Skip to content

Better emission classification for MDL materials#343

Merged
jeffamstutz merged 12 commits into
NVIDIA:next_releasefrom
tarcila:better-emission-classification
Jul 16, 2026
Merged

Better emission classification for MDL materials#343
jeffamstutz merged 12 commits into
NVIDIA:next_releasefrom
tarcila:better-emission-classification

Conversation

@tarcila

@tarcila tarcila commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Improves emissive-geometry classification for MDL materials.

Replaces the name-prefix classifier with a parser over the material's emission expression df::diffuse_edf, producing a summary the renderer uses to decide whether the emitter is simple enough to turn into a direct light — and to exclude the ones it can't light accurately.

Excluded lights still participate in scene lighting (Quality renderer), but with a possibly much longer convergence as part of the usual ray light propagation.

tarcila added 12 commits July 16, 2026 13:32
… gate fix

Add ADR 0007 (emission descriptor and faithfulSet registration policy).
Correct ADR 0005: the Geometry Light gate is emissionIsSampleable, not
emissionIsConstant (the latter is only the Stage-1 constant-radiance deposit
fast path); the text predated the Stage-2 generalization.
Add rtx_test_MdlEmissionClassifier, a host-only unit test linking libmdl (no
CUDA/OptiX/GPU; the MDL SDK is dlopen'd at runtime). It compiles inline .mdl
snippets through the same Core flow the device uses and asserts on
classifyEmission, characterizing current behavior as a baseline before the
descriptor refactor: constant-literal fold, parameter/texture dynamic recipes,
no-emission, and power-mode rejection. SKIPs (return 77) when the MDL SDK is
not loadable.
buildEmissionIR walks a compiled material's emission sub-DAGs (surface/backface
× {EDF, intensity, mode} plus thin_walled) into an owned, immutable IR that
retains zero MDL-SDK pointers — the device does not keep the compiled material,
so any retained IExpression* would dangle.

Nodes are keyed on MDL semantics (IFunction_definition::get_semantic), never DB
names, so no user module can masquerade as an intrinsic. Let-block temporaries
are resolved and memoized so a CSE-shared subexpression maps to a single node,
giving the later descriptor fold a decidable exact-identity test. Ternary arms
are all walked (dead arms included) for a structural dependency superset.

Device-free unit tests cover the diffuse-EDF root, argument/texture deps, the
null-EDF (invalid-df) case, and shared-temporary identity.
foldEmissionDescriptor runs the three-valued abstract interpretation of ADR
0007 over the emission IR plus an abstract value source, producing a per-slot
descriptor {verdict, edfKinds, magnitude, mode, dependsOnGeometricState, sign}.
The descriptor is SDK-free so the renderer consumes it without the MDL SDK.

Lattice per ADR 0007: zero-absorbing product, sum zero iff all zero (nonzero
only when provably nonnegative), subtraction ProvablyZero only on exact node
identity, ternary folds a constant condition else joins arms (LUB, Unknown top),
cross-channel color zero. EDF taxonomy keys on df:: semantics (diffuse/spot/
measured leaves, tint, directional_factor, mix families); a null (invalid-df)
EDF folds to ProvablyNull.

The two faithfulness flags make the registration gate sufficient, not just
necessary: sign is ProvablyNonnegative only when every reachable value is proven
>= 0 (so signed emission stays forward-only, unbiased), and
dependsOnGeometricState is set when the emission reaches a fabricated state
quantity (normal/tangent/object-id/position) — the IR now captures texture
coord/wrap args as operands so a state-driven coordinate is caught, while
texture_coordinate stays faithful. Magnitude is the non-negative meanPositive
proxy.

