Guide

GuideDebugging & PerformanceDebugging & 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~10 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, all managed by DebugOverlay: performance, bounding boxes, hit-test, pointer stack, and the render-pass inspector 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 { Application } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

const app = new Application();
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 and F6, 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, renderPassInspector). The overlay’s visible flag suppresses all layers without changing individual visibility.

Layer Property Shortcut
Performance layers.performance F1
Bounding boxes layers.boundingBoxes F2
Hit-test layers.hitTest F3
Pointer stack layers.pointerStack F4
Render-pass inspector layers.renderPassInspector F6

F5 is deliberately unbound — browsers reload the page on it, which would tear down the very session you are inspecting.

While the overlay exists it claims these keys as engine input, so the browser’s own defaults for them (F1’s help window, F3’s find bar) stay suppressed. debug.destroy() releases them again.

Performance layer

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

Metric Source
FPS Rolling 60-sample average of frame times
Frame Current frame duration in milliseconds
Draws GPU draw calls issued this frame (backend.stats.drawCalls)
Nodes Total 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:

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

declare const debug: DebugOverlay;
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:

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

declare const debug: DebugOverlay;
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 false — DebugOverlay._onFrame skips invisible layers entirely. You can leave the overlay constructed for a full development session without worrying about perf impact on profiling.

Logging

Separately from the visual overlays above, ExoJS ships a message-first Logger in the core module — no special entrypoint required. The engine uses it internally (for example, Application logs unexpected failures with source: 'Application'), and it’s available for your own code too. A ready-to-use default instance, logger, is exported alongside the class:

import { logger } from '@codexo/exojs';

logger.debug('Bundle "level-1" queued', { source: 'assets' });
logger.info('Entered gameplay scene', { source: 'scene' });
logger.warn('AudioContext resumed after a user gesture', { source: 'audio' });
logger.error('Simulation step threw', { source: 'physics', error: new Error('physics step failed') });

Each call takes a message plus an optional options bag: source (rendered as [ExoJS][source], or a bare [ExoJS] when omitted), data for structured context, and error for error() calls. Use source to identify the subsystem or class emitting the entry so your own tooling can filter by it.

Severity follows LogSeverity: Debug < Info < Warning < Error. In production builds, Debug/Info/Warning calls never reach a sink — Logger checks severity at runtime and returns early — but only Error calls are unconditionally guaranteed to survive. The build does not compile the lower-severity calls out of the bundle: the logger.debug(...) call itself, its message string, and any data object you pass are still constructed and executed every time, and only discarded afterward. In development builds, a console sink is registered by default and prefixes every line with a styled [ExoJS] (or [ExoJS][source]) badge.

To capture log entries yourself — for an in-game console, telemetry, or a custom debug panel — register a sink with addSink. It returns an unsubscribe function:

import { logger, LogSeverity } from '@codexo/exojs';

const unsubscribe = logger.addSink((entry) => {
    if (entry.severity >= LogSeverity.Warning) {
        console.warn(entry.source, entry.message, entry.data ?? entry.error);
    }
});

// later
unsubscribe();

For warnings that could otherwise repeat every frame (a stale asset reference checked in update, say), pass once — the entry is dropped after the first call for a given key, at any severity:

examples/guides/debugging-and-inspection/logging.ts
override update(delta: Seconds): void {
  if (this.texture.width > 4096) {
    logger.warn('Texture exceeds 4096px - this may hurt performance on mobile GPUs.', { source: 'rendering', once: 'huge-texture' });
  }
}

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, ColorMatrixFilter, LutFilter, ShaderFilter, …).
  • Flags — [mask] when the drawable has an active mask (mask passes add to the total), [cached] when cacheAsTexture 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

DebugOverlay manages the inspector alongside the other layers, so the usual route is a flag or the F6 shortcut:

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

declare const debug: DebugOverlay;
debug.layers.renderPassInspector.visible = true;  // or F6

The inspector walks app.scenes.currentScene?.root each frame, so it sees whichever scene is currently active. It is never added to the scene graph.

Driving the layer without the overlay

RenderPassInspectorLayer extends DebugLayer and can also be driven directly from app.onFrame — useful when you want the inspector without constructing an overlay, or when you need it on a view of your own. Import it from the debug entrypoint, construct it against the application, and render it yourself:

