Skip to content

Add a WebGPU backend alongside WebGL2 - #31

Draft
DanKane2029 wants to merge 2 commits into
mainfrom
webgpu-backend
Draft

DanKane2029 wants to merge 2 commits into
mainfrom
webgpu-backend

Conversation

@DanKane2029

Copy link
Copy Markdown
Owner

Adds a WebGPU backend alongside the existing WebGL2 one — not replacing it. The same ECS world, camera, editor, and shaders' semantics; two interchangeable GPU drivers behind one interface. The Renderer menu in the nav bar switches between them, and "Automatic" prefers WebGPU with a silent fallback to WebGL2.

Targeted at main directly (see CLAUDE.md's note on this repo's stacked-PR history).

Two commits, reviewable in order

1. 2fbee78 — Lift GPU handles off the assets, define the RenderBackend seam. No behavior change; this is the enabling refactor and the bulk of the risk.

GPU handles used to live on the asset objects: VertexBuffer held a WebGLBuffer and a VAO, Shader a WebGLShader, Texture a WebGLTexture, each with created/loaded flags. That married every mesh to one context — Renderer.ts's own comments cited it as why a second context was rejected — and left no field a WebGPU handle could occupy. Handles now live in a backend-owned cache keyed by the asset, so assets are pure CPU-side data.

Also here, because the interface has to fit both APIs honestly:

  • Attribute locations pinned in attributeLocations.ts and bound before link, instead of queried per-program. A mesh's VAO no longer depends on any shader program, and WGSL's numeric @location slots can agree with GLSL's names.
  • pick() and readTargetPixels() are async on both backends. WebGPU can only map memory back asynchronously. GizmoController.onMouseDown awaits the pick and guards a stale answer with a request id plus a button-still-down check.
  • Picker.ts folds into WebGLBackend (it needed the private framebuffer). App.picker/App.renderer → App.pick/App.backend.

2. 75628da — The WebGPU backend. Full parity: lit + unlit shading, textures, selection outline, GPU picking, and the Render panel's offscreen final render.

The four things that bite

  • Clip space. mat4.perspective builds an OpenGL projection (z ∈ [-1,1]); WebGPU wants [0,1]. Handed over unchanged it silently clips away the near half of the view volume — reads like a broken near plane, not a coordinate convention. CLIP_SPACE_CORRECTION fixes it, and a test pins near→0 / far→1.
  • Uniforms are raw bytes. UniformLayout.ts must match the WGSL structs exactly, including a vec3 occupying 16 bytes and a mat3x3 occupying 48, not 36. A wrong offset renders a subtly wrong scene rather than throwing, so this is tested directly.
  • Cull mode is baked into a pipeline. WebGL flips gl.cullFace(FRONT) mid-frame for the outline; WebGPU needs a second pipeline bound partway through the same pass.
  • A canvas's context type is permanent. Switching backends remounts the Scene panel's <canvas> (keyed on the backend choice) rather than reconfiguring it.

One change that reaches outside the backend

Every mesh now carries the same three attributes (standardVertexLayout), with zeroed UVs where a mesh has none. WebGL tolerated a missing attribute by feeding the shader (0,0,0,1); WebGPU rejects a pipeline that doesn't supply every location its shader declares. This makes explicit what WebGL was already substituting, and avoids compiling a shader variant per layout. It grows OBJ-loaded vertex buffers by 2 floats/vertex.

Shaders are hand-written WGSL twins (src/Shaders/wgsl/) rather than transpiled — a transpiler is a heavy build dependency whose output nobody could read, in a repo that exists to be read. A test pins the parts that can be checked without a GPU (MAX_LIGHTS across all four declarations, attribute locations).

Verification

  • tsc --noEmit clean (strict on)
  • 166/166 tests pass, 15 new across UniformLayout.test.ts and Backends.test.ts
  • production webpack build clean; WGSL confirmed inlined into the bundle
  • lint: no new problems (the one pre-existing Vector.test.ts error is untouched)

Not verified in a browser — this environment has no GPU or headless browser, so no frame has actually been rendered through either backend on this branch. Before merging, please run npm run start:dev and check, for each backend: scene draws lit and textured; orbit; click-select; the outline reads as a rim not a solid halo; gizmo drags in all three modes; the Render panel matches the viewport minus gizmo/camera icons; resize the panel then re-pick a corner object (catches id-target resize bugs); close and reopen the Scene panel; switch backends and repeat.

Firefly's GPU handles used to live on the asset objects themselves:
VertexBuffer held a WebGLBuffer and a VAO, Shader a WebGLShader, Texture a
WebGLTexture, each with its own created/loaded flags. That married every mesh
to exactly one WebGL context - Renderer.ts's own comments cited it as the
reason a second context was rejected - and left no room for a second graphics
API at all, since there is no field a WebGPU handle could share.

The handles now live in a backend-owned cache (webgl/GLResourceCache.ts),
keyed by the asset. Assets are pure CPU-side data, and the new RenderBackend
interface is the seam a WebGPU implementation can plug into.

Also here, because the interface has to accommodate both APIs honestly:

- Attribute locations are pinned in attributeLocations.ts and bound before
  link, rather than queried per-program. A mesh's VAO no longer depends on a
  shader program, and WGSL's numeric @location slots can agree with GLSL's
  names.
- pick() and readTargetPixels() are async on both backends. WebGPU can only
  map memory back asynchronously; WebGL resolves immediately. GizmoController
  awaits the pick and guards against a stale answer with a request id and a
  button-still-down check.
- Picker.ts folds into WebGLBackend (it needed the private framebuffer), and
  App exposes App.pick / App.backend instead of App.picker / App.renderer.

No behavior change: tsc clean, 151/151 tests pass.
Firefly can now draw the same world through either graphics API. The Renderer
menu switches between them; "Automatic" prefers WebGPU and falls back to
WebGL2, so a browser without WebGPU is unaffected.

The new backend (src/Renderer/webgpu/) implements the same RenderBackend
interface as the WebGL one, with full parity: lit and unlit shading, textures,
the selection outline, GPU picking, and the Render panel's offscreen final
render. Reading the two backends side by side is half the point - the same
scene, expressed through a state machine and through immutable pipelines.

Shaders are hand-written WGSL twins of the GLSL pair (src/Shaders/wgsl/), and a
ShaderProgram now carries both languages. No transpiler: its output would be
unreadable in a repo that exists to be read.

Four WebGPU-specific things worth knowing, each commented where it lives:

- Clip space. gl-matrix builds an OpenGL projection (z from -1 to 1); WebGPU
  wants 0 to 1. Unchanged, it silently clips away the near half of the view
  volume. CLIP_SPACE_CORRECTION fixes it and a test pins it.
- Uniforms are raw bytes, so UniformLayout.ts has to agree with the WGSL
  structs exactly - including a vec3 taking 16 bytes and a mat3x3 taking 48.
  Tested directly, since a wrong offset renders wrong rather than throwing.
- Cull mode is baked into a pipeline, so the outline pass is a second pipeline
  rather than WebGL's mid-frame cullFace flip.
- A canvas's context type is permanent, so switching backends remounts the
  Scene panel's canvas rather than reconfiguring it.

One change reaches outside the backend: every mesh now carries the same three
attributes (standardVertexLayout), with zeroed UVs where there are none.
WebGL tolerated a missing attribute by feeding the shader zeros; WebGPU rejects
a pipeline that doesn't supply every location its shader declares. This just
makes explicit what WebGL was already substituting.

tsc clean, 166/166 tests pass (15 new), production build clean.

Not verified in a browser - there is no headless GPU in this environment.
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