Rendering

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

View .md Edit on GitHub

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

Renderer2D

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:

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.

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);
  },
});
FieldTypeMeaning
layoutMsnumberMilliseconds in the data and layout stages (rebuildData + doLayout).
paintMsnumberMilliseconds in the paint stage (beginFrame, paint walk, endFrame).
paintedNodesnumberTop-level nodes whose paint() ran this frame. Scales with damaged area, not scene size.

Damage and DirtyRegion

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 for the Space contract.

Rect helpers

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.

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.

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.