Materials and PBR

Physically-based rendering basics

The Phong lighting model served computer graphics well for decades. It produces convincing highlights and diffuse shading with minimal computation. But look closely at a Phong-shaded sphere next to a photograph of a real chrome ball or a matte clay pot—the Phong version looks synthetic, as if covered in plastic film.

Physically-based rendering (PBR) addresses this by modeling how light actually interacts with surfaces. The results look more realistic because the math respects real physics: energy conservation, Fresnel reflections, and microfacet theory. Modern game engines, film production, and visualization tools all use PBR as their foundation.

What Phong Gets Wrong

Phong treats all materials identically except for their shininess exponent. A plastic ball and a metal sphere differ only in how tight their specular highlight is. But in reality, metals and dielectrics (non-metals) behave fundamentally differently.

Metals absorb light that enters them. Their color comes entirely from reflection. Copper looks orange because it reflects more orange wavelengths. Gold reflects yellows and reds. The specular highlight of a metal is tinted by the metal's color.

Dielectrics—plastic, wood, stone, skin—reflect a small amount of light at the surface (specular) and scatter the rest internally (diffuse). Their specular highlight is white or nearly white, regardless of the surface color. A red plastic ball has red diffuse and white specular.

Phong makes no distinction between these cases. Everything gets the same white highlight, making all materials look somewhat plastic.

PBR vs Phong Comparison

Phong
PBR

Notice how PBR metal has tinted reflections matching the surface color, while Phong has the same white highlight as everything else.

The PBR Material Model

PBR simplifies material description to a few intuitive parameters:

Albedo is the base color—what you would see under perfectly diffuse lighting. For metals, this also tints the reflection. For non-metals, this is the diffuse color.

Metallic is a binary-ish property: is this surface metal or not? A value of 1.0 means fully metallic behavior (colored specular, no diffuse). A value of 0.0 means dielectric behavior (white specular, colored diffuse). Values between represent blends, useful for oxidized or dusty metal.

Roughness describes how smooth or rough the surface is at a microscopic level. Low roughness (0.0) gives mirror-like reflections. High roughness (1.0) scatters light in all directions, creating a matte finish. This replaces Phong's arbitrary shininess exponent with a physically meaningful measurement.

Material Property Sliders

Metallic: 0.00Roughness: 0.50

Adjust metallic to switch between dielectric (plastic-like) and metallic behavior. Roughness controls how sharp or diffuse the reflections appear.

These three parameters—albedo, metallic, roughness—describe an enormous range of real materials. Chrome is high metallic, low roughness. Rubber is low metallic, high roughness. Brushed aluminum is high metallic, medium roughness. The system is intuitive because it maps to how we think about real surfaces.

Energy Conservation

A surface cannot reflect more light than it receives. This obvious physical law is called energy conservation, and Phong violates it cheerfully. A Phong material with high diffuse and high specular will reflect more than 100% of incoming light.

PBR enforces conservation. As roughness decreases and reflections become sharper, the specular highlight becomes brighter but smaller. The total reflected energy stays constant. This is why polished metal looks so bright in concentrated areas—it is focusing the same energy into a smaller solid angle.

The math ensures that diffuse and specular contributions sum to at most 1.0. For metals (which have no diffuse), all energy goes to specular. For non-metals, the Fresnel effect determines the split: at glancing angles, more goes to specular; at direct angles, more goes to diffuse.

The Fresnel Effect

Look at a pond on a sunny day. When you look straight down, you see the bottom. When you look at a shallow angle across the surface, you see sky reflection. This angle-dependent reflectivity is the Fresnel effect.

All materials exhibit Fresnel. At grazing angles, everything becomes reflective—even human skin gets a rim of specular at the edges. PBR accounts for this automatically.

The Fresnel term typically uses the Schlick approximation:

