Let each primitive record its own draws

`draw` had three branches, one per shader, which is the dynamic dispatch this
was asking for. A primitive now brings a `PrimitiveRender`: it states the
layout its shader reads, uploads whatever it owns, and records its own draws.
`GlyphRender` owns the atlas and binds it once for a list; `ImageRender` owns
the images and binds one per instance; the default owns nothing and draws them
all in one call. The renderer sets the pipeline, the shared group, the list's
data and the vertex buffer, and knows nothing else about what it is drawing.

Measured before committing to it, since dispatch per list is the cost. Wall
time on this machine swings 2x between runs of one binary, so the comparison
is instructions retired, which is stable to 0.1%: at 256 layers drawing 8
rects, 8 glyphs and 2 images each, 7.4074e9 against 7.4145e9, and at 1024
layers 27.467e9 against 27.498e9. Both are 0.1%, which is 6 instructions per
list drawn -- one indirect call. Recording a list into the pass costs wgpu
about 5,400.

`tests/draw_cost.rs` is that measurement, kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-13 18:30:20 -04:00
1 parent 79dcc156c9
commit b9c4856e3f
8 files changed
+433 -157

No files matched your search

+6
View File
@@ -95,6 +95,12 @@ impl Textures {
self.updates.push(Update::Patch(handle.slot, rect)); self.updates.push(Update::Patch(handle.slot, rect));
} }
/// How many textures are live, which is what a ui can ask; the renderer's
/// copies follow from the updates it drains.
pub fn count(&self) -> usize {
self.images.iter().flatten().count()
}
pub fn free(&mut self) { pub fn free(&mut self) {
for idx in self.recv.try_iter() { for idx in self.recv.try_iter() {
self.images[idx as usize] = None; self.images[idx as usize] = None;
+26 -95
View File
@@ -1,11 +1,6 @@
use crate::{ use crate::{
UiData, UiRenderState, UiData, UiRenderState,
render::{ render::{data::PrimitiveInstance, util::ArrBuf},
data::PrimitiveInstance,
page::GpuPages,
texture::{GpuTextures, default_sampler},
util::ArrBuf,
},
util::{HashMap, Vec2}, util::{HashMap, Vec2},
}; };
use data::WindowUniform; use data::WindowUniform;
@@ -30,9 +25,6 @@ const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
pub struct UiRenderNode { pub struct UiRenderNode {
shared_layout: BindGroupLayout, shared_layout: BindGroupLayout,
shared_group: BindGroup, shared_group: BindGroup,
/// What a primitive that samples binds at group 2, one per `Sampled`.
atlas_layout: BindGroupLayout,
image_layout: BindGroupLayout,
format: TextureFormat, format: TextureFormat,
/// One per registered primitive, in id order. /// One per registered primitive, in id order.
@@ -41,9 +33,6 @@ pub struct UiRenderNode {
layers: HashMap<usize, RenderLayer>, layers: HashMap<usize, RenderLayer>,
active: Vec<usize>, active: Vec<usize>,
window_buffer: Buffer, window_buffer: Buffer,
sampler: Sampler,
pages: GpuPages,
textures: GpuTextures,
masks: ArrBuf<Mask>, masks: ArrBuf<Mask>,
} }
@@ -56,7 +45,7 @@ struct RenderLayer {
struct PrimitivePipeline { struct PrimitivePipeline {
data_layout: BindGroupLayout, data_layout: BindGroupLayout,
pipeline: RenderPipeline, pipeline: RenderPipeline,
samples: Option<Sampled>, render: Box<dyn PrimitiveRender>,
} }
/// One list's vertex buffer and the data its shader reads. /// One list's vertex buffer and the data its shader reads.
@@ -64,8 +53,8 @@ struct ListBuffers {
instance: ArrBuf<PrimitiveInstance>, instance: ArrBuf<PrimitiveInstance>,
data: ArrBuf<u8>, data: ArrBuf<u8>,
group: Option<BindGroup>, group: Option<BindGroup>,
/// The image each instance binds. Empty unless the primitive is textured. /// What the primitive asked to keep per instance, if anything.
slots: Vec<u32>, bindings: Vec<u32>,
} }
impl UiRenderNode { impl UiRenderNode {
@@ -82,21 +71,13 @@ impl UiRenderNode {
// pipeline layouts differ, and each primitive has its own. // pipeline layouts differ, and each primitive has its own.
pass.set_bind_group(1, group, &[]); pass.set_bind_group(1, group, &[]);
pass.set_vertex_buffer(0, list.instance.buffer.slice(..)); pass.set_vertex_buffer(0, list.instance.buffer.slice(..));
match primitive.samples { primitive.render.draw(
Some(Sampled::Image) => { pass,
for (i, &slot) in list.slots.iter().enumerate() { ListDraw {
let Some(image) = self.textures.group(slot) else { instances: list.instance.len() as u32,
continue; bindings: &list.bindings,
}; },
pass.set_bind_group(2, image, &[]); );
pass.draw(0..4, i as u32..i as u32 + 1);
}
continue;
}
Some(Sampled::Atlas) => pass.set_bind_group(2, self.pages.group(), &[]),
None => {}
}
pass.draw(0..4, 0..list.instance.len() as u32);
} }
} }
} }
@@ -109,7 +90,7 @@ impl UiRenderNode {
ui_render: &mut UiRenderState, ui_render: &mut UiRenderState,
) { ) {
// Before the layers: each list is given its pipeline's data layout. // Before the layers: each list is given its pipeline's data layout.
self.build_pipelines(device, &ui.primitives); self.build_pipelines(device, queue, &ui.primitives);
self.active.clear(); self.active.clear();
for (i, draws) in ui_render.layers.iter_mut() { for (i, draws) in ui_render.layers.iter_mut() {
self.active.push(i); self.active.push(i);
@@ -140,13 +121,14 @@ impl UiRenderNode {
}; };
buffers buffers
.get_or_insert_with(|| ListBuffers::new(device)) .get_or_insert_with(|| ListBuffers::new(device))
.update(device, queue, &primitive.data_layout, list); .update(device, queue, primitive, list);
} }
draws.updated = false; draws.updated = false;
} }
} }
self.pages for primitive in &mut self.primitives {
.update(&mut ui.text.atlas, &self.atlas_layout, &self.sampler); primitive.render.update(ui);
}
if ui.masks.changed { if ui.masks.changed {
ui.masks.changed = false; ui.masks.changed = false;
if self.masks.update(device, queue, &ui.masks[..]) { if self.masks.update(device, queue, &ui.masks[..]) {
@@ -158,8 +140,6 @@ impl UiRenderNode {
); );
} }
} }
self.textures
.update(&mut ui.textures, &self.image_layout, &self.sampler);
} }
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) { pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
@@ -171,7 +151,7 @@ impl UiRenderNode {
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice)); queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
} }
pub fn new(device: &Device, queue: &Queue, config: &SurfaceConfiguration) -> Self { pub fn new(device: &Device, config: &SurfaceConfiguration) -> Self {
let window_uniform = WindowUniform { let window_uniform = WindowUniform {
width: config.width as f32, width: config.width as f32,
height: config.height as f32, height: config.height as f32,
@@ -183,48 +163,33 @@ impl UiRenderNode {
}); });
let shared_layout = Self::shared_layout(device); let shared_layout = Self::shared_layout(device);
let atlas_layout = Self::sampled_layout(device, TextureViewDimension::D2Array, "ui atlas");
let image_layout = Self::sampled_layout(device, TextureViewDimension::D2, "ui image");
let masks = ArrBuf::new( let masks = ArrBuf::new(
device, device,
BufferUsages::STORAGE | BufferUsages::COPY_DST, BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks", "ui masks",
); );
let sampler = default_sampler(device);
let pages = GpuPages::new(device, queue, &atlas_layout, &sampler);
let textures = GpuTextures::new(device, queue);
let shared_group = Self::shared_group(device, &shared_layout, &window_buffer, &masks); let shared_group = Self::shared_group(device, &shared_layout, &window_buffer, &masks);
Self { Self {
shared_layout, shared_layout,
shared_group, shared_group,
atlas_layout,
image_layout,
format: config.format, format: config.format,
primitives: Vec::new(), primitives: Vec::new(),
window_buffer, window_buffer,
layers: HashMap::default(), layers: HashMap::default(),
active: Vec::new(), active: Vec::new(),
sampler,
pages,
textures,
masks, masks,
} }
} }
/// Compiles a pipeline for every primitive registered since the last call. /// Compiles a pipeline for every primitive registered since the last call.
/// Sources only ever arrive at the end, so an id keeps its pipeline. /// Sources only ever arrive at the end, so an id keeps its pipeline.
fn build_pipelines(&mut self, device: &Device, registry: &PrimitiveRegistry) { fn build_pipelines(&mut self, device: &Device, queue: &Queue, registry: &PrimitiveRegistry) {
for source in &registry.sources()[self.primitives.len()..] { for source in &registry.sources()[self.primitives.len()..] {
let render = (source.render)(device, queue);
let data_layout = Self::data_layout(device, source.stride); let data_layout = Self::data_layout(device, source.stride);
let mut groups = vec![&self.shared_layout, &data_layout]; let mut groups = vec![&self.shared_layout, &data_layout];
groups.extend(match source.samples { groups.extend(render.layout());
Some(Sampled::Atlas) => Some(&self.atlas_layout),
Some(Sampled::Image) => Some(&self.image_layout),
None => None,
});
let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some(source.label), label: Some(source.label),
bind_group_layouts: &groups, bind_group_layouts: &groups,
@@ -234,7 +199,7 @@ impl UiRenderNode {
self.primitives.push(PrimitivePipeline { self.primitives.push(PrimitivePipeline {
data_layout, data_layout,
pipeline, pipeline,
samples: source.samples, render,
}); });
} }
} }
@@ -358,40 +323,6 @@ impl UiRenderNode {
label: Some("ui primitive data"), label: Some("ui primitive data"),
}) })
} }
/// What a primitive samples, and the sampler it reads it with. One per
/// `Sampled`, since the atlas is an array and an image is not.
fn sampled_layout(
device: &Device,
dimension: TextureViewDimension,
label: &'static str,
) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: dimension,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: None,
},
],
label: Some(label),
})
}
pub fn texture_count(&self) -> usize {
self.textures.count()
}
} }
impl RenderLayer { impl RenderLayer {
@@ -416,7 +347,7 @@ impl ListBuffers {
"primitive data", "primitive data",
), ),
group: None, group: None,
slots: Vec::new(), bindings: Vec::new(),
} }
} }
@@ -424,18 +355,18 @@ impl ListBuffers {
&mut self, &mut self,
device: &Device, device: &Device,
queue: &Queue, queue: &Queue,
layout: &BindGroupLayout, primitive: &PrimitivePipeline,
list: &InstanceList, list: &InstanceList,
) { ) {
self.slots.clear(); self.bindings.clear();
self.slots.extend_from_slice(list.slots()); primitive.render.instance_bindings(list, &mut self.bindings);
self.instance.update(device, queue, list.instances()); self.instance.update(device, queue, list.instances());
let resized = self.data.update(device, queue, list.data()); let resized = self.data.update(device, queue, list.data());
if list.instances().is_empty() { if list.instances().is_empty() {
self.group = None; self.group = None;
} else if resized || self.group.is_none() { } else if resized || self.group.is_none() {
self.group = Some(device.create_bind_group(&BindGroupDescriptor { self.group = Some(device.create_bind_group(&BindGroupDescriptor {
layout, layout: &primitive.data_layout,
entries: &[BindGroupEntry { entries: &[BindGroupEntry {
binding: 0, binding: 0,
resource: self.data.buffer.as_entire_binding(), resource: self.data.buffer.as_entire_binding(),
+39 -2
View File
@@ -1,12 +1,49 @@
use wgpu::*; use wgpu::*;
use crate::GlyphAtlas; use crate::{GlyphAtlas, UiData};
use super::{ use super::{
atlas::PAGE, atlas::PAGE,
texture::{sampled_group, write_region}, primitive::{ListDraw, PrimitiveRender},
texture::{default_sampler, sampled_group, sampled_layout, write_region},
}; };
/// Draws glyphs from the atlas, which it owns: one array texture bound once
/// for a whole list, since every glyph in it reads the same pages.
pub struct GlyphRender {
pages: GpuPages,
layout: BindGroupLayout,
sampler: Sampler,
}
impl GlyphRender {
pub fn new(device: &Device, queue: &Queue) -> Self {
let layout = sampled_layout(device, TextureViewDimension::D2Array, "ui atlas");
let sampler = default_sampler(device);
Self {
pages: GpuPages::new(device, queue, &layout, &sampler),
layout,
sampler,
}
}
}
impl PrimitiveRender for GlyphRender {
fn layout(&self) -> Option<&BindGroupLayout> {
Some(&self.layout)
}
fn update(&mut self, ui: &mut UiData) {
self.pages
.update(&mut ui.text.atlas, &self.layout, &self.sampler);
}
fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>) {
pass.set_bind_group(2, self.pages.group(), &[]);
pass.draw(0..4, 0..list.instances);
}
}
/// The glyph atlas on the GPU: one array texture whose layers are the pages /// The glyph atlas on the GPU: one array texture whose layers are the pages
/// `GlyphAtlas` packs. /// `GlyphAtlas` packs.
/// ///
+69 -51
View File
@@ -1,11 +1,16 @@
use std::{any::TypeId, marker::PhantomData}; use std::{any::TypeId, marker::PhantomData};
use crate::{ use crate::{
Color, TextureHandle, UiRegion, WidgetId, Color, TextureHandle, UiData, UiRegion, WidgetId,
render::data::{MaskIdx, PrimitiveInstance}, render::{
data::{MaskIdx, PrimitiveInstance},
page::GlyphRender,
texture::ImageRender,
},
util::{HashMap, Vec2}, util::{HashMap, Vec2},
}; };
use bytemuck::Pod; use bytemuck::Pod;
use wgpu::{BindGroupLayout, Device, Queue, RenderPass};
/// One instance of a primitive, laid out as the struct its shader reads. /// One instance of a primitive, laid out as the struct its shader reads.
/// ///
@@ -15,33 +20,61 @@ pub trait Primitive: Pod + 'static {
/// Compiled after `prelude.wgsl`, which states what it declares and what /// Compiled after `prelude.wgsl`, which states what it declares and what
/// it is given. /// it is given.
const WGSL: &'static str; const WGSL: &'static str;
/// What its shader samples. Nothing, for a primitive that draws from its
/// own data alone. /// Made once, the first time the renderer sees this primitive. It owns
const SAMPLES: Option<Samples<Self>> = None; /// whatever the shader samples and records the primitive's own draws; the
/// default owns nothing and draws every instance in one call.
fn render(device: &Device, queue: &Queue) -> Box<dyn PrimitiveRender>
where
Self: Sized,
{
let _ = (device, queue);
Box::new(Instanced)
}
} }
/// What a primitive samples, and how often that has to be bound. /// The renderer's half of a primitive: what it samples, what it uploads, and
pub enum Samples<P> { /// what draws it records.
/// The glyph atlas, bound once for a whole list. ///
Atlas, /// Everything a draw shares -- the pipeline, the window and masks, the list's
/// A `Textures` slot read from each instance, bound for that instance /// own data and instance buffer -- is set before this is called. What is left
/// alone -- so an instance is a draw of its own. /// is what only this primitive knows: its group 2, and how many draws its
Image(fn(&P) -> u32), /// instances are.
pub trait PrimitiveRender {
/// The layout its shader reads at group 2. `None` for a primitive whose
/// shader samples nothing, whose pipeline then has no group 2 at all.
fn layout(&self) -> Option<&BindGroupLayout> {
None
}
/// Uploads whatever this primitive owns, once a frame, before any draw.
fn update(&mut self, ui: &mut UiData) {
let _ = ui;
}
/// Keeps what the primitive needs per instance at draw time, read from
/// the list's own data. A primitive that binds nothing per instance --
/// most of them -- leaves this empty and draws in one call.
fn instance_bindings(&self, list: &InstanceList, out: &mut Vec<u32>) {
let _ = (list, out);
}
fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>);
} }
/// `Samples` with the type erased, which is all the renderer needs. /// What a `PrimitiveRender` draws: this list's instances, and whatever
#[derive(Clone, Copy, PartialEq, Eq)] /// `instance_bindings` kept for them.
pub enum Sampled { pub struct ListDraw<'a> {
Atlas, pub instances: u32,
Image, pub bindings: &'a [u32],
} }
impl<P> Samples<P> { /// The default: nothing sampled, every instance in one call.
const fn erase(&self) -> Sampled { pub struct Instanced;
match self {
Self::Atlas => Sampled::Atlas, impl PrimitiveRender for Instanced {
Self::Image(_) => Sampled::Image, fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>) {
} pass.draw(0..4, 0..list.instances);
} }
} }
@@ -80,7 +113,7 @@ pub struct PrimitiveSource {
pub label: &'static str, pub label: &'static str,
/// Size of one instance's entry, stated as the data binding's minimum. /// Size of one instance's entry, stated as the data binding's minimum.
pub stride: u64, pub stride: u64,
pub samples: Option<Sampled>, pub render: fn(&Device, &Queue) -> Box<dyn PrimitiveRender>,
} }
impl PrimitiveRegistry { impl PrimitiveRegistry {
@@ -92,7 +125,7 @@ impl PrimitiveRegistry {
wgsl: P::WGSL, wgsl: P::WGSL,
label: std::any::type_name::<P>(), label: std::any::type_name::<P>(),
stride: size_of::<P>() as u64, stride: size_of::<P>() as u64,
samples: P::SAMPLES.as_ref().map(Samples::erase), render: P::render,
}); });
kinds.len() as u32 - 1 kinds.len() as u32 - 1
}); });
@@ -113,8 +146,6 @@ pub struct InstanceList {
free: Vec<usize>, free: Vec<usize>,
/// `stride` bytes of the primitive's own data per instance. /// `stride` bytes of the primitive's own data per instance.
data: Vec<u8>, data: Vec<u8>,
/// The image each instance samples. Empty unless it samples one.
slots: Vec<u32>,
/// From the type the list was made for, so a write is never checked. /// From the type the list was made for, so a write is never checked.
stride: usize, stride: usize,
} }
@@ -126,7 +157,6 @@ impl InstanceList {
assoc: Vec::new(), assoc: Vec::new(),
free: Vec::new(), free: Vec::new(),
data: Vec::new(), data: Vec::new(),
slots: Vec::new(),
stride: size_of::<P>(), stride: size_of::<P>(),
} }
} }
@@ -139,31 +169,21 @@ impl InstanceList {
&self.data &self.data
} }
pub fn slots(&self) -> &[u32] { pub fn stride(&self) -> usize {
&self.slots self.stride
} }
fn push( fn push(&mut self, id: WidgetId, inst: PrimitiveInstance, data: &[u8]) -> usize {
&mut self,
id: WidgetId,
inst: PrimitiveInstance,
data: &[u8],
slot: Option<u32>,
) -> usize {
if let Some(i) = self.free.pop() { if let Some(i) = self.free.pop() {
self.instances[i] = inst; self.instances[i] = inst;
self.assoc[i] = id; self.assoc[i] = id;
self.data[i * self.stride..][..self.stride].copy_from_slice(data); self.data[i * self.stride..][..self.stride].copy_from_slice(data);
if let Some(slot) = slot {
self.slots[i] = slot;
}
i i
} else { } else {
let i = self.instances.len(); let i = self.instances.len();
self.instances.push(inst); self.instances.push(inst);
self.assoc.push(id); self.assoc.push(id);
self.data.extend_from_slice(data); self.data.extend_from_slice(data);
self.slots.extend(slot);
i i
} }
} }
@@ -178,14 +198,10 @@ impl InstanceList {
let instances = &mut self.instances; let instances = &mut self.instances;
let assoc = &mut self.assoc; let assoc = &mut self.assoc;
let data = &mut self.data; let data = &mut self.data;
let slots = &mut self.slots;
let stride = self.stride; let stride = self.stride;
self.free.drain(..).filter_map(move |i| { self.free.drain(..).filter_map(move |i| {
instances.swap_remove(i); instances.swap_remove(i);
assoc.swap_remove(i); assoc.swap_remove(i);
if !slots.is_empty() {
slots.swap_remove(i);
}
let last = instances.len(); let last = instances.len();
data.copy_within(last * stride..(last + 1) * stride, i * stride); data.copy_within(last * stride..(last + 1) * stride, i * stride);
data.truncate(last * stride); data.truncate(last * stride);
@@ -244,10 +260,6 @@ impl LayerDraws {
id, id,
PrimitiveInstance { region, mask_idx }, PrimitiveInstance { region, mask_idx },
bytemuck::bytes_of(&primitive), bytemuck::bytes_of(&primitive),
match &P::SAMPLES {
Some(Samples::Image(slot)) => Some(slot(&primitive)),
_ => None,
},
); );
PrimitiveHandle { PrimitiveHandle {
layer, layer,
@@ -352,7 +364,10 @@ unsafe impl bytemuck::Pod for GlyphPrimitive {}
unsafe impl bytemuck::Zeroable for GlyphPrimitive {} unsafe impl bytemuck::Zeroable for GlyphPrimitive {}
impl Primitive for GlyphPrimitive { impl Primitive for GlyphPrimitive {
const WGSL: &'static str = include_str!("shader/glyph.wgsl"); const WGSL: &'static str = include_str!("shader/glyph.wgsl");
const SAMPLES: Option<Samples<Self>> = Some(Samples::Atlas);
fn render(device: &Device, queue: &Queue) -> Box<dyn PrimitiveRender> {
Box::new(GlyphRender::new(device, queue))
}
} }
/// One drawn image. Its shader reads nothing per instance; the slot names the /// One drawn image. Its shader reads nothing per instance; the slot names the
@@ -365,7 +380,10 @@ pub struct TexturePrimitive {
impl Primitive for TexturePrimitive { impl Primitive for TexturePrimitive {
const WGSL: &'static str = include_str!("shader/texture.wgsl"); const WGSL: &'static str = include_str!("shader/texture.wgsl");
const SAMPLES: Option<Samples<Self>> = Some(Samples::Image(|texture| texture.slot));
fn render(device: &Device, queue: &Queue) -> Box<dyn PrimitiveRender> {
Box::new(ImageRender::new(device, queue))
}
} }
impl From<&TextureHandle> for TexturePrimitive { impl From<&TextureHandle> for TexturePrimitive {
+86 -7
View File
@@ -1,7 +1,60 @@
use image::{DynamicImage, EncodableLayout, GenericImageView, RgbaImage}; use image::{DynamicImage, EncodableLayout, GenericImageView, RgbaImage};
use wgpu::{util::DeviceExt, *}; use wgpu::{util::DeviceExt, *};
use crate::{PatchRect, TextureUpdate, Textures}; use crate::{
PatchRect, TextureUpdate, Textures, UiData,
render::{
TexturePrimitive,
primitive::{ListDraw, PrimitiveRender},
},
};
/// Draws standalone images, which it owns. Each is its own texture, so each
/// instance binds its own and is a draw of its own.
pub struct ImageRender {
textures: GpuTextures,
layout: BindGroupLayout,
sampler: Sampler,
}
impl ImageRender {
pub fn new(device: &Device, queue: &Queue) -> Self {
Self {
textures: GpuTextures::new(device, queue),
layout: sampled_layout(device, TextureViewDimension::D2, "ui image"),
sampler: default_sampler(device),
}
}
}
impl PrimitiveRender for ImageRender {
fn layout(&self) -> Option<&BindGroupLayout> {
Some(&self.layout)
}
fn update(&mut self, ui: &mut UiData) {
self.textures
.update(&mut ui.textures, &self.layout, &self.sampler);
}
fn instance_bindings(&self, list: &super::InstanceList, out: &mut Vec<u32>) {
let slots = list
.data()
.chunks_exact(list.stride())
.map(|data| bytemuck::pod_read_unaligned::<TexturePrimitive>(data).slot);
out.extend(slots);
}
fn draw<'a>(&'a self, pass: &mut RenderPass<'a>, list: ListDraw<'a>) {
for (i, &slot) in list.bindings.iter().enumerate() {
let Some(image) = self.textures.group(slot) else {
continue;
};
pass.set_bind_group(2, image, &[]);
pass.draw(0..4, i as u32..i as u32 + 1);
}
}
}
/// The standalone images a ui draws, each its own texture and bind group -- /// The standalone images a ui draws, each its own texture and bind group --
/// unlike the glyph atlas in `super::page`, which is one array they share. /// unlike the glyph atlas in `super::page`, which is one array they share.
@@ -49,10 +102,6 @@ impl GpuTextures {
self.slots.get(slot as usize)?.as_ref().map(|i| &i.group) self.slots.get(slot as usize)?.as_ref().map(|i| &i.group)
} }
pub fn count(&self) -> usize {
self.slots.iter().flatten().count()
}
fn create( fn create(
&self, &self,
image: &DynamicImage, image: &DynamicImage,
@@ -134,8 +183,8 @@ pub fn write_region(queue: &Queue, dst: TexelCopyTextureInfo, src: &RgbaImage, r
); );
} }
/// A texture and the sampler that reads it: what a primitive that samples /// What a primitive that samples binds: a texture, and the sampler that reads
/// binds, whichever of the two it is. /// it.
pub fn sampled_group( pub fn sampled_group(
device: &Device, device: &Device,
layout: &BindGroupLayout, layout: &BindGroupLayout,
@@ -159,6 +208,36 @@ pub fn sampled_group(
}) })
} }
/// The layout for one of those. The dimension differs -- the atlas is an
/// array of pages and an image is not -- and nothing else does.
pub fn sampled_layout(
device: &Device,
dimension: TextureViewDimension,
label: &'static str,
) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: dimension,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: None,
},
],
label: Some(label),
})
}
pub fn default_sampler(device: &Device) -> Sampler { pub fn default_sampler(device: &Device) -> Sampler {
device.create_sampler(&SamplerDescriptor::default()) device.create_sampler(&SamplerDescriptor::default())
} }
+1 -1
View File
@@ -215,7 +215,7 @@ impl DefaultAppState for Client {
"widgets: {}\nactive: {}\ntextures: {}", "widgets: {}\nactive: {}\ntextures: {}",
rsc.widgets().len(), rsc.widgets().len(),
render.active_widgets(), render.active_widgets(),
self.ui_state.renderer.ui.texture_count(), rsc.ui().textures.count(),
); );
if new != *rsc.widgets()[self.info].content { if new != *rsc.widgets()[self.info].content {
*rsc.widgets_mut()[self.info].content = new; *rsc.widgets_mut()[self.info].content = new;
+1 -1
View File
@@ -117,7 +117,7 @@ impl UiRenderer {
let encoder = Self::create_encoder(&device); let encoder = Self::create_encoder(&device);
let ui = UiRenderNode::new(&device, &queue, &config); let ui = UiRenderNode::new(&device, &config);
Self { Self {
surface, surface,
+205
View File
@@ -0,0 +1,205 @@
//! 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
);
}
}