Skip to content

feat(camera): depth of field from style.depth and a focus distance - #356

Merged
LeadcodeDev merged 1 commit into
mainfrom
feat/depth-of-field
Sep 27, 2026
Merged

LeadcodeDev merged 1 commit into
mainfrom
feat/depth-of-field

Conversation

@LeadcodeDev

Copy link
Copy Markdown
Owner

Refs #355 — its tractable half, the one I said could go first without deciding anything about materials.

It reuses what is already there

style.depth drives parallax: an element at depth: 3 travels three times as far as the backdrop. The same two numbers now drive sharpness.

Field Default Meaning
camera.focus 1.0 the depth that is sharp, on style.depth's own scale
camera.aperture 0.0 blur pixels per unit of depth difference — 0 means no depth of field
sigma = aperture × |depth − focus|

Linear 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 as zoom and rotation, so a rack focus is just a focus keyframe track. Animating aperture instead opens and closes the effect without moving the focal plane.

"camera": {
  "aperture": 7.0,
  "focus": 1.0,
  "keyframes": [{ "property": "focus", "values": [
    { "time": 0.0, "value": 1.0 },
    { "time": 2.0, "value": 3.0 }
  ]}]
}

That also meant 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 — the "a feature is not done when loader.rs knows about it" trap, working as intended for once.

Measured

Three cards at depths 1, 2 and 3, aperture: 7, focus animated 1 → 3. Counting edge-gradient pixels (neither background nor the card's pure colour):

t focus depth 1 depth 2 depth 3
0.0 1.0 0 22 41
1.0 2.0 21 0 20
2.0 3.0 43 22 0

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-identical
  • a_plane_on_the_focus_distance_stays_sharp
  • a_node_with_no_declared_depth_sits_on_the_default_focal_plane — depth and focus both default to 1.0, so a scenario mentioning neither is sharp
  • focus_is_symmetric_in_front_of_and_behind_the_focal_plane
  • depth_of_field_sigma_grows_linearly_with_the_aperture

cargo 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.filter and chromatic_aberration, and extends that same layer's bleed bounds by 3 × sigma so the gradient is not cut off at the box edge. An overflow: hidden parent will still cut it — that is CSS semantics, and rules/depth-of-field.md says 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 material preset 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.

#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
@LeadcodeDev LeadcodeDev added the enhancement New feature or request label Sep 27, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 27, 2026
@LeadcodeDev
LeadcodeDev merged commit 3c467ea into main Sep 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant