# Rendering

> The Renderer2D contract, which damage regions a frame receives, FrameTiming, the Damage and DirtyRegion types, rect helpers and a Canvas2D renderer.

Source: https://dirtytalk.42.dog/docs/spatial/rendering/

`@dirtytalk/spatial` does not draw anything itself. You implement `Renderer2D`, and `SceneRoot` calls it once per frame with the regions that need repainting.

## Renderer2D

```ts
import type { Renderer2D, Rect } from '@dirtytalk/spatial';

const renderer: Renderer2D = {
  beginFrame(regions: readonly Rect[]) {},
  endFrame() {},
};
```

- `beginFrame(regions)` starts a frame, scissored or clipped to `regions`. The array is never empty when called.
- `endFrame()` ends the frame (submit commands, restore state).

Between the two calls, `SceneRoot` calls `paint()` on the top-level children whose bounds overlap the regions, in adoption (z) order. Nodes draw into whatever context they captured; the root passes `undefined` as the `layer` argument.

## Frame regions

`regions` is the list of the frame's individual damage rects, not their union. A far single-frame move arrives as two disjoint rects (erase the old footprint, fill the new one); a renderer that scissors to each rect leaves the gap between them untouched.

The root builds the list as follows:

1. Each damage rect (of any kind) is clamped to the root's `bounds`. Rects with zero or negative area are dropped.
2. A rect merges into an earlier region when their bounding rect is no larger than both areas combined, so containment and overlapping erase/fill pairs collapse while disjoint rects stay separate.
3. If more than 16 distinct regions result, the frame collapses to a single bounding rect.
4. If `fullFrame` is `true`, the list is just `[root.bounds]` and no children are culled.

If the list is empty (for example only `'data'` damage off screen), the frame is skipped and neither renderer method is called.

A renderer that does not support multiple scissor rects can union the list and clip to the result:

```ts
import { unionRects } from '@dirtytalk/spatial';
import type { Renderer2D, Rect } from '@dirtytalk/spatial';

const renderer: Renderer2D = {
  beginFrame(regions: readonly Rect[]) {
    const clip = unionRects(regions);
    console.log('clip to', clip);
  },
  endFrame() {},
};
```

## FrameTiming

Pass `onFrameTiming` in `SceneRootOptions` to receive per-frame CPU timing. `performance.now()` is only called when the hook is set. It is not called for skipped frames.

```ts
import { SceneRoot } from '@dirtytalk/spatial';
import type { FrameTiming, Renderer2D } from '@dirtytalk/spatial';

const renderer: Renderer2D = { beginFrame() {}, endFrame() {} };

const root = new SceneRoot(renderer, {
  onFrameTiming: (t: FrameTiming) => {
    console.log(t.layoutMs, t.paintMs, t.paintedNodes);
  },
});
```

| Field          | Type     | Meaning                                                                                   |
| -------------- | -------- | ----------------------------------------------------------------------------------------- |
| `layoutMs`     | `number` | Milliseconds in the data and layout stages (`rebuildData` + `doLayout`).                  |
| `paintMs`      | `number` | Milliseconds in the paint stage (`beginFrame`, paint walk, `endFrame`).                   |
| `paintedNodes` | `number` | Top-level nodes whose `paint()` ran this frame. Scales with damaged area, not scene size. |

## Damage and DirtyRegion

```ts
import type { Damage, DamageKind, DirtyRegion, Rect } from '@dirtytalk/spatial';

const rect: Rect = { x: 0, y: 0, w: 10, h: 10 };
const kind: DamageKind = 'paint'; // 'paint' | 'layout' | 'data'
const damage: Damage = { rect, kind }; // `node` is optional
const region: DirtyRegion = [damage]; // readonly Damage[]
```

- `Rect` is an axis-aligned rectangle in CSS pixels: `{ x, y, w, h }`.
- `Damage` is one entry. `node` identifies the `SceneNode` that raised it (typed `unknown`); the pipeline uses it to find which node to run `rebuildData` and `doLayout` on.
- `DirtyRegion` is `readonly Damage[]`, the Region type of the spatial `DirtyChannel`.

