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
11 changes: 11 additions & 0 deletions bindings/c/clay.h
Original file line number Diff line number Diff line change
Expand Up @@ -11665,6 +11665,17 @@ clay_result clay_voxel_build_plane_pick(const clay_voxel_grid* grid, const float
* an edit reaches. That is the promise a host most depends on and the one that
* is silent when it breaks: too tight leaves visibly stale bricks at a blend
* seam, with nothing on the host's side to point at. */
/* NOTE (issue #650): this is also DILATED BY THE SMOOTH COMBINES AFTER THE NODE
* in its chain -- at each level, the siblings that follow it or the group
* holding it -- by the widest one's blend support. The node is the running
* value wherever it is the nearest thing, so moving it changes that value far
* from its own box, and a smooth sibling after it reads the running value out
* to its support and carries the change back into the band. Measured before:
* two r = 0.3 spheres, the second smooth at k = 0.3, the first moved 0.1 --
* band samples moved by up to 0.044 outside the box plus the band, and a cache
* dirtied by it kept 25 stale bricks. Nothing widens for a node with only hard
* siblings after it, which includes every node appended last -- a stroke's
* dabs -- and every document without a smooth blend. */
clay_result clay_layer_node_influence_bound(const clay_document* doc, clay_layer_id layer,
clay_node_id node, float out_min[3],
float out_max[3], int32_t* out_has_bounds,
Expand Down
6 changes: 5 additions & 1 deletion bindings/c/clay_c.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7756,6 +7756,10 @@ void drag_frontier(const clay_document* doc, const scene::Layer& layer,
// parameter edit to the same node_command_bound and never looks at it.
scene::Command probe_cmd{scene::SetDeformersCmd{layer.id, scene::kNoNode, {}}};
auto& probe = std::get<scene::SetDeformersCmd>(probe_cmd);
// One memo for the loop, for the same reason: each node's bound walks the
// siblings after it (#650), and without it a 1,428-warp drag over 10,000
// items spent 65 ms there against 0.39 before that term existed.
scene::LayerExtent memo;
for (const brush::PreparedMove& p : prepared) {
std::uint32_t ordinal = 0;
if (!root_ordinal_of(*layer.sdf, p.node, &ordinal)) {
Expand All @@ -7764,7 +7768,7 @@ void drag_frontier(const clay_document* doc, const scene::Layer& layer,
}
df.min_ordinal = std::min(df.min_ordinal, ordinal);
probe.node = p.node;
const math::Aabb bound = scene::command_influence_bound(d, probe_cmd);
const math::Aabb bound = scene::command_influence_bound(d, probe_cmd, &memo);
if (bound.empty()) continue;
df.spans.emplace_back(ordinal, bound);
}
Expand Down
41 changes: 41 additions & 0 deletions docs/05-claycore-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,47 @@ What remains is one walk per query rather than none: `apply_edit` takes
two. Removing that needs a revision-scoped cache on the ABI's document, which
`clay_document::cached()` is already shaped for.

### An edit reaches as far as the smooth combines after it (#650)

A node's own bound says where ITS combine can move the running value. That is
not where its RAW value stops changing: the first node of a chain has no combine
at all -- it is the running value -- and any node is the running value wherever
it is the nearest thing, so moving it changes that value far from its box.
Beyond the band a hard union ignores that, `min()` being exact. A SMOOTH combine
further down does not: it reads the running value out to its support and lowers
the result by the blend, so a difference beyond the band on both sides comes
back inside it. Two r = 0.3 spheres, the second smooth at k = 0.3, the first
moved 0.1: band samples moved by up to 0.044 outside the moved node's box plus
the band, and a cache dirtied by that box kept 25 stale bricks. Seed 5128 of
`benchmarks/undo_bound_oracle_probe` is the same mechanism at one ulp -- a
grab's eased rim changes the node's value far from its surface, and the next
sibling's blend carries it into the band.

`node_reach_bound` therefore dilates, at each level before the enclosing group's
own support, by the drag of the combines after the node in that chain: the
chain pad's terms (`cull_pad_terms`) over the later siblings, resolved at each
profile's FULL support rather than at the chain envelope the cull uses -- one
blend provably reaches its whole support, and the envelope left 0.012 moves on
the fixture above. A later sibling GROUP contributes its own combine only, since
its children start a chain of their own. Nothing widens for a node with only
hard siblings after it, which is every node appended last and every document
without a smooth blend.

The head narrowing of #639 does not take the term: a link that is the identity
outside its ball leaves the RAW field bit-identical there, and every combine is
pointwise. What it missed was the clamp into the node's bound, which now holds
the drag. Over 400 plain and 400 rich trials of the oracle the raw-bound
violations went 0 / 10 to 0 / 0 and the bricks the undo bounds refill rose 2.5%
and 2.8%.

The walk is over the LATER siblings, so a loop over a chain's nodes paid it once
per node: a drag frontier over 1,428 warps of a 10,000-item layer went 0.39 ->
65 ms, and undoing a 2,134-warp Move there 0.85 -> 94.6 ms. `ChainDragMemo`
holds the chain's suffix maxima, filled from the end only as far as a query
asks; `LayerExtent` owns one per query, the drag frontier threads one through
its loop, and an undo step's replay shares one across its commands, clearing it
after any command but a deformer or colour edit (0.45 and 1.04 ms).

### ...and a MOVE of one is bounded by its sweep

The layer-wide answer above is right for the question it answers and ruinous as
Expand Down
82 changes: 80 additions & 2 deletions include/clay/scene/bounds.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
// field arbitrarily far away, so their influence is infinite.

#include <cstddef>
#include <memory>
#include <optional>
#include <unordered_map>
#include <vector>

#include "clay/math/geom.h"
#include "clay/scene/document.h"
Expand Down Expand Up @@ -197,6 +200,8 @@ class LayerExtentCache {
std::size_t keeps_ = 0;
};

class ChainDragMemo;

class LayerExtent {
public:
LayerExtent() = default;
Expand All @@ -205,6 +210,9 @@ class LayerExtent {
// (#451). Every caller that already threads a LayerExtent gets that for
// free; nothing else changes.
explicit LayerExtent(LayerExtentCache* cache) : cache_(cache) {}
// Answering downstream_drag from a memo that outlives this query -- one
// the caller keeps valid across several (see ChainDragMemo).
explicit LayerExtent(ChainDragMemo* drags) : drags_(drags) {}

const math::Aabb& of(const SdfContent& content, const Layer& layer) {
if (cache_) return cache_->of(content, layer);
Expand All @@ -225,12 +233,19 @@ class LayerExtent {
// here rather than derived from a timing.
std::size_t walks() const { return walks_; }

// The drag of the combines after a node, memoized for the query
// (ChainDragMemo::after). Borrowed when the extent was given one.
float downstream_drag(const SdfContent& content, const Layer& layer, NodeId parent,
int index);

private:
LayerExtentCache* cache_ = nullptr;
ChainDragMemo* drags_ = nullptr;
const SdfContent* content_ = nullptr;
const Layer* layer_ = nullptr;
math::Aabb extent_;
std::size_t walks_ = 0;
std::shared_ptr<ChainDragMemo> own_drags_;
};

// World-space INFLUENCE bound: the geometry bound for local ops, the LAYER's
Expand Down Expand Up @@ -413,6 +428,65 @@ struct CullPadTerms {
// on a single pad float stays valid (#362).
float blend_total(std::size_t n_eff) const;
float total(std::size_t n_eff) const { return feather + blend_total(n_eff); }
// The item maxima at each profile's full SUPPORT: the pre-#335 pad, and the
// ceiling blend_total clamps its seam term to.
float item_support_ceiling() const;
// Every term at its full support, seam included, plus the feather: how far
// ONE of these combines can move a result from where its running operand
// changed. What the reach of an edit takes for the combines after it
// (bounds.cpp, downstream_chain_drag); the cull keeps the envelope.
float support_total() const;
};

// How far the combines after position `index` in the chain `parent` holds (the
// layer's roots for kNoNode) can drag a change to the running value the node
// there fed (#650): the term node_reach_bound adds per level, as per-chain
// suffix maxima of `cull_pad_terms` resolved at full support. The same number
// the unmemoized walk gives, bit for bit.
//
// A walk over the LATER siblings, which a loop over a chain's nodes pays once
// per node: a drag frontier resolving 1,428 dragged items of a 10,000-item
// layer went 0.39 -> 65 ms, and undoing a 2,134-warp Move over the same count
// 0.85 -> 94.6 ms. Filled from the END and only as far forward as a query asks,
// so a loop walks each chain once -- and a node appended last, which is what a
// stroke's dabs are, walks nothing at all.
//
// VALID WHILE NO CHAIN'S MEMBERS OR THEIR TERMS CHANGE: order, visibility, op,
// blend, mirror participation, a feathered volume's band or scale, the layer's
// symmetry. An edit to a node's deformers or colour touches none of them, which
// is what lets an undo keep one across a whole Move step
// (command_keeps_chain_drag); anything else calls clear().
class ChainDragMemo {
public:
float after(const SdfContent& content, const Layer& layer, NodeId parent, int index);
void clear() {
chains_.clear();
content_ = nullptr;
layer_ = nullptr;
}

private:
// Per chain, the suffix maxima REVERSED: `[j]` is the last j members'
// terms raised together, so `[0]` is none.
//
// A FLAT LIST, searched linearly, and each chain's vector reserved to its
// full length on first use. Not a hash map, and not grown a member at a
// time: this memo is built on every surface drag, and the drag's
// per-item allocation gate (test_sculpt_allocation.cpp) is what caught
// the difference -- an unordered_map plus a vector regrown across a
// 400-node chain added 13 allocations per drag, and MSVC's unordered_map
// allocates even when empty, which took the gate from under 6.0 per item
// to 6.0625 there alone. A query touches one or two chains, and an undo
// step that keeps the memo across commands keeps it on one.
struct Chain {
NodeId parent = kNoNode;
std::vector<CullPadTerms> from_end;
};
std::vector<CullPadTerms>& from_end_of(NodeId parent, std::size_t chain_length);

const SdfContent* content_ = nullptr;
const Layer* layer_ = nullptr;
std::vector<Chain> chains_;
};

// One node's contribution, so a caller that has GAINED a node can raise a
Expand Down Expand Up @@ -545,7 +619,10 @@ float document_cull_pad(const Document& doc);

// Where an edit to `id` can change the layer's field: node_influence_bound,
// dilated once per enclosing group by that group's blend support, up to the
// root.
// root -- and, at every level before that support, by the drag of the SMOOTH
// combines that follow in that chain (#650; ChainDragMemo, and bounds.cpp's
// downstream_chain_drag for why). Nothing for a node followed only by hard
// ones, which is every node appended last.
//
// This is the answer to "where does an edit to this node LAND", which is a
// different question from "where is this node" and used to be answered with
Expand Down Expand Up @@ -592,7 +669,8 @@ math::Aabb layer_reach_in_document(const Document& doc, LayerId layer_id,
// to a host as a region to dirty (issue #325).
//
// Per sharing layer this is node_reach_bound -- the node's own bound dilated
// once per enclosing GROUP -- carried the rest of the way up by
// by the smooth combines after it and once per enclosing GROUP -- carried the
// rest of the way up by
// layer_reach_in_document. `scene::node_command_bound` IS this function, so the
// query a host asks and the region the command path dirties are one expression
// and cannot drift.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
## Why

`benchmarks/undo_bound_oracle_probe` under `RV_RAWBOUND`, seed 5128 in rich
mode, found the raw field moving by an ulp at samples outside the undo bound
dilated by the band, identically on main before and after #648 (#650). The
issue suspected the group blend's support. It is not the group: the group in
that seed is the first root, so its combine never applies (#515).

The node is the FIRST item of its chain, and the item after it blends smooth at
k = 0.297. The first item of a chain has no combine -- it IS the running value
-- and any item is the running value wherever it is the nearest thing, so an
edit to it changes that value far from its own box. Beyond the band a hard
union leaves that alone (`min()` is exact). A smooth combine further down does
not: `csmin(a, b)` reads `a` wherever `|a - b| < support`, so with `b` in the
band it reads `a` out to band + support and carries the change back into the
band. In the seed, a grab's eased rim changes the node's value by an ulp inside
the grab's ball, 0.3 from the node's surface; the sibling's blend lifts that
into the band, and the undo bound -- the ball clamped into the node's bound --
stopped at the node's box.

It is not an ulp in general. Measured on main (8b7a5713), two r = 0.3 spheres
0.3 apart, the second smooth at k = 0.3, the first moved 0.1:

| | band samples moved outside box + band | worst | stale bricks, `mark_dirty_nodes` |
|---|---:|---:|---:|
| main, at the root | 6,359 | 0.0438 | 25 |
| main, in a blended group | 6,359 | 0.0438 | 25 |
| main, second at k = 0.1 | | | 8 |
| this change | 0 | 0 | 0 |

## What changes

- **`node_reach_bound` dilates by the drag of the combines after the node**, at
each level, before the enclosing group's support: `cull_pad_terms` raised
over the later siblings in that chain, resolved at each profile's full
support. A later sibling group contributes its own combine only. That makes
`clay_layer_node_influence_bound`, `clay_brick_cache_mark_dirty_nodes`, the
command path and the undo bound (through the #639 clamp) cover the fillet.
- **Nothing widens for a node with only hard combines after it** -- every node
appended last, so a stroke's dabs, and every document without a smooth blend
report bit-identical bounds.
- **`ChainDragMemo`**, per-chain suffix maxima filled from the end, owned per
query by `LayerExtent`, threaded through the drag frontier's loop and shared
across an undo step's replay.
- No ABI change; `clay.h` gains a note on `clay_layer_node_influence_bound`.

## What building it found

- **The envelope is the wrong resolution.** Resolving the later siblings' terms
at the chain envelope the cull uses (`blend_total`) left 110 band samples
moved by up to 0.0123 on the two-sphere fixture: the envelope is a k-multiple
fitted to what a CULL may drop against an fp16 tolerance, and a single blend
provably reaches its whole support. Full support: 0.
- **The #639 head narrowing needs nothing.** The first cut also dilated the
head's ball, and the oracle's refills rose 72% / 56%. A link that is the
identity outside its ball leaves the raw field bit-identical there and every
combine is pointwise, so the ball needs no drag term; only the clamp into the
node's bound was too tight. Reverted: refills rise 2.5% / 2.8%.
- **The walk is over LATER siblings, so loops paid it per node.** A drag
frontier resolving 1,428 warps of a 10,000-item smooth layer: 0.39 -> 65 ms.
Undoing a 2,134-warp Move over the same count: 0.85 -> 94.6 ms. With the
memo: 0.45 ms and 1.04 ms. The replay's memo is cleared after any command but
a deformer or colour edit; a test holds the clear, and fails without it.
- **The frontier's mirrored ridge resumes MORE.** The drag frontier prepares
prefix seeds over each dragged node's reach; under the mirror the wider reach
now spans the four corner bricks, so they resume (16 / 0) where the
unmirrored control still refuses them (12 / 4). The bit-exact parity check
against a fresh oracle holds every frame, so the test's `==` became `>=`.

## Measured on the oracle (400 trials each, `RV_RAWBOUND`)

| | raw-bound violations | stale trials | bricks refilled |
|---|---:|---:|---:|
| plain, main | 0 | 0 | 1,397,332 |
| plain, this change | 0 | 0 | 1,432,408 (+2.5%) |
| rich, main | 10 (seed 5128) | 3 | 2,394,500 |
| rich, this change | 0 | 3 | 2,461,088 (+2.8%) |

The three rich stale trials (5111, 5128, 5229) are the brick build disagreeing
with the raw field, #649's, and identical on main. `RV_FORWARD` reports 12 stale
trials on main and on this change alike.

## What it does not cover

A GATED node after the edited one mixes the running value with its own combine,
and a lerp of two beyond-band values is not beyond band, so it can carry the
difference in at any distance. `chain_carries_only_supports` already refuses it
for the intersect surface delta; this term does not model it.
Loading
Loading