@solidtv/renderer 1.2.9 → 1.3.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 (54) hide show
  1. package/dist/src/common/EventEmitter.d.ts +6 -0
  2. package/dist/src/common/EventEmitter.js +29 -11
  3. package/dist/src/common/EventEmitter.js.map +1 -1
  4. package/dist/src/core/CoreNode.d.ts +7 -0
  5. package/dist/src/core/CoreNode.js +30 -9
  6. package/dist/src/core/CoreNode.js.map +1 -1
  7. package/dist/src/core/CoreTextNode.js +3 -1
  8. package/dist/src/core/CoreTextNode.js.map +1 -1
  9. package/dist/src/core/CoreTextureManager.d.ts +10 -0
  10. package/dist/src/core/CoreTextureManager.js +38 -5
  11. package/dist/src/core/CoreTextureManager.js.map +1 -1
  12. package/dist/src/core/Stage.d.ts +19 -0
  13. package/dist/src/core/Stage.js +28 -1
  14. package/dist/src/core/Stage.js.map +1 -1
  15. package/dist/src/core/lib/ImageWorker.js +22 -6
  16. package/dist/src/core/lib/ImageWorker.js.map +1 -1
  17. package/dist/src/core/lib/textureSvg.js +4 -1
  18. package/dist/src/core/lib/textureSvg.js.map +1 -1
  19. package/dist/src/core/lib/validateImageBitmap.d.ts +18 -0
  20. package/dist/src/core/lib/validateImageBitmap.js +66 -0
  21. package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
  22. package/dist/src/core/platforms/web/WebPlatform.js +6 -0
  23. package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
  24. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -1
  25. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
  26. package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +15 -0
  27. package/dist/src/core/renderers/webgl/WebGlRenderer.js +24 -0
  28. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  29. package/dist/src/core/textures/ImageTexture.js +21 -7
  30. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  31. package/dist/src/main-api/Renderer.d.ts +41 -0
  32. package/dist/src/main-api/Renderer.js +8 -0
  33. package/dist/src/main-api/Renderer.js.map +1 -1
  34. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  35. package/dist/tsconfig.tsbuildinfo +1 -0
  36. package/package.json +1 -1
  37. package/src/common/EventEmitter.ts +29 -11
  38. package/src/core/CoreNode.test.ts +66 -0
  39. package/src/core/CoreNode.ts +32 -9
  40. package/src/core/CoreTextNode.test.ts +145 -0
  41. package/src/core/CoreTextNode.ts +3 -1
  42. package/src/core/CoreTextureManager.ts +61 -8
  43. package/src/core/Stage.contextLoss.test.ts +45 -0
  44. package/src/core/Stage.ts +30 -0
  45. package/src/core/lib/ImageWorker.ts +30 -6
  46. package/src/core/lib/textureSvg.ts +4 -1
  47. package/src/core/lib/validateImageBitmap.test.ts +119 -0
  48. package/src/core/lib/validateImageBitmap.ts +89 -0
  49. package/src/core/platforms/web/WebPlatform.contextLoss.test.ts +80 -0
  50. package/src/core/platforms/web/WebPlatform.ts +7 -0
  51. package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -1
  52. package/src/core/renderers/webgl/WebGlRenderer.ts +28 -0
  53. package/src/core/textures/ImageTexture.ts +25 -7
  54. package/src/main-api/Renderer.ts +50 -0
@@ -10,6 +10,7 @@ import { EventEmitter } from '../common/EventEmitter.js';
10
10
  import type { Stage } from './Stage.js';
11
11
  import {
12
12
  validateCreateImageBitmap,
13
+ detectPremultiplyAlphaHonored,
13
14
  type CreateImageBitmapSupport,
14
15
  } from './lib/validateImageBitmap.js';
15
16
  import type { Platform } from './platforms/Platform.js';
