@lightningjs/renderer 2.14.3 → 2.15.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 (33) hide show
  1. package/dist/src/core/CoreTextNode.js +2 -2
  2. package/dist/src/core/CoreTextNode.js.map +1 -1
  3. package/dist/src/core/CoreTextureManager.d.ts +3 -2
  4. package/dist/src/core/CoreTextureManager.js +29 -10
  5. package/dist/src/core/CoreTextureManager.js.map +1 -1
  6. package/dist/src/core/Stage.d.ts +20 -0
  7. package/dist/src/core/Stage.js +31 -2
  8. package/dist/src/core/Stage.js.map +1 -1
  9. package/dist/src/core/platform.js +29 -3
  10. package/dist/src/core/platform.js.map +1 -1
  11. package/dist/src/core/renderers/CoreContextTexture.d.ts +1 -1
  12. package/dist/src/core/renderers/canvas/CanvasCoreTexture.d.ts +1 -1
  13. package/dist/src/core/renderers/canvas/CanvasCoreTexture.js +7 -6
  14. package/dist/src/core/renderers/canvas/CanvasCoreTexture.js.map +1 -1
  15. package/dist/src/core/renderers/webgl/WebGlCoreCtxRenderTexture.js +3 -0
  16. package/dist/src/core/renderers/webgl/WebGlCoreCtxRenderTexture.js.map +1 -1
  17. package/dist/src/core/renderers/webgl/WebGlCoreCtxTexture.d.ts +5 -6
  18. package/dist/src/core/renderers/webgl/WebGlCoreCtxTexture.js +30 -18
  19. package/dist/src/core/renderers/webgl/WebGlCoreCtxTexture.js.map +1 -1
  20. package/dist/src/main-api/Renderer.d.ts +46 -0
  21. package/dist/src/main-api/Renderer.js +39 -1
  22. package/dist/src/main-api/Renderer.js.map +1 -1
  23. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  24. package/package.json +1 -1
  25. package/src/core/CoreTextNode.ts +2 -2
  26. package/src/core/CoreTextureManager.ts +27 -10
  27. package/src/core/Stage.ts +36 -2
  28. package/src/core/platform.ts +40 -3
  29. package/src/core/renderers/CoreContextTexture.ts +1 -1
  30. package/src/core/renderers/canvas/CanvasCoreTexture.ts +11 -11
  31. package/src/core/renderers/webgl/WebGlCoreCtxRenderTexture.ts +5 -0
  32. package/src/core/renderers/webgl/WebGlCoreCtxTexture.ts +52 -40
  33. package/src/main-api/Renderer.ts +57 -1
