Examples

Tools

An inspector, a console and a profiler over a running game, and edits that undo.

Starts examples/tools in this page, on WebGPU where your browser has it.

Open on its own · Source

In-game tools is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /tools/.

Source

main.ts

examples/tools/main.ts
/**
 * Debugging a running game from inside it: six bouncing balls and the engine's tools overlay.
 *
 * Press F3 for the overlay: an inspector on the selected ball, a console the page fills with what
 * the balls do, and a profiler of frame times. Tab selects the next ball; [ and ] give it less or
 * more bounce, as an edit the overlay can undo with Ctrl+Z; R drops every ball again. `balls.drs`
 * holds the rules, and the frame meter in the corner is core's.
 */
import { FpsMeter, MeshBuilder, computeLightMatrix, createEnvironment } from '@driftengine/core';
import type { Vec3 } from '@driftengine/core';
import { World, buildSchedule, runSchedule } from '@driftengine/entities';
import type { ComponentType, Entity, Schedule } from '@driftengine/entities';
import { bindModule, registerEntityModule } from '@driftengine/script';
import type { ComponentRegistry } from '@driftengine/script';
import {
  appendLog,
  bindPanel,
  consolePanel,
  createConsoleView,
  createFrameHistory,
  createGpuPassTimings,
  createInspectorView,
  createLogRing,
  createProfilerView,
  createSelection,
  createToolsOverlay,
  entitiesInspectable,
  inspectorPanel,
  keyEvent,
  paintOverlay,
  pointerEvent,
  primarySelection,
  profilerPanel,
  pushFrame,
  recordGpuSample,
  selectOnly,
  setFieldCommand,
  wheelEvent,
} from '@driftengine/tools';
import type { OverlayPainter, Severity, ToolsOverlay } from '@driftengine/tools';
import { loadModule, patchModule } from 'driftscript';
import { controls, flag, openStage } from '../common/stage';
import * as ballsScript from './balls.drs';

const stage = await openStage({
  directionalShadows: true,
  outputTransform: 'aces',
  sceneSamples: 4,
  /* WebGPU times its passes only when asked, since it costs every pass two queries. This page is
     asking: the profiler shows what they measure. WebGL2 times them either way. */
  gpuTiming: true,
});
const { renderer, camera } = stage;
/* Core's frame meter, which makes its own element in the corner. */
const meter = new FpsMeter();

// #region world
/* The rules registered once; a save rebuilds the schedule and keeps every ball. */
const balls = loadModule(ballsScript as Record<string, unknown>);
const registry: ComponentRegistry = new Map();
let schedule: Schedule = buildSchedule(registerEntityModule(balls, registry).systems);
const bound = bindModule(balls, { entities: { components: registry, prefabs: new Map() } });
if (!bound.bound) throw new Error(bound.reason);
if (import.meta.hot) {
  import.meta.hot.accept('./balls.drs', (next) => {
    if (next === undefined) return;
    patchModule(balls, next as Record<string, unknown>);
    schedule = buildSchedule(registerEntityModule(balls, registry).systems);
  });
}
const Ball = registry.get('Ball') as ComponentType;
const world = new World();
const COUNT = 6;
const entities: Entity[] = [];
function drop(): void {
  for (const ball of entities) world.destroy(ball);
  entities.length = 0;
  for (let i = 0; i < COUNT; i += 1) {
    const ball = world.create();
    world.add(ball, Ball, { y: 2 + i * 0.6, bounce: 0.55 + i * 0.07 });
    entities.push(ball);
  }
}
drop();
// #endregion

// #region panels
/* What each panel reads. The inspector reads the entity world through an adapter; the console a
   ring of entries the page appends to; the profiler the frame's times and the GPU's. */
const selection = createSelection();
selectOnly(selection, entities[0] as number);
const inspectable = entitiesInspectable(world, [Ball]);
/* Five lines, which the console shows whole on a phone held sideways: the overlay does not scroll
   it yet, so a longer ring would show its oldest lines and hide the newest. */
