tiramisu/scene
Declarative scene graph for building 3D scenes.
This module provides the scene node types returned by your view function. The engine
automatically diffs and patches the Three.js scene to match your declarative description,
similar to how virtual DOM works in web frameworks.
Scene Structure
Your view function returns a single root Node. Use scene.empty to group multiple
nodes together:
fn view(model: Model, ctx: Context) -> scene.Node {
scene.empty(
id: "root",
transform: transform.identity,
children: [
camera_node,
player_mesh,
enemy_mesh,
light_node,
],
)
}
Node Types
empty: Invisible grouping node for organizationmesh: 3D object with geometry and materialsprite: 2D billboard that always faces cameracamera: Viewpoint with projection settingslight: Ambient, directional, point, or spot lightaudio: Global or positional audio sourcemodel: Loaded GLTF/GLB 3D model with animationsLOD: Level-of-detail with automatic switching
String IDs
All nodes require a unique string ID for efficient diffing:
scene.mesh(id: "player", ...)
scene.mesh(id: "enemy-" <> int.to_string(idx), ...)
Physics
Attach physics bodies to meshes for simulation:
scene.mesh(
id: "ball",
geometry: sphere_geo,
material: ball_mat,
transform: transform.identity,
physics: Some(
physics.new_rigid_body(physics.Dynamic)
|> physics.with_collider(physics.Sphere(transform.identity, 1.0))
|> physics.build()
),
)
Types
Level of Detail (LOD) configuration.
Defines which mesh to display based on camera distance. Use with LOD scene node
for automatic detail switching to improve performance.
Example
scene.LOD(
id: "tree",
levels: [
scene.lod_level(distance: 0.0, node: high_detail_mesh), // 0-50 units
scene.lod_level(distance: 50.0, node: medium_detail_mesh), // 50-100 units
scene.lod_level(distance: 100.0, node: low_detail_mesh), // 100+ units
],
transform: transform.identity,
)
pub type LODLevel {
LODLevel(distance: Float, node: Node)
}
Constructors
-
LODLevel(distance: Float, node: Node)
Opaque type for the WebGL renderer
pub type Renderer =
savoiardi.Renderer
Values
pub fn animated_sprite(
id id: String,
sprite sprite: spritesheet.Sprite,
size size: vec2.Vec2(Float),
transform transform: transform.Transform,
physics physics: option.Option(physics.RigidBody),
) -> Node
Create an animated sprite node with spritesheet animation.
Animated sprites display a textured plane that cycles through frames from a spritesheet. The animation is managed by an AnimationMachine which handles frame advancement and animation state transitions.
Example
import gleam/option
import gleam/result
import gleam/time/duration
import tiramisu/scene
import tiramisu/spritesheet
import vec/vec2
// In your init()
let assert Ok(machine) =
spritesheet.new(texture: player_texture, columns: 8, rows: 4)
|> result.map(spritesheet.with_animation(
_,
name: "idle",
frames: [0, 1, 2, 3],
frame_duration: duration.milliseconds(100),
loop: spritesheet.Repeat,
))
|> result.map(spritesheet.with_pixel_art(_, True))
let model = Model(machine: machine, ..)
// In your update()
fn update(model, msg, ctx) {
case msg {
Tick -> {
let #(new_machine, _) =
spritesheet.update(model.machine, model.context, ctx.delta_time)
Model(..model, machine: new_machine)
}
}
}
// In your view()
fn view(model, _ctx) {
[
scene.animated_sprite(
id: "player",
sprite: spritesheet.to_sprite(model.machine),
size: vec2.Vec2(2.0, 2.0),
transform: transform.identity,
physics: option.None,
),
]
}
pub fn audio(id id: String, audio audio: audio.Audio) -> Node
Create an audio scene node.
Audio nodes play sounds in the scene. See the audio module for creating audio sources.
Example
import tiramisu/scene
import tiramisu/audio
import gleam/option
// Background music
let background_music = audio.new_audio("background")
|> audio.with_source(audio.Stream("music/theme.mp3"))
|> audio.with_loop(True)
|> audio.with_volume(0.5)
|> audio.with_autoplay(True)
scene.audio(id: "bgm", audio: background_music, children: [])
// Sound effect (from pre-loaded buffer)
let assert Ok(jump_buffer) = asset.get_audio(cache, "sounds/jump.mp3")
let jump_sound = audio.new_audio("jump")
|> audio.with_source(audio.Buffer(jump_buffer))
|> audio.with_volume(0.8)
scene.audio(id: "jump-sfx", audio: jump_sound, children: [])
pub fn camera(
id id: String,
camera camera: camera.Camera,
transform transform: transform.Transform,
active active: Bool,
viewport viewport: option.Option(camera.ViewPort),
postprocessing postprocessing: option.Option(
camera.PostProcessing,
),
) -> Node
Create a camera scene node.
Cameras define the viewpoint for rendering. At least one active camera is required. Multiple cameras can be used for split-screen, minimaps, or picture-in-picture.
Active: Only active cameras render. Set to True for at least one camera.
Look At: Optional target point the camera faces (camera auto-rotates to face it).
Viewport: Optional screen region for this camera (for split-screen).
Example
import tiramisu/scene
import tiramisu/camera
import tiramisu/transform
import vec/vec3
import gleam/option
// Main perspective camera
let assert Ok(cam) = camera.perspective(field_of_view: 75.0, near: 0.1, far: 1000.0)
scene.camera(
id: "main-camera",
camera: cam,
transform: transform.at(position: vec3.Vec3(0.0, 5.0, 10.0)),
active: True,
viewport: option.None, // Fullscreen
)
// Minimap camera (top-down view in corner)
let assert Ok(minimap_cam) = camera.orthographic(
left: -20.0, right: 20.0, top: 20.0, bottom: -20.0, near: 0.1, far: 100.0
)
scene.camera(
id: "minimap",
camera: minimap_cam,
transform: transform.at(position: vec3.Vec3(0.0, 50.0, 0.0))
|> transform.with_euler_rotation(vec3.Vec3(-1.57, 0.0, 0.0)),
active: True,
viewport: option.Some(camera.ViewPort(position: vec2.Vec2(10, 10), size: vec2.Vec2(200, 200))),
)
pub fn canvas(
id id: String,
picture picture: paint.Picture,
texture_size texture_size: vec2.Vec2(Int),
size size: vec2.Vec2(Float),
transform transform: transform.Transform,
) -> Node
Create a canvas node with a paint.Picture drawing rendered to a texture.
Canvas nodes are Three.js planes with paint.Picture drawings rendered to canvas textures. Unlike CSS2D/CSS3D, they are true 3D meshes that respect depth testing (hide behind objects).
Uses the paint library for canvas drawing operations.
Picture: A paint.Picture created using paint’s drawing API Texture Size: Canvas texture resolution in pixels as Vec2(width, height) (higher = sharper but more memory) Size: World space size of the canvas plane as Vec2(width, height) Transform: Position, rotation, scale
Example
import tiramisu/scene
import tiramisu/transform
import vec/vec2
import vec/vec3
import paint as p
// Create health bar using paint
let health_bar = p.combine([
// Background
p.rectangle(256.0, 64.0)
|> p.fill(p.colour_rgb(0, 0, 0)),
// Health bar
p.rectangle(192.0, 20.0)
|> p.translate_xy(10.0, 22.0)
|> p.fill(p.colour_rgb(255, 0, 0)),
// Text
p.text("HP: 75/100", 14.0)
|> p.translate_xy(10.0, 50.0)
|> p.fill(p.colour_rgb(255, 255, 255)),
])
scene.canvas(
id: "health",
picture: health_bar,
texture_size: vec2.Vec2(256, 64),
size: vec2.Vec2(2.0, 0.5),
transform: transform.at(position: vec3.Vec3(0.0, 2.0, 0.0)),
)
pub fn css2d(
id id: String,
html html: String,
transform transform: transform.Transform,
) -> Node
Create a CSS2D label that follows a 3D position in screen space.
CSS2D labels are HTML elements that follow 3D objects but always face the camera. Perfect for health bars, nameplates, tooltips, or interactive UI elements.
HTML: Raw HTML string. Use Lustre’s element.to_string() for type-safe HTML.
Position: Offset from parent object (or world position if top-level node).
Example
import tiramisu/scene
import vec/vec3
import lustre/element/html
import lustre/element
import lustre/attribute
// Option 1: Using Lustre (recommended)
let hp_element = html.div([attribute.class("bg-red-500 text-white px-4 py-2")], [
html.text("HP: 100")
])
scene.css2d(
id: "player-hp",
html: element.to_string(hp_element),
position: vec3.Vec3(0.0, 2.0, 0.0),
)
// Option 2: Raw HTML string
scene.css2d(
id: "player-name",
html: "<div class='text-white font-bold'>Player</div>",
transform: vec3.Vec3(0.0, 2.5, 0.0),
)
pub fn css3d(
id id: String,
html html: String,
transform transform: transform.Transform,
) -> Node
Create a CSS3D label that respects 3D depth and occlusion.
CSS3D labels are HTML elements that live “in” 3D space with full transformations. Unlike CSS2D labels (always on top), CSS3D labels hide behind objects and can rotate in 3D. Great for immersive UI elements.
HTML: Raw HTML string. Use Lustre’s element.to_string() for type-safe HTML.
Position: Offset from parent object (or world position if top-level node).
Example
import tiramisu/scene
import vec/vec3
// Label that hides behind objects
scene.css3d(
id: "3d-sign",
html: "<div class='text-white text-2xl'>→ Exit</div>",
transform: vec3.Vec3(0.0, 2.0, 0.0),
)
pub fn debug_axes(
id id: String,
origin origin: vec3.Vec3(Float),
size size: Float,
) -> Node
Create a debug coordinate axes visualization.
Displays X (red), Y (green), and Z (blue) axes from the origin point. Useful for visualizing object orientation, camera position, or world origin.
Origin: Center point in world space. Size: Length of each axis line in units.
Example
// Show world origin
scene.debug_axes(
id: "world_axes",
origin: vec3.Vec3(0.0, 0.0, 0.0),
size: 5.0, // 5 unit length axes
)
// Show object local axes
scene.debug_axes(
id: "player_axes",
origin: player_position,
size: 2.0,
)
pub fn debug_box(
id id: String,
min min: vec3.Vec3(Float),
max max: vec3.Vec3(Float),
color color: Int,
) -> Node
Create a debug wireframe box visualization.
Useful for visualizing collision bounds, trigger zones, or spatial regions.
Min/Max: Define the axis-aligned bounding box corners in world space. Color: Hex color for the wireframe lines.
Example
// Visualize a collision box
scene.debug_box(
id: "trigger_zone",
min: vec3.Vec3(-5.0, 0.0, -5.0),
max: vec3.Vec3(5.0, 3.0, 5.0),
color: 0x00ff00, // Green wireframe
)
pub fn debug_grid(
id id: String,
size size: Float,
divisions divisions: Int,
color color: Int,
) -> Node
Create a debug ground grid visualization.
Displays a grid on the XZ plane (horizontal ground plane) centered at origin. Useful for spatial reference, scale indication, or level design.
Size: Total width/depth of the grid in units. Divisions: Number of grid cells (higher = finer grid). Color: Hex color for the grid lines.
Example
// Create a 20x20 unit grid with 10 divisions
scene.debug_grid(
id: "ground_grid",
size: 20.0, // 20 units wide
divisions: 10, // 10x10 cells (2 units per cell)
color: 0x444444, // Dark gray
)
pub fn debug_line(
id id: String,
from from: vec3.Vec3(Float),
to to: vec3.Vec3(Float),
color color: Int,
) -> Node
Create a debug line segment visualization.
Useful for visualizing raycasts, trajectories, connections, or directions.
From/To: Start and end points in world space. Color: Hex color for the line.
Example
// Visualize raycast from player to target
scene.debug_line(
id: "raycast",
from: player_position,
to: target_position,
color: 0xffff00, // Yellow line
children: [],
)
pub fn debug_point(
id id: String,
position position: vec3.Vec3(Float),
size size: Float,
color color: Int,
) -> Node
Create a debug point visualization.
Displays a small sphere at the specified position. Useful for marking locations, waypoints, spawn points, or intersection points.
Position: Point location in world space. Size: Radius of the debug sphere in units (typically small like 0.1-0.5). Color: Hex color for the sphere.
Example
// Mark spawn points
scene.debug_point(
id: "spawn_1",
position: vec3.Vec3(10.0, 0.0, 5.0),
size: 0.3, // Small sphere
color: 0x00ff00, // Green
)
// Mark raycast hit point
scene.debug_point(
id: "hit_point",
position: raycast_result.point,
size: 0.2,
color: 0xff0000, // Red
)
pub fn debug_sphere(
id id: String,
center center: vec3.Vec3(Float),
radius radius: Float,
color color: Int,
) -> Node
Create a debug wireframe sphere visualization.
Useful for visualizing sphere colliders, range indicators, or explosion radii.
Center: Center position in world space. Radius: Sphere radius (should match your collider if visualizing physics). Color: Hex color for the wireframe lines.
Example
// Visualize attack range
scene.debug_sphere(
id: "attack_range",
center: player_position,
radius: 5.0, // 5 unit attack radius
color: 0xff0000, // Red wireframe
children: [],
)
pub fn empty(
id id: String,
transform transform: transform.Transform,
children children: List(Node),
) -> Node
Create an empty node for organization, pivot points, or grouping.
Empty nodes don’t render anything but are useful for organizing your scene hierarchy, creating pivot points for rotation/animation, or grouping related objects.
This replaces the old group function with clearer intent.
Example
import tiramisu/scene
import tiramisu/transform
// Group car parts together
scene.empty(
id: "car",
transform: car_transform,
children: [
scene.mesh(id: "body", ..., children: []),
scene.mesh(id: "wheel-fl", ..., children: []),
scene.mesh(id: "wheel-fr", ..., children: []),
],
)
pub fn instanced_mesh(
id id: String,
geometry geometry: geometry.Geometry,
material material: material.Material,
instances instances: List(transform.Transform),
) -> Node
Create an instanced mesh for rendering many identical objects efficiently.
Instead of creating N separate meshes (N draw calls), instanced meshes render all instances in a single draw call. Perfect for forests, crowds, particles, or any scene with many repeated objects.
Performance: Use this when you have 10+ identical objects for significant speedup.
Example
import tiramisu/scene
import tiramisu/geometry
import tiramisu/material
import tiramisu/transform
import vec/vec3
import gleam/list
// Create 100 trees efficiently
let assert Ok(tree_geo) = geometry.cylinder(radius: 0.2, height: 3.0)
let assert Ok(tree_mat) = material.lambert(
color: 0x8b4513,
map: option.None,
normal_map: option.None,
ambient_oclusion_map: option.None,
)
let tree_positions = list.range(0, 99)
|> list.map(fn(i) {
let x = int.to_float(i % 10) *. 5.0
let z = int.to_float(i / 10) *. 5.0
transform.at(position: vec3.Vec3(x, 0.0, z))
})
scene.InstancedMesh(
id: "forest",
geometry: tree_geo,
material: tree_mat,
instances: tree_positions, // All rendered in 1 draw call!
)
pub fn instanced_model(
id id: String,
object object: savoiardi.Object3D,
instances instances: List(transform.Transform),
physics physics: option.Option(physics.RigidBody),
material material: option.Option(material.Material),
transparent transparent: Bool,
) -> Node
Create instanced 3D models for rendering many copies of a loaded asset.
Like InstancedMesh, but for loaded models (GLTF/FBX/OBJ). Renders all instances
in one draw call for maximum performance.
Example
import tiramisu/scene
import tiramisu/model
import tiramisu/transform
import vec/vec3
import gleam/option
import gleam/list
// After loading a GLTF model
let rock_scene = model.get_scene(rock_gltf)
// Place 50 rocks around the scene
let rock_positions = list.range(0, 49)
|> list.map(fn(i) {
let angle = int.to_float(i) *. 0.125
let radius = 20.0
let x = radius *. float.cos(angle)
let z = radius *. float.sin(angle)
transform.at(position: vec3.Vec3(x, 0.0, z))
})
scene.instanced_model(
id: "rock-field",
object: rock_scene,
instances: rock_positions,
physics: option.None,
material: option.None,
)
pub fn light(
id id: String,
light light: light.Light,
transform transform: transform.Transform,
) -> Node
Create a light scene node.
Lights illuminate the scene. See the light module for different light types
(ambient, directional, point, spot, hemisphere).
Example
import tiramisu/scene
import tiramisu/light
import tiramisu/transform
import vec/vec3
// Directional sun light
let assert Ok(sun) = light.directional(intensity: 1.2, color: 0xffffff)
|> light.with_shadows(True)
scene.light(
id: "sun",
light: sun,
transform: transform.identity
|> transform.with_euler_rotation(vec3.Vec3(-0.8, 0.3, 0.0)),
)
pub fn lod(
id id: String,
levels levels: List(LODLevel),
transform transform: transform.Transform,
) -> Node
Create a Level of Detail (LOD) node.
LOD nodes automatically switch between different detail levels based on camera distance, improving performance by showing simpler models when far away.
Levels: Ordered list from closest (distance: 0.0) to farthest. Use lod_level() to create.
Example
import tiramisu/scene
import tiramisu/geometry
import tiramisu/material
import tiramisu/transform
import gleam/option
// High detail mesh (shown up close)
let assert Ok(high_geo) = geometry.sphere(radius: 1.0, width_segments: 32, height_segments: 32)
let assert Ok(mat) = material.new() |> material.with_color(0x00ff00) |> material.build()
let high_detail = scene.mesh(
id: "tree-high",
geometry: high_geo,
material: mat,
transform: transform.identity,
physics: option.None,
)
// Low detail mesh (shown far away)
let assert Ok(low_geo) = geometry.sphere(radius: 1.0, width_segments: 8, height_segments: 8)
let low_detail = scene.mesh(
id: "tree-low",
geometry: low_geo,
material: mat,
transform: transform.identity,
physics: option.None,
)
scene.lod(
id: "optimized-tree",
levels: [
scene.lod_level(distance: 0.0, node: high_detail), // 0-50 units away
scene.lod_level(distance: 50.0, node: low_detail), // 50+ units away
],
transform: transform.identity,
)
pub fn lod_level(
distance distance: Float,
node node: Node,
) -> LODLevel
Create an LOD level with a distance threshold and scene node.
Levels should be ordered from closest (distance: 0.0) to farthest.
Example
let high_detail = scene.lod_level(distance: 0.0, node: detailed_mesh)
let low_detail = scene.lod_level(distance: 100.0, node: simple_mesh)
pub fn mesh(
id id: String,
geometry geometry: geometry.Geometry,
material material: material.Material,
transform transform: transform.Transform,
physics physics: option.Option(physics.RigidBody),
) -> Node
Create a mesh scene node.
Meshes are the basic building blocks for 3D objects. They combine geometry (shape), material (appearance), and transform (position/rotation/scale).
Physics: Optional rigid body for physics simulation.
Example
import tiramisu/scene
import tiramisu/geometry
import tiramisu/material
import tiramisu/transform
import gleam/option
import vec/vec3
// Create a red cube
let assert Ok(cube_geo) = geometry.box(width: 1.0, height: 1.0, depth: 1.0)
let assert Ok(red_mat) = material.new()
|> material.with_color(0xff0000)
|> material.build()
scene.mesh(
id: "player",
geometry: cube_geo,
material: red_mat,
transform: transform.at(position: vec3.Vec3(0.0, 1.0, 0.0)),
physics: option.None,
children: [],
)
pub fn object_3d(
id id: String,
object object: savoiardi.Object3D,
transform transform: transform.Transform,
animation animation: option.Option(model.AnimationPlayback),
physics physics: option.Option(physics.RigidBody),
material material: option.Option(material.Material),
transparent transparent: Bool,
) -> Node
Create a 3D model node from a loaded asset (GLTF, FBX, OBJ).
Use this for models loaded via the model module. Supports animations and physics.
Animation: Optional animation playback (single or blended). See animation module.
Physics: Optional rigid body for physics simulation.
Example
import tiramisu/scene
import tiramisu/model
import tiramisu/transform
import vec/vec3
import gleam/option
import gleam/list
// After loading a GLTF model
let scene_object = model.get_scene(gltf_data)
let clips = model.get_animations(gltf_data)
// Find walk animation
let walk_clip = list.find(clips, fn(clip) {
model.clip_name(clip) == "Walk"
})
let walk_anim = model.new_animation(walk_clip)
|> model.set_speed(1.2)
|> model.set_loop(model.LoopRepeat)
scene.object_3d(
id: "player",
object: scene_object,
transform: transform.at(position: vec3.Vec3(0.0, 0.0, 0.0)),
animation: option.Some(model.SingleAnimation(walk_anim)),
physics: option.None,
material: option.None,
)