@@ -389,12 +389,18 @@ export class CoreTextureManager extends EventEmitter {
389
389
 
390
390
  // For non-image textures, upload immediately
391
391
  if (texture.type !== TextureType.image) {
392
- this.uploadTexture(texture);
392
+ this.uploadTexture(texture).catch((err) => {
393
+ console.error('Failed to upload non-image texture:', err);
394
+ texture.setState('failed');
395
+ });
393
396
  } else {
394
397
  // For image textures, queue for throttled upload
395
398
  // If it's a priority texture, upload it immediately
396
399
  if (priority === true) {
397
- this.uploadTexture(texture);
400
+ this.uploadTexture(texture).catch((err) => {
401
+ console.error('Failed to upload priority texture:', err);
402
+ texture.setState('failed');
403
+ });
398
404
  } else {
399
405
  this.enqueueUploadTexture(texture);
400
406
  }
@@ -410,8 +416,9 @@ export class CoreTextureManager extends EventEmitter {
410
416
  * Upload a texture to the GPU
411
417
  *
412
418
  * @param texture Texture to upload
419
+ * @returns Promise that resolves when the texture is fully loaded
413
420
  */
414
- uploadTexture(texture: Texture): void {
421
+ async uploadTexture(texture: Texture): Promise<void> {
415
422
  if (
416
423
  this.stage.txMemManager.doNotExceedCriticalThreshold === true &&
417
424
  this.stage.txMemManager.criticalCleanupRequested === true
@@ -427,7 +434,7 @@ export class CoreTextureManager extends EventEmitter {
427
434
  return;
428
435
  }
429
436
 
430
- coreContext.load();
437
+ await coreContext.load();
431
438
  }
432
439
 
433
440
  /**
@@ -442,7 +449,7 @@ export class CoreTextureManager extends EventEmitter {
442
449
  *
443
450
  * @param maxProcessingTime - The maximum processing time in milliseconds
444
451
  */
445
- processSome(maxProcessingTime: number): void {
452
+ async processSome(maxProcessingTime: number): Promise<void> {
446
453
  if (this.initialized === false) {
447
454
  return;
448
455
  }
@@ -456,18 +463,28 @@ export class CoreTextureManager extends EventEmitter {
456
463
  ) {
457
464
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
458
465
  const texture = this.priorityQueue.pop()!;
459
- texture.getTextureData().then(() => {
460
- this.uploadTexture(texture);
461
- });
466
+ try {
467
+ await texture.getTextureData();
468
+ await this.uploadTexture(texture);
469
+ } catch (error) {
470
+ console.error('Failed to process priority texture:', error);
471
+ // Continue with next texture instead of stopping entire queue
472
+ }
462
473
  }
463
474
 
464
- // Process uploads
475
+ // Process uploads - await each upload to prevent GPU overload
465
476
  while (
466
477
  this.uploadTextureQueue.length > 0 &&
467
478
  getTimeStamp() - startTime < maxProcessingTime
468
479
  ) {
469
480
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
470
- this.uploadTexture(this.uploadTextureQueue.shift()!);
481
+ const texture = this.uploadTextureQueue.shift()!;
482
+ try {
483
+ await this.uploadTexture(texture);
484
+ } catch (error) {
485
+ console.error('Failed to upload texture:', error);
486
+ // Continue with next texture instead of stopping entire queue
487
+ }
471
488
  }
472
489
  }
473
490
 
package/src/core/Stage.ts CHANGED
@@ -68,6 +68,7 @@ export interface StageOptions {
68
68
  canvas: HTMLCanvasElement | OffscreenCanvas;
69
69
  clearColor: number;
70
70
  fpsUpdateInterval: number;
71
+ targetFPS: number;
71
72
  enableContextSpy: boolean;
72
73
  forceWebGL2: boolean;
73
74
  numImageWorkers: number;
@@ -111,6 +112,16 @@ export class Stage {
111
112
  public readonly strictBounds: boolean;
112
113
  public readonly defaultTexture: Texture | null = null;
113
114
 
115
+ /**
116
+ * Target frame time in milliseconds (calculated from targetFPS)
117
+ *
118
+ * @remarks
119
+ * This is pre-calculated to avoid recalculating on every frame.
120
+ * - 0 means no throttling (use display refresh rate)
121
+ * - >0 means throttle to this frame time (1000 / targetFPS)
122
+ */
123
+ public targetFrameTime: number = 0;
124
+
114
125
  /**
115
126
  * Renderer Event Bus for the Stage to emit events onto
116
127
  *
@@ -156,6 +167,10 @@ export class Stage {
156
167
  } = options;
157
168
 
158
169
  this.eventBus = options.eventBus;
170
+
171
+ // Calculate target frame time from targetFPS option
172
+ this.targetFrameTime = options.targetFPS > 0 ? 1000 / options.targetFPS : 0;
173
+
159
174
  this.txManager = new CoreTextureManager(this, {
160
175
  numImageWorkers,
161
176
  createImageBitmapSupport,
@@ -292,6 +307,20 @@ export class Stage {
292
307
  this.renderRequested = true;
293
308
  }
294
309
 
310
+ /**
311
+ * Update the target frame time based on the current targetFPS setting
312
+ *
313
+ * @remarks
314
+ * This should be called whenever the targetFPS option is changed
315
+ * to ensure targetFrameTime stays in sync.
316
+ * targetFPS of 0 means no throttling (targetFrameTime = 0)
317
+ * targetFPS > 0 means throttle to 1000/targetFPS milliseconds
318
+ */
319
+ updateTargetFrameTime() {
320
+ this.targetFrameTime =
321
+ this.options.targetFPS > 0 ? 1000 / this.options.targetFPS : 0;
322
+ }
323
+
295
324
  updateFrameTime() {
296
325
  const newFrameTime = getTimeStamp();
297
326
  this.lastFrameTime = this.currentFrameTime;
@@ -372,8 +401,13 @@ export class Stage {
372
401
  this.root.update(this.deltaTime, this.root.clippingRect);
373
402
  }
374
403
 
375
- // Process some textures
376
- this.txManager.processSome(this.options.textureProcessingTimeLimit);
404
+ // Process some textures asynchronously but don't block the frame
405
+ // Use a background task to prevent frame drops
406
+ this.txManager
407
+ .processSome(this.options.textureProcessingTimeLimit)
408
+ .catch((err) => {
409
+ console.error('Error processing textures:', err);
410
+ });
377
411
 
378
412
  // Reset render operations and clear the canvas
379
413
  renderer.reset();
@@ -24,14 +24,38 @@ import type { Stage } from './Stage.js';
24
24
  */
25
25
  export const startLoop = (stage: Stage) => {
26
26
  let isIdle = false;
27
- const runLoop = () => {
27
+ let lastFrameTime = 0;
28
+
29
+ const runLoop = (currentTime: number = 0) => {
30
+ const targetFrameTime = stage.targetFrameTime;
31
+
32
+ // Check if we should throttle this frame
33
+ if (targetFrameTime > 0 && currentTime - lastFrameTime < targetFrameTime) {
34
+ // Too early for next frame, schedule with setTimeout for precise timing
35
+ const delay = targetFrameTime - (currentTime - lastFrameTime);
36
+ setTimeout(() => requestAnimationFrame(runLoop), delay);
37
+ return;
38
+ }
39
+
40
+ lastFrameTime = currentTime;
41
+
28
42
  stage.updateFrameTime();
29
43
  stage.updateAnimations();
30
44
 
31
45
  if (!stage.hasSceneUpdates()) {
32
46
  // We still need to calculate the fps else it looks like the app is frozen
33
47
  stage.calculateFps();
34
- setTimeout(runLoop, 16.666666666666668);
48
+
49
+ if (targetFrameTime > 0) {
50
+ // Use setTimeout for throttled idle frames
51
+ setTimeout(
52
+ () => requestAnimationFrame(runLoop),
53
+ Math.max(targetFrameTime, 16.666666666666668),
54
+ );
55
+ } else {
56
+ // Use standard idle timeout when not throttling
57
+ setTimeout(() => requestAnimationFrame(runLoop), 16.666666666666668);
58
+ }
35
59
 
36
60
  if (!isIdle) {
37
61
  stage.eventBus.emit('idle');
@@ -49,8 +73,21 @@ export const startLoop = (stage: Stage) => {
49
73
  isIdle = false;
50
74
  stage.drawFrame();
51
75
  stage.flushFrameEvents();
52
- requestAnimationFrame(runLoop);
76
+
77
+ // Schedule next frame
78
+ if (targetFrameTime > 0) {
79
+ // Use setTimeout + rAF combination for precise FPS control
80
+ const nextFrameDelay = Math.max(
81
+ 0,
82
+ targetFrameTime - (performance.now() - currentTime),
83
+ );
84
+ setTimeout(() => requestAnimationFrame(runLoop), nextFrameDelay);
85
+ } else {
86
+ // Use standard rAF when not throttling
87
+ requestAnimationFrame(runLoop);
88
+ }
53
89
  };
90
+
54
91
  requestAnimationFrame(runLoop);
55
92
  };
56
93
 
@@ -34,7 +34,7 @@ export abstract class CoreContextTexture {
34
34
  this.memManager.setTextureMemUse(this.textureSource, byteSize);
35
35
  }
36
36
 
37
- abstract load(): void;
37
+ abstract load(): Promise<void>;
38
38
  abstract free(): void;
39
39
 
40
40
  get renderable(): boolean {
@@ -35,19 +35,19 @@ export class CanvasCoreTexture extends CoreContextTexture {
35
35
  }
36
36
  | undefined;
37
37
 
38
- load(): void {
38
+ async load(): Promise<void> {
39
39
  this.textureSource.setState('loading');
40
40
 
41
- this.onLoadRequest()
42
- .then((size) => {
43
- this.textureSource.setState('loaded', size);
44
- this.textureSource.freeTextureData();
45
- this.updateMemSize();
46
- })
47
- .catch((err) => {
48
- this.textureSource.setState('failed', err as Error);
49
- this.textureSource.freeTextureData();
50
- });
41
+ try {
42
+ const size = await this.onLoadRequest();
43
+ this.textureSource.setState('loaded', size);
44
+ this.textureSource.freeTextureData();
45
+ this.updateMemSize();
46
+ } catch (err) {
47
+ this.textureSource.setState('failed', err as Error);
48
+ this.textureSource.freeTextureData();
49
+ throw err;
50
+ }
51
51
  }
52
52
 
53
53
  free(): void {
@@ -41,6 +41,11 @@ export class WebGlCoreCtxRenderTexture extends WebGlCoreCtxTexture {
41
41
  const { glw } = this;
42
42
  const nativeTexture = (this._nativeCtxTexture =
43
43
  this.createNativeCtxTexture());
44
+
45
+ if (!nativeTexture) {
46
+ throw new Error('Failed to create native texture for RenderTexture');
47
+ }
48
+
44
49
  const { width, height } = this.textureSource;
45
50
 
46
51
  // Create Framebuffer object
@@ -78,54 +78,65 @@ export class WebGlCoreCtxTexture extends CoreContextTexture {
78
78
  * to force the texture to be pre-loaded prior to accessing the ctxTexture
79
79
  * property.
80
80
  */
81
- load() {
82
- // If the texture is already loading or loaded, don't load it again.
81
+ async load(): Promise<void> {
82
+ // If the texture is already loading or loaded, return resolved promise
83
83
  if (this.state === 'loading' || this.state === 'loaded') {
84
- return;
84
+ return Promise.resolve();
85
85
  }
86
86
 
87
87
  this.state = 'loading';
88
88
  this.textureSource.setState('loading');
89
+
90
+ // Await the native texture creation to ensure GPU buffer is fully allocated
89
91
  this._nativeCtxTexture = this.createNativeCtxTexture();
90
92
 
91
93
  if (this._nativeCtxTexture === null) {
92
94
  this.state = 'failed';
93
- this.textureSource.setState(
94
- 'failed',
95
- new Error('Could not create WebGL Texture'),
96
- );
95
+ const error = new Error('Could not create WebGL Texture');
96
+ this.textureSource.setState('failed', error);
97
97
  console.error('Could not create WebGL Texture');
98
- return;
98
+ throw error;
99
99
  }
100
100
 
101
- this.onLoadRequest()
102
- .then(({ width, height }) => {
103
- // If the texture has been freed while loading, return early.
104
- if (this.state === 'freed') {
105
- return;
106
- }
107
-
108
- this.state = 'loaded';
109
- this._w = width;
110
- this._h = height;
111
- // Update the texture source's width and height so that it can be used
112
- // for rendering.
113
- this.textureSource.setState('loaded', { width, height });
114
-
115
- // cleanup source texture data
116
- this.textureSource.freeTextureData();
117
- })
118
- .catch((err) => {
119
- // If the texture has been freed while loading, return early.
120
- if (this.state === 'freed') {
121
- return;
122
- }
123
-
124
- this.state = 'failed';
125
- this.textureSource.setState('failed', err);
101
+ try {
102
+ const { width, height } = await this.onLoadRequest();
103
+
104
+ // If the texture has been freed while loading, return early.
105
+ // Type assertion needed because state could change during async operations
106
+ if ((this.state as string) === 'freed') {
107
+ return;
108
+ }
109
+
110
+ this.state = 'loaded';
111
+ this._w = width;
112
+ this._h = height;
113
+ // Update the texture source's width and height so that it can be used
114
+ // for rendering.
115
+ this.textureSource.setState('loaded', { width, height });
116
+
117
+ // cleanup source texture data next tick
118
+ // This is done using queueMicrotask to ensure it runs after the current
119
+ // event loop tick, allowing the texture to be fully loaded and bound
120
+ // to the GL context before freeing the source data.
121
+ // This is important to avoid issues with the texture data being
122
+ // freed while the texture is still being loaded or used.
123
+ queueMicrotask(() => {
126
124
  this.textureSource.freeTextureData();
127
- console.error(err);
128
125
  });
126
+ } catch (err: unknown) {
127
+ // If the texture has been freed while loading, return early.
128
+ // Type assertion needed because state could change during async operations
129
+ if ((this.state as string) === 'freed') {
130
+ return;
131
+ }
132
+
133
+ this.state = 'failed';
134
+ const error = err instanceof Error ? err : new Error(String(err));
135
+ this.textureSource.setState('failed', error);
136
+ this.textureSource.freeTextureData();
137
+ console.error(err);
138
+ throw error; // Re-throw to propagate the error
139
+ }
129
140
  }
130
141
 
131
142
  /**
@@ -268,17 +279,17 @@ export class WebGlCoreCtxTexture extends CoreContextTexture {
268
279
  }
269
280
 
270
281
  /**
271
- * Create native context texture
282
+ * Create native context texture asynchronously
272
283
  *
273
284
  * @remarks
274
- * When this method returns the returned texture will be bound to the GL context state.
285
+ * When this method resolves, the returned texture will be bound to the GL context state
286
+ * and fully ready for use. This ensures proper GPU resource allocation timing.
275
287
  *
276
- * @param width
277
- * @param height
278
- * @returns
288
+ * @returns Promise that resolves to the native WebGL texture or null on failure
279
289
  */
280
- protected createNativeCtxTexture() {
290
+ protected createNativeCtxTexture(): WebGLTexture | null {
281
291
  const { glw } = this;
292
+
282
293
  const nativeTexture = glw.createTexture();
283
294
  if (!nativeTexture) {
284
295
  return null;
@@ -296,6 +307,7 @@ export class WebGlCoreCtxTexture extends CoreContextTexture {
296
307
  // texture wrapping method
297
308
  glw.texParameteri(glw.TEXTURE_WRAP_S, glw.CLAMP_TO_EDGE);
298
309
  glw.texParameteri(glw.TEXTURE_WRAP_T, glw.CLAMP_TO_EDGE);
310
+
299
311
  return nativeTexture;
300
312
  }
301
313
  }
@@ -157,6 +157,22 @@ export interface RendererMainSettings {
157
157
  */
158
158
  fpsUpdateInterval?: number;
159
159
 
160
+ /**
161
+ * Target FPS for the global render loop
162
+ *
163
+ * @remarks
164
+ * Controls the maximum frame rate of the entire rendering system.
165
+ * When set to 0, no throttling is applied (use display refresh rate).
166
+ * When set to a positive number, the global requestAnimationFrame loop
167
+ * will be throttled to this target FPS, affecting all animations and rendering.
168
+ *
169
+ * This provides global performance control for the entire application,
170
+ * useful for managing performance on lower-end devices.
171
+ *
172
+ * @defaultValue `0` (no throttling, use display refresh rate)
173
+ */
174
+ targetFPS?: number;
175
+
160
176
  /**
161
177
  * Include context call (i.e. WebGL) information in FPS updates
162
178
  *
@@ -403,6 +419,7 @@ export class RendererMain extends EventEmitter {
403
419
  settings.devicePhysicalPixelRatio || window.devicePixelRatio,
404
420
  clearColor: settings.clearColor ?? 0x00000000,
405
421
  fpsUpdateInterval: settings.fpsUpdateInterval || 0,
422
+ targetFPS: settings.targetFPS || 0,
406
423
  numImageWorkers:
407
424
  settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2,
408
425
  enableContextSpy: settings.enableContextSpy ?? false,
@@ -412,7 +429,7 @@ export class RendererMain extends EventEmitter {
412
429
  quadBufferSize: settings.quadBufferSize ?? 4 * 1024 * 1024,
413
430
  fontEngines: settings.fontEngines,
414
431
  strictBounds: settings.strictBounds ?? true,
415
- textureProcessingTimeLimit: settings.textureProcessingTimeLimit || 10,
432
+ textureProcessingTimeLimit: settings.textureProcessingTimeLimit || 42,
416
433
  canvas: settings.canvas || document.createElement('canvas'),
417
434
  createImageBitmapSupport: settings.createImageBitmapSupport || 'full',
418
435
  };
@@ -449,6 +466,7 @@ export class RendererMain extends EventEmitter {
449
466
  enableContextSpy: this.settings.enableContextSpy,
450
467
  forceWebGL2: this.settings.forceWebGL2,
451
468
  fpsUpdateInterval: this.settings.fpsUpdateInterval,
469
+ targetFPS: this.settings.targetFPS,
452
470
  numImageWorkers: this.settings.numImageWorkers,
453
471
  renderEngine: this.settings.renderEngine,
454
472
  textureMemory: resolvedTxSettings,
@@ -748,4 +766,42 @@ export class RendererMain extends EventEmitter {
748
766
  setClearColor(color: number) {
749
767
  this.stage.setClearColor(color);
750
768
  }
769
+
770
+ /**
771
+ * Gets the target FPS for the global render loop
772
+ *
773
+ * @returns The current target FPS (0 means no throttling)
774
+ *
775
+ * @remarks
776
+ * This controls the maximum frame rate of the entire rendering system.
777
+ * When 0, the system runs at display refresh rate.
778
+ */
779
+ get targetFPS(): number {
780
+ return this.stage.options.targetFPS;
781
+ }
782
+
783
+ /**
784
+ * Sets the target FPS for the global render loop
785
+ *
786
+ * @param fps - The target FPS to set for the global render loop.
787
+ * Set to 0 or a negative value to disable throttling.
788
+ *
789
+ * @remarks
790
+ * This setting affects the entire rendering system immediately.
791
+ * All animations, rendering, and frame updates will be throttled
792
+ * to this target FPS. Provides global performance control.
793
+ *
794
+ * @example
795
+ * ```typescript
796
+ * // Set global target to 30fps for better performance
797
+ * renderer.targetFPS = 30;
798
+ *
799
+ * // Disable global throttling (use display refresh rate)
800
+ * renderer.targetFPS = 0;
801
+ * ```
802
+ */
803
+ set targetFPS(fps: number) {
804
+ this.stage.options.targetFPS = fps > 0 ? fps : 0;
805
+ this.stage.updateTargetFrameTime();
806
+ }
751
807
  }