From cf2968dd7f39c0f0e65b5bb65832edbf39fd1391 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:54:43 +0900 Subject: [PATCH 01/25] fix reflected/refracted guides --- shaders/world/world.rgen.slang | 328 +++++++++++------- shaders/world/world_guide.rmiss.slang | 10 + .../comfyfluffy/caustica/rt/RtComposite.java | 3 +- 3 files changed, 215 insertions(+), 126 deletions(-) create mode 100644 shaders/world/world_guide.rmiss.slang diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 8f8e1247..7a29f6cb 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -63,6 +63,19 @@ static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit // World-space displacement of the motion-guide surface since the previous frame (0 for static // terrain/sky, an entity's per-vertex/object motion for a dynamic hit). static float3 gv_motionObjDisp; +// Transmission replaces the ordinary guide tuple with the visible destination. These four values retain +// the foreground glass/water interface for RR's separate specular inputs. +static float3 gv_specSurfaceCamRel; +static float3 gv_specSurfaceNormal; +static float gv_specSurfaceRoughness; +static float3 gv_specSurfaceAlbedo; + +void preserveSpecSurfaceGuide() { + gv_specSurfaceCamRel = gv_hitCamRel; + gv_specSurfaceNormal = gv_normal; + gv_specSurfaceRoughness = gv_rough; + gv_specSurfaceAlbedo = gv_specAlb; +} uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } uint payloadEmissionSource() { @@ -216,9 +229,11 @@ float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { return lerp(1.0, min(focus, CAUSTIC_MAX), fade); } -// GGX BRDF. Roughness is clamped away from 0 so the delta sun's specular highlight stays finite -// and the VNDF sample is well-conditioned. alpha = roughness^2 (perceptual -> GGX) throughout. -static const float MIN_ROUGH = 0.045; +// LabPBR smoothness is 8-bit and this renderer stores squared perceptual roughness. Half of the gap +// between the exact-zero code and the first nonzero code robustly identifies authored zero without +// turning merely glossy texels into delta lobes. +static const float AUTHORED_ZERO_ROUGHNESS_MAX = 0.5 / (255.0 * 255.0); +static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } @@ -760,6 +775,8 @@ static const uint TERRAIN_BUCKETS = 4u; static const uint SBT_RADIANCE = 0u; static const uint SBT_SHADOW = TERRAIN_BUCKETS; static const uint SBT_STRIDE_BUCKET = 1u; +static const uint MISS_RADIANCE = 0u; +static const uint MISS_GUIDE = 1u; RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { RayDesc r; @@ -807,12 +824,24 @@ void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, flo uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); Payload tracePayload = makeRadiancePayload(flags, rayCone); HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, - SBT_RADIANCE, SBT_STRIDE_BUCKET, 0u, makeRay(ro, tmin, rd, tmax), tracePayload); + SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, + makeRay(ro, tmin, rd, tmax), tracePayload); ReorderThread(hObj); payload = makeRadiancePayload(flags, rayCone); HitObject::Invoke(topLevelAS, hObj, payload); } +// Auxiliary RR probes deliberately use ordinary TraceRay. They are one-off, incoherent rays whose +// result is consumed immediately, so paying a reorder barrier and carrying guide state across it only +// adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, +// not sky radiance. +void traceGuide(uint rayFlags, uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + float rayConeWidth, float rayConeSpread) { + payload = makeRadiancePayload(0u, packHalf2(float2(rayConeWidth, rayConeSpread))); + TraceRay(topLevelAS, rayFlags, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, + MISS_GUIDE, makeRay(ro, tmin, rd, tmax), payload); +} + Payload makeShadowPayload() { Payload shadowPayload; shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); @@ -864,27 +893,21 @@ float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 ref return projectPrevNdc(mirroredHit); } -// Reflection (specular) motion vector for DLSS-RR. Covers smooth specular blocks (metals / LabPBR _s) -// and water reflections including underwater total-internal-reflection — any primary surface with a -// sharp specular lobe (specular albedo > 0, roughness <= 0.5). A sky miss is treated as a reflected -// hit at infinity along the reflected direction, so the same mirror-image reprojection handles surface -// and sky reflections uniformly (continuous as the reflected distance -> infinity). -float2 specularReflectionMotion(float3 surfacePos, float3 primaryDir, float2 currentNdc, float2 size, float primaryConeSpread) { - if (length(gv_normal) < 0.5 || max(gv_specAlb.r, max(gv_specAlb.g, gv_specAlb.b)) <= 0.001 || gv_rough > 0.5) { +// Reflection motion remains owned by the physical foreground interface even when transmission later +// replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). +float2 specularReflectionMotion(float3 surfacePos, float3 surfaceNormal, float surfaceRoughness, + float3 surfaceSpecularAlbedo, float3 primaryDir, + float2 currentNdc, float2 size, float primaryConeSpread) { + if (length(surfaceNormal) < 0.5 + || max(surfaceSpecularAlbedo.r, max(surfaceSpecularAlbedo.g, surfaceSpecularAlbedo.b)) <= 0.001 + || surfaceRoughness > 0.5) { return float2(0.0, 0.0); } - float3 n = normalize(gv_normal); + float3 n = normalize(surfaceNormal); float3 specDir = reflect(primaryDir, n); - // The bias is ONLY for the trace origin (so the reflection ray doesn't self-intersect the surface it - // leaves). The hit is then reconstructed from the TRUE surface point `surfacePos`, NOT the biased - // origin: reconstructing from `surfacePos + n*SURF_BIAS` bakes a constant `-n*SURF_BIAS` world offset - // into the mirrored virtual point, and that offset's screen projection blows up at grazing angles (n - // lies in the screen plane there) -> a non-zero MV in a static scene. Reconstructing from surfacePos - // cancels the term so the mirror lands exactly on the view ray (static MV = bit-exact zero) WITHOUT - // zeroing the trace bias the main path tracer needs to avoid surface acne. - traceRadianceReordered(CULL_SECONDARY, surfacePos + n * SURF_BIAS, RAY_TMIN, specDir, 10000.0, - false, + traceGuide(RAY_FLAG_NONE, CULL_SECONDARY, surfacePos + n * SURF_BIAS, + RAY_TMIN, specDir, 10000.0, max(length(surfacePos - worldPush.camOffset) * primaryConeSpread, RAY_CONE_MIN_WIDTH), max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); @@ -892,64 +915,89 @@ float2 specularReflectionMotion(float3 surfacePos, float3 primaryDir, float2 cur float3 reflectedMotionPrev; if (payload.hitT > 0.0) { reflectedHit = surfacePos + specDir * payload.hitT; - // Reflected water/fluid is a static dielectric (its chit MV lane is repurposed); only opaque - // geometry carries a real per-vertex displacement. Particles are off secondary rays (cull mask). - reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE ? payload.motionPrev : float3(0.0, 0.0, 0.0); + reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE + ? payload.motionPrev : float3(0.0, 0.0, 0.0); } else { - reflectedHit = surfacePos + specDir * 1.0e6; // sky reflection: virtual image at infinity along specDir + reflectedHit = surfacePos + specDir * 1.0e6; reflectedMotionPrev = float3(0.0, 0.0, 0.0); } float2 prevNdc = previousReflectionNdc(surfacePos, n, reflectedHit, reflectedMotionPrev); return (prevNdc - currentNdc) * 0.5 * size; } -// One-interface refraction for the water guide buffers: trace the refracted ray from the surface into -// (or out of) the water and report the content it lands on. Diffuse AND specular albedo, plus MV, then -// track that hit as its own feature (the water surface itself has no true diffuse/specular response of -// its own worth demodulating by — it's a clear interface), while depth stays on the water surface. -// `refracted` is false on total internal reflection (no refracted hit exists); a sky miss returns a far -// point along the refracted direction, which the self-delta MV handles uniformly. -void refractedGuideHit(float3 surfacePos, float3 incidentDir, float3 surfaceNormal, bool inWaterAtSurface, - float materialIor, - float rayConeWidth, float rayConeSpread, - out float3 hitCamRel, out float3 motionPrev, out bool refracted, out float3 diffuseAlbedo, - out float3 specAlbedo) { - hitCamRel = surfacePos - worldPush.camOffset; - motionPrev = float3(0.0, 0.0, 0.0); - refracted = false; - diffuseAlbedo = float3(0.0, 0.0, 0.0); - specAlbedo = float3(0.0, 0.0, 0.0); - - float etaI = inWaterAtSurface ? materialIor : 1.0; - float etaT = inWaterAtSurface ? 1.0 : materialIor; - float3 refractedDir = refract(incidentDir, surfaceNormal, etaI / etaT); - if (dot(refractedDir, refractedDir) <= 0.0) { - return; // total internal reflection: no refracted guide hit exists - } - refractedDir = normalize(refractedDir); - refracted = true; - - float3 ro = surfacePos - surfaceNormal * SURF_BIAS; - traceRadianceReordered(CULL_SECONDARY, ro, RAY_TMIN, refractedDir, 10000.0, - false, rayConeWidth, rayConeSpread); - if (payload.hitT < 0.0) { - hitCamRel = (ro + refractedDir * 1.0e6) - worldPush.camOffset; // sky through the surface, at infinity - motionPrev = float3(0.0, 0.0, 0.0); - diffuseAlbedo = SKY_DIFF_ALBEDO; // sky guide: keep radiance out of the demodulation albedo - specAlbedo = SKY_SPEC_ALBEDO; - return; - } +// Deterministic transmitted destination for clear-interface RR guides. The first interface has already +// been crossed. Continue through thin glass and water until opaque/particle content or sky is reached; +// TIR changes the walk into a reflected secondary ray instead of producing a false foreground guide. +void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, + float roughness, float3 diffuseAlbedo) { + gv_hitCamRel = hitCamRel; + gv_motionHitCamRel = hitCamRel; + gv_motionObjDisp = motionPrev; + gv_motionUseRefracted = true; + gv_normal = normal; + gv_rough = roughness; + gv_albedo = diffuseAlbedo; +} - float3 hitPos = ro + refractedDir * payload.hitT; - hitCamRel = hitPos - worldPush.camOffset; - uint material = payloadMaterial(); - motionPrev = material != MATERIAL_WATER ? payload.motionPrev : float3(0.0, 0.0, 0.0); - if (material == MATERIAL_OPAQUE) { - diffuseAlbedo = payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); - float3 f0 = payload.f0; - float rough = clamp(payloadRoughness(), MIN_ROUGH, 1.0); - float NoV = clamp(dot(payload.normal, -refractedDir), 0.0, 1.0); - specAlbedo = rrSpecularAlbedo(f0, rough * rough, NoV); +void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 surfaceNormal, + float currentIor, float3 transmissionFilter, float rayBias, + float rayConeWidth, float rayConeSpread) { + if (dot(transmittedDir, transmittedDir) <= 0.0) return; + + float3 direction = normalize(transmittedDir); + float3 ro = surfacePos + (dot(surfaceNormal, direction) >= 0.0 + ? surfaceNormal : -surfaceNormal) * rayBias; + uint guideVisibilityMask = CULL_PRIMARY; + currentIor = max(currentIor, 1.0); + + for (uint crossing = 0u; crossing < MAX_TRANSMISSION_GUIDE_HITS; ++crossing) { + traceGuide(RAY_FLAG_NONE, guideVisibilityMask, ro, RAY_TMIN, direction, 10000.0, + rayConeWidth, rayConeSpread); + if (payload.hitT <= 0.0) { + setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, + float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), + 1.0, SKY_DIFF_ALBEDO * transmissionFilter); + return; + } + + uint material = payloadMaterial(); + float3 interfacePos = ro + direction * payload.hitT; + if (material == MATERIAL_OPAQUE || material == MATERIAL_PARTICLE) { + float endpointRoughness = material == MATERIAL_PARTICLE + ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); + float3 endpointAlbedo = material == MATERIAL_PARTICLE + ? payload.albedo + : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); + setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, + payload.normal, endpointRoughness, endpointAlbedo * transmissionFilter); + return; + } + + float3 interfaceNormal = normalize(payload.normal); + if (material == MATERIAL_GLASS) { + // Main models glass as a collapsed thin sheet: no direction/IOR change, but each crossed + // pane contributes its authored tint exactly as the radiance path does. + transmissionFilter *= payload.albedo * clamp(payloadTransmission(), 0.0, 1.0); + ro = interfacePos - interfaceNormal * GLASS_TRANSMIT_BIAS; + continue; + } + if (material != MATERIAL_WATER) return; + + if ((worldPush.flags & 16u) != 0u) { + interfaceNormal = applyWaterWaves(interfaceNormal, + interfacePos.xz + worldPush.waterAnchor.xy, worldPush.waterParams.w); + } + float targetIor = payloadWaterEntering() ? max(payloadIor(), 1.0) : 1.0; + float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); + if (dot(nextDirection, nextDirection) <= 0.0) { + guideVisibilityMask = CULL_SECONDARY; + direction = normalize(reflect(direction, interfaceNormal)); + } else { + direction = normalize(nextDirection); + currentIor = targetIor; + } + ro = interfacePos + (dot(interfaceNormal, direction) >= 0.0 + ? interfaceNormal : -interfaceNormal) * SURF_BIAS; } } @@ -958,6 +1006,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint inout uint seed) { float3 L = float3(0.0, 0.0, 0.0); float3 throughput = float3(1.0, 1.0, 1.0); + bool captureGuides = sampleIndex == 0u; float rayConeWidth = 0.0; float rayConeSpread = max(primaryConeSpread, RAY_CONE_MIN_SPREAD); int maxBounces = int(worldPush.maxBounces); @@ -1001,7 +1050,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The // packed show-celestial flag decides whether this path segment may see the bright disc. float3 sky = payload.albedo; - if (bounce == 0) { // primary ray hit sky: guides describe "no surface" + if (bounce == 0 && captureGuides) { // primary ray hit sky: guides describe "no surface" gv_normal = float3(0.0, 0.0, 0.0); // Flat mid-grey albedo guide for sky (NOT the bright sky radiance): demodulating DLSS-RR // by the huge sun-disc radiance rings/artifacts around the sun. A constant albedo keeps @@ -1015,6 +1064,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = float3(0.0, 0.0, 0.0); // sky is static + preserveSpecSurfaceGuide(); } L += throughput * sky; // escaped to sky break; @@ -1031,7 +1081,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint float3 hitPos = ro + rd * payload.hitT; rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); uint material = payloadMaterial(); - if (bounce == 0) { + if (bounce == 0 && captureGuides) { gv_emission = payloadEmission(); gv_emissionSource = payloadEmissionSource(); } @@ -1047,15 +1097,19 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint float transmission = clamp(payloadTransmission(), 0.0, 1.0); float cosI = clamp(dot(-rd, n), 0.0, 1.0); float F = fresnelDielectric(cosI, 1.0, materialIor); - if (bounce == 0) { // primary glass hit: feed RR a smooth dielectric specular surface + if (bounce == 0 && captureGuides) { // primary glass hit: feed RR a smooth dielectric specular surface gv_normal = n; - gv_rough = clamp(payloadRoughness(), MIN_ROUGH, 1.0); + gv_rough = clamp(payloadRoughness(), 0.0, 1.0); gv_specAlb = float3(F, F, F); - gv_albedo = glassTint; // demodulate by the transmission tint + gv_albedo = float3(0.0, 0.0, 0.0); // coherent fallback when no transmitted layer exists gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; // 0 for static terrain + preserveSpecSurfaceGuide(); + + resolveTransmissionGuide(hitPos, rd, n, 1.0, glassTint * transmission, + GLASS_TRANSMIT_BIAS, rayConeWidth, rayConeSpread); } if (rndf(seed) < F) { rd = reflect(rd, n); @@ -1080,14 +1134,17 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. if (material == MATERIAL_PARTICLE) { float3 albedo = payload.albedo; - gv_normal = n; - gv_albedo = albedo; // RR demodulation target = the particle texel (keeps it sharp) - gv_specAlb = float3(0.0, 0.0, 0.0); - gv_rough = 1.0; - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; // per-particle MV: de-ghost moving particles + if (captureGuides) { + gv_normal = n; + gv_albedo = albedo; // RR demodulation target = the particle texel (keeps it sharp) + gv_specAlb = float3(0.0, 0.0, 0.0); + gv_rough = 1.0; + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = payload.motionPrev; // per-particle MV: de-ghost moving particles + preserveSpecSurfaceGuide(); + } float3 lightDir = worldPush.lightDir.xyz; float lightHalfAngle = worldPush.lightDir.w; @@ -1152,29 +1209,33 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint } waterExt = waterExtinction(payload.albedo); float cosI = clamp(dot(-rd, n), 0.0, 1.0); - if (bounce == 0) { // primary water hit: feed RR a smooth dielectric specular surface - gv_normal = n; - gv_rough = clamp(payloadRoughness(), MIN_ROUGH, 1.0); - gv_hitCamRel = hitPos - worldPush.camOffset; // depth + reflection start at the water surface - // Diffuse AND specular albedo + MV track the refracted hit (floor/shore seen through the - // surface) so refracted content stops ghosting; depth stays on the water surface. Water's - // own Fresnel reflectance is ignored here — a clear interface, not a demodulation target. - float3 refractedAlbedo; - float3 refractedSpecAlbedo; - refractedGuideHit(hitPos, rd, n, inWater, materialIor, rayConeWidth, rayConeSpread, - gv_motionHitCamRel, gv_motionObjDisp, - gv_motionUseRefracted, refractedAlbedo, refractedSpecAlbedo); - gv_albedo = refractedAlbedo; - gv_specAlb = refractedSpecAlbedo; - } float etaI = inWater ? materialIor : 1.0; float etaT = inWater ? 1.0 : materialIor; float F = fresnelDielectric(cosI, etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + if (bounce == 0 && captureGuides) { // primary water hit: feed RR a smooth dielectric specular surface + gv_normal = n; + gv_rough = clamp(payloadRoughness(), 0.0, 1.0); + gv_albedo = float3(0.0, 0.0, 0.0); // pure-specular fallback for TIR/no destination + gv_specAlb = float3(F, F, F); + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = float3(0.0, 0.0, 0.0); + preserveSpecSurfaceGuide(); + + if (dot(transmittedDir, transmittedDir) > 0.0) { + resolveTransmissionGuide(hitPos, transmittedDir, n, + materialEntering ? materialIor : 1.0, + float3(1.0, 1.0, 1.0), SURF_BIAS, + rayConeWidth, rayConeSpread); + } + } if (rndf(seed) < F) { rd = reflect(rd, n); ro = hitPos + n * SURF_BIAS; // reflected ray stays on the incidence side } else { - rd = refract(rd, n, etaI / etaT); // F < 1 here, so this is never total internal reflection + rd = transmittedDir; // F < 1 here, so this is never total internal reflection throughput *= transmission; ro = hitPos - n * SURF_BIAS; // transmitted ray crosses to the far side // Derived from this hit's face orientation, not toggled — self-corrects instead of a @@ -1201,12 +1262,13 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // Material split. Metals tint F0 by albedo and have no diffuse lobe; dielectrics use a fixed // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). - float rough = clamp(payloadRoughness(), MIN_ROUGH, 1.0); + float rough = clamp(payloadRoughness(), 0.0, 1.0); float metal = clamp(payloadMetalness(), 0.0, 1.0); + bool exactSpecular = rough <= AUTHORED_ZERO_ROUGHNESS_MAX; float3 diffAlb = albedo * (1.0 - metal); float3 F0 = payload.f0; - if (bounce == 0) { // primary-visibility surface: capture the denoiser guide buffers + if (bounce == 0 && captureGuides) { // primary-visibility surface: capture the denoiser guide buffers gv_normal = n; gv_albedo = diffAlb; // RR diffuse-albedo demodulation target gv_rough = rough; @@ -1215,6 +1277,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; // per-vertex world motion (0 for static terrain/sky) + preserveSpecSurfaceGuide(); } // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. @@ -1316,20 +1379,31 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their // relative reflectance. - float ps = clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); + float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 + ? 1.0 + : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); if (rndf(seed) < ps) { - float3 h = sampleGGXVNDF(n, v, rough, seed); - float3 l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal - float ndl2 = dot(n, l); - if (ndl2 <= 0.0) { - break; // microfacet reflects below the surface: terminate this path + float3 l; + if (exactSpecular) { + // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the + // degenerate VNDF at alpha=0 and preserves a genuinely sharp reflection without a floor. + l = reflect(rd, n); + throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; + } else { + float3 h = sampleGGXVNDF(n, v, rough, seed); + l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal + float ndl2 = dot(n, l); + if (ndl2 <= 0.0) { + break; // microfacet reflects below the surface: terminate this path + } + // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. + float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); + throughput *= F * ggxG1(ndl2, rough) / ps; + rayConeSpread = max(rayConeSpread, + rough * rough * RAY_CONE_GLOSSY_SPREAD_SCALE); } - // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. - float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); - throughput *= F * ggxG1(ndl2, rough) / ps; ro = p; rd = l; - rayConeSpread = max(rayConeSpread, rough * rough * RAY_CONE_GLOSSY_SPREAD_SCALE); showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc } else { throughput *= diffAlb / (1.0 - ps); @@ -1384,19 +1458,20 @@ void main() { } frameRadiance /= float(spp); + // Reflection remains owned by the foreground interface even when glass/water replaced the ordinary + // tuple with the transmitted destination. + float2 specMotion = specularReflectionMotion( + gv_specSurfaceCamRel + worldPush.camOffset, gv_specSurfaceNormal, + gv_specSurfaceRoughness, gv_specSurfaceAlbedo, + dir, jndc, size, rayConeSpread); + // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. - // Depth follows the primary surface (water included); only the MV tracks the refracted hit. + // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); gv_depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; - // Guide buffers: first-hit attributes for the denoiser / DLSS-RR (written every frame). - gNormal[pix] = float4(gv_normal, gv_rough); - gAlbedo[pix] = float4(gv_albedo, 1.0); - gDepth[pix] = gv_depth; - gSpecAlbedo[pix] = float4(gv_specAlb, 1.0); // specular albedo for RR demodulation - // Motion vector: reproject this guide point through the previous frame's view-projection. // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is @@ -1406,17 +1481,20 @@ void main() { float4 prevClip = mul(worldPush.prevViewProj, float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); float2 prevNdc = prevClip.xy / prevClip.w; float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc - // Water MV guide: track the one-refraction hit (the floor seen through the surface), not the water - // surface, so refracted content stops ghosting. That hit is its own feature: its screen motion is - // its own reprojection delta (prev - current projection) — bit-exact zero when static, no Snell - // solve needed. The constant per-frame refraction offset cancels out of prev - current. + // Transmitted content is its own feature: compare its previous and current projections rather than + // subtracting the primary interface's jittered NDC. if (gv_motionUseRefracted) { float4 curClipRefr = mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)); curNdc = curClipRefr.xy / curClipRefr.w; } float2 motion = (prevNdc - curNdc) * 0.5 * size; + + // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. + gNormal[pix] = float4(gv_normal, gv_rough); + gAlbedo[pix] = float4(gv_albedo, 1.0); + gDepth[pix] = gv_depth; gMotion[pix] = motion; - float2 specMotion = specularReflectionMotion(gv_hitCamRel + worldPush.camOffset, dir, jndc, size, rayConeSpread); + gSpecAlbedo[pix] = float4(gv_specSurfaceAlbedo, 1.0); gSpecMotion[pix] = specMotion; // Debug guide-buffer visualization: bypass accumulation and show a guide directly. @@ -1431,7 +1509,7 @@ void main() { } else if (pc.debugView == 4u) { dbg = float3(gv_rough, gv_rough, gv_rough); // roughness } else if (pc.debugView == 6u) { - dbg = gv_specAlb; // specular albedo + dbg = gv_specSurfaceAlbedo; // exact value bound to DLSS-RR } else if (pc.debugView == 7u) { dbg = float3(clamp(0.5 + specMotion * 0.05, 0.0, 1.0), 0.5); // reflection motion } else if (pc.debugView == 8u) { diff --git a/shaders/world/world_guide.rmiss.slang b/shaders/world/world_guide.rmiss.slang new file mode 100644 index 00000000..8f36faf5 --- /dev/null +++ b/shaders/world/world_guide.rmiss.slang @@ -0,0 +1,10 @@ +// Minimal miss record for reflection/refraction guide probes. These rays consume only hit-vs-miss +// state; fixed sky guide values are supplied by raygen, so running the atmosphere shader is wasted work. +import world_common; + +[shader("miss")] +void main(inout Payload payload) { + payload.hitT = -1.0; + payload.normal = half3(0.0h, 0.0h, 0.0h); + payload.flags = 0u; +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index de538743..8c8de17d 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -520,7 +520,8 @@ private RtPipeline ensureWorld(RtContext ctx) { if (worldPipeline == null) { bindlessTextureCapacity = RtEntityTextures.maxTextures(); worldPipeline = RtPipeline.create(ctx, RtDeviceBringup.worldRaygenShader(), - new String[]{"world.rmiss.spv"}, "world.rchit.spv", "world.rahit.spv", + new String[]{"world.rmiss.spv", "world_guide.rmiss.spv"}, + "world.rchit.spv", "world.rahit.spv", WorldPushConstantsData.BYTE_SIZE, true, GUIDE_COUNT, bindlessTextureCapacity, true); // Per-frame world data lives in this BDA ring; the pipeline pushes its address and hot fields. if (pushRing == null) { From 8ff4929875c3f6120fbb1796af43ea8a6c275894 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:55:38 +0900 Subject: [PATCH 02/25] fix water --- shaders/world/world.rgen.slang | 206 ++++++++++++++++-- shaders/world/world_common.slang | 2 +- .../comfyfluffy/caustica/rt/RtComposite.java | 17 +- 3 files changed, 199 insertions(+), 26 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 7a29f6cb..ea67a868 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -67,12 +67,21 @@ static float3 gv_motionObjDisp; // the foreground glass/water interface for RR's separate specular inputs. static float3 gv_specSurfaceCamRel; static float3 gv_specSurfaceNormal; +static float3 gv_specSurfacePreviousNormal; +static float3 gv_specSurfaceBiasNormal; static float gv_specSurfaceRoughness; static float3 gv_specSurfaceAlbedo; +// Per-call control/results for the primary dielectric estimator. Ordinary pixels still trace once; +// a primary glass/water hit traces both Fresnel continuations and recombines them exactly. +static uint pathPrimaryDielectricBranch; +static bool pathPrimaryDielectricHit; +static float pathPrimaryDielectricF; void preserveSpecSurfaceGuide() { gv_specSurfaceCamRel = gv_hitCamRel; gv_specSurfaceNormal = gv_normal; + gv_specSurfacePreviousNormal = gv_normal; + gv_specSurfaceBiasNormal = gv_normal; gv_specSurfaceRoughness = gv_rough; gv_specSurfaceAlbedo = gv_specAlb; } @@ -149,13 +158,20 @@ static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each oct static const float WAVE_G = 9.81; static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components -float2 waterWaveGrad(float2 p, float t) { +float waterWaveLodWeight(float wavelength, float footprint) { + float cyclesPerPixel = max(footprint, 0.0) / max(wavelength, 1.0e-4); + return 1.0 - smoothstep(0.25, 0.5, cyclesPerPixel); +} + +float2 waterWaveGrad(float2 p, float t, float footprint) { // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; t *= WAVE_SPEED; float2 g = float2(0.0, 0.0); float lambda = 14.0; for (int i = 0; i < WAVE_COUNT; i++) { + float lodWeight = waterWaveLodWeight(lambda, footprint); + if (lodWeight <= 0.0) break; float k = 2.0 * PI / lambda; float w = sqrt(WAVE_G * k); // deep-water dispersion: phase speed √(g/k) // Heading: deterministic irregular spread about the wind, widening down the spectrum. @@ -172,23 +188,80 @@ float2 waterWaveGrad(float2 p, float t) { } float S = 1.2 + 0.12 * float(i); // crest sharpness: the short chop is choppier float e = exp(S * (sin(ph) - 1.0)); - g += (Q[i] / k) * (S * cos(ph) * e) * dph; // a = Q/k; ∇h = a·S·cos φ·e^{S(sin φ−1)}·∇φ + g += lodWeight * (Q[i] / k) * (S * cos(ph) * e) * dph; lambda /= 1.5; } return g * WATER_WAVE_STRENGTH; } +float2 waterWaveGrad(float2 p, float t) { + return waterWaveGrad(p, t, 0.0); +} + +// Evaluate the current gradient and linearly rewind it to the previous phase in one spectrum walk. +// Frame deltas are bounded on the CPU, so this avoids a second set of trigonometric/exponential work +// while remaining accurate over the sub-frame interval used by reflection reprojection. +void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, + out float2 currentGrad, out float2 previousGrad) { + const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; + currentT *= WAVE_SPEED; + previousT *= WAVE_SPEED; + currentGrad = float2(0.0, 0.0); + float2 gradientDt = float2(0.0, 0.0); + float lambda = 14.0; + for (int i = 0; i < WAVE_COUNT; i++) { + float lodWeight = waterWaveLodWeight(lambda, footprint); + if (lodWeight <= 0.0) break; + float k = 2.0 * PI / lambda; + float w = sqrt(WAVE_G * k); + float spread = (0.15 + 0.11 * float(i)) * sin(float(i) * 2.4 + 1.3); + float cs = cos(spread), sn = sin(spread); + float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, + WAVE_WIND.x * sn + WAVE_WIND.y * cs); + float phase = k * dot(d, p) - w * currentT + 1.7 * float(i) * float(i); + float phaseDt = -w; + float2 dph = k * d; + float2 dphDt = float2(0.0, 0.0); + if (i < 4) { + float2 pd = float2(-d.y, d.x); + float meanderPhase = 0.4 * k * dot(pd, p) + 0.5 * w * currentT; + float meanderDt = 0.5 * w; + phase += WAVE_MEANDER * sin(meanderPhase); + phaseDt += WAVE_MEANDER * cos(meanderPhase) * meanderDt; + dph += (WAVE_MEANDER * cos(meanderPhase) * 0.4 * k) * pd; + dphDt = (-WAVE_MEANDER * sin(meanderPhase) * meanderDt * 0.4 * k) * pd; + } + float S = 1.2 + 0.12 * float(i); + float sinPhase = sin(phase); + float cosPhase = cos(phase); + float e = exp(S * (sinPhase - 1.0)); + float amplitude = lodWeight * (Q[i] / k) * S; + currentGrad += amplitude * cosPhase * e * dph; + gradientDt += amplitude * e + * (phaseDt * (-sinPhase + S * cosPhase * cosPhase) * dph + + cosPhase * dphDt); + lambda /= 1.5; + } + previousGrad = currentGrad - (currentT - previousT) * gradientDt; + currentGrad *= WATER_WAVE_STRENGTH; + previousGrad *= WATER_WAVE_STRENGTH; +} + // Perturb a (near-)horizontal water surface normal by the wave field. Flowing side faces (|n.y| small) // keep their flat geometric normal. `worldXZ` must be a world-stable coordinate (rebased hit + the pushed // anchor) so the ripples stay pinned in the world. `nGeo` is already oriented toward the viewer, so the // down-viewed underside (n.y < 0) takes the mirrored slope (-up). -float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t) { +float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { if (abs(nGeo.y) < 0.5) return nGeo; - float2 grad = waterWaveGrad(worldXZ, t); + float2 grad = waterWaveGrad(worldXZ, t, footprint); float3 up = normalize(float3(-grad.x, 1.0, -grad.y)); return nGeo.y >= 0.0 ? up : -up; } +float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t) { + return applyWaterWaves(nGeo, worldXZ, t, 0.0); +} + // ---- Water caustics. Sunlight refracting through the waved surface converges/diverges before it // reaches an underwater receiver; the analytic wave field makes the true focusing factor computable // instead of faked with a scrolling texture. Where the shadow ray of an underwater NEE vertex crossed @@ -234,6 +307,9 @@ float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { // turning merely glossy texels into delta lobes. static const float AUTHORED_ZERO_ROUGHNESS_MAX = 0.5 / (255.0 * 255.0); static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; +static const uint PRIMARY_DIELECTRIC_RANDOM = 0u; +static const uint PRIMARY_DIELECTRIC_TRANSMISSION = 1u; +static const uint PRIMARY_DIELECTRIC_REFLECTION = 2u; float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } @@ -787,6 +863,13 @@ RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { return r; } +float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, + float3 outgoingDirection, float bias) { + float3 sideNormal = dot(surfaceNormal, outgoingDirection) >= 0.0 + ? surfaceNormal : -surfaceNormal; + return position + sideNormal * bias; +} + // A radiance payload carrying only the two words the invoked hit/miss shader READS: `flags` // (show-celestial gate, consumed by world.rmiss) and the packed ray cone (consumed by world.rchit for // texture LOD). Every other member is an OUTPUT that world.rchit/world.rmiss write, so it carries no @@ -895,7 +978,9 @@ float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 ref // Reflection motion remains owned by the physical foreground interface even when transmission later // replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). -float2 specularReflectionMotion(float3 surfacePos, float3 surfaceNormal, float surfaceRoughness, +float2 specularReflectionMotion(float3 surfacePos, float3 surfaceNormal, + float3 surfaceBiasNormal, + float3 previousSurfaceNormal, float surfaceRoughness, float3 surfaceSpecularAlbedo, float3 primaryDir, float2 currentNdc, float2 size, float primaryConeSpread) { if (length(surfaceNormal) < 0.5 @@ -906,7 +991,8 @@ float2 specularReflectionMotion(float3 surfacePos, float3 surfaceNormal, float s float3 n = normalize(surfaceNormal); float3 specDir = reflect(primaryDir, n); - traceGuide(RAY_FLAG_NONE, CULL_SECONDARY, surfacePos + n * SURF_BIAS, + traceGuide(RAY_FLAG_NONE, CULL_SECONDARY, + offsetSurfaceOrigin(surfacePos, normalize(surfaceBiasNormal), specDir, SURF_BIAS), RAY_TMIN, specDir, 10000.0, max(length(surfacePos - worldPush.camOffset) * primaryConeSpread, RAY_CONE_MIN_WIDTH), max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); @@ -921,7 +1007,10 @@ float2 specularReflectionMotion(float3 surfacePos, float3 surfaceNormal, float s reflectedHit = surfacePos + specDir * 1.0e6; reflectedMotionPrev = float3(0.0, 0.0, 0.0); } - float2 prevNdc = previousReflectionNdc(surfacePos, n, reflectedHit, reflectedMotionPrev); + float3 previousN = length(previousSurfaceNormal) >= 0.5 + ? normalize(previousSurfaceNormal) : n; + float2 prevNdc = previousReflectionNdc( + surfacePos, previousN, reflectedHit, reflectedMotionPrev); return (prevNdc - currentNdc) * 0.5 * size; } @@ -939,14 +1028,15 @@ void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, gv_albedo = diffuseAlbedo; } -void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 surfaceNormal, +void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, + float3 surfaceBiasNormal, float currentIor, float3 transmissionFilter, float rayBias, float rayConeWidth, float rayConeSpread) { if (dot(transmittedDir, transmittedDir) <= 0.0) return; float3 direction = normalize(transmittedDir); - float3 ro = surfacePos + (dot(surfaceNormal, direction) >= 0.0 - ? surfaceNormal : -surfaceNormal) * rayBias; + float3 ro = offsetSurfaceOrigin( + surfacePos, normalize(surfaceBiasNormal), direction, rayBias); uint guideVisibilityMask = CULL_PRIMARY; currentIor = max(currentIor, 1.0); @@ -974,6 +1064,7 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 s } float3 interfaceNormal = normalize(payload.normal); + float3 interfaceBiasNormal = interfaceNormal; if (material == MATERIAL_GLASS) { // Main models glass as a collapsed thin sheet: no direction/IOR change, but each crossed // pane contributes its authored tint exactly as the radiance path does. @@ -984,8 +1075,11 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 s if (material != MATERIAL_WATER) return; if ((worldPush.flags & 16u) != 0u) { + float waterFootprint = rayConeWidth + / max(abs(dot(-direction, interfaceBiasNormal)), 0.2); interfaceNormal = applyWaterWaves(interfaceNormal, - interfacePos.xz + worldPush.waterAnchor.xy, worldPush.waterParams.w); + interfacePos.xz + worldPush.waterAnchor.xy, + worldPush.waterParams.w, waterFootprint); } float targetIor = payloadWaterEntering() ? max(payloadIor(), 1.0) : 1.0; float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); @@ -996,8 +1090,8 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 s direction = normalize(nextDirection); currentIor = targetIor; } - ro = interfacePos + (dot(interfaceNormal, direction) >= 0.0 - ? interfaceNormal : -interfaceNormal) * SURF_BIAS; + ro = offsetSurfaceOrigin( + interfacePos, interfaceBiasNormal, direction, SURF_BIAS); } } @@ -1006,6 +1100,8 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint inout uint seed) { float3 L = float3(0.0, 0.0, 0.0); float3 throughput = float3(1.0, 1.0, 1.0); + pathPrimaryDielectricHit = false; + pathPrimaryDielectricF = 0.0; bool captureGuides = sampleIndex == 0u; float rayConeWidth = 0.0; float rayConeSpread = max(primaryConeSpread, RAY_CONE_MIN_SPREAD); @@ -1097,6 +1193,10 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint float transmission = clamp(payloadTransmission(), 0.0, 1.0); float cosI = clamp(dot(-rd, n), 0.0, 1.0); float F = fresnelDielectric(cosI, 1.0, materialIor); + if (bounce == 0 && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM) { + pathPrimaryDielectricHit = true; + pathPrimaryDielectricF = F; + } if (bounce == 0 && captureGuides) { // primary glass hit: feed RR a smooth dielectric specular surface gv_normal = n; gv_rough = clamp(payloadRoughness(), 0.0, 1.0); @@ -1108,10 +1208,15 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_motionObjDisp = payload.motionPrev; // 0 for static terrain preserveSpecSurfaceGuide(); - resolveTransmissionGuide(hitPos, rd, n, 1.0, glassTint * transmission, + resolveTransmissionGuide(hitPos, rd, n, + 1.0, glassTint * transmission, GLASS_TRANSMIT_BIAS, rayConeWidth, rayConeSpread); } - if (rndf(seed) < F) { + bool chooseReflection = bounce == 0 + && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM + ? pathPrimaryDielectricBranch == PRIMARY_DIELECTRIC_REFLECTION + : rndf(seed) < F; + if (chooseReflection) { rd = reflect(rd, n); ro = hitPos + n * SURF_BIAS; // reflected ray stays on the incidence side } else { @@ -1202,10 +1307,28 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken // reflections, wobbling refraction. This water body's biome tint (carried in payload.albedo // for water hits) sets the absorption applied to the next submerged segment. + float3 surfaceWaterNormal = n; + float3 previousWaterNormal = n; if (waterWaves) { // World-stable wave domain: rebased hit XZ + the pushed anchor (terrain origin mod 4096) // reconstructs a world-pinned coordinate that survives the per-move terrain rebase. - n = applyWaterWaves(n, hitPos.xz + worldPush.waterAnchor.xy, worldPush.waterParams.w); + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth + / max(abs(dot(-rd, surfaceWaterNormal)), 0.2); + if (bounce == 0 && captureGuides && abs(surfaceWaterNormal.y) >= 0.5) { + float2 currentGrad; + float2 previousGrad; + waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, + worldPush.waterAnchor.z, waterFootprint, + currentGrad, previousGrad); + float orientation = surfaceWaterNormal.y >= 0.0 ? 1.0 : -1.0; + n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); + previousWaterNormal = orientation + * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); + } else { + n = applyWaterWaves(surfaceWaterNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } } waterExt = waterExtinction(payload.albedo); float cosI = clamp(dot(-rd, n), 0.0, 1.0); @@ -1213,9 +1336,13 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint float etaT = inWater ? 1.0 : materialIor; float F = fresnelDielectric(cosI, etaI, etaT); float3 transmittedDir = refract(rd, n, etaI / etaT); + if (bounce == 0 && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM) { + pathPrimaryDielectricHit = true; + pathPrimaryDielectricF = F; + } if (bounce == 0 && captureGuides) { // primary water hit: feed RR a smooth dielectric specular surface gv_normal = n; - gv_rough = clamp(payloadRoughness(), 0.0, 1.0); + gv_rough = 0.0; // transport above is an exact Fresnel interface, not finite GGX gv_albedo = float3(0.0, 0.0, 0.0); // pure-specular fallback for TIR/no destination gv_specAlb = float3(F, F, F); gv_hitCamRel = hitPos - worldPush.camOffset; @@ -1223,21 +1350,28 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_motionUseRefracted = false; gv_motionObjDisp = float3(0.0, 0.0, 0.0); preserveSpecSurfaceGuide(); + gv_specSurfacePreviousNormal = previousWaterNormal; + gv_specSurfaceBiasNormal = surfaceWaterNormal; if (dot(transmittedDir, transmittedDir) > 0.0) { - resolveTransmissionGuide(hitPos, transmittedDir, n, + resolveTransmissionGuide(hitPos, transmittedDir, surfaceWaterNormal, materialEntering ? materialIor : 1.0, float3(1.0, 1.0, 1.0), SURF_BIAS, rayConeWidth, rayConeSpread); } } - if (rndf(seed) < F) { + bool chooseReflection = bounce == 0 + && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM + ? pathPrimaryDielectricBranch == PRIMARY_DIELECTRIC_REFLECTION + : rndf(seed) < F; + if (chooseReflection) { rd = reflect(rd, n); - ro = hitPos + n * SURF_BIAS; // reflected ray stays on the incidence side + ro = offsetSurfaceOrigin(hitPos, surfaceWaterNormal, rd, SURF_BIAS); } else { + if (dot(transmittedDir, transmittedDir) <= 0.0) break; rd = transmittedDir; // F < 1 here, so this is never total internal reflection throughput *= transmission; - ro = hitPos - n * SURF_BIAS; // transmitted ray crosses to the far side + ro = offsetSurfaceOrigin(hitPos, surfaceWaterNormal, rd, SURF_BIAS); // Derived from this hit's face orientation, not toggled — self-corrects instead of a // stray/missing face corrupting the medium for the rest of the path (see PAYLOAD_WATER_ENTERING). inWater = materialEntering; @@ -1454,14 +1588,42 @@ void main() { float3 frameRadiance = float3(0.0, 0.0, 0.0); for (uint s = 0u; s < spp; s++) { seed = pcg(seed); // decorrelate per-sample paths (shared camera ray, no AA jitter yet) - frameRadiance += tracePath(origin, dir, rayConeSpread, uint2(pix), s, seed); + uint transmissionSeed = seed; + pathPrimaryDielectricBranch = PRIMARY_DIELECTRIC_TRANSMISSION; + float3 transmissionRadiance = + tracePath(origin, dir, rayConeSpread, uint2(pix), s, transmissionSeed); + + bool primaryDielectricHit = pathPrimaryDielectricHit; + float primaryDielectricF = pathPrimaryDielectricF; + float3 sampleRadiance = transmissionRadiance; + + if (primaryDielectricHit) { + uint reflectionSeed = seed ^ 0xa511e9b3u; + reflectionSeed = pcg(reflectionSeed); + pathPrimaryDielectricBranch = PRIMARY_DIELECTRIC_REFLECTION; + float3 reflectionRadiance = + tracePath(origin, dir, rayConeSpread, uint2(pix), s + spp, reflectionSeed); + + float dielectricF = saturate(primaryDielectricF); + sampleRadiance = + dielectricF * reflectionRadiance + + (1.0 - dielectricF) * transmissionRadiance; + seed = transmissionSeed ^ reflectionSeed; + seed = pcg(seed); + } else { + seed = transmissionSeed; + } + + frameRadiance += sampleRadiance; } + pathPrimaryDielectricBranch = PRIMARY_DIELECTRIC_RANDOM; frameRadiance /= float(spp); // Reflection remains owned by the foreground interface even when glass/water replaced the ordinary // tuple with the transmitted destination. float2 specMotion = specularReflectionMotion( gv_specSurfaceCamRel + worldPush.camOffset, gv_specSurfaceNormal, + gv_specSurfaceBiasNormal, gv_specSurfacePreviousNormal, gv_specSurfaceRoughness, gv_specSurfaceAlbedo, dir, jndc, size, rayConeSpread); diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index 566f0964..5be6c8c8 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -55,7 +55,7 @@ public struct WorldPush { public float4 sunUv; // vanilla sun sprite atlas rect (u0,v0,u1,v1) public float4 moonUv; // vanilla current-moon-phase sprite atlas rect public float4 waterParams; // xyz camera-biome water tint, w wave time (s) - public float4 waterAnchor; // xy world-stable wave-domain anchor + public float4 waterAnchor; // xy world-stable wave-domain anchor, z previous wave time public float4x4 curViewProj; // forward camera-relative view-projection public uint breakCount; public BreakEntry breaking[8]; // Java capacity and breakCount serialization are generated from this diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 8c8de17d..a6184e95 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -263,6 +263,8 @@ private static final class PushSlot { private float mvCamDeltaY; private float mvCamDeltaZ; private boolean mvHasPrev; + private float previousWaterWaveTime; + private boolean waterWaveTimeValid; private long atlasSampler; private boolean failed; private boolean loggedActive; @@ -737,6 +739,7 @@ private void ensureOutput(RtContext ctx, int width, int height) { exposure.ensureResources(ctx); mvHasPrev = false; // recreated images -> first MV frame is zero + waterWaveTimeValid = false; if (worldPipeline != null) { worldPipeline.setStorageImage(output.view); bindGuideImages(); @@ -834,13 +837,21 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo wtg = ((wc >> 8) & 0xFF) / 255f; wtb = (wc & 0xFF) / 255f; } - Float4 waterParams = new Float4(wtr, wtg, wtb, - (float) (System.nanoTime() / 1.0e9 % 3600.0)); + float waterWaveTime = (float) (System.nanoTime() / 1.0e9 % 3600.0); + float waterWaveDelta = waterWaveTime - previousWaterWaveTime; + // A first frame, long pause, or one-hour phase wrap has no adjacent wave frame to reproject. + // Use the current phase so the reflection MV is neutral instead of manufacturing a huge jump. + float priorWaterWaveTime = waterWaveTimeValid + && waterWaveDelta >= 0f && waterWaveDelta <= 0.25f + ? previousWaterWaveTime : waterWaveTime; + previousWaterWaveTime = waterWaveTime; + waterWaveTimeValid = true; + Float4 waterParams = new Float4(wtr, wtg, wtb, waterWaveTime); // W1 wave-domain anchor: the terrain rebase origin reduced mod 4096 (kept small for shader // float precision). hitPos.xz (rebased) + anchor reconstructs a world-pinned coordinate, so the // ripple pattern stays fixed in the world as the player moves and the rebase origin shifts. Float4 waterAnchor = new Float4(terrain.blockX & WATER_ANCHOR_MASK, - terrain.blockZ & WATER_ANCHOR_MASK, 0f, 0f); + terrain.blockZ & WATER_ANCHOR_MASK, priorWaterWaveTime, 0f); // Rebuild the TLAS this frame from static section instances merged with dynamic entity // instances, bind it into the pipeline's descriptor ring, record the build, then barrier so From 341d370066811b2e0f0b0d39eb3eda01ccea7fb3 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:17:29 +0900 Subject: [PATCH 03/25] Unify roughness on GGX alpha; guide/wave cleanups Two related passes over the pq branch's guide work. Roughness convention. LabPBR defines roughness = (1 - perceptualSmoothness)^2, which IS the GGX alpha, and DLSS-RR documents its guide input as linear roughness. The shader was squaring the stored value again, so the alpha actually shaded was (1 - s)^4 and every LabPBR-textured surface rendered sharper than authored. ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo now take alpha as-is. RtMaterials.Profile held perceptual values, so those are converted to alpha (identical shading for the profile path, only the units change); RtLabPbr was already correct and is unchanged. The mip filter's Toksvig widening moves to a plain linear average in alpha space, reproducing the alpha the old mixed-space sqrt round-trip produced. Thresholds that were written in perceptual space are converted, not relabelled: the reflection-MV gate becomes SPEC_MOTION_ALPHA_MAX = 0.25, and the delta-mirror threshold is MIRROR_ALPHA_MAX = 4e-4 (~12x tighter than the sun disc, so the lobe is unresolvable). Below it ggxD is exactly 0 and the sun arrives solely via the mirror ray hitting the disc, which is the correct accounting; above it authored roughness passes through unmodified, so there is no floor and no cliff. Restoring MIN_ROUGH is therefore unnecessary, but removing it had left the range between the old 8-bit authored-zero epsilon and 0.045 unguarded. ggxD's 1e-7 denominator guard is documented as load-bearing rather than fixed: it truncates the NEE highlight peak below alpha ~0.0135, which is what keeps the sun from being double-counted against the BSDF-sampled ray that also sees the disc (showCelestial, with no MIS between them). Cleanups, no behaviour change: * gv_specAlb (write-only) and the six gv_specSurface* statics collapse into one SpecSurface struct, dropping the call-then-patch at the water branch and taking specularReflectionMotion from 10 parameters to 5. * One waterWaveSpectrum walk behind a WITH_DT generic replaces the duplicated spectrum in waterWaveGradTemporal, so the Q table and wave constants exist once and the derivative terms compile out for the caustics caller. * Drop the dead applyWaterWaves 3-arg overload and traceGuide's always-NONE rayFlags; make tracePath's captureGuides an explicit parameter instead of encoding it as sampleIndex != 0; drop two redundant normalizes. Shaders compile and pass spirv-val; 34 tests pass. Not GPU-verified: LabPBR surfaces will read visibly rougher, which is the point of the convention fix. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 288 ++++++++++-------- .../caustica/rt/material/RtLabPbr.java | 4 + .../rt/material/RtMaterialOverrides.java | 2 + .../rt/material/RtMaterialRegistry.java | 2 +- .../rt/material/RtMaterialTextureData.java | 13 +- .../caustica/rt/material/RtMaterials.java | 22 +- 6 files changed, 183 insertions(+), 148 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index ea67a868..2c966b65 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -26,8 +26,9 @@ import world_common; // HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. [[vk::binding(1, 0)]] [format("rgba16f")] RWTexture2D outImage; // Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written -// every frame regardless of accumulation. gNormal.w carries roughness; gDepth carries HW reversed-Z depth. -[[vk::binding(3, 0)]] [format("rgba16f")] RWTexture2D gNormal; // xyz world normal, w roughness +// every frame regardless of accumulation. gNormal.w carries LINEAR roughness (= GGX alpha, which is what +// DLSS-RR documents for this input); gDepth carries HW reversed-Z depth. +[[vk::binding(3, 0)]] [format("rgba16f")] RWTexture2D gNormal; // xyz world normal, w linear roughness [[vk::binding(4, 0)]] [format("rgba16f")] RWTexture2D gAlbedo; // rgb diffuse albedo [[vk::binding(5, 0)]] [format("r32f")] RWTexture2D gDepth; // HW reversed-Z depth [[vk::binding(6, 0)]] [format("rg16f")] RWTexture2D gMotion; // screen-space motion (render px) @@ -51,7 +52,6 @@ struct VisibilityResult { // is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. static float3 gv_normal; static float3 gv_albedo; -static float3 gv_specAlb; // specular albedo fed to DLSS-RR for specular demodulation (0 = pure diffuse). static float gv_rough; static float gv_emission; static uint gv_emissionSource; @@ -63,29 +63,43 @@ static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit // World-space displacement of the motion-guide surface since the previous frame (0 for static // terrain/sky, an entity's per-vertex/object motion for a dynamic hit). static float3 gv_motionObjDisp; -// Transmission replaces the ordinary guide tuple with the visible destination. These four values retain -// the foreground glass/water interface for RR's separate specular inputs. -static float3 gv_specSurfaceCamRel; -static float3 gv_specSurfaceNormal; -static float3 gv_specSurfacePreviousNormal; -static float3 gv_specSurfaceBiasNormal; -static float gv_specSurfaceRoughness; -static float3 gv_specSurfaceAlbedo; +// Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs +// (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is +// the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. +struct SpecSurface { + float3 camRel; // interface position relative to the current camera + float3 normal; // shading normal (wave-perturbed for water) + float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates + float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface + float roughness; + float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) +}; +static SpecSurface gv_spec; + +// Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading +// normal now and last frame, flat geometric normal for the bias) via the six-argument form. +SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, float3 biasNormal, + float roughness, float3 albedo) { + SpecSurface s; + s.camRel = camRel; + s.normal = normal; + s.previousNormal = previousNormal; + s.biasNormal = biasNormal; + s.roughness = roughness; + s.albedo = albedo; + return s; +} + +SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { + return makeSpecSurface(camRel, normal, normal, normal, roughness, albedo); +} + // Per-call control/results for the primary dielectric estimator. Ordinary pixels still trace once; // a primary glass/water hit traces both Fresnel continuations and recombines them exactly. static uint pathPrimaryDielectricBranch; static bool pathPrimaryDielectricHit; static float pathPrimaryDielectricF; -void preserveSpecSurfaceGuide() { - gv_specSurfaceCamRel = gv_hitCamRel; - gv_specSurfaceNormal = gv_normal; - gv_specSurfacePreviousNormal = gv_normal; - gv_specSurfaceBiasNormal = gv_normal; - gv_specSurfaceRoughness = gv_rough; - gv_specSurfaceAlbedo = gv_specAlb; -} - uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } uint payloadEmissionSource() { return (payload.flags >> PAYLOAD_EMISSION_SOURCE_SHIFT) & PAYLOAD_EMISSION_SOURCE_MASK; @@ -94,6 +108,11 @@ uint payloadEmissionSource() { // travelling into the water volume (vs. out of it) at this hit's face. bool payloadWaterEntering() { return (payload.flags & PAYLOAD_WATER_ENTERING) != 0u; } bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } +// LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used +// end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly +// that, RtMaterials.Profile carries the same units, and DLSS-RR wants linear roughness in its guide. So +// nothing squares it: ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo all take this value as alpha as-is, and +// any threshold compared against it is an alpha threshold. float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } @@ -163,11 +182,16 @@ float waterWaveLodWeight(float wavelength, float footprint) { return 1.0 - smoothstep(0.25, 0.5, cyclesPerPixel); } -float2 waterWaveGrad(float2 p, float t, float footprint) { +// One walk of the wave spectrum, shared by every consumer so the spectrum constants exist once. WITH_DT +// is a generic so the derivative terms are compiled out entirely for callers that only want the +// gradient — the trigonometry is shared, but the chain-rule work is not free. +void waterWaveSpectrum(float2 p, float t, float footprint, + out float2 grad, out float2 gradDt) { // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; t *= WAVE_SPEED; - float2 g = float2(0.0, 0.0); + grad = float2(0.0, 0.0); + gradDt = float2(0.0, 0.0); float lambda = 14.0; for (int i = 0; i < WAVE_COUNT; i++) { float lodWeight = waterWaveLodWeight(lambda, footprint); @@ -180,18 +204,41 @@ float2 waterWaveGrad(float2 p, float t, float footprint) { float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, WAVE_WIND.x * sn + WAVE_WIND.y * cs); float ph = k * dot(d, p) - w * t + 1.7 * float(i) * float(i); float2 dph = k * d; + float phDt = -w; // ∂φ/∂t + float2 dphDt = float2(0.0, 0.0); // ∂(∇φ)/∂t if (i < 4) { // meander only the components whose fronts are long enough to read as lines float2 pd = float2(-d.y, d.x); float wph = 0.4 * k * dot(pd, p) + 0.5 * w * t; ph += WAVE_MEANDER * sin(wph); dph += (WAVE_MEANDER * cos(wph) * 0.4 * k) * pd; + if (WITH_DT != 0) { + float wphDt = 0.5 * w; + phDt += WAVE_MEANDER * cos(wph) * wphDt; + dphDt = (-WAVE_MEANDER * sin(wph) * wphDt * 0.4 * k) * pd; + } } float S = 1.2 + 0.12 * float(i); // crest sharpness: the short chop is choppier - float e = exp(S * (sin(ph) - 1.0)); - g += lodWeight * (Q[i] / k) * (S * cos(ph) * e) * dph; + float sinPh = sin(ph); + float cosPh = cos(ph); + float e = exp(S * (sinPh - 1.0)); + // a = Q/k; ∇h = a·S·cos φ·e^{S(sin φ−1)}·∇φ + float amplitude = lodWeight * (Q[i] / k) * S; + grad += amplitude * cosPh * e * dph; + if (WITH_DT != 0) { + // ∂/∂t of the term above, chain-ruled through φ and ∇φ. + gradDt += amplitude * e + * (phDt * (-sinPh + S * cosPh * cosPh) * dph + cosPh * dphDt); + } lambda /= 1.5; } - return g * WATER_WAVE_STRENGTH; + grad *= WATER_WAVE_STRENGTH; + gradDt *= WATER_WAVE_STRENGTH; +} + +float2 waterWaveGrad(float2 p, float t, float footprint) { + float2 grad, gradDt; + waterWaveSpectrum<0>(p, t, footprint, grad, gradDt); + return grad; } float2 waterWaveGrad(float2 p, float t) { @@ -203,48 +250,9 @@ float2 waterWaveGrad(float2 p, float t) { // while remaining accurate over the sub-frame interval used by reflection reprojection. void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, out float2 currentGrad, out float2 previousGrad) { - const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; - currentT *= WAVE_SPEED; - previousT *= WAVE_SPEED; - currentGrad = float2(0.0, 0.0); - float2 gradientDt = float2(0.0, 0.0); - float lambda = 14.0; - for (int i = 0; i < WAVE_COUNT; i++) { - float lodWeight = waterWaveLodWeight(lambda, footprint); - if (lodWeight <= 0.0) break; - float k = 2.0 * PI / lambda; - float w = sqrt(WAVE_G * k); - float spread = (0.15 + 0.11 * float(i)) * sin(float(i) * 2.4 + 1.3); - float cs = cos(spread), sn = sin(spread); - float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, - WAVE_WIND.x * sn + WAVE_WIND.y * cs); - float phase = k * dot(d, p) - w * currentT + 1.7 * float(i) * float(i); - float phaseDt = -w; - float2 dph = k * d; - float2 dphDt = float2(0.0, 0.0); - if (i < 4) { - float2 pd = float2(-d.y, d.x); - float meanderPhase = 0.4 * k * dot(pd, p) + 0.5 * w * currentT; - float meanderDt = 0.5 * w; - phase += WAVE_MEANDER * sin(meanderPhase); - phaseDt += WAVE_MEANDER * cos(meanderPhase) * meanderDt; - dph += (WAVE_MEANDER * cos(meanderPhase) * 0.4 * k) * pd; - dphDt = (-WAVE_MEANDER * sin(meanderPhase) * meanderDt * 0.4 * k) * pd; - } - float S = 1.2 + 0.12 * float(i); - float sinPhase = sin(phase); - float cosPhase = cos(phase); - float e = exp(S * (sinPhase - 1.0)); - float amplitude = lodWeight * (Q[i] / k) * S; - currentGrad += amplitude * cosPhase * e * dph; - gradientDt += amplitude * e - * (phaseDt * (-sinPhase + S * cosPhase * cosPhase) * dph - + cosPhase * dphDt); - lambda /= 1.5; - } - previousGrad = currentGrad - (currentT - previousT) * gradientDt; - currentGrad *= WATER_WAVE_STRENGTH; - previousGrad *= WATER_WAVE_STRENGTH; + float2 gradDt; + waterWaveSpectrum<1>(p, currentT, footprint, currentGrad, gradDt); + previousGrad = currentGrad - (currentT - previousT) * WAVE_SPEED * gradDt; } // Perturb a (near-)horizontal water surface normal by the wave field. Flowing side faces (|n.y| small) @@ -258,10 +266,6 @@ float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { return nGeo.y >= 0.0 ? up : -up; } -float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t) { - return applyWaterWaves(nGeo, worldXZ, t, 0.0); -} - // ---- Water caustics. Sunlight refracting through the waved surface converges/diverges before it // reaches an underwater receiver; the analytic wave field makes the true focusing factor computable // instead of faked with a scrolling texture. Where the shadow ray of an underwater NEE vertex crossed @@ -302,10 +306,26 @@ float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { return lerp(1.0, min(focus, CAUSTIC_MAX), fade); } -// LabPBR smoothness is 8-bit and this renderer stores squared perceptual roughness. Half of the gap -// between the exact-zero code and the first nonzero code robustly identifies authored zero without -// turning merely glossy texels into delta lobes. -static const float AUTHORED_ZERO_ROUGHNESS_MAX = 0.5 / (255.0 * 255.0); +// Below this GGX alpha the lobe is treated as a delta mirror. The threshold is not about detecting an +// authored zero — it is about the point where a finite lobe stops being distinguishable from a mirror. +// A GGX lobe's half-angle is ~alpha, and the sun's angular radius is ~0.0047 rad, so alpha = 4e-4 is +// ~12x tighter than the sun disc and far under a pixel footprint at any reflection distance. Nothing +// below it can be resolved, and the delta path is both sharper and better conditioned there. +// +// Two things follow from taking the delta path, and both are why the threshold is set here rather than +// at the 8-bit quantum: +// * ggxD(_, 0) is exactly 0, so the NEE specular term vanishes by construction and the sun arrives +// solely via the mirror ray hitting the disc (showCelestial). That is the correct, unbiased +// accounting — see the double-count note on ggxD below. +// * There is no floor to apply above the threshold: alpha > 4e-4 is well conditioned for both the +// VNDF sample and the NDF, so authored roughness passes through unmodified and the mapping stays +// continuous. A separate MIN_ROUGH would only reintroduce a cliff. +// +// Stored roughness IS alpha (see payloadRoughness), so these compare against it directly. +static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= 0.98 +// Above this the specular lobe is too broad for a single mirrored reprojection to describe, so the +// reflection MV is left at zero and RR falls back to the ordinary MV. +static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; static const uint PRIMARY_DIELECTRIC_RANDOM = 0u; static const uint PRIMARY_DIELECTRIC_TRANSMISSION = 1u; @@ -314,17 +334,23 @@ static const uint PRIMARY_DIELECTRIC_REFLECTION = 2u; float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } // GGX normal distribution. -float ggxD(float ndh, float rough) { - float a = rough * rough; - float a2 = a * a; +// NOTE: the 1e-7 denominator guard is not merely a divide-by-zero epsilon, and it is load-bearing. At +// the lobe peak d == a2, so the guard dominates PI*d*d until a2 >> 1.8e-4, i.e. rough >> 0.116; every +// smoother material has its NEE highlight peak truncated. That truncation is what keeps the sun from +// being counted twice: a specular continuation sets showCelestial, so the BSDF-sampled ray may hit the +// sun disc in world.rmiss while NEE is also adding a sun term for the same lobe, with no MIS weighting +// between them. Removing the guard restores the physical peak AND the double count, and makes the +// single-sample sun estimator explode wherever the lobe is tighter than the disc (~0.0047 rad). The +// real fix is MIS (or a disc-aware specular light sample), not an epsilon edit — left as-is. +float ggxD(float ndh, float alpha) { + float a2 = alpha * alpha; float d = ndh * ndh * (a2 - 1.0) + 1.0; return a2 / (PI * d * d + 1.0e-7); } // Smith GGX masking term for one direction (separable form; G2 = G1(v) * G1(l)). -float ggxG1(float ndx, float rough) { - float a = rough * rough; - float a2 = a * a; +float ggxG1(float ndx, float alpha) { + float a2 = alpha * alpha; return 2.0 * ndx / (ndx + sqrt(a2 + (1.0 - a2) * ndx * ndx) + 1.0e-7); } @@ -349,7 +375,7 @@ float hg(float cosT, float g) { } // NVIDIA RR guide-buffer helper: integrated/view-dependent specular reflectivity, not raw F0. -// alpha is GGX alpha (perceptual roughness squared), NoV is the first-hit view cosine. +// alpha is GGX alpha (== the linear roughness materials store), NoV is the first-hit view cosine. float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { NoV = abs(NoV); float NoV2 = NoV * NoV; @@ -454,14 +480,14 @@ float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { } // Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the -// sampled microfacet normal (half-vector) in world space. alpha = rough^2. Paired with the separable -// Smith term, the importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). -float3 sampleGGXVNDF(float3 n, float3 ve, float rough, inout uint s) { +// sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the +// importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). +float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); float3 b = normalize(cross(n, t)); t = cross(b, n); float3 Ve = float3(dot(ve, t), dot(ve, b), dot(ve, n)); // view in tangent space (z = n) - float a = rough * rough; + float a = alpha; float3 Vh = normalize(float3(a * Ve.x, a * Ve.y, Ve.z)); float lensq = Vh.x * Vh.x + Vh.y * Vh.y; float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); @@ -918,10 +944,10 @@ void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, flo // result is consumed immediately, so paying a reorder barrier and carrying guide state across it only // adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, // not sky radiance. -void traceGuide(uint rayFlags, uint cullMask, float3 ro, float tmin, float3 rd, float tmax, +void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, float rayConeWidth, float rayConeSpread) { payload = makeRadiancePayload(0u, packHalf2(float2(rayConeWidth, rayConeSpread))); - TraceRay(topLevelAS, rayFlags, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, + TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_GUIDE, makeRay(ro, tmin, rd, tmax), payload); } @@ -978,23 +1004,22 @@ float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 ref // Reflection motion remains owned by the physical foreground interface even when transmission later // replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). -float2 specularReflectionMotion(float3 surfacePos, float3 surfaceNormal, - float3 surfaceBiasNormal, - float3 previousSurfaceNormal, float surfaceRoughness, - float3 surfaceSpecularAlbedo, float3 primaryDir, +float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, float2 currentNdc, float2 size, float primaryConeSpread) { - if (length(surfaceNormal) < 0.5 - || max(surfaceSpecularAlbedo.r, max(surfaceSpecularAlbedo.g, surfaceSpecularAlbedo.b)) <= 0.001 - || surfaceRoughness > 0.5) { + if (length(surface.normal) < 0.5 + || max(surface.albedo.r, max(surface.albedo.g, surface.albedo.b)) <= 0.001 + || surface.roughness > SPEC_MOTION_ALPHA_MAX) { return float2(0.0, 0.0); } - float3 n = normalize(surfaceNormal); + float3 surfacePos = surface.camRel + worldPush.camOffset; + float3 n = normalize(surface.normal); float3 specDir = reflect(primaryDir, n); - traceGuide(RAY_FLAG_NONE, CULL_SECONDARY, - offsetSurfaceOrigin(surfacePos, normalize(surfaceBiasNormal), specDir, SURF_BIAS), + // biasNormal is a closest-hit normal, already unit length, and only its sign matters here. + traceGuide(CULL_SECONDARY, + offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), RAY_TMIN, specDir, 10000.0, - max(length(surfacePos - worldPush.camOffset) * primaryConeSpread, RAY_CONE_MIN_WIDTH), + max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); float3 reflectedHit; @@ -1007,8 +1032,8 @@ float2 specularReflectionMotion(float3 surfacePos, float3 surfaceNormal, reflectedHit = surfacePos + specDir * 1.0e6; reflectedMotionPrev = float3(0.0, 0.0, 0.0); } - float3 previousN = length(previousSurfaceNormal) >= 0.5 - ? normalize(previousSurfaceNormal) : n; + float3 previousN = length(surface.previousNormal) >= 0.5 + ? normalize(surface.previousNormal) : n; float2 prevNdc = previousReflectionNdc( surfacePos, previousN, reflectedHit, reflectedMotionPrev); return (prevNdc - currentNdc) * 0.5 * size; @@ -1035,13 +1060,13 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, if (dot(transmittedDir, transmittedDir) <= 0.0) return; float3 direction = normalize(transmittedDir); - float3 ro = offsetSurfaceOrigin( - surfacePos, normalize(surfaceBiasNormal), direction, rayBias); + // surfaceBiasNormal is a closest-hit normal, already unit length, and only its sign matters here. + float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); uint guideVisibilityMask = CULL_PRIMARY; currentIor = max(currentIor, 1.0); for (uint crossing = 0u; crossing < MAX_TRANSMISSION_GUIDE_HITS; ++crossing) { - traceGuide(RAY_FLAG_NONE, guideVisibilityMask, ro, RAY_TMIN, direction, 10000.0, + traceGuide(guideVisibilityMask, ro, RAY_TMIN, direction, 10000.0, rayConeWidth, rayConeSpread); if (payload.hitT <= 0.0) { setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, @@ -1097,12 +1122,11 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, // One Monte Carlo path from (ro, rd). Returns accumulated radiance along the path. float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint sampleIndex, - inout uint seed) { + bool captureGuides, inout uint seed) { float3 L = float3(0.0, 0.0, 0.0); float3 throughput = float3(1.0, 1.0, 1.0); pathPrimaryDielectricHit = false; pathPrimaryDielectricF = 0.0; - bool captureGuides = sampleIndex == 0u; float rayConeWidth = 0.0; float rayConeSpread = max(primaryConeSpread, RAY_CONE_MIN_SPREAD); int maxBounces = int(worldPush.maxBounces); @@ -1152,7 +1176,6 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // by the huge sun-disc radiance rings/artifacts around the sun. A constant albedo keeps // the brightness in the lighting channel where RR resolves it cleanly. gv_albedo = SKY_DIFF_ALBEDO; - gv_specAlb = SKY_SPEC_ALBEDO; gv_rough = 1.0; gv_emission = 0.0; gv_emissionSource = EMISSION_SOURCE_NONE; @@ -1160,7 +1183,8 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = float3(0.0, 0.0, 0.0); // sky is static - preserveSpecSurfaceGuide(); + // Zero normal: specularReflectionMotion rejects this and returns a zero reflection MV. + gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); } L += throughput * sky; // escaped to sky break; @@ -1200,13 +1224,12 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint if (bounce == 0 && captureGuides) { // primary glass hit: feed RR a smooth dielectric specular surface gv_normal = n; gv_rough = clamp(payloadRoughness(), 0.0, 1.0); - gv_specAlb = float3(F, F, F); gv_albedo = float3(0.0, 0.0, 0.0); // coherent fallback when no transmitted layer exists gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; // 0 for static terrain - preserveSpecSurfaceGuide(); + gv_spec = makeSpecSurface(gv_hitCamRel, n, gv_rough, float3(F, F, F)); resolveTransmissionGuide(hitPos, rd, n, 1.0, glassTint * transmission, @@ -1242,13 +1265,13 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint if (captureGuides) { gv_normal = n; gv_albedo = albedo; // RR demodulation target = the particle texel (keeps it sharp) - gv_specAlb = float3(0.0, 0.0, 0.0); gv_rough = 1.0; gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; // per-particle MV: de-ghost moving particles - preserveSpecSurfaceGuide(); + // Zero specular albedo: no reflection MV is traced for a diffuse billboard. + gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, float3(0.0, 0.0, 0.0)); } float3 lightDir = worldPush.lightDir.xyz; @@ -1344,14 +1367,14 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_normal = n; gv_rough = 0.0; // transport above is an exact Fresnel interface, not finite GGX gv_albedo = float3(0.0, 0.0, 0.0); // pure-specular fallback for TIR/no destination - gv_specAlb = float3(F, F, F); gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = float3(0.0, 0.0, 0.0); - preserveSpecSurfaceGuide(); - gv_specSurfacePreviousNormal = previousWaterNormal; - gv_specSurfaceBiasNormal = surfaceWaterNormal; + // The reflection reprojects off the wave-displaced normal (now and last frame) but must + // leave the surface along the flat geometric one. + gv_spec = makeSpecSurface(gv_hitCamRel, n, previousWaterNormal, surfaceWaterNormal, + 0.0, float3(F, F, F)); if (dot(transmittedDir, transmittedDir) > 0.0) { resolveTransmissionGuide(hitPos, transmittedDir, surfaceWaterNormal, @@ -1398,7 +1421,11 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). float rough = clamp(payloadRoughness(), 0.0, 1.0); float metal = clamp(payloadMetalness(), 0.0, 1.0); - bool exactSpecular = rough <= AUTHORED_ZERO_ROUGHNESS_MAX; + // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. + // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the + // reflection sharp instead of accumulating it as a glossy lobe. + bool exactSpecular = rough <= MIRROR_ALPHA_MAX; + rough = exactSpecular ? 0.0 : rough; float3 diffAlb = albedo * (1.0 - metal); float3 F0 = payload.f0; @@ -1406,12 +1433,12 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_normal = n; gv_albedo = diffAlb; // RR diffuse-albedo demodulation target gv_rough = rough; - gv_specAlb = rrSpecularAlbedo(F0, rough * rough, dot(n, v)); gv_hitCamRel = ro + rd * payload.hitT - worldPush.camOffset; // camera-relative hit position gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; // per-vertex world motion (0 for static terrain/sky) - preserveSpecSurfaceGuide(); + gv_spec = makeSpecSurface(gv_hitCamRel, n, rough, + rrSpecularAlbedo(F0, rough, dot(n, v))); } // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. @@ -1533,8 +1560,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); throughput *= F * ggxG1(ndl2, rough) / ps; - rayConeSpread = max(rayConeSpread, - rough * rough * RAY_CONE_GLOSSY_SPREAD_SCALE); + rayConeSpread = max(rayConeSpread, rough * RAY_CONE_GLOSSY_SPREAD_SCALE); } ro = p; rd = l; @@ -1590,8 +1616,10 @@ void main() { seed = pcg(seed); // decorrelate per-sample paths (shared camera ray, no AA jitter yet) uint transmissionSeed = seed; pathPrimaryDielectricBranch = PRIMARY_DIELECTRIC_TRANSMISSION; - float3 transmissionRadiance = - tracePath(origin, dir, rayConeSpread, uint2(pix), s, transmissionSeed); + // Guides describe one deterministic surface, so only the first sample's transmission branch + // writes them; the reflection branch and every later sample reuse what it captured. + float3 transmissionRadiance = tracePath(origin, dir, rayConeSpread, uint2(pix), s, + s == 0u, transmissionSeed); bool primaryDielectricHit = pathPrimaryDielectricHit; float primaryDielectricF = pathPrimaryDielectricF; @@ -1601,8 +1629,8 @@ void main() { uint reflectionSeed = seed ^ 0xa511e9b3u; reflectionSeed = pcg(reflectionSeed); pathPrimaryDielectricBranch = PRIMARY_DIELECTRIC_REFLECTION; - float3 reflectionRadiance = - tracePath(origin, dir, rayConeSpread, uint2(pix), s + spp, reflectionSeed); + float3 reflectionRadiance = tracePath(origin, dir, rayConeSpread, uint2(pix), s + spp, + false, reflectionSeed); float dielectricF = saturate(primaryDielectricF); sampleRadiance = @@ -1621,11 +1649,7 @@ void main() { // Reflection remains owned by the foreground interface even when glass/water replaced the ordinary // tuple with the transmitted destination. - float2 specMotion = specularReflectionMotion( - gv_specSurfaceCamRel + worldPush.camOffset, gv_specSurfaceNormal, - gv_specSurfaceBiasNormal, gv_specSurfacePreviousNormal, - gv_specSurfaceRoughness, gv_specSurfaceAlbedo, - dir, jndc, size, rayConeSpread); + float2 specMotion = specularReflectionMotion(gv_spec, dir, jndc, size, rayConeSpread); // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer @@ -1656,7 +1680,7 @@ void main() { gAlbedo[pix] = float4(gv_albedo, 1.0); gDepth[pix] = gv_depth; gMotion[pix] = motion; - gSpecAlbedo[pix] = float4(gv_specSurfaceAlbedo, 1.0); + gSpecAlbedo[pix] = float4(gv_spec.albedo, 1.0); gSpecMotion[pix] = specMotion; // Debug guide-buffer visualization: bypass accumulation and show a guide directly. @@ -1671,7 +1695,7 @@ void main() { } else if (pc.debugView == 4u) { dbg = float3(gv_rough, gv_rough, gv_rough); // roughness } else if (pc.debugView == 6u) { - dbg = gv_specSurfaceAlbedo; // exact value bound to DLSS-RR + dbg = gv_spec.albedo; // exact value bound to DLSS-RR } else if (pc.debugView == 7u) { dbg = float3(clamp(0.5 + specMotion * 0.05, 0.0, 1.0), 0.5); // reflection motion } else if (pc.debugView == 8u) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtLabPbr.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtLabPbr.java index 1f984141..93b3eb29 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtLabPbr.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtLabPbr.java @@ -35,6 +35,10 @@ private RtLabPbr() { public static Specular decodeSpec(float red, float green, float blue, float alpha, float albedoR, float albedoG, float albedoB) { float smoothness = clamp01(red); + // LabPBR red is perceptual smoothness; roughness = (1 - perceptualSmoothness)^2 is LabPBR's + // LINEAR roughness, which is GGX alpha. That is the convention the whole pipeline uses (see + // RtMaterials.Profile and world.rgen's payloadRoughness), so it is stored as-is and never squared + // again downstream. float roughness = (1.0f - smoothness) * (1.0f - smoothness); float g = clamp01(green) * 255.0f; float metalness; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java index c94b2cd0..698270a9 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java @@ -69,6 +69,8 @@ static Rule parse(JsonObject root, Identifier source) { Float metalness = null; if (root.has("base")) { JsonObject base = root.getAsJsonObject("base"); + // "roughness" is LINEAR roughness (GGX alpha), the same units LabPBR stores and + // RtMaterials.Profile carries — NOT perceptual roughness. alpha = (1 - smoothness)^2. roughness = optionalFloat(base, "roughness"); metalness = optionalFloat(base, "metalness"); } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java index 1566e677..1666a2fb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java @@ -424,7 +424,7 @@ private static RtMaterialDesc.EmissionSummary variantSummary(int features, boole private static RtMaterialDesc compileDesc(int model, int features, RtMaterials.Profile profile, boolean emitting, boolean neutral, RtMaterialDesc.EmissionSummary emissionSummary) { - float roughness = model == MODEL_GLASS ? 0.05f : profile.roughness(); + float roughness = model == MODEL_GLASS ? 0.0025f : profile.roughness(); // linear; s = 0.95 float metalness = model == MODEL_GLASS ? 0.0f : profile.metalness(); float ior = model == MODEL_WATER ? 1.333f : (model == MODEL_GLASS ? 1.52f : 1.0f); float transmission = model == MODEL_WATER || model == MODEL_GLASS ? 1.0f : 0.0f; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialTextureData.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialTextureData.java index 90986096..a0850bb5 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialTextureData.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialTextureData.java @@ -48,7 +48,7 @@ static List mipChain(Level base, int maxLevel) { /** * Reduce already-decoded physical channels. Emission is energy-averaged, normals are averaged then * renormalized, and lost normal length raises roughness so distant normal detail does not become a - * falsely smooth surface. + * falsely smooth surface. Roughness is linear (GGX alpha) throughout — see {@link RtLabPbr}. */ static Level reduce(Level src) { int width = Math.max(1, (src.width + 1) / 2); @@ -58,7 +58,7 @@ static Level reduce(Level src) { float[] surface1 = new float[surface0.length]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { - float roughSq = 0.0f; + float alphaSum = 0.0f; float metal = 0.0f, emission = 0.0f, sss = 0.0f; float nx = 0.0f, ny = 0.0f, nz = 0.0f, ao = 0.0f, heightValue = 0.0f; float f0r = 0.0f, f0g = 0.0f, f0b = 0.0f, transmission = 0.0f; @@ -70,8 +70,7 @@ static Level reduce(Level src) { int sx = x * 2 + ox; if (sx >= src.width) continue; int si = (sy * src.width + sx) * CHANNELS; - float rough = src.surface0[si]; - roughSq += rough * rough; + alphaSum += src.surface0[si]; metal += src.surface0[si + 1]; emission += src.surface0[si + 2]; sss += src.surface0[si + 3]; @@ -105,8 +104,10 @@ static Level reduce(Level src) { } int di = (y * width + x) * CHANNELS; // Toksvig-style variance term. This is intentionally conservative and monotonic. - surface0[di] = clamp01((float) Math.sqrt(Math.min(1.0f, - roughSq * inv + Math.max(0.0f, 1.0f - normalLength)))); + // Both terms are in linear-roughness (GGX alpha) space, which is where normal-map + // variance adds: alpha behaves like a variance, so averaging and widening are both + // plain sums here — no perceptual round-trip. + surface0[di] = clamp01(alphaSum * inv + Math.max(0.0f, 1.0f - normalLength)); surface0[di + 1] = clamp01(metal * inv); surface0[di + 2] = clamp01(emission * inv); surface0[di + 3] = clamp01(sss * inv); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterials.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterials.java index 9311160f..a0c67138 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterials.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterials.java @@ -18,12 +18,16 @@ public enum Profile { // The first four are the sprite-classifiable profiles ({@link #profile} can only return these); // RtMaterialRegistry's variant index depends on their ordinals (checked at its class load). // WATER/LAVA exist only for the dedicated fluid singleton headers. - DEFAULT(0.9f, 0.0f), - METAL(0.3f, 1.0f), - GLASS(0.1f, 0.0f), - SMOOTH(0.35f, 0.0f), - WATER(0.08f, 0.0f), - LAVA(0.7f, 0.0f); + // + // Roughness is LINEAR (GGX alpha), matching LabPBR's roughness = (1 - perceptualSmoothness)^2 and + // what DLSS-RR wants in its guide. The comment after each value is the perceptual smoothness it + // corresponds to, which is the easier number to reason about when re-tuning: alpha = (1 - s)^2. + DEFAULT(0.81f, 0.0f), // s = 0.10 + METAL(0.09f, 1.0f), // s = 0.70 + GLASS(0.01f, 0.0f), // s = 0.90 + SMOOTH(0.1225f, 0.0f), // s = 0.65 + WATER(0.0064f, 0.0f), // s = 0.92 + LAVA(0.49f, 0.0f); // s = 0.30 private final float roughness; private final float metalness; @@ -46,8 +50,8 @@ public float metalness() { public static final float WATER_ROUGH = Profile.WATER.roughness(); /** Lava: opaque emitter, moderately rough. */ public static final float LAVA_ROUGH = Profile.LAVA.roughness(); - /** Default entity roughness. */ - public static final float ENTITY_ROUGH = 0.8f; + /** Default entity roughness (linear; s = 0.11). */ + public static final float ENTITY_ROUGH = 0.64f; private static final Set SMOOTH = Set.of( Blocks.QUARTZ_BLOCK, Blocks.SMOOTH_QUARTZ, Blocks.QUARTZ_BRICKS, Blocks.QUARTZ_PILLAR, @@ -56,7 +60,7 @@ public float metalness() { Blocks.POLISHED_DEEPSLATE, Blocks.POLISHED_BLACKSTONE, Blocks.PRISMARINE, Blocks.PRISMARINE_BRICKS, Blocks.DARK_PRISMARINE); - /** Perceptual roughness for this block's surface. */ + /** Linear roughness (GGX alpha) for this block's surface. */ public static float roughness(BlockState state) { return profile(state).roughness(); } From c22ebf3a710c3b8ea35d68b069640e96bbfa18f9 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:12:30 +0900 Subject: [PATCH 04/25] Unify water/glass behind one dielectric interface with a medium stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Water and glass were two branches implementing the same physics with different approximations: water refracted and tracked `inWater` + a single `waterExt`, glass was a thin tint multiply, and neither could express the other. Refraction also could not ask what the ray was travelling THROUGH, only what it hit, so `refract` was always fed `inWater ? ior : 1.0` and glass->water or nested water were inexpressible. One handler now covers both. What varies is per material, not per branch: * volume (PAYLOAD_DIELECTRIC_VOLUME) refracts and pushes a participating medium, so the segment inside is attenuated by that medium's extinction. * thin is a collapsed slab whose two interfaces cancel: no bend, no medium, tint applied once per crossing. Both faces still see the material's index, so the Fresnel glint is unchanged. * water additionally takes the wave normal and marks its medium for caustics. The medium stack is depth 2 in named fields, not an array: a dynamically indexed local would land in scratch memory and this raygen is already register-bound. Depth 2 covers air->water->glass and air->glass->water; deeper nesting degrades to air on the way out, and since `entering` is re-derived per face from geometry rather than toggled, the path re-synchronises at the next crossing. Beer-Lambert now reads the current medium's extinction, so it applies inside ANY volume dielectric rather than only water, and PAYLOAD_WATER_ENTERING generalises to PAYLOAD_DIELECTRIC_ENTERING (set for glass too, which it never was). IOR and thin/volume become per-material instead of per-model. RtDielectrics holds the built-in table: ice/packed/blue/frosted ice refract at 1.309 as volumes, everything else translucent falls back to thin soda-lime 1.52, and water keeps 1.333. Ice previously refracted like window glass. The table is sprite-keyed and resolved ONCE per sprite, so it adds no variants to the profile x glass x emitting cross product. `transmission.volume` in a material JSON overrides the classification either way; transmission.ior already existed. Glass blocks stay thin deliberately. Geometrically they are cubes, but a solid refracting cube makes a window unreadable, so vanilla-style glass keeps the collapsed-slab look while ice — which reads as a solid block of frozen water — becomes a volume. Shaders compile and pass spirv-val; 39 tests pass. NOT GPU-verified. Next: move the deterministic Fresnel split inside the bounce loop so it applies at every diffuseDepth == 0 dielectric rather than only bounce 0, which also lets resolveTransmissionGuide be deleted in favour of capturing guides on the transmission branch the path already traces. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rchit.slang | 28 +- shaders/world/world.rgen.slang | 324 ++++++++++-------- shaders/world/world_common.slang | 19 +- .../caustica/rt/material/RtDielectrics.java | 73 ++++ .../rt/material/RtMaterialOverrides.java | 23 +- .../rt/material/RtMaterialRegistry.java | 30 +- .../rt/material/RtDielectricsTest.java | 43 +++ .../rt/material/RtMaterialOverridesTest.java | 30 ++ 8 files changed, 409 insertions(+), 161 deletions(-) create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java diff --git a/shaders/world/world.rchit.slang b/shaders/world/world.rchit.slang index 264ffc2b..38578231 100644 --- a/shaders/world/world.rchit.slang +++ b/shaders/world/world.rchit.slang @@ -25,6 +25,16 @@ void payloadSetPacked(inout Payload payload, uint material, float roughness, flo payload.iorTransmission = packHalf2(float2(ior, transmission)); } +// Dielectric side/kind bits, shared by every hit path. `entering` comes from the face orientation, so it +// is re-derived at each crossing instead of toggled (see PAYLOAD_DIELECTRIC_ENTERING). +void payloadSetDielectric(inout Payload payload, uint material, MaterialHeader header, bool entering) { + if (material != MATERIAL_WATER && material != MATERIAL_GLASS) return; + if (entering) payload.flags |= PAYLOAD_DIELECTRIC_ENTERING; + if ((header.features & MATERIAL_FEATURE_DIELECTRIC_VOLUME) != 0u) { + payload.flags |= PAYLOAD_DIELECTRIC_VOLUME; + } +} + uint materialEmissionSource(MaterialHeader header, float emission) { if (emission <= 0.0) return EMISSION_SOURCE_NONE; if ((header.features & MATERIAL_FEATURE_SPEC) != 0u) return EMISSION_SOURCE_LAB_PBR; @@ -315,9 +325,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, material, surface.roughness, surface.metalness, emission, sss, header.params.z, header.params.w, materialEmissionSource(header, emission)); - if (material == MATERIAL_WATER && entering) { - payload.flags |= PAYLOAD_WATER_ENTERING; - } + payloadSetDielectric(payload, material, header, entering); return; } @@ -333,10 +341,11 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) float3 tint = pr.tint.rgb; // Water prims are single-sided, wound outward from the fluid volume (RtFluidMesher), so the - // UNFLIPPED geometric normal is a reliable "which side is water" signal: a ray travelling against - // it is entering the volume, one travelling with it is exiting. Captured before the toward-viewer - // flip below and carried in the payload so raygen can set inWater from hit orientation instead of - // toggling parity per crossing (a single missing/extra face no longer desyncs the rest of the path). + // UNFLIPPED geometric normal is a reliable "which side of the dielectric" signal: a ray travelling + // against it is entering the volume, one travelling with it is exiting. Captured before the + // toward-viewer flip below and carried in the payload so raygen derives the medium from hit + // orientation instead of toggling parity per crossing (a single missing/extra face no longer + // desyncs the rest of the path). bool entering = dot(WorldRayDirection(), n) < 0.0; // Lever B: per-triangle corner UVs in primitive order. uvs[3*pid + k] is a contiguous, @@ -385,6 +394,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, MATERIAL_GLASS, glassSurface.roughness, glassSurface.metalness, 0.0, 0.0, materialHeader.params.z, materialHeader.params.w, EMISSION_SOURCE_NONE); + payloadSetDielectric(payload, MATERIAL_GLASS, materialHeader, entering); return; } @@ -417,9 +427,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, material, surface.roughness, surface.metalness, surface.emission, surface.sss, materialHeader.params.z, materialHeader.params.w, materialEmissionSource(materialHeader, surface.emission)); - if (material == MATERIAL_WATER && entering) { - payload.flags |= PAYLOAD_WATER_ENTERING; - } + payloadSetDielectric(payload, material, materialHeader, entering); // RIS emitter-NEE membership: raygen gates this emitter's direct-hit emission term (RIS covers it). if ((pr.flags & TERRAIN_PRIM_IN_LIGHT_BUFFER) != 0u) { payload.flags |= PAYLOAD_EMITTER_IN_LIST; diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 2c966b65..f2de8342 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -104,9 +104,11 @@ uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } uint payloadEmissionSource() { return (payload.flags >> PAYLOAD_EMISSION_SOURCE_SHIFT) & PAYLOAD_EMISSION_SOURCE_MASK; } -// Set by world.rchit only on a MATERIAL_WATER hit (see PAYLOAD_WATER_ENTERING) — whether the ray was -// travelling into the water volume (vs. out of it) at this hit's face. -bool payloadWaterEntering() { return (payload.flags & PAYLOAD_WATER_ENTERING) != 0u; } +// Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was +// travelling into the volume (vs. out of it) at this hit's face, and whether the material is a volume +// dielectric at all rather than a thin slab. +bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } +bool payloadDielectricVolume() { return (payload.flags & PAYLOAD_DIELECTRIC_VOLUME) != 0u; } bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } // LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used // end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly @@ -160,6 +162,74 @@ float3 waterExtinction(float3 tint) { return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); } +// A volume dielectric's tint is a filter over one block of travel rather than a biome water colour, so it +// maps to extinction by inverting Beer-Lambert over that reference distance: a tint of t after 1 block +// means sigma = -ln(t). Clamped away from 0 so a fully saturated texel stays finite. +static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks +float3 volumeExtinction(float3 tint, float transmission) { + float3 filtered = clamp(lerp(float3(1.0, 1.0, 1.0), tint, clamp(transmission, 0.0, 1.0)), + 1.0e-3, 1.0); + return -log(filtered) / VOLUME_TINT_REFERENCE_DISTANCE; +} + +// ---- Participating medium the path is currently inside. +// +// Refraction needs the RELATIVE index across an interface, so the renderer has to know what it is +// travelling through, not just what it is hitting. `ior` drives Snell/Fresnel; `extinction` drives the +// per-segment Beer-Lambert attenuation. +// +// The stack is depth 2 (current + the one it will return to) held in named fields, NOT an array. A +// dynamically indexed local array lands in scratch memory, and this raygen is already register-bound — +// paying an occupancy hit for nesting that Minecraft does not produce would be a bad trade. Depth 2 +// covers air->water->glass and air->glass->water, which is the realistic worst case; anything deeper +// degrades to air on the way out, and because `entering` is re-derived per face from geometry rather +// than toggled, the path re-synchronises at the next crossing instead of staying corrupted. +struct Medium { + float ior; + float3 extinction; + bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific +}; + +struct MediumStack { + Medium current; + Medium outer; +}; + +Medium airMedium() { + Medium m; + m.ior = 1.0; + m.extinction = float3(0.0, 0.0, 0.0); + m.water = false; + return m; +} + +MediumStack makeMediumStack(Medium start) { + MediumStack s; + s.current = start; + s.outer = airMedium(); + return s; +} + +void mediumPush(inout MediumStack stack, Medium entered) { + stack.outer = stack.current; + stack.current = entered; +} + +void mediumPop(inout MediumStack stack) { + stack.current = stack.outer; + stack.outer = airMedium(); +} + +// Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume +// dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. +Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { + Medium m; + m.ior = ior; + m.extinction = isWater ? waterExtinction(tint) : volumeExtinction(tint, transmission); + m.water = isWater; + return m; +} + // Animated surface waves — a directional wave spectrum, gradient-only (the surface stays geometrically // flat; only the normal tilts). Three things make it read as water rather than animated glass: // * Sharp-crested profile: each component is h = a·exp(S·(sin φ − 1)) — peaked crests, wide flat @@ -1088,25 +1158,31 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, return; } + if (material != MATERIAL_WATER && material != MATERIAL_GLASS) return; + float3 interfaceNormal = normalize(payload.normal); float3 interfaceBiasNormal = interfaceNormal; - if (material == MATERIAL_GLASS) { - // Main models glass as a collapsed thin sheet: no direction/IOR change, but each crossed - // pane contributes its authored tint exactly as the radiance path does. + if (!payloadDielectricVolume()) { + // Thin slab: no direction or index change, but each crossed pane contributes its authored + // tint exactly as the radiance path does. transmissionFilter *= payload.albedo * clamp(payloadTransmission(), 0.0, 1.0); ro = interfacePos - interfaceNormal * GLASS_TRANSMIT_BIAS; continue; } - if (material != MATERIAL_WATER) return; - if ((worldPush.flags & 16u) != 0u) { + // Volume: mirror the radiance path's refraction. Absorption inside the medium is deliberately + // NOT accumulated into transmissionFilter — RR demodulates by albedo, and folding a + // distance-dependent attenuation into the albedo guide would make it disagree with the colour. + if (material == MATERIAL_WATER && (worldPush.flags & 16u) != 0u) { float waterFootprint = rayConeWidth / max(abs(dot(-direction, interfaceBiasNormal)), 0.2); interfaceNormal = applyWaterWaves(interfaceNormal, interfacePos.xz + worldPush.waterAnchor.xy, worldPush.waterParams.w, waterFootprint); } - float targetIor = payloadWaterEntering() ? max(payloadIor(), 1.0) : 1.0; + // Exiting returns to air: the walk keeps only the current index, which is the same depth-1 + // approximation the guide already made and is exact for a single enclosing volume. + float targetIor = payloadDielectricEntering() ? max(payloadIor(), 1.0) : 1.0; float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); if (dot(nextDirection, nextDirection) <= 0.0) { guideVisibilityMask = CULL_SECONDARY; @@ -1141,12 +1217,12 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; proposalSeed = pcg(proposalSeed); - // Start submerged if the camera is in water so the first segment gets the right medium orientation - // and absorption when the eye is underwater. - bool inWater = (worldPush.flags & 1u) != 0u; - // Current medium extinction. Defaults to the camera-biome tint (correct when the eye starts - // submerged, before any water surface is hit); refreshed to the hit water body's tint on each crossing. - float3 waterExt = waterExtinction(worldPush.waterParams.xyz); + // Start submerged if the camera is in water, so the first segment already carries the right relative + // index and absorption when the eye is underwater. The camera-biome tint is the correct extinction + // until the first water surface is crossed, after which each hit supplies its own body's tint. + MediumStack medium = makeMediumStack((worldPush.flags & 1u) != 0u + ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) + : airMedium()); bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from @@ -1190,11 +1266,12 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint break; } - // Beer–Lambert: attenuate along the segment just travelled if it lay inside water. Applied to - // every hit reached while submerged (the water-exit face or the underwater floor), shifting the - // transmitted radiance with depth. - if (inWater) { - throughput *= exp(-waterExt * payload.hitT); + // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies + // to every hit reached while inside a volume dielectric (its own exit face, or whatever content + // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so + // the ordinary case costs one comparison. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); } float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit @@ -1206,47 +1283,112 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_emissionSource = payloadEmissionSource(); } - // Stained glass / ice (material 3): a thin colored dielectric. Stochastically reflect (Fresnel - // glint) or transmit; on transmission the throughput is multiplied by the pane's tint, so light - // passing through is colored. payload.albedo is the transmission tint (texel rgb mixed toward - // white by 1 − opacity). Treated as a thin pane, so the transmitted ray continues straight (no - // refraction bend) — the instance stays double-sided and the normal already faces the incoming ray. - if (material == MATERIAL_GLASS) { - float3 glassTint = payload.albedo; - float materialIor = max(payloadIor(), 1.0e-3); + // ---- Dielectric interface: water, stained glass, ice. ONE handler, because the transport is the + // same physics — Fresnel from the relative index across the face, then reflect or transmit, with + // no diffuse response and no sun NEE (the surface is specular). What varies is per material, not + // per branch: + // * volume (PAYLOAD_DIELECTRIC_VOLUME): the interface bends the ray and pushes a participating + // medium, so the segment inside is attenuated by that medium's extinction. Water and ice. + // * thin: a collapsed slab whose two interfaces cancel — no bend, no medium, and the tint is + // applied once per crossing. Glass panes and vanilla-style windows, which should read as + // coloured windows rather than solid refracting cubes. + // * water additionally takes the animated wave normal and marks its medium for the caustic term. + // Which side of the face we are on comes from the geometry (payloadDielectricEntering), so a + // stray or missing face cannot desync the medium for the rest of the path. + if (material == MATERIAL_WATER || material == MATERIAL_GLASS) { + bool isWater = material == MATERIAL_WATER; + bool volumeDielectric = payloadDielectricVolume(); + bool entering = payloadDielectricEntering(); + float3 tint = payload.albedo; float transmission = clamp(payloadTransmission(), 0.0, 1.0); + + // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken + // reflections, wobbling refraction. The wave domain is world-stable: rebased hit XZ plus the + // pushed anchor (terrain origin mod 4096) survives the per-move terrain rebase. + float3 geometricNormal = n; // unrippled; ray origins offset along this + float3 previousNormal = n; // wave normal one frame ago, for the reflection MV + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + if (bounce == 0 && captureGuides && abs(geometricNormal.y) >= 0.5) { + float2 currentGrad; + float2 previousGrad; + waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, + worldPush.waterAnchor.z, waterFootprint, currentGrad, previousGrad); + float orientation = geometricNormal.y >= 0.0 ? 1.0 : -1.0; + n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); + previousNormal = orientation + * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); + } else { + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } + } + + // The medium this face opens into. Exiting returns to whatever enclosed it, which is what the + // stack remembers; a thin slab opens into nothing, so both sides stay in the current medium. + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); + float etaI = medium.current.ior; + // A thin slab still has the material's index at BOTH faces — it just never becomes the + // medium, so the ray does not bend and the enclosing medium never changes. Only a volume + // exit reads the enclosing index back off the stack. + float etaT = volumeDielectric && !entering ? medium.outer.ior : entered.ior; + float cosI = clamp(dot(-rd, n), 0.0, 1.0); - float F = fresnelDielectric(cosI, 1.0, materialIor); + float F = fresnelDielectric(cosI, etaI, etaT); + // A thin slab does not bend, so its "transmitted direction" is simply the incident one. + float3 transmittedDir = volumeDielectric ? refract(rd, n, etaI / etaT) : rd; + float transmitBias = volumeDielectric ? SURF_BIAS : GLASS_TRANSMIT_BIAS; + if (bounce == 0 && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM) { pathPrimaryDielectricHit = true; pathPrimaryDielectricF = F; } - if (bounce == 0 && captureGuides) { // primary glass hit: feed RR a smooth dielectric specular surface + if (bounce == 0 && captureGuides) { // feed RR a smooth dielectric specular surface gv_normal = n; - gv_rough = clamp(payloadRoughness(), 0.0, 1.0); - gv_albedo = float3(0.0, 0.0, 0.0); // coherent fallback when no transmitted layer exists + // Transport here is an exact Fresnel interface, not a finite GGX lobe. A thin slab keeps + // its authored roughness because frosted/etched panes do scatter. + gv_rough = volumeDielectric ? 0.0 : clamp(payloadRoughness(), 0.0, 1.0); + gv_albedo = float3(0.0, 0.0, 0.0); // fallback when TIR leaves no transmitted layer gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; // 0 for static terrain - gv_spec = makeSpecSurface(gv_hitCamRel, n, gv_rough, float3(F, F, F)); + gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; + gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, + gv_rough, float3(F, F, F)); - resolveTransmissionGuide(hitPos, rd, n, - 1.0, glassTint * transmission, - GLASS_TRANSMIT_BIAS, rayConeWidth, rayConeSpread); + if (dot(transmittedDir, transmittedDir) > 0.0) { + resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, + volumeDielectric && entering ? entered.ior : medium.current.ior, + volumeDielectric ? float3(1.0, 1.0, 1.0) : tint * transmission, + transmitBias, rayConeWidth, rayConeSpread); + } } + bool chooseReflection = bounce == 0 && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM ? pathPrimaryDielectricBranch == PRIMARY_DIELECTRIC_REFLECTION : rndf(seed) < F; if (chooseReflection) { rd = reflect(rd, n); - ro = hitPos + n * SURF_BIAS; // reflected ray stays on the incidence side + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); } else { - throughput *= glassTint * transmission; // colored, optionally partial transmission - ro = hitPos - n * GLASS_TRANSMIT_BIAS; // cross to the far side without overshooting a close neighbour + if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + if (volumeDielectric) { + // Crossing into or out of the volume. Absorption is the medium's job from here, so + // the tint is NOT also multiplied into the throughput — that would double-count it. + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + } else { + throughput *= tint * transmission; // collapsed slab: colour the ray once per crossing + } } - showCelestial = true; // specular interface: the continuation ray may see the disc + showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc if (bounce >= rrStart) { float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); if (rndf(seed) > q) { @@ -1316,102 +1458,6 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint continue; } - // Water: smooth dielectric interface. Choose reflection or refraction stochastically by the - // Fresnel reflectance F — sampling by F leaves the throughput unchanged (lossless interface), so - // there is no diffuse albedo multiply and no sun NEE here (the surface is specular). On - // transmission, the new medium is read from this hit's face orientation (payloadWaterEntering), - // not toggled — see PAYLOAD_WATER_ENTERING. The bounce cap bounds stacked refractions. - if (material == MATERIAL_WATER) { - float materialIor = max(payloadIor(), 1.0e-3); - float transmission = clamp(payloadTransmission(), 0.0, 1.0); - // The primary-water guide traces another radiance ray through the global payload. Preserve - // this interface's orientation before that nested trace overwrites payload.flags. - bool materialEntering = payloadWaterEntering(); - // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken - // reflections, wobbling refraction. This water body's biome tint (carried in payload.albedo - // for water hits) sets the absorption applied to the next submerged segment. - float3 surfaceWaterNormal = n; - float3 previousWaterNormal = n; - if (waterWaves) { - // World-stable wave domain: rebased hit XZ + the pushed anchor (terrain origin mod 4096) - // reconstructs a world-pinned coordinate that survives the per-move terrain rebase. - float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; - float waterFootprint = rayConeWidth - / max(abs(dot(-rd, surfaceWaterNormal)), 0.2); - if (bounce == 0 && captureGuides && abs(surfaceWaterNormal.y) >= 0.5) { - float2 currentGrad; - float2 previousGrad; - waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, - worldPush.waterAnchor.z, waterFootprint, - currentGrad, previousGrad); - float orientation = surfaceWaterNormal.y >= 0.0 ? 1.0 : -1.0; - n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); - previousWaterNormal = orientation - * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); - } else { - n = applyWaterWaves(surfaceWaterNormal, waterDomain, - worldPush.waterParams.w, waterFootprint); - } - } - waterExt = waterExtinction(payload.albedo); - float cosI = clamp(dot(-rd, n), 0.0, 1.0); - float etaI = inWater ? materialIor : 1.0; - float etaT = inWater ? 1.0 : materialIor; - float F = fresnelDielectric(cosI, etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); - if (bounce == 0 && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM) { - pathPrimaryDielectricHit = true; - pathPrimaryDielectricF = F; - } - if (bounce == 0 && captureGuides) { // primary water hit: feed RR a smooth dielectric specular surface - gv_normal = n; - gv_rough = 0.0; // transport above is an exact Fresnel interface, not finite GGX - gv_albedo = float3(0.0, 0.0, 0.0); // pure-specular fallback for TIR/no destination - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = float3(0.0, 0.0, 0.0); - // The reflection reprojects off the wave-displaced normal (now and last frame) but must - // leave the surface along the flat geometric one. - gv_spec = makeSpecSurface(gv_hitCamRel, n, previousWaterNormal, surfaceWaterNormal, - 0.0, float3(F, F, F)); - - if (dot(transmittedDir, transmittedDir) > 0.0) { - resolveTransmissionGuide(hitPos, transmittedDir, surfaceWaterNormal, - materialEntering ? materialIor : 1.0, - float3(1.0, 1.0, 1.0), SURF_BIAS, - rayConeWidth, rayConeSpread); - } - } - bool chooseReflection = bounce == 0 - && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM - ? pathPrimaryDielectricBranch == PRIMARY_DIELECTRIC_REFLECTION - : rndf(seed) < F; - if (chooseReflection) { - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, surfaceWaterNormal, rd, SURF_BIAS); - } else { - if (dot(transmittedDir, transmittedDir) <= 0.0) break; - rd = transmittedDir; // F < 1 here, so this is never total internal reflection - throughput *= transmission; - ro = offsetSurfaceOrigin(hitPos, surfaceWaterNormal, rd, SURF_BIAS); - // Derived from this hit's face orientation, not toggled — self-corrects instead of a - // stray/missing face corrupting the medium for the rest of the path (see PAYLOAD_WATER_ENTERING). - inWater = materialEntering; - } - // Specular interface (mirror reflect / clear refract): the continuation ray should see the - // sun/moon disc directly — no NEE happened here to double-count it. - showCelestial = true; - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - break; - } - throughput /= q; - } - continue; - } - float3 albedo = payload.albedo; float sss = payloadSss(); // LabPBR SSS strength (0 when absent) float3 p = hitPos + n * SURF_BIAS; @@ -1477,7 +1523,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, // so it applies to the whole NEE term (diffuse + specular). - if (inWater && waterWaves && shadow.waterHitT > 0.0) { + if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); } if (max(vis.r, max(vis.g, vis.b)) > 0.0) { @@ -1522,7 +1568,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint float3 visB = shadowBack.transmittance; // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should // flicker with the same light bands as the floor around it. - if (inWater && waterWaves && shadowBack.waterHitT > 0.0) { + if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, lightDir, shadowBack.waterHitT); } diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index 5be6c8c8..9b2edc74 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -140,11 +140,18 @@ public static const uint PAYLOAD_SHOW_CELESTIAL = 4u; // Set by world.rchit on a terrain hit whose prim carries TERRAIN_PRIM_IN_LIGHT_BUFFER: this emitter is // RIS-sampled, so raygen gates its direct-hit emission on diffuse continuation rays (no double count). public static const uint PAYLOAD_EMITTER_IN_LIST = 128u; -// Set by world.rchit on a MATERIAL_WATER hit: true when the incoming ray travels against the prim's -// outward face normal (entering the water volume), false when it exits (water -> air). Bit 2 -// (PAYLOAD_SHOW_CELESTIAL) is only meaningful on a miss and this bit only on a hit, so they never need -// to be read together, but they get distinct bits anyway to keep the two concerns unambiguous. -public static const uint PAYLOAD_WATER_ENTERING = 8u; +// Set by world.rchit on any dielectric hit (water or glass): true when the incoming ray travels against +// the prim's outward face normal (entering the volume), false when it exits. Derived from face +// orientation rather than toggled, so a stray or missing face cannot corrupt the medium for the rest of +// the path — each crossing re-derives which side it is on. Bit 2 (PAYLOAD_SHOW_CELESTIAL) is only +// meaningful on a miss and this bit only on a hit, so they never need to be read together, but they get +// distinct bits anyway to keep the two concerns unambiguous. +public static const uint PAYLOAD_DIELECTRIC_ENTERING = 8u; +// Set by world.rchit when the hit material is a VOLUME dielectric: the interface refracts the ray and +// pushes a participating medium whose extinction attenuates the segment inside it. Clear means a THIN +// dielectric — a collapsed slab whose two interfaces cancel, so the ray passes straight through and the +// tint is applied once. Glass panes and vanilla-style windows are thin; ice and solid blocks are volume. +public static const uint PAYLOAD_DIELECTRIC_VOLUME = 256u; public static const uint PAYLOAD_EMISSION_SOURCE_SHIFT = 4u; public static const uint PAYLOAD_EMISSION_SOURCE_MASK = 7u; public static const uint EMISSION_SOURCE_NONE = 0u; @@ -196,6 +203,8 @@ public struct MaterialHeader { public static const uint MATERIAL_FEATURE_SPEC = 1u; public static const uint MATERIAL_FEATURE_NORMAL = 2u; public static const uint MATERIAL_FEATURE_HEURISTIC_EMISSION = 4u; +// This dielectric is a volume, not a thin slab: refract and track a medium. See PAYLOAD_DIELECTRIC_VOLUME. +public static const uint MATERIAL_FEATURE_DIELECTRIC_VOLUME = 8u; public static const uint MATERIAL_FEATURE_STOCHASTIC_ALPHA = 16u; // Final HDR emission strength (EMISSIVE_STRENGTH baseline * any JSON override multiplier), baked in Java // at material-compile time (RtMaterialRegistry) and packed here as a 16-bit fraction of the max — every diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java new file mode 100644 index 00000000..49ac1eea --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java @@ -0,0 +1,73 @@ +package dev.comfyfluffy.caustica.rt.material; + +import net.minecraft.resources.Identifier; + +import java.util.Map; + +/** + * Built-in refractive indices and thin/volume classification for the dielectric blocks vanilla ships. + * + *

Two properties decide how {@code world.rgen}'s dielectric interface behaves, and both are physical + * facts about the material rather than anything derivable from the render layer: + * + *

    + *
  • IOR drives Snell refraction and the Fresnel split. Everything translucent used to share a + * single per-model constant (1.52 for glass, 1.333 for water), so ice refracted like window glass. + *
  • Volume vs thin. A volume dielectric bends the ray and pushes a participating medium whose + * extinction attenuates the segment inside it. A thin one is a collapsed slab: its two interfaces + * cancel, so the ray passes straight through and the tint is applied once. This is a deliberate + * modelling choice, not a measurement — a glass block is geometrically a cube, but treating it as a + * solid refracting cube makes windows unreadable, so vanilla-style glass stays thin while ice + * (which reads as a solid block of frozen water) becomes a volume. + *
+ * + *

Sprite-keyed rather than block-keyed because the material registry compiles per sprite; a resource + * pack that renames textures simply falls back to the per-model default, and a + * {@code caustica/materials/*.json} rule overrides either value explicitly. + */ +public final class RtDielectrics { + private RtDielectrics() {} + + /** Soda-lime glass. The default for anything translucent that is not otherwise classified. */ + public static final float GLASS_IOR = 1.52f; + /** Fresh water at room temperature; also the fluid singleton's index. */ + public static final float WATER_IOR = 1.333f; + /** Ice Ih, slightly below liquid water. */ + public static final float ICE_IOR = 1.309f; + + /** + * How a dielectric interface behaves. {@code volume} true means refract and track a medium. + */ + public record Dielectric(float ior, boolean volume) {} + + private static final Dielectric THIN_GLASS = new Dielectric(GLASS_IOR, false); + private static final Dielectric VOLUME_ICE = new Dielectric(ICE_IOR, true); + public static final Dielectric WATER = new Dielectric(WATER_IOR, true); + + // Ice is the one vanilla family that genuinely reads as a solid transparent block, so it is the one + // that earns the volume treatment. Slime and honey are translucent but visually gelatinous rather + // than refractive, and nether portal is an emissive effect, so they stay thin. + private static final Map BY_SPRITE = Map.of( + "block/ice", VOLUME_ICE, + "block/packed_ice", VOLUME_ICE, + "block/blue_ice", VOLUME_ICE, + "block/frosted_ice_0", VOLUME_ICE, + "block/frosted_ice_1", VOLUME_ICE, + "block/frosted_ice_2", VOLUME_ICE, + "block/frosted_ice_3", VOLUME_ICE); + + /** + * Built-in dielectric for a sprite, or the thin-glass default when the sprite is unrecognised. Only + * meaningful for materials the mesher classified as translucent; opaque materials ignore it. + */ + public static Dielectric forSprite(Identifier spriteName) { + if (spriteName == null) return THIN_GLASS; + Dielectric known = BY_SPRITE.get(spriteName.getPath()); + return known != null ? known : THIN_GLASS; + } + + /** The default used for fallback variants compiled without a sprite. */ + public static Dielectric defaultGlass() { + return THIN_GLASS; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java index 698270a9..dfdd0453 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java @@ -84,10 +84,14 @@ static Rule parse(JsonObject root, Identifier source) { } Float transmission = null; Float ior = null; + Boolean volume = null; if (root.has("transmission")) { JsonObject value = root.getAsJsonObject("transmission"); transmission = optionalFloat(value, "factor"); ior = optionalFloat(value, "ior"); + // "volume": true refracts and tracks a participating medium (ice, solid dielectric blocks); + // false is a collapsed thin slab that passes the ray straight through (window glass, panes). + if (value.has("volume")) volume = value.get("volume").getAsBoolean(); } validate01("roughness", roughness); validate01("metalness", metalness); @@ -105,7 +109,7 @@ static Rule parse(JsonObject root, Identifier source) { emissionStrength = clamped; } return new Rule(source, sprite, block, model, roughness, metalness, ior, transmission, - emissionStrength); + volume, emissionStrength); } public List rules() { @@ -114,6 +118,11 @@ public List rules() { public record Rule(Identifier source, Identifier sprite, Identifier block, Integer model, Float roughness, Float metalness, Float ior, Float transmission, + /** + * Overrides the built-in thin/volume classification (see {@link RtDielectrics}). + * Null leaves whatever the material resolved to. + */ + Boolean volume, /** * Multiplier on whatever emission the material naturally resolves to (LabPBR * {@code _s}, heuristic mask, or state-uniform block light) — NOT a replacement. @@ -142,18 +151,24 @@ RtMaterialDesc apply(RtMaterialDesc base) { : (model != null ? defaultIor(nextModel) : base.ior()); float nextTransmission = transmission != null ? transmission : (model != null ? defaultTransmission(nextModel) : base.transmission()); + int nextFeatures = base.features(); + if (volume != null) { + nextFeatures = volume + ? nextFeatures | RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME + : nextFeatures & ~RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME; + } // A multiplier on the base's already-resolved strength (0 when emissionSource is NONE): // this can brighten/dim an existing emitter but never light up a genuinely non-emissive one. float nextEmissionStrength = emissionStrength != null ? base.emissionStrength() * emissionStrength : base.emissionStrength(); - return new RtMaterialDesc(nextModel, RtMaterialDesc.Source.OVERRIDE, base.features(), + return new RtMaterialDesc(nextModel, RtMaterialDesc.Source.OVERRIDE, nextFeatures, nextRoughness, nextMetalness, nextIor, nextTransmission, base.emissionSource(), nextEmissionStrength, base.emissionSummary()); } private static float defaultIor(int model) { - return model == RtMaterialRegistry.MODEL_WATER ? 1.333f - : model == RtMaterialRegistry.MODEL_GLASS ? 1.52f : 1.0f; + return model == RtMaterialRegistry.MODEL_WATER ? RtDielectrics.WATER_IOR + : model == RtMaterialRegistry.MODEL_GLASS ? RtDielectrics.GLASS_IOR : 1.0f; } private static float defaultTransmission(int model) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java index 1666a2fb..e27ff106 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java @@ -44,6 +44,8 @@ public final class RtMaterialRegistry { public static final int FEATURE_SPEC = 1; public static final int FEATURE_NORMAL = 2; public static final int FEATURE_HEURISTIC_EMISSION = 4; + /** Mirrors MATERIAL_FEATURE_DIELECTRIC_VOLUME: refract and track a medium instead of a thin slab. */ + public static final int FEATURE_DIELECTRIC_VOLUME = 8; public static final int FEATURE_STOCHASTIC_ALPHA = 16; // HDR radiance of a full (level-15-equivalent) emitter, modulated by albedo — the single knob // (formerly duplicated as a literal in world.rgen.slang and RtLightCollector). Baked into every @@ -171,6 +173,9 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, break; } } + // Resolved once per sprite: IOR/volume is a property of the material, so it costs no extra + // variants — it varies with the sprite, not with the profile/glass/emitting cross product. + RtDielectrics.Dielectric dielectric = RtDielectrics.forSprite(sprite.contents().name()); int[] variants = new int[profileVariants]; for (RtMaterials.Profile profile : SPRITE_PROFILES) { for (boolean glass : new boolean[]{false, true}) { @@ -178,7 +183,8 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, int features = emitting ? baseFeatures : baseFeatures & ~FEATURE_HEURISTIC_EMISSION; RtMaterialDesc desc = compileDesc(glass ? MODEL_GLASS : MODEL_OPAQUE, features, profile, emitting, false, - variantSummary(features, emitting, entry, stats.uniformSummary())); + variantSummary(features, emitting, entry, stats.uniformSummary()), + dielectric); if (spriteWide != null) { desc = spriteWide.rule.apply(desc); } @@ -198,7 +204,8 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, int features = emitting ? baseFeatures : baseFeatures & ~FEATURE_HEURISTIC_EMISSION; RtMaterialDesc base = compileDesc(glass ? MODEL_GLASS : MODEL_OPAQUE, features, profile, emitting, false, - variantSummary(features, emitting, entry, stats.uniformSummary())); + variantSummary(features, emitting, entry, stats.uniformSummary()), + dielectric); RtMaterialDesc desc = compiled.rule.apply(base); overrideVariants[index(profile, glass, emitting)] = headers.size(); add(headers, descriptions, grids, desc, stats.average(), entry, stats.albedoGrid()); @@ -424,9 +431,26 @@ private static RtMaterialDesc.EmissionSummary variantSummary(int features, boole private static RtMaterialDesc compileDesc(int model, int features, RtMaterials.Profile profile, boolean emitting, boolean neutral, RtMaterialDesc.EmissionSummary emissionSummary) { + return compileDesc(model, features, profile, emitting, neutral, emissionSummary, + RtDielectrics.defaultGlass()); + } + + private static RtMaterialDesc compileDesc(int model, int features, RtMaterials.Profile profile, + boolean emitting, boolean neutral, + RtMaterialDesc.EmissionSummary emissionSummary, + RtDielectrics.Dielectric dielectric) { float roughness = model == MODEL_GLASS ? 0.0025f : profile.roughness(); // linear; s = 0.95 float metalness = model == MODEL_GLASS ? 0.0f : profile.metalness(); - float ior = model == MODEL_WATER ? 1.333f : (model == MODEL_GLASS ? 1.52f : 1.0f); + // Refractive index and thin/volume behaviour are per material, not per model: ice and window + // glass are both MODEL_GLASS but refract differently and only one of them is a volume. + float ior = switch (model) { + case MODEL_WATER -> RtDielectrics.WATER_IOR; + case MODEL_GLASS -> dielectric.ior(); + default -> 1.0f; + }; + if (model == MODEL_WATER || (model == MODEL_GLASS && dielectric.volume())) { + features |= FEATURE_DIELECTRIC_VOLUME; + } float transmission = model == MODEL_WATER || model == MODEL_GLASS ? 1.0f : 0.0f; boolean labPbr = (features & (FEATURE_SPEC | FEATURE_NORMAL)) != 0; RtMaterialDesc.Source source = neutral ? RtMaterialDesc.Source.NEUTRAL diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java new file mode 100644 index 00000000..a5ca59a0 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java @@ -0,0 +1,43 @@ +package dev.comfyfluffy.caustica.rt.material; + +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class RtDielectricsTest { + @Test + void iceFamilyRefractsAsAVolume() { + for (String sprite : new String[]{"block/ice", "block/packed_ice", "block/blue_ice", + "block/frosted_ice_0", "block/frosted_ice_3"}) { + var dielectric = RtDielectrics.forSprite(Identifier.parse("minecraft:" + sprite)); + assertTrue(dielectric.volume(), sprite + " must be a volume dielectric"); + assertEquals(RtDielectrics.ICE_IOR, dielectric.ior(), 1.0e-6f, sprite); + } + } + + @Test + void windowGlassStaysThinSoItReadsAsAWindow() { + var glass = RtDielectrics.forSprite(Identifier.parse("minecraft:block/glass")); + assertFalse(glass.volume()); + assertEquals(RtDielectrics.GLASS_IOR, glass.ior(), 1.0e-6f); + } + + @Test + void unknownAndNullSpritesFallBackToThinGlass() { + var modded = RtDielectrics.forSprite(Identifier.parse("somemod:block/weird_crystal")); + assertFalse(modded.volume()); + assertEquals(RtDielectrics.GLASS_IOR, modded.ior(), 1.0e-6f); + assertEquals(RtDielectrics.defaultGlass(), RtDielectrics.forSprite(null)); + } + + @Test + void iceRefractsLessStronglyThanGlass() { + // Ice Ih sits just below liquid water, which sits well below soda-lime glass. Getting this + // ordering wrong is the whole reason the per-model constant was not good enough. + assertTrue(RtDielectrics.ICE_IOR < RtDielectrics.WATER_IOR); + assertTrue(RtDielectrics.WATER_IOR < RtDielectrics.GLASS_IOR); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java index 06f89e32..16368f41 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java @@ -38,6 +38,36 @@ void parsesVersionedExtensibleMaterialProperties() { assertEquals(1.0f, applied.transmission()); } + @Test + void transmissionVolumeFlagTogglesTheDielectricFeatureBit() { + RtMaterialDesc thinBase = new RtMaterialDesc(RtMaterialRegistry.MODEL_GLASS, + RtMaterialDesc.Source.HEURISTIC, 0, 0.0025f, 0.0f, 1.52f, 1.0f, + RtMaterialDesc.EmissionSource.NONE, 0.0f, RtMaterialDesc.EmissionSummary.NONE); + var toVolume = RtMaterialOverrides.parse(JsonParser.parseString(""" + {"format":1,"match":{"sprite":"minecraft:block/ice"}, + "transmission":{"ior":1.309,"volume":true}} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/ice.json")); + RtMaterialDesc volume = toVolume.apply(thinBase); + assertEquals(1.309f, volume.ior()); + assertTrue((volume.features() & RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME) != 0); + + // The flag must be able to turn the bit OFF as well, or a pack could not make a modded solid + // dielectric behave like a window pane. + var toThin = RtMaterialOverrides.parse(JsonParser.parseString(""" + {"format":1,"match":{"sprite":"minecraft:block/ice"}, + "transmission":{"volume":false}} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/ice.json")); + assertFalse((toThin.apply(volume).features() + & RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME) != 0); + + // Omitting it leaves whatever the material resolved to. + var silent = RtMaterialOverrides.parse(JsonParser.parseString(""" + {"format":1,"match":{"sprite":"minecraft:block/ice"},"base":{"roughness":0.5}} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/ice.json")); + assertTrue((silent.apply(volume).features() + & RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME) != 0); + } + @Test void emissionStrengthCannotForceEmissionOntoANonEmissiveMaterial() { var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" From 4c105ddbede3bc08c98281d4235d1a86b8e8dcac Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:32:45 +0900 Subject: [PATCH 05/25] Drop the thin-slab dielectric; rename MATERIAL_GLASS to MATERIAL_DIELECTRIC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every dielectric is a volume now: it refracts and pushes a participating medium whose extinction attenuates the segment inside. The thin/volume axis added a second behaviour to every dielectric site — Fresnel, transmitted direction, ray bias, guide roughness, the transmission-guide walk and the throughput update all branched on it — to model a case that the medium stack already subsumes. Glass blocks now genuinely refract rather than passing the ray straight through with a tint multiply. With thin gone, PAYLOAD_DIELECTRIC_VOLUME, MATERIAL_FEATURE_DIELECTRIC_VOLUME, FEATURE_DIELECTRIC_VOLUME and the transmission.volume JSON override all describe a distinction that no longer exists, so they are removed. RtDielectrics collapses to what actually varies: a per-sprite refractive index. Naming. MATERIAL_GLASS covered glass, ice and anything else transparent, and was never a good name once it stopped meaning "thin pane" — it is MATERIAL_DIELECTRIC (MODEL_DIELECTRIC in Java). MATERIAL_WATER keeps its own id because it still owns behaviour nothing else has: the animated wave normal, the caustic term, biome-tint absorption calibrated per block of depth, and a chit path fed by the fluid mesher rather than the translucent terrain layer. The JSON model names followed: the old "thin_dielectric"/"volume_dielectric" pair becomes "dielectric"/"water", since both were volumes and the distinction they named is gone. No shipped material JSON used either name. Two details preserved rather than collapsed: * The transmitted-ray bias stays per material, now named INSET_TRANSMIT_BIAS. It is not a thin/volume property — it exists because RtTerrainMesher recesses TRANSLUCENT quads by TRANSLUCENT_INSET, so a glass/ice face touching a slab needs a bias smaller than that inset or the ray restarts past the neighbour. Water is meshed by RtFluidMesher with no inset and takes the ordinary bias. * volumeExtinction now folds `transmission` in as a transmittance multiplier (-ln(tint * factor)) instead of lerping the tint toward white by it. The lerp inverted the factor's meaning at the low end: 0 produced a perfectly clear medium where every other use of transmission.factor means opaque. Shaders compile and pass spirv-val; 39 tests pass. NOT GPU-verified — glass and ice both change how they refract, so they are the things to look at. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rahit.slang | 2 +- shaders/world/world.rchit.slang | 23 ++-- shaders/world/world.rgen.slang | 122 ++++++++---------- shaders/world/world_common.slang | 18 ++- .../caustica/rt/material/RtDielectrics.java | 69 ++++------ .../rt/material/RtMaterialOverrides.java | 30 ++--- .../rt/material/RtMaterialRegistry.java | 41 +++--- .../rt/material/RtDielectricsTest.java | 32 ++--- .../rt/material/RtMaterialOverridesTest.java | 56 ++++---- 9 files changed, 162 insertions(+), 231 deletions(-) diff --git a/shaders/world/world.rahit.slang b/shaders/world/world.rahit.slang index 459b7f9e..60b082da 100644 --- a/shaders/world/world.rahit.slang +++ b/shaders/world/world.rahit.slang @@ -86,7 +86,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // Radiance rays accept the surface and continue to closest-hit. Shadow rays use the same material // model here so entity dielectrics transmit instead of becoming opaque shadow blockers. bool shadowRay = (RayFlags() & RAY_FLAG_SKIP_CLOSEST_HIT_SHADER) != 0u; - if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_GLASS) { + if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_DIELECTRIC) { float3 tint = lerp(float3(1.0, 1.0, 1.0), srgbToLinear(texel.rgb) * epr.tint.rgb, texel.a); payload.albedo *= half3(tint * clamp(materialHeader.params.w, 0.0, 1.0)); diff --git a/shaders/world/world.rchit.slang b/shaders/world/world.rchit.slang index 38578231..a57b889d 100644 --- a/shaders/world/world.rchit.slang +++ b/shaders/world/world.rchit.slang @@ -25,14 +25,11 @@ void payloadSetPacked(inout Payload payload, uint material, float roughness, flo payload.iorTransmission = packHalf2(float2(ior, transmission)); } -// Dielectric side/kind bits, shared by every hit path. `entering` comes from the face orientation, so it -// is re-derived at each crossing instead of toggled (see PAYLOAD_DIELECTRIC_ENTERING). -void payloadSetDielectric(inout Payload payload, uint material, MaterialHeader header, bool entering) { - if (material != MATERIAL_WATER && material != MATERIAL_GLASS) return; +// Which side of a dielectric face this hit is on, shared by every hit path. Comes from the face +// orientation, so it is re-derived at each crossing instead of toggled (see PAYLOAD_DIELECTRIC_ENTERING). +void payloadSetDielectric(inout Payload payload, uint material, bool entering) { + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; if (entering) payload.flags |= PAYLOAD_DIELECTRIC_ENTERING; - if ((header.features & MATERIAL_FEATURE_DIELECTRIC_VOLUME) != 0u) { - payload.flags |= PAYLOAD_DIELECTRIC_VOLUME; - } } uint materialEmissionSource(MaterialHeader header, float emission) { @@ -300,7 +297,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) MaterialHeader header = ConstPtr(pc.materialTableAddr)[pr.materialId]; uint material = header.model; float3 baseAlbedo = srgbToLinear(entityTexel.rgb) * pr.tint.rgb; - float3 albedo = material == MATERIAL_GLASS + float3 albedo = material == MATERIAL_DIELECTRIC ? lerp(float3(1.0, 1.0, 1.0), baseAlbedo, entityTexel.a) : baseAlbedo; Surface surface = evaluateMaterial(header, euvCoord, entityLod, albedo, n, ep0, ep1, ep2, euv[e0], euv[e1], euv[e2], vdir, @@ -325,7 +322,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, material, surface.roughness, surface.metalness, emission, sss, header.params.z, header.params.w, materialEmissionSource(header, emission)); - payloadSetDielectric(payload, material, header, entering); + payloadSetDielectric(payload, material, entering); return; } @@ -375,7 +372,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // Stained glass / ice: a thin colored filter resolved in raygen // (material 3). albedo carries the transmission tint = texel rgb mixed toward white by (1 − opacity), // so a more opaque texel tints transmitted light more strongly. - if (materialHeader.model == MATERIAL_GLASS) { + if (materialHeader.model == MATERIAL_DIELECTRIC) { float4 gtex = blockAlbedoAtlas.SampleLevel(uv, blockLod); float3 gtexRgb = srgbToLinear(gtex.rgb); float3 glassAlbedo = lerp(float3(1.0, 1.0, 1.0), gtexRgb * tint, gtex.a); @@ -391,10 +388,10 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payload.hitT = RayTCurrent(); payload.motionPrev = half3(0.0h, 0.0h, 0.0h); payload.f0 = half3(glassSurface.f0); - payloadSetPacked(payload, MATERIAL_GLASS, glassSurface.roughness, + payloadSetPacked(payload, MATERIAL_DIELECTRIC, glassSurface.roughness, glassSurface.metalness, 0.0, 0.0, materialHeader.params.z, materialHeader.params.w, EMISSION_SOURCE_NONE); - payloadSetDielectric(payload, MATERIAL_GLASS, materialHeader, entering); + payloadSetDielectric(payload, MATERIAL_DIELECTRIC, entering); return; } @@ -427,7 +424,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, material, surface.roughness, surface.metalness, surface.emission, surface.sss, materialHeader.params.z, materialHeader.params.w, materialEmissionSource(materialHeader, surface.emission)); - payloadSetDielectric(payload, material, materialHeader, entering); + payloadSetDielectric(payload, material, entering); // RIS emitter-NEE membership: raygen gates this emitter's direct-hit emission term (RIS covers it). if ((pr.flags & TERRAIN_PRIM_IN_LIGHT_BUFFER) != 0u) { payload.flags |= PAYLOAD_EMITTER_IN_LIST; diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index f2de8342..97d64bbe 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -105,10 +105,8 @@ uint payloadEmissionSource() { return (payload.flags >> PAYLOAD_EMISSION_SOURCE_SHIFT) & PAYLOAD_EMISSION_SOURCE_MASK; } // Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was -// travelling into the volume (vs. out of it) at this hit's face, and whether the material is a volume -// dielectric at all rather than a thin slab. +// travelling into the volume (vs. out of it) at this hit's face. bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } -bool payloadDielectricVolume() { return (payload.flags & PAYLOAD_DIELECTRIC_VOLUME) != 0u; } bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } // LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used // end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly @@ -136,13 +134,14 @@ static const float WATER_IOR = 1.333; static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); -// Stained glass / ice (material 3): a thin colored dielectric. IOR ~1.5 (soda-lime glass). -// Transmitted-ray origin offset when crossing a glass face. Must be SMALLER than the terrain-side -// TRANSLUCENT_INSET (RtTerrain, 2e-4 blocks): glass is recessed into its block by that inset, so a face -// touching a slab/stair leaves a tiny gap before the neighbour's surface. A large bias (SURF_BIAS) would -// restart the ray past that neighbour and see through it; this lands the ray inside the gap so the -// neighbour is still hit, while still clearing the glass surface to avoid self-intersection. -static const float GLASS_TRANSMIT_BIAS = 1.0e-4; +// Transmitted-ray origin offset when crossing a face from the TRANSLUCENT terrain layer (glass, ice). +// Must be SMALLER than the mesher-side TRANSLUCENT_INSET (RtTerrainMesher, 2e-4 blocks): those quads are +// recessed into their block by that inset, so a face touching a slab/stair leaves a tiny gap before the +// neighbour's surface. A large bias (SURF_BIAS) would restart the ray past that neighbour and see +// through it; this lands the ray inside the gap so the neighbour is still hit, while still clearing the +// surface it left to avoid self-intersection. Water is meshed by RtFluidMesher with no inset, so it uses +// the ordinary bias. +static const float INSET_TRANSMIT_BIAS = 1.0e-4; // Ray-cone texture LOD: raygen tracks a one-pixel footprint along the path, and closest-hit converts it // to texture mip levels from the local UV/world-space scale. Diffuse/glossy bounces widen the cone so @@ -162,14 +161,16 @@ float3 waterExtinction(float3 tint) { return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); } -// A volume dielectric's tint is a filter over one block of travel rather than a biome water colour, so it -// maps to extinction by inverting Beer-Lambert over that reference distance: a tint of t after 1 block -// means sigma = -ln(t). Clamped away from 0 so a fully saturated texel stays finite. +// A non-water dielectric's tint is a filter over one block of travel rather than a biome water colour, so +// it maps to extinction by inverting Beer-Lambert over that reference distance: transmittance t after one +// block means sigma = -ln(t). `transmission` scales that transmittance, so it keeps the sense the +// material's authored factor has everywhere else — 1 passes the tint through unchanged, 0 is opaque. +// Clamped away from zero so a fully saturated texel stays finite instead of producing an infinite sigma. static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks float3 volumeExtinction(float3 tint, float transmission) { - float3 filtered = clamp(lerp(float3(1.0, 1.0, 1.0), tint, clamp(transmission, 0.0, 1.0)), + float3 blockTransmittance = clamp(clamp(tint, 0.0, 1.0) * clamp(transmission, 0.0, 1.0), 1.0e-3, 1.0); - return -log(filtered) / VOLUME_TINT_REFERENCE_DISTANCE; + return -log(blockTransmittance) / VOLUME_TINT_REFERENCE_DISTANCE; } // ---- Participating medium the path is currently inside. @@ -827,7 +828,7 @@ static const uint SECONDARY_RIS_DIVISOR = 4u; // failure mode is noisier emitter light at depth rather than missing light. Unlike the divisor this also // drops shadeReservoir's per-vertex shadow ray, which never scaled with M. Gating at all: 16.1 -> 15.7ms. // -// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_GLASS and +// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_DIELECTRIC and // MATERIAL_WATER continue without shading anything, so looking through a pane spends bounce 0 on the // glass and lands the first shaded surface at bounce 1. Gating on `bounce` therefore starved surfaces // seen through glass or water — visually primary, but counted as depth — and they went black, since only @@ -1110,7 +1111,7 @@ float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, } // Deterministic transmitted destination for clear-interface RR guides. The first interface has already -// been crossed. Continue through thin glass and water until opaque/particle content or sky is reached; +// been crossed. Continue through every crossed dielectric until opaque/particle content or sky is reached; // TIR changes the walk into a reflected secondary ray instead of producing a false foreground guide. void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, float roughness, float3 diffuseAlbedo) { @@ -1125,7 +1126,7 @@ void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 surfaceBiasNormal, - float currentIor, float3 transmissionFilter, float rayBias, + float currentIor, float rayBias, float rayConeWidth, float rayConeSpread) { if (dot(transmittedDir, transmittedDir) <= 0.0) return; @@ -1140,8 +1141,7 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, rayConeWidth, rayConeSpread); if (payload.hitT <= 0.0) { setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, - float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), - 1.0, SKY_DIFF_ALBEDO * transmissionFilter); + float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, SKY_DIFF_ALBEDO); return; } @@ -1154,25 +1154,17 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, ? payload.albedo : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, - payload.normal, endpointRoughness, endpointAlbedo * transmissionFilter); + payload.normal, endpointRoughness, endpointAlbedo); return; } - if (material != MATERIAL_WATER && material != MATERIAL_GLASS) return; + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; + // Mirror the radiance path's refraction. Absorption inside the crossed media is deliberately NOT + // folded into the albedo guide: RR demodulates by albedo, and a distance-dependent attenuation + // baked into it would disagree with the colour, which is where the extinction actually lands. float3 interfaceNormal = normalize(payload.normal); float3 interfaceBiasNormal = interfaceNormal; - if (!payloadDielectricVolume()) { - // Thin slab: no direction or index change, but each crossed pane contributes its authored - // tint exactly as the radiance path does. - transmissionFilter *= payload.albedo * clamp(payloadTransmission(), 0.0, 1.0); - ro = interfacePos - interfaceNormal * GLASS_TRANSMIT_BIAS; - continue; - } - - // Volume: mirror the radiance path's refraction. Absorption inside the medium is deliberately - // NOT accumulated into transmissionFilter — RR demodulates by albedo, and folding a - // distance-dependent attenuation into the albedo guide would make it disagree with the colour. if (material == MATERIAL_WATER && (worldPush.flags & 16u) != 0u) { float waterFootprint = rayConeWidth / max(abs(dot(-direction, interfaceBiasNormal)), 0.2); @@ -1283,21 +1275,18 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint gv_emissionSource = payloadEmissionSource(); } - // ---- Dielectric interface: water, stained glass, ice. ONE handler, because the transport is the - // same physics — Fresnel from the relative index across the face, then reflect or transmit, with - // no diffuse response and no sun NEE (the surface is specular). What varies is per material, not - // per branch: - // * volume (PAYLOAD_DIELECTRIC_VOLUME): the interface bends the ray and pushes a participating - // medium, so the segment inside is attenuated by that medium's extinction. Water and ice. - // * thin: a collapsed slab whose two interfaces cancel — no bend, no medium, and the tint is - // applied once per crossing. Glass panes and vanilla-style windows, which should read as - // coloured windows rather than solid refracting cubes. - // * water additionally takes the animated wave normal and marks its medium for the caustic term. - // Which side of the face we are on comes from the geometry (payloadDielectricEntering), so a - // stray or missing face cannot desync the medium for the rest of the path. - if (material == MATERIAL_WATER || material == MATERIAL_GLASS) { + // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every + // dielectric is a volume. Fresnel from the relative index across the face, then reflect or + // refract; no diffuse response and no sun NEE, because the surface is specular. Transmission + // pushes or pops a participating medium, and the segment travelled inside it is attenuated by + // that medium's extinction rather than by a tint multiply at the interface. + // + // Water is the only material that adds anything: the animated wave normal, and a medium marked + // for the caustic term. Which side of the face we are on comes from the geometry + // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest + // of the path. + if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { bool isWater = material == MATERIAL_WATER; - bool volumeDielectric = payloadDielectricVolume(); bool entering = payloadDielectricEntering(); float3 tint = payload.albedo; float transmission = clamp(payloadTransmission(), 0.0, 1.0); @@ -1325,20 +1314,20 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint } } - // The medium this face opens into. Exiting returns to whatever enclosed it, which is what the - // stack remembers; a thin slab opens into nothing, so both sides stay in the current medium. + // The medium this face opens into, and the one it returns to on the way out — which is what + // the stack remembers. Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); float etaI = medium.current.ior; - // A thin slab still has the material's index at BOTH faces — it just never becomes the - // medium, so the ray does not bend and the enclosing medium never changes. Only a volume - // exit reads the enclosing index back off the stack. - float etaT = volumeDielectric && !entering ? medium.outer.ior : entered.ior; + float etaT = entering ? entered.ior : medium.outer.ior; float cosI = clamp(dot(-rd, n), 0.0, 1.0); float F = fresnelDielectric(cosI, etaI, etaT); - // A thin slab does not bend, so its "transmitted direction" is simply the incident one. - float3 transmittedDir = volumeDielectric ? refract(rd, n, etaI / etaT) : rd; - float transmitBias = volumeDielectric ? SURF_BIAS : GLASS_TRANSMIT_BIAS; + float3 transmittedDir = refract(rd, n, etaI / etaT); + // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a + // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full + // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water + // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; if (bounce == 0 && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM) { pathPrimaryDielectricHit = true; @@ -1346,9 +1335,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint } if (bounce == 0 && captureGuides) { // feed RR a smooth dielectric specular surface gv_normal = n; - // Transport here is an exact Fresnel interface, not a finite GGX lobe. A thin slab keeps - // its authored roughness because frosted/etched panes do scatter. - gv_rough = volumeDielectric ? 0.0 : clamp(payloadRoughness(), 0.0, 1.0); + gv_rough = 0.0; // an exact Fresnel interface, not a finite GGX lobe gv_albedo = float3(0.0, 0.0, 0.0); // fallback when TIR leaves no transmitted layer gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionHitCamRel = gv_hitCamRel; @@ -1359,8 +1346,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint if (dot(transmittedDir, transmittedDir) > 0.0) { resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, - volumeDielectric && entering ? entered.ior : medium.current.ior, - volumeDielectric ? float3(1.0, 1.0, 1.0) : tint * transmission, + entering ? entered.ior : medium.outer.ior, transmitBias, rayConeWidth, rayConeSpread); } } @@ -1376,16 +1362,12 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen rd = normalize(transmittedDir); ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); - if (volumeDielectric) { - // Crossing into or out of the volume. Absorption is the medium's job from here, so - // the tint is NOT also multiplied into the throughput — that would double-count it. - if (entering) { - mediumPush(medium, entered); - } else { - mediumPop(medium); - } + // Crossing into or out of the volume. Absorption is the medium's job from here, so the + // tint is NOT also multiplied into the throughput — that would double-count it. + if (entering) { + mediumPush(medium, entered); } else { - throughput *= tint * transmission; // collapsed slab: colour the ray once per crossing + mediumPop(medium); } } showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index 9b2edc74..cf2fb2b5 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -140,18 +140,13 @@ public static const uint PAYLOAD_SHOW_CELESTIAL = 4u; // Set by world.rchit on a terrain hit whose prim carries TERRAIN_PRIM_IN_LIGHT_BUFFER: this emitter is // RIS-sampled, so raygen gates its direct-hit emission on diffuse continuation rays (no double count). public static const uint PAYLOAD_EMITTER_IN_LIST = 128u; -// Set by world.rchit on any dielectric hit (water or glass): true when the incoming ray travels against -// the prim's outward face normal (entering the volume), false when it exits. Derived from face +// Set by world.rchit on any dielectric hit (water or glass/ice): true when the incoming ray travels +// against the prim's outward face normal (entering the volume), false when it exits. Derived from face // orientation rather than toggled, so a stray or missing face cannot corrupt the medium for the rest of // the path — each crossing re-derives which side it is on. Bit 2 (PAYLOAD_SHOW_CELESTIAL) is only // meaningful on a miss and this bit only on a hit, so they never need to be read together, but they get // distinct bits anyway to keep the two concerns unambiguous. public static const uint PAYLOAD_DIELECTRIC_ENTERING = 8u; -// Set by world.rchit when the hit material is a VOLUME dielectric: the interface refracts the ray and -// pushes a participating medium whose extinction attenuates the segment inside it. Clear means a THIN -// dielectric — a collapsed slab whose two interfaces cancel, so the ray passes straight through and the -// tint is applied once. Glass panes and vanilla-style windows are thin; ice and solid blocks are volume. -public static const uint PAYLOAD_DIELECTRIC_VOLUME = 256u; public static const uint PAYLOAD_EMISSION_SOURCE_SHIFT = 4u; public static const uint PAYLOAD_EMISSION_SOURCE_MASK = 7u; public static const uint EMISSION_SOURCE_NONE = 0u; @@ -161,9 +156,14 @@ public static const uint EMISSION_SOURCE_LAB_PBR = 3u; public static const uint PAYLOAD_MATERIAL_MASK = 3u; public static const uint MATERIAL_OPAQUE = 0u; +// Both dielectrics are volumes: they refract and push a participating medium. They stay distinct ids +// because water owns behaviour nothing else has — an animated wave surface, the caustic term, biome-tint +// absorption calibrated per block of depth, and a chit path fed by the fluid mesher rather than the +// (inset) translucent terrain layer. MATERIAL_DIELECTRIC is everything else transparent: glass, ice, and +// any solid transparent block a pack defines. public static const uint MATERIAL_WATER = 1u; public static const uint MATERIAL_PARTICLE = 2u; -public static const uint MATERIAL_GLASS = 3u; +public static const uint MATERIAL_DIELECTRIC = 3u; // Entity per-triangle record (48 B). The final lane mirrors TerrainPrim's integer material metadata. public struct Prim { @@ -203,8 +203,6 @@ public struct MaterialHeader { public static const uint MATERIAL_FEATURE_SPEC = 1u; public static const uint MATERIAL_FEATURE_NORMAL = 2u; public static const uint MATERIAL_FEATURE_HEURISTIC_EMISSION = 4u; -// This dielectric is a volume, not a thin slab: refract and track a medium. See PAYLOAD_DIELECTRIC_VOLUME. -public static const uint MATERIAL_FEATURE_DIELECTRIC_VOLUME = 8u; public static const uint MATERIAL_FEATURE_STOCHASTIC_ALPHA = 16u; // Final HDR emission strength (EMISSIVE_STRENGTH baseline * any JSON override multiplier), baked in Java // at material-compile time (RtMaterialRegistry) and packed here as a 16-bit fraction of the max — every diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java index 49ac1eea..58cd106a 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java @@ -5,25 +5,18 @@ import java.util.Map; /** - * Built-in refractive indices and thin/volume classification for the dielectric blocks vanilla ships. + * Built-in refractive indices for the dielectric blocks vanilla ships. * - *

Two properties decide how {@code world.rgen}'s dielectric interface behaves, and both are physical - * facts about the material rather than anything derivable from the render layer: + *

Every dielectric is a volume: {@code world.rgen} refracts at the interface and pushes a + * participating medium whose extinction attenuates the segment inside. IOR is therefore the whole + * material description — it drives both the Snell bend and the Fresnel split, and getting it wrong is + * visible as the wrong amount of distortion. Everything translucent used to share one per-model + * constant, so ice refracted exactly like window glass. * - *

    - *
  • IOR drives Snell refraction and the Fresnel split. Everything translucent used to share a - * single per-model constant (1.52 for glass, 1.333 for water), so ice refracted like window glass. - *
  • Volume vs thin. A volume dielectric bends the ray and pushes a participating medium whose - * extinction attenuates the segment inside it. A thin one is a collapsed slab: its two interfaces - * cancel, so the ray passes straight through and the tint is applied once. This is a deliberate - * modelling choice, not a measurement — a glass block is geometrically a cube, but treating it as a - * solid refracting cube makes windows unreadable, so vanilla-style glass stays thin while ice - * (which reads as a solid block of frozen water) becomes a volume. - *
- * - *

Sprite-keyed rather than block-keyed because the material registry compiles per sprite; a resource - * pack that renames textures simply falls back to the per-model default, and a - * {@code caustica/materials/*.json} rule overrides either value explicitly. + *

Sprite-keyed rather than block-keyed because the material registry compiles per sprite, and + * resolved once per sprite so it adds no variants to the profile x model x emission cross product. A + * resource pack that renames textures falls back to the soda-lime default, and a + * {@code caustica/materials/*.json} rule can set {@code transmission.ior} explicitly. */ public final class RtDielectrics { private RtDielectrics() {} @@ -35,39 +28,21 @@ private RtDielectrics() {} /** Ice Ih, slightly below liquid water. */ public static final float ICE_IOR = 1.309f; - /** - * How a dielectric interface behaves. {@code volume} true means refract and track a medium. - */ - public record Dielectric(float ior, boolean volume) {} - - private static final Dielectric THIN_GLASS = new Dielectric(GLASS_IOR, false); - private static final Dielectric VOLUME_ICE = new Dielectric(ICE_IOR, true); - public static final Dielectric WATER = new Dielectric(WATER_IOR, true); - - // Ice is the one vanilla family that genuinely reads as a solid transparent block, so it is the one - // that earns the volume treatment. Slime and honey are translucent but visually gelatinous rather - // than refractive, and nether portal is an emissive effect, so they stay thin. - private static final Map BY_SPRITE = Map.of( - "block/ice", VOLUME_ICE, - "block/packed_ice", VOLUME_ICE, - "block/blue_ice", VOLUME_ICE, - "block/frosted_ice_0", VOLUME_ICE, - "block/frosted_ice_1", VOLUME_ICE, - "block/frosted_ice_2", VOLUME_ICE, - "block/frosted_ice_3", VOLUME_ICE); + private static final Map IOR_BY_SPRITE = Map.of( + "block/ice", ICE_IOR, + "block/packed_ice", ICE_IOR, + "block/blue_ice", ICE_IOR, + "block/frosted_ice_0", ICE_IOR, + "block/frosted_ice_1", ICE_IOR, + "block/frosted_ice_2", ICE_IOR, + "block/frosted_ice_3", ICE_IOR); /** - * Built-in dielectric for a sprite, or the thin-glass default when the sprite is unrecognised. Only + * Built-in refractive index for a sprite, or soda-lime glass when the sprite is unrecognised. Only * meaningful for materials the mesher classified as translucent; opaque materials ignore it. */ - public static Dielectric forSprite(Identifier spriteName) { - if (spriteName == null) return THIN_GLASS; - Dielectric known = BY_SPRITE.get(spriteName.getPath()); - return known != null ? known : THIN_GLASS; - } - - /** The default used for fallback variants compiled without a sprite. */ - public static Dielectric defaultGlass() { - return THIN_GLASS; + public static float iorForSprite(Identifier spriteName) { + if (spriteName == null) return GLASS_IOR; + return IOR_BY_SPRITE.getOrDefault(spriteName.getPath(), GLASS_IOR); } } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java index dfdd0453..b244b8ee 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java @@ -58,10 +58,13 @@ static Rule parse(JsonObject root, Identifier source) { Integer model = null; if (root.has("model")) { + // Every dielectric is a volume now, so the old thin/volume names described nothing. "water" + // is the animated fluid surface (waves, caustics, biome-tint absorption); "dielectric" is + // every other transparent material. model = switch (root.get("model").getAsString()) { case "opaque" -> RtMaterialRegistry.MODEL_OPAQUE; - case "volume_dielectric" -> RtMaterialRegistry.MODEL_WATER; - case "thin_dielectric" -> RtMaterialRegistry.MODEL_GLASS; + case "water" -> RtMaterialRegistry.MODEL_WATER; + case "dielectric" -> RtMaterialRegistry.MODEL_DIELECTRIC; default -> throw new IllegalArgumentException("Unknown material model"); }; } @@ -84,14 +87,10 @@ static Rule parse(JsonObject root, Identifier source) { } Float transmission = null; Float ior = null; - Boolean volume = null; if (root.has("transmission")) { JsonObject value = root.getAsJsonObject("transmission"); transmission = optionalFloat(value, "factor"); ior = optionalFloat(value, "ior"); - // "volume": true refracts and tracks a participating medium (ice, solid dielectric blocks); - // false is a collapsed thin slab that passes the ray straight through (window glass, panes). - if (value.has("volume")) volume = value.get("volume").getAsBoolean(); } validate01("roughness", roughness); validate01("metalness", metalness); @@ -109,7 +108,7 @@ static Rule parse(JsonObject root, Identifier source) { emissionStrength = clamped; } return new Rule(source, sprite, block, model, roughness, metalness, ior, transmission, - volume, emissionStrength); + emissionStrength); } public List rules() { @@ -118,11 +117,6 @@ public List rules() { public record Rule(Identifier source, Identifier sprite, Identifier block, Integer model, Float roughness, Float metalness, Float ior, Float transmission, - /** - * Overrides the built-in thin/volume classification (see {@link RtDielectrics}). - * Null leaves whatever the material resolved to. - */ - Boolean volume, /** * Multiplier on whatever emission the material naturally resolves to (LabPBR * {@code _s}, heuristic mask, or state-uniform block light) — NOT a replacement. @@ -151,28 +145,22 @@ RtMaterialDesc apply(RtMaterialDesc base) { : (model != null ? defaultIor(nextModel) : base.ior()); float nextTransmission = transmission != null ? transmission : (model != null ? defaultTransmission(nextModel) : base.transmission()); - int nextFeatures = base.features(); - if (volume != null) { - nextFeatures = volume - ? nextFeatures | RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME - : nextFeatures & ~RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME; - } // A multiplier on the base's already-resolved strength (0 when emissionSource is NONE): // this can brighten/dim an existing emitter but never light up a genuinely non-emissive one. float nextEmissionStrength = emissionStrength != null ? base.emissionStrength() * emissionStrength : base.emissionStrength(); - return new RtMaterialDesc(nextModel, RtMaterialDesc.Source.OVERRIDE, nextFeatures, + return new RtMaterialDesc(nextModel, RtMaterialDesc.Source.OVERRIDE, base.features(), nextRoughness, nextMetalness, nextIor, nextTransmission, base.emissionSource(), nextEmissionStrength, base.emissionSummary()); } private static float defaultIor(int model) { return model == RtMaterialRegistry.MODEL_WATER ? RtDielectrics.WATER_IOR - : model == RtMaterialRegistry.MODEL_GLASS ? RtDielectrics.GLASS_IOR : 1.0f; + : model == RtMaterialRegistry.MODEL_DIELECTRIC ? RtDielectrics.GLASS_IOR : 1.0f; } private static float defaultTransmission(int model) { - return model == RtMaterialRegistry.MODEL_WATER || model == RtMaterialRegistry.MODEL_GLASS + return model == RtMaterialRegistry.MODEL_WATER || model == RtMaterialRegistry.MODEL_DIELECTRIC ? 1.0f : 0.0f; } } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java index e27ff106..69873d7e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java @@ -40,12 +40,10 @@ public final class RtMaterialRegistry { // with a plain mask. public static final int MODEL_OPAQUE = 0; public static final int MODEL_WATER = 1; - public static final int MODEL_GLASS = 3; + public static final int MODEL_DIELECTRIC = 3; public static final int FEATURE_SPEC = 1; public static final int FEATURE_NORMAL = 2; public static final int FEATURE_HEURISTIC_EMISSION = 4; - /** Mirrors MATERIAL_FEATURE_DIELECTRIC_VOLUME: refract and track a medium instead of a thin slab. */ - public static final int FEATURE_DIELECTRIC_VOLUME = 8; public static final int FEATURE_STOCHASTIC_ALPHA = 16; // HDR radiance of a full (level-15-equivalent) emitter, modulated by albedo — the single knob // (formerly duplicated as a literal in world.rgen.slang and RtLightCollector). Baked into every @@ -57,7 +55,7 @@ public final class RtMaterialRegistry { private static final float MAX_EMISSION_STRENGTH = 32.0f; private static final int MAX_LOD_SHIFT = 24; - private static final int MODEL_VARIANTS = 2; // ordinary opaque/cutout and thin glass + private static final int MODEL_VARIANTS = 2; // ordinary opaque/cutout and transparent dielectric private static final int EMISSION_VARIANTS = 2; // state-gated emission disabled/enabled private static final int VARIANT_OPAQUE = 0; private static final int VARIANT_GLASS = 1; @@ -132,7 +130,7 @@ public void rebuild(RtContext ctx, RtBlockMaterials blockMaterials, RtMaterialOv continue; } fallbackVariants[variant] = headers.size(); - add(headers, descriptions, grids, compileDesc(glass ? MODEL_GLASS : MODEL_OPAQUE, 0, + add(headers, descriptions, grids, compileDesc(glass ? MODEL_DIELECTRIC : MODEL_OPAQUE, 0, profile, emitting, true, RtMaterialDesc.EmissionSummary.NONE), transparentWhiteAverage(), fallbackEntry, null); } @@ -173,18 +171,18 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, break; } } - // Resolved once per sprite: IOR/volume is a property of the material, so it costs no extra - // variants — it varies with the sprite, not with the profile/glass/emitting cross product. - RtDielectrics.Dielectric dielectric = RtDielectrics.forSprite(sprite.contents().name()); + // Resolved once per sprite: IOR is a property of the material, so it costs no extra variants + // — it varies with the sprite, not with the profile/glass/emitting cross product. + float dielectricIor = RtDielectrics.iorForSprite(sprite.contents().name()); int[] variants = new int[profileVariants]; for (RtMaterials.Profile profile : SPRITE_PROFILES) { for (boolean glass : new boolean[]{false, true}) { for (boolean emitting : new boolean[]{false, true}) { int features = emitting ? baseFeatures : baseFeatures & ~FEATURE_HEURISTIC_EMISSION; - RtMaterialDesc desc = compileDesc(glass ? MODEL_GLASS : MODEL_OPAQUE, features, + RtMaterialDesc desc = compileDesc(glass ? MODEL_DIELECTRIC : MODEL_OPAQUE, features, profile, emitting, false, variantSummary(features, emitting, entry, stats.uniformSummary()), - dielectric); + dielectricIor); if (spriteWide != null) { desc = spriteWide.rule.apply(desc); } @@ -202,10 +200,10 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, for (boolean glass : new boolean[]{false, true}) { for (boolean emitting : new boolean[]{false, true}) { int features = emitting ? baseFeatures : baseFeatures & ~FEATURE_HEURISTIC_EMISSION; - RtMaterialDesc base = compileDesc(glass ? MODEL_GLASS : MODEL_OPAQUE, + RtMaterialDesc base = compileDesc(glass ? MODEL_DIELECTRIC : MODEL_OPAQUE, features, profile, emitting, false, variantSummary(features, emitting, entry, stats.uniformSummary()), - dielectric); + dielectricIor); RtMaterialDesc desc = compiled.rule.apply(base); overrideVariants[index(profile, glass, emitting)] = headers.size(); add(headers, descriptions, grids, desc, stats.average(), entry, stats.albedoGrid()); @@ -432,26 +430,23 @@ private static RtMaterialDesc compileDesc(int model, int features, RtMaterials.P boolean emitting, boolean neutral, RtMaterialDesc.EmissionSummary emissionSummary) { return compileDesc(model, features, profile, emitting, neutral, emissionSummary, - RtDielectrics.defaultGlass()); + RtDielectrics.GLASS_IOR); } private static RtMaterialDesc compileDesc(int model, int features, RtMaterials.Profile profile, boolean emitting, boolean neutral, RtMaterialDesc.EmissionSummary emissionSummary, - RtDielectrics.Dielectric dielectric) { - float roughness = model == MODEL_GLASS ? 0.0025f : profile.roughness(); // linear; s = 0.95 - float metalness = model == MODEL_GLASS ? 0.0f : profile.metalness(); - // Refractive index and thin/volume behaviour are per material, not per model: ice and window - // glass are both MODEL_GLASS but refract differently and only one of them is a volume. + float dielectricIor) { + float roughness = model == MODEL_DIELECTRIC ? 0.0025f : profile.roughness(); // linear; s = 0.95 + float metalness = model == MODEL_DIELECTRIC ? 0.0f : profile.metalness(); + // Refractive index is per material, not per model: ice and window glass are both + // MODEL_DIELECTRIC but bend light by measurably different amounts. float ior = switch (model) { case MODEL_WATER -> RtDielectrics.WATER_IOR; - case MODEL_GLASS -> dielectric.ior(); + case MODEL_DIELECTRIC -> dielectricIor; default -> 1.0f; }; - if (model == MODEL_WATER || (model == MODEL_GLASS && dielectric.volume())) { - features |= FEATURE_DIELECTRIC_VOLUME; - } - float transmission = model == MODEL_WATER || model == MODEL_GLASS ? 1.0f : 0.0f; + float transmission = model == MODEL_WATER || model == MODEL_DIELECTRIC ? 1.0f : 0.0f; boolean labPbr = (features & (FEATURE_SPEC | FEATURE_NORMAL)) != 0; RtMaterialDesc.Source source = neutral ? RtMaterialDesc.Source.NEUTRAL : (labPbr ? RtMaterialDesc.Source.LAB_PBR : RtMaterialDesc.Source.HEURISTIC); diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java index a5ca59a0..e5365478 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java @@ -4,39 +4,31 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; final class RtDielectricsTest { @Test - void iceFamilyRefractsAsAVolume() { + void iceFamilyRefractsAtIceIndex() { for (String sprite : new String[]{"block/ice", "block/packed_ice", "block/blue_ice", "block/frosted_ice_0", "block/frosted_ice_3"}) { - var dielectric = RtDielectrics.forSprite(Identifier.parse("minecraft:" + sprite)); - assertTrue(dielectric.volume(), sprite + " must be a volume dielectric"); - assertEquals(RtDielectrics.ICE_IOR, dielectric.ior(), 1.0e-6f, sprite); + assertEquals(RtDielectrics.ICE_IOR, + RtDielectrics.iorForSprite(Identifier.parse("minecraft:" + sprite)), 1.0e-6f, sprite); } } @Test - void windowGlassStaysThinSoItReadsAsAWindow() { - var glass = RtDielectrics.forSprite(Identifier.parse("minecraft:block/glass")); - assertFalse(glass.volume()); - assertEquals(RtDielectrics.GLASS_IOR, glass.ior(), 1.0e-6f); + void unknownAndNullSpritesFallBackToSodaLimeGlass() { + assertEquals(RtDielectrics.GLASS_IOR, + RtDielectrics.iorForSprite(Identifier.parse("minecraft:block/glass")), 1.0e-6f); + assertEquals(RtDielectrics.GLASS_IOR, + RtDielectrics.iorForSprite(Identifier.parse("somemod:block/weird_crystal")), 1.0e-6f); + assertEquals(RtDielectrics.GLASS_IOR, RtDielectrics.iorForSprite(null), 1.0e-6f); } @Test - void unknownAndNullSpritesFallBackToThinGlass() { - var modded = RtDielectrics.forSprite(Identifier.parse("somemod:block/weird_crystal")); - assertFalse(modded.volume()); - assertEquals(RtDielectrics.GLASS_IOR, modded.ior(), 1.0e-6f); - assertEquals(RtDielectrics.defaultGlass(), RtDielectrics.forSprite(null)); - } - - @Test - void iceRefractsLessStronglyThanGlass() { - // Ice Ih sits just below liquid water, which sits well below soda-lime glass. Getting this - // ordering wrong is the whole reason the per-model constant was not good enough. + void iceRefractsLessStronglyThanWaterWhichRefractsLessThanGlass() { + // Ice Ih sits just below liquid water, which sits well below soda-lime glass. Collapsing these + // onto one per-model constant is exactly what made ice refract like a window. assertTrue(RtDielectrics.ICE_IOR < RtDielectrics.WATER_IOR); assertTrue(RtDielectrics.WATER_IOR < RtDielectrics.GLASS_IOR); } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java index 16368f41..05006eb3 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java @@ -14,13 +14,13 @@ final class RtMaterialOverridesTest { void parsesVersionedExtensibleMaterialProperties() { var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" {"format":1,"match":{"block":"minecraft:blue_stained_glass", - "sprite":"minecraft:block/blue_stained_glass"},"model":"thin_dielectric", + "sprite":"minecraft:block/blue_stained_glass"},"model":"dielectric", "base":{"roughness":0.06,"metalness":0.0}, "emission":{"strength":2.0,"color_source":"albedo"}, "transmission":{"factor":1.0,"ior":1.52}} """).getAsJsonObject(), Identifier.parse("test:caustica/materials/glass.json")); assertEquals(Identifier.parse("minecraft:block/blue_stained_glass"), rule.sprite()); - assertEquals(RtMaterialRegistry.MODEL_GLASS, rule.model()); + assertEquals(RtMaterialRegistry.MODEL_DIELECTRIC, rule.model()); assertEquals(1.52f, rule.ior()); assertEquals(2.0f, rule.emissionStrength()); RtMaterialDesc base = new RtMaterialDesc(RtMaterialRegistry.MODEL_OPAQUE, @@ -39,33 +39,37 @@ void parsesVersionedExtensibleMaterialProperties() { } @Test - void transmissionVolumeFlagTogglesTheDielectricFeatureBit() { - RtMaterialDesc thinBase = new RtMaterialDesc(RtMaterialRegistry.MODEL_GLASS, - RtMaterialDesc.Source.HEURISTIC, 0, 0.0025f, 0.0f, 1.52f, 1.0f, + void transmissionIorOverridesTheBuiltInIndex() { + RtMaterialDesc glassBase = new RtMaterialDesc(RtMaterialRegistry.MODEL_DIELECTRIC, + RtMaterialDesc.Source.HEURISTIC, 0, 0.0025f, 0.0f, RtDielectrics.GLASS_IOR, 1.0f, RtMaterialDesc.EmissionSource.NONE, 0.0f, RtMaterialDesc.EmissionSummary.NONE); - var toVolume = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"minecraft:block/ice"}, - "transmission":{"ior":1.309,"volume":true}} - """).getAsJsonObject(), Identifier.parse("test:caustica/materials/ice.json")); - RtMaterialDesc volume = toVolume.apply(thinBase); - assertEquals(1.309f, volume.ior()); - assertTrue((volume.features() & RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME) != 0); - - // The flag must be able to turn the bit OFF as well, or a pack could not make a modded solid - // dielectric behave like a window pane. - var toThin = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"minecraft:block/ice"}, - "transmission":{"volume":false}} - """).getAsJsonObject(), Identifier.parse("test:caustica/materials/ice.json")); - assertFalse((toThin.apply(volume).features() - & RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME) != 0); + var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" + {"format":1,"match":{"sprite":"somemod:block/crystal"}, + "model":"dielectric","transmission":{"ior":2.417}} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/crystal.json")); + RtMaterialDesc applied = rule.apply(glassBase); + assertEquals(RtMaterialRegistry.MODEL_DIELECTRIC, applied.model()); + assertEquals(2.417f, applied.ior()); - // Omitting it leaves whatever the material resolved to. + // Omitting ior on a rule that does not change the model leaves the base index alone. var silent = RtMaterialOverrides.parse(JsonParser.parseString(""" - {"format":1,"match":{"sprite":"minecraft:block/ice"},"base":{"roughness":0.5}} - """).getAsJsonObject(), Identifier.parse("test:caustica/materials/ice.json")); - assertTrue((silent.apply(volume).features() - & RtMaterialRegistry.FEATURE_DIELECTRIC_VOLUME) != 0); + {"format":1,"match":{"sprite":"somemod:block/crystal"},"base":{"roughness":0.5}} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/crystal.json")); + assertEquals(RtDielectrics.GLASS_IOR, silent.apply(glassBase).ior()); + } + + @Test + void waterModelKeepsItsOwnIndexWhenSelectedByName() { + var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" + {"format":1,"match":{"sprite":"somemod:block/pool"},"model":"water"} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/pool.json")); + RtMaterialDesc base = new RtMaterialDesc(RtMaterialRegistry.MODEL_OPAQUE, + RtMaterialDesc.Source.HEURISTIC, 0, 0.8f, 0.0f, 1.0f, 0.0f, + RtMaterialDesc.EmissionSource.NONE, 0.0f, RtMaterialDesc.EmissionSummary.NONE); + RtMaterialDesc applied = rule.apply(base); + assertEquals(RtMaterialRegistry.MODEL_WATER, applied.model()); + assertEquals(RtDielectrics.WATER_IOR, applied.ior()); + assertEquals(1.0f, applied.transmission()); } @Test From 5fae79c67a90dceddab40201561b5e5c99bbb9dc Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:46:36 +0900 Subject: [PATCH 06/25] Stop total internal reflection leaking into the ordinary motion vector Glass became a volume dielectric, which made TIR common where it was previously impossible: the critical angle for 1.52 -> air is ~41 degrees, so a ray refracted into a glass block and striking a side face is very often past it. That exposed a latent bug in the transmission-guide walk. On TIR the walk switched to the reflected direction and kept going, then reported whatever it landed on through setTransmissionGuide, which marks its endpoint as its own feature (gv_motionUseRefracted). main() therefore wrote that point's own reprojection delta into gMotion. That is valid for a genuinely refracted destination, where the near-constant refraction offset cancels between frames, but wrong for a mirror image: a virtual image sweeps at roughly twice the camera's rate and in the opposite sense, so the error grew with camera motion and showed as large, visibly wrong motion vectors on glass. TIR now bails out of the walk instead, leaving the foreground interface tuple the caller already wrote. That is what TIR physically deserves: F is 1, so specular albedo is 1 and diffuse albedo is 0, the pixel is entirely reflection, and gSpecMotion already describes it correctly via the mirror-image reprojection in previousReflectionNdc. Depth stays on the interface, the only real surface there. The guide walk's cull mask no longer changes mid-walk, so it is a constant again. Also guard the perspective divides in both motion paths, which the depth computation two lines above already did but the motion vectors did not. A guide point can project behind either camera -- most easily a mirror image, which sits as far behind the reflector as its source is in front -- and w <= 0 turns the divide into an arbitrarily large vector handed straight to DLSS-RR. Report no motion there and let RR fall back. This covers gMotion (both the direct and transmitted-feature branches) and gSpecMotion, which had the same latent hazard even though the reported artifact was in the ordinary MV. Shaders compile and pass spirv-val; 39 tests pass. NOT GPU-verified. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 60 ++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 97d64bbe..2baa9f52 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -1054,9 +1054,14 @@ VisibilityResult visibility(float3 origin, float3 dir, float tmax) { return result; } -float2 projectPrevNdc(float3 worldPos) { +// `valid` is false when the point projects behind the previous camera, where the perspective divide +// would otherwise manufacture an arbitrarily large NDC. Mirror images reach that case easily: a virtual +// image sits as far behind the reflector as the source is in front, so a reflection seen at a grazing +// angle can land behind the eye even though the reflector itself is comfortably in view. +float2 projectPrevNdc(float3 worldPos, out bool valid) { float4 clip = mul(worldPush.prevViewProj, float4(worldPos - worldPush.camOffset + worldPush.camDelta, 1.0)); - return clip.xy / clip.w; + valid = clip.w > 0.0; + return valid ? clip.xy / clip.w : float2(0.0, 0.0); } // Previous-frame screen position of a planar reflection. The reflected image of a world point P seen @@ -1066,11 +1071,12 @@ float2 projectPrevNdc(float3 worldPos) { // explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so // static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); // reflectedMotionPrev carries the reflected content's own displacement. -float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, float3 reflectedMotionPrev) { +float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, + float3 reflectedMotionPrev, out bool valid) { float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed float3 prevHit = reflectedWorldPos - reflectedMotionPrev; float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - surfacePos, n); - return projectPrevNdc(mirroredHit); + return projectPrevNdc(mirroredHit, valid); } // Reflection motion remains owned by the physical foreground interface even when transmission later @@ -1105,9 +1111,10 @@ float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, } float3 previousN = length(surface.previousNormal) >= 0.5 ? normalize(surface.previousNormal) : n; + bool prevValid; float2 prevNdc = previousReflectionNdc( - surfacePos, previousN, reflectedHit, reflectedMotionPrev); - return (prevNdc - currentNdc) * 0.5 * size; + surfacePos, previousN, reflectedHit, reflectedMotionPrev, prevValid); + return prevValid ? (prevNdc - currentNdc) * 0.5 * size : float2(0.0, 0.0); } // Deterministic transmitted destination for clear-interface RR guides. The first interface has already @@ -1133,11 +1140,10 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 direction = normalize(transmittedDir); // surfaceBiasNormal is a closest-hit normal, already unit length, and only its sign matters here. float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); - uint guideVisibilityMask = CULL_PRIMARY; currentIor = max(currentIor, 1.0); for (uint crossing = 0u; crossing < MAX_TRANSMISSION_GUIDE_HITS; ++crossing) { - traceGuide(guideVisibilityMask, ro, RAY_TMIN, direction, 10000.0, + traceGuide(CULL_PRIMARY, ro, RAY_TMIN, direction, 10000.0, rayConeWidth, rayConeSpread); if (payload.hitT <= 0.0) { setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, @@ -1177,12 +1183,21 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float targetIor = payloadDielectricEntering() ? max(payloadIor(), 1.0) : 1.0; float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); if (dot(nextDirection, nextDirection) <= 0.0) { - guideVisibilityMask = CULL_SECONDARY; - direction = normalize(reflect(direction, interfaceNormal)); - } else { - direction = normalize(nextDirection); - currentIor = targetIor; + // Total internal reflection: there is no transmitted destination to describe, and following + // the reflection instead would be actively harmful. setTransmissionGuide marks its endpoint + // as its own feature, whose MV is that point's own reprojection delta — valid for a + // refracted point (the near-constant refraction offset cancels between frames) but WRONG for + // a mirror image, which sweeps at roughly twice the camera's rate and in the opposite sense. + // That produced motion vectors that grew with camera motion. + // + // Bailing leaves the foreground interface tuple the caller already wrote, which is what TIR + // physically deserves: F is 1, so specular albedo is 1 and diffuse albedo is 0, the pixel is + // pure reflection, and gSpecMotion already describes it with a proper mirror-image + // reprojection. Depth stays on the interface, which is the only real surface here. + return; } + direction = normalize(nextDirection); + currentIor = targetIor; ro = offsetSurfaceOrigin( interfacePos, interfaceBiasNormal, direction, SURF_BIAS); } @@ -1693,15 +1708,24 @@ void main() { // For a dynamic surface, previous-frame position is the current point minus its world-space // displacement; subtracting gv_motionObjDisp de-cameras the MV. float4 prevClip = mul(worldPush.prevViewProj, float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); - float2 prevNdc = prevClip.xy / prevClip.w; float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc // Transmitted content is its own feature: compare its previous and current projections rather than // subtracting the primary interface's jittered NDC. - if (gv_motionUseRefracted) { - float4 curClipRefr = mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)); - curNdc = curClipRefr.xy / curClipRefr.w; + float4 curClipMotion = gv_motionUseRefracted + ? mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)) + : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform + // Guard both perspective divides the way the depth above does. A guide point can land behind either + // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the + // frustum as the camera turns — and w at or below zero turns the divide into an arbitrarily large + // vector that DLSS-RR reprojects with. No valid previous position exists in that case, so report no + // motion and let RR fall back instead of handing it garbage. + float2 motion; + if (prevClip.w > 0.0 && (!gv_motionUseRefracted || curClipMotion.w > 0.0)) { + if (gv_motionUseRefracted) curNdc = curClipMotion.xy / curClipMotion.w; + motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; + } else { + motion = float2(0.0, 0.0); } - float2 motion = (prevNdc - curNdc) * 0.5 * size; // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. gNormal[pix] = float4(gv_normal, gv_rough); From 957569b9f772c04ae09a8054b38fd37ed8047bc2 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:48:11 +0900 Subject: [PATCH 07/25] add logs to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d5a3ee6f..69b5d54a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ out/ *.class third_party/ bin/ +/logs From db6418baf4e7afa6b96b33918b9ec22eb8e6111e Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:44:49 +0900 Subject: [PATCH 08/25] Drop the emission-mask/emission-source debug views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Views 8 (Emission Mask) and 9 (Emission Source) were the only consumers of gv_emission/gv_emissionSource, payloadEmissionSource(), and the extra guide capture at bounce 0 that fed them. All of it is gone rather than deferred: payloadEmission()/EMISSION_SOURCE_* stay, since world.rchit still packs them into the payload for actual emissive shading — only the raygen-side debug unpacking and display path is removed. RtVideoOptions' debugView option drops from a 0-9 enum to 0-7, and the two English-only debug-view labels (no other locale had them translated) are gone. The push-constant field itself is untouched; an out-of-range stored value still falls through to the shader's default (motion) view. 39 tests pass; world.rgen.spv shrinks (1030576 -> 1017692 bytes). Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 20 ------------------- .../caustica/client/RtVideoOptions.java | 4 ++-- .../resources/assets/caustica/lang/en_us.json | 4 +--- 3 files changed, 3 insertions(+), 25 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 2baa9f52..2d3f2bd9 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -53,8 +53,6 @@ struct VisibilityResult { static float3 gv_normal; static float3 gv_albedo; static float gv_rough; -static float gv_emission; -static uint gv_emissionSource; // Linear VIEW-SPACE depth guide inputs; sky resolves to a large far value (no negative sentinel). static float gv_depth; static float3 gv_hitCamRel; // primary-surface hit position relative to the current camera @@ -101,9 +99,6 @@ static bool pathPrimaryDielectricHit; static float pathPrimaryDielectricF; uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } -uint payloadEmissionSource() { - return (payload.flags >> PAYLOAD_EMISSION_SOURCE_SHIFT) & PAYLOAD_EMISSION_SOURCE_MASK; -} // Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was // travelling into the volume (vs. out of it) at this hit's face. bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } @@ -1260,8 +1255,6 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // the brightness in the lighting channel where RR resolves it cleanly. gv_albedo = SKY_DIFF_ALBEDO; gv_rough = 1.0; - gv_emission = 0.0; - gv_emissionSource = EMISSION_SOURCE_NONE; gv_hitCamRel = rd * 1.0e6; // sky at infinity: only rotation parallax (-> large far depth) gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; @@ -1285,10 +1278,6 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint float3 hitPos = ro + rd * payload.hitT; rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); uint material = payloadMaterial(); - if (bounce == 0 && captureGuides) { - gv_emission = payloadEmission(); - gv_emissionSource = payloadEmissionSource(); - } // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every // dielectric is a volume. Fresnel from the relative index across the face, then reflect or @@ -1750,15 +1739,6 @@ void main() { dbg = gv_spec.albedo; // exact value bound to DLSS-RR } else if (pc.debugView == 7u) { dbg = float3(clamp(0.5 + specMotion * 0.05, 0.0, 1.0), 0.5); // reflection motion - } else if (pc.debugView == 8u) { - dbg = float3(gv_emission, gv_emission, gv_emission); // resolved emission mask/strength - } else if (pc.debugView == 9u) { - float3 sourceColor = gv_emissionSource == EMISSION_SOURCE_LAB_PBR ? float3(0.15, 0.8, 1.0) - : gv_emissionSource == EMISSION_SOURCE_HEURISTIC ? float3(1.0, 0.45, 0.05) - : gv_emissionSource == EMISSION_SOURCE_UNIFORM ? float3(1.0, 1.0, 1.0) - : float3(0.0, 0.0, 0.0); - dbg = sourceColor * (gv_emissionSource == EMISSION_SOURCE_NONE - ? 0.0 : max(0.2, clamp(gv_emission, 0.0, 1.0))); } else { dbg = float3(clamp(0.5 + motion * 0.05, 0.0, 1.0), 0.5); // motion: red=+x, green=+y } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index b540c568..3ef0b06f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -178,8 +178,8 @@ private static OptionInstance debugView() { // CycleButton (used for Enum values) already prepends "caption: " itself (DisplayState. // NAME_AND_VALUE), so this must return only the value's text, not caption + value again. (caption, value) -> Component.translatable("caustica.options.rt.debugView." + value), - new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), Codec.INT), - Math.clamp(setting.value(), 0, 9), + new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7), Codec.INT), + Math.clamp(setting.value(), 0, 7), setting::set); } diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index 364a2e04..323cf7cf 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -56,7 +56,5 @@ "caustica.options.rt.debugView.4": "Roughness", "caustica.options.rt.debugView.5": "Motion", "caustica.options.rt.debugView.6": "Specular", - "caustica.options.rt.debugView.7": "Specular Motion", - "caustica.options.rt.debugView.8": "Emission Mask", - "caustica.options.rt.debugView.9": "Emission Source" + "caustica.options.rt.debugView.7": "Specular Motion" } From b4a0e7b64295eeea68ee22d097a4b8246d0a5be0 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:56:33 +0900 Subject: [PATCH 09/25] Trace the path once: continuations as data, not a second inlined copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nsight put the raygen's problem in plain view. Every trace callsite reported exactly TWO calling contexts, because main() called tracePath from two textual sites (the transmission and reflection branches of the primary dielectric split) and the compiler inlined the whole path tracer at both. Live state at the primary trace had gone 308B -> 2932B against main, and the top stall had flipped from LGSB to NOINST — an instruction-fetch stall, i.e. the shader had outgrown its instruction cache. The second copy was also largely dead at runtime: every guide-capture block and the whole resolveTransmissionGuide walk sit behind captureGuides, which is false on the reflection branch. It could never execute there and only occupied I-cache. Continuations are now data. PathSegment carries everything needed to resume tracing (ro, rd, throughput, medium, cone, seed, bounce, diffuseDepth, and the capture/split permissions), tracePath takes one and may hand back another, and main consumes them through a single call site in a bounded [loop]. The [loop] attribute is load-bearing: unrolling would duplicate the path tracer again and undo the whole change. The split itself gets simpler as a result. Instead of a global branch selector steering two whole-path retraces that main recombined by F afterwards, the dielectric multiplies its own throughput by (1-F) and emits the reflection as a segment weighted by F. Segments are then just summed. The pre-interface contribution is accumulated once instead of once per branch, and the reflection inherits the medium and the accumulated Beer-Lambert directly instead of retracing the camera ray to rediscover them. Energy is unchanged. Also hoists the guide resolve out of the tail: writeGuides() runs immediately after the capturing sample, so the gv_* state dies there instead of staying live across every later sample's traces (the single largest live value in the profile was gv_hitCamRel at 504B). The debug views read the guide images back rather than gv_*, so no guide state survives merely to service a branch that is off in every ordinary frame. Verified in the SPIR-V: OpReorderThreadWithHitObjectEXT and OpHitObjectExecuteShaderEXT are down to 1 each (one bounce loop), and OpHitObjectTraceRayEXT to 6 = 1 radiance + 5 visibility() sites, previously 10 contexts. world.rgen.spv: 1017692 -> 645972 bytes, a 36.5% reduction. Shaders compile and pass spirv-val; 39 tests pass. NOT GPU-verified, and the sampling sequence changed (segments derive their own seeds), so noise will differ frame to frame even though the estimator is unchanged. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 286 ++++++++++++++++++++------------- 1 file changed, 177 insertions(+), 109 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 2d3f2bd9..4c20b0b6 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -53,8 +53,6 @@ struct VisibilityResult { static float3 gv_normal; static float3 gv_albedo; static float gv_rough; -// Linear VIEW-SPACE depth guide inputs; sky resolves to a large far value (no negative sentinel). -static float gv_depth; static float3 gv_hitCamRel; // primary-surface hit position relative to the current camera static float3 gv_motionHitCamRel; // motion-guide hit position; water tracks the refracted hit instead static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit (its own reprojection delta) @@ -92,11 +90,6 @@ SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float return makeSpecSurface(camRel, normal, normal, normal, roughness, albedo); } -// Per-call control/results for the primary dielectric estimator. Ordinary pixels still trace once; -// a primary glass/water hit traces both Fresnel continuations and recombines them exactly. -static uint pathPrimaryDielectricBranch; -static bool pathPrimaryDielectricHit; -static float pathPrimaryDielectricF; uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } // Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was @@ -216,6 +209,49 @@ void mediumPop(inout MediumStack stack) { stack.outer = airMedium(); } +// Everything needed to resume tracing from a point in the scene. The path tracer takes one of these and +// may hand back another, which is how a dielectric's two Fresnel continuations are traced without +// instantiating the path tracer twice: the second branch is DATA, not a second inlined copy. `throughput` +// already carries the branch weight, so segments are simply summed. +// +// This is deliberately shaped like a record that could live in a buffer rather than a register: the +// planned split into a primary/guide pass and an indirect pass hands continuations between dispatches, +// and this is the payload it would write. +struct PathSegment { + float3 ro; + float3 rd; + float3 throughput; + MediumStack medium; + float rayConeWidth; + float rayConeSpread; + uint seed; + int bounce; // interfaces already consumed, so RR start and the bounce cap stay global + int diffuseDepth; // surfaces already SHADED (see the field of the same name in tracePath) + bool showCelestial; + bool captureGuides; + bool maySplit; // may still spawn a deterministic Fresnel branch +}; + +PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, + float rayConeWidth, float rayConeSpread, uint seed, + int bounce, int diffuseDepth, bool showCelestial, + bool captureGuides, bool maySplit) { + PathSegment s; + s.ro = ro; + s.rd = rd; + s.throughput = throughput; + s.medium = medium; + s.rayConeWidth = rayConeWidth; + s.rayConeSpread = rayConeSpread; + s.seed = seed; + s.bounce = bounce; + s.diffuseDepth = diffuseDepth; + s.showCelestial = showCelestial; + s.captureGuides = captureGuides; + s.maySplit = maySplit; + return s; +} + // Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume // dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { @@ -393,9 +429,9 @@ static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= // reflection MV is left at zero and RR falls back to the ordinary MV. static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; -static const uint PRIMARY_DIELECTRIC_RANDOM = 0u; -static const uint PRIMARY_DIELECTRIC_TRANSMISSION = 1u; -static const uint PRIMARY_DIELECTRIC_REFLECTION = 2u; +// Continuations one sample may carry: the camera segment, plus the reflection branch a primary +// dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. +static const uint MAX_PATH_SEGMENTS = 2u; float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } @@ -1199,14 +1235,19 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, } // One Monte Carlo path from (ro, rd). Returns accumulated radiance along the path. -float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint sampleIndex, - bool captureGuides, inout uint seed) { +float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, + out PathSegment pending, out bool hasPending) { float3 L = float3(0.0, 0.0, 0.0); - float3 throughput = float3(1.0, 1.0, 1.0); - pathPrimaryDielectricHit = false; - pathPrimaryDielectricF = 0.0; - float rayConeWidth = 0.0; - float rayConeSpread = max(primaryConeSpread, RAY_CONE_MIN_SPREAD); + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + uint seed = seg.seed; + bool captureGuides = seg.captureGuides; + bool maySplit = seg.maySplit; + pending = seg; // `out` must be written on every path; hasPending gates whether it is read + hasPending = false; + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); int maxBounces = int(worldPush.maxBounces); int rrStart = maxBounces <= 3 ? 1 : 2; // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the @@ -1219,24 +1260,22 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; proposalSeed = pcg(proposalSeed); - // Start submerged if the camera is in water, so the first segment already carries the right relative - // index and absorption when the eye is underwater. The camera-biome tint is the correct extinction - // until the first water surface is crossed, after which each hit supplies its own body's tint. - MediumStack medium = makeMediumStack((worldPush.flags & 1u) != 0u - ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) - : airMedium()); + // The medium the segment starts in. For the camera segment that is water when the eye is submerged + // (so the first ray already carries the right relative index and absorption); for a segment split off + // a dielectric it is whatever the parent was travelling through. + MediumStack medium = seg.medium; bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. - bool showCelestial = true; + bool showCelestial = seg.showCelestial; // Number of surfaces this path has SHADED so far, as distinct from `bounce`, which also counts // dielectric interfaces. Glass and water continue without shading anything, so a wall behind a pane // is bounce 1 but diffuseDepth 0 — visually primary, and it must be treated as such. See // RIS_MAX_DIFFUSE_DEPTH. - int diffuseDepth = 0; - for (int bounce = 0; bounce <= maxBounces; bounce++) { + int diffuseDepth = seg.diffuseDepth; + for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person @@ -1333,10 +1372,6 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - if (bounce == 0 && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM) { - pathPrimaryDielectricHit = true; - pathPrimaryDielectricF = F; - } if (bounce == 0 && captureGuides) { // feed RR a smooth dielectric specular surface gv_normal = n; gv_rough = 0.0; // an exact Fresnel interface, not a finite GGX lobe @@ -1355,10 +1390,23 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint } } - bool chooseReflection = bounce == 0 - && pathPrimaryDielectricBranch != PRIMARY_DIELECTRIC_RANDOM - ? pathPrimaryDielectricBranch == PRIMARY_DIELECTRIC_REFLECTION - : rndf(seed) < F; + // Deterministic split at the first interface a visually-primary path meets: trace BOTH + // continuations and weight them by F exactly, instead of picking one and paying the variance. + // The reflection leaves as a pending segment rather than a recursive call, so the path tracer + // is instantiated once. It stays on the incidence side, so it inherits the medium unchanged. + bool split = maySplit && diffuseDepth == 0; + if (split) { + float3 splitDir = reflect(rd, n); + pending = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, splitDir, SURF_BIAS), splitDir, + throughput * F, medium, rayConeWidth, rayConeSpread, seed ^ 0xa511e9b3u, + bounce + 1, diffuseDepth, true, false, false); + hasPending = true; + maySplit = false; + throughput *= 1.0 - F; // this segment is now the transmitted branch only + } + + bool chooseReflection = split ? false : rndf(seed) < F; if (chooseReflection) { rd = reflect(rd, n); ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); @@ -1617,78 +1665,20 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint return L; } -[shader("raygeneration")] -void main() { - worldPush = ConstPtr(pc.worldPushAddr)[0]; // load the per-frame push data from the BDA buffer - int2 pix = int2(DispatchRaysIndex().xy); - float2 size = float2(DispatchRaysDimensions().xy); - float2 uv = (float2(pix) + 0.5) / size; - float2 ndc = uv * 2.0 - 1.0; // unjittered pixel centre — the motion vector reprojects this - - // Offset the primary ray within the pixel by the sub-pixel jitter reported to DLSS-RR, so - // successive frames sample different positions and RR can resolve detail above render resolution. - // The guide buffers below come from this jittered first hit (they must match the jittered colour); - // only the motion vector stays on the unjittered centre, which DLSS expects. - float2 jndc = (uv + worldPush.jitter / size) * 2.0 - 1.0; - - float4 nearH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 1.0, 1.0)); - float4 farH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 0.0, 1.0)); - float3 nearP = nearH.xyz / nearH.w; - float3 farP = farH.xyz / farH.w; - - float3 origin = nearP + worldPush.camOffset; - float3 dir = normalize(farP - nearP); - float rayConeSpread = primaryRayConeSpread(jndc, size, dir); - - uint seed = (uint(pix.x) * 1973u + uint(pix.y) * 9277u + 26699u) ^ (worldPush.frameIndex * 2654435761u); - - uint spp = max(worldPush.spp, 1u); - float3 frameRadiance = float3(0.0, 0.0, 0.0); - for (uint s = 0u; s < spp; s++) { - seed = pcg(seed); // decorrelate per-sample paths (shared camera ray, no AA jitter yet) - uint transmissionSeed = seed; - pathPrimaryDielectricBranch = PRIMARY_DIELECTRIC_TRANSMISSION; - // Guides describe one deterministic surface, so only the first sample's transmission branch - // writes them; the reflection branch and every later sample reuse what it captured. - float3 transmissionRadiance = tracePath(origin, dir, rayConeSpread, uint2(pix), s, - s == 0u, transmissionSeed); - - bool primaryDielectricHit = pathPrimaryDielectricHit; - float primaryDielectricF = pathPrimaryDielectricF; - float3 sampleRadiance = transmissionRadiance; - - if (primaryDielectricHit) { - uint reflectionSeed = seed ^ 0xa511e9b3u; - reflectionSeed = pcg(reflectionSeed); - pathPrimaryDielectricBranch = PRIMARY_DIELECTRIC_REFLECTION; - float3 reflectionRadiance = tracePath(origin, dir, rayConeSpread, uint2(pix), s + spp, - false, reflectionSeed); - - float dielectricF = saturate(primaryDielectricF); - sampleRadiance = - dielectricF * reflectionRadiance - + (1.0 - dielectricF) * transmissionRadiance; - seed = transmissionSeed ^ reflectionSeed; - seed = pcg(seed); - } else { - seed = transmissionSeed; - } - - frameRadiance += sampleRadiance; - } - pathPrimaryDielectricBranch = PRIMARY_DIELECTRIC_RANDOM; - frameRadiance /= float(spp); - - // Reflection remains owned by the foreground interface even when glass/water replaced the ordinary +// Resolve the captured gv_* guide state into the DLSS-RR guide images. Called once, right after the +// sample that captured them, so the guide state dies there instead of staying live across every +// remaining sample's traces. +void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { + // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary // tuple with the transmitted destination. - float2 specMotion = specularReflectionMotion(gv_spec, dir, jndc, size, rayConeSpread); + float2 specMotion = specularReflectionMotion(gv_spec, primaryDir, jndc, size, primaryConeSpread); // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); - gv_depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; + float depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; // Motion vector: reproject this guide point through the previous frame's view-projection. // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is @@ -1696,7 +1686,8 @@ void main() { // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. // For a dynamic surface, previous-frame position is the current point minus its world-space // displacement; subtracting gv_motionObjDisp de-cameras the MV. - float4 prevClip = mul(worldPush.prevViewProj, float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); + float4 prevClip = mul(worldPush.prevViewProj, + float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc // Transmitted content is its own feature: compare its previous and current projections rather than // subtracting the primary interface's jittered NDC. @@ -1719,28 +1710,105 @@ void main() { // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. gNormal[pix] = float4(gv_normal, gv_rough); gAlbedo[pix] = float4(gv_albedo, 1.0); - gDepth[pix] = gv_depth; + gDepth[pix] = depth; gMotion[pix] = motion; gSpecAlbedo[pix] = float4(gv_spec.albedo, 1.0); gSpecMotion[pix] = specMotion; +} + +[shader("raygeneration")] +void main() { + worldPush = ConstPtr(pc.worldPushAddr)[0]; // load the per-frame push data from the BDA buffer + int2 pix = int2(DispatchRaysIndex().xy); + float2 size = float2(DispatchRaysDimensions().xy); + float2 uv = (float2(pix) + 0.5) / size; + float2 ndc = uv * 2.0 - 1.0; // unjittered pixel centre — the motion vector reprojects this + + // Offset the primary ray within the pixel by the sub-pixel jitter reported to DLSS-RR, so + // successive frames sample different positions and RR can resolve detail above render resolution. + // The guide buffers below come from this jittered first hit (they must match the jittered colour); + // only the motion vector stays on the unjittered centre, which DLSS expects. + float2 jndc = (uv + worldPush.jitter / size) * 2.0 - 1.0; + + float4 nearH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 1.0, 1.0)); + float4 farH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 0.0, 1.0)); + float3 nearP = nearH.xyz / nearH.w; + float3 farP = farH.xyz / farH.w; + + float3 origin = nearP + worldPush.camOffset; + float3 dir = normalize(farP - nearP); + float rayConeSpread = primaryRayConeSpread(jndc, size, dir); + + uint seed = (uint(pix.x) * 1973u + uint(pix.y) * 9277u + 26699u) ^ (worldPush.frameIndex * 2654435761u); + + // Camera medium: water when the eye is submerged, so the first ray already carries the right relative + // index and absorption. The camera-biome tint is the correct extinction until the first water surface + // is crossed, after which each hit supplies its own body's tint. + MediumStack cameraMedium = makeMediumStack((worldPush.flags & 1u) != 0u + ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) + : airMedium()); + + uint spp = max(worldPush.spp, 1u); + float3 frameRadiance = float3(0.0, 0.0, 0.0); + for (uint s = 0u; s < spp; s++) { + seed = pcg(seed); // decorrelate per-sample paths (shared camera ray, no AA jitter yet) + // Guides describe one deterministic surface, so only the first sample's camera segment writes + // them; the split-off reflection and every later sample reuse what it captured. + PathSegment segment = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), cameraMedium, + 0.0, rayConeSpread, seed, 0, 0, true, s == 0u, true); + float3 sampleRadiance = float3(0.0, 0.0, 0.0); + PathSegment queued = segment; + bool hasQueued = false; + + // ONE tracePath call site. A dielectric's second Fresnel branch arrives here as data, so the + // compiler instantiates the path tracer once instead of once per branch. [loop] is load-bearing: + // unrolling this would duplicate the whole path tracer again and undo the point of the exercise. + [loop] + for (uint segIndex = 0u; segIndex < MAX_PATH_SEGMENTS; ++segIndex) { + if (segIndex > 0u) { + if (!hasQueued) break; + segment = queued; + hasQueued = false; + } + PathSegment emitted; + bool didEmit; + sampleRadiance += tracePath(segment, uint2(pix), + s + segIndex * spp, emitted, didEmit); + if (didEmit) { + queued = emitted; + hasQueued = true; + } + } + + // Written as soon as the capturing sample finishes, so none of the gv_* guide state stays live + // across the remaining samples — that live range is paid at every trace in between. + if (s == 0u) { + writeGuides(pix, dir, jndc, size, rayConeSpread); + } + frameRadiance += sampleRadiance; + } + frameRadiance /= float(spp); - // Debug guide-buffer visualization: bypass accumulation and show a guide directly. + // Debug guide-buffer visualization: bypass accumulation and show a guide directly. Read back from the + // images writeGuides already wrote rather than from gv_*, so no guide state has to stay live to the + // end of the shader just to service a branch that is off in every ordinary frame. if (pc.debugView != 0u) { + float4 normalRough = gNormal[pix]; float3 dbg; if (pc.debugView == 1u) { - dbg = gv_normal * 0.5 + 0.5; // world normal -> [0,1] + dbg = normalRough.xyz * 0.5 + 0.5; // world normal -> [0,1] } else if (pc.debugView == 2u) { - dbg = gv_albedo; // diffuse albedo + dbg = gAlbedo[pix].rgb; // diffuse albedo } else if (pc.debugView == 3u) { - dbg = float3(gv_depth, gv_depth, gv_depth); // HW reversed-Z depth (near=white, far/sky=black) + dbg = float3(gDepth[pix], gDepth[pix], gDepth[pix]); // HW reversed-Z (near=white, far=black) } else if (pc.debugView == 4u) { - dbg = float3(gv_rough, gv_rough, gv_rough); // roughness + dbg = float3(normalRough.w, normalRough.w, normalRough.w); // roughness } else if (pc.debugView == 6u) { - dbg = gv_spec.albedo; // exact value bound to DLSS-RR + dbg = gSpecAlbedo[pix].rgb; // exact value bound to DLSS-RR } else if (pc.debugView == 7u) { - dbg = float3(clamp(0.5 + specMotion * 0.05, 0.0, 1.0), 0.5); // reflection motion + dbg = float3(clamp(0.5 + gSpecMotion[pix] * 0.05, 0.0, 1.0), 0.5); // reflection motion } else { - dbg = float3(clamp(0.5 + motion * 0.05, 0.0, 1.0), 0.5); // motion: red=+x, green=+y + dbg = float3(clamp(0.5 + gMotion[pix] * 0.05, 0.0, 1.0), 0.5); // motion: red=+x, green=+y } outImage[pix] = float4(dbg, 1.0); return; From 22dd2164f51323d2c1b813c3bd219b889cf5d838 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:58:29 +0900 Subject: [PATCH 10/25] docs: wavefront split plan of record Design + phasing for splitting the raygen into a primary/guide pass and an indirect pass, connected by the guide images and a continuation-record append buffer. PathSegment from b4a0e7b is already the record. Records the profile evidence, the accepted duplication (dielectric transport in both passes), a 48B packed record layout with its ~44-55MB budget, and explicit kill criteria for M1 so the approach can be abandoned cheaply if the bandwidth-vs-occupancy bet does not pay. Flags the no-reorder SER A/B (already an open question in GPU_PERF_PLAN.md) as a prerequisite so the two changes do not get entangled. Co-Authored-By: Claude Opus 5 --- docs/WAVEFRONT_PLAN.md | 124 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/WAVEFRONT_PLAN.md diff --git a/docs/WAVEFRONT_PLAN.md b/docs/WAVEFRONT_PLAN.md new file mode 100644 index 00000000..4921986e --- /dev/null +++ b/docs/WAVEFRONT_PLAN.md @@ -0,0 +1,124 @@ +# Wavefront Split Plan — primary/guide pass + indirect pass + +Plan of record started 2026-07-26, `pq` @ `b4a0e7b`. Supersedes nothing; complements +`docs/GPU_PERF_PLAN.md` (whose P-A/P-B/P-C ray-count and locality levers stay valid and orthogonal). + +## 0. Why + +Profile `run/nsight-profile/live-state-5.csv`, pq @ `c22ebf3`, against `main`: + +| Callsite | main | pq @ c22ebf3 | contexts (pq) | +|---|---|---|---| +| primary radiance trace | 308 B / 30 v | **2932 B / 42 v** | 2 | +| `visibility()` (shadow) | 391 B / 39 v | 862 B / 77 v | 10 (5 sites x 2) | + +Top stall flipped `LGSB` -> **`NOINST`** (instruction fetch), i.e. the shader outgrew its I-cache. + +`b4a0e7b` fixed the duplication half of this: continuations became data (`PathSegment`), `tracePath` +is instantiated once behind a `[loop]`, and `world.rgen.spv` went **1017692 -> 645972 bytes (-36.5%)**. +That is a one-time correction, not a trend change. The megakernel still contains two workloads with +opposite characteristics and it keeps growing: + +| | primary / guide | indirect / shading | +|---|---|---| +| runs | once per pixel | once per bounce, in a loop | +| coherence | screen-coherent | incoherent | +| owns | guides, spec surface + MV, transmission walk, wave temporal derivative | NEE, RIS, SSS, GGX continuation, RR | + +Everything inlined into one entry point means the hot loop carries the primary code's instruction +footprint even though it is dead after bounce 0, and the primary code carries the loop's live state. + +This also aligns with the standing lesson in `GPU_PERF_PLAN.md` §0: **reduce live state, never add +it.** rgen was at 126 live registers with occupancy as the binding constraint, and traversal (21.5% +of samples, 89% LGSB) is poorly hidden *because* of that. Splitting the kernel is the structural +version of that lesson; the RIS wave-batching attempt failed because it did the reverse. + +## 1. Design + +Two raygen entry points in the same RT pipeline, selected by SBT record at dispatch, connected by the +existing guide images plus one new continuation buffer. + +**Pass A — `world_primary.rgen`.** Camera ray; walk the dielectric chain while `diffuseDepth == 0`; +capture guides; resolve spec motion and the transmitted guide; write the six guide images. Emit one +continuation record per surviving path (two when a dielectric splits). Does **no** shading. + +**Pass B — `world_indirect.rgen`.** One thread per record. Runs the bounce loop with NEE / RIS / SSS / +GGX continuation. Never touches `gv_*`. + +`PathSegment` from `b4a0e7b` is already the record; that commit was deliberately shaped for this. + +### What each pass does NOT contain + +- Pass A: no NEE, no RIS, no SSS, no GGX sampling, no Russian roulette. +- Pass B: no guide capture, no `resolveTransmissionGuide`, no `specularReflectionMotion`, no + `waterWaveGradTemporal`, no debug views. + +### Accepted duplication + +Dielectric transport (~90 lines) and the non-temporal wave normal appear in both passes: pass A walks +the primary chain, pass B must still handle a reflection ray hitting water. This is real and worth +paying — the block it buys separation from (RIS + NEE + SSS + GGX) is several times larger. + +## 2. Record layout + +Target 48 B. Unpacked `PathSegment` is ~100 B; the packing below is lossless where it matters and the +`medium` outer slot is the only speculative squeeze. + +| field | packed | B | +|---|---|---| +| `ro` | float3 (rebased world) | 12 | +| `rd` | octahedral unorm16x2 | 4 | +| `throughput` | rgb9e5 | 4 | +| `medium.current` | ior half + extinction rgb9e5 | 6 | +| `medium.outer` | ior half + extinction rgb9e5 | 6 | +| `rayConeWidth` / `rayConeSpread` | half x2 | 4 | +| `seed` | uint | 4 | +| `bounce`, `diffuseDepth`, `showCelestial`, `maySplit`, pixel index | packed uint x2 | 8 | + +At 1280x720 render resolution: 921600 x 48 B = **44.2 MB** for one record per pixel. Use an **append +buffer with an atomic counter** sized ~1.25x pixel count (~55 MB) rather than a fixed 2-per-pixel +array — splits are the exception, and an append buffer also hands pass B a dense, coherent queue. +When the queue is full, fall back to the stochastic branch instead of splitting: graceful, and the +estimator stays unbiased. + +**Open:** 44-55 MB is not free on 8 GB cards. If it bites, the fallback is to keep pass B in the same +dispatch for the common single-segment case and only spill splits — measure before deciding. + +## 3. Phases + +- **M0 — plumbing.** Generalize `RtPipeline.create` to take `String[] rgen` (same pattern the `rmiss` + array already uses) and select the raygen SBT record at dispatch. Add a second raygen that is a + copy of the current one. Play-test: pixel-identical output, one extra dispatch. +- **M1 — split, no branching.** Move primary/guide work to pass A, bounce loop to pass B, one record + per pixel, deterministic split temporarily disabled (falls back to stochastic). Play-test + profile. + This is the milestone that proves or kills the approach. +- **M2 — splits back.** Re-enable the deterministic Fresnel split as a second appended record. + Play-test for energy parity against `b4a0e7b`. +- **M3 — measure.** Expect: raygen live state well under `main`'s 308 B baseline, NOINST gone, + traversal LGSB better hidden via higher occupancy. Compare against `b4a0e7b`, not against + `c22ebf3`. +- **M4 — later.** ReSTIR spatial reuse becomes a third pass over the G-buffer. This is the reason the + split is worth doing even if M3 is only neutral: spatial reuse is inherently a screen-space + multi-pass algorithm and would otherwise be bolted onto a megakernel. + +## 4. Risks / kill criteria + +- **Bandwidth vs occupancy.** The whole bet is that removing live state buys more than the record + traffic costs. M1 must show it. If M1 profiles neutral-or-worse with occupancy unchanged, stop and + keep `b4a0e7b`. +- **Pass B incoherence.** Records start incoherent, but they already are today; SER still applies + inside pass B, and a dense queue is strictly better than a sparse screen dispatch. +- **SER interaction is an open question** independent of this work — `GPU_PERF_PLAN.md` §0 flags that + the scheduler itself costs 7.7% and a no-reorder A/B has never been run. Do that A/B *before* M1 so + the two changes are not entangled. +- **Two dispatches means a barrier**; trivial next to the trace cost, but it serialises pass A/B, so + any pass A tail latency is exposed. + +## 5. Status + +- [x] Step 2 (single instantiation + guide hoist) — `b4a0e7b`, NOT GPU-verified +- [ ] no-reorder A/B (prerequisite, independent) +- [ ] M0 plumbing +- [ ] M1 split +- [ ] M2 splits restored +- [ ] M3 measure From d76f450459125ee7365d9fa84c9d61a32377c24a Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:17:47 +0900 Subject: [PATCH 11/25] M0: multiple raygen shaders per RT pipeline Generalizes RtPipeline.create to take String[] rgen, mirroring the rmiss array it already accepted. N raygen records lead the SBT, the miss and hit regions shift behind them, and trace() gains a raygenIndex that selects which record the dispatch launches. The raygen region must name exactly one record (size == stride), so choosing a pass is only a matter of which record it points at. Descriptor set, miss table and hit table are shared across raygens, so the two passes of the wavefront split cost one pipeline rather than two. No behaviour change: the world pipeline still registers a single raygen and every dispatch passes index 0. Groundwork for M1 in docs/WAVEFRONT_PLAN.md. 39 tests pass. Not GPU-verified. Co-Authored-By: Claude Opus 5 --- .../comfyfluffy/caustica/rt/RtComposite.java | 2 +- .../caustica/rt/pipeline/RtPipeline.java | 78 +++++++++++++------ 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index a6184e95..417d745a 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -521,7 +521,7 @@ public void ensureResourcesReady(RtContext ctx) { private RtPipeline ensureWorld(RtContext ctx) { if (worldPipeline == null) { bindlessTextureCapacity = RtEntityTextures.maxTextures(); - worldPipeline = RtPipeline.create(ctx, RtDeviceBringup.worldRaygenShader(), + worldPipeline = RtPipeline.create(ctx, new String[]{RtDeviceBringup.worldRaygenShader()}, new String[]{"world.rmiss.spv", "world_guide.rmiss.spv"}, "world.rchit.spv", "world.rahit.spv", WorldPushConstantsData.BYTE_SIZE, true, GUIDE_COUNT, bindlessTextureCapacity, true); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java index a03fb52a..f3946bc8 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java @@ -82,6 +82,7 @@ public final class RtPipeline { private final long pipeline; private final RtBuffer sbt; private final long sbtStride; + private final int raygenCount; private final int missCount; private final int hitGroupCount; private final int pushConstantSize; @@ -96,7 +97,7 @@ public final class RtPipeline { private final int skyAtlasBinding; private boolean destroyed; - private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, long pipeline, RtBuffer sbt, long stride, int missCount, int hitGroupCount, int pushConstantSize, int pushConstantStages, int firstExtraBinding, + private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, long pipeline, RtBuffer sbt, long stride, int raygenCount, int missCount, int hitGroupCount, int pushConstantSize, int pushConstantStages, int firstExtraBinding, long bindlessLayout, long bindlessPool, long bindlessSet, int skyAtlasBinding) { this.ctx = ctx; this.descriptorSetLayout = dsl; @@ -111,6 +112,7 @@ private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, this.pipeline = pipeline; this.sbt = sbt; this.sbtStride = stride; + this.raygenCount = raygenCount; this.missCount = missCount; this.hitGroupCount = hitGroupCount; this.pushConstantSize = pushConstantSize; @@ -128,8 +130,12 @@ private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, * constants: radiance records first, shadow records second, then entity records. {@code extraStorageImages} * adds that many raygen-visible storage images at bindings 3.. (the DLSS-RR guide buffers); * write them with {@link #setExtraStorageImage}. + * + *

{@code rgen} may hold several raygen shaders. They share this pipeline's descriptor set, miss + * table and hit table; {@link #trace(VkCommandBuffer, int, int, ByteBuffer, int)} picks one per + * dispatch by index. */ - public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, String rchit, String rahit, int pushConstantSize, boolean withBlockAlbedoAtlas, int extraStorageImages, int bindlessTextures, boolean skyAtlas) { + public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, String rchit, String rahit, int pushConstantSize, boolean withBlockAlbedoAtlas, int extraStorageImages, int bindlessTextures, boolean skyAtlas) { VkDevice vk = ctx.vk(); boolean hasAhit = rahit != null; String label = "world RT pipeline"; @@ -260,17 +266,24 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri long layout = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, layout, label + " pipeline layout"); - // Stages: raygen, one miss per rmiss entry, the closest-hit, then (optionally) the any-hit. - // Groups are raygen + N miss + the hit records selected by traceRayEXT's SBT offset/stride. + // Stages: one per rgen entry, one miss per rmiss entry, the closest-hit, then (optionally) + // the any-hit. Groups are N raygen + M miss + the hit records selected by traceRayEXT's SBT + // offset/stride. Multiple raygens share one pipeline and are selected at dispatch by pointing + // the raygen SBT region at a different record — that is how the primary/guide pass and the + // indirect pass coexist without duplicating the hit and miss tables. + int raygenCount = rgen.length; int missCount = rmiss.length; int hitGroupCount = hasAhit ? RtAccel.SBT_HIT_GROUP_COUNT : 1; - int groupCount = 1 + missCount + hitGroupCount; - int hitGroupIdx = 1 + missCount; - int chitStage = 1 + missCount; + int groupCount = raygenCount + missCount + hitGroupCount; + int hitGroupIdx = raygenCount + missCount; + int chitStage = raygenCount + missCount; int ahitStage = chitStage + 1; - int stageCount = 1 + missCount + 1 + (hasAhit ? 1 : 0); - long mGen = loadModule(vk, stack, rgen); - RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, mGen, label + " " + rgen); + int stageCount = raygenCount + missCount + 1 + (hasAhit ? 1 : 0); + long[] mGen = new long[raygenCount]; + for (int g = 0; g < raygenCount; g++) { + mGen[g] = loadModule(vk, stack, rgen[g]); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, mGen[g], label + " " + rgen[g]); + } long[] mMiss = new long[missCount]; for (int m = 0; m < missCount; m++) { mMiss[m] = loadModule(vk, stack, rmiss[m]); @@ -284,9 +297,11 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri } ByteBuffer entry = stack.UTF8("main"); VkPipelineShaderStageCreateInfo.Buffer stages = VkPipelineShaderStageCreateInfo.calloc(stageCount, stack); - stages.get(0).sType$Default().stage(VK_SHADER_STAGE_RAYGEN_BIT_KHR).module(mGen).pName(entry); + for (int g = 0; g < raygenCount; g++) { + stages.get(g).sType$Default().stage(VK_SHADER_STAGE_RAYGEN_BIT_KHR).module(mGen[g]).pName(entry); + } for (int m = 0; m < missCount; m++) { - stages.get(1 + m).sType$Default().stage(VK_SHADER_STAGE_MISS_BIT_KHR).module(mMiss[m]).pName(entry); + stages.get(raygenCount + m).sType$Default().stage(VK_SHADER_STAGE_MISS_BIT_KHR).module(mMiss[m]).pName(entry); } stages.get(chitStage).sType$Default().stage(VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR).module(mHit).pName(entry); if (hasAhit) { @@ -294,11 +309,13 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri } VkRayTracingShaderGroupCreateInfoKHR.Buffer groups = VkRayTracingShaderGroupCreateInfoKHR.calloc(groupCount, stack); - groups.get(0).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR) - .generalShader(0).closestHitShader(VK_SHADER_UNUSED_KHR).anyHitShader(VK_SHADER_UNUSED_KHR).intersectionShader(VK_SHADER_UNUSED_KHR); + for (int g = 0; g < raygenCount; g++) { + groups.get(g).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR) + .generalShader(g).closestHitShader(VK_SHADER_UNUSED_KHR).anyHitShader(VK_SHADER_UNUSED_KHR).intersectionShader(VK_SHADER_UNUSED_KHR); + } for (int m = 0; m < missCount; m++) { - groups.get(1 + m).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR) - .generalShader(1 + m).closestHitShader(VK_SHADER_UNUSED_KHR).anyHitShader(VK_SHADER_UNUSED_KHR).intersectionShader(VK_SHADER_UNUSED_KHR); + groups.get(raygenCount + m).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR) + .generalShader(raygenCount + m).closestHitShader(VK_SHADER_UNUSED_KHR).anyHitShader(VK_SHADER_UNUSED_KHR).intersectionShader(VK_SHADER_UNUSED_KHR); } for (int h = 0; h < hitGroupCount; h++) { groups.get(hitGroupIdx + h).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_TRIANGLES_HIT_GROUP_KHR) @@ -320,7 +337,9 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri long pipeline = pPipeline.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE, pipeline, label); - VK10.vkDestroyShaderModule(vk, mGen, null); + for (int g = 0; g < raygenCount; g++) { + VK10.vkDestroyShaderModule(vk, mGen[g], null); + } for (int m = 0; m < missCount; m++) { VK10.vkDestroyShaderModule(vk, mMiss[m], null); } @@ -347,7 +366,7 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri MemoryUtil.memCopy(MemoryUtil.memAddress(handles) + (long) g * handleSize, sbt.mapped + g * stride, handleSize); } sbt.flush(); - return new RtPipeline(ctx, dsl, pool, sets, layout, pipeline, sbt, stride, missCount, hitGroupCount, pushConstantSize, pcStages, firstExtraBinding, + return new RtPipeline(ctx, dsl, pool, sets, layout, pipeline, sbt, stride, raygenCount, missCount, hitGroupCount, pushConstantSize, pcStages, firstExtraBinding, bindlessLayout, bindlessPool, bindlessSet, skyBinding); } } @@ -479,11 +498,22 @@ public boolean hasBindless() { } public void trace(VkCommandBuffer cmd, int width, int height) { - trace(cmd, width, height, null); + trace(cmd, width, height, null, 0); } - /** Record bind (+ optional raygen push constants) + trace into the given command buffer. */ public void trace(VkCommandBuffer cmd, int width, int height, java.nio.ByteBuffer pushConstants) { + trace(cmd, width, height, pushConstants, 0); + } + + /** + * Record bind (+ optional raygen push constants) + trace into the given command buffer. + * {@code raygenIndex} selects which raygen record of the SBT this dispatch launches; the miss and + * hit regions are shared, so passes over the same scene differ only in this index. + */ + public void trace(VkCommandBuffer cmd, int width, int height, java.nio.ByteBuffer pushConstants, int raygenIndex) { + if (raygenIndex < 0 || raygenIndex >= raygenCount) { + throw new IllegalArgumentException("raygen index " + raygenIndex + " out of range [0, " + raygenCount + ")"); + } try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "trace rays")) { VK10.vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, pipeline); java.nio.LongBuffer boundSets = bindlessSet != 0L @@ -493,12 +523,14 @@ public void trace(VkCommandBuffer cmd, int width, int height, java.nio.ByteBuffe if (pushConstants != null && pushConstantSize > 0) { VK10.vkCmdPushConstants(cmd, pipelineLayout, pushConstantStages, 0, pushConstants); } + // The raygen region must name exactly one record (size == stride), so selecting a pass is a + // matter of which record it points at. VkStridedDeviceAddressRegionKHR raygen = VkStridedDeviceAddressRegionKHR.calloc(stack) - .deviceAddress(sbt.deviceAddress).stride(sbtStride).size(sbtStride); + .deviceAddress(sbt.deviceAddress + (long) raygenIndex * sbtStride).stride(sbtStride).size(sbtStride); VkStridedDeviceAddressRegionKHR miss = VkStridedDeviceAddressRegionKHR.calloc(stack) - .deviceAddress(sbt.deviceAddress + sbtStride).stride(sbtStride).size((long) missCount * sbtStride); + .deviceAddress(sbt.deviceAddress + (long) raygenCount * sbtStride).stride(sbtStride).size((long) missCount * sbtStride); VkStridedDeviceAddressRegionKHR hit = VkStridedDeviceAddressRegionKHR.calloc(stack) - .deviceAddress(sbt.deviceAddress + (1L + missCount) * sbtStride).stride(sbtStride).size((long) hitGroupCount * sbtStride); + .deviceAddress(sbt.deviceAddress + (long) (raygenCount + missCount) * sbtStride).stride(sbtStride).size((long) hitGroupCount * sbtStride); VkStridedDeviceAddressRegionKHR callable = VkStridedDeviceAddressRegionKHR.calloc(stack); vkCmdTraceRaysKHR(cmd, raygen, miss, hit, callable, width, height, 1); } From 7bcc7b5e9a3dd12ecd08488a4eddb052e33dfdd2 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:18:42 +0900 Subject: [PATCH 12/25] docs: record the b4a0e7b regression and retarget the plan b4a0e7b GPU-tested at 47ms against db6418b's 21ms. Contexts dropped 2 -> 1 as designed, but live state at the primary trace went 2932B -> 3347B: the pending segment (288B) is created at the primary dielectric and cannot be consumed until tracePath returns, so it survives every subsequent trace in registers, and [loop] additionally forced main's own state to become loop-carried. Halving the instruction footprint bought nothing because occupancy, not instruction fetch, is the binding constraint. Second confirmation of GPU_PERF_PLAN.md's rule that trading live state for instructions loses in this kernel. Recorded so the next reader does not repeat it. Retargets the plan accordingly: the split is now the targeted fix for a diagnosed regression rather than a speculative bet, the M1 baseline is db6418b's 21ms rather than the regressed build, and a new kill criterion covers reproducing the same failure through a buffer if pass A holds records live instead of writing and exiting. Co-Authored-By: Claude Opus 5 --- docs/WAVEFRONT_PLAN.md | 47 +++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/WAVEFRONT_PLAN.md b/docs/WAVEFRONT_PLAN.md index 4921986e..f430dd00 100644 --- a/docs/WAVEFRONT_PLAN.md +++ b/docs/WAVEFRONT_PLAN.md @@ -16,8 +16,34 @@ Top stall flipped `LGSB` -> **`NOINST`** (instruction fetch), i.e. the shader ou `b4a0e7b` fixed the duplication half of this: continuations became data (`PathSegment`), `tracePath` is instantiated once behind a `[loop]`, and `world.rgen.spv` went **1017692 -> 645972 bytes (-36.5%)**. -That is a one-time correction, not a trend change. The megakernel still contains two workloads with -opposite characteristics and it keeps growing: + +### 0.1 `b4a0e7b` regressed: 21 ms -> 47 ms (`continuation b4a0e7b.csv`) + +It made things **worse**, badly. Contexts did drop 2 -> 1 as designed, but live state at the primary +trace went **2932 B -> 3347 B**, and LGSB rose sharply. Top contributors: + +| B | source | what | +|---|---|---| +| 504 | `world.rgen.slang:149` | medium extinction (was 336 B) | +| 432 | `:1316` | `n = payload.normal` | +| 324 | `:1516` | `gv_hitCamRel` | +| **288** | **`:1400`** | **`pending = makePathSegment(...)`** | +| 156 / 120 | `:1739` / `:1738` | `dir` / `origin`, now loop-carried | + +The mechanism: **`pending` is created at the primary dielectric and cannot be consumed until +`tracePath` returns, so it survives every subsequent trace in registers.** `[loop]` additionally +blocks specialisation, forcing main's own state to become loop-carried. Halving the instruction +footprint bought nothing because occupancy — not instruction fetch — is the binding constraint. + +**This is `GPU_PERF_PLAN.md` §0's rule confirmed a second time: in this kernel, reducing instructions +at the cost of live state is a losing trade.** The RIS wave-batching revert was the first instance. + +It also converts this plan from a bet into a targeted fix. Pass A **writes the record to memory and +exits**; pass B **reads it once at entry**, where it becomes ordinary loop state. Neither pass holds a +continuation live across a trace, which is precisely what `b4a0e7b` got wrong. M1 must preserve that +property or it will reproduce the same regression through a more expensive mechanism. + +The megakernel still contains two workloads with opposite characteristics and it keeps growing: | | primary / guide | indirect / shading | |---|---|---| @@ -95,8 +121,8 @@ dispatch for the common single-segment case and only spill splits — measure be - **M2 — splits back.** Re-enable the deterministic Fresnel split as a second appended record. Play-test for energy parity against `b4a0e7b`. - **M3 — measure.** Expect: raygen live state well under `main`'s 308 B baseline, NOINST gone, - traversal LGSB better hidden via higher occupancy. Compare against `b4a0e7b`, not against - `c22ebf3`. + traversal LGSB better hidden via higher occupancy. **Baseline to beat is `db6418b` at 21 ms**, not + `b4a0e7b` at 47 ms — beating the regression proves nothing. - **M4 — later.** ReSTIR spatial reuse becomes a third pass over the G-buffer. This is the reason the split is worth doing even if M3 is only neutral: spatial reuse is inherently a screen-space multi-pass algorithm and would otherwise be bolted onto a megakernel. @@ -104,8 +130,12 @@ dispatch for the common single-segment case and only spill splits — measure be ## 4. Risks / kill criteria - **Bandwidth vs occupancy.** The whole bet is that removing live state buys more than the record - traffic costs. M1 must show it. If M1 profiles neutral-or-worse with occupancy unchanged, stop and - keep `b4a0e7b`. + traffic costs. M1 must show it. If M1 does not beat `db6418b`'s 21 ms, revert to `db6418b`'s + structure — `b4a0e7b` is not a fallback, it is a regression being carried deliberately while the + split lands. +- **Reproducing the `b4a0e7b` failure through a buffer.** If pass A holds the record live across its + own traces instead of writing and exiting, it pays the same 288 B live range *plus* the memory + traffic. Check the live-state CSV for pass A, not just the frame time. - **Pass B incoherence.** Records start incoherent, but they already are today; SER still applies inside pass B, and a dense queue is strictly better than a sparse screen dispatch. - **SER interaction is an open question** independent of this work — `GPU_PERF_PLAN.md` §0 flags that @@ -116,9 +146,10 @@ dispatch for the common single-segment case and only spill splits — measure be ## 5. Status -- [x] Step 2 (single instantiation + guide hoist) — `b4a0e7b`, NOT GPU-verified +- [x] Step 2 (single instantiation + guide hoist) — `b4a0e7b`, **GPU-tested: 21 ms -> 47 ms, REGRESSION** + (kept deliberately; the split is its fix, see §0.1) +- [x] M0 plumbing — `d76f450`, multiple raygens per pipeline, no behaviour change - [ ] no-reorder A/B (prerequisite, independent) -- [ ] M0 plumbing - [ ] M1 split - [ ] M2 splits restored - [ ] M3 measure From 428922d85a0e6434edcae833dc0929b2c528500a Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:41:13 +0900 Subject: [PATCH 13/25] M1: split primary and indirect raygen passes --- build.gradle | 11 +- docs/WAVEFRONT_PLAN.md | 5 +- shaders/world/world.rgen.slang | 1822 +-------------- shaders/world/world_common.slang | 2 + shaders/world/world_path.slanginc | 1986 +++++++++++++++++ shaders/world/world_primary.rgen.slang | 3 + .../comfyfluffy/caustica/rt/RtComposite.java | 43 +- .../caustica/rt/RtDeviceBringup.java | 17 +- .../rt/material/RtMaterialLayoutTest.java | 11 +- 9 files changed, 2060 insertions(+), 1840 deletions(-) create mode 100644 shaders/world/world_path.slanginc create mode 100644 shaders/world/world_primary.rgen.slang diff --git a/build.gradle b/build.gradle index 452a4abd..de3865a0 100644 --- a/build.gradle +++ b/build.gradle @@ -160,11 +160,14 @@ abstract class CompileShaders extends DefaultTask { def base = outBase(src) def spv = new File(scratchDir, "${base}.spv") if (src.name.endsWith(".slang")) { - // Build both SER encodings from one source; RtDeviceBringup selects the supported variant. - compileOneSlang(src, spv, base == "world.rgen" + // Build both SER encodings for the two world raygens; RtDeviceBringup selects the + // supported variant for both passes. + def worldRaygen = base == "world.rgen" || base == "world_primary.rgen" + compileOneSlang(src, spv, worldRaygen ? ["-capability", "spvShaderInvocationReorderEXT"] : []) - if (base == "world.rgen") { - compileOneSlang(src, new File(scratchDir, "world_nv.rgen.spv"), + if (worldRaygen) { + def nvBase = base == "world.rgen" ? "world_nv.rgen.spv" : "world_primary_nv.rgen.spv" + compileOneSlang(src, new File(scratchDir, nvBase), ["-capability", "spvShaderInvocationReorderNV"]) } } else { diff --git a/docs/WAVEFRONT_PLAN.md b/docs/WAVEFRONT_PLAN.md index f430dd00..620fc38a 100644 --- a/docs/WAVEFRONT_PLAN.md +++ b/docs/WAVEFRONT_PLAN.md @@ -149,7 +149,8 @@ dispatch for the common single-segment case and only spill splits — measure be - [x] Step 2 (single instantiation + guide hoist) — `b4a0e7b`, **GPU-tested: 21 ms -> 47 ms, REGRESSION** (kept deliberately; the split is its fix, see §0.1) - [x] M0 plumbing — `d76f450`, multiple raygens per pipeline, no behaviour change -- [ ] no-reorder A/B (prerequisite, independent) -- [ ] M1 split +- [ ] no-reorder A/B (prerequisite, independent; intentionally skipped for this work) +- [x] M1 split — 48 B packed records, primary/guide + indirect dispatches, stochastic dielectric + fallback. **GPU-tested: primary 4.4 ms + indirect 9.8 ms = 14.2 ms**, versus the 21 ms baseline. - [ ] M2 splits restored - [ ] M3 measure diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 4c20b0b6..672447cf 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -1,1819 +1,3 @@ -// Perspective path tracer. invViewProj = inverse(proj * viewRot) maps clip to camera-relative world. -// Primary rays are reconstructed from two unprojected points (near and far plane) rather than assuming -// the eye is at the origin: Minecraft bakes view bobbing into the projection, so the effective eye comes -// from the matrix. camOffset shifts camera-relative space into the terrain's rebased coordinates. Depth -// is reversed-Z (near=1, far=0). -// -// Lighting is an iterative path-tracing loop in raygen, not recursion from closest-hit, so -// maxRayRecursionDepth stays 1. At each surface vertex: -// * NEE toward the sun — a single analytic directional light, gated by one hit-object visibility query -// (TerminateOnFirstHit | SkipClosestHit). Lambertian BRDF = albedo/PI. -// * a cosine-weighted Lambertian continuation ray (throughput *= albedo; the PI and cos cancel). -// Sky / ambient light needs no separate AO pass: continuation rays that escape land in world.rmiss, -// which returns sky radiance — so soft sky fill and contact AO both fall out of the path integral. -// Russian roulette terminates deep paths. Output is HDR (R16G16B16A16_SFLOAT); the HDR -> LDR tonemap -// happens later at the display.comp seam. -// -// SER: the build emits EXT and NV shader-invocation-reorder variants from this source. -// -// Each frame emits a single SPP-averaged (still noisy) estimate; temporal convergence is the denoiser's -// job. With RR disabled the output is the raw noisy path trace — a reference view, not a converged image. -import world_common; - -[[vk::push_constant]] WorldPushConstants pc; - -[[vk::binding(0, 0)]] RaytracingAccelerationStructure topLevelAS; -// HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. -[[vk::binding(1, 0)]] [format("rgba16f")] RWTexture2D outImage; -// Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written -// every frame regardless of accumulation. gNormal.w carries LINEAR roughness (= GGX alpha, which is what -// DLSS-RR documents for this input); gDepth carries HW reversed-Z depth. -[[vk::binding(3, 0)]] [format("rgba16f")] RWTexture2D gNormal; // xyz world normal, w linear roughness -[[vk::binding(4, 0)]] [format("rgba16f")] RWTexture2D gAlbedo; // rgb diffuse albedo -[[vk::binding(5, 0)]] [format("r32f")] RWTexture2D gDepth; // HW reversed-Z depth -[[vk::binding(6, 0)]] [format("rg16f")] RWTexture2D gMotion; // screen-space motion (render px) -[[vk::binding(7, 0)]] [format("rgba16f")] RWTexture2D gSpecAlbedo; // specular albedo (0 — diffuse-only) -[[vk::binding(8, 0)]] [format("rg16f")] RWTexture2D gSpecMotion; // reflection MVs for RR - -// Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL -// `worldPush` global). Layout constants generated from this module's SPIR-V — see world_common.WorldPush. -static WorldPush worldPush; - -// The radiance payload remains module-level so tracePath and its guide helpers can share it across -// HitObject trace/invoke calls, mirroring the GLSL rayPayloadEXT global. -static Payload payload; - -struct VisibilityResult { - float3 transmittance; - float waterHitT; -}; - -// First-hit (primary-visibility) guide attributes, captured at bounce 0 of tracePath. The primary ray -// is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. -static float3 gv_normal; -static float3 gv_albedo; -static float gv_rough; -static float3 gv_hitCamRel; // primary-surface hit position relative to the current camera -static float3 gv_motionHitCamRel; // motion-guide hit position; water tracks the refracted hit instead -static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit (its own reprojection delta) -// World-space displacement of the motion-guide surface since the previous frame (0 for static -// terrain/sky, an entity's per-vertex/object motion for a dynamic hit). -static float3 gv_motionObjDisp; -// Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs -// (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is -// the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. -struct SpecSurface { - float3 camRel; // interface position relative to the current camera - float3 normal; // shading normal (wave-perturbed for water) - float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates - float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface - float roughness; - float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) -}; -static SpecSurface gv_spec; - -// Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading -// normal now and last frame, flat geometric normal for the bias) via the six-argument form. -SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, float3 biasNormal, - float roughness, float3 albedo) { - SpecSurface s; - s.camRel = camRel; - s.normal = normal; - s.previousNormal = previousNormal; - s.biasNormal = biasNormal; - s.roughness = roughness; - s.albedo = albedo; - return s; -} - -SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { - return makeSpecSurface(camRel, normal, normal, normal, roughness, albedo); -} - - -uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } -// Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was -// travelling into the volume (vs. out of it) at this hit's face. -bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } -bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } -// LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used -// end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly -// that, RtMaterials.Profile carries the same units, and DLSS-RR wants linear roughness in its guide. So -// nothing squares it: ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo all take this value as alpha as-is, and -// any threshold compared against it is an alpha threshold. -float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } -float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } -float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } -float payloadSss() { return unpackHalf2(payload.emissionSss).y; } -float payloadIor() { return unpackHalf2(payload.iorTransmission).x; } -float payloadTransmission() { return unpackHalf2(payload.iorTransmission).y; } - -static const float PI = 3.14159265359; -static const float INV_PI = 0.31830988618; - -// The analytic sky (gradient + sun/moon discs + stars) is computed in world.rmiss. On a miss, raygen -// reads payload.albedo and accumulates that sky radiance. The directional NEE light is pushed separately -// as worldPush.lightDir / worldPush.lightRadiance / worldPush.sunDir. -static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) -static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface - -// Water is a smooth dielectric. IOR 1.333; absorption is per-block Beer-Lambert extinction. -static const float WATER_IOR = 1.333; -static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. -static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); - -// Transmitted-ray origin offset when crossing a face from the TRANSLUCENT terrain layer (glass, ice). -// Must be SMALLER than the mesher-side TRANSLUCENT_INSET (RtTerrainMesher, 2e-4 blocks): those quads are -// recessed into their block by that inset, so a face touching a slab/stair leaves a tiny gap before the -// neighbour's surface. A large bias (SURF_BIAS) would restart the ray past that neighbour and see -// through it; this lands the ray inside the gap so the neighbour is still hit, while still clearing the -// surface it left to avoid self-intersection. Water is meshed by RtFluidMesher with no inset, so it uses -// the ordinary bias. -static const float INSET_TRANSMIT_BIAS = 1.0e-4; - -// Ray-cone texture LOD: raygen tracks a one-pixel footprint along the path, and closest-hit converts it -// to texture mip levels from the local UV/world-space scale. Diffuse/glossy bounces widen the cone so -// indirect texture fetches don't keep sampling mip 0 through broad lobes. -static const float RAY_CONE_MIN_SPREAD = 1.0e-5; -static const float RAY_CONE_MIN_WIDTH = 1.0e-5; -static const float RAY_CONE_DIFFUSE_SPREAD = 0.25; -static const float RAY_CONE_GLOSSY_SPREAD_SCALE = 0.35; - -// Per-channel Beer–Lambert extinction from a water body's biome tint (carried in payload.albedo for -// water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean -// tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor -// keeps even a white-tinted body very slightly absorbing so deep water never reads as clear vacuum. -static const float WATER_DENSITY = 0.05; -static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); -float3 waterExtinction(float3 tint) { - return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); -} - -// A non-water dielectric's tint is a filter over one block of travel rather than a biome water colour, so -// it maps to extinction by inverting Beer-Lambert over that reference distance: transmittance t after one -// block means sigma = -ln(t). `transmission` scales that transmittance, so it keeps the sense the -// material's authored factor has everywhere else — 1 passes the tint through unchanged, 0 is opaque. -// Clamped away from zero so a fully saturated texel stays finite instead of producing an infinite sigma. -static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks -float3 volumeExtinction(float3 tint, float transmission) { - float3 blockTransmittance = clamp(clamp(tint, 0.0, 1.0) * clamp(transmission, 0.0, 1.0), - 1.0e-3, 1.0); - return -log(blockTransmittance) / VOLUME_TINT_REFERENCE_DISTANCE; -} - -// ---- Participating medium the path is currently inside. -// -// Refraction needs the RELATIVE index across an interface, so the renderer has to know what it is -// travelling through, not just what it is hitting. `ior` drives Snell/Fresnel; `extinction` drives the -// per-segment Beer-Lambert attenuation. -// -// The stack is depth 2 (current + the one it will return to) held in named fields, NOT an array. A -// dynamically indexed local array lands in scratch memory, and this raygen is already register-bound — -// paying an occupancy hit for nesting that Minecraft does not produce would be a bad trade. Depth 2 -// covers air->water->glass and air->glass->water, which is the realistic worst case; anything deeper -// degrades to air on the way out, and because `entering` is re-derived per face from geometry rather -// than toggled, the path re-synchronises at the next crossing instead of staying corrupted. -struct Medium { - float ior; - float3 extinction; - bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific -}; - -struct MediumStack { - Medium current; - Medium outer; -}; - -Medium airMedium() { - Medium m; - m.ior = 1.0; - m.extinction = float3(0.0, 0.0, 0.0); - m.water = false; - return m; -} - -MediumStack makeMediumStack(Medium start) { - MediumStack s; - s.current = start; - s.outer = airMedium(); - return s; -} - -void mediumPush(inout MediumStack stack, Medium entered) { - stack.outer = stack.current; - stack.current = entered; -} - -void mediumPop(inout MediumStack stack) { - stack.current = stack.outer; - stack.outer = airMedium(); -} - -// Everything needed to resume tracing from a point in the scene. The path tracer takes one of these and -// may hand back another, which is how a dielectric's two Fresnel continuations are traced without -// instantiating the path tracer twice: the second branch is DATA, not a second inlined copy. `throughput` -// already carries the branch weight, so segments are simply summed. -// -// This is deliberately shaped like a record that could live in a buffer rather than a register: the -// planned split into a primary/guide pass and an indirect pass hands continuations between dispatches, -// and this is the payload it would write. -struct PathSegment { - float3 ro; - float3 rd; - float3 throughput; - MediumStack medium; - float rayConeWidth; - float rayConeSpread; - uint seed; - int bounce; // interfaces already consumed, so RR start and the bounce cap stay global - int diffuseDepth; // surfaces already SHADED (see the field of the same name in tracePath) - bool showCelestial; - bool captureGuides; - bool maySplit; // may still spawn a deterministic Fresnel branch -}; - -PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, - float rayConeWidth, float rayConeSpread, uint seed, - int bounce, int diffuseDepth, bool showCelestial, - bool captureGuides, bool maySplit) { - PathSegment s; - s.ro = ro; - s.rd = rd; - s.throughput = throughput; - s.medium = medium; - s.rayConeWidth = rayConeWidth; - s.rayConeSpread = rayConeSpread; - s.seed = seed; - s.bounce = bounce; - s.diffuseDepth = diffuseDepth; - s.showCelestial = showCelestial; - s.captureGuides = captureGuides; - s.maySplit = maySplit; - return s; -} - -// Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume -// dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. -Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { - Medium m; - m.ior = ior; - m.extinction = isWater ? waterExtinction(tint) : volumeExtinction(tint, transmission); - m.water = isWater; - return m; -} - -// Animated surface waves — a directional wave spectrum, gradient-only (the surface stays geometrically -// flat; only the normal tilts). Three things make it read as water rather than animated glass: -// * Sharp-crested profile: each component is h = a·exp(S·(sin φ − 1)) — peaked crests, wide flat -// troughs (pure sines look like rolling jelly). Still C∞ smooth with an exact analytic gradient. -// * Real deep-water dispersion ω = √(g·k): long swell strides past (λ=14 m → ~4.7 m/s) while short -// chop flutters nearly in place — wrong relative speeds are the biggest tell of fake water. -// * Wind + meander: headings spread around a fixed wind direction (tight for the swell, wide for the -// chop), and the largest components' phase is modulated along their crest (chain-ruled into the -// gradient) so wavefronts wander instead of ruling straight periodic lines across the sea. -// The domain is world-anchored (see applyWaterWaves) so the pattern is pinned in world space, and the -// caustics (waterCaustic) differentiate this same field, so they stay in sync with the visible ripples. -static const float WATER_WAVE_STRENGTH = 0.3; // slope scale (higher = choppier glints / more broken reflections) -static const float WAVE_SPEED = 0.8; // global time scale; dispersion keeps relative speeds physical -static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each octave 1.5× shorter) -static const float WAVE_G = 9.81; -static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction -static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components -float waterWaveLodWeight(float wavelength, float footprint) { - float cyclesPerPixel = max(footprint, 0.0) / max(wavelength, 1.0e-4); - return 1.0 - smoothstep(0.25, 0.5, cyclesPerPixel); -} - -// One walk of the wave spectrum, shared by every consumer so the spectrum constants exist once. WITH_DT -// is a generic so the derivative terms are compiled out entirely for callers that only want the -// gradient — the trigonometry is shared, but the chain-rule work is not free. -void waterWaveSpectrum(float2 p, float t, float footprint, - out float2 grad, out float2 gradDt) { - // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. - const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; - t *= WAVE_SPEED; - grad = float2(0.0, 0.0); - gradDt = float2(0.0, 0.0); - float lambda = 14.0; - for (int i = 0; i < WAVE_COUNT; i++) { - float lodWeight = waterWaveLodWeight(lambda, footprint); - if (lodWeight <= 0.0) break; - float k = 2.0 * PI / lambda; - float w = sqrt(WAVE_G * k); // deep-water dispersion: phase speed √(g/k) - // Heading: deterministic irregular spread about the wind, widening down the spectrum. - float spread = (0.15 + 0.11 * float(i)) * sin(float(i) * 2.4 + 1.3); - float cs = cos(spread), sn = sin(spread); - float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, WAVE_WIND.x * sn + WAVE_WIND.y * cs); - float ph = k * dot(d, p) - w * t + 1.7 * float(i) * float(i); - float2 dph = k * d; - float phDt = -w; // ∂φ/∂t - float2 dphDt = float2(0.0, 0.0); // ∂(∇φ)/∂t - if (i < 4) { // meander only the components whose fronts are long enough to read as lines - float2 pd = float2(-d.y, d.x); - float wph = 0.4 * k * dot(pd, p) + 0.5 * w * t; - ph += WAVE_MEANDER * sin(wph); - dph += (WAVE_MEANDER * cos(wph) * 0.4 * k) * pd; - if (WITH_DT != 0) { - float wphDt = 0.5 * w; - phDt += WAVE_MEANDER * cos(wph) * wphDt; - dphDt = (-WAVE_MEANDER * sin(wph) * wphDt * 0.4 * k) * pd; - } - } - float S = 1.2 + 0.12 * float(i); // crest sharpness: the short chop is choppier - float sinPh = sin(ph); - float cosPh = cos(ph); - float e = exp(S * (sinPh - 1.0)); - // a = Q/k; ∇h = a·S·cos φ·e^{S(sin φ−1)}·∇φ - float amplitude = lodWeight * (Q[i] / k) * S; - grad += amplitude * cosPh * e * dph; - if (WITH_DT != 0) { - // ∂/∂t of the term above, chain-ruled through φ and ∇φ. - gradDt += amplitude * e - * (phDt * (-sinPh + S * cosPh * cosPh) * dph + cosPh * dphDt); - } - lambda /= 1.5; - } - grad *= WATER_WAVE_STRENGTH; - gradDt *= WATER_WAVE_STRENGTH; -} - -float2 waterWaveGrad(float2 p, float t, float footprint) { - float2 grad, gradDt; - waterWaveSpectrum<0>(p, t, footprint, grad, gradDt); - return grad; -} - -float2 waterWaveGrad(float2 p, float t) { - return waterWaveGrad(p, t, 0.0); -} - -// Evaluate the current gradient and linearly rewind it to the previous phase in one spectrum walk. -// Frame deltas are bounded on the CPU, so this avoids a second set of trigonometric/exponential work -// while remaining accurate over the sub-frame interval used by reflection reprojection. -void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, - out float2 currentGrad, out float2 previousGrad) { - float2 gradDt; - waterWaveSpectrum<1>(p, currentT, footprint, currentGrad, gradDt); - previousGrad = currentGrad - (currentT - previousT) * WAVE_SPEED * gradDt; -} - -// Perturb a (near-)horizontal water surface normal by the wave field. Flowing side faces (|n.y| small) -// keep their flat geometric normal. `worldXZ` must be a world-stable coordinate (rebased hit + the pushed -// anchor) so the ripples stay pinned in the world. `nGeo` is already oriented toward the viewer, so the -// down-viewed underside (n.y < 0) takes the mirrored slope (-up). -float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { - if (abs(nGeo.y) < 0.5) return nGeo; - float2 grad = waterWaveGrad(worldXZ, t, footprint); - float3 up = normalize(float3(-grad.x, 1.0, -grad.y)); - return nGeo.y >= 0.0 ? up : -up; -} - -// ---- Water caustics. Sunlight refracting through the waved surface converges/diverges before it -// reaches an underwater receiver; the analytic wave field makes the true focusing factor computable -// instead of faked with a scrolling texture. Where the shadow ray of an underwater NEE vertex crossed -// water (returned by visibility() as VisibilityResult.waterHitT), evaluate the horizontal landing -// position of the refracted sun ray as a function of surface position and finite-difference it. The -// caustic intensity is the inverse Jacobian determinant of that surface→floor mapping (area compression = brightening, real -// fold caustics where det → 0). Because this uses the SAME wave field as the visible surface normals, -// the caustic pattern stays in sync with the ripples, and the per-sample sun-quad jitter (sampleSquare) -// shifts the pattern per sample → caustics physically blur with depth under DLSS-RR accumulation. -static const float CAUSTIC_EPS = 0.25; // finite-difference step (m); also low-passes sub-step wave detail -static const float CAUSTIC_MAX = 6.0; // focusing clamp: keeps fold-line singularities from becoming fireflies - -// Horizontal landing offset of a refracted sun ray entering the surface at world-stable xz, intersected -// with the receiver plane h metres below. inc = sunlight travel direction (downward, unit). -float2 causticLanding(float2 xz, float t, float3 inc, float h) { - float2 g = waterWaveGrad(xz, t); - float3 nw = normalize(float3(-g.x, 1.0, -g.y)); - float3 rdw = refract(inc, nw, 1.0 / WATER_IOR); // air→water: never TIR - return xz + rdw.xz * (h / max(-rdw.y, 0.05)); -} - -float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { - // Grazing light: the receiver-plane intersection degenerates and caustics vanish in reality anyway - // (long slant paths absorb + the pattern smears out) — fade the effect to neutral 1.0. - float fade = smoothstep(0.06, 0.18, lightDir.y); - if (fade <= 0.0) return 1.0; - float h = max(waterDist * lightDir.y, 0.05); // vertical depth of the receiver below the exit point - float t = worldPush.waterParams.w; - float3 inc = -lightDir; - float2 base = exitPos.xz + worldPush.waterAnchor.xy; // world-stable wave domain (same as applyWaterWaves) - float2 p0 = causticLanding(base, t, inc, h); - float2 px = causticLanding(base + float2(CAUSTIC_EPS, 0.0), t, inc, h); - float2 pz = causticLanding(base + float2(0.0, CAUSTIC_EPS), t, inc, h); - float det = abs((px.x - p0.x) * (pz.y - p0.y) - (px.y - p0.y) * (pz.x - p0.x)); - // Flat water ⇒ all landings displace identically ⇒ det = eps² ⇒ focus = 1 (energy-neutral); the - // clamp bounds the fold-line spikes whose energy the surrounding darkening already paid for. - float focus = (CAUSTIC_EPS * CAUSTIC_EPS) / max(det, 1.0e-5); - return lerp(1.0, min(focus, CAUSTIC_MAX), fade); -} - -// Below this GGX alpha the lobe is treated as a delta mirror. The threshold is not about detecting an -// authored zero — it is about the point where a finite lobe stops being distinguishable from a mirror. -// A GGX lobe's half-angle is ~alpha, and the sun's angular radius is ~0.0047 rad, so alpha = 4e-4 is -// ~12x tighter than the sun disc and far under a pixel footprint at any reflection distance. Nothing -// below it can be resolved, and the delta path is both sharper and better conditioned there. -// -// Two things follow from taking the delta path, and both are why the threshold is set here rather than -// at the 8-bit quantum: -// * ggxD(_, 0) is exactly 0, so the NEE specular term vanishes by construction and the sun arrives -// solely via the mirror ray hitting the disc (showCelestial). That is the correct, unbiased -// accounting — see the double-count note on ggxD below. -// * There is no floor to apply above the threshold: alpha > 4e-4 is well conditioned for both the -// VNDF sample and the NDF, so authored roughness passes through unmodified and the mapping stays -// continuous. A separate MIN_ROUGH would only reintroduce a cliff. -// -// Stored roughness IS alpha (see payloadRoughness), so these compare against it directly. -static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= 0.98 -// Above this the specular lobe is too broad for a single mirrored reprojection to describe, so the -// reflection MV is left at zero and RR falls back to the ordinary MV. -static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 -static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; -// Continuations one sample may carry: the camera segment, plus the reflection branch a primary -// dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. -static const uint MAX_PATH_SEGMENTS = 2u; - -float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } - -// GGX normal distribution. -// NOTE: the 1e-7 denominator guard is not merely a divide-by-zero epsilon, and it is load-bearing. At -// the lobe peak d == a2, so the guard dominates PI*d*d until a2 >> 1.8e-4, i.e. rough >> 0.116; every -// smoother material has its NEE highlight peak truncated. That truncation is what keeps the sun from -// being counted twice: a specular continuation sets showCelestial, so the BSDF-sampled ray may hit the -// sun disc in world.rmiss while NEE is also adding a sun term for the same lobe, with no MIS weighting -// between them. Removing the guard restores the physical peak AND the double count, and makes the -// single-sample sun estimator explode wherever the lobe is tighter than the disc (~0.0047 rad). The -// real fix is MIS (or a disc-aware specular light sample), not an epsilon edit — left as-is. -float ggxD(float ndh, float alpha) { - float a2 = alpha * alpha; - float d = ndh * ndh * (a2 - 1.0) + 1.0; - return a2 / (PI * d * d + 1.0e-7); -} - -// Smith GGX masking term for one direction (separable form; G2 = G1(v) * G1(l)). -float ggxG1(float ndx, float alpha) { - float a2 = alpha * alpha; - return 2.0 * ndx / (ndx + sqrt(a2 + (1.0 - a2) * ndx * ndx) + 1.0e-7); -} - -float3 fresnelSchlick(float cosT, float3 f0) { - float m = clamp(1.0 - cosT, 0.0, 1.0); - float m2 = m * m; - return f0 + (1.0 - f0) * (m2 * m2 * m); -} - -// Henyey–Greenstein phase function (with 4π normalisation so it integrates to 1 over the sphere). -// g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). -static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional -static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) -// Deepest SHADED surface at which the SSS transmission shadow ray fires. diffuseDepth, not `bounce`: the -// glass/water continue branches don't shade a surface, so foliage seen through a window is still -// diffuseDepth 0 and should backlight like any other primary leaf — see RIS_MAX_DIFFUSE_DEPTH for the -// same distinction and why it matters. -static const int MAX_SSS_DIFFUSE_DEPTH = 1; // fire on the first-shaded surface + one indirect hit -float hg(float cosT, float g) { - float g2 = g * g; - return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); -} - -// NVIDIA RR guide-buffer helper: integrated/view-dependent specular reflectivity, not raw F0. -// alpha is GGX alpha (== the linear roughness materials store), NoV is the first-hit view cosine. -float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { - NoV = abs(NoV); - float NoV2 = NoV * NoV; - float alpha2 = alpha * alpha; - float4 X = float4(1.0, NoV, NoV2, NoV * NoV2); - float4 Y = float4(1.0, alpha, alpha2, alpha * alpha2); - - float2 m1 = float2( - dot(float2(0.99044, -1.28514), X.xy), - dot(float2(1.29678, -0.755907), X.xy)); - float3 Xxyw = float3(X.x, X.y, X.w); - float3 m2 = float3( - dot(float3(1.0, 2.92338, 59.4188), Xxyw), - dot(float3(20.3225, -27.0302, 222.592), Xxyw), - dot(float3(121.563, 626.13, 316.627), Xxyw)); - float bias = dot(m1, Y.xy) / max(dot(m2, float3(Y.x, Y.y, Y.w)), 1.0e-7); - - float2 m3 = float2( - dot(float2(0.0365463, 3.32707), X.xy), - dot(float2(9.0632, -9.04756), X.xy)); - float3 Xxzw = float3(X.x, X.z, X.w); - float3 m4 = float3( - dot(float3(1.0, 3.59685, -1.36772), Xxzw), - dot(float3(9.04401, -16.3174, 9.22949), Xxzw), - dot(float3(5.56589, 19.7886, -20.2123), Xxzw)); - float scale = dot(m3, Y.xy) / max(dot(m4, float3(Y.x, Y.y, Y.w)), 1.0e-7); - - bias *= clamp(specularColor.g * 50.0, 0.0, 1.0); - return specularColor * max(0.0, scale) + float3(max(0.0, bias), max(0.0, bias), max(0.0, bias)); -} - -// Exact (unpolarized) Fresnel reflectance for a dielectric interface. cosI is the incidence cosine on -// the incoming side; etaI/etaT are the IORs the ray travels from / into. Returns 1.0 on total internal -// reflection (sin of the transmitted angle >= 1), which matches refract() returning the zero vector — -// so reflect/refract selection and TIR stay consistent. -float fresnelDielectric(float cosI, float etaI, float etaT) { - float sinT2 = (etaI * etaI) / (etaT * etaT) * max(0.0, 1.0 - cosI * cosI); - if (sinT2 >= 1.0) { - return 1.0; - } - float cosT = sqrt(1.0 - sinT2); - float rs = (etaI * cosI - etaT * cosT) / (etaI * cosI + etaT * cosT); - float rp = (etaI * cosT - etaT * cosI) / (etaI * cosT + etaT * cosI); - return 0.5 * (rs * rs + rp * rp); -} - -// PCG hash RNG. -uint pcg(inout uint s) { - s = s * 747796405u + 2891336453u; - uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; - return (w >> 22u) ^ w; -} -float rndf(inout uint s) { - // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first - // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` - // tests (most seriously the F == 1 total-internal-reflection branch). - return float(pcg(s) >> 8u) * (1.0 / 16777216.0); -} - -float3 primaryRayDir(float2 ndc) { - float4 nearH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 1.0, 1.0)); - float4 farH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 0.0, 1.0)); - float3 nearP = nearH.xyz / nearH.w; - float3 farP = farH.xyz / farH.w; - return normalize(farP - nearP); -} - -float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { - float2 onePixelNdc = 2.0 / size; - float3 dx = primaryRayDir(ndc + float2(onePixelNdc.x, 0.0)); - float3 dy = primaryRayDir(ndc + float2(0.0, onePixelNdc.y)); - return max(max(length(cross(dir, dx)), length(cross(dir, dy))), RAY_CONE_MIN_SPREAD); -} - -// Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the -// importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation -// throughput is just *= albedo with no PI/cos bookkeeping left over. -float3 cosineDir(float3 n, inout uint s) { - float u1 = rndf(s); - float u2 = rndf(s); - float r = sqrt(u1); - float phi = 6.2831853 * u2; - float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); - float3 b = normalize(cross(n, t)); - t = cross(b, n); - float3 local = float3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - u1))); - return normalize(local.x * t + local.y * b + local.z * n); -} - -// Soft shadows: sample a direction within the light's SQUARE angular extent about `axis`. MC's -// sun/moon are square quads, so the NEE shadow ray samples a square (not a cone) — the same square the -// visible disc in world.rmiss uses, built from the same celestial tangent frame (right = arc-travel -// direction, from worldPush.celestial.xyz). Averaged over frames by DLSS-RR this yields soft penumbrae that -// widen with occluder distance (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). -float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { - float3 right = normalize(cross(axis, worldPush.celestial.xyz)); - float3 up = cross(right, axis); - float t = tan(halfAngle); - float u = (rndf(s) * 2.0 - 1.0) * t; - float v = (rndf(s) * 2.0 - 1.0) * t; - return normalize(axis + u * right + v * up); -} - -// Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the -// sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the -// importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). -float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { - float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); - float3 b = normalize(cross(n, t)); - t = cross(b, n); - float3 Ve = float3(dot(ve, t), dot(ve, b), dot(ve, n)); // view in tangent space (z = n) - float a = alpha; - float3 Vh = normalize(float3(a * Ve.x, a * Ve.y, Ve.z)); - float lensq = Vh.x * Vh.x + Vh.y * Vh.y; - float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); - float3 T2 = cross(Vh, T1); - float u1 = rndf(s); - float u2 = rndf(s); - float r = sqrt(u1); - float phi = 6.2831853 * u2; - float p1 = r * cos(phi); - float p2 = r * sin(phi); - float ss = 0.5 * (1.0 + Vh.z); - p2 = (1.0 - ss) * sqrt(1.0 - p1 * p1) + ss * p2; - float3 Nh = p1 * T1 + p2 * T2 + sqrt(max(0.0, 1.0 - p1 * p1 - p2 * p2)) * Vh; - float3 Ne = normalize(float3(a * Nh.x, a * Nh.y, max(0.0, Nh.z))); // microfacet normal, tangent space - return normalize(Ne.x * t + Ne.y * b + Ne.z * n); // -> world space -} - -// ===== RIS emitter NEE ================================================================= -// Block emitters (torches, glowstone, lava, ...) get explicit NEE: per diffuse vertex, stream M uniform -// candidates from the global light buffer through a weighted reservoir keyed by the UNSHADOWED target -// p-hat = luminance(f * Le * G * area), then spend ONE shadow ray on the survivor. Sun/moon NEE stays -// separate and additive. Lights are footprint bounding RECTANGLES (RtLightCollector): sampling a point -// uniformly over the rectangle has pdf 1/area, so `area` rides the contribution like any area light. -// Single-frame RIS only (no temporal reservoir reuse): DLSS-RR's own history already denoises this well -// enough that a persistent per-pixel reservoir wasn't worth the added complexity and VRAM. -// One resample serves three mutually exclusive receiver terms (see evalSampleContrib): front BRDF, -// two-sided particle billboards, and LabPBR SSS backscatter from lights BEHIND the surface — all share -// the reservoir and its single shadow ray, whose origin follows the survivor's side of the surface. - -// A reservoir in registers. pos/lnrm/le/area describe the chosen emitter sample; W is its unbiased -// contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted). -struct Reservoir { - float3 pos; // light sample point (rebased world space) - float3 lnrm; // emitter outward normal - float3 le; // emitter radiance - float area; // emitter rectangle area (the area-light pdf factor) - float M; - float W; - float wSum; - float phat; -}; - -Reservoir resEmpty() { - Reservoir r; - r.pos = float3(0.0, 0.0, 0.0); - r.lnrm = float3(0.0, 0.0, 0.0); - r.le = float3(0.0, 0.0, 0.0); - r.area = 0.0; - r.M = 0.0; - r.W = 0.0; - r.wSum = 0.0; - r.phat = 0.0; - return r; -} - -// Unshadowed contribution of a fixed light sample point at surface (hitPos,n,v); out p-hat = its -// luminance (the resampling target). Evaluated from the UNBIASED hitPos — SURF_BIAS is applied only to -// the shadow-ray origin in shadeReservoir, on whichever side the survivor sample lands. Three receiver -// modes share one target function because they are mutually exclusive per candidate: -// - front hemisphere (ndl>0): the same Lambert(+GGX) BRDF split the sun NEE uses; -// - twoSided (particles): billboards have no back face, ndl=abs(ndl) folds both sides into the front; -// - back hemisphere with sss>0 (LabPBR leaves/grass): HG transmission phase — cosT = dot(wi,rd) peaks -// when the ray points from the light through the slab toward the camera (same as the sun SSS term). -float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n, - float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, - bool twoSided, float sss, out float phat) { - phat = 0.0; - float3 toL = sp - hitPos; - float dist2 = dot(toL, toL); - if (dist2 < 1.0e-6) { - return float3(0.0, 0.0, 0.0); - } - float dist = sqrt(dist2); - float3 wi = toL / dist; - float cosL = max(0.0, dot(lnrm, -wi)); - if (cosL <= 0.0) { - return float3(0.0, 0.0, 0.0); // receiver behind the emitter face - } - float ndl = dot(n, wi); - if (twoSided) { - ndl = abs(ndl); - } - float3 contrib = float3(0.0, 0.0, 0.0); - if (ndl > 0.0) { - float G = ndl * cosL / dist2; // area-light geometry term (x area) - float3 brdf = diffAlb * INV_PI; - // twoSided callers are particle billboards, which pass rough=1/F0=0 and want diffuse-only - // shading: fresnelSchlick still returns up to full white at grazing angles even with F0=0 (that - // IS the Fresnel effect), so skipping the term here — not just zeroing F0 — is what keeps - // billboards from picking up a rim highlight they were never meant to have. - if (!twoSided) { - float3 h = normalize(wi + v); - float ndh = max(0.0, dot(n, h)); - float ndv = max(1.0e-4, dot(n, v)); - float vdh = max(0.0, dot(v, h)); - float D = ggxD(ndh, rough); - float Gs = ggxG1(ndv, rough) * ggxG1(ndl, rough); - float3 Fs = fresnelSchlick(vdh, F0); - brdf += (D * Gs) * Fs / (4.0 * ndv * ndl); - } - contrib = brdf * le * (G * area); - } else if (sss > 0.0) { - float backNdl = -ndl; - float G = backNdl * cosL / dist2; - float cosT = dot(wi, rd); - contrib = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * le * (G * area); - } - phat = luminance(contrib); - return contrib; -} - -// Select one light from the emitted-power distribution in O(1). -void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); - uint column = min(uint(aliasSample), worldPush.lightCount - 1u); - if (pc.lightAliasAddr != 0) { - LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; - bool self = aliasSample - float(column) < a.accept; - lightIndex = self ? column : a.aliasIndex; - } else { - lightIndex = column; - } -} - -bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { - cell.spanOffset = 0u; - cell.spanCount = 0u; - cell.invWeightSum = 0.0; - cellCoord = int3(0, 0, 0); - if (pc.lightGridCellAddr == 0 || pc.lightGridSpanAddr == 0) return false; - int3 coord = int3(floor((p - worldPush.lightGridOrigin.xyz) / worldPush.lightGridOrigin.w)); - if (coord.x < 0 || coord.y < 0 || coord.z < 0 - || coord.x >= worldPush.lightGridDims.x - || coord.y >= worldPush.lightGridDims.y - || coord.z >= worldPush.lightGridDims.z) return false; - uint linear = (uint(coord.z) * uint(worldPush.lightGridDims.y) + uint(coord.y)) - * uint(worldPush.lightGridDims.x) + uint(coord.x); - cell = ConstPtr(pc.lightGridCellAddr)[linear]; - cellCoord = coord; - return cell.spanCount > 0u; -} - -void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, - out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(lightCount); - uint column = min(uint(aliasSample), lightCount - 1u); - LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; - bool self = aliasSample - float(column) < alias.accept; - uint localIndex = self ? column : alias.aliasIndex; - lightIndex = firstLight + localIndex; -} - -float unpackUnsignedFloat(uint bits, uint mantissaBits) { - uint mantissaMask = (1u << mantissaBits) - 1u; - uint mantissa = bits & mantissaMask; - uint exponent = (bits >> mantissaBits) & 31u; - if (exponent == 0u) { - return float(mantissa) * exp2(float(1 - 15 - int(mantissaBits))); - } - return (1.0 + float(mantissa) / float(1u << mantissaBits)) - * exp2(float(int(exponent) - 15)); -} - -float3 lightRadiance(Light light) { - uint packed = light.le; - return float3(unpackUnsignedFloat(packed & 0x7ffu, 6u), - unpackUnsignedFloat((packed >> 11u) & 0x7ffu, 6u), - unpackUnsignedFloat((packed >> 22u) & 0x3ffu, 5u)); -} - -int3 lightSectionCoord(Light light) { - uint packed = light.section; - return int3(int(packed & 0x3ffu), int((packed >> 10u) & 0x3ffu), - int((packed >> 20u) & 0x3ffu)); -} - -// The half axes share three lanes two-at-a-time; see the Light record in world_common. -float3 lightHalfU(Light light) { - return float3(unpackHalf2(light.halfUxy), unpackHalf2(light.halfUzVx).x); -} - -float3 lightHalfV(Light light) { - return float3(unpackHalf2(light.halfUzVx).y, unpackHalf2(light.halfVyz)); -} - -// U x V drives both the emitter normal and the rectangle area, so it is computed once and shared. -float3 lightCrossUV(Light light) { - return cross(lightHalfU(light), lightHalfV(light)); -} - -// The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store. -float lightArea(Light light) { - return 4.0 * length(lightCrossUV(light)); -} - -float3 lightGeometricNormal(Light light) { - float3 normal = normalize(lightCrossUV(light)); - return (light.section & 0x40000000u) != 0u ? -normal : normal; -} - -float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, - float localProbability) { - float power = max(0.0, lightArea(light) * luminance(le)); - float globalPdf = pc.lightAliasAddr != 0 - ? power * worldPush.lightRebase.w : 1.0 / float(worldPush.lightCount); - float localPdf = 0.0; - if (localProbability > 0.0 && power > 0.0) { - int3 delta = lightSectionCoord(light) - cellCoord; - if (all(abs(delta) <= int3(2, 2, 2))) { - float3 deltaF = float3(delta); - localPdf = power * cell.invWeightSum / max(1.0, dot(deltaF, deltaF)); - } - } - return localProbability * localPdf + (1.0 - localProbability) * globalPdf; -} - -void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, - out uint lightIndex) { - ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); - float aliasSample = rndf(proposalSeed) * float(cell.spanCount); - uint column = min(uint(aliasSample), cell.spanCount - 1u); - LightGridSpan span = spans[cell.spanOffset + column]; - bool self = aliasSample - float(column) < span.accept; - uint firstLight = self ? span.firstLight : span.aliasFirstLight; - uint lightCount = self ? (span.packedLightCounts & 0xffffu) - : (span.packedLightCounts >> 16u); - selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex); -} - -// Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section -// by emitted power and section distance, then its section-local power alias. Every light in -// the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. -// Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single -// Light48 load: section power cancels with the section-local light PDF. -void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, - out uint lightIndex) { - if (useLocal) { - selectLightGridSpanLight(cell, proposalSeed, lightIndex); - } else { - selectGlobalLight(proposalSeed, lightIndex); - } -} - -// ---- RIS cost budget. Both constants below come from a set of temporary probes that pinned the -// candidate walk out of the shader to bound what memory-side RIS work could ever be worth. Against an -// 18.0ms frame at M=8, removing every dependent load (span -> alias -> record) and all lane divergence -// saved 5.9ms — a third of the frame. Split by vertex that was ~4.3ms at secondary vertices against -// ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost -// was chasing DEPTH, not divergence, and it lived at secondary vertices. The two knobs below spend that -// finding; a presampled candidate pool would attack the same 5.9ms structurally, but see the note on -// proposalSeed in tracePath for why it should share the pool rather than the seed. - -// Candidate-count divisor for RIS at secondary vertices (bounce > 0). Secondary vertices carried ~78% of -// the cost while being much the more forgiving place to spend variance: that radiance is already -// integrated over a diffuse lobe before the denoiser sees it. 18.0 -> 16.1ms, and indistinguishable from -// 1 by eye — whereas reducing M globally to 2 was clearly worse, because that also degraded the primary -// hit. The primary hit always keeps the full worldPush.risCandidates. -// -// 4 is the floor worth using rather than a tuning accident. The stratification below keeps at least one -// global candidate, so M=2 is the smallest count that still schedules one local AND one global; at M=1 -// localProbability collapses to 0 and the light-grid proposal that makes nearby emitters sample well is -// gone entirely. -static const uint SECONDARY_RIS_DIVISOR = 4u; - -// Deepest SHADED surface at which RIS emitter NEE runs. Past it, emitters are gathered only when a path -// hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep, so the -// failure mode is noisier emitter light at depth rather than missing light. Unlike the divisor this also -// drops shadeReservoir's per-vertex shadow ray, which never scaled with M. Gating at all: 16.1 -> 15.7ms. -// -// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_DIELECTRIC and -// MATERIAL_WATER continue without shading anything, so looking through a pane spends bounce 0 on the -// glass and lands the first shaded surface at bounce 1. Gating on `bounce` therefore starved surfaces -// seen through glass or water — visually primary, but counted as depth — and they went black, since only -// a path randomly striking an emitter could light them. Keying on shaded surfaces removes that whole -// class of bug and lets the budget go lower than the two bounces of headroom the dielectric prefix used -// to cost. -// -// Caveat this does not solve: a smooth specular bounce IS a shaded surface, so a wall seen in a metal -// block counts as depth 1. Accumulated roughness rather than a vertex count is the principled fix, and -// is what a radiance cache would need anyway to decide cache-vs-trace. -static const uint RIS_MAX_DIFFUSE_DEPTH = 2u; - -// Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen -// sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. -Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, - float rough, bool twoSided, float sss, uint shadedDepth, - inout uint seed, inout uint proposalSeed) { - Reservoir r = resEmpty(); - LightGridCell gridCell; - int3 gridCellCoord; - float3 gridLookup = hitPos; - if (pc.lightGridCellAddr != 0) { - // Stochastically blend across hard section boundaries. Every cell proposal retains full global - // support, so conditioning on this independently jittered lookup remains unbiased. - gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5) - * worldPush.lightGridOrigin.w; - } - bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); - // shadedDepth, not the bounce index: a surface behind glass or water is the first thing this path has - // shaded and is visually primary, so it keeps the full candidate count. - uint candidateCount = shadedDepth == 0u - ? worldPush.risCandidates - : max(1u, worldPush.risCandidates / SECONDARY_RIS_DIVISOR); - // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six - // local and two global candidates, with every lane taking the same branch for a given candidate. - // Counts that are not divisible by four use the nearest practical split with at least one global - // candidate; M=1 is global-only so the estimator retains full support. - uint globalCandidateCount = hasGridCell - ? max(1u, (candidateCount + 2u) / 4u) : candidateCount; - uint localCandidateCount = candidateCount - globalCandidateCount; - float localProbability = float(localCandidateCount) / float(candidateCount); - for (uint c = 0u; c < candidateCount; c++) { - r.M += 1.0; - uint li; - uint globalsBefore = (c * globalCandidateCount) / candidateCount; - uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; - bool useLocal = hasGridCell && globalsAfter == globalsBefore; - selectLightGridLight(gridCell, useLocal, proposalSeed, li); - Light lg = ConstPtr(pc.lightBufAddr)[li]; - // Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area). - float s = rndf(seed) * 2.0 - 1.0; - float t = rndf(seed) * 2.0 - 1.0; - float3 sp = lg.pos + worldPush.lightRebase.xyz - + s * lightHalfU(lg) + t * lightHalfV(lg); - float3 le = lightRadiance(lg); - float3 lightNormal = lightGeometricNormal(lg); - float area = lightArea(lg); - float phat; - evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd, - diffAlb, F0, rough, twoSided, sss, phat); - if (phat <= 0.0) { - continue; - } - float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability); - float w = phat / max(sourcePdf, 1.0e-20); - r.wSum += w; - if (rndf(seed) * r.wSum < w) { // weighted reservoir update - r.pos = sp; - r.lnrm = lightNormal; - r.le = le; - r.area = area; - r.phat = phat; - } - } - r.W = r.phat > 0.0 ? (r.wSum / (r.M * r.phat)) : 0.0; - return r; -} - -// Shade a finalized reservoir: recompute the survivor's contribution at (hitPos,n,v), cast ONE shadow -// ray, and return throughput-free radiance contrib*vis*W. The one ray serves whichever term fired for -// the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the -// sample's side of the surface. -float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, - float3 F0, float rough, bool twoSided, float sss, out float3 vis) { - vis = float3(0.0, 0.0, 0.0); - if (s.W <= 0.0 || s.phat <= 0.0) { - return float3(0.0, 0.0, 0.0); - } - float phat; - float3 contrib = evalSampleContrib(s.pos, s.lnrm, s.le, s.area, hitPos, n, v, rd, - diffAlb, F0, rough, twoSided, sss, phat); - if (phat <= 0.0) { - return float3(0.0, 0.0, 0.0); - } - // Pick the shadow-ray origin on the sample's side of the surface FIRST, then measure the ray from - // that origin — measuring dist from hitPos while tracing from the biased origin shifts the ray tip - // up to SURF_BIAS*ndl toward the emitter plane, beating the 0.001*dist stop-short margin for lights - // closer than ~SURF_BIAS/0.001 blocks -> shadow ray hits the emitter's own face -> per-texel black. - float side = dot(n, s.pos - hitPos) >= 0.0 ? 1.0 : -1.0; - float3 origin = hitPos + side * n * SURF_BIAS; - float3 toL = s.pos - origin; - float dist = length(toL); - // Stop just short of the sample point so the ray doesn't self-occlude on the emitter's own face. - VisibilityResult shadow = visibility(origin, toL / dist, dist * 0.999); - vis = shadow.transmittance; - return contrib * vis * s.W; -} - -// TLAS instance-mask bits (set on the Java side, ANDed against these per-ray cull masks): -// bit 0 (0x01) — secondary rays: shadows, GI bounces, reflections. -// bit 1 (0x02) — the primary camera ray. -// Terrain / entities / block entities keep 0xFF (both bits). Particles use 0x02: primary-visible -// receiver billboards only. The first-person camera owner uses 0x01 (secondary only). -static const uint CULL_SECONDARY = 0x01u; -static const uint CULL_PRIMARY = 0x02u; -static const uint TERRAIN_BUCKETS = 4u; -static const uint SBT_RADIANCE = 0u; -static const uint SBT_SHADOW = TERRAIN_BUCKETS; -static const uint SBT_STRIDE_BUCKET = 1u; -static const uint MISS_RADIANCE = 0u; -static const uint MISS_GUIDE = 1u; - -RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { - RayDesc r; - r.Origin = origin; - r.TMin = tmin; - r.Direction = dir; - r.TMax = tmax; - return r; -} - -float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, - float3 outgoingDirection, float bias) { - float3 sideNormal = dot(surfaceNormal, outgoingDirection) >= 0.0 - ? surfaceNormal : -surfaceNormal; - return position + sideNormal * bias; -} - -// A radiance payload carrying only the two words the invoked hit/miss shader READS: `flags` -// (show-celestial gate, consumed by world.rmiss) and the packed ray cone (consumed by world.rchit for -// texture LOD). Every other member is an OUTPUT that world.rchit/world.rmiss write, so it carries no -// information into a trace and is initialized to a neutral value here. -Payload makeRadiancePayload(uint flags, uint rayCone) { - Payload p; - p.albedo = half3(0.0h, 0.0h, 0.0h); - p.normal = half3(0.0h, 0.0h, 0.0h); - p.hitT = -1.0; // miss sentinel: world.rmiss writes only albedo, so a miss leaves this negative - p.motionPrev = half3(0.0h, 0.0h, 0.0h); - p.f0 = half3(0.0h, 0.0h, 0.0h); - p.flags = flags; - p.roughMetal = 0u; - p.emissionSss = 0u; - p.iorTransmission = 0u; - p.rayCone = rayCone; - return p; -} - -// SER radiance trace: trace into a hit object, reorder threads by hit coherence, THEN invoke the -// hit/miss shader — this is where the divergent Section/Prim shading work (world.rchit/world.rahit) -// actually happens, so reordering before it is what pays for the reorder's own cost. -// -// The payload is built TWICE from the trace state rather than carried across ReorderThread. Traversal -// runs only world.rahit, which never writes a RADIANCE payload: its terrain tint paths live in the -// translucent/water buckets, which carry no any-hit record for radiance rays, and its entity paths are -// gated on RAY_FLAG_SKIP_CLOSEST_HIT_SHADER. So whatever TraceRay leaves in the payload holds no -// information. Reading it back and carrying it over would pin all ten members — 72 bytes — in registers -// across the one point where SER must spill every live value, which profiling showed to be the peak -// live state in this shader. Rebuilding costs a few constant moves and leaves only these two words -// spanning the reorder. -void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - bool showCelestial, float rayConeWidth, float rayConeSpread) { - uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; - uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); - Payload tracePayload = makeRadiancePayload(flags, rayCone); - HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, - SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, - makeRay(ro, tmin, rd, tmax), tracePayload); - ReorderThread(hObj); - payload = makeRadiancePayload(flags, rayCone); - HitObject::Invoke(topLevelAS, hObj, payload); -} - -// Auxiliary RR probes deliberately use ordinary TraceRay. They are one-off, incoherent rays whose -// result is consumed immediately, so paying a reorder barrier and carrying guide state across it only -// adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, -// not sky radiance. -void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - float rayConeWidth, float rayConeSpread) { - payload = makeRadiancePayload(0u, packHalf2(float2(rayConeWidth, rayConeSpread))); - TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, - MISS_GUIDE, makeRay(ro, tmin, rd, tmax), payload); -} - -Payload makeShadowPayload() { - Payload shadowPayload; - shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); - shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit - shadowPayload.normal = half3(0.0h, 0.0h, 0.0h); - shadowPayload.motionPrev = half3(0.0h, 0.0h, 0.0h); - shadowPayload.f0 = half3(0.0h, 0.0h, 0.0h); - shadowPayload.flags = 0u; - shadowPayload.roughMetal = 0u; - shadowPayload.emissionSss = 0u; - shadowPayload.iorTransmission = 0u; - shadowPayload.rayCone = 0u; - return shadowPayload; -} - -VisibilityResult visibility(float3 origin, float3 dir, float tmax) { - // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow - // path uses only albedo as accumulated transmittance and hitT as the nearest-water crossing. - Payload shadowPayload = makeShadowPayload(); - // Shadow SBT records run any-hit only for cutout/translucent/water. Cutout alpha-tests; translucent - // and water tint shadowPayload.albedo and pass through. Solid blocks terminate traversal. There is no - // closest or miss shader worth executing, so use a hit object only as the traversal result. - HitObject hObj = HitObject::TraceRay(topLevelAS, - RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER, - CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, 0u, - makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); - VisibilityResult result; - result.transmittance = hObj.IsMiss() ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); - result.waterHitT = shadowPayload.hitT; - return result; -} - -// `valid` is false when the point projects behind the previous camera, where the perspective divide -// would otherwise manufacture an arbitrarily large NDC. Mirror images reach that case easily: a virtual -// image sits as far behind the reflector as the source is in front, so a reflection seen at a grazing -// angle can land behind the eye even though the reflector itself is comfortably in view. -float2 projectPrevNdc(float3 worldPos, out bool valid) { - float4 clip = mul(worldPush.prevViewProj, float4(worldPos - worldPush.camOffset + worldPush.camDelta, 1.0)); - valid = clip.w > 0.0; - return valid ? clip.xy / clip.w : float2(0.0, 0.0); -} - -// Previous-frame screen position of a planar reflection. The reflected image of a world point P seen -// in a planar reflector is the MIRROR image V = mirror(P) across the surface plane: the eye sees V -// along a straight line, so the reflection appears at proj(V). Project the mirror image directly. -// Gotcha: intersecting a reflection ray with the surface plane divides by dot(mirrorDir, n), which -// explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so -// static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); -// reflectedMotionPrev carries the reflected content's own displacement. -float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, - float3 reflectedMotionPrev, out bool valid) { - float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed - float3 prevHit = reflectedWorldPos - reflectedMotionPrev; - float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - surfacePos, n); - return projectPrevNdc(mirroredHit, valid); -} - -// Reflection motion remains owned by the physical foreground interface even when transmission later -// replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). -float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, - float2 currentNdc, float2 size, float primaryConeSpread) { - if (length(surface.normal) < 0.5 - || max(surface.albedo.r, max(surface.albedo.g, surface.albedo.b)) <= 0.001 - || surface.roughness > SPEC_MOTION_ALPHA_MAX) { - return float2(0.0, 0.0); - } - - float3 surfacePos = surface.camRel + worldPush.camOffset; - float3 n = normalize(surface.normal); - float3 specDir = reflect(primaryDir, n); - // biasNormal is a closest-hit normal, already unit length, and only its sign matters here. - traceGuide(CULL_SECONDARY, - offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), - RAY_TMIN, specDir, 10000.0, - max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), - max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); - - float3 reflectedHit; - float3 reflectedMotionPrev; - if (payload.hitT > 0.0) { - reflectedHit = surfacePos + specDir * payload.hitT; - reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE - ? payload.motionPrev : float3(0.0, 0.0, 0.0); - } else { - reflectedHit = surfacePos + specDir * 1.0e6; - reflectedMotionPrev = float3(0.0, 0.0, 0.0); - } - float3 previousN = length(surface.previousNormal) >= 0.5 - ? normalize(surface.previousNormal) : n; - bool prevValid; - float2 prevNdc = previousReflectionNdc( - surfacePos, previousN, reflectedHit, reflectedMotionPrev, prevValid); - return prevValid ? (prevNdc - currentNdc) * 0.5 * size : float2(0.0, 0.0); -} - -// Deterministic transmitted destination for clear-interface RR guides. The first interface has already -// been crossed. Continue through every crossed dielectric until opaque/particle content or sky is reached; -// TIR changes the walk into a reflected secondary ray instead of producing a false foreground guide. -void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, - float roughness, float3 diffuseAlbedo) { - gv_hitCamRel = hitCamRel; - gv_motionHitCamRel = hitCamRel; - gv_motionObjDisp = motionPrev; - gv_motionUseRefracted = true; - gv_normal = normal; - gv_rough = roughness; - gv_albedo = diffuseAlbedo; -} - -void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, - float3 surfaceBiasNormal, - float currentIor, float rayBias, - float rayConeWidth, float rayConeSpread) { - if (dot(transmittedDir, transmittedDir) <= 0.0) return; - - float3 direction = normalize(transmittedDir); - // surfaceBiasNormal is a closest-hit normal, already unit length, and only its sign matters here. - float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); - currentIor = max(currentIor, 1.0); - - for (uint crossing = 0u; crossing < MAX_TRANSMISSION_GUIDE_HITS; ++crossing) { - traceGuide(CULL_PRIMARY, ro, RAY_TMIN, direction, 10000.0, - rayConeWidth, rayConeSpread); - if (payload.hitT <= 0.0) { - setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, - float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, SKY_DIFF_ALBEDO); - return; - } - - uint material = payloadMaterial(); - float3 interfacePos = ro + direction * payload.hitT; - if (material == MATERIAL_OPAQUE || material == MATERIAL_PARTICLE) { - float endpointRoughness = material == MATERIAL_PARTICLE - ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); - float3 endpointAlbedo = material == MATERIAL_PARTICLE - ? payload.albedo - : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); - setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, - payload.normal, endpointRoughness, endpointAlbedo); - return; - } - - if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; - - // Mirror the radiance path's refraction. Absorption inside the crossed media is deliberately NOT - // folded into the albedo guide: RR demodulates by albedo, and a distance-dependent attenuation - // baked into it would disagree with the colour, which is where the extinction actually lands. - float3 interfaceNormal = normalize(payload.normal); - float3 interfaceBiasNormal = interfaceNormal; - if (material == MATERIAL_WATER && (worldPush.flags & 16u) != 0u) { - float waterFootprint = rayConeWidth - / max(abs(dot(-direction, interfaceBiasNormal)), 0.2); - interfaceNormal = applyWaterWaves(interfaceNormal, - interfacePos.xz + worldPush.waterAnchor.xy, - worldPush.waterParams.w, waterFootprint); - } - // Exiting returns to air: the walk keeps only the current index, which is the same depth-1 - // approximation the guide already made and is exact for a single enclosing volume. - float targetIor = payloadDielectricEntering() ? max(payloadIor(), 1.0) : 1.0; - float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); - if (dot(nextDirection, nextDirection) <= 0.0) { - // Total internal reflection: there is no transmitted destination to describe, and following - // the reflection instead would be actively harmful. setTransmissionGuide marks its endpoint - // as its own feature, whose MV is that point's own reprojection delta — valid for a - // refracted point (the near-constant refraction offset cancels between frames) but WRONG for - // a mirror image, which sweeps at roughly twice the camera's rate and in the opposite sense. - // That produced motion vectors that grew with camera motion. - // - // Bailing leaves the foreground interface tuple the caller already wrote, which is what TIR - // physically deserves: F is 1, so specular albedo is 1 and diffuse albedo is 0, the pixel is - // pure reflection, and gSpecMotion already describes it with a proper mirror-image - // reprojection. Depth stays on the interface, which is the only real surface here. - return; - } - direction = normalize(nextDirection); - currentIor = targetIor; - ro = offsetSurfaceOrigin( - interfacePos, interfaceBiasNormal, direction, SURF_BIAS); - } -} - -// One Monte Carlo path from (ro, rd). Returns accumulated radiance along the path. -float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, - out PathSegment pending, out bool hasPending) { - float3 L = float3(0.0, 0.0, 0.0); - float3 ro = seg.ro; - float3 rd = seg.rd; - float3 throughput = seg.throughput; - uint seed = seg.seed; - bool captureGuides = seg.captureGuides; - bool maySplit = seg.maySplit; - pending = seg; // `out` must be written on every path; hasPending gates whether it is read - hasPending = false; - float rayConeWidth = seg.rayConeWidth; - float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); - int maxBounces = int(worldPush.maxBounces); - int rrStart = maxBounces <= 3 ? 1 : 2; - // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the - // candidate count is non-zero. Off => emitters are gathered only on path hits (legacy behaviour). - bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && pc.lightBufAddr != 0; - // Independent light-proposal RNG. Screen-space coherent selection is intentionally deferred until - // there is a real presampled RIS tile buffer; direct sharing made tile-shaped lighting noise visible. - // Measured: making a 16x16 tile share this seed recovers at most 1.3ms, so a future pool should share - // the POOL rather than the seed — the win is in removing dependent loads, not in coherence. - uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u - ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; - proposalSeed = pcg(proposalSeed); - // The medium the segment starts in. For the camera segment that is water when the eye is submerged - // (so the first ray already carries the right relative index and absorption); for a segment split off - // a dielectric it is whatever the parent was travelling through. - MediumStack medium = seg.medium; - bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation - // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned - // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from - // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc - // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. - bool showCelestial = seg.showCelestial; - // Number of surfaces this path has SHADED so far, as distinct from `bounce`, which also counts - // dielectric interfaces. Glass and water continue without shading anything, so a wall behind a pane - // is bounce 1 but diffuseDepth 0 — visually primary, and it must be treated as such. See - // RIS_MAX_DIFFUSE_DEPTH. - int diffuseDepth = seg.diffuseDepth; - for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { - // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to - // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. - // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person - // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. - traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); - - if (payload.hitT < 0.0) { - // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The - // packed show-celestial flag decides whether this path segment may see the bright disc. - float3 sky = payload.albedo; - if (bounce == 0 && captureGuides) { // primary ray hit sky: guides describe "no surface" - gv_normal = float3(0.0, 0.0, 0.0); - // Flat mid-grey albedo guide for sky (NOT the bright sky radiance): demodulating DLSS-RR - // by the huge sun-disc radiance rings/artifacts around the sun. A constant albedo keeps - // the brightness in the lighting channel where RR resolves it cleanly. - gv_albedo = SKY_DIFF_ALBEDO; - gv_rough = 1.0; - gv_hitCamRel = rd * 1.0e6; // sky at infinity: only rotation parallax (-> large far depth) - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = float3(0.0, 0.0, 0.0); // sky is static - // Zero normal: specularReflectionMotion rejects this and returns a zero reflection MV. - gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); - } - L += throughput * sky; // escaped to sky - break; - } - - // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies - // to every hit reached while inside a volume dielectric (its own exit face, or whatever content - // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so - // the ordinary case costs one comparison. - if (any(medium.current.extinction > 0.0)) { - throughput *= exp(-medium.current.extinction * payload.hitT); - } - - float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit - float3 hitPos = ro + rd * payload.hitT; - rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); - uint material = payloadMaterial(); - - // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every - // dielectric is a volume. Fresnel from the relative index across the face, then reflect or - // refract; no diffuse response and no sun NEE, because the surface is specular. Transmission - // pushes or pops a participating medium, and the segment travelled inside it is attenuated by - // that medium's extinction rather than by a tint multiply at the interface. - // - // Water is the only material that adds anything: the animated wave normal, and a medium marked - // for the caustic term. Which side of the face we are on comes from the geometry - // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest - // of the path. - if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { - bool isWater = material == MATERIAL_WATER; - bool entering = payloadDielectricEntering(); - float3 tint = payload.albedo; - float transmission = clamp(payloadTransmission(), 0.0, 1.0); - - // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken - // reflections, wobbling refraction. The wave domain is world-stable: rebased hit XZ plus the - // pushed anchor (terrain origin mod 4096) survives the per-move terrain rebase. - float3 geometricNormal = n; // unrippled; ray origins offset along this - float3 previousNormal = n; // wave normal one frame ago, for the reflection MV - if (isWater && waterWaves) { - float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; - float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); - if (bounce == 0 && captureGuides && abs(geometricNormal.y) >= 0.5) { - float2 currentGrad; - float2 previousGrad; - waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, - worldPush.waterAnchor.z, waterFootprint, currentGrad, previousGrad); - float orientation = geometricNormal.y >= 0.0 ? 1.0 : -1.0; - n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); - previousNormal = orientation - * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); - } else { - n = applyWaterWaves(geometricNormal, waterDomain, - worldPush.waterParams.w, waterFootprint); - } - } - - // The medium this face opens into, and the one it returns to on the way out — which is what - // the stack remembers. - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); - float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.ior; - - float cosI = clamp(dot(-rd, n), 0.0, 1.0); - float F = fresnelDielectric(cosI, etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); - // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a - // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full - // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water - // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. - float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - - if (bounce == 0 && captureGuides) { // feed RR a smooth dielectric specular surface - gv_normal = n; - gv_rough = 0.0; // an exact Fresnel interface, not a finite GGX lobe - gv_albedo = float3(0.0, 0.0, 0.0); // fallback when TIR leaves no transmitted layer - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; - gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, - gv_rough, float3(F, F, F)); - - if (dot(transmittedDir, transmittedDir) > 0.0) { - resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, - entering ? entered.ior : medium.outer.ior, - transmitBias, rayConeWidth, rayConeSpread); - } - } - - // Deterministic split at the first interface a visually-primary path meets: trace BOTH - // continuations and weight them by F exactly, instead of picking one and paying the variance. - // The reflection leaves as a pending segment rather than a recursive call, so the path tracer - // is instantiated once. It stays on the incidence side, so it inherits the medium unchanged. - bool split = maySplit && diffuseDepth == 0; - if (split) { - float3 splitDir = reflect(rd, n); - pending = makePathSegment( - offsetSurfaceOrigin(hitPos, geometricNormal, splitDir, SURF_BIAS), splitDir, - throughput * F, medium, rayConeWidth, rayConeSpread, seed ^ 0xa511e9b3u, - bounce + 1, diffuseDepth, true, false, false); - hasPending = true; - maySplit = false; - throughput *= 1.0 - F; // this segment is now the transmitted branch only - } - - bool chooseReflection = split ? false : rndf(seed) < F; - if (chooseReflection) { - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); - } else { - if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen - rd = normalize(transmittedDir); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); - // Crossing into or out of the volume. Absorption is the medium's job from here, so the - // tint is NOT also multiplied into the throughput — that would double-count it. - if (entering) { - mediumPush(medium, entered); - } else { - mediumPop(medium); - } - } - showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - break; - } - throughput /= q; - } - continue; - } - - // Particles (material == 2): camera-facing receiver billboard. The instance mask keeps particles - // off secondary rays, so this only fires at bounce 0; they receive direct light and one diffuse - // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. - if (material == MATERIAL_PARTICLE) { - float3 albedo = payload.albedo; - if (captureGuides) { - gv_normal = n; - gv_albedo = albedo; // RR demodulation target = the particle texel (keeps it sharp) - gv_rough = 1.0; - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; // per-particle MV: de-ghost moving particles - // Zero specular albedo: no reflection MV is traced for a diffuse billboard. - gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, float3(0.0, 0.0, 0.0)); - } - - float3 lightDir = worldPush.lightDir.xyz; - float lightHalfAngle = worldPush.lightDir.w; - if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); - } - // Billboards are effectively two-sided receivers. Bias from the side facing the light; shadow - // rays exclude particles anyway, but this keeps the origin sane when the light is behind the - // camera side. - float signedNdl = dot(n, lightDir); - float ndl = abs(signedNdl); - if (ndl > 0.0) { - float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; - float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; - if (max(vis.r, max(vis.g, vis.b)) > 0.0) { - L += throughput * albedo * INV_PI * worldPush.lightRadiance.xyz * ndl * vis; - } - } - - // RIS direct lighting from block emitters (lava, glowstone, torches, ...) — same reservoir - // sampler as terrain/entities, but two-sided: a billboard has no back face, so light - // striking either side of the quad should still land (matches the sun/moon NEE above). - if (risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH)) { - float3 v = -rd; - Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, uint(diffuseDepth), seed, proposalSeed); - float3 risVis; - L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, risVis); - } - - if (bounce >= maxBounces) { - break; - } - throughput *= albedo; - ro = hitPos + n * SURF_BIAS; - rd = cosineDir(n, seed); - rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); - showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above - diffuseDepth++; // a billboard is a shaded surface, unlike the dielectric branches - continue; - } - - float3 albedo = payload.albedo; - float sss = payloadSss(); // LabPBR SSS strength (0 when absent) - float3 p = hitPos + n * SURF_BIAS; - float3 v = -rd; // view direction (toward the camera / incoming ray) - - // Material split. Metals tint F0 by albedo and have no diffuse lobe; dielectrics use a fixed - // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). - float rough = clamp(payloadRoughness(), 0.0, 1.0); - float metal = clamp(payloadMetalness(), 0.0, 1.0); - // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. - // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the - // reflection sharp instead of accumulating it as a glossy lobe. - bool exactSpecular = rough <= MIRROR_ALPHA_MAX; - rough = exactSpecular ? 0.0 : rough; - float3 diffAlb = albedo * (1.0 - metal); - float3 F0 = payload.f0; - - if (bounce == 0 && captureGuides) { // primary-visibility surface: capture the denoiser guide buffers - gv_normal = n; - gv_albedo = diffAlb; // RR diffuse-albedo demodulation target - gv_rough = rough; - gv_hitCamRel = ro + rd * payload.hitT - worldPush.camOffset; // camera-relative hit position - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; // per-vertex world motion (0 for static terrain/sky) - gv_spec = makeSpecSurface(gv_hitCamRel, n, rough, - rrSpecularAlbedo(F0, rough, dot(n, v))); - } - - // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. - // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples - // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial - // false), which the previous vertex's RIS already accounted for; the primary ray and - // specular/dielectric bounces (showCelestial true) still add it, since no emitter NEE happened - // along those. Emitters NOT in the light buffer (sparse/sub-threshold footprints, entities) are - // never NEE-sampled, so they always gather — bit-identical to the no-NEE path, no energy lost. - // Emission is already the full HDR strength (EMISSIVE_STRENGTH baseline * any override - // multiplier baked in Java at material-compile time — see MaterialHeader.features packing). - float emission = payloadEmission(); - // One gate drives both the RIS call below and the direct-hit emission suppression here. They must - // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where - // RIS is skipped must also gather emission directly or that light is lost outright. - bool risActive = risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); - bool gateEmitter = risActive && payloadEmitterInList(); - if (emission > 0.0 && (!gateEmitter || showCelestial)) { - L += throughput * albedo * emission; - } - - // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert - // diffuse + GGX specular, one shadow ray. Jitter the light direction within its square angular - // extent (worldPush.lightDir.w half-angle) so the shadow ray samples the light's quad — soft penumbrae - // over accumulation. The same sampled direction drives ndl, the shadow ray, and the specular - // half-vector. - float3 lightDir = worldPush.lightDir.xyz; - float lightHalfAngle = worldPush.lightDir.w; - if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); - } - float ndl = max(0.0, dot(n, lightDir)); - if (ndl > 0.0) { - VisibilityResult shadow = visibility(p, lightDir, 10000.0); - float3 vis = shadow.transmittance; - // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by - // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, - // so it applies to the whole NEE term (diffuse + specular). - if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { - vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); - } - if (max(vis.r, max(vis.g, vis.b)) > 0.0) { - float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) - float3 h = normalize(lightDir + v); - float ndh = max(0.0, dot(n, h)); - float ndv = max(1.0e-4, dot(n, v)); - float vdh = max(0.0, dot(v, h)); - float D = ggxD(ndh, rough); - float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); // separable Smith masking - float3 F = fresnelSchlick(vdh, F0); - brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular - L += throughput * brdf * worldPush.lightRadiance.xyz * ndl * vis; - } - } - - // RIS direct lighting from block emitters (single-frame only, no temporal reservoir reuse). - // Front lobe + SSS backlighting are resampled together and share ONE shadow ray — a light can - // only be in front of n or behind it, never both, so combining them into one target function is - // exact, not an approximation. The SSS term is gated off past MAX_SSS_DIFFUSE_DEPTH by passing - // sss=0 there (falls back to plain front-only RIS). Keyed on diffuseDepth, not `bounce`, for the - // same reason as RIS_MAX_DIFFUSE_DEPTH above: leaves seen through a window are still the first - // surface this path has shaded, and should still backlight. - if (risActive) { - float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; - Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, - uint(diffuseDepth), seed, proposalSeed); - float3 risVis; - L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, - activeSss, risVis); - } - - // Thin-surface SSS transmission. Light entering from the back face scatters through toward the - // viewer via a forward-biased HG phase (leaves/grass backlit by the sun glow when you look - // through them toward the light source). Shadow ray fires from the back face to avoid - // self-occlusion. cosT = dot(lightDir, rd): rd tracks the ray toward the sun when the sun is - // behind the slab, giving cosT ≈ 1 → forward-scatter peak. - if (sss > 0.0 && diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH) { - float backNdl = max(0.0, dot(-n, lightDir)); - if (backNdl > 0.0) { - VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); - float3 visB = shadowBack.transmittance; - // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should - // flicker with the same light bands as the floor around it. - if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { - visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, - lightDir, shadowBack.waterHitT); - } - if (max(visB.r, max(visB.g, visB.b)) > 0.0) { - float cosT = dot(lightDir, rd); - L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * worldPush.lightRadiance.xyz * visB; - } - } - } - - // Every shading term for this surface is now accounted for, so it counts toward the shaded depth. - // The dielectric branches above return before reaching here, which is exactly the distinction - // between this and `bounce`. - diffuseDepth++; - - // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their - // relative reflectance. - float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 - ? 1.0 - : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); - if (rndf(seed) < ps) { - float3 l; - if (exactSpecular) { - // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the - // degenerate VNDF at alpha=0 and preserves a genuinely sharp reflection without a floor. - l = reflect(rd, n); - throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; - } else { - float3 h = sampleGGXVNDF(n, v, rough, seed); - l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal - float ndl2 = dot(n, l); - if (ndl2 <= 0.0) { - break; // microfacet reflects below the surface: terminate this path - } - // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. - float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); - throughput *= F * ggxG1(ndl2, rough) / ps; - rayConeSpread = max(rayConeSpread, rough * RAY_CONE_GLOSSY_SPREAD_SCALE); - } - ro = p; - rd = l; - showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc - } else { - throughput *= diffAlb / (1.0 - ps); - ro = p; - rd = cosineDir(n, seed); - rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); - showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) - } - - // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - break; - } - throughput /= q; - } - } - return L; -} - -// Resolve the captured gv_* guide state into the DLSS-RR guide images. Called once, right after the -// sample that captured them, so the guide state dies there instead of staying live across every -// remaining sample's traces. -void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { - // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary - // tuple with the transmitted destination. - float2 specMotion = specularReflectionMotion(gv_spec, primaryDir, jndc, size, primaryConeSpread); - - // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative - // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer - // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. - // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. - float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); - float depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; - - // Motion vector: reproject this guide point through the previous frame's view-projection. - // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is - // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is - // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. - // For a dynamic surface, previous-frame position is the current point minus its world-space - // displacement; subtracting gv_motionObjDisp de-cameras the MV. - float4 prevClip = mul(worldPush.prevViewProj, - float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); - float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc - // Transmitted content is its own feature: compare its previous and current projections rather than - // subtracting the primary interface's jittered NDC. - float4 curClipMotion = gv_motionUseRefracted - ? mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)) - : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform - // Guard both perspective divides the way the depth above does. A guide point can land behind either - // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the - // frustum as the camera turns — and w at or below zero turns the divide into an arbitrarily large - // vector that DLSS-RR reprojects with. No valid previous position exists in that case, so report no - // motion and let RR fall back instead of handing it garbage. - float2 motion; - if (prevClip.w > 0.0 && (!gv_motionUseRefracted || curClipMotion.w > 0.0)) { - if (gv_motionUseRefracted) curNdc = curClipMotion.xy / curClipMotion.w; - motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; - } else { - motion = float2(0.0, 0.0); - } - - // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. - gNormal[pix] = float4(gv_normal, gv_rough); - gAlbedo[pix] = float4(gv_albedo, 1.0); - gDepth[pix] = depth; - gMotion[pix] = motion; - gSpecAlbedo[pix] = float4(gv_spec.albedo, 1.0); - gSpecMotion[pix] = specMotion; -} - -[shader("raygeneration")] -void main() { - worldPush = ConstPtr(pc.worldPushAddr)[0]; // load the per-frame push data from the BDA buffer - int2 pix = int2(DispatchRaysIndex().xy); - float2 size = float2(DispatchRaysDimensions().xy); - float2 uv = (float2(pix) + 0.5) / size; - float2 ndc = uv * 2.0 - 1.0; // unjittered pixel centre — the motion vector reprojects this - - // Offset the primary ray within the pixel by the sub-pixel jitter reported to DLSS-RR, so - // successive frames sample different positions and RR can resolve detail above render resolution. - // The guide buffers below come from this jittered first hit (they must match the jittered colour); - // only the motion vector stays on the unjittered centre, which DLSS expects. - float2 jndc = (uv + worldPush.jitter / size) * 2.0 - 1.0; - - float4 nearH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 1.0, 1.0)); - float4 farH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 0.0, 1.0)); - float3 nearP = nearH.xyz / nearH.w; - float3 farP = farH.xyz / farH.w; - - float3 origin = nearP + worldPush.camOffset; - float3 dir = normalize(farP - nearP); - float rayConeSpread = primaryRayConeSpread(jndc, size, dir); - - uint seed = (uint(pix.x) * 1973u + uint(pix.y) * 9277u + 26699u) ^ (worldPush.frameIndex * 2654435761u); - - // Camera medium: water when the eye is submerged, so the first ray already carries the right relative - // index and absorption. The camera-biome tint is the correct extinction until the first water surface - // is crossed, after which each hit supplies its own body's tint. - MediumStack cameraMedium = makeMediumStack((worldPush.flags & 1u) != 0u - ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) - : airMedium()); - - uint spp = max(worldPush.spp, 1u); - float3 frameRadiance = float3(0.0, 0.0, 0.0); - for (uint s = 0u; s < spp; s++) { - seed = pcg(seed); // decorrelate per-sample paths (shared camera ray, no AA jitter yet) - // Guides describe one deterministic surface, so only the first sample's camera segment writes - // them; the split-off reflection and every later sample reuse what it captured. - PathSegment segment = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), cameraMedium, - 0.0, rayConeSpread, seed, 0, 0, true, s == 0u, true); - float3 sampleRadiance = float3(0.0, 0.0, 0.0); - PathSegment queued = segment; - bool hasQueued = false; - - // ONE tracePath call site. A dielectric's second Fresnel branch arrives here as data, so the - // compiler instantiates the path tracer once instead of once per branch. [loop] is load-bearing: - // unrolling this would duplicate the whole path tracer again and undo the point of the exercise. - [loop] - for (uint segIndex = 0u; segIndex < MAX_PATH_SEGMENTS; ++segIndex) { - if (segIndex > 0u) { - if (!hasQueued) break; - segment = queued; - hasQueued = false; - } - PathSegment emitted; - bool didEmit; - sampleRadiance += tracePath(segment, uint2(pix), - s + segIndex * spp, emitted, didEmit); - if (didEmit) { - queued = emitted; - hasQueued = true; - } - } - - // Written as soon as the capturing sample finishes, so none of the gv_* guide state stays live - // across the remaining samples — that live range is paid at every trace in between. - if (s == 0u) { - writeGuides(pix, dir, jndc, size, rayConeSpread); - } - frameRadiance += sampleRadiance; - } - frameRadiance /= float(spp); - - // Debug guide-buffer visualization: bypass accumulation and show a guide directly. Read back from the - // images writeGuides already wrote rather than from gv_*, so no guide state has to stay live to the - // end of the shader just to service a branch that is off in every ordinary frame. - if (pc.debugView != 0u) { - float4 normalRough = gNormal[pix]; - float3 dbg; - if (pc.debugView == 1u) { - dbg = normalRough.xyz * 0.5 + 0.5; // world normal -> [0,1] - } else if (pc.debugView == 2u) { - dbg = gAlbedo[pix].rgb; // diffuse albedo - } else if (pc.debugView == 3u) { - dbg = float3(gDepth[pix], gDepth[pix], gDepth[pix]); // HW reversed-Z (near=white, far=black) - } else if (pc.debugView == 4u) { - dbg = float3(normalRough.w, normalRough.w, normalRough.w); // roughness - } else if (pc.debugView == 6u) { - dbg = gSpecAlbedo[pix].rgb; // exact value bound to DLSS-RR - } else if (pc.debugView == 7u) { - dbg = float3(clamp(0.5 + gSpecMotion[pix] * 0.05, 0.0, 1.0), 0.5); // reflection motion - } else { - dbg = float3(clamp(0.5 + gMotion[pix] * 0.05, 0.0, 1.0), 0.5); // motion: red=+x, green=+y - } - outImage[pix] = float4(dbg, 1.0); - return; - } - - // Single noisy estimate; the denoiser (DLSS-RR) handles temporal convergence. - outImage[pix] = float4(frameRadiance, 1.0); -} +#define CAUSTICA_PRIMARY_PASS 0 +#define CAUSTICA_INDIRECT_PASS 1 +#include "world_path.slanginc" diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index cf2fb2b5..263a7732 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -8,6 +8,7 @@ module world_common; // scalarBlockLayout device feature. Keep std430 unless RtDeviceBringup explicitly enables that feature; // this alias is the single layout switch and generated Java serialization stays in lock-step. public typealias ConstPtr = Ptr; +public typealias DevicePtr = Ptr; // Push constant block. The hot inline lanes avoid dereferencing WorldPush for values that are read in // hit shaders or used for raygen control flow — every 64-bit device address lives here for exactly that @@ -25,6 +26,7 @@ public struct WorldPushConstants { public uint64_t lightLocalAliasAddr; // power aliases relative to each section's range public uint64_t lightGridCellAddr; // dense light grid cell headers (0 = global proposals only) public uint64_t lightGridSpanAddr; // packed weighted section spans referenced by cell headers + public uint64_t pathQueueAddr; // packed primary -> indirect continuation records public uint frameIndex; public uint debugView; }; diff --git a/shaders/world/world_path.slanginc b/shaders/world/world_path.slanginc new file mode 100644 index 00000000..0c426cb6 --- /dev/null +++ b/shaders/world/world_path.slanginc @@ -0,0 +1,1986 @@ +// Shared implementation for the primary/guide and indirect raygen entry points. +// Included by world_primary.rgen.slang and world.rgen.slang; the entry point wrappers define +// CAUSTICA_PRIMARY_PASS or CAUSTICA_INDIRECT_PASS before including this file. +// +// Perspective path tracer. invViewProj = inverse(proj * viewRot) maps clip to camera-relative world. +// Primary rays are reconstructed from two unprojected points (near and far plane) rather than assuming +// the eye is at the origin: Minecraft bakes view bobbing into the projection, so the effective eye comes +// from the matrix. camOffset shifts camera-relative space into the terrain's rebased coordinates. Depth +// is reversed-Z (near=1, far=0). +// +// Lighting is an iterative path-tracing loop in raygen, not recursion from closest-hit, so +// maxRayRecursionDepth stays 1. At each surface vertex: +// * NEE toward the sun — a single analytic directional light, gated by one hit-object visibility query +// (TerminateOnFirstHit | SkipClosestHit). Lambertian BRDF = albedo/PI. +// * a cosine-weighted Lambertian continuation ray (throughput *= albedo; the PI and cos cancel). +// Sky / ambient light needs no separate AO pass: continuation rays that escape land in world.rmiss, +// which returns sky radiance — so soft sky fill and contact AO both fall out of the path integral. +// Russian roulette terminates deep paths. Output is HDR (R16G16B16A16_SFLOAT); the HDR -> LDR tonemap +// happens later at the display.comp seam. +// +// SER: the build emits EXT and NV shader-invocation-reorder variants from this source. +// +// Each frame emits a single SPP-averaged (still noisy) estimate; temporal convergence is the denoiser's +// job. With RR disabled the output is the raw noisy path trace — a reference view, not a converged image. +import world_common; + +[[vk::push_constant]] WorldPushConstants pc; + +[[vk::binding(0, 0)]] RaytracingAccelerationStructure topLevelAS; +// HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. +[[vk::binding(1, 0)]] [format("rgba16f")] RWTexture2D outImage; +// Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written +// every frame regardless of accumulation. gNormal.w carries LINEAR roughness (= GGX alpha, which is what +// DLSS-RR documents for this input); gDepth carries HW reversed-Z depth. +[[vk::binding(3, 0)]] [format("rgba16f")] RWTexture2D gNormal; // xyz world normal, w linear roughness +[[vk::binding(4, 0)]] [format("rgba16f")] RWTexture2D gAlbedo; // rgb diffuse albedo +[[vk::binding(5, 0)]] [format("r32f")] RWTexture2D gDepth; // HW reversed-Z depth +[[vk::binding(6, 0)]] [format("rg16f")] RWTexture2D gMotion; // screen-space motion (render px) +[[vk::binding(7, 0)]] [format("rgba16f")] RWTexture2D gSpecAlbedo; // specular albedo (0 — diffuse-only) +[[vk::binding(8, 0)]] [format("rg16f")] RWTexture2D gSpecMotion; // reflection MVs for RR + +// Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL +// `worldPush` global). Layout constants generated from this module's SPIR-V — see world_common.WorldPush. +static WorldPush worldPush; + +// The radiance payload remains module-level so tracePath and its guide helpers can share it across +// HitObject trace/invoke calls, mirroring the GLSL rayPayloadEXT global. +static Payload payload; + +struct VisibilityResult { + float3 transmittance; + float waterHitT; +}; + +// First-hit (primary-visibility) guide attributes, captured at bounce 0 of tracePath. The primary ray +// is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. +static float3 gv_normal; +static float3 gv_albedo; +static float gv_rough; +static float3 gv_hitCamRel; // primary-surface hit position relative to the current camera +static float3 gv_motionHitCamRel; // motion-guide hit position; water tracks the refracted hit instead +static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit (its own reprojection delta) +// World-space displacement of the motion-guide surface since the previous frame (0 for static +// terrain/sky, an entity's per-vertex/object motion for a dynamic hit). +static float3 gv_motionObjDisp; +// Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs +// (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is +// the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. +struct SpecSurface { + float3 camRel; // interface position relative to the current camera + float3 normal; // shading normal (wave-perturbed for water) + float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates + float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface + float roughness; + float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) +}; +static SpecSurface gv_spec; + +// Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading +// normal now and last frame, flat geometric normal for the bias) via the six-argument form. +SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, float3 biasNormal, + float roughness, float3 albedo) { + SpecSurface s; + s.camRel = camRel; + s.normal = normal; + s.previousNormal = previousNormal; + s.biasNormal = biasNormal; + s.roughness = roughness; + s.albedo = albedo; + return s; +} + +SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { + return makeSpecSurface(camRel, normal, normal, normal, roughness, albedo); +} + + +uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } +// Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was +// travelling into the volume (vs. out of it) at this hit's face. +bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } +bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } +// LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used +// end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly +// that, RtMaterials.Profile carries the same units, and DLSS-RR wants linear roughness in its guide. So +// nothing squares it: ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo all take this value as alpha as-is, and +// any threshold compared against it is an alpha threshold. +float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } +float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } +float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } +float payloadSss() { return unpackHalf2(payload.emissionSss).y; } +float payloadIor() { return unpackHalf2(payload.iorTransmission).x; } +float payloadTransmission() { return unpackHalf2(payload.iorTransmission).y; } + +static const float PI = 3.14159265359; +static const float INV_PI = 0.31830988618; + +// The analytic sky (gradient + sun/moon discs + stars) is computed in world.rmiss. On a miss, raygen +// reads payload.albedo and accumulates that sky radiance. The directional NEE light is pushed separately +// as worldPush.lightDir / worldPush.lightRadiance / worldPush.sunDir. +static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) +static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface + +// Water is a smooth dielectric. IOR 1.333; absorption is per-block Beer-Lambert extinction. +static const float WATER_IOR = 1.333; +static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. +static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); + +// Transmitted-ray origin offset when crossing a face from the TRANSLUCENT terrain layer (glass, ice). +// Must be SMALLER than the mesher-side TRANSLUCENT_INSET (RtTerrainMesher, 2e-4 blocks): those quads are +// recessed into their block by that inset, so a face touching a slab/stair leaves a tiny gap before the +// neighbour's surface. A large bias (SURF_BIAS) would restart the ray past that neighbour and see +// through it; this lands the ray inside the gap so the neighbour is still hit, while still clearing the +// surface it left to avoid self-intersection. Water is meshed by RtFluidMesher with no inset, so it uses +// the ordinary bias. +static const float INSET_TRANSMIT_BIAS = 1.0e-4; + +// Ray-cone texture LOD: raygen tracks a one-pixel footprint along the path, and closest-hit converts it +// to texture mip levels from the local UV/world-space scale. Diffuse/glossy bounces widen the cone so +// indirect texture fetches don't keep sampling mip 0 through broad lobes. +static const float RAY_CONE_MIN_SPREAD = 1.0e-5; +static const float RAY_CONE_MIN_WIDTH = 1.0e-5; +static const float RAY_CONE_DIFFUSE_SPREAD = 0.25; +static const float RAY_CONE_GLOSSY_SPREAD_SCALE = 0.35; + +// Per-channel Beer–Lambert extinction from a water body's biome tint (carried in payload.albedo for +// water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean +// tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor +// keeps even a white-tinted body very slightly absorbing so deep water never reads as clear vacuum. +static const float WATER_DENSITY = 0.05; +static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); +float3 waterExtinction(float3 tint) { + return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); +} + +// A non-water dielectric's tint is a filter over one block of travel rather than a biome water colour, so +// it maps to extinction by inverting Beer-Lambert over that reference distance: transmittance t after one +// block means sigma = -ln(t). `transmission` scales that transmittance, so it keeps the sense the +// material's authored factor has everywhere else — 1 passes the tint through unchanged, 0 is opaque. +// Clamped away from zero so a fully saturated texel stays finite instead of producing an infinite sigma. +static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks +float3 volumeExtinction(float3 tint, float transmission) { + float3 blockTransmittance = clamp(clamp(tint, 0.0, 1.0) * clamp(transmission, 0.0, 1.0), + 1.0e-3, 1.0); + return -log(blockTransmittance) / VOLUME_TINT_REFERENCE_DISTANCE; +} + +// ---- Participating medium the path is currently inside. +// +// Refraction needs the RELATIVE index across an interface, so the renderer has to know what it is +// travelling through, not just what it is hitting. `ior` drives Snell/Fresnel; `extinction` drives the +// per-segment Beer-Lambert attenuation. +// +// The stack is depth 2 (current + the one it will return to) held in named fields, NOT an array. A +// dynamically indexed local array lands in scratch memory, and this raygen is already register-bound — +// paying an occupancy hit for nesting that Minecraft does not produce would be a bad trade. Depth 2 +// covers air->water->glass and air->glass->water, which is the realistic worst case; anything deeper +// degrades to air on the way out, and because `entering` is re-derived per face from geometry rather +// than toggled, the path re-synchronises at the next crossing instead of staying corrupted. +struct Medium { + float ior; + float3 extinction; + bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific +}; + +struct MediumStack { + Medium current; + Medium outer; +}; + +Medium airMedium() { + Medium m; + m.ior = 1.0; + m.extinction = float3(0.0, 0.0, 0.0); + m.water = false; + return m; +} + +MediumStack makeMediumStack(Medium start) { + MediumStack s; + s.current = start; + s.outer = airMedium(); + return s; +} + +void mediumPush(inout MediumStack stack, Medium entered) { + stack.outer = stack.current; + stack.current = entered; +} + +void mediumPop(inout MediumStack stack) { + stack.current = stack.outer; + stack.outer = airMedium(); +} + +// Everything needed to resume tracing from a point in the scene. The path tracer takes one of these and +// may hand back another, which is how a dielectric's two Fresnel continuations are traced without +// instantiating the path tracer twice: the second branch is DATA, not a second inlined copy. `throughput` +// already carries the branch weight, so segments are simply summed. +// +// This is deliberately shaped like a record that could live in a buffer rather than a register: the +// planned split into a primary/guide pass and an indirect pass hands continuations between dispatches, +// and this is the payload it would write. +struct PathSegment { + float3 ro; + float3 rd; + float3 throughput; + MediumStack medium; + float rayConeWidth; + float rayConeSpread; + uint seed; + int bounce; // interfaces already consumed, so RR start and the bounce cap stay global + int diffuseDepth; // surfaces already SHADED (see the field of the same name in tracePath) + bool showCelestial; + bool captureGuides; + bool maySplit; // may still spawn a deterministic Fresnel branch +}; + +PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, + float rayConeWidth, float rayConeSpread, uint seed, + int bounce, int diffuseDepth, bool showCelestial, + bool captureGuides, bool maySplit) { + PathSegment s; + s.ro = ro; + s.rd = rd; + s.throughput = throughput; + s.medium = medium; + s.rayConeWidth = rayConeWidth; + s.rayConeSpread = rayConeSpread; + s.seed = seed; + s.bounce = bounce; + s.diffuseDepth = diffuseDepth; + s.showCelestial = showCelestial; + s.captureGuides = captureGuides; + s.maySplit = maySplit; + return s; +} + +// Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume +// dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. +Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { + Medium m; + m.ior = ior; + m.extinction = isWater ? waterExtinction(tint) : volumeExtinction(tint, transmission); + m.water = isWater; + return m; +} + +// Animated surface waves — a directional wave spectrum, gradient-only (the surface stays geometrically +// flat; only the normal tilts). Three things make it read as water rather than animated glass: +// * Sharp-crested profile: each component is h = a·exp(S·(sin φ − 1)) — peaked crests, wide flat +// troughs (pure sines look like rolling jelly). Still C∞ smooth with an exact analytic gradient. +// * Real deep-water dispersion ω = √(g·k): long swell strides past (λ=14 m → ~4.7 m/s) while short +// chop flutters nearly in place — wrong relative speeds are the biggest tell of fake water. +// * Wind + meander: headings spread around a fixed wind direction (tight for the swell, wide for the +// chop), and the largest components' phase is modulated along their crest (chain-ruled into the +// gradient) so wavefronts wander instead of ruling straight periodic lines across the sea. +// The domain is world-anchored (see applyWaterWaves) so the pattern is pinned in world space, and the +// caustics (waterCaustic) differentiate this same field, so they stay in sync with the visible ripples. +static const float WATER_WAVE_STRENGTH = 0.3; // slope scale (higher = choppier glints / more broken reflections) +static const float WAVE_SPEED = 0.8; // global time scale; dispersion keeps relative speeds physical +static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each octave 1.5× shorter) +static const float WAVE_G = 9.81; +static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction +static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components +float waterWaveLodWeight(float wavelength, float footprint) { + float cyclesPerPixel = max(footprint, 0.0) / max(wavelength, 1.0e-4); + return 1.0 - smoothstep(0.25, 0.5, cyclesPerPixel); +} + +// One walk of the wave spectrum, shared by every consumer so the spectrum constants exist once. WITH_DT +// is a generic so the derivative terms are compiled out entirely for callers that only want the +// gradient — the trigonometry is shared, but the chain-rule work is not free. +void waterWaveSpectrum(float2 p, float t, float footprint, + out float2 grad, out float2 gradDt) { + // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. + const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; + t *= WAVE_SPEED; + grad = float2(0.0, 0.0); + gradDt = float2(0.0, 0.0); + float lambda = 14.0; + for (int i = 0; i < WAVE_COUNT; i++) { + float lodWeight = waterWaveLodWeight(lambda, footprint); + if (lodWeight <= 0.0) break; + float k = 2.0 * PI / lambda; + float w = sqrt(WAVE_G * k); // deep-water dispersion: phase speed √(g/k) + // Heading: deterministic irregular spread about the wind, widening down the spectrum. + float spread = (0.15 + 0.11 * float(i)) * sin(float(i) * 2.4 + 1.3); + float cs = cos(spread), sn = sin(spread); + float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, WAVE_WIND.x * sn + WAVE_WIND.y * cs); + float ph = k * dot(d, p) - w * t + 1.7 * float(i) * float(i); + float2 dph = k * d; + float phDt = -w; // ∂φ/∂t + float2 dphDt = float2(0.0, 0.0); // ∂(∇φ)/∂t + if (i < 4) { // meander only the components whose fronts are long enough to read as lines + float2 pd = float2(-d.y, d.x); + float wph = 0.4 * k * dot(pd, p) + 0.5 * w * t; + ph += WAVE_MEANDER * sin(wph); + dph += (WAVE_MEANDER * cos(wph) * 0.4 * k) * pd; + if (WITH_DT != 0) { + float wphDt = 0.5 * w; + phDt += WAVE_MEANDER * cos(wph) * wphDt; + dphDt = (-WAVE_MEANDER * sin(wph) * wphDt * 0.4 * k) * pd; + } + } + float S = 1.2 + 0.12 * float(i); // crest sharpness: the short chop is choppier + float sinPh = sin(ph); + float cosPh = cos(ph); + float e = exp(S * (sinPh - 1.0)); + // a = Q/k; ∇h = a·S·cos φ·e^{S(sin φ−1)}·∇φ + float amplitude = lodWeight * (Q[i] / k) * S; + grad += amplitude * cosPh * e * dph; + if (WITH_DT != 0) { + // ∂/∂t of the term above, chain-ruled through φ and ∇φ. + gradDt += amplitude * e + * (phDt * (-sinPh + S * cosPh * cosPh) * dph + cosPh * dphDt); + } + lambda /= 1.5; + } + grad *= WATER_WAVE_STRENGTH; + gradDt *= WATER_WAVE_STRENGTH; +} + +float2 waterWaveGrad(float2 p, float t, float footprint) { + float2 grad, gradDt; + waterWaveSpectrum<0>(p, t, footprint, grad, gradDt); + return grad; +} + +float2 waterWaveGrad(float2 p, float t) { + return waterWaveGrad(p, t, 0.0); +} + +// Evaluate the current gradient and linearly rewind it to the previous phase in one spectrum walk. +// Frame deltas are bounded on the CPU, so this avoids a second set of trigonometric/exponential work +// while remaining accurate over the sub-frame interval used by reflection reprojection. +void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, + out float2 currentGrad, out float2 previousGrad) { + float2 gradDt; + waterWaveSpectrum<1>(p, currentT, footprint, currentGrad, gradDt); + previousGrad = currentGrad - (currentT - previousT) * WAVE_SPEED * gradDt; +} + +// Perturb a (near-)horizontal water surface normal by the wave field. Flowing side faces (|n.y| small) +// keep their flat geometric normal. `worldXZ` must be a world-stable coordinate (rebased hit + the pushed +// anchor) so the ripples stay pinned in the world. `nGeo` is already oriented toward the viewer, so the +// down-viewed underside (n.y < 0) takes the mirrored slope (-up). +float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { + if (abs(nGeo.y) < 0.5) return nGeo; + float2 grad = waterWaveGrad(worldXZ, t, footprint); + float3 up = normalize(float3(-grad.x, 1.0, -grad.y)); + return nGeo.y >= 0.0 ? up : -up; +} + +// ---- Water caustics. Sunlight refracting through the waved surface converges/diverges before it +// reaches an underwater receiver; the analytic wave field makes the true focusing factor computable +// instead of faked with a scrolling texture. Where the shadow ray of an underwater NEE vertex crossed +// water (returned by visibility() as VisibilityResult.waterHitT), evaluate the horizontal landing +// position of the refracted sun ray as a function of surface position and finite-difference it. The +// caustic intensity is the inverse Jacobian determinant of that surface→floor mapping (area compression = brightening, real +// fold caustics where det → 0). Because this uses the SAME wave field as the visible surface normals, +// the caustic pattern stays in sync with the ripples, and the per-sample sun-quad jitter (sampleSquare) +// shifts the pattern per sample → caustics physically blur with depth under DLSS-RR accumulation. +static const float CAUSTIC_EPS = 0.25; // finite-difference step (m); also low-passes sub-step wave detail +static const float CAUSTIC_MAX = 6.0; // focusing clamp: keeps fold-line singularities from becoming fireflies + +// Horizontal landing offset of a refracted sun ray entering the surface at world-stable xz, intersected +// with the receiver plane h metres below. inc = sunlight travel direction (downward, unit). +float2 causticLanding(float2 xz, float t, float3 inc, float h) { + float2 g = waterWaveGrad(xz, t); + float3 nw = normalize(float3(-g.x, 1.0, -g.y)); + float3 rdw = refract(inc, nw, 1.0 / WATER_IOR); // air→water: never TIR + return xz + rdw.xz * (h / max(-rdw.y, 0.05)); +} + +float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { + // Grazing light: the receiver-plane intersection degenerates and caustics vanish in reality anyway + // (long slant paths absorb + the pattern smears out) — fade the effect to neutral 1.0. + float fade = smoothstep(0.06, 0.18, lightDir.y); + if (fade <= 0.0) return 1.0; + float h = max(waterDist * lightDir.y, 0.05); // vertical depth of the receiver below the exit point + float t = worldPush.waterParams.w; + float3 inc = -lightDir; + float2 base = exitPos.xz + worldPush.waterAnchor.xy; // world-stable wave domain (same as applyWaterWaves) + float2 p0 = causticLanding(base, t, inc, h); + float2 px = causticLanding(base + float2(CAUSTIC_EPS, 0.0), t, inc, h); + float2 pz = causticLanding(base + float2(0.0, CAUSTIC_EPS), t, inc, h); + float det = abs((px.x - p0.x) * (pz.y - p0.y) - (px.y - p0.y) * (pz.x - p0.x)); + // Flat water ⇒ all landings displace identically ⇒ det = eps² ⇒ focus = 1 (energy-neutral); the + // clamp bounds the fold-line spikes whose energy the surrounding darkening already paid for. + float focus = (CAUSTIC_EPS * CAUSTIC_EPS) / max(det, 1.0e-5); + return lerp(1.0, min(focus, CAUSTIC_MAX), fade); +} + +// Below this GGX alpha the lobe is treated as a delta mirror. The threshold is not about detecting an +// authored zero — it is about the point where a finite lobe stops being distinguishable from a mirror. +// A GGX lobe's half-angle is ~alpha, and the sun's angular radius is ~0.0047 rad, so alpha = 4e-4 is +// ~12x tighter than the sun disc and far under a pixel footprint at any reflection distance. Nothing +// below it can be resolved, and the delta path is both sharper and better conditioned there. +// +// Two things follow from taking the delta path, and both are why the threshold is set here rather than +// at the 8-bit quantum: +// * ggxD(_, 0) is exactly 0, so the NEE specular term vanishes by construction and the sun arrives +// solely via the mirror ray hitting the disc (showCelestial). That is the correct, unbiased +// accounting — see the double-count note on ggxD below. +// * There is no floor to apply above the threshold: alpha > 4e-4 is well conditioned for both the +// VNDF sample and the NDF, so authored roughness passes through unmodified and the mapping stays +// continuous. A separate MIN_ROUGH would only reintroduce a cliff. +// +// Stored roughness IS alpha (see payloadRoughness), so these compare against it directly. +static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= 0.98 +// Above this the specular lobe is too broad for a single mirrored reprojection to describe, so the +// reflection MV is left at zero and RR falls back to the ordinary MV. +static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 +static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; +// Continuations one sample may carry: the camera segment, plus the reflection branch a primary +// dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. +static const uint MAX_PATH_SEGMENTS = 2u; + +float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } + +// GGX normal distribution. +// NOTE: the 1e-7 denominator guard is not merely a divide-by-zero epsilon, and it is load-bearing. At +// the lobe peak d == a2, so the guard dominates PI*d*d until a2 >> 1.8e-4, i.e. rough >> 0.116; every +// smoother material has its NEE highlight peak truncated. That truncation is what keeps the sun from +// being counted twice: a specular continuation sets showCelestial, so the BSDF-sampled ray may hit the +// sun disc in world.rmiss while NEE is also adding a sun term for the same lobe, with no MIS weighting +// between them. Removing the guard restores the physical peak AND the double count, and makes the +// single-sample sun estimator explode wherever the lobe is tighter than the disc (~0.0047 rad). The +// real fix is MIS (or a disc-aware specular light sample), not an epsilon edit — left as-is. +float ggxD(float ndh, float alpha) { + float a2 = alpha * alpha; + float d = ndh * ndh * (a2 - 1.0) + 1.0; + return a2 / (PI * d * d + 1.0e-7); +} + +// Smith GGX masking term for one direction (separable form; G2 = G1(v) * G1(l)). +float ggxG1(float ndx, float alpha) { + float a2 = alpha * alpha; + return 2.0 * ndx / (ndx + sqrt(a2 + (1.0 - a2) * ndx * ndx) + 1.0e-7); +} + +float3 fresnelSchlick(float cosT, float3 f0) { + float m = clamp(1.0 - cosT, 0.0, 1.0); + float m2 = m * m; + return f0 + (1.0 - f0) * (m2 * m2 * m); +} + +// Henyey–Greenstein phase function (with 4π normalisation so it integrates to 1 over the sphere). +// g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). +static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional +static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) +// Deepest SHADED surface at which the SSS transmission shadow ray fires. diffuseDepth, not `bounce`: the +// glass/water continue branches don't shade a surface, so foliage seen through a window is still +// diffuseDepth 0 and should backlight like any other primary leaf — see RIS_MAX_DIFFUSE_DEPTH for the +// same distinction and why it matters. +static const int MAX_SSS_DIFFUSE_DEPTH = 1; // fire on the first-shaded surface + one indirect hit +float hg(float cosT, float g) { + float g2 = g * g; + return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); +} + +// NVIDIA RR guide-buffer helper: integrated/view-dependent specular reflectivity, not raw F0. +// alpha is GGX alpha (== the linear roughness materials store), NoV is the first-hit view cosine. +float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { + NoV = abs(NoV); + float NoV2 = NoV * NoV; + float alpha2 = alpha * alpha; + float4 X = float4(1.0, NoV, NoV2, NoV * NoV2); + float4 Y = float4(1.0, alpha, alpha2, alpha * alpha2); + + float2 m1 = float2( + dot(float2(0.99044, -1.28514), X.xy), + dot(float2(1.29678, -0.755907), X.xy)); + float3 Xxyw = float3(X.x, X.y, X.w); + float3 m2 = float3( + dot(float3(1.0, 2.92338, 59.4188), Xxyw), + dot(float3(20.3225, -27.0302, 222.592), Xxyw), + dot(float3(121.563, 626.13, 316.627), Xxyw)); + float bias = dot(m1, Y.xy) / max(dot(m2, float3(Y.x, Y.y, Y.w)), 1.0e-7); + + float2 m3 = float2( + dot(float2(0.0365463, 3.32707), X.xy), + dot(float2(9.0632, -9.04756), X.xy)); + float3 Xxzw = float3(X.x, X.z, X.w); + float3 m4 = float3( + dot(float3(1.0, 3.59685, -1.36772), Xxzw), + dot(float3(9.04401, -16.3174, 9.22949), Xxzw), + dot(float3(5.56589, 19.7886, -20.2123), Xxzw)); + float scale = dot(m3, Y.xy) / max(dot(m4, float3(Y.x, Y.y, Y.w)), 1.0e-7); + + bias *= clamp(specularColor.g * 50.0, 0.0, 1.0); + return specularColor * max(0.0, scale) + float3(max(0.0, bias), max(0.0, bias), max(0.0, bias)); +} + +// Exact (unpolarized) Fresnel reflectance for a dielectric interface. cosI is the incidence cosine on +// the incoming side; etaI/etaT are the IORs the ray travels from / into. Returns 1.0 on total internal +// reflection (sin of the transmitted angle >= 1), which matches refract() returning the zero vector — +// so reflect/refract selection and TIR stay consistent. +float fresnelDielectric(float cosI, float etaI, float etaT) { + float sinT2 = (etaI * etaI) / (etaT * etaT) * max(0.0, 1.0 - cosI * cosI); + if (sinT2 >= 1.0) { + return 1.0; + } + float cosT = sqrt(1.0 - sinT2); + float rs = (etaI * cosI - etaT * cosT) / (etaI * cosI + etaT * cosT); + float rp = (etaI * cosT - etaT * cosI) / (etaI * cosT + etaT * cosI); + return 0.5 * (rs * rs + rp * rp); +} + +// PCG hash RNG. +uint pcg(inout uint s) { + s = s * 747796405u + 2891336453u; + uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; + return (w >> 22u) ^ w; +} +float rndf(inout uint s) { + // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first + // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` + // tests (most seriously the F == 1 total-internal-reflection branch). + return float(pcg(s) >> 8u) * (1.0 / 16777216.0); +} + +float3 primaryRayDir(float2 ndc) { + float4 nearH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 1.0, 1.0)); + float4 farH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 0.0, 1.0)); + float3 nearP = nearH.xyz / nearH.w; + float3 farP = farH.xyz / farH.w; + return normalize(farP - nearP); +} + +float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { + float2 onePixelNdc = 2.0 / size; + float3 dx = primaryRayDir(ndc + float2(onePixelNdc.x, 0.0)); + float3 dy = primaryRayDir(ndc + float2(0.0, onePixelNdc.y)); + return max(max(length(cross(dir, dx)), length(cross(dir, dy))), RAY_CONE_MIN_SPREAD); +} + +// Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the +// importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation +// throughput is just *= albedo with no PI/cos bookkeeping left over. +float3 cosineDir(float3 n, inout uint s) { + float u1 = rndf(s); + float u2 = rndf(s); + float r = sqrt(u1); + float phi = 6.2831853 * u2; + float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); + float3 b = normalize(cross(n, t)); + t = cross(b, n); + float3 local = float3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - u1))); + return normalize(local.x * t + local.y * b + local.z * n); +} + +// Soft shadows: sample a direction within the light's SQUARE angular extent about `axis`. MC's +// sun/moon are square quads, so the NEE shadow ray samples a square (not a cone) — the same square the +// visible disc in world.rmiss uses, built from the same celestial tangent frame (right = arc-travel +// direction, from worldPush.celestial.xyz). Averaged over frames by DLSS-RR this yields soft penumbrae that +// widen with occluder distance (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). +float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { + float3 right = normalize(cross(axis, worldPush.celestial.xyz)); + float3 up = cross(right, axis); + float t = tan(halfAngle); + float u = (rndf(s) * 2.0 - 1.0) * t; + float v = (rndf(s) * 2.0 - 1.0) * t; + return normalize(axis + u * right + v * up); +} + +// Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the +// sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the +// importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). +float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { + float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); + float3 b = normalize(cross(n, t)); + t = cross(b, n); + float3 Ve = float3(dot(ve, t), dot(ve, b), dot(ve, n)); // view in tangent space (z = n) + float a = alpha; + float3 Vh = normalize(float3(a * Ve.x, a * Ve.y, Ve.z)); + float lensq = Vh.x * Vh.x + Vh.y * Vh.y; + float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); + float3 T2 = cross(Vh, T1); + float u1 = rndf(s); + float u2 = rndf(s); + float r = sqrt(u1); + float phi = 6.2831853 * u2; + float p1 = r * cos(phi); + float p2 = r * sin(phi); + float ss = 0.5 * (1.0 + Vh.z); + p2 = (1.0 - ss) * sqrt(1.0 - p1 * p1) + ss * p2; + float3 Nh = p1 * T1 + p2 * T2 + sqrt(max(0.0, 1.0 - p1 * p1 - p2 * p2)) * Vh; + float3 Ne = normalize(float3(a * Nh.x, a * Nh.y, max(0.0, Nh.z))); // microfacet normal, tangent space + return normalize(Ne.x * t + Ne.y * b + Ne.z * n); // -> world space +} + +// ===== RIS emitter NEE ================================================================= +// Block emitters (torches, glowstone, lava, ...) get explicit NEE: per diffuse vertex, stream M uniform +// candidates from the global light buffer through a weighted reservoir keyed by the UNSHADOWED target +// p-hat = luminance(f * Le * G * area), then spend ONE shadow ray on the survivor. Sun/moon NEE stays +// separate and additive. Lights are footprint bounding RECTANGLES (RtLightCollector): sampling a point +// uniformly over the rectangle has pdf 1/area, so `area` rides the contribution like any area light. +// Single-frame RIS only (no temporal reservoir reuse): DLSS-RR's own history already denoises this well +// enough that a persistent per-pixel reservoir wasn't worth the added complexity and VRAM. +// One resample serves three mutually exclusive receiver terms (see evalSampleContrib): front BRDF, +// two-sided particle billboards, and LabPBR SSS backscatter from lights BEHIND the surface — all share +// the reservoir and its single shadow ray, whose origin follows the survivor's side of the surface. + +// A reservoir in registers. pos/lnrm/le/area describe the chosen emitter sample; W is its unbiased +// contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted). +struct Reservoir { + float3 pos; // light sample point (rebased world space) + float3 lnrm; // emitter outward normal + float3 le; // emitter radiance + float area; // emitter rectangle area (the area-light pdf factor) + float M; + float W; + float wSum; + float phat; +}; + +Reservoir resEmpty() { + Reservoir r; + r.pos = float3(0.0, 0.0, 0.0); + r.lnrm = float3(0.0, 0.0, 0.0); + r.le = float3(0.0, 0.0, 0.0); + r.area = 0.0; + r.M = 0.0; + r.W = 0.0; + r.wSum = 0.0; + r.phat = 0.0; + return r; +} + +// Unshadowed contribution of a fixed light sample point at surface (hitPos,n,v); out p-hat = its +// luminance (the resampling target). Evaluated from the UNBIASED hitPos — SURF_BIAS is applied only to +// the shadow-ray origin in shadeReservoir, on whichever side the survivor sample lands. Three receiver +// modes share one target function because they are mutually exclusive per candidate: +// - front hemisphere (ndl>0): the same Lambert(+GGX) BRDF split the sun NEE uses; +// - twoSided (particles): billboards have no back face, ndl=abs(ndl) folds both sides into the front; +// - back hemisphere with sss>0 (LabPBR leaves/grass): HG transmission phase — cosT = dot(wi,rd) peaks +// when the ray points from the light through the slab toward the camera (same as the sun SSS term). +float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n, + float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, + bool twoSided, float sss, out float phat) { + phat = 0.0; + float3 toL = sp - hitPos; + float dist2 = dot(toL, toL); + if (dist2 < 1.0e-6) { + return float3(0.0, 0.0, 0.0); + } + float dist = sqrt(dist2); + float3 wi = toL / dist; + float cosL = max(0.0, dot(lnrm, -wi)); + if (cosL <= 0.0) { + return float3(0.0, 0.0, 0.0); // receiver behind the emitter face + } + float ndl = dot(n, wi); + if (twoSided) { + ndl = abs(ndl); + } + float3 contrib = float3(0.0, 0.0, 0.0); + if (ndl > 0.0) { + float G = ndl * cosL / dist2; // area-light geometry term (x area) + float3 brdf = diffAlb * INV_PI; + // twoSided callers are particle billboards, which pass rough=1/F0=0 and want diffuse-only + // shading: fresnelSchlick still returns up to full white at grazing angles even with F0=0 (that + // IS the Fresnel effect), so skipping the term here — not just zeroing F0 — is what keeps + // billboards from picking up a rim highlight they were never meant to have. + if (!twoSided) { + float3 h = normalize(wi + v); + float ndh = max(0.0, dot(n, h)); + float ndv = max(1.0e-4, dot(n, v)); + float vdh = max(0.0, dot(v, h)); + float D = ggxD(ndh, rough); + float Gs = ggxG1(ndv, rough) * ggxG1(ndl, rough); + float3 Fs = fresnelSchlick(vdh, F0); + brdf += (D * Gs) * Fs / (4.0 * ndv * ndl); + } + contrib = brdf * le * (G * area); + } else if (sss > 0.0) { + float backNdl = -ndl; + float G = backNdl * cosL / dist2; + float cosT = dot(wi, rd); + contrib = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * le * (G * area); + } + phat = luminance(contrib); + return contrib; +} + +// Select one light from the emitted-power distribution in O(1). +void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { + float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); + uint column = min(uint(aliasSample), worldPush.lightCount - 1u); + if (pc.lightAliasAddr != 0) { + LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; + bool self = aliasSample - float(column) < a.accept; + lightIndex = self ? column : a.aliasIndex; + } else { + lightIndex = column; + } +} + +bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { + cell.spanOffset = 0u; + cell.spanCount = 0u; + cell.invWeightSum = 0.0; + cellCoord = int3(0, 0, 0); + if (pc.lightGridCellAddr == 0 || pc.lightGridSpanAddr == 0) return false; + int3 coord = int3(floor((p - worldPush.lightGridOrigin.xyz) / worldPush.lightGridOrigin.w)); + if (coord.x < 0 || coord.y < 0 || coord.z < 0 + || coord.x >= worldPush.lightGridDims.x + || coord.y >= worldPush.lightGridDims.y + || coord.z >= worldPush.lightGridDims.z) return false; + uint linear = (uint(coord.z) * uint(worldPush.lightGridDims.y) + uint(coord.y)) + * uint(worldPush.lightGridDims.x) + uint(coord.x); + cell = ConstPtr(pc.lightGridCellAddr)[linear]; + cellCoord = coord; + return cell.spanCount > 0u; +} + +void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, + out uint lightIndex) { + float aliasSample = rndf(proposalSeed) * float(lightCount); + uint column = min(uint(aliasSample), lightCount - 1u); + LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; + bool self = aliasSample - float(column) < alias.accept; + uint localIndex = self ? column : alias.aliasIndex; + lightIndex = firstLight + localIndex; +} + +float unpackUnsignedFloat(uint bits, uint mantissaBits) { + uint mantissaMask = (1u << mantissaBits) - 1u; + uint mantissa = bits & mantissaMask; + uint exponent = (bits >> mantissaBits) & 31u; + if (exponent == 0u) { + return float(mantissa) * exp2(float(1 - 15 - int(mantissaBits))); + } + return (1.0 + float(mantissa) / float(1u << mantissaBits)) + * exp2(float(int(exponent) - 15)); +} + +float3 lightRadiance(Light light) { + uint packed = light.le; + return float3(unpackUnsignedFloat(packed & 0x7ffu, 6u), + unpackUnsignedFloat((packed >> 11u) & 0x7ffu, 6u), + unpackUnsignedFloat((packed >> 22u) & 0x3ffu, 5u)); +} + +int3 lightSectionCoord(Light light) { + uint packed = light.section; + return int3(int(packed & 0x3ffu), int((packed >> 10u) & 0x3ffu), + int((packed >> 20u) & 0x3ffu)); +} + +// The half axes share three lanes two-at-a-time; see the Light record in world_common. +float3 lightHalfU(Light light) { + return float3(unpackHalf2(light.halfUxy), unpackHalf2(light.halfUzVx).x); +} + +float3 lightHalfV(Light light) { + return float3(unpackHalf2(light.halfUzVx).y, unpackHalf2(light.halfVyz)); +} + +// U x V drives both the emitter normal and the rectangle area, so it is computed once and shared. +float3 lightCrossUV(Light light) { + return cross(lightHalfU(light), lightHalfV(light)); +} + +// The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store. +float lightArea(Light light) { + return 4.0 * length(lightCrossUV(light)); +} + +float3 lightGeometricNormal(Light light) { + float3 normal = normalize(lightCrossUV(light)); + return (light.section & 0x40000000u) != 0u ? -normal : normal; +} + +float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, + float localProbability) { + float power = max(0.0, lightArea(light) * luminance(le)); + float globalPdf = pc.lightAliasAddr != 0 + ? power * worldPush.lightRebase.w : 1.0 / float(worldPush.lightCount); + float localPdf = 0.0; + if (localProbability > 0.0 && power > 0.0) { + int3 delta = lightSectionCoord(light) - cellCoord; + if (all(abs(delta) <= int3(2, 2, 2))) { + float3 deltaF = float3(delta); + localPdf = power * cell.invWeightSum / max(1.0, dot(deltaF, deltaF)); + } + } + return localProbability * localPdf + (1.0 - localProbability) * globalPdf; +} + +void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, + out uint lightIndex) { + ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); + float aliasSample = rndf(proposalSeed) * float(cell.spanCount); + uint column = min(uint(aliasSample), cell.spanCount - 1u); + LightGridSpan span = spans[cell.spanOffset + column]; + bool self = aliasSample - float(column) < span.accept; + uint firstLight = self ? span.firstLight : span.aliasFirstLight; + uint lightCount = self ? (span.packedLightCounts & 0xffffu) + : (span.packedLightCounts >> 16u); + selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex); +} + +// Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section +// by emitted power and section distance, then its section-local power alias. Every light in +// the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. +// Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single +// Light48 load: section power cancels with the section-local light PDF. +void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, + out uint lightIndex) { + if (useLocal) { + selectLightGridSpanLight(cell, proposalSeed, lightIndex); + } else { + selectGlobalLight(proposalSeed, lightIndex); + } +} + +// ---- RIS cost budget. Both constants below come from a set of temporary probes that pinned the +// candidate walk out of the shader to bound what memory-side RIS work could ever be worth. Against an +// 18.0ms frame at M=8, removing every dependent load (span -> alias -> record) and all lane divergence +// saved 5.9ms — a third of the frame. Split by vertex that was ~4.3ms at secondary vertices against +// ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost +// was chasing DEPTH, not divergence, and it lived at secondary vertices. The two knobs below spend that +// finding; a presampled candidate pool would attack the same 5.9ms structurally, but see the note on +// proposalSeed in tracePath for why it should share the pool rather than the seed. + +// Candidate-count divisor for RIS at secondary vertices (bounce > 0). Secondary vertices carried ~78% of +// the cost while being much the more forgiving place to spend variance: that radiance is already +// integrated over a diffuse lobe before the denoiser sees it. 18.0 -> 16.1ms, and indistinguishable from +// 1 by eye — whereas reducing M globally to 2 was clearly worse, because that also degraded the primary +// hit. The primary hit always keeps the full worldPush.risCandidates. +// +// 4 is the floor worth using rather than a tuning accident. The stratification below keeps at least one +// global candidate, so M=2 is the smallest count that still schedules one local AND one global; at M=1 +// localProbability collapses to 0 and the light-grid proposal that makes nearby emitters sample well is +// gone entirely. +static const uint SECONDARY_RIS_DIVISOR = 4u; + +// Deepest SHADED surface at which RIS emitter NEE runs. Past it, emitters are gathered only when a path +// hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep, so the +// failure mode is noisier emitter light at depth rather than missing light. Unlike the divisor this also +// drops shadeReservoir's per-vertex shadow ray, which never scaled with M. Gating at all: 16.1 -> 15.7ms. +// +// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_DIELECTRIC and +// MATERIAL_WATER continue without shading anything, so looking through a pane spends bounce 0 on the +// glass and lands the first shaded surface at bounce 1. Gating on `bounce` therefore starved surfaces +// seen through glass or water — visually primary, but counted as depth — and they went black, since only +// a path randomly striking an emitter could light them. Keying on shaded surfaces removes that whole +// class of bug and lets the budget go lower than the two bounces of headroom the dielectric prefix used +// to cost. +// +// Caveat this does not solve: a smooth specular bounce IS a shaded surface, so a wall seen in a metal +// block counts as depth 1. Accumulated roughness rather than a vertex count is the principled fix, and +// is what a radiance cache would need anyway to decide cache-vs-trace. +static const uint RIS_MAX_DIFFUSE_DEPTH = 2u; + +// Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen +// sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. +Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, + float rough, bool twoSided, float sss, uint shadedDepth, + inout uint seed, inout uint proposalSeed) { + Reservoir r = resEmpty(); + LightGridCell gridCell; + int3 gridCellCoord; + float3 gridLookup = hitPos; + if (pc.lightGridCellAddr != 0) { + // Stochastically blend across hard section boundaries. Every cell proposal retains full global + // support, so conditioning on this independently jittered lookup remains unbiased. + gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5) + * worldPush.lightGridOrigin.w; + } + bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); + // shadedDepth, not the bounce index: a surface behind glass or water is the first thing this path has + // shaded and is visually primary, so it keeps the full candidate count. + uint candidateCount = shadedDepth == 0u + ? worldPush.risCandidates + : max(1u, worldPush.risCandidates / SECONDARY_RIS_DIVISOR); + // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six + // local and two global candidates, with every lane taking the same branch for a given candidate. + // Counts that are not divisible by four use the nearest practical split with at least one global + // candidate; M=1 is global-only so the estimator retains full support. + uint globalCandidateCount = hasGridCell + ? max(1u, (candidateCount + 2u) / 4u) : candidateCount; + uint localCandidateCount = candidateCount - globalCandidateCount; + float localProbability = float(localCandidateCount) / float(candidateCount); + for (uint c = 0u; c < candidateCount; c++) { + r.M += 1.0; + uint li; + uint globalsBefore = (c * globalCandidateCount) / candidateCount; + uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; + bool useLocal = hasGridCell && globalsAfter == globalsBefore; + selectLightGridLight(gridCell, useLocal, proposalSeed, li); + Light lg = ConstPtr(pc.lightBufAddr)[li]; + // Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area). + float s = rndf(seed) * 2.0 - 1.0; + float t = rndf(seed) * 2.0 - 1.0; + float3 sp = lg.pos + worldPush.lightRebase.xyz + + s * lightHalfU(lg) + t * lightHalfV(lg); + float3 le = lightRadiance(lg); + float3 lightNormal = lightGeometricNormal(lg); + float area = lightArea(lg); + float phat; + evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd, + diffAlb, F0, rough, twoSided, sss, phat); + if (phat <= 0.0) { + continue; + } + float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability); + float w = phat / max(sourcePdf, 1.0e-20); + r.wSum += w; + if (rndf(seed) * r.wSum < w) { // weighted reservoir update + r.pos = sp; + r.lnrm = lightNormal; + r.le = le; + r.area = area; + r.phat = phat; + } + } + r.W = r.phat > 0.0 ? (r.wSum / (r.M * r.phat)) : 0.0; + return r; +} + +// Shade a finalized reservoir: recompute the survivor's contribution at (hitPos,n,v), cast ONE shadow +// ray, and return throughput-free radiance contrib*vis*W. The one ray serves whichever term fired for +// the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the +// sample's side of the surface. +float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, + float3 F0, float rough, bool twoSided, float sss, out float3 vis) { + vis = float3(0.0, 0.0, 0.0); + if (s.W <= 0.0 || s.phat <= 0.0) { + return float3(0.0, 0.0, 0.0); + } + float phat; + float3 contrib = evalSampleContrib(s.pos, s.lnrm, s.le, s.area, hitPos, n, v, rd, + diffAlb, F0, rough, twoSided, sss, phat); + if (phat <= 0.0) { + return float3(0.0, 0.0, 0.0); + } + // Pick the shadow-ray origin on the sample's side of the surface FIRST, then measure the ray from + // that origin — measuring dist from hitPos while tracing from the biased origin shifts the ray tip + // up to SURF_BIAS*ndl toward the emitter plane, beating the 0.001*dist stop-short margin for lights + // closer than ~SURF_BIAS/0.001 blocks -> shadow ray hits the emitter's own face -> per-texel black. + float side = dot(n, s.pos - hitPos) >= 0.0 ? 1.0 : -1.0; + float3 origin = hitPos + side * n * SURF_BIAS; + float3 toL = s.pos - origin; + float dist = length(toL); + // Stop just short of the sample point so the ray doesn't self-occlude on the emitter's own face. + VisibilityResult shadow = visibility(origin, toL / dist, dist * 0.999); + vis = shadow.transmittance; + return contrib * vis * s.W; +} + +// TLAS instance-mask bits (set on the Java side, ANDed against these per-ray cull masks): +// bit 0 (0x01) — secondary rays: shadows, GI bounces, reflections. +// bit 1 (0x02) — the primary camera ray. +// Terrain / entities / block entities keep 0xFF (both bits). Particles use 0x02: primary-visible +// receiver billboards only. The first-person camera owner uses 0x01 (secondary only). +static const uint CULL_SECONDARY = 0x01u; +static const uint CULL_PRIMARY = 0x02u; +static const uint TERRAIN_BUCKETS = 4u; +static const uint SBT_RADIANCE = 0u; +static const uint SBT_SHADOW = TERRAIN_BUCKETS; +static const uint SBT_STRIDE_BUCKET = 1u; +static const uint MISS_RADIANCE = 0u; +static const uint MISS_GUIDE = 1u; + +RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { + RayDesc r; + r.Origin = origin; + r.TMin = tmin; + r.Direction = dir; + r.TMax = tmax; + return r; +} + +float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, + float3 outgoingDirection, float bias) { + float3 sideNormal = dot(surfaceNormal, outgoingDirection) >= 0.0 + ? surfaceNormal : -surfaceNormal; + return position + sideNormal * bias; +} + +// A radiance payload carrying only the two words the invoked hit/miss shader READS: `flags` +// (show-celestial gate, consumed by world.rmiss) and the packed ray cone (consumed by world.rchit for +// texture LOD). Every other member is an OUTPUT that world.rchit/world.rmiss write, so it carries no +// information into a trace and is initialized to a neutral value here. +Payload makeRadiancePayload(uint flags, uint rayCone) { + Payload p; + p.albedo = half3(0.0h, 0.0h, 0.0h); + p.normal = half3(0.0h, 0.0h, 0.0h); + p.hitT = -1.0; // miss sentinel: world.rmiss writes only albedo, so a miss leaves this negative + p.motionPrev = half3(0.0h, 0.0h, 0.0h); + p.f0 = half3(0.0h, 0.0h, 0.0h); + p.flags = flags; + p.roughMetal = 0u; + p.emissionSss = 0u; + p.iorTransmission = 0u; + p.rayCone = rayCone; + return p; +} + +// SER radiance trace: trace into a hit object, reorder threads by hit coherence, THEN invoke the +// hit/miss shader — this is where the divergent Section/Prim shading work (world.rchit/world.rahit) +// actually happens, so reordering before it is what pays for the reorder's own cost. +// +// The payload is built TWICE from the trace state rather than carried across ReorderThread. Traversal +// runs only world.rahit, which never writes a RADIANCE payload: its terrain tint paths live in the +// translucent/water buckets, which carry no any-hit record for radiance rays, and its entity paths are +// gated on RAY_FLAG_SKIP_CLOSEST_HIT_SHADER. So whatever TraceRay leaves in the payload holds no +// information. Reading it back and carrying it over would pin all ten members — 72 bytes — in registers +// across the one point where SER must spill every live value, which profiling showed to be the peak +// live state in this shader. Rebuilding costs a few constant moves and leaves only these two words +// spanning the reorder. +void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + bool showCelestial, float rayConeWidth, float rayConeSpread) { + uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; + uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); + Payload tracePayload = makeRadiancePayload(flags, rayCone); + HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, + SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, + makeRay(ro, tmin, rd, tmax), tracePayload); + ReorderThread(hObj); + payload = makeRadiancePayload(flags, rayCone); + HitObject::Invoke(topLevelAS, hObj, payload); +} + +// Auxiliary RR probes deliberately use ordinary TraceRay. They are one-off, incoherent rays whose +// result is consumed immediately, so paying a reorder barrier and carrying guide state across it only +// adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, +// not sky radiance. +void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + float rayConeWidth, float rayConeSpread) { + payload = makeRadiancePayload(0u, packHalf2(float2(rayConeWidth, rayConeSpread))); + TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, + MISS_GUIDE, makeRay(ro, tmin, rd, tmax), payload); +} + +Payload makeShadowPayload() { + Payload shadowPayload; + shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); + shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit + shadowPayload.normal = half3(0.0h, 0.0h, 0.0h); + shadowPayload.motionPrev = half3(0.0h, 0.0h, 0.0h); + shadowPayload.f0 = half3(0.0h, 0.0h, 0.0h); + shadowPayload.flags = 0u; + shadowPayload.roughMetal = 0u; + shadowPayload.emissionSss = 0u; + shadowPayload.iorTransmission = 0u; + shadowPayload.rayCone = 0u; + return shadowPayload; +} + +VisibilityResult visibility(float3 origin, float3 dir, float tmax) { + // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow + // path uses only albedo as accumulated transmittance and hitT as the nearest-water crossing. + Payload shadowPayload = makeShadowPayload(); + // Shadow SBT records run any-hit only for cutout/translucent/water. Cutout alpha-tests; translucent + // and water tint shadowPayload.albedo and pass through. Solid blocks terminate traversal. There is no + // closest or miss shader worth executing, so use a hit object only as the traversal result. + HitObject hObj = HitObject::TraceRay(topLevelAS, + RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER, + CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, 0u, + makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); + VisibilityResult result; + result.transmittance = hObj.IsMiss() ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); + result.waterHitT = shadowPayload.hitT; + return result; +} + +// `valid` is false when the point projects behind the previous camera, where the perspective divide +// would otherwise manufacture an arbitrarily large NDC. Mirror images reach that case easily: a virtual +// image sits as far behind the reflector as the source is in front, so a reflection seen at a grazing +// angle can land behind the eye even though the reflector itself is comfortably in view. +float2 projectPrevNdc(float3 worldPos, out bool valid) { + float4 clip = mul(worldPush.prevViewProj, float4(worldPos - worldPush.camOffset + worldPush.camDelta, 1.0)); + valid = clip.w > 0.0; + return valid ? clip.xy / clip.w : float2(0.0, 0.0); +} + +// Previous-frame screen position of a planar reflection. The reflected image of a world point P seen +// in a planar reflector is the MIRROR image V = mirror(P) across the surface plane: the eye sees V +// along a straight line, so the reflection appears at proj(V). Project the mirror image directly. +// Gotcha: intersecting a reflection ray with the surface plane divides by dot(mirrorDir, n), which +// explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so +// static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); +// reflectedMotionPrev carries the reflected content's own displacement. +float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, + float3 reflectedMotionPrev, out bool valid) { + float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed + float3 prevHit = reflectedWorldPos - reflectedMotionPrev; + float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - surfacePos, n); + return projectPrevNdc(mirroredHit, valid); +} + +// Reflection motion remains owned by the physical foreground interface even when transmission later +// replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). +float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, + float2 currentNdc, float2 size, float primaryConeSpread) { + if (length(surface.normal) < 0.5 + || max(surface.albedo.r, max(surface.albedo.g, surface.albedo.b)) <= 0.001 + || surface.roughness > SPEC_MOTION_ALPHA_MAX) { + return float2(0.0, 0.0); + } + + float3 surfacePos = surface.camRel + worldPush.camOffset; + float3 n = normalize(surface.normal); + float3 specDir = reflect(primaryDir, n); + // biasNormal is a closest-hit normal, already unit length, and only its sign matters here. + traceGuide(CULL_SECONDARY, + offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), + RAY_TMIN, specDir, 10000.0, + max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), + max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); + + float3 reflectedHit; + float3 reflectedMotionPrev; + if (payload.hitT > 0.0) { + reflectedHit = surfacePos + specDir * payload.hitT; + reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE + ? payload.motionPrev : float3(0.0, 0.0, 0.0); + } else { + reflectedHit = surfacePos + specDir * 1.0e6; + reflectedMotionPrev = float3(0.0, 0.0, 0.0); + } + float3 previousN = length(surface.previousNormal) >= 0.5 + ? normalize(surface.previousNormal) : n; + bool prevValid; + float2 prevNdc = previousReflectionNdc( + surfacePos, previousN, reflectedHit, reflectedMotionPrev, prevValid); + return prevValid ? (prevNdc - currentNdc) * 0.5 * size : float2(0.0, 0.0); +} + +// Deterministic transmitted destination for clear-interface RR guides. The first interface has already +// been crossed. Continue through every crossed dielectric until opaque/particle content or sky is reached; +// TIR changes the walk into a reflected secondary ray instead of producing a false foreground guide. +void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, + float roughness, float3 diffuseAlbedo) { + gv_hitCamRel = hitCamRel; + gv_motionHitCamRel = hitCamRel; + gv_motionObjDisp = motionPrev; + gv_motionUseRefracted = true; + gv_normal = normal; + gv_rough = roughness; + gv_albedo = diffuseAlbedo; +} + +void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, + float3 surfaceBiasNormal, + float currentIor, float rayBias, + float rayConeWidth, float rayConeSpread) { + if (dot(transmittedDir, transmittedDir) <= 0.0) return; + + float3 direction = normalize(transmittedDir); + // surfaceBiasNormal is a closest-hit normal, already unit length, and only its sign matters here. + float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); + currentIor = max(currentIor, 1.0); + + for (uint crossing = 0u; crossing < MAX_TRANSMISSION_GUIDE_HITS; ++crossing) { + traceGuide(CULL_PRIMARY, ro, RAY_TMIN, direction, 10000.0, + rayConeWidth, rayConeSpread); + if (payload.hitT <= 0.0) { + setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, + float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, SKY_DIFF_ALBEDO); + return; + } + + uint material = payloadMaterial(); + float3 interfacePos = ro + direction * payload.hitT; + if (material == MATERIAL_OPAQUE || material == MATERIAL_PARTICLE) { + float endpointRoughness = material == MATERIAL_PARTICLE + ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); + float3 endpointAlbedo = material == MATERIAL_PARTICLE + ? payload.albedo + : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); + setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, + payload.normal, endpointRoughness, endpointAlbedo); + return; + } + + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; + + // Mirror the radiance path's refraction. Absorption inside the crossed media is deliberately NOT + // folded into the albedo guide: RR demodulates by albedo, and a distance-dependent attenuation + // baked into it would disagree with the colour, which is where the extinction actually lands. + float3 interfaceNormal = normalize(payload.normal); + float3 interfaceBiasNormal = interfaceNormal; + if (material == MATERIAL_WATER && (worldPush.flags & 16u) != 0u) { + float waterFootprint = rayConeWidth + / max(abs(dot(-direction, interfaceBiasNormal)), 0.2); + interfaceNormal = applyWaterWaves(interfaceNormal, + interfacePos.xz + worldPush.waterAnchor.xy, + worldPush.waterParams.w, waterFootprint); + } + // Exiting returns to air: the walk keeps only the current index, which is the same depth-1 + // approximation the guide already made and is exact for a single enclosing volume. + float targetIor = payloadDielectricEntering() ? max(payloadIor(), 1.0) : 1.0; + float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); + if (dot(nextDirection, nextDirection) <= 0.0) { + // Total internal reflection: there is no transmitted destination to describe, and following + // the reflection instead would be actively harmful. setTransmissionGuide marks its endpoint + // as its own feature, whose MV is that point's own reprojection delta — valid for a + // refracted point (the near-constant refraction offset cancels between frames) but WRONG for + // a mirror image, which sweeps at roughly twice the camera's rate and in the opposite sense. + // That produced motion vectors that grew with camera motion. + // + // Bailing leaves the foreground interface tuple the caller already wrote, which is what TIR + // physically deserves: F is 1, so specular albedo is 1 and diffuse albedo is 0, the pixel is + // pure reflection, and gSpecMotion already describes it with a proper mirror-image + // reprojection. Depth stays on the interface, which is the only real surface here. + return; + } + direction = normalize(nextDirection); + currentIor = targetIor; + ro = offsetSurfaceOrigin( + interfacePos, interfaceBiasNormal, direction, SURF_BIAS); + } +} + +// One Monte Carlo path from the continuation produced by the primary pass. +// Deterministic dielectric splitting is deliberately disabled for M1; M2 will append the second branch. +float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { + float3 L = float3(0.0, 0.0, 0.0); + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + uint seed = seg.seed; + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); + int maxBounces = int(worldPush.maxBounces); + int rrStart = maxBounces <= 3 ? 1 : 2; + // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the + // candidate count is non-zero. Off => emitters are gathered only on path hits (legacy behaviour). + bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && pc.lightBufAddr != 0; + // Independent light-proposal RNG. Screen-space coherent selection is intentionally deferred until + // there is a real presampled RIS tile buffer; direct sharing made tile-shaped lighting noise visible. + // Measured: making a 16x16 tile share this seed recovers at most 1.3ms, so a future pool should share + // the POOL rather than the seed — the win is in removing dependent loads, not in coherence. + uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u + ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; + proposalSeed = pcg(proposalSeed); + // The medium the segment starts in. For the camera segment that is water when the eye is submerged + // (so the first ray already carries the right relative index and absorption); for a segment split off + // a dielectric it is whatever the parent was travelling through. + MediumStack medium = seg.medium; + bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation + // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned + // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from + // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc + // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. + bool showCelestial = seg.showCelestial; + // Number of surfaces this path has SHADED so far, as distinct from `bounce`, which also counts + // dielectric interfaces. Glass and water continue without shading anything, so a wall behind a pane + // is bounce 1 but diffuseDepth 0 — visually primary, and it must be treated as such. See + // RIS_MAX_DIFFUSE_DEPTH. + int diffuseDepth = seg.diffuseDepth; + for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { + // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to + // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. + // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person + // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. + traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, + showCelestial, rayConeWidth, rayConeSpread); + + if (payload.hitT < 0.0) { + // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The + // packed show-celestial flag decides whether this path segment may see the bright disc. + float3 sky = payload.albedo; + L += throughput * sky; // escaped to sky + break; + } + + // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies + // to every hit reached while inside a volume dielectric (its own exit face, or whatever content + // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so + // the ordinary case costs one comparison. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); + } + + float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit + float3 hitPos = ro + rd * payload.hitT; + rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); + uint material = payloadMaterial(); + + // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every + // dielectric is a volume. Fresnel from the relative index across the face, then reflect or + // refract; no diffuse response and no sun NEE, because the surface is specular. Transmission + // pushes or pops a participating medium, and the segment travelled inside it is attenuated by + // that medium's extinction rather than by a tint multiply at the interface. + // + // Water is the only material that adds anything: the animated wave normal, and a medium marked + // for the caustic term. Which side of the face we are on comes from the geometry + // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest + // of the path. + if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 tint = payload.albedo; + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + + // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken + // reflections, wobbling refraction. The wave domain is world-stable: rebased hit XZ plus the + // pushed anchor (terrain origin mod 4096) survives the per-move terrain rebase. + float3 geometricNormal = n; // unrippled; ray origins offset along this + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } + + // The medium this face opens into, and the one it returns to on the way out — which is what + // the stack remembers. + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.outer.ior; + + float cosI = clamp(dot(-rd, n), 0.0, 1.0); + float F = fresnelDielectric(cosI, etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a + // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full + // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water + // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + + bool chooseReflection = rndf(seed) < F; + if (chooseReflection) { + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); + } else { + if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + // Crossing into or out of the volume. Absorption is the medium's job from here, so the + // tint is NOT also multiplied into the throughput — that would double-count it. + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + } + showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + break; + } + throughput /= q; + } + continue; + } + + // Particles (material == 2): camera-facing receiver billboard. The instance mask keeps particles + // off secondary rays, so this only fires at bounce 0; they receive direct light and one diffuse + // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. + if (material == MATERIAL_PARTICLE) { + float3 albedo = payload.albedo; + + float3 lightDir = worldPush.lightDir.xyz; + float lightHalfAngle = worldPush.lightDir.w; + if (lightHalfAngle > 0.0) { + lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + } + // Billboards are effectively two-sided receivers. Bias from the side facing the light; shadow + // rays exclude particles anyway, but this keeps the origin sane when the light is behind the + // camera side. + float signedNdl = dot(n, lightDir); + float ndl = abs(signedNdl); + if (ndl > 0.0) { + float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; + float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; + if (max(vis.r, max(vis.g, vis.b)) > 0.0) { + L += throughput * albedo * INV_PI * worldPush.lightRadiance.xyz * ndl * vis; + } + } + + // RIS direct lighting from block emitters (lava, glowstone, torches, ...) — same reservoir + // sampler as terrain/entities, but two-sided: a billboard has no back face, so light + // striking either side of the quad should still land (matches the sun/moon NEE above). + if (risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH)) { + float3 v = -rd; + Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, + true, 0.0, uint(diffuseDepth), seed, proposalSeed); + float3 risVis; + L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, + true, 0.0, risVis); + } + + if (bounce >= maxBounces) { + break; + } + throughput *= albedo; + ro = hitPos + n * SURF_BIAS; + rd = cosineDir(n, seed); + rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); + showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above + diffuseDepth++; // a billboard is a shaded surface, unlike the dielectric branches + continue; + } + + float3 albedo = payload.albedo; + float sss = payloadSss(); // LabPBR SSS strength (0 when absent) + float3 p = hitPos + n * SURF_BIAS; + float3 v = -rd; // view direction (toward the camera / incoming ray) + + // Material split. Metals tint F0 by albedo and have no diffuse lobe; dielectrics use a fixed + // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). + float rough = clamp(payloadRoughness(), 0.0, 1.0); + float metal = clamp(payloadMetalness(), 0.0, 1.0); + // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. + // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the + // reflection sharp instead of accumulating it as a glossy lobe. + bool exactSpecular = rough <= MIRROR_ALPHA_MAX; + rough = exactSpecular ? 0.0 : rough; + float3 diffAlb = albedo * (1.0 - metal); + float3 F0 = payload.f0; + + // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. + // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples + // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial + // false), which the previous vertex's RIS already accounted for; the primary ray and + // specular/dielectric bounces (showCelestial true) still add it, since no emitter NEE happened + // along those. Emitters NOT in the light buffer (sparse/sub-threshold footprints, entities) are + // never NEE-sampled, so they always gather — bit-identical to the no-NEE path, no energy lost. + // Emission is already the full HDR strength (EMISSIVE_STRENGTH baseline * any override + // multiplier baked in Java at material-compile time — see MaterialHeader.features packing). + float emission = payloadEmission(); + // One gate drives both the RIS call below and the direct-hit emission suppression here. They must + // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where + // RIS is skipped must also gather emission directly or that light is lost outright. + bool risActive = risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); + bool gateEmitter = risActive && payloadEmitterInList(); + if (emission > 0.0 && (!gateEmitter || showCelestial)) { + L += throughput * albedo * emission; + } + + // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert + // diffuse + GGX specular, one shadow ray. Jitter the light direction within its square angular + // extent (worldPush.lightDir.w half-angle) so the shadow ray samples the light's quad — soft penumbrae + // over accumulation. The same sampled direction drives ndl, the shadow ray, and the specular + // half-vector. + float3 lightDir = worldPush.lightDir.xyz; + float lightHalfAngle = worldPush.lightDir.w; + if (lightHalfAngle > 0.0) { + lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + } + float ndl = max(0.0, dot(n, lightDir)); + if (ndl > 0.0) { + VisibilityResult shadow = visibility(p, lightDir, 10000.0); + float3 vis = shadow.transmittance; + // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by + // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, + // so it applies to the whole NEE term (diffuse + specular). + if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { + vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); + } + if (max(vis.r, max(vis.g, vis.b)) > 0.0) { + float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) + float3 h = normalize(lightDir + v); + float ndh = max(0.0, dot(n, h)); + float ndv = max(1.0e-4, dot(n, v)); + float vdh = max(0.0, dot(v, h)); + float D = ggxD(ndh, rough); + float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); // separable Smith masking + float3 F = fresnelSchlick(vdh, F0); + brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular + L += throughput * brdf * worldPush.lightRadiance.xyz * ndl * vis; + } + } + + // RIS direct lighting from block emitters (single-frame only, no temporal reservoir reuse). + // Front lobe + SSS backlighting are resampled together and share ONE shadow ray — a light can + // only be in front of n or behind it, never both, so combining them into one target function is + // exact, not an approximation. The SSS term is gated off past MAX_SSS_DIFFUSE_DEPTH by passing + // sss=0 there (falls back to plain front-only RIS). Keyed on diffuseDepth, not `bounce`, for the + // same reason as RIS_MAX_DIFFUSE_DEPTH above: leaves seen through a window are still the first + // surface this path has shaded, and should still backlight. + if (risActive) { + float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; + Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, + uint(diffuseDepth), seed, proposalSeed); + float3 risVis; + L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, + activeSss, risVis); + } + + // Thin-surface SSS transmission. Light entering from the back face scatters through toward the + // viewer via a forward-biased HG phase (leaves/grass backlit by the sun glow when you look + // through them toward the light source). Shadow ray fires from the back face to avoid + // self-occlusion. cosT = dot(lightDir, rd): rd tracks the ray toward the sun when the sun is + // behind the slab, giving cosT ≈ 1 → forward-scatter peak. + if (sss > 0.0 && diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH) { + float backNdl = max(0.0, dot(-n, lightDir)); + if (backNdl > 0.0) { + VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); + float3 visB = shadowBack.transmittance; + // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should + // flicker with the same light bands as the floor around it. + if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { + visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, + lightDir, shadowBack.waterHitT); + } + if (max(visB.r, max(visB.g, visB.b)) > 0.0) { + float cosT = dot(lightDir, rd); + L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * worldPush.lightRadiance.xyz * visB; + } + } + } + + // Every shading term for this surface is now accounted for, so it counts toward the shaded depth. + // The dielectric branches above return before reaching here, which is exactly the distinction + // between this and `bounce`. + diffuseDepth++; + + // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their + // relative reflectance. + float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 + ? 1.0 + : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); + if (rndf(seed) < ps) { + float3 l; + if (exactSpecular) { + // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the + // degenerate VNDF at alpha=0 and preserves a genuinely sharp reflection without a floor. + l = reflect(rd, n); + throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; + } else { + float3 h = sampleGGXVNDF(n, v, rough, seed); + l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal + float ndl2 = dot(n, l); + if (ndl2 <= 0.0) { + break; // microfacet reflects below the surface: terminate this path + } + // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. + float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); + throughput *= F * ggxG1(ndl2, rough) / ps; + rayConeSpread = max(rayConeSpread, rough * RAY_CONE_GLOSSY_SPREAD_SCALE); + } + ro = p; + rd = l; + showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc + } else { + throughput *= diffAlb / (1.0 - ps); + ro = p; + rd = cosineDir(n, seed); + rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); + showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) + } + + // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + break; + } + throughput /= q; + } + } + return L; +} + +// Resolve the captured gv_* guide state into the DLSS-RR guide images. Called once, right after the +// sample that captured them, so the guide state dies there instead of staying live across every +// remaining sample's traces. +void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { + // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary + // tuple with the transmitted destination. + float2 specMotion = specularReflectionMotion(gv_spec, primaryDir, jndc, size, primaryConeSpread); + + // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative + // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer + // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. + // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. + float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); + float depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; + + // Motion vector: reproject this guide point through the previous frame's view-projection. + // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is + // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is + // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. + // For a dynamic surface, previous-frame position is the current point minus its world-space + // displacement; subtracting gv_motionObjDisp de-cameras the MV. + float4 prevClip = mul(worldPush.prevViewProj, + float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); + float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc + // Transmitted content is its own feature: compare its previous and current projections rather than + // subtracting the primary interface's jittered NDC. + float4 curClipMotion = gv_motionUseRefracted + ? mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)) + : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform + // Guard both perspective divides the way the depth above does. A guide point can land behind either + // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the + // frustum as the camera turns — and w at or below zero turns the divide into an arbitrarily large + // vector that DLSS-RR reprojects with. No valid previous position exists in that case, so report no + // motion and let RR fall back instead of handing it garbage. + float2 motion; + if (prevClip.w > 0.0 && (!gv_motionUseRefracted || curClipMotion.w > 0.0)) { + if (gv_motionUseRefracted) curNdc = curClipMotion.xy / curClipMotion.w; + motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; + } else { + motion = float2(0.0, 0.0); + } + + // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. + gNormal[pix] = float4(gv_normal, gv_rough); + gAlbedo[pix] = float4(gv_albedo, 1.0); + gDepth[pix] = depth; + gMotion[pix] = motion; + gSpecAlbedo[pix] = float4(gv_spec.albedo, 1.0); + gSpecMotion[pix] = specMotion; +} + +// 48-byte queue record. float3 + uint intentionally share the first 16-byte lane; every remaining +// field is a uint, so Std430DataLayout gives this an exact 48-byte stride. +struct PackedPathSegment { + float3 ro; + uint rd; + uint throughput; + uint currentExtinction; + uint outerExtinction; + uint mediumIors; + uint rayCone; + uint seed; + uint pathFlags; + uint pixelSample; +}; + +static const uint PATH_VALID = 1u << 11u; + +float2 octEncode(float3 direction) { + float3 n = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 1.0e-20); + float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + float2 oct = n.z >= 0.0 ? n.xy : (1.0 - abs(n.yx)) * signNotZero; + return oct * 0.5 + 0.5; +} + +float3 octDecode(float2 encoded) { + float2 oct = encoded * 2.0 - 1.0; + float3 n = float3(oct, 1.0 - abs(oct.x) - abs(oct.y)); + if (n.z < 0.0) { + float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + n.xy = (1.0 - abs(n.yx)) * signNotZero; + } + return normalize(n); +} + +uint packUnorm16x2(float2 v) { + uint2 q = uint2(round(clamp(v, 0.0, 1.0) * 65535.0)); + return q.x | (q.y << 16u); +} + +float2 unpackUnorm16x2(uint p) { + return float2(p & 0xffffu, p >> 16u) / 65535.0; +} + +// DXGI_FORMAT_R9G9B9E5_SHAREDEXP, implemented locally because Slang exposes no packing intrinsic. +uint packRgb9e5(float3 value) { + float3 v = clamp(value, 0.0, 65408.0); + float maxChannel = max(v.x, max(v.y, v.z)); + uint exponent = maxChannel < exp2(-16.0) + ? 0u : uint(floor(log2(maxChannel))) + 16u; + exponent = min(exponent, 31u); + float scale = exp2(float(int(exponent) - 24)); + uint maxMantissa = uint(floor(maxChannel / scale + 0.5)); + if (maxMantissa == 512u && exponent < 31u) { + exponent++; + scale *= 2.0; + } + uint3 mantissa = min(uint3(floor(v / scale + 0.5)), uint3(511u, 511u, 511u)); + return mantissa.x | (mantissa.y << 9u) | (mantissa.z << 18u) | (exponent << 27u); +} + +float3 unpackRgb9e5(uint p) { + float scale = exp2(float(int(p >> 27u) - 24)); + return float3(p & 0x1ffu, (p >> 9u) & 0x1ffu, (p >> 18u) & 0x1ffu) * scale; +} + +PackedPathSegment packPathSegment(PathSegment seg, uint pixelIndex, uint sampleIndex, bool valid) { + PackedPathSegment p; + p.ro = seg.ro; + p.rd = packUnorm16x2(octEncode(seg.rd)); + p.throughput = packRgb9e5(seg.throughput); + p.currentExtinction = packRgb9e5(seg.medium.current.extinction); + p.outerExtinction = packRgb9e5(seg.medium.outer.extinction); + p.mediumIors = packHalf2(float2(seg.medium.current.ior, seg.medium.outer.ior)); + p.rayCone = packHalf2(float2(seg.rayConeWidth, seg.rayConeSpread)); + p.seed = seg.seed; + p.pathFlags = (uint(seg.bounce) & 15u) + | ((uint(seg.diffuseDepth) & 15u) << 4u) + | (seg.showCelestial ? 1u << 8u : 0u) + | (seg.medium.current.water ? 1u << 9u : 0u) + | (seg.medium.outer.water ? 1u << 10u : 0u) + | (valid ? PATH_VALID : 0u); + p.pixelSample = (pixelIndex & 0x1fffffffu) | ((sampleIndex & 7u) << 29u); + return p; +} + +PathSegment unpackPathSegment(PackedPathSegment p, out bool valid) { + float2 iors = unpackHalf2(p.mediumIors); + Medium current; + current.ior = iors.x; + current.extinction = unpackRgb9e5(p.currentExtinction); + current.water = (p.pathFlags & (1u << 9u)) != 0u; + Medium outer; + outer.ior = iors.y; + outer.extinction = unpackRgb9e5(p.outerExtinction); + outer.water = (p.pathFlags & (1u << 10u)) != 0u; + MediumStack medium; + medium.current = current; + medium.outer = outer; + float2 cone = unpackHalf2(p.rayCone); + valid = (p.pathFlags & PATH_VALID) != 0u; + return makePathSegment(p.ro, octDecode(unpackUnorm16x2(p.rd)), + unpackRgb9e5(p.throughput), medium, cone.x, cone.y, p.seed, + int(p.pathFlags & 15u), int((p.pathFlags >> 4u) & 15u), + (p.pathFlags & (1u << 8u)) != 0u, false, false); +} + +// Walk only the visually-primary dielectric chain. The terminal non-dielectric/miss trace is repeated +// by the indirect pass, so the returned record describes the state immediately BEFORE that trace. +// This keeps closest-hit payload data out of the queue and, critically, writes the record before any +// later trace rather than keeping a continuation live across traversal. +PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool alive) { + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + MediumStack medium = seg.medium; + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); + uint seed = seg.seed; + bool showCelestial = seg.showCelestial; + int diffuseDepth = seg.diffuseDepth; + int maxBounces = int(worldPush.maxBounces); + int rrStart = maxBounces <= 3 ? 1 : 2; + bool waterWaves = (worldPush.flags & 16u) != 0u; + alive = false; + + for (int bounce = seg.bounce; bounce <= maxBounces; ++bounce) { + PathSegment terminal = makePathSegment(ro, rd, throughput, medium, + rayConeWidth, rayConeSpread, seed, bounce, diffuseDepth, + showCelestial, false, false); + traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, + ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); + + if (payload.hitT < 0.0) { + if (bounce == 0 && captureGuides) { + gv_normal = float3(0.0, 0.0, 0.0); + gv_albedo = SKY_DIFF_ALBEDO; + gv_rough = 1.0; + gv_hitCamRel = rd * 1.0e6; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = float3(0.0, 0.0, 0.0); + gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); + } + alive = true; + return terminal; + } + + float3 n = payload.normal; + float3 hitPos = ro + rd * payload.hitT; + uint material = payloadMaterial(); + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) { + if (bounce == 0 && captureGuides) { + gv_normal = n; + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = payload.motionPrev; + if (material == MATERIAL_PARTICLE) { + gv_albedo = payload.albedo; + gv_rough = 1.0; + gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, + float3(0.0, 0.0, 0.0)); + } else { + float rough = clamp(payloadRoughness(), 0.0, 1.0); + rough = rough <= MIRROR_ALPHA_MAX ? 0.0 : rough; + float metal = clamp(payloadMetalness(), 0.0, 1.0); + float3 diffAlb = payload.albedo * (1.0 - metal); + float3 v = -rd; + gv_albedo = diffAlb; + gv_rough = rough; + gv_spec = makeSpecSurface(gv_hitCamRel, n, rough, + rrSpecularAlbedo(payload.f0, rough, dot(n, v))); + } + } + alive = true; + return terminal; + } + + // This hit is consumed in pass A, so carry its travelled-medium absorption and cone growth into + // the record for the next trace. The terminal hit above is deliberately not consumed here. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); + } + rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), + RAY_CONE_MIN_WIDTH); + + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 geometricNormal = n; + float3 previousNormal = n; + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + if (bounce == 0 && captureGuides && abs(geometricNormal.y) >= 0.5) { + float2 currentGrad; + float2 previousGrad; + waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, + worldPush.waterAnchor.z, waterFootprint, currentGrad, previousGrad); + float orientation = geometricNormal.y >= 0.0 ? 1.0 : -1.0; + n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); + previousNormal = orientation + * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); + } else { + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } + } + + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), + payload.albedo, transmission); + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.outer.ior; + float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + + if (bounce == 0 && captureGuides) { + gv_normal = n; + gv_rough = 0.0; + gv_albedo = float3(0.0, 0.0, 0.0); + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; + gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, + gv_rough, float3(F, F, F)); + if (dot(transmittedDir, transmittedDir) > 0.0) { + resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, + entering ? entered.ior : medium.outer.ior, + transmitBias, rayConeWidth, rayConeSpread); + } + } + + if (rndf(seed) < F) { + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); + } else { + if (dot(transmittedDir, transmittedDir) <= 0.0) { + return terminal; + } + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + } + showCelestial = true; + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + return terminal; + } + throughput /= q; + } + } + return seg; +} + +void writeDebugView(int2 pix) { + float4 normalRough = gNormal[pix]; + float3 dbg; + if (pc.debugView == 1u) { + dbg = normalRough.xyz * 0.5 + 0.5; + } else if (pc.debugView == 2u) { + dbg = gAlbedo[pix].rgb; + } else if (pc.debugView == 3u) { + dbg = float3(gDepth[pix], gDepth[pix], gDepth[pix]); + } else if (pc.debugView == 4u) { + dbg = float3(normalRough.w, normalRough.w, normalRough.w); + } else if (pc.debugView == 6u) { + dbg = gSpecAlbedo[pix].rgb; + } else if (pc.debugView == 7u) { + dbg = float3(clamp(0.5 + gSpecMotion[pix] * 0.05, 0.0, 1.0), 0.5); + } else { + dbg = float3(clamp(0.5 + gMotion[pix] * 0.05, 0.0, 1.0), 0.5); + } + outImage[pix] = float4(dbg, 1.0); +} + +#if CAUSTICA_PRIMARY_PASS +[shader("raygeneration")] +void main() { + worldPush = ConstPtr(pc.worldPushAddr)[0]; + uint2 dispatchIndex = DispatchRaysIndex().xy; + uint2 dimensions = DispatchRaysDimensions().xy; + int2 pix = int2(dispatchIndex); + float2 size = float2(dimensions); + float2 uv = (float2(pix) + 0.5) / size; + float2 jndc = (uv + worldPush.jitter / size) * 2.0 - 1.0; + float4 nearH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 1.0, 1.0)); + float4 farH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 0.0, 1.0)); + float3 nearP = nearH.xyz / nearH.w; + float3 farP = farH.xyz / farH.w; + float3 origin = nearP + worldPush.camOffset; + float3 dir = normalize(farP - nearP); + float rayConeSpread = primaryRayConeSpread(jndc, size, dir); + uint seed = (dispatchIndex.x * 1973u + dispatchIndex.y * 9277u + 26699u) + ^ (worldPush.frameIndex * 2654435761u); + MediumStack cameraMedium = makeMediumStack((worldPush.flags & 1u) != 0u + ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) + : airMedium()); + uint spp = max(worldPush.spp, 1u); + uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; + DevicePtr queue = DevicePtr(pc.pathQueueAddr); + + for (uint s = 0u; s < spp; ++s) { + seed = pcg(seed); + PathSegment camera = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), + cameraMedium, 0.0, rayConeSpread, seed, 0, 0, true, false, false); + bool alive; + PathSegment terminal = tracePrimary(camera, s == 0u, alive); + // Store immediately: no continuation remains live across writeGuides or another primary trace. + queue[pixelIndex * spp + s] = packPathSegment(terminal, pixelIndex, s, alive); + if (s == 0u) { + writeGuides(pix, dir, jndc, size, rayConeSpread); + } + } + + if (pc.debugView != 0u) { + writeDebugView(pix); + } +} +#endif + +#if CAUSTICA_INDIRECT_PASS +[shader("raygeneration")] +void main() { + worldPush = ConstPtr(pc.worldPushAddr)[0]; + if (pc.debugView != 0u) { + return; // pass A owns guide visualization output + } + uint2 dispatchIndex = DispatchRaysIndex().xy; + uint2 dimensions = DispatchRaysDimensions().xy; + int2 pix = int2(dispatchIndex); + uint spp = max(worldPush.spp, 1u); + uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; + ConstPtr queue = ConstPtr(pc.pathQueueAddr); + float3 frameRadiance = float3(0.0, 0.0, 0.0); + for (uint s = 0u; s < spp; ++s) { + bool valid; + PathSegment segment = unpackPathSegment(queue[pixelIndex * spp + s], valid); + if (valid) { + frameRadiance += tracePath(segment, uint2(pix), s); + } + } + outImage[pix] = float4(frameRadiance / float(spp), 1.0); +} +#endif diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang new file mode 100644 index 00000000..ea458fbe --- /dev/null +++ b/shaders/world/world_primary.rgen.slang @@ -0,0 +1,3 @@ +#define CAUSTICA_PRIMARY_PASS 1 +#define CAUSTICA_INDIRECT_PASS 0 +#include "world_path.slanginc" diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 417d745a..2a86b393 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -95,6 +95,7 @@ public static boolean enabled() { // Hot addresses/frameIndex and raygen's debugView avoid unnecessary global-memory dereferences; // WorldPushConstantsData is generated from the same Slang module and owns this second ABI as well. private static final int GUIDE_COUNT = 6; // RR guide buffers bound at world-pipeline bindings 3..8 + private static final long PATH_RECORD_BYTES = 48L; private static int debugView() { return CausticaConfig.Rt.Composite.DEBUG_VIEW.value(); } @@ -179,6 +180,10 @@ public static long frameCounter() { private int pushSlot; private RtDisplayPipeline displayPipeline; private RtImage output; + // Packed primary -> indirect continuations. M1 stores one record per render pixel per configured + // sample; the indirect dispatch keeps one invocation per pixel and consumes that pixel's records. + private RtBuffer continuationQueue; + private int continuationQueueSpp = -1; private RtImage displayImage; // Parallel PQ-encoded ([0,1], ST.2084) HDR display image. Written alongside displayImage when HDR is // enabled. When the PQ swapchain is active, the combined UI overlay is composited over this image, then @@ -521,7 +526,9 @@ public void ensureResourcesReady(RtContext ctx) { private RtPipeline ensureWorld(RtContext ctx) { if (worldPipeline == null) { bindlessTextureCapacity = RtEntityTextures.maxTextures(); - worldPipeline = RtPipeline.create(ctx, new String[]{RtDeviceBringup.worldRaygenShader()}, + worldPipeline = RtPipeline.create(ctx, new String[]{ + RtDeviceBringup.worldPrimaryRaygenShader(), + RtDeviceBringup.worldRaygenShader()}, new String[]{"world.rmiss.spv", "world_guide.rmiss.spv"}, "world.rchit.spv", "world.rahit.spv", WorldPushConstantsData.BYTE_SIZE, true, GUIDE_COUNT, bindlessTextureCapacity, true); @@ -690,8 +697,11 @@ private void destroyGuideImages() { private void ensureOutput(RtContext ctx, int width, int height) { boolean rrEnabled = RtDlssRr.enabled(); int rrQuality = rrEnabled ? RtDlssRr.quality() : Integer.MIN_VALUE; - if (output != null && displayImage != null && hdrDisplayImage != null && rrOutput != null && exposure.ready() + int desiredSpp = Math.max(spp(), 1); + if (output != null && continuationQueue != null + && displayImage != null && hdrDisplayImage != null && rrOutput != null && exposure.ready() && displayW == width && displayH == height + && continuationQueueSpp == desiredSpp && renderSizeRrEnabled == rrEnabled && renderSizeRrQuality == rrQuality) { return; } @@ -705,6 +715,10 @@ private void ensureOutput(RtContext ctx, int width, int height) { if (output != null) { output.destroy(); } + if (continuationQueue != null) { + continuationQueue.destroy(); + continuationQueue = null; + } destroyGuideImages(); displayW = width; @@ -724,6 +738,13 @@ private void ensureOutput(RtContext ctx, int width, int height) { // mapping seam. displayImage stays R8G8B8A8 to match the main target it is copied into // (vkCmdCopyImage requires texel-size-compatible formats). output = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH); + continuationQueueSpp = desiredSpp; + long continuationBytes = Math.multiplyExact( + Math.multiplyExact((long) renderW, (long) renderH), + Math.multiplyExact((long) continuationQueueSpp, PATH_RECORD_BYTES)); + continuationQueue = ctx.createBuffer(continuationBytes, + VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false, + "path continuation queue " + renderW + "x" + renderH + "x" + continuationQueueSpp); displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM, "RT display image " + width + "x" + height); // PQ-encoded ([0,1], ST.2084) HDR display image, written in parallel by display.comp when HDR mode is active. hdrDisplayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height); @@ -936,11 +957,16 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo RtMaterialRegistry.INSTANCE.tableAddress(), terrain.lightBufferAddress(), terrain.lightAliasBufferAddress(), terrain.lightLocalAliasBufferAddress(), terrain.lightGridCellBufferAddress(), - terrain.lightGridSpanBufferAddress(), + terrain.lightGridSpanBufferAddress(), continuationQueue.deviceAddress, (int) frameCounter, debugView).write(pushConstants); - try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world trace"); - RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.trace")) { - active.trace(cmd, renderW, renderH, pushConstants); + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world primary trace"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.tracePrimary")) { + active.trace(cmd, renderW, renderH, pushConstants, 0); + } + VulkanCommandEncoder.memoryBarrier(cmd, stack); // continuation/guide writes visible to pass B + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world indirect trace"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.traceIndirect")) { + active.trace(cmd, renderW, renderH, pushConstants, 1); } VulkanCommandEncoder.memoryBarrier(cmd, stack); // RT writes visible to DLSS reads // DLSS-RR denoise + upscale. The RT pass wrote noisy color (render res) + guides; @@ -1223,6 +1249,11 @@ public void destroy() { output.destroy(); output = null; } + if (continuationQueue != null) { + continuationQueue.destroy(); + continuationQueue = null; + continuationQueueSpp = -1; + } destroyGuideImages(); exposure.destroy(); if (displayPipeline != null) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java index 515b0986..0c22db42 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java @@ -216,17 +216,22 @@ public static boolean enabledByProperty() { RAY_QUERY_FEATURE); private enum SerBackend { - NONE("none", null, "world.rgen.spv"), - NV("NV", VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, "world_nv.rgen.spv"), - EXT("EXT", VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, "world.rgen.spv"); + NONE("none", null, "world_primary.rgen.spv", "world.rgen.spv"), + NV("NV", VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, + "world_primary_nv.rgen.spv", "world_nv.rgen.spv"), + EXT("EXT", VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, + "world_primary.rgen.spv", "world.rgen.spv"); final String label; final String extensionName; + final String worldPrimaryRaygenShader; final String worldRaygenShader; - SerBackend(String label, String extensionName, String worldRaygenShader) { + SerBackend(String label, String extensionName, String worldPrimaryRaygenShader, + String worldRaygenShader) { this.label = label; this.extensionName = extensionName; + this.worldPrimaryRaygenShader = worldPrimaryRaygenShader; this.worldRaygenShader = worldRaygenShader; } } @@ -250,6 +255,10 @@ public static String worldRaygenShader() { return serBackend.worldRaygenShader; } + public static String worldPrimaryRaygenShader() { + return serBackend.worldPrimaryRaygenShader; + } + public static boolean serNvEnabled() { return serBackend == SerBackend.NV; } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java index 0a2a85af..6f7f02e5 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java @@ -34,15 +34,16 @@ void reflectedMaterialHeaderMatchesHotAbi() { @Test void reflectedWorldPushConstantsIncludeLightBuffersAndDebugView() { - // 9 uint64_t addresses (worldPush/table/entityTable/materialTable + the 5 light buffers) + 2 uint. - assertEquals(80, WorldPushConstantsData.BYTE_SIZE); + // 10 uint64_t addresses (world/table/material, 5 light buffers, path queue) + 2 uint. + assertEquals(88, WorldPushConstantsData.BYTE_SIZE); ByteBuffer data = ByteBuffer.allocateDirect(WorldPushConstantsData.BYTE_SIZE) .order(ByteOrder.nativeOrder()); - new WorldPushConstantsData(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10, 11).write(data); + new WorldPushConstantsData(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11, 12).write(data); assertEquals(4L, data.getLong(24)); // materialTableAddr assertEquals(5L, data.getLong(32)); // lightBufAddr assertEquals(9L, data.getLong(64)); // lightGridSpanAddr (last of the light-buffer addresses) - assertEquals(10, data.getInt(72)); // frameIndex - assertEquals(11, data.getInt(76)); // debugView + assertEquals(10L, data.getLong(72)); // pathQueueAddr + assertEquals(11, data.getInt(80)); // frameIndex + assertEquals(12, data.getInt(84)); // debugView } } From 62e8aae090a3eb6986feda31acf6b697bbb2ad74 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:51:59 +0900 Subject: [PATCH 14/25] Split world_path.slanginc into modules per pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 landed the two dispatches but left the implementation as one 1986-line include that both raygens pulled in whole, with the pass difference expressed as two trailing #if blocks. Separation was left to dead-code elimination: nothing stopped guide code compiling into the indirect pass or RIS into the primary one, and nothing said which half a given function belonged to. Ten modules, each with a header stating what it owns and what it depends on: rt_core bindings, worldPush/payload, shared constants rt_math GGX, Fresnel, HG, PCG, direction sampling (stateless) rt_medium extinction mappings + the depth-2 medium stack rt_segment PathSegment and its packed 48-byte buffer form rt_water wave spectrum, normal perturbation, caustics rt_trace SBT/cull constants, payload builders, the three ray casts rt_lighting light grid, RIS reservoirs, reservoir shading INDIRECT ONLY rt_guides gv_* state, spec surface + reflection MV, transmitted-destination walk, guide image writes, debug views PRIMARY ONLY rt_primary tracePrimary PRIMARY ONLY rt_indirect tracePath INDIRECT ONLY Each raygen now includes only what it needs, so the split is enforced by the include graph instead of by DCE. The passes turned out to be genuinely disjoint: tracePrimary references no RIS, no visibility() and no BSDF sampling, and tracePath references guide state zero times (its one 'gv_' hit was a comment). Not just cosmetic — excluding the unused modules shrank both passes: primary 368064 -> 295200 bytes (-19.8%) indirect 551528 -> 513744 bytes (-6.8%) so DCE had not in fact been stripping it all. Pure code motion: the 1891-line body was sliced verbatim (line counts reconcile exactly) and both main() bodies are unchanged. Behaviour should be identical; the size drop is the only intended difference. 39 tests pass, spirv-val clean. NOT GPU-verified. Co-Authored-By: Claude Opus 5 --- shaders/world/rt_core.slanginc | 92 ++ shaders/world/rt_guides.slanginc | 266 ++++ shaders/world/rt_indirect.slanginc | 345 ++++ shaders/world/rt_lighting.slanginc | 355 +++++ shaders/world/rt_math.slanginc | 190 +++ shaders/world/rt_medium.slanginc | 82 + shaders/world/rt_primary.slanginc | 155 ++ shaders/world/rt_segment.slanginc | 154 ++ shaders/world/rt_trace.slanginc | 119 ++ shaders/world/rt_water.slanginc | 165 ++ shaders/world/world.rgen.slang | 56 +- shaders/world/world_path.slanginc | 1986 ------------------------ shaders/world/world_primary.rgen.slang | 71 +- 13 files changed, 2044 insertions(+), 1992 deletions(-) create mode 100644 shaders/world/rt_core.slanginc create mode 100644 shaders/world/rt_guides.slanginc create mode 100644 shaders/world/rt_indirect.slanginc create mode 100644 shaders/world/rt_lighting.slanginc create mode 100644 shaders/world/rt_math.slanginc create mode 100644 shaders/world/rt_medium.slanginc create mode 100644 shaders/world/rt_primary.slanginc create mode 100644 shaders/world/rt_segment.slanginc create mode 100644 shaders/world/rt_trace.slanginc create mode 100644 shaders/world/rt_water.slanginc delete mode 100644 shaders/world/world_path.slanginc diff --git a/shaders/world/rt_core.slanginc b/shaders/world/rt_core.slanginc new file mode 100644 index 00000000..dcf8d66d --- /dev/null +++ b/shaders/world/rt_core.slanginc @@ -0,0 +1,92 @@ +// Bindings, per-frame push state, the ray payload, and the constants every pass shares. +// Include first: everything below depends on pc, worldPush and payload. + + +[[vk::push_constant]] WorldPushConstants pc; + +[[vk::binding(0, 0)]] RaytracingAccelerationStructure topLevelAS; +// HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. +[[vk::binding(1, 0)]] [format("rgba16f")] RWTexture2D outImage; +// Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written +// every frame regardless of accumulation. gNormal.w carries LINEAR roughness (= GGX alpha, which is what +// DLSS-RR documents for this input); gDepth carries HW reversed-Z depth. +[[vk::binding(3, 0)]] [format("rgba16f")] RWTexture2D gNormal; // xyz world normal, w linear roughness +[[vk::binding(4, 0)]] [format("rgba16f")] RWTexture2D gAlbedo; // rgb diffuse albedo +[[vk::binding(5, 0)]] [format("r32f")] RWTexture2D gDepth; // HW reversed-Z depth +[[vk::binding(6, 0)]] [format("rg16f")] RWTexture2D gMotion; // screen-space motion (render px) +[[vk::binding(7, 0)]] [format("rgba16f")] RWTexture2D gSpecAlbedo; // specular albedo (0 — diffuse-only) +[[vk::binding(8, 0)]] [format("rg16f")] RWTexture2D gSpecMotion; // reflection MVs for RR + +// Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL +// `worldPush` global). Layout constants generated from this module's SPIR-V — see world_common.WorldPush. +static WorldPush worldPush; + +// The radiance payload remains module-level so tracePath and its guide helpers can share it across +// HitObject trace/invoke calls, mirroring the GLSL rayPayloadEXT global. +static Payload payload; + +struct VisibilityResult { + float3 transmittance; + float waterHitT; +}; + + + +uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } +// Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was +// travelling into the volume (vs. out of it) at this hit's face. +bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } +bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } +// LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used +// end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly +// that, RtMaterials.Profile carries the same units, and DLSS-RR wants linear roughness in its guide. So +// nothing squares it: ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo all take this value as alpha as-is, and +// any threshold compared against it is an alpha threshold. +float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } +float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } +float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } +float payloadSss() { return unpackHalf2(payload.emissionSss).y; } +float payloadIor() { return unpackHalf2(payload.iorTransmission).x; } +float payloadTransmission() { return unpackHalf2(payload.iorTransmission).y; } + +static const float PI = 3.14159265359; +static const float INV_PI = 0.31830988618; + +// The analytic sky (gradient + sun/moon discs + stars) is computed in world.rmiss. On a miss, raygen +// reads payload.albedo and accumulates that sky radiance. The directional NEE light is pushed separately +// as worldPush.lightDir / worldPush.lightRadiance / worldPush.sunDir. +static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) +static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface + +// Water is a smooth dielectric. IOR 1.333; absorption is per-block Beer-Lambert extinction. +static const float WATER_IOR = 1.333; +static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. +static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); + +// Transmitted-ray origin offset when crossing a face from the TRANSLUCENT terrain layer (glass, ice). +// Must be SMALLER than the mesher-side TRANSLUCENT_INSET (RtTerrainMesher, 2e-4 blocks): those quads are +// recessed into their block by that inset, so a face touching a slab/stair leaves a tiny gap before the +// neighbour's surface. A large bias (SURF_BIAS) would restart the ray past that neighbour and see +// through it; this lands the ray inside the gap so the neighbour is still hit, while still clearing the +// surface it left to avoid self-intersection. Water is meshed by RtFluidMesher with no inset, so it uses +// the ordinary bias. +static const float INSET_TRANSMIT_BIAS = 1.0e-4; + +// Ray-cone texture LOD: raygen tracks a one-pixel footprint along the path, and closest-hit converts it +// to texture mip levels from the local UV/world-space scale. Diffuse/glossy bounces widen the cone so +// indirect texture fetches don't keep sampling mip 0 through broad lobes. +static const float RAY_CONE_MIN_SPREAD = 1.0e-5; +static const float RAY_CONE_MIN_WIDTH = 1.0e-5; +static const float RAY_CONE_DIFFUSE_SPREAD = 0.25; +static const float RAY_CONE_GLOSSY_SPREAD_SCALE = 0.35; + +// Stored roughness IS alpha (see payloadRoughness), so these compare against it directly. +static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= 0.98 +// Above this the specular lobe is too broad for a single mirrored reprojection to describe, so the +// reflection MV is left at zero and RR falls back to the ordinary MV. +static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 +static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; +// Continuations one sample may carry: the camera segment, plus the reflection branch a primary +// dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. +static const uint MAX_PATH_SEGMENTS = 2u; + diff --git a/shaders/world/rt_guides.slanginc b/shaders/world/rt_guides.slanginc new file mode 100644 index 00000000..97220eae --- /dev/null +++ b/shaders/world/rt_guides.slanginc @@ -0,0 +1,266 @@ +// DLSS-RR guide state and everything that resolves it: the gv_* captures, the foreground specular +// surface and its reflection motion vector, the transmitted-destination walk, and the image writes. +// PRIMARY PASS ONLY. Depends on rt_trace, rt_water and rt_segment. + +// First-hit (primary-visibility) guide attributes, captured at bounce 0 of tracePath. The primary ray +// is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. +static float3 gv_normal; +static float3 gv_albedo; +static float gv_rough; +static float3 gv_hitCamRel; // primary-surface hit position relative to the current camera +static float3 gv_motionHitCamRel; // motion-guide hit position; water tracks the refracted hit instead +static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit (its own reprojection delta) +// World-space displacement of the motion-guide surface since the previous frame (0 for static +// terrain/sky, an entity's per-vertex/object motion for a dynamic hit). +static float3 gv_motionObjDisp; +// Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs +// (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is +// the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. +struct SpecSurface { + float3 camRel; // interface position relative to the current camera + float3 normal; // shading normal (wave-perturbed for water) + float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates + float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface + float roughness; + float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) +}; +static SpecSurface gv_spec; + +// Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading +// normal now and last frame, flat geometric normal for the bias) via the six-argument form. +SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, float3 biasNormal, + float roughness, float3 albedo) { + SpecSurface s; + s.camRel = camRel; + s.normal = normal; + s.previousNormal = previousNormal; + s.biasNormal = biasNormal; + s.roughness = roughness; + s.albedo = albedo; + return s; +} + +SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { + return makeSpecSurface(camRel, normal, normal, normal, roughness, albedo); +} +// angle can land behind the eye even though the reflector itself is comfortably in view. +float2 projectPrevNdc(float3 worldPos, out bool valid) { + float4 clip = mul(worldPush.prevViewProj, float4(worldPos - worldPush.camOffset + worldPush.camDelta, 1.0)); + valid = clip.w > 0.0; + return valid ? clip.xy / clip.w : float2(0.0, 0.0); +} + +// Previous-frame screen position of a planar reflection. The reflected image of a world point P seen +// in a planar reflector is the MIRROR image V = mirror(P) across the surface plane: the eye sees V +// along a straight line, so the reflection appears at proj(V). Project the mirror image directly. +// Gotcha: intersecting a reflection ray with the surface plane divides by dot(mirrorDir, n), which +// explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so +// static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); +// reflectedMotionPrev carries the reflected content's own displacement. +float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, + float3 reflectedMotionPrev, out bool valid) { + float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed + float3 prevHit = reflectedWorldPos - reflectedMotionPrev; + float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - surfacePos, n); + return projectPrevNdc(mirroredHit, valid); +} + +// Reflection motion remains owned by the physical foreground interface even when transmission later +// replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). +float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, + float2 currentNdc, float2 size, float primaryConeSpread) { + if (length(surface.normal) < 0.5 + || max(surface.albedo.r, max(surface.albedo.g, surface.albedo.b)) <= 0.001 + || surface.roughness > SPEC_MOTION_ALPHA_MAX) { + return float2(0.0, 0.0); + } + + float3 surfacePos = surface.camRel + worldPush.camOffset; + float3 n = normalize(surface.normal); + float3 specDir = reflect(primaryDir, n); + // biasNormal is a closest-hit normal, already unit length, and only its sign matters here. + traceGuide(CULL_SECONDARY, + offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), + RAY_TMIN, specDir, 10000.0, + max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), + max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); + + float3 reflectedHit; + float3 reflectedMotionPrev; + if (payload.hitT > 0.0) { + reflectedHit = surfacePos + specDir * payload.hitT; + reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE + ? payload.motionPrev : float3(0.0, 0.0, 0.0); + } else { + reflectedHit = surfacePos + specDir * 1.0e6; + reflectedMotionPrev = float3(0.0, 0.0, 0.0); + } + float3 previousN = length(surface.previousNormal) >= 0.5 + ? normalize(surface.previousNormal) : n; + bool prevValid; + float2 prevNdc = previousReflectionNdc( + surfacePos, previousN, reflectedHit, reflectedMotionPrev, prevValid); + return prevValid ? (prevNdc - currentNdc) * 0.5 * size : float2(0.0, 0.0); +} + +// Deterministic transmitted destination for clear-interface RR guides. The first interface has already +// been crossed. Continue through every crossed dielectric until opaque/particle content or sky is reached; +// TIR changes the walk into a reflected secondary ray instead of producing a false foreground guide. +void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, + float roughness, float3 diffuseAlbedo) { + gv_hitCamRel = hitCamRel; + gv_motionHitCamRel = hitCamRel; + gv_motionObjDisp = motionPrev; + gv_motionUseRefracted = true; + gv_normal = normal; + gv_rough = roughness; + gv_albedo = diffuseAlbedo; +} + +void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, + float3 surfaceBiasNormal, + float currentIor, float rayBias, + float rayConeWidth, float rayConeSpread) { + if (dot(transmittedDir, transmittedDir) <= 0.0) return; + + float3 direction = normalize(transmittedDir); + // surfaceBiasNormal is a closest-hit normal, already unit length, and only its sign matters here. + float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); + currentIor = max(currentIor, 1.0); + + for (uint crossing = 0u; crossing < MAX_TRANSMISSION_GUIDE_HITS; ++crossing) { + traceGuide(CULL_PRIMARY, ro, RAY_TMIN, direction, 10000.0, + rayConeWidth, rayConeSpread); + if (payload.hitT <= 0.0) { + setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, + float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, SKY_DIFF_ALBEDO); + return; + } + + uint material = payloadMaterial(); + float3 interfacePos = ro + direction * payload.hitT; + if (material == MATERIAL_OPAQUE || material == MATERIAL_PARTICLE) { + float endpointRoughness = material == MATERIAL_PARTICLE + ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); + float3 endpointAlbedo = material == MATERIAL_PARTICLE + ? payload.albedo + : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); + setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, + payload.normal, endpointRoughness, endpointAlbedo); + return; + } + + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; + + // Mirror the radiance path's refraction. Absorption inside the crossed media is deliberately NOT + // folded into the albedo guide: RR demodulates by albedo, and a distance-dependent attenuation + // baked into it would disagree with the colour, which is where the extinction actually lands. + float3 interfaceNormal = normalize(payload.normal); + float3 interfaceBiasNormal = interfaceNormal; + if (material == MATERIAL_WATER && (worldPush.flags & 16u) != 0u) { + float waterFootprint = rayConeWidth + / max(abs(dot(-direction, interfaceBiasNormal)), 0.2); + interfaceNormal = applyWaterWaves(interfaceNormal, + interfacePos.xz + worldPush.waterAnchor.xy, + worldPush.waterParams.w, waterFootprint); + } + // Exiting returns to air: the walk keeps only the current index, which is the same depth-1 + // approximation the guide already made and is exact for a single enclosing volume. + float targetIor = payloadDielectricEntering() ? max(payloadIor(), 1.0) : 1.0; + float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); + if (dot(nextDirection, nextDirection) <= 0.0) { + // Total internal reflection: there is no transmitted destination to describe, and following + // the reflection instead would be actively harmful. setTransmissionGuide marks its endpoint + // as its own feature, whose MV is that point's own reprojection delta — valid for a + // refracted point (the near-constant refraction offset cancels between frames) but WRONG for + // a mirror image, which sweeps at roughly twice the camera's rate and in the opposite sense. + // That produced motion vectors that grew with camera motion. + // + // Bailing leaves the foreground interface tuple the caller already wrote, which is what TIR + // physically deserves: F is 1, so specular albedo is 1 and diffuse albedo is 0, the pixel is + // pure reflection, and gSpecMotion already describes it with a proper mirror-image + // reprojection. Depth stays on the interface, which is the only real surface here. + return; + } + direction = normalize(nextDirection); + currentIor = targetIor; + ro = offsetSurfaceOrigin( + interfacePos, interfaceBiasNormal, direction, SURF_BIAS); + } +} + +// One Monte Carlo path from the continuation produced by the primary pass. + +// Resolve the captured gv_* guide state into the DLSS-RR guide images. Called once, right after the +// sample that captured them, so the guide state dies there instead of staying live across every +// remaining sample's traces. +void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { + // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary + // tuple with the transmitted destination. + float2 specMotion = specularReflectionMotion(gv_spec, primaryDir, jndc, size, primaryConeSpread); + + // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative + // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer + // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. + // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. + float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); + float depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; + + // Motion vector: reproject this guide point through the previous frame's view-projection. + // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is + // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is + // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. + // For a dynamic surface, previous-frame position is the current point minus its world-space + // displacement; subtracting gv_motionObjDisp de-cameras the MV. + float4 prevClip = mul(worldPush.prevViewProj, + float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); + float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc + // Transmitted content is its own feature: compare its previous and current projections rather than + // subtracting the primary interface's jittered NDC. + float4 curClipMotion = gv_motionUseRefracted + ? mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)) + : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform + // Guard both perspective divides the way the depth above does. A guide point can land behind either + // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the + // frustum as the camera turns — and w at or below zero turns the divide into an arbitrarily large + // vector that DLSS-RR reprojects with. No valid previous position exists in that case, so report no + // motion and let RR fall back instead of handing it garbage. + float2 motion; + if (prevClip.w > 0.0 && (!gv_motionUseRefracted || curClipMotion.w > 0.0)) { + if (gv_motionUseRefracted) curNdc = curClipMotion.xy / curClipMotion.w; + motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; + } else { + motion = float2(0.0, 0.0); + } + + // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. + gNormal[pix] = float4(gv_normal, gv_rough); + gAlbedo[pix] = float4(gv_albedo, 1.0); + gDepth[pix] = depth; + gMotion[pix] = motion; + gSpecAlbedo[pix] = float4(gv_spec.albedo, 1.0); + gSpecMotion[pix] = specMotion; +} + +// 48-byte queue record. float3 + uint intentionally share the first 16-byte lane; every remaining + +void writeDebugView(int2 pix) { + float4 normalRough = gNormal[pix]; + float3 dbg; + if (pc.debugView == 1u) { + dbg = normalRough.xyz * 0.5 + 0.5; + } else if (pc.debugView == 2u) { + dbg = gAlbedo[pix].rgb; + } else if (pc.debugView == 3u) { + dbg = float3(gDepth[pix], gDepth[pix], gDepth[pix]); + } else if (pc.debugView == 4u) { + dbg = float3(normalRough.w, normalRough.w, normalRough.w); + } else if (pc.debugView == 6u) { + dbg = gSpecAlbedo[pix].rgb; + } else if (pc.debugView == 7u) { + dbg = float3(clamp(0.5 + gSpecMotion[pix] * 0.05, 0.0, 1.0), 0.5); + } else { + dbg = float3(clamp(0.5 + gMotion[pix] * 0.05, 0.0, 1.0), 0.5); + } + outImage[pix] = float4(dbg, 1.0); +} diff --git a/shaders/world/rt_indirect.slanginc b/shaders/world/rt_indirect.slanginc new file mode 100644 index 00000000..a0ee52af --- /dev/null +++ b/shaders/world/rt_indirect.slanginc @@ -0,0 +1,345 @@ +// tracePath: the bounce loop — NEE, RIS, SSS, BSDF continuation and Russian roulette. +// INDIRECT PASS ONLY. Depends on rt_lighting. + +// Deterministic dielectric splitting is deliberately disabled for M1; M2 will append the second branch. +float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { + float3 L = float3(0.0, 0.0, 0.0); + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + uint seed = seg.seed; + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); + int maxBounces = int(worldPush.maxBounces); + int rrStart = maxBounces <= 3 ? 1 : 2; + // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the + // candidate count is non-zero. Off => emitters are gathered only on path hits (legacy behaviour). + bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && pc.lightBufAddr != 0; + // Independent light-proposal RNG. Screen-space coherent selection is intentionally deferred until + // there is a real presampled RIS tile buffer; direct sharing made tile-shaped lighting noise visible. + // Measured: making a 16x16 tile share this seed recovers at most 1.3ms, so a future pool should share + // the POOL rather than the seed — the win is in removing dependent loads, not in coherence. + uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u + ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; + proposalSeed = pcg(proposalSeed); + // The medium the segment starts in. For the camera segment that is water when the eye is submerged + // (so the first ray already carries the right relative index and absorption); for a segment split off + // a dielectric it is whatever the parent was travelling through. + MediumStack medium = seg.medium; + bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation + // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned + // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from + // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc + // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. + bool showCelestial = seg.showCelestial; + // Number of surfaces this path has SHADED so far, as distinct from `bounce`, which also counts + // dielectric interfaces. Glass and water continue without shading anything, so a wall behind a pane + // is bounce 1 but diffuseDepth 0 — visually primary, and it must be treated as such. See + // RIS_MAX_DIFFUSE_DEPTH. + int diffuseDepth = seg.diffuseDepth; + for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { + // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to + // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. + // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person + // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. + traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, + showCelestial, rayConeWidth, rayConeSpread); + + if (payload.hitT < 0.0) { + // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The + // packed show-celestial flag decides whether this path segment may see the bright disc. + float3 sky = payload.albedo; + L += throughput * sky; // escaped to sky + break; + } + + // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies + // to every hit reached while inside a volume dielectric (its own exit face, or whatever content + // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so + // the ordinary case costs one comparison. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); + } + + float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit + float3 hitPos = ro + rd * payload.hitT; + rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); + uint material = payloadMaterial(); + + // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every + // dielectric is a volume. Fresnel from the relative index across the face, then reflect or + // refract; no diffuse response and no sun NEE, because the surface is specular. Transmission + // pushes or pops a participating medium, and the segment travelled inside it is attenuated by + // that medium's extinction rather than by a tint multiply at the interface. + // + // Water is the only material that adds anything: the animated wave normal, and a medium marked + // for the caustic term. Which side of the face we are on comes from the geometry + // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest + // of the path. + if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 tint = payload.albedo; + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + + // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken + // reflections, wobbling refraction. The wave domain is world-stable: rebased hit XZ plus the + // pushed anchor (terrain origin mod 4096) survives the per-move terrain rebase. + float3 geometricNormal = n; // unrippled; ray origins offset along this + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } + + // The medium this face opens into, and the one it returns to on the way out — which is what + // the stack remembers. + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.outer.ior; + + float cosI = clamp(dot(-rd, n), 0.0, 1.0); + float F = fresnelDielectric(cosI, etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a + // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full + // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water + // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + + bool chooseReflection = rndf(seed) < F; + if (chooseReflection) { + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); + } else { + if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + // Crossing into or out of the volume. Absorption is the medium's job from here, so the + // tint is NOT also multiplied into the throughput — that would double-count it. + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + } + showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + break; + } + throughput /= q; + } + continue; + } + + // Particles (material == 2): camera-facing receiver billboard. The instance mask keeps particles + // off secondary rays, so this only fires at bounce 0; they receive direct light and one diffuse + // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. + if (material == MATERIAL_PARTICLE) { + float3 albedo = payload.albedo; + + float3 lightDir = worldPush.lightDir.xyz; + float lightHalfAngle = worldPush.lightDir.w; + if (lightHalfAngle > 0.0) { + lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + } + // Billboards are effectively two-sided receivers. Bias from the side facing the light; shadow + // rays exclude particles anyway, but this keeps the origin sane when the light is behind the + // camera side. + float signedNdl = dot(n, lightDir); + float ndl = abs(signedNdl); + if (ndl > 0.0) { + float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; + float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; + if (max(vis.r, max(vis.g, vis.b)) > 0.0) { + L += throughput * albedo * INV_PI * worldPush.lightRadiance.xyz * ndl * vis; + } + } + + // RIS direct lighting from block emitters (lava, glowstone, torches, ...) — same reservoir + // sampler as terrain/entities, but two-sided: a billboard has no back face, so light + // striking either side of the quad should still land (matches the sun/moon NEE above). + if (risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH)) { + float3 v = -rd; + Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, + true, 0.0, uint(diffuseDepth), seed, proposalSeed); + float3 risVis; + L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, + true, 0.0, risVis); + } + + if (bounce >= maxBounces) { + break; + } + throughput *= albedo; + ro = hitPos + n * SURF_BIAS; + rd = cosineDir(n, seed); + rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); + showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above + diffuseDepth++; // a billboard is a shaded surface, unlike the dielectric branches + continue; + } + + float3 albedo = payload.albedo; + float sss = payloadSss(); // LabPBR SSS strength (0 when absent) + float3 p = hitPos + n * SURF_BIAS; + float3 v = -rd; // view direction (toward the camera / incoming ray) + + // Material split. Metals tint F0 by albedo and have no diffuse lobe; dielectrics use a fixed + // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). + float rough = clamp(payloadRoughness(), 0.0, 1.0); + float metal = clamp(payloadMetalness(), 0.0, 1.0); + // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. + // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the + // reflection sharp instead of accumulating it as a glossy lobe. + bool exactSpecular = rough <= MIRROR_ALPHA_MAX; + rough = exactSpecular ? 0.0 : rough; + float3 diffAlb = albedo * (1.0 - metal); + float3 F0 = payload.f0; + + // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. + // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples + // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial + // false), which the previous vertex's RIS already accounted for; the primary ray and + // specular/dielectric bounces (showCelestial true) still add it, since no emitter NEE happened + // along those. Emitters NOT in the light buffer (sparse/sub-threshold footprints, entities) are + // never NEE-sampled, so they always gather — bit-identical to the no-NEE path, no energy lost. + // Emission is already the full HDR strength (EMISSIVE_STRENGTH baseline * any override + // multiplier baked in Java at material-compile time — see MaterialHeader.features packing). + float emission = payloadEmission(); + // One gate drives both the RIS call below and the direct-hit emission suppression here. They must + // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where + // RIS is skipped must also gather emission directly or that light is lost outright. + bool risActive = risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); + bool gateEmitter = risActive && payloadEmitterInList(); + if (emission > 0.0 && (!gateEmitter || showCelestial)) { + L += throughput * albedo * emission; + } + + // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert + // diffuse + GGX specular, one shadow ray. Jitter the light direction within its square angular + // extent (worldPush.lightDir.w half-angle) so the shadow ray samples the light's quad — soft penumbrae + // over accumulation. The same sampled direction drives ndl, the shadow ray, and the specular + // half-vector. + float3 lightDir = worldPush.lightDir.xyz; + float lightHalfAngle = worldPush.lightDir.w; + if (lightHalfAngle > 0.0) { + lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + } + float ndl = max(0.0, dot(n, lightDir)); + if (ndl > 0.0) { + VisibilityResult shadow = visibility(p, lightDir, 10000.0); + float3 vis = shadow.transmittance; + // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by + // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, + // so it applies to the whole NEE term (diffuse + specular). + if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { + vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); + } + if (max(vis.r, max(vis.g, vis.b)) > 0.0) { + float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) + float3 h = normalize(lightDir + v); + float ndh = max(0.0, dot(n, h)); + float ndv = max(1.0e-4, dot(n, v)); + float vdh = max(0.0, dot(v, h)); + float D = ggxD(ndh, rough); + float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); // separable Smith masking + float3 F = fresnelSchlick(vdh, F0); + brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular + L += throughput * brdf * worldPush.lightRadiance.xyz * ndl * vis; + } + } + + // RIS direct lighting from block emitters (single-frame only, no temporal reservoir reuse). + // Front lobe + SSS backlighting are resampled together and share ONE shadow ray — a light can + // only be in front of n or behind it, never both, so combining them into one target function is + // exact, not an approximation. The SSS term is gated off past MAX_SSS_DIFFUSE_DEPTH by passing + // sss=0 there (falls back to plain front-only RIS). Keyed on diffuseDepth, not `bounce`, for the + // same reason as RIS_MAX_DIFFUSE_DEPTH above: leaves seen through a window are still the first + // surface this path has shaded, and should still backlight. + if (risActive) { + float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; + Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, + uint(diffuseDepth), seed, proposalSeed); + float3 risVis; + L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, + activeSss, risVis); + } + + // Thin-surface SSS transmission. Light entering from the back face scatters through toward the + // viewer via a forward-biased HG phase (leaves/grass backlit by the sun glow when you look + // through them toward the light source). Shadow ray fires from the back face to avoid + // self-occlusion. cosT = dot(lightDir, rd): rd tracks the ray toward the sun when the sun is + // behind the slab, giving cosT ≈ 1 → forward-scatter peak. + if (sss > 0.0 && diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH) { + float backNdl = max(0.0, dot(-n, lightDir)); + if (backNdl > 0.0) { + VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); + float3 visB = shadowBack.transmittance; + // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should + // flicker with the same light bands as the floor around it. + if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { + visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, + lightDir, shadowBack.waterHitT); + } + if (max(visB.r, max(visB.g, visB.b)) > 0.0) { + float cosT = dot(lightDir, rd); + L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * worldPush.lightRadiance.xyz * visB; + } + } + } + + // Every shading term for this surface is now accounted for, so it counts toward the shaded depth. + // The dielectric branches above return before reaching here, which is exactly the distinction + // between this and `bounce`. + diffuseDepth++; + + // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their + // relative reflectance. + float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 + ? 1.0 + : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); + if (rndf(seed) < ps) { + float3 l; + if (exactSpecular) { + // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the + // degenerate VNDF at alpha=0 and preserves a genuinely sharp reflection without a floor. + l = reflect(rd, n); + throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; + } else { + float3 h = sampleGGXVNDF(n, v, rough, seed); + l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal + float ndl2 = dot(n, l); + if (ndl2 <= 0.0) { + break; // microfacet reflects below the surface: terminate this path + } + // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. + float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); + throughput *= F * ggxG1(ndl2, rough) / ps; + rayConeSpread = max(rayConeSpread, rough * RAY_CONE_GLOSSY_SPREAD_SCALE); + } + ro = p; + rd = l; + showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc + } else { + throughput *= diffAlb / (1.0 - ps); + ro = p; + rd = cosineDir(n, seed); + rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); + showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) + } + + // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + break; + } + throughput /= q; + } + } + return L; +} diff --git a/shaders/world/rt_lighting.slanginc b/shaders/world/rt_lighting.slanginc new file mode 100644 index 00000000..635b001a --- /dev/null +++ b/shaders/world/rt_lighting.slanginc @@ -0,0 +1,355 @@ +// Direct lighting: the emitter light grid, RIS reservoirs, and reservoir shading. +// INDIRECT PASS ONLY — the primary pass shades nothing. Depends on rt_trace and rt_math. + +// contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted). +struct Reservoir { + float3 pos; // light sample point (rebased world space) + float3 lnrm; // emitter outward normal + float3 le; // emitter radiance + float area; // emitter rectangle area (the area-light pdf factor) + float M; + float W; + float wSum; + float phat; +}; + +Reservoir resEmpty() { + Reservoir r; + r.pos = float3(0.0, 0.0, 0.0); + r.lnrm = float3(0.0, 0.0, 0.0); + r.le = float3(0.0, 0.0, 0.0); + r.area = 0.0; + r.M = 0.0; + r.W = 0.0; + r.wSum = 0.0; + r.phat = 0.0; + return r; +} + +// Unshadowed contribution of a fixed light sample point at surface (hitPos,n,v); out p-hat = its +// luminance (the resampling target). Evaluated from the UNBIASED hitPos — SURF_BIAS is applied only to +// the shadow-ray origin in shadeReservoir, on whichever side the survivor sample lands. Three receiver +// modes share one target function because they are mutually exclusive per candidate: +// - front hemisphere (ndl>0): the same Lambert(+GGX) BRDF split the sun NEE uses; +// - twoSided (particles): billboards have no back face, ndl=abs(ndl) folds both sides into the front; +// - back hemisphere with sss>0 (LabPBR leaves/grass): HG transmission phase — cosT = dot(wi,rd) peaks +// when the ray points from the light through the slab toward the camera (same as the sun SSS term). +float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n, + float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, + bool twoSided, float sss, out float phat) { + phat = 0.0; + float3 toL = sp - hitPos; + float dist2 = dot(toL, toL); + if (dist2 < 1.0e-6) { + return float3(0.0, 0.0, 0.0); + } + float dist = sqrt(dist2); + float3 wi = toL / dist; + float cosL = max(0.0, dot(lnrm, -wi)); + if (cosL <= 0.0) { + return float3(0.0, 0.0, 0.0); // receiver behind the emitter face + } + float ndl = dot(n, wi); + if (twoSided) { + ndl = abs(ndl); + } + float3 contrib = float3(0.0, 0.0, 0.0); + if (ndl > 0.0) { + float G = ndl * cosL / dist2; // area-light geometry term (x area) + float3 brdf = diffAlb * INV_PI; + // twoSided callers are particle billboards, which pass rough=1/F0=0 and want diffuse-only + // shading: fresnelSchlick still returns up to full white at grazing angles even with F0=0 (that + // IS the Fresnel effect), so skipping the term here — not just zeroing F0 — is what keeps + // billboards from picking up a rim highlight they were never meant to have. + if (!twoSided) { + float3 h = normalize(wi + v); + float ndh = max(0.0, dot(n, h)); + float ndv = max(1.0e-4, dot(n, v)); + float vdh = max(0.0, dot(v, h)); + float D = ggxD(ndh, rough); + float Gs = ggxG1(ndv, rough) * ggxG1(ndl, rough); + float3 Fs = fresnelSchlick(vdh, F0); + brdf += (D * Gs) * Fs / (4.0 * ndv * ndl); + } + contrib = brdf * le * (G * area); + } else if (sss > 0.0) { + float backNdl = -ndl; + float G = backNdl * cosL / dist2; + float cosT = dot(wi, rd); + contrib = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * le * (G * area); + } + phat = luminance(contrib); + return contrib; +} + +// Select one light from the emitted-power distribution in O(1). +void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { + float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); + uint column = min(uint(aliasSample), worldPush.lightCount - 1u); + if (pc.lightAliasAddr != 0) { + LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; + bool self = aliasSample - float(column) < a.accept; + lightIndex = self ? column : a.aliasIndex; + } else { + lightIndex = column; + } +} + +bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { + cell.spanOffset = 0u; + cell.spanCount = 0u; + cell.invWeightSum = 0.0; + cellCoord = int3(0, 0, 0); + if (pc.lightGridCellAddr == 0 || pc.lightGridSpanAddr == 0) return false; + int3 coord = int3(floor((p - worldPush.lightGridOrigin.xyz) / worldPush.lightGridOrigin.w)); + if (coord.x < 0 || coord.y < 0 || coord.z < 0 + || coord.x >= worldPush.lightGridDims.x + || coord.y >= worldPush.lightGridDims.y + || coord.z >= worldPush.lightGridDims.z) return false; + uint linear = (uint(coord.z) * uint(worldPush.lightGridDims.y) + uint(coord.y)) + * uint(worldPush.lightGridDims.x) + uint(coord.x); + cell = ConstPtr(pc.lightGridCellAddr)[linear]; + cellCoord = coord; + return cell.spanCount > 0u; +} + +void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, + out uint lightIndex) { + float aliasSample = rndf(proposalSeed) * float(lightCount); + uint column = min(uint(aliasSample), lightCount - 1u); + LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; + bool self = aliasSample - float(column) < alias.accept; + uint localIndex = self ? column : alias.aliasIndex; + lightIndex = firstLight + localIndex; +} + +float unpackUnsignedFloat(uint bits, uint mantissaBits) { + uint mantissaMask = (1u << mantissaBits) - 1u; + uint mantissa = bits & mantissaMask; + uint exponent = (bits >> mantissaBits) & 31u; + if (exponent == 0u) { + return float(mantissa) * exp2(float(1 - 15 - int(mantissaBits))); + } + return (1.0 + float(mantissa) / float(1u << mantissaBits)) + * exp2(float(int(exponent) - 15)); +} + +float3 lightRadiance(Light light) { + uint packed = light.le; + return float3(unpackUnsignedFloat(packed & 0x7ffu, 6u), + unpackUnsignedFloat((packed >> 11u) & 0x7ffu, 6u), + unpackUnsignedFloat((packed >> 22u) & 0x3ffu, 5u)); +} + +int3 lightSectionCoord(Light light) { + uint packed = light.section; + return int3(int(packed & 0x3ffu), int((packed >> 10u) & 0x3ffu), + int((packed >> 20u) & 0x3ffu)); +} + +// The half axes share three lanes two-at-a-time; see the Light record in world_common. +float3 lightHalfU(Light light) { + return float3(unpackHalf2(light.halfUxy), unpackHalf2(light.halfUzVx).x); +} + +float3 lightHalfV(Light light) { + return float3(unpackHalf2(light.halfUzVx).y, unpackHalf2(light.halfVyz)); +} + +// U x V drives both the emitter normal and the rectangle area, so it is computed once and shared. +float3 lightCrossUV(Light light) { + return cross(lightHalfU(light), lightHalfV(light)); +} + +// The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store. +float lightArea(Light light) { + return 4.0 * length(lightCrossUV(light)); +} + +float3 lightGeometricNormal(Light light) { + float3 normal = normalize(lightCrossUV(light)); + return (light.section & 0x40000000u) != 0u ? -normal : normal; +} + +float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, + float localProbability) { + float power = max(0.0, lightArea(light) * luminance(le)); + float globalPdf = pc.lightAliasAddr != 0 + ? power * worldPush.lightRebase.w : 1.0 / float(worldPush.lightCount); + float localPdf = 0.0; + if (localProbability > 0.0 && power > 0.0) { + int3 delta = lightSectionCoord(light) - cellCoord; + if (all(abs(delta) <= int3(2, 2, 2))) { + float3 deltaF = float3(delta); + localPdf = power * cell.invWeightSum / max(1.0, dot(deltaF, deltaF)); + } + } + return localProbability * localPdf + (1.0 - localProbability) * globalPdf; +} + +void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, + out uint lightIndex) { + ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); + float aliasSample = rndf(proposalSeed) * float(cell.spanCount); + uint column = min(uint(aliasSample), cell.spanCount - 1u); + LightGridSpan span = spans[cell.spanOffset + column]; + bool self = aliasSample - float(column) < span.accept; + uint firstLight = self ? span.firstLight : span.aliasFirstLight; + uint lightCount = self ? (span.packedLightCounts & 0xffffu) + : (span.packedLightCounts >> 16u); + selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex); +} + +// Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section +// by emitted power and section distance, then its section-local power alias. Every light in +// the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. +// Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single +// Light48 load: section power cancels with the section-local light PDF. +void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, + out uint lightIndex) { + if (useLocal) { + selectLightGridSpanLight(cell, proposalSeed, lightIndex); + } else { + selectGlobalLight(proposalSeed, lightIndex); + } +} + +// ---- RIS cost budget. Both constants below come from a set of temporary probes that pinned the +// candidate walk out of the shader to bound what memory-side RIS work could ever be worth. Against an +// 18.0ms frame at M=8, removing every dependent load (span -> alias -> record) and all lane divergence +// saved 5.9ms — a third of the frame. Split by vertex that was ~4.3ms at secondary vertices against +// ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost +// was chasing DEPTH, not divergence, and it lived at secondary vertices. The two knobs below spend that +// finding; a presampled candidate pool would attack the same 5.9ms structurally, but see the note on +// proposalSeed in tracePath for why it should share the pool rather than the seed. + +// Candidate-count divisor for RIS at secondary vertices (bounce > 0). Secondary vertices carried ~78% of +// the cost while being much the more forgiving place to spend variance: that radiance is already +// integrated over a diffuse lobe before the denoiser sees it. 18.0 -> 16.1ms, and indistinguishable from +// 1 by eye — whereas reducing M globally to 2 was clearly worse, because that also degraded the primary +// hit. The primary hit always keeps the full worldPush.risCandidates. +// +// 4 is the floor worth using rather than a tuning accident. The stratification below keeps at least one +// global candidate, so M=2 is the smallest count that still schedules one local AND one global; at M=1 +// localProbability collapses to 0 and the light-grid proposal that makes nearby emitters sample well is +// gone entirely. +static const uint SECONDARY_RIS_DIVISOR = 4u; + +// Deepest SHADED surface at which RIS emitter NEE runs. Past it, emitters are gathered only when a path +// hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep, so the +// failure mode is noisier emitter light at depth rather than missing light. Unlike the divisor this also +// drops shadeReservoir's per-vertex shadow ray, which never scaled with M. Gating at all: 16.1 -> 15.7ms. +// +// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_DIELECTRIC and +// MATERIAL_WATER continue without shading anything, so looking through a pane spends bounce 0 on the +// glass and lands the first shaded surface at bounce 1. Gating on `bounce` therefore starved surfaces +// seen through glass or water — visually primary, but counted as depth — and they went black, since only +// a path randomly striking an emitter could light them. Keying on shaded surfaces removes that whole +// class of bug and lets the budget go lower than the two bounces of headroom the dielectric prefix used +// to cost. +// +// Caveat this does not solve: a smooth specular bounce IS a shaded surface, so a wall seen in a metal +// block counts as depth 1. Accumulated roughness rather than a vertex count is the principled fix, and +// is what a radiance cache would need anyway to decide cache-vs-trace. +static const uint RIS_MAX_DIFFUSE_DEPTH = 2u; + +// Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen +// sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. +Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, + float rough, bool twoSided, float sss, uint shadedDepth, + inout uint seed, inout uint proposalSeed) { + Reservoir r = resEmpty(); + LightGridCell gridCell; + int3 gridCellCoord; + float3 gridLookup = hitPos; + if (pc.lightGridCellAddr != 0) { + // Stochastically blend across hard section boundaries. Every cell proposal retains full global + // support, so conditioning on this independently jittered lookup remains unbiased. + gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5) + * worldPush.lightGridOrigin.w; + } + bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); + // shadedDepth, not the bounce index: a surface behind glass or water is the first thing this path has + // shaded and is visually primary, so it keeps the full candidate count. + uint candidateCount = shadedDepth == 0u + ? worldPush.risCandidates + : max(1u, worldPush.risCandidates / SECONDARY_RIS_DIVISOR); + // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six + // local and two global candidates, with every lane taking the same branch for a given candidate. + // Counts that are not divisible by four use the nearest practical split with at least one global + // candidate; M=1 is global-only so the estimator retains full support. + uint globalCandidateCount = hasGridCell + ? max(1u, (candidateCount + 2u) / 4u) : candidateCount; + uint localCandidateCount = candidateCount - globalCandidateCount; + float localProbability = float(localCandidateCount) / float(candidateCount); + for (uint c = 0u; c < candidateCount; c++) { + r.M += 1.0; + uint li; + uint globalsBefore = (c * globalCandidateCount) / candidateCount; + uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; + bool useLocal = hasGridCell && globalsAfter == globalsBefore; + selectLightGridLight(gridCell, useLocal, proposalSeed, li); + Light lg = ConstPtr(pc.lightBufAddr)[li]; + // Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area). + float s = rndf(seed) * 2.0 - 1.0; + float t = rndf(seed) * 2.0 - 1.0; + float3 sp = lg.pos + worldPush.lightRebase.xyz + + s * lightHalfU(lg) + t * lightHalfV(lg); + float3 le = lightRadiance(lg); + float3 lightNormal = lightGeometricNormal(lg); + float area = lightArea(lg); + float phat; + evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd, + diffAlb, F0, rough, twoSided, sss, phat); + if (phat <= 0.0) { + continue; + } + float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability); + float w = phat / max(sourcePdf, 1.0e-20); + r.wSum += w; + if (rndf(seed) * r.wSum < w) { // weighted reservoir update + r.pos = sp; + r.lnrm = lightNormal; + r.le = le; + r.area = area; + r.phat = phat; + } + } + r.W = r.phat > 0.0 ? (r.wSum / (r.M * r.phat)) : 0.0; + return r; +} + +// Shade a finalized reservoir: recompute the survivor's contribution at (hitPos,n,v), cast ONE shadow +// ray, and return throughput-free radiance contrib*vis*W. The one ray serves whichever term fired for +// the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the +// sample's side of the surface. +float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, + float3 F0, float rough, bool twoSided, float sss, out float3 vis) { + vis = float3(0.0, 0.0, 0.0); + if (s.W <= 0.0 || s.phat <= 0.0) { + return float3(0.0, 0.0, 0.0); + } + float phat; + float3 contrib = evalSampleContrib(s.pos, s.lnrm, s.le, s.area, hitPos, n, v, rd, + diffAlb, F0, rough, twoSided, sss, phat); + if (phat <= 0.0) { + return float3(0.0, 0.0, 0.0); + } + // Pick the shadow-ray origin on the sample's side of the surface FIRST, then measure the ray from + // that origin — measuring dist from hitPos while tracing from the biased origin shifts the ray tip + // up to SURF_BIAS*ndl toward the emitter plane, beating the 0.001*dist stop-short margin for lights + // closer than ~SURF_BIAS/0.001 blocks -> shadow ray hits the emitter's own face -> per-texel black. + float side = dot(n, s.pos - hitPos) >= 0.0 ? 1.0 : -1.0; + float3 origin = hitPos + side * n * SURF_BIAS; + float3 toL = s.pos - origin; + float dist = length(toL); + // Stop just short of the sample point so the ray doesn't self-occlude on the emitter's own face. + VisibilityResult shadow = visibility(origin, toL / dist, dist * 0.999); + vis = shadow.transmittance; + return contrib * vis * s.W; +} + +// TLAS instance-mask bits (set on the Java side, ANDed against these per-ray cull masks): +// bit 0 (0x01) — secondary rays: shadows, GI bounces, reflections. +// bit 1 (0x02) — the primary camera ray. +// Terrain / entities / block entities keep 0xFF (both bits). Particles use 0x02: primary-visible diff --git a/shaders/world/rt_math.slanginc b/shaders/world/rt_math.slanginc new file mode 100644 index 00000000..22aacc95 --- /dev/null +++ b/shaders/world/rt_math.slanginc @@ -0,0 +1,190 @@ +// Stateless math: luminance, GGX (D/G/VNDF), Fresnel, Henyey-Greenstein, the RR specular-albedo +// fit, the PCG stream, and direction sampling. No bindings, no globals, no rays. + +float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } + +// GGX normal distribution. +// NOTE: the 1e-7 denominator guard is not merely a divide-by-zero epsilon, and it is load-bearing. At +// the lobe peak d == a2, so the guard dominates PI*d*d until a2 >> 1.8e-4, i.e. rough >> 0.116; every +// smoother material has its NEE highlight peak truncated. That truncation is what keeps the sun from +// being counted twice: a specular continuation sets showCelestial, so the BSDF-sampled ray may hit the +// sun disc in world.rmiss while NEE is also adding a sun term for the same lobe, with no MIS weighting +// between them. Removing the guard restores the physical peak AND the double count, and makes the +// single-sample sun estimator explode wherever the lobe is tighter than the disc (~0.0047 rad). The +// real fix is MIS (or a disc-aware specular light sample), not an epsilon edit — left as-is. +float ggxD(float ndh, float alpha) { + float a2 = alpha * alpha; + float d = ndh * ndh * (a2 - 1.0) + 1.0; + return a2 / (PI * d * d + 1.0e-7); +} + +// Smith GGX masking term for one direction (separable form; G2 = G1(v) * G1(l)). +float ggxG1(float ndx, float alpha) { + float a2 = alpha * alpha; + return 2.0 * ndx / (ndx + sqrt(a2 + (1.0 - a2) * ndx * ndx) + 1.0e-7); +} + +float3 fresnelSchlick(float cosT, float3 f0) { + float m = clamp(1.0 - cosT, 0.0, 1.0); + float m2 = m * m; + return f0 + (1.0 - f0) * (m2 * m2 * m); +} + +// Henyey–Greenstein phase function (with 4π normalisation so it integrates to 1 over the sphere). +// g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). +static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional +static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) +// Deepest SHADED surface at which the SSS transmission shadow ray fires. diffuseDepth, not `bounce`: the +// glass/water continue branches don't shade a surface, so foliage seen through a window is still +// diffuseDepth 0 and should backlight like any other primary leaf — see RIS_MAX_DIFFUSE_DEPTH for the +// same distinction and why it matters. +static const int MAX_SSS_DIFFUSE_DEPTH = 1; // fire on the first-shaded surface + one indirect hit +float hg(float cosT, float g) { + float g2 = g * g; + return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); +} + +// NVIDIA RR guide-buffer helper: integrated/view-dependent specular reflectivity, not raw F0. +// alpha is GGX alpha (== the linear roughness materials store), NoV is the first-hit view cosine. +float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { + NoV = abs(NoV); + float NoV2 = NoV * NoV; + float alpha2 = alpha * alpha; + float4 X = float4(1.0, NoV, NoV2, NoV * NoV2); + float4 Y = float4(1.0, alpha, alpha2, alpha * alpha2); + + float2 m1 = float2( + dot(float2(0.99044, -1.28514), X.xy), + dot(float2(1.29678, -0.755907), X.xy)); + float3 Xxyw = float3(X.x, X.y, X.w); + float3 m2 = float3( + dot(float3(1.0, 2.92338, 59.4188), Xxyw), + dot(float3(20.3225, -27.0302, 222.592), Xxyw), + dot(float3(121.563, 626.13, 316.627), Xxyw)); + float bias = dot(m1, Y.xy) / max(dot(m2, float3(Y.x, Y.y, Y.w)), 1.0e-7); + + float2 m3 = float2( + dot(float2(0.0365463, 3.32707), X.xy), + dot(float2(9.0632, -9.04756), X.xy)); + float3 Xxzw = float3(X.x, X.z, X.w); + float3 m4 = float3( + dot(float3(1.0, 3.59685, -1.36772), Xxzw), + dot(float3(9.04401, -16.3174, 9.22949), Xxzw), + dot(float3(5.56589, 19.7886, -20.2123), Xxzw)); + float scale = dot(m3, Y.xy) / max(dot(m4, float3(Y.x, Y.y, Y.w)), 1.0e-7); + + bias *= clamp(specularColor.g * 50.0, 0.0, 1.0); + return specularColor * max(0.0, scale) + float3(max(0.0, bias), max(0.0, bias), max(0.0, bias)); +} + +// Exact (unpolarized) Fresnel reflectance for a dielectric interface. cosI is the incidence cosine on +// the incoming side; etaI/etaT are the IORs the ray travels from / into. Returns 1.0 on total internal +// reflection (sin of the transmitted angle >= 1), which matches refract() returning the zero vector — +// so reflect/refract selection and TIR stay consistent. +float fresnelDielectric(float cosI, float etaI, float etaT) { + float sinT2 = (etaI * etaI) / (etaT * etaT) * max(0.0, 1.0 - cosI * cosI); + if (sinT2 >= 1.0) { + return 1.0; + } + float cosT = sqrt(1.0 - sinT2); + float rs = (etaI * cosI - etaT * cosT) / (etaI * cosI + etaT * cosT); + float rp = (etaI * cosT - etaT * cosI) / (etaI * cosT + etaT * cosI); + return 0.5 * (rs * rs + rp * rp); +} + +// PCG hash RNG. +uint pcg(inout uint s) { + s = s * 747796405u + 2891336453u; + uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; + return (w >> 22u) ^ w; +} +float rndf(inout uint s) { + // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first + // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` + // tests (most seriously the F == 1 total-internal-reflection branch). + return float(pcg(s) >> 8u) * (1.0 / 16777216.0); +} + +float3 primaryRayDir(float2 ndc) { + float4 nearH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 1.0, 1.0)); + float4 farH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 0.0, 1.0)); + float3 nearP = nearH.xyz / nearH.w; + float3 farP = farH.xyz / farH.w; + return normalize(farP - nearP); +} + +float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { + float2 onePixelNdc = 2.0 / size; + float3 dx = primaryRayDir(ndc + float2(onePixelNdc.x, 0.0)); + float3 dy = primaryRayDir(ndc + float2(0.0, onePixelNdc.y)); + return max(max(length(cross(dir, dx)), length(cross(dir, dy))), RAY_CONE_MIN_SPREAD); +} + +// Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the +// importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation +// throughput is just *= albedo with no PI/cos bookkeeping left over. +float3 cosineDir(float3 n, inout uint s) { + float u1 = rndf(s); + float u2 = rndf(s); + float r = sqrt(u1); + float phi = 6.2831853 * u2; + float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); + float3 b = normalize(cross(n, t)); + t = cross(b, n); + float3 local = float3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - u1))); + return normalize(local.x * t + local.y * b + local.z * n); +} + +// Soft shadows: sample a direction within the light's SQUARE angular extent about `axis`. MC's +// sun/moon are square quads, so the NEE shadow ray samples a square (not a cone) — the same square the +// visible disc in world.rmiss uses, built from the same celestial tangent frame (right = arc-travel +// direction, from worldPush.celestial.xyz). Averaged over frames by DLSS-RR this yields soft penumbrae that +// widen with occluder distance (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). +float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { + float3 right = normalize(cross(axis, worldPush.celestial.xyz)); + float3 up = cross(right, axis); + float t = tan(halfAngle); + float u = (rndf(s) * 2.0 - 1.0) * t; + float v = (rndf(s) * 2.0 - 1.0) * t; + return normalize(axis + u * right + v * up); +} + +// Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the +// sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the +// importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). +float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { + float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); + float3 b = normalize(cross(n, t)); + t = cross(b, n); + float3 Ve = float3(dot(ve, t), dot(ve, b), dot(ve, n)); // view in tangent space (z = n) + float a = alpha; + float3 Vh = normalize(float3(a * Ve.x, a * Ve.y, Ve.z)); + float lensq = Vh.x * Vh.x + Vh.y * Vh.y; + float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); + float3 T2 = cross(Vh, T1); + float u1 = rndf(s); + float u2 = rndf(s); + float r = sqrt(u1); + float phi = 6.2831853 * u2; + float p1 = r * cos(phi); + float p2 = r * sin(phi); + float ss = 0.5 * (1.0 + Vh.z); + p2 = (1.0 - ss) * sqrt(1.0 - p1 * p1) + ss * p2; + float3 Nh = p1 * T1 + p2 * T2 + sqrt(max(0.0, 1.0 - p1 * p1 - p2 * p2)) * Vh; + float3 Ne = normalize(float3(a * Nh.x, a * Nh.y, max(0.0, Nh.z))); // microfacet normal, tangent space + return normalize(Ne.x * t + Ne.y * b + Ne.z * n); // -> world space +} + +// ===== RIS emitter NEE ================================================================= +// Block emitters (torches, glowstone, lava, ...) get explicit NEE: per diffuse vertex, stream M uniform +// candidates from the global light buffer through a weighted reservoir keyed by the UNSHADOWED target +// p-hat = luminance(f * Le * G * area), then spend ONE shadow ray on the survivor. Sun/moon NEE stays +// separate and additive. Lights are footprint bounding RECTANGLES (RtLightCollector): sampling a point +// uniformly over the rectangle has pdf 1/area, so `area` rides the contribution like any area light. +// Single-frame RIS only (no temporal reservoir reuse): DLSS-RR's own history already denoises this well +// enough that a persistent per-pixel reservoir wasn't worth the added complexity and VRAM. +// One resample serves three mutually exclusive receiver terms (see evalSampleContrib): front BRDF, +// two-sided particle billboards, and LabPBR SSS backscatter from lights BEHIND the surface — all share +// the reservoir and its single shadow ray, whose origin follows the survivor's side of the surface. + +// A reservoir in registers. pos/lnrm/le/area describe the chosen emitter sample; W is its unbiased diff --git a/shaders/world/rt_medium.slanginc b/shaders/world/rt_medium.slanginc new file mode 100644 index 00000000..88287d26 --- /dev/null +++ b/shaders/world/rt_medium.slanginc @@ -0,0 +1,82 @@ +// Participating media: the tint-to-extinction mappings and the depth-2 medium stack the dielectric +// interface pushes and pops. Depends on rt_core only. + +// Per-channel Beer–Lambert extinction from a water body's biome tint (carried in payload.albedo for +// water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean +// tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor +// keeps even a white-tinted body very slightly absorbing so deep water never reads as clear vacuum. +static const float WATER_DENSITY = 0.05; +static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); +float3 waterExtinction(float3 tint) { + return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); +} + +// A non-water dielectric's tint is a filter over one block of travel rather than a biome water colour, so +// it maps to extinction by inverting Beer-Lambert over that reference distance: transmittance t after one +// block means sigma = -ln(t). `transmission` scales that transmittance, so it keeps the sense the +// material's authored factor has everywhere else — 1 passes the tint through unchanged, 0 is opaque. +// Clamped away from zero so a fully saturated texel stays finite instead of producing an infinite sigma. +static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks +float3 volumeExtinction(float3 tint, float transmission) { + float3 blockTransmittance = clamp(clamp(tint, 0.0, 1.0) * clamp(transmission, 0.0, 1.0), + 1.0e-3, 1.0); + return -log(blockTransmittance) / VOLUME_TINT_REFERENCE_DISTANCE; +} + +// ---- Participating medium the path is currently inside. +// +// Refraction needs the RELATIVE index across an interface, so the renderer has to know what it is +// travelling through, not just what it is hitting. `ior` drives Snell/Fresnel; `extinction` drives the +// per-segment Beer-Lambert attenuation. +// +// The stack is depth 2 (current + the one it will return to) held in named fields, NOT an array. A +// dynamically indexed local array lands in scratch memory, and this raygen is already register-bound — +// paying an occupancy hit for nesting that Minecraft does not produce would be a bad trade. Depth 2 +// covers air->water->glass and air->glass->water, which is the realistic worst case; anything deeper +// degrades to air on the way out, and because `entering` is re-derived per face from geometry rather +// than toggled, the path re-synchronises at the next crossing instead of staying corrupted. +struct Medium { + float ior; + float3 extinction; + bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific +}; + +struct MediumStack { + Medium current; + Medium outer; +}; + +Medium airMedium() { + Medium m; + m.ior = 1.0; + m.extinction = float3(0.0, 0.0, 0.0); + m.water = false; + return m; +} + +MediumStack makeMediumStack(Medium start) { + MediumStack s; + s.current = start; + s.outer = airMedium(); + return s; +} + +void mediumPush(inout MediumStack stack, Medium entered) { + stack.outer = stack.current; + stack.current = entered; +} + +void mediumPop(inout MediumStack stack) { + stack.current = stack.outer; + stack.outer = airMedium(); +} + +// Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume +// dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. +Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { + Medium m; + m.ior = ior; + m.extinction = isWater ? waterExtinction(tint) : volumeExtinction(tint, transmission); + m.water = isWater; + return m; +} diff --git a/shaders/world/rt_primary.slanginc b/shaders/world/rt_primary.slanginc new file mode 100644 index 00000000..bd3bf40b --- /dev/null +++ b/shaders/world/rt_primary.slanginc @@ -0,0 +1,155 @@ +// tracePrimary: camera ray through the dielectric chain to the first surface worth shading, capturing +// guides on the way. Returns the continuation the indirect pass resumes. +// PRIMARY PASS ONLY. Depends on rt_guides. + +// later trace rather than keeping a continuation live across traversal. +PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool alive) { + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + MediumStack medium = seg.medium; + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); + uint seed = seg.seed; + bool showCelestial = seg.showCelestial; + int diffuseDepth = seg.diffuseDepth; + int maxBounces = int(worldPush.maxBounces); + int rrStart = maxBounces <= 3 ? 1 : 2; + bool waterWaves = (worldPush.flags & 16u) != 0u; + alive = false; + + for (int bounce = seg.bounce; bounce <= maxBounces; ++bounce) { + PathSegment terminal = makePathSegment(ro, rd, throughput, medium, + rayConeWidth, rayConeSpread, seed, bounce, diffuseDepth, + showCelestial, false, false); + traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, + ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); + + if (payload.hitT < 0.0) { + if (bounce == 0 && captureGuides) { + gv_normal = float3(0.0, 0.0, 0.0); + gv_albedo = SKY_DIFF_ALBEDO; + gv_rough = 1.0; + gv_hitCamRel = rd * 1.0e6; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = float3(0.0, 0.0, 0.0); + gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); + } + alive = true; + return terminal; + } + + float3 n = payload.normal; + float3 hitPos = ro + rd * payload.hitT; + uint material = payloadMaterial(); + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) { + if (bounce == 0 && captureGuides) { + gv_normal = n; + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = payload.motionPrev; + if (material == MATERIAL_PARTICLE) { + gv_albedo = payload.albedo; + gv_rough = 1.0; + gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, + float3(0.0, 0.0, 0.0)); + } else { + float rough = clamp(payloadRoughness(), 0.0, 1.0); + rough = rough <= MIRROR_ALPHA_MAX ? 0.0 : rough; + float metal = clamp(payloadMetalness(), 0.0, 1.0); + float3 diffAlb = payload.albedo * (1.0 - metal); + float3 v = -rd; + gv_albedo = diffAlb; + gv_rough = rough; + gv_spec = makeSpecSurface(gv_hitCamRel, n, rough, + rrSpecularAlbedo(payload.f0, rough, dot(n, v))); + } + } + alive = true; + return terminal; + } + + // This hit is consumed in pass A, so carry its travelled-medium absorption and cone growth into + // the record for the next trace. The terminal hit above is deliberately not consumed here. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); + } + rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), + RAY_CONE_MIN_WIDTH); + + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 geometricNormal = n; + float3 previousNormal = n; + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + if (bounce == 0 && captureGuides && abs(geometricNormal.y) >= 0.5) { + float2 currentGrad; + float2 previousGrad; + waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, + worldPush.waterAnchor.z, waterFootprint, currentGrad, previousGrad); + float orientation = geometricNormal.y >= 0.0 ? 1.0 : -1.0; + n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); + previousNormal = orientation + * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); + } else { + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } + } + + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), + payload.albedo, transmission); + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.outer.ior; + float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + + if (bounce == 0 && captureGuides) { + gv_normal = n; + gv_rough = 0.0; + gv_albedo = float3(0.0, 0.0, 0.0); + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; + gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, + gv_rough, float3(F, F, F)); + if (dot(transmittedDir, transmittedDir) > 0.0) { + resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, + entering ? entered.ior : medium.outer.ior, + transmitBias, rayConeWidth, rayConeSpread); + } + } + + if (rndf(seed) < F) { + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); + } else { + if (dot(transmittedDir, transmittedDir) <= 0.0) { + return terminal; + } + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + } + showCelestial = true; + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + return terminal; + } + throughput /= q; + } + } + return seg; +} diff --git a/shaders/world/rt_segment.slanginc b/shaders/world/rt_segment.slanginc new file mode 100644 index 00000000..9b61aec7 --- /dev/null +++ b/shaders/world/rt_segment.slanginc @@ -0,0 +1,154 @@ +// PathSegment — a resumable continuation — plus its packed 48-byte buffer form. This is the record +// the primary pass writes and the indirect pass reads. Depends on rt_medium. + + +// Everything needed to resume tracing from a point in the scene. The path tracer takes one of these and +// may hand back another, which is how a dielectric's two Fresnel continuations are traced without +// instantiating the path tracer twice: the second branch is DATA, not a second inlined copy. `throughput` +// already carries the branch weight, so segments are simply summed. +// +// This is deliberately shaped like a record that could live in a buffer rather than a register: the +// planned split into a primary/guide pass and an indirect pass hands continuations between dispatches, +// and this is the payload it would write. +struct PathSegment { + float3 ro; + float3 rd; + float3 throughput; + MediumStack medium; + float rayConeWidth; + float rayConeSpread; + uint seed; + int bounce; // interfaces already consumed, so RR start and the bounce cap stay global + int diffuseDepth; // surfaces already SHADED (see the field of the same name in tracePath) + bool showCelestial; + bool captureGuides; + bool maySplit; // may still spawn a deterministic Fresnel branch +}; + +PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, + float rayConeWidth, float rayConeSpread, uint seed, + int bounce, int diffuseDepth, bool showCelestial, + bool captureGuides, bool maySplit) { + PathSegment s; + s.ro = ro; + s.rd = rd; + s.throughput = throughput; + s.medium = medium; + s.rayConeWidth = rayConeWidth; + s.rayConeSpread = rayConeSpread; + s.seed = seed; + s.bounce = bounce; + s.diffuseDepth = diffuseDepth; + s.showCelestial = showCelestial; + s.captureGuides = captureGuides; + s.maySplit = maySplit; + return s; +} +// field is a uint, so Std430DataLayout gives this an exact 48-byte stride. +struct PackedPathSegment { + float3 ro; + uint rd; + uint throughput; + uint currentExtinction; + uint outerExtinction; + uint mediumIors; + uint rayCone; + uint seed; + uint pathFlags; + uint pixelSample; +}; + +static const uint PATH_VALID = 1u << 11u; + +float2 octEncode(float3 direction) { + float3 n = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 1.0e-20); + float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + float2 oct = n.z >= 0.0 ? n.xy : (1.0 - abs(n.yx)) * signNotZero; + return oct * 0.5 + 0.5; +} + +float3 octDecode(float2 encoded) { + float2 oct = encoded * 2.0 - 1.0; + float3 n = float3(oct, 1.0 - abs(oct.x) - abs(oct.y)); + if (n.z < 0.0) { + float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + n.xy = (1.0 - abs(n.yx)) * signNotZero; + } + return normalize(n); +} + +uint packUnorm16x2(float2 v) { + uint2 q = uint2(round(clamp(v, 0.0, 1.0) * 65535.0)); + return q.x | (q.y << 16u); +} + +float2 unpackUnorm16x2(uint p) { + return float2(p & 0xffffu, p >> 16u) / 65535.0; +} + +// DXGI_FORMAT_R9G9B9E5_SHAREDEXP, implemented locally because Slang exposes no packing intrinsic. +uint packRgb9e5(float3 value) { + float3 v = clamp(value, 0.0, 65408.0); + float maxChannel = max(v.x, max(v.y, v.z)); + uint exponent = maxChannel < exp2(-16.0) + ? 0u : uint(floor(log2(maxChannel))) + 16u; + exponent = min(exponent, 31u); + float scale = exp2(float(int(exponent) - 24)); + uint maxMantissa = uint(floor(maxChannel / scale + 0.5)); + if (maxMantissa == 512u && exponent < 31u) { + exponent++; + scale *= 2.0; + } + uint3 mantissa = min(uint3(floor(v / scale + 0.5)), uint3(511u, 511u, 511u)); + return mantissa.x | (mantissa.y << 9u) | (mantissa.z << 18u) | (exponent << 27u); +} + +float3 unpackRgb9e5(uint p) { + float scale = exp2(float(int(p >> 27u) - 24)); + return float3(p & 0x1ffu, (p >> 9u) & 0x1ffu, (p >> 18u) & 0x1ffu) * scale; +} + +PackedPathSegment packPathSegment(PathSegment seg, uint pixelIndex, uint sampleIndex, bool valid) { + PackedPathSegment p; + p.ro = seg.ro; + p.rd = packUnorm16x2(octEncode(seg.rd)); + p.throughput = packRgb9e5(seg.throughput); + p.currentExtinction = packRgb9e5(seg.medium.current.extinction); + p.outerExtinction = packRgb9e5(seg.medium.outer.extinction); + p.mediumIors = packHalf2(float2(seg.medium.current.ior, seg.medium.outer.ior)); + p.rayCone = packHalf2(float2(seg.rayConeWidth, seg.rayConeSpread)); + p.seed = seg.seed; + p.pathFlags = (uint(seg.bounce) & 15u) + | ((uint(seg.diffuseDepth) & 15u) << 4u) + | (seg.showCelestial ? 1u << 8u : 0u) + | (seg.medium.current.water ? 1u << 9u : 0u) + | (seg.medium.outer.water ? 1u << 10u : 0u) + | (valid ? PATH_VALID : 0u); + p.pixelSample = (pixelIndex & 0x1fffffffu) | ((sampleIndex & 7u) << 29u); + return p; +} + +PathSegment unpackPathSegment(PackedPathSegment p, out bool valid) { + float2 iors = unpackHalf2(p.mediumIors); + Medium current; + current.ior = iors.x; + current.extinction = unpackRgb9e5(p.currentExtinction); + current.water = (p.pathFlags & (1u << 9u)) != 0u; + Medium outer; + outer.ior = iors.y; + outer.extinction = unpackRgb9e5(p.outerExtinction); + outer.water = (p.pathFlags & (1u << 10u)) != 0u; + MediumStack medium; + medium.current = current; + medium.outer = outer; + float2 cone = unpackHalf2(p.rayCone); + valid = (p.pathFlags & PATH_VALID) != 0u; + return makePathSegment(p.ro, octDecode(unpackUnorm16x2(p.rd)), + unpackRgb9e5(p.throughput), medium, cone.x, cone.y, p.seed, + int(p.pathFlags & 15u), int((p.pathFlags >> 4u) & 15u), + (p.pathFlags & (1u << 8u)) != 0u, false, false); +} + +// Walk only the visually-primary dielectric chain. The terminal non-dielectric/miss trace is repeated +// by the indirect pass, so the returned record describes the state immediately BEFORE that trace. +// This keeps closest-hit payload data out of the queue and, critically, writes the record before any diff --git a/shaders/world/rt_trace.slanginc b/shaders/world/rt_trace.slanginc new file mode 100644 index 00000000..dca9b4b2 --- /dev/null +++ b/shaders/world/rt_trace.slanginc @@ -0,0 +1,119 @@ +// Ray dispatch: SBT/cull constants, payload construction, and the three ways this renderer casts a +// ray — reordered radiance, non-reordered guide probe, and shadow visibility. Depends on rt_core. + +// receiver billboards only. The first-person camera owner uses 0x01 (secondary only). +static const uint CULL_SECONDARY = 0x01u; +static const uint CULL_PRIMARY = 0x02u; +static const uint TERRAIN_BUCKETS = 4u; +static const uint SBT_RADIANCE = 0u; +static const uint SBT_SHADOW = TERRAIN_BUCKETS; +static const uint SBT_STRIDE_BUCKET = 1u; +static const uint MISS_RADIANCE = 0u; +static const uint MISS_GUIDE = 1u; + +RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { + RayDesc r; + r.Origin = origin; + r.TMin = tmin; + r.Direction = dir; + r.TMax = tmax; + return r; +} + +float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, + float3 outgoingDirection, float bias) { + float3 sideNormal = dot(surfaceNormal, outgoingDirection) >= 0.0 + ? surfaceNormal : -surfaceNormal; + return position + sideNormal * bias; +} + +// A radiance payload carrying only the two words the invoked hit/miss shader READS: `flags` +// (show-celestial gate, consumed by world.rmiss) and the packed ray cone (consumed by world.rchit for +// texture LOD). Every other member is an OUTPUT that world.rchit/world.rmiss write, so it carries no +// information into a trace and is initialized to a neutral value here. +Payload makeRadiancePayload(uint flags, uint rayCone) { + Payload p; + p.albedo = half3(0.0h, 0.0h, 0.0h); + p.normal = half3(0.0h, 0.0h, 0.0h); + p.hitT = -1.0; // miss sentinel: world.rmiss writes only albedo, so a miss leaves this negative + p.motionPrev = half3(0.0h, 0.0h, 0.0h); + p.f0 = half3(0.0h, 0.0h, 0.0h); + p.flags = flags; + p.roughMetal = 0u; + p.emissionSss = 0u; + p.iorTransmission = 0u; + p.rayCone = rayCone; + return p; +} + +// SER radiance trace: trace into a hit object, reorder threads by hit coherence, THEN invoke the +// hit/miss shader — this is where the divergent Section/Prim shading work (world.rchit/world.rahit) +// actually happens, so reordering before it is what pays for the reorder's own cost. +// +// The payload is built TWICE from the trace state rather than carried across ReorderThread. Traversal +// runs only world.rahit, which never writes a RADIANCE payload: its terrain tint paths live in the +// translucent/water buckets, which carry no any-hit record for radiance rays, and its entity paths are +// gated on RAY_FLAG_SKIP_CLOSEST_HIT_SHADER. So whatever TraceRay leaves in the payload holds no +// information. Reading it back and carrying it over would pin all ten members — 72 bytes — in registers +// across the one point where SER must spill every live value, which profiling showed to be the peak +// live state in this shader. Rebuilding costs a few constant moves and leaves only these two words +// spanning the reorder. +void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + bool showCelestial, float rayConeWidth, float rayConeSpread) { + uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; + uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); + Payload tracePayload = makeRadiancePayload(flags, rayCone); + HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, + SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, + makeRay(ro, tmin, rd, tmax), tracePayload); + ReorderThread(hObj); + payload = makeRadiancePayload(flags, rayCone); + HitObject::Invoke(topLevelAS, hObj, payload); +} + +// Auxiliary RR probes deliberately use ordinary TraceRay. They are one-off, incoherent rays whose +// result is consumed immediately, so paying a reorder barrier and carrying guide state across it only +// adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, +// not sky radiance. +void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + float rayConeWidth, float rayConeSpread) { + payload = makeRadiancePayload(0u, packHalf2(float2(rayConeWidth, rayConeSpread))); + TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, + MISS_GUIDE, makeRay(ro, tmin, rd, tmax), payload); +} + +Payload makeShadowPayload() { + Payload shadowPayload; + shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); + shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit + shadowPayload.normal = half3(0.0h, 0.0h, 0.0h); + shadowPayload.motionPrev = half3(0.0h, 0.0h, 0.0h); + shadowPayload.f0 = half3(0.0h, 0.0h, 0.0h); + shadowPayload.flags = 0u; + shadowPayload.roughMetal = 0u; + shadowPayload.emissionSss = 0u; + shadowPayload.iorTransmission = 0u; + shadowPayload.rayCone = 0u; + return shadowPayload; +} + +VisibilityResult visibility(float3 origin, float3 dir, float tmax) { + // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow + // path uses only albedo as accumulated transmittance and hitT as the nearest-water crossing. + Payload shadowPayload = makeShadowPayload(); + // Shadow SBT records run any-hit only for cutout/translucent/water. Cutout alpha-tests; translucent + // and water tint shadowPayload.albedo and pass through. Solid blocks terminate traversal. There is no + // closest or miss shader worth executing, so use a hit object only as the traversal result. + HitObject hObj = HitObject::TraceRay(topLevelAS, + RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER, + CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, 0u, + makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); + VisibilityResult result; + result.transmittance = hObj.IsMiss() ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); + result.waterHitT = shadowPayload.hitT; + return result; +} + +// `valid` is false when the point projects behind the previous camera, where the perspective divide +// would otherwise manufacture an arbitrarily large NDC. Mirror images reach that case easily: a virtual +// image sits as far behind the reflector as the source is in front, so a reflection seen at a grazing diff --git a/shaders/world/rt_water.slanginc b/shaders/world/rt_water.slanginc new file mode 100644 index 00000000..cf3c95a1 --- /dev/null +++ b/shaders/world/rt_water.slanginc @@ -0,0 +1,165 @@ +// Water surface: the wave spectrum (with its analytic time derivative), the normal perturbation it +// drives, and the caustic term that differentiates the same field. Depends on rt_core. + + +// Animated surface waves — a directional wave spectrum, gradient-only (the surface stays geometrically +// flat; only the normal tilts). Three things make it read as water rather than animated glass: +// * Sharp-crested profile: each component is h = a·exp(S·(sin φ − 1)) — peaked crests, wide flat +// troughs (pure sines look like rolling jelly). Still C∞ smooth with an exact analytic gradient. +// * Real deep-water dispersion ω = √(g·k): long swell strides past (λ=14 m → ~4.7 m/s) while short +// chop flutters nearly in place — wrong relative speeds are the biggest tell of fake water. +// * Wind + meander: headings spread around a fixed wind direction (tight for the swell, wide for the +// chop), and the largest components' phase is modulated along their crest (chain-ruled into the +// gradient) so wavefronts wander instead of ruling straight periodic lines across the sea. +// The domain is world-anchored (see applyWaterWaves) so the pattern is pinned in world space, and the +// caustics (waterCaustic) differentiate this same field, so they stay in sync with the visible ripples. +static const float WATER_WAVE_STRENGTH = 0.3; // slope scale (higher = choppier glints / more broken reflections) +static const float WAVE_SPEED = 0.8; // global time scale; dispersion keeps relative speeds physical +static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each octave 1.5× shorter) +static const float WAVE_G = 9.81; +static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction +static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components +float waterWaveLodWeight(float wavelength, float footprint) { + float cyclesPerPixel = max(footprint, 0.0) / max(wavelength, 1.0e-4); + return 1.0 - smoothstep(0.25, 0.5, cyclesPerPixel); +} + +// One walk of the wave spectrum, shared by every consumer so the spectrum constants exist once. WITH_DT +// is a generic so the derivative terms are compiled out entirely for callers that only want the +// gradient — the trigonometry is shared, but the chain-rule work is not free. +void waterWaveSpectrum(float2 p, float t, float footprint, + out float2 grad, out float2 gradDt) { + // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. + const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; + t *= WAVE_SPEED; + grad = float2(0.0, 0.0); + gradDt = float2(0.0, 0.0); + float lambda = 14.0; + for (int i = 0; i < WAVE_COUNT; i++) { + float lodWeight = waterWaveLodWeight(lambda, footprint); + if (lodWeight <= 0.0) break; + float k = 2.0 * PI / lambda; + float w = sqrt(WAVE_G * k); // deep-water dispersion: phase speed √(g/k) + // Heading: deterministic irregular spread about the wind, widening down the spectrum. + float spread = (0.15 + 0.11 * float(i)) * sin(float(i) * 2.4 + 1.3); + float cs = cos(spread), sn = sin(spread); + float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, WAVE_WIND.x * sn + WAVE_WIND.y * cs); + float ph = k * dot(d, p) - w * t + 1.7 * float(i) * float(i); + float2 dph = k * d; + float phDt = -w; // ∂φ/∂t + float2 dphDt = float2(0.0, 0.0); // ∂(∇φ)/∂t + if (i < 4) { // meander only the components whose fronts are long enough to read as lines + float2 pd = float2(-d.y, d.x); + float wph = 0.4 * k * dot(pd, p) + 0.5 * w * t; + ph += WAVE_MEANDER * sin(wph); + dph += (WAVE_MEANDER * cos(wph) * 0.4 * k) * pd; + if (WITH_DT != 0) { + float wphDt = 0.5 * w; + phDt += WAVE_MEANDER * cos(wph) * wphDt; + dphDt = (-WAVE_MEANDER * sin(wph) * wphDt * 0.4 * k) * pd; + } + } + float S = 1.2 + 0.12 * float(i); // crest sharpness: the short chop is choppier + float sinPh = sin(ph); + float cosPh = cos(ph); + float e = exp(S * (sinPh - 1.0)); + // a = Q/k; ∇h = a·S·cos φ·e^{S(sin φ−1)}·∇φ + float amplitude = lodWeight * (Q[i] / k) * S; + grad += amplitude * cosPh * e * dph; + if (WITH_DT != 0) { + // ∂/∂t of the term above, chain-ruled through φ and ∇φ. + gradDt += amplitude * e + * (phDt * (-sinPh + S * cosPh * cosPh) * dph + cosPh * dphDt); + } + lambda /= 1.5; + } + grad *= WATER_WAVE_STRENGTH; + gradDt *= WATER_WAVE_STRENGTH; +} + +float2 waterWaveGrad(float2 p, float t, float footprint) { + float2 grad, gradDt; + waterWaveSpectrum<0>(p, t, footprint, grad, gradDt); + return grad; +} + +float2 waterWaveGrad(float2 p, float t) { + return waterWaveGrad(p, t, 0.0); +} + +// Evaluate the current gradient and linearly rewind it to the previous phase in one spectrum walk. +// Frame deltas are bounded on the CPU, so this avoids a second set of trigonometric/exponential work +// while remaining accurate over the sub-frame interval used by reflection reprojection. +void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, + out float2 currentGrad, out float2 previousGrad) { + float2 gradDt; + waterWaveSpectrum<1>(p, currentT, footprint, currentGrad, gradDt); + previousGrad = currentGrad - (currentT - previousT) * WAVE_SPEED * gradDt; +} + +// Perturb a (near-)horizontal water surface normal by the wave field. Flowing side faces (|n.y| small) +// keep their flat geometric normal. `worldXZ` must be a world-stable coordinate (rebased hit + the pushed +// anchor) so the ripples stay pinned in the world. `nGeo` is already oriented toward the viewer, so the +// down-viewed underside (n.y < 0) takes the mirrored slope (-up). +float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { + if (abs(nGeo.y) < 0.5) return nGeo; + float2 grad = waterWaveGrad(worldXZ, t, footprint); + float3 up = normalize(float3(-grad.x, 1.0, -grad.y)); + return nGeo.y >= 0.0 ? up : -up; +} + +// ---- Water caustics. Sunlight refracting through the waved surface converges/diverges before it +// reaches an underwater receiver; the analytic wave field makes the true focusing factor computable +// instead of faked with a scrolling texture. Where the shadow ray of an underwater NEE vertex crossed +// water (returned by visibility() as VisibilityResult.waterHitT), evaluate the horizontal landing +// position of the refracted sun ray as a function of surface position and finite-difference it. The +// caustic intensity is the inverse Jacobian determinant of that surface→floor mapping (area compression = brightening, real +// fold caustics where det → 0). Because this uses the SAME wave field as the visible surface normals, +// the caustic pattern stays in sync with the ripples, and the per-sample sun-quad jitter (sampleSquare) +// shifts the pattern per sample → caustics physically blur with depth under DLSS-RR accumulation. +static const float CAUSTIC_EPS = 0.25; // finite-difference step (m); also low-passes sub-step wave detail +static const float CAUSTIC_MAX = 6.0; // focusing clamp: keeps fold-line singularities from becoming fireflies + +// Horizontal landing offset of a refracted sun ray entering the surface at world-stable xz, intersected +// with the receiver plane h metres below. inc = sunlight travel direction (downward, unit). +float2 causticLanding(float2 xz, float t, float3 inc, float h) { + float2 g = waterWaveGrad(xz, t); + float3 nw = normalize(float3(-g.x, 1.0, -g.y)); + float3 rdw = refract(inc, nw, 1.0 / WATER_IOR); // air→water: never TIR + return xz + rdw.xz * (h / max(-rdw.y, 0.05)); +} + +float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { + // Grazing light: the receiver-plane intersection degenerates and caustics vanish in reality anyway + // (long slant paths absorb + the pattern smears out) — fade the effect to neutral 1.0. + float fade = smoothstep(0.06, 0.18, lightDir.y); + if (fade <= 0.0) return 1.0; + float h = max(waterDist * lightDir.y, 0.05); // vertical depth of the receiver below the exit point + float t = worldPush.waterParams.w; + float3 inc = -lightDir; + float2 base = exitPos.xz + worldPush.waterAnchor.xy; // world-stable wave domain (same as applyWaterWaves) + float2 p0 = causticLanding(base, t, inc, h); + float2 px = causticLanding(base + float2(CAUSTIC_EPS, 0.0), t, inc, h); + float2 pz = causticLanding(base + float2(0.0, CAUSTIC_EPS), t, inc, h); + float det = abs((px.x - p0.x) * (pz.y - p0.y) - (px.y - p0.y) * (pz.x - p0.x)); + // Flat water ⇒ all landings displace identically ⇒ det = eps² ⇒ focus = 1 (energy-neutral); the + // clamp bounds the fold-line spikes whose energy the surrounding darkening already paid for. + float focus = (CAUSTIC_EPS * CAUSTIC_EPS) / max(det, 1.0e-5); + return lerp(1.0, min(focus, CAUSTIC_MAX), fade); +} + +// Below this GGX alpha the lobe is treated as a delta mirror. The threshold is not about detecting an +// authored zero — it is about the point where a finite lobe stops being distinguishable from a mirror. +// A GGX lobe's half-angle is ~alpha, and the sun's angular radius is ~0.0047 rad, so alpha = 4e-4 is +// ~12x tighter than the sun disc and far under a pixel footprint at any reflection distance. Nothing +// below it can be resolved, and the delta path is both sharper and better conditioned there. +// +// Two things follow from taking the delta path, and both are why the threshold is set here rather than +// at the 8-bit quantum: +// * ggxD(_, 0) is exactly 0, so the NEE specular term vanishes by construction and the sun arrives +// solely via the mirror ray hitting the disc (showCelestial). That is the correct, unbiased +// accounting — see the double-count note on ggxD below. +// * There is no floor to apply above the threshold: alpha > 4e-4 is well conditioned for both the +// VNDF sample and the NDF, so authored roughness passes through unmodified and the mapping stays +// continuous. A separate MIN_ROUGH would only reintroduce a cliff. +// diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 672447cf..075cf951 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -1,3 +1,53 @@ -#define CAUSTICA_PRIMARY_PASS 0 -#define CAUSTICA_INDIRECT_PASS 1 -#include "world_path.slanginc" +// Indirect pass (pass B of the wavefront split — see docs/WAVEFRONT_PLAN.md). +// +// Resumes the continuations the primary pass queued and runs the bounce loop: NEE toward the sun, RIS +// emitter lighting, thin-surface SSS, BSDF continuation and Russian roulette. It captures no guides and +// owns no motion vectors — rt_guides and rt_primary are deliberately absent from the include list +// below, so guide state cannot leak into this pass by accident. +// +// Lighting is iterative here rather than recursive from closest-hit, so maxRayRecursionDepth stays 1. +// At each surface vertex: +// * NEE toward the sun — a single analytic directional light, gated by one hit-object visibility +// query (TerminateOnFirstHit | SkipClosestHit). Lambertian BRDF = albedo/PI. +// * a cosine-weighted Lambertian continuation ray (throughput *= albedo; the PI and cos cancel). +// Sky / ambient light needs no separate AO pass: continuation rays that escape land in world.rmiss, +// which returns sky radiance — so soft sky fill and contact AO both fall out of the path integral. +// +// Each frame emits a single SPP-averaged (still noisy) estimate; temporal convergence is the denoiser's +// job. With RR disabled the output is the raw noisy path trace — a reference view, not a converged +// image. Output is HDR (R16G16B16A16_SFLOAT); the HDR -> LDR tonemap happens at the display.comp seam. +// +// SER: the build emits EXT and NV shader-invocation-reorder variants from this source. +import world_common; + +#include "rt_core.slanginc" +#include "rt_math.slanginc" +#include "rt_medium.slanginc" +#include "rt_segment.slanginc" +#include "rt_water.slanginc" +#include "rt_trace.slanginc" +#include "rt_lighting.slanginc" +#include "rt_indirect.slanginc" + +[shader("raygeneration")] +void main() { + worldPush = ConstPtr(pc.worldPushAddr)[0]; + if (pc.debugView != 0u) { + return; // pass A owns guide visualization output + } + uint2 dispatchIndex = DispatchRaysIndex().xy; + uint2 dimensions = DispatchRaysDimensions().xy; + int2 pix = int2(dispatchIndex); + uint spp = max(worldPush.spp, 1u); + uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; + ConstPtr queue = ConstPtr(pc.pathQueueAddr); + float3 frameRadiance = float3(0.0, 0.0, 0.0); + for (uint s = 0u; s < spp; ++s) { + bool valid; + PathSegment segment = unpackPathSegment(queue[pixelIndex * spp + s], valid); + if (valid) { + frameRadiance += tracePath(segment, uint2(pix), s); + } + } + outImage[pix] = float4(frameRadiance / float(spp), 1.0); +} diff --git a/shaders/world/world_path.slanginc b/shaders/world/world_path.slanginc deleted file mode 100644 index 0c426cb6..00000000 --- a/shaders/world/world_path.slanginc +++ /dev/null @@ -1,1986 +0,0 @@ -// Shared implementation for the primary/guide and indirect raygen entry points. -// Included by world_primary.rgen.slang and world.rgen.slang; the entry point wrappers define -// CAUSTICA_PRIMARY_PASS or CAUSTICA_INDIRECT_PASS before including this file. -// -// Perspective path tracer. invViewProj = inverse(proj * viewRot) maps clip to camera-relative world. -// Primary rays are reconstructed from two unprojected points (near and far plane) rather than assuming -// the eye is at the origin: Minecraft bakes view bobbing into the projection, so the effective eye comes -// from the matrix. camOffset shifts camera-relative space into the terrain's rebased coordinates. Depth -// is reversed-Z (near=1, far=0). -// -// Lighting is an iterative path-tracing loop in raygen, not recursion from closest-hit, so -// maxRayRecursionDepth stays 1. At each surface vertex: -// * NEE toward the sun — a single analytic directional light, gated by one hit-object visibility query -// (TerminateOnFirstHit | SkipClosestHit). Lambertian BRDF = albedo/PI. -// * a cosine-weighted Lambertian continuation ray (throughput *= albedo; the PI and cos cancel). -// Sky / ambient light needs no separate AO pass: continuation rays that escape land in world.rmiss, -// which returns sky radiance — so soft sky fill and contact AO both fall out of the path integral. -// Russian roulette terminates deep paths. Output is HDR (R16G16B16A16_SFLOAT); the HDR -> LDR tonemap -// happens later at the display.comp seam. -// -// SER: the build emits EXT and NV shader-invocation-reorder variants from this source. -// -// Each frame emits a single SPP-averaged (still noisy) estimate; temporal convergence is the denoiser's -// job. With RR disabled the output is the raw noisy path trace — a reference view, not a converged image. -import world_common; - -[[vk::push_constant]] WorldPushConstants pc; - -[[vk::binding(0, 0)]] RaytracingAccelerationStructure topLevelAS; -// HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. -[[vk::binding(1, 0)]] [format("rgba16f")] RWTexture2D outImage; -// Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written -// every frame regardless of accumulation. gNormal.w carries LINEAR roughness (= GGX alpha, which is what -// DLSS-RR documents for this input); gDepth carries HW reversed-Z depth. -[[vk::binding(3, 0)]] [format("rgba16f")] RWTexture2D gNormal; // xyz world normal, w linear roughness -[[vk::binding(4, 0)]] [format("rgba16f")] RWTexture2D gAlbedo; // rgb diffuse albedo -[[vk::binding(5, 0)]] [format("r32f")] RWTexture2D gDepth; // HW reversed-Z depth -[[vk::binding(6, 0)]] [format("rg16f")] RWTexture2D gMotion; // screen-space motion (render px) -[[vk::binding(7, 0)]] [format("rgba16f")] RWTexture2D gSpecAlbedo; // specular albedo (0 — diffuse-only) -[[vk::binding(8, 0)]] [format("rg16f")] RWTexture2D gSpecMotion; // reflection MVs for RR - -// Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL -// `worldPush` global). Layout constants generated from this module's SPIR-V — see world_common.WorldPush. -static WorldPush worldPush; - -// The radiance payload remains module-level so tracePath and its guide helpers can share it across -// HitObject trace/invoke calls, mirroring the GLSL rayPayloadEXT global. -static Payload payload; - -struct VisibilityResult { - float3 transmittance; - float waterHitT; -}; - -// First-hit (primary-visibility) guide attributes, captured at bounce 0 of tracePath. The primary ray -// is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. -static float3 gv_normal; -static float3 gv_albedo; -static float gv_rough; -static float3 gv_hitCamRel; // primary-surface hit position relative to the current camera -static float3 gv_motionHitCamRel; // motion-guide hit position; water tracks the refracted hit instead -static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit (its own reprojection delta) -// World-space displacement of the motion-guide surface since the previous frame (0 for static -// terrain/sky, an entity's per-vertex/object motion for a dynamic hit). -static float3 gv_motionObjDisp; -// Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs -// (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is -// the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. -struct SpecSurface { - float3 camRel; // interface position relative to the current camera - float3 normal; // shading normal (wave-perturbed for water) - float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates - float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface - float roughness; - float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) -}; -static SpecSurface gv_spec; - -// Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading -// normal now and last frame, flat geometric normal for the bias) via the six-argument form. -SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, float3 biasNormal, - float roughness, float3 albedo) { - SpecSurface s; - s.camRel = camRel; - s.normal = normal; - s.previousNormal = previousNormal; - s.biasNormal = biasNormal; - s.roughness = roughness; - s.albedo = albedo; - return s; -} - -SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { - return makeSpecSurface(camRel, normal, normal, normal, roughness, albedo); -} - - -uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } -// Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was -// travelling into the volume (vs. out of it) at this hit's face. -bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } -bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } -// LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used -// end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly -// that, RtMaterials.Profile carries the same units, and DLSS-RR wants linear roughness in its guide. So -// nothing squares it: ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo all take this value as alpha as-is, and -// any threshold compared against it is an alpha threshold. -float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } -float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } -float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } -float payloadSss() { return unpackHalf2(payload.emissionSss).y; } -float payloadIor() { return unpackHalf2(payload.iorTransmission).x; } -float payloadTransmission() { return unpackHalf2(payload.iorTransmission).y; } - -static const float PI = 3.14159265359; -static const float INV_PI = 0.31830988618; - -// The analytic sky (gradient + sun/moon discs + stars) is computed in world.rmiss. On a miss, raygen -// reads payload.albedo and accumulates that sky radiance. The directional NEE light is pushed separately -// as worldPush.lightDir / worldPush.lightRadiance / worldPush.sunDir. -static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) -static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface - -// Water is a smooth dielectric. IOR 1.333; absorption is per-block Beer-Lambert extinction. -static const float WATER_IOR = 1.333; -static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. -static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); - -// Transmitted-ray origin offset when crossing a face from the TRANSLUCENT terrain layer (glass, ice). -// Must be SMALLER than the mesher-side TRANSLUCENT_INSET (RtTerrainMesher, 2e-4 blocks): those quads are -// recessed into their block by that inset, so a face touching a slab/stair leaves a tiny gap before the -// neighbour's surface. A large bias (SURF_BIAS) would restart the ray past that neighbour and see -// through it; this lands the ray inside the gap so the neighbour is still hit, while still clearing the -// surface it left to avoid self-intersection. Water is meshed by RtFluidMesher with no inset, so it uses -// the ordinary bias. -static const float INSET_TRANSMIT_BIAS = 1.0e-4; - -// Ray-cone texture LOD: raygen tracks a one-pixel footprint along the path, and closest-hit converts it -// to texture mip levels from the local UV/world-space scale. Diffuse/glossy bounces widen the cone so -// indirect texture fetches don't keep sampling mip 0 through broad lobes. -static const float RAY_CONE_MIN_SPREAD = 1.0e-5; -static const float RAY_CONE_MIN_WIDTH = 1.0e-5; -static const float RAY_CONE_DIFFUSE_SPREAD = 0.25; -static const float RAY_CONE_GLOSSY_SPREAD_SCALE = 0.35; - -// Per-channel Beer–Lambert extinction from a water body's biome tint (carried in payload.albedo for -// water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean -// tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor -// keeps even a white-tinted body very slightly absorbing so deep water never reads as clear vacuum. -static const float WATER_DENSITY = 0.05; -static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); -float3 waterExtinction(float3 tint) { - return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); -} - -// A non-water dielectric's tint is a filter over one block of travel rather than a biome water colour, so -// it maps to extinction by inverting Beer-Lambert over that reference distance: transmittance t after one -// block means sigma = -ln(t). `transmission` scales that transmittance, so it keeps the sense the -// material's authored factor has everywhere else — 1 passes the tint through unchanged, 0 is opaque. -// Clamped away from zero so a fully saturated texel stays finite instead of producing an infinite sigma. -static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks -float3 volumeExtinction(float3 tint, float transmission) { - float3 blockTransmittance = clamp(clamp(tint, 0.0, 1.0) * clamp(transmission, 0.0, 1.0), - 1.0e-3, 1.0); - return -log(blockTransmittance) / VOLUME_TINT_REFERENCE_DISTANCE; -} - -// ---- Participating medium the path is currently inside. -// -// Refraction needs the RELATIVE index across an interface, so the renderer has to know what it is -// travelling through, not just what it is hitting. `ior` drives Snell/Fresnel; `extinction` drives the -// per-segment Beer-Lambert attenuation. -// -// The stack is depth 2 (current + the one it will return to) held in named fields, NOT an array. A -// dynamically indexed local array lands in scratch memory, and this raygen is already register-bound — -// paying an occupancy hit for nesting that Minecraft does not produce would be a bad trade. Depth 2 -// covers air->water->glass and air->glass->water, which is the realistic worst case; anything deeper -// degrades to air on the way out, and because `entering` is re-derived per face from geometry rather -// than toggled, the path re-synchronises at the next crossing instead of staying corrupted. -struct Medium { - float ior; - float3 extinction; - bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific -}; - -struct MediumStack { - Medium current; - Medium outer; -}; - -Medium airMedium() { - Medium m; - m.ior = 1.0; - m.extinction = float3(0.0, 0.0, 0.0); - m.water = false; - return m; -} - -MediumStack makeMediumStack(Medium start) { - MediumStack s; - s.current = start; - s.outer = airMedium(); - return s; -} - -void mediumPush(inout MediumStack stack, Medium entered) { - stack.outer = stack.current; - stack.current = entered; -} - -void mediumPop(inout MediumStack stack) { - stack.current = stack.outer; - stack.outer = airMedium(); -} - -// Everything needed to resume tracing from a point in the scene. The path tracer takes one of these and -// may hand back another, which is how a dielectric's two Fresnel continuations are traced without -// instantiating the path tracer twice: the second branch is DATA, not a second inlined copy. `throughput` -// already carries the branch weight, so segments are simply summed. -// -// This is deliberately shaped like a record that could live in a buffer rather than a register: the -// planned split into a primary/guide pass and an indirect pass hands continuations between dispatches, -// and this is the payload it would write. -struct PathSegment { - float3 ro; - float3 rd; - float3 throughput; - MediumStack medium; - float rayConeWidth; - float rayConeSpread; - uint seed; - int bounce; // interfaces already consumed, so RR start and the bounce cap stay global - int diffuseDepth; // surfaces already SHADED (see the field of the same name in tracePath) - bool showCelestial; - bool captureGuides; - bool maySplit; // may still spawn a deterministic Fresnel branch -}; - -PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, - float rayConeWidth, float rayConeSpread, uint seed, - int bounce, int diffuseDepth, bool showCelestial, - bool captureGuides, bool maySplit) { - PathSegment s; - s.ro = ro; - s.rd = rd; - s.throughput = throughput; - s.medium = medium; - s.rayConeWidth = rayConeWidth; - s.rayConeSpread = rayConeSpread; - s.seed = seed; - s.bounce = bounce; - s.diffuseDepth = diffuseDepth; - s.showCelestial = showCelestial; - s.captureGuides = captureGuides; - s.maySplit = maySplit; - return s; -} - -// Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume -// dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. -Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { - Medium m; - m.ior = ior; - m.extinction = isWater ? waterExtinction(tint) : volumeExtinction(tint, transmission); - m.water = isWater; - return m; -} - -// Animated surface waves — a directional wave spectrum, gradient-only (the surface stays geometrically -// flat; only the normal tilts). Three things make it read as water rather than animated glass: -// * Sharp-crested profile: each component is h = a·exp(S·(sin φ − 1)) — peaked crests, wide flat -// troughs (pure sines look like rolling jelly). Still C∞ smooth with an exact analytic gradient. -// * Real deep-water dispersion ω = √(g·k): long swell strides past (λ=14 m → ~4.7 m/s) while short -// chop flutters nearly in place — wrong relative speeds are the biggest tell of fake water. -// * Wind + meander: headings spread around a fixed wind direction (tight for the swell, wide for the -// chop), and the largest components' phase is modulated along their crest (chain-ruled into the -// gradient) so wavefronts wander instead of ruling straight periodic lines across the sea. -// The domain is world-anchored (see applyWaterWaves) so the pattern is pinned in world space, and the -// caustics (waterCaustic) differentiate this same field, so they stay in sync with the visible ripples. -static const float WATER_WAVE_STRENGTH = 0.3; // slope scale (higher = choppier glints / more broken reflections) -static const float WAVE_SPEED = 0.8; // global time scale; dispersion keeps relative speeds physical -static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each octave 1.5× shorter) -static const float WAVE_G = 9.81; -static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction -static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components -float waterWaveLodWeight(float wavelength, float footprint) { - float cyclesPerPixel = max(footprint, 0.0) / max(wavelength, 1.0e-4); - return 1.0 - smoothstep(0.25, 0.5, cyclesPerPixel); -} - -// One walk of the wave spectrum, shared by every consumer so the spectrum constants exist once. WITH_DT -// is a generic so the derivative terms are compiled out entirely for callers that only want the -// gradient — the trigonometry is shared, but the chain-rule work is not free. -void waterWaveSpectrum(float2 p, float t, float footprint, - out float2 grad, out float2 gradDt) { - // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. - const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; - t *= WAVE_SPEED; - grad = float2(0.0, 0.0); - gradDt = float2(0.0, 0.0); - float lambda = 14.0; - for (int i = 0; i < WAVE_COUNT; i++) { - float lodWeight = waterWaveLodWeight(lambda, footprint); - if (lodWeight <= 0.0) break; - float k = 2.0 * PI / lambda; - float w = sqrt(WAVE_G * k); // deep-water dispersion: phase speed √(g/k) - // Heading: deterministic irregular spread about the wind, widening down the spectrum. - float spread = (0.15 + 0.11 * float(i)) * sin(float(i) * 2.4 + 1.3); - float cs = cos(spread), sn = sin(spread); - float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, WAVE_WIND.x * sn + WAVE_WIND.y * cs); - float ph = k * dot(d, p) - w * t + 1.7 * float(i) * float(i); - float2 dph = k * d; - float phDt = -w; // ∂φ/∂t - float2 dphDt = float2(0.0, 0.0); // ∂(∇φ)/∂t - if (i < 4) { // meander only the components whose fronts are long enough to read as lines - float2 pd = float2(-d.y, d.x); - float wph = 0.4 * k * dot(pd, p) + 0.5 * w * t; - ph += WAVE_MEANDER * sin(wph); - dph += (WAVE_MEANDER * cos(wph) * 0.4 * k) * pd; - if (WITH_DT != 0) { - float wphDt = 0.5 * w; - phDt += WAVE_MEANDER * cos(wph) * wphDt; - dphDt = (-WAVE_MEANDER * sin(wph) * wphDt * 0.4 * k) * pd; - } - } - float S = 1.2 + 0.12 * float(i); // crest sharpness: the short chop is choppier - float sinPh = sin(ph); - float cosPh = cos(ph); - float e = exp(S * (sinPh - 1.0)); - // a = Q/k; ∇h = a·S·cos φ·e^{S(sin φ−1)}·∇φ - float amplitude = lodWeight * (Q[i] / k) * S; - grad += amplitude * cosPh * e * dph; - if (WITH_DT != 0) { - // ∂/∂t of the term above, chain-ruled through φ and ∇φ. - gradDt += amplitude * e - * (phDt * (-sinPh + S * cosPh * cosPh) * dph + cosPh * dphDt); - } - lambda /= 1.5; - } - grad *= WATER_WAVE_STRENGTH; - gradDt *= WATER_WAVE_STRENGTH; -} - -float2 waterWaveGrad(float2 p, float t, float footprint) { - float2 grad, gradDt; - waterWaveSpectrum<0>(p, t, footprint, grad, gradDt); - return grad; -} - -float2 waterWaveGrad(float2 p, float t) { - return waterWaveGrad(p, t, 0.0); -} - -// Evaluate the current gradient and linearly rewind it to the previous phase in one spectrum walk. -// Frame deltas are bounded on the CPU, so this avoids a second set of trigonometric/exponential work -// while remaining accurate over the sub-frame interval used by reflection reprojection. -void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, - out float2 currentGrad, out float2 previousGrad) { - float2 gradDt; - waterWaveSpectrum<1>(p, currentT, footprint, currentGrad, gradDt); - previousGrad = currentGrad - (currentT - previousT) * WAVE_SPEED * gradDt; -} - -// Perturb a (near-)horizontal water surface normal by the wave field. Flowing side faces (|n.y| small) -// keep their flat geometric normal. `worldXZ` must be a world-stable coordinate (rebased hit + the pushed -// anchor) so the ripples stay pinned in the world. `nGeo` is already oriented toward the viewer, so the -// down-viewed underside (n.y < 0) takes the mirrored slope (-up). -float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { - if (abs(nGeo.y) < 0.5) return nGeo; - float2 grad = waterWaveGrad(worldXZ, t, footprint); - float3 up = normalize(float3(-grad.x, 1.0, -grad.y)); - return nGeo.y >= 0.0 ? up : -up; -} - -// ---- Water caustics. Sunlight refracting through the waved surface converges/diverges before it -// reaches an underwater receiver; the analytic wave field makes the true focusing factor computable -// instead of faked with a scrolling texture. Where the shadow ray of an underwater NEE vertex crossed -// water (returned by visibility() as VisibilityResult.waterHitT), evaluate the horizontal landing -// position of the refracted sun ray as a function of surface position and finite-difference it. The -// caustic intensity is the inverse Jacobian determinant of that surface→floor mapping (area compression = brightening, real -// fold caustics where det → 0). Because this uses the SAME wave field as the visible surface normals, -// the caustic pattern stays in sync with the ripples, and the per-sample sun-quad jitter (sampleSquare) -// shifts the pattern per sample → caustics physically blur with depth under DLSS-RR accumulation. -static const float CAUSTIC_EPS = 0.25; // finite-difference step (m); also low-passes sub-step wave detail -static const float CAUSTIC_MAX = 6.0; // focusing clamp: keeps fold-line singularities from becoming fireflies - -// Horizontal landing offset of a refracted sun ray entering the surface at world-stable xz, intersected -// with the receiver plane h metres below. inc = sunlight travel direction (downward, unit). -float2 causticLanding(float2 xz, float t, float3 inc, float h) { - float2 g = waterWaveGrad(xz, t); - float3 nw = normalize(float3(-g.x, 1.0, -g.y)); - float3 rdw = refract(inc, nw, 1.0 / WATER_IOR); // air→water: never TIR - return xz + rdw.xz * (h / max(-rdw.y, 0.05)); -} - -float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { - // Grazing light: the receiver-plane intersection degenerates and caustics vanish in reality anyway - // (long slant paths absorb + the pattern smears out) — fade the effect to neutral 1.0. - float fade = smoothstep(0.06, 0.18, lightDir.y); - if (fade <= 0.0) return 1.0; - float h = max(waterDist * lightDir.y, 0.05); // vertical depth of the receiver below the exit point - float t = worldPush.waterParams.w; - float3 inc = -lightDir; - float2 base = exitPos.xz + worldPush.waterAnchor.xy; // world-stable wave domain (same as applyWaterWaves) - float2 p0 = causticLanding(base, t, inc, h); - float2 px = causticLanding(base + float2(CAUSTIC_EPS, 0.0), t, inc, h); - float2 pz = causticLanding(base + float2(0.0, CAUSTIC_EPS), t, inc, h); - float det = abs((px.x - p0.x) * (pz.y - p0.y) - (px.y - p0.y) * (pz.x - p0.x)); - // Flat water ⇒ all landings displace identically ⇒ det = eps² ⇒ focus = 1 (energy-neutral); the - // clamp bounds the fold-line spikes whose energy the surrounding darkening already paid for. - float focus = (CAUSTIC_EPS * CAUSTIC_EPS) / max(det, 1.0e-5); - return lerp(1.0, min(focus, CAUSTIC_MAX), fade); -} - -// Below this GGX alpha the lobe is treated as a delta mirror. The threshold is not about detecting an -// authored zero — it is about the point where a finite lobe stops being distinguishable from a mirror. -// A GGX lobe's half-angle is ~alpha, and the sun's angular radius is ~0.0047 rad, so alpha = 4e-4 is -// ~12x tighter than the sun disc and far under a pixel footprint at any reflection distance. Nothing -// below it can be resolved, and the delta path is both sharper and better conditioned there. -// -// Two things follow from taking the delta path, and both are why the threshold is set here rather than -// at the 8-bit quantum: -// * ggxD(_, 0) is exactly 0, so the NEE specular term vanishes by construction and the sun arrives -// solely via the mirror ray hitting the disc (showCelestial). That is the correct, unbiased -// accounting — see the double-count note on ggxD below. -// * There is no floor to apply above the threshold: alpha > 4e-4 is well conditioned for both the -// VNDF sample and the NDF, so authored roughness passes through unmodified and the mapping stays -// continuous. A separate MIN_ROUGH would only reintroduce a cliff. -// -// Stored roughness IS alpha (see payloadRoughness), so these compare against it directly. -static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= 0.98 -// Above this the specular lobe is too broad for a single mirrored reprojection to describe, so the -// reflection MV is left at zero and RR falls back to the ordinary MV. -static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 -static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; -// Continuations one sample may carry: the camera segment, plus the reflection branch a primary -// dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. -static const uint MAX_PATH_SEGMENTS = 2u; - -float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } - -// GGX normal distribution. -// NOTE: the 1e-7 denominator guard is not merely a divide-by-zero epsilon, and it is load-bearing. At -// the lobe peak d == a2, so the guard dominates PI*d*d until a2 >> 1.8e-4, i.e. rough >> 0.116; every -// smoother material has its NEE highlight peak truncated. That truncation is what keeps the sun from -// being counted twice: a specular continuation sets showCelestial, so the BSDF-sampled ray may hit the -// sun disc in world.rmiss while NEE is also adding a sun term for the same lobe, with no MIS weighting -// between them. Removing the guard restores the physical peak AND the double count, and makes the -// single-sample sun estimator explode wherever the lobe is tighter than the disc (~0.0047 rad). The -// real fix is MIS (or a disc-aware specular light sample), not an epsilon edit — left as-is. -float ggxD(float ndh, float alpha) { - float a2 = alpha * alpha; - float d = ndh * ndh * (a2 - 1.0) + 1.0; - return a2 / (PI * d * d + 1.0e-7); -} - -// Smith GGX masking term for one direction (separable form; G2 = G1(v) * G1(l)). -float ggxG1(float ndx, float alpha) { - float a2 = alpha * alpha; - return 2.0 * ndx / (ndx + sqrt(a2 + (1.0 - a2) * ndx * ndx) + 1.0e-7); -} - -float3 fresnelSchlick(float cosT, float3 f0) { - float m = clamp(1.0 - cosT, 0.0, 1.0); - float m2 = m * m; - return f0 + (1.0 - f0) * (m2 * m2 * m); -} - -// Henyey–Greenstein phase function (with 4π normalisation so it integrates to 1 over the sphere). -// g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). -static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional -static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) -// Deepest SHADED surface at which the SSS transmission shadow ray fires. diffuseDepth, not `bounce`: the -// glass/water continue branches don't shade a surface, so foliage seen through a window is still -// diffuseDepth 0 and should backlight like any other primary leaf — see RIS_MAX_DIFFUSE_DEPTH for the -// same distinction and why it matters. -static const int MAX_SSS_DIFFUSE_DEPTH = 1; // fire on the first-shaded surface + one indirect hit -float hg(float cosT, float g) { - float g2 = g * g; - return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); -} - -// NVIDIA RR guide-buffer helper: integrated/view-dependent specular reflectivity, not raw F0. -// alpha is GGX alpha (== the linear roughness materials store), NoV is the first-hit view cosine. -float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { - NoV = abs(NoV); - float NoV2 = NoV * NoV; - float alpha2 = alpha * alpha; - float4 X = float4(1.0, NoV, NoV2, NoV * NoV2); - float4 Y = float4(1.0, alpha, alpha2, alpha * alpha2); - - float2 m1 = float2( - dot(float2(0.99044, -1.28514), X.xy), - dot(float2(1.29678, -0.755907), X.xy)); - float3 Xxyw = float3(X.x, X.y, X.w); - float3 m2 = float3( - dot(float3(1.0, 2.92338, 59.4188), Xxyw), - dot(float3(20.3225, -27.0302, 222.592), Xxyw), - dot(float3(121.563, 626.13, 316.627), Xxyw)); - float bias = dot(m1, Y.xy) / max(dot(m2, float3(Y.x, Y.y, Y.w)), 1.0e-7); - - float2 m3 = float2( - dot(float2(0.0365463, 3.32707), X.xy), - dot(float2(9.0632, -9.04756), X.xy)); - float3 Xxzw = float3(X.x, X.z, X.w); - float3 m4 = float3( - dot(float3(1.0, 3.59685, -1.36772), Xxzw), - dot(float3(9.04401, -16.3174, 9.22949), Xxzw), - dot(float3(5.56589, 19.7886, -20.2123), Xxzw)); - float scale = dot(m3, Y.xy) / max(dot(m4, float3(Y.x, Y.y, Y.w)), 1.0e-7); - - bias *= clamp(specularColor.g * 50.0, 0.0, 1.0); - return specularColor * max(0.0, scale) + float3(max(0.0, bias), max(0.0, bias), max(0.0, bias)); -} - -// Exact (unpolarized) Fresnel reflectance for a dielectric interface. cosI is the incidence cosine on -// the incoming side; etaI/etaT are the IORs the ray travels from / into. Returns 1.0 on total internal -// reflection (sin of the transmitted angle >= 1), which matches refract() returning the zero vector — -// so reflect/refract selection and TIR stay consistent. -float fresnelDielectric(float cosI, float etaI, float etaT) { - float sinT2 = (etaI * etaI) / (etaT * etaT) * max(0.0, 1.0 - cosI * cosI); - if (sinT2 >= 1.0) { - return 1.0; - } - float cosT = sqrt(1.0 - sinT2); - float rs = (etaI * cosI - etaT * cosT) / (etaI * cosI + etaT * cosT); - float rp = (etaI * cosT - etaT * cosI) / (etaI * cosT + etaT * cosI); - return 0.5 * (rs * rs + rp * rp); -} - -// PCG hash RNG. -uint pcg(inout uint s) { - s = s * 747796405u + 2891336453u; - uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; - return (w >> 22u) ^ w; -} -float rndf(inout uint s) { - // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first - // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` - // tests (most seriously the F == 1 total-internal-reflection branch). - return float(pcg(s) >> 8u) * (1.0 / 16777216.0); -} - -float3 primaryRayDir(float2 ndc) { - float4 nearH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 1.0, 1.0)); - float4 farH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 0.0, 1.0)); - float3 nearP = nearH.xyz / nearH.w; - float3 farP = farH.xyz / farH.w; - return normalize(farP - nearP); -} - -float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { - float2 onePixelNdc = 2.0 / size; - float3 dx = primaryRayDir(ndc + float2(onePixelNdc.x, 0.0)); - float3 dy = primaryRayDir(ndc + float2(0.0, onePixelNdc.y)); - return max(max(length(cross(dir, dx)), length(cross(dir, dy))), RAY_CONE_MIN_SPREAD); -} - -// Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the -// importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation -// throughput is just *= albedo with no PI/cos bookkeeping left over. -float3 cosineDir(float3 n, inout uint s) { - float u1 = rndf(s); - float u2 = rndf(s); - float r = sqrt(u1); - float phi = 6.2831853 * u2; - float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); - float3 b = normalize(cross(n, t)); - t = cross(b, n); - float3 local = float3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - u1))); - return normalize(local.x * t + local.y * b + local.z * n); -} - -// Soft shadows: sample a direction within the light's SQUARE angular extent about `axis`. MC's -// sun/moon are square quads, so the NEE shadow ray samples a square (not a cone) — the same square the -// visible disc in world.rmiss uses, built from the same celestial tangent frame (right = arc-travel -// direction, from worldPush.celestial.xyz). Averaged over frames by DLSS-RR this yields soft penumbrae that -// widen with occluder distance (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). -float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { - float3 right = normalize(cross(axis, worldPush.celestial.xyz)); - float3 up = cross(right, axis); - float t = tan(halfAngle); - float u = (rndf(s) * 2.0 - 1.0) * t; - float v = (rndf(s) * 2.0 - 1.0) * t; - return normalize(axis + u * right + v * up); -} - -// Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the -// sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the -// importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). -float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { - float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); - float3 b = normalize(cross(n, t)); - t = cross(b, n); - float3 Ve = float3(dot(ve, t), dot(ve, b), dot(ve, n)); // view in tangent space (z = n) - float a = alpha; - float3 Vh = normalize(float3(a * Ve.x, a * Ve.y, Ve.z)); - float lensq = Vh.x * Vh.x + Vh.y * Vh.y; - float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); - float3 T2 = cross(Vh, T1); - float u1 = rndf(s); - float u2 = rndf(s); - float r = sqrt(u1); - float phi = 6.2831853 * u2; - float p1 = r * cos(phi); - float p2 = r * sin(phi); - float ss = 0.5 * (1.0 + Vh.z); - p2 = (1.0 - ss) * sqrt(1.0 - p1 * p1) + ss * p2; - float3 Nh = p1 * T1 + p2 * T2 + sqrt(max(0.0, 1.0 - p1 * p1 - p2 * p2)) * Vh; - float3 Ne = normalize(float3(a * Nh.x, a * Nh.y, max(0.0, Nh.z))); // microfacet normal, tangent space - return normalize(Ne.x * t + Ne.y * b + Ne.z * n); // -> world space -} - -// ===== RIS emitter NEE ================================================================= -// Block emitters (torches, glowstone, lava, ...) get explicit NEE: per diffuse vertex, stream M uniform -// candidates from the global light buffer through a weighted reservoir keyed by the UNSHADOWED target -// p-hat = luminance(f * Le * G * area), then spend ONE shadow ray on the survivor. Sun/moon NEE stays -// separate and additive. Lights are footprint bounding RECTANGLES (RtLightCollector): sampling a point -// uniformly over the rectangle has pdf 1/area, so `area` rides the contribution like any area light. -// Single-frame RIS only (no temporal reservoir reuse): DLSS-RR's own history already denoises this well -// enough that a persistent per-pixel reservoir wasn't worth the added complexity and VRAM. -// One resample serves three mutually exclusive receiver terms (see evalSampleContrib): front BRDF, -// two-sided particle billboards, and LabPBR SSS backscatter from lights BEHIND the surface — all share -// the reservoir and its single shadow ray, whose origin follows the survivor's side of the surface. - -// A reservoir in registers. pos/lnrm/le/area describe the chosen emitter sample; W is its unbiased -// contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted). -struct Reservoir { - float3 pos; // light sample point (rebased world space) - float3 lnrm; // emitter outward normal - float3 le; // emitter radiance - float area; // emitter rectangle area (the area-light pdf factor) - float M; - float W; - float wSum; - float phat; -}; - -Reservoir resEmpty() { - Reservoir r; - r.pos = float3(0.0, 0.0, 0.0); - r.lnrm = float3(0.0, 0.0, 0.0); - r.le = float3(0.0, 0.0, 0.0); - r.area = 0.0; - r.M = 0.0; - r.W = 0.0; - r.wSum = 0.0; - r.phat = 0.0; - return r; -} - -// Unshadowed contribution of a fixed light sample point at surface (hitPos,n,v); out p-hat = its -// luminance (the resampling target). Evaluated from the UNBIASED hitPos — SURF_BIAS is applied only to -// the shadow-ray origin in shadeReservoir, on whichever side the survivor sample lands. Three receiver -// modes share one target function because they are mutually exclusive per candidate: -// - front hemisphere (ndl>0): the same Lambert(+GGX) BRDF split the sun NEE uses; -// - twoSided (particles): billboards have no back face, ndl=abs(ndl) folds both sides into the front; -// - back hemisphere with sss>0 (LabPBR leaves/grass): HG transmission phase — cosT = dot(wi,rd) peaks -// when the ray points from the light through the slab toward the camera (same as the sun SSS term). -float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n, - float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, - bool twoSided, float sss, out float phat) { - phat = 0.0; - float3 toL = sp - hitPos; - float dist2 = dot(toL, toL); - if (dist2 < 1.0e-6) { - return float3(0.0, 0.0, 0.0); - } - float dist = sqrt(dist2); - float3 wi = toL / dist; - float cosL = max(0.0, dot(lnrm, -wi)); - if (cosL <= 0.0) { - return float3(0.0, 0.0, 0.0); // receiver behind the emitter face - } - float ndl = dot(n, wi); - if (twoSided) { - ndl = abs(ndl); - } - float3 contrib = float3(0.0, 0.0, 0.0); - if (ndl > 0.0) { - float G = ndl * cosL / dist2; // area-light geometry term (x area) - float3 brdf = diffAlb * INV_PI; - // twoSided callers are particle billboards, which pass rough=1/F0=0 and want diffuse-only - // shading: fresnelSchlick still returns up to full white at grazing angles even with F0=0 (that - // IS the Fresnel effect), so skipping the term here — not just zeroing F0 — is what keeps - // billboards from picking up a rim highlight they were never meant to have. - if (!twoSided) { - float3 h = normalize(wi + v); - float ndh = max(0.0, dot(n, h)); - float ndv = max(1.0e-4, dot(n, v)); - float vdh = max(0.0, dot(v, h)); - float D = ggxD(ndh, rough); - float Gs = ggxG1(ndv, rough) * ggxG1(ndl, rough); - float3 Fs = fresnelSchlick(vdh, F0); - brdf += (D * Gs) * Fs / (4.0 * ndv * ndl); - } - contrib = brdf * le * (G * area); - } else if (sss > 0.0) { - float backNdl = -ndl; - float G = backNdl * cosL / dist2; - float cosT = dot(wi, rd); - contrib = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * le * (G * area); - } - phat = luminance(contrib); - return contrib; -} - -// Select one light from the emitted-power distribution in O(1). -void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); - uint column = min(uint(aliasSample), worldPush.lightCount - 1u); - if (pc.lightAliasAddr != 0) { - LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; - bool self = aliasSample - float(column) < a.accept; - lightIndex = self ? column : a.aliasIndex; - } else { - lightIndex = column; - } -} - -bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { - cell.spanOffset = 0u; - cell.spanCount = 0u; - cell.invWeightSum = 0.0; - cellCoord = int3(0, 0, 0); - if (pc.lightGridCellAddr == 0 || pc.lightGridSpanAddr == 0) return false; - int3 coord = int3(floor((p - worldPush.lightGridOrigin.xyz) / worldPush.lightGridOrigin.w)); - if (coord.x < 0 || coord.y < 0 || coord.z < 0 - || coord.x >= worldPush.lightGridDims.x - || coord.y >= worldPush.lightGridDims.y - || coord.z >= worldPush.lightGridDims.z) return false; - uint linear = (uint(coord.z) * uint(worldPush.lightGridDims.y) + uint(coord.y)) - * uint(worldPush.lightGridDims.x) + uint(coord.x); - cell = ConstPtr(pc.lightGridCellAddr)[linear]; - cellCoord = coord; - return cell.spanCount > 0u; -} - -void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, - out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(lightCount); - uint column = min(uint(aliasSample), lightCount - 1u); - LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; - bool self = aliasSample - float(column) < alias.accept; - uint localIndex = self ? column : alias.aliasIndex; - lightIndex = firstLight + localIndex; -} - -float unpackUnsignedFloat(uint bits, uint mantissaBits) { - uint mantissaMask = (1u << mantissaBits) - 1u; - uint mantissa = bits & mantissaMask; - uint exponent = (bits >> mantissaBits) & 31u; - if (exponent == 0u) { - return float(mantissa) * exp2(float(1 - 15 - int(mantissaBits))); - } - return (1.0 + float(mantissa) / float(1u << mantissaBits)) - * exp2(float(int(exponent) - 15)); -} - -float3 lightRadiance(Light light) { - uint packed = light.le; - return float3(unpackUnsignedFloat(packed & 0x7ffu, 6u), - unpackUnsignedFloat((packed >> 11u) & 0x7ffu, 6u), - unpackUnsignedFloat((packed >> 22u) & 0x3ffu, 5u)); -} - -int3 lightSectionCoord(Light light) { - uint packed = light.section; - return int3(int(packed & 0x3ffu), int((packed >> 10u) & 0x3ffu), - int((packed >> 20u) & 0x3ffu)); -} - -// The half axes share three lanes two-at-a-time; see the Light record in world_common. -float3 lightHalfU(Light light) { - return float3(unpackHalf2(light.halfUxy), unpackHalf2(light.halfUzVx).x); -} - -float3 lightHalfV(Light light) { - return float3(unpackHalf2(light.halfUzVx).y, unpackHalf2(light.halfVyz)); -} - -// U x V drives both the emitter normal and the rectangle area, so it is computed once and shared. -float3 lightCrossUV(Light light) { - return cross(lightHalfU(light), lightHalfV(light)); -} - -// The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store. -float lightArea(Light light) { - return 4.0 * length(lightCrossUV(light)); -} - -float3 lightGeometricNormal(Light light) { - float3 normal = normalize(lightCrossUV(light)); - return (light.section & 0x40000000u) != 0u ? -normal : normal; -} - -float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, - float localProbability) { - float power = max(0.0, lightArea(light) * luminance(le)); - float globalPdf = pc.lightAliasAddr != 0 - ? power * worldPush.lightRebase.w : 1.0 / float(worldPush.lightCount); - float localPdf = 0.0; - if (localProbability > 0.0 && power > 0.0) { - int3 delta = lightSectionCoord(light) - cellCoord; - if (all(abs(delta) <= int3(2, 2, 2))) { - float3 deltaF = float3(delta); - localPdf = power * cell.invWeightSum / max(1.0, dot(deltaF, deltaF)); - } - } - return localProbability * localPdf + (1.0 - localProbability) * globalPdf; -} - -void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, - out uint lightIndex) { - ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); - float aliasSample = rndf(proposalSeed) * float(cell.spanCount); - uint column = min(uint(aliasSample), cell.spanCount - 1u); - LightGridSpan span = spans[cell.spanOffset + column]; - bool self = aliasSample - float(column) < span.accept; - uint firstLight = self ? span.firstLight : span.aliasFirstLight; - uint lightCount = self ? (span.packedLightCounts & 0xffffu) - : (span.packedLightCounts >> 16u); - selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex); -} - -// Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section -// by emitted power and section distance, then its section-local power alias. Every light in -// the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. -// Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single -// Light48 load: section power cancels with the section-local light PDF. -void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, - out uint lightIndex) { - if (useLocal) { - selectLightGridSpanLight(cell, proposalSeed, lightIndex); - } else { - selectGlobalLight(proposalSeed, lightIndex); - } -} - -// ---- RIS cost budget. Both constants below come from a set of temporary probes that pinned the -// candidate walk out of the shader to bound what memory-side RIS work could ever be worth. Against an -// 18.0ms frame at M=8, removing every dependent load (span -> alias -> record) and all lane divergence -// saved 5.9ms — a third of the frame. Split by vertex that was ~4.3ms at secondary vertices against -// ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost -// was chasing DEPTH, not divergence, and it lived at secondary vertices. The two knobs below spend that -// finding; a presampled candidate pool would attack the same 5.9ms structurally, but see the note on -// proposalSeed in tracePath for why it should share the pool rather than the seed. - -// Candidate-count divisor for RIS at secondary vertices (bounce > 0). Secondary vertices carried ~78% of -// the cost while being much the more forgiving place to spend variance: that radiance is already -// integrated over a diffuse lobe before the denoiser sees it. 18.0 -> 16.1ms, and indistinguishable from -// 1 by eye — whereas reducing M globally to 2 was clearly worse, because that also degraded the primary -// hit. The primary hit always keeps the full worldPush.risCandidates. -// -// 4 is the floor worth using rather than a tuning accident. The stratification below keeps at least one -// global candidate, so M=2 is the smallest count that still schedules one local AND one global; at M=1 -// localProbability collapses to 0 and the light-grid proposal that makes nearby emitters sample well is -// gone entirely. -static const uint SECONDARY_RIS_DIVISOR = 4u; - -// Deepest SHADED surface at which RIS emitter NEE runs. Past it, emitters are gathered only when a path -// hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep, so the -// failure mode is noisier emitter light at depth rather than missing light. Unlike the divisor this also -// drops shadeReservoir's per-vertex shadow ray, which never scaled with M. Gating at all: 16.1 -> 15.7ms. -// -// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_DIELECTRIC and -// MATERIAL_WATER continue without shading anything, so looking through a pane spends bounce 0 on the -// glass and lands the first shaded surface at bounce 1. Gating on `bounce` therefore starved surfaces -// seen through glass or water — visually primary, but counted as depth — and they went black, since only -// a path randomly striking an emitter could light them. Keying on shaded surfaces removes that whole -// class of bug and lets the budget go lower than the two bounces of headroom the dielectric prefix used -// to cost. -// -// Caveat this does not solve: a smooth specular bounce IS a shaded surface, so a wall seen in a metal -// block counts as depth 1. Accumulated roughness rather than a vertex count is the principled fix, and -// is what a radiance cache would need anyway to decide cache-vs-trace. -static const uint RIS_MAX_DIFFUSE_DEPTH = 2u; - -// Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen -// sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. -Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, - float rough, bool twoSided, float sss, uint shadedDepth, - inout uint seed, inout uint proposalSeed) { - Reservoir r = resEmpty(); - LightGridCell gridCell; - int3 gridCellCoord; - float3 gridLookup = hitPos; - if (pc.lightGridCellAddr != 0) { - // Stochastically blend across hard section boundaries. Every cell proposal retains full global - // support, so conditioning on this independently jittered lookup remains unbiased. - gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5) - * worldPush.lightGridOrigin.w; - } - bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); - // shadedDepth, not the bounce index: a surface behind glass or water is the first thing this path has - // shaded and is visually primary, so it keeps the full candidate count. - uint candidateCount = shadedDepth == 0u - ? worldPush.risCandidates - : max(1u, worldPush.risCandidates / SECONDARY_RIS_DIVISOR); - // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six - // local and two global candidates, with every lane taking the same branch for a given candidate. - // Counts that are not divisible by four use the nearest practical split with at least one global - // candidate; M=1 is global-only so the estimator retains full support. - uint globalCandidateCount = hasGridCell - ? max(1u, (candidateCount + 2u) / 4u) : candidateCount; - uint localCandidateCount = candidateCount - globalCandidateCount; - float localProbability = float(localCandidateCount) / float(candidateCount); - for (uint c = 0u; c < candidateCount; c++) { - r.M += 1.0; - uint li; - uint globalsBefore = (c * globalCandidateCount) / candidateCount; - uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; - bool useLocal = hasGridCell && globalsAfter == globalsBefore; - selectLightGridLight(gridCell, useLocal, proposalSeed, li); - Light lg = ConstPtr(pc.lightBufAddr)[li]; - // Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area). - float s = rndf(seed) * 2.0 - 1.0; - float t = rndf(seed) * 2.0 - 1.0; - float3 sp = lg.pos + worldPush.lightRebase.xyz - + s * lightHalfU(lg) + t * lightHalfV(lg); - float3 le = lightRadiance(lg); - float3 lightNormal = lightGeometricNormal(lg); - float area = lightArea(lg); - float phat; - evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd, - diffAlb, F0, rough, twoSided, sss, phat); - if (phat <= 0.0) { - continue; - } - float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability); - float w = phat / max(sourcePdf, 1.0e-20); - r.wSum += w; - if (rndf(seed) * r.wSum < w) { // weighted reservoir update - r.pos = sp; - r.lnrm = lightNormal; - r.le = le; - r.area = area; - r.phat = phat; - } - } - r.W = r.phat > 0.0 ? (r.wSum / (r.M * r.phat)) : 0.0; - return r; -} - -// Shade a finalized reservoir: recompute the survivor's contribution at (hitPos,n,v), cast ONE shadow -// ray, and return throughput-free radiance contrib*vis*W. The one ray serves whichever term fired for -// the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the -// sample's side of the surface. -float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, - float3 F0, float rough, bool twoSided, float sss, out float3 vis) { - vis = float3(0.0, 0.0, 0.0); - if (s.W <= 0.0 || s.phat <= 0.0) { - return float3(0.0, 0.0, 0.0); - } - float phat; - float3 contrib = evalSampleContrib(s.pos, s.lnrm, s.le, s.area, hitPos, n, v, rd, - diffAlb, F0, rough, twoSided, sss, phat); - if (phat <= 0.0) { - return float3(0.0, 0.0, 0.0); - } - // Pick the shadow-ray origin on the sample's side of the surface FIRST, then measure the ray from - // that origin — measuring dist from hitPos while tracing from the biased origin shifts the ray tip - // up to SURF_BIAS*ndl toward the emitter plane, beating the 0.001*dist stop-short margin for lights - // closer than ~SURF_BIAS/0.001 blocks -> shadow ray hits the emitter's own face -> per-texel black. - float side = dot(n, s.pos - hitPos) >= 0.0 ? 1.0 : -1.0; - float3 origin = hitPos + side * n * SURF_BIAS; - float3 toL = s.pos - origin; - float dist = length(toL); - // Stop just short of the sample point so the ray doesn't self-occlude on the emitter's own face. - VisibilityResult shadow = visibility(origin, toL / dist, dist * 0.999); - vis = shadow.transmittance; - return contrib * vis * s.W; -} - -// TLAS instance-mask bits (set on the Java side, ANDed against these per-ray cull masks): -// bit 0 (0x01) — secondary rays: shadows, GI bounces, reflections. -// bit 1 (0x02) — the primary camera ray. -// Terrain / entities / block entities keep 0xFF (both bits). Particles use 0x02: primary-visible -// receiver billboards only. The first-person camera owner uses 0x01 (secondary only). -static const uint CULL_SECONDARY = 0x01u; -static const uint CULL_PRIMARY = 0x02u; -static const uint TERRAIN_BUCKETS = 4u; -static const uint SBT_RADIANCE = 0u; -static const uint SBT_SHADOW = TERRAIN_BUCKETS; -static const uint SBT_STRIDE_BUCKET = 1u; -static const uint MISS_RADIANCE = 0u; -static const uint MISS_GUIDE = 1u; - -RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { - RayDesc r; - r.Origin = origin; - r.TMin = tmin; - r.Direction = dir; - r.TMax = tmax; - return r; -} - -float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, - float3 outgoingDirection, float bias) { - float3 sideNormal = dot(surfaceNormal, outgoingDirection) >= 0.0 - ? surfaceNormal : -surfaceNormal; - return position + sideNormal * bias; -} - -// A radiance payload carrying only the two words the invoked hit/miss shader READS: `flags` -// (show-celestial gate, consumed by world.rmiss) and the packed ray cone (consumed by world.rchit for -// texture LOD). Every other member is an OUTPUT that world.rchit/world.rmiss write, so it carries no -// information into a trace and is initialized to a neutral value here. -Payload makeRadiancePayload(uint flags, uint rayCone) { - Payload p; - p.albedo = half3(0.0h, 0.0h, 0.0h); - p.normal = half3(0.0h, 0.0h, 0.0h); - p.hitT = -1.0; // miss sentinel: world.rmiss writes only albedo, so a miss leaves this negative - p.motionPrev = half3(0.0h, 0.0h, 0.0h); - p.f0 = half3(0.0h, 0.0h, 0.0h); - p.flags = flags; - p.roughMetal = 0u; - p.emissionSss = 0u; - p.iorTransmission = 0u; - p.rayCone = rayCone; - return p; -} - -// SER radiance trace: trace into a hit object, reorder threads by hit coherence, THEN invoke the -// hit/miss shader — this is where the divergent Section/Prim shading work (world.rchit/world.rahit) -// actually happens, so reordering before it is what pays for the reorder's own cost. -// -// The payload is built TWICE from the trace state rather than carried across ReorderThread. Traversal -// runs only world.rahit, which never writes a RADIANCE payload: its terrain tint paths live in the -// translucent/water buckets, which carry no any-hit record for radiance rays, and its entity paths are -// gated on RAY_FLAG_SKIP_CLOSEST_HIT_SHADER. So whatever TraceRay leaves in the payload holds no -// information. Reading it back and carrying it over would pin all ten members — 72 bytes — in registers -// across the one point where SER must spill every live value, which profiling showed to be the peak -// live state in this shader. Rebuilding costs a few constant moves and leaves only these two words -// spanning the reorder. -void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - bool showCelestial, float rayConeWidth, float rayConeSpread) { - uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; - uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); - Payload tracePayload = makeRadiancePayload(flags, rayCone); - HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, - SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, - makeRay(ro, tmin, rd, tmax), tracePayload); - ReorderThread(hObj); - payload = makeRadiancePayload(flags, rayCone); - HitObject::Invoke(topLevelAS, hObj, payload); -} - -// Auxiliary RR probes deliberately use ordinary TraceRay. They are one-off, incoherent rays whose -// result is consumed immediately, so paying a reorder barrier and carrying guide state across it only -// adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, -// not sky radiance. -void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - float rayConeWidth, float rayConeSpread) { - payload = makeRadiancePayload(0u, packHalf2(float2(rayConeWidth, rayConeSpread))); - TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, - MISS_GUIDE, makeRay(ro, tmin, rd, tmax), payload); -} - -Payload makeShadowPayload() { - Payload shadowPayload; - shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); - shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit - shadowPayload.normal = half3(0.0h, 0.0h, 0.0h); - shadowPayload.motionPrev = half3(0.0h, 0.0h, 0.0h); - shadowPayload.f0 = half3(0.0h, 0.0h, 0.0h); - shadowPayload.flags = 0u; - shadowPayload.roughMetal = 0u; - shadowPayload.emissionSss = 0u; - shadowPayload.iorTransmission = 0u; - shadowPayload.rayCone = 0u; - return shadowPayload; -} - -VisibilityResult visibility(float3 origin, float3 dir, float tmax) { - // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow - // path uses only albedo as accumulated transmittance and hitT as the nearest-water crossing. - Payload shadowPayload = makeShadowPayload(); - // Shadow SBT records run any-hit only for cutout/translucent/water. Cutout alpha-tests; translucent - // and water tint shadowPayload.albedo and pass through. Solid blocks terminate traversal. There is no - // closest or miss shader worth executing, so use a hit object only as the traversal result. - HitObject hObj = HitObject::TraceRay(topLevelAS, - RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER, - CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, 0u, - makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); - VisibilityResult result; - result.transmittance = hObj.IsMiss() ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); - result.waterHitT = shadowPayload.hitT; - return result; -} - -// `valid` is false when the point projects behind the previous camera, where the perspective divide -// would otherwise manufacture an arbitrarily large NDC. Mirror images reach that case easily: a virtual -// image sits as far behind the reflector as the source is in front, so a reflection seen at a grazing -// angle can land behind the eye even though the reflector itself is comfortably in view. -float2 projectPrevNdc(float3 worldPos, out bool valid) { - float4 clip = mul(worldPush.prevViewProj, float4(worldPos - worldPush.camOffset + worldPush.camDelta, 1.0)); - valid = clip.w > 0.0; - return valid ? clip.xy / clip.w : float2(0.0, 0.0); -} - -// Previous-frame screen position of a planar reflection. The reflected image of a world point P seen -// in a planar reflector is the MIRROR image V = mirror(P) across the surface plane: the eye sees V -// along a straight line, so the reflection appears at proj(V). Project the mirror image directly. -// Gotcha: intersecting a reflection ray with the surface plane divides by dot(mirrorDir, n), which -// explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so -// static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); -// reflectedMotionPrev carries the reflected content's own displacement. -float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, - float3 reflectedMotionPrev, out bool valid) { - float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed - float3 prevHit = reflectedWorldPos - reflectedMotionPrev; - float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - surfacePos, n); - return projectPrevNdc(mirroredHit, valid); -} - -// Reflection motion remains owned by the physical foreground interface even when transmission later -// replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). -float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, - float2 currentNdc, float2 size, float primaryConeSpread) { - if (length(surface.normal) < 0.5 - || max(surface.albedo.r, max(surface.albedo.g, surface.albedo.b)) <= 0.001 - || surface.roughness > SPEC_MOTION_ALPHA_MAX) { - return float2(0.0, 0.0); - } - - float3 surfacePos = surface.camRel + worldPush.camOffset; - float3 n = normalize(surface.normal); - float3 specDir = reflect(primaryDir, n); - // biasNormal is a closest-hit normal, already unit length, and only its sign matters here. - traceGuide(CULL_SECONDARY, - offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), - RAY_TMIN, specDir, 10000.0, - max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), - max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); - - float3 reflectedHit; - float3 reflectedMotionPrev; - if (payload.hitT > 0.0) { - reflectedHit = surfacePos + specDir * payload.hitT; - reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE - ? payload.motionPrev : float3(0.0, 0.0, 0.0); - } else { - reflectedHit = surfacePos + specDir * 1.0e6; - reflectedMotionPrev = float3(0.0, 0.0, 0.0); - } - float3 previousN = length(surface.previousNormal) >= 0.5 - ? normalize(surface.previousNormal) : n; - bool prevValid; - float2 prevNdc = previousReflectionNdc( - surfacePos, previousN, reflectedHit, reflectedMotionPrev, prevValid); - return prevValid ? (prevNdc - currentNdc) * 0.5 * size : float2(0.0, 0.0); -} - -// Deterministic transmitted destination for clear-interface RR guides. The first interface has already -// been crossed. Continue through every crossed dielectric until opaque/particle content or sky is reached; -// TIR changes the walk into a reflected secondary ray instead of producing a false foreground guide. -void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, - float roughness, float3 diffuseAlbedo) { - gv_hitCamRel = hitCamRel; - gv_motionHitCamRel = hitCamRel; - gv_motionObjDisp = motionPrev; - gv_motionUseRefracted = true; - gv_normal = normal; - gv_rough = roughness; - gv_albedo = diffuseAlbedo; -} - -void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, - float3 surfaceBiasNormal, - float currentIor, float rayBias, - float rayConeWidth, float rayConeSpread) { - if (dot(transmittedDir, transmittedDir) <= 0.0) return; - - float3 direction = normalize(transmittedDir); - // surfaceBiasNormal is a closest-hit normal, already unit length, and only its sign matters here. - float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); - currentIor = max(currentIor, 1.0); - - for (uint crossing = 0u; crossing < MAX_TRANSMISSION_GUIDE_HITS; ++crossing) { - traceGuide(CULL_PRIMARY, ro, RAY_TMIN, direction, 10000.0, - rayConeWidth, rayConeSpread); - if (payload.hitT <= 0.0) { - setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, - float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, SKY_DIFF_ALBEDO); - return; - } - - uint material = payloadMaterial(); - float3 interfacePos = ro + direction * payload.hitT; - if (material == MATERIAL_OPAQUE || material == MATERIAL_PARTICLE) { - float endpointRoughness = material == MATERIAL_PARTICLE - ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); - float3 endpointAlbedo = material == MATERIAL_PARTICLE - ? payload.albedo - : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); - setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, - payload.normal, endpointRoughness, endpointAlbedo); - return; - } - - if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; - - // Mirror the radiance path's refraction. Absorption inside the crossed media is deliberately NOT - // folded into the albedo guide: RR demodulates by albedo, and a distance-dependent attenuation - // baked into it would disagree with the colour, which is where the extinction actually lands. - float3 interfaceNormal = normalize(payload.normal); - float3 interfaceBiasNormal = interfaceNormal; - if (material == MATERIAL_WATER && (worldPush.flags & 16u) != 0u) { - float waterFootprint = rayConeWidth - / max(abs(dot(-direction, interfaceBiasNormal)), 0.2); - interfaceNormal = applyWaterWaves(interfaceNormal, - interfacePos.xz + worldPush.waterAnchor.xy, - worldPush.waterParams.w, waterFootprint); - } - // Exiting returns to air: the walk keeps only the current index, which is the same depth-1 - // approximation the guide already made and is exact for a single enclosing volume. - float targetIor = payloadDielectricEntering() ? max(payloadIor(), 1.0) : 1.0; - float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); - if (dot(nextDirection, nextDirection) <= 0.0) { - // Total internal reflection: there is no transmitted destination to describe, and following - // the reflection instead would be actively harmful. setTransmissionGuide marks its endpoint - // as its own feature, whose MV is that point's own reprojection delta — valid for a - // refracted point (the near-constant refraction offset cancels between frames) but WRONG for - // a mirror image, which sweeps at roughly twice the camera's rate and in the opposite sense. - // That produced motion vectors that grew with camera motion. - // - // Bailing leaves the foreground interface tuple the caller already wrote, which is what TIR - // physically deserves: F is 1, so specular albedo is 1 and diffuse albedo is 0, the pixel is - // pure reflection, and gSpecMotion already describes it with a proper mirror-image - // reprojection. Depth stays on the interface, which is the only real surface here. - return; - } - direction = normalize(nextDirection); - currentIor = targetIor; - ro = offsetSurfaceOrigin( - interfacePos, interfaceBiasNormal, direction, SURF_BIAS); - } -} - -// One Monte Carlo path from the continuation produced by the primary pass. -// Deterministic dielectric splitting is deliberately disabled for M1; M2 will append the second branch. -float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { - float3 L = float3(0.0, 0.0, 0.0); - float3 ro = seg.ro; - float3 rd = seg.rd; - float3 throughput = seg.throughput; - uint seed = seg.seed; - float rayConeWidth = seg.rayConeWidth; - float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); - int maxBounces = int(worldPush.maxBounces); - int rrStart = maxBounces <= 3 ? 1 : 2; - // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the - // candidate count is non-zero. Off => emitters are gathered only on path hits (legacy behaviour). - bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && pc.lightBufAddr != 0; - // Independent light-proposal RNG. Screen-space coherent selection is intentionally deferred until - // there is a real presampled RIS tile buffer; direct sharing made tile-shaped lighting noise visible. - // Measured: making a 16x16 tile share this seed recovers at most 1.3ms, so a future pool should share - // the POOL rather than the seed — the win is in removing dependent loads, not in coherence. - uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u - ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; - proposalSeed = pcg(proposalSeed); - // The medium the segment starts in. For the camera segment that is water when the eye is submerged - // (so the first ray already carries the right relative index and absorption); for a segment split off - // a dielectric it is whatever the parent was travelling through. - MediumStack medium = seg.medium; - bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation - // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned - // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from - // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc - // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. - bool showCelestial = seg.showCelestial; - // Number of surfaces this path has SHADED so far, as distinct from `bounce`, which also counts - // dielectric interfaces. Glass and water continue without shading anything, so a wall behind a pane - // is bounce 1 but diffuseDepth 0 — visually primary, and it must be treated as such. See - // RIS_MAX_DIFFUSE_DEPTH. - int diffuseDepth = seg.diffuseDepth; - for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { - // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to - // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. - // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person - // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. - traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); - - if (payload.hitT < 0.0) { - // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The - // packed show-celestial flag decides whether this path segment may see the bright disc. - float3 sky = payload.albedo; - L += throughput * sky; // escaped to sky - break; - } - - // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies - // to every hit reached while inside a volume dielectric (its own exit face, or whatever content - // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so - // the ordinary case costs one comparison. - if (any(medium.current.extinction > 0.0)) { - throughput *= exp(-medium.current.extinction * payload.hitT); - } - - float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit - float3 hitPos = ro + rd * payload.hitT; - rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); - uint material = payloadMaterial(); - - // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every - // dielectric is a volume. Fresnel from the relative index across the face, then reflect or - // refract; no diffuse response and no sun NEE, because the surface is specular. Transmission - // pushes or pops a participating medium, and the segment travelled inside it is attenuated by - // that medium's extinction rather than by a tint multiply at the interface. - // - // Water is the only material that adds anything: the animated wave normal, and a medium marked - // for the caustic term. Which side of the face we are on comes from the geometry - // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest - // of the path. - if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { - bool isWater = material == MATERIAL_WATER; - bool entering = payloadDielectricEntering(); - float3 tint = payload.albedo; - float transmission = clamp(payloadTransmission(), 0.0, 1.0); - - // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken - // reflections, wobbling refraction. The wave domain is world-stable: rebased hit XZ plus the - // pushed anchor (terrain origin mod 4096) survives the per-move terrain rebase. - float3 geometricNormal = n; // unrippled; ray origins offset along this - if (isWater && waterWaves) { - float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; - float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); - n = applyWaterWaves(geometricNormal, waterDomain, - worldPush.waterParams.w, waterFootprint); - } - - // The medium this face opens into, and the one it returns to on the way out — which is what - // the stack remembers. - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); - float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.ior; - - float cosI = clamp(dot(-rd, n), 0.0, 1.0); - float F = fresnelDielectric(cosI, etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); - // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a - // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full - // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water - // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. - float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - - bool chooseReflection = rndf(seed) < F; - if (chooseReflection) { - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); - } else { - if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen - rd = normalize(transmittedDir); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); - // Crossing into or out of the volume. Absorption is the medium's job from here, so the - // tint is NOT also multiplied into the throughput — that would double-count it. - if (entering) { - mediumPush(medium, entered); - } else { - mediumPop(medium); - } - } - showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - break; - } - throughput /= q; - } - continue; - } - - // Particles (material == 2): camera-facing receiver billboard. The instance mask keeps particles - // off secondary rays, so this only fires at bounce 0; they receive direct light and one diffuse - // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. - if (material == MATERIAL_PARTICLE) { - float3 albedo = payload.albedo; - - float3 lightDir = worldPush.lightDir.xyz; - float lightHalfAngle = worldPush.lightDir.w; - if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); - } - // Billboards are effectively two-sided receivers. Bias from the side facing the light; shadow - // rays exclude particles anyway, but this keeps the origin sane when the light is behind the - // camera side. - float signedNdl = dot(n, lightDir); - float ndl = abs(signedNdl); - if (ndl > 0.0) { - float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; - float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; - if (max(vis.r, max(vis.g, vis.b)) > 0.0) { - L += throughput * albedo * INV_PI * worldPush.lightRadiance.xyz * ndl * vis; - } - } - - // RIS direct lighting from block emitters (lava, glowstone, torches, ...) — same reservoir - // sampler as terrain/entities, but two-sided: a billboard has no back face, so light - // striking either side of the quad should still land (matches the sun/moon NEE above). - if (risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH)) { - float3 v = -rd; - Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, uint(diffuseDepth), seed, proposalSeed); - float3 risVis; - L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, risVis); - } - - if (bounce >= maxBounces) { - break; - } - throughput *= albedo; - ro = hitPos + n * SURF_BIAS; - rd = cosineDir(n, seed); - rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); - showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above - diffuseDepth++; // a billboard is a shaded surface, unlike the dielectric branches - continue; - } - - float3 albedo = payload.albedo; - float sss = payloadSss(); // LabPBR SSS strength (0 when absent) - float3 p = hitPos + n * SURF_BIAS; - float3 v = -rd; // view direction (toward the camera / incoming ray) - - // Material split. Metals tint F0 by albedo and have no diffuse lobe; dielectrics use a fixed - // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). - float rough = clamp(payloadRoughness(), 0.0, 1.0); - float metal = clamp(payloadMetalness(), 0.0, 1.0); - // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. - // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the - // reflection sharp instead of accumulating it as a glossy lobe. - bool exactSpecular = rough <= MIRROR_ALPHA_MAX; - rough = exactSpecular ? 0.0 : rough; - float3 diffAlb = albedo * (1.0 - metal); - float3 F0 = payload.f0; - - // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. - // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples - // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial - // false), which the previous vertex's RIS already accounted for; the primary ray and - // specular/dielectric bounces (showCelestial true) still add it, since no emitter NEE happened - // along those. Emitters NOT in the light buffer (sparse/sub-threshold footprints, entities) are - // never NEE-sampled, so they always gather — bit-identical to the no-NEE path, no energy lost. - // Emission is already the full HDR strength (EMISSIVE_STRENGTH baseline * any override - // multiplier baked in Java at material-compile time — see MaterialHeader.features packing). - float emission = payloadEmission(); - // One gate drives both the RIS call below and the direct-hit emission suppression here. They must - // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where - // RIS is skipped must also gather emission directly or that light is lost outright. - bool risActive = risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); - bool gateEmitter = risActive && payloadEmitterInList(); - if (emission > 0.0 && (!gateEmitter || showCelestial)) { - L += throughput * albedo * emission; - } - - // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert - // diffuse + GGX specular, one shadow ray. Jitter the light direction within its square angular - // extent (worldPush.lightDir.w half-angle) so the shadow ray samples the light's quad — soft penumbrae - // over accumulation. The same sampled direction drives ndl, the shadow ray, and the specular - // half-vector. - float3 lightDir = worldPush.lightDir.xyz; - float lightHalfAngle = worldPush.lightDir.w; - if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); - } - float ndl = max(0.0, dot(n, lightDir)); - if (ndl > 0.0) { - VisibilityResult shadow = visibility(p, lightDir, 10000.0); - float3 vis = shadow.transmittance; - // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by - // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, - // so it applies to the whole NEE term (diffuse + specular). - if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { - vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); - } - if (max(vis.r, max(vis.g, vis.b)) > 0.0) { - float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) - float3 h = normalize(lightDir + v); - float ndh = max(0.0, dot(n, h)); - float ndv = max(1.0e-4, dot(n, v)); - float vdh = max(0.0, dot(v, h)); - float D = ggxD(ndh, rough); - float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); // separable Smith masking - float3 F = fresnelSchlick(vdh, F0); - brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular - L += throughput * brdf * worldPush.lightRadiance.xyz * ndl * vis; - } - } - - // RIS direct lighting from block emitters (single-frame only, no temporal reservoir reuse). - // Front lobe + SSS backlighting are resampled together and share ONE shadow ray — a light can - // only be in front of n or behind it, never both, so combining them into one target function is - // exact, not an approximation. The SSS term is gated off past MAX_SSS_DIFFUSE_DEPTH by passing - // sss=0 there (falls back to plain front-only RIS). Keyed on diffuseDepth, not `bounce`, for the - // same reason as RIS_MAX_DIFFUSE_DEPTH above: leaves seen through a window are still the first - // surface this path has shaded, and should still backlight. - if (risActive) { - float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; - Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, - uint(diffuseDepth), seed, proposalSeed); - float3 risVis; - L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, - activeSss, risVis); - } - - // Thin-surface SSS transmission. Light entering from the back face scatters through toward the - // viewer via a forward-biased HG phase (leaves/grass backlit by the sun glow when you look - // through them toward the light source). Shadow ray fires from the back face to avoid - // self-occlusion. cosT = dot(lightDir, rd): rd tracks the ray toward the sun when the sun is - // behind the slab, giving cosT ≈ 1 → forward-scatter peak. - if (sss > 0.0 && diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH) { - float backNdl = max(0.0, dot(-n, lightDir)); - if (backNdl > 0.0) { - VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); - float3 visB = shadowBack.transmittance; - // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should - // flicker with the same light bands as the floor around it. - if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { - visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, - lightDir, shadowBack.waterHitT); - } - if (max(visB.r, max(visB.g, visB.b)) > 0.0) { - float cosT = dot(lightDir, rd); - L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * worldPush.lightRadiance.xyz * visB; - } - } - } - - // Every shading term for this surface is now accounted for, so it counts toward the shaded depth. - // The dielectric branches above return before reaching here, which is exactly the distinction - // between this and `bounce`. - diffuseDepth++; - - // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their - // relative reflectance. - float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 - ? 1.0 - : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); - if (rndf(seed) < ps) { - float3 l; - if (exactSpecular) { - // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the - // degenerate VNDF at alpha=0 and preserves a genuinely sharp reflection without a floor. - l = reflect(rd, n); - throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; - } else { - float3 h = sampleGGXVNDF(n, v, rough, seed); - l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal - float ndl2 = dot(n, l); - if (ndl2 <= 0.0) { - break; // microfacet reflects below the surface: terminate this path - } - // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. - float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); - throughput *= F * ggxG1(ndl2, rough) / ps; - rayConeSpread = max(rayConeSpread, rough * RAY_CONE_GLOSSY_SPREAD_SCALE); - } - ro = p; - rd = l; - showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc - } else { - throughput *= diffAlb / (1.0 - ps); - ro = p; - rd = cosineDir(n, seed); - rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); - showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) - } - - // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - break; - } - throughput /= q; - } - } - return L; -} - -// Resolve the captured gv_* guide state into the DLSS-RR guide images. Called once, right after the -// sample that captured them, so the guide state dies there instead of staying live across every -// remaining sample's traces. -void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { - // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary - // tuple with the transmitted destination. - float2 specMotion = specularReflectionMotion(gv_spec, primaryDir, jndc, size, primaryConeSpread); - - // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative - // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer - // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. - // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. - float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); - float depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; - - // Motion vector: reproject this guide point through the previous frame's view-projection. - // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is - // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is - // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. - // For a dynamic surface, previous-frame position is the current point minus its world-space - // displacement; subtracting gv_motionObjDisp de-cameras the MV. - float4 prevClip = mul(worldPush.prevViewProj, - float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); - float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc - // Transmitted content is its own feature: compare its previous and current projections rather than - // subtracting the primary interface's jittered NDC. - float4 curClipMotion = gv_motionUseRefracted - ? mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)) - : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform - // Guard both perspective divides the way the depth above does. A guide point can land behind either - // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the - // frustum as the camera turns — and w at or below zero turns the divide into an arbitrarily large - // vector that DLSS-RR reprojects with. No valid previous position exists in that case, so report no - // motion and let RR fall back instead of handing it garbage. - float2 motion; - if (prevClip.w > 0.0 && (!gv_motionUseRefracted || curClipMotion.w > 0.0)) { - if (gv_motionUseRefracted) curNdc = curClipMotion.xy / curClipMotion.w; - motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; - } else { - motion = float2(0.0, 0.0); - } - - // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. - gNormal[pix] = float4(gv_normal, gv_rough); - gAlbedo[pix] = float4(gv_albedo, 1.0); - gDepth[pix] = depth; - gMotion[pix] = motion; - gSpecAlbedo[pix] = float4(gv_spec.albedo, 1.0); - gSpecMotion[pix] = specMotion; -} - -// 48-byte queue record. float3 + uint intentionally share the first 16-byte lane; every remaining -// field is a uint, so Std430DataLayout gives this an exact 48-byte stride. -struct PackedPathSegment { - float3 ro; - uint rd; - uint throughput; - uint currentExtinction; - uint outerExtinction; - uint mediumIors; - uint rayCone; - uint seed; - uint pathFlags; - uint pixelSample; -}; - -static const uint PATH_VALID = 1u << 11u; - -float2 octEncode(float3 direction) { - float3 n = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 1.0e-20); - float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); - float2 oct = n.z >= 0.0 ? n.xy : (1.0 - abs(n.yx)) * signNotZero; - return oct * 0.5 + 0.5; -} - -float3 octDecode(float2 encoded) { - float2 oct = encoded * 2.0 - 1.0; - float3 n = float3(oct, 1.0 - abs(oct.x) - abs(oct.y)); - if (n.z < 0.0) { - float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); - n.xy = (1.0 - abs(n.yx)) * signNotZero; - } - return normalize(n); -} - -uint packUnorm16x2(float2 v) { - uint2 q = uint2(round(clamp(v, 0.0, 1.0) * 65535.0)); - return q.x | (q.y << 16u); -} - -float2 unpackUnorm16x2(uint p) { - return float2(p & 0xffffu, p >> 16u) / 65535.0; -} - -// DXGI_FORMAT_R9G9B9E5_SHAREDEXP, implemented locally because Slang exposes no packing intrinsic. -uint packRgb9e5(float3 value) { - float3 v = clamp(value, 0.0, 65408.0); - float maxChannel = max(v.x, max(v.y, v.z)); - uint exponent = maxChannel < exp2(-16.0) - ? 0u : uint(floor(log2(maxChannel))) + 16u; - exponent = min(exponent, 31u); - float scale = exp2(float(int(exponent) - 24)); - uint maxMantissa = uint(floor(maxChannel / scale + 0.5)); - if (maxMantissa == 512u && exponent < 31u) { - exponent++; - scale *= 2.0; - } - uint3 mantissa = min(uint3(floor(v / scale + 0.5)), uint3(511u, 511u, 511u)); - return mantissa.x | (mantissa.y << 9u) | (mantissa.z << 18u) | (exponent << 27u); -} - -float3 unpackRgb9e5(uint p) { - float scale = exp2(float(int(p >> 27u) - 24)); - return float3(p & 0x1ffu, (p >> 9u) & 0x1ffu, (p >> 18u) & 0x1ffu) * scale; -} - -PackedPathSegment packPathSegment(PathSegment seg, uint pixelIndex, uint sampleIndex, bool valid) { - PackedPathSegment p; - p.ro = seg.ro; - p.rd = packUnorm16x2(octEncode(seg.rd)); - p.throughput = packRgb9e5(seg.throughput); - p.currentExtinction = packRgb9e5(seg.medium.current.extinction); - p.outerExtinction = packRgb9e5(seg.medium.outer.extinction); - p.mediumIors = packHalf2(float2(seg.medium.current.ior, seg.medium.outer.ior)); - p.rayCone = packHalf2(float2(seg.rayConeWidth, seg.rayConeSpread)); - p.seed = seg.seed; - p.pathFlags = (uint(seg.bounce) & 15u) - | ((uint(seg.diffuseDepth) & 15u) << 4u) - | (seg.showCelestial ? 1u << 8u : 0u) - | (seg.medium.current.water ? 1u << 9u : 0u) - | (seg.medium.outer.water ? 1u << 10u : 0u) - | (valid ? PATH_VALID : 0u); - p.pixelSample = (pixelIndex & 0x1fffffffu) | ((sampleIndex & 7u) << 29u); - return p; -} - -PathSegment unpackPathSegment(PackedPathSegment p, out bool valid) { - float2 iors = unpackHalf2(p.mediumIors); - Medium current; - current.ior = iors.x; - current.extinction = unpackRgb9e5(p.currentExtinction); - current.water = (p.pathFlags & (1u << 9u)) != 0u; - Medium outer; - outer.ior = iors.y; - outer.extinction = unpackRgb9e5(p.outerExtinction); - outer.water = (p.pathFlags & (1u << 10u)) != 0u; - MediumStack medium; - medium.current = current; - medium.outer = outer; - float2 cone = unpackHalf2(p.rayCone); - valid = (p.pathFlags & PATH_VALID) != 0u; - return makePathSegment(p.ro, octDecode(unpackUnorm16x2(p.rd)), - unpackRgb9e5(p.throughput), medium, cone.x, cone.y, p.seed, - int(p.pathFlags & 15u), int((p.pathFlags >> 4u) & 15u), - (p.pathFlags & (1u << 8u)) != 0u, false, false); -} - -// Walk only the visually-primary dielectric chain. The terminal non-dielectric/miss trace is repeated -// by the indirect pass, so the returned record describes the state immediately BEFORE that trace. -// This keeps closest-hit payload data out of the queue and, critically, writes the record before any -// later trace rather than keeping a continuation live across traversal. -PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool alive) { - float3 ro = seg.ro; - float3 rd = seg.rd; - float3 throughput = seg.throughput; - MediumStack medium = seg.medium; - float rayConeWidth = seg.rayConeWidth; - float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); - uint seed = seg.seed; - bool showCelestial = seg.showCelestial; - int diffuseDepth = seg.diffuseDepth; - int maxBounces = int(worldPush.maxBounces); - int rrStart = maxBounces <= 3 ? 1 : 2; - bool waterWaves = (worldPush.flags & 16u) != 0u; - alive = false; - - for (int bounce = seg.bounce; bounce <= maxBounces; ++bounce) { - PathSegment terminal = makePathSegment(ro, rd, throughput, medium, - rayConeWidth, rayConeSpread, seed, bounce, diffuseDepth, - showCelestial, false, false); - traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, - ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); - - if (payload.hitT < 0.0) { - if (bounce == 0 && captureGuides) { - gv_normal = float3(0.0, 0.0, 0.0); - gv_albedo = SKY_DIFF_ALBEDO; - gv_rough = 1.0; - gv_hitCamRel = rd * 1.0e6; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = float3(0.0, 0.0, 0.0); - gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); - } - alive = true; - return terminal; - } - - float3 n = payload.normal; - float3 hitPos = ro + rd * payload.hitT; - uint material = payloadMaterial(); - if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) { - if (bounce == 0 && captureGuides) { - gv_normal = n; - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; - if (material == MATERIAL_PARTICLE) { - gv_albedo = payload.albedo; - gv_rough = 1.0; - gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, - float3(0.0, 0.0, 0.0)); - } else { - float rough = clamp(payloadRoughness(), 0.0, 1.0); - rough = rough <= MIRROR_ALPHA_MAX ? 0.0 : rough; - float metal = clamp(payloadMetalness(), 0.0, 1.0); - float3 diffAlb = payload.albedo * (1.0 - metal); - float3 v = -rd; - gv_albedo = diffAlb; - gv_rough = rough; - gv_spec = makeSpecSurface(gv_hitCamRel, n, rough, - rrSpecularAlbedo(payload.f0, rough, dot(n, v))); - } - } - alive = true; - return terminal; - } - - // This hit is consumed in pass A, so carry its travelled-medium absorption and cone growth into - // the record for the next trace. The terminal hit above is deliberately not consumed here. - if (any(medium.current.extinction > 0.0)) { - throughput *= exp(-medium.current.extinction * payload.hitT); - } - rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), - RAY_CONE_MIN_WIDTH); - - bool isWater = material == MATERIAL_WATER; - bool entering = payloadDielectricEntering(); - float3 geometricNormal = n; - float3 previousNormal = n; - if (isWater && waterWaves) { - float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; - float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); - if (bounce == 0 && captureGuides && abs(geometricNormal.y) >= 0.5) { - float2 currentGrad; - float2 previousGrad; - waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, - worldPush.waterAnchor.z, waterFootprint, currentGrad, previousGrad); - float orientation = geometricNormal.y >= 0.0 ? 1.0 : -1.0; - n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); - previousNormal = orientation - * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); - } else { - n = applyWaterWaves(geometricNormal, waterDomain, - worldPush.waterParams.w, waterFootprint); - } - } - - float transmission = clamp(payloadTransmission(), 0.0, 1.0); - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), - payload.albedo, transmission); - float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.ior; - float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); - float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - - if (bounce == 0 && captureGuides) { - gv_normal = n; - gv_rough = 0.0; - gv_albedo = float3(0.0, 0.0, 0.0); - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; - gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, - gv_rough, float3(F, F, F)); - if (dot(transmittedDir, transmittedDir) > 0.0) { - resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, - entering ? entered.ior : medium.outer.ior, - transmitBias, rayConeWidth, rayConeSpread); - } - } - - if (rndf(seed) < F) { - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); - } else { - if (dot(transmittedDir, transmittedDir) <= 0.0) { - return terminal; - } - rd = normalize(transmittedDir); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); - if (entering) { - mediumPush(medium, entered); - } else { - mediumPop(medium); - } - } - showCelestial = true; - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - return terminal; - } - throughput /= q; - } - } - return seg; -} - -void writeDebugView(int2 pix) { - float4 normalRough = gNormal[pix]; - float3 dbg; - if (pc.debugView == 1u) { - dbg = normalRough.xyz * 0.5 + 0.5; - } else if (pc.debugView == 2u) { - dbg = gAlbedo[pix].rgb; - } else if (pc.debugView == 3u) { - dbg = float3(gDepth[pix], gDepth[pix], gDepth[pix]); - } else if (pc.debugView == 4u) { - dbg = float3(normalRough.w, normalRough.w, normalRough.w); - } else if (pc.debugView == 6u) { - dbg = gSpecAlbedo[pix].rgb; - } else if (pc.debugView == 7u) { - dbg = float3(clamp(0.5 + gSpecMotion[pix] * 0.05, 0.0, 1.0), 0.5); - } else { - dbg = float3(clamp(0.5 + gMotion[pix] * 0.05, 0.0, 1.0), 0.5); - } - outImage[pix] = float4(dbg, 1.0); -} - -#if CAUSTICA_PRIMARY_PASS -[shader("raygeneration")] -void main() { - worldPush = ConstPtr(pc.worldPushAddr)[0]; - uint2 dispatchIndex = DispatchRaysIndex().xy; - uint2 dimensions = DispatchRaysDimensions().xy; - int2 pix = int2(dispatchIndex); - float2 size = float2(dimensions); - float2 uv = (float2(pix) + 0.5) / size; - float2 jndc = (uv + worldPush.jitter / size) * 2.0 - 1.0; - float4 nearH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 1.0, 1.0)); - float4 farH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 0.0, 1.0)); - float3 nearP = nearH.xyz / nearH.w; - float3 farP = farH.xyz / farH.w; - float3 origin = nearP + worldPush.camOffset; - float3 dir = normalize(farP - nearP); - float rayConeSpread = primaryRayConeSpread(jndc, size, dir); - uint seed = (dispatchIndex.x * 1973u + dispatchIndex.y * 9277u + 26699u) - ^ (worldPush.frameIndex * 2654435761u); - MediumStack cameraMedium = makeMediumStack((worldPush.flags & 1u) != 0u - ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) - : airMedium()); - uint spp = max(worldPush.spp, 1u); - uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; - DevicePtr queue = DevicePtr(pc.pathQueueAddr); - - for (uint s = 0u; s < spp; ++s) { - seed = pcg(seed); - PathSegment camera = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), - cameraMedium, 0.0, rayConeSpread, seed, 0, 0, true, false, false); - bool alive; - PathSegment terminal = tracePrimary(camera, s == 0u, alive); - // Store immediately: no continuation remains live across writeGuides or another primary trace. - queue[pixelIndex * spp + s] = packPathSegment(terminal, pixelIndex, s, alive); - if (s == 0u) { - writeGuides(pix, dir, jndc, size, rayConeSpread); - } - } - - if (pc.debugView != 0u) { - writeDebugView(pix); - } -} -#endif - -#if CAUSTICA_INDIRECT_PASS -[shader("raygeneration")] -void main() { - worldPush = ConstPtr(pc.worldPushAddr)[0]; - if (pc.debugView != 0u) { - return; // pass A owns guide visualization output - } - uint2 dispatchIndex = DispatchRaysIndex().xy; - uint2 dimensions = DispatchRaysDimensions().xy; - int2 pix = int2(dispatchIndex); - uint spp = max(worldPush.spp, 1u); - uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; - ConstPtr queue = ConstPtr(pc.pathQueueAddr); - float3 frameRadiance = float3(0.0, 0.0, 0.0); - for (uint s = 0u; s < spp; ++s) { - bool valid; - PathSegment segment = unpackPathSegment(queue[pixelIndex * spp + s], valid); - if (valid) { - frameRadiance += tracePath(segment, uint2(pix), s); - } - } - outImage[pix] = float4(frameRadiance / float(spp), 1.0); -} -#endif diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang index ea458fbe..9569e8a8 100644 --- a/shaders/world/world_primary.rgen.slang +++ b/shaders/world/world_primary.rgen.slang @@ -1,3 +1,68 @@ -#define CAUSTICA_PRIMARY_PASS 1 -#define CAUSTICA_INDIRECT_PASS 0 -#include "world_path.slanginc" +// Primary/guide pass (pass A of the wavefront split — see docs/WAVEFRONT_PLAN.md). +// +// Traces the camera ray through the dielectric chain to the first surface worth shading, captures the +// DLSS-RR guide buffers, and writes one resumable continuation per sample to the path queue. It shades +// nothing: no NEE, no RIS, no BSDF sampling. That is why rt_lighting and rt_indirect are absent below, +// and their absence is the point — the pass separation is enforced by this include list rather than +// left to dead-code elimination. +// +// Perspective setup: invViewProj = inverse(proj * viewRot) maps clip to camera-relative world. Primary +// rays are reconstructed from two unprojected points (near and far plane) rather than assuming the eye +// is at the origin, because Minecraft bakes view bobbing into the projection, so the effective eye comes +// from the matrix. camOffset shifts camera-relative space into the terrain's rebased coordinates. Depth +// is reversed-Z (near=1, far=0). +// +// SER: the build emits EXT and NV shader-invocation-reorder variants from this source. +import world_common; + +#include "rt_core.slanginc" +#include "rt_math.slanginc" +#include "rt_medium.slanginc" +#include "rt_segment.slanginc" +#include "rt_water.slanginc" +#include "rt_trace.slanginc" +#include "rt_guides.slanginc" +#include "rt_primary.slanginc" + +[shader("raygeneration")] +void main() { + worldPush = ConstPtr(pc.worldPushAddr)[0]; + uint2 dispatchIndex = DispatchRaysIndex().xy; + uint2 dimensions = DispatchRaysDimensions().xy; + int2 pix = int2(dispatchIndex); + float2 size = float2(dimensions); + float2 uv = (float2(pix) + 0.5) / size; + float2 jndc = (uv + worldPush.jitter / size) * 2.0 - 1.0; + float4 nearH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 1.0, 1.0)); + float4 farH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 0.0, 1.0)); + float3 nearP = nearH.xyz / nearH.w; + float3 farP = farH.xyz / farH.w; + float3 origin = nearP + worldPush.camOffset; + float3 dir = normalize(farP - nearP); + float rayConeSpread = primaryRayConeSpread(jndc, size, dir); + uint seed = (dispatchIndex.x * 1973u + dispatchIndex.y * 9277u + 26699u) + ^ (worldPush.frameIndex * 2654435761u); + MediumStack cameraMedium = makeMediumStack((worldPush.flags & 1u) != 0u + ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) + : airMedium()); + uint spp = max(worldPush.spp, 1u); + uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; + DevicePtr queue = DevicePtr(pc.pathQueueAddr); + + for (uint s = 0u; s < spp; ++s) { + seed = pcg(seed); + PathSegment camera = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), + cameraMedium, 0.0, rayConeSpread, seed, 0, 0, true, false, false); + bool alive; + PathSegment terminal = tracePrimary(camera, s == 0u, alive); + // Store immediately: no continuation remains live across writeGuides or another primary trace. + queue[pixelIndex * spp + s] = packPathSegment(terminal, pixelIndex, s, alive); + if (s == 0u) { + writeGuides(pix, dir, jndc, size, rayConeSpread); + } + } + + if (pc.debugView != 0u) { + writeDebugView(pix); + } +} From cb5a51dc0d1a9a5b9c90dab72972d00e19b8da0e Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:57:37 +0900 Subject: [PATCH 15/25] Rename rt_*.slanginc modules to *.slang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .slanginc had no build-system meaning: the compile task's stage glob only matches the eight "*..slang" suffixes (rgen/rchit/rahit/rmiss/rcall/ comp/vert/frag), so a bare module name was already excluded from direct compilation regardless of extension — same as world_common.slang. The rt_ prefix was redundant inside shaders/world, where every file is already world-RT code. core / math / medium / segment / water / trace / lighting / guides / primary / indirect. Pure rename: git mv + updated #include paths in the two raygen entry points + prose cross-references in each module's header comment. No line moved, no code changed. 39 tests pass, spirv-val clean. SPIR-V byte counts shift by ~1KB from shorter embedded debug-info source paths, not from any code difference. NOT GPU-verified. Co-Authored-By: Claude Opus 5 --- shaders/world/{rt_core.slanginc => core.slang} | 0 .../world/{rt_guides.slanginc => guides.slang} | 2 +- .../{rt_indirect.slanginc => indirect.slang} | 2 +- .../{rt_lighting.slanginc => lighting.slang} | 2 +- shaders/world/{rt_math.slanginc => math.slang} | 0 .../world/{rt_medium.slanginc => medium.slang} | 2 +- .../{rt_primary.slanginc => primary.slang} | 2 +- .../{rt_segment.slanginc => segment.slang} | 2 +- .../world/{rt_trace.slanginc => trace.slang} | 2 +- .../world/{rt_water.slanginc => water.slang} | 2 +- shaders/world/world.rgen.slang | 18 +++++++++--------- shaders/world/world_primary.rgen.slang | 18 +++++++++--------- 12 files changed, 26 insertions(+), 26 deletions(-) rename shaders/world/{rt_core.slanginc => core.slang} (100%) rename shaders/world/{rt_guides.slanginc => guides.slang} (99%) rename shaders/world/{rt_indirect.slanginc => indirect.slang} (99%) rename shaders/world/{rt_lighting.slanginc => lighting.slang} (99%) rename shaders/world/{rt_math.slanginc => math.slang} (100%) rename shaders/world/{rt_medium.slanginc => medium.slang} (98%) rename shaders/world/{rt_primary.slanginc => primary.slang} (99%) rename shaders/world/{rt_segment.slanginc => segment.slang} (99%) rename shaders/world/{rt_trace.slanginc => trace.slang} (99%) rename shaders/world/{rt_water.slanginc => water.slang} (99%) diff --git a/shaders/world/rt_core.slanginc b/shaders/world/core.slang similarity index 100% rename from shaders/world/rt_core.slanginc rename to shaders/world/core.slang diff --git a/shaders/world/rt_guides.slanginc b/shaders/world/guides.slang similarity index 99% rename from shaders/world/rt_guides.slanginc rename to shaders/world/guides.slang index 97220eae..dbf69d61 100644 --- a/shaders/world/rt_guides.slanginc +++ b/shaders/world/guides.slang @@ -1,6 +1,6 @@ // DLSS-RR guide state and everything that resolves it: the gv_* captures, the foreground specular // surface and its reflection motion vector, the transmitted-destination walk, and the image writes. -// PRIMARY PASS ONLY. Depends on rt_trace, rt_water and rt_segment. +// PRIMARY PASS ONLY. Depends on trace, water and segment. // First-hit (primary-visibility) guide attributes, captured at bounce 0 of tracePath. The primary ray // is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. diff --git a/shaders/world/rt_indirect.slanginc b/shaders/world/indirect.slang similarity index 99% rename from shaders/world/rt_indirect.slanginc rename to shaders/world/indirect.slang index a0ee52af..0bbaa177 100644 --- a/shaders/world/rt_indirect.slanginc +++ b/shaders/world/indirect.slang @@ -1,5 +1,5 @@ // tracePath: the bounce loop — NEE, RIS, SSS, BSDF continuation and Russian roulette. -// INDIRECT PASS ONLY. Depends on rt_lighting. +// INDIRECT PASS ONLY. Depends on lighting. // Deterministic dielectric splitting is deliberately disabled for M1; M2 will append the second branch. float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { diff --git a/shaders/world/rt_lighting.slanginc b/shaders/world/lighting.slang similarity index 99% rename from shaders/world/rt_lighting.slanginc rename to shaders/world/lighting.slang index 635b001a..0910a5fb 100644 --- a/shaders/world/rt_lighting.slanginc +++ b/shaders/world/lighting.slang @@ -1,5 +1,5 @@ // Direct lighting: the emitter light grid, RIS reservoirs, and reservoir shading. -// INDIRECT PASS ONLY — the primary pass shades nothing. Depends on rt_trace and rt_math. +// INDIRECT PASS ONLY — the primary pass shades nothing. Depends on trace and math. // contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted). struct Reservoir { diff --git a/shaders/world/rt_math.slanginc b/shaders/world/math.slang similarity index 100% rename from shaders/world/rt_math.slanginc rename to shaders/world/math.slang diff --git a/shaders/world/rt_medium.slanginc b/shaders/world/medium.slang similarity index 98% rename from shaders/world/rt_medium.slanginc rename to shaders/world/medium.slang index 88287d26..1f903b1c 100644 --- a/shaders/world/rt_medium.slanginc +++ b/shaders/world/medium.slang @@ -1,5 +1,5 @@ // Participating media: the tint-to-extinction mappings and the depth-2 medium stack the dielectric -// interface pushes and pops. Depends on rt_core only. +// interface pushes and pops. Depends on core only. // Per-channel Beer–Lambert extinction from a water body's biome tint (carried in payload.albedo for // water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean diff --git a/shaders/world/rt_primary.slanginc b/shaders/world/primary.slang similarity index 99% rename from shaders/world/rt_primary.slanginc rename to shaders/world/primary.slang index bd3bf40b..1087b098 100644 --- a/shaders/world/rt_primary.slanginc +++ b/shaders/world/primary.slang @@ -1,6 +1,6 @@ // tracePrimary: camera ray through the dielectric chain to the first surface worth shading, capturing // guides on the way. Returns the continuation the indirect pass resumes. -// PRIMARY PASS ONLY. Depends on rt_guides. +// PRIMARY PASS ONLY. Depends on guides. // later trace rather than keeping a continuation live across traversal. PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool alive) { diff --git a/shaders/world/rt_segment.slanginc b/shaders/world/segment.slang similarity index 99% rename from shaders/world/rt_segment.slanginc rename to shaders/world/segment.slang index 9b61aec7..ba38ec54 100644 --- a/shaders/world/rt_segment.slanginc +++ b/shaders/world/segment.slang @@ -1,5 +1,5 @@ // PathSegment — a resumable continuation — plus its packed 48-byte buffer form. This is the record -// the primary pass writes and the indirect pass reads. Depends on rt_medium. +// the primary pass writes and the indirect pass reads. Depends on medium. // Everything needed to resume tracing from a point in the scene. The path tracer takes one of these and diff --git a/shaders/world/rt_trace.slanginc b/shaders/world/trace.slang similarity index 99% rename from shaders/world/rt_trace.slanginc rename to shaders/world/trace.slang index dca9b4b2..e3fb12ad 100644 --- a/shaders/world/rt_trace.slanginc +++ b/shaders/world/trace.slang @@ -1,5 +1,5 @@ // Ray dispatch: SBT/cull constants, payload construction, and the three ways this renderer casts a -// ray — reordered radiance, non-reordered guide probe, and shadow visibility. Depends on rt_core. +// ray — reordered radiance, non-reordered guide probe, and shadow visibility. Depends on core. // receiver billboards only. The first-person camera owner uses 0x01 (secondary only). static const uint CULL_SECONDARY = 0x01u; diff --git a/shaders/world/rt_water.slanginc b/shaders/world/water.slang similarity index 99% rename from shaders/world/rt_water.slanginc rename to shaders/world/water.slang index cf3c95a1..03b1e90a 100644 --- a/shaders/world/rt_water.slanginc +++ b/shaders/world/water.slang @@ -1,5 +1,5 @@ // Water surface: the wave spectrum (with its analytic time derivative), the normal perturbation it -// drives, and the caustic term that differentiates the same field. Depends on rt_core. +// drives, and the caustic term that differentiates the same field. Depends on core. // Animated surface waves — a directional wave spectrum, gradient-only (the surface stays geometrically diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 075cf951..26868a8d 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -2,7 +2,7 @@ // // Resumes the continuations the primary pass queued and runs the bounce loop: NEE toward the sun, RIS // emitter lighting, thin-surface SSS, BSDF continuation and Russian roulette. It captures no guides and -// owns no motion vectors — rt_guides and rt_primary are deliberately absent from the include list +// owns no motion vectors — guides and primary are deliberately absent from the include list // below, so guide state cannot leak into this pass by accident. // // Lighting is iterative here rather than recursive from closest-hit, so maxRayRecursionDepth stays 1. @@ -20,14 +20,14 @@ // SER: the build emits EXT and NV shader-invocation-reorder variants from this source. import world_common; -#include "rt_core.slanginc" -#include "rt_math.slanginc" -#include "rt_medium.slanginc" -#include "rt_segment.slanginc" -#include "rt_water.slanginc" -#include "rt_trace.slanginc" -#include "rt_lighting.slanginc" -#include "rt_indirect.slanginc" +#include "core.slang" +#include "math.slang" +#include "medium.slang" +#include "segment.slang" +#include "water.slang" +#include "trace.slang" +#include "lighting.slang" +#include "indirect.slang" [shader("raygeneration")] void main() { diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang index 9569e8a8..1df1a044 100644 --- a/shaders/world/world_primary.rgen.slang +++ b/shaders/world/world_primary.rgen.slang @@ -2,7 +2,7 @@ // // Traces the camera ray through the dielectric chain to the first surface worth shading, captures the // DLSS-RR guide buffers, and writes one resumable continuation per sample to the path queue. It shades -// nothing: no NEE, no RIS, no BSDF sampling. That is why rt_lighting and rt_indirect are absent below, +// nothing: no NEE, no RIS, no BSDF sampling. That is why lighting and indirect are absent below, // and their absence is the point — the pass separation is enforced by this include list rather than // left to dead-code elimination. // @@ -15,14 +15,14 @@ // SER: the build emits EXT and NV shader-invocation-reorder variants from this source. import world_common; -#include "rt_core.slanginc" -#include "rt_math.slanginc" -#include "rt_medium.slanginc" -#include "rt_segment.slanginc" -#include "rt_water.slanginc" -#include "rt_trace.slanginc" -#include "rt_guides.slanginc" -#include "rt_primary.slanginc" +#include "core.slang" +#include "math.slang" +#include "medium.slang" +#include "segment.slang" +#include "water.slang" +#include "trace.slang" +#include "guides.slang" +#include "primary.slang" [shader("raygeneration")] void main() { From 27e10d0c6c517d47f96f8f9935145152dc3258a2 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:46:11 +0900 Subject: [PATCH 16/25] refactor --- shaders/world/guides.slang | 57 +-- shaders/world/indirect.slang | 345 ----------------- shaders/world/lighting.slang | 64 ++-- shaders/world/math.slang | 56 ++- shaders/world/medium.slang | 38 +- shaders/world/primary.slang | 155 -------- shaders/world/segment.slang | 73 ++-- shaders/world/trace.slang | 34 +- shaders/world/water.slang | 52 +-- shaders/world/world.rahit.slang | 1 + shaders/world/world.rchit.slang | 1 + shaders/world/world.rgen.slang | 356 +++++++++++++++++- shaders/world/world_common.slang | 12 - .../world/{core.slang => world_core.slang} | 81 ++-- shaders/world/world_primary.rgen.slang | 165 +++++++- 15 files changed, 741 insertions(+), 749 deletions(-) delete mode 100644 shaders/world/indirect.slang delete mode 100644 shaders/world/primary.slang rename shaders/world/{core.slang => world_core.slang} (52%) diff --git a/shaders/world/guides.slang b/shaders/world/guides.slang index dbf69d61..5a931dd8 100644 --- a/shaders/world/guides.slang +++ b/shaders/world/guides.slang @@ -4,31 +4,40 @@ // First-hit (primary-visibility) guide attributes, captured at bounce 0 of tracePath. The primary ray // is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. -static float3 gv_normal; -static float3 gv_albedo; -static float gv_rough; -static float3 gv_hitCamRel; // primary-surface hit position relative to the current camera -static float3 gv_motionHitCamRel; // motion-guide hit position; water tracks the refracted hit instead -static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit (its own reprojection delta) + +import world_common; +import world_core; +import math; +import medium; +import segment; +import water; +import trace; + +public static float3 gv_normal = float3(0.0, 0.0, 0.0); +public static float3 gv_albedo = float3(0.0, 0.0, 0.0); +public static float gv_rough = 0.0; +public static float3 gv_hitCamRel = float3(0.0, 0.0, 0.0); // primary-surface hit position relative to the current camera +public static float3 gv_motionHitCamRel = float3(0.0, 0.0, 0.0); // motion-guide hit position; water tracks the refracted hit instead +public static bool gv_motionUseRefracted = false; // true when the MV tracks the refracted hit (its own reprojection delta) // World-space displacement of the motion-guide surface since the previous frame (0 for static // terrain/sky, an entity's per-vertex/object motion for a dynamic hit). -static float3 gv_motionObjDisp; +public static float3 gv_motionObjDisp = float3(0.0, 0.0, 0.0); // Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs // (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is // the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. -struct SpecSurface { - float3 camRel; // interface position relative to the current camera - float3 normal; // shading normal (wave-perturbed for water) - float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates - float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface - float roughness; - float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) +public struct SpecSurface { + public float3 camRel; // interface position relative to the current camera + public float3 normal; // shading normal (wave-perturbed for water) + public float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates + public float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface + public float roughness; + public float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) }; -static SpecSurface gv_spec; +public static SpecSurface gv_spec = {}; // Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading // normal now and last frame, flat geometric normal for the bias) via the six-argument form. -SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, float3 biasNormal, +public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, float3 biasNormal, float roughness, float3 albedo) { SpecSurface s; s.camRel = camRel; @@ -40,11 +49,11 @@ SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, return s; } -SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { +public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { return makeSpecSurface(camRel, normal, normal, normal, roughness, albedo); } // angle can land behind the eye even though the reflector itself is comfortably in view. -float2 projectPrevNdc(float3 worldPos, out bool valid) { +public float2 projectPrevNdc(float3 worldPos, out bool valid) { float4 clip = mul(worldPush.prevViewProj, float4(worldPos - worldPush.camOffset + worldPush.camDelta, 1.0)); valid = clip.w > 0.0; return valid ? clip.xy / clip.w : float2(0.0, 0.0); @@ -57,7 +66,7 @@ float2 projectPrevNdc(float3 worldPos, out bool valid) { // explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so // static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); // reflectedMotionPrev carries the reflected content's own displacement. -float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, +public float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, float3 reflectedMotionPrev, out bool valid) { float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed float3 prevHit = reflectedWorldPos - reflectedMotionPrev; @@ -67,7 +76,7 @@ float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 ref // Reflection motion remains owned by the physical foreground interface even when transmission later // replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). -float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, +public float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, float2 currentNdc, float2 size, float primaryConeSpread) { if (length(surface.normal) < 0.5 || max(surface.albedo.r, max(surface.albedo.g, surface.albedo.b)) <= 0.001 @@ -106,7 +115,7 @@ float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, // Deterministic transmitted destination for clear-interface RR guides. The first interface has already // been crossed. Continue through every crossed dielectric until opaque/particle content or sky is reached; // TIR changes the walk into a reflected secondary ray instead of producing a false foreground guide. -void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, +public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, float roughness, float3 diffuseAlbedo) { gv_hitCamRel = hitCamRel; gv_motionHitCamRel = hitCamRel; @@ -117,7 +126,7 @@ void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, gv_albedo = diffuseAlbedo; } -void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, +public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 surfaceBiasNormal, float currentIor, float rayBias, float rayConeWidth, float rayConeSpread) { @@ -194,7 +203,7 @@ void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, // Resolve the captured gv_* guide state into the DLSS-RR guide images. Called once, right after the // sample that captured them, so the guide state dies there instead of staying live across every // remaining sample's traces. -void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { +public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary // tuple with the transmitted destination. float2 specMotion = specularReflectionMotion(gv_spec, primaryDir, jndc, size, primaryConeSpread); @@ -244,7 +253,7 @@ void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float pr // 48-byte queue record. float3 + uint intentionally share the first 16-byte lane; every remaining -void writeDebugView(int2 pix) { +public void writeDebugView(int2 pix) { float4 normalRough = gNormal[pix]; float3 dbg; if (pc.debugView == 1u) { diff --git a/shaders/world/indirect.slang b/shaders/world/indirect.slang deleted file mode 100644 index 0bbaa177..00000000 --- a/shaders/world/indirect.slang +++ /dev/null @@ -1,345 +0,0 @@ -// tracePath: the bounce loop — NEE, RIS, SSS, BSDF continuation and Russian roulette. -// INDIRECT PASS ONLY. Depends on lighting. - -// Deterministic dielectric splitting is deliberately disabled for M1; M2 will append the second branch. -float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { - float3 L = float3(0.0, 0.0, 0.0); - float3 ro = seg.ro; - float3 rd = seg.rd; - float3 throughput = seg.throughput; - uint seed = seg.seed; - float rayConeWidth = seg.rayConeWidth; - float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); - int maxBounces = int(worldPush.maxBounces); - int rrStart = maxBounces <= 3 ? 1 : 2; - // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the - // candidate count is non-zero. Off => emitters are gathered only on path hits (legacy behaviour). - bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && pc.lightBufAddr != 0; - // Independent light-proposal RNG. Screen-space coherent selection is intentionally deferred until - // there is a real presampled RIS tile buffer; direct sharing made tile-shaped lighting noise visible. - // Measured: making a 16x16 tile share this seed recovers at most 1.3ms, so a future pool should share - // the POOL rather than the seed — the win is in removing dependent loads, not in coherence. - uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u - ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; - proposalSeed = pcg(proposalSeed); - // The medium the segment starts in. For the camera segment that is water when the eye is submerged - // (so the first ray already carries the right relative index and absorption); for a segment split off - // a dielectric it is whatever the parent was travelling through. - MediumStack medium = seg.medium; - bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation - // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned - // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from - // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc - // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. - bool showCelestial = seg.showCelestial; - // Number of surfaces this path has SHADED so far, as distinct from `bounce`, which also counts - // dielectric interfaces. Glass and water continue without shading anything, so a wall behind a pane - // is bounce 1 but diffuseDepth 0 — visually primary, and it must be treated as such. See - // RIS_MAX_DIFFUSE_DEPTH. - int diffuseDepth = seg.diffuseDepth; - for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { - // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to - // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. - // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person - // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. - traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); - - if (payload.hitT < 0.0) { - // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The - // packed show-celestial flag decides whether this path segment may see the bright disc. - float3 sky = payload.albedo; - L += throughput * sky; // escaped to sky - break; - } - - // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies - // to every hit reached while inside a volume dielectric (its own exit face, or whatever content - // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so - // the ordinary case costs one comparison. - if (any(medium.current.extinction > 0.0)) { - throughput *= exp(-medium.current.extinction * payload.hitT); - } - - float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit - float3 hitPos = ro + rd * payload.hitT; - rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); - uint material = payloadMaterial(); - - // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every - // dielectric is a volume. Fresnel from the relative index across the face, then reflect or - // refract; no diffuse response and no sun NEE, because the surface is specular. Transmission - // pushes or pops a participating medium, and the segment travelled inside it is attenuated by - // that medium's extinction rather than by a tint multiply at the interface. - // - // Water is the only material that adds anything: the animated wave normal, and a medium marked - // for the caustic term. Which side of the face we are on comes from the geometry - // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest - // of the path. - if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { - bool isWater = material == MATERIAL_WATER; - bool entering = payloadDielectricEntering(); - float3 tint = payload.albedo; - float transmission = clamp(payloadTransmission(), 0.0, 1.0); - - // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken - // reflections, wobbling refraction. The wave domain is world-stable: rebased hit XZ plus the - // pushed anchor (terrain origin mod 4096) survives the per-move terrain rebase. - float3 geometricNormal = n; // unrippled; ray origins offset along this - if (isWater && waterWaves) { - float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; - float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); - n = applyWaterWaves(geometricNormal, waterDomain, - worldPush.waterParams.w, waterFootprint); - } - - // The medium this face opens into, and the one it returns to on the way out — which is what - // the stack remembers. - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); - float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.ior; - - float cosI = clamp(dot(-rd, n), 0.0, 1.0); - float F = fresnelDielectric(cosI, etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); - // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a - // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full - // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water - // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. - float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - - bool chooseReflection = rndf(seed) < F; - if (chooseReflection) { - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); - } else { - if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen - rd = normalize(transmittedDir); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); - // Crossing into or out of the volume. Absorption is the medium's job from here, so the - // tint is NOT also multiplied into the throughput — that would double-count it. - if (entering) { - mediumPush(medium, entered); - } else { - mediumPop(medium); - } - } - showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - break; - } - throughput /= q; - } - continue; - } - - // Particles (material == 2): camera-facing receiver billboard. The instance mask keeps particles - // off secondary rays, so this only fires at bounce 0; they receive direct light and one diffuse - // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. - if (material == MATERIAL_PARTICLE) { - float3 albedo = payload.albedo; - - float3 lightDir = worldPush.lightDir.xyz; - float lightHalfAngle = worldPush.lightDir.w; - if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); - } - // Billboards are effectively two-sided receivers. Bias from the side facing the light; shadow - // rays exclude particles anyway, but this keeps the origin sane when the light is behind the - // camera side. - float signedNdl = dot(n, lightDir); - float ndl = abs(signedNdl); - if (ndl > 0.0) { - float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; - float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; - if (max(vis.r, max(vis.g, vis.b)) > 0.0) { - L += throughput * albedo * INV_PI * worldPush.lightRadiance.xyz * ndl * vis; - } - } - - // RIS direct lighting from block emitters (lava, glowstone, torches, ...) — same reservoir - // sampler as terrain/entities, but two-sided: a billboard has no back face, so light - // striking either side of the quad should still land (matches the sun/moon NEE above). - if (risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH)) { - float3 v = -rd; - Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, uint(diffuseDepth), seed, proposalSeed); - float3 risVis; - L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, risVis); - } - - if (bounce >= maxBounces) { - break; - } - throughput *= albedo; - ro = hitPos + n * SURF_BIAS; - rd = cosineDir(n, seed); - rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); - showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above - diffuseDepth++; // a billboard is a shaded surface, unlike the dielectric branches - continue; - } - - float3 albedo = payload.albedo; - float sss = payloadSss(); // LabPBR SSS strength (0 when absent) - float3 p = hitPos + n * SURF_BIAS; - float3 v = -rd; // view direction (toward the camera / incoming ray) - - // Material split. Metals tint F0 by albedo and have no diffuse lobe; dielectrics use a fixed - // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). - float rough = clamp(payloadRoughness(), 0.0, 1.0); - float metal = clamp(payloadMetalness(), 0.0, 1.0); - // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. - // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the - // reflection sharp instead of accumulating it as a glossy lobe. - bool exactSpecular = rough <= MIRROR_ALPHA_MAX; - rough = exactSpecular ? 0.0 : rough; - float3 diffAlb = albedo * (1.0 - metal); - float3 F0 = payload.f0; - - // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. - // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples - // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial - // false), which the previous vertex's RIS already accounted for; the primary ray and - // specular/dielectric bounces (showCelestial true) still add it, since no emitter NEE happened - // along those. Emitters NOT in the light buffer (sparse/sub-threshold footprints, entities) are - // never NEE-sampled, so they always gather — bit-identical to the no-NEE path, no energy lost. - // Emission is already the full HDR strength (EMISSIVE_STRENGTH baseline * any override - // multiplier baked in Java at material-compile time — see MaterialHeader.features packing). - float emission = payloadEmission(); - // One gate drives both the RIS call below and the direct-hit emission suppression here. They must - // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where - // RIS is skipped must also gather emission directly or that light is lost outright. - bool risActive = risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); - bool gateEmitter = risActive && payloadEmitterInList(); - if (emission > 0.0 && (!gateEmitter || showCelestial)) { - L += throughput * albedo * emission; - } - - // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert - // diffuse + GGX specular, one shadow ray. Jitter the light direction within its square angular - // extent (worldPush.lightDir.w half-angle) so the shadow ray samples the light's quad — soft penumbrae - // over accumulation. The same sampled direction drives ndl, the shadow ray, and the specular - // half-vector. - float3 lightDir = worldPush.lightDir.xyz; - float lightHalfAngle = worldPush.lightDir.w; - if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); - } - float ndl = max(0.0, dot(n, lightDir)); - if (ndl > 0.0) { - VisibilityResult shadow = visibility(p, lightDir, 10000.0); - float3 vis = shadow.transmittance; - // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by - // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, - // so it applies to the whole NEE term (diffuse + specular). - if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { - vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); - } - if (max(vis.r, max(vis.g, vis.b)) > 0.0) { - float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) - float3 h = normalize(lightDir + v); - float ndh = max(0.0, dot(n, h)); - float ndv = max(1.0e-4, dot(n, v)); - float vdh = max(0.0, dot(v, h)); - float D = ggxD(ndh, rough); - float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); // separable Smith masking - float3 F = fresnelSchlick(vdh, F0); - brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular - L += throughput * brdf * worldPush.lightRadiance.xyz * ndl * vis; - } - } - - // RIS direct lighting from block emitters (single-frame only, no temporal reservoir reuse). - // Front lobe + SSS backlighting are resampled together and share ONE shadow ray — a light can - // only be in front of n or behind it, never both, so combining them into one target function is - // exact, not an approximation. The SSS term is gated off past MAX_SSS_DIFFUSE_DEPTH by passing - // sss=0 there (falls back to plain front-only RIS). Keyed on diffuseDepth, not `bounce`, for the - // same reason as RIS_MAX_DIFFUSE_DEPTH above: leaves seen through a window are still the first - // surface this path has shaded, and should still backlight. - if (risActive) { - float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; - Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, - uint(diffuseDepth), seed, proposalSeed); - float3 risVis; - L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, - activeSss, risVis); - } - - // Thin-surface SSS transmission. Light entering from the back face scatters through toward the - // viewer via a forward-biased HG phase (leaves/grass backlit by the sun glow when you look - // through them toward the light source). Shadow ray fires from the back face to avoid - // self-occlusion. cosT = dot(lightDir, rd): rd tracks the ray toward the sun when the sun is - // behind the slab, giving cosT ≈ 1 → forward-scatter peak. - if (sss > 0.0 && diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH) { - float backNdl = max(0.0, dot(-n, lightDir)); - if (backNdl > 0.0) { - VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); - float3 visB = shadowBack.transmittance; - // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should - // flicker with the same light bands as the floor around it. - if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { - visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, - lightDir, shadowBack.waterHitT); - } - if (max(visB.r, max(visB.g, visB.b)) > 0.0) { - float cosT = dot(lightDir, rd); - L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * worldPush.lightRadiance.xyz * visB; - } - } - } - - // Every shading term for this surface is now accounted for, so it counts toward the shaded depth. - // The dielectric branches above return before reaching here, which is exactly the distinction - // between this and `bounce`. - diffuseDepth++; - - // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their - // relative reflectance. - float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 - ? 1.0 - : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); - if (rndf(seed) < ps) { - float3 l; - if (exactSpecular) { - // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the - // degenerate VNDF at alpha=0 and preserves a genuinely sharp reflection without a floor. - l = reflect(rd, n); - throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; - } else { - float3 h = sampleGGXVNDF(n, v, rough, seed); - l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal - float ndl2 = dot(n, l); - if (ndl2 <= 0.0) { - break; // microfacet reflects below the surface: terminate this path - } - // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. - float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); - throughput *= F * ggxG1(ndl2, rough) / ps; - rayConeSpread = max(rayConeSpread, rough * RAY_CONE_GLOSSY_SPREAD_SCALE); - } - ro = p; - rd = l; - showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc - } else { - throughput *= diffAlb / (1.0 - ps); - ro = p; - rd = cosineDir(n, seed); - rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); - showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) - } - - // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - break; - } - throughput /= q; - } - } - return L; -} diff --git a/shaders/world/lighting.slang b/shaders/world/lighting.slang index 0910a5fb..9342804c 100644 --- a/shaders/world/lighting.slang +++ b/shaders/world/lighting.slang @@ -2,18 +2,24 @@ // INDIRECT PASS ONLY — the primary pass shades nothing. Depends on trace and math. // contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted). -struct Reservoir { - float3 pos; // light sample point (rebased world space) - float3 lnrm; // emitter outward normal - float3 le; // emitter radiance - float area; // emitter rectangle area (the area-light pdf factor) - float M; - float W; - float wSum; - float phat; + +import world_common; +import world_core; +import math; +import trace; + +public struct Reservoir { + public float3 pos; // light sample point (rebased world space) + public float3 lnrm; // emitter outward normal + public float3 le; // emitter radiance + public float area; // emitter rectangle area (the area-light pdf factor) + public float M; + public float W; + public float wSum; + public float phat; }; -Reservoir resEmpty() { +public Reservoir resEmpty() { Reservoir r; r.pos = float3(0.0, 0.0, 0.0); r.lnrm = float3(0.0, 0.0, 0.0); @@ -34,7 +40,7 @@ Reservoir resEmpty() { // - twoSided (particles): billboards have no back face, ndl=abs(ndl) folds both sides into the front; // - back hemisphere with sss>0 (LabPBR leaves/grass): HG transmission phase — cosT = dot(wi,rd) peaks // when the ray points from the light through the slab toward the camera (same as the sun SSS term). -float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n, +public float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, bool twoSided, float sss, out float phat) { phat = 0.0; @@ -83,7 +89,7 @@ float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 h } // Select one light from the emitted-power distribution in O(1). -void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { +public void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); uint column = min(uint(aliasSample), worldPush.lightCount - 1u); if (pc.lightAliasAddr != 0) { @@ -95,7 +101,7 @@ void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { } } -bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { +public bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { cell.spanOffset = 0u; cell.spanCount = 0u; cell.invWeightSum = 0.0; @@ -113,7 +119,7 @@ bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { return cell.spanCount > 0u; } -void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, +public void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, out uint lightIndex) { float aliasSample = rndf(proposalSeed) * float(lightCount); uint column = min(uint(aliasSample), lightCount - 1u); @@ -123,7 +129,7 @@ void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSee lightIndex = firstLight + localIndex; } -float unpackUnsignedFloat(uint bits, uint mantissaBits) { +public float unpackUnsignedFloat(uint bits, uint mantissaBits) { uint mantissaMask = (1u << mantissaBits) - 1u; uint mantissa = bits & mantissaMask; uint exponent = (bits >> mantissaBits) & 31u; @@ -134,44 +140,44 @@ float unpackUnsignedFloat(uint bits, uint mantissaBits) { * exp2(float(int(exponent) - 15)); } -float3 lightRadiance(Light light) { +public float3 lightRadiance(Light light) { uint packed = light.le; return float3(unpackUnsignedFloat(packed & 0x7ffu, 6u), unpackUnsignedFloat((packed >> 11u) & 0x7ffu, 6u), unpackUnsignedFloat((packed >> 22u) & 0x3ffu, 5u)); } -int3 lightSectionCoord(Light light) { +public int3 lightSectionCoord(Light light) { uint packed = light.section; return int3(int(packed & 0x3ffu), int((packed >> 10u) & 0x3ffu), int((packed >> 20u) & 0x3ffu)); } // The half axes share three lanes two-at-a-time; see the Light record in world_common. -float3 lightHalfU(Light light) { +public float3 lightHalfU(Light light) { return float3(unpackHalf2(light.halfUxy), unpackHalf2(light.halfUzVx).x); } -float3 lightHalfV(Light light) { +public float3 lightHalfV(Light light) { return float3(unpackHalf2(light.halfUzVx).y, unpackHalf2(light.halfVyz)); } // U x V drives both the emitter normal and the rectangle area, so it is computed once and shared. -float3 lightCrossUV(Light light) { +public float3 lightCrossUV(Light light) { return cross(lightHalfU(light), lightHalfV(light)); } // The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store. -float lightArea(Light light) { +public float lightArea(Light light) { return 4.0 * length(lightCrossUV(light)); } -float3 lightGeometricNormal(Light light) { +public float3 lightGeometricNormal(Light light) { float3 normal = normalize(lightCrossUV(light)); return (light.section & 0x40000000u) != 0u ? -normal : normal; } -float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, +public float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, float localProbability) { float power = max(0.0, lightArea(light) * luminance(le)); float globalPdf = pc.lightAliasAddr != 0 @@ -187,7 +193,7 @@ float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, return localProbability * localPdf + (1.0 - localProbability) * globalPdf; } -void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, +public void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, out uint lightIndex) { ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); float aliasSample = rndf(proposalSeed) * float(cell.spanCount); @@ -205,7 +211,7 @@ void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, // the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. // Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single // Light48 load: section power cancels with the section-local light PDF. -void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, +public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, out uint lightIndex) { if (useLocal) { selectLightGridSpanLight(cell, proposalSeed, lightIndex); @@ -233,7 +239,7 @@ void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposal // global candidate, so M=2 is the smallest count that still schedules one local AND one global; at M=1 // localProbability collapses to 0 and the light-grid proposal that makes nearby emitters sample well is // gone entirely. -static const uint SECONDARY_RIS_DIVISOR = 4u; +public static const uint SECONDARY_RIS_DIVISOR = 4u; // Deepest SHADED surface at which RIS emitter NEE runs. Past it, emitters are gathered only when a path // hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep, so the @@ -251,11 +257,11 @@ static const uint SECONDARY_RIS_DIVISOR = 4u; // Caveat this does not solve: a smooth specular bounce IS a shaded surface, so a wall seen in a metal // block counts as depth 1. Accumulated roughness rather than a vertex count is the principled fix, and // is what a radiance cache would need anyway to decide cache-vs-trace. -static const uint RIS_MAX_DIFFUSE_DEPTH = 2u; +public static const uint RIS_MAX_DIFFUSE_DEPTH = 2u; // Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen // sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. -Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, +public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, bool twoSided, float sss, uint shadedDepth, inout uint seed, inout uint proposalSeed) { Reservoir r = resEmpty(); @@ -323,7 +329,7 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl // ray, and return throughput-free radiance contrib*vis*W. The one ray serves whichever term fired for // the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the // sample's side of the surface. -float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, +public float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, bool twoSided, float sss, out float3 vis) { vis = float3(0.0, 0.0, 0.0); if (s.W <= 0.0 || s.phat <= 0.0) { diff --git a/shaders/world/math.slang b/shaders/world/math.slang index 22aacc95..62d09462 100644 --- a/shaders/world/math.slang +++ b/shaders/world/math.slang @@ -1,7 +1,10 @@ // Stateless math: luminance, GGX (D/G/VNDF), Fresnel, Henyey-Greenstein, the RR specular-albedo // fit, the PCG stream, and direction sampling. No bindings, no globals, no rays. -float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } +import world_common; +import world_core; + +public float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } // GGX normal distribution. // NOTE: the 1e-7 denominator guard is not merely a divide-by-zero epsilon, and it is load-bearing. At @@ -12,19 +15,19 @@ float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } // between them. Removing the guard restores the physical peak AND the double count, and makes the // single-sample sun estimator explode wherever the lobe is tighter than the disc (~0.0047 rad). The // real fix is MIS (or a disc-aware specular light sample), not an epsilon edit — left as-is. -float ggxD(float ndh, float alpha) { +public float ggxD(float ndh, float alpha) { float a2 = alpha * alpha; float d = ndh * ndh * (a2 - 1.0) + 1.0; return a2 / (PI * d * d + 1.0e-7); } // Smith GGX masking term for one direction (separable form; G2 = G1(v) * G1(l)). -float ggxG1(float ndx, float alpha) { +public float ggxG1(float ndx, float alpha) { float a2 = alpha * alpha; return 2.0 * ndx / (ndx + sqrt(a2 + (1.0 - a2) * ndx * ndx) + 1.0e-7); } -float3 fresnelSchlick(float cosT, float3 f0) { +public float3 fresnelSchlick(float cosT, float3 f0) { float m = clamp(1.0 - cosT, 0.0, 1.0); float m2 = m * m; return f0 + (1.0 - f0) * (m2 * m2 * m); @@ -32,21 +35,21 @@ float3 fresnelSchlick(float cosT, float3 f0) { // Henyey–Greenstein phase function (with 4π normalisation so it integrates to 1 over the sphere). // g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). -static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional -static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) +public static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional +public static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) // Deepest SHADED surface at which the SSS transmission shadow ray fires. diffuseDepth, not `bounce`: the // glass/water continue branches don't shade a surface, so foliage seen through a window is still // diffuseDepth 0 and should backlight like any other primary leaf — see RIS_MAX_DIFFUSE_DEPTH for the // same distinction and why it matters. -static const int MAX_SSS_DIFFUSE_DEPTH = 1; // fire on the first-shaded surface + one indirect hit -float hg(float cosT, float g) { +public static const int MAX_SSS_DIFFUSE_DEPTH = 1; // fire on the first-shaded surface + one indirect hit +public float hg(float cosT, float g) { float g2 = g * g; return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); } // NVIDIA RR guide-buffer helper: integrated/view-dependent specular reflectivity, not raw F0. // alpha is GGX alpha (== the linear roughness materials store), NoV is the first-hit view cosine. -float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { +public float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { NoV = abs(NoV); float NoV2 = NoV * NoV; float alpha2 = alpha * alpha; @@ -81,7 +84,7 @@ float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { // the incoming side; etaI/etaT are the IORs the ray travels from / into. Returns 1.0 on total internal // reflection (sin of the transmitted angle >= 1), which matches refract() returning the zero vector — // so reflect/refract selection and TIR stay consistent. -float fresnelDielectric(float cosI, float etaI, float etaT) { +public float fresnelDielectric(float cosI, float etaI, float etaT) { float sinT2 = (etaI * etaI) / (etaT * etaT) * max(0.0, 1.0 - cosI * cosI); if (sinT2 >= 1.0) { return 1.0; @@ -93,19 +96,19 @@ float fresnelDielectric(float cosI, float etaI, float etaT) { } // PCG hash RNG. -uint pcg(inout uint s) { +public uint pcg(inout uint s) { s = s * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; } -float rndf(inout uint s) { +public float rndf(inout uint s) { // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` // tests (most seriously the F == 1 total-internal-reflection branch). return float(pcg(s) >> 8u) * (1.0 / 16777216.0); } -float3 primaryRayDir(float2 ndc) { +public float3 primaryRayDir(float2 ndc) { float4 nearH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 1.0, 1.0)); float4 farH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 0.0, 1.0)); float3 nearP = nearH.xyz / nearH.w; @@ -113,7 +116,7 @@ float3 primaryRayDir(float2 ndc) { return normalize(farP - nearP); } -float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { +public float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { float2 onePixelNdc = 2.0 / size; float3 dx = primaryRayDir(ndc + float2(onePixelNdc.x, 0.0)); float3 dy = primaryRayDir(ndc + float2(0.0, onePixelNdc.y)); @@ -123,7 +126,7 @@ float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { // Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the // importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation // throughput is just *= albedo with no PI/cos bookkeeping left over. -float3 cosineDir(float3 n, inout uint s) { +public float3 cosineDir(float3 n, inout uint s) { float u1 = rndf(s); float u2 = rndf(s); float r = sqrt(u1); @@ -140,7 +143,7 @@ float3 cosineDir(float3 n, inout uint s) { // visible disc in world.rmiss uses, built from the same celestial tangent frame (right = arc-travel // direction, from worldPush.celestial.xyz). Averaged over frames by DLSS-RR this yields soft penumbrae that // widen with occluder distance (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). -float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { +public float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { float3 right = normalize(cross(axis, worldPush.celestial.xyz)); float3 up = cross(right, axis); float t = tan(halfAngle); @@ -152,7 +155,7 @@ float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { // Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the // sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the // importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). -float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { +public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); float3 b = normalize(cross(n, t)); t = cross(b, n); @@ -175,16 +178,9 @@ float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { return normalize(Ne.x * t + Ne.y * b + Ne.z * n); // -> world space } -// ===== RIS emitter NEE ================================================================= -// Block emitters (torches, glowstone, lava, ...) get explicit NEE: per diffuse vertex, stream M uniform -// candidates from the global light buffer through a weighted reservoir keyed by the UNSHADOWED target -// p-hat = luminance(f * Le * G * area), then spend ONE shadow ray on the survivor. Sun/moon NEE stays -// separate and additive. Lights are footprint bounding RECTANGLES (RtLightCollector): sampling a point -// uniformly over the rectangle has pdf 1/area, so `area` rides the contribution like any area light. -// Single-frame RIS only (no temporal reservoir reuse): DLSS-RR's own history already denoises this well -// enough that a persistent per-pixel reservoir wasn't worth the added complexity and VRAM. -// One resample serves three mutually exclusive receiver terms (see evalSampleContrib): front BRDF, -// two-sided particle billboards, and LabPBR SSS backscatter from lights BEHIND the surface — all share -// the reservoir and its single shadow ray, whose origin follows the survivor's side of the surface. - -// A reservoir in registers. pos/lnrm/le/area describe the chosen emitter sample; W is its unbiased +public float3 srgbToLinear(float3 c) { + float3 lo = c / 12.92; + float3 hi = pow((c + 0.055) / 1.055, float3(2.4, 2.4, 2.4)); + float3 isHi = step(float3(0.04045, 0.04045, 0.04045), c); + return lerp(lo, hi, isHi); +} diff --git a/shaders/world/medium.slang b/shaders/world/medium.slang index 1f903b1c..dd284c81 100644 --- a/shaders/world/medium.slang +++ b/shaders/world/medium.slang @@ -5,9 +5,13 @@ // water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean // tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor // keeps even a white-tinted body very slightly absorbing so deep water never reads as clear vacuum. -static const float WATER_DENSITY = 0.05; -static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); -float3 waterExtinction(float3 tint) { + +import world_common; +import world_core; + +public static const float WATER_DENSITY = 0.05; +public static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); +public float3 waterExtinction(float3 tint) { return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); } @@ -16,8 +20,8 @@ float3 waterExtinction(float3 tint) { // block means sigma = -ln(t). `transmission` scales that transmittance, so it keeps the sense the // material's authored factor has everywhere else — 1 passes the tint through unchanged, 0 is opaque. // Clamped away from zero so a fully saturated texel stays finite instead of producing an infinite sigma. -static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks -float3 volumeExtinction(float3 tint, float transmission) { +public static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks +public float3 volumeExtinction(float3 tint, float transmission) { float3 blockTransmittance = clamp(clamp(tint, 0.0, 1.0) * clamp(transmission, 0.0, 1.0), 1.0e-3, 1.0); return -log(blockTransmittance) / VOLUME_TINT_REFERENCE_DISTANCE; @@ -35,18 +39,18 @@ float3 volumeExtinction(float3 tint, float transmission) { // covers air->water->glass and air->glass->water, which is the realistic worst case; anything deeper // degrades to air on the way out, and because `entering` is re-derived per face from geometry rather // than toggled, the path re-synchronises at the next crossing instead of staying corrupted. -struct Medium { - float ior; - float3 extinction; - bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific +public struct Medium { + public float ior; + public float3 extinction; + public bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific }; -struct MediumStack { - Medium current; - Medium outer; +public struct MediumStack { + public Medium current; + public Medium outer; }; -Medium airMedium() { +public Medium airMedium() { Medium m; m.ior = 1.0; m.extinction = float3(0.0, 0.0, 0.0); @@ -54,26 +58,26 @@ Medium airMedium() { return m; } -MediumStack makeMediumStack(Medium start) { +public MediumStack makeMediumStack(Medium start) { MediumStack s; s.current = start; s.outer = airMedium(); return s; } -void mediumPush(inout MediumStack stack, Medium entered) { +public void mediumPush(inout MediumStack stack, Medium entered) { stack.outer = stack.current; stack.current = entered; } -void mediumPop(inout MediumStack stack) { +public void mediumPop(inout MediumStack stack) { stack.current = stack.outer; stack.outer = airMedium(); } // Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume // dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. -Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { +public Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { Medium m; m.ior = ior; m.extinction = isWater ? waterExtinction(tint) : volumeExtinction(tint, transmission); diff --git a/shaders/world/primary.slang b/shaders/world/primary.slang deleted file mode 100644 index 1087b098..00000000 --- a/shaders/world/primary.slang +++ /dev/null @@ -1,155 +0,0 @@ -// tracePrimary: camera ray through the dielectric chain to the first surface worth shading, capturing -// guides on the way. Returns the continuation the indirect pass resumes. -// PRIMARY PASS ONLY. Depends on guides. - -// later trace rather than keeping a continuation live across traversal. -PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool alive) { - float3 ro = seg.ro; - float3 rd = seg.rd; - float3 throughput = seg.throughput; - MediumStack medium = seg.medium; - float rayConeWidth = seg.rayConeWidth; - float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); - uint seed = seg.seed; - bool showCelestial = seg.showCelestial; - int diffuseDepth = seg.diffuseDepth; - int maxBounces = int(worldPush.maxBounces); - int rrStart = maxBounces <= 3 ? 1 : 2; - bool waterWaves = (worldPush.flags & 16u) != 0u; - alive = false; - - for (int bounce = seg.bounce; bounce <= maxBounces; ++bounce) { - PathSegment terminal = makePathSegment(ro, rd, throughput, medium, - rayConeWidth, rayConeSpread, seed, bounce, diffuseDepth, - showCelestial, false, false); - traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, - ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); - - if (payload.hitT < 0.0) { - if (bounce == 0 && captureGuides) { - gv_normal = float3(0.0, 0.0, 0.0); - gv_albedo = SKY_DIFF_ALBEDO; - gv_rough = 1.0; - gv_hitCamRel = rd * 1.0e6; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = float3(0.0, 0.0, 0.0); - gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); - } - alive = true; - return terminal; - } - - float3 n = payload.normal; - float3 hitPos = ro + rd * payload.hitT; - uint material = payloadMaterial(); - if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) { - if (bounce == 0 && captureGuides) { - gv_normal = n; - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; - if (material == MATERIAL_PARTICLE) { - gv_albedo = payload.albedo; - gv_rough = 1.0; - gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, - float3(0.0, 0.0, 0.0)); - } else { - float rough = clamp(payloadRoughness(), 0.0, 1.0); - rough = rough <= MIRROR_ALPHA_MAX ? 0.0 : rough; - float metal = clamp(payloadMetalness(), 0.0, 1.0); - float3 diffAlb = payload.albedo * (1.0 - metal); - float3 v = -rd; - gv_albedo = diffAlb; - gv_rough = rough; - gv_spec = makeSpecSurface(gv_hitCamRel, n, rough, - rrSpecularAlbedo(payload.f0, rough, dot(n, v))); - } - } - alive = true; - return terminal; - } - - // This hit is consumed in pass A, so carry its travelled-medium absorption and cone growth into - // the record for the next trace. The terminal hit above is deliberately not consumed here. - if (any(medium.current.extinction > 0.0)) { - throughput *= exp(-medium.current.extinction * payload.hitT); - } - rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), - RAY_CONE_MIN_WIDTH); - - bool isWater = material == MATERIAL_WATER; - bool entering = payloadDielectricEntering(); - float3 geometricNormal = n; - float3 previousNormal = n; - if (isWater && waterWaves) { - float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; - float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); - if (bounce == 0 && captureGuides && abs(geometricNormal.y) >= 0.5) { - float2 currentGrad; - float2 previousGrad; - waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, - worldPush.waterAnchor.z, waterFootprint, currentGrad, previousGrad); - float orientation = geometricNormal.y >= 0.0 ? 1.0 : -1.0; - n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); - previousNormal = orientation - * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); - } else { - n = applyWaterWaves(geometricNormal, waterDomain, - worldPush.waterParams.w, waterFootprint); - } - } - - float transmission = clamp(payloadTransmission(), 0.0, 1.0); - Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), - payload.albedo, transmission); - float etaI = medium.current.ior; - float etaT = entering ? entered.ior : medium.outer.ior; - float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); - float3 transmittedDir = refract(rd, n, etaI / etaT); - float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - - if (bounce == 0 && captureGuides) { - gv_normal = n; - gv_rough = 0.0; - gv_albedo = float3(0.0, 0.0, 0.0); - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; - gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, - gv_rough, float3(F, F, F)); - if (dot(transmittedDir, transmittedDir) > 0.0) { - resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, - entering ? entered.ior : medium.outer.ior, - transmitBias, rayConeWidth, rayConeSpread); - } - } - - if (rndf(seed) < F) { - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); - } else { - if (dot(transmittedDir, transmittedDir) <= 0.0) { - return terminal; - } - rd = normalize(transmittedDir); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); - if (entering) { - mediumPush(medium, entered); - } else { - mediumPop(medium); - } - } - showCelestial = true; - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - return terminal; - } - throughput /= q; - } - } - return seg; -} diff --git a/shaders/world/segment.slang b/shaders/world/segment.slang index ba38ec54..0210e89d 100644 --- a/shaders/world/segment.slang +++ b/shaders/world/segment.slang @@ -10,22 +10,27 @@ // This is deliberately shaped like a record that could live in a buffer rather than a register: the // planned split into a primary/guide pass and an indirect pass hands continuations between dispatches, // and this is the payload it would write. -struct PathSegment { - float3 ro; - float3 rd; - float3 throughput; - MediumStack medium; - float rayConeWidth; - float rayConeSpread; - uint seed; - int bounce; // interfaces already consumed, so RR start and the bounce cap stay global - int diffuseDepth; // surfaces already SHADED (see the field of the same name in tracePath) - bool showCelestial; - bool captureGuides; - bool maySplit; // may still spawn a deterministic Fresnel branch + +import world_common; +import world_core; +import medium; + +public struct PathSegment { + public float3 ro; + public float3 rd; + public float3 throughput; + public MediumStack medium; + public float rayConeWidth; + public float rayConeSpread; + public uint seed; + public int bounce; // interfaces already consumed, so RR start and the bounce cap stay global + public int diffuseDepth; // surfaces already SHADED (see the field of the same name in tracePath) + public bool showCelestial; + public bool captureGuides; + public bool maySplit; // may still spawn a deterministic Fresnel branch }; -PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, +public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, float rayConeWidth, float rayConeSpread, uint seed, int bounce, int diffuseDepth, bool showCelestial, bool captureGuides, bool maySplit) { @@ -45,29 +50,29 @@ PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack return s; } // field is a uint, so Std430DataLayout gives this an exact 48-byte stride. -struct PackedPathSegment { - float3 ro; - uint rd; - uint throughput; - uint currentExtinction; - uint outerExtinction; - uint mediumIors; - uint rayCone; - uint seed; - uint pathFlags; - uint pixelSample; +public struct PackedPathSegment { + public float3 ro; + public uint rd; + public uint throughput; + public uint currentExtinction; + public uint outerExtinction; + public uint mediumIors; + public uint rayCone; + public uint seed; + public uint pathFlags; + public uint pixelSample; }; -static const uint PATH_VALID = 1u << 11u; +public static const uint PATH_VALID = 1u << 11u; -float2 octEncode(float3 direction) { +public float2 octEncode(float3 direction) { float3 n = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 1.0e-20); float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); float2 oct = n.z >= 0.0 ? n.xy : (1.0 - abs(n.yx)) * signNotZero; return oct * 0.5 + 0.5; } -float3 octDecode(float2 encoded) { +public float3 octDecode(float2 encoded) { float2 oct = encoded * 2.0 - 1.0; float3 n = float3(oct, 1.0 - abs(oct.x) - abs(oct.y)); if (n.z < 0.0) { @@ -77,17 +82,17 @@ float3 octDecode(float2 encoded) { return normalize(n); } -uint packUnorm16x2(float2 v) { +public uint packUnorm16x2(float2 v) { uint2 q = uint2(round(clamp(v, 0.0, 1.0) * 65535.0)); return q.x | (q.y << 16u); } -float2 unpackUnorm16x2(uint p) { +public float2 unpackUnorm16x2(uint p) { return float2(p & 0xffffu, p >> 16u) / 65535.0; } // DXGI_FORMAT_R9G9B9E5_SHAREDEXP, implemented locally because Slang exposes no packing intrinsic. -uint packRgb9e5(float3 value) { +public uint packRgb9e5(float3 value) { float3 v = clamp(value, 0.0, 65408.0); float maxChannel = max(v.x, max(v.y, v.z)); uint exponent = maxChannel < exp2(-16.0) @@ -103,12 +108,12 @@ uint packRgb9e5(float3 value) { return mantissa.x | (mantissa.y << 9u) | (mantissa.z << 18u) | (exponent << 27u); } -float3 unpackRgb9e5(uint p) { +public float3 unpackRgb9e5(uint p) { float scale = exp2(float(int(p >> 27u) - 24)); return float3(p & 0x1ffu, (p >> 9u) & 0x1ffu, (p >> 18u) & 0x1ffu) * scale; } -PackedPathSegment packPathSegment(PathSegment seg, uint pixelIndex, uint sampleIndex, bool valid) { +public PackedPathSegment packPathSegment(PathSegment seg, uint pixelIndex, uint sampleIndex, bool valid) { PackedPathSegment p; p.ro = seg.ro; p.rd = packUnorm16x2(octEncode(seg.rd)); @@ -128,7 +133,7 @@ PackedPathSegment packPathSegment(PathSegment seg, uint pixelIndex, uint sampleI return p; } -PathSegment unpackPathSegment(PackedPathSegment p, out bool valid) { +public PathSegment unpackPathSegment(PackedPathSegment p, out bool valid) { float2 iors = unpackHalf2(p.mediumIors); Medium current; current.ior = iors.x; diff --git a/shaders/world/trace.slang b/shaders/world/trace.slang index e3fb12ad..14729dde 100644 --- a/shaders/world/trace.slang +++ b/shaders/world/trace.slang @@ -2,16 +2,20 @@ // ray — reordered radiance, non-reordered guide probe, and shadow visibility. Depends on core. // receiver billboards only. The first-person camera owner uses 0x01 (secondary only). -static const uint CULL_SECONDARY = 0x01u; -static const uint CULL_PRIMARY = 0x02u; -static const uint TERRAIN_BUCKETS = 4u; -static const uint SBT_RADIANCE = 0u; -static const uint SBT_SHADOW = TERRAIN_BUCKETS; -static const uint SBT_STRIDE_BUCKET = 1u; -static const uint MISS_RADIANCE = 0u; -static const uint MISS_GUIDE = 1u; -RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { +import world_common; +import world_core; + +public static const uint CULL_SECONDARY = 0x01u; +public static const uint CULL_PRIMARY = 0x02u; +public static const uint TERRAIN_BUCKETS = 4u; +public static const uint SBT_RADIANCE = 0u; +public static const uint SBT_SHADOW = TERRAIN_BUCKETS; +public static const uint SBT_STRIDE_BUCKET = 1u; +public static const uint MISS_RADIANCE = 0u; +public static const uint MISS_GUIDE = 1u; + +public RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { RayDesc r; r.Origin = origin; r.TMin = tmin; @@ -20,7 +24,7 @@ RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { return r; } -float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, +public float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, float3 outgoingDirection, float bias) { float3 sideNormal = dot(surfaceNormal, outgoingDirection) >= 0.0 ? surfaceNormal : -surfaceNormal; @@ -31,7 +35,7 @@ float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, // (show-celestial gate, consumed by world.rmiss) and the packed ray cone (consumed by world.rchit for // texture LOD). Every other member is an OUTPUT that world.rchit/world.rmiss write, so it carries no // information into a trace and is initialized to a neutral value here. -Payload makeRadiancePayload(uint flags, uint rayCone) { +public Payload makeRadiancePayload(uint flags, uint rayCone) { Payload p; p.albedo = half3(0.0h, 0.0h, 0.0h); p.normal = half3(0.0h, 0.0h, 0.0h); @@ -58,7 +62,7 @@ Payload makeRadiancePayload(uint flags, uint rayCone) { // across the one point where SER must spill every live value, which profiling showed to be the peak // live state in this shader. Rebuilding costs a few constant moves and leaves only these two words // spanning the reorder. -void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, +public void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, bool showCelestial, float rayConeWidth, float rayConeSpread) { uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); @@ -75,14 +79,14 @@ void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, flo // result is consumed immediately, so paying a reorder barrier and carrying guide state across it only // adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, // not sky radiance. -void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, +public void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, float rayConeWidth, float rayConeSpread) { payload = makeRadiancePayload(0u, packHalf2(float2(rayConeWidth, rayConeSpread))); TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_GUIDE, makeRay(ro, tmin, rd, tmax), payload); } -Payload makeShadowPayload() { +public Payload makeShadowPayload() { Payload shadowPayload; shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit @@ -97,7 +101,7 @@ Payload makeShadowPayload() { return shadowPayload; } -VisibilityResult visibility(float3 origin, float3 dir, float tmax) { +public VisibilityResult visibility(float3 origin, float3 dir, float tmax) { // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow // path uses only albedo as accumulated transmittance and hitT as the nearest-water crossing. Payload shadowPayload = makeShadowPayload(); diff --git a/shaders/world/water.slang b/shaders/world/water.slang index 03b1e90a..1766e431 100644 --- a/shaders/world/water.slang +++ b/shaders/world/water.slang @@ -13,13 +13,17 @@ // gradient) so wavefronts wander instead of ruling straight periodic lines across the sea. // The domain is world-anchored (see applyWaterWaves) so the pattern is pinned in world space, and the // caustics (waterCaustic) differentiate this same field, so they stay in sync with the visible ripples. -static const float WATER_WAVE_STRENGTH = 0.3; // slope scale (higher = choppier glints / more broken reflections) -static const float WAVE_SPEED = 0.8; // global time scale; dispersion keeps relative speeds physical -static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each octave 1.5× shorter) -static const float WAVE_G = 9.81; -static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction -static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components -float waterWaveLodWeight(float wavelength, float footprint) { + +import world_common; +import world_core; + +public static const float WATER_WAVE_STRENGTH = 0.3; // slope scale (higher = choppier glints / more broken reflections) +public static const float WAVE_SPEED = 0.8; // global time scale; dispersion keeps relative speeds physical +public static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each octave 1.5× shorter) +public static const float WAVE_G = 9.81; +public static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction +public static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components +public float waterWaveLodWeight(float wavelength, float footprint) { float cyclesPerPixel = max(footprint, 0.0) / max(wavelength, 1.0e-4); return 1.0 - smoothstep(0.25, 0.5, cyclesPerPixel); } @@ -27,7 +31,7 @@ float waterWaveLodWeight(float wavelength, float footprint) { // One walk of the wave spectrum, shared by every consumer so the spectrum constants exist once. WITH_DT // is a generic so the derivative terms are compiled out entirely for callers that only want the // gradient — the trigonometry is shared, but the chain-rule work is not free. -void waterWaveSpectrum(float2 p, float t, float footprint, +public void waterWaveSpectrum(float2 p, float t, float footprint, out float2 grad, out float2 gradDt) { // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; @@ -77,20 +81,20 @@ void waterWaveSpectrum(float2 p, float t, float footprint, gradDt *= WATER_WAVE_STRENGTH; } -float2 waterWaveGrad(float2 p, float t, float footprint) { +public float2 waterWaveGrad(float2 p, float t, float footprint) { float2 grad, gradDt; waterWaveSpectrum<0>(p, t, footprint, grad, gradDt); return grad; } -float2 waterWaveGrad(float2 p, float t) { +public float2 waterWaveGrad(float2 p, float t) { return waterWaveGrad(p, t, 0.0); } // Evaluate the current gradient and linearly rewind it to the previous phase in one spectrum walk. // Frame deltas are bounded on the CPU, so this avoids a second set of trigonometric/exponential work // while remaining accurate over the sub-frame interval used by reflection reprojection. -void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, +public void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, out float2 currentGrad, out float2 previousGrad) { float2 gradDt; waterWaveSpectrum<1>(p, currentT, footprint, currentGrad, gradDt); @@ -101,7 +105,7 @@ void waterWaveGradTemporal(float2 p, float currentT, float previousT, float foot // keep their flat geometric normal. `worldXZ` must be a world-stable coordinate (rebased hit + the pushed // anchor) so the ripples stay pinned in the world. `nGeo` is already oriented toward the viewer, so the // down-viewed underside (n.y < 0) takes the mirrored slope (-up). -float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { +public float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { if (abs(nGeo.y) < 0.5) return nGeo; float2 grad = waterWaveGrad(worldXZ, t, footprint); float3 up = normalize(float3(-grad.x, 1.0, -grad.y)); @@ -117,19 +121,19 @@ float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { // fold caustics where det → 0). Because this uses the SAME wave field as the visible surface normals, // the caustic pattern stays in sync with the ripples, and the per-sample sun-quad jitter (sampleSquare) // shifts the pattern per sample → caustics physically blur with depth under DLSS-RR accumulation. -static const float CAUSTIC_EPS = 0.25; // finite-difference step (m); also low-passes sub-step wave detail -static const float CAUSTIC_MAX = 6.0; // focusing clamp: keeps fold-line singularities from becoming fireflies +public static const float CAUSTIC_EPS = 0.25; // finite-difference step (m); also low-passes sub-step wave detail +public static const float CAUSTIC_MAX = 6.0; // focusing clamp: keeps fold-line singularities from becoming fireflies // Horizontal landing offset of a refracted sun ray entering the surface at world-stable xz, intersected // with the receiver plane h metres below. inc = sunlight travel direction (downward, unit). -float2 causticLanding(float2 xz, float t, float3 inc, float h) { +public float2 causticLanding(float2 xz, float t, float3 inc, float h) { float2 g = waterWaveGrad(xz, t); float3 nw = normalize(float3(-g.x, 1.0, -g.y)); float3 rdw = refract(inc, nw, 1.0 / WATER_IOR); // air→water: never TIR return xz + rdw.xz * (h / max(-rdw.y, 0.05)); } -float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { +public float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { // Grazing light: the receiver-plane intersection degenerates and caustics vanish in reality anyway // (long slant paths absorb + the pattern smears out) — fade the effect to neutral 1.0. float fade = smoothstep(0.06, 0.18, lightDir.y); @@ -147,19 +151,3 @@ float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { float focus = (CAUSTIC_EPS * CAUSTIC_EPS) / max(det, 1.0e-5); return lerp(1.0, min(focus, CAUSTIC_MAX), fade); } - -// Below this GGX alpha the lobe is treated as a delta mirror. The threshold is not about detecting an -// authored zero — it is about the point where a finite lobe stops being distinguishable from a mirror. -// A GGX lobe's half-angle is ~alpha, and the sun's angular radius is ~0.0047 rad, so alpha = 4e-4 is -// ~12x tighter than the sun disc and far under a pixel footprint at any reflection distance. Nothing -// below it can be resolved, and the delta path is both sharper and better conditioned there. -// -// Two things follow from taking the delta path, and both are why the threshold is set here rather than -// at the 8-bit quantum: -// * ggxD(_, 0) is exactly 0, so the NEE specular term vanishes by construction and the sun arrives -// solely via the mirror ray hitting the disc (showCelestial). That is the correct, unbiased -// accounting — see the double-count note on ggxD below. -// * There is no floor to apply above the threshold: alpha > 4e-4 is well conditioned for both the -// VNDF sample and the NDF, so authored roughness passes through unmodified and the mapping stays -// continuous. A separate MIN_ROUGH would only reintroduce a cliff. -// diff --git a/shaders/world/world.rahit.slang b/shaders/world/world.rahit.slang index 60b082da..09bb6522 100644 --- a/shaders/world/world.rahit.slang +++ b/shaders/world/world.rahit.slang @@ -12,6 +12,7 @@ // a trace. Shadow traversal uses albedo.rgb as accumulated transmittance and hitT as the nearest-water // crossing; radiance cutout paths do not touch either field. import world_common; +import math; [[vk::push_constant]] WorldPushConstants pc; diff --git a/shaders/world/world.rchit.slang b/shaders/world/world.rchit.slang index a57b889d..d0bd4c18 100644 --- a/shaders/world/world.rchit.slang +++ b/shaders/world/world.rchit.slang @@ -7,6 +7,7 @@ // through worldPushAddr first would cost an extra global-memory load just to find the address of the // load we actually want. The cold WorldPush struct is dereferenced only for the breaking overlay. import world_common; +import math; [[vk::push_constant]] WorldPushConstants pc; diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 26868a8d..506de38c 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -19,15 +19,355 @@ // // SER: the build emits EXT and NV shader-invocation-reorder variants from this source. import world_common; +import world_core; +import math; +import medium; +import segment; +import water; +import trace; +import lighting; -#include "core.slang" -#include "math.slang" -#include "medium.slang" -#include "segment.slang" -#include "water.slang" -#include "trace.slang" -#include "lighting.slang" -#include "indirect.slang" +public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { + float3 L = float3(0.0, 0.0, 0.0); + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + uint seed = seg.seed; + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); + int maxBounces = int(worldPush.maxBounces); + int rrStart = maxBounces <= 3 ? 1 : 2; + // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the + // candidate count is non-zero. Off => emitters are gathered only on path hits (legacy behaviour). + bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && pc.lightBufAddr != 0; + // Independent light-proposal RNG. Screen-space coherent selection is intentionally deferred until + // there is a real presampled RIS tile buffer; direct sharing made tile-shaped lighting noise visible. + // Measured: making a 16x16 tile share this seed recovers at most 1.3ms, so a future pool should share + // the POOL rather than the seed — the win is in removing dependent loads, not in coherence. + uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u + ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; + proposalSeed = pcg(proposalSeed); + // The medium the segment starts in. For the camera segment that is water when the eye is submerged + // (so the first ray already carries the right relative index and absorption); for a segment split off + // a dielectric it is whatever the parent was travelling through. + MediumStack medium = seg.medium; + bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation + // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned + // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from + // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc + // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. + bool showCelestial = seg.showCelestial; + // Number of surfaces this path has SHADED so far, as distinct from `bounce`, which also counts + // dielectric interfaces. Glass and water continue without shading anything, so a wall behind a pane + // is bounce 1 but diffuseDepth 0 — visually primary, and it must be treated as such. See + // RIS_MAX_DIFFUSE_DEPTH. + int diffuseDepth = seg.diffuseDepth; + for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { + // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to + // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. + // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person + // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. + traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, + showCelestial, rayConeWidth, rayConeSpread); + + if (payload.hitT < 0.0) { + // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The + // packed show-celestial flag decides whether this path segment may see the bright disc. + float3 sky = payload.albedo; + L += throughput * sky; // escaped to sky + break; + } + + // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies + // to every hit reached while inside a volume dielectric (its own exit face, or whatever content + // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so + // the ordinary case costs one comparison. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); + } + + float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit + float3 hitPos = ro + rd * payload.hitT; + rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); + uint material = payloadMaterial(); + + // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every + // dielectric is a volume. Fresnel from the relative index across the face, then reflect or + // refract; no diffuse response and no sun NEE, because the surface is specular. Transmission + // pushes or pops a participating medium, and the segment travelled inside it is attenuated by + // that medium's extinction rather than by a tint multiply at the interface. + // + // Water is the only material that adds anything: the animated wave normal, and a medium marked + // for the caustic term. Which side of the face we are on comes from the geometry + // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest + // of the path. + if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 tint = payload.albedo; + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + + // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken + // reflections, wobbling refraction. The wave domain is world-stable: rebased hit XZ plus the + // pushed anchor (terrain origin mod 4096) survives the per-move terrain rebase. + float3 geometricNormal = n; // unrippled; ray origins offset along this + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } + + // The medium this face opens into, and the one it returns to on the way out — which is what + // the stack remembers. + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.outer.ior; + + float cosI = clamp(dot(-rd, n), 0.0, 1.0); + float F = fresnelDielectric(cosI, etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a + // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full + // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water + // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + + bool chooseReflection = rndf(seed) < F; + if (chooseReflection) { + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); + } else { + if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + // Crossing into or out of the volume. Absorption is the medium's job from here, so the + // tint is NOT also multiplied into the throughput — that would double-count it. + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + } + showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + break; + } + throughput /= q; + } + continue; + } + + // Particles (material == 2): camera-facing receiver billboard. The instance mask keeps particles + // off secondary rays, so this only fires at bounce 0; they receive direct light and one diffuse + // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. + if (material == MATERIAL_PARTICLE) { + float3 albedo = payload.albedo; + + float3 lightDir = worldPush.lightDir.xyz; + float lightHalfAngle = worldPush.lightDir.w; + if (lightHalfAngle > 0.0) { + lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + } + // Billboards are effectively two-sided receivers. Bias from the side facing the light; shadow + // rays exclude particles anyway, but this keeps the origin sane when the light is behind the + // camera side. + float signedNdl = dot(n, lightDir); + float ndl = abs(signedNdl); + if (ndl > 0.0) { + float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; + float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; + if (max(vis.r, max(vis.g, vis.b)) > 0.0) { + L += throughput * albedo * INV_PI * worldPush.lightRadiance.xyz * ndl * vis; + } + } + + // RIS direct lighting from block emitters (lava, glowstone, torches, ...) — same reservoir + // sampler as terrain/entities, but two-sided: a billboard has no back face, so light + // striking either side of the quad should still land (matches the sun/moon NEE above). + if (risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH)) { + float3 v = -rd; + Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, + true, 0.0, uint(diffuseDepth), seed, proposalSeed); + float3 risVis; + L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, + true, 0.0, risVis); + } + + if (bounce >= maxBounces) { + break; + } + throughput *= albedo; + ro = hitPos + n * SURF_BIAS; + rd = cosineDir(n, seed); + rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); + showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above + diffuseDepth++; // a billboard is a shaded surface, unlike the dielectric branches + continue; + } + + float3 albedo = payload.albedo; + float sss = payloadSss(); // LabPBR SSS strength (0 when absent) + float3 p = hitPos + n * SURF_BIAS; + float3 v = -rd; // view direction (toward the camera / incoming ray) + + // Material split. Metals tint F0 by albedo and have no diffuse lobe; dielectrics use a fixed + // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). + float rough = clamp(payloadRoughness(), 0.0, 1.0); + float metal = clamp(payloadMetalness(), 0.0, 1.0); + // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. + // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the + // reflection sharp instead of accumulating it as a glossy lobe. + bool exactSpecular = rough <= MIRROR_ALPHA_MAX; + rough = exactSpecular ? 0.0 : rough; + float3 diffAlb = albedo * (1.0 - metal); + float3 F0 = payload.f0; + + // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. + // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples + // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial + // false), which the previous vertex's RIS already accounted for; the primary ray and + // specular/dielectric bounces (showCelestial true) still add it, since no emitter NEE happened + // along those. Emitters NOT in the light buffer (sparse/sub-threshold footprints, entities) are + // never NEE-sampled, so they always gather — bit-identical to the no-NEE path, no energy lost. + // Emission is already the full HDR strength (EMISSIVE_STRENGTH baseline * any override + // multiplier baked in Java at material-compile time — see MaterialHeader.features packing). + float emission = payloadEmission(); + // One gate drives both the RIS call below and the direct-hit emission suppression here. They must + // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where + // RIS is skipped must also gather emission directly or that light is lost outright. + bool risActive = risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); + bool gateEmitter = risActive && payloadEmitterInList(); + if (emission > 0.0 && (!gateEmitter || showCelestial)) { + L += throughput * albedo * emission; + } + + // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert + // diffuse + GGX specular, one shadow ray. Jitter the light direction within its square angular + // extent (worldPush.lightDir.w half-angle) so the shadow ray samples the light's quad — soft penumbrae + // over accumulation. The same sampled direction drives ndl, the shadow ray, and the specular + // half-vector. + float3 lightDir = worldPush.lightDir.xyz; + float lightHalfAngle = worldPush.lightDir.w; + if (lightHalfAngle > 0.0) { + lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + } + float ndl = max(0.0, dot(n, lightDir)); + if (ndl > 0.0) { + VisibilityResult shadow = visibility(p, lightDir, 10000.0); + float3 vis = shadow.transmittance; + // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by + // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, + // so it applies to the whole NEE term (diffuse + specular). + if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { + vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); + } + if (max(vis.r, max(vis.g, vis.b)) > 0.0) { + float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) + float3 h = normalize(lightDir + v); + float ndh = max(0.0, dot(n, h)); + float ndv = max(1.0e-4, dot(n, v)); + float vdh = max(0.0, dot(v, h)); + float D = ggxD(ndh, rough); + float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); // separable Smith masking + float3 F = fresnelSchlick(vdh, F0); + brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular + L += throughput * brdf * worldPush.lightRadiance.xyz * ndl * vis; + } + } + + // RIS direct lighting from block emitters (single-frame only, no temporal reservoir reuse). + // Front lobe + SSS backlighting are resampled together and share ONE shadow ray — a light can + // only be in front of n or behind it, never both, so combining them into one target function is + // exact, not an approximation. The SSS term is gated off past MAX_SSS_DIFFUSE_DEPTH by passing + // sss=0 there (falls back to plain front-only RIS). Keyed on diffuseDepth, not `bounce`, for the + // same reason as RIS_MAX_DIFFUSE_DEPTH above: leaves seen through a window are still the first + // surface this path has shaded, and should still backlight. + if (risActive) { + float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; + Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, + uint(diffuseDepth), seed, proposalSeed); + float3 risVis; + L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, + activeSss, risVis); + } + + // Thin-surface SSS transmission. Light entering from the back face scatters through toward the + // viewer via a forward-biased HG phase (leaves/grass backlit by the sun glow when you look + // through them toward the light source). Shadow ray fires from the back face to avoid + // self-occlusion. cosT = dot(lightDir, rd): rd tracks the ray toward the sun when the sun is + // behind the slab, giving cosT ≈ 1 → forward-scatter peak. + if (sss > 0.0 && diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH) { + float backNdl = max(0.0, dot(-n, lightDir)); + if (backNdl > 0.0) { + VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); + float3 visB = shadowBack.transmittance; + // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should + // flicker with the same light bands as the floor around it. + if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { + visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, + lightDir, shadowBack.waterHitT); + } + if (max(visB.r, max(visB.g, visB.b)) > 0.0) { + float cosT = dot(lightDir, rd); + L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * worldPush.lightRadiance.xyz * visB; + } + } + } + + // Every shading term for this surface is now accounted for, so it counts toward the shaded depth. + // The dielectric branches above return before reaching here, which is exactly the distinction + // between this and `bounce`. + diffuseDepth++; + + // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their + // relative reflectance. + float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 + ? 1.0 + : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); + if (rndf(seed) < ps) { + float3 l; + if (exactSpecular) { + // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the + // degenerate VNDF at alpha=0 and preserves a genuinely sharp reflection without a floor. + l = reflect(rd, n); + throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; + } else { + float3 h = sampleGGXVNDF(n, v, rough, seed); + l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal + float ndl2 = dot(n, l); + if (ndl2 <= 0.0) { + break; // microfacet reflects below the surface: terminate this path + } + // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. + float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); + throughput *= F * ggxG1(ndl2, rough) / ps; + rayConeSpread = max(rayConeSpread, rough * RAY_CONE_GLOSSY_SPREAD_SCALE); + } + ro = p; + rd = l; + showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc + } else { + throughput *= diffAlb / (1.0 - ps); + ro = p; + rd = cosineDir(n, seed); + rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); + showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) + } + + // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + break; + } + throughput /= q; + } + } + return L; +} [shader("raygeneration")] void main() { diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index 263a7732..f7c034bd 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -253,15 +253,3 @@ public uint packHalf2(float2 v) { public float2 unpackHalf2(uint p) { return float2(f16tof32(p & 0xFFFFu), f16tof32(p >> 16)); } - -// Vanilla's block/entity atlases are ordinary sRGB-encoded PNGs sampled through a plain UNORM view (no -// hardware sRGB decode), so the raw texel is gamma-encoded. The path tracer's BRDF math (Lambertian/GGX, -// Beer-Lambert, NEE) is only correct on linear radiance/albedo, so every COLOR texture sample (not the -// LabPBR _s/_n data maps, which are already linear data channels) must go through this before it's used -// in any lighting calculation. -public float3 srgbToLinear(float3 c) { - float3 lo = c / 12.92; - float3 hi = pow((c + 0.055) / 1.055, float3(2.4, 2.4, 2.4)); - float3 isHi = step(float3(0.04045, 0.04045, 0.04045), c); - return lerp(lo, hi, isHi); -} diff --git a/shaders/world/core.slang b/shaders/world/world_core.slang similarity index 52% rename from shaders/world/core.slang rename to shaders/world/world_core.slang index dcf8d66d..08b061d7 100644 --- a/shaders/world/core.slang +++ b/shaders/world/world_core.slang @@ -1,67 +1,68 @@ // Bindings, per-frame push state, the ray payload, and the constants every pass shares. -// Include first: everything below depends on pc, worldPush and payload. +// Import first: everything below depends on pc, worldPush and payload. +import world_common; -[[vk::push_constant]] WorldPushConstants pc; +[[vk::push_constant]] public WorldPushConstants pc; -[[vk::binding(0, 0)]] RaytracingAccelerationStructure topLevelAS; +[[vk::binding(0, 0)]] public RaytracingAccelerationStructure topLevelAS; // HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. -[[vk::binding(1, 0)]] [format("rgba16f")] RWTexture2D outImage; +[[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D outImage; // Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written // every frame regardless of accumulation. gNormal.w carries LINEAR roughness (= GGX alpha, which is what // DLSS-RR documents for this input); gDepth carries HW reversed-Z depth. -[[vk::binding(3, 0)]] [format("rgba16f")] RWTexture2D gNormal; // xyz world normal, w linear roughness -[[vk::binding(4, 0)]] [format("rgba16f")] RWTexture2D gAlbedo; // rgb diffuse albedo -[[vk::binding(5, 0)]] [format("r32f")] RWTexture2D gDepth; // HW reversed-Z depth -[[vk::binding(6, 0)]] [format("rg16f")] RWTexture2D gMotion; // screen-space motion (render px) -[[vk::binding(7, 0)]] [format("rgba16f")] RWTexture2D gSpecAlbedo; // specular albedo (0 — diffuse-only) -[[vk::binding(8, 0)]] [format("rg16f")] RWTexture2D gSpecMotion; // reflection MVs for RR +[[vk::binding(3, 0)]] [format("rgba16f")] public RWTexture2D gNormal; // xyz world normal, w linear roughness +[[vk::binding(4, 0)]] [format("rgba16f")] public RWTexture2D gAlbedo; // rgb diffuse albedo +[[vk::binding(5, 0)]] [format("r32f")] public RWTexture2D gDepth; // HW reversed-Z depth +[[vk::binding(6, 0)]] [format("rg16f")] public RWTexture2D gMotion; // screen-space motion (render px) +[[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; // specular albedo (0 — diffuse-only) +[[vk::binding(8, 0)]] [format("rg16f")] public RWTexture2D gSpecMotion; // reflection MVs for RR // Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL // `worldPush` global). Layout constants generated from this module's SPIR-V — see world_common.WorldPush. -static WorldPush worldPush; +public static WorldPush worldPush = {}; // The radiance payload remains module-level so tracePath and its guide helpers can share it across // HitObject trace/invoke calls, mirroring the GLSL rayPayloadEXT global. -static Payload payload; +public static Payload payload = {}; -struct VisibilityResult { - float3 transmittance; - float waterHitT; +public struct VisibilityResult { + public float3 transmittance; + public float waterHitT; }; -uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } +public uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } // Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was // travelling into the volume (vs. out of it) at this hit's face. -bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } -bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } +public bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } +public bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } // LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used // end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly // that, RtMaterials.Profile carries the same units, and DLSS-RR wants linear roughness in its guide. So // nothing squares it: ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo all take this value as alpha as-is, and // any threshold compared against it is an alpha threshold. -float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } -float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } -float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } -float payloadSss() { return unpackHalf2(payload.emissionSss).y; } -float payloadIor() { return unpackHalf2(payload.iorTransmission).x; } -float payloadTransmission() { return unpackHalf2(payload.iorTransmission).y; } +public float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } +public float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } +public float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } +public float payloadSss() { return unpackHalf2(payload.emissionSss).y; } +public float payloadIor() { return unpackHalf2(payload.iorTransmission).x; } +public float payloadTransmission() { return unpackHalf2(payload.iorTransmission).y; } -static const float PI = 3.14159265359; -static const float INV_PI = 0.31830988618; +public static const float PI = 3.14159265359; +public static const float INV_PI = 0.31830988618; // The analytic sky (gradient + sun/moon discs + stars) is computed in world.rmiss. On a miss, raygen // reads payload.albedo and accumulates that sky radiance. The directional NEE light is pushed separately // as worldPush.lightDir / worldPush.lightRadiance / worldPush.sunDir. -static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) -static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface +public static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) +public static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface // Water is a smooth dielectric. IOR 1.333; absorption is per-block Beer-Lambert extinction. -static const float WATER_IOR = 1.333; -static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. -static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); +public static const float WATER_IOR = 1.333; +public static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. +public static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); // Transmitted-ray origin offset when crossing a face from the TRANSLUCENT terrain layer (glass, ice). // Must be SMALLER than the mesher-side TRANSLUCENT_INSET (RtTerrainMesher, 2e-4 blocks): those quads are @@ -70,23 +71,23 @@ static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); // through it; this lands the ray inside the gap so the neighbour is still hit, while still clearing the // surface it left to avoid self-intersection. Water is meshed by RtFluidMesher with no inset, so it uses // the ordinary bias. -static const float INSET_TRANSMIT_BIAS = 1.0e-4; +public static const float INSET_TRANSMIT_BIAS = 1.0e-4; // Ray-cone texture LOD: raygen tracks a one-pixel footprint along the path, and closest-hit converts it // to texture mip levels from the local UV/world-space scale. Diffuse/glossy bounces widen the cone so // indirect texture fetches don't keep sampling mip 0 through broad lobes. -static const float RAY_CONE_MIN_SPREAD = 1.0e-5; -static const float RAY_CONE_MIN_WIDTH = 1.0e-5; -static const float RAY_CONE_DIFFUSE_SPREAD = 0.25; -static const float RAY_CONE_GLOSSY_SPREAD_SCALE = 0.35; +public static const float RAY_CONE_MIN_SPREAD = 1.0e-5; +public static const float RAY_CONE_MIN_WIDTH = 1.0e-5; +public static const float RAY_CONE_DIFFUSE_SPREAD = 0.25; +public static const float RAY_CONE_GLOSSY_SPREAD_SCALE = 0.35; // Stored roughness IS alpha (see payloadRoughness), so these compare against it directly. -static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= 0.98 +public static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= 0.98 // Above this the specular lobe is too broad for a single mirrored reprojection to describe, so the // reflection MV is left at zero and RR falls back to the ordinary MV. -static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 -static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; +public static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 +public static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; // Continuations one sample may carry: the camera segment, plus the reflection branch a primary // dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. -static const uint MAX_PATH_SEGMENTS = 2u; +public static const uint MAX_PATH_SEGMENTS = 2u; diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang index 1df1a044..89d4ceda 100644 --- a/shaders/world/world_primary.rgen.slang +++ b/shaders/world/world_primary.rgen.slang @@ -14,15 +14,164 @@ // // SER: the build emits EXT and NV shader-invocation-reorder variants from this source. import world_common; +import world_core; +import math; +import medium; +import segment; +import water; +import trace; +import guides; -#include "core.slang" -#include "math.slang" -#include "medium.slang" -#include "segment.slang" -#include "water.slang" -#include "trace.slang" -#include "guides.slang" -#include "primary.slang" +public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool alive) { + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + MediumStack medium = seg.medium; + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); + uint seed = seg.seed; + bool showCelestial = seg.showCelestial; + int diffuseDepth = seg.diffuseDepth; + int maxBounces = int(worldPush.maxBounces); + int rrStart = maxBounces <= 3 ? 1 : 2; + bool waterWaves = (worldPush.flags & 16u) != 0u; + alive = false; + + for (int bounce = seg.bounce; bounce <= maxBounces; ++bounce) { + PathSegment terminal = makePathSegment(ro, rd, throughput, medium, + rayConeWidth, rayConeSpread, seed, bounce, diffuseDepth, + showCelestial, false, false); + traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, + ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); + + if (payload.hitT < 0.0) { + if (bounce == 0 && captureGuides) { + gv_normal = float3(0.0, 0.0, 0.0); + gv_albedo = SKY_DIFF_ALBEDO; + gv_rough = 1.0; + gv_hitCamRel = rd * 1.0e6; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = float3(0.0, 0.0, 0.0); + gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); + } + alive = true; + return terminal; + } + + float3 n = payload.normal; + float3 hitPos = ro + rd * payload.hitT; + uint material = payloadMaterial(); + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) { + if (bounce == 0 && captureGuides) { + gv_normal = n; + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = payload.motionPrev; + if (material == MATERIAL_PARTICLE) { + gv_albedo = payload.albedo; + gv_rough = 1.0; + gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, + float3(0.0, 0.0, 0.0)); + } else { + float rough = clamp(payloadRoughness(), 0.0, 1.0); + rough = rough <= MIRROR_ALPHA_MAX ? 0.0 : rough; + float metal = clamp(payloadMetalness(), 0.0, 1.0); + float3 diffAlb = payload.albedo * (1.0 - metal); + float3 v = -rd; + gv_albedo = diffAlb; + gv_rough = rough; + gv_spec = makeSpecSurface(gv_hitCamRel, n, rough, + rrSpecularAlbedo(payload.f0, rough, dot(n, v))); + } + } + alive = true; + return terminal; + } + + // This hit is consumed in pass A, so carry its travelled-medium absorption and cone growth into + // the record for the next trace. The terminal hit above is deliberately not consumed here. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); + } + rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), + RAY_CONE_MIN_WIDTH); + + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 geometricNormal = n; + float3 previousNormal = n; + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + if (bounce == 0 && captureGuides && abs(geometricNormal.y) >= 0.5) { + float2 currentGrad; + float2 previousGrad; + waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, + worldPush.waterAnchor.z, waterFootprint, currentGrad, previousGrad); + float orientation = geometricNormal.y >= 0.0 ? 1.0 : -1.0; + n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); + previousNormal = orientation + * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); + } else { + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } + } + + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), + payload.albedo, transmission); + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.outer.ior; + float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + + if (bounce == 0 && captureGuides) { + gv_normal = n; + gv_rough = 0.0; + gv_albedo = float3(0.0, 0.0, 0.0); + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionHitCamRel = gv_hitCamRel; + gv_motionUseRefracted = false; + gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; + gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, + gv_rough, float3(F, F, F)); + if (dot(transmittedDir, transmittedDir) > 0.0) { + resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, + entering ? entered.ior : medium.outer.ior, + transmitBias, rayConeWidth, rayConeSpread); + } + } + + if (rndf(seed) < F) { + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); + } else { + if (dot(transmittedDir, transmittedDir) <= 0.0) { + return terminal; + } + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + } + showCelestial = true; + if (bounce >= rrStart) { + float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); + if (rndf(seed) > q) { + return terminal; + } + throughput /= q; + } + } + return seg; +} [shader("raygeneration")] void main() { From 2ba359c97eae9178e61c699f12efacb2685739cf Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:10:53 +0900 Subject: [PATCH 17/25] deterministic split --- shaders/world/guides.slang | 50 ++++-- shaders/world/lighting.slang | 21 +-- shaders/world/math.slang | 8 +- shaders/world/segment.slang | 15 +- shaders/world/world.rgen.slang | 52 +++---- shaders/world/world_core.slang | 6 +- shaders/world/world_primary.rgen.slang | 145 +++++++++++++++--- .../comfyfluffy/caustica/rt/RtComposite.java | 10 +- 8 files changed, 208 insertions(+), 99 deletions(-) diff --git a/shaders/world/guides.slang b/shaders/world/guides.slang index 5a931dd8..d11b93f7 100644 --- a/shaders/world/guides.slang +++ b/shaders/world/guides.slang @@ -22,6 +22,12 @@ public static bool gv_motionUseRefracted = false; // true when the MV tracks the // World-space displacement of the motion-guide surface since the previous frame (0 for static // terrain/sky, an entity's per-vertex/object motion for a dynamic hit). public static float3 gv_motionObjDisp = float3(0.0, 0.0, 0.0); +// M2 fast path: when the deterministic reflection leaf reaches opaque content/sky in exactly one ray, +// Pass A already owns the endpoint that specularReflectionMotion would trace again. More complex leaves +// leave this false and retain the deterministic auxiliary guide trace. +public static bool gv_directReflectionEndpointValid = false; +public static float3 gv_directReflectionEndpoint = float3(0.0, 0.0, 0.0); +public static float3 gv_directReflectionMotionPrev = float3(0.0, 0.0, 0.0); // Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs // (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is // the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. @@ -87,22 +93,27 @@ public float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, float3 surfacePos = surface.camRel + worldPush.camOffset; float3 n = normalize(surface.normal); float3 specDir = reflect(primaryDir, n); - // biasNormal is a closest-hit normal, already unit length, and only its sign matters here. - traceGuide(CULL_SECONDARY, - offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), - RAY_TMIN, specDir, 10000.0, - max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), - max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); float3 reflectedHit; float3 reflectedMotionPrev; - if (payload.hitT > 0.0) { - reflectedHit = surfacePos + specDir * payload.hitT; - reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE - ? payload.motionPrev : float3(0.0, 0.0, 0.0); + if (gv_directReflectionEndpointValid) { + reflectedHit = gv_directReflectionEndpoint; + reflectedMotionPrev = gv_directReflectionMotionPrev; } else { - reflectedHit = surfacePos + specDir * 1.0e6; - reflectedMotionPrev = float3(0.0, 0.0, 0.0); + // biasNormal is a closest-hit normal, already unit length, and only its sign matters here. + traceGuide(CULL_SECONDARY, + offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), + RAY_TMIN, specDir, 10000.0, + max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), + max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); + if (payload.hitT > 0.0) { + reflectedHit = surfacePos + specDir * payload.hitT; + reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE + ? payload.motionPrev : float3(0.0, 0.0, 0.0); + } else { + reflectedHit = surfacePos + specDir * 1.0e6; + reflectedMotionPrev = float3(0.0, 0.0, 0.0); + } } float3 previousN = length(surface.previousNormal) >= 0.5 ? normalize(surface.previousNormal) : n; @@ -129,7 +140,8 @@ public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 nor public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, float3 surfaceBiasNormal, float currentIor, float rayBias, - float rayConeWidth, float rayConeSpread) { + float rayConeWidth, float rayConeSpread, + float3 guideFilter) { if (dot(transmittedDir, transmittedDir) <= 0.0) return; float3 direction = normalize(transmittedDir); @@ -142,7 +154,8 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, rayConeWidth, rayConeSpread); if (payload.hitT <= 0.0) { setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, - float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, SKY_DIFF_ALBEDO); + float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, + guideFilter * SKY_DIFF_ALBEDO); return; } @@ -155,12 +168,19 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, ? payload.albedo : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, - payload.normal, endpointRoughness, endpointAlbedo); + payload.normal, endpointRoughness, guideFilter * endpointAlbedo); return; } if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; + // The closest-hit shader has already alpha-blended stained-glass texture/tint toward white. + // Count each dielectric volume once on entry; applying it again at the exit face would square a + // single block's filter. Water uses payload.albedo to parameterize Beer extinction instead. + if (material == MATERIAL_DIELECTRIC && payloadDielectricEntering()) { + guideFilter *= payload.albedo; + } + // Mirror the radiance path's refraction. Absorption inside the crossed media is deliberately NOT // folded into the albedo guide: RR demodulates by albedo, and a distance-dependent attenuation // baked into it would disagree with the colour, which is where the extinction actually lands. diff --git a/shaders/world/lighting.slang b/shaders/world/lighting.slang index 9342804c..e06abf74 100644 --- a/shaders/world/lighting.slang +++ b/shaders/world/lighting.slang @@ -246,23 +246,14 @@ public static const uint SECONDARY_RIS_DIVISOR = 4u; // failure mode is noisier emitter light at depth rather than missing light. Unlike the divisor this also // drops shadeReservoir's per-vertex shadow ray, which never scaled with M. Gating at all: 16.1 -> 15.7ms. // -// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_DIELECTRIC and -// MATERIAL_WATER continue without shading anything, so looking through a pane spends bounce 0 on the -// glass and lands the first shaded surface at bounce 1. Gating on `bounce` therefore starved surfaces -// seen through glass or water — visually primary, but counted as depth — and they went black, since only -// a path randomly striking an emitter could light them. Keying on shaded surfaces removes that whole -// class of bug and lets the budget go lower than the two bounces of headroom the dielectric prefix used -// to cost. -// -// Caveat this does not solve: a smooth specular bounce IS a shaded surface, so a wall seen in a metal -// block counts as depth 1. Accumulated roughness rather than a vertex count is the principled fix, and -// is what a radiance cache would need anyway to decide cache-vs-trace. -public static const uint RIS_MAX_DIFFUSE_DEPTH = 2u; +// This counts hits observed by Pass B. The primary/interface prefix consumed by Pass A is outside the +// budget, while every material and lobe encountered after the handoff advances it uniformly. +public static const uint RIS_MAX_INDIRECT_DEPTH = 2u; // Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen // sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, - float rough, bool twoSided, float sss, uint shadedDepth, + float rough, bool twoSided, float sss, uint indirectDepth, inout uint seed, inout uint proposalSeed) { Reservoir r = resEmpty(); LightGridCell gridCell; @@ -275,9 +266,7 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 * worldPush.lightGridOrigin.w; } bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); - // shadedDepth, not the bounce index: a surface behind glass or water is the first thing this path has - // shaded and is visually primary, so it keeps the full candidate count. - uint candidateCount = shadedDepth == 0u + uint candidateCount = indirectDepth == 0u ? worldPush.risCandidates : max(1u, worldPush.risCandidates / SECONDARY_RIS_DIVISOR); // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six diff --git a/shaders/world/math.slang b/shaders/world/math.slang index 62d09462..4571684e 100644 --- a/shaders/world/math.slang +++ b/shaders/world/math.slang @@ -37,11 +37,9 @@ public float3 fresnelSchlick(float cosT, float3 f0) { // g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). public static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional public static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) -// Deepest SHADED surface at which the SSS transmission shadow ray fires. diffuseDepth, not `bounce`: the -// glass/water continue branches don't shade a surface, so foliage seen through a window is still -// diffuseDepth 0 and should backlight like any other primary leaf — see RIS_MAX_DIFFUSE_DEPTH for the -// same distinction and why it matters. -public static const int MAX_SSS_DIFFUSE_DEPTH = 1; // fire on the first-shaded surface + one indirect hit +// SSS is primary-terminal lighting only. The handoff surface is Pass B hit 0; any surface reached by a +// continuation is indirect and must not schedule an SSS shadow ray. +public static const int MAX_SSS_INDIRECT_DEPTH = 0; public float hg(float cosT, float g) { float g2 = g * g; return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); diff --git a/shaders/world/segment.slang b/shaders/world/segment.slang index 0210e89d..b735866d 100644 --- a/shaders/world/segment.slang +++ b/shaders/world/segment.slang @@ -24,7 +24,6 @@ public struct PathSegment { public float rayConeSpread; public uint seed; public int bounce; // interfaces already consumed, so RR start and the bounce cap stay global - public int diffuseDepth; // surfaces already SHADED (see the field of the same name in tracePath) public bool showCelestial; public bool captureGuides; public bool maySplit; // may still spawn a deterministic Fresnel branch @@ -32,7 +31,7 @@ public struct PathSegment { public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, float rayConeWidth, float rayConeSpread, uint seed, - int bounce, int diffuseDepth, bool showCelestial, + int bounce, bool showCelestial, bool captureGuides, bool maySplit) { PathSegment s; s.ro = ro; @@ -43,7 +42,6 @@ public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, Medi s.rayConeSpread = rayConeSpread; s.seed = seed; s.bounce = bounce; - s.diffuseDepth = diffuseDepth; s.showCelestial = showCelestial; s.captureGuides = captureGuides; s.maySplit = maySplit; @@ -60,10 +58,11 @@ public struct PackedPathSegment { public uint rayCone; public uint seed; public uint pathFlags; - public uint pixelSample; + public uint nextRecord; }; public static const uint PATH_VALID = 1u << 11u; +public static const uint PATH_NO_NEXT = 0xffffffffu; public float2 octEncode(float3 direction) { float3 n = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 1.0e-20); @@ -113,7 +112,7 @@ public float3 unpackRgb9e5(uint p) { return float3(p & 0x1ffu, (p >> 9u) & 0x1ffu, (p >> 18u) & 0x1ffu) * scale; } -public PackedPathSegment packPathSegment(PathSegment seg, uint pixelIndex, uint sampleIndex, bool valid) { +public PackedPathSegment packPathSegment(PathSegment seg, bool valid, uint nextRecord) { PackedPathSegment p; p.ro = seg.ro; p.rd = packUnorm16x2(octEncode(seg.rd)); @@ -124,12 +123,11 @@ public PackedPathSegment packPathSegment(PathSegment seg, uint pixelIndex, uint p.rayCone = packHalf2(float2(seg.rayConeWidth, seg.rayConeSpread)); p.seed = seg.seed; p.pathFlags = (uint(seg.bounce) & 15u) - | ((uint(seg.diffuseDepth) & 15u) << 4u) | (seg.showCelestial ? 1u << 8u : 0u) | (seg.medium.current.water ? 1u << 9u : 0u) | (seg.medium.outer.water ? 1u << 10u : 0u) | (valid ? PATH_VALID : 0u); - p.pixelSample = (pixelIndex & 0x1fffffffu) | ((sampleIndex & 7u) << 29u); + p.nextRecord = nextRecord; return p; } @@ -150,8 +148,7 @@ public PathSegment unpackPathSegment(PackedPathSegment p, out bool valid) { valid = (p.pathFlags & PATH_VALID) != 0u; return makePathSegment(p.ro, octDecode(unpackUnorm16x2(p.rd)), unpackRgb9e5(p.throughput), medium, cone.x, cone.y, p.seed, - int(p.pathFlags & 15u), int((p.pathFlags >> 4u) & 15u), - (p.pathFlags & (1u << 8u)) != 0u, false, false); + int(p.pathFlags & 15u), (p.pathFlags & (1u << 8u)) != 0u, false, false); } // Walk only the visually-primary dielectric chain. The terminal non-dielectric/miss trace is repeated diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 506de38c..9e9478bd 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -57,11 +57,9 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. bool showCelestial = seg.showCelestial; - // Number of surfaces this path has SHADED so far, as distinct from `bounce`, which also counts - // dielectric interfaces. Glass and water continue without shading anything, so a wall behind a pane - // is bounce 1 but diffuseDepth 0 — visually primary, and it must be treated as such. See - // RIS_MAX_DIFFUSE_DEPTH. - int diffuseDepth = seg.diffuseDepth; + // Pass B treats every hit it sees as one indirect-depth step, regardless of material or selected + // continuation lobe. Pass A's primary/interface prefix is outside this budget. + int indirectDepth = 0; for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. @@ -90,6 +88,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 hitPos = ro + rd * payload.hitT; rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); uint material = payloadMaterial(); + int hitDepth = indirectDepth++; // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every // dielectric is a volume. Fresnel from the relative index across the face, then reflect or @@ -187,10 +186,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // RIS direct lighting from block emitters (lava, glowstone, torches, ...) — same reservoir // sampler as terrain/entities, but two-sided: a billboard has no back face, so light // striking either side of the quad should still land (matches the sun/moon NEE above). - if (risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH)) { + if (risOn && hitDepth <= int(RIS_MAX_INDIRECT_DEPTH)) { float3 v = -rd; Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, uint(diffuseDepth), seed, proposalSeed); + true, 0.0, uint(hitDepth), seed, proposalSeed); float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, true, 0.0, risVis); @@ -204,7 +203,6 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { rd = cosineDir(n, seed); rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above - diffuseDepth++; // a billboard is a shaded surface, unlike the dielectric branches continue; } @@ -220,7 +218,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the // reflection sharp instead of accumulating it as a glossy lobe. - bool exactSpecular = rough <= MIRROR_ALPHA_MAX; + bool exactSpecular = isDeltaAlpha(rough); rough = exactSpecular ? 0.0 : rough; float3 diffAlb = albedo * (1.0 - metal); float3 F0 = payload.f0; @@ -238,7 +236,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // One gate drives both the RIS call below and the direct-hit emission suppression here. They must // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where // RIS is skipped must also gather emission directly or that light is lost outright. - bool risActive = risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); + bool risActive = risOn && hitDepth <= int(RIS_MAX_INDIRECT_DEPTH); bool gateEmitter = risActive && payloadEmitterInList(); if (emission > 0.0 && (!gateEmitter || showCelestial)) { L += throughput * albedo * emission; @@ -281,14 +279,12 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // RIS direct lighting from block emitters (single-frame only, no temporal reservoir reuse). // Front lobe + SSS backlighting are resampled together and share ONE shadow ray — a light can // only be in front of n or behind it, never both, so combining them into one target function is - // exact, not an approximation. The SSS term is gated off past MAX_SSS_DIFFUSE_DEPTH by passing - // sss=0 there (falls back to plain front-only RIS). Keyed on diffuseDepth, not `bounce`, for the - // same reason as RIS_MAX_DIFFUSE_DEPTH above: leaves seen through a window are still the first - // surface this path has shaded, and should still backlight. + // exact, not an approximation. The SSS term is gated off past MAX_SSS_INDIRECT_DEPTH by passing + // sss=0 there (falls back to plain front-only RIS). if (risActive) { - float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; + float activeSss = hitDepth <= MAX_SSS_INDIRECT_DEPTH ? sss : 0.0; Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, - uint(diffuseDepth), seed, proposalSeed); + uint(hitDepth), seed, proposalSeed); float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, risVis); @@ -299,7 +295,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // through them toward the light source). Shadow ray fires from the back face to avoid // self-occlusion. cosT = dot(lightDir, rd): rd tracks the ray toward the sun when the sun is // behind the slab, giving cosT ≈ 1 → forward-scatter peak. - if (sss > 0.0 && diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH) { + if (sss > 0.0 && hitDepth <= MAX_SSS_INDIRECT_DEPTH) { float backNdl = max(0.0, dot(-n, lightDir)); if (backNdl > 0.0) { VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); @@ -317,11 +313,6 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } } - // Every shading term for this surface is now accounted for, so it counts toward the shaded depth. - // The dielectric branches above return before reaching here, which is exactly the distinction - // between this and `bounce`. - diffuseDepth++; - // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their // relative reflectance. float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 @@ -383,10 +374,19 @@ void main() { ConstPtr queue = ConstPtr(pc.pathQueueAddr); float3 frameRadiance = float3(0.0, 0.0, 0.0); for (uint s = 0u; s < spp; ++s) { - bool valid; - PathSegment segment = unpackPathSegment(queue[pixelIndex * spp + s], valid); - if (valid) { - frameRadiance += tracePath(segment, uint2(pix), s); + uint recordIndex = pixelIndex * spp + s; + // [loop] + for (uint leaf = 0u; leaf < 2u; ++leaf) { + PackedPathSegment packed = queue[recordIndex]; + bool valid; + PathSegment segment = unpackPathSegment(packed, valid); + if (valid) { + frameRadiance += tracePath(segment, uint2(pix), s + leaf * spp); + } + if (packed.nextRecord == PATH_NO_NEXT) { + break; + } + recordIndex = packed.nextRecord; } } outImage[pix] = float4(frameRadiance / float(spp), 1.0); diff --git a/shaders/world/world_core.slang b/shaders/world/world_core.slang index 08b061d7..1218199d 100644 --- a/shaders/world/world_core.slang +++ b/shaders/world/world_core.slang @@ -86,8 +86,12 @@ public static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smooth // Above this the specular lobe is too broad for a single mirrored reprojection to describe, so the // reflection MV is left at zero and RR falls back to the ordinary MV. public static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 + +public bool isDeltaAlpha(float roughness) { + return clamp(roughness, 0.0, 1.0) <= MIRROR_ALPHA_MAX; +} + public static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; // Continuations one sample may carry: the camera segment, plus the reflection branch a primary // dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. public static const uint MAX_PATH_SEGMENTS = 2u; - diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang index 89d4ceda..4b89f44c 100644 --- a/shaders/world/world_primary.rgen.slang +++ b/shaders/world/world_primary.rgen.slang @@ -22,7 +22,10 @@ import water; import trace; import guides; -public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool alive) { +public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowSplit, + DevicePtr queue, + uint splitRecord, + out uint nextRecord, out bool alive) { float3 ro = seg.ro; float3 rd = seg.rd; float3 throughput = seg.throughput; @@ -31,20 +34,28 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool al float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); uint seed = seg.seed; bool showCelestial = seg.showCelestial; - int diffuseDepth = seg.diffuseDepth; int maxBounces = int(worldPush.maxBounces); - int rrStart = maxBounces <= 3 ? 1 : 2; bool waterWaves = (worldPush.flags & 16u) != 0u; + bool awaitingDirectReflectionEndpoint = false; + if (captureGuides) { + gv_directReflectionEndpointValid = false; + } + nextRecord = PATH_NO_NEXT; alive = false; for (int bounce = seg.bounce; bounce <= maxBounces; ++bounce) { PathSegment terminal = makePathSegment(ro, rd, throughput, medium, - rayConeWidth, rayConeSpread, seed, bounce, diffuseDepth, + rayConeWidth, rayConeSpread, seed, bounce, showCelestial, false, false); traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); if (payload.hitT < 0.0) { + if (awaitingDirectReflectionEndpoint) { + gv_directReflectionEndpointValid = true; + gv_directReflectionEndpoint = ro + rd * 1.0e6; + gv_directReflectionMotionPrev = float3(0.0, 0.0, 0.0); + } if (bounce == 0 && captureGuides) { gv_normal = float3(0.0, 0.0, 0.0); gv_albedo = SKY_DIFF_ALBEDO; @@ -63,6 +74,14 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool al float3 hitPos = ro + rd * payload.hitT; uint material = payloadMaterial(); if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) { + float rough = material == MATERIAL_PARTICLE + ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); + bool exactSpecular = material == MATERIAL_OPAQUE && isDeltaAlpha(rough); + rough = exactSpecular ? 0.0 : rough; + float metal = material == MATERIAL_PARTICLE + ? 0.0 : clamp(payloadMetalness(), 0.0, 1.0); + float3 diffAlb = material == MATERIAL_PARTICLE + ? float3(payload.albedo) : float3(payload.albedo) * (1.0 - metal); if (bounce == 0 && captureGuides) { gv_normal = n; gv_hitCamRel = hitPos - worldPush.camOffset; @@ -75,10 +94,6 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool al gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, float3(0.0, 0.0, 0.0)); } else { - float rough = clamp(payloadRoughness(), 0.0, 1.0); - rough = rough <= MIRROR_ALPHA_MAX ? 0.0 : rough; - float metal = clamp(payloadMetalness(), 0.0, 1.0); - float3 diffAlb = payload.albedo * (1.0 - metal); float3 v = -rd; gv_albedo = diffAlb; gv_rough = rough; @@ -86,6 +101,33 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool al rrSpecularAlbedo(payload.f0, rough, dot(n, v))); } } + + // A non-emissive pure delta mirror is primary transport. Consume it here so the reflected + // feature remains outside Pass B's indirect-hit budget. Mixed diffuse+specular and emissive + // surfaces remain terminal in Pass B. + bool pureDeltaMirror = exactSpecular + && luminance(diffAlb) <= 1.0e-6 + && payloadEmission() <= 0.0; + if (pureDeltaMirror) { + awaitingDirectReflectionEndpoint = false; // a mirror chain needs the fallback guide trace + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); + } + rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), + RAY_CONE_MIN_WIDTH); + float3 v = -rd; + throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), payload.f0); + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, n, rd, SURF_BIAS); + showCelestial = true; + continue; + } + if (awaitingDirectReflectionEndpoint) { + gv_directReflectionEndpointValid = true; + gv_directReflectionEndpoint = hitPos; + gv_directReflectionMotionPrev = material == MATERIAL_OPAQUE + ? float3(payload.motionPrev) : float3(0.0, 0.0, 0.0); + } alive = true; return terminal; } @@ -99,6 +141,11 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool al RAY_CONE_MIN_WIDTH); bool isWater = material == MATERIAL_WATER; + if (awaitingDirectReflectionEndpoint) { + // A second dielectric makes this a multi-interface/stochastic endpoint; retain the + // deterministic reflection-guide trace instead of reusing a noisy radiance leaf. + awaitingDirectReflectionEndpoint = false; + } bool entering = payloadDielectricEntering(); float3 geometricNormal = n; float3 previousNormal = n; @@ -142,10 +189,45 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool al if (dot(transmittedDir, transmittedDir) > 0.0) { resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, entering ? entered.ior : medium.outer.ior, - transmitBias, rayConeWidth, rayConeSpread); + transmitBias, rayConeWidth, rayConeSpread, + !isWater && entering + ? float3(payload.albedo) + : float3(1.0, 1.0, 1.0)); } } + // M2 splits once at the first non-TIR visually-primary Fresnel interface. The transmitted + // continuation is written to device memory BEFORE the reflection trace begins, so it cannot + // become a live range spanning traversal. The caller reloads and walks it only after this leaf's + // terminal record has itself been written. + bool splitEligible = allowSplit + && dot(transmittedDir, transmittedDir) > 0.0 + && F > 0.0 && F < 1.0; + if (splitEligible) { + MediumStack transmittedMedium = medium; + if (entering) { + mediumPush(transmittedMedium, entered); + } else { + mediumPop(transmittedMedium); + } + float3 deferredDir = normalize(transmittedDir); + PathSegment deferred = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), + deferredDir, throughput * (1.0 - F), transmittedMedium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true, false, false); + queue[splitRecord] = packPathSegment(deferred, true, PATH_NO_NEXT); + nextRecord = splitRecord; + throughput *= F; + seed ^= 0xa511e9b3u; + rd = reflect(rd, n); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); + showCelestial = true; + awaitingDirectReflectionEndpoint = captureGuides && bounce == 0; + allowSplit = false; + continue; + } + if (rndf(seed) < F) { rd = reflect(rd, n); ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); @@ -162,13 +244,6 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, out bool al } } showCelestial = true; - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - return terminal; - } - throughput /= q; - } } return seg; } @@ -196,18 +271,40 @@ void main() { : airMedium()); uint spp = max(worldPush.spp, 1u); uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; + uint baseRecordCount = dimensions.x * dimensions.y * spp; + uint splitRecord = baseRecordCount + pixelIndex; DevicePtr queue = DevicePtr(pc.pathQueueAddr); for (uint s = 0u; s < spp; ++s) { seed = pcg(seed); - PathSegment camera = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), - cameraMedium, 0.0, rayConeSpread, seed, 0, 0, true, false, false); - bool alive; - PathSegment terminal = tracePrimary(camera, s == 0u, alive); - // Store immediately: no continuation remains live across writeGuides or another primary trace. - queue[pixelIndex * spp + s] = packPathSegment(terminal, pixelIndex, s, alive); - if (s == 0u) { - writeGuides(pix, dir, jndc, size, rayConeSpread); + PathSegment current = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), + cameraMedium, 0.0, rayConeSpread, seed, 0, true, false, false); + uint recordIndex = pixelIndex * spp + s; + bool captureGuides = s == 0u; + // The variance-reduction split is a per-pixel expense, not a per-sample expense. Additional + // SPP samples retain the unbiased stochastic Fresnel choice, bounding this invocation to one + // secondary leaf for the pixel regardless of SPP. + bool allowSplit = s == 0u; + [loop] + for (uint leaf = 0u; leaf < 2u; ++leaf) { + uint nextRecord; + bool alive; + PathSegment terminal = tracePrimary(current, captureGuides, allowSplit, + queue, splitRecord, nextRecord, alive); + // Store the completed leaf immediately. If it split, only a uint link—not the deferred + // PathSegment—survived its traces; the branch itself is reloaded below. + queue[recordIndex] = packPathSegment(terminal, alive, nextRecord); + if (captureGuides) { + writeGuides(pix, dir, jndc, size, rayConeSpread); + captureGuides = false; + } + if (nextRecord == PATH_NO_NEXT) { + break; + } + bool deferredValid; + current = unpackPathSegment(queue[nextRecord], deferredValid); + recordIndex = nextRecord; + allowSplit = false; } } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 2a86b393..5b3f9fa1 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -739,12 +739,16 @@ private void ensureOutput(RtContext ctx, int width, int height) { // (vkCmdCopyImage requires texel-size-compatible formats). output = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH); continuationQueueSpp = desiredSpp; - long continuationBytes = Math.multiplyExact( + long baseRecords = Math.multiplyExact( Math.multiplyExact((long) renderW, (long) renderH), - Math.multiplyExact((long) continuationQueueSpp, PATH_RECORD_BYTES)); + (long) continuationQueueSpp); + long splitRecords = Math.multiplyExact((long) renderW, (long) renderH); + long continuationBytes = Math.multiplyExact( + Math.addExact(baseRecords, splitRecords), PATH_RECORD_BYTES); continuationQueue = ctx.createBuffer(continuationBytes, VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false, - "path continuation queue " + renderW + "x" + renderH + "x" + continuationQueueSpp); + "path continuation queue " + renderW + "x" + renderH + "x" + continuationQueueSpp + + " + one split slot per pixel"); displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM, "RT display image " + width + "x" + height); // PQ-encoded ([0,1], ST.2084) HDR display image, written in parallel by display.comp when HDR mode is active. hdrDisplayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height); From 99c7449a992c8081268668f4dac91f6fb3f391b4 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:56:27 +0900 Subject: [PATCH 18/25] Refine wavefront split ownership --- build.gradle | 13 +- docs/WAVEFRONT_PLAN.md | 207 ++++++++++++++---- shaders/world/guides.slang | 129 +++++------ shaders/world/lighting.slang | 28 +-- shaders/world/math.slang | 5 +- shaders/world/trace.slang | 11 + shaders/world/world.rgen.slang | 41 ++-- shaders/world/world_core.slang | 1 - shaders/world/world_primary.rgen.slang | 180 ++++++--------- .../comfyfluffy/caustica/rt/RtComposite.java | 19 +- .../caustica/rt/RtDeviceBringup.java | 2 +- 11 files changed, 332 insertions(+), 304 deletions(-) diff --git a/build.gradle b/build.gradle index de3865a0..bf7685eb 100644 --- a/build.gradle +++ b/build.gradle @@ -160,14 +160,13 @@ abstract class CompileShaders extends DefaultTask { def base = outBase(src) def spv = new File(scratchDir, "${base}.spv") if (src.name.endsWith(".slang")) { - // Build both SER encodings for the two world raygens; RtDeviceBringup selects the - // supported variant for both passes. - def worldRaygen = base == "world.rgen" || base == "world_primary.rgen" - compileOneSlang(src, spv, worldRaygen + // Pass B uses SER and needs both encodings. Pass A intentionally compiles without an + // invocation-reorder capability and uses ordinary TraceRay. + def worldIndirectRaygen = base == "world.rgen" + compileOneSlang(src, spv, worldIndirectRaygen ? ["-capability", "spvShaderInvocationReorderEXT"] : []) - if (worldRaygen) { - def nvBase = base == "world.rgen" ? "world_nv.rgen.spv" : "world_primary_nv.rgen.spv" - compileOneSlang(src, new File(scratchDir, nvBase), + if (worldIndirectRaygen) { + compileOneSlang(src, new File(scratchDir, "world_nv.rgen.spv"), ["-capability", "spvShaderInvocationReorderNV"]) } } else { diff --git a/docs/WAVEFRONT_PLAN.md b/docs/WAVEFRONT_PLAN.md index 620fc38a..d3f74f22 100644 --- a/docs/WAVEFRONT_PLAN.md +++ b/docs/WAVEFRONT_PLAN.md @@ -59,31 +59,105 @@ it.** rgen was at 126 live registers with occupancy as the binding constraint, a of samples, 89% LGSB) is poorly hidden *because* of that. Splitting the kernel is the structural version of that lesson; the RIS wave-batching attempt failed because it did the reverse. +### 0.2 M1 result: 21 ms -> 14.2 ms + +M1 landed in `428922d` and was subsequently split into explicit per-pass modules (`62e8aae`, +`cb5a51d`, `27e10d0`). GPU timing: + +| pass | time | +|---|---:| +| primary / guide | 4.4 ms | +| indirect / shading | 9.8 ms | +| **combined** | **14.2 ms** | + +That is **6.8 ms / 32.4% faster** than the 21 ms `db6418b` baseline. The split therefore clears its +kill criterion and confirms the live-state diagnosis. Pass A now runs without SER; a full no-reorder +A/B for Pass B remains outside M2. + ## 1. Design Two raygen entry points in the same RT pipeline, selected by SBT record at dispatch, connected by the existing guide images plus one new continuation buffer. -**Pass A — `world_primary.rgen`.** Camera ray; walk the dielectric chain while `diffuseDepth == 0`; -capture guides; resolve spec motion and the transmitted guide; write the six guide images. Emit one -continuation record per surviving path (two when a dielectric splits). Does **no** shading. +**Pass A — `world_primary.rgen`.** One invocation and exactly one camera sample per pixel, independent +of configured SPP. Trace the camera ray once, capture the foreground guides, and emit either the +pre-hit continuation or one/two post-interface continuations. At the first eligible dielectric, write +reflection and transmission records immediately and stop radiance traversal. Only deterministic +reflection/refraction guide probes may trace farther. Pass A uses ordinary `TraceRay`, with no +invocation-reorder capability or barrier. -**Pass B — `world_indirect.rgen`.** One thread per record. Runs the bounce loop with NEE / RIS / SSS / -GGX continuation. Never touches `gv_*`. +**Pass B — `world.rgen`.** One invocation per pixel. Load each valid leaf and resample its terminal +continuation `worldPush.spp` times with decorrelated seeds, run NEE / RIS / SSS / GGX continuation, +sum the leaves, divide by SPP, and write the pixel once. It never touches `gv_*`. `PathSegment` from `b4a0e7b` is already the record; that commit was deliberately shaped for this. ### What each pass does NOT contain -- Pass A: no NEE, no RIS, no SSS, no GGX sampling, no Russian roulette. -- Pass B: no guide capture, no `resolveTransmissionGuide`, no `specularReflectionMotion`, no +- Pass A: no NEE, no RIS, no SSS, no finite GGX sampling, and no radiance trace after the camera hit. +- Pass B: no guide capture, no auxiliary guide walks, no `specularReflectionMotion`, no `waterWaveGradTemporal`, no debug views. ### Accepted duplication -Dielectric transport (~90 lines) and the non-temporal wave normal appear in both passes: pass A walks -the primary chain, pass B must still handle a reflection ray hitting water. This is real and worth -paying — the block it buys separation from (RIS + NEE + SSS + GGX) is several times larger. +First-interface dielectric math and the non-temporal wave normal remain in both passes: Pass A creates +the first split, while Pass B owns all radiance transport after it and can hit later water/glass. + +### 1.1 M2 transport boundary + +The ownership rule is now **first dielectric interface only**. + +- Water and `MATERIAL_DIELECTRIC` are modeled as exact Fresnel interfaces. Pass A splits the first + non-TIR interface and queues both weighted post-interface states. Degenerate F=0/F=1 interfaces queue + their sole continuation. Pass B owns every subsequent radiance trace and dielectric decision. +- All opaque surfaces, including non-emissive pure delta mirrors, terminate Pass A at the pre-hit + continuation and are handled by Pass B. +- A polished opaque dielectric with both diffuse and exact-specular response is **not** the same split + as glass. Glass has reflection `F` and transmission `1-F`; the current opaque BSDF is additive + diffuse plus specular. Multiplying its diffuse branch by `1-F` would change energy. Mixed + diffuse+delta opaque surfaces therefore remain in Pass B for M2 unless a record flag explicitly + requests diffuse-only terminal shading. +- Every finite glossy lobe stays in Pass B. Its destination is a distribution, not a stable guide + feature, and moving it would destroy Pass A's coherence without producing a valid sharp guide. + +There is no carried `diffuseDepth`. Pass B starts an `indirectDepth` counter at zero for each queued +leaf and advances it on every hit it observes, regardless of material or selected lobe. The +primary/interface prefix consumed by Pass A remains outside this budget; once handed off, all hits are +treated uniformly for depth gating. RIS remains active with the full configured candidate count at every +Pass B hit. Pass B hits 0 and 1 retain full SSS, with SSS removed beginning at hit 2. This counter is +deliberately independent of packed `bounce`, so split records beginning at transport bounce 1 do not +lose first/second-hit SSS quality. + +### 1.2 M2 guide ownership + +Once Pass A deterministically owns the first dielectric split, dedicated deterministic guide probes +supply the endpoints without affecting queued radiance: + +| leaf | ordinary guide | specular guide | +|---|---|---| +| deterministic refracted probe | `gAlbedo`, `gMotion`, destination depth/normal | — | +| deterministic reflected probe | — | reflected endpoint for `gSpecMotion` | + +- The refracted guide accumulates a `guideFilter` only for **entering stained-glass/dielectric volumes**, multiplying the + terminal diffuse albedo by `payload.albedo`. The closest-hit shader has already performed the desired + texture-alpha blend (`lerp(white, texture*tint, alpha)`). Applying it once on entry avoids counting a + block's front and back faces twice. Water is excluded: its payload tint parameterizes Beer extinction. +- Keep live guide state and the `rgba16f` / `rg16f` storage-image bindings FP32. The ray payload already + packs hit attributes as `half3`, so changing `gv_*` only added another precision boundary. A + half-typed image binding was also ineffective: Slang canonicalized it to `OpTypeImage %float` and + widened values before `OpImageWrite`; the Vulkan image store owns the final format conversion. +- Keep distance-dependent Beer–Lambert extinction in radiance/lighting, not `gAlbedo`; otherwise the + material guide varies with path length and disagrees with RR's material demodulation. +- The baseline `gSpecAlbedo` remains average view-dependent material reflectance + (`rrSpecularAlbedo` / interface Fresnel). The reflected terminal supplies motion, not albedo. + “Reflected diffuse content × reflection strength” is an experimental, flag-gated follow-up because it + is not the documented guide identity and is unstable for emissive destinations, metals (`diffAlb=0`), + sky, and mirror recursion. It requires a material-reflectance fallback and an in-place A/B. +- `resolveTransmissionGuide` deterministically refracts through later interfaces and never follows a + reflected branch into ordinary albedo/depth. TIR freezes the ordinary tuple on that interface. + Reflection motion uses its own one-ray guide probe. These are the only traces Pass A performs after + queuing the radiance split. The refracted walk uses `worldPush.maxBounces` as its crossing limit; it + has no separate guide-only cap. ## 2. Record layout @@ -99,16 +173,38 @@ Target 48 B. Unpacked `PathSegment` is ~100 B; the packing below is lossless whe | `medium.outer` | ior half + extinction rgb9e5 | 6 | | `rayConeWidth` / `rayConeSpread` | half x2 | 4 | | `seed` | uint | 4 | -| `bounce`, `diffuseDepth`, `showCelestial`, `maySplit`, pixel index | packed uint x2 | 8 | +| `bounce`, `showCelestial`, medium/valid flags, next-record link | packed uint x2 | 8 | + +M1 allocated one fixed record per render pixel **per SPP sample**: + +`baseRecords = renderWidth * renderHeight * spp` + +At 1280x720 and SPP 1 this is 921600 x 48 B = **44.2 MB (42.2 MiB)**. At SPP 8 it was +353.9 MB (337.5 MiB). + +M2 keeps the race-free ownership model: one Pass B invocation writes one pixel. It does **not** launch +one indirect raygen per appended record, because two records for one pixel would race on `outImage` +without another radiance buffer and reduction pass. + +Pass A is fixed at SPP 1 and can emit at most one second leaf. Configured SPP belongs solely to Pass B, +which resamples the stored leaf or leaves. + +Use a base-plus-fixed-secondary queue: + +1. Every pixel owns one fixed base record. +2. Every pixel owns one fixed secondary record at `baseRecords + pixelIndex`. +3. The base record's currently unused `pixelSample` word becomes a `nextRecord` index/sentinel. +4. Pass B loads the base record and then its optional linked record, sums both locally, and writes once. +5. No atomic allocation, queue header, reset, overflow path, or transfer-to-trace barrier is required. -At 1280x720 render resolution: 921600 x 48 B = **44.2 MB** for one record per pixel. Use an **append -buffer with an atomic counter** sized ~1.25x pixel count (~55 MB) rather than a fixed 2-per-pixel -array — splits are the exception, and an append buffer also hands pass B a dense, coherent queue. -When the queue is full, fall back to the stochastic branch instead of splitting: graceful, and the -estimator stays unbiased. +The allocation is always `pixelCount * 2 * 48 B`: 88.5 MB (84.4 MiB) at 1280x720, independent of +configured SPP. A storage texture would need three `RGBA32UI` texels for the same 48-byte record and +would not reduce traversal work, so the queue remains a linear BDA buffer. -**Open:** 44-55 MB is not free on 8 GB cards. If it bites, the fallback is to keep pass B in the same -dispatch for the common single-segment case and only spill splits — measure before deciding. +At the split, Pass A writes the transmission record to the fixed secondary slot and returns the +reflection record as the base. It does not trace or reload either branch. Pass B follows the link and +owns later stochastic interfaces, bounding the queue at two leaves without carrying branch state across +a Pass A radiance trace. ## 3. Phases @@ -116,31 +212,48 @@ dispatch for the common single-segment case and only spill splits — measure be array already uses) and select the raygen SBT record at dispatch. Add a second raygen that is a copy of the current one. Play-test: pixel-identical output, one extra dispatch. - **M1 — split, no branching.** Move primary/guide work to pass A, bounce loop to pass B, one record - per pixel, deterministic split temporarily disabled (falls back to stochastic). Play-test + profile. + per pixel/sample, deterministic split temporarily disabled (falls back to stochastic). Play-test + profile. This is the milestone that proves or kills the approach. -- **M2 — splits back.** Re-enable the deterministic Fresnel split as a second appended record. - Play-test for energy parity against `b4a0e7b`. -- **M3 — measure.** Expect: raygen live state well under `main`'s 308 B baseline, NOINST gone, - traversal LGSB better hidden via higher occupancy. **Baseline to beat is `db6418b` at 21 ms**, not - `b4a0e7b` at 47 ms — beating the regression proves nothing. +- **M2.1 — transmitted-guide filter.** Apply entry-only stained-glass alpha/tint to the terminal + ordinary albedo guide. Exclude water and Beer extinction. Validate in debug view 2. +- **M2.2 — linked secondary + dielectric split.** Give every pixel one directly indexed secondary + record, split the single Pass A sample once at the first visually-primary Fresnel interface, write both + post-interface continuations, and return without tracing either. +- **M2.3 — Pass-B transport/depth.** Keep opaque delta mirrors and every post-split radiance trace in + Pass B, remove carried `diffuseDepth`, and count every hit observed by Pass B. +- **M2.4 — deterministic auxiliary guides.** Use dedicated reflected/refracted guide probes after the + first hit. Never let stochastic radiance choices or reflected content enter ordinary guides. +- **M2.5 — experimental reflected-content guide.** Optional runtime A/B only. Modulate reflected + terminal diffuse content by material reflectance with explicit fallbacks for sky/emissive/metal/mirror. + Do not make this the default based only on another game's debug buffer. +- **M3 — validate and measure.** Play-test energy parity against the stochastic M1 reference, debug all + six guides, profile Pass A/Pass B and Pass A live state, and compare combined time against both M1's + **14.2 ms** and `db6418b`'s 21 ms. M2 must not give back the structural M1 win. - **M4 — later.** ReSTIR spatial reuse becomes a third pass over the G-buffer. This is the reason the split is worth doing even if M3 is only neutral: spatial reuse is inherently a screen-space multi-pass algorithm and would otherwise be bolted onto a megakernel. ## 4. Risks / kill criteria -- **Bandwidth vs occupancy.** The whole bet is that removing live state buys more than the record - traffic costs. M1 must show it. If M1 does not beat `db6418b`'s 21 ms, revert to `db6418b`'s - structure — `b4a0e7b` is not a fallback, it is a regression being carried deliberately while the - split lands. -- **Reproducing the `b4a0e7b` failure through a buffer.** If pass A holds the record live across its - own traces instead of writing and exiting, it pays the same 288 B live range *plus* the memory - traffic. Check the live-state CSV for pass A, not just the frame time. -- **Pass B incoherence.** Records start incoherent, but they already are today; SER still applies - inside pass B, and a dense queue is strictly better than a sparse screen dispatch. -- **SER interaction is an open question** independent of this work — `GPU_PERF_PLAN.md` §0 flags that - the scheduler itself costs 7.7% and a no-reorder A/B has never been run. Do that A/B *before* M1 so - the two changes are not entangled. +- **M1 regression budget.** M1 proved the structure at 14.2 ms. M2 is killed or redesigned if branch + work/live state materially gives that win back; 21 ms is no longer an acceptable success threshold. +- **Measured M2 baseline.** The first linked-spill implementation measured **Pass A 8.6 ms + Pass B + 11.1 ms = 19.7 ms**, versus M1's 14.2 ms. The 4.2 ms Pass A increase identifies duplicated + traversal as the primary cost; changing the 48 B linear buffer to a storage texture cannot remove it. +- **Reproducing the `b4a0e7b` failure.** Pass A must not trace either queued radiance branch. Check its + live-state CSV as well as frame time. +- **Pass A tail latency.** Only deterministic guide probes may extend beyond the camera hit. Profile + deep glass/water guide walks separately. +- **Queue footprint.** The fixed secondary mapping removes atomics and overflow but costs one extra + 48-byte record per pixel even when no split occurs. Watch VRAM at high render resolution and SPP. +- **Opaque energy mismatch.** Never reuse dielectric `(F, 1-F)` weights for an additive opaque + diffuse+specular BSDF. Moving mixed surfaces requires an explicit diffuse-only terminal contract. +- **Rough dielectric gap.** Current water/glass transport is exact-delta in both passes. A real glossy + dielectric is a separate BSDF feature, not something M2 gets merely by comparing roughness. +- **Spec-guide experiment.** Destination albedo is not material reflectance. Keep it flag-gated with + robust fallbacks and judge RR stability, not only debug-view appearance. +- **SER is Pass B only.** Pass A is compiled without an invocation-reorder capability and uses ordinary + `TraceRay`. A full no-reorder A/B for the larger indirect shader remains a separate experiment. - **Two dispatches means a barrier**; trivial next to the trace cost, but it serialises pass A/B, so any pass A tail latency is exposed. @@ -149,8 +262,24 @@ dispatch for the common single-segment case and only spill splits — measure be - [x] Step 2 (single instantiation + guide hoist) — `b4a0e7b`, **GPU-tested: 21 ms -> 47 ms, REGRESSION** (kept deliberately; the split is its fix, see §0.1) - [x] M0 plumbing — `d76f450`, multiple raygens per pipeline, no behaviour change -- [ ] no-reorder A/B (prerequisite, independent; intentionally skipped for this work) +- [x] Pass A no-SER — ordinary `TraceRay`; emitted SPIR-V has no reorder capability/instructions +- [ ] Pass B no-reorder A/B (independent; intentionally deferred) - [x] M1 split — 48 B packed records, primary/guide + indirect dispatches, stochastic dielectric fallback. **GPU-tested: primary 4.4 ms + indirect 9.8 ms = 14.2 ms**, versus the 21 ms baseline. -- [ ] M2 splits restored -- [ ] M3 measure +- [x] M2.1 transmitted-guide filter — implemented; shader validation passed, GPU guide-view check pending +- [x] M2.2 linked secondary + one deterministic dielectric split — implemented with one fixed slot + per pixel, no atomics/overflow, Pass A fixed at one sample, and no post-split radiance trace in + Pass A; GPU profile pending +- [x] M2.3 Pass-B transport + local per-hit depth — opaque delta and all post-split transport remain + in Pass B; GPU parity check pending +- [x] M2.4 deterministic auxiliary guides — reflected/refracted guide probes are decoupled from + stochastic radiance and reflected content never enters ordinary guides; GPU parity check pending +- [ ] M2.5 reflected-content `gSpecAlbedo` experiment (flagged, non-default) +- [ ] M3 GPU validation and measurement + +Current worktree verification: Gradle test suite and SPIR-V validation pass. The emitted +`PackedPathSegment` array stride is still 48 B and `nextRecord` remains at byte offset 44. + +Initial M2 measurement: **8.6 ms Pass A + 11.1 ms Pass B = 19.7 ms total**. Keep the queue buffer-backed: +mapping one record to a texture needs three `RGBA32UI` texels (the same 48 B payload) and does not +address the dominant extra traversal. Re-profile the leaf-owned guide path and fixed-SPP-1 Pass A. diff --git a/shaders/world/guides.slang b/shaders/world/guides.slang index d11b93f7..61207c9d 100644 --- a/shaders/world/guides.slang +++ b/shaders/world/guides.slang @@ -1,15 +1,14 @@ // DLSS-RR guide state and everything that resolves it: the gv_* captures, the foreground specular -// surface and its reflection motion vector, the transmitted-destination walk, and the image writes. +// surface and its reflection motion vector, and the image writes. // PRIMARY PASS ONLY. Depends on trace, water and segment. -// First-hit (primary-visibility) guide attributes, captured at bounce 0 of tracePath. The primary ray -// is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. +// Primary-visibility guide attributes. Pass A traces exactly one sample per pixel; a split reflection +// leaf supplies the spec endpoint and its transmission leaf may replace the ordinary tuple. import world_common; import world_core; import math; import medium; -import segment; import water; import trace; @@ -22,18 +21,12 @@ public static bool gv_motionUseRefracted = false; // true when the MV tracks the // World-space displacement of the motion-guide surface since the previous frame (0 for static // terrain/sky, an entity's per-vertex/object motion for a dynamic hit). public static float3 gv_motionObjDisp = float3(0.0, 0.0, 0.0); -// M2 fast path: when the deterministic reflection leaf reaches opaque content/sky in exactly one ray, -// Pass A already owns the endpoint that specularReflectionMotion would trace again. More complex leaves -// leave this false and retain the deterministic auxiliary guide trace. -public static bool gv_directReflectionEndpointValid = false; -public static float3 gv_directReflectionEndpoint = float3(0.0, 0.0, 0.0); -public static float3 gv_directReflectionMotionPrev = float3(0.0, 0.0, 0.0); // Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs // (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is // the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. public struct SpecSurface { public float3 camRel; // interface position relative to the current camera - public float3 normal; // shading normal (wave-perturbed for water) + public float3 normal; // FP32: reflection reprojection amplifies normal quantization at distance public float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates public float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface public float roughness; @@ -94,26 +87,22 @@ public float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, float3 n = normalize(surface.normal); float3 specDir = reflect(primaryDir, n); + // Pass A stops radiance traversal at the first split, so reflection motion keeps one dedicated, + // deterministic guide probe. + traceGuide(CULL_SECONDARY, + offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), + RAY_TMIN, specDir, 10000.0, + max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), + max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); float3 reflectedHit; float3 reflectedMotionPrev; - if (gv_directReflectionEndpointValid) { - reflectedHit = gv_directReflectionEndpoint; - reflectedMotionPrev = gv_directReflectionMotionPrev; + if (payload.hitT > 0.0) { + reflectedHit = surfacePos + specDir * payload.hitT; + reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE + ? payload.motionPrev : float3(0.0, 0.0, 0.0); } else { - // biasNormal is a closest-hit normal, already unit length, and only its sign matters here. - traceGuide(CULL_SECONDARY, - offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), - RAY_TMIN, specDir, 10000.0, - max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), - max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); - if (payload.hitT > 0.0) { - reflectedHit = surfacePos + specDir * payload.hitT; - reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE - ? payload.motionPrev : float3(0.0, 0.0, 0.0); - } else { - reflectedHit = surfacePos + specDir * 1.0e6; - reflectedMotionPrev = float3(0.0, 0.0, 0.0); - } + reflectedHit = surfacePos + specDir * 1.0e6; + reflectedMotionPrev = float3(0.0, 0.0, 0.0); } float3 previousN = length(surface.previousNormal) >= 0.5 ? normalize(surface.previousNormal) : n; @@ -123,9 +112,6 @@ public float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, return prevValid ? (prevNdc - currentNdc) * 0.5 * size : float2(0.0, 0.0); } -// Deterministic transmitted destination for clear-interface RR guides. The first interface has already -// been crossed. Continue through every crossed dielectric until opaque/particle content or sky is reached; -// TIR changes the walk into a reflected secondary ray instead of producing a false foreground guide. public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, float roughness, float3 diffuseAlbedo) { gv_hitCamRel = hitCamRel; @@ -137,19 +123,19 @@ public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 nor gv_albedo = diffuseAlbedo; } +// Deterministic ordinary guide behind the first transmitted interface. This is guide-only work: +// radiance reflection/transmission continuations are queued at the first split and traced by Pass B. public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, - float3 surfaceBiasNormal, - float currentIor, float rayBias, - float rayConeWidth, float rayConeSpread, - float3 guideFilter) { + float3 surfaceBiasNormal, MediumStack medium, float rayBias, + float rayConeWidth, float rayConeSpread, float3 guideFilter) { if (dot(transmittedDir, transmittedDir) <= 0.0) return; float3 direction = normalize(transmittedDir); - // surfaceBiasNormal is a closest-hit normal, already unit length, and only its sign matters here. float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); - currentIor = max(currentIor, 1.0); - for (uint crossing = 0u; crossing < MAX_TRANSMISSION_GUIDE_HITS; ++crossing) { + // The camera interface consumed bounce 0; the remaining configured bounce budget is the natural + // cap for deterministic guide crossings too. + for (uint crossing = 0u; crossing < worldPush.maxBounces; ++crossing) { traceGuide(CULL_PRIMARY, ro, RAY_TMIN, direction, 10000.0, rayConeWidth, rayConeSpread); if (payload.hitT <= 0.0) { @@ -171,58 +157,47 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, payload.normal, endpointRoughness, guideFilter * endpointAlbedo); return; } - if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; - // The closest-hit shader has already alpha-blended stained-glass texture/tint toward white. - // Count each dielectric volume once on entry; applying it again at the exit face would square a - // single block's filter. Water uses payload.albedo to parameterize Beer extinction instead. - if (material == MATERIAL_DIELECTRIC && payloadDielectricEntering()) { - guideFilter *= payload.albedo; - } - - // Mirror the radiance path's refraction. Absorption inside the crossed media is deliberately NOT - // folded into the albedo guide: RR demodulates by albedo, and a distance-dependent attenuation - // baked into it would disagree with the colour, which is where the extinction actually lands. - float3 interfaceNormal = normalize(payload.normal); - float3 interfaceBiasNormal = interfaceNormal; - if (material == MATERIAL_WATER && (worldPush.flags & 16u) != 0u) { + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 geometricNormal = normalize(payload.normal); + float3 interfaceNormal = geometricNormal; + if (isWater && (worldPush.flags & 16u) != 0u) { float waterFootprint = rayConeWidth - / max(abs(dot(-direction, interfaceBiasNormal)), 0.2); - interfaceNormal = applyWaterWaves(interfaceNormal, + / max(abs(dot(-direction, geometricNormal)), 0.2); + interfaceNormal = applyWaterWaves(geometricNormal, interfacePos.xz + worldPush.waterAnchor.xy, worldPush.waterParams.w, waterFootprint); } - // Exiting returns to air: the walk keeps only the current index, which is the same depth-1 - // approximation the guide already made and is exact for a single enclosing volume. - float targetIor = payloadDielectricEntering() ? max(payloadIor(), 1.0) : 1.0; - float3 nextDirection = refract(direction, interfaceNormal, currentIor / targetIor); + + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), + payload.albedo, transmission); + float etaT = entering ? entered.ior : medium.outer.ior; + float3 nextDirection = refract(direction, interfaceNormal, medium.current.ior / etaT); if (dot(nextDirection, nextDirection) <= 0.0) { - // Total internal reflection: there is no transmitted destination to describe, and following - // the reflection instead would be actively harmful. setTransmissionGuide marks its endpoint - // as its own feature, whose MV is that point's own reprojection delta — valid for a - // refracted point (the near-constant refraction offset cancels between frames) but WRONG for - // a mirror image, which sweeps at roughly twice the camera's rate and in the opposite sense. - // That produced motion vectors that grew with camera motion. - // - // Bailing leaves the foreground interface tuple the caller already wrote, which is what TIR - // physically deserves: F is 1, so specular albedo is 1 and diffuse albedo is 0, the pixel is - // pure reflection, and gSpecMotion already describes it with a proper mirror-image - // reprojection. Depth stays on the interface, which is the only real surface here. + // Never let a TIR reflection become ordinary diffuse/depth. + setTransmissionGuide(interfacePos - worldPush.camOffset, + isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), + interfaceNormal, 0.0, float3(0.0, 0.0, 0.0)); return; } + if (!isWater && entering) { + guideFilter *= payload.albedo; + } + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } direction = normalize(nextDirection); - currentIor = targetIor; - ro = offsetSurfaceOrigin( - interfacePos, interfaceBiasNormal, direction, SURF_BIAS); + ro = offsetSurfaceOrigin(interfacePos, geometricNormal, direction, + isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS); } } -// One Monte Carlo path from the continuation produced by the primary pass. - -// Resolve the captured gv_* guide state into the DLSS-RR guide images. Called once, right after the -// sample that captured them, so the guide state dies there instead of staying live across every -// remaining sample's traces. +// Resolve the captured gv_* guide state into the DLSS-RR guide images after Pass A queues continuations. public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary // tuple with the transmitted destination. diff --git a/shaders/world/lighting.slang b/shaders/world/lighting.slang index e06abf74..78ed7364 100644 --- a/shaders/world/lighting.slang +++ b/shaders/world/lighting.slang @@ -229,32 +229,10 @@ public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint p // finding; a presampled candidate pool would attack the same 5.9ms structurally, but see the note on // proposalSeed in tracePath for why it should share the pool rather than the seed. -// Candidate-count divisor for RIS at secondary vertices (bounce > 0). Secondary vertices carried ~78% of -// the cost while being much the more forgiving place to spend variance: that radiance is already -// integrated over a diffuse lobe before the denoiser sees it. 18.0 -> 16.1ms, and indistinguishable from -// 1 by eye — whereas reducing M globally to 2 was clearly worse, because that also degraded the primary -// hit. The primary hit always keeps the full worldPush.risCandidates. -// -// 4 is the floor worth using rather than a tuning accident. The stratification below keeps at least one -// global candidate, so M=2 is the smallest count that still schedules one local AND one global; at M=1 -// localProbability collapses to 0 and the light-grid proposal that makes nearby emitters sample well is -// gone entirely. -public static const uint SECONDARY_RIS_DIVISOR = 4u; - -// Deepest SHADED surface at which RIS emitter NEE runs. Past it, emitters are gathered only when a path -// hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep, so the -// failure mode is noisier emitter light at depth rather than missing light. Unlike the divisor this also -// drops shadeReservoir's per-vertex shadow ray, which never scaled with M. Gating at all: 16.1 -> 15.7ms. -// -// This counts hits observed by Pass B. The primary/interface prefix consumed by Pass A is outside the -// budget, while every material and lobe encountered after the handoff advances it uniformly. -public static const uint RIS_MAX_INDIRECT_DEPTH = 2u; - // Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen // sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, - float rough, bool twoSided, float sss, uint indirectDepth, - inout uint seed, inout uint proposalSeed) { + float rough, bool twoSided, float sss, inout uint seed, inout uint proposalSeed) { Reservoir r = resEmpty(); LightGridCell gridCell; int3 gridCellCoord; @@ -266,9 +244,7 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 * worldPush.lightGridOrigin.w; } bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); - uint candidateCount = indirectDepth == 0u - ? worldPush.risCandidates - : max(1u, worldPush.risCandidates / SECONDARY_RIS_DIVISOR); + uint candidateCount = worldPush.risCandidates; // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six // local and two global candidates, with every lane taking the same branch for a given candidate. // Counts that are not divisible by four use the nearest practical split with at least one global diff --git a/shaders/world/math.slang b/shaders/world/math.slang index 4571684e..f4fa270f 100644 --- a/shaders/world/math.slang +++ b/shaders/world/math.slang @@ -37,9 +37,8 @@ public float3 fresnelSchlick(float cosT, float3 f0) { // g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). public static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional public static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) -// SSS is primary-terminal lighting only. The handoff surface is Pass B hit 0; any surface reached by a -// continuation is indirect and must not schedule an SSS shadow ray. -public static const int MAX_SSS_INDIRECT_DEPTH = 0; +// Keep SSS on the first two Pass B hits. Pass A's primary/interface prefix is outside this local depth. +public static const int MAX_SSS_INDIRECT_DEPTH = 1; public float hg(float cosT, float g) { float g2 = g * g; return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); diff --git a/shaders/world/trace.slang b/shaders/world/trace.slang index 14729dde..e8e89194 100644 --- a/shaders/world/trace.slang +++ b/shaders/world/trace.slang @@ -50,6 +50,17 @@ public Payload makeRadiancePayload(uint flags, uint rayCone) { return p; } +// Ordinary radiance trace for latency-sensitive, coherent work such as Pass A. It invokes hit/miss +// shaders directly and therefore requires no invocation-reorder capability. +public void traceRadiance(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + bool showCelestial, float rayConeWidth, float rayConeSpread) { + payload = makeRadiancePayload(showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u, + packHalf2(float2(rayConeWidth, rayConeSpread))); + TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, + SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, + makeRay(ro, tmin, rd, tmax), payload); +} + // SER radiance trace: trace into a hit object, reorder threads by hit coherence, THEN invoke the // hit/miss shader — this is where the divergent Section/Prim shading work (world.rchit/world.rahit) // actually happens, so reordering before it is what pays for the reorder's own cost. diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 9e9478bd..747ebcf1 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -32,7 +32,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 ro = seg.ro; float3 rd = seg.rd; float3 throughput = seg.throughput; - uint seed = seg.seed; + // Pass A is fixed at one sample per pixel. Decorrelate each Pass B resample here so configured SPP + // produces independent lighting/BSDF paths from the shared terminal continuation. + uint seed = seg.seed ^ sampleIndex * 2246822519u; + seed = pcg(seed); float rayConeWidth = seg.rayConeWidth; float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); int maxBounces = int(worldPush.maxBounces); @@ -58,7 +61,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. bool showCelestial = seg.showCelestial; // Pass B treats every hit it sees as one indirect-depth step, regardless of material or selected - // continuation lobe. Pass A's primary/interface prefix is outside this budget. + // continuation lobe. Deliberately start at zero instead of seg.bounce: the first hit of either + // queued split branch is still the primary-terminal shading point. Hits 0 and 1 retain SSS; RIS + // remains active with its full configured candidate count at every hit. Pass A's primary/interface + // prefix is outside this SSS quality budget. int indirectDepth = 0; for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to @@ -186,10 +192,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // RIS direct lighting from block emitters (lava, glowstone, torches, ...) — same reservoir // sampler as terrain/entities, but two-sided: a billboard has no back face, so light // striking either side of the quad should still land (matches the sun/moon NEE above). - if (risOn && hitDepth <= int(RIS_MAX_INDIRECT_DEPTH)) { + if (risOn) { float3 v = -rd; Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, uint(hitDepth), seed, proposalSeed); + true, 0.0, seed, proposalSeed); float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, true, 0.0, risVis); @@ -236,7 +242,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // One gate drives both the RIS call below and the direct-hit emission suppression here. They must // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where // RIS is skipped must also gather emission directly or that light is lost outright. - bool risActive = risOn && hitDepth <= int(RIS_MAX_INDIRECT_DEPTH); + bool risActive = risOn; bool gateEmitter = risActive && payloadEmitterInList(); if (emission > 0.0 && (!gateEmitter || showCelestial)) { L += throughput * albedo * emission; @@ -284,7 +290,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { if (risActive) { float activeSss = hitDepth <= MAX_SSS_INDIRECT_DEPTH ? sss : 0.0; Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, - uint(hitDepth), seed, proposalSeed); + seed, proposalSeed); float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, risVis); @@ -373,21 +379,20 @@ void main() { uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; ConstPtr queue = ConstPtr(pc.pathQueueAddr); float3 frameRadiance = float3(0.0, 0.0, 0.0); - for (uint s = 0u; s < spp; ++s) { - uint recordIndex = pixelIndex * spp + s; - // [loop] - for (uint leaf = 0u; leaf < 2u; ++leaf) { - PackedPathSegment packed = queue[recordIndex]; - bool valid; - PathSegment segment = unpackPathSegment(packed, valid); - if (valid) { + uint recordIndex = pixelIndex; + for (uint leaf = 0u; leaf < 2u; ++leaf) { + PackedPathSegment packed = queue[recordIndex]; + bool valid; + PathSegment segment = unpackPathSegment(packed, valid); + if (valid) { + for (uint s = 0u; s < spp; ++s) { frameRadiance += tracePath(segment, uint2(pix), s + leaf * spp); } - if (packed.nextRecord == PATH_NO_NEXT) { - break; - } - recordIndex = packed.nextRecord; } + if (packed.nextRecord == PATH_NO_NEXT) { + break; + } + recordIndex = packed.nextRecord; } outImage[pix] = float4(frameRadiance / float(spp), 1.0); } diff --git a/shaders/world/world_core.slang b/shaders/world/world_core.slang index 1218199d..5b5185f5 100644 --- a/shaders/world/world_core.slang +++ b/shaders/world/world_core.slang @@ -91,7 +91,6 @@ public bool isDeltaAlpha(float roughness) { return clamp(roughness, 0.0, 1.0) <= MIRROR_ALPHA_MAX; } -public static const uint MAX_TRANSMISSION_GUIDE_HITS = 8u; // Continuations one sample may carry: the camera segment, plus the reflection branch a primary // dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. public static const uint MAX_PATH_SEGMENTS = 2u; diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang index 4b89f44c..fbd0f5d6 100644 --- a/shaders/world/world_primary.rgen.slang +++ b/shaders/world/world_primary.rgen.slang @@ -1,10 +1,9 @@ // Primary/guide pass (pass A of the wavefront split — see docs/WAVEFRONT_PLAN.md). // -// Traces the camera ray through the dielectric chain to the first surface worth shading, captures the -// DLSS-RR guide buffers, and writes one resumable continuation per sample to the path queue. It shades -// nothing: no NEE, no RIS, no BSDF sampling. That is why lighting and indirect are absent below, -// and their absence is the point — the pass separation is enforced by this include list rather than -// left to dead-code elimination. +// Traces one camera radiance ray, captures DLSS-RR guides, and writes one or two resumable +// continuations per pixel. At the first eligible dielectric it queues both post-interface branches and +// stops; every later radiance trace belongs to Pass B. Deterministic guide probes may continue through +// refraction, but Pass A does no NEE, RIS, SSS, or BSDF sampling. // // Perspective setup: invViewProj = inverse(proj * viewRot) maps clip to camera-relative world. Primary // rays are reconstructed from two unprojected points (near and far plane) rather than assuming the eye @@ -12,7 +11,8 @@ // from the matrix. camOffset shifts camera-relative space into the terrain's rebased coordinates. Depth // is reversed-Z (near=1, far=0). // -// SER: the build emits EXT and NV shader-invocation-reorder variants from this source. +// Pass A deliberately uses ordinary TraceRay. Its short, visually coherent direct/interface walk is +// latency-sensitive and does not amortize an SER reorder barrier. import world_common; import world_core; import math; @@ -22,7 +22,7 @@ import water; import trace; import guides; -public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowSplit, +public PathSegment tracePrimary(PathSegment seg, DevicePtr queue, uint splitRecord, out uint nextRecord, out bool alive) { @@ -34,29 +34,20 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowS float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); uint seed = seg.seed; bool showCelestial = seg.showCelestial; - int maxBounces = int(worldPush.maxBounces); bool waterWaves = (worldPush.flags & 16u) != 0u; - bool awaitingDirectReflectionEndpoint = false; - if (captureGuides) { - gv_directReflectionEndpointValid = false; - } nextRecord = PATH_NO_NEXT; alive = false; - for (int bounce = seg.bounce; bounce <= maxBounces; ++bounce) { + { + int bounce = seg.bounce; PathSegment terminal = makePathSegment(ro, rd, throughput, medium, rayConeWidth, rayConeSpread, seed, bounce, showCelestial, false, false); - traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, - ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); + traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, + showCelestial, rayConeWidth, rayConeSpread); if (payload.hitT < 0.0) { - if (awaitingDirectReflectionEndpoint) { - gv_directReflectionEndpointValid = true; - gv_directReflectionEndpoint = ro + rd * 1.0e6; - gv_directReflectionMotionPrev = float3(0.0, 0.0, 0.0); - } - if (bounce == 0 && captureGuides) { + if (bounce == 0) { gv_normal = float3(0.0, 0.0, 0.0); gv_albedo = SKY_DIFF_ALBEDO; gv_rough = 1.0; @@ -82,7 +73,7 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowS ? 0.0 : clamp(payloadMetalness(), 0.0, 1.0); float3 diffAlb = material == MATERIAL_PARTICLE ? float3(payload.albedo) : float3(payload.albedo) * (1.0 - metal); - if (bounce == 0 && captureGuides) { + if (bounce == 0) { gv_normal = n; gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionHitCamRel = gv_hitCamRel; @@ -102,32 +93,6 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowS } } - // A non-emissive pure delta mirror is primary transport. Consume it here so the reflected - // feature remains outside Pass B's indirect-hit budget. Mixed diffuse+specular and emissive - // surfaces remain terminal in Pass B. - bool pureDeltaMirror = exactSpecular - && luminance(diffAlb) <= 1.0e-6 - && payloadEmission() <= 0.0; - if (pureDeltaMirror) { - awaitingDirectReflectionEndpoint = false; // a mirror chain needs the fallback guide trace - if (any(medium.current.extinction > 0.0)) { - throughput *= exp(-medium.current.extinction * payload.hitT); - } - rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), - RAY_CONE_MIN_WIDTH); - float3 v = -rd; - throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), payload.f0); - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, n, rd, SURF_BIAS); - showCelestial = true; - continue; - } - if (awaitingDirectReflectionEndpoint) { - gv_directReflectionEndpointValid = true; - gv_directReflectionEndpoint = hitPos; - gv_directReflectionMotionPrev = material == MATERIAL_OPAQUE - ? float3(payload.motionPrev) : float3(0.0, 0.0, 0.0); - } alive = true; return terminal; } @@ -141,18 +106,13 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowS RAY_CONE_MIN_WIDTH); bool isWater = material == MATERIAL_WATER; - if (awaitingDirectReflectionEndpoint) { - // A second dielectric makes this a multi-interface/stochastic endpoint; retain the - // deterministic reflection-guide trace instead of reusing a noisy radiance leaf. - awaitingDirectReflectionEndpoint = false; - } bool entering = payloadDielectricEntering(); float3 geometricNormal = n; float3 previousNormal = n; if (isWater && waterWaves) { float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); - if (bounce == 0 && captureGuides && abs(geometricNormal.y) >= 0.5) { + if (bounce == 0 && abs(geometricNormal.y) >= 0.5) { float2 currentGrad; float2 previousGrad; waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, @@ -176,7 +136,7 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowS float3 transmittedDir = refract(rd, n, etaI / etaT); float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - if (bounce == 0 && captureGuides) { + if (bounce == 0) { gv_normal = n; gv_rough = 0.0; gv_albedo = float3(0.0, 0.0, 0.0); @@ -187,21 +147,22 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowS gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, gv_rough, float3(F, F, F)); if (dot(transmittedDir, transmittedDir) > 0.0) { + MediumStack guideMedium = medium; + if (entering) { + mediumPush(guideMedium, entered); + } else { + mediumPop(guideMedium); + } resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, - entering ? entered.ior : medium.outer.ior, - transmitBias, rayConeWidth, rayConeSpread, - !isWater && entering - ? float3(payload.albedo) - : float3(1.0, 1.0, 1.0)); + guideMedium, transmitBias, rayConeWidth, rayConeSpread, + !isWater && entering + ? float3(payload.albedo) : float3(1.0, 1.0, 1.0)); } } - // M2 splits once at the first non-TIR visually-primary Fresnel interface. The transmitted - // continuation is written to device memory BEFORE the reflection trace begins, so it cannot - // become a live range spanning traversal. The caller reloads and walks it only after this leaf's - // terminal record has itself been written. - bool splitEligible = allowSplit - && dot(transmittedDir, transmittedDir) > 0.0 + // Split once, write both post-interface continuations, and stop Pass A radiance traversal. + // Pass B owns every radiance trace after this point. + bool splitEligible = dot(transmittedDir, transmittedDir) > 0.0 && F > 0.0 && F < 1.0; if (splitEligible) { MediumStack transmittedMedium = medium; @@ -218,34 +179,41 @@ public PathSegment tracePrimary(PathSegment seg, bool captureGuides, bool allowS true, false, false); queue[splitRecord] = packPathSegment(deferred, true, PATH_NO_NEXT); nextRecord = splitRecord; - throughput *= F; - seed ^= 0xa511e9b3u; - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); - showCelestial = true; - awaitingDirectReflectionEndpoint = captureGuides && bounce == 0; - allowSplit = false; - continue; + float3 reflectedDir = reflect(rd, n); + PathSegment reflected = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), + reflectedDir, throughput * F, medium, + rayConeWidth, rayConeSpread, seed ^ 0xa511e9b3u, bounce + 1, + true, false, false); + alive = true; + return reflected; } - if (rndf(seed) < F) { - rd = reflect(rd, n); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); + bool hasTransmission = dot(transmittedDir, transmittedDir) > 0.0; + PathSegment continuation; + if (!hasTransmission || F >= 1.0) { + float3 reflectedDir = reflect(rd, n); + continuation = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), + reflectedDir, throughput * F, medium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true, false, false); } else { - if (dot(transmittedDir, transmittedDir) <= 0.0) { - return terminal; - } - rd = normalize(transmittedDir); - ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + float3 deferredDir = normalize(transmittedDir); if (entering) { mediumPush(medium, entered); } else { mediumPop(medium); } + continuation = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), + deferredDir, throughput * (1.0 - F), medium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true, false, false); } - showCelestial = true; + alive = true; + return continuation; } - return seg; } [shader("raygeneration")] @@ -269,45 +237,21 @@ void main() { MediumStack cameraMedium = makeMediumStack((worldPush.flags & 1u) != 0u ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) : airMedium()); - uint spp = max(worldPush.spp, 1u); uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; - uint baseRecordCount = dimensions.x * dimensions.y * spp; + uint baseRecordCount = dimensions.x * dimensions.y; uint splitRecord = baseRecordCount + pixelIndex; DevicePtr queue = DevicePtr(pc.pathQueueAddr); - for (uint s = 0u; s < spp; ++s) { - seed = pcg(seed); - PathSegment current = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), - cameraMedium, 0.0, rayConeSpread, seed, 0, true, false, false); - uint recordIndex = pixelIndex * spp + s; - bool captureGuides = s == 0u; - // The variance-reduction split is a per-pixel expense, not a per-sample expense. Additional - // SPP samples retain the unbiased stochastic Fresnel choice, bounding this invocation to one - // secondary leaf for the pixel regardless of SPP. - bool allowSplit = s == 0u; - [loop] - for (uint leaf = 0u; leaf < 2u; ++leaf) { - uint nextRecord; - bool alive; - PathSegment terminal = tracePrimary(current, captureGuides, allowSplit, - queue, splitRecord, nextRecord, alive); - // Store the completed leaf immediately. If it split, only a uint link—not the deferred - // PathSegment—survived its traces; the branch itself is reloaded below. - queue[recordIndex] = packPathSegment(terminal, alive, nextRecord); - if (captureGuides) { - writeGuides(pix, dir, jndc, size, rayConeSpread); - captureGuides = false; - } - if (nextRecord == PATH_NO_NEXT) { - break; - } - bool deferredValid; - current = unpackPathSegment(queue[nextRecord], deferredValid); - recordIndex = nextRecord; - allowSplit = false; - } - } + seed = pcg(seed); + PathSegment current = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), + cameraMedium, 0.0, rayConeSpread, seed, 0, true, false, false); + uint nextRecord; + bool alive; + PathSegment terminal = tracePrimary( + current, queue, splitRecord, nextRecord, alive); + queue[pixelIndex] = packPathSegment(terminal, alive, nextRecord); + writeGuides(pix, dir, jndc, size, rayConeSpread); if (pc.debugView != 0u) { writeDebugView(pix); } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 5b3f9fa1..46e884eb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -180,10 +180,9 @@ public static long frameCounter() { private int pushSlot; private RtDisplayPipeline displayPipeline; private RtImage output; - // Packed primary -> indirect continuations. M1 stores one record per render pixel per configured - // sample; the indirect dispatch keeps one invocation per pixel and consumes that pixel's records. + // Packed primary -> indirect continuations. Pass A is fixed at one sample and owns two records per + // render pixel (base + optional transmission); Pass B resamples them at the configured SPP. private RtBuffer continuationQueue; - private int continuationQueueSpp = -1; private RtImage displayImage; // Parallel PQ-encoded ([0,1], ST.2084) HDR display image. Written alongside displayImage when HDR is // enabled. When the PQ swapchain is active, the combined UI overlay is composited over this image, then @@ -697,11 +696,9 @@ private void destroyGuideImages() { private void ensureOutput(RtContext ctx, int width, int height) { boolean rrEnabled = RtDlssRr.enabled(); int rrQuality = rrEnabled ? RtDlssRr.quality() : Integer.MIN_VALUE; - int desiredSpp = Math.max(spp(), 1); if (output != null && continuationQueue != null && displayImage != null && hdrDisplayImage != null && rrOutput != null && exposure.ready() && displayW == width && displayH == height - && continuationQueueSpp == desiredSpp && renderSizeRrEnabled == rrEnabled && renderSizeRrQuality == rrQuality) { return; } @@ -738,17 +735,12 @@ private void ensureOutput(RtContext ctx, int width, int height) { // mapping seam. displayImage stays R8G8B8A8 to match the main target it is copied into // (vkCmdCopyImage requires texel-size-compatible formats). output = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH); - continuationQueueSpp = desiredSpp; - long baseRecords = Math.multiplyExact( - Math.multiplyExact((long) renderW, (long) renderH), - (long) continuationQueueSpp); - long splitRecords = Math.multiplyExact((long) renderW, (long) renderH); + long pixelRecords = Math.multiplyExact((long) renderW, (long) renderH); long continuationBytes = Math.multiplyExact( - Math.addExact(baseRecords, splitRecords), PATH_RECORD_BYTES); + Math.multiplyExact(pixelRecords, 2L), PATH_RECORD_BYTES); continuationQueue = ctx.createBuffer(continuationBytes, VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false, - "path continuation queue " + renderW + "x" + renderH + "x" + continuationQueueSpp - + " + one split slot per pixel"); + "path continuation queue " + renderW + "x" + renderH + "x2"); displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM, "RT display image " + width + "x" + height); // PQ-encoded ([0,1], ST.2084) HDR display image, written in parallel by display.comp when HDR mode is active. hdrDisplayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height); @@ -1256,7 +1248,6 @@ public void destroy() { if (continuationQueue != null) { continuationQueue.destroy(); continuationQueue = null; - continuationQueueSpp = -1; } destroyGuideImages(); exposure.destroy(); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java index 0c22db42..369f30a4 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java @@ -218,7 +218,7 @@ public static boolean enabledByProperty() { private enum SerBackend { NONE("none", null, "world_primary.rgen.spv", "world.rgen.spv"), NV("NV", VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, - "world_primary_nv.rgen.spv", "world_nv.rgen.spv"), + "world_primary.rgen.spv", "world_nv.rgen.spv"), EXT("EXT", VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, "world_primary.rgen.spv", "world.rgen.spv"); From a84bc5efa905b7e3acdd8605f6cf765aee9b9e79 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:15:03 +0900 Subject: [PATCH 19/25] Use reflected content for specular guides --- docs/WAVEFRONT_PLAN.md | 26 ++++++++++++++------------ shaders/world/guides.slang | 33 ++++++++++++++++++++++++++------- shaders/world/world_core.slang | 2 +- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/docs/WAVEFRONT_PLAN.md b/docs/WAVEFRONT_PLAN.md index d3f74f22..53144a36 100644 --- a/docs/WAVEFRONT_PLAN.md +++ b/docs/WAVEFRONT_PLAN.md @@ -95,7 +95,7 @@ sum the leaves, divide by SPP, and write the pixel once. It never touches `gv_*` ### What each pass does NOT contain - Pass A: no NEE, no RIS, no SSS, no finite GGX sampling, and no radiance trace after the camera hit. -- Pass B: no guide capture, no auxiliary guide walks, no `specularReflectionMotion`, no +- Pass B: no guide capture, no auxiliary guide walks, no `resolveSpecularGuides`, no `waterWaveGradTemporal`, no debug views. ### Accepted duplication @@ -148,11 +148,12 @@ supply the endpoints without affecting queued radiance: widened values before `OpImageWrite`; the Vulkan image store owns the final format conversion. - Keep distance-dependent Beer–Lambert extinction in radiance/lighting, not `gAlbedo`; otherwise the material guide varies with path length and disagrees with RR's material demodulation. -- The baseline `gSpecAlbedo` remains average view-dependent material reflectance - (`rrSpecularAlbedo` / interface Fresnel). The reflected terminal supplies motion, not albedo. - “Reflected diffuse content × reflection strength” is an experimental, flag-gated follow-up because it - is not the documented guide identity and is unstable for emissive destinations, metals (`diffAlb=0`), - sky, and mirror recursion. It requires a material-reflectance fallback and an in-place A/B. +- For exact mirrors, `gSpecAlbedo` is the reflected terminal's diffuse content multiplied by the + foreground reflection strength/color (`rrSpecularAlbedo` or interface Fresnel). The existing + reflection-motion probe supplies that terminal without another ray. Sky, emissive destinations, + metals (`diffAlb=0`), and another dielectric/mirror fall back to the foreground material reflectance + to avoid an unstable zero or recursively defined demodulation signal. Rougher surfaces retain their + conventional material-reflectance guide. - `resolveTransmissionGuide` deterministically refracts through later interfaces and never follows a reflected branch into ordinary albedo/depth. TIR freezes the ordinary tuple on that interface. Reflection motion uses its own one-ray guide probe. These are the only traces Pass A performs after @@ -223,9 +224,9 @@ a Pass A radiance trace. Pass B, remove carried `diffuseDepth`, and count every hit observed by Pass B. - **M2.4 — deterministic auxiliary guides.** Use dedicated reflected/refracted guide probes after the first hit. Never let stochastic radiance choices or reflected content enter ordinary guides. -- **M2.5 — experimental reflected-content guide.** Optional runtime A/B only. Modulate reflected - terminal diffuse content by material reflectance with explicit fallbacks for sky/emissive/metal/mirror. - Do not make this the default based only on another game's debug buffer. +- **M2.5 — reflected-content guide.** For exact mirrors, modulate reflected terminal diffuse content + by foreground material reflectance, with explicit material-reflectance fallbacks for + sky/emissive/metal/mirror destinations. - **M3 — validate and measure.** Play-test energy parity against the stochastic M1 reference, debug all six guides, profile Pass A/Pass B and Pass A live state, and compare combined time against both M1's **14.2 ms** and `db6418b`'s 21 ms. M2 must not give back the structural M1 win. @@ -250,8 +251,8 @@ a Pass A radiance trace. diffuse+specular BSDF. Moving mixed surfaces requires an explicit diffuse-only terminal contract. - **Rough dielectric gap.** Current water/glass transport is exact-delta in both passes. A real glossy dielectric is a separate BSDF feature, not something M2 gets merely by comparing roughness. -- **Spec-guide experiment.** Destination albedo is not material reflectance. Keep it flag-gated with - robust fallbacks and judge RR stability, not only debug-view appearance. +- **Reflected-content spec guide.** Destination albedo is not the documented material-reflectance + identity. Keep the robust fallbacks and judge RR stability/ringing as well as debug-view appearance. - **SER is Pass B only.** Pass A is compiled without an invocation-reorder capability and uses ordinary `TraceRay`. A full no-reorder A/B for the larger indirect shader remains a separate experiment. - **Two dispatches means a barrier**; trivial next to the trace cost, but it serialises pass A/B, so @@ -274,7 +275,8 @@ a Pass A radiance trace. in Pass B; GPU parity check pending - [x] M2.4 deterministic auxiliary guides — reflected/refracted guide probes are decoupled from stochastic radiance and reflected content never enters ordinary guides; GPU parity check pending -- [ ] M2.5 reflected-content `gSpecAlbedo` experiment (flagged, non-default) +- [x] M2.5 reflected-content `gSpecAlbedo` — exact mirrors use reflected diffuse content times + foreground reflection strength, with material-reflectance fallbacks - [ ] M3 GPU validation and measurement Current worktree verification: Gradle test suite and SPIR-V validation pass. The emitted diff --git a/shaders/world/guides.slang b/shaders/world/guides.slang index 61207c9d..b182c5c9 100644 --- a/shaders/world/guides.slang +++ b/shaders/world/guides.slang @@ -73,10 +73,15 @@ public float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, flo return projectPrevNdc(mirroredHit, valid); } -// Reflection motion remains owned by the physical foreground interface even when transmission later -// replaces the ordinary guide tuple. Guide tracing is intentionally non-SER (traceGuide above). -public float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, - float2 currentNdc, float2 size, float primaryConeSpread) { +// Reflection guides remain owned by the physical foreground interface even when transmission later +// replaces the ordinary guide tuple. The same non-SER probe resolves both motion and, for exact +// mirrors, the reflected terminal's diffuse content. Destinations without a stable diffuse albedo keep +// the foreground material reflectance: sky, emissive surfaces, metals, and another dielectric/mirror +// would otherwise give RR a zero or recursively defined demodulation signal. +public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, + float2 currentNdc, float2 size, float primaryConeSpread, + out float3 specAlbedo) { + specAlbedo = surface.albedo; if (length(surface.normal) < 0.5 || max(surface.albedo.r, max(surface.albedo.g, surface.albedo.b)) <= 0.001 || surface.roughness > SPEC_MOTION_ALPHA_MAX) { @@ -98,8 +103,20 @@ public float2 specularReflectionMotion(SpecSurface surface, float3 primaryDir, float3 reflectedMotionPrev; if (payload.hitT > 0.0) { reflectedHit = surfacePos + specDir * payload.hitT; - reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE + uint reflectedMaterial = payloadMaterial(); + reflectedMotionPrev = reflectedMaterial == MATERIAL_OPAQUE ? payload.motionPrev : float3(0.0, 0.0, 0.0); + if (surface.roughness <= MIRROR_ALPHA_MAX + && (reflectedMaterial == MATERIAL_OPAQUE + || reflectedMaterial == MATERIAL_PARTICLE) + && payloadEmission() <= 0.0) { + float3 reflectedDiffuse = reflectedMaterial == MATERIAL_PARTICLE + ? float3(payload.albedo) + : float3(payload.albedo) * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); + if (max(reflectedDiffuse.r, max(reflectedDiffuse.g, reflectedDiffuse.b)) > 0.001) { + specAlbedo = surface.albedo * reflectedDiffuse; + } + } } else { reflectedHit = surfacePos + specDir * 1.0e6; reflectedMotionPrev = float3(0.0, 0.0, 0.0); @@ -201,7 +218,9 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary // tuple with the transmitted destination. - float2 specMotion = specularReflectionMotion(gv_spec, primaryDir, jndc, size, primaryConeSpread); + float3 specAlbedo; + float2 specMotion = resolveSpecularGuides( + gv_spec, primaryDir, jndc, size, primaryConeSpread, specAlbedo); // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer @@ -242,7 +261,7 @@ public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, f gAlbedo[pix] = float4(gv_albedo, 1.0); gDepth[pix] = depth; gMotion[pix] = motion; - gSpecAlbedo[pix] = float4(gv_spec.albedo, 1.0); + gSpecAlbedo[pix] = float4(specAlbedo, 1.0); gSpecMotion[pix] = specMotion; } diff --git a/shaders/world/world_core.slang b/shaders/world/world_core.slang index 5b5185f5..62252f11 100644 --- a/shaders/world/world_core.slang +++ b/shaders/world/world_core.slang @@ -15,7 +15,7 @@ import world_common; [[vk::binding(4, 0)]] [format("rgba16f")] public RWTexture2D gAlbedo; // rgb diffuse albedo [[vk::binding(5, 0)]] [format("r32f")] public RWTexture2D gDepth; // HW reversed-Z depth [[vk::binding(6, 0)]] [format("rg16f")] public RWTexture2D gMotion; // screen-space motion (render px) -[[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; // specular albedo (0 — diffuse-only) +[[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; // exact-mirror reflected diffuse × reflectance; material reflectance otherwise [[vk::binding(8, 0)]] [format("rg16f")] public RWTexture2D gSpecMotion; // reflection MVs for RR // Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL From 0fa1adf72f12a15bb9082fdc5781e1b7e1e57e3a Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:20:15 +0900 Subject: [PATCH 20/25] simplify --- shaders/world/guides.slang | 6 ++--- shaders/world/lighting.slang | 15 ++++++------ shaders/world/segment.slang | 18 ++++---------- shaders/world/world.rgen.slang | 33 +++++++++++++++----------- shaders/world/world_primary.rgen.slang | 29 ++++++++-------------- 5 files changed, 43 insertions(+), 58 deletions(-) diff --git a/shaders/world/guides.slang b/shaders/world/guides.slang index b182c5c9..98bbbd26 100644 --- a/shaders/world/guides.slang +++ b/shaders/world/guides.slang @@ -16,7 +16,6 @@ public static float3 gv_normal = float3(0.0, 0.0, 0.0); public static float3 gv_albedo = float3(0.0, 0.0, 0.0); public static float gv_rough = 0.0; public static float3 gv_hitCamRel = float3(0.0, 0.0, 0.0); // primary-surface hit position relative to the current camera -public static float3 gv_motionHitCamRel = float3(0.0, 0.0, 0.0); // motion-guide hit position; water tracks the refracted hit instead public static bool gv_motionUseRefracted = false; // true when the MV tracks the refracted hit (its own reprojection delta) // World-space displacement of the motion-guide surface since the previous frame (0 for static // terrain/sky, an entity's per-vertex/object motion for a dynamic hit). @@ -132,7 +131,6 @@ public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, float roughness, float3 diffuseAlbedo) { gv_hitCamRel = hitCamRel; - gv_motionHitCamRel = hitCamRel; gv_motionObjDisp = motionPrev; gv_motionUseRefracted = true; gv_normal = normal; @@ -236,12 +234,12 @@ public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, f // For a dynamic surface, previous-frame position is the current point minus its world-space // displacement; subtracting gv_motionObjDisp de-cameras the MV. float4 prevClip = mul(worldPush.prevViewProj, - float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); + float4(gv_hitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc // Transmitted content is its own feature: compare its previous and current projections rather than // subtracting the primary interface's jittered NDC. float4 curClipMotion = gv_motionUseRefracted - ? mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)) + ? mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)) : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform // Guard both perspective divides the way the depth above does. A guide point can land behind either // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the diff --git a/shaders/world/lighting.slang b/shaders/world/lighting.slang index 78ed7364..0789b4f8 100644 --- a/shaders/world/lighting.slang +++ b/shaders/world/lighting.slang @@ -220,13 +220,13 @@ public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint p } } -// ---- RIS cost budget. Both constants below come from a set of temporary probes that pinned the -// candidate walk out of the shader to bound what memory-side RIS work could ever be worth. Against an +// ---- RIS cost profile. Temporary probes pinned the candidate walk out of the shader to bound what +// memory-side RIS work could ever be worth. Against an // 18.0ms frame at M=8, removing every dependent load (span -> alias -> record) and all lane divergence // saved 5.9ms — a third of the frame. Split by vertex that was ~4.3ms at secondary vertices against // ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost -// was chasing DEPTH, not divergence, and it lived at secondary vertices. The two knobs below spend that -// finding; a presampled candidate pool would attack the same 5.9ms structurally, but see the note on +// was chasing depth, not divergence, and it lived at secondary vertices. A presampled candidate pool +// would attack the same 5.9ms structurally, but see the note on // proposalSeed in tracePath for why it should share the pool rather than the seed. // Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen @@ -245,6 +245,7 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 } bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); uint candidateCount = worldPush.risCandidates; + r.M = float(candidateCount); // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six // local and two global candidates, with every lane taking the same branch for a given candidate. // Counts that are not divisible by four use the nearest practical split with at least one global @@ -254,7 +255,6 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 uint localCandidateCount = candidateCount - globalCandidateCount; float localProbability = float(localCandidateCount) / float(candidateCount); for (uint c = 0u; c < candidateCount; c++) { - r.M += 1.0; uint li; uint globalsBefore = (c * globalCandidateCount) / candidateCount; uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; @@ -295,8 +295,7 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 // the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the // sample's side of the surface. public float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, - float3 F0, float rough, bool twoSided, float sss, out float3 vis) { - vis = float3(0.0, 0.0, 0.0); + float3 F0, float rough, bool twoSided, float sss) { if (s.W <= 0.0 || s.phat <= 0.0) { return float3(0.0, 0.0, 0.0); } @@ -316,7 +315,7 @@ public float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, flo float dist = length(toL); // Stop just short of the sample point so the ray doesn't self-occlude on the emitter's own face. VisibilityResult shadow = visibility(origin, toL / dist, dist * 0.999); - vis = shadow.transmittance; + float3 vis = shadow.transmittance; return contrib * vis * s.W; } diff --git a/shaders/world/segment.slang b/shaders/world/segment.slang index b735866d..1ff9d265 100644 --- a/shaders/world/segment.slang +++ b/shaders/world/segment.slang @@ -25,14 +25,11 @@ public struct PathSegment { public uint seed; public int bounce; // interfaces already consumed, so RR start and the bounce cap stay global public bool showCelestial; - public bool captureGuides; - public bool maySplit; // may still spawn a deterministic Fresnel branch }; public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, float rayConeWidth, float rayConeSpread, uint seed, - int bounce, bool showCelestial, - bool captureGuides, bool maySplit) { + int bounce, bool showCelestial) { PathSegment s; s.ro = ro; s.rd = rd; @@ -43,8 +40,6 @@ public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, Medi s.seed = seed; s.bounce = bounce; s.showCelestial = showCelestial; - s.captureGuides = captureGuides; - s.maySplit = maySplit; return s; } // field is a uint, so Std430DataLayout gives this an exact 48-byte stride. @@ -61,7 +56,6 @@ public struct PackedPathSegment { public uint nextRecord; }; -public static const uint PATH_VALID = 1u << 11u; public static const uint PATH_NO_NEXT = 0xffffffffu; public float2 octEncode(float3 direction) { @@ -112,7 +106,7 @@ public float3 unpackRgb9e5(uint p) { return float3(p & 0x1ffu, (p >> 9u) & 0x1ffu, (p >> 18u) & 0x1ffu) * scale; } -public PackedPathSegment packPathSegment(PathSegment seg, bool valid, uint nextRecord) { +public PackedPathSegment packPathSegment(PathSegment seg, uint nextRecord) { PackedPathSegment p; p.ro = seg.ro; p.rd = packUnorm16x2(octEncode(seg.rd)); @@ -125,13 +119,12 @@ public PackedPathSegment packPathSegment(PathSegment seg, bool valid, uint nextR p.pathFlags = (uint(seg.bounce) & 15u) | (seg.showCelestial ? 1u << 8u : 0u) | (seg.medium.current.water ? 1u << 9u : 0u) - | (seg.medium.outer.water ? 1u << 10u : 0u) - | (valid ? PATH_VALID : 0u); + | (seg.medium.outer.water ? 1u << 10u : 0u); p.nextRecord = nextRecord; return p; } -public PathSegment unpackPathSegment(PackedPathSegment p, out bool valid) { +public PathSegment unpackPathSegment(PackedPathSegment p) { float2 iors = unpackHalf2(p.mediumIors); Medium current; current.ior = iors.x; @@ -145,10 +138,9 @@ public PathSegment unpackPathSegment(PackedPathSegment p, out bool valid) { medium.current = current; medium.outer = outer; float2 cone = unpackHalf2(p.rayCone); - valid = (p.pathFlags & PATH_VALID) != 0u; return makePathSegment(p.ro, octDecode(unpackUnorm16x2(p.rd)), unpackRgb9e5(p.throughput), medium, cone.x, cone.y, p.seed, - int(p.pathFlags & 15u), (p.pathFlags & (1u << 8u)) != 0u, false, false); + int(p.pathFlags & 15u), (p.pathFlags & (1u << 8u)) != 0u); } // Walk only the visually-primary dielectric chain. The terminal non-dielectric/miss trace is repeated diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 747ebcf1..68299e60 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -107,6 +107,11 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest // of the path. if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { + // A dielectric has no local shading term. At the path cap its continuation cannot be traced, + // so avoid computing an unused Fresnel choice, medium update, and roulette step. + if (bounce >= maxBounces) { + break; + } bool isWater = material == MATERIAL_WATER; bool entering = payloadDielectricEntering(); float3 tint = payload.albedo; @@ -196,9 +201,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 v = -rd; Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, true, 0.0, seed, proposalSeed); - float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, risVis); + true, 0.0); } if (bounce >= maxBounces) { @@ -242,8 +246,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // One gate drives both the RIS call below and the direct-hit emission suppression here. They must // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where // RIS is skipped must also gather emission directly or that light is lost outright. - bool risActive = risOn; - bool gateEmitter = risActive && payloadEmitterInList(); + bool gateEmitter = risOn && payloadEmitterInList(); if (emission > 0.0 && (!gateEmitter || showCelestial)) { L += throughput * albedo * emission; } @@ -287,13 +290,12 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // only be in front of n or behind it, never both, so combining them into one target function is // exact, not an approximation. The SSS term is gated off past MAX_SSS_INDIRECT_DEPTH by passing // sss=0 there (falls back to plain front-only RIS). - if (risActive) { + if (risOn) { float activeSss = hitDepth <= MAX_SSS_INDIRECT_DEPTH ? sss : 0.0; Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, seed, proposalSeed); - float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, - activeSss, risVis); + activeSss); } // Thin-surface SSS transmission. Light entering from the back face scatters through toward the @@ -319,6 +321,12 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } } + // Preserve all local lighting at the terminal hit, but do not sample a continuation that the + // bounce loop cannot trace. + if (bounce >= maxBounces) { + break; + } + // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their // relative reflectance. float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 @@ -380,14 +388,11 @@ void main() { ConstPtr queue = ConstPtr(pc.pathQueueAddr); float3 frameRadiance = float3(0.0, 0.0, 0.0); uint recordIndex = pixelIndex; - for (uint leaf = 0u; leaf < 2u; ++leaf) { + for (uint leaf = 0u; leaf < MAX_PATH_SEGMENTS; ++leaf) { PackedPathSegment packed = queue[recordIndex]; - bool valid; - PathSegment segment = unpackPathSegment(packed, valid); - if (valid) { - for (uint s = 0u; s < spp; ++s) { - frameRadiance += tracePath(segment, uint2(pix), s + leaf * spp); - } + PathSegment segment = unpackPathSegment(packed); + for (uint s = 0u; s < spp; ++s) { + frameRadiance += tracePath(segment, uint2(pix), s + leaf * spp); } if (packed.nextRecord == PATH_NO_NEXT) { break; diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang index fbd0f5d6..c23e30e2 100644 --- a/shaders/world/world_primary.rgen.slang +++ b/shaders/world/world_primary.rgen.slang @@ -25,7 +25,7 @@ import guides; public PathSegment tracePrimary(PathSegment seg, DevicePtr queue, uint splitRecord, - out uint nextRecord, out bool alive) { + out uint nextRecord) { float3 ro = seg.ro; float3 rd = seg.rd; float3 throughput = seg.throughput; @@ -36,13 +36,12 @@ public PathSegment tracePrimary(PathSegment seg, bool showCelestial = seg.showCelestial; bool waterWaves = (worldPush.flags & 16u) != 0u; nextRecord = PATH_NO_NEXT; - alive = false; { int bounce = seg.bounce; PathSegment terminal = makePathSegment(ro, rd, throughput, medium, rayConeWidth, rayConeSpread, seed, bounce, - showCelestial, false, false); + showCelestial); traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread); @@ -52,12 +51,10 @@ public PathSegment tracePrimary(PathSegment seg, gv_albedo = SKY_DIFF_ALBEDO; gv_rough = 1.0; gv_hitCamRel = rd * 1.0e6; - gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = float3(0.0, 0.0, 0.0); gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); } - alive = true; return terminal; } @@ -76,7 +73,6 @@ public PathSegment tracePrimary(PathSegment seg, if (bounce == 0) { gv_normal = n; gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; if (material == MATERIAL_PARTICLE) { @@ -93,7 +89,6 @@ public PathSegment tracePrimary(PathSegment seg, } } - alive = true; return terminal; } @@ -141,7 +136,6 @@ public PathSegment tracePrimary(PathSegment seg, gv_rough = 0.0; gv_albedo = float3(0.0, 0.0, 0.0); gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; gv_motionUseRefracted = false; gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, @@ -176,16 +170,15 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), transmittedMedium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, false, false); - queue[splitRecord] = packPathSegment(deferred, true, PATH_NO_NEXT); + true); + queue[splitRecord] = packPathSegment(deferred, PATH_NO_NEXT); nextRecord = splitRecord; float3 reflectedDir = reflect(rd, n); PathSegment reflected = makePathSegment( offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), reflectedDir, throughput * F, medium, rayConeWidth, rayConeSpread, seed ^ 0xa511e9b3u, bounce + 1, - true, false, false); - alive = true; + true); return reflected; } @@ -197,7 +190,7 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), reflectedDir, throughput * F, medium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, false, false); + true); } else { float3 deferredDir = normalize(transmittedDir); if (entering) { @@ -209,9 +202,8 @@ public PathSegment tracePrimary(PathSegment seg, offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), deferredDir, throughput * (1.0 - F), medium, rayConeWidth, rayConeSpread, seed, bounce + 1, - true, false, false); + true); } - alive = true; return continuation; } } @@ -244,12 +236,11 @@ void main() { seed = pcg(seed); PathSegment current = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), - cameraMedium, 0.0, rayConeSpread, seed, 0, true, false, false); + cameraMedium, 0.0, rayConeSpread, seed, 0, true); uint nextRecord; - bool alive; PathSegment terminal = tracePrimary( - current, queue, splitRecord, nextRecord, alive); - queue[pixelIndex] = packPathSegment(terminal, alive, nextRecord); + current, queue, splitRecord, nextRecord); + queue[pixelIndex] = packPathSegment(terminal, nextRecord); writeGuides(pix, dir, jndc, size, rayConeSpread); if (pc.debugView != 0u) { From 5ef3531874f942106d73acb455ef5a166b852dc4 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:25:19 +0900 Subject: [PATCH 21/25] Fix reflected motion reprojection --- docs/WAVEFRONT_PLAN.md | 7 +++++-- shaders/world/guides.slang | 29 ++++++++++++++++++-------- shaders/world/world_primary.rgen.slang | 3 ++- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/docs/WAVEFRONT_PLAN.md b/docs/WAVEFRONT_PLAN.md index 53144a36..6e1df481 100644 --- a/docs/WAVEFRONT_PLAN.md +++ b/docs/WAVEFRONT_PLAN.md @@ -86,7 +86,7 @@ reflection and transmission records immediately and stop radiance traversal. Onl reflection/refraction guide probes may trace farther. Pass A uses ordinary `TraceRay`, with no invocation-reorder capability or barrier. -**Pass B — `world.rgen`.** One invocation per pixel. Load each valid leaf and resample its terminal +**Pass B — `world.rgen`.** One invocation per pixel. Load each linked leaf and resample its terminal continuation `worldPush.spp` times with decorrelated seeds, run NEE / RIS / SSS / GGX continuation, sum the leaves, divide by SPP, and write the pixel once. It never touches `gv_*`. @@ -154,6 +154,9 @@ supply the endpoints without affecting queued radiance: metals (`diffAlb=0`), and another dielectric/mirror fall back to the foreground material reflectance to avoid an unstable zero or recursively defined demodulation signal. Rougher surfaces retain their conventional material-reflectance guide. +- Reflection reprojection uses both the foreground reflector's and reflected endpoint's + current-minus-previous displacement. The previous endpoint is mirrored around the previous reflector + position, so translating opaque or dielectric entities do not leave their specular motion behind. - `resolveTransmissionGuide` deterministically refracts through later interfaces and never follows a reflected branch into ordinary albedo/depth. TIR freezes the ordinary tuple on that interface. Reflection motion uses its own one-ray guide probe. These are the only traces Pass A performs after @@ -174,7 +177,7 @@ Target 48 B. Unpacked `PathSegment` is ~100 B; the packing below is lossless whe | `medium.outer` | ior half + extinction rgb9e5 | 6 | | `rayConeWidth` / `rayConeSpread` | half x2 | 4 | | `seed` | uint | 4 | -| `bounce`, `showCelestial`, medium/valid flags, next-record link | packed uint x2 | 8 | +| `bounce`, `showCelestial`, medium flags, next-record link | packed uint x2 | 8 | M1 allocated one fixed record per render pixel **per SPP sample**: diff --git a/shaders/world/guides.slang b/shaders/world/guides.slang index 98bbbd26..ced82482 100644 --- a/shaders/world/guides.slang +++ b/shaders/world/guides.slang @@ -28,6 +28,7 @@ public struct SpecSurface { public float3 normal; // FP32: reflection reprojection amplifies normal quantization at distance public float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates public float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface + public float3 motionPrev; // current-minus-previous displacement of the reflecting surface public float roughness; public float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) }; @@ -35,20 +36,27 @@ public static SpecSurface gv_spec = {}; // Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading // normal now and last frame, flat geometric normal for the bias) via the six-argument form. -public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, float3 biasNormal, - float roughness, float3 albedo) { +public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, + float3 biasNormal, float3 motionPrev, float roughness, float3 albedo) { SpecSurface s; s.camRel = camRel; s.normal = normal; s.previousNormal = previousNormal; s.biasNormal = biasNormal; + s.motionPrev = motionPrev; s.roughness = roughness; s.albedo = albedo; return s; } public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { - return makeSpecSurface(camRel, normal, normal, normal, roughness, albedo); + return makeSpecSurface(camRel, normal, normal, normal, + float3(0.0, 0.0, 0.0), roughness, albedo); +} + +public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 motionPrev, + float roughness, float3 albedo) { + return makeSpecSurface(camRel, normal, normal, normal, motionPrev, roughness, albedo); } // angle can land behind the eye even though the reflector itself is comfortably in view. public float2 projectPrevNdc(float3 worldPos, out bool valid) { @@ -64,11 +72,12 @@ public float2 projectPrevNdc(float3 worldPos, out bool valid) { // explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so // static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); // reflectedMotionPrev carries the reflected content's own displacement. -public float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, - float3 reflectedMotionPrev, out bool valid) { +public float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 surfaceMotionPrev, + float3 reflectedWorldPos, float3 reflectedMotionPrev, out bool valid) { float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed + float3 previousSurfacePos = surfacePos - surfaceMotionPrev; float3 prevHit = reflectedWorldPos - reflectedMotionPrev; - float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - surfacePos, n); + float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - previousSurfacePos, n); return projectPrevNdc(mirroredHit, valid); } @@ -103,8 +112,9 @@ public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, if (payload.hitT > 0.0) { reflectedHit = surfacePos + specDir * payload.hitT; uint reflectedMaterial = payloadMaterial(); - reflectedMotionPrev = reflectedMaterial == MATERIAL_OPAQUE - ? payload.motionPrev : float3(0.0, 0.0, 0.0); + // Closest-hit writes motionPrev for every material. Terrain/water are zero; dynamic opaque and + // dielectric entities carry their per-vertex/object displacement. + reflectedMotionPrev = payload.motionPrev; if (surface.roughness <= MIRROR_ALPHA_MAX && (reflectedMaterial == MATERIAL_OPAQUE || reflectedMaterial == MATERIAL_PARTICLE) @@ -124,7 +134,8 @@ public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, ? normalize(surface.previousNormal) : n; bool prevValid; float2 prevNdc = previousReflectionNdc( - surfacePos, previousN, reflectedHit, reflectedMotionPrev, prevValid); + surfacePos, previousN, surface.motionPrev, + reflectedHit, reflectedMotionPrev, prevValid); return prevValid ? (prevNdc - currentNdc) * 0.5 * size : float2(0.0, 0.0); } diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang index c23e30e2..f55c74a1 100644 --- a/shaders/world/world_primary.rgen.slang +++ b/shaders/world/world_primary.rgen.slang @@ -84,7 +84,7 @@ public PathSegment tracePrimary(PathSegment seg, float3 v = -rd; gv_albedo = diffAlb; gv_rough = rough; - gv_spec = makeSpecSurface(gv_hitCamRel, n, rough, + gv_spec = makeSpecSurface(gv_hitCamRel, n, float3(payload.motionPrev), rough, rrSpecularAlbedo(payload.f0, rough, dot(n, v))); } } @@ -139,6 +139,7 @@ public PathSegment tracePrimary(PathSegment seg, gv_motionUseRefracted = false; gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, + isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), gv_rough, float3(F, F, F)); if (dot(transmittedDir, transmittedDir) > 0.0) { MediumStack guideMedium = medium; From e5941a5310e972ddc42429d02fa7f5ffbe9c00ce Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:25:41 +0900 Subject: [PATCH 22/25] Remove completed wavefront plan --- docs/WAVEFRONT_PLAN.md | 290 ----------------------------------------- 1 file changed, 290 deletions(-) delete mode 100644 docs/WAVEFRONT_PLAN.md diff --git a/docs/WAVEFRONT_PLAN.md b/docs/WAVEFRONT_PLAN.md deleted file mode 100644 index 6e1df481..00000000 --- a/docs/WAVEFRONT_PLAN.md +++ /dev/null @@ -1,290 +0,0 @@ -# Wavefront Split Plan — primary/guide pass + indirect pass - -Plan of record started 2026-07-26, `pq` @ `b4a0e7b`. Supersedes nothing; complements -`docs/GPU_PERF_PLAN.md` (whose P-A/P-B/P-C ray-count and locality levers stay valid and orthogonal). - -## 0. Why - -Profile `run/nsight-profile/live-state-5.csv`, pq @ `c22ebf3`, against `main`: - -| Callsite | main | pq @ c22ebf3 | contexts (pq) | -|---|---|---|---| -| primary radiance trace | 308 B / 30 v | **2932 B / 42 v** | 2 | -| `visibility()` (shadow) | 391 B / 39 v | 862 B / 77 v | 10 (5 sites x 2) | - -Top stall flipped `LGSB` -> **`NOINST`** (instruction fetch), i.e. the shader outgrew its I-cache. - -`b4a0e7b` fixed the duplication half of this: continuations became data (`PathSegment`), `tracePath` -is instantiated once behind a `[loop]`, and `world.rgen.spv` went **1017692 -> 645972 bytes (-36.5%)**. - -### 0.1 `b4a0e7b` regressed: 21 ms -> 47 ms (`continuation b4a0e7b.csv`) - -It made things **worse**, badly. Contexts did drop 2 -> 1 as designed, but live state at the primary -trace went **2932 B -> 3347 B**, and LGSB rose sharply. Top contributors: - -| B | source | what | -|---|---|---| -| 504 | `world.rgen.slang:149` | medium extinction (was 336 B) | -| 432 | `:1316` | `n = payload.normal` | -| 324 | `:1516` | `gv_hitCamRel` | -| **288** | **`:1400`** | **`pending = makePathSegment(...)`** | -| 156 / 120 | `:1739` / `:1738` | `dir` / `origin`, now loop-carried | - -The mechanism: **`pending` is created at the primary dielectric and cannot be consumed until -`tracePath` returns, so it survives every subsequent trace in registers.** `[loop]` additionally -blocks specialisation, forcing main's own state to become loop-carried. Halving the instruction -footprint bought nothing because occupancy — not instruction fetch — is the binding constraint. - -**This is `GPU_PERF_PLAN.md` §0's rule confirmed a second time: in this kernel, reducing instructions -at the cost of live state is a losing trade.** The RIS wave-batching revert was the first instance. - -It also converts this plan from a bet into a targeted fix. Pass A **writes the record to memory and -exits**; pass B **reads it once at entry**, where it becomes ordinary loop state. Neither pass holds a -continuation live across a trace, which is precisely what `b4a0e7b` got wrong. M1 must preserve that -property or it will reproduce the same regression through a more expensive mechanism. - -The megakernel still contains two workloads with opposite characteristics and it keeps growing: - -| | primary / guide | indirect / shading | -|---|---|---| -| runs | once per pixel | once per bounce, in a loop | -| coherence | screen-coherent | incoherent | -| owns | guides, spec surface + MV, transmission walk, wave temporal derivative | NEE, RIS, SSS, GGX continuation, RR | - -Everything inlined into one entry point means the hot loop carries the primary code's instruction -footprint even though it is dead after bounce 0, and the primary code carries the loop's live state. - -This also aligns with the standing lesson in `GPU_PERF_PLAN.md` §0: **reduce live state, never add -it.** rgen was at 126 live registers with occupancy as the binding constraint, and traversal (21.5% -of samples, 89% LGSB) is poorly hidden *because* of that. Splitting the kernel is the structural -version of that lesson; the RIS wave-batching attempt failed because it did the reverse. - -### 0.2 M1 result: 21 ms -> 14.2 ms - -M1 landed in `428922d` and was subsequently split into explicit per-pass modules (`62e8aae`, -`cb5a51d`, `27e10d0`). GPU timing: - -| pass | time | -|---|---:| -| primary / guide | 4.4 ms | -| indirect / shading | 9.8 ms | -| **combined** | **14.2 ms** | - -That is **6.8 ms / 32.4% faster** than the 21 ms `db6418b` baseline. The split therefore clears its -kill criterion and confirms the live-state diagnosis. Pass A now runs without SER; a full no-reorder -A/B for Pass B remains outside M2. - -## 1. Design - -Two raygen entry points in the same RT pipeline, selected by SBT record at dispatch, connected by the -existing guide images plus one new continuation buffer. - -**Pass A — `world_primary.rgen`.** One invocation and exactly one camera sample per pixel, independent -of configured SPP. Trace the camera ray once, capture the foreground guides, and emit either the -pre-hit continuation or one/two post-interface continuations. At the first eligible dielectric, write -reflection and transmission records immediately and stop radiance traversal. Only deterministic -reflection/refraction guide probes may trace farther. Pass A uses ordinary `TraceRay`, with no -invocation-reorder capability or barrier. - -**Pass B — `world.rgen`.** One invocation per pixel. Load each linked leaf and resample its terminal -continuation `worldPush.spp` times with decorrelated seeds, run NEE / RIS / SSS / GGX continuation, -sum the leaves, divide by SPP, and write the pixel once. It never touches `gv_*`. - -`PathSegment` from `b4a0e7b` is already the record; that commit was deliberately shaped for this. - -### What each pass does NOT contain - -- Pass A: no NEE, no RIS, no SSS, no finite GGX sampling, and no radiance trace after the camera hit. -- Pass B: no guide capture, no auxiliary guide walks, no `resolveSpecularGuides`, no - `waterWaveGradTemporal`, no debug views. - -### Accepted duplication - -First-interface dielectric math and the non-temporal wave normal remain in both passes: Pass A creates -the first split, while Pass B owns all radiance transport after it and can hit later water/glass. - -### 1.1 M2 transport boundary - -The ownership rule is now **first dielectric interface only**. - -- Water and `MATERIAL_DIELECTRIC` are modeled as exact Fresnel interfaces. Pass A splits the first - non-TIR interface and queues both weighted post-interface states. Degenerate F=0/F=1 interfaces queue - their sole continuation. Pass B owns every subsequent radiance trace and dielectric decision. -- All opaque surfaces, including non-emissive pure delta mirrors, terminate Pass A at the pre-hit - continuation and are handled by Pass B. -- A polished opaque dielectric with both diffuse and exact-specular response is **not** the same split - as glass. Glass has reflection `F` and transmission `1-F`; the current opaque BSDF is additive - diffuse plus specular. Multiplying its diffuse branch by `1-F` would change energy. Mixed - diffuse+delta opaque surfaces therefore remain in Pass B for M2 unless a record flag explicitly - requests diffuse-only terminal shading. -- Every finite glossy lobe stays in Pass B. Its destination is a distribution, not a stable guide - feature, and moving it would destroy Pass A's coherence without producing a valid sharp guide. - -There is no carried `diffuseDepth`. Pass B starts an `indirectDepth` counter at zero for each queued -leaf and advances it on every hit it observes, regardless of material or selected lobe. The -primary/interface prefix consumed by Pass A remains outside this budget; once handed off, all hits are -treated uniformly for depth gating. RIS remains active with the full configured candidate count at every -Pass B hit. Pass B hits 0 and 1 retain full SSS, with SSS removed beginning at hit 2. This counter is -deliberately independent of packed `bounce`, so split records beginning at transport bounce 1 do not -lose first/second-hit SSS quality. - -### 1.2 M2 guide ownership - -Once Pass A deterministically owns the first dielectric split, dedicated deterministic guide probes -supply the endpoints without affecting queued radiance: - -| leaf | ordinary guide | specular guide | -|---|---|---| -| deterministic refracted probe | `gAlbedo`, `gMotion`, destination depth/normal | — | -| deterministic reflected probe | — | reflected endpoint for `gSpecMotion` | - -- The refracted guide accumulates a `guideFilter` only for **entering stained-glass/dielectric volumes**, multiplying the - terminal diffuse albedo by `payload.albedo`. The closest-hit shader has already performed the desired - texture-alpha blend (`lerp(white, texture*tint, alpha)`). Applying it once on entry avoids counting a - block's front and back faces twice. Water is excluded: its payload tint parameterizes Beer extinction. -- Keep live guide state and the `rgba16f` / `rg16f` storage-image bindings FP32. The ray payload already - packs hit attributes as `half3`, so changing `gv_*` only added another precision boundary. A - half-typed image binding was also ineffective: Slang canonicalized it to `OpTypeImage %float` and - widened values before `OpImageWrite`; the Vulkan image store owns the final format conversion. -- Keep distance-dependent Beer–Lambert extinction in radiance/lighting, not `gAlbedo`; otherwise the - material guide varies with path length and disagrees with RR's material demodulation. -- For exact mirrors, `gSpecAlbedo` is the reflected terminal's diffuse content multiplied by the - foreground reflection strength/color (`rrSpecularAlbedo` or interface Fresnel). The existing - reflection-motion probe supplies that terminal without another ray. Sky, emissive destinations, - metals (`diffAlb=0`), and another dielectric/mirror fall back to the foreground material reflectance - to avoid an unstable zero or recursively defined demodulation signal. Rougher surfaces retain their - conventional material-reflectance guide. -- Reflection reprojection uses both the foreground reflector's and reflected endpoint's - current-minus-previous displacement. The previous endpoint is mirrored around the previous reflector - position, so translating opaque or dielectric entities do not leave their specular motion behind. -- `resolveTransmissionGuide` deterministically refracts through later interfaces and never follows a - reflected branch into ordinary albedo/depth. TIR freezes the ordinary tuple on that interface. - Reflection motion uses its own one-ray guide probe. These are the only traces Pass A performs after - queuing the radiance split. The refracted walk uses `worldPush.maxBounces` as its crossing limit; it - has no separate guide-only cap. - -## 2. Record layout - -Target 48 B. Unpacked `PathSegment` is ~100 B; the packing below is lossless where it matters and the -`medium` outer slot is the only speculative squeeze. - -| field | packed | B | -|---|---|---| -| `ro` | float3 (rebased world) | 12 | -| `rd` | octahedral unorm16x2 | 4 | -| `throughput` | rgb9e5 | 4 | -| `medium.current` | ior half + extinction rgb9e5 | 6 | -| `medium.outer` | ior half + extinction rgb9e5 | 6 | -| `rayConeWidth` / `rayConeSpread` | half x2 | 4 | -| `seed` | uint | 4 | -| `bounce`, `showCelestial`, medium flags, next-record link | packed uint x2 | 8 | - -M1 allocated one fixed record per render pixel **per SPP sample**: - -`baseRecords = renderWidth * renderHeight * spp` - -At 1280x720 and SPP 1 this is 921600 x 48 B = **44.2 MB (42.2 MiB)**. At SPP 8 it was -353.9 MB (337.5 MiB). - -M2 keeps the race-free ownership model: one Pass B invocation writes one pixel. It does **not** launch -one indirect raygen per appended record, because two records for one pixel would race on `outImage` -without another radiance buffer and reduction pass. - -Pass A is fixed at SPP 1 and can emit at most one second leaf. Configured SPP belongs solely to Pass B, -which resamples the stored leaf or leaves. - -Use a base-plus-fixed-secondary queue: - -1. Every pixel owns one fixed base record. -2. Every pixel owns one fixed secondary record at `baseRecords + pixelIndex`. -3. The base record's currently unused `pixelSample` word becomes a `nextRecord` index/sentinel. -4. Pass B loads the base record and then its optional linked record, sums both locally, and writes once. -5. No atomic allocation, queue header, reset, overflow path, or transfer-to-trace barrier is required. - -The allocation is always `pixelCount * 2 * 48 B`: 88.5 MB (84.4 MiB) at 1280x720, independent of -configured SPP. A storage texture would need three `RGBA32UI` texels for the same 48-byte record and -would not reduce traversal work, so the queue remains a linear BDA buffer. - -At the split, Pass A writes the transmission record to the fixed secondary slot and returns the -reflection record as the base. It does not trace or reload either branch. Pass B follows the link and -owns later stochastic interfaces, bounding the queue at two leaves without carrying branch state across -a Pass A radiance trace. - -## 3. Phases - -- **M0 — plumbing.** Generalize `RtPipeline.create` to take `String[] rgen` (same pattern the `rmiss` - array already uses) and select the raygen SBT record at dispatch. Add a second raygen that is a - copy of the current one. Play-test: pixel-identical output, one extra dispatch. -- **M1 — split, no branching.** Move primary/guide work to pass A, bounce loop to pass B, one record - per pixel/sample, deterministic split temporarily disabled (falls back to stochastic). Play-test + profile. - This is the milestone that proves or kills the approach. -- **M2.1 — transmitted-guide filter.** Apply entry-only stained-glass alpha/tint to the terminal - ordinary albedo guide. Exclude water and Beer extinction. Validate in debug view 2. -- **M2.2 — linked secondary + dielectric split.** Give every pixel one directly indexed secondary - record, split the single Pass A sample once at the first visually-primary Fresnel interface, write both - post-interface continuations, and return without tracing either. -- **M2.3 — Pass-B transport/depth.** Keep opaque delta mirrors and every post-split radiance trace in - Pass B, remove carried `diffuseDepth`, and count every hit observed by Pass B. -- **M2.4 — deterministic auxiliary guides.** Use dedicated reflected/refracted guide probes after the - first hit. Never let stochastic radiance choices or reflected content enter ordinary guides. -- **M2.5 — reflected-content guide.** For exact mirrors, modulate reflected terminal diffuse content - by foreground material reflectance, with explicit material-reflectance fallbacks for - sky/emissive/metal/mirror destinations. -- **M3 — validate and measure.** Play-test energy parity against the stochastic M1 reference, debug all - six guides, profile Pass A/Pass B and Pass A live state, and compare combined time against both M1's - **14.2 ms** and `db6418b`'s 21 ms. M2 must not give back the structural M1 win. -- **M4 — later.** ReSTIR spatial reuse becomes a third pass over the G-buffer. This is the reason the - split is worth doing even if M3 is only neutral: spatial reuse is inherently a screen-space - multi-pass algorithm and would otherwise be bolted onto a megakernel. - -## 4. Risks / kill criteria - -- **M1 regression budget.** M1 proved the structure at 14.2 ms. M2 is killed or redesigned if branch - work/live state materially gives that win back; 21 ms is no longer an acceptable success threshold. -- **Measured M2 baseline.** The first linked-spill implementation measured **Pass A 8.6 ms + Pass B - 11.1 ms = 19.7 ms**, versus M1's 14.2 ms. The 4.2 ms Pass A increase identifies duplicated - traversal as the primary cost; changing the 48 B linear buffer to a storage texture cannot remove it. -- **Reproducing the `b4a0e7b` failure.** Pass A must not trace either queued radiance branch. Check its - live-state CSV as well as frame time. -- **Pass A tail latency.** Only deterministic guide probes may extend beyond the camera hit. Profile - deep glass/water guide walks separately. -- **Queue footprint.** The fixed secondary mapping removes atomics and overflow but costs one extra - 48-byte record per pixel even when no split occurs. Watch VRAM at high render resolution and SPP. -- **Opaque energy mismatch.** Never reuse dielectric `(F, 1-F)` weights for an additive opaque - diffuse+specular BSDF. Moving mixed surfaces requires an explicit diffuse-only terminal contract. -- **Rough dielectric gap.** Current water/glass transport is exact-delta in both passes. A real glossy - dielectric is a separate BSDF feature, not something M2 gets merely by comparing roughness. -- **Reflected-content spec guide.** Destination albedo is not the documented material-reflectance - identity. Keep the robust fallbacks and judge RR stability/ringing as well as debug-view appearance. -- **SER is Pass B only.** Pass A is compiled without an invocation-reorder capability and uses ordinary - `TraceRay`. A full no-reorder A/B for the larger indirect shader remains a separate experiment. -- **Two dispatches means a barrier**; trivial next to the trace cost, but it serialises pass A/B, so - any pass A tail latency is exposed. - -## 5. Status - -- [x] Step 2 (single instantiation + guide hoist) — `b4a0e7b`, **GPU-tested: 21 ms -> 47 ms, REGRESSION** - (kept deliberately; the split is its fix, see §0.1) -- [x] M0 plumbing — `d76f450`, multiple raygens per pipeline, no behaviour change -- [x] Pass A no-SER — ordinary `TraceRay`; emitted SPIR-V has no reorder capability/instructions -- [ ] Pass B no-reorder A/B (independent; intentionally deferred) -- [x] M1 split — 48 B packed records, primary/guide + indirect dispatches, stochastic dielectric - fallback. **GPU-tested: primary 4.4 ms + indirect 9.8 ms = 14.2 ms**, versus the 21 ms baseline. -- [x] M2.1 transmitted-guide filter — implemented; shader validation passed, GPU guide-view check pending -- [x] M2.2 linked secondary + one deterministic dielectric split — implemented with one fixed slot - per pixel, no atomics/overflow, Pass A fixed at one sample, and no post-split radiance trace in - Pass A; GPU profile pending -- [x] M2.3 Pass-B transport + local per-hit depth — opaque delta and all post-split transport remain - in Pass B; GPU parity check pending -- [x] M2.4 deterministic auxiliary guides — reflected/refracted guide probes are decoupled from - stochastic radiance and reflected content never enters ordinary guides; GPU parity check pending -- [x] M2.5 reflected-content `gSpecAlbedo` — exact mirrors use reflected diffuse content times - foreground reflection strength, with material-reflectance fallbacks -- [ ] M3 GPU validation and measurement - -Current worktree verification: Gradle test suite and SPIR-V validation pass. The emitted -`PackedPathSegment` array stride is still 48 B and `nextRecord` remains at byte offset 44. - -Initial M2 measurement: **8.6 ms Pass A + 11.1 ms Pass B = 19.7 ms total**. Keep the queue buffer-backed: -mapping one record to a texture needs three `RGBA32UI` texels (the same 48 B payload) and does not -address the dominant extra traversal. Re-profile the leaf-owned guide path and fixed-SPP-1 Pass A. From d4c7c4dd13d880b43c3160a1bfea88ebd7b7f427 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:15:47 +0900 Subject: [PATCH 23/25] improve ser --- shaders/world/trace.slang | 11 +++++++++-- shaders/world/world.rgen.slang | 5 ++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/shaders/world/trace.slang b/shaders/world/trace.slang index e8e89194..32b527c5 100644 --- a/shaders/world/trace.slang +++ b/shaders/world/trace.slang @@ -74,14 +74,21 @@ public void traceRadiance(uint cullMask, float3 ro, float tmin, float3 rd, float // live state in this shader. Rebuilding costs a few constant moves and leaves only these two words // spanning the reorder. public void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - bool showCelestial, float rayConeWidth, float rayConeSpread) { + bool showCelestial, float rayConeWidth, float rayConeSpread, + uint pathPhaseHint) { uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); Payload tracePayload = makeRadiancePayload(flags, rayCone); HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, makeRay(ro, tmin, rd, tmax), tracePayload); - ReorderThread(hObj); + // The hit object groups shader IDs first. Its SBT index then supplies the two-bit geometry/material + // bucket ({solid, cutout, translucent, water}); terrain and entity record ranges are both multiples + // of TERRAIN_BUCKETS, so the low bits have the same meaning. The high two bits describe path lifetime: + // pre-roulette, roulette-active, or guaranteed terminal after this hit. + uint materialHint = hObj.GetShaderTableIndex() & (TERRAIN_BUCKETS - 1u); + uint coherenceHint = (pathPhaseHint << 2u) | materialHint; + ReorderThread(hObj, coherenceHint, 4u); payload = makeRadiancePayload(flags, rayCone); HitObject::Invoke(topLevelAS, hObj, payload); } diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 68299e60..83cc3ec9 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -71,8 +71,11 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. + // SER lifetime phase: keep paths that are not roulette-eligible, paths that may terminate via + // roulette, and paths guaranteed to end at the bounce cap in separate coherence groups. + uint pathPhaseHint = bounce >= maxBounces ? 2u : (bounce >= rrStart ? 1u : 0u); traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); + showCelestial, rayConeWidth, rayConeSpread, pathPhaseHint); if (payload.hitT < 0.0) { // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The From b3b852d7553301e6c2faaaa6cef3488488aea8ac Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:05:58 +0900 Subject: [PATCH 24/25] drop nv ser --- build.gradle | 14 +++-- shaders/world/trace.slang | 50 ++++------------- shaders/world/trace_ser.slang | 37 +++++++++++++ shaders/world/world.rgen.slang | 10 +++- shaders/world/world_guide.rmiss.slang | 5 +- .../caustica/rt/RtDeviceBringup.java | 55 +++---------------- 6 files changed, 77 insertions(+), 94 deletions(-) create mode 100644 shaders/world/trace_ser.slang diff --git a/build.gradle b/build.gradle index bf7685eb..9d436efd 100644 --- a/build.gradle +++ b/build.gradle @@ -160,14 +160,16 @@ abstract class CompileShaders extends DefaultTask { def base = outBase(src) def spv = new File(scratchDir, "${base}.spv") if (src.name.endsWith(".slang")) { - // Pass B uses SER and needs both encodings. Pass A intentionally compiles without an - // invocation-reorder capability and uses ordinary TraceRay. def worldIndirectRaygen = base == "world.rgen" - compileOneSlang(src, spv, worldIndirectRaygen - ? ["-capability", "spvShaderInvocationReorderEXT"] : []) if (worldIndirectRaygen) { - compileOneSlang(src, new File(scratchDir, "world_nv.rgen.spv"), - ["-capability", "spvShaderInvocationReorderNV"]) + // Keep a capability-free TraceRay fallback for devices without EXT SER, and + // publish the reordered variant separately when the extension is available. + compileOneSlang(src, spv, []) + compileOneSlang(src, new File(scratchDir, "world_ser.rgen.spv"), + ["-DCAUSTICA_ENABLE_EXT_SER", "-capability", "spvShaderInvocationReorderEXT"]) + } else { + // Pass A intentionally uses ordinary TraceRay. + compileOneSlang(src, spv, []) } } else { compileOne(src, spv, []) diff --git a/shaders/world/trace.slang b/shaders/world/trace.slang index 32b527c5..8da3f482 100644 --- a/shaders/world/trace.slang +++ b/shaders/world/trace.slang @@ -1,5 +1,5 @@ -// Ray dispatch: SBT/cull constants, payload construction, and the three ways this renderer casts a -// ray — reordered radiance, non-reordered guide probe, and shadow visibility. Depends on core. +// Ray dispatch: SBT/cull constants, payload construction, ordinary/reordered radiance, guide probes, +// and shadow visibility. Depends on core. // receiver billboards only. The first-person camera owner uses 0x01 (secondary only). @@ -61,38 +61,6 @@ public void traceRadiance(uint cullMask, float3 ro, float tmin, float3 rd, float makeRay(ro, tmin, rd, tmax), payload); } -// SER radiance trace: trace into a hit object, reorder threads by hit coherence, THEN invoke the -// hit/miss shader — this is where the divergent Section/Prim shading work (world.rchit/world.rahit) -// actually happens, so reordering before it is what pays for the reorder's own cost. -// -// The payload is built TWICE from the trace state rather than carried across ReorderThread. Traversal -// runs only world.rahit, which never writes a RADIANCE payload: its terrain tint paths live in the -// translucent/water buckets, which carry no any-hit record for radiance rays, and its entity paths are -// gated on RAY_FLAG_SKIP_CLOSEST_HIT_SHADER. So whatever TraceRay leaves in the payload holds no -// information. Reading it back and carrying it over would pin all ten members — 72 bytes — in registers -// across the one point where SER must spill every live value, which profiling showed to be the peak -// live state in this shader. Rebuilding costs a few constant moves and leaves only these two words -// spanning the reorder. -public void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - bool showCelestial, float rayConeWidth, float rayConeSpread, - uint pathPhaseHint) { - uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; - uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); - Payload tracePayload = makeRadiancePayload(flags, rayCone); - HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, - SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, - makeRay(ro, tmin, rd, tmax), tracePayload); - // The hit object groups shader IDs first. Its SBT index then supplies the two-bit geometry/material - // bucket ({solid, cutout, translucent, water}); terrain and entity record ranges are both multiples - // of TERRAIN_BUCKETS, so the low bits have the same meaning. The high two bits describe path lifetime: - // pre-roulette, roulette-active, or guaranteed terminal after this hit. - uint materialHint = hObj.GetShaderTableIndex() & (TERRAIN_BUCKETS - 1u); - uint coherenceHint = (pathPhaseHint << 2u) | materialHint; - ReorderThread(hObj, coherenceHint, 4u); - payload = makeRadiancePayload(flags, rayCone); - HitObject::Invoke(topLevelAS, hObj, payload); -} - // Auxiliary RR probes deliberately use ordinary TraceRay. They are one-off, incoherent rays whose // result is consumed immediately, so paying a reorder barrier and carrying guide state across it only // adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, @@ -111,7 +79,9 @@ public Payload makeShadowPayload() { shadowPayload.normal = half3(0.0h, 0.0h, 0.0h); shadowPayload.motionPrev = half3(0.0h, 0.0h, 0.0h); shadowPayload.f0 = half3(0.0h, 0.0h, 0.0h); - shadowPayload.flags = 0u; + // world_guide.rmiss clears this sentinel. An accepted opaque hit skips closest-hit and therefore + // leaves it set, while ignored translucent/water hits continue until either a later opaque hit or miss. + shadowPayload.flags = 1u; shadowPayload.roughMetal = 0u; shadowPayload.emissionSss = 0u; shadowPayload.iorTransmission = 0u; @@ -125,13 +95,15 @@ public VisibilityResult visibility(float3 origin, float3 dir, float tmax) { Payload shadowPayload = makeShadowPayload(); // Shadow SBT records run any-hit only for cutout/translucent/water. Cutout alpha-tests; translucent // and water tint shadowPayload.albedo and pass through. Solid blocks terminate traversal. There is no - // closest or miss shader worth executing, so use a hit object only as the traversal result. - HitObject hObj = HitObject::TraceRay(topLevelAS, + // closest shader worth executing. The lightweight guide miss clears the flags sentinel so this path + // needs only ordinary TraceRay and no invocation-reorder capability. + TraceRay(topLevelAS, RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER, - CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, 0u, + CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, MISS_GUIDE, makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); VisibilityResult result; - result.transmittance = hObj.IsMiss() ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); + result.transmittance = shadowPayload.flags == 0u + ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); result.waterHitT = shadowPayload.hitT; return result; } diff --git a/shaders/world/trace_ser.slang b/shaders/world/trace_ser.slang new file mode 100644 index 00000000..043d0f64 --- /dev/null +++ b/shaders/world/trace_ser.slang @@ -0,0 +1,37 @@ +// EXT Shader Execution Reordering radiance dispatch. Kept in a separate module so the ordinary +// indirect shader never imports hit-object operations or their SPIR-V capability. + +import world_common; +import world_core; +import trace; + +// Trace into a hit object, reorder threads by hit coherence, THEN invoke the hit/miss shader. This is +// where the divergent Section/Prim shading work (world.rchit/world.rahit) actually happens, so +// reordering before it is what pays for the reorder's own cost. +// +// The payload is built TWICE from the trace state rather than carried across ReorderThread. Traversal +// runs only world.rahit, which never writes a RADIANCE payload: its terrain tint paths live in the +// translucent/water buckets, which carry no any-hit record for radiance rays, and its entity paths are +// gated on RAY_FLAG_SKIP_CLOSEST_HIT_SHADER. So whatever TraceRay leaves in the payload holds no +// information. Reading it back and carrying it over would pin all ten members — 72 bytes — in registers +// across the one point where SER must spill every live value. Rebuilding costs a few constant moves and +// leaves only these two words spanning the reorder. +public void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + bool showCelestial, float rayConeWidth, float rayConeSpread, + uint pathPhaseHint) { + uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; + uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); + Payload tracePayload = makeRadiancePayload(flags, rayCone); + HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, + SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, + makeRay(ro, tmin, rd, tmax), tracePayload); + // The hit object groups shader IDs first. Its SBT index then supplies the two-bit geometry/material + // bucket ({solid, cutout, translucent, water}); terrain and entity record ranges are both multiples + // of TERRAIN_BUCKETS, so the low bits have the same meaning. The high two bits describe path lifetime: + // pre-roulette, roulette-active, or guaranteed terminal after this hit. + uint materialHint = hObj.GetShaderTableIndex() & (TERRAIN_BUCKETS - 1u); + uint coherenceHint = (pathPhaseHint << 2u) | materialHint; + ReorderThread(hObj, coherenceHint, 4u); + payload = makeRadiancePayload(flags, rayCone); + HitObject::Invoke(topLevelAS, hObj, payload); +} diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 83cc3ec9..eb7623e2 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -17,7 +17,7 @@ // job. With RR disabled the output is the raw noisy path trace — a reference view, not a converged // image. Output is HDR (R16G16B16A16_SFLOAT); the HDR -> LDR tonemap happens at the display.comp seam. // -// SER: the build emits EXT and NV shader-invocation-reorder variants from this source. +// The build emits an ordinary TraceRay fallback and an optional EXT SER variant from this source. import world_common; import world_core; import math; @@ -25,6 +25,9 @@ import medium; import segment; import water; import trace; +#ifdef CAUSTICA_ENABLE_EXT_SER +import trace_ser; +#endif import lighting; public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { @@ -71,11 +74,16 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. +#ifdef CAUSTICA_ENABLE_EXT_SER // SER lifetime phase: keep paths that are not roulette-eligible, paths that may terminate via // roulette, and paths guaranteed to end at the bounce cap in separate coherence groups. uint pathPhaseHint = bounce >= maxBounces ? 2u : (bounce >= rrStart ? 1u : 0u); traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, showCelestial, rayConeWidth, rayConeSpread, pathPhaseHint); +#else + traceRadiance(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, + showCelestial, rayConeWidth, rayConeSpread); +#endif if (payload.hitT < 0.0) { // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The diff --git a/shaders/world/world_guide.rmiss.slang b/shaders/world/world_guide.rmiss.slang index 8f36faf5..e23113ca 100644 --- a/shaders/world/world_guide.rmiss.slang +++ b/shaders/world/world_guide.rmiss.slang @@ -1,5 +1,6 @@ -// Minimal miss record for reflection/refraction guide probes. These rays consume only hit-vs-miss -// state; fixed sky guide values are supplied by raygen, so running the atmosphere shader is wasted work. +// Minimal miss record for reflection/refraction guide probes and ordinary TraceRay shadow visibility. +// Guide rays consume only hit-vs-miss state; fixed sky guide values are supplied by raygen. Shadow rays +// seed flags=1 and use this clear as their miss marker, avoiding a hit-object dependency. import world_common; [shader("miss")] diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java index 369f30a4..616135f7 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java @@ -22,7 +22,6 @@ import org.lwjgl.vulkan.VkPhysicalDeviceRayTracingPositionFetchFeaturesKHR; import org.lwjgl.vulkan.VkPhysicalDeviceRayQueryFeaturesKHR; import org.lwjgl.vulkan.VkPhysicalDeviceRayTracingInvocationReorderFeaturesEXT; -import org.lwjgl.vulkan.VkPhysicalDeviceRayTracingInvocationReorderFeaturesNV; import org.lwjgl.vulkan.VkPhysicalDeviceFeatures; import org.lwjgl.vulkan.VkPhysicalDeviceVulkan12Features; import org.lwjgl.vulkan.VkPhysicalDeviceOpacityMicromapFeaturesEXT; @@ -53,8 +52,6 @@ import static org.lwjgl.vulkan.KHRPresentId.VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_ID_FEATURES_KHR; import static org.lwjgl.vulkan.EXTRayTracingInvocationReorder.VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME; import static org.lwjgl.vulkan.EXTRayTracingInvocationReorder.VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_INVOCATION_REORDER_FEATURES_EXT; -import static org.lwjgl.vulkan.NVRayTracingInvocationReorder.VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME; -import static org.lwjgl.vulkan.NVRayTracingInvocationReorder.VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_INVOCATION_REORDER_FEATURES_NV; /** * RT device bring-up. Enables the hardware ray-tracing device extensions and their @@ -94,15 +91,6 @@ public static boolean enabledByProperty() { VK_KHR_RAY_TRACING_POSITION_FETCH_EXTENSION_NAME, VK_KHR_RAY_QUERY_EXTENSION_NAME); - /** - * Shader Execution Reordering is still required by Caustica's current world raygen, but the SPIR-V - * extension differs between the original NVIDIA path and the ratified EXT path. Prefer NV when present - * for older NVIDIA drivers, otherwise use EXT. - */ - public static final List SER_EXTENSIONS = List.of( - VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, - VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME); - /** * OPTIONAL RT extensions: enabled only when the selected device supports them AND the gate is on, but * never required — a device lacking them still comes up RT-capable (unlike {@link #RT_EXTENSIONS}, whose @@ -149,9 +137,6 @@ public static boolean enabledByProperty() { private static final VulkanPNextStruct RAY_QUERY_FEATURES_STRUCT = new VulkanPNextStruct( VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_QUERY_FEATURES_KHR, VkPhysicalDeviceRayQueryFeaturesKHR.SIZEOF); - private static final VulkanPNextStruct SER_NV_FEATURES_STRUCT = new VulkanPNextStruct( - VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_INVOCATION_REORDER_FEATURES_NV, - VkPhysicalDeviceRayTracingInvocationReorderFeaturesNV.SIZEOF); private static final VulkanPNextStruct SER_EXT_FEATURES_STRUCT = new VulkanPNextStruct( VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_INVOCATION_REORDER_FEATURES_EXT, VkPhysicalDeviceRayTracingInvocationReorderFeaturesEXT.SIZEOF); @@ -190,9 +175,6 @@ public static boolean enabledByProperty() { VkPhysicalDeviceRayTracingPositionFetchFeaturesKHR.RAYTRACINGPOSITIONFETCH); private static final VulkanFeature RAY_QUERY_FEATURE = new VulkanFeature( RAY_QUERY_FEATURES_STRUCT, "rayQuery", VkPhysicalDeviceRayQueryFeaturesKHR.RAYQUERY); - private static final VulkanFeature SER_NV_FEATURE = new VulkanFeature( - SER_NV_FEATURES_STRUCT, "rayTracingInvocationReorder(NV)", - VkPhysicalDeviceRayTracingInvocationReorderFeaturesNV.RAYTRACINGINVOCATIONREORDER); private static final VulkanFeature SER_EXT_FEATURE = new VulkanFeature( SER_EXT_FEATURES_STRUCT, "rayTracingInvocationReorder(EXT)", VkPhysicalDeviceRayTracingInvocationReorderFeaturesEXT.RAYTRACINGINVOCATIONREORDER); @@ -217,10 +199,8 @@ public static boolean enabledByProperty() { private enum SerBackend { NONE("none", null, "world_primary.rgen.spv", "world.rgen.spv"), - NV("NV", VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, - "world_primary.rgen.spv", "world_nv.rgen.spv"), EXT("EXT", VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, - "world_primary.rgen.spv", "world.rgen.spv"); + "world_primary.rgen.spv", "world_ser.rgen.spv"); final String label; final String extensionName; @@ -239,7 +219,7 @@ private enum SerBackend { private record FeatureSupport(List missingRequired, SerBackend serBackend, boolean omm, boolean presentId, boolean wideLines) { boolean supportsRt() { - return missingRequired.isEmpty() && serBackend != SerBackend.NONE; + return missingRequired.isEmpty(); } } @@ -259,10 +239,6 @@ public static String worldPrimaryRaygenShader() { return serBackend.worldPrimaryRaygenShader; } - public static boolean serNvEnabled() { - return serBackend == SerBackend.NV; - } - public static boolean serExtEnabled() { return serBackend == SerBackend.EXT; } @@ -465,14 +441,9 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD boolean hasSerExt = physicalDevice.hasDeviceExtension( VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME); - boolean hasSerNv = physicalDevice.hasDeviceExtension( - VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME); if (hasSerExt) { SER_EXT_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); } - if (hasSerNv) { - SER_NV_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); - } boolean queryOmm = ommRequested() && physicalDevice.hasDeviceExtension(VK_EXT_OPACITY_MICROMAP_EXTENSION_NAME); @@ -495,13 +466,8 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD missing.add(feature.name()); } } - // Preserve the existing EXT preference, but fall back to NV when EXT is advertised with a - // false feature boolean. SerBackend supportedSer = hasSerExt && SER_EXT_FEATURE.get(available) ? SerBackend.EXT - : hasSerNv && SER_NV_FEATURE.get(available) ? SerBackend.NV : SerBackend.NONE; - if (supportedSer == SerBackend.NONE) { - missing.add("rayTracingInvocationReorder(NV or EXT)"); - } + : SerBackend.NONE; return new FeatureSupport(missing, supportedSer, queryOmm && OMM_FEATURE.get(available), queryPresentId && PRESENT_ID_FEATURE.get(available), @@ -515,11 +481,6 @@ private static String firstUnsupportedExtension(VulkanPhysicalDevice physicalDev return ext; } } - if (!physicalDevice.hasDeviceExtension(VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME) - && !physicalDevice.hasDeviceExtension(VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME)) { - return VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME + " or " - + VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME; - } return null; } @@ -538,7 +499,7 @@ public static void addExtensions(List augmentedExtensions, VulkanPhysica } } String serExtension = support.serBackend.extensionName; - if (!augmentedExtensions.contains(serExtension)) { + if (serExtension != null && !augmentedExtensions.contains(serExtension)) { augmentedExtensions.add(serExtension); } for (String ext : supportedOptionalExtensions(physicalDevice, support)) { @@ -588,7 +549,9 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { // Bindless entity textures: a runtime-sized sampler2D[] indexed non-uniformly in the hit shader, // with partially-bound + update-after-bind slots (a growing per-RenderType registry). Core on the // VK 1.4 device; just needs enabling alongside bufferDeviceAddress on the same struct. - features.add(support.serBackend == SerBackend.NV ? SER_NV_FEATURE : SER_EXT_FEATURE); + if (support.serBackend == SerBackend.EXT) { + features.add(SER_EXT_FEATURE); + } // Optional: wideLines (core VK10 feature, no extension). Lets the world-overlay pass (block // outline) draw a real thick native line via a raster pipeline's lineWidth / VK_DYNAMIC_STATE_LINE @@ -634,10 +597,10 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { serBackend = support.serBackend; List optionalExtensions = supportedOptionalExtensions(physicalDevice, support); CausticaMod.LOGGER.info( - "Ray tracing: enabling {} + {}{} + features [bufferDeviceAddress, accelerationStructure, rayTracingPipeline, rayQuery, rayTracingInvocationReorder({})" + "Ray tracing: enabling {}{}{} + features [bufferDeviceAddress, accelerationStructure, rayTracingPipeline, rayQuery, SER={}" + (wideLinesEnabled ? ", wideLines(max=" + maxLineWidth + ")" : "") + (ommEnabled ? ", opacityMicromap" : "") + "] + overlayMsaa=" + overlayMsaaSamples + "x on [{}]", - RT_EXTENSIONS, serBackend.extensionName, + RT_EXTENSIONS, serBackend.extensionName == null ? "" : " + " + serBackend.extensionName, optionalExtensions.isEmpty() ? "" : " + " + optionalExtensions, serBackend.label, physicalDevice.deviceName()); } From a8f122fead96e9e61f65ea037cfedb95ae8c6ef0 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:36:00 +0900 Subject: [PATCH 25/25] update water param & fix caustics --- shaders/world/medium.slang | 2 +- shaders/world/world_guide.rmiss.slang | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/shaders/world/medium.slang b/shaders/world/medium.slang index dd284c81..6f01f521 100644 --- a/shaders/world/medium.slang +++ b/shaders/world/medium.slang @@ -9,7 +9,7 @@ import world_common; import world_core; -public static const float WATER_DENSITY = 0.05; +public static const float WATER_DENSITY = 0.1; public static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); public float3 waterExtinction(float3 tint) { return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); diff --git a/shaders/world/world_guide.rmiss.slang b/shaders/world/world_guide.rmiss.slang index e23113ca..39f71c62 100644 --- a/shaders/world/world_guide.rmiss.slang +++ b/shaders/world/world_guide.rmiss.slang @@ -5,7 +5,8 @@ import world_common; [shader("miss")] void main(inout Payload payload) { - payload.hitT = -1.0; + // Preserve hitT: guide payloads already seed the -1 miss sentinel, while shadow any-hit may have + // recorded the nearest water crossing here for underwater caustics. payload.normal = half3(0.0h, 0.0h, 0.0h); payload.flags = 0u; }