Physically Based Rendering (PBR) — Cook-Torrance BRDF Spheres in Pure GLSL

515 views 4 replies
Live Shader
Loading versions...

Physically Based Rendering has fundamentally transformed real-time graphics. Rather than relying on ad-hoc lighting hacks, PBR grounds shading in the physics of light transport — energy conservation, microfacet theory, and Fresnel reflectance. Every major engine (Unreal, Unity, Frostbite) now uses a variant of the Cook-Torrance microfacet BRDF as its default shading model. In this post, we build a complete PBR material preview from scratch — a grid of raymarched spheres with varying metallic and roughness values, lit by an approximated HDR environment, all inside a single fragment shader.

The Complete PBR Shader

This shader raymarches a 3×3 grid of spheres. Each column increases roughness from left to right, each row transitions from dielectric (plastic) at the top to full metallic at the bottom. The lighting uses a Cook-Torrance specular BRDF with GGX normal distribution, Smith-GGX geometry, and Fresnel-Schlick — plus a fake HDR environment for image-based lighting approximation and diffuse irradiance.

precision mediump float;

uniform vec2 iResolution;
uniform float iTime;

// ============================================================
// CONSTANTS
// ============================================================
#define PI 3.14159265359
#define MAX_STEPS 100
#define MAX_DIST 50.0
#define SURF_DIST 0.001
#define NUM_SPHERES 9

// ============================================================
// FAKE HDR ENVIRONMENT MAP
// Approximate a studio HDRI with procedural gradients and
// bright light sources for convincing reflections.
// ============================================================
vec3 envMap(vec3 rd) {
    // Base sky gradient
    float sunHeight = 0.4;
    vec3 sunDir = normalize(vec3(0.6, sunHeight, -0.7));
    vec3 sunColor = vec3(1.0, 0.9, 0.75) * 5.0;

    // Sky gradient from horizon to zenith
    float sky = max(rd.y, 0.0);
    vec3 col = mix(vec3(0.35, 0.38, 0.42), vec3(0.1, 0.15, 0.35), sky);

    // Ground plane reflection (dark floor)
    if (rd.y < 0.0) {
        col = vec3(0.08, 0.08, 0.1) * (1.0 + 0.3 * smoothstep(-0.5, 0.0, rd.y));
    }

    // Bright sun disc for sharp specular highlights
    float sunDot = max(dot(rd, sunDir), 0.0);
    col += sunColor * pow(sunDot, 256.0);
    col += vec3(1.0, 0.8, 0.5) * 0.5 * pow(sunDot, 32.0);

    // Secondary fill light from opposite side
    vec3 fillDir = normalize(vec3(-0.5, 0.3, 0.8));
    float fillDot = max(dot(rd, fillDir), 0.0);
    col += vec3(0.3, 0.4, 0.6) * 1.5 * pow(fillDot, 64.0);

    // Rim light from below-behind for dramatic effect
    vec3 rimDir = normalize(vec3(0.0, -0.2, 1.0));
    float rimDot = max(dot(rd, rimDir), 0.0);
    col += vec3(0.2, 0.25, 0.4) * 0.8 * pow(rimDot, 16.0);

    // Subtle horizon glow
    float horizon = 1.0 - abs(rd.y);
    col += vec3(0.3, 0.3, 0.35) * 0.3 * pow(horizon, 8.0);

    return col;
}

// ============================================================
// SPHERE GRID SDF
// Returns distance to nearest sphere and sphere ID (0-8)
// ============================================================
vec2 mapScene(vec3 p) {
    float d = MAX_DIST;
    float id = -1.0;

    // 3x3 grid of spheres
    float spacing = 2.5;
    float radius = 0.9;
    int idx = 0;

    for (int row = 0; row < 3; row++) {
        for (int col = 0; col < 3; col++) {
            float fx = float(col) - 1.0;
            float fy = float(row) - 1.0;
            vec3 center = vec3(fx * spacing, fy * spacing, 0.0);
            float sd = length(p - center) - radius;
            if (sd < d) {
                d = sd;
                id = float(idx);
            }
            idx++;
        }
    }

    // Ground plane far below for ambient occlusion feel
    float ground = p.y + 4.0;
    if (ground < d) {
        d = ground;
        id = -1.0;
    }

    return vec2(d, id);
}