const log = createLogRing(5);
const ROW = 16;
const consoleView = createConsoleView({ rowHeight: ROW });
consoleView.filter.minSeverity = level(flag('level', 'debug'));
const history = createFrameHistory(120);
const timings = createGpuPassTimings();

function panels() {
  return [
    bindPanel(
      inspectorPanel,
      () => ({ world: inspectable }),
      createInspectorView({ selection, rowHeight: ROW }),
    ),
    bindPanel(consolePanel, () => ({ log }), consoleView),
    bindPanel(
      profilerPanel,
      () => ({ timings, history, residency: null }),
      createProfilerView({ rowHeight: ROW }),
    ),
  ];
}
// #endregion

// #region overlay
/* Closed until F3. The overlay lays itself out in a column of its own space and paints through
   four calls, which here go to a 2D canvas over the stage. Where that space sits on the page is the
   page's choice: here, below the hint and above the switches, so the page's own controls stay
   clear. */
let side: 'left' | 'right' = flag('side', 'right') === 'left' ? 'left' : 'right';
let overlay: ToolsOverlay = makeOverlay(flag('open', 'no') === 'yes');
function makeOverlay(open: boolean): ToolsOverlay {
  return createToolsOverlay({ panels: panels(), key: 'F3', side, visible: open });
}

const layer = document.querySelector<HTMLCanvasElement>('#tools');
const ink = layer?.getContext('2d') ?? null;
const painter: OverlayPainter = {
  rect(x, y, w, h, colour) {
    if (ink === null) return;
    ink.fillStyle = colour;
    ink.fillRect(x, y, w, h);
  },
  text(content, x, y, colour) {
    if (ink === null) return;
    ink.fillStyle = colour;
    ink.font = '12px ui-monospace, monospace';
    ink.fillText(content, x + 8, y);
  },
  clip(x, y, w, h) {
    if (ink === null) return;
    ink.save();
    ink.beginPath();
    ink.rect(x, y, w, h);
    ink.clip();
  },
  unclip() {
    ink?.restore();
  },
};

let top = 0;
function fit(): void {
  if (layer === null || ink === null) return;
  const scale = devicePixelRatio;
  const above = document.querySelector('#hint')?.getBoundingClientRect().bottom ?? 0;
  const below = document.querySelector('#controls')?.getBoundingClientRect().top ?? innerHeight;
  top = Math.round(above + 8);
  layer.width = Math.round(innerWidth * scale);
  layer.height = Math.round(innerHeight * scale);
  ink.setTransform(scale, 0, 0, scale, 0, top * scale);
  overlay.resize(innerWidth, Math.max(0, Math.round(below - 8) - top));
}
addEventListener('resize', fit);
// #endregion

// #region input
/* Every event goes to the overlay first; what it takes, the game never sees. */
addEventListener('keydown', (event) => {
  if (overlay.route(keyEvent(event.key, event.shiftKey, event.ctrlKey))) {
    event.preventDefault();
    return;
  }
  if (event.key === 'Tab') {
    event.preventDefault();
    const at = entities.indexOf(primarySelection(selection) ?? -1);
    selectOnly(selection, entities[(at + 1) % entities.length] as number);
    overlay.invalidate();
  } else if (event.key === '[' || event.key === ']') {
    nudgeBounce(event.key === ']' ? 0.05 : -0.05);
  } else if (event.key === 'r' || event.key === 'R') {
    drop();
    selectOnly(selection, entities[0] as number);
    appendLog(log, 'info', 'every ball dropped again');
    overlay.invalidate();
  }
});
/* Pointer events in the overlay's space, which starts `top` pixels down the page. */
addEventListener('pointerdown', (event) => {
  if (overlay.route(pointerEvent('down', event.clientX, event.clientY - top, event.button)))
    event.preventDefault();
});
addEventListener('pointerup', (event) => {
  overlay.route(pointerEvent('up', event.clientX, event.clientY - top, event.button));
});
addEventListener(
  'wheel',
  (event) => {
    if (overlay.route(wheelEvent(event.clientX, event.clientY - top, event.deltaX, event.deltaY)))
      event.preventDefault();
  },
  { passive: false },
);
// #endregion

