Creating Crystal Animation
Crystal - 3D Voxel Animation Tutorial
This guide walks you through how to generate a looping 3D voxel animation of crystals using SpatialStudio.
The script creates sparkling crystal formations that grow, rotate, and emit magical particles 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 6 crystal formations, each with:
- A geometric crystal body with multiple facets
- Rotating inner core with prismatic colors
- Sparkling particle effects around the base
- Dynamic light rays emanating from the tips
-
Animates them growing and pulsing for 8 seconds at 30 FPS
-
Outputs the file
crystal.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
). -
Crystal structure Crystals are drawn as multi-sided geometric shapes with sharp edges and faceted surfaces.
-
Inner core Each crystal gets a rotating prismatic core that cycles through rainbow colors.
-
Particle effects Tiny glowing particles swirl around the crystal base, creating a magical atmosphere.
-
Light rays Bright beams of light shoot upward from crystal tips, with intensity that pulses over time.
-
Animation loop A normalized time variable
t
cycles from0 → 2π
, ensuring the 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 crystal.py
and run:
python crystal.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/crystal.splv"
# Crystal settings
CRYSTAL_COUNT = 6
CRYSTAL_HEIGHT = 20
CRYSTAL_BASE_RADIUS = 6
PARTICLE_COUNT = 50
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 hsv_to_rgb(h, s, v):
"""Convert HSV color to RGB"""
c = v * s
x = c * (1 - abs((h / 60) % 2 - 1))
m = v - c
if h < 60:
r, g, b = c, x, 0
elif h < 120:
r, g, b = x, c, 0
elif h < 180:
r, g, b = 0, c, x
elif h < 240:
r, g, b = 0, x, c
elif h < 300:
r, g, b = x, 0, c
else:
r, g, b = c, 0, x
return (int((r + m) * 255), int((g + m) * 255), int((b + m) * 255))
def generate_crystal_body(volume, cx, cy, cz, base_color, height, t, crystal_id):
# Crystal grows over time
growth = 0.7 + 0.3 * np.sin(t * 0.5 + crystal_id * 0.8)
actual_height = int(height * growth)
for y in range(actual_height):
# Crystal tapers toward the top
progress = y / actual_height
radius = CRYSTAL_BASE_RADIUS * (1 - progress * 0.8)
# Create faceted sides (hexagonal cross-section)
for angle_step in range(6):
angle = (angle_step / 6.0) * 2 * np.pi + t * 0.2
# Create each face of the crystal
for r in range(int(radius)):
face_x = int(r * np.cos(angle))
face_z = int(r * np.sin(angle))
# Add some geometric variation
if r == int(radius) - 1: # Edge highlights
edge_brightness = 1.5
else:
edge_brightness = 1.0 - progress * 0.3
final_color = tuple(min(255, int(c * edge_brightness)) for c in base_color)
add_voxel(volume, cx + face_x, cy - y, cz + face_z, final_color)
def generate_crystal_core(volume, cx, cy, cz, height, t, crystal_id):
# Rotating prismatic core
core_rotation = t * 2.0 + crystal_id * 1.5
for y in range(int(height * 0.8)):
progress = y / height
core_radius = 2 * (1 - progress * 0.5)
# Rainbow core that shifts over time
hue = (core_rotation * 50 + y * 10) % 360
core_color = hsv_to_rgb(hue, 0.8, 1.0)
for dx in range(-int(core_radius), int(core_radius) + 1):
for dz in range(-int(core_radius), int(core_radius) + 1):
if dx*dx + dz*dz <= core_radius*core_radius:
add_voxel(volume, cx + dx, cy - y, cz + dz, core_color)
def generate_particles(volume, cx, cy, cz, t, crystal_id):
# Swirling particles around the base
for i in range(PARTICLE_COUNT // CRYSTAL_COUNT):
particle_t = t * 1.5 + i * 0.5 + crystal_id * 2.0
# Circular motion around crystal base
radius = 8 + 3 * np.sin(particle_t * 0.8)
height_offset = abs(np.sin(particle_t * 1.2)) * 15
px = cx + int(radius * np.cos(particle_t))
py = cy + int(height_offset)
pz = cz + int(radius * np.sin(particle_t))
# Particle color based on position and time
hue = (particle_t * 100 + i * 30) % 360
particle_color = hsv_to_rgb(hue, 0.6, 0.9)
add_voxel(volume, px, py, pz, particle_color)
# Add some particle trails
for trail in range(3):
trail_x = px - int(trail * np.cos(particle_t))
trail_z = pz - int(trail * np.sin(particle_t))
faded_color = tuple(int(c * (0.8 - trail * 0.2)) for c in particle_color)
add_voxel(volume, trail_x, py, trail_z, faded_color)
def generate_light_rays(volume, cx, cy, cz, height, t, crystal_id):
# Light beams shooting upward from crystal tips
pulse = 0.5 + 0.5 * np.sin(t * 3.0 + crystal_id * 1.2)
ray_intensity = int(255 * pulse)
ray_color = (ray_intensity, ray_intensity // 2, ray_intensity)
# Multiple light rays
for ray in range(3):
ray_angle = (ray / 3.0) * 2 * np.pi + t * 0.5
for y in range(height, height + 15):
spread = (y - height) * 0.3
ray_x = cx + int(spread * np.cos(ray_angle))
ray_z = cz + int(spread * np.sin(ray_angle))
# Fade the ray as it goes up
fade = 1.0 - (y - height) / 15.0
faded_color = tuple(int(c * fade) for c in ray_color)
add_voxel(volume, ray_x, cy - y, ray_z, faded_color)
def generate_crystal_cluster(volume, cx, cy, cz, t):
crystal_colors = [
(100, 200, 255), # Ice blue
(255, 100, 200), # Pink
(150, 255, 150), # Green
(255, 255, 100), # Yellow
(200, 150, 255), # Purple
(255, 180, 100), # Orange
]
for i in range(CRYSTAL_COUNT):
# Position crystals in a circle
angle = (i / CRYSTAL_COUNT) * 2 * np.pi
radius = 25 + 5 * np.sin(i * 0.7 + t * 0.3)
crystal_x = cx + int(radius * np.cos(angle))
crystal_z = cz + int(radius * np.sin(angle))
crystal_y = cy + int(3 * np.sin(t * 0.8 + i * 0.5))
height = CRYSTAL_HEIGHT + int(5 * np.sin(t * 0.6 + i * 0.4))
color = crystal_colors[i % len(crystal_colors)]
generate_crystal_body(volume, crystal_x, crystal_y, crystal_z, color, height, t, i)
generate_crystal_core(volume, crystal_x, crystal_y, crystal_z, height, t, i)
generate_particles(volume, crystal_x, crystal_y, crystal_z, t, i)
generate_light_rays(volume, crystal_x, crystal_y, crystal_z, height, t, i)
def generate_scene(volume, t):
generate_crystal_cluster(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 crystals"):
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
- Change
CRYSTAL_COUNT
to create more crystal formations. - Modify
crystal_colors
to experiment with different color schemes. - Adjust
CRYSTAL_HEIGHT
andCRYSTAL_BASE_RADIUS
for different crystal sizes. - Add more particle effects by increasing
PARTICLE_COUNT
. - Create pulsing crystals by modifying the
growth
calculation ingenerate_crystal_body()
.