Lab 3.1 — Blinn–Phong Lighting
← Course 4 syllabus · Module 3 · Prev: « Lab 2.5 · Next: Lab 3.2 »
Goal
Light the Module 2 scene from first principles: Lambert diffuse as an inner product, Blinn–Phong specular via the half vector (and why it displaced Phong’s reflect-vector original), and ambient as the honest confession it is — a constant standing in for all the light transport the local model ignores. Build the three classical light types — directional, point (with attenuation), spot (with cone falloff) — driven from a uniform-buffer light array, and compare per-fragment vs. per-vertex evaluation on real meshes.
This is also the gamma lab. The standing rule for the rest of the course — all lighting math happens in linear space; sRGB encoding happens exactly once, at the end — is established here by deliberately producing the two classic failure images (no-gamma and double-gamma) next to the correct one, so the eye learns to recognize both diseases on sight. Every shading lab after this one assumes the discipline without restating it.
Recommended reading
- Halladay — the diffuse-lighting and specular/rim-lighting chapters (title-level references — confirm against the copy in hand). The shader-intuition backbone of this whole module: read it for the thinking (what each dot product is asking the surface), not the API.
- MbT — the basic-lighting chapter (directional/point/spot, per-fragment shading, 5th-ed. structure): this is the Metal track’s path through the same material.
- Lengyel — the illumination/shading-model sections of the lighting chapter: the half-vector formulation and attenuation, stated with the vectors defined precisely.
- D&P — the lighting chapter’s treatment of the standard model, for a second telling with the coordinate-space bookkeeping spelled out.
- LearnOpenGL — “Basic Lighting”, “Materials”, “Light casters”, “Multiple lights”, and above all “Gamma Correction” — API-agnostic, free, and the clearest short treatment of the linear-vs-sRGB pipeline available.
- Course 1 §1 (inner products and orthogonal decomposition — every term below is one) and Course 1 §16 (sRGB and gamma as a signal-encoding question).
Prerequisites
- Lab 2.5 done on both tracks — the textured glTF scene with depth and a working camera is the canvas being lit.
- Lab 2.3’s transform stack understood — this lab adds one more matrix to it and asks you to justify it.
- Both shader toolchains fluent from Module 2: GLSL → SPIR-V via
glslcin the CMake build, MSL via Xcode.
Project & environment setup
- New shader pair per track in
shaders/:lit.vert/lit.frag(GLSL) andLit.metal(MSL) — replacing Module 2’s unlit textured shaders as the scene’s default. - Extend the per-frame UBO scheme from Lab 2.3 with a light array: a fixed-capacity array of light structs (type tag, position/direction, color/intensity, attenuation and cone parameters) plus an active-light count. Mind std140/Metal alignment rules — a
vec3is not the size you think it is; record the layout you settled on innotes.md. - Vertex layout grows a normal attribute (tinygltf already parses it; Module 2 dropped it on the floor).
- No new dependencies, no new assets: the entire lab is shader work plus the UBO plumbing.
Where results go:
| Artifact | Path |
|---|---|
| Notes, UBO layout, gamma A/B/C postmortem, per-vertex comparison | labs/lab-3-1/notes.md |
| Screenshots (both APIs), captures | labs/lab-3-1/captures/ |
| Light-count scaling numbers | labs/lab-3-1/benchmarks/ |
Background
The model
Fix the unit vectors at a shaded point:
- \(\mathbf{n}\) — the surface normal,
- \(\mathbf{l}\) — toward the light,
- \(\mathbf{v}\) — toward the viewer,
- \(\mathbf{h} = \dfrac{\mathbf{l} + \mathbf{v}}{\lVert \mathbf{l} + \mathbf{v} \rVert}\) — the half vector.
The per-light Blinn–Phong contribution is
\[ L \;=\; k_a \;+\; k_d \,\max(\mathbf{n}\cdot\mathbf{l},\,0) \;+\; k_s \,\max(\mathbf{n}\cdot\mathbf{h},\,0)^{p}, \]
each \(k\) carrying the light’s color and the material’s response. Every term is Course 1 §1 at work. The diffuse dot product \(\mathbf{n}\cdot\mathbf{l}\) is the cosine in Lambert’s law — the projection of the light direction onto the normal, i.e. how much of the light’s flux a tilted surface actually intercepts; the clamp is the statement that surfaces receive no light from behind.
The specular term asks a different question: how close is the microscopic mirror orientation that would reflect \(\mathbf{l}\) into \(\mathbf{v}\) — which is exactly \(\mathbf{h}\) — to the actual normal? The exponent \(p\) sharpens the answer into a highlight. Phong’s original formulation used the reflect vector instead:
\[ \mathbf{r} \;=\; 2(\mathbf{n}\cdot\mathbf{l})\,\mathbf{n} - \mathbf{l}, \qquad L_s = k_s\,\max(\mathbf{r}\cdot\mathbf{v},\,0)^{p}. \]
Blinn’s half-vector form won for three reasons worth internalizing: it is cheaper when \(\mathbf{h}\) can be shared or approximated across a surface; it behaves better at grazing angles, where the reflect form clips the highlight against the horizon; and — the deep reason, which Lab 3.3 makes precise — \(\mathbf{n}\cdot\mathbf{h}\) is the quantity microfacet theory actually cares about. Note the exponents are not interchangeable: a Blinn highlight needs a noticeably larger \(p\) (rule of thumb: a few times larger) to match a given Phong highlight’s width — verify at the machine rather than trusting the folklore.
Attenuation and the light types
A directional light has no position and no falloff — one direction, one color. Physics says a point source falls off as \(1/d^2\); the classical raster form is
\[ \text{att}(d) \;=\; \frac{1}{k_c + k_l\, d + k_q\, d^2}, \]
whose constant and linear terms exist to tame the \(1/d^2\) singularity as \(d \to 0\) and to give artists a longer throw in the LDR era. This lab implements the classical form; Lab 3.4’s HDR pipeline is what makes physical \(1/d^2\) (plus exposure) viable — a connection to state now and confirm then.
A spot light is a point light multiplied by a cone factor. With \(\theta\) the angle between the spot axis and the fragment direction, and inner/outer cone angles \(\theta_{in} < \theta_{out}\), the smooth falloff is
\[ s \;=\; \operatorname{clamp}\!\left( \frac{\cos\theta - \cos\theta_{out}}{\cos\theta_{in} - \cos\theta_{out}},\; 0,\; 1 \right), \]
blending from full intensity inside the inner cone to zero outside the outer. A hard cutoff at a single angle is the first scheduled failure image of the lab.
The normal matrix
Normals do not transform by the model matrix \(M\): they are covectors — defined by the plane they are orthogonal to, not by a direction embedded in the surface. Under non-uniform scale, transforming \(\mathbf{n}\) by \(M\) breaks orthogonality to the transformed tangent plane. The correct map is the inverse-transpose (upper-left \(3{\times}3\)):
\[ \mathbf{n}' \;=\; (M^{-1})^{\mathsf{T}}\, \mathbf{n}, \]
derivable in two lines from the invariance of \(\mathbf{n}^{\mathsf{T}}\mathbf{t} = 0\) for tangent vectors \(\mathbf{t}\) — a pure §1 argument. Produce the derivation in notes.md before implementing it; being able to reproduce it on demand is part of the deliverable.
Gamma
Textures authored for display are stored sRGB-encoded — a perceptual encoding, roughly a \(1/2.2\) power law (§16):
\[ c_{\text{sRGB}} \;\approx\; c_{\text{linear}}^{1/2.2}, \]
(the true sRGB transfer function is piecewise, linear near zero — the approximation is fine for reasoning, the hardware implements the real one). Lighting math is only valid in linear space: adding and scaling encoded values adds and scales the wrong quantities. The correct pipeline: sRGB-decode inputs (free, via sRGB texture formats), compute in linear, sRGB-encode exactly once at the end (free again, via the sRGB framebuffer format chosen back in Labs 0.3/0.4).
Skip the final encode → everything too dark, shadow falloff crushed. Decode nothing but encode anyway → the double-gamma wash: pale, gray, low-contrast. Both get produced on purpose below, because both appear constantly in the wild.
Tasks
Both tracks build the same ladder to the same screenshots — GLSL and MSL each written from scratch (the point of the dual track is that the ideas transfer, not the text).
Vulkan (C++20)
- Normals in. Add the normal attribute to the vertex layout and pipeline; feed the normal matrix per object alongside the model matrix. First deliverable image: normals visualized as RGB — the debugging view every later lab reuses. Include one screenshot of a non-uniformly scaled object shaded with \(M\) vs. \((M^{-1})^{\mathsf{T}}\) to show the distinction is real.
- One directional light. Lambert + Blinn–Phong specular + small ambient, per-fragment, all in world space (or view space — pick one, write the choice and its conventions in
notes.md, and keep it for the rest of the course). Sweep the exponent \(p\) over decades (2 → 256) and screenshot the highlight progression. - The light array. Move lights into the UBO array with a count; accumulate contributions in a loop. Add point lights with the classical attenuation and a spot with the smooth cone falloff — plus one screenshot of the hard-cutoff cone to see why the blend exists.
- Per-vertex variant. A second pipeline evaluating the identical model in the vertex shader with interpolated color. Same scene, same lights, low-poly and high-poly meshes side by side, camera orbiting so the highlight has to travel across triangles.
- Gamma A/B/C. Three builds of the frame: correct (linear math, sRGB out), no-gamma, double-gamma. Same camera, same lights — three screenshots into
captures/, one paragraph innotes.mdnaming what each error does to shadow falloff and to the specular highlight.
Metal (Swift)
- Normals + normal matrix through the MSL vertex function; same RGB-normals debug view, same uniform/non-uniform scale evidence. Metal’s sRGB handling on the drawable mirrors the Vulkan choice — confirm the pixel format actually in use before trusting anything downstream.
- Directional light in MSL, per-fragment, same conventions as the Vulkan track — the two windows side by side must be indistinguishable (this module’s standing acceptance test).
- Light array in a Swift-side struct mirrored into the buffer binding; point + spot as above. Note where Metal’s alignment rules differ from std140 in your layout notes — the mismatches are exactly where “works on one API” bugs live.
- Per-vertex variant and the same low-poly comparison screenshots.
- Gamma A/B/C reproduced — trivially, if the linear-space rule was followed; instructively, if not.
Deliverable & expected results
- Both apps rendering the Module 2 scene under a directional light, ≥2 point lights, and 1 spot light, visually matched across APIs; the normals-as-RGB view toggleable.
- In
notes.md: the UBO layout with alignment notes, the space convention chosen, the normal-matrix derivation, the gamma A/B/C postmortem, and the per-vertex vs. per-fragment comparison with screenshots.
| Quantity | Predicted | Measured |
|---|---|---|
| Highlight size vs. exponent \(p\) | shrinks and intensifies as \(p\) grows — qualitative sweep 2→256, roughly halving in width per few× in \(p\) | … |
| Blinn vs. Phong exponent for matched highlight | Blinn needs the larger exponent — record the ratio you find | … |
| Per-vertex artifacts on low-poly mesh | specular highlight missing, dim, or crawling across triangles under camera motion; fine on dense mesh | … |
| Cost per additional light (frame time) | linear in light count — the loop runs per fragment per light | … |
| No-gamma image | too dark, crushed falloff | … |
| Double-gamma image | washed out, gray shadows, bleached highlight | … |
Profiling & performance
First real fragment-shader cost of the course, so first real use of the shader tools. On the Metal side, take an Xcode GPU capture and open the shader profiler on the lit fragment function — see where the per-light loop’s cost lives, and watch the cost line move as the light count goes 1 → 4 → 8. On the Vulkan side, capture the same scene in RenderDoc on the Linux desktop (RTX 4090) and read the pass cost from the event browser’s duration column.
Wrap the frame in the Tracy zones carried since Lab 0.3; GPU-side timestamp queries arrive properly in Lab 6.3 — until then the capture tools are the GPU truth. Save one capture per API showing the 8-light frame in captures/.
Analysis & reconciliation
Predict the light-count scaling before measuring it: if lighting dominates fragment cost, doubling lights should approach doubling shading time — where it doesn’t, name what else the fragment pays for (texture fetches, interpolant pressure) and how you’d test that claim. Reconcile the Blinn-vs-Phong exponent ratio against the folklore value.
For the gamma triptych, write the one-paragraph explanation of where in each broken pipeline the extra or missing power function sits — the test is being able to diagnose which disease a screenshot has without seeing the code. File the per-vertex results as a cost/quality tradeoff statement: when would you actually ship Gouraud shading in 2026, if ever — and at what vertex density does the answer flip?
Going further
- Implement the physical \(1/d^2\) attenuation behind a toggle and observe why it’s unusable without HDR — then leave the toggle in for Lab 3.4 to vindicate.
- Rim lighting (a \(1 - \mathbf{n}\cdot\mathbf{v}\) term — Halladay covers the intuition) as a first taste of non-physical stylized terms.
- Half-Lambert / wrap lighting as an ambient alternative; compare against the constant-ambient confession.
- Port the Vulkan build to the Pi 5 and compare the 8-light fragment cost across all three GPUs.