diff --git a/.gitignore b/.gitignore index d5a3ee6f..69b5d54a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ out/ *.class third_party/ bin/ +/logs diff --git a/build.gradle b/build.gradle index 452a4abd..9d436efd 100644 --- a/build.gradle +++ b/build.gradle @@ -160,12 +160,16 @@ abstract class CompileShaders extends DefaultTask { def base = outBase(src) def spv = new File(scratchDir, "${base}.spv") if (src.name.endsWith(".slang")) { - // Build both SER encodings from one source; RtDeviceBringup selects the supported variant. - compileOneSlang(src, spv, base == "world.rgen" - ? ["-capability", "spvShaderInvocationReorderEXT"] : []) - if (base == "world.rgen") { - compileOneSlang(src, new File(scratchDir, "world_nv.rgen.spv"), - ["-capability", "spvShaderInvocationReorderNV"]) + def worldIndirectRaygen = base == "world.rgen" + if (worldIndirectRaygen) { + // Keep a capability-free TraceRay fallback for devices without EXT SER, and + // publish the reordered variant separately when the extension is available. + compileOneSlang(src, spv, []) + compileOneSlang(src, new File(scratchDir, "world_ser.rgen.spv"), + ["-DCAUSTICA_ENABLE_EXT_SER", "-capability", "spvShaderInvocationReorderEXT"]) + } else { + // Pass A intentionally uses ordinary TraceRay. + compileOneSlang(src, spv, []) } } else { compileOne(src, spv, []) diff --git a/shaders/world/guides.slang b/shaders/world/guides.slang new file mode 100644 index 00000000..ced82482 --- /dev/null +++ b/shaders/world/guides.slang @@ -0,0 +1,298 @@ +// DLSS-RR guide state and everything that resolves it: the gv_* captures, the foreground specular +// surface and its reflection motion vector, and the image writes. +// PRIMARY PASS ONLY. Depends on trace, water and segment. + +// Primary-visibility guide attributes. Pass A traces exactly one sample per pixel; a split reflection +// leaf supplies the spec endpoint and its transmission leaf may replace the ordinary tuple. + +import world_common; +import world_core; +import math; +import medium; +import water; +import trace; + +public static float3 gv_normal = float3(0.0, 0.0, 0.0); +public static float3 gv_albedo = float3(0.0, 0.0, 0.0); +public static float gv_rough = 0.0; +public static float3 gv_hitCamRel = float3(0.0, 0.0, 0.0); // primary-surface hit position relative to the current camera +public static bool gv_motionUseRefracted = false; // true when the MV tracks the refracted hit (its own reprojection delta) +// World-space displacement of the motion-guide surface since the previous frame (0 for static +// terrain/sky, an entity's per-vertex/object motion for a dynamic hit). +public static float3 gv_motionObjDisp = float3(0.0, 0.0, 0.0); +// Transmission replaces the ordinary guide tuple with the visible destination, so RR's specular inputs +// (specular albedo + reflection MV) need the foreground glass/water interface kept separately. This is +// the surface the reflection physically lives on; for an opaque primary hit it is that hit itself. +public struct SpecSurface { + public float3 camRel; // interface position relative to the current camera + public float3 normal; // FP32: reflection reprojection amplifies normal quantization at distance + public float3 previousNormal; // the same normal one frame ago; equal to `normal` unless the surface animates + public float3 biasNormal; // geometric normal, used only to offset a ray origin off the surface + public float3 motionPrev; // current-minus-previous displacement of the reflecting surface + public float roughness; + public float3 albedo; // specular albedo fed to DLSS-RR for demodulation (0 = pure diffuse) +}; +public static SpecSurface gv_spec = {}; + +// Static surfaces reuse one normal for all three roles. Water overrides them (wave-displaced shading +// normal now and last frame, flat geometric normal for the bias) via the six-argument form. +public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 previousNormal, + float3 biasNormal, float3 motionPrev, float roughness, float3 albedo) { + SpecSurface s; + s.camRel = camRel; + s.normal = normal; + s.previousNormal = previousNormal; + s.biasNormal = biasNormal; + s.motionPrev = motionPrev; + s.roughness = roughness; + s.albedo = albedo; + return s; +} + +public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float roughness, float3 albedo) { + return makeSpecSurface(camRel, normal, normal, normal, + float3(0.0, 0.0, 0.0), roughness, albedo); +} + +public SpecSurface makeSpecSurface(float3 camRel, float3 normal, float3 motionPrev, + float roughness, float3 albedo) { + return makeSpecSurface(camRel, normal, normal, normal, motionPrev, roughness, albedo); +} +// angle can land behind the eye even though the reflector itself is comfortably in view. +public float2 projectPrevNdc(float3 worldPos, out bool valid) { + float4 clip = mul(worldPush.prevViewProj, float4(worldPos - worldPush.camOffset + worldPush.camDelta, 1.0)); + valid = clip.w > 0.0; + return valid ? clip.xy / clip.w : float2(0.0, 0.0); +} + +// Previous-frame screen position of a planar reflection. The reflected image of a world point P seen +// in a planar reflector is the MIRROR image V = mirror(P) across the surface plane: the eye sees V +// along a straight line, so the reflection appears at proj(V). Project the mirror image directly. +// Gotcha: intersecting a reflection ray with the surface plane divides by dot(mirrorDir, n), which +// explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so +// static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); +// reflectedMotionPrev carries the reflected content's own displacement. +public float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 surfaceMotionPrev, + float3 reflectedWorldPos, float3 reflectedMotionPrev, out bool valid) { + float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed + float3 previousSurfacePos = surfacePos - surfaceMotionPrev; + float3 prevHit = reflectedWorldPos - reflectedMotionPrev; + float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - previousSurfacePos, n); + return projectPrevNdc(mirroredHit, valid); +} + +// Reflection guides remain owned by the physical foreground interface even when transmission later +// replaces the ordinary guide tuple. The same non-SER probe resolves both motion and, for exact +// mirrors, the reflected terminal's diffuse content. Destinations without a stable diffuse albedo keep +// the foreground material reflectance: sky, emissive surfaces, metals, and another dielectric/mirror +// would otherwise give RR a zero or recursively defined demodulation signal. +public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, + float2 currentNdc, float2 size, float primaryConeSpread, + out float3 specAlbedo) { + specAlbedo = surface.albedo; + if (length(surface.normal) < 0.5 + || max(surface.albedo.r, max(surface.albedo.g, surface.albedo.b)) <= 0.001 + || surface.roughness > SPEC_MOTION_ALPHA_MAX) { + return float2(0.0, 0.0); + } + + float3 surfacePos = surface.camRel + worldPush.camOffset; + float3 n = normalize(surface.normal); + float3 specDir = reflect(primaryDir, n); + + // Pass A stops radiance traversal at the first split, so reflection motion keeps one dedicated, + // deterministic guide probe. + traceGuide(CULL_SECONDARY, + offsetSurfaceOrigin(surfacePos, surface.biasNormal, specDir, SURF_BIAS), + RAY_TMIN, specDir, 10000.0, + max(length(surface.camRel) * primaryConeSpread, RAY_CONE_MIN_WIDTH), + max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); + float3 reflectedHit; + float3 reflectedMotionPrev; + if (payload.hitT > 0.0) { + reflectedHit = surfacePos + specDir * payload.hitT; + uint reflectedMaterial = payloadMaterial(); + // Closest-hit writes motionPrev for every material. Terrain/water are zero; dynamic opaque and + // dielectric entities carry their per-vertex/object displacement. + reflectedMotionPrev = payload.motionPrev; + if (surface.roughness <= MIRROR_ALPHA_MAX + && (reflectedMaterial == MATERIAL_OPAQUE + || reflectedMaterial == MATERIAL_PARTICLE) + && payloadEmission() <= 0.0) { + float3 reflectedDiffuse = reflectedMaterial == MATERIAL_PARTICLE + ? float3(payload.albedo) + : float3(payload.albedo) * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); + if (max(reflectedDiffuse.r, max(reflectedDiffuse.g, reflectedDiffuse.b)) > 0.001) { + specAlbedo = surface.albedo * reflectedDiffuse; + } + } + } else { + reflectedHit = surfacePos + specDir * 1.0e6; + reflectedMotionPrev = float3(0.0, 0.0, 0.0); + } + float3 previousN = length(surface.previousNormal) >= 0.5 + ? normalize(surface.previousNormal) : n; + bool prevValid; + float2 prevNdc = previousReflectionNdc( + surfacePos, previousN, surface.motionPrev, + reflectedHit, reflectedMotionPrev, prevValid); + return prevValid ? (prevNdc - currentNdc) * 0.5 * size : float2(0.0, 0.0); +} + +public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, + float roughness, float3 diffuseAlbedo) { + gv_hitCamRel = hitCamRel; + gv_motionObjDisp = motionPrev; + gv_motionUseRefracted = true; + gv_normal = normal; + gv_rough = roughness; + gv_albedo = diffuseAlbedo; +} + +// Deterministic ordinary guide behind the first transmitted interface. This is guide-only work: +// radiance reflection/transmission continuations are queued at the first split and traced by Pass B. +public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, + float3 surfaceBiasNormal, MediumStack medium, float rayBias, + float rayConeWidth, float rayConeSpread, float3 guideFilter) { + if (dot(transmittedDir, transmittedDir) <= 0.0) return; + + float3 direction = normalize(transmittedDir); + float3 ro = offsetSurfaceOrigin(surfacePos, surfaceBiasNormal, direction, rayBias); + + // The camera interface consumed bounce 0; the remaining configured bounce budget is the natural + // cap for deterministic guide crossings too. + for (uint crossing = 0u; crossing < worldPush.maxBounces; ++crossing) { + traceGuide(CULL_PRIMARY, ro, RAY_TMIN, direction, 10000.0, + rayConeWidth, rayConeSpread); + if (payload.hitT <= 0.0) { + setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, + float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, + guideFilter * SKY_DIFF_ALBEDO); + return; + } + + uint material = payloadMaterial(); + float3 interfacePos = ro + direction * payload.hitT; + if (material == MATERIAL_OPAQUE || material == MATERIAL_PARTICLE) { + float endpointRoughness = material == MATERIAL_PARTICLE + ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); + float3 endpointAlbedo = material == MATERIAL_PARTICLE + ? payload.albedo + : payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); + setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, + payload.normal, endpointRoughness, guideFilter * endpointAlbedo); + return; + } + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; + + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 geometricNormal = normalize(payload.normal); + float3 interfaceNormal = geometricNormal; + if (isWater && (worldPush.flags & 16u) != 0u) { + float waterFootprint = rayConeWidth + / max(abs(dot(-direction, geometricNormal)), 0.2); + interfaceNormal = applyWaterWaves(geometricNormal, + interfacePos.xz + worldPush.waterAnchor.xy, + worldPush.waterParams.w, waterFootprint); + } + + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), + payload.albedo, transmission); + float etaT = entering ? entered.ior : medium.outer.ior; + float3 nextDirection = refract(direction, interfaceNormal, medium.current.ior / etaT); + if (dot(nextDirection, nextDirection) <= 0.0) { + // Never let a TIR reflection become ordinary diffuse/depth. + setTransmissionGuide(interfacePos - worldPush.camOffset, + isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), + interfaceNormal, 0.0, float3(0.0, 0.0, 0.0)); + return; + } + if (!isWater && entering) { + guideFilter *= payload.albedo; + } + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + direction = normalize(nextDirection); + ro = offsetSurfaceOrigin(interfacePos, geometricNormal, direction, + isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS); + } +} + +// Resolve the captured gv_* guide state into the DLSS-RR guide images after Pass A queues continuations. +public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, float primaryConeSpread) { + // Reflection remains owned by the foreground interface even when a dielectric replaced the ordinary + // tuple with the transmitted destination. + float3 specAlbedo; + float2 specMotion = resolveSpecularGuides( + gv_spec, primaryDir, jndc, size, primaryConeSpread, specAlbedo); + + // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative + // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer + // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. + // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. + float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); + float depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; + + // Motion vector: reproject this guide point through the previous frame's view-projection. + // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is + // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is + // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. + // For a dynamic surface, previous-frame position is the current point minus its world-space + // displacement; subtracting gv_motionObjDisp de-cameras the MV. + float4 prevClip = mul(worldPush.prevViewProj, + float4(gv_hitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); + float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc + // Transmitted content is its own feature: compare its previous and current projections rather than + // subtracting the primary interface's jittered NDC. + float4 curClipMotion = gv_motionUseRefracted + ? mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)) + : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform + // Guard both perspective divides the way the depth above does. A guide point can land behind either + // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the + // frustum as the camera turns — and w at or below zero turns the divide into an arbitrarily large + // vector that DLSS-RR reprojects with. No valid previous position exists in that case, so report no + // motion and let RR fall back instead of handing it garbage. + float2 motion; + if (prevClip.w > 0.0 && (!gv_motionUseRefracted || curClipMotion.w > 0.0)) { + if (gv_motionUseRefracted) curNdc = curClipMotion.xy / curClipMotion.w; + motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; + } else { + motion = float2(0.0, 0.0); + } + + // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. + gNormal[pix] = float4(gv_normal, gv_rough); + gAlbedo[pix] = float4(gv_albedo, 1.0); + gDepth[pix] = depth; + gMotion[pix] = motion; + gSpecAlbedo[pix] = float4(specAlbedo, 1.0); + gSpecMotion[pix] = specMotion; +} + +// 48-byte queue record. float3 + uint intentionally share the first 16-byte lane; every remaining + +public void writeDebugView(int2 pix) { + float4 normalRough = gNormal[pix]; + float3 dbg; + if (pc.debugView == 1u) { + dbg = normalRough.xyz * 0.5 + 0.5; + } else if (pc.debugView == 2u) { + dbg = gAlbedo[pix].rgb; + } else if (pc.debugView == 3u) { + dbg = float3(gDepth[pix], gDepth[pix], gDepth[pix]); + } else if (pc.debugView == 4u) { + dbg = float3(normalRough.w, normalRough.w, normalRough.w); + } else if (pc.debugView == 6u) { + dbg = gSpecAlbedo[pix].rgb; + } else if (pc.debugView == 7u) { + dbg = float3(clamp(0.5 + gSpecMotion[pix] * 0.05, 0.0, 1.0), 0.5); + } else { + dbg = float3(clamp(0.5 + gMotion[pix] * 0.05, 0.0, 1.0), 0.5); + } + outImage[pix] = float4(dbg, 1.0); +} diff --git a/shaders/world/lighting.slang b/shaders/world/lighting.slang new file mode 100644 index 00000000..0789b4f8 --- /dev/null +++ b/shaders/world/lighting.slang @@ -0,0 +1,325 @@ +// Direct lighting: the emitter light grid, RIS reservoirs, and reservoir shading. +// INDIRECT PASS ONLY — the primary pass shades nothing. Depends on trace and math. + +// contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted). + +import world_common; +import world_core; +import math; +import trace; + +public struct Reservoir { + public float3 pos; // light sample point (rebased world space) + public float3 lnrm; // emitter outward normal + public float3 le; // emitter radiance + public float area; // emitter rectangle area (the area-light pdf factor) + public float M; + public float W; + public float wSum; + public float phat; +}; + +public Reservoir resEmpty() { + Reservoir r; + r.pos = float3(0.0, 0.0, 0.0); + r.lnrm = float3(0.0, 0.0, 0.0); + r.le = float3(0.0, 0.0, 0.0); + r.area = 0.0; + r.M = 0.0; + r.W = 0.0; + r.wSum = 0.0; + r.phat = 0.0; + return r; +} + +// Unshadowed contribution of a fixed light sample point at surface (hitPos,n,v); out p-hat = its +// luminance (the resampling target). Evaluated from the UNBIASED hitPos — SURF_BIAS is applied only to +// the shadow-ray origin in shadeReservoir, on whichever side the survivor sample lands. Three receiver +// modes share one target function because they are mutually exclusive per candidate: +// - front hemisphere (ndl>0): the same Lambert(+GGX) BRDF split the sun NEE uses; +// - twoSided (particles): billboards have no back face, ndl=abs(ndl) folds both sides into the front; +// - back hemisphere with sss>0 (LabPBR leaves/grass): HG transmission phase — cosT = dot(wi,rd) peaks +// when the ray points from the light through the slab toward the camera (same as the sun SSS term). +public float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n, + float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, + bool twoSided, float sss, out float phat) { + phat = 0.0; + float3 toL = sp - hitPos; + float dist2 = dot(toL, toL); + if (dist2 < 1.0e-6) { + return float3(0.0, 0.0, 0.0); + } + float dist = sqrt(dist2); + float3 wi = toL / dist; + float cosL = max(0.0, dot(lnrm, -wi)); + if (cosL <= 0.0) { + return float3(0.0, 0.0, 0.0); // receiver behind the emitter face + } + float ndl = dot(n, wi); + if (twoSided) { + ndl = abs(ndl); + } + float3 contrib = float3(0.0, 0.0, 0.0); + if (ndl > 0.0) { + float G = ndl * cosL / dist2; // area-light geometry term (x area) + float3 brdf = diffAlb * INV_PI; + // twoSided callers are particle billboards, which pass rough=1/F0=0 and want diffuse-only + // shading: fresnelSchlick still returns up to full white at grazing angles even with F0=0 (that + // IS the Fresnel effect), so skipping the term here — not just zeroing F0 — is what keeps + // billboards from picking up a rim highlight they were never meant to have. + if (!twoSided) { + float3 h = normalize(wi + v); + float ndh = max(0.0, dot(n, h)); + float ndv = max(1.0e-4, dot(n, v)); + float vdh = max(0.0, dot(v, h)); + float D = ggxD(ndh, rough); + float Gs = ggxG1(ndv, rough) * ggxG1(ndl, rough); + float3 Fs = fresnelSchlick(vdh, F0); + brdf += (D * Gs) * Fs / (4.0 * ndv * ndl); + } + contrib = brdf * le * (G * area); + } else if (sss > 0.0) { + float backNdl = -ndl; + float G = backNdl * cosL / dist2; + float cosT = dot(wi, rd); + contrib = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * le * (G * area); + } + phat = luminance(contrib); + return contrib; +} + +// Select one light from the emitted-power distribution in O(1). +public void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { + float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); + uint column = min(uint(aliasSample), worldPush.lightCount - 1u); + if (pc.lightAliasAddr != 0) { + LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; + bool self = aliasSample - float(column) < a.accept; + lightIndex = self ? column : a.aliasIndex; + } else { + lightIndex = column; + } +} + +public bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { + cell.spanOffset = 0u; + cell.spanCount = 0u; + cell.invWeightSum = 0.0; + cellCoord = int3(0, 0, 0); + if (pc.lightGridCellAddr == 0 || pc.lightGridSpanAddr == 0) return false; + int3 coord = int3(floor((p - worldPush.lightGridOrigin.xyz) / worldPush.lightGridOrigin.w)); + if (coord.x < 0 || coord.y < 0 || coord.z < 0 + || coord.x >= worldPush.lightGridDims.x + || coord.y >= worldPush.lightGridDims.y + || coord.z >= worldPush.lightGridDims.z) return false; + uint linear = (uint(coord.z) * uint(worldPush.lightGridDims.y) + uint(coord.y)) + * uint(worldPush.lightGridDims.x) + uint(coord.x); + cell = ConstPtr(pc.lightGridCellAddr)[linear]; + cellCoord = coord; + return cell.spanCount > 0u; +} + +public void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, + out uint lightIndex) { + float aliasSample = rndf(proposalSeed) * float(lightCount); + uint column = min(uint(aliasSample), lightCount - 1u); + LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; + bool self = aliasSample - float(column) < alias.accept; + uint localIndex = self ? column : alias.aliasIndex; + lightIndex = firstLight + localIndex; +} + +public float unpackUnsignedFloat(uint bits, uint mantissaBits) { + uint mantissaMask = (1u << mantissaBits) - 1u; + uint mantissa = bits & mantissaMask; + uint exponent = (bits >> mantissaBits) & 31u; + if (exponent == 0u) { + return float(mantissa) * exp2(float(1 - 15 - int(mantissaBits))); + } + return (1.0 + float(mantissa) / float(1u << mantissaBits)) + * exp2(float(int(exponent) - 15)); +} + +public float3 lightRadiance(Light light) { + uint packed = light.le; + return float3(unpackUnsignedFloat(packed & 0x7ffu, 6u), + unpackUnsignedFloat((packed >> 11u) & 0x7ffu, 6u), + unpackUnsignedFloat((packed >> 22u) & 0x3ffu, 5u)); +} + +public int3 lightSectionCoord(Light light) { + uint packed = light.section; + return int3(int(packed & 0x3ffu), int((packed >> 10u) & 0x3ffu), + int((packed >> 20u) & 0x3ffu)); +} + +// The half axes share three lanes two-at-a-time; see the Light record in world_common. +public float3 lightHalfU(Light light) { + return float3(unpackHalf2(light.halfUxy), unpackHalf2(light.halfUzVx).x); +} + +public float3 lightHalfV(Light light) { + return float3(unpackHalf2(light.halfUzVx).y, unpackHalf2(light.halfVyz)); +} + +// U x V drives both the emitter normal and the rectangle area, so it is computed once and shared. +public float3 lightCrossUV(Light light) { + return cross(lightHalfU(light), lightHalfV(light)); +} + +// The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store. +public float lightArea(Light light) { + return 4.0 * length(lightCrossUV(light)); +} + +public float3 lightGeometricNormal(Light light) { + float3 normal = normalize(lightCrossUV(light)); + return (light.section & 0x40000000u) != 0u ? -normal : normal; +} + +public float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, + float localProbability) { + float power = max(0.0, lightArea(light) * luminance(le)); + float globalPdf = pc.lightAliasAddr != 0 + ? power * worldPush.lightRebase.w : 1.0 / float(worldPush.lightCount); + float localPdf = 0.0; + if (localProbability > 0.0 && power > 0.0) { + int3 delta = lightSectionCoord(light) - cellCoord; + if (all(abs(delta) <= int3(2, 2, 2))) { + float3 deltaF = float3(delta); + localPdf = power * cell.invWeightSum / max(1.0, dot(deltaF, deltaF)); + } + } + return localProbability * localPdf + (1.0 - localProbability) * globalPdf; +} + +public void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, + out uint lightIndex) { + ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); + float aliasSample = rndf(proposalSeed) * float(cell.spanCount); + uint column = min(uint(aliasSample), cell.spanCount - 1u); + LightGridSpan span = spans[cell.spanOffset + column]; + bool self = aliasSample - float(column) < span.accept; + uint firstLight = self ? span.firstLight : span.aliasFirstLight; + uint lightCount = self ? (span.packedLightCounts & 0xffffu) + : (span.packedLightCounts >> 16u); + selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex); +} + +// Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section +// by emitted power and section distance, then its section-local power alias. Every light in +// the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. +// Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single +// Light48 load: section power cancels with the section-local light PDF. +public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, + out uint lightIndex) { + if (useLocal) { + selectLightGridSpanLight(cell, proposalSeed, lightIndex); + } else { + selectGlobalLight(proposalSeed, lightIndex); + } +} + +// ---- RIS cost profile. Temporary probes pinned the candidate walk out of the shader to bound what +// memory-side RIS work could ever be worth. Against an +// 18.0ms frame at M=8, removing every dependent load (span -> alias -> record) and all lane divergence +// saved 5.9ms — a third of the frame. Split by vertex that was ~4.3ms at secondary vertices against +// ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost +// was chasing depth, not divergence, and it lived at secondary vertices. A presampled candidate pool +// would attack the same 5.9ms structurally, but see the note on +// proposalSeed in tracePath for why it should share the pool rather than the seed. + +// Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen +// sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. +public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, + float rough, bool twoSided, float sss, inout uint seed, inout uint proposalSeed) { + Reservoir r = resEmpty(); + LightGridCell gridCell; + int3 gridCellCoord; + float3 gridLookup = hitPos; + if (pc.lightGridCellAddr != 0) { + // Stochastically blend across hard section boundaries. Every cell proposal retains full global + // support, so conditioning on this independently jittered lookup remains unbiased. + gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5) + * worldPush.lightGridOrigin.w; + } + bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); + uint candidateCount = worldPush.risCandidates; + r.M = float(candidateCount); + // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six + // local and two global candidates, with every lane taking the same branch for a given candidate. + // Counts that are not divisible by four use the nearest practical split with at least one global + // candidate; M=1 is global-only so the estimator retains full support. + uint globalCandidateCount = hasGridCell + ? max(1u, (candidateCount + 2u) / 4u) : candidateCount; + uint localCandidateCount = candidateCount - globalCandidateCount; + float localProbability = float(localCandidateCount) / float(candidateCount); + for (uint c = 0u; c < candidateCount; c++) { + uint li; + uint globalsBefore = (c * globalCandidateCount) / candidateCount; + uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; + bool useLocal = hasGridCell && globalsAfter == globalsBefore; + selectLightGridLight(gridCell, useLocal, proposalSeed, li); + Light lg = ConstPtr(pc.lightBufAddr)[li]; + // Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area). + float s = rndf(seed) * 2.0 - 1.0; + float t = rndf(seed) * 2.0 - 1.0; + float3 sp = lg.pos + worldPush.lightRebase.xyz + + s * lightHalfU(lg) + t * lightHalfV(lg); + float3 le = lightRadiance(lg); + float3 lightNormal = lightGeometricNormal(lg); + float area = lightArea(lg); + float phat; + evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd, + diffAlb, F0, rough, twoSided, sss, phat); + if (phat <= 0.0) { + continue; + } + float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability); + float w = phat / max(sourcePdf, 1.0e-20); + r.wSum += w; + if (rndf(seed) * r.wSum < w) { // weighted reservoir update + r.pos = sp; + r.lnrm = lightNormal; + r.le = le; + r.area = area; + r.phat = phat; + } + } + r.W = r.phat > 0.0 ? (r.wSum / (r.M * r.phat)) : 0.0; + return r; +} + +// Shade a finalized reservoir: recompute the survivor's contribution at (hitPos,n,v), cast ONE shadow +// ray, and return throughput-free radiance contrib*vis*W. The one ray serves whichever term fired for +// the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the +// sample's side of the surface. +public float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, + float3 F0, float rough, bool twoSided, float sss) { + if (s.W <= 0.0 || s.phat <= 0.0) { + return float3(0.0, 0.0, 0.0); + } + float phat; + float3 contrib = evalSampleContrib(s.pos, s.lnrm, s.le, s.area, hitPos, n, v, rd, + diffAlb, F0, rough, twoSided, sss, phat); + if (phat <= 0.0) { + return float3(0.0, 0.0, 0.0); + } + // Pick the shadow-ray origin on the sample's side of the surface FIRST, then measure the ray from + // that origin — measuring dist from hitPos while tracing from the biased origin shifts the ray tip + // up to SURF_BIAS*ndl toward the emitter plane, beating the 0.001*dist stop-short margin for lights + // closer than ~SURF_BIAS/0.001 blocks -> shadow ray hits the emitter's own face -> per-texel black. + float side = dot(n, s.pos - hitPos) >= 0.0 ? 1.0 : -1.0; + float3 origin = hitPos + side * n * SURF_BIAS; + float3 toL = s.pos - origin; + float dist = length(toL); + // Stop just short of the sample point so the ray doesn't self-occlude on the emitter's own face. + VisibilityResult shadow = visibility(origin, toL / dist, dist * 0.999); + float3 vis = shadow.transmittance; + return contrib * vis * s.W; +} + +// TLAS instance-mask bits (set on the Java side, ANDed against these per-ray cull masks): +// bit 0 (0x01) — secondary rays: shadows, GI bounces, reflections. +// bit 1 (0x02) — the primary camera ray. +// Terrain / entities / block entities keep 0xFF (both bits). Particles use 0x02: primary-visible diff --git a/shaders/world/math.slang b/shaders/world/math.slang new file mode 100644 index 00000000..f4fa270f --- /dev/null +++ b/shaders/world/math.slang @@ -0,0 +1,183 @@ +// Stateless math: luminance, GGX (D/G/VNDF), Fresnel, Henyey-Greenstein, the RR specular-albedo +// fit, the PCG stream, and direction sampling. No bindings, no globals, no rays. + +import world_common; +import world_core; + +public float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } + +// GGX normal distribution. +// NOTE: the 1e-7 denominator guard is not merely a divide-by-zero epsilon, and it is load-bearing. At +// the lobe peak d == a2, so the guard dominates PI*d*d until a2 >> 1.8e-4, i.e. rough >> 0.116; every +// smoother material has its NEE highlight peak truncated. That truncation is what keeps the sun from +// being counted twice: a specular continuation sets showCelestial, so the BSDF-sampled ray may hit the +// sun disc in world.rmiss while NEE is also adding a sun term for the same lobe, with no MIS weighting +// between them. Removing the guard restores the physical peak AND the double count, and makes the +// single-sample sun estimator explode wherever the lobe is tighter than the disc (~0.0047 rad). The +// real fix is MIS (or a disc-aware specular light sample), not an epsilon edit — left as-is. +public float ggxD(float ndh, float alpha) { + float a2 = alpha * alpha; + float d = ndh * ndh * (a2 - 1.0) + 1.0; + return a2 / (PI * d * d + 1.0e-7); +} + +// Smith GGX masking term for one direction (separable form; G2 = G1(v) * G1(l)). +public float ggxG1(float ndx, float alpha) { + float a2 = alpha * alpha; + return 2.0 * ndx / (ndx + sqrt(a2 + (1.0 - a2) * ndx * ndx) + 1.0e-7); +} + +public float3 fresnelSchlick(float cosT, float3 f0) { + float m = clamp(1.0 - cosT, 0.0, 1.0); + float m2 = m * m; + return f0 + (1.0 - f0) * (m2 * m2 * m); +} + +// Henyey–Greenstein phase function (with 4π normalisation so it integrates to 1 over the sphere). +// g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). +public static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional +public static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) +// Keep SSS on the first two Pass B hits. Pass A's primary/interface prefix is outside this local depth. +public static const int MAX_SSS_INDIRECT_DEPTH = 1; +public float hg(float cosT, float g) { + float g2 = g * g; + return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); +} + +// NVIDIA RR guide-buffer helper: integrated/view-dependent specular reflectivity, not raw F0. +// alpha is GGX alpha (== the linear roughness materials store), NoV is the first-hit view cosine. +public float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { + NoV = abs(NoV); + float NoV2 = NoV * NoV; + float alpha2 = alpha * alpha; + float4 X = float4(1.0, NoV, NoV2, NoV * NoV2); + float4 Y = float4(1.0, alpha, alpha2, alpha * alpha2); + + float2 m1 = float2( + dot(float2(0.99044, -1.28514), X.xy), + dot(float2(1.29678, -0.755907), X.xy)); + float3 Xxyw = float3(X.x, X.y, X.w); + float3 m2 = float3( + dot(float3(1.0, 2.92338, 59.4188), Xxyw), + dot(float3(20.3225, -27.0302, 222.592), Xxyw), + dot(float3(121.563, 626.13, 316.627), Xxyw)); + float bias = dot(m1, Y.xy) / max(dot(m2, float3(Y.x, Y.y, Y.w)), 1.0e-7); + + float2 m3 = float2( + dot(float2(0.0365463, 3.32707), X.xy), + dot(float2(9.0632, -9.04756), X.xy)); + float3 Xxzw = float3(X.x, X.z, X.w); + float3 m4 = float3( + dot(float3(1.0, 3.59685, -1.36772), Xxzw), + dot(float3(9.04401, -16.3174, 9.22949), Xxzw), + dot(float3(5.56589, 19.7886, -20.2123), Xxzw)); + float scale = dot(m3, Y.xy) / max(dot(m4, float3(Y.x, Y.y, Y.w)), 1.0e-7); + + bias *= clamp(specularColor.g * 50.0, 0.0, 1.0); + return specularColor * max(0.0, scale) + float3(max(0.0, bias), max(0.0, bias), max(0.0, bias)); +} + +// Exact (unpolarized) Fresnel reflectance for a dielectric interface. cosI is the incidence cosine on +// the incoming side; etaI/etaT are the IORs the ray travels from / into. Returns 1.0 on total internal +// reflection (sin of the transmitted angle >= 1), which matches refract() returning the zero vector — +// so reflect/refract selection and TIR stay consistent. +public float fresnelDielectric(float cosI, float etaI, float etaT) { + float sinT2 = (etaI * etaI) / (etaT * etaT) * max(0.0, 1.0 - cosI * cosI); + if (sinT2 >= 1.0) { + return 1.0; + } + float cosT = sqrt(1.0 - sinT2); + float rs = (etaI * cosI - etaT * cosT) / (etaI * cosI + etaT * cosT); + float rp = (etaI * cosT - etaT * cosI) / (etaI * cosT + etaT * cosI); + return 0.5 * (rs * rs + rp * rp); +} + +// PCG hash RNG. +public uint pcg(inout uint s) { + s = s * 747796405u + 2891336453u; + uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; + return (w >> 22u) ^ w; +} +public float rndf(inout uint s) { + // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first + // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` + // tests (most seriously the F == 1 total-internal-reflection branch). + return float(pcg(s) >> 8u) * (1.0 / 16777216.0); +} + +public float3 primaryRayDir(float2 ndc) { + float4 nearH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 1.0, 1.0)); + float4 farH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 0.0, 1.0)); + float3 nearP = nearH.xyz / nearH.w; + float3 farP = farH.xyz / farH.w; + return normalize(farP - nearP); +} + +public float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { + float2 onePixelNdc = 2.0 / size; + float3 dx = primaryRayDir(ndc + float2(onePixelNdc.x, 0.0)); + float3 dy = primaryRayDir(ndc + float2(0.0, onePixelNdc.y)); + return max(max(length(cross(dir, dx)), length(cross(dir, dy))), RAY_CONE_MIN_SPREAD); +} + +// Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the +// importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation +// throughput is just *= albedo with no PI/cos bookkeeping left over. +public float3 cosineDir(float3 n, inout uint s) { + float u1 = rndf(s); + float u2 = rndf(s); + float r = sqrt(u1); + float phi = 6.2831853 * u2; + float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); + float3 b = normalize(cross(n, t)); + t = cross(b, n); + float3 local = float3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - u1))); + return normalize(local.x * t + local.y * b + local.z * n); +} + +// Soft shadows: sample a direction within the light's SQUARE angular extent about `axis`. MC's +// sun/moon are square quads, so the NEE shadow ray samples a square (not a cone) — the same square the +// visible disc in world.rmiss uses, built from the same celestial tangent frame (right = arc-travel +// direction, from worldPush.celestial.xyz). Averaged over frames by DLSS-RR this yields soft penumbrae that +// widen with occluder distance (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). +public float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { + float3 right = normalize(cross(axis, worldPush.celestial.xyz)); + float3 up = cross(right, axis); + float t = tan(halfAngle); + float u = (rndf(s) * 2.0 - 1.0) * t; + float v = (rndf(s) * 2.0 - 1.0) * t; + return normalize(axis + u * right + v * up); +} + +// Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the +// sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the +// importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). +public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { + float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); + float3 b = normalize(cross(n, t)); + t = cross(b, n); + float3 Ve = float3(dot(ve, t), dot(ve, b), dot(ve, n)); // view in tangent space (z = n) + float a = alpha; + float3 Vh = normalize(float3(a * Ve.x, a * Ve.y, Ve.z)); + float lensq = Vh.x * Vh.x + Vh.y * Vh.y; + float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); + float3 T2 = cross(Vh, T1); + float u1 = rndf(s); + float u2 = rndf(s); + float r = sqrt(u1); + float phi = 6.2831853 * u2; + float p1 = r * cos(phi); + float p2 = r * sin(phi); + float ss = 0.5 * (1.0 + Vh.z); + p2 = (1.0 - ss) * sqrt(1.0 - p1 * p1) + ss * p2; + float3 Nh = p1 * T1 + p2 * T2 + sqrt(max(0.0, 1.0 - p1 * p1 - p2 * p2)) * Vh; + float3 Ne = normalize(float3(a * Nh.x, a * Nh.y, max(0.0, Nh.z))); // microfacet normal, tangent space + return normalize(Ne.x * t + Ne.y * b + Ne.z * n); // -> world space +} + +public float3 srgbToLinear(float3 c) { + float3 lo = c / 12.92; + float3 hi = pow((c + 0.055) / 1.055, float3(2.4, 2.4, 2.4)); + float3 isHi = step(float3(0.04045, 0.04045, 0.04045), c); + return lerp(lo, hi, isHi); +} diff --git a/shaders/world/medium.slang b/shaders/world/medium.slang new file mode 100644 index 00000000..6f01f521 --- /dev/null +++ b/shaders/world/medium.slang @@ -0,0 +1,86 @@ +// Participating media: the tint-to-extinction mappings and the depth-2 medium stack the dielectric +// interface pushes and pops. Depends on core only. + +// Per-channel Beer–Lambert extinction from a water body's biome tint (carried in payload.albedo for +// water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean +// tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor +// keeps even a white-tinted body very slightly absorbing so deep water never reads as clear vacuum. + +import world_common; +import world_core; + +public static const float WATER_DENSITY = 0.1; +public static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); +public float3 waterExtinction(float3 tint) { + return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); +} + +// A non-water dielectric's tint is a filter over one block of travel rather than a biome water colour, so +// it maps to extinction by inverting Beer-Lambert over that reference distance: transmittance t after one +// block means sigma = -ln(t). `transmission` scales that transmittance, so it keeps the sense the +// material's authored factor has everywhere else — 1 passes the tint through unchanged, 0 is opaque. +// Clamped away from zero so a fully saturated texel stays finite instead of producing an infinite sigma. +public static const float VOLUME_TINT_REFERENCE_DISTANCE = 1.0; // blocks +public float3 volumeExtinction(float3 tint, float transmission) { + float3 blockTransmittance = clamp(clamp(tint, 0.0, 1.0) * clamp(transmission, 0.0, 1.0), + 1.0e-3, 1.0); + return -log(blockTransmittance) / VOLUME_TINT_REFERENCE_DISTANCE; +} + +// ---- Participating medium the path is currently inside. +// +// Refraction needs the RELATIVE index across an interface, so the renderer has to know what it is +// travelling through, not just what it is hitting. `ior` drives Snell/Fresnel; `extinction` drives the +// per-segment Beer-Lambert attenuation. +// +// The stack is depth 2 (current + the one it will return to) held in named fields, NOT an array. A +// dynamically indexed local array lands in scratch memory, and this raygen is already register-bound — +// paying an occupancy hit for nesting that Minecraft does not produce would be a bad trade. Depth 2 +// covers air->water->glass and air->glass->water, which is the realistic worst case; anything deeper +// degrades to air on the way out, and because `entering` is re-derived per face from geometry rather +// than toggled, the path re-synchronises at the next crossing instead of staying corrupted. +public struct Medium { + public float ior; + public float3 extinction; + public bool water; // drives the wave-refraction caustic on submerged receivers; nothing else is water-specific +}; + +public struct MediumStack { + public Medium current; + public Medium outer; +}; + +public Medium airMedium() { + Medium m; + m.ior = 1.0; + m.extinction = float3(0.0, 0.0, 0.0); + m.water = false; + return m; +} + +public MediumStack makeMediumStack(Medium start) { + MediumStack s; + s.current = start; + s.outer = airMedium(); + return s; +} + +public void mediumPush(inout MediumStack stack, Medium entered) { + stack.outer = stack.current; + stack.current = entered; +} + +public void mediumPop(inout MediumStack stack) { + stack.current = stack.outer; + stack.outer = airMedium(); +} + +// Water's tint is a biome colour whose absorption is calibrated per block of depth; any other volume +// dielectric's tint is a filter over a reference block of travel. Both end up as per-channel extinction. +public Medium makeDielectricMedium(bool isWater, float ior, float3 tint, float transmission) { + Medium m; + m.ior = ior; + m.extinction = isWater ? waterExtinction(tint) : volumeExtinction(tint, transmission); + m.water = isWater; + return m; +} diff --git a/shaders/world/segment.slang b/shaders/world/segment.slang new file mode 100644 index 00000000..1ff9d265 --- /dev/null +++ b/shaders/world/segment.slang @@ -0,0 +1,148 @@ +// PathSegment — a resumable continuation — plus its packed 48-byte buffer form. This is the record +// the primary pass writes and the indirect pass reads. Depends on medium. + + +// Everything needed to resume tracing from a point in the scene. The path tracer takes one of these and +// may hand back another, which is how a dielectric's two Fresnel continuations are traced without +// instantiating the path tracer twice: the second branch is DATA, not a second inlined copy. `throughput` +// already carries the branch weight, so segments are simply summed. +// +// This is deliberately shaped like a record that could live in a buffer rather than a register: the +// planned split into a primary/guide pass and an indirect pass hands continuations between dispatches, +// and this is the payload it would write. + +import world_common; +import world_core; +import medium; + +public struct PathSegment { + public float3 ro; + public float3 rd; + public float3 throughput; + public MediumStack medium; + public float rayConeWidth; + public float rayConeSpread; + public uint seed; + public int bounce; // interfaces already consumed, so RR start and the bounce cap stay global + public bool showCelestial; +}; + +public PathSegment makePathSegment(float3 ro, float3 rd, float3 throughput, MediumStack medium, + float rayConeWidth, float rayConeSpread, uint seed, + int bounce, bool showCelestial) { + PathSegment s; + s.ro = ro; + s.rd = rd; + s.throughput = throughput; + s.medium = medium; + s.rayConeWidth = rayConeWidth; + s.rayConeSpread = rayConeSpread; + s.seed = seed; + s.bounce = bounce; + s.showCelestial = showCelestial; + return s; +} +// field is a uint, so Std430DataLayout gives this an exact 48-byte stride. +public struct PackedPathSegment { + public float3 ro; + public uint rd; + public uint throughput; + public uint currentExtinction; + public uint outerExtinction; + public uint mediumIors; + public uint rayCone; + public uint seed; + public uint pathFlags; + public uint nextRecord; +}; + +public static const uint PATH_NO_NEXT = 0xffffffffu; + +public float2 octEncode(float3 direction) { + float3 n = direction / max(abs(direction.x) + abs(direction.y) + abs(direction.z), 1.0e-20); + float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + float2 oct = n.z >= 0.0 ? n.xy : (1.0 - abs(n.yx)) * signNotZero; + return oct * 0.5 + 0.5; +} + +public float3 octDecode(float2 encoded) { + float2 oct = encoded * 2.0 - 1.0; + float3 n = float3(oct, 1.0 - abs(oct.x) - abs(oct.y)); + if (n.z < 0.0) { + float2 signNotZero = float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + n.xy = (1.0 - abs(n.yx)) * signNotZero; + } + return normalize(n); +} + +public uint packUnorm16x2(float2 v) { + uint2 q = uint2(round(clamp(v, 0.0, 1.0) * 65535.0)); + return q.x | (q.y << 16u); +} + +public float2 unpackUnorm16x2(uint p) { + return float2(p & 0xffffu, p >> 16u) / 65535.0; +} + +// DXGI_FORMAT_R9G9B9E5_SHAREDEXP, implemented locally because Slang exposes no packing intrinsic. +public uint packRgb9e5(float3 value) { + float3 v = clamp(value, 0.0, 65408.0); + float maxChannel = max(v.x, max(v.y, v.z)); + uint exponent = maxChannel < exp2(-16.0) + ? 0u : uint(floor(log2(maxChannel))) + 16u; + exponent = min(exponent, 31u); + float scale = exp2(float(int(exponent) - 24)); + uint maxMantissa = uint(floor(maxChannel / scale + 0.5)); + if (maxMantissa == 512u && exponent < 31u) { + exponent++; + scale *= 2.0; + } + uint3 mantissa = min(uint3(floor(v / scale + 0.5)), uint3(511u, 511u, 511u)); + return mantissa.x | (mantissa.y << 9u) | (mantissa.z << 18u) | (exponent << 27u); +} + +public float3 unpackRgb9e5(uint p) { + float scale = exp2(float(int(p >> 27u) - 24)); + return float3(p & 0x1ffu, (p >> 9u) & 0x1ffu, (p >> 18u) & 0x1ffu) * scale; +} + +public PackedPathSegment packPathSegment(PathSegment seg, uint nextRecord) { + PackedPathSegment p; + p.ro = seg.ro; + p.rd = packUnorm16x2(octEncode(seg.rd)); + p.throughput = packRgb9e5(seg.throughput); + p.currentExtinction = packRgb9e5(seg.medium.current.extinction); + p.outerExtinction = packRgb9e5(seg.medium.outer.extinction); + p.mediumIors = packHalf2(float2(seg.medium.current.ior, seg.medium.outer.ior)); + p.rayCone = packHalf2(float2(seg.rayConeWidth, seg.rayConeSpread)); + p.seed = seg.seed; + p.pathFlags = (uint(seg.bounce) & 15u) + | (seg.showCelestial ? 1u << 8u : 0u) + | (seg.medium.current.water ? 1u << 9u : 0u) + | (seg.medium.outer.water ? 1u << 10u : 0u); + p.nextRecord = nextRecord; + return p; +} + +public PathSegment unpackPathSegment(PackedPathSegment p) { + float2 iors = unpackHalf2(p.mediumIors); + Medium current; + current.ior = iors.x; + current.extinction = unpackRgb9e5(p.currentExtinction); + current.water = (p.pathFlags & (1u << 9u)) != 0u; + Medium outer; + outer.ior = iors.y; + outer.extinction = unpackRgb9e5(p.outerExtinction); + outer.water = (p.pathFlags & (1u << 10u)) != 0u; + MediumStack medium; + medium.current = current; + medium.outer = outer; + float2 cone = unpackHalf2(p.rayCone); + return makePathSegment(p.ro, octDecode(unpackUnorm16x2(p.rd)), + unpackRgb9e5(p.throughput), medium, cone.x, cone.y, p.seed, + int(p.pathFlags & 15u), (p.pathFlags & (1u << 8u)) != 0u); +} + +// Walk only the visually-primary dielectric chain. The terminal non-dielectric/miss trace is repeated +// by the indirect pass, so the returned record describes the state immediately BEFORE that trace. +// This keeps closest-hit payload data out of the queue and, critically, writes the record before any diff --git a/shaders/world/trace.slang b/shaders/world/trace.slang new file mode 100644 index 00000000..8da3f482 --- /dev/null +++ b/shaders/world/trace.slang @@ -0,0 +1,113 @@ +// Ray dispatch: SBT/cull constants, payload construction, ordinary/reordered radiance, guide probes, +// and shadow visibility. Depends on core. + +// receiver billboards only. The first-person camera owner uses 0x01 (secondary only). + +import world_common; +import world_core; + +public static const uint CULL_SECONDARY = 0x01u; +public static const uint CULL_PRIMARY = 0x02u; +public static const uint TERRAIN_BUCKETS = 4u; +public static const uint SBT_RADIANCE = 0u; +public static const uint SBT_SHADOW = TERRAIN_BUCKETS; +public static const uint SBT_STRIDE_BUCKET = 1u; +public static const uint MISS_RADIANCE = 0u; +public static const uint MISS_GUIDE = 1u; + +public RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { + RayDesc r; + r.Origin = origin; + r.TMin = tmin; + r.Direction = dir; + r.TMax = tmax; + return r; +} + +public float3 offsetSurfaceOrigin(float3 position, float3 surfaceNormal, + float3 outgoingDirection, float bias) { + float3 sideNormal = dot(surfaceNormal, outgoingDirection) >= 0.0 + ? surfaceNormal : -surfaceNormal; + return position + sideNormal * bias; +} + +// A radiance payload carrying only the two words the invoked hit/miss shader READS: `flags` +// (show-celestial gate, consumed by world.rmiss) and the packed ray cone (consumed by world.rchit for +// texture LOD). Every other member is an OUTPUT that world.rchit/world.rmiss write, so it carries no +// information into a trace and is initialized to a neutral value here. +public Payload makeRadiancePayload(uint flags, uint rayCone) { + Payload p; + p.albedo = half3(0.0h, 0.0h, 0.0h); + p.normal = half3(0.0h, 0.0h, 0.0h); + p.hitT = -1.0; // miss sentinel: world.rmiss writes only albedo, so a miss leaves this negative + p.motionPrev = half3(0.0h, 0.0h, 0.0h); + p.f0 = half3(0.0h, 0.0h, 0.0h); + p.flags = flags; + p.roughMetal = 0u; + p.emissionSss = 0u; + p.iorTransmission = 0u; + p.rayCone = rayCone; + return p; +} + +// Ordinary radiance trace for latency-sensitive, coherent work such as Pass A. It invokes hit/miss +// shaders directly and therefore requires no invocation-reorder capability. +public void traceRadiance(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + bool showCelestial, float rayConeWidth, float rayConeSpread) { + payload = makeRadiancePayload(showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u, + packHalf2(float2(rayConeWidth, rayConeSpread))); + TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, + SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, + makeRay(ro, tmin, rd, tmax), payload); +} + +// Auxiliary RR probes deliberately use ordinary TraceRay. They are one-off, incoherent rays whose +// result is consumed immediately, so paying a reorder barrier and carrying guide state across it only +// adds latency. The dedicated miss record also avoids the atmosphere march: guides need hit-vs-miss, +// not sky radiance. +public void traceGuide(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + float rayConeWidth, float rayConeSpread) { + payload = makeRadiancePayload(0u, packHalf2(float2(rayConeWidth, rayConeSpread))); + TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, + MISS_GUIDE, makeRay(ro, tmin, rd, tmax), payload); +} + +public Payload makeShadowPayload() { + Payload shadowPayload; + shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); + shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit + 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); + // world_guide.rmiss clears this sentinel. An accepted opaque hit skips closest-hit and therefore + // leaves it set, while ignored translucent/water hits continue until either a later opaque hit or miss. + shadowPayload.flags = 1u; + shadowPayload.roughMetal = 0u; + shadowPayload.emissionSss = 0u; + shadowPayload.iorTransmission = 0u; + shadowPayload.rayCone = 0u; + return shadowPayload; +} + +public VisibilityResult visibility(float3 origin, float3 dir, float tmax) { + // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow + // path uses only albedo as accumulated transmittance and hitT as the nearest-water crossing. + Payload shadowPayload = makeShadowPayload(); + // Shadow SBT records run any-hit only for cutout/translucent/water. Cutout alpha-tests; translucent + // and water tint shadowPayload.albedo and pass through. Solid blocks terminate traversal. There is no + // closest shader worth executing. The lightweight guide miss clears the flags sentinel so this path + // needs only ordinary TraceRay and no invocation-reorder capability. + TraceRay(topLevelAS, + RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER, + CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, MISS_GUIDE, + makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); + VisibilityResult result; + result.transmittance = shadowPayload.flags == 0u + ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); + result.waterHitT = shadowPayload.hitT; + return result; +} + +// `valid` is false when the point projects behind the previous camera, where the perspective divide +// would otherwise manufacture an arbitrarily large NDC. Mirror images reach that case easily: a virtual +// image sits as far behind the reflector as the source is in front, so a reflection seen at a grazing diff --git a/shaders/world/trace_ser.slang b/shaders/world/trace_ser.slang new file mode 100644 index 00000000..043d0f64 --- /dev/null +++ b/shaders/world/trace_ser.slang @@ -0,0 +1,37 @@ +// EXT Shader Execution Reordering radiance dispatch. Kept in a separate module so the ordinary +// indirect shader never imports hit-object operations or their SPIR-V capability. + +import world_common; +import world_core; +import trace; + +// Trace into a hit object, reorder threads by hit coherence, THEN invoke the hit/miss shader. This is +// where the divergent Section/Prim shading work (world.rchit/world.rahit) actually happens, so +// reordering before it is what pays for the reorder's own cost. +// +// The payload is built TWICE from the trace state rather than carried across ReorderThread. Traversal +// runs only world.rahit, which never writes a RADIANCE payload: its terrain tint paths live in the +// translucent/water buckets, which carry no any-hit record for radiance rays, and its entity paths are +// gated on RAY_FLAG_SKIP_CLOSEST_HIT_SHADER. So whatever TraceRay leaves in the payload holds no +// information. Reading it back and carrying it over would pin all ten members — 72 bytes — in registers +// across the one point where SER must spill every live value. Rebuilding costs a few constant moves and +// leaves only these two words spanning the reorder. +public void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, + bool showCelestial, float rayConeWidth, float rayConeSpread, + uint pathPhaseHint) { + uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; + uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); + Payload tracePayload = makeRadiancePayload(flags, rayCone); + HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, + SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, + makeRay(ro, tmin, rd, tmax), tracePayload); + // The hit object groups shader IDs first. Its SBT index then supplies the two-bit geometry/material + // bucket ({solid, cutout, translucent, water}); terrain and entity record ranges are both multiples + // of TERRAIN_BUCKETS, so the low bits have the same meaning. The high two bits describe path lifetime: + // pre-roulette, roulette-active, or guaranteed terminal after this hit. + uint materialHint = hObj.GetShaderTableIndex() & (TERRAIN_BUCKETS - 1u); + uint coherenceHint = (pathPhaseHint << 2u) | materialHint; + ReorderThread(hObj, coherenceHint, 4u); + payload = makeRadiancePayload(flags, rayCone); + HitObject::Invoke(topLevelAS, hObj, payload); +} diff --git a/shaders/world/water.slang b/shaders/world/water.slang new file mode 100644 index 00000000..1766e431 --- /dev/null +++ b/shaders/world/water.slang @@ -0,0 +1,153 @@ +// Water surface: the wave spectrum (with its analytic time derivative), the normal perturbation it +// drives, and the caustic term that differentiates the same field. Depends on core. + + +// Animated surface waves — a directional wave spectrum, gradient-only (the surface stays geometrically +// flat; only the normal tilts). Three things make it read as water rather than animated glass: +// * Sharp-crested profile: each component is h = a·exp(S·(sin φ − 1)) — peaked crests, wide flat +// troughs (pure sines look like rolling jelly). Still C∞ smooth with an exact analytic gradient. +// * Real deep-water dispersion ω = √(g·k): long swell strides past (λ=14 m → ~4.7 m/s) while short +// chop flutters nearly in place — wrong relative speeds are the biggest tell of fake water. +// * Wind + meander: headings spread around a fixed wind direction (tight for the swell, wide for the +// chop), and the largest components' phase is modulated along their crest (chain-ruled into the +// gradient) so wavefronts wander instead of ruling straight periodic lines across the sea. +// The domain is world-anchored (see applyWaterWaves) so the pattern is pinned in world space, and the +// caustics (waterCaustic) differentiate this same field, so they stay in sync with the visible ripples. + +import world_common; +import world_core; + +public static const float WATER_WAVE_STRENGTH = 0.3; // slope scale (higher = choppier glints / more broken reflections) +public static const float WAVE_SPEED = 0.8; // global time scale; dispersion keeps relative speeds physical +public static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each octave 1.5× shorter) +public static const float WAVE_G = 9.81; +public static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction +public static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components +public float waterWaveLodWeight(float wavelength, float footprint) { + float cyclesPerPixel = max(footprint, 0.0) / max(wavelength, 1.0e-4); + return 1.0 - smoothstep(0.25, 0.5, cyclesPerPixel); +} + +// One walk of the wave spectrum, shared by every consumer so the spectrum constants exist once. WITH_DT +// is a generic so the derivative terms are compiled out entirely for callers that only want the +// gradient — the trigonometry is shared, but the chain-rule work is not free. +public void waterWaveSpectrum(float2 p, float t, float footprint, + out float2 grad, out float2 gradDt) { + // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. + const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; + t *= WAVE_SPEED; + grad = float2(0.0, 0.0); + gradDt = float2(0.0, 0.0); + float lambda = 14.0; + for (int i = 0; i < WAVE_COUNT; i++) { + float lodWeight = waterWaveLodWeight(lambda, footprint); + if (lodWeight <= 0.0) break; + float k = 2.0 * PI / lambda; + float w = sqrt(WAVE_G * k); // deep-water dispersion: phase speed √(g/k) + // Heading: deterministic irregular spread about the wind, widening down the spectrum. + float spread = (0.15 + 0.11 * float(i)) * sin(float(i) * 2.4 + 1.3); + float cs = cos(spread), sn = sin(spread); + float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, WAVE_WIND.x * sn + WAVE_WIND.y * cs); + float ph = k * dot(d, p) - w * t + 1.7 * float(i) * float(i); + float2 dph = k * d; + float phDt = -w; // ∂φ/∂t + float2 dphDt = float2(0.0, 0.0); // ∂(∇φ)/∂t + if (i < 4) { // meander only the components whose fronts are long enough to read as lines + float2 pd = float2(-d.y, d.x); + float wph = 0.4 * k * dot(pd, p) + 0.5 * w * t; + ph += WAVE_MEANDER * sin(wph); + dph += (WAVE_MEANDER * cos(wph) * 0.4 * k) * pd; + if (WITH_DT != 0) { + float wphDt = 0.5 * w; + phDt += WAVE_MEANDER * cos(wph) * wphDt; + dphDt = (-WAVE_MEANDER * sin(wph) * wphDt * 0.4 * k) * pd; + } + } + float S = 1.2 + 0.12 * float(i); // crest sharpness: the short chop is choppier + float sinPh = sin(ph); + float cosPh = cos(ph); + float e = exp(S * (sinPh - 1.0)); + // a = Q/k; ∇h = a·S·cos φ·e^{S(sin φ−1)}·∇φ + float amplitude = lodWeight * (Q[i] / k) * S; + grad += amplitude * cosPh * e * dph; + if (WITH_DT != 0) { + // ∂/∂t of the term above, chain-ruled through φ and ∇φ. + gradDt += amplitude * e + * (phDt * (-sinPh + S * cosPh * cosPh) * dph + cosPh * dphDt); + } + lambda /= 1.5; + } + grad *= WATER_WAVE_STRENGTH; + gradDt *= WATER_WAVE_STRENGTH; +} + +public float2 waterWaveGrad(float2 p, float t, float footprint) { + float2 grad, gradDt; + waterWaveSpectrum<0>(p, t, footprint, grad, gradDt); + return grad; +} + +public float2 waterWaveGrad(float2 p, float t) { + return waterWaveGrad(p, t, 0.0); +} + +// Evaluate the current gradient and linearly rewind it to the previous phase in one spectrum walk. +// Frame deltas are bounded on the CPU, so this avoids a second set of trigonometric/exponential work +// while remaining accurate over the sub-frame interval used by reflection reprojection. +public void waterWaveGradTemporal(float2 p, float currentT, float previousT, float footprint, + out float2 currentGrad, out float2 previousGrad) { + float2 gradDt; + waterWaveSpectrum<1>(p, currentT, footprint, currentGrad, gradDt); + previousGrad = currentGrad - (currentT - previousT) * WAVE_SPEED * gradDt; +} + +// Perturb a (near-)horizontal water surface normal by the wave field. Flowing side faces (|n.y| small) +// keep their flat geometric normal. `worldXZ` must be a world-stable coordinate (rebased hit + the pushed +// anchor) so the ripples stay pinned in the world. `nGeo` is already oriented toward the viewer, so the +// down-viewed underside (n.y < 0) takes the mirrored slope (-up). +public float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t, float footprint) { + if (abs(nGeo.y) < 0.5) return nGeo; + float2 grad = waterWaveGrad(worldXZ, t, footprint); + float3 up = normalize(float3(-grad.x, 1.0, -grad.y)); + return nGeo.y >= 0.0 ? up : -up; +} + +// ---- Water caustics. Sunlight refracting through the waved surface converges/diverges before it +// reaches an underwater receiver; the analytic wave field makes the true focusing factor computable +// instead of faked with a scrolling texture. Where the shadow ray of an underwater NEE vertex crossed +// water (returned by visibility() as VisibilityResult.waterHitT), evaluate the horizontal landing +// position of the refracted sun ray as a function of surface position and finite-difference it. The +// caustic intensity is the inverse Jacobian determinant of that surface→floor mapping (area compression = brightening, real +// fold caustics where det → 0). Because this uses the SAME wave field as the visible surface normals, +// the caustic pattern stays in sync with the ripples, and the per-sample sun-quad jitter (sampleSquare) +// shifts the pattern per sample → caustics physically blur with depth under DLSS-RR accumulation. +public static const float CAUSTIC_EPS = 0.25; // finite-difference step (m); also low-passes sub-step wave detail +public static const float CAUSTIC_MAX = 6.0; // focusing clamp: keeps fold-line singularities from becoming fireflies + +// Horizontal landing offset of a refracted sun ray entering the surface at world-stable xz, intersected +// with the receiver plane h metres below. inc = sunlight travel direction (downward, unit). +public float2 causticLanding(float2 xz, float t, float3 inc, float h) { + float2 g = waterWaveGrad(xz, t); + float3 nw = normalize(float3(-g.x, 1.0, -g.y)); + float3 rdw = refract(inc, nw, 1.0 / WATER_IOR); // air→water: never TIR + return xz + rdw.xz * (h / max(-rdw.y, 0.05)); +} + +public float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { + // Grazing light: the receiver-plane intersection degenerates and caustics vanish in reality anyway + // (long slant paths absorb + the pattern smears out) — fade the effect to neutral 1.0. + float fade = smoothstep(0.06, 0.18, lightDir.y); + if (fade <= 0.0) return 1.0; + float h = max(waterDist * lightDir.y, 0.05); // vertical depth of the receiver below the exit point + float t = worldPush.waterParams.w; + float3 inc = -lightDir; + float2 base = exitPos.xz + worldPush.waterAnchor.xy; // world-stable wave domain (same as applyWaterWaves) + float2 p0 = causticLanding(base, t, inc, h); + float2 px = causticLanding(base + float2(CAUSTIC_EPS, 0.0), t, inc, h); + float2 pz = causticLanding(base + float2(0.0, CAUSTIC_EPS), t, inc, h); + float det = abs((px.x - p0.x) * (pz.y - p0.y) - (px.y - p0.y) * (pz.x - p0.x)); + // Flat water ⇒ all landings displace identically ⇒ det = eps² ⇒ focus = 1 (energy-neutral); the + // clamp bounds the fold-line spikes whose energy the surrounding darkening already paid for. + float focus = (CAUSTIC_EPS * CAUSTIC_EPS) / max(det, 1.0e-5); + return lerp(1.0, min(focus, CAUSTIC_MAX), fade); +} diff --git a/shaders/world/world.rahit.slang b/shaders/world/world.rahit.slang index 459b7f9e..09bb6522 100644 --- a/shaders/world/world.rahit.slang +++ b/shaders/world/world.rahit.slang @@ -12,6 +12,7 @@ // a trace. Shadow traversal uses albedo.rgb as accumulated transmittance and hitT as the nearest-water // crossing; radiance cutout paths do not touch either field. import world_common; +import math; [[vk::push_constant]] WorldPushConstants pc; @@ -86,7 +87,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // Radiance rays accept the surface and continue to closest-hit. Shadow rays use the same material // model here so entity dielectrics transmit instead of becoming opaque shadow blockers. bool shadowRay = (RayFlags() & RAY_FLAG_SKIP_CLOSEST_HIT_SHADER) != 0u; - if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_GLASS) { + if (instanceKind == ENTITY_BIT && shadowRay && materialHeader.model == MATERIAL_DIELECTRIC) { float3 tint = lerp(float3(1.0, 1.0, 1.0), srgbToLinear(texel.rgb) * epr.tint.rgb, texel.a); payload.albedo *= half3(tint * clamp(materialHeader.params.w, 0.0, 1.0)); diff --git a/shaders/world/world.rchit.slang b/shaders/world/world.rchit.slang index 264ffc2b..d0bd4c18 100644 --- a/shaders/world/world.rchit.slang +++ b/shaders/world/world.rchit.slang @@ -7,6 +7,7 @@ // through worldPushAddr first would cost an extra global-memory load just to find the address of the // load we actually want. The cold WorldPush struct is dereferenced only for the breaking overlay. import world_common; +import math; [[vk::push_constant]] WorldPushConstants pc; @@ -25,6 +26,13 @@ void payloadSetPacked(inout Payload payload, uint material, float roughness, flo payload.iorTransmission = packHalf2(float2(ior, transmission)); } +// Which side of a dielectric face this hit is on, shared by every hit path. Comes from the face +// orientation, so it is re-derived at each crossing instead of toggled (see PAYLOAD_DIELECTRIC_ENTERING). +void payloadSetDielectric(inout Payload payload, uint material, bool entering) { + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; + if (entering) payload.flags |= PAYLOAD_DIELECTRIC_ENTERING; +} + uint materialEmissionSource(MaterialHeader header, float emission) { if (emission <= 0.0) return EMISSION_SOURCE_NONE; if ((header.features & MATERIAL_FEATURE_SPEC) != 0u) return EMISSION_SOURCE_LAB_PBR; @@ -290,7 +298,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) MaterialHeader header = ConstPtr(pc.materialTableAddr)[pr.materialId]; uint material = header.model; float3 baseAlbedo = srgbToLinear(entityTexel.rgb) * pr.tint.rgb; - float3 albedo = material == MATERIAL_GLASS + float3 albedo = material == MATERIAL_DIELECTRIC ? lerp(float3(1.0, 1.0, 1.0), baseAlbedo, entityTexel.a) : baseAlbedo; Surface surface = evaluateMaterial(header, euvCoord, entityLod, albedo, n, ep0, ep1, ep2, euv[e0], euv[e1], euv[e2], vdir, @@ -315,9 +323,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, material, surface.roughness, surface.metalness, emission, sss, header.params.z, header.params.w, materialEmissionSource(header, emission)); - if (material == MATERIAL_WATER && entering) { - payload.flags |= PAYLOAD_WATER_ENTERING; - } + payloadSetDielectric(payload, material, entering); return; } @@ -333,10 +339,11 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) float3 tint = pr.tint.rgb; // Water prims are single-sided, wound outward from the fluid volume (RtFluidMesher), so the - // UNFLIPPED geometric normal is a reliable "which side is water" signal: a ray travelling against - // it is entering the volume, one travelling with it is exiting. Captured before the toward-viewer - // flip below and carried in the payload so raygen can set inWater from hit orientation instead of - // toggling parity per crossing (a single missing/extra face no longer desyncs the rest of the path). + // UNFLIPPED geometric normal is a reliable "which side of the dielectric" signal: a ray travelling + // against it is entering the volume, one travelling with it is exiting. Captured before the + // toward-viewer flip below and carried in the payload so raygen derives the medium from hit + // orientation instead of toggling parity per crossing (a single missing/extra face no longer + // desyncs the rest of the path). bool entering = dot(WorldRayDirection(), n) < 0.0; // Lever B: per-triangle corner UVs in primitive order. uvs[3*pid + k] is a contiguous, @@ -366,7 +373,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // Stained glass / ice: a thin colored filter resolved in raygen // (material 3). albedo carries the transmission tint = texel rgb mixed toward white by (1 − opacity), // so a more opaque texel tints transmitted light more strongly. - if (materialHeader.model == MATERIAL_GLASS) { + if (materialHeader.model == MATERIAL_DIELECTRIC) { float4 gtex = blockAlbedoAtlas.SampleLevel(uv, blockLod); float3 gtexRgb = srgbToLinear(gtex.rgb); float3 glassAlbedo = lerp(float3(1.0, 1.0, 1.0), gtexRgb * tint, gtex.a); @@ -382,9 +389,10 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payload.hitT = RayTCurrent(); payload.motionPrev = half3(0.0h, 0.0h, 0.0h); payload.f0 = half3(glassSurface.f0); - payloadSetPacked(payload, MATERIAL_GLASS, glassSurface.roughness, + payloadSetPacked(payload, MATERIAL_DIELECTRIC, glassSurface.roughness, glassSurface.metalness, 0.0, 0.0, materialHeader.params.z, materialHeader.params.w, EMISSION_SOURCE_NONE); + payloadSetDielectric(payload, MATERIAL_DIELECTRIC, entering); return; } @@ -417,9 +425,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payloadSetPacked(payload, material, surface.roughness, surface.metalness, surface.emission, surface.sss, materialHeader.params.z, materialHeader.params.w, materialEmissionSource(materialHeader, surface.emission)); - if (material == MATERIAL_WATER && entering) { - payload.flags |= PAYLOAD_WATER_ENTERING; - } + payloadSetDielectric(payload, material, entering); // RIS emitter-NEE membership: raygen gates this emitter's direct-hit emission term (RIS covers it). if ((pr.flags & TERRAIN_PRIM_IN_LIGHT_BUFFER) != 0u) { payload.flags |= PAYLOAD_EMITTER_IN_LIST; diff --git a/shaders/world/world.rgen.slang b/shaders/world/world.rgen.slang index 8f8e1247..eb7623e2 100644 --- a/shaders/world/world.rgen.slang +++ b/shaders/world/world.rgen.slang @@ -1,965 +1,46 @@ -// Perspective path tracer. invViewProj = inverse(proj * viewRot) maps clip to camera-relative world. -// Primary rays are reconstructed from two unprojected points (near and far plane) rather than assuming -// the eye is at the origin: Minecraft bakes view bobbing into the projection, so the effective eye comes -// from the matrix. camOffset shifts camera-relative space into the terrain's rebased coordinates. Depth -// is reversed-Z (near=1, far=0). +// Indirect pass (pass B of the wavefront split — see docs/WAVEFRONT_PLAN.md). // -// Lighting is an iterative path-tracing loop in raygen, not recursion from closest-hit, so -// maxRayRecursionDepth stays 1. At each surface vertex: -// * NEE toward the sun — a single analytic directional light, gated by one hit-object visibility query -// (TerminateOnFirstHit | SkipClosestHit). Lambertian BRDF = albedo/PI. +// Resumes the continuations the primary pass queued and runs the bounce loop: NEE toward the sun, RIS +// emitter lighting, thin-surface SSS, BSDF continuation and Russian roulette. It captures no guides and +// owns no motion vectors — guides and primary are deliberately absent from the include list +// below, so guide state cannot leak into this pass by accident. +// +// Lighting is iterative here rather than recursive from closest-hit, so maxRayRecursionDepth stays 1. +// At each surface vertex: +// * NEE toward the sun — a single analytic directional light, gated by one hit-object visibility +// query (TerminateOnFirstHit | SkipClosestHit). Lambertian BRDF = albedo/PI. // * a cosine-weighted Lambertian continuation ray (throughput *= albedo; the PI and cos cancel). // Sky / ambient light needs no separate AO pass: continuation rays that escape land in world.rmiss, // which returns sky radiance — so soft sky fill and contact AO both fall out of the path integral. -// Russian roulette terminates deep paths. Output is HDR (R16G16B16A16_SFLOAT); the HDR -> LDR tonemap -// happens later at the display.comp seam. -// -// SER: the build emits EXT and NV shader-invocation-reorder variants from this source. // // Each frame emits a single SPP-averaged (still noisy) estimate; temporal convergence is the denoiser's -// job. With RR disabled the output is the raw noisy path trace — a reference view, not a converged image. -import world_common; - -[[vk::push_constant]] WorldPushConstants pc; - -[[vk::binding(0, 0)]] RaytracingAccelerationStructure topLevelAS; -// HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. -[[vk::binding(1, 0)]] [format("rgba16f")] RWTexture2D outImage; -// Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written -// every frame regardless of accumulation. gNormal.w carries roughness; gDepth carries HW reversed-Z depth. -[[vk::binding(3, 0)]] [format("rgba16f")] RWTexture2D gNormal; // xyz world normal, w roughness -[[vk::binding(4, 0)]] [format("rgba16f")] RWTexture2D gAlbedo; // rgb diffuse albedo -[[vk::binding(5, 0)]] [format("r32f")] RWTexture2D gDepth; // HW reversed-Z depth -[[vk::binding(6, 0)]] [format("rg16f")] RWTexture2D gMotion; // screen-space motion (render px) -[[vk::binding(7, 0)]] [format("rgba16f")] RWTexture2D gSpecAlbedo; // specular albedo (0 — diffuse-only) -[[vk::binding(8, 0)]] [format("rg16f")] RWTexture2D gSpecMotion; // reflection MVs for RR - -// Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL -// `worldPush` global). Layout constants generated from this module's SPIR-V — see world_common.WorldPush. -static WorldPush worldPush; - -// The radiance payload remains module-level so tracePath and its guide helpers can share it across -// HitObject trace/invoke calls, mirroring the GLSL rayPayloadEXT global. -static Payload payload; - -struct VisibilityResult { - float3 transmittance; - float waterHitT; -}; - -// First-hit (primary-visibility) guide attributes, captured at bounce 0 of tracePath. The primary ray -// is deterministic (no AA jitter yet), so every SPP sample's bounce 0 yields identical values. -static float3 gv_normal; -static float3 gv_albedo; -static float3 gv_specAlb; // specular albedo fed to DLSS-RR for specular demodulation (0 = pure diffuse). -static float gv_rough; -static float gv_emission; -static uint gv_emissionSource; -// Linear VIEW-SPACE depth guide inputs; sky resolves to a large far value (no negative sentinel). -static float gv_depth; -static float3 gv_hitCamRel; // primary-surface hit position relative to the current camera -static float3 gv_motionHitCamRel; // motion-guide hit position; water tracks the refracted hit instead -static bool gv_motionUseRefracted; // true when the MV tracks the refracted hit (its own reprojection delta) -// World-space displacement of the motion-guide surface since the previous frame (0 for static -// terrain/sky, an entity's per-vertex/object motion for a dynamic hit). -static float3 gv_motionObjDisp; - -uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } -uint payloadEmissionSource() { - return (payload.flags >> PAYLOAD_EMISSION_SOURCE_SHIFT) & PAYLOAD_EMISSION_SOURCE_MASK; -} -// Set by world.rchit only on a MATERIAL_WATER hit (see PAYLOAD_WATER_ENTERING) — whether the ray was -// travelling into the water volume (vs. out of it) at this hit's face. -bool payloadWaterEntering() { return (payload.flags & PAYLOAD_WATER_ENTERING) != 0u; } -bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } -float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } -float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } -float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } -float payloadSss() { return unpackHalf2(payload.emissionSss).y; } -float payloadIor() { return unpackHalf2(payload.iorTransmission).x; } -float payloadTransmission() { return unpackHalf2(payload.iorTransmission).y; } - -static const float PI = 3.14159265359; -static const float INV_PI = 0.31830988618; - -// The analytic sky (gradient + sun/moon discs + stars) is computed in world.rmiss. On a miss, raygen -// reads payload.albedo and accumulates that sky radiance. The directional NEE light is pushed separately -// as worldPush.lightDir / worldPush.lightRadiance / worldPush.sunDir. -static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) -static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface - -// Water is a smooth dielectric. IOR 1.333; absorption is per-block Beer-Lambert extinction. -static const float WATER_IOR = 1.333; -static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. -static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); - -// Stained glass / ice (material 3): a thin colored dielectric. IOR ~1.5 (soda-lime glass). -// Transmitted-ray origin offset when crossing a glass face. Must be SMALLER than the terrain-side -// TRANSLUCENT_INSET (RtTerrain, 2e-4 blocks): glass is recessed into its block by that inset, so a face -// touching a slab/stair leaves a tiny gap before the neighbour's surface. A large bias (SURF_BIAS) would -// restart the ray past that neighbour and see through it; this lands the ray inside the gap so the -// neighbour is still hit, while still clearing the glass surface to avoid self-intersection. -static const float GLASS_TRANSMIT_BIAS = 1.0e-4; - -// Ray-cone texture LOD: raygen tracks a one-pixel footprint along the path, and closest-hit converts it -// to texture mip levels from the local UV/world-space scale. Diffuse/glossy bounces widen the cone so -// indirect texture fetches don't keep sampling mip 0 through broad lobes. -static const float RAY_CONE_MIN_SPREAD = 1.0e-5; -static const float RAY_CONE_MIN_WIDTH = 1.0e-5; -static const float RAY_CONE_DIFFUSE_SPREAD = 0.25; -static const float RAY_CONE_GLOSSY_SPREAD_SCALE = 0.35; - -// Per-channel Beer–Lambert extinction from a water body's biome tint (carried in payload.albedo for -// water hits, or worldPush.waterParams.xyz for the camera's own biome when starting submerged). A blue ocean -// tint (low red) absorbs red fastest → bluer with depth; swamp green-brown shifts the hue. The floor -// keeps even a white-tinted body very slightly absorbing so deep water never reads as clear vacuum. -static const float WATER_DENSITY = 0.05; -static const float3 WATER_ABSORB_FLOOR = float3(0.015, 0.010, 0.008); -float3 waterExtinction(float3 tint) { - return WATER_ABSORB_FLOOR + WATER_DENSITY * (float3(1.0, 1.0, 1.0) - clamp(tint, 0.0, 1.0)); -} - -// Animated surface waves — a directional wave spectrum, gradient-only (the surface stays geometrically -// flat; only the normal tilts). Three things make it read as water rather than animated glass: -// * Sharp-crested profile: each component is h = a·exp(S·(sin φ − 1)) — peaked crests, wide flat -// troughs (pure sines look like rolling jelly). Still C∞ smooth with an exact analytic gradient. -// * Real deep-water dispersion ω = √(g·k): long swell strides past (λ=14 m → ~4.7 m/s) while short -// chop flutters nearly in place — wrong relative speeds are the biggest tell of fake water. -// * Wind + meander: headings spread around a fixed wind direction (tight for the swell, wide for the -// chop), and the largest components' phase is modulated along their crest (chain-ruled into the -// gradient) so wavefronts wander instead of ruling straight periodic lines across the sea. -// The domain is world-anchored (see applyWaterWaves) so the pattern is pinned in world space, and the -// caustics (waterCaustic) differentiate this same field, so they stay in sync with the visible ripples. -static const float WATER_WAVE_STRENGTH = 0.3; // slope scale (higher = choppier glints / more broken reflections) -static const float WAVE_SPEED = 0.8; // global time scale; dispersion keeps relative speeds physical -static const int WAVE_COUNT = 10; // λ = 14 m … 0.38 m (each octave 1.5× shorter) -static const float WAVE_G = 9.81; -static const float2 WAVE_WIND = normalize(float2(1.0, 0.35)); // dominant travel direction -static const float WAVE_MEANDER = 1.1; // crest phase-wobble amplitude (rad) on the long components -float2 waterWaveGrad(float2 p, float t) { - // Per-wave steepness a·k: gentle in the long swell, concentrated in the mid/short chop. - const float Q[WAVE_COUNT] = { 0.030, 0.035, 0.042, 0.050, 0.060, 0.065, 0.065, 0.058, 0.048, 0.038 }; - t *= WAVE_SPEED; - float2 g = float2(0.0, 0.0); - float lambda = 14.0; - for (int i = 0; i < WAVE_COUNT; i++) { - float k = 2.0 * PI / lambda; - float w = sqrt(WAVE_G * k); // deep-water dispersion: phase speed √(g/k) - // Heading: deterministic irregular spread about the wind, widening down the spectrum. - float spread = (0.15 + 0.11 * float(i)) * sin(float(i) * 2.4 + 1.3); - float cs = cos(spread), sn = sin(spread); - float2 d = float2(WAVE_WIND.x * cs - WAVE_WIND.y * sn, WAVE_WIND.x * sn + WAVE_WIND.y * cs); - float ph = k * dot(d, p) - w * t + 1.7 * float(i) * float(i); - float2 dph = k * d; - if (i < 4) { // meander only the components whose fronts are long enough to read as lines - float2 pd = float2(-d.y, d.x); - float wph = 0.4 * k * dot(pd, p) + 0.5 * w * t; - ph += WAVE_MEANDER * sin(wph); - dph += (WAVE_MEANDER * cos(wph) * 0.4 * k) * pd; - } - float S = 1.2 + 0.12 * float(i); // crest sharpness: the short chop is choppier - float e = exp(S * (sin(ph) - 1.0)); - g += (Q[i] / k) * (S * cos(ph) * e) * dph; // a = Q/k; ∇h = a·S·cos φ·e^{S(sin φ−1)}·∇φ - lambda /= 1.5; - } - return g * WATER_WAVE_STRENGTH; -} - -// Perturb a (near-)horizontal water surface normal by the wave field. Flowing side faces (|n.y| small) -// keep their flat geometric normal. `worldXZ` must be a world-stable coordinate (rebased hit + the pushed -// anchor) so the ripples stay pinned in the world. `nGeo` is already oriented toward the viewer, so the -// down-viewed underside (n.y < 0) takes the mirrored slope (-up). -float3 applyWaterWaves(float3 nGeo, float2 worldXZ, float t) { - if (abs(nGeo.y) < 0.5) return nGeo; - float2 grad = waterWaveGrad(worldXZ, t); - float3 up = normalize(float3(-grad.x, 1.0, -grad.y)); - return nGeo.y >= 0.0 ? up : -up; -} - -// ---- Water caustics. Sunlight refracting through the waved surface converges/diverges before it -// reaches an underwater receiver; the analytic wave field makes the true focusing factor computable -// instead of faked with a scrolling texture. Where the shadow ray of an underwater NEE vertex crossed -// water (returned by visibility() as VisibilityResult.waterHitT), evaluate the horizontal landing -// position of the refracted sun ray as a function of surface position and finite-difference it. The -// caustic intensity is the inverse Jacobian determinant of that surface→floor mapping (area compression = brightening, real -// fold caustics where det → 0). Because this uses the SAME wave field as the visible surface normals, -// the caustic pattern stays in sync with the ripples, and the per-sample sun-quad jitter (sampleSquare) -// shifts the pattern per sample → caustics physically blur with depth under DLSS-RR accumulation. -static const float CAUSTIC_EPS = 0.25; // finite-difference step (m); also low-passes sub-step wave detail -static const float CAUSTIC_MAX = 6.0; // focusing clamp: keeps fold-line singularities from becoming fireflies - -// Horizontal landing offset of a refracted sun ray entering the surface at world-stable xz, intersected -// with the receiver plane h metres below. inc = sunlight travel direction (downward, unit). -float2 causticLanding(float2 xz, float t, float3 inc, float h) { - float2 g = waterWaveGrad(xz, t); - float3 nw = normalize(float3(-g.x, 1.0, -g.y)); - float3 rdw = refract(inc, nw, 1.0 / WATER_IOR); // air→water: never TIR - return xz + rdw.xz * (h / max(-rdw.y, 0.05)); -} - -float waterCaustic(float3 exitPos, float3 lightDir, float waterDist) { - // Grazing light: the receiver-plane intersection degenerates and caustics vanish in reality anyway - // (long slant paths absorb + the pattern smears out) — fade the effect to neutral 1.0. - float fade = smoothstep(0.06, 0.18, lightDir.y); - if (fade <= 0.0) return 1.0; - float h = max(waterDist * lightDir.y, 0.05); // vertical depth of the receiver below the exit point - float t = worldPush.waterParams.w; - float3 inc = -lightDir; - float2 base = exitPos.xz + worldPush.waterAnchor.xy; // world-stable wave domain (same as applyWaterWaves) - float2 p0 = causticLanding(base, t, inc, h); - float2 px = causticLanding(base + float2(CAUSTIC_EPS, 0.0), t, inc, h); - float2 pz = causticLanding(base + float2(0.0, CAUSTIC_EPS), t, inc, h); - float det = abs((px.x - p0.x) * (pz.y - p0.y) - (px.y - p0.y) * (pz.x - p0.x)); - // Flat water ⇒ all landings displace identically ⇒ det = eps² ⇒ focus = 1 (energy-neutral); the - // clamp bounds the fold-line spikes whose energy the surrounding darkening already paid for. - float focus = (CAUSTIC_EPS * CAUSTIC_EPS) / max(det, 1.0e-5); - return lerp(1.0, min(focus, CAUSTIC_MAX), fade); -} - -// GGX BRDF. Roughness is clamped away from 0 so the delta sun's specular highlight stays finite -// and the VNDF sample is well-conditioned. alpha = roughness^2 (perceptual -> GGX) throughout. -static const float MIN_ROUGH = 0.045; - -float luminance(float3 c) { return dot(c, float3(0.2126, 0.7152, 0.0722)); } - -// GGX normal distribution. -float ggxD(float ndh, float rough) { - float a = rough * rough; - float a2 = a * a; - float d = ndh * ndh * (a2 - 1.0) + 1.0; - return a2 / (PI * d * d + 1.0e-7); -} - -// Smith GGX masking term for one direction (separable form; G2 = G1(v) * G1(l)). -float ggxG1(float ndx, float rough) { - float a = rough * rough; - float a2 = a * a; - return 2.0 * ndx / (ndx + sqrt(a2 + (1.0 - a2) * ndx * ndx) + 1.0e-7); -} - -float3 fresnelSchlick(float cosT, float3 f0) { - float m = clamp(1.0 - cosT, 0.0, 1.0); - float m2 = m * m; - return f0 + (1.0 - f0) * (m2 * m2 * m); -} - -// Henyey–Greenstein phase function (with 4π normalisation so it integrates to 1 over the sphere). -// g=0 → isotropic; g>0 → forward-scatter peak at cosT=1 (light and view aligned through a thin slab). -static const float SSS_G = 0.6; // forward-scatter anisotropy; leaves/grass are quite directional -static const float SSS_STRENGTH = 1.0; // 1.0 ≈ 2.5× the Lambertian at peak (sss=1, cosT=1, backNdl=1) -// Deepest SHADED surface at which the SSS transmission shadow ray fires. diffuseDepth, not `bounce`: the -// glass/water continue branches don't shade a surface, so foliage seen through a window is still -// diffuseDepth 0 and should backlight like any other primary leaf — see RIS_MAX_DIFFUSE_DEPTH for the -// same distinction and why it matters. -static const int MAX_SSS_DIFFUSE_DEPTH = 1; // fire on the first-shaded surface + one indirect hit -float hg(float cosT, float g) { - float g2 = g * g; - return (1.0 - g2) / (4.0 * PI * pow(max(0.0, 1.0 + g2 - 2.0 * g * cosT), 1.5)); -} - -// NVIDIA RR guide-buffer helper: integrated/view-dependent specular reflectivity, not raw F0. -// alpha is GGX alpha (perceptual roughness squared), NoV is the first-hit view cosine. -float3 rrSpecularAlbedo(float3 specularColor, float alpha, float NoV) { - NoV = abs(NoV); - float NoV2 = NoV * NoV; - float alpha2 = alpha * alpha; - float4 X = float4(1.0, NoV, NoV2, NoV * NoV2); - float4 Y = float4(1.0, alpha, alpha2, alpha * alpha2); - - float2 m1 = float2( - dot(float2(0.99044, -1.28514), X.xy), - dot(float2(1.29678, -0.755907), X.xy)); - float3 Xxyw = float3(X.x, X.y, X.w); - float3 m2 = float3( - dot(float3(1.0, 2.92338, 59.4188), Xxyw), - dot(float3(20.3225, -27.0302, 222.592), Xxyw), - dot(float3(121.563, 626.13, 316.627), Xxyw)); - float bias = dot(m1, Y.xy) / max(dot(m2, float3(Y.x, Y.y, Y.w)), 1.0e-7); - - float2 m3 = float2( - dot(float2(0.0365463, 3.32707), X.xy), - dot(float2(9.0632, -9.04756), X.xy)); - float3 Xxzw = float3(X.x, X.z, X.w); - float3 m4 = float3( - dot(float3(1.0, 3.59685, -1.36772), Xxzw), - dot(float3(9.04401, -16.3174, 9.22949), Xxzw), - dot(float3(5.56589, 19.7886, -20.2123), Xxzw)); - float scale = dot(m3, Y.xy) / max(dot(m4, float3(Y.x, Y.y, Y.w)), 1.0e-7); - - bias *= clamp(specularColor.g * 50.0, 0.0, 1.0); - return specularColor * max(0.0, scale) + float3(max(0.0, bias), max(0.0, bias), max(0.0, bias)); -} - -// Exact (unpolarized) Fresnel reflectance for a dielectric interface. cosI is the incidence cosine on -// the incoming side; etaI/etaT are the IORs the ray travels from / into. Returns 1.0 on total internal -// reflection (sin of the transmitted angle >= 1), which matches refract() returning the zero vector — -// so reflect/refract selection and TIR stay consistent. -float fresnelDielectric(float cosI, float etaI, float etaT) { - float sinT2 = (etaI * etaI) / (etaT * etaT) * max(0.0, 1.0 - cosI * cosI); - if (sinT2 >= 1.0) { - return 1.0; - } - float cosT = sqrt(1.0 - sinT2); - float rs = (etaI * cosI - etaT * cosT) / (etaI * cosI + etaT * cosT); - float rp = (etaI * cosT - etaT * cosI) / (etaI * cosT + etaT * cosI); - return 0.5 * (rs * rs + rp * rp); -} - -// PCG hash RNG. -uint pcg(inout uint s) { - s = s * 747796405u + 2891336453u; - uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; - return (w >> 22u) ^ w; -} -float rndf(inout uint s) { - // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first - // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` - // tests (most seriously the F == 1 total-internal-reflection branch). - return float(pcg(s) >> 8u) * (1.0 / 16777216.0); -} - -float3 primaryRayDir(float2 ndc) { - float4 nearH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 1.0, 1.0)); - float4 farH = mul(worldPush.invViewProj, float4(ndc.x, ndc.y, 0.0, 1.0)); - float3 nearP = nearH.xyz / nearH.w; - float3 farP = farH.xyz / farH.w; - return normalize(farP - nearP); -} - -float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { - float2 onePixelNdc = 2.0 / size; - float3 dx = primaryRayDir(ndc + float2(onePixelNdc.x, 0.0)); - float3 dy = primaryRayDir(ndc + float2(0.0, onePixelNdc.y)); - return max(max(length(cross(dir, dx)), length(cross(dir, dy))), RAY_CONE_MIN_SPREAD); -} - -// Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the -// importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation -// throughput is just *= albedo with no PI/cos bookkeeping left over. -float3 cosineDir(float3 n, inout uint s) { - float u1 = rndf(s); - float u2 = rndf(s); - float r = sqrt(u1); - float phi = 6.2831853 * u2; - float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); - float3 b = normalize(cross(n, t)); - t = cross(b, n); - float3 local = float3(r * cos(phi), r * sin(phi), sqrt(max(0.0, 1.0 - u1))); - return normalize(local.x * t + local.y * b + local.z * n); -} - -// Soft shadows: sample a direction within the light's SQUARE angular extent about `axis`. MC's -// sun/moon are square quads, so the NEE shadow ray samples a square (not a cone) — the same square the -// visible disc in world.rmiss uses, built from the same celestial tangent frame (right = arc-travel -// direction, from worldPush.celestial.xyz). Averaged over frames by DLSS-RR this yields soft penumbrae that -// widen with occluder distance (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). -float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { - float3 right = normalize(cross(axis, worldPush.celestial.xyz)); - float3 up = cross(right, axis); - float t = tan(halfAngle); - float u = (rndf(s) * 2.0 - 1.0) * t; - float v = (rndf(s) * 2.0 - 1.0) * t; - return normalize(axis + u * right + v * up); -} - -// Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the -// sampled microfacet normal (half-vector) in world space. alpha = rough^2. Paired with the separable -// Smith term, the importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). -float3 sampleGGXVNDF(float3 n, float3 ve, float rough, inout uint s) { - float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); - float3 b = normalize(cross(n, t)); - t = cross(b, n); - float3 Ve = float3(dot(ve, t), dot(ve, b), dot(ve, n)); // view in tangent space (z = n) - float a = rough * rough; - float3 Vh = normalize(float3(a * Ve.x, a * Ve.y, Ve.z)); - float lensq = Vh.x * Vh.x + Vh.y * Vh.y; - float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); - float3 T2 = cross(Vh, T1); - float u1 = rndf(s); - float u2 = rndf(s); - float r = sqrt(u1); - float phi = 6.2831853 * u2; - float p1 = r * cos(phi); - float p2 = r * sin(phi); - float ss = 0.5 * (1.0 + Vh.z); - p2 = (1.0 - ss) * sqrt(1.0 - p1 * p1) + ss * p2; - float3 Nh = p1 * T1 + p2 * T2 + sqrt(max(0.0, 1.0 - p1 * p1 - p2 * p2)) * Vh; - float3 Ne = normalize(float3(a * Nh.x, a * Nh.y, max(0.0, Nh.z))); // microfacet normal, tangent space - return normalize(Ne.x * t + Ne.y * b + Ne.z * n); // -> world space -} - -// ===== RIS emitter NEE ================================================================= -// Block emitters (torches, glowstone, lava, ...) get explicit NEE: per diffuse vertex, stream M uniform -// candidates from the global light buffer through a weighted reservoir keyed by the UNSHADOWED target -// p-hat = luminance(f * Le * G * area), then spend ONE shadow ray on the survivor. Sun/moon NEE stays -// separate and additive. Lights are footprint bounding RECTANGLES (RtLightCollector): sampling a point -// uniformly over the rectangle has pdf 1/area, so `area` rides the contribution like any area light. -// Single-frame RIS only (no temporal reservoir reuse): DLSS-RR's own history already denoises this well -// enough that a persistent per-pixel reservoir wasn't worth the added complexity and VRAM. -// One resample serves three mutually exclusive receiver terms (see evalSampleContrib): front BRDF, -// two-sided particle billboards, and LabPBR SSS backscatter from lights BEHIND the surface — all share -// the reservoir and its single shadow ray, whose origin follows the survivor's side of the surface. - -// A reservoir in registers. pos/lnrm/le/area describe the chosen emitter sample; W is its unbiased -// contribution weight, M the effective sample count. wSum/phat are streaming scratch (not persisted). -struct Reservoir { - float3 pos; // light sample point (rebased world space) - float3 lnrm; // emitter outward normal - float3 le; // emitter radiance - float area; // emitter rectangle area (the area-light pdf factor) - float M; - float W; - float wSum; - float phat; -}; - -Reservoir resEmpty() { - Reservoir r; - r.pos = float3(0.0, 0.0, 0.0); - r.lnrm = float3(0.0, 0.0, 0.0); - r.le = float3(0.0, 0.0, 0.0); - r.area = 0.0; - r.M = 0.0; - r.W = 0.0; - r.wSum = 0.0; - r.phat = 0.0; - return r; -} - -// Unshadowed contribution of a fixed light sample point at surface (hitPos,n,v); out p-hat = its -// luminance (the resampling target). Evaluated from the UNBIASED hitPos — SURF_BIAS is applied only to -// the shadow-ray origin in shadeReservoir, on whichever side the survivor sample lands. Three receiver -// modes share one target function because they are mutually exclusive per candidate: -// - front hemisphere (ndl>0): the same Lambert(+GGX) BRDF split the sun NEE uses; -// - twoSided (particles): billboards have no back face, ndl=abs(ndl) folds both sides into the front; -// - back hemisphere with sss>0 (LabPBR leaves/grass): HG transmission phase — cosT = dot(wi,rd) peaks -// when the ray points from the light through the slab toward the camera (same as the sun SSS term). -float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, float3 hitPos, float3 n, - float3 v, float3 rd, float3 diffAlb, float3 F0, float rough, - bool twoSided, float sss, out float phat) { - phat = 0.0; - float3 toL = sp - hitPos; - float dist2 = dot(toL, toL); - if (dist2 < 1.0e-6) { - return float3(0.0, 0.0, 0.0); - } - float dist = sqrt(dist2); - float3 wi = toL / dist; - float cosL = max(0.0, dot(lnrm, -wi)); - if (cosL <= 0.0) { - return float3(0.0, 0.0, 0.0); // receiver behind the emitter face - } - float ndl = dot(n, wi); - if (twoSided) { - ndl = abs(ndl); - } - float3 contrib = float3(0.0, 0.0, 0.0); - if (ndl > 0.0) { - float G = ndl * cosL / dist2; // area-light geometry term (x area) - float3 brdf = diffAlb * INV_PI; - // twoSided callers are particle billboards, which pass rough=1/F0=0 and want diffuse-only - // shading: fresnelSchlick still returns up to full white at grazing angles even with F0=0 (that - // IS the Fresnel effect), so skipping the term here — not just zeroing F0 — is what keeps - // billboards from picking up a rim highlight they were never meant to have. - if (!twoSided) { - float3 h = normalize(wi + v); - float ndh = max(0.0, dot(n, h)); - float ndv = max(1.0e-4, dot(n, v)); - float vdh = max(0.0, dot(v, h)); - float D = ggxD(ndh, rough); - float Gs = ggxG1(ndv, rough) * ggxG1(ndl, rough); - float3 Fs = fresnelSchlick(vdh, F0); - brdf += (D * Gs) * Fs / (4.0 * ndv * ndl); - } - contrib = brdf * le * (G * area); - } else if (sss > 0.0) { - float backNdl = -ndl; - float G = backNdl * cosL / dist2; - float cosT = dot(wi, rd); - contrib = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * le * (G * area); - } - phat = luminance(contrib); - return contrib; -} - -// Select one light from the emitted-power distribution in O(1). -void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); - uint column = min(uint(aliasSample), worldPush.lightCount - 1u); - if (pc.lightAliasAddr != 0) { - LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; - bool self = aliasSample - float(column) < a.accept; - lightIndex = self ? column : a.aliasIndex; - } else { - lightIndex = column; - } -} - -bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoord) { - cell.spanOffset = 0u; - cell.spanCount = 0u; - cell.invWeightSum = 0.0; - cellCoord = int3(0, 0, 0); - if (pc.lightGridCellAddr == 0 || pc.lightGridSpanAddr == 0) return false; - int3 coord = int3(floor((p - worldPush.lightGridOrigin.xyz) / worldPush.lightGridOrigin.w)); - if (coord.x < 0 || coord.y < 0 || coord.z < 0 - || coord.x >= worldPush.lightGridDims.x - || coord.y >= worldPush.lightGridDims.y - || coord.z >= worldPush.lightGridDims.z) return false; - uint linear = (uint(coord.z) * uint(worldPush.lightGridDims.y) + uint(coord.y)) - * uint(worldPush.lightGridDims.x) + uint(coord.x); - cell = ConstPtr(pc.lightGridCellAddr)[linear]; - cellCoord = coord; - return cell.spanCount > 0u; -} - -void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, - out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(lightCount); - uint column = min(uint(aliasSample), lightCount - 1u); - LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; - bool self = aliasSample - float(column) < alias.accept; - uint localIndex = self ? column : alias.aliasIndex; - lightIndex = firstLight + localIndex; -} - -float unpackUnsignedFloat(uint bits, uint mantissaBits) { - uint mantissaMask = (1u << mantissaBits) - 1u; - uint mantissa = bits & mantissaMask; - uint exponent = (bits >> mantissaBits) & 31u; - if (exponent == 0u) { - return float(mantissa) * exp2(float(1 - 15 - int(mantissaBits))); - } - return (1.0 + float(mantissa) / float(1u << mantissaBits)) - * exp2(float(int(exponent) - 15)); -} - -float3 lightRadiance(Light light) { - uint packed = light.le; - return float3(unpackUnsignedFloat(packed & 0x7ffu, 6u), - unpackUnsignedFloat((packed >> 11u) & 0x7ffu, 6u), - unpackUnsignedFloat((packed >> 22u) & 0x3ffu, 5u)); -} - -int3 lightSectionCoord(Light light) { - uint packed = light.section; - return int3(int(packed & 0x3ffu), int((packed >> 10u) & 0x3ffu), - int((packed >> 20u) & 0x3ffu)); -} - -// The half axes share three lanes two-at-a-time; see the Light record in world_common. -float3 lightHalfU(Light light) { - return float3(unpackHalf2(light.halfUxy), unpackHalf2(light.halfUzVx).x); -} - -float3 lightHalfV(Light light) { - return float3(unpackHalf2(light.halfUzVx).y, unpackHalf2(light.halfVyz)); -} - -// U x V drives both the emitter normal and the rectangle area, so it is computed once and shared. -float3 lightCrossUV(Light light) { - return cross(lightHalfU(light), lightHalfV(light)); -} - -// The rect spans 2U x 2V, so its area is 4|U x V| — exactly the rectArea the collector used to store. -float lightArea(Light light) { - return 4.0 * length(lightCrossUV(light)); -} - -float3 lightGeometricNormal(Light light) { - float3 normal = normalize(lightCrossUV(light)); - return (light.section & 0x40000000u) != 0u ? -normal : normal; -} - -float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCoord, - float localProbability) { - float power = max(0.0, lightArea(light) * luminance(le)); - float globalPdf = pc.lightAliasAddr != 0 - ? power * worldPush.lightRebase.w : 1.0 / float(worldPush.lightCount); - float localPdf = 0.0; - if (localProbability > 0.0 && power > 0.0) { - int3 delta = lightSectionCoord(light) - cellCoord; - if (all(abs(delta) <= int3(2, 2, 2))) { - float3 deltaF = float3(delta); - localPdf = power * cell.invWeightSum / max(1.0, dot(deltaF, deltaF)); - } - } - return localProbability * localPdf + (1.0 - localProbability) * globalPdf; -} - -void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, - out uint lightIndex) { - ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); - float aliasSample = rndf(proposalSeed) * float(cell.spanCount); - uint column = min(uint(aliasSample), cell.spanCount - 1u); - LightGridSpan span = spans[cell.spanOffset + column]; - bool self = aliasSample - float(column) < span.accept; - uint firstLight = self ? span.firstLight : span.aliasFirstLight; - uint lightCount = self ? (span.packedLightCounts & 0xffffu) - : (span.packedLightCounts >> 16u); - selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex); -} - -// Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section -// by emitted power and section distance, then its section-local power alias. Every light in -// the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. -// Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single -// Light48 load: section power cancels with the section-local light PDF. -void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, - out uint lightIndex) { - if (useLocal) { - selectLightGridSpanLight(cell, proposalSeed, lightIndex); - } else { - selectGlobalLight(proposalSeed, lightIndex); - } -} - -// ---- RIS cost budget. Both constants below come from a set of temporary probes that pinned the -// candidate walk out of the shader to bound what memory-side RIS work could ever be worth. Against an -// 18.0ms frame at M=8, removing every dependent load (span -> alias -> record) and all lane divergence -// saved 5.9ms — a third of the frame. Split by vertex that was ~4.3ms at secondary vertices against -// ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost -// was chasing DEPTH, not divergence, and it lived at secondary vertices. The two knobs below spend that -// finding; a presampled candidate pool would attack the same 5.9ms structurally, but see the note on -// proposalSeed in tracePath for why it should share the pool rather than the seed. - -// Candidate-count divisor for RIS at secondary vertices (bounce > 0). Secondary vertices carried ~78% of -// the cost while being much the more forgiving place to spend variance: that radiance is already -// integrated over a diffuse lobe before the denoiser sees it. 18.0 -> 16.1ms, and indistinguishable from -// 1 by eye — whereas reducing M globally to 2 was clearly worse, because that also degraded the primary -// hit. The primary hit always keeps the full worldPush.risCandidates. -// -// 4 is the floor worth using rather than a tuning accident. The stratification below keeps at least one -// global candidate, so M=2 is the smallest count that still schedules one local AND one global; at M=1 -// localProbability collapses to 0 and the light-grid proposal that makes nearby emitters sample well is -// gone entirely. -static const uint SECONDARY_RIS_DIVISOR = 4u; - -// Deepest SHADED surface at which RIS emitter NEE runs. Past it, emitters are gathered only when a path -// hits one directly — the pre-RIS behaviour, still unbiased because gateEmitter lifts in lockstep, so the -// failure mode is noisier emitter light at depth rather than missing light. Unlike the divisor this also -// drops shadeReservoir's per-vertex shadow ray, which never scaled with M. Gating at all: 16.1 -> 15.7ms. -// -// This counts diffuseDepth, not `bounce`, and the difference is not cosmetic. MATERIAL_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 twoSided, float sss, uint shadedDepth, - inout uint seed, inout uint proposalSeed) { - Reservoir r = resEmpty(); - LightGridCell gridCell; - int3 gridCellCoord; - float3 gridLookup = hitPos; - if (pc.lightGridCellAddr != 0) { - // Stochastically blend across hard section boundaries. Every cell proposal retains full global - // support, so conditioning on this independently jittered lookup remains unbiased. - gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5) - * worldPush.lightGridOrigin.w; - } - bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); - // shadedDepth, not the bounce index: a surface behind glass or water is the first thing this path has - // shaded and is visually primary, so it keeps the full candidate count. - uint candidateCount = shadedDepth == 0u - ? worldPush.risCandidates - : max(1u, worldPush.risCandidates / SECONDARY_RIS_DIVISOR); - // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six - // local and two global candidates, with every lane taking the same branch for a given candidate. - // Counts that are not divisible by four use the nearest practical split with at least one global - // candidate; M=1 is global-only so the estimator retains full support. - uint globalCandidateCount = hasGridCell - ? max(1u, (candidateCount + 2u) / 4u) : candidateCount; - uint localCandidateCount = candidateCount - globalCandidateCount; - float localProbability = float(localCandidateCount) / float(candidateCount); - for (uint c = 0u; c < candidateCount; c++) { - r.M += 1.0; - uint li; - uint globalsBefore = (c * globalCandidateCount) / candidateCount; - uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; - bool useLocal = hasGridCell && globalsAfter == globalsBefore; - selectLightGridLight(gridCell, useLocal, proposalSeed, li); - Light lg = ConstPtr(pc.lightBufAddr)[li]; - // Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area). - float s = rndf(seed) * 2.0 - 1.0; - float t = rndf(seed) * 2.0 - 1.0; - float3 sp = lg.pos + worldPush.lightRebase.xyz - + s * lightHalfU(lg) + t * lightHalfV(lg); - float3 le = lightRadiance(lg); - float3 lightNormal = lightGeometricNormal(lg); - float area = lightArea(lg); - float phat; - evalSampleContrib(sp, lightNormal, le, area, hitPos, n, v, rd, - diffAlb, F0, rough, twoSided, sss, phat); - if (phat <= 0.0) { - continue; - } - float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability); - float w = phat / max(sourcePdf, 1.0e-20); - r.wSum += w; - if (rndf(seed) * r.wSum < w) { // weighted reservoir update - r.pos = sp; - r.lnrm = lightNormal; - r.le = le; - r.area = area; - r.phat = phat; - } - } - r.W = r.phat > 0.0 ? (r.wSum / (r.M * r.phat)) : 0.0; - return r; -} - -// Shade a finalized reservoir: recompute the survivor's contribution at (hitPos,n,v), cast ONE shadow -// ray, and return throughput-free radiance contrib*vis*W. The one ray serves whichever term fired for -// the survivor (front BRDF, twoSided billboard, or SSS backscatter) — the origin is biased toward the -// sample's side of the surface. -float3 shadeReservoir(Reservoir s, float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, - float3 F0, float rough, bool twoSided, float sss, out float3 vis) { - vis = float3(0.0, 0.0, 0.0); - if (s.W <= 0.0 || s.phat <= 0.0) { - return float3(0.0, 0.0, 0.0); - } - float phat; - float3 contrib = evalSampleContrib(s.pos, s.lnrm, s.le, s.area, hitPos, n, v, rd, - diffAlb, F0, rough, twoSided, sss, phat); - if (phat <= 0.0) { - return float3(0.0, 0.0, 0.0); - } - // Pick the shadow-ray origin on the sample's side of the surface FIRST, then measure the ray from - // that origin — measuring dist from hitPos while tracing from the biased origin shifts the ray tip - // up to SURF_BIAS*ndl toward the emitter plane, beating the 0.001*dist stop-short margin for lights - // closer than ~SURF_BIAS/0.001 blocks -> shadow ray hits the emitter's own face -> per-texel black. - float side = dot(n, s.pos - hitPos) >= 0.0 ? 1.0 : -1.0; - float3 origin = hitPos + side * n * SURF_BIAS; - float3 toL = s.pos - origin; - float dist = length(toL); - // Stop just short of the sample point so the ray doesn't self-occlude on the emitter's own face. - VisibilityResult shadow = visibility(origin, toL / dist, dist * 0.999); - vis = shadow.transmittance; - return contrib * vis * s.W; -} - -// TLAS instance-mask bits (set on the Java side, ANDed against these per-ray cull masks): -// bit 0 (0x01) — secondary rays: shadows, GI bounces, reflections. -// bit 1 (0x02) — the primary camera ray. -// Terrain / entities / block entities keep 0xFF (both bits). Particles use 0x02: primary-visible -// receiver billboards only. The first-person camera owner uses 0x01 (secondary only). -static const uint CULL_SECONDARY = 0x01u; -static const uint CULL_PRIMARY = 0x02u; -static const uint TERRAIN_BUCKETS = 4u; -static const uint SBT_RADIANCE = 0u; -static const uint SBT_SHADOW = TERRAIN_BUCKETS; -static const uint SBT_STRIDE_BUCKET = 1u; - -RayDesc makeRay(float3 origin, float tmin, float3 dir, float tmax) { - RayDesc r; - r.Origin = origin; - r.TMin = tmin; - r.Direction = dir; - r.TMax = tmax; - return r; -} - -// A radiance payload carrying only the two words the invoked hit/miss shader READS: `flags` -// (show-celestial gate, consumed by world.rmiss) and the packed ray cone (consumed by world.rchit for -// texture LOD). Every other member is an OUTPUT that world.rchit/world.rmiss write, so it carries no -// information into a trace and is initialized to a neutral value here. -Payload makeRadiancePayload(uint flags, uint rayCone) { - Payload p; - p.albedo = half3(0.0h, 0.0h, 0.0h); - p.normal = half3(0.0h, 0.0h, 0.0h); - p.hitT = -1.0; // miss sentinel: world.rmiss writes only albedo, so a miss leaves this negative - p.motionPrev = half3(0.0h, 0.0h, 0.0h); - p.f0 = half3(0.0h, 0.0h, 0.0h); - p.flags = flags; - p.roughMetal = 0u; - p.emissionSss = 0u; - p.iorTransmission = 0u; - p.rayCone = rayCone; - return p; -} - -// SER radiance trace: trace into a hit object, reorder threads by hit coherence, THEN invoke the -// hit/miss shader — this is where the divergent Section/Prim shading work (world.rchit/world.rahit) -// actually happens, so reordering before it is what pays for the reorder's own cost. +// job. With RR disabled the output is the raw noisy path trace — a reference view, not a converged +// image. Output is HDR (R16G16B16A16_SFLOAT); the HDR -> LDR tonemap happens at the display.comp seam. // -// 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), tracePayload); - ReorderThread(hObj); - payload = makeRadiancePayload(flags, rayCone); - HitObject::Invoke(topLevelAS, hObj, payload); -} - -Payload makeShadowPayload() { - Payload shadowPayload; - shadowPayload.albedo = half3(1.0h, 1.0h, 1.0h); - shadowPayload.hitT = -1.0; // water-crossing sentinel, filled by the water any-hit - shadowPayload.normal = half3(0.0h, 0.0h, 0.0h); - shadowPayload.motionPrev = half3(0.0h, 0.0h, 0.0h); - shadowPayload.f0 = half3(0.0h, 0.0h, 0.0h); - shadowPayload.flags = 0u; - shadowPayload.roughMetal = 0u; - shadowPayload.emissionSss = 0u; - shadowPayload.iorTransmission = 0u; - shadowPayload.rayCone = 0u; - return shadowPayload; -} - -VisibilityResult visibility(float3 origin, float3 dir, float tmax) { - // Vulkan requires an identical payload structure for every stage reachable by this trace. The shadow - // path uses only albedo as accumulated transmittance and hitT as the nearest-water crossing. - Payload shadowPayload = makeShadowPayload(); - // Shadow SBT records run any-hit only for cutout/translucent/water. Cutout alpha-tests; translucent - // and water tint shadowPayload.albedo and pass through. Solid blocks terminate traversal. There is no - // closest or miss shader worth executing, so use a hit object only as the traversal result. - HitObject hObj = HitObject::TraceRay(topLevelAS, - RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH | RAY_FLAG_SKIP_CLOSEST_HIT_SHADER, - CULL_SECONDARY, SBT_SHADOW, SBT_STRIDE_BUCKET, 0u, - makeRay(origin, RAY_TMIN, dir, tmax), shadowPayload); - VisibilityResult result; - result.transmittance = hObj.IsMiss() ? shadowPayload.albedo : float3(0.0, 0.0, 0.0); - result.waterHitT = shadowPayload.hitT; - return result; -} - -float2 projectPrevNdc(float3 worldPos) { - float4 clip = mul(worldPush.prevViewProj, float4(worldPos - worldPush.camOffset + worldPush.camDelta, 1.0)); - return clip.xy / clip.w; -} - -// Previous-frame screen position of a planar reflection. The reflected image of a world point P seen -// in a planar reflector is the MIRROR image V = mirror(P) across the surface plane: the eye sees V -// along a straight line, so the reflection appears at proj(V). Project the mirror image directly. -// Gotcha: intersecting a reflection ray with the surface plane divides by dot(mirrorDir, n), which -// explodes at grazing angles and produces non-zero MV in a static scene. Mirroring is division-free, so -// static MV is bit-exact zero at any incidence. The reflector is assumed static (terrain / water); -// reflectedMotionPrev carries the reflected content's own displacement. -float2 previousReflectionNdc(float3 surfacePos, float3 surfaceNormal, float3 reflectedWorldPos, float3 reflectedMotionPrev) { - float3 n = normalize(surfaceNormal); // mirror(P) is orientation-independent, so no viewer flip needed - float3 prevHit = reflectedWorldPos - reflectedMotionPrev; - float3 mirroredHit = prevHit - 2.0 * n * dot(prevHit - surfacePos, n); - return projectPrevNdc(mirroredHit); -} - -// Reflection (specular) motion vector for DLSS-RR. Covers smooth specular blocks (metals / LabPBR _s) -// and water reflections including underwater total-internal-reflection — any primary surface with a -// sharp specular lobe (specular albedo > 0, roughness <= 0.5). A sky miss is treated as a reflected -// hit at infinity along the reflected direction, so the same mirror-image reprojection handles surface -// and sky reflections uniformly (continuous as the reflected distance -> infinity). -float2 specularReflectionMotion(float3 surfacePos, float3 primaryDir, float2 currentNdc, float2 size, float primaryConeSpread) { - if (length(gv_normal) < 0.5 || max(gv_specAlb.r, max(gv_specAlb.g, gv_specAlb.b)) <= 0.001 || gv_rough > 0.5) { - return float2(0.0, 0.0); - } - - float3 n = normalize(gv_normal); - float3 specDir = reflect(primaryDir, n); - // The bias is ONLY for the trace origin (so the reflection ray doesn't self-intersect the surface it - // leaves). The hit is then reconstructed from the TRUE surface point `surfacePos`, NOT the biased - // origin: reconstructing from `surfacePos + n*SURF_BIAS` bakes a constant `-n*SURF_BIAS` world offset - // into the mirrored virtual point, and that offset's screen projection blows up at grazing angles (n - // lies in the screen plane there) -> a non-zero MV in a static scene. Reconstructing from surfacePos - // cancels the term so the mirror lands exactly on the view ray (static MV = bit-exact zero) WITHOUT - // zeroing the trace bias the main path tracer needs to avoid surface acne. - traceRadianceReordered(CULL_SECONDARY, surfacePos + n * SURF_BIAS, RAY_TMIN, specDir, 10000.0, - false, - max(length(surfacePos - worldPush.camOffset) * primaryConeSpread, RAY_CONE_MIN_WIDTH), - max(primaryConeSpread, RAY_CONE_MIN_SPREAD)); - - float3 reflectedHit; - float3 reflectedMotionPrev; - if (payload.hitT > 0.0) { - reflectedHit = surfacePos + specDir * payload.hitT; - // Reflected water/fluid is a static dielectric (its chit MV lane is repurposed); only opaque - // geometry carries a real per-vertex displacement. Particles are off secondary rays (cull mask). - reflectedMotionPrev = payloadMaterial() == MATERIAL_OPAQUE ? payload.motionPrev : float3(0.0, 0.0, 0.0); - } else { - reflectedHit = surfacePos + specDir * 1.0e6; // sky reflection: virtual image at infinity along specDir - reflectedMotionPrev = float3(0.0, 0.0, 0.0); - } - float2 prevNdc = previousReflectionNdc(surfacePos, n, reflectedHit, reflectedMotionPrev); - return (prevNdc - currentNdc) * 0.5 * size; -} - -// One-interface refraction for the water guide buffers: trace the refracted ray from the surface into -// (or out of) the water and report the content it lands on. Diffuse AND specular albedo, plus MV, then -// track that hit as its own feature (the water surface itself has no true diffuse/specular response of -// its own worth demodulating by — it's a clear interface), while depth stays on the water surface. -// `refracted` is false on total internal reflection (no refracted hit exists); a sky miss returns a far -// point along the refracted direction, which the self-delta MV handles uniformly. -void refractedGuideHit(float3 surfacePos, float3 incidentDir, float3 surfaceNormal, bool inWaterAtSurface, - float materialIor, - float rayConeWidth, float rayConeSpread, - out float3 hitCamRel, out float3 motionPrev, out bool refracted, out float3 diffuseAlbedo, - out float3 specAlbedo) { - hitCamRel = surfacePos - worldPush.camOffset; - motionPrev = float3(0.0, 0.0, 0.0); - refracted = false; - diffuseAlbedo = float3(0.0, 0.0, 0.0); - specAlbedo = float3(0.0, 0.0, 0.0); - - float etaI = inWaterAtSurface ? materialIor : 1.0; - float etaT = inWaterAtSurface ? 1.0 : materialIor; - float3 refractedDir = refract(incidentDir, surfaceNormal, etaI / etaT); - if (dot(refractedDir, refractedDir) <= 0.0) { - return; // total internal reflection: no refracted guide hit exists - } - refractedDir = normalize(refractedDir); - refracted = true; - - float3 ro = surfacePos - surfaceNormal * SURF_BIAS; - traceRadianceReordered(CULL_SECONDARY, ro, RAY_TMIN, refractedDir, 10000.0, - false, rayConeWidth, rayConeSpread); - if (payload.hitT < 0.0) { - hitCamRel = (ro + refractedDir * 1.0e6) - worldPush.camOffset; // sky through the surface, at infinity - motionPrev = float3(0.0, 0.0, 0.0); - diffuseAlbedo = SKY_DIFF_ALBEDO; // sky guide: keep radiance out of the demodulation albedo - specAlbedo = SKY_SPEC_ALBEDO; - return; - } - - float3 hitPos = ro + refractedDir * payload.hitT; - hitCamRel = hitPos - worldPush.camOffset; - uint material = payloadMaterial(); - motionPrev = material != MATERIAL_WATER ? payload.motionPrev : float3(0.0, 0.0, 0.0); - if (material == MATERIAL_OPAQUE) { - diffuseAlbedo = payload.albedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); - float3 f0 = payload.f0; - float rough = clamp(payloadRoughness(), MIN_ROUGH, 1.0); - float NoV = clamp(dot(payload.normal, -refractedDir), 0.0, 1.0); - specAlbedo = rrSpecularAlbedo(f0, rough * rough, NoV); - } -} - -// One Monte Carlo path from (ro, rd). Returns accumulated radiance along the path. -float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint sampleIndex, - inout uint seed) { +// The build emits an ordinary TraceRay fallback and an optional EXT SER variant from this source. +import world_common; +import world_core; +import math; +import medium; +import segment; +import water; +import trace; +#ifdef CAUSTICA_ENABLE_EXT_SER +import trace_ser; +#endif +import lighting; + +public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 L = float3(0.0, 0.0, 0.0); - float3 throughput = float3(1.0, 1.0, 1.0); - float rayConeWidth = 0.0; - float rayConeSpread = max(primaryConeSpread, RAY_CONE_MIN_SPREAD); + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + // Pass A is fixed at one sample per pixel. Decorrelate each Pass B resample here so configured SPP + // produces independent lighting/BSDF paths from the shared terminal continuation. + uint seed = seg.seed ^ sampleIndex * 2246822519u; + seed = pcg(seed); + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); int maxBounces = int(worldPush.maxBounces); int rrStart = maxBounces <= 3 ? 1 : 2; // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the @@ -972,99 +53,124 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; proposalSeed = pcg(proposalSeed); - // Start submerged if the camera is in water so the first segment gets the right medium orientation - // and absorption when the eye is underwater. - bool inWater = (worldPush.flags & 1u) != 0u; - // Current medium extinction. Defaults to the camera-biome tint (correct when the eye starts - // submerged, before any water surface is hit); refreshed to the hit water body's tint on each crossing. - float3 waterExt = waterExtinction(worldPush.waterParams.xyz); + // The medium the segment starts in. For the camera segment that is water when the eye is submerged + // (so the first ray already carries the right relative index and absorption); for a segment split off + // a dielectric it is whatever the parent was travelling through. + MediumStack medium = seg.medium; bool waterWaves = (worldPush.flags & 16u) != 0u; // animated wave-normal perturbation // Sky-disc gate (see world.rmiss): the sun/moon disc is shown to the primary ray and to rays spawned // by a specular/dielectric bounce (mirror reflections + refractions of the sun/moon), but hidden from // diffuse-sampled continuation rays — those are already covered by the sun NEE, so the tiny disc // would be a double-counted firefly. Reset true on every specular/water bounce, false on a diffuse one. - bool showCelestial = true; - // 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++) { + bool showCelestial = seg.showCelestial; + // Pass B treats every hit it sees as one indirect-depth step, regardless of material or selected + // continuation lobe. Deliberately start at zero instead of seg.bounce: the first hit of either + // queued split branch is still the primary-terminal shading point. Hits 0 and 1 retain SSS; RIS + // remains active with its full configured candidate count at every hit. Pass A's primary/interface + // prefix is outside this SSS quality budget. + int indirectDepth = 0; + for (int bounce = seg.bounce; bounce <= maxBounces; bounce++) { // Radiance SBT records run any-hit only for true alpha cutout. Translucent/water go straight to // closest-hit for dielectric handling. Geometry is double-sided; the chit flips the normal. // Primary (bounce 0) is the camera ray (CULL_PRIMARY): sees particles but not the first-person // player. Bounce rays are secondary (CULL_SECONDARY): exclude particles, include the player. +#ifdef CAUSTICA_ENABLE_EXT_SER + // SER lifetime phase: keep paths that are not roulette-eligible, paths that may terminate via + // roulette, and paths guaranteed to end at the bounce cap in separate coherence groups. + uint pathPhaseHint = bounce >= maxBounces ? 2u : (bounce >= rrStart ? 1u : 0u); traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); + showCelestial, rayConeWidth, rayConeSpread, pathPhaseHint); +#else + traceRadiance(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, + showCelestial, rayConeWidth, rayConeSpread); +#endif if (payload.hitT < 0.0) { // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into payload.albedo. The // packed show-celestial flag decides whether this path segment may see the bright disc. float3 sky = payload.albedo; - if (bounce == 0) { // primary ray hit sky: guides describe "no surface" - gv_normal = float3(0.0, 0.0, 0.0); - // Flat mid-grey albedo guide for sky (NOT the bright sky radiance): demodulating DLSS-RR - // by the huge sun-disc radiance rings/artifacts around the sun. A constant albedo keeps - // the brightness in the lighting channel where RR resolves it cleanly. - gv_albedo = SKY_DIFF_ALBEDO; - gv_specAlb = SKY_SPEC_ALBEDO; - gv_rough = 1.0; - gv_emission = 0.0; - gv_emissionSource = EMISSION_SOURCE_NONE; - gv_hitCamRel = rd * 1.0e6; // sky at infinity: only rotation parallax (-> large far depth) - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = float3(0.0, 0.0, 0.0); // sky is static - } L += throughput * sky; // escaped to sky break; } - // Beer–Lambert: attenuate along the segment just travelled if it lay inside water. Applied to - // every hit reached while submerged (the water-exit face or the underwater floor), shifting the - // transmitted radiance with depth. - if (inWater) { - throughput *= exp(-waterExt * payload.hitT); + // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies + // to every hit reached while inside a volume dielectric (its own exit face, or whatever content + // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so + // the ordinary case costs one comparison. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); } float3 n = payload.normal; // oriented toward the incoming ray by the closest-hit float3 hitPos = ro + rd * payload.hitT; rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), RAY_CONE_MIN_WIDTH); uint material = payloadMaterial(); - if (bounce == 0) { - gv_emission = payloadEmission(); - gv_emissionSource = payloadEmissionSource(); - } - - // Stained glass / ice (material 3): a thin colored dielectric. Stochastically reflect (Fresnel - // glint) or transmit; on transmission the throughput is multiplied by the pane's tint, so light - // passing through is colored. payload.albedo is the transmission tint (texel rgb mixed toward - // white by 1 − opacity). Treated as a thin pane, so the transmitted ray continues straight (no - // refraction bend) — the instance stays double-sided and the normal already faces the incoming ray. - if (material == MATERIAL_GLASS) { - float3 glassTint = payload.albedo; - float materialIor = max(payloadIor(), 1.0e-3); + int hitDepth = indirectDepth++; + + // ---- Dielectric interface: water, stained glass, ice. ONE handler and one behaviour — every + // dielectric is a volume. Fresnel from the relative index across the face, then reflect or + // refract; no diffuse response and no sun NEE, because the surface is specular. Transmission + // pushes or pops a participating medium, and the segment travelled inside it is attenuated by + // that medium's extinction rather than by a tint multiply at the interface. + // + // Water is the only material that adds anything: the animated wave normal, and a medium marked + // for the caustic term. Which side of the face we are on comes from the geometry + // (payloadDielectricEntering), so a stray or missing face cannot desync the medium for the rest + // of the path. + if (material == MATERIAL_WATER || material == MATERIAL_DIELECTRIC) { + // A dielectric has no local shading term. At the path cap its continuation cannot be traced, + // so avoid computing an unused Fresnel choice, medium update, and roulette step. + if (bounce >= maxBounces) { + break; + } + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 tint = payload.albedo; float transmission = clamp(payloadTransmission(), 0.0, 1.0); - float cosI = clamp(dot(-rd, n), 0.0, 1.0); - float F = fresnelDielectric(cosI, 1.0, materialIor); - if (bounce == 0) { // primary glass hit: feed RR a smooth dielectric specular surface - gv_normal = n; - gv_rough = clamp(payloadRoughness(), MIN_ROUGH, 1.0); - gv_specAlb = float3(F, F, F); - gv_albedo = glassTint; // demodulate by the transmission tint - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; // 0 for static terrain + + // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken + // reflections, wobbling refraction. The wave domain is world-stable: rebased hit XZ plus the + // pushed anchor (terrain origin mod 4096) survives the per-move terrain rebase. + float3 geometricNormal = n; // unrippled; ray origins offset along this + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); } - if (rndf(seed) < F) { + + // The medium this face opens into, and the one it returns to on the way out — which is what + // the stack remembers. + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), tint, transmission); + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.outer.ior; + + float cosI = clamp(dot(-rd, n), 0.0, 1.0); + float F = fresnelDielectric(cosI, etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + // The translucent terrain layer is recessed by TRANSLUCENT_INSET (RtTerrainMesher), so a + // glass/ice face touching a slab or stair sits a hair behind that neighbour's surface. A full + // SURF_BIAS would restart the transmitted ray past the neighbour and see through it. Water + // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + + bool chooseReflection = rndf(seed) < F; + if (chooseReflection) { rd = reflect(rd, n); - ro = hitPos + n * SURF_BIAS; // reflected ray stays on the incidence side + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); } else { - throughput *= glassTint * transmission; // colored, optionally partial transmission - ro = hitPos - n * GLASS_TRANSMIT_BIAS; // cross to the far side without overshooting a close neighbour + if (dot(transmittedDir, transmittedDir) <= 0.0) break; // TIR with F < 1 cannot happen + rd = normalize(transmittedDir); + ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, transmitBias); + // Crossing into or out of the volume. Absorption is the medium's job from here, so the + // tint is NOT also multiplied into the throughput — that would double-count it. + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } } - showCelestial = true; // specular interface: the continuation ray may see the disc + showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc if (bounce >= rrStart) { float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); if (rndf(seed) > q) { @@ -1080,14 +186,6 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // path continuation into the scene, but never appear in shadows/reflections/GI or emit radiance. if (material == MATERIAL_PARTICLE) { float3 albedo = payload.albedo; - gv_normal = n; - gv_albedo = albedo; // RR demodulation target = the particle texel (keeps it sharp) - gv_specAlb = float3(0.0, 0.0, 0.0); - gv_rough = 1.0; - gv_hitCamRel = hitPos - worldPush.camOffset; - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; // per-particle MV: de-ghost moving particles float3 lightDir = worldPush.lightDir.xyz; float lightHalfAngle = worldPush.lightDir.w; @@ -1110,13 +208,12 @@ 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 && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH)) { + if (risOn) { float3 v = -rd; Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, uint(diffuseDepth), seed, proposalSeed); - float3 risVis; + true, 0.0, seed, proposalSeed); L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, risVis); + true, 0.0); } if (bounce >= maxBounces) { @@ -1127,70 +224,6 @@ 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; - } - - // Water: smooth dielectric interface. Choose reflection or refraction stochastically by the - // Fresnel reflectance F — sampling by F leaves the throughput unchanged (lossless interface), so - // there is no diffuse albedo multiply and no sun NEE here (the surface is specular). On - // transmission, the new medium is read from this hit's face orientation (payloadWaterEntering), - // not toggled — see PAYLOAD_WATER_ENTERING. The bounce cap bounds stacked refractions. - if (material == MATERIAL_WATER) { - float materialIor = max(payloadIor(), 1.0e-3); - float transmission = clamp(payloadTransmission(), 0.0, 1.0); - // The primary-water guide traces another radiance ray through the global payload. Preserve - // this interface's orientation before that nested trace overwrites payload.flags. - bool materialEntering = payloadWaterEntering(); - // Ripple the (near-flat) surface normal before any Fresnel/guide use → glints, broken - // reflections, wobbling refraction. This water body's biome tint (carried in payload.albedo - // for water hits) sets the absorption applied to the next submerged segment. - if (waterWaves) { - // World-stable wave domain: rebased hit XZ + the pushed anchor (terrain origin mod 4096) - // reconstructs a world-pinned coordinate that survives the per-move terrain rebase. - n = applyWaterWaves(n, hitPos.xz + worldPush.waterAnchor.xy, worldPush.waterParams.w); - } - waterExt = waterExtinction(payload.albedo); - float cosI = clamp(dot(-rd, n), 0.0, 1.0); - if (bounce == 0) { // primary water hit: feed RR a smooth dielectric specular surface - gv_normal = n; - gv_rough = clamp(payloadRoughness(), MIN_ROUGH, 1.0); - gv_hitCamRel = hitPos - worldPush.camOffset; // depth + reflection start at the water surface - // Diffuse AND specular albedo + MV track the refracted hit (floor/shore seen through the - // surface) so refracted content stops ghosting; depth stays on the water surface. Water's - // own Fresnel reflectance is ignored here — a clear interface, not a demodulation target. - float3 refractedAlbedo; - float3 refractedSpecAlbedo; - refractedGuideHit(hitPos, rd, n, inWater, materialIor, rayConeWidth, rayConeSpread, - gv_motionHitCamRel, gv_motionObjDisp, - gv_motionUseRefracted, refractedAlbedo, refractedSpecAlbedo); - gv_albedo = refractedAlbedo; - gv_specAlb = refractedSpecAlbedo; - } - float etaI = inWater ? materialIor : 1.0; - float etaT = inWater ? 1.0 : materialIor; - float F = fresnelDielectric(cosI, etaI, etaT); - if (rndf(seed) < F) { - rd = reflect(rd, n); - ro = hitPos + n * SURF_BIAS; // reflected ray stays on the incidence side - } else { - rd = refract(rd, n, etaI / etaT); // F < 1 here, so this is never total internal reflection - throughput *= transmission; - ro = hitPos - n * SURF_BIAS; // transmitted ray crosses to the far side - // Derived from this hit's face orientation, not toggled — self-corrects instead of a - // stray/missing face corrupting the medium for the rest of the path (see PAYLOAD_WATER_ENTERING). - inWater = materialEntering; - } - // Specular interface (mirror reflect / clear refract): the continuation ray should see the - // sun/moon disc directly — no NEE happened here to double-count it. - showCelestial = true; - if (bounce >= rrStart) { - float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { - break; - } - throughput /= q; - } continue; } @@ -1201,22 +234,16 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // Material split. Metals tint F0 by albedo and have no diffuse lobe; dielectrics use a fixed // 0.04 F0, sourced from the chit (LabPBR custom/metal F0, or the dielectric default it applies). - float rough = clamp(payloadRoughness(), MIN_ROUGH, 1.0); + float rough = clamp(payloadRoughness(), 0.0, 1.0); float metal = clamp(payloadMetalness(), 0.0, 1.0); + // Unresolvably tight lobes become exact delta mirrors; everything else keeps its authored value. + // Snapping to 0 also propagates into the guide buffers, which is what tells RR to hold the + // reflection sharp instead of accumulating it as a glossy lobe. + bool exactSpecular = isDeltaAlpha(rough); + rough = exactSpecular ? 0.0 : rough; float3 diffAlb = albedo * (1.0 - metal); float3 F0 = payload.f0; - if (bounce == 0) { // primary-visibility surface: capture the denoiser guide buffers - gv_normal = n; - gv_albedo = diffAlb; // RR diffuse-albedo demodulation target - gv_rough = rough; - gv_specAlb = rrSpecularAlbedo(F0, rough * rough, dot(n, v)); - gv_hitCamRel = ro + rd * payload.hitT - worldPush.camOffset; // camera-relative hit position - gv_motionHitCamRel = gv_hitCamRel; - gv_motionUseRefracted = false; - gv_motionObjDisp = payload.motionPrev; // per-vertex world motion (0 for static terrain/sky) - } - // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial @@ -1230,8 +257,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 && diffuseDepth <= int(RIS_MAX_DIFFUSE_DEPTH); - bool gateEmitter = risActive && payloadEmitterInList(); + bool gateEmitter = risOn && payloadEmitterInList(); if (emission > 0.0 && (!gateEmitter || showCelestial)) { L += throughput * albedo * emission; } @@ -1253,7 +279,7 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint // Underwater receiver whose shadow ray crossed a water surface: scale the direct light by // the wave-refraction caustic at the exit point. Focusing scales the incident irradiance, // so it applies to the whole NEE term (diffuse + specular). - if (inWater && waterWaves && shadow.waterHitT > 0.0) { + if (medium.current.water && waterWaves && shadow.waterHitT > 0.0) { vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); } if (max(vis.r, max(vis.g, vis.b)) > 0.0) { @@ -1273,17 +299,14 @@ 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_DIFFUSE_DEPTH by passing - // sss=0 there (falls back to plain front-only RIS). Keyed on diffuseDepth, not `bounce`, for the - // same reason as RIS_MAX_DIFFUSE_DEPTH above: leaves seen through a window are still the first - // surface this path has shaded, and should still backlight. - if (risActive) { - float activeSss = diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH ? sss : 0.0; + // exact, not an approximation. The SSS term is gated off past MAX_SSS_INDIRECT_DEPTH by passing + // sss=0 there (falls back to plain front-only RIS). + if (risOn) { + float activeSss = hitDepth <= MAX_SSS_INDIRECT_DEPTH ? sss : 0.0; Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, - uint(diffuseDepth), seed, proposalSeed); - float3 risVis; + seed, proposalSeed); L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, - activeSss, risVis); + activeSss); } // Thin-surface SSS transmission. Light entering from the back face scatters through toward the @@ -1291,16 +314,16 @@ 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 && diffuseDepth <= MAX_SSS_DIFFUSE_DEPTH) { + if (sss > 0.0 && hitDepth <= MAX_SSS_INDIRECT_DEPTH) { float backNdl = max(0.0, dot(-n, lightDir)); if (backNdl > 0.0) { VisibilityResult shadowBack = visibility(hitPos - n * SURF_BIAS, lightDir, 10000.0); float3 visB = shadowBack.transmittance; // Same caustic as the front-face NEE — underwater kelp/seagrass transmission should // flicker with the same light bands as the floor around it. - if (inWater && waterWaves && shadowBack.waterHitT > 0.0) { + if (medium.current.water && waterWaves && shadowBack.waterHitT > 0.0) { visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, - lightDir, shadowBack.waterHitT); + lightDir, shadowBack.waterHitT); } if (max(visB.r, max(visB.g, visB.b)) > 0.0) { float cosT = dot(lightDir, rd); @@ -1309,27 +332,38 @@ 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++; + // Preserve all local lighting at the terminal hit, but do not sample a continuation that the + // bounce loop cannot trace. + if (bounce >= maxBounces) { + break; + } // Indirect continuation: importance-sample the diffuse OR the GGX specular lobe, chosen by their // relative reflectance. - float ps = clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); + float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 + ? 1.0 + : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); if (rndf(seed) < ps) { - float3 h = sampleGGXVNDF(n, v, rough, seed); - float3 l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal - float ndl2 = dot(n, l); - if (ndl2 <= 0.0) { - break; // microfacet reflects below the surface: terminate this path + float3 l; + if (exactSpecular) { + // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the + // degenerate VNDF at alpha=0 and preserves a genuinely sharp reflection without a floor. + l = reflect(rd, n); + throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; + } else { + float3 h = sampleGGXVNDF(n, v, rough, seed); + l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal + float ndl2 = dot(n, l); + if (ndl2 <= 0.0) { + break; // microfacet reflects below the surface: terminate this path + } + // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. + float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); + throughput *= F * ggxG1(ndl2, rough) / ps; + rayConeSpread = max(rayConeSpread, rough * RAY_CONE_GLOSSY_SPREAD_SCALE); } - // VNDF + separable Smith ⇒ weight = F · G2/G1(v) = F · G1(l); divide by the lobe pdf ps. - float3 F = fresnelSchlick(max(0.0, dot(v, h)), F0); - throughput *= F * ggxG1(ndl2, rough) / ps; ro = p; rd = l; - rayConeSpread = max(rayConeSpread, rough * rough * RAY_CONE_GLOSSY_SPREAD_SCALE); showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc } else { throughput *= diffAlb / (1.0 - ps); @@ -1353,103 +387,28 @@ float3 tracePath(float3 ro, float3 rd, float primaryConeSpread, uint2 pix, uint [shader("raygeneration")] void main() { - worldPush = ConstPtr(pc.worldPushAddr)[0]; // load the per-frame push data from the BDA buffer - int2 pix = int2(DispatchRaysIndex().xy); - float2 size = float2(DispatchRaysDimensions().xy); - float2 uv = (float2(pix) + 0.5) / size; - float2 ndc = uv * 2.0 - 1.0; // unjittered pixel centre — the motion vector reprojects this - - // Offset the primary ray within the pixel by the sub-pixel jitter reported to DLSS-RR, so - // successive frames sample different positions and RR can resolve detail above render resolution. - // The guide buffers below come from this jittered first hit (they must match the jittered colour); - // only the motion vector stays on the unjittered centre, which DLSS expects. - float2 jndc = (uv + worldPush.jitter / size) * 2.0 - 1.0; - - float4 nearH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 1.0, 1.0)); - float4 farH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 0.0, 1.0)); - float3 nearP = nearH.xyz / nearH.w; - float3 farP = farH.xyz / farH.w; - - float3 origin = nearP + worldPush.camOffset; - float3 dir = normalize(farP - nearP); - float rayConeSpread = primaryRayConeSpread(jndc, size, dir); - - uint seed = (uint(pix.x) * 1973u + uint(pix.y) * 9277u + 26699u) ^ (worldPush.frameIndex * 2654435761u); - + worldPush = ConstPtr(pc.worldPushAddr)[0]; + if (pc.debugView != 0u) { + return; // pass A owns guide visualization output + } + uint2 dispatchIndex = DispatchRaysIndex().xy; + uint2 dimensions = DispatchRaysDimensions().xy; + int2 pix = int2(dispatchIndex); uint spp = max(worldPush.spp, 1u); + uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; + ConstPtr queue = ConstPtr(pc.pathQueueAddr); float3 frameRadiance = float3(0.0, 0.0, 0.0); - for (uint s = 0u; s < spp; s++) { - seed = pcg(seed); // decorrelate per-sample paths (shared camera ray, no AA jitter yet) - frameRadiance += tracePath(origin, dir, rayConeSpread, uint2(pix), s, seed); - } - frameRadiance /= float(spp); - - // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative - // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer - // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. - // Depth follows the primary surface (water included); only the MV tracks the refracted hit. - float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); - gv_depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; - - // Guide buffers: first-hit attributes for the denoiser / DLSS-RR (written every frame). - gNormal[pix] = float4(gv_normal, gv_rough); - gAlbedo[pix] = float4(gv_albedo, 1.0); - gDepth[pix] = gv_depth; - gSpecAlbedo[pix] = float4(gv_specAlb, 1.0); // specular albedo for RR demodulation - - // Motion vector: reproject this guide point through the previous frame's view-projection. - // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is - // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is - // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. - // For a dynamic surface, previous-frame position is the current point minus its world-space - // displacement; subtracting gv_motionObjDisp de-cameras the MV. - float4 prevClip = mul(worldPush.prevViewProj, float4(gv_motionHitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); - float2 prevNdc = prevClip.xy / prevClip.w; - float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc - // Water MV guide: track the one-refraction hit (the floor seen through the surface), not the water - // surface, so refracted content stops ghosting. That hit is its own feature: its screen motion is - // its own reprojection delta (prev - current projection) — bit-exact zero when static, no Snell - // solve needed. The constant per-frame refraction offset cancels out of prev - current. - if (gv_motionUseRefracted) { - float4 curClipRefr = mul(worldPush.curViewProj, float4(gv_motionHitCamRel, 1.0)); - curNdc = curClipRefr.xy / curClipRefr.w; - } - float2 motion = (prevNdc - curNdc) * 0.5 * size; - gMotion[pix] = motion; - float2 specMotion = specularReflectionMotion(gv_hitCamRel + worldPush.camOffset, dir, jndc, size, rayConeSpread); - gSpecMotion[pix] = specMotion; - - // Debug guide-buffer visualization: bypass accumulation and show a guide directly. - if (pc.debugView != 0u) { - float3 dbg; - if (pc.debugView == 1u) { - dbg = gv_normal * 0.5 + 0.5; // world normal -> [0,1] - } else if (pc.debugView == 2u) { - dbg = gv_albedo; // diffuse albedo - } else if (pc.debugView == 3u) { - dbg = float3(gv_depth, gv_depth, gv_depth); // HW reversed-Z depth (near=white, far/sky=black) - } else if (pc.debugView == 4u) { - dbg = float3(gv_rough, gv_rough, gv_rough); // roughness - } else if (pc.debugView == 6u) { - dbg = gv_specAlb; // specular albedo - } else if (pc.debugView == 7u) { - dbg = float3(clamp(0.5 + specMotion * 0.05, 0.0, 1.0), 0.5); // reflection motion - } else if (pc.debugView == 8u) { - dbg = float3(gv_emission, gv_emission, gv_emission); // resolved emission mask/strength - } else if (pc.debugView == 9u) { - float3 sourceColor = gv_emissionSource == EMISSION_SOURCE_LAB_PBR ? float3(0.15, 0.8, 1.0) - : gv_emissionSource == EMISSION_SOURCE_HEURISTIC ? float3(1.0, 0.45, 0.05) - : gv_emissionSource == EMISSION_SOURCE_UNIFORM ? float3(1.0, 1.0, 1.0) - : float3(0.0, 0.0, 0.0); - dbg = sourceColor * (gv_emissionSource == EMISSION_SOURCE_NONE - ? 0.0 : max(0.2, clamp(gv_emission, 0.0, 1.0))); - } else { - dbg = float3(clamp(0.5 + motion * 0.05, 0.0, 1.0), 0.5); // motion: red=+x, green=+y + uint recordIndex = pixelIndex; + for (uint leaf = 0u; leaf < MAX_PATH_SEGMENTS; ++leaf) { + PackedPathSegment packed = queue[recordIndex]; + PathSegment segment = unpackPathSegment(packed); + for (uint s = 0u; s < spp; ++s) { + frameRadiance += tracePath(segment, uint2(pix), s + leaf * spp); } - outImage[pix] = float4(dbg, 1.0); - return; + if (packed.nextRecord == PATH_NO_NEXT) { + break; + } + recordIndex = packed.nextRecord; } - - // Single noisy estimate; the denoiser (DLSS-RR) handles temporal convergence. - outImage[pix] = float4(frameRadiance, 1.0); + outImage[pix] = float4(frameRadiance / float(spp), 1.0); } diff --git a/shaders/world/world_common.slang b/shaders/world/world_common.slang index 566f0964..f7c034bd 100644 --- a/shaders/world/world_common.slang +++ b/shaders/world/world_common.slang @@ -8,6 +8,7 @@ module world_common; // scalarBlockLayout device feature. Keep std430 unless RtDeviceBringup explicitly enables that feature; // this alias is the single layout switch and generated Java serialization stays in lock-step. public typealias ConstPtr = Ptr; +public typealias DevicePtr = Ptr; // Push constant block. The hot inline lanes avoid dereferencing WorldPush for values that are read in // hit shaders or used for raygen control flow — every 64-bit device address lives here for exactly that @@ -25,6 +26,7 @@ public struct WorldPushConstants { public uint64_t lightLocalAliasAddr; // power aliases relative to each section's range public uint64_t lightGridCellAddr; // dense light grid cell headers (0 = global proposals only) public uint64_t lightGridSpanAddr; // packed weighted section spans referenced by cell headers + public uint64_t pathQueueAddr; // packed primary -> indirect continuation records public uint frameIndex; public uint debugView; }; @@ -55,7 +57,7 @@ public struct WorldPush { public float4 sunUv; // vanilla sun sprite atlas rect (u0,v0,u1,v1) public float4 moonUv; // vanilla current-moon-phase sprite atlas rect public float4 waterParams; // xyz camera-biome water tint, w wave time (s) - public float4 waterAnchor; // xy world-stable wave-domain anchor + public float4 waterAnchor; // xy world-stable wave-domain anchor, z previous wave time public float4x4 curViewProj; // forward camera-relative view-projection public uint breakCount; public BreakEntry breaking[8]; // Java capacity and breakCount serialization are generated from this @@ -140,11 +142,13 @@ public static const uint PAYLOAD_SHOW_CELESTIAL = 4u; // Set by world.rchit on a terrain hit whose prim carries TERRAIN_PRIM_IN_LIGHT_BUFFER: this emitter is // RIS-sampled, so raygen gates its direct-hit emission on diffuse continuation rays (no double count). public static const uint PAYLOAD_EMITTER_IN_LIST = 128u; -// Set by world.rchit on a MATERIAL_WATER hit: true when the incoming ray travels against the prim's -// outward face normal (entering the water volume), false when it exits (water -> air). Bit 2 -// (PAYLOAD_SHOW_CELESTIAL) is only meaningful on a miss and this bit only on a hit, so they never need -// to be read together, but they get distinct bits anyway to keep the two concerns unambiguous. -public static const uint PAYLOAD_WATER_ENTERING = 8u; +// Set by world.rchit on any dielectric hit (water or glass/ice): true when the incoming ray travels +// against the prim's outward face normal (entering the volume), false when it exits. Derived from face +// orientation rather than toggled, so a stray or missing face cannot corrupt the medium for the rest of +// the path — each crossing re-derives which side it is on. Bit 2 (PAYLOAD_SHOW_CELESTIAL) is only +// meaningful on a miss and this bit only on a hit, so they never need to be read together, but they get +// distinct bits anyway to keep the two concerns unambiguous. +public static const uint PAYLOAD_DIELECTRIC_ENTERING = 8u; public static const uint PAYLOAD_EMISSION_SOURCE_SHIFT = 4u; public static const uint PAYLOAD_EMISSION_SOURCE_MASK = 7u; public static const uint EMISSION_SOURCE_NONE = 0u; @@ -154,9 +158,14 @@ public static const uint EMISSION_SOURCE_LAB_PBR = 3u; public static const uint PAYLOAD_MATERIAL_MASK = 3u; public static const uint MATERIAL_OPAQUE = 0u; +// Both dielectrics are volumes: they refract and push a participating medium. They stay distinct ids +// because water owns behaviour nothing else has — an animated wave surface, the caustic term, biome-tint +// absorption calibrated per block of depth, and a chit path fed by the fluid mesher rather than the +// (inset) translucent terrain layer. MATERIAL_DIELECTRIC is everything else transparent: glass, ice, and +// any solid transparent block a pack defines. public static const uint MATERIAL_WATER = 1u; public static const uint MATERIAL_PARTICLE = 2u; -public static const uint MATERIAL_GLASS = 3u; +public static const uint MATERIAL_DIELECTRIC = 3u; // Entity per-triangle record (48 B). The final lane mirrors TerrainPrim's integer material metadata. public struct Prim { @@ -244,15 +253,3 @@ public uint packHalf2(float2 v) { public float2 unpackHalf2(uint p) { return float2(f16tof32(p & 0xFFFFu), f16tof32(p >> 16)); } - -// Vanilla's block/entity atlases are ordinary sRGB-encoded PNGs sampled through a plain UNORM view (no -// hardware sRGB decode), so the raw texel is gamma-encoded. The path tracer's BRDF math (Lambertian/GGX, -// Beer-Lambert, NEE) is only correct on linear radiance/albedo, so every COLOR texture sample (not the -// LabPBR _s/_n data maps, which are already linear data channels) must go through this before it's used -// in any lighting calculation. -public float3 srgbToLinear(float3 c) { - float3 lo = c / 12.92; - float3 hi = pow((c + 0.055) / 1.055, float3(2.4, 2.4, 2.4)); - float3 isHi = step(float3(0.04045, 0.04045, 0.04045), c); - return lerp(lo, hi, isHi); -} diff --git a/shaders/world/world_core.slang b/shaders/world/world_core.slang new file mode 100644 index 00000000..62252f11 --- /dev/null +++ b/shaders/world/world_core.slang @@ -0,0 +1,96 @@ +// Bindings, per-frame push state, the ray payload, and the constants every pass shares. +// Import first: everything below depends on pc, worldPush and payload. + +import world_common; + +[[vk::push_constant]] public WorldPushConstants pc; + +[[vk::binding(0, 0)]] public RaytracingAccelerationStructure topLevelAS; +// HDR trace target: linear radiance, may exceed 1. Tonemap happens later at the display.comp seam. +[[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D outImage; +// Guide buffers — first-hit (primary-visibility) attributes consumed by the denoiser/DLSS-RR. Written +// every frame regardless of accumulation. gNormal.w carries LINEAR roughness (= GGX alpha, which is what +// DLSS-RR documents for this input); gDepth carries HW reversed-Z depth. +[[vk::binding(3, 0)]] [format("rgba16f")] public RWTexture2D gNormal; // xyz world normal, w linear roughness +[[vk::binding(4, 0)]] [format("rgba16f")] public RWTexture2D gAlbedo; // rgb diffuse albedo +[[vk::binding(5, 0)]] [format("r32f")] public RWTexture2D gDepth; // HW reversed-Z depth +[[vk::binding(6, 0)]] [format("rg16f")] public RWTexture2D gMotion; // screen-space motion (render px) +[[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; // exact-mirror reflected diffuse × reflectance; material reflectance otherwise +[[vk::binding(8, 0)]] [format("rg16f")] public RWTexture2D gSpecMotion; // reflection MVs for RR + +// Per-frame push data loaded once from the BDA buffer at the top of main() (same pattern as the GLSL +// `worldPush` global). Layout constants generated from this module's SPIR-V — see world_common.WorldPush. +public static WorldPush worldPush = {}; + +// The radiance payload remains module-level so tracePath and its guide helpers can share it across +// HitObject trace/invoke calls, mirroring the GLSL rayPayloadEXT global. +public static Payload payload = {}; + +public struct VisibilityResult { + public float3 transmittance; + public float waterHitT; +}; + + + +public uint payloadMaterial() { return payload.flags & PAYLOAD_MATERIAL_MASK; } +// Set by world.rchit on any dielectric hit (see PAYLOAD_DIELECTRIC_ENTERING) — whether the ray was +// travelling into the volume (vs. out of it) at this hit's face. +public bool payloadDielectricEntering() { return (payload.flags & PAYLOAD_DIELECTRIC_ENTERING) != 0u; } +public bool payloadEmitterInList() { return (payload.flags & PAYLOAD_EMITTER_IN_LIST) != 0u; } +// LINEAR roughness, i.e. GGX alpha directly — NOT perceptual roughness. This is the one convention used +// end to end: LabPBR defines roughness = (1 - perceptualSmoothness)^2 and RtLabPbr.decode stores exactly +// that, RtMaterials.Profile carries the same units, and DLSS-RR wants linear roughness in its guide. So +// nothing squares it: ggxD/ggxG1/sampleGGXVNDF/rrSpecularAlbedo all take this value as alpha as-is, and +// any threshold compared against it is an alpha threshold. +public float payloadRoughness() { return unpackHalf2(payload.roughMetal).x; } +public float payloadMetalness() { return unpackHalf2(payload.roughMetal).y; } +public float payloadEmission() { return unpackHalf2(payload.emissionSss).x; } +public float payloadSss() { return unpackHalf2(payload.emissionSss).y; } +public float payloadIor() { return unpackHalf2(payload.iorTransmission).x; } +public float payloadTransmission() { return unpackHalf2(payload.iorTransmission).y; } + +public static const float PI = 3.14159265359; +public static const float INV_PI = 0.31830988618; + +// The analytic sky (gradient + sun/moon discs + stars) is computed in world.rmiss. On a miss, raygen +// reads payload.albedo and accumulates that sky radiance. The directional NEE light is pushed separately +// as worldPush.lightDir / worldPush.lightRadiance / worldPush.sunDir. +public static const float SURF_BIAS = 0.005; // offset secondary-ray origins along the normal (anti-acne) +public static const float RAY_TMIN = 0.001; // tiny tmin: the normal offset already clears the surface + +// Water is a smooth dielectric. IOR 1.333; absorption is per-block Beer-Lambert extinction. +public static const float WATER_IOR = 1.333; +public static const float3 SKY_SPEC_ALBEDO = float3(0.5, 0.5, 0.5); // RR guide default for sky pixels. +public static const float3 SKY_DIFF_ALBEDO = float3(0.5, 0.5, 0.5); + +// Transmitted-ray origin offset when crossing a face from the TRANSLUCENT terrain layer (glass, ice). +// Must be SMALLER than the mesher-side TRANSLUCENT_INSET (RtTerrainMesher, 2e-4 blocks): those quads are +// recessed into their block by that inset, so a face touching a slab/stair leaves a tiny gap before the +// neighbour's surface. A large bias (SURF_BIAS) would restart the ray past that neighbour and see +// through it; this lands the ray inside the gap so the neighbour is still hit, while still clearing the +// surface it left to avoid self-intersection. Water is meshed by RtFluidMesher with no inset, so it uses +// the ordinary bias. +public static const float INSET_TRANSMIT_BIAS = 1.0e-4; + +// Ray-cone texture LOD: raygen tracks a one-pixel footprint along the path, and closest-hit converts it +// to texture mip levels from the local UV/world-space scale. Diffuse/glossy bounces widen the cone so +// indirect texture fetches don't keep sampling mip 0 through broad lobes. +public static const float RAY_CONE_MIN_SPREAD = 1.0e-5; +public static const float RAY_CONE_MIN_WIDTH = 1.0e-5; +public static const float RAY_CONE_DIFFUSE_SPREAD = 0.25; +public static const float RAY_CONE_GLOSSY_SPREAD_SCALE = 0.35; + +// Stored roughness IS alpha (see payloadRoughness), so these compare against it directly. +public static const float MIRROR_ALPHA_MAX = 4.0e-4; // LabPBR perceptual smoothness >= 0.98 +// Above this the specular lobe is too broad for a single mirrored reprojection to describe, so the +// reflection MV is left at zero and RR falls back to the ordinary MV. +public static const float SPEC_MOTION_ALPHA_MAX = 0.25; // perceptual smoothness <= 0.5 + +public bool isDeltaAlpha(float roughness) { + return clamp(roughness, 0.0, 1.0) <= MIRROR_ALPHA_MAX; +} + +// Continuations one sample may carry: the camera segment, plus the reflection branch a primary +// dielectric splits off. Raising this needs a real queue, which is what the continuation buffer is for. +public static const uint MAX_PATH_SEGMENTS = 2u; diff --git a/shaders/world/world_guide.rmiss.slang b/shaders/world/world_guide.rmiss.slang new file mode 100644 index 00000000..39f71c62 --- /dev/null +++ b/shaders/world/world_guide.rmiss.slang @@ -0,0 +1,12 @@ +// Minimal miss record for reflection/refraction guide probes and ordinary TraceRay shadow visibility. +// Guide rays consume only hit-vs-miss state; fixed sky guide values are supplied by raygen. Shadow rays +// seed flags=1 and use this clear as their miss marker, avoiding a hit-object dependency. +import world_common; + +[shader("miss")] +void main(inout Payload payload) { + // Preserve hitT: guide payloads already seed the -1 miss sentinel, while shadow any-hit may have + // recorded the nearest water crossing here for underwater caustics. + payload.normal = half3(0.0h, 0.0h, 0.0h); + payload.flags = 0u; +} diff --git a/shaders/world/world_primary.rgen.slang b/shaders/world/world_primary.rgen.slang new file mode 100644 index 00000000..f55c74a1 --- /dev/null +++ b/shaders/world/world_primary.rgen.slang @@ -0,0 +1,250 @@ +// Primary/guide pass (pass A of the wavefront split — see docs/WAVEFRONT_PLAN.md). +// +// Traces one camera radiance ray, captures DLSS-RR guides, and writes one or two resumable +// continuations per pixel. At the first eligible dielectric it queues both post-interface branches and +// stops; every later radiance trace belongs to Pass B. Deterministic guide probes may continue through +// refraction, but Pass A does no NEE, RIS, SSS, or BSDF sampling. +// +// Perspective setup: invViewProj = inverse(proj * viewRot) maps clip to camera-relative world. Primary +// rays are reconstructed from two unprojected points (near and far plane) rather than assuming the eye +// is at the origin, because Minecraft bakes view bobbing into the projection, so the effective eye comes +// from the matrix. camOffset shifts camera-relative space into the terrain's rebased coordinates. Depth +// is reversed-Z (near=1, far=0). +// +// Pass A deliberately uses ordinary TraceRay. Its short, visually coherent direct/interface walk is +// latency-sensitive and does not amortize an SER reorder barrier. +import world_common; +import world_core; +import math; +import medium; +import segment; +import water; +import trace; +import guides; + +public PathSegment tracePrimary(PathSegment seg, + DevicePtr queue, + uint splitRecord, + out uint nextRecord) { + float3 ro = seg.ro; + float3 rd = seg.rd; + float3 throughput = seg.throughput; + MediumStack medium = seg.medium; + float rayConeWidth = seg.rayConeWidth; + float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); + uint seed = seg.seed; + bool showCelestial = seg.showCelestial; + bool waterWaves = (worldPush.flags & 16u) != 0u; + nextRecord = PATH_NO_NEXT; + + { + int bounce = seg.bounce; + PathSegment terminal = makePathSegment(ro, rd, throughput, medium, + rayConeWidth, rayConeSpread, seed, bounce, + showCelestial); + traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, + showCelestial, rayConeWidth, rayConeSpread); + + if (payload.hitT < 0.0) { + if (bounce == 0) { + gv_normal = float3(0.0, 0.0, 0.0); + gv_albedo = SKY_DIFF_ALBEDO; + gv_rough = 1.0; + gv_hitCamRel = rd * 1.0e6; + gv_motionUseRefracted = false; + gv_motionObjDisp = float3(0.0, 0.0, 0.0); + gv_spec = makeSpecSurface(gv_hitCamRel, gv_normal, 1.0, SKY_SPEC_ALBEDO); + } + return terminal; + } + + float3 n = payload.normal; + float3 hitPos = ro + rd * payload.hitT; + uint material = payloadMaterial(); + if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) { + float rough = material == MATERIAL_PARTICLE + ? 1.0 : clamp(payloadRoughness(), 0.0, 1.0); + bool exactSpecular = material == MATERIAL_OPAQUE && isDeltaAlpha(rough); + rough = exactSpecular ? 0.0 : rough; + float metal = material == MATERIAL_PARTICLE + ? 0.0 : clamp(payloadMetalness(), 0.0, 1.0); + float3 diffAlb = material == MATERIAL_PARTICLE + ? float3(payload.albedo) : float3(payload.albedo) * (1.0 - metal); + if (bounce == 0) { + gv_normal = n; + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionUseRefracted = false; + gv_motionObjDisp = payload.motionPrev; + if (material == MATERIAL_PARTICLE) { + gv_albedo = payload.albedo; + gv_rough = 1.0; + gv_spec = makeSpecSurface(gv_hitCamRel, n, 1.0, + float3(0.0, 0.0, 0.0)); + } else { + float3 v = -rd; + gv_albedo = diffAlb; + gv_rough = rough; + gv_spec = makeSpecSurface(gv_hitCamRel, n, float3(payload.motionPrev), rough, + rrSpecularAlbedo(payload.f0, rough, dot(n, v))); + } + } + + return terminal; + } + + // This hit is consumed in pass A, so carry its travelled-medium absorption and cone growth into + // the record for the next trace. The terminal hit above is deliberately not consumed here. + if (any(medium.current.extinction > 0.0)) { + throughput *= exp(-medium.current.extinction * payload.hitT); + } + rayConeWidth = max(rayConeWidth + rayConeSpread * max(payload.hitT, 0.0), + RAY_CONE_MIN_WIDTH); + + bool isWater = material == MATERIAL_WATER; + bool entering = payloadDielectricEntering(); + float3 geometricNormal = n; + float3 previousNormal = n; + if (isWater && waterWaves) { + float2 waterDomain = hitPos.xz + worldPush.waterAnchor.xy; + float waterFootprint = rayConeWidth / max(abs(dot(-rd, geometricNormal)), 0.2); + if (bounce == 0 && abs(geometricNormal.y) >= 0.5) { + float2 currentGrad; + float2 previousGrad; + waterWaveGradTemporal(waterDomain, worldPush.waterParams.w, + worldPush.waterAnchor.z, waterFootprint, currentGrad, previousGrad); + float orientation = geometricNormal.y >= 0.0 ? 1.0 : -1.0; + n = orientation * normalize(float3(-currentGrad.x, 1.0, -currentGrad.y)); + previousNormal = orientation + * normalize(float3(-previousGrad.x, 1.0, -previousGrad.y)); + } else { + n = applyWaterWaves(geometricNormal, waterDomain, + worldPush.waterParams.w, waterFootprint); + } + } + + float transmission = clamp(payloadTransmission(), 0.0, 1.0); + Medium entered = makeDielectricMedium(isWater, max(payloadIor(), 1.0), + payload.albedo, transmission); + float etaI = medium.current.ior; + float etaT = entering ? entered.ior : medium.outer.ior; + float F = fresnelDielectric(clamp(dot(-rd, n), 0.0, 1.0), etaI, etaT); + float3 transmittedDir = refract(rd, n, etaI / etaT); + float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; + + if (bounce == 0) { + gv_normal = n; + gv_rough = 0.0; + gv_albedo = float3(0.0, 0.0, 0.0); + gv_hitCamRel = hitPos - worldPush.camOffset; + gv_motionUseRefracted = false; + gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; + gv_spec = makeSpecSurface(gv_hitCamRel, n, previousNormal, geometricNormal, + isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), + gv_rough, float3(F, F, F)); + if (dot(transmittedDir, transmittedDir) > 0.0) { + MediumStack guideMedium = medium; + if (entering) { + mediumPush(guideMedium, entered); + } else { + mediumPop(guideMedium); + } + resolveTransmissionGuide(hitPos, transmittedDir, geometricNormal, + guideMedium, transmitBias, rayConeWidth, rayConeSpread, + !isWater && entering + ? float3(payload.albedo) : float3(1.0, 1.0, 1.0)); + } + } + + // Split once, write both post-interface continuations, and stop Pass A radiance traversal. + // Pass B owns every radiance trace after this point. + bool splitEligible = dot(transmittedDir, transmittedDir) > 0.0 + && F > 0.0 && F < 1.0; + if (splitEligible) { + MediumStack transmittedMedium = medium; + if (entering) { + mediumPush(transmittedMedium, entered); + } else { + mediumPop(transmittedMedium); + } + float3 deferredDir = normalize(transmittedDir); + PathSegment deferred = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), + deferredDir, throughput * (1.0 - F), transmittedMedium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true); + queue[splitRecord] = packPathSegment(deferred, PATH_NO_NEXT); + nextRecord = splitRecord; + float3 reflectedDir = reflect(rd, n); + PathSegment reflected = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), + reflectedDir, throughput * F, medium, + rayConeWidth, rayConeSpread, seed ^ 0xa511e9b3u, bounce + 1, + true); + return reflected; + } + + bool hasTransmission = dot(transmittedDir, transmittedDir) > 0.0; + PathSegment continuation; + if (!hasTransmission || F >= 1.0) { + float3 reflectedDir = reflect(rd, n); + continuation = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, reflectedDir, SURF_BIAS), + reflectedDir, throughput * F, medium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true); + } else { + float3 deferredDir = normalize(transmittedDir); + if (entering) { + mediumPush(medium, entered); + } else { + mediumPop(medium); + } + continuation = makePathSegment( + offsetSurfaceOrigin(hitPos, geometricNormal, deferredDir, transmitBias), + deferredDir, throughput * (1.0 - F), medium, + rayConeWidth, rayConeSpread, seed, bounce + 1, + true); + } + return continuation; + } +} + +[shader("raygeneration")] +void main() { + worldPush = ConstPtr(pc.worldPushAddr)[0]; + uint2 dispatchIndex = DispatchRaysIndex().xy; + uint2 dimensions = DispatchRaysDimensions().xy; + int2 pix = int2(dispatchIndex); + float2 size = float2(dimensions); + float2 uv = (float2(pix) + 0.5) / size; + float2 jndc = (uv + worldPush.jitter / size) * 2.0 - 1.0; + float4 nearH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 1.0, 1.0)); + float4 farH = mul(worldPush.invViewProj, float4(jndc.x, jndc.y, 0.0, 1.0)); + float3 nearP = nearH.xyz / nearH.w; + float3 farP = farH.xyz / farH.w; + float3 origin = nearP + worldPush.camOffset; + float3 dir = normalize(farP - nearP); + float rayConeSpread = primaryRayConeSpread(jndc, size, dir); + uint seed = (dispatchIndex.x * 1973u + dispatchIndex.y * 9277u + 26699u) + ^ (worldPush.frameIndex * 2654435761u); + MediumStack cameraMedium = makeMediumStack((worldPush.flags & 1u) != 0u + ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) + : airMedium()); + uint pixelIndex = dispatchIndex.y * dimensions.x + dispatchIndex.x; + uint baseRecordCount = dimensions.x * dimensions.y; + uint splitRecord = baseRecordCount + pixelIndex; + DevicePtr queue = DevicePtr(pc.pathQueueAddr); + + seed = pcg(seed); + PathSegment current = makePathSegment(origin, dir, float3(1.0, 1.0, 1.0), + cameraMedium, 0.0, rayConeSpread, seed, 0, true); + uint nextRecord; + PathSegment terminal = tracePrimary( + current, queue, splitRecord, nextRecord); + queue[pixelIndex] = packPathSegment(terminal, nextRecord); + + writeGuides(pix, dir, jndc, size, rayConeSpread); + if (pc.debugView != 0u) { + writeDebugView(pix); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index b540c568..3ef0b06f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -178,8 +178,8 @@ private static OptionInstance debugView() { // CycleButton (used for Enum values) already prepends "caption: " itself (DisplayState. // NAME_AND_VALUE), so this must return only the value's text, not caption + value again. (caption, value) -> Component.translatable("caustica.options.rt.debugView." + value), - new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), Codec.INT), - Math.clamp(setting.value(), 0, 9), + new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7), Codec.INT), + Math.clamp(setting.value(), 0, 7), setting::set); } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index de538743..46e884eb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -95,6 +95,7 @@ public static boolean enabled() { // Hot addresses/frameIndex and raygen's debugView avoid unnecessary global-memory dereferences; // WorldPushConstantsData is generated from the same Slang module and owns this second ABI as well. private static final int GUIDE_COUNT = 6; // RR guide buffers bound at world-pipeline bindings 3..8 + private static final long PATH_RECORD_BYTES = 48L; private static int debugView() { return CausticaConfig.Rt.Composite.DEBUG_VIEW.value(); } @@ -179,6 +180,9 @@ public static long frameCounter() { private int pushSlot; private RtDisplayPipeline displayPipeline; private RtImage output; + // Packed primary -> indirect continuations. Pass A is fixed at one sample and owns two records per + // render pixel (base + optional transmission); Pass B resamples them at the configured SPP. + private RtBuffer continuationQueue; private RtImage displayImage; // Parallel PQ-encoded ([0,1], ST.2084) HDR display image. Written alongside displayImage when HDR is // enabled. When the PQ swapchain is active, the combined UI overlay is composited over this image, then @@ -263,6 +267,8 @@ private static final class PushSlot { private float mvCamDeltaY; private float mvCamDeltaZ; private boolean mvHasPrev; + private float previousWaterWaveTime; + private boolean waterWaveTimeValid; private long atlasSampler; private boolean failed; private boolean loggedActive; @@ -519,8 +525,11 @@ public void ensureResourcesReady(RtContext ctx) { private RtPipeline ensureWorld(RtContext ctx) { if (worldPipeline == null) { bindlessTextureCapacity = RtEntityTextures.maxTextures(); - worldPipeline = RtPipeline.create(ctx, RtDeviceBringup.worldRaygenShader(), - new String[]{"world.rmiss.spv"}, "world.rchit.spv", "world.rahit.spv", + worldPipeline = RtPipeline.create(ctx, new String[]{ + RtDeviceBringup.worldPrimaryRaygenShader(), + RtDeviceBringup.worldRaygenShader()}, + new String[]{"world.rmiss.spv", "world_guide.rmiss.spv"}, + "world.rchit.spv", "world.rahit.spv", WorldPushConstantsData.BYTE_SIZE, true, GUIDE_COUNT, bindlessTextureCapacity, true); // Per-frame world data lives in this BDA ring; the pipeline pushes its address and hot fields. if (pushRing == null) { @@ -687,7 +696,8 @@ private void destroyGuideImages() { private void ensureOutput(RtContext ctx, int width, int height) { boolean rrEnabled = RtDlssRr.enabled(); int rrQuality = rrEnabled ? RtDlssRr.quality() : Integer.MIN_VALUE; - if (output != null && displayImage != null && hdrDisplayImage != null && rrOutput != null && exposure.ready() + if (output != null && continuationQueue != null + && displayImage != null && hdrDisplayImage != null && rrOutput != null && exposure.ready() && displayW == width && displayH == height && renderSizeRrEnabled == rrEnabled && renderSizeRrQuality == rrQuality) { return; @@ -702,6 +712,10 @@ private void ensureOutput(RtContext ctx, int width, int height) { if (output != null) { output.destroy(); } + if (continuationQueue != null) { + continuationQueue.destroy(); + continuationQueue = null; + } destroyGuideImages(); displayW = width; @@ -721,6 +735,12 @@ private void ensureOutput(RtContext ctx, int width, int height) { // mapping seam. displayImage stays R8G8B8A8 to match the main target it is copied into // (vkCmdCopyImage requires texel-size-compatible formats). output = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH); + long pixelRecords = Math.multiplyExact((long) renderW, (long) renderH); + long continuationBytes = Math.multiplyExact( + Math.multiplyExact(pixelRecords, 2L), PATH_RECORD_BYTES); + continuationQueue = ctx.createBuffer(continuationBytes, + VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false, + "path continuation queue " + renderW + "x" + renderH + "x2"); displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM, "RT display image " + width + "x" + height); // PQ-encoded ([0,1], ST.2084) HDR display image, written in parallel by display.comp when HDR mode is active. hdrDisplayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height); @@ -736,6 +756,7 @@ private void ensureOutput(RtContext ctx, int width, int height) { exposure.ensureResources(ctx); mvHasPrev = false; // recreated images -> first MV frame is zero + waterWaveTimeValid = false; if (worldPipeline != null) { worldPipeline.setStorageImage(output.view); bindGuideImages(); @@ -833,13 +854,21 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo wtg = ((wc >> 8) & 0xFF) / 255f; wtb = (wc & 0xFF) / 255f; } - Float4 waterParams = new Float4(wtr, wtg, wtb, - (float) (System.nanoTime() / 1.0e9 % 3600.0)); + float waterWaveTime = (float) (System.nanoTime() / 1.0e9 % 3600.0); + float waterWaveDelta = waterWaveTime - previousWaterWaveTime; + // A first frame, long pause, or one-hour phase wrap has no adjacent wave frame to reproject. + // Use the current phase so the reflection MV is neutral instead of manufacturing a huge jump. + float priorWaterWaveTime = waterWaveTimeValid + && waterWaveDelta >= 0f && waterWaveDelta <= 0.25f + ? previousWaterWaveTime : waterWaveTime; + previousWaterWaveTime = waterWaveTime; + waterWaveTimeValid = true; + Float4 waterParams = new Float4(wtr, wtg, wtb, waterWaveTime); // W1 wave-domain anchor: the terrain rebase origin reduced mod 4096 (kept small for shader // float precision). hitPos.xz (rebased) + anchor reconstructs a world-pinned coordinate, so the // ripple pattern stays fixed in the world as the player moves and the rebase origin shifts. Float4 waterAnchor = new Float4(terrain.blockX & WATER_ANCHOR_MASK, - terrain.blockZ & WATER_ANCHOR_MASK, 0f, 0f); + terrain.blockZ & WATER_ANCHOR_MASK, priorWaterWaveTime, 0f); // Rebuild the TLAS this frame from static section instances merged with dynamic entity // instances, bind it into the pipeline's descriptor ring, record the build, then barrier so @@ -924,11 +953,16 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo RtMaterialRegistry.INSTANCE.tableAddress(), terrain.lightBufferAddress(), terrain.lightAliasBufferAddress(), terrain.lightLocalAliasBufferAddress(), terrain.lightGridCellBufferAddress(), - terrain.lightGridSpanBufferAddress(), + terrain.lightGridSpanBufferAddress(), continuationQueue.deviceAddress, (int) frameCounter, debugView).write(pushConstants); - try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world trace"); - RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.trace")) { - active.trace(cmd, renderW, renderH, pushConstants); + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world primary trace"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.tracePrimary")) { + active.trace(cmd, renderW, renderH, pushConstants, 0); + } + VulkanCommandEncoder.memoryBarrier(cmd, stack); // continuation/guide writes visible to pass B + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world indirect trace"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.traceIndirect")) { + active.trace(cmd, renderW, renderH, pushConstants, 1); } VulkanCommandEncoder.memoryBarrier(cmd, stack); // RT writes visible to DLSS reads // DLSS-RR denoise + upscale. The RT pass wrote noisy color (render res) + guides; @@ -1211,6 +1245,10 @@ public void destroy() { output.destroy(); output = null; } + if (continuationQueue != null) { + continuationQueue.destroy(); + continuationQueue = null; + } destroyGuideImages(); exposure.destroy(); if (displayPipeline != null) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java index 515b0986..616135f7 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java @@ -22,7 +22,6 @@ import org.lwjgl.vulkan.VkPhysicalDeviceRayTracingPositionFetchFeaturesKHR; import org.lwjgl.vulkan.VkPhysicalDeviceRayQueryFeaturesKHR; import org.lwjgl.vulkan.VkPhysicalDeviceRayTracingInvocationReorderFeaturesEXT; -import org.lwjgl.vulkan.VkPhysicalDeviceRayTracingInvocationReorderFeaturesNV; import org.lwjgl.vulkan.VkPhysicalDeviceFeatures; import org.lwjgl.vulkan.VkPhysicalDeviceVulkan12Features; import org.lwjgl.vulkan.VkPhysicalDeviceOpacityMicromapFeaturesEXT; @@ -53,8 +52,6 @@ import static org.lwjgl.vulkan.KHRPresentId.VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_ID_FEATURES_KHR; import static org.lwjgl.vulkan.EXTRayTracingInvocationReorder.VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME; import static org.lwjgl.vulkan.EXTRayTracingInvocationReorder.VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_INVOCATION_REORDER_FEATURES_EXT; -import static org.lwjgl.vulkan.NVRayTracingInvocationReorder.VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME; -import static org.lwjgl.vulkan.NVRayTracingInvocationReorder.VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_INVOCATION_REORDER_FEATURES_NV; /** * RT device bring-up. Enables the hardware ray-tracing device extensions and their @@ -94,15 +91,6 @@ public static boolean enabledByProperty() { VK_KHR_RAY_TRACING_POSITION_FETCH_EXTENSION_NAME, VK_KHR_RAY_QUERY_EXTENSION_NAME); - /** - * Shader Execution Reordering is still required by Caustica's current world raygen, but the SPIR-V - * extension differs between the original NVIDIA path and the ratified EXT path. Prefer NV when present - * for older NVIDIA drivers, otherwise use EXT. - */ - public static final List SER_EXTENSIONS = List.of( - VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, - VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME); - /** * OPTIONAL RT extensions: enabled only when the selected device supports them AND the gate is on, but * never required — a device lacking them still comes up RT-capable (unlike {@link #RT_EXTENSIONS}, whose @@ -149,9 +137,6 @@ public static boolean enabledByProperty() { private static final VulkanPNextStruct RAY_QUERY_FEATURES_STRUCT = new VulkanPNextStruct( VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_QUERY_FEATURES_KHR, VkPhysicalDeviceRayQueryFeaturesKHR.SIZEOF); - private static final VulkanPNextStruct SER_NV_FEATURES_STRUCT = new VulkanPNextStruct( - VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_INVOCATION_REORDER_FEATURES_NV, - VkPhysicalDeviceRayTracingInvocationReorderFeaturesNV.SIZEOF); private static final VulkanPNextStruct SER_EXT_FEATURES_STRUCT = new VulkanPNextStruct( VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_INVOCATION_REORDER_FEATURES_EXT, VkPhysicalDeviceRayTracingInvocationReorderFeaturesEXT.SIZEOF); @@ -190,9 +175,6 @@ public static boolean enabledByProperty() { VkPhysicalDeviceRayTracingPositionFetchFeaturesKHR.RAYTRACINGPOSITIONFETCH); private static final VulkanFeature RAY_QUERY_FEATURE = new VulkanFeature( RAY_QUERY_FEATURES_STRUCT, "rayQuery", VkPhysicalDeviceRayQueryFeaturesKHR.RAYQUERY); - private static final VulkanFeature SER_NV_FEATURE = new VulkanFeature( - SER_NV_FEATURES_STRUCT, "rayTracingInvocationReorder(NV)", - VkPhysicalDeviceRayTracingInvocationReorderFeaturesNV.RAYTRACINGINVOCATIONREORDER); private static final VulkanFeature SER_EXT_FEATURE = new VulkanFeature( SER_EXT_FEATURES_STRUCT, "rayTracingInvocationReorder(EXT)", VkPhysicalDeviceRayTracingInvocationReorderFeaturesEXT.RAYTRACINGINVOCATIONREORDER); @@ -216,17 +198,20 @@ public static boolean enabledByProperty() { RAY_QUERY_FEATURE); private enum SerBackend { - NONE("none", null, "world.rgen.spv"), - NV("NV", VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, "world_nv.rgen.spv"), - EXT("EXT", VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, "world.rgen.spv"); + NONE("none", null, "world_primary.rgen.spv", "world.rgen.spv"), + EXT("EXT", VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME, + "world_primary.rgen.spv", "world_ser.rgen.spv"); final String label; final String extensionName; + final String worldPrimaryRaygenShader; final String worldRaygenShader; - SerBackend(String label, String extensionName, String worldRaygenShader) { + SerBackend(String label, String extensionName, String worldPrimaryRaygenShader, + String worldRaygenShader) { this.label = label; this.extensionName = extensionName; + this.worldPrimaryRaygenShader = worldPrimaryRaygenShader; this.worldRaygenShader = worldRaygenShader; } } @@ -234,7 +219,7 @@ private enum SerBackend { private record FeatureSupport(List missingRequired, SerBackend serBackend, boolean omm, boolean presentId, boolean wideLines) { boolean supportsRt() { - return missingRequired.isEmpty() && serBackend != SerBackend.NONE; + return missingRequired.isEmpty(); } } @@ -250,8 +235,8 @@ public static String worldRaygenShader() { return serBackend.worldRaygenShader; } - public static boolean serNvEnabled() { - return serBackend == SerBackend.NV; + public static String worldPrimaryRaygenShader() { + return serBackend.worldPrimaryRaygenShader; } public static boolean serExtEnabled() { @@ -456,14 +441,9 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD boolean hasSerExt = physicalDevice.hasDeviceExtension( VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME); - boolean hasSerNv = physicalDevice.hasDeviceExtension( - VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME); if (hasSerExt) { SER_EXT_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); } - if (hasSerNv) { - SER_NV_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); - } boolean queryOmm = ommRequested() && physicalDevice.hasDeviceExtension(VK_EXT_OPACITY_MICROMAP_EXTENSION_NAME); @@ -486,13 +466,8 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD missing.add(feature.name()); } } - // Preserve the existing EXT preference, but fall back to NV when EXT is advertised with a - // false feature boolean. SerBackend supportedSer = hasSerExt && SER_EXT_FEATURE.get(available) ? SerBackend.EXT - : hasSerNv && SER_NV_FEATURE.get(available) ? SerBackend.NV : SerBackend.NONE; - if (supportedSer == SerBackend.NONE) { - missing.add("rayTracingInvocationReorder(NV or EXT)"); - } + : SerBackend.NONE; return new FeatureSupport(missing, supportedSer, queryOmm && OMM_FEATURE.get(available), queryPresentId && PRESENT_ID_FEATURE.get(available), @@ -506,11 +481,6 @@ private static String firstUnsupportedExtension(VulkanPhysicalDevice physicalDev return ext; } } - if (!physicalDevice.hasDeviceExtension(VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME) - && !physicalDevice.hasDeviceExtension(VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME)) { - return VK_NV_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME + " or " - + VK_EXT_RAY_TRACING_INVOCATION_REORDER_EXTENSION_NAME; - } return null; } @@ -529,7 +499,7 @@ public static void addExtensions(List augmentedExtensions, VulkanPhysica } } String serExtension = support.serBackend.extensionName; - if (!augmentedExtensions.contains(serExtension)) { + if (serExtension != null && !augmentedExtensions.contains(serExtension)) { augmentedExtensions.add(serExtension); } for (String ext : supportedOptionalExtensions(physicalDevice, support)) { @@ -579,7 +549,9 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { // Bindless entity textures: a runtime-sized sampler2D[] indexed non-uniformly in the hit shader, // with partially-bound + update-after-bind slots (a growing per-RenderType registry). Core on the // VK 1.4 device; just needs enabling alongside bufferDeviceAddress on the same struct. - features.add(support.serBackend == SerBackend.NV ? SER_NV_FEATURE : SER_EXT_FEATURE); + if (support.serBackend == SerBackend.EXT) { + features.add(SER_EXT_FEATURE); + } // Optional: wideLines (core VK10 feature, no extension). Lets the world-overlay pass (block // outline) draw a real thick native line via a raster pipeline's lineWidth / VK_DYNAMIC_STATE_LINE @@ -625,10 +597,10 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { serBackend = support.serBackend; List optionalExtensions = supportedOptionalExtensions(physicalDevice, support); CausticaMod.LOGGER.info( - "Ray tracing: enabling {} + {}{} + features [bufferDeviceAddress, accelerationStructure, rayTracingPipeline, rayQuery, rayTracingInvocationReorder({})" + "Ray tracing: enabling {}{}{} + features [bufferDeviceAddress, accelerationStructure, rayTracingPipeline, rayQuery, SER={}" + (wideLinesEnabled ? ", wideLines(max=" + maxLineWidth + ")" : "") + (ommEnabled ? ", opacityMicromap" : "") + "] + overlayMsaa=" + overlayMsaaSamples + "x on [{}]", - RT_EXTENSIONS, serBackend.extensionName, + RT_EXTENSIONS, serBackend.extensionName == null ? "" : " + " + serBackend.extensionName, optionalExtensions.isEmpty() ? "" : " + " + optionalExtensions, serBackend.label, physicalDevice.deviceName()); } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java new file mode 100644 index 00000000..58cd106a --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtDielectrics.java @@ -0,0 +1,48 @@ +package dev.comfyfluffy.caustica.rt.material; + +import net.minecraft.resources.Identifier; + +import java.util.Map; + +/** + * Built-in refractive indices for the dielectric blocks vanilla ships. + * + *

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

Sprite-keyed rather than block-keyed because the material registry compiles per sprite, and + * resolved once per sprite so it adds no variants to the profile x model x emission cross product. A + * resource pack that renames textures falls back to the soda-lime default, and a + * {@code caustica/materials/*.json} rule can set {@code transmission.ior} explicitly. + */ +public final class RtDielectrics { + private RtDielectrics() {} + + /** Soda-lime glass. The default for anything translucent that is not otherwise classified. */ + public static final float GLASS_IOR = 1.52f; + /** Fresh water at room temperature; also the fluid singleton's index. */ + public static final float WATER_IOR = 1.333f; + /** Ice Ih, slightly below liquid water. */ + public static final float ICE_IOR = 1.309f; + + private static final Map IOR_BY_SPRITE = Map.of( + "block/ice", ICE_IOR, + "block/packed_ice", ICE_IOR, + "block/blue_ice", ICE_IOR, + "block/frosted_ice_0", ICE_IOR, + "block/frosted_ice_1", ICE_IOR, + "block/frosted_ice_2", ICE_IOR, + "block/frosted_ice_3", ICE_IOR); + + /** + * Built-in refractive index for a sprite, or soda-lime glass when the sprite is unrecognised. Only + * meaningful for materials the mesher classified as translucent; opaque materials ignore it. + */ + public static float iorForSprite(Identifier spriteName) { + if (spriteName == null) return GLASS_IOR; + return IOR_BY_SPRITE.getOrDefault(spriteName.getPath(), GLASS_IOR); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtLabPbr.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtLabPbr.java index 1f984141..93b3eb29 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtLabPbr.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtLabPbr.java @@ -35,6 +35,10 @@ private RtLabPbr() { public static Specular decodeSpec(float red, float green, float blue, float alpha, float albedoR, float albedoG, float albedoB) { float smoothness = clamp01(red); + // LabPBR red is perceptual smoothness; roughness = (1 - perceptualSmoothness)^2 is LabPBR's + // LINEAR roughness, which is GGX alpha. That is the convention the whole pipeline uses (see + // RtMaterials.Profile and world.rgen's payloadRoughness), so it is stored as-is and never squared + // again downstream. float roughness = (1.0f - smoothness) * (1.0f - smoothness); float g = clamp01(green) * 255.0f; float metalness; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java index c94b2cd0..b244b8ee 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverrides.java @@ -58,10 +58,13 @@ static Rule parse(JsonObject root, Identifier source) { Integer model = null; if (root.has("model")) { + // Every dielectric is a volume now, so the old thin/volume names described nothing. "water" + // is the animated fluid surface (waves, caustics, biome-tint absorption); "dielectric" is + // every other transparent material. model = switch (root.get("model").getAsString()) { case "opaque" -> RtMaterialRegistry.MODEL_OPAQUE; - case "volume_dielectric" -> RtMaterialRegistry.MODEL_WATER; - case "thin_dielectric" -> RtMaterialRegistry.MODEL_GLASS; + case "water" -> RtMaterialRegistry.MODEL_WATER; + case "dielectric" -> RtMaterialRegistry.MODEL_DIELECTRIC; default -> throw new IllegalArgumentException("Unknown material model"); }; } @@ -69,6 +72,8 @@ static Rule parse(JsonObject root, Identifier source) { Float metalness = null; if (root.has("base")) { JsonObject base = root.getAsJsonObject("base"); + // "roughness" is LINEAR roughness (GGX alpha), the same units LabPBR stores and + // RtMaterials.Profile carries — NOT perceptual roughness. alpha = (1 - smoothness)^2. roughness = optionalFloat(base, "roughness"); metalness = optionalFloat(base, "metalness"); } @@ -150,12 +155,12 @@ RtMaterialDesc apply(RtMaterialDesc base) { } private static float defaultIor(int model) { - return model == RtMaterialRegistry.MODEL_WATER ? 1.333f - : model == RtMaterialRegistry.MODEL_GLASS ? 1.52f : 1.0f; + return model == RtMaterialRegistry.MODEL_WATER ? RtDielectrics.WATER_IOR + : model == RtMaterialRegistry.MODEL_DIELECTRIC ? RtDielectrics.GLASS_IOR : 1.0f; } private static float defaultTransmission(int model) { - return model == RtMaterialRegistry.MODEL_WATER || model == RtMaterialRegistry.MODEL_GLASS + return model == RtMaterialRegistry.MODEL_WATER || model == RtMaterialRegistry.MODEL_DIELECTRIC ? 1.0f : 0.0f; } } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java index 1566e677..69873d7e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialRegistry.java @@ -40,7 +40,7 @@ public final class RtMaterialRegistry { // with a plain mask. public static final int MODEL_OPAQUE = 0; public static final int MODEL_WATER = 1; - public static final int MODEL_GLASS = 3; + public static final int MODEL_DIELECTRIC = 3; public static final int FEATURE_SPEC = 1; public static final int FEATURE_NORMAL = 2; public static final int FEATURE_HEURISTIC_EMISSION = 4; @@ -55,7 +55,7 @@ public final class RtMaterialRegistry { private static final float MAX_EMISSION_STRENGTH = 32.0f; private static final int MAX_LOD_SHIFT = 24; - private static final int MODEL_VARIANTS = 2; // ordinary opaque/cutout and thin glass + private static final int MODEL_VARIANTS = 2; // ordinary opaque/cutout and transparent dielectric private static final int EMISSION_VARIANTS = 2; // state-gated emission disabled/enabled private static final int VARIANT_OPAQUE = 0; private static final int VARIANT_GLASS = 1; @@ -130,7 +130,7 @@ public void rebuild(RtContext ctx, RtBlockMaterials blockMaterials, RtMaterialOv continue; } fallbackVariants[variant] = headers.size(); - add(headers, descriptions, grids, compileDesc(glass ? MODEL_GLASS : MODEL_OPAQUE, 0, + add(headers, descriptions, grids, compileDesc(glass ? MODEL_DIELECTRIC : MODEL_OPAQUE, 0, profile, emitting, true, RtMaterialDesc.EmissionSummary.NONE), transparentWhiteAverage(), fallbackEntry, null); } @@ -171,14 +171,18 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, break; } } + // Resolved once per sprite: IOR is a property of the material, so it costs no extra variants + // — it varies with the sprite, not with the profile/glass/emitting cross product. + float dielectricIor = RtDielectrics.iorForSprite(sprite.contents().name()); int[] variants = new int[profileVariants]; for (RtMaterials.Profile profile : SPRITE_PROFILES) { for (boolean glass : new boolean[]{false, true}) { for (boolean emitting : new boolean[]{false, true}) { int features = emitting ? baseFeatures : baseFeatures & ~FEATURE_HEURISTIC_EMISSION; - RtMaterialDesc desc = compileDesc(glass ? MODEL_GLASS : MODEL_OPAQUE, features, + RtMaterialDesc desc = compileDesc(glass ? MODEL_DIELECTRIC : MODEL_OPAQUE, features, profile, emitting, false, - variantSummary(features, emitting, entry, stats.uniformSummary())); + variantSummary(features, emitting, entry, stats.uniformSummary()), + dielectricIor); if (spriteWide != null) { desc = spriteWide.rule.apply(desc); } @@ -196,9 +200,10 @@ true, true, uniformWhiteSummary()), whiteAverage(), fallbackEntry, for (boolean glass : new boolean[]{false, true}) { for (boolean emitting : new boolean[]{false, true}) { int features = emitting ? baseFeatures : baseFeatures & ~FEATURE_HEURISTIC_EMISSION; - RtMaterialDesc base = compileDesc(glass ? MODEL_GLASS : MODEL_OPAQUE, + RtMaterialDesc base = compileDesc(glass ? MODEL_DIELECTRIC : MODEL_OPAQUE, features, profile, emitting, false, - variantSummary(features, emitting, entry, stats.uniformSummary())); + variantSummary(features, emitting, entry, stats.uniformSummary()), + dielectricIor); RtMaterialDesc desc = compiled.rule.apply(base); overrideVariants[index(profile, glass, emitting)] = headers.size(); add(headers, descriptions, grids, desc, stats.average(), entry, stats.albedoGrid()); @@ -424,10 +429,24 @@ private static RtMaterialDesc.EmissionSummary variantSummary(int features, boole private static RtMaterialDesc compileDesc(int model, int features, RtMaterials.Profile profile, boolean emitting, boolean neutral, RtMaterialDesc.EmissionSummary emissionSummary) { - float roughness = model == MODEL_GLASS ? 0.05f : profile.roughness(); - float metalness = model == MODEL_GLASS ? 0.0f : profile.metalness(); - float ior = model == MODEL_WATER ? 1.333f : (model == MODEL_GLASS ? 1.52f : 1.0f); - float transmission = model == MODEL_WATER || model == MODEL_GLASS ? 1.0f : 0.0f; + return compileDesc(model, features, profile, emitting, neutral, emissionSummary, + RtDielectrics.GLASS_IOR); + } + + private static RtMaterialDesc compileDesc(int model, int features, RtMaterials.Profile profile, + boolean emitting, boolean neutral, + RtMaterialDesc.EmissionSummary emissionSummary, + float dielectricIor) { + float roughness = model == MODEL_DIELECTRIC ? 0.0025f : profile.roughness(); // linear; s = 0.95 + float metalness = model == MODEL_DIELECTRIC ? 0.0f : profile.metalness(); + // Refractive index is per material, not per model: ice and window glass are both + // MODEL_DIELECTRIC but bend light by measurably different amounts. + float ior = switch (model) { + case MODEL_WATER -> RtDielectrics.WATER_IOR; + case MODEL_DIELECTRIC -> dielectricIor; + default -> 1.0f; + }; + float transmission = model == MODEL_WATER || model == MODEL_DIELECTRIC ? 1.0f : 0.0f; boolean labPbr = (features & (FEATURE_SPEC | FEATURE_NORMAL)) != 0; RtMaterialDesc.Source source = neutral ? RtMaterialDesc.Source.NEUTRAL : (labPbr ? RtMaterialDesc.Source.LAB_PBR : RtMaterialDesc.Source.HEURISTIC); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialTextureData.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialTextureData.java index 90986096..a0850bb5 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialTextureData.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterialTextureData.java @@ -48,7 +48,7 @@ static List mipChain(Level base, int maxLevel) { /** * Reduce already-decoded physical channels. Emission is energy-averaged, normals are averaged then * renormalized, and lost normal length raises roughness so distant normal detail does not become a - * falsely smooth surface. + * falsely smooth surface. Roughness is linear (GGX alpha) throughout — see {@link RtLabPbr}. */ static Level reduce(Level src) { int width = Math.max(1, (src.width + 1) / 2); @@ -58,7 +58,7 @@ static Level reduce(Level src) { float[] surface1 = new float[surface0.length]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { - float roughSq = 0.0f; + float alphaSum = 0.0f; float metal = 0.0f, emission = 0.0f, sss = 0.0f; float nx = 0.0f, ny = 0.0f, nz = 0.0f, ao = 0.0f, heightValue = 0.0f; float f0r = 0.0f, f0g = 0.0f, f0b = 0.0f, transmission = 0.0f; @@ -70,8 +70,7 @@ static Level reduce(Level src) { int sx = x * 2 + ox; if (sx >= src.width) continue; int si = (sy * src.width + sx) * CHANNELS; - float rough = src.surface0[si]; - roughSq += rough * rough; + alphaSum += src.surface0[si]; metal += src.surface0[si + 1]; emission += src.surface0[si + 2]; sss += src.surface0[si + 3]; @@ -105,8 +104,10 @@ static Level reduce(Level src) { } int di = (y * width + x) * CHANNELS; // Toksvig-style variance term. This is intentionally conservative and monotonic. - surface0[di] = clamp01((float) Math.sqrt(Math.min(1.0f, - roughSq * inv + Math.max(0.0f, 1.0f - normalLength)))); + // Both terms are in linear-roughness (GGX alpha) space, which is where normal-map + // variance adds: alpha behaves like a variance, so averaging and widening are both + // plain sums here — no perceptual round-trip. + surface0[di] = clamp01(alphaSum * inv + Math.max(0.0f, 1.0f - normalLength)); surface0[di + 1] = clamp01(metal * inv); surface0[di + 2] = clamp01(emission * inv); surface0[di + 3] = clamp01(sss * inv); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterials.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterials.java index 9311160f..a0c67138 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterials.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtMaterials.java @@ -18,12 +18,16 @@ public enum Profile { // The first four are the sprite-classifiable profiles ({@link #profile} can only return these); // RtMaterialRegistry's variant index depends on their ordinals (checked at its class load). // WATER/LAVA exist only for the dedicated fluid singleton headers. - DEFAULT(0.9f, 0.0f), - METAL(0.3f, 1.0f), - GLASS(0.1f, 0.0f), - SMOOTH(0.35f, 0.0f), - WATER(0.08f, 0.0f), - LAVA(0.7f, 0.0f); + // + // Roughness is LINEAR (GGX alpha), matching LabPBR's roughness = (1 - perceptualSmoothness)^2 and + // what DLSS-RR wants in its guide. The comment after each value is the perceptual smoothness it + // corresponds to, which is the easier number to reason about when re-tuning: alpha = (1 - s)^2. + DEFAULT(0.81f, 0.0f), // s = 0.10 + METAL(0.09f, 1.0f), // s = 0.70 + GLASS(0.01f, 0.0f), // s = 0.90 + SMOOTH(0.1225f, 0.0f), // s = 0.65 + WATER(0.0064f, 0.0f), // s = 0.92 + LAVA(0.49f, 0.0f); // s = 0.30 private final float roughness; private final float metalness; @@ -46,8 +50,8 @@ public float metalness() { public static final float WATER_ROUGH = Profile.WATER.roughness(); /** Lava: opaque emitter, moderately rough. */ public static final float LAVA_ROUGH = Profile.LAVA.roughness(); - /** Default entity roughness. */ - public static final float ENTITY_ROUGH = 0.8f; + /** Default entity roughness (linear; s = 0.11). */ + public static final float ENTITY_ROUGH = 0.64f; private static final Set SMOOTH = Set.of( Blocks.QUARTZ_BLOCK, Blocks.SMOOTH_QUARTZ, Blocks.QUARTZ_BRICKS, Blocks.QUARTZ_PILLAR, @@ -56,7 +60,7 @@ public float metalness() { Blocks.POLISHED_DEEPSLATE, Blocks.POLISHED_BLACKSTONE, Blocks.PRISMARINE, Blocks.PRISMARINE_BRICKS, Blocks.DARK_PRISMARINE); - /** Perceptual roughness for this block's surface. */ + /** Linear roughness (GGX alpha) for this block's surface. */ public static float roughness(BlockState state) { return profile(state).roughness(); } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java index a03fb52a..f3946bc8 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java @@ -82,6 +82,7 @@ public final class RtPipeline { private final long pipeline; private final RtBuffer sbt; private final long sbtStride; + private final int raygenCount; private final int missCount; private final int hitGroupCount; private final int pushConstantSize; @@ -96,7 +97,7 @@ public final class RtPipeline { private final int skyAtlasBinding; private boolean destroyed; - private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, long pipeline, RtBuffer sbt, long stride, int missCount, int hitGroupCount, int pushConstantSize, int pushConstantStages, int firstExtraBinding, + private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, long pipeline, RtBuffer sbt, long stride, int raygenCount, int missCount, int hitGroupCount, int pushConstantSize, int pushConstantStages, int firstExtraBinding, long bindlessLayout, long bindlessPool, long bindlessSet, int skyAtlasBinding) { this.ctx = ctx; this.descriptorSetLayout = dsl; @@ -111,6 +112,7 @@ private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, this.pipeline = pipeline; this.sbt = sbt; this.sbtStride = stride; + this.raygenCount = raygenCount; this.missCount = missCount; this.hitGroupCount = hitGroupCount; this.pushConstantSize = pushConstantSize; @@ -128,8 +130,12 @@ private RtPipeline(RtContext ctx, long dsl, long pool, long[] sets, long layout, * constants: radiance records first, shadow records second, then entity records. {@code extraStorageImages} * adds that many raygen-visible storage images at bindings 3.. (the DLSS-RR guide buffers); * write them with {@link #setExtraStorageImage}. + * + *

{@code rgen} may hold several raygen shaders. They share this pipeline's descriptor set, miss + * table and hit table; {@link #trace(VkCommandBuffer, int, int, ByteBuffer, int)} picks one per + * dispatch by index. */ - public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, String rchit, String rahit, int pushConstantSize, boolean withBlockAlbedoAtlas, int extraStorageImages, int bindlessTextures, boolean skyAtlas) { + public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, String rchit, String rahit, int pushConstantSize, boolean withBlockAlbedoAtlas, int extraStorageImages, int bindlessTextures, boolean skyAtlas) { VkDevice vk = ctx.vk(); boolean hasAhit = rahit != null; String label = "world RT pipeline"; @@ -260,17 +266,24 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri long layout = p.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, layout, label + " pipeline layout"); - // Stages: raygen, one miss per rmiss entry, the closest-hit, then (optionally) the any-hit. - // Groups are raygen + N miss + the hit records selected by traceRayEXT's SBT offset/stride. + // Stages: one per rgen entry, one miss per rmiss entry, the closest-hit, then (optionally) + // the any-hit. Groups are N raygen + M miss + the hit records selected by traceRayEXT's SBT + // offset/stride. Multiple raygens share one pipeline and are selected at dispatch by pointing + // the raygen SBT region at a different record — that is how the primary/guide pass and the + // indirect pass coexist without duplicating the hit and miss tables. + int raygenCount = rgen.length; int missCount = rmiss.length; int hitGroupCount = hasAhit ? RtAccel.SBT_HIT_GROUP_COUNT : 1; - int groupCount = 1 + missCount + hitGroupCount; - int hitGroupIdx = 1 + missCount; - int chitStage = 1 + missCount; + int groupCount = raygenCount + missCount + hitGroupCount; + int hitGroupIdx = raygenCount + missCount; + int chitStage = raygenCount + missCount; int ahitStage = chitStage + 1; - int stageCount = 1 + missCount + 1 + (hasAhit ? 1 : 0); - long mGen = loadModule(vk, stack, rgen); - RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, mGen, label + " " + rgen); + int stageCount = raygenCount + missCount + 1 + (hasAhit ? 1 : 0); + long[] mGen = new long[raygenCount]; + for (int g = 0; g < raygenCount; g++) { + mGen[g] = loadModule(vk, stack, rgen[g]); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SHADER_MODULE, mGen[g], label + " " + rgen[g]); + } long[] mMiss = new long[missCount]; for (int m = 0; m < missCount; m++) { mMiss[m] = loadModule(vk, stack, rmiss[m]); @@ -284,9 +297,11 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri } ByteBuffer entry = stack.UTF8("main"); VkPipelineShaderStageCreateInfo.Buffer stages = VkPipelineShaderStageCreateInfo.calloc(stageCount, stack); - stages.get(0).sType$Default().stage(VK_SHADER_STAGE_RAYGEN_BIT_KHR).module(mGen).pName(entry); + for (int g = 0; g < raygenCount; g++) { + stages.get(g).sType$Default().stage(VK_SHADER_STAGE_RAYGEN_BIT_KHR).module(mGen[g]).pName(entry); + } for (int m = 0; m < missCount; m++) { - stages.get(1 + m).sType$Default().stage(VK_SHADER_STAGE_MISS_BIT_KHR).module(mMiss[m]).pName(entry); + stages.get(raygenCount + m).sType$Default().stage(VK_SHADER_STAGE_MISS_BIT_KHR).module(mMiss[m]).pName(entry); } stages.get(chitStage).sType$Default().stage(VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR).module(mHit).pName(entry); if (hasAhit) { @@ -294,11 +309,13 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri } VkRayTracingShaderGroupCreateInfoKHR.Buffer groups = VkRayTracingShaderGroupCreateInfoKHR.calloc(groupCount, stack); - groups.get(0).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR) - .generalShader(0).closestHitShader(VK_SHADER_UNUSED_KHR).anyHitShader(VK_SHADER_UNUSED_KHR).intersectionShader(VK_SHADER_UNUSED_KHR); + for (int g = 0; g < raygenCount; g++) { + groups.get(g).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR) + .generalShader(g).closestHitShader(VK_SHADER_UNUSED_KHR).anyHitShader(VK_SHADER_UNUSED_KHR).intersectionShader(VK_SHADER_UNUSED_KHR); + } for (int m = 0; m < missCount; m++) { - groups.get(1 + m).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR) - .generalShader(1 + m).closestHitShader(VK_SHADER_UNUSED_KHR).anyHitShader(VK_SHADER_UNUSED_KHR).intersectionShader(VK_SHADER_UNUSED_KHR); + groups.get(raygenCount + m).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_GENERAL_KHR) + .generalShader(raygenCount + m).closestHitShader(VK_SHADER_UNUSED_KHR).anyHitShader(VK_SHADER_UNUSED_KHR).intersectionShader(VK_SHADER_UNUSED_KHR); } for (int h = 0; h < hitGroupCount; h++) { groups.get(hitGroupIdx + h).sType$Default().type(VK_RAY_TRACING_SHADER_GROUP_TYPE_TRIANGLES_HIT_GROUP_KHR) @@ -320,7 +337,9 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri long pipeline = pPipeline.get(0); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_PIPELINE, pipeline, label); - VK10.vkDestroyShaderModule(vk, mGen, null); + for (int g = 0; g < raygenCount; g++) { + VK10.vkDestroyShaderModule(vk, mGen[g], null); + } for (int m = 0; m < missCount; m++) { VK10.vkDestroyShaderModule(vk, mMiss[m], null); } @@ -347,7 +366,7 @@ public static RtPipeline create(RtContext ctx, String rgen, String[] rmiss, Stri MemoryUtil.memCopy(MemoryUtil.memAddress(handles) + (long) g * handleSize, sbt.mapped + g * stride, handleSize); } sbt.flush(); - return new RtPipeline(ctx, dsl, pool, sets, layout, pipeline, sbt, stride, missCount, hitGroupCount, pushConstantSize, pcStages, firstExtraBinding, + return new RtPipeline(ctx, dsl, pool, sets, layout, pipeline, sbt, stride, raygenCount, missCount, hitGroupCount, pushConstantSize, pcStages, firstExtraBinding, bindlessLayout, bindlessPool, bindlessSet, skyBinding); } } @@ -479,11 +498,22 @@ public boolean hasBindless() { } public void trace(VkCommandBuffer cmd, int width, int height) { - trace(cmd, width, height, null); + trace(cmd, width, height, null, 0); } - /** Record bind (+ optional raygen push constants) + trace into the given command buffer. */ public void trace(VkCommandBuffer cmd, int width, int height, java.nio.ByteBuffer pushConstants) { + trace(cmd, width, height, pushConstants, 0); + } + + /** + * Record bind (+ optional raygen push constants) + trace into the given command buffer. + * {@code raygenIndex} selects which raygen record of the SBT this dispatch launches; the miss and + * hit regions are shared, so passes over the same scene differ only in this index. + */ + public void trace(VkCommandBuffer cmd, int width, int height, java.nio.ByteBuffer pushConstants, int raygenIndex) { + if (raygenIndex < 0 || raygenIndex >= raygenCount) { + throw new IllegalArgumentException("raygen index " + raygenIndex + " out of range [0, " + raygenCount + ")"); + } try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "trace rays")) { VK10.vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, pipeline); java.nio.LongBuffer boundSets = bindlessSet != 0L @@ -493,12 +523,14 @@ public void trace(VkCommandBuffer cmd, int width, int height, java.nio.ByteBuffe if (pushConstants != null && pushConstantSize > 0) { VK10.vkCmdPushConstants(cmd, pipelineLayout, pushConstantStages, 0, pushConstants); } + // The raygen region must name exactly one record (size == stride), so selecting a pass is a + // matter of which record it points at. VkStridedDeviceAddressRegionKHR raygen = VkStridedDeviceAddressRegionKHR.calloc(stack) - .deviceAddress(sbt.deviceAddress).stride(sbtStride).size(sbtStride); + .deviceAddress(sbt.deviceAddress + (long) raygenIndex * sbtStride).stride(sbtStride).size(sbtStride); VkStridedDeviceAddressRegionKHR miss = VkStridedDeviceAddressRegionKHR.calloc(stack) - .deviceAddress(sbt.deviceAddress + sbtStride).stride(sbtStride).size((long) missCount * sbtStride); + .deviceAddress(sbt.deviceAddress + (long) raygenCount * sbtStride).stride(sbtStride).size((long) missCount * sbtStride); VkStridedDeviceAddressRegionKHR hit = VkStridedDeviceAddressRegionKHR.calloc(stack) - .deviceAddress(sbt.deviceAddress + (1L + missCount) * sbtStride).stride(sbtStride).size((long) hitGroupCount * sbtStride); + .deviceAddress(sbt.deviceAddress + (long) (raygenCount + missCount) * sbtStride).stride(sbtStride).size((long) hitGroupCount * sbtStride); VkStridedDeviceAddressRegionKHR callable = VkStridedDeviceAddressRegionKHR.calloc(stack); vkCmdTraceRaysKHR(cmd, raygen, miss, hit, callable, width, height, 1); } diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index 364a2e04..323cf7cf 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -56,7 +56,5 @@ "caustica.options.rt.debugView.4": "Roughness", "caustica.options.rt.debugView.5": "Motion", "caustica.options.rt.debugView.6": "Specular", - "caustica.options.rt.debugView.7": "Specular Motion", - "caustica.options.rt.debugView.8": "Emission Mask", - "caustica.options.rt.debugView.9": "Emission Source" + "caustica.options.rt.debugView.7": "Specular Motion" } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java new file mode 100644 index 00000000..e5365478 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtDielectricsTest.java @@ -0,0 +1,35 @@ +package dev.comfyfluffy.caustica.rt.material; + +import net.minecraft.resources.Identifier; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class RtDielectricsTest { + @Test + void iceFamilyRefractsAtIceIndex() { + for (String sprite : new String[]{"block/ice", "block/packed_ice", "block/blue_ice", + "block/frosted_ice_0", "block/frosted_ice_3"}) { + assertEquals(RtDielectrics.ICE_IOR, + RtDielectrics.iorForSprite(Identifier.parse("minecraft:" + sprite)), 1.0e-6f, sprite); + } + } + + @Test + void unknownAndNullSpritesFallBackToSodaLimeGlass() { + assertEquals(RtDielectrics.GLASS_IOR, + RtDielectrics.iorForSprite(Identifier.parse("minecraft:block/glass")), 1.0e-6f); + assertEquals(RtDielectrics.GLASS_IOR, + RtDielectrics.iorForSprite(Identifier.parse("somemod:block/weird_crystal")), 1.0e-6f); + assertEquals(RtDielectrics.GLASS_IOR, RtDielectrics.iorForSprite(null), 1.0e-6f); + } + + @Test + void iceRefractsLessStronglyThanWaterWhichRefractsLessThanGlass() { + // Ice Ih sits just below liquid water, which sits well below soda-lime glass. Collapsing these + // onto one per-model constant is exactly what made ice refract like a window. + assertTrue(RtDielectrics.ICE_IOR < RtDielectrics.WATER_IOR); + assertTrue(RtDielectrics.WATER_IOR < RtDielectrics.GLASS_IOR); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java index 0a2a85af..6f7f02e5 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialLayoutTest.java @@ -34,15 +34,16 @@ void reflectedMaterialHeaderMatchesHotAbi() { @Test void reflectedWorldPushConstantsIncludeLightBuffersAndDebugView() { - // 9 uint64_t addresses (worldPush/table/entityTable/materialTable + the 5 light buffers) + 2 uint. - assertEquals(80, WorldPushConstantsData.BYTE_SIZE); + // 10 uint64_t addresses (world/table/material, 5 light buffers, path queue) + 2 uint. + assertEquals(88, WorldPushConstantsData.BYTE_SIZE); ByteBuffer data = ByteBuffer.allocateDirect(WorldPushConstantsData.BYTE_SIZE) .order(ByteOrder.nativeOrder()); - new WorldPushConstantsData(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10, 11).write(data); + new WorldPushConstantsData(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11, 12).write(data); assertEquals(4L, data.getLong(24)); // materialTableAddr assertEquals(5L, data.getLong(32)); // lightBufAddr assertEquals(9L, data.getLong(64)); // lightGridSpanAddr (last of the light-buffer addresses) - assertEquals(10, data.getInt(72)); // frameIndex - assertEquals(11, data.getInt(76)); // debugView + assertEquals(10L, data.getLong(72)); // pathQueueAddr + assertEquals(11, data.getInt(80)); // frameIndex + assertEquals(12, data.getInt(84)); // debugView } } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java index 06f89e32..05006eb3 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/material/RtMaterialOverridesTest.java @@ -14,13 +14,13 @@ final class RtMaterialOverridesTest { void parsesVersionedExtensibleMaterialProperties() { var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" {"format":1,"match":{"block":"minecraft:blue_stained_glass", - "sprite":"minecraft:block/blue_stained_glass"},"model":"thin_dielectric", + "sprite":"minecraft:block/blue_stained_glass"},"model":"dielectric", "base":{"roughness":0.06,"metalness":0.0}, "emission":{"strength":2.0,"color_source":"albedo"}, "transmission":{"factor":1.0,"ior":1.52}} """).getAsJsonObject(), Identifier.parse("test:caustica/materials/glass.json")); assertEquals(Identifier.parse("minecraft:block/blue_stained_glass"), rule.sprite()); - assertEquals(RtMaterialRegistry.MODEL_GLASS, rule.model()); + assertEquals(RtMaterialRegistry.MODEL_DIELECTRIC, rule.model()); assertEquals(1.52f, rule.ior()); assertEquals(2.0f, rule.emissionStrength()); RtMaterialDesc base = new RtMaterialDesc(RtMaterialRegistry.MODEL_OPAQUE, @@ -38,6 +38,40 @@ void parsesVersionedExtensibleMaterialProperties() { assertEquals(1.0f, applied.transmission()); } + @Test + void transmissionIorOverridesTheBuiltInIndex() { + RtMaterialDesc glassBase = new RtMaterialDesc(RtMaterialRegistry.MODEL_DIELECTRIC, + RtMaterialDesc.Source.HEURISTIC, 0, 0.0025f, 0.0f, RtDielectrics.GLASS_IOR, 1.0f, + RtMaterialDesc.EmissionSource.NONE, 0.0f, RtMaterialDesc.EmissionSummary.NONE); + var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" + {"format":1,"match":{"sprite":"somemod:block/crystal"}, + "model":"dielectric","transmission":{"ior":2.417}} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/crystal.json")); + RtMaterialDesc applied = rule.apply(glassBase); + assertEquals(RtMaterialRegistry.MODEL_DIELECTRIC, applied.model()); + assertEquals(2.417f, applied.ior()); + + // Omitting ior on a rule that does not change the model leaves the base index alone. + var silent = RtMaterialOverrides.parse(JsonParser.parseString(""" + {"format":1,"match":{"sprite":"somemod:block/crystal"},"base":{"roughness":0.5}} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/crystal.json")); + assertEquals(RtDielectrics.GLASS_IOR, silent.apply(glassBase).ior()); + } + + @Test + void waterModelKeepsItsOwnIndexWhenSelectedByName() { + var rule = RtMaterialOverrides.parse(JsonParser.parseString(""" + {"format":1,"match":{"sprite":"somemod:block/pool"},"model":"water"} + """).getAsJsonObject(), Identifier.parse("test:caustica/materials/pool.json")); + RtMaterialDesc base = new RtMaterialDesc(RtMaterialRegistry.MODEL_OPAQUE, + RtMaterialDesc.Source.HEURISTIC, 0, 0.8f, 0.0f, 1.0f, 0.0f, + RtMaterialDesc.EmissionSource.NONE, 0.0f, RtMaterialDesc.EmissionSummary.NONE); + RtMaterialDesc applied = rule.apply(base); + assertEquals(RtMaterialRegistry.MODEL_WATER, applied.model()); + assertEquals(RtDielectrics.WATER_IOR, applied.ior()); + assertEquals(1.0f, applied.transmission()); + } + @Test void emissionStrengthCannotForceEmissionOntoANonEmissiveMaterial() { var rule = RtMaterialOverrides.parse(JsonParser.parseString("""