Shading

Let’s continue adding “realism” to the scene; in this chapter, we’ll examine how to add lights to the scene and how to light the objects it contains.

The title of this chapter is Shading, not Illumination; these are two different but closely related concepts. Illumination refers to the math and algorithms necessary to compute the effect of light in a single point in the scene; Shading deals with techniques to extend the effect of light from a discrete set of points to entire objects.

The Light chapter in the Raytracing section already tells you all you need to know about illumination. We can define ambient, point and directional lights; the way to compute illumination at any point in the scene given its position and a surface normal at that point in the same way in a raytracer and in a rasterizer; the theory is exactly the same.

The more interesting part, which we’ll explore in this chapter, is how to take this “illumination at a point” tool and make it work for objects made out of triangles.

Flat shading

Let’s start simple. Since we can compute illumination at a point, let’s just pick any point in a triangle (say, its center), compute lighting there, and use the illumination value to shade the whole triangle (to do the actual shading, we can multiply the triangle color by the illumination value):

Not so bad. And it’s easy to see why. Every point in the triangle has the same normal, and as long as the light is far away enough, the light vectors are approximately parallel, so every point receives roughly the same amount of light. This roughly explains the discontinuity between the two triangles that make up each side of the cube.

So what happens if we try with an object where every point has a different normal?

Not so good. It is very obvious that the object is not a true sphere, but an approximation made out of flat triangular patches. Because this kind of illuminations makes curved objects look like flat, it’s called flat shading.

Gouraud shading

How can we do better? The easiest way, for which we have most of the infrastructure in place, is to compute lighting not at the center of the triangle, but at its three vertices; these lighting values between \(0.0\) and \(1.0\) can then be linearly interpolated first across the edges and then across the surface of the triangle to color every pixel with a smoothly varying shade. This is, in fact, exactly what we were doing in the Drawing shaded triangles chapter; the only difference is that we’re computing the intensity values at each vertex using a lighting model, instead of assigning fixed values.

This is called Gouraud Shading, after Henri Gouraud, who came up with this idea in 1971. Here are the results of applying it to the cube and the sphere:

The cube looks slightly better; the discontinuity is now gone, because the both triangles of each face share two vertexes, and of course illumination is computed in exactly the same way for both.

The sphere, however, still looks faceted, and the discontinuities in its surface look really wrong. This shouldn’t be surprising since we’re treating the sphere as a collection of flat surfaces after all. In particular, we’re using very different normals for the adjacent faces - and in particular, we’re computing the illumination of the same vertex using very different normals for different triangles!

Let’s take a step back. The fact that we’re using flat triangles to represent a curved object is a limitation of our techniques, not a property of the object itself.

Each vertex in the sphere model corresponds to a point in the sphere, but the triangles they define are just an approximation of the surface of the sphere. As such, it would be a good idea to make the vertexes represent their points in the sphere as closely as possible - and that means, among other things, using the actual sphere normals for each vertex:

This doesn’t apply to the cube, however; even though triangles share vertex positions, each face needs to be shaded independently of the others. There’s no single, “correct” normal for the vertexes of a cube.

How to solve this dilemma? It’s simpler than it looks. Instead of computing triangle normals, we’ll make them part of the model; this way, the designer of the object can use the normals to describe the surface curvature (or lack thereof). Also, to accommodate the case of the cube and other objects with flat faces, we’ll make vertex normals a property of the vertex-in-a-triangle, not of the vertex itself:

model {
    name = cube
    vertexes {
        0 = (-1, -1, -1)
        1 = (-1, -1, 1)
        2 = (-1, 1, 1)
        ...
    }
    triangles {
        0 = {
            vertexes = [0, 1, 2]
            normals = [(-1, 0, 0), (-1, 0, 0), (-1, 0, 0)]
        }
        ...
    }
}

Here’s the scene rendered using Gouraud Shading with appropriate vertex normals:

The cube still looks like a cube, and the sphere now looks remarkably like a sphere. In fact, you can only tell it’s made out of triangles by looking at its contour (this can be alleviated by using more, smaller triangles, at the expense of more computing power).

The illusion starts breaking down when we try to render shiny objects, though; the specular highlight on the sphere is decidedly unrealistic.

There’s a subtler issue as well: when we move a point light very close to a big face, we’d naturally expect it to look brighter, and the specular effects to become more pronounced; however, we get the exact opposite:

What’s happening here is that even though we expect points near the center of the triangle to receive a lot of light (since \(\vec{L}\) and \(\vec{N}\) are roughly parallel), we’re not computing lighting at these points, but at the vertexes, where the closer the light is to the surface, the bigger is the angle with the normal. This means that every interior pixel will use an intensity interpolated between two low values, which will be in turn a low value:

Phong shading

The limitations of Gouraud Shading can be easily overcome, but as usual, there’s a tradeoff between quality and resource usage.

Flat Shading involved a single lighting calculation per triangle. Gouraud Shading requires three lighting calculations per triangle, plus the interpolation of a single attribute, the illumination, across the triangle. The next step in this quality and per-pixel cost increase is to calculate lighting at every pixel of the triangle.

This doesn’t sound particularly complex from a theoretical point of view; we’re computing lighting at one or three points already, and we were computing per-pixel lighting for the raytracer after all. What is tricky here is figuring out where the inputs to the illumination equation come from.

We need \(\vec{L}\). For directional lights, \(\vec{L}\) is given. For point lights, \(\vec{L}\) is defined as the vector from the point in the scene, \(P\), to the position of the light, \(Q\). However, we don’t have \(Q\) for every pixel of the triangle, but only for the vertexes.

We do have the projection of \(P\) - that is, the \(x'\) and \(y'\) we’re about to draw on the canvas! We know that

\[ x' = {Xd \over Z} \]

\[ y' = {Yd \over Z} \]

and we also happen to have an interpolated but geometrically correct value for \(1 \over Z\) as part of the depth buffering algorithm, so

\[ x' = Xd{1 \over Z} \]

\[ y' = Yd{1 \over Z} \]

So we can get \(P\) from these values:

\[ X = {x' \over d{1 \over Z}} \]

\[ Y = {y' \over d{1 \over Z}} \]

\[ Z = {1 \over {1 \over Z}} \]

We need \(\vec{V}\). This is trivial once we compute \(P\) as explained above, since the position of the camera is known.

We need \(\vec{N}\). We only have the normals at the vertexes. When all you have is a hammer, every problem looks like a nail, and our hammer is linear interpolation of attribute values! We can take the values of \(N_x\), \(N_y\) and \(N_z\) at each vertex and treat them as unrelated real numbers we can linearly interpolate. At every pixel, we reassemble the interpolated components into a vector, we normalize it, and we use it as the normal at that pixel.

This technique is called Phong Shading, after Bui Tuong Phong, who invented it in 1973. Here are the results:

Source code and live demo >>

The sphere looks perfect, except for its contour (which isn’t the shading algorithm’s fault after all), and the effect of getting the light closer to the triangle now behaves in the way we’d expect.

This also solves the problem with the light getting close to a face, now giving the expected results:

At this point we’ve matched the capabilities of the raytracer developed in Part I, except for shadows and reflections. Using the exact same scene definition, here’s the output of the rasterizer we’re developing:

And for reference, here’s the raytraced version:

They look almost identical, despite using vastly different techniques. This is expected, since we’re using different techniques to render the same scene. The only visible difference is in the edges of the spheres, which the raytracer renders as mathematically perfect spheres, while the rasterizer approximates them as a set of triangles.

<< Hidden surface removal · Textures >>
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.