@solidtv/renderer 1.2.8 → 1.2.10

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.
@@ -295,6 +295,10 @@ export interface CoreNodeProps {
295
295
  * its descendants from overflowing outside of the Node's x/y/width/height
296
296
  * bounds.
297
297
  *
298
+ * Pass `true` to clip exactly to the Node's bounds, or pass a
299
+ * `[top, right, bottom, left]` tuple to expand the clip rectangle outward
300
+ * by the given pixel amounts on each side (negative values inset it).
301
+ *
298
302
  * For WebGL, clipping is implemented using the high-performance WebGL
299
303
  * operation scissor. As a consequence, clipping does not work for
300
304
  * non-rectangular areas. So, if the element is rotated
@@ -305,7 +309,7 @@ export interface CoreNodeProps {
305
309
  *
306
310
  * @default `false`
307
311
  */
308
- clipping: boolean;
312
+ clipping: boolean | [number, number, number, number];
309
313
  /**
310
314
  * The color of the Node.
311
315
  *
@@ -804,13 +808,14 @@ export class CoreNode extends EventEmitter {
804
808
  public renderBound?: Bound;
805
809
  public strictBound?: Bound;
806
810
  public preloadBound?: Bound;
807
- public clippingRect: RectWithValid = {
808
- x: 0,
809
- y: 0,
810
- w: 0,
811
- h: 0,
812
- valid: false,
813
- };
811
+ /**
812
+ * Points at the shared `NO_CLIPPING_RECT` until this node actually
813
+ * participates in clipping (either it clips, or an ancestor's clip rect
814
+ * propagates down). Clipping is rare across the scene graph, so most nodes
815
+ * never allocate their own rect — `calculateClippingRect` swaps in a private
816
+ * object lazily the first time one is needed.
817
+ */
818
+ public clippingRect: RectWithValid = NO_CLIPPING_RECT;
814
819
  public textureCoords?: TextureCoords;
815
820
  public updateShaderUniforms: boolean = false;
816
821
  public isRenderable = false;
@@ -1366,7 +1371,7 @@ export class CoreNode extends EventEmitter {
1366
1371
  childUpdateType |= UpdateType.Global;
1367
1372
  }
1368
1373
 
1369
- if (this.clipping === true) {
1374
+ if (this.props.clipping !== false) {
1370
1375
  updateType |= UpdateType.Clipping | UpdateType.RenderBounds;
1371
1376
  childUpdateType |= UpdateType.RenderBounds;
1372
1377
  }
@@ -1669,14 +1674,31 @@ export class CoreNode extends EventEmitter {
1669
1674
  }
1670
1675
 
1671
1676
  // clipping is enabled and we are in bounds create our own bounds
1672
- const { x, y, w, h } = this.props;
1677
+ const { x, y, w, h, clipping } = this.props;
1673
1678
 
1674
1679
  // Pick the global transform if available, otherwise use the local transform
1675
1680
  // global transform is only available if the node in an RTT chain
1676
1681
  const { tx, ty } = this.sceneGlobalTransform || this.globalTransform || {};
1677
1682
  const _x = tx ?? x;
1678
1683
  const _y = ty ?? y;
1679
- this.strictBound = createBound(_x, _y, _x + w, _y + h, this.strictBound);
1684
+
1685
+ let mT = 0;
1686
+ let mR = 0;
1687
+ let mB = 0;
1688
+ let mL = 0;
1689
+ if (Array.isArray(clipping) === true) {
1690
+ mT = clipping[0];
1691
+ mR = clipping[1];
1692
+ mB = clipping[2];
1693
+ mL = clipping[3];
1694
+ }
1695
+ this.strictBound = createBound(
1696
+ _x - mL,
1697
+ _y - mT,
1698
+ _x + w + mR,
1699
+ _y + h + mB,
1700
+ this.strictBound,
1701
+ );
1680
1702
 
1681
1703
  this.preloadBound = createPreloadBounds(
1682
1704
  this.strictBound,
@@ -1912,15 +1934,47 @@ export class CoreNode extends EventEmitter {
1912
1934
  * Finally, the node's parentClippingRect and clippingRect properties are updated.
1913
1935
  */
1914
1936
  calculateClippingRect(parentClippingRect: RectWithValid) {
1915
- const { clippingRect, props, globalTransform: gt } = this;
1937
+ const { props, globalTransform: gt } = this;
1916
1938
  const { clipping } = props;
1917
1939
  const isRotated = gt!.tb !== 0 || gt!.tc !== 0;
1940
+ const nodeClips = clipping !== false && isRotated === false;
1918
1941
 
1919
- if (clipping === true && isRotated === false) {
1920
- clippingRect.x = gt!.tx;
1921
- clippingRect.y = gt!.ty;
1922
- clippingRect.w = this.props.w * gt!.ta;
1923
- clippingRect.h = this.props.h * gt!.td;
1942
+ // Common case: this node doesn't clip and no ancestor clip rect needs to
1943
+ // propagate. No node-owned rect is required, so point at the shared
1944
+ // invalid default and skip the allocation entirely.
1945
+ if (nodeClips === false && parentClippingRect.valid === false) {
1946
+ this.clippingRect = NO_CLIPPING_RECT;
1947
+ return;
1948
+ }
1949
+
1950
+ // A node-owned, mutable rect is needed. Allocate one lazily the first time
1951
+ // (the default shares NO_CLIPPING_RECT, which must never be written to).
1952
+ let clippingRect = this.clippingRect;
1953
+ if (clippingRect === NO_CLIPPING_RECT) {
1954
+ clippingRect = this.clippingRect = {
1955
+ x: 0,
1956
+ y: 0,
1957
+ w: 0,
1958
+ h: 0,
1959
+ valid: false,
1960
+ };
1961
+ }
1962
+
1963
+ if (nodeClips === true) {
1964
+ let mT = 0;
1965
+ let mR = 0;
1966
+ let mB = 0;
1967
+ let mL = 0;
1968
+ if (Array.isArray(clipping) === true) {
1969
+ mT = clipping[0];
1970
+ mR = clipping[1];
1971
+ mB = clipping[2];
1972
+ mL = clipping[3];
1973
+ }
1974
+ clippingRect.x = gt!.tx - mL;
1975
+ clippingRect.y = gt!.ty - mT;
1976
+ clippingRect.w = this.props.w * gt!.ta + mL + mR;
1977
+ clippingRect.h = this.props.h * gt!.td + mT + mB;
1924
1978
  clippingRect.valid = true;
1925
1979
  } else {
1926
1980
  clippingRect.valid = false;
@@ -2421,11 +2475,14 @@ export class CoreNode extends EventEmitter {
2421
2475
  this.setUpdateType(UpdateType.RenderBounds);
2422
2476
  }
2423
2477
 
2424
- get clipping(): boolean {
2478
+ get clipping(): boolean | [number, number, number, number] {
2425
2479
  return this.props.clipping;
2426
2480
  }
2427
2481
 
2428
- set clipping(value: boolean) {
2482
+ set clipping(value: boolean | [number, number, number, number]) {
2483
+ if (this.props.clipping === value) {
2484
+ return;
2485
+ }
2429
2486
  this.props.clipping = value;
2430
2487
  this.setUpdateType(
2431
2488
  UpdateType.Clipping | UpdateType.RenderBounds | UpdateType.Children,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Tests for WebGL context-loss handling on the Stage.
3
+ *
4
+ * Covers the pragmatic core support added for low-RAM devices (Chromium 123+
5
+ * drops the GPU context when backgrounded). On loss we stop the render loop
6
+ * and emit a `contextLost` event so consumers can reload; the engine does not
7
+ * rebuild GL resources in place, so there is no restore path.
8
+ */
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { Stage } from './Stage.js';
11
+ import { EventEmitter } from '../common/EventEmitter.js';
12
+
13
+ // Build a minimal Stage-like object that exercises the context-loss methods
14
+ // without standing up the full GL-backed constructor.
15
+ function makeStage() {
16
+ const eventBus = new EventEmitter();
17
+ const stage = Object.create(Stage.prototype) as Stage;
18
+ (stage as unknown as { eventBus: EventEmitter }).eventBus = eventBus;
19
+ stage.isContextLost = false;
20
+ return { stage, eventBus };
21
+ }
22
+
23
+ describe('Stage.setContextLost', () => {
24
+ it('sets the flag and emits contextLost', () => {
25
+ const { stage, eventBus } = makeStage();
26
+ const onLost = vi.fn();
27
+ eventBus.on('contextLost', onLost);
28
+
29
+ stage.setContextLost();
30
+
31
+ expect(stage.isContextLost).toBe(true);
32
+ expect(onLost).toHaveBeenCalledTimes(1);
33
+ });
34
+
35
+ it('is idempotent — does not re-emit when already lost', () => {
36
+ const { stage, eventBus } = makeStage();
37
+ const onLost = vi.fn();
38
+ eventBus.on('contextLost', onLost);
39
+
40
+ stage.setContextLost();
41
+ stage.setContextLost();
42
+
43
+ expect(onLost).toHaveBeenCalledTimes(1);
44
+ });
45
+ });
package/src/core/Stage.ts CHANGED
@@ -119,6 +119,18 @@ export class Stage {
119
119
  */
120
120
  public readonly eventBus: EventEmitter;
121
121
 
122
+ /**
123
+ * Whether the underlying WebGL context has been lost.
124
+ *
125
+ * @remarks
126
+ * Set by the renderer's `webglcontextlost` listener. Once true it stays true:
127
+ * the engine does not rebuild GPU resources in-place, so the render loop
128
+ * stops and the supported recovery is to reload the app (see the `contextLost`
129
+ * event). This avoids issuing GL calls against a dead context, which return
130
+ * null/throw on low-RAM devices (e.g. Chromium 123+ backgrounding behaviour).
131
+ */
132
+ public isContextLost = false;
133
+
122
134
  /// State
123
135
  startTime = 0;
124
136
  deltaTime = 0;
@@ -429,6 +441,21 @@ export class Stage {
429
441
  });
430
442
  }
431
443
 
444
+ /**
445
+ * Mark the WebGL context as lost. Stops GL work and notifies consumers.
446
+ *
447
+ * @remarks
448
+ * The engine does not rebuild GPU resources in-place; consumers are expected
449
+ * to reload the app in response to the `contextLost` event.
450
+ */
451
+ setContextLost() {
452
+ if (this.isContextLost === true) {
453
+ return;
454
+ }
455
+ this.isContextLost = true;
456
+ this.eventBus.emit('contextLost');
457
+ }
458
+
432
459
  /**
433
460
  * Create default PixelTexture
434
461
  */
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Tests that the render loop stops doing GL work once the WebGL context is
3
+ * lost.
4
+ *
5
+ * When `stage.isContextLost === true`, `runLoop` must issue no GL-touching
6
+ * calls and must not reschedule itself — the engine does not rebuild GL
7
+ * resources, so recovery is via app reload.
8
+ */
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { WebPlatform } from './WebPlatform.js';
11
+ import type { Stage } from '../../Stage.js';
12
+
13
+ function makeFakeStage(isContextLost: boolean) {
14
+ return {
15
+ isContextLost,
16
+ targetFrameTime: 0,
17
+ updateFrameTime: vi.fn(),
18
+ updateAnimations: vi.fn(() => false),
19
+ hasSceneUpdates: vi.fn(() => true),
20
+ drawFrame: vi.fn(),
21
+ flushFrameEvents: vi.fn(),
22
+ calculateFps: vi.fn(),
23
+ } as unknown as Stage;
24
+ }
25
+
26
+ describe('WebPlatform render loop context-loss guard', () => {
27
+ afterEach(() => {
28
+ vi.unstubAllGlobals();
29
+ });
30
+
31
+ it('skips all GL work and does not reschedule while context is lost', () => {
32
+ let capturedLoop: ((t?: number) => void) | null = null;
33
+ const raf = vi.fn((cb: (t?: number) => void) => {
34
+ capturedLoop = cb;
35
+ return 1;
36
+ });
37
+ const setTimeoutSpy = vi.fn(
38
+ (_cb: () => void, _ms?: number) =>
39
+ 1 as unknown as ReturnType<typeof setTimeout>,
40
+ );
41
+ vi.stubGlobal('requestAnimationFrame', raf);
42
+ vi.stubGlobal('setTimeout', setTimeoutSpy);
43
+
44
+ const stage = makeFakeStage(true);
45
+ new WebPlatform().startLoop(stage);
46
+
47
+ // startLoop kicks off the first frame via requestAnimationFrame
48
+ expect(capturedLoop).not.toBeNull();
49
+ expect(raf).toHaveBeenCalledTimes(1);
50
+
51
+ // Run one iteration with the context lost
52
+ capturedLoop!(0);
53
+
54
+ // No GL-touching frame work happened
55
+ expect(stage.updateFrameTime).not.toHaveBeenCalled();
56
+ expect(stage.drawFrame).not.toHaveBeenCalled();
57
+ expect(stage.hasSceneUpdates).not.toHaveBeenCalled();
58
+
59
+ // The loop did not reschedule itself (neither RAF nor setTimeout)
60
+ expect(raf).toHaveBeenCalledTimes(1);
61
+ expect(setTimeoutSpy).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('runs frame work when the context is healthy', () => {
65
+ let capturedLoop: ((t?: number) => void) | null = null;
66
+ const raf = vi.fn((cb: (t?: number) => void) => {
67
+ capturedLoop = cb;
68
+ return 1;
69
+ });
70
+ vi.stubGlobal('requestAnimationFrame', raf);
71
+
72
+ const stage = makeFakeStage(false);
73
+ new WebPlatform().startLoop(stage);
74
+
75
+ capturedLoop!(0);
76
+
77
+ expect(stage.updateFrameTime).toHaveBeenCalledTimes(1);
78
+ expect(stage.drawFrame).toHaveBeenCalledTimes(1);
79
+ });
80
+ });
@@ -32,6 +32,13 @@ export class WebPlatform extends Platform {
32
32
  const buffer = 4;
33
33
 
34
34
  const runLoop = (currentTime: number = 0) => {
35
+ // The GL context is lost and the engine does not rebuild it in place.
36
+ // Stop the loop entirely (no reschedule) so we issue no GL calls against
37
+ // a dead context. Recovery is via app reload (see the `contextLost` event).
38
+ if (stage.isContextLost === true) {
39
+ return;
40
+ }
41
+
35
42
  const targetFrameTime = stage.targetFrameTime;
36
43
 
37
44
  // Frame Limiting logic
@@ -174,6 +174,8 @@ export class WebGlRenderer extends CoreRenderer {
174
174
  const glw = (this.glw = new WebGlContextWrapper(gl));
175
175
  glw.viewport(0, 0, options.canvas.width, options.canvas.height);
176
176
 
177
+ this.attachContextLossListeners(options.canvas);
178
+
177
179
  this.updateClearColor(this.stage.clearColor);
178
180
 
179
181
  glw.setBlend(true);
@@ -300,6 +302,32 @@ export class WebGlRenderer extends CoreRenderer {
300
302
  ]);
301
303
  }
302
304
 
305
+ /**
306
+ * Listen for WebGL context loss on the canvas.
307
+ *
308
+ * @remarks
309
+ * On low-RAM devices (e.g. Chromium 123+ after backgrounding) the GPU
310
+ * context is dropped, after which `gl.createTexture()` and friends return
311
+ * null and the engine would crash. We pause the render loop via the Stage
312
+ * flag and surface a `contextLost` event so consumers can react.
313
+ *
314
+ * We intentionally do NOT call `event.preventDefault()` (which would ask the
315
+ * browser to restore the context) and do NOT listen for
316
+ * `webglcontextrestored`: the engine cannot rebuild its GPU resources
317
+ * in-place, so the supported recovery is to reload the app. See BROWSERS.md.
318
+ */
319
+ private attachContextLossListeners(
320
+ canvas: HTMLCanvasElement | OffscreenCanvas,
321
+ ): void {
322
+ if ('addEventListener' in canvas === false) {
323
+ return;
324
+ }
325
+ const target = canvas as HTMLCanvasElement;
326
+ target.addEventListener('webglcontextlost', () => {
327
+ this.stage.setContextLost();
328
+ });
329
+ }
330
+
303
331
  reset() {
304
332
  const { glw } = this;
305
333
  if (DIRTY_QUAD_BUFFER) {
@@ -145,6 +145,28 @@ export interface RendererMainCriticalCleanupFailedEvent {
145
145
  criticalThreshold: number;
146
146
  }
147
147
 
148
+ /**
149
+ * WebGL Context Lost Event Data
150
+ *
151
+ * @remarks
152
+ * Fired when the underlying WebGL context is lost (e.g. on low-RAM devices
153
+ * running Chromium 123+ after the app has been backgrounded). The render loop
154
+ * stops; in-engine GL resources are NOT rebuilt, so the supported handling is
155
+ * to reload the app.
156
+ *
157
+ * @category Events
158
+ * @example
159
+ * ```typescript
160
+ * renderer.on('contextLost', () => {
161
+ * window.location.reload();
162
+ * });
163
+ * ```
164
+ */
165
+ export interface RendererMainContextLostEvent {
166
+ /** This event has no payload - listen without parameters */
167
+ readonly __eventHasNoPayload?: never;
168
+ }
169
+
148
170
  /**
149
171
  * Settings for the Renderer that can be updated during runtime.
150
172
  */
@@ -506,6 +528,7 @@ export type RendererMainSettings = RendererRuntimeSettings & {
506
528
  * @see {@link RendererMainIdleEvent}
507
529
  * @see {@link RendererMainCriticalCleanupEvent}
508
530
  * @see {@link RendererMainCriticalCleanupFailedEvent}
531
+ * @see {@link RendererMainContextLostEvent}
509
532
  *
510
533
  * @fires RendererMain#fpsUpdate
511
534
  * @fires RendererMain#frameTick
@@ -513,6 +536,7 @@ export type RendererMainSettings = RendererRuntimeSettings & {
513
536
  * @fires RendererMain#idle
514
537
  * @fires RendererMain#criticalCleanup
515
538
  * @fires RendererMain#criticalCleanupFailed
539
+ * @fires RendererMain#contextLost
516
540
  */
517
541
  export class RendererMain extends EventEmitter {
518
542
  readonly root: INode;