Fix what reviewing the primitive rework turned up

Three defects, two of them invisible to every case I had run.

An image alone in a layer failed validation. The texture pipeline never
bound group 1, and every earlier case happened to have a rect in the
same layer, which left one bound from the primitive draw -- so the bug
was hidden by the tests passing.

A `min_binding_size: None` binding takes its minimum from the first
pipeline built against that bind group layout, so one shared group 1
layout held every primitive to the largest. Rect and glyph coexisted
only because glyph is the bigger of the two; the texture slots, at four
bytes, did not. Each primitive now gets its own layout with its entry
size stated, which is also why `PrimitiveRegistry` records the stride.
Because the pipeline layouts now differ per primitive, a pipeline change
drops the bound groups, so group 2 moves after `set_pipeline`.

An empty list still built a bind group over a buffer too small for one
entry, which the stated minimum would now reject. It gets no bind group,
and nothing draws it.

Also from the read-through: `UiRenderNode` kept a `Device` beside the
one `update` is handed, `PrimitiveRegistry::default` registered inside
an `assert_eq!`, and `mask_idx` was an unqualified integer varying where
`idx` beside it was `flat`.
This commit is contained in:
iris committed 2026-09-13 14:27:09 -04:00
1 parent 4d9839f380
commit a08f61a80c
3 files changed
+95 -52

No files matched your search

