Skip to content

WebGPU 2D pipeline: sprites, text, primitives, blend modes, clipping and stencil masks (#1184) — 20.0.0 - #1562

Merged
obiot merged 6 commits into
masterfrom
webgpu-2d-pipeline
Aug 2, 2026
Merged

WebGPU 2D pipeline: sprites, text, primitives, blend modes, clipping and stencil masks (#1184) — 20.0.0#1562
obiot merged 6 commits into
masterfrom
webgpu-2d-pipeline

Conversation

@obiot

@obiot obiot commented Aug 2, 2026

Copy link
Copy Markdown
Member

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/destroy driven by addBatcher/setBatcher), same flush-before-any-GPU-visible-state-change discipline, same RenderState-owned transform/save-restore model. Contributes to #1184; also folds in the pending await app.init() changelog callout.

What renders now under renderer: video.WEBGPU

Sprites/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-based setMask/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

  • One command encoder + one render pass per frame (clear() opens with a color-clear load, flush() submits), depth24plus-stencil8 attached 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).
  • Per-frame buffer arena: every internal flush writes its vertices into its own region of persistent 1 MiB GPUBuffer pages (queue.writeBuffer ordering makes region reuse within a frame impossible), so a "flush" costs one writeBuffer + one draw — no full-buffer re-uploads.
  • Dynamic-offset uniform ring for frame globals (projection + line width) — the Shared frame-globals uniform buffer (projection / tint / time across all shaders) #1555 bind-group-0 shape, realized on the WebGPU side first; mid-frame projection swaps (floating containers) give each recorded draw its own slot.
  • Pipeline cache keyed by 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 (setStencilReference carries the mask level).
  • Declarative vertex layouts: the backend-neutral formats/topologies of Backend-neutral vertex formats and topologies (GLenum → WebGPU-style strings, with GLenum back-compat) #1551 feed GPUVertexBufferLayout directly — 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 * .a premultiply contract), and every WGSL vertex shader carries the GL→WebGPU clip-z remap (z+w)/2 so non-zero depths don't silently clip.
  • Texture store: copyExternalImageToTexture with 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 untouched TextureCache still owns unit bookkeeping. CanvasRenderTarget gained WebGPU invalidate/destroy branches so dynamic Text re-bakes reach the GPU.
  • Device-loss recovery: renegotiate + rebuild in dependency order, batchers re-init with the claim-and-hold idiom, textures lazily re-upload.

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 (aTextureId kept in the stream), MSAA (sampleCount in the key), compressed textures/mipmaps, #gradientMask (gradient fills of non-rect shapes warn + solid-fill). Capability flags stay honestly false.

Verification

  • Suite: melonjs 5229 (incl. new device-free pipeline units + availability-gated device specs), matter 177, planck 169, debug-plugin 16 — all passing; eslint 0 errors, tsc clean both packages, engine + examples builds green.
  • Visual: headed-Chromium screenshots on apple/metal-3 at every checkpoint (background → sprite/text scene → blend/primitive/mask/clip scene), zero device validation errors via uncapturederror hooks.
  • Worth knowing: headless SwiftShader negotiates a WebGPU device but cannot present (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

obiot and others added 2 commits August 2, 2026 11:38
…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
Copilot AI review requested due to automatic review settings August 2, 2026 06:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +307 to +325
_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,
});
}
Comment on lines 334 to 341
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
Copilot AI review requested due to automatic review settings August 2, 2026 07:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • stride is derived from the last attribute only, and there’s no validation that the resulting vertexSize is 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), VertexArrayBuffer writes 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 Uint32ArrayUint8Array, which is platform-endian. On a (rare) big-endian JS runtime this would fail even if Color.toUint32 is 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
Copilot AI review requested due to automatic review settings August 2, 2026 08:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • GPUTexture objects don’t expose .width / .height in 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
Copilot AI review requested due to automatic review settings August 2, 2026 09:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: toBeInstanceOf is being used on *.prototype objects, 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/height properties, so the size-check always fails and createDepthTexture() will destroy/recreate the depth-stencil texture even when the canvas size hasn’t changed (and depthTexture.width/height reads as undefined). 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
		) {

Comment on lines +18 to +22
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
Copilot AI review requested due to automatic review settings August 2, 2026 09:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() checks this.depthTexture.width/height, but GPUTexture objects don’t expose width/height in the WebGPU API. This means the size check will never succeed and the depth/stencil texture will be destroyed/recreated every time createDepthTexture() 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() drops this.depthTexture by setting it to null but never calls destroy() on the existing GPUTexture. 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

  • WebGLBatcher is only used as a type in this file (compositor/batcher constructor 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";

@obiot
obiot merged commit eac8555 into master Aug 2, 2026
7 checks passed
@obiot
obiot deleted the webgpu-2d-pipeline branch August 2, 2026 09:53
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.

2 participants