PixiJS Batching and RenderGroups: Structuring Scenes for Fewer Draw Calls
PixiJS batches sprites sharing a base texture into single draw calls. Texture atlases and v8 RenderGroups let you structure scenes so the GPU draws instead of switches. A reproducible 2,000-sprite starfield shows the difference.
23 Feb 2026, 06:30 UTC

The problem: thousands of sprites, hundreds of draw calls
You add 2,000 star sprites to a PixiJS scene. Each star loads its own tiny PNG. The game runs at 60 fps on your laptop, but drops to 30 on a phone. Profiling shows 1,800+ draw calls per frame. The GPU spends more time switching textures than drawing pixels.
The fix isn't fewer sprites—it's fewer texture switches. PixiJS batches sprites that share a base texture and a compatible blend mode into a single WebGL/WebGPU draw call. If every star has its own texture, every star becomes its own draw call. Pack those stars into one atlas texture and the same scene drops to a handful of calls.
How PixiJS batching works
At render time, PixiJS walks the scene graph depth-first. Consecutive sprites that reference the same base texture and blend mode are grouped into one batch. A batch breaks when:
- The next sprite uses a different base texture
- The blend mode changes (e.g., normal → additive)
- A
Graphics,Text, orMeshnode appears between sprites - A filter or mask forces an off-screen render target
This means scene-graph order directly controls GPU state changes. Sibling order matters. A container holding 500 sprites from texture A, then 500 from texture B, produces two batches. Interleaving them (A, B, A, B) produces 1,000 batches.
Texture atlases: the first optimization
A texture atlas packs many small images into one larger texture. Each sprite references a sub-rectangle (a Texture with a frame) but shares the same baseTexture. Tools like TexturePacker, Shoebox, or the free pixi-atlas CLI generate the atlas image and a JSON map.
// Example atlas JSON snippet
{
"frames": {
"star-1.png": { "frame": {"x":0,"y":0,"w":16,"h":16} },
"star-2.png": { "frame": {"x":16,"y":0,"w":16,"h":16} }
},
"meta": { "image": "stars.png", "size": {"w":256,"h":256} }
}
Load the atlas once, then create sprites by frame name:
const assets = await PIXI.Assets.load('stars.json');
const stars = [];
for (let i = 0; i < 2000; i++) {
const frame = `star-${(i % 8) + 1}.png`; // 8 unique star frames
stars.push(new PIXI.Sprite(assets[frame]));
}
app.stage.addChild(...stars);
All 2,000 sprites now share stars.png as their base texture. PixiJS batches them into one or two draw calls (depending on vertex buffer limits).
PixiJS v8 RenderGroups: explicit batching control
Version 8 introduces RenderGroup, a first-class API for defining batching boundaries. Instead of relying solely on scene-graph order, you wrap a subtree in a RenderGroup and PixiJS treats it as a single batching/caching unit. This replaces much of v7's cacheAsBitmap pattern.
// PixiJS v8 only
import { RenderGroup } from 'pixi.js';
const starGroup = new RenderGroup();
starGroup.addChild(...stars); // 2,000 atlas-backed sprites
app.stage.addChild(starGroup);
RenderGroup also enables static and dynamic modes. A static group (default) assumes the subtree rarely changes; PixiJS can cache its geometry. A dynamic group re-uploads buffers each frame but still batches internally. Choose static for background layers, dynamic for moving particle fields.
Worked example: starfield comparison you can run
Create a minimal test to see the difference. This example targets PixiJS v8 and works with both WebGL and WebGPU renderers.
- Set up a project with
npm create vite@latest pixi-batch-test -- --template vanilla, thennpm i pixi.js@^8. - Generate an atlas (or skip and use individual textures for the first run). Place 8 star PNGs (16×16) in
src/assets/stars/. Run:npx @pixi/atlas src/assets/stars -o src/assets/stars.atlas.png -f json - Create two scenes in
main.js:import * as PIXI from 'pixi.js'; const app = new PIXI.Application(); await app.init({ width: 800, height: 600, preference: 'webgl' }); // or 'webgpu' document.body.appendChild(app.canvas); // Scene A: 2,000 sprites, each with its own base texture (simulated) const separateTextures = []; for (let i = 0; i < 8; i++) { separateTextures.push(await PIXI.Assets.load(`/stars/star-${i+1}.png`)); } const separateStars = []; for (let i = 0; i < 2000; i++) { separateStars.push(new PIXI.Sprite(separateTextures[i % 8])); } // Scene B: 2,000 sprites sharing one atlas base texture const atlas = await PIXI.Assets.load('/stars.atlas.json'); const atlasStars = []; for (let i = 0; i < 2000; i++) { atlasStars.push(new PIXI.Sprite(atlas[`star-${(i % 8)+1}.png`])); } // Toggle with '1' or '2' key let current = separateStars; app.stage.addChild(...current); window.addEventListener('keydown', (e) => { if (e.key === '1' || e.key === '2') { app.stage.removeChildren(); current = e.key === '1' ? separateStars : atlasStars; app.stage.addChild(...current); } }); - Measure draw calls. Open Chrome DevTools → Performance tab, record a few frames, and check the "GPU" track for "Draw" events. Or install the PixiJS DevTools browser extension (shows batch count in the inspector). Alternatively, use SpectorJS to capture a frame and count
drawArrays/drawElementscalls.
Expected outcome: Scene A shows hundreds of draw calls (one per texture switch). Scene B shows 1–4 draw calls (vertex buffer splits at 65k vertices). The difference is measurable on any device.
Trade-offs and limitations
- Atlas build step: You need a packing tool in your pipeline. For rapid prototyping, individual textures are faster to iterate.
- RenderGroup complexity: Static groups won't reflect child transforms unless you call
renderGroup.updateTransform()or mark them dirty. Dynamic groups cost more per frame. - ParticleContainer (v7) /
ParticleContainer(v8) still exists for extreme counts (10k+ simple sprites). It drops features like filters, masks, and nested children. Use it when you need raw throughput and can live with the restrictions. - Filters break batches: A
BlurFilteron a container forces an off-screen render target. Scope filters to the smallest subtree possible—apply to a 50-sprite explosion, not the 2,000-sprite background. - Batching is an implementation detail: Exact draw-call numbers vary by renderer (WebGL vs WebGPU), PixiJS version, and GPU driver. Treat measurements as guidance, not guarantees.
Actionable checklist for your scene
- Open your game in SpectorJS or PixiJS DevTools. Note the draw-call count.
- Identify the top 3 containers contributing the most calls.
- For each: are siblings using different base textures? Can they share an atlas?
- Are filters or masks applied to large containers? Move them down to leaf subtrees.
- If on v8, wrap static background layers in a
RenderGroup(static mode). Wrap moving particle layers in a dynamicRenderGrouporParticleContainer. - Re-measure. Aim for < 50 draw calls for a typical 2D scene; < 10 for simple ones.
Batching isn't magic—it's a contract between your scene structure and the renderer. Honor the contract (shared base texture, consistent blend mode, minimal interleaving) and the GPU spends its budget drawing, not switching.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.