13.3 A Simple Path Tracer

The path tracing estimator in Equation (13.7) makes it possible to apply the BSDF and light sampling techniques that were respectively defined in Chapters 9 and 12 to rendering. As shown in Figure 13.6, more effective importance sampling approaches than the uniform sampling in the RandomWalkIntegrator significantly reduce error. Although the SimplePathIntegrator takes longer to render an image at equal sample counts, most of that increase is because paths often terminate early with the RandomWalkIntegrator; because it samples outgoing directions at intersections uniformly over the sphere, half of the sampled directions lead to path termination at non-transmissive surfaces. The overall improvement in Monte Carlo efficiency from the SimplePathIntegrator is 12.8 times .

Figure 13.6: Comparison of the RandomWalkIntegrator and the SimplePathIntegrator. (a) Scene rendered with 64 pixel samples using the RandomWalkIntegrator. (b) Rendered with 64 pixel samples and the SimplePathIntegrator. The SimplePathIntegrator gives an image that is visibly much improved, thanks to using more effective BSDF and light sampling techniques. Here, mean squared error (MSE) is reduced by a factor of 101. Even though rendering time was 7.8 times longer, the overall improvement in Monte Carlo efficiency was still 12.8 times . (Scene courtesy of Angelo Ferretti.)

The “simple” in the name of this integrator is meaningful: PathIntegrator, which will be introduced shortly, adds a number of additional sampling improvements and should be used in preference to SimplePathIntegrator if rendering efficiency is important. This integrator is still useful beyond pedagogy, however; it is also useful for debugging and for validating the implementation of sampling algorithms. For example, it can be configured to use BSDFs’ sampling methods or to use uniform directional sampling; given a sufficient number of samples, both approaches should converge to the same result (assuming that the BSDF is not perfect specular). If they do not, the error is presumably in the BSDF sampling code. Light sampling techniques can be tested in a similar fashion.

<<SimplePathIntegrator Definition>>= 
class SimplePathIntegrator : public RayIntegrator { public: <<SimplePathIntegrator Public Methods>> 
SimplePathIntegrator(int maxDepth, bool sampleLights, bool sampleBSDF, Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights); SampledSpectrum Li(RayDifferential ray, SampledWavelengths &lambda, Sampler sampler, ScratchBuffer &scratchBuffer, VisibleSurface *visibleSurface) const; static std::unique_ptr<SimplePathIntegrator> Create( const ParameterDictionary &parameters, Camera camera, Sampler sampler, Primitive aggregate, std::vector<Light> lights, const FileLoc *loc); std::string ToString() const;
private: <<SimplePathIntegrator Private Members>> 
int maxDepth; bool sampleLights, sampleBSDF; UniformLightSampler lightSampler;
};

The constructor sets the following member variables from provided parameters, so it is not included here. Similar to the RandomWalkIntegrator, maxDepth caps the maximum path length.

The sampleLights member variable determines whether lights’ SampleLi() methods should be used to sample direct illumination or whether illumination should only be found by rays randomly intersecting emissive surfaces, as was done in the RandomWalkIntegrator. In a similar fashion, sampleBSDF determines whether BSDFs’ Sample_f() methods should be used to sample directions or whether uniform directional sampling should be used. Both are true by default. A UniformLightSampler is always used for sampling a light; this, too, is an instance where this integrator opts for simplicity and a lower likelihood of bugs in exchange for lower efficiency.

<<SimplePathIntegrator Private Members>>= 
int maxDepth; bool sampleLights, sampleBSDF; UniformLightSampler lightSampler;

As a RayIntegrator, this integrator provides a Li() method that returns an estimate of the radiance along the provided ray. It does not provide the capability of initializing a VisibleSurface at the first intersection point, so the corresponding parameter is ignored.

