From 48bdd5ba7219f33142776c3d539807a89f73dfd6 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Mon, 8 Jun 2026 17:24:56 -0400 Subject: [PATCH] fix(text): bound SDF layout cache eagerly; debug overlay uses SDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for a JS-heap leak (OOM / black screen) observed running the debug overlay over animated text for several minutes on a low-RAM device. 1. examples debug overlay (?debug=true) used the Canvas text renderer (no fontFamily/SDF specified). Canvas text rasterizes a fresh ImageTexture (a full bitmap) on every text change, and the overlay rewrites its text every interval — ~390KB x ~2/s. Force SDF so the per-interval update draws from the shared atlas with no texture allocation. This was the dominant leak (~0.9MB/s). 2. The SDF layout cache only evicted on idle. A continuously animating scene never goes idle, so any app with ever-changing SDF text (clock, counter, score, fps readout) grew the cache without bound. Bound it eagerly on insert too — a single Map delete on a cache miss, so it doesn't compete with steady-state rendering. Idle cleanup is retained for bulk trimming. Verified: the original repro (stress-single-level-text&debug=true&multiplier=10) went from ~0.9 MB/s heap growth to flat (~0.004 MB/s) over a multi-minute run. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/index.ts | 7 +++++++ .../text-rendering/SdfTextRenderer.test.ts | 21 ++++++++++--------- src/core/text-rendering/SdfTextRenderer.ts | 20 +++++++++++++++--- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/examples/index.ts b/examples/index.ts index 9384c31..2f8a3b1 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -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, diff --git a/src/core/text-rendering/SdfTextRenderer.test.ts b/src/core/text-rendering/SdfTextRenderer.test.ts index 6247ad6..400deca 100644 --- a/src/core/text-rendering/SdfTextRenderer.test.ts +++ b/src/core/text-rendering/SdfTextRenderer.test.ts @@ -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); }); diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index f4adc07..b6172e5 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -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 @@ -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,