Skip to content
Closed
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
95 changes: 95 additions & 0 deletions src/core/renderers/webgl/WebGlRenderer.sdfUpload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { WebGlRenderer } from './WebGlRenderer.js';

/**
* Tests for the static-SDF-buffer upload skip.
*
* The renderer instance is created without running the constructor (which
* requires a live WebGL context); only the fields the tested methods touch
* are populated.
*/
const makeRenderer = (): WebGlRenderer => {
const renderer = Object.create(
WebGlRenderer.prototype,
) as unknown as WebGlRenderer;
renderer.sdfBufferChanged = true;
renderer.lastSdfUploadSize = -1;
renderer.sdfBufferIdx = 0;
return renderer;
};

describe('shouldUploadSdfBuffer', () => {
it('should upload on the first frame', () => {
const renderer = makeRenderer();
renderer.sdfBufferIdx = 240;

expect(renderer.shouldUploadSdfBuffer()).toBe(true);
});

it('should skip when content is unchanged and size matches last upload', () => {
const renderer = makeRenderer();
renderer.sdfBufferChanged = false;
renderer.sdfBufferIdx = 240;
renderer.lastSdfUploadSize = 240;

expect(renderer.shouldUploadSdfBuffer()).toBe(false);
});

it('should upload when a cache-miss write occurred even at matching size', () => {
const renderer = makeRenderer();
renderer.sdfBufferChanged = true;
renderer.sdfBufferIdx = 240;
renderer.lastSdfUploadSize = 240;

expect(renderer.shouldUploadSdfBuffer()).toBe(true);
});

it('should upload when the used size differs from the last upload', () => {
const renderer = makeRenderer();
renderer.sdfBufferChanged = false;
renderer.sdfBufferIdx = 120;
renderer.lastSdfUploadSize = 240;

expect(renderer.shouldUploadSdfBuffer()).toBe(true);
});
});

