Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9b1fe88
docs: plan the live pane WebGL 2 fallback
drmrd Sep 7, 2026
8ba0958
test: pin the live preview backend probe to a three way answer
drmrd Sep 7, 2026
3c7a344
feat: probe whether the live preview can render on WebGPU or WebGL 2
drmrd Sep 7, 2026
fa82a23
refactor: close the live preview backend probe cycle
drmrd Sep 7, 2026
86fd254
test: require the scene canvas to mount on a WebGL 2 runtime
drmrd Sep 7, 2026
94b1f26
fix: mount the live 3D view on a WebGL 2 runtime instead of refusing it
drmrd Sep 7, 2026
5328ef6
refactor: close the scene canvas backend gate cycle
drmrd Sep 7, 2026
e714e80
test: require the scene pane to render a WebGL 2 preview
drmrd Sep 7, 2026
c3b6a78
fix: keep the 3D preview for browsers that render through WebGL 2
drmrd Sep 7, 2026
7c18d3b
refactor: close the scene pane backend gate cycle
drmrd Sep 7, 2026
f4a4a08
test: require a dismissible notice when the preview runs without WebGPU
drmrd Sep 7, 2026
4ceeaef
feat: say when the 3D preview is running without WebGPU
drmrd Sep 7, 2026
5ac97d0
refactor: close the WebGPU fallback notice cycle
drmrd Sep 7, 2026
7cc93a9
docs: record the live pane WebGL 2 fallback in ADR-0174
drmrd Sep 7, 2026
74b17fd
docs: record the live pane WebGL 2 fallback plan as executed
drmrd Sep 7, 2026
2cb44ea
test: require the backend probe to survive a throwing context request
drmrd Sep 7, 2026
bcafdf0
fix: keep a refused WebGL 2 context probe from blanking the editor
drmrd Sep 7, 2026
d3b0763
refactor: document the cached answer on the live preview backend probe
drmrd Sep 7, 2026
1411d66
docs: record the probe throw guard in ADR-0174 and the plan
drmrd Sep 7, 2026
f8f0da7
docs: update the debugging playbook for the landed WebGL 2 fallback
drmrd Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .claude/skills/vernacular-debugging-playbook/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,10 @@ Story: the live preview is a second scene-construction path that has repeatedly

The two paths:

