Three.js From Zero · Article s4-01
PBR Math From Scratch
PBR Math From Scratch
S1-03 used PBR materials. S4-01 builds one. Not from library, from math. If you can write a PBR shader by hand, you can extend, debug, and stylize any material in Three.js. That's the foundation Season 4 is built on.
The demo is three spheres side by side: the left is Three.js's built-in
MeshStandardMaterial. The middle is our hand-written PBR
ShaderMaterial. The right is the difference (pure energy error, should look
nearly black). Adjust material + lighting parameters — both should stay in lockstep,
proving the implementation is correct.
The rendering equation (abbreviated)
For any surface point, the outgoing radiance in the view direction is a sum of light contributions:
L_out(ωᵥ) = ∫ f(ωᵥ, ωₗ) · L_in(ωₗ) · (n · ωₗ) dωₗ
hemisphere
Where f is the BRDF (bidirectional reflectance distribution
function) — the part you author. L_in is light from direction ωₗ.
(n · ωₗ) is the Lambert cosine (surface facing the light).
For a single directional light, the integral collapses:
L_out = f · L_light · (n · ωₗ)
The BRDF has two parts
f = f_diffuse + f_specular
Diffuse is the matte, directionless scattered light (Lambertian). Specular is the mirror-like highlight (Cook-Torrance microfacet model). Real surfaces have both in varying ratios — metalness sliders between "all specular, tinted by base color" and "some diffuse + neutral specular".
Diffuse term (Lambert)
f_diffuse = (albedo / π) · (1 - metalness)
Metals have no diffuse component (all their visible light is the mirror highlight). The division by π is an energy normalization — without it, integrating over the hemisphere returns more light than came in.
Specular term (Cook-Torrance)
f_specular = (D · F · G) / (4 · (n·ωᵥ) · (n·ωₗ))
Three microfacet functions, each with a specific role:
| Term | Meaning |
|---|---|
| D | Normal Distribution — probability that microfacets are oriented to reflect toward view |
| F | Fresnel — reflectivity depends on angle (more at grazing) |
| G | Geometry / visibility — microfacets shadow and mask each other |
D — GGX / Trowbridge-Reitz
The industry standard since Burley's Disney paper (2012). Concentrated highlight with a long tail — realistic.
float D_GGX(float NoH, float roughness) {
float a = roughness * roughness;
float a2 = a * a;
float denom = NoH * NoH * (a2 - 1.0) + 1.0;
return a2 / (3.14159 * denom * denom);
}
Note roughness² — Burley's convention. Makes the slider feel linear.
F — Schlick's Fresnel approximation
Real Fresnel is expensive. Schlick's approximation is within ~1% and one multiply:
vec3 F_Schlick(float VoH, vec3 F0) {
return F0 + (1.0 - F0) * pow(1.0 - VoH, 5.0);
}
F0 is the reflectivity at normal incidence. For dielectrics (non-metals),
a universal 0.04 (4% — plastic, glass, skin). For metals, the base color itself
— metals reflect their tint.
vec3 F0 = mix(vec3(0.04), baseColor, metalness);
Metalness at 0.5 is physically nonsensical — a pixel is either metallic or not — but it smoothly blends the F0 for transition zones (edges, paint wear).
G — Smith's height-correlated visibility
float V_SmithGGX(float NoV, float NoL, float roughness) {
float a = roughness * roughness;
float GGXV = NoL * sqrt(NoV * NoV * (1.0 - a) + a);
float GGXL = NoV * sqrt(NoL * NoL * (1.0 - a) + a);
return 0.5 / (GGXV + GGXL); // this is V = G / (4 · NoV · NoL) combined
}
This is the "visibility" form — it absorbs the 1 / (4 · NoV · NoL)
denominator from the specular formula, so when you multiply by D and F you're done:
float D = D_GGX(NoH, roughness);
vec3 F = F_Schlick(VoH, F0);
float V = V_SmithGGX(NoV, NoL, roughness);
vec3 specular = D * V * F;
Putting it together
vec3 evalPBR(vec3 N, vec3 V, vec3 L, vec3 albedo, float metalness, float roughness) {
vec3 H = normalize(V + L);
float NoV = max(dot(N, V), 0.001);
float NoL = max(dot(N, L), 0.0);
float NoH = max(dot(N, H), 0.0);
float VoH = max(dot(V, H), 0.0);
vec3 F0 = mix(vec3(0.04), albedo, metalness);
// Diffuse
vec3 diffuse = (1.0 - metalness) * albedo / 3.14159;
// Specular
float D = D_GGX(NoH, roughness);
vec3 F = F_Schlick(VoH, F0);
float V_ = V_SmithGGX(NoV, NoL, roughness);
vec3 specular = D * V_ * F;
// Energy-conservation: kD = (1 - kS) where kS is fresnel-weighted
vec3 kD = (vec3(1.0) - F) * (1.0 - metalness);
vec3 diffuseContrib = kD * albedo / 3.14159;
return (diffuseContrib + specular) * NoL;
}
Multiply by incoming light color × intensity. Add ambient (from envmap, see S4-02). Done.
Why the math matters
Three.js's MeshStandardMaterial runs exactly this math (plus envmap
integration, normal mapping, etc.). But once you know it, you can:
- Swap the D function for Beckmann, Phong, or anisotropic variants
- Implement clearcoat, sheen, transmission, iridescence (S1-03's Physical extras) as additional lobes
- Author non-photoreal BRDFs (cel-shaded, toon, cross-hatched) by replacing specific terms
- Debug visually — render D, G, F individually (the demo's view modes do this)
- Optimize — skip terms on distant objects, share calculations across passes
Energy conservation — why PBR physicalizes
A surface can't reflect more light than falls on it. The terms above guarantee this:
Dintegrates to 1 over the hemisphere (probability distribution)- Fresnel's reflected + refracted halves always sum to ≤ 1
kD = 1 - kS— whatever light the specular doesn't reflect, the diffuse scatters
Break any of these and bright surfaces will emit light they weren't given — the telltale look of non-PBR materials.
What's missing here (and in later articles)
- Indirect lighting from environment maps — S4-02 covers IBL
- Area lights — LTCs (Linearly Transformed Cosines)
- Clearcoat — a second specular lobe on top
- Sheen — a retroreflective layer for cloth
- Subsurface scattering — light bouncing inside the material (S4-06)
- Anisotropic highlights — brushed metal, hair (S4-07)
Common first-time pitfalls
- Black pixels at glancing angles.
NoVorNoLis going negative. Clamp withmax(dot, 0.001)for NoV (avoid divide-by-zero) andmax(dot, 0)for NoL. - Specular too bright at edges. Missing G/V term, or wrong D formula. Compare against MeshStandardMaterial.
- Roughness feels wrong at low values. You're not using roughness² — remember Burley's convention.
- Metal has diffuse contribution. Forgot the
(1 - metalness)on the diffuse term. - Highlights look like sharp dots. GGX at very low roughness is aliased. Clamp roughness to 0.05+ or multisample.
- Colors wrong in dark areas. Linear space violation. Ensure textures are sRGB-decoded on input and your output is tonemapped + sRGB-encoded.
Exercises
- Anisotropic GGX: replace the symmetric D with an anisotropic variant using tangent + bitangent. Render brushed metal.
- Oren-Nayar diffuse: replace Lambertian with Oren-Nayar for better matte surfaces (chalk, clay).
- Multi-scattering compensation: at high roughness, GGX energy-compensates via a compensation term. Research + add it.
What's next
S4-02 — IBL Internals. Environment maps → irradiance + prefiltered specular. The PMREM pipeline (from S1-04) explained from the equation up, with a roll-your-own version.