Skip to content

WebGPU: asynchronous pipeline creation for mesh draws (opt-in) - #19090

Open
enriqxxx wants to merge 19 commits into
BabylonJS:masterfrom
enriqxxx:async-pipeline-creation-r2
Open

enriqxxx wants to merge 19 commits into
BabylonJS:masterfrom
enriqxxx:async-pipeline-creation-r2

Conversation

@enriqxxx

@enriqxxx enriqxxx commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Forum: 64298 #11, "Async pipelines: extend the existing path, and handle incomplete frames".

On WebGPU, the first draw that needs a new render pipeline creates it synchronously: the browser compiles the shaders for that pipeline state before it runs the commands that follow, and the frame waits for it (here about 100 ms per new PBR variant). This PR adds an opt-in, engine.asynchronousPipelineCreation (off by default): a mesh draw whose pipeline is not created yet is omitted while the pipeline is created asynchronously, through the existing preWarmPipeline(). With the option off, nothing changes.

What the option does.

  • Only draws of meshes can wait. Mesh._processRendering gives the draws it submits their owner, the mesh, in every pass that draws it (main pass, shadow maps, also through a ShadowDepthWrapper, depth renderer, geometry buffer, effect layers, outline, volumetric light scattering) and whatever draw context its material gives them, but not to what an observer of its draw (onBeforeDrawObservable) draws; _draw reads the owner from the engine. The color and depth compositors of point splatting wait with their splat mesh. Post-processes, effect renderers, sprites, particles, the bounding boxes of occlusion queries and the other draws have none, and keep creating their pipelines synchronously, so a full-screen pass is never skipped and a query never closes empty. Some of them run only once: the environment BRDF table, for one, is expanded from RGBD by a single post-process render (RGBDTextureTools.ExpandRGBDTexture). In our app, an earlier version of this option that let any draw wait omitted that render: the table stayed at zero, and every PBR material drew black while its depth was still written.
  • Only the passes the scene renders again can wait: its cameras, the render targets of its frame (customRenderTargets, the render targets of its materials and components) and the frame graph. An object renderer's mesh draws wait only in a rendering its shouldRender() asked for. A pass rendered once draws every mesh in that rendering, creating its pipelines synchronously, so it never misses a mesh. That covers an object renderer with REFRESHRATE_RENDER_ONCE (of a render target, or of a frame graph task), such as a GPU picking (its target renders once per request); an object renderer rendered by a direct call to render(), such as the voxelization of IBL shadows or a decal of MeshUVSpaceRenderer; a screenshot through a render target (CreateScreenshotUsingRenderTarget renders the scene's frame once into its target), and any render outside the scene's frame.
  • A mesh is shown in all its passes together: once a draw of the mesh waits in a frame, the mesh is omitted in every pass until a frame in which none of its draws waited. Meanwhile, a render target refreshed every N frames renders in every frame, until the mesh is drawn again (two frames after the last wait, that recovery frame included), so it holds the mesh back and shows it in the same frames as the other passes; then it goes back to its rate. Every such render target of the engine does, including those that do not draw the mesh: while pipelines are created, these extra renders are the cost of the option (documented on asynchronousPipelineCreation and ObjectRenderer.shouldRender()). Only the passes drawn before the first wait of that frame show it alone, for that frame (in the test below, the shadow map draws of a new mesh, one frame).
  • Snapshots never replay a frame that misses a draw forever. A recording that omitted draws is replayed while their pipelines are created, and recorded again at the first frame after they are all created. A recording that would freeze a mesh in some passes only (a wait after an earlier pass of the frame drew it, or draws held back with nothing pending) is recorded again at the next frame.
  • areAllEffectsReady() (so scene.isReady()) is false while the pipelines this engine waits for are being created: its own creations, and a creation by another engine of a pipeline it draws too.

On top of the existing path. No second cache: WebGPUCacheRenderPipeline.tryGetRenderPipeline() looks up the same cache and, on a miss, calls preWarmPipeline(), which now

  • creates a pipeline configuration once: pending creations are kept by cache token, and a second request for the same configuration (from a draw or from createRenderPipelineAsync()) gets the pending promise;
  • counts, in each engine's cache, the creations it waits for (numAwaitedPipelines), which the engine's readiness reads; the cache forgets them when its device is lost or its engine is disposed. NumPendingPipelines counts those of all the engines, for diagnostics;
  • reports a failed creation (Logger.Error with the pipeline label; callers still get the rejection), and the next miss of that configuration creates it synchronously, so the WebGPU error shows exactly as today;
  • is ignored once the device is replaced or the last engine disposed: the reset of the cache forgets the pending creations, so a late completion is neither kept, counted nor reported, and NumPendingPipelines does not wait for creations of a device that is gone; the engine also ignores the completions of an older generation (its own dispose included).

It is named asynchronous pipeline creation: the browser may still do part of the compilation elsewhere or at first use; nothing here claims the whole cost left the main thread.