// ============================================================
// RAYMARCHING
// ============================================================
vec2 raymarch(vec3 ro, vec3 rd) {
    float t = 0.0;
    float id = -1.0;
    for (int i = 0; i < MAX_STEPS; i++) {
        vec3 p = ro + rd * t;
        vec2 res = mapScene(p);
        if (res.x < SURF_DIST) {
            id = res.y;
            break;
        }
        t += res.x;
        if (t > MAX_DIST) break;
    }
    return vec2(t, id);
}

// ============================================================
// NORMAL ESTIMATION via central differences
// ============================================================
vec3 getNormal(vec3 p) {
    vec2 e = vec2(0.001, 0.0);
    return normalize(vec3(
        mapScene(p + e.xyy).x - mapScene(p - e.xyy).x,
        mapScene(p + e.yxy).x - mapScene(p - e.yxy).x,
        mapScene(p + e.yyx).x - mapScene(p - e.yyx).x
    ));
}

// ============================================================
// PBR MATERIAL PROPERTIES
// Returns: vec4(albedo.rgb, metallic) and roughness via out param
// ============================================================
void getMaterial(float id, out vec3 albedo, out float metallic, out float roughness) {
    // Each sphere gets unique material properties
    // Columns (left to right): increasing roughness
    // Rows (bottom to top): dielectric -> metallic

    int idx = int(id);
    int col = idx - (idx / 3) * 3; // mod 3
    int row = idx / 3;

    // Roughness increases left to right: 0.05, 0.3, 0.7
    float r = 0.05;
    if (col == 1) r = 0.3;
    if (col == 2) r = 0.7;
    roughness = r;

    // Metallic increases bottom to top: 0.0, 0.5, 1.0
    metallic = float(row) * 0.5;

    // Albedo varies to show different material types
    // Bottom row: colored plastics (dielectric)
    // Middle row: semi-metallic
    // Top row: metals (gold, copper, silver)
    if (row == 0) {
        // Dielectric: vibrant colors
        if (col == 0) albedo = vec3(0.9, 0.1, 0.1);      // Red plastic, smooth
        else if (col == 1) albedo = vec3(0.1, 0.8, 0.2);  // Green plastic, medium
        else albedo = vec3(0.1, 0.3, 0.9);                 // Blue plastic, rough
    } else if (row == 1) {
        // Semi-metallic
        if (col == 0) albedo = vec3(0.8, 0.7, 0.6);       // Brushed alloy
        else if (col == 1) albedo = vec3(0.6, 0.6, 0.65);  // Worn steel
        else albedo = vec3(0.5, 0.45, 0.4);                // Rough stone-metal
    } else {
        // Full metals: F0 = albedo for metals
        if (col == 0) albedo = vec3(1.0, 0.78, 0.34);     // Gold, polished
        else if (col == 1) albedo = vec3(0.97, 0.74, 0.62); // Copper, medium
        else albedo = vec3(0.76, 0.76, 0.78);              // Silver, rough
    }
}

// ============================================================
// COOK-TORRANCE BRDF COMPONENTS
// ============================================================

// GGX/Trowbridge-Reitz Normal Distribution Function
// Models the statistical distribution of microfacet orientations
float distributionGGX(float NdotH, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH2 = NdotH * NdotH;
    float denom = NdotH2 * (a2 - 1.0) + 1.0;
    return a2 / (PI * denom * denom);
}

// Smith's method with Schlick-GGX approximation
// Models self-shadowing of microfacets (geometry obstruction)
float geometrySchlickGGX(float NdotV, float roughness) {
    float r = roughness + 1.0;
    float k = (r * r) / 8.0; // k for direct lighting
    return NdotV / (NdotV * (1.0 - k) + k);
}

