diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 8e2df5c36..392922ca1 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -27,7 +27,6 @@ import type { TextureOptions } from './CoreTextureManager.js'; import type { CoreRenderer } from './renderers/CoreRenderer.js'; import type { Stage } from './Stage.js'; import { - TextureType, type Texture, type TextureFailedEventHandler, type TextureFreedEventHandler, @@ -918,10 +917,7 @@ export class CoreNode extends EventEmitter { } // only emit failed outward if we've exhausted all retry attempts - if ( - this.texture !== null && - this.texture.retryCount > this.texture.maxRetryCount - ) { + if (this.texture !== null && this.texture.hasExhaustedRetries()) { this.emit('failed', { type: 'texture', error, @@ -1473,8 +1469,8 @@ export class CoreNode extends EventEmitter { if (this.texture !== null) { // preemptive check for failed textures this will mark the current node as non-renderable // and will prevent further checks until the texture is reloaded or retry is reset on the texture - if (this.texture.retryCount > this.texture.maxRetryCount) { - // texture has failed to load, we cannot render + if (this.texture.hasExhaustedRetries()) { + // texture has failed to load after all retries, we cannot render this.updateTextureOwnership(false); this.setRenderable(false); return; diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index 7bdc23e95..c47c1f394 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -197,6 +197,7 @@ export class CoreTextureManager extends EventEmitter { public maxRetryCount: number; private priorityQueue: Array = []; private uploadTextureQueue: Array = []; + private retryQueue: Set = new Set(); private initialized = false; private stage: Stage; private numImageWorkers: number; @@ -316,6 +317,44 @@ export class CoreTextureManager extends EventEmitter { } } + /** + * Add a texture to the retry queue + * + * @remarks + * Textures in the retry queue will have their `load()` method called + * automatically when their exponential backoff delay has elapsed. + * + * @param texture - The texture to add to retry queue + */ + addToRetryQueue(texture: Texture): void { + this.retryQueue.add(texture); + } + + /** + * Remove a texture from the retry queue + * + * @param texture - The texture to remove from retry queue + */ + removeFromRetryQueue(texture: Texture): void { + this.retryQueue.delete(texture); + } + + /** + * Process all textures in the retry queue + * + * @remarks + * Called every frame from Stage.updateFrameTime(). Checks each texture + * in the retry queue and calls load() if its exponential backoff delay + * has elapsed. Textures are automatically removed from the queue when + * they are successfully loaded or when they exhaust their retry attempts. + */ + processTextureRetries(): void { + for (const texture of this.retryQueue) { + // load() will check canRetryNow() internally and only proceed if backoff has elapsed + texture.load(); + } + } + /** * Create a texture * diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 2ea9d7fcc..b392433cc 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -344,6 +344,9 @@ export class Stage { this.txManager.frameTime = newFrameTime; this.txMemManager.frameTime = newFrameTime; + // Process texture retries with exponential backoff + this.txManager.processTextureRetries(); + // This event is emitted at the beginning of the frame (before any updates // or rendering), so no need to to use `stage.queueFrameEvent` here. this.eventBus.emit('frameTick', { diff --git a/src/core/textures/Texture.ts b/src/core/textures/Texture.ts index a5c6b8cad..3e80c7b9c 100644 --- a/src/core/textures/Texture.ts +++ b/src/core/textures/Texture.ts @@ -174,6 +174,23 @@ export abstract class Texture extends EventEmitter { public retryCount = 0; public maxRetryCount: number; + /** + * Frame time when the last retry attempt was made + * + * @remarks + * Used to implement exponential backoff for texture loading retries. + */ + private lastRetryAttemptFrameTime = 0; + + /** + * Base delay in milliseconds for exponential backoff retry strategy + * + * @remarks + * Actual delay = BASE_RETRY_DELAY * Math.pow(2, retryCount - 1) + * This ensures: 1st retry after 500ms, 2nd after 1s, 3rd after 2s, etc. + */ + private static readonly BASE_RETRY_DELAY = 500; // milliseconds + /** * Timestamp when texture was created (for startup grace period) */ @@ -284,6 +301,12 @@ export abstract class Texture extends EventEmitter { if (oldSize !== newSize && newSize === 1) { (this.renderable as boolean) = true; this.onChangeIsRenderable?.(true); + + // If the texture previously failed and can still retry, re-enqueue it. + if (this.state === 'failed' && !this.hasExhaustedRetries()) { + this.txManager.addToRetryQueue(this); + } + this.load(); } } else { @@ -296,17 +319,57 @@ export abstract class Texture extends EventEmitter { (this.renderable as boolean) = false; this.onChangeIsRenderable?.(false); + // Remove from retry queue when no nodes are using this texture. + this.txManager.removeFromRetryQueue(this); + // note, not doing a cleanup here, cleanup is managed by the Stage/TextureMemoryManager // when it deems appropriate based on memory pressure } } } + /** + * Check if enough time has passed to attempt a retry using exponential backoff. + * + * @remarks + * Uses exponential backoff formula: BASE_DELAY * Math.pow(2, retryCount - 1) + * This keeps retry attempts throttled within the render loop without setTimeout. + * Frame time is obtained from txManager, which is updated by the renderer. + * + * @returns true if a retry should be attempted, false if still waiting + */ + private canRetryNow(): boolean { + // First attempt always allowed + if (this.retryCount === 0) { + return true; + } + + const currentFrameTime = this.txManager.frameTime; + const requiredDelayMs = + Texture.BASE_RETRY_DELAY * Math.pow(2, this.retryCount - 1); + const timeSinceLastAttempt = + currentFrameTime - this.lastRetryAttemptFrameTime; + + return timeSinceLastAttempt >= requiredDelayMs; + } + load(): void { - if (this.retryCount > this.maxRetryCount) { + if ( // We've exceeded the max retry count, do not attempt to load again + this.hasExhaustedRetries() === true || + // Skip loading if no nodes are using this texture (out of bounds/not renderable) + this.renderableOwners.length === 0 || + // Skip if already loading + this.state === 'loading' || + // Check if we should wait longer before retrying + this.canRetryNow() === false + ) { + if (this.renderableOwners.length === 0) { + this.txManager.removeFromRetryQueue(this); + } return; } + this.txManager.loadTexture(this); } @@ -410,6 +473,9 @@ export abstract class Texture extends EventEmitter { } payload = this._dimensions; + + // Remove from retry queue when successfully loaded + this.txManager.removeFromRetryQueue(this); } else if (state === 'failed') { this._error = errorOrDimensions as Error; payload = this._error; @@ -419,6 +485,14 @@ export abstract class Texture extends EventEmitter { // to determine if we should try loading again this.retryCount += 1; + this.lastRetryAttemptFrameTime = this.txManager.frameTime; + + if (!this.hasExhaustedRetries()) { + this.txManager.addToRetryQueue(this); + } else { + this.txManager.removeFromRetryQueue(this); + } + queueMicrotask(() => { this.release(); }); @@ -436,6 +510,15 @@ export abstract class Texture extends EventEmitter { this.emit(state, payload); } + /** + * Check if this texture has exhausted all retry attempts. + * + * @returns true if retries are exhausted, false otherwise + */ + hasExhaustedRetries(): boolean { + return this.retryCount > 0 && this.retryCount >= this.maxRetryCount; + } + /** * Get the texture data for this texture. *