Measured against 9.33.0 on 87250b7, which 9.33.0 contains (the branch is on master 8958948; the commits since, the KTX2 encoder of #19011 and the version file, touch nothing measured here), the machine checked quiet before and during each measure. Chromium 147, RTX 3080 Laptop GPU, WebGPU, 800×450. Deterministic scene: 600 meshes with a StandardMaterial, a directional light with a 4-cascade CSM; every 40 frames a wave of 20 meshes appears with a PBR variant no draw has used yet (albedo and emissive textures, clear coat, sheen, iridescence: a new shader, so a new main-pass pipeline). 8 waves, 360 frames per run, a new browser for every run, 2 rounds in alternating order, in three modes: no snapshot, compatibilityMode = false, and a FAST snapshot recorded again at each wave. A frame's time is from its start until the GPU finished it (onSubmittedWorkDone).

Arm Slowest frame (no snapshot / compatibilityMode = false / FAST snapshot) Frames over 100 ms, of 8 waves Pipelines created inside a frame A wave is drawn
9.33.0 118-121 / 108-116 / 122-129 ms 5-8 8 at once
this branch, asynchronousPipelineCreation 33-34 / 31-35 / 35-45 ms 0 0 5-6 / 6-7 / 8-11 frames later (about 110-140 ms)
this branch, the app prewarms each wave (below) 34 / 27-32 / 38-42 ms 0 8 4-5 / 5-6 / 11-14 frames later (about 85-115 ms)
  • Once a wave is drawn (the last frame before the next wave), every arm shows the same image as 9.33.0, for the 8 waves in the three modes; with the option off, this branch shows the same image as 9.33.0 in every frame. No errors.
  • With nothing pending (the same scene without waves, about 2,800 draws per frame, 4 rounds in alternating order), the option costs nothing measurable: CPU time per frame 10.24 ms on / 9.90 ms off without a snapshot (rounds 9.59-10.78 and 9.58-10.15), 7.74 / 7.76 ms with compatibilityMode = false, with differences of both signs between rounds; and with the option off, this branch matches 9.33.0 (9.86 / 9.95 ms, 7.71 / 7.55 ms). With it off, a draw reads one boolean more, and the pipeline cache's synchronous path is unchanged. A first version that kept the wait state on the mesh and read it at every draw cost 0.4-0.7 ms per frame in this scene (the time was in _draw, in a CPU profile); the state is now read only in a frame in which a draw waited and the one after.
  • A Playground of the same waves, which counts the render pipelines the device creates synchronously and asynchronously, and on a build with the option runs them with it off and on: https://playground.babylonjs.com/#INWH19#0. On 9.33.0 with WebGPU its slowest frame is 86 to 103 ms and the waves create their pipelines synchronously (9 for 8 waves); with WebGL2, 26 to 43 ms. On this branch with the option on: 21 ms, 8 pipelines created asynchronously and none synchronously.
  • With a FAST snapshot, a wave costs three recordings instead of one: the one it interrupts (a shadow map pass had drawn the new meshes before the main pass missed their pipeline, so it is recorded again at the next frame), a coherent one without the wave (replayed while the pipeline is created), and the complete one.

Prewarming known content instead. The same waves, prewarmed as an app would: the effect of the real sub mesh (isReadyForSubMesh; forceCompilationAsync() compiles with a temporary sub mesh), then createRenderPipelineAsync({ effect, mesh }) with the default states; the frames go on and the wave is shown when the promise resolves. It removes the long frames too, but it does not create the pipeline the wave draws with: the first draw of each wave still creates one synchronously. The new meshes receive the CSM, whose shadow map is a depth texture (shadowTexture0, DEPTH32_FLOAT), so their draws use texture state 1, and createRenderPipelineAsync() always prewarms texture state 0. That synchronous creation was short here only because the browser had compiled the same shaders during the prewarm. The automatic handling creates exactly the pipelines the draws ask for, in every pass (shadow maps included, whose effects the app does not see), without knowing the content in advance; the price is the omitted draws. Letting createRenderPipelineAsync() take the texture state (or derive it from a sub mesh) would be a separate change.

Files.

  • webgpuCacheRenderPipeline.ts: pending creations by cache token, the failure report and the synchronous creation after it, tryGetRenderPipeline() and lastPendingPipeline, the creations each engine's cache waits for (numAwaitedPipelines), NumPendingPipelines.
  • webgpuCacheRenderPipelineTree.ts: ResetCache() forgets the failures.
  • webgpuEngine.pure.ts: asynchronousPipelineCreation, the omitted draws, the snapshot recorded again, areAllEffectsReady(), device loss and dispose.
  • webgpuSnapshotRendering.ts: invalidateRecording() (the frame is recorded again at the next frame, the existing mechanism of a restarted render pass).
  • mesh.pure.ts: the owner of the draws _processRendering submits, given back in a finally; none while the observers of a draw are notified; the owner of another mesh for a mesh that waits with it.
  • abstractMesh.pure.ts: the two frames the mesh remembers (in its internal data, so nothing is allocated), and the mesh it waits with.
  • gaussianPointSplattingController.pure.ts: the compositors wait with their splat mesh.
  • gpuPicker.ts: each request renders the picking target once.
  • abstractEngine.pure.ts, scene.pure.ts, objectRenderer.ts, screenshotTools.pure.ts: the passes whose mesh draws may wait (_drawsMayWait), the rendering an object renderer's shouldRender() asked for, the renderings done once (_renderingOnce), and the last frame a mesh draw waited (_lastPipelineWaitFrameId), with which ObjectRenderer.shouldRender() renders a target refreshed every N frames in every frame.

Unit tests.

  • webgpuCacheRenderPipeline.test.ts, 10 more: a configuration created once while it is pending; tryGetRenderPipeline() returns null while the pipeline is created, then the cached one; it shares a creation started by preWarmPipeline(); a failed creation is reported and the next miss creates the pipeline synchronously; a disabled cache creates synchronously; a creation pending when the cache is reset is forgotten, and ignored when it completes; one that fails after the reset is neither reported nor marked as failed. Removing the deduplication fails 3 of them, removing the synchronous creation after a failure fails 1, and the reset as it was (failed creations only) fails the last 2. With two engines: one stays ready while the other creates a pipeline it does not draw; one waits for a pipeline the other is creating once it draws it too; an engine forgets its creations when its device is lost.
  • webgpuEngine.asynchronousPipelineCreation.test.ts, 15, through the engine's own draw path with the GPU replaced: a mesh omitted in all its passes and drawn in all of them in the same frame (also with a cached bundle, and two meshes that share their material's draw context); the draws that never wait (no owner, a pass rendered once); a shadow map refreshed every 4 frames while its mesh waits there or in the main pass; a frame graph object renderer rendered once and the voxelization of IBL shadows, which keep the mesh after the creations complete (the scene's frame and its renderers run as they are); a GPU picking and two multi-pickings with the picking pipeline new, through the scene's render targets, which read the mesh (the pixels hold the mesh id of the draws that reached the GPU); a decal stamped by a direct call from the camera's pass; the bounding box of an occlusion query in a FAST snapshot; recordings made again; a creation that completes after a device loss.
  • webgpuSnapshotRendering.test.ts, 1 more: invalidateRecording().
  • webgpuEngine.dispose.test.ts, 1 more: the engine's cache forgets the creations it waits for.
  • babylon.scene.drawsMayWait.test.ts, 6: the draws of the scene's cameras may wait during the frame only; those of a render target of the frame may, those of one rendered once never, also from inside a camera's pass; a render outside the frame never; a screenshot through a render target never, nor the render targets of the frame it renders; every scope is restored when a rendering throws, the frame graph's too.
  • babylon.mesh.pipelineWaitOwner.test.ts, 5: each mesh gets its own owner in its shadow map and main passes, with its own draw contexts or its material's; the bounding box of an occlusion query gets none, nor does what an observer of a draw draws; the compositors of point splatting get their splat mesh's; the owner is given back after the draws of a mesh, also when one of them throws.
  • All the core unit tests on 6b5be44 (296 files): 4,878 passed, 1 expected fail (already marked on master).

Visualization test. "WebGPU asynchronous pipeline creation - every mesh in all its passes" (#XX5SUM#1), against one reference image: in compatibility mode, in non-compatibility mode, with snapshot rendering, and with the option off.

Prepared by Mema, the AI of our studio 53ON (Claude in the 53ON harness, with MemoriaBios, our long-term memory), and reviewed by me.

🤖 Generated with Claude Code

enriqxxx and others added 4 commits October 30, 2026 06:19
engine.asynchronousPipelineCreation (off by default): a mesh draw whose render
pipeline is not created yet is omitted while the pipeline is created
asynchronously, through the existing preWarmPipeline(), instead of the frame
waiting for a synchronous creation. With the option off, nothing changes.

- Only draws of meshes can wait. The draw contexts of a mesh, in every pass that
  draws it (shadow maps and ShadowDepthWrapper included), share a small state of
  the mesh, linked once when the pass's draw wrapper is created. A mesh is shown
  in all its passes together. Post-processes and the other draws keep creating
  their pipelines synchronously.
- Snapshots never replay a frame that misses a draw forever: a recording that
  omitted draws is recorded again at the first frame after their pipelines are
  created, and an incoherent one at the next frame (invalidateRecording()).
- preWarmPipeline() creates a configuration once (pending creations kept by
  cache token, shared with createRenderPipelineAsync()), reports a failure and
  creates that configuration synchronously at its next miss, and its late
  completions are ignored once the device is replaced or the engine disposed.
- areAllEffectsReady() is false while pipelines are being created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mesh draw may wait for its pipeline only in a pass the scene renders again:
its cameras, the render targets of its frame and the frame graph
(AbstractEngine._drawsMayWait). A render target with REFRESHRATE_RENDER_ONCE,
a screenshot through a render target and any render outside the scene's frame
draw every mesh in that rendering (AbstractEngine._renderingOnce), creating
their pipelines synchronously: such a pass is not rendered again, so a waiting
draw would be missing from it for good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reset are ignored

A reset of the pipeline cache (a device change, the last WebGPU engine disposed) now forgets the pending creations too, not only
the failed ones: one that completes later, on the device it was started on, is neither kept, counted, nor reported, and
NumPendingPipelines no longer waits for creations of a device that is gone. Both tests fail with the reset as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…settings

The option and its state are declared after disableCacheRenderPipelines,
with the settings of the pipeline cache, instead of between
snapshotRenderingReset() and snapshotRendering. Only the place changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@deltakosh deltakosh self-assigned this Oct 30, 2026

@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 @enriqxxx,

One gap in the shadow pass: ShadowDepthWrapper._makeEffect() stores mainDrawWrapper directly in params.drawWrapper[passIdForDrawWrapper] (shadowDepthWrapper.ts:221-231), so the new _pipelineWait link at line 161 is only set for the wrappers of additional passes. With a single shadow generator, which is the common case, the shadow-map draw never waits with the mesh: its pipeline is created synchronously, and the shadow of a mesh whose main draw is still waiting is rendered. Could you link mainDrawWrapper too (when subMesh is known), with a test?

Thanks!

…Wrapper waits with its mesh

From the review of BabylonJS#19090: ShadowDepthWrapper._makeEffect() creates the
draw wrapper of the pass it is first called for and stores it directly,
so getEffect() never linked it to the mesh's wait state, and with a single
shadow generator the shadow-map draw did not wait with its mesh. It is
linked when it is created now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@enriqxxx

Copy link
Copy Markdown
Contributor Author

Thanks, you are right. The description says that the draw contexts of a mesh wait "in every pass that draws it (main pass, shadow maps, also through a ShadowDepthWrapper, ...)", and no test covered that last case: the wrapper of the first pass is created in _makeEffect() and was never linked, so with a single shadow generator the shadow-map draw did not wait with its mesh.

Fixed in c358594: _makeEffect() links mainDrawWrapper to the mesh's wait state when it creates it, as getEffect() does for the wrappers of the other passes. A new test in babylon.subMesh.pipelineWait.test.ts, with a material that has a ShadowDepthWrapper and one shadow generator, checks that the shadow-map draw contexts of the first pass and of another pass share the mesh's wait state; it fails without the fix.

The branch's own tests: 42 passed. All the core unit tests on 8958948 (296 files): 4,860 passed, 1 expected fail (already marked on master).

Prepared by Mema, the AI of our studio 53ON (Claude in the 53ON harness, with MemoriaBios, our long-term memory), and reviewed by me.

@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 @enriqxxx,

Thanks for the update. ShadowDepthWrapper now gives the first shadow pass's mainDrawWrapper the same _pipelineWait (the mesh's _internalAbstractMeshDataInfo) as the other passes, so a single shadow pass waits on asynchronous pipeline creation too. The new test covers the first and the additional passes. This resolves my previous request.

LGTM, thanks!

@deltakosh
deltakosh requested a review from Popov72 October 31, 2026 00:40
@Popov72

Popov72 commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

enriqxxx and others added 2 commits November 2, 2026 14:27
…ission, and every new scope is exception-safe

Mesh._processRendering gives the draws it submits their owner (AbstractEngine._pipelineWaitOwner), in all the passes of the mesh and
whatever draw context its material gives them, so a ShaderMaterial(..., false), whose draw context its meshes share, waits with
their shadow and depth passes. Any other draw has no owner and never waits: the bounding box of an occlusion query draws
synchronously, so its query never closes empty (in a FAST snapshot recording that culled a visible mesh, with no creation left to
record it again). The draw contexts no longer carry a wait state: IDrawContext, WebGPUDrawContext, SubMesh and ShadowDepthWrapper
are back to master. The render target rendered once, the frame graph, the render targets of the frame and the cameras restore
their scope in finally blocks.

Tests through the engine's own draw path (drawElementsType, beginFrame, endFrame, the end of each render pass, its snapshot
rendering and bundle list; only the GPU and the completion of the asynchronous creations are faked), with draw contexts from real
sub meshes, materials and draw wrappers: deferred completion, the holdback of a cached bundle, the draws that never wait, an
occlusion query's bounding box in a FAST recording and its replays, the snapshot's recovery. On the previous commit the
material-context and occlusion tests fail and the 6 others pass; removing any piece of the dispatch wiring makes one fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Playground #XX5SUM#1 (WebGPU only) and four entries sharing one reference image, generated with synchronous creation: asynchronous
creation in and out of the compatibility mode, with snapshot rendering (recorded while the pipelines are created), and synchronous
creation. The meshes take each path a mesh draw can take, all casting shadows: thin instances, a ShaderMaterial(..., false) shared
by two meshes, a ShadowDepthWrapper and a render target rendered once. The Playground renders until the scene has been ready for
three frames (a mesh comes back the frame after its last wait), so the frames of the test show every mesh in all its passes, and it
gives the engine its creation mode back when the scene is disposed. The four entries also pass with zero tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@enriqxxx

