R0KernelPath
module 243 / 262

Lighting, Shading & Materials

From photons to pixels: master the physics of light transport, build Phong's elegant reflection model, and understand why shading algorithms are the soul of 3D rendering.

~18h focused study10 concepts

Overview

Light is the medium of visual perception, and simulating it convincingly is perhaps the central challenge of computer graphics. When a photon leaves a light source, bounces off a matte wall, grazes a polished surface, and enters the human eye — something beautiful happens. This module teaches you to replicate that process on a GPU, from first principles to production-grade shading pipelines.

We begin with the physics: what IS light, and how do real surfaces interact with it? We'll study the geometry of reflection, refraction, and scattering through the lens of radiometry — the branch of physics that gives us the vocabulary (irradiance, radiance, BRDF) needed to talk precisely about light. This physics-first approach means you will never cargo-cult a shading formula; you will understand exactly where every term comes from.

The Phong reflection model — developed by Bui Tuong Phong in 1975 for his PhD dissertation at the University of Utah — remains the canonical first shading model every graphics student learns. It is elegant because it decomposes surface appearance into three physically motivated components: ambient (fill light from the environment), diffuse (matte, directional scattering), and specular (glossy highlights). We will derive each term carefully, implement it in GLSL, and discuss where the model succeeds and where it breaks down.

Shading models describe the mathematics of light interaction at a single point, but shading algorithms describe how we assign colors to the pixels of a triangle mesh. Flat, Gouraud, and Phong shading represent an evolutionary arc from cheap-but-ugly to expensive-but-convincing, and studying them concretely teaches you the difference between per-face, per-vertex, and per-fragment computation — a distinction that appears throughout GPU programming. We close by extending surfaces with texture mapping and normal maps, which let a triangle with just three vertices simulate arbitrarily complex geometry and material variation.

why this matters for the kernel

Lighting and shading are the reason 3D renders look real. A perfectly modeled mesh with no shading is a silhouette; add physically-based illumination and it becomes a photograph. Every video game, animated film, AR overlay, and medical visualization you have ever seen runs on the ideas in this module. When a lighting artist adjusts a roughness parameter in Pixar's RenderMan, they are tuning a BRDF parameter; when a game engine bakes lightmaps, it is precomputing the irradiance integral; when a smartphone portrait-mode algorithm adds a bokeh blur, it is approximating depth-of-field using the very geometry of specular highlights we study here.

Beyond graphics, the mathematical machinery in this module recurs throughout computer vision and physically-based simulation. The rendering equation is a Fredholm integral equation of the second kind, and solving it approximately is what Monte Carlo path tracers do. Normal mapping, at its core, is a per-pixel coordinate-frame rotation, and that same idea appears in neural radiance fields (NeRF), in physically-based material editors, and in the latent-space geometry of diffusion models. Understanding shading at the level of physics and mathematics equips you to reason clearly about all these downstream applications.

Phong Illumination: Light Interaction at a Surface Point

rendering diagram…

