Files
skills/shader-dev/techniques/voronoi-cellular-noise.md
shihao 6487becf60 Initial commit: add all skills files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:52:49 +08:00

13 KiB

  • IMPORTANT: All declared uniform variables must be used in the shader code, otherwise the compiler will optimize them away. After optimization, gl.getUniformLocation() returns null, and setting that uniform triggers a WebGL INVALID_OPERATION error, which may cause rendering failure. Ensure uniforms like iTime are actually used in main() (e.g., float t = iTime * 1.0;)

Voronoi & Cellular Noise

Use Cases

  • Natural textures: cells, cracked soil, stone, skin pores
  • Structured patterns: crystals, honeycombs, shattered glass, mosaics
  • Effects: fire/nebula (fBm stacking), crack generation
  • Procedural materials: cloud noise, terrain height maps, stylized partitioning

Core Principles

Voronoi noise = spatial partitioning: scatter feature points, assign each pixel to the "cell" of its nearest feature point.

Algorithm flow:

  1. floor divides into an integer grid; each cell contains a randomly offset feature point
  2. Search the 3x3 (2D) or 3x3x3 (3D) neighborhood for all feature points
  3. Record the nearest distance F1 (optionally second-nearest F2)
  4. Map F1, F2, or F2-F1 to color/height/shape

Distance metrics:

  • Euclidean: dot(r,r) (squared, fast) -> final sqrt
  • Manhattan: abs(r.x)+abs(r.y)
  • Chebyshev: max(abs(r.x), abs(r.y))

Exact border distance (two-pass algorithm): dot(0.5*(mr+r), normalize(r-mr)) Rounded borders (harmonic mean): 1/(1/(d2-d1) + 1/(d3-d1))

Implementation Steps

Step 1: Hash Functions

// sin-dot hash (suitable for most cases)
vec2 hash2(vec2 p) {
    p = vec2(dot(p, vec2(127.1, 311.7)),
             dot(p, vec2(269.5, 183.3)));
    return fract(sin(p) * 43758.5453);
}

// 3D version
vec3 hash3(vec3 p) {
    float n = sin(dot(p, vec3(7.0, 157.0, 113.0)));
    return fract(vec3(2097152.0, 262144.0, 32768.0) * n);
}

// High-quality integer hash (ES 3.0+, more uniform)
vec3 hash3_uint(vec3 p) {
    uvec3 q = uvec3(ivec3(p)) * uvec3(1597334673U, 3812015801U, 2798796415U);
    q = (q.x ^ q.y ^ q.z) * uvec3(1597334673U, 3812015801U, 2798796415U);
    return vec3(q) / float(0xffffffffU);
}

Step 2: Basic F1 Voronoi

// Returns (F1 distance, cell ID)
vec2 voronoi(vec2 x) {
    vec2 n = floor(x);
    vec2 f = fract(x);
    vec3 m = vec3(8.0);

    for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++) {
        vec2 g = vec2(float(i), float(j));
        vec2 o = hash2(n + g);
        vec2 r = g - f + o;
        float d = dot(r, r);
        if (d < m.x) {
            m = vec3(d, o);
        }
    }
    return vec2(sqrt(m.x), m.y + m.z);
}

Step 3: F1 + F2 (Edge Detection)

// Returns vec2(F1, F2), edge value = F2 - F1
vec2 voronoi_f1f2(vec2 x) {
    vec2 p = floor(x);
    vec2 f = fract(x);
    vec2 res = vec2(8.0);

    for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++) {
        vec2 b = vec2(i, j);
        vec2 r = b - f + hash2(p + b);
        float d = dot(r, r);
        if (d < res.x) {
            res.y = res.x;
            res.x = d;
        } else if (d < res.y) {
            res.y = d;
        }
    }
    return sqrt(res);
}

Step 4: Exact Border Distance (Two-Pass Algorithm)

