From 43eae9e3ec9fe4d8b757f3263b46dd3eb84a416e Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:13:51 +0900 Subject: [PATCH 1/9] Keep the radiance payload out of the SER reorder save set traceRadianceReordered read all ten payload members back out of the payload variable after HitObject::TraceRay, held them across ReorderThread, and reconstructed them for HitObject::Invoke. Traversal only runs 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. The values carried across the reorder were therefore always dead. Pass the trace state (show-celestial, ray cone) as explicit parameters and rebuild the payload from it on each side of the reorder instead. Only the two packed trace-state words now span ReorderThread. Nsight attributed the peak live state in this shader to the reorder point, which is what prompted the change; the measured win is instead from instruction count, ~60 redundant load/store ops per radiance trace across three trace sites. Payload storage bytes are unchanged, since those are a per-call-site reservation rather than SSA liveness. SPIR-V: payload access chains before each reorder 30 -> 0, total RayPayloadKHR access chains 67 -> 34, zero non-debug instructions between TraceRay and ReorderThread. Payload struct and cross-stage ABI are untouched. Measured ~0.8ms/frame. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 56 +++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 274c0a2d..fd6fd06b 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -78,10 +78,6 @@ 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; } -void payloadSetTraceState(bool showCelestial, float rayConeWidth, float rayConeSpread) { - payload.flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; - payload.rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); -} static const float PI = 3.14159265359; static const float INV_PI = 0.31830988618; @@ -703,13 +699,46 @@ RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { return r; } +// 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 = float3(0.0, 0.0, 0.0); + p.normal = float3(0.0, 0.0, 0.0); + p.hitT = -1.0; // miss sentinel: world.rmiss writes only albedo, so a miss leaves this negative + p.motionPrev = float3(0.0, 0.0, 0.0); + p.f0 = float3(0.0, 0.0, 0.0); + 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. -void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax) { +// +// 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, 0u, makeRay(ro, tmin, rd, tmax), payload); + SBT_RADIANCE, SBT_STRIDE_BUCKET, 0u, makeRay(ro, tmin, rd, tmax), tracePayload); ReorderThread(hObj); + payload = makeRadiancePayload(flags, rayCone); HitObject::Invoke(topLevelAS, hObj, payload); } @@ -783,11 +812,10 @@ float2 specularReflectionMotion(float3 surfacePos, float3 primaryDir, float2 cur // 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. - payload.hitT = -1.0; - payloadSetTraceState(false, + traceRadianceReordered(CULL_SECONDARY, surfacePos + n * SURF_BIAS, RAY_TMIN, specDir, 10000.0, + false, max(length(surfacePos - worldPush.camOffset) * primaryConeSpread, RAY_CONE_MIN_WIDTH), max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); - traceRadianceReordered(CULL_SECONDARY, surfacePos + n * SURF_BIAS, RAY_TMIN, specDir, 10000.0); float3 reflectedHit; float3 reflectedMotionPrev; @@ -831,9 +859,8 @@ void refractedGuideHit(float3 surfacePos, float3 incidentDir, float3 surfaceNorm refracted = true; float3 ro = surfacePos - surfaceNormal * SURF_BIAS; - payload.hitT = -1.0; - payloadSetTraceState(false, rayConeWidth, rayConeSpread); - traceRadianceReordered(CULL_SECONDARY, ro, RAY_TMIN, refractedDir, 10000.0); + 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); @@ -889,13 +916,12 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. bool showCelestial = true; for (int bounce = 0; bounce <= maxBounces; bounce++) { - payload.hitT = -1.0; - payloadSetTraceState(showCelestial, rayConeWidth, rayConeSpread); // 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); + 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 From c90e90f105a63388abbe52ecb32e175d593422df Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:35:00 +0900 Subject: [PATCH 2/9] Pack the radiance payload's vector lanes to half3 albedo, normal, motionPrev and f0 become half3, taking Payload from 72 to 48 bytes. Payload storage is reserved per trace call site, so every byte is paid twice per radiance trace and preserved across the SER reorder. hitT stays f32: it reaches 10000 blocks and the hit position is reconstructed from it, where half's ~4-block spacing at that magnitude would be visible. No device-feature change. The baseline already declared OpCapability Float16/Int16 via packHalf2, and VulkanBackendMixin's SDK_SHADER_FEATURES already enables shaderFloat16 at device creation. world.rmiss clamps sky radiance to HALF_MAX so an out-of-range value cannot reach raygen as +inf and propagate as NaN. This is a guard, not a correction: SUN_DISC_RADIANCE is 24.0, three orders under the ceiling. Slang rejects implicit float3 -> half3 under -warnings-as-errors, so producers cast explicitly; reads widen implicitly and are unchanged. Measured 0.3ms/frame. Nsight peak live state at the reorder 790 -> 614 bytes, and the HitObject-attributed values 110/110/78 -> 62/62/62, so hit-object live state includes payload state it references. Visuals verified by hand. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rahit.slang | 10 +++---- shaders/world/world.rchit.slang | 46 ++++++++++++++++---------------- shaders/world/world.rgen.slang | 16 +++++------ shaders/world/world.rmiss.slang | 11 +++++--- shaders/world/world_common.slang | 18 ++++++++++--- 5 files changed, 57 insertions(+), 44 deletions(-) diff --git a/shaders/world/world.rahit.slang b/shaders/world/world.rahit.slang index 731ee7b6..459b7f9e 100644 --- a/shaders/world/world.rahit.slang +++ b/shaders/world/world.rahit.slang @@ -89,13 +89,13 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_GLASS) { float3 tint = lerp(float3(1.0, 1.0, 1.0), srgbToLinear(texel.rgb) * epr.tint.rgb, texel.a); - payload.albedo *= tint * clamp(materialHeader.params.w, 0.0, 1.0); + payload.albedo *= half3(tint * clamp(materialHeader.params.w, 0.0, 1.0)); IgnoreHit(); } if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_WATER) { float3 tint = srgbToLinear(texel.rgb) * epr.tint.rgb; - payload.albedo *= lerp(float3(1.0, 1.0, 1.0), tint, WATER_SHADOW_TINT) - * clamp(materialHeader.params.w, 0.0, 1.0); + payload.albedo *= half3(lerp(float3(1.0, 1.0, 1.0), tint, WATER_SHADOW_TINT) + * clamp(materialHeader.params.w, 0.0, 1.0)); payload.hitT = payload.hitT < 0.0 ? RayTCurrent() : min(payload.hitT, RayTCurrent()); IgnoreHit(); } @@ -110,7 +110,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // the biome water color, then keep walking so submerged terrain is lit by colored transmission. if (bucket == BUCKET_WATER) { TerrainPrim pr = ConstPtr(sec.primAddr)[tri]; - payload.albedo *= lerp(float3(1.0, 1.0, 1.0), pr.tint.rgb, WATER_SHADOW_TINT); + payload.albedo *= half3(lerp(float3(1.0, 1.0, 1.0), pr.tint.rgb, WATER_SHADOW_TINT)); // Record the NEAREST water crossing (any-hit order is arbitrary) in the shadow payload's hitT // lane. For an underwater shading point this is the exit point of its sun shadow ray, where // world.rgen evaluates the wave-refraction caustic. visibility() seeds the -1 sentinel. @@ -136,7 +136,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // The neutral floor is a flat per-hit dimming, NOT scaled by alpha: vanilla clear glass has a low // natural alpha, so folding it into the alpha-scaled term crushed it to near-zero for exactly the // white/clear-glass case it's meant to cover. - payload.albedo *= exp(-colorExtinction * materialHeader.average.a - TRANSLUCENT_NEUTRAL_EXTINCTION); + payload.albedo *= half3(exp(-colorExtinction * materialHeader.average.a - TRANSLUCENT_NEUTRAL_EXTINCTION)); IgnoreHit(); } diff --git a/shaders/world/world.rchit.slang b/shaders/world/world.rchit.slang index 9ea3485b..264ffc2b 100644 --- a/shaders/world/world.rchit.slang +++ b/shaders/world/world.rchit.slang @@ -234,19 +234,19 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) if (dot(pn, WorldRayDirection()) > 0.0) { pn = -pn; } - payload.albedo = srgbToLinear(entityAlbedoTex[NonUniformResourceIndex(pslot)].SampleLevel(puv, particleLod).rgb) * pr.tint.rgb; - payload.normal = pn; + payload.albedo = half3(srgbToLinear(entityAlbedoTex[NonUniformResourceIndex(pslot)].SampleLevel(puv, particleLod).rgb) * pr.tint.rgb); + payload.normal = half3(pn); payload.hitT = RayTCurrent(); // Per-particle motion vector: interpolate the captured per-vertex displacement (uniform across // the billboard's verts) with the same indices/barycentrics as the UV. dispAddr == 0 falls back // to rigidDisp, which particles write as zero. if (g.dispAddr != 0) { ConstPtr pd = ConstPtr(g.dispAddr); - payload.motionPrev = pbary.x * pd[p0].xyz + pbary.y * pd[p1].xyz + pbary.z * pd[p2].xyz; + payload.motionPrev = half3(pbary.x * pd[p0].xyz + pbary.y * pd[p1].xyz + pbary.z * pd[p2].xyz); } else { - payload.motionPrev = g.rigidDisp.xyz; + payload.motionPrev = half3(g.rigidDisp.xyz); } - payload.f0 = float3(0.0, 0.0, 0.0); + payload.f0 = half3(0.0h, 0.0h, 0.0h); payloadSetPacked(payload, MATERIAL_PARTICLE, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, EMISSION_SOURCE_NONE); return; @@ -297,19 +297,19 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) header.params.x, header.params.y, pr.normal.w, material == MATERIAL_OPAQUE); n = surface.normal; - payload.albedo = albedo * surface.ao; - payload.normal = n; + payload.albedo = half3(albedo * surface.ao); + payload.normal = half3(n); payload.hitT = RayTCurrent(); // Per-vertex motion vector: interpolate the captured per-vertex displacement with the same // indices/barycentrics used for the UV above. Rotation and skeletal/lid animation use a // per-vertex buffer; pure whole-object translation is packed into rigidDisp with no buffer. if (g.dispAddr != 0) { ConstPtr dd = ConstPtr(g.dispAddr); - payload.motionPrev = ebary.x * dd[e0].xyz + ebary.y * dd[e1].xyz + ebary.z * dd[e2].xyz; + payload.motionPrev = half3(ebary.x * dd[e0].xyz + ebary.y * dd[e1].xyz + ebary.z * dd[e2].xyz); } else { - payload.motionPrev = g.rigidDisp.xyz; + payload.motionPrev = half3(g.rigidDisp.xyz); } - payload.f0 = surface.f0; + payload.f0 = half3(surface.f0); float emission = material == MATERIAL_OPAQUE ? surface.emission : 0.0; float sss = material == MATERIAL_OPAQUE ? surface.sss : 0.0; payloadSetPacked(payload, material, surface.roughness, surface.metalness, @@ -376,12 +376,12 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) n = glassSurface.normal; // Translucent blocks (glass, ice, …) are breakable too — apply the same overlay here, reusing // gtex (already sampled above) rather than re-fetching blockAlbedoAtlas. - payload.albedo = applyBreaking(glassAlbedo * glassSurface.ao, - rayCone, WorldRayOrigin(), WorldRayDirection(), RayTCurrent(), n); - payload.normal = n; + payload.albedo = half3(applyBreaking(glassAlbedo * glassSurface.ao, + rayCone, WorldRayOrigin(), WorldRayDirection(), RayTCurrent(), n)); + payload.normal = half3(n); payload.hitT = RayTCurrent(); - payload.motionPrev = float3(0.0, 0.0, 0.0); - payload.f0 = glassSurface.f0; + payload.motionPrev = half3(0.0h, 0.0h, 0.0h); + payload.f0 = half3(glassSurface.f0); payloadSetPacked(payload, MATERIAL_GLASS, glassSurface.roughness, glassSurface.metalness, 0.0, 0.0, materialHeader.params.z, materialHeader.params.w, EMISSION_SOURCE_NONE); @@ -391,10 +391,10 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // Water (tint.w == 1) carries the pure biome water tint (no grey water-texture multiply): raygen // shades the surface as a clear dielectric and only needs the tint to derive the per-channel // Beer–Lambert absorption. Opaque terrain uses textured albedo. - payload.albedo = materialHeader.model == MATERIAL_WATER - ? tint : srgbToLinear(blockAlbedoAtlas.SampleLevel(uv, blockLod).rgb) * tint; + payload.albedo = half3(materialHeader.model == MATERIAL_WATER + ? tint : srgbToLinear(blockAlbedoAtlas.SampleLevel(uv, blockLod).rgb) * tint); payload.hitT = RayTCurrent(); - payload.motionPrev = float3(0.0, 0.0, 0.0); // static terrain: camera-only motion vector + payload.motionPrev = half3(0.0h, 0.0h, 0.0h); // static terrain: camera-only motion vector uint material = materialHeader.model == MATERIAL_WATER ? MATERIAL_WATER : MATERIAL_OPAQUE; // normal.w packs the 0..1 block-light level plus a +2 offset flag for non-SOLID (cutout / @@ -402,18 +402,18 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) float ew = pr.normal.w; bool nonSolid = ew >= 1.5; float emission = nonSolid ? ew - 2.0 : ew; // heuristic emission source (block light level) - Surface surface = evaluateMaterial(materialHeader, uv, blockLod, payload.albedo, n, + Surface surface = evaluateMaterial(materialHeader, uv, blockLod, float3(payload.albedo), n, tp0, tp1, tp2, uv0, uv1, uv2, vdir, materialHeader.params.x, materialHeader.params.y, emission, nonSolid); n = surface.normal; - payload.albedo *= surface.ao; + payload.albedo *= half(surface.ao); // Block-breaking overlay: opaque/cutout terrain (water skips — fluids aren't breakable). Evaluate // it after normal mapping so decal projection uses the same shading orientation as the old path. if (material != MATERIAL_WATER) { - payload.albedo = applyBreaking(payload.albedo, rayCone, WorldRayOrigin(), WorldRayDirection(), RayTCurrent(), n); + payload.albedo = half3(applyBreaking(float3(payload.albedo), rayCone, WorldRayOrigin(), WorldRayDirection(), RayTCurrent(), n)); } - payload.normal = n; - payload.f0 = surface.f0; + payload.normal = half3(n); + payload.f0 = half3(surface.f0); payloadSetPacked(payload, material, surface.roughness, surface.metalness, surface.emission, surface.sss, materialHeader.params.z, materialHeader.params.w, materialEmissionSource(materialHeader, surface.emission)); diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index fd6fd06b..ad127fb4 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -705,11 +705,11 @@ RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { // information into a trace and is initialized to a neutral value here. Payload makeRadiancePayload(uint flags, uint rayCone) { Payload p; - p.albedo = float3(0.0, 0.0, 0.0); - p.normal = float3(0.0, 0.0, 0.0); + 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 = float3(0.0, 0.0, 0.0); - p.f0 = float3(0.0, 0.0, 0.0); + 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; @@ -744,11 +744,11 @@ void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, flo Payload makeShadowPayload() { Payload shadowPayload; - shadowPayload.albedo = float3(1.0, 1.0, 1.0); + shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit - shadowPayload.normal = float3(0.0, 0.0, 0.0); - shadowPayload.motionPrev = float3(0.0, 0.0, 0.0); - shadowPayload.f0 = float3(0.0, 0.0, 0.0); + 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; diff --git a/shaders/world/world.rmiss.slang b/shaders/world/world.rmiss.slang index 761b8173..f7d648e3 100644 --- a/shaders/world/world.rmiss.slang +++ b/shaders/world/world.rmiss.slang @@ -287,11 +287,14 @@ void main(inout Payload payload) { } } - payload.albedo = max(col, float3(0.0, 0.0, 0.0)); // raygen reads this as sky radiance + bounce-0 guide + // albedo is a half3 lane, so the sky's HDR radiance is clamped to half's finite range rather than + // allowed to round up to +inf and propagate as NaN through the path throughput. This is a guard, not + // a correction: the brightest term here is SUN_DISC_RADIANCE at 24.0, far under the 65504 ceiling. + payload.albedo = half3(clamp(col, float3(0.0, 0.0, 0.0), float3(HALF_MAX, HALF_MAX, HALF_MAX))); payload.hitT = -1.0; - payload.normal = float3(0.0, 0.0, 0.0); - payload.motionPrev = float3(0.0, 0.0, 0.0); - payload.f0 = float3(0.0, 0.0, 0.0); + payload.normal = half3(0.0h, 0.0h, 0.0h); + payload.motionPrev = half3(0.0h, 0.0h, 0.0h); + payload.f0 = half3(0.0h, 0.0h, 0.0h); payload.flags = 0u; payload.roughMetal = packHalf2(float2(1.0, 0.0)); payload.emissionSss = packHalf2(float2(0.0, 0.0)); diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index 6d42d840..43e0656f 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -97,13 +97,23 @@ public struct LightGridSpan { // TerrainPrim.flags bit 0: this emissive quad is in the light buffer (RtLightCollector membership). public static const uint TERRAIN_PRIM_IN_LIGHT_BUFFER = 1u; +// Largest finite half. The payload's half3 lanes carry HDR values (sky radiance most of all), so +// producers clamp to this instead of letting the conversion round up to +inf. +public static const float HALF_MAX = 65504.0; + // Radiance-ray payload (location 0). Member order and types are a cross-stage ABI. +// +// The three-component lanes are half3: they cost 6 bytes instead of 12, and payload storage is reserved +// per trace call site, so every byte here is paid twice per radiance trace (once for TraceRay, once for +// Invoke) and is preserved across the SER reorder. hitT stays f32 — it reaches 10000 blocks and the hit +// position is reconstructed from it, where half's ~4-block spacing at that magnitude would be visible. public struct Payload { - public float3 albedo; // hit: block albedo. miss: sky radiance. - public float3 normal; // hit: geometric normal, viewer-oriented. + // Sky radiance on a miss, so this carries HDR values well above 1; half tops out at 65504. + public half3 albedo; // hit: block albedo. miss: sky radiance. + public half3 normal; // hit: geometric normal, viewer-oriented. public float hitT; // >= 0 on hit, < 0 on miss. - public float3 motionPrev; // per-vertex world displacement since last frame. - public float3 f0; // specular F0. + public half3 motionPrev; // per-vertex world displacement since last frame. + public half3 f0; // specular F0. public uint flags; // bits 0..1 material, bit 2 celestial, bit 3 water-entering, bits 4..6 emission source. public uint roughMetal; // packHalf2x16(roughness, metalness) public uint emissionSss; // packHalf2x16(emission, sss) From ee76ebed07e6fbcd34e89b72dc0679d7713f23c6 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:57:57 +0900 Subject: [PATCH 3/9] Compact the GPU light record from 48 to 32 bytes RIS fetches these at random indices, M times per shading vertex, and at 48 bytes about half of them straddled a 64-byte cache line and cost two transactions. 32 divides the line, so a record now never straddles one: ~1.5 transactions per candidate becomes 1.0. Layout is {pos.xyz, packedLe} {halfU.xy, halfU.z|halfV.x, halfV.yz, section}, half axes packed two per lane. The centre stays f32 because the RIS target divides by squared distance to it; the axes are block-scale offsets well inside half's range. The area lane is dropped, not approximated. It is exactly 4*|halfU x halfV|, since the collector builds halfU = 0.5*(aHi-aLo)*e01 and rectArea = |e01 x e03|*(aHi-aLo)*(bHi-bLo). world.rgen derives it from the cross product lightGeometricNormal already computes, so it costs one length() the compiler shares with the normalize. p-hat is unchanged. Measured 0.5ms/frame, against ~1ms predicted from the transaction count alone. The shortfall is informative: per-candidate cost is not only bytes but the three dependent round trips (span, alias, record), which this does not reduce. RtLightHierarchyTest hardcoded the old stride and section lane; it now derives both from GPU_FLOATS_PER_LIGHT so a future layout change fails at the constant rather than drifting silently. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 38 ++++++++++---- shaders/world/world_common.slang | 21 ++++++-- .../rt/terrain/RtLightGridManager.java | 17 ++++++- .../caustica/rt/terrain/RtLightHierarchy.java | 51 ++++++++++++------- .../rt/terrain/RtLightHierarchyTest.java | 11 ++-- 5 files changed, 101 insertions(+), 37 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index ad127fb4..497c4254 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -527,26 +527,45 @@ float unpackUnsignedFloat(uint bits, uint mantissaBits) { } float3 lightRadiance(Light light) { - uint packed = asuint(light.halfULe.w); + 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 = asuint(light.halfVSection.w); + 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(cross(light.halfULe.xyz, light.halfVSection.xyz)); - return (asuint(light.halfVSection.w) & 0x40000000u) != 0u ? -normal : normal; + 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, light.posArea.w * luminance(le)); + float power = max(0.0, lightArea(light) * luminance(le)); float globalPdf = worldPush.lightAliasAddr != 0 ? power * worldPush.lightRebase.w : 1.0 / float(worldPush.lightCount); float localPdf = 0.0; @@ -623,12 +642,13 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl // 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.posArea.xyz + worldPush.lightRebase.xyz - + s * lg.halfULe.xyz + t * lg.halfVSection.xyz; + 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, lg.posArea.w, hitPos, n, v, rd, + evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd, diffAlb, F0, rough, pbr, twoSided, sss, phat); if (phat <= 0.0) { continue; @@ -640,7 +660,7 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl r.pos = sp; r.lnrm = lightNormal; r.le = le; - r.area = lg.posArea.w; + r.area = area; r.phat = phat; } } diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index 43e0656f..5260581d 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -64,12 +64,25 @@ public struct WorldPush { public uint risCandidates; // RIS candidate count M per diffuse vertex (0 = emitter NEE off) }; -// 48-byte hot area-light record. Radiance uses packed R11G11B10 and the grid-relative owner section +// 32-byte hot area-light record. Radiance uses packed R11G11B10 and the grid-relative owner section // uses three unsigned 10-bit coordinates; the geometric normal is reconstructed from the half axes. +// +// 32 divides the 64-byte cache line, so a record never straddles one. At the previous 48 bytes about +// half of them did, costing two transactions apiece — and RIS fetches these at random indices, M per +// shading vertex, so that is the layout's whole purpose. float3 forces 16-byte struct alignment and 32 +// is a multiple of it, so this lands with no tail padding. +// +// The rectangle area is derived rather than stored: the rect spans 2U x 2V, so area = 4*|U x V|, which +// is exactly what the collector used to write (halfU = 0.5*(aHi-aLo)*e01 and rectArea = +// |e01 x e03|*(aHi-aLo)*(bHi-bLo)). lightGeometricNormal already needs that cross product, so the +// freed lane costs one length() the compiler shares with the normalize. public struct Light { - public float4 posArea; - public float4 halfULe; // xyz world half-axis U, w bitcast R11G11B10 radiance - public float4 halfVSection; // xyz half-axis V, w bitcast 10:10:10 section + normal-flip bit + public float3 pos; // rebased world centre, f32 (the RIS target divides by distance to it) + public uint le; // bitcast R11G11B10 radiance + public uint halfUxy; // half2(U.x, U.y) + public uint halfUzVx; // half2(U.z, V.x) + public uint halfVyz; // half2(V.y, V.z) + public uint section; // 10:10:10 grid-relative section coord + bit 30 normal-flip }; // Eight-byte Vose alias-table column. PDFs are reconstructed from the selected Light. diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java index c807a2d9..b89fce85 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java @@ -296,8 +296,21 @@ int record = light * RtLightHierarchy.GPU_FLOATS_PER_LIGHT; float z = lights[record + 2]; double dx = x - px, dy = y - py, dz = z - pz; if (dx * dx + dy * dy + dz * dz > radiusSq) continue; - float area = lights[record + 3]; - int packedLe = Float.floatToRawIntBits(lights[record + 7]); + // Area is derived, not stored — 4*|halfU x halfV| (see RtLightHierarchy.GPU_FLOATS_PER_LIGHT). + int packedU = Float.floatToRawIntBits(lights[record + 4]); + int packedUzVx = Float.floatToRawIntBits(lights[record + 5]); + int packedV = Float.floatToRawIntBits(lights[record + 6]); + float hux = Float.float16ToFloat((short) packedU); + float huy = Float.float16ToFloat((short) (packedU >>> 16)); + float huz = Float.float16ToFloat((short) packedUzVx); + float hvx = Float.float16ToFloat((short) (packedUzVx >>> 16)); + float hvy = Float.float16ToFloat((short) packedV); + float hvz = Float.float16ToFloat((short) (packedV >>> 16)); + float crossX = huy * hvz - huz * hvy; + float crossY = huz * hvx - hux * hvz; + float crossZ = hux * hvy - huy * hvx; + float area = 4f * (float) Math.sqrt(crossX * crossX + crossY * crossY + crossZ * crossZ); + int packedLe = Float.floatToRawIntBits(lights[record + 3]); float leR = RtLightHierarchy.unpackUnsignedFloat(packedLe & 0x7ff, 6); float leG = RtLightHierarchy.unpackUnsignedFloat((packedLe >>> 11) & 0x7ff, 6); float leB = RtLightHierarchy.unpackUnsignedFloat((packedLe >>> 22) & 0x3ff, 5); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchy.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchy.java index d9d51044..c01bfb62 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchy.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchy.java @@ -13,13 +13,28 @@ */ final class RtLightHierarchy { static final int SOURCE_FLOATS_PER_LIGHT = RtLightCollector.FLOATS_PER_LIGHT; - static final int GPU_FLOATS_PER_LIGHT = 12; + /** + * 8 floats / 32 B per GPU record: {@code {pos.xyz, packedLe} {halfU.xy, halfU.z|halfV.x, halfV.yz, + * section}}, half axes packed two per lane. 32 divides the 64 B cache line, so a record never + * straddles one — at the previous 48 B roughly half of them did, costing two transactions each. RIS + * fetches these at random indices, so that halving of transactions is the point of the layout. + *