fn fresnelSchlick(cosTheta: f32, F0: vec3f) -> vec3f {
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
wgsl

Here, F0 is the reflectance at normal incidence (looking straight at the surface). For dielectrics, this is typically around 0.04. For metals, it equals the albedo color. The function smoothly interpolates toward 1.0 as the angle becomes grazing.

Microfacet Theory

Real surfaces are not perfectly smooth. Even polished metal has microscopic roughness. PBR models this by imagining the surface as countless tiny mirrors (microfacets), each pointing in a slightly different direction.

The roughness parameter controls how spread out these microfacet orientations are. Low roughness means most microfacets point in the same direction—the surface acts nearly like a flat mirror. High roughness means microfacets point everywhere—light scatters diffusely.

This explains why rough surfaces have broad, dim highlights while smooth surfaces have sharp, bright ones. The same total energy reflects, but rough surfaces spread it across more directions.

The distribution of microfacet orientations is typically modeled with the GGX (Trowbridge-Reitz) distribution:

fn distributionGGX(N: vec3f, H: vec3f, roughness: f32) -> f32 {
    let a = roughness * roughness;
    let a2 = a * a;
    let NdotH = max(dot(N, H), 0.0);
    let NdotH2 = NdotH * NdotH;
    
    let num = a2;
    let denom = (NdotH2 * (a2 - 1.0) + 1.0);
    return num / (3.14159 * denom * denom);
}
wgsl

GGX produces highlights with a characteristic long tail that matches real-world observations better than earlier models.

The Geometry Function

Not all microfacets contribute to the final image. Some are shadowed by neighboring microfacets (they cannot see the light), and some are masked (they cannot see the camera). The geometry function accounts for this self-shadowing.

fn geometrySchlickGGX(NdotV: f32, roughness: f32) -> f32 {
    let r = roughness + 1.0;
    let k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}
 
fn geometrySmith(N: vec3f, V: vec3f, L: vec3f, roughness: f32) -> f32 {
    let NdotV = max(dot(N, V), 0.0);
    let NdotL = max(dot(N, L), 0.0);
    let ggx2 = geometrySchlickGGX(NdotV, roughness);
    let ggx1 = geometrySchlickGGX(NdotL, roughness);
    return ggx1 * ggx2;
}
wgsl

This reduces brightness at grazing angles where self-shadowing is most severe.

A Complete PBR Shader

Here is a practical WGSL implementation combining all the pieces:

struct Material {
    albedo: vec3f,
    metallic: f32,
    roughness: f32,
}
 
const PI: f32 = 3.14159265359;
 
fn fresnelSchlick(cosTheta: f32, F0: vec3f) -> vec3f {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}
 
fn distributionGGX(N: vec3f, H: vec3f, roughness: f32) -> f32 {
    let a = roughness * roughness;
    let a2 = a * a;
    let NdotH = max(dot(N, H), 0.0);
    let NdotH2 = NdotH * NdotH;
    
    let denom = (NdotH2 * (a2 - 1.0) + 1.0);
    return a2 / (PI * denom * denom);
}
 
fn geometrySchlickGGX(NdotV: f32, roughness: f32) -> f32 {
    let r = roughness + 1.0;
    let k = (r * r) / 8.0;
    return NdotV / (NdotV * (1.0 - k) + k);
}
 
fn geometrySmith(N: vec3f, V: vec3f, L: vec3f, roughness: f32) -> f32 {
    let NdotV = max(dot(N, V), 0.0);
    let NdotL = max(dot(N, L), 0.0);
    return geometrySchlickGGX(NdotV, roughness) * geometrySchlickGGX(NdotL, roughness);
}
 
fn calculatePBR(
    N: vec3f,        // Surface normal
    V: vec3f,        // View direction
    L: vec3f,        // Light direction
    radiance: vec3f, // Light color/intensity
    material: Material
) -> vec3f {
    let H = normalize(V + L);
    
    // Calculate F0 (reflectance at normal incidence)
    // Dielectrics use 0.04, metals use albedo
    let F0 = mix(vec3f(0.04), material.albedo, material.metallic);
    
    // Cook-Torrance BRDF
    let NDF = distributionGGX(N, H, material.roughness);
    let G = geometrySmith(N, V, L, material.roughness);
    let F = fresnelSchlick(max(dot(H, V), 0.0), F0);
    
    // Specular contribution
    let numerator = NDF * G * F;
    let denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
    let specular = numerator / denominator;
    
    // Energy conservation: what's not reflected is refracted (diffuse)
    let kS = F; // Specular contribution
    var kD = vec3f(1.0) - kS; // Diffuse contribution
    kD *= 1.0 - material.metallic; // Metals have no diffuse
    
    // Lambertian diffuse
    let NdotL = max(dot(N, L), 0.0);
    let diffuse = kD * material.albedo / PI;
    
    return (diffuse + specular) * radiance * NdotL;
}
wgsl

The shader takes normal, view direction, light direction, and material properties. It returns the outgoing radiance—the color contribution from this light. For multiple lights, call this function for each and sum the results.

Material Uniforms

Send material data to the shader through a uniform buffer:

struct MaterialUniforms {
    albedo: vec3f,
    metallic: f32,
    roughness: f32,
    _padding: vec3f, // Alignment padding
}
 
@group(1) @binding(0) var<uniform> material: MaterialUniforms;
wgsl

On the CPU side:

const materialData = new Float32Array([
  // albedo (r, g, b)
  0.8, 0.2, 0.1,
  // metallic
  0.0,
  // roughness
  0.5,
  // padding
  0.0, 0.0, 0.0,
]);
 
device.queue.writeBuffer(materialBuffer, 0, materialData);
typescript

Pay attention to alignment. WebGPU requires vec3f to align to 16 bytes, so we often pad structures.

Texture Maps

Production PBR uses textures to vary material properties across the surface:

Albedo map stores the base color. Sample it in the fragment shader instead of using a uniform color.

Normal map stores per-pixel normals, adding surface detail without geometry. This makes flat surfaces appear bumpy or textured.

Roughness map varies roughness across the surface. A wooden table might be smooth where polished, rough where worn.

Metallic map marks which parts are metal. A robot character might have metal plates and rubber joints.

Ambient occlusion map darkens crevices where ambient light cannot reach. This adds depth and realism.

Material Maps Demo

All maps combined: the rusty areas are rougher and less metallic, while clean metal is smoother and more reflective.

Sampling in the shader:

@group(2) @binding(0) var albedoTexture: texture_2d<f32>;
@group(2) @binding(1) var normalTexture: texture_2d<f32>;
@group(2) @binding(2) var metallicRoughnessTexture: texture_2d<f32>;
@group(2) @binding(3) var textureSampler: sampler;
 
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
    let albedo = textureSample(albedoTexture, textureSampler, input.uv).rgb;
    let normalMap = textureSample(normalTexture, textureSampler, input.uv).rgb;
    let mr = textureSample(metallicRoughnessTexture, textureSampler, input.uv);
    
    // Convert normal map from [0,1] to [-1,1]
    let tangentNormal = normalMap * 2.0 - 1.0;
    
    // Transform to world space using TBN matrix
    let N = normalize(input.TBN * tangentNormal);
    
    let material = Material(
        albedo,
        mr.b, // Metallic in blue channel
        mr.g, // Roughness in green channel
    );
    
    // ... rest of lighting calculation
}
wgsl

