In this project, you'll implement an elegant and relatively efficient path tracer for producing photorealistic images similar to Figure 2. This will combine high-performance CPU programming techniques and a sophisticated mathematical integration technique. This is the actual algorithm used for most preprendered computer generated imagery today, including both live-action special effects and animated films.
You can see the image quality for yourself. For performance, consider that my Rays project renderer took 13 minutes to render Sponza at 640×400 with direct illumination only. Yours was probably similar. In this new Paths project, my renderer takes about 15 milliseconds to render the same image on the same computer (and yours is required to be under one second). That's a speedup of about 50,000x for a program with the same code side!
You are not permitted to look at the pathTrace
G3D sample program or the source code for
G3D::PathTracer
, or use G3D::PathTracer
in your solution.
Path tracing is a family of techniques first introduced by Kajia in “The Rendering Equation” [Kajiya86]. All path tracers converge to a numerical solution to the radiance equation [Immel86],
\begin{equation} \Lo(X, \wo) = \Le(X, \wo) + \int_{\mathbf{S}^2} \Li(X, \wi) \cdot f_x(\wi, \wo) \cdot |\wi \cdot \n| ~ d\wi. \end{equation}
They do this by combining three ideas:
The path tracer for this project uses explicit point lights for direct illumination, but also can compute indirect illumination from emissive surfaces as well. It is remarkably similar to what Kajiya first described, but is extended with full importance sampling of the scattering-function.
Your path tracer will be thousands of times faster than the ray tracer you wrote for the Rays project. You'll use this performance to tackle much more complex scenes and produce realistic illumination effects within them.
The performance comes from multiple sources:
You also will retain the GPU post-processing and multithreading from the Rays project.
There are no limitations for this project, beyond the obvious: don't just copy a path tracer that you found elsewhere online. You may look at any G3D sample program or any part of G3D, and I encourage you to do so. In fact, you may even look at any path tracer you find elsewhere for reference (the PBRT one is particularly nice), but be careful—there's a lot of math involved in a path tracer, and tiny changes can dramatically affect correctness.
Implement a path tracer with the following properties by refactoring the Rays project to/with:
G3D::debugPrintf
to the console is sufficient)
G3D::GApp::show
window title is sufficient)
G3D::TriTree
)
for accelerating ray intersection and a library-provided importance-sampled scattering
function (i.e.,
G3D::Surfel::scatter
)
G3D::Film::exposeAndRender
)
|
|
|
|
|
|
|
|
|
You wrote almost all of the radiometry logic for this project when working on the last, indirect light, step of the Rays project. Most of this project is about making that code scale: to more lights, more triangles, more rays per pixel, more scattering events per path. Plan to spend most of your time on the implementation refactoring your old code into multithreaded loops over arrays rather than writing new rendering code from scratch.
Delete your ray-intersection code from the previous project. You don't need it. G3D provides
all of the intersection code this time around. The
G3D::TriTreeBase
abstract base class gives the interface for ray casting and the G3D::TriTree
class is a
concrete implementation with fast triangle intersection. (It is actually a typedef
for
different implementations on different platforms.) Remember to check isNull(surfel)
whenever
you pull a surfel out of your ray cast result buffer produced by G3D::TriTree
, since rays can
miss the entire scene if they escape to the “sky”.
Since you don't want a name conflict with G3D::PathTracer
, keep your class named RayTracer
or call it something like MyPathTracer
.
A lot of this project is about refactoring code, which is a key software development practice. Restructure the program in small passes and stabilize it between changes. Do this even if it means writing code that you're going to rewrite again in a few minutes. It is easier to fix mistakes and keep track of what is going on when you work incrementally. And getting something right the first time is better than spending hours debugging it later.
The order that I recommend for morphing your ray tracer into a fast path tracer is:
G3D::TriTree
single-ray intersection calls
G3D::Surfel
scattering instead of uniform scattering
G3D::TriTree
batch-intersection calls. You can use
G3D::runConcurrently
for the inner loops.
1 / numLights
.
The last two steps require the least code but are perhaps the easiest to make mistakes in and the hardest to implement (in terms of thinking about the mathematics). Leaving them until last ensures that most of your specification-oriented work will be working as soon as possible.
You'll have a lot of code that looks like this:
runConcurrently(0, surfelBuffer.size(), [&](int i) {
const shared_ptr< Surfel >& surfel = surfelBuffer[i];
if (notNull(surfel)) {
Random& rng = Random::threadRandom();
...
}
});
and
runConcurrently(Point2int32(0, 0), Point2int32(width, height),
[&](Point2int32 point) {
...
});
Recall image layout: the pixel at x, y
is at index = x + y * width
. This allows you to go
back and forth between loops that iterate in 2D over pixels and the 1D buffer data structures.
The Rays project processed each path on its own task, from primary ray through to light source. That use of purely thread concurrency is inefficient on a modern architecture. Both CPUs and GPUs are capable of single-instruction multiple-data (SIMD) parallelism as well as task (i.e., thread/core) concurrency. SIMD parallelism requires performing the identical operation on a buffer of data in lockstep. Hitting radically different points of a task on one thread (e.g., shading vs. ray casting) also hurts cache efficiency.
This project therefore casts all rays and finds their intersections before shading any. Every step proceeds in this way. In order to carry data between stages, you'll need a number of large arrays that I'll call “buffers” for this project. These are structurally parallel to the final image: all buffers have the same length, and it is equal to the number of pixels.
The buffers that I used were:
modulationBuffer
(Color3)How much the surfaces between the eye and the current path node have already modulated the contribution observed due to the BSDF. Initialized based on the number of rays per pixel.
rayBuffer
The path rays
surfelBuffer
Surfaces hit, or nullptr
for missed rays
biradianceBuffer
Biradiance due to the selected light, assuming it is visible, weighted by the importance
sampling inverse relative probability of having sampled this light. The actual light position
is implicitly encoded in the shadowRayBuffer
.
shadowRayBuffer
Visibility test rays for the corresponding lights in the biradianceBuffer
.
lightShadowedBuffer
True if the surface is in shadow from the selected lights, false otherwise.
def traceImage(radianceImage, camera):
if thescene changed:
extract surfaces from the scene
rebuild the tree
// All operations act on all pixels in parallel
set the image to all black
repeat for the number of paths per pixel:
generate primary rays
initialize the modulationBuffer to Color3(1 / paths per pixel)
repeat for the number of scattering events:
intersect all rays, storing the result in surfelBuffer
add emissive terms (use the sky's radiance for missed rays and
apply the modulation buffer)
if there are lights:
choose which light to sample, measuring biradiance and
computing a shadow ray
cast all shadow rays
shade all hit points
if not the last iteration:
scatter the rays, multiplying the modulation buffer by
the scattering weights
In my implementation, almost every one of those steps becomes a helper method of
about five lines of code, containing a concurrent iterator. The intersection
is entirely handled by the various intersectRays
methods on G3D::TriTree
,
which happen to (by design) have exactly the signatures that you'll need.
In a scene where there are a lot of lights, importance sampling based on the potential contribution of the light at the surface is actually a very good strategy. Consider 100 equally bright lights in a grid, where each one barely illuminates the area under the next. At any point, I should spend most of my time sampling the light that is right next to the surface, not the 99 that contribute negligible amounts to the final image. I can get a 100x speedup by importance sampling with only a slight increase in variance (noise) in the final image for the same number of ray casts and shading operations.
As an extreme example, the G3D Cornell box is modeled with six pyramidal spot lights instead of one omni light. This is so that the real-time shadow maps only have a 90° field of view and minimize perspective distortion. If you sample only one light uniformly, you get horrible noise: 5/6 of the time, there's no contribution because the target is outside of the light's pyramid. If you sample all of the lights, you just run 6x slower because 5/6 of the time you compute zero. If you importance sample, you always choose the light that contains the point in its pyramid, so you get the quality of sampling all but the speed of sampling one.
Light importance sampling requires you to compute the biradiance at the surfel location due
to each light (i.e., just invoke G3D::Light::biradiance
). Then, select a light with
probability proportional to its biradiance contribution, and weigh it by the inverse
probability of selecting that light.
The easiest way to do this is to intialize a counter to a random number between zero and the total biradiance (I just summed all three color channels) of all lights relative to this surfel. Then, iterate through the lights, decreasing the counter by the biradiance of each light. When the counter becomes negative, select the light for the current iteration.
The weight for importance sampling is one over the probability with which the light was sampled. So, in a scene with a light contributing 20 W/m2 biradiance at a point and one contributing 10 W/m2, we select the first light twice as frequently, but divide the resulting biradiance by 2/3 (i.e., multiply it by 1.5) to account for the other light that wasn't sampled. When we sample the second light, we divide by 1/3 (i.e., multiply by 3) to account for the infrequency of sampling that light and the unsampled, brighter first light.
Note that each surface point (which is in its own pixel) potentially may select a different light to sample.
For performance:
const Array< X >&
so that they aren't copied!
G3D::TriTreeBase::COHERENT_RAY_HINT
m_lastTreeBuildTime
and only rebuild when
max(m_scene->lastEditingTime(), m_scene->lastStructuralChangeTime(),
m_scene->lastVisibleChangeTime()) > m_lastTreeBuildTime
. When the
scene is set, force a build with m_lastTreeBuildTime = 0
.
TriTree::COHERENT_RAY_HINT | TriTree::DO_NOT_CULL_BACKFACES | TriTree::OCCLUSION_TEST_ONLY
G3D::SmallArray
or static thread_local
storage on a G3D::Array
with
fastClear
to avoid freeing the underlying memory
between operations.For image quality:
surfel->geometricNormal
.
For shadow rays, this is just surfel->position + surfel->geometricNormal * epsilon
. For recursive indirect rays, it must
be on the side determined by the outgoing ray:
rayBuffer[i] = Ray::fromOriginAndDirection(surfel->position + surfel->geometricNormal *
epsilon * sign(w_o.dot(surfel->geometricNormal)), w_o)
, which works for both transmitted and reflected rays.
Radiance3(0.5f, 0.5f, 0.5f)
, which gives a flat gray background. You could substitute the gradient that
you designed in the Rays project for slightly more interesting lighting.For thread safety:
G3D::Random::threadCommon
to access a pseudorandom number generator that can be efficiently used on each thread. This must be called on the thread that wants to use it.
Don't fetch the instance for each random number call, just once at the top of your lambda expression
G3D::Image
object with a mutex, provided that each thread only writes to the pixels
assigned to it and there are barriers between tasks. The design described in this project follows those restrictions.
runConcurrently
calls, except for the image and buffer
elements for the current iteration index.
light->position().w == 1
)
debugPane->setNewChildSize(500, -1, 300);
G3D::Image::increment
to update the radiance bufferfor (Point2int32 P(0, 0); P.y < radianceImage->height(); ++P.y) {
for (P.x = 0; P.x < radianceImage->width(); ++P.x) {
radianceImage->set(P,
Radiance3(rayBuffer[P.x + P.y * radianceImage->width()].direction() *
0.5f + Vector3::one() * 0.5f));
}
}
For more inspiration and some impressive extensions to the base specification, look at some examples of work created by my students for this project:
Graphics Codex Projects © 2018 Morgan McGuire
This document is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. You may redistribute this document, including modifications, for noncommercial purposes as long as this copyright statement remains unmodified and your work is indicated as a derivative work.
This document is written in Markdeep to make it easy for teachers to modify it for use in their own courses. Just view the source in a browser and then save it.
All C++ and GLSL source code in this document is released into the public domain to avoid IP concerns on your own project solutions. That code may be used in any project, including commercially without attribution.
Images by others, the G3D Innovation Engine, the Graphics Codex are not covered by this document's copyright and license.
Images with citations are property of their respective owners.
The G3D Innovation Engine © 2000-2018 Morgan McGuire, available under the 2-clause BSD license.
The Graphics Codex © 2011-2018 Morgan McGuire. All rights reserved.