ignifx
GitHubnpm · soon
All examples

Devtools overlay

Platform3D

  • Mouse
  • Keyboard
  • Touch
  • Gamepad

`@ignifx/devtools` is one extension and one key. Register it, press backtick, and nine tabs appear over the canvas: frame numbers, the entity tree, a live component inspector, asset handles, input, audio, physics, the log and a per-phase timing graph. Every example on this site carries it; this is the one that opens it for you, pointed at the `Director` entity, whose serialised fields are rows you can edit. Closed, it costs nothing — no system runs and no DOM exists until `open()` — which is why a game registers it behind a development flag and forgets about it.

A ring of small blue capsules orbiting a lit orange pylon on a grid floor, with the ignifx devtools overlay docked down the left of the frame: a strip of nine tabs above an Inspector listing the Director entity's transform and its wave, spawned, alive and score fields.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Type a new number into the Inspector's `waveSeconds` row and watch the wave clock follow it.
  • Open Stats for draw calls and engine CPU, then Timeline for the same frame, phase by phase.
  • Select a drone in the Scene tab: the Inspector follows, down to its live transform.

Read the guide

Show source

Source

main.ts
import { Camera } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, slider, toggle } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";import { Director } from "./director.ts";import { createRing, Drone, START_SPEED } from "./ring.ts";import type { DevtoolsPanelName } from "ignifx";/** * The devtools overlay, open before you arrive. * * `@ignifx/devtools` is one extension and one key. Register it, press backtick, and nine tabs * appear over the canvas: frame numbers, the entity tree, a live component inspector, asset * handles, input, audio, physics, the log and a per-phase timing graph. It costs nothing while it * is closed — no system runs, no signal is subscribed to and no DOM exists until `open()` — which * is why a game registers it behind a development flag and then forgets about it. Every other * example on this site carries it too; press backtick on any of them. * * This is the one page that opens it for you, through the `devtools` settings section below. * `openOnStart` does it, `position: "left"` keeps it clear of the parameter panel, and `panels` * both re-orders and filters the tab strip — so the overlay opens on the **Inspector**, pointed at * the `Director` entity. * * The Inspector is where a game's own state shows up. Every `Script.define` field is a row it reads * and writes: type a number into `wave` and the next frame's `update` uses it, because the rows * write through to the live component. `director.ts` also publishes the same four numbers into * `app.diagnostics`, which is the API the Stats panel's own rows come from and the one a headless * test reads without opening anything. * * Worth knowing before copying that last part: the Stats panel draws a **fixed** list of engine * counters. A custom group is read back with `app.diagnostics.group("game")` — as the panel on the * right does — and is not a row on the overlay. *//** The nine panels, in the tab order this example wants: the Inspector first, then the rest. */const PANELS: readonly DevtoolsPanelName[] = [  "inspector",  "stats",  "scene",  "timeline",  "assets",  "console",  "input",  "audio",  "physics",];/** How many of {@link PANELS} get a "Show" button in the parameter panel. */const SHORTCUTS = 4;bootExample({  title: "Devtools overlay",  settings: {    rendering: {      clearColor: { r: 0.043, g: 0.059, b: 0.094, a: 1 },      msaaSamples: 4,      features: { shadows: true },    },    devtools: { openOnStart: true, position: "left", panels: [...PANELS], opacity: 0.94 },    time: { fixedDeltaTime: 1 / 60 },  },  async setup({ app, panel }) {    app.registerComponents([Director, Drone]);    const focus = { x: 0, y: 0.5, z: 0 };    const eye = app.world.createEntity("Main Camera");    eye.addComponent(Camera, { near: 0.1, far: 200, fov: 44 });    attachOrbit(app, eye, { yaw: 26, pitch: 27, distance: 4.4, target: focus, minDistance: 2, maxDistance: 14 });    const rig = createLightRig(app, { focus, shadows: true, shadowDarkness: 0.3 });    await createGridGround(app, { size: 24 });    createRing(app);    // The entity the overlay opens pointed at. `select` works before `app.start()` — the service    // exists from `createApp` — so the Inspector has something to show on its first refresh.    const stage = app.world.createEntity("Director");    const director = stage.addComponent(Director);    app.devtools.select(stage);    panel({      title: "Devtools overlay",      groups: [        {          label: "Overlay",          controls: [            toggle("Open", {              value: true,              change: (on: boolean): void => {                // `open()` builds the DOM and registers the sampler; `close()` disposes the DOM.                // The sampler stays registered afterwards and returns on its first line, because                // core's scheduler has `registerSystem` and no `unregisterSystem`.                if (on) {                  app.devtools.open();                } else {                  app.devtools.close();                }              },            }),            ...PANELS.slice(0, SHORTCUTS).map((name: DevtoolsPanelName) =>              button(`Show ${name}`, (): void => {                app.devtools.panel(name).show();              }),            ),          ],        },        {          label: "Gameplay",          controls: [            slider("Wave length", { min: 1, max: 12, step: 0.5 }, bind(director, "waveSeconds")),            slider(              "Drone speed",              { min: 0, max: 120, step: 4 },              {                value: START_SPEED,                change: (speed: number): void => {                  for (const drone of app.world.components(Drone)) {                    drone.speed = speed;                  }                },              },            ),            toggle("Key light casts", bind(rig.key.shadows, "enabled")),            button("Next wave", (): void => {              director.advanceWave();            }),          ],        },        {          label: "app.diagnostics",          collapsed: true,          controls: [            readout("game/wave", (): string => director.read("wave")),            readout("game/spawned", (): string => director.read("spawned")),            readout("game/alive", (): string => director.read("alive")),            readout("game/score", (): string => director.read("score")),            readout("Scripts updated", (): string => String(app.diagnostics.frame.scriptsUpdated)),          ],        },      ],    });  },});
director.ts
/** * The gameplay the overlay watches: a wave counter with four serialized fields, published once a * frame into a counter group on `app.diagnostics`. * * @remarks * It is the shape `skills/ignifx/references/recipes/show-diagnostics-in-devtools.md` recommends, * and the reason it is that shape is the per-frame path. A counter group is a **fixed list of names * over a numeric array**: register it once, resolve each name to an index once in `awake`, then * `set` by index in `update`. No string is hashed and nothing is allocated after the first frame. * * `groupOrRegister` rather than `registerGroup`, because a second registration of the same name is * `IGX-1503` and a scene reload runs `awake` again. * * The fields are declared with `Script.define`, which is what puts them in the devtools Inspector * as editable rows: `wave` typed into the overlay is `wave` in the next frame's `update`. That is * the whole reason this class has fields rather than private state. */import { f32, i32, Script } from "ignifx";import type { DiagnosticsGroup, ScriptCallbacks } from "ignifx";/** The counter group's name, as `app.diagnostics.group("game")` asks for it. */export const COUNTER_GROUP = "game";/** The counters, in the order their indices are resolved. */export const COUNTER_NAMES = ["wave", "spawned", "alive", "score"] as const;/** One of {@link COUNTER_NAMES}. */export type CounterName = (typeof COUNTER_NAMES)[number];/** How many drones a wave is worth, so the numbers on screen mean something. */const SPAWNS_PER_WAVE = 7;/** * A wave director: four numbers a game would have anyway, on the clock and in the diagnostics * table. * * @example * ```ts * const stage = app.world.createEntity("Director"); * const director = stage.addComponent(Director); * app.devtools.select(stage); * ``` */export class Director  extends Script.define({    wave: i32(1, { min: 1, tooltip: "Which wave is running." }),    spawned: i32(0, { min: 0, tooltip: "How many drones have been spawned in total." }),    alive: i32(SPAWNS_PER_WAVE, { min: 0, tooltip: "How many are still flying." }),    score: i32(0, { min: 0, tooltip: "Points banked." }),    waveSeconds: f32(6, { min: 0.5, tooltip: "How long one wave lasts, in seconds." }),  })  implements ScriptCallbacks{  /** The namespaced registration id. */  static typeId = "devtools/Director";  /** The counter group, resolved once. */  #group: DiagnosticsGroup | null = null;  /** The counters' indices, in {@link COUNTER_NAMES} order. */  readonly #indices: number[] = [];  /** Seconds left in the current wave. */  #remaining = 0;  /** Registers the counter group and resolves its indices — once, not per frame. */  awake(): void {    const group = this.app.diagnostics.groupOrRegister(COUNTER_GROUP, [...COUNTER_NAMES]);    this.#group = group;    this.#indices.length = 0;    for (const name of COUNTER_NAMES) {      this.#indices.push(group.index(name));    }    this.#remaining = this.waveSeconds;    this.spawned = SPAWNS_PER_WAVE;  }  /**   * Runs the wave clock and writes the four counters.   *   * @param dt - Seconds since the previous frame, scaled by `time.timeScale`. Under `?static=1`   * the scale is zero, so the wave holds where it was authored and the overlay's own refresh —   * which runs on the unscaled clock — keeps drawing it.   */  update(dt: number): void {    this.#remaining -= dt;    if (this.#remaining <= 0) {      this.advanceWave();    } else if (this.alive > 0 && dt > 0) {      // A wave thins out across its length rather than in one step, so the numbers on the overlay      // move while you watch them.      const share = Math.floor((1 - this.#remaining / Math.max(this.waveSeconds, 0.5)) * SPAWNS_PER_WAVE);      const cleared = Math.max(0, Math.min(SPAWNS_PER_WAVE, share));      const left = SPAWNS_PER_WAVE - cleared;      this.score += Math.max(0, this.alive - left) * 10;      this.alive = left;    }    const group = this.#group;    if (group === null) {      return;    }    group.set(this.#indices[0] ?? 0, this.wave);    group.set(this.#indices[1] ?? 0, this.spawned);    group.set(this.#indices[2] ?? 0, this.alive);    group.set(this.#indices[3] ?? 0, this.score);  }  /** Starts the next wave: what the panel's button and the wave clock both call. */  advanceWave(): void {    this.wave += 1;    this.alive = SPAWNS_PER_WAVE;    this.spawned += SPAWNS_PER_WAVE;    this.score += 100;    this.#remaining = this.waveSeconds;  }  /**   * Reads one counter back out of the diagnostics table.   *   * @remarks   * Through the group rather than off the field, on purpose: it is the same read a HUD, a test or   * the Stats panel would do, and it proves the number reached the table.   *   * @param name - Which counter.   * @returns The value, or `"—"` before `awake` has run.   */  read(name: CounterName): string {    const group = this.#group;    if (group === null) {      return "";    }    return String(group.get(group.index(name)));  }}
ring.ts
/** * The scene the overlay watches: a pylon and a ring of drones that keep moving, so the Stats * panel's numbers change and the Timeline graph has something to draw. * * @remarks * A separate file for the reason `pbr-model/shot.ts` is: none of it is a lesson about devtools. * `main.ts` is then the settings section that opens the overlay, the selection it opens pointed at, * and the panel that drives both. * * The loop at the bottom is exactly what the overlay's **Scene** tab shows: one entity per drone, * named, under the world's root. Selecting a row there points the Inspector at that drone, and the * Inspector's rows are this file's `Script.define` fields. */import { createMaterialAsset, f32, i32, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script } from "ignifx";import type { App, ScriptCallbacks } from "ignifx";/** How many drones orbit the pylon. */export const DRONES = 7;/** The speed a drone starts at, in degrees per second, before its per-index stagger. */export const START_SPEED = 36;/** Carries one drone around the centre. */export class Drone  extends Script.define({    index: i32(0, { min: 0, tooltip: "Which drone this is; sets its phase around the ring." }),    speed: f32(START_SPEED, { min: 0, tooltip: "Degrees around the ring per second." }),    radius: f32(1.35, { min: 0.1, tooltip: "How far out the drone flies, in metres." }),  })  implements ScriptCallbacks{  /** The namespaced registration id. */  static typeId = "devtools/Drone";  /** How far round the circle this drone has travelled, in degrees. */  #angle = 0;  /**   * Advances the orbit.   *   * @param dt - Seconds since the previous frame, scaled — so `?static=1` holds the ring still   * while the overlay keeps refreshing, because the overlay runs on the **unscaled** clock. That   * is what makes this example's capture reproducible with the overlay up.   */  update(dt: number): void {    this.#angle += this.speed * dt;    const phase = ((this.index / DRONES) * 360 + this.#angle) * (Math.PI / 180);    this.transform.localPosition.set(      Math.cos(phase) * this.radius,      0.5 + Math.sin(phase * 2) * 0.22,      Math.sin(phase) * this.radius,    );  }}/** * Builds the pylon and the ring. * * @remarks * `app.registerComponents([Drone])` is the caller's job, because a game registers every component * it uses in one place. * * @param app - The app the entities and the assets belong to. * * @example * ```ts * app.registerComponents([Drone]); * createRing(app); * ``` */export function createRing(app: App): void {  const shell = createMaterialAsset(    app,    pbrMaterialDefinition({      name: "devtools/shell",      baseColor: { r: 0.3, g: 0.42, b: 0.55, a: 1 },      metallic: 0.5,      roughness: 0.32,    }),    [],  );  const core = createMaterialAsset(    app,    pbrMaterialDefinition({      name: "devtools/core",      baseColor: { r: 0.18, g: 0.2, b: 0.24, a: 1 },      metallic: 0.2,      roughness: 0.55,      emissive: { r: 0.55, g: 0.24, b: 0.06, a: 1 },    }),    [],  );  const pylon = app.world.createEntity("Pylon");  pylon.transform.localPosition.set(0, 0.42, 0);  pylon.addComponent(MeshRenderer, {    mesh: MeshAsset.cylinder(app, { height: 0.84, diameterTop: 0.24, diameterBottom: 0.44, tessellation: 24 }),    materials: [core],    castShadows: true,  });  // One mesh shared by every drone, one entity each.  const droneMesh = MeshAsset.capsule(app, { height: 0.34, radius: 0.09, tessellation: 12 });  for (let index = 0; index < DRONES; index += 1) {    const drone = app.world.createEntity(`Drone ${String(index + 1)}`);    drone.addComponent(MeshRenderer, { mesh: droneMesh, materials: [shell], castShadows: true });    drone.addComponent(Drone, { index, speed: START_SPEED + index * 3 });  }}

Uses:devtoolsapp.devtoolsapp.diagnosticsScriptMeshRenderer

Assets:everything in this example is created in code.