Better emission classification for MDL materials#343
Merged
jeffamstutz merged 12 commits intoJul 16, 2026
Conversation
… 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
self-requested a review
July 16, 2026 15:31
jeffamstutz
approved these changes
Jul 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.