Depth photo
<sg-depth-photo>: a photograph (or a drawing) with a depth map, seen through a camera. The <img> inside is the picture and its alt is what a screen reader hears. Live (three.js): the focus racks by depth with a circle-of-confusion gather, the camera dollies with true parallax, and the water moves inside a mask.
quiet warm playful
Open the live demo
npx susegad add depth-photo
Stands on: Core, Core: components, Engine, Stage3d: the three.js tier, Tokens
The prompt
the prompt
A photograph with a depth map, seen through a camera: the rack focus of a film lens, a slow dolly, and water that moves inside its own mask.
The picture stays an ordinary <img>, with its alt text, for everyone the canvas cannot reach. Over it, three.js draws the same picture as a surface pushed back to each pixel's depth, so moving the camera gives true parallax, and a lens model blurs every pixel by how far it sits from the focus. Where three cannot run, or should not (quiet, reduced motion, Save-Data), a 2D canvas draws the same frame at rest, and the focus still racks.
<script type="importmap">{ "imports": { "three": "/vendor/three.module.js" } }</script>
<link rel="stylesheet" href="susegad/stage3d/depth-photo/depth-photo.css">
<script type="module" src="susegad/stage3d/depth-photo/depth-photo.js"></script>
<sg-depth-photo depth="depth.png" layers="layers.png" horizon="0.334" shore="0.478" keep="0.46 0.56" focus="0.21">
<img src="photo.jpg" alt="A paper plate of sev puri on a laterite ledge, the bay behind it">
</sg-depth-photo>
| Attribute | Values | What it does |
|---|---|---|
focus | 0 to 1 | The depth that is sharp: 0 is the horizon, 1 the nearest thing. |
aperture | 0 to 2 | A gain on the register's blur. |
dolly, dolly-path | 0 to 1; "dx dy forward pitchDeg" | How far the camera has moved along its path. |
parallax, sea, clarity | 0 to 2, 0 to 2, 0 to 1 | Gains on the pointer lean, the water, and local contrast where in focus. |
keep | "u v" | The photo point kept in view when the frame crops it. |
depth, layers, horizon, shore | URLs; fractions | The depth map; the masks (R water, G sky, B subject); the water's band. |
treatment | photo, drawn | Draw the photo, or a child img or canvas with data-treatment="drawn". |
renderer | auto, live, 2d, still | Force a tier. |
The prompt
Make a web component that shows a photograph with a depth map as a small 3D stage. Keep the photograph as a real <img> with alt text inside the element and lay a canvas over it, hidden from assistive tech, so the picture is there with no JavaScript, no WebGL, or before anything loads. Load three.js with a dynamic import('three'), and only when the element will actually move; if the import fails, say why once in the console and draw in 2D. Draw in two passes. First, a look pass in the photo's own coordinates: grade the photo so its blacks lift toward our ink colour and its whites ease toward paper, add a fixed grain, and inside the water mask flow the pixels toward the viewer with two phases crossfaded (a flow map), faster nearer the shore, with slow light bands rolling in; render it to a mipmapped target and only again when the water has moved. Second, a stage pass: a grid mesh whose vertices are pushed back to each pixel's distance, placed on the rays of a rest camera so the rest view reproduces the photo exactly and any camera move is true parallax. Take the nearest depth within one grid cell for the geometry, so the stretched triangles at a depth edge fall on the far, soft side instead of making teeth along the near edge. In its fragment shader, gather the look over each pixel's circle of confusion on a golden-angle spiral, counting a tap only if its own blur reaches that far, so a sharp plate never bleeds into the soft sea; use fewer taps for small circles and a mip level to pre-blur what they skip. Give it three registers: quiet draws the graded still with the 2D renderer and never downloads three; warm moves only the water; playful adds a pointer and tilt lean and livelier water. Under reduced motion, draw the still. Pause off screen, watch frame time with a quality governor, and halve the pixels when frames stay far too slow. Expose duration, renderFrame(t) and canvasFor(width, height) so an exporter can render exact frames.
Words to code
| When you say | Technique | What happens |
|---|---|---|
keep the photograph as a real <img> | native first | static native = 'img'. The canvas is aria-hidden and only fades in over the image after its first frame lands (an opaque WebGL canvas is black until then). |
| only when the element will actually move | lazy tier | chooseTier() (pure) returns live, 2d or still. Only live calls loadThree(), which caches one import('three') and resolves null on failure. |
| a flow map | two-phase crossfade | LOOK_FRAG samples the photo at uv - flow * (phase - 0.5) for two phases half a cycle apart and mixes them by abs(phase0 * 2 - 1), so each phase resets while it is invisible. seaFlow() in the core is the same curve, tested. |
| on the rays of a rest camera | reprojection | STAGE_VERT puts each vertex at (ndc * tan * z, -z) with z from the depth; projectPoint() in the core does the same maths, and a test proves the rest camera maps every point back to its own pixel. |
| the nearest depth within one grid cell | dilation | Nine depth taps per vertex, max()ed, before the vertex moves. |
| counting a tap only if its own blur reaches that far | scatter as gather | w = smoothstep(r - 0.15 c, r + 0.05 c, coc(tap)), the standard fix for a sharp subject's halo. |
| the circle of confusion | lens model | coc() in story-shims.js (story 1's components/focus/focus.core.js): proportional to |depth - focus| past an in-focus band. The same model drives <sg-focus> and the 2D renderer. |
| draw in 2D | three blur levels | depth-photo.2d.js: the graded photo at full, half and no blur, mixed per pixel by masks computed from the depth map for this focus. Painted once per change. |
| halve the pixels when frames stay far too slow | rescue | The engine governor ignores frames over 250 ms, so three frames over 120 ms in a row halve max-pixels here, down to an eighth. |
| exact frames | frame contract | renderFrame(t) draws time t with the pointer at rest and reads one pixel back before it resolves. canvasFor(w, h) builds its own renderer at exactly w x h. |
Accessibility
- The picture's words are the
<img>alt. The canvas, the drawing and the water are decoration. - Nothing here takes focus or needs a key. The playful lean follows the pointer or the phone's tilt, and never asks for tilt permission.
- Reduced motion draws the still in every register, with no loop running.
- Forced colours hide the canvas and show the photograph.
Credit
Grown from the Susegad scenes' WebGL helper (engine/src/gl.js) and Tollem's 2D fallback discipline. The depth map comes from Depth Anything V2 Small (Apache-2.0), run offline by story 1's tools/depth. The rack focus is a cinematographer's move; the owner's photograph of sev puri by the bay at Reis Magos is its first subject.
<sg-depth-photo> shows a photograph (or a drawing) with a depth map as a small 3D stage: the focus racks by depth, the camera can dolly in with true parallax, and water moves inside a mask. It enhances the <img> inside it, which stays the picture for everyone the canvas cannot reach.
Everyday uses: a homestay or hotel hero that pulls focus from the view to the room, a product shot that comes forward as you scroll, a case study that moves attention across one photograph.
Use
<!-- three is needed only for the live tier; map it, or let your bundler resolve it -->
<script type="importmap">{ "imports": { "three": "/vendor/three.module.js" } }</script>
<link rel="stylesheet" href="susegad/stage3d/depth-photo/depth-photo.css">
<script type="module" src="susegad/stage3d/depth-photo/depth-photo.js"></script>
<sg-depth-photo depth="balcao-depth.png" layers="balcao-layers.png" horizon="0.3" shore="0.5" keep="0.5 0.6" focus="0.2">
<img src="balcao.jpg" alt="The balcão at dusk, the paddy fields beyond it">
</sg-depth-photo>
For a drawing, write the depth map from the drawing's own geometry: packages/scenes/veranda does this, exactly, for a drawn veranda. For a photograph, run a depth model once, offline (Depth Anything V2 Small is what story 1 uses), and scale its output to 0 far, 1 near. Pack the masks into layers.png (R water, G sky, B the subject).
Drive it from script:
const dp = document.querySelector('sg-depth-photo');
dp.set({ focus: 0.87, dolly: 0.6 }); // numbers are clamped; unreadable ones are ignored
dp.place(0.46, 0.6, 0.87); // where a photo point sits in the element now, in CSS px
dp.blurAt(0.2); // the blur radius, in CSS px, of a point at that depth
Attributes
| Attribute | Values | Default |
|---|---|---|
focus | 0 to 1 (0 the horizon, 1 the nearest thing) | 0.2 |
aperture | 0 to 2, a gain on the register's blur | 1 |
dolly | 0 to 1 | 0 |
dolly-path | "dx dy forward pitchDeg" in scene units | "0 -0.06 0.28 -3" |
parallax | 0 to 2, a gain on playful's lean | 1 |
sea | 0 to 2, a gain on the register's water | 1 |
clarity | 0 to 1, local contrast where in focus | 0 |
keep | "u v", the photo point kept in view | "0.5 0.5" |
depth | URL of the depth map (white near) | required |
layers | URL of the masks (R water, G sky, B subject) | none |
horizon, shore | the water's band, fractions of the height | 0.33, 0.48 |
treatment | photo, drawn | photo |
renderer | auto, live, 2d, still | auto |
max-pixels | the live tier's pixel budget | 1000000 |
duration | seconds, for export | 12 |
register | quiet, warm, playful | inherited |
Properties, methods and events
tier(read only):live,2dorstill.params,canvas,duration.set(params),place(u, v, d),blurAt(d),still().- The frame contract (story 1's
packages/story/frame.js; its two helpers live instory-shims.jsuntil then):renderFrame(t)draws exactly timeton the element's own canvas, with the pointer at rest;canvasFor(width, height, { register, keep })returns an off-screen target at exactly that size (keepreframes it, for a share card), withset(),place(),renderFrame(t)andrelease(). sg-readyafter the first drawn frame (detail: { tier, ms }).sg-tierwhen the tier is chosen (detail: { tier, reason }, for example"three.js did not load").
Registers
| Tier | Moves | |
|---|---|---|
| quiet | 2D, never downloads three | nothing; the focus changes as state |
| warm | live | the water only; the rack focus as a transition |
| playful | live | livelier water, and the camera leans with the pointer or the phone's tilt |
Reduced motion, Save-Data and devices with 1 GB of memory or less draw in 2D. Without WebGL, or when three fails to load, it draws in 2D and says why. After a real WebGL context loss it carries on in 2D.
Accessibility
- The
<img>inside is the picture: write its alt as you would for the photograph alone. The canvas isaria-hidden. - Nothing takes focus. The lean is decoration and never asks for motion-sensor permission.
- Forced colours show the photograph without the canvas.
Depth edges
The live tier draws the surface twice. The near layer takes the nearest depth within one grid cell and drops any pixel farther than itself; the far layer, drawn behind it, keeps the map's own depth (and, around the subject in the layers map's blue channel, the farthest depth, with a small inpaint along the depth gradient). So a triangle stretched across a depth edge is never shown: on the story's share card the plate's left rim had a stretched band up to 30 px wide (mean 9.4 px over 188 rows), now drawn by the far layer. Give the element depth maps whose edges are clean steps where it matters (story 1's build/layers.py makes depth-stage.png that way).
Performance
- On this build machine's Intel UHD GPU, a 390 x 844 phone stage at 3x (capped at 1.0 MP) averaged 18.3 ms a frame with the water moving, and a 540 x 720 desktop stage 16.6 ms. The water redraws at 30 Hz. The governor steps detail down on slower devices.
- The look pass only redraws when the water moves; the 2D tier draws once per change.
- Headless Chromium draws WebGL on the CPU (SwiftShader). There the rescue halves the pixels until frames are bearable, so its numbers say little about a real GPU.
Known limits
Pixelation when the camera moves in close (a residual, not fixed). The look pass samples the photo at up to 1280 px tall on screen (the photo's own height for an export). On the story's old phone stage (679 x 1471 device px) at the plate beat, the dolly shows about 63% of the photo's height, so it would need about 2,320 look px to be 1:1: at 1280 each look pixel spans 1.8 device px, and even the 2048 px web copy of the photo would span 1.13. Before
22b2366the look was 768 px tall, 3.0 device px per look pixel. The mesh is 192 columns, about 5.6 device px a cell at that beat. Numbers computed from the code's sizing, not measured. The fix is a larger source (the 3000 x 4000 original) and a look target sized past 1280 when the dolly is close, at a memory and frame-time cost.A jagged seam where the two layers meet (a residual, not fixed). Where the near layer drops a stretched pixel, the far layer draws it, and the boundary between them follows the grid, not the object. Measured on the sev puri photo at the share card's moment (dolly 1, focus on the plate), at the plate's left rim, rendered at 2x (2400 x 1260): the band the far layer fills is 24.6 px wide on average, 60 px at most, over 284 rows, and its outer edge steps 1.78 px RMS from row to row (at 1x: 12.4, 30 and 1.43). At 2x that reads as a cut-out halo along the foil. Likely fixes: a finer grid near depth edges, or feathering the far layer into the near across the band. Measured with a
.shotsproberim.mjs(debug: true paints the dropped band magenta).
Budget
| File | Bytes |
|---|---|
depth-photo.js (element) | 14.9 KB of 16 KB (decision 0009) |
depth-photo.core.js (pure) | 3.8 KB (decision 0010) |
depth-photo.gl.js (live renderer) | 15.4 KB (two stage layers since the share-card review; the 46,080 total was accepted on 26 Sep 2026) |
depth-photo.2d.js (2D renderer) | 6.5 KB |
skins/* | 2.3 KB |
three.js itself (npm three 0.186.1) is loaded only by the live tier.