At each surface point, the Phong model decomposes outgoing radiance into three components. The ambient term is constant; the diffuse term depends on the angle between n and l (Lambert's law); the specular term depends on the alignment of the surface normal with the half-vector h, raised to the shininess power. All three are summed and output by the fragment shader.

The concepts

01The Physics of Light: Radiometry Basics

Before writing a single line of shader code, we need the right vocabulary. Light is electromagnetic radiation, and the branch of physics that measures it is radiometry.

The key quantities:

  • Radiant flux (Φ): Total power of light, in watts. A light bulb radiates flux.
  • Irradiance (E): Flux arriving per unit area on a surface, W/m². This is what a surface 'receives'.
  • Radiance (L): Flux per unit area per unit solid angle, W/(m²·sr). Radiance is what a camera or eye actually measures along a ray — it is the 'brightness along a direction'.
  • Solid angle (ω): The 2D angle generalised to 3D; measured in steradians (sr). The whole sphere subtends 4π sr.

The crucial relationship: irradiance from a small source in direction l with radiance L is:

E = L · cos(θ)

where θ is the angle between the incoming direction l and the surface normal n. This is Lambert's cosine law, and it explains why surfaces look darker at grazing angles — they intercept less flux per unit area. The cos(θ) = n · l dot product will appear in every shading equation we write.

The BRDF (Bidirectional Reflectance Distribution Function) f_r(ωᵢ, ωₒ) is the fundamental object that describes a material. It answers: 'For light arriving from direction ωᵢ, how much gets scattered toward direction ωₒ?' Units are 1/sr. The full rendering equation integrates the BRDF over all incoming directions:

Lₒ(x, ωₒ) = Lₑ(x, ωₒ) + ∫_Ω f_r(ωᵢ,ωₒ) · Lᵢ(x,ωᵢ) · cos(θᵢ) dωᵢ

Phong shading is a closed-form approximation to this integral for simple analytic lights.

# Illustrating Lambert's cosine law
import numpy as np

def irradiance(L_radiance, light_dir, normal):
    """Irradiance at a surface point from a distant light.
    L_radiance: radiance of the incoming light (scalar or RGB tuple)
    light_dir: unit vector FROM surface TOWARD light
    normal: unit surface normal
    Returns irradiance E.
    """
    cos_theta = max(0.0, np.dot(light_dir, normal))  # clamp negatives
    return L_radiance * cos_theta

n = np.array([0, 1, 0])  # flat surface, normal points up
l1 = np.array([0, 1, 0])  # light directly overhead => cos=1
l2 = np.array([0.707, 0.707, 0])  # 45 degrees => cos=0.707
l3 = np.array([1, 0, 0])  # grazing => cos=0

print(irradiance(1.0, l1, n))  # 1.0
print(irradiance(1.0, l2, n))  # ~0.707
print(irradiance(1.0, l3, n))  # 0.0

02The Geometry of Reflection: Normals, Vectors, and the Half-Vector

All shading is geometry. At a surface point P, we define four key unit vectors:

  1. n — the surface normal (perpendicular to the surface, pointing outward)
  2. l — the light direction (unit vector from P toward the light source)
  3. v — the view direction (unit vector from P toward the camera/eye)
  4. r — the perfect specular reflection of l about n

Computing r: the reflection of l about n is:

r = 2(n · l)n - l

This is derived by decomposing l into components parallel and perpendicular to n, then flipping the perpendicular component.

The half-vector h is the unit vector halfway between l and v:

h = normalize(l + v)

Why does it matter? For a perfectly smooth mirror, the surface reflects light toward v only when r = v, equivalently when n = h. So n · h measures how 'aligned' the surface normal is with the half-vector — a proxy for how specular a highlight appears. The Blinn-Phong model uses n · h instead of r · v and is preferred in practice because it is cheaper to compute and better-behaved at grazing angles.

Another crucial vector is the tangent and bitangent: a coordinate frame on the surface used for normal mapping (more on this later). The triple (tangent T, bitangent B, normal N) defines tangent space, where texture coordinates live.

import numpy as np

def normalize(v):
    return v / np.linalg.norm(v)

def reflect(l, n):
    """Perfect specular reflection of l about n.
    l: unit vector TOWARD surface (incident)
    Returns r: unit vector AWAY from surface (reflected)
    """
    return 2.0 * np.dot(n, l) * n - l

def half_vector(l, v):
    """Half-vector between light direction and view direction."""
    return normalize(l + v)

# Example
n = normalize(np.array([0.0, 1.0, 0.0]))
l = normalize(np.array([1.0, 1.0, 0.0]))   # light from upper-right
v = normalize(np.array([-1.0, 1.0, 0.0]))  # camera upper-left

r = reflect(l, n)
h = half_vector(l, v)

print("Reflection r:", r)
print("n.r:", np.dot(n, r))  # should be same as n.l
print("Half-vector h:", h)
print("n.h:", np.dot(n, h))

03Diffuse (Lambertian) Reflection

When light hits a rough, matte surface — chalk, unfinished wood, concrete — it scatters equally in all directions. This is Lambertian reflection, and it describes a surface where the outgoing radiance is the same regardless of where you stand.

The BRDF for a perfect Lambertian surface is a constant:

f_r = k_d / π

where k_d ∈ [0,1]³ is the diffuse color (albedo) of the surface. The 1/π factor is a normalization that ensures the surface does not reflect more energy than it receives (energy conservation).

The diffuse irradiance at a point is:

I_diffuse = k_d · I_light · max(0, n · l)

The max(0, ...) clamps negative dot products — physically, a surface point in shadow receives no light from behind the horizon.

In practice the diffuse color k_d is read from a texture (the albedo map or diffuse map), which is what allows a single flat polygon to display complex painted color variation. Each texel in the diffuse map stores the base color at that surface location.

Lambertian reflection is view-independent — the diffuse term looks the same from all viewing angles. This is the key distinction from specular reflection, which is view-dependent.

// GLSL fragment shader — diffuse only
#version 330 core

uniform vec3 lightDir;    // unit vector toward light (world space)
uniform vec3 lightColor;  // RGB intensity
uniform vec3 kd;          // diffuse albedo (or sample from texture)

in vec3 fragNormal;       // interpolated world-space normal

out vec4 fragColor;

void main() {
    vec3 n = normalize(fragNormal);
    vec3 l = normalize(lightDir);

    float diffuse = max(0.0, dot(n, l));
    vec3 color = kd * lightColor * diffuse;

    fragColor = vec4(color, 1.0);
}

04Specular Reflection and the Phong / Blinn-Phong Model

Polished surfaces — plastic, metal, wet skin, glass — show specular highlights: bright spots where the surface acts almost like a mirror. The Phong specular model approximates this with:

I_specular = k_s · I_light · max(0, r · v)^n

where:

  • k_s is the specular color (often white or a tint of the light)
  • r = reflect(-l, n) is the ideal reflection direction
  • v is the view direction
  • n (confusingly also called shininess or phong exponent) controls the size of the highlight

The exponent n is the key parameter. A small n (e.g. 2–10) gives a broad, soft highlight — like plastic. A large n (e.g. 100–1000) gives a tight, brilliant highlight — like a polished metal ball. Physically, higher n approximates a smoother surface.

Blinn-Phong replaces r · v with n · h where h = normalize(l + v). It produces equivalent results for most viewing angles and is computationally cheaper. For a Blinn-Phong model to produce the same highlight width as Phong, use shininess ≈ 4× higher.

I_specular_BlinnPhong = k_s · I_light · max(0, n · h)^shininess

The Phong BRDF is NOT energy-conserving (it can reflect more light than it receives), which is why modern pipelines use the microfacet BRDF (Cook-Torrance). But Phong is invaluable pedagogically and still widely used in real-time engines with artistic controls.

// GLSL fragment shader — Blinn-Phong specular
#version 330 core

uniform vec3 lightDir;    // unit vec toward light
uniform vec3 lightColor;
uniform vec3 viewPos;     // camera world-space position
uniform float shininess;  // e.g. 32.0, 64.0, 256.0
uniform vec3 ks;          // specular color

in vec3 fragNormal;
in vec3 fragPos;          // world-space fragment position

out vec4 fragColor;

void main() {
    vec3 n = normalize(fragNormal);
    vec3 l = normalize(lightDir);
    vec3 v = normalize(viewPos - fragPos);
    vec3 h = normalize(l + v);  // half-vector

    float spec = pow(max(0.0, dot(n, h)), shininess);
    vec3 color = ks * lightColor * spec;

    fragColor = vec4(color, 1.0);
}

05Ambient Light and the Full Phong Illumination Model

The real world has indirect illumination: light bounces off walls, floors, and ceilings and fills in shadows. A physically correct simulation requires path tracing to capture all these bounces, which is expensive. Phong's pragmatic approximation is the ambient term: a constant color added to every surface point regardless of lighting direction:

I_ambient = k_a · I_ambient_light

The ambient term ensures objects in shadow are not completely black. It is the crudest possible approximation to global illumination, but it works surprisingly well for artistic purposes.

The complete Phong illumination model for a single light source sums all three terms:

I = k_a · I_a  +  k_d · I_d · max(0, n·l)  +  k_s · I_s · max(0, r·v)^n

For multiple light sources, the diffuse and specular terms are summed over all lights (ambient is added once). Each light can have its own color and intensity.

A common material specification (used directly in OpenGL fixed-function pipeline and in many shader tutorials) stores:

  • k_a: ambient reflectivity color
  • k_d: diffuse reflectivity color (often same as k_a)
  • k_s: specular reflectivity color
  • shininess: Phong exponent

Modern PBR (Physically Based Rendering) replaces these with albedo, metallic, and roughness parameters that map more directly to physical material properties, but the underlying math is a direct evolution of the Phong model.

// Full Phong illumination — GLSL
#version 330 core

struct Material {
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    float shininess;
};

struct Light {
    vec3 direction;  // toward the light
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};

uniform Material material;
uniform Light light;
uniform vec3 viewPos;

in vec3 fragNormal;
in vec3 fragPos;
out vec4 fragColor;

void main() {
    vec3 n = normalize(fragNormal);
    vec3 l = normalize(light.direction);
    vec3 v = normalize(viewPos - fragPos);
    vec3 h = normalize(l + v);

    // Ambient
    vec3 ambient = material.ambient * light.ambient;

    // Diffuse
    float diff = max(0.0, dot(n, l));
    vec3 diffuse = material.diffuse * light.diffuse * diff;

    // Specular (Blinn-Phong)
    float spec = pow(max(0.0, dot(n, h)), material.shininess);
    vec3 specular = material.specular * light.specular * spec;

    fragColor = vec4(ambient + diffuse + specular, 1.0);
}

06Flat, Gouraud, and Phong Shading

The Phong illumination model tells us the color at a point; a shading algorithm tells us when in the pipeline to compute it. Three classic algorithms trade quality for speed:

Flat Shading Compute one color per triangle, using the face normal. All fragments of the triangle get that color. Fast, but produces a faceted, polygon-soup appearance because normal discontinuities at edges are visible. Good for intentionally low-poly aesthetics or debugging normal geometry.

Gouraud Shading (Henri Gouraud, 1971) Compute the illumination model once per vertex (using the per-vertex normal), then linearly interpolate colors across the triangle during rasterization. The interpolation happens in the rasterizer as a barycentric blend. Much smoother than flat shading, but highlights can 'miss' if the highlight center lies in the interior of a triangle — the peak is invisible unless it happens to fall on a vertex.

Phong Shading (Bui Tuong Phong, 1975) — not to be confused with the illumination model! Interpolate the normals per vertex across the triangle (barycentric interpolation), then evaluate the illumination model once per fragment using the interpolated normal. This is what GPU fragment shaders do by default. It correctly captures highlights in triangle interiors and produces smooth, convincing results. The cost is one full illumination model evaluation per fragment instead of per vertex.

The modern GPU pipeline always runs Phong shading (per-fragment shading) by default. 'Flat' and 'Gouraud' are opt-in via the GLSL flat and smooth interpolation qualifiers.

AlgorithmPer-facePer-vertexPer-fragmentQuality
FlatNormal+color—constantlow
Gouraud—Normal+colorinterpolate colormedium
Phong—Normalinterpolate normal, then shadehigh
// Vertex shader — outputs interpolated normal for Phong shading
#version 330 core
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aNormal;

uniform mat4 model, view, projection;
uniform mat3 normalMatrix; // transpose(inverse(model)) upper 3x3

out vec3 fragNormal;  // will be interpolated by rasterizer
out vec3 fragPos;

void main() {
    vec4 worldPos = model * vec4(aPos, 1.0);
    fragPos = worldPos.xyz;
    // Transform normal to world space (not just model matrix!
    // because non-uniform scale would distort normals)
    fragNormal = normalMatrix * aNormal;
    gl_Position = projection * view * worldPos;
}
// The fragment shader from the previous concept evaluates
// the Phong illumination model using the interpolated fragNormal.
// That IS Phong shading.

07Texture Mapping

A mesh with thousands of triangles can represent geometric complexity, but surface detail — the label on a can of soup, the grain in wood, the stubble on a face — is too fine-grained for geometry. Texture mapping projects a 2D image (the texture) onto the surface of a 3D mesh.

Each vertex carries a UV coordinate (u, v) ∈ [0,1]², which indexes into the texture image. During rasterization, the UV coordinates are interpolated across each triangle (barycentric interpolation again), and the fragment shader samples the texture at the interpolated UV to retrieve the texel color.

Texture filtering is critical: a high-resolution texture mapped to a distant object will have many texels per pixel (minification), while a low-resolution texture on a close object has many pixels per texel (magnification). The solutions:

  • Nearest-neighbor: snap to the closest texel. Fast but aliased.
  • Bilinear interpolation: blend the 4 nearest texels. Smooth magnification.
  • Mipmapping: precompute down-scaled versions of the texture (the mip chain). Select the mip level where texel size ≈ pixel size. Eliminates minification aliasing almost for free.
  • Trilinear filtering: bilinear within two mip levels, then linearly blend between them.
  • Anisotropic filtering (AF): samples the texture along the actual projected pixel footprint, which is elliptical at grazing angles. Dramatically improves texture quality on surfaces viewed at shallow angles.

Texture coordinate wrapping determines what happens outside [0,1]: REPEAT tiles the texture, CLAMP pins to the edge texel, MIRROR flips alternating tiles.

Modern graphics uses many texture 'slots' simultaneously: diffuse/albedo maps, specular maps, roughness maps, metallic maps, emissive maps — each one a spatial function over the surface encoded as a 2D image.

// GLSL texture sampling
#version 330 core

uniform sampler2D diffuseMap;  // texture unit 0
uniform sampler2D specularMap; // texture unit 1 (per-pixel specularity)

uniform vec3 lightDir;
uniform vec3 viewPos;

in vec2 texCoord;    // interpolated UV from vertex shader
in vec3 fragNormal;
in vec3 fragPos;

out vec4 fragColor;

void main() {
    // Sample material properties from textures
    vec3 kd = texture(diffuseMap, texCoord).rgb;
    float ks_scalar = texture(specularMap, texCoord).r;

    vec3 n = normalize(fragNormal);
    vec3 l = normalize(lightDir);
    vec3 v = normalize(viewPos - fragPos);
    vec3 h = normalize(l + v);

    float diff = max(0.0, dot(n, l));
    float spec = pow(max(0.0, dot(n, h)), 64.0) * ks_scalar;

    fragColor = vec4(kd * diff + vec3(spec), 1.0);
}

08Normal Mapping

Texture mapping handles color variation, but a flat polygon still shades as a flat polygon because its normal never changes. Normal mapping (or bump mapping in its original form) stores a per-texel perturbation of the surface normal in a texture, letting a flat polygon shade as if it had complex micro-geometry — rivets, dents, brick mortar — without adding a single triangle.

A normal map is an RGB texture where the (r,g,b) channels encode a 3D normal vector. The most common convention is tangent-space normal maps (blue-purple tint in practice): the stored normal is relative to the surface's tangent frame (T, B, N), so the texture is reusable across different orientations. A blue pixel (0.5, 0.5, 1.0) encodes the 'unperturbed' normal (0, 0, 1) in tangent space.

To use a tangent-space normal map in a fragment shader:

  1. Build the TBN matrix from the per-vertex tangent, bitangent, and normal (all in world space).
  2. Sample the normal map and decode: n_tangent = normalize(texture(normalMap, uv).rgb * 2.0 - 1.0)
  3. Transform to world space: n_world = normalize(TBN * n_tangent)
  4. Use n_world in place of the geometric normal in the shading equation.

The TBN matrix must be computed in the vertex shader from the mesh geometry (Gram-Schmidt orthonormalization is used to handle UV seams and ensure orthogonality). Each vertex needs both a UV derivative (to compute tangent direction) and the surface normal.

Parallax mapping extends normal mapping by offsetting the UV coordinate based on the view angle and the stored height, creating an illusion of actual geometric depth — used for brick walls and cobblestones in games.

// GLSL — Normal mapping with TBN matrix
// Vertex shader outputs TBN columns
// (tangent T, bitangent B, normal N computed on CPU per-mesh)
#version 330 core
layout(location=0) in vec3 aPos;
layout(location=1) in vec3 aNormal;
layout(location=2) in vec2 aTexCoord;
layout(location=3) in vec3 aTangent;
layout(location=4) in vec3 aBitangent;

uniform mat4 model, view, projection;
uniform mat3 normalMatrix;

out vec2 texCoord;
out mat3 TBN;  // tangent-to-world matrix
out vec3 fragPos;

void main() {
    vec3 T = normalize(normalMatrix * aTangent);
    vec3 B = normalize(normalMatrix * aBitangent);
    vec3 N = normalize(normalMatrix * aNormal);
    TBN = mat3(T, B, N);  // columns are the tangent-space basis in world space
    texCoord = aTexCoord;
    vec4 worldPos = model * vec4(aPos, 1.0);
    fragPos = worldPos.xyz;
    gl_Position = projection * view * worldPos;
}

// Fragment shader: decode normal and shade
// vec3 normalSample = texture(normalMap, texCoord).rgb;
// vec3 n_tangent = normalize(normalSample * 2.0 - 1.0);
// vec3 n_world = normalize(TBN * n_tangent);
// ... use n_world in Phong shading ...

09Light Attenuation and Multiple Light Types

Real light diminishes with distance — this is attenuation, and in physically accurate rendering it follows the inverse-square law: intensity ∝ 1/d². In practice, OpenGL and game engines use a three-term attenuation model:

attenuation = 1 / (k_c + k_l * d + k_q * d²)

where k_c (constant), k_l (linear), k_q (quadratic) are tunable parameters. Pure inverse-square law is k_c=0, k_l=0, k_q=1.

The three canonical light types in real-time rendering:

Directional Light: Infinitely distant source (the sun). All rays are parallel; no attenuation. Just a direction vector and a color. This is what we have used in the examples so far.

Point Light: Omnidirectional light at a specific world-space position. The light direction l varies per fragment: l = normalize(lightPos - fragPos). Attenuates with distance. Models bulbs, flames, etc.

Spot Light: A point light with a cone of influence. Two angles define the cone: the inner cutoff (full intensity) and outer cutoff (zero intensity). The fragment is only lit if the angle between the fragment direction and the spotlight direction falls inside the outer cone. A smoothstep between inner and outer angles gives a soft edge:

float theta = dot(fragToLight, -spotDir);
float epsilon = cos(innerCutoff) - cos(outerCutoff);
float intensity = clamp((theta - cos(outerCutoff)) / epsilon, 0.0, 1.0);

All three types can be composed and summed in a fragment shader loop over a light array.

// Point light with attenuation
#version 330 core

struct PointLight {
    vec3 position;
    vec3 color;
    float kC, kL, kQ;  // attenuation constants
};

uniform PointLight light;
uniform vec3 viewPos;

in vec3 fragNormal;
in vec3 fragPos;
in vec2 texCoord;
uniform sampler2D diffuseMap;

out vec4 fragColor;

void main() {
    vec3 kd = texture(diffuseMap, texCoord).rgb;
    vec3 n = normalize(fragNormal);
    vec3 toLight = light.position - fragPos;
    float d = length(toLight);
    vec3 l = toLight / d;
    vec3 v = normalize(viewPos - fragPos);
    vec3 h = normalize(l + v);

    float att = 1.0 / (light.kC + light.kL * d + light.kQ * d * d);

    float diff = max(0.0, dot(n, l));
    float spec = pow(max(0.0, dot(n, h)), 64.0);

    vec3 color = (kd * diff + 0.3 * spec) * light.color * att;
    fragColor = vec4(color, 1.0);
}

10Toward Physical Accuracy: Energy Conservation and PBR

Phong shading has a fundamental flaw: it is not energy-conserving. A surface can emit more light than it receives if you pick large k_s or k_d values. Physically, the total reflected radiance must be ≤ total incident irradiance.

Physically Based Rendering (PBR) fixes this with a rigorous BRDF. The dominant model is the Cook-Torrance microfacet BRDF:

f_r(ωᵢ, ωₒ) = D(h) · F(ωᵢ, h) · G(ωᵢ, ωₒ) / (4 · (n·ωᵢ) · (n·ωₒ))

where:

  • D(h): Normal Distribution Function — what fraction of microfacets have normal h? Common choice: GGX/Trowbridge-Reitz.
  • F(ωᵢ, h): Fresnel term — how much light is reflected vs transmitted? Computed via Schlick approximation: F = F₀ + (1−F₀)(1 − cos θ)⁵
  • G(ωᵢ, ωₒ): Geometry/shadowing term — accounts for microfacets blocking each other.

The PBR material model parameterizes surfaces by:

  • albedo (base color)
  • metallic (0=dielectric like plastic/wood, 1=conductor like gold/iron)
  • roughness (0=perfect mirror, 1=fully diffuse)

This parameterization is physically meaningful, artist-friendly, and consistent across different lighting environments — unlike Phong's ad hoc k_a/k_d/k_s/shininess tuple.

Understanding Phong deeply is the prerequisite for understanding why PBR is better: you can see exactly which physical invariants Phong violates and how the microfacet model restores them.

// Schlick Fresnel approximation (used in PBR shaders)
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
    // F0: reflectance at normal incidence
    // For dielectrics: F0 = vec3(0.04)
    // For metals: F0 = albedo (color-tinted)
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

// GGX Normal Distribution Function
float distributionGGX(vec3 N, vec3 H, float roughness) {
    float a      = roughness * roughness;
    float a2     = a * a;
    float NdotH  = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
    float denom  = NdotH2 * (a2 - 1.0) + 1.0;
    return a2 / (3.14159265 * denom * denom);
}
// Full PBR shader combines D, F, G with a diffuse BRDF
// (Lambert for dielectrics, zero for metals)

Key insights

  • ✦Lambert's cosine law (I ∝ n·l) is not an approximation — it is an exact geometric consequence of how flux projects onto a surface, and it appears in every serious shading equation.
  • ✦The Phong illumination model and Phong shading algorithm are different things: the former is a formula for color at a point; the latter is a decision about where (per-face/vertex/fragment) to evaluate that formula.
  • ✦Blinn-Phong is strictly preferred over Phong in practice: the half-vector h is cheaper to compute than the reflection r, produces the same visual result, and is well-defined even when the view direction is behind the surface.
  • ✦Normal mapping does not add geometry — it perturbs the normal used in the shading equation. The underlying mesh is still flat; only the lighting computation is fooled.
  • ✦The TBN matrix is a per-fragment coordinate frame change, not just a lookup: it rotates the tangent-space normal from the normal map into world space so the shading math stays consistent.
  • ✦Phong shading is NOT energy-conserving: k_d + k_s can exceed 1, violating physics. This is the core motivation for physically based rendering (PBR).
  • ✦Mipmapping is a pre-computation that costs 1/3 extra GPU memory but eliminates most texture aliasing artifacts — a canonical example of trading storage for quality.
  • ✦The Fresnel effect is why every surface (even matte ones) becomes mirror-like at grazing angles — and why Phong shading looks wrong for surfaces viewed edge-on.

Common pitfalls

  • ⚠Forgetting to clamp n·l with max(0, n·l): negative dot products mean the light is behind the surface, and without clamping you get dark 'anti-highlights' on backfaces.
  • ⚠Transforming normals with the model matrix: normal vectors must be multiplied by the transposed inverse of the model matrix (the 'normal matrix'), or non-uniform scaling will distort them into incorrect directions.
  • ⚠Interpolating colors across large triangles (Gouraud shading) causes specular highlights to disappear when the highlight lies in the triangle's interior — switch to per-fragment shading.
  • ⚠Decoding normal map values without the *2-1 remap: RGB texels are in [0,1] but normals are in [-1,1]; missing the remap gives a hemisphere of sky-blue normals that never point downward.
  • ⚠Using the wrong space for TBN: the tangent, bitangent, and normal must all be in the SAME space (world or view) before constructing the TBN matrix, or the normal transform will be wrong.
  • ⚠Setting shininess=0 in Phong: pow(x, 0) = 1 for any x, causing a constant white specular overlay across the entire surface — always ensure shininess > 0.
  • ⚠Ignoring the normalMatrix and just using the model matrix for normals: works fine for pure rotation/translation, silently breaks for any scene with scaling.
  • ⚠Summing ambient for every light: ambient should be added once (it models environment light, not a specific source); adding it per-light overbrightens the scene.

Practice

  1. 01
    medium

    Implement a complete Phong-shaded scene in raw OpenGL (C++) or WebGL (JavaScript). Create a unit sphere approximated by a UV sphere mesh, attach a Blinn-Phong fragment shader with a single directional light, and add a UI slider for the shininess exponent. Observe how the highlight changes from broad and soft (shininess=4) to tight and brilliant (shininess=256).

  2. 02
    hard

    Prove mathematically that the Phong BRDF is NOT energy-conserving. Specifically, show that for k_d = 1 and k_s = 1, the integral of the BRDF over the hemisphere times the cosine term can exceed 1. Then derive the correct normalization factor (1/π) for the Lambertian BRDF that DOES conserve energy.

  3. 03
    medium

    Implement Gouraud shading for a teapot mesh (download the Utah teapot .obj file). Then switch to Phong shading. Deliberately use a very coarse triangulation and a high shininess value (256) so the difference is dramatic. Screenshot both, annotate where Gouraud fails to capture the specular highlight.

  4. 04
    hard

    Implement normal mapping from scratch. Take a flat quad (two triangles forming a rectangle) textured with a brick wall albedo map and its corresponding tangent-space normal map. Implement the TBN matrix construction and normal decode in GLSL. Animate a point light orbiting the quad to verify the normals are correctly lit from all directions.

  5. 05
    easy

    Analyze the Fresnel effect empirically. Using a reflective (metallic) sphere with a PBR shader (or find any real-time renderer that exposes roughness/metallic), set roughness=0 and observe how the brightness of the specular reflection changes as you orbit the camera from directly above (normal incidence, θ≈0) to near the horizon (grazing incidence, θ≈90°). Verify qualitatively that F(0°)=F0 and F(90°)=1 as predicted by the Schlick approximation.

  6. 06
    easy

    Derive the reflection vector formula r = 2(n·l)n - l from scratch. Start from the definition of specular reflection (angle of incidence equals angle of reflection), decompose l into components parallel and perpendicular to n, and show that reflecting the perpendicular component gives the result.

Resources

📕Fundamentals of Computer Graphics, 4th ed.
bookpaidShirley & Marschner, CRC Press

Chapters 10 (surface shading) and 11 (texture mapping) are the canonical undergraduate treatment. Every concept in this module is covered with rigorous derivations.

📕Real-Time Rendering, 4th ed.
bookpaidAkenine-Möller, Haines, Hoffman — CRC Press

The definitive reference for GPU shading and PBR. Chapter 9 covers physically based shading in exhaustive detail.

⚡LearnOpenGL — Lighting
interactivefree

Excellent hands-on walkthrough of ambient/diffuse/specular, multiple light types, and normal mapping with runnable source code.

📕Physically Based Rendering: From Theory to Implementation (PBRT)
bookfree

Free online version of the definitive path tracing reference. Chapter 5 (color/radiometry) and Chapter 9 (reflection models) are directly relevant.

🎓MIT 6.837 Computer Graphics (OCW)
coursefree

MIT's undergraduate graphics course. Lecture slides on shading and ray casting are high-quality and freely available.

✎Understanding the Phong Illumination Model
articlefree

Wikipedia's entry is unusually rigorous and includes the original Phong formulation alongside Blinn's modification.

✎Background: Physics and Math of Shading — Naty Hoffman (SIGGRAPH)
articlefree

Hoffman's SIGGRAPH 2013 course notes are the best concise derivation of PBR from first principles. Freely available.

📜The Rendering Equation — James Kajiya (1986)
paperfree

The seminal paper that unified all light transport in a single integral equation. Worth reading after understanding Phong to see the bigger picture.

Checkpoints — you can now…

  • ✓You can state Lambert's cosine law from memory and explain why it follows from the geometry of flux intercepted by a tilted surface.
  • ✓You can derive the reflection vector r = 2(n·l)n - l from scratch, without looking it up.
  • ✓You can write a complete Blinn-Phong fragment shader in GLSL from memory, correctly computing ambient, diffuse, and specular terms for a point light with attenuation.
  • ✓You can explain the difference between flat, Gouraud, and Phong shading and identify which stage of the GPU pipeline (vertex vs fragment shader) each uses.
  • ✓You can explain why normal mapping does not add geometry and describe the role of the TBN matrix in transforming tangent-space normals to world space.
  • ✓You can identify at least two ways that the Phong model violates physical accuracy and describe how PBR corrects them.
  • ✓You can look at a rendered image and diagnose common shading bugs: missing max(0, n·l) clamping, incorrect normal matrix, wrong texture coordinate decoding.