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 say | Technique | What happens |
|---|---|---|
| once, cached | a shared promise | loadThree() keeps one promise; setThreeLoader(fn) replaces the loader and resets it. |
resolve null instead of rejecting | fail soft | The loader's rejection is caught; the element reads null and builds the 2D renderer. The sg-tier event carries the reason. |
| never download three | tier choice | chooseTier({ motion: 'still' | 'state' }) returns '2d' before anything else is considered. |
| on the rest camera's ray | reprojection | reproject(u, v, d, tanX, tanY) and projectPoint(); a test proves the round trip at rest. |
like object-fit: cover | frustum window | coverWindow(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
| Export | What 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, parseVec | The camera maths. Pure. |
The tiers
| Tier | When | Downloads three |
|---|---|---|
live | WebGL2, three loaded, motion allowed (warm or playful, no reduced motion), not a lite device | yes |
2d | quiet, reduced motion, Save-Data, 1 GB or less, no WebGL, three failed | no |
still | forced, or the images failed to load | no |
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 bundled | Minified | gzip | brotli |
|---|---|---|---|
import('three'), the whole module, as loadThree() does today | 745,430 B | 192,931 B | 156,241 B |
only the 16 names depth-photo.gl.js uses (WebGLRenderer, Texture, ShaderMaterial, PerspectiveCamera and the rest), tree-shaken | 529,357 B | 133,181 B | 109,570 B |
depth-photo.gl.js on its own, minified | 11,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.