A WebGL2 sprite batching example that collapses ten thousand sprites into a
single drawElements. Every sprite vertex is packed into one pre-allocated
Float32Array, a procedurally generated texture atlas removes texture switching,
and when WebGL2 is missing the Canvas 2D fallback path takes over. The same
scene is compared live across both render paths: FPS, frame time and a draw
call counter on screen.
| File | Contents |
|---|---|
src/sprites.ts |
makeRng (mulberry32), Sprite, makeSprites, updateSprites — the deterministic scene |
src/atlas.ts |
AtlasLayout, UVRect, atlasWidth/Height, frameCount, atlasFrame(index, layout, inset) — pure UV math |
src/atlas-pixels.ts |
makeAtlasPixels (procedural RGBA atlas, NO external assets) + atlasToCanvas |
src/batch.ts |
FLOATS_PER_SPRITE, MAX_CAPACITY, makeQuadIndices, BatchRenderer, SpriteBatch (begin/draw/flush/end) |
src/shaders.ts |
BATCH_VERTEX_SHADER + BATCH_FRAGMENT_SHADER (#version 300 es on the first line, pixel→clip on the GPU) |
src/gl.ts |
compileShader / createProgram — verbatim from project #8 |
src/texture.ts |
createTexture(gl, data, width, height) — from #8, adapted for a non-square atlas |
src/gl-renderer.ts |
VAO + VBO (bufferSubData) + IBO (STATIC_DRAW) + blending + drawElements |
src/canvas2d-renderer.ts |
drawSpritesCanvas2D — one drawImage per sprite (the reference path) |
src/render-path.ts |
createRenderPath — WebGL2 / Canvas 2D behind a single interface |
src/sampler.ts |
createSampler — FPS / frame time over a 500 ms window |
src/main.ts |
Demo: two canvases, sprite count buttons, path switch, HUD |
src/bench-cli.ts |
Node bench: UV correctness, packing throughput, draw call accounting |
test/batch.test.ts |
7 tests: offset/stride, no overwriting, flush threshold, RangeError, indices |
test/atlas.test.ts |
8 tests: atlasFrame UVs, wrapping, inset, procedural atlas pixels |
npm installnpm run dev # Vite dev server — demo (sprite count + render path switch)
npm run build # tsc --noEmit + vite build (dist/)
npm test # vitest — 15 pure-logic tests (NO WebGL calls)
npm run bench # Node bench: UV correctness + packing speed + draw call accounting
npm run devis mandatory: the demo runs off the Vite module server. Openindex.htmlwithfile://and the modules never load — the screen stays blank.
✓ test/batch.test.ts (7 tests)
✓ test/atlas.test.ts (8 tests)
Test Files 2 passed (2)
Tests 15 passed (15)
== atlasFrame correctness (4×2, 32px) ==
frame 0 u[0.000..0.250] v[0.000..0.500] OK
...
wrapping (8→0, -1→7): OK
result: ALL CORRECT
== SpriteBatch packing throughput ==
2,000,000 sprites 17.4 ms
114.96 M sprite/s (8.7 ns/sprite)
128.0 MB of floats written, 123 shipments
== naive drawImage path vs batch path (CPU side, no GPU) ==
sprite naive ms naive calls batch ms batch calls
1,000 0.071 1,000 0.143 1
10,000 0.096 10,000 0.172 1
50,000 0.968 50,000 0.836 4
== flush accounting (capacity 16,384) ==
1,000 sprites → 1 draw call (expected 1) OK
10,000 sprites → 1 draw call (expected 1) OK
16,384 sprites → 1 draw call (expected 1) OK
50,000 sprites → 4 draw call (expected 4) OK
Timings vary from machine to machine; the draw call column does not: n on
Canvas 2D, ceil(n / 16384) in the batch.
The naive ms column is not a real drawImage in Node but a stub that sums its
arguments — the per-call ceremony on the driver side is missing there. The real
difference shows up in the browser demo.
On a dark navy background, eight different shapes from the procedural atlas (filled circle, ring, diamond, framed square — four colors × two rows) bounce around the screen. Control panel at the top left:
- 1,000 / 10,000 / 50,000 sprite buttons,
- WebGL2 batch ↔ Canvas 2D path switch,
- HUD: path, sprite count, FPS, frame time, draw call.
At 10,000 sprites in WebGL2 mode you get draw call: 1 and FPS glued to 60;
switch to Canvas 2D and it becomes draw call: 10,000 with a visible FPS drop.
At 50,000 WebGL2 reports draw call: 4, while Canvas 2D practically crawls.
There is no bleeding from the neighbouring frame's color along sprite edges —
atlasFrame(..., 0.5) pulls half a texel inward.
Note: the two render paths use separate
<canvas>elements (#gl-scene,#c2d-scene). You cannot take both awebgl2and a2dcontext from the same canvas; the firstgetContextcall fixes the canvas type permanently.
The headless vitest (Node) environment has no WebGL2RenderingContext and no
GPU. SpriteBatch deliberately draws from behind the BatchRenderer interface:
in the test we hand it a fake "courier" and verify the container's contents
(offset, stride, vertex order, flush threshold). atlasFrame and
makeAtlasPixels are pure functions too — numbers in, numbers/arrays out. That
the GPU draws the right pixels is verified by eye in the browser.
MIT