Light

The first step to add “realism” to our rendering of the scene will be the simulation of light. Light is an insanely complex topic, so we’ll present a very simplified model that is good enough for our purposes. Some parts of this model aren’t even an approximation of the physical models; they’re just fast and look good.

We’ll start with some simplifying assumptions that will make our lives easier.

First, we declare that all light is white. This lets us characterize any light using a single real number, \(i\), called the intensity of the light. Simulating colored lights isn’t that complicated (you just use three intensity values, one per color channel, and compute all color and lighting channel-wise) but in order to keep this work simple, we won’t go there.

Second, we’ll ignore the atmosphere. This means lights don’t become any less bright no matter how far away they are. Again, attenuating the intensity of the light based on distance isn’t that complicated, but we’ll leave it out for clarity.

Light sources

Light has to come from somewhere. In this section we’ll define three different types of light sources.

Point lights

A point light emits light from a fixed point in space, called its position. Light is emitted equally in every direction; this why they’re also called omnidirectional lights. A point light is therefore fully characterized by its position and its intensity.

A good real-life example of what a point light approximates is a lightbulb. While a lightbulb doesn’t emit light from a single point, and it isn’t perfectly omnidirectional, the approximation is good enough.

Let’s define the vector \(\vec{L}\) as the direction from a point in the scene, \(P\), to the light, \(Q\). This vector, called the light vector, is just \(Q - P\). Note that since \(Q\) is fixed and \(P\) can be any point in the scene, in general \(\vec{L}\) is different for every point in the scene.

Directional lights

If a point light is a good approximation of a lightbulb, does it also work as an approximation of the Sun?

It’s a tricky question, and the answer depends on what are you trying to render.

At the solar system scale, the Sun can be approximated as a point light. After all, it emits light from a point (a rather big point, though) and it emits in all directions, so it seems to qualify.

However, if your scene represents something happening on Earth, it’s not such a good approximation. The Sun is so far away from us that every ray of light effectively has the same direction1. While you could approximate this with a point light very, very, very far away from the scene, this distance and the distance between the objects in your scene are so different in magnitude you’d start running into numerical accuracy errors.

For these cases, we define directional lights. Like point lights, a directional light has an intensity, but unlike them, it doesn’t have a position; instead, it has a direction. You can think of them as infinitely distant point lights at the specified direction.

While in the case of point lights we needed to compute a different light vector \(\vec{L}\) for every point \(P\) in the scene, in this case \(\vec{L}\) is given. In the Sun-to-Earth-scene example, \(\vec{L}\) would be \((\text{center of Sun}) - (\text{center of Earth})\).

Ambient light

Can every real-life light be modeled as a point or directional light? Pretty much2. Are these two types of lights enough for our purposes? Unfortunately, no.

Consider what happens to the Moon. The only significant light source nearby is the Sun. So the “front half” of the Moon respect to the sun gets all its light, and the “back half” is in complete darkness. We see this from different angles from Earth, creating what we call the “phases” of the Moon.

However, the situation on Earth is a bit different. Even points that don’t receive light directly from a light source aren’t completely in the dark (just look at the floor under your table). How do rays of light reach these points if their “view” of the light sources is obstructed by something else?

As mentioned in the Color Models section, when light hits an object, part of it is absorbed, but the rest is scattered back into the scene. This means that light can come not only from light sources, but also from objects who get it from light sources and scatter it back. But why stop there? The scattered light will in turn hit some other object, part of it will be absorbed, and part of it will be scattered back into the scene. Light loses part of its brightness with each bounce, but in theory we could continue ad infinitum3.

This means we should treat every object as a light source. As you can imagine, this would add a lot of complexity to our model, so we won’t go that way4.

But we still don’t want every object to be either directly illuminated or completely dark (unless we’re actually rendering a model of the Solar System). To overcome this limitation, we’ll define a third type of light source, called ambient light, which is characterized only by its intensity. It’s assumed it unconditionally contributes some light to every point in the scene. It’s a gross oversimplification of the very complex interaction between the light sources and the surfaces in the scene, but it works.

