This part of the first commit begins with the hit record produced by the BVH. It resolves a material closure, evaluates emitted and direct light, samples a new BSDF event, and multiplies the four-component spectral throughput carried to the next bounce.

Commit contents Files discussed in this chapter ggx.cpp · material.h · preview_surface.cpp · path_trace_pass.cpp · direct_lighting.cpp
Complete commit diff ↗

Material closure and BSDF object

The renderer first resolves the material ID stored in the hit. Missing materials use a defined fallback rather than leaving a null pointer in the bounce loop:

HitRecord hit{*nextHit};
const IMaterial* material{scene.GetMaterial(hit.MatId)};
if (material == nullptr) {
    material = &DefaultMaterial::Instance();
}

BSDFClosure c{material->GetClosure(hit)};
if (!settings.EnableSubsurface) {
    c.Subsurface = 0.0f;
}

This is a literal excerpt from path_trace_pass.cpp. The closure contains resolved parameters; the IBSDF created from it provides evaluation, density, and sampling behavior. Keeping the hit and closure separate allows texture evaluation and Hydra material translation to finish before the transport loop requests a scattering operation.

The earlier volumetric path-tracer description uses a phase function for a medium interaction and GGX for a surface interaction. This chapter examines the surface branch in the HdRestir source instead of repeating the complete path algorithm.

GGX choices in this implementation

PBRT’s microfacet chapter linked above supplies the GGX/Trowbridge–Reitz derivation, normalization conditions, and masking-shadowing background. The discussion here is limited to how the first HdRestir revision specializes and regularizes that model.

The specular component uses a GGX microfacet BRDF:

\[f_{\mathrm{spec}}(\omega_i,\omega_o)= \frac{D(h)\,F(\omega_i,h)\,G(\omega_i,\omega_o)} {4\lvert n\cdot\omega_i\rvert\lvert n\cdot\omega_o\rvert}, \qquad h=\frac{\omega_i+\omega_o}{\lVert\omega_i+\omega_o\rVert}.\]

The implementation evaluates the distribution and a separable masking term:

const float alpha {std::max(0.001f, _c.Roughness * _c.Roughness)};
const float alpha2{alpha * alpha};
const float denom {nDotH * nDotH * (alpha2 - 1.0f) + 1.0f};
const float Dval  {alpha2 / (kPi * denom * denom)};
const float k_g   {alpha / 2.0f};
const float G_l   {nDotL / (nDotL * (1.0f - k_g) + k_g)};
const float G_v   {nDotV / (nDotV * (1.0f - k_g) + k_g)};
const float Gval  {G_l * G_v};

For \(c=n\cdot h\), the coded distribution is

\[D_{\mathrm{GGX}}(h)= \frac{\alpha^2} {\pi\left(c^2(\alpha^2-1)+1\right)^2}.\]

The max(0.001f, ...) operations are denominator guards and roughness floors. They modify the exact BRDF near grazing angles and at zero roughness. The code therefore implements a regularized approximation rather than an ideal delta mirror at roughness zero.

The Fresnel term uses Schlick’s fifth-power approximation:

const GfVec3f F0{
    _c.SpecularColor * (1.0f - _c.Metallic) * 0.04f
    + _c.BaseColor * _c.Metallic};
const GfVec3f Fval{
    F0 + (GfVec3f{1.0f, 1.0f, 1.0f} - F0)
        * std::pow(1.0f - lDotH, 5.0f)};
const GfVec3f specBsdf{
    (Fval * Dval * Gval) / (4.0f * nDotL * nDotV)};

F0 interpolates between a dielectric baseline and base-color-controlled metal reflectance. This is the renderer’s material model; it is not a recovery of measured complex index-of-refraction data. The derivation of the microfacet denominator and the reflection/refraction Jacobians is available in Walter et al..

Diffuse and specular mixture

The initial model adds a Lambertian diffuse term:

const GfVec3f finalDiffuse{
    _c.BaseColor * (1.0f - _c.Subsurface)
    + _c.SubsurfaceColor * _c.Subsurface};
const GfVec3f diffBsdf{
    finalDiffuse
    * (1.0f - _c.Metallic)
    * (1.0f - _c.Transmission)
    / kPi};

return diffBsdf + specBsdf;

Division by \(\pi\) normalizes a Lambertian lobe because \(\int_{\Omega^+}\cos\theta\,d\omega=\pi\). The metallic and transmission factors remove diffuse response for those limiting material modes. The sum is an implementation model; this block alone does not prove energy conservation for every combination of parameters because the diffuse term is not explicitly reduced by the angle-dependent Fresnel value.

One bounce of spectral transport

The first commit samples one wavelength packet per camera path. Emission, direct lighting, absorption, and bounce throughput are evaluated at those same four wavelengths:

totalRadiance += throughput * RGBToSpectrum(c.Emission, lambda);
const std::unique_ptr<IBSDF> bsdfOwner{
    material->CreateBSDF(BSDFClosure{c})};
const ShadingPoint surface{
    hit,
    *bsdfOwner,
    c,
    shadingNormal,
    currentRay.Dir,
    lambda,
    isInside};
totalRadiance += throughput
    * SampleDirectLighting(surface, scene.GetLights(), scene, rng);

