Skip to content

Commit 3c467ea

Browse files
authored
feat(camera): depth of field from style.depth and a focus distance (#356)
#355's tractable half. `style.depth` already drives parallax — an element at `depth: 3` travels three times as far as the backdrop when the camera moves. The same two numbers now drive sharpness: sigma = aperture * |depth - focus| `camera.focus` defaults to `1.0`, the plane an element sits on when it declares no depth, and `camera.aperture` defaults to `0.0`. A scenario that mentions neither is focused on everything it has, and `aperture: 0` renders byte-identical to before this existed — pinned by a test rather than assumed. Linear and symmetric: two units in front defocuses exactly as much as two units behind. The blur composes into the node's existing filter chain, next to `style.filter` and `chromatic_aberration`, and extends the same layer's bleed bounds by `3 * sigma` so the gradient is not cut off at the box edge. Both fields go through `interpolate_camera_property`, so keyframes came free: animating `focus` is a rack focus, animating `aperture` opens and closes the effect without moving the focal plane. That also means `KNOWN_CAMERA_PROPERTIES` had to learn them — the validator rejects an unknown keyframe property by design, and it caught `"property": "focus"` before any render did. Measured on three cards at depths 1, 2 and 3 with `aperture: 7` and `focus` animated 1 → 3, counting edge-gradient pixels: focus depth1 depth2 depth3 t=0.0 1.0 0 22 41 t=1.0 2.0 21 0 20 t=2.0 3.0 43 22 0 The diagonal of zeros is the rack focus travelling. Seven tests. Two fail without the filter, reporting `sharp=0px, defocused=0px`; the other five assert equality and pass both ways on purpose — they pin that aperture 0, a plane on the focal distance, and a node with no declared depth all render exactly as they did before. The glossy material from #355 is not here. It needs a lighting model decided first, which is a product call rather than an implementation detail. Refs #355
1 parent 4a41ef1 commit 3c467ea

5 files changed

Lines changed: 271 additions & 3 deletions

File tree

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

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pub struct PlaneCamera {
3434
pub rotation: f32,
3535
pub origin_x: f32,
3636
pub origin_y: f32,
37+
pub focus: f32,
38+
pub aperture: f32,
3739
}
3840

3941
fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: (f32, f32)) {
@@ -272,13 +274,24 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
272274
let aberration_shift = active_chromatic_aberration(&node.css, ctx.frame.time)
273275
.map(|(cfg, progress)| crate::engine::animator::chromatic_aberration_shift(cfg, progress));
274276
let aberration_filter = aberration_shift.and_then(chromatic_aberration_image_filter);
277+
let defocus_sigma = match (tree_depth, ctx.frame.camera.as_ref()) {
278+
(1, Some(cam)) => depth_of_field_sigma(cam, node.css.depth.unwrap_or(1.0)),
279+
_ => 0.0,
280+
};
281+
let defocus_filter = depth_of_field_image_filter(defocus_sigma);
275282
let combined_filter = {
276283
use skia_safe::image_filters;
277-
match (content_filter, aberration_filter) {
284+
let node_filter = match (content_filter, aberration_filter) {
278285
(Some(cf), Some(af)) => image_filters::compose(af, cf),
279286
(Some(cf), None) => Some(cf),
280287
(None, Some(af)) => Some(af),
281288
(None, None) => None,
289+
};
290+
match (node_filter, defocus_filter) {
291+
(Some(nf), Some(df)) => image_filters::compose(df, nf),
292+
(Some(nf), None) => Some(nf),
293+
(None, Some(df)) => Some(df),
294+
(None, None) => None,
282295
}
283296
};
284297
let opened_opacity_layer = if opacity < 1.0 || combined_filter.is_some() {
@@ -302,9 +315,11 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
302315
.map(|shadows| box_shadow_bleed(shadows, &length_ctx))
303316
.unwrap_or(0.0);
304317
let aberration_bleed_px = aberration_shift.map(|s| s.abs().ceil()).unwrap_or(0.0);
318+
let defocus_bleed_px = (defocus_sigma * 3.0).ceil();
305319
let bleed = filter_bleed_px
306320
.max(shadow_bleed_px)
307-
.max(aberration_bleed_px);
321+
.max(aberration_bleed_px)
322+
.max(defocus_bleed_px);
308323
let mut bounds = Rect::from_xywh(
309324
box_layout.x - bleed,
310325
box_layout.y - bleed,
@@ -553,6 +568,20 @@ fn chromatic_aberration_image_filter(shift: f32) -> Option<skia_safe::ImageFilte
553568
image_filters::blend(BlendMode::Plus, Some(red), Some(cyan), None)
554569
}
555570

571+
pub fn depth_of_field_sigma(camera: &PlaneCamera, depth: f32) -> f32 {
572+
if camera.aperture <= 0.0 {
573+
return 0.0;
574+
}
575+
(camera.aperture * (depth - camera.focus).abs()).max(0.0)
576+
}
577+
578+
fn depth_of_field_image_filter(sigma: f32) -> Option<skia_safe::ImageFilter> {
579+
if sigma <= 0.0 {
580+
return None;
581+
}
582+
skia_safe::image_filters::blur((sigma, sigma), skia_safe::TileMode::Decal, None, None)
583+
}
584+
556585
fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 {
557586
use crate::css::style::FilterFn;
558587
let mut bleed = 0.0f32;
@@ -2941,6 +2970,160 @@ mod paint_order_tests {
29412970
);
29422971
}
29432972

2973+
fn dof_camera(focus: f32, aperture: f32) -> PlaneCamera {
2974+
PlaneCamera {
2975+
pan_x: 0.0,
2976+
pan_y: 0.0,
2977+
zoom: 1.0,
2978+
rotation: 0.0,
2979+
origin_x: 200.0,
2980+
origin_y: 200.0,
2981+
focus,
2982+
aperture,
2983+
}
2984+
}
2985+
2986+
fn plane_at_depth(depth: Option<f32>) -> BoxNode {
2987+
BoxNode {
2988+
id: 0,
2989+
kind: BoxKind::Container,
2990+
css: CssStyle {
2991+
position: Some(Position::Absolute),
2992+
left: Some(CLP::Px(150.0)),
2993+
top: Some(CLP::Px(150.0)),
2994+
width: Some(CSize::Length(CLP::Px(100.0))),
2995+
height: Some(CSize::Length(CLP::Px(100.0))),
2996+
background: Some(Background::Color(CssColor::String("#ff0000".into()))),
2997+
depth,
2998+
..Default::default()
2999+
},
3000+
children: vec![],
3001+
intrinsic: None,
3002+
source_path: None,
3003+
window: None,
3004+
}
3005+
}
3006+
3007+
fn render_with_camera(node: BoxNode, camera: Option<PlaneCamera>) -> Vec<u8> {
3008+
let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
3009+
root.assign_ids(0);
3010+
let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
3011+
let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
3012+
let mut frame = test_frame(400, 400);
3013+
frame.camera = camera;
3014+
paint_tree(surface.canvas(), &root, &layout, &frame, &NoopDispatcher);
3015+
let info = skia_safe::ImageInfo::new(
3016+
(400, 400),
3017+
skia_safe::ColorType::RGBA8888,
3018+
skia_safe::AlphaType::Unpremul,
3019+
None,
3020+
);
3021+
let mut buf = vec![0u8; 400 * 400 * 4];
3022+
surface.read_pixels(&info, &mut buf, 400 * 4, (0, 0));
3023+
buf
3024+
}
3025+
3026+
/// How many pixels along the plane's top edge are neither background nor
3027+
/// full red — the width of the gradient a blur leaves behind.
3028+
fn edge_softness(buf: &[u8]) -> usize {
3029+
(120..190)
3030+
.filter(|y| {
3031+
let i = (y * 400 + 200) * 4;
3032+
let (r, g) = (buf[i], buf[i + 1]);
3033+
r > 12 && r < 240 && g < 60
3034+
})
3035+
.count()
3036+
}
3037+
3038+
#[test]
3039+
fn an_aperture_of_zero_renders_exactly_as_no_camera_at_all() {
3040+
let sharp = render_with_camera(plane_at_depth(Some(2.0)), None);
3041+
let zero_aperture =
3042+
render_with_camera(plane_at_depth(Some(2.0)), Some(dof_camera(1.0, 0.0)));
3043+
assert_eq!(
3044+
sharp, zero_aperture,
3045+
"aperture 0 must be byte-identical to no depth of field: every scenario written \
3046+
before this one declares no aperture"
3047+
);
3048+
}
3049+
3050+
#[test]
3051+
fn a_plane_on_the_focus_distance_stays_sharp() {
3052+
let no_dof = render_with_camera(plane_at_depth(Some(2.0)), None);
3053+
let focused = render_with_camera(plane_at_depth(Some(2.0)), Some(dof_camera(2.0, 8.0)));
3054+
assert_eq!(
3055+
no_dof, focused,
3056+
"a plane sitting exactly on the focus distance is in focus whatever the aperture"
3057+
);
3058+
}
3059+
3060+
#[test]
3061+
fn a_plane_away_from_the_focus_distance_is_blurred() {
3062+
let sharp_softness = edge_softness(&render_with_camera(plane_at_depth(Some(3.0)), None));
3063+
let blurred_softness = edge_softness(&render_with_camera(
3064+
plane_at_depth(Some(3.0)),
3065+
Some(dof_camera(1.0, 6.0)),
3066+
));
3067+
assert!(
3068+
blurred_softness > sharp_softness + 8,
3069+
"a plane two depth units off focus must show a measurably soft edge: \
3070+
sharp={sharp_softness}px, defocused={blurred_softness}px"
3071+
);
3072+
}
3073+
3074+
#[test]
3075+
fn the_further_from_focus_the_softer_the_edge() {
3076+
let near = edge_softness(&render_with_camera(
3077+
plane_at_depth(Some(2.0)),
3078+
Some(dof_camera(1.0, 6.0)),
3079+
));
3080+
let far = edge_softness(&render_with_camera(
3081+
plane_at_depth(Some(4.0)),
3082+
Some(dof_camera(1.0, 6.0)),
3083+
));
3084+
assert!(
3085+
far > near,
3086+
"softness must grow with distance from the focal plane: \
3087+
1 unit off={near}px, 3 units off={far}px"
3088+
);
3089+
}
3090+
3091+
#[test]
3092+
fn focus_is_symmetric_in_front_of_and_behind_the_focal_plane() {
3093+
let in_front = edge_softness(&render_with_camera(
3094+
plane_at_depth(Some(1.0)),
3095+
Some(dof_camera(3.0, 6.0)),
3096+
));
3097+
let behind = edge_softness(&render_with_camera(
3098+
plane_at_depth(Some(5.0)),
3099+
Some(dof_camera(3.0, 6.0)),
3100+
));
3101+
assert_eq!(
3102+
in_front, behind,
3103+
"two units in front and two units behind defocus by the same amount"
3104+
);
3105+
}
3106+
3107+
#[test]
3108+
fn a_node_with_no_declared_depth_sits_on_the_default_focal_plane() {
3109+
let no_dof = render_with_camera(plane_at_depth(None), None);
3110+
let with_dof = render_with_camera(plane_at_depth(None), Some(dof_camera(1.0, 10.0)));
3111+
assert_eq!(
3112+
no_dof, with_dof,
3113+
"depth defaults to 1.0 and focus defaults to 1.0, so a scenario that declares \
3114+
neither is focused on everything it has"
3115+
);
3116+
}
3117+
3118+
#[test]
3119+
fn depth_of_field_sigma_grows_linearly_with_the_aperture() {
3120+
let cam = dof_camera(1.0, 4.0);
3121+
assert_eq!(depth_of_field_sigma(&cam, 1.0), 0.0);
3122+
assert_eq!(depth_of_field_sigma(&cam, 2.0), 4.0);
3123+
assert_eq!(depth_of_field_sigma(&cam, 3.0), 8.0);
3124+
assert_eq!(depth_of_field_sigma(&dof_camera(1.0, 0.0), 9.0), 0.0);
3125+
}
3126+
29443127
fn card_with_shadow(overflow_hidden: bool) -> BoxNode {
29453128
let mut css = CssStyle {
29463129
position: Some(Position::Absolute),

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,23 @@ pub struct Camera {
852852
/// default to 0 (top-left corner) — set both explicitly.
853853
#[serde(default)]
854854
pub origin: Option<CameraOrigin>,
855+
/// Depth the camera is focused on, on the same scale as `style.depth`.
856+
/// Default `1.0` — the plane an element sits on when it declares no
857+
/// depth, so a scenario that never mentions focus is focused on
858+
/// everything it has.
859+
#[serde(default = "default_camera_focus")]
860+
pub focus: f32,
861+
/// How fast sharpness falls off away from `focus`, in blur pixels per
862+
/// unit of depth difference. Default `0.0` — no depth of field at all,
863+
/// which is what every scenario written before this rendered. `4.0` is
864+
/// a visible but readable separation; past `20.0` an out-of-focus plane
865+
/// reads as a colour wash.
866+
///
867+
/// Animate `focus` through `keyframes` for a rack focus; animating
868+
/// `aperture` instead opens and closes the effect without moving the
869+
/// focal plane.
870+
#[serde(default)]
871+
pub aperture: f32,
855872
/// Keyframe animations for camera properties.
856873
#[serde(default)]
857874
pub keyframes: Vec<CameraKeyframe>,
@@ -867,7 +884,9 @@ pub struct CameraOrigin {
867884
pub y: f32,
868885
}
869886

870-
const KNOWN_CAMERA_PROPERTIES: &[&str] = &["x", "y", "zoom", "rotation", "origin.x", "origin.y"];
887+
const KNOWN_CAMERA_PROPERTIES: &[&str] = &[
888+
"x", "y", "zoom", "rotation", "origin.x", "origin.y", "focus", "aperture",
889+
];
871890

872891
fn validate_camera_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
873892
if KNOWN_CAMERA_PROPERTIES.contains(&value) {
@@ -925,6 +944,10 @@ pub struct CameraKeyframePoint {
925944
pub value: f32,
926945
}
927946

947+
fn default_camera_focus() -> f32 {
948+
1.0
949+
}
950+
928951
fn default_camera_zoom() -> f32 {
929952
1.0
930953
}

‎crates/rustmotion/skills/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con
236236
- [rules/halo-shapes.md](rules/halo-shapes.md) - `halo` beyond circles: `radius_x`/`radius_y`/`rotation` for a wide thin band of light, and why the blur follows the short axis
237237
- [rules/zoom-blur-transition.md](rules/zoom-blur-transition.md) - The radial "tunnel" cut: `zoom_blur`'s `strength`/`origin`, why it had to be a transition and not an effect, and the pivot-coincident-edge trap
238238
- [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
239+
- [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
239240
- [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds
240241
- [rules/clip-path.md](rules/clip-path.md) - Non-rectangular masking: the six `clip-path` shapes, how their percentages resolve, and why `node-path` is not one of them yet
241242
- [rules/overlapping-scenes.md](rules/overlapping-scenes.md) - Make an element outlive a cut: overlapping `at` windows composite instead of replacing, who supplies the background, and why `snap` never creates an overlap
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Profondeur de champ : `camera.focus` et `camera.aperture`
2+
3+
`style.depth` sert déjà à la parallaxe : un élément à `depth: 3` se déplace trois
4+
fois plus que le décor quand la caméra bouge. Les deux mêmes chiffres pilotent
5+
maintenant la netteté.
6+
7+
| Champ | Défaut | Rôle |
8+
|---|---|---|
9+
| `camera.focus` | `1.0` | la profondeur qui est nette, sur l'échelle de `style.depth` |
10+
| `camera.aperture` | `0.0` | pixels de flou par unité d'écart, donc `0` = tout net |
11+
12+
```
13+
sigma = aperture × |depth − focus|
14+
```
15+
16+
C'est linéaire et symétrique : un plan deux unités devant est aussi flou qu'un
17+
plan deux unités derrière.
18+
19+
```json
20+
"camera": {
21+
"aperture": 7.0,
22+
"focus": 1.0,
23+
"keyframes": [{ "property": "focus", "values": [
24+
{ "time": 0.0, "value": 1.0 },
25+
{ "time": 2.0, "value": 3.0 }
26+
]}]
27+
}
28+
```
29+
30+
`focus` et `aperture` s'animent par `keyframes` comme `zoom` et `rotation`.
31+
Animer `focus` donne un **rack focus** — la mise au point glisse d'un plan à
32+
l'autre. Animer `aperture` ouvre et ferme l'effet sans déplacer le plan net.
33+
34+
## Ordres de grandeur
35+
36+
`aperture: 4` sépare visiblement sans gêner la lecture. Au-delà de `20`, un plan
37+
hors focus devient un aplat de couleur — utile pour un fond, pas pour du texte
38+
qu'on doit encore reconnaître.
39+
40+
## Trois choses à savoir
41+
42+
**Rien ne bouge sans profondeurs distinctes.** La caméra par plans n'est résolue
43+
que si au moins un enfant déclare `style.depth` ; et comme `depth` et `focus`
44+
valent tous deux `1.0` par défaut, un scénario qui ne mentionne ni l'un ni l'autre
45+
est net partout. `aperture: 0` rend **exactement** les mêmes octets qu'avant que
46+
la fonctionnalité existe.
47+
48+
**La profondeur se lit au premier niveau.** Comme la parallaxe, le flou s'applique
49+
aux enfants directs de la scène : un plan est une couche, pas un nœud isolé au
50+
fond d'un sous-arbre. Mettre `depth` sur un enfant profond ne le défocalise pas
51+
tout seul.
52+
53+
**Le flou déborde de la boîte.** La couche de peinture est élargie de `3 × sigma`
54+
pour que le dégradé ne soit pas coupé net au bord. Un `overflow: hidden` sur le
55+
parent, lui, le coupera — c'est la sémantique CSS attendue, pas un bug.

‎crates/rustmotion/src/engine/render/scene.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ static IDENTITY_CAMERA: Camera = Camera {
8686
rotation: 0.0,
8787
origin: None,
8888
keyframes: Vec::new(),
89+
focus: 1.0,
90+
aperture: 0.0,
8991
};
9092

9193
fn effective_camera(scene: &Scene) -> Option<&Camera> {
@@ -114,6 +116,8 @@ fn resolve_plane_camera(
114116
rotation: interpolate_camera_property(camera, "rotation", time) + shake.rotation as f32,
115117
origin_x,
116118
origin_y,
119+
focus: interpolate_camera_property(camera, "focus", time),
120+
aperture: interpolate_camera_property(camera, "aperture", time),
117121
}
118122
}
119123

@@ -1338,6 +1342,8 @@ pub(super) fn interpolate_camera_property(camera: &Camera, property: &str, time:
13381342
"y" => camera.y,
13391343
"zoom" => camera.zoom,
13401344
"rotation" => camera.rotation,
1345+
"focus" => camera.focus,
1346+
"aperture" => camera.aperture,
13411347
"origin.x" => camera.origin.as_ref().map(|o| o.x).unwrap_or(0.0),
13421348
"origin.y" => camera.origin.as_ref().map(|o| o.y).unwrap_or(0.0),
13431349
_ => 0.0,

0 commit comments

Comments
 (0)