@lightningjs/renderer 2.17.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/src/core/CoreNode.js +7 -5
  2. package/dist/src/core/CoreNode.js.map +1 -1
  3. package/dist/src/core/CoreTextureManager.d.ts +14 -8
  4. package/dist/src/core/CoreTextureManager.js +33 -59
  5. package/dist/src/core/CoreTextureManager.js.map +1 -1
  6. package/dist/src/core/Stage.d.ts +3 -3
  7. package/dist/src/core/Stage.js +9 -14
  8. package/dist/src/core/Stage.js.map +1 -1
  9. package/dist/src/core/TextureMemoryManager.d.ts +21 -17
  10. package/dist/src/core/TextureMemoryManager.js +99 -106
  11. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  12. package/dist/src/core/lib/WebGlContextWrapper.d.ts +10 -0
  13. package/dist/src/core/lib/WebGlContextWrapper.js +32 -0
  14. package/dist/src/core/lib/WebGlContextWrapper.js.map +1 -1
  15. package/dist/src/core/lib/textureCompression.js +13 -6
  16. package/dist/src/core/lib/textureCompression.js.map +1 -1
  17. package/dist/src/core/platform.js +4 -1
  18. package/dist/src/core/platform.js.map +1 -1
  19. package/dist/src/core/renderers/CoreContextTexture.d.ts +1 -0
  20. package/dist/src/core/renderers/CoreContextTexture.js.map +1 -1
  21. package/dist/src/core/renderers/canvas/CanvasCoreTexture.d.ts +1 -0
  22. package/dist/src/core/renderers/canvas/CanvasCoreTexture.js +4 -3
  23. package/dist/src/core/renderers/canvas/CanvasCoreTexture.js.map +1 -1
  24. package/dist/src/core/renderers/webgl/WebGlCoreCtxTexture.d.ts +9 -0
  25. package/dist/src/core/renderers/webgl/WebGlCoreCtxTexture.js +59 -29
  26. package/dist/src/core/renderers/webgl/WebGlCoreCtxTexture.js.map +1 -1
  27. package/dist/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.js +4 -4
  28. package/dist/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.js.map +1 -1
  29. package/dist/src/core/textures/ColorTexture.d.ts +2 -2
  30. package/dist/src/core/textures/ColorTexture.js +1 -2
  31. package/dist/src/core/textures/ColorTexture.js.map +1 -1
  32. package/dist/src/core/textures/ImageTexture.d.ts +7 -1
  33. package/dist/src/core/textures/ImageTexture.js +55 -39
  34. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  35. package/dist/src/core/textures/NoiseTexture.js +1 -1
  36. package/dist/src/core/textures/NoiseTexture.js.map +1 -1
  37. package/dist/src/core/textures/RenderTexture.js +1 -1
  38. package/dist/src/core/textures/RenderTexture.js.map +1 -1
  39. package/dist/src/core/textures/SubTexture.d.ts +1 -2
  40. package/dist/src/core/textures/SubTexture.js +11 -29
  41. package/dist/src/core/textures/SubTexture.js.map +1 -1
  42. package/dist/src/core/textures/Texture.d.ts +51 -7
  43. package/dist/src/core/textures/Texture.js +127 -15
  44. package/dist/src/core/textures/Texture.js.map +1 -1
  45. package/dist/src/main-api/Inspector.d.ts +3 -0
  46. package/dist/src/main-api/Inspector.js +156 -0
  47. package/dist/src/main-api/Inspector.js.map +1 -1
  48. package/dist/src/main-api/Renderer.d.ts +1 -3
  49. package/dist/src/main-api/Renderer.js +2 -4
  50. package/dist/src/main-api/Renderer.js.map +1 -1
  51. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  52. package/package.json +1 -1
  53. package/src/core/CoreNode.ts +8 -4
  54. package/src/core/CoreTextureManager.ts +59 -65
  55. package/src/core/Stage.ts +10 -16
  56. package/src/core/TextureMemoryManager.ts +118 -131
  57. package/src/core/lib/WebGlContextWrapper.ts +38 -0
  58. package/src/core/lib/textureCompression.ts +18 -7
  59. package/src/core/platform.ts +5 -1
  60. package/src/core/renderers/CoreContextTexture.ts +1 -0
  61. package/src/core/renderers/canvas/CanvasCoreTexture.ts +5 -3
  62. package/src/core/renderers/webgl/WebGlCoreCtxTexture.ts +78 -40
  63. package/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts +10 -4
  64. package/src/core/textures/ColorTexture.ts +4 -7
  65. package/src/core/textures/ImageTexture.ts +66 -51
  66. package/src/core/textures/NoiseTexture.ts +1 -1
  67. package/src/core/textures/RenderTexture.ts +1 -1
  68. package/src/core/textures/SubTexture.ts +14 -31
  69. package/src/core/textures/Texture.ts +150 -21
  70. package/src/main-api/Inspector.ts +203 -0
  71. package/src/main-api/Renderer.ts +2 -4
