CS 488: Lecture 19 – Value Noise
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:
- a 64×64 image of white noise weighted at 1/3
- a 32×32 image of white noise scaled to 64×64 and weighted at 2/3
With three layers, we’d composite these three images:
- a 64×64 image of white noise weighted at 1/7
- a 32×32 image of white noise scaled to 64×64 and weighted at 2/7
- a 16×16 image of white noise scaled to 64×64 and weighted at 4/7
With four layers, we’d composite these four images:
- a 64×64 image of white noise weighted at 1/15
- a 32×32 image of white noise scaled to 64×64 and weighted at 2/15
- a 16×16 image of white noise scaled to 64×64 and weighted at 4/15
- a 4×4 image of white noise scaled to 64×64 and weighted at 8/15
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.
P.S. It’s time for a haiku!
Rocks are beautiful
Can cars be beautiful too?
Or must they crash first?