Creating Geyser Animation
Creating a Geyser Animation with SpatialStudio
This guide walks you through how to generate a looping 3D voxel animation of a geyser using SpatialStudio.
The script creates a realistic geyser that erupts with water particles, steam clouds, and rocky terrain 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
- Builds a rocky geyser base with natural stone textures
- Animates a water eruption with:
- Powerful water jet shooting upward
- Scattered water droplets and splash effects
- Rising steam clouds that dissipate
- Cyclic eruption pattern with buildup and calm phases
- Runs for 10 seconds at 30 FPS with realistic timing
- Outputs the file
geyser.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
). -
Geyser base Rocky terrain is generated using noise patterns to create natural stone formations around the geyser opening.
-
Water eruption The main water column uses physics-based height calculations with velocity and gravity simulation for realistic motion.
-
Particle system Water droplets are spawned and animated with individual trajectories, creating splash effects around the main jet.
-
Steam effects Semi-transparent white and gray particles rise and expand to simulate steam and mist.
-
Animation cycle The eruption follows a realistic pattern: buildup → powerful eruption → gradual decline → calm period, then repeats.
-
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 geyser.py
and run:
python geyser.py
Full Script
import numpy as np
from spatialstudio import splv
from tqdm import tqdm
import random
# Scene setup
SIZE, FPS, SECONDS = 128, 30, 10
FRAMES = FPS * SECONDS
CENTER_X = CENTER_Y = CENTER_Z = SIZE // 2
OUT_PATH = "../outputs/geyser.splv"
# Geyser settings
GEYSER_BASE_RADIUS = 12
GEYSER_OPENING_RADIUS = 4
MAX_ERUPTION_HEIGHT = 45
PARTICLE_COUNT = 150
def add_voxel(volume, x, y, z, color, alpha=255):
if 0 <= x < SIZE and 0 <= y < SIZE and 0 <= z < SIZE:
volume[x, y, z, :3] = color
volume[x, y, z, 3] = alpha
def generate_terrain(volume, cx, cy, cz):
"""Generate rocky base terrain around the geyser"""
rock_colors = [(120, 120, 120), (100, 90, 80), (140, 130, 120), (80, 75, 70)]
for dx in range(-GEYSER_BASE_RADIUS, GEYSER_BASE_RADIUS+1):
for dz in range(-GEYSER_BASE_RADIUS, GEYSER_BASE_RADIUS+1):
dist = np.sqrt(dx*dx + dz*dz)
if dist <= GEYSER_BASE_RADIUS:
# Create height variation using noise
height_noise = np.sin(dx*0.3) * np.cos(dz*0.3) + np.sin(dx*0.1 + dz*0.1)
base_height = int(3 + height_noise * 2)
# Create geyser opening
if dist <= GEYSER_OPENING_RADIUS:
base_height = max(0, base_height - 2)
for dy in range(base_height):
color_idx = int((dx + dz + dy) % len(rock_colors))
brightness = 0.8 + 0.4 * np.random.random()
rock_color = tuple(int(c * brightness) for c in rock_colors[color_idx])
add_voxel(volume, cx+dx, cy-5+dy, cz+dz, rock_color)
def get_eruption_strength(t, cycle_length=6.28):
"""Calculate eruption strength based on time (0-1)"""
cycle_pos = (t % cycle_length) / cycle_length
if cycle_pos < 0.1: # Buildup phase
return cycle_pos * 10 * 0.3
elif cycle_pos < 0.4: # Main eruption
return 0.8 + 0.2 * np.sin(cycle_pos * 20)
elif cycle_pos < 0.7: # Decline
return 0.9 * (1 - (cycle_pos - 0.4) / 0.3)
else: # Calm period
return 0.1 * np.sin(cycle_pos * 10)
def generate_water_column(volume, cx, cy, cz, t):
"""Generate the main water eruption column"""
water_colors = [(50, 150, 255), (70, 180, 255), (30, 130, 200)]
strength = get_eruption_strength(t)
max_height = int(MAX_ERUPTION_HEIGHT * strength)
for height in range(max_height):
# Water column gets thinner as it goes up
radius = max(1, GEYSER_OPENING_RADIUS * (1 - height / (max_height + 10)))
# Add turbulence to water flow
turbulence = np.sin(height * 0.3 + t * 8) * np.cos(height * 0.2 + t * 6)
offset_x = int(turbulence * (height / max_height) * 2)
offset_z = int(np.cos(height * 0.25 + t * 7) * (height / max_height) * 1.5)
for dx in range(-int(radius), int(radius)+1):
for dz in range(-int(radius), int(radius)+1):
if dx*dx + dz*dz <= radius*radius:
color = water_colors[int((height + dx + dz) % len(water_colors))]
alpha = max(100, 255 - int(height * 2))
add_voxel(volume, cx+dx+offset_x, cy+height, cz+dz+offset_z, color, alpha)
def generate_water_particles(volume, cx, cy, cz, t):
"""Generate scattered water droplets and splash effects"""
strength = get_eruption_strength(t)
particle_colors = [(80, 160, 255), (100, 180, 255), (60, 140, 200)]
# Create deterministic but seemingly random particles
for i in range(int(PARTICLE_COUNT * strength)):
# Use deterministic randomness based on time and particle index
seed = int(t * 10) * 1000 + i
np.random.seed(seed % 10000)
# Initial position near geyser opening
start_radius = GEYSER_OPENING_RADIUS + np.random.random() * 3
angle = np.random.random() * 2 * np.pi
px = cx + start_radius * np.cos(angle)
pz = cz + start_radius * np.sin(angle)
# Particle physics
initial_velocity = 15 + np.random.random() * 20 * strength
gravity = 0.3
time_offset = np.random.random() * 3
particle_time = max(0, (t % 6.28) - time_offset)
# Calculate position
py = cy + initial_velocity * particle_time - 0.5 * gravity * particle_time * particle_time
px += np.random.random() * 6 - 3 # Lateral drift
pz += np.random.random() * 6 - 3
if py > cy - 5: # Only draw if above ground
color = particle_colors[i % len(particle_colors)]
alpha = max(50, int(255 * (1 - particle_time / 8)))
add_voxel(volume, int(px), int(py), int(pz), color, alpha)
def generate_steam(volume, cx, cy, cz, t):
"""Generate rising steam clouds"""
steam_colors = [(220, 220, 220), (200, 200, 200), (180, 180, 180)]
strength = get_eruption_strength(t)
for i in range(int(80 * strength)):
# Deterministic steam particles
seed = int(t * 5) * 500 + i + 50000
np.random.seed(seed % 10000)
# Steam rises from hot water
steam_radius = 8 + np.random.random() * 12
angle = np.random.random() * 2 * np.pi
height_factor = np.random.random()
sx = cx + steam_radius * np.cos(angle) * height_factor
sz = cz + steam_radius * np.sin(angle) * height_factor
sy = cy + 5 + height_factor * 30
# Steam drifts and expands
drift = np.sin(t * 2 + i * 0.1) * 3
sx += drift
sz += np.cos(t * 1.5 + i * 0.15) * 2
color = steam_colors[i % len(steam_colors)]
alpha = max(20, int(120 * (1 - height_factor) * strength))
add_voxel(volume, int(sx), int(sy), int(sz), color, alpha)
def generate_scene(volume, t):
"""Generate complete geyser scene"""
generate_terrain(volume, CENTER_X, CENTER_Y, CENTER_Z)
generate_water_column(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_water_particles(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_steam(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
# Initialize encoder
enc = splv.Encoder(SIZE, SIZE, SIZE, framerate=FPS, outputPath=OUT_PATH, motionVectors="off")
# Generate animation frames
for frame in tqdm(range(FRAMES), desc="Generating geyser"):
volume = np.zeros((SIZE, SIZE, SIZE, 4), dtype=np.uint8)
t = (frame / FRAMES) * 2 * np.pi * (SECONDS / 6) # Adjust for eruption cycle
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
MAX_ERUPTION_HEIGHT
to make the geyser more or less powerful - Change
PARTICLE_COUNT
to add more water droplets and splash effects - Modify the eruption cycle timing in
get_eruption_strength()
for different patterns - Experiment with
rock_colors
to create different terrain types - Add more steam by increasing the steam particle count
- Try different
water_colors
for unique water effects