Creating Fish Animation
Fish Swimming Animation
This guide walks you through how to generate a looping 3D voxel animation of fish using SpatialStudio.
The script creates colorful fish that swim, glide, and move their fins 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 fish, each with:
- An elliptical voxel body with scales
- Animated fins and tail
- Eyes that follow their swimming direction
- Animates them swimming in circular patterns for 8 seconds at 30 FPS
- Outputs the file
fish.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
). -
Fish body Fish are drawn as elongated ellipsoids with procedural scale patterns for realistic texture.
-
Fins and tail Each fish gets animated pectoral fins and a tail that wave naturally as they swim.
-
Eyes Small black voxels with white highlights are positioned on each side of the fish head.
-
Animation loop A normalized time variable
t
cycles from0 → 2π
, creating smooth swimming motions and fin movement. -
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 fish.py
and run:
python fish.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/fish.splv"
# Fish settings
FISH_COUNT = 6
FISH_LENGTH = 12
FISH_HEIGHT = 6
FISH_WIDTH = 4
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 generate_fish_body(volume, cx, cy, cz, color, direction, t):
# Create elliptical fish body with scales
for dx in range(-FISH_LENGTH//2, FISH_LENGTH//2 + 1):
for dy in range(-FISH_HEIGHT//2, FISH_HEIGHT//2 + 1):
for dz in range(-FISH_WIDTH//2, FISH_WIDTH//2 + 1):
# Ellipsoid equation
ellipse_val = (dx*dx)/(FISH_LENGTH//2)**2 + (dy*dy)/(FISH_HEIGHT//2)**2 + (dz*dz)/(FISH_WIDTH//2)**2
if ellipse_val <= 1.0:
# Add scale pattern
scale_pattern = np.sin(dx * 0.5) * np.sin(dy * 0.7) * 0.2
brightness = 1.0 + scale_pattern
final_color = tuple(min(255, int(c * brightness)) for c in color)
# Rotate based on swimming direction
rx = int(cx + dx * np.cos(direction) - dz * np.sin(direction))
ry = cy + dy
rz = int(cz + dx * np.sin(direction) + dz * np.cos(direction))
add_voxel(volume, rx, ry, rz, final_color)
def generate_fins(volume, cx, cy, cz, direction, t, fin_color):
# Animated pectoral fins
fin_wave = np.sin(t * 4.0) * 0.3
fin_size = 3
# Left fin
for i in range(fin_size):
fx = cx + int((2 + i) * np.cos(direction + np.pi/2 + fin_wave))
fy = cy - 1
fz = cz + int((2 + i) * np.sin(direction + np.pi/2 + fin_wave))
add_voxel(volume, fx, fy, fz, fin_color)
# Right fin
for i in range(fin_size):
fx = cx + int((2 + i) * np.cos(direction - np.pi/2 - fin_wave))
fy = cy - 1
fz = cz + int((2 + i) * np.sin(direction - np.pi/2 - fin_wave))
add_voxel(volume, fx, fy, fz, fin_color)
def generate_tail(volume, cx, cy, cz, direction, t, tail_color):
# Animated tail
tail_wave = np.sin(t * 5.0) * 0.4
tail_length = 4
for i in range(1, tail_length + 1):
# Tail gets wider as it goes back
tail_spread = i // 2
tail_x = cx - int((FISH_LENGTH//2 + i) * np.cos(direction))
tail_z = cz - int((FISH_LENGTH//2 + i) * np.sin(direction))
# Main tail spine
add_voxel(volume, tail_x, cy, tail_z, tail_color)
# Tail spread (top and bottom)
if i > 1:
sway = int(tail_wave * i * 0.5)
add_voxel(volume, tail_x + sway, cy + tail_spread, tail_z, tail_color)
add_voxel(volume, tail_x + sway, cy - tail_spread, tail_z, tail_color)
def generate_eyes(volume, cx, cy, cz, direction):
# Eye positions on sides of head
eye_offset = 2
eye_x = cx + int(eye_offset * np.cos(direction))
eye_z = cz + int(eye_offset * np.sin(direction))
# Left eye
left_eye_x = eye_x + int(np.cos(direction + np.pi/2))
left_eye_z = eye_z + int(np.sin(direction + np.pi/2))
add_voxel(volume, left_eye_x, cy + 1, left_eye_z, (0, 0, 0))
add_voxel(volume, left_eye_x, cy + 2, left_eye_z, (255, 255, 255))
# Right eye
right_eye_x = eye_x + int(np.cos(direction - np.pi/2))
right_eye_z = eye_z + int(np.sin(direction - np.pi/2))
add_voxel(volume, right_eye_x, cy + 1, right_eye_z, (0, 0, 0))
add_voxel(volume, right_eye_x, cy + 2, right_eye_z, (255, 255, 255))
def generate_school(volume, t):
fish_colors = [
(255, 140, 0), # Orange
(30, 144, 255), # Dodger Blue
(255, 69, 0), # Red Orange
(50, 205, 50), # Lime Green
(255, 20, 147), # Deep Pink
(138, 43, 226), # Blue Violet
]
for i in range(FISH_COUNT):
# Each fish swims in its own circular pattern
base_angle = (i / FISH_COUNT) * 2 * np.pi
swim_radius = 25 + 10 * np.sin(i * 0.7)
# Swimming motion
angle = base_angle + t * 0.5 + i * 0.3
x = CENTER_X + int(swim_radius * np.cos(angle))
z = CENTER_Z + int(swim_radius * np.sin(angle))
y = CENTER_Y + int(8 * np.sin(t * 0.8 + i * 0.5)) # Vertical bobbing
# Swimming direction
direction = angle + np.pi/2
color = fish_colors[i % len(fish_colors)]
fin_color = tuple(max(20, c - 50) for c in color) # Darker fins
# Generate fish parts
generate_fish_body(volume, x, y, z, color, direction, t)
generate_fins(volume, x, y, z, direction, t, fin_color)
generate_tail(volume, x, y, z, direction, t, fin_color)
generate_eyes(volume, x, y, z, direction)
def generate_bubbles(volume, t):
# Add some floating bubbles for atmosphere
bubble_color = (200, 230, 255)
for i in range(15):
bubble_x = CENTER_X + int(20 * np.sin(t * 0.3 + i))
bubble_y = int((CENTER_Y - 40 + (t * 10 + i * 8) % 80)) # Rising bubbles
bubble_z = CENTER_Z + int(15 * np.cos(t * 0.4 + i * 0.7))
# Small bubble (1-2 voxels)
add_voxel(volume, bubble_x, bubble_y, bubble_z, bubble_color)
if i % 3 == 0: # Some bigger bubbles
add_voxel(volume, bubble_x + 1, bubble_y, bubble_z, bubble_color)
def generate_scene(volume, t):
generate_school(volume, t)
generate_bubbles(volume, t)
enc = splv.Encoder(SIZE, SIZE, SIZE, framerate=FPS, outputPath=OUT_PATH, motionVectors="off")
for frame in tqdm(range(FRAMES), desc="Generating fish"):
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
FISH_COUNT
to create a larger or smaller school. - Modify
fish_colors
to use different color schemes. - Adjust
swim_radius
to make fish swim in tighter or wider circles. - Add seaweed or coral by creating vertical structures with
add_voxel()
. - Experiment with different swimming patterns by changing the angle calculations.