How spryte got 2–3.8× faster than PixiJS
spryte is the renderer inside debuff, now a standalone MIT package. On our benchmark it's 2.0–3.8× faster than PixiJS 8 on like-for-like workloads, and it ships about 9× less JavaScript at startup (10.1 kB vs 87.8 kB, brotli).
No single trick got it there. It came from a chain of measurements, each ruling something in or out, followed by a benchmark built to stop us fooling ourselves. This post walks through that chain, including the dead ends and the numbers that went down once we checked them properly.
The starting point
spryte started as the WebGL2 renderer in @debuffjp/core. It was already an instanced, multi-texture batcher: every sprite is one instance in a GPU buffer, up to 8 textures are bound per draw call, and nothing allocates in the hot path. On a bunnymark it held about 100,000 sprites at 60 fps on our test laptop. That was about 2× PixiJS's Sprite, but slower than PixiJS's ParticleContainer.
The question was why, and whether we could do better without turning spryte into a different kind of library.
1. Measure where the time goes
Our benchmark already split each frame into two numbers: main-thread time before the GPU sync, and total time to GPU completion. At 200,000 sprites:
| Frame | Main thread | Per sprite (CPU) | |
|---|---|---|---|
spryte draw.sprite | 21 ms | 14 ms | ~70 ns |
PixiJS ParticleContainer | 13 ms | 7 ms | ~36 ns |
GPU time was about the same. The gap was JavaScript, per sprite.
2. A dead end, and a false positive
Our first guess was a micro-optimisation. draw.sprite checked "image" in frame on every call, and on a DOM image that walks a long prototype chain, so we cached the last image. The benchmark didn't move.
An earlier tweak had looked like a 25% gain in partial runs. A full run showed it was noise: on this laptop, frame times vary by about ±15% between runs. That's when we stopped guessing and took a CPU profile at 200k sprites:
| Function | Share of samples |
|---|---|
Draw.sprite | 26% |
WebGL2Backend.quad | 23% |
GPU sync (readPixels) | 21% |
The cost was spread across the per-call work itself: reading options, the transform maths, and 13 buffer writes per sprite. No single line was slow, so trimming the per-call path couldn't close a 2× gap. The fix had to be structural.
3. Stop paying JavaScript per sprite
ParticleContainer wins because it's a bulk API. So we added one in spryte's immediate-mode style:
draw.sprites(frame, positions, count, options);It takes one frame and many positions. The shared transform is worked out once, and each copy is written in a tight loop. That brought spryte level with ParticleContainer up to about 200,000 sprites.
Then, at 400,000, spryte fell behind again (28 vs 18 ms). This time the main-thread number was fine, and the GPU-side share had grown.
4. The real bottleneck was bandwidth
Every copy still uploaded a full 52-byte instance (a 2×3 matrix, a UV rectangle, colour, flash and a texture unit), even though only the position changed. At 400,000 sprites that's about 21 MB per frame.
So we added a second GPU program in which each instance is just a vec2 position: 8 bytes instead of 52. Everything shared (the transform, UVs, tint, pixel snapping) became a uniform, sent once per call. And because the instance data is just positions, a Float32Array you already have can go straight to bufferData:
const positions = new Float32Array(2 * MAX); // x, y pairs you update each frame
draw.sprites(spark, positions, live); // no per-copy JavaScript in spryte at allThe result: 200,000 sprites in 3.8 ms, and 60 fps held at 800,000 sprites, against 200,000 for ParticleContainer.
5. Real particles change per particle
Real particles vary in scale, rotation and colour, and ParticleContainer supports that. We added optional per-copy arrays without giving up the zero-per-copy-JavaScript property:
draw.sprites(spark, positions, live, { tint: "#ffd36b" }, { scale, rotation, color });- Each array is a separate GPU buffer, uploaded as is.
- Colour stays packed as
0xRRGGBBAAin aUint32Arrayand is unpacked in the shader with bit shifts, so JavaScript never reorders bytes. - Arrays you don't pass read constant vertex-attribute defaults (scale 1, rotation 0, white), so you only upload what you use.
- The vertex shader runs exactly the same transform maths as
draw.sprite.
6. Prove it draws the same thing
A fast path that draws slightly different pixels is a bug. We wrote checks that run on a real GPU (headless Chrome): draw a scene the simple way (sprite() in a loop) and the fast way, then compare every pixel. Each check was confirmed to fail on a deliberately broken implementation.
The checks found a real bug. With per-copy rotation, a copy whose rotation happened to be zero wasn't pixel-snapped, but sprite() would have snapped it. At first the 25-pixel difference looked like float noise inside a tolerance, but it was identical on WebGL2 and on Canvas 2D. Float noise wouldn't be. That meant a deterministic cause, which we traced and fixed. The tolerance went back to zero.
7. A benchmark designed to catch us
The first comparison was a single run per renderer. To make the numbers hold up, we rebuilt the benchmark:
- Interleaved rounds. Several rounds, with the renderer order rotated each round, reported as median with min–max.
- A pixel-parity gate. Before any timing, every renderer draws the same seeded frame at 2,000 and at 50,000 sprites. The run fails if a renderer's drawn area differs from the reference by more than 0.5%, or if pixels differ inside sprites. "Inside" means the 3×3 neighbourhood is one flat colour in both frames, so edges, where libraries legitimately round differently, don't count. A wrong tint, alpha or texture does. We validated the metric: giving PixiJS a deliberately wrong tint raised it from 0% to 22%.
- Automatic environment capture, including power source and plan. We had typed "default power plan" into a note by hand; the laptop was actually on Turbo. The runner now detects it.
- Like for like:
draw.spriteagainstSprite, anddraw.spritesagainstParticleContainer. Both use nearest filtering, no antialiasing and whole-pixel rounding (PixiJS withroundPixels: true).
That method lowered two of our early claims: the bulk position-only case went from "3×" to 1.8×, and one-call-per-sprite from "4×" to 2.8×.
8. Then we attacked it on purpose
We asked three adversarial agents to break things.
One tried to prove the benchmark favoured spryte. It found a real flaw. In our "dynamic" scenes only position and rotation actually changed; scale, tint and alpha were constant, so a competent PixiJS user would mark them static. We made every property genuinely change over time. That lowered our headline from 4.2× to 3.8× (particles) and from 3.8× to 3.3× (mixed sprites). It also checked several things and found them fair: PixiJS's WebGPU renderer is slower than its WebGL one here; roundPixels costs nothing; an atlas helps PixiJS by about 11%, within noise; and the sync and warm-up are the same for both.
One tried to break spryte. It found six real bugs, each with a failing test:
- a shader that failed to compile left the renderer broken;
sprites()with acounttoo large drew nothing at all on WebGL2;- Canvas 2D smoothing went wrong after a clip;
- oversized render targets failed silently;
- a
NaNcount corrupted the frame's counters; - drawing after
dispose()touched dead GPU objects.
All six are fixed on spryte's main branch and ship in its next release, 0.1.1. A new check also forces a real GPU context loss and restore, and confirms the next frame is pixel-identical to a fresh renderer's.
One fact-checked every published claim. The ratios were right, but parity was oversold, our size claim had no committed harness, and the starter size was quoted four different ways. All of those are fixed, and the size harness is now in the repo.
The result
Same machine, same assets, same simulation, 3 interleaved rounds, parity gate passed:
| Workload | spryte | PixiJS 8 | spryte is |
|---|---|---|---|
| Dynamic particles (everything changes per particle), 200k | 7.7 ms | 28.9 ms (ParticleContainer) | 3.8× faster |
| Sprites with changing rotation, scale, tint and alpha, 4 textures, 50k | 9.8 ms | 32.3 ms (Sprite) | 3.3× faster |
| Bunnymark, one call per sprite, 50k | 5.8 ms | 16.4 ms (Sprite) | 2.8× faster |
| Bunnymark, bulk, 200k | 4.4 ms | 8.9 ms (ParticleContainer) | 2.0× faster |
| JS at startup | 10.1 kB | 87.8 kB | ~9× smaller |
NVIDIA RTX 5060 Laptop GPU, AMD Ryzen 7 260, Chrome 155, AC power, power plan Turbo. Raw data, method and caveats: benchmarks.
What this doesn't show yet
- Phones, barely. We have one phone so far: an iPhone on iOS 26, one quick round, with iOS's 1 ms timer resolution. It's indicative only. There, spryte's lead held for general sprites (about 3.6× at 50k) and for dynamic particles (about 2.3× at 200k). For position-only bulk drawing, PixiJS's
ParticleContainerdrew level (12 vs 13 ms at 200k), as you'd expect where the GPU's pixel-fill rate, not JavaScript, is the limit. We want many more devices, especially mid-range Androids, before calling anything settled. - Other workloads. Tilemaps, text-heavy UI, clipping, render targets and shaders, object churn, memory and load time aren't measured yet.
- Other renderers. PixiJS is the reference everyone knows, but it isn't the only one.
That last point is why we built 2dbench, an open benchmark where any 2D web renderer plugs in through a small adapter: Phaser, KAPLAY, Excalibur, LittleJS and three.js are next. It has:
- one shared, seeded simulation, so every library does identical work;
- the same parity gate, with PixiJS
Sprite, the incumbent, as the reference rather than our own code; - statistics chosen from a variance study rather than asserted;
- results recorded with their full environment.
Anyone can run it on a phone at debuffjp.github.io/2dbench/mobile.html and submit the results through a GitHub issue, which a workflow turns into a validated pull request. If you have a device that isn't in the community results yet, please run it.
One more honest note from 2dbench: its simulation is lighter than debuff's own benchmark, which leaves more of each frame to the renderer, so the same two libraries come out further apart there (particles about 4.7× rather than 3.8×). Benchmark design moves ratios. That's why we quote the more conservative numbers and publish the method with every one.
Lessons
- Profile before optimising. Our first guess did nothing, and a "win" turned out to be noise.
- Find the real bottleneck class. Per-call JavaScript, then upload bandwidth. Neither yields to micro-tuning.
- Make fast paths prove themselves. Compare pixels against the simple path, on a real GPU, with checks you've seen fail.
- Attack your own benchmark. Twice, the honest number was lower than our first claim. Publishing the method and the corrections is what makes the remaining numbers worth believing.
Try it: npm install spryte. The API reference and the benchmark method are on this site, and the code is at github.com/debuffjp/spryte.