diff --git a/.claude/skills/vernacular-debugging-playbook/SKILL.md b/.claude/skills/vernacular-debugging-playbook/SKILL.md index aab3470e..3bf36d50 100644 --- a/.claude/skills/vernacular-debugging-playbook/SKILL.md +++ b/.claude/skills/vernacular-debugging-playbook/SKILL.md @@ -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=` for a named fixture; param catalog: vernacular-config-and-flags). Compare against the live pane on the same content. @@ -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). diff --git a/bridge/index.ts b/bridge/index.ts index 0e92b137..20191892 100644 --- a/bridge/index.ts +++ b/bridge/index.ts @@ -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' diff --git a/bridge/react/live-preview-backend.test.ts b/bridge/react/live-preview-backend.test.ts new file mode 100644 index 00000000..8981a79a --- /dev/null +++ b/bridge/react/live-preview-backend.test.ts @@ -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 { + 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) + }) +}) diff --git a/bridge/react/live-preview-backend.ts b/bridge/react/live-preview-backend.ts new file mode 100644 index 00000000..95b19158 --- /dev/null +++ b/bridge/react/live-preview-backend.ts @@ -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 +} diff --git a/bridge/react/scene-canvas.test.tsx b/bridge/react/scene-canvas.test.tsx index 013373f5..0cf56c3a 100644 --- a/bridge/react/scene-canvas.test.tsx +++ b/bridge/react/scene-canvas.test.tsx @@ -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 , 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: () =>
, +})) 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() 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() + + 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() + + expect(screen.getByTestId('webgpu-scene-view')).toBeInTheDocument() }) }) diff --git a/bridge/react/scene-canvas.tsx b/bridge/react/scene-canvas.tsx index ee04c790..88b566d9 100644 --- a/bridge/react/scene-canvas.tsx +++ b/bridge/react/scene-canvas.tsx @@ -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 (
- This 3D view requires a WebGPU-capable browser. + This browser cannot render the 3D view.
) } diff --git a/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md b/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md new file mode 100644 index 00000000..1a101698 --- /dev/null +++ b/docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md @@ -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 `` 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`. diff --git a/docs/plans/2026-09-07-live-pane-webgl-fallback.md b/docs/plans/2026-09-07-live-pane-webgl-fallback.md new file mode 100644 index 00000000..6ac1fbda --- /dev/null +++ b/docs/plans/2026-09-07-live-pane-webgl-fallback.md @@ -0,0 +1,77 @@ +# Live pane WebGL 2 fallback Implementation Plan + +> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Every application change here runs the role-separated red-green-blue cycle: a failing `test:` commit, a minimal `feat:` commit, and a closing `refactor:` commit (empty when the review finds nothing). + +**Goal:** Stop telling WebGL 2 browsers that the 3D preview is unavailable. The preview mounts and renders through the renderer's own WebGL 2 fallback, and a dismissible notice near the pane says the preview is running without WebGPU. Issue #476. + +**Architecture:** Two gates refuse the live view today. `editor/shell/scene-pane.tsx` shows a design-system `EmptyState` reading "Your browser does not support WebGPU", and `bridge/react/scene-canvas.tsx` shows a bare `role="status"` message reading "This 3D view requires a WebGPU-capable browser". Both ask `detectRenderBackend()`, which returns `'webgpu'` when `navigator.gpu` is present and `'unsupported'` otherwise. Meanwhile `engine/renderer/create-renderer.ts` builds a `WebGPURenderer`, which selects WebGPU when it is there and falls back to its own WebGL 2 backend when it is not; the deterministic harness pins that same WebGL 2 backend with `forceWebGL` for every committed scene baseline. So the render path the gates refuse is the path CI already exercises. + +Neither gate can simply be deleted. Removing the bridge gate outright would mount the R3F `` in jsdom, where `react-use-measure` needs a `ResizeObserver` the unit environment does not define, so `editor-shell` and app tests would fail on a rendering environment they never meant to exercise. The gates need a wider question instead of no question: can this runtime render 3D at all. That is WebGPU, or a `webgl2` canvas context, and jsdom has neither. + +**Tech Stack:** React 19, `@react-three/fiber`, the existing `editor/design-system` `Banner` and `EmptyState` primitives, vitest with jsdom. + +**Spec:** Issue #476. Its precondition, live-view pixel coverage in CI, landed as issue #469 and ADR-0171. + +## Global Constraints + +- **Allowed files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.css`, `editor/shell/scene-pane.test.tsx`, `bridge/react/live-preview-backend.ts` and its test, `bridge/react/scene-canvas.tsx` and its test, one export line in `bridge/index.ts`, this plan, and `docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md`. Anything under `engine/`, `core/`, `app/`, `storage/`, `e2e/`, or the build configuration means stop and report. +- **Repo rules:** Conventional Commits; no em-dashes; no trailers; author `Dan Moore <9156191+drmrd@users.noreply.github.com>`; ADR prose passes the humanizer standard. +- **Full check chain before the branch is done:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm test`, `pnpm build`, `pnpm rgb:audit`, each exit code read on its own. + +## Design decisions carried into this plan + +1. **The capability probe lives in `bridge/react/`, not in `engine/`.** Widening `RenderBackend` in `engine/renderer/detect-backend.ts` would be the natural home, and the ADR records that as the follow-up. This branch is fenced out of `engine/`, and the probe needs no Three.js: it asks `navigator.gpu` through the engine's existing `detectRenderBackend()` and then asks a throwaway canvas for a `webgl2` context. Bridge is the layer that decides whether the R3F canvas mounts, and it is the lowest layer both consumers can import from, since `editor/` may import `bridge/` but `bridge/` may not import `editor/`. +2. **`detectLivePreviewBackend()` returns three values, not two.** `'webgpu'`, `'webgl2'`, and `'unsupported'`. The pane needs all three: only `'unsupported'` still earns the empty state, and only `'webgl2'` raises the notice. +3. **The notice is the design system's `Banner`, rendered inline in the pane.** It is the shell's existing notice vocabulary, it already carries a dismiss control and the design-language styling, and rendering it directly rather than through the notification store keeps the message next to the pane it describes instead of in the app frame's banner row. The pane owns the dismissed flag as component state, so the notice appears once per session and stays gone after a dismissal. +4. **The notice overlays the pane rather than taking a layout row.** A layout row would change the pane's height and every measurement built on it. The overlay wrapper is pointer-inert so an orbit drag passes through it, and the banner itself takes pointer events back so the dismiss button works. This mirrors the existing `.scene-pane__overlay` treatment. +5. **No e2e file changes.** The notice and the widened gate only reach a runtime that lacks WebGPU. The `scene-*` specs run in the `scene-webgl` project, where `navigator.gpu` is present on the development Mac and the macOS CI runner, so those runs take the `'webgpu'` branch and render exactly what they render today. The `home.png` baseline is captured on the default view, which has no 3D region at all (`toggle-three-d.spec.ts` asserts the count is zero before the toggle). The chromium, firefox, and webkit projects now mount the live view where they previously showed the fallback text; the one spec that visits the pane there, `three-d-preview-canvas.spec.ts`, measures the region height and says in its own header that it holds either way. + +--- + +### Task 1: The probe + +**Files:** `bridge/react/live-preview-backend.ts`, `bridge/react/live-preview-backend.test.ts`. + +- [x] **Step 1 (RED):** A failing test that `detectLivePreviewBackend()` reports `'webgpu'` with `navigator.gpu` present, `'webgl2'` with no `navigator.gpu` but an obtainable `webgl2` canvas context, and `'unsupported'` when neither is there. +- [x] **Step 2 (GREEN):** The module. It delegates the WebGPU question to the engine's `detectRenderBackend()` and probes a detached canvas for the WebGL 2 context, treating a throw as no support. +- [x] **Step 3 (BLUE):** Clean-code review and a closing `refactor:` commit. + +### Task 2: The bridge gate + +**Files:** `bridge/react/scene-canvas.tsx`, `bridge/react/scene-canvas.test.tsx`, `bridge/index.ts`. + +- [x] **Step 1 (RED):** A failing test that `SceneCanvas` mounts the live scene view on a WebGL 2 runtime, alongside the existing test that it keeps the accessible fallback when neither backend exists. The live view is mocked at the module edge so jsdom never mounts an R3F canvas. +- [x] **Step 2 (GREEN):** The gate asks `detectLivePreviewBackend() === 'unsupported'`. The barrel exports the probe and its type. +- [x] **Step 3 (BLUE):** Review and a closing `refactor:` commit. + +### Task 3: The pane gate + +**Files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.test.tsx`. + +- [x] **Step 1 (RED):** A failing test that `ScenePane` renders the live canvas rather than the unavailable empty state when the runtime falls back to WebGL 2, and keeps the empty state when nothing can render. +- [x] **Step 2 (GREEN):** The pane asks the probe and gates on `'unsupported'`. +- [x] **Step 3 (BLUE):** Review and a closing `refactor:` commit. + +### Task 4: The notice + +**Files:** `editor/shell/scene-pane.tsx`, `editor/shell/scene-pane.css`, `editor/shell/scene-pane.test.tsx`. + +- [x] **Step 1 (RED):** A failing test that the pane shows a notice saying the preview is running without WebGPU on the WebGL 2 branch, shows no notice on WebGPU, and drops the notice once the dismiss control is pressed. +- [x] **Step 2 (GREEN):** The inline `Banner`, its overlay rule, and the dismissed flag. +- [x] **Step 3 (BLUE):** Review and a closing `refactor:` commit. + +**Outcome:** the dismissal was first planned as its own cycle. Rendering the design system's `Banner` with `dismissible: true` brings its dismiss control along, so a separate cycle would have had no failing test to open it. The dismissal assertion moved into this cycle's red instead. + +### Task 5: ADR-0174 and the check chain + +**Files:** `docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md`. + +- [x] **Step 1:** Write the ADR: the reversal of the gate ADR-0004 deferred and ADR-0019 recorded, what made the reversal safe (ADR-0171's live-view CI lane, and ADR-0151's note that WebGL 2 is the only baselined backend), the three-value probe and why it sits in bridge, the notice, and the follow-ups. `pnpm knowledge:index` must exit 0 and every `related` slug must resolve to a file. +- [x] **Step 2:** Run the full check chain, each exit code read on its own, and `pnpm rgb:audit` against `origin/main`. + +### Task 6: Review findings + +**Files:** `bridge/react/live-preview-backend.ts` and its test, `docs/knowledge/decisions/ADR-0174-live-pane-webgl-fallback.md`. + +- [x] **Step 1:** The clean-code review raised one must-fix: the probe called `getContext` unguarded, against this plan's own Task 1 wording. Privacy-hardening extensions throw from `getContext` to defeat feature probes, the probe runs during render, and the application mounts no error boundary, so a throw would blank the editor. Red, green, and a closing blue: a failing test for the throwing runtime, the `try`/`catch`, and a doc comment on `detectLivePreviewBackend` recording that the answer is cached per module instance (the review's second finding). +- [x] **Step 2:** ADR-0174 records the throw case alongside the constructor-check reasoning. diff --git a/editor/shell/scene-pane.css b/editor/shell/scene-pane.css index c7a1f85b..3acc53a7 100644 --- a/editor/shell/scene-pane.css +++ b/editor/shell/scene-pane.css @@ -21,3 +21,24 @@ justify-content: center; pointer-events: none; } + +/* + * The backend notice rides over the pane rather than taking a row of it, so the canvas + * keeps the height every measurement below it is built on. It is anchored bottom-right + * to leave the bottom-left camera-controls caption clear, and the wrapper ignores + * pointer input so an orbit drag passes through; the banner itself takes pointer input + * back so its dismiss button works. The width cap keeps the message to a readable + * measure on a wide pane. + */ +.scene-pane__notice { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + z-index: 20; + max-width: min(24rem, calc(100% - var(--space-6))); + pointer-events: none; +} + +.scene-pane__notice > * { + pointer-events: auto; +} diff --git a/editor/shell/scene-pane.test.tsx b/editor/shell/scene-pane.test.tsx index 4a752cbe..ba05059f 100644 --- a/editor/shell/scene-pane.test.tsx +++ b/editor/shell/scene-pane.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest' -import { render, screen, waitFor, act } from '@testing-library/react' +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react' import type { SceneGraph } from '../../core' +import type { LivePreviewBackend } from '../../bridge' import { ScenePane } from './scene-pane' // An empty scene graph: no walls/rooms/openings/stairs/furniture, so the real @@ -37,6 +38,10 @@ const graphWithGeometry: SceneGraph = { // Mockable scene-graph state so each test can drive the empty/non-empty branch. let mockSceneGraph: SceneGraph = graphWithGeometry +// Which backend the runtime can drive. The pane asks the bridge probe rather than +// navigator directly, so each test states the runtime it means here. +let mockBackend: LivePreviewBackend = 'webgpu' + // When true, the mocked SceneCanvas suspends on its first render (throws a // promise) before resolving to the live stub. This mimics how R3F's // suspends its subtree until the renderer boots and the first frame is ready, @@ -67,30 +72,34 @@ vi.mock('../../bridge', () => ({ }, useSceneGraph: () => mockSceneGraph, useActiveFloorId: () => 'g', + detectLivePreviewBackend: () => mockBackend, })) -describe('ScenePane', () => { - afterEach(() => { - vi.unstubAllGlobals() - mockSceneGraph = graphWithGeometry - suspendSceneCanvasOnce = false - sceneCanvasResolved = false - resolveSceneCanvas = null - }) +afterEach(() => { + mockSceneGraph = graphWithGeometry + mockBackend = 'webgpu' + suspendSceneCanvasOnce = false + sceneCanvasResolved = false + resolveSceneCanvas = null +}) - it('renders the styled empty-state fallback when WebGPU is unavailable', () => { - vi.stubGlobal('navigator', {}) +describe('ScenePane backend gating', () => { + it('renders the styled empty-state fallback when the runtime can render no 3D at all', () => { + mockBackend = 'unsupported' const { container } = render() // The design-system EmptyState title, not a bare unstyled string. expect(screen.getByText(/3D preview unavailable/i)).toBeInTheDocument() + // Missing WebGPU is no longer the reason a preview is unavailable, so the copy + // must not blame it: a WebGL 2 browser reaches the live view instead. + expect(screen.queryByText(/WebGPU/i)).toBeNull() // Rendered through the EmptyState primitive (its section markup), not a raw div. expect(container.querySelector('.ds-status--empty')).not.toBeNull() }) it('reassures the user without nesting a duplicate region landmark', () => { - vi.stubGlobal('navigator', {}) + mockBackend = 'unsupported' const { container } = render() @@ -103,7 +112,7 @@ describe('ScenePane', () => { }) it('renders the live scene rather than the fallback when WebGPU is available', () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' render() @@ -113,8 +122,54 @@ describe('ScenePane', () => { expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() }) + it('renders the live scene when the runtime falls back to WebGL 2', () => { + // The renderer targets WebGPU when it is there and falls back to its own WebGL 2 + // backend when it is not, and that fallback path is what every committed scene + // baseline renders through. A WebGL 2 browser therefore gets the preview. + mockBackend = 'webgl2' + + render() + + expect(screen.queryByText(/3D preview unavailable/i)).toBeNull() + expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() + }) + + it('says the preview is running without WebGPU when it falls back to WebGL 2', () => { + mockBackend = 'webgl2' + + render() + + // The two backends can shade a frame differently, so the pane says which one is + // driving rather than letting the difference look like a bug. + expect(screen.getByText(/running without WebGPU/i)).toBeInTheDocument() + // The notice sits beside a working preview, not in place of one. + expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() + }) + + it('drops the backend notice once the user dismisses it', () => { + mockBackend = 'webgl2' + + render() + fireEvent.click(screen.getByRole('button', { name: /dismiss/i })) + + // One showing is the point: the message is worth saying, not worth keeping a + // stripe across the view for the rest of the visit. + expect(screen.queryByText(/running without WebGPU/i)).toBeNull() + expect(screen.getByTestId('live-scene-canvas')).toBeInTheDocument() + }) + + it('shows no backend notice when the preview runs on WebGPU', () => { + mockBackend = 'webgpu' + + render() + + expect(screen.queryByText(/running without WebGPU/i)).toBeNull() + }) +}) + +describe('ScenePane scene readiness', () => { it('keeps the live scene canvas mounted and overlays empty-floor guidance when the active floor has no geometry', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = emptyGraph const { container } = render() @@ -140,7 +195,7 @@ describe('ScenePane', () => { }) it('does not unmount the live scene canvas when the active floor transitions from having geometry to being empty', () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = graphWithGeometry const { rerender } = render() @@ -157,7 +212,7 @@ describe('ScenePane', () => { }) it('shows a loading fallback while the live 3D canvas boots, then the canvas', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' // Geometry is present, so the empty branch is not taken and ScenePane must // delegate to the live canvas. Make that canvas suspend on first render to // exercise the Suspense fallback ScenePane wraps it in. @@ -180,7 +235,7 @@ describe('ScenePane', () => { }) it('shows the loading placeholder for a canvas that mounts late already not-ready', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = graphWithGeometry // The canvas suspends first, so it is not present in the tree at mount // time. When it resolves, it is inserted into the pane subtree already @@ -208,7 +263,7 @@ describe('ScenePane', () => { }) it('shows a quiet placeholder until the scene canvas signals its first frame is ready, then clears it', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = graphWithGeometry render() @@ -235,7 +290,7 @@ describe('ScenePane', () => { }) it('shows only the loading placeholder over an empty floor until the scene is ready, then swaps to the empty-floor guidance', async () => { - vi.stubGlobal('navigator', { gpu: {} }) + mockBackend = 'webgpu' mockSceneGraph = emptyGraph render() diff --git a/editor/shell/scene-pane.tsx b/editor/shell/scene-pane.tsx index eed6e733..aeda0eac 100644 --- a/editor/shell/scene-pane.tsx +++ b/editor/shell/scene-pane.tsx @@ -8,17 +8,34 @@ import { } from 'react' import { sceneGraphForFloor, sceneGraphHasGeometry } from '../../core' -import { detectRenderBackend } from '../../engine' -import { SceneCanvas, useActiveFloorId, useSceneGraph } from '../../bridge' +import { + SceneCanvas, + detectLivePreviewBackend, + useActiveFloorId, + useSceneGraph, +} from '../../bridge' import { LIVE_SCENE_CANVAS_TEST_ID, SCENE_READY_ATTRIBUTE, } from '../../bridge/react/scene-readiness' -import { EmptyState, LoadingState } from '../design-system' +import { Banner, EmptyState, LoadingState, type Notification } from '../design-system' import './scene-pane.css' const LIVE_SCENE_CANVAS_SELECTOR = `[data-testid="${LIVE_SCENE_CANVAS_TEST_ID}"]` +// The renderer falls back to its own WebGL 2 backend when a browser exposes no WebGPU +// adapter, and the two backends can shade the same frame differently. Saying so keeps +// that difference from reading as a defect. It is the design system's banner rather +// than a bespoke strip so it carries the shell's notice styling and dismiss control. +const WEBGL_FALLBACK_NOTICE: Notification = { + id: 'scene-pane-webgl-fallback', + tier: 'banner', + severity: 'info', + message: + 'The 3D preview is running without WebGPU. Shading and lighting can look slightly different here.', + dismissible: true, +} + // Whether the pane's own subtree reports its first frame ready: true when the live // canvas node is not (yet) present, so a not-yet-mounted or still-suspended canvas // never stacks a redundant loading placeholder on top of the Suspense fallback, and @@ -58,6 +75,21 @@ function useSceneReady(paneRef: RefObject): boolean { return isSceneReady } +// The notice shown while the preview renders through the renderer's WebGL 2 fallback. +// Dismissal is remembered for as long as the pane stays mounted and no longer: the +// message is worth one showing per visit, not a permanent stripe across the view. +function BackendNotice(): ReactElement | null { + const [isDismissed, setDismissed] = useState(false) + if (isDismissed) { + return null + } + return ( +
+ setDismissed(true)} /> +
+ ) +} + // The pane lives in the editor layer so the styled fallback can use the design // system, which the bridge layer cannot import. export function ScenePane(): ReactElement { @@ -65,12 +97,13 @@ export function ScenePane(): ReactElement { const activeFloorId = useActiveFloorId() const paneRef = useRef(null) const isSceneReady = useSceneReady(paneRef) - if (detectRenderBackend() !== 'webgpu') { + const backend = detectLivePreviewBackend() + if (backend === 'unsupported') { return ( ) } @@ -81,6 +114,7 @@ export function ScenePane(): ReactElement { }> + {backend === 'webgl2' ? : null} {isSceneReady ? null : (