Many PBR workflows pack metallic and roughness into a single texture (often with AO in the red channel). This saves texture samples and memory.

Image-Based Lighting

Point lights alone produce harsh results. Real environments have light coming from everywhere—the sky, reflected surfaces, ambient illumination. Image-based lighting (IBL) captures this by using environment maps.

For diffuse IBL, we pre-compute an irradiance map: a blurred version of the environment that represents average incoming light from each direction. For specular IBL, we pre-filter the environment at multiple roughness levels.

IBL is a larger topic, but the core idea is straightforward: sample the environment in the reflection direction, weighted by the BRDF. This gives reflections that look like they belong in the scene.

Material Ball

Albedo
Metallic
1.00
Roughness
0.30

The classic material ball setup: a sphere with ground reflection. Try different presets to see how PBR handles metals, plastics, and everything in between.

Key Takeaways

  • Phong treats all materials identically; PBR distinguishes metals from dielectrics
  • Albedo is base color, metallic determines reflection behavior, roughness controls surface smoothness
  • Energy conservation ensures materials never reflect more light than they receive
  • The Fresnel effect makes surfaces more reflective at grazing angles
  • Microfacet theory models microscopic surface roughness with the GGX distribution
  • The geometry function accounts for self-shadowing between microfacets
  • Texture maps allow material properties to vary across the surface
  • The Cook-Torrance BRDF combines these elements into a unified lighting model