Scene graph

SceneNode and SceneRoot: bounds, parent/children, damage kinds, the data, layout, paint pipeline, batching, subclassing and scheduler choice.

View .md Edit on GitHub

A scene graph is a tree of SceneNodes under one SceneRoot. Nodes own their bounds and report damage; the root collects it, runs the render pipeline and calls your renderer.

SceneNode

SceneNode is an abstract class. Subclass it and implement paint.

import { SceneNode } from '@dirtytalk/spatial';
import type { SceneNodeOptions, Rect } from '@dirtytalk/spatial';

const options: SceneNodeOptions = {
  bounds: { x: 0, y: 0, w: 100, h: 50 },
  clipsOverflow: true,
};

Options

OptionTypeDefaultMeaning
boundsRect{ x: 0, y: 0, w: 0, h: 0 }Position and size in CSS pixels.
clipsOverflowbooleanfalseClip damage raised by descendants to this node’s bounds.

Bounds, parent and children

  • bounds: Rect is a plain field. Assigning it directly does not report damage; use setBounds(next) to move or resize a node.
  • parent: SceneNode | null and children: SceneNode[] describe the tree. Treat them as read-only and change the tree with adoptChild and removeChild.
  • All bounds share one coordinate space: a child’s bounds are not relative to its parent.
import { SceneNode, SceneRoot } from '@dirtytalk/spatial';
import type { Renderer2D } from '@dirtytalk/spatial';
import { SyncScheduler } from '@dirtytalk/engine';

class Box extends SceneNode {
  paint() {}
}

const renderer: Renderer2D = { beginFrame() {}, endFrame() {} };
const root = new SceneRoot(renderer, {
  scheduler: new SyncScheduler(),
  bounds: { x: 0, y: 0, w: 400, h: 300 },
});

const box = new Box({ bounds: { x: 0, y: 0, w: 50, h: 50 } });
root.adoptChild(box); // paint damage for { x: 0, y: 0, w: 50, h: 50 }

box.setBounds({ x: 100, y: 0, w: 50, h: 50 });
// two paint entries (old footprint, new footprint) and a layout entry for the parent

root.removeChild(box); // paint damage for the area the box occupied

Tree operations

  • setBounds(next) does nothing if next equals the current bounds (rectEquals). Otherwise it emits paint damage for the old rect, updates bounds, emits paint damage for the new rect, and, if the node has a parent, emits layout damage for the new rect targeting the parent so the parent’s doLayout runs.
  • adoptChild(child) detaches child from its previous parent (emitting paint damage there), attaches it, and emits one paint damage for the child’s bounds. It throws Error('SceneNode.adoptChild: a node cannot adopt itself or an ancestor') if child is the node itself or one of its ancestors.
  • removeChild(child) does nothing if child is not a child of this node. Otherwise it removes it, emits paint damage for its bounds and sets child.parent to null.

Damage only reaches a renderer when the node is attached to a SceneRoot (directly or through ancestors). Mutating a detached subtree emits nothing.

Damage kinds

Every damage entry has a DamageKind that decides which pipeline stages run for the node that raised it.

KindMeaningStages run for the node
'data'The data behind the node changed.rebuildData(), then doLayout(), then paint
'layout'Sizes or positions of children changed.doLayout(), then paint
'paint'Only the appearance changed.paint

The data, layout, paint pipeline

When the scheduler flushes, SceneRoot renders one frame:

  1. Data. For each 'data' entry whose node defines rebuildData, call it once.
  2. Layout. For each 'data' or 'layout' entry whose node defines doLayout, call it once.
  3. Paint. Compute the frame’s regions and call renderer.beginFrame(regions), paint() on the top-level children that overlap the regions, then renderer.endFrame().

Details:

  • Each node runs each stage at most once per frame, however many times it was marked.
  • Damage raised while stages run (for example a doLayout that calls setBounds on children) joins the same frame; stages repeat until no new damage appears.
  • 'data' and 'layout' damage still contributes its rect to the paint regions.
  • If no region remains after clamping to the root bounds, the frame is skipped: neither beginFrame nor endFrame is called.

markDamaged

markDamaged(kind, rect?) is a protected method: call it from inside a subclass, not from outside.

import { SceneNode } from '@dirtytalk/spatial';

class Counter extends SceneNode {
  value = 0;