@@ -412,7 +412,7 @@ export class SdfTextRenderer extends TextRenderer<SdfTextRendererState> {
412
412
  this.setStatus(state, 'failed', new Error(msg));
413
413
  return;
414
414
  }
415
- trFontFace.texture.setRenderableOwner(state, true);
415
+ trFontFace.texture.setRenderableOwner(state.props.fontFamily, true);
416
416
  }
417
417
 
418
418
  // If the font hasn't been loaded yet, stop here.
@@ -787,13 +787,16 @@ export class SdfTextRenderer extends TextRenderer<SdfTextRendererState> {
787
787
  renderable: boolean,
788
788
  ): void {
789
789
  super.setIsRenderable(state, renderable);
790
- state.trFontFace?.texture.setRenderableOwner(state, renderable);
790
+ state.trFontFace?.texture.setRenderableOwner(
791
+ state.props.fontFamily,
792
+ renderable,
793
+ );
791
794
  }
792
795
 
793
796
  override destroyState(state: SdfTextRendererState): void {
794
797
  super.destroyState(state);
795
798
  // If there's a Font Face assigned we must free the owner relation to its texture
796
- state.trFontFace?.texture.setRenderableOwner(state, false);
799
+ state.trFontFace?.texture.setRenderableOwner(state.props.fontFamily, false);
797
800
  }
798
801
  //#endregion Overrides
799
802
 
