Chapter 7: Optical Computation

7.1 Overview

We have already studied how Scene::SaveImage calls Scene::TraceRay to trace rays from the camera point out into the scene. TraceRay determines whether the ray intersects with the surface of any of the objects in the scene. If TraceRay finds one or more intersections along a given direction, it then picks the closest intersection and calls Scene::CalculateLighting to determine what color to assign to the pixel corresponding to that intersection. This is the first of four chapters that discuss the optical computations implemented by CalculateLighting and other functions called by it.

CalculateLighting is the heart of the ray tracing algorithm. Given a point on the surface of some solid, and the direction from which it is seen, CalculateLighting uses physics equations to decide where that ray bounces, bends, and splits into multiple rays. It takes care of all this work so that the different classes derived from SolidObject can focus on surface equations and point containment. Because instances of SolidObject provide a common interface for finding intersections with surfaces of any shape, CalculateLighting can treat all solid objects as interchangeable parts, applying the same optical concepts in every case.

CalculateLighting is passed an Intersection object, representing the intersection of a camera ray with the point on the surface that is closest to the camera in a particular direction from the vantage point. Its job is to add up the contributions of all light rays sent toward the camera from that point and to return the result as a Color struct that contains separate red, green, and blue values. It calls the following helper functions to determine the contributions to the pixel color caused by different physical processes:

7.2 class Optics

Before diving into the details of the reflection and refraction code, it is important to understand how this C++ code represents the optical properties of a point on the surface of a solid object. Take a look in imager.h for the class called Optics. This class holds a matte color, a gloss color, and an opacity value.

7.2.1 Matte color

The matte color indicates the intrinsic color scattered equally in all directions by that point when perfectly white light shines on it. In the real world, actual white light is composed of an infinite number of wavelengths (all the colors of the spectrum), but in our simple model we use red, green, and blue, the primary colors as perceived by the human eye. The matte color represents what fractions of red, green, and blue light the point reflects, irrespective of what color light reaches that point on the object. If you think of a red ball being inherently red whether or not any light is shining on it (philosophical musings notwithstanding), then you get the idea here. Pure red light shining on a pure red object will look the same as white light shining on a red object or red light shining on a white object: in all three cases, only red light reaches the viewer's eye. However, a white object and a red object will appear very different from each other when green light is shining on both of them. The matte color is a property of a point on an object independent of any illumination reaching that point. (We will soon see how the inherent colors of an object are combined with illumination to produce a perceived color.)

7.2.2 Gloss color

Similarly, the gloss color indicates the fraction of red, green, and blue light that bounces from the point due to glossy reflection. The gloss color provides a way to make mirror images of surrounding scenery have a different color tone than the original scenery. The gloss color is independent of the matte color, so as an example, an object may have a reddish matte color while simultaneously reflecting blue mirror images of nearby objects. If the gloss color is white (that is, the red, green, and blue color components are all 1), there is no color distortion of reflected scenery — the mirror image of an object will have the same color as the original.

7.2.3 Avoiding amplification

The constructor for the Optics class and its member functions SetGlossColor and SetMatteColor enforce that every matte color and gloss color have red, green, and blue color components constrained to the range 0 to 1. Any attempt to go outside this range results in an exception. This constraint is necessary (but not sufficient) to prevent unrealistic amplification of light; we don't want an object to reflect more light than it receives from the environment, because that can cause images to look wrong. Amplification can also lead to excessive run time or crashes due to floating point overflows after a long series of amplifying reflections. This undesirable amplification problem can also occur if matte and gloss color components add up to values greater than 1. No such constraint is enforced by the Optics class, but that class does provide the member function SetMatteGlossBalance to help you avoid this problem. Call this function with the first parameter glossFactor in the range 0 to 1 to select how much of the opaque behavior should be due to matte reflection and how much should be due to glossy reflection. A gloss factor of 1 causes glossy reflection to dominate completely with no matte reflection at all, while a gloss factor of 0 eliminates all glossy reflection leaving only matte reflection present. SetMatteGlossBalance will store an adjusted gloss color and matte color inside the Optics instance that are guaranteed not to cause light amplification, while preserving the intended tone of each color.

7.2.4 Opacity

The opacity value is a number in the range 0 to 1 that tells how opaque or transparent that point on the object's surface is. If the opacity is 1, it means that all light shining on that point is prevented from entering the object: some is matte-reflected, some is gloss-reflected, and the rest is absorbed by the point. If the opacity is 0, it means that point on the object's surface is completely transparent in the sense that it has no matte or glossy reflection. In this case, the matte color and gloss colors are completely ignored; their values have no effect on the color of the pixel when the opacity is 0.