enriqxxx commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Fixed in 22d6e9b, with the visualization test in eebfb1e.

The root change: the owner of a draw is now supplied at submission, not stored on a draw context. Mesh._processRendering sets engine._pipelineWaitOwner to the mesh for the draws it submits, in all its passes and whatever draw context its material gives them, and restores it in a finally. Any other draw has no owner and never waits. _pipelineWait is gone from the draw contexts: IDrawContext, WebGPUDrawContext, SubMesh and ShadowDepthWrapper are back to master.

Keep occlusion-query bounding-box draws synchronous and outside mesh holdback. BoundingBoxRenderer.renderOcclusionBoundingBox draws outside Mesh._processRendering, so its bounding box has no owner, though it uses the sub mesh's wrapper. It creates its pipeline in the frame and is never held back, so the query never closes empty. Test: "draws the bounding box of an occlusion query while its mesh waits, in a FAST snapshot's recording and in its replays". While the mesh waits, the bounding box is in the recorded pass and in the FAST replays (where the scene draws nothing). Once the mesh's pipeline is created, the snapshot is recorded again with both. On c358594 it fails: the recorded pass is empty.

Handle mesh draws whose wrapper belongs to the material. With the owner supplied at submission, the draw of a ShaderMaterial(..., false) mesh waits with its shadow and depth passes, and nothing is stored on the material's shared context: two meshes that share it each wait by their own owner. Test: "holds back the draw of each mesh that draws with its material's draw context, by its own owner". On c358594 it fails: the first mesh's main draw goes through while its shadow pass waits. A NullEngine test checks that Mesh._processRendering gives each mesh its owner in its shadow-map and main passes: a StandardMaterial, two meshes sharing a ShaderMaterial(..., false), and a ShadowDepthWrapper. It also checks that the occlusion query's bounding box gets no owner.

