<canvas>
body {
  margin: 0;
  overflow: hidden;
}
const STG = {
  sensorsOffset: 80,
  nbSensors: 8,
  noiseScale: 0.003,
  noiseOffset: 0,
  noiseExponent: 1.4,
  invFriction: 0.98,
  attrStrength: 1,
}

const gui = new dat.GUI()
gui.add(STG, "sensorsOffset", 0, 200)
const offsetCtrl = gui.add(STG, "noiseOffset", 0, 3)
const nsExponentCtrl = gui.add(STG, "noiseExponent", 0.1, 4)
gui.add(STG, "invFriction", 0.9, 0.9999)
gui.add(STG, "attrStrength", 0.5, 4)

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


// generate a noise map
const simplex = new SimplexNoise("aaa")
const noiseMap = new Float32Array(W*H)
const noiseMapUint8 = new Uint8ClampedArray(W*H*4)
const index = (x, y) => x + y*W
// the ImageData which will store the noise to be drawn
let noiseMapImg
// fills the noise bases on the
const updateNoise = () => {
  for (let x = 0; x < W; x++) {
    for (let y = 0; y < H; y++) {
      const idx = index(x, y)
      let N = simplex.noise3D(x * STG.noiseScale, y * STG.noiseScale, STG.noiseOffset) * .5 + .5
      N = Math.pow(N, STG.noiseExponent)
      const Nint = (N*255)|0
      noiseMap[idx] = N
      noiseMapUint8[idx*4] = Nint
      noiseMapUint8[idx*4+1] = Nint
      noiseMapUint8[idx*4+2] = Nint
      noiseMapUint8[idx*4+3] = 255
    }
  }
  // put the noise map data into ImageData for quicker rendering
  noiseMapImg = new ImageData(noiseMapUint8, W, H)
}
offsetCtrl.onChange(updateNoise)
nsExponentCtrl.onChange(updateNoise)
updateNoise()


// initialize the particle & sensors
const particle = {
  x: Math.random()*W,
  y: Math.random()*H,
  vx: 0,
  vy: 0,
  ax: 0,
  ay: 0
}
const sensors = new Array(STG.nbSensors)
for (let i = 0; i < sensors.length; i++) {
  sensors[i] = {
    angle: i/sensors.length * 2*Math.PI,
    weight: 0,
    x: 0,
    y: 0,
    fakeX: 0,
    fakeY: 0
  }
}

// add a button to reset the particle
const btnCtrl = {
  resetParticle: () => {
    particle.x = Math.random()*W
    particle.y = Math.random()*H
    particle.vx = 0
    particle.vy = 0
    particle.ax = 0
    particle.ay = 0
  }
}
gui.add(btnCtrl, "resetParticle")


// toroidal topology (coordinates that loop on the edges)
const toroidal = (x, y) => {
  if (x > W) x%= W
  else if (x < 0) x = W + x
  if (y > H) y%= H
  else if (y < 0) y = H + y
  return { x, y }
}


const update = () => {
  // update each sensor by sampling the noise map
  for (const sensor of sensors) {
    // get sensor position
    sensor.fakeX = particle.x + Math.cos(sensor.angle) * STG.sensorsOffset
    sensor.fakeY = particle.y + Math.sin(sensor.angle) * STG.sensorsOffset
    const Spos = toroidal(sensor.fakeX, sensor.fakeY)
    const idx = index(Spos.x|0, Spos.y|0)
    sensor.x = Spos.x
    sensor.y = Spos.y
    sensor.weight = noiseMap[idx]
  }
  
  // compute the direction based on the weight of each sensor
  let dirX = 0
  let dirY = 0
  for (const sensor of sensors) {
    dirX+= Math.cos(sensor.angle) * sensor.weight
    dirY+= Math.sin(sensor.angle) * sensor.weight
  }
  dirX/= STG.nbSensors
  dirY/= STG.nbSensors
  particle.ax = dirX
  particle.ay = dirY
  
  // now update the particle
  particle.vx+= particle.ax * STG.attrStrength
  particle.vy+= particle.ay * STG.attrStrength
  particle.x+= particle.vx
  particle.y+= particle.vy
  particle.vx*= STG.invFriction
  particle.vy*= STG.invFriction
  
  // toroidal topology
  const { x, y } = toroidal(particle.x, particle.y)
  particle.x = x
  particle.y = y
}


const drawVector = (cx, cy, x, y) => {
  ctx.save()
  ctx.translate(cx, cy)
  const angle = Math.atan2(y, x)
  const len = Math.sqrt(x**2 + y**2)
  ctx.rotate(angle)
  // the main line
  ctx.beginPath()
  ctx.moveTo(0, 0)
  ctx.translate(len, 0)
  ctx.lineTo(0, 0)
  ctx.stroke()
  // the arrow
  ctx.beginPath()
  ctx.moveTo(-5, 5)
  ctx.lineTo(0, 0)
  ctx.lineTo(-5, -5)
  ctx.stroke()
  ctx.restore()
}


const draw = () => {
  // clear the background
  ctx.fillStyle = "black"
  ctx.fillRect(0, 0, W, H)
  
  // draw the simplex noise
  ctx.putImageData(noiseMapImg, 0, 0)
  
  // then the sensors
  for (const sensor of sensors) {
    // draw the link
    ctx.strokeStyle = "blue"
    ctx.lineWidth = 1.5
    ctx.beginPath()
    ctx.moveTo(particle.x, particle.y)
    ctx.lineTo(sensor.fakeX, sensor.fakeY)
    ctx.stroke()
    
    // draw the sensor
    ctx.lineWidth = 2
    const val = (sensor.weight*255)|0
    ctx.fillStyle = `rgb(${val}, ${val}, ${val})`
    ctx.beginPath()
    ctx.arc(sensor.x, sensor.y, 8, 0, 2*Math.PI)
    ctx.fill()
    ctx.stroke()
    
    // draw the sensor weight, so that it's always visible
    ctx.strokeStyle = "#00ff00"
    drawVector(
      particle.x, 
      particle.y, 
      Math.cos(sensor.angle) * sensor.weight * STG.sensorsOffset, 
      Math.sin(sensor.angle) * sensor.weight * STG.sensorsOffset
    )
  }
  
  // draw the particle
  ctx.fillStyle = "white"
  ctx.beginPath()
  ctx.arc(particle.x, particle.y, 18, 0, 2*Math.PI)
  ctx.fill()
  ctx.fillStyle = "red"
  ctx.beginPath()
  ctx.arc(particle.x, particle.y, 16, 0, 2*Math.PI)
  ctx.fill()
  
  // draw the direction of the particle
  ctx.strokeStyle = "red"
  ctx.lineWidth = 4
  const mag = Math.sqrt(particle.ax**2 + particle.ay**2)
  const dx = particle.ax / mag
  const dy = particle.ay / mag
  const L = Math.min(300, Math.max(30, mag * 500))
  
  drawVector(particle.x, particle.y, dx * L, dy * L)
}


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

loop()

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
  2. https://cdnjs.cloudflare.com/ajax/libs/simplex-noise/2.4.0/simplex-noise.min.js