// Combined geometry term: both view and light directions
float geometrySmith(float NdotV, float NdotL, float roughness) {
    return geometrySchlickGGX(NdotV, roughness) *
           geometrySchlickGGX(NdotL, roughness);
}

// Fresnel-Schlick approximation
// At grazing angles, all surfaces become perfect mirrors
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

// Fresnel-Schlick with roughness for IBL
// Rougher surfaces have less prominent Fresnel at grazing angles
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness) {
    return F0 + (max(vec3(1.0 - roughness), F0) - F0) *
           pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

// ============================================================
// EVALUATE COOK-TORRANCE FOR A SINGLE LIGHT
// The rendering equation: Lo = integral(fr * Li * NdotL) dw
// fr = kD * (albedo/PI) + kS * DGF / (4 * NdotV * NdotL)
// ============================================================
vec3 evalLight(vec3 N, vec3 V, vec3 L, vec3 radiance,
               vec3 albedo, float metallic, float roughness, vec3 F0) {
    vec3 H = normalize(V + L);

    float NdotV = max(dot(N, V), 0.001);
    float NdotL = max(dot(N, L), 0.0);
    float NdotH = max(dot(N, H), 0.0);
    float HdotV = max(dot(H, V), 0.0);

    // Cook-Torrance specular BRDF
    float D = distributionGGX(NdotH, roughness);
    float G = geometrySmith(NdotV, NdotL, roughness);
    vec3 F = fresnelSchlick(HdotV, F0);

    // Specular term with energy-conserving denominator
    vec3 numerator = D * G * F;
    float denominator = 4.0 * NdotV * NdotL + 0.0001;
    vec3 specular = numerator / denominator;

    // kS = Fresnel, kD = 1 - kS (energy conservation)
    // Metals have no diffuse component
    vec3 kD = (vec3(1.0) - F) * (1.0 - metallic);

    // Lambertian diffuse
    vec3 diffuse = kD * albedo / PI;

    return (diffuse + specular) * radiance * NdotL;
}

// ============================================================
// IMAGE-BASED LIGHTING (IBL) APPROXIMATION
// Pre-filtered environment map approximation without textures
// ============================================================
vec3 evalIBL(vec3 N, vec3 V, vec3 albedo, float metallic, float roughness, vec3 F0) {
    float NdotV = max(dot(N, V), 0.001);

    // Fresnel with roughness correction for IBL
    vec3 F = fresnelSchlickRoughness(NdotV, F0, roughness);
    vec3 kD = (vec3(1.0) - F) * (1.0 - metallic);

    // Diffuse IBL: sample environment in normal direction
    // This approximates the irradiance integral
    vec3 irradiance = envMap(N) * 0.4;
    // Add some ground bounce
    irradiance += vec3(0.05, 0.05, 0.06) * max(-N.y, 0.0);
    vec3 diffuse = kD * albedo * irradiance;

    // Specular IBL: sample environment in reflection direction
    // Rougher surfaces use a more scattered reflection
    vec3 R = reflect(-V, N);

    // Approximate pre-filtered environment by blurring via
    // multiple samples biased by roughness
    vec3 prefilteredColor = vec3(0.0);
    float totalWeight = 0.0;
    // Simple 5-tap approximation of blurred reflection
    for (int i = 0; i < 5; i++) {
        float fi = float(i);
        float angle = fi * 1.2566; // 2PI/5
        float spread = roughness * roughness * 0.5;
        // Perturb reflection direction based on roughness
        vec3 tangent = normalize(cross(R, vec3(0.0, 1.0, 0.1)));
        vec3 bitangent = cross(R, tangent);
        vec3 sampleDir = normalize(R +
            tangent * cos(angle) * spread * (fi * 0.2 + 0.1) +
            bitangent * sin(angle) * spread * (fi * 0.2 + 0.1));
        float w = 1.0 / (1.0 + fi * 0.5);
        prefilteredColor += envMap(sampleDir) * w;
        totalWeight += w;
    }
    prefilteredColor /= totalWeight;

    // Approximate the split-sum BRDF integration LUT
    // Using Karis's analytical fit
    float a = roughness;
    float envBRDFx = 1.0 - a;
    float envBRDFy = a;
    // Approximate: scale = F0*brdfX + brdfY
    float oneMinusCos = pow(1.0 - NdotV, 5.0);
    float brdfScale = 1.0 - a * 0.5; // simplified
    vec3 specular = prefilteredColor * (F * brdfScale + oneMinusCos * (1.0 - a) * 0.1);

    return diffuse + specular;
}

// ============================================================
// SOFT SHADOW (optional, for ground contact)
// ============================================================
float softShadow(vec3 ro, vec3 rd, float mint, float maxt) {
    float res = 1.0;
    float t = mint;
    for (int i = 0; i < 32; i++) {
        float h = mapScene(ro + rd * t).x;
        res = min(res, 8.0 * h / t);
        t += clamp(h, 0.02, 0.2);
        if (t > maxt) break;
    }
    return clamp(res, 0.0, 1.0);
}

// ============================================================
// AMBIENT OCCLUSION
// ============================================================
float calcAO(vec3 p, vec3 n) {
    float occ = 0.0;
    float sca = 1.0;
    for (int i = 0; i < 5; i++) {
        float h = 0.01 + 0.12 * float(i);
        float d = mapScene(p + h * n).x;
        occ += (h - d) * sca;
        sca *= 0.95;
    }
    return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}

// ============================================================
// MAIN
// ============================================================
void main() {
    vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;

    // Camera setup with gentle orbit
    float angle = iTime * 0.15;
    vec3 ro = vec3(sin(angle) * 10.0, 2.0, cos(angle) * 10.0 - 5.0);
    vec3 target = vec3(0.0, 0.0, 0.0);
    vec3 forward = normalize(target - ro);
    vec3 right = normalize(cross(forward, vec3(0.0, 1.0, 0.0)));
    vec3 up = cross(right, forward);
    vec3 rd = normalize(forward * 1.8 + right * uv.x + up * uv.y);

    // Raymarch
    vec2 hit = raymarch(ro, rd);
    float t = hit.x;
    float id = hit.y;

    vec3 col;

    if (t < MAX_DIST && id >= 0.0) {
        // Hit a sphere — compute PBR shading
        vec3 p = ro + rd * t;
        vec3 N = getNormal(p);
        vec3 V = -rd;

        // Get material properties for this sphere
        vec3 albedo;
        float metallic, roughness;
        getMaterial(id, albedo, metallic, roughness);

        // F0: reflectance at normal incidence
        // Dielectrics: ~0.04, Metals: use albedo as F0
        vec3 F0 = mix(vec3(0.04), albedo, metallic);

        // Direct lighting from key light
        vec3 lightPos1 = normalize(vec3(0.6, 0.4, -0.7));
        vec3 radiance1 = vec3(1.0, 0.95, 0.85) * 3.5;

        // Secondary fill light
        vec3 lightPos2 = normalize(vec3(-0.5, 0.3, 0.8));
        vec3 radiance2 = vec3(0.3, 0.4, 0.6) * 2.0;

        // Rim / back light
        vec3 lightPos3 = normalize(vec3(0.0, -0.2, 1.0));
        vec3 radiance3 = vec3(0.2, 0.25, 0.4) * 1.5;

        // Accumulate direct lighting
        col = vec3(0.0);
        col += evalLight(N, V, lightPos1, radiance1, albedo, metallic, roughness, F0);
        col += evalLight(N, V, lightPos2, radiance2, albedo, metallic, roughness, F0);
        col += evalLight(N, V, lightPos3, radiance3, albedo, metallic, roughness, F0);

        // Image-based lighting for ambient/environment reflections
        col += evalIBL(N, V, albedo, metallic, roughness, F0);

        // Ambient occlusion
        float ao = calcAO(p, N);
        col *= ao;

    } else if (t < MAX_DIST && id < 0.0) {
        // Hit the ground plane
        vec3 p = ro + rd * t;
        vec3 N = vec3(0.0, 1.0, 0.0);

        // Checkerboard pattern for ground
        float checker = mod(floor(p.x * 0.5) + floor(p.z * 0.5), 2.0);
        vec3 groundAlbedo = mix(vec3(0.15), vec3(0.25), checker);
        float groundRough = 0.8;

        vec3 F0 = vec3(0.04);
        vec3 V = -rd;

        vec3 lightDir = normalize(vec3(0.6, 0.4, -0.7));
        float shadow = softShadow(p + N * 0.01, lightDir, 0.02, 10.0);

        col = evalLight(N, V, lightDir, vec3(2.5) * shadow, groundAlbedo, 0.0, groundRough, F0);
        col += evalIBL(N, V, groundAlbedo, 0.0, groundRough, F0) * 0.5;

        // Fog for ground
        float fog = 1.0 - exp(-0.015 * t * t);
        col = mix(col, vec3(0.15, 0.16, 0.2), fog);
    } else {
        // Background: environment map
        col = envMap(rd);
    }

    // ========================================================
    // HDR TONE MAPPING (ACES filmic approximation)
    // ========================================================
    col = col / (col + vec3(1.0)); // Reinhard as fallback base
    // ACES filmic curve
    vec3 x = col * 2.0; // exposure boost
    col = (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);

    // Gamma correction (linear -> sRGB)
    col = pow(clamp(col, 0.0, 1.0), vec3(1.0 / 2.2));

    // Subtle vignette
    vec2 q = gl_FragCoord.xy / iResolution.xy;
    col *= 0.5 + 0.5 * pow(16.0 * q.x * q.y * (1.0 - q.x) * (1.0 - q.y), 0.15);

    gl_FragColor = vec4(col, 1.0);
}

Understanding the Rendering Equation

All of PBR is rooted in the rendering equation, introduced by James Kajiya in 1986. In its simplified real-time form for direct lighting, the outgoing radiance at a point is:

Lo(p, wo) = integral over hemisphere[ fr(p, wi, wo) * Li(p, wi) * (n dot wi) dwi ]

Where fr is the Bidirectional Reflectance Distribution Function (BRDF), Li is incoming radiance from direction wi, and the cosine term accounts for Lambert's law. In real-time rendering, we approximate this integral by summing contributions from discrete analytical lights and an environment map (IBL).

Microfacet Theory

The Cook-Torrance model treats surfaces as composed of millions of tiny perfect mirrors — microfacets — each oriented slightly differently. The statistical distribution of these orientations determines the material's appearance. A smooth mirror has microfacets all pointing in the same direction; a rough surface has randomly oriented microfacets that scatter light broadly. Three functions capture this behavior:

Normal Distribution Function (NDF) — GGX/Trowbridge-Reitz

The NDF describes what fraction of microfacets are oriented to reflect light from the light direction toward the viewer. GGX (also called Trowbridge-Reitz) is the industry standard because it produces a natural-looking highlight with a long specular tail that matches real-world materials.

// D(h) = alpha^2 / (PI * ((n.h)^2 * (alpha^2 - 1) + 1)^2)
// alpha = roughness^2 (perceptual roughness remapping)
float distributionGGX(float NdotH, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH2 = NdotH * NdotH;
    float denom = NdotH2 * (a2 - 1.0) + 1.0;
    return a2 / (PI * denom * denom);
}

Notice the roughness squaring — this is a perceptual remapping so that roughness 0.5 looks visually "halfway" between smooth and rough, rather than being mathematically halfway. Disney/Unreal popularized this convention.

Geometry Function — Smith-GGX

Microfacets can shadow each other (blocking incoming light) or mask each other (blocking outgoing reflected light). The geometry function G models this self-occlusion. Smith's method separates the view and light directions into independent terms and multiplies them:

// Schlick-GGX approximation for one direction
// k = (roughness + 1)^2 / 8  for direct lighting
// k = roughness^2 / 2        for IBL
float geometrySchlickGGX(float NdotV, float roughness) {
    float r = roughness + 1.0;
    float k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}

// Combined: G(n,v,l,r) = G_view * G_light
float geometrySmith(float NdotV, float NdotL, float roughness) {
    return geometrySchlickGGX(NdotV, roughness)
         * geometrySchlickGGX(NdotL, roughness);
}

At roughness 0, G approaches 1.0 everywhere (no self-shadowing). At high roughness, G significantly dims the specular at grazing angles, preventing the "energy explosion" that would otherwise occur.

Fresnel — Schlick Approximation

Every surface becomes a perfect mirror at grazing angles — this is the Fresnel effect. You see it when looking across a lake: near your feet, you see the lake bottom; far away, you see pure reflection. Schlick's approximation captures this with a simple power-of-5 formula:

// F(cosTheta) = F0 + (1 - F0) * (1 - cosTheta)^5
// F0 = reflectance at normal incidence
//   Dielectrics: ~0.04 (plastic, glass, water)
//   Metals: use albedo color (gold=0.91,0.78,0.34)
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) *
        pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

