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;