"use client";
import { memo, useEffect, useRef, useState } from "react";
import { Renderer, Program, Mesh, Triangle, Geometry, RenderTarget } from "ogl";
import { useAnimationLoop, type Metrics } from "@/hooks/use-animation-loop";
export type MyceliumSpawn = "ring" | "scatter" | "disc";
export type MyceliumAgents = "65k" | "262k" | "1M";
interface MyceliumProps {
agentCount?: MyceliumAgents;
speed?: number;
sensorDistance?: number;
sensorAngle?: number;
turnSpeed?: number;
wander?: number;
decay?: number;
diffuse?: number;
deposit?: number;
attract?: number;
spawn?: MyceliumSpawn;
trailColor?: string;
background?: string;
glow?: number;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const FIELD = 1024;
const AGENT_SIDE: Record<string, number> = { "65k": 256, "262k": 512, "1M": 1024 };
const FOOD_RADIUS = 0.055;
const FOOD_RATE = 0.09;
const SETTLE_STEPS = 150;
const SETTLE_BUDGET_MS = 300;
const SPAWN_ID: Record<string, number> = { ring: 0, scatter: 1, disc: 2 };
const TAU = 6.283185307179586;
const hexToRgb01 = (hex: string): [number, number, number] => {
const h = hex.replace("#", "");
const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
const n = parseInt(full, 16);
if (Number.isNaN(n) || full.length !== 6) return [1, 1, 1];
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
};
const vertex = `#version 300 es
in vec2 position;
in vec2 uv;
out vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const HASH = `
float hash21(vec2 p) {
p = fract(p * vec2(127.1, 311.7));
p += dot(p, p + 34.56);
return fract(p.x * p.y);
}`;
const spawnFragment = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
uniform float uSpawn;
uniform float uJitter;
${HASH}
void main() {
float r1 = hash21(vUv * 313.7 + uJitter);
float r2 = hash21(vUv * 71.3 + uJitter + 19.0);
vec2 pos;
float ang;
if (uSpawn < 0.5) {
// A ring facing inward. The population collides with itself at the centre
// and has to resolve the jam, which is what produces the first branches.
float t = r1 * ${TAU};
pos = 0.5 + vec2(cos(t), sin(t)) * (0.36 + (r2 - 0.5) * 0.03);
ang = t + ${TAU / 2};
} else if (uSpawn < 1.5) {
pos = vec2(r1, r2);
ang = hash21(vUv * 97.1 + uJitter + 41.0) * ${TAU};
} else {
// sqrt on the radius, or the disc packs everything into the middle — area
// grows with r², so a uniform radius is not a uniform disc.
float t = r1 * ${TAU};
pos = 0.5 + vec2(cos(t), sin(t)) * (sqrt(r2) * 0.3);
ang = t;
}
fragColor = vec4(pos, ang, 1.0);
}`;
const agentFragment = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
uniform sampler2D uAgents;
uniform sampler2D uTrail;
uniform float uDt;
uniform float uSpeed;
uniform float uSensorDist;
uniform float uSensorAngle;
uniform float uTurn;
uniform float uWander;
uniform float uSeed;
${HASH}
float sense(vec2 pos, float ang, float dist) {
return texture(uTrail, pos + vec2(cos(ang), sin(ang)) * dist).r;
}
void main() {
vec4 a = texture(uAgents, vUv);
vec2 pos = a.xy;
float ang = a.z;
float c = sense(pos, ang, uSensorDist);
float l = sense(pos, ang + uSensorAngle, uSensorDist);
float r = sense(pos, ang - uSensorAngle, uSensorDist);
float turn = uTurn * uDt;
if (c > l && c > r) {
// Straight on. Doing nothing here is what makes a trail a trail.
} else if (c < l && c < r) {
// Both flanks beat the centre: the agent is straddling a ridge and there is
// no correct answer. A deterministic tie-break would send every straddling
// agent the same way and the field would grow a grain; a coin flip is the
// only thing that keeps the network isotropic.
ang += (hash21(vUv * 511.0 + uSeed) - 0.5) * 2.0 * turn;
} else if (l > r) {
ang += turn;
} else if (r > l) {
ang -= turn;
}
// Wander. Trail-following is pure positive feedback — a vein that wins takes
// the agents that would have kept its rivals alive, so left alone the colony
// converges onto two or three trunks and the rest of the field goes black.
// That collapse is real slime-mould behaviour and it is also a dead
// background, so the agents are given the noise a real one has: a small random
// walk on the heading that constantly leaks explorers off the trunk roads into
// empty space, where they seed the next generation of branches. Exploration
// against exploitation, and it is the reason this never settles.
ang += (hash21(vUv * 733.0 + uSeed * 1.7) - 0.5) * 2.0 * uWander * uDt;
// No term here steers toward the pointer. The attractant is laid into the
// trail map as food and the colony has to *find* it — sensors pick up the
// gradient diffusion spreads out from it, and the veins thicken toward your
// cursor over a few seconds the way they would toward an oat flake. A direct
// heading force would be instantaneous and would make this a brush.
// Headings integrate forever. Left unbounded the accumulated angle outgrows
// float32's fraction and the turn quantises into visible steps after a few
// minutes on screen — the drift is slow enough to look like a design choice.
ang = mod(ang, ${TAU});
pos = fract(pos + vec2(cos(ang), sin(ang)) * uSpeed * uDt);
fragColor = vec4(pos, ang, 1.0);
}`;
const zeroFragment = `#version 300 es
precision highp float;
out vec4 fragColor;
void main() { fragColor = vec4(0.0, 0.0, 0.0, 1.0); }`;
const diffuseFragment = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
uniform sampler2D uTrail;
uniform vec2 uTexel;
uniform float uDecay;
uniform float uDiffuse;
uniform vec2 uPointer;
uniform float uFood;
uniform float uFoodRadius;
void main() {
float sum = 0.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
sum += texture(uTrail, vUv + vec2(float(x), float(y)) * uTexel).r;
}
}
float here = texture(uTrail, vUv).r;
// The food source, folded into the pass that already walks every cell rather
// than costing a draw of its own. Shortest path across the seam, because the
// field is a torus and a cell at the left edge is adjacent to the right one.
float food = 0.0;
if (uFood > 0.0) {
vec2 d = vUv - uPointer;
d -= round(d);
food = uFood * (1.0 - smoothstep(0.0, uFoodRadius, length(d)));
}
// Saturating, not unbounded. Deposit is the only source and decay the only
// sink, so a cell settles at deposit/(1 - decay) — which for any deposit worth
// seeing is far above one. Unclamped, that number runs to the hundreds and
// every tone map flattens the whole field to one colour. Clamped, the ratio
// instead decides *where* the ceiling is reached: a vein carrying fifty agents
// pins at 1 while open ground a few texels away sits near 0.05, and the
// structure is legible because it is spatial rather than magnitudinal.
fragColor = vec4(clamp(mix(here, sum / 9.0, uDiffuse) * uDecay + food, 0.0, 1.0), 0.0, 0.0, 1.0);
}`;
const depositVertex = `#version 300 es
in float aIndex;
uniform sampler2D uAgents;
uniform float uSide;
void main() {
float y = floor(aIndex / uSide);
vec2 uv = (vec2(aIndex - y * uSide, y) + 0.5) / uSide;
gl_Position = vec4(texture(uAgents, uv).xy * 2.0 - 1.0, 0.0, 1.0);
gl_PointSize = 1.0;
}`;
const depositFragment = `#version 300 es
precision highp float;
out vec4 fragColor;
uniform float uDeposit;
void main() {
// Alpha stays at zero. The blend is additive on every channel, so a nonzero
// alpha here would ramp the target's alpha toward infinity over a session for
// a channel nothing ever reads.
fragColor = vec4(uDeposit, 0.0, 0.0, 0.0);
}`;
const displayFragment = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 fragColor;
uniform sampler2D uTrail;
uniform vec2 uResolution;
uniform vec3 uInk;
uniform vec3 uBg;
uniform float uGlow;
void main() {
float ar = uResolution.x / max(uResolution.y, 1.0);
// Cover, not stretch. The field is square and the container is not; stretching
// turns every strand into an ellipse and the anisotropy reads as a bug.
vec2 k = ar > 1.0 ? vec2(1.0, 1.0 / ar) : vec2(ar, 1.0);
float t = texture(uTrail, (vUv - 0.5) * k + 0.5).r;
// The interesting range of a saturating field is its bottom: a fresh strand
// sits near zero and a trunk sits near one, so a linear ramp spends most of
// the palette on ground that is merely warm. The curve lifts the faint end
// and leaves headroom at the top for the glow to mean something.
float v = 1.0 - exp(-t * 3.4);
vec3 col = mix(uBg, uInk, v);
col += uInk * pow(v, 3.0) * uGlow;
fragColor = vec4(col, 1.0);
}`;
const Mycelium = memo(
({
agentCount = "1M",
speed = 55,
sensorDistance = 7,
sensorAngle = 24,
turnSpeed = 24,
wander = 13,
decay = 0.97,
diffuse = 0.35,
deposit = 0.03,
attract = 0.6,
spawn = "scatter",
trailColor = "#a855f7",
background = "#07060c",
glow = 0.8,
paused = false,
reducedMotion = false,
className,
}: MyceliumProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const drawRef = useRef<((dt: number) => void | false) | null>(null);
const measureRef = useRef<((m: Metrics) => void) | null>(null);
const glRef = useRef<WebGLRenderingContext | WebGL2RenderingContext | null>(null);
const respawnRef = useRef(true);
const pointer = useRef({ x: 0.5, y: 0.5, on: false });
const [fallback, setFallback] = useState(false);
const loop = useAnimationLoop({
target: containerRef,
halted: paused || reducedMotion,
dpr: "auto",
onResize: (metrics) => measureRef.current?.(metrics),
onFrame: ({ dt }) => (drawRef.current ? drawRef.current(dt) : false),
gl: () => glRef.current,
});
const live = useRef({
speed, sensorDistance, sensorAngle, turnSpeed, wander, decay, diffuse, deposit,
attract, spawn, trailColor, background, glow,
});
live.current = {
speed, sensorDistance, sensorAngle, turnSpeed, wander, decay, diffuse, deposit,
attract, spawn, trailColor, background, glow,
};
const settleRef = useRef<(() => void) | null>(null);
useEffect(() => {
const container = containerRef.current;
if (fallback || !container) return;
const side = AGENT_SIDE[agentCount] ?? 1024;
const population = side * side;
let renderer: Renderer;
try {
renderer = new Renderer({
webgl: 2,
alpha: false,
antialias: false,
powerPreference: "high-performance",
dpr: Math.min(window.devicePixelRatio || 1, 2),
});
const probe = renderer.gl as unknown as WebGL2RenderingContext;
if (!probe.getExtension("EXT_color_buffer_float")) {
throw new Error("Mycelium requires EXT_color_buffer_float");
}
} catch {
setFallback(true);
return;
}
const glc = renderer.gl;
const gl2 = glc as unknown as WebGL2RenderingContext;
glRef.current = gl2;
const canvas = glc.canvas as HTMLCanvasElement;
canvas.style.display = "block";
canvas.style.position = "absolute";
canvas.style.top = "0";
canvas.style.left = "0";
container.appendChild(canvas);
const makeTrail = () =>
new RenderTarget(glc, {
width: FIELD,
height: FIELD,
depth: false,
type: gl2.HALF_FLOAT,
format: gl2.RGBA,
internalFormat: gl2.RGBA16F,
minFilter: gl2.LINEAR,
magFilter: gl2.LINEAR,
wrapS: gl2.REPEAT,
wrapT: gl2.REPEAT,
});
const makeAgents = () =>
new RenderTarget(glc, {
width: side,
height: side,
depth: false,
type: gl2.FLOAT,
format: gl2.RGBA,
internalFormat: gl2.RGBA32F,
minFilter: gl2.NEAREST,
magFilter: gl2.NEAREST,
wrapS: gl2.CLAMP_TO_EDGE,
wrapT: gl2.CLAMP_TO_EDGE,
});
let trailRead = makeTrail();
let trailWrite = makeTrail();
let agentRead = makeAgents();
let agentWrite = makeAgents();
const quad = new Triangle(glc);
const indices = new Float32Array(population);
for (let i = 0; i < population; i++) indices[i] = i;
const cloud = new Geometry(glc, { aIndex: { size: 1, data: indices } });
const spawnProgram = new Program(glc, {
vertex,
fragment: spawnFragment,
depthTest: false,
depthWrite: false,
uniforms: {
uSpawn: { value: SPAWN_ID[spawn] ?? 0 },
uJitter: { value: 0 },
},
});
const agentProgram = new Program(glc, {
vertex,
fragment: agentFragment,
depthTest: false,
depthWrite: false,
uniforms: {
uAgents: { value: agentRead.texture },
uTrail: { value: trailRead.texture },
uDt: { value: 0 },
uSpeed: { value: speed / FIELD },
uSensorDist: { value: sensorDistance / FIELD },
uSensorAngle: { value: (sensorAngle * Math.PI) / 180 },
uTurn: { value: turnSpeed },
uWander: { value: wander },
uSeed: { value: 0 },
},
});
const diffuseProgram = new Program(glc, {
vertex,
fragment: diffuseFragment,
depthTest: false,
depthWrite: false,
uniforms: {
uTrail: { value: trailRead.texture },
uTexel: { value: new Float32Array([1 / FIELD, 1 / FIELD]) },
uDecay: { value: decay },
uDiffuse: { value: diffuse },
uPointer: { value: new Float32Array([0.5, 0.5]) },
uFood: { value: 0 },
uFoodRadius: { value: FOOD_RADIUS },
},
});
const depositProgram = new Program(glc, {
vertex: depositVertex,
fragment: depositFragment,
depthTest: false,
depthWrite: false,
transparent: true,
uniforms: {
uAgents: { value: agentRead.texture },
uSide: { value: side },
uDeposit: { value: deposit },
},
});
depositProgram.setBlendFunc(glc.ONE, glc.ONE);
const displayProgram = new Program(glc, {
vertex,
fragment: displayFragment,
depthTest: false,
depthWrite: false,
uniforms: {
uTrail: { value: trailRead.texture },
uResolution: { value: new Float32Array([1, 1]) },
uInk: { value: new Float32Array(hexToRgb01(trailColor)) },
uBg: { value: new Float32Array(hexToRgb01(background)) },
uGlow: { value: glow },
},
});
const zeroProgram = new Program(glc, {
vertex,
fragment: zeroFragment,
depthTest: false,
depthWrite: false,
});
const zeroMesh = new Mesh(glc, { geometry: quad, program: zeroProgram });
const spawnMesh = new Mesh(glc, { geometry: quad, program: spawnProgram });
const agentMesh = new Mesh(glc, { geometry: quad, program: agentProgram });
const diffuseMesh = new Mesh(glc, { geometry: quad, program: diffuseProgram });
const displayMesh = new Mesh(glc, { geometry: quad, program: displayProgram });
const depositMesh = new Mesh(glc, {
geometry: cloud,
program: depositProgram,
mode: glc.POINTS,
frustumCulled: false,
});
type U = Record<string, { value: number | Float32Array | unknown }>;
const sp = spawnProgram.uniforms as U;
const ag = agentProgram.uniforms as U;
const df = diffuseProgram.uniforms as U;
const dp = depositProgram.uniforms as U;
const ds = displayProgram.uniforms as U;
let jitter = 0;
let seed = 0;
const respawn = () => {
sp.uSpawn.value = SPAWN_ID[live.current.spawn] ?? 0;
sp.uJitter.value = jitter;
jitter = (jitter + 17.13) % 991;
renderer.render({ scene: spawnMesh, target: agentRead });
renderer.render({ scene: spawnMesh, target: agentWrite });
renderer.render({ scene: zeroMesh, target: trailRead });
renderer.render({ scene: zeroMesh, target: trailWrite });
};
const paint = () => {
const l = live.current;
ds.uTrail.value = trailRead.texture;
(ds.uInk.value as Float32Array).set(hexToRgb01(l.trailColor));
(ds.uBg.value as Float32Array).set(hexToRgb01(l.background));
ds.uGlow.value = l.glow;
renderer.render({ scene: displayMesh });
};
let stepsRun = 0;
let lastDt = 1 / 60;
const simulate = (step: number) => {
const l = live.current;
stepsRun++;
seed = (seed + 0.6180339887) % 1;
ag.uAgents.value = agentRead.texture;
ag.uTrail.value = trailRead.texture;
ag.uDt.value = step;
ag.uSpeed.value = l.speed / FIELD;
ag.uSensorDist.value = l.sensorDistance / FIELD;
ag.uSensorAngle.value = (l.sensorAngle * Math.PI) / 180;
ag.uTurn.value = l.turnSpeed;
ag.uWander.value = l.wander;
ag.uSeed.value = seed;
renderer.render({ scene: agentMesh, target: agentWrite });
let t = agentRead;
agentRead = agentWrite;
agentWrite = t;
df.uTrail.value = trailRead.texture;
df.uDecay.value = Math.pow(l.decay, step * 60);
df.uDiffuse.value = l.diffuse;
df.uFood.value = pointer.current.on ? l.attract * FOOD_RATE * step * 60 : 0;
const p = df.uPointer.value as Float32Array;
p[0] = pointer.current.x;
p[1] = pointer.current.y;
renderer.render({ scene: diffuseMesh, target: trailWrite });
dp.uAgents.value = agentRead.texture;
dp.uDeposit.value = l.deposit * step * 60;
renderer.render({ scene: depositMesh, target: trailWrite, clear: false });
t = trailRead;
trailRead = trailWrite;
trailWrite = t;
};
settleRef.current = () => {
if (stepsRun >= SETTLE_STEPS) return;
if (respawnRef.current) {
respawn();
respawnRef.current = false;
}
if (lastDt > 1 / 20) {
paint();
return;
}
const deadline = performance.now() + SETTLE_BUDGET_MS;
while (stepsRun < SETTLE_STEPS && performance.now() < deadline) {
simulate(1 / 60);
}
paint();
};
drawRef.current = (dt) => {
if (respawnRef.current) {
respawn();
respawnRef.current = false;
}
lastDt = dt > 0 ? dt : lastDt;
simulate(Math.min(dt, 1 / 30));
paint();
};
measureRef.current = ({ width, height, dpr }) => {
renderer.dpr = dpr;
renderer.setSize(Math.max(1, Math.floor(width)), Math.max(1, Math.floor(height)));
const res = ds.uResolution.value as Float32Array;
res[0] = glc.drawingBufferWidth;
res[1] = glc.drawingBufferHeight;
paint();
};
loop.resize();
loop.start();
return () => {
drawRef.current = null;
measureRef.current = null;
if (container.contains(canvas)) container.removeChild(canvas);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fallback, agentCount]);
useEffect(() => {
if (!(paused || reducedMotion)) return;
settleRef.current?.();
}, [paused, reducedMotion]);
useEffect(() => {
respawnRef.current = true;
loop.paint();
}, [spawn, loop]);
useEffect(() => {
loop.paint();
}, [trailColor, background, glow, loop]);
const track = (e: React.PointerEvent<HTMLDivElement>) => {
if (reducedMotion) return;
const r = e.currentTarget.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return;
const nx = (e.clientX - r.left) / r.width;
const ny = 1 - (e.clientY - r.top) / r.height;
const ar = r.width / r.height;
pointer.current.x = (nx - 0.5) * (ar > 1 ? 1 : ar) + 0.5;
pointer.current.y = (ny - 0.5) * (ar > 1 ? 1 / ar : 1) + 0.5;
pointer.current.on = true;
loop.start();
};
const release = () => {
pointer.current.on = false;
};
if (fallback) {
return (
<div
className={className ?? "relative h-full w-full overflow-hidden"}
style={{
backgroundColor: background,
backgroundImage: `radial-gradient(ellipse 40% 22% at 34% 42%, ${trailColor}3a 0%, transparent 70%), radial-gradient(ellipse 30% 34% at 66% 58%, ${trailColor}2e 0%, transparent 70%), radial-gradient(circle at 50% 50%, ${trailColor}22 0%, transparent 62%)`,
}}
/>
);
}
return (
<div
ref={containerRef}
className={`[&_canvas]:touch-none ${className ?? "relative h-full w-full overflow-hidden"}`}
onPointerMove={track}
onPointerLeave={release}
onPointerCancel={release}
/>
);
},
);
Mycelium.displayName = "Mycelium";
export default Mycelium;