Skip to content

feat(camera): pan across a world wider than the frame, per-keyframe easing, camera-aware validation - #453

Merged
LeadcodeDev merged 1 commit into
mainfrom
feat/camera-428
Sep 29, 2026
Merged

LeadcodeDev merged 1 commit into
mainfrom
feat/camera-428

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Closes #428. Part of #438.

Stacked on #452 (fix/motion-blur-427), which is green and awaiting its merge. GitHub will retarget this to main once that lands; the diff here is the camera work alone.

The frame was never missing the content

canvas.clip_rect(Rect::from_wh(width, height), …) was issued after apply_camera_transform had already put the camera translation on the canvas matrix. Skia bakes a clip into device space using the matrix in force when clip_rect is called, so the clip travelled with the camera; past a pan of roughly the frame's own width it left the surface entirely and the frame rendered blank.

The clip is the surface, so it goes first now, at both sites — render_frame_v2_scaled_core and render_scene_fg_scaled — with the save/restore guards unwound in the matching order. Nothing else culls out-of-frame content: the issue's own scenario renders correctly with the clip order alone.

Verified with the exact JSON from the issue. At t=0 the centre pixel is background (16,16,24); at t=2 it is (255,51,102), the element's own #FF3366.

validate had to follow

The camera fold in geometry.rs read camera.x/.y/.zoom — the static struct fields — so a purely keyframe-driven camera folded as if it never moved. That is the issue's exact case, and it is the always-on validate_geometry pass, not --strict-anim, so a plain rustmotion validate rejected the element.

fold_camera_at now resolves each property at a time. The static pass samples the camera across the scene at the cadence anim_sample_times already uses, stopping at freeze_at, and reports an element only if no sampled pose brings it into frame.

Deliberately not over-corrected: a camera that only ever reaches x=500, with the element at x=9000, still reports ERROR: div (viewport overflow). A camera on the scene is not a blanket exemption, and there is a test pinning each side of that.

No coverage is lost by the restructuring: check_viewport's own predicate and the new bbox_overflows are the same comparison at the same eps, so skipping the call when the element is in view is exactly equivalent to calling it and having it return early.

Per-keyframe easing

CameraKeyframePoint gains easing: Option<EasingType>, governing the segment starting at it — the shape a component Keyframe already uses, mirrored rather than reinvented. With none set anywhere, the track-level easing still applies to every segment.

Verified by render rather than by unit test alone. Camera x: 0 → 900 over 1 s, element at local x=860, probed at t=0.5:

camera x element's left edge
track linear 450 410
per-keyframe ease_in 112.5 748

ease_in_cubic(0.5) = 0.125, so 860 - 112.5 = 747.5. The arithmetic lands on the pixel.

"easing": "spring" on a camera keyframe is accepted and resolves as linear: a camera keyframe has no spring config to drive one. Pre-existing — it was already true of the track-level easing — and now written down rather than left to be discovered.

The interpolation moved to Camera::resolve_property in scenario.rs, because geometry.rs needs it and engine::render::scene is private behind render/mod.rs's explicit re-export list. interpolate_camera_property is a one-line delegate now, so the renderer and the validator read camera keyframes through the same code.

Handheld shake needed no code

scene.shake already composes additively in camera_pose_at, and effective_camera already synthesises an identity camera when only shake is set, so shake alone moves the frame with no camera block at all. Checked before adding anything, and confirmed by render — camera panning x: 0 → 300 over 1 s, a 40 px impact at t=0.5:

time pan-only edge actual shake delta
0.466 720 720 0
0.500 710 670 −40
0.533 700 704 +4

Exactly the impact's amplitude on top of the ongoing pan, decaying the frame after. So no camera.shake: two regression tests pin the existing composition instead, both verified to fail if the + shake term is removed.

Known gap, pre-existing and now documented

The camera fold accounts for x, y and zoom only — fold_static_camera never handled rotation either. An element brought into frame purely by camera.rotation or an animated origin is still reported as an overflow. Not a regression, but more visible now that the pan/zoom half works, so it is written into geometry-safety.md rather than left as a surprise.

Verification

All four items verified end to end — scenario JSON, rustmotion validate, render, image inspected — as the definition of done asks, with the tables above as the evidence. Seven 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 all pass.

Docs

geometry-safety.md gains a section on why "outside the viewport" is a question about time once a scene has a camera, including the two limits above. camera-3d.md gains the clip explanation and the per-keyframe easing convention. camera-motion-blur.md distinguishes scene.shake from it in the "not to be confused with" list. Both new examples are complete, validatable scenarios rather than fragments, so the skill-example test actually checks them.

@LeadcodeDev LeadcodeDev self-assigned this Sep 29, 2026
Base automatically changed from fix/motion-blur-427 to main September 29, 2026 18:19
…asing, camera-aware validation

Closes #428. Part of #438.

The frame was never missing the content. `canvas.clip_rect(Rect::from_wh(…))`
was issued after `apply_camera_transform` had already put the camera
translation on the canvas matrix, and Skia bakes a clip into device space
using the matrix in force when `clip_rect` is called. The clip therefore
travelled with the camera, and past a pan of roughly the frame's own width it
left the surface entirely and the frame rendered blank. The clip is the
surface, so it now goes first, at both sites — `render_frame_v2_scaled_core`
and `render_scene_fg_scaled` — with the guards unwound in the matching order.
Nothing else culls out-of-frame content: the issue's own scenario renders with
the clip order alone.

`validate` agreed with the old renderer and has to follow. The camera fold in
`geometry.rs` read `camera.x`/`.y`/`.zoom` — the static struct fields — so a
purely keyframe-driven camera folded as if it never moved, in the always-on
pass as well as under `--strict-anim`. `fold_camera_at` now resolves each
property at a time, and the static pass samples the camera across the scene at
the cadence `anim_sample_times` already uses, stopping at `freeze_at`. An
element is reported only if no sampled pose brings it into frame; one the
camera never reaches still is. `check_viewport`'s predicate and `bbox_overflows`
are the same test, so skipping the call when the element is in view loses no
coverage.

`CameraKeyframePoint` gains `easing: Option<EasingType>`, governing the segment
starting at it — the shape component `Keyframe` already uses, deliberately
mirrored rather than reinvented. `"spring"` there resolves as linear, since a
camera keyframe has no spring config to drive; that was already true of the
track-level easing.

The interpolation moved to `Camera::resolve_property` in `scenario.rs`, because
`geometry.rs` needs it and `engine::render::scene` is private behind an
explicit re-export list. `interpolate_camera_property` is now a delegate, so
per-keyframe easing reaches the renderer and the validator from one place.

Handheld shake needed no code. `scene.shake` already composes additively in
`camera_pose_at`, and `effective_camera` already synthesises an identity camera
when only `shake` is set — verified by render at 30fps across an impact, where
the element moves by exactly the 40px amplitude on top of an ongoing pan. Two
regression tests pin it instead of a second mechanism.

Known gap, pre-existing and now documented: the camera fold accounts for `x`,
`y` and `zoom` only. An element brought into frame purely by `camera.rotation`
or an animated `origin` is still reported.
@LeadcodeDev
LeadcodeDev merged commit c132922 into main Sep 29, 2026
4 checks passed
@LeadcodeDev
LeadcodeDev deleted the feat/camera-428 branch September 29, 2026 18:28
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.

Camera: a world larger than the frame, per-segment easing, handheld shake, camera-aware validation

1 participant