15.3 Volumetric Light Transport
These sampling building blocks make it possible to implement various light transport algorithms in participating media. We can now implement the fragments in the EstimateDirect() function from Section 14.3.1 that handle the cases related to participating media.
First, after a light has been sampled, if the interaction is a scattering event in participating media, it’s necessary to compute the value of the phase function for the outgoing direction and the incident illumination direction as well as the value of the PDF for sampling that direction for multiple importance sampling. Because we assume that phase functions are sampled perfectly, these values are the same.
The direct lighting calculation needs to take a sample from the phase function’s distribution. Sample_p() provides this capability; as described earlier, the value it returns gives both the phase function’s value and the PDF’s.
15.3.1 Path Tracing
The VolPathIntegrator is a SamplerIntegrator that accounts for scattering and attenuation from participating media as well as scattering from surfaces. It is defined in the files integrators/volpath.h and integrators/volpath.cpp and has a general structure that is very similar to the PathIntegrator, so here we will only discuss the differences between those two classes. See Figures 15.4 and 15.5 for images rendered with this integrator that show off the importance of accounting for multiple scattering in participating media.
As a SamplerIntegrator, the VolPathIntegrator’s main responsibility is to implement the Li() method. The general structure of its implementation is very similar to that of PathIntegrator::Li(), though with a few small changes related to participating media.
At each step in sampling the scattering path, the ray is first intersected with the surfaces in the scene to find the closest surface intersection, if any. Next, participating media are accounted for with a call to the Medium::Sample() method, which initializes the provided MediumInteraction if a medium interaction should be the next vertex in the path. In either case, Sample() also returns a factor accounting for the beam transmittance and sampling PDF to either the surface or medium interaction.
In scenes with very dense scattering media, the effort spent on first finding surface intersections will often be wasted, as Medium::Sample() will usually generate a medium interaction instead. For such scenes, a more efficient implementation would be to first sample a medium interaction, updating the ray’s tMax value accordingly before intersecting the ray with primitives in the scene. In turn, surface intersection tests would be much more efficient, as the ray to be tested would often be fairly short. (Further investigating and addressing this issue is left for Exercise 15.5.)
Depending on whether the sampled interaction for this ray is within participating media or at a point on a surface, one of two fragments handles computing the direct illumination at the point and sampling the next direction.
Thanks to the fragments defined earlier in this section, the UniformSampleOneLight() function already supports estimating direct illumination at points in participating media, so we just need to pass the MediumInteraction for the sampled interaction to it. The direction for the ray leaving the medium interaction is then easily found with a call to Sample_p().
For scattering from surfaces, the computation performed is almost exactly the same as the regular PathIntegrator, except that attenuation of radiance from light sources to surface intersection points is incorporated by calling VisibilityTester::Tr() instead of VisibilityTester::Unoccluded() when sampling direct illumination. Because these differences are minor, we won’t include the corresponding code here.