diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index d508511..ea48935 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -409,9 +409,9 @@ export class CoreTextureManager extends EventEmitter { // Anything that arrived before initialization completed is now safe to // process. Without this, queued textures would sit until the next frame - // tick happens to call processSome(). + // tick happens to drain them. if (this.uploadTextureQueue.size > 0) { - this.processSome(Infinity).catch((err) => { + this.processUntil(Infinity).catch((err) => { console.error('Failed to drain pre-init texture queue:', err); }); } @@ -587,11 +587,41 @@ export class CoreTextureManager extends EventEmitter { } /** - * Process a limited number of uploads. + * Upload a single queued texture to the GPU. * - * @param maxProcessingTime - The maximum processing time in milliseconds + * @remarks + * Used while animations are running so uploads don't steal time from the + * animation. If the dequeued texture already died (failed/freed), nothing is + * uploaded this frame and the next call handles the following one. + */ + async processOne(): Promise { + if (this.initialized === false) { + return; + } + + const texture = this.uploadTextureQueue.shift(); + if (texture === undefined) { + return; + } + + await this.uploadQueued(texture); + } + + /** + * Upload queued textures to the GPU until the per-frame time budget runs out. + * + * @remarks + * Called once per frame when idle. Textures are uploaded one-by-one; after + * each, the elapsed time is rechecked and processing stops once it exceeds + * `maxProcessingTime`, leaving the rest queued for the next frame. + * + * In normal operation a queued texture's data is already decoded + * (`loadTexture` awaits `getTextureData` before enqueuing), so this budgets + * GPU upload time. Pass `Infinity` to drain the whole queue. + * + * @param maxProcessingTime - The time budget for this frame, in milliseconds */ - async processSome(maxProcessingTime: number): Promise { + async processUntil(maxProcessingTime: number): Promise { if (this.initialized === false) { return; } @@ -599,79 +629,54 @@ export class CoreTextureManager extends EventEmitter { const platform = this.platform; const startTime = platform.getTimeStamp(); - // 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 }> = []; - - // 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) { - const texture = this.uploadTextureQueue.shift(); - if (texture === undefined) break; - - if (isDead(texture)) { - 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 }); + while (platform.getTimeStamp() - startTime < maxProcessingTime) { + const texture = this.uploadTextureQueue.shift(); + if (texture === undefined) { + // Queue drained. + break; } - }; - fillPrefetch(); + await this.uploadQueued(texture); + } + } - while ( - pending.length > 0 && - platform.getTimeStamp() - startTime < maxProcessingTime - ) { - // 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(); + /** + * Decode (if needed) and upload a single already-dequeued texture. + * + * @remarks + * Shared by {@link processOne} and {@link processUntil}. Dead (failed/freed) + * textures and upload failures are skipped without throwing. + */ + private async uploadQueued(texture: Texture): Promise { + if (this.isTextureDead(texture)) { + return; + } - if (isDead(next.texture)) { - continue; + try { + if (texture.textureData === null) { + await texture.getTextureData(); } - - try { - await next.data; - if (isDead(next.texture)) { - continue; - } - await this.uploadTexture(next.texture); - } catch (error) { - console.error('Failed to upload texture:', error); - // Continue with next texture instead of stopping entire queue + if (this.isTextureDead(texture)) { + return; } + await this.uploadTexture(texture); + } catch (error) { + console.error('Failed to upload texture:', error); + // Skip this texture instead of stalling the 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 (!isDead(texture)) { - this.uploadTextureQueue.add(texture); - } - } + /** + * A texture is "dead" once it has failed or been freed — both terminal for + * the upload pipeline. + * + * @remarks + * Kept as a method rather than an inline check so TypeScript doesn't + * permanently narrow `state` after the first comparison: the property is + * mutable and can transition across the awaits in {@link uploadQueued}. + */ + private isTextureDead(texture: Texture): boolean { + return texture.state === 'failed' || texture.state === 'freed'; } public hasUpdates(): boolean { diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 53be64d..50ea82a 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -595,11 +595,16 @@ export class Stage { // Process some textures asynchronously but don't block the frame // Use a background task to prevent frame drops if (this.txManager.hasUpdates() === true) { - const timeLimit = hasActiveAnimations - ? this.options.textureProcessingTimeLimit / 2 - : this.options.textureProcessingTimeLimit; + // While animating, upload at most one texture per frame so uploads don't + // steal time from the animation; otherwise fill the per-frame time budget. + const processing = + hasActiveAnimations === true + ? this.txManager.processOne() + : this.txManager.processUntil( + this.options.textureProcessingTimeLimit, + ); - this.txManager.processSome(timeLimit).catch((err) => { + processing.catch((err) => { console.error('Error processing textures:', err); }); }