The F0 parameter encodes the base reflectivity. This is where the metallic/dielectric distinction lives: dielectrics all have approximately 4% reflectance at normal incidence (F0 = 0.04), while metals reflect 50-100% and tint the reflection with their albedo color.

Energy Conservation

A surface cannot reflect more light than it receives. The Fresnel term F tells us what fraction of light is specularly reflected. The remaining energy (1 - F) is available for diffuse scattering. For metals, there is no diffuse component at all — all light is either specularly reflected or absorbed:

// kS = Fresnel reflectance (specular weight)
// kD = 1 - kS, scaled down for metals
vec3 kS = F;
vec3 kD = (vec3(1.0) - kS) * (1.0 - metallic);

// Final BRDF: diffuse + specular
// Diffuse: Lambertian = albedo / PI
// Specular: Cook-Torrance = DGF / (4 * NdotV * NdotL)
vec3 Lo = (kD * albedo / PI + DGF / denom) * radiance * NdotL;

Metallic vs. Dielectric Workflow

Modern PBR uses two artist-facing parameters — metallic (0 or 1, occasionally in between for worn edges) and roughness (0 = mirror, 1 = completely diffuse). This is the "metallic/roughness" workflow used by Unreal, Unity, Godot, and glTF. The alternative is the "specular/glossiness" workflow, which exposes F0 directly. Both produce identical results — they are just different parameterizations of the same Cook-Torrance model.