// #region edit
/* An edit is a command: pushed onto the overlay's stack, it is applied once and can be taken
   back with Ctrl+Z while the overlay is open. */
function nudgeBounce(by: number): void {
  const ball = primarySelection(selection);
  if (ball === null) return;
  const now = world.read(ball, Ball, 'bounce') as number;
  const next = Math.round(Math.min(0.95, Math.max(0, now + by)) * 100) / 100;
  const command = setFieldCommand(inspectable, [ball], 'Ball', ['bounce'], [next]);
  if (command === null) return;
  overlay.undo.push(command);
  appendLog(
    log,
    'debug',
    `ball ${entities.indexOf(ball) + 1} bounce ${now.toFixed(2)} to ${next.toFixed(2)}`,
  );
  overlay.invalidate();
}
// #endregion

// #region log
/* What the balls did this step, said once each. Every entry names where the rule lives, so a click
   on it in the console moves the source cursor there. */
const seen = new Map<Entity, { hits: number; resting: boolean }>();
function report(): void {
  entities.forEach((ball, index) => {
    const hits = world.read(ball, Ball, 'hits') as number;
    const resting = (world.read(ball, Ball, 'vy') as number) === 0;
    const before = seen.get(ball) ?? { hits: 0, resting: false };
    if (hits > before.hits) {
      const impact = world.read(ball, Ball, 'impact') as number;
      const severity: Severity = impact > 6 ? 'warn' : 'info';
      appendLog(
        log,
        severity,
        `ball ${index + 1} hit the floor at ${impact.toFixed(1)} m/s`,
        'examples/tools/balls.drs',
        26,
      );
    }
    if (resting && !before.resting) appendLog(log, 'info', `ball ${index + 1} has come to rest`);
    seen.set(ball, { hits, resting });
  });
}
// #endregion

function level(value: string): Severity {
  return value === 'info' || value === 'warn' ? value : 'debug';
}

controls([
  {
    key: 'side',
    label: 'overlay',
    value: side,
    options: ['right', 'left'].map((s) => ({ text: s, value: s })),
    change: (value) => {
      side = value === 'left' ? 'left' : 'right';
      const open = overlay.visible;
      overlay.dispose();
      overlay = makeOverlay(open);
      fit();
    },
  },
  {
    key: 'level',
    label: 'console',
    value: consoleView.filter.minSeverity,
    options: (['debug', 'info', 'warn'] as const).map((s) => ({ text: s, value: s })),
    change: (value) => {
      consoleView.filter.minSeverity = level(value);
      overlay.invalidate();
    },
  },
]);
/* Fitted once the switches are drawn, since the overlay stops above them. */
fit();

