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.
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);
}
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).
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:
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.
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.
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.
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;
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.
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.
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.
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.