Key differences between metals and dielectrics:

Dielectrics (plastic, wood, skin, stone): F0 is achromatic ~0.04. They have a strong diffuse component. Albedo map provides the diffuse color. Specular highlights are always white (they reflect the light color, not the surface color).

Metals (gold, copper, iron, aluminum): F0 equals the albedo (which is why metal albedo maps often look "too bright" on their own). No diffuse component. Specular highlights are tinted by the metal's color — this is why gold reflections look gold, not white.

Image-Based Lighting (IBL)

Direct lighting alone produces harsh, unrealistic images. Real scenes are illuminated from all directions — the sky, bounced light off walls, ground reflections. IBL captures this by treating an environment map as a continuous light source. The rendering equation is split into two integrals:

Diffuse IBL: Pre-convolved irradiance map — heavily blurred environment sampled in the normal direction. This gives soft, omnidirectional fill lighting.

Specular IBL: Pre-filtered environment map — progressively blurred at higher roughness, sampled in the reflection direction. Combined with a BRDF integration LUT (the "split-sum" approximation by Karis). In our shader, we approximate this with multiple taps biased by roughness.

HDR and Tone Mapping

PBR lighting operates in linear HDR space — radiance values can exceed 1.0, especially for bright specular highlights and environment lights. Before display, we must compress this range to [0, 1]. The ACES (Academy Color Encoding System) filmic curve is widely used because it gracefully rolls off highlights while preserving color saturation:

