Ray Tracing with Voxels in C++ Series
Ray Tracing with Voxels in C++ Series – Part 1 – Jacco’s Blog

In this series we build a physically based renderer for a dynamic voxel world. From ‘Whitted-style’ ray tracing with shadows, glass and mirrors, we go all the way to ‘unbiased path tracing’ – and beyond, exploring advanced topics such as reprojection, denoising and blue noise. The accompanying source code is available on Github and is written in easy-to-read ‘sane C++’.
This series consists of nine articles. Contents:  (tentative until finalized)

Starting point: Voxel ray tracing template code. Lights and shadows (this article).
Whitted-style: Reflections, recursion, glass.
Stochastic techniques: Anti-aliasing and soft shadows.
Noise reduction: Stratification, blue noise.
Converging under movement: Reprojection.
Path tracing: Basics first; then: Importance Sampling.
Denoising fundamentals.
Acceleration structures: Extending the grid to a multi-level grid.
Acceleration structures: Adding a TLAS.   

In This Article…
In this first article we will use the bare-bones template and add some color to it, as well as lights, and shadows. Shadows will be calculated using rays to the light sources. If these concepts are already clear to you, feel free to skip to the challenges at the end of the article, or to the next article (once it is released): Each article starts with the same template code, so you can jump in at any point.
Starting Point
For this series we will use a somewhat unusual starting point: a ready-made template that renders a basic image using ray tracing. The scene is a small voxel world, procedurally generated. You can find this template in its github repo. It runs out-of-the-box on a Windows system: just open the .sln file in Microsoft Visual Studio Community Edition and press F5 to build and run it.

Figure 1: Output of the unmodified template: 128³ voxels with color-coded normals, and 50 million rays per second.
Note: Compared to starting ‘from scratch’, the template has the benefit that we quickly get to the interesting ray tracing topics: shadows, reflections, fancy cameras and so on. At the same time, the template is simple enough to make low-level optimizations worthwhile. Article 8 exploits this as we add a multi-level grid to improve the performance of the base code.
– o –
A Brief Tour
In its pure form, ray tracing is a process that produces an image from a 3D scene by following the path that light takes, from a light source, to a camera, or rather: in the opposite direction.
Figure 2, left: primary rays finding the nearest object. Right: using shadow rays to establish light transport.
This is shown in the left image. The camera (or eye) looks at pixels on a screen. This is simulated with a ‘ray’, i.e. a line of (potentially) infinite length, originating at the camera. The ray continues until it encounters an object. The first object along the ray is the object we ‘see’ through the pixel. Some rays hit nothing; they disappear into the void and do not bring back any light. In that case we can plot a black pixel, or perhaps a blue one, to simulate the sky.
You can find this functionality implemented in source file renderer.cpp, in the Renderer::Tick function. The code in this function loops over the pixels on the screen, creates a primary ray for each, and uses the Renderer::Trace function to determine what the color of the pixel should be. The Renderer::Trace function finds the nearest intersection of the ray and the scene. If nothing is found, it simply returns float3(0), i.e. black. Otherwise, it calculates a color for the hitpoint, but this functionality is not complete yet.
Note: We’re intentionally skipping details here. Line 31, for example: This line assigns each row of pixels to a thread, using OpenMP. This way, the renderer runs significantly faster on a multi-core CPU. And, on line 39, a floating point color is converted to an integer color. Monitors expect integer values, but ray tracing requires floating point colors for range and precision. More on that later.
For the rays that did hit an object we now need to determine if light flows via that object. This is illustrated in the right image. New rays are created from the hitpoint to the light source. If these rays get to the light without encountering any obstructions, we know that the surface may reflect light, via the pixel, to the camera.
Determining the flow of light via the object is what ray tracing is all about. Initially, the light is a simple point light, and the object has a simple material. But soon, we will be illuminating using many complex lights, and reflect via subtle materials. Light will travel via mirrors and through glass. The camera will have an aperture, which causes depth of field.

Figure 3: Minecraft with ray tracing effects.
Rendering images like the above one is not all that hard. On top of that, we can do it in real-time, on a CPU. Have a look at the code in scene.cpp: The code in there is all that is needed to replicate the behavior of modern ray tracing enabled GPUs. It may not be the simplest code, but at 150-ish lines of code it is hardly a vast code base either.
This concludes our brief tour. And now, it’s time to establish light transport.
– o – 
Into the Light
Let’s start by adding a light source to the world. The simplest light is a point light: all it needs is a position and a color. So, just above Renderer::Trace we add:






float3 pos( 1, 1, 1 );float3 color( 1, 1, 1 );



A word about the light position pos: In the template, the voxel object occupies space between x=0..1, y=0..1 and z=0..1. The whole shape is thus a 1x1x1 cube. The size of the world can be changed in scene.h, where WORLDSIZE is set to 128. Changing this value does however not change the size of the voxel object; it merely changes its resolution. Funny thing is: that’s the same thing. We can simply bring the camera closer to make the object look larger, or move it back to make it look smaller.
So, a light at x=1, y=1 and z=1 is located on one of the corners of the voxel object. 
Note that the color of the light is also its intensity: Because we use floating point colors, a bright yellow light might have a color like float3(100,100,20). Obviously, for a nearby light that would be blinding, but if the light is very far away, such a color is not extreme at all.
The effect of light on a point on the surface of a voxel depends on four factors:

The distance between the light and the surface point;
The brightness and color of the light source;
The orientation of the surface to the light;
The mutual visibility of the light and the surface point.

