Basic Lighting

Normals, diffuse, and specular

Why Light Matters

A shape without lighting is just a silhouette. Lighting reveals form, creating the illusion that flat pixels represent three-dimensional objects. The interplay of bright and dark surfaces tells our eyes about curvature, depth, and material properties.

The lighting models we cover here are approximations—simplifications of how light actually behaves in the physical world. Real light bounces between surfaces, scatters through materials, and interacts in complex ways. But these simplified models produce convincing results at minimal computational cost, which is why they remain fundamental to real-time graphics.

Surface Normals

A normal is a unit vector perpendicular to a surface. It answers the question: which way is this surface facing?

At any point on a surface, the normal points directly outward. On a sphere, each point's normal aims away from the center. On a flat floor, every point shares the same upward normal. On a complex mesh, normals vary across the surface, following its curves and angles.

Normals must have length 1. We only care about direction, not magnitude. This normalization is essential—the lighting math depends on it.

Interactive: Surface Normals

Sphere normals point outward from the center. Each point has a unique direction.

Switch between shapes to see how normals differ. On the sphere, normals fan outward radially. On the cube, each face shares a single normal direction. The smooth-shaded sphere interpolates normals across its surface, creating the illusion of curvature even with flat triangles.

Where Normals Come From

For simple shapes like spheres and boxes, normals follow directly from the geometry. A sphere's normal at any point equals the normalized position vector from the center. A box face's normal is constant across the entire face.

For meshes, normals come from one of two sources:

Flat shading: Each triangle computes its normal from the cross product of two edges. Every pixel on that triangle uses the same normal. This produces faceted, angular surfaces.

Smooth shading: Each vertex stores a normal, typically the average of the normals of all faces sharing that vertex. The rasterizer interpolates between vertex normals across the triangle, producing smooth gradients.

Most meshes use smooth shading for curved surfaces and flat shading for sharp edges. The choice is encoded in the mesh data itself—vertices along a sharp edge are duplicated so each face can have its own normal.

Diffuse Lighting

Diffuse reflection models how matte surfaces scatter light. When light hits a rough surface, it bounces in all directions. The brightness we perceive depends only on the angle between the surface and the light source, not on where we are standing.

This is Lambert's Law: the intensity of diffuse reflection is proportional to the cosine of the angle between the surface normal and the light direction.

Since the dot product of two unit vectors equals the cosine of the angle between them, the calculation is simple:

fn diffuse(normal: vec3f, lightDir: vec3f) -> f32 {
    return max(dot(normal, lightDir), 0.0);
}
wgsl

When the normal points directly at the light (angle = 0°), the dot product is 1—maximum brightness. When perpendicular (angle = 90°), the dot product is 0—no direct illumination. The max with 0 ensures we never get negative light when the surface faces away.

Interactive: Diffuse Lighting

diffuse = max(dot(normal, lightDir), 0.0)

Points facing the light are bright. Points perpendicular receive no direct light.

Move the light and watch the shading change. Points where the normal aligns with the light direction are brightest. Points where the normal is perpendicular receive no direct light. The smooth falloff across the surface creates the perception of curvature.

Specular Lighting

Shiny surfaces have specular highlights—bright spots where light reflects directly into your eye. Unlike diffuse reflection, specular depends on the viewer's position. The highlight appears where the reflected light direction aligns with the view direction.

The classic approach computes the reflection of the light direction across the normal:

fn specular(normal: vec3f, lightDir: vec3f, viewDir: vec3f, shininess: f32) -> f32 {
    let reflectDir = reflect(-lightDir, normal);
    let specAngle = max(dot(reflectDir, viewDir), 0.0);
    return pow(specAngle, shininess);
}
wgsl

The pow controls the size and sharpness of the highlight. Higher values create tighter, more focused spots (polished metal, plastic). Lower values create broader, softer highlights (brushed surfaces, skin).

The reflect function mirrors the light direction across the normal. Built into WGSL, it computes: reflect(I, N) = I - 2.0 * dot(N, I) * N.

An alternative formulation uses the half-vector—the vector halfway between the light and view directions:

fn specularHalfVector(normal: vec3f, lightDir: vec3f, viewDir: vec3f, shininess: f32) -> f32 {
    let halfDir = normalize(lightDir + viewDir);
    return pow(max(dot(normal, halfDir), 0.0), shininess);
}
wgsl

This is the Blinn-Phong variant, slightly cheaper to compute and often producing nicer results at grazing angles.

Ambient Lighting

Real-world scenes have light bouncing everywhere—off walls, ceilings, floors, other objects. Simulating this accurately is expensive (global illumination). For real-time rendering, we approximate indirect light with a constant ambient term:

fn ambient(color: vec3f, strength: f32) -> vec3f {
    return color * strength;
}
wgsl

Ambient light adds a baseline brightness to everything, preventing areas facing away from all lights from being pitch black. It is a crude approximation, but it keeps shadows from looking like holes in the world.

