Guide

Guide Debugging & Performance Performance

Performance

Measure scene limits with focused stress examples.

Intermediate ~5 min read

What you'll learn

  • measure scene limits with stress examples
  • read the performance overlay to find bottlenecks

Before you start

Performance

Performance in ExoJS is about three things: how many drawables you push per frame, how many render passes they cost, and how much state change happens between draws. The engine does a lot automatically — batching sprites that share a texture, reusing render-target memory across filter passes, culling nodes outside the view — but the decisions that matter most are yours: how many sprites, how many textures, how many filters, how many particle systems.

Measuring with the performance layer

Before making changes, measure. The PerformanceLayer from @codexo/exojs/debug gives you FPS, frame time, draw-call count, and node count in real time:

import { DebugOverlay } from '@codexo/exojs/debug';

const debug = new DebugOverlay(app);
debug.layers.performance.visible = true;

Watch the FPS number while you add sprites, enable filters, or spawn particles. The sparkline shows frame-time stability — a flat line is good, spikes indicate intermittent work. The draw-call count tells you how many GPU-level draw calls the renderer issued this frame.

Sprite batching

Sprites that share the same texture are batched into a single draw call on WebGPU backends. On WebGL2, single-texture batching also applies. The practical consequence: 600 sprites all using one texture atlas cost far less than 600 sprites each using a different texture.

The multi-texture-stress example demonstrates this — a grid of sprites spread across 4 textures vs. a grid using 1 texture. The draw-call count difference is visible in the performance layer.

For texture-atlas workflows, use a single Spritesheet-sliced Texture and select frames via sprite.setTextureFrame() rather than loading separate images per sprite.

Render passes

Every filter on a drawable adds one render pass for that drawable. A container with three filters costs three passes beyond the main batch draw. The cost scales with the drawable’s bounding-box area (in pixels), not the canvas size, so occluding a filtered sprite behind a solid overlay doesn’t prevent the filter passes from running.

The RenderPassInspectorLayer shows exactly who is adding passes. Enable it during development when frame time rises and you suspect filter overhead:

import { RenderPassInspectorLayer } from '@codexo/exojs/debug';
// See 7.6 Render pipeline debugging for setup

The two highest-impact reductions: remove filters you don’t need, and set cacheAsBitmap = true on filtered containers that don’t change every frame. For the full set of strategies with examples, see Render pipeline debugging.

Particle system throughput

The ParticleSystem (from @codexo/exojs-particles) is designed for throughput — SoA storage, no per-particle allocations, instanced rendering. The practical limits depend on whether you’re on CPU or GPU path:

  • CPU path (WebGL2, or any non-GPU-eligible update module): Suitable for moderate particle counts — performance drops roughly linearly with particle count. The per-frame update cost is proportional to aliveCount × number of update modules.
  • GPU path (WebGPU, all modules GPU-eligible): The composite WGSL compute shader runs update logic on the GPU in one dispatch, writing directly into the renderer’s instance buffer — no CPU readback. This path becomes attractive for large systems with many particles and GPU-eligible modules.

Check system.gpuMode at runtime to know which path is active. Measure with the PerformanceLayer to find the right capacity target for your scene — the optimal number depends on your module count, particle lifetime, and target frame rate.

Scene-graph churn

Adding and removing many children from a container each frame triggers transform-dirty propagation and bounds recomputation. For dynamic scenes where objects appear and disappear frequently, prefer:

  • sprite.visible = false over container.removeChild(sprite) — keeps the node in the tree, skips rendering.
  • Pre-allocate a pool of sprites and recycle them by toggling visibility rather than constructing/destroying.
  • Use zIndex only where layering is required. Mixed zIndex values add per-group sorting work during render-plan optimization.

Texture updates

Updating a texture source (e.g. a canvas, video frame, or DataTexture buffer) incurs GPU upload work each frame, and may also trigger GPU re-allocation when dimensions/format change. For per-frame updates:

  • Use DataTexture.commitRect() for partial uploads (cheaper than full commit()).
  • Avoid creating new Texture instances per frame — reuse one texture and update its source.
  • Keep RenderTexture dimensions stable — resize() triggers framebuffer recreation.

View culling

Nodes that fall entirely outside the current View’s viewport are skipped by the renderer. This is automatic and per-node. The culling check is AABB-based rather than exact shape, so nodes near the viewport edge may still be rendered even if partially outside. For large scrolling worlds, this is a significant performance multiplier — only the visible subset of your scene graph incurs draw cost.

Overriding the cull check with cullArea

The automatic check calls getBounds(), which for a Container walks every visible child and unions their bounds — for a complex Graphics node or a container with hundreds of children, that walk runs every frame even when the node’s on-screen footprint is trivial to reason about. Set cullArea to a Rectangle and the cull check uses it directly instead of computing bounds:

