Inside spryte: how its renderer differs from PixiJS's
spryte and PixiJS 8 both draw 2D sprites with WebGL, batch many textures per draw call and use premultiplied alpha, and where they share a feature they draw the same pixels. Underneath they're built on opposite bets. PixiJS keeps a scene graph and builds four vertices per sprite in JavaScript. spryte is immediate mode: every draw is one GPU instance, and its bulk path uploads 8 bytes per sprite with no per-sprite JavaScript at all. This article walks through those choices, with byte counts taken from both libraries' source (spryte 0.1.2, PixiJS 8.22). For what they're worth in frame time, see how spryte got fast.
| spryte | PixiJS 8 | |
|---|---|---|
| API model | Immediate mode: draw.sprite(...) every frame | Retained scene graph of Containers |
| Sprite on the GPU | One instance of a shared quad | Four vertices plus six indices |
| Bytes uploaded per sprite | 52 (draw.sprite), 8 (draw.sprites, position only) | 96 of vertices + 24 of indices (Sprite), 32 per dynamic attribute pair (ParticleContainer) |
| Corner positions computed in | Vertex shader | JavaScript (packQuadAttributes) |
| Textures per draw call | 8 | Up to the GPU's texture-unit limit (commonly 16) |
| Backends | WebGL2, plus a lazily loaded Canvas 2D fallback | WebGL and WebGPU |
| JS at startup (brotli) | 10.1 kB | 87.8 kB |
1. Immediate mode against a scene graph
PixiJS is retained: you build a tree of Container, Sprite and Graphics objects, change their properties, and the renderer walks the tree. PixiJS 8 adds render groups, which cache the list of draw instructions and rebuild it only when the tree's structure changes (structureDidChange). That's a good fit for UI and scenes where most objects sit still.
spryte has no tree. A game calls draw.sprite, draw.rect or draw.text each frame, the way it would with Canvas 2D, and spryte keeps only what can't be recomputed cheaply: uploaded textures, rasterised text and its GPU buffers. Game objects stay in the game's own data structures. There's no object per sprite to allocate, no parent transform to propagate and no dirty flag to track, and the hot path allocates nothing.
The cost is that spryte can't skip work for things that didn't move. For games, where most of the screen changes every frame, that's the right trade. A static backdrop can still be drawn once into a render target and reused.
2. Everything is a quad
Inside spryte, every draw call becomes one backend operation:
quad(matrix, source, srcRect, rgba, flash, smooth)The 2×3 matrix maps a unit square into the scene. Rects use a 1×1 white texture, circles a cached 64 px disc, and text a cached raster or bitmap-font glyphs. Because there's one primitive, every feature that reduces to quads batches with everything else for free: text, shapes and sprites share draw calls.
PixiJS has a richer set of renderables (meshes, Graphics with tessellated paths, tiling sprites), each with its own pipe. That's more capable and more code: it's a large part of the 9× size gap.
3. Instances against vertices
This is the biggest structural difference.
PixiJS's default batcher writes four vertices per sprite into one big buffer. For each corner, JavaScript multiplies the world transform by the corner's local position and writes 6 words: position (2 floats), UV (2 floats), packed colour and packed texture id plus rounding flag. That's 4 × 24 = 96 bytes, plus 6 indices (24 bytes as Uint32) per sprite.
spryte uses instanced rendering. A static 4-vertex quad is bound once, and each sprite is one 52-byte instance: the 2×3 matrix (6 floats), the UV rectangle (4 floats), flash and texture unit (2 floats) and RGBA8 colour. The vertex shader applies the matrix to the quad's corners, so JavaScript does one matrix per sprite instead of four corner transforms, and uploads less than half the bytes.
4. A bulk path with no per-sprite JavaScript
Most of a particle system's sprites share everything but position. spryte's draw.sprites(frame, positions, count, options, each?) uses a second GPU program where an instance is just a vec2: 8 bytes. The shared transform, UVs, tint and flash become uniforms, sent once per call, and pixel snapping runs in the vertex shader with the same rule draw.sprite uses. A Float32Array you already have goes straight to bufferData, untouched by spryte.
Optional per-copy arrays (scale, rotation, packed 0xRRGGBBAA colour) are separate buffers. Ones you don't pass read constant vertex-attribute defaults, so a fully dynamic particle costs 8 + 4 + 4 + 4 = 20 bytes. Colour is unpacked in the shader with bit shifts, so JavaScript never reorders bytes.
PixiJS's equivalent, ParticleContainer, splits attributes into static and dynamic buffers and only re-uploads the dynamic ones, which is the right idea. Its attributes are still per vertex, though: a dynamic position is 4 × 8 = 32 bytes per particle, against spryte's 8.
5. Batching and what breaks a batch
Both libraries bind several textures per draw call and pick one per sprite in the shader, so sprites from different images still batch. PixiJS sizes the batch to the GPU's texture-unit limit. spryte uses 8 (texture, filter) pairs and carries the filter in WebGL2 sampler objects, so one uploaded texture serves smooth and pixelated draws in the same batch.
A spryte batch flushes on a 9th distinct texture, a full 16,384-instance buffer, a blend or clip change, and at the end of the frame. Custom shaders don't break the instance format: the user's effect() is spliced between spryte's fragment header and main, behind a #line 1 so compile errors point at the user's own lines, and those draws batch like any other.
6. Colour maths
Both libraries premultiply on upload and blend with ONE, ONE_MINUS_SRC_ALPHA. spryte's fragment shader is short enough to quote:
vec4 t = texture(u_tex, v_uv) * v_color; // tint × alpha, premultiplied
outColor = vec4(mix(t.rgb, vec3(t.a), flash), t.a); // flash mixes toward whiteIn a parity audit of 29 scenes against PixiJS 8.22, spryte's WebGL2 output matched exactly wherever the two have equivalent features, including tint on partly transparent pixels.
7. A Canvas 2D fallback, loaded only when needed
PixiJS's second backend is WebGPU. spryte's is Canvas 2D, for browsers or GPUs where WebGL2 isn't available, and it's a separate chunk fetched by dynamic import() only when it's used, so WebGL2 users never download it.
Each quad becomes setTransform plus drawImage. Canvas 2D has no per-draw tint, and its composite modes can't reproduce premultiplied tint on partly transparent pixels, so a tinted or flashed frame is recoloured once on the CPU with the same formula as the shader, then cached per source, frame, colour and flash. Untinted draws skip that entirely. A browser check draws the same tinted, flashed, partly transparent sprites on both backends, on the canvas and inside a render target, and requires every channel to agree within 2 levels.
The fallback is a correctness path, not a fast one: it holds about 5,000 sprites at 60 fps, like raw Canvas 2D. Anti-aliased edges of rotated sprites can differ by a pixel's coverage, since Canvas 2D rasterises edges its own way.
8. Other renderers
- Phaser 3 is retained, like PixiJS: a display list, with WebGL pipelines that build multi-texture vertex batches on the CPU.
- three.js can draw 2D sprites, but each
Spriteis its own object and draw call. Thousands of sprites needInstancedMeshor a custom shader, which is the instancing spryte does by default. - Raw Canvas 2D is one
drawImageper sprite with no batching, which is why it tops out near 5,000 sprites at 60 fps on the benchmark machine.
What this doesn't show
- Where retained wins. A scene graph with cached instructions can beat immediate mode when almost nothing changes. Our benchmarks animate every sprite, so they don't measure that case.
- WebGPU. spryte doesn't have a WebGPU backend yet; PixiJS does.
- Features spryte leaves out: meshes, vector paths, masks beyond axis-aligned clips, and most blend modes. Each will come when a game needs it and a benchmark shows the common path doesn't pay for it.
Read the source: spryte's whole WebGL2 backend is one file, webgl2.ts. Try it with npm install spryte.