feat(camera): pan across a world wider than the frame, per-keyframe easing, camera-aware validation - #453
Merged
Conversation
12 tasks
…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
force-pushed
the
feat/camera-428
branch
from
September 29, 2026 18:21
fd0610d to
6d7d66a
Compare
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 #428. Part of #438.
The frame was never missing the content
canvas.clip_rect(Rect::from_wh(width, height), …)was issued afterapply_camera_transformhad already put the camera translation on the canvas matrix. Skia bakes a clip into device space using the matrix in force whenclip_rectis 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_coreandrender_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=0the centre pixel is background(16,16,24); att=2it is(255,51,102), the element's own#FF3366.validatehad to followThe camera fold in
geometry.rsreadcamera.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-onvalidate_geometrypass, not--strict-anim, so a plainrustmotion validaterejected the element.fold_camera_atnow resolves each property at a time. The static pass samples the camera across the scene at the cadenceanim_sample_timesalready uses, stopping atfreeze_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 atx=9000, still reportsERROR: 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 newbbox_overflowsare the same comparison at the sameeps, so skipping the call when the element is in view is exactly equivalent to calling it and having it return early.Per-keyframe easing
CameraKeyframePointgainseasing: Option<EasingType>, governing the segment starting at it — the shape a componentKeyframealready 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 → 900over 1 s, element at localx=860, probed att=0.5:linearease_inease_in_cubic(0.5) = 0.125, so860 - 112.5 = 747.5. The arithmetic lands on the pixel.The interpolation moved to
Camera::resolve_propertyinscenario.rs, becausegeometry.rsneeds it andengine::render::sceneis private behindrender/mod.rs's explicit re-export list.interpolate_camera_propertyis a one-line delegate now, so the renderer and the validator read camera keyframes through the same code.Handheld shake needed no code
scene.shakealready composes additively incamera_pose_at, andeffective_cameraalready synthesises an identity camera when onlyshakeis set, so shake alone moves the frame with nocamerablock at all. Checked before adding anything, and confirmed by render — camera panningx: 0 → 300over 1 s, a 40 px impact att=0.5:timeExactly 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+ shaketerm is removed.Known gap, pre-existing and now documented
The camera fold accounts for
x,yandzoomonly —fold_static_cameranever handledrotationeither. An element brought into frame purely bycamera.rotationor an animatedoriginis still reported as an overflow. Not a regression, but more visible now that the pan/zoom half works, so it is written intogeometry-safety.mdrather 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, andcargo test --workspace --features rustmotion/studioall pass.Docs
geometry-safety.mdgains 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.mdgains the clip explanation and the per-keyframe easing convention.camera-motion-blur.mddistinguishesscene.shakefrom 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.