You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On the first load of a 3D scene the background renders immediately and the geometry appears up to a second later, often in waves rather than all at once. It scales with the number of distinct program / pipeline variants a scene uses, not with its triangle count, so the larger the scene the longer the wait.
The cost is shader and pipeline construction, done lazily on first use, on the render thread.
To be precise about which programs are lazy: on WebGL the base programs are already built during await app.init() — batchers are constructed in WebGLRenderer.init and GLShader's constructor compiles and links — and both lit batchers deliberately call _bindLightBlock() from their own init() (lit_mesh_batcher.js:80, lit_quad_batcher.js:201) so the default program's block index is resolved at birth. What is lazy is the shaderVariant() permutations: mesh|fog, instanced|0..N, shadow, shadow|fog. That matches the profile below, where every slow query is against a newly linked program.
Where it lands
WebGPU — video/webgpu/pipeline/cache.js:506 calls device.createRenderPipeline, the synchronous form, on a cache miss, mid-frame. The cache key (cache.js:469) is ```${shaderKey}|${topology}|${blend}|${pma}|${stencilMode}|${format}|${sampleCount}`` plus |mesh:cull:front, `|dw0` and `|fog`. `shaderKey` is the dominant multiplier — the shader family, `mesh` vs the lit tier vs `instanced|N` vs each registered custom module — and it is also the only axis derivable from a renderable, which matters below. A scene mints one pipeline per combination it encounters, each paying its compile the first frame it appears. `video/webgpu/texture/store.js:325` does the same for the mipmap pipeline. Nothing in the engine uses `createRenderPipelineAsync`.
WebGL — compilation and linking are already asynchronous inside the driver; both compileShader and linkProgram return in ~0 ms. The stall lands wherever the engine first queries the result, which forces the driver to finish:
video/webgl/utils/program.js:39 — getProgramParameter(program, LINK_STATUS); on most drivers linking is the expensive half
video/webgl/buffer/uniformblock.js:89 — getUniformBlockIndex(program, …), reached through bindTo from lit_mesh_batcher.js:101 and lit_quad_batcher.js:269
Measured
CPU profile of the forest example, headless software rasterizer:
share
self time
bindTo (→ getUniformBlockIndex), mesh tier
41.9%
2091 ms
compileShader (→ COMPILE_STATUS)
9.2%
457 ms
Of 17 getUniformBlockIndex calls only three are slow — 2053 ms, 1845 ms, 1772 ms — and each is the first query against a newly linked program. _bindLightBlock already guards against re-binding the same program, so the call count is not the problem; the wait is. The blocking-task timeline shows the same shape: a 436 ms block, then a ~2000 ms block, with geometry arriving after each.
Two caveats. The numbers come from a software rasterizer, so the absolute values are far larger than on a GPU — the structure is what matters. And the WebGPU half is from reading the code: the local harness has no navigator.gpu, so that path is not measured.
Why switching to the async call is not, by itself, the fix
createRenderPipelineAsync moves the compile off the calling frame, but with nowhere to wait for it the result is a frame that renders without the geometry instead of a frame that stalls. The cost has to move earlier, to a point where the game is already waiting.
Proposal
A public warm-up, e.g. renderer.warmUp(container) (name open): walk the renderables a container will draw, resolve the pipeline / program key each one would need, and build them ahead of the first frame.
Called automatically from level.load(), so the common path gets it for free. level.load() is the right hook because it is not TMX-only — safeLoadLevel branches on targetLevel.format, and anything that is not "tmx" goes through the generic addTo(container, options), which is how glTF/GLB scenes load (me.level.load("diorama", { scale: 50 }) is in its own JSDoc). So one hook covers tilemaps and 3D scenes alike.
The window is already the right one. level.load() calls state.stop() when the loop was running and defers the real work, so the loop is stopped across the whole load. The insertion point is in safeLoadLevel (level/level.js), after the container is populated and before the loop resumes:
// TMX loader, or targetLevel.addTo(...) — container is now populated<--warmuphere: theworldcontentsareknownandtheloopisstoppedemit(LEVEL_LOADED,levelId);options.onLoaded(levelId);if(restart)state.restart();
A synchronous warm-up is enough, and needs no API change. The loop is stopped at that point, so building the pipelines costs the same total time but spends it while the game is already waiting instead of on the first rendered frame — which is the entire goal. The asynchronous pipeline-creation path buys one extra thing on top: it keeps the main thread responsive during the warm-up, which on a loading screen is the least valuable place to have it. #1646 would provide a completion point to await at if we want that later; it is a possible refinement, not a prerequisite.
What is still undecided
Two of these block implementation.
1. The pipeline key cannot be derived from a renderable alone. Half of it can: batcher choice (mesh.lit), instanced family (mesh.instanceLayout → instancedFamilyFor(layout), which registers module and vertex layout and is callable outside a pass), cullMode / frontFace (mesh.cullBackFaces, mesh.rightHanded), custom shader family, topology. The other half needs draw-time context a container walk does not have:
renderer._fog3d is set only by camera.draw(). At warm-up time no camera is drawing, and options.container may not even be the container a current camera draws — a glTF scene can be loaded into a detached container for an inactive stage.
Transparent routing keys off packedTint >>> 24 !== 0xff, where the alpha is the accumulated product of every ancestor's opacity. Replicating that per renderable means replicating the whole ancestor accumulation.
retained (whether a Camera3d draws it on a backend with supportsRetainedMesh) flips frontFace.
renderer.stencilMode is mask state at draw time.
So the ticket has to pick one of:
(a) dry-run the draw path with recording suppressed, reusing every derivation for free and staying correct as axes are added. Needs new "derive but do not record" plumbing in both backends.
(b) explicit key-derivation methods per batcher taking (renderable, context). Cheaper, but creates a second implementation of the key that must be kept in step with the first.
2. Minting the program is not the expensive part. The profile says bindTo is 41.9% and compilation 9.2%. bindTo is reached only from _bindLightBlock(), which runs on a program switch during a draw. A warm-up that only calls shaderVariant() / pipelineCache.get() moves the 9% and leaves the 42% exactly where it was. The warm-up must also perform the binding query that forces the driver to resolve each newly minted program. This is the difference between fixing the reported stall and not.
3. Context loss would undo it, mid-game. Neither backend's cache survives a restore: WebGL rebuilds shaderVariants from scratch in init(), and WebGPU replaces pipelineCache outright on device.lost. After a restore the exact stall returns, now during play rather than on a loading screen — worse than the case being fixed. Does warmUp re-run on ONCONTEXT_RESTORED, and who holds the container reference?
4. The API is unspecified beyond its name. Return type (void, a count, a promise — the "synchronous is enough" argument implies void, which is a signature decision, not a detail); behaviour on Canvas or a renderer with no 3D content (Renderer.drawMesh is an empty base at video/renderer.js:806, so a no-op override is the obvious precedent); recursion, idempotency, detached containers, null. And scope: is it pipelines only, or everything a first draw allocates? The mipmap pipeline cited above is minted during a texture upload, which a pipeline-key walk never reaches, and retained geometry uploads on first draw. Relatedly, a tilemap's first-frame cost is texture upload and atlas setup rather than mesh pipelines — so it is not yet clear the level.load() hook helps the TMX case used to justify it.
5. KHR_parallel_shader_compile is not used anywhere. It is the WebGL counterpart to asynchronous pipeline creation and the only way to make the WebGL half non-blocking. Worth an explicit decision rather than silence.
6. Existing tests constrain this. No test calls level.load() at all, so the hook has no coverage to extend and needs a new spec. And webgl_mesh_fog.spec.js:249 asserts shaderVariants.has("mesh|fog") === false — an eager warm-up contradicts it directly. webgpu_pipeline.spec.js and webgpu_post_effect.spec.js assert exact pipeline counts and family keys, and family keys are registration-order dependent (key = \effect:${registeredModules.size}``), so warming ahead of first draw renumbers them.
Scope notes
The warm-up can only resolve keys for what is in the container when it runs. An object spawned later that introduces a new blend mode still compiles on first use. The lazy path stays as the fallback; this removes the bulk case.
Scenes not built through level.load() — a world assembled procedurally, or meshes added by hand — need the explicit call, which is why the public method is worth having on its own.
The engine's preloader registers levels from its parsers (loader/parsers/tmx.js:33 and loader/parsers/gltf.js:1123 both call level.add()) but never instantiates one — nothing in loader.js or loadingscreen.js calls level.load(). Assets first, level after.
Outside the level module there are exactly two callers of level.load(), both in renderable/trigger.js (lines 202 and 228). Inside it, reload(), next() and previous() all delegate to load(). The reveal effect in the trigger is chained off onLoaded, which runs beforestate.restart() — so a warm-up inserted ahead of onLoaded makes the fade-in start once the pipelines are warm, with no extra sequencing.
Distance fog added |fog as a pipeline / define axis this release, and the transparent pass (Mesh/Sprite3d: blended (alpha) pass for soft-transparent 3D sprites #1516) added another per mesh — a blended draw takes depthWrite: false plus the mesh's own blendMode as the blend axis. Both multiply the variant count. Note fog is renderer-global per camera pass, not per mesh (mesh.fog === false opts out at the uniform level only and does not change the key), so the doubling materialises when fogged and unfogged passes coexist — a Camera2d pass, a minimap, fog toggled at runtime — rather than within one fogged pass. The lazy-compile behaviour predates both.
Worth measuring on a real GPU before sizing the work — the software-rasterizer numbers above establish the shape, not the budget.
On the first load of a 3D scene the background renders immediately and the geometry appears up to a second later, often in waves rather than all at once. It scales with the number of distinct program / pipeline variants a scene uses, not with its triangle count, so the larger the scene the longer the wait.
The cost is shader and pipeline construction, done lazily on first use, on the render thread.
To be precise about which programs are lazy: on WebGL the base programs are already built during
await app.init()— batchers are constructed inWebGLRenderer.initandGLShader's constructor compiles and links — and both lit batchers deliberately call_bindLightBlock()from their owninit()(lit_mesh_batcher.js:80,lit_quad_batcher.js:201) so the default program's block index is resolved at birth. What is lazy is theshaderVariant()permutations:mesh|fog,instanced|0..N,shadow,shadow|fog. That matches the profile below, where every slow query is against a newly linked program.Where it lands
WebGPU —
video/webgpu/pipeline/cache.js:506callsdevice.createRenderPipeline, the synchronous form, on a cache miss, mid-frame. The cache key (cache.js:469) is ```${shaderKey}|${topology}|${blend}|${pma}|${stencilMode}|${format}|${sampleCount}`` plus|mesh:cull:front, `|dw0` and `|fog`. `shaderKey` is the dominant multiplier — the shader family, `mesh` vs the lit tier vs `instanced|N` vs each registered custom module — and it is also the only axis derivable from a renderable, which matters below. A scene mints one pipeline per combination it encounters, each paying its compile the first frame it appears. `video/webgpu/texture/store.js:325` does the same for the mipmap pipeline. Nothing in the engine uses `createRenderPipelineAsync`.WebGL — compilation and linking are already asynchronous inside the driver; both
compileShaderandlinkProgramreturn in ~0 ms. The stall lands wherever the engine first queries the result, which forces the driver to finish:video/webgl/utils/program.js:10—getShaderParameter(shader, COMPILE_STATUS)video/webgl/utils/program.js:39—getProgramParameter(program, LINK_STATUS); on most drivers linking is the expensive halfvideo/webgl/buffer/uniformblock.js:89—getUniformBlockIndex(program, …), reached throughbindTofromlit_mesh_batcher.js:101andlit_quad_batcher.js:269Measured
CPU profile of the forest example, headless software rasterizer:
bindTo(→getUniformBlockIndex), mesh tiercompileShader(→COMPILE_STATUS)Of 17
getUniformBlockIndexcalls only three are slow — 2053 ms, 1845 ms, 1772 ms — and each is the first query against a newly linked program._bindLightBlockalready guards against re-binding the same program, so the call count is not the problem; the wait is. The blocking-task timeline shows the same shape: a 436 ms block, then a ~2000 ms block, with geometry arriving after each.Two caveats. The numbers come from a software rasterizer, so the absolute values are far larger than on a GPU — the structure is what matters. And the WebGPU half is from reading the code: the local harness has no
navigator.gpu, so that path is not measured.Why switching to the async call is not, by itself, the fix
createRenderPipelineAsyncmoves the compile off the calling frame, but with nowhere to wait for it the result is a frame that renders without the geometry instead of a frame that stalls. The cost has to move earlier, to a point where the game is already waiting.Proposal
A public warm-up, e.g.
renderer.warmUp(container)(name open): walk the renderables a container will draw, resolve the pipeline / program key each one would need, and build them ahead of the first frame.Called automatically from
level.load(), so the common path gets it for free.level.load()is the right hook because it is not TMX-only —safeLoadLevelbranches ontargetLevel.format, and anything that is not"tmx"goes through the genericaddTo(container, options), which is how glTF/GLB scenes load (me.level.load("diorama", { scale: 50 })is in its own JSDoc). So one hook covers tilemaps and 3D scenes alike.The window is already the right one.
level.load()callsstate.stop()when the loop was running and defers the real work, so the loop is stopped across the whole load. The insertion point is insafeLoadLevel(level/level.js), after the container is populated and before the loop resumes:A synchronous warm-up is enough, and needs no API change. The loop is stopped at that point, so building the pipelines costs the same total time but spends it while the game is already waiting instead of on the first rendered frame — which is the entire goal. The asynchronous pipeline-creation path buys one extra thing on top: it keeps the main thread responsive during the warm-up, which on a loading screen is the least valuable place to have it. #1646 would provide a completion point to await at if we want that later; it is a possible refinement, not a prerequisite.
What is still undecided
Two of these block implementation.
1. The pipeline key cannot be derived from a renderable alone. Half of it can: batcher choice (
mesh.lit), instanced family (mesh.instanceLayout→instancedFamilyFor(layout), which registers module and vertex layout and is callable outside a pass),cullMode/frontFace(mesh.cullBackFaces,mesh.rightHanded), custom shader family, topology. The other half needs draw-time context a container walk does not have:renderer._fog3dis set only bycamera.draw(). At warm-up time no camera is drawing, andoptions.containermay not even be the container a current camera draws — a glTF scene can be loaded into a detached container for an inactive stage.packedTint >>> 24 !== 0xff, where the alpha is the accumulated product of every ancestor's opacity. Replicating that per renderable means replicating the whole ancestor accumulation.retained(whether aCamera3ddraws it on a backend withsupportsRetainedMesh) flipsfrontFace.renderer.stencilModeis mask state at draw time.So the ticket has to pick one of:
(renderable, context). Cheaper, but creates a second implementation of the key that must be kept in step with the first.2. Minting the program is not the expensive part. The profile says
bindTois 41.9% and compilation 9.2%.bindTois reached only from_bindLightBlock(), which runs on a program switch during a draw. A warm-up that only callsshaderVariant()/pipelineCache.get()moves the 9% and leaves the 42% exactly where it was. The warm-up must also perform the binding query that forces the driver to resolve each newly minted program. This is the difference between fixing the reported stall and not.3. Context loss would undo it, mid-game. Neither backend's cache survives a restore: WebGL rebuilds
shaderVariantsfrom scratch ininit(), and WebGPU replacespipelineCacheoutright ondevice.lost. After a restore the exact stall returns, now during play rather than on a loading screen — worse than the case being fixed. DoeswarmUpre-run onONCONTEXT_RESTORED, and who holds the container reference?4. The API is unspecified beyond its name. Return type (
void, a count, a promise — the "synchronous is enough" argument impliesvoid, which is a signature decision, not a detail); behaviour on Canvas or a renderer with no 3D content (Renderer.drawMeshis an empty base atvideo/renderer.js:806, so a no-op override is the obvious precedent); recursion, idempotency, detached containers,null. And scope: is it pipelines only, or everything a first draw allocates? The mipmap pipeline cited above is minted during a texture upload, which a pipeline-key walk never reaches, and retained geometry uploads on first draw. Relatedly, a tilemap's first-frame cost is texture upload and atlas setup rather than mesh pipelines — so it is not yet clear thelevel.load()hook helps the TMX case used to justify it.5.
KHR_parallel_shader_compileis not used anywhere. It is the WebGL counterpart to asynchronous pipeline creation and the only way to make the WebGL half non-blocking. Worth an explicit decision rather than silence.6. Existing tests constrain this. No test calls
level.load()at all, so the hook has no coverage to extend and needs a new spec. Andwebgl_mesh_fog.spec.js:249assertsshaderVariants.has("mesh|fog") === false— an eager warm-up contradicts it directly.webgpu_pipeline.spec.jsandwebgpu_post_effect.spec.jsassert exact pipeline counts and family keys, and family keys are registration-order dependent (key = \effect:${registeredModules.size}``), so warming ahead of first draw renumbers them.Scope notes
level.load()— a world assembled procedurally, or meshes added by hand — need the explicit call, which is why the public method is worth having on its own.loader/parsers/tmx.js:33andloader/parsers/gltf.js:1123both calllevel.add()) but never instantiates one — nothing inloader.jsorloadingscreen.jscallslevel.load(). Assets first, level after.level.load(), both inrenderable/trigger.js(lines 202 and 228). Inside it,reload(),next()andprevious()all delegate toload(). The reveal effect in the trigger is chained offonLoaded, which runs beforestate.restart()— so a warm-up inserted ahead ofonLoadedmakes the fade-in start once the pipelines are warm, with no extra sequencing.|fogas a pipeline / define axis this release, and the transparent pass (Mesh/Sprite3d: blended (alpha) pass for soft-transparent 3D sprites #1516) added another per mesh — a blended draw takesdepthWrite: falseplus the mesh's ownblendModeas the blend axis. Both multiply the variant count. Note fog is renderer-global per camera pass, not per mesh (mesh.fog === falseopts out at the uniform level only and does not change the key), so the doubling materialises when fogged and unfogged passes coexist — a Camera2d pass, a minimap, fog toggled at runtime — rather than within one fogged pass. The lazy-compile behaviour predates both.