feat(camera): depth of field from style.depth and a focus distance - #356
Merged
Merged
Conversation
#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
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #355 — its tractable half, the one I said could go first without deciding anything about materials.
It reuses what is already there
style.depthdrives parallax: an element atdepth: 3travels three times as far as the backdrop. The same two numbers now drive sharpness.camera.focus1.0style.depth's own scalecamera.aperture0.00means no depth of fieldLinear and symmetric: two units in front defocuses exactly as much as two units behind.
Keyframes came free, and the validator caught the gap
Both fields go through
interpolate_camera_property, the same path aszoomandrotation, so a rack focus is just afocuskeyframe track. Animatingapertureinstead opens and closes the effect without moving the focal plane.That also meant
KNOWN_CAMERA_PROPERTIEShad to learn them. The validator rejects an unknown keyframe property by design, and it caught"property": "focus"before any render did — the "a feature is not done whenloader.rsknows about it" trap, working as intended for once.Measured
Three cards at depths 1, 2 and 3,
aperture: 7,focusanimated 1 → 3. Counting edge-gradient pixels (neither background nor the card's pure colour):The diagonal of zeros is the rack focus travelling. Softness grows linearly with distance from focus, symmetrically.
Verification
Seven tests. Two fail without the filter, reporting
sharp=0px, defocused=0px. The other five assert equality and pass both ways on purpose — they are the backward-compatibility pins:an_aperture_of_zero_renders_exactly_as_no_camera_at_all— byte-identicala_plane_on_the_focus_distance_stays_sharpa_node_with_no_declared_depth_sits_on_the_default_focal_plane—depthandfocusboth default to1.0, so a scenario mentioning neither is sharpfocus_is_symmetric_in_front_of_and_behind_the_focal_planedepth_of_field_sigma_grows_linearly_with_the_aperturecargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(1576) all clean.Where the blur sits in the pipeline
It composes into the node's existing filter chain beside
style.filterandchromatic_aberration, and extends that same layer's bleed bounds by3 × sigmaso the gradient is not cut off at the box edge. Anoverflow: hiddenparent will still cut it — that is CSS semantics, andrules/depth-of-field.mdsays so.Not in this PR
The glossy material — highlight, specular edge, inner shade — is the other half of #355 and is not here. It needs a lighting model decided first: either the scenario declares a light and shapes derive all three from it, or a
materialpreset bakes a fixed look. Those are different features with different vocabularies. That decision is coming in a follow-up, not smuggled in here.Written comment-free, per the codebase-wide rule from #345.