And, since we are interested in how much light the surface point reflects towards the camera, we must also take into account the color of the material (in ray tracing jargon: the albedo).
Let’s see what information we have inside Renderer::Trace to work with.






float3 Renderer::Trace( Ray& ray, int depth, int, int ){    scene.FindNearest( ray );    if (ray.voxel == 0) return float3(0); // or a fancy sky color    float3 N = ray.GetNormal();    float3 I = ray.IntersectionPoint();    float3 albedo = ray.GetAlbedo();    return (N + 1) * 0.5f;}



To calculate the distance between the light and the intersection point, we create a vector between the two positions, and take its length:






float3 L = pos - I;float distance = length( L );



With this information we evaluate the first factor on our list: Distance attenuation. The strength of a light decreases by the squared distance, so we scale by 1/distance^2.
The vector to the light, L, is also used to take into account the orientation of the surface towards the light. For this we take the dot product between the surface normal N and the normalized vector L. This value becomes negative when the light is behind the surface, so we clamp it to zero.






float cosa = max( 0.0f, dot( N, normalize( L ) ) );



Putting everything together, we can now calculate the light reflected by the surface:






return color * albedo * (1 / (distance * distance)) * cosa;



Renderer::Trace now produces the following image:

Figure 4: Basic lighting on the voxels.
The only thing missing is shadows. We need to find out if the light is visible from the intersection point.
– o – 
Into the Shadows
For that we use a new ray: the shadow ray. It’s a bit different from the primary ray that we cast from the camera through a pixel: instead of detecting the nearest object along an infinite line, a shadow ray merely detects an obstacle between two points. All it needs to give us is a yes/no answer: can we get there? And: The ray is just as long as the distance between the intersection point and the light; we are not interested in geometry beyond the light source.
The template provides shadow ray functionality, in the form of the Scene::IsOccluded function. It takes a Ray, and it returns a boolean.
We can set up the shadow ray and cast it into the scene:






Ray shadowRay( I, L, distance );if (scene.IsOccluded( shadowRay )) return float3( 0 );



Placing this before the line that returned the reflected light gets us shadows.

Figure 5: Lighting and shadows.
As you can see, there is a lot of pitch-black pixels: the ‘sky’ is black, and so are the shadows. Fixing the sky is easy: we can for example return float3( 0.7f, 0.8f, 1 ) instead of float3( 0 ). To fix the black shadows, we should add more lights. With just the single source of light, blackness is a logical consequence. See for example this photo of Saturn, which is also illuminated (almost exclusively) by a single point light.

Figure 6: Saturn illuminated by the sun, with (almost) hard shadows on the rings. Image: JPL/NASA
Scenes on Earth rarely have pitch black shadows. There is always a source of light: the sky, distant light sources or subtle reflections between objects. How to render this properly is a topic for another day.
– o – 
Beyond Black and White
The pictures we have rendered so far are all black and white. This is inevitable: The light we added is white, and the voxels are all white, since that is really the only color ever returned by Ray::GetAlbedo(). To do something about this, we have to look into the Creation of the World.
This monumental event is handled by the code in the Scene constructor, which starts on line 23 of file scene.cpp. The world in the template consists of 128x128x128 voxels. Each voxel stores a 32-bit value. If this value is 0, the voxel is considered to be empty space. All other voxels are set to 0xFFFFFF, which in a HTML editor would be the color white. We can swap this uniform whiteness for an orange gradient based on the y-coordinate in the world:






Set( x, y, z, n > 0.09f ? 0x020101 * y : 0 );



Together with a slightly yellow light source and a blue sky, the world now looks a lot nicer.

Figure 7: Shading, shadows and better colors.
It’s time to finish this article. In the next section you will find some (totally optional) challenges to practice with the theory presented here. In the next article we will add reflections, glass and a more realistic sky to the world.
– o – 
Challenges
Challenge 1: Increase the resolution of the world to 256x256x256. Add three freely floating cubes of 16x16x16 voxels each to the world: one green, one red, one blue cube. Note: Generating the world will now take pretty long: it will help to cache the world to disk and load it from a file on subsequent runs.
Challenge 2: Add support for multiple light sources to Renderer::Trace. Use this to add a red, a green and a blue light. Demonstrate that overlapping the three light colors creates white light. Make the lights dynamic and give them interesting paths.
Challenge 3: Replace the world generation code by something creative. This could be a mathematical shape, or a 3D room, or something you load from a MagicaVoxel file.
Challenge 4: Produce a minimalist game in the voxel world. For example: Recreate Pong. Later on, the dynamic game world will be a great test scenario for newly added functionality.
– o – 
Etc.
If you want to share some of your work, consider posting on X about it. You can also follow me there (@j_bikker), or contact me at bikker.j@gmail.com.
Want to read more about computer graphics? Also on this blog:

How to build a BVH series
Speeding up the Lighthouse 2 renderer
Software Optimization

Other articles in the “Ray Tracing with Voxels in C++” series:

Starting point: Voxel ray tracing template code. Lights and shadows (this article).
Whitted-style: Reflections, recursion, glass.
Stochastic techniques: Anti-aliasing and soft shadows.
Noise reduction: Stratification, blue noise.
Converging under movement: Reprojection.
Path tracing: Basics first; then: Importance Sampling.
Denoising fundamentals.
Acceleration structures: Extending the grid to a multi-level grid.
Acceleration structures: Adding a TLAS.    

