Visitar URL original
[WebGPU] Add render-pass command stream batching for native hosts by matthargett · Pull Request #19075 · BabylonJS/Babylon.js · GitHub
Skip to content

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

Merged
deltakosh merged 4 commits into
BabylonJS:masterfrom
rebeckerspecialties:native-webgpu-render-command-batching
Oct 28, 2026
Merged

deltakosh merged 4 commits into
BabylonJS:masterfrom
rebeckerspecialties:native-webgpu-render-command-batching

Conversation

@matthargett

Copy link
Copy Markdown
Contributor

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 (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.
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.
Copilot AI balanced review requested due to automatic review settings October 28, 2026 08:29

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@deltakosh deltakosh 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.

Hello @matthargett,

The opt-in batching and render-pass flush boundaries look ready to merge.

Thanks!

@deltakosh
deltakosh enabled auto-merge (squash) October 28, 2026 08:41
@deltakosh

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 1 pipeline(s).

@bjsplat

bjsplat commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Please make sure to label your PR with "bug", "new feature" or "breaking change" label(s).
To prevent this PR from going to the changelog marked it with the "skip changelog" label.

@bjsplat

bjsplat commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Snapshot stored with reference name:
refs/pull/19075/merge

Test environment:
https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/19075/merge/index.html

To test a playground add it to the URL, for example:

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/19075/merge/index.html#WGZLGJ#4600

Links to test your changes to core in the published versions of the Babylon tools (does not contain changes you made to the tools themselves):

https://playground.babylonjs.com/?snapshot=refs/pull/19075/merge
https://sandbox.babylonjs.com/?snapshot=refs/pull/19075/merge
https://gui.babylonjs.com/?snapshot=refs/pull/19075/merge
https://nme.babylonjs.com/?snapshot=refs/pull/19075/merge

To test the snapshot in the playground with a playground ID add it after the snapshot query string:

https://playground.babylonjs.com/?snapshot=refs/pull/19075/merge#BCU1XR#0

If you made changes to the sandbox or playground in this PR, additional comments will be generated soon containing links to the dev versions of those tools.

@bjsplat

bjsplat commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

🟢 Memory Leak Test Results

4 passed, 0 leaked out of 4 scenarios

🟢 All memory leak tests passed — no leaks detected.

Passed Scenarios (4)
Scenario Package
Core Playground #2FDQT5#1508 @babylonjs/core
Core Playground #T90MQ4#14 @babylonjs/core
Core Playground #8EDB5N#2 @babylonjs/core
Core Playground #LL5BIQ#636 @babylonjs/core

@bjsplat

bjsplat commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

⚡ Performance Test Results

🟢 All performance tests passed — no regressions detected.

@deltakosh
deltakosh merged commit 87250b7 into BabylonJS:master Oct 28, 2026
25 checks passed
@Popov72

Popov72 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Follow-up review after merge: the native batching benefit is credible, but the no-provider browser path still builds a scratch command and goes through submit -> failed append -> empty flush -> direct replay. My isolated comparison of the exact base/head _draw methods with warmed stub encoders reproduced about 5 ns/draw of added compatibility-mode bookkeeping. This is not a browser frame-time regression measurement; the reported Chrome scene results remain consistent with no meaningful regression there.

I am preparing a focused follow-up to gate batching before command construction, initialize native batching lazily, and preserve direct submission when no provider is installed. It will cover compatibility draws and render-bundle construction/reuse without changing the native wire protocol.

@matthargett, could you clarify the native contract separately?

  1. Resource ownership: the stream stores handle IDs without retaining all referenced GPU objects until flush. Who pins the native resources while those words are pending, especially uncached bind groups replaced by subsequent draws (such as external-texture materials)? Is that guarantee implemented in the decoder/registry, and can you link that code? NativeEngine's CommandBufferEncoder._pending explicitly retains its resource arguments; the new provider contract does not currently state an equivalent guarantee.
  2. Failure atomicity: when _recordCommands() returns false, is it guaranteed that no commands have been recorded and that the encoder remains usable, or can a prefix already have been recorded? This determines whether ordered direct replay can be safe.
  3. Recovery scope: the current failure path discards previously accepted draws and only switches future draws to direct submission. For a render-once target, the incomplete result can persist rather than self-correct on the next frame. What recovery behavior should the host/engine guarantee for these targets?

These are ownership/recovery contract questions, not a claim that the tested native backend has exhibited a resource-lifetime failure. I will keep any protocol/recovery correction separate from the browser-path follow-up until the implementation guarantees are established.

@Popov72

Popov72 commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

The browser-path follow-up is now open as #19076. It restores direct no-provider submission and initializes native batching lazily, without changing the native wire protocol or failure policy.

The focused devhost comparison covered pre-merge, merged and corrected submission methods in compatibility mode, forced bundle construction and warmed bundle reuse. All variants produced identical pixels and 1,000 draws/frame with no GPU validation errors. The frame timings were noisy, so the PR reports them without claiming a reliable FPS gain. The 55 focused unit scenarios and scoped compiler/lint/format checks passed.

The native resource-pinning / failure-atomicity / render-once recovery questions above remain a separate follow-up pending your implementation details, @matthargett.

deltakosh pushed a commit that referenced this pull request Nov 2, 2026
## Summary

Follow-up to #19075. Preserve direct WebGPU encoder submission when no
native command-recorder provider is installed, instead of constructing a
scratch command and entering the batcher only to replay it immediately.

- Allocate the stream, batcher and reusable indexed/non-indexed commands
only when a host first installs a provider.
- Keep no-provider compatibility draws, render-bundle construction, and
snapshot recording on the direct encoder path. Cached bundle reuse
retains its existing early return.
- Preserve flushing before provider replacement/removal and before
leaving native compatibility-mode submission for bundle recording.
- Retain the lazily allocated native state after provider removal so
removing/reinstalling a provider does not reset the existing
permanent-fallback policy.
- Add 12 focused engine-submission scenarios covering direct
indexed/non-indexed/indirect draws, bundle construction/reuse, snapshot
recording, native batching, provider transitions and permanent fallback.
Update the provider API documentation to describe lazy allocation and
the direct default path.

This removes unnecessary default-path work; it does not claim that every
additional branch is literally free or promise a measurable FPS gain.

## Native ownership and failure semantics: separate follow-up

The wire protocol, resource-ID handling and recording-failure policy are
unchanged here. Questions to @matthargett about pending-resource
pinning, atomic versus partial recording failure, and recovery of
render-once targets are tracked separately in [the merged-PR
discussion](#19075 (comment)).
No behavior is assumed for an unavailable native decoder implementation.

## Focused browser comparison

Used the `testScene` devhost experience with a static 512x512 WebGPU
scene containing 1,000 boxes and 32 materials on Edge 156 / Windows /
NVIDIA Blackwell. Compared the exact draw/dynamic-state/pass-end methods
from pre-merge `d7ee32b22f972ab359594cc1668709f07b0d237b`, merged
`87250b7ba41eac62ad0c135e8a8ec3634cae5f1f`, and the corrected source,
installed sequentially into the same engine/scene to isolate the
submission changes. This is a source-method comparison, **not an
independent full-bundle A/B benchmark**.

Each mode used six rotated/reversed variant blocks, ten warm-up frames
per block and thirty measured frames per block (180 measured frames per
variant per mode). Timed main-thread `beginFrame()` + `scene.render()` +
`endFrame()`; these are CPU frame-submission times, not GPU timings.

| Workload | Pre-merge mean | Merged mean | Corrected mean |
| --- | ---: | ---: | ---: |
| Compatibility mode | 10.111 ms | 9.809 ms | 10.054 ms |
| Bundle construction forced each frame | 12.156 ms | 12.812 ms | 11.379
ms |
| Warmed bundle reuse | 7.778 ms | 7.856 ms | 7.703 ms |

The frame measurements are noisy and do not support claiming a reliable
scene-level speedup. All nine workload/variant combinations produced
exactly 1,000 draws per frame, identical pixel SHA-256
`8b629f51cbbc241480c6e7990a90fa5b6e04753dde378dd4777b009a47c59f6a`, and
no WebGPU validation errors. The no-provider corrected engine retained
null batching state. Raw samples and comparison scripts are preserved in
the implementation session artifacts.

## Validation

- 55 focused tests passed across draw submission, command stream,
generic batcher, flush boundaries, engine queries and snapshot
rendering.
- Scoped TypeScript compiler check of the changed engine/test and their
dependencies passed.
- Changed-file ESLint and Prettier checks passed; the repository
pre-commit hook also passed without additional tracked changes.
- Temporary devhost changes were reverted, and both validation servers
were stopped.
- No full-repository build or test suite was run.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.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.

5 participants