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

Why the light-selection probability belongs in the PDF

With \(L\) lights, UniformLightSampler selects light \(\ell\) with probability \(1/L\), then samples a point or direction conditionally with density \(p(z\mid\ell)\). The joint proposal is

\[p_N(z,\ell)=\frac1L p(z\mid\ell).\]

Forgetting \(1/L\) makes the estimate darker as more lights are added.

The sampled domain is the pair \((\ell,z)\), not only the point \(z\). Different lights may contain geometrically identical positions or directions, but their emission and selection probabilities remain associated with the light identity. Treating the light index as part of the random variable avoids an ambiguous mixture PDF.

Proof The light-selection probability is part of the joint proposal density

Assumptions: A light is selected uniformly and its conditional sampler covers the contribution on that light.

The NEE estimator is \(F(Z,\ell)/p_N(Z,\ell)\). Its expectation is

\[\sum_{\ell=1}^{L}\int \frac1L p(z\mid\ell) \frac{F(z,\ell)}{(1/L)p(z\mid\ell)}\,dz =\sum_{\ell=1}^{L}\int F(z,\ell)\,dz.\]

The probability of choosing the light cancels exactly. If the denominator omits it, the result becomes \(1/L\) of the desired sum.

Combining light and BSDF sampling

The MISDirectLightIntegrator evaluates light and BSDF proposals with the power heuristic

\[w_t(z)= \frac{(N_t p_t(z))^\beta} {\sum_k(N_kp_k(z))^\beta}, \qquad \beta=2.\]

As in the previous chapter, \(\sum_t w_t(z)=1\), so the estimator remains unbiased. Squaring emphasizes the technique whose density is larger at the sample and often reduces variance. The exponent changes variance, not the partition-of-unity proof.

CUDA VolPT provides useful historical context in its path-tracing discussion, including the benefit and GPU control-flow cost of next-event estimation, and in its bidirectional path-tracing discussion, where multiple sampling strategies are combined with MIS. HdRestir applies that combination locally to light and BSDF proposals at one surface.

Area, solid-angle, and discrete-delta PDFs cannot be mixed blindly. HdRestir’s Pdf type records the measure and performs the rectangle-light Jacobian conversion before MIS. Delta lights use probability mass and are exclusive from continuous competitors.

For a point or directional delta light, the BSDF technique has zero probability of generating exactly the same event under a continuous solid-angle distribution. Applying a continuous two-technique heuristic would compare incompatible measures and suppress a contribution that has no actual competitor. The implementation therefore assigns the NEE delta contribution a weight of one.

For finite area lights, both NEE and BSDF sampling can generate the same direction. Their PDFs must include all conditional choices and be expressed in solid angle at the shading point before the power heuristic is evaluated.

The following model applies the same formula to one non-delta direction. Moving either density changes the relative MIS weight; changing the exponent changes how strongly the larger density is preferred. The two displayed weights always sum to one because the numerator terms are normalized by their sum.

Interactive model: MIS power-heuristic weights Both PDFs refer to the same non-delta direction and use the same solid-angle measure; each technique contributes one sample.
Light weight BSDF weight Sum 1.000000

Increasing β moves more weight toward the technique with the larger PDF. The two weights still form a partition of unity. Delta events require the separate treatment described below.

Read the refactor from the seam inward

Commit contents Files discussed in this chapter direct_light_integrator_interface.h → uniform_light_sampler.cpp → path_integrator.cpp → mis_direct_light_integrator.cpp
Complete commit diff ↗

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 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.

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.

Record this: At commit e6fb9bb, render mis_area_lights_scene.usda with light-only sampling, BSDF-only sampling if you expose it, and the MIS integrator. Use the same seed, sample count, camera, and exposure; label all three states.
Caption to keep: MIS follows the light proposal where it is effective and the BSDF proposal where that is effective, while its weights sum to one.

Code map

The interface now accepts a different direct-light strategy. The next commit uses it to insert RIS.