Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions src/core/platforms/web/WebPlatform.outOfMemory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/core/platforms/web/WebPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class WebPlatform extends Platform {
stage.txMemManager.handleOutOfMemory();
}
stage.shManager.cleanup();
stage.cleanupTextRenderers();
stage.eventBus.emit('idle');
isIdle = true;
}
Expand Down
26 changes: 26 additions & 0 deletions src/core/text-rendering/CanvasTextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
*/
Expand Down Expand Up @@ -252,6 +277,7 @@ const CanvasTextRenderer = {
renderQuads,
init,
clearLayoutCache,
cleanup,
};

export default CanvasTextRenderer;
133 changes: 133 additions & 0 deletions src/core/text-rendering/SdfTextRenderer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions src/core/text-rendering/SdfTextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
*/
Expand All @@ -367,6 +396,7 @@ const SdfTextRenderer = {
addQuads,
renderQuads,
init,
cleanup,
};

export default SdfTextRenderer;
7 changes: 7 additions & 0 deletions src/core/text-rendering/TextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/main-api/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading