@solidtv/renderer 1.2.10 → 1.3.1
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.
- package/dist/src/core/CoreTextNode.js +3 -1
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/CoreTextureManager.d.ts +10 -0
- package/dist/src/core/CoreTextureManager.js +38 -5
- package/dist/src/core/CoreTextureManager.js.map +1 -1
- package/dist/src/core/Stage.js +3 -1
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.d.ts +16 -0
- package/dist/src/core/TextureMemoryManager.js +26 -2
- package/dist/src/core/TextureMemoryManager.js.map +1 -1
- package/dist/src/core/lib/ImageWorker.js +22 -6
- package/dist/src/core/lib/ImageWorker.js.map +1 -1
- package/dist/src/core/lib/textureSvg.js +4 -1
- package/dist/src/core/lib/textureSvg.js.map +1 -1
- package/dist/src/core/lib/validateImageBitmap.d.ts +18 -0
- package/dist/src/core/lib/validateImageBitmap.js +66 -0
- package/dist/src/core/lib/validateImageBitmap.js.map +1 -1
- package/dist/src/core/platforms/web/WebPlatform.js +8 -0
- package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
- package/dist/src/core/renderers/CoreRenderer.d.ts +11 -0
- package/dist/src/core/renderers/CoreRenderer.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +1 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +4 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -1
- package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.d.ts +13 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +33 -0
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/text-rendering/SdfFontHandler.js +5 -1
- package/dist/src/core/text-rendering/SdfFontHandler.js.map +1 -1
- package/dist/src/core/textures/ImageTexture.js +21 -7
- package/dist/src/core/textures/ImageTexture.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +110 -7
- package/dist/src/main-api/Renderer.js +14 -5
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/core/CoreTextNode.test.ts +145 -0
- package/src/core/CoreTextNode.ts +3 -1
- package/src/core/CoreTextureManager.ts +61 -8
- package/src/core/Stage.ts +3 -0
- package/src/core/TextureMemoryManager.test.ts +98 -0
- package/src/core/TextureMemoryManager.ts +28 -2
- package/src/core/lib/ImageWorker.ts +30 -6
- package/src/core/lib/textureSvg.ts +4 -1
- package/src/core/lib/validateImageBitmap.test.ts +119 -0
- package/src/core/lib/validateImageBitmap.ts +89 -0
- package/src/core/platforms/web/WebPlatform.outOfMemory.test.ts +78 -0
- package/src/core/platforms/web/WebPlatform.ts +8 -0
- package/src/core/renderers/CoreRenderer.ts +12 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +5 -0
- package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -1
- package/src/core/renderers/webgl/WebGlRenderer.ts +36 -0
- package/src/core/text-rendering/SdfFontHandler.ts +6 -1
- package/src/core/text-rendering/tests/SdfFontHandler.test.ts +112 -0
- package/src/core/textures/ImageTexture.ts +25 -7
- package/src/main-api/Renderer.ts +125 -11
|
@@ -342,10 +342,36 @@ export class TextureMemoryManager {
|
|
|
342
342
|
}, 1000);
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
// If the threshold is 0, we disable
|
|
346
|
-
// setTextureMemUse method with a no-op function.
|
|
345
|
+
// If the threshold is 0, we disable memory tracking/cleanup by replacing the
|
|
346
|
+
// setTextureMemUse method with a no-op function. Note this only disables LRU
|
|
347
|
+
// tracking — GPU out-of-memory detection still runs (see handleOutOfMemory).
|
|
347
348
|
if (criticalThreshold === 0) {
|
|
348
349
|
this.setTextureMemUse = () => {};
|
|
349
350
|
}
|
|
350
351
|
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* React to a real GPU out-of-memory reported by the renderer.
|
|
355
|
+
*
|
|
356
|
+
* @remarks
|
|
357
|
+
* WebGL never exposes the VRAM budget up front, so the only certain signal is
|
|
358
|
+
* a `GL_OUT_OF_MEMORY` after the fact. When it fires we queue an `outOfMemory`
|
|
359
|
+
* frame event carrying the estimated memory in use and the critical threshold
|
|
360
|
+
* in effect — the estimate is a *measured ceiling* (the real budget is at or
|
|
361
|
+
* below it). What to do about it (lower the threshold, persist, reload) is
|
|
362
|
+
* application policy, not the renderer's; see the `outOfMemory` event docs on
|
|
363
|
+
* the public Renderer for the recommended integration.
|
|
364
|
+
*
|
|
365
|
+
* The engine also requests an immediate cleanup as a best-effort mitigation
|
|
366
|
+
* to free non-renderable textures before the app reacts.
|
|
367
|
+
*/
|
|
368
|
+
handleOutOfMemory(): void {
|
|
369
|
+
this.stage.queueFrameEvent('outOfMemory', {
|
|
370
|
+
memUsed: this.memUsed,
|
|
371
|
+
criticalThreshold: this.criticalThreshold,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Free whatever non-renderable textures we can right now.
|
|
375
|
+
this.criticalCleanupRequested = true;
|
|
376
|
+
}
|
|
351
377
|
}
|
|
@@ -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:
|
|
94
|
+
premultiplyAlpha: bitmapMode,
|
|
82
95
|
colorSpaceConversion: 'none',
|
|
83
96
|
imageOrientation: 'none',
|
|
84
97
|
})
|
|
85
98
|
.then(function (data) {
|
|
86
|
-
resolve({ data: data, premultiplyAlpha:
|
|
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:
|
|
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:
|
|
121
|
+
premultiplyAlpha: bitmapMode,
|
|
108
122
|
colorSpaceConversion: 'none',
|
|
109
123
|
imageOrientation: 'none',
|
|
110
124
|
})
|
|
111
125
|
.then(function (data) {
|
|
112
|
-
resolve({ data: data, premultiplyAlpha:
|
|
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:
|
|
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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that the GPU out-of-memory probe runs at the idle transition (end of a
|
|
3
|
+
* render burst), not on every active frame.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { WebPlatform } from './WebPlatform.js';
|
|
7
|
+
import type { Stage } from '../../Stage.js';
|
|
8
|
+
|
|
9
|
+
function makeIdleStage(outOfMemory: boolean) {
|
|
10
|
+
const checkForOutOfMemory = vi.fn(() => outOfMemory);
|
|
11
|
+
const handleOutOfMemory = vi.fn();
|
|
12
|
+
const stage = {
|
|
13
|
+
isContextLost: false,
|
|
14
|
+
targetFrameTime: 0,
|
|
15
|
+
updateFrameTime: vi.fn(),
|
|
16
|
+
updateAnimations: vi.fn(() => false),
|
|
17
|
+
hasSceneUpdates: vi.fn(() => false), // idle
|
|
18
|
+
calculateFps: vi.fn(),
|
|
19
|
+
drawFrame: vi.fn(),
|
|
20
|
+
flushFrameEvents: vi.fn(),
|
|
21
|
+
shManager: { cleanup: vi.fn() },
|
|
22
|
+
eventBus: { emit: vi.fn() },
|
|
23
|
+
txMemManager: {
|
|
24
|
+
checkCleanup: vi.fn(() => false),
|
|
25
|
+
cleanup: vi.fn(),
|
|
26
|
+
handleOutOfMemory,
|
|
27
|
+
},
|
|
28
|
+
renderer: { checkForOutOfMemory },
|
|
29
|
+
} as unknown as Stage;
|
|
30
|
+
return { stage, checkForOutOfMemory, handleOutOfMemory };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('WebPlatform render loop — out-of-memory probe at idle', () => {
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function runOneIdleFrame(stage: Stage) {
|
|
39
|
+
let capturedLoop: ((t?: number) => void) | null = null;
|
|
40
|
+
const raf = vi.fn((cb: (t?: number) => void) => {
|
|
41
|
+
capturedLoop = cb;
|
|
42
|
+
return 1;
|
|
43
|
+
});
|
|
44
|
+
vi.stubGlobal('requestAnimationFrame', raf);
|
|
45
|
+
vi.stubGlobal(
|
|
46
|
+
'setTimeout',
|
|
47
|
+
vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
new WebPlatform().startLoop(stage);
|
|
51
|
+
capturedLoop!(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('probes the renderer once when the scene goes idle', () => {
|
|
55
|
+
const { stage, checkForOutOfMemory } = makeIdleStage(false);
|
|
56
|
+
runOneIdleFrame(stage);
|
|
57
|
+
expect(checkForOutOfMemory).toHaveBeenCalledTimes(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles OOM when the probe reports it at idle', () => {
|
|
61
|
+
const { stage, handleOutOfMemory } = makeIdleStage(true);
|
|
62
|
+
runOneIdleFrame(stage);
|
|
63
|
+
expect(handleOutOfMemory).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not handle OOM when the probe reports none', () => {
|
|
67
|
+
const { stage, handleOutOfMemory } = makeIdleStage(false);
|
|
68
|
+
runOneIdleFrame(stage);
|
|
69
|
+
expect(handleOutOfMemory).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not probe on an active (non-idle) frame', () => {
|
|
73
|
+
const { stage, checkForOutOfMemory } = makeIdleStage(false);
|
|
74
|
+
(stage.hasSceneUpdates as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
|
75
|
+
runOneIdleFrame(stage);
|
|
76
|
+
expect(checkForOutOfMemory).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -77,6 +77,14 @@ export class WebPlatform extends Platform {
|
|
|
77
77
|
setTimeout(requestLoop, Math.max(targetFrameTime, 15));
|
|
78
78
|
|
|
79
79
|
if (isIdle === false) {
|
|
80
|
+
// The render burst has settled. Probe for a GPU out-of-memory now
|
|
81
|
+
// rather than every frame: GL errors accumulate and persist until
|
|
82
|
+
// drained, so a single check here still catches any OOM raised during
|
|
83
|
+
// the active frames, without paying the getError() CPU/GPU sync on
|
|
84
|
+
// every frame. Queues the `outOfMemory` event, flushed below.
|
|
85
|
+
if (stage.renderer.checkForOutOfMemory() === true) {
|
|
86
|
+
stage.txMemManager.handleOutOfMemory();
|
|
87
|
+
}
|
|
80
88
|
stage.shManager.cleanup();
|
|
81
89
|
stage.eventBus.emit('idle');
|
|
82
90
|
isIdle = true;
|
|
@@ -67,4 +67,16 @@ export abstract class CoreRenderer {
|
|
|
67
67
|
* on the next render call.
|
|
68
68
|
*/
|
|
69
69
|
invalidateQuadBuffer?(): void;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Probe the backend for a GPU out-of-memory condition since the last call.
|
|
73
|
+
* Returns `true` when an out-of-memory was seen. Backends that cannot detect
|
|
74
|
+
* this (e.g. Canvas2D) return `false`.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* Called once per frame by the Stage. Backends where the probe is expensive
|
|
78
|
+
* (a CPU/GPU sync, e.g. WebGL `gl.getError()`) rely on this once-per-frame
|
|
79
|
+
* cadence rather than checking per draw/upload.
|
|
80
|
+
*/
|
|
81
|
+
abstract checkForOutOfMemory(): boolean;
|
|
70
82
|
}
|
|
@@ -264,6 +264,11 @@ export class CanvasRenderer extends CoreRenderer {
|
|
|
264
264
|
return null;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// Canvas2D has no GPU out-of-memory signal to probe.
|
|
268
|
+
checkForOutOfMemory(): boolean {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
267
272
|
/**
|
|
268
273
|
* Updates the clear color of the canvas renderer.
|
|
269
274
|
*
|
|
@@ -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
|
-
|
|
197
|
+
!!textureData.premultiplyAlpha,
|
|
194
198
|
);
|
|
195
199
|
|
|
196
200
|
glw.texImage2D(0, format, format, glw.UNSIGNED_BYTE, tdata);
|
|
@@ -42,6 +42,15 @@ import type { Dimensions } from '../../../common/CommonTypes.js';
|
|
|
42
42
|
|
|
43
43
|
export type WebGlRendererOptions = CoreRendererOptions;
|
|
44
44
|
|
|
45
|
+
const GL_OUT_OF_MEMORY = 0x0505;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Upper bound on how many queued GL errors we drain per frame in
|
|
49
|
+
* {@link WebGlRenderer.checkForOutOfMemory}. Keeps the per-frame `getError()`
|
|
50
|
+
* sync cost fixed even if the error queue is unexpectedly deep.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_DRAINED_GL_ERRORS = 8;
|
|
53
|
+
|
|
45
54
|
interface CoreWebGlSystem {
|
|
46
55
|
parameters: CoreWebGlParameters;
|
|
47
56
|
extensions: CoreWebGlExtensions;
|
|
@@ -1283,6 +1292,33 @@ export class WebGlRenderer extends CoreRenderer {
|
|
|
1283
1292
|
return bufferInfo;
|
|
1284
1293
|
}
|
|
1285
1294
|
|
|
1295
|
+
/**
|
|
1296
|
+
* Drain the GL error queue once and report whether a GL_OUT_OF_MEMORY was
|
|
1297
|
+
* seen since the last call.
|
|
1298
|
+
*
|
|
1299
|
+
* @remarks
|
|
1300
|
+
* `gl.getError()` forces a CPU↔GPU sync, so this is deliberately invoked at
|
|
1301
|
+
* most once per frame by the Stage rather than after each texture upload.
|
|
1302
|
+
* `getError()` returns one error at a time, so we drain a bounded number of
|
|
1303
|
+
* queued errors to ensure a non-OOM error ahead of the OOM doesn't mask it
|
|
1304
|
+
* for this frame. Non-OOM errors are ignored here (the renderer otherwise
|
|
1305
|
+
* only inspects them in development builds).
|
|
1306
|
+
*/
|
|
1307
|
+
override checkForOutOfMemory(): boolean {
|
|
1308
|
+
const glw = this.glw;
|
|
1309
|
+
let outOfMemory = false;
|
|
1310
|
+
for (let i = 0; i < MAX_DRAINED_GL_ERRORS; i++) {
|
|
1311
|
+
const error = glw.getError();
|
|
1312
|
+
if (error === 0) {
|
|
1313
|
+
break;
|
|
1314
|
+
}
|
|
1315
|
+
if (error === GL_OUT_OF_MEMORY) {
|
|
1316
|
+
outOfMemory = true;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
return outOfMemory;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1286
1322
|
getDefaultShaderNode(): WebGlShaderNode {
|
|
1287
1323
|
if (this.defaultShaderNode !== null) {
|
|
1288
1324
|
return this.defaultShaderNode as WebGlShaderNode;
|
|
@@ -12,6 +12,7 @@ import { UpdateType } from '../CoreNode.js';
|
|
|
12
12
|
import { hasZeroWidthSpace } from './Utils.js';
|
|
13
13
|
import { normalizeFontMetrics } from './TextLayoutEngine.js';
|
|
14
14
|
import { isProductionEnvironment } from '../../utils.js';
|
|
15
|
+
import type { TextureError } from '../TextureError.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* SDF Font Data structure matching msdf-bmfont-xml output
|
|
@@ -397,7 +398,11 @@ export const loadFont = (
|
|
|
397
398
|
resolve();
|
|
398
399
|
});
|
|
399
400
|
|
|
400
|
-
|
|
401
|
+
// EventEmitter invokes listeners as (target, data), so the error payload
|
|
402
|
+
// is the SECOND argument. The first arg is the Texture that emitted the
|
|
403
|
+
// event. Reading it as the only param (the previous behavior) rejected
|
|
404
|
+
// and logged the Texture instead of the actual TextureError.
|
|
405
|
+
atlasTexture.on('failed', (_target, error: TextureError) => {
|
|
401
406
|
// Cleanup on error
|
|
402
407
|
fontLoadPromises.delete(fontFamily);
|
|
403
408
|
if (fontCache[fontFamily]) {
|