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,