Repository navigation
Rendering: stop first-use stutters on WebGL and WebGPU - #1754
Merged
Merged
Conversation
Version bumped in both manifests, because `release.ts` asserts they match and that check runs after `npm publish` rather than before it, so a mismatch strands a release half done. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Two stalls found by profiling INSIDE the frames that stuttered on a 3D example, rather than around them. Together they were 86% of the time spent in those frames, and the larger one was not graphics work at all. Teardown probed every GL object with an `is*` query before deleting it. The probe is there for a real reason: deleting a handle whose context has gone raises INVALID_OPERATION, and `is*` reports false for exactly that case while being error-free to call. It is also SYNCHRONOUS, blocking until the driver answers, and teardown runs per object, so a scene dropping a batch of retained meshes paid a round trip for every buffer and vertex array in it. But the probe can only ever have something to say about a context that has been lost at least once; any other context still owns every handle made against it. It is now skipped until that happens, which for most games is never. `buffer/deletable.js` holds the rule, and the context-lost handler is what turns the probe back on. A first attempt replaced the probes with `isContextLost()`. Two context-loss specs caught it: after a RESTORE the context is live again while the old handles are dead, which is the one case the probe exists for and that check cannot see. Second, a `ShaderEffect` hosted on a `Mesh` was compiled on the frame it first drew, even when its asset had been preloaded. Preloading builds the standalone program, but a mesh host splices the body into the mesh shader, which is a different program again and was not in the warmed set. So a game that declared every effect in its manifest still stuttered the first time each one appeared, which is the opposite of what declaring them looks like it buys. `prewarm` now builds the spliced variant for every preloaded effect, in both fog states, alongside the variants it already warmed. Measured on the same scene, profiling inside the stuttering frames: five freeze windows became one, and neither `isBuffer` nor `getShaderParameter` appears in the remaining profile at all. The one that is left is a different call again, in render target creation, and is untouched here. Both are pinned by `tests/prewarm-effects.spec.js` and verified by mutation: removing the warming loop, making the probe unconditional, and dropping the probe entirely each fail a test. Full suite green at 8032. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The spec was written and formatted after the last build, so the lint gate never saw it and four `arrow-body-style` errors reached CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
… fix three upload bugs The first-use freezes on WebGPU (iPhone Air, iOS 27) came from pipelines compiled in the frame that first drew them. A pipeline is keyed by blend, culling, transparency, fog and the target format, so it can only be warmed once the renderable drawing it is known, which is why preloading the shader never helped. The renderer now queues that work when the object is known: - `addPostEffect`, assigning `shader`, and `setToneMapping` warm the effect's pipelines through `createRenderPipelineAsync`, keyed by the same function the draw uses - `Container.addChild` warms a renderable's pipeline and, on both GPU backends, an advanced blend mode's shader and offscreen target - a chain gets render targets for every attached effect, enabled or not - WebGL links mesh-hosted effects on attach, time-sliced over frames - the warm-up runs again after a lost context or device is restored - WebGPU builds the mip-chain generator in `prewarm()` when the default texture filter is linear `pipelineCache.inFrameMisses` counts pipelines still created mid-frame; Earth Defender during play goes from 3 to 0 on both browsers. `prewarm` stays on by default and the public API is unchanged. Three older bugs surfaced by sweeping every example on both browsers and backends, each pinned by a test that fails on the old code: - Sprite: a video refused by the autoplay policy called `play()` every frame and logged an uncaught rejection each time (up to 279 in 5s on Chrome). One request is now in flight at a time; a refusal waits for a click, touch or key press and plays from inside that handler. Pausing the game cancels the wait, so a click during a pause starts nothing, as before - WebGL: ETC1 textures drew black because `compressedTexSubImage2D` rejects ETC1. They upload as ETC2 RGB8, which decodes the same data, or level by level into mutable storage when only ETC1 is supported - WebGPU: the smallest compressed mip levels (2x2, 1x1 of a 4x4-block format) were rejected because the copy was not whole blocks Also moves the MaterialBatcher class doc back onto the class, which a module constant had separated from it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Removes the first-use stutters on both GPU backends: work that a draw needs the first time it happens is now done when the object is known, not in the frame that draws it. It started from freezes on an iPhone Air (iOS 27, WebGPU through
AUTO) the first time a shield, a projectile glow or a hit flash appeared. While verifying it across every example, it also fixes three older bugs.prewarmstays on by default and the public API is unchanged.WebGPU: pipelines compiled where the draw would compile them
A WebGPU pipeline compile measured 49-140ms on first draw (desktop, cold), preloaded or not. Preloading the shader cannot help: a pipeline is keyed by blend, culling, transparency, fog and the target format and sample count (under
hdrthe camera targets arergba16float, the canvasbgra8unorm), so it can only be built once the renderable drawing it is known.The renderer now queues that warm-up the moment the object is known:
addPostEffect, assigningshader, and the tone curvesetToneMappingbuildsContainer.addChild, for the renderable's own pipeline, and on both GPU backends an advanced blend mode's shader and offscreen target (a first draw measured at up to 136ms)Warming goes through
pipelineCache.warm()=createRenderPipelineAsync, at most 16 in flight, with keys from the samekeyFor()the draw uses, so a warmed key cannot drift from a drawn one (webgpu_warm_keys.spec.jscompares them). It runs in a microtask, honoursprewarm, and is skipped on Canvas.pipelineCache.inFrameMisses/inFrameMissKeyscount the pipelines still created mid-frame; that is the instrument. A 3D game during play: 3 → 0 on both browsers.The one case it cannot cover is attaching and drawing in the same frame. Attaching effects when a pool or a stage is built, rather than when the object is shown, gives the compile time to finish; the effects and performance skills now say so.
WebGL: the teardown probes and mesh-hosted effects
Releasing a GL object asked an
is*query first. The probe exists for a real reason (deleting a handle whose context has gone raisesINVALID_OPERATION, andis*reports false for exactly that case) but it is synchronous, and teardown runs per object, so a scene dropping a batch of retained meshes paid a round trip per buffer and vertex array: 70.4% of the time inside the stuttering frames. The probe can only say something about a context that has been lost at least once, so it is now skipped until that happens.buffer/deletable.jsholds the rule and thewebglcontextlosthandler turns it back on.A
ShaderEffecton aMeshwas compiled on the frame it first drew even when preloaded: a mesh host splices the body into the mesh shader, a different program.prewarmnow builds the spliced variant for every preloaded effect in both fog states, and an effect attached inline is linked on attach, time-sliced (4ms) over the following frames so a pool of effect-carrying meshes does not pay for all of them at once.Both backends
prewarm()whentextureFilterresolves to"linear", instead of on the first mipmapped uploadThree older bugs the example sweep found
Each was present before this PR, and each has a test that fails on the old code and passes now.
play()every frame. Before any user gesture the browser refuses playback, andupdate()asked again each frame without catching the rejection: up to 279 uncaught errors in 5s on Chrome. One request is now in flight at a time; aNotAllowedErrorwaits for a click, touch or key press (the events the audio unlock uses) and plays from inside that handler. Pausing the game cancels the wait, so a click during a pause starts nothing, as before (a mutant that skipped this is what found the case)compressedTexSubImage2D, which ETC1 refuses, leaving the texture empty. ETC1 now uploads as ETC2 RGB8 (0x9274), which decodes the same bytes, or level by level into mutable storage withTEXTURE_MAX_LEVELcapped when only ETC1 exists. This also fixes the ETC2-only browsers that reported ETC1 as supportedAlso moves the
MaterialBatcherclass doc back onto the class, which a module constant had separated from it, so it reaches the published declarations again.Verification
prewarm-hooks,webgpu_pipeline_warm,webgpu_warm_keys,webgl_compressed, plus additions toprewarm,prewarm-effects,webgpu_prewarm,webgpu_mipmaps,webgpu_compressedandvideo-sprite. All of it mutation-tested; for the three bug fixes, 21 of 21 mutations caught. Full engine suite: 333 files, 8109 passed, 10 skippedAlso opens 20.10.0, since the fixes need a section to live in. Both manifests are bumped together because
release.tsasserts they match and that check runs afternpm publish.🤖 Generated with Claude Code
https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx