tiramisu/scene
Scene graph module - declarative 3D scene construction.
This module provides types and functions for building 3D scenes declaratively.
Scenes are composed of SceneNode values that describe meshes, lights, cameras, and groups.
Core Concepts
- Immutability: Scene nodes are immutable values. Updates create new nodes.
- Hierarchy: Use
Groupnodes to create parent-child relationships. - Validation: Geometry and material constructors return
Resultto catch invalid parameters. - Performance: Use
InstancedMeshfor many identical objects (1 draw call instead of thousands).
Quick Example
import tiramisu/scene
import tiramisu/transform
import gleam/option
import vec/vec3
pub fn view(model: Model) {
let assert Ok(geometry) = scene.box(width: 1.0, height: 1.0, depth: 1.0)
let assert Ok(material) = scene.basic_material(color: 0xff0000, transparent: False, opacity: 1.0)
[
scene.Mesh(
id: "player",
geometry: geometry,
material: material,
transform: transform.at(vec3.Vec3(0.0, 1.0, 0.0)),
physics: option.None,
),
scene.Light(
id: "sun",
light: scene.DirectionalLight(color: 0xffffff, intensity: 1.0),
transform: transform.identity,
),
]
}
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(id) {
LODLevel(distance: Float, node: Node(id))
}
Constructors
-
LODLevel(distance: Float, node: Node(id))
Viewport configuration for split-screen or picture-in-picture rendering.
Coordinates are in pixels from the top-left of the canvas.
pub type ViewPort {
ViewPort(x: Int, y: Int, width: Int, height: Int)
}
Constructors
-
ViewPort(x: Int, y: Int, width: Int, height: Int)Arguments
- x
-
X position from left edge in pixels
- y
-
Y position from top edge in pixels
- width
-
Width in pixels
- height
-
Height in pixels
Values
pub fn animated_sprite(
id id: id,
spritesheet spritesheet: spritesheet.Spritesheet,
animation animation: spritesheet.Animation,
state state: spritesheet.AnimationState,
width width: Float,
height height: Float,
transform transform: transform.Transform,
pixel_art pixel_art: Bool,
) -> Node(id)
Create an animated sprite node with spritesheet animation.
Animated sprites display a textured plane that cycles through frames from a spritesheet. The animation state is managed in your model and updated each frame.
Parameters
id: Unique identifier for this spritespritesheet: The spritesheet definitionanimation: The animation sequence to playstate: Current animation state (from your model)width: World space width of the sprite planeheight: World space height of the sprite planetransform: Position, rotation, and scalepixel_art: If True, uses nearest-neighbor filtering for crisp pixels
Example
import iv
import tiramisu/scene
import tiramisu/spritesheet
// In your init()
let assert Ok(sheet) = spritesheet.from_grid(
texture: player_texture,
columns: 8,
rows: 1,
)
let walk_anim = spritesheet.animation(
name: "walk",
frames: iv.from_list([0, 1, 2, 3, 4, 5, 6, 7]),
frame_duration: 0.1,
loop: spritesheet.Repeat,
)
let model = Model(
player_state: spritesheet.initial_state("walk"),
// ...
)
// In your update()
fn update(model, msg, ctx) {
case msg {
Tick -> {
let new_state = spritesheet.update(
state: model.player_state,
animation: walk_anim,
delta_time: ctx.delta_time,
)
Model(..model, player_state: new_state)
}
}
}
// In your view()
fn view(model, _ctx) {
[
scene.animated_sprite(
id: Player,
spritesheet: sheet,
animation: walk_anim,
state: model.player_state,
width: 2.0,
height: 2.0,
transform: transform.at(vec3.Vec3(0.0, 0.0, 0.0)),
pixel_art: True,
),
]
}
pub fn audio(id id: id, audio audio: audio.Audio) -> Node(id)
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)
// 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)
pub fn camera(
id id: id,
camera camera: camera.Camera,
transform transform: transform.Transform,
look_at look_at: option.Option(vec3.Vec3(Float)),
active active: Bool,
viewport viewport: option.Option(ViewPort),
) -> Node(id)
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)),
look_at: option.Some(vec3.Vec3(0.0, 0.0, 0.0)), // Look at origin
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)),
look_at: option.None,
active: True,
viewport: option.Some(scene.ViewPort(x: 10, y: 10, width: 200, height: 200)),
)
pub fn canvas(
id id: id,
picture picture: paint.Picture,
texture_width texture_width: Int,
texture_height texture_height: Int,
width width: Float,
height height: Float,
transform transform: transform.Transform,
) -> Node(id)
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 Width/Height: Canvas texture resolution in pixels (higher = sharper but more memory) Width/Height: World space size of the canvas plane Transform: Position, rotation, scale
Example
import tiramisu/scene
import tiramisu/transform
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_width: 256,
texture_height: 64,
width: 2.0,
height: 0.5,
transform: transform.at(position: vec3.Vec3(0.0, 2.0, 0.0)),
)
pub fn css2d_label(
id id: id,
html html: String,
transform transform: transform.Transform,
) -> Node(id)
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_label(
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_label(
id: "player-name",
html: "<div class='text-white font-bold'>Player</div>",
position: vec3.Vec3(0.0, 2.5, 0.0),
)
pub fn css3d_label(
id id: id,
html html: String,
transform transform: transform.Transform,
) -> Node(id)
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_label(
id: "3d-sign",
html: "<div class='text-white text-2xl'>→ Exit</div>",
position: vec3.Vec3(0.0, 2.0, 0.0),
)
pub fn debug_axes(
id id: id,
origin origin: vec3.Vec3(Float),
size size: Float,
) -> Node(id)
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: id,
min min: vec3.Vec3(Float),
max max: vec3.Vec3(Float),
color color: Int,
) -> Node(id)
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: id,
size size: Float,
divisions divisions: Int,
color color: Int,
) -> Node(id)
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: id,
from from: vec3.Vec3(Float),
to to: vec3.Vec3(Float),
color color: Int,
) -> Node(id)
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
)
pub fn debug_point(
id id: id,
position position: vec3.Vec3(Float),
size size: Float,
color color: Int,
) -> Node(id)
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: id,
center center: vec3.Vec3(Float),
radius radius: Float,
color color: Int,
) -> Node(id)
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
)
pub fn group(
id id: id,
transform transform: transform.Transform,
children children: List(Node(id)),
) -> Node(id)
Create a group node for scene hierarchy.
Groups allow you to organize nodes in a parent-child hierarchy. The group’s transform is applied to all children, making it easy to move/rotate/scale multiple objects together.
Example
import tiramisu/scene
import tiramisu/transform
import vec/vec3
// Solar system: sun with orbiting planets
scene.Group(
id: "solar-system",
transform: transform.identity,
children: [
scene.Mesh(...), // Sun at center
scene.Group(
id: "earth-orbit",
transform: transform.at(position: vec3.Vec3(10.0, 0.0, 0.0))
|> transform.rotate_y(model.earth_angle),
children: [
scene.Mesh(...), // Earth
scene.Group(
id: "moon-orbit",
transform: transform.at(position: vec3.Vec3(2.0, 0.0, 0.0))
|> transform.rotate_y(model.moon_angle),
children: [scene.Mesh(...)], // Moon
),
],
),
],
)
pub fn instanced_mesh(
id id: id,
geometry geometry: geometry.Geometry,
material material: material.Material,
instances instances: List(transform.Transform),
) -> Node(id)
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: id,
object object: asset.Object3D,
instances instances: List(transform.Transform),
physics physics: option.Option(physics.RigidBody),
) -> Node(id)
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/asset
import tiramisu/transform
import vec/vec3
import gleam/option
import gleam/list
// Load rock model
let assert Ok(rock_data) = asset.get_model(cache, "rock.glb")
// 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 *. gleam_community.maths.cos(angle)
let z = radius *. gleam_community.maths.sin(angle)
transform.at(position: vec3.Vec3(x, 0.0, z))
})
scene.InstancedModel(
id: "rock-field",
object: rock_data.scene,
instances: rock_positions,
physics: option.None,
)
pub fn light(
id id: id,
light light: light.Light,
transform transform: transform.Transform,
) -> Node(id)
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: id,
levels levels: List(LODLevel(id)),
transform transform: transform.Transform,
) -> Node(id)
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(id),
) -> LODLevel(id)
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: id,
geometry geometry: geometry.Geometry,
material material: material.Material,
transform transform: transform.Transform,
physics physics: option.Option(physics.RigidBody),
) -> Node(id)
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,
)
pub fn model_3d(
id id: id,
object object: asset.Object3D,
transform transform: transform.Transform,
animation animation: option.Option(animation.AnimationPlayback),
physics physics: option.Option(physics.RigidBody),
) -> Node(id)
Create a 3D model node from a loaded asset (GLTF, FBX, OBJ).
Use this for models loaded via the asset 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/asset
import tiramisu/animation
import tiramisu/transform
import vec/vec3
import gleam/option
import gleam/list
// Load model from cache
let assert Ok(gltf_data) = asset.get_model(cache, "character.glb")
// Find walk animation
let walk_clip = list.find(gltf_data.animations, fn(clip) {
animation.clip_name(clip) == "Walk"
})
let walk_anim = animation.new_animation(walk_clip)
|> animation.set_speed(1.2)
|> animation.set_loop(animation.LoopRepeat)
scene.Model3D(
id: "player",
object: gltf_data.scene,
transform: transform.at(position: vec3.Vec3(0.0, 0.0, 0.0)),
animation: option.Some(animation.SingleAnimation(walk_anim)),
physics: option.None,
)
pub fn particles(
id id: id,
emitter emitter: particle_emitter.ParticleEmitter,
transform transform: transform.Transform,
active active: Bool,
) -> Node(id)
Create a particle emitter scene node.
Particle systems create effects like fire, smoke, sparks, or magic spells.
See the particle_emitter module for configuring emitters.
Active: Set to True to emit particles, False to pause emission.
Example
import tiramisu/scene
import tiramisu/particle_emitter as particles
import tiramisu/transform
import vec/vec3
import gleam/option
// Fire effect
let fire_emitter = particles.new()
|> particles.with_rate(50.0) // 50 particles per second
|> particles.with_lifetime(1.0, 2.0) // Live 1-2 seconds
|> particles.with_initial_velocity(vec3.Vec3(0.0, 3.0, 0.0))
|> particles.with_velocity_randomness(vec3.Vec3(0.5, 0.5, 0.5))
|> particles.with_size(0.5, 0.1) // Start large, end small
|> particles.with_color(0xff4500, 0xff0000) // Orange to red
|> particles.with_opacity(1.0, 0.0) // Fade out
scene.Particles(
id: "campfire",
emitter: fire_emitter,
transform: transform.at(position: vec3.Vec3(0.0, 0.0, 0.0)),
active: True,
)