Fragment Lighting

So, in order to deal with interpolation artifacts, we need to interpolate the actual
light direction and normal, instead of just the results of the lighting equation. This
is called per-fragment lighting or just fragment lighting.

This is pretty simple, conceptually. We simply need to do the lighting computations in
the fragment shader. So the fragment shader needs the position of the fragment, the
light's position (or the direction to the light from that position), and the surface
normal of the fragment at that position. And all of these values need to be in the same
coordinate space.

There is a problem that needs to be dealt with first. Normals do not interpolate well.
Or rather, wildly different normals do not interpolate well. And light directions can be
very different if the light source is close to the triangle relative to that triangle's
size.

Consider the large plane we have. The direction toward the light will be very
different at each vertex, so long as our light remains in relatively close proximity to
the plane.

Part of the problem is with interpolating values along the diagonal of our triangle.
Interpolation within a triangle works like this. For any position within the area of the
triangle, that position can be expressed as a weighted sum of the positions of the three
vertices.

Figure 10.3. Triangle Interpolation

P = αA + βB + γC, where α + β + γ = 1.0

The α, β, and γ values are not the distances from their respective points to the point
of interest. In the above case, the point P is in the exact center of the triangle.
Thus, the three values are each ⅓.

If the point of interest is along an edge of the triangle, then the contribution of
the vertex not sharing that edge is zero.

Figure 10.4. Triangle Edge Interpolation

Here, point P is exactly halfway between points C and B. Therefore, β, and γ are both
0.5, but α is 0.0. If point P is anywhere along the edge of a triangle, it gets none of
its final interpolated value from the third vertex. So along a triangle's edge, it acts
like the kind of linear interpolation we have seen before.

This is how OpenGL interpolates the vertex shader outputs. It takes the α, β, and γ
coordinates for the fragment's position and combines them with the vertex output value
for the three vertices in the same way it does for the fragment's position. There is
slightly more to it than that, but we will discuss that later.

The ground plane in our example is made of two large triangles. They look like
this:

Figure 10.5. Two Triangle Quadrilateral

What happens if we put the color black on the top-right and bottom-left points, and
put the color green on the top-left and bottom-right points? If you interpolate these
across the surface, you would get this:

Figure 10.6. Two Triangle Interpolation

The color is pure green along the diagonal. That is because along a triangle's edge,
the value interpolated will only be the color of the two vertices along that edge. The
value is interpolated based only on each triangle individually, not on extra data from
another neighboring triangle.

In our case, this means that for points along the main diagonal, the light direction
will only be composed of the direction values from the two vertices on that diagonal.
This is not good. This would not be much of a problem if the light direction did not
change much along the surface, but with large triangles (relative to how close the light
is to them), that is simply not the case.

Since we cannot interpolate the light direction very well, we need to interpolate
something else. Something that does exhibit the characteristics we need when
interpolated.

Positions interpolate quite well. Interpolating the top-left position and bottom-right
positions gets an accurate position value along the diagonal. So instead of
interpolating the light direction, we interpolate the components of the light direction.
Namely, the two positions. The light position is a constant, so we only need to
interpolate the vertex position.

Now, we could do this in any space. But for illustrative purposes, we will be doing
this in model space. That is, both the light position and vertex position will be in
model space.

One of the advantages of doing things in model space is that it gets rid of that pesky
matrix inverse/transpose we had to do to transform normals correctly. Indeed, normals
are not transformed at all. One of the disadvantages is that it requires computing an
inverse matrix for our light position, so that we can go from world space to model
space.

The Fragment Point Lighting tutorial shows off how
fragment lighting works.

Figure 10.7. Fragment Point Lighting

Much better.

This tutorial is controlled as before, with a few exceptions. Pressing the
t key will toggle a scale factor onto to be applied to the
cylinder, and pressing the h key will toggle between per-fragment
lighting and per-vertex lighting.

The rendering code has changed somewhat, considering the use of model space for
lighting instead of camera space. The start of the rendering looks as follows:

The new code is the last line, where we transform the world-space light into camera
space. This is done to make the math much easier. Since our matrix stack is building up
the transform from model to camera space, the inverse of this matrix would be a
transform from camera space to model space. So we need to put our light position into
camera space before we transform it by the inverse.