However, there is a potentially confusing aspect of the opacity value. As mentioned above, there are two types of mirror-like reflection: glossy reflection and refractive reflection. Even when the opacity is 0, the surface point can still exhibit mirror reflection as a side-effect of refraction. This is because in the real world, refraction and reflection are physically intertwined phenomena; they are both continuously variable depending on the angle between incident light and a line perpendicular to the surface. However, glossy reflection is necessary in addition to refractive reflection in any complete ray tracing model for two reasons: refractive reflection does not allow any color tinge to be added to reflective images, and more importantly, refractive reflection does not provide for surfaces like silver or polished steel that reflect almost all incident light (at least not without making light travel faster than it does in a vacuum, which is physically impossible). In short, the real world has more than one mechanism for creating mirror images, and this ray tracing code therefore emulates them for the sake of generating realistic images.

7.2.5 Splitting the light's energy

For now, the following very rough pseudo-code formulas summarize how the energy in a ray of light is distributed across matte reflection, mirror reflection, and refraction:

    matte   = opacity*matteColor
    mirror  = opacity*glossColor + (1-opacity)*(1-transmitted)
    refract = (1-opacity)*transmitted

In these formulas, opacity, matteColor, and glossColor are the values stored in an instance of the Optics class. As opacity increases from 0 to 1, the value of (1-opacity) decreases from 1 to 0. You can think of (1-opacity) as being the degree of transparency of the surface point. The symbol transmitted represents the fraction of light that penetrates the surface of the object and continues on through its interior. The value of (1-transmitted) is the remainder of the light intensity that contributes the refractive part of mirror reflection. Again I stress that these formulas are rough. The actual C++ code is more complicated, but we will explore that in detail later. For now, the intent is to convey how a ray of light is split up into an opaque part and a transparent part, with the somewhat counterintuitive twist that some of the "transparent" part consists of refractively-reflected light that doesn't actually pass through the object. You would set opacity equal to 0 to model a substance like clear glass or water, 1 to model an opaque substance like wood or steel, and somewhere in between 0 and 1 for dusty glass.

7.2.6 Surface optics for a solid

The member function SolidObject::SurfaceOptics returns a value of type Optics when given the location of any point on the object's surface. By default, solid objects have uniform optics across their entire surface, but derived classes may override the SurfaceOptics member function to make opacity, matte color, and gloss color vary depending on where light strikes the object's surface. (See class ChessBoard in chessboard.h and chessboard.cpp for an example of variable optics.)

7.3 Implementation of CalculateLighting

7.3.1 Recursion limits

The CalculateLighting member function participates in mutual recursion with the other member functions CalculateRefraction and CalculateReflection, although indirectly. TraceRay calls CalculateLighting, which in turn calls the other member functions CalculateRefraction and CalculateReflection, both of which make even deeper calls to TraceRay as needed. There are two built-in limits to this mutual recursion, for the sake of efficiency and to avoid stack overflow crashes from aberrent special cases. The first is a limit on how many times CalculateLighting may be recursively called:

    if (recursionDepth <= MAX_OPTICAL_RECURSION_DEPTH)

The recursion depth starts out at 0 when SaveImage calls TraceRay. TraceRay adds 1 to the recursion depth that it passes to CalculateLighting. The other functions that participate in mutual recursion, CalculateLighting, CalculateRefraction, and CalculateReflection, all pass this recursion depth along verbatim. As the recursion gets deeper and deeper, each nested call to TraceRay keeps increasing the recursion depth passed to the other functions. Once the recursion depth exceeds the maximum allowed recursion depth, CalculateLighting bails out immediately and returns a pure black color to its caller. This is not the most elegant behavior, but it should happen only in extremely rare circumstances. It is certainly better than letting the program crash from a stack overflow and not getting any image at all!

The second recursion limiter is the ray intensity. As a ray is reflected or refracted from object to object in the scene, it becomes weaker as these optical processes reduce its remaining energy. It is possible for the ray of light to become so weak that it has no significant effect on the pixel color. The ray intensity is calculated and passed down the chain of recursive calls. The CalculateLighting function avoids excessive calculation for cases where the light is too weak to matter via the following conditional statement:

    if (IsSignificant(rayIntensity))

This helper function is very simple. It returns true only if one of the components of the color parameter is large enough to matter for rendering. The threshold for significance is a factor of one in a thousand, safely below the $\frac{1}{256}$ color resolution of PNG and other similar image formats:

