Advanced Lighting

Back in the Lighting chapters we built the Phong lighting model, and it has served us well: three cheap terms that together make a surface look lit. It is not perfect, though, and its specular term in particular has a flaw that is easy to miss until you go looking for it. This chapter is about that flaw and the small change, due to James Blinn, that removes it. It is a short chapter, but the model it ends with is the one every later lesson in this part of the book lights with.

Blinn-Phong

Phong's highlight is good at small, sharp shines, the kind a high shininess exponent gives. It goes wrong in the opposite case: a rough surface, a low exponent, and so a wide, soft highlight. Look at a flat floor lit from just above with an exponent of 1, and the highlight does not fade out at its edges. It stops, with a visible rim, as if someone had cut it out with scissors.

Recall how the term is computed. We reflect the light direction about the normal, and compare the reflection with the direction to the eye:

spec = pow(max(dot(to_eye, reflect(-to_light, n)), 0.0), shininess)

The max clamps the cosine to zero once the angle between the two vectors passes 90 degrees, since a negative cosine raised to a power is meaningless. For the diffuse term that same clamp is exactly right: an angle past 90 degrees between the normal and the light means the light is behind the surface. The specular term is measuring something else, though: the angle between the eye and the reflected ray. That angle can pass 90 degrees while the light is still well in front of the surface and the eye can still see it. LearnOpenGL's diagram shows the two cases side by side:

