Guide

Guide Debugging & Performance Debugging & inspection

Debugging & inspection

Inspect scene state and runtime behavior with overlay layers, and trace filter chains and render passes with the in-engine inspector.

Intermediate ~8 min read

What you'll learn

  • overlay performance, bounds, and hit-test layers
  • toggle debug layers without changing scene code
  • inspect filter chains and render-pass counts

Before you start

Debugging & inspection

ExoJS ships five diagnostic layers in the @codexo/exojs/debug optional entrypoint. Four are managed by DebugOverlay (performance, bounding boxes, hit-test, pointer stack), and RenderPassInspectorLayer is a standalone layer covered below. These tools render as overlays on top of your running scene — no scene-logic changes, no special draw-call instrumentation, no build flags.

DebugOverlay

The DebugOverlay is the entry point. Create one against your application, toggle individual layers by setting their visible flag:

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

const debug = new DebugOverlay(app);

// During development — just flip a flag
debug.layers.performance.visible = true;
debug.layers.boundingBoxes.visible = true;

// Toggle with keyboard shortcuts (F1–F4 while canvas has focus)

The overlay subscribes to app.onFrame and renders each visible layer. World-space layers (boundingBoxes, hitTest) render first under screen-space panels (performance, pointerStack). The overlay’s visible flag suppresses all layers without changing individual visibility.

Performance layer

The PerformanceLayer shows four real-time metrics in a compact panel (top-left):

MetricSource
FPSRolling 60-sample average of frame times
FrameCurrent frame duration in milliseconds
DrawsGPU draw calls issued this frame (backend.stats.drawCalls)
NodesTotal RenderNode count in the scene

A sparkline below the text shows the last 120 frames of frame-time history — a quick visual of frame-rate stability. The sparkline maxes out at 33ms (~30 FPS), so any frame that hits the top boundary is below 30 FPS.

Press F1 or set debug.layers.performance.visible = true to enable it.

Bounding-boxes layer

The BoundingBoxesLayer draws colored rectangle outlines around every visible RenderNode with non-zero bounds. Each node’s outline hue cycles by its zIndex — adjacent z-indices get visually distinct colors. This layer renders in world space, so boxes move and rotate with the scene:

debug.layers.boundingBoxes.visible = true;  // or F2

Use this to debug layout issues, verify sprite bounds match expectations, or spot invisible nodes taking up space. It is the fastest way to answer “where does the engine think this node is?”

Hit-test layer

The HitTestLayer color-codes interactive nodes based on their pointer state:

  • Magenta: interactive but idle (not hovered)
  • Yellow: currently hovered
  • Cyan: pointer-captured (being dragged or pressed)

This layer renders in world space. Combined with debug.layers.pointerStack.visible (F4), which lists which nodes are under the cursor in a screen-space panel, you can trace the full hit-test path from pointer position to interactive node:

debug.layers.hitTest.visible = true;       // or F3
debug.layers.pointerStack.visible = true;  // or F4

The pointer-stack panel shows up to 10 nodes under the cursor sorted by zIndex (topmost first), with canvas coordinates, constructor names, and an — interactive flag for nodes that participate in hit testing.

Overlay lifecycle

All debug layers live on DebugOverlay.layers. The overlay subscribes to application events (onFrame, onKeyDown, onResize) and drives layer updates and rendering. Call debug.destroy() when you no longer need the overlay — it unsubscribes from all events and destroys every layer.

Debug layers have zero overhead when their visible flag is falseDebugOverlay._onFrame skips invisible layers entirely. You can leave the overlay constructed for a full development session without worrying about perf impact on profiling.

Inspecting the render pipeline

Every filter attached to a drawable costs at least one extra render pass. A stack of three filters on a container means the container renders to an off-screen target, the first filter reads and writes a target, the second does the same, and the third composites the result — four passes for one element. When your frame budget tightens, knowing who is adding passes and why matters.

RenderPassInspectorLayer (added in v0.8.3) shows you that information live, in a compact text panel overlaid on the canvas. It ships in the @codexo/exojs/debug optional entrypoint alongside the other debug layers.

What the layer reveals

Each frame, RenderPassInspectorLayer walks the scene graph and collects an entry for every RenderNode that has at least one filter. The panel displays:

  • Total pass count across all filtered drawables (one pass per filter, plus one per mask).
  • Per-drawable rows showing the constructor name (Sprite, Container, Mesh, Graphics) and the drawable’s bounding-box dimensions.
  • Filter sequence indented under each drawable, in execution order, by constructor name (BlurFilter, ColorFilter, LutFilter, WebGpuShaderFilter, …).
  • Flags[mask] when the drawable has an active mask (mask passes add to the total), [cached] when cacheAsBitmap is set (cached drawables apply filters once, not per frame).

