Scene Graph & Transforms
The scene graph is a typed, mutable, retained-mode tree. You build it once and then mutate
it (rect.x = 10); the engine figures out what to redraw.
Stage // root: owns the host element, camera, renderer, events └─ Layer // a render partition (Stage's only allowed children) └─ Group // a transformable container, anywhere in the tree ├─ Shape // a drawable leaf: Rect, Circle, Line, Text, … └─ Group // groups nest freelyThe node types
Section titled “The node types”| Class | Role |
|---|---|
Node |
The abstract base: identity (id, name), parent link, the local transform (x, y, scaleX/Y, rotation, skewX/Y, offsetX/Y), opacity, visible, listening, events, and serialization. |
Container |
A Node that holds children: add(...), removeChild, removeChildren, z-order (moveToTop/moveUp/…), traversal (find, traverse, getDescendants). |
Group |
The everyday container — group nodes to move/scale/rotate them together. |
Layer |
A top-level partition; the Stage’s only legal children. Use sparingly. |
Shape |
The drawable leaf — Rect, Circle, Ellipse, Line, Polygon, Text, Image. Carries paint style, emits draw ops, answers hit-tests. |
Stage |
The root: owns the host element, the Camera, the renderer, and the event manager. |
Setters are guarded — assigning an unchanged value is a no-op (if (v !== this._x)). That is
what makes the framework adapters loop-safe: re-rendering with the same prop value costs nothing.
import { Stage, Layer, Group, Rect } from '@veyrajs/core'
const stage = new Stage({ container: el, width: 800, height: 480 })const layer = stage.createLayer() // Stage.add() accepts Layers onlyconst group = new Group({ x: 100, rotation: 15 })group.add(new Rect({ width: 80, height: 60, fill: '#38bdf8' }))layer.add(group) // the rect moves & rotates with the groupTransforms & the pivot
Section titled “Transforms & the pivot”Every node has a full affine transform: position, scale, rotation (degrees, clockwise), skew, and an offset that moves the pivot. The local matrix is composed in a fixed order:
T(x, y) · R(rotation) · Skew(skewX, skewY) · S(scaleX, scaleY) · T(-offsetX, -offsetY)The trailing T(-offset) is what makes offsetX/offsetY behave as a pivot: rotation and scale
happen around that local point. Drag the sliders — the yellow dot marks the node’s (x, y), which
is where the local (offsetX, offsetY) point lands:
Coordinate spaces
Section titled “Coordinate spaces”Everything is one affine Matrix (2×3). There are four explicit spaces:
| Space | What it is |
|---|---|
| Screen | CSS pixels relative to the host element (what pointer events report). |
| World | the logical scene; screen = ViewMatrix · world (the camera). |
| Local | a node’s own frame; world = nodeWorldMatrix · local. |
| Media | (reserved) source-image pixels, for annotation work. |
Convention: top-left origin, y-down, rotation in degrees clockwise — the Canvas/DOM convention.
Lazy, version-counted world transforms
Section titled “Lazy, version-counted world transforms”A node’s worldMatrix() is parent.worldMatrix() · localMatrix(), computed lazily and cached
against a version counter. A node recomputes only when its own transform changed or an
ancestor’s world matrix actually moved:
- each node has a
_worldVersionthat increments only when its world matrix really changes; - a child records the parent version it last computed against;
- on
worldMatrix()it returns the cache unless its own transform is dirty or the parent’s version advanced.
The payoff: changing one node never eagerly walks its subtree — avoiding the whole-subtree
invalidation that plagues some engines. Bounds (getLocalBounds / getWorldBounds) are cached the
same way.
const group = new Group({ x: 100 })const child = new Rect({ x: 10, width: 20, height: 20 })group.add(child)child.worldMatrix().applyToPoint({ x: 0, y: 0 }) // { x: 110, y: 0 }group.x = 200child.worldMatrix().applyToPoint({ x: 0, y: 0 }) // { x: 210, y: 0 } — recomputed lazilyDirty tracking — you never call render()
Section titled “Dirty tracking — you never call render()”A mutation calls an internal markDirty(), which walks to the root Stage and schedules one
redraw per animation frame via the FrameScheduler (Konva’s batchDraw idea). You mutate freely;
the engine coalesces:
rect.x = 60rect.y = 80rect.fill = '#f472b6' // three mutations …// … exactly one frame is rendered on the next requestAnimationFrameSee Rendering & the Frame Loop for what happens inside that frame.
Related
Section titled “Related”- Shapes — the drawable leaves and their geometry.
- Camera & Coordinate Spaces — screen ↔ world conversions.
- Design Philosophy — why the matrix composition rule matters.