Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

WebGL2 Dynamic Sprite Batching & Texture Atlas Packing

▶ Live demo · Source

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.

What is in here

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

Install

npm install

Running

npm 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 dev is mandatory: the demo runs off the Vite module server. Open index.html with file:// and the modules never load — the screen stays blank.

Expected output

npm test

 ✓ test/batch.test.ts (7 tests)
 ✓ test/atlas.test.ts (8 tests)
 Test Files  2 passed (2)
      Tests  15 passed (15)

npm run bench

== 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.

npm run dev

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 a webgl2 and a 2d context from the same canvas; the first getContext call fixes the canvas type permanently.

Note: why don't the tests call WebGL?

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.

License

MIT

About

Batched 2D sprite rendering in WebGL2: procedural texture atlas generation, dynamic vertex buffer batching, single draw call dispatch, and Canvas 2D performance comparison.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages