diff --git a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy index e609d0483..2287bb030 100644 --- a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy +++ b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy @@ -31,6 +31,8 @@ abstract class GenerateRtBindings extends DefaultTask { TLAS: "topLevelAS", OUTPUT: "outImage", BLOCK_ALBEDO: "blockAlbedoAtlas", G_NORMAL: "gNormal", G_ALBEDO: "gAlbedo", G_DEPTH: "gDepth", G_MOTION: "gMotion", G_SPEC_ALBEDO: "gSpecAlbedo", G_SPEC_MOTION: "gSpecMotion", + RESERVOIR_A: "reservoirA", RESERVOIR_B: "reservoirB", + CLOUD_NOISE: "cloudNoiseTex", CELESTIALS: "celestialsAtlas", SKY_VIEW: "skyViewLut", TRANSMITTANCE: "transmittanceLut", ENTITY_ALBEDO: "entityAlbedoTex", MATERIAL_SURFACE0: "materialSurface0Tex", MATERIAL_NORMAL_AO: "materialNormalAoTex", MATERIAL_SURFACE1: "materialSurface1Tex"]], @@ -120,9 +122,15 @@ abstract class GenerateRtBindings extends DefaultTask { constants.WORLD_SET = ordinary.values().first().set ordinary.each { suffix, location -> constants["WORLD_${suffix}"] = location.index } def guides = ordinary.findAll { suffix, ignored -> (suffix as String).startsWith("G_") } - def storageImages = guides + ordinary.findAll { suffix, ignored -> suffix == "OUTPUT" } + // RESERVOIR_* are storage images like the guides, but deliberately not counted as guides: + // WORLD_GUIDE_COUNT bounds setExtraStorageImage's slot range, which addresses bindings + // contiguously from WORLD_G_NORMAL. Folding the reservoirs in there would let a guide + // slot index walk into them. + def reservoirs = ordinary.findAll { suffix, ignored -> (suffix as String).startsWith("RESERVOIR_") } + def storageImages = guides + reservoirs + ordinary.findAll { suffix, ignored -> suffix == "OUTPUT" } def samplers = ordinary.findAll { suffix, ignored -> suffix != "TLAS" && !storageImages.containsKey(suffix) } constants.WORLD_GUIDE_COUNT = guides.size() + constants.WORLD_RESERVOIR_COUNT = reservoirs.size() constants.WORLD_SET_BINDING_COUNT = ordinary.values()*.index.max() + 1 constants.WORLD_SET_STORAGE_IMAGE_COUNT = storageImages.size() constants.WORLD_SET_SAMPLER_COUNT = samplers.size() diff --git a/shaders/common/display_common.slang b/shaders/common/display_common.slang index 54ef9fe1e..520d9af94 100644 --- a/shaders/common/display_common.slang +++ b/shaders/common/display_common.slang @@ -77,6 +77,19 @@ public struct DisplayPush { // SUM of every band, so RtComposite folds the 1/levelCount normalisation into this value: the authored // look-package strength then means the same thing whichever level count the resolution supports. public float bloomStrength; + // ---- Unreal-style scene-referred colour grading, applied after the LMT and before the output + // transform, which is where UE's own grade sits relative to its tonemapper. Values are the + // renderer's working space (ACEScg/AP1) and UE's default working space is also AP1, so the + // published numbers from a UE post-process volume transfer directly rather than approximately. + public int gradeEnabled; // 0 = skip the whole stage + public float saturation; // global + public float contrast; // global, pivoted at 0.18 + public float gain; // global + public float highlightSaturation; // multiplies the global value in the highlight region + public float highlightGain; // multiplies the global value in the highlight region + public float highlightsMin; // luma where the highlight region starts blending in + // ---- RCAS. 0 disables it and its four extra taps. + public float sharpness; }; public struct BloomPush { diff --git a/shaders/pipelines/display/main.comp.slang b/shaders/pipelines/display/main.comp.slang index b92d243d7..7f7e7c712 100644 --- a/shaders/pipelines/display/main.comp.slang +++ b/shaders/pipelines/display/main.comp.slang @@ -44,6 +44,56 @@ float3 applyLook(float3 exposedAcesCg) { return shaperDecode(shaped); } +// ---- Unreal-style colour grading ----------------------------------------------------------------- +// +// A reimplementation of UE's ColorCorrectAll from PostProcessCombineLUTs.usf, so that a grade authored +// against a UE post-process volume — such as the one in the "Ultra Realism Tonemapper" guide — produces +// the same image here. Two things make that transfer exact rather than approximate: UE's default +// working colour space is AP1, which is this renderer's working space too, and UE grades scene-referred +// before its tonemapper, which is where this sits relative to the output transform. +// +// The 0.18 contrast pivot is what keeps contrast from also being an exposure change: scaling around +// mid grey leaves mid grey fixed, so contrast and exposure stay independent controls. +// ACEScg luma weights, matching world_common.slang's ACESCG_LUMA. Duplicated rather than imported: +// this pipeline does not otherwise depend on the ray tracer's module tree. +static const float3 ACESCG_LUMA = float3(0.27222872, 0.67408177, 0.05368952); +static const float GRADE_PIVOT = 0.18; +// UE's ColorCorrectionShadowsMax / HighlightsMax defaults. Only HighlightsMin is worth exposing — +// it is the one the guide actually moves, and the other two only reshape the blend between regions. +static const float GRADE_SHADOWS_MAX = 0.09; +static const float GRADE_HIGHLIGHTS_MAX = 1.0; + +float3 colorCorrect(float3 color, float saturation, float contrast, float gain) { + float luma = dot(color, ACESCG_LUMA); + // Saturation as a lerp from luma, clamped at zero: values above 1 extrapolate away from grey, and + // without the clamp a strongly saturated colour can extrapolate a channel negative and come back + // through the tone LUT as a hard chroma artifact. + color = max(lerp(float3(luma), color, saturation), float3(0.0)); + color = pow(max(color / GRADE_PIVOT, float3(0.0)), float3(contrast)) * GRADE_PIVOT; + return color * gain; +} + +float3 applyGrade(float3 acesCg) { + if (pc.gradeEnabled == 0) { + return acesCg; + } + float luma = dot(acesCg, ACESCG_LUMA); + // Region weights sum to 1 by construction, so the three graded results blend without changing + // overall level — the midtone weight is whatever the other two leave behind. + float shadowWeight = 1.0 - smoothstep(0.0, GRADE_SHADOWS_MAX, luma); + float highlightWeight = smoothstep(pc.highlightsMin, GRADE_HIGHLIGHTS_MAX, luma); + float midWeight = max(1.0 - shadowWeight - highlightWeight, 0.0); + + // Region parameters multiply the global ones, exactly as UE composes them — so the guide's + // highlight saturation of 0.95 means 0.95 x the global 0.75, not 0.95 outright. + float3 midtones = colorCorrect(acesCg, pc.saturation, pc.contrast, pc.gain); + float3 highlights = colorCorrect(acesCg, pc.saturation * pc.highlightSaturation, + pc.contrast, pc.gain * pc.highlightGain); + // Shadows use the global grade unmodified: the guide overrides no shadow-region parameters, and + // inventing a shadow lift here would silently diverge from the source it is reproducing. + return midtones * (shadowWeight + midWeight) + highlights * highlightWeight; +} + float3 sampleBloom(int2 outputPixel, uint outputWidth, uint outputHeight) { float2 uv = (float2(outputPixel) + 0.5) / float2(outputWidth, outputHeight); return bloomImage.SampleLevel(uv, 0.0).rgb; @@ -141,6 +191,55 @@ float3 tonemapHdr(float3 lookedAcesCg) { return displayGammaHdr(hdrToneLut.SampleLevel(lutTexCoord(uvw, pc.lutSize), 0.0).rgb); } +// Everything up to, but not including, the output transform. Factored out because RCAS below needs it +// at four neighbouring pixels as well as the centre. +float3 gradedAcesCg(int2 pix, float exposure, uint w, uint h) { + float3 exposedAcesCg = max(rtImage[pix].rgb * exposure, float3(0.0)); + exposedAcesCg += sampleBloom(pix, w, h) * max(pc.bloomStrength, 0.0); + return applyGrade(applyLook(exposedAcesCg)); +} + +// ---- RCAS ------------------------------------------------------------------------------------------ +// +// AMD's Robust Contrast-Adaptive Sharpening, from FSR. "Robust" is the operative word: rather than a +// fixed unsharp kernel, it derives a per-pixel sharpening lobe from how much headroom the local +// neighbourhood actually has, so it cannot ring a highlight into clipping or crush a shadow. That makes +// it the right choice downstream of DLSS Ray Reconstruction, where a fixed sharpen would amplify the +// denoiser's own reconstruction error along edges. +// +// TWO DELIBERATE PLACEMENT DECISIONS: +// +// It runs AFTER the output transform, on display code values, not on scene-linear radiance. RCAS's +// headroom maths assumes a bounded signal with a peak of 1. Scene-linear HDR has no such bound — a +// 10,000 nit emitter is just a large number — and sharpening it would produce overshoot proportional to +// the highlight's absolute intensity rather than to its visible contrast. +// +// The HDR path sharpens PQ code values, which is what "HDR-aware" means here. PQ is perceptually +// uniform and bounded [0,1], so the same lobe produces the same *apparent* edge enhancement at 100 nits +// and at 1000 nits. Sharpening linear nits instead would make bright highlights receive hundreds of +// times more absolute overshoot than midtones, which reads as ringing exactly where the display is most +// able to show it. +// +// Cost is four extra pipeline evaluations per pixel. Gated on sharpness, so a disabled slider costs one +// comparison. +static const float RCAS_LIMIT = -0.1875; // FSR's clamp on the lobe; beyond it the filter visibly rings. + +float3 rcas(float3 e, float3 b, float3 d, float3 f, float3 h, float sharpness) { + float3 mn4 = min(min(b, d), min(f, h)); + float3 mx4 = max(max(b, d), max(f, h)); + // Headroom to black and to peak white, per channel. The tighter of the two bounds the lobe, so a + // pixel already near either end of the range is sharpened less rather than pushed past it. + float3 hitMin = mn4 / (4.0 * max(mx4, float3(1.0e-6))); + float3 hitMax = (1.0 - mx4) / (4.0 * max(mx4, float3(1.0e-6))); + float3 lobeRgb = max(-hitMin, hitMax); + float lobe = max(RCAS_LIMIT, min(max(lobeRgb.r, max(lobeRgb.g, lobeRgb.b)), 0.0)) * sharpness; + return (lobe * (b + d + f + h) + e) / (4.0 * lobe + 1.0); +} + +int2 clampPixel(int2 pix, uint w, uint h) { + return int2(clamp(pix.x, 0, int(w) - 1), clamp(pix.y, 0, int(h) - 1)); +} + [shader("compute")] [numthreads(16, 16, 1)] void main(uint3 dispatchId : SV_DispatchThreadID) { @@ -151,14 +250,31 @@ void main(uint3 dispatchId : SV_DispatchThreadID) { return; } - float4 rt = rtImage[pix]; float exposure = max(exposureImage[int2(0, 0)], 0.0); - float3 exposedAcesCg = max(rt.rgb * exposure, float3(0.0)); - exposedAcesCg += sampleBloom(pix, w, h) * max(pc.bloomStrength, 0.0); - float3 lookedAcesCg = applyLook(exposedAcesCg); - outputImage[pix] = float4(tonemap(lookedAcesCg), 1.0); + float3 lookedAcesCg = gradedAcesCg(pix, exposure, w, h); + + float sharpness = max(pc.sharpness, 0.0); + if (sharpness <= 0.0) { + outputImage[pix] = float4(tonemap(lookedAcesCg), 1.0); + if (pc.hdrEnabled != 0) { + hdrImage[pix] = float4(tonemapHdr(lookedAcesCg), 1.0); + } + return; + } + + // Edge pixels clamp to themselves, which makes the lobe collapse toward zero at the border rather + // than sharpening against a wrapped or undefined neighbour. + float3 upAcesCg = gradedAcesCg(clampPixel(pix + int2(0, -1), w, h), exposure, w, h); + float3 leftAcesCg = gradedAcesCg(clampPixel(pix + int2(-1, 0), w, h), exposure, w, h); + float3 rightAcesCg = gradedAcesCg(clampPixel(pix + int2(1, 0), w, h), exposure, w, h); + float3 downAcesCg = gradedAcesCg(clampPixel(pix + int2(0, 1), w, h), exposure, w, h); + + outputImage[pix] = float4(rcas(tonemap(lookedAcesCg), tonemap(upAcesCg), tonemap(leftAcesCg), + tonemap(rightAcesCg), tonemap(downAcesCg), sharpness), 1.0); if (pc.hdrEnabled != 0) { - hdrImage[pix] = float4(tonemapHdr(lookedAcesCg), 1.0); + hdrImage[pix] = float4(rcas(tonemapHdr(lookedAcesCg), tonemapHdr(upAcesCg), + tonemapHdr(leftAcesCg), tonemapHdr(rightAcesCg), + tonemapHdr(downAcesCg), sharpness), 1.0); } } diff --git a/shaders/pipelines/world/any_hit.rahit.slang b/shaders/pipelines/world/any_hit.rahit.slang index 67762c735..4b2e3a738 100644 --- a/shaders/pipelines/world/any_hit.rahit.slang +++ b/shaders/pipelines/world/any_hit.rahit.slang @@ -64,7 +64,13 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) int texSlot = int(epr.tint.w + 0.5); float4 texel = entityAlbedoTex[NonUniformResourceIndex(texSlot)].SampleLevel(uv, 0.0); bool stochasticAlpha = false; - MaterialHeader materialHeader; + // Zero-initialised rather than left to the ENTITY_BIT branch below. Every read of this is + // already guarded by an instanceKind == ENTITY_BIT test, so the value is never observed + // unassigned -- but the compiler cannot prove that across the separate guards, and newer slangc + // versions promote the resulting may-be-uninitialised diagnostic to an error under the + // project's -warnings-as-errors. A defined zero is also the safer failure mode: model 0 falls + // through the dielectric and water branches instead of reading a stack value as a material. + MaterialHeader materialHeader = {}; if (instanceKind == ENTITY_BIT) { materialHeader = ConstPtr(pc.materialTableAddr)[epr.materialId]; stochasticAlpha = (materialHeader.features & MATERIAL_FEATURE_STOCHASTIC_ALPHA) != 0u; diff --git a/shaders/pipelines/world/bindings.slang b/shaders/pipelines/world/bindings.slang index 7a9ca0620..fb448c338 100644 --- a/shaders/pipelines/world/bindings.slang +++ b/shaders/pipelines/world/bindings.slang @@ -15,6 +15,19 @@ import world_common; [[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; [[vk::binding(8, 0)]] [format("rg16f")] public RWTexture2D gSpecMotion; +// ReSTIR temporal reservoirs. Both are bound permanently and swap roles each frame via +// worldPush.restir.y, rather than the descriptors themselves being rewritten: descriptor sets are +// ring-buffered across frames in flight, so a per-frame rewrite would mutate a set an earlier frame is +// still reading. A push constant costs nothing and cannot race. +// Double-width — two texels per pixel, see restir.slang. rgba32f because the emitter position needs +// full float precision. +[[vk::binding(12, 0)]] [format("rgba32f")] public RWTexture2D reservoirA; +[[vk::binding(13, 0)]] [format("rgba32f")] public RWTexture2D reservoirB; + +// Precomputed cloud shape field. R = 4-octave base, G = 3-octave erosion detail, tiling in all three +// axes. Replaces hundreds of ALU ops per density sample with one filtered fetch — see RtCloudNoise. +[[vk::binding(14, 0)]] public Sampler3D cloudNoiseTex; + [[vk::binding(2, 0)]] public Sampler2D blockAlbedoAtlas; [[vk::binding(9, 0)]] public Sampler2D celestialsAtlas; [[vk::binding(10, 0)]] public Sampler2D skyViewLut; diff --git a/shaders/pipelines/world/closest_hit.rchit.slang b/shaders/pipelines/world/closest_hit.rchit.slang index bf21f8859..4df8f1513 100644 --- a/shaders/pipelines/world/closest_hit.rchit.slang +++ b/shaders/pipelines/world/closest_hit.rchit.slang @@ -101,6 +101,141 @@ float3 perturbNormal(float3 n, float3 p0, float3 p1, float3 p2, float2 t0, float return nm; } +// ---- Parallax occlusion mapping ------------------------------------------------------------------- +// +// Packs such as Patrix author most of their apparent depth into the LabPBR height channel (_n alpha) +// rather than into geometry. Caustica already ingests that channel -- RtBlockMaterials writes it to +// normalAo.a and RtMaterialTextureData carries it correctly down the mip chain -- but nothing has ever +// read it. This does. +// +// WHAT THIS IS AND IS NOT. This shifts the texture coordinate along the view ray through the height +// field, so surfaces gain real depth parallax: mortar lines sink, stones stand proud, and the whole +// thing shifts correctly as you move. It does NOT displace geometry, so SILHOUETTES STAY FLAT -- a +// block edge viewed side-on is still a straight line. Actual displacement in a path tracer needs the +// height field to exist as geometry the rays can hit, either tessellated into the BLAS or handled by a +// custom intersection shader, which is a different and much larger feature. Every real-time POM +// implementation makes this same trade. +// +// The offset is applied ONCE, before the albedo fetch, so albedo, roughness, F0 and the normal map all +// land on the same shifted coordinate. Offsetting only the material channels would slide them out of +// register with the colour and look worse than no POM at all. +// +// LabPBR height: 1.0 is the surface, 0.0 the deepest point, so depth = 1 - height. + +static const int POM_MAX_STEPS = 64; +static const int POM_REFINE_STEPS = 5; + +// Tangent frame for the hit triangle. Duplicated from perturbNormal rather than shared, because that +// function consumes it immediately and threading it out would change a signature every shading path +// depends on. Same maths; if one changes, change both. +bool hitTangentFrame(float3 n, float3 p0, float3 p1, float3 p2, float2 t0, float2 t1, float2 t2, + out float3 T, out float3 B) { + T = float3(1.0, 0.0, 0.0); + B = float3(0.0, 1.0, 0.0); + float2 g1 = t1 - t0; + float2 g2 = t2 - t0; + float det = g1.x * g2.y - g1.y * g2.x; + if (abs(det) <= 1.0e-12) { + return false; + } + float r = 1.0 / det; + float3 traw = ((p1 - p0) * g2.y - (p2 - p0) * g1.y) * r; + T = normalize(traw - n * dot(n, traw)); + B = cross(n, T) * (det < 0.0 ? -1.0 : 1.0); + return true; +} + +float pomHeightAt(MaterialHeader header, float2 localUv, float lod) { + float2 pageUv = header.materialUv.xy + clamp(localUv, float2(0.0), float2(1.0)) * header.materialUv.zw; + return samplePageNormalAo(header.texturePage, pageUv, lod).a; +} + +// The march itself, in the sprite's own [0,1] space. Terrain reaches this through an atlas-rect +// conversion; entities pass their UV straight in, because an entity texture is its own bindless image +// rather than a region of a shared atlas — there is no rect to map through, and running terrain's +// conversion on an identity rect would be a no-op at best and a silent scale error at worst. +float2 parallaxLocalUv(MaterialHeader header, float2 localUv, float lod, float3 n, + float3 p0, float3 p1, float3 p2, float2 t0, float2 t1, float2 t2, + float3 vdir, float3 pomParams) { + float depthScale = pomParams.x; + if (depthScale <= 0.0 || (header.features & MATERIAL_FEATURE_NORMAL) == 0u) { + return localUv; + } + // Distance fade. POM's cost is per-hit and its benefit vanishes once a sprite covers a few pixels, + // so it is faded out by ray-cone LOD rather than switched off at a hard distance -- a hard cutoff + // would pop a visible seam across the ground as you walk. + float fade = 1.0 - smoothstep(pomParams.z - 1.0, pomParams.z, lod); + if (fade <= 0.0) { + return localUv; + } + float3 T; + float3 B; + if (!hitTangentFrame(n, p0, p1, p2, t0, t1, t2, T, B)) { + return localUv; + } + // View direction in tangent space. At grazing angles vz goes to zero and the offset would explode, + // so it is floored -- the classic POM stretching artifact is worse than the lost parallax. + float3 viewT = float3(dot(vdir, T), dot(vdir, B), dot(vdir, n)); + float vz = max(abs(viewT.z), 0.35); + + localUv = clamp(localUv, float2(0.0), float2(1.0)); + // Total UV travel for a ray descending the full height range. + float2 maxOffset = (viewT.xy / vz) * depthScale * fade; + + // Steps scale with view angle: a steep view crosses little of the height field and needs few + // samples, a grazing one crosses a lot and needs many. + int steps = int(clamp(lerp(float(POM_MAX_STEPS), 8.0, abs(viewT.z)) * max(pomParams.y, 0.1), + 4.0, float(POM_MAX_STEPS))); + float stepDepth = 1.0 / float(steps); + float2 stepOffset = maxOffset * stepDepth; + + // Linear search for the first step where the ray has sunk below the height field. + float rayDepth = 0.0; + float2 current = localUv; + float height = pomHeightAt(header, current, lod); + float surfaceDepth = 1.0 - height; + for (int i = 0; i < steps; ++i) { + if (rayDepth >= surfaceDepth) { + break; + } + current -= stepOffset; + rayDepth += stepDepth; + surfaceDepth = 1.0 - pomHeightAt(header, current, lod); + } + + // Binary refinement of the crossing. Cheaper and more stable than the usual linear interpolation + // between the last two samples when the height field has hard steps, which block textures are + // almost entirely made of. + float2 lo = current; + float2 hi = current + stepOffset; + for (int i = 0; i < POM_REFINE_STEPS; ++i) { + float2 mid = (lo + hi) * 0.5; + float midRayDepth = rayDepth - stepDepth * 0.5; + if (1.0 - pomHeightAt(header, mid, lod) > midRayDepth) { + hi = mid; + } else { + lo = mid; + rayDepth = midRayDepth; + } + stepDepth *= 0.5; + } + return clamp((lo + hi) * 0.5, float2(0.0), float2(1.0)); +} + +// Terrain: convert into the sprite's local space, march, convert back. Clamping happens inside the +// march, in local space, so the result can never bleed into a neighbouring sprite -- the single most +// common way POM goes wrong on a texture atlas. +float2 parallaxOffsetUv(MaterialHeader header, float2 atlasUv, float lod, float3 n, + float3 p0, float3 p1, float3 p2, float2 t0, float2 t1, float2 t2, + float3 vdir, float3 pomParams) { + if (pomParams.x <= 0.0 || (header.features & MATERIAL_FEATURE_NORMAL) == 0u) { + return atlasUv; + } + float2 localUv = (atlasUv - header.albedoUv.xy) * header.albedoUv.zw; + float2 shifted = parallaxLocalUv(header, localUv, lod, n, p0, p1, p2, t0, t1, t2, vdir, pomParams); + return header.albedoUv.xy + shifted / max(header.albedoUv.zw, float2(1.0e-8)); +} + struct Surface { float3 normal; float3 f0; @@ -292,8 +427,16 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) float3 ep2 = mul(entityO2w, HitTriangleVertexPosition(2)); float entityLod = rayConeTextureLod(rayCone, texSizePx(entityAlbedoTex[NonUniformResourceIndex(texSlot)]), ep0, ep1, ep2, euv[e0], euv[e1], euv[e2]); - float4 entityTexel = entityAlbedoTex[NonUniformResourceIndex(texSlot)].SampleLevel(euvCoord, entityLod); MaterialHeader header = ConstPtr(pc.materialTableAddr)[pr.materialId]; + // Parallax on entity geometry, before the albedo fetch so colour and material channels stay in + // register — same rule as terrain. Entity UVs are already in the texture's own space, so the + // march runs directly rather than through an atlas rect. Mob armour, shulker shells and chest + // trim carry LabPBR height in packs like Patrix, and without this they stay flat while the + // blocks around them do not. + euvCoord = parallaxLocalUv(header, euvCoord, entityLod, n, ep0, ep1, ep2, + euv[e0], euv[e1], euv[e2], vdir, + ConstPtr(pc.worldPushAddr)[0].pom.xyz); + float4 entityTexel = entityAlbedoTex[NonUniformResourceIndex(texSlot)].SampleLevel(euvCoord, entityLod); uint material = header.model; float3 baseAlbedo709 = srgbToLinear(entityTexel.rgb) * srgbToLinear(pr.tint.rgb); float3 opticalColor709 = material == MATERIAL_DIELECTRIC @@ -401,6 +544,11 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) // shades the surface as a clear dielectric and only needs the tint to derive the per-channel // Beer–Lambert absorption. Opaque terrain uses textured albedo. float3 tint709 = srgbToLinear(tint); + // Parallax BEFORE the albedo fetch, so every channel sampled below shares one shifted coordinate. + // One indirect load for the POM parameters, taken only on the terrain path that uses them rather + // than at shader entry, matching how the entity path above reaches WorldPush. + uv = parallaxOffsetUv(materialHeader, uv, blockLod, n, tp0, tp1, tp2, uv0, uv1, uv2, vdir, + ConstPtr(pc.worldPushAddr)[0].pom.xyz); float3 albedo709 = srgbToLinear(blockAlbedoAtlas.SampleLevel(uv, blockLod).rgb) * tint709; packAlbedo(payload, bt709ToAcesCg( materialHeader.model == MATERIAL_WATER ? tint709 : albedo709)); diff --git a/shaders/pipelines/world/clouds.slang b/shaders/pipelines/world/clouds.slang new file mode 100644 index 000000000..535cf7936 --- /dev/null +++ b/shaders/pipelines/world/clouds.slang @@ -0,0 +1,399 @@ +// Raymarched volumetric cloud layer. Depends on sky and bindings. +// +// A single cumulus deck between cloudBottom and cloudBottom+thickness, marched front to back and lit by +// whichever celestial body dominates, through the same transmittance LUT the terrain and the sun disc +// use. Output is linear BT.709 (sky.slang's working space), returned as a scattered-radiance plus +// transmittance pair so the caller composites the deck over the sky it already computed. +// +// Density is procedural rather than textured. A 3D noise texture would be the faster answer per sample, +// but it costs a descriptor binding, a CPU-side bake, and a resource lifetime to manage in RtComposite; +// the arithmetic version keeps the entire feature inside two shader files plus eight push floats. If this +// ever becomes the frame's bottleneck, the density function is the single thing to move to a texture and +// nothing else here changes. +// +// Shape is the standard cumulus recipe: a low-frequency fBm establishes the base shape, coverage remaps +// it so raising coverage grows existing clouds outward instead of fading a uniform haze up from zero, a +// height gradient rounds the bottom and flattens-then-billows the top, and a high-frequency fBm erodes +// the edges so silhouettes are wispy rather than blobby. +// +// Lighting is dual-lobe Henyey-Greenstein — a strong forward lobe for the silver lining when you look +// toward the sun, a weak backward lobe so the deck does not go flat when you look away — with a Beer's +// law transmittance along a short march toward the light, and the "powder" term that reproduces the dark +// edge on the lit side of a cloud that pure Beer's law gets wrong. +// +// COST WARNING, and the reason for the gate in sky.rmiss: this is a nested march, ~64 view samples each +// spawning ~6 light samples, each of those evaluating several octaves of noise. That is affordable once +// per pixel on a primary ray. It is not affordable on every diffuse bounce's miss, which is why the caller +// runs it only for rays that may see the celestial discs — the primary ray and specular/dielectric +// continuations. Diffuse bounces see the unclouded sky, which loses the deck's contribution to bounce +// lighting; that is a deliberate trade, not an oversight. + +import world_common; +import sky; +import bindings; + +// Base step counts, scaled at runtime by the quality setting. 64/6 is the reference quality; halving +// the view steps is close to free visually on a moving camera because the deck is smooth and the +// denoiser sees it as low-frequency, while halving the light steps costs more than it saves — the +// light march is what produces the shading gradient across a cloud. +public static const int CLOUD_VIEW_STEPS = 64; +public static const int CLOUD_LIGHT_STEPS = 6; +// Marching past this is wasted work: at the deck's grazing angles the transmittance cutoff below always +// trips first, and the noise domain loses fp32 precision long before this. +public static const float CLOUD_MAX_MARCH = 60000.0; +// Front-to-back early out. 0.01 is ~6.6 stops down, far under the ACES 2.0 output transform's ability to +// show a difference. +public static const float CLOUD_MIN_TRANSMITTANCE = 0.01; + +public static const float CLOUD_HG_FORWARD = 0.80; +public static const float CLOUD_HG_BACKWARD = -0.25; +public static const float CLOUD_HG_MIX = 0.60; + +public struct CloudParams { + public float coverage; // 0 clear .. 1 overcast. 0 disables the whole module. + public float density; // extinction per block inside fully dense cloud + public float bottomY; // deck base, rebased world Y + public float thickness; // deck depth in blocks + public float2 wind; // domain offset in blocks, advanced on the CPU so it is frame-rate independent + public float detail; // 0..1 erosion strength + public float featureSize; // blocks per noise period; smaller means more, tighter cloud masses + public float ambient; // fraction of zenith sky scattered in as multiple-scattering stand-in + public float quality; // scales the march step counts; 1 is reference, 0.5 is half the samples +}; + +public CloudParams makeCloudParams(WorldPush push) { + CloudParams c; + c.coverage = clamp(push.cloud0.x, 0.0, 1.0); + c.density = max(push.cloud0.y, 0.0); + c.bottomY = push.cloud0.z; + c.thickness = max(push.cloud0.w, 1.0); + c.wind = push.cloud1.xy; + c.detail = clamp(push.cloud1.z, 0.0, 1.0); + c.ambient = max(push.cloud1.w, 0.0); + c.featureSize = max(push.cloud2.w, 1.0); + c.quality = clamp(push.cloud3.x, 0.1, 1.0); + return c; +} + +public bool cloudsActive(CloudParams c) { + return c.coverage > 0.0 && c.density > 0.0; +} + +// ---- Noise ------------------------------------------------------------------------------------------- +// Value noise on a hashed integer lattice. Chosen over gradient noise because clouds want the blobby, +// isotropic character value noise already has, and it is roughly half the ALU. + +// Value-noise helpers removed: the base and detail fields are now baked into cloudNoiseTex +// (RtCloudNoise), so nothing evaluates them at runtime any more. + +// ---- Density ----------------------------------------------------------------------------------------- + +// Vertical profile: a soft rounded base and a wider billowing top, both zero at the deck boundaries so the +// march never terminates against a hard slab face. +float cloudHeightProfile(float relativeHeight) { + float h = clamp(relativeHeight, 0.0, 1.0); + float base = smoothstep(0.0, 0.22, h); + float top = 1.0 - smoothstep(0.55, 1.0, h); + return base * top; +} + +public float cloudDensity(CloudParams c, float3 position) { + float relativeHeight = (position.y - c.bottomY) / c.thickness; + if (relativeHeight <= 0.0 || relativeHeight >= 1.0) { + return 0.0; + } + float profile = cloudHeightProfile(relativeHeight); + if (profile <= 0.0) { + return 0.0; + } + + // Base shape. Horizontal scale is coarser than vertical: cumulus fields are wide and shallow, and + // isotropic noise reads as fog banks rather than clouds. + // + // FEATURE SIZE IS CONFIGURABLE, and the original hardcoded 0.0012 was badly wrong: one noise period + // spanned ~830 blocks, so an entire render distance held two or three cloud masses, and wind at + // 1.5 blocks/second moved the field 0.2% of a feature width per second — visually frozen. "Almost + // no clouds" and "clouds do not move" were one mistake, not two. + float baseFrequency = 1.0 / max(c.featureSize, 1.0); + float3 domain = float3(position.x + c.wind.x, position.y, position.z + c.wind.y); + float3 baseCoord = float3(domain.x * baseFrequency, + domain.y * baseFrequency * 2.9, + domain.z * baseFrequency); + // One filtered fetch instead of four octaves of hashed value noise. The texture tiles, so the + // repeat address mode does the domain wrapping for free and the coordinate needs no fract(). + float4 noise = cloudNoiseTex.SampleLevel(baseCoord, 0.0); + float shape = noise.x; + + // Coverage as a threshold remap, not a multiply. Multiplying would raise a flat haze everywhere as + // coverage rises; subtracting a threshold and renormalizing grows the clouds that exist outward from + // their cores, which is what an advancing front actually looks like. + float threshold = 1.0 - c.coverage; + float shaped = saturate((shape - threshold) / max(1.0 - threshold, 1.0e-3)); + shaped *= profile; + if (shaped <= 0.0) { + return 0.0; + } + + // Erosion, applied as an inward carve whose strength falls off with density. Eroding the core as hard + // as the edge would punch holes through the middle of every cloud. + if (c.detail > 0.0) { + // Detail rides at 10x the base frequency so it scales with feature size rather than staying + // fixed while the base changes underneath it. Second fetch rather than reusing the first + // texel's G: the two fields are sampled at different frequencies, which is the point. + float detailFrequency = baseFrequency * 10.0; + float3 detailCoord = float3(domain.x * detailFrequency, + domain.y * detailFrequency * 1.7, + domain.z * detailFrequency); + float erosion = 1.0 - cloudNoiseTex.SampleLevel(detailCoord, 0.0).y; + float strength = c.detail * (1.0 - shaped); + shaped = saturate(shaped - erosion * erosion * strength); + } + + return shaped * c.density; +} + +// ---- Ground shadowing --------------------------------------------------------------------------------- +// +// The deck is marched only on celestial-visible rays, so without this the world underneath is lit as if +// the sky were clear: an overcast noon stays as bright as a clear one, which is the single most obviously +// wrong thing about a cloud layer that only exists in the sky. +// +// This is a separate, deliberately coarse march along the shadow ray. Two departures from the view march, +// both intentional: +// +// * two octaves, no erosion. A cloud shadow reaching the ground has been diffused through hundreds of +// blocks of forward scattering; the deck's fine edge structure is simply not present in it. Marching +// the detail octaves here would cost more and produce a crisper shadow than physics allows. +// * a transmittance floor. Beer's law through a dense deck goes to near zero, but real overcast ground +// is not black — it is lit by light that scattered through the deck and arrives diffusely. That +// multiple-scattering path is not modelled, so the floor stands in for it. Without it, storms +// produce a world lit only by block light, which looks broken rather than dark. + +public static const int CLOUD_SHADOW_STEPS = 8; + +float cloudDensityCoarse(CloudParams c, float3 position) { + float relativeHeight = (position.y - c.bottomY) / c.thickness; + if (relativeHeight <= 0.0 || relativeHeight >= 1.0) { + return 0.0; + } + float3 domain = float3(position.x + c.wind.x, position.y, position.z + c.wind.y); + float baseFrequency = 1.0 / max(c.featureSize, 1.0); + float shape = cloudNoiseTex.SampleLevel(float3(domain.x * baseFrequency, + domain.y * baseFrequency * 2.9, + domain.z * baseFrequency), 0.0).x; + float threshold = 1.0 - c.coverage; + return saturate((shape - threshold) / max(1.0 - threshold, 1.0e-3)) + * cloudHeightProfile(relativeHeight) * c.density; +} + +// Fraction of celestial light reaching `position` through the deck. `strength` scales the optical depth; +// `floorValue` is the multiple-scattering floor described above. +public float cloudShadowTransmittance(CloudParams c, float3 position, float3 lightDir, + float strength, float floorValue) { + if (!cloudsActive(c) || strength <= 0.0) { + return 1.0; + } + // A receiver above the deck is not shadowed by it, and a light below the horizon contributes nothing + // worth marching for. + if (position.y >= c.bottomY + c.thickness || lightDir.y <= 0.01) { + return 1.0; + } + float tNear; + float tFar; + if (!cloudSlabRange(c, position, lightDir, tNear, tFar)) { + return 1.0; + } + // Cap the slant path: at low sun the geometric distance through the deck grows without bound, and an + // unclamped march makes the world snap to the shadow floor for the last few minutes before sunset. + float span = min(tFar - tNear, c.thickness * 4.0); + float step = span / float(CLOUD_SHADOW_STEPS); + float opticalDepth = 0.0; + float t = tNear + step * 0.5; + for (int i = 0; i < CLOUD_SHADOW_STEPS; ++i) { + opticalDepth += cloudDensityCoarse(c, position + lightDir * t) * step; + t += step; + } + return lerp(clamp(floorValue, 0.0, 1.0), 1.0, exp(-opticalDepth * strength)); +} + +// ---- Rain wetness ------------------------------------------------------------------------------------- +// +// A wet surface is not just a darker surface. Two things happen physically, and doing only the first is +// the usual mistake: +// +// 1. A water film fills the surface's microscopic pores. Light entering the film is trapped by total +// internal reflection at the film's top and bounces inside until it is absorbed, so less of it comes +// back out — the surface darkens. Porous materials (dirt, wool, sand) darken far more than sealed +// ones (glass, metal), which is why the effect scales with roughness here: in the absence of a +// porosity channel, authored roughness is the best available proxy for it. +// +// 2. The film's own top surface is smooth, so the material gains a sharp specular lobe it did not have +// dry. This is what actually reads as "wet" — the darkening alone just looks like a texture swap. +// +// Applied only to upward-facing surfaces: rain lands on horizontal faces, runs off vertical ones, and +// never reaches undersides. + +public static const float WETNESS_DARKENING = 0.45; // strongest albedo loss, for a fully porous surface +public static const float WETNESS_SMOOTHNESS = 0.15; // roughness a fully wet surface tends toward + +// `skyExposure` is the caller's upward visibility test — rain does not reach a surface under a roof. +public void applyWetness(float wetness, float skyExposure, float3 normal, + inout float3 albedo, inout float rough) { + float wet = clamp(wetness, 0.0, 1.0) * clamp(skyExposure, 0.0, 1.0); + // Fade over the upper hemisphere rather than a hard n.y > 0 test, so a sloped face does not show a + // seam against the flat one next to it. + wet *= smoothstep(0.0, 0.35, normal.y); + if (wet <= 0.0) { + return; + } + // Roughness stands in for porosity: a rough surface has pores to fill, a polished one does not. + float porosity = rough; + albedo *= 1.0 - WETNESS_DARKENING * porosity * wet; + // Toward, not to: even a wet gravel path keeps some of its own scatter, and driving roughness to the + // film value outright turns every wet surface into a mirror. + rough = lerp(rough, max(WETNESS_SMOOTHNESS, rough * 0.25), wet); +} + +// ---- Phase and light march --------------------------------------------------------------------------- + +float cloudHenyeyGreenstein(float cosTheta, float g) { + float gg = g * g; + float denom = 1.0 + gg - 2.0 * g * cosTheta; + return (1.0 - gg) / (4.0 * SKY_PI * max(denom * sqrt(max(denom, 1.0e-6)), 1.0e-6)); +} + +float cloudPhase(float cosTheta) { + return lerp(cloudHenyeyGreenstein(cosTheta, CLOUD_HG_BACKWARD), + cloudHenyeyGreenstein(cosTheta, CLOUD_HG_FORWARD), CLOUD_HG_MIX); +} + +// Optical depth from a point toward the light. Steps grow geometrically: near samples decide the visible +// shading gradient, far ones only need to establish that the light is or is not deeply buried. +float cloudLightOpticalDepth(CloudParams c, float3 position, float3 lightDir) { + float opticalDepth = 0.0; + float step = max(c.thickness * 0.08, 1.0); + float travelled = 0.0; + int lightSteps = int(max(float(CLOUD_LIGHT_STEPS) * c.quality, 3.0)); + for (int i = 0; i < lightSteps; ++i) { + travelled += step; + opticalDepth += cloudDensity(c, position + lightDir * travelled) * step; + step *= 1.5; + } + return opticalDepth; +} + +// Beer's law with the powder correction. Pure Beer's law makes a cloud brightest exactly where it is +// densest on the lit side, which is backwards: light entering a dense boundary scatters back out of the +// surface it entered through, leaving a characteristic dark rim. The (1 - e^-2d) factor restores it. +float cloudBeerPowder(float opticalDepth) { + float beer = exp(-opticalDepth); + float powder = 1.0 - exp(-opticalDepth * 2.0); + return beer * lerp(1.0, powder * 2.0, 0.5); +} + +// ---- Slab intersection ------------------------------------------------------------------------------- + +// Returns false when the ray never meets the deck: below it looking down, above it looking up, or level +// enough to never cross either plane. +bool cloudSlabRange(CloudParams c, float3 origin, float3 direction, out float tNear, out float tFar) { + tNear = 0.0; + tFar = 0.0; + float bottom = c.bottomY; + float top = c.bottomY + c.thickness; + if (abs(direction.y) < 1.0e-5) { + if (origin.y <= bottom || origin.y >= top) { + return false; + } + tNear = 0.0; + tFar = CLOUD_MAX_MARCH; + return true; + } + float t0 = (bottom - origin.y) / direction.y; + float t1 = (top - origin.y) / direction.y; + tNear = max(min(t0, t1), 0.0); + tFar = min(max(t0, t1), CLOUD_MAX_MARCH); + return tFar > tNear; +} + +// ---- March ------------------------------------------------------------------------------------------- + +public struct CloudResult { + public float3 scattering; // linear BT.709 radiance scattered toward the viewer + public float transmittance; // what fraction of the sky behind the deck survives +}; + +// Vanilla's CLOUD_COLOR for the camera's position, applied hue-only. Same reasoning as the fog tint: +// preserving the deck's own luminance means a dark authored cloud colour cannot black out a sunlit +// deck, and the time-of-day response computed from the atmosphere survives the tint. +float3 tintCloud(float3 scattering, float3 tint709, float strength) { + float clamped = clamp(strength, 0.0, 1.0); + if (clamped <= 0.0) { + return scattering; + } + float3 tint = bt709ToAcesCg(max(tint709, float3(0.0))); + float tintLuma = dot(tint, ACESCG_LUMA); + // Same trap as the fog tint: a black CLOUD_COLOR is missing data, not an instruction to render + // black clouds. Leave the deck lit by the atmosphere when there is no usable tint. + if (tintLuma <= 1.0e-3) { + return scattering; + } + return lerp(scattering, scattering * (tint / tintLuma), clamped); +} + +public CloudResult marchClouds(CloudParams c, SkyState state, float3 origin, float3 direction, + float3 tint709, float tintStrength) { + CloudResult result; + result.scattering = float3(0.0, 0.0, 0.0); + result.transmittance = 1.0; + if (!cloudsActive(c)) { + return result; + } + float tNear; + float tFar; + if (!cloudSlabRange(c, origin, direction, tNear, tFar)) { + return result; + } + + // Whichever body is up drives the deck. Blending the two would double-light the deck through dawn and + // dusk, when both are near the horizon and both transmittances are near zero anyway. + bool sunUp = state.sunDir.y >= state.moonDir.y; + float3 lightDir = sunUp ? state.sunDir : state.moonDir; + float3 lightIrradiance = (sunUp ? state.sunIlluminance : state.moonIlluminance) + * transmittanceToSpace(transmittanceLut, state.viewerRadiusKm, lightDir, + sunUp ? state.sunAngularRadius : state.moonAngularRadius); + + // Multiple scattering inside the deck is not marched. Standing in for it: the sky's own radiance from + // straight up, which is where a cloud's shadowed underside actually gets most of its light. + float2 zenithUv = skyViewLutUv(state.viewerRadiusKm, 1.0, + lightViewCosine(float3(0.0, 1.0, 0.0), lightDir), sunUp ? SKY_VIEW_BODY_SUN : SKY_VIEW_BODY_MOON); + float3 ambient = skyViewLut.SampleLevel(zenithUv, 0.0).rgb * c.ambient; + + float phase = cloudPhase(dot(direction, lightDir)); + int viewSteps = int(max(float(CLOUD_VIEW_STEPS) * c.quality, 8.0)); + float stepSize = (tFar - tNear) / float(viewSteps); + float t = tNear + stepSize * 0.5; + + for (int i = 0; i < viewSteps; ++i) { + float3 position = origin + direction * t; + float density = cloudDensity(c, position); + if (density > 0.0) { + float lightDepth = cloudLightOpticalDepth(c, position, lightDir); + float3 inScatter = lightIrradiance * phase * cloudBeerPowder(lightDepth) + ambient; + + // Energy-conserving integration of the segment rather than a rectangle rule on radiance: + // integrate(0..dt) L * sigma * e^(-sigma*s) ds = L * (1 - e^(-sigma*dt)). This stays correct as + // step size changes, so raising CLOUD_VIEW_STEPS refines the silhouette without also changing + // how bright the deck is. + float segmentTransmittance = exp(-density * stepSize); + result.scattering += result.transmittance * inScatter * (1.0 - segmentTransmittance); + result.transmittance *= segmentTransmittance; + if (result.transmittance < CLOUD_MIN_TRANSMITTANCE) { + result.transmittance = 0.0; + break; + } + } + t += stepSize; + } + result.scattering = tintCloud(result.scattering, tint709, tintStrength); + return result; +} diff --git a/shaders/pipelines/world/fog.slang b/shaders/pipelines/world/fog.slang new file mode 100644 index 000000000..5d965a6ec --- /dev/null +++ b/shaders/pipelines/world/fog.slang @@ -0,0 +1,178 @@ +// Exponential height fog, integrated analytically per path segment. Depends on sky and bindings. +// +// This is aerial perspective for the block-scale world, not the planet-scale atmosphere: sky.slang's LUTs +// already carry Rayleigh/Mie/ozone over 100 km, but their density at the 0-320 block range the player +// actually looks through is far too low to read as fog. This module adds a second, thin, tunable medium +// that lives entirely inside the render distance. +// +// It is applied as an AFFINE operation on radiance, not as a throughput multiply. A throughput-only +// exp(-sigma*t) — which is all the existing water/glass path does — is pure absorption, and pure +// absorption sends the horizon to black rather than to sky colour. Fog needs the in-scatter term too: +// +// L' = L * T + Lin * (1 - T) +// +// so every call site has to hand over both the accumulated radiance and the throughput. That is why this +// is a helper taking two inout parameters rather than something foldable into MediumStack: the medium +// stack is deliberately a pure-extinction abstraction and this would have quietly broken that invariant +// for water and glass as well. +// +// The in-scatter radiance is the SKY VIEW LUT sampled in the ray's own direction, not a configured +// colour. That is the whole reason this reads as atmosphere rather than as a grey wash: fog at sunset is +// orange toward the sun and blue away from it, fog at night falls to the airglow floor, and fog under an +// overcast look package tracks whatever the LUT bake produced — all for free, and all guaranteed to match +// the sky the miss shader draws, because it is literally the same texture. +// +// Transmittance is deliberately scalar rather than per-channel. Real fog is Mie-dominated and close to +// spectrally flat at these distances, and a scalar T lets the whole thing cost one exp and one LUT pair. + +import world_common; +import sky; +import medium; +import bindings; + +// The trace tmax. Integrating a miss out to this distance is correct rather than arbitrary: an upward ray +// converges (the exponential integral is finite as t goes to infinity), and a horizontal ray at the +// reference height saturates to full fog, which is exactly what a fogged horizon should do. +public static const float FOG_MAX_DISTANCE = 10000.0; + +// Keeps exp() finite for a viewer far under the reference plane or a steep downward ray in a thin fog. +// e^60 is already past anything that survives the 1 - T below, so clamping here costs nothing visible. +static const float FOG_EXP_CLAMP = 60.0; + +public struct FogParams { + public float sigma; // extinction per block at referenceY. 0 disables the whole module. + public float falloff; // 1 / scale height, per block + public float referenceY; // rebased world Y at which density is sigma + public float albedo; // single-scattering albedo; 1 is a perfectly scattering (non-absorbing) fog +}; + +public FogParams makeFogParams(WorldPush push) { + FogParams f; + f.sigma = max(push.fog.x, 0.0); + f.falloff = max(push.fog.y, 0.0); + f.referenceY = push.fog.z; + f.albedo = clamp(push.fog.w, 0.0, 1.0); + return f; +} + +public bool fogActive(FogParams f) { + return f.sigma > 0.0; +} + +// Fog exists in air only. Inside water or glass that medium's own Beer-Lambert extinction is already the +// correct and complete answer, and layering a second medium on top would double-count the attenuation. +public bool fogAppliesTo(Medium m) { + return !m.water && all(m.extinction <= 0.0); +} + +// Optical depth of an exponential-density slab along ro + rd*s for s in [0, t]. +// +// n(y) = exp(-(y - referenceY) * falloff) +// tau = sigma * integral of n(ro.y + rd.y*s) ds +// = sigma * a * (1 - exp(-b*t)) / b, a = n(ro.y), b = rd.y * falloff +// +// The b -> 0 limit (a level ray, which is the common case when looking at the horizon) is the removable +// singularity sigma*a*t, taken by an explicit branch rather than by an epsilon in the denominator. +public float fogOpticalDepth(FogParams f, float3 ro, float3 rd, float t) { + float travel = clamp(t, 0.0, FOG_MAX_DISTANCE); + if (travel <= 0.0) { + return 0.0; + } + float density = exp(-clamp((ro.y - f.referenceY) * f.falloff, -FOG_EXP_CLAMP, FOG_EXP_CLAMP)); + float b = rd.y * f.falloff; + float integral; + if (abs(b) < 1.0e-5) { + integral = density * travel; + } else { + integral = density * (1.0 - exp(-clamp(b * travel, -FOG_EXP_CLAMP, FOG_EXP_CLAMP))) / b; + } + return f.sigma * max(integral, 0.0); +} + +// Radiance scattered into the ray by the fog. Two LUT fetches, matching the miss shader's skyDome: the +// sun slice plus the moon slice, then the isotropic airglow floor so night fog settles at the same +// luminance the night sky does instead of going black. sky.slang works in linear BT.709; the path +// integral is ACEScg, so this crosses the same seam the miss shader does, in the same place. +// Vanilla's FOG_COLOR for wherever the camera is, and how hard to pull scattering toward it. In the +// Overworld this is a light tint over the physically computed sky, so a swamp reads green-grey without +// losing the atmosphere's time-of-day behaviour. In the Nether and the End there is no atmosphere worth +// sampling, so the caller sends full strength and this becomes the entire fog colour. +public float3 fogTint(FogParams f, float3 inscatter, WorldPush push) { + float strength = clamp(push.dimFog.w, 0.0, 1.0); + if (strength <= 0.0) { + return inscatter; + } + // Tint by hue, not by replacement: preserve the scattering's own luminance so a dark fog colour + // cannot black out a bright sunlit haze, and a bright one cannot light up a night scene. + float3 tint = bt709ToAcesCg(max(push.dimFog.rgb, float3(0.0))); + float tintLuma = dot(tint, ACESCG_LUMA); + // A BLACK tint means "no colour information", not "no light". Clamping the divisor was not enough: + // dividing a black tint by a tiny epsilon still yields zero, so the multiply below wiped the fog + // out and every distant surface went to black. A server or dimension that reports no FOG_COLOR is + // exactly this case, and the correct response is to leave the atmosphere's own colour alone. + if (tintLuma <= 1.0e-3) { + return inscatter; + } + float3 normalizedTint = tint / tintLuma; + return lerp(inscatter, inscatter * normalizedTint, strength); +} + +public float3 fogInscatter(FogParams f, float3 rd, SkyState state) { + // THE SKY-VIEW LUT HAS NO DATA BELOW THE HORIZON, and this is where that bites. + // + // sky.rmiss only ever samples it for rays that escaped to space, which are above the horizon by + // construction. Fog is different: most fogged rays point slightly DOWN, at distant terrain. Sampling + // the LUT there returns black, so every long ray toward the ground accumulated black in-scatter and + // the horizon went to pitch black — worse the denser the fog, because denser fog means more of that + // black and less of the surface behind it. + // + // Physically the fix is not a clamp for its own sake: light scattered into a downward ray comes from + // the sky above the scattering point, and near the horizon that is the horizon sky. So the lookup + // direction is folded up to just above the horizon while the LIGHT-relative angle keeps the true + // direction, which is what preserves the orange-toward-the-sun behaviour. + float horizonCos = horizonZenithCos(state.viewerRadiusKm); + float lookupZenithCos = max(rd.y, horizonCos + 1.0e-3); + float2 sunUv = skyViewLutUv(state.viewerRadiusKm, lookupZenithCos, + lightViewCosine(rd, state.sunDir), SKY_VIEW_BODY_SUN); + float2 moonUv = skyViewLutUv(state.viewerRadiusKm, lookupZenithCos, + lightViewCosine(rd, state.moonDir), SKY_VIEW_BODY_MOON); + float3 sky709 = skyViewLut.SampleLevel(sunUv, 0.0).rgb + + skyViewLut.SampleLevel(moonUv, 0.0).rgb + + state.airglowLuminance; + return bt709ToAcesCg(max(sky709, float3(0.0))) * f.albedo; +} + +// Fold one travelled segment into the path. Call once per segment, BEFORE the segment's endpoint +// contributes anything to L, so that the in-scatter enters at this segment's throughput and everything +// beyond it is attenuated by this segment's transmittance. +// Debug: replace the path's radiance with the raw fog in-scatter colour, so the value can be SEEN +// rather than inferred. Three fog fixes have now been attempted from reasoning alone and all three +// were wrong about the cause; this is the cheapest way to find out whether the in-scatter is black +// (a lookup problem) or bright (a composition problem), which need opposite fixes. +public bool fogDebugInscatter(FogParams f, WorldPush push, float3 rd, SkyState state, + inout float3 L, inout float3 throughput) { + if (push.fog2.x < 0.5) { + return false; + } + L = fogTint(f, fogInscatter(f, rd, state), push); + throughput = float3(0.0); + return true; +} + +public void applyFogSegment(FogParams f, SkyState state, Medium medium, WorldPush push, + float3 ro, float3 rd, float t, + inout float3 L, inout float3 throughput) { + if (fogDebugInscatter(f, push, rd, state, L, throughput)) { + return; + } + if (!fogActive(f) || !fogAppliesTo(medium)) { + return; + } + float tau = fogOpticalDepth(f, ro, rd, t); + if (tau <= 0.0) { + return; + } + float transmittance = exp(-tau); + L += throughput * fogTint(f, fogInscatter(f, rd, state), push) * (1.0 - transmittance); + throughput *= transmittance; +} diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index 2df3dc6ab..65a9e1683 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -31,6 +31,9 @@ import trace_ser; import lighting; import sky; import bindings; +import fog; +import clouds; +import restir; // Atmospheric transmittance LUT (RtSkyLut), also bound to world.rmiss at the same binding: raygen reads it // to colour the NEE sun/moonlight, the miss shader reads it to tint the visible discs and stars. @@ -61,6 +64,14 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // (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; + FogParams fog = makeFogParams(worldPush); + SkyState fogSky = skyState(worldPush); + // Cloud shadowing runs on shadow rays, which every bounce casts, so unlike the view march it is + // affordable here — a coarse 8-step march of a 2-octave field, and only when a deck exists at all. + CloudParams shadowClouds = makeCloudParams(worldPush); + float cloudShadowStrength = worldPush.cloud2.x; + float cloudShadowFloor = worldPush.cloud2.y; + float wetness = worldPush.cloud2.z; 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 @@ -73,6 +84,20 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // 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; + // Pass A consumed the camera -> first-dielectric prefix, so Pass B never walks it and would leave the + // fog off anything seen through water or glass. Re-integrate that prefix here as a straight segment + // from the eye to this record's origin. Refraction bent the real path, but fog is a smooth function + // of position over tens of blocks and the bend is sub-block, so the straight-line approximation is + // below the noise floor. Both Fresnel branches get it, each weighted by its own throughput, and those + // two throughputs sum to one — so the in-scatter is added once, not twice. + if (seg.bounce > 0 && fogActive(fog)) { + float3 fromEye = ro - worldPush.camOffset; + float prefix = length(fromEye); + if (prefix > 1.0e-3) { + applyFogSegment(fog, fogSky, airMedium(), worldPush, worldPush.camOffset, fromEye / prefix, prefix, + L, throughput); + } + } 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. @@ -100,6 +125,11 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // world.rmiss writes sky radiance (gradient + sun/moon disc + stars) into the payload's // surface words as a full float3, so this is absolute scene units with nothing to undo. The // packed show-celestial flag decides whether this path segment may see the bright disc. + // Fog first: the escaping ray still crossed the whole fogged column, so the sky it reveals is + // attenuated by that column and the column's own in-scatter is added in front of it. At the + // horizon the two converge on the same sky-view texel, which is why a fogged horizon fades + // INTO the sky instead of forming a visible band against it. + applyFogSegment(fog, fogSky, medium.current, worldPush, ro, rd, FOG_MAX_DISTANCE, L, throughput); float3 sky = payloadSky(); L += throughput * sky; // escaped to sky break; @@ -112,6 +142,9 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { if (any(medium.current.extinction > 0.0)) { throughput *= exp(-medium.current.extinction * payload.hitT); } + // Air only — applyFogSegment gates on the medium itself, so a segment inside water or glass is a + // no-op here and its own extinction above remains the complete answer. + applyFogSegment(fog, fogSky, medium.current, worldPush, ro, rd, payload.hitT, L, throughput); float3 n = payloadNormal(); // oriented toward the incoming ray by the closest-hit float3 hitPos = ro + rd * payload.hitT; @@ -212,6 +245,12 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { if (ndl > 0.0) { float3 shadowOrigin = hitPos + (signedNdl >= 0.0 ? n : -n) * SURF_BIAS; float3 vis = visibility(shadowOrigin, lightDir, 10000.0).transmittance; + // Particles get their own evaluation: this branch returns before the terrain shading + // vertex below, so there is no shared value to hoist here. Same bounce cap. + if (bounce <= 1) { + vis *= cloudShadowTransmittance(shadowClouds, hitPos, lightDir, + cloudShadowStrength, cloudShadowFloor); + } if (max(vis.r, max(vis.g, vis.b)) > 0.0) { L += throughput * albedo * INV_PI * celestialLight.illuminance * ndl * vis; } @@ -253,6 +292,15 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // reflection sharp instead of accumulating it as a glossy lobe. bool exactSpecular = isDeltaAlpha(rough); rough = exactSpecular ? 0.0 : rough; + // Rain wetness. The upward visibility test is an extra ray per shaded vertex, so it is gated on + // there being rain at all: in clear weather this costs one comparison. The ray is short — 320 + // blocks clears any plausible build without paying for a full-height trace — and only its + // luminance is used, so a surface under stained glass is treated as partly exposed, which is + // closer to right than a binary test. + if (wetness > 0.0 && n.y > 0.0) { + float skyExposure = luminance(visibility(p, float3(0.0, 1.0, 0.0), 320.0).transmittance); + applyWetness(wetness, skyExposure, n, albedo, rough); + } float3 diffAlb = albedo * (1.0 - metal); float3 F0 = payload.f0; @@ -284,10 +332,25 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { if (lightHalfAngle > 0.0) { lightDir = sampleSquare(lightDir, lightHalfAngle, seed); } + // Cloud shadow, computed ONCE per shading vertex. It was previously evaluated at each of the + // three NEE sites below, all of which use the same shading point and the same light direction — + // so two thirds of the work was recomputing an identical number. Each evaluation is an 8-step + // march of a 2-octave field, so this is the single cheapest large saving available here. + // + // Also capped by bounce: the deck's shadow on a second-order bounce is a small correction to an + // already-dim contribution, and skipping it there costs nothing visible while removing the + // march from the majority of rays in a deep path. + float cloudShadow = bounce <= 1 + ? cloudShadowTransmittance(shadowClouds, hitPos, lightDir, + cloudShadowStrength, cloudShadowFloor) + : 1.0; float ndl = max(0.0, dot(n, lightDir)); if (ndl > 0.0) { VisibilityResult shadow = visibility(p, lightDir, 10000.0); - float3 vis = shadow.transmittance; + // The deck attenuates the light before it ever reaches the geometry, so it multiplies the + // shadow ray's visibility rather than the illuminance: same term, and it stays correct when + // the caustic factor below scales the whole NEE contribution. + float3 vis = shadow.transmittance * cloudShadow; // 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). @@ -308,15 +371,70 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } } - // RIS direct lighting from block emitters (single-frame only, no temporal reservoir reuse). + // RIS direct lighting from block emitters, with optional ReSTIR temporal reuse at the primary + // hit (restir.slang). // 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_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; + // ReSTIR temporal reuse, primary hit only. A secondary bounce has no stable screen-space + // identity to reproject through, so reusing history there would combine unrelated surfaces. Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, seed, proposalSeed); + // Primary hit only, and only for the camera's own path: bounce > 0 and the queued + // dielectric leaves have no stable screen-space identity to reproject through, so reusing + // history there would be combining unrelated surfaces into one pixel's reservoir. + float restirStrength = worldPush.restir.x; + bool restirPixel = restirStrength > 0.0 && bounce == 0 && seg.bounce == 0; + if (restirPixel) { + uint parity = uint(worldPush.restir.y + 0.5); + // Reprojection through the surface motion vector the guides already produce for DLSS, + // so history follows a moving surface rather than a fixed screen position. + float2 motion = gMotion[int2(pix)]; + int2 prevPix = int2(floor(float2(pix) + 0.5 - motion)); + uint reservoirW; + uint reservoirH; + reservoirA.GetDimensions(reservoirW, reservoirH); + // The images are double-width, so the addressable pixel count is half the texel width. + int renderWidth = int(reservoirW) / 2; + // Clamped rather than skipped: a pixel whose reprojection left the screen still has + // usable neighbours, and anchoring the spatial taps at the edge is better than + // dropping reuse entirely for everything moving off-frame. + prevPix = int2(clamp(prevPix.x, 0, renderWidth - 1), + clamp(prevPix.y, 0, int(reservoirH) - 1)); + { + PackedReservoir history = loadReservoir(prevPix, parity); + // Geometric rejection: same depth and normal, or the sample belonged to another + // surface that merely happened to land on this pixel last frame. + float currentDepth = payload.hitT; + float historyDepth = gDepth[prevPix]; + float3 historyNormal = gNormal[prevPix].xyz; + if (temporalSurfaceMatches(currentDepth, historyDepth, n, historyNormal)) { + r = temporalCombine(r, history, hitPos, n, v, rd, diffAlb, F0, rough, + false, activeSss, seed); + } + } + // Spatial reuse. Taps read the same history image, for the reason given in + // restir.slang: sampling this frame's output would race the concurrent writes of the + // very dispatch doing the sampling. + int spatialTaps = min(int(worldPush.restir.z), RESTIR_MAX_SPATIAL_TAPS); + float spatialRadius = worldPush.restir.w; + for (int tap = 0; tap < spatialTaps; ++tap) { + int2 tapPix = prevPix + spatialTapOffset(spatialRadius, seed); + if (any(tapPix < int2(0, 0)) || tapPix.x >= renderWidth + || tapPix.y >= int(reservoirH)) { + continue; + } + if (!temporalSurfaceMatches(payload.hitT, gDepth[tapPix], n, gNormal[tapPix].xyz)) { + continue; + } + r = temporalCombine(r, loadReservoir(tapPix, parity), hitPos, n, v, rd, + diffAlb, F0, rough, false, activeSss, seed); + } + storeReservoir(int2(pix), parity, toPacked(r)); + } L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss); } @@ -337,6 +455,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { visB *= waterCaustic(hitPos + lightDir * shadowBack.waterHitT, lightDir, shadowBack.waterHitT); } + visB *= cloudShadow; if (max(visB.r, max(visB.g, visB.b)) > 0.0) { float cosT = dot(lightDir, rd); L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * celestialLight.illuminance * visB; diff --git a/shaders/pipelines/world/restir.slang b/shaders/pipelines/world/restir.slang new file mode 100644 index 000000000..f2b34fa7d --- /dev/null +++ b/shaders/pipelines/world/restir.slang @@ -0,0 +1,258 @@ +// ReSTIR temporal reuse for the block-emitter reservoirs. Depends on lighting and bindings. +// +// lighting.slang's risInitial already does the RIS half of ReSTIR: it draws candidate emitters from the +// light grid and keeps one survivor weighted by its target function. What it does not do is remember +// anything. Every pixel, every frame, starts from an empty reservoir, so the effective sample count is +// whatever the candidate budget allows in one frame — which is why caves and night scenes stay noisy no +// matter how good the denoiser is. +// +// This adds the missing half: last frame's survivor, reprojected through the motion vector, is combined +// into this frame's reservoir. Over a stationary second a pixel accumulates hundreds of effective +// candidates for the cost of one, and the emitter that actually matters wins consistently instead of +// flickering between whichever few were sampled that frame. +// +// FOUR CORRECTNESS REQUIREMENTS, each of which produces a specific artifact when skipped: +// +// * M-capping. Unbounded history makes a pixel infinitely confident in a stale sample and the image +// stops responding to change — a torch you place lights nothing for several seconds. The cap trades +// a little variance for a bounded reaction time. +// * p-hat re-evaluation. The stored sample's target function must be recomputed against THIS frame's +// surface, not reused from last frame's. A reservoir carried onto a rotated or moved surface has a +// different BRDF and geometry term, and reusing the old weight is the classic ReSTIR bias. +// * Geometric rejection. The reprojected pixel is only the same surface if depth and normal agree. +// Without the test, history bleeds across silhouettes and leaves a smear trailing every moving edge. +// * Reset on discontinuity. First frame, resize, dimension change, world reload — the buffer holds +// positions in a rebased space that no longer exists, so it must be treated as empty. +// +// PING-PONG WITHOUT DESCRIPTOR REWRITES. Both images stay bound; a frame-parity push constant decides +// which is history. Rewriting descriptors per frame would touch ring-buffered sets that frames still in +// flight are reading — a use-after-write that validation layers catch only sometimes and that shows up +// as intermittent corruption otherwise. +// +// STORAGE. Two ping-ponged rgba32f images at RENDER resolution (binding 12 read, 13 write). Four +// channels is not enough for a Reservoir's fourteen floats, so what is stored is the minimum needed to +// replay the sample: the emitter point, its normal, its radiance, area, M and W. Everything else — +// wSum, phat — is recomputed on load, which is required anyway by the p-hat rule above. +// +// xyz : emitter sample point, rebased world space (needs full fp32; a half here quantises the light +// position into visible banding across a large room) +// w : packed, see below +// +// Only the primary hit is reused. A secondary bounce has no stable screen-space identity to reproject +// through, so temporal reuse there would be combining unrelated surfaces. + +import world_common; +import lighting; +import math; +import bindings; + +// Effective-sample ceiling. 20x the per-frame candidate budget is the usual starting point: enough +// history to kill the noise, short enough that a light change resolves in a few frames. +public static const float RESTIR_M_CAP = 20.0; +// Depth agreement, as a fraction of the reprojected depth. Proportional rather than absolute because a +// fixed epsilon that works at 5 blocks rejects everything at 200. +public static const float RESTIR_DEPTH_TOLERANCE = 0.05; +// Normal agreement in cosine. 0.9 is about 25 degrees — loose enough to survive normal-mapped detail, +// tight enough to reject a perpendicular wall behind a silhouette. +public static const float RESTIR_NORMAL_TOLERANCE = 0.9; + +public struct PackedReservoir { + public float3 pos; + public float3 lnrm; + public float3 le; + public float area; + public float M; + public float W; +}; + +public PackedReservoir packedEmpty() { + PackedReservoir p; + p.pos = float3(0.0); + p.lnrm = float3(0.0); + p.le = float3(0.0); + p.area = 0.0; + p.M = 0.0; + p.W = 0.0; + return p; +} + +public bool packedValid(PackedReservoir p) { + return p.M > 0.0 && p.W > 0.0; +} + +// ---- Octahedral normal encoding, 16 bits per component into one float -------------------------------- +// The emitter normal only feeds a cosine term, so a couple of degrees of quantisation is invisible. +// Octahedral beats spherical coordinates here for having no pole singularity. + +float2 octEncode(float3 n) { + n /= max(abs(n.x) + abs(n.y) + abs(n.z), 1.0e-8); + float2 oct = n.xy; + if (n.z < 0.0) { + oct = (1.0 - abs(float2(n.y, n.x))) * float2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0); + } + return oct * 0.5 + 0.5; +} + +float3 octDecode(float2 oct) { + float2 f = oct * 2.0 - 1.0; + float3 n = float3(f.x, f.y, 1.0 - abs(f.x) - abs(f.y)); + float t = max(-n.z, 0.0); + n.x += n.x >= 0.0 ? -t : t; + n.y += n.y >= 0.0 ? -t : t; + return normalize(n); +} + +// ---- Reservoir <-> image texels ---------------------------------------------------------------------- +// +// Two texels per pixel, side by side in a double-width image: texel 2x holds position + packed normal, +// texel 2x+1 holds radiance + area + M + W. A double-width image rather than two images keeps this to +// one binding pair and one allocation, and the two texels are adjacent so a load touches one cache line. + +// Slang has no built-in half packers here, and the three-value pack needs a custom split anyway: +// area and M are small and bounded, W needs range but not precision, so W keeps a full half and the +// other two share the remaining 16 bits as 8-bit fixed point. area is normalised against a generous +// emitter-face bound; a block face is at most 1 square block. +static const float RESTIR_AREA_SCALE = 4.0; + +uint packHalf2(float2 v) { + return (f32tof16(v.x) & 0xFFFFu) | ((f32tof16(v.y) & 0xFFFFu) << 16); +} + +float2 unpackHalf2(uint v) { + return float2(f16tof32(v & 0xFFFFu), f16tof32((v >> 16) & 0xFFFFu)); +} + +uint packAreaMW(float area, float M, float W) { + uint areaBits = uint(clamp(area / RESTIR_AREA_SCALE, 0.0, 1.0) * 255.0 + 0.5); + uint mBits = uint(clamp(M / RESTIR_M_CAP, 0.0, 1.0) * 255.0 + 0.5); + return areaBits | (mBits << 8) | ((f32tof16(W) & 0xFFFFu) << 16); +} + +void unpackAreaMW(uint bits, out float area, out float M, out float W) { + area = float(bits & 0xFFu) / 255.0 * RESTIR_AREA_SCALE; + M = float((bits >> 8) & 0xFFu) / 255.0 * RESTIR_M_CAP; + W = f16tof32((bits >> 16) & 0xFFFFu); +} + +// parity 0 => A is history and B is written; parity 1 => the reverse. +public PackedReservoir loadReservoir(int2 pix, uint parity) { + int2 lo = int2(pix.x * 2, pix.y); + int2 hi = int2(pix.x * 2 + 1, pix.y); + float4 a = parity == 0u ? reservoirA[lo] : reservoirB[lo]; + float4 b = parity == 0u ? reservoirA[hi] : reservoirB[hi]; + PackedReservoir p; + p.pos = a.xyz; + p.lnrm = octDecode(unpackHalf2(asuint(a.w))); + p.le = b.xyz; + // area, M and W share the last channel via a half3-style pack: area and M are small and bounded, + // W needs range but not precision. Splitting them out would have cost a third texel. + unpackAreaMW(asuint(b.w), p.area, p.M, p.W); + return p; +} + +public void storeReservoir(int2 pix, uint parity, PackedReservoir p) { + float4 lo = float4(p.pos, asfloat(packHalf2(octEncode(p.lnrm)))); + float4 hi = float4(p.le, asfloat(packAreaMW(p.area, p.M, p.W))); + if (parity == 0u) { + reservoirB[int2(pix.x * 2, pix.y)] = lo; + reservoirB[int2(pix.x * 2 + 1, pix.y)] = hi; + } else { + reservoirA[int2(pix.x * 2, pix.y)] = lo; + reservoirA[int2(pix.x * 2 + 1, pix.y)] = hi; + } +} + +public PackedReservoir toPacked(Reservoir r) { + PackedReservoir p; + p.pos = r.pos; + p.lnrm = r.lnrm; + p.le = r.le; + p.area = r.area; + p.M = r.M; + p.W = r.W; + return p; +} + +// ---- Spatial reuse ----------------------------------------------------------------------------------- +// +// Temporal reuse alone cannot help a pixel that has no usable history: the frame after a camera cut, a +// disoccluded edge, a surface that just came round a corner. Those are exactly the pixels a player is +// looking at, and they stay at single-frame noise while everything around them has converged. Spatial +// reuse borrows from screen-space neighbours, which have no such gap. +// +// Neighbours are read from the HISTORY image, not from this frame's output. Reading what the current +// dispatch is concurrently writing would be a race — reservoir writes are unordered across a dispatch, +// so a neighbour might be last frame's value, this frame's, or a torn mix, varying run to run. Sampling +// last frame's completed buffer makes this spatiotemporal reuse with a one-frame lag, which is both +// well-defined and what the lag already is for the temporal path. +// +// HONESTY ABOUT BIAS. This is the practical variant, not the unbiased one. Correct spatial ReSTIR needs +// per-neighbour MIS weights that account for each neighbour's own sampling domain; that costs a +// visibility ray per tap and roughly doubles the maths. What this does instead — re-evaluating p-hat at +// the receiving surface, rejecting geometric mismatches, and capping M — bounds the error to a slight +// over-smoothing near contact shadows rather than a visible energy shift. It is the trade almost every +// shipping ReSTIR implementation makes, but it IS a trade. + +public static const int RESTIR_MAX_SPATIAL_TAPS = 8; +static const float RESTIR_TWO_PI = 6.28318530718; + +// A disc sample around the pixel. Uniform in area rather than in radius, so taps do not bunch at the +// centre where they duplicate information the pixel already has. +public int2 spatialTapOffset(float radius, inout uint seed) { + float angle = rndf(seed) * RESTIR_TWO_PI; + float r = radius * sqrt(rndf(seed)); + return int2(int(round(cos(angle) * r)), int(round(sin(angle) * r))); +} + +// ---- Temporal combine -------------------------------------------------------------------------------- + +// Was the reprojected pixel the same surface? Both tests are needed: depth alone accepts a wall seen +// edge-on behind a silhouette, normals alone accept a parallel surface further away. +public bool temporalSurfaceMatches(float currentDepth, float historyDepth, + float3 currentNormal, float3 historyNormal) { + if (historyDepth <= 0.0 || currentDepth <= 0.0) { + return false; + } + if (abs(currentDepth - historyDepth) > currentDepth * RESTIR_DEPTH_TOLERANCE) { + return false; + } + return dot(currentNormal, historyNormal) >= RESTIR_NORMAL_TOLERANCE; +} + +// Merge a history reservoir into this frame's. `history.W` is the unbiased contribution weight from the +// frame it was built in; multiplying by the freshly evaluated p-hat and M converts it back into the +// resampling weight this reservoir's stream expects. +public Reservoir temporalCombine(Reservoir current, PackedReservoir history, + float3 hitPos, float3 n, float3 v, float3 rd, + float3 diffAlb, float3 F0, float rough, + bool twoSided, float sss, inout uint seed) { + if (!packedValid(history)) { + return current; + } + // Re-evaluate the stored sample against the CURRENT surface. This is the step that keeps reuse + // unbiased; the stored p-hat belongs to a surface that may no longer exist. + float phat; + evalSampleContrib(history.pos, history.lnrm, history.le, history.area, + hitPos, n, v, rd, diffAlb, F0, rough, twoSided, sss, phat); + if (phat <= 0.0) { + // The remembered emitter contributes nothing here — behind the surface now, or facing away. + // Its M is still evidence about how much was sampled, but the sample itself cannot survive. + current.M += min(history.M, RESTIR_M_CAP); + return current; + } + float historyM = min(history.M, RESTIR_M_CAP); + float weight = phat * history.W * historyM; + current.wSum += weight; + current.M += historyM; + if (rndf(seed) * current.wSum <= weight) { + current.pos = history.pos; + current.lnrm = history.lnrm; + current.le = history.le; + current.area = history.area; + current.phat = phat; + } + // Rebuild the contribution weight from the merged stream. Guarding on phat keeps a survivor whose + // target function collapsed from producing an infinite W and a white fireflies pixel. + current.W = current.phat > 0.0 ? current.wSum / (current.M * current.phat) : 0.0; + return current; +} diff --git a/shaders/pipelines/world/sky.rmiss.slang b/shaders/pipelines/world/sky.rmiss.slang index 112bda7a4..a888f416c 100644 --- a/shaders/pipelines/world/sky.rmiss.slang +++ b/shaders/pipelines/world/sky.rmiss.slang @@ -14,6 +14,7 @@ import world_common; import sky; import bindings; +import clouds; // Vanilla celestials atlas (sun + moon-phase sprites), bound by RtComposite. Sampled with an explicit LOD // (a miss shader has no derivatives). Keeping the real sprites means a resource pack's sun and moon still @@ -38,6 +39,10 @@ float hash13(float3 p3) { return frac((p3.x + p3.y) * p3.z); } +// How much of the Overworld starfield the End keeps. Dimmer than a clear night: the End's own sky +// colour already lifts the black floor, and full-strength stars over it read as an Overworld night. +static const float END_STAR_STRENGTH = 0.35; + // Rotate v about a unit axis by angle (Rodrigues). Used to wheel the starfield about the celestial pole. float3 rotateAxis(float3 v, float3 axis, float ang) { float c = cos(ang), s = sin(ang); @@ -151,6 +156,43 @@ void main(inout Payload payload) { // zero below the horizon, matching the NEE visibility test. } + // Non-Overworld dimensions. The atmosphere LUTs model an Earth sky: Rayleigh, Mie, ozone, a planet + // radius, a sun. None of that exists in the Nether or the End, and running it there produces a blue + // daytime sky in a place that should have neither. Vanilla classifies this itself via + // DimensionType.skybox(), which is what the mode here mirrors — so custom dimensions declaring one of + // those skyboxes get the right treatment too, without a hardcoded dimension-name list. + uint skyboxMode = uint(worldPush.dimSky.w + 0.5); + if (skyboxMode != SKYBOX_OVERWORLD) { + // Both non-Overworld skies are flat-lit from vanilla's own SKY_COLOR for the camera's position, + // so a crimson forest and a soul sand valley differ here exactly as they do in vanilla. + float3 flat709 = max(worldPush.dimSky.rgb, float3(0.0)); + float3 flatSky = bt709ToAcesCg(flat709); + if (skyboxMode == SKYBOX_END) { + // The End keeps a starfield — it is the one thing that reads as "sky" rather than "ceiling" + // there — but no sun, no moon, no horizon gradient. + flatSky += bt709ToAcesCg(stars(dir, state, float3(1.0))) * END_STAR_STRENGTH; + } + packSky(payload, flatSky); + return; + } + + // Cloud deck, composited over everything above. It sits at a few hundred blocks while the sky LUT + // models 100 km, so the deck is treated as a thin layer in front of the whole atmosphere: attenuate + // what is behind it, add what it scatters. Stars and the sun/moon discs are correctly occluded by + // this because they were accumulated into `color` above. + // + // Gated on showCelestial, which is exactly the set of rays that already pay for disc sprites: the + // primary ray and specular/dielectric continuations. A diffuse bounce's miss skips the march + // entirely and sees clear sky — see the cost note in clouds.slang. + if (showCelestial) { + CloudParams cloudParams = makeCloudParams(worldPush); + if (cloudsActive(cloudParams)) { + CloudResult deck = marchClouds(cloudParams, state, WorldRayOrigin(), dir, + worldPush.dimCloud.rgb, worldPush.dimCloud.w); + color = color * deck.transmittance + deck.scattering; + } + } + // The sky goes out in absolute scene units at full fp32 — packSky writes the payload's three surface // words as raw floats rather than halves, which is why the sun disc's ~1e5 cd/m² needs neither a // pre-exposure scale nor a clamp to stay representable. The fp16 ceiling reappears only at world.rgen's diff --git a/shaders/pipelines/world/world_common.slang b/shaders/pipelines/world/world_common.slang index bc297ab81..59e597135 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -88,8 +88,48 @@ public struct WorldPush { // near mid-grey at any scene brightness; the display pass divides it back out, so the two cancel // exactly and this cannot change the image. 1.0 disables it. public float preExposure; + // ---- Height fog. x extinction per block at the reference height (0 disables), y 1/scale-height, + // z reference world Y in REBASED coordinates (Java subtracts the terrain origin, so this stays a + // small number as the player moves), w single-scattering albedo. Integrated analytically in + // fog.slang; the in-scatter colour comes from the sky view LUT, so nothing about the fog's colour + // lives here. + public float4 fog; + // ---- Cloud deck. cloud0: x coverage 0..1 (0 disables), y extinction per block at full density, + // z deck base as REBASED world Y, w deck thickness in blocks. cloud1: xy wind domain offset in + // blocks (advanced on the CPU so it is frame-rate independent and world-stable across a rebase), + // z detail erosion 0..1, w ambient multiple-scattering fraction. + public float4 cloud0; + public float4 cloud1; + // cloud2: x ground-shadow strength (0 disables shadowing without disabling the deck), y the + // multiple-scattering floor the shadow cannot darken past, z rain wetness 0..1, + // w cloud feature size in blocks (one noise period). + public float4 cloud2; + // ---- Parallax occlusion mapping. x depth in sprite-widths (0 disables it entirely), y step-count + // scale, z ray-cone LOD at which it has faded out, w unused. + public float4 pom; + // ---- Dimension / biome environment colours, read from 26.2's EnvironmentAttributes system. + // dimSky: rgb = SKY_COLOR (linear BT.709), w = skybox mode (0 overworld, 1 end, 2 none/nether). + // dimFog: rgb = FOG_COLOR (linear BT.709), w = how strongly it tints scattering, 0 = not at all. + public float4 dimSky; + public float4 dimFog; + // dimCloud: rgb = vanilla CLOUD_COLOR (linear BT.709), w = tint strength (0 = leave the deck lit + // purely by the atmosphere). + public float4 dimCloud; + // cloud3: x cloud march quality scale (1 = reference step counts), yzw unused. + public float4 cloud3; + // fog2: x = debug mode (1 = show raw fog in-scatter as the final colour), yzw unused. + public float4 fog2; + // ---- ReSTIR. x temporal reuse strength (0 disables it and both image accesses), y frame parity + // selecting which reservoir image is history this frame, z spatial neighbour taps per pixel + // (0 = temporal only), w spatial tap radius in render pixels. + public float4 restir; }; +// Skybox modes, mirroring vanilla's DimensionType.Skybox enum. +public static const uint SKYBOX_OVERWORLD = 0u; +public static const uint SKYBOX_END = 1u; +public static const uint SKYBOX_NONE = 2u; + // 32-byte hot area-light record. Linear ACEScg radiance uses packed R11G11B10; the // grid-relative owner section uses three unsigned 10-bit coordinates; the geometric normal is // reconstructed from the half axes. diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 0088a319d..bdaec4422 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -58,7 +58,8 @@ public static void ensureRegistered() { Object[] touch = { Rt.ENABLED, Rt.Composite.SPP, Rt.Composite.MAX_BOUNCES, Rt.Terrain.ASYNC_DISPATCH_PER_PASS, Rt.Omm.ENABLED, Rt.Entities.ENABLED, Rt.Entities.GLOW_ENABLED, Rt.EntityTextures.MAX_TEXTURES, Rt.DlssRr.ENABLED, Rt.Fg.ENABLED, - Rt.Reflex.ENABLED, Rt.Exposure.MODE, Rt.Tonemap.GAMMA, Rt.FrameStats.ENABLED, + Rt.Reflex.ENABLED, Rt.Fog.DENSITY, Rt.Clouds.COVERAGE, Rt.Grade.ENABLED, Rt.Tonemap.VIEW_TRANSFORM, Rt.Pom.DEPTH, Rt.Restir.TEMPORAL, + Rt.Weather.RAIN_OVERRIDE, Rt.Lod.ENABLED, Rt.Exposure.MODE, Rt.Tonemap.GAMMA, Rt.FrameStats.ENABLED, Rt.Screenshots.EXR_ENABLED, Rt.Hdr.ENABLED, Ngx.PATH, }; } @@ -92,6 +93,13 @@ private static void writeComments() { FILE.setComment("reflex", " NVIDIA Reflex. Requires supported NVIDIA hardware and drivers.\n" + " minimum-interval-us controls frame limiting; 0 disables the limit."); + FILE.setComment("fog", + " Exponential height fog. density is extinction per block at height (0 disables it);\n" + + " scale-height is how fast it thins going up. Fog colour is taken from the sky, not set here."); + FILE.setComment("clouds", + " Volumetric cloud deck. coverage 0 disables it entirely (and costs nothing).\n" + + " altitude is the deck base in world Y; thickness is its depth in blocks.\n" + + " Clouds are lit from the sun/moon through the same atmosphere as the sky, so they have no colour setting."); FILE.setComment("lights", " Controls direct lighting from glowing blocks such as torches, glowstone, and lava.\n" + " Set ris-candidates to 0 to disable it. stats, dump, and dump-radius are debugging options."); @@ -683,6 +691,304 @@ private Fg() { * The renderer configures the swapchain latency mode, paces frames with {@code vkLatencySleepNV}, * and emits simulation, render-submit, and present latency markers. */ + /** + * Exponential height fog. Off by default: {@code density} is the extinction per block at + * {@code height}, so 0 leaves the renderer bit-identical to a build without this feature. + * Colour is not configurable on purpose — the fog is lit from the sky-view LUT, so it tracks + * time of day and sun direction on its own. + */ + public static final class Fog { + /** Extinction per block at the reference height. ~0.004 is a light haze, ~0.03 is thick. */ + public static final FloatSetting DENSITY = + clampedFloat("caustica.rt.fog.density", "fog.density", 0.0f, 0.0f, 1.0f); + /** Blocks over which density falls by 1/e going up. Large values approach uniform distance fog. */ + public static final FloatSetting SCALE_HEIGHT = + clampedFloat("caustica.rt.fog.scaleHeight", "fog.scale-height", 24.0f, 1.0f, 1024.0f); + /** World Y at which density equals DENSITY. Sea level by default. */ + public static final FloatSetting HEIGHT = + clampedFloat("caustica.rt.fog.height", "fog.height", 62.0f, -512.0f, 1024.0f); + /** + * How strongly rain thickens the fog, as a multiple of density at full rain. Rain haze is + * the single biggest visual cue that it is raining at distance, so this defaults high. + */ + public static final FloatSetting WEATHER_RESPONSE = + clampedFloat("caustica.rt.fog.weatherResponse", "fog.weather-response", 3.0f, 0.0f, 32.0f); + /** + * How strongly biome climate modulates fog. Cold and wet biomes get more haze, hot and dry + * biomes less. 0 makes fog uniform everywhere. + */ + public static final FloatSetting BIOME_RESPONSE = + clampedFloat("caustica.rt.fog.biomeResponse", "fog.biome-response", 0.6f, 0.0f, 1.0f); + /** + * Diagnostic. 1 replaces the whole image with the fog's in-scatter colour, so you can see + * directly whether that colour is black. Black screen means the sky lookup is failing; + * a coloured screen means the lookup is fine and the composition is at fault. 0 = off. + */ + public static final IntSetting DEBUG = + clampedInt("caustica.rt.fog.debug", "fog.debug", 0, 0, 1); + /** Single-scattering albedo. 1 scatters everything; lower values also absorb, darkening distance. */ + public static final FloatSetting ALBEDO = + clampedFloat("caustica.rt.fog.albedo", "fog.albedo", 0.92f, 0.0f, 1.0f); + + private Fog() { + } + } + + /** + * Volumetric cloud deck. Off by default: {@code coverage} 0 skips the march entirely, so a + * default build pays nothing. The deck is marched only on rays that may see the sun and moon + * discs (primary and specular), so it does not contribute to bounce lighting. + */ + public static final class Clouds { + /** 0 clear, 1 overcast. Raising it grows existing clouds outward rather than fading in haze. */ + public static final FloatSetting COVERAGE = + clampedFloat("caustica.rt.clouds.coverage", "clouds.coverage", 0.0f, 0.0f, 1.0f); + /** Extinction per block inside fully dense cloud. Higher reads as darker, more solid cumulus. */ + public static final FloatSetting DENSITY = + clampedFloat("caustica.rt.clouds.density", "clouds.density", 0.045f, 0.0f, 1.0f); + /** + * Follow vanilla's CLOUD_HEIGHT environment attribute instead of {@link #ALTITUDE}. On by + * default so a dimension or datapack that moves its cloud layer moves this deck with it. + */ + public static final BooleanSetting VANILLA_HEIGHT = + bool("caustica.rt.clouds.vanillaHeight", "clouds.vanilla-height", true); + /** + * How strongly vanilla's CLOUD_COLOR tints the deck. Applied hue-only, so it recolours + * without overriding the atmosphere's own time-of-day response. 0 leaves the deck lit + * purely by the sun and sky. + */ + public static final FloatSetting VANILLA_TINT = + clampedFloat("caustica.rt.clouds.vanillaTint", "clouds.vanilla-tint", 0.5f, 0.0f, 1.0f); + /** Deck base, world Y. Used when vanilla-height is off, or when vanilla reports none. */ + public static final FloatSetting ALTITUDE = + clampedFloat("caustica.rt.clouds.altitude", "clouds.altitude", 192.0f, -512.0f, 4096.0f); + /** Deck depth in blocks. Thin decks read as stratus, thick ones as cumulus. */ + public static final FloatSetting THICKNESS = + clampedFloat("caustica.rt.clouds.thickness", "clouds.thickness", 90.0f, 1.0f, 2048.0f); + /** + * Blocks per noise period — roughly the width of one cloud mass. Smaller gives more, tighter + * clouds. 220 puts several masses inside a normal render distance; the original hardcoded + * value behaved like ~830, which is why the sky looked almost empty. + */ + public static final FloatSetting FEATURE_SIZE = + clampedFloat("caustica.rt.clouds.featureSize", "clouds.feature-size", + 160.0f, 16.0f, 4096.0f); + /** + * Domain drift in blocks per second. 0 freezes the deck. Judge this against feature-size: + * drift only reads as motion when it covers a noticeable fraction of a cloud per second. + */ + public static final FloatSetting WIND_SPEED = + clampedFloat("caustica.rt.clouds.windSpeed", "clouds.wind-speed", 12.0f, 0.0f, 256.0f); + /** High-frequency edge erosion. 0 gives smooth blobs, 1 gives wispy, broken silhouettes. */ + public static final FloatSetting DETAIL = + clampedFloat("caustica.rt.clouds.detail", "clouds.detail", 0.45f, 0.0f, 1.0f); + /** + * How strongly vanilla weather drives the deck. 1 takes coverage to overcast and thickens + * and darkens the deck as rain and thunder ramp; 0 ignores weather entirely. + */ + public static final FloatSetting WEATHER_RESPONSE = + clampedFloat("caustica.rt.clouds.weatherResponse", "clouds.weather-response", 1.0f, 0.0f, 1.0f); + /** + * How strongly the deck shadows the world. 0 leaves the ground lit as if the sky were clear + * and skips the shadow march entirely; 1 is full Beer's law through the coarse deck. + */ + public static final FloatSetting SHADOW_STRENGTH = + clampedFloat("caustica.rt.clouds.shadowStrength", "clouds.shadow-strength", 1.0f, 0.0f, 4.0f); + /** + * Floor the cloud shadow cannot darken past, standing in for light that scatters through the + * deck and arrives diffusely. 0 gives physically-too-dark overcast; real overcast sits around + * 0.1-0.2 of clear-sky direct. + */ + public static final FloatSetting SHADOW_FLOOR = + clampedFloat("caustica.rt.clouds.shadowFloor", "clouds.shadow-floor", 0.15f, 0.0f, 1.0f); + /** + * How wet rain makes sky-exposed surfaces: darker and glossier while it rains. 0 disables the + * effect and its upward visibility ray. Snowy biomes are excluded automatically — snow does + * not wet a surface. + */ + public static final FloatSetting WETNESS = + clampedFloat("caustica.rt.clouds.wetness", "clouds.wetness", 1.0f, 0.0f, 1.0f); + /** + * Scales the cloud march's step counts. 1.0 is reference quality; 0.5 halves the samples + * and is close to invisible on a moving camera, because the deck is smooth and Ray + * Reconstruction treats it as low-frequency detail. This is the main FPS dial for clouds — + * reach for it before dropping coverage, which changes how the sky looks. + */ + public static final FloatSetting QUALITY = + clampedFloat("caustica.rt.clouds.quality", "clouds.quality", 1.0f, 0.1f, 1.0f); + /** Stand-in for in-cloud multiple scattering, as a fraction of zenith sky radiance. */ + public static final FloatSetting AMBIENT = + clampedFloat("caustica.rt.clouds.ambient", "clouds.ambient", 0.35f, 0.0f, 4.0f); + + private Clouds() { + } + } + + /** + * Scene-referred colour grading and output sharpening, both applied in the display pass. + * + *

The grade defaults reproduce the "Ultra Realism Tonemapper for UE5" post-process volume. + * They transfer exactly rather than approximately because UE's default working colour space is + * AP1, the same space this renderer paths in. Note the guide tuned those numbers against AgX + * Punchy; with the ACES 2.0 view transform they will land somewhere different, which is a + * reason to set {@code view-transform} to match, not a reason to change the numbers. + */ + public static final class Grade { + /** Master switch. Off leaves the display pass bit-identical to an ungraded build. */ + public static final BooleanSetting ENABLED = + bool("caustica.rt.grade.enabled", "grade.enabled", false); + public static final FloatSetting SATURATION = + clampedFloat("caustica.rt.grade.saturation", "grade.saturation", 0.75f, 0.0f, 4.0f); + public static final FloatSetting CONTRAST = + clampedFloat("caustica.rt.grade.contrast", "grade.contrast", 1.05f, 0.1f, 4.0f); + public static final FloatSetting GAIN = + clampedFloat("caustica.rt.grade.gain", "grade.gain", 1.30f, 0.0f, 8.0f); + /** Multiplies the global saturation inside the highlight region, as UE composes them. */ + public static final FloatSetting HIGHLIGHT_SATURATION = + clampedFloat("caustica.rt.grade.highlightSaturation", "grade.highlight-saturation", + 0.95f, 0.0f, 4.0f); + /** Multiplies the global gain inside the highlight region. */ + public static final FloatSetting HIGHLIGHT_GAIN = + clampedFloat("caustica.rt.grade.highlightGain", "grade.highlight-gain", + 1.60f, 0.0f, 8.0f); + /** Luma at which the highlight region starts blending in (UE's ColorCorrectionHighlightsMin). */ + public static final FloatSetting HIGHLIGHTS_MIN = + clampedFloat("caustica.rt.grade.highlightsMin", "grade.highlights-min", + 0.28f, 0.0f, 1.0f); + /** + * RCAS strength, 0 disables it and its four extra taps. Applied after the output transform + * on display code values — PQ code values on the HDR path — so a highlight receives the + * same apparent enhancement as a midtone rather than hundreds of times more overshoot. + */ + public static final FloatSetting SHARPNESS = + clampedFloat("caustica.rt.grade.sharpness", "grade.sharpness", 0.0f, 0.0f, 1.0f); + + private Grade() { + } + } + + /** + * Parallax occlusion mapping from the LabPBR height channel (_n alpha). Gives packs that author + * depth into the height map — Patrix and similar — real surface parallax instead of flat + * normal shading. Silhouettes stay flat: this shifts texture coordinates, it does not displace + * geometry. + */ + public static final class Pom { + /** + * Depth in sprite-widths. 0 disables it and its march entirely. 0.05 is subtle and safe; + * past ~0.12 the flat silhouettes start giving the illusion away at grazing angles. + */ + public static final FloatSetting DEPTH = + clampedFloat("caustica.rt.pom.depth", "pom.depth", 0.0f, 0.0f, 0.5f); + /** Scales the march's step count. Below 1 is faster and steppier on tall height fields. */ + public static final FloatSetting QUALITY = + clampedFloat("caustica.rt.pom.quality", "pom.quality", 1.0f, 0.1f, 2.0f); + /** Ray-cone LOD at which POM has faded out. Lower means it stops sooner with distance. */ + public static final FloatSetting FADE_LOD = + clampedFloat("caustica.rt.pom.fadeLod", "pom.fade-lod", 4.0f, 0.0f, 12.0f); + + private Pom() { + } + } + + /** + * ReSTIR temporal reuse for the block-emitter reservoirs. Off by default. Reuses last frame's + * chosen emitter where the surface is unchanged, so a pixel accumulates far more effective + * candidates than one frame's budget allows — the difference shows up in caves and at night, + * where the light sampling is the noise floor. + */ + /** + * Manual weather override for the renderer's fog, cloud and wetness response. + * + *

This exists because servers frequently never send rain packets — Wynncraft being the + * case in point — so {@code level.getRainLevel()} sits at zero forever and none of the storm + * visuals can be seen or tuned. Client-side weather mods solve this properly by driving + * vanilla's own rain state; this is the fallback for when no such mod is available for your + * Minecraft version. + * + *

It overrides only what the RENDERER reads. It does not make it rain: no particles, no + * sound, no gameplay effect. Vanilla's own weather is untouched. + */ + public static final class Weather { + /** Rain level 0..1, or -1 to use whatever the world reports. */ + public static final FloatSetting RAIN_OVERRIDE = + clampedFloat("caustica.rt.weather.rainOverride", "weather.rain-override", + -1.0f, -1.0f, 1.0f); + /** Thunder level 0..1, or -1 to use the world's. Layers on top of rain, as vanilla does. */ + public static final FloatSetting THUNDER_OVERRIDE = + clampedFloat("caustica.rt.weather.thunderOverride", "weather.thunder-override", + -1.0f, -1.0f, 1.0f); + + private Weather() { + } + } + + /** + * Distant terrain from Distant Horizons' database. Off by default. + * + *

DH is used as a WORLD DATABASE only — its own rendering should be disabled. Caustica reads + * LOD terrain through DH's public API, meshes it, and puts it in the acceleration structure, so + * distant terrain is traced like everything else: it casts shadows, appears in reflections and + * contributes to global illumination, which a rasterised LOD renderer cannot do. + * + *

Requires Distant Horizons installed with data for the current world. On a server that means + * something has populated its database — on Wynncraft, the WynnLODGrabber download. + */ + public static final class Lod { + /** Master switch. Off skips every DH query, mesh and BLAS build. */ + public static final BooleanSetting ENABLED = + bool("caustica.rt.lod.enabled", "lod.enabled", false); + /** + * DH detail level. Each virtual block covers 2^detail world blocks, and one LOD section + * therefore spans 16*2^detail blocks for the triangle cost of one ordinary section: + * 3 gives 128-block sections, 4 gives 256. Lower is sharper and far more expensive. + */ + public static final IntSetting DETAIL = + clampedInt("caustica.rt.lod.detail", "lod.detail", 3, 1, 6); + /** Radius in LOD sections around the player. 8 at detail 3 is roughly 1024 blocks. */ + public static final IntSetting RADIUS = + clampedInt("caustica.rt.lod.radius", "lod.radius", 8, 1, 64); + /** + * Vertical span in LOD sections, centred on sea level. Distant terrain rarely needs the + * full world height, and every extra layer is a full ring of sections. + */ + public static final IntSetting HEIGHT_SECTIONS = + clampedInt("caustica.rt.lod.heightSections", "lod.height-sections", 2, 1, 16); + /** + * LOD sections started per frame. This is the streaming throttle: each one is a DH database + * query plus a mesh plus a BLAS build, so a high value stutters while the world loads. + */ + public static final IntSetting SECTIONS_PER_FRAME = + clampedInt("caustica.rt.lod.sectionsPerFrame", "lod.sections-per-frame", 2, 1, 32); + + private Lod() { + } + } + + public static final class Restir { + /** + * Strength of temporal reuse, 0 disables it and both reservoir image accesses. 1 is full + * reuse up to the shader's M cap; lower values shorten the effective history. + */ + public static final FloatSetting TEMPORAL = + clampedFloat("caustica.rt.restir.temporal", "restir.temporal", 0.0f, 0.0f, 1.0f); + + /** + * Screen-space neighbour taps per pixel, 0 for temporal-only. Helps exactly where temporal + * reuse cannot: disoccluded edges and the frame after a camera cut, which is where the eye + * is. Capped at 8 in the shader. + */ + public static final IntSetting SPATIAL_TAPS = + intAtLeast("caustica.rt.restir.spatialTaps", "restir.spatial-taps", 3, 0); + /** Spatial tap radius in render pixels. Wider borrows more but mismatches more often. */ + public static final FloatSetting SPATIAL_RADIUS = + clampedFloat("caustica.rt.restir.spatialRadius", "restir.spatial-radius", + 8.0f, 1.0f, 64.0f); + + private Restir() { + } + } + public static final class Reflex { public static final BooleanSetting ENABLED = bool("caustica.rt.reflex", "reflex.enabled", false); public static final BooleanSetting LOW_LATENCY_BOOST = @@ -813,6 +1119,58 @@ private static String sanitizeMode(String value) { public static final class Tonemap { public static final FloatSetting GAMMA = clampedFloat("caustica.rt.tonemap.gamma", "tonemap.gamma", 1.0f, 0.1f, 5.0f); + /** + * SDR view transform: {@code aces2} (default), {@code agx-punchy}, or {@code agx-base}. + * The AgX options are baked from sobotka/AgX — the same config the "Ultra Realism + * Tonemapper for UE5" guide installs into Unreal — so this renderer and that Unreal setup + * resolve to the same image transform. + * + *

SDR ONLY. AgX has no HDR output transform; every view in that config terminates in a + * ~100 nit display encoding. When HDR output is enabled the PQ path stays ACES 2.0 whatever + * this is set to, so on an HDR display this setting changes nothing that reaches the screen. + */ + public static final StringSetting VIEW_TRANSFORM = + string("caustica.rt.tonemap.viewTransform", "tonemap.view-transform", + "aces2", Tonemap::sanitizeViewTransform); + + private static String sanitizeViewTransform(String value) { + if ("agx-punchy".equalsIgnoreCase(value)) { + return "agx-punchy"; + } + if ("agx-base".equalsIgnoreCase(value)) { + return "agx-base"; + } + return "aces2"; + } + + /** + * HDR view transform resource. {@code agx-punchy} maps to the AgX HDR bake; everything + * else, including {@code agx-base}, stays on ACES 2.0 at the requested peak. + * + *

The AgX HDR bake is a construction, not an upstream transform: AgX has no HDR output + * transform, so this is AgX's tone curve and look presented through PQ with diffuse white + * at BT.2408's 203 nits. You get the AgX image on an HDR display. You do NOT get AgX with + * HDR highlight range — its sigmoid still rolls off to its own white point. Pick ACES 2.0 + * if highlight range is what you are after. + */ + public static String hdrLutResource(int peakNits) { + if ("agx-punchy".equals(VIEW_TRANSFORM.get())) { + return "hdr_agx_punchy_rec2020.bin"; + } + return "hdr_aces2_rec2020_" + peakNits + "nit.bin"; + } + + /** Resource name under {@code /caustica/color/luts/} for the selected SDR view transform. */ + public static String sdrLutResource() { + switch (VIEW_TRANSFORM.get()) { + case "agx-punchy": + return "sdr_agx_punchy_rec709.bin"; + case "agx-base": + return "sdr_agx_base_rec709.bin"; + default: + return "sdr_aces2_rec709.bin"; + } + } private Tonemap() { } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index d65090f8c..ef7613fd9 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -20,6 +20,7 @@ import dev.comfyfluffy.caustica.rt.gen.WorldPushData.Int4; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.BiomeColors; +import net.minecraft.world.attribute.EnvironmentAttributes; import net.minecraft.client.renderer.texture.TextureAtlas; import net.minecraft.client.renderer.texture.TextureAtlasSprite; import net.minecraft.client.resources.model.ModelBakery; @@ -37,6 +38,8 @@ import org.lwjgl.system.MemoryUtil; import org.lwjgl.vulkan.KHRSynchronization2; import org.lwjgl.vulkan.VK10; +import org.lwjgl.vulkan.VkClearColorValue; +import org.lwjgl.vulkan.VkImageSubresourceRange; import org.lwjgl.vulkan.VkBufferImageCopy; import org.lwjgl.vulkan.VkCommandBuffer; import org.lwjgl.vulkan.VkDependencyInfo; @@ -69,6 +72,7 @@ import dev.comfyfluffy.caustica.rt.pipeline.RtExposure; import dev.comfyfluffy.caustica.rt.pipeline.RtPipeline; import dev.comfyfluffy.caustica.rt.pipeline.RtToneLut; +import dev.comfyfluffy.caustica.rt.terrain.RtCloudNoise; import dev.comfyfluffy.caustica.rt.terrain.RtTerrain; import java.nio.ByteBuffer; @@ -244,6 +248,24 @@ private static final class PushSlot { private RtImage gMotion; private RtImage gSpecAlbedo; private RtImage gSpecMotion; + /** + * ReSTIR temporal reservoirs. Two images, both bound for the pipeline's lifetime, swapping the + * roles of history and destination each frame via a push constant rather than by rewriting + * descriptors — see {@link RtPipeline#setReservoirImages}. Double-width: two texels per pixel. + */ + /** + * Precomputed cloud shape field. Created once and never rewritten — the wind offset moves the + * sampling domain rather than the data, so nothing here changes after upload. + */ + private RtImage cloudNoise; + private long cloudNoiseSampler; + private RtImage reservoirA; + private RtImage reservoirB; + /** Flips each frame; 0 means A is history. */ + private int reservoirParity; + /** Set whenever history becomes meaningless — first frame, resize, dimension change. */ + private boolean reservoirHistoryValid; + private Object reservoirDimension; // Display-res RT image the display mapper reads: DLSS-RR writes it (render -> display denoise+upscale), or a // linear blit of `output` fills it when RR is off/unavailable (the no-RR reference / fallback). private RtImage rrOutput; @@ -604,12 +626,20 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { debugPresentPipeline = RtDebugPresentPipeline.create(ctx); } if (sdrToneLut == null) { - sdrToneLut = RtToneLut.load(ctx, "sdr_aces2_rec709.bin"); + // SDR view transform is selectable: ACES 2.0 (the renderer's default) or either AgX + // appearance baked from sobotka/AgX. Only the SDR path branches — AgX has no HDR output + // transform, so the PQ path below stays ACES 2.0 regardless of this setting. + sdrToneLut = RtToneLut.load(ctx, CausticaConfig.Rt.Tonemap.sdrLutResource()); } // The mastering target is live, so track it each frame. int wantedHdrNits = CausticaConfig.Rt.Hdr.PEAK_NITS.value(); if (hdrToneLut == null || loadedHdrLutNits != wantedHdrNits) { - RtToneLut newHdrLut = RtToneLut.load(ctx, "hdr_aces2_rec2020_" + wantedHdrNits + "nit.bin"); + // The AgX HDR LUT has no per-peak variants: its tone curve rolls off to its own white + // point rather than to the display's, so a 4000 nit bake would be identical to a 500 + // nit one. That is the honest consequence of AgX having no HDR output transform — this + // is the AgX look in a PQ container, not AgX extended to HDR range. + String hdrLutName = CausticaConfig.Rt.Tonemap.hdrLutResource(wantedHdrNits); + RtToneLut newHdrLut = RtToneLut.load(ctx, hdrLutName); if (newHdrLut.size != sdrToneLut.size) { // display.comp's lutSize push constant is shared by both LUT samples (see // lutTexCoord()); bake_display_lut.py currently always sizes both the same, but @@ -722,6 +752,9 @@ private RtPipeline ensureWorld(RtContext ctx) { new String[]{"sky.rmiss.spv", "guide.rmiss.spv"}, "closest_hit.rchit.spv", "any_hit.rahit.spv", WorldPushConstantsData.BYTE_SIZE, bindlessTextureCapacity); + // Cloud noise is a descriptor owned by the world pipeline. Create/bind it only after the pipeline + // exists; recreating the pipeline simply rebinds the device-lifetime texture and sampler. + ensureCloudNoise(ctx); // Per-frame world data lives in this BDA ring; the pipeline pushes its address and hot fields. if (pushRing == null) { pushRing = new PushSlot[PUSH_RING]; @@ -857,6 +890,7 @@ private void bindGuideImages() { worldPipeline.setExtraStorageImage(3, gMotion.view); worldPipeline.setExtraStorageImage(4, gSpecAlbedo.view); worldPipeline.setExtraStorageImage(5, gSpecMotion.view); + worldPipeline.setReservoirImages(reservoirA.view, reservoirB.view); } private void destroyGuideImages() { @@ -884,6 +918,14 @@ private void destroyGuideImages() { gSpecMotion.destroy(); gSpecMotion = null; } + if (reservoirA != null) { + reservoirA.destroy(); + reservoirA = null; + } + if (reservoirB != null) { + reservoirB.destroy(); + reservoirB = null; + } if (rrOutput != null) { rrOutput.destroy(); rrOutput = null; @@ -968,6 +1010,31 @@ private void ensureOutput(RtContext ctx, int width, int height) { gMotion = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16_SFLOAT, "guide motion " + renderW + "x" + renderH); gSpecAlbedo = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide specular albedo " + renderW + "x" + renderH); gSpecMotion = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16_SFLOAT, "guide specular motion " + renderW + "x" + renderH); + // ReSTIR reservoirs, at RENDER resolution and double width (two texels per pixel). rgba32f + // because the stored emitter position is a rebased world coordinate: a half there quantises + // light positions into visible banding across a large room. Recreated with the guides, so a + // resolution or DLSS-quality change reallocates them and invalidates history along with it. + reservoirA = ctx.createStorageImage(renderW * 2, renderH, VK10.VK_FORMAT_R32G32B32A32_SFLOAT, + "ReSTIR reservoir A " + renderW + "x" + renderH); + reservoirB = ctx.createStorageImage(renderW * 2, renderH, VK10.VK_FORMAT_R32G32B32A32_SFLOAT, + "ReSTIR reservoir B " + renderW + "x" + renderH); + reservoirHistoryValid = false; + reservoirParity = 0; + // Zero both images at creation. The history-valid flag guards the frame AFTER a reallocation, + // but it is a whole-frame switch and reservoir writes are per-pixel: a pixel the raygen never + // reaches — sky, or any pixel whose primary ray found no emitter-lit surface — is never written, + // so once the flag flips on, that pixel's neighbours read whatever the allocator left in memory. + // packedValid() rejects M<=0, and zeroed memory gives exactly that; undefined memory does not. + ctx.submitSync(cmd -> { + try (MemoryStack clearStack = MemoryStack.stackPush()) { + VkClearColorValue zero = VkClearColorValue.calloc(clearStack); + VkImageSubresourceRange.Buffer range = VkImageSubresourceRange.calloc(1, clearStack); + range.get(0).aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .baseMipLevel(0).levelCount(1).baseArrayLayer(0).layerCount(1); + VK10.vkCmdClearColorImage(cmd, reservoirA.image, VK10.VK_IMAGE_LAYOUT_GENERAL, zero, range); + VK10.vkCmdClearColorImage(cmd, reservoirB.image, VK10.VK_IMAGE_LAYOUT_GENERAL, zero, range); + } + }); // Display-res RT image the display mapper reads. Always present (DLSS-RR target, or blit-upscale fallback). rrOutput = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "DLSS-RR output " + width + "x" + height); exposure.ensureResources(ctx); @@ -1121,6 +1188,182 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo // resolved slot rides along with the uploadPending() call right below. BreakEntry[] breaking = breakingEntries(terrain); SkyPush sky = skyPush(); + // Height fog. The reference plane is authored in world Y but the shader works in rebased + // coordinates, so the terrain origin is subtracted here rather than pushing the origin into + // the shader: it keeps the value small enough for fp32 no matter how far the player walks. + // falloff is 1/scale-height; the clamp only guards a hand-edited config, since the setting + // itself is already bounded away from zero. + // Weather and biome climate. Both are read once per frame from the camera's position: fog and + // a cloud deck are whole-sky properties, so sampling them per-pixel would buy nothing but a + // seam where two biomes meet. + // + // Vanilla interpolates rain and thunder over ~½ second, and getRainLevel already returns the + // smoothed value, so weather ramps in and out without any easing of ours. Thunder is layered + // on top of rain rather than replacing it — vanilla raises both during a storm. + float rainLevel = 0f; + float thunderLevel = 0f; + // Biome climate stands in for the per-biome sky colours that 26.2 no longer carries: + // BiomeSpecialEffects was reduced to water and foliage tints, so temperature and whether the + // biome has precipitation are the only atmosphere-relevant signals vanilla still exposes. + // Cold and wet reads as hazy, hot and dry as clear — the mapping a desert-vs-taiga eye test + // would produce anyway. + float biomeHaze = 1f; + float wetness = 0f; + if (level != null) { + rainLevel = Mth.clamp(level.getRainLevel(1.0f), 0f, 1f); + thunderLevel = Mth.clamp(level.getThunderLevel(1.0f), 0f, 1f); + // A negative override means "use the world". Applied after reading rather than instead + // of it, so turning the override off returns to live weather without a reload. + float rainOverride = CausticaConfig.Rt.Weather.RAIN_OVERRIDE.value(); + if (rainOverride >= 0f) { + rainLevel = rainOverride; + } + float thunderOverride = CausticaConfig.Rt.Weather.THUNDER_OVERRIDE.value(); + if (thunderOverride >= 0f) { + thunderLevel = thunderOverride; + } + var biome = level.getBiome(cameraBlockPos).value(); + // getBaseTemperature is roughly 0 (snowy) to 2 (desert/nether) in vanilla data. + float temperature = Mth.clamp(biome.getBaseTemperature(), 0f, 2f); + float climate = (1f - temperature * 0.5f) * (biome.hasPrecipitation() ? 1f : 0.35f); + float biomeResponse = CausticaConfig.Rt.Fog.BIOME_RESPONSE.value(); + biomeHaze = Mth.lerp(biomeResponse, 1f, 0.4f + climate * 1.2f); + // Wetness needs actual liquid water falling. A biome with no precipitation (desert) gets + // nothing, and one cold enough for snow gets nothing either — snow accumulates, it does + // not soak in, and a glossy snowfield would be plainly wrong. The 0.15 cutoff is where + // vanilla's own precipitation type flips. + boolean rainsHere = biome.hasPrecipitation() && biome.getBaseTemperature() > 0.15f; + wetness = rainsHere ? rainLevel * CausticaConfig.Rt.Clouds.WETNESS.value() : 0f; + } + // Rain multiplies fog density rather than adding to it, so a configured density of 0 stays + // 0 in a downpour. Weather driving a feature the user switched off would be a surprise. + float fogWeather = 1f + rainLevel * CausticaConfig.Rt.Fog.WEATHER_RESPONSE.value(); + float fogScaleHeight = Math.max(CausticaConfig.Rt.Fog.SCALE_HEIGHT.value(), 1.0e-2f); + Float4 fog = new Float4( + CausticaConfig.Rt.Fog.DENSITY.value() * fogWeather * biomeHaze, + 1.0f / fogScaleHeight, + CausticaConfig.Rt.Fog.HEIGHT.value() - terrain.blockY, + CausticaConfig.Rt.Fog.ALBEDO.value()); + + // Cloud deck. The wind offset is accumulated on the CPU from wall time rather than derived + // in the shader from frameIndex, so the deck drifts at a fixed blocks-per-second regardless + // of frame rate. Folding the terrain origin (mod 4096, same trick as the water wave anchor) + // into the offset keeps the noise domain pinned to the world across a terrain rebase — without + // it the whole sky would visibly slide sideways every time the player crossed a rebase + // boundary. The mask keeps the value small enough that fp32 still resolves the detail octave. + float cloudWind = CausticaConfig.Rt.Clouds.WIND_SPEED.value() * waterWaveTime; + // Storm response. Coverage closes the remaining gap toward overcast rather than adding a + // fixed amount, so a sky that is already at 0.8 does not overshoot past 1 and clip flat. + // Density rises and ambient falls together: that pairing — thicker and less sky bouncing + // around inside — is what makes a storm deck read as heavy rather than merely large. + float cloudWeather = CausticaConfig.Rt.Clouds.WEATHER_RESPONSE.value() + * Math.min(rainLevel + thunderLevel * 0.5f, 1f); + // Cloud colour and height come from the same attribute system. CLOUD_HEIGHT is a world Y, + // so it is rebased alongside everything else below. + float cloudR = 1f, cloudG = 1f, cloudB = 1f; + float vanillaCloudHeight = Float.NaN; + if (level != null) { + var reader = level.environmentAttributes(); + int cloudColor = reader.getValue(EnvironmentAttributes.CLOUD_COLOR, cameraBlockPos); + cloudR = srgbToLinear(((cloudColor >> 16) & 0xFF) / 255f); + cloudG = srgbToLinear(((cloudColor >> 8) & 0xFF) / 255f); + cloudB = srgbToLinear((cloudColor & 0xFF) / 255f); + vanillaCloudHeight = reader.getValue(EnvironmentAttributes.CLOUD_HEIGHT, cameraBlockPos); + } + Float4 dimCloud = new Float4(cloudR, cloudG, cloudB, + CausticaConfig.Rt.Clouds.VANILLA_TINT.value()); + Float4 cloud3 = new Float4(CausticaConfig.Rt.Clouds.QUALITY.value(), 0f, 0f, 0f); + Float4 fog2 = new Float4(CausticaConfig.Rt.Fog.DEBUG.value(), 0f, 0f, 0f); + // Vanilla's cloud height wins when enabled and actually reported — a dimension with no cloud + // layer leaves the attribute absent, in which case the configured altitude is the only + // sensible answer rather than dropping the deck to zero. + float cloudDeckBase = CausticaConfig.Rt.Clouds.VANILLA_HEIGHT.value() + && !Float.isNaN(vanillaCloudHeight) + ? vanillaCloudHeight + : CausticaConfig.Rt.Clouds.ALTITUDE.value(); + float cloudCoverage = CausticaConfig.Rt.Clouds.COVERAGE.value(); + cloudCoverage += (1f - cloudCoverage) * cloudWeather * 0.85f; + Float4 cloud0 = new Float4( + cloudCoverage, + CausticaConfig.Rt.Clouds.DENSITY.value() * (1f + cloudWeather * 1.5f), + cloudDeckBase - terrain.blockY, + CausticaConfig.Rt.Clouds.THICKNESS.value()); + Float4 cloud1 = new Float4( + (terrain.blockX & WATER_ANCHOR_MASK) + cloudWind * 0.8f, + (terrain.blockZ & WATER_ANCHOR_MASK) + cloudWind * 0.6f, + CausticaConfig.Rt.Clouds.DETAIL.value(), + CausticaConfig.Rt.Clouds.AMBIENT.value() * (1f - cloudWeather * 0.55f)); + // Ground shadowing. The floor is lowered as the storm builds: a heavier deck really does pass + // less diffuse light, and holding the clear-sky floor through a thunderstorm is what makes an + // overcast world read as merely dimmed rather than actually overcast. + Float4 cloud2 = new Float4( + CausticaConfig.Rt.Clouds.SHADOW_STRENGTH.value(), + CausticaConfig.Rt.Clouds.SHADOW_FLOOR.value() * (1f - cloudWeather * 0.5f), + wetness, + CausticaConfig.Rt.Clouds.FEATURE_SIZE.value()); + // Vanilla environment colours. 26.2 moved per-biome and per-dimension colour out of + // BiomeSpecialEffects into the EnvironmentAttributes system, which samples and interpolates + // across biome boundaries itself — so this is vanilla's own blended value at the camera, + // not a nearest-biome lookup that would pop at a border. + // + // Sampled once per frame at the camera. Sky and fog colour are whole-view properties; a + // per-pixel lookup would buy nothing and cost a chunk-lookup per ray. + int skyboxMode = 0; + float skyR = 0f, skyG = 0f, skyB = 0f; + float fogR = 0f, fogG = 0f, fogB = 0f; + float fogTintStrength = 0f; + if (level != null) { + var reader = level.environmentAttributes(); + int skyColor = reader.getValue(EnvironmentAttributes.SKY_COLOR, cameraBlockPos); + int fogColor = reader.getValue(EnvironmentAttributes.FOG_COLOR, cameraBlockPos); + skyR = srgbToLinear(((skyColor >> 16) & 0xFF) / 255f); + skyG = srgbToLinear(((skyColor >> 8) & 0xFF) / 255f); + skyB = srgbToLinear((skyColor & 0xFF) / 255f); + fogR = srgbToLinear(((fogColor >> 16) & 0xFF) / 255f); + fogG = srgbToLinear(((fogColor >> 8) & 0xFF) / 255f); + fogB = srgbToLinear((fogColor & 0xFF) / 255f); + skyboxMode = switch (level.dimensionType().skybox()) { + case OVERWORLD -> 0; + case END -> 1; + case NONE -> 2; + }; + // In the Overworld the physical atmosphere is the truth and vanilla's fog colour is a + // light artistic tint on top of it. Elsewhere there is no atmosphere to defer to, so + // vanilla's colour becomes the whole answer. + fogTintStrength = skyboxMode == 0 + ? CausticaConfig.Rt.Fog.BIOME_RESPONSE.value() * 0.5f + : 1.0f; + } + Float4 dimSky = new Float4(skyR, skyG, skyB, skyboxMode); + Float4 dimFog = new Float4(fogR, fogG, fogB, fogTintStrength); + // ReSTIR temporal reuse. History is only meaningful if it describes this world in this + // rebased space: a dimension change reuses the same images with positions from a different + // world entirely, which would light the Nether with the Overworld's torches until the + // reservoirs churned over. A resize already clears the flag when the images are recreated. + Object currentDimension = level == null ? null : level.dimension(); + if (!java.util.Objects.equals(currentDimension, reservoirDimension)) { + reservoirDimension = currentDimension; + reservoirHistoryValid = false; + } + reservoirParity ^= 1; + float restirStrength = CausticaConfig.Rt.Restir.TEMPORAL.value(); + Float4 restir = new Float4( + reservoirHistoryValid ? restirStrength : 0f, + reservoirParity, + CausticaConfig.Rt.Restir.SPATIAL_TAPS.value(), + CausticaConfig.Rt.Restir.SPATIAL_RADIUS.value()); + // Valid from the frame after the first one that wrote anything: the destination image this + // frame becomes history next frame. + if (restirStrength > 0f) { + reservoirHistoryValid = true; + } else { + reservoirHistoryValid = false; + } + Float4 pom = new Float4( + CausticaConfig.Rt.Pom.DEPTH.value(), + CausticaConfig.Rt.Pom.QUALITY.value(), + CausticaConfig.Rt.Pom.FADE_LOD.value(), + 0f); new WorldPushData( frameInvViewProj, new Float3((float) (camX - terrain.blockX), (float) (camY - terrain.blockY), @@ -1156,7 +1399,18 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo CausticaConfig.Rt.Lights.RIS_CANDIDATES.value(), // Must be the SAME value the exposure resolve divides out this frame (it reads it // from the same RtExposure accessor), or the two stop cancelling. - exposure.preExposure() + exposure.preExposure(), + fog, + cloud0, + cloud1, + cloud2, + pom, + dimSky, + dimFog, + dimCloud, + cloud3, + fog2, + restir ).write(push); pushBuf.flush(0L, WORLD_PUSH_SIZE); // Upload any entity textures registered this frame into the bindless set before the trace. @@ -1262,7 +1516,15 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.displayMap")) { displayPipeline.dispatch(cmd, displayW, displayH, CausticaConfig.Rt.Hdr.enabled(), sdrToneLut.size, CausticaConfig.Rt.Tonemap.GAMMA.value(), loadedHdrLutNits, - true, lookLut.size, LOOK.bloom().strength() / bloomLevels.length); + true, lookLut.size, LOOK.bloom().strength() / bloomLevels.length, + CausticaConfig.Rt.Grade.ENABLED.value(), + CausticaConfig.Rt.Grade.SATURATION.value(), + CausticaConfig.Rt.Grade.CONTRAST.value(), + CausticaConfig.Rt.Grade.GAIN.value(), + CausticaConfig.Rt.Grade.HIGHLIGHT_SATURATION.value(), + CausticaConfig.Rt.Grade.HIGHLIGHT_GAIN.value(), + CausticaConfig.Rt.Grade.HIGHLIGHTS_MIN.value(), + CausticaConfig.Rt.Grade.SHARPNESS.value()); } hdrWrittenThisFrame = CausticaConfig.Rt.Hdr.enabled(); VulkanCommandEncoder.memoryBarrier(cmd, stack); // display output visible to debug composite @@ -1441,6 +1703,14 @@ private void refreshCelestialUvCache(int moonPhase) { celestialUvMoonPhase = moonPhase; } + /** + * sRGB EOTF for a single 0..1 channel. The environment-attribute colours arrive as packed sRGB + * bytes; the shader tints in linear, so they cross the transfer function exactly once, here. + */ + private static float srgbToLinear(float code) { + return code <= 0.04045f ? code / 12.92f : (float) Math.pow((code + 0.055f) / 1.055f, 2.4); + } + private static Float4 linearAcesCgFromSrgb(double r, double g, double b, float w) { return linearAcesCgFromBt709( srgbToLinear(r), srgbToLinear(g), srgbToLinear(b), w); @@ -1556,6 +1826,17 @@ public void destroy() { worldPipeline.destroy(); worldPipeline = null; } + if (cloudNoise != null) { + cloudNoise.destroy(); + cloudNoise = null; + } + if (cloudNoiseSampler != 0L) { + RtContext ctx = RtContext.currentOrNull(); + if (ctx != null) { + VK10.vkDestroySampler(ctx.vk(), cloudNoiseSampler, null); + } + cloudNoiseSampler = 0L; + } bindlessTextureCapacity = 0; materialBindingsReady = false; materialEpochTraceGate = false; @@ -1577,6 +1858,41 @@ public void destroy() { } } + /** + * Creates and binds the cloud shape texture, once. LINEAR filtering with REPEAT addressing in all + * three axes: the field is generated to tile, so the hardware's wrap does the domain folding and + * the shader needs no fract() on its coordinate. + */ + private void ensureCloudNoise(RtContext ctx) { + if (cloudNoise != null) { + worldPipeline.setCloudNoise(cloudNoise.view, cloudNoiseSampler); + return; + } + java.nio.ByteBuffer data = RtCloudNoise.generate(); + try { + cloudNoise = ctx.createSampled3dImage(RtCloudNoise.SIZE, RtCloudNoise.SIZE, RtCloudNoise.SIZE, + VK10.VK_FORMAT_R8G8_UNORM, RtCloudNoise.BYTES_PER_TEXEL, data, "cloud noise 128^3"); + } finally { + org.lwjgl.system.MemoryUtil.memFree(data); + } + try (MemoryStack stack = MemoryStack.stackPush()) { + VkSamplerCreateInfo sci = VkSamplerCreateInfo.calloc(stack).sType$Default() + .magFilter(VK10.VK_FILTER_LINEAR).minFilter(VK10.VK_FILTER_LINEAR) + .mipmapMode(VK10.VK_SAMPLER_MIPMAP_MODE_NEAREST) + .addressModeU(VK10.VK_SAMPLER_ADDRESS_MODE_REPEAT) + .addressModeV(VK10.VK_SAMPLER_ADDRESS_MODE_REPEAT) + .addressModeW(VK10.VK_SAMPLER_ADDRESS_MODE_REPEAT) + .minLod(0f).maxLod(0f); + LongBuffer p = stack.mallocLong(1); + if (VK10.vkCreateSampler(ctx.vk(), sci, null, p) != VK10.VK_SUCCESS) { + throw new IllegalStateException("vkCreateSampler(cloud noise) failed"); + } + cloudNoiseSampler = p.get(0); + RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_SAMPLER, cloudNoiseSampler, "cloud noise sampler"); + } + worldPipeline.setCloudNoise(cloudNoise.view, cloudNoiseSampler); + } + private long atlasSampler(RtContext ctx) { if (atlasSampler == 0L) { try (MemoryStack stack = MemoryStack.stackPush()) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java index 079f92240..cf8f427f5 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java @@ -24,6 +24,9 @@ import org.lwjgl.vulkan.VkDevice; import org.lwjgl.vulkan.VkFenceCreateInfo; import org.lwjgl.vulkan.VkFormatProperties; +import java.nio.ByteBuffer; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.VkBufferImageCopy; import org.lwjgl.vulkan.VkImageCreateInfo; import org.lwjgl.vulkan.VkImageFormatProperties; import org.lwjgl.vulkan.VkImageMemoryBarrier; @@ -393,6 +396,96 @@ public RtImage createStorageImage(int width, int height, int format, String labe return new RtImage(vma, vk, image, allocation, view, width, height); } + /** + * Creates a tiling 3D texture from host data and leaves it in SHADER_READ_ONLY_OPTIMAL. + * + *

Written once at creation and never again, which is why it takes the simple synchronous path: + * no ping-pong, no per-frame descriptor churn, nothing for a frame in flight to race against. That + * is the whole reason this is a much smaller risk than a storage image the shaders write to. + * + * @param data tightly packed texels, depth-major, sized width*height*depth*bytesPerTexel + */ + public RtImage createSampled3dImage(int width, int height, int depth, int format, + int bytesPerTexel, ByteBuffer data, String label) { + long imageBytes = (long) width * height * depth * bytesPerTexel; + int usage = VK10.VK_IMAGE_USAGE_SAMPLED_BIT | VK10.VK_IMAGE_USAGE_TRANSFER_DST_BIT; + long image; + long allocation; + long view; + try (MemoryStack stack = MemoryStack.stackPush()) { + VkImageCreateInfo ici = VkImageCreateInfo.calloc(stack).sType$Default() + .imageType(VK10.VK_IMAGE_TYPE_3D).format(format) + .mipLevels(1).arrayLayers(1).samples(VK10.VK_SAMPLE_COUNT_1_BIT) + .tiling(VK10.VK_IMAGE_TILING_OPTIMAL).usage(usage) + .sharingMode(VK10.VK_SHARING_MODE_EXCLUSIVE) + .initialLayout(VK10.VK_IMAGE_LAYOUT_UNDEFINED); + ici.extent().set(width, height, depth); + VmaAllocationCreateInfo iaci = VmaAllocationCreateInfo.calloc(stack) + .usage(Vma.VMA_MEMORY_USAGE_AUTO); + LongBuffer pImage = stack.mallocLong(1); + PointerBuffer pAlloc = stack.mallocPointer(1); + check(Vma.vmaCreateImage(vma, ici, iaci, pImage, pAlloc, null), "vmaCreateImage(3D)"); + image = pImage.get(0); + allocation = pAlloc.get(0); + RtDebugLabels.nameImage(this, image, label); + + VkImageViewCreateInfo vci = VkImageViewCreateInfo.calloc(stack).sType$Default() + .image(image).viewType(VK10.VK_IMAGE_VIEW_TYPE_3D).format(format); + vci.subresourceRange().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT).levelCount(1).layerCount(1); + LongBuffer pView = stack.mallocLong(1); + check(VK10.vkCreateImageView(vk, vci, null, pView), "vkCreateImageView(3D)"); + view = pView.get(0); + RtDebugLabels.nameImageView(this, view, label + " view"); + } + + RtBuffer staging = createUploadBuffer(imageBytes, label + " upload"); + MemoryUtil.memCopy(MemoryUtil.memAddress(data), staging.mapped, imageBytes); + staging.flush(); + long imageFinal = image; + submitSync(cmd -> { + try (MemoryStack stack = MemoryStack.stackPush(); + RtDebugLabels.Scope ignored = RtDebugLabels.scope(this, cmd, "upload " + label)) { + VkImageMemoryBarrier.Buffer toDst = VkImageMemoryBarrier.calloc(1, stack); + toDst.get(0).sType$Default() + .oldLayout(VK10.VK_IMAGE_LAYOUT_UNDEFINED) + .newLayout(VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) + .srcAccessMask(0).dstAccessMask(VK10.VK_ACCESS_TRANSFER_WRITE_BIT) + .srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .image(imageFinal); + toDst.get(0).subresourceRange().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .levelCount(1).layerCount(1); + VK10.vkCmdPipelineBarrier(cmd, VK10.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK10.VK_PIPELINE_STAGE_TRANSFER_BIT, 0, null, null, toDst); + + VkBufferImageCopy.Buffer region = VkBufferImageCopy.calloc(1, stack); + region.get(0).bufferOffset(0).bufferRowLength(0).bufferImageHeight(0); + region.get(0).imageSubresource().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .mipLevel(0).baseArrayLayer(0).layerCount(1); + region.get(0).imageOffset().set(0, 0, 0); + region.get(0).imageExtent().set(width, height, depth); + VK10.vkCmdCopyBufferToImage(cmd, staging.handle, imageFinal, + VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, region); + + VkImageMemoryBarrier.Buffer toRead = VkImageMemoryBarrier.calloc(1, stack); + toRead.get(0).sType$Default() + .oldLayout(VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL) + .newLayout(VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) + .srcAccessMask(VK10.VK_ACCESS_TRANSFER_WRITE_BIT) + .dstAccessMask(VK10.VK_ACCESS_SHADER_READ_BIT) + .srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED) + .image(imageFinal); + toRead.get(0).subresourceRange().aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT) + .levelCount(1).layerCount(1); + VK10.vkCmdPipelineBarrier(cmd, VK10.VK_PIPELINE_STAGE_TRANSFER_BIT, + VK10.VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, null, null, toRead); + } + }); + staging.destroy(); + return new RtImage(vma, vk, image, allocation, view, width, height); + } + private void requireStorageImageSupport(int width, int height, int format, int usage, String label) { try (MemoryStack stack = MemoryStack.stackPush()) { VkFormatProperties formatProperties = VkFormatProperties.calloc(stack); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java index 7722b8fd8..8f2996bb3 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java @@ -38,6 +38,11 @@ public final class RtFrameStats { "terrain.drainCompletion", "terrain.snapshotDispatch", "terrain.publish", + // Used at RtTerrain:521 but never registered here, so enabling frame stats crashed + // the client on the first tick that published a light grid. Upstream bug, not a + // LOD one — the stage name table and the call sites had drifted apart. + "terrain.lightGridPublish", + "terrain.lodDispatch", "entity.capture", "entity.capture.extract", "entity.capture.submit", diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java index 76ea79643..93b3ec2e7 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java @@ -204,13 +204,17 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage */ public void dispatch(VkCommandBuffer cmd, int width, int height, boolean hdrEnabled, int lutSize, float gamma, float hdrPeakNits, boolean lookEnabled, int lookLutSize, - float bloomStrength) { + float bloomStrength, boolean gradeEnabled, float saturation, float contrast, + float gain, float highlightSaturation, float highlightGain, + float highlightsMin, float sharpness) { try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "display compute")) { VK10.vkCmdBindPipeline(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, pipeline); VK10.vkCmdBindDescriptorSets(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, pipelineLayout, 0, stack.longs(descriptorSet), null); ByteBuffer push = stack.malloc(DisplayPushData.BYTE_SIZE); new DisplayPushData(hdrEnabled ? 1 : 0, (float) lutSize, gamma, hdrPeakNits, - lookEnabled ? 1 : 0, (float) lookLutSize, bloomStrength).write(push); + lookEnabled ? 1 : 0, (float) lookLutSize, bloomStrength, + gradeEnabled ? 1 : 0, saturation, contrast, gain, + highlightSaturation, highlightGain, highlightsMin, sharpness).write(push); VK10.vkCmdPushConstants(cmd, pipelineLayout, VK10.VK_SHADER_STAGE_COMPUTE_BIT, 0, push); VK10.vkCmdDispatch(cmd, (width + 15) / 16, (height + 15) / 16, 1); } 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 9694ffe64..f73571e1f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java @@ -158,6 +158,19 @@ public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, St binds.get(binding).binding(binding).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK_SHADER_STAGE_RAYGEN_BIT_KHR); } + // ReSTIR reservoirs. Raygen only — the reservoirs are built and consumed there; no hit or + // miss stage touches them, and narrowing the stage flags keeps the driver from having to + // make them visible to shader stages that never read them. + for (int binding = WORLD_RESERVOIR_A; binding <= WORLD_RESERVOIR_B; binding++) { + binds.get(binding).binding(binding).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(1).stageFlags(VK_SHADER_STAGE_RAYGEN_BIT_KHR); + } + // Cloud noise: a sampled 3D texture, read by the miss shader (view march) and the raygen + // (shadow march), so both stages need visibility. + binds.get(WORLD_CLOUD_NOISE).binding(WORLD_CLOUD_NOISE) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1) + .stageFlags(VK_SHADER_STAGE_RAYGEN_BIT_KHR | VK_SHADER_STAGE_MISS_BIT_KHR); binds.get(WORLD_CELESTIALS).binding(WORLD_CELESTIALS) .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) .descriptorCount(1).stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR); @@ -407,6 +420,31 @@ public void setStorageImage(long imageView) { } /** Write one DLSS-RR guide image into its canonical world binding across every ring slot. */ + /** + * Binds the two ReSTIR reservoir images. Written once at creation and again on resize, never + * per-frame: the descriptor sets are ring-buffered across frames in flight, so rewriting them each + * frame would mutate a set an earlier frame is still reading. The two images swap roles through a + * push constant instead — see {@code worldPush.restir.y}. + */ + public void setReservoirImages(long viewA, long viewB) { + try (MemoryStack stack = MemoryStack.stackPush()) { + VkDescriptorImageInfo.Buffer infoA = VkDescriptorImageInfo.calloc(1, stack); + infoA.get(0).imageView(viewA).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkDescriptorImageInfo.Buffer infoB = VkDescriptorImageInfo.calloc(1, stack); + infoB.get(0).imageView(viewB).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkWriteDescriptorSet.Buffer write = VkWriteDescriptorSet.calloc(RING * 2, stack); + for (int i = 0; i < RING; i++) { + write.get(i * 2).sType$Default().dstSet(descriptorSets[i]).dstBinding(WORLD_RESERVOIR_A) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .pImageInfo(infoA); + write.get(i * 2 + 1).sType$Default().dstSet(descriptorSets[i]).dstBinding(WORLD_RESERVOIR_B) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .pImageInfo(infoB); + } + VK10.vkUpdateDescriptorSets(ctx.vk(), write, null); + } + } + public void setExtraStorageImage(int slot, long imageView) { if (slot < 0 || slot >= WORLD_GUIDE_COUNT) { throw new IllegalArgumentException("Guide slot out of range: " + slot); @@ -442,6 +480,14 @@ public void setSkyAtlas(long imageView, long sampler) { writeAtlasBinding(WORLD_CELESTIALS, imageView, sampler); } + /** + * Bind the precomputed cloud shape field. Written once when the texture is created; it is + * immutable for the session, so there is no per-frame descriptor traffic here. + */ + public void setCloudNoise(long imageView, long sampler) { + writeAtlasBinding(WORLD_CLOUD_NOISE, imageView, sampler); + } + public boolean hasSkyAtlas() { return true; } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtCloudNoise.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtCloudNoise.java new file mode 100644 index 000000000..e2e9392d6 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtCloudNoise.java @@ -0,0 +1,134 @@ +package dev.comfyfluffy.caustica.rt.terrain; + +import java.nio.ByteBuffer; + +import org.lwjgl.system.MemoryUtil; + +/** + * Bakes the cloud shape field into a tiling 3D texture. + * + *

Why

+ * {@code clouds.slang} evaluated its density function roughly 64 view samples x 6 light samples per + * pixel, and every evaluation ran three or four octaves of hashed value noise — eight lattice hashes, + * a quintic fade and seven lerps per octave. That is hundreds of ALU operations per sample for a field + * that never changes. Precomputing it turns the whole thing into one hardware-filtered fetch. + * + *

The noise must tile, and that constrains the generator

+ * The shader samples this with a repeating address mode over a domain scaled by cloud feature size, so + * the field has to be seamless across every face. That rules out hashing world coordinates directly: + * the lattice has to wrap modulo the texture size at every octave. {@link #hash} therefore takes + * already-wrapped integer coordinates, and each octave wraps at its own frequency. + * + *

The consequence is that the sky repeats every {@code feature-size} blocks. At the default 140 + * that is a 140-block period, which is invisible from the ground because the deck is only ~90 blocks + * thick and the horizon cuts it off long before a repeat becomes legible. It would be visible from + * far above the deck looking down, which is not a view Minecraft offers. + * + *

Channel layout

+ * R holds the 4-octave base shape, G the 3-octave erosion detail — the same two fields the analytic + * version computed, at the same relative frequencies, so the shader's coverage remap and erosion maths + * are unchanged. Two 8-bit channels: the field feeds a smoothstep remap that quantisation cannot + * survive being visible through, and 8 bits keeps the whole thing at 4 MB. + */ +public final class RtCloudNoise { + /** 128^3 x RG8 = 4 MB. Doubling to 256 costs 32 MB for detail the coverage remap discards. */ + public static final int SIZE = 128; + public static final int BYTES_PER_TEXEL = 2; + + private RtCloudNoise() { + } + + /** + * @return a direct buffer the caller must free with {@link MemoryUtil#memFree}, holding + * SIZE^3 RG8 texels in depth-major order + */ + public static ByteBuffer generate() { + ByteBuffer out = MemoryUtil.memAlloc(SIZE * SIZE * SIZE * BYTES_PER_TEXEL); + for (int z = 0; z < SIZE; z++) { + for (int y = 0; y < SIZE; y++) { + for (int x = 0; x < SIZE; x++) { + float u = (float) x / SIZE; + float v = (float) y / SIZE; + float w = (float) z / SIZE; + // Base starts at 4 periods across the texture rather than 1: a single period per + // axis gives one blob per tile, which reads as a repeating pattern the moment two + // tiles are visible at once. + float base = fbm(u, v, w, 4, 4); + float detail = fbm(u, v, w, 16, 3); + int index = ((z * SIZE + y) * SIZE + x) * BYTES_PER_TEXEL; + out.put(index, (byte) Math.round(clamp01(base) * 255.0f)); + out.put(index + 1, (byte) Math.round(clamp01(detail) * 255.0f)); + } + } + } + return out; + } + + /** Octave sum. Lacunarity 2 is required here — a non-integer ratio would break tiling. */ + private static float fbm(float u, float v, float w, int baseFrequency, int octaves) { + float sum = 0f; + float amplitude = 0.5f; + float normalization = 0f; + int frequency = baseFrequency; + for (int i = 0; i < octaves; i++) { + sum += amplitude * periodicValueNoise(u, v, w, frequency); + normalization += amplitude; + frequency *= 2; + amplitude *= 0.5f; + } + return sum / Math.max(normalization, 1e-6f); + } + + /** Value noise on a lattice that wraps at {@code frequency}, so the result is seamless. */ + private static float periodicValueNoise(float u, float v, float w, int frequency) { + float x = u * frequency; + float y = v * frequency; + float z = w * frequency; + int xi = (int) Math.floor(x); + int yi = (int) Math.floor(y); + int zi = (int) Math.floor(z); + float xf = x - xi; + float yf = y - yi; + float zf = z - zi; + // Quintic fade: C2 continuous, so summed octaves show no lattice creases where they align. + float fx = fade(xf); + float fy = fade(yf); + float fz = fade(zf); + int x0 = Math.floorMod(xi, frequency); + int y0 = Math.floorMod(yi, frequency); + int z0 = Math.floorMod(zi, frequency); + int x1 = Math.floorMod(xi + 1, frequency); + int y1 = Math.floorMod(yi + 1, frequency); + int z1 = Math.floorMod(zi + 1, frequency); + float n000 = hash(x0, y0, z0); + float n100 = hash(x1, y0, z0); + float n010 = hash(x0, y1, z0); + float n110 = hash(x1, y1, z0); + float n001 = hash(x0, y0, z1); + float n101 = hash(x1, y0, z1); + float n011 = hash(x0, y1, z1); + float n111 = hash(x1, y1, z1); + return lerp(lerp(lerp(n000, n100, fx), lerp(n010, n110, fx), fy), + lerp(lerp(n001, n101, fx), lerp(n011, n111, fx), fy), fz); + } + + private static float fade(float t) { + return t * t * t * (t * (t * 6f - 15f) + 10f); + } + + private static float lerp(float a, float b, float t) { + return a + (b - a) * t; + } + + private static float clamp01(float value) { + return value < 0f ? 0f : (value > 1f ? 1f : value); + } + + /** Integer hash to [0,1). Wang-style mix; only needs to decorrelate a lattice, not pass SmallCrush. */ + private static float hash(int x, int y, int z) { + int h = x * 374761393 + y * 668265263 + z * 1274126177; + h = (h ^ (h >>> 13)) * 1274126177; + h ^= h >>> 16; + return (h >>> 8) * (1.0f / 16777216.0f); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtDhLodRegion.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtDhLodRegion.java new file mode 100644 index 000000000..66f11cbba --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtDhLodRegion.java @@ -0,0 +1,225 @@ +package dev.comfyfluffy.caustica.rt.terrain; + +import java.util.List; + +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.block.BlockAndTintGetter; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.world.level.CardinalLighting; +import net.minecraft.world.level.ColorResolver; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.lighting.LevelLightEngine; +import net.minecraft.world.level.material.FluidState; + +/** + * Presents Distant Horizons LOD data to Caustica's existing terrain mesher as if it were an ordinary + * 16x16x16 section. + * + *

The idea

+ * Rather than writing a second mesher for LOD geometry, this makes LOD data look like the input the + * tested one already takes. {@link RtTerrainMesher#buildCpuSection} walks a 16-cubed grid calling + * {@code getBlockState}; back that with DH data and you get full model tessellation, correct sprites, + * correct material IDs, biome tints, fluid handling and the coplanar-quad resolution — all from the + * path that already works for near terrain. No new sprite lookup, no new material resolution, no second + * ABI to keep in sync with {@code PackedSection}. + * + *

How it stays cheap: the section is a SCALE, not a downsample

+ * A virtual block here represents {@code 1 << detailLevel} world blocks on a side. The mesher emits + * section-local coordinates in 0..16 exactly as it always does, and the TLAS instance carries a + * {@code 2^detailLevel} scale alongside its translation. So one LOD section covers a + * {@code (16 * 2^detail)} block cube while costing the triangle budget of one ordinary section — at + * detail 3 that is a 128-block cube for the price of a 16-block one. + * + *

This is the whole reason the approach is viable. Meshing DH data at 1:1 and relying on distance to + * hide it would produce the triangle count of full-detail terrain out to the horizon, which is the + * problem LOD exists to avoid. + * + *

Known approximations, stated rather than hidden

+ *
    + *
  • Light engine and biome lookups fall through to the level. Out past the chunk cache those + * return defaults, so distant terrain gets default sky light and default biome tint. For a path + * tracer this matters less than it would for a rasterizer — the actual lighting is traced, and + * baked light mostly feeds ambient occlusion and emission. But grass and foliage colour at + * distance will be the fallback tint, not the biome's. DH's own {@code biomeWrapper} could fix + * this and is the obvious follow-up.
  • + *
  • One block state per virtual block. DH's run-length column is sampled at the virtual + * block's centre. A single-block ore vein inside an 8-block cube disappears. That is what LOD + * means.
  • + *
  • Block entities return null, as in the near-terrain region, because the mesher never asks.
  • + *
+ */ +final class RtDhLodRegion implements BlockAndTintGetter { + /** Section edge in virtual blocks — the same 16 the mesher walks. */ + static final int SECTION_BLOCKS = 16; + private static final BlockState AIR = Blocks.AIR.defaultBlockState(); + + private final ClientLevel level; + private final CardinalLighting cardinalLighting; + private final LevelLightEngine lightEngine; + /** Dense virtual-block grid, x-major then y then z, so the mesher's inner z loop walks contiguously. */ + private final BlockState[] states = new BlockState[SECTION_BLOCKS * SECTION_BLOCKS * SECTION_BLOCKS]; + private final int detailLevel; + private final int scale; + /** World-space origin of the section, in real blocks. */ + private final int originBlockX; + private final int originBlockY; + private final int originBlockZ; + private boolean empty = true; + + RtDhLodRegion(ClientLevel level, int detailLevel, int originBlockX, int originBlockY, int originBlockZ) { + this.level = level; + this.cardinalLighting = level.cardinalLighting(); + this.lightEngine = level.getLightEngine(); + this.detailLevel = detailLevel; + this.scale = 1 << detailLevel; + this.originBlockX = originBlockX; + this.originBlockY = originBlockY; + this.originBlockZ = originBlockZ; + java.util.Arrays.fill(states, AIR); + } + + int detailLevel() { + return detailLevel; + } + + int scale() { + return scale; + } + + int originBlockX() { + return originBlockX; + } + + int originBlockY() { + return originBlockY; + } + + int originBlockZ() { + return originBlockZ; + } + + /** True when nothing was written — the caller should skip meshing entirely rather than build an empty BLAS. */ + boolean isEmpty() { + return empty; + } + + /** + * Rasterises DH boxes into the virtual grid. + * + *

Boxes are vertical runs of one block state, so this fills a span of virtual Y per box rather + * than looping blocks. A box whose horizontal footprint is larger than one virtual block (which + * happens when DH's detail level is coarser than ours) fills the cells it covers; a box smaller + * than one virtual block writes a single cell, and the last writer wins. Last-writer-wins is + * deliberate and not a coin flip: {@link RtDhLodSource} returns boxes in column order, so the + * winner is the topmost run touching that cell, which is the surface you can actually see. + */ + void fill(List boxes) { + for (RtDhLodSource.LodBox box : boxes) { + int localX0 = Math.floorDiv(box.blockX() - originBlockX, scale); + int localZ0 = Math.floorDiv(box.blockZ() - originBlockZ, scale); + int spanCells = Math.max(1, box.sizeXZ() / scale); + int localY0 = Math.floorDiv(box.bottomY() - originBlockY, scale); + int localY1 = Math.floorDiv(box.topY() - 1 - originBlockY, scale); + if (localY1 < 0 || localY0 >= SECTION_BLOCKS) { + continue; + } + int yStart = Math.max(localY0, 0); + int yEnd = Math.min(localY1, SECTION_BLOCKS - 1); + for (int dx = 0; dx < spanCells; dx++) { + int lx = localX0 + dx; + if (lx < 0 || lx >= SECTION_BLOCKS) { + continue; + } + for (int dz = 0; dz < spanCells; dz++) { + int lz = localZ0 + dz; + if (lz < 0 || lz >= SECTION_BLOCKS) { + continue; + } + for (int ly = yStart; ly <= yEnd; ly++) { + states[index(lx, ly, lz)] = box.blockState(); + empty = false; + } + } + } + } + } + + private static int index(int x, int y, int z) { + return (x * SECTION_BLOCKS + y) * SECTION_BLOCKS + z; + } + + // ---- BlockAndTintGetter ---------------------------------------------------------------------- + // + // The mesher addresses this in VIRTUAL block coordinates: it is told the section is at + // (0,0,0)..(16,16,16) and emits section-local vertices in that space. The instance transform scales + // those back up. So positions arriving here are already local and need no world conversion — which + // also means this never touches the real world's chunk cache for geometry, only for the tint and + // light fallbacks below. + + @Override + public BlockState getBlockState(BlockPos pos) { + int x = pos.getX(); + int y = pos.getY(); + int z = pos.getZ(); + if (x < 0 || y < 0 || z < 0 || x >= SECTION_BLOCKS || y >= SECTION_BLOCKS || z >= SECTION_BLOCKS) { + // Out-of-section neighbour queries drive face culling. Returning AIR means boundary faces + // are always emitted, which costs some triangles but guarantees no hole between adjacent LOD + // sections. A hole in a path tracer is not a seam — primary rays fall through to the sky and + // shadow rays fall through, putting a bar of light on the ground. + return AIR; + } + return states[index(x, y, z)]; + } + + @Override + public FluidState getFluidState(BlockPos pos) { + return getBlockState(pos).getFluidState(); + } + + @Override + public CardinalLighting cardinalLighting() { + return cardinalLighting; + } + + @Override + public LevelLightEngine getLightEngine() { + return lightEngine; + } + + @Override + public BlockEntity getBlockEntity(BlockPos pos) { + return null; // never queried by the RT mesher, as in RtSectionSnapshots.Region + } + + @Override + public int getBlockTint(BlockPos pos, ColorResolver resolver) { + // Sampled at the section's world origin rather than the virtual position: the virtual position + // is meaningless in world space, and one tint for the whole LOD section is both cheaper and + // more stable than a per-cell lookup that would flicker as sections stream. + return level.getBlockTint(new BlockPos(originBlockX, originBlockY, originBlockZ), resolver); + } + + @Override + public boolean hasBiomes() { + return level.hasBiomes(); + } + + @Override + public Holder getBiomeFabric(BlockPos pos) { + return level.getBiomeFabric(new BlockPos(originBlockX, originBlockY, originBlockZ)); + } + + @Override + public int getMinY() { + return level.getMinY(); + } + + @Override + public int getHeight() { + return level.getHeight(); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtDhLodSource.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtDhLodSource.java new file mode 100644 index 000000000..b4f4ea332 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtDhLodSource.java @@ -0,0 +1,375 @@ +package dev.comfyfluffy.caustica.rt.terrain; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.world.level.block.state.BlockState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Reads Distant Horizons' persistent terrain database and turns it into axis-aligned boxes that can be + * meshed and put in the ray-tracing acceleration structure. + * + *

Why this exists

+ * Caustica builds every section from {@code level.getChunk(...)}, the client chunk cache. Beyond the + * server's view distance there is no block data in the process at all — so a Caustica-native LOD can + * reduce detail on loaded terrain, but it can never show terrain that was never sent. DH already solves + * that problem: it maintains a durable, generated, streamed world database far past the chunk cache. + * + *

What this deliberately does NOT do

+ * It does not touch DH's renderer, intercept its draw calls, or read its GPU buffers. DH's rendering + * should be switched OFF when this is used. DH is a data source here and Caustica does all drawing, as + * rays. That avoids the whole class of problems that come from two world renderers coexisting, and it + * means this survives DH changing its rendering backend — which it has done twice recently. + * + *

Why reflection

+ * DH is optional. A hard compile-time dependency would make Caustica refuse to load without it, and + * would pin a DH version. Everything here resolves lazily and degrades to "no LOD" if DH is absent, is + * a version whose API moved, or has not finished loading a world. The API surface used is small and + * documented — {@code DhApi.Delayed.terrainRepo}, {@code DhApi.Delayed.worldProxy}, and + * {@code IDhApiTerrainDataRepo.getAllTerrainDataAtDetailLevelAndPos} — and it is a versioned public + * API rather than internals, so it is a reasonable thing to bind to loosely. + * + *

Data shape

+ * DH returns {@code DhApiTerrainDataPoint[][][]} indexed [x][z][column entry]. Each entry is already a + * vertical RUN — a {@code bottomYBlockPos}..{@code topYBlockPos} span of one block state, with baked + * block and sky light. That is run-length encoding done for us: a 200-block stone column is one entry, + * not 200. Emitting one box per entry is therefore already a large win before any greedy merging, and + * it is why this does not need a general voxel mesher. + * + *

Crucially the entry carries a {@code blockStateWrapper} whose {@code getWrappedMcObject()} is a real + * Minecraft {@link BlockState}. That means distant terrain can resolve to the SAME material table entries + * as near terrain — roughness, metalness, emission, LabPBR maps — rather than the baked vertex colour a + * renderer-interception approach would give you. Distant lava glowing correctly is downstream of this + * one method call. + */ +public final class RtDhLodSource { + private static final Logger LOGGER = LoggerFactory.getLogger("Caustica/DhLod"); + private static final String DH_MOD_ID = "distanthorizons"; + + /** Resolution states. Resolved once; a failure is remembered so a broken API is not retried per frame. */ + private enum State { UNRESOLVED, READY, UNAVAILABLE } + + private static State state = State.UNRESOLVED; + private static Object terrainRepo; + private static Object worldProxy; + private static Method getAllTerrainDataAtDetailLevelAndPos; + private static Method createSoftCache; + private static Method getSinglePlayerLevel; + private static Method getAllLoadedLevelWrappers; + /** + * A soft cache DH's own API creates and owns. Passing null for this parameter is not "no cache" — + * DH treats it as a hard failure and every query returns unsuccessful with the message "Missing + * [IDhApiTerrainDataCache]". That single null was the actual cause of every LOD query failing; + * the footprint-vs-area fix and the diagnostics were correct but never got to matter. + */ + private static Object softCache; + private static Field resultPayload; + private static Field resultSuccess; + private static Field resultMessage; + private static Field pointBottomY; + private static Field pointTopY; + private static Field pointBlockLight; + private static Field pointSkyLight; + private static Field pointBlockState; + private static Method wrapperGetMcObject; + private static final java.util.concurrent.atomic.AtomicBoolean loggedQueryFailure = + new java.util.concurrent.atomic.AtomicBoolean(); + private static final java.util.concurrent.atomic.AtomicBoolean loggedQuerySuccess = + new java.util.concurrent.atomic.AtomicBoolean(); + private static final java.util.concurrent.atomic.AtomicBoolean loggedNoLevel = + new java.util.concurrent.atomic.AtomicBoolean(); + + private RtDhLodSource() { + } + + /** + * One vertical run of a single block state, in world coordinates. {@code topY} is exclusive, matching + * DH's convention, so an empty run is representable and callers do not need an off-by-one guard. + */ + public record LodBox(int blockX, int bottomY, int topY, int blockZ, int sizeXZ, + BlockState blockState, int blockLight, int skyLight) { + public int heightBlocks() { + return topY - bottomY; + } + } + + public static synchronized boolean available() { + resolve(); + return state == State.READY; + } + + private static void resolve() { + if (state != State.UNRESOLVED) { + return; + } + if (!FabricLoader.getInstance().isModLoaded(DH_MOD_ID)) { + state = State.UNAVAILABLE; + return; + } + try { + Class dhApi = Class.forName("com.seibel.distanthorizons.api.DhApi"); + Class delayed = Class.forName("com.seibel.distanthorizons.api.DhApi$Delayed"); + terrainRepo = delayed.getField("terrainRepo").get(null); + worldProxy = delayed.getField("worldProxy").get(null); + if (terrainRepo == null || worldProxy == null) { + // DH is present but has not finished initialising. Stay UNRESOLVED so this retries: + // the fields are populated after world load, not at mod init. + terrainRepo = null; + worldProxy = null; + return; + } + + Class repoInterface = Class.forName( + "com.seibel.distanthorizons.api.interfaces.data.IDhApiTerrainDataRepo"); + Class levelWrapper = Class.forName( + "com.seibel.distanthorizons.api.interfaces.world.IDhApiLevelWrapper"); + Class cacheInterface = Class.forName( + "com.seibel.distanthorizons.api.interfaces.data.IDhApiTerrainDataCache"); + getAllTerrainDataAtDetailLevelAndPos = repoInterface.getMethod( + "getAllTerrainDataAtDetailLevelAndPos", + levelWrapper, byte.class, int.class, int.class, cacheInterface); + createSoftCache = repoInterface.getMethod("createSoftCache"); + + // Two accessors, because they cover different worlds. getSinglePlayerLevel throws on a + // multiplayer server, which would have ruled out exactly the case that matters here: + // Wynncraft with WynnLODGrabber, where DH's database is populated for a SERVER world. + // getAllLoadedLevelWrappers covers that, at the cost of having to pick — see levelWrapper(). + Class worldProxyInterface = Class.forName( + "com.seibel.distanthorizons.api.interfaces.world.IDhApiWorldProxy"); + getSinglePlayerLevel = worldProxyInterface.getMethod("getSinglePlayerLevel"); + getAllLoadedLevelWrappers = worldProxyInterface.getMethod("getAllLoadedLevelWrappers"); + + // DhApiResult exposes success/message/payload as public FIELDS, not getters. + Class resultClass = Class.forName("com.seibel.distanthorizons.api.objects.DhApiResult"); + resultSuccess = resultClass.getField("success"); + resultMessage = resultClass.getField("message"); + resultPayload = resultClass.getField("payload"); + + Class pointClass = Class.forName( + "com.seibel.distanthorizons.api.objects.data.DhApiTerrainDataPoint"); + pointBottomY = pointClass.getField("bottomYBlockPos"); + pointTopY = pointClass.getField("topYBlockPos"); + pointBlockLight = pointClass.getField("blockLightLevel"); + pointSkyLight = pointClass.getField("skyLightLevel"); + pointBlockState = pointClass.getField("blockStateWrapper"); + + Class unsafeWrapper = Class.forName( + "com.seibel.distanthorizons.api.interfaces.IDhApiUnsafeWrapper"); + wrapperGetMcObject = unsafeWrapper.getMethod("getWrappedMcObject"); + + softCache = createSoftCache.invoke(terrainRepo); + state = State.READY; + LOGGER.info("Distant Horizons LOD source resolved ({})", dhApi.getName()); + } catch (ReflectiveOperationException | RuntimeException e) { + // A moved API is expected across DH majors and is not an error worth spamming: log once and + // run without distant terrain, exactly as if DH were not installed. + state = State.UNAVAILABLE; + terrainRepo = null; + worldProxy = null; + LOGGER.warn("Distant Horizons is installed but its API did not resolve; " + + "distant LOD terrain disabled. {}", e.toString()); + } + } + + /** + * Fetches one square area of terrain and flattens it into boxes. + * + *

DH's detailLevel is the size of the QUERIED AREA, not the resolution of the data. This + * is the single most misreadable thing in the API and getting it wrong produces silence rather + * than an error. From DH's own javadoc: 0 = block, 2 = 4x4 blocks, 4 = chunk, 9 = region. So + * {@code detailLevel} 4 asks for one chunk's worth of columns at position (posX, posZ) measured in + * chunks — it does NOT ask for chunk-resolution data. + * + *

Because of that, the caller passes the FOOTPRINT it wants covered and this derives everything + * else. The returned grid's own dimensions then tell us the resolution DH actually had, which is + * read from the array rather than assumed: DH may return a coarser grid than the footprint implies + * if that is all its database holds, and silently treating a 4x4 grid as 16x16 would scatter + * terrain across the section with holes between. + * + *

Call this off the render thread. DH may hit disk. It is a database query, not a memory + * read. + * + * @param footprintBlocks width of the square area to cover, in blocks; must be a power of two + * @param originBlockX world X of the area's corner, a multiple of footprintBlocks + * @param originBlockZ world Z of the area's corner + * @return the boxes, or an empty list if DH is unavailable or has nothing here + */ + public static List fetchArea(int footprintBlocks, int originBlockX, int originBlockZ) { + byte detailLevel = (byte) Integer.numberOfTrailingZeros(Math.max(footprintBlocks, 1)); + int posX = Math.floorDiv(originBlockX, Math.max(footprintBlocks, 1)); + int posZ = Math.floorDiv(originBlockZ, Math.max(footprintBlocks, 1)); + return fetchRegion(detailLevel, posX, posZ, footprintBlocks, originBlockX, originBlockZ); + } + + private static List fetchRegion(byte detailLevel, int posX, int posZ, + int footprintBlocks, int originBlockX, int originBlockZ) { + synchronized (RtDhLodSource.class) { + resolve(); + if (state != State.READY) { + return List.of(); + } + } + try { + Object levelWrapper = levelWrapper(); + if (levelWrapper == null) { + // The most likely single cause of "DH returns nothing": DH has no level loaded at all, + // which is not the same as a level with no terrain in it. + if (loggedNoLevel.compareAndSet(false, true)) { + LOGGER.info("DH has no loaded level — it may be disabled for this world, or its " + + "level lifecycle may require its renderer to be enabled"); + } + return List.of(); + } + Object result = getAllTerrainDataAtDetailLevelAndPos.invoke( + terrainRepo, levelWrapper, detailLevel, posX, posZ, softCache); + if (result == null || !resultSuccess.getBoolean(result)) { + // DH's own explanation, surfaced ONCE at info. It was debug-only, which meant that when + // every query failed the log showed a count of zeroes and no reason — the one piece of + // information that would have identified the cause was being swallowed. Once, because + // a failing setup fails on every query and would otherwise flood the log. + if (loggedQueryFailure.compareAndSet(false, true)) { + LOGGER.info("DH query failed at detail {} pos {},{}: {}", detailLevel, posX, posZ, + result == null ? "null result" : resultMessage.get(result)); + } + return List.of(); + } + // A successful but empty answer is a different diagnosis from a failed one, so say so. + if (loggedQuerySuccess.compareAndSet(false, true)) { + LOGGER.info("DH query succeeded at detail {} pos {},{}", detailLevel, posX, posZ); + } + Object grid = resultPayload.get(result); + if (grid == null) { + return List.of(); + } + return flatten(grid, footprintBlocks, originBlockX, originBlockZ); + } catch (ReflectiveOperationException | RuntimeException e) { + LOGGER.debug("DH region fetch failed at detail {} ({}, {}): {}", + detailLevel, posX, posZ, e.toString()); + return List.of(); + } + } + + /** + * Walks the [x][z][column] array. Reflection on the array rather than a cast because the element + * type lives in DH's classloader-visible API and casting it here would reintroduce the hard + * dependency this class exists to avoid. + */ + private static List flatten(Object grid, int footprintBlocks, int originX, int originZ) + throws ReflectiveOperationException { + List boxes = new ArrayList<>(); + int lenX = Array.getLength(grid); + if (lenX <= 0) { + return boxes; + } + // Resolution derived from what DH actually returned, not from what was asked for. A 128-block + // footprint answered with a 16x16 grid means each cell stands for 8 blocks. + int sizeXZ = Math.max(footprintBlocks / lenX, 1); + for (int ix = 0; ix < lenX; ix++) { + Object column = Array.get(grid, ix); + if (column == null) { + continue; + } + int lenZ = Array.getLength(column); + for (int iz = 0; iz < lenZ; iz++) { + Object entries = Array.get(column, iz); + if (entries == null) { + continue; + } + int lenY = Array.getLength(entries); + for (int iy = 0; iy < lenY; iy++) { + Object point = Array.get(entries, iy); + if (point == null) { + continue; + } + int bottomY = pointBottomY.getInt(point); + int topY = pointTopY.getInt(point); + if (topY <= bottomY) { + continue; + } + Object wrapper = pointBlockState.get(point); + if (wrapper == null) { + continue; + } + Object mcObject = wrapperGetMcObject.invoke(wrapper); + if (!(mcObject instanceof BlockState blockState) || blockState.isAir()) { + // Air runs are the majority of every column and carry no geometry. Skipping + // them here rather than in the mesher keeps the returned list proportional to + // the terrain rather than to the world height. + continue; + } + boxes.add(new LodBox( + originX + ix * sizeXZ, + bottomY, + topY, + originZ + iz * sizeXZ, + sizeXZ, + blockState, + pointBlockLight.getInt(point), + pointSkyLight.getInt(point))); + } + } + } + return boxes; + } + + /** + * The level to query. Single-player has exactly one and DH says so directly. On a server — + * Wynncraft being the case this exists for — that call throws, so this falls back to the loaded + * set. + * + *

Taking the first loaded wrapper is a real limitation, not a tidy default: with more than one + * level loaded it can return the wrong dimension's terrain. It is acceptable here because the + * situation this serves is a server world where DH has one level populated by WynnLODGrabber. If + * distant terrain ever appears from the wrong dimension, this is the line to fix, by matching the + * wrapper's dimension against the client's. + */ + private static Object levelWrapper() throws ReflectiveOperationException { + try { + Object single = getSinglePlayerLevel.invoke(worldProxy); + if (single != null) { + return single; + } + } catch (java.lang.reflect.InvocationTargetException e) { + // IllegalStateException on a server. Expected; fall through. + } + // Iterable, NOT Collection. Verified against DistantHorizons-3.2.0-b-26.2: the signature is + // getAllLoadedLevelWrappers()Ljava/lang/Iterable;. A Collection check here compiles and runs + // fine, silently matches nothing, and disables distant terrain on every server — which is + // exactly the case this fallback exists for. + Object loaded = getAllLoadedLevelWrappers.invoke(worldProxy); + if (loaded instanceof Iterable iterable) { + for (Object wrapper : iterable) { + if (wrapper != null) { + return wrapper; + } + } + } + return null; + } + + /** Forgets the resolved API. Call on world unload so a DH reload is picked up. */ + public static synchronized void invalidate() { + if (state == State.READY) { + state = State.UNRESOLVED; + terrainRepo = null; + worldProxy = null; + // The cache holds soft references into DH's data sources for a world that is going away; + // clear() is the documented way to release them rather than just dropping the reference. + if (softCache instanceof AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception ignored) { + // AutoCloseable#close is declared checked; DH's own override is not, so this is + // unreachable in practice and kept only to satisfy the compiler. + } + } + softCache = null; + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtSectionTable.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtSectionTable.java index cd08d2e25..713d44d45 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtSectionTable.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtSectionTable.java @@ -188,8 +188,13 @@ private void markDirty(long offset, long length) { } RtAccel.Instance instanceFor(SectionGeom geom, int rbx, int rby, int rbz) { - float[] xform = {1, 0, 0, geom.sx - rbx, 0, 1, 0, geom.sy - rby, - 0, 0, 1, geom.sz - rbz}; + // Uniform scale on the diagonal for LOD sections (1 for ordinary terrain, so this stays the + // identity rotation it always was). The translation is the section's world origin rebased, and + // is NOT scaled: the origin is already in world blocks, only the section-local geometry needs + // expanding. + float s = geom.lodScale; + float[] xform = {s, 0, 0, geom.sx - rbx, 0, s, 0, geom.sy - rby, + 0, 0, s, geom.sz - rbz}; return new RtAccel.Instance(xform, geom.blas.deviceAddress, geom.slot); } @@ -207,6 +212,14 @@ static final class SectionGeom { final float[] lights; int slot = -1; int instanceIndex = -1; + /** + * Blocks per virtual block. 1 for ordinary terrain. A distant-LOD section is meshed from a + * 16-cubed virtual grid whose blocks stand for 2^detail world blocks, so the same triangle + * budget covers a much larger volume; the instance transform scales it back up rather than the + * mesher emitting larger vertices, which keeps section-local positions in the same fp32 range + * for every section regardless of detail. + */ + int lodScale = 1; SectionGeom(long key, RtBuffer uvs, RtBuffer material, RtAccel blas, int[] triBase, int sx, int sy, int sz, float[] lights) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtTerrain.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtTerrain.java index 2b91fb0d1..211a2835c 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtTerrain.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtTerrain.java @@ -155,6 +155,36 @@ private static int rebaseDistanceBlocks() { // frames, so evicted geometry waits here until the next publish pass retires it. private final List removed = new ArrayList<>(); private final List prepared = new ArrayList<>(); + // ---- Distant LOD --------------------------------------------------------------------------- + // Held OUTSIDE `resident` on purpose. Residency's window sync evicts anything not in `desired`, + // and `desired` is derived from the vanilla chunk cache — so a LOD section, which by definition + // lives past that window, would be evicted the tick after it published. Keeping a separate map + // means the two grids never fight, at the cost of the small publish block below. + private final Long2ObjectOpenHashMap lodResident = new Long2ObjectOpenHashMap<>(); + /** + * Keys with a dispatch in progress. RENDER THREAD ONLY — fastutil hash sets are not thread-safe, + * and a worker removing a key while the render thread inserts one corrupts the table (it surfaces + * as an ArrayIndexOutOfBounds inside rehash, not as anything that looks like a race). Workers + * report completion through the two synchronized lists below; this set is only ever mutated in + * dispatchLodSection and publishLodPrepared, both of which run on the render thread. + */ + private final LongOpenHashSet lodInFlight = new LongOpenHashSet(); + private final List lodPrepared = + java.util.Collections.synchronizedList(new ArrayList<>()); + /** Keys whose dispatch ended without geometry (empty region, DH miss, or a build failure). */ + private final List lodFailed = java.util.Collections.synchronizedList(new ArrayList<>()); + /** + * Keeps LOD keys clear of real section keys. The key packs scy into 12 signed bits, and legal + * Minecraft section Y is roughly -4..20, so offsetting by 512 per detail level cannot collide with + * a real section or with another detail level. + */ + private static final int LOD_KEY_Y_OFFSET = 512; + /** Regions DH had no geometry for. If this climbs while published stays 0, the database is the problem. */ + private final java.util.concurrent.atomic.AtomicInteger lodEmptyRegions = + new java.util.concurrent.atomic.AtomicInteger(); + /** Total boxes DH handed back. Non-zero with zero published means meshing is the problem, not DH. */ + private final java.util.concurrent.atomic.AtomicInteger lodBoxesSeen = + new java.util.concurrent.atomic.AtomicInteger(); // Worker/build bookkeeping. `inFlight` maps a dispatched section key to a monotonic token; a completed // task whose token no longer matches is discarded. The active-task barrier spans worker + GPU lifetime. private final Long2LongOpenHashMap inFlight = new Long2LongOpenHashMap(); @@ -458,7 +488,9 @@ private void stream(RtContext ctx) { && completedBuilds.isEmpty() && !lightGrid.hasCompletions() && !lightHierarchyDirty - && removed.isEmpty() && prepared.isEmpty()) { + && removed.isEmpty() && prepared.isEmpty() + && lodPrepared.isEmpty() && !CausticaConfig.Rt.Lod.ENABLED.value() + && lodResident.isEmpty()) { return; } int pbx = mc.player.getBlockX(); @@ -481,6 +513,10 @@ private void stream(RtContext ctx) { } } + try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("terrain.lodDispatch")) { + streamLod(ctx, level, pbx, pby, pbz); + } + // Publish only a fully uploaded hierarchy. Newer section changes supersede stale worker/upload // results, while the previous complete generation remains active until this atomic swap. if (lightGrid.hasCompletions()) { @@ -1412,6 +1448,271 @@ private void destroyPreparedSection(PreparedSection ps) { } /** Per-tick render-thread snapshot dependencies shared by reextract + missing dispatch. */ + + // ---- Distant LOD from Distant Horizons ------------------------------------------------------- + // + // A second, much coarser section grid living entirely past the vanilla chunk cache. It rides the + // same mesh -> upload -> BLAS -> table pipeline as ordinary terrain, but is tracked separately + // because residency's window sync would evict it instantly (see lodResident). + // + // LOD sections are never individually evicted. They are cheap to hold (a few hundred at most), the + // player leaving a region is not a reason to rebuild it, and never destroying a published BLAS + // mid-session removes the entire use-after-free class of bug from this path. They are released + // wholesale on world change or when the feature is switched off. + + private static long lodKey(int lx, int ly, int lz, int detail) { + return sectionKey(lx, ly + LOD_KEY_Y_OFFSET * detail, lz); + } + + /** One-shot diagnostics so a silent LOD failure is distinguishable from a working one. */ + private boolean lodLoggedState; + private int lodLoggedPublished = -1; + + private void streamLod(RtContext ctx, ClientLevel level, int pbx, int pby, int pbz) { + boolean enabled = CausticaConfig.Rt.Lod.ENABLED.value(); + if (!enabled || !RtDhLodSource.available()) { + if (enabled && !lodLoggedState) { + // The single most useful line: says plainly whether DH's API resolved at all, which + // separates "Caustica is not asking" from "DH has no data". + lodLoggedState = true; + CausticaMod.LOGGER.info("LOD enabled but the Distant Horizons API is unavailable; no distant terrain"); + } + releaseLod(ctx); + return; + } + int empties = lodEmptyRegions.get(); + if (empties >= 64 && empties % 64 == 0 && lodResident.isEmpty()) { + CausticaMod.LOGGER.info( + "LOD: {} regions queried, {} boxes returned by DH, none published (detail {})", + empties, lodBoxesSeen.get(), CausticaConfig.Rt.Lod.DETAIL.value()); + } + if (!lodLoggedState) { + lodLoggedState = true; + CausticaMod.LOGGER.info("LOD active: detail={}, radius={}, DH source available", + CausticaConfig.Rt.Lod.DETAIL.value(), CausticaConfig.Rt.Lod.RADIUS.value()); + } + publishLodPrepared(ctx, pbx, pby, pbz); + + int detail = CausticaConfig.Rt.Lod.DETAIL.value(); + int scale = 1 << detail; + int sectionBlocks = RtDhLodRegion.SECTION_BLOCKS * scale; + int radius = CausticaConfig.Rt.Lod.RADIUS.value(); + int heightSections = CausticaConfig.Rt.Lod.HEIGHT_SECTIONS.value(); + int budget = CausticaConfig.Rt.Lod.SECTIONS_PER_FRAME.value(); + + int centreX = Math.floorDiv(pbx, sectionBlocks); + int centreZ = Math.floorDiv(pbz, sectionBlocks); + int centreY = Math.floorDiv(62, sectionBlocks); + + // Nearest-first, so the ring the player is looking at fills before the far edge. + outer: + for (int ring = 0; ring <= radius; ring++) { + for (int dx = -ring; dx <= ring; dx++) { + for (int dz = -ring; dz <= ring; dz++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != ring) { + continue; // only this ring's perimeter; inner rings were done already + } + for (int dy = 0; dy < heightSections; dy++) { + if (budget <= 0) { + break outer; + } + int lx = centreX + dx; + int lz = centreZ + dz; + int ly = centreY + dy; + long key = lodKey(lx, ly, lz, detail); + if (lodResident.containsKey(key) || lodInFlight.contains(key)) { + continue; + } + dispatchLodSection(ctx, level, key, lx, ly, lz, detail, scale, sectionBlocks); + budget--; + } + } + } + } + } + + private void dispatchLodSection(RtContext ctx, ClientLevel level, long key, + int lx, int ly, int lz, int detail, int scale, int sectionBlocks) { + DispatchContext dispatch = dispatchContext(ctx, level); + RtMaterialRegistry.Snapshot materialSnapshot = RtMaterialRegistry.INSTANCE.requireSnapshot(); + int originX = lx * sectionBlocks; + int originY = ly * sectionBlocks; + int originZ = lz * sectionBlocks; + lodInFlight.add(key); + beginActiveTask(); + try { + RtWorkerPool.INSTANCE.submit(() -> { + try { + // One query covering the section's whole footprint. DH's detailLevel is the size of + // the queried AREA, not the data resolution, so the footprint is what it needs — + // asking for "detail 3" got an 8x8-block area per 128-block section, which is why + // sections came back empty. + RtDhLodRegion region = new RtDhLodRegion(level, detail, originX, originY, originZ); + java.util.List boxes = + RtDhLodSource.fetchArea(sectionBlocks, originX, originZ); + region.fill(boxes); + lodBoxesSeen.addAndGet(boxes.size()); + if (region.isEmpty()) { + // Empty means DH returned no solid blocks here — either the region is not in + // its database, or the detail level has not been generated. Counted rather + // than logged per section, which would be thousands of lines. + lodEmptyRegions.incrementAndGet(); + finishLodTask(key, null); + return; + } + WorkerTessState ws = WORKER_TESS.get(); + ws.reset(dispatch.blockColors(), dispatch.blockSpriteFinder()); + FluidRenderer fluidRenderer = new FluidRenderer(dispatch.fluidModelSet()); + // Section coords passed as 0,0,0: the region presents a virtual section at the + // origin, and the world placement is carried by the instance transform instead. + CpuSection cpu = buildCpuSection(region, dispatch.modelSet(), ws.blockEmitter, + ws.blockRandom, ws.capture, fluidRenderer, ws.fluidCapture, ws.mesh, ws.pos, + materialSnapshot, 0, 0, 0); + PackedSection packed = cpu.packed(); + if (packed == null) { + finishLodTask(key, null); + return; + } + PreparedSection ps = RtSectionBuilder.prepare(dispatch.ctx(), packed, + cpu.opacityMicromap(), CausticaConfig.Rt.Terrain.BLAS_COMPACTION.value(), + key, originX, originY, originZ); + submitLodBuild(dispatch.ctx(), key, ps, scale); + } catch (Throwable t) { + finishLodTask(key, null); + } + }); + } catch (Throwable t) { + // Submit itself failed, so no worker will ever report: clear the key here. Still the + // render thread at this point, so touching the set directly is safe. + lodInFlight.remove(key); + finishActiveTask(); + throw t; + } + } + + private void submitLodBuild(RtContext ctx, long key, PreparedSection prepared, int scale) { + ctx.gpuExecutor().submit( + () -> false, + cmd -> { + RtSectionBuilder.recordUpload(cmd, prepared); + RtAccel.recordBlasBuilds(ctx, cmd, List.of(prepared.blas())); + }, + () -> { + RtAccel.freeBlasScratch(List.of(prepared.blas())); + prepared.releaseUpload(); + }, + (build, failure) -> { + if (failure != null) { + destroyPreparedSection(prepared); + finishLodTask(key, null); + return; + } + prepared.releaseBuildInputs(); + finishLodTask(key, prepared); + }); + } + + /** + * Worker/GPU-callback side of a dispatch. Only ever appends to a synchronized list; the in-flight + * set is cleared later, on the render thread, in publishLodPrepared. + */ + private void finishLodTask(long key, PreparedSection prepared) { + if (prepared != null) { + lodPrepared.add(prepared); + } else { + lodFailed.add(key); + } + finishActiveTask(); + } + + /** Publishes finished LOD sections into the shared section table. */ + private void publishLodPrepared(RtContext ctx, int pbx, int pby, int pbz) { + // Retire failed keys first so they become eligible for redispatch. Done here, on the render + // thread, because lodInFlight is not thread-safe. + if (!lodFailed.isEmpty()) { + List failed; + synchronized (lodFailed) { + failed = new ArrayList<>(lodFailed); + lodFailed.clear(); + } + for (Long key : failed) { + lodInFlight.remove(key.longValue()); + } + } + if (lodPrepared.isEmpty()) { + return; + } + List batch; + synchronized (lodPrepared) { + batch = new ArrayList<>(lodPrepared); + lodPrepared.clear(); + } + int scale = 1 << CausticaConfig.Rt.Lod.DETAIL.value(); + for (PreparedSection ps : batch) { + lodInFlight.remove(ps.key()); + if (lodResident.containsKey(ps.key()) || !CausticaConfig.Rt.Lod.ENABLED.value()) { + // Either a duplicate dispatch of the same key, or LOD was switched off while this was + // in flight. Retire it rather than publishing geometry nothing will ever release. + ctx.gpuExecutor().retireUnpublished(() -> destroyPreparedSection(ps)); + continue; + } + SectionGeom g = new SectionGeom(ps.key(), ps.uvs(), ps.material(), + ps.blas().accel, ps.triBase(), ps.sx(), ps.sy(), ps.sz(), ps.lights()); + g.lodScale = scale; + g.slot = table.allocateSlot(); + g.instanceIndex = table.instanceList.size(); + table.slots.set(g.slot, g); + table.write(g); + table.instanceList.add(table.instanceFor(g, blockX, blockY, blockZ)); + lodResident.put(ps.key(), g); + } + // Logged at a few thresholds rather than per section: enough to tell "nothing is publishing" + // from "sections are arriving", without a line per frame while the ring fills. + int count = lodResident.size(); + if (count != lodLoggedPublished && (count == 1 || count == 8 || count == 32 || count == 128)) { + lodLoggedPublished = count; + CausticaMod.LOGGER.info("LOD sections published: {}", count); + } + table.flushWrites(); + table.instances = table.instanceList; + } + + /** + * Drops every LOD section. Used when the feature is switched off and on world teardown. The + * instance list is rebuilt from scratch rather than patched: instanceIndex values are positions in + * that list, so removing entries in place would invalidate every later section's index. + */ + private void releaseLod(RtContext ctx) { + if (lodResident.isEmpty()) { + return; + } + List doomed = new ArrayList<>(lodResident.values()); + lodResident.clear(); + lodInFlight.clear(); + synchronized (lodFailed) { + lodFailed.clear(); + } + // Anything still in flight will land in lodPrepared after this; publishLodPrepared drops + // prepared sections whose key is no longer wanted, so they are retired rather than leaked. + for (SectionGeom g : doomed) { + if (g.slot >= 0 && g.slot < table.slots.size()) { + table.slots.set(g.slot, null); + } + g.slot = -1; + g.instanceIndex = -1; + } + table.instanceList.clear(); + for (int i = 0; i < table.slots.size(); i++) { + SectionGeom g = table.slots.get(i); + if (g != null) { + g.instanceIndex = table.instanceList.size(); + table.instanceList.add(table.instanceFor(g, blockX, blockY, blockZ)); + } + } + table.instances = table.instanceList; + retire(ctx, ctx.gpuExecutor().latestGraphicsUse(), doomed); + } + private record DispatchContext(RtContext ctx, ClientLevel level, BlockStateModelSet modelSet, FluidStateModelSet fluidModelSet, BlockColors blockColors, SpriteFinder blockSpriteFinder) { diff --git a/src/main/resources/caustica/color/luts/hdr_agx_punchy_rec2020.bin b/src/main/resources/caustica/color/luts/hdr_agx_punchy_rec2020.bin new file mode 100644 index 000000000..93d7e827e Binary files /dev/null and b/src/main/resources/caustica/color/luts/hdr_agx_punchy_rec2020.bin differ diff --git a/src/main/resources/caustica/color/luts/sdr_agx_base_rec709.bin b/src/main/resources/caustica/color/luts/sdr_agx_base_rec709.bin new file mode 100644 index 000000000..c8e383135 Binary files /dev/null and b/src/main/resources/caustica/color/luts/sdr_agx_base_rec709.bin differ diff --git a/src/main/resources/caustica/color/luts/sdr_agx_punchy_rec709.bin b/src/main/resources/caustica/color/luts/sdr_agx_punchy_rec709.bin new file mode 100644 index 000000000..2b6ed056a Binary files /dev/null and b/src/main/resources/caustica/color/luts/sdr_agx_punchy_rec709.bin differ diff --git a/tools/bake_display_lut.py b/tools/bake_display_lut.py index a06c055e1..4dc7de59f 100644 --- a/tools/bake_display_lut.py +++ b/tools/bake_display_lut.py @@ -50,6 +50,39 @@ # ACES 2.0's built-in BT.2020 transforms use these fixed HDR mastering targets. HDR_REC2020_NITS = [500, 1000, 2000, 4000] +# sobotka/AgX, the config the "Ultra Realism Tonemapper for UE5" guide installs into Unreal via OCIO. +# Baked here as an alternative SDR view transform so the same look is available without a second +# runtime dependency: the guide's Unreal setup and this LUT resolve to the same image transform. +# +# SDR ONLY, and not an oversight. Every view in this config terminates in a display encoding for a +# ~100 nit SDR device (sRGB, BT.1886, Display P3); AgX has no HDR output transform, so there is +# nothing to bake for the PQ path. The HDR LUTs stay ACES 2.0 whatever this is set to. +AGX_CONFIG_URL = "https://github.com/sobotka/AgX" # clone and pass --agx-config /config.ocio + +# ACEScg (AP1/D60) -> linear BT.709 (D65). This is the exact inverse of BT709_TO_ACESCG_* in +# world_common.slang rather than a matrix from a table: the renderer converts sRGB assets INTO ACEScg +# with that matrix, and the AgX config's input space is Linear BT.709, so inverting the renderer's own +# matrix makes the round trip exact instead of leaving a small residual tint on neutrals. +ACESCG_TO_BT709 = [ + [1.70505091, -0.62179208, -0.08325886], + [-0.13025641, 1.14080473, -0.01054832], + [-0.02400335, -0.12896898, 1.15297234], +] + +AGX_LUTS = [ + dict( + name="sdr_agx_punchy_rec709", + agx_space="Appearance Punchy sRGB", + note="SDR output, AgX Punchy appearance, sRGB-encoded BT.709. The guide's default choice; " + "less aggressive desaturation than AgX Base.", + ), + dict( + name="sdr_agx_base_rec709", + agx_space="AgX Base", + note="SDR output, AgX Base, sRGB-encoded BT.709. Stronger highlight desaturation than Punchy.", + ), +] + LUTS = [ dict( name="sdr_aces2_rec709", @@ -83,7 +116,101 @@ def shaper_axis(size: int) -> np.ndarray: return np.exp2(stops) +# BT.709 (D65) -> BT.2020 (D65). Both are D65 so this is a pure primaries change, no chromatic +# adaptation, which is why it can be a plain 3x3 with no CAT term. +BT709_TO_BT2020 = [ + [0.62740390, 0.32928304, 0.04331307], + [0.06909729, 0.91954040, 0.01136232], + [0.01639144, 0.08801331, 0.89559525], +] + +# SMPTE ST 2084 (PQ) constants. +PQ_M1 = 2610.0 / 16384.0 +PQ_M2 = 128.0 * 2523.0 / 4096.0 +PQ_C1 = 3424.0 / 4096.0 +PQ_C2 = 32.0 * 2413.0 / 4096.0 +PQ_C3 = 32.0 * 2392.0 / 4096.0 +PQ_PEAK_NITS = 10000.0 + +# HDR AgX. READ THIS BEFORE TRUSTING THE NAME. +# +# sobotka's config has no HDR view transform -- every view in it terminates in a ~100 nit SDR display +# encoding. So this is NOT a port of an upstream AgX HDR view, because no such thing exists. It is a +# construction: +# +# ACEScg -> AgX Punchy (display code values) -> inverse sRGB EOTF -> display-linear 0..1 +# -> scale so display white lands at DIFFUSE_WHITE_NITS -> BT.2020 -> PQ encode +# +# What that gives you is AgX's tone curve and its characteristic highlight desaturation, presented +# through the PQ pipe, with diffuse white placed at a sane HDR level instead of being crushed to the +# SDR 100 nit assumption. What it does NOT give you is extra highlight range: AgX's sigmoid still +# rolls off to its own white point, so specular highlights do not extend to your display's peak the +# way ACES 2.0's HDR output transforms do. Extending the sigmoid's shoulder to reach 1000+ nits would +# mean redesigning AgX's tone curve, which is a different thing from baking it. +# +# Use this if you want the AgX look on an HDR display. Use ACES 2.0 if you want HDR highlight range. +DIFFUSE_WHITE_NITS = 203.0 # ITU-R BT.2408 reference diffuse white + +AGX_HDR_LUTS = [ + dict( + name="hdr_agx_punchy_rec2020", + agx_space="Appearance Punchy sRGB", + note="AgX Punchy tone curve and look, BT.2020 primaries, PQ encoded, diffuse white at " + "203 nits. See the caveat above: AgX look, not AgX-with-HDR-range.", + ), +] + + +def srgb_eotf_inverse(code): + """Display code values -> display-linear 0..1.""" + code = np.clip(code, 0.0, 1.0) + return np.where(code <= 0.04045, code / 12.92, ((code + 0.055) / 1.055) ** 2.4) + + +def pq_encode(nits): + """Absolute nits -> PQ code values.""" + y = np.clip(nits, 0.0, PQ_PEAK_NITS) / PQ_PEAK_NITS + num = PQ_C1 + PQ_C2 * (y ** PQ_M1) + den = 1.0 + PQ_C3 * (y ** PQ_M1) + return (num / den) ** PQ_M2 + + +def make_agx_hdr_processor(agx_cfg: "OCIO.Config", spec: dict): + """Wraps the SDR AgX processor, then does the display-linear -> PQ stage in numpy.""" + inner = make_agx_processor(agx_cfg, spec) + + class _AgxHdr: + def applyRGB(self, rgb): + inner.applyRGB(rgb) + arr = np.asarray(rgb, dtype=np.float32).reshape(-1, 3) + display_linear = srgb_eotf_inverse(arr) + nits = display_linear * DIFFUSE_WHITE_NITS + rec2020 = nits @ np.array(BT709_TO_BT2020, dtype=np.float64).T + encoded = pq_encode(np.clip(rec2020, 0.0, None)).astype(np.float32) + arr[:] = encoded + np.asarray(rgb, dtype=np.float32).reshape(-1, 3)[:] = arr + + return _AgxHdr() + + +def make_agx_processor(agx_cfg: "OCIO.Config", spec: dict): + """ACEScg -> linear BT.709 -> the requested AgX appearance, as one processor.""" + matrix = [0.0] * 16 + for row in range(3): + for col in range(3): + matrix[row * 4 + col] = ACESCG_TO_BT709[row][col] + matrix[15] = 1.0 + grp = OCIO.GroupTransform() + grp.appendTransform(OCIO.MatrixTransform(matrix)) + grp.appendTransform(OCIO.ColorSpaceTransform(src="Linear BT.709", dst=spec["agx_space"])) + return agx_cfg.getProcessor(grp).getDefaultCPUProcessor() + + def make_processor(cfg: "OCIO.Config", spec: dict): + if spec.get("agx_hdr"): + return make_agx_hdr_processor(cfg, spec) + if "agx_space" in spec: + return make_agx_processor(cfg, spec) if "display_view" in spec: display, view = spec["display_view"] return cfg.getProcessor(SOURCE_SPACE, display, view, OCIO.TRANSFORM_DIR_FORWARD).getDefaultCPUProcessor() @@ -199,8 +326,30 @@ def main() -> None: metavar="PATH", help="replace the default look package's LMT from a normalized log-shaper .cube, then exit", ) + parser.add_argument( + "--agx-config", + type=Path, + metavar="PATH", + help=f"bake the AgX SDR view transforms from a sobotka/AgX config.ocio ({AGX_CONFIG_URL}), " + "then exit. The ACES 2.0 LUTs are left untouched.", + ) args = parser.parse_args() + if args.agx_config is not None: + agx_cfg = OCIO.Config.CreateFromFile(str(args.agx_config)) + digest = hashlib.sha256(Path(args.agx_config).read_bytes()).hexdigest() + print(f"baking AgX view transforms from {args.agx_config}; config SHA-256={digest}") + for spec in AGX_LUTS: + print(f"baking {spec['name']}: {spec['note']}") + rgb = bake_one(agx_cfg, spec, LUT_SIZE) + write_lut(OUT_DIR / f"{spec['name']}.bin", LUT_SIZE, rgb) + for spec in AGX_HDR_LUTS: + print(f"baking {spec['name']}: {spec['note']}") + spec = dict(spec, agx_hdr=True) + rgb = bake_one(agx_cfg, spec, LUT_SIZE) + write_lut(OUT_DIR / f"{spec['name']}.bin", LUT_SIZE, rgb) + return + if args.import_lmt is not None: source_path = args.import_lmt size, rgb, title = read_shaper_cube(source_path)