Skip to content

Debugging & profiling

When a frame paints the wrong thing — a stale region, an unexpected full repaint, a layer that should be cached but isn’t — renderer.debug is the single source of truth. It is a core-owned probe that emits a rich per-frame record (which layers drew, why the frame was full, the exact damage rects, the cache state) and keeps a rolling history ring you can copy into a bug report.

Preview of the Frame debug HUD demo
Frame debug HUD Read live per-frame cost from the renderer's onFrameTiming callback.

It is zero-cost when off: constructing the probe does nothing observable, and the renderer assembles no record unless the probe is capturing. Touching renderer.debug on a hot renderer is safe.

const probe = renderer.debug; // lazily constructed; does NOT start capturing
const unsubscribe = probe.onFrame((d) => {
console.log(d.frame, d.kind, d.fullCause?.reason, d.drawCalls);
for (const layer of d.layers) {
console.log(layer.label, layer.cacheState, layer.instances, layer.glyphs);
}
});
// or capture without a listener (e.g. just to fill the history ring):
probe.enable();
// ...later...
probe.disable();
unsubscribe();

renderer.debug is a RendererDebug:

| Member | Description | | -------------------------- | --------------------------------------------------------------------------------------------------------------------- | | onFrame(cb) | Subscribe to the per-frame FrameDebug event. Returns an unsubscribe fn. Attaching a listener turns capture on. | | enable() / disable() | Toggle explicit capture. Capture stays on while any listener is attached. | | isCapturing | Whether the renderer will assemble a record this frame (enabled OR ≥1 listener). | | ring | The FrameRing of frame summaries. | | lastLayers | The exact Layer[] from the most recent captured render(), or null — mutate visibility / cache-hint by identity. | | requestRender() | Re-render the retained last layers as a full frame. | | layerAabb(layer, space?) | Project the union of a layer’s instance AABBs into a DebugRect in space (default "ui"), or null. |

FrameDebugListener is (d: FrameDebug) => void.

The live event carries direct Layer references and a per-frame SpaceMap. Key fields:

| Field | Description | | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | frame / kind | Monotonic frame index; "full" (clear whole canvas) or "partial" (scissored). | | fullCause | { reason, changed? } — why a full frame fired. On a "view-changed" full, changed lists the fingerprint parts that moved. | | regionsCaller / regionsFinal | Damage rects as the caller passed them, and post-pad/clamp (what the GPU scissored). Space-tagged "ui". | | autoDamage | Per-layer provenance for damage: "auto" frames (which layer produced which rects). | | spaces | The frame’s SpaceMap. | | layers | One LayerDebugInfo per Layer, in z-sorted draw order. | | cache | Bake budget / used bytes / per-bake state / evictions / demotions this frame. | | emphasis | Decoded setEmphasis mirror ({ focusedKey, dimAlpha, t }). | | totalMs / uploadMs / encodeMs / drawCalls / damageRects | Frame timing reused from FrameTiming. |

Each LayerDebugInfo carries layer (direct ref), label, zIndex, space, visible, instances, glyphs, cost, cacheState ("live" | "baked-below" | "baked-above" | "bake-demoted" | "hidden"), and the layer’s debugData.

A FingerprintPart (in fullCause.changed and config types) is one of "camera" | "size" | "dpr" | "background" | "view-key" | "z-order" | "visibility" | "buffer-layer" | "emphasis" — so you can see exactly which view input forced a full repaint.

The probe keeps a fixed-capacity ring of lean per-frame summaries (no Layer refs, no matrices) so history survives without retaining a scene graph, and a bug report can paste a timeline.

import { FRAME_RING_CAPACITY } from "insomni/internal"; // 240
const timeline = renderer.debug.ring.toArray(); // oldest → newest
const json = renderer.debug.ring.exportJson(); // paste into a bug report
const { full, partial } = renderer.debug.ring.ratio();

| Export | Description | | --------------------- | ------------------------------------------------------------------------------------------------------- | | FrameRing | push, toArray() (oldest→newest), ratio(), exportJson(), clear(), size, capacity. | | FRAME_RING_CAPACITY | 240 — default ring capacity. | | summarize(d) | Derive a scalar FrameSummary from a rich FrameDebug. | | FrameSummary | Per-frame scalars: frame, kind, reason, changed, rectCount, rectArea, timing, and layers. | | LayerSummary | Per-layer scalar row: label, cacheState, instances, glyphs. |

Debug rects cross between four spaces. SpaceMap is the per-frame snapshot needed to convert between them, and convertRect is the one pure converter — ui is the hub all conversions route through.

| DebugSpace | Meaning | | -------------- | ----------------------------------------------------------- | | "world" | Camera-transformed authoring coordinates. | | "ui" | device ÷ dpr (“CSS px”). Core’s damage regions live here. | | "device" | Raw backing-store px (canvas.width); ui × dpr. | | "css-layout" | The canvas CSS layout box (clientWidth/clientHeight). |

import { captureSpaceMap, convertRect, type RectXYWH } from "insomni/internal";
const map = captureSpaceMap({
camera, // ResolvedCameraState
deviceSize: { w: canvas.width, h: canvas.height },
dpr,
cssBox: { w: canvas.clientWidth, h: canvas.clientHeight }, // optional
});
const uiRect: RectXYWH = { x: 10, y: 10, width: 40, height: 20 };
const worldRect = convertRect(uiRect, "ui", "world", map);

| Export | Description | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | captureSpaceMap({ camera, deviceSize, dpr, cssBox? }) | Build a SpaceMap (pure, no DOM). worldToUi is the exact world→ui map the draw/scissor path uses, including under HiDPI. | | convertRect(rect, from, to, map) | Convert a RectXYWH between spaces via the ui hub. World↔ui transforms all four corners (correct under rotation). | | DebugSpace | The four-space union above. | | SpaceMap | { dpr, deviceSize, uiSize, cssBox, worldToUi }. | | DebugSize | { w, h }. | | RectXYWH | { x, y, width, height } (no space tag — caller passes from/to). |

These config-surface types describe the debug record’s space-tagged rects and fingerprint parts: FrameDebug, LayerDebugInfo, DebugRect ({ space, x, y, width, height }), and FingerprintPart. They are exported from the package barrel for presenters that type the event.

The render-debug probe answers what the renderer did this frame. For CPU/GPU timing of named spans across your own code, use the profiler; for a ready-made UI that presents the probe, use the monitor.

  • insomni-profilerprofile(name, fn) / startSpan(name).end() record named CPU/GPU spans into pluggable sinks (DevTools marks/measures). Disabled by default; enable via setEnabled(true) or the ?profile=1 URL flag. The disabled path is a fast inline branch.
  • insomni-monitor — a lightweight telemetry/control panel that subscribes to renderer.debug and presents frames, regions, layer state, and the cache budget without you wiring a panel by hand.