diff --git a/bindings/c/clay_c.cpp b/bindings/c/clay_c.cpp index fe0cd5a2..fa4f794d 100644 --- a/bindings/c/clay_c.cpp +++ b/bindings/c/clay_c.cpp @@ -3798,7 +3798,7 @@ clay_result try_band(const Compile& compile, eval::Backend& backend, const math: *out_answered = false; *out_reach = std::numeric_limits::infinity(); scene::Tape tape; - const scene::CullRegion cull{box.dilated(band + tap_pad)}; + const scene::CullRegion cull{box.dilated(band + tap_pad), band}; clay_result r = compile(&cull, &tape); if (r != CLAY_OK) return r; // A region that kept NOTHING cannot answer: every probe reads @@ -4665,6 +4665,22 @@ clay_result read_region(const float region_min[3], const float region_max[3], co return CLAY_OK; } +// How far inside a host's cull region every sample of its lattice lies: the band +// the region was dilated by, as far as the call can see it (scene::CullRegion). +// A host hands clay_eval_grid a brick's cull region and the brick's lattice, so +// this recovers the band clay_brick_cache_cull_region added. Zero when a sample +// sits on or outside the region, which claims nothing. +float lattice_band_in(const math::Aabb& region, const eval::GridQuery& q) { + const kernel::cfloat3 last = + q.origin + kernel::cf3(q.spacing * static_cast(q.nx - 1), + q.spacing * static_cast(q.ny - 1), + q.spacing * static_cast(q.nz - 1)); + const float m = std::min({q.origin.x - region.min.x, q.origin.y - region.min.y, + q.origin.z - region.min.z, region.max.x - last.x, + region.max.y - last.y, region.max.z - last.z}); + return m > 0.0f ? m : 0.0f; +} + // count * per, computed where it cannot wrap, and required to be exactly the // caller's capacity. A count is the one argument this boundary cannot check // against the caller's memory, so it is not inferred. @@ -5168,7 +5184,7 @@ scene::Tape compile_request_tape(const clay_document* doc, const clay_brick_requ ChunkHalf half, scene::LayerId active, const scene::CullIndex* index, const scene::CullPlan* plan, scene::TapeCheckpoint* cp) { - scene::CullRegion cull{request_brick_box(req).dilated(req.band)}; + scene::CullRegion cull{request_brick_box(req).dilated(req.band), req.band}; const scene::Document& d = doc->doc.document; if (half == ChunkHalf::Whole) return cp ? scene::compile_document_resumable(d, cp, &cull, index, plan) @@ -5216,9 +5232,12 @@ clay_result eval_requests_in_chunks(const clay_document* doc, const clay_brick_r } std::shared_ptr index = doc->cull_index(); math::Aabb batch_region; - for (std::size_t i = 0; i < count; ++i) + float batch_band = 0.0f; + for (std::size_t i = 0; i < count; ++i) { batch_region.expand(request_brick_box(requests[i]).dilated(requests[i].band)); - const scene::CullPlan plan = index->plan(batch_region); + batch_band = std::max(batch_band, requests[i].band); + } + const scene::CullPlan plan = index->plan(batch_region, batch_band); constexpr std::size_t kChunk = 4096; constexpr bool kWantCheckpoints = !std::is_same_v, NoPost>; std::vector tapes; @@ -8001,7 +8020,7 @@ void prepare_frontier_seeds(clay_document* doc, const DragFrontier& frontier) { job.key.spacing * static_cast(job.key.dims[1]), job.key.spacing * static_cast(job.key.dims[2])); const math::Aabb box = math::Aabb{lo, lo + size}.dilated(job.key.band); - scene::CullRegion cull{box}; + scene::CullRegion cull{box, job.key.band}; scene::Tape prefix; if (!scene::compile_layer_prefix(d, job.boundary, &prefix, &cull, prep.index.get())) continue; // values stay empty; phase C skips the job @@ -14192,8 +14211,8 @@ clay_result clay_eval_grid(const clay_document* doc, const char* backend, // revision-cached cull index, so it walks the region's neighbourhood // rather than the whole document. std::shared_ptr index = doc->cull_index(); - const scene::CullPlan plan = index->plan(region); - scene::CullRegion cull{region}; + const scene::CullRegion cull{region, lattice_band_in(region, query)}; + const scene::CullPlan plan = index->plan(region, cull.band); scene::Tape tape = scene::compile_document(doc->doc.document, &cull, index.get(), &plan); return eval_grid_into(tape, backend, query, out_values, out_colors_rgb); } @@ -14389,8 +14408,8 @@ clay_result clay_eval_grid_device(const clay_document* doc, clay_device* device, const scene::Tape* tape = nullptr; if (has_region) { std::shared_ptr index = doc->cull_index(); - const scene::CullPlan plan = index->plan(region); - scene::CullRegion cull{region}; + const scene::CullRegion cull{region, lattice_band_in(region, query)}; + const scene::CullPlan plan = index->plan(region, cull.band); culled = scene::compile_document(doc->doc.document, &cull, index.get(), &plan); tape = &culled; } else { @@ -15214,7 +15233,7 @@ UniformProbe probe_uniform_seed(const clay_document* doc, const clay_brick_reque // cull index snapshot and writes only its own brick's slot. void run_uniform_task(const clay_document* doc, const clay_brick_request& req, const scene::CullIndex* index, ResumeTask& t) { - scene::CullRegion cull{request_brick_box(req).dilated(req.band)}; + scene::CullRegion cull{request_brick_box(req).dilated(req.band), req.band}; scene::Tape suffix; scene::TapeCheckpoint next; if (!scene::compile_layer_suffix(t.plan->checkpoint, doc->doc.document, t.plan->appended, @@ -15277,8 +15296,9 @@ void run_resume_task(const ResumeRun& run, ResumeTask& t, std::vector& po run_uniform_task(run.doc, run.requests[t.slot], run.index, t); return; } - const math::Aabb box = request_brick_box(run.requests[t.slot]).dilated(run.requests[t.slot].band); - scene::CullRegion cull{box}; + const float band = run.requests[t.slot].band; + const math::Aabb box = request_brick_box(run.requests[t.slot]).dilated(band); + scene::CullRegion cull{box, band}; // The checkpoint is PER BRICK where it has frames, because a frame's // `emits` depends on this brick's own cull. The plan supplies what is // batch-wide -- the layer and the appended ids -- and the seed the rest. diff --git a/docs/05-claycore-library.md b/docs/05-claycore-library.md index 4eff1052..091569a2 100644 --- a/docs/05-claycore-library.md +++ b/docs/05-claycore-library.md @@ -1283,6 +1283,21 @@ as `clay_mesh_transform_nonuniform` already documents, because rotating a normal is right for a similarity and tilts every one of them off the surface under a squash. +**The per-brick cull widens a squashed placement (#649).** Because the value +can be short of the distance by up to `q = max(s) / min(s)` (the two levels' +ratios multiplied), "the bound is more than band + pad from the brick" no longer +means "the field is more than band + pad there". A brick in between dropped an +item whose field was inside the band: refill samples off `clay_eval_points` by +up to 0.068 on random documents. The cull now widens such a bound by +`(q - 1) · (band + pad + w)` before testing it, where `w` is the rounding and +blend support the bound already carries, and a group by its subtree's widest +term plus `(q - 1)` times its own support (`scene::CullSquash`). The item is +still culled wherever its field cannot reach. That needs the band, so +`scene::CullRegion` carries it next to the region, and `CullIndex::plan` takes +the batch's widest band. A plan made for a narrower band than a region carries is +dropped for a squashed document, which costs the walk and never the field. A +document with no per-axis scale culls exactly as before, whatever the band says. + `kSceneMinor` and `kClaySpaceMinor` move to 16. An older stream loads with (1, 1, 1), which is what those files always meant; a stream written at an older minor does not carry the field, so that minor's reader does not desynchronise. diff --git a/include/clay/eval/bake_volume.h b/include/clay/eval/bake_volume.h index 832b3afe..1e9dd2d6 100644 --- a/include/clay/eval/bake_volume.h +++ b/include/clay/eval/bake_volume.h @@ -246,7 +246,7 @@ inline field::FieldVolume::BrickBlockFill document_block_fill(const scene::Docum std::size_t sampled = 0, instrs = 0; for (std::size_t slot = 0; slot < slots && sampled < kProbes; slot += step, ++sampled) { - scene::CullRegion cr{grid.brick_box(slot).dilated(grid.band)}; + scene::CullRegion cr{grid.brick_box(slot).dilated(grid.band), grid.band}; instrs += scene::compile_document(doc, &cr).instrs.size(); } const std::size_t whole = tape.instrs.size(); @@ -274,11 +274,12 @@ inline field::FieldVolume::BrickBlockFill document_block_fill(const scene::Docum for (std::size_t s = 0; s < count; ++s) window.expand(grid.brick_box(first + s).dilated(grid.band)); const scene::CullIndex& idx = **index; - const scene::CullPlan plan = idx.plan(window); + const scene::CullPlan plan = idx.plan(window, grid.band); parallel::ThreadPool::instance().parallel_for( count, 1, [&](std::size_t lo, std::size_t hi) { for (std::size_t s = lo; s < hi; ++s) { - scene::CullRegion cr{grid.brick_box(first + s).dilated(grid.band)}; + scene::CullRegion cr{grid.brick_box(first + s).dilated(grid.band), + grid.band}; const scene::Tape brick = scene::compile_document(doc, &cr, &idx, &plan); float* block = out + s * field::kBrickSamples; bool near = false; diff --git a/include/clay/scene/bounds.h b/include/clay/scene/bounds.h index 1ccd67a6..55ed13dc 100644 --- a/include/clay/scene/bounds.h +++ b/include/clay/scene/bounds.h @@ -82,6 +82,52 @@ bool deformers_bound_gradient(const Node& item); // is what meshing and raycast clipping want. math::Aabb item_geometry_bound(const Node& item, const Layer& layer); +// HOW FAR PAST ITS BOUND A SQUASHED PLACEMENT'S FIELD STILL READS IN THE BAND, +// as the per-brick cull needs it (issue #649; tape.h, CullRegion). +// +// Outside its bound an item's field exceeds band + pad only when that field is +// a distance. Under a per-axis scale it is not: `cscale_nu_dist` multiplies the +// local value by the smallest component, so the field is short of the true +// distance by up to q = max(s) / min(s) at each level, the two ratios +// multiplied. A point D from the item's geometry therefore reads at least D / q +// (less the rounding), and the cull's "more than band + pad + w from the +// geometry" -- w being what the bound already dilates by, the rounding and the +// combine's support -- must become q times that. So the bound is widened by +// +// slope * (band + pad) + reach, slope = q - 1, reach = slope * w, +// +// before it is tested. A group carries the widest slope of its subtree and the +// widest reach, plus slope times its own blend support, for the same reason +// one level up. +// +// ZERO for a similarity -- the default (1, 1, 1) included -- exactly, since +// q is then max/min of three equal floats. So a document with no per-axis +// scale makes bit-identical cull decisions to the cull before this existed. +// +// It widens; it does not exempt. `item_geometry_reach_in_document` refuses a +// squashed placement outright because it has no band to widen by; the cull +// does, and keeps culling a squashed item everywhere its field cannot reach. +struct CullSquash { + float slope = 0.0f; + float reach = 0.0f; + + bool none() const { return slope == 0.0f && reach == 0.0f; } + // The widening for a cull whose samples lie `band_pad` inside its test + // region: the caller's band plus the compiler's pad. + float dilation(float band_pad) const { return slope * band_pad + reach; } + void raise(const CullSquash& o) { + slope = kernel::cmax(slope, o.slope); + reach = kernel::cmax(reach, o.reach); + } +}; + +// One item, placed by its layer. +CullSquash item_cull_squash(const Node& item, const Layer& layer); + +// A node as the cull tests it: an item as above, a group over its subtree (the +// box `node_influence_bound` returns, widened by this, is what it covers). +CullSquash node_cull_squash(const SdfContent& content, NodeId id, const Layer& layer); + // Whether an item's influence is confined to its own geometry. False means the // item changes the field arbitrarily far away — a non-local op, an infinite // grid repeat, or a primitive with no finite extent — and no finite bound may diff --git a/include/clay/scene/cull_index.h b/include/clay/scene/cull_index.h index f532855b..89958cae 100644 --- a/include/clay/scene/cull_index.h +++ b/include/clay/scene/cull_index.h @@ -74,13 +74,24 @@ class CullIndex { NodeId id; math::Aabb bound; bool local; // false: never culled (item_influence_is_local) + // How the cull widens `bound` for a squashed placement (issue #649): + // item_cull_squash for items, node_cull_squash for groups. Zero for + // every similarity, which is every node of most documents. + CullSquash squash; }; // One coarse cull for a batch of compiles. `region` MUST contain every // cull region the plan is later used with (dilate each by its band // first, exactly as the per-brick CullRegion is built); the plan itself // applies the feather pad, as the per-brick test does. - CullPlan plan(const math::Aabb& region) const; + // + // `band` is the widest CullRegion::band those regions carry. A squashed + // placement's widening grows with it, so the coarse cull widens each chain + // that holds one by its widest entry's widening at this band. A compile + // handed the plan with a wider band drops the plan and walks (see + // CullPlan::serves_band), so a caller that passes too little loses speed + // on a squashed document, never an item. + CullPlan plan(const math::Aabb& region, float band = 0.0f) const; // -- extending an index after an append -------------------------------- // @@ -142,8 +153,8 @@ class CullIndex { // scan: an entry that can never be culled -- non-local, or an infinite // bound -- is stored as an infinite box, so the three-clause survive // test collapses to one box intersection; and 24 B a piece against the - // 40 B Entry lets the scan stream bounds rather than stride over the - // node pointers and ids it does not read. + // 48 B Entry (40 B when this was measured) lets the scan stream bounds + // rather than stride over the node pointers and ids it does not read. // // Both together are 8x on the scan itself at 50 000 items and 5.3x on // `plan` around it, and the gap between those two numbers is the @@ -160,6 +171,11 @@ class CullIndex { // attacking. std::vector probes; bool prunable; // no feathered volume replace among the items + // The widest squash among the entries: the coarse scan widens its test + // region by this rather than each probe by its own, which keeps the + // packed scan one box test per entry and survival a superset of every + // per-brick decision. Zero for a chain with no squashed placement. + CullSquash widest; }; // One layer's cull pad, kept as its TWO TERMS rather than as the sum, so @@ -237,10 +253,17 @@ class CullPlan { return it == pruned_.end() ? nullptr : &it->second; } + // Whether a per-brick region carrying `band` may use this plan: always, + // unless a squashed placement was widened for a narrower band than that + // (CullIndex::plan). + bool serves_band(float band) const { return !squashed_ || band <= band_; } + private: friend class CullIndex; std::unordered_map, CullIndex::KeyHash> pruned_; + float band_ = 0.0f; + bool squashed_ = false; }; // The cached-index form of CullIndex::append, and the ONE place the decision diff --git a/include/clay/scene/tape.h b/include/clay/scene/tape.h index 8a97bc5a..8adc2cc3 100644 --- a/include/clay/scene/tape.h +++ b/include/clay/scene/tape.h @@ -160,8 +160,34 @@ struct Tape { // fixed dilation bounds it for an arbitrary document, which is why the // clamp, not the fit, is the last word. Hard unions have no such term — // min() is exact and associative — and measure identical at any length. +// +// A SQUASHED PLACEMENT reaches further than its bound for a third reason +// (issue #649). A per-axis scale — the item's or its layer's — makes the field +// a BOUND on the distance rather than the distance: `cscale_nu_dist` multiplies +// the local value by the smallest component, so the field can be short of the +// true distance by up to q = max(s) / min(s), the two levels' ratios +// multiplied. "More than band + pad from the bound, so more than band + pad in +// value" then holds only out to q times that, and a brick in between dropped an +// item whose field was inside the band there — in-band refill samples off the +// raw field by up to 0.068 on random documents. The compiler widens such an +// item's bound by (q - 1) * (band + pad + the dilation the bound already +// carries) before the test (scene::CullSquash): the item is still culled, just +// not short of where its field reaches. +// +// That is what `band` is for: how far inside `region` every sample the caller +// will trust lies — the dilation it applied to its brick. Zero claims the +// samples may sit anywhere in the region, which is right for a caller that names +// a region rather than a brick, and under-widens a squashed item for one that +// dilated a brick without saying so. Nothing else reads it: a document with no +// per-axis scale culls exactly as it did, whatever the band says. struct CullRegion { math::Aabb region; + float band = 0.0f; + + CullRegion() = default; + // Not explicit: `CullRegion cull{box}` is how a caller that names a region + // rather than a brick spells it. + CullRegion(const math::Aabb& r, float b = 0.0f) : region(r), band(b) {} }; class CullIndex; diff --git a/openspec/changes/widen-the-cull-for-a-squashed-placement/proposal.md b/openspec/changes/widen-the-cull-for-a-squashed-placement/proposal.md new file mode 100644 index 00000000..b44c87d5 --- /dev/null +++ b/openspec/changes/widen-the-cull-for-a-squashed-placement/proposal.md @@ -0,0 +1,18 @@ +## Why +#649: a full brick build disagrees with the document's own raw field (`clay_eval_points`) at in-band samples. #652 fixed the deformer cull, which was the largest part. It named two mechanisms that remain, and this change fixes the second (B): **squashed placements are culled as if their field were a distance.** + +A per-axis scale, on an item or on its layer, makes the field a bound on the distance rather than the distance. `cscale_nu_dist` multiplies the local value by the smallest component, so the value can be short of the true distance by up to `q = max(s) / min(s)`, and the two levels' ratios multiply. The per-brick cull drops an item once its bound is more than band + pad from the brick. For a squashed item that is not enough: its field there can still be inside the band out to q times that distance. Seeds 5743 and 6290 of the rich sweep reduce to this: 5,625 and 151 in-band samples off, worst 0.068. + +`item_geometry_reach_in_document` already refuses a squashed placement for the same reason. The cull cannot refuse, because exempting every squashed item would make a squashed layer cull nothing. It widens the bound instead. + +## What Changes +- `scene::CullRegion` gains `band`: how far inside the region the samples lie. Its zero means what every existing `CullRegion{box}` meant. +- `scene::CullSquash` (`item_cull_squash`, `node_cull_squash`): `slope = q - 1` and `reach = slope * w`, where w is the rounding and combine support the bound already carries. A group takes its subtree's widest slope and reach, plus slope times its own support. The cull tests the bound widened by `slope * (band + pad) + reach`. +- `CullIndex::Entry` caches the squash. `CullIndex::plan` takes the batch's widest band and widens each squashed chain's scan region. `CullPlan::serves_band` refuses a region with a wider band than the plan was made for, when the document holds a squashed placement. +- The band is set where it is known: the C ABI refill paths, the volume bake, gradient-normal meshing, and `clay_eval_grid`, which takes it from the lattice's margin inside the host's region. + +## Impact +- A document with no per-axis scale makes bit-identical cull decisions. The squash is exactly zero for a similarity, and a test pins it. +- A squashed document keeps a squashed item in the bricks its field reaches, and no others. +- No ABI, format or version change. `CullIndex::Entry` grows from 40 to 48 bytes. +- Mechanism A of #649, the chain-pad envelope below 75 contributors, is not addressed. #649 stays open for it. diff --git a/openspec/changes/widen-the-cull-for-a-squashed-placement/specs/scene-model/spec.md b/openspec/changes/widen-the-cull-for-a-squashed-placement/specs/scene-model/spec.md new file mode 100644 index 00000000..8f98b6e4 --- /dev/null +++ b/openspec/changes/widen-the-cull-for-a-squashed-placement/specs/scene-model/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Per-brick tape culling +For brick evaluation the compiler SHALL emit per-brick tapes containing only the items whose influence bound intersects that brick (the Dreams design), preserving evaluation semantics exactly. + +Deciding which items those are SHALL NOT require visiting every item in the document. The compiler SHALL consult a spatial index over item influence bounds, so that the cost of culling one brick scales with the number of items NEAR that brick rather than with the size of the document. + +The index SHALL be derived from the same definition of reach the compiler already uses — `item_influence_bound`, and `item_influence_is_local` for whether an item has a bound at all — so that no second notion of what an item touches can go stale against the first. An item that is not local SHALL be emitted unconditionally rather than placed in the index. + +The index SHALL be owned by, and invalidated with, the compiled tape it culls for, so that a document mutation cannot leave the index and the tape disagreeing about the same document. + +A SQUASHED placement — a non-uniform per-axis scale on the item, on its layer, or both — emits a field that can be short of the true distance by up to `q = max(s) / min(s)`, the two levels' ratios multiplied. The cull SHALL widen such an item's bound by `(q - 1) * (band + pad + w)` before testing it, where `band` is how far inside the cull region the brick's samples lie, `pad` is the compiler's own cull pad, and `w` is the dilation the bound already carries (rounding and combine support). A group SHALL be widened by its subtree's widest such term plus `(q - 1)` times its own blend support. The widened item SHALL still be culled from any brick its field cannot reach; a squashed placement SHALL NOT be exempted from the cull. The index and any coarse plan SHALL widen identically, so a planned compile stays byte-identical to an unplanned one. A plan made for a narrower band than a region carries SHALL NOT be used for that region where the document holds a squashed placement. A document with no per-axis scale SHALL make the same cull decisions whatever band a region carries. + +#### Scenario: Culled tape matches full tape +- **WHEN** a brick is evaluated with its culled tape and with the full scene tape +- **THEN** the brick data is bit-identical, and the culled tape length is ≤ the full tape length + +#### Scenario: Culling cost does not follow document size +- **WHEN** the same brick is culled from a document of 100 items and from a document of 10 000 items with the same local density +- **THEN** the time to produce the culled tape does not grow in proportion to the item count + +#### Scenario: A non-local item is never culled away +- **WHEN** a document contains an item whose influence is unbounded and a brick that its geometry does not come near +- **THEN** that item is present in the brick's culled tape, and the brick data is bit-identical to the full-tape result + +#### Scenario: An edit is visible to the next cull +- **WHEN** an item is added, moved or removed and a brick is culled immediately afterwards +- **THEN** the culled tape reflects the edit, exactly as a full recompile would + +#### Scenario: A squashed placement is kept where its field reaches the band +- **WHEN** an item scaled (4, 1, 1), or an item on a layer scaled (4, 1, 1), sits more than band + pad from a brick by its bound, but its field inside the brick is within the band +- **THEN** the brick's refill matches `clay_eval_points` at every in-band sample, with the batch planned for the requests' band + +#### Scenario: A planned compile widens as the walk does +- **WHEN** a document with squashed items, squashed groups and a squashed layer is culled per brick with and without the cull index and a plan made for the region's band +- **THEN** the tapes are byte-identical, and a plan made for no band is refused for a region that carries one + +#### Scenario: An unsquashed document ignores the band +- **WHEN** a document with no per-axis scale is culled with a region that carries a band and with the same region that carries none +- **THEN** the two tapes are byte-identical diff --git a/openspec/changes/widen-the-cull-for-a-squashed-placement/tasks.md b/openspec/changes/widen-the-cull-for-a-squashed-placement/tasks.md new file mode 100644 index 00000000..f61bf692 --- /dev/null +++ b/openspec/changes/widen-the-cull-for-a-squashed-placement/tasks.md @@ -0,0 +1,42 @@ +## Implementation +- [x] `scene::CullRegion` carries `band`, with a constructor so every `CullRegion{box}` spelling keeps meaning "no band". +- [x] `scene::CullSquash`, `item_cull_squash` and `node_cull_squash` (bounds.h). `item_bound_dilation` is factored out of `placed_local_bound`, so the widening multiplies exactly the dilation the bound carries. +- [x] Compiler: the item test, the group test and the planned-entry test widen a squashed bound by `slope * (band + pad) + reach`. The squash is computed only for a node the plain bound would drop. +- [x] `CullIndex::Entry::squash`, set at build and on append; `Chain::widest`; `CullIndex::plan(region, band)` widens a squashed chain's scan region; `CullPlan::serves_band` refuses a plan made for a narrower band, and `usable_plan` drops it. +- [x] The band is set where it is known: the refill's request tapes, its batch plan, the resume and uniform-brick tasks, the frontier jobs, `try_band`, the volume bake, gradient-normal meshing, and `clay_eval_grid` (the band the region adds around the lattice). +- [x] Regression test `tests/unit/test_squashed_cull.cpp`. +- [x] `docs/05` states the widening. + +## Measured +`RV_RAWCHECK=1 undo_bound_oracle_probe`, Release, Apple M-series, origin/main 7023dd9a against this change: + +| | main | this change | +|---|---|---| +| seed 5743 rich | 5,625 samples off, worst 0.06767 | 0 | +| seed 6290 rich | 151 samples off, worst 0.01986 | 0 | +| seeds 5001..6500, rich | 35 documents, worst 0.0825 | 9 documents, worst 0.0170 | +| seeds 1..1500, plain | 21 documents, worst 0.0310 | 21 documents, identical lines | + +Of the 9 rich documents left, 5483 (10,525 -> 14 samples), 5907 (4,132 -> 236) and 5976 (1,652 -> 77) carried B alongside the chain-pad mechanism. 6055 moves slightly (3,380 -> 3,177, same worst value). The other five do not move. + +The regression test, compiled unchanged against origin/main: item scaled (4, 1, 1) 652 samples off, worst 0.11; layer scaled (4, 1, 1) 652, worst 0.11; both levels, blended, rounded and grouped 66, worst 0.025. All three are 0 with the change. + +What it costs a document with no squash (`clay_bench`, Release, Apple M-series, CPU). Both binaries were interleaved over 3 rounds of 5 repetitions. Figures are medians in µs: + +| case | origin/main | this change | +|---|---|---| +| BM_DeepDocCullPlanned2000 | 264 | 268 (+1.5%) | +| BM_DeepDocCullPlanned10000 | 1296 | 1310 (+1.1%) | +| BM_DeepDocCull2000 (no plan) | 1027 | 1059 (+3.1%) | +| BM_DeepDocRefillPlanned10000 | 716 | 736 (+2.7%) | +| BM_CullIndexRebuild | 1150 | 1196 (+4.0%) | + +## What building it found +- The first draft tested the squash before the plain bound and called `item_cull_squash` out of line for every dropped item. That cost +11% on the unplanned cull and +3.5% on the planned one. The plain test now decides every bound it does not drop, and a similarity is answered inline (`culled_item`). What remains is the extra comparison and the 8 bytes per `CullIndex::Entry`. +- The coarse plan is the part that is easy to get wrong. It knows only a batch region, and a squashed entry's widening depends on the band. A plan made with no band prunes a squashed item a per-brick test would keep. So the plan takes the band, and a compile refuses a plan made for a narrower band where it matters (only a document holding a squashed placement). There are about thirty `plan(region)` callers in tests and benchmarks, and none of them is wrong under the default. They only lose the plan on a squashed document. +- Widening each probe would break the packed scan's one-box-per-entry layout. The scan widens the REGION instead, by the chain's widest entry. That is conservative for the coarse cull only, because the per-brick test re-checks each survivor with its own widening. +- A batch plan over scattered bricks covers the whole document and prunes nothing. The first draft of the planned-path test used one, and it passed with the plan's widening removed. Each brick is now planned alone, and that test fails under the mutation. +- `clay_eval_grid` with a host region does not say what band the region was dilated by, but the lattice it evaluates does: the margin between the lattice and the region is that band. + +## Left out +Mechanism A of #649 (seed 978: the #335 chain-pad envelope applies its N = 75 value below 75 contributors) is a pad-versus-performance trade that needs the device gate. This change does not touch it. diff --git a/src/mesh/marching.cpp b/src/mesh/marching.cpp index af0da88c..46d73ed1 100644 --- a/src/mesh/marching.cpp +++ b/src/mesh/marching.cpp @@ -736,7 +736,8 @@ void apply_brick_attributes(Mesh& m, const brick::BrickCache& cache, const scene math::Aabb all_regions; for (const auto& [key, verts] : groups) all_regions.expand(cache.cull_region(key).dilated(options.gradient_eps)); - const scene::CullPlan plan = cull_index->plan(all_regions); + const float band = cache.config().band(); + const scene::CullPlan plan = cull_index->plan(all_regions, band); // ONE culled tape per group, shared by the colour and the normal of every // vertex the group holds. The evaluation goes to the CPU backend as a // single flattened batch (eval_points_batch) rather than a serial loop @@ -758,7 +759,7 @@ void apply_brick_attributes(Mesh& m, const brick::BrickCache& cache, const scene for (const auto& [key, verts] : groups) { // Dilated by gradient_eps on top of the band, so the tetrahedron taps // of a vertex at the region's edge stay inside the culled zone. - const scene::CullRegion cull{cache.cull_region(key).dilated(options.gradient_eps)}; + const scene::CullRegion cull{cache.cull_region(key).dilated(options.gradient_eps), band}; tapes.push_back(scene::compile_document(doc, &cull, cull_index, &plan)); order.insert(order.end(), verts.begin(), verts.end()); offsets.push_back(order.size()); diff --git a/src/scene/bounds.cpp b/src/scene/bounds.cpp index 28097063..c8b45b71 100644 --- a/src/scene/bounds.cpp +++ b/src/scene/bounds.cpp @@ -996,6 +996,28 @@ void expand_by_radial_copies(const Node& item, const Layer& layer, const Aabb& l // deformer's own ball -- for `deformer_head_reach_in_document`, which places // a region of the item rather than all of it and must take every copy and // every dilation this does, not a second spelling of them. +// How far an item's bound reaches past its placed geometry: the rounding and +// the combine's support. Shared with `item_cull_squash`, which widens a +// squashed item's bound by a multiple of exactly this. +// +// Rounding is authored in item-local units (tape emits round*scale); +// erosion (negative rounding) shrinks the surface, never the bound. +// Paint fades over max(profile support, k). Extended modes deviate +// within their documented support of the item surface (kernel/tape.h) — +// for groove/tongue that is the rounding again (rb), on top of the +// rounding dilation the item field already carries. +// Rounding is authored in item-local units and the tape converts it by the +// same factor it multiplies the distance by, so the bound has to use that +// factor too rather than the uniform scale alone. +float item_bound_dilation(const Node& item, const Layer& layer) { + float round_world = item.rounding * placed_distance_scale(layer, item); + float combine = op_is_extended(item.op) + ? kernel::ccombine_extended_support(static_cast(item.op), + item.blend.k, round_world) + : kernel::cmax(item.blend.support(), item.blend.k); + return kernel::cmax(round_world, 0.0f) + combine; +} + Aabb placed_local_bound(const Node& item, const Layer& layer, const Aabb& local, bool with_copies, const math::cfloat4x4* layer_m = nullptr) { if (local.empty()) return local; @@ -1013,21 +1035,7 @@ Aabb placed_local_bound(const Node& item, const Layer& layer, const Aabb& local, expand_by_mirror_copies(item, layer, local, lm, &bound); if (item.mirror) expand_by_radial_copies(item, layer, local, lm, &bound); } - // Rounding is authored in item-local units (tape emits round*scale); - // erosion (negative rounding) shrinks the surface, never the bound. - // Paint fades over max(profile support, k). Extended modes deviate - // within their documented support of the item surface (kernel/tape.h) — - // for groove/tongue that is the rounding again (rb), on top of the - // rounding dilation the item field already carries. - // Rounding is authored in item-local units and the tape converts it by the - // same factor it multiplies the distance by, so the bound has to use that - // factor too rather than the uniform scale alone. - float round_world = item.rounding * placed_distance_scale(layer, item); - float combine = op_is_extended(item.op) - ? kernel::ccombine_extended_support(static_cast(item.op), - item.blend.k, round_world) - : kernel::cmax(item.blend.support(), item.blend.k); - return bound.dilated(kernel::cmax(round_world, 0.0f) + combine); + return bound.dilated(item_bound_dilation(item, layer)); } Aabb geometry_bound(const Node& item, const Layer& layer, bool with_copies, @@ -1041,6 +1049,14 @@ Aabb item_geometry_bound(const Node& item, const Layer& layer) { return geometry_bound(item, layer, /*with_copies=*/true); } +CullSquash item_cull_squash(const Node& item, const Layer& layer) { + if (placed_is_similarity(layer, item)) return CullSquash{}; + const float q = scale_axes_reach(layer.scale_axes) / scale_axes_factor(layer.scale_axes) * + (scale_axes_reach(item.scale_axes) / scale_axes_factor(item.scale_axes)); + const float slope = kernel::cmax(q - 1.0f, 0.0f); + return CullSquash{slope, slope * item_bound_dilation(item, layer)}; +} + bool item_is_feathered_replace(const Node& item) { return item.op == Op::Replace && prim_is_volume(item.prim.type) && item.volume && item.volume->feather() > 0.0f; @@ -1693,6 +1709,42 @@ Aabb node_influence_bound(const SdfContent& content, NodeId id, const Layer& lay return b.empty() ? b : b.dilated(group_blend_support(*n, layer)); } +namespace { +// What `layer_influence_extent` covers, as a squash: the widest over the items +// whose geometry it unions. An intersect's bound IS that extent, so this is the +// widening that goes with it. +CullSquash layer_cull_squash(const SdfContent& content, const Layer& layer) { + CullSquash s; + for (const auto& [id, n] : content.nodes()) { + (void)id; + if (!n.is_group && n.visible) s.raise(item_cull_squash(n, layer)); + } + return s; +} +} // namespace + +// Term for term the walk `node_influence_bound` takes, so the widening always +// goes with the box it widens. +CullSquash node_cull_squash(const SdfContent& content, NodeId id, const Layer& layer) { + const Node* n = content.find(id); + if (!n || !n->visible) return CullSquash{}; + if (!n->is_group) + return item_nonlocality(*n) == Nonlocality::BoundedByLayer + ? layer_cull_squash(content, layer) + : item_cull_squash(*n, layer); + // An infinite bound is never culled, so only the intersect arm, whose box + // is the layer's extent, needs a widening. + if (!op_is_local(n->op)) + return n->op == Op::Intersect ? layer_cull_squash(content, layer) : CullSquash{}; + CullSquash s; + for (NodeId c : n->children) s.raise(node_cull_squash(content, c, layer)); + // The group's own support is reached at q times its width too, as the + // item's is: a child D from its geometry reads at least D / q, and the + // group's combine moves the result wherever that is within its support. + s.reach += s.slope * group_blend_support(*n, layer); + return s; +} + // Does a group's OWN combine apply at all, or does it merely initialise? // // `compile_group` is the authority and this mirrors it term for term: diff --git a/src/scene/cull_index.cpp b/src/scene/cull_index.cpp index 5df28961..54292a8f 100644 --- a/src/scene/cull_index.cpp +++ b/src/scene/cull_index.cpp @@ -121,12 +121,14 @@ void CullIndex::build_chain(const SdfContent& content, const std::vector e.id = id; if (n->is_group) { e.bound = node_influence_bound(content, id, layer); + e.squash = node_cull_squash(content, id, layer); // A group is always cull-tested; its bound reports infinity for // a non-local subtree, which the survive test lets through. e.local = true; build_chain(content, n->children, layer); } else { e.bound = item_geometry_bound(*n, layer); + e.squash = item_cull_squash(*n, layer); e.local = item_influence_is_local(*n); // The compiler's choice between the feathered and the hard // replace depends on whether the cull dropped ANYTHING from this @@ -136,6 +138,7 @@ void CullIndex::build_chain(const SdfContent& content, const std::vector } chain.entries.push_back(e); chain.probes.push_back(probe_for(e)); + chain.widest.raise(e.squash); } chains_.push_back(std::move(chain)); } @@ -245,6 +248,7 @@ bool CullIndex::append(const std::vector& appended) { bool forbids_pruning = false; if (n->is_group) { e.bound = node_influence_bound(content, id, layer); + e.squash = node_cull_squash(content, id, layer); // A group is always cull-tested; its bound reports infinity for // a non-local subtree, which the survive test lets through. e.local = true; @@ -252,11 +256,13 @@ bool CullIndex::append(const std::vector& appended) { build_chain(content, n->children, layer); } else { e.bound = item_geometry_bound(*n, layer); + e.squash = item_cull_squash(*n, layer); e.local = item_influence_is_local(*n); forbids_pruning = item_is_feathered_replace(*n); } chains_[at].entries.push_back(e); chains_[at].probes.push_back(probe_for(e)); + chains_[at].widest.raise(e.squash); if (forbids_pruning) chains_[at].prunable = false; } } @@ -276,9 +282,10 @@ bool CullIndex::append(const std::vector& appended) { return true; } -CullPlan CullIndex::plan(const math::Aabb& region) const { +CullPlan CullIndex::plan(const math::Aabb& region, float band) const { CullPlan plan; plan.pruned_.reserve(chains_.size()); + plan.band_ = band; // Dilated by the feather pad exactly as the per-brick test is // (Compiler::begin_cull), so coarse survival stays a superset of // per-brick survival. @@ -306,11 +313,21 @@ CullPlan CullIndex::plan(const math::Aabb& region) const { const bool packed = !test.is_infinite(); for (const Chain& chain : chains_) { if (!chain.prunable) continue; + // A chain holding a squashed placement is scanned against a region + // widened by its widest entry's widening at this band (issue #649) -- + // the same widening the per-brick test applies to each entry's bound, + // applied to the region instead, where one box serves the whole chain. + // Dilating keeps an infinite region infinite and an empty one empty, + // so `packed` still answers for it. + const bool squashed = !chain.widest.none(); + plan.squashed_ = plan.squashed_ || squashed; + const math::Aabb chain_test = + squashed ? test.dilated(chain.widest.dilation(band + pad_)) : test; std::vector kept; if (packed) - scan_packed(chain.entries, chain.probes, test, &kept); + scan_packed(chain.entries, chain.probes, chain_test, &kept); else - scan_exact(chain.entries, test, &kept); + scan_exact(chain.entries, chain_test, &kept); // Stored even when nothing was dropped: the survivors carry the // cached bounds, so a planned chain never recomputes one per brick. plan.pruned_.emplace(Key{chain.layer, chain.ids}, std::move(kept)); diff --git a/src/scene/tape_build.cpp b/src/scene/tape_build.cpp index a4093695..f04a78d3 100644 --- a/src/scene/tape_build.cpp +++ b/src/scene/tape_build.cpp @@ -139,6 +139,9 @@ struct Compiler { // What cull tests actually intersect against: the caller's region, wider // by feather_cull_pad when a feathered replace is present. math::Aabb cull_test; + // How far inside `cull_test` the samples lie: the caller's band plus the + // pad. A squashed item's bound is widened against it (CullSquash). + float cull_band_pad_ = 0.0f; // Reused across items so a document full of curves does not allocate a // fresh vector per item; only ever read between assignment and use. std::vector scratch_curve; @@ -165,13 +168,32 @@ struct Compiler { void begin_cull(const CullRegion* cull_region, float pad) { cull = cull_region; - if (cull) cull_test = pad > 0.0f ? cull->region.dilated(pad) : cull->region; + if (!cull) return; + cull_test = pad > 0.0f ? cull->region.dilated(pad) : cull->region; + cull_band_pad_ = cull->band + pad; } - bool culled(const math::Aabb& bound) const { + // `squash` widens the bound of a placement whose field is not a distance + // (issue #649, bounds.h). Widening only ever keeps more, so a caller may + // test the plain bound first and pay for the squash only when it drops. + // + // The plain test runs first and decides every bound it does not drop, so + // a document with no squash pays two comparisons per dropped bound. + bool culled(const math::Aabb& bound, const CullSquash& squash = CullSquash{}) const { if (!cull) return false; if (bound.is_infinite()) return false; - return !bound.intersects(cull_test); + if (bound.intersects(cull_test)) return false; + return squash.none() || + !bound.dilated(squash.dilation(cull_band_pad_)).intersects(cull_test); + } + + // The item test of `compile_list` off the plan. A similarity -- nearly + // every item -- is decided inline, without the call that would only return + // a zero squash. + bool culled_item(const math::Aabb& geometry, const Node& item, const Layer& layer) const { + if (!culled(geometry)) return false; + return placed_is_similarity(layer, item) || + culled(geometry, item_cull_squash(item, layer)); } // -- emission ------------------------------------------------------------ @@ -1044,6 +1066,16 @@ struct Compiler { // -- chains -------------------------------------------------------------- + // The group test of `compile_list`, from the entry when a plan supplied one + // and from the walk otherwise. The subtree's squash is gathered only for a + // group the plain bound would drop. + bool culled_group(const CullIndex::Entry* e, const SdfContent& content, const Node& group, + const Layer& layer) const { + if (e) return culled(e->bound, e->squash); + const math::Aabb bound = node_influence_bound(content, group.id, layer); + return culled(bound) && culled(bound, node_cull_squash(content, group.id, layer)); + } + // Compile an ordered node list. have_acc says whether a running value is // already on the stack below; returns whether one is there afterwards. bool compile_list(const std::vector& ids, const SdfContent& content, @@ -1116,16 +1148,18 @@ struct Compiler { // which `begin_cull` sets only where there is a region to set it // from -- and it is pinned by a test rather than assumed, because // it is an invariant of a DIFFERENT function than this one. - if (pruned && !(!e->local || e->bound.is_infinite() || - e->bound.intersects(cull_test))) { + // + // The squash rides on the entry for the same reason (issue #649): + // a squashed placement's bound is widened before it is tested, and + // the planned walk must widen it exactly as the unplanned one does. + if (pruned && e->local && culled(e->bound, e->squash)) { cull_dropped = true; continue; } const Node* n = e ? e->node : content.find(ids[at]); if (!n || !n->visible) continue; if (n->is_group) { - if ((!pruned || !cull) && - culled(e ? e->bound : node_influence_bound(content, n->id, layer))) { + if ((!pruned || !cull) && culled_group(e, content, *n, layer)) { cull_dropped = true; continue; } @@ -1144,7 +1178,8 @@ struct Compiler { // A non-local item has an infinite influence bound and so can // never be culled; item_influence_is_local is the single // definition of that test, shared with item_influence_bound. - if (!pruned && cull && item_influence_is_local(*n) && culled(geometry)) { + if (!pruned && cull && item_influence_is_local(*n) && + culled_item(geometry, *n, layer)) { cull_dropped = true; continue; } @@ -1757,6 +1792,20 @@ LayerId visible_sdf_layer_above(const Document& doc, LayerId layer, std::uint32_ return lowest; } +namespace { +// A plan prunes nothing without a cull region (it could only mean a pruned +// whole-document tape) and serves only a region it was planned wide enough +// for: one whose band is no wider than the plan's own where the document holds +// a squashed placement, whose widening grows with the band (issue #649). A plan +// that cannot serve the region is dropped, which costs the walk and never the +// field. +const CullPlan* usable_plan(const CullRegion* cull, const CullIndex* index, + const CullPlan* plan) { + if (!cull || !index || !plan) return nullptr; + return plan->serves_band(cull->band) ? plan : nullptr; +} +} // namespace + Tape compile_document(const Document& doc, const CullRegion* cull, const CullIndex* index, const CullPlan* plan) { Compiler c; @@ -1767,7 +1816,7 @@ Tape compile_document(const Document& doc, const CullRegion* cull, const CullInd // caller can want. if (index && index->document() != &doc) index = nullptr; c.index = index; - c.plan = cull && index ? plan : nullptr; + c.plan = usable_plan(cull, index, plan); c.run(doc, cull); c.tape.compile_id = next_compile_id(); return std::move(c.tape); @@ -1787,7 +1836,7 @@ Tape compile_document_resumable(const Document& doc, TapeCheckpoint* out_checkpo // whole-document tape. if (index && index->document() != &doc) index = nullptr; c.index = index; - c.plan = cull && index ? plan : nullptr; + c.plan = usable_plan(cull, index, plan); c.run(doc, cull); c.tape.compile_id = next_compile_id(); if (out_checkpoint) *out_checkpoint = c.checkpoint; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 526d6724..6b8bf9e8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -181,6 +181,7 @@ add_executable(clay_unit_tests unit/test_c_mask_from_group.cpp unit/test_volume_cull.cpp unit/test_deformer_cull.cpp + unit/test_squashed_cull.cpp unit/test_layer_extent_cache.cpp unit/test_layer_placement.cpp unit/test_computed_placement.cpp diff --git a/tests/unit/test_squashed_cull.cpp b/tests/unit/test_squashed_cull.cpp new file mode 100644 index 00000000..af00362a --- /dev/null +++ b/tests/unit/test_squashed_cull.cpp @@ -0,0 +1,326 @@ +// A squashed placement is culled for the field it emits, not for a distance +// (issue #649, mechanism B). +// +// A per-axis scale -- an item's or a layer's -- makes the field a BOUND on the +// distance rather than the distance: the tape evaluates at `p / s` and +// multiplies back by min(s) (`cscale_nu_dist`), so the value can be short of +// the true distance by up to q = max(s) / min(s). The per-brick cull argued +// "the item's bound is more than band + pad from the brick, so its field there +// is more than band + pad", and that holds only out to q * (band + pad) for a +// squashed one. A brick in between dropped the item although its field was +// inside the band there, and the refill disagreed with clay_eval_points. +// +// Driven through the C ABI refill and clay_eval_points, so the same source +// compiled against the pre-fix library and failed there: random documents in +// `undo_bound_oracle_probe` (seeds 5743 and 6290, `rich`) shrink to this. + +#include + +#include +#include +#include +#include + +#include "clay.h" +#include "clay/scene/bounds.h" +#include "clay/scene/cull_index.h" +#include "clay/scene/tape.h" +#include "kernel_utils.h" +#include "scene_utils.h" + +namespace { + +constexpr float kVoxel = 0.05f; +constexpr int kDim = 8; +constexpr std::size_t kSamples = 8 * 8 * 8; +constexpr float kBand = 3.0f * kVoxel; +constexpr float kWidth = kVoxel * static_cast(kDim); + +struct Doc { + clay_document* d = nullptr; + clay_layer_id layer = 0; + Doc() { + d = clay_document_create(); + REQUIRE(d != nullptr); + REQUIRE(clay_add_sdf_layer(d, "body", &layer) == CLAY_OK); + } + ~Doc() { clay_document_destroy(d); } + Doc(const Doc&) = delete; + Doc& operator=(const Doc&) = delete; +}; + +struct Ball { + float radius = 0.5f; + float x = 0.0f; + float axes[3] = {1.0f, 1.0f, 1.0f}; + int32_t blend = CLAY_BLEND_HARD; + float k = 0.0f; + float rounding = 0.0f; +}; + +clay_node_id add_ball(Doc& doc, const Ball& b, clay_node_id group = 0) { + clay_item* it = clay_item_create(CLAY_PRIM_SPHERE, &b.radius, 1); + REQUIRE(it != nullptr); + const float pos[3] = {b.x, 0.0f, 0.0f}; + REQUIRE(clay_item_set_position(it, pos) == CLAY_OK); + REQUIRE(clay_item_set_scale_nonuniform(it, b.axes) == CLAY_OK); + REQUIRE(clay_item_set_blend(it, b.blend, b.k) == CLAY_OK); + REQUIRE(clay_item_set_rounding(it, b.rounding) == CLAY_OK); + clay_node_id id = 0; + if (group) + REQUIRE(clay_layer_add_item_in_group(doc.d, doc.layer, group, -1, it, &id) == CLAY_OK); + else + REQUIRE(clay_layer_add_item(doc.d, doc.layer, it, &id) == CLAY_OK); + clay_item_destroy(it); + return id; +} + +void squash_layer(Doc& doc, float sx) { + const float pos[3] = {0.0f, 0.0f, 0.0f}; + const float axis[3] = {0.0f, 0.0f, 1.0f}; + const float scale[3] = {sx, 1.0f, 1.0f}; + REQUIRE(clay_document_set_layer_transform_nonuniform(doc.d, doc.layer, pos, axis, 0.0f, + scale) == CLAY_OK); +} + +// +X edge of the node's influence bound as the library reports it: the box the +// cull tested before this fix, with no allowance for the squash. +float influence_max_x(const Doc& doc, clay_node_id node) { + float lo[3], hi[3]; + int32_t has = 0, inf = 0; + REQUIRE(clay_layer_node_influence_bound(doc.d, doc.layer, node, lo, hi, &has, &inf) == + CLAY_OK); + REQUIRE(has == 1); + REQUIRE(inf == 0); + return hi[0]; +} + +clay_brick_request brick_at(float x, float y, float z) { + clay_brick_request r{}; + r.origin[0] = x; + r.origin[1] = y; + r.origin[2] = z; + r.spacing = kVoxel; + r.dims[0] = r.dims[1] = r.dims[2] = kDim; + r.band = kBand; + return r; +} + +float clamp_band(float v) { return v < -kBand ? -kBand : (v > kBand ? kBand : v); } + +struct Outcome { + std::size_t reached = 0; // in-band samples beyond the unsquashed band + std::size_t disagree = 0; // in-band samples the refill got wrong + float worst = 0.0f; +}; + +// A row of bricks along +X whose band-dilated boxes all MISS the node's +// unsquashed bound -- every one of them was culled before -- refilled in one +// batch (so through the cull index's plan), and every in-band sample compared +// with the document's own raw field. +Outcome refill_past(const Doc& doc, float edge) { + std::vector reqs; + const float x0 = edge + kBand + 0.01f; + for (int i = 0; i < 3; ++i) + for (float y : {-kWidth * 0.5f, -kWidth * 1.5f, kWidth * 0.5f}) + reqs.push_back(brick_at(x0 + kWidth * static_cast(i), y, -kWidth * 0.5f)); + std::vector values(reqs.size() * kSamples); + REQUIRE(clay_brick_cache_eval_requests(doc.d, nullptr, reqs.data(), reqs.size(), + values.data(), values.size(), nullptr, 0) == CLAY_OK); + + std::vector pts; + pts.reserve(values.size() * 3); + for (const clay_brick_request& r : reqs) + for (int k = 0; k < kDim; ++k) + for (int j = 0; j < kDim; ++j) + for (int i = 0; i < kDim; ++i) { + pts.push_back(r.origin[0] + kVoxel * static_cast(i)); + pts.push_back(r.origin[1] + kVoxel * static_cast(j)); + pts.push_back(r.origin[2] + kVoxel * static_cast(k)); + } + std::vector raw(values.size()); + REQUIRE(clay_eval_points(doc.d, nullptr, pts.data(), raw.size(), raw.data(), nullptr) == + CLAY_OK); + + Outcome out; + for (std::size_t s = 0; s < raw.size(); ++s) { + if (std::fabs(raw[s]) > kBand) continue; + if (pts[s * 3] > edge + kBand) ++out.reached; + const float err = std::fabs(clamp_band(values[s]) - clamp_band(raw[s])); + if (err > 1e-4f) ++out.disagree; + if (err > out.worst) out.worst = err; + } + return out; +} + +void check(const Outcome& o) { + // The field really is inside the band out there -- otherwise every brick + // would agree because there was nothing to drop, and this would pass on + // the code it is meant to catch. + REQUIRE(o.reached > 0); + INFO("in-band samples off: " << o.disagree << ", worst " << o.worst); + CHECK(o.disagree == 0); +} + +} // namespace + +TEST_CASE("squashed cull: an item's per-axis scale keeps it in the bricks its field reaches") { + Doc doc; + Ball b; + b.axes[0] = 4.0f; // q = 4: the field at +X is a quarter of the distance + const clay_node_id id = add_ball(doc, b); + check(refill_past(doc, influence_max_x(doc, id))); +} + +TEST_CASE("squashed cull: a layer's per-axis scale keeps its items in the bricks they reach") { + Doc doc; + const clay_node_id id = add_ball(doc, Ball{}); + squash_layer(doc, 4.0f); + check(refill_past(doc, influence_max_x(doc, id))); +} + +TEST_CASE("squashed cull: both levels, blended, rounded and grouped") { + // q = 2 * 2. The item's own reach (rounding and blend support) and the + // group's blend support are reached at q times their width too, and the + // smooth chain gives the cull a pad, so every term of the dilation is live. + Doc doc; + Ball base; + base.radius = 0.3f; + base.x = -0.4f; + add_ball(doc, base); + clay_node_id group = 0; + REQUIRE(clay_layer_add_group(doc.d, doc.layer, 0, -1, CLAY_OP_ADD, CLAY_BLEND_QUADRATIC, 0.04f, + 0.0f, &group) == CLAY_OK); + Ball b; + b.axes[0] = 2.0f; + b.blend = CLAY_BLEND_QUADRATIC; + b.k = 0.06f; + b.rounding = 0.02f; + add_ball(doc, b, group); + squash_layer(doc, 2.0f); + check(refill_past(doc, influence_max_x(doc, group))); +} + +// -- the planned path widens exactly as the walk does ------------------------ +// +// The cull index caches each node's squash beside its bound, and the coarse +// plan widens a chain holding one by the widest entry's widening at the band it +// was planned for. Both are accelerations, so a planned compile must be +// BYTE-IDENTICAL to the plain one -- and a plan made for a narrower band than +// the region carries must be refused rather than trusted. + +namespace { + +using namespace clay; +using kernel::cf3; +using kernel::cfloat3; + +void require_same_tape(const scene::Tape& a, const scene::Tape& b) { + REQUIRE(a.instrs.size() == b.instrs.size()); + for (std::size_t i = 0; i < a.instrs.size(); ++i) { + REQUIRE(a.instrs[i].op == b.instrs[i].op); + REQUIRE(a.instrs[i].param_offset == b.instrs[i].param_offset); + } + REQUIRE(a.params == b.params); + REQUIRE(a.blob == b.blob); +} + +// gnarly_document with its body layer squashed and items squashed at the root, +// inside the nested groups and in the instanced copy that shares them. +scene::Document squashed_gnarly() { + scene::Document doc = clay_test::gnarly_document(); + scene::Layer& body = doc.layers[0]; + body.scale_axes = cf3(2.5f, 0.6f, 1.0f); + scene::SdfContent& c = *body.sdf; + int touched = 0; + for (const auto& [id, n] : c.nodes()) { + if (n.is_group || (id % 3) != 0) continue; + c.find_mut(id)->scale_axes = cf3(0.4f, 1.0f, 2.0f); + ++touched; + } + REQUIRE(touched >= 3); + return doc; +} + +// Brick-sized boxes over the document. Each is planned ALONE: a plan over the +// union of scattered bricks covers the whole document and prunes nothing, and +// then a coarse cull that forgot the squash would go unseen. +std::vector random_bricks(std::uint64_t seed) { + clay_test::Lcg rng(seed); + std::vector out; + for (int b = 0; b < 64; ++b) { + const cfloat3 corner = rng.vec3(-3.0f, 3.0f); + out.push_back(math::Aabb{corner, corner + cf3(0.4f, 0.4f, 0.4f)}); + } + return out; +} + +} // namespace + +TEST_CASE("squashed cull: a planned compile widens exactly as the plain one does") { + const scene::Document doc = squashed_gnarly(); + const float band = 0.15f; + const scene::Tape full = scene::compile_document(doc); + const scene::CullIndex index(doc); + + clay_test::Lcg rng(6490); + std::size_t in_band = 0; + for (const math::Aabb& brick : random_bricks(649)) { + const scene::CullRegion cull{brick.dilated(band), band}; + const scene::CullPlan plan = index.plan(cull.region, band); + // Planned for no band: a region that carries one cannot be served by + // it, and the compile walks instead. + const scene::CullPlan narrow = index.plan(cull.region); + CHECK(plan.serves_band(band)); + CHECK_FALSE(narrow.serves_band(band)); + const scene::Tape plain = scene::compile_document(doc, &cull); + require_same_tape(plain, scene::compile_document(doc, &cull, &index, &plan)); + require_same_tape(plain, scene::compile_document(doc, &cull, &index, &narrow)); + for (int i = 0; i < 64; ++i) { + const cfloat3 p = cf3(rng.range(brick.min.x, brick.max.x), + rng.range(brick.min.y, brick.max.y), + rng.range(brick.min.z, brick.max.z)); + const float df = kernel::cclamp(full.eval(p).d, -band, band); + if (kernel::cabs(df) < band) ++in_band; + CHECK(df == kernel::cclamp(plain.eval(p).d, -band, band)); + } + } + REQUIRE(in_band > 0); +} + +TEST_CASE("squashed cull: a document with no per-axis scale culls as it always did") { + // The squash is zero for every similarity, so the band a region carries + // cannot change a single decision: the tape is the one a region without + // a band compiles, byte for byte, planned or not. + const scene::Document doc = clay_test::gnarly_document(); + const float band = 0.15f; + const scene::CullIndex index(doc); + for (const math::Aabb& brick : random_bricks(652)) { + const scene::CullRegion bare{brick.dilated(band)}; + const scene::CullRegion banded{brick.dilated(band), band}; + const scene::CullPlan plan = index.plan(banded.region); + CHECK(plan.serves_band(1e9f)); // nothing to widen, so any band is served + const scene::Tape before = scene::compile_document(doc, &bare); + require_same_tape(before, scene::compile_document(doc, &banded)); + require_same_tape(before, scene::compile_document(doc, &banded, &index, &plan)); + } +} + +TEST_CASE("squashed cull: the squash is zero for a similarity and q - 1 otherwise") { + scene::Layer layer; + scene::Node n; + n.prim = scene::Prim::sphere(0.5f); + CHECK(scene::item_cull_squash(n, layer).none()); + n.scale_axes = cf3(2.0f, 2.0f, 2.0f); // uniform: still a similarity + CHECK(scene::item_cull_squash(n, layer).none()); + + n.scale_axes = cf3(1.0f, 4.0f, 2.0f); + n.blend = scene::Blend{scene::BlendProfile::Quadratic, 0.1f}; + const scene::CullSquash item = scene::item_cull_squash(n, layer); + CHECK(item.slope == doctest::Approx(3.0f)); + CHECK(item.reach > 0.0f); // the blend support is reached three times further too + + // The two levels multiply: q = 4 at the item and 2 at the layer. + layer.scale_axes = cf3(1.0f, 1.0f, 2.0f); + CHECK(scene::item_cull_squash(n, layer).slope == doctest::Approx(7.0f)); +}