Repository navigation
[WebGPU] Add render-pass command stream batching for native hosts - #18562
matthargett wants to merge 2 commits into
Conversation
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.
There was a problem hiding this comment.
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
RenderCommandBatcherand integrated it intoWebGPUEnginecompatibility-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.
|
@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. |
|
moving as draft in the meantime to prevent accidental merge |
|
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. |
|
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 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: |
…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.
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 everysetPipeline,setBindGroup,setVertexBuffer,setIndexBufferanddraw*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
FRHICommandListrecords 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:
GPURenderPassEncodermethod as its own N-API callback;setBindGroupreads its dynamic offsets into a freshstd::vectoron every call (NativeDawn.cpp#L2295-L2340).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 reusedUint32Array, 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_recordCommandscall on the render pass.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_occlusionQueryActiveguard introduced by Fix WebGPU occlusion query begin sequencing #18918._drawnow has one apply site,ApplyWebGPURenderPassDrawCommand, so indirect and multi-draw lowering plug in at one place rather than at each encoder call.Host contract
A host opts in by installing a provider on the engine:
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. TheprotocolVersionhandshake mirrors thePROTOCOL_VERSIONguard 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 throughApplyWebGPURenderPassDrawCommand, which issues the same encoder calls the old inline code did.beginFrame()throughscene.render():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
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=unitoverpackages/dev/core/test/unitwith this branch merged with current master: 289 files, 4,793 tests pass plus the one pre-existing expected failure, including 39 focused tests acrossrenderCommandBatcher.test.ts,webgpuRenderPassCommandStream.test.ts,webgpuRenderPassFlushBoundaries.test.tsandengine.query.test.ts.npx eslint --quietover all changed source and test files: clean. Thepure.tsbarrel 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):
issues=0), SSAO2 enabled inmode=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.Related work
This continues the Hill Valley NativeXR AR Portal optimization series already merged from this validation effort:
Future work
multiDrawCallCount/multiDrawDrawCountstream fields (wgpu-native exposes MDI; the WebGPU spec does not yet).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.GPURenderBundleEncoder, so the same lowering could batch bundle construction undercompatibilityMode = falseif profiling justifies it.