@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,133 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { CoreTextNodeProps } from '../CoreTextNode.js';
|
|
3
|
+
|
|
4
|
+
// Mock the font handler so renderText/generateTextLayout can run without a
|
|
5
|
+
// real loaded font. getFontData is only invoked on a layout-cache MISS, so the
|
|
6
|
+
// call count is our probe for cache hits vs misses.
|
|
7
|
+
vi.mock('./SdfFontHandler.js', () => {
|
|
8
|
+
const fontData = {
|
|
9
|
+
data: {
|
|
10
|
+
common: { base: 0, scaleW: 512, scaleH: 512, lineHeight: 50 },
|
|
11
|
+
info: { size: 42 },
|
|
12
|
+
distanceField: { distanceRange: 4 },
|
|
13
|
+
},
|
|
14
|
+
glyphMap: new Map(),
|
|
15
|
+
kernings: {},
|
|
16
|
+
atlasTexture: {},
|
|
17
|
+
metrics: {},
|
|
18
|
+
maxCharHeight: 50,
|
|
19
|
+
};
|
|
20
|
+
const metrics = {
|
|
21
|
+
ascender: 40,
|
|
22
|
+
descender: -10,
|
|
23
|
+
lineGap: 0,
|
|
24
|
+
capHeight: 30,
|
|
25
|
+
xHeight: 20,
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
type: 'sdf',
|
|
29
|
+
init: vi.fn(),
|
|
30
|
+
getFontData: vi.fn(() => fontData),
|
|
31
|
+
getFontMetrics: vi.fn(() => metrics),
|
|
32
|
+
measureText: vi.fn((text: string) => text.length * 10),
|
|
33
|
+
getAtlas: vi.fn(() => null),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
import SdfTextRenderer from './SdfTextRenderer.js';
|
|
38
|
+
import * as SdfFontHandler from './SdfFontHandler.js';
|
|
39
|
+
|
|
40
|
+
const makeProps = (text: string): CoreTextNodeProps =>
|
|
41
|
+
({
|
|
42
|
+
text,
|
|
43
|
+
fontFamily: 'Test',
|
|
44
|
+
fontStyle: 'normal',
|
|
45
|
+
fontSize: 42,
|
|
46
|
+
letterSpacing: 0,
|
|
47
|
+
lineHeight: 0,
|
|
48
|
+
maxHeight: 0,
|
|
49
|
+
maxWidth: 100000,
|
|
50
|
+
maxLines: 0,
|
|
51
|
+
textAlign: 'left',
|
|
52
|
+
wordBreak: 'normal',
|
|
53
|
+
overflowSuffix: '',
|
|
54
|
+
} as unknown as CoreTextNodeProps);
|
|
55
|
+
|
|
56
|
+
const initRenderer = (cacheSize: number): void => {
|
|
57
|
+
const fakeStage = {
|
|
58
|
+
options: { textLayoutCacheSize: cacheSize },
|
|
59
|
+
shManager: {
|
|
60
|
+
registerShaderType: vi.fn(),
|
|
61
|
+
createShader: vi.fn(() => ({})),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
SdfTextRenderer.init(fakeStage as never);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const render = (text: string): void => {
|
|
68
|
+
SdfTextRenderer.renderText(makeProps(text));
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
describe('SdfTextRenderer layout cache', () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
// Empty the module-level cache between tests, then reset call counts.
|
|
74
|
+
initRenderer(0);
|
|
75
|
+
SdfTextRenderer.cleanup();
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('reuses the cached layout for identical strings', () => {
|
|
80
|
+
initRenderer(10);
|
|
81
|
+
|
|
82
|
+
render('Badge');
|
|
83
|
+
render('Badge');
|
|
84
|
+
|
|
85
|
+
// Second render is a cache hit: no fresh layout generation.
|
|
86
|
+
expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('caches long strings too (no length-based skip)', () => {
|
|
90
|
+
initRenderer(10);
|
|
91
|
+
const long = 'x'.repeat(500);
|
|
92
|
+
|
|
93
|
+
render(long);
|
|
94
|
+
render(long);
|
|
95
|
+
|
|
96
|
+
// Bounded purely by the LRU cap, not by length.
|
|
97
|
+
expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('cleanup trims to the cap and evicts least-recently-used first', () => {
|
|
101
|
+
initRenderer(2);
|
|
102
|
+
|
|
103
|
+
render('A');
|
|
104
|
+
render('B');
|
|
105
|
+
render('C');
|
|
106
|
+
// Re-access 'A' so it becomes most-recently-used; 'B' is now the LRU.
|
|
107
|
+
render('A');
|
|
108
|
+
|
|
109
|
+
SdfTextRenderer.cleanup();
|
|
110
|
+
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
render('B'); // evicted -> miss
|
|
113
|
+
render('A'); // survived -> hit
|
|
114
|
+
render('C'); // survived -> hit
|
|
115
|
+
|
|
116
|
+
expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('cleanup is a no-op while under the cap', () => {
|
|
120
|
+
initRenderer(10);
|
|
121
|
+
|
|
122
|
+
render('one');
|
|
123
|
+
render('two');
|
|
124
|
+
|
|
125
|
+
SdfTextRenderer.cleanup();
|
|
126
|
+
|
|
127
|
+
vi.clearAllMocks();
|
|
128
|
+
render('one'); // still cached -> hit
|
|
129
|
+
render('two'); // still cached -> hit
|
|
130
|
+
|
|
131
|
+
expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(0);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -24,10 +24,20 @@ const type = 'sdf' as const;
|
|
|
24
24
|
|
|
25
25
|
let sdfShader: WebGlShaderNode | null = null;
|
|
26
26
|
|
|
27
|
+
// Upper bound on layoutCache entries, enforced on idle via `cleanup`.
|
|
28
|
+
// Overridden from stage options in `init`. The cache is allowed to grow past
|
|
29
|
+
// this during active rendering and is trimmed back to it when the stage idles.
|
|
30
|
+
let maxLayoutCacheSize = 250;
|
|
31
|
+
|
|
27
32
|
// Initialize the SDF text renderer
|
|
28
33
|
const init = (stage: Stage): void => {
|
|
29
34
|
SdfFontHandler.init();
|
|
30
35
|
|
|
36
|
+
const configuredCacheSize = stage.options.textLayoutCacheSize;
|
|
37
|
+
if (configuredCacheSize !== undefined) {
|
|
38
|
+
maxLayoutCacheSize = configuredCacheSize;
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// Register SDF shader with the shader manager
|
|
32
42
|
stage.shManager.registerShaderType('Sdf', Sdf);
|
|
33
43
|
sdfShader = stage.shManager.createShader('Sdf') as WebGlShaderNode;
|
|
@@ -58,6 +68,11 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
|
|
|
58
68
|
const cacheKey = getLayoutCacheKey(props);
|
|
59
69
|
let layout = layoutCache.get(cacheKey);
|
|
60
70
|
if (layout !== undefined) {
|
|
71
|
+
// Refresh LRU recency: re-insert moves the key to the most-recently-used
|
|
72
|
+
// end so idle `cleanup` evicts genuinely cold entries first. renderText
|
|
73
|
+
// runs on text/layout change, not per frame, so this re-insert is cheap.
|
|
74
|
+
layoutCache.delete(cacheKey);
|
|
75
|
+
layoutCache.set(cacheKey, layout);
|
|
61
76
|
return {
|
|
62
77
|
remainingLines: 0,
|
|
63
78
|
hasRemainingText: false,
|
|
@@ -357,6 +372,20 @@ const generateTextLayout = (
|
|
|
357
372
|
};
|
|
358
373
|
};
|
|
359
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Trim the layout cache back down to `maxLayoutCacheSize`, evicting the
|
|
377
|
+
* least-recently-used entries first. Called when the stage goes idle so this
|
|
378
|
+
* never competes with active rendering. A fresh iterator is taken each step so
|
|
379
|
+
* we always delete the current front (oldest) key without iterator-invalidation
|
|
380
|
+
* concerns; this runs at most once per idle transition and only when over cap.
|
|
381
|
+
*/
|
|
382
|
+
const cleanup = (): void => {
|
|
383
|
+
while (layoutCache.size > maxLayoutCacheSize) {
|
|
384
|
+
const oldest = layoutCache.keys().next().value as string;
|
|
385
|
+
layoutCache.delete(oldest);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
360
389
|
/**
|
|
361
390
|
* SDF Text Renderer - implements TextRenderer interface
|
|
362
391
|
*/
|
|
@@ -367,6 +396,7 @@ const SdfTextRenderer = {
|
|
|
367
396
|
addQuads,
|
|
368
397
|
renderQuads,
|
|
369
398
|
init,
|
|
399
|
+
cleanup,
|
|
370
400
|
};
|
|
371
401
|
|
|
372
402
|
export default SdfTextRenderer;
|
|
@@ -435,6 +435,13 @@ export interface TextRenderer {
|
|
|
435
435
|
renderProps: TextRenderProps,
|
|
436
436
|
) => void | SdfRenderOp | null;
|
|
437
437
|
init: (stage: Stage) => void;
|
|
438
|
+
/**
|
|
439
|
+
* Trim internal caches back down to their configured limits.
|
|
440
|
+
* Called when the stage goes idle so cache eviction never competes with
|
|
441
|
+
* active rendering. Backends with no bounded cache may implement this as a
|
|
442
|
+
* no-op.
|
|
443
|
+
*/
|
|
444
|
+
cleanup: () => void;
|
|
438
445
|
}
|
|
439
446
|
|
|
440
447
|
/**
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { loadFont } from '../SdfFontHandler.js';
|
|
3
|
+
import { EventEmitter } from '../../../common/EventEmitter.js';
|
|
4
|
+
import { TextureError, TextureErrorCode } from '../../TextureError.js';
|
|
5
|
+
import type { Stage } from '../../Stage.js';
|
|
6
|
+
|
|
7
|
+
// Minimal XHR stand-in: synchronously delivers valid SDF font JSON so loadFont
|
|
8
|
+
// proceeds to create the atlas texture and attach its event listeners.
|
|
9
|
+
class FakeXHR {
|
|
10
|
+
status = 200;
|
|
11
|
+
response: unknown = null;
|
|
12
|
+
responseType = '';
|
|
13
|
+
onload: (() => void) | null = null;
|
|
14
|
+
onerror: (() => void) | null = null;
|
|
15
|
+
open(): void {}
|
|
16
|
+
send(): void {
|
|
17
|
+
this.response = { chars: [{}] };
|
|
18
|
+
if (this.onload !== null) {
|
|
19
|
+
this.onload();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A texture is just an EventEmitter to loadFont; stub the few props it reads.
|
|
25
|
+
function makeFakeTexture() {
|
|
26
|
+
const tex = new EventEmitter() as unknown as EventEmitter & {
|
|
27
|
+
state: string;
|
|
28
|
+
preventCleanup: boolean;
|
|
29
|
+
setRenderableOwner: (owner: string, val: boolean) => void;
|
|
30
|
+
};
|
|
31
|
+
tex.state = 'loading'; // not 'loaded' -> goes through the listener path
|
|
32
|
+
tex.preventCleanup = false;
|
|
33
|
+
tex.setRenderableOwner = () => {};
|
|
34
|
+
return tex;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Drain microtasks + one macrotask so the async loader reaches listener setup.
|
|
38
|
+
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
39
|
+
|
|
40
|
+
describe('SdfFontHandler loadFont — failed event argument', () => {
|
|
41
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
42
|
+
let originalXHR: unknown;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
46
|
+
originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
|
|
47
|
+
.XMLHttpRequest;
|
|
48
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
49
|
+
FakeXHR;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
errSpy.mockRestore();
|
|
54
|
+
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
|
|
55
|
+
originalXHR;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects with the TextureError (second emit arg), not the emitting texture', async () => {
|
|
59
|
+
const tex = makeFakeTexture();
|
|
60
|
+
const stage = {
|
|
61
|
+
txManager: { createTexture: () => tex },
|
|
62
|
+
} as unknown as Stage;
|
|
63
|
+
|
|
64
|
+
const promise = loadFont(stage, {
|
|
65
|
+
fontFamily: 'TestSdfFailFont',
|
|
66
|
+
atlasUrl: 'atlas.png',
|
|
67
|
+
atlasDataUrl: 'atlas.json',
|
|
68
|
+
} as Parameters<typeof loadFont>[1]);
|
|
69
|
+
|
|
70
|
+
await flush();
|
|
71
|
+
|
|
72
|
+
const error = new TextureError(
|
|
73
|
+
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
|
|
74
|
+
'boom',
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Attach the rejection assertion before emitting so the handler is ready.
|
|
78
|
+
const assertion = expect(promise).rejects.toBe(error);
|
|
79
|
+
|
|
80
|
+
// EventEmitter calls listeners as (target, data) -> (tex, error).
|
|
81
|
+
tex.emit('failed', error);
|
|
82
|
+
|
|
83
|
+
await assertion;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('logs the error, not the texture, on failure', async () => {
|
|
87
|
+
const tex = makeFakeTexture();
|
|
88
|
+
const stage = {
|
|
89
|
+
txManager: { createTexture: () => tex },
|
|
90
|
+
} as unknown as Stage;
|
|
91
|
+
|
|
92
|
+
const promise = loadFont(stage, {
|
|
93
|
+
fontFamily: 'TestSdfFailFontLog',
|
|
94
|
+
atlasUrl: 'atlas.png',
|
|
95
|
+
atlasDataUrl: 'atlas.json',
|
|
96
|
+
} as Parameters<typeof loadFont>[1]);
|
|
97
|
+
|
|
98
|
+
await flush();
|
|
99
|
+
|
|
100
|
+
const error = new TextureError(
|
|
101
|
+
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
|
|
102
|
+
'boom',
|
|
103
|
+
);
|
|
104
|
+
const rejected = promise.catch(() => {});
|
|
105
|
+
tex.emit('failed', error);
|
|
106
|
+
await rejected;
|
|
107
|
+
|
|
108
|
+
const lastCall = errSpy.mock.calls[errSpy.mock.calls.length - 1]!;
|
|
109
|
+
expect(lastCall[1]).toBe(error);
|
|
110
|
+
expect(lastCall[1]).not.toBe(tex);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -269,6 +269,24 @@ export class ImageTexture extends Texture {
|
|
|
269
269
|
}
|
|
270
270
|
|
|
271
271
|
override async getTextureSource(): Promise<TextureData> {
|
|
272
|
+
// Compressed textures are not supported by the Canvas2D renderer.
|
|
273
|
+
// Fail fast here before incurring a network fetch or binary decode.
|
|
274
|
+
if (this.txManager.renderer?.mode === 'canvas') {
|
|
275
|
+
const { src, type } = this.props;
|
|
276
|
+
if (
|
|
277
|
+
type === 'compressed' ||
|
|
278
|
+
(typeof src === 'string' && isCompressedTextureContainer(src) === true)
|
|
279
|
+
) {
|
|
280
|
+
const err = new Error(
|
|
281
|
+
`ImageTexture: Compressed textures are not supported in Canvas2D render mode (src: ${String(
|
|
282
|
+
src,
|
|
283
|
+
)})`,
|
|
284
|
+
);
|
|
285
|
+
this.setState('failed', err);
|
|
286
|
+
return { data: null };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
272
290
|
let resp: TextureData;
|
|
273
291
|
try {
|
|
274
292
|
resp = await this.determineImageTypeAndLoadImage();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { SubTexture } from './SubTexture.js';
|
|
3
|
+
import { ImageTexture } from './ImageTexture.js';
|
|
4
|
+
import type { CoreTextureManager } from '../CoreTextureManager.js';
|
|
5
|
+
|
|
6
|
+
const flushMicrotasks = () => Promise.resolve();
|
|
7
|
+
|
|
8
|
+
describe('SubTexture lifecycle', () => {
|
|
9
|
+
it('detaches its parent-texture listeners on destroy', async () => {
|
|
10
|
+
// 'initial' state means the constructor's microtask attaches listeners
|
|
11
|
+
// without synchronously firing any of the state handlers.
|
|
12
|
+
const parent = {
|
|
13
|
+
state: 'initial',
|
|
14
|
+
dimensions: null,
|
|
15
|
+
error: null,
|
|
16
|
+
on: vi.fn(),
|
|
17
|
+
off: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
const txManager = {
|
|
20
|
+
maxRetryCount: 0,
|
|
21
|
+
platform: {},
|
|
22
|
+
resolveParentTexture: () => parent,
|
|
23
|
+
} as unknown as CoreTextureManager;
|
|
24
|
+
|
|
25
|
+
const parentImage = new ImageTexture(txManager, {} as never);
|
|
26
|
+
const sub = new SubTexture(txManager, {
|
|
27
|
+
texture: parentImage,
|
|
28
|
+
x: 0,
|
|
29
|
+
y: 0,
|
|
30
|
+
w: 10,
|
|
31
|
+
h: 10,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Listeners are attached in a microtask after construction.
|
|
35
|
+
await flushMicrotasks();
|
|
36
|
+
expect(parent.on).toHaveBeenCalledTimes(4);
|
|
37
|
+
|
|
38
|
+
sub.destroy();
|
|
39
|
+
|
|
40
|
+
const offEvents = (parent.off.mock.calls as Array<[string]>)
|
|
41
|
+
.map((c) => c[0])
|
|
42
|
+
.sort();
|
|
43
|
+
expect(offEvents).toEqual(['failed', 'freed', 'loaded', 'loading']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -133,6 +133,19 @@ export class SubTexture extends Texture {
|
|
|
133
133
|
this.parentTexture.setRenderableOwner(this.subtextureId, isRenderable);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
override destroy(): void {
|
|
137
|
+
// Detach from the parent texture's event emitter. The parent is typically a
|
|
138
|
+
// long-lived shared atlas (preventCleanup), so without this each destroyed
|
|
139
|
+
// SubTexture — and everything its handlers capture — would be retained by
|
|
140
|
+
// the parent's listener lists for the rest of the session.
|
|
141
|
+
const parentTx = this.parentTexture;
|
|
142
|
+
parentTx.off('loading', this.onParentTxLoading);
|
|
143
|
+
parentTx.off('loaded', this.onParentTxLoaded);
|
|
144
|
+
parentTx.off('failed', this.onParentTxFailed);
|
|
145
|
+
parentTx.off('freed', this.onParentTxFreed);
|
|
146
|
+
super.destroy();
|
|
147
|
+
}
|
|
148
|
+
|
|
136
149
|
override async getTextureSource(): Promise<TextureData> {
|
|
137
150
|
// Check if parent texture is loaded
|
|
138
151
|
return new Promise((resolve, reject) => {
|
|
@@ -240,6 +240,11 @@ export abstract class Texture extends EventEmitter {
|
|
|
240
240
|
return false;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
// Don't cleanup a texture that is in the process of loading
|
|
244
|
+
if (this.state === 'loading') {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
243
248
|
// Don't cleanup if not renderable
|
|
244
249
|
if (this.renderable === true) {
|
|
245
250
|
return false;
|
|
@@ -341,6 +346,9 @@ export abstract class Texture extends EventEmitter {
|
|
|
341
346
|
*/
|
|
342
347
|
free(): void {
|
|
343
348
|
this.ctxTexture?.free();
|
|
349
|
+
// Null out the freed ctxTexture so a subsequent reload re-creates it via
|
|
350
|
+
// getCtxTexture() instead of reusing a stale, already-freed reference.
|
|
351
|
+
this.ctxTexture = undefined;
|
|
344
352
|
}
|
|
345
353
|
|
|
346
354
|
/**
|
|
@@ -370,6 +378,10 @@ export abstract class Texture extends EventEmitter {
|
|
|
370
378
|
|
|
371
379
|
// Always free texture data regardless of state
|
|
372
380
|
this.freeTextureData();
|
|
381
|
+
|
|
382
|
+
// Drop any remaining subscribers so a texture destroyed while something
|
|
383
|
+
// still holds a listener does not retain it (and its captures).
|
|
384
|
+
this.removeAllListeners();
|
|
373
385
|
}
|
|
374
386
|
|
|
375
387
|
/**
|
package/src/main-api/Renderer.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { Platform } from '../core/platforms/Platform.js';
|
|
|
28
28
|
* @category Events
|
|
29
29
|
* @example
|
|
30
30
|
* ```typescript
|
|
31
|
-
* renderer.on('fpsUpdate', (data) => {
|
|
31
|
+
* renderer.on('fpsUpdate', (_target, data) => {
|
|
32
32
|
* console.log(`Current FPS: ${data.fps}`);
|
|
33
33
|
* if (data.contextSpyData) {
|
|
34
34
|
* console.log('WebGL calls:', data.contextSpyData);
|
|
@@ -49,7 +49,7 @@ export interface RendererMainFpsUpdateEvent {
|
|
|
49
49
|
* @category Events
|
|
50
50
|
* @example
|
|
51
51
|
* ```typescript
|
|
52
|
-
* renderer.on('frameTick', (data) => {
|
|
52
|
+
* renderer.on('frameTick', (_target, data) => {
|
|
53
53
|
* console.log(`Frame time: ${data.time}ms, delta: ${data.delta}ms`);
|
|
54
54
|
* });
|
|
55
55
|
* ```
|
|
@@ -67,7 +67,7 @@ export interface RendererMainFrameTickEvent {
|
|
|
67
67
|
* @category Events
|
|
68
68
|
* @example
|
|
69
69
|
* ```typescript
|
|
70
|
-
* renderer.on('renderUpdate', (data) => {
|
|
70
|
+
* renderer.on('renderUpdate', (_target, data) => {
|
|
71
71
|
* console.log(`Rendered quads: ${data.quads}, renderOps: ${data.renderOps}`);
|
|
72
72
|
* });
|
|
73
73
|
* ```
|
|
@@ -110,7 +110,7 @@ export interface RendererMainIdleEvent {
|
|
|
110
110
|
* @category Events
|
|
111
111
|
* @example
|
|
112
112
|
* ```typescript
|
|
113
|
-
* renderer.on('criticalCleanup', (data) => {
|
|
113
|
+
* renderer.on('criticalCleanup', (_target, data) => {
|
|
114
114
|
* console.log(`Memory cleanup triggered!`);
|
|
115
115
|
* console.log(`Memory used: ${data.memUsed} bytes`);
|
|
116
116
|
* console.log(`Critical threshold: ${data.criticalThreshold} bytes`);
|
|
@@ -130,7 +130,7 @@ export interface RendererMainCriticalCleanupEvent {
|
|
|
130
130
|
* @category Events
|
|
131
131
|
* @example
|
|
132
132
|
* ```typescript
|
|
133
|
-
* renderer.on('criticalCleanupFailed', (data) => {
|
|
133
|
+
* renderer.on('criticalCleanupFailed', (_target, data) => {
|
|
134
134
|
* console.warn(`Memory cleanup failed!`);
|
|
135
135
|
* console.log(`Memory still used: ${data.memUsed} bytes`);
|
|
136
136
|
* console.log(`Critical threshold: ${data.criticalThreshold} bytes`);
|
|
@@ -167,6 +167,90 @@ export interface RendererMainContextLostEvent {
|
|
|
167
167
|
readonly __eventHasNoPayload?: never;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* GPU Out Of Memory Event Data
|
|
172
|
+
*
|
|
173
|
+
* @remarks
|
|
174
|
+
* Fired when the renderer detects a real `GL_OUT_OF_MEMORY` from the GPU (probed
|
|
175
|
+
* once per frame). This is the only certain signal that the texture memory
|
|
176
|
+
* estimate has overshot the device's real VRAM budget. At this point a texture
|
|
177
|
+
* upload has already failed and the driver may soon drop the WebGL context, so
|
|
178
|
+
* the supported recovery is for the application to reload with a lower
|
|
179
|
+
* `criticalThreshold`.
|
|
180
|
+
*
|
|
181
|
+
* `memUsed` is the estimated texture memory in use at the moment of the failure.
|
|
182
|
+
* Because the upload failed, the real GPU budget is at or below this value — so
|
|
183
|
+
* it is a good basis for the next `criticalThreshold`.
|
|
184
|
+
*
|
|
185
|
+
* The renderer deliberately does NOT persist, reload, or change the threshold
|
|
186
|
+
* itself — that is application policy. The recommended integration is to lower
|
|
187
|
+
* the threshold, persist it, and reload:
|
|
188
|
+
*
|
|
189
|
+
* @category Events
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* // --- on startup: read the calibrated threshold before creating the renderer
|
|
193
|
+
* //
|
|
194
|
+
* // Namespace the storage key per app. On TV devices that run from the
|
|
195
|
+
* // filesystem (file://) the origin is null/opaque, so a bare key can collide
|
|
196
|
+
* // across apps — including the path keeps each app's calibration separate.
|
|
197
|
+
* const STORAGE_KEY = `myapp:criticalThreshold:${location.pathname}`;
|
|
198
|
+
*
|
|
199
|
+
* // Never let calibration drive the threshold so low the UX breaks. Pick a
|
|
200
|
+
* // floor that matches your app (here, 70% of the default budget).
|
|
201
|
+
* const DEFAULT_CRITICAL = 200e6;
|
|
202
|
+
* const MIN_THRESHOLD = Math.round(DEFAULT_CRITICAL * 0.7);
|
|
203
|
+
*
|
|
204
|
+
* function readCriticalThreshold(): number {
|
|
205
|
+
* const raw =
|
|
206
|
+
* typeof localStorage !== 'undefined'
|
|
207
|
+
* ? localStorage.getItem(STORAGE_KEY)
|
|
208
|
+
* : null;
|
|
209
|
+
* const stored = raw !== null ? parseInt(raw, 10) : NaN;
|
|
210
|
+
* if (Number.isNaN(stored) === false && stored > 0) {
|
|
211
|
+
* return Math.max(stored, MIN_THRESHOLD);
|
|
212
|
+
* }
|
|
213
|
+
* return DEFAULT_CRITICAL;
|
|
214
|
+
* }
|
|
215
|
+
*
|
|
216
|
+
* const renderer = new RendererMain(
|
|
217
|
+
* { textureMemory: { criticalThreshold: readCriticalThreshold() } },
|
|
218
|
+
* 'app',
|
|
219
|
+
* );
|
|
220
|
+
*
|
|
221
|
+
* // --- at runtime: react to a real GPU out-of-memory
|
|
222
|
+
* let handlingOOM = false;
|
|
223
|
+
* renderer.on('outOfMemory', (_target, { memUsed, criticalThreshold }) => {
|
|
224
|
+
* if (handlingOOM === true) {
|
|
225
|
+
* return; // debounce — several uploads can fail in the same burst
|
|
226
|
+
* }
|
|
227
|
+
* handlingOOM = true;
|
|
228
|
+
*
|
|
229
|
+
* // The OOM proves the real budget is <= memUsed. Drop to 90% of the lower
|
|
230
|
+
* // of (estimate, current threshold), but never below the floor.
|
|
231
|
+
* const ceiling = Math.min(memUsed, criticalThreshold);
|
|
232
|
+
* const next = Math.max(Math.round(ceiling * 0.9), MIN_THRESHOLD);
|
|
233
|
+
*
|
|
234
|
+
* try {
|
|
235
|
+
* localStorage.setItem(STORAGE_KEY, String(next));
|
|
236
|
+
* } catch (e) {
|
|
237
|
+
* // storage may be blocked (e.g. file:// with storage disabled); the new
|
|
238
|
+
* // value just won't survive the reload.
|
|
239
|
+
* }
|
|
240
|
+
*
|
|
241
|
+
* // Reload so the renderer reinitializes with the lower budget. The engine
|
|
242
|
+
* // does not rebuild GPU resources in place, so reload is the clean recovery.
|
|
243
|
+
* location.reload();
|
|
244
|
+
* });
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export interface RendererMainOutOfMemoryEvent {
|
|
248
|
+
/** Estimated texture memory in use at the time of the failure (bytes) */
|
|
249
|
+
memUsed: number;
|
|
250
|
+
/** Critical threshold in effect at the time of the failure (bytes) */
|
|
251
|
+
criticalThreshold: number;
|
|
252
|
+
}
|
|
253
|
+
|
|
170
254
|
/**
|
|
171
255
|
* Settings for the Renderer that can be updated during runtime.
|
|
172
256
|
*/
|
|
@@ -309,6 +393,23 @@ export interface RendererRuntimeSettings {
|
|
|
309
393
|
* Configuration settings for {@link RendererMain}
|
|
310
394
|
*/
|
|
311
395
|
export type RendererMainSettings = RendererRuntimeSettings & {
|
|
396
|
+
/**
|
|
397
|
+
* Maximum number of entries kept in the SDF text layout cache
|
|
398
|
+
*
|
|
399
|
+
* @remarks
|
|
400
|
+
* The SDF text renderer caches the computed glyph layout for a given
|
|
401
|
+
* `text` + font + layout-prop combination so that identical strings (e.g.
|
|
402
|
+
* repeated badges/labels) are not re-laid-out. The cache is content-keyed
|
|
403
|
+
* and shared across nodes, and is trimmed down to this many (most recently
|
|
404
|
+
* used) entries whenever the stage goes idle.
|
|
405
|
+
*
|
|
406
|
+
* Set this higher for content-dense UIs with many simultaneous unique
|
|
407
|
+
* strings, or lower to cap memory more aggressively.
|
|
408
|
+
*
|
|
409
|
+
* @defaultValue `250`
|
|
410
|
+
*/
|
|
411
|
+
textLayoutCacheSize: number;
|
|
412
|
+
|
|
312
413
|
/**
|
|
313
414
|
* Include context call (i.e. WebGL) information in FPS updates
|
|
314
415
|
*
|
|
@@ -532,11 +633,11 @@ export type RendererMainSettings = RendererRuntimeSettings & {
|
|
|
532
633
|
*
|
|
533
634
|
* Listen to events using the standard EventEmitter API:
|
|
534
635
|
* ```typescript
|
|
535
|
-
* renderer.on('fpsUpdate', (data: RendererMainFpsUpdateEvent) => {
|
|
636
|
+
* renderer.on('fpsUpdate', (_target, data: RendererMainFpsUpdateEvent) => {
|
|
536
637
|
* console.log(`FPS: ${data.fps}`);
|
|
537
638
|
* });
|
|
538
639
|
*
|
|
539
|
-
* renderer.on('idle', (
|
|
640
|
+
* renderer.on('idle', () => {
|
|
540
641
|
* // Renderer is idle - no scene changes
|
|
541
642
|
* });
|
|
542
643
|
* ```
|
|
@@ -548,6 +649,7 @@ export type RendererMainSettings = RendererRuntimeSettings & {
|
|
|
548
649
|
* @see {@link RendererMainCriticalCleanupEvent}
|
|
549
650
|
* @see {@link RendererMainCriticalCleanupFailedEvent}
|
|
550
651
|
* @see {@link RendererMainContextLostEvent}
|
|
652
|
+
* @see {@link RendererMainOutOfMemoryEvent}
|
|
551
653
|
*
|
|
552
654
|
* @fires RendererMain#fpsUpdate
|
|
553
655
|
* @fires RendererMain#frameTick
|
|
@@ -556,6 +658,7 @@ export type RendererMainSettings = RendererRuntimeSettings & {
|
|
|
556
658
|
* @fires RendererMain#criticalCleanup
|
|
557
659
|
* @fires RendererMain#criticalCleanupFailed
|
|
558
660
|
* @fires RendererMain#contextLost
|
|
661
|
+
* @fires RendererMain#outOfMemory
|
|
559
662
|
*/
|
|
560
663
|
export class RendererMain extends EventEmitter {
|
|
561
664
|
readonly root: INode;
|
|
@@ -592,6 +695,7 @@ export class RendererMain extends EventEmitter {
|
|
|
592
695
|
fpsUpdateInterval: settings.fpsUpdateInterval || 0,
|
|
593
696
|
enableClear: settings.enableClear ?? true,
|
|
594
697
|
targetFPS: settings.targetFPS || 0,
|
|
698
|
+
textLayoutCacheSize: settings.textLayoutCacheSize ?? 250,
|
|
595
699
|
numImageWorkers:
|
|
596
700
|
settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2,
|
|
597
701
|
enableContextSpy: settings.enableContextSpy ?? false,
|
|
@@ -669,6 +773,7 @@ export class RendererMain extends EventEmitter {
|
|
|
669
773
|
textBaselineMode: settings.textBaselineMode!,
|
|
670
774
|
inspector: settings.inspector !== null,
|
|
671
775
|
targetFPS: settings.targetFPS!,
|
|
776
|
+
textLayoutCacheSize: settings.textLayoutCacheSize!,
|
|
672
777
|
textureProcessingTimeLimit: settings.textureProcessingTimeLimit!,
|
|
673
778
|
createImageBitmapSupport: settings.createImageBitmapSupport!,
|
|
674
779
|
premultiplyAlphaHonored: settings.premultiplyAlphaHonored,
|
|
@@ -717,11 +822,13 @@ export class RendererMain extends EventEmitter {
|
|
|
717
822
|
const currentTxSettings =
|
|
718
823
|
(this.stage && this.stage.options.textureMemory) || {};
|
|
719
824
|
|
|
825
|
+
const criticalThreshold =
|
|
826
|
+
textureMemory?.criticalThreshold ??
|
|
827
|
+
currentTxSettings?.criticalThreshold ??
|
|
828
|
+
200e6;
|
|
829
|
+
|
|
720
830
|
return {
|
|
721
|
-
criticalThreshold
|
|
722
|
-
textureMemory?.criticalThreshold ??
|
|
723
|
-
currentTxSettings?.criticalThreshold ??
|
|
724
|
-
200e6,
|
|
831
|
+
criticalThreshold,
|
|
725
832
|
targetThresholdLevel:
|
|
726
833
|
textureMemory?.targetThresholdLevel ??
|
|
727
834
|
currentTxSettings?.targetThresholdLevel ??
|