@solidtv/renderer 1.3.0 → 1.3.2
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/CoreNode.d.ts +0 -8
- package/dist/src/core/CoreNode.js +7 -14
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/Stage.d.ts +7 -15
- package/dist/src/core/Stage.js +22 -39
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.d.ts +16 -0
- package/dist/src/core/TextureMemoryManager.js +30 -5
- package/dist/src/core/TextureMemoryManager.js.map +1 -1
- package/dist/src/core/lib/utils.d.ts +0 -1
- package/dist/src/core/lib/utils.js +0 -3
- package/dist/src/core/lib/utils.js.map +1 -1
- package/dist/src/core/platforms/web/WebPlatform.js +9 -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/CoreShaderNode.d.ts +16 -0
- package/dist/src/core/renderers/CoreShaderNode.js +22 -0
- package/dist/src/core/renderers/CoreShaderNode.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.d.ts +1 -0
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +27 -3
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasShaderNode.d.ts +0 -1
- package/dist/src/core/renderers/canvas/CanvasShaderNode.js +0 -1
- package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasTexture.d.ts +1 -1
- package/dist/src/core/renderers/canvas/CanvasTexture.js +25 -6
- package/dist/src/core/renderers/canvas/CanvasTexture.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/renderers/webgl/WebGlShaderNode.d.ts +0 -1
- package/dist/src/core/renderers/webgl/WebGlShaderNode.js +0 -1
- package/dist/src/core/renderers/webgl/WebGlShaderNode.js.map +1 -1
- package/dist/src/core/text-rendering/CanvasTextRenderer.d.ts +1 -0
- package/dist/src/core/text-rendering/CanvasTextRenderer.js +23 -0
- package/dist/src/core/text-rendering/CanvasTextRenderer.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/text-rendering/SdfTextRenderer.d.ts +1 -0
- package/dist/src/core/text-rendering/SdfTextRenderer.js +27 -0
- package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
- package/dist/src/core/text-rendering/TextRenderer.d.ts +7 -0
- package/dist/src/core/textures/ImageTexture.js +11 -0
- package/dist/src/core/textures/ImageTexture.js.map +1 -1
- package/dist/src/core/textures/SubTexture.d.ts +1 -0
- package/dist/src/core/textures/SubTexture.js +12 -0
- package/dist/src/core/textures/SubTexture.js.map +1 -1
- package/dist/src/core/textures/Texture.js +10 -0
- package/dist/src/core/textures/Texture.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +108 -7
- package/dist/src/main-api/Renderer.js +10 -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/CoreNode.ts +8 -23
- package/src/core/Stage.ts +24 -47
- package/src/core/TextureMemoryManager.test.ts +98 -0
- package/src/core/TextureMemoryManager.ts +32 -5
- package/src/core/lib/utils.ts +0 -4
- package/src/core/platforms/web/WebPlatform.outOfMemory.test.ts +79 -0
- package/src/core/platforms/web/WebPlatform.ts +9 -0
- package/src/core/renderers/CoreRenderer.ts +12 -0
- package/src/core/renderers/CoreShaderNode.test.ts +53 -0
- package/src/core/renderers/CoreShaderNode.ts +23 -0
- package/src/core/renderers/canvas/CanvasRenderer.test.ts +34 -0
- package/src/core/renderers/canvas/CanvasRenderer.ts +38 -4
- package/src/core/renderers/canvas/CanvasShaderNode.ts +0 -1
- package/src/core/renderers/canvas/CanvasTexture.test.ts +31 -0
- package/src/core/renderers/canvas/CanvasTexture.ts +35 -7
- package/src/core/renderers/webgl/WebGlRenderer.ts +36 -0
- package/src/core/renderers/webgl/WebGlShaderNode.ts +0 -1
- package/src/core/text-rendering/CanvasTextRenderer.ts +26 -0
- package/src/core/text-rendering/SdfFontHandler.ts +6 -1
- package/src/core/text-rendering/SdfTextRenderer.test.ts +133 -0
- package/src/core/text-rendering/SdfTextRenderer.ts +30 -0
- package/src/core/text-rendering/TextRenderer.ts +7 -0
- package/src/core/text-rendering/tests/SdfFontHandler.test.ts +112 -0
- package/src/core/textures/ImageTexture.ts +18 -0
- package/src/core/textures/SubTexture.test.ts +45 -0
- package/src/core/textures/SubTexture.ts +13 -0
- package/src/core/textures/Texture.ts +12 -0
- package/src/main-api/Renderer.ts +118 -11
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
cleanupTextRenderers: vi.fn(),
|
|
23
|
+
eventBus: { emit: vi.fn() },
|
|
24
|
+
txMemManager: {
|
|
25
|
+
checkCleanup: vi.fn(() => false),
|
|
26
|
+
cleanup: vi.fn(),
|
|
27
|
+
handleOutOfMemory,
|
|
28
|
+
},
|
|
29
|
+
renderer: { checkForOutOfMemory },
|
|
30
|
+
} as unknown as Stage;
|
|
31
|
+
return { stage, checkForOutOfMemory, handleOutOfMemory };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('WebPlatform render loop — out-of-memory probe at idle', () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
vi.unstubAllGlobals();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function runOneIdleFrame(stage: Stage) {
|
|
40
|
+
let capturedLoop: ((t?: number) => void) | null = null;
|
|
41
|
+
const raf = vi.fn((cb: (t?: number) => void) => {
|
|
42
|
+
capturedLoop = cb;
|
|
43
|
+
return 1;
|
|
44
|
+
});
|
|
45
|
+
vi.stubGlobal('requestAnimationFrame', raf);
|
|
46
|
+
vi.stubGlobal(
|
|
47
|
+
'setTimeout',
|
|
48
|
+
vi.fn(() => 1 as unknown as ReturnType<typeof setTimeout>),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
new WebPlatform().startLoop(stage);
|
|
52
|
+
capturedLoop!(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it('probes the renderer once when the scene goes idle', () => {
|
|
56
|
+
const { stage, checkForOutOfMemory } = makeIdleStage(false);
|
|
57
|
+
runOneIdleFrame(stage);
|
|
58
|
+
expect(checkForOutOfMemory).toHaveBeenCalledTimes(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('handles OOM when the probe reports it at idle', () => {
|
|
62
|
+
const { stage, handleOutOfMemory } = makeIdleStage(true);
|
|
63
|
+
runOneIdleFrame(stage);
|
|
64
|
+
expect(handleOutOfMemory).toHaveBeenCalledTimes(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not handle OOM when the probe reports none', () => {
|
|
68
|
+
const { stage, handleOutOfMemory } = makeIdleStage(false);
|
|
69
|
+
runOneIdleFrame(stage);
|
|
70
|
+
expect(handleOutOfMemory).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not probe on an active (non-idle) frame', () => {
|
|
74
|
+
const { stage, checkForOutOfMemory } = makeIdleStage(false);
|
|
75
|
+
(stage.hasSceneUpdates as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
|
76
|
+
runOneIdleFrame(stage);
|
|
77
|
+
expect(checkForOutOfMemory).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -77,7 +77,16 @@ 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();
|
|
89
|
+
stage.cleanupTextRenderers();
|
|
81
90
|
stage.eventBus.emit('idle');
|
|
82
91
|
isIdle = true;
|
|
83
92
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { CoreShaderNode } from './CoreShaderNode.js';
|
|
3
|
+
import type { Stage } from '../Stage.js';
|
|
4
|
+
import type { CoreShaderType } from './CoreShaderNode.js';
|
|
5
|
+
|
|
6
|
+
const makeNode = (mutate: ReturnType<typeof vi.fn>) => {
|
|
7
|
+
const stage = {
|
|
8
|
+
shManager: { mutateShaderValueUsage: mutate },
|
|
9
|
+
} as unknown as Stage;
|
|
10
|
+
return new CoreShaderNode(
|
|
11
|
+
'test',
|
|
12
|
+
{ time: undefined } as CoreShaderType,
|
|
13
|
+
stage,
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe('CoreShaderNode.detachNode', () => {
|
|
18
|
+
it('releases the held value-cache key so idle cleanup can evict it', () => {
|
|
19
|
+
const mutate = vi.fn();
|
|
20
|
+
const node = makeNode(mutate);
|
|
21
|
+
node.valueKey = 'color:1;node-width:10node-height:10';
|
|
22
|
+
|
|
23
|
+
node.detachNode();
|
|
24
|
+
|
|
25
|
+
expect(mutate).toHaveBeenCalledTimes(1);
|
|
26
|
+
expect(mutate).toHaveBeenCalledWith(
|
|
27
|
+
'color:1;node-width:10node-height:10',
|
|
28
|
+
-1,
|
|
29
|
+
);
|
|
30
|
+
// Key is cleared so a double-detach can't decrement twice.
|
|
31
|
+
expect(node.valueKey).toBe('');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('is a no-op when no value key is held', () => {
|
|
35
|
+
const mutate = vi.fn();
|
|
36
|
+
const node = makeNode(mutate);
|
|
37
|
+
|
|
38
|
+
node.detachNode();
|
|
39
|
+
|
|
40
|
+
expect(mutate).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not double-decrement on repeated detach', () => {
|
|
44
|
+
const mutate = vi.fn();
|
|
45
|
+
const node = makeNode(mutate);
|
|
46
|
+
node.valueKey = 'k';
|
|
47
|
+
|
|
48
|
+
node.detachNode();
|
|
49
|
+
node.detachNode();
|
|
50
|
+
|
|
51
|
+
expect(mutate).toHaveBeenCalledTimes(1);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -96,6 +96,12 @@ export class CoreShaderNode<Props extends object = Record<string, unknown>> {
|
|
|
96
96
|
protected node: CoreNode | null = null;
|
|
97
97
|
readonly time: CoreShaderType['time'] = undefined;
|
|
98
98
|
update: (() => void) | undefined = undefined;
|
|
99
|
+
/**
|
|
100
|
+
* The shader-value cache key currently held by this node (set by the
|
|
101
|
+
* subclass `update()` after the most recent successful value resolution).
|
|
102
|
+
* Tracked on the base so {@link detachNode} can release it on teardown.
|
|
103
|
+
*/
|
|
104
|
+
valueKey = '';
|
|
99
105
|
private _valueKeyCache = '';
|
|
100
106
|
private _valueKeyDirty = true;
|
|
101
107
|
private _lastW = 0;
|
|
@@ -162,6 +168,23 @@ export class CoreShaderNode<Props extends object = Record<string, unknown>> {
|
|
|
162
168
|
this.node = node;
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Release this node's currently-held shader-value cache entry so the shader
|
|
173
|
+
* manager's idle `cleanup()` can reclaim it, and detach from the owning node.
|
|
174
|
+
*
|
|
175
|
+
* Called from {@link CoreNode.destroy}. Mirrors the `prevKey` release done on
|
|
176
|
+
* every value-key change in the subclass `update()`; without it a destroyed
|
|
177
|
+
* node's last value-cache entry stays pinned at usage >= 1 forever and can
|
|
178
|
+
* never be evicted.
|
|
179
|
+
*/
|
|
180
|
+
detachNode() {
|
|
181
|
+
if (this.valueKey.length > 0) {
|
|
182
|
+
this.stage.shManager.mutateShaderValueUsage(this.valueKey, -1);
|
|
183
|
+
this.valueKey = '';
|
|
184
|
+
}
|
|
185
|
+
this.node = null;
|
|
186
|
+
}
|
|
187
|
+
|
|
165
188
|
createValueKey() {
|
|
166
189
|
if (
|
|
167
190
|
this._valueKeyDirty === false &&
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CanvasRenderer } from './CanvasRenderer.js';
|
|
3
|
+
import { TextureType } from '../../textures/Texture.js';
|
|
4
|
+
|
|
5
|
+
describe('CanvasRenderer.renderContext', () => {
|
|
6
|
+
it('skips drawing when canvas texture image is undefined', () => {
|
|
7
|
+
const drawImage = vi.fn();
|
|
8
|
+
|
|
9
|
+
const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer;
|
|
10
|
+
(renderer as any).context = {
|
|
11
|
+
drawImage,
|
|
12
|
+
globalAlpha: 1,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const node = {
|
|
16
|
+
premultipliedColorTl: 0xffffffff,
|
|
17
|
+
globalTransform: { tx: 10, ty: 20 },
|
|
18
|
+
props: { w: 100, h: 50 },
|
|
19
|
+
worldAlpha: 1,
|
|
20
|
+
textureCoords: { x1: 0, y1: 0, x2: 1, y2: 1 },
|
|
21
|
+
} as any;
|
|
22
|
+
|
|
23
|
+
const texture = {
|
|
24
|
+
type: TextureType.image,
|
|
25
|
+
ctxTexture: {
|
|
26
|
+
getImage: () => undefined,
|
|
27
|
+
},
|
|
28
|
+
} as any;
|
|
29
|
+
|
|
30
|
+
expect(() => renderer.renderContext(node, texture)).not.toThrow();
|
|
31
|
+
expect(drawImage).not.toHaveBeenCalled();
|
|
32
|
+
expect((renderer as any).context.globalAlpha).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -63,7 +63,19 @@ export class CanvasRenderer extends CoreRenderer {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const hasTransform = ta !== 1;
|
|
66
|
-
const
|
|
66
|
+
const clippingValid = clippingRect.valid === true;
|
|
67
|
+
|
|
68
|
+
// If the clipping rect is valid but zero-area, the node is fully clipped — skip rendering
|
|
69
|
+
if (
|
|
70
|
+
clippingValid === true &&
|
|
71
|
+
clippingRect.w === 0 &&
|
|
72
|
+
clippingRect.h === 0
|
|
73
|
+
) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const hasClipping =
|
|
78
|
+
clippingValid === true && clippingRect.w !== 0 && clippingRect.h !== 0;
|
|
67
79
|
const shader = node.props.shader;
|
|
68
80
|
const hasShader = shader !== null;
|
|
69
81
|
|
|
@@ -124,7 +136,7 @@ export class CanvasRenderer extends CoreRenderer {
|
|
|
124
136
|
|
|
125
137
|
if (textureType !== TextureType.color) {
|
|
126
138
|
const tintColor = parseColor(color);
|
|
127
|
-
let image: ImageBitmap | HTMLCanvasElement | HTMLImageElement;
|
|
139
|
+
let image: ImageBitmap | HTMLCanvasElement | HTMLImageElement | null;
|
|
128
140
|
|
|
129
141
|
if (textureType === TextureType.subTexture) {
|
|
130
142
|
image = (
|
|
@@ -134,12 +146,29 @@ export class CanvasRenderer extends CoreRenderer {
|
|
|
134
146
|
image = (texture.ctxTexture as CanvasTexture).getImage(tintColor);
|
|
135
147
|
}
|
|
136
148
|
|
|
149
|
+
// The texture can disappear while an async load/fetch is racing (e.g. 404);
|
|
150
|
+
// skip drawing this frame instead of dereferencing an invalid image object.
|
|
151
|
+
if (image === null || image === undefined) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const imageWidth = image.width;
|
|
156
|
+
const imageHeight = image.height;
|
|
157
|
+
if (
|
|
158
|
+
typeof imageWidth !== 'number' ||
|
|
159
|
+
typeof imageHeight !== 'number' ||
|
|
160
|
+
imageWidth <= 0 ||
|
|
161
|
+
imageHeight <= 0
|
|
162
|
+
) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
137
166
|
this.context.globalAlpha = tintColor.a ?? node.worldAlpha;
|
|
138
167
|
|
|
139
168
|
const txCoords = node.textureCoords;
|
|
140
169
|
if (txCoords) {
|
|
141
|
-
const ix =
|
|
142
|
-
const iy =
|
|
170
|
+
const ix = imageWidth;
|
|
171
|
+
const iy = imageHeight;
|
|
143
172
|
|
|
144
173
|
let sx = txCoords.x1 * ix;
|
|
145
174
|
let sy = txCoords.y1 * iy;
|
|
@@ -264,6 +293,11 @@ export class CanvasRenderer extends CoreRenderer {
|
|
|
264
293
|
return null;
|
|
265
294
|
}
|
|
266
295
|
|
|
296
|
+
// Canvas2D has no GPU out-of-memory signal to probe.
|
|
297
|
+
checkForOutOfMemory(): boolean {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
267
301
|
/**
|
|
268
302
|
* Updates the clear color of the canvas renderer.
|
|
269
303
|
*
|
|
@@ -26,7 +26,6 @@ export class CanvasShaderNode<
|
|
|
26
26
|
> extends CoreShaderNode<Props> {
|
|
27
27
|
private updater: ((node: CoreNode, props?: Props) => void) | undefined =
|
|
28
28
|
undefined;
|
|
29
|
-
private valueKey: string = '';
|
|
30
29
|
computed: Partial<Computed> = {};
|
|
31
30
|
applySNR: boolean;
|
|
32
31
|
render: CanvasShaderType<Props>['render'];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CanvasTexture } from './CanvasTexture.js';
|
|
3
|
+
|
|
4
|
+
describe('CanvasTexture.load', () => {
|
|
5
|
+
it('fails gracefully when textureData.data is null', async () => {
|
|
6
|
+
const textureSource = {
|
|
7
|
+
textureData: { data: null },
|
|
8
|
+
state: 'initial',
|
|
9
|
+
dimensions: null,
|
|
10
|
+
setState(nextState: string) {
|
|
11
|
+
this.state = nextState;
|
|
12
|
+
},
|
|
13
|
+
freeTextureData() {
|
|
14
|
+
// no-op
|
|
15
|
+
},
|
|
16
|
+
} as any;
|
|
17
|
+
|
|
18
|
+
const memManager = {
|
|
19
|
+
setTextureMemUse() {
|
|
20
|
+
// no-op
|
|
21
|
+
},
|
|
22
|
+
} as any;
|
|
23
|
+
|
|
24
|
+
const ctxTexture = new CanvasTexture(memManager, textureSource);
|
|
25
|
+
|
|
26
|
+
await expect(ctxTexture.load()).rejects.toThrow(
|
|
27
|
+
'CanvasTexture: Texture data is null',
|
|
28
|
+
);
|
|
29
|
+
expect(textureSource.state).toBe('failed');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -2,6 +2,7 @@ import type { Dimensions } from '../../../common/CommonTypes.js';
|
|
|
2
2
|
import { assertTruthy } from '../../../utils.js';
|
|
3
3
|
import { formatRgba, type IParsedColor } from '../../lib/colorParser.js';
|
|
4
4
|
import { CoreContextTexture } from '../CoreContextTexture.js';
|
|
5
|
+
import type { Texture } from '../../textures/Texture.js';
|
|
5
6
|
|
|
6
7
|
export class CanvasTexture extends CoreContextTexture {
|
|
7
8
|
protected image:
|
|
@@ -17,10 +18,23 @@ export class CanvasTexture extends CoreContextTexture {
|
|
|
17
18
|
| undefined;
|
|
18
19
|
|
|
19
20
|
async load(): Promise<void> {
|
|
21
|
+
// Capture textureData synchronously before any await - a pending
|
|
22
|
+
// freeTextureDataTask microtask could null textureSource.textureData
|
|
23
|
+
// during the first async suspension, causing onLoadRequest to fail.
|
|
24
|
+
const textureData = this.textureSource.textureData;
|
|
25
|
+
assertTruthy(textureData?.data, 'Texture data is null before load');
|
|
26
|
+
|
|
20
27
|
this.textureSource.setState('loading');
|
|
21
28
|
|
|
22
29
|
try {
|
|
23
|
-
const size = await this.onLoadRequest();
|
|
30
|
+
const size = await this.onLoadRequest(textureData.data);
|
|
31
|
+
|
|
32
|
+
// Guard against the texture being freed while the load was in flight
|
|
33
|
+
if (this.textureSource.state === 'freed') {
|
|
34
|
+
this.image = undefined;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
this.textureSource.setState('loaded', size);
|
|
25
39
|
this.textureSource.freeTextureData();
|
|
26
40
|
this.updateMemSize();
|
|
@@ -63,9 +77,11 @@ export class CanvasTexture extends CoreContextTexture {
|
|
|
63
77
|
|
|
64
78
|
getImage(
|
|
65
79
|
color: IParsedColor,
|
|
66
|
-
): ImageBitmap | HTMLCanvasElement | HTMLImageElement {
|
|
80
|
+
): ImageBitmap | HTMLCanvasElement | HTMLImageElement | null {
|
|
67
81
|
const image = this.image;
|
|
68
|
-
|
|
82
|
+
if (image === undefined) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
69
85
|
|
|
70
86
|
if (color.isWhite) {
|
|
71
87
|
if (this.tintCache) {
|
|
@@ -114,9 +130,21 @@ export class CanvasTexture extends CoreContextTexture {
|
|
|
114
130
|
return canvas;
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
private async onLoadRequest(
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
private async onLoadRequest(
|
|
134
|
+
data: NonNullable<Texture['textureData']>['data'],
|
|
135
|
+
): Promise<Dimensions> {
|
|
136
|
+
if (data === null) {
|
|
137
|
+
throw new Error('CanvasTexture: Texture data is null');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// CompressedData objects (KTX, PVR, ASTC) carry GPU-format mipmap buffers
|
|
141
|
+
// that cannot be decoded by Canvas2D. Reject explicitly rather than falling
|
|
142
|
+
// through silently and leaving this.image unassigned.
|
|
143
|
+
if (typeof data === 'object' && 'mipmaps' in data) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
'CanvasTexture: Compressed texture data is not supported in Canvas2D render mode',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
120
148
|
|
|
121
149
|
// TODO: canvas from text renderer should be able to provide the canvas directly
|
|
122
150
|
// instead of having to re-draw it into a new canvas...
|
|
@@ -125,7 +153,7 @@ export class CanvasTexture extends CoreContextTexture {
|
|
|
125
153
|
canvas.width = data.width;
|
|
126
154
|
canvas.height = data.height;
|
|
127
155
|
const ctx = canvas.getContext('2d');
|
|
128
|
-
if (ctx) ctx.putImageData(data, 0, 0);
|
|
156
|
+
if (ctx !== null) ctx.putImageData(data, 0, 0);
|
|
129
157
|
this.image = canvas;
|
|
130
158
|
return { w: data.width, h: data.height };
|
|
131
159
|
} else if (
|
|
@@ -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;
|
|
@@ -40,10 +40,22 @@ const layoutCache = new Map<
|
|
|
40
40
|
}
|
|
41
41
|
>();
|
|
42
42
|
|
|
43
|
+
// Upper bound on layoutCache entries, enforced on idle via `cleanup`.
|
|
44
|
+
// Overridden from stage options in `init`. Note: the Canvas path does not
|
|
45
|
+
// currently populate `layoutCache`, so this is effectively inert today and
|
|
46
|
+
// exists to keep the eviction policy uniform with the SDF backend should
|
|
47
|
+
// Canvas layout caching be wired up later.
|
|
48
|
+
let maxLayoutCacheSize = 250;
|
|
49
|
+
|
|
43
50
|
// Initialize the Text Renderer
|
|
44
51
|
const init = (stage: Stage): void => {
|
|
45
52
|
const dpr = stage.options.devicePhysicalPixelRatio;
|
|
46
53
|
|
|
54
|
+
const configuredCacheSize = stage.options.textLayoutCacheSize;
|
|
55
|
+
if (configuredCacheSize !== undefined) {
|
|
56
|
+
maxLayoutCacheSize = configuredCacheSize;
|
|
57
|
+
}
|
|
58
|
+
|
|
47
59
|
// Drawing canvas and context
|
|
48
60
|
canvas = stage.platform.createCanvas() as HTMLCanvasElement | OffscreenCanvas;
|
|
49
61
|
context = canvas.getContext('2d', { willReadFrequently: true }) as
|
|
@@ -224,6 +236,19 @@ const clearLayoutCache = (): void => {
|
|
|
224
236
|
layoutCache.clear();
|
|
225
237
|
};
|
|
226
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Trim the layout cache back down to `maxLayoutCacheSize`, evicting the
|
|
241
|
+
* least-recently-used entries first. Called when the stage goes idle. The
|
|
242
|
+
* Canvas path does not currently populate `layoutCache`, so this is a no-op in
|
|
243
|
+
* practice today; it mirrors the SDF backend's eviction policy.
|
|
244
|
+
*/
|
|
245
|
+
const cleanup = (): void => {
|
|
246
|
+
while (layoutCache.size > maxLayoutCacheSize) {
|
|
247
|
+
const oldest = layoutCache.keys().next().value as string;
|
|
248
|
+
layoutCache.delete(oldest);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
227
252
|
/**
|
|
228
253
|
* Add quads for rendering (Canvas doesn't use quads)
|
|
229
254
|
*/
|
|
@@ -252,6 +277,7 @@ const CanvasTextRenderer = {
|
|
|
252
277
|
renderQuads,
|
|
253
278
|
init,
|
|
254
279
|
clearLayoutCache,
|
|
280
|
+
cleanup,
|
|
255
281
|
};
|
|
256
282
|
|
|
257
283
|
export default CanvasTextRenderer;
|
|
@@ -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]) {
|