  increment() {
    this.value++;
    this.markDamaged('paint'); // defaults to this.bounds
  }

  paint() {}
}
  • rect defaults to this.bounds. Pass a smaller rect to damage only part of the node.
  • The rect is clamped by every ancestor with clipsOverflow: true, nearest first.
  • With no SceneRoot above the node, nothing happens.

batch

batch(fn) is protected. It buffers the damage this node raises while fn runs and emits one entry per kind (and target node) with the union of the rects.

import { SceneNode } from '@dirtytalk/spatial';

class Sprite extends SceneNode {
  x = 0;
  y = 0;

  moveAndTint(x: number, y: number) {
    this.batch(() => {
      this.x = x;
      this.y = y;
      this.markDamaged('paint');
      this.setBounds({ ...this.bounds, x, y });
    });
  }

  paint() {}
}
  • Nested batch calls are absorbed by the outermost one.
  • Damage is emitted even if fn throws, so mutations made before the throw still repaint. The error propagates.
  • Only damage raised through the batching node is buffered. Damage raised on other nodes while fn runs (for example a child touched by adoptChild) is emitted normally.
  • setBounds inside a batch yields a layout entry targeting the parent, which is kept separate from this node’s paint entry.

Subclassing SceneNode

Required:

  • paint(layer: unknown): void. Draw the node. SceneRoot calls it with undefined for each top-level child it paints; capture whatever drawing context you need in the constructor.

Optional:

  • rebuildData?(): void. Recompute derived data. Called for 'data' damage.
  • doLayout?(): void. Position children. Called for 'data' and 'layout' damage.
import { SceneNode } from '@dirtytalk/spatial';
import type { SceneNodeOptions } from '@dirtytalk/spatial';

class Row extends SceneNode {
  constructor(
    private readonly ctx: CanvasRenderingContext2D,
    options?: SceneNodeOptions,
  ) {
    super(options);
  }

  doLayout() {
    let x = this.bounds.x;
    for (const child of this.children) {
      child.setBounds({ ...child.bounds, x });
      x += child.bounds.w;
    }
  }

  paint() {
    const { x, y, w, h } = this.bounds;
    this.ctx.strokeRect(x, y, w, h);
    for (const child of this.children) child.paint(undefined);
  }
}

Note: SceneRoot paints only its direct children. A container node is responsible for calling paint on its own children, as Row does above.

SceneRoot

SceneRoot is a SceneNode that owns the DirtyChannel, the scheduler and the renderer.

import { SceneRoot } from '@dirtytalk/spatial';
import type { Renderer2D } from '@dirtytalk/spatial';
import { ManualScheduler } from '@dirtytalk/engine';

const renderer: Renderer2D = { beginFrame() {}, endFrame() {} };
const scheduler = new ManualScheduler();
const root = new SceneRoot(renderer, {
  scheduler,
  bounds: { x: 0, y: 0, w: 800, h: 600 },
});

root.channel; // DirtyChannel<DirtyRegion>
root.renderer; // the renderer passed in
root.fullFrame = false; // true repaints the whole bounds every frame, no culling
root.hitTest(10, 10); // SceneNode | null, see Pointer events
root.dispose(); // cancel the pending frame; later damage is ignored

SceneRootOptions

OptionTypeDefaultMeaning
schedulerSchedulernew RAFScheduler()Decides when pending damage is flushed into a frame.
boundsRectzero rectRoot bounds. Frame regions are clamped to it.
onFrameTiming(timing: FrameTiming) => voidnoneCalled after each painted frame. See Rendering.

The root’s bounds is re-read on every flush, so resizing the root takes effect on the next frame. Assign root.bounds directly or call setBounds.

Choosing a scheduler

SchedulerUse for
RAFSchedulerDefault. One frame per animation frame; coalesces damage in a tick.
MicrotaskSchedulerOne frame per microtask checkpoint, no rendering-loop alignment.
SyncSchedulerTests and demos. Every mark flushes immediately (use batch to group).
ManualSchedulerTests. Call scheduler.pump() to flush when you decide.

See Schedulers for the full contract.

paintFrame

SceneRoot.paintFrame(regions) is a protected hook for stage 3. The default calls beginFrame, paints culled children, calls endFrame and returns the number of nodes painted. Override it to drive a different renderer or paint walk; stages 1 and 2, region computation and timing stay in the base pipeline.