Illumination of a single point

In general, a scene will have a single ambient light (since the ambient light only has an intensity value, any number of them can be trivially combined into a single ambient light), and an arbitrary number of point and directional lights.

In order to compute the illumination of a point, we’ll just compute the amount of light contributed by each light source and add them together to get a single number representing the total amount of light it receives. We can then multiply the color of the surface at that point by this number, to get the appropriately lit color.

So what happens when a ray of light with direction \(\vec{L}\), be it from a directional light or a point light, hits a point \(P\) on some object in our scene?

We can intuitively classify objects in two broad classes, depending on what they do with light: “matte” and “shiny”. Since most objects around us can be classified as “matte”, we’ll focus on them first.

Diffuse reflection

When a ray of light hits a matte object, because its surface is quite irregular at the microscopic level, it’s reflected back into the scene equally in every direction; hence “diffuse” reflection.

To verify this, look at some matte object nearby, for example a wall; if you move respect to the wall, its color doesn’t change. That is, the light you see reflected from the object is the same no matter from where you’re looking at the object.

On the other hand, the amount of light reflected does depend on the angle between the ray of light and the surface. Intuitively, this happens because the energy carried by the ray has to spread over a smaller or bigger area depending on the angle, so the energy reflected to the scene per unit of area is higher or lower respectively:

To explore this mathematically, let’s characterize the orientation of a surface by its normal vector. The normal vector, or simply “the normal”, is a vector perpendicular to the surface at some point. It’s also an unit vector, meaning its length is \(1\). We’ll call this vector \(\vec{N}\).

Modeling diffuse reflection

So a ray of light of direction \(\vec{L}\) and intensity \(I\) hits a surface with normal \(\vec{N}\). What fraction of \(I\) is reflected back to the scene, as a function of \(I\), \(\vec{N}\) and \(\vec{L}\)?

As a geometric analogy, let’s represent the intensity of the light as the “width” of the ray. Its energy spreads over a surface of size \(A\). When \(\vec{N}\) and \(\vec{L}\) have the same direction, that is, the ray is perpendicular to the surface, \(I = A\), which means the energy reflected per unit of area is the same as the incident energy per unit of area; \({I \over A} = 1\). On the other hand, as the angle between \(\vec{L}\) and \(\vec{N}\) approaches \(90^\circ\), \(A\) approaches \(\infty\), so the energy per unit of area approaches 0; \(\lim_{A \to \infty} {I \over A} = 0\). But what happens in-between?

The situation is depicted in the diagram below. We know \(\vec{N}\), \(\vec{L}\) and \(P\); I added the angles \(\alpha\) and \(\beta\), and the points \(Q\), \(\vec{R}\) and \(S\) to make writing about the diagram easier.

Since a ray of light technically has no width, we can assume that everything happens in an infinitesimally small, flat patch of the surface. Even if it’s the surface of a sphere, the area we’re considering is so infinitesimally small that it’s almost flat in comparison with the size of the sphere, just like the Earth looks flat at small scales.

The ray of light, with a width of \(I\), hits the surface at \(P\), at an angle \(\beta\). The normal at \(P\) is \(\vec{N}\), and the energy carried by the ray spreads over \(A\). We need to compute \({I \over A}\).

Consider \(SR\), the “width” of the ray. By definition, it’s perpendicular to \(\vec{L}\), which is also the direction of \(PQ\). Therefore, \(PQ\) and \(QR\) form a right angle, making \(PQR\) a right triangle.

One of the angles of \(PQR\) is \(90^\circ\), and another is \(\beta\). The third angle is then \(90^\circ - \beta\). But note that \(\vec{N}\) and \(PR\) also form a right angle, which means \(\alpha + \beta\) must also be \(90^\circ\). Therefore, \(\widehat{QRP} = \alpha\):

Let’s focus on the triangle \(PQR\). Its angles are \(\alpha\), \(\beta\) and \(90^\circ\). The side \(QR\) measures \(I \over 2\), and the side \(PR\) measures \(A \over 2\).

And now… trigonometry to the rescue! By definition, \(cos(\alpha) = {QR \over PR}\); substituting \(QR\) with \(I \over 2\) and \(PR\) with \(A \over 2\) we get

\[ cos(\alpha) = { {I \over 2} \over {A \over 2} } \]

which becomes

\[ cos(\alpha) = {I \over A} \]

We’re almost there. \(\alpha\) is the angle between \(\vec{N}\) and \(\vec{L}\), so \(cos(\alpha)\) can be expressed as

\[ cos(\alpha) = {{\langle \vec{N}, \vec{L} \rangle} \over {|\vec{N}||\vec{L}|}} \]

And finally

\[ {I \over A} = {{\langle \vec{N}, \vec{L} \rangle} \over {|\vec{N}||\vec{L}|}} \]

We have now arrived at a really simple equation linking the fraction of light reflected with the angle between the surface normal and the direction of the light.

Note that for angles over \(90^\circ\) the value of \(cos(\alpha)\) becomes negative. If we blindly use this value, we can end up with light sources that subtract light. This doesn’t make any physical sense; an angle over \(90^\circ\) just means the light is actually reaching the back of the surface, and it doesn’t contribute any light to the point we’re illuminating. So if \(cos(\alpha)\) becomes negative, we’ll treat it as if it was \(0\).

The diffuse reflection equation

We can now formulate an equation to compute the full amount of light received by a point \(P\), with normal \(\vec{N}\), in a scene with an ambient light of intensity \(I_A\) and \(n\) point or directional lights with intensity \(I_n\) and light vectors \(\vec{L_n}\) either known (for directional lights) or computed for P (for point lights):

\[ I_P = I_A + \sum_{i = 1}^{n} I_i {{\langle \vec{N}, \vec{L_i} \rangle} \over {|\vec{N}||\vec{L_i}|}} \]

It’s worth repeating that the terms where \(\langle \vec{N}, \vec{L_i} \rangle < 0\) shouldn’t be added to the point’s illumination.

Sphere normals

There’s only a tiny detail missing: where do the normals come from?

This question is far trickier than it seems, as we’ll see in the second part of the book. Fortunately, there’s a very simple solution for the case we’re dealing with: the normal vector of any point of a sphere lies on a line that goes through the center of the sphere. So if the sphere center is \(C\), the direction of the normal at point \(P\) is \(P - C\):

Why did I say “the direction of the normal” and not “the normal”? Besides being perpendicular to the surface, we defined the normal to be an unit vector; this would be true only if the radius of the sphere was \(1\), which may not be the case. To compute the actual normal, we need to divide the vector by its own length, thus guaranteeing the result has length \(1\):

\[ \vec{N} = {{P - C} \over {|P - C|}} \]

This is mostly of theoretical interest, since the lighting equation as written above includes the division by \(|\vec{N}|\), but it’s a good idea to have “true” normals; this will make our lives easier later.

Rendering with diffuse reflection

Let’s translate all of this to pseudocode. First, let’s add a couple of lights to the scene:

light {
    type = ambient
    intensity = 0.2
}
light {
    type = point
    intensity = 0.6
    position = (2, 1, 0)
}
light {
    type = directional
    intensity = 0.2
    direction = (1, 4, 4)
}

Note that the intensities conveniently add up to \(1.0\); because of the way the lighting equation works, no point can have a greater light intensity than this. This means we won’t have any “over-exposed” spots.

The lighting equation is fairly easy to translate to pseudocode:

ComputeLighting(P, N) {
    i = 0.0
    for light in scene.Lights {
        if light.type == ambient {
            i += light.intensity
        } else {
            if light.type == point
                L = light.position - P
            else
                L = light.direction

            n_dot_l = dot(N, L)
            if n_dot_l > 0
                i += light.intensity*n_dot_l/(length(N)*length(L))
        }
    }
    return i
}