// Returns vec3(border distance, nearest point offset)
vec3 voronoi_border(vec2 x) {
    vec2 ip = floor(x);
    vec2 fp = fract(x);

    // First pass: find nearest feature point
    vec2 mg, mr;
    float md = 8.0;
    for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++) {
        vec2 g = vec2(float(i), float(j));
        vec2 o = hash2(ip + g);
        vec2 r = g + o - fp;
        float d = dot(r, r);
        if (d < md) { md = d; mr = r; mg = g; }
    }

    // Second pass: exact border distance (5x5 range)
    md = 8.0;
    for (int j = -2; j <= 2; j++)
    for (int i = -2; i <= 2; i++) {
        vec2 g = mg + vec2(float(i), float(j));
        vec2 o = hash2(ip + g);
        vec2 r = g + o - fp;
        if (dot(mr - r, mr - r) > 0.00001)
            md = min(md, dot(0.5 * (mr + r), normalize(r - mr)));
    }
    return vec3(md, mr);
}

Step 5: Feature Point Animation

// Replace static hash inside the neighborhood search loop:
vec2 o = hash2(n + g);
o = 0.5 + 0.5 * sin(iTime + 6.2831 * o); // different phase per point
vec2 r = g - f + o;

Step 6: Coloring & Visualization

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    // Must use iTime, otherwise the compiler will optimize away the uniform
    float time = iTime * 1.0;
    vec2 p = fragCoord.xy / iResolution.xy;
    vec2 uv = p * SCALE;

    vec2 c = voronoi(uv);
    float dist = c.x;
    float id   = c.y;

    // Cell coloring (ID-driven palette)
    vec3 col = 0.5 + 0.5 * cos(id * 6.2831 + vec3(0.0, 1.0, 2.0));
    // Distance falloff
    col *= clamp(1.0 - 0.4 * dist * dist, 0.0, 1.0);
    // Border lines
    col -= (1.0 - smoothstep(0.08, 0.09, dist));

    fragColor = vec4(col, 1.0);
}

Complete Code Template

// === Voronoi Cellular Noise — Complete ShaderToy Template ===
// Supports F1/F2/F2-F1 modes, multiple distance metrics, animation, exact borders

#define SCALE 8.0            // Cell density
#define ANIMATE 1            // 0=static, 1=animated
#define MODE 0               // 0=F1 fill, 1=F2-F1 edges, 2=exact borders
#define DIST_METRIC 0        // 0=Euclidean, 1=Manhattan, 2=Chebyshev

vec2 hash2(vec2 p) {
    p = vec2(dot(p, vec2(127.1, 311.7)),
             dot(p, vec2(269.5, 183.3)));
    return fract(sin(p) * 43758.5453);
}

float distFunc(vec2 r) {
    #if DIST_METRIC == 0
        return dot(r, r);
    #elif DIST_METRIC == 1
        return abs(r.x) + abs(r.y);
    #elif DIST_METRIC == 2
        return max(abs(r.x), abs(r.y));
    #endif
}

vec2 getPoint(vec2 cellId) {
    vec2 o = hash2(cellId);
    #if ANIMATE
        o = 0.5 + 0.5 * sin(iTime + 6.2831 * o);
    #endif
    return o;
}

vec4 voronoi(vec2 x) {
    vec2 n = floor(x);
    vec2 f = fract(x);
    float d1 = 8.0, d2 = 8.0;
    vec2 nearestCell = vec2(0.0);

    for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++) {
        vec2 g = vec2(float(i), float(j));
        vec2 o = getPoint(n + g);
        vec2 r = g - f + o;
        float d = distFunc(r);
        if (d < d1) {
            d2 = d1; d1 = d;
            nearestCell = n + g;
        } else if (d < d2) {
            d2 = d;
        }
    }

    #if DIST_METRIC == 0
        d1 = sqrt(d1); d2 = sqrt(d2);
    #endif
    return vec4(d1, d2, nearestCell);
}

