3D Noise

How to 3D

Chapter 11: Noise

3D Noise

With 2D noise, we can texture planar surfaces. If we extend it to 3D, we can texture volumes. The algorithm to produce 3D noise is a direct extension of the 2D algorithm.

First we need a method for generating random gradients. In 2D, we used polar coordinates: a radius and an angle. In 3D, we use spherical coordinates: a radius, an longitude angle, and a latitude angle. As before, the radius is 1. The longitude angle can be anywhere around the whole sphere and must therefore be in \([0, 2\pi)\). The latitude angle can be anywhere from the south pole to the north pole and must therefore be in \([-\frac{\pi}{2}, \frac{\pi}{2}]\). This pseudocode generates the coordinates and converts them to a Cartesian vector:

function randomGradient3
  longitude = random(0, 2 * pi)
  latitude = random(-pi / 2, pi / 2)
  return new Vector3(
    cos latitude * cos longitude,
    sin latitude,
    cos latitude * sin longitude
  )
function randomGradient3
  longitude = random(0, 2 * pi)
  latitude = random(-pi / 2, pi / 2)
  return new Vector3(
    cos latitude * cos longitude,
    sin latitude,
    cos latitude * sin longitude
  )

The noise algorithm requires a grid of these random 3D gradients:

gradients = new grid of dimensions (width, height, depth)
for each z
  for each y
    for each x
      gradients[x, y, z] = randomGradient3
gradients = new grid of dimensions (width, height, depth)
for each z
  for each y
    for each x
      gradients[x, y, z] = randomGradient3

The noise function reads vectors from this grid and computes the difference vectors and dot products, now considering the third dimension. Instead of four of each value, we have eight:

function noise(p)
  // ...

  // Look up gradients of surrounding cell
  gradient000 = gradients[wrappedFloor.x, wrappedFloor.y, wrappedFloor.z]
  gradient100 = gradients[wrappedCeiling.x, wrappedFloor.y, wrappedFloor.z]
  gradient010 = gradients[wrappedFloor.x, wrappedCeiling.y, wrappedFloor.z]
  gradient110 = gradients[wrappedCeiling.x, wrappedCeiling.y, wrappedFloor.z]
  gradient001 = gradients[wrappedFloor.x, wrappedFloor.y, wrappedCeiling.z]
  gradient101 = gradients[wrappedCeiling.x, wrappedFloor.y, wrappedCeiling.z]
  gradient011 = gradients[wrappedFloor.x, wrappedCeiling.y, wrappedCeiling.z]
  gradient111 = gradients[wrappedCeiling.x, wrappedCeiling.y, wrappedCeiling.z]

  // Calculate difference vectors from 8 corners to p
  diff000 = p - floor
  diff100 = p - [ceiling.x, floor.y, floor.z]
  diff010 = p - [floor.x, ceiling.y, floor.z]
  diff110 = p - [ceiling.x, ceiling.y, floor.z]
  diff001 = p - [floor.x, floor.y, ceiling.z]
  diff101 = p - [ceiling.x, floor.y, ceiling.z]
  diff011 = p - [floor.x, ceiling.y, ceiling.z]
  diff111 = p - [ceiling.x, ceiling.y, ceiling.z]

  // Dot gradients and difference vectors
  dot000 = dot(gradient000, diff000)
  dot100 = dot(gradient100, diff100)
  dot010 = dot(gradient010, diff010)
  dot110 = dot(gradient110, diff110)
  dot001 = dot(gradient001, diff001)
  dot101 = dot(gradient101, diff101)
  dot011 = dot(gradient011, diff011)
  dot111 = dot(gradient111, diff111)
function noise(p)
  // ...

  // Look up gradients of surrounding cell
  gradient000 = gradients[wrappedFloor.x, wrappedFloor.y, wrappedFloor.z]
  gradient100 = gradients[wrappedCeiling.x, wrappedFloor.y, wrappedFloor.z]
  gradient010 = gradients[wrappedFloor.x, wrappedCeiling.y, wrappedFloor.z]
  gradient110 = gradients[wrappedCeiling.x, wrappedCeiling.y, wrappedFloor.z]
  gradient001 = gradients[wrappedFloor.x, wrappedFloor.y, wrappedCeiling.z]
  gradient101 = gradients[wrappedCeiling.x, wrappedFloor.y, wrappedCeiling.z]
  gradient011 = gradients[wrappedFloor.x, wrappedCeiling.y, wrappedCeiling.z]
  gradient111 = gradients[wrappedCeiling.x, wrappedCeiling.y, wrappedCeiling.z]

  // Calculate difference vectors from 8 corners to p
  diff000 = p - floor
  diff100 = p - [ceiling.x, floor.y, floor.z]
  diff010 = p - [floor.x, ceiling.y, floor.z]
  diff110 = p - [ceiling.x, ceiling.y, floor.z]
  diff001 = p - [floor.x, floor.y, ceiling.z]
  diff101 = p - [ceiling.x, floor.y, ceiling.z]
  diff011 = p - [floor.x, ceiling.y, ceiling.z]
  diff111 = p - [ceiling.x, ceiling.y, ceiling.z]

  // Dot gradients and difference vectors
  dot000 = dot(gradient000, diff000)
  dot100 = dot(gradient100, diff100)
  dot010 = dot(gradient010, diff010)
  dot110 = dot(gradient110, diff110)
  dot001 = dot(gradient001, diff001)
  dot101 = dot(gradient101, diff101)
  dot011 = dot(gradient011, diff011)
  dot111 = dot(gradient111, diff111)