More sophisticated techniques like ambient occlusion, spherical harmonics, or baked lighting can improve on this baseline, but the constant ambient term remains a reasonable starting point.

The Phong Model

The Phong lighting model combines ambient, diffuse, and specular:

fn phong(
    normal: vec3f, 
    lightDir: vec3f, 
    viewDir: vec3f,
    lightColor: vec3f,
    objectColor: vec3f
) -> vec3f {
    // Ambient
    let ambient = 0.1 * objectColor;
    
    // Diffuse
    let diff = max(dot(normal, lightDir), 0.0);
    let diffuse = diff * lightColor * objectColor;
    
    // Specular (white highlights)
    let reflectDir = reflect(-lightDir, normal);
    let spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
    let specular = spec * lightColor * 0.5;
    
    return ambient + diffuse + specular;
}
wgsl

Interactive: Phong Components

Ambient
Constant baseline
Diffuse
Matte surface
Specular
Shiny highlight

Toggle each component to see its contribution. Ambient provides baseline brightness everywhere. Diffuse creates the primary shading that reveals form. Specular adds the highlight that suggests shininess. Together, they produce a convincing representation of a lit surface.

Transforming Normals

When a mesh is transformed (rotated, scaled, translated), its normals must also be transformed. But here is a subtlety: normals do not transform the same way as positions.

If you apply a non-uniform scale (stretching more in one axis than another), transforming normals with the same matrix will make them no longer perpendicular to the surface. A sphere scaled into an ellipsoid would have wrong normals.

The solution is the normal matrix: the transpose of the inverse of the upper-left 3×3 of the model matrix.

// In vertex shader
let normalMatrix = transpose(inverse(mat3x3f(
    model[0].xyz,
    model[1].xyz,
    model[2].xyz
)));
let worldNormal = normalize(normalMatrix * vertexNormal);
wgsl

For pure rotations (no scaling), the model matrix's upper-left 3×3 already works. The normal matrix only differs when there is non-uniform scaling.

Interactive: Normal Matrix

Correct Normals

Using transpose(inverse(modelMatrix3x3)), normals remain perpendicular.

Apply non-uniform scaling and watch what happens to the normals. Without the normal matrix, they tilt incorrectly. With it, they remain perpendicular to the surface.

Full WGSL Implementation

Here is a complete fragment shader implementing Phong lighting:

struct FragmentInput {
    @location(0) worldPosition: vec3f,
    @location(1) worldNormal: vec3f,
}
 
struct LightUniforms {
    position: vec3f,
    _pad1: f32,
    color: vec3f,
    _pad2: f32,
}
 
struct MaterialUniforms {
    color: vec3f,
    shininess: f32,
}
 
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
@group(1) @binding(0) var<uniform> light: LightUniforms;
@group(2) @binding(0) var<uniform> material: MaterialUniforms;
 
@fragment
fn main(input: FragmentInput) -> @location(0) vec4f {
    let normal = normalize(input.worldNormal);
    let lightDir = normalize(light.position - input.worldPosition);
    let viewDir = normalize(camera.eye - input.worldPosition);
    
    // Ambient
    let ambient = 0.1 * material.color;
    
    // Diffuse
    let diff = max(dot(normal, lightDir), 0.0);
    let diffuse = diff * light.color * material.color;
    
    // Specular (Blinn-Phong)
    let halfDir = normalize(lightDir + viewDir);
    let spec = pow(max(dot(normal, halfDir), 0.0), material.shininess);
    let specular = spec * light.color * 0.5;
    
    let color = ambient + diffuse + specular;
    return vec4f(color, 1.0);
}
wgsl

The vertex shader passes world position and world normal to the fragment shader. The fragment shader then computes lighting per-pixel, producing smooth gradients even on coarse meshes.

Moving Beyond Phong

The Phong model is a starting point. Real materials are more complex:

Energy conservation: Phong can make surfaces brighter than the incoming light, violating physics. Physically-based models ensure energy is conserved.

Fresnel effect: Real materials reflect more light at grazing angles. The rim of a sphere appears brighter than its center.

Roughness and metalness: The PBR (Physically Based Rendering) approach parameterizes materials by how rough they are and whether they are metallic. Metals have colored specular reflections; dielectrics (plastic, wood) have white highlights.

Multiple lights: Add contributions from each light source. For N lights, run the diffuse and specular calculations N times and sum the results.

These extensions build on the same foundation: normals, dot products, and the interaction between surface orientation, light direction, and view direction.

Key Takeaways

  • Normals are unit vectors perpendicular to surfaces, indicating which direction they face
  • Diffuse lighting uses Lambert's Law: max(dot(normal, lightDir), 0.0)
  • Specular lighting depends on the view direction: reflection or half-vector approach
  • Ambient light provides a constant baseline to prevent pure-black shadows
  • Phong combines ambient + diffuse + specular for convincing lit surfaces
  • Non-uniform scaling requires the normal matrix: transpose(inverse(modelMatrix3x3))
  • Pass world position and world normal from vertex shader to fragment shader for per-pixel lighting