docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape, and hit-testing applies the shape (Iris, 2026-09-07)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
232de0ec53
commit
1121d7cc83
3 files changed
+101
-40
No files matched your search
+61
-38
@@ -965,43 +965,62 @@ already produces an anti-aliased rounded edge from
|
||||
`distance_from_rect(pos, center, corner, radius)` with a half-pixel
|
||||
`smoothstep`, and the border variant multiplies a second coverage in.
|
||||
|
||||
**Design.**
|
||||
**Design** (revised the same day on Iris's two corrections: hit-testing
|
||||
applies the shape too, and a mask should reference a primitive rather
|
||||
than carry a copy of its shape).
|
||||
|
||||
1. **A mask is a shape, and the shape is the same SDF the `Rect`
|
||||
primitive draws with.** `Mask` gains `radius: f32` (one uniform
|
||||
corner radius, matching `Rect.radius`; per-corner radii only when a
|
||||
concrete need appears). The fragment stage computes coverage as
|
||||
`1.0 - smoothstep(-min(edge, radius), edge, distance_from_rect(...))`
|
||||
-- the *same expression* `draw_rounded_rect` uses, factored into one
|
||||
function both call -- and does `color.a *= coverage`. So a mask whose
|
||||
region and radius equal a rounded container's are clipped to exactly
|
||||
the pixels that container fills, corner alpha included, because they
|
||||
are the same arithmetic. `radius = 0` becomes a half-pixel
|
||||
anti-aliased edge instead of today's hard cut, which is what `Rect`
|
||||
does already, so a masked rect and an unmasked one look the same.
|
||||
2. **Nested masks chain and multiply, like moves.** Today one
|
||||
`mask_idx` per primitive; nesting two rectangles could be handled by
|
||||
intersecting spans on the CPU, but the intersection of two rounded
|
||||
rectangles is not a rounded rectangle. So `Mask` gains `parent: u32`
|
||||
(the enclosing mask's slot, or the sentinel), the shader walks the
|
||||
chain multiplying coverage, and the walk is bounded the way
|
||||
`resolve_move` is (`MOVE_CHAIN_LIMIT`'s sibling; assert on overflow
|
||||
in debug, print the chain). The painter's `set_mask` records the
|
||||
current mask as the parent. Alpha multiplies rather than takes a
|
||||
minimum, so a pixel in two feathered corners is dimmed by both --
|
||||
that is what "alpha should be multiplied" asks for and what a real
|
||||
compositor does.
|
||||
3. **The widget API: the container is the mask.** `Masked` takes a
|
||||
`MaskShape` (`Rect`, `Rounded(radius)`); and the rounded `Rect`
|
||||
widget, the thing a code block or card is already inside, gets a
|
||||
`.masked()` builder that wraps its children in a `Masked` carrying
|
||||
*its own* radius. One value, by construction, never a radius on the
|
||||
container and a second one on the mask to keep in sync. The code
|
||||
block in `transcript-ui/src/row.rs` (`.masked()` at the inner
|
||||
rectangle) moves to masking at the rounded container instead.
|
||||
4. **Hit-testing keeps the rectangle.** Input outside the rounded
|
||||
corner but inside the box is a few pixels; not worth a second SDF
|
||||
walk on the CPU. State this in the `Masked` doc so nobody "fixes" it.
|
||||
1. **A mask is a reference to a primitive already drawn, plus how to
|
||||
use it.** `Mask { kind, idx, flags, parent }`: the primitive's
|
||||
binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one:
|
||||
*alpha only* -- take the primitive's coverage and ignore its colour,
|
||||
which is the default and the only mode until a need for another
|
||||
appears), and the enclosing mask's slot for nesting. The fragment
|
||||
stage evaluates the referenced primitive *at the masked pixel* --
|
||||
for a `Rect`, the same `draw_rounded_rect` coverage from the same
|
||||
SDF; for a texture or glyph, the sampled alpha -- and does
|
||||
`color.a *= coverage`. Nothing about the shape is copied: a rounded
|
||||
container's corner and its children's clipped corner are the same
|
||||
primitive's arithmetic, and a texture mask (an alpha image as the
|
||||
clip) works with no new shader path.
|
||||
What this needs from the data layout: evaluating a primitive at an
|
||||
arbitrary pixel means its placement (its spans and `move_idx`, today
|
||||
vertex attributes) has to be readable from a storage buffer in the
|
||||
fragment stage. If it is not already there, put it there once, for
|
||||
every primitive, rather than keeping a second copy for masks -- the
|
||||
vertex stage can read the same buffer. Textures: the shader binds one
|
||||
image at a time (see `masks_layout`'s comment on why an image's own
|
||||
bind group must not name the masks buffer), so a texture mask is
|
||||
limited to what the fragment can sample without a bind-group switch:
|
||||
the atlas, and the primitive's own bound image when the masked
|
||||
primitive is drawn in the same image's batch. Say so at the flag.
|
||||
2. **Nested masks chain and multiply, like moves.** `parent` walks up
|
||||
the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling;
|
||||
debug-assert on overflow and print the chain); coverages multiply,
|
||||
so a pixel inside two feathered corners is dimmed by both, which is
|
||||
what a compositor does and what "alpha should be multiplied" asks.
|
||||
3. **`.masked()` points the mask at the current widget's own
|
||||
primitives.** `Masked` stops describing a region: it records which
|
||||
primitive(s) the wrapping widget drew this frame (the painter knows
|
||||
-- it just allocated the slots) and sets the mask to reference them.
|
||||
So a rounded `Rect` widget's `.masked()` clips its children to
|
||||
itself by pointing at the rect it already draws; an image widget's
|
||||
`.masked()` clips to its alpha. No radius or shape argument exists to
|
||||
fall out of sync. When a widget draws more than one primitive (a
|
||||
bordered rect is one primitive; a card with a stripe is two), the
|
||||
mask references the *first* and the doc says so; a widget that wants
|
||||
another names it.
|
||||
4. **Hit-testing applies the shape.** A press is inside a masked
|
||||
subtree only if the mask's coverage at that point is above one half.
|
||||
For a `Rect` that is the same rounded-rect SDF evaluated on the CPU
|
||||
-- one function in the shared crate, with the WGSL a transliteration
|
||||
of it and a test that compares the two at a grid of points
|
||||
(`headless` renders to a buffer and reads back, or the Rust version
|
||||
is checked against the values the shader produced once and recorded).
|
||||
For a texture, the CPU needs the alpha: keep the alpha channel of an
|
||||
image used as a mask readable on the CPU (it was uploaded from CPU
|
||||
memory; keeping the alpha plane is a quarter of the image), and read
|
||||
it at the point. A masked corner that cannot be tapped and a masked
|
||||
corner that is not drawn are then the same corner.
|
||||
|
||||
**Rejected.** A stencil buffer (a second pass per mask level and no
|
||||
anti-aliasing); the scissor rectangle (rectangles only, no alpha);
|
||||
@@ -1011,8 +1030,12 @@ rendering a masked subtree to an offscreen texture and compositing
|
||||
**Pass conditions.** A headless test draws a rounded container with a
|
||||
masked child that overhangs all four sides and asserts the child's
|
||||
coverage at a corner pixel equals the container's own coverage there
|
||||
(same SDF, so exactly equal, not approximately); a nested-mask test
|
||||
asserts the product at a pixel inside both feathers; a
|
||||
(same primitive evaluated, so exactly equal, not approximately); a
|
||||
nested-mask test asserts the product at a pixel inside both feathers; a
|
||||
texture-mask test clips a rect to an alpha image and asserts a
|
||||
transparent texel masks fully; a hit-test asserts a press in a
|
||||
container's clipped corner misses and one just inside the curve hits,
|
||||
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.
|
||||
Reference in new issue
Block a user