Skip to content

WebGL: stop paying first-use costs in the middle of the frame - #1754

Open
obiot wants to merge 3 commits into
masterfrom
fix/webgl-first-use-stalls
Open

obiot wants to merge 3 commits into
masterfrom
fix/webgl-first-use-stalls

Conversation

@obiot

@obiot obiot commented Oct 18, 2026

Copy link
Copy Markdown
Member

Found by profiling inside the frames that stuttered on a 3D example
rather than around them. Together these were 86% of the time spent in those
frames, and the larger one turned out not to be graphics work at all.

The teardown probes

Releasing a GL object asked an is* query first. 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. A scene dropping a batch of retained meshes paid a round
trip for every buffer and vertex array in it. In the profile it was 70.4%
of the time inside the stuttering frames, ahead of shader compilation.

The probe can only ever say something about a context that has been lost at
least once. Any other context still owns every handle made against it. So it
is skipped until that happens, which for most games is never.
buffer/deletable.js holds the rule and the webglcontextlost handler turns
the probe 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, which
is the one case the probe exists for and that check cannot see.

The mesh-hosted effect

A ShaderEffect 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

Same scene, same harness, profiling inside the stuttering frames:

before:  5 freezes — 50, 65, 76, 80, 86ms
after:   1 freeze  — 56ms

Neither isBuffer nor getShaderParameter appears in the remaining profile
at all. What is left is checkFramebufferStatus during render target
creation, a different call in a different place, and is untouched here.

Verification

tests/prewarm-effects.spec.js pins both, and each was mutation-tested —
removing the warming loop, making the probe unconditional, and dropping the
probe entirely each fail a test, and the suite returns to green when
reverted. Full engine suite green: 329 files, 8032 passed.

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_01NGvtaUNATVCVxD2qcbiY4t

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.

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.

2 participants