In the previous post, I showed flat shading - not because it’s realistic, but purely to show that the perspective transforms and intersection tests were working. I now need to bring lighting into play.
I need to distinguish between color, which represents the color of a pixel on screen, and light, which represents light as it travels around the scene. Each RGB component of a color may range between 0 and 1 (since a screen pixel has a maximum brightness), but the values for light may be arbitrarily large.
Light is simply defined as three values:
Light may be added to other light, or scaled:
It can also be scaled separately in each of the RGB components, and for convenience I do this via
Finally, light can be converted to a color simply by clamping off the value. (As you might guess, light normally needs to be suitably scaled before this happens).
Instead of defining each
Surface in the
Scene as having a (flat)
Color, I now give each
Material instead. Also, since surface lighting is strongly dependent on the surface’s normal,
I include a function for calculating that as well:
Material itself is simply a synonym for a function:
In other words, a
- a set of light sources;
- a ray from the camera to the material;
- a point at which the surface was intersected;
- and a surface normal.
From this information, an output
Light value is computed.
For simplicity here, I’ve only defined two materials: a flat-shaded material (for rendering the surface representing the light source), and a diffuse material (used everywhere else):
At this point I started to get confused between vectors that represented arbitrary movements within the scene, and with vectors that needed to be normalized (unit length) for calculations to be correct.
Logical errors of this kind can be flushed out with a good type system, so I split
Vector into two
Vector became private, usable only by the
Next, I defined (as typeclasses), the unary and binary operations that can be performed on vectors of all kinds:
Note the return types: most vector operations produce a non-normalized vector. Some functions of course, specifically produce a normalized one:
With the typeclasses in place, I then create instances for each combination of
(There don’t seem to be any usages of the binary operators against a
UnitVector and a
NonUnitVector in that order, so I’ve skipped that).
There are two advantages to this approach:
- I can specify, as a type, whether a method specifically requires a normalized vector, and;
- I can be sure that I won’t write inefficient code that tries to re-normalize already-normalized vectors.
The disadvantage is of course code duplication - the code for the two unary paths, and for the three binary paths, is basically duplicated. I’d be interested to hear if anyone has a better solution here.
The above code snippets aren’t the whole story - there were plenty of other refactoring changes needed to adapt the rest of the code. The only remaining significant change was to the rendering function:
The end result is an image that looks as follows:
Code is in Github, if you want to take a look.