Skip to content

feat: WebGPURenderer support - #53

Merged
bigmistqke merged 31 commits into
solidjs-community:nextfrom
bigmistqke:webgpu-support
May 26, 2026
Merged

bigmistqke merged 31 commits into
solidjs-community:nextfrom
bigmistqke:webgpu-support

Conversation

@bigmistqke

Copy link
Copy Markdown
Contributor

Summary

Adds support for WebGPURenderer (and other non-WebGL renderers) by widening the gl Canvas prop and Context.gl from WebGLRenderer to a structural RendererLike interface. Builds on @vorth's original draft — rebased onto next and hardened with prior-art lessons from react-three-fiber v10.

Key changes

  • RendererLike interface — structural type covering WebGLRenderer, WebGPURenderer, SVGRenderer, CSS2DRenderer, and any custom renderer matching the same shape. No new dependency on three/webgpu.
  • Renderer detection in gl memo — recognises any renderer instance regardless of class (previously only WebGLRenderer instanceof was matched and anything else silently fell through to a brand-new default WebGLRenderer).
  • Async init() support — render loop early-returns until await renderer.init() resolves. Skipped when renderer.hasInitialized?.() returns true so users can pass a pre-initialized renderer. Canvas backing buffer is pre-sized before init() to avoid depth-attachment mismatch (matches r3f's fix for #3651).
  • WebGL-only effects guarded — outputEncoding/toneMapping and the config-object useProps branch only run when the active renderer is a WebGLRenderer.
  • Race protection — onCleanup aborts a stale init() when the gl memo swaps mid-await.
  • xr handlers — early-return when renderer.xr is undefined (e.g. non-XR WebGPU build).

Includes @bigmistqke's prior fix f10acf3 (useRef must live at the end of create-three.tsx) because the WebGPU work depends on the post-fix file structure.

Usage

import { WebGPURenderer } from 'three/webgpu'

<Canvas gl={canvas => new WebGPURenderer({ canvas })}>
  {/* scene */}
</Canvas>

Code style

Adds a CONTRIBUTING.md (renamed from CONTRIBUTION.md, trimmed of unused TC39 stage process & design maxims) documenting:

  • No ! non-null assertions — narrow with if/?./early return
  • No abbreviated variable names

Test plan

  • All existing tests pass (pnpm test — 118 pass, 2 todo)
  • TypeScript clean (tsc --noEmit)
  • 7 new tests in tests/core/renderer.test.tsx:
    • RendererLike instance accepted as gl
    • RendererLike instance returned from gl factory
    • init() awaited before first render
    • init() skipped when hasInitialized() returns true
    • bare RendererLike (no init) renders immediately
    • outputEncoding/toneMapping not assigned to non-WebGL renderer
    • xr.connect/disconnect no-op when renderer has no xr manager
  • Manual CI smoke-test with an actual WebGPURenderer (needs a browser; @vorth offered to test)

bigmistqke added 8 commits May 3, 2026 11:57
otherwise changes in ref={() => ...} on three-elements (like ctx.camera, ctx.raycaster, ...) are immediately overwritten by the effects following the useRef.

thank you @clinuxrulz for reporting the bug!
see 5edff06 for the initial fix in next-solid-2
CONTRIBUTION.md was a 2023 project-scaffold copy; the TC39 stage process and
"Design Maxims" sections were never adopted. Rename to the conventional
filename (so GitHub picks it up) and trim to what we actually use. Adds a
Code Style section documenting the no-non-null-assertion and
no-abbreviated-names rules.
Widens Canvas's gl prop and Context.gl from WebGLRenderer to a structural
RendererLike interface, so users can pass a WebGPURenderer, SVGRenderer,
CSS2DRenderer, or any custom renderer matching the same shape.

The new type covers the API solid-three actually uses (render, setSize,
setPixelRatio, getPixelRatio) plus optional fields that may or may not exist
on a given renderer (xr, shadowMap, init, hasInitialized).

Downstream:
- create-three xr handlers now early-return when context.gl.xr is undefined
  (was implicit before, now type-required)
- props.ts colorSpace assignment widens to texture's literal type via cast
- utils.ts hasColorSpace generic switches from three's Renderer to RendererLike
- orbit-controls playground reads three.canvas instead of three.gl.domElement
  (WebGPURenderer has no domElement)
- existing WebGL-only tests cast state.gl to WebGLRenderer at the assertion
  site

Behavior under WebGL is unchanged; subsequent commits wire up the renderer
detection and async init() flow.
Reorder the gl memo so a renderer instance (matched structurally via
isRendererInstance) is recognised regardless of class — previously the
WebGLRenderer instanceof check ran first and anything else fell through to
a brand-new default WebGLRenderer, silently dropping a user-supplied
WebGPURenderer.

Color space and tone-mapping effects are now gated to WebGLRenderer; the
config-object useProps branch is gated to non-instance, non-factory inputs.
WebGPURenderer ships an async init() that must complete before the first
render — otherwise the first frame crashes inside the backend. Gate the
render loop on a new glInitialized flag that flips true after init resolves
(synchronously for renderers without init, so WebGL is unaffected).

- Skip init when the renderer reports hasInitialized() === true, so users
  can pass a pre-initialized renderer (matches r3f's #3651 fix).
- Pre-size the canvas backing buffer (rect × devicePixelRatio) before init
  so WebGPU's depth attachment matches the container; otherwise the default
  300×150 buffer mismatches on the first resize.
- Use onCleanup with a cancelled flag to abort a stale init when the gl
  memo swaps mid-await.
Adds 7 tests for the new gl-prop paths:

- RendererLike instance accepted directly
- RendererLike instance returned from the gl factory
- renderer.init() awaited before the first render
- init() skipped when hasInitialized() returns true
- bare RendererLike (no init) renders immediately
- outputEncoding/toneMapping not assigned to a non-WebGL renderer
- xr.connect/disconnect are no-ops when the renderer has no xr manager
WebGPURenderer exposes the same `outputColorSpace` and `toneMapping` API
as WebGLRenderer; the previous `instanceof WebGLRenderer` guard meant a
flat/linear scene rendered with WebGPU silently kept its defaults.

- Replace `instanceof WebGLRenderer` with structural `'prop' in gl` checks
  so both renderers get the props applied, while exotic renderers
  (SVGRenderer, custom) that lack them are skipped cleanly.
- Drop the legacy `outputEncoding` setter (numeric 3000/3001 constants) in
  favour of `outputColorSpace` with `LinearSRGBColorSpace`/`SRGBColorSpace`.
  solid-three already pins `three ^0.164.1`, well past r152 where this
  became the canonical API. Matches r3f's approach.

Update tests: existing `outputEncoding` assertion swapped for
`outputColorSpace`; split the external-renderer color-management test in
two — one for renderers that lack the props (skipped) and one for
renderers that expose them (applied).
Workflows installed `corepack@latest` and ran `corepack enable` without
pinning pnpm, so corepack pulled pnpm 11.3.0 — which requires Node 22.13+
and crashes on Node 20 trying to load `node:sqlite`. Bumping the runner
keeps us on the latest pnpm without a version pin.
pnpm 10+ ignores install scripts by default and exits non-zero when any
were skipped. esbuild needs its postinstall to fetch the platform binary.
Allowlist it via `pnpm.onlyBuiltDependencies`.
pnpm 11 stopped reading the `pnpm` field in package.json; settings live in
pnpm-workspace.yaml now. Without this, esbuild's postinstall is skipped
and install exits non-zero on CI.
The `onlyBuiltDependencies` config in pnpm-workspace.yaml isn't being
honoured by pnpm 11.3 on the runner — install still exits non-zero on
ignored esbuild postinstall scripts. Use the explicit `--allow-build` flag
instead; it's non-interactive and matches what `pnpm approve-builds` would
do.
pnpm v11 removed `onlyBuiltDependencies` and replaced it with `allowBuilds`
(a map of `<pkg>: true|false`, not a list). The list form silently does
nothing under v11, which is why install kept failing.

Revert the `--allow-build=esbuild` flag from the install step — that flag
lives on `pnpm add` / `pnpm approve-builds`, not `pnpm install`.

Ref: https://pnpm.io/settings#allowbuilds
@pkg-pr-new

pkg-pr-new Bot commented May 26, 2026 •

Copy link
Copy Markdown

commit: e2d4ad5

…URenderer

three 0.164's `WebGPURenderer` declares a stub `xr: { enabled: boolean }`
and `shadowMap: { enabled; type }` — neither satisfies our previous
all-required RendererLike shape, so `new WebGPURenderer()` wasn't even
assignable to the `gl` prop.

- `xr`: keep `enabled` required; mark `isPresenting` and the listener
  methods optional, since renderer/build variants ship different subsets.
- `shadowMap.needsUpdate`: optional (absent on WebGPURenderer).

Mirror r3f's gating pattern: the outer "should we wire up XR?" check now
inspects `addEventListener` presence on `renderer.xr` rather than just
truthiness, so a stub xr object doesn't trigger a no-op session manager.
Inner guards in `handleSessionChange` / `xr.connect` / `xr.disconnect`
remain as defense if `context.gl` swaps mid-session.
- webgpu-simple: hands a WebGPURenderer factory to <Canvas>, lit
  TorusKnot with MeshStandardMaterial. Smallest possible WebGPU demo.
- webgpu-tsl: same geometry with MeshBasicNodeMaterial driven by a TSL
  colorNode (timerLocal / uv / sin / mix / vec3). Shows that procedural
  GPU-side animation needs no per-frame uniform setting.

Imports go through `three/addons/...` and `three/nodes` since three 0.164
predates the `three/webgpu` subpath export.
@bigmistqke bigmistqke changed the title feat: WebGPURenderer support via structural RendererLike feat: WebGPURenderer support May 26, 2026
bigmistqke added 13 commits May 26, 2026 11:42
Matches r3f v10's WebGPU peer pin. Side effects:

- canvas snapshot regenerated (the `data-engine` attribute that three
  auto-adds to its canvas bumped from "three.js r164" to "three.js r181")
- the color-management test was using `gl.outputColorSpace = "test"` as a
  sentinel; three 0.181's renderer now crashes on the next frame when
  asked to convert from an unregistered color space, so swap to a
  valid-but-wrong value (`LinearSRGBColorSpace`) for the same intent.
…Like

Replaces the structural-only Meta<RendererLike> on Context.gl with a tagged
union that gives WebGL and WebGPU users exact three typing (including
their bespoke `xr` / `shadowMap` manager classes) while still letting
custom renderers (SVGRenderer, CSS2DRenderer, user-built) satisfy the
structural `RendererLike` half.

Inspired by r3f's pattern in `store.ts` — they type `state.gl` concretely
to dodge structural-manager-typing pain. We keep RendererLike too so the
door stays open to non-three renderers.

RendererLike now declares optional `xr` and `shadowMap` typed as the union
of the two concrete renderers' fields (`WebGLRenderer["xr"] |
WebGPURenderer["xr"]`), so union access on `Renderer` works without
`'field' in gl` checks. The vendor surfaces are still incompatible enough
that calls require narrowing — see the create-three.tsx duck-typing.

The Canvas `gl` prop, Context.gl, and utils.hasColorSpace all switch from
`RendererLike` to `Renderer`.
The Renderer union surfaces three's WebGL/WebGPU split honestly: `xr` and
`shadowMap` have the same field names but different classes
(`WebXRManager` vs `XRManager`, `WebGLShadowMap` vs `{ enabled, type }`)
with incompatible APIs.

- New `isWebXRManager` / `isWebGLShadowMap` type guards in utils.ts use
  duck-typing (`setAnimationLoop` / `needsUpdate` as discriminators) to
  narrow without importing the manager classes as runtime values — only
  `import type` is needed, so bundle impact is zero.
- XR session wiring (handleSessionChange, xr.connect, xr.disconnect, and
  the connect effect) gates on `isWebXRManager`. WebGPURenderer's
  `XRManager` is correctly excluded — its session API differs and would
  need its own integration code.
- The shadow-map effect runs the common `enabled` / `type` writes for any
  renderer with a shadowMap; only the WebGL-specific `needsUpdate = true`
  write is gated on `isWebGLShadowMap`.
- New `getPendingInit` util encapsulates the `init` / `hasInitialized`
  detection. `WebGLRenderer` has no `init` method at all, so direct
  union-access errors; the util casts through `RendererLike` to make the
  access well-typed and contain the cast to one place. Returns the
  init function or `undefined` based on whether init is needed.

Color management was already structurally checked via `'outputColorSpace'
in gl` / `'toneMapping' in gl`; no changes there.
three 0.181 ships dedicated `three/webgpu` and `three/tsl` subpaths;
swap the playground examples off the older `three/addons/...` and
`three/nodes` paths.

webgpu-tsl: also rename `timerLocal` → `time` (TSL renamed it in a
recent release; new name matches what the broader TSL docs use).
Three new tests exercising the duck-typed narrowings, plus three unit
tests for the `getPendingInit` util:

External RendererLike (integration):
- XR wiring is skipped when `renderer.xr` is a WebGPU-style stub (has
  `enabled` and `addEventListener` but no `setAnimationLoop`); proves the
  `isWebXRManager` discriminator works.
- shadowMap.enabled and .type are applied even when the renderer's
  shadowMap lacks `needsUpdate` (WebGPU shape); only the WebGL-specific
  `needsUpdate = true` write is gated.

getPendingInit (unit):
- returns undefined when init is absent
- returns undefined when hasInitialized() reports true
- returns a callable that invokes init() with the renderer as `this`
`CSS2DRenderer`, `CSS3DRenderer`, and `SVGRenderer` render to DOM —
they have no `setPixelRatio` or `getPixelRatio` because there's no
backing-buffer concept to scale. solid-three was requiring both
methods on `RendererLike` and would crash at runtime calling them.

- Mark `setPixelRatio` / `getPixelRatio` optional on `RendererLike`.
- `canvas.tsx` resize observer calls `setPixelRatio?.(...)`.
- `context.dpr` getter falls back to `globalThis.devicePixelRatio`
  when the renderer has no `getPixelRatio()`.

Adds a test covering a pixel-ratio-less renderer end-to-end.
Demonstrates solid-three's `RendererLike` openness: CSS3DRenderer has
none of `xr`, `shadowMap`, `setPixelRatio`, `getPixelRatio`, or `init` —
it's pure DOM positioning in 3D space. solid-three drives it the same
way it drives WebGL/WebGPU: same render loop, same scene graph, same
`useFrame` hook.

Renders six labelled cards in a slowly rotating carousel. The gl factory
mounts the renderer's `domElement` next to the underlying canvas so the
HTML elements show up over the (unused) WebGL surface.
The previous fallback of `globalThis.devicePixelRatio` fabricated a value
the renderer never claimed — DOM renderers don't scale a backing buffer,
so reporting the device's ratio was a small lie.

Honest default: `1`. Users who need the device's ratio for non-rendering
math read `globalThis.devicePixelRatio` directly, or pass a renderer
that actually exposes `getPixelRatio`.
Solid's JSX returns a DOM element directly, so we can build the card with
a `<div>` literal and cast it through `unknown` to `HTMLDivElement`. Less
imperative than `document.createElement` + `cssText` string.
Unlike CSS3DRenderer which positions HTML in 3D space, SVGRenderer
rasterises the actual three.js scene (meshes, geometries, materials,
lights) into vector SVG paths. Same scene graph as a WebGL scene; only
the renderer differs.

Surfaces the full minimal-RendererLike contract: `render`, `setSize`,
`setPixelRatio` (no-op), no `getPixelRatio`, no `xr`, no `shadowMap`,
no `init`. Lighting still works because the SVGRenderer applies the
material's lit color when rasterising each face.
When `Context.gl` was widened to the `Renderer` union, `domElement` was
dropped from `RendererLike` and the playground OrbitControls was switched
to read `three.canvas` instead of `three.gl.domElement`.

That swap had a subtle wrong-ness: it relied on the renderer's overlay
being marked `pointer-events: none` so events fell through to the
underlying canvas. The SVG example missed that hack and would have
captured input on the SVG element, dropping clicks before they reached
the canvas OrbitControls was attached to.

The renderer-natural answer is to attach to the renderer's own
`domElement` — `<canvas>` for WebGL/WebGPU, `<div>` for CSS3D, `<svg>`
for SVGRenderer. All four renderers we care about have it, typed as the
common supertype `Element`.

- Add `domElement: Element` (required) to `RendererLike`.
- Revert OrbitControls back to `three.gl.domElement` with an
  `as HTMLElement` cast at the call site (three's OrbitControls.connect
  is typed for HTMLElement; SVGElement works in practice).
- Drop the `pointer-events: none` workaround from the CSS3D example.
- Test fakes now include a `domElement` so the structural contract is
  honest at the boundary.
solid-three's Renderer union accepts renderers that don't have a
WebXR-shaped `xr` manager (CSS3DRenderer, SVGRenderer, WebGPURenderer
with its own XRManager, custom). Calling `context.xr.connect()` on
those was a silent no-op — debuggable only by reading source.

Now logs a console.warn so "why isn't XR working" surfaces in the
console. Existing no-op tests updated to assert the warning is emitted.
Adds a `Register` interface as a module-augmentation point — users
declare their concrete renderer choice in a project-local .d.ts and
`useThree().gl`, `Context.gl`, and the `<Canvas gl>` prop all type-narrow
project-wide.

```ts
// src/solid-three.d.ts
import type { WebGPURenderer } from "three/webgpu"

declare module "solid-three" {
  interface Register {
    renderer: WebGPURenderer
  }
}
```

With this declaration, `useThree().gl.init()` is typed without
narrowing, and passing a wrong renderer to `<Canvas gl>` is a type
error.

Same pattern as Vite's `ImportMetaEnv`, Next's `getServerSideProps`,
React Router's `Register`. r3f doesn't have an equivalent — their answer
was the heavyweight one (separate build entry points per renderer).

Without augmentation `Register` stays empty and `ResolvedRenderer` falls
back to the open `Renderer` union — fully backwards compatible.
Adds a "Custom renderers" subsection under Canvas covering:
- The Renderer union (WebGL/WebGPU/SVG/CSS3D/RendererLike).
- Links to the four playground examples.
- The Register module-augmentation pattern for narrowing `gl` typing
  project-wide.

Also updates the inline `CanvasProps` reference type to use
`ResolvedRenderer` so the documentation matches the actual signature.
When a user augments `Register { renderer: WebGPURenderer }` (or any
non-WebGL renderer), the `<Canvas gl>` config-object shorthand is now a
type error. The shorthand only ever builds a default `WebGLRenderer` at
runtime; without this constraint, narrowing to WebGPU + passing
`{ antialias: true }` would silently produce a renderer that violates
the declared type.

```ts
declare module "solid-three" {
  interface Register { renderer: WebGPURenderer }
}

<Canvas gl={{ antialias: true }}>      // ✗ now a type error (was a footgun)
<Canvas gl={c => new WebGPURenderer({ canvas: c })}>  // ✓
```

No behavior change without narrowing — `WebGLRenderer extends Renderer`
(the default union) is true, so the config branch stays available.
`useThree()` now exposes `gl` as the open `Renderer` union (or whatever
narrow type `Register` augmentation declares) — not the bare
`WebGLRenderer` it used to be. `dpr` falls back to `1` for DOM-based
renderers without `getPixelRatio`.

Updates the bullet list in the `useThree` section to match.
@bigmistqke bigmistqke mentioned this pull request May 26, 2026
@bigmistqke

bigmistqke commented May 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Additions since the PR was opened, grouped roughly:

Renderer union typing

  • Renderer = WebGLRenderer | WebGPURenderer | RendererLike — concrete types where available, structural for everything else. WebGL/WebGPU users get exact typing on xr/shadowMap/init; custom renderers (CSS3D, SVG, user-built) still satisfy via RendererLike. (79db70b)
  • Bumped three/@types/three to ^0.181 to match r3f's WebGPU pin; switched playground imports to the three/webgpu / three/tsl subpath exports. (33d5a78, bccecd3)

Per-renderer divergence handling

  • Duck-typed isWebXRManager / isWebGLShadowMap guards + getPendingInit util in utils.ts; create-three.tsx narrows via these instead of instanceof <Renderer>. Avoids importing manager classes as runtime values (import type is enough). (da37a66)
  • context.xr.connect/disconnect now console.warns on no-op so users debugging "why isn't XR working" surface it in the console. (fa97fd6)
  • Restored domElement: Element on RendererLike after the type cascade dropped it — OrbitControls and similar can attach to the renderer's natural input surface (canvas for WebGL/WebGPU, div for CSS3D, svg for SVGRenderer). (7cb2ecb)

DOM-based renderer support

  • setPixelRatio / getPixelRatio are now optional on RendererLike; context.dpr falls back to 1 for renderers that don't expose them (CSS3D, SVG). (03fd4a6, 7bade2e)
  • Playground examples for CSS3DRenderer and SVGRenderer — same scene graph as a WebGL scene, only the renderer factory differs. (0c49e4f, d5e0cb8, 0907cd5)
  • One regression test covering 5 divergence cases (XR stub skip, shadowMap without needsUpdate, getPendingInit). (5018338)

Module-augmentation typing

  • New Register interface — users add one `declare module "solid-three" { interface Register { renderer: WebGPURenderer } }` block and useThree().gl, Context.gl, and <Canvas gl> all narrow project-wide. Same pattern as Vite's ImportMetaEnv / Next's getServerSideProps. r3f doesn't have an equivalent. (0bdaa7f)
  • When Register narrows gl away from WebGL, the Partial<WebGLRenderer> config-shorthand collapses to never — passing { antialias: true } while declaring WebGPU is now a type error rather than a silent footgun. (f47b7c2)

Docs

  • README "Custom renderers" subsection: examples, supported renderers, link to the four playground demos, full Register example with type-error illustration. (3d43146)
  • useThree.gl + dpr bullets updated to reflect the union and DPR fallback. (bf5dd8f, unpushed)

All ~118 tests pass; `tsc --noEmit` clean.

The `gl={{ antialias: true }}` shorthand only builds a default
WebGLRenderer at runtime — when `Register` narrows the renderer to
something else (e.g. WebGPURenderer), the shorthand is a type error.
Previously the docs only showed the factory-form error; add a third
example covering the config-shorthand case.
@bigmistqke
bigmistqke merged commit a8e922f into solidjs-community:next May 26, 2026
2 checks passed
@bigmistqke
bigmistqke deleted the webgpu-support branch June 3, 2026 10:01
@bigmistqke bigmistqke mentioned this pull request Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant