Light & Normals
Move a light around a surface and watch the shading model respond, term by term.
What you should come away with: Why normals need the inverse-transpose, and what separates flat, Gouraud and Phong.
Assumes: The Model Matrix. It will still make sense without it, but that one comes first.
Nothing in this lab simulates light. No ray leaves the lamp and no photon arrives at the eye. There is an equation, evaluated somewhere on a surface, that takes three directions — the N, the direction to the light L, and the direction to the eye V — and returns a colour. The three terms it adds together are the whole of the shading in every picture below.
The arguments worth having are about the inputs rather than the sum. Where the equation is evaluated decides what a curved surface looks like, and how the normal was transformed on its way in decides whether the answer describes the surface at all.
Diffuse brightness is a cosine#
The is one line of the shader: float lambert = max(dot(N, L), 0.0);. Both vectors are unit length, so their dot product is the cosine of the angle between them. A patch of surface facing the light square on gets 1. Tipped sixty degrees away it gets exactly a half. Past ninety degrees the cosine turns negative, which would have the light subtracting brightness from a surface it cannot reach; the max is what stops that, and it is why the far side of the sphere is unlit rather than negatively lit.
That black is what the ambient term exists to prevent. It is a constant — uAmbient defaults to 0.12 in the instrument below — added to the cosine before the base colour multiplies through, so the shadowed side comes out a dark version of the material rather than a grey one. It is not a model of anything. Light arriving off the floor and the walls is real, and a single constant is what stands in for it here — which is why raising it washes out the difference between the lit and unlit sides until the sphere stops reading as round.
The light is a direction and not a place: uLightDir is normalised once and used unchanged at every point on the surface, so there is no distance to the lamp and no falloff — a sun rather than a bulb, and the sphere would receive the same light a mile away. What the cosine scales is an amount of light rather than a number to be written down: the shader decodes the base colour, multiplies there, and encodes the result on the way out, which is why halving the light does not halve the number that reaches the framebuffer. That round trip is the subject of Colour & Gamma.
The highlight belongs to the eye#
Nothing in the diffuse term mentions where you are standing. Orbit the camera in the instrument below and the diffuse shading does not move at all; it is painted onto the surface. The is the opposite kind of thing, and it is two lines: vec3 H = normalize(L + V); and then pow(max(dot(N, H), 0.0), uShininess).
H is the halfway vector, the direction sitting exactly between the light and the eye. It is the normal a mirror would need in order to send this light into this eye, so the surface is brightest where its own normal matches H and falls off as it departs from it. Move the camera and H moves, and the highlight slides across the surface after it. The older formulation reflects L about N and compares the result against V; Blinn’s halfway vector costs less and the lab uses it.
The exponent is a width control. At a shininess of 32 the term has already halved by the time the normal is twelve degrees away from the halfway vector; at 120 it takes six degrees to lose the same half. The term is added as vec3(uSpecular * spec) — white, outside the multiplication by the base colour — so the highlight carries the colour of the light rather than of the surface. That is right for plastic and wrong for gold. It is also gated on the diffuse term, lambert > 0.0, so a face turned away from the light cannot glint at a camera it happens to be facing.
The three models differ only in where the equation runs#
Flat, Gouraud and Phong are not three lighting equations. They are one equation, and what differs is the stage of the pipeline it runs in and what the stage before it hands over.
Gouraud runs the lighting in the vertex shader and passes the resulting colour along as a , so the hardware interpolates a colour across each triangle. The smooth sphere here carries 2,665 vertices, so the lighting runs 2,665 times a frame however large the sphere is on screen. Phong interpolates the normal instead and runs the lighting in the fragment shader, once per the sphere covers — a cost that grows with the size of the sphere on screen and ignores the mesh entirely.
Flat is not a third program in this lab. It is the per-fragment shader handed a different mesh: flatShaded() rebuilds the sphere so that all three corners of every triangle carry the same face normal, and interpolating three identical normals returns that normal. The faceted mesh is deliberately coarser as well — 952 triangles against the smooth one’s 4,992 — because facets too small to see teach nothing.
The exponent in that figure is 120, which puts the half-brightness point of the highlight about six degrees from its centre while the sphere’s triangles are around five degrees across. A mesh this dense does not lose the highlight — some vertex nearly always lands close enough to catch it — but a straight line between three samples cannot reconstruct a cosine raised to the 120th, and what it draws instead has flat sides. Sweep the light in the instrument below with Gouraud selected and the peak of the highlight pulses as the bright spot is dragged from one vertex to the next: full strength when a vertex happens to sit under it, dimmer whenever it falls between three.
This is a trade rather than a mistake. Gouraud moves the work from a stage with millions of invocations to one with thousands, and on a surface with no tight highlight it is indistinguishable from Phong at a fraction of the cost. Both models encode in the fragment stage: the Gouraud vertex shader passes linear light through the varying and the fragment shader raises it to 1/2.2 there, because interpolating encoded values would mix the samples in the wrong space and add a second error to the one being demonstrated.
A normal is not a position#
The stretch slider does something that looks harmless: it builds scaling(1, stretch, 1) — a scale on the y axis and nothing more — and multiplies the vertex positions by it. Positions come out where they belong. Push the normals through the same matrix and the lighting stops describing the surface it is lighting.
A normal is not a little arrow attached to the surface. It is the direction perpendicular to the surface, and perpendicularity is a relationship that a non-uniform scale does not preserve. Flatten a sphere and its surface becomes shallower, so the normals must tilt further towards vertical; scaling them the way the geometry was scaled tilts them the opposite way. The transform that preserves the relationship is the of the model matrix.
For a diagonal matrix that is short enough to check by eye. The inverse of diag(1, s, 1) is diag(1, 1/s, 1), and a diagonal matrix is its own transpose, so the normal’s y component is divided by the scale where the position’s was multiplied by it. Draw the sphere at 0.4 of its height and the correct normal matrix carries 2.5.
The matrix the normals go through
| 1.00 | 0.00 | 0.00 |
| 0.00 | 0.40 | 0.00 |
| 0.00 | 0.00 | 1.00 |
The wrong picture is not noise. Feeding the normals through diag(1, 0.4, 1) gives exactly the normal field of a sphere stretched to two and a half times its height, because 0.4 is what the inverse-transpose of that stretch produces. The flattened sphere is therefore lit correctly — as the tall ellipsoid it is not. That is the signature of this bug in a real scene: nothing looks broken, and the object is confidently lit as something it is not.
Two facts explain why it survives so long in codebases. Under a uniform scale the inverse-transpose is the model matrix divided by the scale squared, and normalize() in the shader deletes that factor, so both matrices produce identical shading. For a pure rotation the inverse is the transpose, so the inverse-transpose is the rotation itself. The mistake is invisible through every rotation and every uniform scale in the project, and shows up on the day somebody squashes one axis of one model — which is the bug The Model Matrix said was waiting here.
Now move all of it at once#
Every figure above holds everything still but one control. Below is the instrument with nothing held back: the three models, a light you can move around the sphere, the four constants of the equation, the stretch and the toggle that breaks it. The equation is printed beside the canvas with your own values in it, and the 3×3 underneath is whichever matrix the normals are going through. The presets are the shortest way in: one of them is this lab’s bug, already switched on.
- light
- normals
The shading equation
N = normalize(normalMatrix * normal) L = normalize(lightDir) H = normalize(L + V) colour = base × (0.12 + 0.80 × max(0, N·L)) + 0.50 × max(0, N·H)^32
Normal matrix (inverse-transpose)
| 1.00 | 0.00 | 0.00 |
| 0.00 | 1.00 | 0.00 |
| 0.00 | 0.00 | 1.00 |
A normal is not a position: it describes an orientation, so it does not transform like one. Scaling a sphere flat in y makes its surface shallower, which means the normals must tilt up, not get squashed down with the geometry. The inverse-transpose is what encodes that. Under uniform scale it reduces to the model matrix, which is why the mistake is invisible until the day someone scales one axis.
Where that leaves you
You can now say why a highlight follows you around the sphere while the diffuse shading stays painted on, and recognise the flattened-sphere signature of normals sent through the model matrix rather than its inverse-transpose.
This did not teach you shadows. Nothing on this site casts one, because a shadow is a second render pass rather than a lighting term.