Files
skills/shader-dev/techniques/terrain-rendering.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

14 KiB
Raw Blame History

Heightfield Ray Marching Terrain Rendering

Use Cases

  • Procedural generation of natural landscapes (mountains, canyons, dunes, etc.) in ShaderToy / Fragment Shaders
  • Complete 3D terrain flythrough scenes in a single pixel shader, without geometry
  • Cinematic aerial perspective, soft shadows, and layered material effects

Core Principles

Rendering pipeline: height field definition → ray marching intersection → normals & materials → lighting → atmospheric effects

  • FBM: f(p) = Σ (aⁿ × noise(2ⁿ × R × p)), a=0.5, R=rotation matrix, 2ⁿ=frequency doubling
  • Derivative erosion: f(p) = Σ (aⁿ × noise(p) / (1 + dot(d,d))), d=accumulated gradient, suppresses detail on steep slopes
  • Adaptive step size: step = factor × (ray.y - terrain_height)

Implementation Steps

  1. Noise & hash — sin-free hash + Value Noise with analytic derivatives (noised returns value + partial derivatives)
  2. FBM terrain — derivative erosion FBM, mat2(0.8,-0.6,0.6,0.8) per-layer rotation to eliminate banding; LOD tiers (L=3/M=9/H=16 octaves)
  3. Ray marching — upper bound clipping + adaptive step STEP_FACTOR * h + distance-adaptive precision abs(h) < 0.0015*t
  4. Normals — finite differences, epsilon increases with distance to avoid distant aliasing, using high-precision terrainH
  5. Soft shadows — march toward sun, track min(k*h/t) to estimate penumbra
  6. Materials — blend rock/grass/snow/sand by height + slope + noise
  7. Lighting — Lambert diffuse + hemisphere ambient + backlight + Fresnel rim light + Blinn-Phong specular
  8. Atmospheric fog — wavelength-dependent attenuation exp(-t*k*vec3(1,1.5,4)) + sun scatter fog color
  9. Sky — zenith-to-horizon gradient + sun disk/halo
  10. Camera — Look-At matrix + path-following flight, height tracks terrain

Complete Code Template

// =====================================================
// Heightfield Terrain Rendering - Complete Template
// =====================================================
#define TERRAIN_OCTAVES 9     // FBM octave count (3~16)
#define TERRAIN_SCALE 0.003   // Terrain spatial frequency
#define TERRAIN_HEIGHT 120.0  // Terrain elevation scale
#define MAX_STEPS 300         // Ray march step count (80~400)
#define MAX_DIST 5000.0       // Maximum render distance
#define STEP_FACTOR 0.4       // March conservative factor (0.3~0.8)
#define SHADOW_STEPS 80       // Shadow step count (32~128)
#define SHADOW_K 16.0         // Penumbra softness (8~64)
#define FOG_DENSITY 0.00025   // Fog density
#define SNOW_HEIGHT 80.0      // Snow line height
#define CAM_ALTITUDE 20.0     // Camera height above ground
#define SUN_DIR normalize(vec3(0.8, 0.4, -0.6))
#define SUN_COL vec3(8.0, 5.0, 3.0)
#define SKY_COL vec3(0.5, 0.7, 1.0)

// ---- Hash & Noise ----
float hash(vec2 p) {
    vec3 p3 = fract(vec3(p.xyx) * 0.1031);
    p3 += dot(p3, p3.yzx + 19.19);
    return fract((p3.x + p3.y) * p3.z);
}

vec3 noised(in vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    vec2 u  = f * f * (3.0 - 2.0 * f);
    vec2 du = 6.0 * f * (1.0 - f);
    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));
    float v = a + (b - a) * u.x + (c - a) * u.y + (a - b - c + d) * u.x * u.y;
    vec2  g = du * (vec2(b - a, c - a) + (a - b - c + d) * u.yx);
    return vec3(v, g);
}

float noise(in vec2 p) { return noised(p).x; }

// ---- FBM Terrain (derivative erosion) + LOD ----
const mat2 m2 = mat2(0.8, -0.6, 0.6, 0.8);

float terrainFBM(in vec2 p, int octaves) {
    p *= TERRAIN_SCALE;
    float a = 0.0, b = 1.0;
    vec2  d = vec2(0.0);
    for (int i = 0; i < 16; i++) {
        if (i >= octaves) break;
        vec3 n = noised(p);
        d += n.yz;
        a += b * n.x / (1.0 + dot(d, d));
        b *= 0.5;
        p = m2 * p * 2.0;
    }
    return a * TERRAIN_HEIGHT;
}

float terrainL(vec2 p) { return terrainFBM(p, 3); }
float terrainM(vec2 p) { return terrainFBM(p, TERRAIN_OCTAVES); }
float terrainH(vec2 p) { return terrainFBM(p, 16); }

