Files
skills/shader-dev/techniques/lighting-model.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

17 KiB

Lighting Models Skill

Use Cases

  • Adding realistic lighting to raymarched or rasterized scenes
  • Simulating light interaction with various materials (metal, dielectric, water, skin, etc.)
  • From simple diffuse/specular to full PBR
  • Multi-light compositing (sun, sky, ambient)
  • Adding material appearance to SDF scenes in ShaderToy

Core Principles

Lighting = Diffuse + Specular Reflection:

  • Diffuse: Lambert's law I = max(0, N·L)
  • Specular: Empirical model uses Blinn-Phong pow(max(0, N·H), shininess); physically-based model uses Cook-Torrance BRDF

Key Formulas

Lambert:        L_diffuse  = albedo * lightColor * max(0, N·L)
Blinn-Phong:    H = normalize(V + L); L_specular = lightColor * pow(max(0, N·H), shininess)
Cook-Torrance:  f_specular = D(h) * F(v,h) * G(l,v,h) / (4 * (N·L) * (N·V))
Fresnel:        F = F0 + (1 - F0) * (1 - V·H)^5
  • D = GGX/Trowbridge-Reitz normal distribution
  • F = Schlick Fresnel approximation
  • G = Smith geometric shadowing
  • F0: dielectric ~0.04, metals use baseColor

Implementation Steps

Step 1: Scene Basics (Normal + Vector Setup)

// SDF normal (finite difference method)
vec3 calcNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);
    return normalize(vec3(
        map(p + e.xyy) - map(p - e.xyy),
        map(p + e.yxy) - map(p - e.yxy),
        map(p + e.yyx) - map(p - e.yyx)
    ));
}

vec3 N = calcNormal(pos);           // surface normal
vec3 V = -rd;                        // view direction
vec3 L = normalize(lightPos - pos);  // light direction (point light)
// directional light: vec3 L = normalize(vec3(0.6, 0.8, -0.5));

Step 2: Lambert Diffuse

float NdotL = max(0.0, dot(N, L));
vec3 diffuse = albedo * lightColor * NdotL;

// energy-conserving version
vec3 diffuse_conserved = albedo / PI * lightColor * NdotL;

// Half-Lambert (reduces over-darkening on backlit faces, commonly used for SSS approximation)
float halfLambert = NdotL * 0.5 + 0.5;
vec3 diffuse_wrapped = albedo * lightColor * halfLambert;

Step 3: Blinn-Phong Specular

vec3 H = normalize(V + L);
float NdotH = max(0.0, dot(N, H));
float SHININESS = 32.0;  // 4.0 (rough) ~ 256.0 (smooth)

// with normalization factor for energy conservation
float normFactor = (SHININESS + 8.0) / (8.0 * PI);
float spec = normFactor * pow(NdotH, SHININESS);
vec3 specular = lightColor * spec;

Step 4: Fresnel-Schlick

