How to Implement Fractional Brownian Motion in GLSL

Fractional Brownian Motion (fBm) is a procedural technique used in computer graphics to generate complex, natural-looking patterns such as terrain, clouds, and turbulent fluids. In GLSL, fBm is implemented by running a loop that accumulates multiple layers—or octaves—of a base noise function, scaling the frequency up and the amplitude down with each successive iteration. This article details the mathematical principles behind fBm, provides a modular GLSL implementation, and explores techniques to optimize and customize the resulting procedural textures.

The Concept of Octave Accumulation

Natural phenomena exhibit self-similarity across different scales: large structures define the broad silhouette, while progressively smaller structures add micro-detail. Fractional Brownian Motion models this behavior by summing scaled evaluations of a base noise function (such as Perlin, Simplex, or Value noise).

The accumulation relies on four primary parameters:

  • Octaves: The total number of noise layers computed. Higher values yield more fine detail at the cost of performance.
  • Amplitude: The weight or intensity of each octave.
  • Frequency: The spatial density or scale of the noise features.
  • Lacunarity: The factor by which the frequency increases each octave (commonly set to 2.0).
  • Gain (or Persistence): The factor by which the amplitude decreases each octave (commonly set to 0.5).

Mathematically, the fBm function \(f(\mathbf{x})\) can be defined as:

\[f(\mathbf{x}) = \sum_{i=0}^{N-1} A \cdot g^i \cdot \text{noise}(\mathbf{x} \cdot f_0 \cdot l^i)\]

where \(N\) is the number of octaves, \(g\) is the gain, \(l\) is the lacunarity, \(A\) is the initial amplitude, and \(f_0\) is the initial frequency.

GLSL Implementation

To implement fBm in a GLSL fragment shader, you begin with a base 2D or 3D noise function and iteratively evaluate it within a loop.

1. The Base Noise Function

A standard 2D pseudo-random gradient or value noise function acts as the fundamental building block:

// Basic pseudo-random hash
float hash(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// 2D Value Noise
float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);

    // Quintic interpolation curve
    vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);

    float a = hash(i);
    float b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0));
    float d = hash(i + vec2(1.0, 1.0));

    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

2. The Accumulation Loop

The fbm function accumulates the base noise across a set number of octaves:

#define NUM_OCTAVES 6

float fbm(vec2 st) {
    float value = 0.0;
    float amplitude = 0.5;
    float frequency = 1.0;
    float lacunarity = 2.0;
    float gain = 0.5;

    for (int i = 0; i < NUM_OCTAVES; i++) {
        value += amplitude * noise(st * frequency);
        frequency *= lacunarity;
        amplitude *= gain;
    }

    return value;
}

Eliminating Directional Artifacts with Rotation

Evaluating standard grid-aligned noise repeatedly with a lacunarity of 2.0 can cause visible grid artifacts or directional bias. A standard optimization involves applying a rotation matrix and a spatial shift to the sample coordinate between octaves.

float fbmRotated(vec2 st) {
    float value = 0.0;
    float amplitude = 0.5;
    
    // 2D rotation matrix (~27.5 degrees)
    mat2 rot = mat2(cos(0.5), sin(0.5), 
                   -sin(0.5), cos(0.5));

    for (int i = 0; i < NUM_OCTAVES; i++) {
        value += amplitude * noise(st);
        st = rot * st * 2.0 + vec2(100.0); // Rotate, scale, and shift
        amplitude *= 0.5;
    }

    return value;
}

This transforms the coordinate space non-linearly across layers, breaking up repetitive patterns and generating more organic results.

Normalization and Dynamic Range

When summing a geometric series where \(gain = 0.5\), the maximum theoretical sum converges to:

\[\sum_{i=0}^{\infty} (0.5)^{i+1} = 1.0\]

If the initial amplitude is \(0.5\) and the gain is \(0.5\), the output will naturally stay within the \([0.0, 1.0]\) range as long as the base noise outputs values in that range. If your base noise function returns values in the \([-1.0, 1.0]\) range (standard for gradient and simplex noise), you can normalize the final result after accumulation:

float normalizedFbm = fbm(st) * 0.5 + 0.5;

Variations: Turbulence and Ridged Multifractal

The accumulated value can be modified during iteration to produce distinct material characteristics:

  • Turbulence: Taking the absolute value of the noise (abs(noise(st))) creates sharp creases resembling fire or marble veins.
  • Ridged Noise: Inverting the absolute noise (1.0 - abs(noise(st))) produces sharp ridges, which are useful for realistic mountain ranges.