Creating Windstorm Animation
Windstorm - 3D Voxel Animation Learning Example
This guide walks you through how to generate a looping 3D voxel animation of a windstorm using SpatialStudio.
The script creates swirling particles, debris, and dust clouds that move in chaotic wind patterns 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 200+ particles including:
- Swirling dust clouds in various shades of brown and gray
- Flying debris pieces in different colors
- Scattered leaves and small objects
- Ground-level dust that gets swept up
- Animates them in turbulent wind patterns for 10 seconds at 30 FPS
- Outputs the file
windstorm.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
). -
Particle system Multiple particle types are created with different sizes, colors, and movement behaviors.
-
Wind physics Particles follow spiral and turbulent motion patterns using sine and cosine functions with noise.
-
Layered movement Different particle layers move at varying speeds and directions to create depth and chaos.
-
Dust clouds Larger, semi-transparent voxel clusters simulate thick dust being blown around.
-
Animation loop A normalized time variable
t
cycles from0 → 2π
, ensuring the storm motion loops smoothly. -
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 windstorm.py
and run:
python windstorm.py
Full Script
import numpy as np
from spatialstudio import splv
from tqdm import tqdm
# Scene setup
SIZE, FPS, SECONDS = 128, 30, 10
FRAMES = FPS * SECONDS
CENTER_X = CENTER_Y = CENTER_Z = SIZE // 2
OUT_PATH = "../outputs/windstorm.splv"
# Storm settings
PARTICLE_COUNT = 250
DUST_CLOUD_COUNT = 15
DEBRIS_COUNT = 50
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_noise(x, y, z, t):
return np.sin(x*0.1 + t) * np.cos(y*0.1 + t*0.7) * np.sin(z*0.1 + t*0.5)
def generate_dust_particles(volume, t):
dust_colors = [
(139, 121, 94), # Sandy brown
(160, 140, 115), # Light brown
(101, 87, 67), # Dark brown
(128, 128, 128), # Gray
(105, 105, 105), # Dim gray
]
for i in range(PARTICLE_COUNT):
# Create spiral wind pattern
angle = (i / PARTICLE_COUNT) * 4*np.pi + t*2.0
height_offset = (i % 40) - 20
# Add turbulence with noise
noise_factor = generate_noise(i*0.1, t*2, angle)
radius = 30 + 15*np.sin(t*1.5 + i*0.1) + noise_factor*10
x = CENTER_X + int(radius * np.cos(angle + noise_factor))
y = CENTER_Y + height_offset + int(8*np.sin(t*3 + i*0.2))
z = CENTER_Z + int(radius * np.sin(angle*0.8 + noise_factor))
# Varying particle sizes
size = 1 + int(2*np.sin(t + i*0.3))
color = dust_colors[i % len(dust_colors)]
alpha = 180 + int(50*np.sin(t*2 + i*0.5))
for dx in range(-size, size+1):
for dy in range(-size, size+1):
for dz in range(-size, size+1):
if dx*dx + dy*dy + dz*dz <= size*size:
add_voxel(volume, x+dx, y+dy, z+dz, color, alpha)
def generate_dust_clouds(volume, t):
cloud_colors = [
(160, 140, 115), # Light sandy
(139, 121, 94), # Medium sand
(112, 98, 75), # Dark sand
]
for i in range(DUST_CLOUD_COUNT):
# Slower, larger movements for clouds
angle = (i / DUST_CLOUD_COUNT) * 2*np.pi + t*0.8
radius = 25 + 20*np.sin(t*0.5 + i*0.4)
x = CENTER_X + int(radius * np.cos(angle))
y = CENTER_Y + int(15*np.sin(t*0.7 + i*0.6))
z = CENTER_Z + int(radius * np.sin(angle*1.2))
cloud_size = 6 + int(3*np.sin(t*0.3 + i*0.2))
color = cloud_colors[i % len(cloud_colors)]
# Create puffy cloud effect
for dx in range(-cloud_size, cloud_size+1):
for dy in range(-cloud_size//2, cloud_size//2+1):
for dz in range(-cloud_size, cloud_size+1):
distance = np.sqrt(dx*dx + dy*dy*2 + dz*dz)
if distance <= cloud_size:
# Fade edges for cloud-like appearance
alpha = int(120 * (1.0 - distance/cloud_size))
if alpha > 20:
add_voxel(volume, x+dx, y+dy, z+dz, color, alpha)
def generate_debris(volume, t):
debris_colors = [
(34, 139, 34), # Forest green (leaves)
(154, 205, 50), # Yellow green (leaves)
(210, 180, 140), # Tan (paper/wood)
(139, 69, 19), # Saddle brown (wood)
(128, 128, 128), # Gray (rocks)
(255, 255, 0), # Yellow (trash)
]
for i in range(DEBRIS_COUNT):
# Erratic, fast-moving debris
base_angle = (i / DEBRIS_COUNT) * 6*np.pi
spiral_t = t*4 + i*0.8
# Create chaotic movement
chaos_x = generate_noise(i*0.5, spiral_t, 0) * 20
chaos_y = generate_noise(0, i*0.3, spiral_t) * 15
chaos_z = generate_noise(spiral_t, 0, i*0.7) * 20
x = CENTER_X + int(30*np.cos(base_angle + spiral_t)) + int(chaos_x)
y = CENTER_Y + int(25*np.sin(spiral_t*1.3)) + int(chaos_y)
z = CENTER_Z + int(30*np.sin(base_angle*0.7 + spiral_t)) + int(chaos_z)
color = debris_colors[i % len(debris_colors)]
# Small, sharp debris pieces
debris_shape = i % 3
if debris_shape == 0: # Single voxel
add_voxel(volume, x, y, z, color)
elif debris_shape == 1: # Small cluster
for dx in [-1, 0, 1]:
add_voxel(volume, x+dx, y, z, color)
else: # Cross shape
add_voxel(volume, x, y, z, color)
add_voxel(volume, x+1, y, z, color)
add_voxel(volume, x, y+1, z, color)
def generate_ground_effects(volume, t):
# Dust being kicked up from the ground
ground_y = CENTER_Y - 40
for i in range(30):
angle = (i / 30) * 2*np.pi + t*3
radius = 35 + 10*np.sin(t*2 + i*0.4)
x = CENTER_X + int(radius * np.cos(angle))
z = CENTER_Z + int(radius * np.sin(angle))
# Rising dust effect
for height in range(8):
y = ground_y + height
if height < 4: # Dense at bottom
alpha = 200 - height*30
size = 2
else: # Sparse at top
alpha = 120 - height*15
size = 1
color = (139, 121, 94) # Sandy brown
for dx in range(-size, size+1):
for dz in range(-size, size+1):
if dx*dx + dz*dz <= size*size:
add_voxel(volume, x+dx, y, z+dz, color, alpha)
def generate_scene(volume, t):
generate_dust_clouds(volume, t)
generate_dust_particles(volume, t)
generate_debris(volume, t)
generate_ground_effects(volume, t)
enc = splv.Encoder(SIZE, SIZE, SIZE, framerate=FPS, outputPath=OUT_PATH, motionVectors="off")
for frame in tqdm(range(FRAMES), desc="Generating windstorm"):
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
- Increase
PARTICLE_COUNT
for a more intense storm. - Modify the
debris_colors
array to change what's flying around. - Add lightning effects by creating bright white flashes at random intervals.
- Experiment with the
generate_noise()
function parameters for different turbulence patterns. - Create a calming effect by gradually reducing wind intensity over time.