2D Value Noise in GLSL with Bicubic Interpolation

This article demonstrates how to implement two-dimensional value noise in GLSL by pairing coordinate hashing with bicubic interpolation. Value noise assigns pseudo-random scalar values to a regular integer grid and smoothly interpolates between them across continuous space. By using a hash function to deterministically generate grid values and applying a bicubic curve to the fractional coordinates, the resulting noise achieves continuous first-order derivatives (\(C^1\) continuity) without visible grid artifacts.

The Mathematical Foundation

2D value noise operates over continuous coordinates \(\mathbf{p} = (x, y)\). The evaluation process divides \(\mathbf{p}\) into:

  1. Integer Lattice Coordinates: \(\mathbf{i} = \lfloor\mathbf{p}\rfloor\), representing the four corners of the grid cell: \((i_x, i_y)\), \((i_x + 1, i_y)\), \((i_x, i_y + 1)\), and \((i_x + 1, i_y + 1)\).
  2. Fractional Coordinates: \(\mathbf{f} = \text{fract}(\mathbf{p}) = \mathbf{p} - \mathbf{i}\), representing the relative offset inside the cell, where \(\mathbf{f} \in [0, 1)^2\).

Linear interpolation across grid corners yields visible creasing along cell borders due to discontinuous first derivatives. Bicubic interpolation resolves this by passing the fractional coordinates through a cubic polynomial whose derivative is zero at both \(0\) and \(1\).

Step 1: Coordinate Hashing

A deterministic hash function maps 2D integer coordinates to a pseudo-random scalar value in the range \([0, 1]\). In GLSL, high-frequency trigonometric functions or bitwise operations are commonly used.

// Deterministic 2D-to-1D pseudo-random hash
float hash(vec2 p) {
    // Dot product with arbitrary large prime-like constants
    float h = dot(p, vec2(127.1, 311.7));
    return fract(sin(h) * 43758.5453123);
}

This function ensures that any specific integer coordinate \((x, y)\) always produces the same scalar value without requiring memory lookups or texture sampling.

Step 2: Bicubic Hermite Interpolation

Bicubic interpolation across a 4-point unit square is achieved by evaluating the tensor product of two 1D cubic Hermite curves. The standard cubic S-curve (the standard GLSL smoothstep formulation) is defined as:

\[S(t) = 3t^2 - 2t^3\]

When applied to both components of \(\mathbf{f}\), the derivatives at cell boundaries (\(t = 0\) and \(t = 1\)) equal zero:

\[\frac{d}{dt}(3t^2 - 2t^3) = 6t - 6t^2 \implies S'(0) = 0, \quad S'(1) = 0\]

Because the derivatives match across boundaries, neighboring cells blend seamlessly without directional seams.

// Cubic Hermite curve applied to both X and Y components
vec2 u = f * f * (3.0 - 2.0 * f);

Step 3: Complete GLSL Implementation

The full noise function computes the four corner hashes and performs a bilinear blend driven by the cubic interpolation weights:

#ifdef GL_ES
precision highp float;
#endif

// Hash function mapping integer lattice positions to [0.0, 1.0]
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 with Bicubic Interpolation
float valueNoise(vec2 p) {
    // 1. Grid cell base coordinate and internal fractional offset
    vec2 i = floor(p);
    vec2 f = fract(p);

    // 2. Sample random values at the four unit-cell corners
    float a = hash(i + vec2(0.0, 0.0));
    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));

    // 3. Compute bicubic Hermite weights: 3f^2 - 2f^3
    vec2 u = f * f * (3.0 - 2.0 * f);

    // 4. Interpolate horizontally, then vertically
    return mix(
        mix(a, b, u.x),
        mix(c, d, u.x),
        u.y
    );
}

Alternative: 16-Point Bicubic Spline (Catmull-Rom)

If continuous second derivatives (\(C^2\)) or non-zero boundary slopes are required, evaluate a \(4\times 4\) neighborhood using a Catmull-Rom or B-spline filter:

// 1D cubic interpolation kernel across 4 samples
float cubic(float v0, float v1, float v2, float v3, float x) {
    float p = (v3 - v2) - (v0 - v1);
    float q = (v0 - v1) - p;
    float r = v2 - v0;
    float s = v1;
    return p * x * x * x + q * x * x + r * x + s;
}

// 16-sample bicubic value noise
float valueNoiseBicubic4x4(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);

    float rows[4];
    for (int y = -1; y <= 2; y++) {
        float p0 = hash(i + vec2(-1.0, float(y)));
        float p1 = hash(i + vec2( 0.0, float(y)));
        float p2 = hash(i + vec2( 1.0, float(y)));
        float p3 = hash(i + vec2( 2.0, float(y)));
        rows[y + 1] = cubic(p0, p1, p2, p3, f.x);
    }

    return cubic(rows[0], rows[1], rows[2], rows[3], f.y);
}

The 4-sample Hermite method is computationally efficient and suitable for real-time fragment shaders, while the 16-sample spline ensures higher curve fidelity at the cost of additional hash evaluations.