// ACES filmic tone mapping
// Maps HDR [0, inf) to LDR [0, 1) with pleasing S-curve
vec3 x = col * exposure;
col = (x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14);

// Don't forget gamma! Linear -> sRGB
col = pow(col, vec3(1.0 / 2.2));

After tone mapping, we apply the sRGB gamma curve. Forgetting this step is one of the most common PBR implementation bugs — it makes everything look washed out and wrong.

What to Look For in the Shader

When you run the shader, observe how the spheres behave:

Bottom-left (red, smooth dielectric): Sharp white specular highlight on a red diffuse body. The highlight is white because dielectrics reflect light color, not surface color. Strong Fresnel rim visible at edges.

Bottom-right (blue, rough dielectric): Broad, soft specular spread. The highlight is much larger but dimmer — energy conservation ensures the total reflected energy stays consistent.

Top-left (gold, smooth metal): Mirror-like reflections tinted gold. No diffuse component at all — the sphere appears dark where it isn't reflecting the environment. Extremely sharp environment reflections.

Top-right (silver, rough metal): Blurred environment reflections across the entire surface. Still no diffuse, but the scattered specular creates a soft, brushed-metal appearance.

This is the power of PBR: two intuitive parameters (metallic and roughness) combined with physically-motivated math produce a vast range of convincing real-world materials, all from the same shader.

