diff --git a/examples/tests/texture-alpha-switching.ts b/examples/tests/texture-alpha-switching.ts new file mode 100644 index 00000000..c8e37567 --- /dev/null +++ b/examples/tests/texture-alpha-switching.ts @@ -0,0 +1,129 @@ +import type { INode, RendererMainSettings } from '@lightningjs/renderer'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export function customSettings(): Partial { + return { + textureMemory: { + cleanupInterval: 5000, + criticalThreshold: 13e6, + baselineMemoryAllocation: 5e6, + doNotExceedCriticalThreshold: true, + debugLogging: false, + }, + }; +} + +export default async function ({ renderer, testRoot }: ExampleSettings) { + const holder1 = renderer.createNode({ + x: 150, + y: 150, + parent: testRoot, + // src: 'https://images.unsplash.com/photo-1690360994204-3d10cc73a08d?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=300&q=80', + alpha: 0, + }); + + const nodeWidth = 200; + const nodeHeight = 200; + const gap = 10; // Define the gap between items + + const spawnRow = function (rowIndex = 0, amount = 8) { + const y = rowIndex * (nodeHeight + gap); + + const rowNode = renderer.createNode({ + x: 0, + y: y, + parent: holder1, + color: 0x000000ff, + }); + + for (let i = 0; i < amount; i++) { + const id = rowIndex * amount + i; + + const childNode = renderer.createNode({ + x: i * nodeWidth, // Adjust position by subtracting the gap + y: 0, + width: nodeWidth, // Width of the green node + height: nodeHeight, // Slightly smaller height + parent: rowNode, + }); + + const imageNode = renderer.createNode({ + x: 0, + y: 0, + width: nodeWidth, + height: nodeHeight, + parent: childNode, + src: `https://picsum.photos/id/${id}/${nodeWidth}/${nodeHeight}`, // Random images + }); + + imageNode.on('failed', () => { + console.log(`Image failed to load for node ${id}`); + childNode.color = 0xffff00ff; // Change color to yellow on error + }); + + renderer.createTextNode({ + x: 0, + y: 0, + autosize: true, + parent: childNode, + text: `Card ${id}`, + fontSize: 20, + color: 0xffffffff, + }); + } + }; + + spawnRow(0); + spawnRow(1); + spawnRow(2); + spawnRow(3); + + const holder2 = renderer.createNode({ + parent: testRoot, + // src: 'https://images.unsplash.com/photo-1690360994204-3d10cc73a08d?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=300&q=80', + alpha: 1, + }); + + const img = renderer.createNode({ + width: 300, + height: 300, + parent: holder2, + src: 'https://images.unsplash.com/photo-1690360994204-3d10cc73a08d?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=300&q=80', + alpha: 1, + }); + + window.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + if (holder1.alpha === 1) { + holder1.alpha = 0; + holder2.alpha = 1; + } else { + holder1.alpha = 1; + holder2.alpha = 0; + } + } else if (e.key === 'r' || e.key === 'R') { + // Reset all squares in holder1 to white + const resetSquares = (node: INode) => { + // If this node has children, process each child + if (node.children && node.children.length > 0) { + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + if (child) { + // Set the color to white + if (!child.src) { + // Only change color of non-image nodes + child.color = 0xffffffff; // White with full alpha + } + // Recursively process this child's children + resetSquares(child); + } + } + } + }; + + // Process all rows in holder1 + resetSquares(holder1 as INode); + console.log('Reset all squares in holder1 to white'); + } + }); +} diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 5b57c118..ff85eb95 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -923,6 +923,9 @@ export class CoreNode extends EventEmitter { }; private onTextureFailed: TextureFailedEventHandler = (_, error) => { + // immediately set isRenderable to false, so that we handle the error + // without waiting for the next frame loop + this.isRenderable = false; this.setUpdateType(UpdateType.IsRenderable); // If parent has a render texture, flag that we need to update @@ -937,6 +940,9 @@ export class CoreNode extends EventEmitter { }; private onTextureFreed: TextureFreedEventHandler = () => { + // immediately set isRenderable to false, so that we handle the error + // without waiting for the next frame loop + this.isRenderable = false; this.setUpdateType(UpdateType.IsRenderable); // If parent has a render texture, flag that we need to update diff --git a/src/core/CoreTextureManager.ts b/src/core/CoreTextureManager.ts index 04b3dd07..dd2694c7 100644 --- a/src/core/CoreTextureManager.ts +++ b/src/core/CoreTextureManager.ts @@ -24,7 +24,7 @@ import { ImageTexture } from './textures/ImageTexture.js'; import { NoiseTexture } from './textures/NoiseTexture.js'; import { SubTexture } from './textures/SubTexture.js'; import { RenderTexture } from './textures/RenderTexture.js'; -import { TextureType, type Texture } from './textures/Texture.js'; +import { Texture, TextureType } from './textures/Texture.js'; import { EventEmitter } from '../common/EventEmitter.js'; import type { Stage } from './Stage.js'; import { @@ -358,29 +358,15 @@ export class CoreTextureManager extends EventEmitter { return; } - // if the texture is already loaded, don't load it again - if ( - texture.ctxTexture !== undefined && - texture.ctxTexture.state === 'loaded' - ) { - texture.setState('loaded'); + if (texture.state === 'loaded') { + // if the texture is already loaded, just return return; } - // if the texture is already being processed, don't load it again - if (this.uploadTextureQueue.includes(texture) === true) { + if (Texture.TRANSITIONAL_TEXTURE_STATES.includes(texture.state)) { return; } - // if the texture is already loading, free it, this can happen if the texture is - // orphaned and then reloaded - if ( - texture.ctxTexture !== undefined && - texture.ctxTexture.state === 'loading' - ) { - texture.free(); - } - // if we're not initialized, just queue the texture into the priority queue if (this.initialized === false) { this.priorityQueue.push(texture); diff --git a/src/core/TextureMemoryManager.ts b/src/core/TextureMemoryManager.ts index 2fb61114..c75b9f12 100644 --- a/src/core/TextureMemoryManager.ts +++ b/src/core/TextureMemoryManager.ts @@ -18,7 +18,7 @@ */ import { isProductionEnvironment } from '../utils.js'; import type { Stage } from './Stage.js'; -import { TextureType, type Texture } from './textures/Texture.js'; +import { Texture, TextureType, type TextureState } from './textures/Texture.js'; import { bytesToMb } from './utils.js'; export interface TextureMemoryManagerSettings { @@ -237,6 +237,12 @@ export class TextureMemoryManager { continue; } + // Skip textures that are in transitional states - we only want to clean up + // textures that are in a stable state (loaded, failed, or freed) + if (Texture.TRANSITIONAL_TEXTURE_STATES.includes(texture.state)) { + continue; + } + this.destroyTexture(texture); } } @@ -247,6 +253,12 @@ export class TextureMemoryManager { * @param texture - The texture to destroy */ destroyTexture(texture: Texture) { + if (this.debugLogging === true) { + console.log( + `[TextureMemoryManager] Destroying texture. State: ${texture.state}`, + ); + } + const txManager = this.stage.txManager; txManager.removeTextureFromQueue(texture); txManager.removeTextureFromCache(texture); @@ -296,6 +308,12 @@ export class TextureMemoryManager { break; } + // Skip textures that are in transitional states - we only want to clean up + // textures that are in a stable state (loaded, failed, or freed) + if (Texture.TRANSITIONAL_TEXTURE_STATES.includes(texture.state)) { + break; + } + this.destroyTexture(texture); } } @@ -320,6 +338,15 @@ export class TextureMemoryManager { ); } + // Note: We skip textures in transitional states during cleanup: + // - 'initial': These textures haven't started loading yet + // - 'fetching': These textures are in the process of being fetched + // - 'fetched': These textures have been fetched but not yet uploaded to GPU + // - 'loading': These textures are being uploaded to the GPU + // + // For 'failed' and 'freed' states, we only remove them from the tracking + // arrays without trying to free GPU resources that don't exist. + // try a quick cleanup first this.cleanupQuick(critical); @@ -356,9 +383,9 @@ export class TextureMemoryManager { const renderableMemUsed = [...this.loadedTextures.keys()].reduce( (acc, texture) => { renderableTexturesLoaded += texture.renderable ? 1 : 0; - return ( - acc + (texture.renderable ? this.loadedTextures.get(texture)! : 0) - ); + // Get the memory used by the texture, defaulting to 0 if not found + const textureMemory = this.loadedTextures.get(texture) ?? 0; + return acc + (texture.renderable ? textureMemory : 0); }, this.baselineMemoryAllocation, ); diff --git a/src/core/animations/CoreAnimationController.ts b/src/core/animations/CoreAnimationController.ts index 602ad9b9..8d3cbc75 100644 --- a/src/core/animations/CoreAnimationController.ts +++ b/src/core/animations/CoreAnimationController.ts @@ -125,7 +125,6 @@ export class CoreAnimationController } private onFinished(this: CoreAnimationController): void { - assertTruthy(this.stoppedResolve); // If the animation is looping, then we need to restart it. const { loop, stopMethod } = this.animation.settings; @@ -143,8 +142,11 @@ export class CoreAnimationController this.unregisterAnimation(); // resolve promise - this.stoppedResolve(); - this.stoppedResolve = null; + if (this.stoppedResolve !== null) { + this.stoppedResolve(); + this.stoppedResolve = null; + } + this.emit('stopped', this); this.state = 'stopped'; } diff --git a/src/core/textures/Texture.ts b/src/core/textures/Texture.ts index 4898f275..dfa07fff 100644 --- a/src/core/textures/Texture.ts +++ b/src/core/textures/Texture.ts @@ -144,6 +144,12 @@ export abstract class Texture extends EventEmitter { private _dimensions: Dimensions | null = null; private _error: Error | null = null; + /** + * Texture states that are considered transitional and should be skipped during cleanup + */ + public static readonly TRANSITIONAL_TEXTURE_STATES: readonly TextureState[] = + ['fetching', 'fetched', 'loading']; + // aggregate state public state: TextureState = 'initial'; @@ -258,10 +264,13 @@ export abstract class Texture extends EventEmitter { * cleaned up. */ destroy(): void { - this.removeAllListeners(); - this.free(); + // Only free GPU resources if we're in a state where they exist + if (this.state === 'loaded') { + this.free(); + } + + // Always free texture data regardless of state this.freeTextureData(); - this.renderableOwners.clear(); } /**