Skip to content

feat(animation): shatter breaks a node's own render into flying Voronoi shards - #408

Merged
LeadcodeDev merged 2 commits into
mainfrom
feat/shatter-effect
Sep 28, 2026
Merged

LeadcodeDev merged 2 commits into
mainfrom
feat/shatter-effect

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #378.

The shape

shatter is 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 }

mode decides which end of the window is the intact node: "out" (assembled →
dispersed), "in" (the mirror), "hold" (disperses and freezes there instead
of converging back).

How the node's pixels are obtained

paint_node's whole post-transform body was extracted verbatim into
paint_node_visual, so the plain path and the shatter path run identical
painting code — no second implementation to diverge. paint_node now only
dispatches 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_field already make), then draws the
single captured image through each shard's clip. The capture runs with
hits: None so children don't register hit rects mid-flight; the node's own rect
stays 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_progress returns None outside [delay, delay + duration) in out
and in. That is a different code path (straight paint_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_not
goes red on the revert.

Determinism

Every shard's geometry, direction, spin sign/magnitude and depth comes from a
pure (seed, index, salt) hash. Injecting SystemTime::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.md

  • origin is a fraction of the box, not pixels — unlike transform-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.
  • shatter is budget-checked. It reads like an exit preset but is not one of
    the exempted names, so delay + duration ≤ scene_duration applies.

Gate

cargo fmt --all --check clean · cargo clippy --workspace --all-targets --features rustmotion/studio -D warnings clean · cargo test --workspace 1749 passed · CLI probe validates and renders (intact before delay, shards mid-flight revealing what is behind, gone after).

…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.
@LeadcodeDev LeadcodeDev added the enhancement New feature or request label Sep 28, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 28, 2026
@LeadcodeDev
LeadcodeDev merged commit 017e80f into main Sep 28, 2026
4 checks passed
@LeadcodeDev
LeadcodeDev deleted the feat/shatter-effect branch September 29, 2026 18:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A shatter effect: break an element's render into fragments that fly apart

1 participant