1866 lines
71 KiB
Markdown
1866 lines
71 KiB
Markdown
|
|
# 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)
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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`, and `eff_ripple` functions are superseded by the `vf_*` value field generators below (used via `_render_vf()`). The `vf_*` 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:
|
|||
|
|
```python
|
|||
|
|
# 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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# 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)
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
# 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
|
|||
|
|
```python
|
|||
|
|
# 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)
|
|||
|
|
```python
|
|||
|
|
# 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)
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
# 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.
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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)
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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)
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
# 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
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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)
|
|||
|
|
```python
|
|||
|
|
# hue_base=0.55, hue_range=0.15 -- blue to cyan
|
|||
|
|
# Lower intensity, slower movement
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Text Overlays
|
|||
|
|
|
|||
|
|
### Scrolling Ticker
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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()`.
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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()`.
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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.
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# 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]:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# 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:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
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 `t` and `t - 0.5`, difference-blend for motion trails
|
|||
|
|
- **Mirror a value field** through an SDF boundary for kaleidoscopic geometry
|