Worley Noise

On my ongoing learning path in the Creative Coding Arts I’m going to make a lot of stops at a bunch of Clichés. I’ve been saying that this is, for me, the equivalent of the “Create a Blog in 5 Minutes with Framework X” of the Web Development scene. This time, the Cliché’s name is Worley Noise and I got to it via another Coding Train episode.

Worley Noise’s algorithm works as follows:

  1. We randomly create and distribute feature points (I called them control points in the code for whatever reason)
  2. With that out of the way, we draw a picture where, for every pixel, we calculate the distance to the feature points
  3. We then map - somehow - that distance to a given color and move on to the next pixel

The result is sometimes alike cells, other times it resembles crystals , it really depends how you approach the color mapping and which distance you decide to use.

I started this exercise off with a simple enough code where I had an array of points distributed randomly across the screen and I went around every pixel on the screen making stroke / point calls to p5.js until the picture was formed. It worked but it was very very slow, so I took a little bit of time to try and make it a little bit better, and although I managed to get a full picture rendered from 12 seconds down to 3s - on my computer, which is a blessing because there’s a GPU available, but also a curse because it’s a 5k display and so the canvas is very large - it’s still slow enough that I didn’t even consider adding animation to this experiment.

In terms of “optimizations” I did a couple of things:

  1. Instead of using P5.js stroke / point methods, I manipulate the pixel array directly, which in turn makes the code an estimated 94.55% less unreadable as we need to navigate a one-dimensional, pixel density aware, 4 color element array instead of just saying “draw this pixel this color”
  2. I also went with the cell approach to the feature points, where instead of having an array of randomly generated points, I now break down the canvas in a grid and randomly place a point in each grid element. This in turn makes the lookups faster as I can address specific grid cells instead of going over all the points.

I also made a few variations in Color Schemes, some are black and white and simply look at the distance to closest point, others - like the Oily ones - look at different distances for the different color components. I didn’t put a lot of science into it, I just have an easy way to add color schemes and played around with them until something visually interesting came out.

As usual the code lies bellow, for the other experiments I’ve been transpiling the ES6 code into more compatible Javascript but I figured I would start serving ES6 code directly instead as most things should be available for most browsers anyway.

If I ever manage to make this code run faster, I’ll definitely explore adding animation to this - be it that the points move around or that I map things in a 3D space and navigate that.

Links:

Examples made using the experiment

All the code that runs this experiment is bellow, apart from any external libraries (i.e. p5.js)

'use strict'

class Particle {
  constructor(sketch, x, y, boundary) {
    this.sketch = sketch

    this.position = this.sketch.createVector(x, y)
    this.velocity = this.sketch.createVector(sketch.random(-5, 5), sketch.random(-5, 5))
    this.boundary = boundary
  }

  update() {
    this.position.add(this.velocity)

    if (this.position.x <= this.boundary[0].x || this.position.x >= this.boundary[1].x) this.velocity.x *= -1
    if (this.position.y <= this.boundary[0].y || this.position.y >= this.boundary[1].y) this.velocity.y *= -1
  }

  draw() {
    this.sketch.push()

    this.sketch.fill("green")
    this.sketch.stroke("green")

    this.sketch.circle(this.position.x, this.position.y, 5)

    this.sketch.pop()

    this.update()
  }
}

