Skip to content

[WebGPU] Add render-pass command stream batching for native hosts - #18562

Closed
matthargett wants to merge 2 commits into
BabylonJS:masterfrom
rebeckerspecialties:native-webgpu-render-command-batching
Closed

matthargett wants to merge 2 commits into
BabylonJS:masterfrom
rebeckerspecialties:native-webgpu-render-command-batching

Conversation

@matthargett

@matthargett matthargett commented Jun 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR adds an internal render-command batching layer for WebGPU draw submission, bringing the WebGPU engine into congruence with a pattern Babylon.js core already ships: the bgfx-based NativeEngine batches its engine commands through a CommandBufferEncoder (thinNativeEngine.pure.ts) and flushes them across the JS/native bridge in bulk, rather than paying one Node-API crossing per call. The WebGPU engine has no equivalent layer, so on hosts that run it over a JS/native boundary every setPipeline, setBindGroup, setVertexBuffer, setIndexBuffer and draw* crosses that boundary individually. That per-draw bridge traffic is the CPU cost profiled in this forum post (iPhone XS / iPhone 12, Hill Valley AR portal scene).

This is also the architecture browsers use one layer down for the identical boundary problem: Chromium's Dawn wire protocol serializes WebGPU commands into batched buffers across the renderer-to-GPU-process boundary, and Unreal's FRHICommandList records on the render thread and translates on the RHI thread. The boundary here is N-API or JSI rather than IPC or a thread hop; the remedy is the same.

Browsers are unaffected, and this is now measured rather than asserted (see Browser cost below): the no-provider path costs about 5 ns per draw in isolation, and a 3,000-draw-per-frame scene in Chrome 152 shows no measurable frame-time difference between this branch and master.

Scope note: this is transport-level, order-preserving submission batching, not draw merging. It does not reorder, sort or combine draws, so it composes with instancing rather than competing with it, and the stream format reserves multi-draw fields for future multi-draw-indirect lowering.

Why the engine is the right layer

