Resolve a primitive's position through a chain of move slots

The plumbing for O(1) subtree movement (LAYOUT.md §2), with every slot
still at zero, so this changes no pixels and the next commit can change
behaviour against a known-good picture.

Every active widget owns a slot in `UiData::moves`: a translation in
physical pixels and the slot it is relative to. A primitive instance and
a mask each name one, and `prelude.wgsl` walks the chain and adds the
accumulated delta. A mask resolves its own chain rather than the drawn
primitive's, so a stationary viewport can clip content that moves inside
it. `CHAIN_LIMIT` is stated on both sides; it bounds a malformed cycle
rather than any real tree.

A slot outlives any one `ActiveData`, because a redraw replaces that
while the widget's children go on pointing at the slot, so it lives in
`UiRenderState::moves` keyed by widget and is retired when the widget
stops being drawn. `MoveIdx` is its own type rather than another
`Id<u32>`: it sits beside `MaskIdx` in an instance and the two must not
be swappable.

`Vec2` is now `repr(align(8))`, which is WGSL's alignment for a
`vec2<f32>`, so a GPU struct holding one is laid out the way its shader
reads it without saying so itself -- `GlyphPrimitive` no longer states
its own alignment, and `MoveOffset` never has to. Both keep a manual
`unsafe impl Pod`, since the trailing padding that alignment introduces
is what `derive(Pod)` refuses. `WindowUniform` holds the `Vec2` its
shader has always called `dim` rather than two loose floats, which was
the last place the two sides described the same bytes differently.

Checked: fmt, clippy and 40 tests. `tabs` (with the image replay),
`view` and `minimal` render byte-identical to `upstream/main`, and
`text` is unchanged.
This commit is contained in:
iris-ai committed 2026-09-14 03:05:14 -04:00
1 parent ca2b4b2173
commit f9ef7514e7
11 files changed
+262 -40

No files matched your search

+44 -17
View File
@@ -17,7 +17,7 @@ mod texture;
mod util;
pub use atlas::*;
pub use data::{Mask, MaskIdx};
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use primitive::*;
const PRELUDE: &str = include_str!("./shader/prelude.wgsl");
@@ -34,6 +34,7 @@ pub struct UiRenderNode {
active: Vec<usize>,
window_buffer: Buffer,
masks: ArrBuf<Mask>,
moves: ArrBuf<MoveOffset>,
}
struct RenderLayer {
@@ -127,32 +128,35 @@ impl UiRenderNode {
for primitive in &mut self.primitives {
primitive.render.update(ui);
}
let mut regroup = false;
if ui.masks.changed {
ui.masks.changed = false;
if self.masks.update(device, queue, &ui.masks[..]) {
self.shared_group = Self::shared_group(
device,
&self.shared_layout,
&self.window_buffer,
&self.masks,
);
}
regroup |= self.masks.update(device, queue, &ui.masks[..]);
}
if ui.moves.changed {
ui.moves.changed = false;
regroup |= self.moves.update(device, queue, ui.moves.entries());
}
if regroup {
self.shared_group = Self::shared_group(
device,
&self.shared_layout,
&self.window_buffer,
&self.masks,
&self.moves,
);
}
}
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
let size = size.into();
let slice = &[WindowUniform {
width: size.x,
height: size.y,
}];
let slice = &[WindowUniform { dim: size }];
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
}
pub fn new(device: &Device, config: &SurfaceConfiguration) -> Self {
let window_uniform = WindowUniform {
width: config.width as f32,
height: config.height as f32,
dim: Vec2::new(config.width as f32, config.height as f32),
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"),
@@ -166,7 +170,13 @@ impl UiRenderNode {
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks",
);
let shared_group = Self::shared_group(device, &shared_layout, &window_buffer, &masks);
let moves = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let shared_group =
Self::shared_group(device, &shared_layout, &window_buffer, &masks, &moves);
Self {
shared_layout,
@@ -177,6 +187,7 @@ impl UiRenderNode {
layers: HashMap::default(),
active: Vec::new(),
masks,
moves,
}
}
@@ -252,7 +263,8 @@ impl UiRenderNode {
})
}
/// What every draw in the ui is given: the window and the masks.
/// What every draw in the ui is given: the window, the masks and the
/// move chain every position is resolved through.
fn shared_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
@@ -276,6 +288,16 @@ impl UiRenderNode {
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: BufferSize::new(size_of::<MoveOffset>() as u64),
},
count: None,
},
],
label: Some("ui shared"),
})
@@ -286,6 +308,7 @@ impl UiRenderNode {
layout: &BindGroupLayout,
window: &Buffer,
masks: &ArrBuf<Mask>,
moves: &ArrBuf<MoveOffset>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
@@ -298,6 +321,10 @@ impl UiRenderNode {
binding: 1,
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 2,
resource: moves.buffer.as_entire_binding(),
},
],
label: Some("ui shared"),
})