In the previous lesson, you created a camera abstraction that encapsulated all camera-related logic into a clean, reusable class. This transformation made your code more maintainable by organizing related functionality into a focused component with a clear interface. The camera class eliminated scattered variables and inline calculations from your main function, replacing them with a simple, expressive API. This same principle of organization and reusability applies not just to classes, but to the fundamental building blocks of your code: constants and utility functions.
If you look carefully at your current ray tracer code, you'll notice several patterns that suggest opportunities for improvement. You have magic numbers scattered throughout your codebase. For example, when testing for ray-sphere intersections, you use 0.001 as a minimum distance threshold to avoid self-intersection artifacts. When computing the background gradient in ray_color(), you use 0.5 as a blending factor. These numbers work, but their meaning isn't immediately clear to someone reading the code. What does 0.001 represent? Why is it that specific value? Could it be changed safely?
You also have mathematical operations that appear in multiple places. Converting angles from degrees to radians is a common operation in computer graphics, and you'll need it frequently as you add camera controls and other features. Right now, if you needed to perform this conversion, you would write the formula inline: angle * 3.14159 / 180.0. But this approach has problems. First, the value of pi is hardcoded with limited precision. Second, the conversion logic is duplicated wherever you need it. Third, the intent isn't as clear as it could be. A function called degrees_to_radians() immediately communicates what's happening, while an inline calculation requires the reader to recognize the formula.
Looking ahead to features you'll implement in upcoming lessons, you'll need random number generation extensively. Materials that scatter light in random directions, antialiasing that samples multiple rays per pixel, and depth of field effects that randomize ray origins all depend on generating random values. Right now, you don't have any random number utilities in your codebase. When you need randomness, you'll have to write the same boilerplate code repeatedly: seeding the random number generator, calling , normalizing the result to the desired range, and handling edge cases. This repetition is error-prone and clutters your code with low-level details.
Creating a Constants Library
Mathematical constants are fundamental to ray tracing. You need pi for angle calculations, trigonometric functions, and geometric formulas. You need a representation of infinity for ray intersection tests, where you want to find the closest intersection within a potentially unbounded range. Rather than hardcoding these values with magic numbers scattered throughout your code, you'll create a dedicated header file that defines them once with appropriate precision and clarity.
Create a new file called src/constants.h and start with the header guards and necessary includes:
The <limits> header provides the std::numeric_limits template, which gives you access to properties of numeric types in a portable, standards-compliant way. This is the proper way to obtain special values like infinity rather than using platform-specific constants or magic numbers.
Now let's define infinity. In ray tracing, you frequently need to represent an unbounded range. For example, when searching for the closest ray-sphere intersection, you might initialize your search range from a small positive value to infinity. The std::numeric_limits<double>::infinity() function returns the IEEE 754 representation of positive infinity for double-precision floating-point numbers. This is a special value that compares as greater than any finite number and has well-defined behavior in arithmetic operations.
Let's break down this declaration. The inline keyword tells the compiler that this variable can be defined in a header file that might be included in multiple translation units without causing linker errors. Without inline, including this header in multiple source files would result in multiple definitions of the same variable, which violates the one definition rule. The inline keyword resolves this by allowing multiple definitions as long as they're identical.
Math Utility Functions for Cleaner Code
With your constants defined, you can now build utility functions that use them to perform common mathematical operations. One of the most frequent operations in computer graphics is converting angles between degrees and radians. Humans naturally think in degrees because they're more intuitive: a right angle is 90 degrees, a half circle is 180 degrees, a full circle is 360 degrees. However, most mathematical functions in programming languages, including C++'s trigonometric functions, work with radians. This means you constantly need to convert between the two representations.
Create a new file called src/math_utils.h and start with the header guards and includes:
Notice that we include constants.h because we'll need the pi constant for our angle conversion function. This demonstrates how utility headers can build on each other, creating layers of functionality.
Now let's implement the degrees-to-radians conversion function:
This function encapsulates the standard conversion formula. To convert degrees to radians, you multiply by pi and divide by 180. This formula comes from the relationship between degrees and radians: a full circle is 360 degrees or 2π radians, so one degree equals π/180 radians. The function is marked inline to suggest to the compiler that it should substitute the function body at the call site rather than generating a function call. For such a simple function, inlining eliminates the overhead of a function call while maintaining the benefits of a named, reusable operation.
The function takes a double parameter and returns a double, maintaining the precision of your calculations. If you pass in 90 degrees, you get approximately 1.5708 radians (π/2). If you pass in 180 degrees, you get approximately 3.14159 radians (π). The conversion is exact within the limits of floating-point precision.
Random Number Generation Utilities
Random number generation is essential for many advanced ray tracing techniques. Realistic materials scatter light in random directions based on their surface properties. Antialiasing samples multiple rays per pixel with slight random variations to smooth out jagged edges. Depth of field effects randomize ray origins to simulate camera lens aperture. Motion blur samples rays at random times during a frame. All of these features depend on generating high-quality random numbers efficiently.
Create a new file called src/random_utils.h and start with the header guards and includes:
The <cstdlib> header provides the std::rand() function, which generates pseudo-random integers. While C++ offers more sophisticated random number generation facilities in the <random> header, std::rand() is sufficient for ray tracing purposes and has the advantage of simplicity. The random numbers don't need to be cryptographically secure; they just need to be well-distributed and fast to generate.
Let's implement the basic random number function that returns a value in the range from zero to just below one:
This function generates a random double in the range [0, 1), meaning it can return zero but will never quite reach one. Let's break down how it works. The std::rand() function returns a random integer between 0 and RAND_MAX, which is a constant defined by your C++ implementation (typically 32767 or 2147483647). To convert this integer to a double in [0, 1), we divide by RAND_MAX + 1.0.
The addition of 1.0 is crucial. If we divided by alone, the function could return exactly 1.0 when returns . By dividing by , the maximum possible return value is , which is slightly less than 1.0. This [0, 1) range is the standard convention for random number generators and prevents edge cases in code that assumes the value is strictly less than one.
Extending vec3 with Utility Integration
Now that you have constants and random number utilities, you can extend your vec3 class with new methods that leverage these foundations. These additions will make your vector class more powerful and prepare it for the advanced lighting and material features you'll implement in upcoming lessons. You'll add three new capabilities: checking if a vector is near zero, generating random vectors, and generating random unit vectors on a sphere.
Open your src/vec3.h file and add the random utilities include at the top, after the existing includes:
This gives your vec3 class access to the random number generation functions you just created. Now let's add the near_zero() method to the public section of the vec3 class, after the existing methods:
This method checks whether a vector is very close to zero in all three dimensions. The threshold value s is set to 1e-8, which is 0.00000001. This is small enough to catch vectors that are effectively zero for practical purposes, but large enough to avoid false positives from normal floating-point rounding errors. The method uses std::fabs() to get the absolute value of each component and checks if all three are below the threshold.
Why do you need this method? In ray tracing, you'll frequently compute vectors that should theoretically be zero but might have tiny non-zero values due to floating-point arithmetic imprecision. For example, when computing the reflection of a ray off a surface, certain geometric configurations might produce a direction vector that should be exactly zero but is actually something like (0.0000000001, -0.0000000002, 0.0000000001). Treating this as a valid direction vector could cause problems in your lighting calculations. The method lets you detect and handle these cases appropriately.
Summary: Building a Foundation for Advanced Features
In this lesson, you created a set of reusable utilities to make your ray tracer cleaner and more maintainable. You defined mathematical constants like pi and infinity in constants.h, eliminating magic numbers and making your code more readable. You added a degrees_to_radians() function in math_utils.h to clarify angle conversions and centralize this common operation. In random_utils.h, you encapsulated random number generation with random_double() functions, making it easy to generate random values in any range.
You also extended your vec3 class with a near_zero() method for numerical stability, a static random() method for generating random vectors, and a random_unit_vector() function for producing uniformly distributed directions on a sphere. These additions prepare your vector class for advanced features like realistic lighting and material scattering.
These utilities will be used throughout your ray tracer, supporting features like materials, antialiasing, and camera controls. By centralizing constants and common operations, your code becomes easier to read, modify, and extend. In the next practice exercises, you’ll use these utilities to deepen your understanding and prepare for more advanced ray tracing techniques.
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
std::rand()
The solution to all these problems is to create a small library of reusable utility functions and constants. These utilities serve as the foundation for cleaner, more maintainable code. Instead of scattering magic numbers throughout your codebase, you define them once in a central location with clear names. Instead of duplicating mathematical formulas, you write them once as functions that can be called anywhere. Instead of mixing low-level random number generation with high-level ray tracing logic, you encapsulate the randomness behind a simple interface.
In this lesson, you'll build three utility headers that will serve your ray tracer throughout its development. First, you'll create constants.h to define fundamental mathematical constants like pi and infinity. These constants will be used throughout your ray tracer for geometric calculations and range checks. Second, you'll create math_utils.h to provide common mathematical operations like angle conversion. These functions will make your code more expressive and easier to understand. Third, you'll create random_utils.h to encapsulate random number generation. These utilities will be essential for the realistic lighting and sampling techniques you'll implement in future lessons.
You'll also extend your vec3 class with new utility methods that leverage these foundations. You'll add a near_zero() method to check if a vector is close to zero, which is important for numerical stability in lighting calculations. You'll add a static random() method to generate random vectors, and you'll implement random_unit_vector() to generate uniformly distributed directions on a sphere. These additions will make your vector class more powerful and self-contained, ready to support the advanced features coming in later lessons.
The benefits of this approach mirror what you gained from the camera abstraction. Your code will be more readable because operations have clear, descriptive names. It will be more maintainable because changes to constants or algorithms happen in one place rather than being scattered throughout the codebase. It will be more reliable because well-tested utility functions reduce the chance of errors in repeated calculations. And it will be more extensible because new features can build on these solid foundations rather than reinventing basic operations.
The constexpr keyword indicates that this value is a compile-time constant. The compiler can evaluate std::numeric_limits<double>::infinity() at compile time and substitute the resulting value wherever infinity is used. This has two benefits. First, it enables compile-time optimizations where the compiler can fold constants and simplify expressions. Second, it documents that this value never changes, making the code's intent clearer.
The type is double because all your ray tracing calculations use double-precision floating-point arithmetic. Using double rather than float provides sufficient precision for the geometric calculations in ray tracing, where small errors can accumulate and cause visible artifacts. The value comes from std::numeric_limits<double>::infinity(), which is the standard, portable way to obtain the infinity value for doubles.
Now let's define pi with sufficient precision for ray tracing:
This definition provides pi to 19 decimal places, which is more than sufficient for double-precision arithmetic. A double has about 15-17 significant decimal digits of precision, so this value captures all the precision that a double can represent. Using a high-precision value ensures that calculations involving pi are as accurate as possible within the limits of floating-point arithmetic.
You might wonder why we define pi explicitly rather than computing it using a formula like 4.0 * std::atan(1.0). While that formula is mathematically correct and would give you pi at runtime, defining it as a constant has advantages. First, it's evaluated at compile time, so there's no runtime cost. Second, it's immediately clear what the value represents. Third, it ensures consistency across all uses of pi in your program. The value we've chosen matches the precision used in many graphics and scientific computing libraries.
Finally, close the header guard:
The complete constants.h file is remarkably simple, but it provides significant value. Now, anywhere in your ray tracer where you need pi or infinity, you can include this header and use these named constants. Instead of writing 3.14159 or 1e30 as magic numbers, you write pi or infinity, making your code's intent immediately clear. If you ever need to adjust these values or add new constants, you have a single, obvious place to do it.
These constants will appear throughout your ray tracer. You'll use pi when implementing camera field of view controls, when computing angles for light scattering, and when working with trigonometric functions. You'll use infinity when initializing search ranges for ray intersections, when representing unbounded intervals, and when checking for special cases in geometric calculations. Having these constants defined cleanly and correctly from the start prevents subtle bugs and makes your code more professional and maintainable.
Close the header guard:
You might wonder why we create a separate function for such a simple calculation. After all, writing degrees * pi / 180.0 inline isn't particularly complex. The answer lies in the principles of clean code and maintainability. First, the function name degrees_to_radians() immediately communicates intent. When someone reads your code and sees this function call, they instantly understand what's happening without having to recognize the formula. Second, if you ever need to adjust the conversion (perhaps to handle edge cases or improve precision), you have a single place to make the change. Third, the function serves as documentation, making it clear that the value is in degrees before the call and in radians after.
As your ray tracer grows, you'll use this function in several places. When you implement camera controls that let users specify field of view in degrees, you'll convert to radians for the internal calculations. When you add rotation transformations, you'll convert user-specified angles to radians for the trigonometric functions. When you implement procedural textures that use angular patterns, you'll convert between representations as needed. Having this utility function makes all these operations cleaner and more consistent.
You might also add other mathematical utilities to this header as your ray tracer evolves. Functions for clamping values to a range, for linear interpolation, for smoothstep curves, and for other common operations would all fit naturally here. The key is to identify operations that appear multiple times in your code and extract them into named, reusable functions. This approach keeps your high-level code focused on ray tracing logic rather than low-level mathematical details.
RAND_MAX
std::rand()
RAND_MAX
RAND_MAX + 1.0
RAND_MAX / (RAND_MAX + 1.0)
The division by a floating-point value (1.0 rather than 1) ensures that the division is performed in floating-point arithmetic, giving you a double result with the full range of values between 0 and 1. If you divided by an integer, you would get integer division, which would always return 0.
Now let's add an overloaded version that generates random numbers in an arbitrary range:
This function takes a minimum and maximum value and returns a random double in the range [min, max). It works by first generating a random value in [0, 1) using the previous function, then scaling and shifting it to the desired range. The expression (max - min) * random_double() gives you a value in [0, max-min), and adding min shifts this to [min, max). This is a standard technique for transforming random numbers from one range to another.
For example, if you call random_double(-1.0, 1.0), you get a random value between -1.0 and just below 1.0. If you call random_double(0.0, 10.0), you get a random value between 0.0 and just below 10.0. This flexibility is useful throughout ray tracing, where you need random values in various ranges for different purposes.
Close the header guard:
These two simple functions provide all the random number generation you'll need for your ray tracer. The first function gives you normalized random values that you can use for probabilities, weights, and other normalized quantities. The second function gives you random values in arbitrary ranges, useful for generating random positions, directions, and other quantities with specific bounds.
One important note about using std::rand(): you should seed the random number generator once at the start of your program using std::srand(). Without seeding, std::rand() will produce the same sequence of numbers every time you run your program, which is useful for debugging but not for final renders. You would typically seed with the current time using std::srand(std::time(nullptr)) in your main function. However, for debugging purposes, you might want to use a fixed seed so that renders are reproducible. On CodeSignal, the environment handles random number seeding automatically, but when you work on your own machine, you'll need to add this initialization.
The random number utilities you've created are simple but powerful. They abstract away the details of random number generation, providing a clean interface that your ray tracing code can use without worrying about the underlying implementation. If you later decide to switch to a different random number generator (perhaps one from the <random> header for better statistical properties), you can change the implementation of these functions without affecting any code that uses them. This encapsulation is another example of the abstraction principle you've been applying throughout your ray tracer.
near_zero()
Now let's add a static method to generate random vectors. Add this to the public section of the class:
This static method creates a vector with random components, where each component is independently chosen from the range [min, max). The static keyword means you call this method on the class itself rather than on an instance: vec3::random(-1, 1) rather than some_vector.random(-1, 1). This makes sense because you're creating a new vector rather than modifying an existing one.
The implementation is straightforward: it constructs a new vec3 by calling random_double(min, max) three times, once for each component. Each component is independent, so you get a random point in a three-dimensional box defined by the min and max values. For example, vec3::random(-1, 1) gives you a random point in a cube centered at the origin with side length 2.
This method will be useful for generating random positions, random offsets, and as a building block for more sophisticated random vector generation. However, for many ray tracing applications, you don't want random vectors in a box; you want random directions uniformly distributed on a sphere. That's what the next function provides.
Add this function after the existing utility functions at the bottom of the file, after unit_vector():
This function generates a random unit vector (a vector with length 1) that is uniformly distributed on the surface of a unit sphere. This is crucial for realistic light scattering, where you need to choose random directions with equal probability in all directions. The implementation uses a technique called rejection sampling, which is simple and effective for this purpose.
Let's trace through how it works. The function enters an infinite loop that will continue until it finds a suitable vector. Inside the loop, it generates a random point in the cube from (-1, -1, -1) to (1, 1, 1) using vec3::random(-1, 1). Most of these points will be inside the unit sphere (the sphere of radius 1 centered at the origin), but some will be outside. The function computes the squared length of the vector using length_squared(), which is more efficient than computing the actual length because it avoids a square root operation.
The condition 1e-160 < lensq && lensq <= 1.0 checks two things. First, it verifies that the squared length is at most 1.0, meaning the point is inside or on the unit sphere. Second, it verifies that the squared length is greater than a very small value (1e-160), which prevents the extremely rare case where the random point is exactly at the origin. If the point is at the origin, you can't normalize it to get a direction, so you need to reject it and try again.
If the point passes both checks, the function normalizes it by dividing by its length. The expression p / std::sqrt(lensq) divides the vector by the square root of its squared length, which is just its length. This normalization scales the vector to have length exactly 1.0, giving you a unit vector. Because the original point was uniformly distributed in the sphere, and normalization just projects it onto the sphere's surface, the resulting unit vector is uniformly distributed on the sphere's surface.
The rejection sampling approach is elegant because it's simple to implement and produces high-quality random directions. The acceptance rate (the probability that a random point in the cube is also in the sphere) is the ratio of the sphere's volume to the cube's volume, which is approximately 52%. This means, on average, you'll need to generate about two random points to get one that's accepted. This is efficient enough for ray tracing purposes, and the simplicity of the code makes it easy to understand and maintain.
You'll use random_unit_vector() extensively when implementing materials that scatter light. For example, a diffuse material scatters light randomly in all directions in the hemisphere above the surface. You'll generate a random unit vector and adjust it to ensure it points in the correct hemisphere. This randomness is what gives diffuse materials their characteristic soft, matte appearance. Without uniform random directions, your materials would look wrong, with visible patterns or biases in the lighting.
The extensions you've made to the vec3 class demonstrate how utility functions and constants enable more sophisticated functionality. The near_zero() method uses a carefully chosen threshold to detect nearly-zero vectors. The random() method uses your random number utilities to generate random vectors. The random_unit_vector() function combines random generation with geometric algorithms to produce uniformly distributed directions. Each of these additions makes your vector class more capable and prepares it for the advanced features coming in future lessons.