The panel does not show drawables with zero filters — they are invisible to the render-pipeline inspector because they contribute no extra passes beyond the main batch draw.

Enabling the layer

RenderPassInspectorLayer extends DebugLayer and is designed to be driven via app.onFrame — the same pattern used by DebugOverlay for the other debug layers. It is not pushed onto the scene stack. The inspector walks scene.currentScene?.root each frame, so it sees whichever scene is currently active.

Import from the debug entrypoint, construct against the application, subscribe to app.onFrame, and toggle visibility:

import { Scene, View } from '@codexo/exojs';
import { RenderPassInspectorLayer } from '@codexo/exojs/debug';

class GameScene extends Scene {
    init(loader) {
        // ... normal scene setup with filters, sprites, etc. ...

        this.inspector = new RenderPassInspectorLayer(this.app);
        this.inspector.visible = true;

        this._screenView = new View(
            this.app.canvas.width / 2,
            this.app.canvas.height / 2,
            this.app.canvas.width,
            this.app.canvas.height,
        );

        this.app.onFrame.add(delta => {
            const backend = this.app.backend;
            const sceneView = backend.view;

            this.inspector.update(delta);

            // Screen-space layer: swap to pixel view so the panel renders
            // at absolute canvas positions.
            backend.setView(this._screenView);
            this.inspector.render(backend);
            backend.setView(sceneView);
        });
    }

    destroy() {
        this.inspector.destroy();
    }
}

The screen-space view swap is necessary because RenderPassInspectorLayer returns 'screen' for viewMode and positions its text panel at absolute pixel coordinates. Without the view swap, the panel would render in the scene’s coordinate system.

If you only need the data and not the built-in panel, read inspector.entries and inspector.totalPasses directly — the update call is still required to populate the entry snapshot:

update(delta) {
    // ... game logic ...

    console.log(`Passes this frame: ${this.inspector.totalPasses}`);
    for (const entry of this.inspector.entries) {
        console.log(
            `${entry.drawableLabel} ${entry.width}x${entry.height}` +
            ` filters=[${entry.filters.map(f => f.constructor.name).join(', ')}]` +
            (entry.hasMask ? ' mask' : '') +
            (entry.cachedAsBitmap ? ' cached' : ''),
        );
    }
}

The entries array is replaced each frame during the inspector’s update. Keep a copy if you need to retain frame history.

Reading the panel

The panel renders in the top-left corner of the screen (at x=200 to avoid overlapping the PerformanceLayer panel). The header line shows the total pass count for the current frame. Below it, each filtered drawable gets a row with its dimensions and flags, followed by an indented list of filters:

Render Passes: 7
Sprite 512x256 [cached]
  0. BlurFilter
  1. ColorFilter
Container 800x600
  0. WebGpuShaderFilter
  1. BlurFilter
Graphics 128x64 [mask]
  0. LutFilter

This tells you: the Sprite has two filters and is bitmap-cached (filters are baked once, not re-composited each frame — no ongoing pass cost). The Container has two active filters costing two passes per frame. The Graphics has a mask (adds one pass) plus a LutFilter (adds another). Total: 2 + 1 + 1 + 1 = 5, plus 2 for the Container = 7.

Understanding pass counts

A filter pass means the GPU renders some geometry into a temporary render target, then a second shader reads that target and produces the filtered result. The engine reuses render target memory across filter steps (the pool keeps a steady state of two allocations regardless of filter count), but each pass still consumes GPU time proportional to the drawable’s bounding-box area.

Common pass-reduction strategies, in order of impact:

  1. Remove filters you don’t need. Every filter the panel lists is active. If a BlurFilter on a background element is invisible under a solid overlay, removing it saves a pass.
  2. Enable cacheAsBitmap on filtered drawables that don’t change every frame. The inspector shows [cached] when active. Cached drawables bake their filters once and skip per-frame re-application.
  3. Consolidate filter stacks. Two ColorFilter instances on the same drawable cost two passes. Most color adjustments (brightness, contrast, saturation) can be combined into a single ColorFilter or replaced with a custom shader filter (WebGl2ShaderFilter or WebGpuShaderFilter) that applies both in one pass.
  4. Reduce drawable size. Filter passes render at the drawable’s bounding-box resolution (ceil). A 2048×2048 sprite with a blur costs far more than a 256×256 one with the same blur radius. The panel shows the resolution each drawable renders at.