Device-free unit vectors cover constant/parameter/textured emission, unbound and
all-black textures, negative emission (sign Unknown), power mode, and geometric-
state dependence via both intensity and texture coordinate.
Image2D folds the emissive Pick Power and the emission-classifier stats into
ONE memoized host scan (computeTextureReduction, guarded on the image data
stamp): per channel, maxAbs (the exact zero proof), meanPositive = mean of
max(texel,0) (the non-negative magnitude proxy), and minValue (the non-negative
sign proof). This replaces the two separate scans (averageValue's mean and a
standalone emissionStats scan) and their two stamps with a single reduction;
averageValue() and emissionStats() both read it. The scan keeps the sRGB->linear
on color channels and the 1/2-channel broadcast. transferPreservesZero is false
only under a nonzero border color, the one case a stored-zero texel can sample
nonzero.

averageValue() now returns meanPositive — the SAME non-negative magnitude proxy
the MDL classifier uses — instead of the plain signed mean, so all three
emissive material types (PBR, PhysicallyBasedMDL, MDL) size Pick Power from one
projection. Native emission is radiance >= 0, so meanPositive == mean for every
real emitter (the parity tests are unchanged); the two only diverge on a
signed-texel emissive texture, where clamping the negative part out of the
picked power is the correct, CDF-representable magnitude. The dead signed-mean
accumulator is dropped from the reduction.

The emission classifier is the sole consumer of ResourceStats and lives behind
USE_MDL, so Sampler::emissionStats(), Image2D::emissionStats(), and the
libmdl/ResourceStats.h include are all gated on USE_MDL — the base sampler no
longer reaches into libmdl in an MDL-off build. The Sampler base default proves
nothing (not zero, not non-negative), so a sampler that cannot reduce its texels
keeps its emission forward-only via the policy's sign gate rather than
registering an unproven light.

The classifier stats are not yet consumed — the descriptor value source wires
them in the next commit. Emissive/MDL API tests (incl. the three-way parity
test) and the classifier unit test stay green.
Replace the raw-mdl compile-time EmissionClassification with the owned emission
IR + descriptor fold. At finalize, MDL folds the registry's cached IR against
this instance's live arguments and bound-sampler reductions into an
EmissionDescriptor, then refreshes the light set. emissionIsSampleable applies
the registration policy (non-null, diffuse, radiant-exitance, non-negative sign,
no geometric-state dependence); emissionAverage returns the non-negative
meanPositive magnitude.

Deletes classifyEmission, walkIntensityFactors, the dynamic-recipe evaluation,
and the EmissionClassification struct — the IR fold subsumes them and handles
mixes, tints, directional factors, and geometric-state detection the string-
prefix walk could not.

Also hardens the fold against two defects: a df::*_mix's EDF leaves nest inside
its df_component[] array, so the fold now deep-scans for them instead of proving
a false ProvablyNull; and a non-finite intensity now fails the sign gate rather
than registering an infinite Pick Power. (The texture-coordinate geometric-state
scan is already present.)

Behavior change: an UNBOUND emissive texture is no longer a Geometry Light — its
MDL lookup is invalid and folds to 0, so it emits nothing (ADR 0007 A5),
superseding ADR-0006's zero-radiance over-inclusion. Bound textured emitters
still register with correct Pick Power (TestEmissiveMdlLight radiance parity).
PhysicallyBasedMDL is unchanged (its post-translate capture already yields the
right sampleability/average). All 60 tests pass.
Move the inline registration gate to material/EmissionPolicy.h as the single
source of truth: kFaithfulSet = {Diffuse} and isRegisterable(SlotDescriptor)
(non-null, kinds ⊆ faithfulSet, radiant-exitance, non-negative sign, no
geometric-state dependence). MDL consumes it instead of a private helper.

Cross-reference the two GPU-side sites that encode the same diffuse-only
assumption — lightPickPower.h (double-sided Lambertian flux) and sampleLight.h
(double-sided normal, geometric normal as shading normal) — so growing the set
has one discoverable set of lockstep edits. No behavior change; all tests pass.
TestEmissionDescriptorPolicy asserts each faithfulSet gate end-to-end through
the ANARI API → compile → fold → policy → light synthesis path, via the world's
numLightInstances property: a constant diffuse emitter registers (1), while a
non-diffuse spot EDF, provably-negative emission, state::normal-dependent
intensity, and power-mode intensity are each described but NOT registered (0) —
they render via the forward path, unbiased. Registered here, not radiance: the
count is the observable seam for exclusion (a forward-only emitter is
pixel-identical to no light).
The Light Pick CDF was a normalized cumulative float, so a light whose power is
below float epsilon of the total quantized to a zero-width interval: the
NEE-sample side (inverseSampleCDF, instancePickProbability delta) could never
select it, yet the hit-side geometryLightHitPdf still reported a positive
analytic pick probability. The deposit's MIS weight then down-weighted the
forward path for a light next-event never delivered — the dim emitter rendered
too dark in a bright+dim scene.

