How PixiJS Batches Draw Calls
Every WebGL draw call has CPU overhead. PixiJS's renderer automatically merges consecutive sprites that share the same base texture and blend mode into a single draw call — a "batch". Break that contract and every sprite becomes its own draw call, collapsing performance at scale.
import { Application, Assets, Sprite } from "pixi.js";
const app = new Application();
await app.init({ width: 800, height: 600, antialias: false });
// Load a single texture atlas — all frames share one base texture
const sheet = await Assets.load("/sprites/characters.json");
for (let i = 0; i < 1000; i++) {
const sprite = new Sprite(sheet.textures["hero_idle_01.png"]);
// Same base texture -> all 1 000 sprites stay in ONE batch
sprite.x = Math.random() * 800;
sprite.y = Math.random() * 600;
app.stage.addChild(sprite);
}
// BAD: mixing textures mid-scene breaks the batch
// const logo = new Sprite(await Assets.load("/logo.png")); // new base texture -> flush!Texture Atlases and Sprite Sheets
A texture atlas packs many images into one PNG. Because every frame in the atlas shares the same base texture, they all belong to one batch — regardless of how many sprites are on screen.
Shared Base Textures and Avoiding Texture Swaps
The renderer flushes its current batch whenever it needs to switch to a different GPU texture. Keeping sprites on the same base texture is the single most impactful batching rule.
| Batch-Friendly Pattern | Batch-Breaking Pattern |
|---|---|
| sprite.tint = 0xff0000 (same texture, tint in shader) | new Sprite(differentTexture) → immediate flush |
| All frames from one atlas.json | Mixing atlas A sprites with atlas B sprites mid-layer |
| sprite.blendMode = 'normal' (default) | sprite.blendMode = 'add' → forces a new batch |
| No per-sprite filters on crowd sprites | sprite.filters = [blurFilter] → breaks batch for that sprite |
| Sorting children by texture before render | Random Z-order mixing sprites from different textures |
Culling Off-Screen Sprites
PixiJS renders every visible object in the scene graph by default, even those outside the viewport. Culling skips the vertex upload and draw for off-screen sprites — essential for large tile maps or open-world scenes.
import { Container, Sprite, RenderLayer } from "pixi.js";
// --- Per-sprite culling ---
const sprite = new Sprite(texture);
sprite.cullable = true; // renderer skips this sprite when off-screen
// Optionally provide a tighter cull rectangle (avoids AABB recompute each frame)
sprite.cullArea = new PIXI.Rectangle(0, 0, 64, 64);
// --- RenderGroup: cache a static subtree as a single texture ---
const staticMap = new Container();
staticMap.isRenderGroup = true; // rendered to an off-screen texture once
// children can still be read by the scene graph but the GPU sees one quad
app.stage.addChild(staticMap);
// Add 10 000 static tiles — only re-renders when staticMap.cacheAsDirty = true
for (let i = 0; i < 10_000; i++) {
const tile = new Sprite(tileTexture);
tile.cullable = true;
staticMap.addChild(tile);
}ParticleContainer for Thousands of Simple Sprites
ParticleContainer trades flexibility for raw throughput. It uploads only position, scale, rotation, alpha, and tint per sprite — no per-child filters, no arbitrary transforms, no mixed textures. The payoff is dramatic for particle effects, rain, dust, or crowd simulations.
import { ParticleContainer, Sprite, Texture } from "pixi.js";
const particles = new ParticleContainer(50_000, {
position: true, // upload x/y each frame
rotation: true, // upload rotation
scale: false, // skip if uniform scale
alpha: true, // upload alpha
tint: false, // skip if no tinting
});
app.stage.addChild(particles);
const texture = Texture.from("spark.png");
for (let i = 0; i < 50_000; i++) {
const spark = new Sprite(texture);
spark.x = Math.random() * app.screen.width;
spark.y = Math.random() * app.screen.height;
particles.addChild(spark);
}
// Animate inside ticker — ParticleContainer skips the full scene-graph walk
app.ticker.add(() => {
for (const child of particles.children) {
(child as Sprite).y += 1;
if ((child as Sprite).y > app.screen.height) (child as Sprite).y = 0;
}
});The Ticker and Delta-Time Loop
PixiJS uses a requestAnimationFrame-based Ticker. Always multiply movements by deltaTime so animations stay frame-rate independent — a machine running at 30 fps should see the same physics as one at 120 fps.
import { Application, Ticker } from "pixi.js";
const app = new Application();
await app.init({ width: 800, height: 600 });
// deltaTime is a multiplier relative to 60 fps target
// At 60 fps: deltaTime ≈ 1.0
// At 30 fps: deltaTime ≈ 2.0 (physics still correct)
app.ticker.add((ticker) => {
sprite.rotation += 0.05 * ticker.deltaTime;
sprite.x += 2 * ticker.deltaTime;
});Memory Management and Cleanup
PixiJS holds GPU-side textures and event listeners that the JavaScript GC cannot reclaim on its own. Failing to call destroy() leaks VRAM and CPU memory — a common cause of steadily climbing frame times in long-running games.
// Destroying a container and its children (keep shared textures alive)
container.destroy({ children: true, texture: false, baseTexture: false });
// Full unload when a level is complete
await Assets.unload("/levels/level1-atlas.json");
// Remove ticker listener to prevent ghost updates after scene teardown
app.ticker.remove(updateFn);
// Destroy a RenderTexture created at runtime
renderTexture.destroy(true); // true = also destroy base texture
// Avoid: holding a reference to a destroyed sprite
let hero: Sprite | null = new Sprite(texture);
hero.destroy();
hero = null; // release JS reference tooroundPixels, Resolution, and Antialias Tradeoffs
Three Application init flags interact with visual quality and GPU fill rate. Pick the right combination for your target device class — desktop games can afford more than mobile browsers.