Debugging & inspection
Inspect scene state and runtime behavior with overlay layers, and trace filter chains and render passes with the in-engine inspector.
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?”
Read the boxes as a diagnosis
A node that’s in the tree but invisible tells you which way to look: no box means its bounds are zero (nothing to draw), while a box off in a corner means it’s mis-anchored or off-screen. F2 tells the two apart instantly.
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.
Debug logs are dropped at runtime, not stripped from the bundle
Only Error reaches a sink in a production build — Debug, Info, and Warning calls are discarded by an early return inside Logger, not removed from the shipped code. The call and its arguments still run, so if a message or data object is expensive to build, construct it lazily (e.g. behind a function or your own guard) rather than assuming it gets stripped.
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:
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]whencacheAsTextureis 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:
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:
// ... 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:
- Remove filters you don’t need. Every filter the panel lists is active. If a
BlurFilteron a background element is invisible under a solid overlay, removing it saves a pass. - Enable
cacheAsTextureon 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. - Consolidate filter stacks. Two
ColorMatrixFilterinstances on the same drawable cost two passes. Most color adjustments (brightness, contrast, saturation) can be combined into a singleColorMatrixFilteror replaced with a customShaderFilterthat applies both in one pass. - 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— aShaderFilterexecutingMeshMaterial (custom)— aMeshwith an attachedMeshMaterialdrawing inside the main render passWebGpuMaskCompositor pass— mask composition for a drawable with an activemask
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.entriesandinspector.totalPasseson a known scene to catch regressions. A PR that accidentally enablescacheAsTexture: falseor adds an unintended filter won’t change visual output but will show up as a pass-count increase.
Examples
Rotating sprites with bounding-box outlines — the fastest way to check that transforms and bounds match.
1600 bouncing sprites with live FPS, frame time, draw-call count, and sparkline.
Try it
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.

