Rendering
The Renderer2D contract, which damage regions a frame receives, FrameTiming, the Damage and DirtyRegion types, rect helpers and a Canvas2D renderer.
@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 toregions. 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:
- Each damage rect (of any kind) is clamped to the root’s
bounds. Rects with zero or negative area are dropped. - 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.
- If more than 16 distinct regions result, the frame collapses to a single bounding rect.
- If
fullFrameistrue, 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);
},
});
| 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
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[]
Rectis an axis-aligned rectangle in CSS pixels:{ x, y, w, h }.Damageis one entry.nodeidentifies theSceneNodethat raised it (typedunknown); the pipeline uses it to find which node to runrebuildDataanddoLayouton.DirtyRegionisreadonly Damage[], the Region type of the spatialDirtyChannel.
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)isr.length === 0.union(a, b)concatenates, returning the other argument when one side is empty.unionInto(acc, b)appendsbintoaccin place (an optimisation for the channel’s private accumulator).intersects(interest, dirty)isfalseif either side is empty. Otherwise it istrueif anydirtyentry 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
rectOverlapsisfalsefor rects that only touch, and for any rect with zero width or height.unionRects([])returns{ x: 0, y: 0, w: 0, h: 0 }.rectClampreturns a zero-size rect (wandhof0) when the rects do not overlap.pointInRectis 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.