// ---- Ray Marching ----
float raymarch(in vec3 ro, in vec3 rd) {
    float t = 0.0;
    if (ro.y > TERRAIN_HEIGHT && rd.y >= 0.0) return -1.0;
    if (ro.y > TERRAIN_HEIGHT) t = (ro.y - TERRAIN_HEIGHT) / (-rd.y);
    for (int i = 0; i < MAX_STEPS; i++) {
        vec3 pos = ro + t * rd;
        float h = pos.y - terrainM(pos.xz);
        if (abs(h) < 0.0015 * t) break;
        if (t > MAX_DIST) return -1.0;
        t += STEP_FACTOR * h;
    }
    return t;
}

// ---- Normals ----
vec3 calcNormal(in vec3 pos, float t) {
    float eps = 0.02 + 0.00005 * t * t;
    float hC = terrainH(pos.xz);
    float hR = terrainH(pos.xz + vec2(eps, 0.0));
    float hU = terrainH(pos.xz + vec2(0.0, eps));
    return normalize(vec3(hC - hR, eps, hC - hU));
}

// ---- Soft Shadows ----
float calcShadow(in vec3 pos, in vec3 sunDir) {
    float res = 1.0, t = 1.0;
    for (int i = 0; i < SHADOW_STEPS; i++) {
        vec3 p = pos + t * sunDir;
        float h = p.y - terrainM(p.xz);
        if (h < 0.001) return 0.0;
        res = min(res, SHADOW_K * h / t);
        t += clamp(h, 2.0, 100.0);
    }
    return clamp(res, 0.0, 1.0);
}

// ---- Materials ----
vec3 getMaterial(in vec3 pos, in vec3 nor) {
    float slope = nor.y, h = pos.y;
    float nz = noise(pos.xz * 0.04) * noise(pos.xz * 0.005);
    vec3 rock  = vec3(0.10, 0.09, 0.08);
    vec3 grass = mix(vec3(0.10, 0.08, 0.04), vec3(0.05, 0.09, 0.02), nz);
    vec3 snow  = vec3(0.62, 0.65, 0.70);
    vec3 sand  = vec3(0.50, 0.45, 0.35);
    vec3 col = rock;
    col = mix(col, grass, smoothstep(0.5, 0.8, slope));
    float snowMask = smoothstep(SNOW_HEIGHT - 20.0 * nz, SNOW_HEIGHT + 10.0, h)
                   * smoothstep(0.3, 0.7, slope);
    col = mix(col, snow, snowMask);
    float beachMask = smoothstep(2.5, 0.0, h) * smoothstep(0.5, 0.9, slope);
    col = mix(col, sand, beachMask);
    return col;
}

// ---- Lighting ----
vec3 calcLighting(in vec3 pos, in vec3 nor, in vec3 rd, float shadow) {
    float dif = clamp(dot(nor, SUN_DIR), 0.0, 1.0);
    float amb = 0.5 + 0.5 * nor.y;
    vec3 backDir = normalize(vec3(-SUN_DIR.x, 0.0, -SUN_DIR.z));
    float bac = clamp(0.2 + 0.8 * dot(nor, backDir), 0.0, 1.0);
    float fre = pow(clamp(1.0 + dot(rd, nor), 0.0, 1.0), 2.0);
    vec3 hal = normalize(SUN_DIR - rd);
    float spe = pow(clamp(dot(nor, hal), 0.0, 1.0), 16.0)
              * (0.04 + 0.96 * pow(1.0 + dot(hal, rd), 5.0));
    vec3 lin = vec3(0.0);
    lin += dif * shadow * SUN_COL * 0.1;
    lin += amb * SKY_COL * 0.2;
    lin += bac * vec3(0.15, 0.05, 0.04);
    lin += fre * SKY_COL * 0.3;
    lin += spe * shadow * SUN_COL * 0.05;
    return lin;
}

// ---- Atmosphere ----
vec3 applyFog(in vec3 col, float t, in vec3 rd) {
    vec3 ext = exp(-t * FOG_DENSITY * vec3(1.0, 1.5, 4.0));
    float sundot = clamp(dot(rd, SUN_DIR), 0.0, 1.0);
    vec3 fogCol = mix(vec3(0.55, 0.55, 0.58), vec3(1.0, 0.7, 0.3), 0.3 * pow(sundot, 8.0));
    return col * ext + fogCol * (1.0 - ext);
}

// ---- Sky ----
vec3 getSky(in vec3 rd) {
    vec3 col = vec3(0.3, 0.5, 0.85) - rd.y * vec3(0.2, 0.15, 0.0);
    float horizon = pow(1.0 - max(rd.y, 0.0), 4.0);
    col = mix(col, vec3(0.8, 0.75, 0.7), 0.5 * horizon);
    float sundot = clamp(dot(rd, SUN_DIR), 0.0, 1.0);
    col += vec3(1.0, 0.7, 0.3) * 0.3 * pow(sundot, 8.0);
    col += vec3(1.0, 0.9, 0.7) * 0.5 * pow(sundot, 64.0);
    col += vec3(1.0, 1.0, 0.9) * min(pow(sundot, 1150.0), 0.3);
    return col;
}

// ---- Camera ----
vec3 cameraPath(float t) {
    return vec3(100.0 * sin(0.2 * t), 0.0, -100.0 * t);
}

mat3 setCamera(in vec3 ro, in vec3 ta) {
    vec3 cw = normalize(ta - ro);
    vec3 cu = normalize(cross(cw, vec3(0.0, 1.0, 0.0)));
    vec3 cv = cross(cu, cw);
    return mat3(cu, cv, cw);
}

// ======== Main Function ========
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
    float time = iTime * 0.5;
    vec3 ro = cameraPath(time);
    ro.y = terrainL(ro.xz) + CAM_ALTITUDE;
    vec3 ta = cameraPath(time + 2.0);
    ta.y = terrainL(ta.xz) + CAM_ALTITUDE * 0.5;
    mat3 cam = setCamera(ro, ta);
    vec3 rd = cam * normalize(vec3(uv, 1.5));

    float t = raymarch(ro, rd);
    vec3 col;
    if (t > 0.0) {
        vec3 pos = ro + t * rd;
        vec3 nor = calcNormal(pos, t);
        vec3 mate = getMaterial(pos, nor);
        float sha = calcShadow(pos + nor * 0.5, SUN_DIR);
        vec3 lin = calcLighting(pos, nor, rd, sha);
        col = mate * lin;
        col = applyFog(col, t, rd);
    } else {
        col = getSky(rd);
    }
    col = 1.0 - exp(-col * 2.0);
    col = pow(col, vec3(1.0 / 2.2));
    fragColor = vec4(col, 1.0);
}

Binary Refinement (optional, called after raymarch)

float bisect(in vec3 ro, in vec3 rd, float tNear, float tFar) {
    for (int i = 0; i < 5; i++) {
        float tMid = 0.5 * (tNear + tFar);
        vec3 pos = ro + tMid * rd;
        float h = pos.y - terrainM(pos.xz);
        if (h > 0.0) tNear = tMid; else tFar = tMid;
    }
    return 0.5 * (tNear + tFar);
}

Common Variants

Relaxation Marching

Automatically increases step size at far distances, covering greater range in 90 steps.

float raymarchRelax(in vec3 ro, in vec3 rd) {
    float t = 0.0;
    float d = (ro + rd * t).y - terrainM((ro + rd * t).xz);
    for (int i = 0; i < 90; i++) {
        if (abs(d) < t * 0.0001 || t > 400.0) break;
        float rl = max(t * 0.02, 1.0);
        t += d * rl;
        vec3 pos = ro + t * rd;
        d = (pos.y - terrainM(pos.xz)) * 0.7;
    }
    return t;
}

Sign-Alternating FBM

Amplitude flips sign each layer, producing rugged alternating ridge/valley patterns.

float terrainSignFlip(in vec2 p) {
    p *= TERRAIN_SCALE;
    float a = 0.0, w = 1.0;
    for (int i = 0; i < TERRAIN_OCTAVES; i++) {
        a += w * noise(p);
        w = -w * 0.4;
        p = m2 * p * 2.0;
    }
    return a * TERRAIN_HEIGHT;
}

Canyon Style (Texture-Driven + 3D Displacement)

Texture sampling + 3D FBM displacement, supporting cliffs/caves and other non-heightfield formations.

float noise3D(in vec3 x) {
    vec3 p = floor(x); vec3 f = fract(x);
    f = f * f * (3.0 - 2.0 * f);
    vec2 uv = (p.xy + vec2(37.0, 17.0) * p.z) + f.xy;
    vec2 rg = textureLod(iChannel0, (uv + 0.5) / 256.0, 0.0).yx;
    return mix(rg.x, rg.y, f.z);
}

const mat3 m3 = mat3(0.00, 0.80, 0.60, -0.80, 0.36,-0.48, -0.60,-0.48, 0.64);

float displacement(vec3 p) {
    float f = 0.5 * noise3D(p); p = m3 * p * 2.02;
    f += 0.25 * noise3D(p);    p = m3 * p * 2.03;
    f += 0.125 * noise3D(p);   p = m3 * p * 2.01;
    f += 0.0625 * noise3D(p);
    return f;
}

float mapCanyon(vec3 p) {
    float h = terrainM(p.xz);
    float dis = displacement(0.25 * p * vec3(1.0, 4.0, 1.0)) * 3.0;
    return (dis + p.y - h) * 0.25;
}

Directional Erosion Noise

Slope direction drives Gabor noise projection, producing realistic dendritic drainage patterns.

#define EROSION_BRANCH 1.5

vec3 erosionNoise(vec2 p, vec2 dir) {
    vec2 ip = floor(p); vec2 fp = fract(p) - 0.5;
    float va = 0.0, wt = 0.0; vec2 dva = vec2(0.0);
    for (int i = -2; i <= 1; i++)
    for (int j = -2; j <= 1; j++) {
        vec2 o = vec2(float(i), float(j));
        vec2 h = hash2(ip - o) * 0.5;
        vec2 pp = fp + o + h;
        float d = dot(pp, pp);
        float w = exp(-d * 2.0);
        float mag = dot(pp, dir);
        va += cos(mag * 6.283) * w;
        dva += -sin(mag * 6.283) * dir * w;
        wt += w;
    }
    return vec3(va, dva) / wt;
}

float terrainErosion(vec2 p, vec2 baseSlope) {
    float e = 0.0, a = 0.5;
    vec2 dir = normalize(baseSlope + vec2(0.001));
    for (int i = 0; i < 5; i++) {
        vec3 n = erosionNoise(p * 4.0, dir);
        e += a * n.x;
        dir = normalize(dir + n.zy * vec2(1.0, -1.0) * EROSION_BRANCH);
        a *= 0.5; p *= 2.0;
    }
    return e;
}

Volumetric Clouds + God Rays

Front-to-back alpha compositing of cloud slabs, accumulating god ray factor.

#define CLOUD_BASE 200.0
#define CLOUD_TOP 300.0

vec4 raymarchClouds(vec3 ro, vec3 rd) {
    float tmin = (CLOUD_BASE - ro.y) / rd.y;
    float tmax = (CLOUD_TOP  - ro.y) / rd.y;
    if (tmin > tmax) { float tmp = tmin; tmin = tmp; tmax = tmp; }
    if (tmin < 0.0) tmin = 0.0;
    float t = tmin;
    vec4 sum = vec4(0.0); float rays = 0.0;
    for (int i = 0; i < 64; i++) {
        if (sum.a > 0.99 || t > tmax) break;
        vec3 pos = ro + t * rd;
        float hFrac = (pos.y - CLOUD_BASE) / (CLOUD_TOP - CLOUD_BASE);
        float shape = 1.0 - 2.0 * abs(hFrac - 0.5);
        float den = shape - 1.6 * (1.0 - noise(pos.xz * 0.01));
        if (den > 0.0) {
            float shadowDen = shape - 1.6 * (1.0 - noise((pos.xz + SUN_DIR.xz * 30.0) * 0.01));
            float shadow = clamp(1.0 - shadowDen * 2.0, 0.0, 1.0);
            vec3 cloudCol = mix(vec3(0.4, 0.4, 0.45), vec3(1.0, 0.95, 0.8), shadow);
            float alpha = clamp(den * 0.4, 0.0, 1.0);
            rays += 0.02 * shadow * (1.0 - sum.a);
            cloudCol *= alpha;
            sum += vec4(cloudCol, alpha) * (1.0 - sum.a);
        }
        t += max(0.5, 0.05 * t);
    }
    sum.rgb += pow(rays, 3.0) * 0.4 * vec3(1.0, 0.8, 0.7);
    return sum;
}

Performance & Composition

Performance:

  • LOD tiers: low octaves for marching (3-9), high octaves for normals (16), lowest for camera (3)
  • Upper bound clipping: intersect ray with terrain max height plane before marching
  • Adaptive precision: hit threshold abs(h) < k * t, tolerates larger error at distance
  • Texture instead of noise: textureLod sampling of pre-baked noise, 2-3x speed
  • Early exit: t > MAX_DIST, alpha > 0.99, shadow h < 0
  • Dithered start: t += hash(fragCoord) * step_size to eliminate banding artifacts

Composition:

  • Terrain + water: water at a fixed y-plane, multi-frequency noise perturbing normals, Fresnel controlling reflection/refraction
  • Terrain + volumetric clouds: render terrain first, then march cloud slab, front-to-back alpha compositing
  • Terrain + volumetric fog: additionally sample 3D FBM density field along ray, decay with distance
  • Terrain + SDF objects: floor(p.xz/gridSize) grid placement, hash(cell) randomization
  • Terrain + TAA: inter-frame reprojection blending, ~10% new frame + 90% history frame

Further Reading

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