Skip to content

Commit 6ebadd7

Browse files
committed
feat(engine): camera focal point and multi-plane parallax
- Camera gains origin {x, y} (frame px, absent = centre, keyframable as origin.x/origin.y following the component dotted convention): translate/rotate/zoom pivot around the resolved origin at all four call sites — zoom onto an element, pan the focal point between keyframes - CssStyle gains depth (default 1.0): planes are the scene's direct children; with a camera and any explicit depth, rendering switches to per-plane camera application (pan*d, zoom'=1+(zoom-1)*d, rotation*d around the origin, content-space clip) carried through PaintFrame; depth 0 locks a background, >1 amplifies foregrounds; no depth declared anywhere keeps the global path untouched and the depth-1 invariant is byte-identity-tested against it - hit-map follows each plane's matrix (tested), geometry validate is layout-space (unaffected), world views stay on the global path, incremental hashing follows automatically; dynamic-depth skill rule gains the real-parallax mechanism
1 parent 194057d commit 6ebadd7

8 files changed

Lines changed: 635 additions & 35 deletions

File tree

.claude/skills/rustmotion/rules/dynamic-depth.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,55 @@
22

33
Static depth (see [depth-layering.md](depth-layering.md)) places elements at different perceived distances. Dynamic depth *animates* each plane independently so the scene breathes and the spatial composition is felt over time, not just read visually.
44

5-
Three independent mechanisms combine freely:
5+
Four independent mechanisms combine freely:
66