vec3 voronoiBorder(vec2 x) {
    vec2 ip = floor(x);
    vec2 fp = fract(x);

    vec2 mg, mr;
    float md = 8.0;
    for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++) {
        vec2 g = vec2(float(i), float(j));
        vec2 o = getPoint(ip + g);
        vec2 r = g + o - fp;
        float d = dot(r, r);
        if (d < md) { md = d; mr = r; mg = g; }
    }

    md = 8.0;
    for (int j = -2; j <= 2; j++)
    for (int i = -2; i <= 2; i++) {
        vec2 g = mg + vec2(float(i), float(j));
        vec2 o = getPoint(ip + g);
        vec2 r = g + o - fp;
        if (dot(mr - r, mr - r) > 0.00001)
            md = min(md, dot(0.5 * (mr + r), normalize(r - mr)));
    }
    return vec3(md, mr);
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    // Must use iTime, otherwise the compiler will optimize away the uniform (especially important when ANIMATE=1)
    float time = iTime * 1.0;
    vec2 p = fragCoord.xy / iResolution.xy;
    p.x *= iResolution.x / iResolution.y;
    vec2 uv = p * SCALE;
    vec3 col = vec3(0.0);

    #if MODE == 0
        vec4 v = voronoi(uv);
        float id = dot(v.zw, vec2(127.1, 311.7));
        col = 0.5 + 0.5 * cos(id * 6.2831 + vec3(0.0, 1.0, 2.0));
        col *= clamp(1.0 - 0.4 * v.x * v.x, 0.0, 1.0);
        col -= (1.0 - smoothstep(0.08, 0.09, v.x));
    #elif MODE == 1
        vec4 v = voronoi(uv);
        float edge = v.y - v.x;
        col = vec3(1.0 - smoothstep(0.0, 0.15, edge));
        col *= vec3(0.2, 0.6, 1.0);
    #elif MODE == 2
        vec3 c = voronoiBorder(uv);
        col = c.x * (0.5 + 0.5 * sin(64.0 * c.x)) * vec3(1.0);
        col = mix(vec3(1.0, 0.6, 0.0), col, smoothstep(0.04, 0.07, c.x));
        float dd = length(c.yz);
        col = mix(vec3(1.0, 0.6, 0.1), col, smoothstep(0.0, 0.12, dd));
    #endif

    fragColor = vec4(col, 1.0);
}

Common Variants

Variant 1: 3D Voronoi + fBm Fire

#define NUM_OCTAVES 5

vec3 hash3(vec3 p) {
    float n = sin(dot(p, vec3(7.0, 157.0, 113.0)));
    return fract(vec3(2097152.0, 262144.0, 32768.0) * n);
}

float voronoi3D(vec3 p) {
    vec3 g = floor(p); p = fract(p);
    float d = 1.0;
    for (int j = -1; j <= 1; j++)
    for (int i = -1; i <= 1; i++)
    for (int k = -1; k <= 1; k++) {
        vec3 b = vec3(i, j, k);
        vec3 r = b - p + hash3(g + b);
        d = min(d, dot(r, r));
    }
    return d;
}

float fbmVoronoi(vec3 p) {
    vec3 t = vec3(0.0, 0.0, p.z + iTime * 1.5);
    float tot = 0.0, sum = 0.0, amp = 1.0;
    for (int i = 0; i < NUM_OCTAVES; i++) {
        tot += voronoi3D(p + t) * amp;
        p *= 2.0; t *= 1.5;
        sum += amp; amp *= 0.5;
    }
    return tot / sum;
}

// Blackbody radiation palette
vec3 firePalette(float i) {
    float T = 1400.0 + 1300.0 * i;
    vec3 L = vec3(7.4, 5.6, 4.4);
    L = pow(L, vec3(5.0)) * (exp(1.43876719683e5 / (T * L)) - 1.0);
    return 1.0 - exp(-5e8 / L);
}

Variant 2: Rounded Borders (3rd-Order Voronoi)

float voronoiRounded(vec2 p) {
    vec2 g = floor(p); p -= g;
    vec3 d = vec3(1.0); // F1, F2, F3

    for (int y = -1; y <= 1; y++)
    for (int x = -1; x <= 1; x++) {
        vec2 o = vec2(x, y);
        o += hash2(g + o) - p;
        float r = dot(o, o);
        d.z = max(d.x, max(d.y, min(d.z, r)));
        d.y = max(d.x, min(d.y, r));
        d.x = min(d.x, r);
    }
    d = sqrt(d);
    return min(2.0 / (1.0 / max(d.y - d.x, 0.001)
                    + 1.0 / max(d.z - d.x, 0.001)), 1.0);
}

Variant 3: Voronoise (Unified Noise-Voronoi Framework)

