Read OSS

The Scene Graph: Object3D, Transforms, and the Geometry-Material Contract

Intermediate

Prerequisites

  • Basic linear algebra (vectors, matrices, quaternions)
  • Article 1: Architecture and Navigation Guide

The Scene Graph: Object3D, Transforms, and the Geometry-Material Contract

Every Three.js application is, at its core, a tree of Object3D nodes. A camera looks at a scene, the scene contains meshes, the meshes hold geometry and materials, and the renderer walks the tree to produce pixels. Understanding how Object3D manages transforms, how the tree propagates matrix updates, and how the geometry-material contract works in Mesh is essential to working with Three.js at any level beyond "copy the tutorial."

Object3D: The Universal Scene Node

Object3D is defined at src/core/Object3D.js and extends EventDispatcher, inheriting the pub/sub system we discussed in Part 1. Its constructor sets up identity via an auto-incrementing numeric id and a uuid, establishes the parent-child relationship fields (parent, children), and — most importantly — creates the transform properties.

The transform state lives in four properties: position (Vector3), rotation (Euler), quaternion (Quaternion), and scale (Vector3). These are wired together through Object.defineProperties at lines 160-226, making them non-replaceable but mutable. Two additional matrices are defined: modelViewMatrix (view-space transform, computed by the renderer) and normalMatrix (for correct normal transformation).

The Euler ↔ Quaternion Synchronization

This is one of the most clever mechanisms in the codebase. Three.js exposes both Euler angles (rotation) and quaternions (quaternion) as properties, and keeps them synchronized bidirectionally through _onChange callbacks:

// src/core/Object3D.js, lines 145-158
function onRotationChange() {
    quaternion.setFromEuler( rotation, false );
}

function onQuaternionChange() {
    rotation.setFromQuaternion( quaternion, undefined, false );
}

rotation._onChange( onRotationChange );
quaternion._onChange( onQuaternionChange );

The false parameter in each conversion suppresses the other's change callback, preventing an infinite ping-pong. When you set object.rotation.y = Math.PI, the Euler's _onChange fires, updating the quaternion silently. When you set object.quaternion.setFromAxisAngle(axis, angle), the quaternion's _onChange fires, updating the Euler silently.

sequenceDiagram
    participant User
    participant Euler as rotation (Euler)
    participant Quaternion as quaternion

    User->>Euler: rotation.y = π
    Euler->>Euler: _onChange fires
    Euler->>Quaternion: setFromEuler(rotation, false)
    Note right of Quaternion: false = don't fire _onChange

    User->>Quaternion: quaternion.setFromAxisAngle(...)
    Quaternion->>Quaternion: _onChange fires
    Quaternion->>Euler: setFromQuaternion(quat, order, false)
    Note right of Euler: false = don't fire _onChange

Tip: If you need to animate rotations, use quaternions directly. Euler angles suffer from gimbal lock and produce unexpected interpolation artifacts. Three.js uses quaternions internally for matrix composition regardless of which property you set.

The Transform Matrix Pipeline

Object3D maintains two matrices: matrix (local transform) and matrixWorld (world transform). Three methods manage their lifecycle, and understanding the hierarchy between them is critical.

updateMatrix() at line 1133 computes the local matrix from position, quaternion, and scale via matrix.compose(). It then applies the optional pivot point adjustment and sets matrixWorldNeedsUpdate = true.

updateMatrixWorld(force) at line 1165 is the workhorse called by the renderer. It first calls updateMatrix() if matrixAutoUpdate is true, then composes the world matrix: for root objects, matrixWorld copies matrix; for children, it's parent.matrixWorld * matrix. Crucially, it then recurses into all children — this is the top-down propagation that ensures every descendant's world matrix is current.

updateWorldMatrix(updateParents, updateChildren) at line 1212 provides selective control. You can update a single node's world matrix without touching the entire subtree, or propagate upward to ancestors first — useful when you need a specific object's world position outside the render loop.

flowchart TD
    A["position, quaternion, scale"] -->|"compose()"| B["matrix (local)"]
    B -->|"parent.matrixWorld × matrix"| C["matrixWorld (world)"]

    subgraph "updateMatrixWorld(force)"
        D["For each child:"] --> E["child.updateMatrixWorld(force)"]
    end

    C --> D

    style A fill:#e8f4f8
    style B fill:#fff3cd
    style C fill:#d4edda

Parent-Child Tree Operations

The add() method at line 746 handles child attachment with several safety checks: self-parenting prevention, type checking via isObject3D, and automatic detachment from any previous parent via removeFromParent(). On success, it dispatches both 'added' (on the child) and 'childadded' (on the parent) events.

The remove() method at line 798 mirrors this with corresponding 'removed' and 'childremoved' events. Both methods accept variadic arguments for bulk operations.

Three traversal methods provide different iteration strategies:

Method Behavior
traverse(callback) Visits every descendant, depth-first
traverseVisible(callback) Same, but skips objects where visible === false
traverseAncestors(callback) Walks up the parent chain to the root

The renderer uses traverseVisible internally for scene projection, and Raycaster uses traverse for intersection testing.

graph TD
    Scene["Scene"] --> Camera["Camera"]
    Scene --> Group["Group"]
    Scene --> Light["DirectionalLight"]
    Group --> MeshA["Mesh A"]
    Group --> MeshB["Mesh B"]
    MeshA --> ChildMesh["Child Mesh"]

    style Scene fill:#d4edda
    style Group fill:#e8f4f8
    style MeshA fill:#fff3cd
    style MeshB fill:#fff3cd
    style ChildMesh fill:#fff3cd

