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>
199 lines
11 KiB
Markdown
199 lines
11 KiB
Markdown
# iris: known problems and things still to build
|
|
|
|
Iris's own list for the library, recorded 2026-09-04 in her words where it
|
|
matters, so the agents working through RUST.md pick these up in a sensible
|
|
order rather than rediscovering them. Each item says where it sits in the
|
|
order and what "done" looks like. Tick and date them in place.
|
|
|
|
## Fix
|
|
|
|
- [x] **Input does not fall through by input type (2026-09-04).**
|
|
`SensorUi::run_sensors` (`src/default/sense.rs`) used to set "consumed,
|
|
stop checking lower layers" from mere hover — a widget registered for
|
|
nothing but `click()` blocked a `Scroll` meant for whatever was behind
|
|
it, since "the cursor is over this widget" and "this widget handled the
|
|
event" were the same check. Fixed by judging consumption per input
|
|
kind: with no button transition and no scroll happening this frame
|
|
("momentary" activity), the topmost hovered widget still wins, same as
|
|
before; when something momentary *is* happening, only a widget whose
|
|
registered senses actually include a matching non-hover one (checked
|
|
via a new `TypeEventManager::registered`, which lists what a widget
|
|
registered without running anything) consumes it, so a widget with only
|
|
`Hovering`/click handlers can no longer block a scroll from reaching a
|
|
list underneath. `iris/src/sense_tests.rs` builds a button-over-a-list
|
|
`Stack` with a plain `HasEvents` impl (no GPU or window) and checks both
|
|
directions: a scroll over the button reaches the list, and a real click
|
|
still reaches the button — confirmed to fail on the pre-fix code and
|
|
pass after.
|
|
|
|
- [x] **Appending one image to an already-loaded list rebuilds every other
|
|
image's bind group (2026-09-05, fixed 2026-09-05).** Found by the
|
|
benchmark below: `GpuTextures::update` (`core/src/render/texture.rs`)
|
|
triggered `rebuild_image_bind_groups` — a loop over *every live
|
|
standalone image*, rebuilding its `BindGroup` — whenever the shared
|
|
`masks` or `move_offsets` GPU buffer was resized (`masks_resized ||
|
|
moves_resized` in `UiRenderNode::update`, `core/src/render/mod.rs`), and
|
|
a widget getting its *first* move-offset slot (LAYOUT.md section 2 —
|
|
every widget gets one on first draw) could be exactly what grows that
|
|
buffer. So one new message with one new image, appended to a transcript
|
|
that already has N images loaded, did not cost O(1): it cost one
|
|
`create_image` for the new image plus one `make_image_bind_group` per
|
|
*existing* image, because the new widget's own move slot pushed the
|
|
arena past its capacity. Measured directly in
|
|
`iris/examples/bench_images.rs`: appending a 1,001st image to 1,000
|
|
already-settled ones reported **1,001** bind-group creates for that one
|
|
frame, not 1 (`./run-bench.sh images`, frame 5 in the transcript below).
|
|
|
|
**Fix**: `masks`/`move_offsets` never belonged in a standalone image's own
|
|
bind group (group 2) in the first place — the group also holds that
|
|
image's own texture view, which is the only thing that is genuinely
|
|
per-image, so a buffer shared by *everything* forced a rebuild of
|
|
*every* group the moment it moved. Gave masks/move_offsets their own
|
|
bind group (group 3 in `shader.wgsl` and `UiRenderNode`: `masks_layout`/
|
|
`masks_group`), bound once per frame in `UiRenderNode::draw` rather than
|
|
once per draw call, instead of duplicating them into every per-image
|
|
group. `GpuTextures` and its image bind groups now know nothing about
|
|
either buffer — `rebuild_image_bind_groups` is called only from
|
|
`grow_array` (the atlas array texture growing, which genuinely does
|
|
change what every image's own bind group must reference) — so a
|
|
masks/move_offsets resize now touches exactly one bind group, ever,
|
|
regardless of how many images are live. Numbers after the fix, same
|
|
benchmark and command:
|
|
|
|
./run-bench.sh images
|
|
frame=1 bind_group_creates=1000 (cold load, unchanged)
|
|
frame=2 bind_group_creates=0 (was 1000 -- see the item below)
|
|
frame=3 bind_group_creates=0
|
|
frame=4 bind_group_creates=0
|
|
(append one image here)
|
|
frame=5 bind_group_creates=1 (was 1001)
|
|
frame=6 bind_group_creates=0
|
|
|
|
`run-headless.sh tabs --shot` still 27266 bytes, byte-for-byte unchanged,
|
|
confirming the bind-group restructuring changed nothing about what is
|
|
drawn.
|
|
- [x] **Bind-group creation takes two frames to reach the steady state, not
|
|
one (2026-09-05, closed by the fix above, 2026-09-05).** Same benchmark:
|
|
loading 1,000 images cold used to report 1,000 creates on frame 1
|
|
(expected — `create_image`, one per new image) *and again* 1,000 on
|
|
frame 2, before settling to 0 from frame 3. This was `rebuild_image_bind_groups`
|
|
firing a second time for the same masks/move-offsets buffer-growth
|
|
reason as the item above, confirming the guess recorded here — the two
|
|
were exactly the same root cause measured two different ways. Frame 2
|
|
now reports 0 (see the numbers above); not a separate fix.
|
|
|
|
## Build
|
|
|
|
- [x] **Benchmarks**, not unit tests, run on demand (2026-09-05; a
|
|
`benches/` or a script under `iris/`, never in `cargo test`). The
|
|
scenario that matters most is a **message list** — chat apps and this
|
|
app's transcript alike — stressed with many messages and many images.
|
|
One case in particular: **resizing an input box** (typing enough text to
|
|
grow it) that pushes a long list of messages above it must stay very
|
|
fast and recalculate almost nothing — a move of everything above, not a
|
|
re-layout. That is exactly the O(1) move chain in LAYOUT.md; the
|
|
benchmark is what proves it. Done when the numbers are in this file with
|
|
the command, and the input-box case reports draws re-run, not just frame
|
|
time.
|
|
|
|
**Built as two rigs**, chosen per scenario by whether a real `wgpu`
|
|
device is needed (`UiRenderState`/`Widgets` touch no GPU or window, so
|
|
most of this runs as an ordinary binary — the same property
|
|
`layout_tests.rs` relies on):
|
|
|
|
- `iris/benches/message_list.rs` — a plain `Instant`-timed binary
|
|
(`[[bench]] harness = false` in `iris/Cargo.toml`), not criterion: see
|
|
the file's own header for why (short version — every scenario here
|
|
reduces to a *count* `UiRenderState::take_counters` already produces,
|
|
which criterion's statistical machinery adds nothing to and which a
|
|
new dependency is not worth pulling in for). Covers (a) first-frame
|
|
cost of a message list of N wrapped-text rows (one in 20 also carrying
|
|
a small in-memory image) for N = 100/1,000/10,000; (b) per-frame cost
|
|
of scrolling that list, 200 ticks; (c) the input-box case — a
|
|
fixed-height field at the bottom of the screen growing by a line 40
|
|
times, with the message list above it filling the rest of the screen.
|
|
Run: `cd iris && cargo bench --bench message_list` (always release —
|
|
`cargo bench` builds the `bench` profile, which is optimized).
|
|
- `iris/examples/bench_images.rs` — needs a real device, so it runs
|
|
through `iris/run-headless.sh bench_images`, printing
|
|
`UiRenderNode::take_image_bind_group_creates()` (a new counter, added
|
|
in `core/src/render/texture.rs` and `core/src/render/mod.rs`,
|
|
mirroring `UiRenderState::take_counters`) each frame. Covers (d): 1,000
|
|
image rows, checked both cold (does bind-group creation reach zero
|
|
once loaded) and after appending one more image once settled (does
|
|
*that* stay cheap) — the second question is what actually matters for
|
|
a live transcript and is what turned up the two Fix items above.
|
|
- `iris/run-bench.sh [list|images]` runs either or both and is what to
|
|
run before/after touching `Scroll`, `Span`, `Sized`, the move-offset
|
|
chain, or `GpuTextures`.
|
|
|
|
**Numbers (2026-09-05, release, `cargo bench`/`run-headless.sh`, this
|
|
VM: AMD Ryzen 7 3800X, 8 cores, rustc 1.98.0 nightly-2026-09-03):**
|
|
|
|
cd iris && cargo bench --bench message_list
|
|
(a) first frame, N=100: 30.30ms draws=227 rewrites=15 moves=0
|
|
(a) first frame, N=1000: 186.04ms draws=2252 rewrites=150 moves=0
|
|
(a) first frame, N=10000:1770.36ms draws=22502 rewrites=1500 moves=0
|
|
(b) scroll, N=100/1000/10000, 200 ticks each:
|
|
draws=200 rewrites=0 moves=200 (identical at every N)
|
|
per-tick average: 0.0002ms (identical at every N)
|
|
(c) input grows 40 lines, N=100/1000/10000 rows above it:
|
|
draws=320 rewrites=40 moves=160 (identical at every N)
|
|
per-line average: 0.0012-0.0013ms (identical at every N)
|
|
|
|
cd iris && ./run-bench.sh images (2026-09-05, before the fix)
|
|
frame=1 bind_group_creates=1000 (cold load)
|
|
frame=2 bind_group_creates=1000 (see Fix item above)
|
|
frame=3 bind_group_creates=0
|
|
frame=4 bind_group_creates=0
|
|
(append one image here)
|
|
frame=5 bind_group_creates=1001 (see Fix item above)
|
|
frame=6 bind_group_creates=0
|
|
|
|
cd iris && ./run-bench.sh images (2026-09-05, after the fix)
|
|
frame=1 bind_group_creates=1000 (cold load, unchanged -- genuine work)
|
|
frame=2 bind_group_creates=0
|
|
frame=3 bind_group_creates=0
|
|
frame=4 bind_group_creates=0
|
|
(append one image here)
|
|
frame=5 bind_group_creates=1 (one image's own create_image, O(1))
|
|
frame=6 bind_group_creates=0
|
|
|
|
**Reading it**: (a) is real, necessary work — shaping and laying out N
|
|
never-before-seen text rows — and scales with N as it must, ~10x cost
|
|
per 10x N. (b) and (c) are the pass conditions that matter: both are
|
|
**exactly flat across N = 100 to 10,000**, confirming LAYOUT.md's O(1)
|
|
move chain holds for both scrolling and for a growing input box pushing
|
|
the message list — draws/moves per tick or per line do not grow with
|
|
list size, and the per-operation cost (a fraction of a microsecond) is
|
|
nowhere near a frame budget. (d)'s cold-load and steady-state halves
|
|
behave as designed; its *append* half did not, until the fix above moved
|
|
masks/move_offsets out of the per-image bind group — now flat at O(1)
|
|
the same way (b) and (c) are.
|
|
- [ ] **Masks defined relative to each other.** Wanted: mask A multiplies
|
|
by something *and also* applies mask B — a mask can reference a parent
|
|
mask, the way the move chain references a parent offset. Today masks
|
|
are independent regions. Design it beside the move chain (same shape:
|
|
a parent index and a bounded walk in the shader); do it when a real
|
|
widget needs it, not before.
|
|
- [ ] **Positions as a single float per scroll.** Iris raised, and half
|
|
rejected, letting a scroll update one float rather than positions:
|
|
input handling cares about most elements in a list, so absolute
|
|
positions must be computed on the CPU anyway. LAYOUT.md's design
|
|
already lands here (GPU walks the chain, CPU resolves on demand for
|
|
hit tests). Keep the CPU resolution lazy and per query; do not
|
|
materialise every row's absolute position per frame.
|
|
- [ ] **Animations, last.** Cosmetic, so after everything above. Must be
|
|
**modular — a piece of the library rather than a core part forced into
|
|
everything, the same way input is**. Whatever the mechanism, a widget
|
|
that does not animate must pay nothing and import nothing for it.
|
|
|
|
## Reconsider
|
|
|
|
- [ ] **`WidgetView`.** Iris is unsure of it: what she wants is an easy way
|
|
to compose a widget from others (a button is the main case). With
|
|
sizing folded into `draw`, composing may be easy enough that `View` is
|
|
redundant. Decide after the layout change lands, by writing a button
|
|
both ways and keeping the one that is shorter to explain; delete the
|
|
other rather than keeping two ways.
|