BRDF and Materials — Physically-Based Rendering
In this tutorial, you'll learn about BRDF and Materials. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
The Bidirectional Reflectance Distribution Function (BRDF) describes how light reflects at an opaque surface, defining the mathematical foundation for physically-based rendering by relating incident and outgoing radiance.
What You'll Learn & Why It Matters
In this tutorial, you will learn BRDF theory — the microfacet model, the Cook-Torrance BRDF, Fresnel equations, energy conservation principles, and how to build a physically-based material system. PBR materials are the modern standard for realistic rendering.
Real-world use: Substance Painter, Unreal Engine, and Unity all use PBR material systems based on microfacet BRDFs. Understanding BRDFs helps you create accurate materials and diagnose rendering artifacts.
Prerequisites
- Lighting Models (previous)
- Path Tracing basics
- Calculus and probability
Learning Path
flowchart LR A[Lighting Models] --> B[BRDF and Materials] B --> C[Path Tracing] B --> D[Real-Time GI] B --> E[Procedural Textures] C --> F[Acceleration Structures] B:::current classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px
What Is a BRDF?
A BRDF, denoted f_r(wi, wo), answers the question: given light incoming from direction wi, how much light reflects toward direction wo? It is defined as the ratio of reflected radiance to incident irradiance.
f_r(wi, wo) = dL_r(wo) / dE_i(wi)
The BRDF has two important properties:
- Helmholtz reciprocity:
f_r(wi, wo) = f_r(wo, wi)— swapping incident and outgoing directions gives the same value - Energy conservation: The integral of the BRDF over all outgoing directions must be <= 1
Lambertian BRDF (Diffuse)
The simplest BRDF models perfect diffuse Reflection:
import numpy as np
def lambertian_brdf(albedo):
"""Lambertian diffuse BRDF - light scatters equally in all directions."""
return albedo / np.pi
def evaluate_lambertian(albedo, ndotl):
"""Direct lighting evaluation."""
brdf = lambertian_brdf(albedo)
return brdf * ndotl
albedo = np.array([0.8, 0.2, 0.2])
ndotl = 0.7
result = evaluate_lambertian(albedo, ndotl)
print(f"Lambertian BRDF: {lambertian_brdf(albedo)}")
print(f"With ndotl={ndotl}: {result}")
Expected output:
Lambertian BRDF: [0.255 0.064 0.064]
With ndotl=0.7: [0.178 0.045 0.045]
The Microfacet Model
Real surfaces are not perfectly smooth at the microscopic level. The microfacet model assumes the surface consists of many tiny perfect mirrors (microfacets) with varying orientations.
flowchart TD
A[Microfacet Surface] --> B[Surface Normal N]
A --> C[Microfacet Normals m]
D[Light Direction L] --> A
E[View Direction V] --> A
A --> F[Only microfacets with m = half-vector H contribute]
subgraph D[Statistical Distribution]
G[NDF: Normal Distribution Function]
H[Shadowing-Masking: G]
I[Fresnel: F]
end
Cook-Torrance BRDF
The Cook-Torrance microfacet BRDF is the standard for PBR:
f_r(wi, wo) = (F * G * D) / (4 * dot(N, wi) * dot(N, wo))
Where:
- D: Normal Distribution Function — distribution of microfacet orientations
- F: Fresnel term — reflectivity as a function of viewing angle
- G: Geometry function — shadowing and masking between microfacets
def ggx_ndf(NdotH, roughness):
"""GGX/Trowbridge-Reitz normal distribution function."""
a = roughness * roughness
a2 = a * a
denom = np.pi * (NdotH * NdotH * (a2 - 1.0) + 1.0)
return a2 / (denom * denom)
def schlick_fresnel(cos_theta, F0):
"""Schlick approximation of Fresnel."""
return F0 + (1.0 - F0) * pow(max(1.0 - cos_theta, 0.0), 5.0)
def smith_ggx_geometry(NdotV, roughness):
"""Smith GGX geometry function."""
k = (roughness + 1.0) * (roughness + 1.0) / 8.0
return NdotV / (NdotV * (1.0 - k) + k)
def cook_torrance(NdotL, NdotV, NdotH, HdotV, roughness, F0, albedo, metallic):
"""Complete Cook-Torrance BRDF."""
D = ggx_ndf(NdotH, roughness)
F = schlick_fresnel(HdotV, F0)
G = smith_ggx_geometry(NdotL, roughness) * smith_ggx_geometry(NdotV, roughness)
specular = (D * F * G) / (4.0 * NdotL * NdotV + 0.0001)
diffuse = (1.0 - F) * (1.0 - metallic) * albedo / np.pi
return diffuse + specular
PBR Material Parameters
class PBRMaterial:
def __init__(self, albedo, metallic, roughness, ao=1.0):
self.albedo = np.array(albedo) # Base color (no lighting)
self.metallic = metallic # 0 = dielectric, 1 = metal
self.roughness = roughness # 0 = smooth, 1 = rough
self.ao = ao # Ambient occlusion
def evaluate(self, N, L, V):
NdotL = max(np.dot(N, L), 0.001)
NdotV = max(np.dot(N, V), 0.001)
H = (L + V) / np.linalg.norm(L + V)
NdotH = max(np.dot(N, H), 0.001)
HdotV = max(np.dot(H, V), 0.001)
F0 = np.interp(self.metallic, [0, 1], [0.04, self.albedo])
result = cook_torrance(NdotL, NdotV, NdotH, HdotV, self.roughness, F0, self.albedo, self.metallic)
return result * NdotL
# Example materials
gold = PBRMaterial([1.0, 0.76, 0.33], 1.0, 0.1, 1.0)
plastic = PBRMaterial([0.8, 0.2, 0.2], 0.0, 0.4, 1.0)
Image-Based Lighting (IBL)
def sample_environment_map(env_map, direction):
"""Sample HDR environment map for IBL."""
u = 0.5 + np.arctan2(direction[2], direction[0]) / (2 * np.pi)
v = 0.5 - np.arcsin(direction[1]) / np.pi
h, w = env_map.shape[:2]
x = int(u * w) % w
y = int(v * h) % h
return env_map[y, x]
Common Errors & Mistakes
1. Missing Division by Pi
Mistake: Using albedo directly as the diffuse BRDF without dividing by pi, making surfaces too bright.
Fix: The Lambertian BRDF is albedo / pi. The pi factor ensures energy conservation when integrating over the hemisphere.
2. Double-Counting Fresnel
Mistake: Applying Fresnel to both specular and diffuse terms, darkening the diffuse too much.
Fix: Fresnel applies only to the specular term. The diffuse term uses (1 - F) * (1 - metallic) as its weight.
3. Roughness Remapping
Mistake: Using roughness directly in the NDF without squaring it.
Fix: GGX uses a = roughness * roughness. This perceptual remapping makes roughness changes more uniform visually.
4. Acne from Small Denominator
Mistake: Dividing by zero in the Cook-Torrance formula when NdotL or NdotV is near zero.
Fix: Add a small epsilon (1e-4) to the denominator: 4 * NdotL * NdotV + 0.0001.
Practice Questions
Question 1
What are the two key properties of any valid BRDF?
Show answer
Helmholtz reciprocity (f(wi, wo) = f(wo, wi)) and energy conservation (integral of BRDF over all outgoing directions <= 1).Question 2
Why does the microfacet model use three separate functions (D, F, G)?
Show answer
D describes the statistical distribution of microfacet orientations, F describes Fresnel Reflection per microfacet, and G handles shadowing/masking between microfacets. Separating them allows independent control.Question 3
What is the physical meaning of metallic = 1.0?
Show answer
Metallic = 1.0 means the material is a pure conductor. It reflects all light as specular (tinted by the albedo/F0) and has zero diffuse component. Light does not enter the surface.Question 4
Why do smooth surfaces (low roughness) have bright, small highlights?
Show answer
Low roughness means microfacets are nearly aligned with the surface normal. Only a narrow set of light/view directions satisfy the mirror Reflection condition, producing bright but small highlights.Challenge
Create a PBR material gallery application that renders spheres with varying roughness (0 to 1) and metallic (0 to 1) parameters under an HDR environment map. Support real-time parameter adjustment and export material presets.
FAQ
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Author: DodaTech | Last updated: June 21, 2026
DodaTech tutorials are built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — security tools used by millions worldwide.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro