Draft Work in progress. Wording, structure, and claims may still change. Feedback welcome. ← Back to roadmap

05 lighting

Phong and Blinn-Phong in the Deferred Pass

First fully lit scene. Diffuse and specular terms, surface normals from the G-Buffer, and basic material response.

Choosing a Model of Light

Before you write a lighting shader, you’re picking a fifty-year-old heuristic. Knowing which one — and why — matters more than the code.


Lighting Is Heuristics All The Way Down

From the early 1970s until physically based rendering went mainstream in the mid-2010s, real-time lighting was not physics. It was a stack of plausibility tricks, layered on top of each other by people who needed a shaded pixel in the next sixteen milliseconds and didn’t have the luxury of solving an integral.

The model I’m implementing in this post — Blinn-Phong — held the industry for roughly twenty-five years. Every console game you remember from that era lit its geometry with some variant of it. When you picture “a shiny plastic ball sitting on a table,” the mental image in your head is Blinn-Phong. This model shaped everyone’s intuition for what a lit surface looks like.

The shader I ended up with is about ten lines long. The interesting part isn’t the code. It’s the fifty years of false starts that made those ten lines the obvious choice — and the fact that half the work was already being done by a pass I’d written for an earlier post.


Color From Geometry And Light, With No Physics To Guide You

The problem any real-time lighting model has to answer is embarrassingly simple to state: given a piece of geometry and one or more lights, what color should each pixel be?

In 1970, there was no good physical answer to that at sixteen-millisecond budgets. Rendering equations hadn’t been written down yet (Kajiya’s came in 1986), and even if they had, nobody could evaluate them in real time. So the early pioneers made a pragmatic bet: find cheap math that produces something plausible, and stop worrying about whether it’s correct.

Three constraints shaped every model in the next two decades:

  • Cheap. A few multiplies per pixel, maybe a power function if we’re feeling rich.
  • Local. No global illumination, no reflections off other surfaces. Each pixel sees only the light and its own surface.
  • Plausible, not correct. “Looks like a lit thing” beats “energy-conserving.”

Everything that follows — flat, Gouraud, Phong, Blinn-Phong — is a different answer to the same question under those three constraints. The shift from one model to the next is almost always driven by the next-cheapest source of plausibility someone found.


Flat Shading — One Normal, One Color

The first answer was also the cheapest. Compute one normal per triangle, take its dot product with the light direction, clamp to zero, scale the surface color by the result. One number per face, applied to every pixel the face covers.

color = albedo · max(N · L, 0)

This is the entire lesson of 1970s flat shading, and it’s the only piece that survives unchanged into every model that comes after: lighting is a function of the normal and the light direction. The dot product N · L is Lambert’s 1760 cosine law wearing a shader hat — the only piece of this whole story with actual physical grounding.

Flat shading gives you that iconic faceted, low-poly, 80s-arcade look. Every triangle is a distinct flat color, seams visible along every edge. It’s the right tool if your art direction leans into it. For a smooth-looking sphere, it’s a disaster — the silhouette reveals the polygon count, and so does the lighting.

The fix isn’t to add more math. It’s to change where the math runs.


Gouraud Shading — Shade The Vertices, Interpolate The Color

Henri Gouraud’s 1971 move was to push the lighting calculation out to the vertices. Compute the color at each corner of the triangle, and let the rasterizer linearly interpolate those three colors across every pixel it covers. Shading cost drops from per-pixel to per-vertex — an enormous win in 1971 — and the faceted look goes away because neighbouring triangles that share a vertex share its color.

For diffuse lighting, Gouraud is shockingly good. A sphere lit with Gouraud looks smooth. Cheap, plausible, done.

For specular highlights, it falls apart by construction. Highlights are small, bright, moving spots. The lighting model is a steep cos^n curve where n is 30 or 100. That curve is not well-represented by three samples taken at the corners of a triangle. Two things go wrong, and they’re both visible:

The highlight lands between the vertices, and disappears.

   V0 (dim)
    /\
   /  \
  / ?? \     <- highlight belongs here, but V0/V1/V2 are all dim,
 /      \       so the interpolated color between them is dim too
V1------V2
(dim)  (dim)

