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 because the function writes to an output stream, for the function, and because you're working with the type, which is an alias for .
Mapping Pixel Coordinates to Colors
Now you're ready to learn the core concept of this lesson: using a pixel's position to determine its color. This is where the magic happens. Instead of assigning the same color to every pixel, you'll calculate each pixel's color based on where it sits in the image.
Think about the structure of your image. You have a grid of pixels, each identified by two coordinates: i for the horizontal position (column) and j for the vertical position (row). The pixel at the top-left corner has coordinates (0, image_height - 1), and the pixel at the bottom-right corner has coordinates (image_width - 1, 0). Remember that you iterate through rows from top to bottom, which is why j starts at image_height - 1 and decreases.
To create a gradient, you need to map these integer pixel coordinates to normalized color values in the range [0.0, 1.0]. The key formula for this mapping is:
Let's break down what this formula does. The variable i represents the horizontal pixel coordinate, ranging from 0 on the left edge to image_width - 1 on the right edge. By dividing i by image_width - 1, you normalize this coordinate to the range [0.0, 1.0]. When is , the result is . When is , the result is . For pixels in between, you get proportional values.
Building Your First Gradient Image
Now let's put everything together and write the complete program that generates a gradient image. You'll create a new main.cc file that uses both the vec3 class and your new write_color utility function. Here's the complete code:
Let's walk through this code step by step. At the top, you include the necessary headers. You need <fstream> for file output, <iostream> for error messages, and "color.h", which brings in the write_color function and, through its own includes, the vec3 class and the color type alias.
The program begins by defining the image dimensions. You're creating a 256×256 pixel image, which is a nice square size that's large enough to see the gradient clearly but small enough to generate quickly. These are declared as const int because they won't change during the program's execution.
Next, you create an output file stream using . This opens a file named for writing. The following line checks if the file opened successfully. If the file couldn't be opened (perhaps due to permission issues or disk space problems), the condition evaluates to true, and the program prints an error message to and returns with an error code of . This is good practice: always check that file operations succeed before proceeding.
Summary: Ready to Create Colorful Images
In this lesson, you moved beyond solid color images to create your first gradient, where colors vary smoothly across the image based on pixel coordinates. You learned how to use a dedicated color.h header file with a write_color utility function to convert normalized color values to the integer range required by the PPM format, using std::clamp to keep values valid.
You discovered how to map pixel coordinates to color values by normalizing them with formulas like double(i) / (image_width - 1), ensuring the gradient spans the full range from 0.0 to 1.0. By mapping each color component independently, you can create a variety of gradient patterns.
You built a complete program that generates a gradient image, with red increasing from left to right, green from bottom to top, and blue constant. This approach gives you mathematical control over every pixel’s color—a foundational concept for procedural image generation and ray tracing.
In the upcoming practice exercises, you’ll experiment with different gradient formulas and see how changing the math changes the image, building intuition for how code translates to visual results.
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
<iostream>
<algorithm>
std::clamp
"vec3.h"
color
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.
i
0
0.0
i
image_width - 1
1.0
You might wonder why you divide by image_width - 1 instead of image_width. This is a subtle but important detail. Imagine you have an image that's 256 pixels wide. The leftmost pixel has i = 0, and the rightmost pixel has i = 255. If you divided by 256, the rightmost pixel would give you 255 / 256 = 0.99609375, which is close to but not exactly 1.0. By dividing by 255 (which is image_width - 1), you get 255 / 255 = 1.0 exactly. This ensures that your gradient spans the full range from 0.0 to 1.0, with the edges of the image reaching the exact endpoints.
The same principle applies to the vertical coordinate. If you want the green component to vary from bottom to top, you use:
Since j starts at image_height - 1 at the top and decreases to 0 at the bottom, this formula gives you 1.0 at the top of the image and 0.0 at the bottom. This creates a gradient in which green increases as you move upward.
You can map each color component independently. In the code you'll write shortly, the red component is based on the horizontal position, the green component is based on the vertical position, and the blue component is set to a constant value of 0.25. This creates a specific gradient pattern, but you could easily create different effects by changing these mappings. For example, you could make red increase from bottom to top, or you could make blue vary diagonally by using a formula like double b = (double(i) + double(j)) / (image_width + image_height - 2).
The power of this approach is that you have complete mathematical control over how colors vary across your image. You're not limited to simple linear gradients. You could use more complex formulas involving squares, square roots, trigonometric functions, or any other mathematical operations. For now, you'll stick with simple linear mappings to understand the fundamentals, but keep in mind that this technique is incredibly flexible.
std::ofstream out("ppm/image.ppm")
image.ppm
!out
std::cerr
1
After confirming the file is open, you write the PPM header. As you learned in Lesson 2, the header consists of three lines: the magic number P3 indicating a plain PPM file, the image dimensions, and the maximum color value 255. These three pieces of information tell any program reading the file how to interpret the pixel data that follows.
Now comes the heart of the program: the nested loops that generate each pixel's color. The outer loop iterates through rows from top to bottom, with j starting at image_height - 1 and decreasing to 0. The inner loop iterates through columns from left to right, with i going from 0 to image_width - 1. For each pixel, you calculate its color based on its coordinates.
The red component is calculated as double(i) / (image_width - 1). The double(i) cast ensures that the division is performed using floating-point arithmetic rather than integer division. This gives you a value that ranges from 0.0 on the left edge of the image to 1.0 on the right edge. Pixels in the middle have intermediate values, creating a smooth transition.
The green component is calculated as double(j) / (image_height - 1). Since j starts high and decreases, this gives you 1.0 at the top of the image and 0.0 at the bottom. The gradient in the green channel runs vertically.
The blue component is set to a constant 0.25. This means every pixel has the same amount of blue, giving the entire image a slight blue tint. You could set this to 0.0 for no blue, 1.0 for full blue, or any value in between.
Once you've calculated the three components, you create a color object: color pixel_color(r, g, b). Remember that color is just an alias for vec3, so this creates a vector with the three color components. Finally, you call write_color(out, pixel_color) to convert the normalized color to integers and write them to the file.
After all pixels have been written, the program prints a success message to std::cerr and returns 0 to indicate successful completion. The file stream is automatically closed when out goes out of scope at the end of the function.
When you compile and run this program, it generates a file called image.ppm. If you open this file in an image viewer that supports the PPM format, you'll see your first gradient image. The exact appearance depends on how the red and green channels combine, which we'll analyze in the next section.