Creating Campfire Animation
Campfire - 3D Voxel Animation Learning Example
This guide walks you through how to generate a looping 3D voxel animation of a campfire using SpatialStudio.
The script creates a realistic campfire with flickering flames, glowing embers, and crackling logs inside a cubic 3D space, then saves the animation to a .splv
file.
What this script does
- Creates a 3D scene of size 128×128×128
- Spawns a campfire with:
- A ring of brown wooden logs
- Dancing orange and red flames
- Floating yellow embers and sparks
- A bed of glowing coals at the base
- Animates the flames flickering and embers rising for 8 seconds at 30 FPS
- Outputs the file
campfire.splv
that you can play in your viewer
How it works (simplified)
-
Voxel volume Each frame is a 3D grid filled with RGBA values (
SIZE × SIZE × SIZE × 4
). -
Log ring Brown cylindrical logs are arranged in a circle as the campfire base.
-
Flames Orange and red flames are drawn as elongated shapes with noise-based flickering motion.
-
Embers Small yellow and orange particles float upward with random drift patterns.
-
Coals Dark red glowing voxels form the fire bed between the logs.
-
Animation loop A normalized time variable
t
cycles from0 → 2π
, making flames dance and embers rise continuously. -
Encoding Frames are passed into
splv.Encoder
, which writes them into the.splv
video file.
Try it yourself
Install requirements first:
pip install spatialstudio numpy tqdm
Then copy this script into campfire.py
and run:
python campfire.py
Full Script
import numpy as np
from spatialstudio import splv
from tqdm import tqdm
import random
# Scene setup
SIZE, FPS, SECONDS = 128, 30, 8
FRAMES = FPS * SECONDS
CENTER_X = CENTER_Y = CENTER_Z = SIZE // 2
OUT_PATH = "../outputs/campfire.splv"
# Campfire settings
LOG_RADIUS = 20
LOG_COUNT = 6
FLAME_HEIGHT = 25
EMBER_COUNT = 15
def add_voxel(volume, x, y, z, color):
if 0 <= x < SIZE and 0 <= y < SIZE and 0 <= z < SIZE:
volume[x, y, z, :3] = color
volume[x, y, z, 3] = 255
def generate_logs(volume, cx, cy, cz, t):
log_color = (101, 67, 33) # Dark brown
log_end_color = (139, 90, 43) # Lighter brown for ends
for i in range(LOG_COUNT):
angle = (i / LOG_COUNT) * 2 * np.pi
log_x = cx + int(LOG_RADIUS * np.cos(angle))
log_z = cz + int(LOG_RADIUS * np.sin(angle))
log_y = cy - 8
# Draw log body
for dx in range(-12, 13):
for dy in range(-3, 4):
for dz in range(-3, 4):
if dy*dy + dz*dz <= 9: # Cylindrical shape
noise = int(np.sin(dx*0.3 + dy*0.5 + t) * 1.5)
final_x = log_x + int(dx * np.cos(angle)) + noise
final_z = log_z + int(dx * np.sin(angle))
final_y = log_y + dy
# Use darker color for log ends
color = log_end_color if abs(dx) > 8 else log_color
add_voxel(volume, final_x, final_y, final_z, color)
def generate_coals(volume, cx, cy, cz, t):
coal_colors = [(139, 0, 0), (178, 34, 34), (255, 69, 0)] # Dark to bright red
for x in range(cx-15, cx+16):
for z in range(cz-15, cz+16):
dist = np.sqrt((x-cx)**2 + (z-cz)**2)
if dist < 12 and random.random() < 0.4:
glow_intensity = np.sin(t * 3 + x*0.1 + z*0.1) * 0.5 + 0.5
color_idx = min(2, int(glow_intensity * 3))
color = coal_colors[color_idx]
add_voxel(volume, x, cy-10, z, color)
def generate_flames(volume, cx, cy, cz, t):
flame_colors = [
(255, 0, 0), # Red base
(255, 69, 0), # Orange-red
(255, 140, 0), # Dark orange
(255, 165, 0), # Orange
(255, 215, 0), # Gold
]
# Generate multiple flame tongues
for flame_id in range(8):
base_angle = (flame_id / 8) * 2 * np.pi
flame_base_x = cx + int(8 * np.cos(base_angle))
flame_base_z = cz + int(8 * np.sin(base_angle))
# Flame height varies with time and position
height_variation = np.sin(t * 4 + flame_id * 0.8) * 8
current_height = FLAME_HEIGHT + int(height_variation)
for h in range(current_height):
# Flame gets narrower as it goes up
width = max(1, int(6 * (1 - h / current_height)))
# Add flickering motion
flicker_x = int(np.sin(t * 6 + h * 0.3 + flame_id) * (h * 0.2))
flicker_z = int(np.cos(t * 5 + h * 0.2 + flame_id) * (h * 0.15))
flame_x = flame_base_x + flicker_x
flame_z = flame_base_z + flicker_z
flame_y = cy - 5 + h
# Color changes with height (red at bottom, yellow at top)
color_progress = h / current_height
color_idx = min(4, int(color_progress * 5))
flame_color = flame_colors[color_idx]
# Draw flame cross-section
for dx in range(-width, width+1):
for dz in range(-width, width+1):
if dx*dx + dz*dz <= width*width:
if random.random() < 0.7: # Some transparency effect
add_voxel(volume, flame_x+dx, flame_y, flame_z+dz, flame_color)
def generate_embers(volume, cx, cy, cz, t):
ember_colors = [(255, 215, 0), (255, 140, 0), (255, 69, 0)] # Gold to orange
random.seed(42) # Consistent ember positions
for i in range(EMBER_COUNT):
# Each ember has its own movement pattern
base_x = random.randint(cx-15, cx+15)
base_z = random.randint(cz-15, cz+15)
# Embers rise and drift
rise_speed = 2 + random.random() * 3
drift_x = np.sin(t * 2 + i * 0.5) * 8
drift_z = np.cos(t * 1.5 + i * 0.3) * 6
ember_x = base_x + int(drift_x)
ember_y = cy + int((t * rise_speed + i * 3) % 40) - 10
ember_z = base_z + int(drift_z)
# Ember brightness flickers
brightness = np.sin(t * 8 + i * 1.2) * 0.3 + 0.7
color_idx = min(2, int(brightness * 3))
ember_color = ember_colors[color_idx]
# Small glowing particle
for dx in range(-1, 2):
for dy in range(-1, 2):
for dz in range(-1, 2):
if dx*dx + dy*dy + dz*dz <= 1:
add_voxel(volume, ember_x+dx, ember_y+dy, ember_z+dz, ember_color)
def generate_smoke(volume, cx, cy, cz, t):
smoke_color = (105, 105, 105) # Gray smoke
for i in range(5):
# Smoke particles rise and spread
smoke_x = cx + int(np.sin(t * 0.5 + i) * 15)
smoke_y = cy + 20 + int(t * 8 + i * 5) % 30
smoke_z = cz + int(np.cos(t * 0.3 + i) * 12)
# Smoke dissipates as it rises
opacity = max(0, 1 - ((smoke_y - cy - 20) / 30))
if opacity > 0 and random.random() < opacity * 0.3:
add_voxel(volume, smoke_x, smoke_y, smoke_z, smoke_color)
def generate_scene(volume, t):
generate_logs(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_coals(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_flames(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_embers(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_smoke(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
enc = splv.Encoder(SIZE, SIZE, SIZE, framerate=FPS, outputPath=OUT_PATH, motionVectors="off")
for frame in tqdm(range(FRAMES), desc="Generating campfire"):
volume = np.zeros((SIZE, SIZE, SIZE, 4), dtype=np.uint8)
t = (frame / FRAMES) * 2*np.pi
generate_scene(volume, t)
enc.encode(splv.Frame(volume, lrAxis="x", udAxis="y", fbAxis="z"))
enc.finish()
print(f"Created {OUT_PATH}")
Next steps
- Adjust
FLAME_HEIGHT
to make taller or shorter flames. - Increase
EMBER_COUNT
for more floating sparks. - Modify
flame_colors
to create blue or green magical flames. - Add wind effects by changing the flicker patterns.
- Experiment with
LOG_COUNT
andLOG_RADIUS
for different campfire sizes.