Skip to content
Copied!
published on 2026-09-08

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 crateModeCorresponding image in the original
r205-perlin-hashHashImage 7 (block noise)
r205-perlin-smoothSolidImage 11 (after smoothing)
r205-perlin-turbTurbulenceImage 12 (turbulence)
r205-perlin-marbleMarbleImage 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 (i,j,k), define

noise(i,j,k)=R[Px[i]⊕Py[j]⊕Pz[k]]

where R is an array of 256 random values in [0,1), and Px, Py, and Pz are independent permutations of {0,1,…,255}. The XOR operation ⊕ mixes the permutations for the three axes. For a continuous input p=(x,y,z), use (⌊4x⌋,⌊4y⌋,⌊4z⌋)mod256 as the lattice indices.

Create common/src/perlin.rs and begin with the lattice tables and noise_hash.

common/src/perlin.rs
rust
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.

common/src/perlin.rs
rust
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.

common/src/texture.rs
rust
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 (g,g,g) and is used as the albedo of a Lambertian material. Each mode maps its values into [0,1]. The raw noise value used by Solid is in [−1,1], so that mode normalizes it with 0.5(1+⋅).

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.

r205-perlin-hash/src/lib.rs
rust
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 p inside a lattice cell, linearly interpolating the values at the eight surrounding lattice points (i+di,j+dj,k+dk), where di,dj,dk∈{0,1}, removes the blocks. With pure trilinear interpolation, however, the first derivative is discontinuous at lattice boundaries, producing visible edges known as Mach bands. I therefore smooth the interpolation coefficients u, v, and w with the Hermite fade function h(t)=t2(3−2t).

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 rijk, rather than a random scalar, at each lattice point. The interpolated value is the dot product of rijk and the displacement vector from the lattice point to the query point, wijk=p−(i,j,k). This dot product becomes zero at every lattice point, so the extrema are no longer fixed to the lattice.

Perlin::noise implements this true form.

common/src/perlin.rs
rust
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 h. They are expanded into the trilinear weights wijk=(i⋅u+(1−i)(1−u)) and so on. 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 [−1,1]; the Solid mode of NoiseTexture::value maps it to [0,1] with 0.5(1+⋅).

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).

r205-perlin-smooth/src/lib.rs
rust
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.

turb(p)=|∑n=0depth−112nnoise(2np)|

Taking the absolute value creates cusps where negative valleys fold back at zero, producing the jagged appearance of clouds or flames.

common/src/perlin.rs
rust
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 [0,1].

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 sin⁡(z), then perturb their phase with turbulence.

marble(p)=12(1+sin⁡(scale⋅z+10⋅turb(p)))

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.

common/src/texture.rs
rust
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 [−1,1], 0.5(1+⋅) normalizes the result to [0,1].

r205-perlin-marble/src/lib.rs
rust
let pertext: Arc<dyn Texture> = Arc::new(NoiseTexture::new(NoiseMode::Marble, 4.0));

The stripes run along the z axis and resemble lines of latitude on the sphere. Turbulence shifts the phase locally, creating irregular veins reminiscent of real marble.

Summary ​

  • Added the Perlin type to common/src/perlin.rs with three methods:
    • noise_hash(p): block noise based on three-axis permutation XOR and scalar random values
    • noise(p): true Perlin noise using Hermite interpolation of dot products with random vectors at lattice points
    • turb(p, depth): turbulence formed by adding octaves with doubled frequency and halved amplitude, followed by an absolute value
  • Added the NoiseMode enum and NoiseTexture to common/src/texture.rs. scale controls frequency, and one type provides four modes: Hash, Solid, Turbulence, and Marble.
  • Added four demo crates, r205-perlin-{hash,smooth,turb,marble}, for comparing the modes on the two_perlin_spheres scene.

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.