PixiJS Performance Triage: Batching, Culling, and Delta-Time for Large Sprite Scenes
PixiJS scenes with hundreds of sprites usually stall on draw calls and off-screen work. Group textures, cull with cullArea, and animate with deltaTime.
12 Sept 2026, 02:48 UTC

The problem usually shows up as a frame-time graph that gets worse as the scene grows: a few hundred sprites are individually cheap, but the renderer is doing much more work than the visible image requires. The useful takeaway is that PixiJS scenes usually improve fastest when you reduce draw calls by controlling sibling order, skip off-screen objects with culling, and make movement frame-rate independent with deltaTime.
Batching depends on sibling order, not just object count
PixiJS can batch sprites that share a base texture and blend mode, but only when they are adjacent siblings. If children alternate textureA, textureB, textureA, the renderer has to break the batch and issue more draw calls. A filtered container placed between otherwise compatible sprites can also interrupt batching.
Treat child order as part of your rendering budget. Group sprites by texture instead of by gameplay role when possible. If gameplay requires interleaving, you may need to sort children by texture periodically or split the scene into layers, but sorting every frame has its own CPU cost.
| Child arrangement | Likely batch behavior |
|---|---|
A, A, A, B, B, B | Compatible siblings can stay in the same batch group |
A, B, A, B, A, B | Texture changes force frequent batch breaks |
A, filteredContainer, A | The filtered container forces a separate render pass |
Cull off-screen work before it reaches the GPU
Enabling cullable on containers and defining a cullArea lets PixiJS skip rendering objects that are outside the visible region. This is one of the cheapest ways to reduce GPU load in large scenes, but it is not free.
Automatic culling relies on up-to-date world transforms. Rapidly moving objects can flicker if the cull area is exactly the same size as the visible bounds, so make the cull area slightly larger than the screen or chunk bounds. Also be careful with deeply nested containers that change transforms every frame: the CPU cost of maintaining culling can outweigh the GPU savings in small scenes.
Animate with delta time, not assumed frames
The PixiJS Ticker exposes deltaTime, normalized to a 60 FPS reference, and elapsedMS for real elapsed milliseconds. Movement code should multiply velocity by deltaTime rather than assuming a fixed frame step. On a 120 Hz display, deltaTime is smaller; on a stalled frame, it is larger.
app.ticker.add((ticker) => {
sprite.x += velocityX * ticker.deltaTime;
sprite.y += velocityY * ticker.deltaTime;
});
Use elapsedMS when you need real milliseconds, such as timers or cooldowns, and keep it separate from normalized movement.
Worked example: hundreds of interactive sprites
The snippets below assume PixiJS v8-style APIs. If you are on an older major version, verify property names such as cullable, cullArea, and eventMode against the migration guide and API docs for your installed release.
import { Application, Container, Sprite, Rectangle } from 'pixi.js';
const app = new Application();
await app.init({ width: 960, height: 540, antialias: true });
document.body.appendChild(app.canvas);
const layerByTexture = new Map();
function layerFor(texture) {
let layer = layerByTexture.get(texture);
if (!layer) {
layer = new Container();
layer.label = texture.label;
app.stage.addChild(layer);
layerByTexture.set(texture, layer);
}
return layer;
}
function addSprite(texture, x, y) {
const sprite = new Sprite(texture);
sprite.x = x;
sprite.y = y;
sprite.eventMode = 'static';
sprite.on('pointertap', () => {
sprite.tint = 0xffcc00;
});
layerFor(texture).addChild(sprite);
return sprite;
}
const moving = [];
for (let i = 0; i < 300; i++) {
const sprite = addSprite(i % 2 === 0 ? textureA : textureB, Math.random() * 960, Math.random() * 540);
moving.push({ sprite, vx: Math.random() * 2 - 1, vy: Math.random() * 2 - 1 });
}
app.ticker.add((ticker) => {
for (const item of moving) {
item.sprite.x += item.vx * ticker.deltaTime;
item.sprite.y += item.vy * ticker.deltaTime;
}
});
The key decision here is grouping by texture. Sprites for the same texture become adjacent siblings inside their own container, which gives the renderer a better chance to batch them. If your game logic needs a specific z-order across textures, that requirement may conflict with texture grouping; profile both arrangements before choosing.
When sprites are expensive to redraw
Dynamically redrawing complex Graphics every frame is expensive because the geometry is re-tessellated. For static complex art, pre-render it once with generateTexture and use the resulting texture in a sprite. In v8-style code, that can look like app.renderer.generateTexture({ target: complexGraphics, resolution: 2 }), but confirm the exact signature for your version.
generateTexture captures a snapshot at call time. Animated content will not update unless you re-render or manually refresh it. RenderTexture is useful for off-screen targets such as minimaps or cached UI panels, but each active render texture adds a separate render pass; too many can become the new bottleneck.
How to verify the change without guessing
- Build a stress test with hundreds of sprites. Toggle culling on and off, then compare frame times in your browser’s performance profiler. Look at frame duration and long tasks, not only average FPS.
- Toggle sprite order and texture grouping in a sample scene. If grouping by texture reduces frame-time spikes, batch breaks were likely part of the problem.
- Profile on your actual target platforms. Batching behavior can differ between WebGL and WebGPU renderers, so do not assume identical draw-call counts.
Run dependency changes from your project root; elevated permissions are not required.
npm install pixi.js@^8.0.0
npm run dev
Expected check: package.json lists the PixiJS version you intended and the dev server starts. Risk: older projects may need migration work before v8-style property names are valid.
Practical limits
Culling is not automatically a win. It helps most when large containers or chunks are frequently off-screen. It can hurt when you enable it on many tiny, deeply nested objects with constant transform updates. Render textures trade memory and extra passes for CPU savings, so use them deliberately rather than as a default cache.
Because these are code changes, keep them in version control. If a change makes performance worse, remove the cullable or cullArea settings or restore the previous child order; no special runtime cleanup is required.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.