const BounceConfig config{
    settings.MaxReflectionBounces,
    settings.MaxRefractionBounces};
const BounceSample bs{
    material->SampleBounce(surface, config, bounceState, rng)};

The line adding direct lighting estimates paths that connect the current surface to a light. SampleBounce generates the continuation event. When that event is valid, the loop applies:

throughput *= bs.ThroughputMul;
currentRay = bs.NextRay;
nextHit = scene.IntersectScene(currentRay.Origin, currentRay.Dir);

At this checkpoint ThroughputMul is not computed by one generic f * cos / pdf expression. SampleBounce first chooses a material branch, then samples that branch:

float localIor = c.Ior;
float C = 10000.0f;
float B = localIor - C / (589.3f * 589.3f);
localIor = B + C / (lambda.lambda[0] * lambda.lambda[0]);

float fresnel = FresnelDielectric(
    GfDot(currentRayDir, shadingNormal), localIor);
float reflectProb = fresnel * c.Specular;
if (c.Metallic > 0.0f)
    reflectProb = std::max(reflectProb, c.Metallic);

if (rng.NextFloat() < reflectProb) {
    // Sample a perfect or rough reflection direction.
    GfVec3f reflTint =
        c.SpecularColor * (1.0f - c.Metallic)
        + c.BaseColor * c.Metallic;
    return {
        .NextRay = {hitPos + shadingNormal * 1e-4f, reflectDir},
        .ThroughputMul = RGBToSpectrum(reflTint, lambda),
    };
}

// ...

GfVec3f diffuseDir = AlignToNormal(
    SampleCosineHemisphere(rng.NextFloat(), rng.NextFloat()),
    shadingNormal);
float nDotL = std::max(0.0f, GfDot(shadingNormal, diffuseDir));
float pdf   = nDotL / float(M_PI);
GfVec3f finalDiffuse =
    c.BaseColor * (1.0f - c.Subsurface)
    + c.SubsurfaceColor * c.Subsurface;
return {
    .NextRay = {hitPos + shadingNormal * 1e-4f, diffuseDir},
    .ThroughputMul = RGBToSpectrum(finalDiffuse, lambda),
};

For the diffuse branch, cosine sampling has conditional density \(p_d(\omega)=\cos\theta/\pi\), while the Lambertian factor is \(f_d=\rho/\pi\). Therefore

\[\frac{f_d(\omega)\cos\theta}{p_d(\omega)} =\frac{(\rho/\pi)\cos\theta}{\cos\theta/\pi} =\rho,\]

which explains why the returned multiplier is the diffuse color and why the computed pdf cancels except for its grazing-angle guard. The outer branch probability samples the material mixture. This cancellation is valid only to the extent that the branch probabilities and returned lobe factors describe the same mixture.

The rough reflection and refraction branches are more approximate: they sample a GGX-oriented half-vector but return a tint rather than explicitly evaluating the full regularized GGX function and its directional PDF. The first commit should therefore be read as its own historical material sampler, not as a general proof that every branch implements \(f_s\cos\theta/p\).

The local IOR is also wavelength dependent, but the direction uses only lambda[0], the hero wavelength. The other three packet wavelengths follow the same geometric path and contribute through spectral evaluation. This is the hero-wavelength correlation described in step 0.a; it is not four independently refracted rays.

Limits visible in this commit

Direct-light estimation is a free function called by the path pass. Its light sampler and estimator cannot be replaced independently. The GGX evaluation also regularizes several dot products and adds diffuse and specular terms without a general energy-compensation construction. These are properties of the historical implementation, not conclusions implied by the general rendering equation.

The next commit separates path continuation, direct-light integration, and light selection. The current first-commit discussion stops before introducing those later interfaces.

Two rows of rendered spheres showing roughness and metallic parameter sweeps
Top row, left to right: roughness \(0.05, 0.20, 0.40, 0.65, 0.90\) with metallic=0. Bottom row: metallic \(0, 0.25, 0.50, 0.75, 1\) with roughness=0.20. Geometry, illumination, camera, seed, and exposure are shared. Captured at 128 spp with revision 4648adc, Path Tracer, seed 0, resolutionLevel=1, and all denoising filters disabled. This later revision is used only to produce a reproducible visualization of the GGX parameters already discussed at e6fe6bb; it is not presented as output from the first commit. Open the USD scene.

The upper row isolates the widening of the GGX lobe. The highlight is compact at low roughness and spreads over a larger solid angle as roughness increases. The lower row keeps roughness fixed while moving from a dielectric response, whose specular reflectance is governed by the interface Fresnel term, toward a metal response whose reflected color comes from the base color. The albedo and normal AOV captures remain useful diagnostics, but they are not reconstructed from this color image and are therefore not shown as if they had been recorded.

The camera move makes the change in highlight width and reflected color easier to follow than in one fixed view. The top row changes roughness; the bottom row changes metallicity. Working tree based on revision dbca10b, GPU ReSTIR, 16 spp per frame, seed 0, firefly filtering and OIDN enabled. This later capture visualizes the material parameters discussed from the first commit. It is not an RGB-versus-spectral comparison and contains no transmissive dispersion test.