./jerome{m}

~/Notes/Physically Based Rendering.md · note · seedling · 4 min · updated

Physically Based Rendering

// status: seedling

"PBR" as shipped in modern engines is roughly 30% physics, 70% convention. The physics gives it consistency across lighting conditions; the conventions make it authorable by artists. These are the bits I keep coming back to.

The rendering equation, in one paragraph

Outgoing radiance L_o at a point, in direction ω_o, equals emission plus the hemispherical integral of incoming radiance L_i weighted by the BRDF f_r and the cosine term:

L_o(\mathbf{x}, \omega_o) = L_e + \int_{\Omega} f_r(\mathbf{x}, \omega_i, \omega_o) \, L_i(\mathbf{x}, \omega_i) \, (\omega_i \cdot \mathbf{n}) \, d\omega_i

Everything in real-time PBR is either (a) picking a good f_r, or (b) approximating that integral.

The microfacet BRDF

The model used by basically every game engine since ~2013. The idea: a macro-scale surface is made of tiny mirror-like microfacets. How rough the surface is tells you how scattered their orientations are. Three factors:

f_r = \frac{D(h) \, F(\omega_o, h) \, G(\omega_i, \omega_o, h)}{4 \, (\omega_i \cdot \mathbf{n})(\omega_o \cdot \mathbf{n})}

  • D(h) — Normal Distribution Function. How many microfacets point toward the half-vector h. GGX (Trowbridge-Reitz) won. It has nice tails that match measured materials better than Beckmann or Phong.
  • F — Fresnel. How much light reflects vs refracts at the interface. Schlick's approximation F0 + (1 - F0)(1 - cosθ)^5 is cheap and close enough.
  • G — geometric term / shadowing-masking. How much microfacets occlude each other. Smith height-correlated is the current default; old uncorrelated Smith is still common.

The denominator normalises the BRDF correctly.

GGX in shader code

glsl
float D_GGX(float NoH, float a) {
    float a2 = a * a;
    float d  = (NoH * a2 - NoH) * NoH + 1.0;
    return a2 / (PI * d * d);
}

vec3 F_Schlick(float VoH, vec3 F0) {
    return F0 + (1.0 - F0) * pow(1.0 - VoH, 5.0);
}

float V_SmithGGXCorrelated(float NoV, float NoL, float a) {
    float a2 = a * a;
    float v  = NoL * sqrt(NoV * NoV * (1.0 - a2) + a2);
    float l  = NoV * sqrt(NoL * NoL * (1.0 - a2) + a2);
    return 0.5 / (v + l);
}

a = roughness * roughness — Disney's remap, now ubiquitous. It gives perceptually linear roughness to authors.

Metals vs dielectrics

PBR's cleanest split. Two material behaviours, one model:

  • Dielectrics (wood, plastic, skin, stone) — F0 ≈ 0.04 (grey), diffuse albedo is coloured, specular is white-ish at grazing.
  • Metals — no diffuse; F0 is the coloured reflectance (gold, copper, silver). Diffuse term is zero.

The metallic workflow packs this: one baseColor texture, one metallic mask. At shade time:

glsl
vec3 F0 = mix(vec3(0.04), baseColor, metallic);
vec3 diffuse = mix(baseColor, vec3(0.0), metallic);

Alternative: specular/glossiness workflow with explicit F0. More flexible but more foot-guns; the metallic workflow is better for most asset pipelines.

Energy conservation

The BRDF integrated over the hemisphere must be ≤ 1 (can't reflect more energy than arrives). In practice:

  • Kulla-Conty / multi-scattering compensation — GGX loses energy at high roughness because it only models single-bounce microfacet scattering. Add a compensating term; Filament's implementation is the reference.
  • Split-sum approximation (Epic UE4 course notes) — the classic IBL trick that splits the lighting integral into two precomputable parts.

Without this, rough metals look unnaturally dark.

Image-based lighting (IBL)

For ambient/environment lighting:

  1. Prefilter the environment cube map for a range of roughnesses (mip chain).
  2. Precompute a 2D BRDF integration LUT indexed by (NoV, roughness).
  3. At shade time: sample prefiltered env at reflect(-V, N) with mip = f(roughness); multiply by F0 * LUT.r + LUT.g. Add diffuse from an irradiance cube map.

This is the split-sum. It's everywhere.

Colour, gamma, and why it matters

Materials are authored in sRGB, lit in linear, displayed in sRGB. Three choices you must make consistently:

  • Albedo/base colour textures → upload as *_SRGB so the sampler decodes automatically.
  • Normal maps, roughness, metallic, AO → upload as *_UNORM or *_RGBA8. They're data, not colour.
  • Render into a linear HDR target (usually R16F_G16F_B16F_A16F), tonemap at the end, output to sRGB.

Almost every "why does my metal look wrong" bug is one of the three. See also OpenGL - learning log on sRGB framebuffers.

Tonemapping

Linear HDR → sRGB [0,1] via a tonemapping curve. Common choices:

  • Reinhard — simplest, desaturates highlights.
  • ACES — filmic, current default for games. Stephen Hill's fit is what everyone uses.
  • AgX — newer, perceptually better behaviour in highlights.
  • Uncharted 2 — Hable's old curve, still looks good.

Applied after all lighting, before the gamma encode. Pair with exposure control.

Things worth reading

  • Real Shading in Unreal Engine 4 — Karis / Epic (SIGGRAPH 2013). The paper that productised modern PBR.
  • Physically-Based Shading at Disney — Burley 2012. The paper that broke the clutter.
  • Filament materials guide — Google's open-source engine docs, the clearest modern reference.
  • Moving Frostbite to Physically Based Rendering — Lagarde & de Rousiers. Production-grade treatment of everything from BRDF to area lights.
  • Self-Shadow — shading course archives

Things that tripped me up

  • Linear vs sRGB in textures — explained above; costs me an hour every new project.
  • roughness vs α — α = roughness². Mixing them gives subtly wrong highlights.
  • Negative NoL — clamp max(dot(N, L), 0.0). Backfacing lights should contribute zero.
  • Area lights — point lights are a simplification. Real-time area lights need Linearly Transformed Cosines (Heitz et al.) — hard to derive, easy to copy.
  • Clearcoat, anisotropy, subsurface — the "extensions" to the core model. Add them only when assets need them; each one multiplies the shading cost.

Back to Notes · see also Deferred vs forward rendering · Shadow mapping