Susegad UI
Register
Theme
Palette

Components

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>
AttributeValuesWhat it does
focus0 to 1The depth that is sharp: 0 is the horizon, 1 the nearest thing.
aperture0 to 2A gain on the register's blur.
dolly, dolly-path0 to 1; "dx dy forward pitchDeg"How far the camera has moved along its path.
parallax, sea, clarity0 to 2, 0 to 2, 0 to 1Gains 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, shoreURLs; fractionsThe depth map; the masks (R water, G sky, B subject); the water's band.
treatmentphoto, drawnDraw the photo, or a child img or canvas with data-treatment="drawn".
rendererauto, live, 2d, stillForce 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 sayTechniqueWhat happens
keep the photograph as a real <img>native firststatic 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 movelazy tierchooseTier() (pure) returns live, 2d or still. Only live calls loadThree(), which caches one import('three') and resolves null on failure.
a flow maptwo-phase crossfadeLOOK_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 camerareprojectionSTAGE_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 celldilationNine depth taps per vertex, max()ed, before the vertex moves.
counting a tap only if its own blur reaches that farscatter as gatherw = smoothstep(r - 0.15 c, r + 0.05 c, coc(tap)), the standard fix for a sharp subject's halo.
the circle of confusionlens modelcoc() 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 2Dthree blur levelsdepth-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 slowrescueThe 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 framesframe contractrenderFrame(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

AttributeValuesDefault
focus0 to 1 (0 the horizon, 1 the nearest thing)0.2
aperture0 to 2, a gain on the register's blur1
dolly0 to 10
dolly-path"dx dy forward pitchDeg" in scene units"0 -0.06 0.28 -3"
parallax0 to 2, a gain on playful's lean1
sea0 to 2, a gain on the register's water1
clarity0 to 1, local contrast where in focus0
keep"u v", the photo point kept in view"0.5 0.5"
depthURL of the depth map (white near)required
layersURL of the masks (R water, G sky, B subject)none
horizon, shorethe water's band, fractions of the height0.33, 0.48
treatmentphoto, drawnphoto
rendererauto, live, 2d, stillauto
max-pixelsthe live tier's pixel budget1000000
durationseconds, for export12
registerquiet, warm, playfulinherited

Properties, methods and events

  • tier (read only): live, 2d or still. 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 in story-shims.js until then): renderFrame(t) draws exactly time t on the element's own canvas, with the pointer at rest; canvasFor(width, height, { register, keep }) returns an off-screen target at exactly that size (keep reframes it, for a share card), with set(), place(), renderFrame(t) and release().
  • sg-ready after the first drawn frame (detail: { tier, ms }). sg-tier when the tier is chosen (detail: { tier, reason }, for example "three.js did not load").

Registers

TierMoves
quiet2D, never downloads threenothing; the focus changes as state
warmlivethe water only; the rack focus as a transition
playfullivelivelier 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 is aria-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 22b2366 the 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 .shots probe rim.mjs (debug: true paints the dropped band magenta).

Budget

FileBytes
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.