diff --git a/devices/rtx/AGENTS.md b/devices/rtx/AGENTS.md index bda2a62da..e597deaa1 100644 --- a/devices/rtx/AGENTS.md +++ b/devices/rtx/AGENTS.md @@ -79,7 +79,7 @@ Each renderer has its own OptiX pipeline. Material and spatial-field shaders are SBT callable slots per material type (one per `SurfaceShaderEntryPoints` value; `device/gpu/sbt.h` is authoritative): -- `Initialize`, `EvaluateNextRay`, `EvaluateTint`, `EvaluateOpacity`, `EvaluateEmission`, `EvaluateTransmission`, `EvaluateNormal`, `Shade`, `EvaluatePdf` +- `Initialize`, `EvaluateNextRay`, `EvaluateTint`, `EvaluateOpacity`, `EvaluateEmission`, `EvaluateTransmission`, `EvaluateNormal`, `EvalBsdf`, `EvaluatePdf` `MaterialGPUData::callableBaseIndex` holds the offset into the callable table. Kernels dispatch shading via `optixDirectCall(callableBaseIndex + SHADE_FN, ...)`. diff --git a/devices/rtx/apps/tests/api/TestPowerLightPickEnv.cpp b/devices/rtx/apps/tests/api/TestPowerLightPickEnv.cpp index 2b714214f..ed22e10b3 100644 --- a/devices/rtx/apps/tests/api/TestPowerLightPickEnv.cpp +++ b/devices/rtx/apps/tests/api/TestPowerLightPickEnv.cpp @@ -29,23 +29,26 @@ * POSSIBILITY OF SUCH DAMAGE. */ -// Power-proportional Light Pick must keep the environment (HDRI) MIS fold -// unbiased. The HDRI is the one light both NEE and the BSDF escape reach, so -// power picking multiplies its pick probability into the env light density on -// BOTH MIS sides. Light transport is linear, so a scene lit by an HDRI plus a -// directional light must equal the sum of the two single-light renders; a broken -// env pick-probability fold (missing on the miss side, or double-applied) breaks -// that. Rendered with 'quality' into a linear float buffer, firefly off. +/* + * Quality combines folded env-CDF NEE, unconditional cosine NEE, and BSDF + * escape. Only the CDF density carries the per-instance Light Pick mass. + * Check linearity with a directional light and analytic Lambertian energy + * rho*L for uniform, nonuniform, multiple, hidden, and one-texel HDRIs. + * Matte isolates the NEE partition; diffuse PBR also exercises escape MIS. + * Hidden lights must illuminate without becoming visible camera backgrounds. + * Measurements use a linear float buffer with the firefly filter disabled. + */ #define ANARI_EXTENSION_UTILITY_IMPL #include -#include #include +#include #include #include #include #include #include +#include #include using uvec2 = std::array; @@ -60,30 +63,45 @@ static void statusFunc(const void *, ANARIStatusCode, const char *message) { - if (severity == ANARI_SEVERITY_FATAL_ERROR) { - fprintf(stderr, "[FATAL][%p] %s\n", source, message); - std::exit(1); - } else if (severity == ANARI_SEVERITY_ERROR) + if (severity == ANARI_SEVERITY_FATAL_ERROR + || severity == ANARI_SEVERITY_ERROR) { fprintf(stderr, "[ERROR][%p] %s\n", source, message); + std::exit(1); + } } static constexpr uvec2 IMAGE_SIZE = {256, 256}; static constexpr int PIXEL_SAMPLES = 256; -static anari::Light makeHDRI(ANARIDevice device) +struct HdriMap { - // A uniform (constant-radiance) environment. - constexpr uint32_t W = 8, H = 4; - std::vector texels(W * H, vec3{0.6f, 0.6f, 0.6f}); + float upperRadiance{0.6f}; + float lowerRadiance{0.6f}; + bool visible{true}; + uvec2 size{64, 128}; +}; + +static anari::Light makeHDRI(ANARIDevice device, HdriMap map = {}) +{ + const uint32_t W = map.size[0], H = map.size[1]; + std::vector texels(W * H); + for (uint32_t y = 0; y < H; ++y) { + const float value = y < H / 2 ? map.upperRadiance : map.lowerRadiance; + for (uint32_t x = 0; x < W; ++x) + texels[y * W + x] = vec3{value, value, value}; + } auto radiance = anari::newArray2D(device, ANARI_FLOAT32_VEC3, W, H); - std::memcpy( - anari::map(device, radiance), texels.data(), texels.size() * sizeof(vec3)); + std::memcpy(anari::map(device, radiance), + texels.data(), + texels.size() * sizeof(vec3)); anari::unmap(device, radiance); auto light = anari::newObject(device, "hdri"); anari::setParameter(device, light, "direction", vec3{0.f, 0.f, 1.f}); - anari::setParameter(device, light, "up", vec3{0.f, 1.f, 0.f}); + // HDRI row zero faces -up, so the first half illuminates the +Y plane. + anari::setParameter(device, light, "up", vec3{0.f, -1.f, 0.f}); anari::setParameter(device, light, "scale", 1.f); + anari::setParameter(device, light, "visible", map.visible); anari::setAndReleaseParameter(device, light, "radiance", radiance); anari::commitParameters(device, light); return light; @@ -174,13 +192,167 @@ static double render(ANARIDevice device, bool hdri, bool directional) return n ? sum / double(n) : 0.0; } +// A planar Lambertian under a uniform environment of radiance L reflects ρL +// exactly (view-independent). Used to catch two-strategy MIS energy error +// (double-count, or cosine NEE omitted from the env-CDF weight). `pbr` uses +// physicallyBased with specular=0 so the continuation lobe has a finite pdf +// — the miss-side three-way weight is invisible to matte (pdf=0). +static double renderDiffusePlane(ANARIDevice device, + bool pbr, + const std::vector &maps = {HdriMap{}}, + bool backdrop = false) +{ + const std::array pos = {vec3{-20.f, 0.f, -20.f}, + vec3{20.f, 0.f, -20.f}, + vec3{20.f, 0.f, 20.f}, + vec3{-20.f, 0.f, 20.f}}; + // Winding produces +Y geometric normals so the camera above the plane sees + // front faces (v0-v2-v1: e1×e2 = +Y). + const std::array, 2> idx = { + std::array{0, 2, 1}, std::array{0, 3, 2}}; + + auto geometry = anari::newObject(device, "triangle"); + anari::setParameterArray1D( + device, geometry, "vertex.position", pos.data(), 4); + anari::setParameterArray1D( + device, geometry, "primitive.index", idx.data(), 2); + anari::commitParameters(device, geometry); + + anari::Material material; + if (pbr) { + material = anari::newObject(device, "physicallyBased"); + anari::setParameter(device, material, "baseColor", vec3{0.8f, 0.8f, 0.8f}); + anari::setParameter(device, material, "metallic", 0.f); + anari::setParameter(device, material, "roughness", 1.f); + anari::setParameter(device, material, "specular", 0.f); + } else { + material = anari::newObject(device, "matte"); + anari::setParameter(device, material, "color", vec3{0.8f, 0.8f, 0.8f}); + } + anari::commitParameters(device, material); + + auto surface = anari::newObject(device); + anari::setAndReleaseParameter(device, surface, "geometry", geometry); + anari::setAndReleaseParameter(device, surface, "material", material); + anari::commitParameters(device, surface); + + std::vector lights; + for (const auto &map : maps) + lights.push_back(makeHDRI(device, map)); + + auto world = anari::newObject(device); + anari::setParameterArray1D(device, world, "surface", &surface, 1); + anari::setParameterArray1D( + device, world, "light", lights.data(), lights.size()); + anari::release(device, surface); + for (auto light : lights) + anari::release(device, light); + anari::commitParameters(device, world); + + auto camera = anari::newObject(device, "perspective"); + anari::setParameter(device, camera, "position", vec3{0.f, 4.f, 0.f}); + anari::setParameter( + device, camera, "direction", vec3{0.f, backdrop ? 1.f : -1.f, 0.f}); + anari::setParameter(device, camera, "up", vec3{0.f, 0.f, 1.f}); + anari::setParameter( + device, camera, "aspect", IMAGE_SIZE[0] / float(IMAGE_SIZE[1])); + anari::commitParameters(device, camera); + + auto renderer = anari::newObject(device, "quality"); + anari::setParameter(device, renderer, "background", vec4{0.f, 0.f, 0.f, 1.f}); + anari::setParameter(device, renderer, "ambientRadiance", 0.f); + anari::setParameter(device, renderer, "pixelSamples", PIXEL_SAMPLES); + anari::setParameter(device, renderer, "fireflyFilterMode", "none"); + anari::commitParameters(device, renderer); + + auto frame = anari::newObject(device); + anari::setParameter(device, frame, "size", IMAGE_SIZE); + anari::setParameter(device, frame, "channel.color", ANARI_FLOAT32_VEC4); + anari::setAndReleaseParameter(device, frame, "world", world); + anari::setAndReleaseParameter(device, frame, "camera", camera); + anari::setAndReleaseParameter(device, frame, "renderer", renderer); + anari::commitParameters(device, frame); + + anari::render(device, frame); + anari::wait(device, frame); + auto fb = anari::map(device, frame, "channel.color"); + + double sum = 0.0; + uint64_t n = 0; + for (uint32_t y = IMAGE_SIZE[1] / 4; y < 3 * IMAGE_SIZE[1] / 4; ++y) { + for (uint32_t x = IMAGE_SIZE[0] / 4; x < 3 * IMAGE_SIZE[0] / 4; ++x) { + const vec4 &p = fb.data[y * IMAGE_SIZE[0] + x]; + sum += 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + ++n; + } + } + anari::unmap(device, frame, "channel.color"); + anari::release(device, frame); + return n ? sum / double(n) : 0.0; +} + +static bool checkEnergy(const char *name, double actual, double expected) +{ + printf("%s: actual=%f expected=%f\n", name, actual, expected); + const double tolerance = expected > 0.0 ? 0.03 * expected : 1e-4; + if (!std::isfinite(actual) || std::abs(actual - expected) > tolerance) { + fprintf(stderr, "FAIL: %s (tolerance=%f)\n", name, tolerance); + return false; + } + return true; +} + int main() { auto device = makeVisRTXDevice(statusFunc); const double both = render(device, true, true); const double env = render(device, true, false); const double sun = render(device, false, true); + const double plane = renderDiffusePlane(device, false); + const double planePbr = renderDiffusePlane(device, true); + // The sum of constant environments is constant: rho * (0.4 + 0.2) = 0.48. + // Unequal powers must form a mixture, not a sum of normalized densities. + bool passed = true; + for (bool pbr : {false, true}) { + passed &= checkEnergy(pbr ? "multiple HDRIs PBR" : "multiple HDRIs matte", + renderDiffusePlane(device, pbr, {{0.4f, 0.4f}, {0.2f, 0.2f}}), + 0.48); + // Even a one-texel map is a constant environment. Its CDF jitters theta + // uniformly, so its solid-angle density is not uniform on the sphere. + passed &= checkEnergy(pbr ? "one-texel HDRI PBR" : "one-texel HDRI matte", + renderDiffusePlane(device, pbr, {{0.6f, 0.6f, true, {1, 1}}}), + 0.48); + // Only the upper hemisphere illuminates the plane, regardless of how + // bright the folded-away hemisphere is. Filtering is confined to a narrow + // band at the horizon where the cosine factor vanishes. + passed &= checkEnergy(pbr ? "nonuniform HDRI PBR" : "nonuniform HDRI matte", + renderDiffusePlane(device, pbr, {{0.6f, 6.f}}), + 0.48); + passed &= checkEnergy( + pbr ? "unequal HDRI mixture PBR" : "unequal HDRI mixture matte", + renderDiffusePlane(device, pbr, {{0.4f, 0.04f}, {0.2f, 2.f}}), + 0.48); + // Hiding a light's background does not remove its illumination. + passed &= checkEnergy(pbr ? "hidden HDRI PBR" : "hidden HDRI matte", + renderDiffusePlane(device, pbr, {{0.6f, 0.6f, false}}), + 0.48); + passed &= checkEnergy( + pbr ? "visible and hidden HDRIs PBR" : "visible and hidden HDRIs matte", + renderDiffusePlane(device, pbr, {{0.4f, 0.4f}, {0.2f, 0.2f, false}}), + 0.48); + passed &= checkEnergy(pbr ? "black HDRI PBR" : "black HDRI matte", + renderDiffusePlane(device, pbr, {{0.f, 0.f}}), + 0.0); + } + passed &= checkEnergy("visible backdrop", + renderDiffusePlane(device, false, {{0.6f, 0.6f}}, true), + 0.6); + passed &= checkEnergy("hidden backdrop", + renderDiffusePlane(device, false, {{0.6f, 0.6f, false}}, true), + 0.0); anari::release(device, device); + if (!passed) + return 1; const double sum = env + sun; const double relErr = sum > 0.0 ? std::abs(both - sum) / sum : 1.0; @@ -195,6 +367,44 @@ int main() fprintf(stderr, "FAIL: HDRI environment did not light the ground\n"); return 1; } + // Lambertian under a uniform environment of radiance L reflects ρL. The HDRI + // texels are 0.6 and the matte albedo is 0.8, so the ground mean must match + // 0.48 — a broken two-strategy MIS (double-count, or cosine NEE omitted from + // the env-CDF weight) shows up as a mean energy error, not just extra noise. + constexpr double albedo = 0.8; + constexpr double envRadiance = 0.6; + const double expected = albedo * envRadiance; + const double relErrEnv = + expected > 0.0 ? std::abs(plane - expected) / expected : 1.0; + printf("plane=%f planePbr=%f envExpected=%f relErrEnv=%f\n", + plane, + planePbr, + expected, + relErrEnv); + constexpr double ENV_ENERGY_TOLERANCE = 0.05; + if (!(relErrEnv <= ENV_ENERGY_TOLERANCE)) { + fprintf(stderr, + "FAIL: matte plane under uniform HDRI not ρL (plane=%f expected=%f " + "relErr=%f, tol %f)\n", + plane, + expected, + relErrEnv, + ENV_ENERGY_TOLERANCE); + return 1; + } + const double relErrPbr = + expected > 0.0 ? std::abs(planePbr - expected) / expected : 1.0; + printf("relErrPbr=%f\n", relErrPbr); + if (!(relErrPbr <= ENV_ENERGY_TOLERANCE)) { + fprintf(stderr, + "FAIL: PBR plane under uniform HDRI not ρL (planePbr=%f expected=%f " + "relErr=%f, tol %f) — miss-side env MIS likely omitted p_C\n", + planePbr, + expected, + relErrPbr, + ENV_ENERGY_TOLERANCE); + return 1; + } constexpr double TOLERANCE = 0.03; if (!(relErr <= TOLERANCE)) { fprintf(stderr, diff --git a/devices/rtx/apps/tests/unit/test_LightPickPower.cpp b/devices/rtx/apps/tests/unit/test_LightPickPower.cpp index d0ed1a85b..5ae6a7c99 100644 --- a/devices/rtx/apps/tests/unit/test_LightPickPower.cpp +++ b/devices/rtx/apps/tests/unit/test_LightPickPower.cpp @@ -167,17 +167,28 @@ int main() > lightPickPower(small, identity, radius)); } - // HDRI: an infinite light — power grows with scale and with radius^2. + // HDRI: an infinite light — power grows with scale, with radius^2, and with + // the map's mean luminance (recovered from pdfWeight; larger pdfWeight means + // a DIMMER map, so power falls as pdfWeight grows). A zero pdfWeight (all- + // black map) yields zero power so it is never picked. { LightGPUData env{}; env.type = LightType::HDRI; env.color = vec3(1.f); env.hdri.scale = 1.f; + env.hdri.pdfWeight = 1.f; // meanLuminance = 1/(4*pi) LightGPUData brighter = env; brighter.hdri.scale = 2.f; + LightGPUData higherMean = env; + higherMean.hdri.pdfWeight = 0.5f; // dimmer denominator => brighter map + LightGPUData black = env; + black.hdri.pdfWeight = 0.f; CHECK(lightPickPower(env, identity, radius) > 0.f); CHECK(lightPickPower(brighter, identity, radius) > lightPickPower(env, identity, radius)); + CHECK(lightPickPower(higherMean, identity, radius) + > lightPickPower(env, identity, radius)); + CHECK(lightPickPower(black, identity, radius) == 0.f); CHECK(lightPickPower(env, identity, 2.f) > lightPickPower(env, identity, 1.f)); } diff --git a/devices/rtx/device/gpu/evalShading.h b/devices/rtx/device/gpu/evalShading.h index 5b4d6e71b..192bf832c 100644 --- a/devices/rtx/device/gpu/evalShading.h +++ b/devices/rtx/device/gpu/evalShading.h @@ -116,21 +116,24 @@ VISRTX_DEVICE float materialEvalPdf(const MaterialShadingState &shadingState, &wi); } -VISRTX_DEVICE vec3 materialShadeSurface( - const MaterialShadingState &shadingState, - const SurfaceHit &hit, - const LightSample &lightSample, - const vec3 &outgoingDir) +// f(wo, wi) * |cos(wi, shading normal)| -- the BSDF value alone, with no light +// radiance and no light pdf folded in: the caller owns the estimator it builds +// from this. Both directions are world space, `wo` points away from the surface +// toward the viewer. Pairs with materialEvalPdf: same lobe mixture, same +// conventions, reflection side only (transmission returns 0, as NEE cannot +// reach it). +VISRTX_DEVICE vec3 materialEvalBsdf(const MaterialShadingState &shadingState, + const vec3 &wo, + const vec3 &wi) { if (shadingState.callableBaseIndex == ~DeviceObjectIndex(0)) - return vec3(0.0f, 0.0f, 0.0f); // No shading by default + return vec3(0.0f); // No material: no reflected energy return optixDirectCall( - shadingState.callableBaseIndex + int(SurfaceShaderEntryPoints::Shade), + shadingState.callableBaseIndex + int(SurfaceShaderEntryPoints::EvalBsdf), &shadingState.data, - &hit, - &lightSample, - &outgoingDir); + &wo, + &wi); } } // namespace visrtx diff --git a/devices/rtx/device/gpu/gpu_util.h b/devices/rtx/device/gpu/gpu_util.h index 4fb230ef7..400a193fe 100644 --- a/devices/rtx/device/gpu/gpu_util.h +++ b/devices/rtx/device/gpu/gpu_util.h @@ -388,17 +388,20 @@ VISRTX_DEVICE vec3 sampleHDRI(const LightGPUData &ld, const vec3 &rayDir) return sampleHDRI(ld, vec2(u, v)) * ld.hdri.scale; } -VISRTX_DEVICE bool getBackgroundLight( - const FrameGPUData &fd, const vec3 &rayDir, vec3 &outRadiance) +// Illumination includes every HDRI; visibleOnly is for camera backgrounds. +VISRTX_DEVICE bool getEnvironmentLight(const FrameGPUData &fd, + const vec3 &rayDir, + vec3 &outRadiance, + bool visibleOnly = false) { - // Accumulate contributions from all visible HDRI lights + // Accumulate radiance independently of which HDRI the CDF technique picked. outRadiance = vec3(0.f); - bool hasVisibleHDRI = false; + bool hasHDRI = false; for (size_t i = 0; i < fd.world.numHdriLightInstances; i++) { const auto &hdriLight = fd.world.hdriLightInstances[i]; const auto &light = fd.registry.lights[hdriLight.lightIndex]; - if (light.hdri.visible) { + if (!visibleOnly || light.hdri.visible) { // Transform ray direction from world space to HDRI local space // For orthonormal matrices, inverse = transpose const mat3 xfmInv = glm::transpose(mat3(hdriLight.xfm)); @@ -407,11 +410,42 @@ VISRTX_DEVICE bool getBackgroundLight( // radiance in sampleHDRILight (raw * hdri.scale * color), so env MIS // deposits identical radiance on the NEE and BSDF-escape sides. outRadiance += sampleHDRI(light, localRayDir) * light.color; - hasVisibleHDRI = true; + hasHDRI = true; } } - return hasVisibleHDRI; + return hasHDRI; +} + +VISRTX_DEVICE bool getBackgroundLight( + const FrameGPUData &fd, const vec3 &rayDir, vec3 &outRadiance) +{ + return getEnvironmentLight(fd, rayDir, outRadiance, true); +} + +// Density of sampleHDRILight's discrete texel selection and uniform UV jitter. +// Filtered radiance is the integrand, not the sampling density: using it as +// the estimator denominator biases coarse maps and sharp texture transitions. +VISRTX_DEVICE float hdriCdfPdf( + const LightGPUData &light, const mat4 &xfm, const vec3 &rayDir) +{ + const auto &hdri = light.hdri; + if (!(hdri.pdfWeight > 0.0f)) + return 0.0f; + const vec3 d = hdri.xfm * (glm::transpose(mat3(xfm)) * rayDir); + const vec2 thetaPhi = sphericalCoordsFromDirection(d); + const vec2 uv = vec2(thetaPhi.y / kTwoPi, thetaPhi.x / kPi); + const uvec2 xy = glm::min(uvec2(uv * vec2(hdri.size)), hdri.size - 1u); + const float rowMass = + hdri.marginalCDF[xy.y] - (xy.y > 0 ? hdri.marginalCDF[xy.y - 1] : 0.0f); + const float *row = hdri.conditionalCDF + xy.y * hdri.size.x; + const float columnMass = row[xy.x] - (xy.x > 0 ? row[xy.x - 1] : 0.0f); + // length(x,y) retains precision near the poles where acos(z) rounds to zero. + const float sinTheta = length(vec2(d)); + if (!(sinTheta > 0.0f)) + return 0.0f; // the exact poles have zero sampling measure + return rowMass * columnMass * float(hdri.size.x) * float(hdri.size.y) + / (2.0f * kPi * kPi * sinTheta); } // Solid-angle sampling pdf of the visible HDRI environment(s) at `rayDir`, used @@ -438,6 +472,42 @@ VISRTX_DEVICE float envPdf(const FrameGPUData &fd, const vec3 &rayDir) return pdf; } +VISRTX_DEVICE vec3 reflectAcrossNormal(const vec3 &w, const vec3 &n) +{ + return w - 2.0f * dot(w, n) * n; +} + +// Solid-angle density of ONE HDRI instance's folded CDF technique at `dir`. +// Folding reflects a sub-horizon CDF sample back across the shading normal, so +// two texels map to every visible direction and the density is the sum of both +// preimages. No Light Pick factor: callers that pick one light multiply it in, +// callers that sample every light (Interactive) must not. +VISRTX_DEVICE float hdriFoldedPdf( + const LightGPUData &light, const mat4 &xfm, const vec3 &dir, const vec3 &ns) +{ + if (!(dot(dir, ns) > 0.0f)) + return 0.0f; // folding maps every sample above the horizon + return hdriCdfPdf(light, xfm, dir) + + hdriCdfPdf(light, xfm, reflectAcrossNormal(dir, ns)); +} + +// Combined folded-CDF density when every HDRI instance is sampled once, as an +// independent NEE technique each (Interactive). The per-instance estimator +// divides by its own hdriFoldedPdf; this unweighted sum is the MIS denominator +// term standing for "some HDRI CDF technique produced `dir`". Quality picks a +// single light instead and weights by pick probability — see envHemiPdf. +VISRTX_DEVICE float envFoldedHemiPdf( + const FrameGPUData &fd, const vec3 &dir, const vec3 &ns) +{ + float pdf = 0.0f; + for (size_t i = 0; i < fd.world.numHdriLightInstances; ++i) { + const auto &instance = fd.world.hdriLightInstances[i]; + pdf += hdriFoldedPdf( + fd.registry.lights[instance.lightIndex], instance.xfm, dir, ns); + } + return pdf; +} + VISRTX_DEVICE uint32_t computeGeometryPrimId(const SurfaceHit &hit) { if (!hit.foundHit) diff --git a/devices/rtx/device/gpu/lightPickPower.h b/devices/rtx/device/gpu/lightPickPower.h index 9b3d2fd7c..df9f91362 100644 --- a/devices/rtx/device/gpu/lightPickPower.h +++ b/devices/rtx/device/gpu/lightPickPower.h @@ -103,10 +103,21 @@ VISRTX_HOST_DEVICE float lightPickPower( detail::affineAreaScale(xfm) / glm::max(ld.ring.oneOverArea, 1e-8f); return detail::pickLuminance(ld.color) * ld.ring.intensity * area * kPi; } - case LightType::HDRI: - // The environment's average luminance is approximated as unit; scale and - // tint carry the only per-light signal until a measured average lands. - return detail::pickLuminance(ld.color) * ld.hdri.scale * sceneCrossSection; + case LightType::HDRI: { + // Including map brightness allocates NEE samples more effectively between + // the HDRI and other lights. Pick Power is a variance heuristic, not the + // sampling pdf: any positive approximation preserves the estimator's mean. + // Estimate the map's solid-angle mean luminance from pdfWeight, which folds + // the equirectangular sinθ Jacobian: pdfWeight = (W·H)/(totalLum·2π²), so + // the mean over the sphere is meanLum = totalLum·π/(2·W·H) = + // 1/(4π·pdfWeight). A zero-luminance (all-black) map has pdfWeight == 0 and + // thus zero power — correctly never picked. + const float meanLuminance = ld.hdri.pdfWeight > 0.0f + ? 1.0f / (2.0f * kTwoPi * ld.hdri.pdfWeight) + : 0.0f; + return detail::pickLuminance(ld.color) * ld.hdri.scale * meanLuminance + * sceneCrossSection; + } case LightType::GEOMETRY: { // Double-sided Lambertian surface: flux = L · area · π, doubled for sides. // This diffuse-only assumption is in lockstep with kFaithfulSet diff --git a/devices/rtx/device/gpu/sbt.h b/devices/rtx/device/gpu/sbt.h index cd5a80a6a..a88261b69 100644 --- a/devices/rtx/device/gpu/sbt.h +++ b/devices/rtx/device/gpu/sbt.h @@ -46,7 +46,7 @@ enum class SurfaceShaderEntryPoints EvaluateEmission, EvaluateTransmission, EvaluateNormal, - Shade, + EvalBsdf, EvaluatePdf, Count }; diff --git a/devices/rtx/device/material/shaders/MDLShader_ptx.cu b/devices/rtx/device/material/shaders/MDLShader_ptx.cu index cd4b009de..14376f66d 100644 --- a/devices/rtx/device/material/shaders/MDLShader_ptx.cu +++ b/devices/rtx/device/material/shaders/MDLShader_ptx.cu @@ -155,16 +155,15 @@ VISRTX_CALLABLE void __direct_callable__init(MDLShadingState *shadingState, mdlInit(&shadingState->state, &shadingState->resData, shadingState->argBlock); } -// Signature must match the call inside shaderMDLSurface in MDLShader.cuh. +// f(wo, wi) * cos, world space. MDL's bsdf_diffuse/bsdf_glossy already carry +// the cosine factor, so they are returned as-is. VISRTX_CALLABLE -vec3 __direct_callable__shadeSurface(const MDLShadingState *shadingState, - const SurfaceHit *hit, - const LightSample *lightSample, - const vec3 *outgoingDir) +vec3 __direct_callable__evalBsdf( + const MDLShadingState *shadingState, const vec3 *wo, const vec3 *wi) { // Eval const float cos_theta = - dot(*outgoingDir, normalize(make_vec3(shadingState->state.normal))); + dot(*wo, normalize(make_vec3(shadingState->state.normal))); if (cos_theta > 0.0f) { BsdfEvaluateData eval_data = {}; if (shadingState->isFrontFace) { @@ -174,20 +173,15 @@ vec3 __direct_callable__shadeSurface(const MDLShadingState *shadingState, eval_data.ior1.x = MI_NEURAYLIB_BSDF_USE_MATERIAL_IOR; eval_data.ior2 = make_float3(1.0f, 1.0f, 1.0f); } - eval_data.k1 = make_float3(normalize(*outgoingDir)); - eval_data.k2 = make_float3(normalize(lightSample->dir)); + eval_data.k1 = make_float3(normalize(*wo)); + eval_data.k2 = make_float3(normalize(*wi)); mdlBsdf_evaluate(&eval_data, &shadingState->state, &shadingState->resData, shadingState->argBlock); - auto radiance_over_pdf = lightSample->radiance / lightSample->pdf; - auto contrib = radiance_over_pdf - * (make_vec3(eval_data.bsdf_diffuse) - + make_vec3(eval_data.bsdf_glossy)); - - return contrib; + return make_vec3(eval_data.bsdf_diffuse) + make_vec3(eval_data.bsdf_glossy); } return vec3(0.0f, 0.0f, 0.0f); @@ -314,7 +308,7 @@ vec3 __direct_callable__evaluateNormal(const MDLShadingState *shadingState) // with the solid-angle sampling pdf, matching NextRay.pdf in nextRay (MDL's // evaluate-pdf and sample-pdf are the same density). A pure specular lobe // evaluates to pdf 0 (NEE can't reach a delta) — consistent with the escape -// owning it via +inf. Mirrors shadeSurface's ior/k1/k2 setup exactly. +// owning it via +inf. Mirrors evalBsdf's ior/k1/k2 setup exactly. VISRTX_CALLABLE float __direct_callable__evaluatePdf( const MDLShadingState *shadingState, const vec3 *wo, const vec3 *wi) { diff --git a/devices/rtx/device/material/shaders/MatteShader_ptx.cu b/devices/rtx/device/material/shaders/MatteShader_ptx.cu index bd2876a0d..ea7c51d07 100644 --- a/devices/rtx/device/material/shaders/MatteShader_ptx.cu +++ b/devices/rtx/device/material/shaders/MatteShader_ptx.cu @@ -94,16 +94,16 @@ vec3 __direct_callable__evaluateNormal(const MatteShadingState *shadingState) return shadingState->normal; } -// Signature must match the call inside shaderMatteSurface in MatteShader.cuh. -VISRTX_CALLABLE vec3 __direct_callable__shadeSurface( - const MatteShadingState *shadingState, - const SurfaceHit *hit, - const LightSample *lightSample, - const vec3 *outgoingDir) +// Lambertian f*cos. `wo` is unused -- the lobe is isotropic -- but kept for +// signature parity across materials. Gates on the state's normal, which is +// hit->Ns except where init() substituted Ng for a degenerate one; using +// hit->Ns here instead would shade those hits with the NaN normal that +// fallback exists to replace. +VISRTX_CALLABLE vec3 __direct_callable__evalBsdf( + const MatteShadingState *shadingState, const vec3 *wo, const vec3 *wi) { - float NdotL = fmaxf(0.0f, dot(hit->Ns, lightSample->dir)); - return shadingState->baseColor * kInvPi * NdotL * lightSample->radiance - / lightSample->pdf; + const float NdotL = fmaxf(0.0f, dot(shadingState->normal, *wi)); + return shadingState->baseColor * kInvPi * NdotL; } // Matte has no continuation ray (nextRay returns a dead ray), so its BSDF can diff --git a/devices/rtx/device/material/shaders/PhysicallyBasedShader_ptx.cu b/devices/rtx/device/material/shaders/PhysicallyBasedShader_ptx.cu index 212e9bdd7..301b9aa01 100644 --- a/devices/rtx/device/material/shaders/PhysicallyBasedShader_ptx.cu +++ b/devices/rtx/device/material/shaders/PhysicallyBasedShader_ptx.cu @@ -397,15 +397,12 @@ VISRTX_DEVICE vec3 evalFresnelWithIridescence( return F; } -VISRTX_CALLABLE vec3 __direct_callable__shadeSurface( - const PhysicallyBasedShadingState *state, - const SurfaceHit *hit, - const LightSample *lightSample, - const vec3 *outgoingDir) +VISRTX_CALLABLE vec3 __direct_callable__evalBsdf( + const PhysicallyBasedShadingState *state, const vec3 *wo, const vec3 *wi) { const vec3 N = state->normal; - const vec3 V = *outgoingDir; - const vec3 L = lightSample->dir; + const vec3 V = *wo; + const vec3 L = *wi; const float NdotL = dot(N, L); // Negated form so a NaN NdotL takes this early-out — NaN compares false @@ -420,7 +417,7 @@ VISRTX_CALLABLE vec3 __direct_callable__shadeSurface( // Base F0 / F90. Specular uses Fresnel at the microfacet (VdotH); the // diffuse weight uses Fresnel at NdotV (Frostbite/Disney convention) so - // shadeSurface and nextRay's diffuse split agree regardless of light dir. + // evalBsdf and nextRay's diffuse split agree regardless of light dir. const vec3 F0 = computeF0(state); const vec3 F90 = computeF90(state); const vec3 F = evalFresnelWithIridescence(state, F0, F90, VdotH); @@ -477,7 +474,7 @@ VISRTX_CALLABLE vec3 __direct_callable__shadeSurface( base += state->sheenColor * Ds * Vs; } - return base * NdotL * lightSample->radiance / lightSample->pdf; + return base * NdotL; } //----------------------------------------------------------------------------- @@ -488,7 +485,7 @@ VISRTX_CALLABLE vec3 __direct_callable__shadeSurface( // escape-side weight (NextRay.pdf) are identical functions and the balance- // heuristic weights partition to 1 exactly (unbiased). // -// Transmission (through-surface) directions return 0: NEE's shadeSurface +// Transmission (through-surface) directions return 0: NEE's evalBsdf // early-outs at NdotL<=0, so they are never combined — the escape estimator // owns them outright (NextRay.pdf = +inf at sample time). //----------------------------------------------------------------------------- @@ -653,7 +650,7 @@ VISRTX_CALLABLE NextRay __direct_callable__nextRay( + fmaxf(luminance(glm::max(vec3(1.0f) - Fv, vec3(0.0f)) * transmissionFilter), 0.0f); // Lambertian throughput collapses to this energy when sampled cosine-weighted - // (cos / pdf cancels with 1/pi); mirrors shadeSurface's diffuseBRDF factors. + // (cos / pdf cancels with 1/pi); mirrors evalBsdf's diffuseBRDF factors. const vec3 diffuseEnergy = glm::max(vec3(1.0f) - Fv, vec3(0.0f)) * state->baseColor * (1.0f - state->metallic) * (1.0f - state->transmission) * state->occlusion; @@ -665,7 +662,7 @@ VISRTX_CALLABLE NextRay __direct_callable__nextRay( const float pDiff = diffSelW / baseSel; // Diffuse lobe: sample around the shading normal so pdf=cos/pi matches the - // BRDF's NdotL (same axis as shadeSurface's diffuse term). + // BRDF's NdotL (same axis as evalBsdf's diffuse term). if (pcg_uniform(rs) >= pSpec) { const vec3 wi = sampleHemisphere(*rs, N); const vec3 weight = @@ -717,7 +714,7 @@ VISRTX_CALLABLE NextRay __direct_callable__nextRay( const vec3 Ltworld = normalize(toWorld * Ltrans); const vec3 weightT = transmitEnergy * (G2t / fmaxf(G1t, 1e-8f)) * clearcoatExitAttn(Ltworld) / fmaxf(pSpec * (1.0f - reflectGivenSpec), 1e-8f); - // Through-surface escape: NEE's shadeSurface early-outs at NdotL<=0, so the + // Through-surface escape: NEE's evalBsdf early-outs at NdotL<=0, so the // env behind glass can only be reached by this continuation. Report +inf so // env MIS gives it w_bsdf=1 (the escape owns it), matching the pre-MIS flag. return NextRay{ diff --git a/devices/rtx/device/renderer/Interactive_ptx.cu b/devices/rtx/device/renderer/Interactive_ptx.cu index f53d64368..d445eb2d8 100644 --- a/devices/rtx/device/renderer/Interactive_ptx.cu +++ b/devices/rtx/device/renderer/Interactive_ptx.cu @@ -101,12 +101,16 @@ struct InteractiveShadingPolicy const vec3 shadowOrigin = shadingHitpoint(hit) + hit.Ng * hit.epsilon; + // The cosine-hemisphere env stratum below runs whenever the world has an + // HDRI, so its density belongs in every env MIS denominator on this hit. + const bool haveHdri = world.numHdriLightInstances > 0; + // 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, + auto lightSample = sampleLight(ss, shadowOrigin, light.lightIndex, light.xfm, @@ -115,8 +119,28 @@ struct InteractiveShadingPolicy if (lightSample.pdf == 0.0f) return; - const LightType lightType = - frameData.registry.lights[light.lightIndex].type; + const auto &lightData = frameData.registry.lights[light.lightIndex]; + const LightType lightType = lightData.type; + + // Hemisphere folding: the HDRI CDF samples the whole sphere, so about + // half its samples land below the horizon where the BSDF is zero — the + // sample and its shadow ray are spent for nothing. Reflect those back + // across the shading normal and pay for it in the density (two texels + // now map to every visible direction, which hdriFoldedPdf sums). The + // estimator denominator also stops being filtered radiance and becomes + // the true CDF-cell density, which was biased on coarse maps. + float envFoldedPdf = 0.0f; + if (lightType == LightType::HDRI) { + if (!(dot(lightSample.dir, hit.Ns) > 0.0f)) + lightSample.dir = reflectAcrossNormal(lightSample.dir, hit.Ns); + // Every technique estimates the full environment, so evaluate the sum + // over all HDRIs here rather than this one instance's radiance. + getEnvironmentLight(frameData, lightSample.dir, lightSample.radiance); + envFoldedPdf = + hdriFoldedPdf(lightData, light.xfm, lightSample.dir, hit.Ns); + if (!(envFoldedPdf > 0.0f)) + return; + } // A Geometry Light's sampled point is on real emissive geometry; stop the // shadow ray short of it or it self-occludes on that surface. @@ -140,20 +164,39 @@ struct InteractiveShadingPolicy glm::lessThanEqual(attenuation, vec3(MIN_CONTRIBUTION_EPSILON)))) return; - vec3 thisLightContrib = - materialShadeSurface(shadingState, hit, lightSample, -ray.dir); + const vec3 fCos = + materialEvalBsdf(shadingState, -ray.dir, lightSample.dir); + // Folded env NEE divides by the folded CDF density, not by the sampler's + // reported (full-sphere, radiance-based) pdf. + const float neePdf = + lightType == LightType::HDRI ? envFoldedPdf : lightSample.pdf; + vec3 thisLightContrib = fCos * lightSample.radiance / neePdf; // 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). - // 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. + // indirect bounce's escape can also reach, and the cosine stratum below + // reaches it too, so combine the three estimators instead of summing them + // (which double-counted the env). p_L is the unweighted sum of the + // per-instance folded CDF densities — each instance is sampled as its own + // technique here, unlike Quality where one Light Pick makes it a + // pick-weighted mixture. 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); + // This instance is one technique among all of them: the numerator is + // its own density (the one the estimator divided by), the denominator + // the sum over every technique that could have produced this + // direction. They coincide for a single HDRI; with several, using the + // sum in the numerator would over-weight each instance by the number + // of instances. + const float pLightAll = + envFoldedHemiPdf(frameData, lightSample.dir, hit.Ns); const float pBsdf = materialEvalPdf(shadingState, -ray.dir, lightSample.dir); - thisLightContrib *= pLight / (pLight + pBsdf); + // lightType == HDRI implies the cosine stratum ran on this hit. + const float pCosine = + fmaxf(0.0f, dot(lightSample.dir, hit.Ns)) * kInvPi; + const float pSum = pLightAll + pBsdf + pCosine; + thisLightContrib *= pSum > 0.0f ? envFoldedPdf / pSum : 0.0f; } contrib += weight * thisLightContrib * attenuation; @@ -174,7 +217,8 @@ struct InteractiveShadingPolicy } 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. + // 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); @@ -196,6 +240,41 @@ struct InteractiveShadingPolicy } } + // Cosine-hemisphere env NEE. A luminance CDF concentrates on the brightest + // texels (the sun), but on a matte surface under a broad sky most of the + // irradiance is diffuse — exactly where that CDF is a poor match and the + // single CDF sample per hit is noisiest. This second stratum samples + // proportional to the cosine instead and MIS-combines with the CDF, so + // whichever technique fits the map wins per-direction. Unlike the NEE loop + // it is not gated on a Light Pick: it always runs when an HDRI exists, so + // p_C carries no pick factor on either side of the weight. + if (haveHdri) { + const vec3 dirC = sampleHemisphere(ss.rs, hit.Ns); + const float cosC = fmaxf(0.0f, dot(dirC, hit.Ns)); + vec3 envRadiance; + if (cosC > 0.0f && getEnvironmentLight(frameData, dirC, envRadiance)) { + const float pCosine = cosC * kInvPi; + const float pLight = envFoldedHemiPdf(frameData, dirC, hit.Ns); + const float pBsdf = materialEvalPdf(shadingState, -ray.dir, dirC); + const float pSum = pCosine + pLight + pBsdf; + if (pCosine > 0.0f && pSum > 0.0f) { + const vec3 fCos = materialEvalBsdf(shadingState, -ray.dir, dirC); + const vec3 contribC = (pCosine / pSum) * fCos * envRadiance / pCosine; + if (glm::any( + glm::greaterThan(contribC, vec3(MIN_CONTRIBUTION_EPSILON)))) { + const Ray shadowRay = { + shadowOrigin, + dirC, + {hit.epsilon, std::numeric_limits::max()}, + }; + const vec3 attenuation = surfaceShadowTransmittance(ss, shadowRay) + * (1.0f - volumeShadowOpacity(ss, shadowRay)); + contrib += contribC * attenuation; + } + } + } + } + // Single indirect bounce — REFLECTION only. Transmission/refraction is // owned by the flat compositing loop, so a through-surface continuation is // discarded here to avoid double-counting the transmitted background. @@ -231,14 +310,28 @@ struct InteractiveShadingPolicy contrib += color * nextRay.contributionWeight; } else { vec3 hdri; - if (getBackgroundLight(frameData, bounceRay.dir, hdri)) { + // Illumination, not the camera backdrop: an indirect bounce must see + // hidden HDRIs, exactly as both NEE strata above do. Using the + // visible-only lookup here would drop the w_bsdf share of a hidden + // HDRI's energy, since all three techniques share one MIS partition. + // (The straight-through backdrop in raygen_helpers.h stays visible- + // only.) + if (getEnvironmentLight(frameData, bounceRay.dir, hdri)) { // Env MIS escape side: weight the BSDF-sampled escape by the same - // balance heuristic as the NEE loop (pLight = envPdf, no - // 1/numLights). A delta / through-surface lobe reports +inf => wBsdf - // = 1; here the bounce is reflection-only so nextRay.pdf is finite. - const float pLight = envPdf(frameData, bounceRay.dir); - const float wBsdf = - isinf(nextRay.pdf) ? 1.0f : nextRay.pdf / (nextRay.pdf + pLight); + // balance heuristic, and against the same two NEE densities, as the + // loop above — p_L is the folded CDF sum and p_C the cosine stratum, + // both evaluated at the surface that spawned this bounce. A delta / + // through-surface lobe reports +inf => wBsdf = 1; here the bounce is + // reflection-only so nextRay.pdf is finite. + const float pLight = + envFoldedHemiPdf(frameData, bounceRay.dir, hit.Ns); + const float pCosine = haveHdri + ? fmaxf(0.0f, dot(bounceRay.dir, hit.Ns)) * kInvPi + : 0.0f; + + const float wBsdf = isinf(nextRay.pdf) + ? 1.0f + : nextRay.pdf / (nextRay.pdf + pLight + pCosine); contrib += wBsdf * hdri * nextRay.contributionWeight; } } diff --git a/devices/rtx/device/renderer/Quality_ptx.cu b/devices/rtx/device/renderer/Quality_ptx.cu index 7dcaa3e1c..78cee633b 100644 --- a/devices/rtx/device/renderer/Quality_ptx.cu +++ b/devices/rtx/device/renderer/Quality_ptx.cu @@ -169,9 +169,10 @@ VISRTX_DEVICE size_t pickLightInstance(const WorldGPUData &world, float u) } // Discrete probability that pickLightInstance selected `idx`, folded with the -// ambient stratum so P(pick) sums to 1 across every pick candidate. lightPickDelta -// holds power_i normalized by the double cumulative total, so folding in -// totalLightPower/totalPower reweights it onto the ambient-inclusive partition. +// ambient stratum so P(pick) sums to 1 across every pick candidate. +// lightPickDelta holds power_i normalized by the double cumulative total, so +// folding in totalLightPower/totalPower reweights it onto the ambient-inclusive +// partition. VISRTX_DEVICE float instancePickProbability( const WorldGPUData &world, size_t idx, float totalPower) { @@ -182,22 +183,31 @@ VISRTX_DEVICE float instancePickProbability( return world.lightPickDelta[idx] * world.totalLightPower / totalPower; } -// Aggregate probability that the Light Pick lands on the HDRI environment. Both -// env-MIS sides fold this into the env light density. Falls back to the uniform -// stratum fraction when no light carries Pick Power (matching sampleLights). -VISRTX_DEVICE float envPickProbability(const FrameGPUData &frameData) +// The folded CDF technique picks one light, so its density is a mixture of +// per-instance densities weighted by each instance's discrete pick probability. +// Interactive samples every light instead and must not use this mixture. +VISRTX_DEVICE float envHemiPdf( + const FrameGPUData &fd, const vec3 &dir, const vec3 &ns) { - const auto &world = frameData.world; - const float ambientPower = ambientPickPower(frameData); + if (!(dot(dir, ns) > 0.0f)) + return 0.0f; + const auto &world = fd.world; + const float ambientPower = ambientPickPower(fd); const float totalPower = world.totalLightPower + ambientPower; - if (totalPower > 0.0f) - return world.hdriPower / totalPower; - // All-dark fallback: mirror sampleLights' uniform stratum count exactly - // (ambient counted iff it carries Pick Power) so both MIS sides agree. const size_t numStrata = world.numLightInstances + (ambientPower > 0.0f ? 1 : 0); - return numStrata > 0 ? float(world.numHdriLightInstances) / float(numStrata) - : 0.0f; + float pdf = 0.0f; + for (size_t i = 0; i < world.numHdriLightInstances; ++i) { + const auto &instance = world.hdriLightInstances[i]; + const auto &light = fd.registry.lights[instance.lightIndex]; + // Match World::appendLight's sanitization, as geometryLightHitPdf does. + const float power = lightPickPower(light, instance.xfm, world.sceneRadius); + const float pickProb = totalPower > 0.0f + ? ((power > 0.0f && isfinite(power)) ? power / totalPower : 0.0f) + : (numStrata > 0 ? 1.0f / float(numStrata) : 0.0f); + pdf += pickProb * hdriFoldedPdf(light, instance.xfm, dir, ns); + } + return pdf; } // NEE density a Geometry Light would report for a BSDF ray that hit it, for the @@ -531,19 +541,17 @@ VISRTX_GLOBAL void __raygen__() auto sampleContribution = vec3(1.0f); - // The environment (visible HDRI lights) is sampled both by NEE at every - // scatter vertex (HDRIs are in the light list) and by a BSDF ray that - // escapes to it. Balance-heuristic MIS combines the two: `bsdfPdf` carries - // the solid-angle pdf of the bounce that produced the current ray, so the - // miss can weight the escape estimator by bsdfPdf/(bsdfPdf + pLight). The + // The environment is sampled by env-CDF NEE, cosine-hemisphere NEE, and a + // BSDF ray that escapes to it. Balance-heuristic MIS combines all three: + // `bsdfPdf` is the solid-angle pdf of the bounce that produced the current + // ray; the miss weights the escape by bsdfPdf/(bsdfPdf + p_L + p_C). The // primary ray is a delta event (the directly visible backdrop), so it - // starts at +inf => w_bsdf = 1. + // starts at +inf => w_bsdf = 1. `lastScatterNs` is the shading normal of + // the surface that spawned the continuation, so p_C can be evaluated at + // the miss with the same function the NEE side uses. float bsdfPdf = INFINITY; - - // Probability the power-proportional Light Pick lands on the environment, - // matching sampleLights. Folded into the env light density on both MIS - // sides so wNee and wBsdf use identical pdf functions. - const float envPickProb = envPickProbability(frameData); + vec3 lastScatterNs(0.0f); + bool lastScatterWasSurface = false; // Coverage pass-throughs are not light-transport events, so they track a // separate, generous budget instead of spending bounceDepth — a deep stack @@ -641,6 +649,7 @@ VISRTX_GLOBAL void __raygen__() // point, so the continuation ray must not re-deposit it on a miss // (bsdfPdf = 0 => w_bsdf = 0). Env MIS for volumes is left as-is. bsdfPdf = 0.0f; + lastScatterWasSurface = false; ++bounceDepth; continue; } @@ -690,7 +699,13 @@ VISRTX_GLOBAL void __raygen__() shadingHitpoint(surfaceHit) + surfaceHit.Ng * surfaceHit.epsilon; const SurfaceLightSample lightPick = sampleLights(ss, frameData, shadowOrigin, surfaceHit.Ns); - const LightSample &lightSample = lightPick.ls; + LightSample lightSample = lightPick.ls; + if (lightPick.isEnv) { + if (!(dot(lightSample.dir, surfaceHit.Ns) > 0.0f)) + lightSample.dir = + reflectAcrossNormal(lightSample.dir, surfaceHit.Ns); + getEnvironmentLight(frameData, lightSample.dir, lightSample.radiance); + } // Positive-pdf gate (not an epsilon): a dim light's tiny pick // probability keeps NEE unbiased; an epsilon floor would drop it and // render it black in bright+dim scenes. @@ -700,56 +715,102 @@ VISRTX_GLOBAL void __raygen__() // into the lit/unlit boundary at grazing light angles. const float lightDotNs = dot(lightSample.dir, surfaceHit.Ns); if (lightDotNs > 0.0f) { - const vec3 directLight = materialShadeSurface( - shadingState, surfaceHit, lightSample, -ray.dir); - // Env MIS: only the HDRI environment can also be reached by the - // BSDF escape, so only it gets a balance-heuristic weight. The - // light density uses envPdf on BOTH sides (here and at the miss), - // not lightSample.pdf, so wNee and wBsdf use identical pdf - // functions and partition to 1 exactly — unbiased regardless of how - // closely envPdf tracks the NEE importance pdf (the NEE estimator - // still divides by its true lightSample.pdf, which carries the same - // envPickProb, inside materialShadeSurface). - // Other light types: p_bsdf = 0 => w_nee = 1 (behaviour unchanged). - float wNee = 1.0f; - if (lightPick.isEnv) { - const float pBsdf = - materialEvalPdf(shadingState, -ray.dir, lightSample.dir); - const float pLight = - envPdf(frameData, lightSample.dir) * envPickProb; - wNee = pLight / (pLight + pBsdf); - } else if (lightPick.isGeometry) { - // lightSample.pdf is the exact NEE density (solid-angle × pick - // probability); the BSDF continuation can also hit this Geometry - // Light, - // so weight against it. Mirrors geometryLightHitPdf on the - // deposit. - const float pBsdf = - materialEvalPdf(shadingState, -ray.dir, lightSample.dir); - wNee = lightSample.pdf / (lightSample.pdf + pBsdf); + // The material returns f*cos; this integrator owns the light, its + // pdf and the MIS weight. + const vec3 fCos = + materialEvalBsdf(shadingState, -ray.dir, lightSample.dir); + const float envPdfHemi = lightPick.isEnv + ? envHemiPdf(frameData, lightSample.dir, surfaceHit.Ns) + : 0.0f; + const float lightPdf = + lightPick.isEnv ? envPdfHemi : lightSample.pdf; + if (lightPdf > 0.0f) { + const vec3 directLight = fCos * lightSample.radiance / lightPdf; + // Env MIS: cosine-hemisphere NEE always runs when an HDRI exists. + // CDF NEE still runs only on an HDRI pick. p_C = cosθ/π. + // p_L = envHemiPdf (folded CDF). + float wNee = 1.0f; + if (lightPick.isEnv) { + const float pBsdf = + materialEvalPdf(shadingState, -ray.dir, lightSample.dir); + const float pLight = envPdfHemi; + const float pCosine = lightDotNs * kInvPi; + const float pSum = pLight + pBsdf + pCosine; + wNee = pSum > 0.0f ? pLight / pSum : 0.0f; + } else if (lightPick.isGeometry) { + // lightSample.pdf is the exact NEE density (solid-angle × pick + // probability); the BSDF continuation can also hit this + // Geometry Light, so weight against it. Mirrors + // geometryLightHitPdf on the deposit. + const float pBsdf = + materialEvalPdf(shadingState, -ray.dir, lightSample.dir); + wNee = lightSample.pdf / (lightSample.pdf + pBsdf); + } + const vec3 contribUpper = + wNee * sampleContribution * opacity * directLight; + const float maxContrib = glm::max( + contribUpper.x, glm::max(contribUpper.y, contribUpper.z)); + if (maxContrib >= SHADOW_SKIP_EPSILON) { + // A Geometry Light's sampled point lies on real, opaque + // geometry, so stop the shadow ray just short of it or it + // self-occludes on the emissive surface itself (~15% energy + // loss). Analytic lights have no geometry there and keep the + // exact distance. + const float shadowDist = lightPick.isGeometry + ? lightSample.dist * (1.0f - GEOMETRY_LIGHT_SHADOW_EPSILON) + : lightSample.dist; + const Ray shadowRay = { + shadowOrigin, + lightSample.dir, + {surfaceHit.epsilon, shadowDist}, + }; + ss.shadowContribWeight = glm::min(1.0f, maxContrib * 2.0f); + const auto attenuation = + surfaceShadowTransmittance(ss, shadowRay) + * volumeShadowTransmittance(ss, shadowRay); + ss.shadowContribWeight = 1.0f; + sample.color += contribUpper * attenuation; + } } - const vec3 contribUpper = - wNee * sampleContribution * opacity * directLight; - const float maxContrib = glm::max( - contribUpper.x, glm::max(contribUpper.y, contribUpper.z)); - if (maxContrib >= SHADOW_SKIP_EPSILON) { - // A Geometry Light's sampled point lies on real, opaque geometry, - // so stop the shadow ray just short of it or it self-occludes on - // the emissive surface itself (~15% energy loss). Analytic lights - // have no geometry there and keep the exact distance. - const float shadowDist = lightPick.isGeometry - ? lightSample.dist * (1.0f - GEOMETRY_LIGHT_SHADOW_EPSILON) - : lightSample.dist; - const Ray shadowRay = { - shadowOrigin, - lightSample.dir, - {surfaceHit.epsilon, shadowDist}, - }; - ss.shadowContribWeight = glm::min(1.0f, maxContrib * 2.0f); - const auto attenuation = surfaceShadowTransmittance(ss, shadowRay) - * volumeShadowTransmittance(ss, shadowRay); - ss.shadowContribWeight = 1.0f; - sample.color += contribUpper * attenuation; + } + } + + // Cosine-hemisphere env NEE: always, when the world has an HDRI — not + // only when Light Pick selected it. Matte has no continuation, so + // gating this on isEnv left most mixed-light pixels with zero env + // samples. p_C has no pick factor (the strategy always runs). p_L still + // carries each instance's pick probability because CDF NEE is + // pick-gated. + if (frameData.world.numHdriLightInstances > 0) { + const vec3 dirC = sampleHemisphere(ss.rs, surfaceHit.Ns); + const float cosC = fmaxf(0.0f, dot(dirC, surfaceHit.Ns)); + vec3 envRadiance; + if (cosC > 0.0f + && getEnvironmentLight(frameData, dirC, envRadiance)) { + const vec3 fCos = materialEvalBsdf(shadingState, -ray.dir, dirC); + const float pCosine = cosC * kInvPi; + const float pLight = envHemiPdf(frameData, dirC, surfaceHit.Ns); + const float pBsdf = materialEvalPdf(shadingState, -ray.dir, dirC); + const float pSum = pCosine + pLight + pBsdf; + if (pCosine > 0.0f && pSum > 0.0f) { + const float wC = pCosine / pSum; + const vec3 contribUpper = wC * sampleContribution * opacity * fCos + * envRadiance / pCosine; + const float maxContrib = glm::max( + contribUpper.x, glm::max(contribUpper.y, contribUpper.z)); + if (maxContrib >= SHADOW_SKIP_EPSILON) { + const Ray shadowRay = { + shadowOrigin, + dirC, + {surfaceHit.epsilon, std::numeric_limits::max()}, + }; + ss.shadowContribWeight = glm::min(1.0f, maxContrib * 2.0f); + const auto attenuation = + surfaceShadowTransmittance(ss, shadowRay) + * volumeShadowTransmittance(ss, shadowRay); + ss.shadowContribWeight = 1.0f; + sample.color += contribUpper * attenuation; + } } } } @@ -778,6 +839,9 @@ VISRTX_GLOBAL void __raygen__() if (shouldTerminatePath(ss, bounceDepth, sampleContribution, true)) break; + lastScatterNs = surfaceHit.Ns; + lastScatterWasSurface = true; + const float side = continuesThroughSurface(nextRay) ? -1.0f : 1.0f; ray = Ray{surfaceHit.hitpoint + surfaceHit.Ng * surfaceHit.epsilon * side, @@ -785,15 +849,22 @@ VISRTX_GLOBAL void __raygen__() } if (!surfaceHit.foundHit && !volumeSample.didScatter) { - // Deposit the environment, MIS-weighted against NEE. pLight mirrors the - // NEE env density: the HDRI importance pdf (envPdf) folded with the - // same power-proportional env pick probability sampleLights applied. - // bsdfPdf - // == +inf (delta / transmission / primary ray) => w_bsdf = 1. - if (vec3 hdri; getBackgroundLight(frameData, ray.dir, hdri)) { - const float pLight = envPdf(frameData, ray.dir) * envPickProb; - const float wBsdf = - isinf(bsdfPdf) ? 1.0f : bsdfPdf / (bsdfPdf + pLight); + // Only never-scattered rays see the visible-only backdrop. Coverage + // pass-throughs do not increment bounceDepth; reflection/transmission + // do, and must retain illumination from hidden HDRIs. + if (vec3 hdri; + getEnvironmentLight(frameData, ray.dir, hdri, bounceDepth == 0)) { + const float pLight = lastScatterWasSurface + ? envHemiPdf(frameData, ray.dir, lastScatterNs) + : 0.0f; + const float pCosine = (lastScatterWasSurface && !isinf(bsdfPdf)) + ? fmaxf(0.0f, dot(ray.dir, lastScatterNs)) * kInvPi + : 0.0f; + // Volume NEE owns its environment deposit (pdf=0); explicitly skip + // it even for an all-black map, rather than evaluating 0/0. + const float wBsdf = isinf(bsdfPdf) ? 1.0f + : bsdfPdf > 0.0f ? bsdfPdf / (bsdfPdf + pLight + pCosine) + : 0.0f; sample.color += wBsdf * sampleContribution * hdri; accumulateValue(sample.opacity, 1.f, sample.opacity); } diff --git a/devices/rtx/device/renderer/Renderer.cpp b/devices/rtx/device/renderer/Renderer.cpp index 3e14337eb..2d5a764ac 100644 --- a/devices/rtx/device/renderer/Renderer.cpp +++ b/devices/rtx/device/renderer/Renderer.cpp @@ -47,6 +47,7 @@ // std #include +#include #include #include // this include may only appear in a single source file: @@ -561,10 +562,10 @@ void Renderer::initOptixPipeline() + int(SurfaceShaderEntryPoints::EvaluateNormal)] = callableDesc; callableDesc.callables.entryFunctionNameDC = - "__direct_callable__shadeSurface"; + "__direct_callable__evalBsdf"; callableDescs[SBT_CALLABLE_MATTE_OFFSET - + int(SurfaceShaderEntryPoints::Shade)] = callableDesc; + + int(SurfaceShaderEntryPoints::EvalBsdf)] = callableDesc; callableDesc.callables.entryFunctionNameDC = "__direct_callable__evaluatePdf"; @@ -609,9 +610,9 @@ void Renderer::initOptixPipeline() + int(SurfaceShaderEntryPoints::EvaluateNormal)] = callableDesc; callableDesc.callables.entryFunctionNameDC = - "__direct_callable__shadeSurface"; + "__direct_callable__evalBsdf"; callableDescs[SBT_CALLABLE_PHYSICALLYBASED_OFFSET - + int(SurfaceShaderEntryPoints::Shade)] = callableDesc; + + int(SurfaceShaderEntryPoints::EvalBsdf)] = callableDesc; callableDesc.callables.entryFunctionNameDC = "__direct_callable__evaluatePdf"; @@ -955,12 +956,19 @@ void Renderer::initOptixPipeline() callableDescs.push_back(callableDesc); callableDesc.callables.entryFunctionNameDC = - "__direct_callable__shadeSurface"; + "__direct_callable__evalBsdf"; callableDescs.push_back(callableDesc); callableDesc.callables.entryFunctionNameDC = "__direct_callable__evaluatePdf"; callableDescs.push_back(callableDesc); + + // The pushes above are positional: slot N of this block must be the + // callable SurfaceShaderEntryPoints(N) names. Renaming an entry point + // without moving its push is otherwise silent -- the wrong callable + // sits at the right index and the material simply misbehaves. + assert(callableDescs.size() - mdlBaseOffset + == size_t(SurfaceShaderEntryPoints::Count)); } m_lastMDLMaterialLibraryUpdateCheck =