77
| Mechanism | Scope | Best for |
88
|---|---|---|
99
| **A. Per-element wiggle seeds** | One element | Uncorrelated floating per card/icon |
1010
| **B. `float_3d` preset** | One element | Hero card with gentle 3D tilt loop |
1111
| **C. Camera keyframes** | Whole scene | Cinematic zoom-in / slow pan |
12+
| **D. `style.depth` (vraie parallaxe)** | Plans top-level | Parallaxe multi-plans pilotée par la caméra |
13+
14+
---
15+
16+
## Mechanism D — Vraie parallaxe caméra : `style.depth`
17+
18+
Quand la scène a une `camera`, chaque **enfant direct** de la scène est un plan. `style.depth` met à l'échelle l'effet caméra sur ce plan (pan × depth, zoom' = 1 + (zoom−1)×depth, rotation × depth, autour de `camera.origin`) :
19+
20+
| `depth` | Effet |
21+
|---|---|
22+
| `0` | Plan verrouillé — la caméra ne le bouge jamais (HUD, watermark, fond fixe) |
23+
| `0.2–0.5` | Arrière-plan lointain — bouge peu (blobs, textures) |
24+
| `1.0` | Plan normal (défaut — comportement identique sans depth) |
25+
| `1.5–2.5` | Avant-plan amplifié — bouge plus que la caméra (profondeur dramatique) |
26+
27+
```json
28+
{
29+
"duration": 6.0,
30+
"camera": {
31+
"keyframes": [
32+
{ "property": "x", "values": [ { "time": 0, "value": 0 }, { "time": 5, "value": 200 } ], "easing": "ease_in_out" }
33+
]
34+
},
35+
"children": [
36+
{ "type": "shape", "shape": "circle", "position": "absolute", "x": 100, "y": 200,
37+
"fill": { "type": "radial", "colors": ["#6366F160", "#6366F100"] },
38+
"style": { "width": 600, "height": 600, "depth": 0.3 } },
39+
{ "type": "card", "style": { "depth": 1.0, "width": 800, "height": 400 } },
40+
{ "type": "badge", "text": "LIVE", "position": "absolute", "x": 80, "y": 100,
41+
"style": { "depth": 1.8 } }
42+
]
43+
}
44+
```
45+
46+
Le fond (0.3) glisse lentement, la card suit la caméra, le badge file en avant-plan — parallaxe cinéma réelle, sans wiggle.
47+
48+
**Règles :**
49+
- `depth` n'agit que sur les **enfants directs** de la scène (v1) ; il gouverne tout le sous-arbre du plan. Un `depth` sur un nœud imbriqué est sans effet caméra.
50+
- Sans `camera` sur la scène, `depth` est inerte.
51+
- `depth: 1.0` partout == aucun depth (byte-identique).
52+
- Le point focal se règle avec `camera.origin` (`{ "x": px, "y": px }`, keyframable via `"origin.x"` / `"origin.y"`) — zoom vers un élément précis puis pan vers un autre.
53+
- Combine avec le mécanisme A (wiggle) librement — la parallaxe caméra est déterministe, le wiggle ajoute la vie.
1254

1355
---
1456

crates/rustmotion-components/src/legacy_dispatch.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ mod tests {
215215
video_width: 200,
216216
video_height: 200,
217217
scene_duration: 1.0,
218+
camera: None,
218219
};
219220
rustmotion_core::engine::paint_pass::paint_tree(
220221
canvas,
@@ -313,6 +314,7 @@ mod tests {
313314
video_width: 200,
314315
video_height: 200,
315316
scene_duration: 1.0,
317+
camera: None,
316318
};
317319
rustmotion_core::engine::paint_pass::paint_tree(
318320
canvas,
@@ -419,6 +421,7 @@ mod tests {
419421
video_width: 200,
420422
video_height: 200,
421423
scene_duration: 1.0,
424+
camera: None,
422425
};
423426
rustmotion_core::engine::paint_pass::paint_tree(
424427
canvas,
@@ -486,6 +489,7 @@ mod tests {
486489
video_width: 10,
487490
video_height: 10,
488491
scene_duration: 1.0,
492+
camera: None,
489493
};
490494
// Wrong payload type — must not panic.
491495
let bogus: Arc<dyn std::any::Any + Send + Sync> = Arc::new(42i64);

crates/rustmotion-components/tests/caption_presets.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ fn render_caption(json: serde_json::Value, time: f64) -> Vec<u8> {
5656
video_width: W,
5757
video_height: H,
5858
scene_duration: 2.0,
59+
camera: None,
5960
};
6061
paint_tree(canvas, &built.root, &layout, &frame, &dispatcher);
6162

crates/rustmotion-core/src/css/style.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ pub struct CssStyle {
104104
pub perspective: Option<Length>,
105105
pub perspective_origin: Option<TransformOrigin>,
106106

107+
// ---- Scene-camera parallax ----
108+
/// Parallax plane depth for the scene camera (issue #90). 0 = locked
109+
/// plane (the camera does not affect it), 1 = normal plane (default),
110+
/// above 1 = amplified foreground. v1: effective on direct children of
111+
/// the scene root only (each top-level child is one plane whose depth
112+
/// governs its whole subtree). Not inherited via cascade.
113+
pub depth: Option<f32>,
114+
107115
// ---- Overflow / stacking ----
108116
pub overflow: Option<Overflow>,
109117
pub overflow_x: Option<Overflow>,

crates/rustmotion-core/src/engine/paint_pass.rs

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,51 @@ pub struct PaintFrame {
4141
/// Total duration of the scene in seconds — used by the dispatcher to
4242
/// compute animation progress (`time / scene_duration`).
4343
pub scene_duration: f64,
44+
/// Resolved scene camera for per-plane parallax (issue #90). `Some` only
45+
/// when the scene declares a camera AND at least one top-level child has
46+
/// an explicit `style.depth` — the paint pass then applies the camera per
47+
/// plane (each direct child of the root, scaled by its depth) and the
48+
/// caller must NOT apply the global camera transform.
49+
pub camera: Option<PlaneCamera>,
50+
}
51+
52+
/// Scene camera resolved at a fixed time, ready for per-plane application.
53+
#[derive(Debug, Clone, Copy, PartialEq)]
54+
pub struct PlaneCamera {
55+
pub pan_x: f32,
56+
pub pan_y: f32,
57+
pub zoom: f32,
58+
pub rotation: f32,
59+
/// Focal point in frame pixels (already resolved; default = frame centre).
60+
pub origin_x: f32,
61+
pub origin_y: f32,
62+
}
63+
64+
/// Apply the scene camera scaled by a plane `depth` (issue #90):
65+
/// pan' = pan·d, zoom' = 1 + (zoom−1)·d, rotation' = rotation·d, around the
66+
/// camera origin. `depth == 1` reproduces the global camera matrix exactly;
67+
/// `depth == 0` is the identity (locked plane). The content-space viewport
68+
/// clip mirrors the global path's clip-after-camera so plane content is cut
69+
/// at the scene rectangle exactly like the single-transform path.
70+
fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: (f32, f32)) {
71+
let zoom = 1.0 + (cam.zoom - 1.0) * depth;
72+
let rotation = cam.rotation * depth;
73+
let pan_x = cam.pan_x * depth;
74+
let pan_y = cam.pan_y * depth;
75+
76+
canvas.translate(Point::new(cam.origin_x, cam.origin_y));
77+
if rotation.abs() > 0.001 {
78+
canvas.rotate(rotation, None);
79+
}
80+
if (zoom - 1.0).abs() > 0.001 {
81+
canvas.scale((zoom, zoom));
82+
}
83+
canvas.translate(Point::new(-cam.origin_x - pan_x, -cam.origin_y - pan_y));
84+
canvas.clip_rect(
85+
Rect::from_wh(viewport.0, viewport.1),
86+
ClipOp::Intersect,
87+
true,
88+
);
4489
}
4590

4691
/// Axis-aligned bounding box of a painted node, in device (video-pixel) coords.
@@ -121,7 +166,7 @@ pub fn paint_tree(
121166
viewport_size: (frame.video_width as f32, frame.video_height as f32),
122167
hits: None,
123168
};
124-
paint_node(canvas, root, &ctx);
169+
paint_node(canvas, root, &ctx, 0);
125170
}
126171

127172
/// Like [`paint_tree`] but also returns the per-frame hit-map: the on-screen
@@ -142,7 +187,7 @@ pub fn paint_tree_with_hits(
142187
viewport_size: (frame.video_width as f32, frame.video_height as f32),
143188
hits: Some(&hits),
144189
};
145-
paint_node(canvas, root, &ctx);
190+
paint_node(canvas, root, &ctx, 0);
146191
hits.into_inner()
147192
}
148193

@@ -154,7 +199,9 @@ struct PaintContext<'a> {
154199
hits: Option<&'a RefCell<HitMap>>,
155200
}
156201

157-
fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) {
202+
/// `tree_depth` counts levels below the synthetic scene root (root = 0,
203+
/// direct children = 1). Per-plane parallax cameras apply at level 1 only.
204+
fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: usize) {
158205
// Visibility window (start_at/end_at): the node keeps its layout space
159206
// but paints nothing — subtree included — outside the window.
160207
if let Some(window) = &node.window {
@@ -179,6 +226,16 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) {
179226

180227
canvas.save();
181228

229+
// 1.5 per-plane scene camera (issue #90): every direct child of the scene
230+
// root is a plane; its explicit `depth` (default 1.0) scales the camera.
231+
// Deeper nodes inherit their plane's transform via the canvas matrix.
232+
if tree_depth == 1 {
233+
if let Some(cam) = &ctx.frame.camera {
234+
let depth = node.css.depth.unwrap_or(1.0);
235+
apply_plane_camera(canvas, cam, depth, ctx.viewport_size);
236+
}
237+
}
238+
182239
// 2. transform
183240
if node.css.transform.is_some() || node.css.perspective.is_some() {
184241
let (tx, ty, _tz) =
@@ -333,7 +390,7 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext) {
333390
let mut indices: Vec<usize> = (0..node.children.len()).collect();
334391
indices.sort_by_key(|&i| node.children[i].css.z_index.unwrap_or(0));
335392
for &i in &indices {
336-
paint_node(canvas, &node.children[i], ctx);
393+
paint_node(canvas, &node.children[i], ctx, tree_depth + 1);
337394
}
338395

339396
// inset shadows (after children so they overlay content)
@@ -1343,6 +1400,7 @@ mod hit_tests {
13431400
video_width: w,
13441401
video_height: h,
13451402
scene_duration: 1.0,
1403+
camera: None,
13461404
}
13471405
}
13481406

@@ -1586,6 +1644,7 @@ mod transform_origin_tests {
15861644
video_width: w,
15871645
video_height: h,
15881646
scene_duration: 1.0,
1647+
camera: None,
15891648
}
15901649
}
15911650

@@ -1977,6 +2036,7 @@ mod glassmorphism_tests {
19772036
video_width: w,
19782037
video_height: h,
19792038
scene_duration: 1.0,
2039+
camera: None,
19802040
}
19812041
}
19822042

