From c08f17153ca98b1a42635944223a581f4d1fbedb Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 2 Jul 2026 05:54:07 -0400 Subject: [PATCH] perf(sdf): translation fast path for scrolling text + skip unchanged uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two SDF text hot-path fixes for the TV scroll scenario: 1. Translation fast path. The SDF vertex cache is world-space, so a scrolling text node missed it every frame: full per-glyph matrix math plus a cache re-snapshot (a second full copy) per node per frame. The hit test now compares only the scale/rotation components; when just tx/ty moved, addSdfTranslatedQuads mem-copies the cached vertices and adds the delta to the two position floats per vertex. The cache keeps its original base transform, so every frame recomputes from the same reference (no drift) and nothing is re-snapshotted. The copy must stay a typed-array set(): packed ABGR colors share the Float32Array and patterns like 0xFFFFFFFF (white) are float32 NaNs that element-wise copies could canonicalize. 2. Skip the SDF GPU upload when bytes are unchanged. render() uploaded the entire SDF buffer via bufferData on every drawn frame — any focus pulse or image fade re-uploaded every glyph on screen. This implements the sdfBufferChanged invariant documented in CLAUDE.md: uploadSdfBuffer skips when every write this frame was an exact cache-hit mem-copy and the size matches the last upload. The flag is raised by every byte- or offset-changing path, failing conservative: cache-miss recompute, translated copy, render-list rebuild (reorder moves identical bytes to different offsets), RTT partial upload (clobbers the GL buffer mid-frame), and backing-store growth. Co-Authored-By: Claude Fable 5 --- .../webgl/WebGlRenderer.sdfBuffer.test.ts | 302 ++++++++++++++++++ src/core/renderers/webgl/WebGlRenderer.ts | 137 +++++++- .../text-rendering/SdfTextRenderer.test.ts | 177 ++++++++++ src/core/text-rendering/SdfTextRenderer.ts | 48 ++- 4 files changed, 648 insertions(+), 16 deletions(-) create mode 100644 src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts diff --git a/src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts b/src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts new file mode 100644 index 0000000..902e699 --- /dev/null +++ b/src/core/renderers/webgl/WebGlRenderer.sdfBuffer.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from 'vitest'; +import { WebGlRenderer } from './WebGlRenderer.js'; + +/** + * Tests for the SDF buffer write paths and the upload skip. + * + * A full renderer needs a live GL context, so methods are exercised on a + * minimal fake `this` via the prototype, holding only the fields each method + * touches. + */ + +const FLOATS_PER_GLYPH = 24; // 4 vertices x 6 floats + +/** + * Build one glyph (4 vertices) of cached vertex data with a bit-pattern color. + * Layout per vertex: [x, y, u, v, color(uint32), distRange]. + */ +const makeCachedGlyph = (color: number): Float32Array => { + const buf = new ArrayBuffer(FLOATS_PER_GLYPH * 4); + const f = new Float32Array(buf); + const u = new Uint32Array(buf); + const corners = [ + [10, 20, 0.1, 0.2], + [30, 20, 0.3, 0.2], + [10, 40, 0.1, 0.4], + [30, 40, 0.3, 0.4], + ]; + for (let v = 0; v < 4; v++) { + const i = v * 6; + f[i] = corners[v]![0]!; + f[i + 1] = corners[v]![1]!; + f[i + 2] = corners[v]![2]!; + f[i + 3] = corners[v]![3]!; + u[i + 4] = color; + f[i + 5] = 4; // distanceRange + } + return f; +}; + +type SdfWriterFake = { + sdfBufferIdx: number; + sdfQuadCount: number; + sdfBuffer: ArrayBuffer; + fSdfBuffer: Float32Array; + uiSdfBuffer: Uint32Array; + sdfBufferChanged: boolean; + ensureSdfBufferCapacity: ReturnType; + finalizeSdfBatch: ReturnType; +}; + +const makeWriterFake = (): SdfWriterFake => { + const sdfBuffer = new ArrayBuffer(1024 * 4); + return { + sdfBufferIdx: 0, + sdfQuadCount: 0, + sdfBuffer, + fSdfBuffer: new Float32Array(sdfBuffer), + uiSdfBuffer: new Uint32Array(sdfBuffer), + sdfBufferChanged: false, + ensureSdfBufferCapacity: vi.fn(), + finalizeSdfBatch: vi.fn(), + }; +}; + +const finalizeArgs = [ + {} as never, // atlasTexture + { x: 0, y: 0, w: 0, h: 0, valid: false }, // clippingRect + 1, // worldAlpha + 100, // width + 50, // height + false, // parentHasRenderTexture + null, // framebufferDimensions + {} as never, // sdfShader +] as const; + +describe('WebGlRenderer.addSdfTranslatedQuads', () => { + it('copies cached vertices with the position delta applied', () => { + const fake = makeWriterFake(); + const cached = makeCachedGlyph(0x00ff00ff); + + WebGlRenderer.prototype.addSdfTranslatedQuads.call( + fake as never, + cached, + 1, + 5, + -3, + ...finalizeArgs, + ); + + const f = fake.fSdfBuffer; + // Positions shifted by (5, -3) + expect([f[0], f[1]]).toEqual([15, 17]); + expect([f[6], f[7]]).toEqual([35, 17]); + expect([f[12], f[13]]).toEqual([15, 37]); + expect([f[18], f[19]]).toEqual([35, 37]); + // UVs and distanceRange untouched + expect([f[2], f[3], f[5]]).toEqual([Math.fround(0.1), Math.fround(0.2), 4]); + expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH); + expect(fake.sdfQuadCount).toBe(1); + expect(fake.sdfBufferChanged).toBe(true); + expect(fake.finalizeSdfBatch.mock.calls.length).toBe(1); + // startQuad 0, numGlyphs 1 + expect(fake.finalizeSdfBatch.mock.calls[0]![0]).toBe(0); + expect(fake.finalizeSdfBatch.mock.calls[0]![1]).toBe(1); + // Source cache untouched + expect(cached[0]).toBe(10); + expect(cached[1]).toBe(20); + }); + + it('preserves packed color bits exactly, including float32 NaN patterns', () => { + const fake = makeWriterFake(); + // 0xFFFFFFFF (white, full alpha) is a float32 NaN bit pattern — an + // element-wise float copy could canonicalize it. The memcpy must not. + const cached = makeCachedGlyph(0xffffffff); + + WebGlRenderer.prototype.addSdfTranslatedQuads.call( + fake as never, + cached, + 1, + 100, + 200, + ...finalizeArgs, + ); + + for (let v = 0; v < 4; v++) { + expect(fake.uiSdfBuffer[v * 6 + 4]).toBe(0xffffffff); + } + }); + + it('appends after existing content and is a no-op for zero glyphs', () => { + const fake = makeWriterFake(); + fake.sdfBufferIdx = FLOATS_PER_GLYPH; + fake.sdfQuadCount = 1; + const cached = makeCachedGlyph(0x000000ff); + + WebGlRenderer.prototype.addSdfTranslatedQuads.call( + fake as never, + cached, + 0, + 5, + 5, + ...finalizeArgs, + ); + expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH); + expect(fake.sdfBufferChanged).toBe(false); + expect(fake.finalizeSdfBatch.mock.calls.length).toBe(0); + + WebGlRenderer.prototype.addSdfTranslatedQuads.call( + fake as never, + cached, + 1, + 5, + 5, + ...finalizeArgs, + ); + expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH * 2); + expect(fake.fSdfBuffer[FLOATS_PER_GLYPH]).toBe(15); + // startQuad continues from the existing quad count + expect(fake.finalizeSdfBatch.mock.calls[0]![0]).toBe(1); + }); +}); + +type UploadFake = { + sdfBufferIdx: number; + sdfBufferChanged: boolean; + lastUploadedSdfSize: number; + sdfBuffer: ArrayBuffer; + sdfQuadBufferCollection: { getBuffer: ReturnType }; + glw: { arrayBufferData: ReturnType; DYNAMIC_DRAW: number }; +}; + +const makeUploadFake = (): UploadFake => ({ + sdfBufferIdx: 0, + sdfBufferChanged: true, + lastUploadedSdfSize: 0, + sdfBuffer: new ArrayBuffer(1024), + sdfQuadBufferCollection: { getBuffer: vi.fn(() => ({})) }, + glw: { arrayBufferData: vi.fn(), DYNAMIC_DRAW: 35048 }, +}); + +const upload = (fake: UploadFake) => + ( + WebGlRenderer.prototype as unknown as { + uploadSdfBuffer: (this: unknown) => void; + } + ).uploadSdfBuffer.call(fake); + +describe('WebGlRenderer.uploadSdfBuffer skip', () => { + it('does nothing when no SDF glyphs were written', () => { + const fake = makeUploadFake(); + + upload(fake); + + expect(fake.glw.arrayBufferData.mock.calls.length).toBe(0); + // Flag is kept so a later frame with content still uploads + expect(fake.sdfBufferChanged).toBe(true); + }); + + it('uploads when changed, then skips identical follow-up frames', () => { + const fake = makeUploadFake(); + fake.sdfBufferIdx = 48; + + upload(fake); + expect(fake.glw.arrayBufferData.mock.calls.length).toBe(1); + expect(fake.sdfBufferChanged).toBe(false); + expect(fake.lastUploadedSdfSize).toBe(48); + + // Next frame: exact cache hits rewrote identical bytes, same size + upload(fake); + expect(fake.glw.arrayBufferData.mock.calls.length).toBe(1); + }); + + it('uploads again when the changed flag is raised', () => { + const fake = makeUploadFake(); + fake.sdfBufferIdx = 48; + upload(fake); + + fake.sdfBufferChanged = true; + upload(fake); + + expect(fake.glw.arrayBufferData.mock.calls.length).toBe(2); + }); + + it('uploads when the size differs even if the flag is clear', () => { + const fake = makeUploadFake(); + fake.sdfBufferIdx = 48; + upload(fake); + + fake.sdfBufferIdx = 24; + upload(fake); + + expect(fake.glw.arrayBufferData.mock.calls.length).toBe(2); + expect(fake.lastUploadedSdfSize).toBe(24); + }); +}); + +describe('sdfBufferChanged raisers', () => { + it('addSdfQuads marks the buffer changed', () => { + const fake = makeWriterFake(); + // One glyph record: [x, y, w, h, u, v, uw, vh] + const glyphs = new Float32Array([0, 0, 10, 10, 0, 0, 0.1, 0.1]); + const transform = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); + + WebGlRenderer.prototype.addSdfQuads.call( + fake as never, + glyphs, + 1, // glyphCount + 1, // fontScale + transform, + 0xffffffff, // color + 1, // worldAlpha + 4, // distanceRange + {} as never, // atlasTexture + { x: 0, y: 0, w: 0, h: 0, valid: false }, // clippingRect + 100, // width + 50, // height + false, // parentHasRenderTexture + null, // framebufferDimensions + {} as never, // sdfShader + ); + + expect(fake.sdfBufferChanged).toBe(true); + expect(fake.sdfBufferIdx).toBe(FLOATS_PER_GLYPH); + }); + + it('invalidateQuadBuffer marks the buffer changed (render-list rebuild)', () => { + const fake = { + sdfBufferChanged: false, + curBufferIdx: 99, + lastUploadedBufferSize: 99, + needsFullUpload: false, + stage: { renderList: [] }, + }; + + WebGlRenderer.prototype.invalidateQuadBuffer.call(fake as never); + + expect(fake.sdfBufferChanged).toBe(true); + }); + + it('ensureSdfBufferCapacity marks the buffer changed on growth only', () => { + const sdfBuffer = new ArrayBuffer(FLOATS_PER_GLYPH * 4); + const fake = { + sdfBuffer, + fSdfBuffer: new Float32Array(sdfBuffer), + uiSdfBuffer: new Uint32Array(sdfBuffer), + sdfBufferChanged: false, + }; + const ensure = (size: number) => + ( + WebGlRenderer.prototype as unknown as { + ensureSdfBufferCapacity: (this: unknown, n: number) => void; + } + ).ensureSdfBufferCapacity.call(fake, size); + + ensure(FLOATS_PER_GLYPH); // fits — no realloc + expect(fake.sdfBufferChanged).toBe(false); + + ensure(FLOATS_PER_GLYPH * 8); // grows + expect(fake.sdfBufferChanged).toBe(true); + expect(fake.fSdfBuffer.length).toBeGreaterThanOrEqual(FLOATS_PER_GLYPH * 8); + }); +}); diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index f1bbfa9..050377a 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -127,6 +127,23 @@ export class WebGlRenderer extends CoreRenderer { sdfQuadCount = 0; sdfQuadBufferCollection: BufferCollection; curSdfRenderOp: SdfRenderOp | null = null; + /** + * Whether the shared SDF buffer's bytes may differ from what the GPU + * currently holds. Set by every write path that produces fresh bytes + * (cache-miss recompute, translated copy), by anything that can shift + * offsets or resize the buffer (render-list rebuild, RTT partial upload, + * backing-store growth), and consumed by {@link uploadSdfBuffer}. The + * exact cache-hit mem-copy path deliberately does NOT set it — it writes + * byte-identical data at identical offsets. Conservative direction: when + * in doubt, set it — a redundant upload is correct, a wrong skip is a + * glitch. + */ + sdfBufferChanged = true; + /** + * Float32 length of the last main-pass SDF upload — the size half of the + * skip test in {@link uploadSdfBuffer}. + */ + lastUploadedSdfSize = 0; /** * When true, the entire quad buffer is re-uploaded to the GPU via bufferData @@ -741,6 +758,9 @@ export class WebGlRenderer extends CoreRenderer { return; } + // Full recompute writes fresh bytes — the GPU copy is now stale. + this.sdfBufferChanged = true; + let idx = this.sdfBufferIdx; this.ensureSdfBufferCapacity(idx + glyphCount * 24); @@ -903,6 +923,76 @@ export class WebGlRenderer extends CoreRenderer { ); } + /** + * Append cached SDF vertices translated by (dx, dy) to the shared buffer. + * + * @remarks + * The scroll fast path: a text node whose transform changed by pure + * translation reuses its world-space vertex cache — one mem-copy plus two + * adds per vertex instead of full per-glyph matrix math, and the cache + * keeps its original base so nothing is re-snapshotted per frame. + * + * The copy MUST stay a typed-array `set` (bit-exact memcpy): packed ABGR + * colors live in the same Float32Array and some bit patterns are float32 + * NaNs, which element-wise float reads/writes may canonicalize and corrupt. + * Only the two position floats of each vertex are touched after the copy. + */ + addSdfTranslatedQuads( + cachedVertices: Float32Array, + numGlyphs: number, + dx: number, + dy: number, + atlasTexture: WebGlCtxTexture, + clippingRect: import('../../lib/utils.js').RectWithValid, + worldAlpha: number, + width: number, + height: number, + parentHasRenderTexture: boolean, + framebufferDimensions: + | import('../../../common/CommonTypes.js').Dimensions + | null, + sdfShader: WebGlShaderNode, + ): void { + if (numGlyphs === 0) { + return; + } + + // Translated positions are fresh bytes — the GPU copy is now stale. + this.sdfBufferChanged = true; + + const startQuad = this.sdfQuadCount; + const idx = this.sdfBufferIdx; + + this.ensureSdfBufferCapacity(idx + cachedVertices.length); + + // Read the buffer reference only after ensureSdfBufferCapacity — growth + // swaps the backing store. + const f = this.fSdfBuffer; + f.set(cachedVertices, idx); + + const end = idx + cachedVertices.length; + for (let i = idx; i < end; i += 6) { + f[i] = f[i]! + dx; + f[i + 1] = f[i + 1]! + dy; + } + + this.sdfBufferIdx = end; + this.sdfQuadCount += numGlyphs; + + this.finalizeSdfBatch( + startQuad, + numGlyphs, + atlasTexture, + clippingRect, + worldAlpha, + width, + height, + parentHasRenderTexture, + framebufferDimensions, + sdfShader, + ); + } + /** * Shared batching logic for SDF render ops. * Called by both `addSdfQuads` (full compute) and `addSdfCachedQuads` (fast copy). @@ -1004,6 +1094,9 @@ export class WebGlRenderer extends CoreRenderer { this.sdfBuffer = newBuffer; this.fSdfBuffer = newFSdfBuffer; this.uiSdfBuffer = newUiSdfBuffer; + + // New backing store — never skip the next upload. + this.sdfBufferChanged = true; } /** @@ -1080,12 +1173,7 @@ export class WebGlRenderer extends CoreRenderer { } // Upload the shared SDF buffer if any SDF glyphs were written this frame. - if (this.sdfBufferIdx > 0) { - const sdfBuf = - this.sdfQuadBufferCollection.getBuffer('a_position') || null; - const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx); - glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW); - } + this.uploadSdfBuffer(); for (let i = 0, length = this.renderOps.length; i < length; i++) { this.renderOps[i]!.draw(this); @@ -1099,6 +1187,34 @@ export class WebGlRenderer extends CoreRenderer { this.numQuadsRendered = this.quadBufferUsage / QUAD_SIZE_IN_BYTES; } + /** + * Upload the shared SDF buffer for the main pass, skipping the driver-side + * `bufferData` copy when the bytes provably match what the GPU already + * holds: every write this frame was an exact cache-hit mem-copy + * (`sdfBufferChanged` false) and the total size matches the previous + * upload. Exact hits write byte-identical data, and identical offsets are + * guaranteed because every source of reorder or resize — cache-miss + * recompute, translated copy, render-list rebuild, RTT partial upload, + * backing-store growth — sets `sdfBufferChanged`. + */ + private uploadSdfBuffer(): void { + if (this.sdfBufferIdx === 0) { + return; + } + if ( + this.sdfBufferChanged === false && + this.sdfBufferIdx === this.lastUploadedSdfSize + ) { + return; + } + const glw = this.glw; + const sdfBuf = this.sdfQuadBufferCollection.getBuffer('a_position') || null; + const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx); + glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW); + this.lastUploadedSdfSize = this.sdfBufferIdx; + this.sdfBufferChanged = false; + } + getQuadCount(): number { return this.numQuadsRendered; } @@ -1335,6 +1451,9 @@ export class WebGlRenderer extends CoreRenderer { this.sdfQuadBufferCollection.getBuffer('a_position') || null; const sdfArr = new Float32Array(this.sdfBuffer, 0, this.sdfBufferIdx); glw.arrayBufferData(sdfBuf, sdfArr, glw.DYNAMIC_DRAW); + // This partial upload replaced the GL buffer's contents and size — the + // main pass upload can no longer be skipped this frame. + this.sdfBufferChanged = true; } for (let i = 0, length = this.renderOps.length; i < length; i++) { @@ -1501,6 +1620,12 @@ export class WebGlRenderer extends CoreRenderer { * next addQuad() pass will reassign compact, contiguous slots starting from 0. */ override invalidateQuadBuffer(): void { + // A render-list rebuild can reorder text nodes without touching their + // SDF vertex caches; byte-identical glyph data would then land at + // different offsets, so the upload skip must not fire on the rebuild + // frame. + this.sdfBufferChanged = true; + if (!DIRTY_QUAD_BUFFER) { return; } diff --git a/src/core/text-rendering/SdfTextRenderer.test.ts b/src/core/text-rendering/SdfTextRenderer.test.ts index 400deca..a7bc683 100644 --- a/src/core/text-rendering/SdfTextRenderer.test.ts +++ b/src/core/text-rendering/SdfTextRenderer.test.ts @@ -133,6 +133,183 @@ describe('SdfTextRenderer layout cache', () => { }); }); +describe('SdfTextRenderer renderQuads cache dispatch', () => { + type FakeRenderer = { + stage: unknown; + sdfBufferIdx: number; + fSdfBuffer: Float32Array; + addSdfQuads: ReturnType; + addSdfCachedQuads: ReturnType; + addSdfTranslatedQuads: ReturnType; + }; + + const GLYPHS = 2; + + const makeRenderer = (): FakeRenderer => { + const renderer: FakeRenderer = { + stage: { + options: { textLayoutCacheSize: 10 }, + shManager: { + registerShaderType: vi.fn(), + createShader: vi.fn(() => ({})), + }, + }, + sdfBufferIdx: 0, + fSdfBuffer: new Float32Array(1024), + // The miss path snapshots [startIdx, endIdx) of fSdfBuffer, so the + // fake must advance sdfBufferIdx like the real writer does. + addSdfQuads: vi.fn(() => { + renderer.sdfBufferIdx += GLYPHS * 24; + }), + addSdfCachedQuads: vi.fn(), + addSdfTranslatedQuads: vi.fn(), + }; + return renderer; + }; + + const makeLayout = () => ({ + glyphs: new Float32Array(GLYPHS * 8), + glyphCount: GLYPHS, + fontScale: 1, + distanceRange: 4, + width: 100, + height: 50, + }); + + const makeCache = () => ({ + vertices: null as Float32Array | null, + glyphCount: 0, + color: 0, + alpha: -1, + transform: new Float32Array(6), + layoutRef: null as unknown, + }); + + const makeRenderProps = ( + cache: ReturnType, + transform: number[], + ) => ({ + fontFamily: 'Test', + fontSize: 42, + color: 0xffffffff, + offsetY: 0, + worldAlpha: 1, + globalTransform: new Float32Array(transform), + clippingRect: { x: 0, y: 0, w: 0, h: 0, valid: false }, + width: 100, + height: 50, + parentHasRenderTexture: false, + framebufferDimensions: null, + sdfCache: cache, + }); + + const IDENTITY = [1, 0, 0, 0, 1, 0, 0, 0, 1]; + const TRANSLATED = [1, 0, 0, 0, 1, 0, 120, -40, 1]; + const SCALED = [2, 0, 0, 0, 2, 0, 0, 0, 1]; + + const renderQuads = ( + renderer: FakeRenderer, + layout: ReturnType, + renderProps: ReturnType, + ) => + SdfTextRenderer.renderQuads( + renderer as never, + layout as never, + null as never, + renderProps as never, + ); + + const setupWithAtlas = () => { + initRenderer(10); + vi.mocked(SdfFontHandler.getAtlas).mockReturnValue({ + ctxTexture: {}, + } as never); + }; + + it('takes the miss path first and snapshots the cache', () => { + setupWithAtlas(); + const renderer = makeRenderer(); + const layout = makeLayout(); + const cache = makeCache(); + + renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY)); + + expect(renderer.addSdfQuads.mock.calls.length).toBe(1); + expect(cache.vertices).not.toBeNull(); + expect(cache.layoutRef).toBe(layout); + expect(cache.glyphCount).toBe(GLYPHS); + }); + + it('takes the mem-copy path when nothing changed', () => { + setupWithAtlas(); + const renderer = makeRenderer(); + const layout = makeLayout(); + const cache = makeCache(); + + renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY)); + renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY)); + + expect(renderer.addSdfQuads.mock.calls.length).toBe(1); + expect(renderer.addSdfCachedQuads.mock.calls.length).toBe(1); + expect(renderer.addSdfTranslatedQuads.mock.calls.length).toBe(0); + }); + + it('takes the translation path when only tx/ty changed, keeping the cache base', () => { + setupWithAtlas(); + const renderer = makeRenderer(); + const layout = makeLayout(); + const cache = makeCache(); + + renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY)); + const baseVertices = cache.vertices; + + renderQuads(renderer, layout, makeRenderProps(cache, TRANSLATED)); + + expect(renderer.addSdfQuads.mock.calls.length).toBe(1); + expect(renderer.addSdfCachedQuads.mock.calls.length).toBe(0); + const calls = renderer.addSdfTranslatedQuads.mock.calls; + expect(calls.length).toBe(1); + // (cachedVertices, glyphCount, dx, dy, ...) + expect(calls[0]![0]).toBe(baseVertices); + expect(calls[0]![1]).toBe(GLYPHS); + expect(calls[0]![2]).toBe(120); + expect(calls[0]![3]).toBe(-40); + // The scroll path must not re-snapshot: base transform and vertices stay + expect(cache.vertices).toBe(baseVertices); + expect(cache.transform[4]).toBe(0); + expect(cache.transform[5]).toBe(0); + }); + + it('falls back to the miss path when scale or rotation changed', () => { + setupWithAtlas(); + const renderer = makeRenderer(); + const layout = makeLayout(); + const cache = makeCache(); + + renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY)); + renderQuads(renderer, layout, makeRenderProps(cache, SCALED)); + + expect(renderer.addSdfQuads.mock.calls.length).toBe(2); + expect(renderer.addSdfTranslatedQuads.mock.calls.length).toBe(0); + }); + + it('falls back to the miss path when color or alpha changed', () => { + setupWithAtlas(); + const renderer = makeRenderer(); + const layout = makeLayout(); + const cache = makeCache(); + + renderQuads(renderer, layout, makeRenderProps(cache, IDENTITY)); + + const faded = makeRenderProps(cache, TRANSLATED); + faded.worldAlpha = 0.5; + renderQuads(renderer, layout, faded); + + expect(renderer.addSdfQuads.mock.calls.length).toBe(2); + expect(renderer.addSdfTranslatedQuads.mock.calls.length).toBe(0); + }); +}); + describe('SdfTextRenderer lazy shader compile', () => { it('registers the SDF shader at init but defers compilation', () => { const shManager = { diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index b6172e5..2f3aab1 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -150,11 +150,14 @@ const addQuads = (_layout?: TextLayout): Float32Array | null => { /** * Submit SDF glyphs to the renderer's shared batched buffer. * - * Two paths: - * 1. **Cache hit** — layout, transform, color, and alpha haven't changed. - * The cached pre-transformed Float32Array is mem-copied directly into the - * shared SDF buffer (no per-glyph matrix math). - * 2. **Cache miss** — re-computes per-glyph world-space vertices via + * Three paths: + * 1. **Exact cache hit** — layout, transform, color, and alpha haven't + * changed. The cached pre-transformed Float32Array is mem-copied directly + * into the shared SDF buffer (no per-glyph matrix math). + * 2. **Translation hit** — only tx/ty changed (the scroll path). The cached + * vertices are copied with the position delta applied; the cache keeps its + * original base so nothing is re-snapshotted. + * 3. **Cache miss** — re-computes per-glyph world-space vertices via * `addSdfQuads`, then snapshots the result into the cache. */ const renderQuads = ( @@ -176,7 +179,7 @@ const renderQuads = ( // Compiles on the first real SDF draw; cheap memoized lookup thereafter. const shader = getSdfShader(webGlRenderer.stage); - // --- Cache-hit fast path ------------------------------------------------ + // --- Cache-hit fast paths ----------------------------------------------- if (cache !== undefined && cache.vertices !== null) { const ct = cache.transform; const t = renderProps.globalTransform; @@ -187,13 +190,38 @@ const renderQuads = ( ct[0] === t[0] && ct[1] === t[1] && ct[2] === t[3] && - ct[3] === t[4] && - ct[4] === t[6] && - ct[5] === t[7] + ct[3] === t[4] ) { - webGlRenderer.addSdfCachedQuads( + const dx = t[6]! - ct[4]!; + const dy = t[7]! - ct[5]!; + + if (dx === 0 && dy === 0) { + // Fully static: mem-copy the cached vertices as-is. + webGlRenderer.addSdfCachedQuads( + cache.vertices, + cache.glyphCount, + ctxTexture, + renderProps.clippingRect, + renderProps.worldAlpha, + layout.width, + layout.height, + renderProps.parentHasRenderTexture, + renderProps.framebufferDimensions, + shader, + ); + return null; + } + + // Pure translation (the scroll path): same glyphs, same scale/rotation, + // only tx/ty moved. Copy the cached vertices shifted by the delta from + // the cached base transform. The cache keeps its original base, so + // every frame recomputes from the same reference — no drift and no + // per-frame re-snapshot. + webGlRenderer.addSdfTranslatedQuads( cache.vertices, cache.glyphCount, + dx, + dy, ctxTexture, renderProps.clippingRect, renderProps.worldAlpha,