diff --git a/core/src/primitive/texture.rs b/core/src/primitive/texture.rs index b85930b..4671c29 100644 --- a/core/src/primitive/texture.rs +++ b/core/src/primitive/texture.rs @@ -95,6 +95,12 @@ impl Textures { 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) { for idx in self.recv.try_iter() { self.images[idx as usize] = None; diff --git a/core/src/render/mod.rs b/core/src/render/mod.rs index 119c25f..414e9fd 100644 --- a/core/src/render/mod.rs +++ b/core/src/render/mod.rs @@ -1,11 +1,6 @@ use crate::{ UiData, UiRenderState, - render::{ - data::PrimitiveInstance, - page::GpuPages, - texture::{GpuTextures, default_sampler}, - util::ArrBuf, - }, + render::{data::PrimitiveInstance, util::ArrBuf}, util::{HashMap, Vec2}, }; use data::WindowUniform; @@ -30,9 +25,6 @@ const PRELUDE: &str = include_str!("./shader/prelude.wgsl"); pub struct UiRenderNode { shared_layout: BindGroupLayout, shared_group: BindGroup, - /// What a primitive that samples binds at group 2, one per `Sampled`. - atlas_layout: BindGroupLayout, - image_layout: BindGroupLayout, format: TextureFormat, /// One per registered primitive, in id order. @@ -41,9 +33,6 @@ pub struct UiRenderNode { layers: HashMap, active: Vec, window_buffer: Buffer, - sampler: Sampler, - pages: GpuPages, - textures: GpuTextures, masks: ArrBuf, } @@ -56,7 +45,7 @@ struct RenderLayer { struct PrimitivePipeline { data_layout: BindGroupLayout, pipeline: RenderPipeline, - samples: Option, + render: Box, } /// One list's vertex buffer and the data its shader reads. @@ -64,8 +53,8 @@ struct ListBuffers { instance: ArrBuf, data: ArrBuf, group: Option, - /// The image each instance binds. Empty unless the primitive is textured. - slots: Vec, + /// What the primitive asked to keep per instance, if anything. + bindings: Vec, } impl UiRenderNode { @@ -82,21 +71,13 @@ impl UiRenderNode { // pipeline layouts differ, and each primitive has its own. pass.set_bind_group(1, group, &[]); pass.set_vertex_buffer(0, list.instance.buffer.slice(..)); - match primitive.samples { - Some(Sampled::Image) => { - for (i, &slot) in list.slots.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); - } - continue; - } - Some(Sampled::Atlas) => pass.set_bind_group(2, self.pages.group(), &[]), - None => {} - } - pass.draw(0..4, 0..list.instance.len() as u32); + primitive.render.draw( + pass, + ListDraw { + instances: list.instance.len() as u32, + bindings: &list.bindings, + }, + ); } } } @@ -109,7 +90,7 @@ impl UiRenderNode { ui_render: &mut UiRenderState, ) { // 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(); for (i, draws) in ui_render.layers.iter_mut() { self.active.push(i); @@ -140,13 +121,14 @@ impl UiRenderNode { }; buffers .get_or_insert_with(|| ListBuffers::new(device)) - .update(device, queue, &primitive.data_layout, list); + .update(device, queue, primitive, list); } draws.updated = false; } } - self.pages - .update(&mut ui.text.atlas, &self.atlas_layout, &self.sampler); + for primitive in &mut self.primitives { + primitive.render.update(ui); + } if ui.masks.changed { ui.masks.changed = false; 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, queue: &Queue) { @@ -171,7 +151,7 @@ impl UiRenderNode { 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 { width: config.width as f32, height: config.height as f32, @@ -183,48 +163,33 @@ impl UiRenderNode { }); 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( device, BufferUsages::STORAGE | BufferUsages::COPY_DST, "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); Self { shared_layout, shared_group, - atlas_layout, - image_layout, format: config.format, primitives: Vec::new(), window_buffer, layers: HashMap::default(), active: Vec::new(), - sampler, - pages, - textures, masks, } } /// Compiles a pipeline for every primitive registered since the last call. /// 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 ®istry.sources()[self.primitives.len()..] { + let render = (source.render)(device, queue); let data_layout = Self::data_layout(device, source.stride); let mut groups = vec![&self.shared_layout, &data_layout]; - groups.extend(match source.samples { - Some(Sampled::Atlas) => Some(&self.atlas_layout), - Some(Sampled::Image) => Some(&self.image_layout), - None => None, - }); + groups.extend(render.layout()); let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { label: Some(source.label), bind_group_layouts: &groups, @@ -234,7 +199,7 @@ impl UiRenderNode { self.primitives.push(PrimitivePipeline { data_layout, pipeline, - samples: source.samples, + render, }); } } @@ -358,40 +323,6 @@ impl UiRenderNode { 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 { @@ -416,7 +347,7 @@ impl ListBuffers { "primitive data", ), group: None, - slots: Vec::new(), + bindings: Vec::new(), } } @@ -424,18 +355,18 @@ impl ListBuffers { &mut self, device: &Device, queue: &Queue, - layout: &BindGroupLayout, + primitive: &PrimitivePipeline, list: &InstanceList, ) { - self.slots.clear(); - self.slots.extend_from_slice(list.slots()); + self.bindings.clear(); + primitive.render.instance_bindings(list, &mut self.bindings); self.instance.update(device, queue, list.instances()); let resized = self.data.update(device, queue, list.data()); if list.instances().is_empty() { self.group = None; } else if resized || self.group.is_none() { self.group = Some(device.create_bind_group(&BindGroupDescriptor { - layout, + layout: &primitive.data_layout, entries: &[BindGroupEntry { binding: 0, resource: self.data.buffer.as_entire_binding(), diff --git a/core/src/render/page.rs b/core/src/render/page.rs index 61d0b3d..421aada 100644 --- a/core/src/render/page.rs +++ b/core/src/render/page.rs @@ -1,12 +1,49 @@ use wgpu::*; -use crate::GlyphAtlas; +use crate::{GlyphAtlas, UiData}; use super::{ 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 /// `GlyphAtlas` packs. /// diff --git a/core/src/render/primitive.rs b/core/src/render/primitive.rs index 32d4b66..a4b483d 100644 --- a/core/src/render/primitive.rs +++ b/core/src/render/primitive.rs @@ -1,11 +1,16 @@ use std::{any::TypeId, marker::PhantomData}; use crate::{ - Color, TextureHandle, UiRegion, WidgetId, - render::data::{MaskIdx, PrimitiveInstance}, + Color, TextureHandle, UiData, UiRegion, WidgetId, + render::{ + data::{MaskIdx, PrimitiveInstance}, + page::GlyphRender, + texture::ImageRender, + }, util::{HashMap, Vec2}, }; use bytemuck::Pod; +use wgpu::{BindGroupLayout, Device, Queue, RenderPass}; /// 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 /// it is given. const WGSL: &'static str; - /// What its shader samples. Nothing, for a primitive that draws from its - /// own data alone. - const SAMPLES: Option> = None; + + /// Made once, the first time the renderer sees this primitive. It owns + /// 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 + where + Self: Sized, + { + let _ = (device, queue); + Box::new(Instanced) + } } -/// What a primitive samples, and how often that has to be bound. -pub enum Samples

{ - /// The glyph atlas, bound once for a whole list. - Atlas, - /// A `Textures` slot read from each instance, bound for that instance - /// alone -- so an instance is a draw of its own. - Image(fn(&P) -> u32), +/// The renderer's half of a primitive: what it samples, what it uploads, and +/// what draws it records. +/// +/// Everything a draw shares -- the pipeline, the window and masks, the list's +/// own data and instance buffer -- is set before this is called. What is left +/// is what only this primitive knows: its group 2, and how many draws its +/// 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) { + 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. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum Sampled { - Atlas, - Image, +/// What a `PrimitiveRender` draws: this list's instances, and whatever +/// `instance_bindings` kept for them. +pub struct ListDraw<'a> { + pub instances: u32, + pub bindings: &'a [u32], } -impl

Samples

{ - const fn erase(&self) -> Sampled { - match self { - Self::Atlas => Sampled::Atlas, - Self::Image(_) => Sampled::Image, - } +/// The default: nothing sampled, every instance in one call. +pub struct Instanced; + +impl PrimitiveRender for Instanced { + 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, /// Size of one instance's entry, stated as the data binding's minimum. pub stride: u64, - pub samples: Option, + pub render: fn(&Device, &Queue) -> Box, } impl PrimitiveRegistry { @@ -92,7 +125,7 @@ impl PrimitiveRegistry { wgsl: P::WGSL, label: std::any::type_name::

(), stride: size_of::

() as u64, - samples: P::SAMPLES.as_ref().map(Samples::erase), + render: P::render, }); kinds.len() as u32 - 1 }); @@ -113,8 +146,6 @@ pub struct InstanceList { free: Vec, /// `stride` bytes of the primitive's own data per instance. data: Vec, - /// The image each instance samples. Empty unless it samples one. - slots: Vec, /// From the type the list was made for, so a write is never checked. stride: usize, } @@ -126,7 +157,6 @@ impl InstanceList { assoc: Vec::new(), free: Vec::new(), data: Vec::new(), - slots: Vec::new(), stride: size_of::

(), } } @@ -139,31 +169,21 @@ impl InstanceList { &self.data } - pub fn slots(&self) -> &[u32] { - &self.slots + pub fn stride(&self) -> usize { + self.stride } - fn push( - &mut self, - id: WidgetId, - inst: PrimitiveInstance, - data: &[u8], - slot: Option, - ) -> usize { + fn push(&mut self, id: WidgetId, inst: PrimitiveInstance, data: &[u8]) -> usize { if let Some(i) = self.free.pop() { self.instances[i] = inst; self.assoc[i] = id; self.data[i * self.stride..][..self.stride].copy_from_slice(data); - if let Some(slot) = slot { - self.slots[i] = slot; - } i } else { let i = self.instances.len(); self.instances.push(inst); self.assoc.push(id); self.data.extend_from_slice(data); - self.slots.extend(slot); i } } @@ -178,14 +198,10 @@ impl InstanceList { let instances = &mut self.instances; let assoc = &mut self.assoc; let data = &mut self.data; - let slots = &mut self.slots; let stride = self.stride; self.free.drain(..).filter_map(move |i| { instances.swap_remove(i); assoc.swap_remove(i); - if !slots.is_empty() { - slots.swap_remove(i); - } let last = instances.len(); data.copy_within(last * stride..(last + 1) * stride, i * stride); data.truncate(last * stride); @@ -244,10 +260,6 @@ impl LayerDraws { id, PrimitiveInstance { region, mask_idx }, bytemuck::bytes_of(&primitive), - match &P::SAMPLES { - Some(Samples::Image(slot)) => Some(slot(&primitive)), - _ => None, - }, ); PrimitiveHandle { layer, @@ -352,7 +364,10 @@ unsafe impl bytemuck::Pod for GlyphPrimitive {} unsafe impl bytemuck::Zeroable for GlyphPrimitive {} impl Primitive for GlyphPrimitive { const WGSL: &'static str = include_str!("shader/glyph.wgsl"); - const SAMPLES: Option> = Some(Samples::Atlas); + + fn render(device: &Device, queue: &Queue) -> Box { + Box::new(GlyphRender::new(device, queue)) + } } /// One drawn image. Its shader reads nothing per instance; the slot names the @@ -365,7 +380,10 @@ pub struct TexturePrimitive { impl Primitive for TexturePrimitive { const WGSL: &'static str = include_str!("shader/texture.wgsl"); - const SAMPLES: Option> = Some(Samples::Image(|texture| texture.slot)); + + fn render(device: &Device, queue: &Queue) -> Box { + Box::new(ImageRender::new(device, queue)) + } } impl From<&TextureHandle> for TexturePrimitive { diff --git a/core/src/render/texture.rs b/core/src/render/texture.rs index 40e5a8f..af1a1ed 100644 --- a/core/src/render/texture.rs +++ b/core/src/render/texture.rs @@ -1,7 +1,60 @@ use image::{DynamicImage, EncodableLayout, GenericImageView, RgbaImage}; 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) { + let slots = list + .data() + .chunks_exact(list.stride()) + .map(|data| bytemuck::pod_read_unaligned::(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 -- /// 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) } - pub fn count(&self) -> usize { - self.slots.iter().flatten().count() - } - fn create( &self, 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 -/// binds, whichever of the two it is. +/// What a primitive that samples binds: a texture, and the sampler that reads +/// it. pub fn sampled_group( device: &Device, 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 { device.create_sampler(&SamplerDescriptor::default()) } diff --git a/examples/tabs/main.rs b/examples/tabs/main.rs index 1ac7a94..ba314c6 100644 --- a/examples/tabs/main.rs +++ b/examples/tabs/main.rs @@ -215,7 +215,7 @@ impl DefaultAppState for Client { "widgets: {}\nactive: {}\ntextures: {}", rsc.widgets().len(), render.active_widgets(), - self.ui_state.renderer.ui.texture_count(), + rsc.ui().textures.count(), ); if new != *rsc.widgets()[self.info].content { *rsc.widgets_mut()[self.info].content = new; diff --git a/src/default/render.rs b/src/default/render.rs index 32e8722..b5e5484 100644 --- a/src/default/render.rs +++ b/src/default/render.rs @@ -117,7 +117,7 @@ impl UiRenderer { let encoder = Self::create_encoder(&device); - let ui = UiRenderNode::new(&device, &queue, &config); + let ui = UiRenderNode::new(&device, &config); Self { surface, diff --git a/tests/draw_cost.rs b/tests/draw_cost.rs new file mode 100644 index 0000000..6e36c35 --- /dev/null +++ b/tests/draw_cost.rs @@ -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 { + let rect = ui.primitives.kind::(); + let glyph = ui.primitives.kind::(); + let texture = ui.primitives.kind::(); + 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 + ); + } +}