vec3 fresnelSchlick(vec3 F0, float cosTheta) {
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

// metallic workflow
vec3 F0 = mix(vec3(0.04), baseColor, metallic);

// computed with V·H (specular reflection BRDF)
float VdotH = max(0.0, dot(V, H));
vec3 F = fresnelSchlick(F0, VdotH);

// computed with N·V (environment reflection, rim light)
float NdotV = max(0.0, dot(N, V));
vec3 F_env = fresnelSchlick(F0, NdotV);

Step 5: GGX Normal Distribution (D Term)

float distributionGGX(float NdotH, float roughness) {
    float a = roughness * roughness;  // roughness must be squared first
    float a2 = a * a;
    float denom = NdotH * NdotH * (a2 - 1.0) + 1.0;
    return a2 / (PI * denom * denom);
}

Step 6: Geometric Shadowing (G Term)

// Method 1: Schlick-GGX
float geometrySchlickGGX(float NdotV, float roughness) {
    float r = roughness + 1.0;
    float k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}
float geometrySmith(float NdotV, float NdotL, float roughness) {
    return geometrySchlickGGX(NdotV, roughness) * geometrySchlickGGX(NdotL, roughness);
}

// Method 2: Height-Correlated Smith (more accurate, directly returns the visibility term)
float visibilitySmith(float NdotV, float NdotL, float roughness) {
    float a2 = roughness * roughness;
    float gv = NdotL * sqrt(NdotV * (NdotV - NdotV * a2) + a2);
    float gl = NdotV * sqrt(NdotL * (NdotL - NdotL * a2) + a2);
    return 0.5 / max(gv + gl, 0.00001);
}

// Method 3: Simplified approximation
float G1V(float dotNV, float k) {
    return 1.0 / (dotNV * (1.0 - k) + k);
}
// Usage: float vis = G1V(NdotL, k) * G1V(NdotV, k); where k = roughness/2

Step 7: Assembling Cook-Torrance BRDF

vec3 cookTorranceBRDF(vec3 N, vec3 V, vec3 L, float roughness, vec3 F0) {
    vec3 H = normalize(V + L);
    float NdotL = max(0.0, dot(N, L));
    float NdotV = max(0.0, dot(N, V));
    float NdotH = max(0.0, dot(N, H));
    float VdotH = max(0.0, dot(V, H));

    float D = distributionGGX(NdotH, roughness);
    vec3 F = fresnelSchlick(F0, VdotH);
    float Vis = visibilitySmith(NdotV, NdotL, roughness);

    // Vis version already includes the 4*NdotV*NdotL denominator
    vec3 specular = D * F * Vis;
    // Or with standard G term: specular = (D * F * G) / max(4.0 * NdotV * NdotL, 0.001);

    return specular * NdotL;
}

Step 8: Multi-Light Accumulation and Compositing

vec3 shade(vec3 pos, vec3 N, vec3 V, vec3 albedo, float roughness, float metallic) {
    vec3 F0 = mix(vec3(0.04), albedo, metallic);
    vec3 diffuseColor = albedo * (1.0 - metallic);  // metals have no diffuse
    vec3 color = vec3(0.0);

    // primary light (sun)
    vec3 sunDir = normalize(vec3(0.6, 0.8, -0.5));
    vec3 sunColor = vec3(1.0, 0.95, 0.85) * 2.0;
    vec3 H = normalize(V + sunDir);
    float NdotL = max(0.0, dot(N, sunDir));
    float NdotV = max(0.0, dot(N, V));
    float VdotH = max(0.0, dot(V, H));
    vec3 F = fresnelSchlick(F0, VdotH);
    vec3 kD = (1.0 - F) * (1.0 - metallic);  // energy conservation

    color += kD * diffuseColor / PI * sunColor * NdotL;
    color += cookTorranceBRDF(N, V, sunDir, roughness, F0) * sunColor;

    // sky light (hemisphere approximation)
    vec3 skyColor = vec3(0.2, 0.5, 1.0) * 0.3;
    float skyDiffuse = 0.5 + 0.5 * N.y;
    color += diffuseColor * skyColor * skyDiffuse;

    // back light / rim light
    vec3 backDir = normalize(vec3(-sunDir.x, 0.0, -sunDir.z));
    float backDiffuse = clamp(dot(N, backDir) * 0.5 + 0.5, 0.0, 1.0);
    color += diffuseColor * vec3(0.25, 0.15, 0.1) * backDiffuse;

    return color;
}

Step 9: Ambient Occlusion (AO)

// Raymarching AO (using SDF queries)
float calcAO(vec3 pos, vec3 nor) {
    float occ = 0.0;
    float sca = 1.0;
    for (int i = 0; i < 5; i++) {
        float h = 0.01 + 0.12 * float(i) / 4.0;
        float d = map(pos + h * nor);
        occ += (h - d) * sca;
        sca *= 0.95;
    }
    return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}

float ao = calcAO(pos, N);
diffuseLight *= ao;
// specular AO (more subtle):
specularLight *= clamp(pow(NdotV + ao, roughness * roughness) - 1.0 + ao, 0.0, 1.0);

Outdoor Three-Light Model

The go-to lighting setup for outdoor SDF scenes. Uses three directional sources to approximate full global illumination with minimal cost:

// === Outdoor Three-Light Lighting ===
// Compute material, occlusion, and shadow first
vec3 material = getMaterial(pos, nor);  // albedo, keep ≤ 0.2 for realism
float occ = calcAO(pos, nor);          // ambient occlusion
float sha = calcSoftShadow(pos, sunDir, 0.02, 8.0);

// Three light contributions
float sun = clamp(dot(nor, sunDir), 0.0, 1.0);        // direct sunlight
float sky = clamp(0.5 + 0.5 * nor.y, 0.0, 1.0);       // hemisphere sky light
float ind = clamp(dot(nor, normalize(sunDir * vec3(-1.0, 0.0, -1.0))), 0.0, 1.0); // indirect bounce

// Combine with colored shadows (key technique: shadow penumbra tints blue)
vec3 lin = vec3(0.0);
lin += sun * vec3(1.64, 1.27, 0.99) * pow(vec3(sha), vec3(1.0, 1.2, 1.5));  // warm sun, colored shadow
lin += sky * vec3(0.16, 0.20, 0.28) * occ;   // cool sky fill
lin += ind * vec3(0.40, 0.28, 0.20) * occ;   // warm ground bounce

vec3 color = material * lin;

Key principles:

  • Colored shadow penumbra: pow(vec3(sha), vec3(1.0, 1.2, 1.5)) makes shadow edges slightly blue/cool, mimicking real subsurface scattering in penumbra regions
  • Material albedo rule: Keep diffuse albedo ≤ 0.2; adjust light intensities for brightness, not material values. Real-world surfaces rarely exceed 0.3 albedo
  • Linear workflow: All computations in linear space, apply gamma pow(color, vec3(1.0/2.2)) at the very end
  • Sky light approximation: 0.5 + 0.5 * nor.y is a cheap hemisphere integral — surfaces pointing up get full sky, pointing down get none
  • Do NOT apply ambient occlusion to the sun/key light — shadows handle that

Complete Code Template

// Lighting Model Complete Template - Runs directly in ShaderToy
// Progressive implementation from Lambert to Cook-Torrance PBR

#define PI 3.14159265359

// ========== Adjustable Parameters ==========
#define ROUGHNESS 0.35
#define METALLIC 0.0
#define ALBEDO vec3(0.8, 0.2, 0.2)
#define SUN_DIR normalize(vec3(0.6, 0.8, -0.5))
#define SUN_COLOR vec3(1.0, 0.95, 0.85) * 2.0
#define SKY_COLOR vec3(0.2, 0.5, 1.0) * 0.4
#define BACKGROUND_TOP vec3(0.5, 0.7, 1.0)
#define BACKGROUND_BOT vec3(0.8, 0.85, 0.9)

// ========== SDF Scene ==========
float map(vec3 p) {
    float sphere = length(p - vec3(0.0, 0.0, 0.0)) - 1.0;
    float ground = p.y + 1.0;
    return min(sphere, ground);
}

vec3 calcNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);
    return normalize(vec3(
        map(p + e.xyy) - map(p - e.xyy),
        map(p + e.yxy) - map(p - e.yxy),
        map(p + e.yyx) - map(p - e.yyx)
    ));
}

