Skip to content

feat(renderer): single depth clear per target for mesh draws (#1468)#1480

Merged
obiot merged 5 commits into
masterfrom
feat/mesh-depth-single-clear
Jun 1, 2026
Merged

feat(renderer): single depth clear per target for mesh draws (#1468)#1480
obiot merged 5 commits into
masterfrom
feat/mesh-depth-single-clear

Conversation

@obiot
Copy link
Copy Markdown
Member

@obiot obiot commented May 31, 2026

Summary

Closes #1468.

`WebGLRenderer.drawMesh` used to issue one `gl.clear(DEPTH_BUFFER_BIT)` and toggle `DEPTH_TEST` / `BLEND` / `depthMask` on entry and exit of every call. Linear cost in mesh count: invisible at AfterBurner-scale (~5 meshes/frame), 6.7ms / 40% budget at 100 meshes, fully tanks the frame at 500.

This PR lifts the depth state ownership into `setBatcher`'s mesh-mode transitions — entering mesh mode pays the state cost once + one depth clear per target, exiting mesh mode restores non-mesh defaults, consecutive mesh draws pay zero between them. Matches Three.js's well-proven approach: the GPU's LEQUAL depth test resolves inter-mesh occlusion per pixel against the accumulated depth attachment, no per-mesh isolation needed.

Behavioural change

Under the old path, two intersecting meshes drawn in painter-wrong order would silently swap: the second mesh's per-mesh depth clear wiped the first's depth, then drew on top regardless of actual distance. Now the GPU's depth test resolves correctly per pixel — closer mesh wins regardless of draw order. This matches user expectations and how every other 3D engine works.

For typical scenes (sorted painter-order, non-intersecting meshes), behaviour is identical.

Measured impact

Pre-PR instrumented profiling of the AfterBurner showcase (Mac Studio, M-series, WebGL2 + Apple GPU):

Metric Value
drawMesh calls/frame 2.3 - 5.0 (avg ~3.7)
drawMesh time/frame 0.15 - 0.59ms (avg ~0.27ms)
60fps budget 0.9% - 3.5%
Per-call cost 49 - 117 µs

AfterBurner-scale: marginal save (~0.05ms/frame). The real ROI is dense 3D scenes where the old N-clear cost dominated the frame budget — extrapolated savings:

Mesh count Old cost/frame Projected new cost
50 3.4ms (20% budget) ~0.5ms
100 6.7ms (40% budget) ~0.7ms
500 33ms (>200% budget — tanks frame) ~3ms

The frame-tanking case is the one this lands ahead of users filing perf bugs on it.

Tests

Two layers of guards in `tests/webgl_mesh_depth.spec.js`:

  • Layer 1 (call-order, structural) — spies on `gl.clear` / `gl.enable` / `gl.disable` to assert:
    • ≤1 depth clear per 5-mesh run
    • ≤1 DEPTH_TEST enable per 5-mesh run
    • ≤1 BLEND disable per 5-mesh run
    • `drawElements` still fires per mesh (no draws lost)
  • Layer 2 (pixel-level, correctness) — renders red+green quads at distinct Z in painter-wrong order and reads pixels back via `gl.readPixels`:
    • Closer mesh must win per pixel even when drawn after the farther one
    • Non-intersecting same-Z meshes don't mutually occlude (LEQUAL edge-case smoke test)

Plus `tests/drawmesh_bench.spec.js` — baseline-benchmark scaffolding for future before/after comparisons at 15 / 50 / 100 / 500 mesh counts. Runs when WebGL2 is available; skips otherwise.

Vitest config change

Added swiftshader launch flags to `vitest.config.ts` so the new WebGL2 tests run under headless CI instead of skipping. Incidentally also enables the previously-skipped `tmxlayer-shader.spec.js` pixel tests in CI — bonus coverage.

Test plan

  • All 3962 tests pass under `pnpm vitest run` (6 new + 3956 existing)
  • No skipped tests now run incorrectly
  • CHANGELOG entry under `### Changed` for 19.7.0
  • Engine builds clean (`pnpm build`)

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 31, 2026 11:11
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

`WebGLRenderer.drawMesh` used to clear DEPTH_BUFFER_BIT and toggle
DEPTH_TEST / BLEND / depthMask on entry and exit of every call. With N
meshes per frame: N depth clears + N state-toggle pairs. The cost
scales linearly — invisible at AfterBurner-scale (~5 meshes), 6.7ms /
40% budget at 100 meshes, fully tanks the frame at 500.

This PR moves mesh-mode state ownership out of the renderer entirely
and into `MeshBatcher`, where it belongs:

- `MeshBatcher.bind()` enters mesh mode: enable DEPTH_TEST + LEQUAL +
  depthMask, disable BLEND, and run a one-shot `clearDepth(1.0) +
  clear(DEPTH_BUFFER_BIT)` if the active target's depth attachment is
  still dirty (tracked by `this._depthDirty`).
- `MeshBatcher.unbind()` exits mesh mode: restore non-mesh defaults
  (BLEND on, DEPTH_TEST off, depthMask false).
- `MeshBatcher` subscribes to a new `event.RENDER_TARGET_CHANGED`
  broadcast that re-arms `_depthDirty` whenever the active framebuffer
  changes identity. Renderer emits the event from `clear()`,
  `clearRenderTarget()`, and `endPostEffect` (after the parent target
  rebinds). Same shape as the existing `event.GPU_TEXTURE_CACHE_RESET`
  subscription pattern in `MaterialBatcher`.

`WebGLRenderer` has zero mesh-specific knowledge after the refactor:

- `drawMesh` is a thin wrapper that calls `setBatcher("mesh")`,
  optionally toggles per-mesh `cullBackFaces`, and queues the geometry.
- `setBatcher` reverts to its pre-refactor form. Bind/unbind on the
  swapping batchers handle their own state.

A run of consecutive mesh draws pays zero state-toggle cost between
them. The GPU's LEQUAL depth test resolves inter-mesh occlusion per
pixel against the accumulated depth buffer — same well-proven approach
Three.js has shipped for years.

This shape ports cleanly to a future WebGPU renderer: `MeshBatcher`'s
WebGPU `bind()` would create a `RenderPassDescriptor` with
`depthLoadOp: "clear"` once per target, then queue render commands
against that pass. The `RENDER_TARGET_CHANGED` event would fire when
the new renderer begins a pass on a different target. No renderer-side
code changes needed for the port.

Behavioural change for one edge case: under the old path, two
intersecting meshes drawn in painter-wrong order would silently swap
(the second mesh's per-mesh clear wiped the first's depth, then drew on
top regardless of actual distance). Now the GPU's depth test resolves
correctly per pixel — closer mesh wins regardless of draw order.

Tests:

- `tests/webgl_mesh_depth.spec.js` — two layers of guard:
  - **Layer 1 (call-order)**: spies on gl.clear / gl.enable / gl.disable
    to assert the structural goal (≤1 depth clear per 5-mesh run, ≤1
    DEPTH_TEST enable, ≤1 BLEND disable) plus drawElements still fires
    per mesh (no draws lost).
  - **Layer 2 (pixel-level)**: renders red+green quads at distinct Z in
    painter-wrong order and reads pixels back via gl.readPixels. Closer
    mesh must win per pixel.
- `tests/drawmesh_bench.spec.js` — synthetic baseline-benchmark
  scaffolding (AfterBurner-scale, 50/100/500-mesh stress). Runs when
  WebGL2 is available; skips Canvas-fallback environments.
- `vitest.config.ts` — chromium swiftshader launch flags so the new
  WebGL2 tests run under headless CI instead of skipping (incidentally
  also enables the previously-skipped tmxlayer-shader pixel specs).

Discovered during the AfterBurner profiling for #1468 (real measurement:
drawMesh costs ~0.27ms/frame at AfterBurner scale — the issue's "8-12ms"
claim was the whole-frame budget, not drawMesh). The refactor lands
the architectural fix without waiting for a dense-3D scene to file
the perf bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@obiot obiot force-pushed the feat/mesh-depth-single-clear branch from e2c0875 to ade8c9f Compare May 31, 2026 13:06
The `launchOptions` field lives on `PlaywrightProviderOptions` (passed
to the `playwright()` factory), not on the per-instance
`BrowserInstanceOption`. TypeScript correctly rejected the misplaced
field. CI lint job caught it; runtime accepted both because the
provider re-exports the launch args into chromium either way.

Functional behaviour unchanged — software WebGL2 still enabled in
headless chromium, the 6 pixel-level mesh-depth tests still run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 31, 2026 14:01
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

CI surfaced 3 pre-existing test failures in `webgl_save_restore.spec.js`
that were silently skipping when WebGL2 was unavailable (the
`if (!isWebGL) return` early-out). Investigating those is unrelated to
the mesh-depth refactor, so revert the swiftshader flag opt-in here to
keep CI green and keep this PR's scope tight.

Net behavioural change vs pre-PR baseline: zero. The new mesh-depth
tests still ship; they skip in CI exactly the same way the existing
TMX layer shader pixel tests skip. Local devs with WebGL2-capable
chromium see them run.

Will be re-enabled (along with a fix for the surfaced save/restore
bugs) in a follow-up issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit flagged the bare `gl.disable(CULL_FACE)` in the
painter-wrong-order depth test — it disabled state without restoring
it, leaving the GL context in a different state than it found it.

The disable was a leftover from earlier debugging; `makeQuadMesh`
already builds POJOs with `cullBackFaces: false`, so `drawMesh` never
touches CULL_FACE for these meshes. Drop the leaky line entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 7 out of 7 changed files in this pull request and generated 8 comments.

Comment thread packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx Outdated
Comment thread packages/examples/src/examples/afterBurner/ExampleAfterBurner.tsx Outdated
Comment thread packages/melonjs/tests/webgl_mesh_depth.spec.js Outdated
Comment thread packages/melonjs/tests/webgl_mesh_depth.spec.js
Comment thread packages/melonjs/tests/webgl_mesh_depth.spec.js Outdated
Comment thread packages/melonjs/tests/webgl_mesh_depth.spec.js
Comment thread packages/melonjs/tests/drawmesh_bench.spec.js Outdated
Comment thread packages/melonjs/src/system/event.ts Outdated
8 findings, all valid:

1. **AfterBurner**: drop temporary `[BENCH #1468]` instrumentation that
   monkey-patched `renderer.drawMesh`, ran a rAF frame counter, and a
   `setInterval` log loop — would have shipped to users with spam logs
   and altered example perf.

2. **webgl_mesh_depth.spec.js**: add `afterAll` that restores the video
   subsystem to `video.AUTO`, matching the cleanup pattern in
   `tmxlayer-shader.spec.js` / `texture-resource.spec.js`. Prevents
   forced-WebGL state from leaking into other spec files.

3. **webgl_mesh_depth.spec.js** (Layer 2 tests): replace
   `renderer.clearColor()` with `renderer.backgroundColor.setColor()` +
   `renderer.clear()` so the lazy depth clear actually re-arms via the
   `RENDER_TARGET_CHANGED` event. With `clearColor()` alone, depth
   values from a previous test in the spec could persist and make the
   per-pixel readback order-dependent.

4. **drawmesh_bench.spec.js**: rewrite the header docstring — the
   "current drawMesh does per-mesh clear + state restore" prose was the
   *old* behaviour. Post-#1468, state/clear ownership is on
   `MeshBatcher.bind()`/`unbind()` and the clear is lazy per target.

5. **event.ts** (`RENDER_TARGET_CHANGED` docstring): reworded from
   "active framebuffer's attachments change identity" to "begin new
   render pass on the active target". `clear()` / `clearRenderTarget()`
   don't physically change the framebuffer binding — they reset state
   on the existing target, which is logically a new pass. Only
   `endPostEffect()` is a true identity change. Subscribers care about
   the "new pass" signal regardless of which it is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@obiot obiot merged commit c8f6889 into master Jun 1, 2026
6 checks passed
@obiot obiot deleted the feat/mesh-depth-single-clear branch June 1, 2026 00:31
obiot added a commit that referenced this pull request Jun 1, 2026
…cher contract

User-facing JSDoc updates for the recent engine changes whose contract
wasn't yet documented at the type level.

**Renderer constants** (`const.ts`):
- `CANVAS` — note that ShaderEffect / Mesh / Camera3d / GPU TMX /
  Light2d are WebGL-only and silently don't work on Canvas
- `WEBGL` — make the throw-on-unavailable behavior explicit (#1479);
  point at AUTO as the fallback opt-in
- `AUTO` — explain the silent-fallback semantics; note that WebGL-only
  subsystems stop working under the fallback path

**Application settings** (`ApplicationSettings`):
- `renderer` JSDoc rewritten to cover the three modes' behaviour and
  point at when to use each
- `cameraClass` JSDoc gains a "WebGL requirement" paragraph — Camera3d
  (or any class with `static defaultSortOn === "depth"`) pairs with
  `video.WEBGL` for a hard throw; pairing with `video.AUTO` emits a
  console.warn when AUTO falls back to Canvas

**Camera3d class JSDoc**:
- "WebGL required" paragraph at the top — perspective projection +
  depth-buffer painter sort + mesh draw all live in the WebGL renderer;
  point at `renderer: video.WEBGL` for the hard-throw contract

**Camera2d.damping field**:
- Explain the frame-rate independence shipped in #1478 — the value is
  still the per-frame fraction at `timer.maxfps`, but the underlying
  `pos.damp` math keeps wall-clock convergence constant if actual
  frame rate drifts. Existing tuning carries over.

**MeshBatcher class JSDoc**:
- "Owns mesh-mode GL state ownership" section explaining the
  bind/unbind lifecycle and the `RENDER_TARGET_CHANGED` subscription
  shipped in #1480. Note the clean WebGPU port shape.

Engine builds clean. No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
obiot added a commit that referenced this pull request Jun 1, 2026
…) (#1482)

* fix(application): fail loudly on WebGL-required misconfiguration (#1479)

Two checks, both in `application.ts` constructor — Camera3d stays
pure-math, Stage untouched.

**Throws** when `renderer: video.WEBGL` is requested but WebGL is
unavailable, instead of silently falling back to Canvas. Pre-fix, both
`AUTO` and `WEBGL` went through `autoDetectRenderer`, which catches
WebGL construction failures and returns a `CanvasRenderer`. The user
had no way to express "I require WebGL, throw if unavailable" even
though `video.WEBGL` looks like exactly that contract. `video.AUTO`
keeps the silent-fallback semantics on purpose.

**Warns** (via `console.warn`) when `cameraClass` declares
`static defaultSortOn = "depth"` (Camera3d or any subclass) but the
active renderer isn't a `WebGLRenderer`. Catches the
`video.AUTO + cameraClass: Camera3d` combination where AUTO fell back
to Canvas and the user would otherwise see a black canvas with no
signal why — Camera3d's perspective projection, frustum culling and
mesh draw path all presuppose a WebGL context.

Warn (not throw) for the cameraClass mismatch so unit-level integration
tests for the `cameraClass → world.sortOn` bootstrap wiring can run
under Canvas without crashing. The strong user-facing signal is
`renderer: video.WEBGL` — Camera3d apps that combine both get the hard
throw from the WebGL check above when WebGL is genuinely unavailable.

`Application` uses `cameraClass.defaultSortOn === "depth"` as the signal
(not `instanceof Camera3d`) so the check doesn't need to import the
concrete Camera3d class — same architectural approach the existing
`world.sortOn` bootstrap already uses.

Tests in `webgl_required_check.spec.js`:

- `renderer: video.WEBGL` throws with a message pointing at video.AUTO
  as the fallback opt-in
- `renderer: video.AUTO` preserves silent fallback (no throw)
- Canvas + Camera3d (and subclass) warns with a useful message
- Canvas + Camera2d-style cameraClass doesn't warn (no false positives)
- No cameraClass setting + Canvas doesn't warn (legacy default)

Pre-existing tests in `renderTarget.spec.js` and `renderTargetPool.spec.js`
that requested `renderer: video.WEBGL` and gracefully skipped on
`video.renderer.gl === undefined` updated to also catch the new throw —
same skip behaviour, explicitly handles the new error mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(examples): user-friendly alert when WebGL is unavailable in 3D demos

Three Camera3d / mesh-using examples now wrap Application construction
in try/catch + show a browser `alert()` instead of failing silently
into a stuck blank canvas. The alert message explains that the example
requires WebGL, suggests enabling hardware acceleration / trying a
different browser, then includes the underlying error details.

After alerting, re-throws so the React example boundary registers the
failure (the gallery's error tile is the right fallback UI).

- afterBurner: was already `renderer: video.WEBGL` + cameraClass:
  Camera3d. Pre #1479 it would silently fall back to Canvas and look
  broken; now the engine throws, this catch surfaces the message.
- mesh3d: was `renderer: video.AUTO`. The example uses me.Mesh
  internally, which requires WebGL; AUTO would silently fall back to
  Canvas, and meshes would render as black silhouettes (or worse).
  Switched to `video.WEBGL` so the engine throws, then alert handles
  the throw.
- multiMaterialMesh: was already `renderer: video.WEBGL`. Same wrap
  added for consistency.

Same three-line shape across all three, plus a one-paragraph alert
message tailored to "this example needs WebGL".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: document WebGL requirement + frame-rate-independence + mesh-batcher contract

User-facing JSDoc updates for the recent engine changes whose contract
wasn't yet documented at the type level.

**Renderer constants** (`const.ts`):
- `CANVAS` — note that ShaderEffect / Mesh / Camera3d / GPU TMX /
  Light2d are WebGL-only and silently don't work on Canvas
- `WEBGL` — make the throw-on-unavailable behavior explicit (#1479);
  point at AUTO as the fallback opt-in
- `AUTO` — explain the silent-fallback semantics; note that WebGL-only
  subsystems stop working under the fallback path

**Application settings** (`ApplicationSettings`):
- `renderer` JSDoc rewritten to cover the three modes' behaviour and
  point at when to use each
- `cameraClass` JSDoc gains a "WebGL requirement" paragraph — Camera3d
  (or any class with `static defaultSortOn === "depth"`) pairs with
  `video.WEBGL` for a hard throw; pairing with `video.AUTO` emits a
  console.warn when AUTO falls back to Canvas

**Camera3d class JSDoc**:
- "WebGL required" paragraph at the top — perspective projection +
  depth-buffer painter sort + mesh draw all live in the WebGL renderer;
  point at `renderer: video.WEBGL` for the hard-throw contract

**Camera2d.damping field**:
- Explain the frame-rate independence shipped in #1478 — the value is
  still the per-frame fraction at `timer.maxfps`, but the underlying
  `pos.damp` math keeps wall-clock convergence constant if actual
  frame rate drifts. Existing tuning carries over.

**MeshBatcher class JSDoc**:
- "Owns mesh-mode GL state ownership" section explaining the
  bind/unbind lifecycle and the `RENDER_TARGET_CHANGED` subscription
  shipped in #1480. Note the clean WebGPU port shape.

Engine builds clean. No code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(application): drop duplicate isWebGLSupported import

Copilot flagged the duplicate import — `device` was already brought in
as a namespace one line earlier (`import * as device from
"../system/device.js"`), so the named import of `isWebGLSupported` from
the same module was redundant. Switch the single call site to
`device.isWebGLSupported(this.settings)` and drop the named import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

Mesh rendering: batch meshes within a frame to amortize depth-clear and drawElements cost

2 participants