UNPKG

@lightningjs/renderer

Version:
845 lines (737 loc) 25.8 kB
/* * If not stated otherwise in this file or this component's LICENSE file the * following copyright and licenses apply: * * Copyright 2023 Comcast Cable Communications Management, LLC. * * Licensed under the Apache License, Version 2.0 (the License); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Tests for the batched SDF write paths, render op grouping, and 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. The SdfBuffer instances themselves are real (they only need * `glw.createBuffer()`), so cursor/growth/change-tracking behavior is the * production code. */ import { beforeAll, describe, expect, it, vi } from 'vitest'; import { WebGlRenderer } from './WebGlRenderer.js'; import { SdfBuffer, SDF_PLAIN_FLOATS_PER_VERTEX, SDF_RICH_FLOATS_PER_VERTEX, SDF_PLAIN_GLYPH_STRIDE, SDF_RICH_GLYPH_STRIDE, } from './SdfBuffer.js'; import type { GlContextWrapper } from '../../platforms/GlContextWrapper.js'; import type { WebGlCtxTexture } from './WebGlCtxTexture.js'; import type { Stage } from '../../Stage.js'; import type { WebGlShaderNode } from './WebGlShaderNode.js'; import type { TextLayout, TextRenderProps, SdfVertexCache, } from '../../text-rendering/TextRenderer.js'; import * as SdfFontHandler from '../../text-rendering/SdfFontHandler.js'; import SdfTextRenderer from '../../text-rendering/SdfTextRenderer.js'; const FLOATS_PER_GLYPH_PLAIN = SDF_PLAIN_FLOATS_PER_VERTEX * 4; // 24 const FLOATS_PER_GLYPH_RICH = SDF_RICH_FLOATS_PER_VERTEX * 4; // 28 const makeGlw = () => ({ FLOAT: 5126, UNSIGNED_BYTE: 5121, createBuffer: () => ({}), } as unknown as GlContextWrapper); const makeSdfBuffer = (layout: 'plain' | 'rich', initialBytes?: number) => new SdfBuffer(makeGlw(), layout, initialBytes); const NO_CLIP = { x: 0, y: 0, w: 0, h: 0, valid: false }; /** * Build one glyph record of design-unit data (see SdfBuffer docs for strides). * The packed span color is written bit-exactly via a Uint32 view so float32 * NaN bit patterns survive (0xffffffff is a float32 NaN). */ const makeGlyph = ( stride: number, packedSpanColor: number, style = 0, ): Float32Array => { const buf = new ArrayBuffer(stride * 4); const f = new Float32Array(buf); const u = new Uint32Array(buf); // x, y, w, h, u, v, uw, vh const fields = [0, 0, 10, 10, 0, 0, 0.1, 0.1] as const; for (let i = 0; i < fields.length; i++) { f[i] = fields[i]!; } if (stride === SDF_RICH_GLYPH_STRIDE) { // 12-float rich record: 8/9 = shearTop/shearBot (0 = no italic lean), // 10 = packed_span_color, 11 = style. u[10] = packedSpanColor; f[11] = style; } return f; }; const IDENTITY = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]); const atlasA = {} as WebGlCtxTexture; const atlasB = {} as WebGlCtxTexture; type FakeFinalize = { finalizeSdfBatch: ReturnType<typeof vi.fn>; }; const makeFinalizerFake = (): FakeFinalize => ({ finalizeSdfBatch: vi.fn(), }); const addSdfQuads = ( fake: unknown, sdfBuffer: SdfBuffer, glyphs: Float32Array, glyphCount: number, overrides: { color?: number; worldAlpha?: number; distanceRange?: number; atlas?: WebGlCtxTexture; transform?: Float32Array; } = {}, ): void => { const { color = 0xffffffff, worldAlpha = 1, distanceRange = 4, atlas = atlasA, transform = IDENTITY, } = overrides; ( WebGlRenderer.prototype as unknown as { addSdfQuads: (this: unknown, ...args: unknown[]) => void; } ).addSdfQuads.call( fake, sdfBuffer, glyphs, glyphCount, 1, // fontScale transform, color, worldAlpha, distanceRange, atlas, NO_CLIP, 100, 50, false, null, {}, ); }; const addSdfTranslatedQuads = ( fake: unknown, sdfBuffer: SdfBuffer, cached: Float32Array, numGlyphs: number, dx: number, dy: number, ): void => { ( WebGlRenderer.prototype as unknown as { addSdfTranslatedQuads: (this: unknown, ...args: unknown[]) => void; } ).addSdfTranslatedQuads.call( fake, sdfBuffer, cached, numGlyphs, dx, dy, atlasA, NO_CLIP, 1, 100, 50, false, null, {}, ); }; // --------------------------------------------------------------------------- // SdfBuffer — layouts, growth, change tracking // --------------------------------------------------------------------------- describe('SdfBuffer', () => { it('plain layout: 6 floats/vertex, no a_style attribute', () => { const b = makeSdfBuffer('plain'); expect(b.floatsPerVertex).toBe(SDF_PLAIN_FLOATS_PER_VERTEX); expect(b.quadBufferCollection.getAttributeInfo('a_position')!.stride).toBe( SDF_PLAIN_FLOATS_PER_VERTEX * 4, ); expect(b.quadBufferCollection.getAttributeInfo('a_style')).toBeUndefined(); }); it('rich layout: 7 floats/vertex, a_style at offset 5f', () => { const b = makeSdfBuffer('rich'); expect(b.floatsPerVertex).toBe(SDF_RICH_FLOATS_PER_VERTEX); expect(b.quadBufferCollection.getAttributeInfo('a_style')).toBeDefined(); expect(b.quadBufferCollection.getAttributeInfo('a_style')!.offset).toBe( 5 * 4, ); expect(b.quadBufferCollection.getAttributeInfo('a_distRange')!.offset).toBe( 6 * 4, ); }); it('clear resets the cursor but not the changed flag', () => { const b = makeSdfBuffer('plain'); b.idx = 100; b.quadCount = 5; b.changed = false; b.clear(); expect(b.idx).toBe(0); expect(b.quadCount).toBe(0); // changed is preserved — a later identical fill may still skip safely. expect(b.changed).toBe(false); }); it('ensureCapacity is a no-op when the data fits', () => { const b = makeSdfBuffer('plain'); const before = b.fBuffer; b.changed = false; b.ensureCapacity(10); expect(b.fBuffer).toBe(before); expect(b.changed).toBe(false); }); it('ensureCapacity doubles and preserves content, marking changed', () => { const b = makeSdfBuffer('plain', 1024); // 256 floats b.fBuffer[0] = 42; b.fBuffer[1] = -7; b.changed = false; b.ensureCapacity(200); // fits in 256 floats — no growth expect(b.changed).toBe(false); b.ensureCapacity(300); // > capacity — doubles to 512 floats expect(b.changed).toBe(true); expect(b.fBuffer.length).toBeGreaterThanOrEqual(300); expect(b.fBuffer[0]).toBe(42); expect(b.fBuffer[1]).toBe(-7); }); }); // --------------------------------------------------------------------------- // addSdfQuads — per-glyph CPU transform into the shared buffer // --------------------------------------------------------------------------- describe('WebGlRenderer.addSdfQuads', () => { it('writes a plain glyph (24 floats) with transformed world position', () => { const fake = makeFinalizerFake(); const b = makeSdfBuffer('plain'); const glyphs = makeGlyph(SDF_PLAIN_GLYPH_STRIDE, 0); addSdfQuads(fake, b, glyphs, 1); const f = b.fBuffer; expect([f[0], f[1]]).toEqual([0, 0]); // TL expect([f[6], f[7]]).toEqual([10, 0]); // TR expect([f[12], f[13]]).toEqual([0, 10]); // BL expect([f[18], f[19]]).toEqual([10, 10]); // BR expect([f[2], f[3], f[5]]).toEqual([0, 0, 4]); // uv, distRange expect(b.idx).toBe(FLOATS_PER_GLYPH_PLAIN); expect(b.quadCount).toBe(1); expect(b.changed).toBe(true); expect(fake.finalizeSdfBatch).toHaveBeenCalledTimes(1); }); it('writes a rich glyph (28 floats) with style and merged span color', () => { const fake = makeFinalizerFake(); const b = makeSdfBuffer('rich'); // Node color red RGBA(255,0,0,255) packed = 0xff0000ff; span green // RGBA(0,255,0,255) packed = 0xff00ff00. Multiplicative merge → black. const glyphs = makeGlyph(SDF_RICH_GLYPH_STRIDE, 0xff00ff00, 3); addSdfQuads(fake, b, glyphs, 1, { color: 0xff0000ff }); expect(b.idx).toBe(FLOATS_PER_GLYPH_RICH); // Merge rounds 255*255 → 254, so RGBA(0,0,0,254): 0xfe000000 expect(b.uiBuffer[4]).toBe(0xfe000000); expect([b.fBuffer[5], b.fBuffer[6]]).toEqual([3, 4]); // style, distRange expect(b.quadCount).toBe(1); }); it('appends after existing content (startQuad continues)', () => { const fake = makeFinalizerFake(); const b = makeSdfBuffer('plain'); const glyphs = makeGlyph(SDF_PLAIN_GLYPH_STRIDE, 0); addSdfQuads(fake, b, glyphs, 1); addSdfQuads(fake, b, glyphs, 1); expect(b.idx).toBe(FLOATS_PER_GLYPH_PLAIN * 2); expect(b.quadCount).toBe(2); // finalizeSdfBatch(sdfBuffer, startQuad, glyphCount, …) expect(fake.finalizeSdfBatch.mock.calls[0]![1]).toBe(0); expect(fake.finalizeSdfBatch.mock.calls[1]![1]).toBe(1); }); it('is a no-op for zero glyphs', () => { const fake = makeFinalizerFake(); const b = makeSdfBuffer('plain'); b.changed = false; const glyphs = makeGlyph(SDF_PLAIN_GLYPH_STRIDE, 0); addSdfQuads(fake, b, glyphs, 0); expect(b.idx).toBe(0); expect(b.changed).toBe(false); expect(fake.finalizeSdfBatch).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // addSdfTranslatedQuads — scroll fast path // --------------------------------------------------------------------------- describe('WebGlRenderer.addSdfTranslatedQuads', () => { const makeCachedGlyph = (color: number, rich: boolean): Float32Array => { const vpf = rich ? SDF_RICH_FLOATS_PER_VERTEX : SDF_PLAIN_FLOATS_PER_VERTEX; const buf = new ArrayBuffer(vpf * 4 * 4); const f = new Float32Array(buf); const u = new Uint32Array(buf); const corners = [ [10, 20], [30, 20], [10, 40], [30, 40], ]; for (let v = 0; v < 4; v++) { const i = v * vpf; f[i] = corners[v]![0]!; f[i + 1] = corners[v]![1]!; f[i + 2] = 0.1; f[i + 3] = 0.2; u[i + 4] = color; if (rich) { f[i + 5] = 2; f[i + 6] = 4; } else { f[i + 5] = 4; } } return f; }; it('shifts positions by (dx, dy) and marks changed', () => { const fake = makeFinalizerFake(); const b = makeSdfBuffer('plain'); const cached = makeCachedGlyph(0x00ff00ff, false); addSdfTranslatedQuads(fake, b, cached, 1, 5, -3); const f = b.fBuffer; 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, color, distRange untouched (float32-rounded source values) expect([f[2], f[3], f[5]]).toEqual([Math.fround(0.1), Math.fround(0.2), 4]); expect(b.idx).toBe(FLOATS_PER_GLYPH_PLAIN); expect(b.quadCount).toBe(1); expect(b.changed).toBe(true); expect(fake.finalizeSdfBatch).toHaveBeenCalledTimes(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 = makeFinalizerFake(); const b = makeSdfBuffer('rich'); // 0xffffffff is a float32 NaN bit pattern — element-wise float copies // could canonicalize it; the memcpy must not. const cached = makeCachedGlyph(0xffffffff, true); addSdfTranslatedQuads(fake, b, cached, 1, 100, 200); for (let v = 0; v < 4; v++) { expect(b.uiBuffer[v * SDF_RICH_FLOATS_PER_VERTEX + 4]).toBe(0xffffffff); } }); it('is a no-op for zero glyphs', () => { const fake = makeFinalizerFake(); const b = makeSdfBuffer('plain'); b.changed = false; const cached = makeCachedGlyph(0x000000ff, false); addSdfTranslatedQuads(fake, b, cached, 0, 5, 5); expect(b.idx).toBe(0); expect(b.changed).toBe(false); // nothing written — no fresh bytes expect(fake.finalizeSdfBatch).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // addSdfCachedQuads — exact cache hits must NOT mark changed // --------------------------------------------------------------------------- describe('WebGlRenderer.addSdfCachedQuads', () => { it('memcpys identical bytes without raising the changed flag', () => { const fake = makeFinalizerFake(); const b = makeSdfBuffer('plain'); b.changed = false; const buf = new ArrayBuffer(FLOATS_PER_GLYPH_PLAIN * 4); const f = new Float32Array(buf); const u = new Uint32Array(buf); for (let v = 0; v < 4; v++) { const i = v * SDF_PLAIN_FLOATS_PER_VERTEX; f[i] = v * 10; f[i + 1] = 20; u[i + 4] = 0xffffffff; // NaN bit pattern f[i + 5] = 4; } ( WebGlRenderer.prototype as unknown as { addSdfCachedQuads: (this: unknown, ...args: unknown[]) => void; } ).addSdfCachedQuads.call( fake, b, f, 1, atlasA, NO_CLIP, 1, 100, 50, false, null, {}, ); expect(b.idx).toBe(FLOATS_PER_GLYPH_PLAIN); expect(b.quadCount).toBe(1); expect(b.changed).toBe(false); // cache-hit memcpy — upload may be skipped expect(b.uiBuffer[4]).toBe(0xffffffff); // bits preserved expect(fake.finalizeSdfBatch).toHaveBeenCalledTimes(1); }); it('is a no-op for zero glyphs', () => { const fake = makeFinalizerFake(); const b = makeSdfBuffer('plain'); b.changed = false; ( WebGlRenderer.prototype as unknown as { addSdfCachedQuads: (this: unknown, ...args: unknown[]) => void; } ).addSdfCachedQuads.call( fake, b, new Float32Array(FLOATS_PER_GLYPH_PLAIN), 0, atlasA, NO_CLIP, 1, 100, 50, false, null, {}, ); expect(b.idx).toBe(0); expect(b.changed).toBe(false); expect(fake.finalizeSdfBatch).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // finalizeSdfBatch — render op grouping / merge // --------------------------------------------------------------------------- type RendererStub = { stage: { pixelRatio: number }; glw: ReturnType<typeof makeGlw> & { arrayBufferData: ReturnType<typeof vi.fn>; }; sdfBufferPlain: SdfBuffer; sdfBufferRich: SdfBuffer; curSdfRenderOp: unknown; curRenderOp: unknown; renderOps: unknown[]; }; const makeRendererStub = (): RendererStub => { const glw = { FLOAT: 5126, UNSIGNED_BYTE: 5121, createBuffer: () => ({}), arrayBufferData: vi.fn(), DYNAMIC_DRAW: 35048, } as unknown as RendererStub['glw']; // Object.create so the real production methods (addSdfQuads, finalizeSdfBatch, // uploadSdfBuffer) are reachable via the prototype. const stub = Object.create(WebGlRenderer.prototype) as RendererStub; stub.stage = { pixelRatio: 1 }; stub.glw = glw; stub.sdfBufferPlain = new SdfBuffer(glw, 'plain'); stub.sdfBufferRich = new SdfBuffer(glw, 'rich'); stub.curSdfRenderOp = null; stub.curRenderOp = null; stub.renderOps = []; return stub; }; const finalize = ( stub: RendererStub, sdfBuffer: SdfBuffer, startQuad: number, glyphCount: number, atlas: WebGlCtxTexture = atlasA, clip = NO_CLIP, ): void => { ( stub as unknown as { finalizeSdfBatch: (this: unknown, ...args: unknown[]) => void; } ).finalizeSdfBatch.call( stub, sdfBuffer, startQuad, glyphCount, atlas, clip, 1, 100, 50, false, null, {}, ); }; describe('WebGlRenderer.finalizeSdfBatch', () => { it('merges into the current op when buffer, atlas and clip match', () => { const stub = makeRendererStub(); finalize(stub, stub.sdfBufferPlain, 0, 3); finalize(stub, stub.sdfBufferPlain, 3, 2); expect(stub.renderOps.length).toBe(1); const op = stub.renderOps[0] as { startQuad: number; numQuads: number; }; expect(op.startQuad).toBe(0); expect(op.numQuads).toBe(5); expect(stub.curSdfRenderOp).toBe(op); }); it('breaks the batch on a different atlas texture', () => { const stub = makeRendererStub(); finalize(stub, stub.sdfBufferPlain, 0, 3, atlasA); finalize(stub, stub.sdfBufferPlain, 3, 2, atlasB); expect(stub.renderOps.length).toBe(2); expect((stub.renderOps[0] as { numQuads: number }).numQuads).toBe(3); expect((stub.renderOps[1] as { numQuads: number }).numQuads).toBe(2); }); it('breaks the batch on a different clipping rect', () => { const stub = makeRendererStub(); const clipB = { x: 10, y: 10, w: 50, h: 50, valid: true }; finalize(stub, stub.sdfBufferPlain, 0, 3, atlasA, NO_CLIP); finalize(stub, stub.sdfBufferPlain, 3, 2, atlasA, clipB); expect(stub.renderOps.length).toBe(2); }); it('breaks the batch across layouts (different SdfBuffer)', () => { const stub = makeRendererStub(); finalize(stub, stub.sdfBufferPlain, 0, 3); finalize(stub, stub.sdfBufferRich, 3, 2); expect(stub.renderOps.length).toBe(2); }); it('resets the regular render op chain so nodes do not extend an SDF op', () => { const stub = makeRendererStub(); stub.curRenderOp = { some: 'prior-op' }; finalize(stub, stub.sdfBufferPlain, 0, 3); expect(stub.curRenderOp).toBeNull(); }); }); // --------------------------------------------------------------------------- // finalizeSdfBatch — inline op placement preserves z-order // --------------------------------------------------------------------------- describe('WebGlRenderer.finalizeSdfBatch inline placement', () => { it('pushes SDF ops directly into renderOps in write order', () => { const stub = makeRendererStub(); // Simulate scene order: text, then a quad, then more text. Each non-mergeable // write becomes its own op positioned exactly where it was written, so an // intervening quad can never be covered by text that draws after it. finalize(stub, stub.sdfBufferPlain, 0, 2); stub.renderOps.push({ kind: 'quad' }); finalize(stub, stub.sdfBufferPlain, 2, 1); expect(stub.renderOps.length).toBe(3); expect(stub.renderOps[0]).toHaveProperty('numQuads', 2); expect(stub.renderOps[1]).toMatchObject({ kind: 'quad' }); expect(stub.renderOps[2]).toHaveProperty('numQuads', 1); }); }); // --------------------------------------------------------------------------- // uploadSdfBuffer skip // --------------------------------------------------------------------------- describe('WebGlRenderer.uploadSdfBuffer skip', () => { const makeUploadStub = () => { const glw = { FLOAT: 5126, UNSIGNED_BYTE: 5121, DYNAMIC_DRAW: 35048, createBuffer: () => ({}), arrayBufferData: vi.fn(), } as unknown as RendererStub['glw']; const stub = makeRendererStub(); stub.glw = glw; return { stub, glw }; }; const upload = (stub: RendererStub) => { (stub as unknown as { uploadSdfBuffer: () => void }).uploadSdfBuffer(); }; it('does nothing when no SDF glyphs were written', () => { const { stub, glw } = makeUploadStub(); upload(stub); expect(glw.arrayBufferData).not.toHaveBeenCalled(); // Flags preserved so a later frame with content still uploads expect(stub.sdfBufferPlain.changed).toBe(true); }); it('uploads when changed, then skips identical follow-up frames', () => { const { stub, glw } = makeUploadStub(); // 2 plain glyphs via cache hits (changed stays false)… stub.sdfBufferPlain.changed = false; stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN * 2; upload(stub); expect(glw.arrayBufferData).toHaveBeenCalledTimes(1); expect(stub.sdfBufferPlain.changed).toBe(false); expect(stub.sdfBufferPlain.lastUploadedSize).toBe( FLOATS_PER_GLYPH_PLAIN * 2, ); // Next frame: exact cache hits rewrote identical bytes, same size upload(stub); expect(glw.arrayBufferData).toHaveBeenCalledTimes(1); }); it('uploads again when the changed flag is raised', () => { const { stub, glw } = makeUploadStub(); stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN; upload(stub); stub.sdfBufferPlain.changed = true; upload(stub); expect(glw.arrayBufferData).toHaveBeenCalledTimes(2); }); it('uploads when the size differs even if the flag is clear', () => { const { stub, glw } = makeUploadStub(); stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN; upload(stub); stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN / 2; upload(stub); expect(glw.arrayBufferData).toHaveBeenCalledTimes(2); expect(stub.sdfBufferPlain.lastUploadedSize).toBe( FLOATS_PER_GLYPH_PLAIN / 2, ); }); it('uploads each layout independently', () => { const { stub, glw } = makeUploadStub(); stub.sdfBufferPlain.idx = FLOATS_PER_GLYPH_PLAIN; stub.sdfBufferRich.idx = FLOATS_PER_GLYPH_RICH; upload(stub); expect(glw.arrayBufferData).toHaveBeenCalledTimes(2); }); }); // --------------------------------------------------------------------------- // renderQuads — cache-hit offset tracking (reorder / dirty-write detection) // --------------------------------------------------------------------------- const GLYPH_VERTS_PLAIN = (() => { const f = new Float32Array(FLOATS_PER_GLYPH_PLAIN); for (let i = 0; i < f.length; i++) { f[i] = i; } return f; })(); const makePlainLayout = (glyphCount = 1) => ({ richText: false, glyphCount, width: 100, height: 50, } as unknown as TextLayout); const makeRenderProps = ( cache: SdfVertexCache, transform?: Float32Array, ): TextRenderProps => ({ fontFamily: 'renderQuads-reorder-test', color: 0xffffffff, worldAlpha: 1, globalTransform: transform ?? new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), clippingRect: NO_CLIP, width: 100, height: 50, parentHasRenderTexture: false, framebufferDimensions: null, sdfCache: cache, } as unknown as TextRenderProps); const makeCache = ( layout: TextLayout, opts: Partial<SdfVertexCache> = {}, ): SdfVertexCache => ({ vertices: GLYPH_VERTS_PLAIN, glyphCount: 1, color: 0xffffffff, alpha: 1, transform: new Float32Array([1, 0, 0, 1, 0, 0]), layoutRef: layout, lastStartQuad: 0, lastWriteDirty: false, ...opts, }); describe('SdfTextRenderer.renderQuads cache-hit offset tracking', () => { const fakeShader = {} as WebGlShaderNode; const fakeStage = { shManager: { registerShaderType: vi.fn(), createShader: vi.fn(() => fakeShader), }, } as unknown as Stage; beforeAll(() => { SdfTextRenderer.init(fakeStage); vi.spyOn(SdfFontHandler, 'getAtlas').mockReturnValue({ ctxTexture: atlasA, } as never); }); it('static hit at the same offset leaves the buffer unchanged (upload may skip)', () => { const stub = makeRendererStub(); stub.sdfBufferPlain.changed = false; const layout = makePlainLayout(); const cache = makeCache(layout); SdfTextRenderer.renderQuads( stub as unknown as never, layout, null, makeRenderProps(cache), ); expect(stub.sdfBufferPlain.changed).toBe(false); expect(cache.lastStartQuad).toBe(0); expect(cache.lastWriteDirty).toBe(false); }); it('marks the buffer changed when a reorder moves the node quad range', () => { const stub = makeRendererStub(); stub.sdfBufferPlain.changed = false; const layout = makePlainLayout(); // Node B writes first, occupying quad 0 at its own unchanged offset. SdfTextRenderer.renderQuads( stub as unknown as never, layout, null, makeRenderProps(makeCache(layout, { lastStartQuad: 0 })), ); expect(stub.sdfBufferPlain.changed).toBe(false); // Node A previously lived at quad 0 (lastStartQuad 0) but a render-list // reorder now draws it after B, so it lands at quad 1. The GPU copy is // stale at both offsets — the buffer must be re-uploaded. const cacheA = makeCache(layout, { lastStartQuad: 0 }); SdfTextRenderer.renderQuads( stub as unknown as never, layout, null, makeRenderProps(cacheA), ); expect(stub.sdfBufferPlain.changed).toBe(true); expect(cacheA.lastStartQuad).toBe(1); expect(cacheA.lastWriteDirty).toBe(false); }); it('re-uploads when a node returns to base after a translated frame', () => { const stub = makeRendererStub(); const layout = makePlainLayout(); const cache = makeCache(layout); // Translated frame: shifted bytes written, buffer marked changed. SdfTextRenderer.renderQuads( stub as unknown as never, layout, null, makeRenderProps(cache, new Float32Array([1, 0, 0, 0, 1, 0, 5, 10, 1])), ); expect(stub.sdfBufferPlain.changed).toBe(true); expect(cache.lastWriteDirty).toBe(true); // The upload consumed the flag. stub.sdfBufferPlain.changed = false; // The node returns to its exact base position: the GPU still holds the // shifted bytes, so the exact cache hit must force a re-upload. SdfTextRenderer.renderQuads( stub as unknown as never, layout, null, makeRenderProps(cache), ); expect(stub.sdfBufferPlain.changed).toBe(true); expect(cache.lastWriteDirty).toBe(false); }); });