71 KiB
Effect Catalog
Effect building blocks that produce visual patterns. In v2, these are used inside scene functions that return a pixel canvas directly. The building blocks below operate on grid coordinate arrays and produce (chars, colors) or value/hue fields that the scene function renders to canvas via _render_vf().
See also: architecture.md · composition.md · scenes.md · shaders.md · troubleshooting.md
Design Philosophy
Effects are the creative core. Don't copy these verbatim for every project -- use them as building blocks and combine, modify, and invent new ones. Every project should feel distinct.
Key principles:
- Layer multiple effects rather than using a single monolithic function
- Parameterize everything -- hue, speed, density, amplitude should all be arguments
- React to features -- audio/video features should modulate at least 2-3 parameters per effect
- Vary per section -- never use the same effect config for the entire video
- Invent project-specific effects -- the catalog below is a starting vocabulary, not a fixed set
Background Fills
Every effect should start with a background. Never leave flat black.
Animated Sine Field (General Purpose)
def bg_sinefield(g, f, t, hue=0.6, bri=0.5, pal=PAL_DEFAULT,
freq=(0.13, 0.17, 0.07, 0.09), speed=(0.5, -0.4, -0.3, 0.2)):
"""Layered sine field. Adjust freq/speed tuples for different textures."""
v1 = np.sin(g.cc*freq[0] + t*speed[0]) * np.sin(g.rr*freq[1] - t*speed[1]) * 0.5 + 0.5
v2 = np.sin(g.cc*freq[2] - t*speed[2] + g.rr*freq[3]) * 0.4 + 0.5
v3 = np.sin(g.dist_n*5 + t*0.2) * 0.3 + 0.4
v4 = np.cos(g.angle*3 - t*0.6) * 0.15 + 0.5
val = np.clip((v1*0.3 + v2*0.25 + v3*0.25 + v4*0.2) * bri * (0.6 + f["rms"]*0.6), 0.06, 1)
mask = val > 0.03
ch = val2char(val, mask, pal)
h = np.full_like(val, hue) + f.get("cent", 0.5)*0.1 + val*0.08
R, G, B = hsv2rgb(h, np.clip(0.35+f.get("flat",0.4)*0.4, 0, 1) * np.ones_like(val), val)
return ch, mkc(R, G, B, g.rows, g.cols)
Video-Source Background
def bg_video(g, frame_rgb, pal=PAL_DEFAULT, brightness=0.5):
small = np.array(Image.fromarray(frame_rgb).resize((g.cols, g.rows)))
lum = np.mean(small, axis=2) / 255.0 * brightness
mask = lum > 0.02
ch = val2char(lum, mask, pal)
co = np.clip(small * np.clip(lum[:,:,None]*1.5+0.3, 0.3, 1), 0, 255).astype(np.uint8)
return ch, co
Noise / Static Field
def bg_noise(g, f, t, pal=PAL_BLOCKS, density=0.3, hue_drift=0.02):
val = np.random.random((g.rows, g.cols)).astype(np.float32) * density * (0.5 + f["rms"]*0.5)
val = np.clip(val, 0, 1); mask = val > 0.02
ch = val2char(val, mask, pal)
R, G, B = hsv2rgb(np.full_like(val, t*hue_drift % 1), np.full_like(val, 0.3), val)
return ch, mkc(R, G, B, g.rows, g.cols)
Perlin-Like Smooth Noise
def bg_smooth_noise(g, f, t, hue=0.5, bri=0.5, pal=PAL_DOTS, octaves=3):
"""Layered sine approximation of Perlin noise. Cheap, smooth, organic."""
val = np.zeros((g.rows, g.cols), dtype=np.float32)
for i in range(octaves):
freq = 0.05 * (2 ** i)
amp = 0.5 / (i + 1)
phase = t * (0.3 + i * 0.2)
val += np.sin(g.cc * freq + phase) * np.cos(g.rr * freq * 0.7 - phase * 0.5) * amp
val = np.clip(val * 0.5 + 0.5, 0, 1) * bri
mask = val > 0.03
ch = val2char(val, mask, pal)
h = np.full_like(val, hue) + val * 0.1
R, G, B = hsv2rgb(h, np.full_like(val, 0.5), val)
return ch, mkc(R, G, B, g.rows, g.cols)
Cellular / Voronoi Approximation
def bg_cellular(g, f, t, n_centers=12, hue=0.5, bri=0.6, pal=PAL_BLOCKS):
"""Voronoi-like cells using distance to nearest of N moving centers."""
rng = np.random.RandomState(42) # deterministic centers
cx = (rng.rand(n_centers) * g.cols).astype(np.float32)
cy = (rng.rand(n_centers) * g.rows).astype(np.float32)
# Animate centers
cx_t = cx + np.sin(t * 0.5 + np.arange(n_centers) * 0.7) * 5
cy_t = cy + np.cos(t * 0.4 + np.arange(n_centers) * 0.9) * 3
# Min distance to any center
min_d = np.full((g.rows, g.cols), 999.0, dtype=np.float32)
for i in range(n_centers):
d = np.sqrt((g.cc - cx_t[i])**2 + (g.rr - cy_t[i])**2)
min_d = np.minimum(min_d, d)
val = np.clip(1.0 - min_d / (g.cols * 0.3), 0, 1) * bri
# Cell edges (where distance is near-equal between two centers)
# ... second-nearest trick for edge highlighting
mask = val > 0.03
ch = val2char(val, mask, pal)
R, G, B = hsv2rgb(np.full_like(val, hue) + min_d * 0.005, np.full_like(val, 0.5), val)
return ch, mkc(R, G, B, g.rows, g.cols)
Note: The v1
eff_rings,eff_rays,eff_spiral,eff_glow,eff_tunnel,eff_vortex,eff_freq_waves,eff_interference,eff_aurora, andeff_ripplefunctions are superseded by thevf_*value field generators below (used via_render_vf()). Thevf_*versions integrate with the multi-grid composition pipeline and are preferred for all new scenes.
Particle Systems
General Pattern
All particle systems use persistent state via the S dict parameter:
# S is the persistent state dict (same as r.S, passed explicitly)
if "px" not in S:
S["px"]=[]; S["py"]=[]; S["vx"]=[]; S["vy"]=[]; S["life"]=[]; S["char"]=[]
# Emit new particles (on beat, continuously, or on trigger)
# Update: position += velocity, apply forces, decay life
# Draw: map to grid, set char/color based on life
# Cull: remove dead, cap total count
Particle Character Sets
Don't hardcode particle chars. Choose per project/mood:
# Energy / explosive
PART_ENERGY = list("*+#@\u26a1\u2726\u2605\u2588\u2593")
PART_SPARK = list("\u00b7\u2022\u25cf\u2605\u2736*+")
# Organic / natural
PART_LEAF = list("\u2740\u2741\u2742\u2743\u273f\u2618\u2022")
PART_SNOW = list("\u2744\u2745\u2746\u00b7\u2022*\u25cb")
PART_RAIN = list("|\u2502\u2503\u2551/\\")
PART_BUBBLE = list("\u25cb\u25ce\u25c9\u25cf\u2218\u2219\u00b0")
# Data / tech
PART_DATA = list("01{}[]<>|/\\")
PART_HEX = list("0123456789ABCDEF")
PART_BINARY = list("01")
# Mystical
PART_RUNE = list("\u16a0\u16a2\u16a6\u16b1\u16b7\u16c1\u16c7\u16d2\u16d6\u16da\u16de\u16df\u2726\u2605")
PART_ZODIAC = list("\u2648\u2649\u264a\u264b\u264c\u264d\u264e\u264f\u2650\u2651\u2652\u2653")
# Minimal
PART_DOT = list("\u00b7\u2022\u25cf")
PART_DASH = list("-=~\u2500\u2550")
Explosion (Beat-Triggered)
def emit_explosion(S, f, center_r, center_c, char_set=PART_ENERGY, count_base=80):
if f.get("beat", 0) > 0:
for _ in range(int(count_base + f["rms"]*150)):
ang = random.uniform(0, 2*math.pi)
sp = random.uniform(1, 9) * (0.5 + f.get("sub_r", 0.3)*2)
S["px"].append(float(center_c))
S["py"].append(float(center_r))
S["vx"].append(math.cos(ang)*sp*2.5)
S["vy"].append(math.sin(ang)*sp)
S["life"].append(1.0)
S["char"].append(random.choice(char_set))
# Update: gravity on vy += 0.03, life -= 0.015
# Color: life * 255 for brightness, hue fade controlled by caller
Rising Embers
# Emit: sy = rows-1, vy = -random.uniform(1,5), vx = random.uniform(-1.5,1.5)
# Update: vx += random jitter * 0.3, life -= 0.01
# Cap at ~1500 particles
Dissolving Cloud
# Init: N=600 particles spread across screen
# Update: slow upward drift, fade life progressively
# life -= 0.002 * (1 + elapsed * 0.05) # accelerating fade
Starfield (3D Projection)
# N stars with (sx, sy, sz) in normalized coords
# Move: sz -= speed (stars approach camera)
# Project: px = cx + sx/sz * cx, py = cy + sy/sz * cy
# Reset stars that pass camera (sz <= 0.01)
# Brightness = (1 - sz), draw streaks behind bright stars
Orbit (Circular/Elliptical Motion)
def emit_orbit(S, n=20, radius=15, speed=1.0, char_set=PART_DOT):
"""Particles orbiting a center point."""
for i in range(n):
angle = i * 2 * math.pi / n
S["px"].append(0.0); S["py"].append(0.0) # will be computed from angle
S["vx"].append(angle) # store angle as "vx" for orbit
S["vy"].append(radius + random.uniform(-2, 2)) # store radius
S["life"].append(1.0)
S["char"].append(random.choice(char_set))
# Update: angle += speed * dt, px = cx + radius * cos(angle), py = cy + radius * sin(angle)
Gravity Well
# Particles attracted toward one or more gravity points
# Update: compute force vector toward each well, apply as acceleration
# Particles that reach well center respawn at edges
Flocking / Boids
Emergent swarm behavior from three simple rules: separation, alignment, cohesion.
def update_boids(S, g, f, n_boids=200, perception=8.0, max_speed=2.0,
sep_weight=1.5, ali_weight=1.0, coh_weight=1.0,
char_set=None):
"""Boids flocking simulation. Particles self-organize into organic groups.
perception: how far each boid can see (grid cells)
sep_weight: separation (avoid crowding) strength
ali_weight: alignment (match neighbor velocity) strength
coh_weight: cohesion (steer toward group center) strength
"""
if char_set is None:
char_set = list("·•●◦∘⬤")
if "boid_x" not in S:
rng = np.random.RandomState(42)
S["boid_x"] = rng.uniform(0, g.cols, n_boids).astype(np.float32)
S["boid_y"] = rng.uniform(0, g.rows, n_boids).astype(np.float32)
S["boid_vx"] = (rng.random(n_boids).astype(np.float32) - 0.5) * max_speed
S["boid_vy"] = (rng.random(n_boids).astype(np.float32) - 0.5) * max_speed
S["boid_ch"] = [random.choice(char_set) for _ in range(n_boids)]
bx = S["boid_x"]; by = S["boid_y"]
bvx = S["boid_vx"]; bvy = S["boid_vy"]
n = len(bx)
# For each boid, compute steering forces
ax = np.zeros(n, dtype=np.float32)
ay = np.zeros(n, dtype=np.float32)
# Spatial hash for efficient neighbor lookup
cell_size = perception
cells = {}
for i in range(n):
cx_i = int(bx[i] / cell_size)
cy_i = int(by[i] / cell_size)
key = (cx_i, cy_i)
if key not in cells:
cells[key] = []
cells[key].append(i)
for i in range(n):
cx_i = int(bx[i] / cell_size)
cy_i = int(by[i] / cell_size)
sep_x, sep_y = 0.0, 0.0
ali_x, ali_y = 0.0, 0.0
coh_x, coh_y = 0.0, 0.0
count = 0
# Check neighboring cells
for dcx in range(-1, 2):
for dcy in range(-1, 2):
for j in cells.get((cx_i + dcx, cy_i + dcy), []):
if j == i:
continue
dx = bx[j] - bx[i]
dy = by[j] - by[i]
dist = np.sqrt(dx * dx + dy * dy)
if dist < perception and dist > 0.01:
count += 1
# Separation: steer away from close neighbors
if dist < perception * 0.4:
sep_x -= dx / (dist * dist)
sep_y -= dy / (dist * dist)
# Alignment: match velocity
ali_x += bvx[j]
ali_y += bvy[j]
# Cohesion: steer toward center of group
coh_x += bx[j]
coh_y += by[j]
if count > 0:
# Normalize and weight
ax[i] += sep_x * sep_weight
ay[i] += sep_y * sep_weight
ax[i] += (ali_x / count - bvx[i]) * ali_weight * 0.1
ay[i] += (ali_y / count - bvy[i]) * ali_weight * 0.1
ax[i] += (coh_x / count - bx[i]) * coh_weight * 0.01
ay[i] += (coh_y / count - by[i]) * coh_weight * 0.01
# Audio reactivity: bass pushes boids outward from center
if f.get("bass", 0) > 0.5:
cx_g, cy_g = g.cols / 2, g.rows / 2
dx = bx - cx_g; dy = by - cy_g
dist = np.sqrt(dx**2 + dy**2) + 1
ax += (dx / dist) * f["bass"] * 2
ay += (dy / dist) * f["bass"] * 2
# Update velocity and position
bvx += ax; bvy += ay
# Clamp speed
speed = np.sqrt(bvx**2 + bvy**2) + 1e-10
over = speed > max_speed
bvx[over] *= max_speed / speed[over]
bvy[over] *= max_speed / speed[over]
bx += bvx; by += bvy
# Wrap at edges
bx %= g.cols; by %= g.rows
S["boid_x"] = bx; S["boid_y"] = by
S["boid_vx"] = bvx; S["boid_vy"] = bvy
# Draw
ch = np.full((g.rows, g.cols), " ", dtype="U1")
co = np.zeros((g.rows, g.cols, 3), dtype=np.uint8)
for i in range(n):
r, c = int(by[i]) % g.rows, int(bx[i]) % g.cols
ch[r, c] = S["boid_ch"][i]
spd = min(1.0, speed[i] / max_speed)
R, G, B = hsv2rgb_scalar(spd * 0.3, 0.8, 0.5 + spd * 0.5)
co[r, c] = (R, G, B)
return ch, co
Flow Field Particles
Particles that follow the gradient of a value field. Any vf_* function becomes a "river" that carries particles:
def update_flow_particles(S, g, f, flow_field, n=500, speed=1.0,
life_drain=0.005, emit_rate=10,
char_set=None):
"""Particles steered by a value field gradient.
flow_field: float32 (rows, cols) — the field particles follow.
Particles flow from low to high values (uphill) or along
the gradient direction.
"""
if char_set is None:
char_set = list("·•∘◦°⋅")
if "fp_x" not in S:
S["fp_x"] = []; S["fp_y"] = []; S["fp_vx"] = []; S["fp_vy"] = []
S["fp_life"] = []; S["fp_ch"] = []
# Emit new particles at random positions
for _ in range(emit_rate):
if len(S["fp_x"]) < n:
S["fp_x"].append(random.uniform(0, g.cols - 1))
S["fp_y"].append(random.uniform(0, g.rows - 1))
S["fp_vx"].append(0.0); S["fp_vy"].append(0.0)
S["fp_life"].append(1.0)
S["fp_ch"].append(random.choice(char_set))
# Compute gradient of flow field (central differences)
pad = np.pad(flow_field, 1, mode="wrap")
grad_x = (pad[1:-1, 2:] - pad[1:-1, :-2]) * 0.5
grad_y = (pad[2:, 1:-1] - pad[:-2, 1:-1]) * 0.5
# Update particles
i = 0
while i < len(S["fp_x"]):
px, py = S["fp_x"][i], S["fp_y"][i]
# Sample gradient at particle position
gc = int(px) % g.cols; gr = int(py) % g.rows
gx = grad_x[gr, gc]; gy = grad_y[gr, gc]
# Steer velocity toward gradient direction
S["fp_vx"][i] = S["fp_vx"][i] * 0.9 + gx * speed * 10
S["fp_vy"][i] = S["fp_vy"][i] * 0.9 + gy * speed * 10
S["fp_x"][i] += S["fp_vx"][i]
S["fp_y"][i] += S["fp_vy"][i]
S["fp_life"][i] -= life_drain
if S["fp_life"][i] <= 0:
for k in ("fp_x", "fp_y", "fp_vx", "fp_vy", "fp_life", "fp_ch"):
S[k].pop(i)
else:
i += 1
# Draw
ch = np.full((g.rows, g.cols), " ", dtype="U1")
co = np.zeros((g.rows, g.cols, 3), dtype=np.uint8)
for i in range(len(S["fp_x"])):
r = int(S["fp_y"][i]) % g.rows
c = int(S["fp_x"][i]) % g.cols
ch[r, c] = S["fp_ch"][i]
v = S["fp_life"][i]
co[r, c] = (int(v * 200), int(v * 180), int(v * 255))
return ch, co
Particle Trails
Draw fading lines between current and previous positions:
def draw_particle_trails(S, g, trail_key="trails", max_trail=8, fade=0.7):
"""Add trails to any particle system. Call after updating positions.
Stores previous positions in S[trail_key] and draws fading lines.
Expects S to have 'px', 'py' lists (standard particle keys).
max_trail: number of previous positions to remember
fade: brightness multiplier per trail step (0.7 = 70% each step back)
"""
if trail_key not in S:
S[trail_key] = []
# Store current positions
current = list(zip(
[int(y) for y in S.get("py", [])],
[int(x) for x in S.get("px", [])]
))
S[trail_key].append(current)
if len(S[trail_key]) > max_trail:
S[trail_key] = S[trail_key][-max_trail:]
# Draw trails onto char/color arrays
ch = np.full((g.rows, g.cols), " ", dtype="U1")
co = np.zeros((g.rows, g.cols, 3), dtype=np.uint8)
trail_chars = list("·∘◦°⋅.,'`")
for age, positions in enumerate(reversed(S[trail_key])):
bri = fade ** age
if bri < 0.05:
break
ci = min(age, len(trail_chars) - 1)
for r, c in positions:
if 0 <= r < g.rows and 0 <= c < g.cols and ch[r, c] == " ":
ch[r, c] = trail_chars[ci]
v = int(bri * 180)
co[r, c] = (v, v, int(v * 0.8))
return ch, co
Rain / Matrix Effects
Column Rain (Vectorized)
def eff_matrix_rain(g, f, t, S, hue=0.33, bri=0.6, pal=PAL_KATA,
speed_base=0.5, speed_beat=3.0):
"""Vectorized matrix rain. S dict persists column positions."""
if "ry" not in S or len(S["ry"]) != g.cols:
S["ry"] = np.random.uniform(-g.rows, g.rows, g.cols).astype(np.float32)
S["rsp"] = np.random.uniform(0.3, 2.0, g.cols).astype(np.float32)
S["rln"] = np.random.randint(8, 40, g.cols)
S["rch"] = np.random.randint(0, len(pal), (g.rows, g.cols)) # pre-assign chars
speed_mult = speed_base + f.get("bass", 0.3)*speed_beat + f.get("sub_r", 0.3)*3
if f.get("beat", 0) > 0: speed_mult *= 2.5
S["ry"] += S["rsp"] * speed_mult
# Reset columns that fall past bottom
rst = (S["ry"] - S["rln"]) > g.rows
S["ry"][rst] = np.random.uniform(-25, -2, rst.sum())
# Vectorized draw using fancy indexing
ch = np.full((g.rows, g.cols), " ", dtype="U1")
co = np.zeros((g.rows, g.cols, 3), dtype=np.uint8)
heads = S["ry"].astype(int)
for c in range(g.cols):
head = heads[c]
trail_len = S["rln"][c]
for i in range(trail_len):
row = head - i
if 0 <= row < g.rows:
fade = 1.0 - i / trail_len
ci = S["rch"][row, c] % len(pal)
ch[row, c] = pal[ci]
v = fade * bri * 255
if i == 0: # head is bright white-ish
co[row, c] = (int(v*0.9), int(min(255, v*1.1)), int(v*0.9))
else:
R, G, B = hsv2rgb_single(hue, 0.7, fade * bri)
co[row, c] = (R, G, B)
return ch, co, S
Glitch / Data Effects
Horizontal Band Displacement
def eff_glitch_displace(ch, co, f, intensity=1.0):
n_bands = int(8 + f.get("flux", 0.3)*25 + f.get("bdecay", 0)*15) * intensity
for _ in range(int(n_bands)):
y = random.randint(0, ch.shape[0]-1)
h = random.randint(1, int(3 + f.get("sub", 0.3)*8))
shift = int((random.random()-0.5) * f.get("rms", 0.3)*40 + f.get("bdecay", 0)*20*(random.random()-0.5))
if shift != 0:
for row in range(h):
rr = y + row
if 0 <= rr < ch.shape[0]:
ch[rr] = np.roll(ch[rr], shift)
co[rr] = np.roll(co[rr], shift, axis=0)
return ch, co
Block Corruption
def eff_block_corrupt(ch, co, f, char_pool=None, count_base=20):
if char_pool is None:
char_pool = list(PAL_BLOCKS[4:] + PAL_KATA[2:8])
for _ in range(int(count_base + f.get("flux", 0.3)*60 + f.get("bdecay", 0)*40)):
bx = random.randint(0, max(1, ch.shape[1]-6))
by = random.randint(0, max(1, ch.shape[0]-4))
bw, bh = random.randint(2,6), random.randint(1,4)
block_char = random.choice(char_pool)
# Fill rectangle with single char and random color
for r in range(bh):
for c in range(bw):
rr, cc = by+r, bx+c
if 0 <= rr < ch.shape[0] and 0 <= cc < ch.shape[1]:
ch[rr, cc] = block_char
co[rr, cc] = (random.randint(100,255), random.randint(0,100), random.randint(0,80))
return ch, co
Scan Bars (Vertical)
def eff_scanbars(ch, co, f, t, n_base=4, chars="|\u2551|!1l"):
for bi in range(int(n_base + f.get("himid_r", 0.3)*12)):
sx = int((t*50*(1+bi*0.3) + bi*37) % ch.shape[1])
for rr in range(ch.shape[0]):
if random.random() < 0.7:
ch[rr, sx] = random.choice(chars)
return ch, co
Error Messages
# Parameterize the error vocabulary per project:
ERRORS_TECH = ["SEGFAULT","0xDEADBEEF","BUFFER_OVERRUN","PANIC!","NULL_PTR",
"CORRUPT","SIGSEGV","ERR_OVERFLOW","STACK_SMASH","BAD_ALLOC"]
ERRORS_COSMIC = ["VOID_BREACH","ENTROPY_MAX","SINGULARITY","DIMENSION_FAULT",
"REALITY_ERR","TIME_PARADOX","DARK_MATTER_LEAK","QUANTUM_DECOHERE"]
ERRORS_ORGANIC = ["CELL_DIVISION_ERR","DNA_MISMATCH","MUTATION_OVERFLOW",
"NEURAL_DEADLOCK","SYNAPSE_TIMEOUT","MEMBRANE_BREACH"]
Hex Data Stream
hex_str = "".join(random.choice("0123456789ABCDEF") for _ in range(random.randint(8,20)))
stamp(ch, co, hex_str, rand_row, rand_col, (0, 160, 80))
Spectrum / Visualization
Mirrored Spectrum Bars
def eff_spectrum(g, f, t, n_bars=64, pal=PAL_BLOCKS, mirror=True):
bar_w = max(1, g.cols // n_bars); mid = g.rows // 2
band_vals = np.array([f.get("sub",0.3), f.get("bass",0.3), f.get("lomid",0.3),
f.get("mid",0.3), f.get("himid",0.3), f.get("hi",0.3)])
ch = np.full((g.rows, g.cols), " ", dtype="U1")
co = np.zeros((g.rows, g.cols, 3), dtype=np.uint8)
for b in range(n_bars):
frac = b / n_bars
fi = frac * 5; lo_i = int(fi); hi_i = min(lo_i+1, 5)
bval = min(1, (band_vals[lo_i]*(1-fi%1) + band_vals[hi_i]*(fi%1)) * 1.8)
height = int(bval * (g.rows//2 - 2))
for dy in range(height):
hue = (f.get("cent",0.5)*0.3 + frac*0.3 + dy/max(height,1)*0.15) % 1.0
ci = pal[min(int(dy/max(height,1)*len(pal)*0.7+len(pal)*0.2), len(pal)-1)]
for dc in range(bar_w - (1 if bar_w > 2 else 0)):
cc = b*bar_w + dc
if 0 <= cc < g.cols:
rows_to_draw = [mid - dy, mid + dy] if mirror else [g.rows - 1 - dy]
for row in rows_to_draw:
if 0 <= row < g.rows:
ch[row, cc] = ci
co[row, cc] = hsv_to_rgb_single(hue, 0.85, 0.5+dy/max(height,1)*0.5)
return ch, co
Waveform
def eff_waveform(g, f, t, row_offset=-5, hue=0.1):
ch = np.full((g.rows, g.cols), " ", dtype="U1")
co = np.zeros((g.rows, g.cols, 3), dtype=np.uint8)
for c in range(g.cols):
wv = (math.sin(c*0.15+t*5)*f.get("bass",0.3)*0.5
+ math.sin(c*0.3+t*8)*f.get("mid",0.3)*0.3
+ math.sin(c*0.6+t*12)*f.get("hi",0.3)*0.15)
wr = g.rows + row_offset + int(wv * 4)
if 0 <= wr < g.rows:
ch[wr, c] = "~"
v = int(120 + f.get("rms",0.3)*135)
co[wr, c] = [v, int(v*0.7), int(v*0.4)]
return ch, co
Fire / Lava
Fire Columns
def eff_fire(g, f, t, n_base=20, hue_base=0.02, hue_range=0.12, pal=PAL_BLOCKS):
n_cols = int(n_base + f.get("bass",0.3)*30 + f.get("sub_r",0.3)*20)
ch = np.full((g.rows, g.cols), " ", dtype="U1")
co = np.zeros((g.rows, g.cols, 3), dtype=np.uint8)
for fi in range(n_cols):
fx_c = int((fi*g.cols/n_cols + np.sin(t*2+fi*0.7)*3) % g.cols)
height = int((f.get("bass",0.3)*0.4 + f.get("sub_r",0.3)*0.3 + f.get("rms",0.3)*0.3) * g.rows * 0.7)
for dy in range(min(height, g.rows)):
fr = g.rows - 1 - dy
frac = dy / max(height, 1)
bri = max(0.1, (1 - frac*0.6) * (0.5 + f.get("rms",0.3)*0.5))
hue = hue_base + frac * hue_range
ci = "\u2588" if frac<0.2 else ("\u2593" if frac<0.4 else ("\u2592" if frac<0.6 else "\u2591"))
ch[fr, fx_c] = ci
R, G, B = hsv2rgb_single(hue, 0.9, bri)
co[fr, fx_c] = (R, G, B)
return ch, co
Ice / Cold Fire (same structure, different hue range)
# hue_base=0.55, hue_range=0.15 -- blue to cyan
# Lower intensity, slower movement
Text Overlays
Scrolling Ticker
def eff_ticker(ch, co, t, text, row, speed=15, color=(80, 100, 140)):
off = int(t * speed) % max(len(text), 1)
doubled = text + " " + text
stamp(ch, co, doubled[off:off+ch.shape[1]], row, 0, color)
Beat-Triggered Words
def eff_beat_words(ch, co, f, words, row_center=None, color=(255,240,220)):
if f.get("beat", 0) > 0:
w = random.choice(words)
r = (row_center or ch.shape[0]//2) + random.randint(-5,5)
stamp(ch, co, w, r, (ch.shape[1]-len(w))//2, color)
Fading Message Sequence
def eff_fading_messages(ch, co, t, elapsed, messages, period=4.0, color_base=(220,220,220)):
msg_idx = int(elapsed / period) % len(messages)
phase = elapsed % period
fade = max(0, min(1.0, phase) * min(1.0, period - phase))
if fade > 0.05:
v = fade
msg = messages[msg_idx]
cr, cg, cb = [int(c * v) for c in color_base]
stamp(ch, co, msg, ch.shape[0]//2, (ch.shape[1]-len(msg))//2, (cr, cg, cb))
Screen Shake
Shift entire char/color arrays on beat:
def eff_shake(ch, co, f, x_amp=6, y_amp=3):
shake_x = int(f.get("sub",0.3)*x_amp*(random.random()-0.5)*2 + f.get("bdecay",0)*4*(random.random()-0.5)*2)
shake_y = int(f.get("bass",0.3)*y_amp*(random.random()-0.5)*2)
if abs(shake_x) > 0:
ch = np.roll(ch, shake_x, axis=1)
co = np.roll(co, shake_x, axis=1)
if abs(shake_y) > 0:
ch = np.roll(ch, shake_y, axis=0)
co = np.roll(co, shake_y, axis=0)
return ch, co
Composable Effect System
The real creative power comes from composition. There are three levels:
Level 1: Character-Level Layering
Stack multiple effects as (chars, colors) layers:
class LayerStack(EffectNode):
"""Render effects bottom-to-top with character-level compositing."""
def add(self, effect, alpha=1.0):
"""alpha < 1.0 = probabilistic override (sparse overlay)."""
self.layers.append((effect, alpha))
# Usage:
stack = LayerStack()
stack.add(bg_effect) # base — fills screen
stack.add(main_effect) # overlay on top (space chars = transparent)
stack.add(particle_effect) # sparse overlay on top of that
ch, co = stack.render(g, f, t, S)
Level 2: Pixel-Level Blending
After rendering to canvases, blend with Photoshop-style modes:
class PixelBlendStack:
"""Stack canvases with blend modes for complex compositing."""
def add(self, canvas, mode="normal", opacity=1.0)
def composite(self) -> canvas
# Usage:
pbs = PixelBlendStack()
pbs.add(canvas_a) # base
pbs.add(canvas_b, "screen", 0.7) # additive glow
pbs.add(canvas_c, "difference", 0.5) # psychedelic interference
result = pbs.composite()
Level 3: Temporal Feedback
Feed previous frame back into current frame for recursive effects:
fb = FeedbackBuffer()
for each frame:
canvas = render_current()
canvas = fb.apply(canvas, decay=0.8, blend="screen",
transform="zoom", transform_amt=0.015, hue_shift=0.02)
Effect Nodes — Uniform Interface
In the v2 protocol, effect nodes are used inside scene functions. The scene function itself returns a canvas. Effect nodes produce intermediate (chars, colors) that are rendered to canvas via the grid's .render() method or _render_vf().
class EffectNode:
def render(self, g, f, t, S) -> (chars, colors)
# Concrete implementations:
class ValueFieldEffect(EffectNode):
"""Wraps a value field function + hue field function + palette."""
def __init__(self, val_fn, hue_fn, pal=PAL_DEFAULT, sat=0.7)
class LambdaEffect(EffectNode):
"""Wrap any (g,f,t,S) -> (ch,co) function."""
def __init__(self, fn)
class ConditionalEffect(EffectNode):
"""Switch effects based on audio features."""
def __init__(self, condition, if_true, if_false=None)
Value Field Generators (Atomic Building Blocks)
These produce float32 arrays (rows, cols) in range [0,1]. They are the raw visual patterns. All have signature (g, f, t, S, **params) -> float32 array.
Trigonometric Fields (sine/cosine-based)
def vf_sinefield(g, f, t, S, bri=0.5,
freq=(0.13, 0.17, 0.07, 0.09), speed=(0.5, -0.4, -0.3, 0.2)):
"""Layered sine field. General purpose background/texture."""
v1 = np.sin(g.cc*freq[0] + t*speed[0]) * np.sin(g.rr*freq[1] - t*speed[1]) * 0.5 + 0.5
v2 = np.sin(g.cc*freq[2] - t*speed[2] + g.rr*freq[3]) * 0.4 + 0.5
v3 = np.sin(g.dist_n*5 + t*0.2) * 0.3 + 0.4
return np.clip((v1*0.35 + v2*0.35 + v3*0.3) * bri * (0.6 + f.get("rms",0.3)*0.6), 0, 1)
def vf_smooth_noise(g, f, t, S, octaves=3, bri=0.5):
"""Multi-octave sine approximation of Perlin noise."""
val = np.zeros((g.rows, g.cols), dtype=np.float32)
for i in range(octaves):
freq = 0.05 * (2 ** i); amp = 0.5 / (i + 1)
phase = t * (0.3 + i * 0.2)
val = val + np.sin(g.cc*freq + phase) * np.cos(g.rr*freq*0.7 - phase*0.5) * amp
return np.clip(val * 0.5 + 0.5, 0, 1) * bri
def vf_rings(g, f, t, S, n_base=6, spacing_base=4):
"""Concentric rings, bass-driven count and wobble."""
n = int(n_base + f.get("sub_r",0.3)*25 + f.get("bass",0.3)*10)
sp = spacing_base + f.get("bass_r",0.3)*7 + f.get("rms",0.3)*3
val = np.zeros((g.rows, g.cols), dtype=np.float32)
for ri in range(n):
rad = (ri+1)*sp + f.get("bdecay",0)*15
wobble = f.get("mid_r",0.3)*5*np.sin(g.angle*3+t*4)
rd = np.abs(g.dist - rad - wobble)
th = 1 + f.get("sub",0.3)*3
val = np.maximum(val, np.clip((1 - rd/th) * (0.4 + f.get("bass",0.3)*0.8), 0, 1))
return val
def vf_spiral(g, f, t, S, n_arms=3, tightness=2.5):
"""Logarithmic spiral arms."""
val = np.zeros((g.rows, g.cols), dtype=np.float32)
for ai in range(n_arms):
offset = ai * 2*np.pi / n_arms
log_r = np.log(g.dist + 1) * tightness
arm_phase = g.angle + offset - log_r + t * 0.8
arm_val = np.clip(np.cos(arm_phase * n_arms) * 0.6 + 0.2, 0, 1)
arm_val *= (0.4 + f.get("rms",0.3)*0.6) * np.clip(1 - g.dist_n*0.5, 0.2, 1)
val = np.maximum(val, arm_val)
return val
def vf_tunnel(g, f, t, S, speed=3.0, complexity=6):
"""Tunnel depth effect — infinite zoom feeling."""
tunnel_d = 1.0 / (g.dist_n + 0.1)
v1 = np.sin(tunnel_d*2 - t*speed) * 0.45 + 0.55
v2 = np.sin(g.angle*complexity + tunnel_d*1.5 - t*2) * 0.35 + 0.55
return np.clip(v1*0.5 + v2*0.5, 0, 1)
def vf_vortex(g, f, t, S, twist=3.0):
"""Twisting radial pattern — distance modulates angle."""
twisted = g.angle + g.dist_n * twist * np.sin(t * 0.5)
val = np.sin(twisted * 4 - t * 2) * 0.5 + 0.5
return np.clip(val * (0.5 + f.get("bass",0.3)*0.8), 0, 1)
def vf_interference(g, f, t, S, n_waves=6):
"""Overlapping sine waves creating moire patterns."""
drivers = ["mid_r", "himid_r", "bass_r", "lomid_r", "hi_r", "sub_r"]
vals = np.zeros((g.rows, g.cols), dtype=np.float32)
for i in range(min(n_waves, len(drivers))):
angle = i * np.pi / n_waves
freq = 0.06 + i * 0.03; sp = 0.5 + i * 0.3
proj = g.cc * np.cos(angle) + g.rr * np.sin(angle)
vals = vals + np.sin(proj*freq + t*sp) * f.get(drivers[i], 0.3) * 2.5
return np.clip(vals * 0.12 + 0.45, 0.1, 1)
def vf_aurora(g, f, t, S, n_bands=3):
"""Horizontal aurora bands."""
val = np.zeros((g.rows, g.cols), dtype=np.float32)
for i in range(n_bands):
fr = 0.08 + i*0.04; fc = 0.012 + i*0.008
sr = 0.7 + i*0.3; sc = 0.18 + i*0.12
val = val + np.sin(g.rr*fr + t*sr) * np.sin(g.cc*fc + t*sc) * (0.6/n_bands)
return np.clip(val * (f.get("lomid_r",0.3)*3 + 0.2), 0, 0.7)
def vf_ripple(g, f, t, S, sources=None, freq=0.3, damping=0.02):
"""Concentric ripples from point sources."""
if sources is None: sources = [(0.5, 0.5)]
val = np.zeros((g.rows, g.cols), dtype=np.float32)
for ry, rx in sources:
dy = g.rr - g.rows*ry; dx = g.cc - g.cols*rx
d = np.sqrt(dy**2 + dx**2)
val = val + np.sin(d*freq - t*4) * np.exp(-d*damping) * 0.5
return np.clip(val + 0.5, 0, 1)
def vf_plasma(g, f, t, S):
"""Classic plasma: sum of sines at different orientations and speeds."""
v = np.sin(g.cc * 0.03 + t * 0.7) * 0.5
v = v + np.sin(g.rr * 0.04 - t * 0.5) * 0.4
v = v + np.sin((g.cc * 0.02 + g.rr * 0.03) + t * 0.3) * 0.3
v = v + np.sin(g.dist_n * 4 - t * 0.8) * 0.3
return np.clip(v * 0.5 + 0.5, 0, 1)
def vf_diamond(g, f, t, S, freq=0.15):
"""Diamond/checkerboard pattern."""
val = np.abs(np.sin(g.cc * freq + t * 0.5)) * np.abs(np.sin(g.rr * freq * 1.2 - t * 0.3))
return np.clip(val * (0.6 + f.get("rms",0.3)*0.8), 0, 1)
def vf_noise_static(g, f, t, S, density=0.4):
"""Random noise — different each frame. Non-deterministic."""
return np.random.random((g.rows, g.cols)).astype(np.float32) * density * (0.5 + f.get("rms",0.3)*0.5)
Noise-Based Fields (organic, non-periodic)
These produce qualitatively different textures from sine-based fields — organic, non-repeating, without visible axis alignment. They're the foundation of high-end generative art.
def _hash2d(ix, iy):
"""Integer-coordinate hash for gradient noise. Returns float32 in [0,1]."""
# Good-quality hash via large prime mixing
n = ix * 374761393 + iy * 668265263
n = (n ^ (n >> 13)) * 1274126177
return ((n ^ (n >> 16)) & 0x7fffffff).astype(np.float32) / 0x7fffffff
def _smoothstep(t):
"""Hermite smoothstep: 3t^2 - 2t^3. Smooth interpolation in [0,1]."""
t = np.clip(t, 0, 1)
return t * t * (3 - 2 * t)
def _smootherstep(t):
"""Perlin's improved smoothstep: 6t^5 - 15t^4 + 10t^3. C2-continuous."""
t = np.clip(t, 0, 1)
return t * t * t * (t * (t * 6 - 15) + 10)
def _value_noise_2d(x, y):
"""2D value noise at arbitrary float coordinates. Returns float32 in [0,1].
x, y: float32 arrays of same shape."""
ix = np.floor(x).astype(np.int64)
iy = np.floor(y).astype(np.int64)
fx = _smootherstep(x - ix)
fy = _smootherstep(y - iy)
# 4-corner hashes
n00 = _hash2d(ix, iy)
n10 = _hash2d(ix + 1, iy)
n01 = _hash2d(ix, iy + 1)
n11 = _hash2d(ix + 1, iy + 1)
# Bilinear interpolation
nx0 = n00 * (1 - fx) + n10 * fx
nx1 = n01 * (1 - fx) + n11 * fx
return nx0 * (1 - fy) + nx1 * fy
def vf_noise(g, f, t, S, freq=0.08, speed=0.3, bri=0.7):
"""Value noise. Smooth, organic, no axis alignment artifacts.
freq: spatial frequency (higher = finer detail).
speed: temporal scroll rate."""
x = g.cc * freq + t * speed
y = g.rr * freq * 0.8 - t * speed * 0.4
return np.clip(_value_noise_2d(x, y) * bri, 0, 1)
def vf_fbm(g, f, t, S, octaves=5, freq=0.06, lacunarity=2.0, gain=0.5,
speed=0.2, bri=0.8):
"""Fractal Brownian Motion — octaved noise with lacunarity/gain control.
The standard building block for clouds, terrain, smoke, organic textures.
octaves: number of noise layers (more = finer detail, more cost)
freq: base spatial frequency
lacunarity: frequency multiplier per octave (2.0 = standard)
gain: amplitude multiplier per octave (0.5 = standard, <0.5 = smoother)
speed: temporal evolution rate
"""
val = np.zeros((g.rows, g.cols), dtype=np.float32)
amplitude = 1.0
f_x = freq
f_y = freq * 0.85 # slight anisotropy avoids grid artifacts
for i in range(octaves):
phase = t * speed * (1 + i * 0.3)
x = g.cc * f_x + phase + i * 17.3 # offset per octave
y = g.rr * f_y - phase * 0.6 + i * 31.7
val = val + _value_noise_2d(x, y) * amplitude
amplitude *= gain
f_x *= lacunarity
f_y *= lacunarity
# Normalize to [0,1]
max_amp = (1 - gain ** octaves) / (1 - gain) if gain != 1 else octaves
return np.clip(val / max_amp * bri * (0.6 + f.get("rms", 0.3) * 0.6), 0, 1)
def vf_domain_warp(g, f, t, S, base_fn=None, warp_fn=None,
warp_strength=15.0, freq=0.06, speed=0.2):
"""Domain warping — feed one noise field's output as coordinate offsets
into another noise field. Produces flowing, melting organic distortion.
Signature technique of high-end generative art (Inigo Quilez).
base_fn: value field to distort (default: fbm)
warp_fn: value field for displacement (default: noise at different freq)
warp_strength: how many grid cells to displace (higher = more warped)
"""
# Warp field: displacement in x and y
wx = _value_noise_2d(g.cc * freq * 1.3 + t * speed, g.rr * freq + 7.1)
wy = _value_noise_2d(g.cc * freq + t * speed * 0.7 + 3.2, g.rr * freq * 1.1 - 11.8)
# Center warp around 0 (noise returns [0,1], shift to [-0.5, 0.5])
wx = (wx - 0.5) * warp_strength * (0.5 + f.get("rms", 0.3) * 1.0)
wy = (wy - 0.5) * warp_strength * (0.5 + f.get("bass", 0.3) * 0.8)
# Sample base field at warped coordinates
warped_cc = g.cc + wx
warped_rr = g.rr + wy
if base_fn is not None:
# Create a temporary grid-like object with warped coords
# Simplification: evaluate base_fn with modified coordinates
val = _value_noise_2d(warped_cc * freq * 0.8 + t * speed * 0.5,
warped_rr * freq * 0.7 - t * speed * 0.3)
else:
# Default: fbm at warped coordinates
val = np.zeros((g.rows, g.cols), dtype=np.float32)
amp = 1.0
fx, fy = freq * 0.8, freq * 0.7
for i in range(4):
val = val + _value_noise_2d(warped_cc * fx + t * speed * 0.5 + i * 13.7,
warped_rr * fy - t * speed * 0.3 + i * 27.3) * amp
amp *= 0.5; fx *= 2.0; fy *= 2.0
val = val / 1.875 # normalize 4-octave sum
return np.clip(val * 0.8, 0, 1)
def vf_voronoi(g, f, t, S, n_cells=20, speed=0.3, edge_width=1.5,
mode="distance", seed=42):
"""Voronoi diagram as value field. Proper implementation with
nearest/second-nearest distance for cell interiors and edges.
mode: "distance" (bright at center, dark at edges),
"edge" (bright at cell boundaries),
"cell_id" (flat color per cell — use with discrete palette)
edge_width: thickness of edge highlight (for "edge" mode)
"""
rng = np.random.RandomState(seed)
# Animated cell centers
cx = rng.rand(n_cells).astype(np.float32) * g.cols
cy = rng.rand(n_cells).astype(np.float32) * g.rows
vx = (rng.rand(n_cells).astype(np.float32) - 0.5) * speed * 10
vy = (rng.rand(n_cells).astype(np.float32) - 0.5) * speed * 10
cx_t = (cx + vx * np.sin(t * 0.5 + np.arange(n_cells) * 0.8)) % g.cols
cy_t = (cy + vy * np.cos(t * 0.4 + np.arange(n_cells) * 1.1)) % g.rows
# Compute nearest and second-nearest distance
d1 = np.full((g.rows, g.cols), 1e9, dtype=np.float32)
d2 = np.full((g.rows, g.cols), 1e9, dtype=np.float32)
id1 = np.zeros((g.rows, g.cols), dtype=np.int32)
for i in range(n_cells):
d = np.sqrt((g.cc - cx_t[i]) ** 2 + (g.rr - cy_t[i]) ** 2)
mask = d < d1
d2 = np.where(mask, d1, np.minimum(d2, d))
id1 = np.where(mask, i, id1)
d1 = np.minimum(d1, d)
if mode == "edge":
# Edges: where d2 - d1 is small
edge_val = np.clip(1.0 - (d2 - d1) / edge_width, 0, 1)
return edge_val * (0.5 + f.get("rms", 0.3) * 0.8)
elif mode == "cell_id":
# Flat per-cell value
return (id1.astype(np.float32) / n_cells) % 1.0
else:
# Distance: bright near center, dark at edges
max_d = g.cols * 0.15
return np.clip(1.0 - d1 / max_d, 0, 1) * (0.5 + f.get("rms", 0.3) * 0.7)
Simulation-Based Fields (emergent, evolving)
These use persistent state S to evolve patterns frame-by-frame. They produce complexity that can't be achieved with stateless math.
def vf_reaction_diffusion(g, f, t, S, feed=0.055, kill=0.062,
da=1.0, db=0.5, dt=1.0, steps_per_frame=8,
init_mode="spots"):
"""Gray-Scott reaction-diffusion model. Produces coral, leopard spots,
mitosis, worm-like, and labyrinthine patterns depending on feed/kill.
The two chemicals A and B interact:
A + 2B → 3B (autocatalytic)
B → P (decay)
feed: rate A is replenished, kill: rate B decays
Different feed/kill ratios produce radically different patterns.
Presets (feed, kill):
Spots/dots: (0.055, 0.062)
Worms/stripes: (0.046, 0.063)
Coral/branching: (0.037, 0.060)
Mitosis/splitting: (0.028, 0.062)
Labyrinth/maze: (0.029, 0.057)
Holes/negative: (0.039, 0.058)
Chaos/unstable: (0.026, 0.051)
steps_per_frame: simulation steps per video frame (more = faster evolution)
"""
key = "rd_" + str(id(g)) # unique per grid
if key + "_a" not in S:
# Initialize chemical fields
A = np.ones((g.rows, g.cols), dtype=np.float32)
B = np.zeros((g.rows, g.cols), dtype=np.float32)
if init_mode == "spots":
# Random seed spots
rng = np.random.RandomState(42)
for _ in range(max(3, g.rows * g.cols // 200)):
r, c = rng.randint(2, g.rows - 2), rng.randint(2, g.cols - 2)
B[r - 1:r + 2, c - 1:c + 2] = 1.0
elif init_mode == "center":
cr, cc = g.rows // 2, g.cols // 2
B[cr - 3:cr + 3, cc - 3:cc + 3] = 1.0
elif init_mode == "ring":
mask = (g.dist_n > 0.2) & (g.dist_n < 0.3)
B[mask] = 1.0
S[key + "_a"] = A
S[key + "_b"] = B
A = S[key + "_a"]
B = S[key + "_b"]
# Audio modulation: feed/kill shift subtly with audio
f_mod = feed + f.get("bass", 0.3) * 0.003
k_mod = kill + f.get("hi_r", 0.3) * 0.002
for _ in range(steps_per_frame):
# Laplacian via 3x3 convolution kernel
# [0.05, 0.2, 0.05]
# [0.2, -1.0, 0.2]
# [0.05, 0.2, 0.05]
pA = np.pad(A, 1, mode="wrap")
pB = np.pad(B, 1, mode="wrap")
lapA = (pA[:-2, 1:-1] + pA[2:, 1:-1] + pA[1:-1, :-2] + pA[1:-1, 2:]) * 0.2 \
+ (pA[:-2, :-2] + pA[:-2, 2:] + pA[2:, :-2] + pA[2:, 2:]) * 0.05 \
- A * 1.0
lapB = (pB[:-2, 1:-1] + pB[2:, 1:-1] + pB[1:-1, :-2] + pB[1:-1, 2:]) * 0.2 \
+ (pB[:-2, :-2] + pB[:-2, 2:] + pB[2:, :-2] + pB[2:, 2:]) * 0.05 \
- B * 1.0
ABB = A * B * B
A = A + (da * lapA - ABB + f_mod * (1 - A)) * dt
B = B + (db * lapB + ABB - (f_mod + k_mod) * B) * dt
A = np.clip(A, 0, 1)
B = np.clip(B, 0, 1)
S[key + "_a"] = A
S[key + "_b"] = B
# Output B chemical as value (the visible pattern)
return np.clip(B * 2.0, 0, 1)
def vf_game_of_life(g, f, t, S, rule="life", birth=None, survive=None,
steps_per_frame=1, density=0.3, fade=0.92, seed=42):
"""Cellular automaton as value field with analog fade trails.
Grid cells are born/die by neighbor count rules. Dead cells fade
gradually instead of snapping to black, producing ghost trails.
rule presets:
"life": B3/S23 (Conway's Game of Life)
"coral": B3/S45678 (slow crystalline growth)
"maze": B3/S12345 (fills to labyrinth)
"anneal": B4678/S35678 (smooth blobs)
"day_night": B3678/S34678 (balanced growth/decay)
Or specify birth/survive directly as sets: birth={3}, survive={2,3}
fade: how fast dead cells dim (0.9 = slow trails, 0.5 = fast)
"""
presets = {
"life": ({3}, {2, 3}),
"coral": ({3}, {4, 5, 6, 7, 8}),
"maze": ({3}, {1, 2, 3, 4, 5}),
"anneal": ({4, 6, 7, 8}, {3, 5, 6, 7, 8}),
"day_night": ({3, 6, 7, 8}, {3, 4, 6, 7, 8}),
}
if birth is None or survive is None:
birth, survive = presets.get(rule, presets["life"])
key = "gol_" + str(id(g))
if key + "_grid" not in S:
rng = np.random.RandomState(seed)
S[key + "_grid"] = (rng.random((g.rows, g.cols)) < density).astype(np.float32)
S[key + "_display"] = S[key + "_grid"].copy()
grid = S[key + "_grid"]
display = S[key + "_display"]
# Beat can inject random noise
if f.get("beat", 0) > 0.5:
inject = np.random.random((g.rows, g.cols)) < 0.02
grid = np.clip(grid + inject.astype(np.float32), 0, 1)
for _ in range(steps_per_frame):
# Count neighbors (toroidal wrap)
padded = np.pad(grid > 0.5, 1, mode="wrap").astype(np.int8)
neighbors = (padded[:-2, :-2] + padded[:-2, 1:-1] + padded[:-2, 2:] +
padded[1:-1, :-2] + padded[1:-1, 2:] +
padded[2:, :-2] + padded[2:, 1:-1] + padded[2:, 2:])
alive = grid > 0.5
new_alive = np.zeros_like(grid, dtype=bool)
for b in birth:
new_alive |= (~alive) & (neighbors == b)
for s in survive:
new_alive |= alive & (neighbors == s)
grid = new_alive.astype(np.float32)
# Analog display: alive cells = 1.0, dead cells fade
display = np.where(grid > 0.5, 1.0, display * fade)
S[key + "_grid"] = grid
S[key + "_display"] = display
return np.clip(display, 0, 1)
def vf_strange_attractor(g, f, t, S, attractor="clifford",
n_points=50000, warmup=500, bri=0.8, seed=42,
params=None):
"""Strange attractor projected to 2D density field.
Iterates N points through attractor equations, bins to grid,
produces a density map. Elegant, non-repeating curves.
attractor presets:
"clifford": sin(a*y) + c*cos(a*x), sin(b*x) + d*cos(b*y)
"de_jong": sin(a*y) - cos(b*x), sin(c*x) - cos(d*y)
"bedhead": sin(x*y/b) + cos(a*x - y), x*sin(a*y) + cos(b*x - y)
params: (a, b, c, d) floats — each attractor has different sweet spots.
If None, uses time-varying defaults for animation.
"""
key = "attr_" + attractor
if params is None:
# Time-varying parameters for slow morphing
a = -1.4 + np.sin(t * 0.05) * 0.3
b = 1.6 + np.cos(t * 0.07) * 0.2
c = 1.0 + np.sin(t * 0.03 + 1) * 0.3
d = 0.7 + np.cos(t * 0.04 + 2) * 0.2
else:
a, b, c, d = params
# Iterate attractor
rng = np.random.RandomState(seed)
x = rng.uniform(-0.1, 0.1, n_points).astype(np.float64)
y = rng.uniform(-0.1, 0.1, n_points).astype(np.float64)
# Warmup iterations (reach the attractor)
for _ in range(warmup):
if attractor == "clifford":
xn = np.sin(a * y) + c * np.cos(a * x)
yn = np.sin(b * x) + d * np.cos(b * y)
elif attractor == "de_jong":
xn = np.sin(a * y) - np.cos(b * x)
yn = np.sin(c * x) - np.cos(d * y)
elif attractor == "bedhead":
xn = np.sin(x * y / b) + np.cos(a * x - y)
yn = x * np.sin(a * y) + np.cos(b * x - y)
else:
xn = np.sin(a * y) + c * np.cos(a * x)
yn = np.sin(b * x) + d * np.cos(b * y)
x, y = xn, yn
# Bin to grid
# Find bounds
margin = 0.1
x_min, x_max = x.min() - margin, x.max() + margin
y_min, y_max = y.min() - margin, y.max() + margin
# Map to grid coordinates
gx = ((x - x_min) / (x_max - x_min) * (g.cols - 1)).astype(np.int32)
gy = ((y - y_min) / (y_max - y_min) * (g.rows - 1)).astype(np.int32)
valid = (gx >= 0) & (gx < g.cols) & (gy >= 0) & (gy < g.rows)
gx, gy = gx[valid], gy[valid]
# Accumulate density
density = np.zeros((g.rows, g.cols), dtype=np.float32)
np.add.at(density, (gy, gx), 1.0)
# Log-scale density for visibility (most bins have few hits)
density = np.log1p(density)
mx = density.max()
if mx > 0:
density = density / mx
return np.clip(density * bri * (0.5 + f.get("rms", 0.3) * 0.8), 0, 1)
SDF-Based Fields (geometric precision)
Signed Distance Fields produce mathematically precise shapes. Unlike sine fields (organic, blurry), SDFs give hard geometric boundaries with controllable edge softness. Combined with domain warping, they create "melting geometry" effects.
All SDF primitives return a signed distance (negative inside, positive outside). Convert to a value field with sdf_render().
def sdf_render(dist, edge_width=1.5, invert=False):
"""Convert signed distance to value field [0,1].
edge_width: controls anti-aliasing / softness of the boundary.
invert: True = bright inside shape, False = bright outside."""
val = 1.0 - np.clip(dist / edge_width, 0, 1) if not invert else np.clip(dist / edge_width, 0, 1)
return np.clip(val, 0, 1)
def sdf_glow(dist, falloff=0.05):
"""Render SDF as glowing outline — bright at boundary, fading both directions."""
return np.clip(np.exp(-np.abs(dist) * falloff), 0, 1)
# --- Primitives ---
def sdf_circle(g, cx_frac=0.5, cy_frac=0.5, radius=0.3):
"""Circle SDF. cx/cy/radius in normalized [0,1] coordinates."""
dx = (g.cc / g.cols - cx_frac) * (g.cols / g.rows) # aspect correction
dy = g.rr / g.rows - cy_frac
return np.sqrt(dx**2 + dy**2) - radius
def sdf_box(g, cx_frac=0.5, cy_frac=0.5, w=0.3, h=0.2, round_r=0.0):
"""Rounded rectangle SDF."""
dx = np.abs(g.cc / g.cols - cx_frac) * (g.cols / g.rows) - w + round_r
dy = np.abs(g.rr / g.rows - cy_frac) - h + round_r
outside = np.sqrt(np.maximum(dx, 0)**2 + np.maximum(dy, 0)**2)
inside = np.minimum(np.maximum(dx, dy), 0)
return outside + inside - round_r
def sdf_ring(g, cx_frac=0.5, cy_frac=0.5, radius=0.3, thickness=0.03):
"""Ring (annulus) SDF."""
d = sdf_circle(g, cx_frac, cy_frac, radius)
return np.abs(d) - thickness
def sdf_line(g, x0=0.2, y0=0.5, x1=0.8, y1=0.5, thickness=0.01):
"""Line segment SDF between two points (normalized coords)."""
ax = g.cc / g.cols * (g.cols / g.rows) - x0 * (g.cols / g.rows)
ay = g.rr / g.rows - y0
bx = (x1 - x0) * (g.cols / g.rows)
by = y1 - y0
h = np.clip((ax * bx + ay * by) / (bx * bx + by * by + 1e-10), 0, 1)
dx = ax - bx * h
dy = ay - by * h
return np.sqrt(dx**2 + dy**2) - thickness
def sdf_triangle(g, cx=0.5, cy=0.5, size=0.25):
"""Equilateral triangle SDF centered at (cx, cy)."""
px = (g.cc / g.cols - cx) * (g.cols / g.rows) / size
py = (g.rr / g.rows - cy) / size
# Equilateral triangle math
k = np.sqrt(3.0)
px = np.abs(px) - 1.0
py = py + 1.0 / k
cond = px + k * py > 0
px2 = np.where(cond, (px - k * py) / 2.0, px)
py2 = np.where(cond, (-k * px - py) / 2.0, py)
px2 = np.clip(px2, -2.0, 0.0)
return -np.sqrt(px2**2 + py2**2) * np.sign(py2) * size
def sdf_star(g, cx=0.5, cy=0.5, n_points=5, outer_r=0.25, inner_r=0.12):
"""Star polygon SDF — n-pointed star."""
px = (g.cc / g.cols - cx) * (g.cols / g.rows)
py = g.rr / g.rows - cy
angle = np.arctan2(py, px)
dist = np.sqrt(px**2 + py**2)
# Modular angle for star symmetry
wedge = 2 * np.pi / n_points
a = np.abs((angle % wedge) - wedge / 2)
# Interpolate radius between inner and outer
r_at_angle = inner_r + (outer_r - inner_r) * np.clip(np.cos(a * n_points) * 0.5 + 0.5, 0, 1)
return dist - r_at_angle
def sdf_heart(g, cx=0.5, cy=0.45, size=0.25):
"""Heart shape SDF."""
px = (g.cc / g.cols - cx) * (g.cols / g.rows) / size
py = -(g.rr / g.rows - cy) / size + 0.3 # flip y, offset
px = np.abs(px)
cond = (px + py) > 1.0
d1 = np.sqrt((px - 0.25)**2 + (py - 0.75)**2) - np.sqrt(2.0) / 4.0
d2 = np.sqrt((px + py - 1.0)**2) / np.sqrt(2.0)
return np.where(cond, d1, d2) * size
# --- Combinators ---
def sdf_union(d1, d2):
"""Boolean union — shape is wherever either SDF is inside."""
return np.minimum(d1, d2)
def sdf_intersect(d1, d2):
"""Boolean intersection — shape is where both SDFs overlap."""
return np.maximum(d1, d2)
def sdf_subtract(d1, d2):
"""Boolean subtraction — d1 minus d2."""
return np.maximum(d1, -d2)
def sdf_smooth_union(d1, d2, k=0.1):
"""Smooth minimum (polynomial) — blends shapes with rounded join.
k: smoothing radius. Higher = more rounding."""
h = np.clip(0.5 + 0.5 * (d2 - d1) / k, 0, 1)
return d2 * (1 - h) + d1 * h - k * h * (1 - h)
def sdf_smooth_subtract(d1, d2, k=0.1):
"""Smooth subtraction — d1 minus d2 with rounded edge."""
return sdf_smooth_union(d1, -d2, k)
def sdf_repeat(g, sdf_fn, spacing_x=0.25, spacing_y=0.25, **sdf_kwargs):
"""Tile an SDF primitive infinitely. spacing in normalized coords."""
# Modular coordinates
mod_cc = (g.cc / g.cols) % spacing_x - spacing_x / 2
mod_rr = (g.rr / g.rows) % spacing_y - spacing_y / 2
# Create modified grid-like arrays for the SDF
# This is a simplified approach — build a temporary namespace
class ModGrid:
pass
mg = ModGrid()
mg.cc = mod_cc * g.cols; mg.rr = mod_rr * g.rows
mg.cols = g.cols; mg.rows = g.rows
return sdf_fn(mg, **sdf_kwargs)
# --- SDF as Value Field ---
def vf_sdf(g, f, t, S, sdf_fn=sdf_circle, edge_width=1.5, glow=False,
glow_falloff=0.03, animate=True, **sdf_kwargs):
"""Wrap any SDF primitive as a standard vf_* value field.
If animate=True, applies slow rotation and breathing to the shape."""
if animate:
sdf_kwargs.setdefault("cx_frac", 0.5)
sdf_kwargs.setdefault("cy_frac", 0.5)
d = sdf_fn(g, **sdf_kwargs)
if glow:
return sdf_glow(d, glow_falloff) * (0.5 + f.get("rms", 0.3) * 0.8)
return sdf_render(d, edge_width) * (0.5 + f.get("rms", 0.3) * 0.8)
Hue Field Generators (Color Mapping)
These produce float32 hue arrays [0,1]. Independently combinable with any value field. Each is a factory returning a closure with signature (g, f, t, S) -> float32 array. Can also be a plain float for fixed hue.
def hf_fixed(hue):
"""Single hue everywhere."""
def fn(g, f, t, S):
return np.full((g.rows, g.cols), hue, dtype=np.float32)
return fn
def hf_angle(offset=0.0):
"""Hue mapped to angle from center — rainbow wheel."""
def fn(g, f, t, S):
return (g.angle / (2 * np.pi) + offset + t * 0.05) % 1.0
return fn
def hf_distance(base=0.5, scale=0.02):
"""Hue mapped to distance from center."""
def fn(g, f, t, S):
return (base + g.dist * scale + t * 0.03) % 1.0
return fn
def hf_time_cycle(speed=0.1):
"""Hue cycles uniformly over time."""
def fn(g, f, t, S):
return np.full((g.rows, g.cols), (t * speed) % 1.0, dtype=np.float32)
return fn
def hf_audio_cent():
"""Hue follows spectral centroid — timbral color shifting."""
def fn(g, f, t, S):
return np.full((g.rows, g.cols), f.get("cent", 0.5) * 0.3, dtype=np.float32)
return fn
def hf_gradient_h(start=0.0, end=1.0):
"""Left-to-right hue gradient."""
def fn(g, f, t, S):
h = np.broadcast_to(
start + (g.cc / g.cols) * (end - start),
(g.rows, g.cols)
).copy() # .copy() is CRITICAL — see troubleshooting.md
return h % 1.0
return fn
def hf_gradient_v(start=0.0, end=1.0):
"""Top-to-bottom hue gradient."""
def fn(g, f, t, S):
h = np.broadcast_to(
start + (g.rr / g.rows) * (end - start),
(g.rows, g.cols)
).copy()
return h % 1.0
return fn
def hf_plasma(speed=0.3):
"""Plasma-style hue field — organic color variation."""
def fn(g, f, t, S):
return (np.sin(g.cc*0.02 + t*speed)*0.5 + np.sin(g.rr*0.015 + t*speed*0.7)*0.5) % 1.0
return fn
Coordinate Transforms
UV-space transforms applied before effect evaluation. Any vf_* function can be rotated, zoomed, tiled, or distorted by transforming the grid coordinates it sees.
Transform Helpers
def uv_rotate(g, angle):
"""Rotate UV coordinates around grid center.
Returns (rotated_cc, rotated_rr) arrays — use in place of g.cc, g.rr."""
cx, cy = g.cols / 2.0, g.rows / 2.0
cos_a, sin_a = np.cos(angle), np.sin(angle)
dx = g.cc - cx
dy = g.rr - cy
return cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a
def uv_scale(g, sx=1.0, sy=1.0, cx_frac=0.5, cy_frac=0.5):
"""Scale UV coordinates around a center point.
sx, sy > 1 = zoom in (fewer repeats), < 1 = zoom out (more repeats)."""
cx = g.cols * cx_frac; cy = g.rows * cy_frac
return cx + (g.cc - cx) / sx, cy + (g.rr - cy) / sy
def uv_skew(g, kx=0.0, ky=0.0):
"""Skew UV coordinates. kx shears horizontally, ky vertically."""
return g.cc + g.rr * kx, g.rr + g.cc * ky
def uv_tile(g, nx=3.0, ny=3.0, mirror=False):
"""Tile UV coordinates. nx, ny = number of repeats.
mirror=True: alternating tiles are flipped (seamless)."""
u = (g.cc / g.cols * nx) % 1.0
v = (g.rr / g.rows * ny) % 1.0
if mirror:
flip_u = ((g.cc / g.cols * nx).astype(int) % 2) == 1
flip_v = ((g.rr / g.rows * ny).astype(int) % 2) == 1
u = np.where(flip_u, 1.0 - u, u)
v = np.where(flip_v, 1.0 - v, v)
return u * g.cols, v * g.rows
def uv_polar(g):
"""Convert Cartesian to polar UV. Returns (angle_as_cc, dist_as_rr).
Use to make any linear effect radial."""
# Angle wraps [0, cols), distance wraps [0, rows)
return g.angle / (2 * np.pi) * g.cols, g.dist_n * g.rows
def uv_cartesian_from_polar(g):
"""Convert polar-addressed effects back to Cartesian.
Treats g.cc as angle and g.rr as radius."""
angle = g.cc / g.cols * 2 * np.pi
radius = g.rr / g.rows
cx, cy = g.cols / 2.0, g.rows / 2.0
return cx + radius * np.cos(angle) * cx, cy + radius * np.sin(angle) * cy
def uv_twist(g, amount=2.0):
"""Twist: rotation increases with distance from center. Creates spiral distortion."""
twist_angle = g.dist_n * amount
return uv_rotate_raw(g.cc, g.rr, g.cols / 2, g.rows / 2, twist_angle)
def uv_rotate_raw(cc, rr, cx, cy, angle):
"""Raw rotation on arbitrary coordinate arrays."""
cos_a, sin_a = np.cos(angle), np.sin(angle)
dx = cc - cx; dy = rr - cy
return cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a
def uv_fisheye(g, strength=1.5):
"""Fisheye / barrel distortion on UV coordinates."""
cx, cy = g.cols / 2.0, g.rows / 2.0
dx = (g.cc - cx) / cx
dy = (g.rr - cy) / cy
r = np.sqrt(dx**2 + dy**2)
r_distort = np.power(r, strength)
scale = np.where(r > 0, r_distort / (r + 1e-10), 1.0)
return cx + dx * scale * cx, cy + dy * scale * cy
def uv_wave(g, t, freq=0.1, amp=3.0, axis="x"):
"""Sinusoidal coordinate displacement. Wobbles the UV space."""
if axis == "x":
return g.cc + np.sin(g.rr * freq + t * 3) * amp, g.rr
else:
return g.cc, g.rr + np.sin(g.cc * freq + t * 3) * amp
def uv_mobius(g, a=1.0, b=0.0, c=0.0, d=1.0):
"""Möbius transformation (conformal map): f(z) = (az + b) / (cz + d).
Operates on complex plane. Produces mathematically precise, visually
striking inversions and circular transforms."""
cx, cy = g.cols / 2.0, g.rows / 2.0
# Map grid to complex plane [-1, 1]
zr = (g.cc - cx) / cx
zi = (g.rr - cy) / cy
# Complex division: (a*z + b) / (c*z + d)
num_r = a * zr - 0 * zi + b # imaginary parts of a,b,c,d = 0 for real params
num_i = a * zi + 0 * zr + 0
den_r = c * zr - 0 * zi + d
den_i = c * zi + 0 * zr + 0
denom = den_r**2 + den_i**2 + 1e-10
wr = (num_r * den_r + num_i * den_i) / denom
wi = (num_i * den_r - num_r * den_i) / denom
return cx + wr * cx, cy + wi * cy
Using Transforms with Value Fields
Transforms modify what coordinates a value field sees. Wrap the transform around the vf_* call:
# Rotate a plasma field 45 degrees
def vf_rotated_plasma(g, f, t, S):
rc, rr = uv_rotate(g, np.pi / 4 + t * 0.1)
class TG: # transformed grid
pass
tg = TG(); tg.cc = rc; tg.rr = rr
tg.rows = g.rows; tg.cols = g.cols
tg.dist_n = g.dist_n; tg.angle = g.angle; tg.dist = g.dist
return vf_plasma(tg, f, t, S)
# Tile a vortex 3x3 with mirror
def vf_tiled_vortex(g, f, t, S):
tc, tr = uv_tile(g, 3, 3, mirror=True)
class TG:
pass
tg = TG(); tg.cc = tc; tg.rr = tr
tg.rows = g.rows; tg.cols = g.cols
tg.dist = np.sqrt((tc - g.cols/2)**2 + (tr - g.rows/2)**2)
tg.dist_n = tg.dist / (tg.dist.max() + 1e-10)
tg.angle = np.arctan2(tr - g.rows/2, tc - g.cols/2)
return vf_vortex(tg, f, t, S)
# Helper: create transformed grid from coordinate arrays
def make_tgrid(g, new_cc, new_rr):
"""Build a grid-like object with transformed coordinates.
Preserves rows/cols for sizing, recomputes polar coords."""
class TG:
pass
tg = TG()
tg.cc = new_cc; tg.rr = new_rr
tg.rows = g.rows; tg.cols = g.cols
cx, cy = g.cols / 2.0, g.rows / 2.0
dx = new_cc - cx; dy = new_rr - cy
tg.dist = np.sqrt(dx**2 + dy**2)
tg.dist_n = tg.dist / (max(cx, cy) + 1e-10)
tg.angle = np.arctan2(dy, dx)
tg.dx = dx; tg.dy = dy
tg.dx_n = dx / max(g.cols, 1)
tg.dy_n = dy / max(g.rows, 1)
return tg
Temporal Coherence
Tools for smooth, intentional parameter evolution over time. Replaces the default pattern of either static parameters or raw audio reactivity.
Easing Functions
Standard animation easing curves. All take t in [0,1] and return [0,1]:
def ease_linear(t): return t
def ease_in_quad(t): return t * t
def ease_out_quad(t): return t * (2 - t)
def ease_in_out_quad(t): return np.where(t < 0.5, 2*t*t, -1 + (4-2*t)*t)
def ease_in_cubic(t): return t**3
def ease_out_cubic(t): return (t - 1)**3 + 1
def ease_in_out_cubic(t):
return np.where(t < 0.5, 4*t**3, 1 - (-2*t + 2)**3 / 2)
def ease_in_expo(t): return np.where(t == 0, 0, 2**(10*(t-1)))
def ease_out_expo(t): return np.where(t == 1, 1, 1 - 2**(-10*t))
def ease_elastic(t):
"""Elastic ease-out — overshoots then settles."""
return np.where(t == 0, 0, np.where(t == 1, 1,
2**(-10*t) * np.sin((t*10 - 0.75) * (2*np.pi) / 3) + 1))
def ease_bounce(t):
"""Bounce ease-out — bounces at the end."""
t = np.asarray(t, dtype=np.float64)
result = np.empty_like(t)
m1 = t < 1/2.75
m2 = (~m1) & (t < 2/2.75)
m3 = (~m1) & (~m2) & (t < 2.5/2.75)
m4 = ~(m1 | m2 | m3)
result[m1] = 7.5625 * t[m1]**2
t2 = t[m2] - 1.5/2.75; result[m2] = 7.5625 * t2**2 + 0.75
t3 = t[m3] - 2.25/2.75; result[m3] = 7.5625 * t3**2 + 0.9375
t4 = t[m4] - 2.625/2.75; result[m4] = 7.5625 * t4**2 + 0.984375
return result
Keyframe Interpolation
Define parameter values at specific times. Interpolates between them with easing:
def keyframe(t, points, ease_fn=ease_in_out_cubic, loop=False):
"""Interpolate between keyframed values.
Args:
t: current time (float, seconds)
points: list of (time, value) tuples, sorted by time
ease_fn: easing function for interpolation
loop: if True, wraps around after last keyframe
Returns:
interpolated value at time t
Example:
twist = keyframe(t, [(0, 1.0), (5, 6.0), (10, 2.0)], ease_out_cubic)
"""
if not points:
return 0.0
if loop:
period = points[-1][0] - points[0][0]
if period > 0:
t = points[0][0] + (t - points[0][0]) % period
# Clamp to range
if t <= points[0][0]:
return points[0][1]
if t >= points[-1][0]:
return points[-1][1]
# Find surrounding keyframes
for i in range(len(points) - 1):
t0, v0 = points[i]
t1, v1 = points[i + 1]
if t0 <= t <= t1:
progress = (t - t0) / (t1 - t0)
eased = ease_fn(progress)
return v0 + (v1 - v0) * eased
return points[-1][1]
def keyframe_array(t, points, ease_fn=ease_in_out_cubic):
"""Keyframe interpolation that works with numpy arrays as values.
points: list of (time, np.array) tuples."""
if t <= points[0][0]: return points[0][1].copy()
if t >= points[-1][0]: return points[-1][1].copy()
for i in range(len(points) - 1):
t0, v0 = points[i]
t1, v1 = points[i + 1]
if t0 <= t <= t1:
progress = ease_fn((t - t0) / (t1 - t0))
return v0 * (1 - progress) + v1 * progress
return points[-1][1].copy()
Value Field Morphing
Smooth transition between two different value fields:
def vf_morph(g, f, t, S, vf_a, vf_b, t_start, t_end,
ease_fn=ease_in_out_cubic):
"""Morph between two value fields over a time range.
Usage:
val = vf_morph(g, f, t, S,
lambda g,f,t,S: vf_plasma(g,f,t,S),
lambda g,f,t,S: vf_vortex(g,f,t,S, twist=5),
t_start=10.0, t_end=15.0)
"""
if t <= t_start:
return vf_a(g, f, t, S)
if t >= t_end:
return vf_b(g, f, t, S)
progress = ease_fn((t - t_start) / (t_end - t_start))
a = vf_a(g, f, t, S)
b = vf_b(g, f, t, S)
return a * (1 - progress) + b * progress
def vf_sequence(g, f, t, S, fields, durations, crossfade=1.0,
ease_fn=ease_in_out_cubic):
"""Cycle through a sequence of value fields with crossfades.
fields: list of vf_* callables
durations: list of float seconds per field
crossfade: seconds of overlap between adjacent fields
"""
total = sum(durations)
t_local = t % total # loop
elapsed = 0
for i, dur in enumerate(durations):
if t_local < elapsed + dur:
# Current field
base = fields[i](g, f, t, S)
# Check if we're in a crossfade zone
time_in = t_local - elapsed
time_left = dur - time_in
if time_in < crossfade and i > 0:
# Fading in from previous
prev = fields[(i - 1) % len(fields)](g, f, t, S)
blend = ease_fn(time_in / crossfade)
return prev * (1 - blend) + base * blend
if time_left < crossfade and i < len(fields) - 1:
# Fading out to next
nxt = fields[(i + 1) % len(fields)](g, f, t, S)
blend = ease_fn(1 - time_left / crossfade)
return base * (1 - blend) + nxt * blend
return base
elapsed += dur
return fields[-1](g, f, t, S)
Temporal Noise
3D noise sampled at (x, y, t) — patterns evolve smoothly in time without per-frame discontinuities:
def vf_temporal_noise(g, f, t, S, freq=0.06, t_freq=0.3, octaves=4,
bri=0.8):
"""Noise field that evolves smoothly in time. Uses 3D noise via
two 2D noise lookups combined with temporal interpolation.
Unlike vf_fbm which scrolls noise (creating directional motion),
this morphs the pattern in-place — cells brighten and dim without
the field moving in any direction."""
# Two noise samples at floor/ceil of temporal coordinate
t_scaled = t * t_freq
t_lo = np.floor(t_scaled)
t_frac = _smootherstep(np.full((g.rows, g.cols), t_scaled - t_lo, dtype=np.float32))
val_lo = np.zeros((g.rows, g.cols), dtype=np.float32)
val_hi = np.zeros((g.rows, g.cols), dtype=np.float32)
amp = 1.0; fx = freq
for i in range(octaves):
val_lo = val_lo + _value_noise_2d(
g.cc * fx + t_lo * 7.3 + i * 13, g.rr * fx + t_lo * 3.1 + i * 29) * amp
val_hi = val_hi + _value_noise_2d(
g.cc * fx + (t_lo + 1) * 7.3 + i * 13, g.rr * fx + (t_lo + 1) * 3.1 + i * 29) * amp
amp *= 0.5; fx *= 2.0
max_amp = (1 - 0.5 ** octaves) / 0.5
val = (val_lo * (1 - t_frac) + val_hi * t_frac) / max_amp
return np.clip(val * bri * (0.6 + f.get("rms", 0.3) * 0.6), 0, 1)
Combining Value Fields
The combinatorial explosion comes from mixing value fields with math:
# Multiplication = intersection (only shows where both have brightness)
combined = vf_plasma(g,f,t,S) * vf_vortex(g,f,t,S)
# Addition = union (shows both, clips at 1.0)
combined = np.clip(vf_rings(g,f,t,S) + vf_spiral(g,f,t,S), 0, 1)
# Interference = beat pattern (shows XOR-like patterns)
combined = np.abs(vf_plasma(g,f,t,S) - vf_tunnel(g,f,t,S))
# Modulation = one effect shapes the other
combined = vf_rings(g,f,t,S) * (0.3 + 0.7 * vf_plasma(g,f,t,S))
# Maximum = shows the brightest of two effects
combined = np.maximum(vf_spiral(g,f,t,S), vf_aurora(g,f,t,S))
Full Scene Example (v2 — Canvas Return)
A v2 scene function composes effects internally and returns a pixel canvas:
def scene_complex(r, f, t, S):
"""v2 scene function: returns canvas (uint8 H,W,3).
r = Renderer, f = audio features, t = time, S = persistent state dict."""
g = r.grids["md"]
rows, cols = g.rows, g.cols
# 1. Value field composition
plasma = vf_plasma(g, f, t, S)
vortex = vf_vortex(g, f, t, S, twist=4.0)
combined = np.clip(plasma * 0.6 + vortex * 0.5 + plasma * vortex * 0.4, 0, 1)
# 2. Color from hue field
h = (hf_angle(0.3)(g,f,t,S) * 0.5 + hf_time_cycle(0.08)(g,f,t,S) * 0.5) % 1.0
# 3. Render to canvas via _render_vf helper
canvas = _render_vf(g, combined, h, sat=0.75, pal=PAL_DENSE)
# 4. Optional: blend a second layer
overlay = _render_vf(r.grids["sm"], vf_rings(r.grids["sm"],f,t,S),
hf_fixed(0.6)(r.grids["sm"],f,t,S), pal=PAL_BLOCK)
canvas = blend_canvas(canvas, overlay, "screen", 0.4)
return canvas
# In the render_clip() loop (handled by the framework):
# canvas = scene_fn(r, f, t, S)
# canvas = tonemap(canvas, gamma=scene_gamma)
# canvas = feedback.apply(canvas, ...)
# canvas = shader_chain.apply(canvas, f=f, t=t)
# pipe.stdin.write(canvas.tobytes())
Vary the value field combo, hue field, palette, blend modes, feedback config, and shader chain per section for maximum visual variety. With 12 value fields × 8 hue fields × 14 palettes × 20 blend modes × 7 feedback transforms × 38 shaders, the combinations are effectively infinite.
Combining Effects — Creative Guide
The catalog above is vocabulary. Here's how to compose it into something that looks intentional.
Layering for Depth
Every scene should have at least two layers at different grid densities:
- Background (sm or xs): dense, dim texture that prevents flat black. fBM, smooth noise, or domain warp at low brightness (bri=0.15-0.25).
- Content (md): the main visual — rings, voronoi, spirals, tunnel. Full brightness.
- Accent (lg or xl): sparse highlights — particles, text stencil, glow pulse. Screen-blended on top.
Interesting Effect Pairs
| Pair | Blend | Why it works |
|---|---|---|
| fBM + voronoi edges | screen |
Organic fills the cells, edges add structure |
| Domain warp + plasma | difference |
Psychedelic organic interference |
| Tunnel + vortex | screen |
Depth perspective + rotational energy |
| Spiral + interference | exclusion |
Moire patterns from different spatial frequencies |
| Reaction-diffusion + fire | add |
Living organic base + dynamic foreground |
| SDF geometry + domain warp | screen |
Clean shapes floating in organic texture |
Effects as Masks
Any value field can be used as a mask for another effect via mask_from_vf():
- Voronoi cells masking fire (fire visible only inside cells)
- fBM masking a solid color layer (organic color clouds)
- SDF shapes masking a reaction-diffusion field
- Animated iris/wipe revealing one effect over another
Inventing New Effects
For every project, create at least one effect that isn't in the catalog:
- Combine two vf_ functions* with math:
np.clip(vf_fbm(...) * vf_rings(...), 0, 1) - Apply coordinate transforms before evaluation:
vf_plasma(twisted_grid, ...) - Use one field to modulate another's parameters:
vf_spiral(..., tightness=2 + vf_fbm(...) * 5) - Stack time offsets: render the same field at
tandt - 0.5, difference-blend for motion trails - Mirror a value field through an SDF boundary for kaleidoscopic geometry