At university, I studied ray tracing and loved it because of its simplicity. We had a small project to make a CPU ray tracer in C++, and although it worked well, it was very slow. A few years later, I received my first RTX card and immediately started building a Vulkan renderer taking advantage of these RT cores. Around the same time, I was interested in voxel rendering, which was a hyped topic in the computer graphics community.

Code available as ZIP download.

Final result#

(note the beautiful FPS graph)

I used this amazing resource from NVIDIA to get started. As I learned more how to GPU trace rays, I started working on procedural voxel world generation. The goal was to render voxels with real-time path tracing, not to make a game, so the voxel code remained basic and was not optimized.

For path tracing, I’ve also kept it basic by retracing everything at each frame without caching the sampled values. This means there’s of course some noise in the videos, which is okay for a prototype, but it’s very visible in the screenshots.

Beautifully simple#

This is the main part of the ray generation shader, it’s almost all that’s needed to render the scene - look how straightforward and elegant it is!


uint totalBounce = 0;
for(int sampleIdx = 0; sampleIdx < NUM_SAMPLES; ++sampleIdx)
{
    vec3 rayOrigin = cameraOrigin;
    vec2 randomPixelCenter = vec2(pixel) + vec2(0.5) + 0.3765 * RandomGaussian(pld.rngState);
    vec2 screenUV = vec2((2.0 * randomPixelCenter.x - resolution.x) / resolution.y,
                        -(2.0 * randomPixelCenter.y - resolution.y) / resolution.y);
    vec3 rayDirection = normalize((vec4(VERTICAL_FOV_RAD * screenUV.x, VERTICAL_FOV_RAD * screenUV.y, -1.0, 1) * pushConstants.cameraView).xyz);

    vec3 accumulatedRayColor = vec3(1.0);
    
    for (int tracedSegments = 0; tracedSegments < TRACED_SEGMENTS; ++tracedSegments)
    {
        traceRayEXT(TLAS, gl_RayFlagsOpaqueEXT, 0xFF, 0, 0, 0, rayOrigin, T_MIN, rayDirection, T_MAX, 0);

        if (tracedSegments == 0)
        {
            depth = pld.depth;
            firstNormal = pld.rayHitSky ? vec3(0) : pld.rayDirection;
            P = pld.rayDirection * pld.depth;
        }

        accumulatedRayColor *= pld.color;

        if (pld.rayHitSky)
        {
            summedPixelColor += accumulatedRayColor;
            break;
        }
        else
        {
            rayOrigin = pld.rayOrigin;
            rayDirection = pld.rayDirection;
            totalBounce++;
        }
    }
}

imageStore(diffuseImage, pixel, vec4(summedPixelColor / float(NUM_SAMPLES), 0.0));
imageStore(normalImage, pixel, vec4(firstNormal, 0.5));
imageStore(depthImage, pixel, vec4(depth, pld.meshID, 0, 0));

Shader reloading#

To speed up my work, I implemented live reloading of shaders, which proved very useful as my voxel world generation is slow. Also, I’ve made sure that a hash of the shader code is stored in the shader file, so that only modified shaders are recompiled.

Debugging#

NSight Graphics helped me a lot with this project, thanks to its powerful RT debugging capabilities.

TLAS/BLAS viewer#

Voxel meshing#

Some final screenshots#

#
#
#
#
#

Some progress videos#

Day and night cycle#

Flat terrain#

Cave darkness#

References#

  1. vk_mini_path_tracer - Neil Bickford. nvpro-samples.github.io/vk_mini_path_tracer

  2. Best Practices: Using NVIDIA RTX Ray Tracing (Updated) - Juha Sjoholm. developer.nvidia.com/blog/best-practices-using-nvidia-rtx-ray-tracing

  3. NVIDIA Nsight Graphics - NVIDIA. developer.nvidia.com/nsight-graphics