Creating Grass Animation
Grass - 3D Voxel Animation Learning Example
This guide walks you through how to generate a looping 3D voxel animation of grass using SpatialStudio.
The script creates a natural grass field with individual blades that sway gently in the wind 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 hundreds of grass blades, each with:
- Multiple segments for natural bending
- Varying heights and thicknesses
- Wind-driven swaying motion
- Color variations from dark green roots to lighter tips
- Animates them swaying naturally for 8 seconds at 30 FPS
- Outputs the file
grass.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
). -
Grass blade generation Each blade is built segment by segment from ground up, with each segment slightly offset for natural curves.
-
Wind simulation Multiple sine waves with different frequencies create realistic wind patterns that affect grass movement.
-
Color gradients Grass blades transition from darker green at the base to lighter green at the tips for realism.
-
Random distribution Grass blades are scattered across the ground plane using deterministic randomization for consistent placement.
-
Animation loop A normalized time variable
t
cycles from0 → 2π
, ensuring the wind 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 grass.py
and run:
python grass.py
Full Script
import numpy as np
from spatialstudio import splv
from tqdm import tqdm
import random
# Scene setup
SIZE, FPS, SECONDS = 128, 30, 8
FRAMES = FPS * SECONDS
CENTER_X = CENTER_Y = CENTER_Z = SIZE // 2
OUT_PATH = "../outputs/grass.splv"
# Grass settings
GRASS_DENSITY = 300
MIN_HEIGHT = 8
MAX_HEIGHT = 20
SEGMENTS_PER_BLADE = 12
WIND_STRENGTH = 2.5
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 get_grass_color(height_ratio):
"""Create color gradient from dark green (base) to light green (tip)"""
base_color = np.array([34, 70, 25]) # Dark green
tip_color = np.array([85, 140, 60]) # Light green
return tuple((base_color + (tip_color - base_color) * height_ratio).astype(int))
def generate_wind_offset(x, z, segment_height, t):
"""Calculate wind displacement using multiple sine waves"""
# Primary wind wave
wind1 = np.sin(t * 1.2 + x * 0.1 + z * 0.05) * WIND_STRENGTH
# Secondary wind for complexity
wind2 = np.sin(t * 2.0 + x * 0.05 + z * 0.1) * WIND_STRENGTH * 0.3
# Higher segments are affected more by wind
height_factor = (segment_height / MAX_HEIGHT) ** 1.5
return (wind1 + wind2) * height_factor
def generate_grass_blade(volume, base_x, base_z, height, thickness, t):
"""Generate a single grass blade with natural swaying motion"""
ground_y = SIZE - 25 # Ground level
# Calculate positions for each segment
current_x, current_y, current_z = float(base_x), float(ground_y), float(base_z)
for segment in range(int(height * SEGMENTS_PER_BLADE)):
segment_height = segment / SEGMENTS_PER_BLADE * height
height_ratio = segment_height / height
# Apply wind effects
wind_x = generate_wind_offset(base_x, base_z, segment_height, t)
wind_z = generate_wind_offset(base_z, base_x, segment_height, t + 1.5)
# Natural grass curve (slightly inward lean)
natural_curve = height_ratio * height_ratio * 0.5
# Update position
current_x += (wind_x + natural_curve) * 0.1
current_y -= height / SEGMENTS_PER_BLADE
current_z += wind_z * 0.1
# Get color based on height
color = get_grass_color(height_ratio)
# Draw segment with thickness
segment_thickness = max(1, int(thickness * (1.0 - height_ratio * 0.7)))
for dx in range(-segment_thickness, segment_thickness + 1):
for dz in range(-segment_thickness, segment_thickness + 1):
if dx*dx + dz*dz <= segment_thickness*segment_thickness:
add_voxel(volume,
int(current_x) + dx,
int(current_y),
int(current_z) + dz,
color)
def generate_ground(volume):
"""Add a simple dirt ground layer"""
ground_color = (101, 67, 33) # Brown dirt
ground_y = SIZE - 25
for x in range(SIZE):
for z in range(SIZE):
for y in range(ground_y, SIZE):
# Add some texture variation
if random.random() < 0.8:
brightness = 0.8 + random.random() * 0.4
final_color = tuple(int(c * brightness) for c in ground_color)
add_voxel(volume, x, y, z, final_color)
def generate_grass_field(volume, t):
"""Generate the entire grass field"""
# Set random seed for consistent grass positions
random.seed(42)
np.random.seed(42)
# Generate ground
generate_ground(volume)
# Generate grass blades
for _ in range(GRASS_DENSITY):
# Random position on ground
base_x = random.randint(5, SIZE - 5)
base_z = random.randint(5, SIZE - 5)
# Random blade properties
height = MIN_HEIGHT + random.random() * (MAX_HEIGHT - MIN_HEIGHT)
thickness = 1 + random.random() * 1.5
# Add some clustering by occasionally placing blades near others
if random.random() < 0.3:
base_x += random.randint(-3, 3)
base_z += random.randint(-3, 3)
base_x = max(5, min(SIZE - 5, base_x))
base_z = max(5, min(SIZE - 5, base_z))
generate_grass_blade(volume, base_x, base_z, height, thickness, t)
def add_environmental_details(volume, t):
"""Add small flowers or weeds occasionally"""
random.seed(123) # Different seed for details
flower_colors = [(255, 255, 100), (255, 150, 200), (200, 200, 255)]
for _ in range(15): # Sparse flowers
x = random.randint(10, SIZE - 10)
z = random.randint(10, SIZE - 10)
y = SIZE - 26 # Just above ground
color = random.choice(flower_colors)
# Small flower cluster
for dx in range(-1, 2):
for dz in range(-1, 2):
if random.random() < 0.6:
sway_x = int(np.sin(t + x * 0.1) * 0.5)
sway_z = int(np.cos(t + z * 0.1) * 0.5)
add_voxel(volume, x + dx + sway_x, y, z + dz + sway_z, color)
def generate_scene(volume, t):
"""Generate the complete grass scene"""
generate_grass_field(volume, t)
add_environmental_details(volume, t)
# Create encoder and generate animation
enc = splv.Encoder(SIZE, SIZE, SIZE, framerate=FPS, outputPath=OUT_PATH, motionVectors="off")
for frame in tqdm(range(FRAMES), desc="Growing grass field"):
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
- Increase
GRASS_DENSITY
for a thicker field (warning: slower rendering). - Modify
WIND_STRENGTH
to make grass sway more or less dramatically. - Change the color gradients in
get_grass_color()
for autumn or spring effects. - Add more environmental details like rocks or butterflies.
- Experiment with different ground textures or add small hills.
Learning tips
- Wind simulation: Notice how multiple sine waves create complex, natural-looking motion.
- Segmented animation: Each grass blade is built from segments that can bend independently.
- Color gradients: Real grass is darker at the base and lighter at the tips.
- Performance: More grass blades look better but take longer to render - find your balance!