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
@@ -8,6 +8,48 @@ capability that moved. Small and trivial changes do not go here.
|
||||
An entry gives the date, what changed, why, and a short before/after where
|
||||
it helps judge the change without the session that made it. Newest first.
|
||||
|
||||
## 2026-09-08: masks have a shape -- `.masked_by(shape)`, and clipping applies to touch
|
||||
|
||||
A mask no longer carries a rectangle. It carries **the slot of a
|
||||
primitive already drawn**, and the fragment stage evaluates that
|
||||
primitive's own coverage at each masked pixel and multiplies it into the
|
||||
alpha -- the same rounded-rect SDF the primitive itself is drawn with.
|
||||
Nothing about the shape is copied, so a rounded container's corner and
|
||||
the corner its content is cut to cannot fall out of step, and nested
|
||||
masks multiply rather than intersect: a pixel inside two feathered
|
||||
corners is dimmed by both.
|
||||
|
||||
// before -- the mask clipped to the padded box, the rounding was
|
||||
// only painted behind it, and the two knew nothing of each other
|
||||
field.scrollable_on(Axis::X)
|
||||
.masked()
|
||||
.pad(dp(FRAME_PAD_DP))
|
||||
.background(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||
|
||||
// after -- one rect, drawn and clipped to
|
||||
field.scrollable_on(Axis::X)
|
||||
.pad(dp(FRAME_PAD_DP))
|
||||
.masked_by(rect(fill).radius(dp(FRAME_RADIUS_DP)))
|
||||
|
||||
`.masked()` is unchanged for callers and still clips to the widget's own
|
||||
box; under it, it now writes an undrawn rect primitive and points the
|
||||
mask at that, so square-cornered clipping is the same mechanism rather
|
||||
than a special case. `.masked_by(shape)` draws `shape` behind the
|
||||
content, in its own layer, and clips to the first primitive it drew.
|
||||
There is no radius or shape argument anywhere -- that is the point.
|
||||
|
||||
**A press now has to be inside the shape, not just the box.** A corner
|
||||
the container rounded away is not there to be tapped, which needed the
|
||||
coverage function on the CPU as well as in the shader;
|
||||
`iris/tests/mask_sdf.rs` runs the shader's own text against the Rust one
|
||||
over a grid of points so the two cannot drift apart.
|
||||
|
||||
One limit worth knowing before reaching for it: **a mask's shape must be
|
||||
a rect**, asserted by name. Clipping to a glyph or an image would need,
|
||||
respectively, a CPU-side alpha plane for the hit test and a bind-group
|
||||
switch the fragment stage cannot make. The shader has the branch where
|
||||
either would go.
|
||||
|
||||
## 2026-09-07: iris runs on a GLES-only Android device, and reports the renderer it cannot build
|
||||
|
||||
`AndroidRenderer::new` asked wgpu for `Backends::PRIMARY`, which does not
|
||||
|
||||
Reference in new issue
Block a user