iris: give masks/move_offsets their own bind group, fixing O(N) image append

GpuTextures folded the masks and move_offsets storage buffers into every
standalone image's own bind group (group 2), alongside that image's
texture view. Since ArrBuf::update hands back a new Buffer identity
whenever either buffer's length changes -- which a widget getting its
first move-offset slot can trigger, unrelated to any image -- every
live image's bind group had to be rebuilt whenever either buffer grew.
Appending a 1,001st image to 1,000 already-settled ones cost 1,001
bind-group creates, not 1 (IRIS_TODO.md, run-bench.sh images).

Moved both buffers into their own bind group (group 3 in shader.wgsl
and UiRenderNode), bound once per frame in draw() rather than once per
per-image bind group. GpuTextures's image bind groups now only
reference the atlas array view, the image's own view and the sampler --
none of which change when masks/move_offsets resize -- so a resize
touches exactly one bind group regardless of how many images are live.
This also closes the "two frames to reach steady state" item, which was
the same bug measured a second way.

Verified: cargo build/clippy/test clean (19 tests), cargo ndk build/clippy
clean, run-headless.sh tabs --shot byte-identical (27266 bytes). New
run-bench.sh images numbers: cold load unchanged at 1000/0/0/0, append
now 1 instead of 1001. Both Fix items in IRIS_TODO.md ticked with the
before/after numbers.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet committed 2026-09-05 05:28:38 -04:00
1 parent e2873df92e
commit 19c36e37f2
4 files changed
+185 -149

No files matched your search

