Field notesBrowser engineering

Make a WebGL page useful when the scene cannot run

A visitor opens a WebGL page on a train, the model download fails, and the loading screen never goes away. The HTTP request succeeded, but the page has failed its user. The useful starting point is a document that explains the project and offers working navigation before a renderer exists.

In the Lab’s reading view, the chapters are HTML. The canvas, loading overlay, sound controls, and camera enhance that document. This separation also makes it possible to describe and link to the project when scripts are disabled.

Separate preference, failure, and capability

Reduced motion is a preference, not a failed graphics test. A lost WebGL context is a runtime failure, not evidence that the visitor has a slow device. Treating both as one “unsupported” state produces confusing messages and can restart motion that the visitor deliberately turned off.

What the page should do in each situation
ConditionUseful response
JavaScript disabled or module unavailableKeep the initial document readable without running recovery code.
Reduced motion or an explicit reading choiceStart with the still presentation and an accurate explanation.
Asset loading failsRelease the loader, preserve the article, and offer a deliberate retry if supported.
Loading takes too longProvide a keyboard-accessible way to skip it.
Context lost after startupStop the rendering path and restore usable content and controls.

The current lab3d.js checks reduced motion, data-saving preference, and ?view=reading before starting the world. Its webglcontextlost handler uses the same no3d() recovery path as startup failure. See the source excerpt below for the actual selectors and conditions.

Make the initial HTML worth reading

A useful fallback answers the same first questions as the interactive page: what is this project, what can I do here, and where can I learn more? “Enable JavaScript” answers none of them. The Lab’s HTML contains a sequence of chapters and links; it does not depend on a successful canvas boot to acquire a title or purpose.

<main id="main">
  <h1>Deep Signal</h1>
  <p>A browser experiment in light, matter, and memory.</p>
  <section aria-labelledby="quartz-title">
    <h2 id="quartz-title">Quartz</h2>
    <p>Explain the specimen and its interaction here.</p>
  </section>
  <a href="/blog/">Read the engineering notes</a>
</main>

This is a shortened structural example, not a second copy of the Lab. A decorative canvas can be hidden from assistive technology with aria-hidden="true". Information and controls that exist only in the canvas need an actual alternative; an ARIA label on a complex scene does not make its interactions accessible.

Hide enhancements until they work

Prefer a default state in which content is visible and enhancements are optional. If a module fails to import, none of its catch handlers can run. A design that hides the document on parse and waits for the module to reveal it has already lost its recovery path.

Use a successful-render state to reveal the scene. Keep essential links and reading content available. In the Lab layout, a <noscript> style also removes the intro and scene controls. That handles intentionally disabled scripts; the default CSS still needs to handle scripts that were enabled but failed.

/* An adaptable pattern: the article remains visible throughout. */
.scene-layer { visibility: hidden; }
.has-scene .scene-layer { visibility: visible; }

@media (prefers-reduced-motion: reduce) {
  .scene-layer { display: none; }
}

Do not make a “Skip intro” control available only after the large scene bundle finishes loading. Put it in the initial HTML and wire a small, independent handler if skipping needs JavaScript. A stalled loader must not hold the entire page’s navigation hostage.

Give startup and disposal a shared owner

A loader timeout creates a race: the visitor chooses reading mode, then an old model request completes and reopens the scene. A cancellation flag or generation token should be checked after each awaited load. Dispose of an abandoned result rather than attaching it to the page.

// Adapt this to the renderer and resource ownership in your app.
let generation = 0;
let world = null;

function useReadingView() {
  generation++;
  world?.dispose();
  world = null;
  document.body.classList.remove("has-scene");
}

async function enableScene() {
  const mine = ++generation;
  try {
    const candidate = await buildWorld();
    if (mine !== generation) {
      candidate.dispose();
      return;
    }
    world?.dispose();
    world = candidate;
    world.renderFirstFrame();
    document.body.classList.add("has-scene");
  } catch {
    // An old failure must not cancel a newer startup.
    if (mine === generation) useReadingView();
  }
}

enableScene();

Here buildWorld(), dispose(), and renderFirstFrame() are application contracts, not browser APIs. Rendering code must also detect context loss. Disposal includes owned animation callbacks, listeners, GPU resources, and audio; merely hiding the canvas leaves that work running.

Test the reading path directly

Start a fresh page with JavaScript disabled. Then test a fresh page with reduced motion, one with the model request blocked, and one in which you skip loading before the request completes. Check that the title, chapter text, contact link, and keyboard navigation still work. A text-length assertion alone cannot tell you whether the document is useful.

For the running scene, deliberately lose the context in a local test, where the browser exposes WEBGL_lose_context, and inspect recovery. Do not assume a screenshot of a still poster validates loader cancellation or focus management.

The repository has a browser smoke harness, but its deployment workflow currently runs the build without that harness. Running a check and wiring a check into deployment are separate tasks. The browser-testing note explains how to keep those claims separate.

What a reading version cannot provide

A written description of an arena is not a keyboard or touch alternative to playing it. A still image cannot convey every spatial relationship in a moving scene. Be specific about what remains available, and keep instructions synchronized with the current project. The test of this page is whether someone can learn from it and navigate onward even when its most ambitious enhancement is unavailable.

Sources & revision

Read the dated source excerpt. It includes original file paths, line numbers, and SHA-256 hashes for the version used in this guide. The public repository may contain a different revision.

28 Oct 2026: Replaced descriptions of the older crystal-shaft layout and removed the unsupported claim that deployment automatically runs fallback tests.

These notes use AI assistance. Read the editorial method and corrections policy. Found an error? Send a correction with this article’s title.