Restore the new engine-wide render-scope state in finally blocks. Done for the render target rendered once (_renderingOnce and _drawsMayWait), the frame graph, the render targets of the frame and the cameras, and for the new owner scope in Mesh._processRendering. Tests: a caught exception in a render target rendered once, in a render target of the frame and in a camera's pass, then another render, in which the draws of the frame may wait again and _renderingOnce is back to 0. Also the frame graph's scope, and the owner restored when a draw throws.

Exercise the actual opt-in draw path and complete frame transitions. The suite now runs the engine's own drawElementsType (_draw, _omitDraw), beginFrame, endFrame and the end of each render pass, with its WebGPUSnapshotRendering and WebGPUBundleList, and asynchronousPipelineCreation on. Only the GPU and the completion of the asynchronous creations are faked: the passes and bundle encoders record the pipelines they draw with, and the pipeline cache creates one pipeline per configuration, cached once it completes. The draw contexts come from real sub meshes, a real ShaderMaterial and a real DrawWrapper. 8 tests:

  • deferred completion: the mesh is in no pass until both its pipelines exist, then in all of them in the same frame, and none of its pipelines is created in a frame;
  • the holdback of a cached bundle (non-compatibility mode);
  • the draws that never wait: without an owner, or in a pass rendered once;
  • the occlusion query's bounding box in a FAST recording and its replays (point 1), and a material's shared draw context (point 2);
  • snapshot recovery: a recording that shows a mesh in some passes only is not kept, and the snapshot is recorded again once the missing pipelines exist; a recording that held back a ready mesh is recorded again; a creation that completes after the pending creations were forgotten is ignored.

