Put positions on the grid, and decode them in the shader

`UiScalar` is `Rel` beside `Px` rather than two floats, so composing a
position down a chain of boxes adds exactly and rounds only at the two
multiplies `within` makes. `UiSpan`, `UiRegion` and `UiVec2` follow it, the
hand-written `Hash` goes away with the bits it hashed, and `impl_op!` grows a
`same` form for a type whose fields are not the same kind of number.

`Len` is still floats, so the seam converts: `Px::from_f32` where a span adds
a child's length to its cursor, and `to_f32` where something outside layout
wants pixels. Those go when `Len` follows.

The GPU reads what the CPU wrote: the instance attributes are `Sint32x2` and
the shader decodes by `1/64` and `1/2^24`, both exact in `f32`, then composes
the move chain in floats as before. It has to agree with itself frame to
frame rather than with the CPU to the last bit.

Two things fell out of making the numbers exact.

`floor` at the rasteriser was picking the pixel below wherever a fraction
divided a window exactly. A fifth of 1920 is 383.99998 through a rounded
`Rel` -- and was 384.0 through an `f32` that happened to round up -- so five
tabs each lost their last column. `snap_floor` takes a coordinate within half
a step of a boundary to be on it, which is the same rule as everywhere else
here: decide where values do not land.

A widget measured on one layer and drawn again on another kept the first
layer, because `try_reuse` compared everything about a retained drawing
except which list it sits in. `Stack` does exactly that for its background,
so every panel's text went under its own background. It only worked before
because the two asks differed by a rounding and forced a redraw;
`tests/retained.rs` pins it now, and `ReuseOutcome` can say `WrongLayer`.

Checked: fmt, clippy, 100 tests, 100 generated seeds in 70 s, all five
shrinker cases at 300 seeds. `tabs`, `view` and `minimal` render
byte-identical at 1920x1200; `random` differs in 36 pixels by one level;
`text` differs where glyph origins moved onto the grid -- same positions,
same spacing, different subpixel coverage, checked at 6x against the old
render.

Measured on the way: with the fuzzer comparing for *equality* rather than
within 0.05 px, `resize`, `repaint` and `size-change` already pass 100 seeds.
`reorder` fails one seed by exactly one step, which is the `Len` seam above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 01:22:12 -04:00
1 parent 7548139861
commit 4e28f1047e
19 files changed
+256 -138

No files matched your search