const double MIN_OPTICAL_INTENSITY = 0.001;

inline bool IsSignificant(const Color& color)
{
    return
        (color.red   >= MIN_OPTICAL_INTENSITY) ||
        (color.green >= MIN_OPTICAL_INTENSITY) ||
        (color.blue  >= MIN_OPTICAL_INTENSITY);
}

7.3.2 Ambiguous intersections

The TraceRay member function calls FindClosestIntersection to search for an intersection in the given direction from the vantage point. That helper function is located in scene.cpp and is coded like this:

int Scene::FindClosestIntersection(
    const Vector& vantage,
    const Vector& direction,
    Intersection& intersection) const
{
    // Build a list of all intersections from all objects.
    cachedIntersectionList.clear();     // empty any previous contents
    SolidObjectList::const_iterator iter = solidObjectList.begin();
    SolidObjectList::const_iterator end  = solidObjectList.end();
    for (; iter != end; ++iter)
    {
        const SolidObject& solid = *(*iter);
        solid.AppendAllIntersections(
            vantage,
            direction,
            cachedIntersectionList);
    }
    return PickClosestIntersection(cachedIntersectionList, intersection);
}

FindClosestIntersection iterates through the solid objects in the scene and collects a list of all intersections the ray may have with them. Then it passes this list to another function called PickClosestIntersection to find the intersection closest to the vantage point, if any intersection exists. That function returns 0 if there is no intersection, or 1 if it finds a single intersection that is closer than all others. In the first case, TraceRay knows that the ray continues on forever without hitting anything, and thus it can use the scene's background color for the ray. In the second case, the closest intersection it found is passed to CalculateLighting to evaluate how the ray of light behaves when it strikes that point. However, there is another case: there can be a tie for the closest intersection point, meaning that the closest intersection point overlaps with more than one surface. The surfaces may belong to different solid objects, or they may belong to a single object. For example, it is possible for a ray of light to strike the exact corner point on a cube. This would register as three separate intersections, one for each of the three cube faces that meet at that corner point. In a case like this, PickClosestIntersection returns 3, meaning there is a three-way tie for the closest intersection. As you can see in the code listing above, FindClosestIntersection returns whatever integer value it receives back from its call to PickClosestIntersection.

As mentioned earlier in this chapter, CalculateLighting determines three different kinds of optical behavior: matte reflection, mirror reflection, and refraction. All three of these physical phenomena depend on the angle of the indicent ray of light with the surface that the ray strikes. If there is more than one surface associated with the closest intersection point, we run into complications. The behavior of the ray of light becomes ambiguous when we don't know which surface to use. One might think we could just split the ray of light into equal parts and let the resulting rays reflect or refract off all of the surfaces independently. One problem with this approach is that it includes surfaces that in reality would not experience the ray of light in the first place. Consider the case of the cube corner mentioned above. It is possible for the incident ray to hit the corner in such a way that only two of the faces are actually lit, the third being in shadow.

Even more complicated is the case of two solids touching at a point where the ray of light strikes. For example, imagine two cubes with corners exactly touching. It becomes quite unclear what should happen when light strikes this point. To avoid these complications, TraceRay doesn't even try to figure out what to do with the light ray. Instead, it throws an exception that indicates that it has encountered an ambiguous intersection. When this exception occurs, it is caught by SaveImage, which marks the associated pixel as being in an ambiguous state. SaveImage then carries on tracing rays for other pixels in the image. After it has completed the entire image, it goes back and patches up any ambiguous pixels by averaging the colors of surrounding pixels that weren't marked ambiguous. Although this is not a perfect solution, it does a surprisingly good job at hiding the pixels that were skipped over, and avoids a lot of unpleasant special cases.

7.3.3 Debugging support

Sometimes when you are creating a new image, you will run across situations you don't understand. The resulting image will not look as you expect. You might look at the output and ask something like, "why is this pixel so much brighter than that pixel?" Because there are so many thousands of pixels, tracing through with the debugger can be tedious. Many debuggers support conditional breakpoints, allowing you to run the code until a particular pixel is reached. However, doing this can really slow down the run time to the point that it becomes unusuable in practice. (Microsoft Visual Studio appears to be re-parsing the conditional breakpoint every time, making it take longer than I have ever been willing to wait.)

To assist in understanding how the code is treating one or more particular pixels in an image, it is possible to change a preprocessor directive inside imager.h so as to enable some extra debugging code in scene.cpp. Just find the line toward the top of that header file that looks like this:

#define RAYTRACE_DEBUG_POINTS 0

