WebGPU: creating render pipelines asynchronously, so a new material does not stall the frame that first draws it

This follows 64298 #11, where this was suggested as a separate discussion.

On WebGPU, the first draw that needs a new render pipeline creates it synchronously, and that frame waits for it. In our test scene, a wave of meshes with a PBR variant no draw had used yet costs 108 to 129 ms in the frame that first draws it. #19090 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, and the mesh shows a few frames later. With the option off, nothing changes.

Following #11:

  • On the existing path. No second cache: on a miss, tryGetRenderPipeline() calls the existing preWarmPipeline(), which now creates a configuration once (a second request for the same cache token, from a draw or from createRenderPipelineAsync(), gets the pending promise), reports a failed creation, after which the next miss creates the pipeline synchronously so the WebGPU error shows as today, and ignores completions once the device is replaced or the engine disposed.
  • Incomplete frames. Only mesh draws can wait, and only in the passes the scene renders again (its cameras, the render targets of its frame, the frame graph). Post-processes, a render target rendered once, a screenshot and any render outside the scene’s frame create their pipelines synchronously, so a pass that runs once never misses a mesh. A mesh shows in all its passes together (shadow maps, depth, geometry buffer, effect layers included). A snapshot never replays a frame that misses a draw: a recording that omitted draws is replayed while their pipelines are created and recorded again at the first frame after, and a recording that would show a mesh in some passes only is recorded again at the next frame. scene.isReady() is false while pipelines are being created.
  • Compared with prewarming known content. Prewarming each wave as an app would (createRenderPipelineAsync({ effect, mesh }) once its effect is ready) also removes the long frames, but here it does not create the pipeline the wave draws with: the new meshes receive a shadow map that is a depth texture, so their draws use texture state 1, and createRenderPipelineAsync() always prewarms texture state 0. The automatic path creates exactly the pipelines the draws ask for, in every pass, without knowing the content in advance; the price is the omitted draws.
  • The name. It is asynchronous pipeline creation: the browser may still compile part of it elsewhere or at first use, and nothing here claims the whole cost left the main thread.

Measured on 9.33.0 and this PR, the machine checked quiet before and during each measure (Chromium 147, RTX 3080 Laptop GPU, 800×450): 600 meshes with a 4-cascade CSM, and every 40 frames a wave of 20 meshes with a new PBR variant, 8 waves, a new browser for every run, 2 rounds in alternating order. A frame’s time runs from its start until the GPU finished it (onSubmittedWorkDone).

Slowest frame (no snapshot / compatibilityMode = false / FAST snapshot) Frames over 100 ms, of 8 waves Pipelines created inside a frame A wave shows
9.33.0 118-121 / 108-116 / 122-129 ms 5-8 8 at once
this PR, the option on 33-34 / 31-35 / 35-45 ms 0 0 5-6 / 6-7 / 8-11 frames later
this PR, the app prewarms each wave 34 / 27-32 / 38-42 ms 0 8 4-5 / 5-6 / 11-14 frames later

Once a wave shows, every arm draws the same image as 9.33.0, in the three modes; with the option off the PR draws as 9.33.0 in every frame. With nothing pending the option costs nothing measurable (CPU per frame 7.74 ms on and 7.76 off with compatibilityMode = false, 10.24 and 9.90 without a snapshot, within the spread between rounds; with it off, the PR matches 9.33.0).

A Playground of the same waves, which counts the pipelines the device creates: https://playground.babylonjs.com/#INWH19#0. On 9.33.0 with WebGPU the slowest frame of a wave is 86 to 103 ms, and each wave creates its pipeline synchronously (9 for 8 waves); with WebGL2, which links programs in parallel before a mesh draws, it is 26 to 43 ms. On a build with the option, it runs the waves again with it on: on this PR, 21 ms, with the 8 pipelines created asynchronously and none synchronously.

One question: would a separate change letting createRenderPipelineAsync() take the texture state (or derive it from a sub mesh) be welcome? It would make prewarming match the draws that receive depth-texture shadows.

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