Skip to content

pixi-performance

Sprite batching, texture atlases, RenderTexture caching, off-screen culling, object pooling, ParticleContainer vs Container, and failure modes that break GPU batching in PixiJS v8+

From plugin
vexjoy-agent
413198 skills198 agents10 commands86 hooks
Install
$ npx -y skills add notque/vexjoy-agent --agent claude-code

How it fires

How this agent gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.

Context preview

The summary Claude sees to decide when to auto-load this agent.

Sprite batching, texture atlases, RenderTexture caching, off-screen culling, object pooling, ParticleContainer vs Container, and failure modes that break GPU batching in PixiJS v8+

Agent definition

pixi-performance.md
description: Sprite batching, texture atlases, RenderTexture caching, off-screen culling, object pooling, ParticleContainer vs Container, and failure modes that break GPU batching in PixiJS v8+
agent: pixijs-combat-renderer
category: performance
version_range: "PixiJS v8+"

PixiJS Performance Reference

> **Scope**: What breaks batching, draw call measurement, ParticleContainer, texture atlases, RenderTexture caching, object pooling.

---

Batching — What Breaks It

PixiJS v8 batches sprites sharing same texture, blend mode, alpha, and no active filters into one draw call.

| Cause | Prevention | |-------|------------| | Different texture | Pack into one atlas | | Filter on ancestor | Filters on container root only | | Different blend mode | Group by blend mode | | Per-sprite tint per frame | One tint per atlas batch | | Alpha on parent Container | Set alpha on sprites, not containers | | Mask on ancestor | Pool masked containers |

---

Texture Atlases

await Assets.load('/atlases/combat.json');
const playerIdleTexture = Texture.from('player_idle_000');

const frames = [];
for (let i = 0; i < 8; i++) {
  frames.push(Texture.from(`player_idle_${String(i).padStart(3, '0')}`));
}
const animSprite = new AnimatedSprite(frames);
animSprite.animationSpeed = 0.15;
animSprite.play();

TexturePacker Settings

Format: Pixi JS (JSON Hash or JSON Array)
Algorithm: MaxRects
Power of Two: YES
Max texture: 2048x2048
Padding: 2
Trim sprites: YES
Extrude: 1

---

RenderTexture for Cached Composites

Bake expensive composites that don't change every frame:

function bakeCharacterToTexture(app: Application, character: Container, width: number, height: number): Sprite {
  const renderTexture = RenderTexture.create({ width, height });
  app.renderer.render({ container: character, target: renderTexture });
  return new Sprite(renderTexture);
}
// Re-bake on state change, not every frame

---

Off-Screen Culling

PixiJS does not cull by default.

const screenBounds = new Rectangle(0, 0, app.screen.width, app.screen.height);

function cullChildren(container: Container): void {
  for (const child of container.children) {
    if (child instanceof Sprite) {
      const bounds = child.getBounds();
      child.visible = screenBounds.intersects(new Rectangle(
        bounds.x - 50, bounds.y - 50, bounds.width + 100, bounds.height + 100,
      ));
    }
  }
}

// Every 3 frames is sufficient
let cullFrame = 0;
app.ticker.add(() => { if (++cullFrame % 3 === 0) cullChildren(entityLayer); });

---

Object Pooling

class SpritePool {
  private pool: Sprite[] = [];
  constructor(texture: Texture, parent: Container, initialSize = 20) {
    this.texture = texture;
    this.parent = parent;
    for (let i = 0; i < initialSize; i++) {
      const sprite = new Sprite(texture);
      sprite.visible = false;
      parent.addChild(sprite);
      this.pool.push(sprite);
    }
  }
  get(): Sprite | null {
    const sprite = this.pool.find(s => !s.visible);
    if (!sprite) return null;
    sprite.visible = true; sprite.alpha = 1; sprite.rotation = 0; sprite.scale.set(1);
    return sprite;
  }
  release(sprite: Sprite): void {
    sprite.visible = false;
    sprite.position.set(-9999, -9999);
  }
}

---

ParticleContainer vs Container

| Feature | ParticleContainer | Container | |---------|------------------|-----------| | Max sprites | 100,000+ | ~5,000 | | Filters | No | Yes | | Children of children | No | Yes | | Blend mode per sprite | No | Yes |

const particleContainer = new ParticleContainer(10000, {
  position: true, rotation: true, scale: true, alpha: true,
  uvs: false,  // disable if single texture
  tint: false, // disable if no tint variation
});

**When NOT to use**: Mixed textures, particles needing filters, particles with children.

---

Pattern Catalog

Group Tint Changes by Value

Per-frame `.tint` flushes batch per sprite. 100 sprites = 100 draw calls. **Fix**: Use custom shader uniform or group sprites by tint value.

Use ParticleContainer for 500+ Uniform Particles

Regular Container: each child inspected in JS every frame (O(n)). Above ~500, JS cost exceeds GPU gain.

Pack Sprites into Texture Atlas

Each unique GPU texture = batch boundary = draw call. One atlas = one draw call for all sprites.

**Detection**:

grep -rn "Texture\.from\|Assets\.load.*\.png" --include="*.ts"

---

Detection Commands

# Per-frame tint mutations
grep -rn "\.tint\s*=" --include="*.ts"

# Non-ParticleContainer particle patterns
grep -rn "addChild.*new Sprite" --include="*.ts" | grep -v "ParticleContainer"

# Individual texture loads (no atlas)
grep -rn "Assets\.load.*\.png\|Texture\.from.*\.png" --include="*.ts"

# Filter application level
grep -rn "\.filters\s*=" --include="*.ts"
Read more
Ships withvexjoy-agent

Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.

Get the whole plugin, auto-invoked