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
187 changes: 185 additions & 2 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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() {
Expand All @@ -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,
Expand Down Expand Up @@ -553,6 +568,20 @@ fn chromatic_aberration_image_filter(shift: f32) -> Option<skia_safe::ImageFilte
image_filters::blend(BlendMode::Plus, Some(red), Some(cyan), None)
}

pub fn depth_of_field_sigma(camera: &PlaneCamera, depth: f32) -> 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<skia_safe::ImageFilter> {
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;
Expand Down Expand Up @@ -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<f32>) -> 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<PlaneCamera>) -> Vec<u8> {
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),
Expand Down
25 changes: 24 additions & 1 deletion crates/rustmotion-core/src/schema/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,23 @@ pub struct Camera {
/// default to 0 (top-left corner) — set both explicitly.
#[serde(default)]
pub origin: Option<CameraOrigin>,
/// 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<CameraKeyframe>,
Expand All @@ -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<E: serde::de::Error>(value: &str) -> Result<(), E> {
if KNOWN_CAMERA_PROPERTIES.contains(&value) {
Expand Down Expand Up @@ -925,6 +944,10 @@ pub struct CameraKeyframePoint {
pub value: f32,
}

fn default_camera_focus() -> f32 {
1.0
}

fn default_camera_zoom() -> f32 {
1.0
}
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 @@ -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
Expand Down
55 changes: 55 additions & 0 deletions crates/rustmotion/skills/rules/depth-of-field.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions crates/rustmotion/src/engine/render/scene.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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),
}
}

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