perf(textures): bound main-thread image decode concurrency#131
Merged
Conversation
On devices without image workers (numImageWorkers === 0, e.g. LG WebOS 3.x where createImageBitmap can't be polyfilled inside a Worker), every image fetch+decode runs on the main thread. Since 1.6.0, getTextureData runs eagerly and independently per node inside loadTexture, with no concurrency ceiling — so a scroll that makes many image nodes renderable in one tick fires dozens of decodes back-to-back and starves the render loop. The existing upload queue only throttles the GPU upload step, which is downstream of the expensive decode. Add a ConcurrencyGate (small FIFO semaphore) that caps how many fetch+decode operations run at once, applied only when there is no image worker manager (with workers, the pool already bounds concurrency). Priority/on-screen textures bypass the gate so they never wait behind off-screen prefetch. The gate wraps only the decode; the per-frame upload budget is untouched. Exposed as the `imageDecodeConcurrency` renderer setting (default 4; 0 disables). Inert on worker-enabled devices. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Note that the concurrency gate bounds the createImageBitmap decode inside getTextureData; on the <img> fallback the real main-thread cost is the synchronous texImage2D at upload, which the per-frame upload budget paces instead. Prevents a future reader from expecting the gate to help that path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
What
Adds a
ConcurrencyGate(small FIFO semaphore) that caps how many image fetch+decode operations run at once, plus a newimageDecodeConcurrencyrenderer setting (default4,0disables). The gate is applied only when there is no image worker manager — i.e.numImageWorkers === 0, or workers/createImageBitmapare unsupported.This is the first main-thread decode-concurrency bound this renderer has ever had. It is not a restoration of prior behavior — see history below.
Why
On devices without image workers (e.g. LG WebOS 3.x, where
createImageBitmapcan't be polyfilled inside a Worker sonumImageWorkersmust stay0), every image fetch+decode runs on the main thread.loadTextureawaitsgetTextureData()(fetch +createImageBitmapdecode) eagerly and independently per node (eachCoreNodefires it viaqueueMicrotask), with no concurrency ceiling. A scroll that makes many image nodes renderable in a single tick therefore kicks off dozens of main-thread decodes back-to-back, starving the render loop and causing frame jank.The existing
uploadTextureQueuedoes not help here — it throttles the GPU upload step (processUntiltime budget), which sits downstream of the expensive decode. The decode has already happened by the time a texture reaches that queue.History (why "first ever", not "restored")
Eager per-node decode in
loadTexturehas been present since the initial release (1.0.x), so decode concurrency on the non-worker path has been unbounded from day one. AprefetchLimit = Math.max(1, numImageWorkers)window existed inprocessSomefrom 1.1.1 (#12) to 1.5.3, but it governed upload-queue overlap, not the decode fan-out — by the time the queue drained,loadTexturehad already populatedtextureData, so that window'sgetTextureData()was a no-op. It was removed in 1.5.4 (#104), which did not change decode concurrency. No released version has ever bounded main-thread decode concurrency; this PR introduces it.How
ConcurrencyGate:acquire()/release()semaphore. A released slot is handed straight to the next FIFO waiter, so the active count never dips belowlimitwhile work is pending.CoreTextureManager.initialize()only whenimageWorkerManager === null && imageDecodeConcurrency > 0. With workers, the pool already bounds concurrency, so the gate staysnull(fully inert).loadTexture()acquire()s beforegetTextureData()andrelease()s in afinally. Priority (on-screen) textures bypass the gate so they never wait behind off-screen prefetch decodes. The gate wraps only the decode; the per-frame upload budget is untouched.imageDecodeConcurrencythroughRendererMainSettings→Stage→CoreTextureManager(TextureManagerSettings).Usage (WebOS 3.x)
Set to
0to opt out (unbounded — current behavior). Tune 3–6 against on-device scroll jank.Reviewer notes
numImageWorkersdefaults to2, sodecodeGateisnulland the code path is a no-op.createImageBitmapdecode (insidegetTextureData). On the<img>fallback path (hasCreateImageBitmap === false), the image decodes lazily and the real main-thread cost is the synchronoustexImage2Dat upload, which the gate does not cover — the per-frame upload budget (processUntil) paces that instead. So the gate is most effective on the createImageBitmap(-polyfill) path, which is the WebOS 3.x case.preloadBoundtuning to cap how many decodes get triggered in the first place.Tests
src/core/CoreTextureManager.test.ts— 6 tests forConcurrencyGate: immediate resolution up to limit, blocking past limit, FIFO slot hand-off, release-with-no-waiters, no ceiling below limit, and a burst test asserting peak concurrency never exceeds the limit.tsc --buildclean; lint clean (0 errors).🤖 Generated with Claude Code