After doing that, it uses a variable to switch between per-vertex and per-fragment
lighting. This just selects which shaders to use; both sets of shaders take the same
uniform values, even though they use them in different program stages.

We compute the inverse matrix using glm::inverse and store it.
Then we use that to compute the model space light position and pass that to the shader.
Then the plane is rendered.

The cylinder is rendered using similar code. It simply does a few transformations to
the model matrix before computing the inverse and rendering.

The shaders are where the real action is. As with previous lighting tutorials, there
are two sets of shaders: one that take a per-vertex color, and one that uses a constant
white color. The vertex shaders that do per-vertex lighting computations should be
familiar:

The main differences between this version and the previous version are simply what one
would expect from the change from camera-space lighting to model space lighting. The
per-vertex inputs are used directly, rather than being transformed into camera space.
There is a second version that omits the inDiffuseColor input.

With per-vertex lighting, we have two vertex shaders:
ModelPosVertexLighting_PCN.vert and
ModelPosVertexLighting_PN.vert. With per-fragment lighting, we
also have two shaders: FragmentLighting_PCN.vert and
FragmentLighting_PN.vert. They are disappointingly
simple:

Since our lighting is done in the fragment shader, there is not much to do except pass
variables through and set the output clip-space position. The version that takes no
diffuse color just passes a vec4 containing just 1.0.

The math is essentially identical between the per-vertex and per-fragment case. The
main difference is the normalization of vertexNormal. This is
necessary because interpolating between two unit vectors does not mean you will get a
unit vector after interpolation. Indeed, interpolating the 3 components guarantees that
you will not get a unit vector.

Gradient Matters

While this may look perfect, there is still one problem. Use the Shift+J key to move the light really close to the cylinder, but without putting
the light inside the cylinder. You should see something like this:

Figure 10.8. Close Lit Cylinder

Notice the vertical bands on the cylinder. This are reminiscent of the same
interpolation problem we had before. Was not doing lighting at the fragment level
supposed to fix this?

It is similar to the original problem, but technically different. Per-vertex
lighting caused lines because of color interpolation artifacts. This is caused by an
optical illusion created by adjacent linear gradients.

The normal is being interpolated linearly across the surface. This also means that
the lighting is changing somewhat linearly across the surface. While the lighting
isn't a linear change, it can be approximated as one over a small area of the
surface.

The edge between two triangles changes how the light interacts. On one side, the
nearly-linear gradient has one slope, and on the other side, it has a different one.
That is, the rate at which the gradients change abruptly changes.

Here is a simple demonstration of this:

Figure 10.9. Adjacent Gradient

These are two adjacent linear gradients, from the bottom left corner to the top
right. The color value increases in intensity as it goes from the bottom left to the
top right. They meet along the diagonal in the middle. Both gradients have the same
color value in the middle, yet it appears that there is a line down the center that
is brighter than the colors on both sides. But it is not; the color on the right
side of the diagonal is actually brighter than the diagonal itself.

That is the optical illusion. Here is a diagram that shows the color intensity as
it moves across the above gradient:

Figure 10.10. Gradient Intensity Plot

The color curve is continuous; there are no breaks or sudden jumps. But it is not
a smooth curve; there is a sharp edge.

It turns out that human vision really wants to find sharp edges in smooth
gradients. Anytime we see a sharp edge, our brains try to turn that into some kind
of shape. And if there is a shape to the gradient intersection, such as a line, we
tend to see that intersection “pop” out at us.

The solution to this problem is not yet available to us. One of the reasons we can
see this so clearly is that the surface has a very regular diffuse reflectance (ie:
color). If the surface color was irregular, if it changed at most every fragment,
then the effect would be virtually impossible to notice.

But the real source of the problem is that the normal is being linearly
interpolated. While this is certainly much better than interpolating the per-vertex
lighting output, it does not produce a normal that matches with the normal of a
perfect cylinder. The correct solution, which we will get to eventually, is to
provide a way to encode the normal for a surface at many points, rather than simply
interpolating vertex normals.