All tutorials Mighty Professional
Build a Game Engine · 3D Rendering

PBR Materials & Lighting

The mesh has a normal and the depth buffer resolves it correctly; now make it look like a real material. We build the metallic-roughness Cook-Torrance model that glTF standardizes and most engines ship: a microfacet BRDF, energy conservation, the right texture color spaces, normal mapping, and HDR tone mapping. The shader core is the centerpiece. This is physically based, not physically correct.

Time~30 min LevelSenior PrereqsThe Going 3D tutorial (mesh, normal, UBOs), Textures (sRGB vs linear), and 3D Math (the inverse-transpose). StackGLSL · C++ & Rust
◂ Build a Game Engine Phase 7 · 3D Rendering Next · Shadow Mapping ▸

01What PBR buys

Physically based shading models the surface response to light with quantities grounded in physics (, the , a distribution), so a material authored once reads correctly under any lighting, and artists author measured properties (base color, metallic, roughness) instead of tuning a specular exponent per scene.

Physically based, not physically correct, and not one equation

It's an approximation under energy-conservation constraints; "based"/"plausible" is the honest 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: spheres across roughness and metallic, under a moving light. The highlight tightens at low roughness and the diffuse vanishes as metallic rises:

Roughness left→right, metallic bottom→top, one orbiting light. The highlight tightens toward the left and spreads toward the right; the top row (metals) loses its diffuse body and reflects the light tinted. Uncheck "square the roughness" and the mid-roughness highlights snap unnaturally sharp, a commonly missed PBR detail (§3).

02The rendering equation

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].

eq. 1 · the rendering equation Lo = Le + Ω fr (ωi, ωo) · Lii) · (n · ωi) dωi

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.

This is the target, not what we evaluate

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].

03The microfacet BRDF

Model the surface as a statistical distribution of microscopic mirror facets; roughness controls how spread out their orientations are. The Cook-Torrance specular term is

eq. 2 · Cook-Torrance specular fspecular = D · F · G4 · (n·v) · (n·l)

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 that became the real-time default[3].

α = roughness², a commonly missed line

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 mid-roughness highlights come out wrongly sharp (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]:

The PBR shader core: one punctual light (GLSL)
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
}

Drag roughness, metallic, base color, and the light to see the highlight reshape and the diffuse/specular split shift:

The inset plots the GGX distribution D, the highlight-tightness term, not the full reflected lobe (that also needs F, G, and the view direction); it narrows as roughness drops. At low roughness the true D peak is far taller than the inset, so the plot clips at the rim (the on-canvas note flags it) instead of rescaling. Push metallic to 1 and the diffuse body goes dark while the specular takes the base-color tint; drag base color and the highlight tint follows it (metals: colored specular, no diffuse). Sweep the light to grazing and the Fresnel rim brightens toward white.

04Diffuse & energy conservation

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.

Energy conservation here is approximate

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-Conty; Filament ships a scaled version)[8]. It's a known limitation, not a bug. Also: kd = 1 − kS is itself a simplification; it treats Fresnel as the whole specular weight.

05Metallic-roughness

The glTF 2.0 workflow has three core inputs: baseColor, metallic, and roughness. The derived shading quantities[2]:

F0 = 0.04 is a common approximation, and the texture set has strict color spaces

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. The texture set, with color spaces that are a correctness issue (cross-ref 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.

The material factor block (CPU side)
#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

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.

Sample it linear, and mind the green-channel convention

A normal map must be sampled as linear/UNORM, not sRGB, the classic bug (cross-ref Textures): an sRGB 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.

Tangent-space normal fetch + TBN (GLSL)
// from a LINEAR/UNORM texture, not sRGB. [0,1] -> [-1,1]
vec3 sampledNormal = texture(normalMap, uv).xyz * 2.0 - 1.0;
// OpenGL (+Y) assumed; 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

Toggle the map and rotate the light to see faked relief on a flat quad; flip the color-space toggle to see the bug:

The quad is flat; the normal map fakes relief that responds to the moving light. Tick "sample as sRGB" and the lighting goes subtly then obviously wrong, the bug from the Textures tutorial. Tick "flip green" and every bump inverts, the +Y versus −Y convention mismatch.

07Punctual lights

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.

Inverse-square, with a window

Raw 1/d² is the physically correct point-light falloff, but it reaches infinitely far and blows up at d→0, so engines add a smooth range window (Karis and Filament use a windowed inverse-square)[3]. Don't ship raw 1/d².

08HDR & tone mapping

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.

Tone mapping is not a clamp, and ACES is a look, not "correct"

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) are approximations of a film-look pipeline, widely used but not perceptually neutral, the author of the simple fit notes it oversaturates 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 (cross-ref Textures).

Tone-map + encode (GLSL)
vec3 color = hdrColor * exposure;         // HDR scene radiance, exposure-scaled
color = color / (color + vec3(1.0));    // Reinhard (swap an ACES fit in production)
// 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

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).

A preview, with a known error

This section is the map of the territory; cubemap capture, the importance-sampled prefilter, and generating the LUT are a later module. The split-sum's prefilter assumes view = normal = reflection, so it can't produce the stretched reflections at grazing angles, the most-cited split-sum limitation. And note: ambient occlusion darkens the ambient term in crevices, it's a coarse approximation of occluded ambient light, not global illumination (it doesn't bounce light).

10Pitfalls

Roughness not squaredThe BRDF uses α = roughness². Mid-roughness highlights come out too sharp otherwise.
Metal with a diffuse bodyMetals have zero diffuse and colored specular (F0 = base color). Apply (1−metallic).
Normal map as sRGBIt stores directions; sample linear/UNORM or the lighting tilts.
Wrong green-channel convention+Y (OpenGL-style) vs −Y (DirectX-style). Mismatch inverts every bump.
Data texture as sRGBMetallic-roughness and occlusion are linear; only base color and emissive are sRGB.
Clamp instead of tone mapClamping clips highlights to white; an operator compresses them.
Double gammaManual pow(1/2.2) on top of an sRGB swapchain over-brightens. Pick one.
"PBR is physically correct"It's physically based; single-scatter loses energy at high roughness.

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 builds shadow mapping (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.

  1. James T. Kajiya. "The Rendering Equation." SIGGRAPH 1986. overview. The integral form real-time rendering approximates.
  2. 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.
  3. Brian Karis. "Real Shading in Unreal Engine 4." SIGGRAPH 2013. selfshadow.com. The GGX/Smith/Schlick choices, windowed lights, and the split-sum IBL.
  4. 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.
  5. Joey de Vries. LearnOpenGL, "PBR" (Theory / Lighting / IBL). learnopengl.com. The canonical tutorial GLSL and the reflectance loop.
  6. 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.
  7. Sébastien Lagarde. "Memo on Fresnel equations." seblagarde.wordpress.com. F0 from IOR (1.5 → 0.04) and the dielectric F0 range.
  8. 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.
  9. Erik Reinhard et al. "Photographic Tone Reproduction for Digital Images." SIGGRAPH 2002. cs.utah.edu. The L/(1+L) operator.
  10. 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).
  11. Marmoset. "Tangent Space & Handedness." docs.marmoset.co. The OpenGL +Y vs DirectX −Y green-channel convention and the tangent handedness sign.

See also