Scene graph
SceneNode and SceneRoot: bounds, parent/children, damage kinds, the data, layout, paint pipeline, batching, subclassing and scheduler choice.
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
| Option | Type | Default | Meaning |
|---|---|---|---|
bounds | Rect | { x: 0, y: 0, w: 0, h: 0 } | Position and size in CSS pixels. |
clipsOverflow | boolean | false | Clip damage raised by descendants to this node’s bounds. |
Bounds, parent and children
bounds: Rectis a plain field. Assigning it directly does not report damage; usesetBounds(next)to move or resize a node.parent: SceneNode | nullandchildren: SceneNode[]describe the tree. Treat them as read-only and change the tree withadoptChildandremoveChild.- All bounds share one coordinate space: a child’s
boundsare 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 ifnextequals the current bounds (rectEquals). Otherwise it emitspaintdamage for the old rect, updatesbounds, emitspaintdamage for the new rect, and, if the node has a parent, emitslayoutdamage for the new rect targeting the parent so the parent’sdoLayoutruns.adoptChild(child)detacheschildfrom its previous parent (emitting paint damage there), attaches it, and emits onepaintdamage for the child’s bounds. It throwsError('SceneNode.adoptChild: a node cannot adopt itself or an ancestor')ifchildis the node itself or one of its ancestors.removeChild(child)does nothing ifchildis not a child of this node. Otherwise it removes it, emitspaintdamage for its bounds and setschild.parenttonull.
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.
| Kind | Meaning | Stages 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:
- Data. For each
'data'entry whose node definesrebuildData, call it once. - Layout. For each
'data'or'layout'entry whose node definesdoLayout, call it once. - Paint. Compute the frame’s regions and call
renderer.beginFrame(regions),paint()on the top-level children that overlap the regions, thenrenderer.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
doLayoutthat callssetBoundson 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
beginFramenorendFrameis 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() {}
}
rectdefaults tothis.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
SceneRootabove 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
batchcalls are absorbed by the outermost one. - Damage is emitted even if
fnthrows, 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
fnruns (for example a child touched byadoptChild) is emitted normally. setBoundsinside a batch yields alayoutentry targeting the parent, which is kept separate from this node’spaintentry.
Subclassing SceneNode
Required:
paint(layer: unknown): void. Draw the node.SceneRootcalls it withundefinedfor 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:
SceneRootpaints only its direct children. A container node is responsible for callingpainton its own children, asRowdoes 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
| Option | Type | Default | Meaning |
|---|---|---|---|
scheduler | Scheduler | new RAFScheduler() | Decides when pending damage is flushed into a frame. |
bounds | Rect | zero rect | Root bounds. Frame regions are clamped to it. |
onFrameTiming | (timing: FrameTiming) => void | none | Called 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
| Scheduler | Use for |
|---|---|
RAFScheduler | Default. One frame per animation frame; coalesces damage in a tick. |
MicrotaskScheduler | One frame per microtask checkpoint, no rendering-loop alignment. |
SyncScheduler | Tests and demos. Every mark flushes immediately (use batch to group). |
ManualScheduler | Tests. 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.