And the only thing left is to use ComputeLighting in TraceRay. We replace the line that returns the color of the sphere

    return closest_sphere.color

with this snippet:

    P = O + closest_t*D  # Compute intersection
    N = P - closest_sphere.center  # Compute sphere normal at intersection
    N = N / length(N)
    return closest_sphere.color*ComputeLighting(P, N)

Just for fun, let’s add a big yellow sphere:

sphere {
    color = (255, 255, 0)  # Yellow
    center = (0, -5001, 0)
    radius = 5000
}

We run the renderer, and lo and behold, the spheres now start to look like spheres!

Source code and live demo >>

But wait, how did the big yellow sphere turn into a flat yellow floor?

It hasn’t; it’s just so big compared to the other three, and the camera is so close to it, that it looks flat; just like our planet looks flat when we’re standing on its surface.

Specular reflection

We now turn our attention to “shiny” objects. Unlike “matte” objects, “shiny” objects seem to change their appearance as you look at them from different points of view.

Take a billiards ball, or a car just out of the car wash. These kind of objects exhibit very specific light patterns, usually bright spots, that seem to move as you turn around them. Unlike matte objects, the way you perceive the surface of these objects does actually depend on your point of view.

Note that a red billiards ball stays red if you walk a couple of steps to the side; but the bright white spot which gives it its shiny appearance does seem to move. This means this new effect doesn’t replace diffuse reflection, but instead complements it.

Why does this happen? We can start with why it doesn’t happen in matte objects. As we saw in the previous section, when a ray of light hits the surface of a matte object, it’s scattered back to the scene equally in every direction. Intuitively, this is because the surface of the object is irregular, so at the microscopic level it behaves like a set of tiny surfaces pointing in random directions:

But what if the surface isn’t that irregular? Let’s go to the other extreme: a perfectly polished mirror. When a ray of light hits a mirror, it’s reflected in a single direction, which is the symmetric of the incident angle respect to the mirror normal. If we call the direction of the reflected light \(\vec{R}\), and we keep the convention that \(\vec{L}\) points to the light source, this is the situation:

Depending on the degree of “polish” of the surface, it behaves more or less like a mirror; hence “specular” reflection5.

For a perfectly polished mirror, the incident ray of light \(\vec{L}\) is reflected in a single direction, \(\vec{R}\). This is what lets you see perfectly clear objects on a mirror: for every incident ray of light \(\vec{L}\), there’s a single reflected ray \(\vec{R}\). But not every object is perfectly polished; while most of the light is reflected in the direction of \(\vec{R}\), some of it is reflected in directions close to \(\vec{R}\); the closer to \(\vec{R}\), the more light is reflected in that direction. The “shininess” of the object is what determines how fast the reflected light decreases as you move away from \(\vec{R}\):

What interests us is to figure out how much light from \(\vec{L}\) is reflected back in the direction of our point of view (because that’s the light we use to determine the color of each point). If \(\vec{V}\) is the “view vector”, pointing from \(P\) to the camera, and \(\alpha\) is the angle between \(\vec{R}\) and \(\vec{V}\), here’s what we have:

For \(\alpha = 0^\circ\), all of the light is reflected. For \(\alpha = 90^\circ\), no light is reflected. As it was the case with diffuse reflection, we need a mathematical expression to determine what happens for intermediate values of \(\alpha\).

Modeling specular reflection

Remember when I mentioned earlier that not all models are based on physical models? Well, this is one of them. The following model is arbitrary, but it’s used because it’s easy to compute and it looks good.

Let’s take \(cos(\alpha)\). It has the nice properties that \(cos(0) = 1\), \(cos(\pm 90) = 0\), and the values become gradually smaller from \(0\) to \(90\) in a very pleasant curve:

