Creating Color Gradients
Introduction: From Solid Colors to Gradients
In the previous lesson, you learned about the vec3 class and discovered how it can represent both geometric quantities like positions and directions, as well as colors through the color type alias. You saw how working with normalized color values in the range [0.0, 1.0] provides flexibility and consistency in your code. You also learned how to convert these normalized values to the integer range [0, 255] required by the PPM image format.
Up to this point, every image you've created has been a solid color. In Lesson 2, you generated a completely red square in which every single pixel had the same RGB values. While this was an excellent way to learn the basics of image generation and the PPM format, real images are rarely uniform. Photographs, rendered scenes, and even simple graphics typically contain colors that vary across the image. The sky might transition from deep blue at the top to lighter blue near the horizon. A sunset might blend from orange to pink to purple. These smooth transitions between colors are called gradients.
In this lesson, you'll take the next step in your ray tracing journey by learning to create images in which the color changes based on each pixel's position. Instead of assigning the same color to every pixel, you'll calculate each pixel's color based on its coordinates. This is a fundamental concept that extends far beyond simple gradients. When you eventually trace rays through a scene, you'll calculate each pixel's color based on where the ray goes, what it hits, and how light interacts with surfaces. The coordinate-to-color mapping you'll learn today is the foundation for all of that.
By the end of this lesson, you'll have created your first gradient image. The image will smoothly transition from one color combination in one corner to a different combination in another corner. You'll understand how to map pixel coordinates to color values, how to normalize those coordinates to the [0.0, 1.0] range, and how to use utility functions to keep your code clean and organized. This might seem like a small step, but it represents a crucial shift in thinking: you're moving from static, uniform images to dynamic, calculated images in which every pixel can be unique.
Creating the Color Utility Function
As your ray tracer grows more sophisticated, you'll find yourself repeatedly performing certain operations. One such operation is converting a color object (with normalized values in the range [0.0, 1.0]) to the integer format required by the PPM file format (values in the range [0, 255]). Rather than writing this conversion code every time you need it, you'll create a utility function that handles this task cleanly and consistently.
To keep your code organized, you'll create a new header file called color.h that contains color-related functionality. This follows a common practice in C++ development: grouping related functions and types into dedicated header files. Your vec3.h file handles vector mathematics, and now color.h will handle color-specific operations. Let's look at the complete color.h file:
The file begins with include guards (#ifndef COLOR_H, #define COLOR_H, and #endif) to prevent multiple inclusion, just like in your vec3.h file. You include <iostream> because the function writes to an output stream, <algorithm> for the std::clamp function, and "vec3.h" because you're working with the color type, which is an alias for vec3.
The write_color function takes two parameters. The first is a reference to an std::ostream, which represents any output stream, such as std::cout for console output or an std::ofstream for file output. By accepting a generic stream reference, the function works with any output destination. The second parameter is a const reference to a color object, which contains the normalized RGB values you want to write.
Inside the function, you first extract the three color components using the x(), y(), and z() accessor methods. Remember that color is just an alias for vec3, so these methods give you the red, green, and blue components, respectively. The auto keyword lets the compiler deduce that these variables are of type double.
The next three lines introduce an important concept: clamping. The std::clamp function ensures that a value stays within a specified range. The syntax std::clamp(r, 0.0, 0.999) means "if r is less than 0.0, return 0.0; if r is greater than 0.999, return 0.999; otherwise, return r unchanged." You might wonder why you need this. In theory, if you're careful with your calculations, your color values should always be in the valid range. However, in practice, floating-point arithmetic can sometimes produce values slightly outside the expected range due to rounding errors or calculation mistakes. Clamping provides a safety net that prevents invalid color values from appearing in your output.
You clamp to 0.999 rather than 1.0 for a subtle but important reason. When you multiply by 256 in the next step, a value of exactly 1.0 would give you 256, which is outside the valid range of [0, 255] for the PPM format. By clamping to 0.999, you ensure that even the maximum value produces 255 after multiplication and truncation.
The final three lines perform the actual output. You multiply each clamped component by 256 and cast the result to an integer using static_cast<int>. This cast truncates any decimal portion, effectively rounding down. For example, if r is 0.5, then 256 * 0.5 = 128.0, which becomes 128 after the cast. If r is 0.999, then 256 * 0.999 = 255.744, which becomes 255 after the cast. The function then writes these three integer values to the output stream, separated by spaces, followed by a newline character.
This utility function encapsulates all the details of color conversion in one place. Whenever you need to write a color to your PPM file, you simply call write_color(out, pixel_color), and the function handles the rest. This makes your main code cleaner and reduces the chance of errors from repeatedly writing the same conversion logic.
