teaching machines

CS 488: Lecture 19 – Value Noise

April 5, 2021 by . Filed under graphics-3d, lectures, spring-2021.

Dear students:

Algorithms produce visual content that is too perfect. We humans see the artifice and find it distasteful. When we explore the physical world, we see surfaces full of splotches and cracks. To achieve similar imperfections in algorithmic content, we introduce randomness. But pure randomness will take us to the opposite extreme of chaos. Today we discuss one method of creating believable randomness: value noise.

White Noise

Randomness falls into several different categories depending on its degree of chaos. The most chaotic is white noise. In sound, the static we sometimes hear on the radio is white noise. On old televisions, the spray of black and white we see when viewing a non-existent channel is white noise.

We can generate white noise with a pseudo-random number generator. This pseudocode does the trick:

static function whiteNoise(dimensions)
  grays = allocate grid
  for each pixel xy
    grays[xy] = random(0, 1)
  return grays

White noise is a little too wild. Nature somehow manages to find a balance between perfection and chaos, and we need to seek that balance ourselves in our generative algorithms.

Value Noise

One technique of organizing the chaos is to confine ourselves to a very small region of white noise. When we apply the noise to a surface, we will inevitably end up needing values at subpixel locations. We compute subpixel noise by interpolating between the neighboring scalars. Noise that is made by smoothing out random scalars is called value noise. The name is in contrast to gradient noise, which we’ll talk about in a later lecture.

When we approach the edges of a field of value noise, interpolation becomes tricky. If we think of the observed value as being at the bottom-left of its cell, then we must be careful to never enter the cells on the top and right edges of the field. Otherwise we’ll get out-of-bounds exceptions when we lookup up the surrounding neighbors. Alternatively, we could clamp the neighbors coordinates to valid indices. Perhaps the best strategy is to wrap the coordinates back around to the opposite edge. With wrapping, the noise field will tile seamlessly with itself. Mathematicians would say that such a noise field has the topology of a torus.

To achieve seamlessness, we use the follow algorithm to perform our linear interpolation at an arbitrary subpixel location xy:

function lerpWrapped(xy)
  xyFloor = floor(xy)
  xyCeil = wrap(xyFloor + 1)
  xyFraction = xy - xyFloor
  below = lerp between bottom-left value and bottom-right value
  above = lerp between top-left value and top-right value
  value = lerp between below and above
  return value

Fractal Noise

Smoothing out white noise via interpolation only slightly improves its aesthetics. We need to take a different approach to address the underlying issue of white noise, which is its high-frequency changes. Our approach will be to add multiple layers (sometimes called octaves) of value noise together.

Each layer will be weighted. The layer with the greatest weight will have low frequency changes. How do we get an image with low frequency changes? Generate a small image of white noise and resuze it, interpolating between the values to smooth out the chaos. The layer with the least weight will be pure white noise.

Suppose we want a field noise whose dimensions are 64×64. With two layers, we’d composite these two images:

With three layers, we’d composite these three images:

With four layers, we’d composite these four images:

Composited noise is sometimes called fractal noise. We compute a field of fractal noise using the following algorithm:

static function fractalNoise(dimensions, layerCount)
  noiseField = allocate grid of 0s
  for each layer
    whiteNoise = generate white noise at current dimensions
    scale whiteNoise to original dimensions
    noiseField += weight * whiteNoise
    halve current dimensions
  return noiseField

The layer count shouldn’t be larger than base-2 logarithm of the dimensions. When the layer count is close to the logarithm, the the noise is smoother.

Scale

The fractal noise generator depends on a scale routine that takes a field of a certain resolution and creates a new field that looks like the original but has a different resolution. How might we implement this? We know that each pixel in the new field needs its value assigned, so we iterate through its lattice points. At each point, we turn the coordinates in proportions and then apply these proportions to the original dimensions. This will in general give us non-integer coordinates. We can send these to our lerpWrapped function to interpolate the surrounding values. Altogether, our routine might look something like this:

function scale(newDimensions)
  newField = allocate grid using newDimensions
  for each lattice point xy in newField
    compute proportion of lattice point  
    j = apply proportion to original dimensions
    value = lookup lerped value at position j
    set newField's value at xy to value
  return newField

Exercise

Now we’re going to try a little experiment. We’ll split the class into four groups, and each group will take on the task of implementing one of the four algorithms described above using this Field2 class. None of the methods is very complicated in and of itself, provided you call upon the methods the other groups are writing and the ones I provide in this class:

export class Field2 {
  // This class represents a two-dimensional field of scalar values.
  // It's like an image, but it has only a single channel.

  static allocate(dimensions) {
    // Create a field of floats.
    // dimensions: a Vector2 of the width and height
    const field = new Field2(dimensions, null);
    field.data = new Float32Array(field.size);
    return field;
  }

  constructor(dimensions, data) {
    // Create a field with the given dimensions and data.
    // dimensions: a Vector2 of the width and height
    // data: a typed array of the field's raster
    this.dimensions = dimensions;
    this.size = this.dimensions.product;
    this.data = data;
  }

  get width() {
    return this.dimensions.x;
  }

  get height() {
    return this.dimensions.y;
  }

  get2(c, r) {
    // Get the value at the given column and row.
    // c: column
    // r: column
    return this.data[r * this.dimensions.x + c];
  }

  get(i) {
    // Get the value at the given 2D coordinates.
    // i: a Vector2 of the column and row
    return this.data[i.y * this.dimensions.x + i.x];
  }

  set(i, value) {
    // Set the value at the given 2D coordinates.
    // i: a Vector2 of the column and row
    // value: the value to set
    this.data[i.y * this.dimensions.x + i.x] = value;
  }

  *[Symbol.iterator]() {
    // An iterator-generator for looping through all the data. This function
    // allows the caller to visit each lattice point in the field with a single
    // for-of loop, like this:
    //
    // for (let i of field) {
    //   // use i, a Vector2
    // }
    const i = new Vector2(0, 0);
    for (i.y = 0; i.y < this.dimensions.y; ++i.y) {
      for (i.x = 0; i.x < this.dimensions.x; ++i.x) {
        yield i;
      }
    }
  }

  toUnsignedByte() {
    // Converts the field's data array to a Uint8Array. Assumes
    // current field holds floats in [0, 1].
    const field = new Field2(this.dimensions, null);
    field.data = new Uint8Array(field.size);
    for (let i = 0; i < this.size; ++i) {
      field.data[i] = Math.floor(this.data[i] * 255);
    }
    return field;
  }
}

See you next time.

Sincerely,

P.S. It’s time for a haiku!

Rocks are beautiful
Can cars be beautiful too?
Or must they crash first?