Change that line to the following and rebuild the entire project to enable the debugger feature:

#define RAYTRACE_DEBUG_POINTS 1

Then in the code that builds and renders a scene, call the member function AddDebugPoint for each pixel you are interested in. Just pass the horizontal and vertical coordinates of each pixel. Here is an example of what these calls might look like:

    scene.AddDebugPoint(419, 300);
    scene.AddDebugPoint(420, 300);
    scene.AddDebugPoint(421, 300);

How do you know the exact coordinates of the pixels? In Windows, I use the built-in Paint utility (mspaint.exe) to edit the output PNG file. Then I move the mouse cursor until it is over a pixel I am interested in. At the bottom of the window it shows the coordinates of the pixel. One important tip to keep in mind: when you are debugging, it is easiest to disable anti-aliasing by making the anti-alias factor be 1. Otherwise you will have to multiply all of your horizontal and vertical pixel coordinates by the anti-aliasing factor to figure out what values should be passed to AddDebugPoint. Even then, the behavior you are trying to figure out for a particular pixel in the output image can be spread across a grid of pre-anti-aliased pixels, causing more complication. So for that reason also, it is far less confusing to debug with anti-aliasing turned off.

If you search for RAYTRACE_DEBUG_POINTS in scene.cpp, you will see a few places where it is used to conditionally include or exclude blocks of source code. Most of these code blocks check to see if the member variable activeDebugPoint is set to a non-null value, and if so, they print some debug information. That member variable is set by the following conditional code block in Scene::SaveImage, which searches through debugPointList for pixel coordinates that match those of the current pixel. Whenever you call AddDebugPoint on a scene object, it adds a new entry to debugPointList.

#if RAYTRACE_DEBUG_POINTS
        {
            using namespace std;

            // Assume no active debug point unless we find one below.
            activeDebugPoint = NULL;

            DebugPointList::const_iterator iter = debugPointList.begin();
            DebugPointList::const_iterator end  = debugPointList.end();
            for(; iter != end; ++iter)
            {
                if ((iter->iPixel == i) && (iter->jPixel == j))
                {
                    cout << endl;
                    cout << "Hit breakpoint at (";
                    cout << i << ", " << j <<")" << endl;
                    activeDebugPoint = &(*iter);
                    break;
                }
            }
        }
#endif

The innermost if statement is an ideal place to set a debug breakpoint. The code will run at almost full speed, far faster than trying to use conditional breakpoints in your debugger. Even without the debugger, you may find that reading the debug output is sufficient to figure out why the ray tracer code is doing something unexpected.

Be sure to change the #define back to 0 when you are done debugging, to make the code run at full speed again, and to avoid printing out the extra debug text. Note that you do not need to remove your calls to AddDebugPoint; they will not harm anything nor cause the rendering to run any slower.

7.3.4 Source code listing

Below is a listing of the complete source code for TraceRay and CalculateLighting. The next three chapters explain the mathematics and algorithms of the important optical helper functions they call: CalculateMatte, CalculateRefraction, and CalculateReflection.

Color Scene::TraceRay(
    const Vector& vantage,
    const Vector& direction,
    double refractiveIndex,
    Color rayIntensity,
    int recursionDepth) const
{
    Intersection intersection;
    const int numClosest = FindClosestIntersection(
        vantage,
        direction,
        intersection);

    switch (numClosest)
    {
    case 0:
        // The ray of light did not hit anything.
        // Therefore we see the background color attenuated
        // by the incoming ray intensity.
        return rayIntensity * backgroundColor;

    case 1:
        // The ray of light struck exactly one closest surface.
        // Determine the lighting using that single intersection.
        return CalculateLighting(
            intersection,
            direction,
            refractiveIndex,
            rayIntensity,
            1 + recursionDepth);

    default:
        // There is an ambiguity: more than one intersection
        // has the same minimum distance.  Caller must catch
        // this exception and have a backup plan for handling
        // this ray of light.
        throw AmbiguousIntersectionException();
    }
}

