Light casters

Every light so far has been a single point in space, shining equally in all directions and equally far. That is a fine start, but real lights come in different kinds: the sun, a bare bulb, a torch. A source that casts light onto objects is called a light caster, and in this chapter we look at the three classic kinds: directional lights, point lights (a better version of what we have had) and spotlights. In the next chapter we combine them all in one scene.

It helps to see what actually differs between them. For every pixel, the lighting from the previous chapters needs two things from a light: the direction towards it, and how much of its light arrives. The Phong terms themselves do not care what kind of light it is. So each light caster is really an answer to those two questions:

LearnOpenGL writes a separate program for each. Our lesson is a single program with a single shader, and keys 1, 2 and 3 switch between the three kinds. It starts on the spotlight, number 3, which is the most fun to fly around with.

Directional light

When a light source is very far away, the rays that reach us from it are practically parallel. Wherever an object is in the scene, the light comes at it from the same direction. Model the source as infinitely far away and the light's position stops mattering altogether; only its direction is left. That is a directional light.

Parallel rays from a distant sun falling on several objects (LearnOpenGL's diagram)

The sun is the obvious example. It is not infinitely far away, but compared to anything in our scene it might as well be, and its rays across a whole landscape are parallel for all practical purposes.

In the shader, a directional light needs no position; it is described by the direction its rays travel. Our lighting formula wants the opposite direction, from the surface towards the light, so we negate it:

float3 to_light = normalize(-light_direction);

People generally find it natural to describe a light by where it points ("down, a little forward"), so that is how the constant is given, and the shader does the flipping. It also normalizes, since there is no reason to trust that a direction typed into a program has length 1. The rest of the lighting is unchanged: the diffuse and specular terms use to_light exactly as before. Our lesson's sun points mostly downwards:

.direction = v3(-0.2, -1.0, -0.3),

A single crate would not show off a light that treats every object alike, so this lesson brings back the ten crates from Coordinate Systems, each with its own position and a rotation that grows with its index:

// world space positions of our cubes
cube_positions: [10]vec3 = {
    { .x = 0.0, .y = 0.0, .z = 0.0 },
    { .x = 2.0, .y = 5.0, .z = -15.0 },
...
};
for (i: u32 = 0; i < 10; i += 1) {
    draw_cube(context, transforms_buffer, t&, m4_mul(m4_translate(cube_positions[i]), m4_rotate(radians(20.0 * cast(i, f32)), v3(1.0, 0.3, 0.5))));
}

Press 1, and fly around:

Ten crates under a directional light: every upward-facing side is lit the same way

Every crate is lit as if by a sun high up in the sky. Faces turned upwards are bright on every crate alike, however near or far; faces turned away are left in ambient light. There is no light cube in this mode, since a directional light has no position to put one at.

Some programs store both kinds of light vector as a float4 and use the fourth component to tell them apart: w = 1 for a position, which translations move, and w = 0 for a direction, which they do not. The old fixed-function OpenGL decided between positional and directional lights exactly that way. We keep an explicit mode instead, which is easier to read.

Point lights

Directional light is good for a sun lighting a whole scene, but most lights are local: a light bulb, a candle, a torch on a wall. Each sits somewhere in the world and shines in every direction, and its light grows weaker with distance. That is a point light.

A point light sending rays out in every direction, weakening with distance (LearnOpenGL's diagram)

The lights of the previous chapters were point lights of a sort: they had a position and shone in all directions. What they lacked was the falloff. With ten crates you would see the problem straight away: the crate at the very back, fifteen units away, would be lit just as brightly as the one right next to the light.

Attenuation

The fading of light over distance is called attenuation. A straight line, dimming the light evenly as distance grows, looks artificial. Real lights are very bright up close, fall off quickly over the first stretch, and then fade slowly over a long tail. A formula with that shape, widely used in real-time graphics, is

\[ F_{att} = \frac{1}{K_c + K_l \cdot d + K_q \cdot d^2} \]

where \(d\) is the distance from the pixel to the light and the three \(K\)s are knobs we choose:

Together they give exactly the shape we wanted: intense near the light, a quick drop, then a long, slow tail. Here is the curve LearnOpenGL plots for a light reaching about 100 units:

Attenuation falling steeply over the first units of distance and then slowly towards zero by distance 100 (LearnOpenGL's diagram)

Choosing the right values

Which values to pick depends on how far a light should reach and on the look of the scene; in the end it comes down to trying them out. A good starting point is a table of values from the Ogre3D wiki, which LearnOpenGL also uses. Each row gives terms for a light that covers roughly the distance in the first column:

DistanceConstantLinearQuadratic
71.00.71.8
131.00.350.44
201.00.220.20
321.00.140.07
501.00.090.032
651.00.070.017
1001.00.0450.0075
1601.00.0270.0028
2001.00.0220.0019
3251.00.0140.0007
6001.00.0070.0002
32501.00.00140.000007

The constant stays at 1 throughout, and the other two shrink as the reach grows, the quadratic one much faster. For scenes the size of ours, somewhere between 32 and 100 units is usually right.

Implementing attenuation

LearnOpenGL adds the three terms to its light struct as uniforms. Our lesson uses the row for 50 units for every light, so it simply writes them into the shader:

if (mode != 1)
{
    to_light = normalize(light_position - p.world);
    float d = length(light_position - p.world);
    strength = 1.0 / (1.0 + 0.09 * d + 0.032 * d * d);
}

For a point light (and the spotlight, which is built on it) the direction comes from the light's position again, as in the earlier chapters, and HLSL's length gives the distance. strength starts at 1, which is what a directional light keeps: the sun does not fade with distance. At the end of the shader it scales the light's contribution:

float ambient_strength = mode == 3 ? 1.0 : strength;
return float4(ambient * ambient_strength + (diffuse + specular) * strength, 1.0);

For a point light the ambient part fades too. One could leave ambient alone, so that some light reaches everywhere, but as soon as there are several lights their ambient parts pile up; attenuating them keeps each light's ambient glow near the light. (What the spotlight does differently is the next section.)

The point light stands where the light stood in the previous chapters, and its light cube is drawn there only in this mode:

point_position: vec3 = v3(1.2, 1.0, 2.0);
The same crates under a point light: nearby faces are lit, distant crates are almost dark

Press 2. The crates nearest the light, at the front and to the right, catch the most of it; further back it fades, and the most distant crates are left with little more than ambient light. The light cube itself is just out of view here, above and to the right of the camera; look up and right (right mouse button) to find it.

Spotlight

The last kind is the spotlight: a light at a position that shines not in all directions but only in a cone around one direction. Inside the cone things are lit; outside they are dark. Street lamps, stage lights and flashlights all behave this way.

A spotlight needs a position, the direction it points, and a cutoff angle giving the size of the cone. For each pixel we check whether it falls inside the cone:

A spotlight's direction, the direction from a fragment to the light, the angle theta between them and the cutoff angle phi (LearnOpenGL's diagram)

As with diffuse lighting, we do not need the angle itself. The dot product of two unit vectors gives its cosine, so we compare cosines instead.

Flashlight

The spotlight in our lesson is a flashlight: a spotlight held by the viewer, always at the camera's position and pointing where the camera looks. All it takes is to copy the camera's position and front vector into the light every frame:

if (mode == 3) {
    light.position = cam.position;
    light.direction = cam.front;
}

The cone's angles are passed as cosines, worked out once on the CPU:

light.inner_cos = cosf(radians(12.5));
light.outer_cos = cosf(radians(17.5));

This saves the shader work. The dot product hands us \(\cos\theta\); to compare angles we would have to take its arc cosine, an expensive function, for every pixel. Comparing \(\cos\theta\) with \(\cos\phi\) gives the same answer for free. There is one thing to keep straight, though: the cosine shrinks as the angle grows.

The cosine curve from 0 to 90 degrees: 1 at 0 degrees, falling to 0 at 90 (LearnOpenGL's diagram)

So "\(\theta\) is less than the cutoff angle" becomes "\(\cos\theta\) is greater than the cutoff's cosine". For a cutoff of 12.5 degrees, whose cosine is about 0.976, a pixel is inside the cone when \(\cos\theta\) lies between 0.976 and 1. In the shader, \(\cos\theta\) is the dot product of the direction to the light with the reversed spotlight direction:

float theta = dot(to_light, normalize(-light_direction));

Both point away from the pixel, towards the light, so their dot product is 1 when the pixel lies exactly along the beam. A first, hard-edged spotlight would light the pixel when theta > inner_cos and leave it in ambient light otherwise. That works, but the beam ends at a sharp edge, as if cut out with scissors, and real flashlights are not like that.

Smooth/soft edges

To soften the edge, we give the spotlight two cones: an inner cone, inside which the light is at full strength, and a wider outer cone, outside which there is none. Between the two, the light fades from full to nothing. Ours are 12.5 and 17.5 degrees.

With \(\phi\) the inner cutoff's cosine and \(\gamma\) the outer one's, the intensity at a pixel is

\[ I = \frac{\theta - \gamma}{\epsilon}, \qquad \epsilon = \phi - \gamma \]

where, in keeping with the shader, \(\theta\) now stands for the cosine. It is a linear ramp between the two cosines: \(I = 1\) on the inner cone's edge, \(I = 0\) on the outer one's. Let's try it with our angles. Their cosines are \(\phi = \cos 12.5° \approx 0.976\) and \(\gamma = \cos 17.5° \approx 0.954\), so \(\epsilon \approx 0.023\). A pixel 15 degrees off the beam, halfway between the two, has \(\theta \approx 0.966\), and

\[ I \approx \frac{0.966 - 0.954}{0.023} \approx 0.54 \]

about half strength, as expected. A pixel right on the beam gives \(I \approx 2\), and one far outside a large negative number, so we clamp \(I\) to the range 0 to 1. HLSL has a function for exactly that clamp, saturate:

if (mode == 3)
{
    // 1 inside the inner cone, 0 outside the outer one, a ramp between.
    float theta = dot(to_light, normalize(-light_direction));
    strength *= saturate((theta - outer_cos) / (inner_cos - outer_cos));
}

Once clamped, the intensity needs no if at all: it simply multiplies into strength, on top of the distance attenuation the spotlight inherits from point lights. The ambient part is the exception. Looking back at the last line of the shader, in spot mode the ambient term gets a strength of 1, untouched by both the cone and the distance:

// The flashlight leaves ambient alone, so outside its cone is dark but not black.
float ambient_strength = mode == 3 ? 1.0 : strength;

Without that, everything outside the beam would be pure black, which is too harsh even for a dark scene.

The crates in darkness, with the flashlight's soft-edged beam lighting the front of the nearest crate

Press 3, or just start the program. The beam lights the crate in front of us with a soft edge, and everything else waits in the dark until we turn towards it. Perfect for a horror game.

Switching between them

Here is how one shader does all three. The light's constant buffer carries everything any of the modes needs, plus the mode itself:

cbuffer light : register(b1)
{
    float3 light_position;
    int mode;               // 1 directional, 2 point, 3 spot
    float3 light_direction; // where the light points
    float inner_cos;
    float3 light_ambient;
    float outer_cos;
    float3 light_diffuse;
    float shininess;
    float3 light_specular;
};

Look at how it is arranged: every scalar sits right after a float3, filling the spare four bytes of that register. Five registers hold the lot. The rin struct follows the same order, and so it needs only one pad at the very end:

light_constants: struct = {
    position: vec3;
    mode: i32;
    direction: vec3;
    inner_cos: f32;      // cosines, so the shader compares without an acos
    ambient: vec3;
    outer_cos: f32;
    diffuse: vec3;
    shininess: f32;
    specular: vec3;
    pad: f32;
}

The keys are read the way Hello Triangle reads Tab: the window procedure records each key press as it arrives, ignoring the repeats Windows sends while a key is held,

// Bit 30 of lparam is set when the key was already down: a repeat.
if (msg == WM_KEYDOWN && wparam < 256 && (lparam & 0x40000000) == 0) {
    keys_pressed[wparam] = 1;
    return 0;
}

and the render loop picks up any of 1 to 3:

for (key: i32 = '1'; key <= '3'; key += 1) {
    if (key_pressed(key)) { mode = key - '0'; }
}

The branches in the shader depend only on mode, which is the same for every pixel of a draw, so the GPU never has to run both sides of one for different pixels; switching this way costs next to nothing.

The full program is here, and its shader here.

Additional resources

Exercises

  1. Make the spotlight hard-edged, lit when theta > inner_cos and unlit otherwise, and compare. Then try < instead of >, and drop the minus sign from -light_direction; explain what you see in each case.
  2. Try other rows of the attenuation table for the point light, such as 13 or 100, and watch how far into the scene the light reaches.
  3. Turn the flashlight into a fixed lamp: give the spotlight a position above the crates and a direction straight down, instead of the camera's. Draw the light cube there too.
Credit: this chapter follows LearnOpenGL's Light casters by Joey de Vries, rewritten for Direct3D 11 and rin. 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.