Lighting and shading
Shading answers one question per pixel: how much light leaves this surface towards my eye? Lambert, Phong, PBR are just different guesses at that answer. The inputs are always the same: the surface normal N, the direction to the light L, the direction to the eye V, and the surface's own colour.
Normals are the whole game
Brightness comes from the normal, not the position.
The normal is the unit vector pointing straight out of the surface. Move a wall and it looks the same; tilt it and it changes completely. Normals must stay unit length (renormalise after interpolation), and non-uniform scaling breaks them unless you transform by the inverse-transpose of the model matrix.
This is why flat vs smooth shading is a normals question, not a geometry question. The same triangles look faceted when each vertex carries its face's normal and rounded when each carries the average of the faces around it — the silhouette stays just as angular. Smooth shading is a lie told by interpolated normals.
Diffuse, specular, ambient
Diffuse = how square-on the surface faces the light. Specular = whether the bounce is heading at my eye.
- Diffuse (Lambert):
max(dot(N, L), 0). Head-on gives 1, edge-on 0, facing away is clamped to 0. It doesn't depend on where you stand — rough surfaces scatter in all directions equally. - Specular (Phong): mirror-bounce
R = reflect(-L, N), thenpow(max(dot(R, V), 0), shininess). Blinn-Phong is cheaper and better behaved: build the half-vectorH = normalize(L + V)and usepow(max(dot(N, H), 0), shininess). H is the normal a mirror would need to bounce the light into your eye, sodot(N, H)means "how close is this surface to being that mirror". The exponent sets tightness: low = broad sheen, high = small hard dot. - Ambient: a flat constant added everywhere, standing in for light that bounced off the rest of the room. A crude hack, and the only thing stopping unlit sides being pure black.
Sum the three. That's classic shading.
The bridge to PBR
PBR swaps invented knobs for measured ones, and refuses to emit more light than it received.
Shininess "80" never meant anything, so a material tuned under one light looked wrong under another. PBR uses three parameters that do mean something: albedo (the base colour actually reflected), roughness (how scattered the micro-surface bounce is — the honest replacement for shininess), and metalness (metals tint their reflection and have no diffuse; dielectrics have white reflections and do). Energy conservation ties them together — diffuse plus specular can never exceed the incoming light — so shinier automatically means less diffuse. That is what makes a PBR material look right in any lighting instead of needing retuning per scene.
Normal maps are fake normals from a texture: an RGB-encoded normal per texel, perturbing N before lighting. The geometry stays flat, the lighting doesn't. Silhouettes give the lie away.
Demo
Lighting terms, isolated and summed — drag the light, then use the view selector to see each term alone before the sum. Toggle flat normals to watch a sphere facet without changing shape.
Gotchas
| Symptom | Cause |
|---|---|
| Object gets brighter when scaled down | Normals scaled with the model matrix instead of its inverse-transpose, then not renormalised |
| Smooth model shows faceted bands | Normals not renormalised in the fragment shader after interpolation |
| Specular highlight leaks onto the dark side | Specular not gated by dot(N, L) > 0 |
| Ringed highlight, or black speckles | pow() on a negative base; clamp the dot product to 0 first |
| Normal map looks lit from below | Green channel convention (OpenGL +Y vs DirectX −Y) |
| Normal-mapped surface lights wrongly as the model rotates | Lighting done in the wrong space — tangent-space normals need the TBN basis |
| Everything looks washed out and flat | Lighting maths done on sRGB values; work in linear, convert at the end |
| Hard terminator / banding on a sphere | 8-bit output with no dither, or mediump normals on mobile |