\(cos(\alpha)\) matches all of our requirements for the specular reflection function, so why not use it?

There’s one detail missing. With this formulation, all objects are equally shiny. How can we adapt the equation to represent varying degrees of shininess?

Remember that shininess is a measure of how quickly the reflection function decreases as \(\alpha\) increases. A very simple way to obtain different shininess curves is to compute the power of \(cos(\alpha)\) to some positive exponent \(s\). Since \(0 \le cos(\alpha) \le 1\), it’s clear that \(0 \le cos(\alpha)^s \le 1\); so \(cos(\alpha)^s\) behaves just like \(cos(\alpha)\), only “narrower”. Here’s \(cos(\alpha)^s\) for different values of \(s\):

The bigger the value of \(s\), the “narrower” the function becomes around \(0\), and the shinier the object looks.

\(s\) is usually called the specular exponent, and it’s a property of the surface. Since the model is not based on physical reality, the values of \(s\) can only be determined by trial-and-error - essentially, tweaking the values until they look “right”6.

Let’s put all of this together. A ray \(\vec{L}\) hits a surface at a point \(P\), where the normal is \(\vec{N}\), and the specular exponent is \(s\). How much light is reflected to the viewing direction \(\vec{V}\)?

We already decided that this value is \(cos(\alpha)^s\), where \(\alpha\) is the angle between \(\vec{V}\) and \(\vec{R}\), which in turn is \(\vec{L}\) reflected respect to \(\vec{N}\). So the first step is to compute \(\vec{R}\) from \(\vec{N}\) and \(\vec{L}\).

We can decompose \(\vec{L}\) into two vectors \(\vec{L_P}\) and \(\vec{L_N}\) such that \(\vec{L} = \vec{L_P} + \vec{L_N}\), where \(\vec{L_N}\) is parallel to \(\vec{N}\) and \(\vec{L_P}\) is perpendicular to \(\vec{N}\):

\(\vec{L_N}\) is the projection of \(\vec{L}\) over \(\vec{N}\); by the properties of the dot product and the fact that \(|\vec{N}| = 1\), the length of this projection is \(\langle \vec{N}, \vec{L} \rangle\). We defined \(\vec{L_N}\) to be parallel to \(\vec{N}\), so \(\vec{L_N} = \vec{N} \langle \vec{N}, \vec{L} \rangle\).

Since \(\vec{L} = \vec{L_P} + \vec{L_N}\), we can immediately get \(\vec{L_P} = \vec{L} - \vec{L_N} = \vec{L} - \vec{N} \langle \vec{N}, \vec{L} \rangle\).

Now look at \(\vec{R}\); since it’s symmetrical to \(\vec{L}\) respect to \(\vec{N}\), its component parallel to \(\vec{N}\) is the same as \(\vec{L}\)’s, and the perpendicular component is opposite to \(\vec{L}\)’s; that is, \(\vec{R} = \vec{L_N} - \vec{L_P}\):

Substituting with the expressions we found above, we get

\[ \vec{R} = \vec{N} \langle \vec{N}, \vec{L} \rangle - \vec{L} + \vec{N} \langle \vec{N}, \vec{L} \rangle \]

and simplifying a bit

\[ \vec{R} = 2\vec{N} \langle \vec{N}, \vec{L} \rangle - \vec{L} \]

The specular reflection term

We’re now ready to write an equation for the specular reflection:

\[ \vec{R} = 2\vec{N} \langle \vec{N}, \vec{L} \rangle - \vec{L} \]

\[ I_S = I_L \left( {{\langle \vec{R}, \vec{V} \rangle} \over {|\vec{R}||\vec{V}|}} \right)^s \]

As with the diffuse lighting, it’s possible that \(cos(\alpha)\) is negative, and again we should ignore it. Also, not every object has to be shiny; for these objects (which we’ll represent with \(s = -1\)) the specular term shouldn’t be computed at all.

Rendering with specular reflections