This is a clean Cook-Torrance implementation. One thing I'd push back on slightly: clamping NdotL and NdotV to 0.001 instead of 0.0 before the BRDF denominator avoids the divide-by-zero without introducing the dark halo you sometimes get at grazing angles when you clamp harder. Small change but it makes a visible difference on curved surfaces at low roughness values.

float NdotL = max(dot(N, L), 0.001);
float NdotV = max(dot(N, V), 0.001);

Ah man this brings me back, the scanline + phosphor combo is spot on. I actually used a CRT shader a lot like this for a jam game last year and the one addition that really sold it was adding a very subtle barrel distortion. Even a tiny amount makes it feel like you're looking at a curved glass screen:

vec2 barrelDistort(vec2 uv) {
    vec2 centered = uv - 0.5;
    float r2 = dot(centered, centered);
    centered *= 1.0 + 0.15 * r2; // 0.15 controls curvature
    return centered + 0.5;
}

Apply that to your UVs before everything else and it warps the whole image including the scanlines which is what a real CRT does. The corners get slightly cut off which also helps sell the look. You could even darken the edges after the distortion to fake the brightness falloff real CRTs had at the screen borders. Cool shader though, bookmarking this one for reference.

Nice work on this CRT shader. I've been using a similar approach for a retro horror game I'm working on and wanted to share a small tweak that made a big difference for me.

For the barrel distortion, try using a quadratic falloff instead of the standard polynomial. Something like:

vec2 dist = uv * uv * 0.04;
vec2 curved = uv * (1.0 + dist);

It gives a subtler curve that looks more like an actual CRT rather than a fisheye lens. The phosphor glow is a great touch though - I hadn't thought of separating the RGB channels for that. Stealing that idea for sure.

Does anyone know if there's a cheap way to fake the "warm up" effect where the image slowly appears from a horizontal line when you turn on an old TV? That would be perfect for scene transitions.

oh man this brings me back. i spent way too long on a CRT shader for a jam game last year lol

one thing that really sold the effect for me was adding slight color fringing on the phosphor simulation. real CRTs have RGB phosphor triads that don't perfectly overlap, so you get this subtle chromatic separation especially near bright edges. i was sampling the texture 3 times with tiny horizontal offsets for R, G, and B channels. like 0.5-1.0 pixel offset between them.

also if you haven't already, try adding a very subtle horizontal jitter that varies per-scanline and over time. like 1-2 pixels of random horizontal offset. simulates the electron beam not being perfectly stable. combined with the scanlines it really nails that old TV feel.

do you have interlacing in there? alternating which scanlines are bright every other frame is another nice touch for the full retro experience