Skip to content

fix(motion-blur): ghost placement, path transforms, velocity-oriented smear, camera shutter - #452

Merged
LeadcodeDev merged 1 commit into
mainfrom
fix/motion-blur-427
Sep 29, 2026
Merged

LeadcodeDev merged 1 commit into
mainfrom
fix/motion-blur-427

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #427. Part of #438.

The six items of the issue. Five of them share a root cause worth stating once: a displacement the layout and paint passes could not see.

pointer moved by path is never ghosted

The diagnosis in the issue thread (per-ghost clocks not threaded) was wrong — legacy_dispatch.rs already gives each ghost its own remapped ctx.time, and the existing ghost_of_a_path_driven_pointer_samples_a_different_waypoint_offset already proved it.

The real cause: Pointer::paint_content positioned itself with an ad-hoc canvas.translate computed from self.path and ctx.time. paint_pass.rs opens a save_layer for any node at opacity < 1 — so every ghost, always — and bounds it on the node's untransformed layout box. A CSS transform is applied to the canvas matrix before those bounds are computed and is therefore accounted for; an offset applied inside paint_content is not, and gets clipped to a box at the origin. Only the fully opaque principal, which never gets a bounded layer, painted correctly.

box_builder.rs now resolves waypoint_offset while building each node — principal and ghost alike — and injects it as style.transform: translate(dx, dy) at that node's own instant. paint_content no longer does the computation.

This is a general trap, and it is now written down: any Painter that self-positions from ctx.time instead of style.transform will silently clip under opacity < 1.

Ghosts of in-flow nodes are misplaced

ghost_css_for forces position: absolute but only sets left/top when the node is already absolutely positioned. An in-flow node's ghost gets no inset, so taffy free-positions it by the container's justify-content/align-items — in a row, on the first item's slot, which is the "a ghost World is painted over Hello" report.

BuiltScene gains ghost_principal: Vec<(NodeId, NodeId)>, and apply_ghost_layout_fixup (render/scene.rs, wired into the frame path and studio hit-testing) shifts each ghost — and its whole subtree by the same delta — onto the position taffy resolved for its principal. Exact rather than approximate, including under space-between and between siblings of different sizes; the "may differ slightly in an asymmetric layout" caveat in the rule file is gone.

Rejected: wrapping each ghosted child in a synthetic position: relative container (would split layout-affecting from paint-affecting CSS and risks changing flex sizing for every ghost-capable component), and a second run_layout pass (a full taffy pass per frame instead of a map lookup).

mode: "smear" is axis-aligned

It measured |dx| and |dy| separately and emitted Blur { radius-x, radius-y }, so a 45° move gave an isotropic blob. Now DirectionalBlur { angle: atan2(dy, dx).to_degrees(), radius: hypot(dy, dx) }, which paint_pass.rs already knew how to paint.

radius_x / radius_y

#[serde(alias = ...)] on both fields. The exported schema needs the entry under FilterFn, not Blur: FilterFn is #[serde(tag = "fn")], so schemars inlines every variant into its oneOf and there is no standalone "Blur" definition for widen_properties_with to find.

The alias scan is tightened while here. It checked whether an alias string appeared anywhere in the schema text, which radius_x already did — HaloZone::radius_x is a real, unrelated snake_case field. When the source attribute carries a rename, the test now requires the alias to sit in the same properties object as its canonical. Verified to fail on the missing entry:

radius_x (declared in crates/rustmotion-core/src/css/style.rs, expected beside radius-x)

whip streak is stepped

WHIP_STEPS 10 → 120. Two other shapes were tried and rejected against tests/whip.rs::a_bigger_strength_streaks_further:

  • A real Skia directional blur. Blurring the image's own RGB content dilutes peak intensity for a narrow high-contrast feature under a wide kernel, so a larger strength shrank the measured streak front (37 → 23).
  • A reach-adaptive step count (steps = reach_px.ceil() + 1). It gives the two compared strength values different sampling densities (33 vs 129), and repeated "over" composites saturate non-linearly, so denser sampling at a larger reach does not scale the same way.

A fixed count keeps both runs at the same density in t-space. 120 costs 240 draw_image calls per transition frame, about 10 ms at 1920×1080 — roughly 0.2 s on a 0.6 s whip, and only transition frames pay it.

Camera motion blur

camera.motion_blur: { samples, shutter }, opt-in. Each frame resolves the camera pose — x, y, zoom, rotation, plus scene.shake — at both ends of a shutter window ending at the frame's own time. If they match, the frame takes the ordinary single-render path. Otherwise it renders samples exposures at midpoint-rule sub-times and averages the RGBA bytes.

Cost, measured at 1920×1080 on a scene with a grid background, three shapes and two text blocks, taking the slope over 180 frames so process startup drops out:

per frame
without 4.1 ms
samples: 8, shutter: 1.0, camera panning and zooming 8.8 ms

2.2× end to end, not 8×: encode and frame setup are paid once per frame whatever the render costs. The render portion does scale by samples, so the ratio climbs toward it as the scene gets heavier. That is the number #362 was parked on.

