Scene Graph Guide

A scene graph is a tree structure representing all objects in your game world. Tiramisu uses a declarative scene graph that you define in your view() function.

Core Concepts

Scene Nodes

Every object in your game is a SceneNode. Nodes can be:

The View Function

Your view() function returns a List(SceneNode) every frame:

fn view(model: Model) -> List(SceneNode) {
  [
    camera_node,
    player_mesh,
    enemy_mesh,
    light_node,
  ]
}

Tiramisu automatically diffs this list against the previous frame and applies only the changes.

Creating Scene Nodes

Meshes

Basic 3D objects with geometry and material:

import gleam/option
import tiramisu/geometry
import tiramisu/material
import tiramisu/scene
import tiramisu/transform
import vec/vec3

let assert Ok(cube_geometry) = geometry.box(
  width: 1.0,
  height: 1.0,
  depth: 1.0,
)

let assert Ok(cube_material) =
  material.new()
  |> material.with_color(0x4ecdc4)
  |> material.with_metalness(0.5)
  |> material.with_roughness(0.5)
  |> material.build()

scene.Mesh(
  id: "cube",  // Unique identifier
  geometry: cube_geometry,
  material: cube_material,
  transform: transform.Transform(
    position: vec3.Vec3(0.0, 0.0, 0.0),
    rotation: vec3.Vec3(0.0, 0.0, 0.0),
    scale: vec3.Vec3(1.0, 1.0, 1.0),
  ),
  physics: option.None,
)

Geometry Types

Built-in geometries (all return Result(Geometry, GeometryError)):

import tiramisu/geometry

// Box
let assert Ok(box_geo) = geometry.box(width: 2.0, height: 1.0, depth: 1.0)

// Sphere
let assert Ok(sphere_geo) = geometry.sphere(
  radius: 1.0,
  width_segments: 32,
  height_segments: 16,
)

// Cylinder
let assert Ok(cylinder_geo) = geometry.cylinder(
  radius_top: 1.0,
  radius_bottom: 1.0,
  height: 2.0,
  radial_segments: 32,
)

// Plane
let assert Ok(plane_geo) = geometry.plane(width: 10.0, height: 10.0)

// Circle
let assert Ok(circle_geo) = geometry.circle(radius: 1.0, segments: 32)

// Cone
let assert Ok(cone_geo) = geometry.cone(radius: 1.0, height: 2.0, segments: 32)

// Torus
let assert Ok(torus_geo) = geometry.torus(
  radius: 1.0,
  tube: 0.4,
  radial_segments: 16,
  tubular_segments: 32,
)

// Polyhedra
let assert Ok(tetra_geo) = geometry.tetrahedron(radius: 1.0, detail: 0)
let assert Ok(icosa_geo) = geometry.icosahedron(radius: 1.0, detail: 0)

// Custom (loaded from file)
geometry.custom(buffer_geometry)

Material Types

All materials are created using the material module and return Result(Material, MaterialError).

StandardMaterial - PBR (physically-based) with metalness/roughness (recommended):

import tiramisu/material

// Builder pattern (recommended)
let assert Ok(mat) =
  material.new()
  |> material.with_color(0x4ecdc4)
  |> material.with_metalness(0.8)  // 0.0 = non-metal, 1.0 = metal
  |> material.with_roughness(0.2)  // 0.0 = smooth, 1.0 = rough
  |> material.with_map(texture)
  |> material.with_normal_map(normal_texture)
  |> material.build()

// Or direct constructor with all parameters
let assert Ok(mat2) = material.standard(
  color: 0x4ecdc4,
  metalness: 0.8,
  roughness: 0.2,
  map: option.Some(texture),
  normal_map: option.Some(normal_texture),
  ambient_oclusion_map: option.None,
  roughness_map: option.None,
  metalness_map: option.None,
)

BasicMaterial - Unlit, flat color:

let assert Ok(basic_mat) = material.basic(
  color: 0xff0000,
  transparent: False,
  opacity: 1.0,
  map: option.None,
)

PhongMaterial - Classic Phong shading:

let assert Ok(phong_mat) = material.phong(
  color: 0xffe66d,
  shininess: 100.0,
  map: option.None,
)

LambertMaterial - Matte, diffuse-only:

let assert Ok(lambert_mat) = material.lambert(
  color: 0x95e1d3,
  map: option.None,
)

ToonMaterial - Cel-shaded:

let assert Ok(toon_mat) = material.toon(
  color: 0xf38181,
  map: option.None,
)

Lights

All lights are created using the light module and return Result(Light, LightError).

AmbientLight - Uniform lighting from all directions:

import tiramisu/light

let assert Ok(ambient) = light.ambient(intensity: 0.5, color: 0xffffff)

scene.Light(
  id: "ambient",
  light: ambient,
  transform: transform.identity,
)

