Skip to content

Scene Graph

The scene graph is a tree of Nodes. Container adds children; Group, Layer, and Stage are the concrete containers, with Stage as the root and engine entry point.

abstract class Node — base of every scene-graph element. Owns the local transform, visual state, hierarchy linkage, and the lazy, version-counted world-matrix cache. It knows nothing about rendering (there is no draw()).

Optional constructor props (transform + visual + identity).

interface NodeConfig {
x?: number; y?: number
scaleX?: number; scaleY?: number
rotation?: number
skewX?: number; skewY?: number
offsetX?: number; offsetY?: number
opacity?: number
visible?: boolean
listening?: boolean
id?: string
name?: string
}

Each is a real typed getter/setter (not a stringly-typed attrs bag); a transform setter only invalidates when the value actually changes.

// transform (get/set, all numbers)
x y scaleX scaleY rotation skewX skewY offsetX offsetY
// visual
opacity: number
visible: boolean
listening: boolean // reserved for the event system; does not affect rendering
// identity / hierarchy
parent // managed by the container — attach via add(), detach via remove()
id: string
name: string
type: string // read-only discriminant
position(x: number, y: number) // set x and y together
move(dx: number, dy: number) // translate by a delta
localMatrix(): Matrix // cached local transform (Matrix.compose)
worldMatrix(): Matrix // lazy, version-counted world transform
getLocalBounds(): Bounds // abstract — bounds in local space
getWorldBounds(): Bounds // bounds in world space
markDirty() // flag a visual/transform change; walks to the root
remove() // detach from parent
destroy() // tear down
toObject() // serialize to a plain object
on(type, handler, options?) // register a listener; options: { capture }
once(type, handler, options?) // one-shot listener
off(type, handler?) // remove a listener (or all for the type)
hasListeners(type): boolean // any listeners for the type/phase?
const group = new Group({ x: 100 })
const child = new TestRect({ x: 10 })
group.add(child)
child.worldMatrix().applyToPoint({ x: 0, y: 0 }) // { x: 110, y: 0 }
group.x = 200
child.worldMatrix().applyToPoint({ x: 0, y: 0 }) // { x: 210, y: 0 } (recomputed lazily)

The world matrix is cached against a version counter, so changing one node never eagerly walks its subtree — a descendant recomputes only when its own transform changes or an ancestor’s world matrix actually moves. See Matrix and Bounds.

abstract class Container extends Node — branch node holding children, z-order, traversal, and a derived bounds union. Concrete containers: Group, Layer, Stage.

children // readonly view — mutate via the methods, not the array
childCount: number
add(...nodes) // attach; re-parents and throws on cycles
removeChild(node)
removeChildren()
getChildIndex(node): number

Child array order is paint order (earlier = drawn first = visually behind).

moveToTop(node)
moveToBottom(node)
moveUp(node) // swap with the next neighbor
moveDown(node) // swap with the previous neighbor

All depth-first, in child order.

find(predicate) // first matching descendant
traverse(visitor) // visit each descendant
getDescendants() // flat DFS list
isAncestorOf(node): boolean
getLocalBounds(): Bounds // overrides Node — union of child bounds
const g = new Group()
g.add(a, b, c)
g.moveToTop(a) // a now paints last (front)
g.traverse((n) => console.log(n.type))
g.getLocalBounds() // tight box around a, b, c

class Group extends Containertype = 'Group'. The everyday grouping primitive: transform it and its children follow. Adds nothing beyond Container except a concrete type.

new Group(config?: NodeConfig)
const g = new Group({ x: 50, rotation: 15 })
g.add(rect, label) // both move/rotate with the group

Use a Group for logical grouping anywhere in the tree; reach for a Layer only for top-level render partitions.

class Layer extends Containertype = 'Layer'. A top-level partition directly under the Stage; the Stage’s direct children must be Layers. Create via stage.createLayer().

new Layer(config?: NodeConfig)
const layer = stage.createLayer()
layer.add(rect, group)

Today every layer renders to the stage’s single canvas in order; the per-layer offscreen canvas is a later, opt-in caching optimization. Use layers sparingly — they are partitions, not the grouping mechanism.

class Stage extends Container — root of the scene graph and engine entry point. Owns the renderer (defaults to the Canvas2D renderer), the frame scheduler, the camera, and the viewport. Turns “a property changed” into “one frame rendered.”

interface StageOptions {
container: HTMLElement
width?: number
height?: number
pixelRatio?: number
background?: string
renderer?: Renderer
camera?: Camera
hitTester?: HitTester
}
interface Overlay {
drawOps(): DrawOp[] // a screen-space overlay drawn after the scene
}
get width(): number
get height(): number
get pixelRatio(): number
get canvas(): HTMLCanvasElement | undefined // reflects the renderer's canvas (may be undefined when injected)
get camera(): Camera

Delegate to the camera.

screenToWorld(point): Vec2
worldToScreen(point): Vec2

Zoom-aware, options-driven. See hit testing.

hitTest(worldPoint, options?): HitResult | null
getIntersection(worldPoint, options?): Node | null // convenience — just the node
add(...layers) // Layer-only; throws TypeError on a non-Layer child
createLayer(config?): Layer // make and attach a layer in one call
setSize(w: number, h: number)
setPixelRatio(dpr: number)
requestRender() // coalesced (async) — the normal path
render() // synchronous; for explicit needs and tests
addOverlay(overlay: Overlay)
removeOverlay(overlay: Overlay)
destroy() // cancel scheduler → remove children → destroy renderer
import { Stage } from '@veyrajs/core'
const stage = new Stage({ container: el, width: 800, height: 480, background: '#0b1220' })
const layer = stage.createLayer()
layer.add(myShape) // schedules a coalesced render automatically
// ...
stage.destroy()

You don’t normally call render() — mutating properties schedules a coalesced frame. The camera is applied at render time as screen = view · world, so world coordinates stay camera-independent.