15.2 Sampling Volume Scattering

Before proceeding to algorithms that model the effect of light scattering in participating media, we’ll first define some building-block functionality for sampling from distributions related to participating media and for computing the beam transmittance for spatially varying media.

The Medium interface defines a Sample() method, which takes a world space ray and possibly samples a medium scattering interaction along it. The input ray will generally have been intersected against the scene geometry; thus, implementations of this method shouldn’t ever sample a medium interaction at a point on the ray beyond its value. Without loss of generality, the following discussion assumes that there is always a surface at some distance .

<<Medium Interface>>+=
virtual Spectrum Sample(const Ray &ray, Sampler &sampler, MemoryArena &arena, MediumInteraction *mi) const = 0;

The objective of this method is to sample the integral form of the equation of transfer, Equation (15.2), which consists of a surface and a medium-related term:

where is the point on the surface. We will neglect the effect of medium emission and assume directionally constant medium properties, in which case the source term is given by

Two cases can occur: if Sample() doesn’t sample an interaction on the given ray interval , then the surface-related term should be estimated. If it does sample an interaction, the second integral term is to be estimated, and the provided MediumInteraction should be initialized accordingly.

Suppose that denotes the probability per unit distance of generating an interaction at position . Due to the possibility of not sampling a medium interaction, this function generally doesn’t integrate to 1, and we define as the associated discrete probability of sampling the surface term:

With these definitions, we can now specify the semantics of Sample(), which differs from previously encountered techniques for scattering functions like BSDF::Sample_f() in that it does not provide the caller with separate information about the function value and PDF at the sampled position. This information is not generally needed, and some medium models (specifically, the heterogeneous medium) admit more efficient sampling schemes when it is possible to compute ratios of these quantities instead.

When the surface term is selected, the method should return a weight equal to

which corresponds to sampling the first summand. Note that the value of the outgoing radiance is not included in ; it is the responsibility of the caller to account for this term. In the medium case, the method returns

which corresponds to sampling all medium-related terms except for the integral over in-scattered light in Equation (15.6), which must be handled separately.

The scattering coefficient and transmittance allow for spectral variation, hence this method returns a Spectrum-valued weighting factor to update the path throughput weight up to the surface or medium scattering event.

As is generally the case for Monte Carlo integration, estimators like and admit a variety of sampling techniques that all produce the desired distribution. The implementation of the heterogeneous medium will make use of this fact to provide an implementation that is considerably more efficient than the canonical sampling approach based on the inversion method.

So that calling code can easily determine whether the provided MediumInteraction was initialized by Sample(), MediumInteraction provides an IsValid() method that takes advantage of the fact that any time a medium scattering event has been sampled, the phase function pointer will be set.

<<MediumInteraction Public Methods>>+=
bool IsValid() const { return phase != nullptr; }

15.2.1 Homogeneous Medium

The HomogeneousMedium implementation of this method is fairly straightforward; the only complexities come from needing to handle attenuation coefficients that vary by wavelength.

<<HomogeneousMedium Method Definitions>>+=
Spectrum HomogeneousMedium::Sample(const Ray &ray, Sampler &sampler, MemoryArena &arena, MediumInteraction *mi) const { <<Sample a channel and distance along the ray>>
int channel = std::min((int)(sampler.Get1D() * Spectrum::nSamples), Spectrum::nSamples - 1); Float dist = -std::log(1 - sampler.Get1D()) / sigma_t[channel]; Float t = std::min(dist * ray.d.Length(), ray.tMax); bool sampledMedium = t < ray.tMax; if (sampledMedium) *mi = MediumInteraction(ray(t), -ray.d, ray.time, this, ARENA_ALLOC(arena, HenyeyGreenstein)(g));
<<Compute the transmittance and sampling density>>
Spectrum Tr = Exp(-sigma_t * std::min(t, MaxFloat) * ray.d.Length());
<<Return weighting factor for scattering from homogeneous medium>>
Spectrum density = sampledMedium ? (sigma_t * Tr) : Tr; Float pdf = 0; for (int i = 0; i < Spectrum::nSamples; ++i) pdf += density[i]; pdf *= 1 / (Float)Spectrum::nSamples; return sampledMedium ? (Tr * sigma_s / pdf) : (Tr / pdf);
}

In Section 13.3.1 we derived the sampling method for an exponential distribution defined over . For , it is

with PDF

However, the attenuation coefficient in general varies by wavelength. It is not desirable to sample multiple points in the medium, so a uniform sample is first used to select a spectral channel ; the corresponding scalar value is then used to sample a distance along the distribution

using the technique from Equation (15.9). The resulting sampling density is the average of the individual strategies :

