WebGPU 2D pipeline: sprites, text, primitives, blend modes, clipping and stencil masks (#1184) — 20.0.0 - #1562
Conversation
…ng new Application users Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
…des, clipping, stencil masks (#1184) The experimental WebGPU backend grows from bootstrap to the full non-post-effect 2D contract, mirroring the WebGL renderer philosophy exactly (batcher lifecycle, flush-before-state-change, RenderState-owned transforms): - One command encoder + render pass per frame (clear() opens, flush() submits), with a depth24plus-stencil8 attachment carried from day one so masks (now) and meshes (later) never reshape the pipeline set. - WGSL quad + primitive pipelines sharing the frozen GL vertex layouts — the backend-neutral vertex formats of #1551 are consumed declaratively into GPUVertexBufferLayout (#1492), including the GL-convention clip-z remap and the packed-ARGB `.bgr * .a` premultiply contract. - Per-frame buffer arena (each internal flush gets its own region) and a dynamic-offset uniform ring for frame globals (projection + line width — the #1555 bind-group-0 shape), so mid-frame projection swaps (floating containers) keep every recorded draw on its own slot. - Pipeline cache keyed by shader/topology/blend/pma/stencil-mode: all six blend modes (min/max darken/lighten included), stencil write/test variants for setMask/clearMask (level-0 entry breaks the pass with stencilLoadOp clear), scissor clipping with clamped transform-derived AABBs. - Texture store: copyExternalImageToTexture uploads with the GL premultiply convention, sampler cache with per-axis repeat, video version-stamp reupload, filter changes re-pair bind groups without re-uploading, TextureCache unit bookkeeping reused untouched. - Device-loss recovery renegotiates and rebuilds in dependency order; CanvasRenderTarget gained WebGPU invalidate/destroy branches so dynamic Text re-bakes reach the resident texture. Hello WebGPU example reworked into a parity scene (blend modes, masked sprite, clipped container, primitive shapes); verified pixel-level on an Apple Metal adapter — note headless SwiftShader negotiates a device but cannot present, so WebGPU visual verification requires a headed browser. Suite 5240 passing across packages; device-dependent specs skip visibly where WebGPU is absent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
There was a problem hiding this comment.
Pull request overview
This PR expands melonJS’s experimental WebGPU backend from a bootstrap-only renderer to a full non-post-effect 2D rendering pipeline, mirroring the existing WebGL renderer’s batcher lifecycle and render-state model.
Changes:
- Adds WebGPU 2D rendering support for sprites/text (quad pipeline) and primitives/Path2D (primitive pipeline), including blend modes, scissor clipping, and stencil masks.
- Introduces core WebGPU infrastructure (pipeline cache, uniform ring, buffer arena, texture store) plus WGSL shaders for quad/primitive/clear paths.
- Updates docs/changelog and refreshes the Hello WebGPU example; adds device-free unit tests for pipeline invariants.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/melonjs/tests/webgpu_pipeline.spec.js | Adds device-free unit tests for blend normalization, vertex-format sizing, and packed-color byte order. |
| packages/melonjs/src/video/webgpu/webgpu_renderer.js | Implements the main WebGPU renderer: frame pass lifecycle, batchers, blend/scissor/mask behavior, device-loss restore. |
| packages/melonjs/src/video/webgpu/uniform_ring.js | Adds a per-frame dynamic-offset uniform ring for frame globals + clear color. |
| packages/melonjs/src/video/webgpu/texture_store.js | Adds a renderer-owned resident texture + sampler/bind-group cache for WebGPU. |
| packages/melonjs/src/video/webgpu/shaders/quad.wgsl | WGSL quad shader for sprites/text with packed color convention and clip-z remap. |
| packages/melonjs/src/video/webgpu/shaders/primitive.wgsl | WGSL primitive shader for shapes/lines with line width from frame globals. |
| packages/melonjs/src/video/webgpu/shaders/clear.wgsl | WGSL bufferless scissored clear triangle shader. |
| packages/melonjs/src/video/webgpu/pipeline_cache.js | Adds a WebGPU pipeline cache keyed by shader/topology/blend/pma/stencil/etc. |
| packages/melonjs/src/video/webgpu/buffer_arena.js | Adds a per-frame GPU buffer arena for flush-safe writeBuffer regions. |
| packages/melonjs/src/video/webgpu/bindgroups.js | Defines fixed bind-group indices and uniform sizes for the backend. |
| packages/melonjs/src/video/webgpu/batchers/webgpu_batcher.js | Introduces the base WebGPU batcher lifecycle and flush implementation. |
| packages/melonjs/src/video/webgpu/batchers/quad_batcher.js | Adds a WebGPU quad batcher with indexed quad drawing and material bind groups. |
| packages/melonjs/src/video/webgpu/batchers/primitive_batcher.js | Adds a WebGPU primitive batcher with topology handling and thick-line expansion. |
| packages/melonjs/src/video/rendertarget/canvasrendertarget.js | Adds WebGPU invalidate/destroy handling so canvas-backed targets reupload/destroy GPU textures. |
| packages/melonjs/src/const.ts | Updates the video.WEBGPU docstring to reflect expanded 2D support and remaining gaps. |
| packages/melonjs/scripts/build.ts | Registers .wgsl as a text loader for builds. |
| packages/melonjs/CHANGELOG.md | Updates changelog entries for the expanded WebGPU renderer and await app.init() note. |
| packages/examples/src/main.tsx | Updates the WebGPU example description string. |
| packages/examples/src/examples/webgpu/ExampleWebGPU.tsx | Reworks the Hello WebGPU example into a scene demonstrating the new capabilities. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| _createDepthTexture() { | ||
| const canvas = this.getCanvas(); | ||
| const width = Math.max(1, canvas.width); | ||
| const height = Math.max(1, canvas.height); | ||
| if ( | ||
| this._depthTexture && | ||
| this._depthTexture.width === width && | ||
| this._depthTexture.height === height | ||
| ) { | ||
| return; | ||
| } | ||
| this._depthTexture?.destroy(); | ||
| this._depthTexture = this.device.createTexture({ | ||
| label: "melonJS depth-stencil", | ||
| size: [width, height], | ||
| format: DEPTH_STENCIL_FORMAT, | ||
| usage: GPUTextureUsage.RENDER_ATTACHMENT, | ||
| }); | ||
| } |
| clear() { | ||
| if (typeof this.device === "undefined") { | ||
| return; | ||
| } | ||
| // a zero-sized canvas (auto-scale inside a hidden/collapsed parent) | ||
| // has no valid current texture — submitting a pass against it would | ||
| // generate a device validation error every frame | ||
| const canvas = this.getCanvas(); | ||
| if (canvas.width === 0 || canvas.height === 0) { | ||
| return; | ||
| } |
| 3, | ||
| ); | ||
|
|
||
| const melonImage = makeMelonCanvas() as unknown as HTMLImageElement; |
…ycled units served stale pixels) A TextureCache unit number is not a stable identity: units freed by a stage switch (the loading screen's assets) get recycled for new sources with no per-unit release event, and the WebGPU texture store kept serving the old resident texture for the reused unit — the platformer's sky background pattern rendered the loading screen's 256x256 leftovers. Every lookup now validates the record's SOURCE: a recycled unit re-uploads in place (same-size path keeps the GPUTexture and its bind groups; a size change recreates), which also covers ghost frames from stale loading-screen pixels. Also: getUriFragment now splits on "?" as well as "&", so engine flags coexist with SPA hash routers — `/#/platformer?webgpu` runs the stock platformer example on the WebGPU backend, verified end-to-end on an Apple Metal adapter (parallax + screen-blend clouds + tiles + minimap). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/melonjs/src/video/webgpu/batchers/webgpu_batcher.js:65
strideis derived from the last attribute only, and there’s no validation that the resultingvertexSizeis an integer number of floats. If attributes are ever not sorted by offset (or if a custom batcher supplies a non-4-byte-multiple stride),VertexArrayBufferwrites will land at fractional offsets and corrupt the staging buffer (this is explicitly guarded against in the WebGL batcher).
const last = this.attributes[this.attributes.length - 1];
this.stride = last.offset + last.bytes;
this.vertexSize = this.stride / Float32Array.BYTES_PER_ELEMENT;
packages/melonjs/tests/webgpu_pipeline.spec.js:86
- This test reads the packed color bytes via
Uint32Array→Uint8Array, which is platform-endian. On a (rare) big-endian JS runtime this would fail even ifColor.toUint32is correct. If the intent is to assert the little-endian byte order contract used by the vertex stream, write the bytes explicitly in little-endian to keep the unit test portable.
const packed = new Color(0x11, 0x22, 0x33).toUint32(1.0);
const bytes = new Uint8Array(Uint32Array.of(packed).buffer);
// unorm8x4 maps byte i → component i: the attribute arrives as
- primitive lineWidth rides the frame-globals slot, which clear() rewrites every frame — compare against the value the current slot was written with instead of a batcher-local cache that goes stale across frames (thick strokes thinned to 1px from the second frame on) - replaced GPUTextures retire at frame end instead of being destroyed mid-frame: draws already recorded against them would make the whole queue.submit() fail validation and drop the frame - a record already sampled this frame gets a fresh texture on re-upload: queue writes execute before every recorded draw, so an in-place re-upload applied retroactively (shared gradient/Text canvases baking different content mid-frame) - setProjection pushes a frame-globals slot even with no open pass (slots are plain buffer writes; the projection was stale after an explicit mid-frame flush) - _ensurePass pushes a slot when none exists yet (out-of-bracket draws before the renderer's first clear() crashed on a null binding) - reset() during the device renegotiation window no longer re-inits batchers against an undefined device - clear() emits RENDER_TARGET_CHANGED (GL parity, mesh-path contract) - _applyScissor clamps to the attachment (out-of-bounds scissor is a validation error under WebGPU where GL clamps) - removed the unreachable-and-wrong triangle-fan branch from the chunked primitive path; documented the clearColor-honors-mask divergence from GL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/melonjs/src/video/webgpu/webgpu_renderer.js:330
GPUTextureobjects don’t expose.width/.heightin the WebGPU API, so this size check will always fail and_createDepthTexture()will recreate the depth-stencil texture on every call. Track the last allocated size separately (e.g._depthTextureSize) and compare against that instead.
if (
this._depthTexture &&
this._depthTexture.width === width &&
this._depthTexture.height === height
) {
packages/melonjs/tests/webgpu_pipeline.spec.js:86
- This test reads the packed uint32 back via typed-array reinterpretation, which depends on the host platform being little-endian. To make the intent explicit and avoid rare big-endian failures, write the uint32 with
DataView#setUint32(..., true)and then read the bytes.
const packed = new Color(0x11, 0x22, 0x33).toUint32(1.0);
const bytes = new Uint8Array(Uint32Array.of(packed).buffer);
// unorm8x4 maps byte i → component i: the attribute arrives as
…, no underscore prefixes Review comments on the phase-1 PR: - `Batcher` (src/video/gpu/) is now the backend-neutral base class defining the shared lifecycle contract (init/bind/unbind/flush/reset/ destroy); the WebGL base batcher is renamed `WebGLBatcher` and `WebGPUBatcher` derives from the same base, so `addBatcher()` accepts a custom batcher from either backend and validates it up front. WebGPU batcher classes are exported. SpineBatcher moves to `WebGLBatcher` (spine-plugin 4.0.0, peer melonjs >=20) - the webgpu folder now mirrors the webgl subdivision: buffer/ (arena, uniform ring), pipeline/ (cache, bind-group constants), texture/ (store), batchers/, shaders/ - underscore-prefixed internals renamed throughout the webgpu backend (commandEncoder, renderPass, pushFrameGlobals, ...) per the 20.0 naming convention — private means not exported, and names follow the WebGPU vocabulary minus the GPU prefix The neutral base deliberately has no constructor: the backend bases call this.init() from their own constructor AFTER super(), because a derived class's private fields (#topology/#mode) are not installed until the base constructor returns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
packages/melonjs/tests/batcher_hierarchy.spec.js:28
- Same issue as above:
toBeInstanceOfis being used on*.prototypeobjects, which aren’t instances. Check the prototype chain (or construct an instance if practical) so the test reflects inheritance correctly.
it("WebGPU batchers derive from the neutral Batcher base", () => {
expect(WebGPUBatcher.prototype).toBeInstanceOf(Batcher);
expect(WebGPUQuadBatcher.prototype).toBeInstanceOf(WebGPUBatcher);
expect(WebGPUPrimitiveBatcher.prototype).toBeInstanceOf(WebGPUBatcher);
});
packages/melonjs/src/video/webgpu/webgpu_renderer.js:341
- GPUTexture objects don’t expose
width/heightproperties, so the size-check always fails andcreateDepthTexture()will destroy/recreate the depth-stencil texture even when the canvas size hasn’t changed (anddepthTexture.width/heightreads asundefined). Track the size explicitly on the renderer instead of reading it from the GPUTexture.
if (
this.depthTexture &&
this.depthTexture.width === width &&
this.depthTexture.height === height
) {
| it("WebGL batchers derive from the neutral Batcher base", () => { | ||
| expect(WebGLBatcher.prototype).toBeInstanceOf(Batcher); | ||
| expect(QuadBatcher.prototype).toBeInstanceOf(WebGLBatcher); | ||
| expect(PrimitiveBatcher.prototype).toBeInstanceOf(WebGLBatcher); | ||
| }); |
Adversarial review of the refactor found no behavior drift; follow-ups: - addBatcher gates on the BACKEND base class (WebGLBatcher / WebGPUBatcher), not the neutral Batcher — a batcher from the wrong backend now fails up front with the message the gate promises, instead of mid-activation with an unrelated TypeError - a first WebGPUBatcher init without settings throws "attributes definition missing" (WebGL-base parity) instead of a confusing TypeError inside the pipeline cache - settings.ts types the WebGL-only batcher/compositor settings against WebGLBatcher; last three underscore members renamed (indexBuffer, maskDepthWarned, warnGradientShape) New mock-device unit suites (CI-safe, no GPU needed) covering every WebGPU class: - texture store: record lifecycle, source-identity revalidation on recycled units, same-frame fresh-texture rule, retire-vs-destroy, per-axis samplers, bind-group invalidation, cache-reset event - primitive batcher: lineWidth/frame-slot interplay (the review-fixed frame-2 thin-stroke bug now has a regression test), topology-switch flush, line-loop closure, fan re-expansion, thick-line expansion, chunk-boundary math - quad batcher: frozen 28-byte layout (corners/UVs/packed tint/depth), corner transform, material-adoption flush, indexed draw counts - buffer arena: alignment, region isolation, page rollover, oversized throw, reset/destroy - uniform ring: slot math, page-cached bind groups, exact frame-globals byte layout, 65th-slot rollover - pipeline cache: key dedupe, full blend table, stencil variants, vertex-layout consumption, strip index format, clear-never-blends - addBatcher gates + frozen bind-group constants Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 43 out of 43 changed files in this pull request and generated no new comments.
Suppressed comments (3)
packages/melonjs/src/video/webgpu/webgpu_renderer.js:331
createDepthTexture()checksthis.depthTexture.width/height, butGPUTextureobjects don’t exposewidth/heightin the WebGPU API. This means the size check will never succeed and the depth/stencil texture will be destroyed/recreated every timecreateDepthTexture()runs (e.g. on resize events or any other caller), which is unnecessary work and can cause performance/resource churn.
Track the last allocated size on the renderer (e.g. this.depthTextureSize) and compare against that instead, then store it when creating the texture.
createDepthTexture() {
const canvas = this.getCanvas();
const width = Math.max(1, canvas.width);
const height = Math.max(1, canvas.height);
if (
this.depthTexture &&
this.depthTexture.width === width &&
this.depthTexture.height === height
) {
packages/melonjs/src/video/webgpu/webgpu_renderer.js:1714
restoreDevice()dropsthis.depthTextureby setting it tonullbut never callsdestroy()on the existingGPUTexture. Unlike pipeline/buffer caches (which you explicitly tear down), this leaves the old depth/stencil attachment alive until the old device is GC’d, which is avoidable resource retention during device-loss recovery.
Destroy the existing depth texture before nulling the reference.
this.abandonFrame();
this.textureStore?.destroy();
this.vertexArena?.destroy();
this.uniformRing?.destroy();
this.pipelineCache?.clear();
this.depthTexture = null;
this.device = undefined;
packages/melonjs/src/application/settings.ts:16
WebGLBatcheris only used as a type in this file (compositor/batcherconstructor types). Importing it as a value can unnecessarily pull the WebGL batcher module into the runtime dependency graph (and the comment above explicitly calls out avoiding value imports here to prevent circular-import surfaces).
Switch this to a type-only import so it compiles away.
import { RendererType } from "../const";
import { PhysicsAdapter } from "../physics/adapter";
import Renderer from "../video/renderer";
import { WebGLBatcher } from "../video/webgl/batchers/batcher.js";
import { ScaleMethod } from "./scaleMethods";
Summary
The experimental WebGPU backend grows from bootstrap to the full non-post-effect 2D contract, built as a parallel mirror of the WebGL renderer with the identical philosophy — same batcher lifecycle (
init/bind/unbind/flush/reset/destroydriven byaddBatcher/setBatcher), same flush-before-any-GPU-visible-state-change discipline, sameRenderState-owned transform/save-restore model. Contributes to #1184; also folds in the pendingawait app.init()changelog callout.What renders now under
renderer: video.WEBGPUSprites/Text/Particles (WGSL quad pipeline), all filled/stroked shapes + the Path2D API (primitive pipeline with shader-expanded thick lines and round joins), all six blend modes (incl. min/max darken/lighten), patterns with per-axis repeat samplers, ColorLayer/
clearColor/clearRect, transform-derived scissor clipping, and stencil-basedsetMask/clearMask. Verified pixel-level on an Apple Metal adapter — the reworked Hello WebGPU example shows every capability in one scene (blend trio, stencil-masked sprite, clipped container, primitive row).Architecture
clear()opens with a color-clear load,flush()submits),depth24plus-stencil8attached from day one so masks (now) and meshes (later) never invalidate the pipeline set. Mask level-0 entry is the one pass break (stencilLoadOp: "clear", color preserved).GPUBufferpages (queue.writeBufferordering makes region reuse within a frame impossible), so a "flush" costs onewriteBuffer+ one draw — no full-buffer re-uploads.shader|topology|blend|pma|stencilMode|format|sampleCount— everything that is dynamic state under GL and pipeline state under WebGPU, with stencil write/test variants realizing the GL mask machinery (setStencilReferencecarries the mask level).GPUVertexBufferLayoutdirectly — the [WebGPU port] Backend-neutral vertex layout descriptor #1492 consumption, no GL-enum bridge. Vertex streams are byte-identical to the WebGL layouts (28-byte quad / 24-byte primitive, packed-ARGB tint with the.bgr * .apremultiply contract), and every WGSL vertex shader carries the GL→WebGPU clip-z remap(z+w)/2so non-zero depths don't silently clip.copyExternalImageToTexturewith the GL premultiply convention, sampler cache (filter × per-axis repeat), video version-stamp reuploads into resident textures, filter changes re-pair bind groups without re-uploading, and the untouchedTextureCachestill owns unit bookkeeping.CanvasRenderTargetgained WebGPUinvalidate/destroybranches so dynamic Text re-bakes reach the GPU.Deferred (seams left in place)
Post effects/
toFrameTexture(pass-restart primitive exists), ShaderEffect-WGSL, lights (bind group 2 reserved; std140 packers are WGSL-layout compatible), meshes/Camera3d (depth attachment present), GPU tile layers, multi-texture batching (aTextureIdkept in the stream), MSAA (sampleCountin the key), compressed textures/mipmaps,#gradientMask(gradient fills of non-rect shapes warn + solid-fill). Capability flags stay honestlyfalse.Verification
apple/metal-3at every checkpoint (background → sprite/text scene → blend/primitive/mask/clip scene), zero device validation errors viauncapturederrorhooks.getCurrentTexture()is invalid) — WebGPU visual verification requires a headed browser; CI's device-dependent specs skip visibly.🤖 Generated with Claude Code
https://claude.ai/code/session_01QVjYzf76AEU3wJk766JAQi