Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 9

Gradient Image Generation in PPM

Recommended resources

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:

  1. Image Dimensions: Define the width and height of the image.
  2. PPM Header: Print the three required lines to std::cout:
    • P3 (the magic number)
    • width height
    • 255 (the maximum color value)
  3. Pixel Iteration: Use nested loops as described above. The outer loop iterates from j = 0 to height-1, and the inner loop from i = 0 to width-1.
  4. Color Calculation: Inside the loops, calculate the r, g, b components as doubles in the range [0.0, 1.0].
  5. Color Quantization: Convert the floating-point color components to integers in the range [0, 255]. The code uses int(255.999 * r). This multiplication by 255.999 is a simple trick to ensure that a value of 1.0 maps to 255 and not 256, avoiding an overflow after the integer cast.
  6. 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:

  1. Include the <fstream> header.
  2. Create an std::ofstream object: std::ofstream outfile("image.ppm");
  3. Write to this object: outfile << "P3\n";
  4. 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.0
  • g = (height - 1) / (height - 1) = 1.0
  • b = 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.

  1. Create a new C++ project or file. Make sure you have your vec3.h from the previous work available.
  2. In your main function, implement the logic to write a 256x256 pixel PPM image with a color gradient. You can choose either the std::cout redirection method or the std::ofstream method.
  3. Start with the simple implementation, writing the int RGB values directly.
  4. Compile and run your program, generating an image.ppm file. Open it in an image viewer to confirm it looks correct—it should be a smooth gradient from black to yellow.
  5. (Recommended) As a second step, refactor your code to use your vec3 class as a color type and create a write_color helper 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 P3 specification.
  • 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 vec3 class for colors and creating helper functions like write_color leads 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.