The (discrete) probability of sampling a surface interaction at is the complement of generating a medium scattering event between and . This works out to a probability equal to the average transmittance over all spectral channels:

The implementation draws a sample according to Equation (15.11); if the sampled distance is before the ray–primitive intersection (if any), then a medium scattering event is recorded by initializing the MediumInteraction. Otherwise, the sampled point in the medium is ignored, and corresponding surface interaction should be used as the next path vertex by the integrator. This sampling approach is naturally efficient: the probability of generating a medium interaction instead of the surface interaction is exactly equal to 1 minus the beam transmittance for the selected wavelength. Thus, given optically thin media (or a short ray extent), the surface interaction is more often used, and for thick media (or longer rays), a medium interaction is more likely to be sampled.

<<Sample a channel and distance along the ray>>=
int channel = std::min((int)(sampler.Get1D() * Spectrum::nSamples), Spectrum::nSamples - 1); Float dist = -std::log(1 - sampler.Get1D()) / sigma_t[channel]; Float t = std::min(dist * ray.d.Length(), ray.tMax); bool sampledMedium = t < ray.tMax; if (sampledMedium) *mi = MediumInteraction(ray(t), -ray.d, ray.time, this, ARENA_ALLOC(arena, HenyeyGreenstein)(g));

In either case, the beam transmittance Tr is easily computed using Beer’s law, Equation (11.3), just as in the HomogeneousMedium::Tr() method.

<<Compute the transmittance and sampling density>>=
Spectrum Tr = Exp(-sigma_t * std::min(t, MaxFloat) * ray.d.Length());

Finally, the method computes the sample density using Equations (15.11) or (15.12) and returns the resulting sampling weight or , depending on the value of sampledMedium.

<<Return weighting factor for scattering from homogeneous medium>>=
Spectrum density = sampledMedium ? (sigma_t * Tr) : Tr; Float pdf = 0; for (int i = 0; i < Spectrum::nSamples; ++i) pdf += density[i]; pdf *= 1 / (Float)Spectrum::nSamples; return sampledMedium ? (Tr * sigma_s / pdf) : (Tr / pdf);

15.2.2 Heterogeneous Medium

In the case of the GridDensityMedium, extra effort is necessary to deal with the medium’s heterogeneous nature. When the spatial variation can be decomposed into uniform regions (e.g., piecewise constant voxels), a technique known as regular tracking applies standard homogeneous medium techniques to the voxels individually; a disadvantage of this approach is that it becomes costly when there are many voxels. Since the GridDensityMedium relies on linear interpolation, this approach cannot be used.

Other techniques build on a straightforward generalization of the homogeneous sampling PDF from Equation (15.10) with a spatially varying attenuation coefficient:

where evaluates the attenuation at distance along the ray. The most commonly used method for importance sampling Equation (15.13), is known as ray marching. This method inverts an approximate cumulative distribution by partitioning the range into a number of subintervals, numerically approximating the integral in each interval, and finally inverting this discrete representation. Unfortunately discretizing the problem in this way introduces systemic statistical bias, which means that an Integrator using ray marching generally won’t converge to the right result (even when an infinite number of samples per pixel is used). Furthermore, this bias can manifest itself in the form of distracting visual artifacts.

For this reason, we prefer an alternative unbiased approach proposed by Woodcock et al. (1965) that was originally developed to simulate volumetric scattering of neutrons in atomic reactors. This technique is known as delta tracking and is easiest to realize when the attenuation coefficient is monochromatic. Our implementation includes an assertion test (not shown here) to verify that this is indeed the case. Note that the scattering and absorption coefficients are still permitted to vary with respect to wavelength—however, their sum must be uniform.

Figure 15.3 compares regular tracking, ray marching, and delta tracking. Delta tracking can be interpreted as filling the medium with additional (virtual) particles until its attenuation coefficient is constant everywhere. Sampling the resulting homogeneous medium is then easily accomplished using the basic exponential scheme from Equation (15.9). However, whenever an interaction with a particle occurs, it is still necessary to determine if it involved a “real” or a “virtual” particle (in which case the interaction is disregarded). The elegant insight of Woodcock et al. was that this decision can be made randomly based on the local fraction of “real” particles, which leads to a distribution of samples matching Equation (15.13).

The following fragment is part of the GridDensityMedium::GridDensityMedium() constructor; its purpose is to precompute the inverse of the maximum density scale factor over the entire medium, which will be a useful quantity in the delta tracking implementation discussed next.