// Determines the color of an intersection,
// based on illumination it receives via scattering,
// glossy reflection, and refraction (lensing).
// Determines the color of an intersection,
// based on illumination it receives via scattering,
// glossy reflection, and refraction (lensing).
Color Scene::CalculateLighting(
    const Intersection& intersection,
    const Vector& direction,
    double refractiveIndex,
    Color rayIntensity,
    int recursionDepth) const
{
    Color colorSum(0.0, 0.0, 0.0);

#if RAYTRACE_DEBUG_POINTS
    if (activeDebugPoint)
    {
        using namespace std;

        Indent(cout, recursionDepth);
        cout << "CalculateLighting[" << recursionDepth << "] {" << endl;

        Indent(cout, 1+recursionDepth);
        cout << intersection << endl;

        Indent(cout, 1+recursionDepth);
        cout << "direction=" << direction << endl;

        Indent(cout, 1+recursionDepth);
        cout.precision(4);
        cout << "refract=" << fixed << refractiveIndex;
        cout << ", intensity=" << rayIntensity << endl;

        Indent(cout, recursionDepth);
        cout << "}" << endl;
    }
#endif

    // Check for recursion stopping conditions.
    // The first is an absolute upper limit on recursion,
    // so as to avoid stack overflow crashes and to
    // limit computation time due to recursive branching.
    if (recursionDepth <= MAX_OPTICAL_RECURSION_DEPTH)
    {
        // The second limit is checking for the ray path
        // having been partially reflected/refracted until
        // it is too weak to matter significantly for
        // determining the associated pixel's color.
        if (IsSignificant(rayIntensity))
        {
            if (intersection.solid == NULL)
            {
                // If we get here, it means some derived class forgot to
                // initialize intersection.solid before appending to
                // the intersection list.
                throw ImagerException("Undefined solid at intersection.");
            }
            const SolidObject& solid = *intersection.solid;

            // Determine the optical properties at the specified
            // point on whatever solid object the ray intersected with.
            const Optics optics = solid.SurfaceOptics(
                intersection.point,
                intersection.context
            );

            // Opacity of a surface point is the fraction 0..1
            // of the light ray available for matte and gloss.
            // The remainder, transparency = 1-opacity, is
            // available for refraction and refractive reflection.
            const double opacity = optics.GetOpacity();
            const double transparency = 1.0 - opacity;
            if (opacity > 0.0)
            {
                // This object is at least a little bit opaque,
                // so calculate the part of the color caused by
                // matte (scattered) reflection.
                const Color matteColor =
                    opacity *
                    optics.GetMatteColor() *
                    rayIntensity *
                    CalculateMatte(intersection);

                colorSum += matteColor;

#if RAYTRACE_DEBUG_POINTS
                if (activeDebugPoint)
                {
                    using namespace std;

                    Indent(cout, recursionDepth);
                    cout << "matteColor=" << matteColor;
                    cout << ", colorSum=" << colorSum;
                    cout << endl;
                }
#endif
            }

            double refractiveReflectionFactor = 0.0;
            if (transparency > 0.0)
            {
                // This object is at least a little bit transparent,
                // so calculate refraction of the ray passing through
                // the point. The refraction calculation also tells us
                // how much reflection was caused by the interface
                // between the current ray medium and the medium it
                // is now passing into.  This reflection factor will
                // be combined with glossy reflection to determine
                // total reflection below.
                // Note that only the 'transparent' part of the light
                // is available for refraction and refractive reflection.

                colorSum += CalculateRefraction(
                    intersection,
                    direction,
                    refractiveIndex,
                    transparency * rayIntensity,
                    recursionDepth,
                    refractiveReflectionFactor  // output parameter
                );
            }

            // There are two sources of shiny reflection
            // that need to be considered together:
            // 1. Reflection caused by refraction.
            // 2. The glossy part.

            // The refractive part causes reflection of all
            // colors equally.  Each color component is
            // diminished based on transparency (the part
            // of the ray left available to refraction in
            // the first place).
            Color reflectionColor (1.0, 1.0, 1.0);
            reflectionColor *= transparency * refractiveReflectionFactor;

            // Add in the glossy part of the reflection, which
            // can be different for red, green, and blue.
            // It is diminished to the part of the ray that
            // was not available for refraction.
            reflectionColor += opacity * optics.GetGlossColor();

            // Multiply by the accumulated intensity of the
            // ray as it has traveled around the scene.
            reflectionColor *= rayIntensity;

            if (IsSignificant(reflectionColor))
            {
                const Color matteColor = CalculateReflection(
                    intersection,
                    direction,
                    refractiveIndex,
                    reflectionColor,
                    recursionDepth);

                colorSum += matteColor;
            }
        }
    }

#if RAYTRACE_DEBUG_POINTS
    if (activeDebugPoint)
    {
        using namespace std;

        Indent(cout, recursionDepth);
        cout << "CalculateLighting[" << recursionDepth << "] returning ";
        cout << colorSum << endl;
    }
#endif

    return colorSum;
}


Copyright © 2013 by Don Cross. All Rights Reserved.