feat(renderer): single depth clear per target for mesh draws (#1468)#1480
Merged
Conversation
`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>
e2c0875 to
ade8c9f
Compare
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>
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>
3 tasks
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>
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
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:
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`:
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
🤖 Generated with Claude Code