| Path | Entry | Scene construction | Backend |
| ------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| Harness | `?fixture=scene-harness` URL param, handled in `app/app.tsx`, rendered by `bridge/react/scene-harness-view.tsx` | `buildFramedScene` (`bridge/react/framed-scene.ts`) calls engine `buildScene` (`engine/scene/build-scene.ts`): one-shot full build | Deterministic WebGL 2 |
| Live preview | Editor preview/split pane (`WebGPUSceneView`, `bridge/react/webgpu-scene-view.tsx`) | `useFramedScene` > `createFramedSceneReconciler` (`bridge/react/framed-scene-reconciler.ts`): incremental reconcile of subgroups | WebGPU (hard gate, #476 open) |
| Path | Entry | Scene construction | Backend |
| ------------ | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Harness | `?fixture=scene-harness` URL param, handled in `app/app.tsx`, rendered by `bridge/react/scene-harness-view.tsx` | `buildFramedScene` (`bridge/react/framed-scene.ts`) calls engine `buildScene` (`engine/scene/build-scene.ts`): one-shot full build | Deterministic WebGL 2 |
| Live preview | Editor preview/split pane (`WebGPUSceneView`, `bridge/react/webgpu-scene-view.tsx`) | `useFramedScene` > `createFramedSceneReconciler` (`bridge/react/framed-scene-reconciler.ts`): incremental reconcile of subgroups | WebGPU, or its WebGL 2 fallback (#476 landed; only a browser with neither shows the unsupported message) |

Discriminating experiment: build and serve (`pnpm build`, `pnpm preview`), then load `http://localhost:4173/?fixture=scene-harness` (add `&scene=<name>` for a named fixture; param catalog: vernacular-config-and-flags). Compare against the live pane on the same content.

Expand All @@ -114,7 +114,7 @@ Check in this order:
document.querySelector('canvas')?.getBoundingClientRect()
```
A 300x150 (or near-zero) rect means the CSS layout collapsed. Fix the layout; the renderer is innocent.
2. Fallback message "Your browser does not support WebGPU..." (`editor/shell/scene-pane.tsx`): the live pane hard-gates on `detectRenderBackend() === 'webgpu'` (also `bridge/react/scene-canvas.tsx`). Issue #476 (open as of 2026-07-05) tracks falling back to WebGL 2 instead.
2. Fallback message "This browser cannot render the 3D view." (`editor/shell/scene-pane.tsx`): the live pane refuses only when `detectLivePreviewBackend()` (`bridge/react/live-preview-backend.ts`) answers `unsupported`, meaning neither WebGPU nor WebGL 2 exists. On WebGL 2 it renders with a dismissible notice instead (#476, ADR-0174).
3. Empty state "Nothing to show in 3D yet" (`editor/shell/scene-pane.tsx`): the canvas only mounts once the floor has geometry. Draw a wall first.
4. Only then suspect the render path (entry 3, then vernacular-rendering-defect-campaign).

Expand Down
2 changes: 2 additions & 0 deletions bridge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export { useSceneGraph } from './react/use-scene-graph'
export type { UseAutosaveOptions } from './react/use-autosave'
export { useAutosave } from './react/use-autosave'
export { SceneCanvas } from './react/scene-canvas'
export { detectLivePreviewBackend } from './react/live-preview-backend'
export type { LivePreviewBackend } from './react/live-preview-backend'
export { SceneHarnessView, type HarnessScene } from './react/scene-harness-view'
export type { SurfaceSelectionStore } from './selection/surface-selection-store'
export { createSurfaceSelectionStore } from './selection/surface-selection-store'
Expand Down
74 changes: 74 additions & 0 deletions bridge/react/live-preview-backend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach, vi } from 'vitest'

// The probe answers once per page load and reuses the answer, so every case
// loads a fresh copy of the module against the runtime it just stubbed.
async function detectBackend(): Promise<string> {
vi.resetModules()
const { detectLivePreviewBackend } = await import('./live-preview-backend')
return detectLivePreviewBackend()
}

// Stands in for the browser's canvas context lookup. jsdom has no WebGL, and its
// own getContext reports "not implemented" rather than returning null, so every
// case that cares about the WebGL 2 answer states it here.
function stubWebGl2Context(context: unknown): void {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(((contextId: string) =>
contextId === 'webgl2' ? context : null) as never)
}

describe('detectLivePreviewBackend', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

it('reports WebGPU when the runtime exposes an adapter', async () => {
vi.stubGlobal('navigator', { gpu: {} })
stubWebGl2Context({})

expect(await detectBackend()).toBe('webgpu')
})

it('reports WebGL 2 when WebGPU is absent but a WebGL 2 context is available', async () => {
vi.stubGlobal('navigator', {})
stubWebGl2Context({})

expect(await detectBackend()).toBe('webgl2')
})

it('reports the preview unsupported when neither backend is available', async () => {
vi.stubGlobal('navigator', {})
stubWebGl2Context(null)

expect(await detectBackend()).toBe('unsupported')
})

it('reports the preview unsupported when the context probe throws', async () => {
vi.stubGlobal('navigator', {})
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation((() => {
throw new Error('context creation blocked')
}) as never)

// Privacy-hardening extensions throw from getContext rather than returning null,
// precisely to defeat probes like this one. The probe runs during render and the
// application mounts no error boundary, so a throw escaping here would blank the
// whole editor rather than costing the user a 3D preview.
expect(await detectBackend()).toBe('unsupported')
})

it('creates at most one probe context however often it is asked', async () => {
vi.stubGlobal('navigator', {})
stubWebGl2Context({})
vi.resetModules()
const { detectLivePreviewBackend } = await import('./live-preview-backend')

detectLivePreviewBackend()
detectLivePreviewBackend()
detectLivePreviewBackend()

// A live 3D view renders many times, and browsers cap the number of live
// WebGL contexts a document may hold. A probe context per render would
// exhaust that cap and take the real scene canvas down with it.
expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledTimes(1)
})
})
51 changes: 51 additions & 0 deletions bridge/react/live-preview-backend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { detectRenderBackend } from '../../engine'

/**
* Which backend the live 3D preview will actually render through. The engine's
* `WebGPURenderer` targets WebGPU when the runtime exposes an adapter and falls back to
* its own WebGL 2 backend when it does not, so `'webgl2'` is a working preview and only
* `'unsupported'` means no preview at all.
*/
export type LivePreviewBackend = 'webgpu' | 'webgl2' | 'unsupported'

// Browsers cap how many live WebGL contexts one document may hold, and the answer cannot
// change within a page load, so the probe context is created once and the verdict reused.
// A probe per render would exhaust the cap and take the scene canvas down with it.
let probedBackend: LivePreviewBackend | null = null

function canCreateWebGl2Context(): boolean {
if (typeof document === 'undefined') {
return false
}
try {
// A runtime can carry the WebGL2RenderingContext constructor and still refuse a context
// (a blocked GPU, WebGL switched off), so this asks for the context rather than the type.
return Boolean(document.createElement('canvas').getContext('webgl2'))
} catch {
// Privacy-hardening extensions throw from getContext rather than returning null, to
// defeat probes like this one. The probe runs during render and the application mounts
// no error boundary, so a throw escaping here would blank the whole editor instead of
// costing the user a 3D preview.
return false
}
}

function probeLivePreviewBackend(): LivePreviewBackend {
if (detectRenderBackend() === 'webgpu') {
return 'webgpu'
}
return canCreateWebGl2Context() ? 'webgl2' : 'unsupported'
}

/**
* Reports the backend the live 3D preview will render through on this runtime.
*
* The answer is probed once and then cached for the life of the module, so a test that
* renders a real consumer without mocking this module gets whichever runtime the first
* caller in that file saw. Drive the answer with a module mock, or reload the module with
* `vi.resetModules()` between cases.
*/
export function detectLivePreviewBackend(): LivePreviewBackend {
probedBackend ??= probeLivePreviewBackend()
return probedBackend
}
47 changes: 43 additions & 4 deletions bridge/react/scene-canvas.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,57 @@
import { describe, it, expect, afterEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { SceneCanvas } from './scene-canvas'
import type { LivePreviewBackend } from './live-preview-backend'

let mockBackend: LivePreviewBackend = 'unsupported'

// The probe is mocked at the module edge so each case states the runtime it means
// rather than reaching for navigator and a canvas context.
vi.mock('./live-preview-backend', () => ({
detectLivePreviewBackend: () => mockBackend,
}))

// The live view mounts an R3F <Canvas>, which measures its parent through a
// ResizeObserver jsdom does not define. The stub keeps this file about which branch
// SceneCanvas takes, not about the renderer.
vi.mock('./webgpu-scene-view', () => ({
// A component export legitimately keeps its PascalCase name in the mock.
// eslint-disable-next-line @typescript-eslint/naming-convention
WebGPUSceneView: () => <div data-testid="webgpu-scene-view" />,
}))

describe('SceneCanvas', () => {
afterEach(() => {
vi.unstubAllGlobals()
mockBackend = 'unsupported'
})

it('renders an accessible fallback when WebGPU is unavailable', () => {
vi.stubGlobal('navigator', {})
it('renders an accessible fallback when the runtime can render no 3D at all', () => {
mockBackend = 'unsupported'

render(<SceneCanvas />)

const status = screen.getByRole('status')
expect(status).toHaveTextContent(/webgpu/i)
expect(status).toHaveTextContent(/3D view/i)
expect(screen.queryByTestId('webgpu-scene-view')).toBeNull()
})

it('mounts the live scene view when the runtime falls back to WebGL 2', () => {
// The renderer selects WebGPU when it is there and falls back to its own WebGL 2
// backend when it is not, so a WebGL 2 runtime gets a working preview rather than
// an unsupported message.
mockBackend = 'webgl2'

render(<SceneCanvas />)

expect(screen.getByTestId('webgpu-scene-view')).toBeInTheDocument()
expect(screen.queryByRole('status')).toBeNull()
})

it('mounts the live scene view when the runtime exposes WebGPU', () => {
mockBackend = 'webgpu'

render(<SceneCanvas />)

expect(screen.getByTestId('webgpu-scene-view')).toBeInTheDocument()
})
})
11 changes: 6 additions & 5 deletions bridge/react/scene-canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { detectRenderBackend } from '../../engine'
import { detectLivePreviewBackend } from './live-preview-backend'
import { WebGPUSceneView } from './webgpu-scene-view'

/** The 3D viewport. Renders the WebGPU scene when available, otherwise an accessible
* message. The WebGL2 fallback renderer arrives in a later phase. */
/** The 3D viewport. Renders the live scene whenever the runtime can drive a backend, on
* WebGPU or on the renderer's own WebGL 2 fallback, and an accessible message when it
* can drive neither. */
export function SceneCanvas() {
if (detectRenderBackend() !== 'webgpu') {
if (detectLivePreviewBackend() === 'unsupported') {
return (
<div role="status" className="scene-canvas__fallback">
This 3D view requires a WebGPU-capable browser.
This browser cannot render the 3D view.
</div>
)
}
Expand Down
116 changes: 116 additions & 0 deletions docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
slug: decisions/ADR-0174-live-pane-webgl-fallback
title: 'ADR-0174: The live 3D pane renders on WebGL 2 instead of refusing to render'
type: decision
tags: [rendering, webgpu, webgl2, 3d-preview, bridge, editor]
related:
[
decisions/ADR-0004-three-js-r3f-webgpu,
decisions/ADR-0019-bridge-dispatch-boundary,
decisions/ADR-0151-ambient-occlusion-render-pipeline,
decisions/ADR-0171-live-view-ci-render-lane,
]
sourceFiles:
[
bridge/react/live-preview-backend.ts,
bridge/react/scene-canvas.tsx,
editor/shell/scene-pane.tsx,
engine/renderer/create-renderer.ts,
]
status: current
updated: 2026-09-07
---

# ADR-0174: The live 3D pane renders on WebGL 2 instead of refusing to render

## Status

Current. Issue #476. This reverses the gate ADR-0004 chose and ADR-0019 recorded.

## Context

Two gates refused the live 3D view unless `navigator.gpu` was present. `editor/shell/scene-pane.tsx`
showed a design-system empty state reading "Your browser does not support WebGPU", and
`bridge/react/scene-canvas.tsx` showed a bare `role="status"` line reading "This 3D view requires a
WebGPU-capable browser". Both asked `detectRenderBackend()`, whose union is `'webgpu' | 'unsupported'`
because ADR-0004 deferred the WebGL 2 path as a post-MVP fast-follow.

The deferral no longer matches the renderer. `createSceneRenderer` builds a `WebGPURenderer`, which
picks WebGPU when an adapter is there and drops to its own WebGL 2 backend when it is not, and
`forceWebGL` pins that same backend for the deterministic harness. Every committed scene baseline is
therefore a WebGL 2 render (ADR-0151), so the path the gates refused is the path CI has been
exercising all along. A browser with WebGL 2 and no WebGPU was told the preview was unavailable while
the code that would have served it sat one call away.

What was missing was a gate on the live path itself. ADR-0151 named the backend split as an uncovered
risk, and the live view had no pixel coverage until ADR-0171 put the live-view spec on a macOS CI
runner with a real WebGPU adapter. With that lane in place, a regression in the live render fails a
required job, which is what makes widening the gate safe to do now.

## Decision

The pane renders whenever the runtime can drive any 3D backend, and says which one it got.

1. **A three-value probe replaces the two-value question.** `detectLivePreviewBackend()`
(`bridge/react/live-preview-backend.ts`) returns `'webgpu'`, `'webgl2'`, or `'unsupported'`. It
delegates the WebGPU half to the engine's existing `detectRenderBackend()` and answers the WebGL 2
half by asking a detached canvas for a `webgl2` context. Asking for the context rather than
checking for the `WebGL2RenderingContext` constructor is deliberate: a runtime can carry the type
and still refuse a context on a blocked GPU. A refusal that arrives as a throw counts as no
support too. Privacy-hardening extensions throw from `getContext` to defeat exactly this kind of
probe, the probe runs during render, and the application mounts no error boundary, so an escaping
throw would blank the editor rather than cost the user a preview.
2. **The probe runs once per page load.** Browsers cap how many live WebGL contexts one document may
hold, and both callers sit on a render path. A probe context per render would exhaust the cap and
take the scene canvas down with it, so the verdict is cached in the module.
3. **Only `'unsupported'` still earns a refusal.** `SceneCanvas` mounts the live view on `'webgpu'`
and on `'webgl2'`, and `ScenePane` keeps its styled empty state for `'unsupported'` alone. The
empty state no longer names WebGPU, since a missing adapter is no longer the reason a preview is
unavailable.
4. **`'webgl2'` raises a dismissible notice over the pane.** It is the design system's `Banner`, so
it carries the shell's notice styling and dismiss control rather than a bespoke strip. It says the
preview is running without WebGPU and that shading and lighting can look slightly different.
Dismissal lasts as long as the pane stays mounted. The notice is positioned over the canvas rather
than in a layout row, so the pane keeps the height the canvas measurement depends on, and the
wrapper ignores pointer input so an orbit drag passes through it.

### The probe lives in `bridge/`, not `engine/`

Widening `RenderBackend` in `engine/renderer/detect-backend.ts` would be the tidier home, and it is
the recorded follow-up. The probe went to `bridge/react/` for two reasons. It needs no Three.js: it
reads `navigator` and a canvas context, so it breaks no layer invariant. And `bridge/` is the lowest
layer both callers can reach, since `editor/` may import `bridge/` but `bridge/` may not import
`editor/`. The engine's `detectRenderBackend()` keeps its narrow meaning, "is a WebGPU adapter
present", and the bridge probe composes on top of it.

### The gates could not simply be deleted

Deleting the bridge gate outright would mount the R3F `<Canvas>` in jsdom, where `react-use-measure`
needs a `ResizeObserver` the unit environment does not define, so the editor shell and app unit tests
would fail on a renderer they never meant to exercise. jsdom answers the probe with no WebGPU and no
WebGL 2 context, so those tests keep the fallback branch they had.

## Consequences

- A WebGL 2 browser gets a working 3D preview. That is the whole of issue #476.
- No baseline moves. The `scene-*` specs run in the `scene-webgl` Playwright project, where
`navigator.gpu` is present on the development Mac and on the macOS runner, so they take the
`'webgpu'` branch and render what they rendered before. The `home.png` baseline is captured on the
default view, which has no 3D region at all.
- The chromium, firefox, and webkit end-to-end projects now mount the live view where they used to
get the fallback line. The one spec that visits the pane there measures the region height and holds
either way, so no end-to-end file changed.
- WebGL 2 is a real render, not a degraded stub, but it is not pixel-identical to WebGPU. The notice
is what keeps that difference from reading as a defect, and the pane has no way to know how large
the difference is on a given frame.
- `detectRenderBackend()` is now called from one place, the bridge probe. Folding the WebGL 2 leg
into the engine helper and retiring the bridge module is a follow-up, along with a live-view
end-to-end case that pins the WebGL 2 branch and the notice.

## References

- Issue #476.
- ADR-0004 (the deferral this reverses), ADR-0019 (the bridge seam that carried the gate).
- ADR-0151 (WebGL 2 is the only baselined backend), ADR-0171 (the live-view CI lane that made this
safe to widen).
- `docs/plans/2026-09-07-live-pane-webgl-fallback.md`.
Loading
Loading