The next step in 2D was to blerp between the four corners. In 3D, we must trilinearly interpolate between the eight corners. We reduce our eight dot products down to one noise value by lerping four times along the x-axis, twice along the y-axis, and once along the z-axis:

function noise(p)
  // ...
  smoothFraction = [smooth(fraction.x), smooth(fraction.y), smooth(fraction.z)]

  // Interpolate along x-axis
  dot00 = lerp(dot000, dot100, smoothFraction.x)
  dot01 = lerp(dot001, dot101, smoothFraction.x)
  dot10 = lerp(dot010, dot110, smoothFraction.x)
  dot11 = lerp(dot011, dot111, smoothFraction.x)

  // Interpolate along y-axis
  dot0 = lerp(dot00, dot10, smoothFraction.y)
  dot1 = lerp(dot01, dot11, smoothFraction.y)

  // Interpolate along z-axis
  dot = lerp(dot0, dot1, smoothFraction.z)

  // ...
function noise(p)
  // ...
  smoothFraction = [smooth(fraction.x), smooth(fraction.y), smooth(fraction.z)]

  // Interpolate along x-axis
  dot00 = lerp(dot000, dot100, smoothFraction.x)
  dot01 = lerp(dot001, dot101, smoothFraction.x)
  dot10 = lerp(dot010, dot110, smoothFraction.x)
  dot11 = lerp(dot011, dot111, smoothFraction.x)

  // Interpolate along y-axis
  dot0 = lerp(dot00, dot10, smoothFraction.y)
  dot1 = lerp(dot01, dot11, smoothFraction.y)

  // Interpolate along z-axis
  dot = lerp(dot0, dot1, smoothFraction.z)

  // ...

The resulting noise function turns any 3D position into a random value, and its neighboring positions will yield coherently random values. We can use it to perturb our meshes. This mesh, for example, was a sphere before it got mangled by scaling each vertex position by its corresponding noise value:

The mangling happened on the CPU before the geometry was shipped off to the graphics card. If we want to apply the noise in a shader, we must generate a field of 3D noise and upload it as a 3D texture. We create a Field3 abstraction that is very similar to Field2. It has this static method that iterates through and generates the noise value at each element:

function noiseField(width, height, depth, scale)
  field = new field of dimensions (width, height, depth)
  for each z
    for each y
      for each x
        field[x, y, z] = noise((x, y, z) * scale)
  return field
function noiseField(width, height, depth, scale)
  field = new field of dimensions (width, height, depth)
  for each z
    for each y
      for each x
        field[x, y, z] = noise((x, y, z) * scale)
  return field

Most textures used in renderers are 2D, but graphics cards do offer limited support for 3D textures. They use up VRAM faster than 2D textures, so they can't be very big, and not all filtering options may be available.

We allocate and upload a 3D texture with this utility function:

TypeScript
function createRedTexture3d(width: number, height: number, depth: number, texels: Uint8ClampedArray, textureUnit: GLenum = gl.TEXTURE0) {
  gl.activeTexture(textureUnit);
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_3D, texture);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.MIRRORED_REPEAT);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texImage3D(gl.TEXTURE_3D, 0, gl.R8, width, height, depth, 0, gl.RED, gl.UNSIGNED_BYTE, texels);
  return texture;
}
function createRedTexture3d(width: number, height: number, depth: number, texels: Uint8ClampedArray, textureUnit: GLenum = gl.TEXTURE0) {
  gl.activeTexture(textureUnit);
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_3D, texture);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.MIRRORED_REPEAT);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texImage3D(gl.TEXTURE_3D, 0, gl.R8, width, height, depth, 0, gl.RED, gl.UNSIGNED_BYTE, texels);
  return texture;
}

Once the 3D noise is on the graphics card, we can use volumetric noise to simulate natural phenomena like marble, wood, and water.

← CloudsMarble →