Skip to content

Commit 890bb80

Browse files
authored
Texture lifecycle improvements (#497)
This PR introduces several improvements and fixes related to the handling of textures in the rendering pipeline. * Addressed Texture Reloading Issues: Resolved inconsistencies that occurred when textures transitioned back to a renderable state after being previously marked non-renderable. * Preserve Source Data: Adjusted the cleanup process to ensure only the GPU texture is cleared when a texture becomes non-renderable, retaining the source data for potential reuse. * Propagate SubTexture Events: Added functionality to forward SubTexture freed events if the parent texture undergoes cleanup, ensuring proper event handling and lifecycle alignment. * Introduced comprehensive tests to validate the entire texture lifecycle, ensuring robustness and preventing regressions in future updates. (See screenshot below for test validation details.) ![texture-reload-1](https://github.com/user-attachments/assets/c3cf46bb-2fb5-47ad-b3e4-76febbc2b5cb)
2 parents 919cb53 + f94621a commit 890bb80

File tree

4 files changed

+380
-16
lines changed

4 files changed

+380
-16
lines changed

examples/tests/texture-reload.ts

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
/*
2+
* If not stated otherwise in this file or this component's LICENSE file the
3+
* following copyright and licenses apply:
4+
*
5+
* Copyright 2023 Comcast Cable Communications Management, LLC.
6+
*
7+
* Licensed under the Apache License, Version 2.0 (the License);
8+
* you may not use this file except in compliance with the License.
9+
* You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing, software
14+
* distributed under the License is distributed on an "AS IS" BASIS,
15+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16+
* See the License for the specific language governing permissions and
17+
* limitations under the License.
18+
*/
19+
20+
import type { INode, RendererMainSettings } from '@lightningjs/renderer';
21+
import type { ExampleSettings } from '../common/ExampleSettings.js';
22+
23+
import rockoPng from '../assets/rocko.png';
24+
25+
export function customSettings(): Partial<RendererMainSettings> {
26+
return {
27+
textureMemory: {
28+
cleanupInterval: 5000,
29+
debugLogging: true,
30+
},
31+
};
32+
}
33+
34+
const COLORS = [
35+
0xff0000ff, // Red
36+
0x00ff00ff, // Green
37+
0x0000ffff, // Blue
38+
0xffff00ff, // Yellow
39+
0xff00ffff, // Magenta
40+
0x00ffffff, // Cyan
41+
0xffffffff, // White
42+
];
43+
44+
/**
45+
* Function that chooses a random color from the `COLORS` array
46+
*/
47+
function randomColor() {
48+
return COLORS[Math.floor(Math.random() * COLORS.length)];
49+
}
50+
51+
function delay(ms: number) {
52+
return new Promise((resolve) => setTimeout(resolve, ms));
53+
}
54+
55+
export default async function test({ renderer, testRoot }: ExampleSettings) {
56+
const screenWidth = renderer.settings.appWidth;
57+
const screenHeight = renderer.settings.appHeight;
58+
const nodeSize = 128; // Each node will be 128x128 pixels
59+
const memoryThreshold = 130 * 1024 * 1024; // 130 MB
60+
const textureSize = nodeSize * nodeSize * 3; // RGBA bytes per pixel
61+
const maxNodes = Math.ceil(memoryThreshold / textureSize);
62+
const nodes: INode[] = [];
63+
64+
const header = renderer.createTextNode({
65+
fontFamily: 'Ubuntu',
66+
text: `Texture Reload Test`,
67+
fontSize: 45,
68+
parent: testRoot,
69+
x: 500,
70+
y: 50,
71+
});
72+
73+
const finalStatus = renderer.createTextNode({
74+
fontFamily: 'Ubuntu',
75+
text: `Running...`,
76+
fontSize: 30,
77+
parent: testRoot,
78+
color: 0xffff00ff,
79+
x: 500,
80+
y: 100,
81+
});
82+
83+
// Create nodes with unique noise textures until the memory threshold is reached
84+
for (let i = 0; i < maxNodes; i++) {
85+
const x = (i % 27) * 10;
86+
const y = ~~(i / 27) * 10;
87+
88+
const node = renderer.createNode({
89+
x,
90+
y,
91+
width: nodeSize,
92+
height: nodeSize,
93+
parent: testRoot,
94+
color: randomColor(),
95+
texture: renderer.createTexture('NoiseTexture', {
96+
width: nodeSize,
97+
height: nodeSize,
98+
cacheId: i,
99+
}),
100+
});
101+
nodes.push(node);
102+
}
103+
104+
console.log(`Created ${nodes.length} nodes. Memory threshold reached.`);
105+
106+
const testNode = async function (testNode: INode) {
107+
let textureFreed = false;
108+
let textureLoaded = false;
109+
let timedOut = false;
110+
let timeOutTimer: NodeJS.Timeout | null = null;
111+
112+
function resetTimeout() {
113+
if (timeOutTimer) {
114+
clearTimeout(timeOutTimer);
115+
}
116+
117+
timeOutTimer = setTimeout(() => {
118+
timedOut = true;
119+
}, 10000);
120+
}
121+
122+
testNode.on('freed', () => {
123+
console.log('Texture freed event received.');
124+
textureFreed = true;
125+
textureLoaded = false;
126+
});
127+
128+
testNode.on('loaded', () => {
129+
console.log('Texture loaded event received.');
130+
textureLoaded = true;
131+
textureFreed = false;
132+
});
133+
134+
// Wait for the texture to be freed
135+
console.log('Waiting for texture to be loaded...');
136+
while (!textureLoaded && !timedOut) {
137+
await delay(100);
138+
}
139+
140+
if (timedOut) {
141+
console.error('Texture failed to load within 10 seconds.');
142+
return false;
143+
}
144+
145+
resetTimeout();
146+
147+
// Move the node offscreen
148+
console.log('Moving node offscreen...');
149+
// Move the node out of bounds
150+
queueMicrotask(() => {
151+
testNode.x = -screenWidth * 2;
152+
testNode.y = -screenHeight * 2;
153+
});
154+
155+
// Wait for the texture to be freed
156+
console.log('Waiting for texture to be freed...');
157+
while (!textureFreed && !timedOut) {
158+
renderer.rerender();
159+
await delay(100);
160+
}
161+
162+
if (timedOut) {
163+
console.error('Texture failed to free within 10 seconds.');
164+
return false;
165+
}
166+
167+
resetTimeout();
168+
169+
// Move the node back into bounds
170+
console.log('Moving node back into view...');
171+
testNode.x = 0;
172+
testNode.y = 0;
173+
174+
// Wait for the texture to be reloaded
175+
console.log('Waiting for texture to be reloaded...');
176+
while (!textureLoaded && !timedOut) {
177+
await delay(100);
178+
}
179+
180+
if (timedOut) {
181+
console.error('Texture failed to reload within 10 seconds.');
182+
return false;
183+
}
184+
185+
if (timeOutTimer) {
186+
clearTimeout(timeOutTimer);
187+
}
188+
189+
if (textureLoaded) {
190+
return true;
191+
} else {
192+
return false;
193+
}
194+
};
195+
196+
const image = renderer.createTexture('ImageTexture', {
197+
src: rockoPng,
198+
});
199+
200+
image.on('loaded', () => {
201+
console.warn('Parent Image texture loaded.');
202+
});
203+
204+
image.on('freed', () => {
205+
console.warn('Parent Image texture freed.');
206+
});
207+
208+
const nodeSpawnX = 1100;
209+
const nodeSpawnY = 30;
210+
211+
const testCases: Record<string, () => INode> = {
212+
'Noise Texture': () =>
213+
renderer.createNode({
214+
texture: renderer.createTexture('NoiseTexture', {
215+
width: nodeSize,
216+
height: nodeSize,
217+
cacheId: Math.random(),
218+
}),
219+
x: nodeSpawnX,
220+
y: nodeSpawnY,
221+
width: nodeSize,
222+
height: nodeSize,
223+
parent: testRoot,
224+
}),
225+
// No need to test color textures, they all sample from the same 1x1 pixel texture
226+
// and are not subject to the same memory constraints as other textures
227+
// "Color Texture": () => renderer.createNode({
228+
// color: 0xff00ff, // Magenta
229+
// x: nodeSpawnX,
230+
// y: nodeSpawnY,
231+
// width: nodeSize,
232+
// height: nodeSize,
233+
// parent: testRoot,
234+
// }),
235+
SubTexture: () =>
236+
renderer.createNode({
237+
texture: renderer.createTexture('SubTexture', {
238+
texture: image,
239+
x: 30,
240+
y: 0,
241+
width: 50,
242+
height: 50,
243+
}),
244+
x: nodeSpawnX,
245+
y: nodeSpawnY,
246+
width: nodeSize,
247+
height: nodeSize,
248+
parent: testRoot,
249+
}),
250+
'Image Texture': () =>
251+
renderer.createNode({
252+
x: nodeSpawnX,
253+
y: nodeSpawnY,
254+
width: nodeSize,
255+
height: nodeSize,
256+
src: rockoPng,
257+
parent: testRoot,
258+
}),
259+
'RTT Node': () => {
260+
const rtt = renderer.createNode({
261+
rtt: true,
262+
x: nodeSpawnX,
263+
y: nodeSpawnY,
264+
width: nodeSize,
265+
height: nodeSize,
266+
parent: testRoot,
267+
});
268+
269+
const child = renderer.createNode({
270+
x: 0,
271+
y: 0,
272+
width: 100,
273+
height: 100,
274+
color: 0xff0000ff,
275+
parent: rtt,
276+
});
277+
278+
const child2 = renderer.createNode({
279+
x: 0,
280+
y: 20,
281+
width: 100,
282+
height: 100,
283+
src: rockoPng,
284+
parent: rtt,
285+
});
286+
287+
return rtt;
288+
},
289+
};
290+
291+
// Run all tests
292+
let allTestsPassed = true;
293+
let lastStatusOffSet = 30;
294+
let testIdx = 1;
295+
296+
for (const [name, createNode] of Object.entries(testCases)) {
297+
console.log(`${testIdx}. Running test for: ${name}`);
298+
finalStatus.text = `${testIdx}. Running test for: ${name}`;
299+
300+
const testNodeInstance = createNode(); // Create the test node dynamically
301+
302+
const result = await testNode(testNodeInstance);
303+
304+
if (!result) {
305+
finalStatus.text = `Test failed for: ${name}`;
306+
finalStatus.color = 0xff0000ff;
307+
allTestsPassed = false;
308+
}
309+
310+
const status = result ? 'passed' : 'failed';
311+
console.log(`${testIdx}. Test ${result} for: ${name}`);
312+
313+
testNodeInstance.x = 500;
314+
testNodeInstance.y = lastStatusOffSet + 128;
315+
testNodeInstance.width = 128;
316+
testNodeInstance.height = 128;
317+
318+
renderer.createTextNode({
319+
fontFamily: 'Ubuntu',
320+
text: `${testIdx}. Test ${status} for: ${name}`,
321+
fontSize: 30,
322+
parent: testRoot,
323+
color: result ? 0x00ff00ff : 0xff0000ff,
324+
x: 630,
325+
y: lastStatusOffSet + 128 + 128 / 2,
326+
});
327+
328+
lastStatusOffSet += 130;
329+
testIdx++;
330+
}
331+
332+
if (allTestsPassed) {
333+
console.log('All tests passed successfully!');
334+
finalStatus.text = `All tests passed successfully!`;
335+
finalStatus.color = 0x00ff00ff;
336+
337+
return true;
338+
} else {
339+
console.error('One or more tests failed.');
340+
finalStatus.text = `One or more tests failed.`;
341+
finalStatus.color = 0xff0000ff;
342+
return false;
343+
}
344+
}

src/core/CoreNode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2284,7 +2284,7 @@ export class CoreNode extends EventEmitter {
22842284

22852285
this.props.texture = value;
22862286
if (value !== null) {
2287-
value.setRenderableOwner(this, this.isRenderable); // WVB TODO: check if this is correct
2287+
value.setRenderableOwner(this, this.isRenderable);
22882288
this.loadTexture();
22892289
}
22902290

src/core/textures/SubTexture.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,13 @@ export class SubTexture extends Texture {
111111
this.onParentTxLoaded(parentTx, parentTx.dimensions!);
112112
} else if (parentTx.state === 'failed') {
113113
this.onParentTxFailed(parentTx, parentTx.error!);
114+
} else if (parentTx.state === 'freed') {
115+
this.onParentTxFreed();
114116
}
117+
115118
parentTx.on('loaded', this.onParentTxLoaded);
116119
parentTx.on('failed', this.onParentTxFailed);
120+
parentTx.on('freed', this.onParentTxFreed);
117121
});
118122
}
119123

@@ -138,6 +142,10 @@ export class SubTexture extends Texture {
138142
this.setSourceState('failed', error);
139143
};
140144

145+
private onParentTxFreed = () => {
146+
this.setSourceState('freed');
147+
};
148+
141149
override onChangeIsRenderable(isRenderable: boolean): void {
142150
// Propagate the renderable owner change to the parent texture
143151
this.parentTexture.setRenderableOwner(this, isRenderable);

0 commit comments

Comments
 (0)