Creating Meteor Animation
Meteor - 3D Voxel Animation Guide
This guide walks you through how to generate a looping 3D voxel animation of a meteor using SpatialStudio.
The script creates a fiery meteor streaking through space with a glowing trail, particle effects, and dynamic lighting 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 1 meteor, featuring:
- A rocky, textured core with hot spots
- A blazing particle trail that follows behind
- Dynamic fire effects with varying intensity
- Glowing sparks that scatter and fade
- Animates the meteor streaking across space for 8 seconds at 30 FPS
- Outputs the file
meteor.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
). -
Meteor core The meteor body is drawn as an irregular rocky sphere with hot glowing spots using noise functions.
-
Particle trail A dynamic trail of fire particles follows the meteor, with colors transitioning from white-hot to deep red.
-
Sparks and debris Random sparks fly off the meteor in all directions, creating a realistic burning effect.
-
Motion path The meteor follows a diagonal trajectory across the 3D space with slight wobble for realism.
-
Animation loop A normalized time variable
t
cycles from0 → 2π
, with the meteor's position wrapping 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 meteor.py
and run:
python meteor.py
Full Script
import numpy as np
from spatialstudio import splv
from tqdm import tqdm
# Scene setup
SIZE, FPS, SECONDS = 128, 30, 8
FRAMES = FPS * SECONDS
CENTER_X = CENTER_Y = CENTER_Z = SIZE // 2
OUT_PATH = "../outputs/meteor.splv"
# Meteor settings
METEOR_RADIUS = 6
TRAIL_LENGTH = 40
SPARK_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 noise_3d(x, y, z, scale=1.0):
return (np.sin(x * scale) * np.cos(y * scale) + np.sin(z * scale)) * 0.5
def generate_meteor_core(volume, cx, cy, cz, t):
# Rocky meteor colors
rock_color = (80, 60, 40)
hot_color = (255, 120, 0)
for dx in range(-METEOR_RADIUS, METEOR_RADIUS+1):
for dy in range(-METEOR_RADIUS, METEOR_RADIUS+1):
for dz in range(-METEOR_RADIUS, METEOR_RADIUS+1):
dist = np.sqrt(dx*dx + dy*dy + dz*dz)
if dist <= METEOR_RADIUS:
# Add surface roughness
surface_noise = noise_3d(cx+dx, cy+dy, cz+dz, 0.3) + \
noise_3d(cx+dx, cy+dy, cz+dz, 0.1) * 0.5
if dist <= METEOR_RADIUS + surface_noise * 2:
# Hot spots on the meteor
heat = max(0, noise_3d(cx+dx, cy+dy, cz+dz + t, 0.2))
if heat > 0.3:
# Glowing hot spots
intensity = min(1.0, (heat - 0.3) * 2)
color = tuple(int(rock_color[i] * (1-intensity) + hot_color[i] * intensity)
for i in range(3))
else:
color = rock_color
add_voxel(volume, cx+dx, cy+dy, cz+dz, color)
def generate_trail(volume, positions, t):
trail_colors = [
(255, 255, 255), # White hot
(255, 200, 100), # Yellow
(255, 150, 50), # Orange
(255, 100, 0), # Red-orange
(200, 50, 0), # Deep red
(100, 20, 0), # Dark red
]
for i, (tx, ty, tz) in enumerate(positions):
if i >= len(trail_colors):
break
color = trail_colors[i]
trail_size = max(1, METEOR_RADIUS - i // 2)
# Add some randomness to trail particles
for _ in range(max(1, 8 - i)):
offset_x = int(np.random.normal(0, trail_size * 0.5))
offset_y = int(np.random.normal(0, trail_size * 0.5))
offset_z = int(np.random.normal(0, trail_size * 0.5))
# Fade color based on distance from trail center
fade = max(0.3, 1.0 - (abs(offset_x) + abs(offset_y) + abs(offset_z)) / (trail_size * 2))
faded_color = tuple(int(c * fade) for c in color)
add_voxel(volume, tx + offset_x, ty + offset_y, tz + offset_z, faded_color)
def generate_sparks(volume, cx, cy, cz, t):
np.random.seed(int(t * 10)) # Deterministic randomness
for i in range(SPARK_COUNT):
# Random direction for each spark
angle_h = np.random.uniform(0, 2*np.pi)
angle_v = np.random.uniform(-np.pi/3, np.pi/3)
speed = np.random.uniform(3, 8)
# Spark position
sx = cx + int(speed * np.cos(angle_h) * np.cos(angle_v))
sy = cy + int(speed * np.sin(angle_v))
sz = cz + int(speed * np.sin(angle_h) * np.cos(angle_v))
# Spark colors (hot to cool)
spark_colors = [(255, 255, 200), (255, 200, 100), (255, 100, 50)]
color = spark_colors[i % len(spark_colors)]
# Create small spark trails
for j in range(3):
trail_x = sx - int(j * 0.5 * np.cos(angle_h))
trail_y = sy - int(j * 0.5 * np.sin(angle_v))
trail_z = sz - int(j * 0.5 * np.sin(angle_h))
fade = max(0.3, 1.0 - j * 0.3)
faded_color = tuple(int(c * fade) for c in color)
add_voxel(volume, trail_x, trail_y, trail_z, faded_color)
def generate_scene(volume, t):
# Calculate meteor position (diagonal trajectory)
progress = (t / (2*np.pi)) % 1.0
# Meteor path with slight wobble
base_x = int(SIZE * 0.2 + (SIZE * 0.6) * progress)
base_y = int(SIZE * 0.8 - (SIZE * 0.6) * progress) # Top to bottom
base_z = int(SIZE * 0.3 + (SIZE * 0.4) * progress)
# Add wobble
wobble_x = int(3 * np.sin(t * 3.0))
wobble_y = int(2 * np.cos(t * 2.5))
wobble_z = int(2 * np.sin(t * 4.0))
meteor_x = base_x + wobble_x
meteor_y = base_y + wobble_y
meteor_z = base_z + wobble_z
# Store trail positions
trail_positions = []
for i in range(TRAIL_LENGTH):
trail_progress = max(0, progress - i * 0.01)
trail_base_x = int(SIZE * 0.2 + (SIZE * 0.6) * trail_progress)
trail_base_y = int(SIZE * 0.8 - (SIZE * 0.6) * trail_progress)
trail_base_z = int(SIZE * 0.3 + (SIZE * 0.4) * trail_progress)
trail_t = t - i * 0.1
trail_wobble_x = int(3 * np.sin(trail_t * 3.0))
trail_wobble_y = int(2 * np.cos(trail_t * 2.5))
trail_wobble_z = int(2 * np.sin(trail_t * 4.0))
trail_positions.append((
trail_base_x + trail_wobble_x,
trail_base_y + trail_wobble_y,
trail_base_z + trail_wobble_z
))
# Generate meteor components
generate_trail(volume, trail_positions, t)
generate_meteor_core(volume, meteor_x, meteor_y, meteor_z, t)
generate_sparks(volume, meteor_x, meteor_y, meteor_z, t)
enc = splv.Encoder(SIZE, SIZE, SIZE, framerate=FPS, outputPath=OUT_PATH, motionVectors="off")
for frame in tqdm(range(FRAMES), desc="Generating meteor"):
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
METEOR_RADIUS
to make the meteor larger or smaller. - Increase
SPARK_COUNT
for more dramatic particle effects. - Modify the trajectory by changing the path calculations in
generate_scene()
. - Add multiple meteors by calling
generate_meteor_core()
multiple times with different positions. - Experiment with different color palettes in the trail and spark arrays.