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

19 KiB

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

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

// 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;
}
// 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

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)

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

// 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)

// 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

#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.

#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.

// 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

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

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

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)

// 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)

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:

// 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:

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