Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/07-brushes-and-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,19 @@ thin: the wall still reaches the requested thickness along the surface normal.
The SDF path reads the mask at the source surface under each wall sample; the
voxel path grows from masked surface cells for the requested number of layers.

Finding "the surface under a sample" needs the source's gradient, and it is
taken from the lattice the extrude already samples: a central difference of the
distances one cell either side. It costs no source evaluations of its own,
except for a neighbour outside the window of bricks being filled. The extrude
also skips the projection wherever the shell alone decides the stored value.
That is wherever the shell exceeds the largest distance the mask could report
within reach of the sample (by the smooth rim's support when `border_round` is
set), and in any brick lying wholly beyond the band. The skip is exact: the
volume is bit-identical to projecting every sample. The SDF path therefore
evaluates the source about once a sample, as it did before the surface anchor
was added. The first version of the anchor used six extra taps a sample, and the
v0.126.0 device gate measured `mask_extrude` 5-7x slower for it.

The one thing it needed that did not already exist is `brush::mask_to_field`. A
mask is a `[0,1]` scalar on a lattice and **not a distance field**: composing one
into a field expression directly puts a near-vertical step in the result and the
Expand Down
7 changes: 7 additions & 0 deletions include/clay/field/volume.h
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ class FieldVolume {
// them from the world position would be inverting arithmetic that is
// right here.
void sample_cell(std::size_t slot, int i, int out[3]) const;
// The world point of a GLOBAL cell coordinate: the one expression every
// position above goes through. A fill that needs a lattice point no
// brick handed it (a neighbour across the window's edge) calls this
// rather than rebuilding the arithmetic, so the point is the one the
// brick that owns it samples, bit for bit, whatever the compiler
// contracts.
kernel::cfloat3 cell_position(const int cell[3]) const;

// The box the brick's samples span, halo face included.
math::Aabb brick_box(std::size_t slot) const;
Expand Down
14 changes: 13 additions & 1 deletion openspec/changes/honor-mask-extrude-thickness/design.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
## Surface-anchored region
The mask describes which points on the source surface are selected. For a field sample p, estimate the source normal by centered differences, project p to the source surface using its signed distance, then evaluate the measured mask distance at that projection. Compose that region with the existing shell and rim rounding.
The mask describes which points on the source surface are selected. For a field sample p, estimate the source normal by centered differences over the sample lattice, project p to the source surface using its signed distance, then evaluate the measured mask distance at that projection. Compose that region with the existing shell and rim rounding.

For a voxel grid, select masked surface cells once and grow into adjacent cells up to the requested layer count without reapplying the mask to each newly reached cell. Preserve palette indices through the existing colour remap.

## Cost of the anchor
The first implementation estimated the normal with six off-lattice taps per sample, so every sample called the source seven times. On the v0.126.0 device gate (iPad15,5, iOS 27.0.1, against v0.120.1's engine) `mask_extrude` went from 51.6 to 288.3 ms at 10 stamps (5.58x), 374.5 to 2566.1 ms at 100 (6.85x) and 5297 to 26834 ms at 1000 (5.07x), with the growth exponent unchanged at 0.98. The gallery's one-shot `mask_extract` went from 75.3 to 468.5 ms (6.22x). Both device cases take the field path through `clay_document_mask_extrude`. The voxel path is not on the device gate.

Two changes bring the field path back to about one source call per sample:

- The gradient is a central difference over the sample lattice, using the distances one cell either side. The fill takes a whole window of bricks before projecting any of them. A neighbour across a brick face is read from the brick that sampled it, and only a neighbour outside the window calls the source. A lattice point is the same float position whichever brick names it, so samples shared by two bricks store the same bits.
- The projection is skipped wherever the shell alone decides the stored value. A max pyramid over the measured mask distances bounds what the mask could report at any point within |distance| of the sample. Where the shell exceeds that bound, max(shell, region) is the shell. With `border_round` the shell must exceed the bound by the quadratic blend's support (4k), where the blend term is exactly zero. A brick whose every shell is beyond the band is dropped by the volume regardless of the region. The skip is exact: the volume is bit-identical to projecting every sample.

The pyramid's finest level is the measured distances themselves, read in place; only the coarser levels are allocated, about a seventh of the dense array. The pyramid reads up to twice the span it is asked for. That is safe, but it also hides a bound that looked only half as far as it should. The buried-mask test fixtures are built so that a half-reach bound changes stored bits there anyway.

The lattice gradient changes stored values against the six-tap version. On the test fixtures the largest change is 0.002-0.01 cells on smooth surfaces, about 0.13 cells at a box edge, and 0.21 cells for a 0.6 inward wall that reaches the sphere's centre, where the source has no gradient. The brick layout is identical on every fixture.

## Verification
Measure outer wall height along several normals on a spherical cap at 0.05, 0.1, and 0.6 world units. Compare voxel and field extracts within one voxel and preserve the existing refusal, rounding, cancellation, and colour tests.
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ The library SHALL resolve a source field, a mask and a thickness into a new fiel
- **WHEN** a thin mask on a sphere is extruded outward by 0.05, 0.1 or 0.6 world units
- **THEN** the wall height along each sampled surface normal is within 10% of the requested thickness
- **AND** the wall top remains even across the masked patch

#### Scenario: Anchoring the mask does not multiply source evaluations
- **WHEN** a dabbed mask on a 0.8 sphere is extruded outward by 0.05 at a 0.04 cell
- **THEN** the source is evaluated at most 1.5 times per lattice sample on average
- **AND** the result is bit-identical to projecting every sample onto the surface
- **AND** every sample two neighbouring bricks share holds the same value in both
11 changes: 11 additions & 0 deletions openspec/changes/honor-mask-extrude-thickness/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,14 @@
- [x] Add measured height and evenness regression tests for both paths.
- [x] Update the API header and feature documentation.
- [x] Run focused C++ and C ABI regression tests.

## Restore the field path's cost (v0.126.0 device gate)
- [x] Take the projection's gradient from the sample lattice instead of six off-lattice taps.
- [x] Skip the projection where the shell alone decides the stored value, and prove the skip bit-identical to projecting every sample.
- [x] Assert the halo stays seamless across brick faces.
- [x] Gate source calls per lattice sample at <= 1.5 on the device fixture (7 before, 1.009 after).
- [x] A/B the C ABI on the device fixture against main and the pre-anchor engine.
- [x] Read the bound's finest level in place from the measured distances rather than copying them (peak memory).
- [x] Build an off-window neighbour's position through `BrickGrid::cell_position`, the function every sample position goes through.
- [x] Keep the unculled reference and a skip tally in a src-private header; assert the skip fires on whole bricks beyond the band and on stored samples under a painted interior.
- [x] Add a buried-mask fixture that catches a bound reading only half the projection's reach.
Loading
Loading