import { Color, Container, Graphics, Rectangle } from '@codexo/exojs';

// A debris field built from hundreds of small Graphics children — walking
// all of them for getBounds() every frame is wasted work once we already
// know the field never exceeds a 200x200 footprint at its spawn position.
const debris = new Container();

for (let i = 0; i < 300; i++) {
  const shard = new Graphics();
  shard.fillColor = Color.slateGray;
  shard.drawCircle(0, 0, 2);
  shard.x = Math.random() * 200;
  shard.y = Math.random() * 200;
  debris.addChild(shard);
}

debris.x = 400;
debris.y = 300;

// cullArea is compared directly against the view's bounds, in the same
// world-space coordinates getBounds() would otherwise produce — it is not
// re-transformed by the node's own position/scale/rotation. Since `debris`
// is positioned once and never moves, a static rectangle here stays correct.
debris.cullArea = new Rectangle(debris.x, debris.y, 200, 200);

Two things to keep in mind:

  • cullArea replaces getBounds() only in the inView() check — it has no effect on hit-testing, collision, or rendering, and it’s ignored entirely when cullable = false.
  • Because the rectangle is used as-is (not transformed), a cullArea on a node that moves, rotates, or scales after it’s set will go stale. Either recompute it when the node’s position changes, or reserve it for nodes whose world placement is fixed once configured — e.g. static level decoration, or particle-heavy effects anchored to a known spawn point.

Not a checklist

The most useful performance practice is measurement. Turn on the performance layer, watch the numbers, change one thing, measure again. The engine’s behavior under your specific scene — your sprite counts, your texture layout, your filter stacks — matters more than any general guideline.

Examples

Sprite Stress WebGPU Open in Playground View source

Preview is paused until you click Play.

import { Application, Color, Container, Rectangle, Scene, Sprite, Texture } from '@codexo/exojs';

const GRID_COLUMNS = 56;
const GRID_ROWS = 30;
const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(0.02, 0.03, 0.06, 1),
    backend: { type: 'webgpu' },
});

class SpriteStressScene extends Scene {
    private sprites!: { sprite: Sprite; offsetX: number; offsetY: number; phase: number; baseScale: number; driftX: number; driftY: number; rotationSpeed: number }[];
    private spriteLayer!: Container;

    override init(): void {
        const { width, height } = this.app.canvas;
        const atlasTexture = createAtlasTexture();

        this.sprites = [];
        this.spriteLayer = new Container();
        this.spriteLayer.setPosition(width / 2, height / 2);

        const frameChoices = [new Rectangle(0, 0, 64, 64), new Rectangle(64, 0, 64, 64), new Rectangle(0, 64, 64, 64), new Rectangle(64, 64, 64, 64)];
        const tintPalette = [Color.white, Color.skyBlue, Color.gold, Color.hotPink, Color.mediumSpringGreen, Color.orange];

        let index = 0;

        for (let row = 0; row < GRID_ROWS; row++) {
            for (let column = 0; column < GRID_COLUMNS; column++) {
                const sprite = new Sprite(atlasTexture);
                const frameIndex = index % frameChoices.length;
                const offsetX = (column - (GRID_COLUMNS - 1) / 2) * 22;
                const offsetY = (row - (GRID_ROWS - 1) / 2) * 22;
                const phase = (row * GRID_COLUMNS + column) * 0.13;
                const baseScale = 0.58 + (index % 5) * 0.08;

                sprite.setTextureFrame(frameChoices[frameIndex]);
                sprite.setAnchor(0.5);
                sprite.setPosition(offsetX, offsetY);
                sprite.setScale(baseScale);
                sprite.setTint(tintPalette[index % tintPalette.length]);

                this.sprites.push({
                    sprite,
                    offsetX,
                    offsetY,
                    phase,
                    baseScale,
                    driftX: 6 + (index % 7) * 2,
                    driftY: 4 + (index % 5) * 2,
                    rotationSpeed: (index % 2 === 0 ? 1 : -1) * (14 + (index % 6) * 7),
                });

                this.spriteLayer.addChild(sprite);
                index++;
            }
        }
    }

    override update(delta): void {
        const time = this.app.activeTime.seconds;

        this.spriteLayer.rotate(delta.seconds * 2.5);

        for (const entry of this.sprites) {
            const localPhase = time + entry.phase;
            const scale = entry.baseScale + Math.sin(localPhase * 1.8) * 0.08;

            entry.sprite.x = entry.offsetX + Math.sin(localPhase * 1.4) * entry.driftX;
            entry.sprite.y = entry.offsetY + Math.cos(localPhase * 1.7) * entry.driftY;
            entry.sprite.rotation += delta.seconds * entry.rotationSpeed;
            entry.sprite.setScale(scale);
        }
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.spriteLayer);
    }

    override unload(): void {
        this.spriteLayer?.destroy();
    }

    override destroy(): void {
        this.spriteLayer?.destroy();
    }
}

