GGX materials and spectral path transport
e6fe6bb · 2026-05-21 ↗
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.
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.
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.
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.