@@ -813,7 +816,10 @@ export class SdfTextRenderer extends TextRenderer<SdfTextRendererState> {
813
816
  protected releaseFontFace(state: SdfTextRendererState) {
814
817
  state.resLineHeight = undefined;
815
818
  if (state.trFontFace) {
816
- state.trFontFace.texture.setRenderableOwner(state, false);
819
+ state.trFontFace.texture.setRenderableOwner(
820
+ state.props.fontFamily,
821
+ false,
822
+ );
817
823
  state.trFontFace = undefined;
818
824
  }
819
825
  }
@@ -45,13 +45,12 @@ export interface ColorTextureProps {
45
45
  * a Node are.
46
46
  */
47
47
  export class ColorTexture extends Texture {
48
- public override type: TextureType = TextureType.color;
48
+ override readonly type = TextureType.color as const;
49
+ public props: Required<ColorTextureProps>;
49
50
 
50
- props: Required<ColorTextureProps>;
51
-
52
- constructor(txManager: CoreTextureManager, props?: ColorTextureProps) {
51
+ constructor(txManager: CoreTextureManager, props: ColorTextureProps) {
53
52
  super(txManager);
54
- this.props = ColorTexture.resolveDefaults(props || {});
53
+ this.props = ColorTexture.resolveDefaults(props);
55
54
  }
56
55
 
57
56
  get color() {
@@ -77,8 +76,6 @@ export class ColorTexture extends Texture {
77
76
  pixelData[3] = (this.color >>> 24) & 0xff; // Alpha
78
77
  }
79
78
 
80
- this.setState('fetched', { width: 1, height: 1 });
81
-
82
79
  return {
83
80
  data: pixelData,
84
81
  premultiplyAlpha: true,
@@ -107,6 +107,13 @@ export interface ImageTextureProps {
107
107
  * @default null
108
108
  */
109
109
  sy?: number | null;
110
+
111
+ /**
112
+ * Maximum number of times to retry loading the image if it fails.
113
+ *
114
+ * @default 5
115
+ */
116
+ maxRetryCount?: number | null;
110
117
  }
111
118
 
112
119
  /**
@@ -124,13 +131,14 @@ export interface ImageTextureProps {
124
131
  * {@link ImageTextureProps.premultiplyAlpha} prop to `false`.
125
132
  */
126
133
  export class ImageTexture extends Texture {
134
+ override readonly type = TextureType.image as const;
127
135
  public props: Required<ImageTextureProps>;
128
136
 
129
- public override type: TextureType = TextureType.image;
130
-
131
137
  constructor(txManager: CoreTextureManager, props: ImageTextureProps) {
138
+ const resolvedProps = ImageTexture.resolveDefaults(props);
132
139
  super(txManager);
133
- this.props = ImageTexture.resolveDefaults(props);
140
+ this.props = resolvedProps;
141
+ this.maxRetryCount = props.maxRetryCount as number;
134
142
  }
135
143
 
136
144
  hasAlphaChannel(mimeType: string) {
@@ -147,14 +155,19 @@ export class ImageTexture extends Texture {
147
155
  return new Promise<{
148
156
  data: HTMLImageElement | null;
149
157
  premultiplyAlpha: boolean;
150
- }>((resolve) => {
158
+ }>((resolve, reject) => {
151
159
  img.onload = () => {
152
160
  resolve({ data: img, premultiplyAlpha: hasAlpha });
153
161
  };
154
162
 
155
- img.onerror = () => {
156
- console.warn('Image loading failed, returning fallback object.');
157
- resolve({ data: null, premultiplyAlpha: hasAlpha });
163
+ img.onerror = (err) => {
164
+ const errorMessage =
165
+ err instanceof Error
166
+ ? err.message
167
+ : err instanceof Event
168
+ ? `Image loading failed for ${img.src}`
169
+ : 'Unknown image loading error';
170
+ reject(new Error(`Image loading failed: ${errorMessage}`));
158
171
  };
159
172
 
160
173
  if (src instanceof Blob) {
@@ -181,28 +194,40 @@ export class ImageTexture extends Texture {
181
194
 
182
195
  if (imageBitmapSupported.full === true && sw !== null && sh !== null) {
183
196
  // createImageBitmap with crop
184
- const bitmap = await createImageBitmap(blob, sx || 0, sy || 0, sw, sh, {
185
- premultiplyAlpha: hasAlphaChannel ? 'premultiply' : 'none',
186
- colorSpaceConversion: 'none',
187
- imageOrientation: 'none',
188
- });
189
- return { data: bitmap, premultiplyAlpha: hasAlphaChannel };
197
+ try {
198
+ const bitmap = await createImageBitmap(blob, sx || 0, sy || 0, sw, sh, {
199
+ premultiplyAlpha: hasAlphaChannel ? 'premultiply' : 'none',
200
+ colorSpaceConversion: 'none',
201
+ imageOrientation: 'none',
202
+ });
203
+ return { data: bitmap, premultiplyAlpha: hasAlphaChannel };
204
+ } catch (error) {
205
+ throw new Error(`Failed to create image bitmap with crop: ${error}`);
206
+ }
190
207
  } else if (imageBitmapSupported.basic === true) {
191
208
  // basic createImageBitmap without options or crop
192
209
  // this is supported for Chrome v50 to v52/54 that doesn't support options
193
- return {
194
- data: await createImageBitmap(blob),
195
- premultiplyAlpha: hasAlphaChannel,
196
- };
210
+ try {
211
+ return {
212
+ data: await createImageBitmap(blob),
213
+ premultiplyAlpha: hasAlphaChannel,
214
+ };
215
+ } catch (error) {
216
+ throw new Error(`Failed to create basic image bitmap: ${error}`);
217
+ }
197
218
  }
198
219
 
199
220
  // default createImageBitmap without crop but with options
200
- const bitmap = await createImageBitmap(blob, {
201
- premultiplyAlpha: hasAlphaChannel ? 'premultiply' : 'none',
202
- colorSpaceConversion: 'none',
203
- imageOrientation: 'none',
204
- });
205
- return { data: bitmap, premultiplyAlpha: hasAlphaChannel };
221
+ try {
222
+ const bitmap = await createImageBitmap(blob, {
223
+ premultiplyAlpha: hasAlphaChannel ? 'premultiply' : 'none',
224
+ colorSpaceConversion: 'none',
225
+ imageOrientation: 'none',
226
+ });
227
+ return { data: bitmap, premultiplyAlpha: hasAlphaChannel };
228
+ } catch (error) {
229
+ throw new Error(`Failed to create image bitmap with options: ${error}`);
230
+ }
206
231
  }
207
232
 
208
233
  async loadImage(src: string) {
@@ -214,14 +239,18 @@ export class ImageTexture extends Texture {
214
239
  this.txManager.hasWorker === true &&
215
240
  this.txManager.imageWorkerManager !== null
216
241
  ) {
217
- return this.txManager.imageWorkerManager.getImage(
218
- src,
219
- premultiplyAlpha,
220
- sx,
221
- sy,
222
- sw,
223
- sh,
224
- );
242
+ try {
243
+ return this.txManager.imageWorkerManager.getImage(
244
+ src,
245
+ premultiplyAlpha,
246
+ sx,
247
+ sy,
248
+ sw,
249
+ sh,
250
+ );
251
+ } catch (error) {
252
+ throw new Error(`Failed to load image via worker: ${error}`);
253
+ }
225
254
  }
226
255
 
227
256
  let blob;
@@ -229,9 +258,11 @@ export class ImageTexture extends Texture {
229
258
  if (isBase64Image(src) === true) {
230
259
  blob = dataURIToBlob(src);
231
260
  } else {
232
- blob = await fetchJson(src, 'blob').then(
233
- (response) => response as Blob,
234
- );
261
+ try {
262
+ blob = (await fetchJson(src, 'blob')) as Blob;
263
+ } catch (error) {
264
+ throw new Error(`Failed to fetch image blob from ${src}: ${error}`);
265
+ }
235
266
  }
236
267
 
237
268
  return this.createImageBitmap(blob, premultiplyAlpha, sx, sy, sw, sh);
@@ -258,23 +289,6 @@ export class ImageTexture extends Texture {
258
289
  };
259
290
  }
260
291
 
261
- let width, height;
262
- // check if resp.data is typeof Uint8ClampedArray else
263
- // use resp.data.width and resp.data.height
264
- if (resp.data instanceof Uint8Array) {
265
- width = this.props.width ?? 0;
266
- height = this.props.height ?? 0;
267
- } else {
268
- width = resp.data?.width ?? (this.props.width || 0);
269
- height = resp.data?.height ?? (this.props.height || 0);
270
- }
271
-
272
- // we're loaded!
273
- this.setState('fetched', {
274
- width,
275
- height,
276
- });
277
-
278
292
  return {
279
293
  data: resp.data,
280
294
  premultiplyAlpha: this.props.premultiplyAlpha ?? true,
@@ -393,6 +407,7 @@ export class ImageTexture extends Texture {
393
407
  sy: props.sy ?? null,
394
408
  sw: props.sw ?? null,
395
409
  sh: props.sh ?? null,
410
+ maxRetryCount: props.maxRetryCount ?? 5,
396
411
  };
397
412
  }
398
413
 
@@ -75,7 +75,7 @@ export class NoiseTexture extends Texture {
75
75
  pixelData8[i + 3] = 255;
76
76
  }
77
77
 
78
- this.setState('fetched');
78
+ // Noise texture data ready - dimensions will be set during upload
79
79
 
80
80
  return {
81
81
  data: new ImageData(pixelData8, width, height),
@@ -64,7 +64,7 @@ export class RenderTexture extends Texture {
64
64
  }
65
65
 
66
66
  override async getTextureSource(): Promise<TextureData> {
67
- this.setState('fetched');
67
+ // Render texture data ready - dimensions will be set during upload
68
68
 
69
69
  return {
70
70
  data: null,
@@ -30,6 +30,8 @@ import {
30
30
  type TextureState,
31
31
  } from './Texture.js';
32
32
 
33
+ let subTextureId = 0;
34
+
33
35
  /**
34
36
  * Properties of the {@link SubTexture}
35
37
  */
@@ -83,6 +85,7 @@ export class SubTexture extends Texture {
83
85
  parentTexture: Texture;
84
86
 
85
87
  public override type: TextureType = TextureType.subTexture;
88
+ public subtextureId = `subtexture-${subTextureId++}`;
86
89
 
87
90
  constructor(txManager: CoreTextureManager, props: SubTextureProps) {
88
91
  super(txManager);
@@ -97,8 +100,8 @@ export class SubTexture extends Texture {
97
100
  // Resolve parent texture from cache or fallback to provided texture
98
101
  this.parentTexture = txManager.resolveParentTexture(this.props.texture);
99
102
 
100
- if (this.renderableOwners.size > 0) {
101
- this.parentTexture.setRenderableOwner(this, true);
103
+ if (this.renderableOwners.length > 0) {
104
+ this.parentTexture.setRenderableOwner(this.subtextureId, true);
102
105
  }
103
106
 
104
107
  // If parent texture is already loaded / failed, trigger loaded event manually
@@ -107,23 +110,17 @@ export class SubTexture extends Texture {
107
110
  // synchronous task after calling loadTexture()
108
111
  queueMicrotask(() => {
109
112
  const parentTx = this.parentTexture;
110
- if (parentTx.state === 'loaded') {
111
- this.onParentTxLoaded(parentTx, parentTx.dimensions!);
112
- } else if (parentTx.state === 'fetching') {
113
- this.onParentTxFetching();
114
- } else if (parentTx.state === 'fetched') {
115
- this.onParentTxFetched();
113
+ if (parentTx.state === 'loaded' && parentTx.dimensions) {
114
+ this.onParentTxLoaded(parentTx, parentTx.dimensions);
116
115
  } else if (parentTx.state === 'loading') {
117
116
  this.onParentTxLoading();
118
- } else if (parentTx.state === 'failed') {
119
- this.onParentTxFailed(parentTx, parentTx.error!);
117
+ } else if (parentTx.state === 'failed' && parentTx.error) {
118
+ this.onParentTxFailed(parentTx, parentTx.error);
120
119
  } else if (parentTx.state === 'freed') {
121
120
  this.onParentTxFreed();
122
121
  }
123
122
 
124
- parentTx.on('fetched', this.onParentTxFetched);
125
123
  parentTx.on('loading', this.onParentTxLoading);
126
- parentTx.on('fetching', this.onParentTxFetching);
127
124
  parentTx.on('loaded', this.onParentTxLoaded);
128
125
  parentTx.on('failed', this.onParentTxFailed);
129
126
  parentTx.on('freed', this.onParentTxFreed);
@@ -143,17 +140,6 @@ export class SubTexture extends Texture {
143
140
  this.forwardParentTxState('failed', error);
144
141
  };
145
142
 
146
- private onParentTxFetched = () => {
147
- this.forwardParentTxState('fetched', {
148
- width: this.props.width,
149
- height: this.props.height,
150
- });
151
- };
152
-
153
- private onParentTxFetching = () => {
154
- this.forwardParentTxState('fetching');
155
- };
156
-
157
143
  private onParentTxLoading = () => {
158
144
  this.forwardParentTxState('loading');
159
145
  };
@@ -171,17 +157,14 @@ export class SubTexture extends Texture {
171
157
 
172
158
  override onChangeIsRenderable(isRenderable: boolean): void {
173
159
  // Propagate the renderable owner change to the parent texture
174
- this.parentTexture.setRenderableOwner(this, isRenderable);
160
+ this.parentTexture.setRenderableOwner(this.subtextureId, isRenderable);
175
161
  }
176
162
 
177
163
  override async getTextureSource(): Promise<TextureData> {
178
- // Check if parent texture is loaded
179
- return new Promise((resolve, reject) => {
180
- this.setState('fetched');
181
- resolve({
182
- data: this.props,
183
- });
184
- });
164
+ // SubTexture data ready - dimensions will be set during upload
165
+ return {
166
+ data: this.props,
167
+ };
185
168
  }
186
169
 
187
170
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -103,9 +103,7 @@ export interface TextureData {
103
103
 
104
104
  export type TextureState =
105
105
  | 'initial' // Before anything is loaded
106
- | 'fetching' // Fetching or generating texture source
107
- | 'fetched' // Texture source is ready
108
- | 'loading' // Uploading to GPU
106
+ | 'loading' // Loading texture data and uploading to GPU
109
107
  | 'loaded' // Fully loaded and usable
110
108
  | 'failed' // Failed to load
111
109
  | 'freed'; // Released and must be reloaded
@@ -139,16 +137,10 @@ export abstract class Texture extends EventEmitter {
139
137
  private _dimensions: Dimensions | null = null;
140
138
  private _error: Error | null = null;
141
139
 
142
- /**
143
- * Texture states that are considered transitional and should be skipped during cleanup
144
- */
145
- public static readonly TRANSITIONAL_TEXTURE_STATES: readonly TextureState[] =
146
- ['fetching', 'fetched', 'loading'];
147
-
148
140
  // aggregate state
149
141
  public state: TextureState = 'initial';
150
142
 
151
- readonly renderableOwners = new Set<unknown>();
143
+ readonly renderableOwners: any[] = [];
152
144
 
153
145
  readonly renderable: boolean = false;
154
146
 
@@ -160,6 +152,34 @@ export abstract class Texture extends EventEmitter {
160
152
 
161
153
  public textureData: TextureData | null = null;
162
154
 
155
+ /**
156
+ * Memory used by this texture in bytes
157
+ *
158
+ * @remarks
159
+ * This is tracked by the TextureMemoryManager and updated when the texture
160
+ * is loaded/freed. Set to 0 when texture is not loaded.
161
+ */
162
+ public memUsed = 0;
163
+
164
+ public retryCount = 0;
165
+ public maxRetryCount: number | null = null;
166
+
167
+ /**
168
+ * Timestamp when texture was created (for startup grace period)
169
+ */
170
+ private createdAt: number = Date.now();
171
+
172
+ /**
173
+ * Flag to track if grace period has expired to avoid repeated Date.now() calls
174
+ */
175
+ private gracePeriodExpired: boolean = false;
176
+
177
+ /**
178
+ * Grace period in milliseconds to prevent premature cleanup during app startup
179
+ * This helps prevent race conditions when bounds calculation is delayed
180
+ */
181
+ private static readonly STARTUP_GRACE_PERIOD = 2000; // 2 seconds
182
+
163
183
  constructor(protected txManager: CoreTextureManager) {
164
184
  super();
165
185
  }
@@ -172,6 +192,59 @@ export abstract class Texture extends EventEmitter {
172
192
  return this._error;
173
193
  }
174
194
 
195
+ /**
196
+ * Checks if the texture is within the startup grace period.
197
+ * During this period, textures are protected from cleanup to prevent
198
+ * race conditions during app initialization.
199
+ */
200
+ isWithinStartupGracePeriod(): boolean {
201
+ // If grace period already expired, return false immediately
202
+ if (this.gracePeriodExpired) {
203
+ return false;
204
+ }
205
+
206
+ // Check if grace period has expired now
207
+ const hasExpired =
208
+ Date.now() - this.createdAt >= Texture.STARTUP_GRACE_PERIOD;
209
+
210
+ if (hasExpired) {
211
+ // Cache the result to avoid future Date.now() calls
212
+ this.gracePeriodExpired = true;
213
+ return false;
214
+ }
215
+
216
+ return true;
217
+ }
218
+
219
+ /**
220
+ * Checks if the texture can be safely cleaned up.
221
+ * Considers the renderable state, startup grace period, and renderable owners.
222
+ */
223
+ canBeCleanedUp(): boolean {
224
+ // Never cleanup if explicitly prevented
225
+ if (this.preventCleanup) {
226
+ return false;
227
+ }
228
+
229
+ // Don't cleanup if still within startup grace period
230
+ if (this.isWithinStartupGracePeriod()) {
231
+ return false;
232
+ }
233
+
234
+ // Don't cleanup if not renderable
235
+ if (this.renderable === true) {
236
+ return false;
237
+ }
238
+
239
+ // Don't cleanup if there are still renderable owners
240
+ if (this.renderableOwners.length > 0) {
241
+ return false;
242
+ }
243
+
244
+ // Safe to cleanup
245
+ return true;
246
+ }
247
+
175
248
  /**
176
249
  * Add/remove an owner to/from the Texture based on its renderability.
177
250
  *
@@ -186,33 +259,44 @@ export abstract class Texture extends EventEmitter {
186
259
  * @param owner
187
260
  * @param renderable
188
261
  */
189
- setRenderableOwner(owner: unknown, renderable: boolean): void {
190
- const oldSize = this.renderableOwners.size;
262
+ setRenderableOwner(owner: string | number, renderable: boolean): void {
263
+ const oldSize = this.renderableOwners.length;
264
+ const hasOwnerIndex = this.renderableOwners.indexOf(owner);
191
265
 
192
266
  if (renderable === true) {
193
- if (this.renderableOwners.has(owner) === false) {
267
+ if (hasOwnerIndex === -1) {
194
268
  // Add the owner to the set
195
- this.renderableOwners.add(owner);
269
+ this.renderableOwners.push(owner);
196
270
  }
197
271
 
198
- const newSize = this.renderableOwners.size;
199
- if (newSize > oldSize && newSize === 1) {
272
+ const newSize = this.renderableOwners.length;
273
+ if (oldSize !== newSize && newSize === 1) {
200
274
  (this.renderable as boolean) = true;
201
275
  this.onChangeIsRenderable?.(true);
202
276
  this.load();
203
277
  }
204
278
  } else {
205
- this.renderableOwners.delete(owner);
206
- const newSize = this.renderableOwners.size;
207
- if (newSize < oldSize && newSize === 0) {
279
+ if (hasOwnerIndex !== -1) {
280
+ this.renderableOwners.splice(hasOwnerIndex, 1);
281
+ }
282
+
283
+ const newSize = this.renderableOwners.length;
284
+ if (oldSize !== newSize && newSize === 0) {
208
285
  (this.renderable as boolean) = false;
209
286
  this.onChangeIsRenderable?.(false);
210
- this.txManager.orphanTexture(this);
287
+
288
+ // note, not doing a cleanup here, cleanup is managed by the Stage/TextureMemoryManager
289
+ // when it deems appropriate based on memory pressure
211
290
  }
212
291
  }
213
292
  }
214
293
 
215
294
  load(): void {
295
+ if (this.maxRetryCount !== null && this.retryCount > this.maxRetryCount) {
296
+ // We've exceeded the max retry count, do not attempt to load again
297
+ return;
298
+ }
299
+
216
300
  this.txManager.loadTexture(this);
217
301
  }
218
302
 
@@ -251,6 +335,18 @@ export abstract class Texture extends EventEmitter {
251
335
  this.ctxTexture?.free();
252
336
  }
253
337
 
338
+ /**
339
+ * Release the texture data and core context texture for this Texture without changing state.
340
+ *
341
+ * @remarks
342
+ * The ctxTexture is created by the renderer and lives on the GPU.
343
+ */
344
+ release(): void {
345
+ this.ctxTexture?.release();
346
+ this.ctxTexture = undefined;
347
+ this.freeTextureData();
348
+ }
349
+
254
350
  /**
255
351
  * Destroy the texture.
256
352
  *
@@ -276,7 +372,9 @@ export abstract class Texture extends EventEmitter {
276
372
  * e.g. ImageData that is downloaded from a URL.
277
373
  */
278
374
  freeTextureData(): void {
279
- this.textureData = null;
375
+ queueMicrotask(() => {
376
+ this.textureData = null;
377
+ });
280
378
  }
281
379
 
282
380
  public setState(
@@ -289,6 +387,9 @@ export abstract class Texture extends EventEmitter {
289
387
 
290
388
  let payload: Error | Dimensions | null = null;
291
389
  if (state === 'loaded') {
390
+ // Clear any previous error when successfully loading
391
+ this._error = null;
392
+
292
393
  if (
293
394
  errorOrDimensions !== undefined &&
294
395
  'width' in errorOrDimensions === true &&
@@ -303,6 +404,22 @@ export abstract class Texture extends EventEmitter {
303
404
  } else if (state === 'failed') {
304
405
  this._error = errorOrDimensions as Error;
305
406
  payload = this._error;
407
+
408
+ // increment the retry count for the texture
409
+ // this is used to compare against maxRetryCount, if set
410
+ // to determine if we should try loading again
411
+ this.retryCount += 1;
412
+
413
+ queueMicrotask(() => {
414
+ this.release();
415
+ });
416
+ } else if (state === 'loading') {
417
+ // Clear error and reset dimensions when starting to load
418
+ // This ensures stale dimensions from previous loads don't persist
419
+ this._error = null;
420
+ this._dimensions = null;
421
+ } else {
422
+ this._error = null;
306
423
  }
307
424
 
308
425
  // emit the new state
@@ -373,4 +490,16 @@ export abstract class Texture extends EventEmitter {
373
490
  ): Record<string, unknown> {
374
491
  return {};
375
492
  }
493
+
494
+ /**
495
+ * Retry the texture by resetting retryCount and setting state to 'initial'.
496
+ *
497
+ * @remarks
498
+ * This allows the texture to be loaded again.
499
+ */
500
+ public retry(): void {
501
+ this.release();
502
+ this.retryCount = 0;
503
+ this.load();
504
+ }
376
505
  }