app.start(new SpriteStressScene()).catch(() => {
    app.canvas.remove();
    app.destroy();
});

function createAtlasTexture(): Texture {
    const atlasCanvas = document.createElement('canvas');
    const context = atlasCanvas.getContext('2d')!;

    atlasCanvas.width = 128;
    atlasCanvas.height = 128;

    drawAtlasCell(context, 0, 0, '#0f172a', '#ffd166', 'circle');
    drawAtlasCell(context, 64, 0, '#10243d', '#ff6b6b', 'diamond');
    drawAtlasCell(context, 0, 64, '#112b21', '#4ade80', 'star');
    drawAtlasCell(context, 64, 64, '#23163c', '#7dd3fc', 'triangle');

    return new Texture(atlasCanvas);
}

function drawAtlasCell(context: CanvasRenderingContext2D, x: number, y: number, background: string, accent: string, shape: string): void {
    context.fillStyle = background;
    context.fillRect(x, y, 64, 64);

    context.fillStyle = 'rgba(255, 255, 255, 0.08)';
    context.fillRect(x + 4, y + 4, 56, 56);

    context.fillStyle = accent;
    context.beginPath();

    if (shape === 'circle') {
        context.arc(x + 32, y + 32, 18, 0, Math.PI * 2);
    } else if (shape === 'diamond') {
        context.moveTo(x + 32, y + 10);
        context.lineTo(x + 52, y + 32);
        context.lineTo(x + 32, y + 54);
        context.lineTo(x + 12, y + 32);
        context.closePath();
    } else if (shape === 'star') {
        for (let i = 0; i < 5; i++) {
            const outerAngle = -Math.PI / 2 + (i * Math.PI * 2) / 5;
            const innerAngle = outerAngle + Math.PI / 5;
            const outerX = x + 32 + Math.cos(outerAngle) * 20;
            const outerY = y + 32 + Math.sin(outerAngle) * 20;
            const innerX = x + 32 + Math.cos(innerAngle) * 9;
            const innerY = y + 32 + Math.sin(innerAngle) * 9;

            if (i === 0) {
                context.moveTo(outerX, outerY);
            } else {
                context.lineTo(outerX, outerY);
            }

            context.lineTo(innerX, innerY);
        }
        context.closePath();
    } else {
        context.moveTo(x + 32, y + 9);
        context.lineTo(x + 54, y + 52);
        context.lineTo(x + 10, y + 52);
        context.closePath();
    }

    context.fill();
}

A 34×20 grid of sprites (680 total) from a single texture atlas, each with animated position, scale, and rotation. Demonstrates sprite batching throughput.

Particle Stress WebGPU Open in Playground View source

Preview is paused until you click Play.

import {
    Application,
    Color,
    Scene,
    Texture,
} from '@codexo/exojs';
import {
    AlphaFadeOverLifetime,
    ApplyForce,
    Constant,
    Curve,
    particlesExtension,
    ParticleSystem,
    Range,
    RateSpawn,
    ScaleOverLifetime,
    UpdateModule,
    VectorRange,
} from '@codexo/exojs-particles';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(0.02 * 255, 0.02 * 255, 0.045 * 255, 1),
    backend: { type: 'webgpu' },
    extensions: [particlesExtension],
});

class TintCycle extends UpdateModule {
    private palette: Color[];
    private next = 0;

    constructor(palette: Color[]) {
        super();
        this.palette = palette;
    }

    apply(system): void {
        const { color, liveCount, elapsed } = system;
        for (let i = 0; i < liveCount; i++) {
            if (elapsed[i] === 0) {
                color[i] = this.palette[this.next++ % this.palette.length].toRgba();
            }
        }
    }
}

class ParticleStressScene extends Scene {
    private sharedTexture!: Texture;
    private particleSystems!: { instance: ParticleSystem; baseX: number; baseY: number }[];