DirectionalLight - Parallel rays (like the sun):

let assert Ok(sun) = light.directional(intensity: 0.8, color: 0xffffff)

scene.Light(
  id: "sun",
  light: sun,
  transform: transform.Transform(
    position: vec3.Vec3(10.0, 10.0, 10.0),
    rotation: vec3.Vec3(0.0, 0.0, 0.0),
    scale: vec3.Vec3(1.0, 1.0, 1.0),
  ),
)

PointLight - Radiates from a point:

let assert Ok(lamp) = light.point(
  intensity: 1.0,
  color: 0xff6b6b,
  distance: 50.0,
)

scene.Light(
  id: "lamp",
  light: lamp,
  transform: transform.Transform(
    position: vec3.Vec3(0.0, 5.0, 0.0),
    rotation: vec3.Vec3(0.0, 0.0, 0.0),
    scale: vec3.Vec3(1.0, 1.0, 1.0),
  ),
)

SpotLight - Cone of light:

let assert Ok(spotlight) = light.spot(
  intensity: 1.0,
  color: 0xffffff,
  distance: 100.0,
  angle: 0.5,      // Radians
  penumbra: 0.2,   // Soft edge
)

scene.Light(
  id: "spotlight",
  light: spotlight,
  transform: transform.Transform(
    position: vec3.Vec3(0.0, 10.0, 0.0),
    rotation: vec3.Vec3(-1.57, 0.0, 0.0),  // Point down
    scale: vec3.Vec3(1.0, 1.0, 1.0),
  ),
)

HemisphereLight - Sky + ground colors:

let assert Ok(hemi) = light.hemisphere(
  intensity: 0.6,
  sky_color: 0x0077ff,
  ground_color: 0x553311,
)

scene.Light(
  id: "hemi",
  light: hemi,
  transform: transform.identity,
)

Cameras

Perspective Camera (3D games):

import tiramisu/camera

// Aspect ratio is calculated automatically from viewport/window dimensions
let assert Ok(cam) = camera.perspective(
  field_of_view: 75.0,
  near: 0.1,
  far: 1000.0,
)

scene.Camera(
  id: "main",
  camera: cam,
  transform: transform.at(position: vec3.Vec3(0.0, 5.0, 10.0)),
  look_at: option.Some(vec3.Vec3(0.0, 0.0, 0.0)),  // Point camera at origin
  active: True,  // This camera is used for rendering
  viewport: option.None,
)

Orthographic Camera (2D games, isometric):

let assert Ok(cam) = camera.orthographic(
  left: -10.0,
  right: 10.0,
  top: 10.0,
  bottom: -10.0,
  near: 0.1,
  far: 1000.0,
)

scene.Camera(
  id: "2d_cam",
  camera: cam,
  transform: transform.identity,
  active: True,
  viewport: option.None,
)

2D Helper (orthographic camera for pixel-perfect 2D):

// Creates orthographic camera with automatic aspect ratio
let cam = camera.camera_2d(width: 800, height: 600)

scene.Camera(
  id: "2d",
  camera: cam,
  transform: transform.at(position: vec3.Vec3(0.0, 0.0, 5.0)),  // Distance from scene
  active: True,
  viewport: option.None,
)

Multiple Cameras (picture-in-picture):

let assert Ok(main_cam) = camera.perspective(
  field_of_view: 75.0,
  near: 0.1,
  far: 1000.0,
)

let assert Ok(minimap_cam) = camera.perspective(
  field_of_view: 60.0,
  near: 0.1,
  far: 1000.0,
)

