Skip to main content

Welcome to a Grasp lesson

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

Lesson 25

Generating Uniform Random Numbers with `std::random`

Recommended resources

Hello! Welcome back.

In our last lesson, we took a deep dive into the theory behind aliasing, the "jaggies" you see along the edges of your rendered sphere. We concluded that the root cause is under-sampling: using a single ray through the center of a pixel is a poor approximation of the color information within that pixel's entire area.

The logical solution is to improve our sampling strategy. Instead of one ray, we'll cast many rays at slightly different positions within each pixel and average their resulting colors. To do this effectively, we need a way to generate those "slightly different positions" randomly.

That brings us to today's goal: Use std::random to generate uniformly distributed random numbers for sampling. This lesson is all about mastering the modern C++ tool for this job. It's a crucial piece of programming machinery we'll need to implement our anti-aliasing solution in the next lesson.

Why Not rand()?

If you've encountered random numbers in C or older C++ before, you might have seen the rand() function. While simple to use, it's generally avoided in modern C++ for several reasons:

  • Low-Quality Randomness: The quality of the random numbers produced by rand() can be poor, sometimes showing predictable patterns.
  • Global State: rand() relies on a hidden global state, which can cause issues in complex or multi-threaded applications.
  • Limited Control: It's difficult to manage different random streams or use different types of distributions (e.g., normal vs. uniform).

For these reasons, the C++11 standard introduced the <random> library, which gives us a much more powerful, flexible, and high-quality system.


The Modern C++ Approach: The <random> Library

The modern C++ approach involves three key components working together. Understanding this separation of concerns is key to using the library effectively.

Learn Random Number Generation in C++

To get a high-level overview of these components, please read the first part of the article 'Learn Random Number Generation in C++' from Studyplan.dev. It clearly breaks down the process into three steps.

Read the sections 'Generating a Random Number' and 'Seeding a Random Number Generator' to understand the roles of a seeder, an engine, and a distributor.

Let's break down these three components:

  1. The Seeder (std::random_device): This is our source of high-quality, non-deterministic randomness. It typically asks the operating system for a truly random number based on entropy sources (like mouse movements or network packet timings). It's great for seeding, but it can be slow, so we only use it to get things started.

  2. The Engine (std::mt19937): This is a fast pseudo-random number generator (PRNG). You give it an initial seed, and it produces a long sequence of numbers that appear random. A popular and high-quality engine is the Mersenne Twister, available as std::mt19937. A crucial feature is that for the same seed, it will always produce the same sequence of numbers. This is fantastic for debugging!

  3. The Distribution (std::uniform_real_distribution): The engine produces raw, uniformly distributed integer values over a huge range. This is rarely what we want directly. A distribution object takes a number from the engine and maps it to a range and shape that we care about. For anti-aliasing, we'll need random floating-point numbers between 0.0 and 1.0, which is exactly what std::uniform_real_distribution is for.

This combination gives us the best of both worlds: a high-quality initial seed from std::random_device and a fast, reproducible stream of numbers from the engine, shaped to our exact needs by the distribution.


Seeing it in Action

Let's watch a couple of short videos that demonstrate how to put these pieces together in code.

C++ From Scratch: Random Numbers

This first video from the 'C++ From Scratch' channel provides a clear, modern demonstration of combining a seeder, engine, and distribution. It follows the best practices we've just discussed.

Watch from 0:57 to 8:53. The video covers: std::random_device for a non-deterministic seed. Using std::mt19937 as the engine and seeding it with the random device. Using std::uniform_int_distribution to generate dice rolls. Note how the distribution is what you call, passing the engine to it.

Now, let's see how to generate the floating-point numbers we'll actually need for our ray tracer.

C++/Game Tutorial 8: Random Number Generator!

This second video, by 'MakingGamesWithBen', is more conversational but shows the direct switch from integer to floating-point distributions.

Watch from 8:12 to 10:20. The key takeaway here is seeing uniform_int_distribution replaced with uniform_real_distribution<float> to generate numbers in the range [0.0f, 1.0f].