    override init(): void {
        const { width, height } = this.app.canvas;

        this.sharedTexture = createParticleTexture();
        this.particleSystems = [];

        this.particleSystems.push(
            this.buildSystem({
                x: width * 0.24,
                y: height * 0.78,
                rate: 240,
                force: { x: 10, y: 120 },
                scaleStart: 0.94,
                palette: [Color.orange, Color.tomato, Color.gold, Color.mistyRose],
                positionRangeX: 28,
                positionRangeY: 12,
                velocityRangeX: 90,
                velocityMinY: -330,
                velocityMaxY: -170,
                scaleMin: 0.42,
                rotationMax: 260,
                lifetimeMin: 0.95,
                lifetimeMax: 1.6,
            }),
        );

        this.particleSystems.push(
            this.buildSystem({
                x: width * 0.5,
                y: height * 0.82,
                rate: 320,
                force: { x: 0, y: 150 },
                scaleStart: 0.88,
                palette: [Color.skyBlue, Color.deepSkyBlue, Color.mediumTurquoise, Color.white],
                positionRangeX: 38,
                positionRangeY: 16,
                velocityRangeX: 130,
                velocityMinY: -305,
                velocityMaxY: -155,
                scaleMin: 0.36,
                rotationMax: 320,
                lifetimeMin: 0.8,
                lifetimeMax: 1.45,
            }),
        );

        this.particleSystems.push(
            this.buildSystem({
                x: width * 0.76,
                y: height * 0.76,
                rate: 240,
                force: { x: -12, y: 118 },
                scaleStart: 0.92,
                palette: [Color.violet, Color.hotPink, Color.deepPink, Color.plum],
                positionRangeX: 26,
                positionRangeY: 10,
                velocityRangeX: 95,
                velocityMinY: -320,
                velocityMaxY: -165,
                scaleMin: 0.4,
                rotationMax: 280,
                lifetimeMin: 0.9,
                lifetimeMax: 1.55,
            }),
        );
    }

    private buildSystem(config: any): { instance: ParticleSystem; baseX: number; baseY: number } {
        const system = new ParticleSystem(this.sharedTexture, { capacity: 4096 });

        system.setPosition(config.x, config.y);

        system.addSpawnModule(
            new RateSpawn({
                rate: new Constant(config.rate),
                lifetime: new Range(config.lifetimeMin, config.lifetimeMax),
                position: new VectorRange(-config.positionRangeX, config.positionRangeX, -config.positionRangeY, config.positionRangeY),
                velocity: new VectorRange(-config.velocityRangeX, config.velocityRangeX, config.velocityMinY, config.velocityMaxY),
                scale: new VectorRange(config.scaleMin, config.scaleStart, config.scaleMin, config.scaleStart),
                rotationSpeed: new Range(-config.rotationMax, config.rotationMax),
            }),
        );
        system.addUpdateModule(new ApplyForce(config.force.x, config.force.y));
        system.addUpdateModule(
            new ScaleOverLifetime(
                new Curve([
                    { t: 0, v: config.scaleStart },
                    { t: 1, v: 0.05 },
                ]),
            ),
        );
        system.addUpdateModule(new TintCycle(config.palette));
        system.addUpdateModule(
            new AlphaFadeOverLifetime(
                new Curve([
                    { t: 0, v: 1 },
                    { t: 1, v: 0 },
                ]),
            ),
        );

        return { instance: system, baseX: config.x, baseY: config.y };
    }

    override update(delta): void {
        const time = this.app.activeTime.seconds;

        for (let i = 0; i < this.particleSystems.length; i++) {
            const entry = this.particleSystems[i];
            const wave = time + i * 1.2;

            entry.instance.setPosition(entry.baseX + Math.sin(wave * 1.4) * 18, entry.baseY + Math.cos(wave * 1.7) * 10);
            entry.instance.rotation = Math.sin(wave * 0.9) * 5;
            entry.instance.update(delta);
        }
    }

    override draw(context): void {
        context.backend.clear();

        for (const entry of this.particleSystems) {
            context.render(entry.instance);
        }
    }

    override unload(): void {
        this.destroySystems();
    }

    override destroy(): void {
        this.destroySystems();
    }

    private destroySystems(): void {
        for (const entry of this.particleSystems) entry.instance?.destroy();
        this.particleSystems = null!;
        this.sharedTexture = null!;
    }
}

app.start(new ParticleStressScene()).catch(() => {
    app.canvas.remove();
    app.destroy();
});

function createParticleTexture(): Texture {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d')!;

    canvas.width = 56;
    canvas.height = 56;

    const gradient = context.createRadialGradient(28, 28, 2, 28, 28, 28);

    gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
    gradient.addColorStop(0.18, 'rgba(255, 255, 255, 0.98)');
    gradient.addColorStop(0.46, 'rgba(255, 255, 255, 0.72)');
    gradient.addColorStop(0.78, 'rgba(255, 255, 255, 0.16)');
    gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');

    context.fillStyle = gradient;
    context.fillRect(0, 0, canvas.width, canvas.height);

    return new Texture(canvas);
}

Three particle systems emitting 240–320 particles per second each, with custom tint-cycling, scale-over-lifetime, alpha-fade, and force-based velocity. Demonstrates particle system throughput with a non-GPU-eligible custom update module.

Where to go next

The next chapter, Backend comparison, covers the WebGL2 vs. WebGPU decision — how backends are selected, where feature parity exists, and where backend-specific differences remain.