Replaces the bindless `binding_array<texture_2d<f32>>` the renderer bound every texture through. That array needs `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack, so the old shape did not run there at all. The two things being bound want opposite treatment, so they are now split: - **Glyph atlas pages become layers of one `texture_2d_array`.** A glyph primitive carries a `layer` instead of a view/sampler index pair. A layer index is an ordinary sampling operand, so this needs nothing beyond plain Vulkan 1.0 / GLES. Growing the atlas recreates the array with headroom and `copy_texture_to_texture`s the old layers across, no readback. - **A standalone image gets its own texture and its own bind group,** and draws in its own call. It no longer needs a per-instance entry in `PrimitiveData`: the bind group has already picked the texture. `Primitives` keeps images in a list of their own as a result, with `PrimitiveChange::is_image` naming which list a renumbering belongs to -- the two have independent index spaces, so `(layer, inst_idx)` alone would collide between them. Two notes on judgement calls, since this slice was rebuilt on top of `main` rather than transplanted: - The source version renamed `GlyphEntry::is_colored` to `is_color` and added a second `IS_COLOR` flag constant beside the existing `GlyphEntry::IS_COLORED`. Both dropped: #10's naming and its `flags()` are kept, and UVs stay `Vec2` rather than going back to `[f32; 2]`. - `ImageGpu` no longer holds the `Texture` behind its view, which removes an `#[allow(dead_code)]`. A `TextureView` keeps its own reference to the texture, checked by rendering rather than assumed -- see below. ### Verification ``` cargo fmt --all --check cargo clippy --workspace --all-targets --locked -- -D warnings cargo test --workspace --locked ``` All clean; the 4 text-edit tests pass. The only clippy output is the pre-existing future-incompatibility notice about `naga`/`wgpu`/`winit`. Because this is a rendering change, it was also run for real rather than only compiled. The `tabs` example was rendered on this machine's GPU -- Venus onto an RX 7900 XT, confirmed from the loaded ICD (`libvulkan_virtio.so` on `/dev/dri/renderD128`) rather than assumed, since a failed Vulkan init here silently falls back to llvmpipe and would make the screenshots meaningless. Screenshots before and after the change are **byte-identical** (same md5) in two scenes: the default tab, which exercises text (the atlas path) and rects, and the image tab with a standalone image pushed at startup, which exercises the per-image bind group. The image-tab scene needed a temporary local edit to the example to push the image without a click; that edit is not part of this branch. The same comparison, re-run after dropping the `Texture` field, is still byte-identical -- which is the check that the view alone keeps it alive. --------- Co-authored-by: iris <2+iris@noreply.localhost> Reviewed-on: iris/iris#11 Reviewed-by: iris <2+iris@noreply.localhost> Co-authored-by: AIris <4+iris-ai@noreply.localhost>
206 lines
7.4 KiB
Rust
206 lines
7.4 KiB
Rust
//! What one frame of `UiRenderNode::draw` costs on the CPU, against the number
|
|
//! of layers it walks. Recording only: the pass is built and dropped without
|
|
//! being submitted, so this is the loop's cost and not the GPU's.
|
|
//!
|
|
//! cargo test --release --test draw_cost -- --ignored --nocapture
|
|
//!
|
|
//! **Read the instruction count, not the clock.** Wall time here swings by 2x
|
|
//! between runs of one binary on this machine -- more under `cargo test` than
|
|
//! run directly -- while instructions retired are stable to 0.1%:
|
|
//!
|
|
//! perf stat -e instructions:u target/release/.../draw_cost-* --ignored
|
|
//!
|
|
//! Measured that way on 2026-09-13, drawing each primitive through its own
|
|
//! `PrimitiveRender` rather than a match in the renderer costs **6
|
|
//! instructions per list drawn**, which is 0.1% of a frame at both 256 and
|
|
//! 1024 layers. Recording one list into the pass costs wgpu ~5,400.
|
|
//!
|
|
//! The instance is leaked on purpose. Dropping the last one makes the Vulkan
|
|
//! loader unload Mesa's ICD, which faults when a thread that touched Vulkan
|
|
//! exits -- and libtest runs every test on a spawned thread.
|
|
|
|
use std::time::Instant;
|
|
|
|
use iris::prelude::*;
|
|
use iris_core::{
|
|
GlyphPrimitive, MaskIdx, PrimitiveInst, RectPrimitive, TextureHandle, TexturePrimitive, UiData,
|
|
UiRegion, UiRenderNode, UiRenderState,
|
|
};
|
|
use wgpu::{Color as GpuColor, *};
|
|
|
|
const SIZE: u32 = 1024;
|
|
const FRAMES: u32 = 200;
|
|
/// Reported as the best of this many batches. The mean moves by 15% between
|
|
/// runs on this machine, which is more than the thing being measured.
|
|
const BATCHES: u32 = 8;
|
|
|
|
fn gpu() -> Option<(Device, Queue)> {
|
|
// Probed rather than assumed: this machine's Vulkan device comes and goes,
|
|
// and GL is what is left when it is gone.
|
|
let all = Instance::new(&InstanceDescriptor::default());
|
|
let instance = match pollster::block_on(all.request_adapter(&RequestAdapterOptions::default()))
|
|
{
|
|
Ok(_) => all,
|
|
Err(_) => Instance::new(&InstanceDescriptor {
|
|
backends: Backends::GL,
|
|
..Default::default()
|
|
}),
|
|
};
|
|
// Leaked rather than dropped: see the note at the top of the file.
|
|
let instance: &'static Instance = Box::leak(Box::new(instance));
|
|
let adapter =
|
|
pollster::block_on(instance.request_adapter(&RequestAdapterOptions::default())).ok()?;
|
|
println!("adapter: {:?}", adapter.get_info());
|
|
pollster::block_on(adapter.request_device(&DeviceDescriptor::default())).ok()
|
|
}
|
|
|
|
fn config(format: TextureFormat) -> SurfaceConfiguration {
|
|
SurfaceConfiguration {
|
|
usage: TextureUsages::RENDER_ATTACHMENT,
|
|
format,
|
|
width: SIZE,
|
|
height: SIZE,
|
|
present_mode: PresentMode::Fifo,
|
|
desired_maximum_frame_latency: 2,
|
|
alpha_mode: CompositeAlphaMode::Auto,
|
|
view_formats: vec![],
|
|
}
|
|
}
|
|
|
|
/// Every layer draws all three primitives, so the renderer takes a different
|
|
/// path for each list it walks -- which is the case a single-primitive layer
|
|
/// would never exercise. Images are bound per instance, so there are few.
|
|
fn fill(
|
|
ui: &mut UiData,
|
|
render: &mut UiRenderState,
|
|
layers: usize,
|
|
per_layer: usize,
|
|
) -> Vec<TextureHandle> {
|
|
let rect = ui.primitives.kind::<RectPrimitive>();
|
|
let glyph = ui.primitives.kind::<GlyphPrimitive>();
|
|
let texture = ui.primitives.kind::<TexturePrimitive>();
|
|
let id = ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id();
|
|
let handles: Vec<_> = (0..4)
|
|
.map(|_| ui.textures.add(image::RgbaImage::new(4, 4)))
|
|
.collect();
|
|
|
|
let mut layer = 0;
|
|
for _ in 0..layers {
|
|
for _ in 0..per_layer {
|
|
render.layers.write(
|
|
layer,
|
|
PrimitiveInst {
|
|
kind: rect,
|
|
id,
|
|
primitive: RectPrimitive::color(UiColor::WHITE),
|
|
region: UiRegion::FULL,
|
|
mask_idx: MaskIdx::NONE,
|
|
},
|
|
);
|
|
render.layers.write(
|
|
layer,
|
|
PrimitiveInst {
|
|
kind: glyph,
|
|
id,
|
|
primitive: GlyphPrimitive {
|
|
uv_min: vec2(0.0, 0.0),
|
|
uv_max: vec2(1.0, 1.0),
|
|
layer: 0,
|
|
color: UiColor::WHITE,
|
|
flags: 0,
|
|
},
|
|
region: UiRegion::FULL,
|
|
mask_idx: MaskIdx::NONE,
|
|
},
|
|
);
|
|
}
|
|
for h in &handles[..2] {
|
|
render.layers.write(
|
|
layer,
|
|
PrimitiveInst {
|
|
kind: texture,
|
|
id,
|
|
primitive: TexturePrimitive::from(h),
|
|
region: UiRegion::FULL,
|
|
mask_idx: MaskIdx::NONE,
|
|
},
|
|
);
|
|
}
|
|
layer = render.layers.next(layer);
|
|
}
|
|
handles
|
|
}
|
|
|
|
fn frame_cost(device: &Device, queue: &Queue, layers: usize, per_layer: usize) -> f64 {
|
|
let format = TextureFormat::Bgra8Unorm;
|
|
let mut node = UiRenderNode::new(device, &config(format));
|
|
let mut ui = UiData::default();
|
|
let mut render = UiRenderState::new();
|
|
let _handles = fill(&mut ui, &mut render, layers, per_layer);
|
|
node.update(device, queue, &mut ui, &mut render);
|
|
|
|
let target = device.create_texture(&TextureDescriptor {
|
|
label: Some("draw cost"),
|
|
size: Extent3d {
|
|
width: SIZE,
|
|
height: SIZE,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
mip_level_count: 1,
|
|
sample_count: 1,
|
|
dimension: TextureDimension::D2,
|
|
format,
|
|
usage: TextureUsages::RENDER_ATTACHMENT,
|
|
view_formats: &[],
|
|
});
|
|
let view = target.create_view(&TextureViewDescriptor::default());
|
|
|
|
let record = |frames: u32| {
|
|
let start = Instant::now();
|
|
for _ in 0..frames {
|
|
let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor::default());
|
|
{
|
|
let pass = &mut encoder.begin_render_pass(&RenderPassDescriptor {
|
|
color_attachments: &[Some(RenderPassColorAttachment {
|
|
view: &view,
|
|
resolve_target: None,
|
|
ops: Operations {
|
|
load: LoadOp::Clear(GpuColor::BLACK),
|
|
store: StoreOp::Store,
|
|
},
|
|
depth_slice: None,
|
|
})],
|
|
..Default::default()
|
|
});
|
|
node.draw(pass);
|
|
}
|
|
drop(encoder.finish());
|
|
}
|
|
start.elapsed().as_secs_f64() / frames as f64
|
|
};
|
|
record(FRAMES / 4);
|
|
(0..BATCHES)
|
|
.map(|_| record(FRAMES))
|
|
.fold(f64::MAX, f64::min)
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "measurement, not a check"]
|
|
fn draw_cost_by_layer_count() {
|
|
let Some((device, queue)) = gpu() else {
|
|
panic!("no wgpu device; see the this-machine-graphics notes");
|
|
};
|
|
println!(
|
|
"layers, each 8 rects + 8 glyphs + 2 images: us/frame (us per layer), best of {BATCHES}"
|
|
);
|
|
let base = frame_cost(&device, &queue, 1, 8) * 1e6;
|
|
for layers in [8, 64, 256, 1024] {
|
|
let per_frame = frame_cost(&device, &queue, layers, 8) * 1e6;
|
|
// Net of the empty pass, which is the same in any version of this.
|
|
println!(
|
|
"{layers:>5}: {per_frame:8.1} us ({:.3} us)",
|
|
(per_frame - base).max(0.0) / layers as f64
|
|
);
|
|
}
|
|
}
|