5. Perlin Noise
Ray Tracing: The Next Week (v3.2.3): 5 Perlin Noise / 2.5 Perlin Noise
Perlin noise is a grid-based pseudorandom function introduced by Ken Perlin in 1985. It changes smoothly near an input point while appearing random over larger distances, allowing procedural generation of textures with natural variation, such as wood, marble, clouds, and terrain, without referring to an image.
Following the progression in the original, this chapter improves the texture in four stages: block noise based only on hashing, smoothing, true Perlin noise with random vectors at lattice points, turbulence, and finally a marble-like pattern. Each stage is implemented in a separate demo crate and rendered in the browser through WASM.
| Demo crate | Mode | Corresponding image in the original |
|---|---|---|
r205-perlin-hash | Hash | Image 7 (block noise) |
r205-perlin-smooth | Solid | Image 11 (after smoothing) |
r205-perlin-turb | Turbulence | Image 12 (turbulence) |
r205-perlin-marble | Marble | Image 13 (marble) |
The shared implementation adds the Perlin type in common/src/perlin.rs, together with NoiseTexture and NoiseMode in common/src/texture.rs. Scene code can use Lambertian::with_texture, introduced in Chapter 4, without modification.
Block Noise Using Hashing
The first stage is the simplest possible implementation: it returns a predetermined random number for each cell of a three-dimensional grid. For integer lattice indices
where
Create common/src/perlin.rs and begin with the lattice tables and noise_hash.
use crate::utils::{random_double_range, random_int};
use crate::vec3::{Point3, Vec3, dot, unit_vector};
const POINT_COUNT: usize = 256;
pub struct Perlin {
ranvec: [Vec3; POINT_COUNT],
ranfloat: [f64; POINT_COUNT],
perm_x: [usize; POINT_COUNT],
perm_y: [usize; POINT_COUNT],
perm_z: [usize; POINT_COUNT],
}
impl Perlin {
pub fn new() -> Self {
let mut ranvec = [Vec3::new(0.0, 0.0, 0.0); POINT_COUNT];
let mut ranfloat = [0.0_f64; POINT_COUNT];
for i in 0..POINT_COUNT {
ranvec[i] = unit_vector(Vec3::new(
random_double_range(-1.0, 1.0),
random_double_range(-1.0, 1.0),
random_double_range(-1.0, 1.0),
));
ranfloat[i] = random_double_range(0.0, 1.0);
}
Perlin {
ranvec,
ranfloat,
perm_x: perlin_generate_perm(),
perm_y: perlin_generate_perm(),
perm_z: perlin_generate_perm(),
}
}
pub fn noise_hash(&self, p: Point3) -> f64 {
let i = ((4.0 * p.x()) as i32) & 255;
let j = ((4.0 * p.y()) as i32) & 255;
let k = ((4.0 * p.z()) as i32) & 255;
let idx = self.perm_x[i as usize]
^ self.perm_y[j as usize]
^ self.perm_z[k as usize];
self.ranfloat[idx]
}
}ranvec stores the random vectors distributed over the unit sphere that will be used by the final Perlin implementation. ranfloat stores the scalar random values used only by this hash-based version. Retaining both allows a single Perlin type to represent every stage conveniently.
Generate each permutation table with the Fisher-Yates algorithm. A free function is sufficient; it does not need to be an associated function of Perlin.
fn perlin_generate_perm() -> [usize; POINT_COUNT] {
let mut p = [0_usize; POINT_COUNT];
for i in 0..POINT_COUNT {
p[i] = i;
}
// Fisher-Yates shuffle from end to start.
for i in (1..POINT_COUNT).rev() {
let target = random_int(0, i as i32) as usize;
p.swap(i, target);
}
p
}random_int(0, i) is a shared utility that returns a uniformly distributed integer over the closed interval [0, i]. It requires only a small addition to utils.rs.
Differences Between C++ and Rust
The original C++ returns a raw heap array from static int* perlin_generate_perm() and releases it with delete[] in the destructor. In Rust, it is natural to allocate a fixed-size [usize; 256] array on the stack and return it by value. Array values can be moved concisely, with no corresponding new and delete operations. I use a free function rather than a static member function because this simple helper does not need to be associated with the type.
The Hash mode of NoiseTexture calls noise_hash directly.
pub enum NoiseMode {
Hash,
Solid,
Turbulence,
Marble,
}
pub struct NoiseTexture {
pub noise: Perlin,
pub scale: f64,
pub mode: NoiseMode,
}
impl NoiseTexture {
pub fn new(mode: NoiseMode, scale: f64) -> Self {
NoiseTexture {
noise: Perlin::new(),
scale,
mode,
}
}
}
impl Texture for NoiseTexture {
fn value(&self, _u: f64, _v: f64, p: Point3) -> Color {
let sp = p * self.scale;
let gray = match self.mode {
NoiseMode::Hash => self.noise.noise_hash(sp),
NoiseMode::Solid => 0.5 * (1.0 + self.noise.noise(sp)),
NoiseMode::Turbulence => self.noise.turb(sp, 7),
NoiseMode::Marble => {
0.5 * (1.0 + (sp.z() + 10.0 * self.noise.turb(p, 7)).sin())
}
};
Color::new(1.0, 1.0, 1.0) * gray
}
}The final value is gray Lambertian material. Each mode maps its values into noise value used by Solid is in
Demo 1: r205-perlin-hash
The scene is a direct port of two_perlin_spheres() from the original. A ground sphere of radius 1000 and a smaller sphere of radius 2 above it share the same Perlin texture.
fn two_perlin_spheres() -> HittableList {
let mut objects = HittableList::new();
// Hash-only blocky lookup; no input scaling (the hash internally
// multiplies the sample point by 4).
let pertext: Arc<dyn Texture> = Arc::new(NoiseTexture::new(NoiseMode::Hash, 1.0));
objects.add(Box::new(Sphere::with_material(
Point3::new(0.0, -1000.0, 0.0),
1000.0,
Arc::new(Lambertian::with_texture(pertext.clone())),
)));
objects.add(Box::new(Sphere::with_material(
Point3::new(0.0, 2.0, 0.0),
2.0,
Arc::new(Lambertian::with_texture(pertext)),
)));
objects
}The camera uses the same parameters as Chapter 4: lookfrom = (13, 2, 3), lookat = (0, 0, 0), vfov = 20°, and an aperture of 0. The remaining demos share this camera.
Because the value is constant throughout each cell, slices through the cubic lattice are directly visible on the spheres. Smoothing them is the main subject of this chapter.
Smoothing with Hermite Interpolation
For a point
This makes the values continuous, but as the original notes, interpolating values at lattice points still forces the centers and corners of the cells to be extrema, leaving the lattice pattern visible. The clever part of true Perlin noise is to place a random vector
Perlin::noise implements this true form.
impl Perlin {
pub fn noise(&self, p: Point3) -> f64 {
let u = p.x() - p.x().floor();
let v = p.y() - p.y().floor();
let w = p.z() - p.z().floor();
let i = p.x().floor() as i32;
let j = p.y().floor() as i32;
let k = p.z().floor() as i32;
let mut c = [[[Vec3::new(0.0, 0.0, 0.0); 2]; 2]; 2];
for di in 0..2 {
for dj in 0..2 {
for dk in 0..2 {
let idx = self.perm_x[((i + di as i32) & 255) as usize]
^ self.perm_y[((j + dj as i32) & 255) as usize]
^ self.perm_z[((k + dk as i32) & 255) as usize];
c[di][dj][dk] = self.ranvec[idx];
}
}
}
perlin_interp(&c, u, v, w)
}
}
fn perlin_interp(c: &[[[Vec3; 2]; 2]; 2], u: f64, v: f64, w: f64) -> f64 {
let uu = u * u * (3.0 - 2.0 * u);
let vv = v * v * (3.0 - 2.0 * v);
let ww = w * w * (3.0 - 2.0 * w);
let mut accum = 0.0_f64;
for i in 0..2 {
for j in 0..2 {
for k in 0..2 {
let weight_v = Vec3::new(u - i as f64, v - j as f64, w - k as f64);
let fi = i as f64;
let fj = j as f64;
let fk = k as f64;
accum += (fi * uu + (1.0 - fi) * (1.0 - uu))
* (fj * vv + (1.0 - fj) * (1.0 - vv))
* (fk * ww + (1.0 - fk) * (1.0 - ww))
* dot(c[i][j][k], weight_v);
}
}
}
accum
}uu, vv, and ww are interpolation coefficients after applying the fade function dot(c[i][j][k], weight_v) is the dot product between the random vector at the lattice point and its displacement vector to the query point. The resulting range is approximately Solid mode of NoiseTexture::value maps it to
Differences Between C++ and Rust
The original C++ allocates c as the local array vec3 c[2][2][2] and defines the interpolation function perlin_interp as a static member. Rust can declare the same structure with the nested array type [[[Vec3; 2]; 2]; 2] and initialize it in one expression because Vec3 is Copy. perlin_interp is a free function because it does not require any Perlin state. Ownership and borrowing require only passing a reference of type &[[[Vec3; 2]; 2]; 2].
Increasing the input scale raises the frequency and makes the pattern finer. NoiseTexture::new(NoiseMode::Solid, 4.0) calculates sp = p * 4.0 internally before calling noise(sp), which is equivalent to the section in the original that introduces a scale parameter and multiplies the input by four.
Demo 2: r205-perlin-smooth
The scene uses the same two_perlin_spheres function as r205-perlin-hash, with only the texture replaced by NoiseTexture::new(NoiseMode::Solid, 4.0).
let pertext: Arc<dyn Texture> = Arc::new(NoiseTexture::new(NoiseMode::Solid, 4.0));The blocks disappear, leaving undulating contour lines with the characteristic appearance of noise.
Turbulence
Superimposing Perlin noise at multiple frequencies produces a self-similar turbulent pattern. The original uses an octave count of depth = 7, doubling the frequency and halving the amplitude at each layer before taking the absolute value of the sum.
Taking the absolute value creates cusps where negative valleys fold back at zero, producing the jagged appearance of clouds or flames.
impl Perlin {
pub fn turb(&self, p: Point3, depth: i32) -> f64 {
let mut accum = 0.0_f64;
let mut temp_p = p;
let mut weight = 1.0_f64;
for _ in 0..depth {
accum += weight * self.noise(temp_p);
weight *= 0.5;
temp_p = temp_p * 2.0;
}
accum.abs()
}
}Demo 3: r205-perlin-turb
NoiseMode::Turbulence uses turb(sp, 7) directly as its gray value, which remains approximately within
Black streaks in the valleys are characteristic of turbulence. Used directly as a texture, the result resembles glowing embers. For many natural materials, however, turbulence is used to perturb the argument of another function rather than being displayed directly, as the original explains in the next section.
Marble: Perturbing the Phase of a Sine Wave
The final stage is a marble-like texture that Perlin himself presents in his tutorial. Begin with stripes generated by
The stripe frequency is determined by scale * z, or sp.z(), while the 10 * turb(p) term pushes the stripes into undulating shapes. Increasing the coefficient 10 makes the stripes more irregular; bringing it closer to zero restores parallel stripes.
NoiseMode::Marble => 0.5 * (1.0 + (sp.z() + 10.0 * self.noise.turb(p, 7)).sin()),Notice that sp = p * scale is passed to the stripe function while the unscaled p is passed to turb for perturbation. Scaling the turbulence frequency would merely shrink its lattice and make the undulations finer, reducing the effect, so the perturbation remains at world scale.
Demo 4: r205-perlin-marble
This is the demo already shown at the top of the page. It is generated with NoiseMode::Marble, 4.0. Because sin ranges over
let pertext: Arc<dyn Texture> = Arc::new(NoiseTexture::new(NoiseMode::Marble, 4.0));The stripes run along the
Summary
- Added the
Perlintype tocommon/src/perlin.rswith three methods:noise_hash(p): block noise based on three-axis permutation XOR and scalar random valuesnoise(p): true Perlin noise using Hermite interpolation of dot products with random vectors at lattice pointsturb(p, depth): turbulence formed by adding octaves with doubled frequency and halved amplitude, followed by an absolute value
- Added the
NoiseModeenum andNoiseTexturetocommon/src/texture.rs.scalecontrols frequency, and one type provides four modes:Hash,Solid,Turbulence, andMarble. - Added four demo crates,
r205-perlin-{hash,smooth,turb,marble}, for comparing the modes on thetwo_perlin_spheresscene.
The available tools for calculating color as a function of world-space position have now expanded considerably. The next chapter introduces ImageTexture for loading an external image as a texture and proceeds to UV-mapped representations such as a globe.