diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 016eee7..69e882b 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -509,6 +509,19 @@ export class Stage { ); } + /** + * Trim text renderer caches back to their configured limits. + * + * Called when the stage goes idle so layout-cache eviction never competes + * with active rendering. + */ + cleanupTextRenderers() { + const textRenderers = this.textRenderers; + for (const key in textRenderers) { + textRenderers[key]!.cleanup(); + } + } + /** * Start a new frame draw */ diff --git a/src/core/platforms/web/WebPlatform.outOfMemory.test.ts b/src/core/platforms/web/WebPlatform.outOfMemory.test.ts index ae544c2..71d2d6c 100644 --- a/src/core/platforms/web/WebPlatform.outOfMemory.test.ts +++ b/src/core/platforms/web/WebPlatform.outOfMemory.test.ts @@ -19,6 +19,7 @@ function makeIdleStage(outOfMemory: boolean) { drawFrame: vi.fn(), flushFrameEvents: vi.fn(), shManager: { cleanup: vi.fn() }, + cleanupTextRenderers: vi.fn(), eventBus: { emit: vi.fn() }, txMemManager: { checkCleanup: vi.fn(() => false), diff --git a/src/core/platforms/web/WebPlatform.ts b/src/core/platforms/web/WebPlatform.ts index 80a7390..15176fc 100644 --- a/src/core/platforms/web/WebPlatform.ts +++ b/src/core/platforms/web/WebPlatform.ts @@ -86,6 +86,7 @@ export class WebPlatform extends Platform { stage.txMemManager.handleOutOfMemory(); } stage.shManager.cleanup(); + stage.cleanupTextRenderers(); stage.eventBus.emit('idle'); isIdle = true; } diff --git a/src/core/text-rendering/CanvasTextRenderer.ts b/src/core/text-rendering/CanvasTextRenderer.ts index ec1c4ca..43bded2 100644 --- a/src/core/text-rendering/CanvasTextRenderer.ts +++ b/src/core/text-rendering/CanvasTextRenderer.ts @@ -40,10 +40,22 @@ const layoutCache = new Map< } >(); +// Upper bound on layoutCache entries, enforced on idle via `cleanup`. +// Overridden from stage options in `init`. Note: the Canvas path does not +// currently populate `layoutCache`, so this is effectively inert today and +// exists to keep the eviction policy uniform with the SDF backend should +// Canvas layout caching be wired up later. +let maxLayoutCacheSize = 250; + // Initialize the Text Renderer const init = (stage: Stage): void => { const dpr = stage.options.devicePhysicalPixelRatio; + const configuredCacheSize = stage.options.textLayoutCacheSize; + if (configuredCacheSize !== undefined) { + maxLayoutCacheSize = configuredCacheSize; + } + // Drawing canvas and context canvas = stage.platform.createCanvas() as HTMLCanvasElement | OffscreenCanvas; context = canvas.getContext('2d', { willReadFrequently: true }) as @@ -224,6 +236,19 @@ const clearLayoutCache = (): void => { layoutCache.clear(); }; +/** + * Trim the layout cache back down to `maxLayoutCacheSize`, evicting the + * least-recently-used entries first. Called when the stage goes idle. The + * Canvas path does not currently populate `layoutCache`, so this is a no-op in + * practice today; it mirrors the SDF backend's eviction policy. + */ +const cleanup = (): void => { + while (layoutCache.size > maxLayoutCacheSize) { + const oldest = layoutCache.keys().next().value as string; + layoutCache.delete(oldest); + } +}; + /** * Add quads for rendering (Canvas doesn't use quads) */ @@ -252,6 +277,7 @@ const CanvasTextRenderer = { renderQuads, init, clearLayoutCache, + cleanup, }; export default CanvasTextRenderer; diff --git a/src/core/text-rendering/SdfTextRenderer.test.ts b/src/core/text-rendering/SdfTextRenderer.test.ts new file mode 100644 index 0000000..89d3cb3 --- /dev/null +++ b/src/core/text-rendering/SdfTextRenderer.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { CoreTextNodeProps } from '../CoreTextNode.js'; + +// Mock the font handler so renderText/generateTextLayout can run without a +// real loaded font. getFontData is only invoked on a layout-cache MISS, so the +// call count is our probe for cache hits vs misses. +vi.mock('./SdfFontHandler.js', () => { + const fontData = { + data: { + common: { base: 0, scaleW: 512, scaleH: 512, lineHeight: 50 }, + info: { size: 42 }, + distanceField: { distanceRange: 4 }, + }, + glyphMap: new Map(), + kernings: {}, + atlasTexture: {}, + metrics: {}, + maxCharHeight: 50, + }; + const metrics = { + ascender: 40, + descender: -10, + lineGap: 0, + capHeight: 30, + xHeight: 20, + }; + return { + type: 'sdf', + init: vi.fn(), + getFontData: vi.fn(() => fontData), + getFontMetrics: vi.fn(() => metrics), + measureText: vi.fn((text: string) => text.length * 10), + getAtlas: vi.fn(() => null), + }; +}); + +import SdfTextRenderer from './SdfTextRenderer.js'; +import * as SdfFontHandler from './SdfFontHandler.js'; + +const makeProps = (text: string): CoreTextNodeProps => + ({ + text, + fontFamily: 'Test', + fontStyle: 'normal', + fontSize: 42, + letterSpacing: 0, + lineHeight: 0, + maxHeight: 0, + maxWidth: 100000, + maxLines: 0, + textAlign: 'left', + wordBreak: 'normal', + overflowSuffix: '', + } as unknown as CoreTextNodeProps); + +const initRenderer = (cacheSize: number): void => { + const fakeStage = { + options: { textLayoutCacheSize: cacheSize }, + shManager: { + registerShaderType: vi.fn(), + createShader: vi.fn(() => ({})), + }, + }; + SdfTextRenderer.init(fakeStage as never); +}; + +const render = (text: string): void => { + SdfTextRenderer.renderText(makeProps(text)); +}; + +describe('SdfTextRenderer layout cache', () => { + beforeEach(() => { + // Empty the module-level cache between tests, then reset call counts. + initRenderer(0); + SdfTextRenderer.cleanup(); + vi.clearAllMocks(); + }); + + it('reuses the cached layout for identical strings', () => { + initRenderer(10); + + render('Badge'); + render('Badge'); + + // Second render is a cache hit: no fresh layout generation. + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1); + }); + + it('caches long strings too (no length-based skip)', () => { + initRenderer(10); + const long = 'x'.repeat(500); + + render(long); + render(long); + + // Bounded purely by the LRU cap, not by length. + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1); + }); + + it('cleanup trims to the cap and evicts least-recently-used first', () => { + initRenderer(2); + + render('A'); + render('B'); + render('C'); + // Re-access 'A' so it becomes most-recently-used; 'B' is now the LRU. + render('A'); + + SdfTextRenderer.cleanup(); + + vi.clearAllMocks(); + render('B'); // evicted -> miss + render('A'); // survived -> hit + render('C'); // survived -> hit + + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1); + }); + + it('cleanup is a no-op while under the cap', () => { + initRenderer(10); + + render('one'); + render('two'); + + SdfTextRenderer.cleanup(); + + vi.clearAllMocks(); + render('one'); // still cached -> hit + render('two'); // still cached -> hit + + expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index bcfd4cc..3ae298e 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -24,10 +24,20 @@ const type = 'sdf' as const; let sdfShader: WebGlShaderNode | null = null; +// Upper bound on layoutCache entries, enforced on idle via `cleanup`. +// Overridden from stage options in `init`. The cache is allowed to grow past +// this during active rendering and is trimmed back to it when the stage idles. +let maxLayoutCacheSize = 250; + // Initialize the SDF text renderer const init = (stage: Stage): void => { SdfFontHandler.init(); + const configuredCacheSize = stage.options.textLayoutCacheSize; + if (configuredCacheSize !== undefined) { + maxLayoutCacheSize = configuredCacheSize; + } + // Register SDF shader with the shader manager stage.shManager.registerShaderType('Sdf', Sdf); sdfShader = stage.shManager.createShader('Sdf') as WebGlShaderNode; @@ -58,6 +68,11 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { const cacheKey = getLayoutCacheKey(props); let layout = layoutCache.get(cacheKey); if (layout !== undefined) { + // Refresh LRU recency: re-insert moves the key to the most-recently-used + // end so idle `cleanup` evicts genuinely cold entries first. renderText + // runs on text/layout change, not per frame, so this re-insert is cheap. + layoutCache.delete(cacheKey); + layoutCache.set(cacheKey, layout); return { remainingLines: 0, hasRemainingText: false, @@ -357,6 +372,20 @@ const generateTextLayout = ( }; }; +/** + * Trim the layout cache back down to `maxLayoutCacheSize`, evicting the + * least-recently-used entries first. Called when the stage goes idle so this + * never competes with active rendering. A fresh iterator is taken each step so + * we always delete the current front (oldest) key without iterator-invalidation + * concerns; this runs at most once per idle transition and only when over cap. + */ +const cleanup = (): void => { + while (layoutCache.size > maxLayoutCacheSize) { + const oldest = layoutCache.keys().next().value as string; + layoutCache.delete(oldest); + } +}; + /** * SDF Text Renderer - implements TextRenderer interface */ @@ -367,6 +396,7 @@ const SdfTextRenderer = { addQuads, renderQuads, init, + cleanup, }; export default SdfTextRenderer; diff --git a/src/core/text-rendering/TextRenderer.ts b/src/core/text-rendering/TextRenderer.ts index 8a00ab8..93af242 100644 --- a/src/core/text-rendering/TextRenderer.ts +++ b/src/core/text-rendering/TextRenderer.ts @@ -435,6 +435,13 @@ export interface TextRenderer { renderProps: TextRenderProps, ) => void | SdfRenderOp | null; init: (stage: Stage) => void; + /** + * Trim internal caches back down to their configured limits. + * Called when the stage goes idle so cache eviction never competes with + * active rendering. Backends with no bounded cache may implement this as a + * no-op. + */ + cleanup: () => void; } /** diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index e8e130f..f4af1c0 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -393,6 +393,23 @@ export interface RendererRuntimeSettings { * Configuration settings for {@link RendererMain} */ export type RendererMainSettings = RendererRuntimeSettings & { + /** + * Maximum number of entries kept in the SDF text layout cache + * + * @remarks + * The SDF text renderer caches the computed glyph layout for a given + * `text` + font + layout-prop combination so that identical strings (e.g. + * repeated badges/labels) are not re-laid-out. The cache is content-keyed + * and shared across nodes, and is trimmed down to this many (most recently + * used) entries whenever the stage goes idle. + * + * Set this higher for content-dense UIs with many simultaneous unique + * strings, or lower to cap memory more aggressively. + * + * @defaultValue `250` + */ + textLayoutCacheSize: number; + /** * Include context call (i.e. WebGL) information in FPS updates * @@ -678,6 +695,7 @@ export class RendererMain extends EventEmitter { fpsUpdateInterval: settings.fpsUpdateInterval || 0, enableClear: settings.enableClear ?? true, targetFPS: settings.targetFPS || 0, + textLayoutCacheSize: settings.textLayoutCacheSize ?? 250, numImageWorkers: settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2, enableContextSpy: settings.enableContextSpy ?? false, @@ -755,6 +773,7 @@ export class RendererMain extends EventEmitter { textBaselineMode: settings.textBaselineMode!, inspector: settings.inspector !== null, targetFPS: settings.targetFPS!, + textLayoutCacheSize: settings.textLayoutCacheSize!, textureProcessingTimeLimit: settings.textureProcessingTimeLimit!, createImageBitmapSupport: settings.createImageBitmapSupport!, premultiplyAlphaHonored: settings.premultiplyAlphaHonored,