<<Precompute values for Monte Carlo sampling of GridDensityMedium>>=
sigma_t = (sigma_a + sigma_s)[0]; Float maxDensity = 0; for (int i = 0; i < nx * ny * nz; ++i) maxDensity = std::max(maxDensity, density[i]); invMaxDensity = 1 / maxDensity;

<<GridDensityMedium Private Data>>+=
Float sigma_t; Float invMaxDensity;

The Sample() method begins by transforming the ray into the medium coordinate system and normalizing the ray direction; ray.tMax is scaled appropriately to account for the normalization.

<<GridDensityMedium Method Definitions>>+=
Spectrum GridDensityMedium::Sample(const Ray &rWorld, Sampler &sampler, MemoryArena &arena, MediumInteraction *mi) const { Ray ray = WorldToMedium(Ray(rWorld.o, Normalize(rWorld.d), rWorld.tMax * rWorld.d.Length())); <<Compute interval of ray’s overlap with medium bounds>>
const Bounds3f b(Point3f(0, 0, 0), Point3f(1, 1, 1)); Float tMin, tMax; if (!b.IntersectP(ray, &tMin, &tMax)) return Spectrum(1.f);
<<Run delta-tracking iterations to sample a medium interaction>>
Float t = tMin; while (true) { t -= std::log(1 - sampler.Get1D()) * invMaxDensity / sigma_t; if (t >= tMax) break; if (Density(ray(t)) * invMaxDensity > sampler.Get1D()) { <<Populate mi with medium interaction information and return>>
PhaseFunction *phase = ARENA_ALLOC(arena, HenyeyGreenstein)(g); *mi = MediumInteraction(rWorld(t), -rWorld.d, rWorld.time, this, phase); return sigma_s / sigma_t;
} } return Spectrum(1.f);
}

Next, the implementation computes the parametric range of the ray’s overlap with the medium’s bounds, which are the unit cube . This step is technically not required for correct operation but is generally a good idea: reducing the length of the considered ray segment translates into a correspondingly smaller number of delta tracking iterations.

<<Compute interval of ray’s overlap with medium bounds>>=
const Bounds3f b(Point3f(0, 0, 0), Point3f(1, 1, 1)); Float tMin, tMax; if (!b.IntersectP(ray, &tMin, &tMax)) return Spectrum(1.f);

Assuming that the maximum extinction value throughout the medium is given by , each delta-tracking iteration performs a standard exponential step through the uniform medium:

where . These steps are repeated until one of two stopping criteria is satisfied: first, if then we have left the medium without an interaction and Medium::Sample() hasn’t sampled a scattering event. Alternatively, the loop may be terminated at each iteration with probability , the local fraction of “real” particles. This random decision consumes , the second of two uniform samples per iteration .

<<Run delta-tracking iterations to sample a medium interaction>>=
Float t = tMin; while (true) { t -= std::log(1 - sampler.Get1D()) * invMaxDensity / sigma_t; if (t >= tMax) break; if (Density(ray(t)) * invMaxDensity > sampler.Get1D()) { <<Populate mi with medium interaction information and return>>
PhaseFunction *phase = ARENA_ALLOC(arena, HenyeyGreenstein)(g); *mi = MediumInteraction(rWorld(t), -rWorld.d, rWorld.time, this, phase); return sigma_s / sigma_t;
} } return Spectrum(1.f);

The probability of not sampling a medium interaction is equal to the transmittance of the ray segment ; hence 1.0 is returned for the sampling weight according to Equation (15.7). The medium interaction case resembles the fragment <<Sample a channel and distance along the ray>>.

<<Populate mi with medium interaction information and return>>=
PhaseFunction *phase = ARENA_ALLOC(arena, HenyeyGreenstein)(g); *mi = MediumInteraction(rWorld(t), -rWorld.d, rWorld.time, this, phase); return sigma_s / sigma_t;

Finally, we must also provide an implementation of the Tr() method to compute the transmittance along a ray segment. Consider the pseudocode of the following simplistic implementation that performs a call to Sample() and returns 1.0 if the ray passed through the segment and 0.0 when a medium interaction occurred along the way. This effectively turns the transmittance function into a binary random variable.

Float Tr(ray, sampler) { if (Sample(ray, sampler, ...) fails) return 1.0; else return 0.0; }

Since the probability of passing through the medium is equal to the transmittance, this random variable has the correct mean and could be used in the context of unbiased Monte Carlo integration. Calling Tr() many times and averaging the result would produce an increasingly accurate estimate of the transmittance, though this will generally be too costly to do in practice. On the other hand, using the naive binary implementation leads to a high amount of variance.

