Explorations in Randomness

The concept of random number generation has interested me for quite some time, especially because it's so difficult to truly accomplish. However, when it comes to creating something beautiful, you need pattern. The reason we tend to enjoy looking at something is because of a pleasant equilibrium between expectation and novelty. So I set out to explore ways to harness randomness without sacrificing visual appeal and balance. 

I created a random walk class to visualize different randomness algorithms. I'm using p5.js and ES6+ because luxury, but all of this can be accomplished with vanilla things and Processing.

The first algorithm (red) simply moves the walker in one of the four cardinal directions with equal probability.

function equalProbCardinal(s) {
  // s is an instance of the p5 sketch
  const r = Math.floor(s.p5.random(4));
  const increment = s.size * 2;
  if (r === 0) s.x += increment;
  if (r === 1) s.x -= increment;
  if (r === 2) s.y += increment;
  if (r === 3) s.y -= increment;
}

The second (blue) moves the walker in one of the four cardinal directions, diagonal directions or stays in place with equal probability.

function equalProbAllDirs(s) {
  let rX = Math.floor(s.p5.random(-1, 2));
  let rY = Math.floor(s.p5.random(-1, 2));

  function addSpeed(r) {
    if (r > 0) return s.size * 2;
    if (r < 0) return -s.size * 2;
    if (r === 0) return 0;
  }

  s.x += addSpeed(rX);
  s.y += addSpeed(rY);
}

The Walker takes a properties object, which includes a randomness function.

class Walker {
  constructor(props) {
    for (let propName in props) {
      this[propName] = props[propName]
    }

    this.p5.frameRate(this.frameRate || 20);
  }

  constrainCoords() {
    this.x = this.p5.constrain(this.x, this.size, this.p5.width - this.size)
    this.y = this.p5.constrain(this.y, this.size, this.p5.height - this.size)
  }

  render() {
    this.randomGen(this);
    this.constrainCoords();
    this.p5.noStroke();
    this.p5.fill(this.color)
    this.p5.ellipse(this.x, this.y, this.size)
  }
}

The sketch() function is a reusable setter that takes an instance of p5 and other properties with which to instantiate the Walker. It creates a new Walker for each config object it's passed.

function sketch(...configs) {
  return (p5) => {
    let walkers = [];

    function createWalker(config) {
      const x = (p5.width / 2) + config.size;
      const y = (p5.height / 2) + config.size;
      const props = { p5, x, y, ...config };
      walkers.push(new Walker(props));
    }

    p5.setup = function () {
      p5.createCanvas(600, 300);
      configs.forEach((config) => createWalker(config));
    }

    p5.draw = function () {
      walkers.forEach((w) => w.render());
    }
  }
}

Passing sketch() the configurations of two Walkers, I create a new p5 instance that implements both on its canvas.

const s1Config = {
  color: 'rgb(219, 23, 80)',
  size: 3,
  randomGen: (s) => equalProbCardinal(s),
}

const s2Config = {
  color: 'rgb(23, 80, 219)',
  size: 3,
  randomGen: (s) => equalProbAllDirs(s),
}

new p5(sketch(s1Config, s2Config));

In this next implementation, I use Gaussian distribution to update the coordinates of one Walker (green). It takes a standard deviation, and sets the x and y positions to a random Gaussian number, where the position is the mean. The other (yellow) uses the Monte Carlo method, where the probability a given random number will be used is equal to that number.

function gaussian(s, sd) {
  s.x = s.p5.randomGaussian(s.p5.width / 2, sd);
  s.y = s.p5.randomGaussian(s.p5.height / 2, sd);
}

function monteCarlo(s, stepMax) {
  const stepSize = s.p5.random(0, stepMax);
  const probability = stepSize;
  const qualifier = s.p5.random(0, stepMax);

  if (qualifier < probability) {
    s.x += s.p5.random(-stepSize, stepSize);
    s.y += s.p5.random(-stepSize, stepSize);
  }
}
  
const s3Config = {
  color: 'rgb(36, 232, 175)',
  size: 3,
  randomGen: (s) => gaussian(s, 30),
}

const s4Config = {
  color: 'rgb(219, 162, 23)',
  size: 3,
  randomGen: (s) => monteCarlo(s, 20),
}

new p5(sketch(s3Config, s4Config));

Finally, I created a function to position the x and y coordinates based on numbers mapped to Perlin noise. It is far and away the best of the five algorithms here to simulate natural phenomena, and I'm excited to dig into it further.

function perlin(s) {
  const nx = s.p5.noise(s.xoff);
  const ny = s.p5.noise(s.yoff);
  s.x = s.p5.map(nx, 0, 1, 0, s.p5.width);
  s.y = s.p5.map(ny, 0, 1, 0, s.p5.height);
  s.xoff += 0.01;
  s.yoff += 0.01;
}

const s5Config = {
  frameRate: 30,
  color: 'rgb(0, 0, 0)',
  size: 1,
  randomGen: perlin,
  xoff: 0,
  yoff: 1000,
}

new p5(sketch(s5Config));