Creating Blackhole Animation
3D Voxel Animation Tutorial: Black Hole
This guide walks you through how to generate a looping 3D voxel animation of a black hole using SpatialStudio.
The script creates a mesmerizing black hole with swirling matter, particle streams, and gravitational distortion effects 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
- Generates a black hole with:
- A dark central void with event horizon
- Swirling accretion disk with hot plasma colors
- Particle streams being pulled into the gravity well
- Distortion effects around the event horizon
- Animates the gravitational pull and rotation for 8 seconds at 30 FPS
- Outputs the file
blackhole.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
). -
Event horizon The center contains a dark sphere that represents the black hole's event horizon.
-
Accretion disk Hot matter spirals around the black hole in a flattened disk, with colors ranging from deep red to bright white based on temperature and distance.
-
Particle streams Individual particles are drawn as they get pulled toward the black hole, creating dynamic trails.
-
Gravitational effects Matter appears to stretch and distort as it approaches the event horizon, simulating gravitational lensing.
-
Animation loop A normalized time variable
t
cycles from0 → 2π
, ensuring the rotation and particle 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 blackhole.py
and run:
python blackhole.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/blackhole.splv"
# Black hole settings
EVENT_HORIZON_RADIUS = 8
ACCRETION_DISK_INNER = 12
ACCRETION_DISK_OUTER = 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:
# Blend with existing color if there's already something there
existing_alpha = volume[x, y, z, 3] / 255.0
new_alpha = alpha / 255.0
blend_factor = new_alpha * (1 - existing_alpha)
for i in range(3):
volume[x, y, z, i] = int(volume[x, y, z, i] * existing_alpha + color[i] * blend_factor)
volume[x, y, z, 3] = min(255, int((existing_alpha + new_alpha) * 255))
def generate_event_horizon(volume, cx, cy, cz, t):
# Create the dark center with subtle distortion
horizon_color = (20, 5, 30) # Very dark purple
for dx in range(-EVENT_HORIZON_RADIUS-2, EVENT_HORIZON_RADIUS+3):
for dy in range(-EVENT_HORIZON_RADIUS-2, EVENT_HORIZON_RADIUS+3):
for dz in range(-EVENT_HORIZON_RADIUS-2, EVENT_HORIZON_RADIUS+3):
dist = np.sqrt(dx*dx + dy*dy + dz*dz)
if dist <= EVENT_HORIZON_RADIUS:
# Add subtle distortion effect
distortion = np.sin(t * 3 + dist * 0.5) * 0.3
if dist <= EVENT_HORIZON_RADIUS + distortion:
intensity = max(0, 1 - dist / EVENT_HORIZON_RADIUS)
color = tuple(int(c * intensity * 0.3) for c in horizon_color)
add_voxel(volume, cx+dx, cy+dy, cz+dz, color)
def get_temperature_color(temperature):
# Temperature-based color mapping for hot plasma
if temperature < 0.3:
# Dark red
return (int(80 * temperature / 0.3), 0, 0)
elif temperature < 0.6:
# Red to orange
t = (temperature - 0.3) / 0.3
return (80 + int(175 * t), int(50 * t), 0)
elif temperature < 0.8:
# Orange to yellow
t = (temperature - 0.6) / 0.2
return (255, 50 + int(155 * t), int(50 * t))
else:
# Yellow to white (hottest)
t = (temperature - 0.8) / 0.2
return (255, 205 + int(50 * t), 50 + int(205 * t))
def generate_accretion_disk(volume, cx, cy, cz, t):
for dx in range(-ACCRETION_DISK_OUTER-5, ACCRETION_DISK_OUTER+6):
for dz in range(-ACCRETION_DISK_OUTER-5, ACCRETION_DISK_OUTER+6):
for dy in range(-8, 9): # Flattened disk
radius = np.sqrt(dx*dx + dz*dz)
if ACCRETION_DISK_INNER <= radius <= ACCRETION_DISK_OUTER:
# Calculate spiral position
angle = np.arctan2(dz, dx)
spiral_angle = angle + t * 2.0 - radius * 0.08
# Create spiral arms
spiral_intensity = (np.sin(spiral_angle * 3) + 1) * 0.5
# Height falloff for disk shape
height_falloff = np.exp(-abs(dy) * 0.3)
# Distance-based temperature and density
temp_factor = 1.0 / (1 + (radius - ACCRETION_DISK_INNER) * 0.02)
density = spiral_intensity * height_falloff * temp_factor
if density > 0.1: # Only draw if dense enough
# Add turbulence
turbulence = np.sin(dx*0.2 + dz*0.15 + t*4) * 0.1
temperature = temp_factor + turbulence
color = get_temperature_color(temperature)
alpha = min(255, int(density * 180))
add_voxel(volume, cx+dx, cy+dy, cz+dz, color, alpha)
def generate_particle_streams(volume, cx, cy, cz, t):
particle_color = (255, 200, 100) # Bright yellow-orange
for i in range(PARTICLE_COUNT):
# Each particle follows a unique spiral path
particle_phase = (i / PARTICLE_COUNT) * 2 * np.pi
# Particle's journey from outer edge to event horizon
progress = (t * 0.5 + particle_phase) % (2 * np.pi)
journey = progress / (2 * np.pi)
# Start from outer disk, spiral inward
start_radius = ACCRETION_DISK_OUTER + 10
current_radius = start_radius * (1 - journey * 0.8)
if current_radius > EVENT_HORIZON_RADIUS + 2:
angle = particle_phase + progress * 5 # Spiral motion
# Particle position
px = int(current_radius * np.cos(angle))
pz = int(current_radius * np.sin(angle))
py = int(np.sin(progress * 4 + particle_phase) * 4) # Vertical oscillation
# Intensity based on proximity to black hole
intensity = min(1.0, 2.0 / (1 + current_radius * 0.05))
color = tuple(int(c * intensity) for c in particle_color)
alpha = int(intensity * 200)
add_voxel(volume, cx+px, cy+py, cz+pz, color, alpha)
# Add particle trail
for trail in range(1, 4):
trail_angle = angle - trail * 0.1
trail_px = int(current_radius * np.cos(trail_angle))
trail_pz = int(current_radius * np.sin(trail_angle))
trail_alpha = alpha // (trail + 1)
trail_color = tuple(c // (trail + 1) for c in color)
add_voxel(volume, cx+trail_px, cy+py, cz+trail_pz, trail_color, trail_alpha)
def generate_gravitational_lensing(volume, cx, cy, cz, t):
# Add bright spots around the event horizon to simulate lensing effects
lensing_color = (150, 150, 255) # Blue-white light
for i in range(8):
angle = (i / 8) * 2 * np.pi + t * 1.5
radius = EVENT_HORIZON_RADIUS + 4
lx = int(radius * np.cos(angle))
lz = int(radius * np.sin(angle))
ly = int(np.sin(t * 2 + i) * 2)
intensity = (np.sin(t * 3 + i * 0.5) + 1) * 0.5
color = tuple(int(c * intensity * 0.7) for c in lensing_color)
alpha = int(intensity * 150)
# Add multiple voxels for a glowing effect
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, cx+lx+dx, cy+ly+dy, cz+lz+dz, color, alpha//2)
def generate_scene(volume, t):
generate_accretion_disk(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_particle_streams(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_gravitational_lensing(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_event_horizon(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 black hole"):
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
EVENT_HORIZON_RADIUS
to create larger or smaller black holes - Modify
PARTICLE_COUNT
to increase the density of matter streams - Change the temperature color mapping in
get_temperature_color()
for different plasma effects - Experiment with
ACCRETION_DISK_OUTER
to create more expansive disks - Add pulsing effects by modulating the intensity with sine waves over time