Susegad UI
Register
Theme
Palette

Foundations

Stage3d: the three.js tier

The lazy three.js tier (decision 0017): loadThree() imports three only when a live 3D drawing is about to be seen, and resolves null if it cannot; webglAvailable(), liteDevice() and watchVisible() choose and pause the tier; the pure core chooses live, 2D or still, reprojects a photo with a depth map so a moving camera gives true parallax, and fits it to any frame.

npx susegad add stage3d

The prompt

the prompt

The three.js tier: loaded late, and only when a live 3D drawing is about to be seen.

A small module every stage3d element uses to decide whether to draw live in 3D, draw in 2D, or show a still, and to load three.js only for the first. The maths it shares (reprojecting a photo with a depth map, fitting a camera window to any frame, a dolly path) is pure and tested in Node.

import { loadThree, webglAvailable, liteDevice, watchVisible, chooseTier } from 'susegad/stage3d/stage3d.js';

const tier = chooseTier({ webgl: webglAvailable(), motion: 'ambient', lite: liteDevice() });
const THREE = tier === 'live' ? await loadThree() : null;   // null: draw the 2D still

The prompt

Make a small ES module that is the only place three.js enters a front-end library whose core has no dependencies. Load three with a dynamic import('three'), once, cached, and resolve null instead of rejecting when it is blocked, missing or throws, with one console warning that says what happened. Let a page swap the loader for a vendored copy. Test for WebGL2 once and release the test context. Treat Save-Data and 1 GB of memory or less as a lite device. Watch an element's visibility and the tab's, so a stage pauses off screen. In a separate pure module, choose the tier from what the device can do and what the reader asked for: reduced motion and the quiet register always draw in 2D and never download three; no WebGL, no three or a lite device draw in 2D; otherwise live; an explicit renderer attribute wins. Put the camera maths there too: map depth to distance linearly in inverse distance, put each photo point on the rest camera's ray so the rest view reproduces the photo exactly, compute the frustum window that covers any viewport like object-fit: cover while keeping a chosen point in view, and move the camera along a dolly path with a clamped pointer lean.

Words to code

When you sayTechniqueWhat happens
once, cacheda shared promiseloadThree() keeps one promise; setThreeLoader(fn) replaces the loader and resets it.
resolve null instead of rejectingfail softThe loader's rejection is caught; the element reads null and builds the 2D renderer. The sg-tier event carries the reason.
never download threetier choicechooseTier({ motion: 'still' | 'state' }) returns '2d' before anything else is considered.
on the rest camera's rayreprojectionreproject(u, v, d, tanX, tanY) and projectPoint(); a test proves the round trip at rest.
like object-fit: coverfrustum windowcoverWindow(va, pa, tanY, { keep, overscan }) crops the long side and clamps the kept point so the window never leaves the photo.

Budget

stage3d.js 4.4 KB and stage3d.core.js 5.6 KB, budgeted apart from the dependency-free layers (decision 0017). three itself is recorded per story, as bundled.

The three.js tier (decision 0017). Core, engine, tokens and components never import three; a stage3d element does, through this module, and only when it will draw live.

Install

node <path-to-susegad-ui>/packages/cli/bin/susegad.mjs add depth-photo   # brings stage3d with it
npm install                                                               # add wrote three 0.186.1 into package.json

susegad add puts three into your package.json for you (its manifest declares npmDependencies). It is pinned exactly: three changes its API most months.

Then either let your bundler resolve import('three'), or give the page an import map:

<script type="importmap">{ "imports": { "three": "/node_modules/three/build/three.module.js" } }</script>

Or load it from anywhere with setThreeLoader(() => import('/vendor/three.js')).

API

ExportWhat it does
loadThree()three's namespace, loaded once; resolves null if it cannot load (one console warning).
setThreeLoader(fn)Replace the loader.
webglAvailable()WebGL2, tested once.
liteDevice()Save-Data, or 1 GB of memory or less.
watchVisible(el, cb)cb(visible) as the element enters and leaves the viewport or the tab hides; returns an unwatch.
decoded(src)An image loaded and decoded, as a fixed-size ImageBitmap (an <img> with a srcset can change size under a texture).
chooseTier({ webgl, three, motion, lite, forced })'live', '2d' or 'still'. Pure.
depthToZ, reproject, projectPoint, coverWindow, cameraAt, follow, parseVecThe camera maths. Pure.

The tiers

TierWhenDownloads three
liveWebGL2, three loaded, motion allowed (warm or playful, no reduced motion), not a lite deviceyes
2dquiet, reduced motion, Save-Data, 1 GB or less, no WebGL, three failedno
stillforced, or the images failed to loadno

Bytes

A page with no live 3D pays nothing for three. packages/stage3d/stage3d.check.mjs proves it from the request log: scene pages, the docs home, and the depth photo in quiet, under reduced motion and without WebGL request nothing from /node_modules/three, and a control run on the live tier must see three requested.

What three costs when a page does use it, bundled and minified by esbuild 0.28.2 (measured on 29 September 2026 for the veranda staged in 3D):

What is bundledMinifiedgzipbrotli
import('three'), the whole module, as loadThree() does today745,430 B192,931 B156,241 B
only the 16 names depth-photo.gl.js uses (WebGLRenderer, Texture, ShaderMaterial, PerspectiveCamera and the rest), tree-shaken529,357 B133,181 B109,570 B
depth-photo.gl.js on its own, minified11,422 B

WebGLRenderer carries most of three, so naming imports saves about 30%, not most of it. A page that ships the named-import bundle through setThreeLoader() gets the smaller figure. The tier's own code stays in its declared budget: stage3d 10,076 of 12,288 bytes, depth-photo 45,800 of 46,080.