perf(textures): overlap decode with upload, skip resolveDefaults on cache hit#12
Merged
Merged
Conversation
…n cache hit
Two changes to the TextureManager hot paths:
1. processSome now keeps a small sliding window (size =
numImageWorkers, default 2) of in-flight getTextureData() promises
so decode/network work overlaps with the serial GPU upload. The
loop tops up the window before awaiting each upload, so the next
decode is already underway across the worker pool while the
current texture uploads. If the time budget runs out before the
window drains, unfinished textures go back on the queue; their
getTextureData() result memoizes onto texture.textureData so the
next tick skips straight to upload.
2. createTexture now looks up the cache *before* calling
resolveDefaults, skipping the per-call allocation on cache hits.
To make this safe, makeCacheKey now produces the same key whether
the caller passes raw or resolved props:
- ImageTexture: maxRetryCount default (`?? 5`) is now inlined
alongside the existing premultiplyAlpha default; sh/sw checks
tightened from `!== null` to `!= null` so undefined raw inputs
match resolved nulls.
- ColorTexture: color default (`|| 0xffffffff`) inlined so
`{ color: 0 }` resolves to the same key as the post-defaults
form.
NoiseTexture already calls resolveDefaults inside its own
makeCacheKey and is unaffected. SubTexture / RenderTexture return
false from makeCacheKey and are unaffected.
The only external caller of makeCacheKey is resolveParentTexture,
which passes already-resolved props from an ImageTexture instance
— the new key formula produces the same value, so it's
backwards-compatible.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The two sequential `state === 'failed' || state === 'freed'` checks in the upload loop caused tsc --build to flag the second one as no-overlap because TypeScript narrows `next.texture.state` after the first check and doesn't re-widen across the `await`. The state is mutable, so the narrow is unsound — extract an `isDead(texture)` helper to defeat the narrowing. Functional behavior is unchanged. The previous form passed `tsc --noEmit -p tsconfig.json` but failed the stricter `tsc --build` path that visual-regression runs. Co-Authored-By: Claude Opus 4.7 <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.
Summary
Two focused performance changes in
CoreTextureManager. Both are isolated from the recent correctness fixes that landed in #11 (now on main).1.
processSome— overlap decode with GPU uploadToday the upload loop is
await getTextureData(); await uploadTexture()serial.getTextureData()is IO/decode (and forImageTextureit dispatches to a worker pool), whileuploadTexture()is effectively serial because the GL calls block from JS's perspective. Serializing them means the worker pool is mostly idle while we're uploading.After this change,
processSomekeeps a small sliding window of in-flightgetTextureData()promises:max(1, numImageWorkers)— defaults to 2.failed/freedboth before awaiting (cheap skip) and after (handles freed-during-decode)..catchinside the window, so a pre-fired rejection can't surface as an unhandled-rejection while it sits queued.getTextureData()will still complete in the background and memoize ontotexture.textureData, so the next tick skips straight to upload.Upload itself stays serial — the goal is to keep the worker pool busy, not to parallelize GL.
2.
createTexture— look up cache before resolving defaultsBefore: every
createTexturecall (one per texture-bearing node) allocated a fully-resolved props object viaresolveDefaults()even on a cache hit.After:
makeCacheKeyruns against raw props first;resolveDefaultsonly runs on miss. To make this safe, eachmakeCacheKeyhad to produce the same key for raw and resolved inputs:ImageTexture.makeCacheKey—maxRetryCountdefault (?? 5) is now inlined alongside the existingpremultiplyAlphadefault.sh/swchecks tightened from!== nullto!= nullsoundefined(raw) matchesnull(resolved).ColorTexture.makeCacheKey—colordefault (|| 0xffffffff) inlined so{ color: 0 }resolves to the same key as the post-defaults form. This was technically a latent inconsistency before — raw input would have producedColorTexture,0while resolved input producesColorTexture,4294967295.NoiseTexture.makeCacheKeyalready runsresolveDefaultsinternally, so it's unaffected.SubTextureandRenderTexturereturnfalsefrommakeCacheKeyand are unaffected.The one external caller of
makeCacheKey—resolveParentTexture— passes already-resolved props from anImageTextureinstance. The new key formula is backwards-compatible there.Why this matters
Test plan
tsc --noEmitpassesvitest run— all 96 tests passnumImageWorkers: 0(prefetch window falls back to size 1, sequential like before)ImageTextureandColorTexture(e.g., two nodes referencing the samesrc) still resolve to the same instance🤖 Generated with Claude Code