Direct-light integrators and multiple importance sampling
e6fb9bb · 2026-05-26 ↗
This commit separates two operations that were previously implemented together:
- Path integration: how does a ray continue through multiple bounces?
- Direct-light integration: which light connection should be evaluated at the current surface?
PathIntegrator retains multi-bounce transport. A new
IDirectLightIntegrator interface receives a shaded point and evaluates the
direct-light term. ILightSampler separately owns the proposal used to select
a light.
classDiagram
class PathIntegrator {
+Li(ray, scene, rng, wavelengths)
}
class IDirectLightIntegrator {
<<interface>>
+Li(surface, scene, rng)
}
class MISDirectLightIntegrator
class ILightSampler {
<<interface>>
+Sample(scene, rng)
}
class UniformLightSampler
PathIntegrator --> IDirectLightIntegrator
IDirectLightIntegrator <|-- MISDirectLightIntegrator
MISDirectLightIntegrator --> ILightSampler
ILightSampler <|-- UniformLightSampler
The HdRestir-specific invariant is that the discrete light PMF and the
conditional directional density travel together as one proposal density; the
typed PdfSpace conversion prevents area and solid-angle densities from being
combined accidentally.
Read the refactor from the seam inward
The new call boundary is the semantic part of this refactor. The interface is read first, followed by the default MIS implementation that satisfies it.
Direct-light integrator interface
class IDirectLightIntegrator : public IIntegrator {
public:
~IDirectLightIntegrator() override = default;
[[nodiscard]] virtual SampledSpectrum Li(
const ShadingPoint& surface,
const IScene& scene,
Rng& rng,
const std::optional<BsdfBounceConnection>& bsdfConnection) const = 0;
};
The input is already a shaded surface, not a raw ray. The optional
BsdfBounceConnection carries the light or environment reached by the BSDF
sample. That gives the direct-light implementation both competing techniques:
an explicit light proposal and the already-generated BSDF proposal.
The interface returns only radiance. It does not decide how the path continues;
that responsibility remains in PathIntegrator.
This ownership rule prevents the direct-light strategy from advancing path depth, changing persistent path throughput, applying Russian roulette, selecting the continuation ray, or deciding when the full path terminates. It may trace shadow rays and evaluate the connection already produced by BSDF sampling, but those operations estimate direct radiance at the current vertex.
Light-selection probability
if (_lights.empty()) {
return std::nullopt;
}
const std::size_t lightIndex{
std::min(static_cast<std::size_t>(
rng.NextFloat() * _lights.size()), _lights.size() - 1u)};
const ILight& light{*_lights[lightIndex]};
const auto lightSample{light.SampleLight(hitPos, rng)};
// ...
const float lightSelectPdf{1.0f / static_cast<float>(_lights.size())};
// ...
LightSample ls = *lightSample;
ls.Pdf.value *= lightSelectPdf;
The empty-list guard is required before evaluating _lights.size() - 1u or
indexing the vector. For a non-empty list there are two random choices: which
light, then where on that light. Their joint density is the product.
Multiplying the conditional LightSample PDF here means every downstream
estimator receives the complete density and cannot accidentally forget the
\(1/L\) factor proved above.
Strategy construction in the path integrator
auto directLightIntegrator{_directLightFactory(scene)};
// ...
const BounceWithConnectionResult firstBounceResult{
Detail::SampleBounceWithConnection(
firstMaterial, firstSurface, config, bounceState, scene, rng)};
const std::optional<BsdfBounceConnection> firstConnection{
std::holds_alternative<BsdfBounceConnection>(firstBounceResult)
? std::make_optional(
std::get<BsdfBounceConnection>(firstBounceResult))
: std::nullopt};
totalRadiance += directLightIntegrator->Li(
firstSurface, scene, rng, firstConnection);
The factory creates one direct-light strategy for the current scene. The path integrator still owns bounce sampling and throughput; it hands the connection to the strategy only when computing direct radiance. RIS can therefore replace MIS later without duplicating the bounce loop.
The strategy is created from the current scene because a light sampler may cache the set of lights or build a scene-dependent distribution. The factory also becomes the place where a later ReSTIR implementation can stage persistent buffers without adding reservoir requirements to the ordinary MIS integrator.
MIS application to the direct-light contribution
const MISContrib nee{
_evaluateNEE(surface, *candidate->Light, candidate->Ls, scene)};
if (nee.PNee > 0.0f) {
const float misWeight{(nee.IsDelta || !useBsdfTechnique)
? 1.0f
: PowerHeuristic(nee.PNee, nee.PBsdf)};
totalRadiance += nee.Radiance * misWeight;
}
_evaluateNEE has already converted the light PDF to solid angle, traced the
shadow ray, and divided the contribution by PNee. This block supplies only
the MIS partition weight. Delta lights receive weight one because the
continuous BSDF technique cannot sample the same discrete event.
The important work hidden by that summary is visible in the function itself:
const float nDotL{GfDot(shadingNormal, lightSample.Dir)};
if (nDotL <= 0.0f)
return {};
const float dist2{lightSample.Dist * lightSample.Dist};
const float cosY{
std::max(0.0f,
GfDot(-lightSample.Dir, lightSample.LightNormal))};
if (cosY <= 0.0f && !light.IsDeltaLight())
return {};
const float pNee{
lightSample.Pdf
.ConvertTo(
PdfSpace::SolidAngle, dist2, std::max(cosY, 1e-6f))
.value};
if (pNee <= 0.0f)
return {};
const GfVec3f shadowOrigin{
hitPos + shadingNormal * 1e-4f};
const auto shadowHit{
scene.IntersectScene(shadowOrigin, lightSample.Dir)};
if (shadowHit && shadowHit->Depth < lightSample.Dist - 1e-3f)
return {};
const GfVec3f wo{-surface.rayDir};
const GfVec3f bsdfValue{
surface.bsdf.Eval(shadingNormal, wo, lightSample.Dir)};
const float bsdfPdf{
surface.bsdf.Pdf(shadingNormal, wo, lightSample.Dir)};
const SampledSpectrum radiance{
RGBToSpectrum(bsdfValue, surface.lambda)
* RGBToSpectrum(lightSample.Color, surface.lambda)
* (nDotL / pNee)};
For an area sample \(y\) seen from \(x\), the conversion used above is
\[p_\omega(\omega) =p_A(y)\frac{\lVert y-x\rVert^2} {|\mathbf n_y\cdot(-\omega)|}.\]Substituting it into the NEE estimator gives
\[\widehat L_{\mathrm{NEE}} = \frac{ L_e(y\!\to\!x)\, f_s(x,\omega_o,\omega)\, |\mathbf n_x\!\cdot\!\omega| }{ p_\omega(\omega) }.\]The shadow-ray branch multiplies this by visibility \(V(x,y)\): returning zero
when an occluder lies closer than the sampled light endpoint is the code form
of \(V=0\). 1e-4 and 1e-3 are geometric tolerances for ray origin and
endpoint comparison. They prevent common self-intersection errors but also
make the visibility test scale-dependent; very small scenes require a
corresponding tolerance review.
The BSDF-sampled branch performs the complementary computation. When a sampled bounce reaches an emissive surface or environment, it evaluates the NEE proposal density for the same connection and applies the BSDF-side MIS weight. Implementing only the light-sampled side would omit valid paths and would not form the two-technique estimator derived above.
For a finite emitter, the complementary side recovers the same solid-angle light density and weights the already-normalized BSDF sample:
const Pdf areaPdf{
hitLight->EvalPdf(
surface.hit.Position,
connection.Bounce.NextRay.Dir,
std::sqrt(dist2),
connection.Hit->Normal)};
pNee = areaPdf.ConvertTo(
PdfSpace::SolidAngle, dist2, cosY).value;
const float misWeight{
PowerHeuristic(
connection.Bounce.BsdfPdf.value, pNee)};
return radiance
* misWeight
* connection.Bounce.ThroughputMul;
ThroughputMul already contains
\(f_s|\mathbf n\cdot\omega|/p_{\mathrm{BSDF}}\) for this refactored
connection. The code must not divide by the BSDF density again. Conversely,
pNee is needed only to compute the complementary partition weight. When no
light technique can generate the event, pNee <= 0 and the BSDF estimator
keeps weight one.
Validation cases for this commit
The refactor should preserve the previous path tracer’s estimator while changing ownership. Small scenes isolate the main conditions:
| Scene or test | Failure it exposes |
|---|---|
| One point light | Incorrect delta-light weighting |
| One rectangle light on a diffuse surface | Area-to-solid-angle conversion |
| Small area light on a glossy surface | Missing BSDF-sampled light connection |
| Two identical lights | Missing \(1/L\) selection probability |
| Environment only | Incorrect environment proposal handling |
| No lights and a black environment | Empty sampler and zero-radiance handling |
The two-identical-light case has a direct expected result: if the second light has the same transform and emission, mean radiance should double. If the selection probability is missing from the proposal density, the result scales incorrectly when the light count changes.
Two diagnostic scenes
mis_area_lights_scene.usda places differently sized area lights where neither
proposal has the lower variance everywhere. many_lights_scene.usda makes the
\(1/L\) selection factor observable. Each scene isolates one estimator
condition while keeping the remaining render configuration fixed.
The final GPU ReSTIR beauty image is not shown here because it cannot validate this CPU refactor or distinguish the two proposal techniques. The useful future artifact is a controlled triptych—light sampling only, BSDF sampling only, and MIS—using the same commit, seed policy, sample count, exposure, and filter settings. Until those restricted modes are exposed, the source-level invariants and the small scene tests above are the relevant evidence.
Code map
direct_light_integrator_interface.hmis_direct_light_integrator.cppuniform_light_sampler.cpp- Full commit diff
The interface now accepts a different direct-light strategy. The next commit uses it to insert RIS.