diff --git a/crates/rustmotion-core/src/engine/paint_pass.rs b/crates/rustmotion-core/src/engine/paint_pass.rs index 4f579f6..e7dcaeb 100644 --- a/crates/rustmotion-core/src/engine/paint_pass.rs +++ b/crates/rustmotion-core/src/engine/paint_pass.rs @@ -34,6 +34,8 @@ pub struct PlaneCamera { pub rotation: f32, pub origin_x: f32, pub origin_y: f32, + pub focus: f32, + pub aperture: f32, } 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 let aberration_shift = active_chromatic_aberration(&node.css, ctx.frame.time) .map(|(cfg, progress)| crate::engine::animator::chromatic_aberration_shift(cfg, progress)); let aberration_filter = aberration_shift.and_then(chromatic_aberration_image_filter); + let defocus_sigma = match (tree_depth, ctx.frame.camera.as_ref()) { + (1, Some(cam)) => depth_of_field_sigma(cam, node.css.depth.unwrap_or(1.0)), + _ => 0.0, + }; + let defocus_filter = depth_of_field_image_filter(defocus_sigma); let combined_filter = { use skia_safe::image_filters; - match (content_filter, aberration_filter) { + let node_filter = match (content_filter, aberration_filter) { (Some(cf), Some(af)) => image_filters::compose(af, cf), (Some(cf), None) => Some(cf), (None, Some(af)) => Some(af), (None, None) => None, + }; + match (node_filter, defocus_filter) { + (Some(nf), Some(df)) => image_filters::compose(df, nf), + (Some(nf), None) => Some(nf), + (None, Some(df)) => Some(df), + (None, None) => None, } }; 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 .map(|shadows| box_shadow_bleed(shadows, &length_ctx)) .unwrap_or(0.0); let aberration_bleed_px = aberration_shift.map(|s| s.abs().ceil()).unwrap_or(0.0); + let defocus_bleed_px = (defocus_sigma * 3.0).ceil(); let bleed = filter_bleed_px .max(shadow_bleed_px) - .max(aberration_bleed_px); + .max(aberration_bleed_px) + .max(defocus_bleed_px); let mut bounds = Rect::from_xywh( box_layout.x - bleed, box_layout.y - bleed, @@ -553,6 +568,20 @@ fn chromatic_aberration_image_filter(shift: f32) -> Option f32 { + if camera.aperture <= 0.0 { + return 0.0; + } + (camera.aperture * (depth - camera.focus).abs()).max(0.0) +} + +fn depth_of_field_image_filter(sigma: f32) -> Option { + if sigma <= 0.0 { + return None; + } + skia_safe::image_filters::blur((sigma, sigma), skia_safe::TileMode::Decal, None, None) +} + fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 { use crate::css::style::FilterFn; let mut bleed = 0.0f32; @@ -2941,6 +2970,160 @@ mod paint_order_tests { ); } + fn dof_camera(focus: f32, aperture: 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, + aperture, + } + } + + fn plane_at_depth(depth: Option) -> BoxNode { + BoxNode { + id: 0, + kind: BoxKind::Container, + css: CssStyle { + position: Some(Position::Absolute), + left: Some(CLP::Px(150.0)), + top: Some(CLP::Px(150.0)), + width: Some(CSize::Length(CLP::Px(100.0))), + height: Some(CSize::Length(CLP::Px(100.0))), + background: Some(Background::Color(CssColor::String("#ff0000".into()))), + depth, + ..Default::default() + }, + children: vec![], + intrinsic: None, + source_path: None, + window: None, + } + } + + fn render_with_camera(node: BoxNode, camera: Option) -> Vec { + let mut root = root_node(400.0, 400.0, "#000000", vec![node]); + root.assign_ids(0); + let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default()); + let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap(); + let mut frame = test_frame(400, 400); + frame.camera = camera; + paint_tree(surface.canvas(), &root, &layout, &frame, &NoopDispatcher); + let info = skia_safe::ImageInfo::new( + (400, 400), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; 400 * 400 * 4]; + surface.read_pixels(&info, &mut buf, 400 * 4, (0, 0)); + buf + } + + /// How many pixels along the plane's top edge are neither background nor + /// full red — the width of the gradient a blur leaves behind. + fn edge_softness(buf: &[u8]) -> usize { + (120..190) + .filter(|y| { + let i = (y * 400 + 200) * 4; + let (r, g) = (buf[i], buf[i + 1]); + r > 12 && r < 240 && g < 60 + }) + .count() + } + + #[test] + fn an_aperture_of_zero_renders_exactly_as_no_camera_at_all() { + let sharp = render_with_camera(plane_at_depth(Some(2.0)), None); + let zero_aperture = + render_with_camera(plane_at_depth(Some(2.0)), Some(dof_camera(1.0, 0.0))); + assert_eq!( + sharp, zero_aperture, + "aperture 0 must be byte-identical to no depth of field: every scenario written \ + before this one declares no aperture" + ); + } + + #[test] + fn a_plane_on_the_focus_distance_stays_sharp() { + let no_dof = render_with_camera(plane_at_depth(Some(2.0)), None); + let focused = render_with_camera(plane_at_depth(Some(2.0)), Some(dof_camera(2.0, 8.0))); + assert_eq!( + no_dof, focused, + "a plane sitting exactly on the focus distance is in focus whatever the aperture" + ); + } + + #[test] + fn a_plane_away_from_the_focus_distance_is_blurred() { + let sharp_softness = edge_softness(&render_with_camera(plane_at_depth(Some(3.0)), None)); + let blurred_softness = edge_softness(&render_with_camera( + plane_at_depth(Some(3.0)), + Some(dof_camera(1.0, 6.0)), + )); + assert!( + blurred_softness > sharp_softness + 8, + "a plane two depth units off focus must show a measurably soft edge: \ + sharp={sharp_softness}px, defocused={blurred_softness}px" + ); + } + + #[test] + fn the_further_from_focus_the_softer_the_edge() { + let near = edge_softness(&render_with_camera( + plane_at_depth(Some(2.0)), + Some(dof_camera(1.0, 6.0)), + )); + let far = edge_softness(&render_with_camera( + plane_at_depth(Some(4.0)), + Some(dof_camera(1.0, 6.0)), + )); + assert!( + far > near, + "softness must grow with distance from the focal plane: \ + 1 unit off={near}px, 3 units off={far}px" + ); + } + + #[test] + fn focus_is_symmetric_in_front_of_and_behind_the_focal_plane() { + let in_front = edge_softness(&render_with_camera( + plane_at_depth(Some(1.0)), + Some(dof_camera(3.0, 6.0)), + )); + let behind = edge_softness(&render_with_camera( + plane_at_depth(Some(5.0)), + Some(dof_camera(3.0, 6.0)), + )); + assert_eq!( + in_front, behind, + "two units in front and two units behind defocus by the same amount" + ); + } + + #[test] + fn a_node_with_no_declared_depth_sits_on_the_default_focal_plane() { + let no_dof = render_with_camera(plane_at_depth(None), None); + let with_dof = render_with_camera(plane_at_depth(None), Some(dof_camera(1.0, 10.0))); + assert_eq!( + no_dof, with_dof, + "depth defaults to 1.0 and focus defaults to 1.0, so a scenario that declares \ + neither is focused on everything it has" + ); + } + + #[test] + fn depth_of_field_sigma_grows_linearly_with_the_aperture() { + let cam = dof_camera(1.0, 4.0); + assert_eq!(depth_of_field_sigma(&cam, 1.0), 0.0); + assert_eq!(depth_of_field_sigma(&cam, 2.0), 4.0); + assert_eq!(depth_of_field_sigma(&cam, 3.0), 8.0); + assert_eq!(depth_of_field_sigma(&dof_camera(1.0, 0.0), 9.0), 0.0); + } + fn card_with_shadow(overflow_hidden: bool) -> BoxNode { let mut css = CssStyle { position: Some(Position::Absolute), diff --git a/crates/rustmotion-core/src/schema/scenario.rs b/crates/rustmotion-core/src/schema/scenario.rs index 409f1c6..fe71c1b 100644 --- a/crates/rustmotion-core/src/schema/scenario.rs +++ b/crates/rustmotion-core/src/schema/scenario.rs @@ -852,6 +852,23 @@ pub struct Camera { /// default to 0 (top-left corner) — set both explicitly. #[serde(default)] pub origin: Option, + /// Depth the camera is focused on, on the same scale as `style.depth`. + /// Default `1.0` — the plane an element sits on when it declares no + /// depth, so a scenario that never mentions focus is focused on + /// everything it has. + #[serde(default = "default_camera_focus")] + pub focus: f32, + /// How fast sharpness falls off away from `focus`, in blur pixels per + /// unit of depth difference. Default `0.0` — no depth of field at all, + /// which is what every scenario written before this rendered. `4.0` is + /// a visible but readable separation; past `20.0` an out-of-focus plane + /// reads as a colour wash. + /// + /// Animate `focus` through `keyframes` for a rack focus; animating + /// `aperture` instead opens and closes the effect without moving the + /// focal plane. + #[serde(default)] + pub aperture: f32, /// Keyframe animations for camera properties. #[serde(default)] pub keyframes: Vec, @@ -867,7 +884,9 @@ pub struct CameraOrigin { pub y: f32, } -const KNOWN_CAMERA_PROPERTIES: &[&str] = &["x", "y", "zoom", "rotation", "origin.x", "origin.y"]; +const KNOWN_CAMERA_PROPERTIES: &[&str] = &[ + "x", "y", "zoom", "rotation", "origin.x", "origin.y", "focus", "aperture", +]; fn validate_camera_property(value: &str) -> Result<(), E> { if KNOWN_CAMERA_PROPERTIES.contains(&value) { @@ -925,6 +944,10 @@ pub struct CameraKeyframePoint { pub value: f32, } +fn default_camera_focus() -> f32 { + 1.0 +} + fn default_camera_zoom() -> f32 { 1.0 } diff --git a/crates/rustmotion/skills/SKILL.md b/crates/rustmotion/skills/SKILL.md index 847fba8..03f1d36 100644 --- a/crates/rustmotion/skills/SKILL.md +++ b/crates/rustmotion/skills/SKILL.md @@ -236,6 +236,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [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 - [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 - [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/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/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [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 - [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 diff --git a/crates/rustmotion/skills/rules/depth-of-field.md b/crates/rustmotion/skills/rules/depth-of-field.md new file mode 100644 index 0000000..5f05a13 --- /dev/null +++ b/crates/rustmotion/skills/rules/depth-of-field.md @@ -0,0 +1,55 @@ +# Profondeur de champ : `camera.focus` et `camera.aperture` + +`style.depth` sert déjà à la parallaxe : un élément à `depth: 3` se déplace trois +fois plus que le décor quand la caméra bouge. Les deux mêmes chiffres pilotent +maintenant la netteté. + +| Champ | Défaut | Rôle | +|---|---|---| +| `camera.focus` | `1.0` | la profondeur qui est nette, sur l'échelle de `style.depth` | +| `camera.aperture` | `0.0` | pixels de flou par unité d'écart, donc `0` = tout net | + +``` +sigma = aperture × |depth − focus| +``` + +C'est linéaire et symétrique : un plan deux unités devant est aussi flou qu'un +plan deux unités derrière. + +```json +"camera": { + "aperture": 7.0, + "focus": 1.0, + "keyframes": [{ "property": "focus", "values": [ + { "time": 0.0, "value": 1.0 }, + { "time": 2.0, "value": 3.0 } + ]}] +} +``` + +`focus` et `aperture` s'animent par `keyframes` comme `zoom` et `rotation`. +Animer `focus` donne un **rack focus** — la mise au point glisse d'un plan à +l'autre. Animer `aperture` ouvre et ferme l'effet sans déplacer le plan net. + +## Ordres de grandeur + +`aperture: 4` sépare visiblement sans gêner la lecture. Au-delà de `20`, un plan +hors focus devient un aplat de couleur — utile pour un fond, pas pour du texte +qu'on doit encore reconnaître. + +## Trois choses à savoir + +**Rien ne bouge sans profondeurs distinctes.** La caméra par plans n'est résolue +que si au moins un enfant déclare `style.depth` ; et comme `depth` et `focus` +valent tous deux `1.0` par défaut, un scénario qui ne mentionne ni l'un ni l'autre +est net partout. `aperture: 0` rend **exactement** les mêmes octets qu'avant que +la fonctionnalité existe. + +**La profondeur se lit au premier niveau.** Comme la parallaxe, le flou s'applique +aux enfants directs de la scène : un plan est une couche, pas un nœud isolé au +fond d'un sous-arbre. Mettre `depth` sur un enfant profond ne le défocalise pas +tout seul. + +**Le flou déborde de la boîte.** La couche de peinture est élargie de `3 × sigma` +pour que le dégradé ne soit pas coupé net au bord. Un `overflow: hidden` sur le +parent, lui, le coupera — c'est la sémantique CSS attendue, pas un bug. diff --git a/crates/rustmotion/src/engine/render/scene.rs b/crates/rustmotion/src/engine/render/scene.rs index 91289aa..e514512 100644 --- a/crates/rustmotion/src/engine/render/scene.rs +++ b/crates/rustmotion/src/engine/render/scene.rs @@ -86,6 +86,8 @@ static IDENTITY_CAMERA: Camera = Camera { rotation: 0.0, origin: None, keyframes: Vec::new(), + focus: 1.0, + aperture: 0.0, }; fn effective_camera(scene: &Scene) -> Option<&Camera> { @@ -114,6 +116,8 @@ fn resolve_plane_camera( rotation: interpolate_camera_property(camera, "rotation", time) + shake.rotation as f32, origin_x, origin_y, + focus: interpolate_camera_property(camera, "focus", time), + aperture: interpolate_camera_property(camera, "aperture", time), } } @@ -1338,6 +1342,8 @@ pub(super) fn interpolate_camera_property(camera: &Camera, property: &str, time: "y" => camera.y, "zoom" => camera.zoom, "rotation" => camera.rotation, + "focus" => camera.focus, + "aperture" => camera.aperture, "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,