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
7 changes: 7 additions & 0 deletions examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ function createDebugOverlay(renderer: RendererMain): void {
w: 580,
contain: 'width',
color: 0x33ff88ff,
// Use SDF (shared atlas) rather than the Canvas text renderer. This overlay
// rewrites its text every interval; the Canvas renderer rasterizes a fresh
// ImageTexture (a full bitmap) on every text change, which on a long-running
// session leaks memory and can OOM low-RAM devices (black screen). SDF draws
// changing text from the atlas with no per-update texture allocation.
fontFamily: 'Ubuntu',
textRendererOverride: 'sdf',
fontSize: 26,
lineHeight: 30,
zIndex: 100001,
Expand Down
21 changes: 11 additions & 10 deletions src/core/text-rendering/SdfTextRenderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,22 @@ describe('SdfTextRenderer layout cache', () => {
expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
});

it('cleanup trims to the cap and evicts least-recently-used first', () => {
it('eagerly bounds the cache on insert (does not wait for idle cleanup)', () => {
// An animating scene never goes idle, so the cache must be bounded on insert
// rather than relying solely on idle `cleanup`. With cap 2, inserting a 3rd
// entry must immediately evict the least-recently-used (oldest) one — 'A'.
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();
render('A'); // {A}
render('B'); // {A, B}
render('C'); // insert -> over cap -> eagerly evict LRU 'A' -> {B, C}

vi.clearAllMocks();
render('B'); // evicted -> miss
render('A'); // survived -> hit
// Probe survivors first (hits only re-order, they don't change membership),
// then the evicted entry last so its re-insert can't perturb the assertion.
render('C'); // survived -> hit
render('B'); // survived -> hit
render('A'); // evicted -> miss (one layout regen)

expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
});
Expand Down
20 changes: 17 additions & 3 deletions src/core/text-rendering/SdfTextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ 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.
// Upper bound on layoutCache entries. Overridden from stage options in `init`.
// Enforced both eagerly on insert (see `renderText`) and in bulk on idle (see
// `cleanup`). The eager bound matters because a continuously animating scene
// never goes idle, so idle-only eviction would let apps with ever-changing text
// (clocks, counters, score/fps readouts) grow the cache without limit until the
// page runs out of memory. The per-insert cost is a single Map delete on a
// cache miss (i.e. only when new text is laid out), so it does not compete with
// steady-state rendering.
let maxLayoutCacheSize = 250;

// Initialize the SDF text renderer
Expand Down Expand Up @@ -114,6 +119,15 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
layout = generateTextLayout(props, fontData);
layoutCache.set(cacheKey, layout);

// Eagerly bound the cache. Idle `cleanup` alone is not enough: an animating
// scene never idles, so without this, ever-changing text grows the cache
// without limit. The Map is insertion-ordered and cache hits re-insert
// (delete + set) to the end, so the first key is the least-recently-used.
if (layoutCache.size > maxLayoutCacheSize) {
const oldest = layoutCache.keys().next().value as string;
layoutCache.delete(oldest);
}

// For SDF renderer, ImageData is null since we render via WebGL
return {
remainingLines: 0,
Expand Down
Loading