Path Tracing Example Tutorial
About this Tutorial
This tutorial will assume that you have acquired the most recent source code to Milton and that you have built the Milton library.
This tutorial explains the built-in Path Tracer as a way of demonstrating how to use the Milton framework. The goal of this tutorial is to expose you to the tools provided with Milton to develop and extend Milton for yourself. For a more detailed and rigorous explanation of Path Tracing in general, see Wikipedia.
The Basic Algorithm
At a high level, the path tracing algorithm should do the following:
For a given sample on the film plane we want to estimate the spectral exitant radiance. In order to do this, we write an _evaluate fuction which does the following:
A ray is shot into the scene and the intersection is found. If that material is emissive, the emitted radiance along that ray is added to our estimate of Lo. The direct illumination at that point is also added. The BSDF for an exitant direction is sampled (note that we will also need to know the probability with which the exitant ray was sampled. The value of the BSDF at the sample point for the given incoming and outgoing directions is calculated and Russian Roulette is performed to decide if the path should be terminated. If not, then the algorithm is repeated for the ray starting at the intersection point and traveling in the sampled exitant direction. The spectral radiance which is returned is the incident radiance to the current intersection point so it is multiplied by the value of the BSDF which was calculated before, scaled by its contribution to the BSDF, and added to the estimate of the exitant spectral radiance.
This algorithm is clearly recursive, we are constructing paths by continually sampling exitant rays from the BSDFs at sample points.
The Complete Path Tracing in Milton
Now that you know how the algorithm should work, we'll show you how this pseudocode translates to C++. The next section will explain everything that's happening in detail.void PathTracer::_evaluate(const Ray &ray, Spectrum &outSpectrum, PropertyMap &data) { const unsigned depth = data.getValue<unsigned>("depth", 0); // find closest intersection SamplePoint pt; const double t = m_scene->getIntersection(ray, pt); // lazily initialize SamplePoint and return if no intersection // also return if we hit an emitter after the first bounce, // ensuring we don't double-count direct illumination if (!pt.init(ray, t)) return; // evaluate emitted radiance from intersection point if (depth == 0) outSpectrum += pt.emitter->getLe(-ray.direction); // estimate direct illumination outSpectrum += m_directIllumination->evaluate(pt); // sample the BSDF for an exitent direction const Event &event = pt.bsdf->sample(); const Vector3 &wo = event; if (wo == Vector3::zero()) return; // absorbed const double pdf = pt.bsdf->getPdf(event); const Spectrum &fs = pt.bsdf->getBSDF(wo) * ABS(pt.normal.dot(wo)) / pdf; // record russian roulette probability for terminating random walk // note: russian roulette increases variance noticeably, so don't // use it until several bounces have passed const double pCont = (depth < 8 ? (fs != Spectrum::black()) : MIN(1, fs[fs.getMaxFrequency()])); // russian roulette to terminate walk depending on reflectivity of surface if (pCont > Random::sample(0, 1)) { Spectrum Li; // estimate indirect specular illumination by recurring data.getValue<unsigned>("depth") = depth + 1; _evaluate(Ray(pt.position, wo), Li, data); // attenuate incident illumination Li by reflectivity of surface // in exitant direction (bsdf) and add its contribution to the // exitant radiance outSpectrum += fs * Li / pCont; } }
A Little Hand-Holding...
This code is most likely confusing for Milton novices, so we'll walk through it line by line:
void PathTracer::_evaluate(const Ray &ray, Spectrum &outSpectrum, PropertyMap &data) { const unsigned depth = data.getValue<unsigned>("depth", 0);
The _evaluate method is called for every sample on the image plane which is converted from image plane coordinates to world space coordinates and passed to the method as a Ray. The method is expected to return the exitant radiance in outSpectrum.
A Ray is basically a 3-dimensional ray with an origin point and a directional vector. A Spectrum is an abstraction of a color, you can think of Spectrum as an RGB triple.
The PropertyMap is a hashmap which is used for holding any data relevant to the evaluation of the sample. Above the PropertyMap is being used to access a value with the key "depth". The map stores values as boost::any objects (a boost::any is a variant value type, a template class that allows you to deal with arbitrary types in a typesafe manner), so you need to use the templated getValue function to get the value back correctly.
SamplePoint pt; // find closest intersection const double t = m_scene->getIntersection(ray, pt); if (!pt.init(ray, t)) return;
The SamplePoint class is important in Milton, it's used for shading evaluation, sampling, and path generation. It contains information including what shape the intersection point is on, a geometric as well as a shading normal (these can differ in some cases, e.g. bump mapping) and a shading BSDF. Notice that after a SamplePoint is declared, a member variable refering to the Scene is called which computes an intersection from the given ray. The Scene class contains references to all shapes, lights, and materials and is used for computing intersections as the code above shows. Notice that the SamplePoint is passed to the function and that a double "t" value representing the smallest "t" value of any intersections found (or INFINITY if there were no intersections) is returned. The SamplePoint parameter is populated with information about the shape which was intersected but all computations (i.e. world-space normal at intersection point, UV-coordinates, etc.) are left to be computed lazily. These fields are populated when the SamplePoint::init method is called, such as in the code above (note that the method returns false if it cannot be initialized if an invalid t value is provided.)
if (depth == 0) outSpectrum += pt.emitter->getLe(-ray.direction); outSpectrum += m_directIllumination->evaluate(pt);
Now we account for the emissive property of the material that is intersected only if this intersection is the first intersection for the path. This prevents direct illumination from being counted more than once. Then (for all intersections on the path), the direct illumination is evaluated for the current intersection point. Note that the SamplePoint contains the emitter which is queried for the exitant radiance along the opposite direction of the ray. Also note the m_directIllumination object of type DirectIllumination which is an interface for estimating the direct illumination from all luminaires in a scene. This class is useful because most renderers compute direct illumination.
const Event &event = pt.bsdf->sample(); const Vector3 &wo = event; if (wo == Vector3::zero()) return; // absorbed const double pdf = pt.bsdf->getPdf(event); const Spectrum &fs = pt.bsdf->getBSDF(wo) * ABS(pt.normal.dot(wo)) / pdf;
Now the SamplePoint's BSDF is sampled for an exitant direction given the incoming direction of the light. A SamplePoint allows us to determine what object in the scene was intersected, and thus we can determine the material properties of that object through the BSDF class in the SamplePoint's bsdf attribute. Calling BSDF::sample() returns an Event which can be used to get the actual exitant vector and also the probability density with which the vector was sampled. The following line implicitly retrieves the sampled Vector3 from the result (this implemented as a convenience method, it could also be retrieved using Event::getValue<Vector3>). If the sampled direction is the zero vector, then the ray has been absorbed by the material, and the function returns without contributing any more indirect specular illumination to the exitant radiance.
The probability of sampling the exitant vector is computed using BSDF::getPdf and then the actual bsdf is computed using BSDF::getBSDF then scaled according to the direction and the probability density. Note that getBSDF returns a Spectrum because the BSDF can be wavelength dependent.
const double pCont = (depth < 8 ? (fs != Spectrum::black()) : MIN(1, fs[fs.getMaxFrequency()])); if (pCont > Random::sample(0, 1)) { Spectrum Li;
We now perform Russian Roulette to determine whether or not indirect specular illumination should be estimated by recurring. Note that Russian Roulette can greatly increase variance so it is only used when the depth of the path is greater than or equal to 8. The probability of continuing is equal to the largest component of fs.
data.getValue<unsigned>("depth") = depth + 1; _evaluate(Ray(pt.position, wo), Li, data); outSpectrum += fs * Li / pCont; } }
Finally, we recur on the current intersection point with the sampled exitant direction to estimate the indirect specular illumination. This quantity is scaled by the computed fs as well as the probability of having chosen to survive the Russian Roulette. And that's all! Milton does the rest of the work for us.
Hopefully you've learned a bit about the tools provided with Milton and feel comfortable enough to play with the Milton source code yourself. Happy rendering!