It lives in render_frame_v2_scaled (render/scene.rs) rather than the encode loop the issue suggested — render/mod.rs re-exports through an explicit named list, and tasks.rs already funnels through that one function, so it needed no change and apply_post_effects still runs once on the composited frame rather than once per sub-sample.

Content resamples with the camera, because both share one SceneTime. Decoupling a camera-only time channel would double the time-plumbing surface for a benefit a physical shutter does not have.

Verification

Every item verified end to end as the issue's definition of done asks — scenario JSON, rustmotion validate, render, image inspected — not only by unit test:

  • the pointer trails along its path behind the live cursor;
  • Hello World in a row, motion_blur on World: no pink ghost over Hello;
  • a box travelling 1200×700 px smears along that diagonal, edges across the motion staying sharp;
  • a 900 px pan with samples: 8 smears the shot, and the animated-background stays crisp because it is screen-space and does not pan (noted in the rule file).

Nine tests added, each verified to fail when its own fix is reverted. cargo fmt --all --check, cargo clippy --workspace --all-targets --features rustmotion/studio -- -D warnings, and cargo test --workspace --features rustmotion/studio (2121 tests) all pass.

Docs

camera-motion-blur.md is new. motion-blur-and-trail.md, directional-blur.md, whip-transition.md and camera-3d.md are rewritten in English per the repository rule, with the behaviour changes folded in. whip-transition.md also now states that the transition goes on the scene being entered — putting it on the first scene is silently ignored with a warning, which cost a debugging round here.

Out of scope, filed separately

#451 — the viewport clip is issued after the camera transform, so the clip rectangle travels with the camera and a pan wider than the frame renders blank. Reproduced at 1920×1080. Pre-existing and unrelated to motion blur, though camera.motion_blur inherits the same ceiling since each sub-frame goes through that path.

… smear, camera shutter

Closes #427. Part of #438. The six items of the issue, and the one they had
in common: a displacement the layout and paint passes could not see.

`pointer.path` used to resolve its own offset inside `paint_content` with an
ad-hoc `canvas.translate`. The opacity layer the engine opens for any node at
`opacity < 1` — so every ghost, always — bounds its `SaveLayerRec` on the
node's untransformed layout box, so that translate was clipped away and only
the fully opaque principal painted. `box_builder` now resolves `waypoint_offset`
while building each node and injects it as `style.transform`, the channel
keyframed `translate_x`/`translate_y` already use, which is applied before
those bounds are computed. Principal and ghost push it at the same point in
the transform list, so the two compose identically.

An in-flow node's ghost is forced to `position: absolute` with no inset, so
taffy free-positioned it by the container's alignment — in a row, on top of
the first item. `BuiltScene` now records each ghost against its principal, and
`apply_ghost_layout_fixup` shifts the ghost and its whole subtree onto the
position taffy resolved for that principal. Exact, not approximate, including
under `space-between`.

`mode: "smear"` measured |dx| and |dy| separately and emitted an axis-aligned
`Blur`, so diagonal travel gave an isotropic blob. It now emits
`DirectionalBlur { angle: atan2(dy, dx), radius: hypot(dy, dx) }`.

`radius_x`/`radius_y` parse as aliases of `radius-x`/`radius-y`. The exported
schema needs the matching table entry under `FilterFn`, not `Blur`: schemars
inlines an internally tagged enum's variants, so there is no standalone `Blur`
definition to widen. The alias scan is tightened to match, and now requires
an alias to sit beside its renamed canonical rather than merely to appear
somewhere in the schema text — `radius_x` already existed as `HaloZone`'s own
field, which the old substring check could not tell apart.

`whip` stamped ten translated copies, visibly stepped mid-transition.
`WHIP_STEPS` goes to 120. Keeping it fixed rather than scaling it to `reach`
is what keeps two `strength` values comparable: repeated "over" composites
saturate non-linearly, so denser sampling at a larger reach does not scale the
same way, and `a_bigger_strength_streaks_further` fails under both a
reach-adaptive count and a real Skia blur filter.

`camera.motion_blur: { samples, shutter }` renders `samples` sub-frame
exposures over a shutter window ending at the frame's time and averages them.
It lives in `render_frame_v2_scaled`, not the encode loop, so `apply_post_effects`
still runs once on the composited frame. A frame whose camera pose does not
change across the window takes the ordinary single-render path. Measured at
1920x1080 over 180 frames: 4.1 ms/frame without, 8.8 ms/frame at `samples: 8` —
2.2x end to end rather than 8x, because encode and frame setup are paid once
whatever the render costs.

The four rule files this touches are rewritten in English, per the repository
rule; `camera-motion-blur.md` is new. #451 records a pre-existing bug found on
the way: the viewport clip is applied after the camera transform, so a pan
wider than the frame renders blank.
@LeadcodeDev LeadcodeDev self-assigned this Sep 29, 2026
@LeadcodeDev
LeadcodeDev merged commit 0167ac4 into main Sep 29, 2026
4 checks passed
@LeadcodeDev
LeadcodeDev deleted the fix/motion-blur-427 branch September 29, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Motion blur: pointer paths, in-flow ghost placement, velocity-oriented smear, smooth whip, camera motion blur

1 participant