+79 -36
View File
@@ -29,17 +29,15 @@ const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
const TEXTURE_SHADER: &str = include_str!("./shader/texture.wgsl"); const TEXTURE_SHADER: &str = include_str!("./shader/texture.wgsl");
pub struct UiRenderNode { pub struct UiRenderNode {
device: Device,
shared_layout: BindGroupLayout, shared_layout: BindGroupLayout,
shared_group: BindGroup, shared_group: BindGroup,
data_layout: BindGroupLayout, texture_data_layout: BindGroupLayout,
texture_layout: BindGroupLayout, texture_layout: BindGroupLayout,
pipeline_layout: PipelineLayout,
format: TextureFormat, format: TextureFormat,
/// One per registered primitive, in id order. The texture pipeline is /// One per registered primitive, in id order. The texture pipeline is
/// apart because a texture binds group 2 per instance, not per draw. /// apart because a texture binds group 2 per instance, not per draw.
pipelines: Vec<RenderPipeline>, primitives: Vec<PrimitivePipeline>,
texture_pipeline: RenderPipeline, texture_pipeline: RenderPipeline,
layers: HashMap<usize, RenderLayer>, layers: HashMap<usize, RenderLayer>,
@@ -59,6 +57,13 @@ struct RenderLayer {
texture_slots: Vec<u32>, texture_slots: Vec<u32>,
} }
/// What draws one registered primitive. The group 1 layout is its own rather
/// than shared, so its entry size is the minimum only for it.
struct PrimitivePipeline {
data_layout: BindGroupLayout,
pipeline: RenderPipeline,
}
/// One list's vertex buffer and the data its shader reads at group 1. /// One list's vertex buffer and the data its shader reads at group 1.
struct ListBuffers { struct ListBuffers {
instance: ArrBuf<PrimitiveInstance>, instance: ArrBuf<PrimitiveInstance>,
@@ -74,18 +79,26 @@ impl UiRenderNode {
// Types run in registration order, so a rect is under a glyph is // Types run in registration order, so a rect is under a glyph is
// under a texture. Ordering beyond that is what `Layers` is for -- // under a texture. Ordering beyond that is what `Layers` is for --
// freeing an instance swaps another into its place. // freeing an instance swaps another into its place.
pass.set_bind_group(2, self.pages.group(), &[]);
for (id, list) in layer.primitives.iter().enumerate() { for (id, list) in layer.primitives.iter().enumerate() {
let (Some(group), true) = (&list.group, list.instance.len() > 0) else { let Some(group) = &list.group else {
continue; continue;
}; };
pass.set_pipeline(&self.pipelines[id]); pass.set_pipeline(&self.primitives[id].pipeline);
// Both after the pipeline: each primitive has its own pipeline
// layout, and a change drops the groups from where they differ.
pass.set_bind_group(1, group, &[]); pass.set_bind_group(1, group, &[]);
pass.set_bind_group(2, self.pages.group(), &[]);
pass.set_vertex_buffer(0, list.instance.buffer.slice(..)); pass.set_vertex_buffer(0, list.instance.buffer.slice(..));
pass.draw(0..4, 0..list.instance.len() as u32); pass.draw(0..4, 0..list.instance.len() as u32);
} }
if !layer.texture_slots.is_empty() { if !layer.texture_slots.is_empty() {
// Group 1 too, unread as it is: a layer holding only an image
// never ran the loop above, so nothing is bound there.
let Some(data) = &layer.textures.group else {
continue;
};
pass.set_pipeline(&self.texture_pipeline); pass.set_pipeline(&self.texture_pipeline);
pass.set_bind_group(1, data, &[]);
pass.set_vertex_buffer(0, layer.textures.instance.buffer.slice(..)); pass.set_vertex_buffer(0, layer.textures.instance.buffer.slice(..));
for (i, &slot) in layer.texture_slots.iter().enumerate() { for (i, &slot) in layer.texture_slots.iter().enumerate() {
let Some(group) = self.textures.group(slot) else { let Some(group) = self.textures.group(slot) else {
@@ -126,12 +139,17 @@ impl UiRenderNode {
rlayer rlayer
.primitives .primitives
.resize_with(draws.primitives().len(), || ListBuffers::new(device)); .resize_with(draws.primitives().len(), || ListBuffers::new(device));
for (list, draws) in rlayer.primitives.iter_mut().zip(draws.primitives()) { for ((list, draws), pipeline) in rlayer
list.update(device, queue, &self.data_layout, draws); .primitives
.iter_mut()
.zip(draws.primitives())
.zip(&self.primitives)
{
list.update(device, queue, &pipeline.data_layout, draws);
} }
rlayer rlayer
.textures .textures
.update(device, queue, &self.data_layout, &draws.textures); .update(device, queue, &self.texture_data_layout, &draws.textures);
rlayer.texture_slots.clear(); rlayer.texture_slots.clear();
// Read rather than cast: the payload is a byte vec, so it // Read rather than cast: the payload is a byte vec, so it
// carries no alignment a `u32` slice could borrow. // carries no alignment a `u32` slice could borrow.
@@ -142,7 +160,7 @@ impl UiRenderNode {
draws.updated = false; draws.updated = false;
} }
} }
self.build_pipelines(&ui.primitives); self.build_pipelines(device, &ui.primitives);
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[..]) {
@@ -181,7 +199,6 @@ impl UiRenderNode {
}); });
let shared_layout = Self::shared_layout(device); let shared_layout = Self::shared_layout(device);
let data_layout = Self::data_layout(device);
let texture_layout = Self::texture_layout(device); let texture_layout = Self::texture_layout(device);
let masks = ArrBuf::new( let masks = ArrBuf::new(
@@ -195,30 +212,29 @@ impl UiRenderNode {
let pages = GpuPages::new(device, queue, &texture_layout, &sampler); let pages = GpuPages::new(device, queue, &texture_layout, &sampler);
let textures = GpuTextures::new(device, queue); let textures = GpuTextures::new(device, queue);
// One layout for every pipeline, so a primitive that does not sample // A texture instance's data is the slot it binds, which its shader
// or read a buffer binds what is there instead of needing its own. // never reads -- but group 1 is in the layout, so it is bound anyway.
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { let texture_data_layout = Self::data_layout(device, size_of::<u32>() as u64);
label: Some("ui"),
bind_group_layouts: &[&shared_layout, &data_layout, &texture_layout],
immediate_size: 0,
});
let texture_pipeline = Self::pipeline( let texture_pipeline = Self::pipeline(
device, device,
&pipeline_layout, &Self::pipeline_layout(
device,
&shared_layout,
&texture_data_layout,
&texture_layout,
),
config.format, config.format,
TEXTURE_SHADER, TEXTURE_SHADER,
"texture", "texture",
); );
Self { Self {
device: device.clone(),
shared_layout, shared_layout,
shared_group, shared_group,
data_layout, texture_data_layout,
texture_layout, texture_layout,
pipeline_layout,
format: config.format, format: config.format,
pipelines: Vec::new(), primitives: Vec::new(),
texture_pipeline, texture_pipeline,
window_buffer, window_buffer,
layers: HashMap::default(), layers: HashMap::default(),
@@ -232,18 +248,36 @@ impl UiRenderNode {
/// 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, registry: &PrimitiveRegistry) { fn build_pipelines(&mut self, device: &Device, registry: &PrimitiveRegistry) {
for source in &registry.sources()[self.pipelines.len()..] { for source in &registry.sources()[self.primitives.len()..] {
self.pipelines.push(Self::pipeline( let data_layout = Self::data_layout(device, source.stride);
&self.device, let layout = Self::pipeline_layout(
&self.pipeline_layout, device,
self.format, &self.shared_layout,
source.wgsl, &data_layout,
source.label, &self.texture_layout,
)); );
let pipeline = Self::pipeline(device, &layout, self.format, source.wgsl, source.label);
self.primitives.push(PrimitivePipeline {
data_layout,
pipeline,
});
} }
} }
fn pipeline_layout(
device: &Device,
shared: &BindGroupLayout,
data: &BindGroupLayout,
texture: &BindGroupLayout,
) -> PipelineLayout {
device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("ui"),
bind_group_layouts: &[shared, data, texture],
immediate_size: 0,
})
}
fn pipeline( fn pipeline(
device: &Device, device: &Device,
layout: &PipelineLayout, layout: &PipelineLayout,
@@ -346,7 +380,11 @@ impl UiRenderNode {
} }
/// Group 1: one list's per-instance data, whatever its shader reads it as. /// Group 1: one list's per-instance data, whatever its shader reads it as.
fn data_layout(device: &Device) -> BindGroupLayout { ///
/// One per primitive with `stride` stated, because a `None` minimum is
/// filled in from the first pipeline built against the layout -- sharing
/// one would hold every primitive to the largest.
fn data_layout(device: &Device, stride: u64) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor { device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[BindGroupLayoutEntry { entries: &[BindGroupLayoutEntry {
binding: 0, binding: 0,
@@ -354,7 +392,7 @@ impl UiRenderNode {
ty: BindingType::Buffer { ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true }, ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false, has_dynamic_offset: false,
min_binding_size: None, min_binding_size: std::num::NonZero::new(stride),
}, },
count: None, count: None,
}], }],
@@ -428,7 +466,12 @@ impl ListBuffers {
list: &InstanceList, list: &InstanceList,
) { ) {
self.instance.update(device, queue, list.instances()); self.instance.update(device, queue, list.instances());
if self.data.update(device, queue, list.data()) || self.group.is_none() { let resized = self.data.update(device, queue, list.data());
// An empty list has no buffer big enough for one entry, and nothing
// draws it, so it has no bind group either.
if list.instances().is_empty() {
self.group = 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,
entries: &[BindGroupEntry { entries: &[BindGroupEntry {
+15 -15
View File
@@ -51,24 +51,20 @@ pub struct PrimitiveRegistry {
pub struct PrimitiveSource { pub struct PrimitiveSource {
pub wgsl: &'static str, pub wgsl: &'static str,
pub label: &'static str, pub label: &'static str,
/// Size of one instance's entry, which the renderer states as the group 1
/// binding's minimum rather than leaving it to be inferred.
pub stride: u64,
} }
impl Default for PrimitiveRegistry { impl Default for PrimitiveRegistry {
fn default() -> Self { fn default() -> Self {
let mut kinds = Self { kinds: Vec::new() }; let mut registry = Self { kinds: Vec::new() };
assert_eq!( let rect = registry.register::<RectPrimitive>(include_str!("shader/rect.wgsl"), "rect");
kinds let glyph = registry.register::<GlyphPrimitive>(include_str!("shader/glyph.wgsl"), "glyph");
.register::<RectPrimitive>(include_str!("shader/rect.wgsl"), "rect") // The built-ins have constant ids so a widget can name one without the
.id(), // registry; registering them first is what makes those constants true.
RECT.id() assert_eq!((rect.id(), glyph.id()), (RECT.id(), GLYPH.id()));
); registry
assert_eq!(
kinds
.register::<GlyphPrimitive>(include_str!("shader/glyph.wgsl"), "glyph")
.id(),
GLYPH.id()
);
kinds
} }
} }
@@ -78,7 +74,11 @@ impl PrimitiveRegistry {
wgsl: &'static str, wgsl: &'static str,
label: &'static str, label: &'static str,
) -> PrimitiveKind<P> { ) -> PrimitiveKind<P> {
self.kinds.push(PrimitiveSource { wgsl, label }); self.kinds.push(PrimitiveSource {
wgsl,
label,
stride: size_of::<P>() as u64,
});
PrimitiveKind::new(self.kinds.len() as u32 - 1) PrimitiveKind::new(self.kinds.len() as u32 - 1)
} }
+1 -1
View File
@@ -44,7 +44,7 @@ struct VertexOutput {
@location(0) top_left: vec2<f32>, @location(0) top_left: vec2<f32>,
@location(1) bot_right: vec2<f32>, @location(1) bot_right: vec2<f32>,
@location(2) uv: vec2<f32>, @location(2) uv: vec2<f32>,
@location(3) mask_idx: u32, @location(3) @interpolate(flat) mask_idx: u32,
// The instance's own index, which is also where its data sits in group 1. // The instance's own index, which is also where its data sits in group 1.
@location(4) @interpolate(flat) idx: u32, @location(4) @interpolate(flat) idx: u32,
@builtin(position) clip_position: vec4<f32>, @builtin(position) clip_position: vec4<f32>,