examples/guides/debugging-and-inspection/pass-inspector.ts
class GameScene extends Scene {
  private inspector!: RenderPassInspectorLayer;
  private _screenView!: View;

  init() {
    // ... normal scene setup with filters, sprites, etc. ...

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

    this._screenView = new View(this.app.width / 2, this.app.height / 2, this.app.width, this.app.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:

examples/guides/debugging-and-inspection/logging.ts
// ... game logic ...

console.log(`Passes this frame: ${this.inspector.totalPasses}`);
for (const entry of this.inspector.entries) {
  const filterNames = entry.filters.map((f: Filter) => f.constructor.name).join(', ');
  console.log(
    `${entry.drawableLabel} ${entry.width}x${entry.height}` +
      ` filters=[${filterNames}]${entry.hasMask ? ' mask' : ''}${entry.cachedAsTexture ? ' 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. ColorMatrixFilter
Container 800x600
  0. ShaderFilter
  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 cacheAsTexture 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 ColorMatrixFilter instances on the same drawable cost two passes. Most color adjustments (brightness, contrast, saturation) can be combined into a single ColorMatrixFilter or replaced with a custom ShaderFilter 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 strength. 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:

  • ShaderFilter pass — a ShaderFilter 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 cacheAsTexture: false or adds an unintended filter won’t change visual output but will show up as a pass-count increase.

Examples

Bounding BoxesOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, type Seconds, Sprite } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

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

  override init(): void {
    const app = this.app;
    const { width, height } = app;
    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(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(0.8);
      sprite.setPosition(margin + i * step, height / 2 + Math.sin(i) * 80);
      // The boundingBoxes layer walks the SCENE GRAPH (scene.root), so
      // the sprites must be attached to it - nodes that are only passed
      // to context.render() directly are invisible to the overlay.
      this.root.addChild(sprite);
      return { sprite, speed: 0.8 + i * 0.14 };
    });
  }

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

    this.time += delta;
    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: RenderingContext): void {
    context.render(this.root);
  }
}

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

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

await app.start(BoundingBoxesScene);

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

Performance OverlayKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, Container, FixedResolutionCanvasSizing, Keyboard, type RenderingContext, Scene, type Seconds, Sprite, Time } from '@codexo/exojs';
import { DebugOverlay } from '@codexo/exojs/debug';

// The HUD counts every frame longer than this budget, paints those rows red,
// and plots the budget as the guide line halfway up the sparkline.
const budgets = [Time.seconds(1 / 60), Time.seconds(1 / 30), Time.seconds(1 / 144)];

class PerformanceOverlayScene extends Scene {
  private sprites!: { sprite: Sprite; vx: number; vy: number }[];
  private layer!: Container;
  private budgetIndex = 0;

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

    // All sprites share one texture, so adding them to a single container and
    // rendering it once lets the renderer batch them into a single draw call.
    // Rendering each sprite with its own `context.render(sprite)` call would
    // instead emit one draw call per sprite and tank the frame rate.
    this.layer = new Container();
    this.sprites = Array.from({ length: 1600 }, () => {
      const sprite = new Sprite(this.loader.get('image/ship-a.png')).setAnchor(0.5).setScale(0.25);
      sprite.setPosition(Math.random() * width, Math.random() * height);
      this.layer.addChild(sprite);
      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;
    });
    this.inputs.onTrigger(Keyboard.B, () => {
      this.budgetIndex = (this.budgetIndex + 1) % budgets.length;
      debug.layers.performance.frameBudget = budgets[this.budgetIndex]!;
    });
    this.inputs.onTrigger(Keyboard.A, () => {
      debug.layers.assetCache.visible = !debug.layers.assetCache.visible;
    });
  }

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

    for (const item of this.sprites) {
      item.sprite.move(item.vx * delta, item.vy * delta);
      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: RenderingContext): void {
    context.render(this.layer);
  }
}

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

const debug = new DebugOverlay(app);
debug.layers.performance.visible = true;
// Reports what the loader holds: counts per asset type, estimated resident
// bytes, and the heaviest assets. Snapshots on an interval, not per frame.
debug.layers.assetCache.visible = true;

await app.start(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.