Skip to content

fix(webgl): fill entire quad index buffer; cap at Uint16 limit#79

Merged
chiefcll merged 1 commit into
mainfrom
fix/webgl-index-buffer-quad-cap
Jun 9, 2026
Merged

fix(webgl): fill entire quad index buffer; cap at Uint16 limit#79
chiefcll merged 1 commit into
mainfrom
fix/webgl-index-buffer-quad-cap

Conversation

@chiefcll

@chiefcll chiefcll commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

What

createIndexBuffer only filled valid indices for ~1/6 of the quad index buffer, silently capping renderable geometry at ~4167 quads per frame. Anything beyond that drew with all-zero indices (degenerate triangles) and rendered nothing.

Root cause

const indices = new Uint16Array(maxQuads * 6); // 150000 slots
for (let i = 0, j = 0; i < maxQuads; i += 6, j += 4) { ... }

i is the write cursor into the index array and advances by 6 per quad (six indices per quad), so the bound must be maxQuads * 6. Stopping at i < maxQuads exited ~6× too early, leaving ~5/6 of the buffer at its initialized value of 0. A quad whose six indices are all 0 is two zero-area triangles over vertex 0 → invisible.

Because SDF glyphs index into this same shared buffer, text added last in a frame (e.g. a high-zIndex debug overlay drawn over a large card grid) fell into the dead zone and disappeared, while everything before it rendered correctly.

Fix

  • Loop bound is now i < maxQuads * 6, so every slot receives valid indices.
  • maxQuads is capped at 16384. Uint16 element indices can only address 65536 vertices (16384 quads × 4); the previous value of 25000 would have overflowed the vertex id past 65535 and wrapped to the wrong vertices. The capped index buffer is also smaller on the GPU (192 KB vs ~293 KB) while raising usable capacity ~4× (4167 → 16384 quads).

Impact

This affected any scene exceeding ~4167 quads in a frame — not just text. Large grids (e.g. viewport-memory) were hitting it. Reproducible with the stress-tv example at tier 4 / 200 cards + ?debug=true: the overlay truncated mid-line; with this fix it renders fully.

Tests

  • Adds RendererUtils.test.ts: asserts the buffer is fully populated (including the last quad, which the old loop left zeroed) and that maxQuads caps at 16384 with the final vertex id landing exactly at 65535.
  • Full unit suite passes (318/318).

Notes for reviewer

  • The 16384 cap is the hard ceiling of Uint16 element indices. Raising bufferMemory past 16384 * 80 no longer grows this buffer; going beyond would require 32-bit indices (OES_element_index_uint / WebGL2).
  • WebGL-only path; no Canvas backend equivalent.

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@chiefcll chiefcll merged commit d02c766 into main Jun 9, 2026
1 check passed
@chiefcll chiefcll deleted the fix/webgl-index-buffer-quad-cap branch June 9, 2026 02:13
chiefcll added a commit that referenced this pull request Jun 9, 2026
…t) (#80)

Adds `?autosweep=true` to the stress-tv example. Instead of driving the grid
by hand with the remote, it sweeps every tier from low to high card count,
measures median FPS per step (rAF timing, with a short warm-up to skip the
rebuild spike), and bisects between the last good rung and the first bad one
to report the highest count that holds the target frame rate.

  ?test=stress-tv&autosweep=true              # target 60 fps (default)
  ?test=stress-tv&autosweep=true&targetfps=30

Results print to the console (console.table) and an on-screen panel. The sweep
reports two distinct ceilings so they are never conflated:
  - performance wall: the measured FPS knee (CPU- or GPU-bound)
  - correctness wall: the Uint16 index-buffer cap (16384 quads / glyphs)
The reported sweet spot is min(perf wall, correctness wall), and counts are
clamped to the cap so the sweep never builds a scene whose own text would drop.

VAO is fixed at renderer construction, so A/B it with two runs and compare:
  ?test=stress-tv&autosweep=true  vs  ?test=stress-tv&autosweep=true&novao=true

Also refactors buildGrid to take an explicit count so the sweep can drive
arbitrary values; interactive remote controls are unchanged.

Note: the correctness-wall math assumes the index-buffer fix (#79). Best
reviewed/merged alongside it; without it the real cap is ~4167 and high-count
tiers would drop text mid-sweep.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant