Skip to content

perf(textures): overlap decode with upload, skip resolveDefaults on cache hit#12

Merged
chiefcll merged 2 commits into
mainfrom
perf/texture-manager-prefetch-and-cache-lookup
May 11, 2026
Merged

perf(textures): overlap decode with upload, skip resolveDefaults on cache hit#12
chiefcll merged 2 commits into
mainfrom
perf/texture-manager-prefetch-and-cache-lookup

Conversation

@chiefcll
Copy link
Copy Markdown
Contributor

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 upload

Today the upload loop is await getTextureData(); await uploadTexture() serial. getTextureData() is IO/decode (and for ImageTexture it dispatches to a worker pool), while uploadTexture() 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, processSome keeps a small sliding window of in-flight getTextureData() promises:

  • Window size = max(1, numImageWorkers) — defaults to 2.
  • On each iteration we top up the window before awaiting the upload, so the next decode is already underway across the worker pool while the current texture is uploading.
  • State guards check failed/freed both before awaiting (cheap skip) and after (handles freed-during-decode).
  • The data-fetch promise has its own .catch inside the window, so a pre-fired rejection can't surface as an unhandled-rejection while it sits queued.
  • If the time budget runs out mid-window, unfinished textures go back on the queue. Their getTextureData() will still complete in the background and memoize onto texture.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 defaults

Before: every createTexture call (one per texture-bearing node) allocated a fully-resolved props object via resolveDefaults() even on a cache hit.

After: makeCacheKey runs against raw props first; resolveDefaults only runs on miss. To make this safe, each makeCacheKey had to produce the same key for raw and resolved inputs:

  • ImageTexture.makeCacheKeymaxRetryCount default (?? 5) is now inlined alongside the existing premultiplyAlpha default. sh/sw checks tightened from !== null to != null so undefined (raw) matches null (resolved).
  • ColorTexture.makeCacheKeycolor default (|| 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 produced ColorTexture,0 while resolved input produces ColorTexture,4294967295.

NoiseTexture.makeCacheKey already runs resolveDefaults internally, so it's unaffected. SubTexture and RenderTexture return false from makeCacheKey and are unaffected.

The one external caller of makeCacheKeyresolveParentTexture — passes already-resolved props from an ImageTexture instance. The new key formula is backwards-compatible there.

Why this matters

  • (1) is the bigger of the two: on startup-heavy scenes with multiple image workers, today's serial decode-then-upload pattern leaves the worker pool idle most of the time. Overlapping them lets the workers actually do their job.
  • (2) is a smaller per-frame allocation win on every node mount, but it adds up.

Test plan

  • tsc --noEmit passes
  • vitest run — all 96 tests pass
  • Verify image loading on a representative startup-heavy scene
  • Verify behavior with numImageWorkers: 0 (prefetch window falls back to size 1, sequential like before)
  • Verify cache hits for ImageTexture and ColorTexture (e.g., two nodes referencing the same src) still resolve to the same instance

🤖 Generated with Claude Code

chiefcll and others added 2 commits May 10, 2026 21:18
…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>
@chiefcll chiefcll merged commit 09ca9fd into main May 11, 2026
1 check passed
@chiefcll chiefcll deleted the perf/texture-manager-prefetch-and-cache-lookup branch May 11, 2026 01:38
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.

1 participant