Every native WebGPU host for JavaScript binds the render-pass encoder one call at a time, because none of them owns the draw loop:

  • BabylonNative's Dawn backend (BabylonJS/BabylonNative#1781) binds each GPURenderPassEncoder method as its own N-API callback; setBindGroup reads its dynamic offsets into a fresh std::vector on every call (NativeDawn.cpp#L2295-L2340).
  • react-native-webgpu binds the same methods as per-call JSI host functions (GPURenderPassEncoder.h#L103-L112). Its optimizations (prototype caches, import-once native buffers, async work pumped on the owning instance) target per-object and per-frame costs; per-draw crossings are out of its reach for the same reason.
  • The wgpu-native backend this PR was developed against is the same shape.

The Babylon Lite native plan treats this as a first-class metric: every prototype must report "JS↔C crossing count and cost per frame", and option S0d is a purpose-built JS↔C wire that crosses once per frame (BABYLON_LITE_PLAN.md). This PR is that wire for the existing engine: it lives in the transport layer, behind the standard WebGPU API, and a host opts in by installing a provider.

The benefit scales with draws per frame, which is the honest way to read the native numbers below: a frame with ten draws gains nothing; the full Hill Valley scene at 81 draws per frame gains about 8% on iPhone 12; the stress scene at thousands of draws per second cuts draw-level bridge crossings by about 95%.

Architecture

  • RenderCommandBatcher: a small generic lower-or-replay adapter. It tries the optional lowering path; for a draw that cannot lower, it flushes pending lowered work first, then replays the original command exactly once. Ordering is preserved unconditionally.
  • WebGPURenderPassCommandStream: the WebGPU-specific lowerer. It encodes compatibility-mode draws as opcode words into a reused Uint32Array, state-diffing pipeline, index/vertex buffers and bind groups so redundant state is elided before it is recorded, then submits the whole batch through a single _recordCommands call on the render pass.
  • Flush boundaries: batched draws are recorded before every direct render-pass mutation (viewport, scissor, stencil reference, blend constant, full-quad clears, occlusion query begin/end, debug marker push/pop/insert, and render-pass end) through a ThinWebGPUEngine._flushRenderPassCommands() seam that is a no-op for engines that do not batch. Without these boundaries a draw batched inside an occlusion-query bracket would be recorded after the bracket closes (the query reports zero samples and the mesh wrongly disappears as occluded), and GPU captures would attribute draws to the wrong debug group. The occlusion-query flush sits inside the _occlusionQueryActive guard introduced by Fix WebGPU occlusion query begin sequencing #18918.
  • The draw hot path is allocation-free: the engine reuses two scratch draw-command objects (both the lowering and replay paths consume commands synchronously), and the stream reuses scratch id arrays. _draw now has one apply site, ApplyWebGPURenderPassDrawCommand, so indirect and multi-draw lowering plug in at one place rather than at each encoder call.
  • Failure policy: a protocol-version mismatch or a backend recording failure logs one error (enriched with the provider's diagnostics) and permanently falls back to direct encoder calls for that engine; nothing throws from the render loop. Resource ids returned by the provider are validated as positive safe integers at the trust boundary; an out-of-range id makes the draw replay directly.
  • Typed indexed/non-indexed draw command variants instead of a magic numeric draw type.

Host contract

A host opts in by installing a provider on the engine:

engine.renderPassCommandRecorderProvider = {
    protocolVersion: 1,                       // must equal WebGPURenderPassCommandStreamProtocolVersion in the decoder build
    isEnabled: () => true,                    // host-controlled toggle
    getResourceId: (resource) => /* host handle id for a pipeline/buffer/bind group, or 0 */,
    getLastError: () => /* optional backend diagnostics for failure logs */,
};

and exposing a _recordCommands(words, drawCount, multiDrawCallCount, multiDrawDrawCount, wordCount) hook on its render-pass encoders. The recorder must consume the words synchronously; the buffer is reused after the call returns. The protocolVersion handshake mirrors the PROTOCOL_VERSION guard the bgfx-based NativeEngine uses to keep the JS encoder and native decoder from drifting, soft-failing here because this transport is an optimization rather than the engine's only path.

Browser cost

With no provider installed (always the case in browsers) a compatibility-mode draw fills the scratch command, calls submit, which returns at the provider null check, and replays through ApplyWebGPURenderPassDrawCommand, which issues the same encoder calls the old inline code did.

  • Isolated microbenchmark of exactly that path against the inline encoder calls, on a stub render pass (V8 / Node 24, Apple M4 Max, 2,000,000 draws per round, three rounds): inline 8.0–8.3 ns per draw, no-provider path 13.1–13.4 ns per draw. The added cost is about 5 ns per draw, or 15 µs for a 3,000-draw frame.
  • Chrome 152 (WebGPU on Metal, Apple M4 Max), UMD bundles built from this branch and from its master base, a scene of 3,000 meshes with 64 materials (2,996 draws per frame), compatibility mode, 600 timed frames after 150 warm-up frames, measuring main-thread time from beginFrame() through scene.render():
Bundle mean p50 p90 p99
master 38.9 ms 44.5 ms 50.9 ms 69.9 ms
this branch 38.1 ms 43.4 ms 51.9 ms 59.6 ms

The two are indistinguishable within run-to-run noise; Chrome's own cost in that scene is about 13 µs per draw, three orders of magnitude above the added path.

Test coverage

  • Contract-level unit coverage of the batcher and stream: lowering absent/present/disabled, fallback replay with flush-before-replay ordering, state-diff behavior per state kind, indexed/non-indexed/indirect draws, late and out-of-range resource ids, command storage growth, render-pass boundary flushing, no-provider behavior, protocol-version mismatch, and permanent fallback with diagnostics when backend recording fails.
  • Flush-boundary scenario tests (webgpuRenderPassFlushBoundaries.test.ts) drive the real registered engine prototype methods against a semantic model of the native render pass: a GPU timeline that executes direct calls and decoded command-stream batches in submission order, attributing each executed draw to the occlusion query and debug groups open at execution time. Assertions are on user-visible outcomes ("which draws did query N observe", "which draws are inside group X", "did the draws stay on the batched path"), not on encoder-call ordinality.

Validation

  • npx vitest run --project=unit over packages/dev/core/test/unit with this branch merged with current master: 289 files, 4,793 tests pass plus the one pre-existing expected failure, including 39 focused tests across renderCommandBatcher.test.ts, webgpuRenderPassCommandStream.test.ts, webgpuRenderPassFlushBoundaries.test.ts and engine.query.test.ts.
  • npx eslint --quiet over all changed source and test files: clean. The pure.ts barrel entry is in the generator's order.
  • nx build babylonjs: passes; the resulting bundle is the one measured above.

Native integration validation (BabylonNative, WebGPU backed by wgpu-native):

  • macOS Release/LTO Playground, non-AR Hill Valley stress scene, material parity checks passing (issues=0), SSAO2 enabled in mode=prepass. Command stream enabled, steady windows: about 13,920 draws per 2 s grouped into about 720 command-stream bridge calls, about 94.8% fewer draw-level bridge traversals, at 59.5–60.5 native fps. Command stream disabled: same draw volume, cmdStreams=0, about 59–60 native fps. The M4 path is vsync-bound, so this run demonstrates the bridge-call reduction rather than an fps gain.
  • iPhone 12 (A14), full Hill Valley scene forced non-XR (112 active meshes, 81 draws per frame with SSAO2): 18 fps → 19.5 fps with the command stream enabled; no change in the pre-placement phase, which draws about 10 meshes per frame.
  • iPhone XS (A12): about 40 fps → 45 fps with improved 1% lows, as documented in the forum post.

Related work

This continues the Hill Valley NativeXR AR Portal optimization series already merged from this validation effort:

Future work

  • Multi-draw-indirect lowering through the reserved multiDrawCallCount/multiDrawDrawCount stream fields (wgpu-native exposes MDI; the WebGPU spec does not yet).
  • Lower debug markers (pushDebugGroup/popDebugGroup/insertDebugMarker) into stream opcodes so that enabling GPU profiler markers does not fragment batches with boundary flushes; today markers stay correct (flushed) but each marker costs a bridge crossing and splits the batch, which is acceptable for profiling sessions only.
  • The command-stream types already admit GPURenderBundleEncoder, so the same lowering could batch bundle construction under compatibilityMode = false if profiling justifies it.

Hosts that run the WebGPU engine over a JS/native boundary (e.g.
BabylonNative over wgpu-native) pay one bridge crossing per render-pass
encoder call: setPipeline, setBindGroup, setVertexBuffer, setIndexBuffer,
and draw* each traverse Node-API individually. This brings the WebGPU
path into congruence with the pattern core already ships for the
bgfx-based NativeEngine (CommandBufferEncoder + NativeDataStream): encode
commands as words into a reused buffer and submit them across the
boundary in batches.

- RenderCommandBatcher: a generic lower-or-replay adapter. Pending
  lowered work is always flushed before a command replays, so submission
  order is preserved unconditionally.
- WebGPURenderPassCommandStream: encodes compatibility-mode draws as
  opcode words in a reused Uint32Array, state-diffing pipeline,
  index/vertex buffers, and bind groups so redundant state is elided
  before it is recorded, then submits each batch through a single
  _recordCommands call on the render pass.
- Hosts opt in by installing an IWebGPURenderPassCommandRecorderProvider
  on engine.renderPassCommandRecorderProvider, supplying resource handle
  ids, enablement, and diagnostics, with a protocol-version handshake
  mirroring NativeEngine's PROTOCOL_VERSION guard. A version mismatch or
  a recording failure logs once and falls back permanently to direct
  encoder calls; nothing throws from the render loop.
- Batched draws are recorded before every direct render-pass mutation -
  viewport, scissor, stencil reference, blend constant, full-quad
  clears, occlusion query begin/end, debug markers, and pass end -
  through a ThinWebGPUEngine._flushRenderPassCommands seam, keeping
  occlusion-query results and GPU-capture debug groups attributed to the
  correct draws.
- The draw hot path is allocation-free via reused scratch draw commands
  and id arrays. With no provider installed (always the case in
  browsers), the per-draw overhead is a null check and a work-free
  flush, and draws take the standard WebGPU encoder calls unchanged.
- Unit tests cover the batcher and stream contracts, plus flush-boundary
  scenario tests that assert occlusion-query and debug-group draw
  attribution on a decoded native timeline rather than encoder-call
  ordinality.
Copilot AI review requested due to automatic review settings June 11, 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

Note

Copilot was unable to run its full agentic suite in this review.

Introduces render-pass draw batching for WebGPU (targeting JS/native bridge hosts) and adds boundary flushing so occlusion queries and GPU debug markers observe draws in correct submission order.

Changes:

  • Added a generic RenderCommandBatcher and integrated it into WebGPUEngine compatibility-mode draw submission.
  • Implemented a WebGPU render-pass command stream encoder with protocol/version gating and host recorder provider plumbing.
  • Added unit tests covering batching behavior, command stream encoding, and flush-boundary correctness (occlusion queries + debug marker groups).

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/dev/core/src/Engines/renderCommandBatcher.pure.ts Adds generic batcher abstraction (lowering + replay).
packages/dev/core/src/Engines/pure.ts Exposes the new batcher from the pure barrel.
packages/dev/core/src/Engines/WebGPU/webgpuRenderPassCommandStream.ts Implements WebGPU render-pass command stream lowering + replay helper.
packages/dev/core/src/Engines/webgpuEngine.pure.ts Wires batching into compatibility-mode draws and adds flush points before direct pass mutations.
packages/dev/core/src/Engines/thinWebGPUEngine.ts Adds _flushRenderPassCommands hook and calls it before ending a pass.
packages/dev/core/src/Engines/WebGPU/Extensions/engine.query.pure.ts Flushes batched draws at occlusion query boundaries.
packages/dev/core/src/Engines/WebGPU/Extensions/engine.debugging.pure.ts Flushes batched draws at debug group/marker boundaries.
packages/dev/core/test/unit/Engines/renderCommandBatcher.test.ts Adds unit tests for batcher replay/flush/reset behavior.
packages/dev/core/test/unit/Engines/WebGPU/webgpuRenderPassCommandStream.test.ts Adds encoder/lowering unit tests for opcode stream behavior.
packages/dev/core/test/unit/Engines/WebGPU/webgpuRenderPassFlushBoundaries.test.ts Adds scenario tests ensuring correct semantic ordering across flush boundaries.

Provider-supplied resource ids are encoded into 64-bit command words from
JS numbers; an id beyond Number.MAX_SAFE_INTEGER would encode imprecisely
and corrupt the stream. Treat out-of-range ids like missing ones - the
draw falls back to direct encoder replay - and document the range in the
provider contract. Also document at the failure site why a failed backend
recording drops the in-flight batch instead of retaining replayable
copies of every draw.
@sebavan
sebavan requested a review from Popov72 June 11, 2026 16:24
@sebavan

sebavan commented Jun 11, 2026 •

Copy link
Copy Markdown
Member

@matthargett not too sure why we need this part ?

@matthargett

matthargett commented Jun 11, 2026 •

Copy link
Copy Markdown
Contributor Author

@matthargett not too sure why we need this part ?

in my BabylonNative webgpu fork, the amount of N-API/JsRuntimeHost overhead becomes a notable contributor to cache misses while rendering the AR Portal example on iPhone XS and iPhone 12. Ultimately, this cuts the marhsling costs across these layers by over 90%. This is very similar to the work already done on the WebGL2/bgfx side, but in WebGPU in browser it starts to tee up the ability to use multi-draw indirect and other advanced rendering.

@sebavan sebavan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let s see if @Popov72 could review as it seems to complexify the main render for now and I am not sure it is the way he planne to integrate the indirect draw.

@sebavan
sebavan marked this pull request as draft June 12, 2026 23:50
@sebavan

sebavan commented Jun 12, 2026

Copy link
Copy Markdown
Member

moving as draft in the meantime to prevent accidental merge

@sebavan

sebavan commented Jun 17, 2026

Copy link
Copy Markdown
Member

It looks like unwanted overhead in the web version so I am closing for now. Let s revisit in September when @Popov72 is back.

This should not impact the current web setup.

@sebavan sebavan closed this Jun 17, 2026
@matthargett

Copy link
Copy Markdown
Contributor Author

It looks like unwanted overhead in the web version so I am closing for now. Let s revisit in September when @Popov72 is back.

This should not impact the current web setup.

Is there a reason the overhead (and enormous performance per scene complexity gain) is okay on the WebGL path but not the WebGPU path? Is it preferred to make WebGPU non-competitive with WebGL despite its leaps forward? I'm a little confused about why divergence in performance outcomes is preferred.

@matthargett

matthargett commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Continued in #19075: GitHub cannot reopen a pull request whose head branch has moved since it was closed. The branch is brought up to date with current master (9.32.1 era; it sits cleanly on top of #18918's occlusion-query sequencing guard), and the web-overhead concern is answered there by measurement rather than argument.

Browser cost. The browser path is a provider null check plus a replay through one apply function. In isolation that is about 5 ns per draw over the old inline encoder calls (8 ns → 13 ns on a stub pass, V8). In Chrome 152 on Metal, a 3,000-draw-per-frame compatibility-mode scene measures master at 38.9 ms mean / 44.5 ms p50 and this branch at 38.1 ms / 43.4 ms over 600 frames: indistinguishable, against a per-draw cost in Chrome of roughly 13 µs. Full numbers and method are in the updated description.

What changed since June. Every native WebGPU host for JS still binds the encoder one call at a time: BabylonNative's own Dawn backend (BabylonJS/BabylonNative#1781) uses a per-method N-API callback with a heap-allocated offsets vector per setBindGroup, and react-native-webgpu uses per-call JSI host functions. None of them owns the draw loop, so none can amortize; the engine can. The Babylon Lite native plan now lists "JS↔C crossing count and cost per frame" as a required metric for every prototype and proposes a one-crossing-per-frame wire (S0d); this PR is that wire for the existing engine, behind the standard WebGPU API.

Scaling. The gain tracks draws per frame: none at 10 draws, about 8% at 81 draws (iPhone 12, full Hill Valley scene), about 95% fewer bridge crossings in the stress scene. Browsers pay nothing.

On the indirect-draw question: _draw now has exactly one apply site, so indirect or multi-draw lowering plugs in at one place, and the stream already reserves the multi-draw fields. @Popov72 I would value your read on whether that site matches how you plan to integrate indirect draws.

deltakosh pushed a commit that referenced this pull request Oct 28, 2026
…9075)

> Supersedes #18562, which GitHub cannot reopen because its head branch
moved after it was closed. Same change, brought up to date with current
master; the measurements below are new.

## Summary

This PR adds an internal render-command batching layer for WebGPU draw
submission, bringing the WebGPU engine into congruence with a pattern
Babylon.js core already ships: the bgfx-based NativeEngine batches its
engine commands through a `CommandBufferEncoder`
(`thinNativeEngine.pure.ts`) and flushes them across the JS/native
bridge in bulk, rather than paying one Node-API crossing per call. The
WebGPU engine has no equivalent layer, so on hosts that run it over a
JS/native boundary every `setPipeline`, `setBindGroup`,
`setVertexBuffer`, `setIndexBuffer` and `draw*` crosses that boundary
individually. That per-draw bridge traffic is the CPU cost profiled in
[this forum
post](https://forum.babylonjs.com/t/solving-cpu-bottleneck-in-nativexr-hill-valley-ar-portal-scene/63558)
(iPhone XS / iPhone 12, Hill Valley AR portal scene).

This is also the architecture browsers use one layer down for the
identical boundary problem: Chromium's Dawn wire protocol serializes
WebGPU commands into batched buffers across the renderer-to-GPU-process
boundary, and Unreal's `FRHICommandList` records on the render thread
and translates on the RHI thread. The boundary here is N-API or JSI
rather than IPC or a thread hop; the remedy is the same.

**Browsers are unaffected, and this is now measured rather than
asserted** (see *Browser cost* below): the no-provider path costs about
5 ns per draw in isolation, and a 3,000-draw-per-frame scene in Chrome
152 shows no measurable frame-time difference between this branch and
master.

**Scope note:** this is transport-level, order-preserving submission
batching, not draw merging. It does not reorder, sort or combine draws,
so it composes with instancing rather than competing with it, and the
stream format reserves multi-draw fields for future multi-draw-indirect
lowering.

## Why the engine is the right layer

Every native WebGPU host for JavaScript binds the render-pass encoder
one call at a time, because none of them owns the draw loop:

- BabylonNative's Dawn backend
([BabylonJS/BabylonNative#1781](BabylonJS/BabylonNative#1781))
binds each `GPURenderPassEncoder` method as its own N-API callback;
`setBindGroup` reads its dynamic offsets into a fresh `std::vector` on
every call
([NativeDawn.cpp#L2295-L2340](https://github.com/BabylonJS/BabylonNative/blob/da15f7ae1a6f5c59a4d6f1fd322145a3d308f70f/Plugins/NativeDawn/Source/NativeDawn.cpp#L2295-L2340)).
- react-native-webgpu binds the same methods as per-call JSI host
functions
([GPURenderPassEncoder.h#L103-L112](https://github.com/wcandillon/react-native-webgpu/blob/24cf75ba084cb23fb477053fc310ecbfceae6bb2/packages/webgpu/cpp/rnwgpu/api/GPURenderPassEncoder.h#L103-L112)).
Its optimizations (prototype caches, import-once native buffers, async
work pumped on the owning instance) target per-object and per-frame
costs; per-draw crossings are out of its reach for the same reason.
- The wgpu-native backend this PR was developed against is the same
shape.

The Babylon Lite native plan treats this as a first-class metric: every
prototype must report "JS↔C crossing count and cost per frame", and
option S0d is a purpose-built JS↔C wire that crosses once per frame
([BABYLON_LITE_PLAN.md](https://github.com/bghgary/BabylonNative/blob/bghgary/babylon-lite-plan/BABYLON_LITE_PLAN.md)).
This PR is that wire for the existing engine: it lives in the transport
layer, behind the standard WebGPU API, and a host opts in by installing
a provider.

The benefit scales with draws per frame, which is the honest way to read
the native numbers below: a frame with ten draws gains nothing; the full
Hill Valley scene at 81 draws per frame gains about 8% on iPhone 12; the
stress scene at thousands of draws per second cuts draw-level bridge
crossings by about 95%.

## Architecture

- `RenderCommandBatcher`: a small generic lower-or-replay adapter. It
tries the optional lowering path; for a draw that cannot lower, it
flushes pending lowered work first, then replays the original command
exactly once. Ordering is preserved unconditionally.
- `WebGPURenderPassCommandStream`: the WebGPU-specific lowerer. It
encodes compatibility-mode draws as opcode words into a reused
`Uint32Array`, state-diffing pipeline, index/vertex buffers and bind
groups so redundant state is elided before it is recorded, then submits
the whole batch through a single `_recordCommands` call on the render
pass.
- Flush boundaries: batched draws are recorded before every direct
render-pass mutation (viewport, scissor, stencil reference, blend
constant, full-quad clears, occlusion query begin/end, debug marker
push/pop/insert, and render-pass end) through a
`ThinWebGPUEngine._flushRenderPassCommands()` seam that is a no-op for
engines that do not batch. Without these boundaries a draw batched
inside an occlusion-query bracket would be recorded after the bracket
closes (the query reports zero samples and the mesh wrongly disappears
as occluded), and GPU captures would attribute draws to the wrong debug
group. The occlusion-query flush sits inside the `_occlusionQueryActive`
guard introduced by #18918.
- The draw hot path is allocation-free: the engine reuses two scratch
draw-command objects (both the lowering and replay paths consume
commands synchronously), and the stream reuses scratch id arrays.
`_draw` now has one apply site, `ApplyWebGPURenderPassDrawCommand`, so
indirect and multi-draw lowering plug in at one place rather than at
each encoder call.
- Failure policy: a protocol-version mismatch or a backend recording
failure logs one error (enriched with the provider's diagnostics) and
permanently falls back to direct encoder calls for that engine; nothing
throws from the render loop. Resource ids returned by the provider are
validated as positive safe integers at the trust boundary; an
out-of-range id makes the draw replay directly.
- Typed indexed/non-indexed draw command variants instead of a magic
numeric draw type.

## Host contract

A host opts in by installing a provider on the engine:

```js
engine.renderPassCommandRecorderProvider = {
    protocolVersion: 1,                       // must equal WebGPURenderPassCommandStreamProtocolVersion in the decoder build
    isEnabled: () => true,                    // host-controlled toggle
    getResourceId: (resource) => /* host handle id for a pipeline/buffer/bind group, or 0 */,
    getLastError: () => /* optional backend diagnostics for failure logs */,
};
```

and exposing a `_recordCommands(words, drawCount, multiDrawCallCount,
multiDrawDrawCount, wordCount)` hook on its render-pass encoders. The
recorder must consume the words synchronously; the buffer is reused
after the call returns. The `protocolVersion` handshake mirrors the
`PROTOCOL_VERSION` guard the bgfx-based NativeEngine uses to keep the JS
encoder and native decoder from drifting, soft-failing here because this
transport is an optimization rather than the engine's only path.

## Browser cost

With no provider installed (always the case in browsers) a
compatibility-mode draw fills the scratch command, calls `submit`, which
returns at the provider null check, and replays through
`ApplyWebGPURenderPassDrawCommand`, which issues the same encoder calls
the old inline code did.

- Isolated microbenchmark of exactly that path against the inline
encoder calls, on a stub render pass (V8 / Node 24, Apple M4 Max,
2,000,000 draws per round, three rounds): inline 8.0–8.3 ns per draw,
no-provider path 13.1–13.4 ns per draw. The added cost is about 5 ns per
draw, or 15 µs for a 3,000-draw frame.
- Chrome 152 (WebGPU on Metal, Apple M4 Max), UMD bundles built from
this branch and from its master base, a scene of 3,000 meshes with 64
materials (2,996 draws per frame), compatibility mode, 600 timed frames
after 150 warm-up frames, measuring main-thread time from `beginFrame()`
through `scene.render()`:

| Bundle | mean | p50 | p90 | p99 |
|---|---:|---:|---:|---:|
| master | 38.9 ms | 44.5 ms | 50.9 ms | 69.9 ms |
| this branch | 38.1 ms | 43.4 ms | 51.9 ms | 59.6 ms |

The two are indistinguishable within run-to-run noise; Chrome's own cost
in that scene is about 13 µs per draw, three orders of magnitude above
the added path.

## Test coverage

- Contract-level unit coverage of the batcher and stream: lowering
absent/present/disabled, fallback replay with flush-before-replay
ordering, state-diff behavior per state kind,
indexed/non-indexed/indirect draws, late and out-of-range resource ids,
command storage growth, render-pass boundary flushing, no-provider
behavior, protocol-version mismatch, and permanent fallback with
diagnostics when backend recording fails.
- Flush-boundary scenario tests
(`webgpuRenderPassFlushBoundaries.test.ts`) drive the real registered
engine prototype methods against a semantic model of the native render
pass: a GPU timeline that executes direct calls and decoded
command-stream batches in submission order, attributing each executed
draw to the occlusion query and debug groups open at execution time.
Assertions are on user-visible outcomes ("which draws did query N
observe", "which draws are inside group X", "did the draws stay on the
batched path"), not on encoder-call ordinality.

## Validation

- `npx vitest run --project=unit` over `packages/dev/core/test/unit`
with this branch merged with current master: 289 files, 4,793 tests pass
plus the one pre-existing expected failure, including 39 focused tests
across `renderCommandBatcher.test.ts`,
`webgpuRenderPassCommandStream.test.ts`,
`webgpuRenderPassFlushBoundaries.test.ts` and `engine.query.test.ts`.
- `npx eslint --quiet` over all changed source and test files: clean.
The `pure.ts` barrel entry is in the generator's order.
- `nx build babylonjs`: passes; the resulting bundle is the one measured
above.

Native integration validation (BabylonNative, WebGPU backed by
wgpu-native):

- macOS Release/LTO Playground, non-AR Hill Valley stress scene,
material parity checks passing (`issues=0`), SSAO2 enabled in
`mode=prepass`. Command stream enabled, steady windows: about 13,920
draws per 2 s grouped into about 720 command-stream bridge calls, about
94.8% fewer draw-level bridge traversals, at 59.5–60.5 native fps.
Command stream disabled: same draw volume, `cmdStreams=0`, about 59–60
native fps. The M4 path is vsync-bound, so this run demonstrates the
bridge-call reduction rather than an fps gain.
- iPhone 12 (A14), full Hill Valley scene forced non-XR (112 active
meshes, 81 draws per frame with SSAO2): 18 fps → 19.5 fps with the
command stream enabled; no change in the pre-placement phase, which
draws about 10 meshes per frame.
- iPhone XS (A12): about 40 fps → 45 fps with improved 1% lows, as
documented in the forum post.

## Related work

This continues the Hill Valley NativeXR AR Portal optimization series
already merged from this validation effort:

- #18539 — Support SSAO2 with world-space normals
- #18521 — Move custom frame requester to AbstractEngine to enable
120fps support in BabylonNative
- #18538 — Fix KTX noMipmap flip for compressed sRGB mipmaps

## Future work

- Multi-draw-indirect lowering through the reserved
`multiDrawCallCount`/`multiDrawDrawCount` stream fields (wgpu-native
exposes MDI; the WebGPU spec does not yet).
- Lower debug markers
(`pushDebugGroup`/`popDebugGroup`/`insertDebugMarker`) into stream
opcodes so that enabling GPU profiler markers does not fragment batches
with boundary flushes; today markers stay correct (flushed) but each
marker costs a bridge crossing and splits the batch, which is acceptable
for profiling sessions only.
- The command-stream types already admit `GPURenderBundleEncoder`, so
the same lowering could batch bundle construction under
`compatibilityMode = false` if profiling justifies it.
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.

3 participants