+94 -46
View File
@@ -35,6 +35,19 @@ pub struct UiRenderNode {
textures: GpuTextures,
masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>,
/// Group 3: the masks and move-offsets storage buffers, on their own --
/// see IRIS_TODO.md's "Appending one image ... rebuilds every other
/// image's bind group". These used to live in group 2 alongside each
/// standalone image's own texture view, so an image's bind group named
/// the masks/move_offsets buffer directly; the moment either buffer
/// resized (which a widget getting its *first* move slot can trigger,
/// unrelated to any image), `ArrBuf::update` handed back a new `Buffer`
/// identity and every image's bind group -- one per live image -- had
/// to be rebuilt to reference it. Pulling both buffers into their own
/// group, bound once per frame rather than once per draw call, means a
/// buffer resize now rebuilds exactly this one group instead of N.
masks_layout: BindGroupLayout,
masks_group: BindGroup,
}
struct RenderLayer {
@@ -54,6 +67,13 @@ impl UiRenderNode {
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_group, &[]);
// Set once, not per layer or per image: masks/move_offsets are read
// by every primitive and every standalone image alike, and living
// in their own group (rather than folded into group 2 alongside the
// per-image texture view) is what keeps an image's own bind group
// from naming a buffer that changes size on an unrelated widget's
// first draw -- see the comment on `masks_group` below.
pass.set_bind_group(3, &self.masks_group, &[]);
for i in &self.active {
let layer = &self.layers[i];
if layer.instance.len() == 0 && layer.image_instance.len() == 0 {
@@ -164,21 +184,13 @@ impl UiRenderNode {
} else {
false
};
let rebuild_main = self.textures.update(
&mut ui.textures,
&self.rsc_layout,
&self.masks,
&self.move_offsets,
masks_resized || moves_resized,
);
if masks_resized || moves_resized {
self.masks_group =
Self::masks_group(device, &self.masks_layout, &self.masks, &self.move_offsets);
}
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
if rebuild_main {
self.rsc_group = Self::rsc_group(
device,
&self.rsc_layout,
&self.textures,
&self.masks,
&self.move_offsets,
);
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
}
}
@@ -265,11 +277,18 @@ impl UiRenderNode {
);
let rsc_layout = Self::rsc_layout(device);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks, &move_offsets);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
let masks_layout = Self::masks_layout(device);
let masks_group = Self::masks_group(device, &masks_layout, &masks, &move_offsets);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"),
bind_group_layouts: &[&uniform_layout, &primitive_layout, &rsc_layout],
bind_group_layouts: &[
&uniform_layout,
&primitive_layout,
&rsc_layout,
&masks_layout,
],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
@@ -322,6 +341,8 @@ impl UiRenderNode {
textures: tex_manager,
masks,
move_offsets,
masks_layout,
masks_group,
}
}
@@ -355,12 +376,13 @@ impl UiRenderNode {
})
}
/// Group 2: the shared atlas array, one standalone-image slot (a null
/// Group 2: the shared atlas array and one standalone-image slot (a null
/// view for the main draw, a real one for each image's own bind group --
/// see `GpuTextures`), one sampler and the masks buffer. No `count` on
/// any entry: this needs nothing beyond plain Vulkan 1.0 / GLES
/// sampling, unlike the `binding_array` layout it replaced (see
/// TEXTURES.md's "Recommended shape").
/// see `GpuTextures`), plus one sampler. No `count` on any entry: this
/// needs nothing beyond plain Vulkan 1.0 / GLES sampling, unlike the
/// `binding_array` layout it replaced (see TEXTURES.md's "Recommended
/// shape"). Masks and move_offsets are deliberately *not* here -- see
/// `masks_layout` below for why they get their own group.
fn rsc_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
@@ -390,26 +412,6 @@ impl UiRenderNode {
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: None,
},
BindGroupLayoutEntry {
binding: 3,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 4,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
label: Some("ui rsc"),
})
@@ -421,8 +423,6 @@ impl UiRenderNode {
device: &Device,
layout: &BindGroupLayout,
tex_manager: &GpuTextures,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -439,16 +439,64 @@ impl UiRenderNode {
binding: 2,
resource: BindingResource::Sampler(tex_manager.sampler()),
},
],
label: Some("ui rsc"),
})
}
/// Group 3: the masks and move_offsets storage buffers, shared by the
/// main draw and every standalone image alike (see the field comment on
/// `masks_group`). Bound once per frame in `draw()` rather than folded
/// into group 2, so a resize of either buffer -- which an unrelated
/// widget's first move slot can trigger -- rebuilds this one group
/// instead of every image's.
fn masks_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
label: Some("ui masks"),
})
}
fn masks_group(
device: &Device,
layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 3,
binding: 0,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 4,
binding: 1,
resource: move_offsets.buffer.as_entire_binding(),
},
],
label: Some("ui rsc"),
label: Some("ui masks"),
})
}
+5 -2
View File
@@ -72,9 +72,12 @@ var atlas: texture_2d_array<f32>;
var image_texture: texture_2d<f32>;
@group(2) @binding(2)
var samp: sampler;
@group(2) @binding(3)
// Their own group, bound once per frame rather than folded into group 2: see
// UiRenderNode::masks_layout for why an image's own bind group must not name
// either buffer.
@group(3) @binding(0)
var<storage> masks: array<Mask>;
@group(2) @binding(4)
@group(3) @binding(1)
var<storage> move_offsets: array<MoveOffset>;
// A move chain more than this deep means something else is wrong (an
+27 -70
View File
@@ -1,9 +1,7 @@
use image::{DynamicImage, EncodableLayout, GenericImageView};
use wgpu::{util::DeviceExt, *};
use crate::{
Mask, MoveOffset, PatchRect, TextureKind, TextureUpdate, Textures, render::util::ArrBuf,
};
use crate::{PatchRect, TextureKind, TextureUpdate, Textures};
use super::atlas::PAGE;
@@ -74,32 +72,21 @@ pub struct GpuTextures {
impl GpuTextures {
/// Applies queued `Textures` updates, then reports whether the *main*
/// bind group (the one rects and glyphs draw with) needs rebuilding --
/// true when the atlas array was recreated (its view identity changed)
/// or the masks buffer was, since both are bound there. Pushing or
/// freeing a standalone image never touches that group: it built or drops
/// its own.
pub fn update(
&mut self,
textures: &mut Textures,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
masks_resized: bool,
) -> bool {
let mut rebuild_main = masks_resized;
if masks_resized {
// The masks or move-offsets buffer just moved, so every bind
// group holding a reference to either -- one per live
// standalone image -- is stale.
self.rebuild_image_bind_groups(rsc_layout, masks, move_offsets);
}
/// true exactly when the atlas array was recreated (its view identity
/// changed). Pushing or freeing a standalone image never touches that
/// group: it built or drops its own. Masks/move_offsets resizing is
/// `UiRenderNode`'s own concern now (its `masks_group`, group 3) --
/// see that struct's field comment for why standalone images no longer
/// hear about either buffer at all.
pub fn update(&mut self, textures: &mut Textures, rsc_layout: &BindGroupLayout) -> bool {
let mut rebuild_main = false;
for update in textures.updates() {
match update {
TextureUpdate::Push(kind, image) => {
rebuild_main |= self.push(kind, image, rsc_layout, masks, move_offsets);
rebuild_main |= self.push(kind, image, rsc_layout);
}
TextureUpdate::Set(kind, i, image) => {
rebuild_main |= self.set(kind, i, image, rsc_layout, masks, move_offsets);
rebuild_main |= self.set(kind, i, image, rsc_layout);
}
// A patch changes texture contents, not which layer or bind
// group exists, so it never asks for a rebuild -- rebuilding
@@ -118,10 +105,8 @@ impl GpuTextures {
kind: TextureKind,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks, move_offsets);
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
self.slots.push(slot);
rebuilt
}
@@ -132,10 +117,8 @@ impl GpuTextures {
i: u32,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout, masks, move_offsets);
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
self.slots[i as usize] = slot;
rebuilt
}
@@ -145,18 +128,16 @@ impl GpuTextures {
kind: TextureKind,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> (Slot, bool) {
match kind {
TextureKind::Image => {
let gpu = self.create_image(image, rsc_layout, masks, move_offsets);
let gpu = self.create_image(image, rsc_layout);
(Slot::Image(gpu), false)
}
TextureKind::Page { layer } => {
let mut rebuilt = false;
if layer >= self.array_capacity {
self.grow_array(rsc_layout, masks, move_offsets);
self.grow_array(rsc_layout);
rebuilt = true;
}
self.write_full_layer(layer, image);
@@ -244,12 +225,7 @@ impl GpuTextures {
/// copies the old layers across GPU-side -- no readback. Recreates the
/// array's view, which invalidates every bind group that referenced it,
/// so this also rebuilds all of them before returning.
fn grow_array(
&mut self,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) {
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
let new_capacity = self.array_capacity * 2;
let new_texture = Self::create_array_texture(&self.device, new_capacity);
if self.page_count > 0 {
@@ -285,15 +261,14 @@ impl GpuTextures {
..Default::default()
});
self.array_capacity = new_capacity;
self.rebuild_image_bind_groups(rsc_layout, masks, move_offsets);
self.rebuild_image_bind_groups(rsc_layout);
}
fn rebuild_image_bind_groups(
&mut self,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) {
/// Called only from `grow_array`: the atlas array's view identity is the
/// one thing an image's bind group (group 2) still names that can
/// change out from under it. Masks/move_offsets resizing no longer
/// reaches here at all -- see `UiRenderNode::masks_group`.
fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout) {
for slot in &mut self.slots {
if let Slot::Image(gpu) = slot {
gpu.bind_group = Self::make_image_bind_group(
@@ -302,21 +277,13 @@ impl GpuTextures {
&self.array_view,
&gpu.view,
&self.sampler,
masks,
move_offsets,
);
self.bind_group_creates += 1;
}
}
}
fn create_image(
&mut self,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> ImageGpu {
fn create_image(&mut self, image: &DynamicImage, rsc_layout: &BindGroupLayout) -> ImageGpu {
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
let texture = self.device.create_texture_with_data(
@@ -345,8 +312,6 @@ impl GpuTextures {
&self.array_view,
&view,
&self.sampler,
masks,
move_offsets,
);
self.bind_group_creates += 1;
ImageGpu {
@@ -357,16 +322,16 @@ impl GpuTextures {
}
/// Builds group 2 for one standalone image: the shared atlas array, this
/// image's own view, the shared sampler, and the shared masks buffer --
/// the same layout the main draw uses with a null view in the image slot.
/// image's own view and the shared sampler -- the same layout the main
/// draw uses with a null view in the image slot. Deliberately does not
/// touch masks/move_offsets (group 3, `UiRenderNode::masks_group`): see
/// that field's comment for why folding them in here was the bug.
fn make_image_bind_group(
device: &Device,
rsc_layout: &BindGroupLayout,
array_view: &TextureView,
image_view: &TextureView,
sampler: &Sampler,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout: rsc_layout,
@@ -383,14 +348,6 @@ impl GpuTextures {
binding: 2,
resource: BindingResource::Sampler(sampler),
},
BindGroupEntry {
binding: 3,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 4,
resource: move_offsets.buffer.as_entire_binding(),
},
],
label: Some("ui rsc image"),
})