Novák et al. (2014) observed that this binary-valued function can be interpreted as an instance of Russian roulette. However, instead of randomly terminating the algorithm with a value of zero in each iteration, we could also remove the Russian roulette logic and simply multiply the transmittance by the probability of continuation. The resulting estimator has the same mean with a considerably lower variance. We will use this approach in the implementation of GridDensityMedium::Tr().

<<GridDensityMedium Method Definitions>>+=
Spectrum GridDensityMedium::Tr(const Ray &rWorld, Sampler &sampler) const { Ray ray = WorldToMedium(Ray(rWorld.o, Normalize(rWorld.d), rWorld.tMax * rWorld.d.Length())); <<Compute interval of ray’s overlap with medium bounds>>
const Bounds3f b(Point3f(0, 0, 0), Point3f(1, 1, 1)); Float tMin, tMax; if (!b.IntersectP(ray, &tMin, &tMax)) return Spectrum(1.f);
<<Perform ratio tracking to estimate the transmittance value>>
Float Tr = 1, t = tMin; while (true) { t -= std::log(1 - sampler.Get1D()) * invMaxDensity / sigma_t; if (t >= tMax) break; Float density = Density(ray(t)); Tr *= 1 - std::max((Float)0, density * invMaxDensity); } return Spectrum(Tr);
}

The beginning of the Tr() method matches Sample(). The loop body is also identical except for the last line, which multiplies a running product by the ratio of real particles to hypothetical particles. (Novák referred to this scheme as ratio tracking).

<<Perform ratio tracking to estimate the transmittance value>>=
Float Tr = 1, t = tMin; while (true) { t -= std::log(1 - sampler.Get1D()) * invMaxDensity / sigma_t; if (t >= tMax) break; Float density = Density(ray(t)); Tr *= 1 - std::max((Float)0, density * invMaxDensity); } return Spectrum(Tr);

15.2.3 Sampling Phase Functions

It is also useful to be able to draw samples from the distribution described by phase functions—applications include applying multiple importance sampling to computing direct lighting in participating media as well as for sampling scattered directions for indirect lighting samples in participating media. For these applications, PhaseFunction implementations must implement the Sample_p() method, which samples an incident direction given the outgoing direction and a sample value in .

Note that, unlike the BxDF sampling methods, Sample_p() doesn’t return both the phase function’s value and its PDF. Rather, pbrt assumes that phase functions are sampled with PDFs that perfectly match their distributions. In conjunction with the requirement that phase functions themselves be normalized (Equation (11.4)), a single return value encodes both values. When the value of the PDF alone is needed, a call to PhaseFunction::p() suffices.

<<PhaseFunction Interface>>+=
virtual Float Sample_p(const Vector3f &wo, Vector3f *wi, const Point2f &u) const = 0;

The PDF for the Henyey–Greenstein phase function is separable into and components, with as usual. The main task is to sample .

<<HenyeyGreenstein Method Definitions>>+=
Float HenyeyGreenstein::Sample_p(const Vector3f &wo, Vector3f *wi, const Point2f &u) const { <<Compute for Henyey–Greenstein sample>>
Float cosTheta; if (std::abs(g) < 1e-3) cosTheta = 1 - 2 * u[0]; else { Float sqrTerm = (1 - g * g) / (1 - g + 2 * g * u[0]); cosTheta = (1 + g * g - sqrTerm * sqrTerm) / (2 * g); }
<<Compute direction wi for Henyey–Greenstein sample>>
Float sinTheta = std::sqrt(std::max((Float)0, 1 - cosTheta * cosTheta)); Float phi = 2 * Pi * u[1]; Vector3f v1, v2; CoordinateSystem(wo, &v1, &v2); *wi = SphericalDirection(sinTheta, cosTheta, phi, v1, v2, wo);
return PhaseHG(cosTheta, g); }

For Henyey–Greenstein with pbrt’s convention for the orientation of direction vectors, the distribution for is

if ; otherwise, gives a uniform sampling over the sphere of directions.

<<Compute for Henyey–Greenstein sample>>=
Float cosTheta; if (std::abs(g) < 1e-3) cosTheta = 1 - 2 * u[0]; else { Float sqrTerm = (1 - g * g) / (1 - g + 2 * g * u[0]); cosTheta = (1 + g * g - sqrTerm * sqrTerm) / (2 * g); }

Given , what should now be a familiar approach converts them to the direction .

<<Compute direction wi for Henyey–Greenstein sample>>=
Float sinTheta = std::sqrt(std::max((Float)0, 1 - cosTheta * cosTheta)); Float phi = 2 * Pi * u[1]; Vector3f v1, v2; CoordinateSystem(wo, &v1, &v2); *wi = SphericalDirection(sinTheta, cosTheta, phi, v1, v2, wo);