<<SimplePathIntegrator Method Definitions>>= 
SampledSpectrum SimplePathIntegrator::Li(RayDifferential ray, SampledWavelengths &lambda, Sampler sampler, ScratchBuffer &scratchBuffer, VisibleSurface *) const { <<Estimate radiance along ray using simple path tracing>> 
SampledSpectrum L(0.f), beta(1.f); bool specularBounce = true; int depth = 0; while (beta) { <<Find next SimplePathIntegrator vertex and accumulate contribution>> 
<<Intersect ray with scene>> 
pstd::optional<ShapeIntersection> si = Intersect(ray);
<<Account for infinite lights if ray has no intersection>> 
if (!si) { if (!sampleLights || specularBounce) for (const auto &light : infiniteLights) L += beta * light.Le(ray, lambda); break; }
<<Account for emissive surface if light was not sampled>> 
SurfaceInteraction &isect = si->intr; if (!sampleLights || specularBounce) L += beta * isect.Le(-ray.d, lambda);
<<End path if maximum depth reached>> 
if (depth++ == maxDepth) break;
<<Get BSDF and skip over medium boundaries>> 
BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler); if (!bsdf) { isect.SkipIntersection(&ray, si->tHit); continue; }
<<Sample direct illumination if sampleLights is true>> 
Vector3f wo = -ray.d; if (sampleLights) { pstd::optional<SampledLight> sampledLight = lightSampler.Sample(sampler.Get1D()); if (sampledLight) { <<Sample point on sampledLight to estimate direct illumination>> 
Point2f uLight = sampler.Get2D(); pstd::optional<LightLiSample> ls = sampledLight->light.SampleLi(isect, uLight, lambda); if (ls && ls->L && ls->pdf > 0) { <<Evaluate BSDF for light and possibly add scattered radiance>> 
Vector3f wi = ls->wi; SampledSpectrum f = bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n); if (f && Unoccluded(isect, ls->pLight)) L += beta * f * ls->L / (sampledLight->p * ls->pdf);
}
} }
<<Sample outgoing direction at intersection to continue path>> 
if (sampleBSDF) { <<Sample BSDF for new path direction>> 
Float u = sampler.Get1D(); pstd::optional<BSDFSample> bs = bsdf.Sample_f(wo, u, sampler.Get2D()); if (!bs) break; beta *= bs->f * AbsDot(bs->wi, isect.shading.n) / bs->pdf; specularBounce = bs->IsSpecular(); ray = isect.SpawnRay(bs->wi);
} else { <<Uniformly sample sphere or hemisphere to get new path direction>> 
Float pdf; Vector3f wi; BxDFFlags flags = bsdf.Flags(); if (IsReflective(flags) && IsTransmissive(flags)) { wi = SampleUniformSphere(sampler.Get2D()); pdf = UniformSpherePDF(); } else { wi = SampleUniformHemisphere(sampler.Get2D()); pdf = UniformHemispherePDF(); if (IsReflective(flags) && Dot(wo, isect.n) * Dot(wi, isect.n) < 0) wi = -wi; else if (IsTransmissive(flags) && Dot(wo, isect.n) * Dot(wi, isect.n) > 0) wi = -wi; } beta *= bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n) / pdf; specularBounce = false; ray = isect.SpawnRay(wi);
}
} return L;
}

A number of variables record the current state of the path. L is the current estimated scattered radiance from the running total of sigma-summation upper P left-parenthesis normal p Subscript Baseline overbar Subscript i Baseline right-parenthesis and ray is updated after each surface intersection to be the next ray to be traced. specularBounce records if the last outgoing path direction sampled was due to specular reflection; the need to track this will be explained shortly.

The beta variable holds the path throughput weight, which is defined as the factors of the throughput function upper T left-parenthesis normal p Subscript Baseline overbar Subscript i minus 1 Baseline right-parenthesis —that is, the product of the BSDF values and cosine terms for the vertices generated so far, divided by their respective sampling PDFs:

beta equals product Underscript j equals 1 Overscript i minus 2 Endscripts StartFraction f Subscript Baseline left-parenthesis normal p Subscript j plus 1 Baseline right-arrow normal p Subscript j Baseline right-arrow normal p Subscript j minus 1 Baseline right-parenthesis StartAbsoluteValue cosine theta Subscript j Baseline EndAbsoluteValue Over p Subscript omega Sub Subscript Subscript Baseline left-parenthesis omega Subscript j Baseline right-parenthesis EndFraction period

Thus, the product of beta with scattered light from direct lighting from the final vertex of the path gives the contribution for a path. (This quantity will reoccur many times in the following few chapters, and we will consistently refer to it as beta.) Because the effect of earlier path vertices is aggregated in this way, there is no need to store the positions and BSDFs of all the vertices of the path—only the last one.

