From 1188399f25b4562653ad4fdddb8aef98fc1b6d35 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Mon, 8 Jun 2026 22:08:12 -0400 Subject: [PATCH] fix(webgl): fill entire quad index buffer; cap at Uint16 limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createIndexBuffer's loop used `i < maxQuads` as its bound, but `i` is the write cursor into the index array and advances by 6 (six indices per quad). The array is `maxQuads * 6` long, so the loop exited ~6x too early, leaving roughly 5/6 of the buffer at its initialized value of 0. Every quad past ~maxQuads/6 (~4167 with the default 2e6 buffer budget) therefore drew with all-zero indices — two degenerate triangles over vertex 0 — and rendered nothing. This silently dropped any geometry beyond ~4167 quads in a frame. SDF glyphs index into this shared buffer, so text added last (e.g. a high-zIndex debug overlay on top of a large card grid) fell into the dead zone and vanished, while everything before it rendered fine. Fixes: - Loop bound is now `i < maxQuads * 6`, so every slot gets valid indices. - maxQuads is capped at 16384. Uint16 element indices can only address 65536 vertices (16384 quads * 4); the previous 25000 would have overflowed the vertex id past 65535 and wrapped. The capped buffer is also smaller on the GPU (192 KB vs ~293 KB) while raising usable capacity ~4x. Adds a unit test covering full population of the buffer (including the last quad) and the 16384 cap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webgl/internal/RendererUtils.test.ts | 73 +++++++++++++++++++ .../renderers/webgl/internal/RendererUtils.ts | 11 ++- 2 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 src/core/renderers/webgl/internal/RendererUtils.test.ts diff --git a/src/core/renderers/webgl/internal/RendererUtils.test.ts b/src/core/renderers/webgl/internal/RendererUtils.test.ts new file mode 100644 index 0000000..998a141 --- /dev/null +++ b/src/core/renderers/webgl/internal/RendererUtils.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { createIndexBuffer } from './RendererUtils.js'; +import type { WebGlContextWrapper } from '../../../lib/WebGlContextWrapper.js'; + +// Capture the Uint16Array handed to elementArrayBufferData so we can assert on +// the generated quad indices without a real GL context. +function mockGlw(): { + glw: WebGlContextWrapper; + getIndices: () => Uint16Array; +} { + let captured: Uint16Array | null = null; + const glw = { + STATIC_DRAW: 0, + createBuffer: () => ({}), + elementArrayBufferData: (_buffer: unknown, indices: Uint16Array) => { + captured = indices; + }, + } as unknown as WebGlContextWrapper; + return { + glw, + getIndices: () => { + if (captured === null) { + throw new Error('elementArrayBufferData was not called'); + } + return captured; + }, + }; +} + +// The expected 6 element indices for quad `q`: two triangles over the quad's +// four vertices [4q, 4q+1, 4q+2, 4q+3] wound as [0,1,2, 2,1,3]. +const expectedQuad = (q: number): number[] => { + const j = q * 4; + return [j, j + 1, j + 2, j + 2, j + 1, j + 3]; +}; + +describe('createIndexBuffer', () => { + it('fills indices for EVERY quad, not just the first 1/6', () => { + // size / 80 = 800 quads, comfortably under the Uint16 cap. + const { glw, getIndices } = mockGlw(); + createIndexBuffer(glw, 800 * 80); + const indices = getIndices(); + const maxQuads = 800; + + expect(indices.length).toBe(maxQuads * 6); + + // First, middle and — critically — the LAST quad must be populated. The + // original bug stopped the loop at `i < maxQuads`, zeroing every quad past + // ~maxQuads/6 and collapsing it into a degenerate triangle. + for (const q of [0, 1, maxQuads >> 1, maxQuads - 2, maxQuads - 1]) { + const slice = Array.from(indices.subarray(q * 6, q * 6 + 6)); + expect(slice).toEqual(expectedQuad(q)); + } + + // No trailing zeroed slots (every quad past index 0 references vertices > 0). + const lastQuad = Array.from(indices.subarray((maxQuads - 1) * 6)); + expect(lastQuad.some((v) => v !== 0)).toBe(true); + }); + + it('caps at 16384 quads so Uint16 vertex ids never overflow', () => { + // Request far more than Uint16 can address (25000 quads worth of bytes). + const { glw, getIndices } = mockGlw(); + createIndexBuffer(glw, 25000 * 80); + const indices = getIndices(); + + expect(indices.length).toBe(16384 * 6); + // The very last vertex id is 16384*4 - 1 = 65535, the Uint16 maximum. + expect(indices[indices.length - 1]).toBe(65535); + expect(Array.from(indices.subarray(16383 * 6, 16383 * 6 + 6))).toEqual( + expectedQuad(16383), + ); + }); +}); diff --git a/src/core/renderers/webgl/internal/RendererUtils.ts b/src/core/renderers/webgl/internal/RendererUtils.ts index 4d32030..d01ea3d 100644 --- a/src/core/renderers/webgl/internal/RendererUtils.ts +++ b/src/core/renderers/webgl/internal/RendererUtils.ts @@ -11,10 +11,17 @@ export function createIndexBuffer( glw: WebGlContextWrapper, size: number, ): WebGLBuffer | null { - const maxQuads = ~~(size / 80); + // 4 vertices per quad. Element indices are Uint16, so the largest vertex id + // we can address is 65535 — i.e. 16384 quads (16384 * 4 = 65536). Never claim + // more than that, regardless of the requested byte budget. + const maxQuads = Math.min(~~(size / 80), 16384); const indices = new Uint16Array(maxQuads * 6); - for (let i = 0, j = 0; i < maxQuads; i += 6, j += 4) { + // i walks the index slot (6 per quad), j the vertex base (4 per quad). The + // bound must be maxQuads * 6 to fill every slot — stopping at maxQuads left + // ~5/6 of the buffer zeroed, collapsing every quad past ~maxQuads/6 into a + // degenerate triangle (the cause of tail geometry silently disappearing). + for (let i = 0, j = 0; i < maxQuads * 6; i += 6, j += 4) { indices[i] = j; indices[i + 1] = j + 1; indices[i + 2] = j + 2;