describe('sdfBufferChanged invalidation hooks', () => {
it('should be set by SDF buffer growth and preserved data', () => {
const renderer = makeRenderer();
const initial = new ArrayBuffer(8 * Float32Array.BYTES_PER_ELEMENT);
renderer.sdfBuffer = initial;
renderer.fSdfBuffer = new Float32Array(initial);
renderer.uiSdfBuffer = new Uint32Array(initial);
renderer.fSdfBuffer[0] = 42;
renderer.sdfBufferChanged = false;

// Within capacity: no growth, no flag
(renderer as never as Record<string, (n: number) => void>)[
'ensureSdfBufferCapacity'
]!(8);
expect(renderer.sdfBufferChanged).toBe(false);
expect(renderer.fSdfBuffer.length).toBe(8);

// Beyond capacity: growth sets the flag and copies data
(renderer as never as Record<string, (n: number) => void>)[
'ensureSdfBufferCapacity'
]!(16);
expect(renderer.sdfBufferChanged).toBe(true);
expect(renderer.fSdfBuffer.length >= 16).toBe(true);
expect(renderer.fSdfBuffer[0]).toBe(42);
});

it('should be set by invalidateQuadBuffer (render list rebuild)', () => {
const renderer = makeRenderer();
renderer.sdfBufferChanged = false;
(renderer as never as { stage: { renderList: never[] } }).stage = {
renderList: [],
};
renderer.curBufferIdx = 0;

renderer.invalidateQuadBuffer();

expect(renderer.sdfBufferChanged).toBe(true);
});
});
65 changes: 60 additions & 5 deletions src/core/renderers/webgl/WebGlRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,23 @@ export class WebGlRenderer extends CoreRenderer {
* capacity, requiring a full re-upload even when needsFullUpload is false.
*/
lastUploadedBufferSize = 0;
/**
* Set when this frame's CPU-side SDF buffer content may differ from what
* render() last uploaded to the GPU. The cache-hit text path
* (addSdfCachedQuads) writes byte-identical data at identical offsets as
* long as the render list order and every text node's layout / transform /
* color / alpha are unchanged, so a frame where only that path ran (and the
* used size matches the last upload) can skip the per-frame bufferData of
* the entire SDF buffer. Any cache-miss write (addSdfQuads), render list
* rebuild (ordering), RTT pass (RTT glyphs occupy the front of the shared
* buffer), or buffer growth sets this flag and forces an upload.
*/
sdfBufferChanged = true;
/**
* Float32 element count of the last SDF upload performed by render().
* -1 forces the first upload.
*/
lastSdfUploadSize = -1;
/**
* Count of main-scene nodes whose quad data changed this frame and which
* own a buffer slot. Accumulated for free during the addQuad pass (which
Expand Down Expand Up @@ -712,6 +729,9 @@ export class WebGlRenderer extends CoreRenderer {
return;
}

// Cache-miss write: this frame's SDF bytes differ from the last upload.
this.sdfBufferChanged = true;

let idx = this.sdfBufferIdx;
this.ensureSdfBufferCapacity(idx + glyphCount * 24);

Expand Down Expand Up @@ -958,6 +978,9 @@ export class WebGlRenderer extends CoreRenderer {
return;
}

// Backing store is being replaced; never skip the next upload.
this.sdfBufferChanged = true;

let newCapacity = this.fSdfBuffer.length * 2;
while (newCapacity < requiredSize) {
newCapacity *= 2;
Expand Down Expand Up @@ -1050,12 +1073,20 @@ export class WebGlRenderer extends CoreRenderer {
glw.arrayBufferData(buffer, arr, glw.STATIC_DRAW);
}

// Upload the shared SDF buffer if any SDF glyphs were written this frame.
// Upload the shared SDF buffer if any SDF glyphs were written this frame
// AND the content can differ from what the GPU already holds. On frames
// where every text node took the cache-hit path (addSdfCachedQuads), the
// rewrite produced byte-identical data at identical offsets, so the
// (potentially multi-hundred-KB) bufferData is skipped entirely.
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);
if (this.shouldUploadSdfBuffer() === true) {
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.lastSdfUploadSize = this.sdfBufferIdx;
}
this.sdfBufferChanged = false;
}

for (let i = 0, length = this.renderOps.length; i < length; i++) {
Expand All @@ -1070,6 +1101,21 @@ export class WebGlRenderer extends CoreRenderer {
this.numQuadsRendered = this.quadBufferUsage / QUAD_SIZE_IN_BYTES;
}

/**
* Whether render() must re-upload the SDF vertex buffer this frame.
*
* Skipping is safe only when no cache-miss write occurred
* (sdfBufferChanged false) and the used size matches the last upload —
* a size mismatch means the set of contributing text nodes changed even
* if every individual write was a cache hit.
*/
shouldUploadSdfBuffer(): boolean {
if (this.sdfBufferChanged === true) {
return true;
}
return this.sdfBufferIdx !== this.lastSdfUploadSize;
}

getQuadCount(): number {
return this.numQuadsRendered;
}
Expand Down Expand Up @@ -1166,6 +1212,10 @@ export class WebGlRenderer extends CoreRenderer {
renderRTTNodes() {
const { glw } = this;

// RTT passes share the SDF buffer (their glyphs land at the front of it),
// so the main pass cannot assume the GPU copy still matches.
this.sdfBufferChanged = true;

// Save main scene buffer index so RTT rendering doesn't interfere
// with the dirty quad buffer optimization.
const savedBufferIdx = this.curBufferIdx;
Expand Down Expand Up @@ -1469,6 +1519,11 @@ export class WebGlRenderer extends CoreRenderer {
* next addQuad() pass will reassign compact, contiguous slots starting from 0.
*/
override invalidateQuadBuffer(): void {
// A structural render list change reorders SDF writes too, so the GPU
// SDF buffer can no longer be assumed current. Applies regardless of
// the quad-slot mode below.
this.sdfBufferChanged = true;

if (!DIRTY_QUAD_BUFFER) {
return;
}
Expand Down
Loading