<<Estimate radiance along ray using simple path tracing>>= 
SampledSpectrum L(0.f), beta(1.f); bool specularBounce = true; int depth = 0; while (beta) { <<Find next SimplePathIntegrator vertex and accumulate contribution>> 
<<Intersect ray with scene>> 
pstd::optional<ShapeIntersection> si = Intersect(ray);
<<Account for infinite lights if ray has no intersection>> 
if (!si) { if (!sampleLights || specularBounce) for (const auto &light : infiniteLights) L += beta * light.Le(ray, lambda); break; }
<<Account for emissive surface if light was not sampled>> 
SurfaceInteraction &isect = si->intr; if (!sampleLights || specularBounce) L += beta * isect.Le(-ray.d, lambda);
<<End path if maximum depth reached>> 
if (depth++ == maxDepth) break;
<<Get BSDF and skip over medium boundaries>> 
BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler); if (!bsdf) { isect.SkipIntersection(&ray, si->tHit); continue; }
<<Sample direct illumination if sampleLights is true>> 
Vector3f wo = -ray.d; if (sampleLights) { pstd::optional<SampledLight> sampledLight = lightSampler.Sample(sampler.Get1D()); if (sampledLight) { <<Sample point on sampledLight to estimate direct illumination>> 
Point2f uLight = sampler.Get2D(); pstd::optional<LightLiSample> ls = sampledLight->light.SampleLi(isect, uLight, lambda); if (ls && ls->L && ls->pdf > 0) { <<Evaluate BSDF for light and possibly add scattered radiance>> 
Vector3f wi = ls->wi; SampledSpectrum f = bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n); if (f && Unoccluded(isect, ls->pLight)) L += beta * f * ls->L / (sampledLight->p * ls->pdf);
}
} }
<<Sample outgoing direction at intersection to continue path>> 
if (sampleBSDF) { <<Sample BSDF for new path direction>> 
Float u = sampler.Get1D(); pstd::optional<BSDFSample> bs = bsdf.Sample_f(wo, u, sampler.Get2D()); if (!bs) break; beta *= bs->f * AbsDot(bs->wi, isect.shading.n) / bs->pdf; specularBounce = bs->IsSpecular(); ray = isect.SpawnRay(bs->wi);
} else { <<Uniformly sample sphere or hemisphere to get new path direction>> 
Float pdf; Vector3f wi; BxDFFlags flags = bsdf.Flags(); if (IsReflective(flags) && IsTransmissive(flags)) { wi = SampleUniformSphere(sampler.Get2D()); pdf = UniformSpherePDF(); } else { wi = SampleUniformHemisphere(sampler.Get2D()); pdf = UniformHemispherePDF(); if (IsReflective(flags) && Dot(wo, isect.n) * Dot(wi, isect.n) < 0) wi = -wi; else if (IsTransmissive(flags) && Dot(wo, isect.n) * Dot(wi, isect.n) > 0) wi = -wi; } beta *= bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n) / pdf; specularBounce = false; ray = isect.SpawnRay(wi);
}
} return L;

Each iteration of the while loop accounts for an additional segment of a path, corresponding to a term of upper P left-parenthesis normal p Subscript Baseline overbar Subscript i Baseline right-parenthesis ’s sum.

<<Find next SimplePathIntegrator vertex and accumulate contribution>>= 
<<Intersect ray with scene>> 
pstd::optional<ShapeIntersection> si = Intersect(ray);
<<Account for infinite lights if ray has no intersection>> 
if (!si) { if (!sampleLights || specularBounce) for (const auto &light : infiniteLights) L += beta * light.Le(ray, lambda); break; }
<<Account for emissive surface if light was not sampled>> 
SurfaceInteraction &isect = si->intr; if (!sampleLights || specularBounce) L += beta * isect.Le(-ray.d, lambda);
<<End path if maximum depth reached>> 
if (depth++ == maxDepth) break;
<<Get BSDF and skip over medium boundaries>> 
BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler); if (!bsdf) { isect.SkipIntersection(&ray, si->tHit); continue; }
<<Sample direct illumination if sampleLights is true>> 
Vector3f wo = -ray.d; if (sampleLights) { pstd::optional<SampledLight> sampledLight = lightSampler.Sample(sampler.Get1D()); if (sampledLight) { <<Sample point on sampledLight to estimate direct illumination>> 
Point2f uLight = sampler.Get2D(); pstd::optional<LightLiSample> ls = sampledLight->light.SampleLi(isect, uLight, lambda); if (ls && ls->L && ls->pdf > 0) { <<Evaluate BSDF for light and possibly add scattered radiance>> 
Vector3f wi = ls->wi; SampledSpectrum f = bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n); if (f && Unoccluded(isect, ls->pLight)) L += beta * f * ls->L / (sampledLight->p * ls->pdf);
}
} }
<<Sample outgoing direction at intersection to continue path>> 
if (sampleBSDF) { <<Sample BSDF for new path direction>> 
Float u = sampler.Get1D(); pstd::optional<BSDFSample> bs = bsdf.Sample_f(wo, u, sampler.Get2D()); if (!bs) break; beta *= bs->f * AbsDot(bs->wi, isect.shading.n) / bs->pdf; specularBounce = bs->IsSpecular(); ray = isect.SpawnRay(bs->wi);
} else { <<Uniformly sample sphere or hemisphere to get new path direction>> 
Float pdf; Vector3f wi; BxDFFlags flags = bsdf.Flags(); if (IsReflective(flags) && IsTransmissive(flags)) { wi = SampleUniformSphere(sampler.Get2D()); pdf = UniformSpherePDF(); } else { wi = SampleUniformHemisphere(sampler.Get2D()); pdf = UniformHemispherePDF(); if (IsReflective(flags) && Dot(wo, isect.n) * Dot(wi, isect.n) < 0) wi = -wi; else if (IsTransmissive(flags) && Dot(wo, isect.n) * Dot(wi, isect.n) > 0) wi = -wi; } beta *= bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n) / pdf; specularBounce = false; ray = isect.SpawnRay(wi);
}