BufferGeometry and BufferAttribute

BufferGeometry extends EventDispatcher (not Object3D — geometry has no position in space). It follows the same ID + UUID pattern as Object3D and stores vertex data as named BufferAttribute objects.

The data model is a dictionary of attributes accessed via setAttribute(name, attribute) and getAttribute(name). Standard attribute names are 'position', 'normal', 'uv', 'color', and 'tangent', but custom attributes are supported for node materials and custom shaders. Each BufferAttribute wraps a typed array (Float32Array, etc.) plus an itemSize that defines how many elements make up a single vertex entry.

BufferGeometry also manages:

  • Index buffer: an optional BufferAttribute for indexed drawing (shared vertices)
  • Groups: sub-ranges within the geometry that can use different materials
  • Bounding volumes: boundingBox (Box3) and boundingSphere (Sphere), computed on demand via computeBoundingBox() and computeBoundingSphere()

Note the module-level scratch objects at the top of BufferGeometry.js: _m1, _obj, _offset, _box, _vector — the same pooling pattern from Part 1 appears here for methods like applyMatrix4() and computeBoundingSphere().

Three.js ships 22 procedural geometry types (BoxGeometry, SphereGeometry, CylinderGeometry, etc.) in src/geometries/, all extending BufferGeometry with pre-computed vertex data.

Material Base Class and the Property-Driven Approach

Material is the abstract base class for all surface appearance definitions. Like BufferGeometry, it extends EventDispatcher and follows the ID + UUID pattern.

Material takes a purely property-driven approach: rather than setter methods that trigger state changes, you simply set properties like blending, side, depthTest, transparent, opacity, and visible. The renderer reads these properties during the render loop and configures GPU state accordingly. The only "trigger" mechanism is needsUpdate — setting it to true forces the renderer to recompile the material's shader program.

The base class defines over 30 properties covering blending mode, depth testing, stencil operations, polygon offset, alpha test threshold, and more. Concrete subclasses like MeshStandardMaterial add PBR properties (color, roughness, metalness, maps) while the new NodeMaterial hierarchy adds programmable shader nodes.

Tip: Setting material.transparent = true alone doesn't make an object transparent. You also need to set material.opacity < 1.0 or provide a texture with alpha. The transparent flag controls sorting (transparent objects are rendered back-to-front after opaques).

Mesh, InstancedMesh, and BatchedMesh

Mesh is the fundamental renderable object: it extends Object3D and adds geometry and material properties. This geometry-material pairing is the rendering contract — when the renderer encounters a Mesh during scene traversal, it knows how to draw it: the geometry provides vertex data, the material provides appearance and shader programs.

classDiagram
    class Object3D {
        +position: Vector3
        +quaternion: Quaternion
        +scale: Vector3
        +matrixWorld: Matrix4
    }

    class Mesh {
        +geometry: BufferGeometry
        +material: Material
        +isMesh: boolean
        +raycast()
    }

    class InstancedMesh {
        +instanceMatrix: InstancedBufferAttribute
        +instanceColor: InstancedBufferAttribute
        +count: number
    }

    class BatchedMesh {
        +addGeometry()
        +addInstance()
        +setMatrixAt()
    }

    Object3D <|-- Mesh
    Mesh <|-- InstancedMesh
    Mesh <|-- BatchedMesh

Mesh also implements raycast(), making it compatible with the Raycaster intersection system. The method checks the bounding sphere first (a fast rejection test), then tests individual triangles against the ray.

InstancedMesh enables GPU hardware instancing: you supply a single geometry and material, plus an instanceMatrix (a buffer of per-instance 4×4 matrices) and an optional instanceColor. The GPU draws all instances in a single draw call, making it ideal for forests, particle systems, or crowds.

BatchedMesh takes a different approach to reducing draw calls: it merges multiple different geometries into a single vertex buffer and manages instance-to-geometry mappings internally. This is more flexible than InstancedMesh (which requires all instances to share one geometry) but more complex to set up.

Scene and Raycaster

Scene is surprisingly thin at 165 lines. It extends Object3D and adds just a handful of properties: background (Color or Texture for skyboxes), environment (texture for IBL), fog (Fog or FogExp2), overrideMaterial, and blurriness/intensity/rotation controls for background and environment. Scene does not contain any rendering logic — it's purely a data container.

Raycaster provides intersection testing against the scene graph. Its core method intersectObjects() traverses the scene graph and calls each object's raycast() method — this is a visitor pattern where the raycaster provides the ray, and each object type implements its own intersection logic. Mesh tests triangles, Sprite tests a plane, Line tests line segments, and Points tests spheres around each point.

flowchart LR
    RC["Raycaster"] -->|"intersectObjects()"| Scene["Scene.traverse()"]
    Scene --> M1["Mesh.raycast()"]
    Scene --> M2["Sprite.raycast()"]
    Scene --> M3["Line.raycast()"]
    M1 -->|"Hit?"| Results["Sorted Intersections[]"]
    M2 -->|"Hit?"| Results
    M3 -->|"Hit?"| Results

The results array is sorted by distance, with each intersection containing the distance, point (world-space hit position), face (the triangle hit), faceIndex, object (the intersected Object3D), and uv/uv1 for texture coordinate lookups.

What's Next

We've now covered the data structures that every Three.js application manipulates: the Object3D scene tree, the geometry-material contract, and how transforms propagate through the hierarchy. In the next article, we'll see what happens when the renderer actually draws this data — exploring the dual renderer architecture, from the legacy monolithic WebGLRenderer to the new modular Renderer + Backend system that powers the WebGPU future.