crates/rustmotion-core/src/schema/scenario.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,18 +334,34 @@ pub struct Camera {
334334
/// Zoom factor. 1.0 = no zoom, 2.0 = 2x zoom in, 0.5 = zoom out.
335335
#[serde(default = "default_camera_zoom")]
336336
pub zoom: f32,
337-
/// Rotation in degrees around the scene center. Default: 0.
337+
/// Rotation in degrees around the camera origin. Default: 0.
338338
#[serde(default)]
339339
pub rotation: f32,
340+
/// Focal point for zoom/rotation, in frame pixels. Absent = frame centre
341+
/// (the historical behaviour). When the object is present, `x`/`y`
342+
/// default to 0 (top-left corner) — set both explicitly.
343+
#[serde(default)]
344+
pub origin: Option<CameraOrigin>,
340345
/// Keyframe animations for camera properties.
341346
#[serde(default)]
342347
pub keyframes: Vec<CameraKeyframe>,
343348
}
344349

350+
/// Focal point of the camera in frame pixels.
351+
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
352+
pub struct CameraOrigin {
353+
#[serde(default)]
354+
pub x: f32,
355+
#[serde(default)]
356+
pub y: f32,
357+
}
358+
345359
/// A keyframe for a camera property.
346360
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
347361
pub struct CameraKeyframe {
348-
/// The camera property to animate: "x", "y", "zoom", "rotation".
362+
/// The camera property to animate: "x", "y", "zoom", "rotation",
363+
/// "origin.x", "origin.y" (dotted form, matching the component keyframe
364+
/// convention for compound properties).
349365
pub property: String,
350366
/// Time-value pairs for the animation.
351367
pub values: Vec<CameraKeyframePoint>,

0 commit comments

Comments
 (0)