<canvas>
body {
  margin: 0;
  padding: 0;
}

canvas {
  display: block;
}
// system settings
const NB_AGENTS = 2000
// sim settings
const SETTINGS = {
  stepSize: 4,
  sensorOffset: 20,
  sensorAngle: 0.8,
  deposit: 0.5,
  decay: 0.9,
  turnAngle: 0.4,
  randomAngle: 0.2,
  renderAgents: true
}

// weights for the blur pass
const weight = [
	1/16, 1/8, 1/16,
	 1/8, 1/4,  1/8,
	1/16, 1/8, 1/16,
];

// GUI setup
const GUI = new dat.GUI()
GUI.add(SETTINGS, "stepSize", 0, 10)
GUI.add(SETTINGS, "sensorOffset", 0, 40)
GUI.add(SETTINGS, "sensorAngle", 0, Math.PI)
GUI.add(SETTINGS, "turnAngle", 0, Math.PI)
GUI.add(SETTINGS, "randomAngle", 0, Math.PI)
GUI.add(SETTINGS, "deposit", 0, 1)
GUI.add(SETTINGS, "decay", 0.9, 1)
GUI.add(SETTINGS, "renderAgents")

// document setup
const cvs = document.querySelector("canvas")
const ctx = cvs.getContext("2d")
let W, H
cvs.width = W = window.innerWidth
cvs.height = H = window.innerHeight


// toroidal coordinates from any 2D coordinates
// not ANY coordinates, but faster and enough for this purpose
const toroidalCoords = (x, y) => {
  if (x < 0) x = W + x
  else if (x >= W) x-= W
  if (y < 0) y = H + y
  else if (y >= H) y-= H
  return { x, y }
}

// 2d toroidail coordinates to 1D coordinates, for the substrate lookup
const coords2dTo1d = (x, y) => (x + y * W)


// initialisation of the system
const agents = []
for (let i = 0; i < NB_AGENTS; i++) {
  agents.push({
    x: Math.random() * W,
    y: Math.random() * H,
    ang: Math.random() * 2 * Math.PI
  })
}
const substrate = new Float32Array(W*H)
const substrateImageArray = new Uint8ClampedArray(W*H*4)


// performs a blur pass on the substrate array [diffusion]
const diffusion = () => {
  let old = Float32Array.from(substrate)
		for (let y = 1; y < H-1; y++) {
			for (let x = 1; x < W-1; ++x) {
				const diffused_value = (
					old[coords2dTo1d(x-1, y-1)] * weight[0] +
					old[coords2dTo1d(x  , y-1)] * weight[1] +
					old[coords2dTo1d(x+1, y-1)] * weight[2] +
					old[coords2dTo1d(x-1, y  )] * weight[3] +
					old[coords2dTo1d(x  , y  )] * weight[4] +
					old[coords2dTo1d(x+1, y  )] * weight[5] +
					old[coords2dTo1d(x-1, y+1)] * weight[6] +
					old[coords2dTo1d(x  , y+1)] * weight[7] +
					old[coords2dTo1d(x+1, y+1)] * weight[8]
				)
				substrate[coords2dTo1d(x, y)] = diffused_value * SETTINGS.decay
			}
		}
}

const sensor = (agent, ang) => {
  const sx = agent.x + Math.cos(agent.ang + ang) * SETTINGS.sensorOffset
  const sy = agent.y + Math.sin(agent.ang + ang) * SETTINGS.sensorOffset
  const { x, y } = toroidalCoords(sx, sy)
  return substrate[coords2dTo1d(x|0, y|0)]
}

// update of the system
const update = () => {
  // diffusion / evaporation step
  diffusion()
  
  // agents update
  let c1d, SL, SF, SR
  for (const agent of agents) {
    // agent angle based on substrate sampling
    SL = sensor(agent, -SETTINGS.sensorAngle)
    SF = sensor(agent, 0)
    SR = sensor(agent, SETTINGS.sensorAngle)
    
    if (SF > SL && SF > SR) {
      // no change
    }
    else if (SL > SR) {
      agent.ang-= SETTINGS.turnAngle
    }
    else if (SR > SL) {
      agent.ang+= SETTINGS.turnAngle
    }
    else {
      agent.ang+= (Math.random()-0.5) * SETTINGS.randomAngle
    }
    
    // update agent position based on its heading
    let dir = {
      x: Math.cos(agent.ang),
      y: Math.sin(agent.ang)
    }
    const { x, y } = toroidalCoords(
      agent.x + dir.x * SETTINGS.stepSize, 
      agent.y + dir.y * SETTINGS.stepSize
    )
    agent.x = x
    agent.y = y
    
    // agent deposit substrate
    if ((x|0) !== 0 && (x|0) < W-1 && (y|0) !== 0 && (y|0) < H-1) {
      c1d = coords2dTo1d(x|0, y|0)
      substrate[c1d]+= SETTINGS.deposit
    }
  }
}


// draw the system
const draw = () => {
  ctx.fillStyle = "black"
  ctx.fillRect(0, 0, W, H)
  
  // draw the substrate
  let idx, brightness
  for (let x = 0; x < W; x++) {
    for (let y = 0; y < H; y++) {
      idx = coords2dTo1d(x, y)
      brightness = (substrate[idx] * 255) | 0
      idx*=4
      substrateImageArray[idx] = brightness
      substrateImageArray[idx+1] = brightness
      substrateImageArray[idx+2] = brightness
      substrateImageArray[idx+3] = 255
    }
  }
  let substrateImage = new ImageData(substrateImageArray, W, H)
  ctx.putImageData(substrateImage, 0, 0)
  
  
  // draw the agents
  if (SETTINGS.renderAgents) {
    ctx.fillStyle = "red"
    for (const agent of agents) {
      ctx.beginPath()
      ctx.arc(agent.x, agent.y, 1, 0, Math.PI*2)
      ctx.fill()
    }
  }
}

const loop = () => {
  requestAnimationFrame(loop)
  update()
  draw()
}

loop()
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js