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

451 views 0 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.

Moonjump
Forum Search Shader Sandbox
Sign In Register