When a 3D scene should actually start loading
Fourteen Three.js scenes on one site is not a rendering problem, it is a scheduling problem. Two gates, one SPA navigation bug, and the WebGL context I forgot to release.
The expensive part of a scene page is not drawing. It is the moment three.js gets downloaded. Three plus one scene is close to 600 KB; if that rides along with first paint, the bandwidth is spent on a canvas the visitor may never scroll to.
Two gates handle it, and both matter.

Figure: full load on top, the stuck path in the middle, the fixed path at the bottom.
Gate one: visibility, gate two: idle
The stage starts working when it enters the viewport, with 200px of headroom. Scroll short of it and not a single byte is fetched.
Then it waits for requestIdleCallback, so it does not compete with CSS, fonts and the boot script. Safari still has no such API, so it falls back to a 200ms delay — still better than stealing the first paint.
whenVisible(stage, () => {
if (started) return;
whenIdle(() => {
if (!started) void run();
});
});
The gates are fine. The problem is where they live: inside a module script.
Why the second visit is empty
Symptom: opening /en/scenes/aurora/ directly works. Navigating there from /en/scenes/dunes/ leaves the skeleton on screen forever.
The cause is not WebGL, it is ESM caching. The site navigates with ClientRouter, which swaps <body> and re-inserts the new page’s <script type="module">. Modules are cached by URL, so the second insertion never evaluates — bootstrapScene() ran exactly once, on the initial page load.
The first visit works because it is a full load. The second is a cached module being re-inserted, which does nothing at all. data-state stays on loading, and it looks like a failure while no request was ever made.
The fix moves mounting from “module top level” to “event driven”:
document.addEventListener('astro:page-load', () => bootstrapScene());
document.addEventListener('astro:before-swap', teardownScene);
astro:page-load fires on the initial load and after every swap, so a single listener covers the whole lifecycle.
Unmounting counts too
The second bug I fixed in the same pass: the old Vue instance is never torn down on navigation. ClientRouter throws the old DOM away, onBeforeUnmount never runs, so renderer.dispose() never runs either — the WebGL context stays alive.
One leaked context is survivable. The browser limit is around a dozen, and switching between scenes repeatedly will hit it; then new scenes fail to create a context and render black. So astro:before-swap must call app.unmount() explicitly and let the component release geometry, materials and the renderer.
The rules I keep now
- Mounting happens only in
astro:page-load; module top level stays empty - Tear down before the swap and release the WebGL context
- The stage expresses loading / ready / error through
data-state; styles read that attribute only - Manual retry bypasses both gates — the visitor already asked, do not make them wait again
The point of the structure is that adding a scene is just adding a directory. The lifecycle knowledge lives in the mounter, not in every scene.

Comments
…