// ========== AO ==========
float calcAO(vec3 pos, vec3 nor) {
    float occ = 0.0;
    float sca = 1.0;
    for (int i = 0; i < 5; i++) {
        float h = 0.01 + 0.12 * float(i) / 4.0;
        float d = map(pos + h * nor);
        occ += (h - d) * sca;
        sca *= 0.95;
    }
    return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}

// ========== Soft Shadow ==========
float softShadow(vec3 ro, vec3 rd, float mint, float maxt) {
    float res = 1.0;
    float t = mint;
    for (int i = 0; i < 24; i++) {
        float h = map(ro + rd * t);
        res = min(res, 8.0 * h / t);
        t += clamp(h, 0.02, 0.2);
        if (res < 0.001 || t > maxt) break;
    }
    return clamp(res, 0.0, 1.0);
}

// ========== PBR BRDF Components ==========
float D_GGX(float NdotH, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float d = NdotH * NdotH * (a2 - 1.0) + 1.0;
    return a2 / (PI * d * d);
}

vec3 F_Schlick(vec3 F0, float cosTheta) {
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

float V_SmithGGX(float NdotV, float NdotL, float roughness) {
    float a2 = roughness * roughness;
    a2 *= a2;
    float gv = NdotL * sqrt(NdotV * NdotV * (1.0 - a2) + a2);
    float gl = NdotV * sqrt(NdotL * NdotL * (1.0 - a2) + a2);
    return 0.5 / max(gv + gl, 1e-5);
}

// ========== Complete Lighting ==========
vec3 shade(vec3 pos, vec3 N, vec3 V, vec3 albedo, float roughness, float metallic) {
    vec3 F0 = mix(vec3(0.04), albedo, metallic);
    vec3 diffuseColor = albedo * (1.0 - metallic);
    float NdotV = max(dot(N, V), 1e-4);
    float ao = calcAO(pos, N);
    vec3 color = vec3(0.0);

    // sunlight
    {
        vec3 L = SUN_DIR;
        vec3 H = normalize(V + L);
        float NdotL = max(dot(N, L), 0.0);
        float NdotH = max(dot(N, H), 0.0);
        float VdotH = max(dot(V, H), 0.0);
        float D = D_GGX(NdotH, roughness);
        vec3  F = F_Schlick(F0, VdotH);
        float Vis = V_SmithGGX(NdotV, NdotL, roughness);
        vec3 kD = (1.0 - F) * (1.0 - metallic);
        vec3 diffuse  = kD * diffuseColor / PI;
        vec3 specular = D * F * Vis;
        float shadow = softShadow(pos, L, 0.02, 5.0);
        color += (diffuse + specular) * SUN_COLOR * NdotL * shadow;
    }

    // sky light (hemisphere approximation)
    {
        float skyDiff = 0.5 + 0.5 * N.y;
        color += diffuseColor * SKY_COLOR * skyDiff * ao;
    }

    // back light / rim light
    {
        vec3 backDir = normalize(vec3(-SUN_DIR.x, 0.0, -SUN_DIR.z));
        float backDiff = clamp(dot(N, backDir) * 0.5 + 0.5, 0.0, 1.0);
        color += diffuseColor * vec3(0.15, 0.1, 0.08) * backDiff * ao;
    }

    // environment reflection (simplified)
    {
        vec3 R = reflect(-V, N);
        vec3 envColor = mix(BACKGROUND_BOT, BACKGROUND_TOP, clamp(R.y * 0.5 + 0.5, 0.0, 1.0));
        vec3 F_env = F_Schlick(F0, NdotV);
        float envOcc = clamp(pow(NdotV + ao, roughness * roughness) - 1.0 + ao, 0.0, 1.0);
        color += F_env * envColor * envOcc * (1.0 - roughness * 0.7);
    }

    return color;
}

// ========== Raymarching ==========
float raymarch(vec3 ro, vec3 rd) {
    float t = 0.0;
    for (int i = 0; i < 128; i++) {
        float d = map(ro + rd * t);
        if (d < 0.001) return t;
        t += d;
        if (t > 50.0) break;
    }
    return -1.0;
}

// ========== Background ==========
vec3 background(vec3 rd) {
    vec3 col = mix(BACKGROUND_BOT, BACKGROUND_TOP, clamp(rd.y * 0.5 + 0.5, 0.0, 1.0));
    float sun = clamp(dot(rd, SUN_DIR), 0.0, 1.0);
    col += SUN_COLOR * 0.3 * pow(sun, 8.0);
    col += SUN_COLOR * 1.0 * pow(sun, 256.0);
    return col;
}

// ========== Main Function ==========
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;

    float angle = iTime * 0.3;
    vec3 ro = vec3(3.0 * cos(angle), 1.5, 3.0 * sin(angle));
    vec3 ta = vec3(0.0, 0.0, 0.0);
    vec3 ww = normalize(ta - ro);
    vec3 uu = normalize(cross(ww, vec3(0.0, 1.0, 0.0)));
    vec3 vv = cross(uu, ww);
    vec3 rd = normalize(uv.x * uu + uv.y * vv + 1.5 * ww);

    vec3 col = background(rd);
    float t = raymarch(ro, rd);

    if (t > 0.0) {
        vec3 pos = ro + t * rd;
        vec3 N = calcNormal(pos);
        vec3 V = -rd;
        vec3 albedo = ALBEDO;
        float roughness = ROUGHNESS;
        float metallic = METALLIC;

        if (pos.y < -0.99) {
            roughness = 0.8;
            metallic = 0.0;
            float checker = mod(floor(pos.x) + floor(pos.z), 2.0);
            albedo = mix(vec3(0.3), vec3(0.6), checker);
        }

        col = shade(pos, N, V, albedo, roughness, metallic);
    }

    col = col / (col + vec3(1.0));       // Tone mapping (Reinhard)
    col = pow(col, vec3(1.0 / 2.2));     // Gamma
    fragColor = vec4(col, 1.0);
}

