PBR Materials & Lighting
Your mesh is on screen with correct depth, but its material looks right under one light and wrong under the next, metals look like colored plastic, and highlights blow out to flat white. Physically based rendering fixes all three by describing surfaces with a few measurable properties instead of per-scene tweaks. This module builds the metallic-roughness Cook-Torrance model that glTF standardizes and many engines ship: a microfacet BRDF, energy conservation, the right texture color spaces, normal mapping, and HDR tone mapping, with one GLSL shading function at the center. The model is physically based: plausible under its approximations rather than physically exact.
- Read the rendering equation and say which part a real-time shader actually evaluates.
- Implement the Cook-Torrance BRDF with GGX, Smith and Schlick, including the roughness-squared remap.
- Turn glTF's base color, metallic and roughness into F0, diffuse color and α, with each texture in the right color space.
- Apply a tangent-space normal map without the sRGB or green-channel bugs.
- Light with punctual lights, tone-map HDR output, and know what image-based lighting adds.
01What PBR buys
An artist tunes a shiny exponent until a crate looks right in daylight, then the crate looks waxy in the night level and someone tunes it again. Physically based shading models the surface response to light with quantities grounded in physics (, the , a distribution), so a material authored once holds up across lighting setups, and artists author physically meaningful properties (base color, metallic, roughness) instead of tuning a specular exponent per scene.
It's an approximation under energy-conservation constraints, so "based" or "plausible" is the accurate word[6]. And PBR is a family: metallic-roughness vs specular-glossiness parameterizations, GGX vs Beckmann distributions, Lambert vs Disney diffuse. This tutorial teaches the metallic-roughness Cook-Torrance model that glTF 2.0 standardizes[2]; every "the model does X" below is scoped to that variant.
The canonical chart is a grid of spheres across roughness and metallic, under a moving light. The highlight tightens at low roughness and the diffuse vanishes as metallic rises:
02The rendering equation
Every lighting model, from a game shader to a film renderer, is an approximation of one equation, and knowing it tells you what your shader leaves out. Everything traces back to Kajiya's rendering equation: the radiance leaving a point toward the viewer is the emitted radiance plus the integral, over the hemisphere of incoming directions, of the times the incoming radiance times the cosine term[1].
In words: the light leaving a point toward your eye (Lo) is whatever the surface emits (Le, usually zero) plus a sum over every incoming direction. The ∫Ω is that sum: it sweeps the hemisphere of directions ωi above the surface. For each one, take the incoming light Li, multiply by the BRDF (the fraction reflected toward the view direction ωo), and weight by n · ωi, the cosine that dims light arriving at a slant. Hover any symbol to see what it stands for.
Need a refresher on what a hemisphere integral means?
The ∫ sign means "add up a quantity over a continuous range," the continuous version of a sum. Here the range is every direction in the hemisphere (Ω): the half-dome of sky above the surface point, since light can only arrive from in front of the surface, not through it.
Picture splitting that dome into many tiny patches. For each patch you have a direction ωi, the light coming from it, and the cosine weight; you compute the reflected contribution and add them all up. The integral is the exact answer in the limit of infinitely small patches. Real-time rendering can't do that per pixel, so §7 swaps the integral for a finite sum over a few punctual lights, and §9 approximates the rest with image-based lighting.
Lo is outgoing radiance (power per projected area per solid angle) toward the viewer; Le is emitted (nonzero only for emissive surfaces); fr is the BRDF; Li is incoming radiance; (n · ωi) is the Lambert cosine foreshortening; the integral sweeps the hemisphere above the surface.
Real-time can't integrate the hemisphere every pixel, so it replaces the integral with a finite sum over punctual (delta) lights (§7) plus an image-based-lighting approximation (§9). Keep the cosine outside the BRDF (it's part of the equation, not fr): in code, Lo += BRDF * radiance * NdotL[5].
Worked example
One light, one direction: a matte white surface (albedo 0.8) lit by a light that delivers 2.0 units of radiance, arriving 60° from the normal. The Lambert BRDF is 0.8 ÷ π = 0.255, the cosine is cos 60° = 0.5, so this light contributes 0.255 × 2.0 × 0.5 = 0.255 toward the viewer. A punctual light makes the integral exactly this one product; a sky would need the same product summed over every direction.
03The microfacet BRDF
A polished floor shows a sharp highlight and a worn one a broad, dim glow, though both are the same material at a large scale. The difference is at the microscopic scale, so the model describes the surface as a statistical distribution of microscopic mirror facets; roughness controls how spread out their orientations are. The Cook-Torrance specular term is
In words: specular reflection is three statistical terms over a normalizing denominator. D says how many microfacets face the right way to bounce this light to your eye (it sets the highlight's tightness), F is the Fresnel reflectance at those facets, and G discounts facets blocked by their neighbors at grazing angles. The denominator 4 · (n·v) · (n·l) converts the facet statistics back into a per-surface reflectance. Multiply the three on top, divide, and you have the specular lobe.
with the half-vector h = normalize(v + l). The three terms: D is the fraction of facets aligned with h (the normal distribution), F is the Fresnel reflectance at those facets, and G is the fraction not masked or shadowed by neighbors. The choices below (GGX, Smith, Schlick) are the ones Karis adopted for Unreal Engine 4, and they became the common real-time default[3].
- D, GGX/Trowbridge-Reitz: α² / (π·((n·h)²(α²−1)+1)²), with α = roughness². The long tail gives a bright core with a soft falloff.
- G, Smith with Schlick-GGX: masking times shadowing, G₁(v)·G₁(l); the
kremap differs for direct lights versus IBL. - F, Schlick: F₀ + (1−F₀)(1−(v·h))⁵. Reflectance rises to 1 at grazing angles (the rim).
Worked example
GGX's peak, where n·h = 1, is 1 ÷ (π α²). A perceptual roughness of 0.5 gives α = 0.25 and a peak of 5.1; roughness 0.25 gives α = 0.0625 and a peak of 81.5, sixteen times taller and correspondingly narrower: the tight highlight of a polished surface. For F, a dielectric with F₀ = 0.04 reflects 4% head-on, but at a grazing v·h of 0.1 Schlick gives 0.04 + 0.96 × 0.9⁵ = 0.61. That's why the edges of a glass or a wet road reflect so much more than the middle.
The roughness in the UI and texture is perceptual; the BRDF uses its square. glTF, Karis, and Filament all remap alpha = perceptualRoughness²[2][4]. Skip it and α comes out larger than intended, so mid-roughness highlights turn broad and dull and most of the slider reads as rough (the toggle in the §1 widget). One naming hazard: in Filament the user-facing material parameter roughness is perceptual, while inside the BRDF the variable roughness (written a in some listings) is the squared value α. The same word means both, so state your convention once and hold it.
The teaching form below is the textbook D·F·G/(4·NdotV·NdotL) (matching LearnOpenGL); production often folds G and the denominator into a single visibility term V so specular is D·V·F (Filament's height-correlated V_SmithGGXCorrelated)[4]:
const float PI = 3.14159265359;
// D: GGX normal distribution. `alpha` is roughness SQUARED.
float distributionGGX(float NdotH, float alpha) {
float a2 = alpha * alpha;
float denom = NdotH * NdotH * (a2 - 1.0) + 1.0;
return a2 / (PI * denom * denom);
}
// G1: Schlick-GGX for one direction. `k` is remapped from PERCEPTUAL roughness (direct-light form).
float geometrySchlickGGX(float NdotX, float k) {
return NdotX / (NdotX * (1.0 - k) + k);
}
float geometrySmith(float NdotV, float NdotL, float k) {
return geometrySchlickGGX(NdotV, k) * geometrySchlickGGX(NdotL, k); // masking * shadowing
}
// F: Schlick Fresnel. F0 = normal-incidence reflectance (0.04 dielectric, baseColor metal).
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}
vec3 shade(vec3 N, vec3 V, vec3 L, vec3 radiance,
vec3 albedo, float metallic, float perceptualRoughness) {
vec3 H = normalize(V + L);
float NdotV = max(dot(N, V), 1e-4); // floor at 1e-4, not 0: interpolated or normal-mapped normals can push dot(N, V) to 0 or below, which blacks out silhouette pixels; Filament guards the same way (abs + 1e-5)
float NdotL = max(dot(N, L), 0.0); // 0 is fine here: the final *NdotL factor already zeroes the output for back-facing light
float NdotH = max(dot(N, H), 0.0);
float alpha = perceptualRoughness * perceptualRoughness; // THE squaring
float k = (perceptualRoughness + 1.0) * (perceptualRoughness + 1.0) / 8.0; // direct-light k
vec3 F0 = mix(vec3(0.04), albedo, metallic); // dielectric 0.04, metal = baseColor
float D = distributionGGX(NdotH, alpha);
float G = geometrySmith(NdotV, NdotL, k);
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
vec3 specular = (D * G * F) / (4.0 * NdotV * NdotL + 0.0001); // Cook-Torrance
vec3 kD = (vec3(1.0) - F) * (1.0 - metallic); // energy split; metals: 0
vec3 diffuse = kD * albedo / PI; // Lambert
return (diffuse + specular) * radiance * NdotL; // cosine OUTSIDE the BRDF
}
The widget shades one sphere with the shader above:
04Diffuse & energy conservation
A surface can't reflect more light than it receives, and a shader that adds a full diffuse and a full specular term on top of each other does exactly that, so materials glow. The diffuse term is Lambert, albedo / π (albedo is the diffuse base color). Energy conservation splits the budget: specular keeps the Fresnel weight, diffuse gets the rest, kd = (1−F)·(1−metallic). The (1−metallic) factor is why metals have zero diffuse. Reflected diffuse plus specular must not exceed the incoming light.
The single-scatter Cook-Torrance microfacet model loses energy at high roughness: light that should bounce between facets is dropped, so rough metals look too dark. The fix is multi-scatter compensation (Kulla and Conty's; Filament ships a cheaper energy-compensation scale)[8][4]. It's a known limitation of the model rather than an implementation bug. Also: kd = 1 − F is itself a simplification; it treats Fresnel at the half-vector as the whole specular weight.
05Metallic-roughness
An artist hands you a glTF material with a base color, a metallic value and a roughness, and the shader needs F₀, a diffuse color and α. The glTF 2.0 workflow has three core inputs: baseColor, metallic, and roughness. The derived shading quantities[2]:
- F₀ = lerp(0.04, baseColor, metallic): dielectrics reflect 0.04 at normal incidence, metals reflect their base color.
- cdiff = lerp(baseColor, black, metallic); the spec's diffuse term is (1−F)·cdiff/π, the shader's kd path in §3.
- α = roughness².
Worked example
Red plastic: base color (0.8, 0.1, 0.1), metallic 0, roughness 0.4. F₀ = 0.04 in every channel, so its highlight is white; the diffuse color is the full (0.8, 0.1, 0.1), scaled by 1 − F ≈ 0.96 head-on; α = 0.16. Red metal: same base color, metallic 1. F₀ = (0.8, 0.1, 0.1), so the highlight itself is red, and the diffuse color is black. The only change was one number, and it's what separates a plastic toy from a copper pan. The 0.04 itself is Fresnel at normal incidence for an index of refraction of 1.5: ((1.5 − 1) ÷ (1.5 + 1))² = 0.04.
0.04 corresponds to an index of refraction around 1.5; F0 for common dielectrics varies roughly 0.02 to 0.05 (gemstones sit higher), and some workflows expose IOR or a reflectance slider instead[7]. Metals have colored (tinted) specular; dielectrics have achromatic specular plus colored diffuse. Each texture's color space is a correctness issue (see Textures): baseColor and emissive are sRGB; metallic-roughness (green = roughness, blue = metallic), normal, and occlusion (red channel) are linear. Occlusion is often packed with metallic-roughness as ORM (R=occlusion, G=roughness, B=metallic). Sample a data texture through an sRGB view and you corrupt the values.
#include <glm/glm.hpp>
// std140-friendly: vec4s keep 16-byte alignment (cross-ref Going 3D). Textures via descriptors.
struct MaterialFactors {
glm::vec4 baseColorFactor; // rgba
float metallicFactor;
float roughnessFactor;
float normalScale;
float occlusionStrength;
glm::vec4 emissiveFactor; // rgb + pad
};
// Guards the memcpy into the UBO: a reordered field or stray padding would
// silently skew every material, and this fails the build instead.
static_assert(sizeof(MaterialFactors) == 48, "must match the std140 block in the shader");
#[repr(C)]
#[derive(Clone, Copy)]
struct MaterialFactors {
base_color_factor: [f32; 4],
metallic_factor: f32,
roughness_factor: f32,
normal_scale: f32,
occlusion_strength: f32,
emissive_factor: [f32; 4], // rgb + pad for std140
}
// Guards the byte copy into the UBO, same check as the C++ static_assert. For the
// actual upload, derive bytemuck::Pod + Zeroable to cast &MaterialFactors to bytes safely.
const _: () = assert!(std::mem::size_of::<MaterialFactors>() == 48);
06Normal mapping
Modeling every rivet and brick seam as geometry would cost millions of triangles; a normal map fakes them on a flat surface by telling the lighting which way each texel faces. A normal map stores per-texel surface normals in tangent space (the surface's local frame). The TBN matrix (tangent, bitangent, normal) transforms a sampled normal into world space for lighting. Stored values are a direction, not a color, hence linear; the [−1,1]→[0,1] encode maps a flat normal (0, 0, 1) to (0.5, 0.5, 1.0), the characteristic light-blue cast.
Worked example
A texel whose surface leans toward +x has tangent-space normal (0.6, 0, 0.8). Encoding maps each component c to (c + 1) ÷ 2: (0.8, 0.5, 0.9), stored as bytes (204, 128, 230). The shader reverses it with 2 × value − 1 and gets (0.6, 0, 0.8) back. Sample the same texture through an sRGB view and a flat texel's 0.5 is first decoded to 0.52.2 = 0.218, which remaps to −0.565: the "flat" normal becomes (−0.565, −0.565, 1), tilted 38.6° off straight up.
A normal map must be sampled through a linear/UNORM view. Sampling it as sRGB is the classic bug (see Textures): the decode bends every vector and tilts the lighting. And the green-channel convention flips bumps: OpenGL-style maps are +Y (green up), DirectX-style are −Y (green down); the names are authoring conventions, not anything either API specifies[11]. The same map under the wrong convention inverts every detail (rivets cave in). The tangent's handedness (the w sign in glTF tangents) feeds the bitangent: B = cross(N, T) * tangent.w. Drop it and mirrored UVs light wrong.
// from a LINEAR/UNORM texture, not sRGB. [0,1] -> [-1,1]
vec3 sampledNormal = texture(normalMap, uv).xyz * 2.0 - 1.0;
// OpenGL (+Y) assumed, which is glTF's convention; for a DirectX (-Y) map: sampledNormal.y = -sampledNormal.y;
vec3 N = normalize(inNormalWS);
vec3 T = normalize(inTangentWS.xyz);
T = normalize(T - dot(T, N) * N); // Gram-Schmidt re-orthogonalize
vec3 B = cross(N, T) * inTangentWS.w; // handedness sign from the glTF tangent
mat3 TBN = mat3(T, B, N);
vec3 normalWS = normalize(TBN * sampledNormal); // tangent space -> world space
The widget lights a flat quad through a normal map:
07Punctual lights
A torch on a wall should light the floor near it brightly and the far corner barely at all, and the sun should light everything the same. Point, directional, and spot lights are punctual: a delta with zero solid angle, so the hemisphere integral collapses to a single BRDF evaluation per light, Lo += shade(...) summed over lights. Point lights fall off as inverse-square (1/d²) in the irradiance they deliver; directional lights deliver constant irradiance; spots add an angular cone.
Raw 1/d² is the physically correct point-light falloff, but it reaches infinitely far and blows up at d→0, so engines usually add a smooth range window (Karis and Filament both use a windowed inverse-square)[3][4].
Worked example
A point light delivers a quarter of the irradiance at 4 m that it does at 2 m. Karis's windowed falloff for a light with a 10 m range is (1 − (d ÷ 10)4)² ÷ (d² + 1), clamped at 0. At 5 m that's (1 − 0.0625)² ÷ 26 = 0.034, close to the raw 1 ÷ 25 = 0.04; at 10 m it's exactly 0, so the engine can skip the light for anything beyond its range, and at 0 m it's 1 instead of infinity.
08HDR & tone mapping
A sunlit wall and the inside of a cave can differ in brightness by a factor of thousands, but the display only shows values from 0 to 1. Lighting results are unbounded (a bright light times albedo easily exceeds 1.0), so you render to a float/HDR target, apply exposure, then a tone-mapping operator that compresses [0,∞) into [0,1], then sRGB-encode for the display.
A clamp/saturate clips highlights flat to white (hard detail and hue loss); an operator compresses the rolloff so highlight detail survives. Reinhard is L/(1+L)[9]; the popular ACES fits (Narkowicz, Hill) approximate the ACES reference rendering and output transforms, a film-style look that is widely used but not neutral: the author of the simple luminance-only fit notes that it "over saturates brights"[10]. Order matters: exposure → tone-map → sRGB-encode. Do lighting in linear; a manual pow(1/2.2) on top of an sRGB swapchain is the double-correction bug (see Textures).
Worked example
Three pixels after lighting: 0.25, 1.0 and 4.0. A clamp outputs 0.25, 1.0 and 1.0, so the bright wall and the blinding window come out the same white. Reinhard's L ÷ (1 + L) outputs 0.2, 0.5 and 0.8: everything is compressed, but the order and the detail survive. The sRGB encode then turns the middle 0.5 into a stored 188 out of 255.
vec3 color = hdrColor * exposure; // HDR scene radiance, exposure-scaled
color = color / (color + vec3(1.0)); // Reinhard; many engines use a filmic curve such as an ACES fit instead
// Approximate sRGB encode (gamma 1/2.2; the real sRGB OETF is piecewise) for a
// UNORM swapchain. If the swapchain is an sRGB format, DROP this (hardware
// encodes) -- doing both is the double-correction bug.
color = pow(color, vec3(1.0 / 2.2));
09IBL overview
A chrome sphere in a sunset scene should reflect the orange sky, and a few point lights can't produce that. Punctual lights cover direct light; ambient/environment light comes from image-based lighting. Diffuse IBL uses an irradiance map (the environment convolved with the cosine lobe). For specular, Karis's split-sum splits the reflection integral into two precomputed pieces[3]: a prefiltered environment map (the specular environment pre-blurred per roughness into mips) and a BRDF integration LUT (a 2D table on NdotV and roughness giving a scale and bias on F0). Specular IBL is then prefiltered · (F₀·scale + bias).
Worked example
A 256 × 256 environment cubemap has 9 mip levels, 256 down to 1. With the common linear mapping, a roughness-0.5 surface samples mip 0.5 × 8 = 4, the 16 × 16 level, blurred to match that roughness's lobe. Say the LUT returns scale 0.8 and bias 0.05 for this view angle and roughness: a dielectric with F₀ = 0.04 then gets 0.04 × 0.8 + 0.05 = 0.082 of the prefiltered color as its specular reflection.
This section maps the territory; cubemap capture, the importance-sampled prefilter, and generating the LUT are out of scope here, and LearnOpenGL's IBL chapters walk through all three[5]. The split-sum's prefilter assumes view = normal = reflection, so it can't produce the stretched reflections at grazing angles, a limitation Karis notes himself[3]. Ambient occlusion darkens the ambient term in crevices. It's a coarse approximation of occluded ambient light and doesn't bounce light, so it isn't global illumination.
10Pitfalls
11What's next
The surface shades like a real material under direct light. It's still missing the thing that grounds objects in a scene: shadows. The next module, Shadow Mapping, builds it (rendering depth from the light, the bias problem, PCF, and cascades), then deferred rendering and global illumination. The full 3D path is on the series hub.
- James T. Kajiya. "The Rendering Equation." SIGGRAPH 1986. overview. The integral form real-time rendering approximates.
- The Khronos Group. glTF 2.0 Specification (metallic-roughness material, Appendix B). registry.khronos.org. F0 = 0.04 dielectric, α = roughness², the texture channels and color spaces.
- Brian Karis. "Real Shading in Unreal Engine 4." SIGGRAPH 2013. selfshadow.com. The GGX/Smith/Schlick choices, windowed lights, and the split-sum IBL.
- Google. "Physically Based Rendering in Filament." google.github.io/filament. The most rigorous free reference: the BRDF GLSL, the roughness remap, and multi-scatter compensation.
- Joey de Vries. LearnOpenGL, "PBR" (Theory / Lighting / IBL). learnopengl.com. The canonical tutorial GLSL and the reflectance loop.
- Tomas Akenine-Möller, Eric Haines, Naty Hoffman, et al. Real-Time Rendering, 4th ed., ch. 9. realtimerendering.com. Microfacet theory and the physically-based (not correct) framing.
- Sébastien Lagarde. "Memo on Fresnel equations." seblagarde.wordpress.com. F0 from IOR (1.5 → 0.04) and the dielectric F0 range.
- Christopher Kulla and Alejandro Conty. "Revisiting Physically Based Shading at Imageworks." SIGGRAPH 2017. selfshadow.com. Multi-scatter energy loss at high roughness and the compensation fix.
- Erik Reinhard et al. "Photographic Tone Reproduction for Digital Images." SIGGRAPH 2002. cs.utah.edu. The
L/(1+L)operator. - Krzysztof Narkowicz. "ACES Filmic Tone Mapping Curve." knarkowicz.wordpress.com. The simple ACES fit and its caveat that the luminance-only fit "over saturates brights" (ACES is a look, not ground truth).
- Marmoset. "Tangent Space & Handedness." docs.marmoset.co. The OpenGL +Y vs DirectX −Y green-channel convention and the tangent handedness sign.