Skip to content

Commit 2f5e7a0

Browse files
chiefcllclaude
andauthored
feat(text): bound SDF text layout cache with idle LRU eviction (#55)
* feat(text): bound SDF text layout cache with idle LRU eviction The SDF text layout cache (`layoutCache` in SdfTextRenderer) is content-keyed and shared across nodes, but had no eviction: every distinct text+font+layout combination ever rendered stayed in the map for the life of the JS context. On long-lived embedded sessions (no page reload), navigating many entity pages with unique descriptions grows the cache monotonically — each ~200-char description retains a glyph-layout array (~30KB) that is never freed, since node destroy only releases per-node caches, not the shared map. Changes: - Add configurable `textLayoutCacheSize` renderer option (default 250), plumbed through to the SDF/Canvas renderers via stage options. - Skip caching strings over 100 chars: long strings are almost always unique and set once, so they have a near-zero hit rate while being the largest entries. They still lay out and render normally; they just don't enter the cache. This removes the leak at its source. - Make the cache a true LRU: cache hits move the entry to most-recently-used, and a new `cleanup()` trims to the cap evicting least-recently-used first. - Run eviction on idle: Stage.cleanupTextRenderers() is called from the "entering idle" block in WebPlatform, so trimming never competes with active rendering. - Add `cleanup` to the TextRenderer interface; Canvas implements it for parity (its layoutCache is currently dead code — declared/cleared but never read/written — so it is inert today). Rendering output is unchanged (byte-identical), so no visual regression test is added. Unit tests cover hit reuse, the 100-char skip boundary, LRU eviction order, and the under-cap no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(text): drop length-based cache skip, rely on LRU alone Remove MAX_CACHED_TEXT_LENGTH and the >100-char skip guard. The idle LRU eviction already bounds the cache regardless of entry size, so the length skip is redundant — long unique strings simply age out as least-recently-used instead of being refused entry. This also lets long strings that *do* repeat (e.g. a description shown across multiple nodes) benefit from caching. Update the renderer option docs and tests accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(webgl): add cleanupTextRenderers to idle-loop stage mock The idle block now calls stage.cleanupTextRenderers(); add it to the mocked stage in the out-of-memory render-loop test so the idle path doesn't throw. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 222ede3 commit 2f5e7a0

8 files changed

Lines changed: 230 additions & 0 deletions

File tree

src/core/Stage.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,19 @@ export class Stage {
509509
);
510510
}
511511

512+
/**
513+
* Trim text renderer caches back to their configured limits.
514+
*
515+
* Called when the stage goes idle so layout-cache eviction never competes
516+
* with active rendering.
517+
*/
518+
cleanupTextRenderers() {
519+
const textRenderers = this.textRenderers;
520+
for (const key in textRenderers) {
521+
textRenderers[key]!.cleanup();
522+
}
523+
}
524+
512525
/**
513526
* Start a new frame draw
514527
*/

