Guide

Guide Debugging & Performance Performance

Performance

Measure scene limits with focused stress examples.

Intermediate ~4 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.

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
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
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.