Store and accumulate the CDF in double (masses preserved to ~1e-16 of total) and
difference the pick-probability delta in double, so any physically meaningful
light stays selectable with a delta that matches the hit-side pNee. The float
HDRI/primitive CDFs are untouched: inverseSampleCDF is now templated on the CDF
element type.

Normalize by the double cumulative total, not the float m_totalLightPower:
dividing by the float total can push the last entry above 1.0 when float dropped
a dim light's mass, leaving trailing dim lights unselectable while their pNee
stays positive. The double total makes the last entry exactly 1.0 and preserves
those masses.

No test regression (the effect is below the float-epsilon noise floor to
reproduce automatically); this is precision hardening.
geometryLightHitPdf built its pick probability from the per-hit emission for a
constant emitter and from emissionAverage otherwise. But the Light Pick CDF is
always built from emissionAverage (the geometry light's configured radiance), so
the constant branch only happened to agree — and fed signed, per-point emission
that would disagree once emission is negative or spatially varying, biasing the
deposit's MIS weight.

Always use emissionAverage (the non-negative meanPositive magnitude the CDF was
built from) so the hit-side pNee equals the selection pick probability exactly.
No change for constant emitters (emission == emissionAverage); closes the
signed/textured edge. Drops the now-unused emission argument.

Also apply the same (raw > 0 && finite) clamp World::appendLight uses when it
builds the CDF: a light whose recomputed Pick Power is non-finite or non-positive
contributes 0 to the host CDF, so its hit-side pNee must be 0 too — otherwise
wEmission goes NaN/0 and disagrees with selection.
…ounce

Interactive reflection bounce shaded the hit surface with ambient only, never
its emission, so an emitter reached only by a reflection was invisible there and
lit nothing via the forward path — the ADR 0007 render-mode-matrix gap for
Interactive.

Deposit the bounce surface emission, guarded to UNREGISTERED emitters: a
registered (sampleable) one is already covered by the NEE loop, so depositing it
here too would double-count, while an unregistered one has no NEE and needs this
forward estimate. The two sets are disjoint, so no MIS and no double-count — the
parity/Interactive tests stay green.

The other two matrix rows are by-design, not bugs: the matte material is
diffuse-only and non-emissive per the ANARI spec (its dead continuation ray
means no indirect), and Quality finite-depth truncation is inherent (the
pick-power accuracy it depended on is fixed by the CDF/pNee commits). So NVIDIA#3
reduces to this Interactive deposit.
computeTextureReduction() now reduces over the resident device texels
(Array::data(DEVICE), a real H2D upload) with a single thrust::transform_reduce
instead of the host scan, removing the host readback for large emissive
textures. The device functor reproduces the per-texel decode exactly — uint8/255
and sRGB->linear on color channels only — so the classifier stats (maxAbs /
meanPositive / minValue), and the meanPositive magnitude the emissive Pick Power
reads, are bit-for-bit the same policy as the host scan it replaces. If the
image has no device residency the reduction stays Unknown (magnitude unit,
emission forward-only), never a crash.

TextureStats.{h,cu} hold the SDK-free reduction; ungated because averageValue()
feeds the non-MDL PBR emissive path too.
@jeffamstutz
jeffamstutz self-requested a review July 16, 2026 15:31
@jeffamstutz
jeffamstutz merged commit 693e4f6 into NVIDIA:next_release Jul 16, 2026
8 checks passed
@tarcila
tarcila deleted the better-emission-classification branch July 16, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants