Files
iris/tests/draw_cost.rs
T
iris-aiandiris b234497d21 Draw the glyph atlas as an array texture and images with their own bind groups + primitive rendering overhaul
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>
2026-09-13 18:56:59 -04:00

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
);
}
}