plasmodio / sim.js
albertobarnabo's picture
Plasmodio: a slime mold redraws Italy
8f53057 verified
Raw History Blame Contribute Delete
17.2 kB
// Plasmodio — a Physarum polycephalum transport-network simulation on the GPU (WebGL2).
//
// Model: Jones (2010), "Characteristics of pattern formation and evolution in
// approximations of Physarum transport networks", Artificial Life 16(2).
// Each agent senses the chemoattractant trail at three points ahead of it,
// turns toward the strongest, moves one step and deposits trail. The trail
// diffuses and decays. Cities emit trail as food. Sea is impassable except
// along ferry corridors. That is the whole organism.
//
// Textures
// agents RGBA32F (AW x AH) x, y in grid px | heading (rad) | spare
// trail R16F (W x H) chemoattractant concentration
// mask RGBA8 (W x H) R = land coverage, G = ferry corridor, B = unused
const QUAD_VS = `#version 300 es
precision highp float;
out vec2 vUv;
void main(){
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
vUv = p;
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}`;
const UPDATE_FS = `#version 300 es
precision highp float;
uniform highp sampler2D uAgents;
uniform highp sampler2D uTrail;
uniform highp sampler2D uMask;
uniform vec2 uGrid;
uniform float uSA, uSD, uRA, uSS, uSeed, uStep, uFerry, uRand;
out vec4 o;
float hash12(vec2 p){
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
// .r = land coverage, .g = ferry corridor; (0,0) outside the grid
vec2 maskAt(vec2 q){
if (q.x < 0.0 || q.y < 0.0 || q.x >= uGrid.x || q.y >= uGrid.y) return vec2(0.0);
return texelFetch(uMask, ivec2(q), 0).rg;
}
float sense(vec2 p, float ang){
vec2 q = p + vec2(cos(ang), sin(ang)) * uSD;
vec2 m = maskAt(q);
if (max(m.r, m.g) < 0.5) return -1.0;
return texelFetch(uTrail, ivec2(q), 0).r;
}
void main(){
vec4 a = texelFetch(uAgents, ivec2(gl_FragCoord.xy), 0);
// a.w is the step at which this agent joins the colony
if (a.w > uStep) { o = a; return; }
vec2 p = a.xy;
float h = a.z;
float F = sense(p, h), L = sense(p, h + uSA), R = sense(p, h - uSA);
float r = hash12(gl_FragCoord.xy + uSeed);
if (F > L && F > R) { }
else if (F < L && F < R) { h += (r < 0.5) ? uRA : -uRA; }
else if (L > R) { h += uRA; }
else if (R > L) { h -= uRA; }
// a little wanderlust: rarely, an agent forgets the trail and picks a new direction
if (hash12(gl_FragCoord.xy * 0.77 + uSeed + 9.0) < uRand) h = hash12(gl_FragCoord.yx * 1.91 + uSeed) * 6.2831853;
vec2 np = p + vec2(cos(h), sin(h)) * uSS;
vec2 mHere = maskAt(p), mNext = maskAt(np);
bool blocked = max(mNext.r, mNext.g) < 0.5;
// boarding a ferry (stepping from land onto a corridor) is rare: most of the colony stays ashore
if (!blocked && mHere.r >= 0.5 && mNext.r < 0.5 && hash12(gl_FragCoord.xy * 1.37 + uSeed + 5.0) > uFerry) blocked = true;
if (blocked) {
h = hash12(gl_FragCoord.yx + uSeed * 1.7 + 11.0) * 6.2831853;
np = p;
}
o = vec4(np, h, a.w);
}`;
const DEPOSIT_VS = `#version 300 es
precision highp float;
uniform highp sampler2D uAgents;
uniform int uAW;
uniform vec2 uGrid;
uniform float uStep;
void main(){
ivec2 ij = ivec2(gl_VertexID % uAW, gl_VertexID / uAW);
vec4 a = texelFetch(uAgents, ij, 0);
gl_PointSize = 1.0;
if (a.w > uStep) { gl_Position = vec4(-2.0, -2.0, 0.0, 1.0); return; }
gl_Position = vec4((a.xy / uGrid) * 2.0 - 1.0, 0.0, 1.0);
}`;
const DEPOSIT_FS = `#version 300 es
precision highp float;
uniform float uDep;
out vec4 o;
void main(){ o = vec4(uDep, 0.0, 0.0, 0.0); }`;
const FOOD_VS = `#version 300 es
precision highp float;
in vec3 aCity;
uniform vec2 uGrid;
uniform float uRadius;
out float vW;
void main(){
gl_Position = vec4((aCity.xy / uGrid) * 2.0 - 1.0, 0.0, 1.0);
vW = aCity.z;
// bigger towns are bigger flakes of oats
gl_PointSize = uRadius * (0.6 + 0.8 * vW) * 2.0;
}`;
const FOOD_FS = `#version 300 es
precision highp float;
uniform float uFood;
in float vW;
out vec4 o;
void main(){
vec2 d2 = gl_PointCoord - 0.5;
float d = dot(d2, d2) * 4.0;
o = vec4(uFood * (0.5 + 0.5 * vW) * max(0.0, 1.0 - d), 0.0, 0.0, 0.0);
}`;
const DIFFUSE_FS = `#version 300 es
precision highp float;
uniform highp sampler2D uTrail;
uniform highp sampler2D uMask;
uniform vec2 uGrid;
uniform float uDecay, uMax;
out vec4 o;
void main(){
ivec2 c = ivec2(gl_FragCoord.xy);
ivec2 hi = ivec2(uGrid) - 1;
float s = 0.0;
for (int j = -1; j <= 1; j++)
for (int i = -1; i <= 1; i++)
s += texelFetch(uTrail, clamp(c + ivec2(i, j), ivec2(0), hi), 0).r;
vec2 m = texelFetch(uMask, c, 0).rg;
float pass = step(0.5, max(m.r, m.g));
// the trail saturates, like the 8-bit map in Jones (2010): a vein cannot outshine a town
o = vec4(min(s / 9.0 * uDecay, uMax) * pass, 0.0, 0.0, 0.0);
}`;
const RENDER_FS = `#version 300 es
precision highp float;
uniform highp sampler2D uTrail;
uniform highp sampler2D uMask;
uniform vec2 uGrid, uCanvas, uOffset;
uniform float uScale, uExposure;
uniform vec3 uSea, uLand, uCoast, uLo, uHi;
out vec4 o;
float T(ivec2 q){ return texelFetch(uTrail, clamp(q, ivec2(0), ivec2(uGrid) - 1), 0).r; }
void main(){
vec2 sp = vec2(gl_FragCoord.x, uCanvas.y - gl_FragCoord.y);
vec2 g = sp / uScale + uOffset;
if (g.x < 0.0 || g.y < 0.0 || g.x >= uGrid.x || g.y >= uGrid.y) { o = vec4(uSea, 1.0); return; }
vec2 gc = g - 0.5;
ivec2 c = ivec2(floor(gc));
vec2 f = fract(gc);
float t = mix(mix(T(c), T(c + ivec2(1, 0)), f.x), mix(T(c + ivec2(0, 1)), T(c + ivec2(1, 1)), f.x), f.y);
vec3 m = texelFetch(uMask, ivec2(g), 0).rgb;
// soft coastline: where land coverage is fractional, lighten a touch
float edge = m.r * (1.0 - m.r) * 4.0;
vec3 base = mix(uSea, uLand, m.r) + uCoast * edge + uCoast * m.g * (1.0 - m.r) * 0.18;
float e = 1.0 - exp(-t * uExposure);
vec3 trail = mix(uLo, uHi, e * e) * e;
o = vec4(base + trail, 1.0);
}`;
function compile(gl, type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
throw new Error('Shader error: ' + gl.getShaderInfoLog(sh) + '\n' + src);
}
return sh;
}
function program(gl, vs, fs) {
const p = gl.createProgram();
gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, vs));
gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, fs));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error('Link error: ' + gl.getProgramInfoLog(p));
const u = {};
const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(p, i); u[info.name] = gl.getUniformLocation(p, info.name); }
return { p, u };
}
function tex2d(gl, w, h, internal, format, type, data) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0, format, type, data || null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return t;
}
function fbo(gl, tex) {
const f = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, f);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
const st = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (st !== gl.FRAMEBUFFER_COMPLETE) throw new Error('Framebuffer incomplete: 0x' + st.toString(16));
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return f;
}
function hex(c) { return [((c >> 16) & 255) / 255, ((c >> 8) & 255) / 255, (c & 255) / 255]; }
export const AGENT_PRESETS = {
'65k': [256, 256],
'262k': [512, 512],
'1M': [1024, 1024],
'2M': [2048, 1024],
};
export class Sim {
constructor(canvas, mask, opts = {}) {
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: false, stencil: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
if (!gl) throw new Error('This browser has no WebGL2. Try Chrome, Firefox, Edge or Safari 15+.');
if (!gl.getExtension('EXT_color_buffer_float')) throw new Error('This GPU cannot render to float textures (EXT_color_buffer_float).');
this.gl = gl;
this.canvas = canvas;
this.W = opts.W; this.H = opts.H;
this.mask = mask; // { data: Uint8ClampedArray (RGBA, W*H), image }
this.p = {
sensorAngle: 0.5, sensorDist: 9, turnAngle: 0.45, stepSize: 1.0,
deposit: 0.02, // per agent per step at 262k agents; scaled so total trail mass is constant
decay: 0.94, food: 1.0, cityRadius: 4, exposure: 3.4,
trailMax: 1.0, // saturation level of the trail; towns emit enough to pin it there
ferryRate: 0.03, // probability that an agent at the shore boards a corridor
randomTurn: 0.003, // probability per step of forgetting the trail and turning at random
};
Object.assign(this.p, opts.params || {});
this.colors = {
sea: hex(0x0a0e1a), land: hex(0x1a1f2c), coast: hex(0x2c3446),
lo: hex(0xd9772a), hi: hex(0xfff3c4),
};
this.step = 0;
this.agentCount = 0;
this.cityCount = 0;
this._build();
}
_build() {
const gl = this.gl;
this.prog = {
update: program(gl, QUAD_VS, UPDATE_FS),
deposit: program(gl, DEPOSIT_VS, DEPOSIT_FS),
food: program(gl, FOOD_VS, FOOD_FS),
diffuse: program(gl, QUAD_VS, DIFFUSE_FS),
render: program(gl, QUAD_VS, RENDER_FS),
};
// mask texture straight from the image: row 0 = north
this.maskTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.maskTex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, this.mask.image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// trail ping-pong
this.trail = [0, 1].map(() => {
const t = tex2d(gl, this.W, this.H, gl.R16F, gl.RED, gl.HALF_FLOAT, null);
return { t, f: fbo(gl, t) };
});
this.cur = 0;
this.cityBuf = gl.createBuffer();
this.cityVao = gl.createVertexArray();
gl.bindVertexArray(this.cityVao);
gl.bindBuffer(gl.ARRAY_BUFFER, this.cityBuf);
const loc = gl.getAttribLocation(this.prog.food.p, 'aCity');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 3, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
this.emptyVao = gl.createVertexArray();
}
// cities: array of {x, y, w} in grid px, w in [0,1]
setCities(cities) {
const gl = this.gl;
const arr = new Float32Array(cities.length * 3);
cities.forEach((c, i) => { arr[i * 3] = c.x; arr[i * 3 + 1] = c.y; arr[i * 3 + 2] = c.w; });
gl.bindBuffer(gl.ARRAY_BUFFER, this.cityBuf);
gl.bufferData(gl.ARRAY_BUFFER, arr, gl.STATIC_DRAW);
this.cityCount = cities.length;
}
// Seeds the population. mode: 'everywhere' | {x, y, spread, release}
// 'release' spreads the agents' entry into the colony over that many steps, so it grows.
seed(preset, mode) {
const gl = this.gl;
const [AW, AH] = AGENT_PRESETS[preset] || AGENT_PRESETS['1M'];
const n = AW * AH;
const data = new Float32Array(n * 4);
const md = this.mask.data, W = this.W, H = this.H;
const land = (x, y) => {
const i = ((y | 0) * W + (x | 0)) * 4;
return Math.max(md[i], md[i + 1]) >= 128;
};
// land cell list for uniform spawning
if (!this._landCells) {
const cells = [];
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (md[(y * W + x) * 4] >= 128) cells.push(y * W + x);
this._landCells = Uint32Array.from(cells);
}
const lc = this._landCells;
for (let i = 0; i < n; i++) {
let x, y;
if (mode === 'everywhere') {
const c = lc[(Math.random() * lc.length) | 0];
x = (c % W) + Math.random(); y = Math.floor(c / W) + Math.random();
} else {
let tries = 0;
do {
const r = mode.spread * Math.sqrt(-2 * Math.log(1 - Math.random())), a = Math.random() * 6.2831853;
x = mode.x + r * Math.cos(a); y = mode.y + r * Math.sin(a);
} while ((x < 0 || y < 0 || x >= W || y >= H || !land(x, y)) && ++tries < 50);
if (tries >= 50) { x = mode.x; y = mode.y; }
}
data[i * 4] = x; data[i * 4 + 1] = y; data[i * 4 + 2] = Math.random() * 6.2831853;
data[i * 4 + 3] = mode.release ? (i / n) * mode.release : 0;
}
this.release = (mode && mode.release) || 0;
if (this.agents) this.agents.forEach(a => { gl.deleteTexture(a.t); gl.deleteFramebuffer(a.f); });
this.agents = [0, 1].map((k) => {
const t = tex2d(gl, AW, AH, gl.RGBA32F, gl.RGBA, gl.FLOAT, k === 0 ? data : null);
return { t, f: fbo(gl, t) };
});
this.AW = AW; this.AH = AH; this.agentCount = n;
this.clearTrail();
this.step = 0;
}
clearTrail() {
const gl = this.gl;
for (const tr of this.trail) {
gl.bindFramebuffer(gl.FRAMEBUFFER, tr.f);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
this.step = 0;
}
tick() {
const gl = this.gl, p = this.p;
if (!this.agents) return;
const src = this.agents[0], dst = this.agents[1];
const tr = this.trail[this.cur], nx = this.trail[this.cur ^ 1];
gl.disable(gl.BLEND);
gl.bindVertexArray(this.emptyVao);
// 1. sense + move
let pr = this.prog.update;
gl.useProgram(pr.p);
gl.bindFramebuffer(gl.FRAMEBUFFER, dst.f);
gl.viewport(0, 0, this.AW, this.AH);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, src.t); gl.uniform1i(pr.u.uAgents, 0);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, tr.t); gl.uniform1i(pr.u.uTrail, 1);
gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, this.maskTex); gl.uniform1i(pr.u.uMask, 2);
gl.uniform2f(pr.u.uGrid, this.W, this.H);
gl.uniform1f(pr.u.uSA, p.sensorAngle);
gl.uniform1f(pr.u.uSD, p.sensorDist);
gl.uniform1f(pr.u.uRA, p.turnAngle);
gl.uniform1f(pr.u.uSS, p.stepSize);
gl.uniform1f(pr.u.uSeed, (this.step % 1000) * 0.6180339 + 0.137);
gl.uniform1f(pr.u.uStep, this.step);
gl.uniform1f(pr.u.uFerry, p.ferryRate);
gl.uniform1f(pr.u.uRand, p.randomTurn);
gl.drawArrays(gl.TRIANGLES, 0, 3);
this.agents = [dst, src];
// 2. deposit (additive)
gl.bindFramebuffer(gl.FRAMEBUFFER, tr.f);
gl.viewport(0, 0, this.W, this.H);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
pr = this.prog.deposit;
gl.useProgram(pr.p);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, dst.t); gl.uniform1i(pr.u.uAgents, 0);
gl.uniform1i(pr.u.uAW, this.AW);
gl.uniform2f(pr.u.uGrid, this.W, this.H);
gl.uniform1f(pr.u.uStep, this.step);
gl.uniform1f(pr.u.uDep, p.deposit * (262144 / this.agentCount));
gl.drawArrays(gl.POINTS, 0, this.agentCount);
// 3. food from cities
if (this.cityCount) {
pr = this.prog.food;
gl.useProgram(pr.p);
gl.bindVertexArray(this.cityVao);
gl.uniform2f(pr.u.uGrid, this.W, this.H);
gl.uniform1f(pr.u.uRadius, p.cityRadius);
gl.uniform1f(pr.u.uFood, p.food);
gl.drawArrays(gl.POINTS, 0, this.cityCount);
gl.bindVertexArray(this.emptyVao);
}
gl.disable(gl.BLEND);
// 4. diffuse + decay
pr = this.prog.diffuse;
gl.useProgram(pr.p);
gl.bindFramebuffer(gl.FRAMEBUFFER, nx.f);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tr.t); gl.uniform1i(pr.u.uTrail, 0);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.maskTex); gl.uniform1i(pr.u.uMask, 1);
gl.uniform2f(pr.u.uGrid, this.W, this.H);
gl.uniform1f(pr.u.uDecay, p.decay);
gl.uniform1f(pr.u.uMax, p.trailMax);
gl.drawArrays(gl.TRIANGLES, 0, 3);
this.cur ^= 1;
this.step++;
}
// view: { scale: canvas px per grid px, ox, oy: grid coords at the canvas top-left }
render(view) {
const gl = this.gl, c = this.colors, pr = this.prog.render;
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
gl.disable(gl.BLEND);
gl.bindVertexArray(this.emptyVao);
gl.useProgram(pr.p);
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.trail[this.cur].t); gl.uniform1i(pr.u.uTrail, 0);
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, this.maskTex); gl.uniform1i(pr.u.uMask, 1);
gl.uniform2f(pr.u.uGrid, this.W, this.H);
gl.uniform2f(pr.u.uCanvas, this.canvas.width, this.canvas.height);
gl.uniform2f(pr.u.uOffset, view.ox, view.oy);
gl.uniform1f(pr.u.uScale, view.scale);
gl.uniform1f(pr.u.uExposure, this.p.exposure);
gl.uniform3fv(pr.u.uSea, c.sea);
gl.uniform3fv(pr.u.uLand, c.land);
gl.uniform3fv(pr.u.uCoast, c.coast);
gl.uniform3fv(pr.u.uLo, c.lo);
gl.uniform3fv(pr.u.uHi, c.hi);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
}