Repository navigation
fix(motion-blur): ghost placement, path transforms, velocity-oriented smear, camera shutter - #452
Merged
Merged
Conversation
… 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.
This was referenced Sep 29, 2026
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 #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.
pointermoved bypathis never ghostedThe diagnosis in the issue thread (per-ghost clocks not threaded) was wrong —
legacy_dispatch.rsalready gives each ghost its own remappedctx.time, and the existingghost_of_a_path_driven_pointer_samples_a_different_waypoint_offsetalready proved it.The real cause:
Pointer::paint_contentpositioned itself with an ad-hoccanvas.translatecomputed fromself.pathandctx.time.paint_pass.rsopens asave_layerfor any node atopacity < 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 insidepaint_contentis 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.rsnow resolveswaypoint_offsetwhile building each node — principal and ghost alike — and injects it asstyle.transform: translate(dx, dy)at that node's own instant.paint_contentno longer does the computation.Ghosts of in-flow nodes are misplaced
ghost_css_forforcesposition: absolutebut only setsleft/topwhen the node is already absolutely positioned. An in-flow node's ghost gets no inset, so taffy free-positions it by the container'sjustify-content/align-items— in a row, on the first item's slot, which is the "a ghostWorldis painted overHello" report.BuiltScenegainsghost_principal: Vec<(NodeId, NodeId)>, andapply_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 underspace-betweenand 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: relativecontainer (would split layout-affecting from paint-affecting CSS and risks changing flex sizing for every ghost-capable component), and a secondrun_layoutpass (a full taffy pass per frame instead of a map lookup).mode: "smear"is axis-alignedIt measured
|dx|and|dy|separately and emittedBlur { radius-x, radius-y }, so a 45° move gave an isotropic blob. NowDirectionalBlur { angle: atan2(dy, dx).to_degrees(), radius: hypot(dy, dx) }, whichpaint_pass.rsalready knew how to paint.radius_x/radius_y#[serde(alias = ...)]on both fields. The exported schema needs the entry underFilterFn, notBlur:FilterFnis#[serde(tag = "fn")], so schemars inlines every variant into itsoneOfand there is no standalone"Blur"definition forwiden_properties_withto find.The alias scan is tightened while here. It checked whether an alias string appeared anywhere in the schema text, which
radius_xalready did —HaloZone::radius_xis a real, unrelated snake_case field. When the source attribute carries arename, the test now requires the alias to sit in the samepropertiesobject as its canonical. Verified to fail on the missing entry:whipstreak is steppedWHIP_STEPS10 → 120. Two other shapes were tried and rejected againsttests/whip.rs::a_bigger_strength_streaks_further:strengthshrank the measured streak front (37 → 23).steps = reach_px.ceil() + 1). It gives the two comparedstrengthvalues 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 240draw_imagecalls 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, plusscene.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 renderssamplesexposures 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:
samples: 8,shutter: 1.0, camera panning and zooming2.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.rsre-exports through an explicit named list, andtasks.rsalready funnels through that one function, so it needed no change andapply_post_effectsstill 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:HelloWorldin a row,motion_bluronWorld: no pink ghost overHello;samples: 8smears the shot, and theanimated-backgroundstays 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, andcargo test --workspace --features rustmotion/studio(2121 tests) all pass.Docs
camera-motion-blur.mdis new.motion-blur-and-trail.md,directional-blur.md,whip-transition.mdandcamera-3d.mdare rewritten in English per the repository rule, with the behaviour changes folded in.whip-transition.mdalso now states that thetransitiongoes 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_blurinherits the same ceiling since each sub-frame goes through that path.