pixijs-performance

Use this skill when profiling or optimizing a PixiJS v8 app for FPS, draw calls, or GPU memory. Covers destroy patterns (cacheAsTexture(false),…

INSTALLATION
npx skills add https://github.com/pixijs/pixijs-skills --skill pixijs-performance
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

$28

// Tune GC via init options (ms). The textureGC.* properties are

// deprecated since 8.15.0 — use these on the Application init instead.

await app.init({ gcMaxUnusedTime: 60_000, gcFrequency: 30_000 });

**Related skills:** `pixijs-scene-container` (destroy options), `pixijs-scene-core-concepts` (render groups, layers, culling), `pixijs-scene-text` (BitmapText for dynamic content), `pixijs-assets` (atlasing), `pixijs-custom-rendering` (custom batchers).

## Core Patterns

### Proper destroy with cleanup

import { Sprite, Assets } from "pixi.js";

const texture = await Assets.load("character.png");

const sprite = new Sprite(texture);

// Destroy sprite only (preserve texture for reuse)

sprite.destroy();

// Destroy sprite AND its texture

sprite.destroy({ children: true, texture: true, textureSource: true });


When done with a loaded asset entirely:

Assets.unload("character.png");


This removes it from the cache and unloads the GPU resource.

### Application destroy/recreate cycle

import { Application } from "pixi.js";

// Correct destroy that cleans global pools

app.destroy({ releaseGlobalResources: true });

const newApp = new Application();

await newApp.init({ width: 800, height: 600 });


Without `releaseGlobalResources: true`, pooled objects (batches, textures) from the old app leak into the new one, causing flickering and corruption.

### Texture garbage collection

PixiJS auto-collects unused textures and GPU resources via `GCSystem`. Defaults: checks every 30 seconds, removes resources idle for 60 seconds. These are time-based (milliseconds).

import { Application } from "pixi.js";

const app = new Application();

await app.init({

gcActive: true,

gcMaxUnusedTime: 120000, // idle time before cleanup in ms (default: 60000)

gcFrequency: 60000, // check interval in ms (default: 30000)

});


For manual control:

texture.source.unload(); // immediate GPU memory release


### PrepareSystem for GPU upload

Upload textures and graphics to GPU before rendering to avoid first-frame hitches:

import "pixi.js/prepare";

import { Application, Assets } from "pixi.js";

const app = new Application();

await app.init();

// Don't render until assets are uploaded

app.stop();

const texture = await Assets.load("large-scene.png");

// Upload to GPU ahead of time

await app.renderer.prepare.upload(app.stage);

// Now rendering won't hitch on first frame

app.start();


`prepare.upload()` accepts a Container (uploads all textures, text, and graphics in the subtree) or individual resources.

### cacheAsTexture for performance

`cacheAsTexture()` renders a container's subtree to a single texture, reducing draw calls for complex static content. Internally it creates a render group and caches the result.

**When to use:**

- Many static children (UI panels, decorative backgrounds, complex Graphics)

- Containers with expensive filters (cache the filter result)

- Large subtrees that rarely change

**Tradeoffs:**

- Uses GPU memory for the cached texture (larger containers = more memory)

- Max texture size is GPU-dependent (typically 4096x4096; check `renderer.texture.maxTextureSize`)

- Must call `updateCacheTexture()` after modifying children

- Combining with masks is fragile (see the masking skill)

import { Container, Sprite } from "pixi.js";

const panel = new Container();

// ... add many static children ...

panel.cacheAsTexture(true);

// With options

panel.cacheAsTexture({ resolution: 2, antialias: true });

// Refresh after changes

panel.updateCacheTexture();

// MUST disable before destroying (see Common Mistakes below)

panel.cacheAsTexture(false);

panel.destroy();


**Avoid:** toggling on/off repeatedly (constant re-caching negates benefits), caching sparse containers (negligible gain), caching containers larger than 4096x4096.

### Object recycling

Reuse objects by changing their properties instead of destroy/recreate:

import { Sprite, Container, Texture } from "pixi.js";

class BulletPool {

private _pool: Sprite[] = [];

private _container: Container;

constructor(container: Container) {

this._container = container;

}

public get(texture: Texture): Sprite {

let bullet = this._pool.pop();

if (!bullet) {

bullet = new Sprite(texture);

this._container.addChild(bullet);

}

bullet.texture = texture;

bullet.position.set(0, 0);

bullet.rotation = 0;

bullet.scale.set(1);

bullet.alpha = 1;

bullet.tint = 0xffffff;

bullet.blendMode = "normal";

bullet.visible = true;

return bullet;

}

public release(bullet: Sprite): void {

bullet.visible = false;

this._pool.push(bullet);

}

}


Destroying and recreating is significantly more expensive than toggling `visible` and updating properties. GPU resources stay allocated; only scene graph visibility changes.

### Batching optimization

PixiJS batches similar consecutive objects into single draw calls. Batch breaks occur on:

- Object type change (Sprite vs Graphics)

- Texture source change (beyond the per-batch texture limit, typically 16)

- Blend mode change

- Topology change

Optimize draw order:

import { Sprite, Graphics, Container } from "pixi.js";

// 4 draw calls: type alternates

const bad = new Container();

bad.addChild(new Sprite(t1));

bad.addChild(new Graphics().rect(0, 0, 10, 10).fill(0xff0000));

bad.addChild(new Sprite(t2));

bad.addChild(new Graphics().rect(0, 0, 10, 10).fill(0x00ff00));

// 2 draw calls: types grouped

const good = new Container();

good.addChild(new Sprite(t1));

good.addChild(new Sprite(t2));

good.addChild(new Graphics().rect(0, 0, 10, 10).fill(0xff0000));

good.addChild(new Graphics().rect(0, 0, 10, 10).fill(0x00ff00));


Same principle applies to blend modes: `screen/normal/screen/normal` = 4 draws; `screen/screen/normal/normal` = 2 draws.

### Spritesheets over individual textures

import { Assets, Sprite } from "pixi.js";

// Load a spritesheet (single texture atlas)

const sheet = await Assets.load("game-atlas.json");

// All frames share one GPU texture; enables batching

const hero = new Sprite(sheet.textures["hero.png"]);

const enemy = new Sprite(sheet.textures["enemy.png"]);

const coin = new Sprite(sheet.textures["coin.png"]);


Individual textures each require their own GPU upload and break batches when the texture limit is exceeded. Spritesheets consolidate many frames into one atlas texture.

Use `@0.5x` filename suffix on half-resolution sheets so PixiJS auto-scales them.

### Text performance

Text and HTMLText re-render to a canvas and re-upload to the GPU on every change. Never update them per frame unconditionally:

import { BitmapText, Text } from "pixi.js";

// Wrong: re-renders canvas + GPU upload every frame

app.ticker.add(() => {

scoreText.text = Score: ${score};

});

// Correct: use BitmapText for frequently changing content

const scoreText = new BitmapText({

text: "Score: 0",

style: { fontFamily: "Arial", fontSize: 24, fill: 0xffffff },

});

app.ticker.add(() => {

scoreText.text = Score: ${score};

});


BitmapText renders from a pre-generated glyph atlas. Updates only reposition quads; no canvas re-render or GPU upload. Use it for scores, timers, counters, and anything that changes frequently.

If you must use canvas Text, guard updates so they only happen when the value changes:

app.ticker.add(() => {

const next = Score: ${score};

if (scoreText.text !== next) {

scoreText.text = next;

}

});


Text resolution matches the renderer resolution by default. Lower it independently via `text.resolution = 1` to reduce GPU memory on high-DPI displays.

### Graphics performance

Graphics objects are fastest when their shape doesn't change (transforms, alpha, and tint are fine). Small Graphics (under ~100 points) are batched like Sprites. Complex Graphics with hundreds of shapes are slow; convert them to textures instead:

import { Graphics, Sprite } from "pixi.js";

const complex = new Graphics();

// ... draw complex shape ...

// Render once to texture, use as Sprite

const texture = app.renderer.generateTexture(complex);

const sprite = new Sprite(texture);


### Culling

PixiJS skips rendering objects outside the visible area when `cullable` is set. Disabled by default because it trades CPU cost (bounds checking) for GPU savings. Culling only runs when the `CullerPlugin` is registered:

import { extensions, CullerPlugin, Culler, Rectangle } from "pixi.js";

extensions.add(CullerPlugin); // before Application.init

// Enable on objects that may be off-screen

sprite.cullable = true;

// Optional: a pre-computed cull rectangle avoids per-frame bounds calculation.

// Without cullArea, the Culler uses the object's global bounds instead.

sprite.cullArea = new Rectangle(0, 0, 800, 600);

// Skip culling an entire subtree (static UI, always visible)

uiRoot.cullableChildren = false;

// Or cull manually without the plugin:

Culler.shared.cull(app.stage, app.renderer.screen);


`cullableChildren` on a container stops the culler from recursing into its descendants; a large win for static UI panels with many children. `Culler.shared.cull(container, rect)` runs the same logic manually for custom render pipelines. Use culling when you're GPU-bound; avoid it when CPU-bound, since the per-object bounds check adds overhead.

### Resolution and antialias tradeoffs

import { Application } from "pixi.js";

const app = new Application();

// Mobile-friendly: lower resolution, no antialias

await app.init({

resolution: 1,

antialias: false,

backgroundAlpha: 1, // opaque background is faster

});


`resolution: 2` quadruples the pixel count. On mobile, this can halve frame rate. Profile to find the right balance.

### Stagger bulk texture destruction

function staggerDestroy(textures: Texture[], perFrame: number = 5): void {

let index = 0;

const ticker = app.ticker;

const destroy = () => {

const end = Math.min(index + perFrame, textures.length);

for (let i = index; i < end; i++) {

textures[i].destroy(true);

}

index = end;

if (index >= textures.length) {

ticker.remove(destroy);

}

};

ticker.add(destroy);

}


Destroying many textures in one frame causes a freeze. Spread the cost across frames.

### Filters and masks cost

- Set `container.filterArea = new Rectangle(x, y, w, h)` when you know the bounds. Without it, PixiJS measures bounds every frame.

- Release filter memory: `container.filters = null`.

- Mask cost (cheapest to most expensive): axis-aligned Rectangle masks (scissor rect) < Graphics masks (stencil buffer) < Sprite/alpha masks (filter pipeline). Hundreds of masks will slow things down regardless of type; prefer rectangle masks when bounds are axis-aligned.

- Set `interactiveChildren = false` on containers with no interactive children.

- Set `hitArea` on large containers to skip recursive child hit testing.

### Safe destroy order

Remove from scene before destroying:

parent.removeChild(sprite);

sprite.destroy();


Destroying while the render pipeline still holds a reference causes null-pointer crashes. If destruction must happen mid-frame, defer it:

app.ticker.addOnce(() => {

parent.removeChild(sprite);

sprite.destroy();

});


## Common Mistakes

### [CRITICAL] App destroy without releaseGlobalResources

Wrong:

app.destroy();

const newApp = new Application();


Correct:

app.destroy({ releaseGlobalResources: true });

const newApp = new Application();

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card