# Scene graph

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

Source: https://dirtytalk.42.dog/docs/spatial/scene-graph/

A scene graph is a tree of `SceneNode`s 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`.

```ts
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: 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.

```ts
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.

| 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:

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.

```ts
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.

```ts
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.

```ts
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.

```ts
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](/docs/spatial/rendering/#frametiming). |

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](/docs/engine/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.
