Skip to content

Rendering: stop first-use stutters on WebGL and WebGPU - #1754

Merged
obiot merged 4 commits into
masterfrom
fix/webgl-first-use-stalls
Oct 19, 2026
Merged

obiot merged 4 commits into
masterfrom
fix/webgl-first-use-stalls

Conversation

@obiot

@obiot obiot commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

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.

prewarm stays 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 hdr the camera targets are rgba16float, the canvas bgra8unorm), 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, assigning shader, and the tone curve setToneMapping builds
  • Container.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 same keyFor() the draw uses, so a warmed key cannot drift from a drawn one (webgpu_warm_keys.spec.js compares them). It runs in a microtask, honours prewarm, and is skipped on Canvas.

pipelineCache.inFrameMisses / inFrameMissKeys count 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 raises INVALID_OPERATION, and is* 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.js holds the rule and the webglcontextlost handler turns it back on.

A first attempt replaced the probes with isContextLost(). Two context-loss specs failed with INVALID_OPERATION and were right: after a restore the context is live again while the old handles are dead, the one case the probe exists for.

A ShaderEffect on a Mesh was compiled on the frame it first drew even when preloaded: a mesh host splices the body into the mesh shader, a different program. prewarm now 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.

WebGL, same scene, profiling inside the stuttering frames:
before:  5 freezes: 50, 65, 76, 80, 86ms
after:   1 freeze:  56ms

Both backends

  • A chain gets render targets for every effect attached to it, enabled or not, so switching a disabled effect on no longer creates one mid-frame
  • The warm-up runs again after a lost context or device is restored; it never did
  • WebGPU builds the mip-chain generator in prewarm() when textureFilter resolves to "linear", instead of on the first mipmapped upload

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

  • Sprite: video play() every frame. Before any user gesture the browser refuses playback, and update() 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; a NotAllowedError waits 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)
  • WebGL: ETC1 drew black. Every compressed format was uploaded with 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 with TEXTURE_MAX_LEVEL capped when only ETC1 exists. This also fixes the ETC2-only browsers that reported ETC1 as supported
  • WebGPU: the smallest compressed mips were rejected. The copy extent was the logical mip size, but WebGPU needs whole blocks, so the 2×2 and 1×1 levels of a 4×4-block format failed validation and stayed empty

Also moves the MaterialBatcher class doc back onto the class, which a module constant had separated from it, so it reaches the published declarations again.

Verification

  • Tests: new specs prewarm-hooks, webgpu_pipeline_warm, webgpu_warm_keys, webgl_compressed, plus additions to prewarm, prewarm-effects, webgpu_prewarm, webgpu_mipmaps, webgpu_compressed and video-sprite. All of it mutation-tested; for the three bug fixes, 21 of 21 mutations caught. Full engine suite: 333 files, 8109 passed, 10 skipped
  • Real browsers: two 3D games (not in the repo) re-run after every change on Safari and Chrome, WebGPU and WebGL: 0 errors, 0 warnings, 0 pipelines compiled during play
  • Every example: all routes swept on {Safari, Chrome} × {WebGPU, WebGL} against a baseline from master: 0 new errors or warnings; the video errors (279/275/1/1) and the compressed-texture errors and warnings go to 0. Screenshot differences checked pair by pair, all animation
  • Tested by hand on every game before merge

Also opens 20.10.0, since the fixes need a section to live in. Both manifests are bumped together because release.ts asserts they match and that check runs after npm publish.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SeWCkETjW35ZbXDRswV1hx

obiot and others added 2 commits October 19, 2026 06:55
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
Copilot AI balanced review requested due to automatic review settings October 18, 2026 22:56

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 reached their quota limit.

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
Copilot AI balanced review requested due to automatic review settings October 18, 2026 22:58

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 reached their quota limit.

… 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
Copilot AI balanced review requested due to automatic review settings October 19, 2026 22:28

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 reached their quota limit.

@obiot obiot changed the title WebGL: stop paying first-use costs in the middle of the frame Rendering: stop first-use stutters on WebGL and WebGPU Oct 19, 2026
@obiot
obiot merged commit b31051f into master Oct 19, 2026
6 checks passed
@obiot
obiot deleted the fix/webgl-first-use-stalls branch October 19, 2026 22:35
@obiot obiot mentioned this pull request Oct 20, 2026
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.

2 participants