On c358594 the tests of points 1 and 2 fail and the 6 others pass. Removing a piece of the dispatch wiring makes the suite fail: a _draw that never waits fails all 8; without the holdback of cached bundles, 1 fails; without the new recording in beginFrame, 2; without the one in endFrame, 1.

Visualization: Playground #XX5SUM#1 and four config.json entries that share one reference image, generated with synchronous creation:

  • asynchronous creation in compatibility mode;
  • the same in non-compatibility mode;
  • the same with snapshot rendering, recorded while the pipelines are being created;
  • synchronous creation.

The meshes take each path a mesh draw can take, all casting shadows: thin instances, two meshes sharing a ShaderMaterial(..., false), a ShadowDepthWrapper, and a render target rendered once. To be deterministic, the Playground renders until the scene has been ready for three frames (a mesh comes back the frame after its last wait), and on dispose it gives the engine back its creation mode and turns snapshot rendering off. On this machine (real Chrome, WebGPU) the four entries pass, also with zero tolerance (SCREENSHOT_THRESHOLD=0, SCREENSHOT_MAX_PIXEL=0): the asynchronous images are pixel-identical to the synchronous one.

All the core unit tests on 22d6e9b (296 files): 4,865 passed, 1 expected fail (already marked on master). tsc and prettier are clean; eslint reports no errors.

Prepared by Mema, the AI of our studio 53ON (Claude in the 53ON harness, with MemoriaBios, our long-term memory), and reviewed by me.

@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 @enriqxxx,

Moving the wait ownership to draw submission and restoring the rendering scopes in finally blocks closes the remaining gaps, with dispatch, exception-recovery and visualization coverage. Still approved.

Thanks!

@Popov72