+6 -4
View File
@@ -16,11 +16,13 @@ pub struct PrimitiveInstance {
}
impl PrimitiveInstance {
// The region's four scalars, each a `Rel` beside a `Px`: whole counts
// that the shader decodes, rather than the numbers themselves.
const ATTRIBS: [VertexAttribute; 6] = vertex_attr_array![
0 => Float32x2,
1 => Float32x2,
2 => Float32x2,
3 => Float32x2,
0 => Sint32x2,
1 => Sint32x2,
2 => Sint32x2,
3 => Sint32x2,
4 => Uint32,
5 => Uint32,
];
+57 -19
View File
@@ -15,17 +15,54 @@ struct WindowUniform {
};
struct Mask {
x: UiSpan,
y: UiSpan,
x: RawSpan,
y: RawSpan,
move_idx: u32,
}
struct MoveOffset {
x: UiSpan,
y: UiSpan,
x: RawSpan,
y: RawSpan,
parent: u32,
}
// What `iris_core` stores: a whole count of a sixty-fourth of a pixel, and of
// a `1 / 2^24` of a box. Both steps are powers of two, so decoding one is
// exact and the number here is the number the CPU decided.
const PX_STEP: f32 = 1.0 / 64.0;
const REL_STEP: f32 = 1.0 / 16777216.0;
// Every coordinate the CPU decided is a whole count of `PX_STEP`, so one that
// composes to within half a step of a pixel boundary is on that boundary and
// belongs to the pixel above it. Flooring the product instead drops a pixel
// wherever a fraction divides a window exactly: a fifth of 1920 comes out of
// `REL_STEP` as 383.99998, and five tabs each lose their last column.
fn snap_floor(v: vec2<f32>) -> vec2<f32> {
return floor(v + PX_STEP * 0.5);
}
struct RawScalar {
rel: i32,
px: i32,
}
struct RawSpan {
start: RawScalar,
end: RawScalar,
}
fn scalar_of(raw: RawScalar) -> UiScalar {
return UiScalar(f32(raw.rel) * REL_STEP, f32(raw.px) * PX_STEP);
}
fn span_of(raw: RawSpan) -> UiSpan {
return UiSpan(scalar_of(raw.start), scalar_of(raw.end));
}
fn scalar_of_pair(raw: vec2<i32>) -> UiScalar {
return UiScalar(f32(raw.x) * REL_STEP, f32(raw.y) * PX_STEP);
}
struct Region {
x: UiSpan,
y: UiSpan,
@@ -37,9 +74,10 @@ const MOVE_NONE: u32 = 4294967295u;
// resolve a deep one the same way.
const CHAIN_LIMIT: u32 = 64u;
// Written the way `UiScalar::within` writes it rather than as `mix`, so the
// CPU and the shader compose a position with the same arithmetic and answer
// the same thing about where a widget is.
// The same expression `UiScalar::within` uses, in floats rather than on the
// CPU's grid: a move is resolved here so that scrolling a subtree writes one
// entry instead of walking it. What has to hold is that this agrees with
// itself frame to frame, not that it matches the CPU to the last bit.
fn scalar_within(s: UiScalar, p: UiSpan) -> UiScalar {
return UiScalar(
p.start.rel + (p.end.rel - p.start.rel) * s.rel,
@@ -59,7 +97,7 @@ fn resolve_move(idx: u32, local: Region) -> Region {
break;
}
let entry = move_offsets[at];
r = Region(span_within(r.x, entry.x), span_within(r.y, entry.y));
r = Region(span_within(r.x, span_of(entry.x)), span_within(r.y, span_of(entry.y)));
at = entry.parent;
}
return r;
@@ -76,10 +114,10 @@ struct UiScalar {
}
struct InstanceInput {
@location(0) x_start: vec2<f32>,
@location(1) x_end: vec2<f32>,
@location(2) y_start: vec2<f32>,
@location(3) y_end: vec2<f32>,
@location(0) x_start: vec2<i32>,
@location(1) x_end: vec2<i32>,
@location(2) y_start: vec2<i32>,
@location(3) y_end: vec2<i32>,
@location(4) mask_idx: u32,
@location(5) move_idx: u32,
}
@@ -102,8 +140,8 @@ fn vs_main(
var out: VertexOutput;
let local = Region(
UiSpan(UiScalar(in.x_start.x, in.x_start.y), UiScalar(in.x_end.x, in.x_end.y)),
UiSpan(UiScalar(in.y_start.x, in.y_start.y), UiScalar(in.y_end.x, in.y_end.y)),
UiSpan(scalar_of_pair(in.x_start), scalar_of_pair(in.x_end)),
UiSpan(scalar_of_pair(in.y_start), scalar_of_pair(in.y_end)),
);
let r = resolve_move(in.move_idx, local);
let top_left_rel = vec2(r.x.start.rel, r.y.start.rel);
@@ -111,8 +149,8 @@ fn vs_main(
let bot_right_rel = vec2(r.x.end.rel, r.y.end.rel);
let bot_right_px = vec2(r.x.end.px, r.y.end.px);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_px);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_px);
let top_left = snap_floor(top_left_rel * window.dim) + snap_floor(top_left_px);
let bot_right = snap_floor(bot_right_rel * window.dim) + snap_floor(bot_right_px);
let size = bot_right - top_left;
let uv = vec2<f32>(
@@ -137,14 +175,14 @@ fn masked(in: VertexOutput, color: vec4<f32>) -> vec4<f32> {
let mask = masks[in.mask_idx];
// Its own chain, not the drawn primitive's, so a stationary viewport
// clips content that moves inside it.
let m = resolve_move(mask.move_idx, Region(mask.x, mask.y));
let m = resolve_move(mask.move_idx, Region(span_of(mask.x), span_of(mask.y)));
let tl = vec2(m.x.start.rel, m.y.start.rel);
let tl_px = vec2(m.x.start.px, m.y.start.px);
let br = vec2(m.x.end.rel, m.y.end.rel);
let br_px = vec2(m.x.end.px, m.y.end.px);
let top_left = floor(tl * window.dim) + floor(tl_px);
let bot_right = floor(br * window.dim) + floor(br_px);
let top_left = snap_floor(tl * window.dim) + snap_floor(tl_px);
let bot_right = snap_floor(br * window.dim) + snap_floor(br_px);
let pos = in.clip_position.xy;
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
return color * 0.0;