/* The scene: a floor and six balls in a row, the selected one ringed. */
const COLORS: Vec3[] = [
  [0.9, 0.3, 0.25],
  [0.95, 0.65, 0.2],
  [0.85, 0.85, 0.3],
  [0.35, 0.8, 0.4],
  [0.3, 0.55, 0.95],
  [0.65, 0.4, 0.9],
];
const floorMesh = renderer.createMesh(
  new MeshBuilder().addBox([0, -0.1, 0], [7, 0.1, 3], [0.2, 0.21, 0.24]).build(),
);
const ballMeshes = COLORS.map((color) =>
  renderer.createMesh(new MeshBuilder().addSphere([0, 0, 0], 0.35, color, 0, 24, 16, 0.5).build()),
);
const ringMesh = renderer.createMesh(
  new MeshBuilder().addCylinder([0, 0, 0], 0.5, 0.02, 'y', [1, 1, 1], 1, 32).build(),
);
const env = createEnvironment({
  directionalDir: [0.35, 0.85, 0.4],
  directionalColor: [1.4, 1.35, 1.25],
  ambient: [0.3, 0.32, 0.4],
  ambientGround: [0.1, 0.1, 0.12],
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.6;
env.shadowDepthSpan = computeLightMatrix(
  env.directionalDir,
  0,
  1,
  0,
  10,
  renderer.shadowMapSize,
  lightMatrix,
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const models = entities.map(() => new Float32Array(IDENTITY));
const ringModel = new Float32Array(IDENTITY);
let tick = 0;
let last = performance.now();

function place(): void {
  camera.fovYDeg = 45;
  camera.position[0] = 0;
  camera.position[1] = 3.2;
  camera.position[2] = 9;
  camera.lookAt(0, 1.4, 0);
  entities.forEach((ball, i) => {
    const model = models[i] as Float32Array;
    model[12] = (i - (COUNT - 1) / 2) * 1.6;
    model[13] = world.read(ball, Ball, 'y') as number;
  });
}

function drawShadows(): void {
  renderer.beginShadowPass(lightMatrix, 'static');
  renderer.drawShadowCasters((sink) => {
    ballMeshes.forEach((mesh, i) => sink.mesh(mesh, models[i] as Float32Array));
  });
  renderer.endShadowPass();
}

function drawScene(): void {
  renderer.bindMeshPass(camera, env);
  renderer.drawMesh(floorMesh, IDENTITY);
  ballMeshes.forEach((mesh, i) => renderer.drawMesh(mesh, models[i] as Float32Array));
  const chosen = entities.indexOf(primarySelection(selection) ?? -1);
  if (chosen >= 0) {
    ringModel[12] = (models[chosen] as Float32Array)[12] as number;
    ringModel[13] = 0.02;
    renderer.drawMesh(ringMesh, ringModel);
  }
}

stage.run({
  simulate() {
    runSchedule(world, schedule, tick);
    tick += 1;
    report();
  },
  render() {
    const now = performance.now();
    place();
    // #region timing
    /* The frame's time from the clock, and the GPU's in the engine's three brackets: the renderer
       opens shadows and reflection itself, and the game opens rest around the rest of its drawing.
       A sample arrives a few frames after its frame, and never where the device cannot time. */
    pushFrame(history, now - last);
    meter.sample((now - last) / 1000, now);
    last = now;
    const timer = renderer.gpuTimer;
    timer.beginFrame();
    drawShadows();
    renderer.beginFrame([0.08, 0.09, 0.12]);
    timer.begin('rest');
    drawScene();
    timer.end();
    renderer.endFrame();
    timer.endFrame();
    const sample = timer.poll();
    if (sample !== null) recordGpuSample(timings, sample);
    // #endregion

    // #region paint
    /* The overlay over all of it, rebuilt each frame because the profiler changes every frame. */
    if (ink !== null) ink.clearRect(0, -top, innerWidth, innerHeight);
    overlay.invalidate();
    overlay.frame(now);
    paintOverlay(painter, overlay);
    // #endregion
  },
});

balls.drs

examples/tools/balls.drs
// Six balls dropped on a floor, each losing a share of its speed at every bounce until it rests.
// The tools overlay reads them as entities: the inspector shows a ball's fields, and an edit made
// through it can be undone.
//
// Under `npm run examples`, change a rule and save: the balls keep where they are and bounce by
// the new rule. Try a lighter gravity, a floor that gives more back, or balls that never rest.

// #region components
component Ball {
    y: f32 = 3
    vy: f32 = 0
    // The share of its speed a ball keeps at each bounce.
    bounce: f32 = 0.75
    // How hard it last hit the floor, in metres a second, and how many times it has.
    impact: f32 = 0
    hits: u32 = 0
}
// #endregion

// #region rules
let GRAVITY: f32 = 9.81
let FLOOR: f32 = 0.35
// Slower than this at the floor and a ball stops bouncing.
let REST: f32 = 0.4

system Fall {
    writes Ball

    update {
        for e in query<Ball>() {
            e.Ball.vy = e.Ball.vy - GRAVITY / 60
            e.Ball.y = e.Ball.y + e.Ball.vy / 60
            if e.Ball.y < FLOOR {
                e.Ball.y = FLOOR
                let speed = 0 - e.Ball.vy
                if speed > REST {
                    e.Ball.impact = speed
                    e.Ball.hits = e.Ball.hits + 1
                    e.Ball.vy = speed * e.Ball.bounce
                } else {
                    e.Ball.vy = 0
                }
            }
        }
    }
}
// #endregion