Creating Candle Animation
3D Voxel Animation: Candle
This guide walks you through how to generate a looping 3D voxel animation of a candle using SpatialStudio.
The script creates a realistic candle with a flickering flame and melting wax that dances 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 animated candle with:
- A cylindrical wax body that appears to melt
- A dynamic flickering flame
- Glowing light effects around the flame
- Realistic wax drip animations
-
Animates the flame flickering naturally for 8 seconds at 30 FPS
-
Outputs the file
candle.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
). -
Candle body The candle is drawn as a cylinder with subtle variations to simulate melting wax texture.
-
Flame animation The flame uses sine waves and noise functions to create realistic flickering motion.
-
Glow effects Orange and yellow voxels are layered around the flame to simulate light emission.
-
Wax drips Animated dripping effects are added to the sides of the candle for realism.
-
Animation loop A normalized time variable
t
cycles from0 → 2π
, ensuring the flame flicker 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 candle.py
and run:
python candle.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/candle.splv"
# Candle settings
CANDLE_HEIGHT = 40
CANDLE_RADIUS = 8
FLAME_HEIGHT = 15
WICK_HEIGHT = 3
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_candle_body(volume, cx, cy, cz, t):
wax_color = (255, 248, 220) # Cream color
for y in range(CANDLE_HEIGHT):
current_y = cy - CANDLE_HEIGHT//2 + y
# Add slight melting effect
melt_factor = max(0, 1.0 - (CANDLE_HEIGHT - y) / CANDLE_HEIGHT * 0.3)
current_radius = CANDLE_RADIUS + int(melt_factor * 2 * np.sin(t + y * 0.1))
for dx in range(-current_radius, current_radius + 1):
for dz in range(-current_radius, current_radius + 1):
distance = np.sqrt(dx*dx + dz*dz)
if distance <= current_radius:
# Add texture variation
brightness = 0.9 + 0.1 * np.sin(dx * 0.3 + dz * 0.3 + t * 0.2)
final_color = tuple(int(c * brightness) for c in wax_color)
add_voxel(volume, cx + dx, current_y, cz + dz, final_color)
def generate_wax_drips(volume, cx, cy, cz, t):
drip_color = (245, 238, 200) # Slightly darker wax
# Generate several drip streams
for i in range(4):
angle = (i / 4) * 2 * np.pi + t * 0.1
drip_x = cx + int((CANDLE_RADIUS + 1) * np.cos(angle))
drip_z = cz + int((CANDLE_RADIUS + 1) * np.sin(angle))
# Animated drip length
drip_length = int(8 + 4 * np.sin(t * 1.5 + i))
start_y = cy + CANDLE_HEIGHT//2 - 5
for j in range(drip_length):
drip_y = start_y - j
if j > 3: # Make drips get thinner
offset = int((j - 3) * 0.3)
if np.random.random() > 0.7: # Sparse drips
add_voxel(volume, drip_x, drip_y, drip_z, drip_color)
def generate_wick(volume, cx, cy, cz, t):
wick_color = (101, 67, 33) # Dark brown
wick_base_y = cy + CANDLE_HEIGHT//2
for i in range(WICK_HEIGHT):
wick_y = wick_base_y + i
# Add slight sway to the wick
sway = int(np.sin(t * 3 + i * 0.5) * 0.5)
add_voxel(volume, cx + sway, wick_y, cz, wick_color)
def generate_flame(volume, cx, cy, cz, t):
flame_base_y = cy + CANDLE_HEIGHT//2 + WICK_HEIGHT
# Flame colors from hot (white/yellow) to cool (red/orange)
flame_colors = [
(255, 255, 255), # White hot center
(255, 255, 100), # Bright yellow
(255, 200, 50), # Yellow-orange
(255, 150, 0), # Orange
(255, 100, 0), # Red-orange
(200, 50, 0), # Red
]
for y in range(FLAME_HEIGHT):
flame_y = flame_base_y + y
progress = y / FLAME_HEIGHT
# Flame gets narrower and more turbulent as it rises
base_width = max(1, int((1 - progress) * 4))
flicker_intensity = progress * 2
# Add flickering using multiple sine waves
flicker = np.sin(t * 5 + y * 0.3) * flicker_intensity
flicker += np.sin(t * 3.7 + y * 0.5) * flicker_intensity * 0.5
flicker += np.sin(t * 7.1 + y * 0.1) * flicker_intensity * 0.3
flame_width = max(1, int(base_width + flicker))
# Choose color based on height and intensity
color_index = min(len(flame_colors) - 1, int(progress * len(flame_colors)))
flame_color = flame_colors[color_index]
for dx in range(-flame_width, flame_width + 1):
for dz in range(-flame_width, flame_width + 1):
distance = np.sqrt(dx*dx + dz*dz)
if distance <= flame_width:
# Add randomness for flame texture
if np.random.random() > 0.3:
flame_x = cx + dx + int(np.sin(t * 2 + y * 0.2) * 0.5)
flame_z = cz + dz
add_voxel(volume, flame_x, flame_y, flame_z, flame_color)
def generate_glow_effects(volume, cx, cy, cz, t):
glow_color = (255, 200, 100) # Warm glow
flame_center_y = cy + CANDLE_HEIGHT//2 + WICK_HEIGHT + FLAME_HEIGHT//2
glow_radius = int(12 + 2 * np.sin(t * 2))
for dx in range(-glow_radius, glow_radius + 1):
for dy in range(-glow_radius//2, glow_radius//2 + 1):
for dz in range(-glow_radius, glow_radius + 1):
distance = np.sqrt(dx*dx + dy*dy*2 + dz*dz) # Flatten vertically
if distance <= glow_radius:
# Check if voxel is empty (don't overwrite existing voxels)
vx, vy, vz = cx + dx, flame_center_y + dy, cz + dz
if (0 <= vx < SIZE and 0 <= vy < SIZE and 0 <= vz < SIZE and
volume[vx, vy, vz, 3] == 0):
# Fade glow based on distance
intensity = max(0, 1 - distance / glow_radius)
if intensity > 0.1 and np.random.random() > 0.8:
alpha_color = tuple(int(c * intensity * 0.3) for c in glow_color)
volume[vx, vy, vz, :3] = alpha_color
volume[vx, vy, vz, 3] = int(255 * intensity * 0.2)
def generate_scene(volume, t):
generate_candle_body(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_wax_drips(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_wick(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_glow_effects(volume, CENTER_X, CENTER_Y, CENTER_Z, t)
generate_flame(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 candle"):
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
FLAME_HEIGHT
to make the flame taller or shorter. - Change
flame_colors
to create different colored flames (blue, green, etc.). - Modify
CANDLE_RADIUS
to make thicker or thinner candles. - Add multiple candles by calling the generation functions with different positions.
- Experiment with
flicker_intensity
for more or less dramatic flame movement.