Let’s add specular reflections to the scene we’ve been working with so far. First, some changes to the scene itself:

sphere {
    center = (0, -1, 3)
    radius = 1
    color = (255, 0, 0)  # Red
    specular = 500  # Shiny
}
sphere {
    center = (-2, 1, 3)
    radius = 1
    color = (0, 0, 255)  # Blue
    specular = 500  # Shiny
}
sphere {
    center = (2, 1, 3)
    radius = 1
    color = (0, 255, 0)  # Green
    specular = 10  # Somewhat shiny
}
sphere {
    color = (255, 255, 0)  # Yellow
    center = (0, -5001, 0)
    radius = 5000
    specular = 1000  # Very shiny
}

On the code, we need to change ComputeLighting to compute the specular term when necessary and add it to the overall light. Note that it now needs \(\vec{V}\) and \(s\):

ComputeLighting(P, N, V, s) {
    i = 0.0
    for light in scene.Lights {
        if light.type == ambient {
            i += light.intensity
        } else {
            if light.type == point
                L = light.position - P
            else
                L = light.direction

            # Diffuse
            n_dot_l = dot(N, L)
            if n_dot_l > 0
                i += light.intensity*n_dot_l/(length(N)*length(L))

            # Specular
            if s != -1 {
                R = 2*N*dot(N, L) - L
                r_dot_v = dot(R, V)
                if r_dot_v > 0
                    i += light.intensity*pow(r_dot_v/(length(R)*length(V)), s)
            }
        }
    }
    return i
}

And finally, we need to modify TraceRay to pass the new parameters to ComputeLighting. \(s\) is obvious; it comes from the sphere data. But what about \(\vec{V}\)? \(\vec{V}\) is a vector that points from the object to the camera. Fortunately, at TraceRay we already have a vector that points from the camera to the object - that’s \(\vec{D}\), the direction of the ray we’re tracing! So \(\vec{V}\) is simply \(-\vec{D}\).

Here’s the new TraceRay with specular reflection:

TraceRay(O, D, t_min, t_max) {
    closest_t = inf
    closest_sphere = NULL
    for sphere in scene.Spheres {
        t1, t2 = IntersectRaySphere(O, D, sphere)
        if t1 in [t_min, t_max] and t1 < closest_t
            closest_t = t1
            closest_sphere = sphere
        if t2 in [t_min, t_max] and t2 < closest_t
            closest_t = t2
            closest_sphere = sphere
    }
    if closest_sphere == NULL
        return BACKGROUND_COLOR

    P = O + closest_t*D  # Compute intersection
    N = P - closest_sphere.center  # Compute sphere normal at intersection
    N = N / length(N)
    return closest_sphere.color*ComputeLighting(P, N, -D, sphere.specular)
}

And here’s the reward for all this vector juggling:

Source code and live demo >>

<< Basic ray tracing · Shadows >>
Computer Graphics from scratch · Introduction · Table of contents · Common concepts
Part I: Raytracing · Basic ray tracing · Light · Shadows · Reflection · Arbitrary camera · Beyond the basics · Raytracer pseudocode
Part II: Rasterization · Lines · Filled triangles · Shaded triangles · Perspective projection · Scene setup · Clipping · Hidden surface removal · Shading · Textures
Found an error? Everything is in Github.


  1. This is an approximation that holds at city-scale level, but it doesn’t hold over much longer distances - indeed, the ancient greeks were able to compute the radius of the Earth with surprising accuracy based on the different directions of sunlight at the same time but in distant places.

  2. But not necessarily in an easy way; an area light (think of a light behind a diffusor) can be approximated with a lot of point lights on its surface but this is cumbersome, computationally more expensive, and the results aren’t perfect.

  3. Not really, because light is quantized, but close enough.

  4. But at the very least, search for Global Illumination and look at the pretty pictures.

  5. From “speculum”, latin for “mirror”.

  6. For a physically-based model, see Bi-Directional Reflectance Functions (BRDFs)