iris: a mask is a shape, not a rectangle -- .masked_by, and touch obeys it
Iris, on the code fence: "the code block scrolling currently masks in an
inner rectangle. Ideally masks should have a shape associated with them,
rounded rectangle being one of them ... so that the mask becomes the
parent container with rounded edges. Make sure alpha works properly with
it, eg. on the corners where alpha should be decreased / multiplied."
`Mask` is now `{ primitive, parent }` -- the slot of a primitive already
written, plus the mask this one nests inside. The fragment stage
evaluates that primitive's own coverage at the masked pixel, through the
same `rounded_rect_coverage` a drawn rect goes through, and multiplies it
into the alpha along the whole `parent` chain. Nothing about the shape is
copied, so a rounded container's corner and its children's clipped corner
are one piece of arithmetic and cannot drift; two nested feathers dim a
pixel twice, which is the multiply she asked for.
`.masked()` is unchanged for callers: it writes an undrawn rect
(`Drawn::No`/`NOT_DRAWN` -- owned, moved, resized and freed like any
other primitive, simply never rasterized) and points at that, so square
clipping is the same mechanism rather than a special case. New
`.masked_by(shape)` draws `shape` behind the content in its own layer and
clips to the first primitive it drew, with no radius written twice; it
replaces `.masked().background(w)`, which drew both and clipped to the
box. `transcript-ui`'s `BlockFrame::Verbatim` is the first caller.
Hit-testing applies the shape (`SensorUi::run_sensors` ->
`UiRenderState::mask_admits`, coverage above one half, which is where the
drawn edge is), as well as the widget's own box -- the two ask different
questions and both have to hold. `primitive_corners` is a floor-for-floor
transliteration of the shader's `corners_of`, not `region.to_px()`: the
phone's 2.55 density puts nothing on a whole pixel, and skipping the
rounding disagrees with the pixels by up to one along each edge.
A mask's shape must be a rect, asserted by name in `set_mask_to`. A glyph
would need a CPU-side alpha plane before the hit test could agree with
the shader, and a standalone image a bind-group switch the fragment stage
cannot make. So no texture mask exists; the branch where one would go is
in both copies of `mask_coverage`. docs/LAYOUT.md's section end lists this
and the three other places the code is narrower than the design.
Tests. Layer 1, `layout_tests.rs`: the child's coverage swept across the
container's corner arc equals the container's own exactly; nested masks
multiply rather than intersect, asserted where both feathers are partial,
which is the only place the two differ; a press in a rounded-away corner
misses while one inside the curve and one on a straight edge hit; and
`a_plain_mask_still_clips_to_a_square_box`, the half this had no reason to
touch. The first version of the corner test swept the straight chord
between the arc's ends, which lies inside the circle everywhere -- it
proved nothing and said so, which is why it counts both sides now.
`iris/tests/mask_sdf.rs` is the only test here that needs a GPU: it lifts
`distance_from_rect` and `rounded_rect_coverage` out of
`iris_core::SHAPE_SHADER` by name -- lifted, not copied, since a copy
would be edited alongside the shader -- and runs them in a compute pass
over ~200k points at five radii against `iris_core::rounded_rect_coverage`.
Worst disagreement under 1e-5; the negative control (`+ 0.01` inside the
shader's smoothstep) fails it at 0.03.
Layer 2 for looking: `./run-headless.sh phone --phone --shot /tmp/mask.png
--seconds 6 -- -p transcript-fixture` draws the fixture's horizontally
scrolled code fence clipped on the curve at both top corners.
Two things found on the way and fixed here:
- The winit backend had the defect the Android one was fixed for in
85869d0 -- `Backends::PRIMARY` and an `.expect` on the adapter. This
VM's Venus device disappears when the host runs out of virgl contexts,
which happened mid-task, and layer 2 aborted with `Could not get
adapter!` while GL sat there working. It probes and rebuilds the
instance on `Backends::GL` exactly as Android does now, and the request
names the backends it tried. The rule had been written on one member of
a set of two.
- `active_primitive_count` counted mask shapes, so `iris::frame`'s
`primitives=` -- a number Iris reads off a phone report as "how much is
on screen" -- would have gained one per masked widget.
`widget_trait!` now accepts a `///` doc comment on its functions, since
`masked_by` is public API and rustdoc is where a contract is read.
docs/LAYOUT.md, docs/RUST.md (both queue boxes, the commands, and where
the GPU test sits among the three layers), docs/IRIS.md and
docs/IRIS_TODO.md ("Masks defined relative to each other", now closed).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3eb0e033d5
commit
38bf6309cb
23 files changed
+1284
-122
No files matched your search
+36
-1
@@ -948,7 +948,7 @@ When this lands, copy this entry into `IRIS.md` (newest first):
|
||||
> design, the move-offset mechanism this shipped alongside, and the file
|
||||
> list.
|
||||
|
||||
## Masks with a shape (decided 2026-09-07, not yet built)
|
||||
## Masks with a shape (decided 2026-09-07, built 2026-09-08)
|
||||
|
||||
Iris, on the code block's scrolling: "the code block scrolling currently
|
||||
masks in an inner rectangle. Ideally masks should have a shape
|
||||
@@ -1039,3 +1039,38 @@ and that the CPU SDF and the shader agree at a grid of points; a
|
||||
`run-headless.sh --phone` screenshot of a scrolled code block shows
|
||||
rounded corners with no square pixels poking out at the top and bottom
|
||||
of the scrolled content. Record the commands in RUST.md when it lands.
|
||||
|
||||
### What was built (2026-09-08), and where it differs
|
||||
|
||||
The commands and the screenshot are in docs/RUST.md's queue entry. Four
|
||||
places the code is narrower than the design above, each deliberate:
|
||||
|
||||
- **No `kind` and no `flags` on `Mask`.** It is `{ primitive, parent }`.
|
||||
The referenced instance already carries its own `binding`, so a copy
|
||||
of it in the mask is a second thing to keep in step; *alpha only* is
|
||||
the only mode there is, so there is nothing to select. Both are a
|
||||
field away if a second mode appears.
|
||||
- **A mask's shape must be a rect.** `Painter::set_mask_to` asserts it,
|
||||
by name, rather than leaving the shader to read a `rects` entry that
|
||||
is not there. A glyph would need a CPU-side alpha plane before the
|
||||
hit test could agree with the shader, and a standalone image needs a
|
||||
bind-group switch the fragment stage cannot make (`masks_layout`'s own
|
||||
comment on why an image's bind group must not name the masks buffer).
|
||||
So **the texture-mask pass condition is not met and no texture mask
|
||||
exists** — the point of the reference design is that adding one is a
|
||||
binding check and a sampled alpha, with no new shader path, and the
|
||||
shader's `mask_coverage` already has the branch where it would go.
|
||||
- **The shape is a primitive of its own, not always a drawn one.** A
|
||||
plain `.masked()` writes an undrawn `RectPrimitive` at its region
|
||||
(`Drawn::No`, `NOT_DRAWN`) and points the mask at that, so "clip to my
|
||||
box" and "clip to that widget's rounded background" are one mechanism
|
||||
and square-cornered clipping did not become a special case.
|
||||
`.masked_by(shape)` draws `shape` behind the content — in its own
|
||||
layer, the way `Stack` puts a background under its content — and
|
||||
clips to the first primitive it drew.
|
||||
- **The CPU/shader agreement is a GPU test**, `iris/tests/mask_sdf.rs`,
|
||||
the only test in the workspace that needs an adapter. It lifts
|
||||
`distance_from_rect` and `rounded_rect_coverage` out of
|
||||
`iris_core::SHAPE_SHADER` *by name* and runs them in a compute pass,
|
||||
so the thing under test is the shader itself rather than a copy of it
|
||||
that would be edited alongside.
|
||||
Reference in new issue
Block a user