MarbleMagnificence

Dev log

An orthographic camera has no lens

Rendering commit 9768305

SceneKit's depth of field is not mistuned under this camera. It is inert. Proving that took a measurement; replacing it took a depth buffer that runs backwards.

The camera is orthographic, pitched down 35.264 degrees, and spins through a full circle. Adding depth of field to it should have been one line: camera.wantsDepthOfField = true. That line does nothing — not subtly, nothing — because SceneKit’s circle of confusion is a thin-lens model, and an orthographic camera has no lens.

Grayscale depth map of The Mountain: the terraces read as flat bands stepping from near-white at the bottom of the frame to near-black at the top, with the marble a bright disc near the centre.
The buffer the blur reads, rendered headlessly by marble-ci-renderer --depth-map. White is nearest. Note that the raised terraces read brighter than the ground beside them — on this camera high ground is near ground, which is why a focal plane pinned to the marble rakes through a third of the depth budget over one descent.

Proving a negative

“It doesn’t seem to be working” is not a finding. RMSE against a baseline frame, iPhone 16 Pro simulator, where two identical launches differ by 0.0012 — the floor any claim has to clear:

settingRMSE vs baselinevs noise floor
-bloom 0.35 1.20 (control)0.0294924x
dof, 52mm @ f/1.40.007446x
dof, 600mm @ f/1.40.005875x

A 12x sweep in focal length with no monotonic response. Bloom is the control that makes it conclusive rather than suspicious: same chain, 24x the floor. The post-process runs. Depth of field specifically does not.

The trap, for anyone re-testing this: do not test at a short focal length. A 52mm lens focused at 60 units has a hyperfocal distance near 64, so its depth of field swallows the whole scene. A null result there is optically correct and means nothing.

A depth error does not look like a depth error

Real blur needs an SCNTechnique with its own passes, and everything that went wrong in one failed silently. The expensive one: the depth buffer is reverse-Z, 1 at the near plane and 0 at the far one.

Read through a forward lerp, that puts every pixel at z ≈ 340 in a scene spanning 47 to 73 — a flat blue heatmap and a uniformly blurred frame, indistinguishable from an empty depth buffer. Two rounds went into debugging the wrong component. It was never empty. It was inverted.

What broke the deadlock was logging the real camera state from Swift rather than inferring it in the shader: zNear 1.0, zFar 400.0, ortho YES, projection m33 = -0.005013 and m43 = -1.005013 — exactly -2/(f-n) and -(f+n)/(f-n). Ground truth beat three rounds of plausible reasoning.

The focal plane rides in the fog

The blur has to know where to focus, and the obvious channel — a technique symbol set from Swift — does not bind; it reads as zero in the shader.

Hardcoding the plane at 60 world units held until the tunnel ride, which pulls the camera in to about 20 and left the plane behind the scene, softening the whole frame. That does not read as a focus bug either. It reads as a blur that is too strong. Sampling depth at screen centre fails differently: speed look-ahead aims up to 60% of the half-extent past the marble, so rolling fast focuses on the route rather than on the thing being steered.

So the focal plane travels in fogColor.a. This renderer has no scene fog — aerial perspective is a material modifier — and SceneKit includes that channel in every scene buffer regardless. Unlike a symbol it binds, and unlike a depth search it cannot lock onto scenery. Not elegant; written down, so the next person finds a reason rather than a mystery.

Costs

The technique costs the view its 4x MSAA, because multisampling and target chaining do not coexist. The gather has no per-tap CoC weighting, so a sharp subject against a blurred background leaves a faint halo at the silhouette.

Frame cost is unmeasured — two full-resolution passes plus a scene pass, on a phone, with no number attached to any of it. That, not the look, is what should decide whether it ships.