src/core/platforms/web/WebPlatform.outOfMemory.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ function makeIdleStage(outOfMemory: boolean) {
1919
drawFrame: vi.fn(),
2020
flushFrameEvents: vi.fn(),
2121
shManager: { cleanup: vi.fn() },
22+
cleanupTextRenderers: vi.fn(),
2223
eventBus: { emit: vi.fn() },
2324
txMemManager: {
2425
checkCleanup: vi.fn(() => false),

src/core/platforms/web/WebPlatform.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export class WebPlatform extends Platform {
8686
stage.txMemManager.handleOutOfMemory();
8787
}
8888
stage.shManager.cleanup();
89+
stage.cleanupTextRenderers();
8990
stage.eventBus.emit('idle');
9091
isIdle = true;
9192
}

src/core/text-rendering/CanvasTextRenderer.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,22 @@ const layoutCache = new Map<
4040
}
4141
>();
4242

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+
4350
// Initialize the Text Renderer
4451
const init = (stage: Stage): void => {
4552
const dpr = stage.options.devicePhysicalPixelRatio;
4653

54+
const configuredCacheSize = stage.options.textLayoutCacheSize;
55+
if (configuredCacheSize !== undefined) {
56+
maxLayoutCacheSize = configuredCacheSize;
57+
}
58+
4759
// Drawing canvas and context
4860
canvas = stage.platform.createCanvas() as HTMLCanvasElement | OffscreenCanvas;
4961
context = canvas.getContext('2d', { willReadFrequently: true }) as
@@ -224,6 +236,19 @@ const clearLayoutCache = (): void => {
224236
layoutCache.clear();
225237
};
226238

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+
227252
/**
228253
* Add quads for rendering (Canvas doesn't use quads)
229254
*/
@@ -252,6 +277,7 @@ const CanvasTextRenderer = {
252277
renderQuads,
253278
init,
254279
clearLayoutCache,
280+
cleanup,
255281
};
256282

257283
export default CanvasTextRenderer;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

src/core/text-rendering/SdfTextRenderer.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,20 @@ const type = 'sdf' as const;
2424

2525
let sdfShader: WebGlShaderNode | null = null;
2626

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+
2732
// Initialize the SDF text renderer
2833
const init = (stage: Stage): void => {
2934
SdfFontHandler.init();
3035

36+
const configuredCacheSize = stage.options.textLayoutCacheSize;
37+
if (configuredCacheSize !== undefined) {
38+
maxLayoutCacheSize = configuredCacheSize;
39+
}
40+
3141
// Register SDF shader with the shader manager
3242
stage.shManager.registerShaderType('Sdf', Sdf);
3343
sdfShader = stage.shManager.createShader('Sdf') as WebGlShaderNode;
@@ -58,6 +68,11 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
5868
const cacheKey = getLayoutCacheKey(props);
5969
let layout = layoutCache.get(cacheKey);
6070
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);
6176
return {
6277
remainingLines: 0,
6378
hasRemainingText: false,
@@ -357,6 +372,20 @@ const generateTextLayout = (
357372
};
358373
};
359374

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+
360389
/**
361390
* SDF Text Renderer - implements TextRenderer interface
362391
*/
@@ -367,6 +396,7 @@ const SdfTextRenderer = {
367396
addQuads,
368397
renderQuads,
369398
init,
399+
cleanup,
370400
};
371401

372402
export default SdfTextRenderer;

src/core/text-rendering/TextRenderer.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,13 @@ export interface TextRenderer {
435435
renderProps: TextRenderProps,
436436
) => void | SdfRenderOp | null;
437437
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;
438445
}
439446

440447
/**

src/main-api/Renderer.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,23 @@ export interface RendererRuntimeSettings {
393393
* Configuration settings for {@link RendererMain}
394394
*/
395395
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+
396413
/**
397414
* Include context call (i.e. WebGL) information in FPS updates
398415
*
@@ -678,6 +695,7 @@ export class RendererMain extends EventEmitter {
678695
fpsUpdateInterval: settings.fpsUpdateInterval || 0,
679696
enableClear: settings.enableClear ?? true,
680697
targetFPS: settings.targetFPS || 0,
698+
textLayoutCacheSize: settings.textLayoutCacheSize ?? 250,
681699
numImageWorkers:
682700
settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2,
683701
enableContextSpy: settings.enableContextSpy ?? false,
@@ -755,6 +773,7 @@ export class RendererMain extends EventEmitter {
755773
textBaselineMode: settings.textBaselineMode!,
756774
inspector: settings.inspector !== null,
757775
targetFPS: settings.targetFPS!,
776+
textLayoutCacheSize: settings.textLayoutCacheSize!,
758777
textureProcessingTimeLimit: settings.textureProcessingTimeLimit!,
759778
createImageBitmapSupport: settings.createImageBitmapSupport!,
760779
premultiplyAlphaHonored: settings.premultiplyAlphaHonored,

0 commit comments

Comments
 (0)