From bea915d4f12002d6d0663c6e5ab4fb173e321eeb Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 10 May 2026 21:18:49 -0400 Subject: [PATCH 1/2] perf(textures): overlap decode with upload and skip resolveDefaults on cache hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/CoreTextureManager.ts | 94 ++++++++++++++++++++++++------- src/core/textures/ColorTexture.ts | 4 +- src/core/textures/ImageTexture.ts | 12 ++-- 3 files changed, 84 insertions(+), 26 deletions(-) diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index 17f1b15..0b25b86 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -320,7 +320,6 @@ export class CoreTextureManager extends EventEmitter { textureType: Type, props: ExtractProps, ): InstanceType { - let texture: Texture | undefined; const TextureClass = this.txConstructors[textureType]; if (!TextureClass) { throw new TextureError( @@ -328,20 +327,26 @@ export class CoreTextureManager extends EventEmitter { `Texture type "${textureType}" is not registered`, ); } - const resolvedProps = TextureClass.resolveDefaults(props as any); - const cacheKey = TextureClass.makeCacheKey(resolvedProps as any); - if (cacheKey && this.keyCache.has(cacheKey)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - texture = this.keyCache.get(cacheKey)!; - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any - texture = new TextureClass(this, resolvedProps as any); - if (cacheKey) { - this.initTextureToCache(texture, cacheKey); + // Cache key is computed from raw props (each Texture's makeCacheKey + // inlines its own defaults) so we can skip the resolveDefaults + // allocation on a cache hit. + const cacheKey = TextureClass.makeCacheKey(props as any); + if (cacheKey) { + const cached = this.keyCache.get(cacheKey); + if (cached) { + return cached as InstanceType; } } + const resolvedProps = TextureClass.resolveDefaults(props as any); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + const texture = new TextureClass(this, resolvedProps as any); + + if (cacheKey) { + this.initTextureToCache(texture, cacheKey); + } + return texture as InstanceType; } @@ -475,30 +480,77 @@ export class CoreTextureManager extends EventEmitter { const platform = this.platform; const startTime = platform.getTimeStamp(); - // Process uploads - await each upload to prevent GPU overload + // Decode / fetch ("getTextureData") is IO-bound and parallelisable across + // image workers, while GPU upload is effectively serial. Keep a small + // sliding window of in-flight data fetches so the next decode runs while + // we're uploading the current one. + const prefetchLimit = Math.max(1, this.numImageWorkers); + const pending: Array<{ texture: Texture; data: Promise }> = []; + + const fillPrefetch = () => { + while ( + pending.length < prefetchLimit && + this.uploadTextureQueue.size > 0 + ) { + const [texture] = this.uploadTextureQueue; + if (!texture) break; + this.uploadTextureQueue.delete(texture); + + if (texture.state === 'failed' || texture.state === 'freed') { + continue; + } + + // Swallow the rejection here so an early failure doesn't surface as + // an unhandled promise rejection while it sits in the prefetch + // window; we re-check state after awaiting. + const data = + texture.textureData === null + ? texture.getTextureData().catch((err) => { + console.error('Failed to fetch texture data:', err); + return null; + }) + : Promise.resolve(texture.textureData); + + pending.push({ texture, data }); + } + }; + + fillPrefetch(); + while ( - this.uploadTextureQueue.size > 0 && + pending.length > 0 && platform.getTimeStamp() - startTime < maxProcessingTime ) { - const [texture] = this.uploadTextureQueue; - if (!texture) break; - this.uploadTextureQueue.delete(texture); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const next = pending.shift()!; + // Top up the prefetch window before awaiting — the next decode starts + // now and overlaps with this upload. + fillPrefetch(); - // Skip textures that were freed or failed between enqueue and now. - if (texture.state === 'failed' || texture.state === 'freed') { + if (next.texture.state === 'failed' || next.texture.state === 'freed') { continue; } try { - if (texture.textureData === null) { - await texture.getTextureData(); + await next.data; + if (next.texture.state === 'failed' || next.texture.state === 'freed') { + continue; } - await this.uploadTexture(texture); + await this.uploadTexture(next.texture); } catch (error) { console.error('Failed to upload texture:', error); // Continue with next texture instead of stopping entire queue } } + + // Time ran out before we got to these. Put them back so we don't lose + // them — their getTextureData() is already in flight and will populate + // `textureData` for the next tick. + for (const { texture } of pending) { + if (texture.state !== 'failed' && texture.state !== 'freed') { + this.uploadTextureQueue.add(texture); + } + } } public hasUpdates(): boolean { diff --git a/src/core/textures/ColorTexture.ts b/src/core/textures/ColorTexture.ts index 1d6a303..f8321c5 100644 --- a/src/core/textures/ColorTexture.ts +++ b/src/core/textures/ColorTexture.ts @@ -70,7 +70,9 @@ export class ColorTexture extends Texture { } static override makeCacheKey(props: ColorTextureProps): string { - return `ColorTexture,${props.color}`; + // Mirror the default from resolveDefaults so the key is stable whether + // or not the caller has run defaults first. + return `ColorTexture,${props.color || 0xffffffff}`; } static override resolveDefaults( diff --git a/src/core/textures/ImageTexture.ts b/src/core/textures/ImageTexture.ts index 5845f53..e36f651 100644 --- a/src/core/textures/ImageTexture.ts +++ b/src/core/textures/ImageTexture.ts @@ -364,11 +364,15 @@ export class ImageTexture extends Texture { return false; } - let cacheKey = `ImageTexture,${key},${props.premultiplyAlpha ?? 'true'},${ - props.maxRetryCount - }`; + // Inline default values so the key is stable whether or not the caller + // has run them through resolveDefaults first. Must mirror the defaults + // in resolveDefaults below. + const premultiplyAlpha = props.premultiplyAlpha ?? true; + const maxRetryCount = props.maxRetryCount ?? 5; - if (props.sh !== null && props.sw !== null) { + let cacheKey = `ImageTexture,${key},${premultiplyAlpha},${maxRetryCount}`; + + if (props.sh != null && props.sw != null) { cacheKey += `,${props.sx ?? ''},${props.sy ?? ''},${props.sw || ''},${ props.sh || '' }`; From fc9c0b041b8a922e757356c770dc1cefedc664a7 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 10 May 2026 21:23:48 -0400 Subject: [PATCH 2/2] fix(textures): avoid TS narrowing in processSome state checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/CoreTextureManager.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index 0b25b86..2bab2d9 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -487,6 +487,12 @@ export class CoreTextureManager extends EventEmitter { const prefetchLimit = Math.max(1, this.numImageWorkers); const pending: Array<{ texture: Texture; data: Promise }> = []; + // Helper avoids TS narrowing `texture.state` permanently after the first + // discriminated check — the property is mutable and can transition across + // awaits, so we need to re-read it freshly each time. + const isDead = (texture: Texture): boolean => + texture.state === 'failed' || texture.state === 'freed'; + const fillPrefetch = () => { while ( pending.length < prefetchLimit && @@ -496,7 +502,7 @@ export class CoreTextureManager extends EventEmitter { if (!texture) break; this.uploadTextureQueue.delete(texture); - if (texture.state === 'failed' || texture.state === 'freed') { + if (isDead(texture)) { continue; } @@ -527,13 +533,13 @@ export class CoreTextureManager extends EventEmitter { // now and overlaps with this upload. fillPrefetch(); - if (next.texture.state === 'failed' || next.texture.state === 'freed') { + if (isDead(next.texture)) { continue; } try { await next.data; - if (next.texture.state === 'failed' || next.texture.state === 'freed') { + if (isDead(next.texture)) { continue; } await this.uploadTexture(next.texture); @@ -547,7 +553,7 @@ export class CoreTextureManager extends EventEmitter { // them — their getTextureData() is already in flight and will populate // `textureData` for the next tick. for (const { texture } of pending) { - if (texture.state !== 'failed' && texture.state !== 'freed') { + if (!isDead(texture)) { this.uploadTextureQueue.add(texture); } }