Rendering in Retrospect · #2 of 12
The First Shader I Actually Understood
Retrospective — written in 2026, looking back.
Copying Is Not Understanding
For a long time, every lighting shader I wrote was copied — not always literally copy-pasted, but copied in structure. I knew diffuse involved a dot product, specular involved a half-vector and a power, and assembling those pieces in the right order made a sphere look shaded instead of flat.
If you had asked me why the dot product was there, I would have said something true and empty: "it measures how much the surface faces the light." Correct, but it concealed that I could not predict, without running the code, what would happen if the light moved behind the object, why clamping at zero mattered, or why forgetting to normalize in one place made a highlight change size when I scaled an object.
Understanding did not arrive as a new formula. It arrived as geometry. The shader I finally understood was a basic diffuse-plus-specular shader, the kind in every introductory course — no more advanced than the ones I had copied. What changed was that I stopped reading it as algebra and started reading it as a picture I could draw on paper.
The Three Vectors That Explain Almost Everything
At any point on a surface, classic local lighting is a conversation between three directions:
- N, the surface normal: which way this tiny patch of surface faces.
- L, the direction from the surface point toward the light.
- V, the direction from the surface point toward the viewer.
Everything in a simple lighting model is a statement about the angles between these vectors, and the tool for angles between directions is the dot product. For two unit vectors, the dot product is the cosine of the angle between them: same direction, 1; perpendicular, 0; opposite, -1. That single fact, internalized, replaces a surprising amount of memorization.
Diffuse lighting then becomes physical intuition stated in math, not a formula to remember. A surface receives the most light facing the light directly, and less as it turns away, proportionally to the cosine of that turn. A patch at 60 degrees to the light spreads the same incoming energy over more surface — Lambert's cosine law — so it receives cos(60°) = 0.5 times the irradiance. The dot product is that argument compressed into one operation.
// GLSL-style fragment shader excerpt — illustrative
in vec3 vNormal;
in vec3 vWorldPos;
uniform vec3 uLightPos, uLightColor, uCameraPos, uBaseColor;
void main() {
vec3 N = normalize(vNormal);
vec3 L = normalize(uLightPos - vWorldPos);
vec3 V = normalize(uCameraPos - vWorldPos);
// Diffuse: how much does the surface face the light?
float nDotL = max(dot(N, L), 0.0);
vec3 diffuse = uBaseColor * uLightColor * nDotL;
// Specular (Blinn-Phong): how closely does the surface
// mirror the light toward the eye?
vec3 H = normalize(L + V);
float shininess = 32.0; // illustrative exponent
vec3 specular = uLightColor * pow(max(dot(N, H), 0.0), shininess);
gl_FragColor = vec4(diffuse + specular, 1.0);
}
I had typed code like this many times before I understood it. The day it clicked, I was not looking at code. I was drawing the vectors.
Drawing the Half-Vector
Specular reflection was the part I had copied most blindly, so it is fitting that it unlocked the rest.
The question a specular model answers is: how close is the viewer to seeing a mirror reflection of the light from this point? Imagine the point as a tiny mirror with orientation N. A perfect mirror reflects light in exactly one direction. If V aligns with that reflected direction, you see a bright highlight; otherwise, nothing.
Real materials are not perfect mirrors. In the useful fiction of simple models, they are collections of tiny facets pointing in slightly different directions. A facet contributes a highlight when its normal points exactly halfway between L and V, because only then does it mirror the light into the eye. That halfway direction is H, the half-vector: normalize(L + V).
So dot(N, H) asks a geometric question: how close is our surface's normal to the ideal mirror-facet orientation? Near 1, many facets align to bounce light to the viewer. Near 0, almost none do. Raising it to a power narrows the answer: a high exponent counts only facets very close to ideal, perceived as a small, sharp highlight — a smooth surface. A low exponent spreads the highlight — a rougher surface, in this model's crude language.
Once I saw H on paper, bisecting L and V, pow stopped being "the shininess part." It was a falloff control on an angular distance I could visualize.
The Bugs That Taught Me Normalization
Understanding also arrived through bugs the geometry explained retroactively.
The moving highlight. A highlight once grew and shrank as I moved an object, though light and camera were still. The cause: non-uniform scale transforms normals incorrectly if you use the same matrix as for positions, and un-normalized interpolated normals change length across a triangle. A dot product with a non-unit vector is no longer a cosine — it is a cosine scaled by a length I did not intend. Normalizing N in the fragment shader, and transforming normals with the inverse-transpose matrix, fixed a bug I had been "fixing" by tweaking shininess until screenshots looked acceptable.
The light that lit the back side. Without max(dot(N, L), 0.0), a light behind a surface contributes a negative diffuse amount — subtracting light, and producing strange darkening I mistook for a shadow problem. Drawn geometrically it is obvious: a cosine below zero means an angle over 90 degrees, the light is behind the patch, and the correct contribution is zero. The clamp is not a hack. It is the horizon.
The washed-out interpolation. Vertex normals are interpolated across a triangle, and interpolation does not preserve unit length — the vector mid-edge is shorter than its endpoints. That is why the fragment shader re-normalizes. Before I understood that, normalize() calls looked like defensive boilerplate. After, each had a reason I could state.
In each case the bug persisted while I treated the shader as formulas, and became diagnosable once I treated it as geometry: draw the failing case, see which angle is wrong, go to the line responsible.
What Intuition Buys You Later
I still write lighting code more sophisticated than this — energy-conserving models, Fresnel terms, distribution functions that formalize the microfacet story above — and I am still building intuition for parts of them in my personal renderer. But every later model has been easier than the first, because the method changed. Meeting a new term, I now ask:
- Which vectors are involved, and can I draw them?
- What angle, distance, or area is this term really measuring?
- What should happen at the extremes — facing directly, grazing angle, behind the surface — and does the expression do that?
- What breaks if a vector is not normalized, and where is normalization guaranteed?
Fresnel stopped being a mysterious polynomial once I framed it that way: look at a surface edge-on, dot(N, V) approaches 0, and real surfaces become more mirror-like. The formula approximates that observation; it does not replace it.
That is the real lesson. The goal is not to memorize more models, but to reach the point where a model is a compressed description of a picture already in your head — so when the image on screen disagrees with the picture, you know which one to debug, and where.
Copying was not wasted time; it built the vocabulary. But there is a clear before and after. Before: I changed numbers until it looked right. After: I could say in advance what a change would do, and be right often enough that debugging became reasoning instead of search. If you are still copying, my only advice is to take the smallest lighting shader you have and draw every vector in it by hand, for three different light positions. The understanding is not in the code. It is in the drawing the code was always describing.