Common Variants

Variant 1: Classic Phong (Non-PBR)

vec3 R = reflect(-L, N);
float spec = pow(max(0.0, dot(R, V)), 32.0);
vec3 color = albedo * lightColor * NdotL + lightColor * spec;

Variant 2: Point Light Attenuation

float dist = length(lightPos - pos);
float attenuation = 1.0 / (1.0 + dist * 0.1 + dist * dist * 0.01);
color *= attenuation;

Variant 3: IBL (Image-Based Lighting)

// diffuse IBL: spherical harmonics
vec3 diffuseIBL = diffuseColor * SHIrradiance(N);

// specular IBL: EnvBRDFApprox
vec3 EnvBRDFApprox(vec3 specColor, float roughness, float NdotV) {
    vec4 c0 = vec4(-1, -0.0275, -0.572, 0.022);
    vec4 c1 = vec4(1, 0.0425, 1.04, -0.04);
    vec4 r = roughness * c0 + c1;
    float a004 = min(r.x * r.x, exp2(-9.28 * NdotV)) * r.x + r.y;
    vec2 AB = vec2(-1.04, 1.04) * a004 + r.zw;
    return specColor * AB.x + AB.y;
}
vec3 R = reflect(-V, N);
vec3 envColor = textureLod(envMap, R, roughness * 7.0).rgb;
vec3 specularIBL = EnvBRDFApprox(F0, roughness, NdotV) * envColor;