Popov72 commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines will not run the associated pipelines, because the pull request was updated after the run command was issued. Review the pull request again and issue a new run command.

enriqxxx and others added 2 commits November 2, 2026 17:52
…which can show a mesh in some of its passes only

The passes rendered before the first wait of a mesh in a frame have already drawn it, so that frame can show, for instance, the
shadow of a mesh whose main pass draw waits. The option's doc said it in a parenthesis; it now says it as a limitation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es renders in every frame while mesh draws wait

A target with refreshRate > 1 could keep an incomplete render. It could omit a mesh that waited and not render again until its next
refresh, while the main pass drew the mesh two frames after its last wait; or it could draw the mesh in the frame of the first wait
and keep it while the main pass held the mesh back. ObjectRenderer.shouldRender() now also renders such a target in every frame
until the meshes that waited are drawn again (two frames after the last wait), so it holds a mesh back and shows it in the same
frames as the other passes, then goes back to its rate. For that, the last frame in which a mesh draw waited moves to
AbstractEngine. With a snapshot every target already renders in every frame, and with the option off that frame never moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@enriqxxx

enriqxxx commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Two more changes, from the forum discussion of this PR (64471 #3).

The frame of the first wait, documented. deca66d makes it a limitation in the option's doc, where it was only a parenthesis. In the frame where a draw of a mesh first waits, the passes rendered before it have already drawn the mesh, so that frame can show the mesh in some of its passes only (the shadow of a mesh whose main pass draw waits, for instance).

Render targets refreshed every N frames. Fixed in a2780c0. A target with refreshRate > 1 could keep an incomplete render in two ways. It could omit a mesh that waited and not render again until its next refresh, while the main pass drew the mesh two frames after its last wait. Or it could draw the mesh in the frame of the first wait and keep it while the main pass held the mesh back. Now, while mesh draws wait and until those meshes are drawn again, ObjectRenderer.shouldRender() renders such a target in every frame. So it holds a mesh back and shows it in the same frames as the other passes, then goes back to its rate. For that, the last frame in which a mesh draw waited moves from WebGPUEngine to AbstractEngine (_lastPipelineWaitFrameId). A target rendered once still draws every mesh in its rendering, and with a snapshot every target already renders in every frame. With the option off nothing changes, because that frame only moves when a draw waits.
Tests, in the suite that runs the engine's draw path, with a shadow map refreshed every 4 frames whose ObjectRenderer decides when it renders:

  • renders a shadow map refreshed every 4 frames in every frame while its mesh waits there, so the main pass never shows the mesh without it
  • renders a shadow map refreshed every 4 frames in every frame while its mesh waits in the main pass, so it does not keep the shadow of a held back mesh

On deca66d both fail in the frame after the first wait, where the shadow map is not rendered: it keeps the render that omitted the mesh, or the shadow of the mesh the main pass holds back.

All the core unit tests on a2780c0 (296 files): 4,867 passed, 1 expected fail (already marked on master). tsc reports nothing new and nothing in src, prettier is clean, and eslint reports no errors.

Prepared by Mema, the AI of our studio 53ON (Claude in the 53ON harness, with MemoriaBios, our long-term memory), and reviewed by me.

@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 @enriqxxx,

While mesh draws wait for their pipelines, render targets refreshed every N frames now render in every frame (refreshRate > 1 only), so the mesh appears in the same frames across passes, and the first-wait limitation is documented. Covered for both pass orderings. Still approved.

Thanks!

@Popov72

Popov72 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

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

@bjsplat

bjsplat commented Nov 3, 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 Nov 3, 2026

Copy link
Copy Markdown
Collaborator

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

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

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

https://snapshots-cvgtc2eugrd3cgfd.z01.azurefd.net/refs/pull/19090/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/19090/merge
https://sandbox.babylonjs.com/?snapshot=refs/pull/19090/merge
https://gui.babylonjs.com/?snapshot=refs/pull/19090/merge
https://nme.babylonjs.com/?snapshot=refs/pull/19090/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/19090/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 Nov 3, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

🔴 Memory Leak Test Results

0 passed, 1 leaked out of 1 scenarios

Leaked Scenarios

Scenario Package Leaks
Core Playground #2FDQT5#1508 @babylonjs/core 1

@bjsplat

bjsplat commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

⚡ Performance Test Results

🟢 All performance tests passed — no regressions detected.

enriqxxx and others added 5 commits November 3, 2026 19:18
…per request, so it draws every mesh

The picking target kept the refresh rate of 1 in the scene's custom render targets, so its mesh draws could wait for their
pipelines, or be held back with another pass of their mesh. The picker counts the meshes it binds, not the draws, so on a first
pick it read a target without them, and no creation could repair the finished request: pickAsync returned null and multi-picking
missed. Each request now sets the target to REFRESHRATE_RENDER_ONCE, whose rendering draws every mesh and creates the pipelines it
lacks synchronously, and asks for another rendering only while the picking shaders are not ready. The tests pick through the
scene's render targets and its draws, with the GPU replaced: a first pick with the picking pipeline new, and two multi-picks while
the mesh waits in the camera's pass, the second one with that pipeline created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… wait only in a rendering its shouldRender() asked for

A direct call to render() inherited the scope of the pass it was called from: MeshUVSpaceRenderer.renderTexture(), called from an
observer of the camera's pass, rendered its target with mesh draws that could wait, and nothing rendered it again. shouldRender()
now marks the rendering it asks for (the scene's render targets, the frame graph's tasks) until finishRender(), and render() lets
the mesh draws wait only in that rendering: any other one renders once, as with REFRESHRATE_RENDER_ONCE. The voxelization of IBL
shadows renders its targets that way, so it no longer needs its own scope. The test stamps a decal from an observer of the
camera's pass, with the pipeline of its target new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s has no owner

Mesh._draw notified onBeforeDrawObservable (and so the onBeforeDraw callback) inside the scope of the mesh's owner, so an
EffectRenderer pass drawn from there waited with the mesh and could be omitted, losing an initialization done once. The owner is
cleared while the observers are notified, and given back for the mesh's draw. The test draws an EffectRenderer pass from such an
observer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esh wait with the mesh

The color and depth compositors of point splatting each had their own owner, so enabling point depth once the color had warmed
showed the splats without depth for the whole compilation of the depth compositor. A mesh can now wait with the draws of another
(_InternalAbstractMeshDataInfo._pipelineWaitOwner), and both compositors wait with their splat mesh. The test draws them as the
controller does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rgets costs while pipelines are created

The docs of asynchronousPipelineCreation and ObjectRenderer.shouldRender said that a render target refreshed every N frames renders
in every frame while mesh draws wait, but not its reach: every such render target of the engine renders so, including those that
do not draw the meshes that wait, and up to the frame in which the meshes are drawn again, two frames after the last wait (the
recovery frame included). Both now say it, and that these extra renders are the cost of the option while pipelines are created.
The option's list of the passes rendered once also gets those of the previous commits: a GPU picking (REFRESHRATE_RENDER_ONCE),
and an object renderer rendered by a direct call to render() that its shouldRender() did not ask for (the voxelization of IBL
shadows, a decal of MeshUVSpaceRenderer). Comments only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@enriqxxx

enriqxxx commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @deltakosh and @Popov72, everything is addressed in four commits, one per point: 169e023, 15f1d67, 8914308 and 6b5be44. A fifth one, d34a278 (comments only), documents what the refresh of render targets costs while pipelines are created, as @Popov72 asked in the forum: every render target of the engine refreshed every N frames renders in every frame, including those that do not draw the meshes that wait, up to the frame in which they are drawn again (two frames after the last wait, that recovery frame included).

1. GPU picking never waits (169e023). The picking target was in the scene's custom render targets with a refresh rate of 1, so its mesh draws could wait, or be held back with another pass of their mesh, while _checkRenderStatus() counts binds. Each request now sets the picking target to REFRESHRATE_RENDER_ONCE. Its rendering draws every mesh, creates the pipelines it lacks synchronously, and is never held back, because a rendering done once gives its draws no owner to wait with. As before, a request asks for another rendering only when _checkRenderStatus() finds no mesh bound (the picking shaders are not ready yet).

The tests pick through the scene's custom render targets and its draws, with the GPU replaced. The pixels of the picking target hold the meshID bound for the draws of its pass that reached the GPU.

  • draws the mesh of a GPU picking in the render of the first pick, with its picking pipeline new, through the scene's render targets: pickAsync() returns the box.
  • draws the meshes of a GPU multi-picking while they wait in the camera's pass, with their picking pipeline new, then created: two multiPickAsync() requests while the box waits in the camera's pass. The second one finds the picking pipeline created, so only the holdback could omit its draw.

On 54d8a8c, pickAsync() returns null and multiPickAsync() returns [null, null].

2. A direct render() renders once (15f1d67). This follows your suggestion. ObjectRenderer.shouldRender() now marks the rendering it asks for, until finishRender(), and render() lets mesh draws wait only in that rendering. The scene's render targets and the frame graph's tasks go through shouldRender(). Any other call to render() renders once, as with REFRESHRATE_RENDER_ONCE. The voxelization of IBL shadows renders its targets that way, so it no longer needs its own scope, and its test passes unchanged.

Test: draws every mesh in a render target rendered by a direct call from the camera's pass (a decal of MeshUVSpaceRenderer), which nothing renders again. It calls renderTexture() from onBeforeCameraRenderObservable, with the pipeline of its target new. On 54d8a8c the decal's draw is omitted.

3. The observers of a draw (8914308). Mesh._draw clears the owner while it notifies onBeforeDrawObservable (the onBeforeDraw callback included), then gives it back for the mesh's draw.

Test: gives no owner to what an observer of a mesh's draw draws (an EffectRenderer pass), and gives it back to the mesh's draw. On 54d8a8c the observer's pass gets the box's owner.

Minor (6b5be44). Both compositors now share the owner of their splat mesh. A mesh can wait with the draws of another mesh (_InternalAbstractMeshDataInfo._pipelineWaitOwner, read by _processRendering), and the controller sets it on both compositors.

Test: gives the compositors of a point-splatting mesh, its color and its depth, the owner of the mesh. On 8914308 each compositor has its own owner.

All the core unit tests on 6b5be44 (296 files): 4,878 passed, 1 expected fail (already marked on master); d34a278 changes comments only. tsc of core reports 0 errors in a full check, and nothing in the test files this touches; prettier is clean, and eslint reports no new warning.

Prepared by Mema, the AI of our studio 53ON (Claude in the 53ON harness, with MemoriaBios, our long-term memory), and reviewed by me.

@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 @enriqxxx,

The four points of my last review are resolved: GPU picking draws every mesh, only a rendering that shouldRender() asked for lets draws wait, observer draws have no owner, and the point-splatting compositors wait with their mesh. Approving.

Two small things before it can land:

  • packages/dev/core/test/unit/Meshes/babylon.mesh.pipelineWaitOwner.test.ts:44 uses engine.drawElementsType.bind(engine); Function.bind is prohibited under packages/dev/** (.github/instructions/prohibited-apis.instructions.md). Please capture the original method and call it from an arrow with .call(engine, ...).
  • The branch conflicts with master, so CI did not run at this head; please rebase so the checks can confirm it.

Thanks!

@deltakosh
deltakosh enabled auto-merge (squash) November 4, 2026 09:53
@matthargett

Copy link
Copy Markdown
Contributor

Nonblocking integration note: the rebase should preserve the shared texture-state helper from #19107 alongside the async pipeline path. With a native recorder provider installed (#19084), please cover a waiting draw never entering a batch and being submitted exactly once once its pipeline is ready.

enriqxxx and others added 3 commits November 6, 2026 07:46
…tion-r2

Conflict in webgpuEngine.pure.ts: master's _getTextureState and this
branch's _omitDraw and _forgetPendingPipelines were added at the same
place. All are kept, and _draw takes the wait path with the shared
helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The original method is captured and called with .call(engine, ...), as
.github/instructions/prohibited-apis.instructions.md asks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Through the real WebGPUEngine draw with a command recorder provider: no
record while the pipeline is created, nor in the frame it is held back
for the other passes of its mesh, then one draw, once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
auto-merge was automatically disabled November 6, 2026 17:44

Head branch was pushed to by a user without write access

@enriqxxx

enriqxxx commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @deltakosh, both are done.

  • Function.bind (c90a42f). The test captures the original method and calls it with .call(engine, ...).
  • Master (9d036e0). Merged rather than rebased, as RaananW asks on the other PRs, so the reviewed commits stay as they are. The one conflict was in webgpuEngine.pure.ts: master's _getTextureState and this PR's _omitDraw and _forgetPendingPipelines landed at the same place. All are kept.

@matthargett, the shared texture-state helper stays on the wait path: _draw takes it before it asks for the pipeline. bf7fec3 goes through the real draw with a recorder provider installed: a draw that waits is not batched while its pipeline is created or while it is held back for the other passes of its mesh, and is then submitted once. #19084 is not in master, so that test does not run against its atomic batches; whichever of the two lands second can extend it.

On bf7fec3, the core unit suite passes (317 files, 5,169 tests, plus the expected fail already marked on master), and core type-checks with no errors.

Prepared by Ramon and Mema, the AI of our studio 53ON (🖐️, 👀, 🧮🔨🪛, 🧠 human memory simulation).

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

Re-reviewed d34a278...bf7fec3.

  • Master merge 9d036e0: I re-ran the 3-way merge on every file both sides touched (abstractEngine.pure.ts, mesh.pure.ts, visualization config.json, webgpuEngine.pure.ts). The only conflict is in webgpuEngine.pure.ts, where _omitDraw/snapshot hold-back and master's _getTextureState (#19107) were added at the same spot. The resolution keeps both, unchanged.
  • c90a42f: the test no longer uses Function.bind. It captures the method and calls it with .call(engine, ...), per the prohibited-APIs instructions.
  • bf7fec3: a new test runs the real WebGPUEngine draw with a command recorder provider. It checks that a draw is neither recorded nor counted while its pipeline is pending or held back for one frame, and is recorded exactly once after that.

The CodeQL alerts on this head are in existing regexes in shaderProcessor.ts, webgpuShaderProcessorsWGSL.pure.ts and fileTools.pure.ts. None of those files is changed by this PR.

@Popov72

Popov72 commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

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

@bjsplat

bjsplat commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

Reviewer - this PR has made changes to one or more package.json files.

@bjsplat

bjsplat commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Nov 6, 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 Nov 6, 2026

Copy link
Copy Markdown
Collaborator

@bjsplat

bjsplat commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

⚡ Performance Test Results

🟢 All performance tests passed — no regressions detected.

@bjsplat

bjsplat commented Nov 6, 2026

Copy link
Copy Markdown
Collaborator

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

Reviewed the complete focused delta and affected rendering integration against the validated baseline. No actionable findings.

This branch has not been deployed

No deployments
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