Left: view and reflection vectors less than 90 degrees apart. Right: more than 90 degrees apart, so the specular term is clamped to zero (LearnOpenGL's diagram)

On the right, the eye is far from the reflected ray and Phong says there is no highlight. With a high exponent that is harmless: the highlight would have been practically zero there anyway, because the cosine of a large angle raised to the power 32 or 64 is tiny. With a low exponent the highlight is wide enough to still have a real value at 90 degrees, and the clamp chops it from that value straight to zero. That jump is the rim.

In 1977 Blinn proposed measuring a different angle. Instead of reflecting the light, take the halfway vector: the unit vector exactly halfway between the direction to the light and the direction to the eye. Then compare it with the normal. When the eye sits right on the reflected ray, the halfway vector lines up exactly with the normal and the highlight is at its brightest; as the eye moves away from the reflected ray, the halfway vector tilts away from the normal and the highlight dims.

The halfway vector H between the light direction L and the view direction V, compared with the normal N (LearnOpenGL's diagram)

The gain is that the halfway vector stays within 90 degrees of the normal for any eye position that can see the front of the surface, as long as the light is in front of it too. Both directions are in the upper hemisphere, so their average is as well. The cosine never goes negative where it matters, the clamp never bites, and the highlight fades all the way to its edge. This model, Blinn-Phong, is also the one the old fixed-function pipelines of both OpenGL and Direct3D used for their built-in lights.

Getting the halfway vector is one addition and one normalization:

\[\bar{H} = \frac{\bar{L} + \bar{V}}{\|\bar{L} + \bar{V}\|}\]

and the specular term becomes the clamped cosine between it and the normal, raised to the shininess exponent as before. In our shader it reads:

float3 halfway = normalize(to_light + to_eye);
spec = pow(max(dot(n, halfway), 0.0), 4.0);

That is all there is to it. Phong measures the angle between the eye and the reflection; Blinn-Phong measures the angle between the normal and the halfway vector. Everything else in the lighting stays the same.

Matching exponents

The two models do not give the same highlight for the same exponent. The angle between the normal and the halfway vector is smaller than the angle between the eye and the reflection; for a light, normal and eye in one plane it is exactly half. A smaller angle has a larger cosine, so with the same exponent Blinn-Phong's highlight comes out wider. To get roughly the size of highlight Phong gave you, raise Blinn-Phong's exponent: two to four times Phong's is the usual rule of thumb. LearnOpenGL compares Phong at 8 with Blinn-Phong at 32, for example. Expect some tweaking when moving an existing scene from one model to the other.

Cost

Blinn-Phong is also cheap, a little cheaper than Phong in fact. reflect is a dot product, a multiply and a subtraction, and the halfway vector is an addition and a normalization, so per pixel the two are close. Where Blinn-Phong pulled ahead historically is when both the light and the viewer are far away: a directional light, with the eye treated as infinitely distant. Then the light direction and the view direction are the same for every point, so the halfway vector is too, and it can be computed once per frame on the CPU instead of once per pixel. Phong's reflected vector depends on the normal, so it can never be shared that way. Fixed-function hardware relied on exactly this shortcut, and Direct3D 9's D3DRS_LOCALVIEWER render state was the switch between the cheap distant-viewer version and the exact one.

On modern GPUs neither cost matters much, and we compute the halfway vector per pixel, from the real eye and light positions, everywhere in this book. The choice between the two models is about how they look, and Blinn-Phong looks better.

The lesson

The program draws a wooden floor, twenty units square with its texture repeated ten times, and a small white cube marking a point light that hangs at the origin, half a unit above the floor. The camera starts three units back at the light's own height, so it looks along the floor at a low angle: the case where the eye is far from the reflected ray for most of the floor, and Phong's clamp shows. Tab switches between the two models.

The pixel shader needs the light's position and which model to use. Both go in one small constant buffer, mirrored on the CPU by a struct:

; Mirrors `cbuffer light` in advanced_lighting.hlsl.
light_constants struct
    position vec3 <>
    blinn dword ?
light_constants ends
cbuffer light : register(b1)
{
    float3 light_position;
    int blinn;
};

This one needs no padding. A float3 fills three quarters of a 16-byte register and the int slots into the last four bytes, so the assembly struct, a vec3 of three real4s and a dword, lines up byte for byte. (The Advanced Data chapter has the packing rules.) The flag is an int to match the dword plainly, and the shader tests it with if (blinn), which treats any non-zero value as true. The global starts out with the light at the origin and the flag set:

light light_constants <<0.0, 0.0, 0.0>, 1>

Tab flips the flag in the render loop, and the flag is copied into the buffer every frame along with the position:

; mode 0 is Blinn-Phong, mode 1 plain Phong
invoke window_mode, 2
mov ecx, 1
sub ecx, eax
mov light.blinn, ecx
; the floor, lit from the origin
invoke update_buffer, light_buffer, ADDR light, sizeof light_constants
invoke shader_use, ADDR s
invoke bind_constants, 1, light_buffer

window_mode, in window.asm, is how every lesson from here on switches between its variants. It is given the number of modes and returns the current one in eax: Tab steps to the next, wrapping round, and the keys 1 to 9 pick one directly. It reads key presses that the window procedure's WM_KEYDOWN handler recorded, and forgets them as it reads, so holding Tab down changes the mode once rather than every frame. Here there are two modes, and the flag is one minus the mode, so the program starts in Blinn-Phong. The pixel shader then does ordinary lighting with one of the two specular terms:

// Deliberately rough (low exponents, wide highlights): the case where the
// difference shows. Blinn needs about four times Phong's exponent to match.
float4 ps_main(vs_out p) : SV_Target
{
    float3 color = albedo.Sample(linear_wrap, p.uv).rgb;
    float3 n = normalize(p.normal);
    float3 to_light = normalize(light_position - p.world);
    float3 to_eye = normalize(view_position - p.world);

    float spec;
    if (blinn)
    {
        float3 halfway = normalize(to_light + to_eye);
        spec = pow(max(dot(n, halfway), 0.0), 4.0);
    }
    else
    {
        spec = pow(max(dot(to_eye, reflect(-to_light, n)), 0.0), 1.0);
    }

    float3 ambient = 0.05 * color;
    float3 diffuse = max(dot(n, to_light), 0.0) * color;
    return float4(ambient + diffuse * 0.5 + 0.5 * spec, 1.0);
}

The exponents are 4 for Blinn-Phong and 1 for Phong, much lower than you would pick for a real material, and on purpose: the whole point of this program is to put a wide highlight on screen so that Phong's edge has somewhere to show. The vectors are the usual ones from Basic Lighting, worked out in world space, with the eye position view_position coming from the shared transforms buffer in register b0.

Here is the floor with Blinn-Phong, the mode the program starts in, and then with Phong after pressing Tab:

Blinn-Phong: the highlight on the floor fades out smoothly into the darkPhong: the highlight ends in a curved edge, beyond which the floor is suddenly darker

With Phong (right), the bright patch in front of the camera ends along a curved line, and past it the floor drops abruptly to its diffuse light alone. That line is where the angle between the eye and the reflected ray crosses 90 degrees. With Blinn-Phong (left), the same patch thins out gradually and there is no line to find. Move the camera around (W, A, S, D, and the right mouse button to look) and the edge moves with you, because it depends on where the eye is.

Blinn-Phong is not only a fix for an edge case. For most surfaces it is also simply closer to how real materials reflect light, which is why the rest of this book uses it: from the next chapter on, every specular term is computed from the halfway vector. The full program is here, and its shader here.

Additional resources

Exercises

  1. Raise both exponents in advanced_lighting.hlsl, say Phong to 8 and Blinn-Phong to 32, and switch between the modes. Does the edge still show? Try to find the Blinn-Phong exponent that makes its highlight about the same size as Phong's at 8.
  2. Move the light up, to <0.0, 2.0, 0.0> in the light global's initializer, and compare the two modes again. Where does Phong's edge go, and why? Sketch the light, the eye and a point on the floor beyond the light to work it out.
  3. Output only the specular term, float4(spec.xxx, 1.0), for both modes. Looking at the highlight on its own makes the cut-off very clear.
Credit: this chapter follows LearnOpenGL's Advanced Lighting by Joey de Vries, rewritten for Direct3D 11 and x64 assembly. LearnOpenGL's text and code are licensed CC BY-NC 4.0, and so is this page; the wording, code and screenshots here are new, and diagrams marked as LearnOpenGL's are its own.