Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions examples/tests/texture-alpha-switching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { INode, RendererMainSettings } from '@lightningjs/renderer';
import type { ExampleSettings } from '../common/ExampleSettings.js';

export function customSettings(): Partial<RendererMainSettings> {
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');
}
});
}
6 changes: 6 additions & 0 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 4 additions & 18 deletions src/core/CoreTextureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
35 changes: 31 additions & 4 deletions src/core/TextureMemoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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);

Expand Down Expand Up @@ -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,
);
Expand Down
8 changes: 5 additions & 3 deletions src/core/animations/CoreAnimationController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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';
}
Expand Down
15 changes: 12 additions & 3 deletions src/core/textures/Texture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
}

/**
Expand Down