Three.js From Zero · Article s4-01

PBR Math From Scratch

← threejs-from-zeroS4 · Article 01 Season 4
Article S4-01 · Three.js From Zero

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.

compiling shaders…

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:

TermMeaning
DNormal Distribution — probability that microfacets are oriented to reflect toward view
FFresnel — reflectivity depends on angle (more at grazing)
GGeometry / 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:

  • D integrates 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. NoV or NoL is going negative. Clamp with max(dot, 0.001) for NoV (avoid divide-by-zero) and max(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

  1. Anisotropic GGX: replace the symmetric D with an anisotropic variant using tangent + bitangent. Render brushed metal.
  2. Oren-Nayar diffuse: replace Lambertian with Oren-Nayar for better matte surfaces (chalk, clay).
  3. 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.