Test your understanding!

You are given the following code snippet which generates random floating-point numbers between 0.0 and 1.0.

#include <iostream>
#include <random>

int main() {
    std::random_device rd;
    std::mt19937 generator(rd());
    std::uniform_real_distribution<double> distribution(0.0, 1.0);

    for (int i = 0; i < 5; ++i) {
        std::cout << distribution(generator) << std::endl;
    }
}

How would you modify this code to simulate rolling a standard six-sided die (i.e., generate random integers between 1 and 6, inclusive)?

Show answer

You would only need to change the distribution. The seeder and engine can remain the same. You would replace std::uniform_real_distribution<double> with std::uniform_int_distribution and change the range.

#include <iostream>
#include <random>

int main() {
    std::random_device rd;
    std::mt19937 generator(rd());
    // Change is on this line:
    std::uniform_int_distribution<int> distribution(1, 6);

    for (int i = 0; i < 5; ++i) {
        std::cout << distribution(generator) << std::endl;
    }
}

This demonstrates the power of separating the engine from the distribution.


Integrating Random Numbers into Our Project

Now, let's adopt the approach from our main guide, the Ray Tracing in One Weekend series. The book provides a convenient helper function that encapsulates this logic, which we can add to a shared header file.

Ray Tracing in One Weekend Series

Peter Shirley's 'Ray Tracing in One Weekend' shows a concise way to implement this. We will focus on the modern C++ version he provides.

Read the chapter section 'Some Random Number Utilities'. Skip the first code block that uses std::rand(). Focus on the second implementation, under the text '...you can obtain a random number with the conditions we need as follows:'. This shows the random_double() function using std::uniform_real_distribution.

Following this guide, we'll create a common utility header (let's call it rtweekend.h, as the book does) and place our random number functions there.

Here is the code you should add. This structure is very common and effective.

// In a new header file, e.g., "rtweekend.h"

#ifndef RTWEEKEND_H
#define RTWEEKEND_H

#include <cmath>
#include <limits>
#include <memory>
#include <random> // Add this include

// Constants
const double infinity = std::numeric_limits<double>::infinity();
const double pi = 3.1415926535897932385;

// Utility Functions
inline double degrees_to_radians(double degrees) {
    return degrees * pi / 180.0;
}

// NEW RANDOM NUMBER UTILITY
inline double random_double() {
    static std::uniform_real_distribution<double> distribution(0.0, 1.0);
    static std::mt19937 generator;
    return distribution(generator);
}

inline double random_double(double min, double max) {
    // Returns a random real in [min,max).
    return min + (max - min) * random_double();
}

// Common Headers (we will add to this file as we go)
#include "ray.h"
#include "vec3.h"

#endif

A quick note on the static keyword here: using static inside the function ensures that the distribution and generator objects are created only once, the very first time random_double() is called. On all subsequent calls, the same objects are reused. This is much more efficient than re-creating them every time. Notice that the generator is default-constructed, which means it will be seeded with a default value. For a given run of the program, it will produce the same sequence of "random" numbers, which is perfect for debugging and getting consistent renders.


Conclusion

You've now learned the modern, robust way to generate random numbers in C++. This is a fundamental skill that goes far beyond ray tracing, but it's the specific tool we need to solve the problem of aliasing.

Key Takeaways:

  • The modern C++ <random> library is preferred over the old C-style rand().
  • It works by combining three components:
    1. A Seeder (std::random_device) for true randomness.
    2. An Engine (std::mt19937) for fast pseudo-random number generation.
    3. A Distribution (std::uniform_real_distribution) to shape the output to our desired range (e.g., [0.0, 1.0)).
  • We've created a reusable random_double() helper function that encapsulates this logic, making it easy to use throughout our project.

Next Steps

With our random number generator ready, we have everything we need to tackle aliasing. In the next lesson, we will finally implement multi-sample anti-aliasing. You'll modify your main render loop to cast multiple, randomly jittered rays for each pixel and average their colors. The visual improvement will be dramatic and deeply satisfying.