External GPU capture tools

RenderPassInspectorLayer tells you what is being drawn and how many passes it costs. It does not show intermediate render-target contents, shader source, or exact GPU timings.

For that level of detail, use external capture tools:

  • Spector.js — WebGL2 frame capture. Shows every draw call, shader source, uniform values, and render-target contents for a captured frame.
  • Chrome DevTools WebGPU panel — WebGPU frame capture. Shows compute and render passes, pipeline state, bind groups, and buffer contents.

On WebGPU, ExoJS emits labels on key passes so capture tools display meaningful names rather than generic calls. Look for labels such as:

  • WebGpuShaderFilter pass — a WebGpuShaderFilter executing
  • MeshMaterial (custom) — a Mesh with an attached MeshMaterial drawing inside the main render pass
  • WebGpuMaskCompositor pass — mask composition for a drawable with an active mask

These labels are visible in tools that surface WebGPU pass labels (for example, Chrome’s WebGPU tooling).

When to reach for the inspector

  • During development — keep the inspector on while building filter stacks. You see immediately when adding a filter to a container increases the pass count.
  • During profiling — when a scene’s frame time is higher than expected, enable the inspector to rule out (or confirm) filter pass overhead as the cause.
  • In CI — snapshot inspector.entries and inspector.totalPasses on a known scene to catch regressions. A PR that accidentally enables cacheAsBitmap: false or adds an unintended filter won’t change visual output but will show up as a pass-count increase.

Examples

Bounding Boxes Open in Playground View source
import { Application, Color, Scene, Sprite, Texture } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

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

class BoundingBoxesScene extends Scene {
    private sprites!: { sprite: Sprite; speed: number }[];
    private time = 0;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { bunny: 'image/ship-a.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;
        const count = 7;
        const margin = width * 0.12;
        const step = (width - 2 * margin) / (count - 1);

        this.sprites = Array.from({ length: count }, (_, i) => {
            const sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(0.8);
            sprite.setPosition(margin + i * step, height / 2 + Math.sin(i) * 80);
            return { sprite, speed: 0.8 + i * 0.14 };
        });
    }

    override update(delta): void {
        const { height } = this.app.canvas;

        this.time += delta.seconds;
        for (const { sprite, speed } of this.sprites) {
            sprite.setRotation(this.time * 35 * speed);
            sprite.setPosition(sprite.position.x, height / 2 + Math.sin(this.time * speed) * 100);
        }
    }

    override draw(context): void {
        context.backend.clear();
        for (const { sprite } of this.sprites) context.render(sprite);
    }
}

app.start(new BoundingBoxesScene());

Rotating sprites with bounding-box outlines — the fastest way to check that transforms and bounds match.

Performance Overlay Keyboard Open in Playground View source
import { Application, Color, Keyboard, Scene, Sprite, Texture } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

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

class PerformanceOverlayScene extends Scene {
    private sprites!: { sprite: Sprite; vx: number; vy: number }[];

    override async load(loader): Promise<void> {
        await loader.load(Texture, { bunny: 'image/ship-a.png' });
    }

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

        this.sprites = Array.from({ length: 1600 }, () => {
            const sprite = new Sprite(loader.get(Texture, 'bunny')).setAnchor(0.5).setScale(0.25);
            sprite.setPosition(Math.random() * width, Math.random() * height);
            return {
                sprite,
                vx: (Math.random() - 0.5) * 120,
                vy: (Math.random() - 0.5) * 120,
            };
        });
        this.inputs.onTrigger(Keyboard.P, () => {
            debug.layers.performance.visible = !debug.layers.performance.visible;
        });
    }

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

        for (const item of this.sprites) {
            item.sprite.move(item.vx * delta.seconds, item.vy * delta.seconds);
            if (item.sprite.position.x < 0 || item.sprite.position.x > width) item.vx *= -1;
            if (item.sprite.position.y < 0 || item.sprite.position.y > height) item.vy *= -1;
        }
    }

    override draw(context): void {
        context.backend.clear();
        for (const { sprite } of this.sprites) context.render(sprite);
    }
}

app.start(new PerformanceOverlayScene());

1600 bouncing sprites with live FPS, frame time, draw-call count, and sparkline.

Where to go next

The next chapter, Performance, covers scene measurement — sprite stress tests, particle throughput, and how to use the performance layer to identify bottlenecks.