The first step is to find the intersection of the ray for the current segment with the scene geometry.

<<Intersect ray with scene>>= 
pstd::optional<ShapeIntersection> si = Intersect(ray);

If there is no intersection, then the ray path comes to an end. Before the accumulated path radiance estimate can be returned, however, in some cases radiance from infinite light sources is added to the path’s radiance estimate, with contribution scaled by the accumulated beta factor.

If sampleLights is false, then emission is only found when rays happen to intersect emitters, in which case the contribution of infinite area lights must be added to rays that do not intersect any geometry. If it is true, then the integrator calls the Light SampleLi() method to estimate direct illumination at each path vertex. In that case, infinite lights have already been accounted for, except in the case of a specular BSDF at the previous vertex. Then, SampleLi() is not useful since only the specular direction scatters light. Therefore, specularBounce records whether the last BSDF was perfect specular, in which case infinite area lights must be included here after all.

<<Account for infinite lights if ray has no intersection>>= 
if (!si) { if (!sampleLights || specularBounce) for (const auto &light : infiniteLights) L += beta * light.Le(ray, lambda); break; }

If the ray hits an emissive surface, similar logic governs whether its emission is added to the path’s radiance estimate.

<<Account for emissive surface if light was not sampled>>= 
SurfaceInteraction &isect = si->intr; if (!sampleLights || specularBounce) L += beta * isect.Le(-ray.d, lambda);

The next step is to find the BSDF at the intersection point. A special case arises when an unset BSDF is returned by the SurfaceInteraction’s GetBSDF() method. In that case, the current surface should have no effect on light. pbrt uses such surfaces to represent transitions between participating media, whose boundaries are themselves optically inactive (i.e., they have the same index of refraction on both sides). Since the SimplePathIntegrator ignores media, it simply skips over such surfaces without counting them as scattering events in the depth counter.

<<Get BSDF and skip over medium boundaries>>= 
BSDF bsdf = isect.GetBSDF(ray, lambda, camera, scratchBuffer, sampler); if (!bsdf) { isect.SkipIntersection(&ray, si->tHit); continue; }

Otherwise we have a valid surface intersection and can go ahead and increment depth. The path is then terminated if it has reached the maximum depth.

<<End path if maximum depth reached>>= 
if (depth++ == maxDepth) break;

If explicit light sampling is being performed, then the first step is to use the UniformLightSampler to choose a single light source. (Recall from Section 12.6 that sampling only one of the scene’s light sources can still give a valid estimate of the effect of all of them, given suitable weighting.)

<<Sample direct illumination if sampleLights is true>>= 
Vector3f wo = -ray.d; if (sampleLights) { pstd::optional<SampledLight> sampledLight = lightSampler.Sample(sampler.Get1D()); if (sampledLight) { <<Sample point on sampledLight to estimate direct illumination>> 
Point2f uLight = sampler.Get2D(); pstd::optional<LightLiSample> ls = sampledLight->light.SampleLi(isect, uLight, lambda); if (ls && ls->L && ls->pdf > 0) { <<Evaluate BSDF for light and possibly add scattered radiance>> 
Vector3f wi = ls->wi; SampledSpectrum f = bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n); if (f && Unoccluded(isect, ls->pLight)) L += beta * f * ls->L / (sampledLight->p * ls->pdf);
}
} }

Given a light source, a call to SampleLi() yields a sample on the light. If the light sample is valid, a direct lighting calculation is performed.

<<Sample point on sampledLight to estimate direct illumination>>= 
Point2f uLight = sampler.Get2D(); pstd::optional<LightLiSample> ls = sampledLight->light.SampleLi(isect, uLight, lambda); if (ls && ls->L && ls->pdf > 0) { <<Evaluate BSDF for light and possibly add scattered radiance>> 
Vector3f wi = ls->wi; SampledSpectrum f = bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n); if (f && Unoccluded(isect, ls->pLight)) L += beta * f * ls->L / (sampledLight->p * ls->pdf);
}

