Files
skills/shader-dev/techniques/analytic-ray-tracing.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

543 lines
19 KiB
Markdown

# Analytic Ray Tracing
## Use Cases
- Rendering scenes composed of geometric primitives (spheres, planes, boxes, cylinders, ellipsoids, etc.)
- Requiring precise surface intersection points, normals, and distance calculations (no iterative approximation)
- Building the underlying geometry engine for ray tracers / path tracers
- Scenes requiring accurate shadows, reflections, and refractions
## Core Principles
Substitute the ray equation `P(t) = O + tD` into the geometric body's implicit equation to obtain an algebraic equation in `t`, then solve it in closed form.
**Unified intersection workflow**: Build equation -> Simplify to standard form -> Discriminant test -> Take smallest positive root -> Compute gradient at intersection for normal
**Key formulas**:
- **Sphere** `|P-C|^2 = r^2` -> Quadratic equation
- **Plane** `N·P + d = 0` -> Linear equation
- **Box** Intersection of three pairs of parallel planes -> Slab Method
- **Ellipsoid** `|P/R|^2 = 1` -> Sphere intersection in scaled space
- **Torus** `(|P_xy| - R)^2 + P_z^2 = r^2` -> Quartic equation
## Implementation Steps
### Step 1: Ray Generation
```glsl
vec3 generateRay(vec2 fragCoord, vec2 resolution, vec3 ro, vec3 ta) {
vec2 p = (2.0 * fragCoord - resolution) / resolution.y;
vec3 cw = normalize(ta - ro);
vec3 cu = normalize(cross(cw, vec3(0, 1, 0)));
vec3 cv = cross(cu, cw);
float fov = 1.5;
return normalize(p.x * cu + p.y * cv + fov * cw);
}
```
### Step 2: Ray-Sphere Intersection
```glsl
// Optimized version with sphere center at origin
float iSphere(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal, float r) {
float b = dot(ro, rd);
float c = dot(ro, ro) - r * r;
float h = b * b - c;
if (h < 0.0) return MAX_DIST;
h = sqrt(h);
float d1 = -b - h;
float d2 = -b + h;
if (d1 >= distBound.x && d1 <= distBound.y) {
normal = normalize(ro + rd * d1);
return d1;
} else if (d2 >= distBound.x && d2 <= distBound.y) {
normal = normalize(ro + rd * d2);
return d2;
}
return MAX_DIST;
}
```
```glsl
// General version, supports arbitrary sphere center (sph = vec4(center.xyz, radius))
float sphIntersect(vec3 ro, vec3 rd, vec4 sph) {
vec3 oc = ro - sph.xyz;
float b = dot(oc, rd);
float c = dot(oc, oc) - sph.w * sph.w;
float h = b * b - c;
if (h < 0.0) return -1.0;
return -b - sqrt(h);
}
```
### Step 3: Ray-Plane Intersection
```glsl
float iPlane(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal,
vec3 planeNormal, float planeDist) {
float denom = dot(rd, planeNormal);
if (denom > 0.0) return MAX_DIST;
float d = -(dot(ro, planeNormal) + planeDist) / denom;
if (d < distBound.x || d > distBound.y) return MAX_DIST;
normal = planeNormal;
return d;
}
// fast horizontal ground plane
float iGroundPlane(vec3 ro, vec3 rd, float height) {
return -(ro.y - height) / rd.y;
}
```
### Step 4: Ray-Box Intersection (Slab Method)
```glsl
float iBox(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal, vec3 boxSize) {
vec3 m = sign(rd) / max(abs(rd), 1e-8);
vec3 n = m * ro;
vec3 k = abs(m) * boxSize;
vec3 t1 = -n - k;
vec3 t2 = -n + k;
float tN = max(max(t1.x, t1.y), t1.z);
float tF = min(min(t2.x, t2.y), t2.z);
if (tN > tF || tF <= 0.0) return MAX_DIST;
if (tN >= distBound.x && tN <= distBound.y) {
normal = -sign(rd) * step(t1.yzx, t1.xyz) * step(t1.zxy, t1.xyz);
return tN;
} else if (tF >= distBound.x && tF <= distBound.y) {
normal = -sign(rd) * step(t1.yzx, t1.xyz) * step(t1.zxy, t1.xyz);
return tF;
}
return MAX_DIST;
}
```
### Step 5: Ray-Ellipsoid Intersection
```glsl
// Transform to unit sphere space for intersection, transform normal back to original space
float iEllipsoid(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal, vec3 rad) {
vec3 ocn = ro / rad;
vec3 rdn = rd / rad;
float a = dot(rdn, rdn);
float b = dot(ocn, rdn);
float c = dot(ocn, ocn);
float h = b * b - a * (c - 1.0);
if (h < 0.0) return MAX_DIST;
float d = (-b - sqrt(h)) / a;
if (d < distBound.x || d > distBound.y) return MAX_DIST;
normal = normalize((ro + d * rd) / rad);
return d;
}
```
### Step 6: Ray-Cylinder Intersection (With End Caps)
```glsl
// pa, pb: cylinder axis endpoints, ra: radius
float iCylinder(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal,
vec3 pa, vec3 pb, float ra) {
vec3 ca = pb - pa;
vec3 oc = ro - pa;
float caca = dot(ca, ca);
float card = dot(ca, rd);
float caoc = dot(ca, oc);
float a = caca - card * card;
float b = caca * dot(oc, rd) - caoc * card;
float c = caca * dot(oc, oc) - caoc * caoc - ra * ra * caca;
float h = b * b - a * c;
if (h < 0.0) return MAX_DIST;
h = sqrt(h);
float d = (-b - h) / a;
float y = caoc + d * card;
if (y > 0.0 && y < caca && d >= distBound.x && d <= distBound.y) {
normal = (oc + d * rd - ca * y / caca) / ra;
return d;
}
d = ((y < 0.0 ? 0.0 : caca) - caoc) / card;
if (abs(b + a * d) < h && d >= distBound.x && d <= distBound.y) {
normal = normalize(ca * sign(y) / caca);
return d;
}
return MAX_DIST;
}
```
### Step 7: Scene Intersection and Shading
```glsl
#define MAX_DIST 1e10
vec3 worldHit(vec3 ro, vec3 rd, vec2 dist, out vec3 normal) {
vec3 d = vec3(dist, 0.0);
vec3 tmpNormal;
float t;
t = iPlane(ro, rd, d.xy, normal, vec3(0, 1, 0), 0.0);
if (t < d.y) { d.y = t; d.z = 1.0; }
t = iSphere(ro - vec3(0, 0.5, 0), rd, d.xy, tmpNormal, 0.5);
if (t < d.y) { d.y = t; d.z = 2.0; normal = tmpNormal; }
t = iBox(ro - vec3(2, 0.5, 0), rd, d.xy, tmpNormal, vec3(0.5));
if (t < d.y) { d.y = t; d.z = 3.0; normal = tmpNormal; }
return d;
}
vec3 shade(vec3 pos, vec3 normal, vec3 rd, vec3 albedo) {
vec3 lightDir = normalize(vec3(-1.0, 0.75, 1.0));
float diff = max(dot(normal, lightDir), 0.0);
float amb = 0.5 + 0.5 * normal.y;
return albedo * (amb * 0.2 + diff * 0.8);
}
```
> **IMPORTANT: Critical pitfall**: `d.xy` must be passed as distBound, and `d.y` must be updated each time a closer intersection is found! If the deployed code passes the original `dist` directly without updating, the intersection logic will fail (all object distance tests become invalid), resulting in a completely black screen.
```glsl
#define MAX_BOUNCES 4
#define EPSILON 0.001
float schlickFresnel(float cosTheta, float F0) {
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
vec3 radiance(vec3 ro, vec3 rd) {
vec3 color = vec3(0.0);
vec3 mask = vec3(1.0);
vec3 normal;
for (int i = 0; i < MAX_BOUNCES; i++) {
vec3 res = worldHit(ro, rd, vec2(EPSILON, MAX_DIST), normal);
if (res.z < 0.5) {
color += mask * vec3(0.6, 0.8, 1.0);
break;
}
vec3 hitPos = ro + rd * res.y;
vec3 albedo = getAlbedo(res.z);
float F = schlickFresnel(max(0.0, dot(normal, -rd)), 0.04);
color += mask * (1.0 - F) * shade(hitPos, normal, rd, albedo);
mask *= F * albedo;
rd = reflect(rd, normal);
ro = hitPos + EPSILON * rd;
}
return color;
}
```
## Complete Code Template
Runs directly on ShaderToy, includes sphere, plane, and box primitives with reflection and Blinn-Phong shading.
> **IMPORTANT: Must follow**: All intersection function calls must use `d.xy` as the `distBound` parameter, and update `d.y` after each closer intersection is found. Incorrect usage: `iSphere(ro, rd, dist, ...)` (always using the original dist). Correct usage: `iSphere(ro, rd, d.xy, ...)` followed by `if (t < d.y) { d.y = t; ... }` to update.
```glsl
// Analytic Ray Tracing - Complete ShaderToy Template
#define MAX_DIST 1e10
#define EPSILON 0.001
#define MAX_BOUNCES 3
#define FOV 1.5
#define GAMMA 2.2
#define SHADOW_ENABLED true
float iSphere(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal, float r) {
float b = dot(ro, rd);
float c = dot(ro, ro) - r * r;
float h = b * b - c;
if (h < 0.0) return MAX_DIST;
h = sqrt(h);
float d1 = -b - h, d2 = -b + h;
if (d1 >= distBound.x && d1 <= distBound.y) { normal = normalize(ro + rd * d1); return d1; }
if (d2 >= distBound.x && d2 <= distBound.y) { normal = normalize(ro + rd * d2); return d2; }
return MAX_DIST;
}
float iPlane(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal,
vec3 planeNormal, float planeDist) {
float denom = dot(rd, planeNormal);
if (denom > 0.0) return MAX_DIST;
float d = -(dot(ro, planeNormal) + planeDist) / denom;
if (d < distBound.x || d > distBound.y) return MAX_DIST;
normal = planeNormal;
return d;
}
float iBox(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal, vec3 boxSize) {
vec3 m = sign(rd) / max(abs(rd), 1e-8);
vec3 n = m * ro;
vec3 k = abs(m) * boxSize;
vec3 t1 = -n - k, t2 = -n + k;
float tN = max(max(t1.x, t1.y), t1.z);
float tF = min(min(t2.x, t2.y), t2.z);
if (tN > tF || tF <= 0.0) return MAX_DIST;
if (tN >= distBound.x && tN <= distBound.y) {
normal = -sign(rd) * step(t1.yzx, t1.xyz) * step(t1.zxy, t1.xyz); return tN;
}
if (tF >= distBound.x && tF <= distBound.y) {
normal = -sign(rd) * step(t1.yzx, t1.xyz) * step(t1.zxy, t1.xyz); return tF;
}
return MAX_DIST;
}
struct Material { vec3 albedo; float specular; float roughness; };
Material getMaterial(float matId, vec3 pos) {
if (matId < 1.5) {
float checker = mod(floor(pos.x) + floor(pos.z), 2.0);
return Material(vec3(0.4 + 0.4 * checker), 0.02, 0.8);
} else if (matId < 2.5) { return Material(vec3(1.0, 0.2, 0.2), 0.5, 0.3); }
else if (matId < 3.5) { return Material(vec3(0.2, 0.4, 1.0), 0.1, 0.6); }
else if (matId < 4.5) { return Material(vec3(1.0, 1.0, 1.0), 0.8, 0.05); }
else { return Material(vec3(0.8, 0.6, 0.2), 0.3, 0.4); }
}
vec3 worldHit(vec3 ro, vec3 rd, vec2 dist, out vec3 normal) {
vec3 d = vec3(dist, 0.0); vec3 tmp; float t;
t = iPlane(ro, rd, d.xy, tmp, vec3(0, 1, 0), 0.0);
if (t < d.y) { d.y = t; d.z = 1.0; normal = tmp; }
t = iSphere(ro - vec3(-2.0, 1.0, 0.0), rd, d.xy, tmp, 1.0);
if (t < d.y) { d.y = t; d.z = 2.0; normal = tmp; }
t = iSphere(ro - vec3(0.0, 0.6, 2.0), rd, d.xy, tmp, 0.6);
if (t < d.y) { d.y = t; d.z = 3.0; normal = tmp; }
t = iSphere(ro - vec3(2.0, 0.8, -1.0), rd, d.xy, tmp, 0.8);
if (t < d.y) { d.y = t; d.z = 4.0; normal = tmp; }
t = iBox(ro - vec3(0.0, 0.5, -2.0), rd, d.xy, tmp, vec3(0.5));
if (t < d.y) { d.y = t; d.z = 5.0; normal = tmp; }
return d;
}
float shadow(vec3 ro, vec3 rd, float maxDist) {
vec3 normal;
vec3 res = worldHit(ro, rd, vec2(EPSILON, maxDist), normal);
return res.z > 0.5 ? 0.3 : 1.0;
}
float schlick(float cosTheta, float F0) {
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
vec3 skyColor(vec3 rd) {
vec3 col = mix(vec3(1.0), vec3(0.5, 0.7, 1.0), 0.5 + 0.5 * rd.y);
vec3 sunDir = normalize(vec3(-0.4, 0.7, -0.6));
float sun = clamp(dot(sunDir, rd), 0.0, 1.0);
col += vec3(1.0, 0.6, 0.1) * (pow(sun, 4.0) + 10.0 * pow(sun, 32.0));
return col;
}
vec3 render(vec3 ro, vec3 rd) {
vec3 color = vec3(0.0), mask = vec3(1.0), normal;
for (int bounce = 0; bounce < MAX_BOUNCES; bounce++) {
vec3 res = worldHit(ro, rd, vec2(EPSILON, 100.0), normal);
if (res.z < 0.5) { color += mask * skyColor(rd); break; }
vec3 hitPos = ro + rd * res.y;
Material mat = getMaterial(res.z, hitPos);
vec3 lightDir = normalize(vec3(-0.4, 0.7, -0.6));
float diff = max(dot(normal, lightDir), 0.0);
float amb = 0.5 + 0.5 * normal.y;
float sha = SHADOW_ENABLED ? shadow(hitPos + normal * EPSILON, lightDir, 50.0) : 1.0;
vec3 halfVec = normalize(lightDir - rd);
float spec = pow(max(dot(normal, halfVec), 0.0), 1.0 / max(mat.roughness, 0.001));
float F = schlick(max(0.0, dot(normal, -rd)), 0.04 + 0.96 * mat.specular);
vec3 diffCol = mat.albedo * (amb * 0.15 + diff * sha * 0.85);
vec3 specCol = vec3(spec * sha);
color += mask * mix(diffCol, specCol, F * mat.specular);
mask *= F * mat.albedo;
if (length(mask) < 0.01) break;
rd = reflect(rd, normal);
ro = hitPos + normal * EPSILON;
}
return color;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
float angle = 0.3 * iTime;
vec3 ro = vec3(4.0 * cos(angle), 2.5, 4.0 * sin(angle));
vec3 ta = vec3(0.0, 0.5, 0.0);
vec3 cw = normalize(ta - ro);
vec3 cu = normalize(cross(cw, vec3(0, 1, 0)));
vec3 cv = cross(cu, cw);
vec3 rd = normalize(p.x * cu + p.y * cv + FOV * cw);
vec3 col = render(ro, rd);
col = col / (1.0 + col);
col = pow(col, vec3(1.0 / GAMMA));
fragColor = vec4(col, 1.0);
}
```
## Common Variants
### Variant 1: Path Tracing
```glsl
vec3 cosWeightedRandomHemisphereDirection(vec3 n, inout uint seed) {
uint ri = seed * 1103515245u + 12345u;
seed = ri;
float r1 = float(ri) / float(0xFFFFFFFFu);
ri = seed * 1103515245u + 12345u;
seed = ri;
float r2 = float(ri) / float(0xFFFFFFFFu);
vec3 uu = normalize(cross(n, abs(n.y) > 0.5 ? vec3(1,0,0) : vec3(0,1,0)));
vec3 vv = cross(uu, n);
float ra = sqrt(r1);
float rx = ra * cos(6.2831 * r2);
float ry = ra * sin(6.2831 * r2);
float rz = sqrt(1.0 - r1);
return normalize(rx * uu + ry * vv + rz * n);
}
// In the bounce loop, replace reflect with:
// rd = cosWeightedRandomHemisphereDirection(normal, seed);
// ro = hitPos + EPSILON * rd;
// mask *= mat.albedo;
```
### Variant 2: Analytic Soft Shadow
```glsl
float sphSoftShadow(vec3 ro, vec3 rd, vec4 sph) {
vec3 oc = ro - sph.xyz;
float b = dot(oc, rd);
float c = dot(oc, oc) - sph.w * sph.w;
float h = b * b - c;
float d = sqrt(max(0.0, sph.w * sph.w - h)) - sph.w;
float t = -b - sqrt(max(h, 0.0));
return (t > 0.0) ? max(d, 0.0) / t : 1.0;
}
```
### Variant 3: Analytic Anti-Aliasing
```glsl
vec2 sphDistances(vec3 ro, vec3 rd, vec4 sph) {
vec3 oc = ro - sph.xyz;
float b = dot(oc, rd);
float c = dot(oc, oc) - sph.w * sph.w;
float h = b * b - c;
float d = sqrt(max(0.0, sph.w * sph.w - h)) - sph.w;
return vec2(d, -b - sqrt(max(h, 0.0)));
}
// float px = 2.0 / iResolution.y;
// vec2 dt = sphDistances(ro, rd, sph);
// float coverage = 1.0 - clamp(dt.x / (dt.y * px), 0.0, 1.0);
// col = mix(bgColor, sphereColor, coverage);
```
### Variant 4: Refraction (Snell's Law)
```glsl
// Requires a random number function defined first
float hash1(float p) {
return fract(sin(p) * 43758.5453);
}
// Add refraction branch in the render loop:
float refrIndex = 1.5; // glass ~ 1.5, water ~ 1.33
bool inside = dot(rd, normal) > 0.0;
vec3 n = inside ? -normal : normal;
float eta = inside ? refrIndex : 1.0 / refrIndex;
vec3 refracted = refract(rd, n, eta);
float cosI = abs(dot(rd, n));
float F = schlick(cosI, pow((1.0 - eta) / (1.0 + eta), 2.0));
// Use bounce count as random seed
float randSeed = float(bounce) + 1.0;
if (refracted != vec3(0.0) && hash1(randSeed * 12.9898) > F) {
rd = refracted;
} else {
rd = reflect(rd, n);
}
ro = hitPos + rd * EPSILON;
```
### Variant 5: Higher-Order Algebraic Surface (Sphere4)
```glsl
float iSphere4(vec3 ro, vec3 rd, vec2 distBound, inout vec3 normal, float ra) {
float r2 = ra * ra;
vec3 d2 = rd*rd, d3 = d2*rd;
vec3 o2 = ro*ro, o3 = o2*ro;
float ka = 1.0 / dot(d2, d2);
float k0 = ka * dot(ro, d3);
float k1 = ka * dot(o2, d2);
float k2 = ka * dot(o3, rd);
float k3 = ka * (dot(o2, o2) - r2 * r2);
float c0 = k1 - k0 * k0;
float c1 = k2 + 2.0 * k0 * (k0 * k0 - 1.5 * k1);
float c2 = k3 - 3.0 * k0 * (k0 * (k0 * k0 - 2.0 * k1) + 4.0/3.0 * k2);
float p = c0 * c0 * 3.0 + c2;
float q = c0 * c0 * c0 - c0 * c2 + c1 * c1;
float h = q * q - p * p * p * (1.0/27.0);
if (h < 0.0) return MAX_DIST;
h = sqrt(h);
float s = sign(q+h) * pow(abs(q+h), 1.0/3.0);
float t = sign(q-h) * pow(abs(q-h), 1.0/3.0);
vec2 v = vec2((s+t) + c0*4.0, (s-t) * sqrt(3.0)) * 0.5;
float r = length(v);
float d = -abs(v.y) / sqrt(r + v.x) - c1/r - k0;
if (d >= distBound.x && d <= distBound.y) {
vec3 pos = ro + rd * d;
normal = normalize(pos * pos * pos);
return d;
}
return MAX_DIST;
}
```
## Common Errors and Safeguards
### Error 1: Distance Bound Not Updated
**Symptom**: Screen is completely black or shows only background
**Cause**: `distBound.y` not updated after each intersection
**Fix**:
```glsl
// WRONG:
t = iSphere(ro, rd, dist, tmpNormal, 1.0);
// CORRECT:
t = iSphere(ro, rd, d.xy, tmpNormal, 1.0);
if (t < d.y) { d.y = t; d.z = matId; normal = tmpNormal; }
```
### Error 2: EPSILON Too Small Causing Self-Intersection Artifacts
**Symptom**: Black spots or artifacts on object surfaces
**Cause**: `EPSILON` value too small, ray still intersects with itself
**Fix**: Adjust EPSILON based on scene scale; typical values 1e-3 ~ 1e-2
### Error 3: Variable Used as Loop Upper Bound
**Symptom**: WebGL2 compilation failure or shader crash
**Cause**: In GLSL ES 3.0, `for` loop upper bounds must be constants
**Fix**: Use `#define` for loop upper bounds, and keep bounds to 4-5 iterations max
### Error 4: Division by Zero Causing NaN
**Symptom**: Stripe patterns from NaN propagation across the screen
**Cause**: Division not protected when ray direction components are zero
**Fix**: Always use `max(abs(x), 1e-8)` or similar protection
### Error 5: Missing Hash Function in Refraction Variant
**Symptom**: Compilation error "undefined function 'hash1'"
**Fix**: Add the function definition when using the refraction variant:
```glsl
float hash1(float p) {
return fract(sin(p) * 43758.5453);
}
```
## Performance & Composition
**Performance tips**:
- **Distance bound clipping**: Shorten `distBound.y` after each closer intersection; subsequent objects are automatically skipped
- **Bounding sphere pre-test**: Pre-screen with bounding sphere for complex geometry (torus, etc.)
- **Shadow ray simplification**: Only need to determine occlusion, no normal calculation needed
- **Avoid unnecessary sqrt**: Return early when discriminant is negative; `c > 0.0 && b > 0.0` for fast rejection
- **Grid acceleration**: Use 3D DDA grid traversal for large numbers of similar primitives
**Composition approaches**:
- **+ Raymarching SDF**: Analytic primitives define major structures, SDF handles complex details
- **+ Volume effects**: Analytic intersection provides precise entry/exit distances for volume sampling within the range
- **+ PBR materials**: Precise normals plug directly into Cook-Torrance and other BRDFs
- **+ Spatial transforms**: Rotate/translate rays to reuse the same intersection functions
- **+ Analytic AA/AO/soft shadows**: Fully analytic pipeline, zero noise
## Further Reading
For complete step-by-step tutorials, mathematical derivations, and advanced usage, see [reference](../reference/analytic-ray-tracing.md)