The rectangle area is NOT stored: it is exactly {@code 4*|halfU x halfV|}, since the collector + * builds {@code halfU = 0.5*(aHi-aLo)*e01} and {@code rectArea = |e01 x e03|*(aHi-aLo)*(bHi-bLo)}. + * world.rgen derives it from the cross product it already computes for the emitter normal. + */ + static final int GPU_FLOATS_PER_LIGHT = 8; private static final int MAX_PACKED_GRID_DIM = 1024; private static final int NORMAL_FLIP_BIT = 1 << 30; private RtLightHierarchy() { } + /** Two halves into one float lane, low half = x — mirrors world_common.slang's unpackHalf2. */ + private static float packHalf2(float x, float y) { + int bits = (Float.floatToFloat16(y) << 16) | (Float.floatToFloat16(x) & 0xFFFF); + return Float.intBitsToFloat(bits); + } + static Data build(List sections, int rebaseX, int rebaseY, int rebaseZ, BooleanSupplier cancelled) { List orderedSections = orderedSections(sections, cancelled); @@ -64,26 +79,28 @@ static Data build(List sections, int rebaseX, int rebaseY, int reb float leG = section.lights[source + 17]; float leB = section.lights[source + 18]; int packedLe = packR11G11B10(leR, leG, leB); + float halfUx = section.lights[source + 8]; + float halfUy = section.lights[source + 9]; + float halfUz = section.lights[source + 10]; + float halfVx = section.lights[source + 12]; + float halfVy = section.lights[source + 13]; + float halfVz = section.lights[source + 14]; packedLights[destination] = section.lights[source] + ox; packedLights[destination + 1] = section.lights[source + 1] + oy; packedLights[destination + 2] = section.lights[source + 2] + oz; - packedLights[destination + 3] = section.lights[source + 3]; - packedLights[destination + 4] = section.lights[source + 8]; - packedLights[destination + 5] = section.lights[source + 9]; - packedLights[destination + 6] = section.lights[source + 10]; - packedLights[destination + 7] = Float.intBitsToFloat(packedLe); - packedLights[destination + 8] = section.lights[source + 12]; - packedLights[destination + 9] = section.lights[source + 13]; - packedLights[destination + 10] = section.lights[source + 14]; - float crossX = section.lights[source + 9] * section.lights[source + 14] - - section.lights[source + 10] * section.lights[source + 13]; - float crossY = section.lights[source + 10] * section.lights[source + 12] - - section.lights[source + 8] * section.lights[source + 14]; - float crossZ = section.lights[source + 8] * section.lights[source + 13] - - section.lights[source + 9] * section.lights[source + 12]; + packedLights[destination + 3] = Float.intBitsToFloat(packedLe); + // Half axes at half precision: these are block-scale offsets from the rectangle centre, + // well inside half's range and resolution. The centre itself stays f32 because the RIS + // target divides by squared distance to it. + packedLights[destination + 4] = packHalf2(halfUx, halfUy); + packedLights[destination + 5] = packHalf2(halfUz, halfVx); + packedLights[destination + 6] = packHalf2(halfVy, halfVz); + float crossX = halfUy * halfVz - halfUz * halfVy; + float crossY = halfUz * halfVx - halfUx * halfVz; + float crossZ = halfUx * halfVy - halfUy * halfVx; if (crossX * section.lights[source + 4] + crossY * section.lights[source + 5] + crossZ * section.lights[source + 6] < 0.0f) { - packedLights[destination + 11] = Float.intBitsToFloat(NORMAL_FLIP_BIT); + packedLights[destination + 7] = Float.intBitsToFloat(NORMAL_FLIP_BIT); } double luminance = 0.2126 * unpackUnsignedFloat(packedLe & 0x7ff, 6) + 0.7152 * unpackUnsignedFloat((packedLe >>> 11) & 0x7ff, 6) @@ -136,7 +153,7 @@ static Data build(List sections, int rebaseX, int rebaseY, int reb || z >= MAX_PACKED_GRID_DIM) { throw new IllegalStateException("Light section is outside packed light grid"); } - int destination = i * GPU_FLOATS_PER_LIGHT + 11; + int destination = i * GPU_FLOATS_PER_LIGHT + 7; int flags = Float.floatToRawIntBits(packedLights[destination]) & NORMAL_FLIP_BIT; packedLights[destination] = Float.intBitsToFloat(flags | x | (y << 10) | (z << 20)); } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchyTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchyTest.java index 98a8bd5b..43ed5d0b 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchyTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/terrain/RtLightHierarchyTest.java @@ -29,13 +29,14 @@ void buildsStableSectionRangesAndPowerWeightedLocalAliases() { assertEquals(0.25, aliasProbability(data.localAliases(), 0, 2, 0), 1.0e-6); assertEquals(0.75, aliasProbability(data.localAliases(), 0, 2, 1), 1.0e-6); // Position is compacted into rebased world coordinates by the worker. + int stride = RtLightHierarchy.GPU_FLOATS_PER_LIGHT; assertEquals(0f, data.packedLights()[0], 0f); - assertEquals(48f, data.packedLights()[2 * 12], 0f); - // Grid-relative section coordinates live in the final Light48 lane. - assertPackedCoord(data.packedLights()[11], 2, 2, 2); - assertPackedCoord(data.packedLights()[2 * 12 + 11], 5, 4, 3); + assertEquals(48f, data.packedLights()[2 * stride], 0f); + // Grid-relative section coordinates live in the record's final lane. + assertPackedCoord(data.packedLights()[stride - 1], 2, 2, 2); + assertPackedCoord(data.packedLights()[2 * stride + stride - 1], 5, 4, 3); assertEquals(1f / 6f, data.invGlobalPowerSum(), 1.0e-6f); - assertEquals(3L * 48L, data.lightBytes()); + assertEquals(3L * stride * Float.BYTES, data.lightBytes()); assertEquals(3L * 8L, data.globalAliases().bytes()); } From d9f6d058c2760388776c2f6ab204252b29c38ec0 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:59:34 +0900 Subject: [PATCH 4/9] Quarter the RIS candidate count at secondary vertices Probes added here bound what memory-side RIS work can be worth. At M=8 against an 18.0ms frame: pinning every candidate to one light costs 5.9ms, of which ~4.3ms sits at secondary vertices and ~1.1ms at the primary hit. Forcing coherent selection recovers at most 1.3ms, so the cost is chasing depth rather than lane divergence. Secondary vertices are therefore where the money is, and they are also the forgiving place to spend variance: that radiance is integrated over a diffuse lobe before the denoiser sees it. The primary hit keeps worldPush.risCandidates; bounces past it run M/4. Measured 18.0 -> 16.1ms, and indistinguishable from divisor 1 by eye. This is why a global M reduction was the wrong test: M=2 everywhere was clearly worse, because it also degraded the primary hit. M=2 is the natural floor rather than a tuning accident. The proposal stratification keeps at least one global candidate, so M=2 is the smallest count that still schedules one local and one global; M=1 degenerates to global-only and loses the light-grid proposal that makes nearby emitters sample well. The candidate loop tested worldPush.risCandidates directly while the stratification used candidateCount. Identical before, but with a divisor applied it would have run 8 iterations against a schedule built for 2 and corrupted the mixture pdf. It now uses candidateCount throughout. Also adds RIS_MAX_BOUNCE, defaulted off, to gate RIS entirely past a depth: unlike the divisor that also drops shadeReservoir's per-vertex shadow ray, which does not scale with M. gateEmitter reads the same gate so a skipped bounce gathers emitters on direct hits instead of losing their energy. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 75 ++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 497c4254..8a3afef3 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -606,10 +606,46 @@ void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposal } } +// Retained diagnostic — bounds what memory-side RIS work can be worth before any of it gets built. Every +// non-zero setting produces a WRONG image on purpose: they exist to be timed, not looked at. Leave at 0. +// Measured at M=8 (18.0ms baseline): 1 -> 12.1ms, 2 -> 16.7ms, 3 -> 16.9ms, 4 -> 13.7ms. So RIS's memory +// side was 5.9ms of an 18ms frame, of which ~4.3ms sat at secondary vertices and ~1.1ms at the primary +// hit, and coherence accounted for at most 1.3ms of it — depth, not divergence, was the cost. +// 0 = off, normal behaviour. +// 1 = pin every candidate to light 0. Keeps the RNG draws and all the shading ALU, but removes every +// dependent load (span -> alias -> record) and every bit of lane divergence, since all lanes now +// fetch one record. The delta is the ceiling for ANY memory-side RIS optimisation. +// 2 = derive the proposal RNG from a 16x16 screen tile instead of the pixel, so lanes sharing a cell +// select the SAME lights. Chasing depth is unchanged; those record fetches become coherent. NOTE +// this is a LOWER bound on coherence: a shared seed does not force a shared cell, since +// findLightGridCell still keys off per-lane hitPos, so lanes in different cells keep diverging +// through cell.spanOffset. +// 3 = pin at the PRIMARY vertex only (bounce 0). +// 4 = pin at SECONDARY vertices only (bounce > 0). +// 3 and 4 split 1's total by vertex, which is what decides the presample pool. The pool is keyed on the +// world-space light grid cell, so it applies at every bounce — but its coverage depends on a frame-stale +// registry containing the cells secondary vertices land in. If 4 is the larger share, that coverage is +// the whole ballgame and needs verifying before the pool is worth building. +static const uint RIS_PROBE = 0u; + +// Candidate-count divisor for RIS at secondary vertices (bounce > 0). Measured with the probes above, +// secondary vertices carry ~4.3ms of RIS's 5.9ms memory cost — 78% — while being much the more forgiving +// place to spend variance: that radiance is already integrated over a diffuse lobe before the denoiser +// sees it. Reducing M there is therefore a different proposition from reducing it globally, which was +// visibly worse at M=2. Sweep 1 (off) / 2 / 4; the primary hit always keeps worldPush.risCandidates. +static const uint SECONDARY_RIS_DIVISOR = 1u; + +// Deepest bounce at which RIS emitter NEE runs at all. Past it, emitters are gathered only when a path +// hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep. +// Secondary vertices already tolerate 4x fewer candidates; this asks whether deep bounces need RIS at +// all. Unlike the divisor it also removes shadeReservoir's per-vertex shadow ray, which does not scale +// with M and is now a large share of what remains. 64 = effectively off. Sweep 64 / 2 / 1 / 0. +static const uint RIS_MAX_BOUNCE = 64u; + // 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 pbr, bool twoSided, float sss, + float rough, bool pbr, bool twoSided, float sss, uint bounceIndex, inout uint seed, inout uint proposalSeed) { Reservoir r = resEmpty(); LightGridCell gridCell; @@ -622,7 +658,9 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl * worldPush.lightGridOrigin.w; } bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); - uint candidateCount = worldPush.risCandidates; + uint candidateCount = bounceIndex == 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 @@ -631,13 +669,24 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl ? max(1u, (candidateCount + 2u) / 4u) : candidateCount; uint localCandidateCount = candidateCount - globalCandidateCount; float localProbability = float(localCandidateCount) / float(candidateCount); - for (uint c = 0u; c < worldPush.risCandidates; c++) { + 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); + bool pinCandidate = RIS_PROBE == 1u + || (RIS_PROBE == 3u && bounceIndex == 0u) + || (RIS_PROBE == 4u && bounceIndex != 0u); + if (pinCandidate) { + // Consume the draws the real selector would (two on the local path) so the ALU side stays + // roughly comparable, then skip the walk entirely. + rndf(proposalSeed); + rndf(proposalSeed); + li = 0u; + } else { + selectLightGridLight(gridCell, useLocal, proposalSeed, li); + } Light lg = ConstPtr(worldPush.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; @@ -920,7 +969,11 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && worldPush.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. - uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u + // RIS_PROBE == 2 keys this to a 16x16 tile instead of the pixel, making a whole tile select the same + // lights so the record fetches go coherent. Diagnostic only — this is exactly the sharing that + // produced visible tile-shaped noise before, which is why a real pool shares the POOL, not the seed. + uint2 proposalPix = RIS_PROBE == 2u ? (pix >> 4u) : pix; + uint proposalSeed = proposalPix.x * 1973u + proposalPix.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 @@ -1059,7 +1112,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint if (risOn) { float3 v = -rd; Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, false, - true, 0.0, seed, proposalSeed); + true, 0.0, uint(bounce), seed, proposalSeed); float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, false, true, 0.0, risVis); @@ -1173,7 +1226,11 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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(); - bool gateEmitter = risOn && payloadEmitterInList(); + // 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 && bounce <= int(RIS_MAX_BOUNCE); + bool gateEmitter = risActive && payloadEmitterInList(); if (emission > 0.0 && (!gateEmitter || showCelestial)) { L += throughput * albedo * emission; } @@ -1219,10 +1276,10 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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_BOUNCE by passing sss=0 // there (falls back to plain front-only RIS). - if (risOn) { + if (risActive) { float activeSss = bounce <= MAX_SSS_BOUNCE ? sss : 0.0; Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, pbr, false, activeSss, - seed, proposalSeed); + uint(bounce), seed, proposalSeed); float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, pbr, false, activeSss, risVis); From 78d8268335286fdd5cc88c2b422b8bc3c7c449e3 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:16:43 +0900 Subject: [PATCH 5/9] Gate RIS emitter NEE past bounce 2, and drop the cost probes RIS_MAX_BOUNCE goes from off to 2: 16.1 -> 15.7ms, visually hard to distinguish. Unlike SECONDARY_RIS_DIVISOR this also drops shadeReservoir's per-vertex shadow ray, which never scaled with M and was a large share of the remainder. 2 rather than lower, and the reason belongs in the source: `bounce` is not indirect depth. MATERIAL_GLASS and MATERIAL_WATER continue without being diffuse vertices, so a pane spends bounce 0 and the first diffuse vertex behind it lands at bounce 1. Gating below 2 starves surfaces seen through glass or water -- visually primary, but counted as depth -- and they go black, since only a path randomly striking an emitter can light them. Counting diffuse vertices instead of bounces would let this go lower and be more correct at once; noted in the source as the next cleanup. Removes RIS_PROBE and its three sites now that the numbers are banked. The measurements it produced are kept as comments where they justify the two constants: 5.9ms total for RIS's memory side against an 18.0ms frame at M=8, ~4.3ms of it at secondary vertices vs ~1.1ms at the primary hit, and at most 1.3ms attributable to coherence rather than to chasing depth. That last number is why the note on proposalSeed now says a future presampled pool should share the pool, not the seed. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 86 ++++++++++++++-------------------- 1 file changed, 36 insertions(+), 50 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 8a3afef3..d0bd4a30 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -606,41 +606,40 @@ void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposal } } -// Retained diagnostic — bounds what memory-side RIS work can be worth before any of it gets built. Every -// non-zero setting produces a WRONG image on purpose: they exist to be timed, not looked at. Leave at 0. -// Measured at M=8 (18.0ms baseline): 1 -> 12.1ms, 2 -> 16.7ms, 3 -> 16.9ms, 4 -> 13.7ms. So RIS's memory -// side was 5.9ms of an 18ms frame, of which ~4.3ms sat at secondary vertices and ~1.1ms at the primary -// hit, and coherence accounted for at most 1.3ms of it — depth, not divergence, was the cost. -// 0 = off, normal behaviour. -// 1 = pin every candidate to light 0. Keeps the RNG draws and all the shading ALU, but removes every -// dependent load (span -> alias -> record) and every bit of lane divergence, since all lanes now -// fetch one record. The delta is the ceiling for ANY memory-side RIS optimisation. -// 2 = derive the proposal RNG from a 16x16 screen tile instead of the pixel, so lanes sharing a cell -// select the SAME lights. Chasing depth is unchanged; those record fetches become coherent. NOTE -// this is a LOWER bound on coherence: a shared seed does not force a shared cell, since -// findLightGridCell still keys off per-lane hitPos, so lanes in different cells keep diverging -// through cell.spanOffset. -// 3 = pin at the PRIMARY vertex only (bounce 0). -// 4 = pin at SECONDARY vertices only (bounce > 0). -// 3 and 4 split 1's total by vertex, which is what decides the presample pool. The pool is keyed on the -// world-space light grid cell, so it applies at every bounce — but its coverage depends on a frame-stale -// registry containing the cells secondary vertices land in. If 4 is the larger share, that coverage is -// the whole ballgame and needs verifying before the pool is worth building. -static const uint RIS_PROBE = 0u; - -// Candidate-count divisor for RIS at secondary vertices (bounce > 0). Measured with the probes above, -// secondary vertices carry ~4.3ms of RIS's 5.9ms memory cost — 78% — while being much the more forgiving -// place to spend variance: that radiance is already integrated over a diffuse lobe before the denoiser -// sees it. Reducing M there is therefore a different proposition from reducing it globally, which was -// visibly worse at M=2. Sweep 1 (off) / 2 / 4; the primary hit always keeps worldPush.risCandidates. -static const uint SECONDARY_RIS_DIVISOR = 1u; +// ---- 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 bounce at which RIS emitter NEE runs at all. Past it, emitters are gathered only when a path -// hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep. -// Secondary vertices already tolerate 4x fewer candidates; this asks whether deep bounces need RIS at -// all. Unlike the divisor it also removes shadeReservoir's per-vertex shadow ray, which does not scale -// with M and is now a large share of what remains. 64 = effectively off. Sweep 64 / 2 / 1 / 0. -static const uint RIS_MAX_BOUNCE = 64u; +// 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. 16.1 -> 15.7ms. +// +// 2, not lower, and the reason is worth keeping: `bounce` is NOT indirect depth. The MATERIAL_GLASS and +// MATERIAL_WATER branches continue without being diffuse vertices, so looking through a pane spends +// bounce 0 on the glass and lands the first DIFFUSE vertex at bounce 1. Gating below 2 therefore starves +// surfaces seen through glass or water — visually primary, but counted as depth — and they go black, +// since only a path randomly striking an emitter can light them. The two bounces of headroom pay for +// that dielectric prefix. Counting diffuse vertices instead of bounces would let this go lower and be +// more correct at once; it is the obvious next cleanup here. +static const uint RIS_MAX_BOUNCE = 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. @@ -675,18 +674,7 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl uint globalsBefore = (c * globalCandidateCount) / candidateCount; uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; bool useLocal = hasGridCell && globalsAfter == globalsBefore; - bool pinCandidate = RIS_PROBE == 1u - || (RIS_PROBE == 3u && bounceIndex == 0u) - || (RIS_PROBE == 4u && bounceIndex != 0u); - if (pinCandidate) { - // Consume the draws the real selector would (two on the local path) so the ALU side stays - // roughly comparable, then skip the walk entirely. - rndf(proposalSeed); - rndf(proposalSeed); - li = 0u; - } else { - selectLightGridLight(gridCell, useLocal, proposalSeed, li); - } + selectLightGridLight(gridCell, useLocal, proposalSeed, li); Light lg = ConstPtr(worldPush.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; @@ -969,11 +957,9 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && worldPush.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. - // RIS_PROBE == 2 keys this to a 16x16 tile instead of the pixel, making a whole tile select the same - // lights so the record fetches go coherent. Diagnostic only — this is exactly the sharing that - // produced visible tile-shaped noise before, which is why a real pool shares the POOL, not the seed. - uint2 proposalPix = RIS_PROBE == 2u ? (pix >> 4u) : pix; - uint proposalSeed = proposalPix.x * 1973u + proposalPix.y * 9277u + 26699u + // 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); // Start submerged if the camera is in water so the first segment gets the right medium orientation From 2e0c42cde03b8d2d1720d55a856f43f013a9d1bc Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:31:12 +0900 Subject: [PATCH 6/9] Key the RIS bounce budget on shaded surfaces, not on bounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RIS_MAX_BOUNCE and SECONDARY_RIS_DIVISOR both keyed on `bounce`, which also counts dielectric interfaces: MATERIAL_GLASS and MATERIAL_WATER continue without shading anything, so a wall behind a pane is bounce 1 but is the first surface this path has actually shaded. Keyed on bounce, that wall got the secondary candidate count and could fall off the RIS gate entirely — visually primary content penalised as if it were deep indirect light, which is what the earlier black-behind-glass report was. Adds an explicit diffuseDepth counter, incremented once per path at every point a surface is actually shaded (the particle billboard branch, and the main opaque/PBR path after all its NEE/RIS/SSS terms), and NOT in the glass/water continue paths. Both risInitial call sites and the RIS_MAX_DIFFUSE_DEPTH gate now key on it. Renamed RIS_MAX_BOUNCE to RIS_MAX_DIFFUSE_DEPTH and risInitial's bounceIndex to shadedDepth to keep the distinction visible at every call site. Default stays 2, matching the old RIS_MAX_BOUNCE, so this is a correctness fix isolated from any further budget change: frame time should be flat, while surfaces seen through glass or water should get full RIS again instead of the degraded/absent treatment they got before. 1 and 0 are now meaningful budgets to sweep, where they previously included an unpaid-for dielectric prefix. MAX_SSS_BOUNCE has the identical defect and is not touched here — left for its own change since it is a separate visual behaviour. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 53 ++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index d0bd4a30..ac25c331 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -627,24 +627,28 @@ void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposal // gone entirely. static const uint SECONDARY_RIS_DIVISOR = 4u; -// Deepest bounce at which RIS emitter NEE runs at all. 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. 16.1 -> 15.7ms. +// 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. // -// 2, not lower, and the reason is worth keeping: `bounce` is NOT indirect depth. The MATERIAL_GLASS and -// MATERIAL_WATER branches continue without being diffuse vertices, so looking through a pane spends -// bounce 0 on the glass and lands the first DIFFUSE vertex at bounce 1. Gating below 2 therefore starves -// surfaces seen through glass or water — visually primary, but counted as depth — and they go black, -// since only a path randomly striking an emitter can light them. The two bounces of headroom pay for -// that dielectric prefix. Counting diffuse vertices instead of bounces would let this go lower and be -// more correct at once; it is the obvious next cleanup here. -static const uint RIS_MAX_BOUNCE = 2u; +// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_GLASS 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 pbr, bool twoSided, float sss, uint bounceIndex, + float rough, bool pbr, bool twoSided, float sss, uint shadedDepth, inout uint seed, inout uint proposalSeed) { Reservoir r = resEmpty(); LightGridCell gridCell; @@ -657,7 +661,9 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl * worldPush.lightGridOrigin.w; } bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); - uint candidateCount = bounceIndex == 0u + // 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 @@ -974,6 +980,11 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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; + // 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++) { // 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. @@ -1095,10 +1106,10 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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) { + 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, false, - true, 0.0, uint(bounce), seed, proposalSeed); + 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, false, true, 0.0, risVis); @@ -1112,6 +1123,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint 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; } @@ -1215,7 +1227,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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 && bounce <= int(RIS_MAX_BOUNCE); + bool risActive = risOn && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); bool gateEmitter = risActive && payloadEmitterInList(); if (emission > 0.0 && (!gateEmitter || showCelestial)) { L += throughput * albedo * emission; @@ -1265,7 +1277,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint if (risActive) { float activeSss = bounce <= MAX_SSS_BOUNCE ? sss : 0.0; Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, pbr, false, activeSss, - uint(bounce), seed, proposalSeed); + uint(diffuseDepth), seed, proposalSeed); float3 risVis; L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, pbr, false, activeSss, risVis); @@ -1294,6 +1306,11 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint } } + // 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. With PBR off, use the cosine-weighted Lambertian fallback. if (pbr) { From 64a0e0a63cd162feba6fbadea744b10c59da9f07 Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:32:17 +0900 Subject: [PATCH 7/9] Key SSS transmission depth on shaded surfaces, not on bounce Same defect as the RIS gate fixed in 2e0c42c, in the leaf/grass backlight term. MATERIAL_GLASS and MATERIAL_WATER continue without shading a surface, so foliage seen through a window was being counted a bounce deeper than it visually is and could fall outside the SSS budget -- a backlit leaf loses its glow if there happens to be a pane between it and the camera. Renamed MAX_SSS_BOUNCE to MAX_SSS_DIFFUSE_DEPTH and keyed both the RIS activeSss gate and the direct backlight term on diffuseDepth. Value unchanged at 1, so this is the same kind of isolated correctness fix as 2e0c42c: foliage behind glass/water should backlight correctly again, frame time should not move. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index ac25c331..906a54a3 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -247,7 +247,11 @@ 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). 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) -static const int MAX_SSS_BOUNCE = 1; // fire the transmission shadow ray on primary + first indirect hit only +// 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)); @@ -1272,10 +1276,12 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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_BOUNCE by passing sss=0 - // there (falls back to plain front-only RIS). + // 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 = bounce <= MAX_SSS_BOUNCE ? sss : 0.0; + float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, pbr, false, activeSss, uint(diffuseDepth), seed, proposalSeed); float3 risVis; @@ -1288,7 +1294,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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 && bounce <= MAX_SSS_BOUNCE) { + 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); From 45ffb2bdd0a8e7cc874954787bcc975feecbc16f Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:41:10 +0900 Subject: [PATCH 8/9] Drop the PBR-toggle flag; GGX shading is now unconditional worldPush.flags bit 1 gated GGX BRDF + material guides vs. a Lambertian fallback, but RtComposite.java packed it as a hardcoded `0b10` -- nothing ever cleared it. Removed the flag, the `pbr` parameter from evalSampleContrib/risInitial/shadeReservoir, and every `pbr ? x : y` site in tracePath and refractedGuideHit; each now takes the branch that was always live. RtComposite's flags int drops to 0 as its base value, with bit 1 left unused rather than reassigned so a stale reader elsewhere can't silently pick up the wrong meaning. One site needed more than deletion. The particle billboard path called risInitial/shadeReservoir with pbr=false, rough=1.0, F0=0 -- unlike the main path, which always passed the (always-true) global flag. That false was load-bearing: fresnelSchlick(cosT, f0) returns up to full white at grazing angles regardless of f0 (that's the Fresnel effect itself), so zeroing F0 does not zero the specular term the way it looks like it should. Mechanically deleting the pbr gate would have put a white grazing-angle rim on every particle billboard (smoke, rain, ...) that was never there before. twoSided and the old pbr=false correlate exactly in this codebase -- particles are the only twoSided caller and the only one that disabled specular -- so evalSampleContrib now gates the GGX term on !twoSided instead of on the removed pbr, preserving the exact prior behavior without carrying the parameter. Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 111 ++++++++---------- shaders/world/world_common.slang | 2 +- .../comfyfluffy/caustica/rt/RtComposite.java | 8 +- 3 files changed, 56 insertions(+), 65 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 906a54a3..63441409 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -435,7 +435,7 @@ Reservoir resEmpty() { // - 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 pbr, + float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, bool twoSided, float sss, out float phat) { phat = 0.0; float3 toL = sp - hitPos; @@ -457,7 +457,11 @@ float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 h if (ndl > 0.0) { float G = ndl * cosL / dist2; // area-light geometry term (x area) float3 brdf = diffAlb * INV_PI; - if (pbr) { + // 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)); @@ -652,7 +656,7 @@ 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 pbr, bool twoSided, float sss, uint shadedDepth, + float rough, bool twoSided, float sss, uint shadedDepth, inout uint seed, inout uint proposalSeed) { Reservoir r = resEmpty(); LightGridCell gridCell; @@ -696,7 +700,7 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl float area = lightArea(lg); float phat; evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd, - diffAlb, F0, rough, pbr, twoSided, sss, phat); + diffAlb, F0, rough, twoSided, sss, phat); if (phat <= 0.0) { continue; } @@ -720,14 +724,14 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl // 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 pbr, bool twoSided, float sss, out float3 vis) { + 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, pbr, twoSided, sss, phat); + diffAlb, F0, rough, twoSided, sss, phat); if (phat <= 0.0) { return float3(0.0, 0.0, 0.0); } @@ -941,14 +945,11 @@ void refractedGuideHit(float3 surfacePos, float3 incidentDir, float3 surfaceNorm uint material = payloadMaterial(); motionPrev = material != MATERIAL_WATER ? payload.motionPrev : float3(0.0, 0.0, 0.0); if (material == MATERIAL_OPAQUE) { - bool pbr = (worldPush.flags & 2u) != 0u; - diffuseAlbedo = pbr ? payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)) : payload.albedo; - if (pbr) { - 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); - } + 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); } } @@ -961,7 +962,6 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint float rayConeSpread = max(primaryConeSpread, RAY_CONE_MIN_SPREAD); int maxBounces = int(worldPush.maxBounces); int rrStart = maxBounces <= 3 ? 1 : 2; - bool pbr = (worldPush.flags & 2u) != 0u; // GGX BRDF + material guides, else Lambertian fallback // 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 && worldPush.lightBufAddr != 0; @@ -1112,11 +1112,11 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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, false, + 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, - false, true, 0.0, risVis); + true, 0.0, risVis); } if (bounce >= maxBounces) { @@ -1200,18 +1200,17 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint 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. With PBR disabled this collapses to the Lambertian fallback. - float rough = pbr ? clamp(payloadRoughness(), MIN_ROUGH, 1.0) : 1.0; - float metal = pbr ? clamp(payloadMetalness(), 0.0, 1.0) : 0.0; + // 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 metal = clamp(payloadMetalness(), 0.0, 1.0); float3 diffAlb = albedo * (1.0 - metal); - // F0 comes from the chit: dielectric 0.04, a LabPBR custom/metal F0, or albedo for metals. - float3 F0 = pbr ? payload.f0 : lerp(float3(0.04, 0.04, 0.04), albedo, metal); + float3 F0 = payload.f0; if (bounce == 0) { // primary-visibility surface: capture the denoiser guide buffers gv_normal = n; - gv_albedo = pbr ? diffAlb : albedo; // RR diffuse-albedo demodulation target + gv_albedo = diffAlb; // RR diffuse-albedo demodulation target gv_rough = rough; - gv_specAlb = pbr ? rrSpecularAlbedo(F0, rough * rough, dot(n, v)) : float3(0.0, 0.0, 0.0); + 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; @@ -1259,16 +1258,14 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint } if (max(vis.r, max(vis.g, vis.b)) > 0.0) { float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) - if (pbr) { - 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 - } + 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; } } @@ -1282,10 +1279,10 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // 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, pbr, false, activeSss, + 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, pbr, false, + L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, risVis); } @@ -1318,36 +1315,28 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint diffuseDepth++; // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their - // relative reflectance. With PBR off, use the cosine-weighted Lambertian fallback. - if (pbr) { - float ps = 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 - } - // 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); - 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) + // relative reflectance. + float ps = 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 } + // 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 *= albedo; // Lambertian fallback (PI and cos cancel) - showCelestial = false; // diffuse bounce: hide the disc (covered by NEE) + 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. diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index 5260581d..b2682dc8 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -38,7 +38,7 @@ public struct WorldPush { public uint spp; public float2 jitter; public uint64_t entityTableAddr; // entity geometry table - public uint flags; // bit0 submerged, bit1 PBR, bit4 waves + public uint flags; // bit0 submerged, bit4 waves (bit1 was PBR-toggle, now unconditional) public uint maxBounces; public float4 sunDir; // xyz true sun direction, w dayFactor 0..1 public float4 lightDir; // xyz active NEE light dir, w square half-angle (rad) diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 2f8fb54f..fc04bb7e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -803,9 +803,11 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo RtBuffer pushBuf = selectedPushSlot.buffer; ByteBuffer push = MemoryUtil.memByteBuffer(pushBuf.mapped, WORLD_PUSH_SIZE); frameInvViewProj.set(frameProjection).mul(frameViewRotation).invert(); - // flags: PBR BRDF (bit 1, always on) + camera-in-water (so the path tracer starts in the water - // medium when the eye is submerged, fixing the air→water first-segment orientation). - int flags = 0b10; + // flags: camera-in-water (so the path tracer starts in the water medium when the eye is + // submerged, fixing the air→water first-segment orientation) + W1 wave normals. Bit 1 used to + // gate a Lambertian fallback BRDF that nothing ever turned off; the GGX path is unconditional + // now, so that bit is unused rather than reassigned, to avoid a stale reader elsewhere. + int flags = 0; var level = Minecraft.getInstance().level; if (level != null) { cameraBlockPos.set(Mth.floor(camX), Mth.floor(camY), Mth.floor(camZ)); From 24ae88e805e01961d8f76fbdebc6b7f80475d9ab Mon Sep 17 00:00:00 2001 From: ComfyFluffy <24245520+ComfyFluffy@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:53:17 +0900 Subject: [PATCH 9/9] Move every device address in WorldPush to the push-constant block WorldPushConstants exists specifically so hit shaders and raygen control flow can read a 64-bit address without first dereferencing worldPushAddr to find it (see the struct's own doc comment). WorldPush had drifted away from that: two of its addresses were exact duplicates never read through worldPush at all, and five more were unique addresses that could have lived in either place. tableAddr and entityTableAddr are deleted outright, not moved. Every actual read in world.rahit/world.rchit already went through pc.tableAddr/pc.entityTableAddr -- grep confirms no `worldPush.tableAddr` or `worldPush.entityTableAddr` existed anywhere. The WorldPush copies were populated from the same Java values and never read back. lightBufAddr/lightAliasAddr/lightLocalAliasAddr/lightGridCellAddr/ lightGridSpanAddr move to WorldPushConstants. These are read only in world.rgen, which already loads WorldPush once at the top of main(), so the move buys no per-access saving there -- the point is architectural: one struct now holds every device address, so adding the next buffer means one decision instead of two, and nothing can quietly duplicate itself across both again. WorldPushConstantsData/WorldPushData are Slang-reflection-generated records (GenerateShaderRecords), so the Java-side fix was reordering the two constructor call sites in RtComposite to the new field layout -- nothing manual to keep in sync. RtMaterialLayoutTest hardcoded the old 40-byte size and byte offsets for a positional WorldPushConstantsData; updated to the new 80-byte, 11-arg shape it now reflects. WorldPushConstants grows from 40 to 80 bytes, still far under the 128-byte minimum Vulkan guarantees for push constants. Full build + test suite green (34/34). Co-Authored-By: Claude Opus 5 --- shaders/world/world.rgen.slang | 20 +++++++++---------- shaders/world/world_common.slang | 20 ++++++++++--------- .../comfyfluffy/caustica/rt/RtComposite.java | 20 ++++++++++--------- .../rt/material/RtMaterialLayoutTest.java | 15 ++++++++------ 4 files changed, 41 insertions(+), 34 deletions(-) diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 63441409..8f8e1247 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -486,8 +486,8 @@ float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 h void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); uint column = min(uint(aliasSample), worldPush.lightCount - 1u); - if (worldPush.lightAliasAddr != 0) { - LightAlias a = ConstPtr(worldPush.lightAliasAddr)[column]; + if (pc.lightAliasAddr != 0) { + LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; bool self = aliasSample - float(column) < a.accept; lightIndex = self ? column : a.aliasIndex; } else { @@ -500,7 +500,7 @@ bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { cell.spanCount = 0u; cell.invWeightSum = 0.0; cellCoord = int3(0, 0, 0); - if (worldPush.lightGridCellAddr == 0 || worldPush.lightGridSpanAddr == 0) return false; + 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 @@ -508,7 +508,7 @@ bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { || 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(worldPush.lightGridCellAddr)[linear]; + cell = ConstPtr(pc.lightGridCellAddr)[linear]; cellCoord = coord; return cell.spanCount > 0u; } @@ -517,7 +517,7 @@ void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSee out uint lightIndex) { float aliasSample = rndf(proposalSeed) * float(lightCount); uint column = min(uint(aliasSample), lightCount - 1u); - LightAlias alias = ConstPtr(worldPush.lightLocalAliasAddr)[firstLight + column]; + LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; bool self = aliasSample - float(column) < alias.accept; uint localIndex = self ? column : alias.aliasIndex; lightIndex = firstLight + localIndex; @@ -574,7 +574,7 @@ float3 lightGeometricNormal(Light light) { float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, float localProbability) { float power = max(0.0, lightArea(light) * luminance(le)); - float globalPdf = worldPush.lightAliasAddr != 0 + 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) { @@ -589,7 +589,7 @@ float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, out uint lightIndex) { - ConstPtr spans = ConstPtr(worldPush.lightGridSpanAddr); + 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]; @@ -662,7 +662,7 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl LightGridCell gridCell; int3 gridCellCoord; float3 gridLookup = hitPos; - if (worldPush.lightGridCellAddr != 0) { + 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) @@ -689,7 +689,7 @@ Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAl uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; bool useLocal = hasGridCell && globalsAfter == globalsBefore; selectLightGridLight(gridCell, useLocal, proposalSeed, li); - Light lg = ConstPtr(worldPush.lightBufAddr)[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; @@ -964,7 +964,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint 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 && worldPush.lightBufAddr != 0; + 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 diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index b2682dc8..566f0964 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -10,12 +10,21 @@ module world_common; public typealias ConstPtr = 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. +// hit shaders or used for raygen control flow — every 64-bit device address lives here for exactly that +// reason, rather than behind the worldPushAddr indirection. tableAddr/entityTableAddr/materialTableAddr +// are read in world.rahit/world.rchit, which never load WorldPush at all; the light-buffer addresses are +// only read in world.rgen, which loads WorldPush anyway, but keeping every Addr in one place means there +// is exactly one struct to check when adding a new device buffer, instead of two. public struct WorldPushConstants { public uint64_t worldPushAddr; public uint64_t tableAddr; public uint64_t entityTableAddr; public uint64_t materialTableAddr; + public uint64_t lightBufAddr; // RIS emitter-NEE global light buffer (0 = none published) + public uint64_t lightAliasAddr; // power-weighted O(1) alias table (0 = uniform fallback) + 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 uint frameIndex; public uint debugView; }; @@ -31,13 +40,11 @@ public struct BreakEntry { public struct WorldPush { public float4x4 invViewProj; public float3 camOffset; - public uint64_t tableAddr; // section table public uint frameIndex; public float4x4 prevViewProj; public float3 camDelta; public uint spp; public float2 jitter; - public uint64_t entityTableAddr; // entity geometry table public uint flags; // bit0 submerged, bit4 waves (bit1 was PBR-toggle, now unconditional) public uint maxBounces; public float4 sunDir; // xyz true sun direction, w dayFactor 0..1 @@ -52,15 +59,10 @@ public struct WorldPush { public float4x4 curViewProj; // forward camera-relative view-projection public uint breakCount; public BreakEntry breaking[8]; // Java capacity and breakCount serialization are generated from this - public uint64_t lightBufAddr; // RIS emitter-NEE global light buffer (0 = none published) - public uint64_t lightAliasAddr; // power-weighted O(1) alias table (0 = uniform fallback) - public uint64_t lightLocalAliasAddr; // power aliases relative to each section's range public float4 lightRebase; // xyz hierarchy-to-current rebase, w inverse total light power - 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 float4 lightGridOrigin; // xyz minimum cell origin in rebased blocks, w cell size public int4 lightGridDims; // xyz dense grid dimensions, w reserved - public uint lightCount; // packed Light records in lightBufAddr + public uint lightCount; // packed Light records in pc.lightBufAddr public uint risCandidates; // RIS candidate count M per diffuse vertex (0 = emitter NEE off) }; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index fc04bb7e..de538743 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -861,13 +861,11 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo frameInvViewProj, new Float3((float) (camX - terrain.blockX), (float) (camY - terrain.blockY), (float) (camZ - terrain.blockZ)), - terrain.tableAddress(), (int) frameCounter, mvPushMatrix, new Float3(mvCamDeltaX, mvCamDeltaY, mvCamDeltaZ), spp(), new Float2(jitterX, jitterY), - fe.geomTableAddr(), flags, maxBounces(), sky.sunDir(), @@ -882,15 +880,12 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo mvCurProjView, breaking.length, breaking, - // RIS emitter NEE: published light buffer + RIS candidate count (0 = emitter NEE off; - // the shader also requires lightCount > 0, so an empty buffer degrades to legacy gather). - terrain.lightBufferAddress(), - terrain.lightAliasBufferAddress(), - terrain.lightLocalAliasBufferAddress(), + // RIS emitter NEE: candidate count (0 = emitter NEE off; the shader also requires + // lightCount > 0, so an empty buffer degrades to legacy gather). The light buffer + // device addresses themselves are pc.light*Addr — every 64-bit address lives in the + // push-constant block now, not here. new Float4(terrain.lightRebaseOffsetX(), terrain.lightRebaseOffsetY(), terrain.lightRebaseOffsetZ(), terrain.lightInvGlobalPowerSum()), - terrain.lightGridCellBufferAddress(), - terrain.lightGridSpanBufferAddress(), new Float4(terrain.lightGridOriginX(), terrain.lightGridOriginY(), terrain.lightGridOriginZ(), 16f), new Int4(terrain.lightGridDimX(), terrain.lightGridDimY(), terrain.lightGridDimZ(), 0), terrain.lightCount(), @@ -920,9 +915,16 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo VulkanCommandEncoder.memoryBarrier(cmd, stack); // TLAS build visible to the trace // Push the BDA ring slot's address plus the small hot subset used directly by the shaders. + // Every 64-bit device address the trace needs lives here, not behind worldPushAddr: the + // section/entity/material tables are read from world.rahit/world.rchit, which never load + // WorldPush at all, and the RIS light buffers are read from world.rgen's hot inner loop, so + // none of them should cost an extra BDA dereference to find. ByteBuffer pushConstants = stack.malloc(WorldPushConstantsData.BYTE_SIZE); new WorldPushConstantsData(pushBuf.deviceAddress, terrain.tableAddress(), fe.geomTableAddr(), RtMaterialRegistry.INSTANCE.tableAddress(), + terrain.lightBufferAddress(), terrain.lightAliasBufferAddress(), + terrain.lightLocalAliasBufferAddress(), terrain.lightGridCellBufferAddress(), + terrain.lightGridSpanBufferAddress(), (int) frameCounter, debugView).write(pushConstants); try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world trace"); RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.trace")) { 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 f7b445a8..0a2a85af 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java @@ -33,13 +33,16 @@ void reflectedMaterialHeaderMatchesHotAbi() { } @Test - void reflectedWorldPushConstantsIncludeMaterialTableAndDebugView() { - assertEquals(40, WorldPushConstantsData.BYTE_SIZE); + void reflectedWorldPushConstantsIncludeLightBuffersAndDebugView() { + // 9 uint64_t addresses (worldPush/table/entityTable/materialTable + the 5 light buffers) + 2 uint. + assertEquals(80, WorldPushConstantsData.BYTE_SIZE); ByteBuffer data = ByteBuffer.allocateDirect(WorldPushConstantsData.BYTE_SIZE) .order(ByteOrder.nativeOrder()); - new WorldPushConstantsData(1L, 2L, 3L, 4L, 5, 6).write(data); - assertEquals(4L, data.getLong(24)); - assertEquals(5, data.getInt(32)); - assertEquals(6, data.getInt(36)); + new WorldPushConstantsData(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10, 11).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 } }