[
  // Main camera (full screen)
  scene.Camera(
    id: "main",
    camera: main_cam,
    transform: transform.identity,
    look_at: option.None,
    active: True,
    viewport: option.None,
  ),
  // Mini-map camera (top-right corner)
  scene.Camera(
    id: "minimap",
    camera: minimap_cam,
    transform: transform.at(position: vec3.Vec3(0.0, 100.0, 0.0)),
    look_at: option.Some(vec3.Vec3(0.0, 0.0, 0.0)),
    active: False,
    viewport: option.Some(#(800, 450, 200, 150)),  // x, y, width, height
  ),
]

Hierarchy with Groups

Group nodes contain children, creating parent-child relationships:

let assert Ok(body_geo) = geometry.box(width: 1.0, height: 2.0, depth: 1.0)
let assert Ok(body_mat) =
  material.new()
  |> material.with_color(0x4ecdc4)
  |> material.build()

let assert Ok(weapon_geo) = geometry.box(width: 0.2, height: 0.2, depth: 1.5)
let assert Ok(weapon_mat) =
  material.new()
  |> material.with_color(0x888888)
  |> material.build()

let assert Ok(healthbar_geo) = geometry.plane(width: 1.0, height: 0.1)
let assert Ok(healthbar_mat) = material.basic(
  color: 0x00ff00,
  transparent: False,
  opacity: 1.0,
  map: option.None,
)

scene.Group(
  id: "player",
  transform: player_transform,
  children: [
    // Body mesh
    scene.Mesh(
      id: "player_body",
      geometry: body_geo,
      material: body_mat,
      transform: transform.identity,  // Relative to parent
      physics: option.None,
    ),
    // Weapon (attached to player)
    scene.Mesh(
      id: "weapon",
      geometry: weapon_geo,
      material: weapon_mat,
      transform: transform.Transform(
        position: vec3.Vec3(0.5, 0.5, 0.0),  // Offset from player center
        rotation: vec3.Vec3(0.0, 0.0, 0.0),
        scale: vec3.Vec3(1.0, 1.0, 1.0),
      ),
      physics: option.None,
    ),
    // Health bar (UI element)
    scene.Mesh(
      id: "health_bar",
      geometry: healthbar_geo,
      material: healthbar_mat,
      transform: transform.Transform(
        position: vec3.Vec3(0.0, 1.5, 0.0),  // Above player
        rotation: vec3.Vec3(0.0, 0.0, 0.0),
        scale: vec3.Vec3(model.player_health /. 100.0, 1.0, 1.0),
      ),
      physics: option.None,
    ),
  ],
)

Benefits of hierarchy:

Transforms

Every node has a transform (position, rotation, scale):

import tiramisu/transform

// Identity (default: origin, no rotation, scale 1)
transform.identity

// Position only
transform.at(position: vec3.Vec3(10.0, 0.0, 5.0))

// Full transform
transform.Transform(
  position: vec3.Vec3(5.0, 2.0, -3.0),
  rotation: vec3.Vec3(0.0, 1.57, 0.0),  // Radians (90° on Y axis)
  scale: vec3.Vec3(2.0, 2.0, 2.0),      // 2x larger
)

Rotation:

Common rotations:

// Quarter turn clockwise (90°)
vec3.Vec3(0.0, 1.5708, 0.0)

// Half turn (180°)
vec3.Vec3(0.0, 3.1416, 0.0)

// Face down
vec3.Vec3(-1.5708, 0.0, 0.0)

Instanced Rendering

For rendering many identical objects efficiently:

// Create 1000 transform instances
let transforms = list.range(0, 999)
  |> list.map(fn(i) {
    let fi = int.to_float(i)
    transform.Transform(
      position: vec3.Vec3(fi *. 2.0, 0.0, 0.0),
      rotation: vec3.Vec3(0.0, fi *. 0.1, 0.0),
      scale: vec3.Vec3(1.0, 1.0, 1.0),
    )
  })

let assert Ok(tree_geo) = geometry.cylinder(
  radius_top: 0.5,
  radius_bottom: 0.5,
  height: 3.0,
  radial_segments: 8,
)

let assert Ok(tree_mat) =
  material.new()
  |> material.with_color(0x8b4513)
  |> material.build()

scene.InstancedMesh(
  id: "trees",
  geometry: tree_geo,
  material: tree_mat,
  instances: transforms,  // All instances in 1 draw call!
)

Performance:

Level-of-Detail (LOD)

Automatically switch detail based on distance:

// Assume these are loaded or created elsewhere
let complex_geometry = ...  // 10,000 triangles
let medium_geometry = ...   // 2,000 triangles
let low_geometry = ...      // 500 triangles

let assert Ok(detailed_material) =
  material.new()
  |> material.with_color(0x808080)
  |> material.build()

let assert Ok(simple_material) =
  material.new()
  |> material.with_color(0x808080)
  |> material.build()

let assert Ok(billboard_geo) = geometry.plane(width: 10.0, height: 15.0)
let assert Ok(billboard_mat) = material.basic(
  color: 0x808080,
  transparent: True,
  opacity: 0.8,
  map: option.Some(building_texture),
)

scene.LOD(
  id: "building",
  transform: transform.at(position: vec3.Vec3(100.0, 0.0, 50.0)),
  levels: [
    // High detail (0-50 units)
    scene.lod_level(
      distance: 0.0,
      node: scene.Mesh(
        id: "building_high",
        geometry: complex_geometry,
        material: detailed_material,
        transform: transform.identity,
        physics: option.None,
      ),
    ),
    // Medium detail (50-150 units)
    scene.lod_level(
      distance: 50.0,
      node: scene.Mesh(
        id: "building_medium",
        geometry: medium_geometry,
        material: simple_material,
        transform: transform.identity,
        physics: option.None,
      ),
    ),
    // Low detail (150-500 units)
    scene.lod_level(
      distance: 150.0,
      node: scene.Mesh(
        id: "building_low",
        geometry: low_geometry,
        material: simple_material,
        transform: transform.identity,
        physics: option.None,
      ),
    ),
    // Billboard (500+ units)
    scene.lod_level(
      distance: 500.0,
      node: scene.Mesh(
        id: "building_billboard",
        geometry: billboard_geo,
        material: billboard_mat,
        transform: transform.identity,
        physics: option.None,
      ),
    ),
  ],
)

Scene Diffing

Tiramisu automatically detects changes between frames:

What Triggers Updates?

Node removed:

// Frame 1
[mesh1, mesh2, mesh3]

// Frame 2 (mesh2 removed)
[mesh1, mesh3]

// Patch: RemoveNode("mesh2")

Node added:

// Frame 1
[mesh1, mesh2]

// Frame 2 (mesh3 added)
[mesh1, mesh2, mesh3]

// Patch: AddNode("mesh3", ...)

Transform changed:

// Frame 1
scene.Mesh(
  id: "player",
  transform: transform.at(position: vec3.Vec3(0.0, 0.0, 0.0)),
  ...
)

// Frame 2
scene.Mesh(
  id: "player",
  transform: transform.at(position: vec3.Vec3(1.0, 0.0, 0.0)),  // Moved!
  ...
)

// Patch: UpdateTransform("player", new_transform)

Material changed:

// Patch: UpdateMaterial("cube", new_material)

Geometry changed:

// Patch: UpdateGeometry("shape", new_geometry)

Optimization: Unchanged Nodes

If a node is identical between frames, no patch is generated:

// Both frames have identical mesh
let static_mesh = scene.Mesh(
  id: "wall",
  geometry: scene.BoxGeometry(10.0, 5.0, 1.0),
  material: scene.StandardMaterial(color: 0x808080, ...),
  transform: transform.identity,
  physics: option.None,
)

// Frame 1
[static_mesh, dynamic_mesh1]

// Frame 2
[static_mesh, dynamic_mesh2]

// Only dynamic_mesh changed - static_mesh generates no patch!

Best Practices

1. Use Meaningful IDs

// ❌ Bad: String ids with ambiguous ids
id: "mesh1"

// ✅ Good: Typed Ids with descriptive names
pub type Id {
  PlayerBody
  EnemyGoblin(goblin_index: Int)
  Ground
}
id: PlayerBody
id: EnemyGoblin(10)
id: Ground

2. Keep Hierarchies Shallow

// ❌ Bad: Deep nesting (slow)
Group(
  "root",
  children: [
    Group("level1", children: [
      Group("level2", children: [
        Group("level3", children: [mesh])
      ])
    ])
  ]
)

// ✅ Good: Flat or 2-3 levels max
Group("player", children: [body, weapon, ui])

3. Use InstancedMesh for Repeated Objects

// ❌ Bad: 100 separate meshes
list.range(0, 99)
  |> list.map(fn(i) {
    scene.Mesh(id: "coin_" <> int.to_string(i), ...)
  })

// ✅ Good: 1 instanced mesh
scene.InstancedMesh(id: "coins", instances: coin_transforms)

5. Minimize Material/Geometry Variety

// ❌ Bad: Different material for each
list.map(enemies, fn(e) {
  scene.Mesh(material: scene.StandardMaterial(color: e.color, ...), ...)
})

// ✅ Good: Same material, use instances
scene.InstancedMesh(
  material: scene.StandardMaterial(color: 0xff0000, ...),
  instances: enemy_transforms,
)

Debug Visualization

Tiramisu provides debug nodes for visualization:

import tiramisu/debug

// Box (AABB)
scene.DebugBox(
  id: "collision_box",
  min: vec3.Vec3(-1.0, 0.0, -1.0),
  max: vec3.Vec3(1.0, 2.0, 1.0),
  color: 0x00ff00,
)

// Sphere
scene.DebugSphere(
  id: "trigger_zone",
  center: vec3.Vec3(0.0, 0.0, 0.0),
  radius: 5.0,
  color: 0xff0000,
)

// Line
scene.DebugLine(
  id: "raycast",
  from: vec3.Vec3(0.0, 1.0, 0.0),
  to: vec3.Vec3(0.0, 1.0, 10.0),
  color: 0x0000ff,
)

// Axes (X=red, Y=green, Z=blue)
scene.DebugAxes(
  id: "world_origin",
  origin: vec3.Vec3(0.0, 0.0, 0.0),
  size: 5.0,
)

// Grid
scene.DebugGrid(
  id: "ground_grid",
  size: 100.0,
  divisions: 10,
  color: 0x444444,
)

// Point
scene.DebugPoint(
  id: "spawn_point",
  position: vec3.Vec3(10.0, 0.0, 5.0),
  size: 0.5,
  color: 0xffff00,
)

Summary

Key concepts:

Search Document