The highlight lands on a vertex, and smears across the whole triangle.

   V0 (bright)
    /\
   /**\      <- interpolation drags the bright color toward V1 and V2,
  /****\        producing a triangular blob instead of a round spot
 /******\
V1------V2
(dim)  (dim)

You can paper over this by tessellating the mesh until every triangle is smaller than a highlight, but that’s a tax paid against the original speedup. The real lesson was sitting in plain sight: specular lighting needs per-pixel evaluation, not per-vertex.

That was the gap Phong filled four years later.


Phong Shading — Interpolate The Normals, Shade Per Pixel

Bui Tuong Phong’s 1975 insight was that you don’t need to interpolate the color — you need to interpolate the normal. Put a normal at each vertex, let the rasterizer smoothly blend those normals across the triangle, and evaluate the lighting equation at every single pixel using the interpolated normal. The highlight falls exactly where the geometry says it should, regardless of how the triangle is diced up.

The Phong reflection model, evaluated per pixel, looks like this:

color = ambient + kd · max(N · L, 0) + ks · max(R · V, 0)^n

where R = reflect(-L, N) is the mirror reflection of the light direction about the normal, V is the vector from the surface to the camera, and n is the shininess exponent.

Here’s the part that surprised me while I was writing this.

The G-Buffer already implements Phong shading. Not as a philosophical analogy — literally, mechanically, by accident of how deferred shading is built.

The previous post in this series built a G-Buffer geometry pass. That pass interpolates per-vertex normals across every triangle and writes the interpolated result, pixel by pixel, into the Normal attachment. That is exactly Phong’s 1975 move. The lighting pass in this post just reads the already-interpolated normal out of gNormal — no extra work, no special case.

Deferred shading and Phong shading share the same core insight, dressed up in different vocabulary, one decade apart. Phong wanted smooth highlights and paid for them with per-pixel math. Deferred shading wants to avoid redundant work for occluded geometry and ends up paying that same per-pixel cost as a free side effect.

What’s left to do in the lighting pass is the reflection-vector part of the equation — the R term. And that’s where Blinn entered the story.


Blinn-Phong — The Halfway Vector Trick

James Blinn spent 1977 noticing that R = reflect(-L, N) was annoying. Correct, yes, but annoying: a full reflection calculation per pixel, plus a normalize, plus a numerical edge case at grazing angles where the reflection vector can flip chaotically.

His substitution was pure 1970s cleverness. Instead of reflecting L about N and dotting with V, compute the unit vector that sits halfway between L and V, and dot that with N:

H = normalize(L + V)
specular = max(N · H, 0)^n

Geometrically, H equals N exactly when the surface is oriented to reflect light straight back at the camera — the same condition that makes Phong’s R · V equal to one. The two formulations aren’t identical: Blinn’s lobe is narrower for the same exponent, so you tune the shininess up by a factor of two to four to match a Phong highlight by eye. Cheaper, smoother at grazing angles, and close enough to Phong that the rest of the industry stopped using Phong within a decade.

From about 1977 until physically based rendering displaced it in the mid-2010s, Blinn-Phong was the real-time default. Not because it’s right — it isn’t, not in any physical sense — but because the price-to-plausibility ratio was unbeatable for thirty-five years.

This is what my lighting pass actually ships.


The Shader, Step By Step

Here is lighting.frag, verbatim, reading from the three G-Buffer attachments my geometry pass wrote.

#version 450

layout(set = 0, binding = 0) uniform sampler2D gPosition;
layout(set = 0, binding = 1) uniform sampler2D gNormal;
layout(set = 0, binding = 2) uniform sampler2D gAlbedo;

layout(push_constant) uniform LightParams {
    vec4 cameraPos;
    vec4 lightPos;
    vec4 lightColor;   // rgb = color, a = intensity
} light;

layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 outColor;

const float SHININESS_RANGE = 256.0;

void main() {
    vec4 normalSample = texture(gNormal, uv);
    vec4 albedoSample = texture(gAlbedo, uv);

    vec3 fragPos = texture(gPosition, uv).rgb;
    vec3 normal  = normalize(normalSample.rgb);
    vec3 albedo  = albedoSample.rgb;

    // Material params packed into the GBuffer alpha channels by gbuffer.frag.
    float specularStrength = normalSample.a;
    float shininess = albedoSample.a * SHININESS_RANGE;

    // Ambient
    vec3 ambient = 0.05 * albedo;

    // Diffuse (Lambertian)
    vec3 lightDir = normalize(light.lightPos.xyz - fragPos);
    float diff = max(dot(normal, lightDir), 0.0);
    vec3 diffuse = diff * albedo * light.lightColor.rgb * light.lightColor.a;

    // Specular (Blinn-Phong, normalized so peak brightness scales with shininess)
    vec3 viewDir = normalize(light.cameraPos.xyz - fragPos);
    vec3 halfDir = normalize(lightDir + viewDir);
    float shin = max(shininess, 1.0);
    float norm = (shin + 8.0) / 8.0;
    float spec = norm * pow(max(dot(normal, halfDir), 0.0), shin);
    vec3 specular = spec * light.lightColor.rgb * light.lightColor.a * specularStrength;

    // Attenuation
    float dist = length(light.lightPos.xyz - fragPos);
    float attenuation = 1.0 / (1.0 + 0.09 * dist + 0.032 * dist * dist);

    vec3 result = ambient + (diffuse + specular) * attenuation;
    outColor = vec4(result, 1.0);
}

Six beats, each one a deliberate choice with fifty years of history behind it:

  1. Sample the G-Buffer. Position, normal, and albedo come out of the textures written by gbuffer.frag. The alpha channels carry material parameters — I’ll come back to that in the next section.
  2. Ambient floor. vec3 ambient = 0.05 * albedo is the first hack. Real surfaces in shadow aren’t black; they pick up bounced light from the environment. A constant ambient term is the cheapest possible lie that looks right, and every shader in the 80s and 90s paid it. We pay it too. A later post in this series swaps it out for SSAO, which is where this debt comes due.
  3. Lambertian diffuse. max(N · L, 0) is Lambert from 1760 — the single piece of the whole expression with real physical justification.
  4. Blinn-Phong specular. The H vector, dotted with the normal, raised to the shininess exponent. This is the Blinn 1977 swap. Forty years later, here it still is.
  5. Energy normalization. The (shin + 8) / 8 factor is the piece that isn’t in most tutorials and matters the most. Without it, increasing shininess makes the highlight narrower and dimmer — the lobe gets sharper but its total energy drops, so shinier surfaces look darker, which is backwards. Normalizing by (n + 8) / 8 keeps the peak brightness roughly constant as n grows. Real-Time Rendering covers the derivation; Lafortune and Willems wrote the original paper in 1994.
  6. Attenuation. 1 / (1 + 0.09·d + 0.032·d²) is the OpenGL fixed-function falloff — a constant, a linear, and a quadratic term. Physically, light should fall off as 1/d², but the quadratic alone blows up near the light source and produces infinities. The constant-plus-linear padding is another lie that prevents a numerical cliff. It scales poorly to lots of lights, which is the next post’s problem.

Ten meaningful lines of math. Fifty years of decisions inside them.


Material Params As Free Real Estate

Here’s the part of this post that’s specific to this engine.

The G-Buffer in the previous post is three RGBA textures: position, normal, and albedo. Three channels of each carry the thing the name says. But these are RGBA attachments — four channels each — and the alpha channels were doing nothing.

That’s slack in the data contract. Every shading model ever invented for Blinn-Phong needs two more numbers per surface: a specular strength, controlling how reflective the material is, and a shininess exponent, controlling how tight the highlight is. Two floats. And I had two alpha channels doing nothing.

Here’s the C# record that represents a material:

public sealed record MaterialParams(
    float SpecularStrength,
    float Shininess)
{
    public const float ShininessRange = 256f;

    public static readonly MaterialParams Default = new(SpecularStrength: 0.5f, Shininess: 32f);
}

And the push-constants struct that carries it into the geometry pass:

[StructLayout(LayoutKind.Sequential)]
public struct GBufferPushConstants
{
    public Matrix4x4 Model;
    public Matrix4x4 ViewProj;
    public float SpecularStrength;
    public float Shininess;
}

The geometry pass writes the two floats into the spare alpha channels:

// gbuffer.frag
outNormal = vec4(normalize(worldNormal), pc.specularStrength);
outAlbedo = vec4(albedo, pc.shininess / SHININESS_RANGE);

SpecularStrength is already in [0, 1], so it drops straight into an 8-bit alpha channel. Shininess is typically 1 to 256, so I divide by 256 to pack, and the lighting pass multiplies by 256 to unpack. Those are the two lines in lighting.frag you already saw:

float specularStrength = normalSample.a;
float shininess = albedoSample.a * SHININESS_RANGE;

Zero new bindings. Zero extra bandwidth. Zero pipeline changes. The G-Buffer layout didn’t have to be renegotiated; the data shape already had the room.

This is the functional move worth naming. The G-Buffer is a data contract, and the contract had slack. Finding slack in a data shape and filling it is exactly the kind of move pure data structures invite. The shape is the API. Anything the shape allows is legal. No new plumbing, no new sync, no new descriptor set layouts — just two writes on the producer side and two reads on the consumer side, matched by convention about what lives in .a.

The 256 range is not a physical bound. It’s a convention, chosen because it’s where the Blinn-Phong highlight stops visibly changing on my test sphere. A different scene could pick 1024 and rescale in the shader. The encoding is negotiable; the channel is free.


Live Controls — The Imperative Shell At Work

To tune the material, I wanted ImGui sliders. The implementation is one file, and it illustrates something I find myself reaching for constantly:

public static class LightingDebugMenu
{
    public static (PointLight light, MaterialParams material) Draw(PointLight light, MaterialParams material)
    {
        // ... ImGui window setup ...

        var position = DebugFields.DragVector3("Position", light.Position, 0.05f);
        var color = DebugFields.ColorEdit("Color", light.Color);
        var intensity = DebugFields.DragFloat("Intensity", light.Intensity, 0.05f, 0f, 100f);

        var specStrength = DebugFields.SliderFloat("Spec Strength", material.SpecularStrength, 0f, 1f);
        var shininess = DebugFields.SliderFloat("Shininess", material.Shininess, 1f, MaterialParams.ShininessRange,
            flags: ImGuiSliderFlags.Logarithmic);

        return (
            light with { Position = position, Color = color, Intensity = intensity },
            new MaterialParams(specStrength, shininess)
        );
    }
}

PointLight and MaterialParams are immutable records. Draw takes the current pair, draws sliders, reads the new values back, and returns a fresh pair. It mutates nothing. The composition root calls it each frame, takes the returned tuple, and threads it back into the push constants next frame. The lighting pass never learns that a value “changed” — it just sees a different LightingPushConstants this frame than it saw last frame.

This is the functional-core / imperative-shell split working exactly as advertised. The slider is a side effect — ImGui is poking at input state and mutating its own window tree under the hood. That side effect is isolated inside one function, at a visible boundary. On the pure side of the boundary, the only thing that exists is (old state) -> (new state). The shader, the render graph, the scene — none of them know about ImGui. They see immutable state in, immutable state out.

It’s not architecture for its own sake. It means I can swap the debug menu for a scripted animation, a config file, or a networked client, and nothing downstream cares.


What We Built, What We Didn’t

Zooming out. What ships in this post is, almost exactly, the industry default for shading from roughly 1985 to the early 2000s:

  • Phong shading — per-pixel normals, courtesy of the G-Buffer
  • Blinn-Phong specular — halfway-vector highlights
  • Energy normalization — so shinier surfaces don’t get dimmer
  • Lambertian diffuse — the one physically grounded term
  • Constant ambient — a cheap lie that looks right
  • Inverse-polynomial attenuation — the OpenGL fixed-function falloff
  • One light

What I deliberately did not build, yet:

  • Multiple lights. The shader is written for exactly one point light. The G-Buffer decouples lighting cost from geometry cost — that’s its whole selling point — but the lighting shader itself doesn’t scale yet. That’s the next post, and it’s where deferred shading finally earns its name.
  • Directional and ambient lights as first-class objects. Right now a point light is the only primitive. Post 7.
  • Exposing ambient as a lie. The 0.05 * albedo floor is load-bearing and obviously wrong. Screen-space ambient occlusion replaces it in post 8.
  • Energy conservation, Fresnel, microfacet distributions. Physically based rendering, much later in the arc.

The G-Buffer was a promise that lighting and geometry could live in different worlds. This post cashed that promise for one light. The next post cashes it for many — and when it does, the shape of the lighting pass stops looking like a single push constant and starts looking like a storage buffer fed by a culling step. That’s where deferred shading stops being a curiosity and starts paying rent.