Returning to the path tracing estimator in Equation (13.7), we have the path throughput weight in beta, which corresponds to the term in parentheses there. A call to SampleLi() yields a sample on the light. Because the light sampling methods return samples that are with respect to solid angle and not area, yet another Jacobian correction term is necessary, and the estimator becomes

upper P left-parenthesis normal p Subscript Baseline overbar Subscript i Baseline right-parenthesis equals StartFraction upper L Subscript normal e Baseline left-parenthesis normal p Subscript i Baseline right-arrow normal p Subscript i minus 1 Baseline right-parenthesis f Subscript Baseline left-parenthesis normal p Subscript i Baseline right-arrow normal p Subscript i minus 1 Baseline right-arrow normal p Subscript i minus 2 Baseline right-parenthesis StartAbsoluteValue cosine theta Subscript i Baseline EndAbsoluteValue upper V left-parenthesis normal p Subscript i Baseline left-right-arrow normal p Subscript i minus 1 Baseline right-parenthesis Over p Subscript l Baseline left-parenthesis omega Subscript i Baseline right-parenthesis p left-parenthesis l right-parenthesis EndFraction beta comma

where p Subscript l is the solid angle density that the chosen light l would use to sample the direction omega Subscript i and p left-parenthesis l right-parenthesis is the discrete probability of sampling the light l (recall Equation (12.2)). Their product gives the full probability of the light sample.

Before tracing the shadow ray to evaluate the visibility factor upper V , it is worth checking if the BSDF is zero for the sampled direction, in which case that computational expense is unnecessary.

<<Evaluate BSDF for light and possibly add scattered radiance>>= 
Vector3f wi = ls->wi; SampledSpectrum f = bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n); if (f && Unoccluded(isect, ls->pLight)) L += beta * f * ls->L / (sampledLight->p * ls->pdf);

Unoccluded() is a convenience method provided in the Integrator base class.

<<Integrator Public Methods>>+= 
bool Unoccluded(const Interaction &p0, const Interaction &p1) const { return !IntersectP(p0.SpawnRayTo(p1), 1 - ShadowEpsilon); }

To sample the next path vertex, the direction of the ray leaving the surface is found either by calling the BSDF’s sampling method or by sampling uniformly, depending on the sampleBSDF parameter.

<<Sample outgoing direction at intersection to continue path>>= 
if (sampleBSDF) { <<Sample BSDF for new path direction>> 
Float u = sampler.Get1D(); pstd::optional<BSDFSample> bs = bsdf.Sample_f(wo, u, sampler.Get2D()); if (!bs) break; beta *= bs->f * AbsDot(bs->wi, isect.shading.n) / bs->pdf; specularBounce = bs->IsSpecular(); ray = isect.SpawnRay(bs->wi);
} else { <<Uniformly sample sphere or hemisphere to get new path direction>> 
Float pdf; Vector3f wi; BxDFFlags flags = bsdf.Flags(); if (IsReflective(flags) && IsTransmissive(flags)) { wi = SampleUniformSphere(sampler.Get2D()); pdf = UniformSpherePDF(); } else { wi = SampleUniformHemisphere(sampler.Get2D()); pdf = UniformHemispherePDF(); if (IsReflective(flags) && Dot(wo, isect.n) * Dot(wi, isect.n) < 0) wi = -wi; else if (IsTransmissive(flags) && Dot(wo, isect.n) * Dot(wi, isect.n) > 0) wi = -wi; } beta *= bsdf.f(wo, wi) * AbsDot(wi, isect.shading.n) / pdf; specularBounce = false; ray = isect.SpawnRay(wi);
}

If BSDF sampling is being used to sample the new direction, the Sample_f() method gives a direction and the associated BSDF and PDF values. beta can then be updated according to Equation (13.8).

<<Sample BSDF for new path direction>>= 
Float u = sampler.Get1D(); pstd::optional<BSDFSample> bs = bsdf.Sample_f(wo, u, sampler.Get2D()); if (!bs) break; beta *= bs->f * AbsDot(bs->wi, isect.shading.n) / bs->pdf; specularBounce = bs->IsSpecular(); ray = isect.SpawnRay(bs->wi);

Otherwise, the fragment <<Uniformly sample sphere or hemisphere to get new path direction>> uniformly samples a new direction for the ray leaving the surface. It goes through more care than the RandomWalkIntegrator did: for example, if the surface is reflective but not transmissive, it makes sure that the sampled direction is in the hemisphere where light is scattered. We will not include that fragment here, as it has to handle a number of such cases, but there is not much that is interesting about how it does so.