Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -45,13 +48,25 @@ 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);
}
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),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}
}

Expand Down
26 changes: 25 additions & 1 deletion crates/rustmotion-core/src/schema/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CameraKeyframe>,
Expand Down Expand Up @@ -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<E: serde::de::Error>(value: &str) -> Result<(), E> {
Expand Down
1 change: 1 addition & 0 deletions crates/rustmotion/skills/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions crates/rustmotion/skills/rules/camera-3d.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions crates/rustmotion/src/engine/render/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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,
Expand Down
Loading