Hello! Welcome back to our ray tracing journey.
In our last lesson, we deconstructed the PPM image format, understanding its simple text-based header and body. You now know what to write to a file to create an image. Today, we'll put that knowledge into practice.
Our goal for this lesson is to write a C++ program that outputs a gradient image in PPM format by mapping pixel coordinates to colors. This is the classic "Hello, World!" of computer graphics—the first time we'll generate visual output from our code. This step transforms our abstract concepts of rays and colors into a tangible result, creating the digital canvas on which we'll soon render our 3D scenes.
From Pixels to Colors: The Core Logic
The fundamental task is to assign a color to every single pixel in our output image. Since we know from the previous lesson that a PPM image is written in scanline order (top-to-bottom, left-to-right), we can use nested loops to visit each pixel coordinate.
For an image of image_width by image_height, our loop structure will look like this:
for (int j = 0; j < image_height; ++j) { // Rows from top to bottom
for (int i = 0; i < image_width; ++i) { // Pixels from left to right
// Calculate and write the color for the pixel at (i, j)
}
}
But what color do we assign? The simplest way to create a visually interesting pattern is to make the color a function of the pixel's position. We can create a smooth gradient by mapping the pixel's normalized coordinates to the RGB color channels.
For a pixel at (i, j):
- The horizontal position can be normalized to a value between 0.0 and 1.0:
auto r = double(i) / (image_width - 1); - The vertical position can also be normalized:
auto g = double(j) / (image_height - 1);
If we map these values to the Red and Green channels, respectively, and keep Blue at 0, we'll get a gradient that transitions from black at the top-left, to red at the top-right, to green at the bottom-left, and finally to yellow at the bottom-right. This is analogous to how a 2D ramp texture or gradient node works in a shading network, where U and V coordinates drive the color output.
Writing Your First Image
The Ray Tracing in One Weekend book, our primary guide, provides the complete code for this process. It uses a very direct method: writing the PPM data to the standard output stream (std::cout) and then redirecting that output to a file from the command line.
Ray Tracing in One Weekend Series
Let's study the initial implementation from 'Ray Tracing in One Weekend'. This section provides the full C++ code to generate our first image.
Read the section 'Output an Image', paying close attention to the first complete C++ code block. Notice how it first prints the PPM header, then uses nested loops to compute and print the RGB values for each pixel. Also, note the instructions on how to compile and run the code to redirect its output to an image.ppm file.
Key Steps in the Code:
- Image Dimensions: Define the width and height of the image.
- PPM Header: Print the three required lines to
std::cout:P3(the magic number)width height255(the maximum color value)
- Pixel Iteration: Use nested loops as described above. The outer loop iterates from
j = 0toheight-1, and the inner loop fromi = 0towidth-1. - Color Calculation: Inside the loops, calculate the
r,g,bcomponents asdoubles in the range [0.0, 1.0]. - Color Quantization: Convert the floating-point color components to integers in the range [0, 255]. The code uses
int(255.999 * r). This multiplication by255.999is a simple trick to ensure that a value of 1.0 maps to 255 and not 256, avoiding an overflow after the integer cast. - Pixel Output: Print the three integer RGB values to
std::cout, followed by a newline.
After compiling this code, you would run it from your terminal like this:
./your_program > image.ppm
This > operator redirects the text that would normally appear in your console into a file named image.ppm, which you can then open with an image viewer.
A More Explicit Approach: Using File Streams
While redirecting std::cout is clever and simple, a more conventional and arguably more robust method for file writing in C++ is to use the file stream library, <fstream>. This makes the program's intent to write a file explicit in the code.
The following video demonstrates this alternative approach.
C++ Image Programming From Scratch - 4.1
This video from the 'C++ Image Programming From Scratch' series shows how to write a PPM file using std::ofstream. This is a great alternative to the output redirection method and is a standard C++ practice for file I/O.
Watch the segment from 8:44 to 12:45. Focus on how std::ofstream is included and used to open a file. Notice that instead of writing to std::cout, the code writes to the ofstream object (image). The logic for the header and pixel loops remains the same.
Using std::ofstream involves these changes:
- Include the
<fstream>header. - Create an
std::ofstreamobject:std::ofstream outfile("image.ppm"); - Write to this object:
outfile << "P3\n"; - The object's destructor will automatically close the file when it goes out of scope, though explicitly calling
outfile.close()is also fine.
Test your understanding!
Using the gradient logic from Ray Tracing in One Weekend (r = i / (width-1), g = j / (height-1), b = 0), what color would you expect the pixel at the exact bottom-right corner of the image to be?
Show answer
At the bottom-right corner, i would be width - 1 and j would be height - 1.
r = (width - 1) / (width - 1) = 1.0g = (height - 1) / (height - 1) = 1.0b = 0.0
A color with (R=1.0, G=1.0, B=0.0) is pure yellow. After quantization, the integer values would be 255 255 0.
Refactoring for a Scalable Future
The initial code works, but putting all the logic directly in main() isn't ideal for a larger project. We can improve the structure by using the vec3 class we've already defined for geometric vectors. Since colors are also just triplets of numbers (R, G, B), vec3 is a perfect fit.
This is a common practice in graphics programming. We can even create a type alias to make the code more readable:
using color = vec3;
The Ray Tracing in One Weekend book quickly refactors the code to adopt this practice and introduces a helper function to handle the color writing and quantization logic. This encapsulates the details of the PPM format and keeps our main rendering loop cleaner.
Ray Tracing in One Weekend Series
Now let's look at how to refactor our code for better organization. This section from the book introduces a color.h header and a write_color utility function.
Read the 'Color Utility Functions' section. Observe how a color alias is defined for vec3 and how the write_color function neatly packages the logic for converting a color (a vec3 object) into a line of text for our PPM file. Then, look at the updated main function to see how much cleaner the inner loop becomes.
This refactoring is a crucial first step towards building a more complex system. By separating the concern of "calculating a color" from "writing a color to a file," our code becomes more modular and easier to manage.
Your Task: Create an Image
Now it's your turn to write the code.
- Create a new C++ project or file. Make sure you have your
vec3.hfrom the previous work available. - In your
mainfunction, implement the logic to write a 256x256 pixel PPM image with a color gradient. You can choose either thestd::coutredirection method or thestd::ofstreammethod. - Start with the simple implementation, writing the
intRGB values directly. - Compile and run your program, generating an
image.ppmfile. Open it in an image viewer to confirm it looks correct—it should be a smooth gradient from black to yellow. - (Recommended) As a second step, refactor your code to use your
vec3class as acolortype and create awrite_colorhelper function as shown in the resource. This will set you up well for future lessons.
Conclusion
Congratulations! By completing this lesson's task, you will have created the first visual output of your ray tracer. This is a significant milestone that bridges the gap between pure data and a visible image.
Key Takeaways:
- We can generate an image file by programmatically writing text that conforms to the PPM
P3specification. - Nested loops provide a straightforward way to iterate over every pixel in an image from top-to-bottom, left-to-right.
- Mapping normalized pixel coordinates
(i/width, j/height)to RGB channels is a simple and effective technique for generating gradients. - Color values computed as floats or doubles in the [0, 1] range must be quantized—scaled and converted—to integers in the [0, 255] range for 8-bit image formats like PPM.
- Using your
vec3class for colors and creating helper functions likewrite_colorleads to cleaner, more maintainable code.
Next Steps
We now have a digital canvas and know how to paint colors onto it. But right now, those colors are just a simple function of pixel position. To create a 3D scene, we need the colors to be determined by the interaction of rays with objects. In our next lesson, we will do exactly that by tackling the learning outcome: Derive the ray-sphere intersection equation using the quadratic formula. This will allow us to render our very first 3D shape.