feat(animation): shatter breaks a node's own render into flying Voronoi shards - #408
Merged
Merged
Conversation
…ronoi shards Adds AnimationEffect::Shatter (schema/video.rs) and its paint_pass implementation, closing #378. The node's own subtree is rasterised once into a dedicated offscreen surface sized to its box (same "capture pixels, read them back" move as paint_inflated_material), then cut into a deterministic Voronoi partition seeded by `seed` (jittered grid seed points, Sutherland-Hodgman half-plane clipping against every other seed — no external geometry crate). Each cell gets a seeded direction/travel/ spin/depth and is drawn back onto the real canvas with its own transform and clip, in that order: transform first, clip second, or the mask stays put while the pixels slide under it — a real bug caught by shatter_paints_ink_outside_the_nodes_own_box_where_an_intact_node_does_not during development (confirmed red before the fix, green after). paint_node's post-transform body is extracted into paint_node_visual so both the plain path and the shattered offscreen capture share the exact same painting code; the capture pass disables hit registration (children aren't sane click targets mid-shatter) without touching PaintContext's shape. mode: "out"/"in" hard-short-circuit to "no effect at all" outside [delay, delay+duration) — same guarantee chromatic_aberration and zoom_blur already give, verified by reverting shatter_progress's cutoff and watching the pixel-identity tests fail. mode: "hold" is the deliberate exception: it freezes at full dispersion and never reconverges. entrance_budget (validate_schema.rs) gets the new arm the exhaustive match required; shift_delay (schema/video.rs) does too. crates/rustmotion/skills/rules/shatter.md documents the vocabulary, the mode/zero-at-ends contract, and the transform-before-clip pitfall for future readers.
The implementation left the rule file unindexed. It also left out the one thing an author gets wrong first: shatter reads like an exit preset but is not one of the exempted names, so the completion budget applies to it in full -- a shatter meant to land on the cut must satisfy delay + duration == scene_duration, not overrun it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #378.
The shape
shatteris an animation effect that cuts a node's already-painted render —background, border, children, the whole subtree — into deterministic Voronoi
cells and flies each piece away from an origin point, with its own rotation,
scale and fade.
{ "name": "shatter", "delay": 0.9, "duration": 1.0, "mode": "hold", "pieces": 32, "seed": 11, "spread": 1.4, "spin": 140, "depth": 0.5 }modedecides which end of the window is the intact node:"out"(assembled →dispersed),
"in"(the mirror),"hold"(disperses and freezes there insteadof converging back).
How the node's pixels are obtained
paint_node's whole post-transform body was extracted verbatim intopaint_node_visual, so the plain path and the shatter path run identicalpainting code — no second implementation to diverge.
paint_nodenow onlydispatches on
active_shatter.The shatter path paints that same function into a dedicated raster surface the
size of the node's own box, in local coordinates (the move
paint_inflated_material/silhouette_alpha_fieldalready make), then draws thesingle captured image through each shard's clip. The capture runs with
hits: Noneso children don't register hit rects mid-flight; the node's own rectstays registered, exactly as if it weren't breaking.
Cost, release, 120×120 box on a 400×400 canvas: ~135µs/frame plain, ~158µs at 16
pieces, ~200µs at the 64-piece ceiling — and literally zero outside the window,
since that branch never runs.
Zero at both ends, as a branch and not as a limit
shatter_progressreturnsNoneoutside[delay, delay + duration)inoutand
in. That is a different code path (straightpaint_node_visual, no raster,no clipping), not the same function evaluated at a boundary. Reverting it to a
.clamp(0.0, 1.0)— "a fade that gets small" — turns 5 of the 9 tests red,including the pixel-identity ones.
A real bug the tests caught
The first version clipped each shard before transforming it, so the mask
stayed at the cell's original position while the image slid underneath: shards
never visually moved, only their content shifted inside a static hole.
Transform-then-clip fixes it, and
shatter_paints_ink_outside_the_nodes_own_box_where_an_intact_node_does_notgoes red on the revert.
Determinism
Every shard's geometry, direction, spin sign/magnitude and depth comes from a
pure
(seed, index, salt)hash. InjectingSystemTime::now().subsec_nanos()into that hash immediately fails
shatter_two_renders_of_the_same_instant_are_byte_identical.Two traps, both documented in
rules/shatter.mdoriginis a fraction of the box, not pixels — unliketransform-origin.Pixels there are not a schema error, just a degenerate origin outside the box,
so every shard leaves in nearly the same direction instead of radiating.
shatteris budget-checked. It reads like an exit preset but is not one ofthe exempted names, so
delay + duration ≤ scene_durationapplies.Gate
cargo fmt --all --checkclean ·cargo clippy --workspace --all-targets --features rustmotion/studio -D warningsclean ·cargo test --workspace1749 passed · CLI probe validates and renders (intact beforedelay, shards mid-flight revealing what is behind, gone after).