// Avoiding Global Mode: https://github.com/processing/p5.js/wiki/Global-and-instance-mode
const experiment = ( sketch ) => {
  //
  // Configuration
  //
  let container = document.getElementById('sketch-holder')
  let canvas_el

  let control_points_grid = []
  let grid_size_in_pixels = container.clientWidth / 10

  // Controls
  let controls = {
    color_scheme_picker: document.getElementById("experiment-control-color-scheme"),
    save: document.getElementById("experiment-control-save"),
  }

  let control_labels = {
  }

  // Color Schemes
  let color_scheme
  let color_sets = {
    black_white: {
      background: "black",
      label: "White Blobs",
      color: (sorted_distances) => {
        return [
          sketch.map(sorted_distances[0], 0, sketch.width/10, 255, 0, true),
          sketch.map(sorted_distances[0], 0, sketch.width/10, 255, 0, true),
          sketch.map(sorted_distances[0], 0, sketch.width/10, 255, 0, true),
        ]
      }
    },
    black_white_2: {
      background: "black",
      label: "Dark cells",
      color: (sorted_distances) => {
        return [
          sketch.map(sorted_distances[0], 0, grid_size_in_pixels, 0, 255, true),
          sketch.map(sorted_distances[0], 0, grid_size_in_pixels, 0, 255, true),
          sketch.map(sorted_distances[0], 0, grid_size_in_pixels, 0, 255, true),
        ]
      }
    },
    black_white_3: {
      background: "black",
      label: "White Crystals",
      color: (sorted_distances) => {
        return [
          sketch.map(sorted_distances[1], 0, grid_size_in_pixels, 255, 0, true),
          sketch.map(sorted_distances[1], 0, grid_size_in_pixels, 255, 0, true),
          sketch.map(sorted_distances[1], 0, grid_size_in_pixels, 255, 0, true),
        ]
      }
    },
    oily: {
      background: "black",
      label: "Oily",
      color: (sorted_distances) => {
        return [
          sketch.map(sorted_distances[0], 0, grid_size_in_pixels + 50, 255, 0, true),
          sketch.map(sorted_distances[1], 0, grid_size_in_pixels + 50, 255, 0, true),
          sketch.map(sorted_distances[2], 0, grid_size_in_pixels + 50, 255, 0, true)
        ]
      }
    },
    dark_oily: {
      background: "black",
      label: "Dark Oily",
      color: (sorted_distances) => {
        return [
          sketch.map(sorted_distances[0], grid_size_in_pixels / 2, grid_size_in_pixels * 2, 0, 255, true),
          sketch.map(sorted_distances[1], grid_size_in_pixels / 2, grid_size_in_pixels * 2, 0, 255, true),
          sketch.map(sorted_distances[2], grid_size_in_pixels / 2, grid_size_in_pixels * 2, 0, 255, true)
        ]
      }
    },
    lava: {
      background: "black",
      label: "Lava",
      color: (sorted_distances) => {
        return [
          sketch.map(sorted_distances[0], 0                       , grid_size_in_pixels / 2 , 50, 255, true),
          sketch.map(sorted_distances[0], grid_size_in_pixels / 2 , grid_size_in_pixels     , 0, 255, true),
          sketch.map(sorted_distances[0], grid_size_in_pixels     , grid_size_in_pixels * 2 , 0, 255, true)
        ]
      }
    },
    green_crystal: {
      background: "black",
      label: "Green Crystals",
      color: (sorted_distances) => {
        return [
          0,
          sketch.map(sorted_distances[1], 0, grid_size_in_pixels, 0, 255, true),
          0,
        ]
      }
    },
  }

  //
  // P5.js touchpoints
  //
  sketch.setup = () => {
    canvas_el = sketch.createCanvas(container.clientWidth, container.clientHeight)
    canvas_el.parent(container)

    color_scheme = sketch.random(Object.keys(color_sets))

    updateControlPointGrid()

    // Controls
    setupControls()
    installControlHandlers()

  }

  sketch.draw = () => {
    var density = sketch.pixelDensity()
    var x = 0
    var y = 0
    var distances = []
    var color = []
    var index = 0

    sketch.loadPixels()

    for(y = 0; y < sketch.height; y++) {
      for(x = 0; x < sketch.width; x++) {
        // Calculate the distances for all control points
        var index_x = Math.floor(x / grid_size_in_pixels)
        var index_y = Math.floor(y / grid_size_in_pixels)

        distances = []

        for (var i = index_x - 1; i <= index_x + 1; i++) {
          for (var j = index_y - 1; j <= index_y + 1; j++ ) {
            if (control_points_grid[i] && control_points_grid[i][j]) {
              distances.push(
                sketch.dist(x, y, control_points_grid[i][j].position.x, control_points_grid[i][j].position.y)
              )
            }
          }
        }

        distances = distances.sort((a, b) => a - b)

        for (let i = 0; i < density; i++) {
          for (let j = 0; j < density; j++) {
            // loop over
            index = 4 * ((y * density + j) * sketch.width * density + (x * density + i));

            color = color_sets[color_scheme].color(distances)

            sketch.pixels[index]   = color[0] || 0;
            sketch.pixels[index+1] = color[1] || 0;
            sketch.pixels[index+2] = color[2] || 0;
            sketch.pixels[index+3] = color[3] || 255;
          }
        }
      }
    }
    sketch.updatePixels()
    sketch.noLoop()
  }

  //
  // Helper methods
  //

  function updateControlPointGrid() {
    var grid_x = 0
    var grid_y = 0

    var grid_size_x = Math.floor(sketch.width / grid_size_in_pixels) + 1
    var grid_size_y = Math.floor(sketch.height / grid_size_in_pixels) + 1

    for(grid_x = 0; grid_x < grid_size_x; grid_x++) {
      for(grid_y = 0; grid_y < grid_size_y; grid_y++) {
        control_points_grid[grid_x] ||= []

        control_points_grid[grid_x][grid_y] = new Particle(
          sketch,
          sketch.random(grid_x * grid_size_in_pixels, (grid_x+1) * grid_size_in_pixels),
          sketch.random(grid_y * grid_size_in_pixels, (grid_y+1) * grid_size_in_pixels),
          [
            sketch.createVector(grid_x * grid_size_in_pixels, grid_y * grid_size_in_pixels),
            sketch.createVector(grid_x * grid_size_in_pixels + grid_size_in_pixels, grid_y * grid_size_in_pixels + grid_size_in_pixels),
          ]
          )
      }
    }
  }


  //
  // Control Handling
  //

  function setupControls() {
    Object.entries(color_sets).forEach(([key, scheme]) => {
      var option = document.createElement("option")
      option.value = key
      option.text = scheme.label
      if (key == color_scheme) option.selected = "SELECTED"

      controls.color_scheme_picker.add(option)
    })
  }

  function installControlHandlers() {
    //
    // Color Scheme
    //
    controls.color_scheme_picker.addEventListener("change", function(event) {
      color_scheme = event.target.value
      sketch.redraw()
    })

    // Save file
    controls.save.addEventListener("click", function() {
      sketch.save(canvas_el, `worley-noise_${color_scheme}_${grid_size_in_pixels}_${Math.floor(Date.now() / 1000)}`, 'png')
    })
  }

}


// Wait for everything to load
if (document.readyState === 'complete') {
  new p5(experiment)
} else {
  window.onload = (event) => {
    new p5(experiment)
  }
}