@solidtv/renderer 1.3.1 → 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.js +4 -3
- 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 +1 -0
- package/dist/src/core/platforms/web/WebPlatform.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.js +23 -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/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/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 +16 -0
- package/dist/src/main-api/Renderer.js +2 -0
- 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.ts +4 -3
- package/src/core/lib/utils.ts +0 -4
- package/src/core/platforms/web/WebPlatform.outOfMemory.test.ts +1 -0
- package/src/core/platforms/web/WebPlatform.ts +1 -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 +33 -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/WebGlShaderNode.ts +0 -1
- package/src/core/text-rendering/CanvasTextRenderer.ts +26 -0
- 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/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 +19 -0
|
@@ -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;
|
|
@@ -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 (
|
|
@@ -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;
|
|
@@ -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
|
/**
|
|
@@ -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
|
@@ -393,6 +393,23 @@ export interface RendererRuntimeSettings {
|
|
|
393
393
|
* Configuration settings for {@link RendererMain}
|
|
394
394
|
*/
|
|
395
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
|
+
|
|
396
413
|
/**
|
|
397
414
|
* Include context call (i.e. WebGL) information in FPS updates
|
|
398
415
|
*
|
|
@@ -678,6 +695,7 @@ export class RendererMain extends EventEmitter {
|
|
|
678
695
|
fpsUpdateInterval: settings.fpsUpdateInterval || 0,
|
|
679
696
|
enableClear: settings.enableClear ?? true,
|
|
680
697
|
targetFPS: settings.targetFPS || 0,
|
|
698
|
+
textLayoutCacheSize: settings.textLayoutCacheSize ?? 250,
|
|
681
699
|
numImageWorkers:
|
|
682
700
|
settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2,
|
|
683
701
|
enableContextSpy: settings.enableContextSpy ?? false,
|
|
@@ -755,6 +773,7 @@ export class RendererMain extends EventEmitter {
|
|
|
755
773
|
textBaselineMode: settings.textBaselineMode!,
|
|
756
774
|
inspector: settings.inspector !== null,
|
|
757
775
|
targetFPS: settings.targetFPS!,
|
|
776
|
+
textLayoutCacheSize: settings.textLayoutCacheSize!,
|
|
758
777
|
textureProcessingTimeLimit: settings.textureProcessingTimeLimit!,
|
|
759
778
|
createImageBitmapSupport: settings.createImageBitmapSupport!,
|
|
760
779
|
premultiplyAlphaHonored: settings.premultiplyAlphaHonored,
|