#define JITTER 1.0    // 0=regular grid, 1=fully random
#define SMOOTH 0.0    // 0=sharp Voronoi, 1=smooth noise

vec3 hash3(vec2 p) {
    vec3 q = vec3(dot(p, vec2(127.1, 311.7)),
                  dot(p, vec2(269.5, 183.3)),
                  dot(p, vec2(419.2, 371.9)));
    return fract(sin(q) * 43758.5453);
}

float voronoise(vec2 p, float u, float v) {
    float k = 1.0 + 63.0 * pow(1.0 - v, 6.0);
    vec2 i = floor(p); vec2 f = fract(p);
    vec2 a = vec2(0.0);
    for (int y = -2; y <= 2; y++)
    for (int x = -2; x <= 2; x++) {
        vec2 g = vec2(x, y);
        vec3 o = hash3(i + g) * vec3(u, u, 1.0);
        vec2 d = g - f + o.xy;
        float w = pow(1.0 - smoothstep(0.0, 1.414, length(d)), k);
        a += vec2(o.z * w, w);
    }
    return a.x / a.y;
}

Variant 4: Crack Texture (Multi-Layer Recursive Voronoi)

#define CRACK_DEPTH 3.0
#define CRACK_WIDTH 0.0
#define CRACK_SLOPE 50.0

float ofs = 0.5;
#define disp(p) (-ofs + (1.0 + 2.0 * ofs) * hash2(p))

// Main loop
vec4 O = vec4(0.0);
vec2 U = uv;
for (float i = 0.0; i < CRACK_DEPTH; i++) {
    vec2 D = fbm22(U) * 0.67;
    vec3 H = voronoiBorder(U + D);
    float d = H.x;
    d = min(1.0, CRACK_SLOPE * pow(max(0.0, d - CRACK_WIDTH), 1.0));
    O += vec4(1.0 - d) / exp2(i);
    U *= 1.5 * rot(0.37);
}

Variant 5: Tileable 3D Worley (Cloud Noise)

#define TILE_FREQ 4.0

float worleyTileable(vec3 uv, float freq) {
    vec3 id = floor(uv); vec3 p = fract(uv);
    float minDist = 1e4;
    for (float x = -1.0; x <= 1.0; x++)
    for (float y = -1.0; y <= 1.0; y++)
    for (float z = -1.0; z <= 1.0; z++) {
        vec3 offset = vec3(x, y, z);
        vec3 h = hash3_uint(mod(id + offset, vec3(freq))) * 0.5 + 0.5;
        h += offset;
        vec3 d = p - h;
        minDist = min(minDist, dot(d, d));
    }
    return 1.0 - minDist;
}

float worleyFbm(vec3 p, float freq) {
    return worleyTileable(p * freq, freq) * 0.625
         + worleyTileable(p * freq * 2.0, freq * 2.0) * 0.25
         + worleyTileable(p * freq * 4.0, freq * 4.0) * 0.125;
}

float remap(float x, float a, float b, float c, float d) {
    return (((x - a) / (b - a)) * (d - c)) + c;
}
// cloud = remap(perlinNoise, worleyFbm - 1.0, 1.0, 0.0, 1.0);

Performance & Composition

Performance:

  • Use dot(r,r) instead of length during comparison; only sqrt for final output
  • 3D loops can be manually unrolled along the z-axis to reduce nesting
  • Search range: basic F1 uses 3x3; exact borders/Voronoise/extended jitter uses 5x5
  • Hash choice: sin(dot(...)) is fastest; integer hash is more uniform but requires ES 3.0+
  • fBm layers: 3 is sufficient, 5 is the upper limit

Combinations:

  • +fBm distortion: uv + 0.5*fbm22(uv*2.0) -> organic cell shapes
  • +Bump Mapping: finite-difference normal computation -> pseudo-3D bumps
  • +Palette: 0.5+0.5*cos(6.2831*(t+vec3(0,0.33,0.67))) -> rich colors
  • +Raymarching: Voronoi distance as part of the SDF -> cellular surfaces
  • +Multi-scale stacking: Voronoi at different frequencies stacked -> primary structure + fine detail

Further Reading

For complete step-by-step tutorials, mathematical derivations, and advanced usage, see reference