Variant 4: Subsurface Scattering Approximation (SSS)

// SDF-based interior probing
float subsurface(vec3 pos, vec3 L) {
    float sss = 0.0;
    for (int i = 0; i < 5; i++) {
        float h = 0.05 + float(i) * 0.1;
        float d = map(pos + L * h);
        sss += max(0.0, h - d);
    }
    return clamp(1.0 - sss * 4.0, 0.0, 1.0);
}

// Henyey-Greenstein phase function
float HenyeyGreenstein(float cosTheta, float g) {
    float g2 = g * g;
    return (1.0 - g2) / (pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5) * 4.0 * PI);
}
float sssAmount = HenyeyGreenstein(dot(V, L), 0.5);
color += sssColor * sssAmount * NdotL;

Variant 5: Beer's Law Water Lighting

vec3 waterExtinction(float depth) {
    float opticalDepth = depth * 6.0;
    vec3 extinctColor = 1.0 - vec3(0.5, 0.4, 0.1);
    return exp2(-opticalDepth * extinctColor);
}
vec3 underwaterColor = objectColor * waterExtinction(depth);
vec3 inscatter = waterDiffuse * (1.0 - exp(-depth * 0.1));
underwaterColor += inscatter;

Performance & Composition

  • Fresnel optimization: Use x2*x2*x instead of pow(x, 5.0)
  • Visibility term: Use V_SmithGGX to directly return G/(4*NdotV*NdotL), avoiding separate division
  • AO sampling: 5 samples is sufficient; can reduce to 3 at far distances
  • Soft shadow: clamp(h, 0.02, 0.2) limits step size; 14~24 steps usually sufficient; 8.0*h/t controls softness
  • Simplified IBL: Without cubemap, approximate with mix(groundColor, skyColor, R.y*0.5+0.5)
  • Branch culling: Skip specular calculation when NdotL <= 0
  • Raymarching integration: Use SDF finite differences for normals, query SDF directly for AO/shadows
  • Volume rendering integration: Beer's Law attenuation + Henyey-Greenstein phase function; FBM noise procedural normals can be passed directly to lighting functions
  • Post-processing integration: ACES (col*(2.51*col+0.03))/(col*(2.43*col+0.59)+0.14) / Reinhard col/(col+1) + Gamma
  • Reflection integration: reflect(rd, N) to query scene again, blend result with Fresnel weighting

Further Reading

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