From 48b20d0857408b7c3898cf40f6b1187497d31a4c Mon Sep 17 00:00:00 2001 From: Thomas Arcila <134677+tarcila@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:31:14 +0000 Subject: [PATCH] feat(rtx): add maxSampledLights to Interactive renderer Interactive summed direct lighting over ALL light instances per hit (one shadow ray each), so many-light scenes were O(N) and slow. Add a maxSampledLights renderer param (uint, default 8, <=0 = all): when the scene has more light instances than the cap, importance-sample that many via the world Pick-Power CDF and reweight by 1/(K*pPick) instead of looping all of them. E[stochastic] == the all-lights sum, so the accumulated image converges; small scenes keep the exact deterministic loop. HDRI MIS is unchanged (escape side stays deterministic, so pLight=envPdf with the reweight is MIS-consistent). Uniform-pick fallback when total Pick Power is zero. --- devices/rtx/device/gpu/gpu_objects.h | 1 + devices/rtx/device/renderer/Interactive.cpp | 4 ++ devices/rtx/device/renderer/Interactive.h | 1 + .../rtx/device/renderer/Interactive_ptx.cu | 53 ++++++++++++++++--- .../device/visrtx_renderer_interactive.json | 10 ++++ 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/devices/rtx/device/gpu/gpu_objects.h b/devices/rtx/device/gpu/gpu_objects.h index e90ec992e..441ec5a69 100644 --- a/devices/rtx/device/gpu/gpu_objects.h +++ b/devices/rtx/device/gpu/gpu_objects.h @@ -864,6 +864,7 @@ struct InteractiveRendererGPUData vec3 aoColor; float aoIntensity; float inverseVolumeSamplingRateShadows; + int maxSampledLights; // NEE shadow-ray budget per hit; <= 0 samples all lights }; union RendererParametersGPUData diff --git a/devices/rtx/device/renderer/Interactive.cpp b/devices/rtx/device/renderer/Interactive.cpp index 7f9a47ef5..ecd3d9be2 100644 --- a/devices/rtx/device/renderer/Interactive.cpp +++ b/devices/rtx/device/renderer/Interactive.cpp @@ -53,6 +53,9 @@ void Interactive::commitParameters() m_aoSamples = std::clamp(getParam("ambientSamples", 1), 0, 256); m_volumeSamplingRateShadows = std::clamp( getParam("volumeSamplingRateShadows", 0.0125f), 1e-4f, 10.f); + // <= 0 is the "sample all lights" sentinel; clamp negatives to 0 so the kernel + // sees a single unambiguous unlimited value. + m_maxSampledLights = std::max(getParam("maxSampledLights", 8), 0); } void Interactive::populateFrameData(FrameGPUData &fd) const @@ -65,6 +68,7 @@ void Interactive::populateFrameData(FrameGPUData &fd) const interactive.aoIntensity = m_aoIntensity; interactive.inverseVolumeSamplingRateShadows = 1.f / m_volumeSamplingRateShadows; + interactive.maxSampledLights = m_maxSampledLights; } OptixModule Interactive::optixModule() const diff --git a/devices/rtx/device/renderer/Interactive.h b/devices/rtx/device/renderer/Interactive.h index 5f2f5162a..1346d6dd8 100644 --- a/devices/rtx/device/renderer/Interactive.h +++ b/devices/rtx/device/renderer/Interactive.h @@ -52,6 +52,7 @@ struct Interactive : public Renderer vec3 m_aoColor{1.f}; float m_aoIntensity{1.f}; float m_volumeSamplingRateShadows{0.0125f}; + int m_maxSampledLights{8}; }; } // namespace visrtx diff --git a/devices/rtx/device/renderer/Interactive_ptx.cu b/devices/rtx/device/renderer/Interactive_ptx.cu index 208fbcfbd..c98da28a2 100644 --- a/devices/rtx/device/renderer/Interactive_ptx.cu +++ b/devices/rtx/device/renderer/Interactive_ptx.cu @@ -100,7 +100,11 @@ struct InteractiveShadingPolicy * rendererParams.ambientIntensity * materialEvaluateTint(shadingState); const vec3 shadowOrigin = shadingHitpoint(hit) + hit.Ng * hit.epsilon; - for (size_t i = 0; i < world.numLightInstances; i++) { + + // One light instance's NEE contribution, scaled by `weight`. `weight` is 1 + // when every light is sampled and 1/(K*pPick) when a stochastic subset is + // drawn, so the accumulated image converges to the full deterministic sum. + auto addLightContribution = [&](size_t i, float weight) { const auto &light = world.lightInstances[i]; const auto lightSample = sampleLight(ss, shadowOrigin, @@ -109,7 +113,7 @@ struct InteractiveShadingPolicy light.surfaceInstanceIndex); if (lightSample.pdf == 0.0f) - continue; + return; const LightType lightType = frameData.registry.lights[light.lightIndex].type; @@ -134,7 +138,7 @@ struct InteractiveShadingPolicy if (glm::all( glm::lessThanEqual(attenuation, vec3(MIN_CONTRIBUTION_EPSILON)))) - continue; + return; vec3 thisLightContrib = materialShadeSurface(shadingState, hit, lightSample, -ray.dir); @@ -142,8 +146,9 @@ struct InteractiveShadingPolicy // Environment MIS (balance heuristic): the HDRI is the only light the // indirect bounce's escape can also reach, so combine the NEE and escape // estimators instead of summing them (which double-counted the env). - // Interactive loops all lights with no pick, so pLight = envPdf (NO - // 1/numLights). Non-env lights keep wNee = 1 (behaviour unchanged). + // pLight = envPdf independent of the pick, and the 1/(K*pPick) reweight + // keeps E[stochastic] == the all-lights sum, so this stays MIS-consistent + // whether we sample all lights or a subset. Non-env lights keep wNee = 1. if (lightType == LightType::HDRI) { const float pLight = envPdf(frameData, lightSample.dir); const float pBsdf = @@ -151,7 +156,43 @@ struct InteractiveShadingPolicy thisLightContrib *= pLight / (pLight + pBsdf); } - contrib += thisLightContrib * attenuation; + contrib += weight * thisLightContrib * attenuation; + }; + + // `maxSampledLights` caps the shadow-ray budget per hit. When the scene has + // more light instances than the cap, importance-sample that many via the + // world's Pick-Power CDF instead of looping all of them; otherwise keep the + // exact all-lights sum (pixel-identical to an unlimited budget). + const size_t numLights = world.numLightInstances; + const int maxSampled = interactiveParams.maxSampledLights; + const bool sampleAllLights = + maxSampled <= 0 || numLights <= size_t(maxSampled); + + if (sampleAllLights) { + for (size_t i = 0; i < numLights; i++) + addLightContribution(i, 1.0f); + } else { + const int numPicks = maxSampled; + // A zero total Pick Power (every light dark) leaves the CDF unnormalized; + // fall back to a uniform pick to avoid a divide-by-zero, matching Quality. + const bool haveCdf = world.totalLightPower > 0.0f; + for (int s = 0; s < numPicks; s++) { + const float u = pcg_uniform(&ss.rs); + size_t idx; + float pPick; + if (haveCdf) { + idx = glm::min(size_t(detail::inverseSampleCDF( + world.lightPickCdf, int(numLights), u)), + numLights - 1); + const float lo = idx > 0 ? world.lightPickCdf[idx - 1] : 0.0f; + pPick = world.lightPickCdf[idx] - lo; + } else { + idx = glm::min(size_t(u * float(numLights)), numLights - 1); + pPick = 1.0f / float(numLights); + } + if (pPick > 0.0f) + addLightContribution(idx, 1.0f / (float(numPicks) * pPick)); + } } // Single indirect bounce — REFLECTION only. Transmission/refraction is diff --git a/devices/rtx/device/visrtx_renderer_interactive.json b/devices/rtx/device/visrtx_renderer_interactive.json index 48af50acf..68fc825dd 100644 --- a/devices/rtx/device/visrtx_renderer_interactive.json +++ b/devices/rtx/device/visrtx_renderer_interactive.json @@ -161,6 +161,16 @@ "minimum": 0, "description": "ambient occlusion distance" }, + { + "name": "maxSampledLights", + "types": [ + "ANARI_INT32" + ], + "tags": [], + "default": 8, + "minimum": 0, + "description": "max light instances sampled per hit via the Pick-Power CDF; 0 samples all lights (slow with many lights)" + }, { "name": "lightFalloff", "types": [