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

409 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```glsl
// =====================================================
// 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)
```glsl
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.
```glsl
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.
```glsl
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.
```glsl
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.
```glsl
#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.
```glsl
#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](../reference/terrain-rendering.md)