@@ -46,6 +47,9 @@ export interface TextureManagerDebugInfo {
46
47
  export interface TextureManagerSettings {
47
48
  numImageWorkers: number;
48
49
  createImageBitmapSupport: 'auto' | 'basic' | 'options' | 'full';
50
+ // Override for whether createImageBitmap honors premultiplyAlpha:'premultiply'.
51
+ // 'auto' = detect via probe; boolean = force the value and skip the probe.
52
+ premultiplyAlphaHonored: boolean | 'auto';
49
53
  maxRetryCount: number;
50
54
  }
51
55
 
@@ -255,6 +259,7 @@ export class CoreTextureManager extends EventEmitter {
255
259
  basic: false,
256
260
  options: false,
257
261
  full: false,
262
+ premultiplyHonored: null as boolean | null,
258
263
  };
259
264
 
260
265
  hasWorker = !!self.Worker;
@@ -281,8 +286,12 @@ export class CoreTextureManager extends EventEmitter {
281
286
  constructor(stage: Stage, settings: TextureManagerSettings) {
282
287
  super();
283
288
 
284
- const { numImageWorkers, createImageBitmapSupport, maxRetryCount } =
285
- settings;
289
+ const {
290
+ numImageWorkers,
291
+ createImageBitmapSupport,
292
+ premultiplyAlphaHonored,
293
+ maxRetryCount,
294
+ } = settings;
286
295
 
287
296
  this.stage = stage;
288
297
  this.platform = stage.platform;
@@ -292,7 +301,7 @@ export class CoreTextureManager extends EventEmitter {
292
301
  if (createImageBitmapSupport === 'auto') {
293
302
  validateCreateImageBitmap(this.platform)
294
303
  .then((result) => {
295
- this.initialize(result);
304
+ this.resolvePremultiplyAndInit(result, premultiplyAlphaHonored);
296
305
  })
297
306
  .catch(() => {
298
307
  console.warn(
@@ -304,11 +313,15 @@ export class CoreTextureManager extends EventEmitter {
304
313
  this.emit('initialized');
305
314
  });
306
315
  } else {
307
- this.initialize({
308
- basic: createImageBitmapSupport === 'basic',
309
- options: createImageBitmapSupport === 'options',
310
- full: createImageBitmapSupport === 'full',
311
- });
316
+ this.resolvePremultiplyAndInit(
317
+ {
318
+ basic: createImageBitmapSupport === 'basic',
319
+ options: createImageBitmapSupport === 'options',
320
+ full: createImageBitmapSupport === 'full',
321
+ premultiplyHonored: null,
322
+ },
323
+ premultiplyAlphaHonored,
324
+ );
312
325
  }
313
326
 
314
327
  this.registerTextureType('ImageTexture', ImageTexture);
@@ -325,11 +338,51 @@ export class CoreTextureManager extends EventEmitter {
325
338
  this.txConstructors[textureType] = textureClass;
326
339
  }
327
340
 
341
+ /**
342
+ * Resolve `premultiplyHonored` on the support object, then initialize.
343
+ *
344
+ * - boolean override -> use it directly, skip the probe
345
+ * - 'auto' -> run the detection probe (only meaningful when the options/full
346
+ * API exists, since that's the only path that passes the premultiply option)
347
+ */
348
+ private resolvePremultiplyAndInit(
349
+ support: CreateImageBitmapSupport,
350
+ premultiplyAlphaHonored: boolean | 'auto',
351
+ ): void {
352
+ if (premultiplyAlphaHonored !== 'auto') {
353
+ support.premultiplyHonored = premultiplyAlphaHonored;
354
+ this.initialize(support);
355
+ return;
356
+ }
357
+
358
+ if (support.options === false && support.full === false) {
359
+ support.premultiplyHonored = null;
360
+ this.initialize(support);
361
+ return;
362
+ }
363
+
364
+ detectPremultiplyAlphaHonored(this.platform)
365
+ .then((honored) => {
366
+ support.premultiplyHonored = honored;
367
+ this.initialize(support);
368
+ })
369
+ .catch(() => {
370
+ support.premultiplyHonored = null;
371
+ this.initialize(support);
372
+ });
373
+ }
374
+
328
375
  private initialize(support: CreateImageBitmapSupport) {
329
376
  this.hasCreateImageBitmap =
330
377
  support.basic || support.options || support.full;
331
378
  this.imageBitmapSupported = support;
332
379
 
380
+ if (support.premultiplyHonored === false) {
381
+ console.warn(
382
+ '[Lightning] createImageBitmap premultiplyAlpha:"premultiply" is not honored on this device — images may show alpha ghosting. GL-side premultiply fallback recommended.',
383
+ );
384
+ }
385
+
333
386
  if (this.hasCreateImageBitmap === false) {
334
387
  console.warn(
335
388
  '[Lightning] createImageBitmap is not supported on this browser. ImageTexture will be slower.',
@@ -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;
@@ -167,6 +179,7 @@ export class Stage {
167
179
  renderEngine,
168
180
  fontEngines,
169
181
  createImageBitmapSupport,
182
+ premultiplyAlphaHonored,
170
183
  platform,
171
184
  maxRetryCount,
172
185
  } = options;
@@ -194,6 +207,8 @@ export class Stage {
194
207
  this.txManager = new CoreTextureManager(this, {
195
208
  numImageWorkers,
196
209
  createImageBitmapSupport,
210
+ // undefined -> true (default: assume honored, no probe)
211
+ premultiplyAlphaHonored: premultiplyAlphaHonored ?? true,
197
212
  maxRetryCount,
198
213
  });
199
214
 
@@ -429,6 +444,21 @@ export class Stage {
429
444
  });
430
445
  }
431
446
 
447
+ /**
448
+ * Mark the WebGL context as lost. Stops GL work and notifies consumers.
449
+ *
450
+ * @remarks
451
+ * The engine does not rebuild GPU resources in-place; consumers are expected
452
+ * to reload the app in response to the `contextLost` event.
453
+ */
454
+ setContextLost() {
455
+ if (this.isContextLost === true) {
456
+ return;
457
+ }
458
+ this.isContextLost = true;
459
+ this.eventBus.emit('contextLost');
460
+ }
461
+
432
462
  /**
433
463
  * Create default PixelTexture
434
464
  */
@@ -43,12 +43,14 @@ function createImageWorker() {
43
43
  options: {
44
44
  supportsOptionsCreateImageBitmap: boolean;
45
45
  supportsFullCreateImageBitmap: boolean;
46
+ premultiplyAlphaHonored: boolean;
46
47
  },
47
48
  ): Promise<getImageReturn> {
48
49
  return new Promise(function (resolve, reject) {
49
50
  var supportsOptionsCreateImageBitmap =
50
51
  options.supportsOptionsCreateImageBitmap;
51
52
  var supportsFullCreateImageBitmap = options.supportsFullCreateImageBitmap;
53
+ var premultiplyAlphaHonored = options.premultiplyAlphaHonored;
52
54
  var xhr = new XMLHttpRequest();
53
55
  xhr.open('GET', src, true);
54
56
  xhr.responseType = 'blob';
@@ -71,6 +73,17 @@ function createImageWorker() {
71
73
  ? premultiplyAlpha
72
74
  : hasAlphaChannel(blob.type);
73
75
 
76
+ // When the device ignores the createImageBitmap premultiply option,
77
+ // create a straight ('none') bitmap and let WebGL premultiply on
78
+ // upload. `premultiplyAlpha` in the resolved value means "WebGL should
79
+ // premultiply this source on upload".
80
+ var useGlPremultiply =
81
+ withAlphaChannel === true && premultiplyAlphaHonored === false;
82
+ var bitmapMode: 'premultiply' | 'none' =
83
+ withAlphaChannel === true && useGlPremultiply === false
84
+ ? 'premultiply'
85
+ : 'none';
86
+
74
87
  // createImageBitmap with crop and options
75
88
  if (
76
89
  supportsFullCreateImageBitmap === true &&
@@ -78,12 +91,12 @@ function createImageWorker() {
78
91
  height !== null
79
92
  ) {
80
93
  createImageBitmap(blob, x || 0, y || 0, width, height, {
81
- premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none',
94
+ premultiplyAlpha: bitmapMode,
82
95
  colorSpaceConversion: 'none',
83
96
  imageOrientation: 'none',
84
97
  })
85
98
  .then(function (data) {
86
- resolve({ data: data, premultiplyAlpha: withAlphaChannel });
99
+ resolve({ data: data, premultiplyAlpha: useGlPremultiply });
87
100
  })
88
101
  .catch(function (error) {
89
102
  reject(error);
@@ -94,22 +107,23 @@ function createImageWorker() {
94
107
  supportsFullCreateImageBitmap === false
95
108
  ) {
96
109
  // Fallback for browsers that do not support createImageBitmap with options
97
- // this is supported for Chrome v50 to v52/54 that doesn't support options
110
+ // this is supported for Chrome v50 to v52/54 that doesn't support options.
111
+ // The browser default premultiplies, so WebGL must not premultiply again.
98
112
  createImageBitmap(blob)
99
113
  .then(function (data) {
100
- resolve({ data: data, premultiplyAlpha: withAlphaChannel });
114
+ resolve({ data: data, premultiplyAlpha: false });
101
115
  })
102
116
  .catch(function (error) {
103
117
  reject(error);
104
118
  });
105
119
  } else {
106
120
  createImageBitmap(blob, {
107
- premultiplyAlpha: withAlphaChannel ? 'premultiply' : 'none',
121
+ premultiplyAlpha: bitmapMode,
108
122
  colorSpaceConversion: 'none',
109
123
  imageOrientation: 'none',
110
124
  })
111
125
  .then(function (data) {
112
- resolve({ data: data, premultiplyAlpha: withAlphaChannel });
126
+ resolve({ data: data, premultiplyAlpha: useGlPremultiply });
113
127
  })
114
128
  .catch(function (error) {
115
129
  reject(error);
@@ -139,10 +153,13 @@ function createImageWorker() {
139
153
  // these will be set to true if the browser supports the createImageBitmap options or full
140
154
  var supportsOptionsCreateImageBitmap = false;
141
155
  var supportsFullCreateImageBitmap = false;
156
+ // set to false when the device is known to ignore the premultiply option
157
+ var premultiplyAlphaHonored = true;
142
158
 
143
159
  getImage(src, premultiplyAlpha, x, y, width, height, {
144
160
  supportsOptionsCreateImageBitmap,
145
161
  supportsFullCreateImageBitmap,
162
+ premultiplyAlphaHonored,
146
163
  })
147
164
  .then(function (data) {
148
165
  // @ts-ignore ts has wrong postMessage signature
@@ -240,6 +257,13 @@ export class ImageWorkerManager {
240
257
  );
241
258
  }
242
259
 
260
+ if (createImageBitmapSupport.premultiplyHonored === false) {
261
+ workerCode = workerCode.replace(
262
+ 'var premultiplyAlphaHonored = true;',
263
+ 'var premultiplyAlphaHonored = false;',
264
+ );
265
+ }
266
+
243
267
  workerCode = workerCode.replace('"use strict";', '');
244
268
  const blob: Blob = new Blob([workerCode], {
245
269
  type: 'application/javascript',
@@ -91,8 +91,11 @@ export const loadSvg = async (
91
91
  }
92
92
  }
93
93
 
94
+ // getImageData returns straight (un-premultiplied) pixels, so WebGL must
95
+ // premultiply this source on upload (unlike the ImageBitmap path above,
96
+ // where the canvas is already premultiplied by createImageBitmap's default).
94
97
  return {
95
98
  data: ctx.getImageData(0, 0, physW, physH),
96
- premultiplyAlpha: false,
99
+ premultiplyAlpha: true,
97
100
  };
98
101
  };
@@ -0,0 +1,119 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { detectPremultiplyAlphaHonored } from './validateImageBitmap.js';
3
+ import type { Platform } from '../platforms/Platform.js';
4
+
5
+ /**
6
+ * Minimal stand-in for the WebGL constants/methods the probe touches. The probe
7
+ * uploads a known straight pixel, reads it back, and infers whether
8
+ * createImageBitmap premultiplied it. `readbackRed` is what readPixels returns
9
+ * for the red channel: ~128 = premultiplied (honored), ~255 = straight (ignored).
10
+ */
11
+ function createFakeGl(readbackRed: number, framebufferComplete = true) {
12
+ return {
13
+ TEXTURE_2D: 0x0de1,
14
+ UNPACK_PREMULTIPLY_ALPHA_WEBGL: 0x9241,
15
+ UNPACK_FLIP_Y_WEBGL: 0x9240,
16
+ RGBA: 0x1908,
17
+ UNSIGNED_BYTE: 0x1401,
18
+ FRAMEBUFFER: 0x8d40,
19
+ COLOR_ATTACHMENT0: 0x8ce0,
20
+ FRAMEBUFFER_COMPLETE: 0x8cd5,
21
+ createTexture: vi.fn(() => ({})),
22
+ bindTexture: vi.fn(),
23
+ pixelStorei: vi.fn(),
24
+ texImage2D: vi.fn(),
25
+ createFramebuffer: vi.fn(() => ({})),
26
+ bindFramebuffer: vi.fn(),
27
+ framebufferTexture2D: vi.fn(),
28
+ checkFramebufferStatus: vi.fn(() => (framebufferComplete ? 0x8cd5 : 0)),
29
+ readPixels: vi.fn(
30
+ (
31
+ _x: number,
32
+ _y: number,
33
+ _w: number,
34
+ _h: number,
35
+ _format: number,
36
+ _type: number,
37
+ px: Uint8Array,
38
+ ) => {
39
+ px[0] = readbackRed;
40
+ px[1] = 0;
41
+ px[2] = 0;
42
+ px[3] = 128;
43
+ },
44
+ ),
45
+ deleteFramebuffer: vi.fn(),
46
+ deleteTexture: vi.fn(),
47
+ };
48
+ }
49
+
50
+ function createPlatform(gl: object | null): Platform {
51
+ const close = vi.fn();
52
+ return {
53
+ createImageBitmap: vi.fn(() => Promise.resolve({ close })),
54
+ createCanvas: vi.fn(() => ({
55
+ width: 0,
56
+ height: 0,
57
+ getContext: vi.fn((type: string) => (type === 'webgl' ? gl : null)),
58
+ })),
59
+ } as unknown as Platform;
60
+ }
61
+
62
+ describe('detectPremultiplyAlphaHonored', () => {
63
+ beforeEach(() => {
64
+ // node test env has no ImageData global; the probe constructs one.
65
+ (globalThis as unknown as { ImageData: unknown }).ImageData = class {
66
+ data: Uint8ClampedArray;
67
+ width: number;
68
+ height: number;
69
+ constructor(data: Uint8ClampedArray, width: number, height: number) {
70
+ this.data = data;
71
+ this.width = width;
72
+ this.height = height;
73
+ }
74
+ };
75
+ });
76
+
77
+ afterEach(() => {
78
+ delete (globalThis as unknown as { ImageData?: unknown }).ImageData;
79
+ });
80
+
81
+ it('returns true when the bitmap reads back premultiplied (~128)', async () => {
82
+ const platform = createPlatform(createFakeGl(128));
83
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(true);
84
+ });
85
+
86
+ it('returns false when the bitmap reads back straight (~255)', async () => {
87
+ const platform = createPlatform(createFakeGl(255));
88
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(false);
89
+ });
90
+
91
+ it('returns null when createImageBitmap throws', async () => {
92
+ const platform = {
93
+ createImageBitmap: vi.fn(() => Promise.reject(new Error('unsupported'))),
94
+ createCanvas: vi.fn(),
95
+ } as unknown as Platform;
96
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(null);
97
+ });
98
+
99
+ it('returns null when no WebGL context is available', async () => {
100
+ const platform = createPlatform(null);
101
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(null);
102
+ });
103
+
104
+ it('returns null when the framebuffer is incomplete', async () => {
105
+ const platform = createPlatform(createFakeGl(128, false));
106
+ expect(await detectPremultiplyAlphaHonored(platform)).toBe(null);
107
+ });
108
+
109
+ it('disables GL-side premultiply on the probe upload', async () => {
110
+ const gl = createFakeGl(128);
111
+ await detectPremultiplyAlphaHonored(createPlatform(gl));
112
+ // The probe must observe the bitmap's own alpha state, so GL premultiply
113
+ // has to be off during the readback upload.
114
+ expect(gl.pixelStorei).toHaveBeenCalledWith(
115
+ gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,
116
+ false,
117
+ );
118
+ });
119
+ });
@@ -4,6 +4,11 @@ export interface CreateImageBitmapSupport {
4
4
  basic: boolean; // Supports createImageBitmap(image)
5
5
  options: boolean; // Supports createImageBitmap(image, options)
6
6
  full: boolean; // Supports createImageBitmap(image, sx, sy, sw, sh, options)
7
+ // Whether `premultiplyAlpha: 'premultiply'` is actually HONORED (not just
8
+ // accepted without throwing). null = could not determine. Older Safari/WebKit
9
+ // accepts the option but ignores it, returning straight alpha — the source of
10
+ // the edge-ghosting bug on those devices.
11
+ premultiplyHonored: boolean | null;
7
12
  }
8
13
 
9
14
  export async function validateCreateImageBitmap(
@@ -47,6 +52,7 @@ export async function validateCreateImageBitmap(
47
52
  basic: false,
48
53
  options: false,
49
54
  full: false,
55
+ premultiplyHonored: null,
50
56
  };
51
57
 
52
58
  // Test basic createImageBitmap support
@@ -83,5 +89,88 @@ export async function validateCreateImageBitmap(
83
89
  /* ignore */
84
90
  }
85
91
 
92
+ // premultiplyHonored is resolved separately by the caller (it may be a
93
+ // forced override or an explicit opt-in to the probe), so it is left as its
94
+ // default (null) here.
86
95
  return support;
87
96
  }
97
+
98
+ /**
99
+ * Determine whether `createImageBitmap(..., { premultiplyAlpha: 'premultiply' })`
100
+ * is actually honored by this browser.
101
+ *
102
+ * Strategy: feed a known straight-alpha pixel (255, 0, 0, 128) through
103
+ * createImageBitmap with 'premultiply', upload it to a WebGL texture with
104
+ * GL-side premultiply DISABLED (so we observe the bitmap's own state), then
105
+ * read the raw texel back via a framebuffer.
106
+ *
107
+ * - honored -> red comes back premultiplied (~128)
108
+ * - ignored -> red comes back straight (~255) [older Safari/WebKit]
109
+ *
110
+ * @returns true if honored, false if ignored, null if it couldn't be measured
111
+ * (no WebGL, createImageBitmap from ImageData unsupported, framebuffer
112
+ * incomplete, etc.) — caller should treat null as "unknown".
113
+ */
114
+ export async function detectPremultiplyAlphaHonored(
115
+ platform: Platform,
116
+ ): Promise<boolean | null> {
117
+ let bitmap: ImageBitmap;
118
+ try {
119
+ // Straight (un-premultiplied) RGBA. ImageData is straight-alpha by spec.
120
+ const imageData = new ImageData(
121
+ new Uint8ClampedArray([255, 0, 0, 128]),
122
+ 1,
123
+ 1,
124
+ );
125
+ bitmap = await platform.createImageBitmap(imageData, {
126
+ premultiplyAlpha: 'premultiply',
127
+ colorSpaceConversion: 'none',
128
+ imageOrientation: 'none',
129
+ });
130
+ } catch (e) {
131
+ return null;
132
+ }
133
+
134
+ const canvas = platform.createCanvas();
135
+ canvas.width = 1;
136
+ canvas.height = 1;
137
+ const gl = (canvas.getContext('webgl') ||
138
+ canvas.getContext('experimental-webgl')) as WebGLRenderingContext | null;
139
+ if (gl === null) {
140
+ bitmap.close?.();
141
+ return null;
142
+ }
143
+
144
+ const tex = gl.createTexture();
145
+ gl.bindTexture(gl.TEXTURE_2D, tex);
146
+ // Critical: do NOT let GL premultiply. We want to observe whatever state the
147
+ // bitmap itself is in.
148
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
149
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
150
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
151
+
152
+ const fb = gl.createFramebuffer();
153
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
154
+ gl.framebufferTexture2D(
155
+ gl.FRAMEBUFFER,
156
+ gl.COLOR_ATTACHMENT0,
157
+ gl.TEXTURE_2D,
158
+ tex,
159
+ 0,
160
+ );
161
+
162
+ let result: boolean | null = null;
163
+ if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) {
164
+ const px = new Uint8Array(4);
165
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
166
+ // Straight red reads ~255; premultiplied red reads ~128. Split at the
167
+ // midpoint to tolerate rounding/colorspace drift.
168
+ result = px[0]! < 192;
169
+ }
170
+
171
+ gl.deleteFramebuffer(fb);
172
+ gl.deleteTexture(tex);
173
+ bitmap.close?.();
174
+
175
+ return result;
176
+ }
@@ -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
@@ -188,9 +188,13 @@ export class WebGlCtxTexture extends CoreContextTexture {
188
188
  w = tdata.width;
189
189
  h = tdata.height;
190
190
  glw.bindTexture(this._nativeCtxTexture);
191
+ // `premultiplyAlpha` carries the source's GL-upload intent: true when the
192
+ // source pixels are straight and WebGL must premultiply (e.g. a straight
193
+ // bitmap produced when the device ignores the createImageBitmap
194
+ // premultiply option), false when the source is already premultiplied.
191
195
  glw.pixelStorei(
192
196
  glw.UNPACK_PREMULTIPLY_ALPHA_WEBGL,
193
- isImageBitmap ? false : !!textureData.premultiplyAlpha,
197
+ !!textureData.premultiplyAlpha,
194
198
  );
195
199
 
196
200
  glw.texImage2D(0, format, format, glw.UNSIGNED_BYTE, tdata);