From 607c861d4111a7d11b57f08b76d1a3888b62a1fa Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Mon, 28 Sep 2026 00:57:29 +0200 Subject: [PATCH] feat(camera): tilt a whole shot in 3D with one shared vanishing point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `style.depth` drove parallax — translation and zoom. To tilt a whole shot you copied the same `rotate_x`/`rotate_y` keyframes onto every group, and **each group then got its own vanishing point**, which reads as cards turning independently rather than as a camera. `camera.rotate_x`, `rotate_y` and `perspective` apply the projection **once**, about the camera's origin, and scale the rotation by each direct child's `style.depth` — the same rule parallax and `camera.focus` already follow. A plane at depth 3 swings three times as far as one at depth 1, which is where the volume comes from. All three go through `interpolate_camera_property`, so keyframes came free, and `KNOWN_CAMERA_PROPERTIES` had to learn them — the validator rejects an unknown keyframe property by design. The matrix is built with `css_perspective_m44` and `pre_concat`, the same machinery per-node `rotate_x` already uses, rather than a second hand-rolled projection. Four tests. Two fail without the tilt; two assert equality and pass both ways on purpose — a camera declaring perspective but no tilt must not move a pixel, which is what every scenario written before this does. Part 2 of the issue, camera motion blur, is not here. It means accumulating sub-frame renders along the camera's motion, which lives in the encode loop and not in the paint pass. Splitting it rather than half-doing it. Refs #362 --- .../rustmotion-core/src/engine/paint_pass.rs | 95 +++++++++++++++++++ crates/rustmotion-core/src/schema/scenario.rs | 26 ++++- crates/rustmotion/skills/SKILL.md | 1 + crates/rustmotion/skills/rules/camera-3d.md | 52 ++++++++++ crates/rustmotion/src/engine/render/scene.rs | 9 ++ 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 crates/rustmotion/skills/rules/camera-3d.md diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index fd5dfc6..bcdc2f2 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -37,6 +37,9 @@ pub struct PlaneCamera { pub origin_y: f32, pub focus: f32, pub aperture: f32, + pub rotate_x: f32, + pub rotate_y: f32, + pub perspective: f32, } fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: (f32, f32)) { @@ -45,6 +48,9 @@ fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: let pan_x = cam.pan_x * depth; let pan_y = cam.pan_y * depth; + let tilt_x = cam.rotate_x * depth; + let tilt_y = cam.rotate_y * depth; + canvas.translate(Point::new(cam.origin_x, cam.origin_y)); if rotation.abs() > 0.001 { canvas.rotate(rotation, None); @@ -52,6 +58,15 @@ fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: if (zoom - 1.0).abs() > 0.001 { canvas.scale((zoom, zoom)); } + if tilt_x.abs() > 0.001 || tilt_y.abs() > 0.001 { + let mut shot = M44::new_identity(); + if cam.perspective > 0.0 { + shot.pre_concat(&css_perspective_m44(cam.perspective)); + } + shot.pre_concat(&M44::rotate(V3::new(1.0, 0.0, 0.0), tilt_x.to_radians())); + shot.pre_concat(&M44::rotate(V3::new(0.0, 1.0, 0.0), tilt_y.to_radians())); + canvas.concat_44(&shot); + } canvas.translate(Point::new(-cam.origin_x - pan_x, -cam.origin_y - pan_y)); canvas.clip_rect( Rect::from_wh(viewport.0, viewport.1), @@ -4000,6 +4015,83 @@ mod paint_order_tests { ); } + fn shot_camera(rotate_y: f32, perspective: f32) -> PlaneCamera { + PlaneCamera { + pan_x: 0.0, + pan_y: 0.0, + zoom: 1.0, + rotation: 0.0, + origin_x: 200.0, + origin_y: 200.0, + focus: 1.0, + aperture: 0.0, + rotate_x: 0.0, + rotate_y, + perspective, + } + } + + fn ink_x_extent(buf: &[u8]) -> (usize, usize) { + let mut lo = usize::MAX; + let mut hi = 0usize; + for y in 0..400 { + for x in 0..400 { + let i = (y * 400 + x) * 4; + if buf[i] > 150 && buf[i + 1] < 90 { + lo = lo.min(x); + hi = hi.max(x); + } + } + } + (lo, hi) + } + + #[test] + fn a_camera_tilt_of_zero_renders_exactly_as_no_camera() { + let plain = render_with_camera(plane_at_depth(Some(2.0)), None); + let flat = render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(0.0, 1400.0))); + assert_eq!( + plain, flat, + "a camera declaring perspective but no tilt must not move a pixel — every \ + scenario written before this declares neither" + ); + } + + #[test] + fn a_camera_tilt_turns_the_whole_shot() { + let flat = render_with_camera(plane_at_depth(Some(1.0)), None); + let tilted = render_with_camera(plane_at_depth(Some(1.0)), Some(shot_camera(24.0, 1400.0))); + assert_ne!(flat, tilted, "a 24-degree tilt must change the frame"); + } + + #[test] + fn a_deeper_plane_swings_further_than_a_near_one() { + let near = render_with_camera(plane_at_depth(Some(0.5)), Some(shot_camera(24.0, 1400.0))); + let far = render_with_camera(plane_at_depth(Some(3.0)), Some(shot_camera(24.0, 1400.0))); + let resting = ink_x_extent(&render_with_camera(plane_at_depth(Some(1.0)), None)); + + let near_shift = (ink_x_extent(&near).0 as i64 - resting.0 as i64).abs(); + let far_shift = (ink_x_extent(&far).0 as i64 - resting.0 as i64).abs(); + assert!( + far_shift > near_shift, + "the tilt is scaled by style.depth, the same rule parallax and focus follow, so a \ + plane at depth 3 must swing further than one at 0.5: near={near_shift}px, \ + far={far_shift}px" + ); + } + + #[test] + fn perspective_zero_is_an_orthographic_tilt() { + let ortho = render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(24.0, 0.0))); + let projected = + render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(24.0, 900.0))); + assert_ne!( + ortho, projected, + "perspective 0 means no vanishing point at all, which is a different image from \ + one projected at 900px" + ); + } + fn dof_camera(focus: f32, aperture: f32) -> PlaneCamera { PlaneCamera { pan_x: 0.0, @@ -4010,6 +4102,9 @@ mod paint_order_tests { origin_y: 200.0, focus, aperture, + rotate_x: 0.0, + rotate_y: 0.0, + perspective: 0.0, } } diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 18010e4..b119924 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -878,6 +878,20 @@ pub struct Camera { /// focal plane. #[serde(default)] pub aperture: f32, + /// Tilt of the whole shot about the horizontal axis, in degrees, applied + /// to each direct child of the scene scaled by its `style.depth` — the + /// same rule parallax and `focus` already follow. Default 0. + #[serde(default)] + pub rotate_x: f32, + /// Tilt about the vertical axis, same convention. Default 0. + #[serde(default)] + pub rotate_y: f32, + /// Viewing distance for `rotate_x`/`rotate_y`, in pixels. **One vanishing + /// point for the whole shot** — which is the difference from copying the + /// same rotation onto every group, where each group gets its own. `0` + /// means no perspective, an orthographic tilt. Default 0. + #[serde(default)] + pub perspective: f32, /// Keyframe animations for camera properties. #[serde(default)] pub keyframes: Vec, @@ -940,7 +954,17 @@ pub struct CameraOrigin { } const KNOWN_CAMERA_PROPERTIES: &[&str] = &[ - "x", "y", "zoom", "rotation", "origin.x", "origin.y", "focus", "aperture", + "x", + "y", + "zoom", + "rotation", + "origin.x", + "origin.y", + "focus", + "aperture", + "rotate_x", + "rotate_y", + "perspective", ]; fn validate_camera_property(value: &str) -> Result<(), E> { diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index b24eb95..755b91d 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -238,6 +238,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/chromatic-aberration.md](rules/chromatic-aberration.md) - Per-element red/cyan fringe on arrival: `chromatic_aberration`'s `amount`, how its curve differs from `chromatic_wipe`'s, and the `amount`-not-`amplitude` trap - [rules/inflated-material.md](rules/inflated-material.md) - `material: "inflated"`: shading derived from the clipped silhouette, so each branch of a star gets its own relief — and why `bevel` must stay small relative to the shape - [rules/material-and-light.md](rules/material-and-light.md) - Lit surfaces: `style.material`'s three presets, the scene-wide `light` that makes them agree, and why the material follows the box and not a `shape`'s own geometry +- [rules/camera-3d.md](rules/camera-3d.md) - Tilt a whole shot with `camera.rotate_x`/`rotate_y`/`perspective`: one shared vanishing point, and rotation scaled by each plane's `style.depth` - [rules/depth-of-field.md](rules/depth-of-field.md) - Defocus by plane: `camera.focus`/`aperture` on the `style.depth` scale, rack focus by keyframe, and why nothing moves without distinct depths - [rules/text-component-parity.md](rules/text-component-parity.md) - Where `text`, `rich_text` and `gradient_text` disagreed: colour alpha, literal whitespace and baseline, the CSS angle convention and explicit `stops` - [rules/iris-transition.md](rules/iris-transition.md) - `iris` beyond a centred circle: `origin`, `shape`, `fill`+`hold`, `ring` and `reverse`, and the pill coverage approximation diff --git a/crates/rustmotion/skills/rules/camera-3d.md b/crates/rustmotion/skills/rules/camera-3d.md new file mode 100644 index 0000000..f75eaf3 --- /dev/null +++ b/crates/rustmotion/skills/rules/camera-3d.md @@ -0,0 +1,52 @@ +# Incliner tout le plan : `camera.rotate_x` / `rotate_y` / `perspective` + +La parallaxe de `style.depth` connaît la translation et le zoom. Pour faire +basculer un plan entier, on copiait jusqu'ici les mêmes keyframes `rotate_x` sur +chaque groupe — et **chaque groupe se retrouvait avec son propre point de fuite**, +ce qui ne lit pas comme une caméra mais comme des cartes qui tournent chacune dans +son coin. + +```json +"camera": { + "perspective": 1400, + "keyframes": [ + { "property": "rotate_y", + "values": [{ "time": 0, "value": -16 }, { "time": 2, "value": 16 }], + "easing": "ease_in_out" } + ] +} +``` + +| Champ | Défaut | Rôle | +|---|---|---| +| `rotate_x` | `0` | bascule autour de l'axe horizontal, en degrés | +| `rotate_y` | `0` | bascule autour de l'axe vertical | +| `perspective` | `0` | distance d'observation en pixels ; `0` = projection orthographique | + +Les trois s'animent par `keyframes` comme `zoom` et `rotation`. + +## Un seul point de fuite, et l'échelle par profondeur + +La perspective est appliquée **une fois**, autour de l'origine de la caméra. C'est +toute la différence avec la version copiée sur chaque groupe. + +La rotation, elle, est mise à l'échelle par le `style.depth` de chaque enfant +direct de la scène — la même règle que la parallaxe et que `camera.focus` suivent +déjà. Un plan à `depth: 3` bascule trois fois plus qu'un plan à `depth: 1`, ce qui +est ce qui donne la sensation de volume. + +## Deux pièges + +**Ça ne s'applique qu'aux enfants directs de la scène.** Comme la parallaxe : un +plan est une couche. Poser `depth` sur un nœud enfoui dans un sous-arbre ne le +fait pas basculer tout seul. + +**`perspective: 0` n'est pas « pas de rotation ».** C'est une bascule +orthographique — la forme se déforme sans converger. Pour une caméra qui lit comme +une caméra, il faut une distance, et `1200`–`1800` couvre la plupart des cadrages. + +## Ce qui n'y est pas + +Le flou de mouvement de caméra (#362 partie 2) n'est pas ici. Il demande +d'accumuler plusieurs rendus sous-frame le long du mouvement, ce qui vit dans la +boucle d'encodage et non dans le pass de peinture. diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 835927e..fbf42fd 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -108,6 +108,9 @@ static IDENTITY_CAMERA: Camera = Camera { keyframes: Vec::new(), focus: 1.0, aperture: 0.0, + rotate_x: 0.0, + rotate_y: 0.0, + perspective: 0.0, }; fn effective_camera(scene: &Scene) -> Option<&Camera> { @@ -138,6 +141,9 @@ fn resolve_plane_camera( origin_y, focus: interpolate_camera_property(camera, "focus", time), aperture: interpolate_camera_property(camera, "aperture", time), + rotate_x: interpolate_camera_property(camera, "rotate_x", time), + rotate_y: interpolate_camera_property(camera, "rotate_y", time), + perspective: interpolate_camera_property(camera, "perspective", time), } } @@ -1366,6 +1372,9 @@ pub(super) fn interpolate_camera_property(camera: &Camera, property: &str, time: "rotation" => camera.rotation, "focus" => camera.focus, "aperture" => camera.aperture, + "rotate_x" => camera.rotate_x, + "rotate_y" => camera.rotate_y, + "perspective" => camera.perspective, "origin.x" => camera.origin.as_ref().map(|o| o.x).unwrap_or(0.0), "origin.y" => camera.origin.as_ref().map(|o| o.y).unwrap_or(0.0), _ => 0.0,