## RectSpace

`RectSpace` is the engine `Space<DirtyRegion>` that `SceneRoot` hands to its `DirtyChannel`. You only need it if you build your own `DirtyChannel` over rect damage.

- `empty()` is `[]`; `isEmpty(r)` is `r.length === 0`.
- `union(a, b)` concatenates, returning the other argument when one side is empty.
- `unionInto(acc, b)` appends `b` into `acc` in place (an optimisation for the channel's private accumulator).
- `intersects(interest, dirty)` is `false` if either side is empty. Otherwise it is `true` if any `dirty` entry is `'data'` or `'layout'` (pipeline work, not area, so it always matches), or if a `'paint'` entry's rect overlaps an interest rect.

See [Dirty channel](/docs/engine/dirty-channel/) for the `Space` contract.

## Rect helpers

```ts
import {
  rectOverlaps,
  rectEquals,
  unionRects,
  rectClamp,
  pointInRect,
} from '@dirtytalk/spatial';
import type { Rect } from '@dirtytalk/spatial';

const a: Rect = { x: 0, y: 0, w: 10, h: 10 };
const b: Rect = { x: 5, y: 5, w: 10, h: 10 };

rectOverlaps(a, b); // true
rectEquals(a, b); // false
unionRects([a, b]); // { x: 0, y: 0, w: 15, h: 15 }
rectClamp(b, a); // { x: 5, y: 5, w: 5, h: 5 }
pointInRect(0, 0, a); // true
pointInRect(10, 10, a); // false
```

- `rectOverlaps` is `false` for rects that only touch, and for any rect with zero width or height.
- `unionRects([])` returns `{ x: 0, y: 0, w: 0, h: 0 }`.
- `rectClamp` returns a zero-size rect (`w` and `h` of `0`) when the rects do not overlap.
- `pointInRect` is half-open, `[x, x + w) x [y, y + h)`: the top-left edge is inside, the bottom-right edge is outside.

Full signatures: [API reference](/docs/spatial/api/#rect-helpers).

## Writing a Canvas2D renderer

A Canvas2D renderer clips to the regions in `beginFrame`, clears them, and restores state in `endFrame`. Nodes capture the context at construction.

```ts
import { SceneNode, SceneRoot } from '@dirtytalk/spatial';
import type { Renderer2D, Rect } from '@dirtytalk/spatial';

class CanvasRenderer implements Renderer2D {
  constructor(private readonly ctx: CanvasRenderingContext2D) {}

  beginFrame(regions: readonly Rect[]) {
    const { ctx } = this;
    ctx.save();
    ctx.beginPath();
    for (const r of regions) ctx.rect(r.x, r.y, r.w, r.h);
    ctx.clip();
    for (const r of regions) ctx.clearRect(r.x, r.y, r.w, r.h);
  }

  endFrame() {
    this.ctx.restore();
  }
}

class Swatch extends SceneNode {
  constructor(
    private readonly ctx: CanvasRenderingContext2D,
    private color: string,
    bounds: Rect,
  ) {
    super({ bounds });
  }

  setColor(color: string) {
    if (color === this.color) return;
    this.color = color;
    this.markDamaged('paint');
  }

  paint() {
    const { x, y, w, h } = this.bounds;
    this.ctx.fillStyle = this.color;
    this.ctx.fillRect(x, y, w, h);
  }
}

const canvas = document.querySelector('canvas')!;
const ctx = canvas.getContext('2d')!;

const root = new SceneRoot(new CanvasRenderer(ctx), {
  bounds: { x: 0, y: 0, w: canvas.width, h: canvas.height },
});

const swatch = new Swatch(ctx, 'tomato', { x: 20, y: 20, w: 80, h: 80 });
root.adoptChild(swatch);

swatch.setColor('steelblue'); // repaints only { x: 20, y: 20, w: 80, h: 80 }
```

> **Note:** A node that overlaps a region is repainted in full, but only the clipped area is visible. Z-order among painted nodes is adoption order.

The example omits `scheduler`, so it uses the default `RAFScheduler` and frames render on `requestAnimationFrame`.
