From ee8b9f737e48c6888c4fa3da7e8ebd6b53962a23 Mon Sep 17 00:00:00 2001 From: PEQHUB Date: Mon, 10 Aug 2026 10:14:18 -0500 Subject: [PATCH 1/6] feat: add PsychoV24 display controls --- .github/workflows/ci.yml | 4 + THIRD_PARTY_NOTICES.md | 31 ++ shaders/common/display_common.slang | 26 +- shaders/pipelines/display/main.comp.slang | 80 ++- shaders/pipelines/display/psychov24.slang | 514 ++++++++++++++++++ shaders/pipelines/display/tone_mapping.slang | 307 +++++++++++ .../pipelines/exposure_hist/main.comp.slang | 3 +- .../exposure_resolve/main.comp.slang | 11 + .../comfyfluffy/caustica/CausticaConfig.java | 203 ++++++- .../client/RtToneMappingOptionsScreen.java | 165 ++++++ .../caustica/client/RtVideoOptions.java | 284 +++++++++- .../mixin/VideoSettingsScreenMixin.java | 8 + .../caustica/mixin/VulkanGpuSurfaceMixin.java | 6 +- .../comfyfluffy/caustica/rt/RtComposite.java | 24 +- .../comfyfluffy/caustica/rt/RtContext.java | 19 +- .../dev/comfyfluffy/caustica/rt/RtHdr.java | 13 +- .../rt/pipeline/RtDisplayPipeline.java | 35 +- .../caustica/rt/pipeline/RtExposure.java | 69 ++- .../rt/pipeline/RtExposurePipeline.java | 60 +- .../caustica/rt/pipeline/RtToneMapping.java | 366 +++++++++++++ .../resources/assets/caustica/lang/en_us.json | 91 +++- .../caustica/CausticaConfigTest.java | 64 ++- .../comfyfluffy/caustica/rt/RtHdrTest.java | 2 +- .../pipeline/RtDisplayShaderContractTest.java | 40 ++ .../rt/pipeline/RtExposureEv100Test.java | 31 ++ .../rt/pipeline/RtExposurePercentileTest.java | 71 +++ .../rt/pipeline/RtToneMappingTest.java | 109 ++++ 27 files changed, 2553 insertions(+), 83 deletions(-) create mode 100644 shaders/pipelines/display/psychov24.slang create mode 100644 shaders/pipelines/display/tone_mapping.slang create mode 100644 src/main/java/dev/comfyfluffy/caustica/client/RtToneMappingOptionsScreen.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMapping.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureEv100Test.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePercentileTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMappingTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd78a5b..c741a161 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: paths: - ".github/workflows/**" - "build.gradle" + - "buildSrc/**" - "settings.gradle" - "gradle.properties" - "gradle/**" @@ -15,10 +16,12 @@ on: - "native/**" - "shaders/**" - "src/**" + - "THIRD_PARTY_NOTICES.md" pull_request: paths: - ".github/workflows/**" - "build.gradle" + - "buildSrc/**" - "settings.gradle" - "gradle.properties" - "gradle/**" @@ -27,6 +30,7 @@ on: - "native/**" - "shaders/**" - "src/**" + - "THIRD_PARTY_NOTICES.md" env: DLSS_SDK_REF: v310.7.0 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d45012be..d299050f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,6 +4,37 @@ Caustica's project-owned code is licensed under `LGPL-3.0-or-later`. This file documents third-party components and license boundaries that are not changed by Caustica's license. +## PsychoV24 Test24 adaptation + +The PsychoV24 Test24 tone-mapping adaptation in +`shaders/pipelines/display/psychov24.slang` is derived from RenoDX commit +`fc85b7b15585050442ba35412597ecefc9e04cea`. + +Copyright (C) 2026 Carlos Lopez. SPDX-License-Identifier: MIT. + +The adaptation remains subject to the MIT license. The complete license text is +available at : + +Copyright (c) 2026 Carlos Lopez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## NVIDIA DLSS / NGX SDK Caustica can build and distribute release artifacts that include NVIDIA DLSS/NGX diff --git a/shaders/common/display_common.slang b/shaders/common/display_common.slang index 54ef9fe1..9a2d81c8 100644 --- a/shaders/common/display_common.slang +++ b/shaders/common/display_common.slang @@ -73,10 +73,30 @@ public struct DisplayPush { public float hdrPeakNits; // mastering peak baked into the currently bound HDR ACES LUT public int lookEnabled; // 0 = identity, 1 = apply the scene-referred ACES look LUT public float lookLutSize; // lookLut texels per axis (independent of the output-transform LUT size) - // Scene-referred blurred highlight signal added before the LMT. The bloom pyramid's level 0 holds the - // 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. + // Scene-referred blurred highlight signal added before display rendering. The bloom pyramid's level 0 + // holds the 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; + public int sdrMode; // 0 = ACES 2.0 LUT; positive values select local analytical operators + public int hdrMode; // 0 = ACES 2.0 LUT; 2 = PsychoV24; 3 = BT.2390 + public float paperWhiteNits; + public float headroom; + public float sdrParam0; + public float sdrParam1; + public float sdrParam2; + public float sdrParam3; + public float sdrParam4; + public float sdrParam5; + public float sdrParam6; + public float sdrParam7; + public float hdrParam0; + public float hdrParam1; + public float hdrParam2; + public float hdrParam3; + public float hdrParam4; + public float hdrParam5; + public float hdrParam6; + public float hdrParam7; }; public struct BloomPush { diff --git a/shaders/pipelines/display/main.comp.slang b/shaders/pipelines/display/main.comp.slang index b92d243d..47588b0a 100644 --- a/shaders/pipelines/display/main.comp.slang +++ b/shaders/pipelines/display/main.comp.slang @@ -1,10 +1,11 @@ -// Maps the display-res scene-linear ACEScg RT image to sRGB SDR and, when enabled, PQ/BT.2020 HDR, -// via baked ACES 2.0 display-transform LUTs (see tools/bake_display_lut.py). Tonemap seam: the path tracer and DLSS-RR use scene-linear -// ACEScg; exposure is applied from the compositor-owned 1x1 image right here, followed by the selected -// scene-referred ACES look (LMT), then the shared ACES 2.0 output transform for each display. +// Maps the display-res scene-linear ACEScg RT image to sRGB SDR and, when enabled, PQ/BT.2020 HDR. +// The path tracer and DLSS-RR use scene-linear ACEScg; exposure is applied from the compositor-owned +// 1x1 image here. ACES 2.0 uses the baked display-transform LUTs and the package's scene-referred look +// (LMT); local analytical modes own their complete display rendering. import display_common; import bindings; +import tone_mapping; [[vk::push_constant]] DisplayPush pc; @@ -99,6 +100,55 @@ float3 displayGammaSdr(float3 displayColor) { return clamp(srgbEncode(luminanceGamma(srgbDecode(code), BT709_LUMA)), 0.0, 1.0); } +// Local analytical operators were authored against scene-linear BT.709. The transport and the +// ACES 2.0 LUTs use ACEScg/AP1, so this conversion is explicit at the opt-in local seam; the default +// ACES 2.0 path never takes it. +static const float3 ACESCG_TO_BT709_R = float3(1.70505095, -0.62179214, -0.08325887); +static const float3 ACESCG_TO_BT709_G = float3(-0.13025641, 1.14080477, -0.01054832); +static const int PSYCHOV24_SDR_MODE = 8; +static const float3 ACESCG_TO_BT709_B = float3(-0.02400336, -0.12896897, 1.15297234); +float3 acescgToBt709Signed(float3 acesCg) { + return float3( + dot(acesCg, ACESCG_TO_BT709_R), + dot(acesCg, ACESCG_TO_BT709_G), + dot(acesCg, ACESCG_TO_BT709_B)); +} + +ToneMappingParameters sdrToneParameters() { + ToneMappingParameters p; + p.param0 = pc.sdrParam0; + p.param1 = pc.sdrParam1; + p.param2 = pc.sdrParam2; + p.param3 = pc.sdrParam3; + p.param4 = pc.sdrParam4; + p.param5 = pc.sdrParam5; + p.param6 = pc.sdrParam6; + p.param7 = pc.sdrParam7; + return p; +} + +ToneMappingParameters hdrToneParameters() { + ToneMappingParameters p; + p.param0 = pc.hdrParam0; + p.param1 = pc.hdrParam1; + p.param2 = pc.hdrParam2; + p.param3 = pc.hdrParam3; + p.param4 = pc.hdrParam4; + p.param5 = pc.hdrParam5; + p.param6 = pc.hdrParam6; + p.param7 = pc.hdrParam7; + return p; +} + +float3 localSdrToneMap(float3 exposedAcesCg) { + float3 bt709Signed = acescgToBt709Signed(exposedAcesCg); + ToneMappingParameters p = sdrToneParameters(); + float3 mapped = pc.sdrMode == PSYCHOV24_SDR_MODE + ? toneMapPsychoV24Sdr(bt709Signed, p) + : applyClassicToneMapper(pc.sdrMode, max(bt709Signed, float3(0.0)), p); + return displayGammaSdr(srgbEncode(clamp(mapped, 0.0, 1.0))); +} + static const float PQ_M1 = 0.1593017578125; static const float PQ_M2 = 78.84375; static const float PQ_C1 = 0.8359375; @@ -126,6 +176,15 @@ float3 displayGammaHdr(float3 pqColor) { return clamp(pqEncode(adjustedNits), 0.0, 1.0); } +float3 localHdrToneMap(float3 exposedAcesCg) { + float3 bt709Signed = acescgToBt709Signed(exposedAcesCg); + ToneMappingParameters p = hdrToneParameters(); + if (pc.hdrMode == 2) { + return toneMapPsychoV24Hdr(bt709Signed, pc.paperWhiteNits, pc.headroom, p); + } + return toneMapBt2390Hdr(max(bt709Signed, float3(0.0)), pc.paperWhiteNits, pc.headroom); +} + // Exposure -> log2 shaper -> trilinear LUT fetch. The LUT already bakes in ACES 2.0's gamut mapping to // BT.709 and the sRGB OETF, so its output goes straight to outputImage. Single mip (RtToneLut), so an // explicit level-0 SampleLevel is exact, not an approximation. @@ -155,10 +214,17 @@ void main(uint3 dispatchId : SV_DispatchThreadID) { 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 = exposedAcesCg; + if (pc.sdrMode == 0 || (pc.hdrEnabled != 0 && pc.hdrMode == 0)) { + lookedAcesCg = applyLook(exposedAcesCg); + } + outputImage[pix] = pc.sdrMode == 0 + ? float4(tonemap(lookedAcesCg), 1.0) + : float4(localSdrToneMap(exposedAcesCg), 1.0); if (pc.hdrEnabled != 0) { - hdrImage[pix] = float4(tonemapHdr(lookedAcesCg), 1.0); + hdrImage[pix] = pc.hdrMode == 0 + ? float4(tonemapHdr(lookedAcesCg), 1.0) + : float4(displayGammaHdr(localHdrToneMap(exposedAcesCg)), 1.0); } } diff --git a/shaders/pipelines/display/psychov24.slang b/shaders/pipelines/display/psychov24.slang new file mode 100644 index 00000000..3b2a591f --- /dev/null +++ b/shaders/pipelines/display/psychov24.slang @@ -0,0 +1,514 @@ +/* + * Adapted from RenoDX PsychoV Test24. + * Pinned source commit: fc85b7b15585050442ba35412597ecefc9e04cea + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + * + * Caustica profile: fixed 0.18 adaptation/background anchors, SDR peak 1.0, + * HDR peak from the configured headroom, and gamut hue restoration disabled. + */ + +static const float PSYCHO24_EPSILON = 1.0e-6; +static const float PSYCHO24_TWO_PI = 6.2831853071795864769; +static const float PSYCHO24_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7; +static const float PSYCHO24_REFERENCE_CENTERED_RANGE_SIDE_COUNT = 2.0; +static const float PSYCHO24_HEADROOM_RATIO_FALLBACK = 1.0; +static const float PSYCHO24_MIN_AUTO_COMPRESSION = 1.0; +static const float PSYCHO24_MIN_MANUAL_COMPRESSION = 1.0e-6; +static const float PSYCHO24_AUTO_COMPRESSION_SENTINEL = 0.0; +static const float PSYCHO24_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.0; +static const float PSYCHO24_SHADOW_GRADE_RANGE_STOPS = 4.0; + +static const float3 PSYCHO24_LMS_WEIGHTS = float3(0.68990272, 0.34832189, 0.0371597069161); + +static const float3x3 PSYCHO24_BT709_TO_LMS_WEIGHTED = float3x3( + 0.199799812973072, 0.481032356328408, 0.0526887528474167, + 0.0314129769005886, 0.246385149505685, 0.0390828120286937, + 0.000577043431732311, 0.00199210614746666, 0.0189439379660955); + +static const float3x3 PSYCHO24_BT2020_TO_LMS_WEIGHTED = float3x3( + 0.270896642297020, 0.422251540658867, 0.0403727391345614, + 0.0207641953210756, 0.256739246826207, 0.0393774962470899, + 0.000366210725727965, 0.0000124019842897904, 0.0211344748353781); + +static const float3x3 PSYCHO24_LMS_WEIGHTED_TO_BT709 = float3x3( + 7.20046392938217, -14.1316973475976, 9.12814468829434, + -0.898217764419057, 5.89038676696474, -9.65411232470627, + -0.124875582354962, -0.188961374460433, 53.5244932911426); + +static const float3x3 PSYCHO24_XYZ_TO_LMS_WEIGHTED = float3x3( + 0.1842387318611145, 0.58448493480682373, -0.023942500352859497, + -0.13482454419136047, 0.40594476461410522, 0.035885117948055267, + 0.00099319696892052889, -0.0010141372913494706, 0.019818264991044998); +static const float2 PSYCHO24_D65_XY = float2(0.31272, 0.32903); + +float psycho24DivideSafe(float numerator, float denominator, float fallback) { + return denominator == 0.0 ? fallback : numerator / denominator; +} + +float3 psycho24DivideSafe(float3 numerator, float3 denominator, float3 fallback) { + return float3( + denominator.x == 0.0 ? fallback.x : numerator.x / denominator.x, + denominator.y == 0.0 ? fallback.y : numerator.y / denominator.y, + denominator.z == 0.0 ? fallback.z : numerator.z / denominator.z); +} + +float3 psycho24LmsFromBt709(float3 bt709) { + return mul(PSYCHO24_BT709_TO_LMS_WEIGHTED, bt709) / PSYCHO24_LMS_WEIGHTS; +} + +float3 psycho24Bt709FromLms(float3 lms) { + return mul(PSYCHO24_LMS_WEIGHTED_TO_BT709, lms * PSYCHO24_LMS_WEIGHTS); +} + +float3 psycho24MbFromWeighted(float3 weightedLms) { + float y = max(weightedLms.x + weightedLms.y, 0.0); + float inverseY = psycho24DivideSafe(1.0, y, 0.0); + return float3(weightedLms.x * inverseY, weightedLms.z * inverseY, y); +} + +float3 psycho24WeightedFromMb(float3 mb) { + return float3(mb.x, 1.0 - mb.x, mb.y) * mb.z; +} + +float3 psycho24ToAdaptiveRelativeWeightedLms(float3 lmsInput, float3 adaptiveStateLms) { + return psycho24DivideSafe(lmsInput * PSYCHO24_LMS_WEIGHTS, + adaptiveStateLms, float3(0.0)); +} + +float3 psycho24FromAdaptiveRelativeWeightedLms(float3 relativeWeightedLms, + float3 adaptiveStateLms) { + return relativeWeightedLms * max(adaptiveStateLms, float3(PSYCHO24_EPSILON)); +} + +float3 psycho24UnweighLms(float3 weightedLms) { + return weightedLms / PSYCHO24_LMS_WEIGHTS; +} + +float2 psycho24Cie1702WhiteChromaticity() { + float2 xy = PSYCHO24_D65_XY; + float3 xyz = float3(xy.x / xy.y, 1.0, (1.0 - xy.x - xy.y) / xy.y); + return psycho24MbFromWeighted(mul(PSYCHO24_XYZ_TO_LMS_WEIGHTED, xyz)).xy; +} + +static const float PSYCHO24_CIE1702_RAY_T_MAX = 1.0e20; +static const float PSYCHO24_MB_NEAR_WHITE_EPSILON = 1.0e-14; +static const float PSYCHO24_INTERVAL_MAX = 3.402823466e+38; +static const int PSYCHO24_CIE1702_EDGE_COUNT = 7; +static const float2 PSYCHO24_CIE1702_HALFSPACE_NORMALS[PSYCHO24_CIE1702_EDGE_COUNT] = { + float2(-0.043889, -0.006807), + float2(-0.007821, -0.008564), + float2(-0.000604, -0.007942), + float2(0.0, -0.080835), + float2(0.953597, 0.307020), + float2(-0.060969, 0.019752), + float2(-0.106895, 0.004035) +}; +static const float PSYCHO24_CIE1702_HALFSPACE_NUMERATORS[PSYCHO24_CIE1702_EDGE_COUNT] = { + 0.0065035249, + 0.00104900495, + 0.000207697044, + 0.00165556648, + 0.252472349, + 0.0241967351, + 0.0199621232 +}; + +float psycho24RayExitCie1702(float2 origin, float2 direction) { + if (dot(direction, direction) <= PSYCHO24_MB_NEAR_WHITE_EPSILON) { + return PSYCHO24_CIE1702_RAY_T_MAX; + } + + float2 whiteToOrigin = psycho24Cie1702WhiteChromaticity() - origin; + float tBest = PSYCHO24_CIE1702_RAY_T_MAX; + bool hitAny = false; + for (int i = 0; i < PSYCHO24_CIE1702_EDGE_COUNT; ++i) { + float2 normal = PSYCHO24_CIE1702_HALFSPACE_NORMALS[i]; + float denominator = dot(normal, direction); + float numerator = PSYCHO24_CIE1702_HALFSPACE_NUMERATORS[i] + + dot(normal, whiteToOrigin); + float t = denominator > 1.0e-8 + ? numerator * rcp(denominator) + : PSYCHO24_CIE1702_RAY_T_MAX; + tBest = min(tBest, t); + hitAny = hitAny || denominator > 1.0e-8; + } + return hitAny ? max(tBest, 0.0) : PSYCHO24_CIE1702_RAY_T_MAX; +} + +float psycho24RayExitCie1702FromWhite(float2 direction) { + return psycho24RayExitCie1702(psycho24Cie1702WhiteChromaticity(), direction); +} + +float psycho24Cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +bool psycho24RaySegmentHit2D(float2 origin, float2 direction, float2 a, float2 b, + out float tHit) { + tHit = 0.0; + float2 edge = b - a; + float denominator = psycho24Cross2(direction, edge); + if (abs(denominator) <= 1.0e-20) { + return false; + } + float2 aToOrigin = a - origin; + float t = psycho24Cross2(aToOrigin, edge) / denominator; + float u = psycho24Cross2(aToOrigin, direction) / denominator; + if (t < 0.0 || u < 0.0 || u > 1.0) { + return false; + } + tHit = t; + return true; +} + +float3 psycho24RgbToWeightedPrimary(float3x3 rgbToLmsWeighted, uint primaryIndex) { + return float3(rgbToLmsWeighted[0][primaryIndex], + rgbToLmsWeighted[1][primaryIndex], rgbToLmsWeighted[2][primaryIndex]); +} + +float2 psycho24MbFromWeightedPrimary(float3 weightedPrimary) { + return psycho24MbFromWeighted(weightedPrimary).xy; +} + +void psycho24MakeRgbTriangleInMbAdaptiveWeighted(float3x3 rgbToLmsWeighted, + float3 adaptiveStateLms, out float2 r, out float2 g, out float2 b) { + r = psycho24MbFromWeightedPrimary(psycho24RgbToWeightedPrimary(rgbToLmsWeighted, 0) + / adaptiveStateLms); + g = psycho24MbFromWeightedPrimary(psycho24RgbToWeightedPrimary(rgbToLmsWeighted, 1) + / adaptiveStateLms); + b = psycho24MbFromWeightedPrimary(psycho24RgbToWeightedPrimary(rgbToLmsWeighted, 2) + / adaptiveStateLms); +} + +float psycho24RayMaxTRgbTriangleInMb(float2 origin, float2 direction, + float2 r, float2 g, float2 b, out bool hasSolution) { + hasSolution = false; + if (dot(direction, direction) <= PSYCHO24_MB_NEAR_WHITE_EPSILON) { + return 0.0; + } + + float tBest = PSYCHO24_INTERVAL_MAX; + float tHit; + bool hitAny = false; + if (psycho24RaySegmentHit2D(origin, direction, r, g, tHit)) { + tBest = min(tBest, tHit); + hitAny = true; + } + if (psycho24RaySegmentHit2D(origin, direction, g, b, tHit)) { + tBest = min(tBest, tHit); + hitAny = true; + } + if (psycho24RaySegmentHit2D(origin, direction, b, r, tHit)) { + tBest = min(tBest, tHit); + hitAny = true; + } + hasSolution = hitAny; + return hitAny ? max(tBest, 0.0) : 0.0; +} + +float psycho24NeutwoPeakClip(float x, float peak, float clip) { + float peakSafe = max(peak, 0.0); + float clipSafe = max(clip, peakSafe); + float x2 = x * x; + float clip2 = clipSafe * clipSafe; + float peak2 = peakSafe * peakSafe; + float denominatorSquared = mad(x2, clip2 - peak2, clip2 * peak2); + return clipSafe * peakSafe * x * rsqrt(max(denominatorSquared, 1.0e-20)); +} + +float psycho24NeutwoScaleFromRayT(float tPeak, float tClip) { + float tPeakSafe = max(tPeak, 0.0); + float tClipSafe = max(tClip, tPeakSafe); + return saturate(psycho24NeutwoPeakClip(1.0, tPeakSafe, tClipSafe)); +} + +float psycho24SoftCompressionActivationFromRayT(float tPeak) { + float outside = 1.0 - saturate(tPeak); + return psycho24DivideSafe(outside, outside + 0.08, 0.0); +} + +float3 psycho24ClampWeightedLmsToCie1702(float3 weightedInput) { + float3 weightedClamped = max(weightedInput, float3(0.0)); + float3 mb = psycho24MbFromWeighted(weightedClamped); + float y = mb.z; + if (!(y > 1.0e-20)) { + return float3(weightedClamped.x, weightedClamped.y, 0.0); + } + + float2 white = psycho24Cie1702WhiteChromaticity(); + float2 direction = mb.xy - white; + if (dot(direction, direction) <= PSYCHO24_MB_NEAR_WHITE_EPSILON) { + return weightedClamped; + } + float tClip = psycho24RayExitCie1702FromWhite(direction); + float2 lsOut = white + direction * min(1.0, tClip); + return psycho24WeightedFromMb(float3(lsOut, y)); +} + +float3 psycho24GamutCompressWeightedLmsCore(float3 weightedInput, + float3x3 boundRgbToLmsWeighted, float3 adaptiveStateLms, float strength) { + float3 weightedClamped = psycho24ClampWeightedLmsToCie1702(max(weightedInput, float3(0.0))); + float3 mb = psycho24MbFromWeighted(weightedClamped); + float y = mb.z; + if (!(y > 1.0e-20)) { + return float3(weightedClamped.x, weightedClamped.y, 0.0); + } + + float2 white = psycho24Cie1702WhiteChromaticity(); + float2 direction = mb.xy - white; + if (dot(direction, direction) <= PSYCHO24_MB_NEAR_WHITE_EPSILON) { + return weightedClamped; + } + + float2 boundR; + float2 boundG; + float2 boundB; + psycho24MakeRgbTriangleInMbAdaptiveWeighted( + boundRgbToLmsWeighted, adaptiveStateLms, boundR, boundG, boundB); + bool hasPeak; + float tPeak = psycho24RayMaxTRgbTriangleInMb( + white, direction, boundR, boundG, boundB, hasPeak); + float tClip = psycho24RayExitCie1702FromWhite(direction); + if (!hasPeak) { + tPeak = tClip; + } + + float tHard = saturate(tPeak); + float tSoft = psycho24NeutwoScaleFromRayT(min(tPeak, tClip), tClip); + float softMix = saturate(strength) * psycho24SoftCompressionActivationFromRayT(tPeak); + float tFinal = lerp(tHard, tSoft, softMix); + return psycho24WeightedFromMb(float3(white + tFinal * direction, y)); +} + +float psycho24YfFromLms(float3 lms) { + float3 weighted = lms * PSYCHO24_LMS_WEIGHTS; + return max(weighted.x + weighted.y, PSYCHO24_EPSILON); +} + +float psycho24QuinticUnitRamp(float t) { + t = saturate(t); + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); +} + +float psycho24HighlightsScalarV4(float x, float highlights, float adaptedAnchorYf) { + if (highlights == 1.0) { + return x; + } + float t = 0.0; + if (x > adaptedAnchorYf) { + float referenceRangeLog2 = log2(PSYCHO24_HIGHLIGHT_GRADE_REFERENCE_WHITE + / max(adaptedAnchorYf, PSYCHO24_EPSILON)); + t = saturate(log2(x / max(adaptedAnchorYf, PSYCHO24_EPSILON)) + / max(referenceRangeLog2, PSYCHO24_EPSILON)); + } + t = psycho24QuinticUnitRamp(t); + float ratio = max(x / max(adaptedAnchorYf, PSYCHO24_EPSILON), PSYCHO24_EPSILON); + if (highlights > 1.0) { + return lerp(x, adaptedAnchorYf * pow(ratio, highlights), t); + } + float b = adaptedAnchorYf * pow(ratio, 2.0 - highlights); + return psycho24DivideSafe(x * x, lerp(x, b, t), x); +} + +float psycho24ShadowsScalarV4(float x, float shadows, float adaptedAnchorYf) { + if (shadows == 1.0) { + return x; + } + float ratio = max(psycho24DivideSafe(x, adaptedAnchorYf, 0.0), 0.0); + float baseTerm = x * adaptedAnchorYf; + float baseScale = psycho24DivideSafe(baseTerm, ratio, 0.0); + float shadowFloor = adaptedAnchorYf * exp2(-PSYCHO24_SHADOW_GRADE_RANGE_STOPS); + float t = 1.0; + if (x > shadowFloor) { + t = saturate(log2(x / max(adaptedAnchorYf, PSYCHO24_EPSILON)) + / log2(shadowFloor / max(adaptedAnchorYf, PSYCHO24_EPSILON))); + } + t = psycho24QuinticUnitRamp(t); + if (shadows > 1.0) { + float raised = x * (1.0 + psycho24DivideSafe(baseTerm, + pow(max(ratio, PSYCHO24_EPSILON), shadows), 0.0)); + float reference = x * (1.0 + baseScale); + return x + (raised - reference) * t; + } + float lowered = x * (1.0 - psycho24DivideSafe(baseTerm, + pow(max(ratio, PSYCHO24_EPSILON), 2.0 - shadows), 0.0)); + float reference = x * (1.0 - baseScale); + return x + (lowered - reference) * t; +} + +float psycho24AutoCompressionFromCenteredReferenceRange(float anchorOutYf, float peakYf) { + float peakOverAnchor = psycho24DivideSafe(max(peakYf, PSYCHO24_EPSILON), + max(anchorOutYf, PSYCHO24_EPSILON), PSYCHO24_HEADROOM_RATIO_FALLBACK); + peakOverAnchor = max(peakOverAnchor, 1.0 + PSYCHO24_EPSILON); + float referenceOneSideRangeLog10 = PSYCHO24_REFERENCE_SIMULTANEOUS_RANGE_LOG10 + / PSYCHO24_REFERENCE_CENTERED_RANGE_SIDE_COUNT; + float actualAboveAdaptationRangeLog10 = max(log10(peakOverAnchor), PSYCHO24_EPSILON); + return max(referenceOneSideRangeLog10 / actualAboveAdaptationRangeLog10, + PSYCHO24_MIN_AUTO_COMPRESSION); +} + +float3 psycho24ApplyAdaptiveMbPurity(float3 lmsInput, float3 adaptiveNeutralLms, + float purityDelta) { + if (abs(purityDelta - 1.0) <= 1.0e-5) { + return lmsInput; + } + float3 relativeWeighted = psycho24ToAdaptiveRelativeWeightedLms(lmsInput, adaptiveNeutralLms); + float3 mb = psycho24MbFromWeighted(relativeWeighted); + float3 mbNeutral = psycho24MbFromWeighted(PSYCHO24_LMS_WEIGHTS); + float2 mbScaled = lerp(mbNeutral.xy, mb.xy, purityDelta); + return psycho24UnweighLms(psycho24FromAdaptiveRelativeWeightedLms( + psycho24WeightedFromMb(float3(mbScaled, mb.z)), adaptiveNeutralLms)); +} + +static const uint PSYCHO24_MANUAL_HUE_COUNT = 23u; +static const float PSYCHO24_MANUAL_HUE_POSITION[PSYCHO24_MANUAL_HUE_COUNT] = { + 0.01071375, 0.10705012, 0.12795984, 0.15335225, 0.18766853, 0.22076293, + 0.24936653, 0.27634237, 0.29474511, 0.31129214, 0.35078118, 0.39136371, + 0.47262991, 0.49426816, 0.54698948, 0.60705013, 0.68311772, 0.81129214, + 0.91306421, 0.93498424, 0.94625976, 0.96664602, 0.97262991 +}; +static const float PSYCHO24_MANUAL_HUE_X[PSYCHO24_MANUAL_HUE_COUNT] = { + 0.517681, 0.675575, 0.691365, 0.691365, 0.665049, 0.680839, + 0.661513, 0.654523, 0.648355, 0.640461, 0.556250, 0.519408, + 0.450987, 0.435197, 0.424671, 0.464145, 0.516776, 0.608882, + 0.690461, 0.606250, 0.553618, 0.514145, 0.482566 +}; + +float psycho24SampleManualHueLinearity(float sourceHuePhase) { + sourceHuePhase -= floor(sourceHuePhase); + uint lowerIndex = PSYCHO24_MANUAL_HUE_COUNT - 1u; + for (uint i = 0u; i < PSYCHO24_MANUAL_HUE_COUNT; ++i) { + if (sourceHuePhase >= PSYCHO24_MANUAL_HUE_POSITION[i]) { + lowerIndex = i; + } + } + uint upperIndex = (lowerIndex + 1u) % PSYCHO24_MANUAL_HUE_COUNT; + float lowerPosition = PSYCHO24_MANUAL_HUE_POSITION[lowerIndex]; + float upperPosition = upperIndex == 0u + ? PSYCHO24_MANUAL_HUE_POSITION[0] + 1.0 + : PSYCHO24_MANUAL_HUE_POSITION[upperIndex]; + if (upperIndex == 0u && sourceHuePhase < lowerPosition) { + sourceHuePhase += 1.0; + } + float t = saturate(psycho24DivideSafe(sourceHuePhase - lowerPosition, + upperPosition - lowerPosition, 0.0)); + return lerp(PSYCHO24_MANUAL_HUE_X[lowerIndex], PSYCHO24_MANUAL_HUE_X[upperIndex], t); +} + +float3 psycho24ApplyManualHueDirection(float3 compressedLms, float3 directionSourceLms, + float3 adaptiveStateLms, float toWhiteProgress) { + float3 compressedRelativeWeighted = psycho24ToAdaptiveRelativeWeightedLms( + compressedLms, adaptiveStateLms); + float3 sourceRelativeWeighted = psycho24ToAdaptiveRelativeWeightedLms( + directionSourceLms, adaptiveStateLms); + float3 compressedMb = psycho24MbFromWeighted(compressedRelativeWeighted); + float3 sourceMb = psycho24MbFromWeighted(sourceRelativeWeighted); + float2 adaptedNeutralMb = psycho24MbFromWeighted(PSYCHO24_LMS_WEIGHTS).xy; + float2 compressedOffset = compressedMb.xy - adaptedNeutralMb; + float2 sourceOffset = sourceMb.xy - adaptedNeutralMb; + float compressedRadius2 = dot(compressedOffset, compressedOffset); + float sourceRadius2 = dot(sourceOffset, sourceOffset); + if (compressedRadius2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON + || sourceRadius2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON) { + return compressedLms; + } + + float sourceHuePhase = atan2(sourceOffset.y, sourceOffset.x) / PSYCHO24_TWO_PI; + sourceHuePhase -= floor(sourceHuePhase); + float amount = lerp(1.0, psycho24SampleManualHueLinearity(sourceHuePhase), + saturate(toWhiteProgress)); + float compressedRadius = sqrt(compressedRadius2); + float2 compressedDirection = compressedOffset / compressedRadius; + float2 sourceDirection = sourceOffset * rsqrt(sourceRadius2); + float2 outputDirection = lerp(compressedDirection, sourceDirection, amount); + float outputDirection2 = dot(outputDirection, outputDirection); + if (outputDirection2 <= PSYCHO24_EPSILON * PSYCHO24_EPSILON) { + return compressedLms; + } + outputDirection *= rsqrt(outputDirection2); + float3 restoredMb = float3(adaptedNeutralMb + outputDirection * compressedRadius, + compressedMb.z); + return psycho24UnweighLms(psycho24FromAdaptiveRelativeWeightedLms( + psycho24WeightedFromMb(restoredMb), adaptiveStateLms)); +} + +float3 psycho24CopySign(float3 magnitude, float3 source) { + static const uint PSYCHO24_FLOAT_SIGN = 0x80000000u; + static const uint PSYCHO24_FLOAT_MAGNITUDE = 0x7fffffffu; + uint3 signBits = asuint(source) & PSYCHO24_FLOAT_SIGN; + uint3 magnitudeBits = asuint(magnitude) & PSYCHO24_FLOAT_MAGNITUDE; + return asfloat(signBits | magnitudeBits); +} + +float3 psycho24Core(float3 bt709LinearInput, float peakValue, float compression, + float gamutCompression, int gamutCompressionMode, float highlights, float shadows, + float contrast, float purity) { + float3 lmsIn = psycho24LmsFromBt709(bt709LinearInput); + float3 lmsPeak = psycho24LmsFromBt709(float3(peakValue)); + float3 adaptiveStateLms = psycho24LmsFromBt709(float3(0.18)); + float3 anchorIn = max(adaptiveStateLms, float3(PSYCHO24_EPSILON)); + float3 anchorOut = max(adaptiveStateLms, float3(PSYCHO24_EPSILON)); + float contrastPower = max(contrast, PSYCHO24_EPSILON); + + float3 gradedLms = abs(lmsIn); + float gradedYf = psycho24YfFromLms(gradedLms); + float adaptedAnchorYf = psycho24YfFromLms(anchorIn); + float gradedYfOut = psycho24HighlightsScalarV4(gradedYf, highlights, adaptedAnchorYf); + gradedYfOut = psycho24ShadowsScalarV4(gradedYfOut, shadows, adaptedAnchorYf); + gradedLms *= psycho24DivideSafe(gradedYfOut, gradedYf, 1.0); + + float purityDelta = psycho24DivideSafe(max(purity, PSYCHO24_EPSILON), contrastPower, 1.0); + float3 contrastInput = psycho24ApplyAdaptiveMbPurity(gradedLms, anchorIn, purityDelta); + float3 contrastRatio = max(contrastInput / anchorIn, float3(PSYCHO24_EPSILON)); + float3 contrastLms = anchorOut * pow(contrastRatio, float3(contrastPower)); + + float compressionPower = compression; + if (compression == PSYCHO24_AUTO_COMPRESSION_SENTINEL) { + compressionPower = psycho24AutoCompressionFromCenteredReferenceRange( + psycho24YfFromLms(anchorOut), psycho24YfFromLms(lmsPeak)); + } + compressionPower = max(compressionPower, PSYCHO24_MIN_MANUAL_COMPRESSION); + + float3 anchorOverPeak = anchorOut / max(lmsPeak, float3(PSYCHO24_EPSILON)); + float3 compressionSlopeNorm = 1.0 + - pow(max(anchorOverPeak, float3(PSYCHO24_EPSILON)), float3(compressionPower)); + float3 compressionInput = pow(max(contrastLms / anchorOut, float3(PSYCHO24_EPSILON)), + float3(compressionPower) / max(compressionSlopeNorm, float3(PSYCHO24_EPSILON))); + float3 compressionWhiteOffset = pow(max(lmsPeak / anchorOut, float3(PSYCHO24_EPSILON)), + float3(compressionPower)) - float3(1.0); + float3 compressionRolloff = pow(compressionInput + / max(compressionInput + compressionWhiteOffset, float3(PSYCHO24_EPSILON)), + float3(1.0 / compressionPower)); + float3 compressedLms = lmsPeak * compressionRolloff; + float toWhiteProgress = max(compressionRolloff.x, + max(compressionRolloff.y, compressionRolloff.z)); + + float3 hueRestoredLms = psycho24ApplyManualHueDirection( + compressedLms, contrastInput, adaptiveStateLms, toWhiteProgress); + float3 displayScaled = psycho24CopySign(hueRestoredLms, lmsIn); + float3 displayScaledRelativeWeighted = psycho24ToAdaptiveRelativeWeightedLms( + displayScaled, adaptiveStateLms); + if (gamutCompression != 0.0) { + if (gamutCompressionMode == 0) { + displayScaledRelativeWeighted = psycho24GamutCompressWeightedLmsCore( + displayScaledRelativeWeighted, PSYCHO24_BT709_TO_LMS_WEIGHTED, + adaptiveStateLms, gamutCompression); + } else if (gamutCompressionMode == 1) { + displayScaledRelativeWeighted = psycho24GamutCompressWeightedLmsCore( + displayScaledRelativeWeighted, PSYCHO24_BT2020_TO_LMS_WEIGHTED, + adaptiveStateLms, gamutCompression); + } + } + + float3 outputLms = psycho24UnweighLms(psycho24FromAdaptiveRelativeWeightedLms( + displayScaledRelativeWeighted, adaptiveStateLms)); + return psycho24Bt709FromLms(outputLms); +} + +public float3 psychoV24(float3 bt709LinearInput, float peakValue, float compression, + float gamutCompression, int gamutCompressionMode, float highlights, float shadows, + float contrast, float purity) { + return psycho24Core(bt709LinearInput, peakValue, compression, gamutCompression, + gamutCompressionMode, highlights, shadows, contrast, purity); +} diff --git a/shaders/pipelines/display/tone_mapping.slang b/shaders/pipelines/display/tone_mapping.slang new file mode 100644 index 00000000..696b49ad --- /dev/null +++ b/shaders/pipelines/display/tone_mapping.slang @@ -0,0 +1,307 @@ +import psychov24; + + +static const float3 LUMA_BT709 = float3(0.2126, 0.7152, 0.0722); +static const float3 LUMA_BT2020 = float3(0.2627, 0.6780, 0.0593); + +// Row-major mathematical notation; the build targets column-major storage, while mul(M, v) +// preserves the intended color-space transforms. +static const float3x3 BT709_TO_BT2020 = float3x3( + 0.6274039, 0.3292830, 0.0433131, + 0.0690973, 0.9195406, 0.0113612, + 0.0163916, 0.0880132, 0.8955953 +); +static const float3x3 PSYCHO24_BT709_TO_BT2020 = float3x3( + 0.6274039149, 0.3292830288, 0.04331305996, + 0.0690972954, 0.9195403457, 0.01136231795, + 0.01639143936, 0.08801331371, 0.8955952525 +); + +static const float3x3 BT2020_TO_BT709 = float3x3( + 1.6604910, -0.5876411, -0.0728499, + -0.1245505, 1.1328999, -0.0083494, + -0.0181508, -0.1005789, 1.1187297 +); + +static const float PQ_M1 = 0.1593017578125; +static const float PQ_M2 = 78.84375; +static const float PQ_C1 = 0.8359375; +static const float PQ_C2 = 18.8515625; +static const float PQ_C3 = 18.6875; + +float pqEncode(float nits) { + float y = pow(max(nits, 0.0) / 10000.0, PQ_M1); + return pow((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), PQ_M2); +} +float3 pqEncodeNits(float3 nits) { + return float3(pqEncode(nits.r), pqEncode(nits.g), pqEncode(nits.b)); +} + +float safeDiv(float a, float b, float fallback) { + return abs(b) <= 1.0e-12 ? fallback : a / b; +} + +float3 safeDiv(float3 a, float3 b, float3 fallback) { + const float eps = 1.0e-12; + return float3(abs(b.x) <= eps ? fallback.x : a.x / b.x, + abs(b.y) <= eps ? fallback.y : a.y / b.y, + abs(b.z) <= eps ? fallback.z : a.z / b.z); +} + +float luminanceBt709(float3 color) { + return dot(color, LUMA_BT709); +} + + +static const float3x3 AGX_INSET = float3x3( + 0.842479062253094, 0.078433599999999, 0.079223745147764, + 0.042328242261012, 0.878468636469772, 0.079166127460543, + 0.042375654905705, 0.078433600000000, 0.879142973793104 +); + +static const float3x3 AGX_OUTSET = float3x3( + 1.196879005120170, -0.098020881140137, -0.099029744079720, + -0.052896851757456, 1.151903129904170, -0.098961176844843, + -0.052971635514443, -0.098043450117124, 1.151073672641160 +); + +static const float AGX_MIN_EV = -12.47393; +static const float AGX_MAX_EV = 4.026069; + +// AgX's default contrast curve is the polynomial used by the selectable AgX operator. +float3 agxDefaultContrast(float3 x) { + float3 x2 = x * x; + float3 x4 = x2 * x2; + return 15.5 * x4 * x2 + - 40.14 * x4 * x + + 31.96 * x4 + - 6.868 * x2 * x + + 0.4298 * x2 + + 0.1191 * x + - 0.00232; +} + +float3 applyLookAdjustments(float3 color, float contrast, float saturation) { + if (abs(contrast - 1.0) <= 1.0e-6 && abs(saturation - 1.0) <= 1.0e-6) { + return color; + } + color = clamp((color - 0.5) * max(contrast, 0.0) + 0.5, 0.0, 1.0); + float luma = luminanceBt709(color); + return clamp(lerp(float3(luma), color, max(saturation, 0.0)), 0.0, 1.0); +} + +float3 agx(float3 color, float contrast, float saturation) { + color = mul(AGX_INSET, max(color, float3(0.0))); + color = clamp(log2(max(color, float3(1.0e-10))), AGX_MIN_EV, AGX_MAX_EV); + color = (color - AGX_MIN_EV) / (AGX_MAX_EV - AGX_MIN_EV); + color = agxDefaultContrast(color); + color = mul(AGX_OUTSET, color); + return applyLookAdjustments(clamp(color, 0.0, 1.0), contrast, saturation); +} + +// Khronos PBR Neutral analytical operator. +float3 pbrNeutralTonemap(float3 color, float startCompression, float desaturation) { + startCompression = clamp(startCompression, 0.0, 0.99); + desaturation = max(desaturation, 0.0); + color = max(color, float3(0.0)); + float x = min(color.r, min(color.g, color.b)); + float offset = x < 0.08 ? x - 6.25 * x * x : 0.04; + color -= offset; + + float peak = max(color.r, max(color.g, color.b)); + if (peak < startCompression) { + return color; + } + + float d = 1.0 - startCompression; + float newPeak = 1.0 - d * d / max(peak + d - startCompression, 1.0e-6); + color *= safeDiv(newPeak, peak, 1.0); + + float g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0); + return lerp(color, float3(newPeak), g); +} + +float3 luminanceTonemap(float3 color, float lMapped, float lSource) { + return color * safeDiv(lMapped, lSource, 1.0); +} + +// Extended Reinhard photographic operator. +float3 reinhardExtendedTonemap(float3 color, float whitePoint) { + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + float lWhite = max(whitePoint, 1.0e-3); + float lWhite2 = lWhite * lWhite; + float lm = l * (1.0 + l / lWhite2) / (1.0 + l); + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + +// Krzysztof Narkowicz's compact ACES fitted curve, applied to BT.709 luminance. +float3 acesNarkowiczTonemap(float3 color, float inputScale) { + color *= max(inputScale, 0.0); + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + float lm = (l * (2.51 * l + 0.03)) / max(l * (2.43 * l + 0.59) + 0.14, 1.0e-6); + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + +// Timothy Lottes's parameterized filmic curve. +float3 lottesTonemap( + float3 color, float contrast, float shoulder, float maximum, + float middleIn, float middleOut) { + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + + float a = max(contrast, 1.0e-3); + float d = max(shoulder, 1.0e-3); + float hdrMax = max(maximum, 1.0e-3); + float midIn = max(middleIn, 1.0e-3); + float midOut = max(middleOut, 1.0e-3); + float hdrPow = pow(hdrMax, a * d); + float midPow = pow(midIn, a * d); + float denom = max((hdrPow - midPow) * midOut, 1.0e-6); + float b = (-pow(midIn, a) + pow(hdrMax, a) * midOut) / denom; + float c = (hdrPow * pow(midIn, a) - pow(hdrMax, a) * midPow * midOut) / denom; + + float lm = pow(l, a) / max(pow(l, a * d) * b + c, 1.0e-6); + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + +// John Hable's Uncharted 2 filmic curve. +float hablePartial(float x, float a, float b, float c, float d, float e, float f) { + return ((x * (a * x + c * b) + d * e) / max(x * (a * x + b) + d * f, 1.0e-6)) - e / f; +} + +float3 uncharted2Tonemap( + float3 color, float a, float b, float c, float d, float e, float f, + float whitePoint) { + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + + a = max(a, 1.0e-4); + b = max(b, 1.0e-4); + c = max(c, 0.0); + d = max(d, 1.0e-4); + e = max(e, 0.0); + f = max(f, 1.0e-4); + float w = max(whitePoint, 1.0e-3); + float whiteScale = max(hablePartial(w, a, b, c, d, e, f), 1.0e-6); + float lm = hablePartial(l, a, b, c, d, e, f) / whiteScale; + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + +// Hajime Uchimura's GT tone-mapping curve. +float3 gtTonemap( + float3 color, float contrast, float linearStart, float linearLength, + float blackCurve, float blackLift) { + float l = luminanceBt709(color); + if (l < 1.0e-6) { + return color; + } + + const float p = 1.0; + float a = max(contrast, 1.0e-3); + float m = clamp(linearStart, 1.0e-4, 0.99); + linearLength = max(linearLength, 1.0e-4); + float c = max(blackCurve, 1.0e-4); + float b = blackLift; + + float l0 = ((p - m) * linearLength) / a; + float s0 = m + l0; + float s1 = m + a * l0; + float c2 = (a * p) / max(p - s1, 1.0e-6); + float cp = -c2 / p; + + float w0 = 1.0 - smoothstep(0.0, m, l); + float w2 = step(m + l0, l); + float w1 = 1.0 - w0 - w2; + + float toe = m * pow(max(l / m, 0.0), c) + b; + float shoulder = p - (p - s1) * exp(cp * (l - s0)); + float lm = toe * w0 + l * w1 + shoulder * w2; + return luminanceTonemap(color, clamp(lm, 0.0, 1.0), l); +} + + +public struct ToneMappingParameters { + public float param0; + public float param1; + public float param2; + public float param3; + public float param4; + public float param5; + public float param6; + public float param7; +} + +public float3 applyClassicToneMapper(int mode, float3 color, ToneMappingParameters p) { + if (mode == 1) { + return agx(color, p.param0, p.param1); + } else if (mode == 2) { + return pbrNeutralTonemap(color, p.param0, p.param1); + } else if (mode == 3) { + return reinhardExtendedTonemap(color, p.param0); + } else if (mode == 4) { + return acesNarkowiczTonemap(color, p.param0); + } else if (mode == 5) { + return lottesTonemap(color, p.param0, p.param1, p.param2, p.param3, p.param4); + } else if (mode == 6) { + return uncharted2Tonemap(color, p.param0, p.param1, p.param2, p.param3, + p.param4, p.param5, p.param6); + } else if (mode == 7) { + return gtTonemap(color, p.param0, p.param1, p.param2, p.param3, p.param4); + } + return color; +} + +public float3 toneMapPsychoV24Sdr(float3 color, ToneMappingParameters p) { + return psychoV24(color, 1.0, + p.param0, p.param1, 0, p.param2, p.param3, p.param4, p.param5); +} + +public float3 toneMapPsychoV24Hdr(float3 paperReferred709, float paperWhiteNits, + float headroom, ToneMappingParameters p) { + float3 mapped709 = psychoV24(paperReferred709, headroom, + p.param0, p.param1, 1, p.param2, p.param3, p.param4, p.param5); + float3 mapped2020 = max(mul(PSYCHO24_BT709_TO_BT2020, mapped709), float3(0.0)); + return pqEncodeNits(mapped2020 * paperWhiteNits); +} + +// BT.2390 PQ EETF. This is the standard's luma-Y' variant: the 1:1 central region is preserved, +// the Hermite knee rolls the PQ luma toward the target display peak, and a zero-black target is used +// because Caustica has no ambient-black control. The EETF is deliberately not applied independently +// to RGB channels; BT.2390 warns that doing so can introduce color and saturation shifts. +float bt2390EetfPq(float encodedLuminance, float targetPeakNits) { + float maxLum = pqEncode(clamp(targetPeakNits, 1.0, 10000.0)); + float kneeStart = clamp(1.5 * maxLum - 0.5, 0.0, 1.0); + float inputPq = clamp(encodedLuminance, 0.0, 1.0); + float e2 = inputPq; + if (inputPq < kneeStart || kneeStart >= 0.999999) { + return e2; + } + + float t = clamp((inputPq - kneeStart) / max(1.0 - kneeStart, 1.0e-6), 0.0, 1.0); + float t2 = t * t; + float t3 = t2 * t; + e2 = (2.0 * t3 - 3.0 * t2 + 1.0) * kneeStart + + (t3 - 2.0 * t2 + t) * (1.0 - kneeStart) + + (-2.0 * t3 + 3.0 * t2) * maxLum; + // E3 = E2 + b * (1 - E2)^4; b is zero for the renderer's zero-black target. + return clamp(e2, 0.0, 1.0); +} + +public float3 toneMapBt2390Hdr(float3 paperReferred709, float paperWhiteNits, float headroom) { + float3 paperReferred = max(paperReferred709, float3(0.0)); + float3 nits2020 = max(mul(BT709_TO_BT2020, paperReferred * paperWhiteNits), float3(0.0)); + float3 encoded = clamp(pqEncodeNits(nits2020), float3(0.0), float3(1.0)); + float inputLuma = dot(encoded, LUMA_BT2020); + float mappedLuma = bt2390EetfPq(inputLuma, paperWhiteNits * headroom); + return clamp(luminanceTonemap(encoded, mappedLuma, inputLuma), float3(0.0), float3(1.0)); +} diff --git a/shaders/pipelines/exposure_hist/main.comp.slang b/shaders/pipelines/exposure_hist/main.comp.slang index 88d4918c..8478d2b6 100644 --- a/shaders/pipelines/exposure_hist/main.comp.slang +++ b/shaders/pipelines/exposure_hist/main.comp.slang @@ -24,7 +24,8 @@ void main(uint3 dispatchId : SV_DispatchThreadID, uint groupIndex : SV_GroupInde localBins[512u + groupIndex] = 0u; GroupMemoryBarrierWithGroupSync(); - int2 pix = int2(dispatchId.xy * pc.stride); + uint stride = max(pc.stride, 1u); + int2 pix = int2(dispatchId.xy * stride); uint w, h; colorImage.GetDimensions(w, h); if (pix.x < int(w) && pix.y < int(h)) { diff --git a/shaders/pipelines/exposure_resolve/main.comp.slang b/shaders/pipelines/exposure_resolve/main.comp.slang index 39365a8e..ae7ce026 100644 --- a/shaders/pipelines/exposure_resolve/main.comp.slang +++ b/shaders/pipelines/exposure_resolve/main.comp.slang @@ -96,6 +96,17 @@ void main() { } float population = surfacePopulation + skyPopulation * skyScale + emissivePopulation * emissiveScale; + if (population <= 0.0 || !isfinite(population)) { + float previous = (stateBuf[0].initialized != 0u && isfinite(stateBuf[0].previous) + && stateBuf[0].previous > 0.0) ? stateBuf[0].previous : 1.0; + stateBuf[0].resetSeq = pc.resetSeq; + stateBuf[0].previous = previous; + stateBuf[0].initialized = 1u; + float preExposure = (isfinite(pc.preExposure) && pc.preExposure > 0.0) + ? pc.preExposure : 1.0; + exposureImage[int2(0, 0)] = previous / preExposure; + return; + } float total = max(population, 1.0); float lowPercentile = clamp(min(pc.lowPercentile, pc.highPercentile), 0.0, 1.0); float highPercentile = clamp(max(pc.lowPercentile, pc.highPercentile), 0.0, 1.0); diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 0088a319..479af1eb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -58,7 +58,10 @@ 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.Exposure.MODE, Rt.Exposure.LOW_PERCENTILE, Rt.Exposure.HIGH_PERCENTILE, + Rt.Exposure.PRE_EXPOSURE, Rt.Tonemap.GAMMA, + Rt.Sdr.TONE_MAPPER, Rt.Hdr.TONE_MAPPER, + Rt.FrameStats.ENABLED, Rt.Screenshots.EXR_ENABLED, Rt.Hdr.ENABLED, Ngx.PATH, }; } @@ -96,14 +99,18 @@ private static void writeComments() { " 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."); FILE.setComment("tonemap", - " Controls the final image. gamma: 1 is neutral; lower values brighten midtones."); + " Controls the final image. ACES 2.0 is the SDR and HDR default;\n" + + " PsychoV24 and the analytical operators are opt-in, with BT.2390 as the standards-based HDR\n" + + " alternative. gamma: 1 is neutral; lower values brighten midtones."); FILE.setComment("exposure", " Controls automatic exposure. manual-ev sets exposure in manual mode and adjusts it in auto mode.\n" + + " low/high-percentile define the histogram window; pre-exposure keeps stored radiance near mid-grey.\n" + " adapt-darken and adapt-brighten control adjustment speed in seconds.\n" + " sky-weight-cap and emissive-weight-cap limit how much bright areas affect exposure."); FILE.setComment("hdr", " HDR display output. Requires operating system and display support.\n" - + " ui-nits controls UI brightness; peak-nits must be 500, 1000, 2000, or 4000."); + + " ui-nits controls UI brightness; peak-nits uses 50-nit increments from 50 to 5000.\n" + + " ACES 2.0 uses the nearest baked HDR mastering target; analytical HDR modes use the exact value."); FILE.setComment("screenshots", " exr-enabled saves an ACEScg EXR beside the normal F2 PNG while ray tracing is active."); } @@ -746,9 +753,9 @@ public static final class Exposure { public static final FloatSetting ADAPT_BRIGHTEN = exposureScale("caustica.rt.exposure.adaptBrighten", "exposure.adapt-brighten", 0.4f); public static final FloatSetting LOW_PERCENTILE = - clampedFloat("caustica.rt.exposure.lowPercentile", "exposure.low-percentile", 0.50f, 0.0f, 1.0f); + percentile("caustica.rt.exposure.lowPercentile", "exposure.low-percentile", 0.50f); public static final FloatSetting HIGH_PERCENTILE = - clampedFloat("caustica.rt.exposure.highPercentile", "exposure.high-percentile", 0.95f, 0.0f, 1.0f); + percentile("caustica.rt.exposure.highPercentile", "exposure.high-percentile", 0.95f); public static final IntSetting STRIDE = clampedInt("caustica.rt.exposure.stride", "exposure.stride", 2, 1, 8); public static final FloatSetting CENTER_WEIGHT_SIGMA = @@ -798,10 +805,11 @@ public static float clampScale(float value) { } private static String sanitizeMode(String value) { - if ("auto".equalsIgnoreCase(value)) { + String trimmed = value == null ? "" : value.trim(); + if ("auto".equalsIgnoreCase(trimmed)) { return "auto"; } - if ("manual".equalsIgnoreCase(value)) { + if ("manual".equalsIgnoreCase(trimmed)) { return "manual"; } return "auto"; @@ -818,6 +826,93 @@ private Tonemap() { } } + /** Selectable SDR operators. ACES 2.0 is the default baked-LUT path. */ + public static final class Sdr { + public static final StringSetting TONE_MAPPER = + string("caustica.rt.sdr.toneMapper", "sdr.tone-mapper", "aces2.0", + Sdr::sanitizeToneMapper); + public static final FloatSetting AGX_CONTRAST = + finiteClampedFloat("caustica.rt.sdr.agx.contrast", "sdr.agx.contrast", 1.0f, 0.0f, 2.0f); + public static final FloatSetting AGX_SATURATION = + finiteClampedFloat("caustica.rt.sdr.agx.saturation", "sdr.agx.saturation", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PBR_START_COMPRESSION = + finiteClampedFloat("caustica.rt.sdr.pbrNeutral.startCompression", + "sdr.pbr-neutral.start-compression", 0.76f, 0.0f, 0.99f); + public static final FloatSetting PBR_DESATURATION = + finiteClampedFloat("caustica.rt.sdr.pbrNeutral.desaturation", + "sdr.pbr-neutral.desaturation", 0.15f, 0.0f, 1.0f); + public static final FloatSetting REINHARD_WHITE_POINT = + finiteClampedFloat("caustica.rt.sdr.reinhard.whitePoint", + "sdr.reinhard.white-point", 4.0f, 1.0f, 20.0f); + public static final FloatSetting ACES_EXPOSURE = + finiteClampedFloat("caustica.rt.sdr.aces.exposure", + "sdr.aces.exposure", 1.0f, 0.0f, 4.0f); + public static final FloatSetting LOTTES_CONTRAST = + finiteClampedFloat("caustica.rt.sdr.lottes.contrast", + "sdr.lottes.contrast", 1.0f, 0.1f, 5.0f); + public static final FloatSetting LOTTES_SHOULDER = + finiteClampedFloat("caustica.rt.sdr.lottes.shoulder", + "sdr.lottes.shoulder", 1.0f, 0.1f, 5.0f); + public static final FloatSetting LOTTES_HDR_MAX = + finiteClampedFloat("caustica.rt.sdr.lottes.hdrMax", + "sdr.lottes.hdr-max", 16.0f, 1.0f, 64.0f); + public static final FloatSetting LOTTES_MID_IN = + finiteClampedFloat("caustica.rt.sdr.lottes.midIn", + "sdr.lottes.mid-in", 0.18f, 0.01f, 1.0f); + public static final FloatSetting LOTTES_MID_OUT = + finiteClampedFloat("caustica.rt.sdr.lottes.midOut", + "sdr.lottes.mid-out", 0.18f, 0.01f, 1.0f); + public static final FloatSetting UNCHARTED_A = + finiteClampedFloat("caustica.rt.sdr.uncharted2.a", "sdr.uncharted2.a", 0.15f, 0.01f, 1.0f); + public static final FloatSetting UNCHARTED_B = + finiteClampedFloat("caustica.rt.sdr.uncharted2.b", "sdr.uncharted2.b", 0.50f, 0.01f, 2.0f); + public static final FloatSetting UNCHARTED_C = + finiteClampedFloat("caustica.rt.sdr.uncharted2.c", "sdr.uncharted2.c", 0.10f, 0.0f, 1.0f); + public static final FloatSetting UNCHARTED_D = + finiteClampedFloat("caustica.rt.sdr.uncharted2.d", "sdr.uncharted2.d", 0.20f, 0.01f, 2.0f); + public static final FloatSetting UNCHARTED_E = + finiteClampedFloat("caustica.rt.sdr.uncharted2.e", "sdr.uncharted2.e", 0.02f, 0.0f, 1.0f); + public static final FloatSetting UNCHARTED_F = + finiteClampedFloat("caustica.rt.sdr.uncharted2.f", "sdr.uncharted2.f", 0.30f, 0.01f, 2.0f); + public static final FloatSetting UNCHARTED_WHITE_POINT = + finiteClampedFloat("caustica.rt.sdr.uncharted2.whitePoint", + "sdr.uncharted2.white-point", 11.2f, 1.0f, 32.0f); + public static final FloatSetting GT_CONTRAST = + finiteClampedFloat("caustica.rt.sdr.gt.contrast", "sdr.gt.contrast", 1.0f, 0.1f, 4.0f); + public static final FloatSetting GT_LINEAR_START = + finiteClampedFloat("caustica.rt.sdr.gt.linearStart", "sdr.gt.linear-start", 0.22f, 0.01f, 0.99f); + public static final FloatSetting GT_LINEAR_LENGTH = + finiteClampedFloat("caustica.rt.sdr.gt.linearLength", "sdr.gt.linear-length", 0.40f, 0.01f, 4.0f); + public static final FloatSetting GT_BLACK_CURVE = + finiteClampedFloat("caustica.rt.sdr.gt.blackCurve", "sdr.gt.black-curve", 1.33f, 0.1f, 4.0f); + public static final FloatSetting GT_BLACK_LIFT = + finiteClampedFloat("caustica.rt.sdr.gt.blackLift", "sdr.gt.black-lift", 0.0f, -0.5f, 0.5f); + public static final FloatSetting PSYCHOV24_COMPRESSION = + finiteClampedFloat("caustica.rt.sdr.psychov24.compression", + "sdr.psychov24.compression", 1.0f, 0.0f, 8.0f); + public static final FloatSetting PSYCHOV24_GAMUT_COMPRESSION = + finiteClampedFloat("caustica.rt.sdr.psychov24.gamutCompression", + "sdr.psychov24.gamut-compression", 1.0f, 0.0f, 1.0f); + public static final FloatSetting PSYCHOV24_HIGHLIGHTS = + finiteClampedFloat("caustica.rt.sdr.psychov24.highlights", + "sdr.psychov24.highlights", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PSYCHOV24_SHADOWS = + finiteClampedFloat("caustica.rt.sdr.psychov24.shadows", + "sdr.psychov24.shadows", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PSYCHOV24_CONTRAST = + finiteClampedFloat("caustica.rt.sdr.psychov24.contrast", + "sdr.psychov24.contrast", 1.0f, 0.1f, 3.0f); + public static final FloatSetting PSYCHOV24_PURITY = + finiteClampedFloat("caustica.rt.sdr.psychov24.purity", + "sdr.psychov24.purity", 1.0f, 0.0f, 3.0f); + private Sdr() { + } + + private static String sanitizeToneMapper(String value) { + return dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.SdrMode.parse(value).canonicalName(); + } + } + /** Render-frame timing + hitch logging. See {@code RtFrameStats}. */ public static final class FrameStats { public static final BooleanSetting ENABLED = bool("caustica.rt.frameStats", "frame-stats.enabled", false); @@ -854,18 +949,46 @@ private Diagnostics() { * HDR display output. When enabled the swapchain is created in PQ (ST.2084/HDR10 — the display-ready * encoding both HDR10 swapchains and DLSS Frame Generation require; whatever pixel format the surface * pairs with that color space, commonly a 10-bit UNORM), falling back to SDR if the surface doesn't - * advertise it. The ACES LUT owns scene-to-display mapping; {@code uiNits} places SDR-authored UI - * in that PQ output, while {@code peakNits} selects the LUT's mastering target. + * advertise it. The selected display transform owns scene-to-display mapping; {@code uiNits} places + * SDR-authored UI in that PQ output, while {@code peakNits} controls the display peak. ACES 2.0 is + * the default and selects the nearest baked HDR LUT; analytical modes use the exact configured peak. */ public static final class Hdr { public static final BooleanSetting ENABLED = bool("caustica.rt.hdr", "hdr.enabled", false); public static final FloatSetting UI_NITS = clampedFloat("caustica.rt.hdr.uiNits", "hdr.ui-nits", 200.0f, 80.0f, 500.0f); - - // ACES HDR LUTs are available only for these mastering targets. - public static final List PEAK_NITS_STEPS = List.of(500, 1000, 2000, 4000); + public static final FloatSetting PAPER_WHITE_NITS = + finiteClampedFloat("caustica.rt.hdr.paperWhiteNits", "hdr.paper-white-nits", 200.0f, 80.0f, 500.0f); + public static final StringSetting TONE_MAPPER = + string("caustica.rt.hdr.toneMapper", "hdr.tone-mapper", "aces2.0", + Hdr::sanitizeToneMapper); + public static final FloatSetting PSYCHOV24_COMPRESSION = + finiteClampedFloat("caustica.rt.hdr.psychov24.compression", + "hdr.psychov24.compression", 0.0f, 0.0f, 8.0f); + public static final FloatSetting PSYCHOV24_GAMUT_COMPRESSION = + finiteClampedFloat("caustica.rt.hdr.psychov24.gamutCompression", + "hdr.psychov24.gamut-compression", 1.0f, 0.0f, 1.0f); + public static final FloatSetting PSYCHOV24_HIGHLIGHTS = + finiteClampedFloat("caustica.rt.hdr.psychov24.highlights", + "hdr.psychov24.highlights", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PSYCHOV24_SHADOWS = + finiteClampedFloat("caustica.rt.hdr.psychov24.shadows", + "hdr.psychov24.shadows", 1.0f, 0.0f, 3.0f); + public static final FloatSetting PSYCHOV24_CONTRAST = + finiteClampedFloat("caustica.rt.hdr.psychov24.contrast", + "hdr.psychov24.contrast", 1.0f, 0.1f, 3.0f); + public static final FloatSetting PSYCHOV24_PURITY = + finiteClampedFloat("caustica.rt.hdr.psychov24.purity", + "hdr.psychov24.purity", 1.0f, 0.0f, 3.0f); + public static final int PEAK_NITS_MIN = 50; + public static final int PEAK_NITS_MAX = 5000; + public static final int PEAK_NITS_STEP = 50; + // ACES 2.0 HDR LUTs are baked only for these mastering targets. Analytical HDR modes do + // not depend on this list and can use every 50-nit peak exposed by the control. + public static final List ACES_LUT_NITS = List.of(500, 1000, 2000, 4000); public static final IntSetting PEAK_NITS = - intChoice("caustica.rt.hdr.peakNits", "hdr.peak-nits", 1000, PEAK_NITS_STEPS); + quantizedInt("caustica.rt.hdr.peakNits", "hdr.peak-nits", 1000, + PEAK_NITS_MIN, PEAK_NITS_MAX, PEAK_NITS_STEP); // Surface capability and current swapchain state are separate: HDR controls remain available // while the swapchain is native SDR, so enabling HDR can recreate it in PQ. @@ -910,6 +1033,43 @@ public static float uiNits() { return UI_NITS.value(); } + public static float paperWhiteNits() { + // Keep an invalid persisted paper-white value from exceeding the selected display peak. + // The raw setting remains intact so raising the peak restores the user's requested value. + return Math.min(PAPER_WHITE_NITS.value(), PEAK_NITS.value()); + } + + /** Highlight headroom above paper white, in paper-white-referred units. */ + public static float headroom() { + return Math.max(1.0f, PEAK_NITS.value() / Math.max(1.0f, paperWhiteNits())); + } + + /** Selects the nearest packaged ACES 2.0 HDR LUT for the requested display peak. */ + public static int nearestAcesLutNits(int requestedNits) { + int nearest = ACES_LUT_NITS.get(0); + int nearestDistance = Math.abs(requestedNits - nearest); + for (int candidate : ACES_LUT_NITS) { + int distance = Math.abs(requestedNits - candidate); + if (distance < nearestDistance) { + nearest = candidate; + nearestDistance = distance; + } + } + return nearest; + } + + /** Peak represented by the currently active HDR transform and its presentation metadata. */ + public static int effectivePeakNits() { + return dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.HdrMode.parse(TONE_MAPPER.get()) + == dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.HdrMode.ACES_2_0 + ? nearestAcesLutNits(PEAK_NITS.value()) + : PEAK_NITS.value(); + } + + private static String sanitizeToneMapper(String value) { + return dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.HdrMode.parse(value).canonicalName(); + } + } } @@ -944,6 +1104,12 @@ private static IntSetting intChoice(String key, String tomlPath, int fallback, L return new IntSetting(key, tomlPath, fallback, v -> choices.contains(v) ? v : fallback); } + private static IntSetting quantizedInt(String key, String tomlPath, int fallback, + int min, int max, int step) { + return new IntSetting(key, tomlPath, fallback, + v -> Math.clamp(Math.round(v / (float) step) * step, min, max)); + } + private static IntSetting clampedInt(String key, String tomlPath, int fallback, int min, int max) { return new IntSetting(key, tomlPath, fallback, v -> Math.clamp(v, min, max)); } @@ -956,10 +1122,21 @@ private static FloatSetting exposureScale(String key, String tomlPath, float fal return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, v -> Math.clamp(v, 1.0e-4, 1.0e4)); } + private static FloatSetting percentile(String key, String tomlPath, float fallback) { + return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, + v -> Double.isFinite(v) ? Math.clamp(v, 0.0, 1.0) : fallback); + } + private static FloatSetting clampedFloat(String key, String tomlPath, float fallback, float min, float max) { return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, v -> Math.clamp(v, min, max)); } + private static FloatSetting finiteClampedFloat(String key, String tomlPath, float fallback, + float min, float max) { + return new FloatSetting(key, tomlPath, fallback, v -> v, v -> v, + v -> Double.isFinite(v) ? Math.clamp(v, min, max) : fallback); + } + private static FloatSetting radians(String key, String tomlPath, float fallbackDegrees) { return new FloatSetting(key, tomlPath, fallbackDegrees, Math::toRadians, Math::toDegrees, v -> Double.isFinite(v) ? v : 0.0); } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtToneMappingOptionsScreen.java b/src/main/java/dev/comfyfluffy/caustica/client/RtToneMappingOptionsScreen.java new file mode 100644 index 00000000..55bf6406 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtToneMappingOptionsScreen.java @@ -0,0 +1,165 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.client.RtVideoOptions.ResettableControl; +import java.util.ArrayList; +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Options; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.options.OptionsSubScreen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +/** + * Focused exposure and display-mapping page. The complete exposure surface is always available, + * while the curve section is rebuilt whenever the active output path's tone mapper changes so + * inactive algorithms never clutter the page. + */ +public final class RtToneMappingOptionsScreen extends OptionsSubScreen { + private static final long VISIBLE_STATE_DEBOUNCE_NANOS = 100_000_000L; + + private final List resettableControls = new ArrayList<>(); + private String visibleState; + private String pendingVisibleState; + private long pendingVisibleStateSince; + private ResettableControl toneMapperControl; + private boolean toneMapperDragging; + + public RtToneMappingOptionsScreen(Screen lastScreen, Options options) { + super( + lastScreen, + options, + Component.translatable("caustica.options.rt.toneMapping.title")); + } + + @Override + protected void addOptions() { + resettableControls.clear(); + toneMapperControl = null; + list.addHeader(Component.translatable( + "caustica.options.rt.toneMapping.resetHint") + .withStyle(ChatFormatting.GRAY)); + + list.addHeader(Component.translatable( + "caustica.options.rt.toneMapping.section.exposure")); + addSmall(RtVideoOptions.exposureOptions()); + + boolean hdr = requestedHdr(); + list.addHeader(Component.translatable( + hdr + ? "caustica.options.rt.toneMapping.section.hdrOutput" + : "caustica.options.rt.toneMapping.section.sdrOutput")); + toneMapperControl = hdr + ? RtVideoOptions.hdrToneMapper() + : RtVideoOptions.sdrToneMapper(); + addBig(toneMapperControl); + if (hdr) { + addSmall(RtVideoOptions.hdrDisplayOptions()); + } + + ResettableControl[] mapperOptions = + RtVideoOptions.activeToneMapperOptions(hdr); + if (mapperOptions.length > 0) { + list.addHeader(Component.translatable( + "caustica.options.rt.toneMapping.section.activeMapper", + RtVideoOptions.activeToneMapperName(hdr))); + addSmall(mapperOptions); + } + visibleState = currentVisibleState(); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (event.button() == 0 && toneMapperControl != null) { + AbstractWidget widget = list.findOption(toneMapperControl.option()); + toneMapperDragging = widget != null + && widget.visible + && widget.active + && widget.isMouseOver(event.x(), event.y()); + } + if (event.button() == 0 && event.hasControlDown() && event.hasShiftDown()) { + for (ResettableControl control : resettableControls) { + AbstractWidget widget = list.findOption(control.option()); + if (widget != null + && widget.visible + && widget.active + && widget.isMouseOver(event.x(), event.y())) { + control.resetToDefault(); + list.resetOption(control.option()); + CausticaConfig.save(); + return true; + } + } + } + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + boolean handled = super.mouseReleased(event); + if (event.button() == 0) { + toneMapperDragging = false; + if (pendingVisibleState != null) { + pendingVisibleStateSince = System.nanoTime(); + } + } + return handled; + } + + @Override + public void tick() { + super.tick(); + String nextState = currentVisibleState(); + if (!nextState.equals(visibleState)) { + long now = System.nanoTime(); + if (!nextState.equals(pendingVisibleState)) { + pendingVisibleState = nextState; + pendingVisibleStateSince = now; + } else if (!toneMapperDragging + && now - pendingVisibleStateSince >= VISIBLE_STATE_DEBOUNCE_NANOS) { + list.applyUnsavedChanges(); + CausticaConfig.save(); + // OptionsSubScreen keeps this layout instance across rebuildWidgets(). + // Clear its frames as well as the screen widget list, otherwise every + // mapper change leaves another full options list stacked underneath. + layout.removeChildren(); + rebuildWidgets(); + pendingVisibleState = null; + } + } else { + pendingVisibleState = null; + } + } + + @Override + public void removed() { + super.removed(); + CausticaConfig.save(); + } + + private static boolean requestedHdr() { + return CausticaConfig.Rt.Hdr.ENABLED.value(); + } + + private static String currentVisibleState() { + boolean hdr = requestedHdr(); + return (hdr ? "hdr:" : "sdr:") + + (hdr + ? CausticaConfig.Rt.Hdr.TONE_MAPPER.get() + : CausticaConfig.Rt.Sdr.TONE_MAPPER.get()); + } + + private void addBig(ResettableControl control) { + resettableControls.add(control); + list.addBig(control.option()); + } + + private void addSmall(ResettableControl[] controls) { + for (ResettableControl control : controls) { + resettableControls.add(control); + } + list.addSmall(RtVideoOptions.optionInstances(controls)); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index 8fa9206f..01fa62b5 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -6,12 +6,16 @@ import dev.comfyfluffy.caustica.CausticaConfig.FloatSetting; import dev.comfyfluffy.caustica.CausticaConfig.IntSetting; import dev.comfyfluffy.caustica.CausticaConfig.StringSetting; +import dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping; import java.util.ArrayList; import java.util.List; import java.util.Locale; import net.minecraft.client.Minecraft; import net.minecraft.client.OptionInstance; import net.minecraft.client.Options; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Tooltip; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; /** @@ -28,9 +32,28 @@ * {@code quality} changes (see {@code RtDlssRr.ensureFeature}), so it is safe to expose here. */ public final class RtVideoOptions { + /** A tone-mapping submenu option paired with the source-defined default reset value. */ + public record ResettableControl(OptionInstance option, Runnable reset) { + public void resetToDefault() { + reset.run(); + } + } + private RtVideoOptions() { } + private static ResettableControl control(OptionInstance option, T defaultValue) { + return new ResettableControl(option, () -> option.set(defaultValue)); + } + + static OptionInstance[] optionInstances(ResettableControl[] controls) { + OptionInstance[] options = new OptionInstance[controls.length]; + for (int i = 0; i < controls.length; i++) { + options[i] = controls[i].option(); + } + return options; + } + /** * Runtime-tunable RT options, in display order. Paired two-per-row by {@code OptionsList.addSmall}. * The HDR entries are omitted entirely (not just disabled) when this session's swapchain isn't @@ -43,6 +66,9 @@ public static OptionInstance[] runtimeOptions() { List> options = new ArrayList<>(List.of( exposureMode(), manualEv(), + exposureLowPercentile(), + exposureHighPercentile(), + preExposure(), gamma(), spp(), maxBounces(), @@ -54,12 +80,131 @@ public static OptionInstance[] runtimeOptions() { if (CausticaConfig.Rt.Hdr.swapchainPqAvailable()) { options.add(hdrEnabled()); options.add(hdrUiBrightness()); + options.add(hdrPaperWhite()); options.add(hdrPeak()); } - options.add(debugView()); return options.toArray(OptionInstance[]::new); } + /** Exposure and display controls shown by {@link RtToneMappingOptionsScreen}. */ + public static ResettableControl[] exposureOptions() { + return new ResettableControl[] { + control(exposureMode(), CausticaConfig.Rt.Exposure.MODE.defaultValue()), + control(manualEv(), Math.clamp(Math.round(CausticaConfig.Rt.Exposure.MANUAL_EV.defaultValue() * 10.0f), -150, 150)), + control(gamma(), Math.clamp(Math.round(CausticaConfig.Rt.Tonemap.GAMMA.defaultValue() * 100.0f), 50, 150)), + }; + } + + public static ResettableControl sdrToneMapper() { + StringSetting setting = CausticaConfig.Rt.Sdr.TONE_MAPPER; + return new ResettableControl( + toneMapper("caustica.options.rt.sdrToneMapper", RtToneMapping.sdrConfigNames(), setting), + () -> setting.set(setting.defaultValue())); + } + + public static ResettableControl hdrToneMapper() { + StringSetting setting = CausticaConfig.Rt.Hdr.TONE_MAPPER; + return new ResettableControl( + toneMapper("caustica.options.rt.hdrToneMapper", RtToneMapping.hdrConfigNames(), setting), + () -> setting.set(setting.defaultValue())); + } + + private static OptionInstance toneMapper(String captionKey, List values, StringSetting setting) { + int currentIndex = Math.clamp(values.indexOf(setting.get()), 0, values.size() - 1); + return new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, index) -> Component.translatable( + "caustica.options.rt.toneMapper." + values.get(Math.clamp(index, 0, values.size() - 1))), + new OptionInstance.IntRange(0, values.size() - 1), + currentIndex, + index -> setting.set(values.get(Math.clamp(index, 0, values.size() - 1)))); + } + + /** HDR display controls shown in the tone-mapping submenu with reset-to-default support. */ + public static ResettableControl[] hdrDisplayOptions() { + return new ResettableControl[] { + control( + hdrPaperWhite(), + Math.clamp(Math.round(CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS.defaultValue()), 80, 500)), + control( + hdrPeak(), + Math.clamp( + Math.round(CausticaConfig.Rt.Hdr.PEAK_NITS.defaultValue() + / (float) CausticaConfig.Rt.Hdr.PEAK_NITS_STEP), + CausticaConfig.Rt.Hdr.PEAK_NITS_MIN / CausticaConfig.Rt.Hdr.PEAK_NITS_STEP, + CausticaConfig.Rt.Hdr.PEAK_NITS_MAX / CausticaConfig.Rt.Hdr.PEAK_NITS_STEP)), + }; + } + + /** Controls for exactly the selected mapper; ACES 2.0 and BT.2390 have no extra parameters. */ + public static ResettableControl[] activeToneMapperOptions(boolean hdr) { + if (hdr) { + return switch (RtToneMapping.HdrMode.parse(CausticaConfig.Rt.Hdr.TONE_MAPPER.get())) { + case ACES_2_0, BT2390 -> new ResettableControl[0]; + case PSYCHOV24 -> psychoV24Options( + CausticaConfig.Rt.Hdr.PSYCHOV24_COMPRESSION, + CausticaConfig.Rt.Hdr.PSYCHOV24_GAMUT_COMPRESSION, + CausticaConfig.Rt.Hdr.PSYCHOV24_HIGHLIGHTS, + CausticaConfig.Rt.Hdr.PSYCHOV24_SHADOWS, + CausticaConfig.Rt.Hdr.PSYCHOV24_CONTRAST, + CausticaConfig.Rt.Hdr.PSYCHOV24_PURITY); + }; + } + return switch (RtToneMapping.SdrMode.parse(CausticaConfig.Rt.Sdr.TONE_MAPPER.get())) { + case ACES_2_0 -> new ResettableControl[0]; + case AGX -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.agxContrast", CausticaConfig.Rt.Sdr.AGX_CONTRAST, 100, 0, 200, 2), + scaledFloatControl("caustica.options.rt.agxSaturation", CausticaConfig.Rt.Sdr.AGX_SATURATION, 100, 0, 300, 2), + }; + case PBR_NEUTRAL -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.pbrStartCompression", CausticaConfig.Rt.Sdr.PBR_START_COMPRESSION, 100, 0, 99, 2), + scaledFloatControl("caustica.options.rt.pbrDesaturation", CausticaConfig.Rt.Sdr.PBR_DESATURATION, 100, 0, 100, 2), + }; + case REINHARD -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.reinhardWhitePoint", CausticaConfig.Rt.Sdr.REINHARD_WHITE_POINT, 10, 10, 200, 1), + }; + case ACES -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.acesInputScale", CausticaConfig.Rt.Sdr.ACES_EXPOSURE, 100, 0, 400, 2), + }; + case LOTTES -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.lottesContrast", CausticaConfig.Rt.Sdr.LOTTES_CONTRAST, 100, 10, 500, 2), + scaledFloatControl("caustica.options.rt.lottesShoulder", CausticaConfig.Rt.Sdr.LOTTES_SHOULDER, 100, 10, 500, 2), + scaledFloatControl("caustica.options.rt.lottesHdrMax", CausticaConfig.Rt.Sdr.LOTTES_HDR_MAX, 10, 10, 640, 1), + scaledFloatControl("caustica.options.rt.lottesMidIn", CausticaConfig.Rt.Sdr.LOTTES_MID_IN, 100, 1, 100, 2), + scaledFloatControl("caustica.options.rt.lottesMidOut", CausticaConfig.Rt.Sdr.LOTTES_MID_OUT, 100, 1, 100, 2), + }; + case UNCHARTED_2 -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.unchartedShoulderStrength", CausticaConfig.Rt.Sdr.UNCHARTED_A, 100, 1, 100, 2), + scaledFloatControl("caustica.options.rt.unchartedLinearStrength", CausticaConfig.Rt.Sdr.UNCHARTED_B, 100, 1, 200, 2), + scaledFloatControl("caustica.options.rt.unchartedLinearAngle", CausticaConfig.Rt.Sdr.UNCHARTED_C, 100, 0, 100, 2), + scaledFloatControl("caustica.options.rt.unchartedToeStrength", CausticaConfig.Rt.Sdr.UNCHARTED_D, 100, 1, 200, 2), + scaledFloatControl("caustica.options.rt.unchartedToeNumerator", CausticaConfig.Rt.Sdr.UNCHARTED_E, 100, 0, 100, 2), + scaledFloatControl("caustica.options.rt.unchartedToeDenominator", CausticaConfig.Rt.Sdr.UNCHARTED_F, 100, 1, 200, 2), + scaledFloatControl("caustica.options.rt.unchartedWhitePoint", CausticaConfig.Rt.Sdr.UNCHARTED_WHITE_POINT, 10, 10, 320, 1), + }; + case GT -> new ResettableControl[] { + scaledFloatControl("caustica.options.rt.gtContrast", CausticaConfig.Rt.Sdr.GT_CONTRAST, 100, 10, 400, 2), + scaledFloatControl("caustica.options.rt.gtLinearStart", CausticaConfig.Rt.Sdr.GT_LINEAR_START, 100, 1, 99, 2), + scaledFloatControl("caustica.options.rt.gtLinearLength", CausticaConfig.Rt.Sdr.GT_LINEAR_LENGTH, 100, 1, 400, 2), + scaledFloatControl("caustica.options.rt.gtBlackCurve", CausticaConfig.Rt.Sdr.GT_BLACK_CURVE, 100, 10, 400, 2), + scaledFloatControl("caustica.options.rt.gtBlackLift", CausticaConfig.Rt.Sdr.GT_BLACK_LIFT, 100, -50, 50, 2), + }; + case PSYCHOV24 -> psychoV24Options( + CausticaConfig.Rt.Sdr.PSYCHOV24_COMPRESSION, + CausticaConfig.Rt.Sdr.PSYCHOV24_GAMUT_COMPRESSION, + CausticaConfig.Rt.Sdr.PSYCHOV24_HIGHLIGHTS, + CausticaConfig.Rt.Sdr.PSYCHOV24_SHADOWS, + CausticaConfig.Rt.Sdr.PSYCHOV24_CONTRAST, + CausticaConfig.Rt.Sdr.PSYCHOV24_PURITY); + }; + } + + public static Component activeToneMapperName(boolean hdr) { + String name = hdr ? CausticaConfig.Rt.Hdr.TONE_MAPPER.get() : CausticaConfig.Rt.Sdr.TONE_MAPPER.get(); + return Component.translatable("caustica.options.rt.toneMapper." + name); + } + private static OptionInstance exposureMode() { StringSetting setting = CausticaConfig.Rt.Exposure.MODE; return new OptionInstance<>( @@ -89,6 +234,31 @@ private static OptionInstance manualEv() { tenths -> setting.set(tenths / 10.0f)); } + private static OptionInstance exposureLowPercentile() { + return percentile("caustica.options.rt.exposureLowPercentile", + CausticaConfig.Rt.Exposure.LOW_PERCENTILE); + } + + private static OptionInstance exposureHighPercentile() { + return percentile("caustica.options.rt.exposureHighPercentile", + CausticaConfig.Rt.Exposure.HIGH_PERCENTILE); + } + + private static OptionInstance percentile(String captionKey, FloatSetting setting) { + return new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, percent) -> Options.genericValueLabel(caption, + Component.literal(percent + "%")), + new OptionInstance.IntRange(0, 100), + Math.clamp(Math.round(setting.value() * 100.0f), 0, 100), + percent -> setting.set(percent / 100.0f)); + } + + private static OptionInstance preExposure() { + return bool("caustica.options.rt.preExposure", CausticaConfig.Rt.Exposure.PRE_EXPOSURE); + } + private static OptionInstance gamma() { FloatSetting setting = CausticaConfig.Rt.Tonemap.GAMMA; return new OptionInstance<>( @@ -177,19 +347,115 @@ private static OptionInstance hdrUiBrightness() { nits -> setting.set(nits.floatValue())); } - // Each step selects a baked ACES HDR mastering target. Changes take effect on the next frame. + private static OptionInstance hdrPaperWhite() { + FloatSetting setting = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS; + return new OptionInstance<>( + "caustica.options.rt.hdrPaperWhite", + OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.hdrPaperWhite.tooltip")), + (caption, nits) -> Options.genericValueLabel(caption, Component.literal(nits + " nits")), + new OptionInstance.IntRange(80, 500), + Math.clamp(Math.round(setting.value()), 80, 500), + nits -> setting.set(nits.floatValue())); + } + + // Each position is one 50-nit increment. ACES 2.0 selects the nearest baked LUT; analytical HDR + // modes use the exact selected peak. Changes take effect on the next frame. private static OptionInstance hdrPeak() { IntSetting setting = CausticaConfig.Rt.Hdr.PEAK_NITS; - List steps = CausticaConfig.Rt.Hdr.PEAK_NITS_STEPS; - int initialPeak = steps.contains(setting.value()) ? setting.value() : 1000; - int initialPosition = steps.indexOf(initialPeak); + int minPosition = CausticaConfig.Rt.Hdr.PEAK_NITS_MIN / CausticaConfig.Rt.Hdr.PEAK_NITS_STEP; + int maxPosition = CausticaConfig.Rt.Hdr.PEAK_NITS_MAX / CausticaConfig.Rt.Hdr.PEAK_NITS_STEP; + int initialPosition = Math.clamp( + Math.round(setting.value() / (float) CausticaConfig.Rt.Hdr.PEAK_NITS_STEP), + minPosition, + maxPosition); return new OptionInstance<>( "caustica.options.rt.hdrPeak", OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.hdrPeak.tooltip")), - (caption, position) -> Options.genericValueLabel(caption, Component.literal(steps.get(position) + " nits")), - new OptionInstance.IntRange(0, steps.size() - 1), - Math.max(initialPosition, 0), - position -> setting.set(steps.get(position))); + (caption, position) -> Options.genericValueLabel(caption, + Component.literal(position * CausticaConfig.Rt.Hdr.PEAK_NITS_STEP + " nits")), + new OptionInstance.IntRange(minPosition, maxPosition), + initialPosition, + position -> setting.set(position * CausticaConfig.Rt.Hdr.PEAK_NITS_STEP)); + } + + private static ResettableControl[] psychoV24Options( + FloatSetting compression, + FloatSetting gamutCompression, + FloatSetting highlights, + FloatSetting shadows, + FloatSetting contrast, + FloatSetting purity) { + return new ResettableControl[] { + psychoCompressionControl(compression), + percentageControl("caustica.options.rt.psychov24GamutCompression", gamutCompression), + percentageControl("caustica.options.rt.psychov24Highlights", highlights, 0, 300), + percentageControl("caustica.options.rt.psychov24Shadows", shadows, 0, 300), + percentageControl("caustica.options.rt.psychov24Contrast", contrast, 10, 300), + percentageControl("caustica.options.rt.psychov24Purity", purity, 0, 300), + }; + } + + private static ResettableControl psychoCompressionControl(FloatSetting setting) { + String captionKey = "caustica.options.rt.psychov24Compression"; + OptionInstance option = new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, value) -> Options.genericValueLabel(caption, + value == 0 + ? Component.translatable(captionKey + ".auto") + : Component.literal(String.format(Locale.ROOT, "%.2f", value / 100.0f))), + new OptionInstance.IntRange(0, 800), + Math.clamp(Math.round(setting.value() * 100.0f), 0, 800), + value -> setting.set(value / 100.0f)); + return control(option, Math.clamp(Math.round(setting.defaultValue() * 100.0f), 0, 800)); + } + + private static ResettableControl scaledFloatControl( + String captionKey, FloatSetting setting, int scale, int min, int max, int decimals) { + return scaledFloatControl(captionKey, setting, scale, min, max, decimals, ""); + } + + private static ResettableControl scaledFloatControl( + String captionKey, FloatSetting setting, int scale, int min, int max, int decimals, String suffix) { + OptionInstance option = new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, value) -> Options.genericValueLabel(caption, + Component.literal(String.format(Locale.ROOT, "%." + decimals + "f%s", + value / (float) scale, suffix))), + new OptionInstance.IntRange(min, max), + Math.clamp(Math.round(setting.value() * scale), min, max), + value -> setting.set(value / (float) scale)); + return control(option, Math.clamp(Math.round(setting.defaultValue() * scale), min, max)); + } + + private static ResettableControl percentageControl(String captionKey, FloatSetting setting) { + return percentageControl(captionKey, setting, 0, 100); + } + + private static ResettableControl percentageControl( + String captionKey, FloatSetting setting, int min, int max) { + OptionInstance option = new OptionInstance<>( + captionKey, + OptionInstance.cachedConstantTooltip(Component.translatable(captionKey + ".tooltip")), + (caption, value) -> Options.genericValueLabel(caption, Component.literal(value + "%")), + new OptionInstance.IntRange(min, max), + Math.clamp(Math.round(setting.value() * 100.0f), min, max), + value -> setting.set(value / 100.0f)); + return control(option, Math.clamp(Math.round(setting.defaultValue() * 100.0f), min, max)); + } + + /** Launcher paired with Debug View on the main Video Settings page. */ + public static Button toneMappingButton(Screen parent, Runnable beforeOpen) { + return Button.builder( + Component.translatable("caustica.options.rt.toneMappingMenu"), + button -> { + beforeOpen.run(); + Minecraft minecraft = Minecraft.getInstance(); + minecraft.setScreenAndShow(new RtToneMappingOptionsScreen(parent, minecraft.options)); + }) + .tooltip(Tooltip.create(Component.translatable("caustica.options.rt.toneMappingMenu.tooltip"))) + .build(); } private static OptionInstance debugView() { diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java index b0bc35f0..b7bf361e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java @@ -4,9 +4,11 @@ import dev.comfyfluffy.caustica.client.RtVideoOptions; import java.util.ArrayList; import java.util.List; +import net.minecraft.client.Minecraft; import net.minecraft.client.OptionInstance; import net.minecraft.client.Options; import net.minecraft.client.gui.components.OptionsList; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.options.VideoSettingsScreen; import net.minecraft.network.chat.Component; import org.spongepowered.asm.mixin.Mixin; @@ -69,6 +71,12 @@ private static OptionInstance[] qualityOptions(Options options) { } list.addHeader(CAUSTICA$RT_HEADER); list.addSmall(RtVideoOptions.runtimeOptions()); + list.addSmall(List.of(RtVideoOptions.toneMappingButton( + (Screen) (Object) this, + () -> { + list.applyUnsavedChanges(); + CausticaConfig.save(); + }))); } @Inject(method = "removed", at = @At("TAIL")) diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java index a6564e0d..3d495672 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanGpuSurfaceMixin.java @@ -322,8 +322,8 @@ public abstract class VulkanGpuSurfaceMixin { */ @Inject(method = "blitFromTexture", at = @At("HEAD"), cancellable = true) private void caustica$presentHdr(CommandEncoderBackend commandEncoder, GpuTextureView textureView, CallbackInfo ci) { - // The mastering peak is a live option and selects a different baked ACES output LUT without forcing - // swapchain recreation. Refresh the metadata once when that selected LUT changes. + // The mastering peak is a live option. ACES selects the nearest packaged LUT while analytical modes + // use the exact configured peak; neither requires swapchain recreation. Refresh metadata on change. caustica$applyHdrMetadataIfNeeded(); if (this.currentImageIndex < 0) { return; @@ -357,7 +357,7 @@ public abstract class VulkanGpuSurfaceMixin { || !RtHdr.metadataExtensionEnabled() || this.swapchain == 0L) { return; } - int peakNits = CausticaConfig.Rt.Hdr.PEAK_NITS.value(); + int peakNits = CausticaConfig.Rt.Hdr.effectivePeakNits(); if (this.caustica$metadataSwapchain == this.swapchain && this.caustica$metadataPeakNits == peakNits) { return; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index d65090f8..3ce8dc83 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -69,6 +69,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.pipeline.RtToneMapping; import dev.comfyfluffy.caustica.rt.terrain.RtTerrain; import java.nio.ByteBuffer; @@ -606,10 +607,13 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { if (sdrToneLut == null) { sdrToneLut = RtToneLut.load(ctx, "sdr_aces2_rec709.bin"); } - // 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 display peak is live. ACES 2.0 has four packaged mastering targets, so bind the + // nearest one; analytical HDR modes use the exact configured peak in their push constants. + int requestedHdrNits = CausticaConfig.Rt.Hdr.PEAK_NITS.value(); + int wantedHdrLutNits = CausticaConfig.Rt.Hdr.nearestAcesLutNits(requestedHdrNits); + if (hdrToneLut == null || loadedHdrLutNits != wantedHdrLutNits) { + RtToneLut newHdrLut = RtToneLut.load(ctx, + "hdr_aces2_rec2020_" + wantedHdrLutNits + "nit.bin"); 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 @@ -623,11 +627,10 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { hdrToneLut.destroy(); } hdrToneLut = newHdrLut; - loadedHdrLutNits = wantedHdrNits; + loadedHdrLutNits = wantedHdrLutNits; } - // The scene-referred LMT is part of the immutable versioned look package and shared by - // both SDR and HDR output transforms. It cannot be switched independently from the - // package's exposure and photometric anchors. + // The package LMT feeds only the ACES 2.0 SDR and HDR output transforms. Analytical + // mappers consume the exposed scene signal directly and own their display rendering. if (lookLut == null) { lookLut = RtToneLut.loadResource(ctx, LOOK.lmtResource()); } @@ -1260,8 +1263,9 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "map RT to display"); 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, + int displayPeakNits = CausticaConfig.Rt.Hdr.effectivePeakNits(); + displayPipeline.dispatch(cmd, displayW, displayH, RtToneMapping.current(), + sdrToneLut.size, CausticaConfig.Rt.Tonemap.GAMMA.value(), displayPeakNits, true, lookLut.size, LOOK.bloom().strength() / bloomLevels.length); } hdrWrittenThisFrame = CausticaConfig.Rt.Hdr.enabled(); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java index 079f9224..386eb2c6 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java @@ -66,11 +66,13 @@ public final class RtContext { private final int shaderGroupHandleAlignment; private final int maxShaderGroupStride; private final int accelerationStructureScratchAlignment; + private final int maxPushConstantsSize; private final long updateAfterBindCombinedImageSamplerLimit; private long commandPool; private RtContext(VulkanDevice device, long vma, int handleSize, int baseAlign, int handleAlign, - int maxSbtStride, int scratchAlign, long updateAfterBindCombinedImageSamplerLimit) { + int maxSbtStride, int scratchAlign, int maxPushConstantsSize, + long updateAfterBindCombinedImageSamplerLimit) { this.device = device; this.vk = device.vkDevice(); this.vma = vma; @@ -82,6 +84,7 @@ private RtContext(VulkanDevice device, long vma, int handleSize, int baseAlign, this.shaderGroupHandleAlignment = handleAlign; this.maxShaderGroupStride = maxSbtStride; this.accelerationStructureScratchAlignment = scratchAlign; + this.maxPushConstantsSize = maxPushConstantsSize; this.updateAfterBindCombinedImageSamplerLimit = updateAfterBindCombinedImageSamplerLimit; this.gpuExecutor = new RtGpuExecutor(this); } @@ -157,14 +160,17 @@ private static RtContext create(VulkanDevice device) { CausticaMod.LOGGER.info( "RT portability limits: SBT handleAlignment={}, baseAlignment={}, maxStride={}; " - + "AS scratchAlignment={}; update-after-bind combined-sampler limit={}", + + "AS scratchAlignment={}; maxPushConstantsSize={}; " + + "update-after-bind combined-sampler limit={}", rtProps.shaderGroupHandleAlignment(), rtProps.shaderGroupBaseAlignment(), Integer.toUnsignedLong(rtProps.maxShaderGroupStride()), - asProps.minAccelerationStructureScratchOffsetAlignment(), combinedImageSamplerLimit); + asProps.minAccelerationStructureScratchOffsetAlignment(), limits.maxPushConstantsSize(), + combinedImageSamplerLimit); return new RtContext(device, pVma.get(0), rtProps.shaderGroupHandleSize(), rtProps.shaderGroupBaseAlignment(), rtProps.shaderGroupHandleAlignment(), rtProps.maxShaderGroupStride(), - asProps.minAccelerationStructureScratchOffsetAlignment(), combinedImageSamplerLimit); + asProps.minAccelerationStructureScratchOffsetAlignment(), limits.maxPushConstantsSize(), + combinedImageSamplerLimit); } } @@ -216,6 +222,11 @@ public int maxShaderGroupStride() { return maxShaderGroupStride; } + /** Device-reported limit for one Vulkan push-constant range, in bytes. */ + public int maxPushConstantsSize() { + return maxPushConstantsSize; + } + /** Conservative combined-image-sampler limit for a descriptor set using update-after-bind. */ public long updateAfterBindCombinedImageSamplerLimit() { return updateAfterBindCombinedImageSamplerLimit; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java index 4ba56fdb..39f8f075 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtHdr.java @@ -19,7 +19,7 @@ * HDR display support — capability detection/logging plus static mastering metadata for PQ swapchains. * Surface enumeration tells the swapchain-ownership code whether HDR10 is available on the current driver, * window system, compositor, and monitor; {@code VK_EXT_hdr_metadata}, when supported, describes the - * Rec.2020/D65 ACES virtual mastering display to that presentation stack. + * selected Rec.2020/D65 virtual mastering display to that presentation stack. * *

Extended color spaces are reported only when the instance enables * {@code VK_EXT_swapchain_colorspace}. {@code VulkanInstanceMixin} enables it when available; this class @@ -77,10 +77,10 @@ public static boolean metadataExtensionEnabled() { /** * Assigns SMPTE ST 2086 / CTA-861.3 static metadata to one PQ swapchain. * - *

The ACES HDR output LUT is a Rec.2020/D65 virtual master capped at one of the baked mastering - * peaks, so that peak is both the mastering-display maximum and MaxCLL. MaxFALL cannot be known without - * analysing every rendered frame; Vulkan explicitly permits unknown fields to be zero, which is more - * truthful than inventing a scene-average value. + *

The active HDR output transform supplies the Rec.2020/D65 virtual master peak, so metadata matches + * the actual displayed transform. ACES 2.0 uses the nearest packaged LUT peak; analytical modes use the + * exact configured peak. MaxFALL cannot be known without analysing every rendered frame; Vulkan explicitly + * permits unknown fields to be zero, which is more truthful than inventing a scene-average value. */ public static boolean applyMasteringMetadata(VkDevice device, long swapchain, int masteringPeakNits) { if (!hdrMetadataExtensionEnabled || swapchain == 0L) { @@ -136,9 +136,10 @@ record MasteringMetadata( /** Logs the resolved HDR config once (cheap; safe to call repeatedly — guarded by the surface log). */ public static void logConfig() { CausticaMod.LOGGER.info( - "HDR config: enabled={} ui={}nits peak={}nits -> {}", + "HDR config: enabled={} ui={}nits requestedPeak={}nits effectivePeak={}nits -> {}", CausticaConfig.Rt.Hdr.enabled(), CausticaConfig.Rt.Hdr.UI_NITS.value(), CausticaConfig.Rt.Hdr.PEAK_NITS.value(), + CausticaConfig.Rt.Hdr.effectivePeakNits(), CausticaConfig.Rt.Hdr.enabled() ? "HDR display path active" : "SDR display path"); } 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 76ea7964..6438d673 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java @@ -66,6 +66,10 @@ private RtDisplayPipeline(RtContext ctx, long dsl, long pool, long set, long lay } public static RtDisplayPipeline create(RtContext ctx) { + if (ctx.maxPushConstantsSize() < PUSH_BYTES) { + throw new IllegalStateException("Caustica display pipeline requires at least " + PUSH_BYTES + + " push-constant bytes; device reports " + ctx.maxPushConstantsSize()); + } VkDevice vk = ctx.vk(); try (MemoryStack stack = MemoryStack.stackPush()) { VkDescriptorSetLayoutBinding.Buffer binds = VkDescriptorSetLayoutBinding.calloc(DISPLAY_BINDING_COUNT, stack); @@ -197,20 +201,35 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage } /** - * Run the display mapping through the baked ACES 2.0 LUTs: SDR - * (binding 0) always writes; the PQ-encoded HDR image (binding 3) also writes when - * {@code hdrEnabled}. The HDR LUT is baked for a fixed mastering-nits peak (see - * {@code CausticaConfig.Rt.Hdr.PEAK_NITS_STEPS}), selected host-side by which LUT resource is bound. + * Run the selected display transforms. SDR always writes; the PQ-encoded HDR image also writes + * when {@code hdrEnabled}. ACES 2.0 uses the bound display LUTs, while analytical modes execute + * directly in the display shader. */ - public void dispatch(VkCommandBuffer cmd, int width, int height, boolean hdrEnabled, int lutSize, - float gamma, float hdrPeakNits, boolean lookEnabled, int lookLutSize, + public void dispatch(VkCommandBuffer cmd, int width, int height, RtToneMapping.Settings toneMapping, + int lutSize, float gamma, float hdrPeakNits, boolean lookEnabled, int lookLutSize, float bloomStrength) { 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); + RtToneMapping.Parameters sdr = toneMapping.sdrParameters(); + RtToneMapping.Parameters hdr = toneMapping.hdrParameters(); + new DisplayPushData( + toneMapping.hdrEnabled() ? 1 : 0, + (float) lutSize, + gamma, + hdrPeakNits, + lookEnabled ? 1 : 0, + (float) lookLutSize, + bloomStrength, + toneMapping.sdrMode(), + toneMapping.hdrMode(), + toneMapping.paperWhiteNits(), + toneMapping.headroom(), + sdr.param0(), sdr.param1(), sdr.param2(), sdr.param3(), + sdr.param4(), sdr.param5(), sdr.param6(), sdr.param7(), + hdr.param0(), hdr.param1(), hdr.param2(), hdr.param3(), + hdr.param4(), hdr.param5(), hdr.param6(), hdr.param7()).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/RtExposure.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java index 3676f9d7..14d3a03f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java @@ -41,6 +41,7 @@ public final class RtExposure { private ExposureCurve cachedCurve; private boolean resetRequested = true; private int resetSequence; + private Mode previousMode; /** This frame's latched pre-exposure; see {@link #beginFrame(RtGpuExecutor.GraphicsUseWaiter)}. */ private float framePreExposure = 1.0f; @@ -201,6 +202,7 @@ public void destroy() { pendingStateReadback = null; completedState = null; framePreExposure = 1.0f; + previousMode = null; } // Manual mode's exposure scale, also used as the auto-history seed (resetAutoHistory) so the very @@ -382,9 +384,13 @@ private void logOnce() { + ", emissiveCap=" + autoConfig.emissiveWeightCap + ", curve=" + CausticaConfig.Rt.Exposure.curve() + ")" : Float.toString(manualExposureScale()); + RtToneMapping.Settings toneMapping = RtToneMapping.current(); CausticaMod.LOGGER.info("RT display exposure: mode={}, exposure={}, " - + "tonemap=aces2.0(lookPackage={},gamma={}), DLSS-RR exposure=NGX auto", - mode.configName, exposureText, RtLookPackage.current().id(), + + "tonemap=sdr:{},hdr:{}(lookPackage={},paperWhiteNits={},gamma={}), DLSS-RR exposure=NGX auto", + mode.configName, exposureText, + RtToneMapping.SdrMode.parse(CausticaConfig.Rt.Sdr.TONE_MAPPER.get()).canonicalName(), + RtToneMapping.HdrMode.parse(CausticaConfig.Rt.Hdr.TONE_MAPPER.get()).canonicalName(), + RtLookPackage.current().id(), toneMapping.paperWhiteNits(), CausticaConfig.Rt.Tonemap.GAMMA.value()); } @@ -397,6 +403,9 @@ private static float manualEv() { } private AutoConfig autoConfig() { + PercentileWindow percentiles = PercentileWindow.sanitize( + CausticaConfig.Rt.Exposure.LOW_PERCENTILE.value(), + CausticaConfig.Rt.Exposure.HIGH_PERCENTILE.value()); return new AutoConfig( CausticaConfig.Rt.Exposure.KEY.value(), CausticaConfig.Rt.Exposure.minEv(), @@ -404,8 +413,8 @@ private AutoConfig autoConfig() { CausticaConfig.Rt.Exposure.ADAPT_DARKEN.value(), CausticaConfig.Rt.Exposure.ADAPT_BRIGHTEN.value(), manualEv(), - CausticaConfig.Rt.Exposure.LOW_PERCENTILE.value(), - CausticaConfig.Rt.Exposure.HIGH_PERCENTILE.value(), + percentiles.low(), + percentiles.high(), CausticaConfig.Rt.Exposure.STRIDE.value(), CausticaConfig.Rt.Exposure.CENTER_WEIGHT_SIGMA.value(), CausticaConfig.Rt.Exposure.CENTER_WEIGHT_FLOOR.value(), @@ -427,6 +436,10 @@ private AutoConfig autoConfig() { */ public void beginFrame(RtGpuExecutor.GraphicsUseWaiter graphicsUseWaiter) { Mode currentMode = mode(); + if (modeTransitionRequiresReset(previousMode, currentMode)) { + requestReset(); + } + previousMode = currentMode; boolean reset = currentMode == Mode.AUTO && resetRequested; if (reset) { resetSequence++; @@ -489,7 +502,7 @@ private float computePreExposure() { // truncate -- silently de-centring exactly the case pre-exposure exists to handle. The // controller's own minEv/maxEv already bound this value; here we only reject garbage. float previous = completedState.previous(); - return Float.isFinite(previous) && previous > 0.0f ? previous : 1.0f; + return sanitizePreExposure(previous); } private ByteBuffer stateDataBuffer() { @@ -512,8 +525,50 @@ record AutoConfig(float key, float minEv, float maxEv, float adaptDarken, float * unit convention's offset applies. */ float evOffset() { - return RtSceneUnits.EV100_OFFSET - (float) (Math.log(Math.max(preExposure, 1.0e-12f)) / Math.log(2.0)); + return ev100Offset(preExposure); + } + } + + static float ev100Offset(float preExposure) { + float safePreExposure = sanitizePreExposure(preExposure); + return RtSceneUnits.EV100_OFFSET + - (float) (Math.log(safePreExposure) / Math.log(2.0)); + } + + private static float sanitizePreExposure(float value) { + return Float.isFinite(value) && value > 0.0f ? value : 1.0f; + } + + record PercentileWindow(float low, float high) { + private static final float DEFAULT_LOW = 0.50f; + private static final float DEFAULT_HIGH = 0.95f; + + static PercentileWindow sanitize(float low, float high) { + low = sanitizeValue(low, DEFAULT_LOW); + high = sanitizeValue(high, DEFAULT_HIGH); + if (high < low) { + float swap = low; + low = high; + high = swap; + } + if (!(low < high)) { + if (low >= 1.0f) { + low = Math.nextDown(1.0f); + high = 1.0f; + } else { + high = Math.nextUp(low); + } + } + return new PercentileWindow(low, high); } + + private static float sanitizeValue(float value, float fallback) { + return Float.isFinite(value) ? Math.clamp(value, 0.0f, 1.0f) : fallback; + } + } + + static boolean modeTransitionRequiresReset(Mode previous, Mode current) { + return previous != null && previous != current && current == Mode.AUTO; } private ExposureCurve curveConfig() { @@ -624,7 +679,7 @@ private static float slope(float x0, float y0, float x1, float y1) { } } - private enum Mode { + enum Mode { MANUAL("manual"), AUTO("auto"); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java index 79cc5852..1f61b77a 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePipeline.java @@ -35,6 +35,8 @@ /** Compute pipelines for histogram auto-exposure over the RT HDR trace output. */ final class RtExposurePipeline { private static final String SHADER_DIR = "/caustica/shaders/pipelines/"; + private static final int HISTOGRAM_WORKGROUP_SIZE = 16; + private static final long MAX_WEIGHTED_SAMPLES = 0xffff_ffffL / 256L; private final RtContext ctx; private final long histDescriptorSetLayout; @@ -195,16 +197,64 @@ void dispatchHistogram(org.lwjgl.vulkan.VkCommandBuffer cmd, int width, int heig VK10.vkCmdBindDescriptorSets(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, histPipelineLayout, 0, stack.longs(histDescriptorSet), null); ByteBuffer push = stack.malloc(ExposureHistPushData.BYTE_SIZE); - new ExposureHistPushData(config.stride(), config.centerWeightSigma(), config.centerWeightFloor()) + int stride = effectiveStride(width, height, config.stride()); + new ExposureHistPushData(stride, config.centerWeightSigma(), config.centerWeightFloor()) .write(push); VK10.vkCmdPushConstants(cmd, histPipelineLayout, VK10.VK_SHADER_STAGE_COMPUTE_BIT, 0, push); - int stride = config.stride(); - int sampleWidth = (width + stride - 1) / stride; - int sampleHeight = (height + stride - 1) / stride; - VK10.vkCmdDispatch(cmd, (sampleWidth + 15) / 16, (sampleHeight + 15) / 16, 1); + VK10.vkCmdDispatch(cmd, dispatchGroups(width, stride), dispatchGroups(height, stride), 1); } } + static int safeStride(int stride) { + return Math.max(stride, 1); + } + + static int effectiveStride(int width, int height, int requested) { + int stride = safeStride(requested); + int maxDimension = Math.max(Math.max(width, height), 1); + while (weightedSampleCount(width, height, stride) > MAX_WEIGHTED_SAMPLES + && stride < maxDimension) { + int next = stride > maxDimension / 2 ? maxDimension : stride * 2; + if (next == stride) { + break; + } + stride = next; + } + int low = safeStride(requested); + int high = stride; + while (low < high) { + int middle = low + (high - low) / 2; + if (weightedSampleCount(width, height, middle) <= MAX_WEIGHTED_SAMPLES) { + high = middle; + } else { + low = middle + 1; + } + } + return low; + } + + private static long weightedSampleCount(int width, int height, int stride) { + long columns = sampledExtent(width, stride); + long rows = sampledExtent(height, stride); + if (columns > Long.MAX_VALUE / rows) { + return Long.MAX_VALUE; + } + return columns * rows; + } + + static int sampledExtent(int extent, int stride) { + long positiveExtent = Math.max((long) extent, 1L); + long safeDivisor = safeStride(stride); + long samples = (positiveExtent + safeDivisor - 1L) / safeDivisor; + return (int) Math.min(Integer.MAX_VALUE, Math.max(samples, 1L)); + } + + static int dispatchGroups(int extent, int stride) { + long samples = sampledExtent(extent, stride); + long groups = (samples + HISTOGRAM_WORKGROUP_SIZE - 1L) / HISTOGRAM_WORKGROUP_SIZE; + return (int) Math.min(Integer.MAX_VALUE, Math.max(groups, 1L)); + } + void dispatchResolve(org.lwjgl.vulkan.VkCommandBuffer cmd, RtExposure.AutoConfig config, float frameTimeSeconds) { try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure resolve")) { VK10.vkCmdBindPipeline(cmd, VK10.VK_PIPELINE_BIND_POINT_COMPUTE, resolvePipeline); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMapping.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMapping.java new file mode 100644 index 00000000..0d3978ab --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMapping.java @@ -0,0 +1,366 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import dev.comfyfluffy.caustica.CausticaConfig; +import java.util.Arrays; +import java.util.List; + +/** + * Central registry of stable tone-mapper mode IDs, config names, and aliases. Owns only the + * mode table and the immutable {@link Settings} record read by the display dispatch. Does not + * own Vulkan resources, shader compilation, pipeline lifetime, config file I/O, or Minecraft widgets. + * + *

ACES 2.0 is the SDR and HDR default and remains mode 0 for the reference LUT path. Unknown + * config values fall back to that default. The + * integer IDs are mirrored by the display shader's mode switch. + */ +public final class RtToneMapping { + private static final List SDR_CONFIG_NAMES = + Arrays.stream(SdrMode.values()).map(SdrMode::canonicalName).toList(); + private static final List HDR_CONFIG_NAMES = + Arrays.stream(HdrMode.values()).map(HdrMode::canonicalName).toList(); + private static volatile Settings cachedSettings; + + private RtToneMapping() { + } + + /** Stable SDR tone-mapper modes. IDs mirror the display shader's mode switch. */ + public enum SdrMode { + ACES_2_0(0, "aces2.0", "aces-2.0", "aces2"), + AGX(1, "agx"), + PBR_NEUTRAL(2, "pbr-neutral"), + REINHARD(3, "reinhard"), + ACES(4, "aces"), + LOTTES(5, "lottes"), + UNCHARTED_2(6, "uncharted2", "uncharted-2"), + GT(7, "gt", "uchimura"), + PSYCHOV24( + 8, + "psychov24", + "psychovisual", + "psycho-visual", + "psychov", + "psychov11", + "psychov23", + "psychov24-experimental"); + + private final int id; + private final String canonicalName; + private final List aliases; + + SdrMode(int id, String canonicalName, String... aliases) { + this.id = id; + this.canonicalName = canonicalName; + this.aliases = List.of(aliases); + } + + public int id() { + return id; + } + + public String canonicalName() { + return canonicalName; + } + + /** Case-insensitive parse with whitespace trimming; unknown values use the ACES 2.0 default. */ + public static SdrMode parse(String value) { + SdrMode known = find(value); + return known != null ? known : ACES_2_0; + } + + /** Returns whether the value is a canonical name or a committed compatibility alias. */ + public static boolean isKnown(String value) { + return find(value) != null; + } + + private static SdrMode find(String value) { + if (value != null) { + String trimmed = value.trim(); + for (SdrMode mode : values()) { + if (mode.canonicalName.equalsIgnoreCase(trimmed)) { + return mode; + } + for (String alias : mode.aliases) { + if (alias.equalsIgnoreCase(trimmed)) { + return mode; + } + } + } + } + return null; + } + } + + /** Stable HDR tone-mapper modes. IDs mirror the display shader's mode switch. */ + public enum HdrMode { + ACES_2_0(0, "aces2.0", "aces-2.0", "aces2"), + BT2390(3, "bt2390", "bt-2390", "bt.2390", "standard", "standard-hdr"), + PSYCHOV24( + 2, + "psychov24", + "psychovisual", + "psycho-visual", + "psychov", + "psychov11", + "psychov23", + "psychov24-experimental"); + + private final int id; + private final String canonicalName; + private final List aliases; + + HdrMode(int id, String canonicalName, String... aliases) { + this.id = id; + this.canonicalName = canonicalName; + this.aliases = List.of(aliases); + } + + public int id() { + return id; + } + + public String canonicalName() { + return canonicalName; + } + + /** Case-insensitive parse with whitespace trimming; unknown values use the ACES 2.0 default. */ + public static HdrMode parse(String value) { + HdrMode known = find(value); + return known != null ? known : ACES_2_0; + } + + /** Returns whether the value is a canonical name or a committed compatibility alias. */ + public static boolean isKnown(String value) { + return find(value) != null; + } + + private static HdrMode find(String value) { + if (value != null) { + String trimmed = value.trim(); + for (HdrMode mode : values()) { + if (mode.canonicalName.equalsIgnoreCase(trimmed)) { + return mode; + } + for (String alias : mode.aliases) { + if (alias.equalsIgnoreCase(trimmed)) { + return mode; + } + } + } + } + return null; + } + } + + /** Immutable snapshot of the current display tone-mapping settings, read every display dispatch. */ + public record Settings( + boolean hdrEnabled, + int sdrMode, + int hdrMode, + float paperWhiteNits, + float headroom, + Parameters sdrParameters, + Parameters hdrParameters) { + } + + /** Eight mode-dependent scalars mirrored by the display shader's push-constant parameter blocks. */ + public record Parameters( + float param0, + float param1, + float param2, + float param3, + float param4, + float param5, + float param6, + float param7) { + public static final Parameters NONE = + new Parameters(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + } + + /** Immutable canonical SDR mode names in enum order, for the Video Settings selection slider. */ + public static List sdrConfigNames() { + return SDR_CONFIG_NAMES; + } + + /** Immutable canonical HDR mode names in enum order, for the Video Settings selection slider. */ + public static List hdrConfigNames() { + return HDR_CONFIG_NAMES; + } + + /** Read the current sanitized config values, reusing the immutable snapshot while unchanged. */ + public static Settings current() { + SdrMode sdrMode = SdrMode.parse(CausticaConfig.Rt.Sdr.TONE_MAPPER.get()); + HdrMode hdrMode = HdrMode.parse(CausticaConfig.Rt.Hdr.TONE_MAPPER.get()); + boolean hdrEnabled = CausticaConfig.Rt.Hdr.enabled(); + float paperWhiteNits = CausticaConfig.Rt.Hdr.paperWhiteNits(); + float headroom = CausticaConfig.Rt.Hdr.headroom(); + Settings cached = cachedSettings; + if (cached != null + && cached.hdrEnabled() == hdrEnabled + && cached.sdrMode() == sdrMode.id() + && cached.hdrMode() == hdrMode.id() + && same(cached.paperWhiteNits(), paperWhiteNits) + && same(cached.headroom(), headroom) + && matchesSdrParameters(cached.sdrParameters(), sdrMode) + && matchesHdrParameters(cached.hdrParameters(), hdrMode)) { + return cached; + } + + Settings fresh = new Settings( + hdrEnabled, + sdrMode.id(), + hdrMode.id(), + paperWhiteNits, + headroom, + sdrParameters(sdrMode), + hdrParameters(hdrMode)); + cachedSettings = fresh; + return fresh; + } + + private static boolean matchesSdrParameters(Parameters parameters, SdrMode mode) { + return switch (mode) { + case ACES_2_0 -> parameters == Parameters.NONE; + case AGX -> same(parameters.param0(), CausticaConfig.Rt.Sdr.AGX_CONTRAST.value()) + && same(parameters.param1(), CausticaConfig.Rt.Sdr.AGX_SATURATION.value()); + case PBR_NEUTRAL -> same(parameters.param0(), CausticaConfig.Rt.Sdr.PBR_START_COMPRESSION.value()) + && same(parameters.param1(), CausticaConfig.Rt.Sdr.PBR_DESATURATION.value()); + case REINHARD -> same(parameters.param0(), CausticaConfig.Rt.Sdr.REINHARD_WHITE_POINT.value()); + case ACES -> same(parameters.param0(), CausticaConfig.Rt.Sdr.ACES_EXPOSURE.value()); + case LOTTES -> same(parameters.param0(), CausticaConfig.Rt.Sdr.LOTTES_CONTRAST.value()) + && same(parameters.param1(), CausticaConfig.Rt.Sdr.LOTTES_SHOULDER.value()) + && same(parameters.param2(), CausticaConfig.Rt.Sdr.LOTTES_HDR_MAX.value()) + && same(parameters.param3(), CausticaConfig.Rt.Sdr.LOTTES_MID_IN.value()) + && same(parameters.param4(), CausticaConfig.Rt.Sdr.LOTTES_MID_OUT.value()); + case UNCHARTED_2 -> same(parameters.param0(), CausticaConfig.Rt.Sdr.UNCHARTED_A.value()) + && same(parameters.param1(), CausticaConfig.Rt.Sdr.UNCHARTED_B.value()) + && same(parameters.param2(), CausticaConfig.Rt.Sdr.UNCHARTED_C.value()) + && same(parameters.param3(), CausticaConfig.Rt.Sdr.UNCHARTED_D.value()) + && same(parameters.param4(), CausticaConfig.Rt.Sdr.UNCHARTED_E.value()) + && same(parameters.param5(), CausticaConfig.Rt.Sdr.UNCHARTED_F.value()) + && same(parameters.param6(), CausticaConfig.Rt.Sdr.UNCHARTED_WHITE_POINT.value()); + case GT -> same(parameters.param0(), CausticaConfig.Rt.Sdr.GT_CONTRAST.value()) + && same(parameters.param1(), CausticaConfig.Rt.Sdr.GT_LINEAR_START.value()) + && same(parameters.param2(), CausticaConfig.Rt.Sdr.GT_LINEAR_LENGTH.value()) + && same(parameters.param3(), CausticaConfig.Rt.Sdr.GT_BLACK_CURVE.value()) + && same(parameters.param4(), CausticaConfig.Rt.Sdr.GT_BLACK_LIFT.value()); + case PSYCHOV24 -> matchesPsychoParameters(parameters, + CausticaConfig.Rt.Sdr.PSYCHOV24_COMPRESSION.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_GAMUT_COMPRESSION.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_HIGHLIGHTS.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_SHADOWS.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_CONTRAST.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_PURITY.value()); + }; + } + + private static boolean matchesHdrParameters(Parameters parameters, HdrMode mode) { + return switch (mode) { + case ACES_2_0, BT2390 -> parameters == Parameters.NONE; + case PSYCHOV24 -> matchesPsychoParameters(parameters, + CausticaConfig.Rt.Hdr.PSYCHOV24_COMPRESSION.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_GAMUT_COMPRESSION.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_HIGHLIGHTS.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_SHADOWS.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_CONTRAST.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_PURITY.value()); + }; + } + + private static boolean matchesPsychoParameters(Parameters parameters, float compression, + float gamutCompression, float highlights, + float shadows, float contrast, float purity) { + return same(parameters.param0(), compression) + && same(parameters.param1(), gamutCompression) + && same(parameters.param2(), highlights) + && same(parameters.param3(), shadows) + && same(parameters.param4(), contrast) + && same(parameters.param5(), purity) + && same(parameters.param6(), 0.0f) + && same(parameters.param7(), 0.0f); + } + + private static boolean same(float left, float right) { + return Float.floatToIntBits(left) == Float.floatToIntBits(right); + } + + private static Parameters sdrParameters(SdrMode mode) { + return switch (mode) { + case ACES_2_0 -> Parameters.NONE; + case AGX -> new Parameters( + CausticaConfig.Rt.Sdr.AGX_CONTRAST.value(), + CausticaConfig.Rt.Sdr.AGX_SATURATION.value(), + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + case PBR_NEUTRAL -> new Parameters( + CausticaConfig.Rt.Sdr.PBR_START_COMPRESSION.value(), + CausticaConfig.Rt.Sdr.PBR_DESATURATION.value(), + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + case REINHARD -> new Parameters( + CausticaConfig.Rt.Sdr.REINHARD_WHITE_POINT.value(), + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + case ACES -> new Parameters( + CausticaConfig.Rt.Sdr.ACES_EXPOSURE.value(), + 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + case LOTTES -> new Parameters( + CausticaConfig.Rt.Sdr.LOTTES_CONTRAST.value(), + CausticaConfig.Rt.Sdr.LOTTES_SHOULDER.value(), + CausticaConfig.Rt.Sdr.LOTTES_HDR_MAX.value(), + CausticaConfig.Rt.Sdr.LOTTES_MID_IN.value(), + CausticaConfig.Rt.Sdr.LOTTES_MID_OUT.value(), + 0.0f, 0.0f, 0.0f); + case UNCHARTED_2 -> new Parameters( + CausticaConfig.Rt.Sdr.UNCHARTED_A.value(), + CausticaConfig.Rt.Sdr.UNCHARTED_B.value(), + CausticaConfig.Rt.Sdr.UNCHARTED_C.value(), + CausticaConfig.Rt.Sdr.UNCHARTED_D.value(), + CausticaConfig.Rt.Sdr.UNCHARTED_E.value(), + CausticaConfig.Rt.Sdr.UNCHARTED_F.value(), + CausticaConfig.Rt.Sdr.UNCHARTED_WHITE_POINT.value(), + 0.0f); + case GT -> new Parameters( + CausticaConfig.Rt.Sdr.GT_CONTRAST.value(), + CausticaConfig.Rt.Sdr.GT_LINEAR_START.value(), + CausticaConfig.Rt.Sdr.GT_LINEAR_LENGTH.value(), + CausticaConfig.Rt.Sdr.GT_BLACK_CURVE.value(), + CausticaConfig.Rt.Sdr.GT_BLACK_LIFT.value(), + 0.0f, 0.0f, 0.0f); + case PSYCHOV24 -> psychoParameters( + CausticaConfig.Rt.Sdr.PSYCHOV24_COMPRESSION.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_GAMUT_COMPRESSION.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_HIGHLIGHTS.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_SHADOWS.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_CONTRAST.value(), + CausticaConfig.Rt.Sdr.PSYCHOV24_PURITY.value()); + }; + } + + private static Parameters hdrParameters(HdrMode mode) { + return switch (mode) { + case ACES_2_0, BT2390 -> Parameters.NONE; + case PSYCHOV24 -> psychoParameters( + CausticaConfig.Rt.Hdr.PSYCHOV24_COMPRESSION.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_GAMUT_COMPRESSION.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_HIGHLIGHTS.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_SHADOWS.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_CONTRAST.value(), + CausticaConfig.Rt.Hdr.PSYCHOV24_PURITY.value()); + }; + } + + private static Parameters psychoParameters( + float compression, + float gamutCompression, + float highlights, + float shadows, + float contrast, + float purity) { + return new Parameters( + compression, + gamutCompression, + highlights, + shadows, + contrast, + purity, + 0.0f, + 0.0f); + } +} diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index 4533f086..56893e29 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -8,6 +8,12 @@ "caustica.options.rt.manualEv": "Exposure EV", "caustica.options.rt.manualEv.tooltip": "Exposure compensation in stops. In Manual, this is the fixed exposure. In Auto, this biases the auto exposure brighter or darker.", + "caustica.options.rt.exposureLowPercentile": "Shadow Percentile", + "caustica.options.rt.exposureLowPercentile.tooltip": "Ignores darker histogram samples below this percentile.", + "caustica.options.rt.exposureHighPercentile": "Highlight Percentile", + "caustica.options.rt.exposureHighPercentile.tooltip": "Ignores brighter histogram samples above this percentile.", + "caustica.options.rt.preExposure": "Pre-Exposure", + "caustica.options.rt.preExposure.tooltip": "Scale ray-traced radiance before the fp16 write and remove the same scale before display mapping.", "caustica.options.rt.gamma": "Gamma", "caustica.options.rt.gamma.tooltip": "Post-transform luminance gamma. Values below 1.00 brighten shadows and midtones while preserving black, white, and color ratios.", @@ -37,7 +43,90 @@ "caustica.options.rt.hdrUiBrightness.tooltip": "Absolute brightness (in nits) assigned to SDR-authored UI on an HDR display.", "caustica.options.rt.hdrPeak": "HDR Peak Brightness", - "caustica.options.rt.hdrPeak.tooltip": "Absolute brightness (in nits) highlights roll off toward. Set to your display's peak HDR brightness.", + "caustica.options.rt.hdrPeak.tooltip": "Absolute brightness (in nits) highlights roll off toward. Set to your display's peak HDR brightness; the control moves in 50-nit increments.", + "caustica.options.rt.toneMappingMenu": "Exposure & Tone Mapping…", + "caustica.options.rt.toneMappingMenu.tooltip": "Open exposure, output mapping, and active tone-mapper controls.", + "caustica.options.rt.toneMapping.title": "Exposure & Tone Mapping", + "caustica.options.rt.toneMapping.resetHint": "Ctrl+Shift+click an option to reset it to default", + "caustica.options.rt.toneMapping.section.exposure": "Exposure", + "caustica.options.rt.toneMapping.section.sdrOutput": "SDR Output", + "caustica.options.rt.toneMapping.section.hdrOutput": "HDR Output", + "caustica.options.rt.toneMapping.section.activeMapper": "%s Settings", + "caustica.options.rt.sdrToneMapper": "SDR Tone Mapper", + "caustica.options.rt.sdrToneMapper.tooltip": "Selects the SDR display transform. ACES 2.0 is the default; PsychoV24 and the analytical operators are opt-in.", + "caustica.options.rt.hdrToneMapper": "HDR Tone Mapper", + "caustica.options.rt.hdrToneMapper.tooltip": "Selects the HDR10/PQ display transform. ACES 2.0 is the default; PsychoV24 is opt-in and BT.2390 is the standards-based alternative.", + "caustica.options.rt.toneMapper.aces2.0": "ACES 2.0", + "caustica.options.rt.toneMapper.bt2390": "BT.2390", + "caustica.options.rt.toneMapper.agx": "AgX", + "caustica.options.rt.toneMapper.pbr-neutral": "PBR Neutral", + "caustica.options.rt.toneMapper.reinhard": "Reinhard", + "caustica.options.rt.toneMapper.aces": "ACES (Narkowicz Fit)", + "caustica.options.rt.toneMapper.lottes": "Lottes", + "caustica.options.rt.toneMapper.uncharted2": "Uncharted 2", + "caustica.options.rt.toneMapper.gt": "GT / Uchimura", + "caustica.options.rt.toneMapper.psychov24": "PsychoV24", + "caustica.options.rt.hdrPaperWhite": "HDR Paper White", + "caustica.options.rt.hdrPaperWhite.tooltip": "Absolute brightness in nits assigned to scene paper white before HDR headroom.", + "caustica.options.rt.agxContrast": "AgX Contrast", + "caustica.options.rt.agxContrast.tooltip": "Contrast adjustment after the reference AgX transform.", + "caustica.options.rt.agxSaturation": "AgX Saturation", + "caustica.options.rt.agxSaturation.tooltip": "Saturation adjustment after the reference AgX transform.", + "caustica.options.rt.pbrStartCompression": "Compression Start", + "caustica.options.rt.pbrStartCompression.tooltip": "Luminance at which PBR Neutral begins highlight compression.", + "caustica.options.rt.pbrDesaturation": "Desaturation", + "caustica.options.rt.pbrDesaturation.tooltip": "Desaturates compressed highlights toward neutral.", + "caustica.options.rt.reinhardWhitePoint": "Reinhard White Point", + "caustica.options.rt.reinhardWhitePoint.tooltip": "Luminance mapped to display white by extended Reinhard.", + "caustica.options.rt.acesInputScale": "ACES Input Scale", + "caustica.options.rt.acesInputScale.tooltip": "Input scale for the compact ACES fitted operator.", + "caustica.options.rt.lottesContrast": "Lottes Contrast", + "caustica.options.rt.lottesContrast.tooltip": "Contrast exponent for the Lottes filmic curve.", + "caustica.options.rt.lottesShoulder": "Lottes Shoulder", + "caustica.options.rt.lottesShoulder.tooltip": "Shoulder exponent for the Lottes filmic curve.", + "caustica.options.rt.lottesHdrMax": "Lottes HDR Maximum", + "caustica.options.rt.lottesHdrMax.tooltip": "HDR maximum used to normalize the Lottes curve.", + "caustica.options.rt.lottesMidIn": "Lottes Mid In", + "caustica.options.rt.lottesMidIn.tooltip": "Input midpoint anchor for the Lottes curve.", + "caustica.options.rt.lottesMidOut": "Lottes Mid Out", + "caustica.options.rt.lottesMidOut.tooltip": "Output midpoint anchor for the Lottes curve.", + "caustica.options.rt.unchartedShoulderStrength": "Uncharted Shoulder", + "caustica.options.rt.unchartedShoulderStrength.tooltip": "Shoulder strength in the Uncharted 2 curve.", + "caustica.options.rt.unchartedLinearStrength": "Uncharted Linear Strength", + "caustica.options.rt.unchartedLinearStrength.tooltip": "Linear strength in the Uncharted 2 curve.", + "caustica.options.rt.unchartedLinearAngle": "Uncharted Linear Angle", + "caustica.options.rt.unchartedLinearAngle.tooltip": "Linear angle in the Uncharted 2 curve.", + "caustica.options.rt.unchartedToeStrength": "Uncharted Toe Strength", + "caustica.options.rt.unchartedToeStrength.tooltip": "Toe strength in the Uncharted 2 curve.", + "caustica.options.rt.unchartedToeNumerator": "Uncharted Toe Numerator", + "caustica.options.rt.unchartedToeNumerator.tooltip": "Toe numerator in the Uncharted 2 curve.", + "caustica.options.rt.unchartedToeDenominator": "Uncharted Toe Denominator", + "caustica.options.rt.unchartedToeDenominator.tooltip": "Toe denominator in the Uncharted 2 curve.", + "caustica.options.rt.unchartedWhitePoint": "Uncharted White Point", + "caustica.options.rt.unchartedWhitePoint.tooltip": "White point used to normalize Uncharted 2.", + "caustica.options.rt.gtContrast": "GT Contrast", + "caustica.options.rt.gtContrast.tooltip": "Contrast parameter for the GT/Uchimura curve.", + "caustica.options.rt.gtLinearStart": "GT Linear Start", + "caustica.options.rt.gtLinearStart.tooltip": "Start of the linear section in the GT curve.", + "caustica.options.rt.gtLinearLength": "GT Linear Length", + "caustica.options.rt.gtLinearLength.tooltip": "Length of the linear section in the GT curve.", + "caustica.options.rt.gtBlackCurve": "GT Black Curve", + "caustica.options.rt.gtBlackCurve.tooltip": "Black toe curvature in the GT curve.", + "caustica.options.rt.gtBlackLift": "GT Black Lift", + "caustica.options.rt.gtBlackLift.tooltip": "Black-level lift in the GT curve.", + "caustica.options.rt.psychov24Compression": "Compression", + "caustica.options.rt.psychov24Compression.tooltip": "PsychoV24 display-range compression power.", + "caustica.options.rt.psychov24Compression.auto": "Automatic", + "caustica.options.rt.psychov24GamutCompression": "Gamut Compression", + "caustica.options.rt.psychov24GamutCompression.tooltip": "Compresses out-of-gamut PsychoV24 colors toward the display gamut.", + "caustica.options.rt.psychov24Highlights": "Highlights", + "caustica.options.rt.psychov24Highlights.tooltip": "Grades PsychoV24 values above the adaptation anchor.", + "caustica.options.rt.psychov24Shadows": "Shadows", + "caustica.options.rt.psychov24Shadows.tooltip": "Grades PsychoV24 values below the adaptation anchor.", + "caustica.options.rt.psychov24Contrast": "Contrast", + "caustica.options.rt.psychov24Contrast.tooltip": "PsychoV24 cone-response contrast.", + "caustica.options.rt.psychov24Purity": "Color Purity", + "caustica.options.rt.psychov24Purity.tooltip": "PsychoV24 adaptive chroma purity.", "caustica.options.rt.dlssQuality": "DLSS Quality", "caustica.options.rt.dlssQuality.tooltip": "DLSS Ray Reconstruction quality mode. Lower-quality modes render fewer pixels and upscale more aggressively for higher framerates; higher-quality modes render more pixels for a sharper image. Only affects the image when DLSS Ray Reconstruction is enabled.", diff --git a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java index c2a664d6..28d7095f 100644 --- a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java @@ -3,20 +3,74 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; final class CausticaConfigTest { @Test - void invalidPeakNitsFallsBackToDefault() { + void peakNitsUsesThe50NitGrid() { CausticaConfig.IntSetting setting = CausticaConfig.Rt.Hdr.PEAK_NITS; int previous = setting.value(); try { - setting.set(2000); - assertEquals(2000, setting.value()); + setting.set(1050); + assertEquals(1050, setting.value()); - setting.set(900); - assertEquals(1000, setting.value()); + setting.set(1055); + assertEquals(1050, setting.value()); } finally { setting.set(previous); } } + + @Test + void acesUsesTheNearestPackagedPeak() { + assertEquals(500, CausticaConfig.Rt.Hdr.nearestAcesLutNits(500)); + assertEquals(500, CausticaConfig.Rt.Hdr.nearestAcesLutNits(750)); + assertEquals(1000, CausticaConfig.Rt.Hdr.nearestAcesLutNits(900)); + assertEquals(4000, CausticaConfig.Rt.Hdr.nearestAcesLutNits(5000)); + } + + @Test + void registersToneMappingSettingsForConfigRoundTrips() { + CausticaConfig.ensureRegistered(); + assertTrue(hasSetting("caustica.rt.sdr.toneMapper")); + assertTrue(hasSetting("caustica.rt.hdr.toneMapper")); + } + + @Test + void analyticalToneControlsRejectNonfiniteValues() { + var sdrContrast = CausticaConfig.Rt.Sdr.AGX_CONTRAST; + var hdrPaperWhite = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS; + float previousSdrContrast = sdrContrast.value(); + float previousHdrPaperWhite = hdrPaperWhite.value(); + try { + sdrContrast.set(Float.NaN); + hdrPaperWhite.set(Float.POSITIVE_INFINITY); + assertEquals(sdrContrast.defaultValue(), sdrContrast.value()); + assertEquals(hdrPaperWhite.defaultValue(), hdrPaperWhite.value()); + } finally { + sdrContrast.set(previousSdrContrast); + hdrPaperWhite.set(previousHdrPaperWhite); + } + } + + @Test + void paperWhiteCannotExceedTheSelectedPeak() { + var paperWhite = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS; + var peak = CausticaConfig.Rt.Hdr.PEAK_NITS; + float previousPaperWhite = paperWhite.value(); + int previousPeak = peak.value(); + try { + paperWhite.set(200.0f); + peak.set(50); + assertEquals(50.0f, CausticaConfig.Rt.Hdr.paperWhiteNits()); + assertEquals(1.0f, CausticaConfig.Rt.Hdr.headroom()); + } finally { + paperWhite.set(previousPaperWhite); + peak.set(previousPeak); + } + } + + private static boolean hasSetting(String key) { + return CausticaConfig.settings().stream().anyMatch(setting -> setting.key().equals(key)); + } } diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java index 1651f70f..a2b955eb 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtHdrTest.java @@ -9,7 +9,7 @@ final class RtHdrTest { private static final float EPSILON = 0.000001f; @Test - void buildsRec2020D65MetadataAtTheSelectedAcesMasteringPeak() { + void buildsRec2020D65MetadataAtTheSelectedMasteringPeak() { RtHdr.MasteringMetadata metadata = RtHdr.masteringMetadata(1000); assertChromaticity(metadata.red(), 0.708f, 0.292f); diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java new file mode 100644 index 00000000..a49be8ac --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java @@ -0,0 +1,40 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class RtDisplayShaderContractTest { + private static final Path DISPLAY_SHADER = Path.of(System.getProperty("user.dir"), + "shaders", "pipelines", "display", "main.comp.slang"); + + @Test + void acesAndAnalyticalModesUseTheirOwnedSceneSignals() throws IOException { + String source = Files.readString(DISPLAY_SHADER).replaceAll("\\s+", " "); + + assertTrue(source.contains("float3 exposedAcesCg = max(rt.rgb * exposure, float3(0.0));")); + assertTrue(source.contains("exposedAcesCg += sampleBloom(pix, w, h) * max(pc.bloomStrength, 0.0);")); + assertTrue(source.contains("if (pc.sdrMode == 0 || (pc.hdrEnabled != 0 && pc.hdrMode == 0)) { " + + "lookedAcesCg = applyLook(exposedAcesCg); }")); + assertTrue(source.contains("? float4(tonemap(lookedAcesCg), 1.0) : float4(localSdrToneMap(exposedAcesCg), 1.0);")); + assertTrue(source.contains("? float4(tonemapHdr(lookedAcesCg), 1.0) : float4(displayGammaHdr(localHdrToneMap(exposedAcesCg)), 1.0);")); + assertFalse(source.contains("localSdrToneMap(lookedAcesCg)")); + assertFalse(source.contains("localHdrToneMap(lookedAcesCg)")); + assertEquals(1, occurrences(source, "applyLook(exposedAcesCg)")); + } + + private static int occurrences(String text, String needle) { + int count = 0; + int offset = 0; + while ((offset = text.indexOf(needle, offset)) >= 0) { + count++; + offset += needle.length(); + } + return count; + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureEv100Test.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureEv100Test.java new file mode 100644 index 00000000..c54dae55 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposureEv100Test.java @@ -0,0 +1,31 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.comfyfluffy.caustica.rt.RtSceneUnits; +import org.junit.jupiter.api.Test; + +final class RtExposureEv100Test { + @Test + void ev100OffsetRemovesTheLatchedPreExposureScale() { + assertEquals(RtSceneUnits.EV100_OFFSET, RtExposure.ev100Offset(1.0f), 1.0e-6f); + assertEquals(RtSceneUnits.EV100_OFFSET - 2.0f, RtExposure.ev100Offset(4.0f), 1.0e-6f); + } + + @Test + void invalidPreExposureFallsBackToTheNeutralScale() { + assertEquals(RtSceneUnits.EV100_OFFSET, RtExposure.ev100Offset(0.0f), 1.0e-6f); + assertEquals(RtSceneUnits.EV100_OFFSET, RtExposure.ev100Offset(-1.0f), 1.0e-6f); + assertEquals(RtSceneUnits.EV100_OFFSET, RtExposure.ev100Offset(Float.NaN), 1.0e-6f); + } + + @Test + void onlyEnteringAutoInvalidatesHistory() { + assertTrue(RtExposure.modeTransitionRequiresReset(RtExposure.Mode.MANUAL, RtExposure.Mode.AUTO)); + assertFalse(RtExposure.modeTransitionRequiresReset(RtExposure.Mode.AUTO, RtExposure.Mode.MANUAL)); + assertFalse(RtExposure.modeTransitionRequiresReset(RtExposure.Mode.AUTO, RtExposure.Mode.AUTO)); + assertFalse(RtExposure.modeTransitionRequiresReset(null, RtExposure.Mode.AUTO)); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePercentileTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePercentileTest.java new file mode 100644 index 00000000..f8f0d635 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposurePercentileTest.java @@ -0,0 +1,71 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.comfyfluffy.caustica.CausticaConfig; +import org.junit.jupiter.api.Test; + +final class RtExposurePercentileTest { + @Test + void defaultsAndConfigValuesStayInTheUnitInterval() { + var window = RtExposure.PercentileWindow.sanitize(0.50f, 0.95f); + assertEquals(0.50f, window.low(), 1.0e-6f); + assertEquals(0.95f, window.high(), 1.0e-6f); + + var low = CausticaConfig.Rt.Exposure.LOW_PERCENTILE; + var high = CausticaConfig.Rt.Exposure.HIGH_PERCENTILE; + float previousLow = low.value(); + float previousHigh = high.value(); + try { + low.set(-0.5f); + high.set(2.0f); + assertEquals(0.0f, low.value(), 1.0e-6f); + assertEquals(1.0f, high.value(), 1.0e-6f); + low.set(Float.NaN); + high.set(Float.POSITIVE_INFINITY); + assertEquals(0.50f, low.value(), 1.0e-6f); + assertEquals(0.95f, high.value(), 1.0e-6f); + } finally { + low.set(previousLow); + high.set(previousHigh); + } + } + + @Test + void reversedAndEqualWindowsAreNormalizedToAUsableRange() { + var reversed = RtExposure.PercentileWindow.sanitize(0.90f, 0.10f); + assertEquals(0.10f, reversed.low(), 1.0e-6f); + assertEquals(0.90f, reversed.high(), 1.0e-6f); + + var equalLow = RtExposure.PercentileWindow.sanitize(0.0f, 0.0f); + assertEquals(0.0f, equalLow.low()); + assertTrue(equalLow.high() > equalLow.low()); + + var equalHigh = RtExposure.PercentileWindow.sanitize(1.0f, 1.0f); + assertTrue(equalHigh.low() < equalHigh.high()); + assertEquals(1.0f, equalHigh.high()); + } + + @Test + void nonfiniteWindowValuesUseTheDocumentedDefaults() { + var window = RtExposure.PercentileWindow.sanitize(Float.NaN, Float.NEGATIVE_INFINITY); + assertEquals(0.50f, window.low(), 1.0e-6f); + assertEquals(0.95f, window.high(), 1.0e-6f); + } + + @Test + void histogramDispatchGuardsStrideAndSmallOrOverflowingExtents() { + assertEquals(1, RtExposurePipeline.safeStride(0)); + assertEquals(1, RtExposurePipeline.safeStride(-4)); + assertEquals(1, RtExposurePipeline.sampledExtent(0, 0)); + assertEquals(2, RtExposurePipeline.sampledExtent(16, 8)); + assertEquals(3, RtExposurePipeline.sampledExtent(17, 8)); + assertEquals(1, RtExposurePipeline.dispatchGroups(0, 0)); + assertEquals(2, RtExposurePipeline.dispatchGroups(256, 8)); + assertEquals(1, RtExposurePipeline.dispatchGroups(Integer.MAX_VALUE, Integer.MAX_VALUE)); + assertEquals(1, RtExposurePipeline.effectiveStride(3840, 2160, 1)); + assertEquals(2, RtExposurePipeline.effectiveStride(7680, 4320, 1)); + assertEquals(2, RtExposurePipeline.effectiveStride(7680, 4320, 2)); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMappingTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMappingTest.java new file mode 100644 index 00000000..942b372d --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtToneMappingTest.java @@ -0,0 +1,109 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import dev.comfyfluffy.caustica.CausticaConfig; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +final class RtToneMappingTest { + @Test + void defaultConfigUsesAces20ForSdrAndHdr() { + assertEquals(RtToneMapping.SdrMode.ACES_2_0, RtToneMapping.SdrMode.parse(null)); + assertEquals(RtToneMapping.SdrMode.ACES_2_0, RtToneMapping.SdrMode.parse("unknown")); + assertEquals(RtToneMapping.HdrMode.ACES_2_0, RtToneMapping.HdrMode.parse(null)); + assertEquals(RtToneMapping.HdrMode.ACES_2_0, RtToneMapping.HdrMode.parse("unknown")); + assertEquals("aces2.0", CausticaConfig.Rt.Sdr.TONE_MAPPER.defaultValue()); + assertEquals("aces2.0", CausticaConfig.Rt.Hdr.TONE_MAPPER.defaultValue()); + assertEquals(1.0f, CausticaConfig.Rt.Sdr.PSYCHOV24_COMPRESSION.defaultValue()); + assertEquals(1.0f, CausticaConfig.Rt.Sdr.PSYCHOV24_GAMUT_COMPRESSION.defaultValue()); + assertEquals(0.0f, CausticaConfig.Rt.Hdr.PSYCHOV24_COMPRESSION.defaultValue()); + assertEquals(0.50f, CausticaConfig.Rt.Exposure.LOW_PERCENTILE.defaultValue()); + assertEquals(0.95f, CausticaConfig.Rt.Exposure.HIGH_PERCENTILE.defaultValue()); + } + + @Test + void psychov24AndCompatibilityAliasesSelectTheSameMode() { + assertEquals(RtToneMapping.SdrMode.PSYCHOV24, + RtToneMapping.SdrMode.parse("psychov24")); + assertEquals(RtToneMapping.SdrMode.PSYCHOV24, + RtToneMapping.SdrMode.parse("psychovisual")); + assertEquals(RtToneMapping.SdrMode.PSYCHOV24, + RtToneMapping.SdrMode.parse("psycho-visual")); + assertEquals(RtToneMapping.SdrMode.PSYCHOV24, + RtToneMapping.SdrMode.parse("psychov")); + assertEquals(RtToneMapping.HdrMode.PSYCHOV24, + RtToneMapping.HdrMode.parse("psychovisual")); + assertEquals(RtToneMapping.HdrMode.PSYCHOV24, + RtToneMapping.HdrMode.parse("psycho-visual")); + assertEquals(RtToneMapping.HdrMode.PSYCHOV24, + RtToneMapping.HdrMode.parse("psychov")); + assertEquals(RtToneMapping.HdrMode.BT2390, + RtToneMapping.HdrMode.parse(" bt.2390 ")); + } + + @Test + void legacyPsychoNamesRemainCompatibilityAliases() { + assertEquals(RtToneMapping.SdrMode.PSYCHOV24, + RtToneMapping.SdrMode.parse("psychov11")); + assertEquals(RtToneMapping.SdrMode.PSYCHOV24, + RtToneMapping.SdrMode.parse("psychov23")); + assertEquals(RtToneMapping.SdrMode.PSYCHOV24, + RtToneMapping.SdrMode.parse("psychov24-experimental")); + assertEquals(RtToneMapping.HdrMode.PSYCHOV24, + RtToneMapping.HdrMode.parse("psychov11")); + assertEquals(RtToneMapping.HdrMode.PSYCHOV24, + RtToneMapping.HdrMode.parse("psychov23")); + assertEquals(RtToneMapping.HdrMode.PSYCHOV24, + RtToneMapping.HdrMode.parse("psychov24-experimental")); + } + + @Test + void modeIdsAreUniqueAndStable() { + assertEquals(0, RtToneMapping.SdrMode.ACES_2_0.id()); + assertEquals(8, RtToneMapping.SdrMode.PSYCHOV24.id()); + assertEquals(0, RtToneMapping.HdrMode.ACES_2_0.id()); + assertEquals(3, RtToneMapping.HdrMode.BT2390.id()); + assertEquals(2, RtToneMapping.HdrMode.PSYCHOV24.id()); + assertFalse(RtToneMapping.hdrConfigNames().contains("caustica")); + } + + @Test + void psychov24LeavesUnusedPushConstantsZero() { + String previousSdr = CausticaConfig.Rt.Sdr.TONE_MAPPER.get(); + String previousHdr = CausticaConfig.Rt.Hdr.TONE_MAPPER.get(); + try { + CausticaConfig.Rt.Sdr.TONE_MAPPER.set("psychov24"); + CausticaConfig.Rt.Hdr.TONE_MAPPER.set("psychov24"); + RtToneMapping.Settings settings = RtToneMapping.current(); + assertEquals(0.0f, settings.sdrParameters().param6()); + assertEquals(0.0f, settings.sdrParameters().param7()); + assertEquals(0.0f, settings.hdrParameters().param6()); + assertEquals(0.0f, settings.hdrParameters().param7()); + } finally { + CausticaConfig.Rt.Sdr.TONE_MAPPER.set(previousSdr); + CausticaConfig.Rt.Hdr.TONE_MAPPER.set(previousHdr); + RtToneMapping.current(); + } + } + + @Test + void unchangedSnapshotIsReusedAndModeChangesInvalidateIt() { + String previous = CausticaConfig.Rt.Sdr.TONE_MAPPER.get(); + try { + CausticaConfig.Rt.Sdr.TONE_MAPPER.set("aces2.0"); + RtToneMapping.Settings first = RtToneMapping.current(); + assertSame(first, RtToneMapping.current()); + + CausticaConfig.Rt.Sdr.TONE_MAPPER.set("agx"); + RtToneMapping.Settings changed = RtToneMapping.current(); + assertNotSame(first, changed); + assertSame(changed, RtToneMapping.current()); + } finally { + CausticaConfig.Rt.Sdr.TONE_MAPPER.set(previous); + RtToneMapping.current(); + } + } +} From d13d84703ddb9aa835f09b083bc3562864646e47 Mon Sep 17 00:00:00 2001 From: PEQHUB Date: Mon, 10 Aug 2026 10:34:14 -0500 Subject: [PATCH 2/6] feat: integrate canonical Sobol RIS sampling --- THIRD_PARTY_NOTICES.md | 17 ++ shaders/pipelines/world/indirect.rgen.slang | 52 ++--- shaders/pipelines/world/lighting.slang | 77 ++++--- shaders/pipelines/world/math.slang | 199 ++++++++++++++++-- shaders/pipelines/world/world_common.slang | 7 + .../comfyfluffy/caustica/CausticaConfig.java | 3 +- .../caustica/client/RtVideoOptions.java | 26 +++ .../comfyfluffy/caustica/rt/RtComposite.java | 118 ++++++++++- .../rt/pipeline/RtPathSamplerData.java | 140 ++++++++++++ .../rt/pipeline/RtSobolDirectionNumbers.java | 70 ++++++ .../rt/terrain/RtLightGridManager.java | 20 +- .../resources/assets/caustica/lang/en_us.json | 4 + .../caustica/CausticaConfigTest.java | 27 +++ .../rt/pipeline/RtPathSamplingTest.java | 105 +++++++++ 14 files changed, 782 insertions(+), 83 deletions(-) create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSobolDirectionNumbers.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplingTest.java diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d299050f..8404f8ad 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -61,3 +61,20 @@ Bundled NVIDIA SDK runtime libraries may include files matching: Caustica's `ngxshim` native library is project-owned glue code and follows Caustica's project license unless otherwise noted. + +## Joe-Kuo Sobol direction numbers + +`RtSobolDirectionNumbers.java` expands the first four dimensions of the +`new-joe-kuo-6.21201` data set by Frances Y. Kuo and Stephen Joe (2008). + +Copyright (c) 2008, Frances Y. Kuo and Stephen Joe. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the copyright notice, conditions, +and disclaimer are retained. Neither the copyright holders nor the +University of New South Wales or University of Waikato may be used to endorse +derived products without prior written permission. + +The data is provided without warranty; the copyright holders are not liable +for damages arising from its use. The complete notice is retained in the +source file. diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index 2df3dc6a..db0ac365 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -34,15 +34,14 @@ import bindings; // 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. -public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { +public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathBranch) { float3 L = float3(0.0, 0.0, 0.0); float3 ro = seg.ro; float3 rd = seg.rd; float3 throughput = seg.throughput; - // Pass A is fixed at one sample per pixel. Decorrelate each Pass B resample here so configured SPP - // produces independent lighting/BSDF paths from the shared terminal continuation. - uint seed = seg.seed ^ sampleIndex * 2246822519u; - seed = pcg(seed); + // Pass A is fixed at one sample per pixel. Pass B advances one global Sobol index per continuation + // sample; optional groups are keyed from the same immutable transport sampler only when sampled. + PathSampler transportSampler = makePathSampler(pix, sampleIndex, pathBranch); float rayConeWidth = seg.rayConeWidth; float rayConeSpread = max(seg.rayConeSpread, RAY_CONE_MIN_SPREAD); int maxBounces = int(worldPush.maxBounces); @@ -50,13 +49,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // RIS emitter NEE: direct lighting from block emitters is active when lights are published and the // candidate count is non-zero. Otherwise emitters contribute only when a path hits them. bool risOn = worldPush.risCandidates > 0u && worldPush.lightCount > 0u && pc.lightBufAddr != 0; - // Independent light-proposal RNG. Screen-space coherent selection is intentionally deferred until - // there is a real presampled RIS tile buffer; direct sharing made tile-shaped lighting noise visible. - // Measured: making a 16x16 tile share this seed recovers at most 1.3ms, so a future pool should share - // the POOL rather than the seed — the win is in removing dependent loads, not in coherence. - uint proposalSeed = pix.x * 1973u + pix.y * 9277u + 26699u - ^ worldPush.frameIndex * 2654435761u ^ sampleIndex * 2246822519u; - proposalSeed = pcg(proposalSeed); + // Independent light-proposal samples use their own stream. Screen-space coherent selection is + // intentionally deferred until there is a real presampled RIS tile buffer. // The medium the segment starts in. For the camera segment that is water when the eye is submerged // (so the first ray already carries the right relative index and absorption); for a segment split off // a dielectric it is whatever the parent was travelling through. @@ -105,6 +99,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { break; } + beginPathBounce(transportSampler, uint(bounce)); + // Beer–Lambert: attenuate along the segment just travelled by the medium it lay inside. Applies // to every hit reached while inside a volume dielectric (its own exit face, or whatever content // lies within it), shifting the transmitted radiance with distance. Air's extinction is zero, so @@ -166,7 +162,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // comes from the fluid mesher, which applies no inset, so it takes the ordinary bias. float transmitBias = isWater ? SURF_BIAS : INSET_TRANSMIT_BIAS; - bool chooseReflection = rndf(seed) < F; + bool chooseReflection = pathSample(transportSampler, PATH_DIM_INTERFACE) < F; if (chooseReflection) { rd = reflect(rd, n); ro = offsetSurfaceOrigin(hitPos, geometricNormal, rd, SURF_BIAS); @@ -185,7 +181,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { showCelestial = true; // specular interface: the continuation ray may see the sun/moon disc if (bounce >= rrStart) { float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { + if (pathSample(transportSampler, PATH_DIM_RR) > q) { break; } throughput /= q; @@ -202,7 +198,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 lightDir = celestialLight.dir; float lightHalfAngle = celestialLight.halfAngle; if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + PathSampler celestialSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_CELESTIAL); + lightDir = sampleSquare(lightDir, lightHalfAngle, celestialSampler, PATH_DIM_CELESTIAL_U); } // Billboards are effectively two-sided receivers. Bias from the side facing the light; shadow // rays exclude particles anyway, but this keeps the origin sane when the light is behind the @@ -221,9 +218,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // sampler as terrain/entities, but two-sided: a billboard has no back face, so light // striking either side of the quad should still land (matches the sun/moon NEE above). if (risOn) { + PathSampler risSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_RIS_BASE); float3 v = -rd; Reservoir r = risInitial(hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, - true, 0.0, seed, proposalSeed); + true, 0.0, risSampler); L += throughput * shadeReservoir(r, hitPos, n, v, rd, albedo, float3(0.0, 0.0, 0.0), 1.0, true, 0.0); } @@ -233,7 +231,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } throughput *= albedo; ro = hitPos + n * SURF_BIAS; - rd = cosineDir(n, seed); + PathSampler diffuseSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_TRANSPORT_DIFFUSE); + rd = cosineDir(n, diffuseSampler, PATH_DIM_DIFFUSE_U); rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above continue; @@ -282,7 +281,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float3 lightDir = celestialLight.dir; float lightHalfAngle = celestialLight.halfAngle; if (lightHalfAngle > 0.0) { - lightDir = sampleSquare(lightDir, lightHalfAngle, seed); + PathSampler celestialSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_CELESTIAL); + lightDir = sampleSquare(lightDir, lightHalfAngle, celestialSampler, PATH_DIM_CELESTIAL_U); } float ndl = max(0.0, dot(n, lightDir)); if (ndl > 0.0) { @@ -315,8 +315,9 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // sss=0 there (falls back to plain front-only RIS). if (risOn) { float activeSss = hitDepth <= MAX_SSS_INDIRECT_DEPTH ? sss : 0.0; + PathSampler risSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_RIS_BASE); Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, - seed, proposalSeed); + risSampler); L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss); } @@ -355,7 +356,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { float ps = exactSpecular && luminance(diffAlb) <= 1.0e-6 ? 1.0 : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); - if (rndf(seed) < ps) { + bool sampledSpecular = pathSample(transportSampler, PATH_DIM_LOBE) < ps; + if (sampledSpecular) { float3 l; if (exactSpecular) { // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the @@ -363,7 +365,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { l = reflect(rd, n); throughput *= fresnelSchlick(clamp(dot(n, v), 0.0, 1.0), F0) / ps; } else { - float3 h = sampleGGXVNDF(n, v, rough, seed); + PathSampler specularSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_TRANSPORT_SPECULAR); + float3 h = sampleGGXVNDF(n, v, rough, specularSampler, PATH_DIM_GGX_U); l = reflect(rd, h); // rd = -v: reflect the incoming ray about the microfacet normal float ndl2 = dot(n, l); if (ndl2 <= 0.0) { @@ -380,7 +383,8 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { } else { throughput *= diffAlb / (1.0 - ps); ro = p; - rd = cosineDir(n, seed); + PathSampler diffuseSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_TRANSPORT_DIFFUSE); + rd = cosineDir(n, diffuseSampler, PATH_DIM_DIFFUSE_U); rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) } @@ -388,7 +392,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex) { // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. if (bounce >= rrStart) { float q = clamp(max(throughput.r, max(throughput.g, throughput.b)), 0.02, 1.0); - if (rndf(seed) > q) { + if (pathSample(transportSampler, PATH_DIM_RR) > q) { break; } throughput /= q; @@ -417,7 +421,7 @@ void main() { PackedPathSegment packed = queue[recordIndex]; PathSegment segment = unpackPathSegment(packed); for (uint s = 0u; s < spp; ++s) { - frameRadiance += tracePath(segment, uint2(pix), s + leaf * spp); + frameRadiance += tracePath(segment, uint2(pix), s, leaf); } if (packed.nextRecord == PATH_NO_NEXT) { break; diff --git a/shaders/pipelines/world/lighting.slang b/shaders/pipelines/world/lighting.slang index 4604e6bc..b31067ea 100644 --- a/shaders/pipelines/world/lighting.slang +++ b/shaders/pipelines/world/lighting.slang @@ -90,8 +90,8 @@ public float3 evalSampleContrib(float3 sp, float3 lnrm, float3 le, float area, f } // Select one light from the emitted-power distribution in O(1). -public void selectGlobalLight(inout uint proposalSeed, out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(worldPush.lightCount); +public void selectGlobalLight(in PathSampler risSampler, uint dimension, out uint lightIndex) { + float aliasSample = pathSample(risSampler, dimension) * float(worldPush.lightCount); uint column = min(uint(aliasSample), worldPush.lightCount - 1u); if (pc.lightAliasAddr != 0) { LightAlias a = ConstPtr(pc.lightAliasAddr)[column]; @@ -117,12 +117,18 @@ public bool findLightGridCell(float3 p, out LightGridCell cell, out int3 cellCoo * uint(worldPush.lightGridDims.x) + uint(coord.x); cell = ConstPtr(pc.lightGridCellAddr)[linear]; cellCoord = coord; - return cell.spanCount > 0u; + if (cell.spanCount == 0u) { + // The discarded branch-free local chain still reads one span. Sparse trailing cells may carry + // the one-past-end prefix offset, so redirect empty cells to the first valid span. + cell.spanOffset = 0u; + return false; + } + return true; } -public void selectSectionLight(uint firstLight, uint lightCount, inout uint proposalSeed, +public void selectSectionLight(uint firstLight, uint lightCount, in PathSampler risSampler, uint dimension, out uint lightIndex) { - float aliasSample = rndf(proposalSeed) * float(lightCount); + float aliasSample = pathSample(risSampler, dimension) * float(lightCount); uint column = min(uint(aliasSample), lightCount - 1u); LightAlias alias = ConstPtr(pc.lightLocalAliasAddr)[firstLight + column]; bool self = aliasSample - float(column) < alias.accept; @@ -194,31 +200,33 @@ public float proposalPdf(Light light, float3 le, LightGridCell cell, int3 cellCo return localProbability * localPdf + (1.0 - localProbability) * globalPdf; } -public void selectLightGridSpanLight(LightGridCell cell, inout uint proposalSeed, +public void selectLightGridSpanLight(LightGridCell cell, in PathSampler risSampler, uint dimension, out uint lightIndex) { ConstPtr spans = ConstPtr(pc.lightGridSpanAddr); - float aliasSample = rndf(proposalSeed) * float(cell.spanCount); + float aliasSample = pathSample(risSampler, dimension) * float(cell.spanCount); uint column = min(uint(aliasSample), cell.spanCount - 1u); LightGridSpan span = spans[cell.spanOffset + column]; bool self = aliasSample - float(column) < span.accept; uint firstLight = self ? span.firstLight : span.aliasFirstLight; uint lightCount = self ? (span.packedLightCounts & 0xffffu) : (span.packedLightCounts >> 16u); - selectSectionLight(firstLight, lightCount, proposalSeed, lightIndex); + selectSectionLight(firstLight, lightCount, risSampler, dimension + 1u, lightIndex); } // Sample the exact mixture q = alpha*qCell + (1-alpha)*qGlobal. qCell first selects a nearby section // by emitted power and section distance, then its section-local power alias. Every light in // the neighborhood is represented without a fixed cap; qGlobal gives distant lights full support. // Alias spans make weighted local selection O(1). The mixture PDF is reconstructed after the single -// Light48 load: section power cancels with the section-local light PDF. -public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint proposalSeed, - out uint lightIndex) { - if (useLocal) { - selectLightGridSpanLight(cell, proposalSeed, lightIndex); - } else { - selectGlobalLight(proposalSeed, lightIndex); - } +// Light load: section power cancels with the section-local light PDF. +public void selectLightGridLight(LightGridCell cell, bool useLocal, in PathSampler risSampler, + uint dimension, out uint lightIndex) { + // Keep both BDA chains unconditional: NVIDIA Ampere drivers can miscompile branch-dependent indexed + // PhysicalStorageBuffer loads in this candidate loop. Non-empty light hierarchies always publish the + // span and local-alias regions together, and RIS does not call this function for an empty hierarchy. + uint localIndex, globalIndex; + selectLightGridSpanLight(cell, risSampler, dimension, localIndex); + selectGlobalLight(risSampler, dimension, globalIndex); + lightIndex = useLocal ? localIndex : globalIndex; } // ---- RIS cost profile. Temporary probes pinned the candidate walk out of the shader to bound what @@ -228,12 +236,12 @@ public void selectLightGridLight(LightGridCell cell, bool useLocal, inout uint p // ~1.1ms at the primary hit, and forcing coherent selection recovered at most 1.3ms of it. So the cost // was chasing depth, not divergence, and it lived at secondary vertices. A presampled candidate pool // would attack the same 5.9ms structurally, but see the note on -// proposalSeed in tracePath for why it should share the pool rather than the seed. +// RIS stream in tracePath for why it should share the pool rather than the main sample stream. // Initial RIS over M power-weighted candidates -> one resampled sample, no shadow ray yet. The chosen // sample's W = wSum / (M * p-hat); M counts every candidate, including zero-weight ones. public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 diffAlb, float3 F0, - float rough, bool twoSided, float sss, inout uint seed, inout uint proposalSeed) { + float rough, bool twoSided, float sss, in PathSampler risSampler) { Reservoir r = resEmpty(); LightGridCell gridCell; int3 gridCellCoord; @@ -241,13 +249,13 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 if (pc.lightGridCellAddr != 0) { // Stochastically blend across hard section boundaries. Every cell proposal retains full global // support, so conditioning on this independently jittered lookup remains unbiased. - gridLookup += (float3(rndf(proposalSeed), rndf(proposalSeed), rndf(proposalSeed)) - 0.5) + gridLookup += (pathSample3(risSampler, PATH_DIM_RIS_GRID_X) - 0.5) * worldPush.lightGridOrigin.w; } bool hasGridCell = findLightGridCell(gridLookup, gridCell, gridCellCoord); uint candidateCount = worldPush.risCandidates; r.M = float(candidateCount); - // Deterministically stratify the proposal mixture. At the default M=8 this schedules exactly six + // Deterministically stratify the proposal mixture. At M=8 this schedules exactly six // local and two global candidates, with every lane taking the same branch for a given candidate. // Counts that are not divisible by four use the nearest practical split with at least one global // candidate; M=1 is global-only so the estimator retains full support. @@ -257,14 +265,31 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 float localProbability = float(localCandidateCount) / float(candidateCount); for (uint c = 0u; c < candidateCount; c++) { uint li; + // Proposal/replacement and rectangle sampling use two independently shuffled low-dimensional + // groups. Candidate identity is part of each substream key, so no candidate aliases another. + PathSampler candidateSampler = pathSamplerWithGroup(risSampler, + PATH_GROUP_RIS_CANDIDATE_FIRST + c * 2u); uint globalsBefore = (c * globalCandidateCount) / candidateCount; uint globalsAfter = ((c + 1u) * globalCandidateCount) / candidateCount; bool useLocal = hasGridCell && globalsAfter == globalsBefore; - selectLightGridLight(gridCell, useLocal, proposalSeed, li); - Light lg = ConstPtr(pc.lightBufAddr)[li]; + selectLightGridLight(gridCell, useLocal, candidateSampler, PATH_DIM_RIS_SELECT, li); + // Load the 32-byte record as two explicit float4 values. This preserves every packed bit while + // avoiding the struct-typed indexed PhysicalStorageBuffer load shape that miscompiles on Ampere. + uint64_t lightAddr = pc.lightBufAddr + uint64_t(li) * uint64_t(32); + float4 posLe = ConstPtr(lightAddr)[0]; + uint4 packed = asuint(ConstPtr(lightAddr + uint64_t(16))[0]); + Light lg; + lg.pos = posLe.xyz; + lg.le = asuint(posLe.w); + lg.halfUxy = packed.x; + lg.halfUzVx = packed.y; + lg.halfVyz = packed.z; + lg.section = packed.w; // Soft shadows: uniform point on the emitter rectangle ((s,t) in [-1,1]^2, pdf 1/area). - float s = rndf(seed) * 2.0 - 1.0; - float t = rndf(seed) * 2.0 - 1.0; + candidateSampler = pathSamplerWithGroup(risSampler, + PATH_GROUP_RIS_CANDIDATE_FIRST + c * 2u + 1u); + float s = pathSample(candidateSampler, PATH_DIM_RIS_LIGHT_U) * 2.0 - 1.0; + float t = pathSample(candidateSampler, PATH_DIM_RIS_LIGHT_V) * 2.0 - 1.0; float3 sp = lg.pos + worldPush.lightRebase.xyz + s * lightHalfU(lg) + t * lightHalfV(lg); float3 le = lightRadiance(lg); @@ -279,7 +304,9 @@ public Reservoir risInitial(float3 hitPos, float3 n, float3 v, float3 rd, float3 float sourcePdf = proposalPdf(lg, le, gridCell, gridCellCoord, localProbability); float w = phat / max(sourcePdf, 1.0e-20); r.wSum += w; - if (rndf(seed) * r.wSum < w) { // weighted reservoir update + candidateSampler = pathSamplerWithGroup(risSampler, + PATH_GROUP_RIS_CANDIDATE_FIRST + c * 2u); + if (pathSample(candidateSampler, PATH_DIM_RIS_REPLACE) * r.wSum < w) { // weighted reservoir update r.pos = sp; r.lnrm = lightNormal; r.le = le; diff --git a/shaders/pipelines/world/math.slang b/shaders/pipelines/world/math.slang index d5015581..3581c62e 100644 --- a/shaders/pipelines/world/math.slang +++ b/shaders/pipelines/world/math.slang @@ -93,17 +93,178 @@ public float fresnelDielectric(float cosI, float etaI, float etaT) { return 0.5 * (rs * rs + rp * rp); } -// PCG hash RNG. +// PCG remains available for non-path-sampling scheduling. Stochastic estimator decisions use PathSampler. public uint pcg(inout uint s) { s = s * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; return (w >> 22u) ^ w; } -public float rndf(inout uint s) { - // Convert the high 24 bits, which are exactly representable as float. Converting all 32 bits first - // lets the top 128 uint values round to 2^32, incorrectly returning 1.0 and breaking `< probability` - // tests (most seriously the F == 1 total-internal-reflection branch). - return float(pcg(s) >> 8u) * (1.0 / 16777216.0); +public float rndf(inout uint seed) { + return float(pcg(seed) >> 8u) * (1.0 / 16777216.0); +} + +public static const uint PATH_DIM_INTERFACE = 0u; +public static const uint PATH_DIM_LOBE = 1u; +public static const uint PATH_DIM_RR = 2u; +public static const uint PATH_DIM_GGX_U = 0u; +public static const uint PATH_DIM_GGX_V = 1u; +public static const uint PATH_DIM_DIFFUSE_U = 0u; +public static const uint PATH_DIM_DIFFUSE_V = 1u; +public static const uint PATH_DIM_RIS_GRID_X = 0u; +public static const uint PATH_DIM_RIS_GRID_Y = 1u; +public static const uint PATH_DIM_RIS_GRID_Z = 2u; +public static const uint PATH_DIM_RIS_SELECT = 0u; +public static const uint PATH_DIM_RIS_REPLACE = 2u; +public static const uint PATH_DIM_RIS_LIGHT_U = 0u; +public static const uint PATH_DIM_RIS_LIGHT_V = 1u; +public static const uint PATH_DIM_CELESTIAL_U = 0u; + +public static const uint PATH_GROUP_TRANSPORT_CORE = 0u; +public static const uint PATH_GROUP_TRANSPORT_SPECULAR = 1u; +public static const uint PATH_GROUP_TRANSPORT_DIFFUSE = 2u; +public static const uint PATH_GROUP_RIS_BASE = 3u; +public static const uint PATH_GROUP_RIS_CANDIDATE_FIRST = 4u; +public static const uint PATH_GROUP_CELESTIAL = 68u; +public static const uint PATH_GROUP_COUNT = 69u; +public static const uint PATH_BRANCH_COUNT = 2u; +public static const uint PATH_BOUNCE_COUNT = 9u; +public static const uint PATH_SOBOL_DIMENSIONS = 4u; +public static const uint PATH_SOBOL_FIRST_TABLE_DIMENSION = 1u; +public static const uint PATH_SOBOL_TABLE_DIMENSION_COUNT = + PATH_SOBOL_DIMENSIONS - PATH_SOBOL_FIRST_TABLE_DIMENSION; +public static const uint PATH_SOBOL_WORDS_PER_DIMENSION = 128u; +public static const uint PATH_DIRECTION_TABLE_OFFSET = 0u; +public static const uint PATH_ROOT_TABLE_OFFSET = PATH_DIRECTION_TABLE_OFFSET + + PATH_SOBOL_TABLE_DIMENSION_COUNT * PATH_SOBOL_WORDS_PER_DIMENSION; +public static const uint PATH_ROOTS_PER_GROUP = 9u; +public static const uint PATH_ROOT_INDEX_SHUFFLE = 0u; +public static const uint PATH_ROOT_COORDINATE_SCRAMBLE_BASE = 1u; +public static const uint PATH_ROOT_DIGITAL_SHIFT_BASE = 5u; +public static const uint PATH_EPOCH_KEY = 0x9e3779b9u; +public static const uint PATH_STATE_BOUNCE_MASK = 0x1eu; +public static const uint PATH_STATE_GROUP_MASK = 0xfe0u; + +public struct PathSampler { + public uint globalSample; + public uint pixelEpochKey; + public uint shuffledIndex; + // branch, bounce, and group occupy bits 0, 1..4, and 5..11 of the resource-bounded state. + public uint pathState; +} + +public uint pathHash(uint value) { + uint x = value; + x ^= x >> 16u; + x *= 0x21f0aaadu; + x ^= x >> 15u; + x *= 0xf35a2d97u; + x ^= x >> 15u; + return x; +} + +public uint pathReverseBits(uint value) { + // Slang lowers this intrinsic to SPIR-V OpBitReverse. + return reversebits(value); +} + +public uint pathNestedUniformPermutation(uint value, uint seed) { + uint x = pathReverseBits(value); + x ^= x * 0x3d20adeau; + x += seed; + x *= (seed >> 16u) | 1u; + x ^= x * 0x05526c56u; + x ^= x * 0x53a22864u; + return pathReverseBits(x); +} + +public uint pathRootBase(in PathSampler sampler) { + uint state = sampler.pathState; + return PATH_ROOT_TABLE_OFFSET + + (((state & 1u) * PATH_BOUNCE_COUNT + ((state >> 1u) & 15u)) * PATH_GROUP_COUNT + + (state >> 5u)) * PATH_ROOTS_PER_GROUP; +} + +public void rekeyPathSampler(inout PathSampler sampler) { + ConstPtr data = ConstPtr(worldPush.pathSampleAddr); + uint rootBase = pathRootBase(sampler); + uint indexSeed = pathHash(data[rootBase + PATH_ROOT_INDEX_SHUFFLE] + ^ sampler.pixelEpochKey); + sampler.shuffledIndex = pathNestedUniformPermutation(sampler.globalSample, indexSeed); +} + +public PathSampler makePathSampler(uint2 pixel, uint localSample, uint pathBranch) { + uint pixelCode = pixel.x | (pixel.y << 16u); + PathSampler sampler; + sampler.globalSample = worldPush.pathSampleBase + localSample; + sampler.pixelEpochKey = pixelCode ^ worldPush.pathSampleEpoch * PATH_EPOCH_KEY; + sampler.shuffledIndex = sampler.globalSample; + sampler.pathState = pathBranch | (PATH_GROUP_TRANSPORT_CORE << 5u); + return sampler; +} + +public void beginPathBounce(inout PathSampler sampler, uint bounce) { + sampler.pathState = (sampler.pathState & ~PATH_STATE_BOUNCE_MASK) | (bounce << 1u); + rekeyPathSampler(sampler); +} + +public PathSampler pathSamplerWithGroup(in PathSampler parent, uint group) { + PathSampler sampler = parent; + sampler.pathState = (sampler.pathState & ~PATH_STATE_GROUP_MASK) | (group << 5u); + rekeyPathSampler(sampler); + return sampler; +} + +public uint pathSobolBitsFromGray(ConstPtr data, uint gray, uint dimension) { + if (dimension == 0u) return pathReverseBits(gray); + uint tableDimension = dimension - PATH_SOBOL_FIRST_TABLE_DIMENSION; + uint base = PATH_DIRECTION_TABLE_OFFSET + tableDimension * PATH_SOBOL_WORDS_PER_DIMENSION; + return data[base + ((gray >> 0u) & 15u)] + ^ data[base + 16u + ((gray >> 4u) & 15u)] + ^ data[base + 32u + ((gray >> 8u) & 15u)] + ^ data[base + 48u + ((gray >> 12u) & 15u)] + ^ data[base + 64u + ((gray >> 16u) & 15u)] + ^ data[base + 80u + ((gray >> 20u) & 15u)] + ^ data[base + 96u + ((gray >> 24u) & 15u)] + ^ data[base + 112u + ((gray >> 28u) & 15u)]; +} + +public float pathSampleValue(ConstPtr data, in PathSampler sampler, uint rootBase, uint gray, + uint dimension) { + uint bits = pathSobolBitsFromGray(data, gray, dimension); + uint scrambleSeed = pathHash( + data[rootBase + PATH_ROOT_COORDINATE_SCRAMBLE_BASE + dimension] + ^ sampler.pixelEpochKey); + bits = pathNestedUniformPermutation(bits, scrambleSeed); + uint digitalShift = pathHash(data[rootBase + PATH_ROOT_DIGITAL_SHIFT_BASE + dimension] + ^ sampler.pixelEpochKey); + bits ^= digitalShift; + return float(bits >> 8u) * (1.0 / 16777216.0); +} + +public float pathSample(in PathSampler sampler, uint dimension) { + ConstPtr data = ConstPtr(worldPush.pathSampleAddr); + uint sampleIndex = sampler.shuffledIndex; + return pathSampleValue(data, sampler, pathRootBase(sampler), + sampleIndex ^ (sampleIndex >> 1u), dimension); +} + +public float2 pathSample2(in PathSampler sampler, uint dimensionBase) { + ConstPtr data = ConstPtr(worldPush.pathSampleAddr); + uint sampleIndex = sampler.shuffledIndex; + uint gray = sampleIndex ^ (sampleIndex >> 1u); + uint rootBase = pathRootBase(sampler); + return float2(pathSampleValue(data, sampler, rootBase, gray, dimensionBase), + pathSampleValue(data, sampler, rootBase, gray, dimensionBase + 1u)); +} + +public float3 pathSample3(in PathSampler sampler, uint dimensionBase) { + ConstPtr data = ConstPtr(worldPush.pathSampleAddr); + uint sampleIndex = sampler.shuffledIndex; + uint gray = sampleIndex ^ (sampleIndex >> 1u); + uint rootBase = pathRootBase(sampler); + return float3(pathSampleValue(data, sampler, rootBase, gray, dimensionBase), + pathSampleValue(data, sampler, rootBase, gray, dimensionBase + 1u), + pathSampleValue(data, sampler, rootBase, gray, dimensionBase + 2u)); } public float3 primaryRayDir(float2 ndc) { @@ -124,9 +285,10 @@ public float primaryRayConeSpread(float2 ndc, float2 size, float3 dir) { // Cosine-weighted hemisphere sample about n (Malley's method). For a Lambertian BRDF this is the // importance-sampling match: BRDF*cos/pdf = (albedo/PI)*cos / (cos/PI) = albedo, so the continuation // throughput is just *= albedo with no PI/cos bookkeeping left over. -public float3 cosineDir(float3 n, inout uint s) { - float u1 = rndf(s); - float u2 = rndf(s); +public float3 cosineDir(float3 n, in PathSampler sampler, uint dimensionBase) { + float2 samples = pathSample2(sampler, dimensionBase); + float u1 = samples.x; + float u2 = samples.y; float r = sqrt(u1); float phi = 6.2831853 * u2; float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); @@ -136,25 +298,21 @@ public float3 cosineDir(float3 n, inout uint s) { return normalize(local.x * t + local.y * b + local.z * n); } -// Soft shadows: sample a direction within the light's SQUARE angular extent about `axis`. MC's sun/moon -// are square quads, so the NEE shadow ray samples a square (not a cone) — the same square, in the same -// horizon-levelled tangent frame (sky.celestialSquareFrame), that world.rmiss draws the visible body in. -// Averaged over frames by DLSS-RR this yields soft penumbrae that widen with occluder distance -// (contact-hardening) for free. halfAngle <= 0 ⇒ exact direction (hard). -public float3 sampleSquare(float3 axis, float halfAngle, inout uint s) { +public float3 sampleSquare(float3 axis, float halfAngle, in PathSampler sampler, uint dimensionBase) { float3 right; float3 up; celestialSquareFrame(axis, right, up); float t = tan(halfAngle); - float u = (rndf(s) * 2.0 - 1.0) * t; - float v = (rndf(s) * 2.0 - 1.0) * t; + float2 samples = pathSample2(sampler, dimensionBase); + float u = (samples.x * 2.0 - 1.0) * t; + float v = (samples.y * 2.0 - 1.0) * t; return normalize(axis + u * right + v * up); } // Sample a GGX visible-normal (VNDF, Heitz 2018) about geometric normal n for view ve, returning the // sampled microfacet normal (half-vector) in world space. Paired with the separable Smith term, the // importance-sampling weight reduces to F * G1(NdotL) (applied by the caller). -public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { +public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, in PathSampler sampler, uint dimensionBase) { float3 t = normalize(abs(n.x) > 0.9 ? float3(0.0, 1.0, 0.0) : float3(1.0, 0.0, 0.0)); float3 b = normalize(cross(n, t)); t = cross(b, n); @@ -164,8 +322,9 @@ public float3 sampleGGXVNDF(float3 n, float3 ve, float alpha, inout uint s) { float lensq = Vh.x * Vh.x + Vh.y * Vh.y; float3 T1 = lensq > 0.0 ? float3(-Vh.y, Vh.x, 0.0) * rsqrt(lensq) : float3(1.0, 0.0, 0.0); float3 T2 = cross(Vh, T1); - float u1 = rndf(s); - float u2 = rndf(s); + float2 samples = pathSample2(sampler, dimensionBase); + float u1 = samples.x; + float u2 = samples.y; float r = sqrt(u1); float phi = 6.2831853 * u2; float p1 = r * cos(phi); diff --git a/shaders/pipelines/world/world_common.slang b/shaders/pipelines/world/world_common.slang index bc297ab8..bd5f1cee 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -88,6 +88,13 @@ 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; + // Canonical sample progression. pathSampleBase advances once per per-pixel continuation sample; + // the two possible continuation leaves have independent branch roots instead of skipping Sobol indices. + // pathSampleEpoch changes whenever temporal history and the sequence cursor reset. Every valid RT + // dispatch has a nonzero pathSampleAddr -- initialization failure falls back outside RT. + public uint pathSampleBase; + public uint pathSampleEpoch; + public uint64_t pathSampleAddr; }; // 32-byte hot area-light record. Linear ACEScg radiance uses packed R11G11B10; the diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 479af1eb..ceaeaef0 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -57,6 +57,7 @@ public static void ensureRegistered() { @SuppressWarnings("unused") Object[] touch = { Rt.ENABLED, Rt.Composite.SPP, Rt.Composite.MAX_BOUNCES, Rt.Terrain.ASYNC_DISPATCH_PER_PASS, Rt.Omm.ENABLED, + Rt.Lights.RIS_CANDIDATES, Rt.Entities.ENABLED, Rt.Entities.GLOW_ENABLED, Rt.EntityTextures.MAX_TEXTURES, Rt.DlssRr.ENABLED, Rt.Fg.ENABLED, Rt.Reflex.ENABLED, Rt.Exposure.MODE, Rt.Exposure.LOW_PERCENTILE, Rt.Exposure.HIGH_PERCENTILE, Rt.Exposure.PRE_EXPOSURE, Rt.Tonemap.GAMMA, @@ -578,7 +579,7 @@ private Terrain() { /** RIS block-emitter lights. {@code ris-candidates = 0} disables everything. */ public static final class Lights { public static final IntSetting RIS_CANDIDATES = - intAtLeast("caustica.rt.risCandidates", "lights.ris-candidates", 8, 0); + clampedInt("caustica.rt.risCandidates", "lights.ris-candidates", 8, 0, 32); public static final FloatSetting MIN_FILL_RATIO = finiteFloat("caustica.rt.lightMinFillRatio", "lights.min-fill-ratio", 0.25f); public static final BooleanSetting STATS = bool("caustica.rt.lightStats", "lights.stats", false); diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index 01fa62b5..505dd4d6 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -7,6 +7,7 @@ import dev.comfyfluffy.caustica.CausticaConfig.IntSetting; import dev.comfyfluffy.caustica.CausticaConfig.StringSetting; import dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping; +import dev.comfyfluffy.caustica.rt.terrain.RtTerrain; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -72,6 +73,7 @@ public static OptionInstance[] runtimeOptions() { gamma(), spp(), maxBounces(), + risCandidates(), entities(), particles(), waterWaves(), @@ -293,6 +295,30 @@ private static OptionInstance maxBounces() { setting::set); } + private static OptionInstance risCandidates() { + IntSetting setting = CausticaConfig.Rt.Lights.RIS_CANDIDATES; + return new OptionInstance<>( + "caustica.options.rt.risCandidates", + OptionInstance.cachedConstantTooltip(Component.translatable("caustica.options.rt.risCandidates.tooltip")), + (caption, value) -> Options.genericValueLabel(caption, + value == 0 + ? Component.translatable("caustica.options.rt.risCandidates.off") + : Component.literal(value + " candidates")), + new OptionInstance.IntRange(0, 32), + Math.clamp(setting.value(), 0, 32), + value -> { + if (setting.value() == value) { + return; + } + setting.set(value); + // Meshing omits emitter records while RIS is disabled; rebuild residency when the + // setting changes so the selected light population reaches the next render. DLSS-RR + // intentionally keeps its history here: this is a gradual lighting change, not a + // camera, dimension, resolution, or feature discontinuity. + RtTerrain.requestFullClear(); + }); + } + private static OptionInstance entities() { return bool("caustica.options.rt.entities", CausticaConfig.Rt.Entities.ENABLED); } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index 3ce8dc83..f4ca0a6c 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -67,6 +67,7 @@ import dev.comfyfluffy.caustica.rt.pipeline.RtHdrCompositePipeline; import dev.comfyfluffy.caustica.rt.pipeline.RtSdrPresentPipeline; import dev.comfyfluffy.caustica.rt.pipeline.RtExposure; +import dev.comfyfluffy.caustica.rt.pipeline.RtPathSamplerData; import dev.comfyfluffy.caustica.rt.pipeline.RtPipeline; import dev.comfyfluffy.caustica.rt.pipeline.RtToneLut; import dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping; @@ -106,6 +107,9 @@ public static boolean enabled() { // generated from the same Slang module and owns this second ABI as well. debugView is no longer // part of it -- no world shader reads it anymore; debug views are a downstream compute pass. private static final long PATH_RECORD_BYTES = 48L; + private static final int PATH_SEGMENTS_PER_PIXEL = RtPathSamplerData.PATH_BRANCH_COUNT; + private static final int PATH_PIXEL_AXIS_LIMIT = 1 << 16; + private static final long PATH_SAMPLE_INDEX_LIMIT = 1L << Integer.SIZE; private static int debugView() { return CausticaConfig.Rt.Composite.DEBUG_VIEW.value(); } @@ -153,6 +157,11 @@ public static long frameCounter() { } private RtPipeline worldPipeline; + private RtPathSamplerData pathSamplerData; + private long pathSampleCursor; + private int pathSampleEpoch; + private boolean pathSamplerResetPending = true; + private long pathSamplingPolicySignature = Long.MIN_VALUE; // Set at the HEAD of Minecraft.reloadResourcePacks() (mixin): a resource reload recreates the block // atlas + entity textures. We tear down the world pipeline there (drops all descriptor references) and // rebuild it once the NEW atlas is in place — detected by the atlas view handle changing away from @@ -504,9 +513,77 @@ public void captureFrame(Matrix4f projection, Matrix4fc viewRotation, double cam /** Reset exposure filtering after an explicit render-state invalidation such as F3+A. */ public void resetExposureHistory() { + pathSamplerResetPending = true; exposure.requestReset(); } + private void refreshPathSamplingPolicy(int frameSpp) { + long reservation = pathSamplesPerFrame(frameSpp); + long signature = pathSamplingPolicySignature(frameSpp); + if (pathSamplingPolicySignature != signature) { + pathSamplingPolicySignature = signature; + pathSamplerResetPending = true; + resetPathSamplingConsumers(); + } + if (!pathSamplerResetPending && pathSampleCursor > PATH_SAMPLE_INDEX_LIMIT - reservation) { + pathSamplerResetPending = true; + resetPathSamplingConsumers(); + } + if (pathSamplerResetPending) { + pathSampleCursor = 0L; + pathSampleEpoch++; + if (pathSampleEpoch == 0) { + pathSampleEpoch = 1; + } + pathSamplerResetPending = false; + } + } + + private void resetPathSamplingConsumers() { + mvHasPrev = false; + waterWaveTimeValid = false; + fgReset = true; + } + + private long pathSamplingPolicySignature(int frameSpp) { + int bounceCount = maxBounces(); + if (bounceCount < 0 || bounceCount > RtPathSamplerData.MAX_SUPPORTED_BOUNCE) { + throw new IllegalStateException("Path sampler does not support max-bounces=" + bounceCount); + } + int risCandidates = CausticaConfig.Rt.Lights.RIS_CANDIDATES.value(); + if (risCandidates < 0 || risCandidates > RtPathSamplerData.MAX_RIS_CANDIDATES) { + throw new IllegalStateException("Path sampler does not support RIS candidates=" + risCandidates); + } + + long signature = 17L; + signature = signature * 31L + RtPathSamplerData.ALGORITHM_VERSION; + signature = signature * 31L + frameSpp; + signature = signature * 31L + bounceCount; + signature = signature * 31L + risCandidates; + return signature; + } + + private static long pathSamplesPerFrame(int frameSpp) { + if (frameSpp < 1) { + throw new IllegalArgumentException("Path-tracing SPP must be positive: " + frameSpp); + } + long reservation = frameSpp; + if (reservation > PATH_SAMPLE_INDEX_LIMIT) { + throw new IllegalArgumentException("Path-tracing SPP exhausts the 32-bit sample domain: " + frameSpp); + } + return reservation; + } + + private int reservePathSamples(int frameSpp) { + long reservation = pathSamplesPerFrame(frameSpp); + if (pathSampleCursor > PATH_SAMPLE_INDEX_LIMIT - reservation) { + throw new IllegalStateException("Path sample cursor was not reset before 32-bit exhaustion"); + } + int base = (int) pathSampleCursor; + pathSampleCursor += reservation; + return base; + } + /** * The frame's forward camera-relative view-projection (jitter-free), exactly what {@code world.rgen} * traced with — overlay raster passes ({@code dev.comfyfluffy.caustica.rt.overlay}) reuse it so their content lands @@ -669,8 +746,10 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { return false; } refreshMaterialBindingsIfNeeded(ctx); + int frameSpp = spp(); + refreshPathSamplingPolicy(frameSpp); updateMotion(); - recordFrame(ctx, active, nativeColor); + recordFrame(ctx, active, nativeColor, frameSpp); if (!loggedActive) { loggedActive = true; CausticaMod.LOGGER.info("RT composite active (terrain): {}x{}, RT output replaces the world target", width, height); @@ -733,6 +812,11 @@ private RtPipeline ensureWorld(RtContext ctx) { VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true, "rt world push " + i)); } } + if (pathSamplerData == null) { + pathSamplerData = RtPathSamplerData.create(ctx); + CausticaMod.LOGGER.info("Initialized canonical path sampler v{}", + RtPathSamplerData.ALGORITHM_VERSION); + } if (output != null) { worldPipeline.setStorageImage(output.view); bindGuideImages(); @@ -943,10 +1027,10 @@ private void ensureOutput(RtContext ctx, int width, int height) { output = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH); long pixelRecords = Math.multiplyExact((long) renderW, (long) renderH); long continuationBytes = Math.multiplyExact( - Math.multiplyExact(pixelRecords, 2L), PATH_RECORD_BYTES); + Math.multiplyExact(pixelRecords, (long) PATH_SEGMENTS_PER_PIXEL), PATH_RECORD_BYTES); continuationQueue = ctx.createBuffer(continuationBytes, VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false, - "path continuation queue " + renderW + "x" + renderH + "x2"); + "path continuation queue " + renderW + "x" + renderH + "x" + PATH_SEGMENTS_PER_PIXEL); displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM, "RT display image " + width + "x" + height); // PQ-encoded ([0,1], ST.2084) HDR display image, written in parallel by display.comp when HDR mode is active. hdrDisplayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height); @@ -1025,7 +1109,7 @@ private void updateMotion() { mvHasPrev = true; } - private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColor) { + private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColor, int frameSpp) { long dstImage = vkImage(nativeColor); var encoder = (VulkanCommandEncoder) ((CommandEncoderAccessor) RenderSystem.getDevice().createCommandEncoder()).caustica$getBackend(); RtGpuExecutor gpuExecutor = ctx.gpuExecutor(); @@ -1035,6 +1119,17 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo // Reuse a completed readback slot, then latch one pre-exposure value for both raygen and resolve. // This belongs after the timeline snapshot and before any world push data is written. exposure.beginFrame(graphicsUseWaiter); + if (renderW > PATH_PIXEL_AXIS_LIMIT || renderH > PATH_PIXEL_AXIS_LIMIT) { + throw new IllegalStateException("Path sampler requires render dimensions at or below 65536: " + + renderW + "x" + renderH); + } + int pathSampleBase = reservePathSamples(frameSpp); + RtPathSamplerData samplerData = Objects.requireNonNull(pathSamplerData, + "Path sampler data must exist before recording an RT frame"); + long pathSampleAddress = samplerData.deviceAddress(); + if (pathSampleAddress == 0L) { + throw new IllegalStateException("Path sampler data lost its device address"); + } pendingGraphicsUse = graphicsUse; RtEntities.FrameEntities frameEntities = null; VkCommandBuffer cmd = encoder.allocateAndBeginTransientCommandBuffer(); @@ -1131,7 +1226,7 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo (int) frameCounter, mvPushMatrix, new Float3(mvCamDeltaX, mvCamDeltaY, mvCamDeltaZ), - spp(), + frameSpp, new Float2(jitterX, jitterY), flags, maxBounces(), @@ -1159,7 +1254,10 @@ 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(), + pathSampleBase, + pathSampleEpoch, + pathSampleAddress ).write(push); pushBuf.flush(0L, WORLD_PUSH_SIZE); // Upload any entity textures registered this frame into the bindless set before the trace. @@ -1497,6 +1595,14 @@ public void destroy() { continuationQueue.destroy(); continuationQueue = null; } + if (pathSamplerData != null) { + pathSamplerData.destroy(); + pathSamplerData = null; + } + pathSampleCursor = 0L; + pathSampleEpoch = 0; + pathSamplerResetPending = true; + pathSamplingPolicySignature = Long.MIN_VALUE; destroyGuideImages(); exposure.destroy(); if (displayPipeline != null) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java new file mode 100644 index 00000000..ad37d880 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java @@ -0,0 +1,140 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import dev.comfyfluffy.caustica.rt.RtContext; +import dev.comfyfluffy.caustica.rt.accel.RtBuffer; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.VK10; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Objects; + +/** + * Immutable GPU data for Caustica's canonical shuffled-scrambled Sobol path sampler. + * + *

The resource stores compact Joe-Kuo Sobol nibble lookups for dimensions 1..3; dimension 0 is + * evaluated analytically as the bit-reversed Gray code. It also stores independently generated + * randomization roots for every continuation branch, bounce, and semantic low-dimensional group. The + * independent per-coordinate digital-shift roots are the estimator's unbiased randomization; the + * nested-uniform index shuffle and coordinate scramble improve projection quality without being the + * proof foundation. This is sample-indexed direction data, never a spatial tile. + */ +public final class RtPathSamplerData { + public static final int ALGORITHM_VERSION = 3; + + static final int DIMENSIONS = RtSobolDirectionNumbers.DIMENSIONS; + static final int NIBBLE_BLOCKS = 8; + static final int NIBBLE_VALUES = 16; + static final int WORDS_PER_DIMENSION = NIBBLE_BLOCKS * NIBBLE_VALUES; + static final int FIRST_TABLE_DIMENSION = 1; + static final int TABLE_DIMENSION_COUNT = DIMENSIONS - FIRST_TABLE_DIMENSION; + + public static final int PATH_BRANCH_COUNT = 2; + public static final int MAX_SUPPORTED_BOUNCE = 8; + static final int BOUNCE_COUNT = MAX_SUPPORTED_BOUNCE + 1; + public static final int MAX_RIS_CANDIDATES = 32; + + static final int GROUP_COUNT = 3 + 1 + MAX_RIS_CANDIDATES * 2 + 1; + static final int ROOTS_PER_GROUP = 1 + DIMENSIONS * 2; + + static final int DIRECTION_TABLE_OFFSET = 0; + static final int ROOT_TABLE_OFFSET = DIRECTION_TABLE_OFFSET + + TABLE_DIMENSION_COUNT * WORDS_PER_DIMENSION; + static final int ROOT_WORD_COUNT = PATH_BRANCH_COUNT * BOUNCE_COUNT * GROUP_COUNT * ROOTS_PER_GROUP; + static final int WORD_COUNT = ROOT_TABLE_OFFSET + ROOT_WORD_COUNT; + static final long BYTE_SIZE = (long) WORD_COUNT * Integer.BYTES; + static final int ADDRESS_ALIGNMENT = 16; + + private static final int[][] DIRECTIONS = RtSobolDirectionNumbers.createDirections(); + + private final RtBuffer buffer; + + private RtPathSamplerData(RtBuffer buffer) { + this.buffer = buffer; + } + + /** Create the one required sampler resource. Failure propagates and disables RT through RtComposite. */ + public static RtPathSamplerData create(RtContext ctx) { + Objects.requireNonNull(ctx, "ctx"); + SecureRandom random = new SecureRandom(); + int[] roots = new int[ROOT_WORD_COUNT]; + for (int index = 0; index < roots.length; index++) { + roots[index] = random.nextInt(); + } + return create(ctx, roots); + } + + private static RtPathSamplerData create(RtContext ctx, int[] randomizationRoots) { + Objects.requireNonNull(ctx, "ctx"); + int[] roots = checkedRootCopy(randomizationRoots); + RtBuffer buffer = ctx.createAlignedBuffer(BYTE_SIZE, VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, true, + "canonical shuffled-scrambled Sobol path sampler", ADDRESS_ALIGNMENT); + try { + requireUsableBuffer(buffer.mapped, buffer.deviceAddress); + + int[] words = buildResourceWords(roots); + long address = buffer.mapped; + for (int word : words) { + MemoryUtil.memPutInt(address, word); + address += Integer.BYTES; + } + buffer.flush(); + return new RtPathSamplerData(buffer); + } catch (RuntimeException | Error failure) { + try { + buffer.destroy(); + } catch (RuntimeException | Error destroyFailure) { + failure.addSuppressed(destroyFailure); + } + throw failure; + } + } + + public long deviceAddress() { + return buffer.deviceAddress; + } + + public void destroy() { + buffer.destroy(); + } + + static void requireUsableBuffer(long mappedAddress, long deviceAddress) { + if (mappedAddress == 0L) { + throw new IllegalStateException("Path sampler data buffer is not host mapped"); + } + if (deviceAddress == 0L) { + throw new IllegalStateException("Path sampler data buffer has no device address"); + } + } + + static int[] buildResourceWords(int[] randomizationRoots) { + int[] roots = checkedRootCopy(randomizationRoots); + int[] words = new int[WORD_COUNT]; + for (int dimension = FIRST_TABLE_DIMENSION; dimension < DIMENSIONS; dimension++) { + int tableDimension = dimension - FIRST_TABLE_DIMENSION; + int dimensionOffset = DIRECTION_TABLE_OFFSET + tableDimension * WORDS_PER_DIMENSION; + for (int block = 0; block < NIBBLE_BLOCKS; block++) { + int blockOffset = dimensionOffset + block * NIBBLE_VALUES; + for (int nibble = 0; nibble < NIBBLE_VALUES; nibble++) { + int value = 0; + for (int bit = 0; bit < 4; bit++) { + if ((nibble & (1 << bit)) != 0) { + value ^= DIRECTIONS[dimension][block * 4 + bit]; + } + } + words[blockOffset + nibble] = value; + } + } + } + System.arraycopy(roots, 0, words, ROOT_TABLE_OFFSET, roots.length); + return words; + } + + private static int[] checkedRootCopy(int[] roots) { + if (roots == null || roots.length != ROOT_WORD_COUNT) { + throw new IllegalArgumentException("Path sampler requires exactly " + ROOT_WORD_COUNT + + " randomization roots"); + } + return Arrays.copyOf(roots, ROOT_WORD_COUNT); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSobolDirectionNumbers.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSobolDirectionNumbers.java new file mode 100644 index 00000000..a0454ed0 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSobolDirectionNumbers.java @@ -0,0 +1,70 @@ +/* + * Sobol direction-number data notice + * + * Copyright (c) 2008, Frances Y. Kuo and Stephen Joe + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, are permitted + * provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this list of conditions + * and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions + * and the following disclaimer in the documentation and/or other materials provided with the + * distribution. + * 3. Neither the names of the copyright holders nor the names of the University of New South Wales and + * the University of Waikato and its contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT + * OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package dev.comfyfluffy.caustica.rt.pipeline; + +/** Exact expansion of the first four Joe-Kuo D(6) Sobol dimensions. */ +final class RtSobolDirectionNumbers { + static final int DIMENSIONS = 4; + + // Rows 2..4 from new-joe-kuo-6.21201. Dimension 1 is defined analytically below. + private static final int[][] PARAMETERS = { + {}, + {1, 0, 1}, + {2, 1, 1, 3}, + {3, 1, 1, 3, 1} + }; + + private RtSobolDirectionNumbers() { + } + + static int[][] createDirections() { + int[][] directions = new int[DIMENSIONS][Integer.SIZE]; + for (int bit = 0; bit < Integer.SIZE; bit++) { + directions[0][bit] = 1 << (Integer.SIZE - 1 - bit); + } + for (int dimension = 1; dimension < DIMENSIONS; dimension++) { + int[] parameters = PARAMETERS[dimension]; + int degree = parameters[0]; + int coefficient = parameters[1]; + for (int bit = 1; bit <= degree; bit++) { + directions[dimension][bit - 1] = parameters[bit + 1] << (Integer.SIZE - bit); + } + for (int bit = degree + 1; bit <= Integer.SIZE; bit++) { + int value = directions[dimension][bit - degree - 1] + ^ (directions[dimension][bit - degree - 1] >>> degree); + for (int k = 1; k < degree; k++) { + if (((coefficient >>> (degree - 1 - k)) & 1) != 0) { + value ^= directions[dimension][bit - k - 1]; + } + } + directions[dimension][bit - 1] = value; + } + } + return directions; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java index b89fce85..28e6f250 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/terrain/RtLightGridManager.java @@ -179,9 +179,9 @@ private void submitUpload(RtContext ctx, long requestId, RtLightHierarchy.Data d cursor = upload.mapped + layout.globalAliasOffset; writeAliases(cursor, data.globalAliases()); RtLightGrid.Data grid = layout.hasGrid ? data.grid() : null; + cursor = upload.mapped + layout.localAliasOffset; + writeAliases(cursor, data.localAliases()); if (grid != null) { - cursor = upload.mapped + layout.localAliasOffset; - writeAliases(cursor, data.localAliases()); cursor = upload.mapped + layout.cellOffset; for (int i = 0; i < grid.cellOffsets().length; i++) { MemoryUtil.memPutInt(cursor, grid.cellOffsets()[i]); @@ -198,6 +198,9 @@ private void submitUpload(RtContext ctx, long requestId, RtLightHierarchy.Data d MemoryUtil.memPutFloat(cursor + 12, grid.spanAccept()[i]); cursor += 16; } + } else { + // RIS reads the discarded local chain unconditionally; a zero span keeps that load in-bounds. + MemoryUtil.memSet(upload.mapped + layout.spanOffset, 0, 16); } upload.flush(); @@ -379,9 +382,9 @@ private static PublishedState empty(long generation) { long lightAddress() { return address(layout.lightOffset); } long globalAliasAddress() { return address(layout.globalAliasOffset); } - long localAliasAddress() { return layout.hasGrid ? address(layout.localAliasOffset) : 0L; } + long localAliasAddress() { return lightCount > 0 ? address(layout.localAliasOffset) : 0L; } long cellAddress() { return layout.hasGrid ? address(layout.cellOffset) : 0L; } - long spanAddress() { return layout.hasGrid ? address(layout.spanOffset) : 0L; } + long spanAddress() { return lightCount > 0 ? address(layout.spanOffset) : 0L; } private long address(long offset) { return arena != null ? arena.deviceAddress + offset : 0L; @@ -408,14 +411,17 @@ static Layout of(RtLightHierarchy.Data data, boolean includeGrid) { cursor = align16(Math.addExact(cursor, data.lightBytes())); long globalAliases = cursor; cursor = align16(Math.addExact(cursor, data.globalAliases().bytes())); - long localAliases = 0L, cells = 0L, spans = 0L; + long localAliases = cursor; + cursor = align16(Math.addExact(cursor, data.localAliases().bytes())); + long cells = 0L, spans; if (includeGrid) { - localAliases = cursor; - cursor = align16(Math.addExact(cursor, data.localAliases().bytes())); cells = cursor; cursor = align16(Math.addExact(cursor, data.grid().cellBytes())); spans = cursor; cursor = align16(Math.addExact(cursor, data.grid().spanBytes())); + } else { + spans = cursor; + cursor = align16(Math.addExact(cursor, 16L)); } return new Layout(lights, globalAliases, localAliases, cells, spans, cursor, includeGrid); } diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index 56893e29..1661588a 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -24,6 +24,10 @@ "caustica.options.rt.maxBounces": "Path Bounces", "caustica.options.rt.maxBounces.tooltip": "Maximum number of secondary path-tracing bounces after the primary hit. Higher captures more indirect light but costs more.", + "caustica.options.rt.risCandidates": "RIS Light Candidates", + "caustica.options.rt.risCandidates.tooltip": "Emitter candidates tested per diffuse vertex. Higher values reduce torch and glow-light noise but cost more ray-tracing work; zero disables RIS.", + "caustica.options.rt.risCandidates.off": "Off", + "caustica.options.rt.entities": "Ray-Traced Entities", "caustica.options.rt.entities.tooltip": "Include entities and block entities in the ray-traced scene.", diff --git a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java index 28d7095f..d4c7b67d 100644 --- a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java @@ -53,6 +53,33 @@ void analyticalToneControlsRejectNonfiniteValues() { } } + @Test + void risCandidatesPreserveTheUpstreamDefaultAndClampAllRuntimeInputs() { + CausticaConfig.IntSetting setting = CausticaConfig.Rt.Lights.RIS_CANDIDATES; + int previous = setting.value(); + try { + assertEquals(8, setting.defaultValue()); + setting.set(-1); + assertEquals(0, setting.value()); + setting.set(64); + assertEquals(32, setting.value()); + } finally { + setting.set(previous); + } + } + + @Test + void samplingDefaultsMatchTheRendererProfile() { + assertEquals(8, CausticaConfig.Rt.Lights.RIS_CANDIDATES.defaultValue()); + assertEquals(4, CausticaConfig.Rt.Composite.MAX_BOUNCES.defaultValue()); + } + + @Test + void registersSamplingSettingsForConfigRoundTrips() { + CausticaConfig.ensureRegistered(); + assertTrue(hasSetting("caustica.rt.risCandidates")); + } + @Test void paperWhiteCannotExceedTheSelectedPeak() { var paperWhite = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS; diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplingTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplingTest.java new file mode 100644 index 00000000..7b79750f --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplingTest.java @@ -0,0 +1,105 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class RtPathSamplingTest { + private static final int[][] DIRECTIONS = RtSobolDirectionNumbers.createDirections(); + + @Test + void grayCodeSobolValuesMatchReference() { + int[][] expected = { + {0x00000000, 0x80000000, 0xc0000000, 0x40000000, + 0x60000000, 0xe0000000, 0xa0000000, 0x20000000}, + {0x00000000, 0x80000000, 0x40000000, 0xc0000000, + 0x60000000, 0xe0000000, 0x20000000, 0xa0000000}, + {0x00000000, 0x80000000, 0x40000000, 0xc0000000, + 0xa0000000, 0x20000000, 0xe0000000, 0x60000000}, + {0x00000000, 0x80000000, 0x40000000, 0xc0000000, + 0xe0000000, 0x60000000, 0xa0000000, 0x20000000} + }; + for (int dimension = 0; dimension < expected.length; dimension++) { + for (int sample = 0; sample < expected[dimension].length; sample++) { + assertEquals(expected[dimension][sample], directSobolBits(sample, dimension)); + } + } + } + + @Test + void resourceLayoutIsDeterministicAndCompact() { + int[] roots = roots(0x51a7cafeL); + int[] first = RtPathSamplerData.buildResourceWords(roots); + int[] second = RtPathSamplerData.buildResourceWords(roots); + assertArrayEquals(first, second); + assertEquals(0, RtPathSamplerData.DIRECTION_TABLE_OFFSET); + assertEquals(RtPathSamplerData.ROOT_TABLE_OFFSET + RtPathSamplerData.ROOT_WORD_COUNT, + RtPathSamplerData.WORD_COUNT); + assertArrayEquals(roots, Arrays.copyOfRange(first, + RtPathSamplerData.ROOT_TABLE_OFFSET, RtPathSamplerData.WORD_COUNT)); + } + + @Test + void resourceLookupMatchesDirectSobolEvaluation() { + int[] words = RtPathSamplerData.buildResourceWords(roots(0x51a7cafeL)); + Random random = new Random(0x5e0e1ceL); + for (int iteration = 0; iteration < 2048; iteration++) { + int sample = random.nextInt(); + int dimension = random.nextInt(DIRECTIONS.length); + assertEquals(directSobolBits(sample, dimension), resourceSobolBits(words, sample, dimension)); + } + } + + @Test + void invalidResourcesAndAddressesFailClosed() { + assertThrows(IllegalArgumentException.class, + () -> RtPathSamplerData.buildResourceWords(new int[1])); + assertThrows(IllegalArgumentException.class, + () -> RtPathSamplerData.buildResourceWords(null)); + assertThrows(IllegalStateException.class, + () -> RtPathSamplerData.requireUsableBuffer(0L, 1L)); + assertThrows(IllegalStateException.class, + () -> RtPathSamplerData.requireUsableBuffer(1L, 0L)); + } + + private static int directSobolBits(int sampleIndex, int dimension) { + int gray = sampleIndex ^ (sampleIndex >>> 1); + int value = 0; + for (int bit = 0; bit < Integer.SIZE; bit++) { + if ((gray & (1 << bit)) != 0) { + value ^= DIRECTIONS[dimension][bit]; + } + } + return value; + } + + private static int resourceSobolBits(int[] words, int sampleIndex, int dimension) { + int gray = sampleIndex ^ (sampleIndex >>> 1); + if (dimension == 0) { + return Integer.reverse(gray); + } + int base = RtPathSamplerData.DIRECTION_TABLE_OFFSET + + (dimension - RtPathSamplerData.FIRST_TABLE_DIMENSION) + * RtPathSamplerData.WORDS_PER_DIMENSION; + int value = 0; + for (int block = 0; block < RtPathSamplerData.NIBBLE_BLOCKS; block++) { + value ^= words[base + block * RtPathSamplerData.NIBBLE_VALUES + + ((gray >>> (block * 4)) & 15)]; + } + return value; + } + + private static int[] roots(long seed) { + Random random = new Random(seed); + int[] roots = new int[RtPathSamplerData.ROOT_WORD_COUNT]; + for (int index = 0; index < roots.length; index++) { + roots[index] = random.nextInt(); + } + return roots; + } +} From 7184ae66ad3fbc34aea1c2ccd407948218b074b3 Mon Sep 17 00:00:00 2001 From: PEQHUB Date: Mon, 10 Aug 2026 10:57:12 -0500 Subject: [PATCH 3/6] feat: integrate DLSS-RR guides and SHARC --- .github/workflows/ci.yml | 13 +- THIRD_PARTY_NOTICES.md | 16 +- build.gradle | 431 +++++++++- .../caustica/build/GenerateRtBindings.groovy | 7 +- .../build/GenerateShaderRecords.groovy | 18 + docs/developer_guide.md | 30 +- native/ngx_shim/ngx_shim.cpp | 95 ++- shaders/common/display_common.slang | 6 + shaders/layout/layout_probe.slang | 14 + shaders/pipelines/display/bindings.slang | 3 + shaders/pipelines/display/main.comp.slang | 132 ++- shaders/pipelines/world/any_hit.rahit.slang | 2 +- shaders/pipelines/world/bindings.slang | 15 +- .../pipelines/world/closest_hit.rchit.slang | 17 +- shaders/pipelines/world/guides.slang | 95 ++- shaders/pipelines/world/indirect.rgen.slang | 204 ++++- shaders/pipelines/world/math.slang | 8 +- shaders/pipelines/world/primary.rgen.slang | 13 +- shaders/pipelines/world/sharc_types.slang | 45 + shaders/pipelines/world/sky.rmiss.slang | 74 ++ shaders/pipelines/world/trace.slang | 6 +- shaders/pipelines/world/trace_ser.slang | 5 +- shaders/pipelines/world/world_common.slang | 19 + shaders/sharc/sharc_bridge.slang | 343 ++++++++ shaders/sharc/sharc_resolve.comp.slang | 36 + .../comfyfluffy/caustica/CausticaConfig.java | 39 +- .../caustica/client/CausticaClient.java | 27 +- .../caustica/client/CausticaJitter.java | 30 +- .../caustica/client/RtSharcOptionsScreen.java | 178 ++++ .../caustica/client/RtVideoOptions.java | 7 +- .../client/VanillaRenderController.java | 4 +- .../caustica/mixin/GameRendererMixin.java | 6 +- .../mixin/VideoSettingsScreenMixin.java | 21 +- .../caustica/mixin/VulkanBackendMixin.java | 18 +- .../caustica/mixin/VulkanInstanceMixin.java | 10 +- .../comfyfluffy/caustica/ngx/NgxLibrary.java | 42 +- .../comfyfluffy/caustica/ngx/NgxRuntime.java | 156 +++- .../comfyfluffy/caustica/rt/RtComposite.java | 773 +++++++++++++++--- .../comfyfluffy/caustica/rt/RtContext.java | 8 +- .../caustica/rt/RtDeviceBringup.java | 45 +- .../comfyfluffy/caustica/rt/RtFrameStats.java | 3 + .../comfyfluffy/caustica/rt/RtSharcCache.java | 239 ++++++ .../caustica/rt/RtSharcSupport.java | 102 +++ .../comfyfluffy/caustica/rt/RtSkyMath.java | 32 + .../caustica/rt/entity/RtEntities.java | 61 +- .../caustica/rt/entity/RtEntityTextures.java | 25 +- .../rt/material/RtBlockMaterials.java | 13 +- .../rt/pipeline/RtDisplayPipeline.java | 59 +- .../caustica/rt/pipeline/RtDlssRr.java | 211 ++++- .../rt/pipeline/RtPathSamplerData.java | 2 +- .../caustica/rt/pipeline/RtPipeline.java | 10 +- .../rt/pipeline/RtSharcResolvePipeline.java | 120 +++ .../resources/assets/caustica/lang/en_us.json | 39 +- .../caustica/CausticaConfigTest.java | 13 +- .../caustica/client/CausticaClientTest.java | 16 + .../caustica/client/CausticaJitterTest.java | 32 + .../caustica/rt/RtSharcSkyResetTest.java | 36 + .../comfyfluffy/caustica/rt/RtSharcTest.java | 19 + .../pipeline/RtDisplayShaderContractTest.java | 4 +- .../caustica/rt/pipeline/RtDlssRrTest.java | 16 + 60 files changed, 3719 insertions(+), 344 deletions(-) create mode 100644 shaders/pipelines/world/sharc_types.slang create mode 100644 shaders/sharc/sharc_bridge.slang create mode 100644 shaders/sharc/sharc_resolve.comp.slang create mode 100644 src/main/java/dev/comfyfluffy/caustica/client/RtSharcOptionsScreen.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/RtSharcCache.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/RtSharcSupport.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/RtSkyMath.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSharcResolvePipeline.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/client/CausticaClientTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/client/CausticaJitterTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/RtSharcSkyResetTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/RtSharcTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRrTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c741a161..983a2e39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,10 @@ on: - "THIRD_PARTY_NOTICES.md" env: - DLSS_SDK_REF: v310.7.0 + DLSS_SDK_REF: a291cc7d2cc642a51566f3dfd5376f635cd1b284 DLSS_SDK_PATH: third_party/DLSS + SHARC_SDK_REF: e19ccacd511f42a3df6f850052d508c13c9e9737 + SHARC_SDK_PATH: third_party/SHARC NGX_SHIM_CONFIG: release NGX_VENDOR_CONFIG: rel VULKAN_SDK_VERSION: 1.4.350.0 @@ -142,6 +144,13 @@ jobs: ref: ${{ env.DLSS_SDK_REF }} path: ${{ env.DLSS_SDK_PATH }} + - name: Checkout SHARC SDK + uses: actions/checkout@v4 + with: + repository: NVIDIA-RTX/SHARC + ref: ${{ env.SHARC_SDK_REF }} + path: ${{ env.SHARC_SDK_PATH }} + - name: Setup Java uses: actions/setup-java@v4 with: @@ -178,6 +187,8 @@ jobs: - name: Build bundled jar env: DLSS_SDK: ${{ github.workspace }}/${{ env.DLSS_SDK_PATH }} + SHARC_SDK: ${{ github.workspace }}/${{ env.SHARC_SDK_PATH }} + SHARC_LICENSE_ACCEPTED: "true" run: > bash ./gradlew build -PngxPlatforms=windows-x64,linux-x64 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 8404f8ad..f5ebb8dc 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -44,7 +44,7 @@ not licensed under the LGPL. The NVIDIA SDK components remain subject to the NVIDIA RTX SDKs license: - + The LGPL license grant for Caustica does not grant rights to NVIDIA SDK components. Redistribution and use of those components must comply with @@ -78,3 +78,17 @@ derived products without prior written permission. The data is provided without warranty; the copyright holders are not liable for damages arising from its use. The complete notice is retained in the source file. + +## NVIDIA SHaRC SDK + +Caustica can build release artifacts with NVIDIA SHaRC SDK 1.8.0.0 shader +inputs and runtime resources. SHaRC is proprietary software provided by NVIDIA +Corporation and is not licensed under Caustica's LGPL-3.0-or-later license. + +The SHaRC SDK remains subject to the NVIDIA RTX SDKs license: + + + +The complete accepted SHaRC license is packaged in release artifacts at +`META-INF/licenses/nvidia/NVIDIA-SHARC-SDK.txt`. The LGPL license grant for +Caustica does not grant rights to the SHaRC SDK. diff --git a/build.gradle b/build.gradle index c23b48b0..e65c1474 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ import javax.inject.Inject import org.gradle.process.ExecOperations import dev.comfyfluffy.caustica.build.GenerateShaderRecords import dev.comfyfluffy.caustica.build.GenerateRtBindings +import java.security.MessageDigest plugins { id "fabric-loom" version "${loom_version}" @@ -81,6 +82,88 @@ processResources { // spirv-val from the Vulkan SDK or PATH. def shaderGenRoot = layout.buildDirectory.dir("generated/shaders") // classpath root -> /caustica/shaders/*.spv +// NVIDIA SHaRC is an optional, separately licensed shader input. The ordinary build remains stock when +// SHARC_SDK is absent; an explicitly configured SDK must be the pinned 1.8.0.0 checkout so the shader ABI +// cannot silently drift from the generated Java records and runtime cache layout. +def sharcVersion = "1.8.0.0" +def sharcCommit = "e19ccacd511f42a3df6f850052d508c13c9e9737" +def sharcHashes = [ + "include/SharcCommon.h": "d4b6e2765828a4b1c71bbf40609b2dbe72b4aec8ff4d80ccda21a83ab634ecc4", + "include/SharcTypes.h": "cde3f200e4e84f029968dffd9eede5228f2ae0b0bbc166d2babb1aa88dc044b3", + "include/HashGridCommon.h": "5a6d67186a88e61f0d47518f5986021f309f2897ccb482ab9e011a7a08749e7f", + "include/HashGridTypes.h": "34012bcffff0f2545c108a7c9dcb223298a70ebc2c377a86fbddfd1ecd76d614", + "License.md": "5575ad921bfe6027dc830887ff51ceb4097e31c2d538b6932cd4bbdf5c1899a9" +] +def configuredSharcSdk = (findProperty("sharcSdk") ?: System.getenv("SHARC_SDK"))?.toString()?.trim() +def sharcLicenseProperty = findProperty("acceptSharcLicense") +def sharcLicenseAccepted = sharcLicenseProperty != null + ? sharcLicenseProperty.toString().toBoolean() + : (System.getenv("SHARC_LICENSE_ACCEPTED")?.toBoolean() ?: false) +def sha256File = { File input -> + def digest = MessageDigest.getInstance("SHA-256") + input.withInputStream { stream -> + byte[] buffer = new byte[64 * 1024] + int read + while ((read = stream.read(buffer)) >= 0) { + if (read > 0) digest.update(buffer, 0, read) + } + } + digest.digest().collect { String.format("%02x", it & 0xff) }.join() +} +def sha256TextFile = { File input -> + def normalized = input.getText("UTF-8").replace("\r\n", "\n").replace("\r", "\n") + def digest = MessageDigest.getInstance("SHA-256") + digest.update(normalized.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + digest.digest().collect { String.format("%02x", it & 0xff) }.join() +} +def sharcSdkRoot = configuredSharcSdk ? file(configuredSharcSdk) : null +def validateSharcSdk = { File sdk -> + if (sdk == null) return + if (!sharcLicenseAccepted) { + throw new GradleException("SHARC_SDK is set, but the NVIDIA SHaRC 1.8 license was not explicitly accepted. " + + "Pass -PacceptSharcLicense=true after reviewing ${new File(sdk, 'License.md')}") + } + if (!sdk.isDirectory()) { + throw new GradleException("SHARC_SDK must point to the pinned git checkout ${sharcCommit}: ${sdk}") + } + def head = new ProcessBuilder("git", "-C", sdk.absolutePath, "rev-parse", "HEAD") + .redirectErrorStream(true).start() + def headText = head.inputStream.text.trim() + if (head.waitFor() != 0 || !headText.equalsIgnoreCase(sharcCommit)) { + throw new GradleException("SHARC_SDK HEAD ${headText} is not pinned to ${sharcCommit}") + } + def status = new ProcessBuilder("git", "-C", sdk.absolutePath, "status", "--porcelain", "--untracked-files=all") + .redirectErrorStream(true).start() + def statusText = status.inputStream.text.trim() + if (status.waitFor() != 0 || !statusText.isEmpty()) { + throw new GradleException(status.exitValue() == 0 + ? "SHARC_SDK checkout is dirty: ${statusText}" + : "Could not read SHARC_SDK checkout status: ${statusText}") + } + def common = new File(sdk, "include/SharcCommon.h") + if (!common.isFile()) throw new GradleException("Missing pinned SHaRC header: ${common}") + def commonText = common.getText("UTF-8") + ["SHARC_VERSION_MAJOR": "1", "SHARC_VERSION_MINOR": "8", "SHARC_VERSION_BUILD": "0", + "SHARC_VERSION_REVISION": "0"].each { name, value -> + if (!(commonText =~ /#define\s+${name}\s+${value}(?:\s|$)/)) { + throw new GradleException("SHARC_SDK ${common} does not declare ${name}=${value}") + } + } + sharcHashes.each { relative, expected -> + def input = new File(sdk, relative) + if (!input.isFile()) throw new GradleException("Missing pinned SHaRC input: ${input}") + def actual = sha256TextFile(input) + if (!actual.equalsIgnoreCase(expected)) { + throw new GradleException("SHARC_SDK hash mismatch for ${relative}: ${actual} != ${expected}") + } + } +} +validateSharcSdk(sharcSdkRoot) +def sharcSdkContentFingerprint = sharcSdkRoot == null ? "" : sharcHashes.collect { relative, expected -> + "${relative}:${sha256TextFile(new File(sharcSdkRoot, relative))}" +}.join("|") +def sharcLicenseRawHash = sharcSdkRoot == null ? "" : sha256File(new File(sharcSdkRoot, "License.md")) + def resolveVulkanTool = { String name -> def exe = org.gradle.internal.os.OperatingSystem.current().windows ? ".exe" : "" def sdk = System.getenv("VULKAN_SDK") @@ -93,11 +176,87 @@ def resolveVulkanTool = { String name -> return "${name}${exe}" // fall back to PATH; exec fails with a clear message if absent } +def locateExecutable = { String command -> + def direct = file(command) + if (direct.isFile()) return direct + def windows = org.gradle.internal.os.OperatingSystem.current().windows + def names = windows && !command.toLowerCase(java.util.Locale.ROOT).endsWith(".exe") + ? [command, "${command}.exe"] : [command] + def path = System.getenv("PATH") ?: "" + for (String entry : path.split(java.io.File.pathSeparator)) { + if (entry.isEmpty()) continue + for (String name : names) { + def candidate = new File(entry, name) + if (candidate.isFile()) return candidate + } + } + return null +} + +def locateVulkanTool = { String command -> locateExecutable(command) } + +// Keep executable paths as task inputs for diagnostics, and hash their contents so replacing a +// compiler or validator at the same path invalidates generated outputs. +def toolContentFingerprint = { String command -> + def executable = locateVulkanTool(command) + if (executable == null) return "unresolved:${command}" + return "${executable.absolutePath}:${sha256File(executable)}" +} + +def runIdentityCommand = { File executable, List arguments -> + if (executable == null || !executable.isFile()) { + return [status: "missing", output: ""] + } + def commandLine = new ArrayList() + commandLine.add(executable.absolutePath) + commandLine.addAll(arguments.collect { it.toString() }) + def process = new ProcessBuilder(commandLine) + .redirectErrorStream(true).start() + def output = process.inputStream.getText("UTF-8").trim() + def exitCode = process.waitFor() + [status: exitCode == 0 ? "ok" : "failed-${exitCode}", output: output] +} + +def slangcTool = resolveVulkanTool("slangc") +def spirvValTool = resolveVulkanTool("spirv-val") +def minimumSlangVersion = [2026, 4] +def validateSlangCompiler = { + File executable = locateVulkanTool(slangcTool) + if (executable == null || !executable.isFile()) { + throw new GradleException("slangc was not found. Set VULKAN_SDK to a Vulkan SDK containing Slang ${minimumSlangVersion[0]}.${minimumSlangVersion[1]} or newer.") + } + def result = runIdentityCommand(executable, ["-version"]) + if (result.status != "ok") { + throw new GradleException("Could not query slangc at ${executable}: ${result.output}") + } + def match = result.output =~ /(\d{4})\.(\d+)/ + if (!match.find()) { + throw new GradleException("Could not parse slangc version from ${executable}: ${result.output}") + } + int major = match.group(1).toInteger() + int minor = match.group(2).toInteger() + if (major < minimumSlangVersion[0] || (major == minimumSlangVersion[0] && minor < minimumSlangVersion[1])) { + throw new GradleException("slangc ${major}.${minor} at ${executable} is too old; Caustica requires Slang ${minimumSlangVersion[0]}.${minimumSlangVersion[1]} or newer. " + + "Point VULKAN_SDK at the compatible SDK so its slangc and spirv-val are selected together.") + } +} +def validateShaderToolchain = tasks.register("validateShaderToolchain") { + group = "verification" + description = "Checks that the selected Slang compiler supports Caustica's shader pointer layout syntax." + inputs.property("slangc", slangcTool) + inputs.property("slangcContentHash", toolContentFingerprint(slangcTool)) + doLast { validateSlangCompiler() } +} + abstract class CompileShaders extends DefaultTask { @InputDirectory abstract DirectoryProperty getSrcDir() @OutputDirectory abstract DirectoryProperty getOutDir() @Input abstract Property getSpirvVal() @Input abstract Property getSlangc() + @Input abstract Property getSpirvValContentHash() + @Input abstract Property getSlangcContentHash() + @Input abstract Property getSharcSdk() + @Input abstract Property getSharcSdkContentHash() @Inject abstract ExecOperations getExecOps() @@ -136,6 +295,7 @@ abstract class CompileShaders extends DefaultTask { } def shaderFiles = srcDir.get().asFileTree.matching { include stageIncludes + exclude "sharc/**" }.files.sort { it.absolutePath } def duplicateOutputs = shaderFiles .groupBy { "${outBase(srcDir.get().asFile, it)}.spv" } @@ -166,6 +326,45 @@ abstract class CompileShaders extends DefaultTask { compileOneSlang(src, spv, []) } } + File sdk = sharcSdk.getOrElse("").trim() ? new File(sharcSdk.get()) : null + if (sdk != null) { + // The pinned SDK header has a false-return path whose out parameter is not initialized. Slang + // diagnoses that under warnings-as-errors. The source/header hashes above validate the original; + // this disposable copy only repairs the compiler warning and is never packaged. + File compilerInclude = new File(temporaryDir, "sharc-include") + project.copy { from(new File(sdk, "include")); into(compilerInclude) } + File common = new File(compilerInclude, "SharcCommon.h") + String commonText = common.getText("UTF-8") + int functionStart = commonText.indexOf("bool SharcGetCachedRadiance") + int functionBrace = functionStart >= 0 ? commonText.indexOf("{", functionStart) : -1 + if (functionBrace < 0) throw new GradleException("Pinned SHaRC header lacks SharcGetCachedRadiance") + common.setText(commonText.substring(0, functionBrace + 1) + + "\n radiance = float3(0.0f, 0.0f, 0.0f);" + + commonText.substring(functionBrace + 1), "UTF-8") + def sharcIncludes = ["-I", new File(srcDir.get().asFile, "pipelines/world").absolutePath, + "-I", new File(srcDir.get().asFile, "sharc").absolutePath, + "-I", compilerInclude.absolutePath] + def sharcCapabilities = ["-capability", "spvInt64Atomics"] + File indirectSource = new File(srcDir.get().asFile, "pipelines/world/indirect.rgen.slang") + def variantArgs = ["-DCAUSTICA_SHARC_VARIANT=1", "-DSHARC_ENABLE_SH_ENCODING=1"] + sharcCapabilities + sharcIncludes + compileOneSlang(indirectSource, + new File(scratchDir, "pipelines/world/indirect_sharc_query.rgen.spv"), + variantArgs + ["-DCAUSTICA_SHARC_QUERY=1"]) + compileOneSlang(indirectSource, + new File(scratchDir, "pipelines/world/indirect_sharc_ser_query.rgen.spv"), + variantArgs + ["-DCAUSTICA_SHARC_QUERY=1", "-DCAUSTICA_ENABLE_EXT_SER", + "-capability", "spvShaderInvocationReorderEXT"]) + compileOneSlang(indirectSource, + new File(scratchDir, "pipelines/world/indirect_sharc_update.rgen.spv"), + variantArgs + ["-DCAUSTICA_SHARC_UPDATE=1"]) + compileOneSlang(indirectSource, + new File(scratchDir, "pipelines/world/indirect_sharc_ser_update.rgen.spv"), + variantArgs + ["-DCAUSTICA_SHARC_UPDATE=1", "-DCAUSTICA_ENABLE_EXT_SER", + "-capability", "spvShaderInvocationReorderEXT"]) + compileOneSlang(new File(srcDir.get().asFile, "sharc/sharc_resolve.comp.slang"), + new File(scratchDir, "sharc/sharc_resolve.comp.spv"), + ["-DSHARC_ENABLE_SH_ENCODING=1"] + sharcCapabilities + sharcIncludes) + } if (outDirFile.exists() && !outDirFile.deleteDir()) { throw new GradleException("failed to clear generated shaders under ${outDirFile}") } @@ -185,8 +384,13 @@ def compileShaders = tasks.register("compileShaders", CompileShaders) { description = "Compiles shaders/** shader sources to SPIR-V and validates them." srcDir = file("shaders") outDir = layout.buildDirectory.dir("generated/shaders/caustica/shaders") - spirvVal = resolveVulkanTool("spirv-val") - slangc = resolveVulkanTool("slangc") + spirvVal = spirvValTool + spirvValContentHash = toolContentFingerprint(spirvValTool) + slangc = slangcTool + slangcContentHash = toolContentFingerprint(slangcTool) + sharcSdk = sharcSdkRoot?.absolutePath ?: "" + sharcSdkContentHash = sharcSdkContentFingerprint + dependsOn(validateShaderToolchain) } def generateShaderRecords = tasks.register("generateShaderRecords", GenerateShaderRecords) { @@ -194,9 +398,12 @@ def generateShaderRecords = tasks.register("generateShaderRecords", GenerateShad description = "Generates typed Java shader records and serializers from Slang reflection." shaderRoot = file("shaders") probeSource = file("shaders/layout/layout_probe.slang") - slangc = resolveVulkanTool("slangc") - spirvVal = resolveVulkanTool("spirv-val") + slangc = slangcTool + slangcContentHash = toolContentFingerprint(slangcTool) + spirvVal = spirvValTool + spirvValContentHash = toolContentFingerprint(spirvValTool) outDir = layout.buildDirectory.dir("generated/sources/shaderRecords") + dependsOn(validateShaderToolchain) } def rtBindingsGenRoot = layout.buildDirectory.dir("generated/sources/rtBindings") @@ -204,8 +411,10 @@ def generateRtBindings = tasks.register("generateRtBindings", GenerateRtBindings group = "build" description = "Generates Java descriptor bindings from Slang reflection." shaderRoot = file("shaders") - slangc = resolveVulkanTool("slangc") + slangc = slangcTool + slangcContentHash = toolContentFingerprint(slangcTool) outDir = rtBindingsGenRoot + dependsOn(validateShaderToolchain) } tasks.named("test", Test) { @@ -219,6 +428,49 @@ sourceSets.main.java.srcDir(files(rtBindingsGenRoot).builtBy(generateRtBindings) // same /caustica/shaders/ path the pipeline loaders read. builtBy wires the dependency. sourceSets.main.resources.srcDir(files(shaderGenRoot).builtBy(compileShaders)) +def sharcMetadataRoot = layout.buildDirectory.dir("generated/sharc-metadata") +def generateSharcMetadata = tasks.register("generateSharcMetadata") { + group = "build" + description = "Publishes SHaRC 1.8 capability metadata only for an explicitly accepted SDK build." + inputs.property("sharcSdk", sharcSdkRoot?.absolutePath ?: "") + inputs.property("sharcCommit", sharcCommit) + inputs.property("sharcVersion", sharcVersion) + inputs.property("sharcHashes", sharcHashes.toString()) + inputs.property("sharcSdkContentHash", sharcSdkContentFingerprint) + inputs.property("sharcLicenseRawHash", sharcLicenseRawHash) + outputs.dir(sharcMetadataRoot) + doLast { + def root = sharcMetadataRoot.get().asFile + delete(root) + if (sharcSdkRoot == null) return + def metadata = new File(root, "caustica/sharc.properties") + metadata.parentFile.mkdirs() + metadata.setText("""version=${sharcVersion} +commit=${sharcCommit} +sharcCommonSha256=${sharcHashes['include/SharcCommon.h']} +sharcTypesSha256=${sharcHashes['include/SharcTypes.h']} +hashGridCommonSha256=${sharcHashes['include/HashGridCommon.h']} +hashGridTypesSha256=${sharcHashes['include/HashGridTypes.h']} +licenseCanonicalTextSha256=${sharcHashes['License.md']} +licensePackagedSha256=${sharcLicenseRawHash} +artifacts=true +directionalSh=true +queryVariants=ordinary,ext-ser +updateVariants=ordinary,ext-ser +resolveVariants=directional-sh +shaderBufferInt64Atomics=true +shaderFloat16=true +storageBuffer16BitAccess=true +""", "UTF-8") + copy { + from(new File(sharcSdkRoot, "License.md")) + into(new File(root, "META-INF/licenses/nvidia")) + rename { "NVIDIA-SHARC-SDK.txt" } + } + } +} +sourceSets.main.resources.srcDir(files(sharcMetadataRoot).builtBy(generateSharcMetadata)) + def ngxNativeGenRoot = layout.buildDirectory.dir("generated/ngx-natives") def requestedTaskNames = gradle.startParameter.taskNames.collect { it.toLowerCase(java.util.Locale.ROOT) } def isRunClientInvocation = requestedTaskNames.any { it == "runclient" || it.endsWith(":runclient") } @@ -300,10 +552,179 @@ if (requestedNgxPlatformsRaw.size() == 1 && requestedNgxPlatformsRaw[0].equalsIg def dlssSdkEnv = providers.environmentVariable("DLSS_SDK") def dlssSdkRootProvider = dlssSdkEnv.map { file(it) } +def expectedDlssSdkCommit = "a291cc7d2cc642a51566f3dfd5376f635cd1b284" +def expectedDlssSdkHeaderHashes = [ + "include/nvsdk_ngx_defs.h": "ea23f33497cd274860d1c25a97644fce807dcb0037c594547203343103fad03e", + "include/nvsdk_ngx_defs_dlssd.h": "d2fde340db2189c89bce093bc1edd7b3579df48decac329218a98a9c5fd46018", + "include/nvsdk_ngx_defs_dlssg.h": "5e76e5cf0397f0b093887d0392b427a6b3e3f722cec5f5a4795357edeb6de4ba", + "include/nvsdk_ngx_defs_vk.h": "0a24d0861ace7d6b9362a67b7f08bea1b33ea123c5ce730b19133de8a891d031" +] +def expectedDlssSdkPayloadHashes = [ + "windows-x64": [ + staticLibrary: ["lib/Windows_x86_64/x64/nvsdk_ngx_d.lib", + "31e82b4ec3242ec6e5b42c73e1f5f5e260338a6ee213bd64444f6c2fa364aa84"] + ], + "linux-x64": [ + staticLibrary: ["lib/Linux_x86_64/libnvsdk_ngx.a", + "dae18dce6fdbab45f7304b14901c93c41073d7cc31d555d6cb1fa145958937a6"] + ] +] +def expectedDlssSdkRuntimeHashes = [ + "windows-x64": [ + "rel": [ + "nvngx_dlssd.dll": "f4e97624f70fbb769acb11ebd751b512ecc9463d4bd6aef04896d3956e6084a0", + "nvngx_dlssg.dll": "135eaf0733c1e37381a8c28abcf7a862404a54132b81787c04e35d09efc5e36f" + ], + "dev": [ + "nvngx_dlssd.dll": "fd83687c98d00754ae8e26b5daa7dabfed04557113c69c9eddfe4b6f3acaf426", + "nvngx_dlssg.dll": "0d33b5de65d60a943c33bd096d574297302b214a1c5baa6bcb74bc1150a608de" + ] + ], + "linux-x64": [ + "rel": [ + "libnvidia-ngx-dlssd.so": "efd465933bf9a40b65f3c6d61aa079f4a4b188004e9c2b432e3375783b0029f3", + "libnvidia-ngx-dlssg.so": "676cfeace1bf675a281cf234df619f24cef16a1259f36119ebdf01138468a057" + ], + "dev": [ + "libnvidia-ngx-dlssd.so": "910997cf8e1b2a1c2e9a5474e9881e7fff0ffcf25284523ca853ac47b0f9b14b", + "libnvidia-ngx-dlssg.so": "1275c6409a508027438b938487042e67fd4a116c397232ca19984372b81163bb" + ] + ] +] + +def validateDlssSdk = tasks.register("validateDlssSdk") { + group = "verification" + description = "Validates the pinned DLSS headers and native payloads used by the selected NGX bundle." + inputs.property("dlssSdk", dlssSdkEnv.orElse("")) + inputs.property("dlssCommit", expectedDlssSdkCommit) + inputs.property("dlssHeaderHashes", expectedDlssSdkHeaderHashes.toString()) + inputs.property("dlssPayloadHashes", expectedDlssSdkPayloadHashes.toString()) + inputs.property("dlssRuntimeHashes", expectedDlssSdkRuntimeHashes.toString()) + inputs.property("ngxVendorConfig", ngxVendorConfig) + inputs.property("ngxPlatforms", selectedNgxNativePlatforms*.name.join(",")) + outputs.upToDateWhen { false } + doLast { + File sdk = dlssSdkRootProvider.orNull + if (sdk == null || !sdk.isDirectory()) { + throw new GradleException("DLSS_SDK must point to the pinned SDK checkout ${expectedDlssSdkCommit}.") + } + def runGit = { List arguments -> + def command = ["git", "-C", sdk.absolutePath] + arguments + def process = new ProcessBuilder(command).redirectErrorStream(true).start() + def output = process.inputStream.getText("UTF-8").trim() + [status: process.waitFor(), output: output] + } + def head = runGit(["rev-parse", "HEAD"]) + if (head.status != 0 || !head.output.equalsIgnoreCase(expectedDlssSdkCommit)) { + throw new GradleException("DLSS_SDK HEAD ${head.output} is not pinned to ${expectedDlssSdkCommit}") + } + def status = runGit(["status", "--porcelain", "--untracked-files=all"]) + if (status.status != 0 || !status.output.isEmpty()) { + throw new GradleException(status.status == 0 + ? "DLSS_SDK checkout is dirty: ${status.output}" + : "Could not read DLSS_SDK checkout status: ${status.output}") + } + def requireHash = { String label, File input, String expected, boolean textInput -> + if (!input.isFile()) throw new GradleException("Missing pinned ${label}: ${input}") + def actual = textInput ? sha256TextFile(input) : sha256File(input) + if (!actual.equalsIgnoreCase(expected)) { + throw new GradleException("DLSS_SDK hash mismatch for ${label}: ${actual} != ${expected}") + } + } + expectedDlssSdkHeaderHashes.each { relative, expected -> + requireHash(relative, new File(sdk, relative), expected, true) + } + selectedNgxNativePlatforms.each { platform -> + def staticLibrary = expectedDlssSdkPayloadHashes[platform.name].staticLibrary + requireHash("${platform.name} static library", new File(sdk, staticLibrary[0]), staticLibrary[1], false) + File vendorRoot = platform.vendorRoot(sdk, ngxVendorConfig) + expectedDlssSdkRuntimeHashes[platform.name][ngxVendorConfig].each { baseName, expected -> + File runtime = new File(vendorRoot, baseName) + if (!runtime.isFile() && platform.name == "linux-x64") { + def candidates = fileTree(vendorRoot) { include "${baseName}.*" }.files.sort { it.name } + if (candidates.size() != 1) { + throw new GradleException("Expected one ${baseName} runtime in ${vendorRoot}, found ${candidates*.name}") + } + runtime = candidates[0] + } + requireHash("${platform.name} ${runtime.name}", runtime, expected, false) + } + } + } +} + +def ngxShimBuildType = ngxShimConfig == "debug" ? "Debug" : "Release" +def ngxShimBuildRoot = file("build/cmake/ngx_shim/${ngxShimConfig}") +def ngxShimBinaryName = currentNgxPlatform == "windows-x64" ? "ngxshim.dll" : "libngxshim.so" +def ngxShimBinary = new File(ngxShimOutDir, ngxShimBinaryName) +abstract class BuildNgxShim extends DefaultTask { + @InputDirectory abstract DirectoryProperty getSourceDir() + @Input abstract Property getBuildRoot() + @Input abstract Property getBuildType() + @Input abstract Property getCmakeExecutable() + @Input abstract Property getDlssSdk() + @Input abstract Property getVulkanSdk() + @Input abstract ListProperty getConfigureArgs() + @OutputFile abstract RegularFileProperty getOutputFile() + + @Inject abstract ExecOperations getExecOps() + + @TaskAction + void build() { + String dlssSdkPath = dlssSdk.get() + String vulkanSdkPath = vulkanSdk.get() + if (!dlssSdkPath || !new File(dlssSdkPath).isDirectory()) { + throw new GradleException("DLSS_SDK must point to the pinned SDK checkout before building the NGX shim.") + } + if (!vulkanSdkPath || !new File(vulkanSdkPath).isDirectory()) { + throw new GradleException("VULKAN_SDK must point to the Vulkan SDK before building the NGX shim.") + } + File buildRoot = new File(buildRoot.get()) + if (buildRoot.exists() && !buildRoot.deleteDir()) { + throw new GradleException("Could not clear the NGX CMake build directory: ${buildRoot}") + } + String cmake = cmakeExecutable.get() + def configure = ["-S", sourceDir.get().asFile.absolutePath, "-B", buildRoot.absolutePath] + configure.addAll(configureArgs.get()) + getExecOps().exec { + commandLine([cmake] + configure) + environment "DLSS_SDK", dlssSdkPath + environment "VULKAN_SDK", vulkanSdkPath + } + getExecOps().exec { + commandLine([cmake, "--build", buildRoot.absolutePath, "--config", buildType.get(), "--target", "ngxshim", "--parallel"]) + environment "DLSS_SDK", dlssSdkPath + environment "VULKAN_SDK", vulkanSdkPath + } + File output = outputFile.get().asFile + if (!output.isFile()) { + throw new GradleException("NGX shim build succeeded but did not produce ${output}") + } + } +} + +def cmakeForNgxShim = locateExecutable((findProperty("releaseCmake") ?: "cmake").toString()) +def buildNgxShim = tasks.register("buildNgxShim", BuildNgxShim) { + group = "build" + description = "Rebuilds the NGX shim from the current native source before packaging it." + dependsOn(validateDlssSdk) + sourceDir.set(layout.projectDirectory.dir("native/ngx_shim")) + buildRoot.set(ngxShimBuildRoot.absolutePath) + buildType.set(ngxShimBuildType) + cmakeExecutable.set(cmakeForNgxShim?.absolutePath ?: "cmake") + dlssSdk.set(dlssSdkRootProvider.map { it.absolutePath }.orElse("")) + vulkanSdk.set(providers.environmentVariable("VULKAN_SDK").orElse("")) + configureArgs.set(org.gradle.internal.os.OperatingSystem.current().windows + ? ["-G", "Visual Studio 17 2022", "-A", "x64"] + : ["-DCMAKE_BUILD_TYPE=${ngxShimBuildType}"]) + outputFile.set(ngxShimBinary) + inputs.files(fileTree("native/ngx_shim")) +} def bundleNgxNatives = tasks.register("bundleNgxNatives") { group = "build" description = "Bundles NGX shim and ${ngxVendorConfig} DLSS native libraries into the mod resources." + dependsOn(buildNgxShim) inputs.property "ngxShimConfig", ngxShimConfig inputs.property "ngxVendorConfig", ngxVendorConfig 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 e609d048..2a83baf4 100644 --- a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy +++ b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateRtBindings.groovy @@ -22,6 +22,7 @@ abstract class GenerateRtBindings extends DefaultTask { abstract DirectoryProperty getShaderRoot() @Input abstract Property getSlangc() + @Input abstract Property getSlangcContentHash() @OutputDirectory abstract DirectoryProperty getOutDir() @Inject abstract ExecOperations getExecOps() @@ -31,12 +32,16 @@ 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", + G_RESPONSIVITY: "gResponsivity", G_PARTICLE_MASK: "gParticleMask", + G_SKY_CLASSIFICATION: "gSkyClassification", CELESTIALS: "celestialsAtlas", SKY_VIEW: "skyViewLut", TRANSMITTANCE: "transmittanceLut", + END_SKY: "endSkyTexture", ENTITY_ALBEDO: "entityAlbedoTex", MATERIAL_SURFACE0: "materialSurface0Tex", MATERIAL_NORMAL_AO: "materialNormalAoTex", MATERIAL_SURFACE1: "materialSurface1Tex"]], [prefix: "DISPLAY", source: "pipelines/display/main.comp.slang", resources: [ OUTPUT: "outputImage", RT_IMAGE: "rtImage", EXPOSURE: "exposureImage", HDR_OUTPUT: "hdrImage", - SDR_TONE_LUT: "toneLut", HDR_TONE_LUT: "hdrToneLut", LOOK_LUT: "lookLut", BLOOM: "bloomImage"]], + SDR_TONE_LUT: "toneLut", HDR_TONE_LUT: "hdrToneLut", LOOK_LUT: "lookLut", BLOOM: "bloomImage", + SKY_CLASSIFICATION: "skyClassificationImage", END_SKY: "endSkyTexture", CELESTIALS: "celestialsAtlas"]], [prefix: "DEBUG_PRESENT", source: "pipelines/debug_present/main.comp.slang", resources: [ OUTPUT: "outputImage", G_NORMAL: "gNormal", G_ALBEDO: "gAlbedo", G_DEPTH: "gDepth", G_MOTION: "gMotion", G_SPEC_ALBEDO: "gSpecAlbedo", G_SPEC_MOTION: "gSpecMotion", diff --git a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy index ddeecdc1..74cb18cc 100644 --- a/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy +++ b/buildSrc/src/main/groovy/dev/comfyfluffy/caustica/build/GenerateShaderRecords.groovy @@ -27,7 +27,9 @@ abstract class GenerateShaderRecords extends DefaultTask { abstract RegularFileProperty getProbeSource() @Input abstract Property getSlangc() + @Input abstract Property getSlangcContentHash() @Input abstract Property getSpirvVal() + @Input abstract Property getSpirvValContentHash() @OutputDirectory abstract DirectoryProperty getOutDir() @Inject abstract ExecOperations getExecOps() @@ -297,6 +299,17 @@ abstract class GenerateShaderRecords extends DefaultTask { Map exposureStateType = exposureStateProbeArray.type.elementType as Map int exposureStateByteSize = exposureStateProbeArray.type.uniformStride as int + def structuredProbe = { String probeName, String structName -> + def probeParameter = reflection.parameters.find { it.name == probeName } + def array = probeParameter?.type?.resultType?.fields?.find { it.name == "values" } + if (array?.type?.kind != "array" || array.type.elementType?.name != structName) { + throw new GradleException("unexpected ${structName} reflection probe shape") + } + [type: array.type.elementType as Map, byteSize: array.type.uniformStride as int] + } + def sharcPush = structuredProbe("sharcPushLayoutProbe", "SharcPushConstants") + def sharcFrame = structuredProbe("sharcFrameLayoutProbe", "SharcFrame") + def generatedRoot = outDir.get().asFile if (generatedRoot.exists() && !generatedRoot.deleteDir()) { throw new GradleException("failed to clear generated shader record sources under ${generatedRoot}") @@ -310,6 +323,11 @@ abstract class GenerateShaderRecords extends DefaultTask { new File(packageDir, "ExposureStateData.java").setText( generateJava(exposureStateType, exposureStateByteSize, "ExposureStateData", true), "UTF-8") + new File(packageDir, "SharcPushConstantsData.java").setText( + generateJava(sharcPush.type, sharcPush.byteSize, "SharcPushConstantsData"), "UTF-8") + new File(packageDir, "SharcFrameData.java").setText( + generateJava(sharcFrame.type, sharcFrame.byteSize, "SharcFrameData"), "UTF-8") + PUSH_CONSTANT_PROBES.each { probeName, structName, className -> Map type = extractPushConstantType(reflection, probeName, structName) int byteSize = extractPushConstantByteSize(reflection, probeName) diff --git a/docs/developer_guide.md b/docs/developer_guide.md index a28b092a..7b594187 100644 --- a/docs/developer_guide.md +++ b/docs/developer_guide.md @@ -3,9 +3,14 @@ ## Windows 1. Install the Vulkan SDK from . - The installer sets `VULKAN_SDK` automatically. -2. Download the DLSS SDK from . - Extract it, then set `DLSS_SDK` to the folder you extracted. + The installer sets `VULKAN_SDK` automatically. Caustica requires Slang + 2026.4 or newer for its raw-device-pointer layout declarations; on Windows, + Vulkan SDK 1.4.350.0 is a known-good baseline. `VULKAN_SDK` must point to + that compatible SDK so `slangc` and `spirv-val` come from the same install. +2. Clone the DLSS SDK from and check out commit + `a291cc7d2cc642a51566f3dfd5376f635cd1b284`. Set `DLSS_SDK` to that clean Git + checkout. Gradle validates the commit, clean status, headers, static library, + and runtime libraries before building or packaging the NGX shim. To set it permanently for your Windows user account, run PowerShell with: @@ -20,14 +25,20 @@ $env:DLSS_SDK = "C:\path\to\dlss-sdk" ``` -3. Configure and build the native shim: +3. To include NVIDIA SHaRC, use a clean SHaRC 1.8 checkout at commit + `e19ccacd511f42a3df6f850052d508c13c9e9737`, pass its path as + `-PsharcSdk=C:\path\to\SHARC-1.8.0.0`, and pass + `-PacceptSharcLicense=true` after reviewing its license. Without that + explicit SDK and acceptance, Gradle builds the ordinary RT variants only. + +4. Configure and build the native shim directly when working on its C++ code: ```powershell cmake -S native/ngx_shim -B build/cmake/ngx_shim/release -DCMAKE_BUILD_TYPE=Release cmake --build build/cmake/ngx_shim/release --config Release ``` -4. Run the client: +5. Run the client: ```powershell $env:JAVA_TOOL_OPTIONS = "-Xmx8G -XX:+UseCompactObjectHeaders -XX:+AlwaysPreTouch -XX:+UseStringDeduplication -XX:+UseZGC" @@ -36,15 +47,18 @@ $env:JAVA_TOOL_OPTIONS = "-Xmx8G -XX:+UseCompactObjectHeaders -XX:+AlwaysPreTouc ## Linux -Set `DLSS_SDK` and `VULKAN_SDK` before configuring CMake: +Use the same clean pinned DLSS checkout and Vulkan SDK described above, then +set `DLSS_SDK` and `VULKAN_SDK` before configuring CMake: ```bash export DLSS_SDK=/path/to/dlss-sdk export VULKAN_SDK=/path/to/vulkan-sdk ``` -`DLSS_SDK` must contain the NGX headers and static library. `VULKAN_SDK` must -contain Vulkan headers. +Gradle validates the exact DLSS commit, clean status, headers, selected static +library, and selected runtime payloads. `VULKAN_SDK` must provide Slang 2026.4 +or newer plus `spirv-val`. To include SHaRC, also pass the pinned checkout as +`-PsharcSdk=/path/to/SHARC-1.8.0.0 -PacceptSharcLicense=true`. Then configure and build the native shim: diff --git a/native/ngx_shim/ngx_shim.cpp b/native/ngx_shim/ngx_shim.cpp index 0ce33f1c..6e121ddf 100644 --- a/native/ngx_shim/ngx_shim.cpp +++ b/native/ngx_shim/ngx_shim.cpp @@ -44,6 +44,7 @@ static const char* kProjectId = "b6f1e9c2-7a44-4d1e-9b3a-1f2c3d4e5a6b"; static NVSDK_NGX_Parameter* g_capabilityParams = nullptr; static VkDevice g_device = VK_NULL_HANDLE; +static bool g_initialized = false; static int g_lastResult = 0; // Logging sink wired into NVSDK_NGX_FeatureCommonInfo so the (closed) NGX core/SDK pipes its own @@ -87,6 +88,11 @@ extern "C" { #define NGX_SHIM_EXPORT __attribute__((visibility("default"))) #endif +// Increment whenever the flat C ABI changes incompatibly. Java checks this before using any export. +NGX_SHIM_EXPORT int ngxshim_abi_version() { + return 1; +} + // Last NVSDK_NGX_Result observed, for diagnostics from the Java side. NGX_SHIM_EXPORT int ngxshim_last_result() { return g_lastResult; @@ -140,6 +146,8 @@ NGX_SHIM_EXPORT int ngxshim_init(unsigned long long appId, const wchar_t* dataPa appId, (void*) dataPath, (void*) instance, (void*) physicalDevice, (void*) device, getInstanceProcAddr, getDeviceProcAddr, (void*) featureDllPath); g_device = device; + g_initialized = false; + g_capabilityParams = nullptr; NVSDK_NGX_FeatureCommonInfo info; std::memset(&info, 0, sizeof(info)); @@ -166,13 +174,24 @@ NGX_SHIM_EXPORT int ngxshim_init(unsigned long long appId, const wchar_t* dataPa NGX_LOG("init: Init_with_ProjectID r=0x%08x", (unsigned) r); if (NVSDK_NGX_FAILED(r)) { NGX_LOG("init: FAILED init, returning 0x%08x", (unsigned) r); + g_device = VK_NULL_HANDLE; return (int) r; } + g_initialized = true; NGX_LOG("init: calling NVSDK_NGX_VULKAN_GetCapabilityParameters"); r = NVSDK_NGX_VULKAN_GetCapabilityParameters(&g_capabilityParams); g_lastResult = (int) r; NGX_LOG("init: GetCapabilityParameters r=0x%08x g_capabilityParams=%p", (unsigned) r, (void*) g_capabilityParams); + if (NVSDK_NGX_FAILED(r)) { + if (g_capabilityParams) { + NVSDK_NGX_VULKAN_DestroyParameters(g_capabilityParams); + g_capabilityParams = nullptr; + } + NVSDK_NGX_VULKAN_Shutdown1(g_device); + g_initialized = false; + g_device = VK_NULL_HANDLE; + } return (int) r; } @@ -367,15 +386,15 @@ NGX_SHIM_EXPORT void* ngxshim_create_dlssd(VkCommandBuffer cmd, } NVSDK_NGX_Result r = NVSDK_NGX_Result_Success; - if (renderPreset != 0) { - unsigned int preset = (unsigned int) renderPreset; - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_DLAA, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Quality, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Balanced, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Performance, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_UltraPerformance, preset); - NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_UltraQuality, preset); - } + // The capability block is shared across feature instances, so write every hint on every create; + // zero restores the DLL's default when the caller requests the Default preset. + unsigned int preset = (unsigned int) renderPreset; + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_DLAA, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Quality, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Balanced, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_Performance, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_UltraPerformance, preset); + NVSDK_NGX_Parameter_SetUI(params, NVSDK_NGX_Parameter_RayReconstruction_Hint_Render_Preset_UltraQuality, preset); NVSDK_NGX_DLSSD_Create_Params createParams; std::memset(&createParams, 0, sizeof(createParams)); @@ -403,6 +422,13 @@ NGX_SHIM_EXPORT void* ngxshim_create_dlssd(VkCommandBuffer cmd, } DlssFeature* feature = (DlssFeature*) std::malloc(sizeof(DlssFeature)); + if (!feature) { + NGX_LOG("create_dlssd: wrapper allocation failed; releasing handle=%p", (void*) handle); + if (handle) { + NVSDK_NGX_VULKAN_ReleaseFeature(handle); + } + return nullptr; + } feature->handle = handle; feature->params = params; feature->ownsParams = false; // shared capability block, freed at shutdown @@ -410,21 +436,22 @@ NGX_SHIM_EXPORT void* ngxshim_create_dlssd(VkCommandBuffer cmd, return feature; } -// Records a DLSS Ray Reconstruction evaluation. Guide buffers: HDR color, linear depth, motion +// Records a DLSS Ray Reconstruction evaluation. Guide buffers: HDR color, hardware depth, motion // vectors, diffuse albedo, specular albedo, world-space normals (roughness packed in normals.w), -// and reflection motion vectors. Specular hit distance remains in the ABI/resources for debug and -// easy A/B, but is disabled by leaving pInSpecularHitDistance null. +// and reflection motion vectors. Particle classification and responsivity are optional render-resolution +// guides consumed by DLSSD to avoid reusing history for dynamic pixels. // Output is the only read-write (storage) resource. All non-output images use the color aspect; -// depth is a linear value carried in a color image, not a depth-aspect attachment. -NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd(VkCommandBuffer cmd, void* feature, +// depth is a non-linear, reversed-Z hardware value carried in a color image, not a depth-aspect attachment. +NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd_v2(VkCommandBuffer cmd, void* feature, VkImageView colorView, VkImage colorImage, int colorFormat, VkImageView depthView, VkImage depthImage, int depthFormat, VkImageView mvView, VkImage mvImage, int mvFormat, VkImageView diffuseAlbedoView, VkImage diffuseAlbedoImage, int diffuseAlbedoFormat, VkImageView specularAlbedoView, VkImage specularAlbedoImage, int specularAlbedoFormat, - VkImageView normalsView, VkImage normalsImage, int normalsFormat, - VkImageView specularMotionView, VkImage specularMotionImage, int specularMotionFormat, - VkImageView specularHitDistanceView, VkImage specularHitDistanceImage, int specularHitDistanceFormat, + VkImageView normalsView, VkImage normalsImage, int normalsFormat, + VkImageView specularMotionView, VkImage specularMotionImage, int specularMotionFormat, + VkImageView particleMaskView, VkImage particleMaskImage, int particleMaskFormat, + VkImageView responsivityMaskView, VkImage responsivityMaskImage, int responsivityMaskFormat, VkImageView outputView, VkImage outputImage, int outputFormat, unsigned int renderWidth, unsigned int renderHeight, unsigned int displayWidth, unsigned int displayHeight, @@ -439,12 +466,10 @@ NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd(VkCommandBuffer cmd, void* feature, NGX_LOG("evaluate_dlssd: null feature, returning -1"); return -1; } - NGX_LOG("evaluate_dlssd: handle=%p params=%p color=%p depth=%p mv=%p diffuse=%p specular=%p normals=%p specMotion=%p output=%p", + NGX_LOG("evaluate_dlssd: handle=%p params=%p color=%p depth=%p mv=%p diffuse=%p specular=%p normals=%p specMotion=%p particles=%p responsivity=%p output=%p", (void*) f->handle, (void*) f->params, (void*) colorView, (void*) depthView, (void*) mvView, - (void*) diffuseAlbedoView, (void*) specularAlbedoView, (void*) normalsView, (void*) specularMotionView, (void*) outputView); - (void) specularHitDistanceView; - (void) specularHitDistanceImage; - (void) specularHitDistanceFormat; + (void*) diffuseAlbedoView, (void*) specularAlbedoView, (void*) normalsView, (void*) specularMotionView, + (void*) particleMaskView, (void*) responsivityMaskView, (void*) outputView); NVSDK_NGX_Resource_VK color = makeImageResource(colorView, colorImage, colorFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); NVSDK_NGX_Resource_VK depth = makeImageResource(depthView, depthImage, depthFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); @@ -453,6 +478,8 @@ NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd(VkCommandBuffer cmd, void* feature, NVSDK_NGX_Resource_VK specularAlbedo = makeImageResource(specularAlbedoView, specularAlbedoImage, specularAlbedoFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); NVSDK_NGX_Resource_VK normals = makeImageResource(normalsView, normalsImage, normalsFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); NVSDK_NGX_Resource_VK specularMotion = makeImageResource(specularMotionView, specularMotionImage, specularMotionFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); + NVSDK_NGX_Resource_VK particleMask = makeImageResource(particleMaskView, particleMaskImage, particleMaskFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); + NVSDK_NGX_Resource_VK responsivityMask = makeImageResource(responsivityMaskView, responsivityMaskImage, responsivityMaskFormat, renderWidth, renderHeight, VK_IMAGE_ASPECT_COLOR_BIT, false); NVSDK_NGX_Resource_VK output = makeImageResource(outputView, outputImage, outputFormat, displayWidth, displayHeight, VK_IMAGE_ASPECT_COLOR_BIT, true); NVSDK_NGX_VK_DLSSD_Eval_Params eval; @@ -465,6 +492,10 @@ NGX_SHIM_EXPORT int ngxshim_evaluate_dlssd(VkCommandBuffer cmd, void* feature, eval.pInSpecularAlbedo = &specularAlbedo; eval.pInNormals = &normals; eval.pInMotionVectorsReflections = &specularMotion; + eval.pInIsParticleMask = &particleMask; + eval.pInResponsivityMask = &responsivityMask; + eval.InResponsivityMaskSubrectBase.X = 0; + eval.InResponsivityMaskSubrectBase.Y = 0; eval.pInSpecularHitDistance = nullptr; // HW depth needs the projection so DLSS can linearize it (jitter-free; NGX left-multiply layout). eval.pInWorldToViewMatrix = worldToViewMatrix; @@ -638,15 +669,29 @@ NGX_SHIM_EXPORT void ngxshim_release(void* feature) { NGX_LOG("release: exit"); } -NGX_SHIM_EXPORT void ngxshim_shutdown(VkDevice device) { +NGX_SHIM_EXPORT int ngxshim_shutdown(VkDevice device) { NGX_LOG("shutdown: enter device=%p g_device=%p g_capabilityParams=%p", (void*) device, (void*) g_device, (void*) g_capabilityParams); if (g_capabilityParams) { - NVSDK_NGX_VULKAN_DestroyParameters(g_capabilityParams); + NVSDK_NGX_Result r = NVSDK_NGX_VULKAN_DestroyParameters(g_capabilityParams); + g_lastResult = (int) r; + if (NVSDK_NGX_FAILED(r)) { + NGX_LOG("shutdown: DestroyParameters failed r=0x%08x", (unsigned) r); + return (int) r; + } g_capabilityParams = nullptr; } - NVSDK_NGX_VULKAN_Shutdown1(device ? device : g_device); + if (g_initialized) { + NVSDK_NGX_Result r = NVSDK_NGX_VULKAN_Shutdown1(device ? device : g_device); + g_lastResult = (int) r; + if (NVSDK_NGX_FAILED(r)) { + NGX_LOG("shutdown: Shutdown1 failed r=0x%08x", (unsigned) r); + return (int) r; + } + } + g_initialized = false; g_device = VK_NULL_HANDLE; NGX_LOG("shutdown: exit"); + return g_lastResult; } } // extern "C" diff --git a/shaders/common/display_common.slang b/shaders/common/display_common.slang index 9a2d81c8..42f8a9f9 100644 --- a/shaders/common/display_common.slang +++ b/shaders/common/display_common.slang @@ -97,6 +97,12 @@ public struct DisplayPush { public float hdrParam5; public float hdrParam6; public float hdrParam7; + public float4 skyColor; // captured vanilla sky/clear color in linear BT.709 + public float4x4 invViewProj; // unjittered camera-relative inverse projection + public uint skybox; // SKYBOX_NONE, SKYBOX_OVERWORLD, or SKYBOX_END + public uint skyFlags; // SKY_FLAG_END_FLASH when the vanilla End flash is active + public float4 skyParams; // reserved, flash intensity, flash X angle, flash Y angle + public float4 endFlashUv; // celestials-atlas UV rect for end_flash }; public struct BloomPush { diff --git a/shaders/layout/layout_probe.slang b/shaders/layout/layout_probe.slang index cecbe033..365ce002 100644 --- a/shaders/layout/layout_probe.slang +++ b/shaders/layout/layout_probe.slang @@ -3,6 +3,7 @@ // complete Std430DataLayout: field types, byte offsets, total size, matrix mode, and array counts. import world_common; import display_common; +import sharc_types; struct WorldPushLayoutProbe { WorldPush values[2]; // array stride is the complete, tail-padded WorldPush byte size @@ -16,9 +17,19 @@ struct ExposureStateLayoutProbe { ExposureState values[2]; // array stride is the complete, tail-padded ExposureState byte size }; +struct SharcPushLayoutProbe { + SharcPushConstants values[2]; +}; + +struct SharcFrameLayoutProbe { + SharcFrame values[2]; +}; + [[vk::binding(0, 0)]] StructuredBuffer worldPushLayoutProbe; [[vk::binding(1, 0)]] StructuredBuffer materialHeaderLayoutProbe; [[vk::binding(2, 0)]] StructuredBuffer exposureStateLayoutProbe; +[[vk::binding(3, 0)]] StructuredBuffer sharcPushLayoutProbe; +[[vk::binding(4, 0)]] StructuredBuffer sharcFrameLayoutProbe; [[vk::push_constant]] WorldPushConstants pushConstantsLayoutProbe; [shader("compute")] @@ -29,6 +40,9 @@ void main(uint3 id : SV_DispatchThreadID) { float sink = worldPushLayoutProbe[0].values[0].invViewProj[0][0] + materialHeaderLayoutProbe[0].values[0].params.x + exposureStateLayoutProbe[0].values[0].previous + + float(sharcPushLayoutProbe[0].values[0].sharcUpdateTileSize) + + float(sharcFrameLayoutProbe[0].values[0].capacity) + + worldPushLayoutProbe[0].values[0].mipMapBias + float(pushConstantsLayoutProbe.frameIndex); } } diff --git a/shaders/pipelines/display/bindings.slang b/shaders/pipelines/display/bindings.slang index 1aa138f6..e10a1ab1 100644 --- a/shaders/pipelines/display/bindings.slang +++ b/shaders/pipelines/display/bindings.slang @@ -6,3 +6,6 @@ [[vk::binding(5, 0)]] public Sampler3D hdrToneLut; [[vk::binding(6, 0)]] public Sampler3D lookLut; [[vk::binding(7, 0)]] public Sampler2D bloomImage; +[[vk::binding(8, 0)]] [format("r16f")] public RWTexture2D skyClassificationImage; +[[vk::binding(9, 0)]] public Sampler2D endSkyTexture; +[[vk::binding(10, 0)]] public Sampler2D celestialsAtlas; diff --git a/shaders/pipelines/display/main.comp.slang b/shaders/pipelines/display/main.comp.slang index 47588b0a..96b5ac09 100644 --- a/shaders/pipelines/display/main.comp.slang +++ b/shaders/pipelines/display/main.comp.slang @@ -50,6 +50,28 @@ float3 sampleBloom(int2 outputPixel, uint outputWidth, uint outputHeight) { return bloomImage.SampleLevel(uv, 0.0).rgb; } +float skyAtRenderPixel(int2 renderPixel) { + uint renderWidth, renderHeight; + skyClassificationImage.GetDimensions(renderWidth, renderHeight); + int2 maxPixel = int2(renderWidth, renderHeight) - int2(1, 1); + return clamp(skyClassificationImage[clamp(renderPixel, int2(0, 0), maxPixel)], 0.0, 1.0); +} + +// Upscale the primary-sky classification with the same bilinear footprint as the display pixel. The +// separate display classification marks only a visible primary sky miss; transmitted sky remains a surface feature. +float skyCoverage(int2 pixel, uint displayWidth, uint displayHeight) { + uint renderWidth, renderHeight; + skyClassificationImage.GetDimensions(renderWidth, renderHeight); + float2 sampleCoord = (float2(pixel) + 0.5) * float2(renderWidth, renderHeight) + / float2(displayWidth, displayHeight) - 0.5; + int2 base = int2(floor(sampleCoord)); + float2 fraction = fract(sampleCoord); + return skyAtRenderPixel(base) * (1.0 - fraction.x) * (1.0 - fraction.y) + + skyAtRenderPixel(base + int2(1, 0)) * fraction.x * (1.0 - fraction.y) + + skyAtRenderPixel(base + int2(0, 1)) * (1.0 - fraction.x) * fraction.y + + skyAtRenderPixel(base + int2(1, 1)) * fraction.x * fraction.y; +} + // Artistic gamma after the view/display transform. Apply the curve to display luminance and scale // RGB uniformly instead of exponentiating each channel: this lifts shadows/midtones without pulling // chromaticities toward white. The uniform scale is limited at the display boundary so saturated @@ -85,6 +107,66 @@ float3 srgbDecode(float3 code) { return float3(srgbDecodeChannel(code.r), srgbDecodeChannel(code.g), srgbDecodeChannel(code.b)); } +static const float3 BT709_TO_ACESCG_R = float3(0.61309743, 0.33952314, 0.04737945); +static const float3 BT709_TO_ACESCG_G = float3(0.07019372, 0.91635388, 0.01345240); +static const float3 BT709_TO_ACESCG_B = float3(0.02061559, 0.10956977, 0.86981463); + +float3 bt709ToAcesCg(float3 bt709) { + return float3( + dot(bt709, BT709_TO_ACESCG_R), + dot(bt709, BT709_TO_ACESCG_G), + dot(bt709, BT709_TO_ACESCG_B)); +} + +float2 endSkyUv(float3 dir) { + float3 a = abs(dir); + float t = 100.0 / max(max(a.x, a.y), a.z); + float3 p = dir * t; + if (a.x >= a.y && a.x >= a.z) { + if (dir.x > 0.0) return (float2(p.y, p.z) + 100.0) * (16.0 / 200.0); + return (float2(-p.y, p.z) + 100.0) * (16.0 / 200.0); + } + if (a.y >= a.z) { + if (dir.y > 0.0) return (float2(p.x, -p.z) + 100.0) * (16.0 / 200.0); + return (float2(p.x, p.z) + 100.0) * (16.0 / 200.0); + } + if (dir.z > 0.0) return (float2(p.x, p.y) + 100.0) * (16.0 / 200.0); + return (float2(p.x, -p.y) + 100.0) * (16.0 / 200.0); +} + +float3 nativeEndFlash(float3 dir) { + if ((pc.skyFlags & 1u) == 0u) return float3(0.0); + static const float PI = 3.14159265359; + float y = PI - pc.skyParams.w; + float x = -0.5 * PI - pc.skyParams.z; + float cy = cos(y), sy = sin(y), cx = cos(x), sx = sin(x); + float3 right = float3(cy, 0.0, -sy); + float3 up = float3(sy * sx, cx, cy * sx); + float3 forward = float3(sy * cx, -sx, cy * cx); + float3 center = up * 100.0; + float denom = dot(dir, up); + if (abs(denom) < 1.0e-5) return float3(0.0); + float hitDistance = dot(center, up) / denom; + if (hitDistance <= 0.0) return float3(0.0); + float3 q = dir * hitDistance - center; + float2 local = float2(dot(q, right), dot(q, forward)) / 60.0; + if (max(abs(local.x), abs(local.y)) > 1.0) return float3(0.0); + float2 uv = lerp(pc.endFlashUv.xy, pc.endFlashUv.zw, local * 0.5 + 0.5); + float4 texel = celestialsAtlas.SampleLevel(uv, 0.0); + float intensity = pc.skyParams.y; + return srgbDecode(texel.rgb) * texel.a * intensity * intensity; +} + +float3 nativeEndSky(float2 uv) { + float2 ndc = uv * 2.0 - 1.0; + float4 nearH = mul(pc.invViewProj, float4(ndc, 1.0, 1.0)); + float4 farH = mul(pc.invViewProj, float4(ndc, 0.0, 1.0)); + float3 dir = normalize(farH.xyz / max(farH.w, 1.0e-6) - nearH.xyz / max(nearH.w, 1.0e-6)); + float4 texel = endSkyTexture.SampleLevel(endSkyUv(dir), 0.0); + float3 endSkyColor = srgbDecode(texel.rgb) * (40.0 / 255.0); + return lerp(pc.skyColor.rgb, endSkyColor, clamp(texel.a, 0.0, 1.0)) + nativeEndFlash(dir); +} + float3 srgbEncode(float3 linearColor) { return float3( srgbEncodeChannel(linearColor.r), @@ -200,6 +282,34 @@ float3 tonemapHdr(float3 lookedAcesCg) { return displayGammaHdr(hdrToneLut.SampleLevel(lutTexCoord(uvw, pc.lutSize), 0.0).rgb); } +// Fixed screen-space rank order for final SDR quantization. The permutation is stable across frames so +// the dither cannot enter DLSS-RR history or shimmer with camera jitter; the half-step thresholds make +// each rank's rounding decision unbiased over the tile. +static const float SKY_DITHER_RANK_8X8[64] = { + 0.281250, 0.968750, 0.375000, 0.859375, 0.250000, 0.937500, 0.406250, 0.812500, + 0.593750, 0.187500, 0.734375, 0.046875, 0.578125, 0.218750, 0.703125, 0.015625, + 0.453125, 0.765625, 0.328125, 0.921875, 0.484375, 0.796875, 0.343750, 0.890625, + 0.640625, 0.093750, 0.546875, 0.156250, 0.671875, 0.078125, 0.515625, 0.125000, + 0.265625, 0.953125, 0.421875, 0.828125, 0.296875, 0.984375, 0.390625, 0.843750, + 0.562500, 0.234375, 0.687500, 0.000000, 0.609375, 0.203125, 0.718750, 0.031250, + 0.468750, 0.781250, 0.359375, 0.875000, 0.437500, 0.750000, 0.312500, 0.906250, + 0.656250, 0.062500, 0.500000, 0.140625, 0.625000, 0.109375, 0.531250, 0.171875 +}; + +float3 ditherSkySdr(float3 displayColor, int2 pixel) { + uint2 tilePixel = uint2(pixel) & uint2(7u, 7u); + float rank = SKY_DITHER_RANK_8X8[int(tilePixel.y * 8u + tilePixel.x)]; + float threshold = rank + 0.5 / 64.0; + float3 code = clamp(displayColor, 0.0, 1.0) * 255.0; + float3 lower = floor(code); + float3 fraction = code - lower; + float3 roundUp = float3( + fraction.r > threshold ? 1.0 : 0.0, + fraction.g > threshold ? 1.0 : 0.0, + fraction.b > threshold ? 1.0 : 0.0); + return (lower + roundUp) / 255.0; +} + [shader("compute")] [numthreads(16, 16, 1)] void main(uint3 dispatchId : SV_DispatchThreadID) { @@ -212,15 +322,29 @@ void main(uint3 dispatchId : SV_DispatchThreadID) { float4 rt = rtImage[pix]; float exposure = max(exposureImage[int2(0, 0)], 0.0); - float3 exposedAcesCg = max(rt.rgb * exposure, float3(0.0)); + float3 sceneLinearAcesCg = max(rt.rgb, float3(0.0)); + float coverage = 0.0; + if (pc.skybox == 2u || (pc.hdrEnabled == 0 && pc.skybox == 1u)) { + coverage = skyCoverage(pix, w, h); + } + if (pc.skybox == 2u) { + sceneLinearAcesCg = lerp(sceneLinearAcesCg, + bt709ToAcesCg(max(nativeEndSky((float2(pix) + 0.5) / float2(w, h)), float3(0.0))), + clamp(coverage, 0.0, 1.0)); + } + float3 exposedAcesCg = sceneLinearAcesCg * exposure; exposedAcesCg += sampleBloom(pix, w, h) * max(pc.bloomStrength, 0.0); float3 lookedAcesCg = exposedAcesCg; if (pc.sdrMode == 0 || (pc.hdrEnabled != 0 && pc.hdrMode == 0)) { lookedAcesCg = applyLook(exposedAcesCg); } - outputImage[pix] = pc.sdrMode == 0 - ? float4(tonemap(lookedAcesCg), 1.0) - : float4(localSdrToneMap(exposedAcesCg), 1.0); + float3 sdrColor = pc.sdrMode == 0 + ? tonemap(lookedAcesCg) + : localSdrToneMap(exposedAcesCg); + if (pc.hdrEnabled == 0 && pc.skybox == 1u && coverage >= 0.999) { + sdrColor = ditherSkySdr(sdrColor, pix); + } + outputImage[pix] = float4(sdrColor, 1.0); if (pc.hdrEnabled != 0) { hdrImage[pix] = pc.hdrMode == 0 diff --git a/shaders/pipelines/world/any_hit.rahit.slang b/shaders/pipelines/world/any_hit.rahit.slang index 67762c73..04ce6c0a 100644 --- a/shaders/pipelines/world/any_hit.rahit.slang +++ b/shaders/pipelines/world/any_hit.rahit.slang @@ -64,7 +64,7 @@ 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; + 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 7a9ca062..8199c836 100644 --- a/shaders/pipelines/world/bindings.slang +++ b/shaders/pipelines/world/bindings.slang @@ -3,7 +3,12 @@ import world_common; +#ifdef CAUSTICA_SHARC_VARIANT +import sharc_types; +[[vk::push_constant]] public SharcPushConstants pc; +#else [[vk::push_constant]] public WorldPushConstants pc; +#endif [[vk::binding(0, 0)]] public RaytracingAccelerationStructure topLevelAS; [[vk::binding(1, 0)]] [format("rgba16f")] public RWTexture2D outImage; @@ -14,11 +19,15 @@ import world_common; [[vk::binding(6, 0)]] [format("rg16f")] public RWTexture2D gMotion; [[vk::binding(7, 0)]] [format("rgba16f")] public RWTexture2D gSpecAlbedo; [[vk::binding(8, 0)]] [format("rg16f")] public RWTexture2D gSpecMotion; +[[vk::binding(9, 0)]] [format("r16f")] public RWTexture2D gResponsivity; +[[vk::binding(10, 0)]] [format("r8ui")] public RWTexture2D gParticleMask; +[[vk::binding(11, 0)]] [format("r16f")] public RWTexture2D gSkyClassification; [[vk::binding(2, 0)]] public Sampler2D blockAlbedoAtlas; -[[vk::binding(9, 0)]] public Sampler2D celestialsAtlas; -[[vk::binding(10, 0)]] public Sampler2D skyViewLut; -[[vk::binding(11, 0)]] public Sampler2D transmittanceLut; +[[vk::binding(12, 0)]] public Sampler2D celestialsAtlas; +[[vk::binding(13, 0)]] public Sampler2D skyViewLut; +[[vk::binding(14, 0)]] public Sampler2D transmittanceLut; +[[vk::binding(15, 0)]] public Sampler2D endSkyTexture; [[vk::binding(0, 1)]] public Sampler2D entityAlbedoTex[]; [[vk::binding(1, 1)]] public Sampler2D materialSurface0Tex[]; diff --git a/shaders/pipelines/world/closest_hit.rchit.slang b/shaders/pipelines/world/closest_hit.rchit.slang index bf21f885..cb46ea9c 100644 --- a/shaders/pipelines/world/closest_hit.rchit.slang +++ b/shaders/pipelines/world/closest_hit.rchit.slang @@ -46,6 +46,11 @@ float2 texSizePx(Sampler2D t) { return float2(w, h); } +float causticaMipMapBias() { + float bias = ConstPtr(pc.worldPushAddr)[0].mipMapBias; + return bias == bias && abs(bias) <= 16.0 ? bias : 0.0; +} + float rayConeHitWidth(uint rayCone) { float2 cone = unpackHalf2(rayCone); return max(cone.x + cone.y * max(RayTCurrent(), 0.0), RAY_CONE_MIN_WIDTH); @@ -64,12 +69,12 @@ float rayConeTextureLod(uint rayCone, float2 textureSizePx, float3 p0, float3 p1 max(edgeTexelsPerWorld(p1, p2, uv1, uv2, textureSizePx), edgeTexelsPerWorld(p2, p0, uv2, uv0, textureSizePx))); float footprint = max(rayConeHitWidth(rayCone) * texelsPerWorld, RAY_CONE_MIN_TEXEL_FOOTPRINT); - return clamp(log2(footprint), 0.0, RAY_CONE_MAX_LOD); + return clamp(log2(footprint) + causticaMipMapBias(), 0.0, RAY_CONE_MAX_LOD); } float rayConeUnitUvLod(uint rayCone, float2 textureSizePx) { float footprint = max(rayConeHitWidth(rayCone) * max(textureSizePx.x, textureSizePx.y), RAY_CONE_MIN_TEXEL_FOOTPRINT); - return clamp(log2(footprint), 0.0, RAY_CONE_MAX_LOD); + return clamp(log2(footprint) + causticaMipMapBias(), 0.0, RAY_CONE_MAX_LOD); } // LabPBR _n decode (shared): rotate the tangent-space normal into world space via a TBN built from the @@ -243,9 +248,9 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) packAlbedo(payload, bt709ToAcesCg(particleAlbedo709)); packNormal(payload, pn); payload.hitT = RayTCurrent(); - // Per-particle motion vector: interpolate the captured per-vertex displacement (uniform across - // the billboard's verts) with the same indices/barycentrics as the UV. dispAddr == 0 falls back - // to rigidDisp, which particles write as zero. + // Per-particle motion vector: interpolate captured per-vertex displacement with the same + // indices/barycentrics as the UV. dispAddr == 0 falls back to rigidDisp, which particles write + // as zero. if (g.dispAddr != 0) { ConstPtr pd = ConstPtr(g.dispAddr); payload.motionPrev = half3(pbary.x * pd[p0].xyz + pbary.y * pd[p1].xyz + pbary.z * pd[p2].xyz); @@ -255,6 +260,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) payload.f0 = half3(0.0h, 0.0h, 0.0h); payloadSetPacked(payload, MATERIAL_PARTICLE, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, EMISSION_SOURCE_NONE); + payload.flags |= PAYLOAD_SHARC_DYNAMIC; return; } // Dynamic entities: instances with this custom-index flag bit carry real captured ModelPart @@ -323,6 +329,7 @@ void main(inout Payload payload, in BuiltInTriangleIntersectionAttributes attr) emission, sss, header.params.z, header.params.w, materialEmissionSource(header, emission)); payloadSetDielectric(payload, material, entering); + payload.flags |= PAYLOAD_SHARC_DYNAMIC; return; } diff --git a/shaders/pipelines/world/guides.slang b/shaders/pipelines/world/guides.slang index 7f135197..a2a93141 100644 --- a/shaders/pipelines/world/guides.slang +++ b/shaders/pipelines/world/guides.slang @@ -20,6 +20,9 @@ public static float3 gv_albedo = float3(0.0, 0.0, 0.0); // without another material-ID image. public static bool gv_emissive = false; public static float gv_rough = 0.0; +public static bool gv_primarySky = false; +public static bool gv_primaryParticle = false; +public static bool gv_transmittedSky = false; public static float3 gv_hitCamRel = float3(0.0, 0.0, 0.0); // primary-surface hit position relative to the current camera public static bool gv_motionUseRefracted = false; // true when the MV tracks the refracted hit (its own reprojection delta) // World-space displacement of the motion-guide surface since the previous frame (0 for static @@ -70,6 +73,15 @@ public float2 projectPrevNdc(float3 worldPos, out bool valid) { return valid ? clip.xy / clip.w : float2(0.0, 0.0); } +// Project an infinite sky direction into the previous camera. W=0 intentionally removes camera +// translation: an infinitely distant sky does not move when the camera walks, but it does move when +// the camera rotates. +public float2 projectPrevSkyNdc(float3 direction, out bool valid) { + float4 clip = mul(worldPush.prevViewProj, float4(direction, 0.0)); + valid = clip.w > 0.0 && clip.w == clip.w && abs(clip.w) <= 1.0e20; + return valid ? clip.xy / clip.w : float2(0.0, 0.0); +} + // Previous-frame screen position of a planar reflection. The reflected image of a world point P seen // in a planar reflector is the MIRROR image V = mirror(P) across the surface plane: the eye sees V // along a straight line, so the reflection appears at proj(V). Project the mirror image directly. @@ -146,7 +158,9 @@ public float2 resolveSpecularGuides(SpecSurface surface, float3 primaryDir, } public void setTransmissionGuide(float3 hitCamRel, float3 motionPrev, float3 normal, - float roughness, float3 diffuseAlbedo, bool emissive) { + float roughness, float3 diffuseAlbedo, bool emissive, bool particle) { + gv_transmittedSky = false; + gv_primaryParticle = particle; gv_hitCamRel = hitCamRel; gv_motionObjDisp = motionPrev; gv_motionUseRefracted = true; @@ -174,7 +188,8 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, if (payload.hitT <= 0.0) { setTransmissionGuide((ro + direction * 1.0e6) - worldPush.camOffset, float3(0.0, 0.0, 0.0), float3(0.0, 0.0, 0.0), 1.0, - guideFilter * SKY_DIFF_ALBEDO, false); + guideFilter * SKY_DIFF_ALBEDO, false, false); + gv_transmittedSky = true; return; } @@ -189,7 +204,7 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, : hitAlbedo * (1.0 - clamp(payloadMetalness(), 0.0, 1.0)); setTransmissionGuide(interfacePos - worldPush.camOffset, payload.motionPrev, payloadNormal(), endpointRoughness, guideFilter * endpointAlbedo, - payloadEmission() > 0.0); + payloadEmission() > 0.0, material == MATERIAL_PARTICLE); return; } if (material != MATERIAL_WATER && material != MATERIAL_DIELECTRIC) return; @@ -215,7 +230,7 @@ public void resolveTransmissionGuide(float3 surfacePos, float3 transmittedDir, // Never let a TIR reflection become ordinary diffuse/depth. setTransmissionGuide(interfacePos - worldPush.camOffset, isWater ? float3(0.0, 0.0, 0.0) : float3(payload.motionPrev), - interfaceNormal, 0.0, float3(0.0, 0.0, 0.0), false); + interfaceNormal, 0.0, float3(0.0, 0.0, 0.0), false, false); return; } if (!isWater && entering) { @@ -242,36 +257,44 @@ public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, f // Hardware (non-linear, reversed-Z) depth for DLSS-RR + Frame Generation: project the camera-relative // primary hit through the forward view-projection and take ndc z/w — exactly the value a rasterizer - // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag; sky's far hit -> ~0. + // would write. Reversed-Z (near=1, far=0), so DLSS gets the DepthInverted flag. A primary sky miss + // is a far-plane guide, not a finite hit point: publish exact reversed-Z far depth. // Clear transmission uses destination depth so the entire ordinary tuple describes one layer. - float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); - float depth = curClip.w > 0.0 ? curClip.z / curClip.w : 0.0; - - // Motion vector: reproject this guide point through the previous frame's view-projection. - // The primary ray was cast through the JITTERED ndc (jndc), so this hit's current screen position is - // jndc, NOT the pixel centre. Subtract jndc so the MV is jitter-free (= 0 when static), which is - // what DLSS expects with MVJittered unset. Scaled from NDC into render-pixel space. - // For a dynamic surface, previous-frame position is the current point minus its world-space - // displacement; subtracting gv_motionObjDisp de-cameras the MV. - float4 prevClip = mul(worldPush.prevViewProj, - float4(gv_hitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); - float2 curNdc = jndc; // primary hit's current screen position is exactly the jittered ray ndc - // Transmitted content is its own feature: compare its previous and current projections rather than - // subtracting the primary interface's jittered NDC. - float4 curClipMotion = gv_motionUseRefracted - ? mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)) - : float4(0.0, 0.0, 0.0, 1.0); // unused; keeps the guard below uniform - // Guard both perspective divides the way the depth above does. A guide point can land behind either - // camera — most easily a transmitted destination seen at a grazing angle, or one that leaves the - // frustum as the camera turns — and w at or below zero turns the divide into an arbitrarily large - // vector that DLSS-RR reprojects with. No valid previous position exists in that case, so report no - // motion and let RR fall back instead of handing it garbage. float2 motion; - if (prevClip.w > 0.0 && (!gv_motionUseRefracted || curClipMotion.w > 0.0)) { - if (gv_motionUseRefracted) curNdc = curClipMotion.xy / curClipMotion.w; - motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; - } else { + float depth; + if (gv_transmittedSky) { + // A transmitted sky has no stable destination correspondence behind its foreground interface. + // Keep its ordinary motion invalid so RR cannot smear glass-to-sky pixels into splotches. + depth = 0.0; motion = float2(0.0, 0.0); + } else if (gv_primarySky) { + depth = 0.0; + bool previousSkyValid; + float2 previousSkyNdc = projectPrevSkyNdc(primaryDir, previousSkyValid); + motion = previousSkyValid + ? (previousSkyNdc - jndc) * 0.5 * size + : float2(0.0, 0.0); + } else { + float4 curClip = mul(worldPush.curViewProj, float4(gv_hitCamRel, 1.0)); + bool clipWFinite = curClip.w > 0.0 && curClip.w == curClip.w && abs(curClip.w) <= 1.0e20; + float projectedDepth = clipWFinite ? curClip.z / curClip.w : 0.0; + bool depthValid = clipWFinite + && projectedDepth == projectedDepth && abs(projectedDepth) <= 1.0e20; + depth = depthValid ? clamp(projectedDepth, 0.0, 1.0) : 0.0; + + // The primary ray was cast through the jittered NDC (jndc). Subtracting it keeps the + // motion guide jitter-free, which is required because MVJittered is not enabled. + float4 prevClip = mul(worldPush.prevViewProj, + float4(gv_hitCamRel + worldPush.camDelta - gv_motionObjDisp, 1.0)); + float2 curNdc = jndc; + if (gv_motionUseRefracted) { + curNdc = depthValid ? curClip.xy / curClip.w : float2(0.0, 0.0); + } + if (prevClip.w > 0.0 && (!gv_motionUseRefracted || depthValid)) { + motion = (prevClip.xy / prevClip.w - curNdc) * 0.5 * size; + } else { + motion = float2(0.0, 0.0); + } } // Guide buffers: first-hit or coherently replaced attributes consumed by the denoiser/DLSS-RR. @@ -281,4 +304,14 @@ public void writeGuides(int2 pix, float3 primaryDir, float2 jndc, float2 size, f gMotion[pix] = motion; gSpecAlbedo[pix] = float4(specAlbedo, 1.0); gSpecMotion[pix] = specMotion; + // Primary and transmitted sky have no stable surface correspondence; responsivity tells DLSSD not + // to reuse their history. Transmission replaces the ordinary guide tuple, so both sky branches must + // classify identically with the depth/motion invalidation above. Display needs a separate primary-sky + // classification because transmitted sky remains a visible foreground surface there. + // Particle classification prevents dynamic particle pixels from being accumulated as opaque terrain. + // Only a visible primary miss is eligible for native End-sky replacement. A transmitted sky stays + // a surface feature so the display pass cannot overwrite the foreground glass/water layer. + gResponsivity[pix] = (gv_primarySky || gv_transmittedSky) ? 1.0 : 0.0; + gSkyClassification[pix] = gv_primarySky ? 1.0 : 0.0; + gParticleMask[pix] = gv_primaryParticle ? 1u : 0u; } diff --git a/shaders/pipelines/world/indirect.rgen.slang b/shaders/pipelines/world/indirect.rgen.slang index db0ac365..9b942966 100644 --- a/shaders/pipelines/world/indirect.rgen.slang +++ b/shaders/pipelines/world/indirect.rgen.slang @@ -20,6 +20,9 @@ // The build emits an ordinary TraceRay fallback and an optional EXT SER variant from this source. import world_common; import world_core; +#ifdef CAUSTICA_SHARC_VARIANT +import sharc_types; +#endif import math; import medium; import segment; @@ -32,6 +35,12 @@ import lighting; import sky; import bindings; +#ifdef CAUSTICA_SHARC_VARIANT +#include "sharc_bridge.slang" +static SharcParameters sharcParameters; +static SharcFrame sharcFrame; +#endif + // 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. public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathBranch) { @@ -67,6 +76,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB // 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; +#ifdef CAUSTICA_SHARC_UPDATE + SharcState sharcState; + SharcInit(sharcState); +#endif 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. @@ -77,10 +90,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB // roulette, and paths guaranteed to end at the bounce cap in separate coherence groups. uint pathPhaseHint = bounce >= maxBounces ? 2u : (bounce >= rrStart ? 1u : 0u); traceRadianceReordered(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread, pathPhaseHint); + showCelestial, false, rayConeWidth, rayConeSpread, pathPhaseHint); #else traceRadiance(bounce == 0 ? CULL_PRIMARY : CULL_SECONDARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); + showCelestial, false, rayConeWidth, rayConeSpread); #endif if (payload.hitT < 0.0) { @@ -96,6 +109,10 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB // packed show-celestial flag decides whether this path segment may see the bright disc. float3 sky = payloadSky(); L += throughput * sky; // escaped to sky +#ifdef CAUSTICA_SHARC_UPDATE + causticaSharcUpdateMiss(sharcParameters, sharcState, sky, + (sharcFrame.sharcFlags & 1u) != 0u); +#endif break; } @@ -186,6 +203,15 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB } throughput /= q; } +#ifdef CAUSTICA_SHARC_UPDATE +#if SHARC_ENABLE_SH_ENCODING + SharcSetRadianceDirectionWeight(sharcState, 1.0); +#endif + if (!causticaSharcSetThroughput(sharcParameters, sharcState, throughput)) { + break; + } + throughput = float3(1.0, 1.0, 1.0); +#endif continue; } @@ -235,6 +261,15 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB rd = cosineDir(n, diffuseSampler, PATH_DIM_DIFFUSE_U); rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); showCelestial = false; // diffuse receiver: direct sun/moon was handled by NEE above +#ifdef CAUSTICA_SHARC_UPDATE +#if SHARC_ENABLE_SH_ENCODING + SharcSetRadianceDirectionWeight(sharcState, 0.0); +#endif + if (!causticaSharcSetThroughput(sharcParameters, sharcState, throughput)) { + break; + } + throughput = float3(1.0, 1.0, 1.0); +#endif continue; } @@ -255,6 +290,26 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB float3 diffAlb = albedo * (1.0 - metal); float3 F0 = payload.f0; +#ifdef CAUSTICA_SHARC_VARIANT + bool sharcFiniteSurface = causticaFinite3(hitPos, CAUSTICA_SHARC_WORLD_LIMIT) + && causticaFinite3(n, 1.0e6) && causticaFinite3(diffAlb, 1.0e6) + && causticaFinite3(v, 1.0e6); + bool sharcDynamicSurface = (payload.flags & PAYLOAD_SHARC_DYNAMIC) != 0u; + // Only stable diffuse secondary surfaces own/query cache entries. Primary pixels, specular-visible + // paths, dynamic geometry, metals, and malformed material data remain live. The explicit debug bit + // is limited to the camera-terminal segment so it cannot accidentally make reflected paths cacheable. + bool sharcPrimaryDebug = seg.bounce == 0 && hitDepth == 0 + && (pc.sharcDebugFlags & 1u) != 0u; + bool sharcPathEligible = !showCelestial || sharcPrimaryDebug; + bool sharcDepthEligible = sharcPathEligible && (hitDepth > 0 || sharcPrimaryDebug); + bool sharcDiffuseEligible = sharcDepthEligible && sharcFiniteSurface + && !sharcDynamicSurface && rough >= 0.0 && rough <= 1.0 + && metal >= 0.0 && metal <= 0.1 + && rough > max(MIRROR_ALPHA_MAX, pc.sharcRoughnessThreshold) + && all(diffAlb >= float3(0.0, 0.0, 0.0)) + && luminance(diffAlb) > 1.0e-4 && dot(n, v) > 1.0e-4; +#endif + // Emissive surfaces (lava, glowstone, torches, ...) add radiance directly, colored by albedo. // RIS emitter NEE: the direct-hit emission term is gated only for emitters RIS actually samples // (payload emitter-in-list bit) — and then only off diffuse continuation rays (showCelestial @@ -269,8 +324,19 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB // agree: gateEmitter exists only because RIS already accounted for this emitter, so a bounce where // RIS is skipped must also gather emission directly or that light is lost outright. bool gateEmitter = risOn && payloadEmitterInList(); +#ifdef CAUSTICA_SHARC_VARIANT + float3 sharcCacheableDirectLighting = float3(0.0, 0.0, 0.0); + float3 sharcLiveDirectLighting = float3(0.0, 0.0, 0.0); + float3 sharcMaterialEmissive = float3(0.0, 0.0, 0.0); +#endif if (emission > 0.0 && (!gateEmitter || showCelestial)) { +#ifdef CAUSTICA_SHARC_VARIANT + float3 emissionLighting = albedo * emission; + L += throughput * emissionLighting; + sharcMaterialEmissive = emissionLighting; +#else L += throughput * albedo * emission; +#endif } // NEE: direct light from the dominant celestial body (sun by day, moon by night) — Lambert @@ -295,6 +361,26 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB vis *= waterCaustic(p + lightDir * shadow.waterHitT, lightDir, shadow.waterHitT); } if (max(vis.r, max(vis.g, vis.b)) > 0.0) { +#ifdef CAUSTICA_SHARC_VARIANT + float3 diffuseBrdf = diffAlb * INV_PI; + float3 h = normalize(lightDir + v); + float ndh = max(0.0, dot(n, h)); + float ndv = max(1.0e-4, dot(n, v)); + float vdh = max(0.0, dot(v, h)); + float D = ggxD(ndh, rough); + float G = ggxG1(ndv, rough) * ggxG1(ndl, rough); + float3 directScale = celestialLight.illuminance * ndl * vis; + float3 diffuseLighting = diffuseBrdf * directScale; + float3 specularLighting = ((D * G) * fresnelSchlick(vdh, F0) + / (4.0 * ndv * ndl)) * directScale; + sharcCacheableDirectLighting += diffuseLighting; + sharcLiveDirectLighting += specularLighting; +#ifdef CAUSTICA_SHARC_UPDATE + L += throughput * (diffuseLighting + specularLighting); +#else + L += throughput * specularLighting; +#endif +#else float3 brdf = diffAlb * INV_PI; // Lambertian diffuse (f = albedo/PI) float3 h = normalize(lightDir + v); float ndh = max(0.0, dot(n, h)); @@ -305,6 +391,7 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB float3 F = fresnelSchlick(vdh, F0); brdf += (D * G) * F / (4.0 * ndv * ndl); // Cook–Torrance specular L += throughput * brdf * celestialLight.illuminance * ndl * vis; +#endif } } @@ -318,8 +405,15 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB PathSampler risSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_RIS_BASE); Reservoir r = risInitial(hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss, risSampler); +#ifdef CAUSTICA_SHARC_VARIANT + float3 risLighting = shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, + activeSss); + L += throughput * risLighting; + sharcLiveDirectLighting += risLighting; +#else L += throughput * shadeReservoir(r, hitPos, n, v, rd, diffAlb, F0, rough, false, activeSss); +#endif } // Thin-surface SSS transmission. Light entering from the back face scatters through toward the @@ -340,7 +434,14 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB } if (max(visB.r, max(visB.g, visB.b)) > 0.0) { float cosT = dot(lightDir, rd); +#ifdef CAUSTICA_SHARC_VARIANT + float3 sssLighting = diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl + * celestialLight.illuminance * visB; + L += throughput * sssLighting; + sharcLiveDirectLighting += sssLighting; +#else L += throughput * diffAlb * sss * SSS_STRENGTH * hg(cosT, SSS_G) * backNdl * celestialLight.illuminance * visB; +#endif } } } @@ -348,6 +449,14 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB // Preserve all local lighting at the terminal hit, but do not sample a continuation that the // bounce loop cannot trace. if (bounce >= maxBounces) { +#ifdef CAUSTICA_SHARC_QUERY + L += throughput * sharcCacheableDirectLighting; +#endif +#ifdef CAUSTICA_SHARC_UPDATE + causticaSharcPropagate(sharcParameters, sharcState, + sharcCacheableDirectLighting + sharcLiveDirectLighting + sharcMaterialEmissive, + (sharcFrame.sharcFlags & 1u) != 0u); +#endif break; } @@ -357,7 +466,35 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB ? 1.0 : clamp(luminance(F0) / (luminance(F0) + luminance(diffAlb) + 1.0e-4), 0.1, 0.9); bool sampledSpecular = pathSample(transportSampler, PATH_DIM_LOBE) < ps; +#ifdef CAUSTICA_SHARC_UPDATE + float3 sharcPropagatedLighting = sharcCacheableDirectLighting + + sharcLiveDirectLighting + sharcMaterialEmissive; + if (!sampledSpecular && sharcDiffuseEligible) { + SharcHitData sharcHit = causticaMakeSharcHit(hitPos, n, diffAlb, + sharcMaterialEmissive, v, 0.0); + if (!causticaSharcUpdateHit(sharcParameters, sharcState, sharcHit, + sharcCacheableDirectLighting, sharcLiveDirectLighting, + sharcCacheableDirectLighting + sharcLiveDirectLighting, + (sharcFrame.sharcFlags & 1u) != 0u, + pathSample(pathSamplerWithGroup(transportSampler, PATH_GROUP_SHARC), + PATH_DIM_SHARC_UPDATE))) { + break; + } + } else { + causticaSharcPropagate(sharcParameters, sharcState, sharcPropagatedLighting, + (sharcFrame.sharcFlags & 1u) != 0u); + } +#endif if (sampledSpecular) { +#ifdef CAUSTICA_SHARC_UPDATE +#if SHARC_ENABLE_SH_ENCODING + SharcSetRadianceDirectionWeight(sharcState, + causticaSharcDirectionalityWeight(exactSpecular, false, rough)); +#endif +#endif +#ifdef CAUSTICA_SHARC_QUERY + L += throughput * sharcCacheableDirectLighting; +#endif float3 l; if (exactSpecular) { // Authored zero is a delta distribution, not a narrow finite GGX lobe. This avoids the @@ -381,12 +518,28 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB rd = l; showCelestial = true; // specular lobe: this ray is a (glossy) mirror — show the disc } else { +#ifdef CAUSTICA_SHARC_QUERY + float3 cachedRadiance; + if (sharcDiffuseEligible + && causticaSharcTryQuery(sharcParameters, hitPos, n, diffAlb, v, payload.hitT, + cachedRadiance)) { + L += throughput * cachedRadiance; + break; + } + // A cold/invalid cache restores the live diffuse direct term and keeps tracing unbiased. + L += throughput * sharcCacheableDirectLighting; +#endif throughput *= diffAlb / (1.0 - ps); ro = p; PathSampler diffuseSampler = pathSamplerWithGroup(transportSampler, PATH_GROUP_TRANSPORT_DIFFUSE); rd = cosineDir(n, diffuseSampler, PATH_DIM_DIFFUSE_U); rayConeSpread = max(rayConeSpread, RAY_CONE_DIFFUSE_SPREAD); showCelestial = false; // diffuse lobe: disc covered by NEE, hide it (anti-firefly) +#ifdef CAUSTICA_SHARC_UPDATE +#if SHARC_ENABLE_SH_ENCODING + SharcSetRadianceDirectionWeight(sharcState, 0.0); +#endif +#endif } // Russian roulette on deeper bounces: survive with prob = max channel, rescale survivors. @@ -397,6 +550,12 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB } throughput /= q; } +#ifdef CAUSTICA_SHARC_UPDATE + if (!causticaSharcSetThroughput(sharcParameters, sharcState, throughput)) { + break; + } + throughput = float3(1.0, 1.0, 1.0); +#endif } return L; } @@ -404,11 +563,51 @@ public float3 tracePath(PathSegment seg, uint2 pix, uint sampleIndex, uint pathB [shader("raygeneration")] void main() { worldPush = ConstPtr(pc.worldPushAddr)[0]; +#ifdef CAUSTICA_SHARC_VARIANT + sharcFrame = ConstPtr(pc.sharcFrameAddr)[0]; + sharcParameters = causticaSharcParameters(sharcFrame.hashEntriesAddr, sharcFrame.accumulationAddr, + sharcFrame.resolvedAddr, sharcFrame.capacity, sharcFrame.cameraPosition, + sharcFrame.sceneScale, sharcFrame.radianceScale, + sharcFrame.gridLogarithmBase, sharcFrame.gridLevelBias); +#endif // Derive this dispatch's directional light once. The same transmittance LUT drives terrain lighting // and the visible sun/moon, keeping atmospheric tint consistent across both consumers. celestialLight = dominantCelestialLight(skyState(worldPush), transmittanceLut); // The path integral is ACEScg end to end; sky.slang works in linear BT.709, so this is the one seam. celestialLight.illuminance = bt709ToAcesCg(celestialLight.illuminance); +#ifdef CAUSTICA_SHARC_UPDATE + uint2 tileIndex = DispatchRaysIndex().xy; + uint tileSize = max(pc.sharcUpdateTileSize, 1u); + uint tileArea = max(tileSize * tileSize, 1u); + uint tileSeedInput = tileIndex.x * 1973u + tileIndex.y * 9277u + 26699u; + uint tileSeed = pcg(tileSeedInput); + uint tileOffset = (tileSeed % tileArea + sharcFrame.frameIndex % tileArea) % tileArea; + uint2 updatePixel = tileIndex * tileSize + + uint2(tileOffset % tileSize, tileOffset / tileSize); + uint2 renderDimensions = uint2(max(pc.sharcRenderWidth, 1u), max(pc.sharcRenderHeight, 1u)); + if (any(updatePixel >= renderDimensions)) return; + float2 updateSize = float2(renderDimensions); + float2 updateUv = (float2(updatePixel) + 0.5) / updateSize; + float2 updateNdc = (updateUv + worldPush.jitter / updateSize) * 2.0 - 1.0; + float4 updateNearH = mul(worldPush.invViewProj, float4(updateNdc.x, updateNdc.y, 1.0, 1.0)); + float4 updateFarH = mul(worldPush.invViewProj, float4(updateNdc.x, updateNdc.y, 0.0, 1.0)); + float3 updateNear = updateNearH.xyz / updateNearH.w; + float3 updateFar = updateFarH.xyz / updateFarH.w; + float3 updateOrigin = updateNear + worldPush.camOffset; + float3 updateDirection = normalize(updateFar - updateNear); + uint updateSeedInput = tileSeed ^ (updatePixel.x * 1973u + updatePixel.y * 9277u + 26699u) + ^ (sharcFrame.frameIndex * 2654435761u); + uint updateSeed = pcg(updateSeedInput); + MediumStack updateMedium = makeMediumStack((worldPush.flags & 1u) != 0u + ? makeDielectricMedium(true, WATER_IOR, worldPush.waterParams.xyz, 1.0) + : airMedium()); + PathSegment updateSegment = makePathSegment(updateOrigin, updateDirection, + float3(1.0, 1.0, 1.0), updateMedium, 0.0, + primaryRayConeSpread(updateNdc, updateSize, updateDirection), updateSeed, 0, true); + tracePath(updateSegment, updatePixel, 0u, 0u); + return; +#endif +#if !defined(CAUSTICA_SHARC_UPDATE) uint2 dispatchIndex = DispatchRaysIndex().xy; uint2 dimensions = DispatchRaysDimensions().xy; int2 pix = int2(dispatchIndex); @@ -437,4 +636,5 @@ void main() { // white as white, so it costs nothing visible. float3 stored = frameRadiance * (worldPush.preExposure / float(spp)); outImage[pix] = float4(clamp(stored, float3(0.0), float3(HALF_MAX)), 1.0); +#endif } diff --git a/shaders/pipelines/world/math.slang b/shaders/pipelines/world/math.slang index 3581c62e..d7b53c34 100644 --- a/shaders/pipelines/world/math.slang +++ b/shaders/pipelines/world/math.slang @@ -117,6 +117,7 @@ public static const uint PATH_DIM_RIS_SELECT = 0u; public static const uint PATH_DIM_RIS_REPLACE = 2u; public static const uint PATH_DIM_RIS_LIGHT_U = 0u; public static const uint PATH_DIM_RIS_LIGHT_V = 1u; +public static const uint PATH_DIM_SHARC_UPDATE = 0u; public static const uint PATH_DIM_CELESTIAL_U = 0u; public static const uint PATH_GROUP_TRANSPORT_CORE = 0u; @@ -124,8 +125,9 @@ public static const uint PATH_GROUP_TRANSPORT_SPECULAR = 1u; public static const uint PATH_GROUP_TRANSPORT_DIFFUSE = 2u; public static const uint PATH_GROUP_RIS_BASE = 3u; public static const uint PATH_GROUP_RIS_CANDIDATE_FIRST = 4u; -public static const uint PATH_GROUP_CELESTIAL = 68u; -public static const uint PATH_GROUP_COUNT = 69u; +public static const uint PATH_GROUP_SHARC = 68u; +public static const uint PATH_GROUP_CELESTIAL = 69u; +public static const uint PATH_GROUP_COUNT = 70u; public static const uint PATH_BRANCH_COUNT = 2u; public static const uint PATH_BOUNCE_COUNT = 9u; public static const uint PATH_SOBOL_DIMENSIONS = 4u; @@ -163,7 +165,7 @@ public uint pathHash(uint value) { } public uint pathReverseBits(uint value) { - // Slang lowers this intrinsic to SPIR-V OpBitReverse. + // The pinned Slang toolchain lowers this intrinsic to SPIR-V OpBitReverse. return reversebits(value); } diff --git a/shaders/pipelines/world/primary.rgen.slang b/shaders/pipelines/world/primary.rgen.slang index 48f990bc..3265d89e 100644 --- a/shaders/pipelines/world/primary.rgen.slang +++ b/shaders/pipelines/world/primary.rgen.slang @@ -43,8 +43,12 @@ public PathSegment tracePrimary(PathSegment seg, PathSegment terminal = makePathSegment(ro, rd, throughput, medium, rayConeWidth, rayConeSpread, seed, bounce, showCelestial); + if (bounce == 0) { + gv_primaryParticle = false; + gv_transmittedSky = false; + } traceRadiance(CULL_PRIMARY, ro, 0.0, rd, 10000.0, - showCelestial, rayConeWidth, rayConeSpread); + showCelestial, bounce == 0, rayConeWidth, rayConeSpread); if (payload.hitT < 0.0) { if (bounce == 0) { @@ -52,6 +56,8 @@ public PathSegment tracePrimary(PathSegment seg, gv_albedo = SKY_DIFF_ALBEDO; gv_emissive = false; gv_rough = 1.0; + gv_primarySky = true; + gv_transmittedSky = false; gv_hitCamRel = rd * 1.0e6; gv_motionUseRefracted = false; gv_motionObjDisp = float3(0.0, 0.0, 0.0); @@ -76,6 +82,9 @@ public PathSegment tracePrimary(PathSegment seg, if (bounce == 0) { gv_normal = n; gv_hitCamRel = hitPos - worldPush.camOffset; + gv_primarySky = false; + gv_primaryParticle = material == MATERIAL_PARTICLE; + gv_transmittedSky = false; gv_motionUseRefracted = false; gv_motionObjDisp = payload.motionPrev; gv_emissive = payloadEmission() > 0.0; @@ -140,6 +149,8 @@ public PathSegment tracePrimary(PathSegment seg, gv_rough = 0.0; gv_albedo = float3(0.0, 0.0, 0.0); gv_emissive = false; + gv_primarySky = false; + gv_transmittedSky = false; gv_hitCamRel = hitPos - worldPush.camOffset; gv_motionUseRefracted = false; gv_motionObjDisp = isWater ? float3(0.0, 0.0, 0.0) : payload.motionPrev; diff --git a/shaders/pipelines/world/sharc_types.slang b/shaders/pipelines/world/sharc_types.slang new file mode 100644 index 00000000..f25d181c --- /dev/null +++ b/shaders/pipelines/world/sharc_types.slang @@ -0,0 +1,45 @@ +// SHaRC-only ABI types. Keeping this module out of world_common keeps the ordinary +// path shader's interface independent of the optional SDK build. +module sharc_types; + +import world_common; + +public struct SharcPushConstants { + public uint64_t worldPushAddr; + public uint64_t tableAddr; + public uint64_t entityTableAddr; + public uint64_t materialTableAddr; + public uint64_t lightBufAddr; + public uint64_t lightAliasAddr; + public uint64_t lightLocalAliasAddr; + public uint64_t lightGridCellAddr; + public uint64_t lightGridSpanAddr; + public uint64_t pathQueueAddr; + public uint frameIndex; + public uint64_t sharcFrameAddr; + public uint sharcUpdateTileSize; + public uint sharcRenderWidth; + public uint sharcRenderHeight; + // Additional minimum linear roughness for diffuse cache ownership. + public float sharcRoughnessThreshold; + // bit 0: developer comparison mode that permits primary-terminal cache insertion/query. + public uint sharcDebugFlags; +}; + +public struct SharcFrame { + public uint64_t hashEntriesAddr; + public uint64_t accumulationAddr; + public uint64_t resolvedAddr; + public float3 cameraPosition; + public uint capacity; + public float3 cameraPositionPrev; + public uint frameIndex; + public float sceneScale; + public float radianceScale; + public uint accumulationFrameNum; + public uint staleFrameNumMax; + public float gridLogarithmBase; + public float gridLevelBias; + // bit 0: enable the confidence-based anti-firefly adjustment during updates + public uint sharcFlags; +}; diff --git a/shaders/pipelines/world/sky.rmiss.slang b/shaders/pipelines/world/sky.rmiss.slang index 112bda7a..cfb27a34 100644 --- a/shaders/pipelines/world/sky.rmiss.slang +++ b/shaders/pipelines/world/sky.rmiss.slang @@ -99,11 +99,85 @@ float3 skyDome(float3 dir, SkyState state) { return skyViewLut.SampleLevel(sunUv, 0.0).rgb + skyViewLut.SampleLevel(moonUv, 0.0).rgb; } +// SkyRenderer.buildEndSky() draws six 200x200 quads around the camera. This is the ray equivalent of +// those faces, including the per-face UV orientation and the 0..16 repeat range of end_sky.png. +float2 endSkyUv(float3 dir) { + float3 a = abs(dir); + float t = 100.0 / max(max(a.x, a.y), a.z); + float3 p = dir * t; + if (a.x >= a.y && a.x >= a.z) { + if (dir.x > 0.0) return (float2(p.y, p.z) + 100.0) * (16.0 / 200.0); + return (float2(-p.y, p.z) + 100.0) * (16.0 / 200.0); + } + if (a.y >= a.z) { + if (dir.y > 0.0) return (float2(p.x, -p.z) + 100.0) * (16.0 / 200.0); + return (float2(p.x, p.z) + 100.0) * (16.0 / 200.0); + } + if (dir.z > 0.0) return (float2(p.x, p.y) + 100.0) * (16.0 / 200.0); + return (float2(p.x, -p.y) + 100.0) * (16.0 / 200.0); +} + +float3 endFlashRadiance(float3 dir, WorldPush worldPush) { + if ((worldPush.skyFlags & SKY_FLAG_END_FLASH) == 0u) { + return float3(0.0); + } + // Matches SkyRenderer.renderEndFlash's Y(180-y) * X(-90-x) pose, translation (0,100,0), + // and 60x60 quad scale. + static const float PI = 3.14159265359; + float y = PI - worldPush.skyParams.w; + float x = -0.5 * PI - worldPush.skyParams.z; + float cy = cos(y), sy = sin(y), cx = cos(x), sx = sin(x); + float3 right = float3(cy, 0.0, -sy); + float3 up = float3(sy * sx, cx, cy * sx); + float3 forward = float3(sy * cx, -sx, cy * cx); + float3 center = up * 100.0; + float denom = dot(dir, up); + if (abs(denom) < 1.0e-5) return float3(0.0); + float hitDistance = dot(center, up) / denom; + if (hitDistance <= 0.0) return float3(0.0); + float3 q = dir * hitDistance - center; + float2 local = float2(dot(q, right), dot(q, forward)) / 60.0; + if (max(abs(local.x), abs(local.y)) > 1.0) return float3(0.0); + float2 uv = lerp(worldPush.endFlashUv.xy, worldPush.endFlashUv.zw, local * 0.5 + 0.5); + float4 texel = celestialsAtlas.SampleLevel(uv, 0.0); + float intensity = worldPush.skyParams.y; + return srgbToLinear(texel.rgb) * texel.a * intensity * intensity; +} + [shader("miss")] void main(inout Payload payload) { WorldPush worldPush = ConstPtr(pc.worldPushAddr)[0]; SkyState state = skyState(worldPush); float3 dir = normalize(WorldRayDirection()); + + if (worldPush.skybox == SKYBOX_NONE) { + packSky(payload, bt709ToAcesCg(max(worldPush.skyColor.rgb, float3(0.0)))); + payload.hitT = -1.0; + payload.flags = 0u; + payload.roughMetal = packHalf2(float2(1.0, 0.0)); + payload.emissionSss = packHalf2(float2(0.0, 0.0)); + payload.iorTransmission = packHalf2(float2(1.0, 0.0)); + payload.rayCone = 0u; + return; + } + + if (worldPush.skybox == SKYBOX_END) { + float4 texel = endSkyTexture.SampleLevel(endSkyUv(dir), 0.0); + float3 endSkyColor = srgbToLinear(texel.rgb) * (40.0 / 255.0); + float3 color = lerp(worldPush.skyColor.rgb, endSkyColor, clamp(texel.a, 0.0, 1.0)); + if ((payload.flags & PAYLOAD_PRIMARY_SKY) != 0u) { + color += endFlashRadiance(dir, worldPush); + } + packSky(payload, bt709ToAcesCg(max(color, float3(0.0)))); + payload.hitT = -1.0; + payload.flags = 0u; + payload.roughMetal = packHalf2(float2(1.0, 0.0)); + payload.emissionSss = packHalf2(float2(0.0, 0.0)); + payload.iorTransmission = packHalf2(float2(1.0, 0.0)); + payload.rayCone = 0u; + return; + } + bool showCelestial = (payload.flags & PAYLOAD_SHOW_CELESTIAL) != 0u; float3 color = skyDome(dir, state); diff --git a/shaders/pipelines/world/trace.slang b/shaders/pipelines/world/trace.slang index 6a4f5b6e..7da52092 100644 --- a/shaders/pipelines/world/trace.slang +++ b/shaders/pipelines/world/trace.slang @@ -57,8 +57,10 @@ public Payload makeRadiancePayload(uint flags, uint rayCone) { // Ordinary radiance trace for latency-sensitive, coherent work such as Pass A. It invokes hit/miss // shaders directly and therefore requires no invocation-reorder capability. public void traceRadiance(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - bool showCelestial, float rayConeWidth, float rayConeSpread) { - payload = makeRadiancePayload(showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u, + bool showCelestial, bool primarySky, float rayConeWidth, float rayConeSpread) { + uint flags = (showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u) + | (primarySky ? PAYLOAD_PRIMARY_SKY : 0u); + payload = makeRadiancePayload(flags, packHalf2(float2(rayConeWidth, rayConeSpread))); TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, SBT_RADIANCE, SBT_STRIDE_BUCKET, MISS_RADIANCE, diff --git a/shaders/pipelines/world/trace_ser.slang b/shaders/pipelines/world/trace_ser.slang index 89bffc44..2956cb87 100644 --- a/shaders/pipelines/world/trace_ser.slang +++ b/shaders/pipelines/world/trace_ser.slang @@ -18,9 +18,10 @@ import trace; // across the one point where SER must spill every live value. Rebuilding costs a few constant moves and // leaves only these two words spanning the reorder. public void traceRadianceReordered(uint cullMask, float3 ro, float tmin, float3 rd, float tmax, - bool showCelestial, float rayConeWidth, float rayConeSpread, + bool showCelestial, bool primarySky, float rayConeWidth, float rayConeSpread, uint pathPhaseHint) { - uint flags = showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u; + uint flags = (showCelestial ? PAYLOAD_SHOW_CELESTIAL : 0u) + | (primarySky ? PAYLOAD_PRIMARY_SKY : 0u); uint rayCone = packHalf2(float2(rayConeWidth, rayConeSpread)); Payload tracePayload = makeRadiancePayload(flags, rayCone); HitObject hObj = HitObject::TraceRay(topLevelAS, RAY_FLAG_NONE, cullMask, diff --git a/shaders/pipelines/world/world_common.slang b/shaders/pipelines/world/world_common.slang index bd5f1cee..e910fe3b 100644 --- a/shaders/pipelines/world/world_common.slang +++ b/shaders/pipelines/world/world_common.slang @@ -10,6 +10,11 @@ module world_common; public typealias ConstPtr = Ptr; public typealias DevicePtr = Ptr; +public static const uint SKYBOX_NONE = 0u; +public static const uint SKYBOX_OVERWORLD = 1u; +public static const uint SKYBOX_END = 2u; +public static const uint SKY_FLAG_END_FLASH = 1u; + // Push constant block. The hot inline lanes avoid dereferencing WorldPush for values that are read in // hit shaders or used for raygen control flow — every 64-bit device address lives here for exactly that // reason, rather than behind the worldPushAddr indirection. tableAddr/entityTableAddr/materialTableAddr @@ -83,6 +88,9 @@ public struct WorldPush { public int4 lightGridDims; // xyz dense grid dimensions, w reserved public uint lightCount; // packed Light records in pc.lightBufAddr public uint risCandidates; // RIS candidate count M per diffuse vertex (0 = emitter NEE off) + // NVIDIA DLSS mip-map bias: log2(render resolution / display resolution) - 1 while DLSS is active. + // Native rendering keeps this at zero; closest-hit consumes it in explicit SampleLevel LOD. + public float mipMapBias; // Pre-exposure scalar applied to radiance at the outImage write // ONLY, so all light transport above stays in absolute scene units. Keeps stored fp16 values // near mid-grey at any scene brightness; the display pass divides it back out, so the two cancel @@ -95,6 +103,13 @@ public struct WorldPush { public uint pathSampleBase; public uint pathSampleEpoch; public uint64_t pathSampleAddr; + // Dimension-specific native sky state. Appended so the existing transport ABI stays stable for + // every field above; generated WorldPushData owns the padding and offsets. + public uint skybox; // SKYBOX_NONE, SKYBOX_OVERWORLD, or SKYBOX_END + public uint skyFlags; // SKY_FLAG_END_FLASH when the vanilla End flash is active + public float4 skyColor; // captured vanilla sky/clear color, converted to linear BT.709 + public float4 skyParams; // reserved, flash intensity, flash X angle, flash Y angle + public float4 endFlashUv; // celestials-atlas UV rect for end_flash }; // 32-byte hot area-light record. Linear ACEScg radiance uses packed R11G11B10; the @@ -183,6 +198,10 @@ public struct Payload { }; public static const uint PAYLOAD_SHOW_CELESTIAL = 4u; +// Marks the visible camera ray so the native End sky can be composited after DLSS-RR history. +public static const uint PAYLOAD_PRIMARY_SKY = 256u; +// Dynamic/entity and particle hits are view/animation dependent and must never own persistent SHaRC voxels. +public static const uint PAYLOAD_SHARC_DYNAMIC = 512u; // Set by world.rchit on a terrain hit whose prim carries TERRAIN_PRIM_IN_LIGHT_BUFFER: this emitter is // RIS-sampled, so raygen gates its direct-hit emission on diffuse continuation rays (no double count). public static const uint PAYLOAD_EMITTER_IN_LIST = 128u; diff --git a/shaders/sharc/sharc_bridge.slang b/shaders/sharc/sharc_bridge.slang new file mode 100644 index 00000000..946d1426 --- /dev/null +++ b/shaders/sharc/sharc_bridge.slang @@ -0,0 +1,343 @@ +// Caustica-owned adapter for NVIDIA SHaRC 1.8. +// +// The NVIDIA headers are an external, separately licensed build input. They are deliberately not copied +// into this source tree. This adapter keeps the cache in BDA buffers and makes local/direct-light +// ownership explicit: current-vertex lighting stays live, while only propagated radiance is cached. +#ifndef CAUSTICA_SHARC_BRIDGE +#define CAUSTICA_SHARC_BRIDGE 1 + +#ifndef SHARC_UPDATE +#define SHARC_UPDATE 0 +#endif +#ifndef SHARC_QUERY +#define SHARC_QUERY 0 +#endif +#ifdef CAUSTICA_SHARC_UPDATE +#undef SHARC_UPDATE +#define SHARC_UPDATE 1 +#endif +#ifdef CAUSTICA_SHARC_QUERY +#undef SHARC_QUERY +#define SHARC_QUERY 1 +#endif + +#define SHARC_ENABLE_GLSL 0 +#ifndef SHARC_ENABLE_SH_ENCODING +#define SHARC_ENABLE_SH_ENCODING 1 +#endif +#define HASH_GRID_ENABLE_64_BIT_ATOMICS 1 +#define HASH_GRID_USE_NORMALS 1 +#define HASH_GRID_COMPACT 0 +#define SHARC_PROPAGATION_DEPTH 4 +#define SHARC_MATERIAL_DEMODULATION 1 +#define SHARC_SEPARATE_EMISSIVE 1 +#define SHARC_ENABLE_CACHE_RESAMPLING 1 +#define SHARC_ENABLE_RESPONSIVE_LIGHTING 0 +#ifndef SHARC_SAMPLE_NUM_THRESHOLD +#define SHARC_SAMPLE_NUM_THRESHOLD 3 +#endif +#define CAUSTICA_SHARC_MIN_SEGMENT_RATIO 1.0 +#define SHARC_LINEAR_PROBE_WINDOW_SIZE \ + ((entryIndex + 1u < sharcParameters.hashGridData.capacity) \ + ? min(8u, sharcParameters.hashGridData.capacity - (entryIndex + 1u)) : 0u) +#define CAUSTICA_SHARC_WORLD_LIMIT 1.0e6 + +typealias SharcRWPtr = Ptr; +#define RW_STRUCTURED_BUFFER(name, type) SharcRWPtr name +#define BUFFER_AT_OFFSET(name, offset) name[offset] + +#include "SharcCommon.h" + +bool causticaFinite3(float3 value, float limit) { + return all(value == value) && all(abs(value) <= float3(limit, limit, limit)); +} + +float causticaSharcDirectionalityWeight(bool isDelta, bool isDiffuse, float perceptualRoughness) { + if (isDiffuse) return 0.0; + if (isDelta) return 1.0; + if (perceptualRoughness != perceptualRoughness || abs(perceptualRoughness) > 1.0) return 0.0; + float smoothness = saturate(1.0 - perceptualRoughness); + return smoothness * smoothness; +} + +float3 causticaSafeDirection(float3 direction) { + float lengthSquared = dot(direction, direction); + return causticaFinite3(direction, 1.0e30) && lengthSquared > 1.0e-12 && lengthSquared < 1.0e30 + ? direction * rsqrt(lengthSquared) : float3(0.0, 0.0, 1.0); +} + +bool causticaCacheSampleFinite(float3 radiance, float3 sampleWeight) { + return causticaFinite3(radiance, HALF_MAX) && all(radiance >= float3(0.0)) + && causticaFinite3(sampleWeight, HALF_MAX) && all(sampleWeight >= float3(0.0)); +} + +bool causticaSharcGridGeometry(SharcParameters parameters, float3 positionWorld, + out float voxelSize) { + voxelSize = 0.0; + if (!causticaFinite3(positionWorld, CAUSTICA_SHARC_WORLD_LIMIT)) return false; + float3 biasedPosition = positionWorld + + float3(HASH_GRID_POSITION_BIAS, HASH_GRID_POSITION_BIAS, HASH_GRID_POSITION_BIAS); + uint level = HashGridGetLevel(biasedPosition, parameters.hashGridParameters); + voxelSize = HashGridGetVoxelSize(level, parameters.hashGridParameters); + if (!causticaFinite3(float3(voxelSize, voxelSize, voxelSize), CAUSTICA_SHARC_WORLD_LIMIT) + || voxelSize <= 0.0) return false; + float3 gridPosition = floor(biasedPosition / voxelSize); + return causticaFinite3(gridPosition, 65535.0); +} + +float3 causticaSharcResolvePreviousCamera(SharcParameters parameters, float3 cameraPositionPrev) { + float base = parameters.hashGridParameters.logarithmBase; + float minVoxelSize = base / (parameters.hashGridParameters.sceneScale + * pow(base, parameters.hashGridParameters.levelBias)); + bool currentSafe = causticaFinite3(float3(minVoxelSize, minVoxelSize, minVoxelSize), + CAUSTICA_SHARC_WORLD_LIMIT) + && minVoxelSize > 0.0 + && causticaFinite3(floor(parameters.hashGridParameters.cameraPosition / minVoxelSize), 65535.0); + bool previousSafe = causticaFinite3(cameraPositionPrev, CAUSTICA_SHARC_WORLD_LIMIT) + && currentSafe + && causticaFinite3(floor(cameraPositionPrev / minVoxelSize), 65535.0); + return previousSafe ? cameraPositionPrev : parameters.hashGridParameters.cameraPosition; +} + +bool causticaValidCacheIndex(SharcParameters parameters, uint index) { + return index != HASH_GRID_INVALID_CACHE_INDEX && index < parameters.hashGridData.capacity; +} + +bool causticaCacheSampleUsable(SharcParameters parameters, float3 radiance, float3 sampleWeight) { + if (!causticaCacheSampleFinite(radiance, sampleWeight)) return false; + float3 weighted = radiance * sampleWeight; + return causticaFinite3(weighted * parameters.radianceScale, 1.0e8); +} + +float3 causticaSharcAntiFireflyWeight(SharcParameters parameters, HashGridIndex cacheIndex, + float3 sampleValue, float3 sampleWeight) { + float scalarWeight = max(SharcLuma(sampleWeight), 1.0); + if (!causticaFinite3(sampleWeight, HALF_MAX) || any(sampleWeight < float3(0.0)) + || !causticaFinite3(sampleValue, HALF_MAX) || any(sampleValue < float3(0.0)) + || !causticaFinite3(float3(scalarWeight, scalarWeight, scalarWeight), HALF_MAX) + || scalarWeight <= 2.0) return sampleWeight; + + SharcVoxelData previous = SharcGetVoxelData(parameters.resolvedBuffer, cacheIndex); + float previousSamples = previous.accumulatedSampleNum; + if (previousSamples > 2.0 && previousSamples == previousSamples + && abs(previousSamples) <= HALF_MAX) { + float previousLuma = max(SharcRadianceLuma(previous.accumulatedRadiance), 1.0); + float currentLuma = max(SharcLuma(sampleValue * sampleWeight), 1.0); + if (causticaFinite3(float3(previousLuma, currentLuma, 0.0), HALF_MAX) && currentLuma > 0.0) { + float confidenceT = saturate((previousSamples - 2.0) / 10.0); + sampleWeight *= saturate(lerp(5.0, 10.0, confidenceT) * previousLuma / currentLuma); + } + } else { + sampleWeight /= sqrt(scalarWeight); + } + return sampleWeight; +} + +void causticaSharcAddVoxelData(SharcParameters parameters, HashGridIndex cacheIndex, + float3 sampleValue, float3 sampleWeight, + float3 sampleDirection, float sampleDirectionWeight, + uint sampleData, bool antiFirefly) { + if (!causticaValidCacheIndex(parameters, cacheIndex) + || !causticaCacheSampleFinite(sampleValue, sampleWeight)) return; + if (antiFirefly) { + sampleWeight = causticaSharcAntiFireflyWeight(parameters, cacheIndex, + sampleValue, sampleWeight); + } + if (causticaCacheSampleUsable(parameters, sampleValue, sampleWeight)) { + SharcAddVoxelData(parameters, cacheIndex, sampleValue, SharcSampleWeight(sampleWeight), + sampleDirection, sampleDirectionWeight, sampleData); + } +} + +SharcHitData causticaMakeSharcHit(float3 positionWorld, float3 normalWorld, float3 albedo, + float3 emissive, float3 outgoingDirectionToPreviousVertex, + float directionalityWeight) { + SharcHitData hit; + hit.positionWorld = positionWorld; + hit.normalWorld = causticaSafeDirection(normalWorld); + hit.materialDemodulation = max(albedo, float3(1.0e-3)); + hit.emissive = emissive; +#if SHARC_ENABLE_SH_ENCODING + hit.radianceDirectionWorld = causticaSafeDirection(outgoingDirectionToPreviousVertex); + hit.radianceDirectionWeight = clamp(directionalityWeight, 0.0, 1.0); +#endif + return hit; +} + +bool causticaSharcTryQuery(SharcParameters parameters, float3 positionWorld, float3 normalWorld, + float3 diffuseAlbedo, float3 outgoingDirection, float segmentLength, + out float3 radiance) { + radiance = float3(0.0, 0.0, 0.0); + if (!causticaFinite3(positionWorld, CAUSTICA_SHARC_WORLD_LIMIT) + || !causticaFinite3(normalWorld, 1.0e6) + || !causticaFinite3(diffuseAlbedo, 1.0e6) + || !causticaFinite3(outgoingDirection, 1.0e6) + || !causticaFinite3(float3(segmentLength, segmentLength, segmentLength), CAUSTICA_SHARC_WORLD_LIMIT) + || all(diffuseAlbedo <= float3(1.0e-4)) + || dot(causticaSafeDirection(normalWorld), causticaSafeDirection(outgoingDirection)) <= 1.0e-4) { + return false; + } + float voxelSize; + if (!causticaSharcGridGeometry(parameters, positionWorld, voxelSize)) return false; + float minSegment = voxelSize * CAUSTICA_SHARC_MIN_SEGMENT_RATIO; + if (!causticaFinite3(float3(voxelSize, minSegment, segmentLength), CAUSTICA_SHARC_WORLD_LIMIT) + || segmentLength < minSegment) return false; + SharcHitData hit = causticaMakeSharcHit(positionWorld, normalWorld, diffuseAlbedo, + float3(0.0, 0.0, 0.0), outgoingDirection, 0.0); + if (!SharcGetCachedRadiance(parameters, hit, radiance, false)) return false; + if (!causticaFinite3(radiance, HALF_MAX) || any(radiance < float3(0.0)) + || !any(radiance > float3(1.0e-6))) { + radiance = float3(0.0); + return false; + } + return true; +} + +void causticaSharcPropagate(SharcParameters parameters, inout SharcState state, + float3 propagatedDirectLighting, bool antiFirefly) { +#if SHARC_UPDATE + for (uint i = 0u; i < state.pathLength; ++i) { + float3 sampleWeight = float3(state.sampleWeights[i]); + if (!causticaValidCacheIndex(parameters, state.cacheIndices[i]) + || !causticaCacheSampleFinite(propagatedDirectLighting, sampleWeight)) continue; + float3 previousDirection = float3(0.0, 0.0, 1.0); + float previousWeight = 0.0; +#if SHARC_ENABLE_SH_ENCODING + previousDirection = state.radianceDirections[i]; + previousWeight = state.radianceDirectionWeights[i]; +#endif + causticaSharcAddVoxelData(parameters, state.cacheIndices[i], propagatedDirectLighting, + sampleWeight, previousDirection, previousWeight, 0u, antiFirefly); + } +#endif +} + +void causticaSharcUpdateMiss(SharcParameters parameters, inout SharcState state, + float3 radiance, bool antiFirefly) { +#if SHARC_UPDATE + causticaSharcPropagate(parameters, state, radiance, antiFirefly); +#endif +} + +bool causticaSharcUpdateHit(SharcParameters parameters, inout SharcState state, + SharcHitData hit, float3 cacheableDirectLighting, + float3 liveDirectLighting, float3 propagatedDirectLighting, + bool antiFirefly, float random) { +#if SHARC_UPDATE + if (!causticaFinite3(hit.positionWorld, CAUSTICA_SHARC_WORLD_LIMIT) + || !causticaFinite3(hit.normalWorld, 1.0e6) + || !causticaFinite3(hit.materialDemodulation, 1.0e6) + || !causticaFinite3(cacheableDirectLighting, HALF_MAX) + || !causticaFinite3(liveDirectLighting, HALF_MAX) + || !causticaFinite3(propagatedDirectLighting, HALF_MAX) + || any(cacheableDirectLighting < float3(0.0)) + || any(liveDirectLighting < float3(0.0)) + || any(propagatedDirectLighting < float3(0.0))) return false; + float voxelSize; + if (!causticaSharcGridGeometry(parameters, hit.positionWorld, voxelSize)) { + causticaSharcPropagate(parameters, state, propagatedDirectLighting + hit.emissive, antiFirefly); + return false; + } + HashGridKey key; + HashGridIndex index = HashGridInsertEntry(parameters.hashGridData, hit.positionWorld, + hit.normalWorld, parameters.hashGridParameters, key); + if (!causticaValidCacheIndex(parameters, index)) { + causticaSharcPropagate(parameters, state, propagatedDirectLighting + hit.emissive, antiFirefly); + return false; + } + float3 demodulation = max(hit.materialDemodulation, float3(1.0e-3)); + float3 direction = SharcGetRadianceDirection(hit); + float directionWeight = SharcGetRadianceDirectionWeight(hit); + bool continueTracing = true; + float3 propagatedRadiance = propagatedDirectLighting + hit.emissive; +#if SHARC_ENABLE_CACHE_RESAMPLING + uint resamplingDepth = uint(round(lerp(float(SHARC_RESAMPLING_DEPTH_MIN), + float(SHARC_PROPAGATION_DEPTH), random))); + if (resamplingDepth <= state.pathLength) { + SharcVoxelData voxel = SharcGetVoxelData(parameters.resolvedBuffer, index); + if (voxel.accumulatedSampleNum > SHARC_SAMPLE_NUM_THRESHOLD) { + float3 cachedRadiance = SharcDecodeRadiance(voxel.accumulatedRadiance, direction); +#if SHARC_MATERIAL_DEMODULATION + cachedRadiance *= hit.materialDemodulation; +#endif +#if SHARC_SEPARATE_EMISSIVE + cachedRadiance += hit.emissive; +#endif + // The cache owns diffuse transport; current-vertex specular/RIS/SSS remains live. + float3 resampledRadiance = cachedRadiance + liveDirectLighting; +#if !SHARC_SEPARATE_EMISSIVE + resampledRadiance += hit.emissive; +#endif + if (causticaFinite3(resampledRadiance, HALF_MAX) && all(resampledRadiance >= float3(0.0))) { + propagatedRadiance = resampledRadiance; + continueTracing = false; + } + } + } +#endif + causticaSharcPropagate(parameters, state, propagatedRadiance, antiFirefly); + if (!continueTracing) return false; + float3 demodulatedDirect = cacheableDirectLighting / demodulation; + if (!causticaCacheSampleUsable(parameters, demodulatedDirect, float3(1.0))) return false; + causticaSharcAddVoxelData(parameters, index, demodulatedDirect, float3(1.0), + direction, directionWeight, 1u, antiFirefly); + uint shiftCount = min(state.pathLength, uint(SHARC_PROPAGATION_DEPTH - 1)); + for (uint i = shiftCount; i > 0u; --i) { + state.cacheIndices[i] = state.cacheIndices[i - 1u]; + state.sampleWeights[i] = state.sampleWeights[i - 1u]; +#if SHARC_ENABLE_SH_ENCODING + state.radianceDirections[i] = state.radianceDirections[i - 1u]; + state.radianceDirectionWeights[i] = state.radianceDirectionWeights[i - 1u]; +#endif + } + state.cacheIndices[0] = index; + state.sampleWeights[0] = SharcSampleWeight(1.0 / demodulation); +#if SHARC_ENABLE_SH_ENCODING + state.radianceDirections[0] = direction; + state.radianceDirectionWeights[0] = directionWeight; +#endif + state.pathLength = min(state.pathLength + 1u, uint(SHARC_PROPAGATION_DEPTH)); +#endif + return true; +} + +bool causticaSharcSetThroughput(SharcParameters parameters, inout SharcState state, + float3 segmentThroughput) { +#if SHARC_UPDATE + if (!causticaCacheSampleUsable(parameters, segmentThroughput, float3(1.0))) { + state.pathLength = 0u; + return false; + } + SharcSetThroughput(state, segmentThroughput); +#endif + return true; +} + +SharcParameters causticaSharcParameters(uint64_t hashEntriesAddress, uint64_t accumulationAddress, + uint64_t resolvedAddress, uint capacity, float3 cameraPosition, + float sceneScale, float radianceScale, + float gridLogarithmBase, float gridLevelBias) { + SharcParameters parameters; + float3 safeCameraPosition = causticaFinite3(cameraPosition, CAUSTICA_SHARC_WORLD_LIMIT) + ? cameraPosition : float3(0.0, 0.0, 0.0); + parameters.hashGridParameters.cameraPosition = safeCameraPosition; + float safeLogarithmBase = gridLogarithmBase == gridLogarithmBase + && abs(gridLogarithmBase) <= 16.0 && gridLogarithmBase >= 1.01 ? gridLogarithmBase : 2.0; + float safeSceneScale = sceneScale == sceneScale + && abs(sceneScale) <= 100.0 && sceneScale >= 1.0 ? sceneScale : 1.0; + float safeLevelBias = gridLevelBias == gridLevelBias + && abs(gridLevelBias) <= 16.0 ? gridLevelBias : 0.0; + float safeRadianceScale = radianceScale == radianceScale + && abs(radianceScale) <= 1000.0 && radianceScale >= 50.0 ? radianceScale : 1000.0; + parameters.hashGridParameters.logarithmBase = safeLogarithmBase; + parameters.hashGridParameters.sceneScale = safeSceneScale; + parameters.hashGridParameters.levelBias = safeLevelBias; + parameters.hashGridData.capacity = capacity; + parameters.hashGridData.hashEntriesBuffer = SharcRWPtr(hashEntriesAddress); + parameters.radianceScale = safeRadianceScale; + parameters.accumulationBuffer = SharcRWPtr(accumulationAddress); + parameters.resolvedBuffer = SharcRWPtr(resolvedAddress); + return parameters; +} + +#endif diff --git a/shaders/sharc/sharc_resolve.comp.slang b/shaders/sharc/sharc_resolve.comp.slang new file mode 100644 index 00000000..9fe10894 --- /dev/null +++ b/shaders/sharc/sharc_resolve.comp.slang @@ -0,0 +1,36 @@ +// SHaRC 1.8 directional-SH resolve. The full cache is dispatched; invalid slots exit in the shader. +#define SHARC_UPDATE 0 +#define SHARC_QUERY 0 + +import world_common; +import sharc_types; +#include "sharc_bridge.slang" + +struct SharcResolvePush { + uint64_t sharcFrameAddr; +}; + +[[vk::push_constant]] SharcResolvePush resolvePush; + +[shader("compute")] +[numthreads(256, 1, 1)] +void main(uint3 id : SV_DispatchThreadID) { + SharcFrame frame = ConstPtr(resolvePush.sharcFrameAddr)[0]; + uint entryIndex = id.x; + if (entryIndex >= frame.capacity) return; + + HashGridKey hashKey = SharcRWPtr(frame.hashEntriesAddr)[entryIndex]; + if (hashKey == HASH_GRID_INVALID_HASH_KEY) return; + + SharcParameters parameters = causticaSharcParameters( + frame.hashEntriesAddr, frame.accumulationAddr, frame.resolvedAddr, frame.capacity, + frame.cameraPosition, frame.sceneScale, frame.radianceScale, + frame.gridLogarithmBase, frame.gridLevelBias); + SharcResolveParameters resolve; + resolve.cameraPositionPrev = causticaSharcResolvePreviousCamera(parameters, frame.cameraPositionPrev); + resolve.accumulationFrameNum = frame.accumulationFrameNum; + resolve.responsiveFrameNum = 1u; + resolve.staleFrameNumMax = frame.staleFrameNumMax; + resolve.frameIndex = frame.frameIndex; + SharcResolveEntry(entryIndex, parameters, resolve); +} diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index ceaeaef0..f5effc04 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -56,8 +56,8 @@ public static void reloadFromSystemProperties() { public static void ensureRegistered() { @SuppressWarnings("unused") Object[] touch = { - Rt.ENABLED, Rt.Composite.SPP, Rt.Composite.MAX_BOUNCES, Rt.Terrain.ASYNC_DISPATCH_PER_PASS, Rt.Omm.ENABLED, - Rt.Lights.RIS_CANDIDATES, + Rt.ENABLED, Rt.Composite.SPP, Rt.Composite.MAX_BOUNCES, Rt.Sharc.ENABLED, + Rt.Terrain.ASYNC_DISPATCH_PER_PASS, Rt.Omm.ENABLED, Rt.Lights.RIS_CANDIDATES, Rt.Entities.ENABLED, Rt.Entities.GLOW_ENABLED, Rt.EntityTextures.MAX_TEXTURES, Rt.DlssRr.ENABLED, Rt.Fg.ENABLED, Rt.Reflex.ENABLED, Rt.Exposure.MODE, Rt.Exposure.LOW_PERCENTILE, Rt.Exposure.HIGH_PERCENTILE, Rt.Exposure.PRE_EXPOSURE, Rt.Tonemap.GAMMA, @@ -537,6 +537,8 @@ private Rt() { } public static final class Composite { + /** Debug value that exposes the full-resolution path-traced image before reconstruction. */ + public static final int RAW_DEBUG_VIEW = 10; public static final IntSetting DEBUG_VIEW = intValue("caustica.rt.debugView", "composite.debug-view", 0); public static final IntSetting SPP = intAtLeast("caustica.rt.spp", "composite.spp", 1, 1); public static final IntSetting MAX_BOUNCES = @@ -556,6 +558,38 @@ private Composite() { } } + /** Runtime-safe controls for the optional, separately packaged SHaRC directional cache. */ + public static final class Sharc { + public static final BooleanSetting ENABLED = bool("caustica.rt.sharc.enabled", "sharc.enabled", true); + public static final IntSetting CACHE_EXPONENT = + clampedInt("caustica.rt.sharc.cacheExponent", "sharc.cache-exponent", 20, 16, 23); + public static final BooleanSetting ANTI_FIREFLY = bool( + "caustica.rt.sharc.antiFirefly", "sharc.anti-firefly", true); + /** Developer comparison mode; production keeps camera-visible primary surfaces live. */ + public static final BooleanSetting PRIMARY_SURFACE_DEBUG = bool( + "caustica.rt.sharc.primarySurfaceDebug", "sharc.primary-surface-debug", false); + public static final IntSetting UPDATE_TILE_SIZE = + clampedInt("caustica.rt.sharc.updateTileSize", "sharc.update-tile-size", 8, 2, 64); + public static final IntSetting ACCUMULATION_FRAMES = + clampedInt("caustica.rt.sharc.accumulationFrames", "sharc.accumulation-frames", 8, 1, 1024); + public static final IntSetting STALE_FRAMES = + clampedInt("caustica.rt.sharc.staleFrames", "sharc.stale-frames", 32, 8, 1024); + public static final FloatSetting SCENE_SCALE = finiteClampedFloat( + "caustica.rt.sharc.sceneScale", "sharc.scene-scale", 1.0f, 1.0f, 100.0f); + public static final FloatSetting RADIANCE_SCALE = finiteClampedFloat( + "caustica.rt.sharc.radianceScale", "sharc.radiance-scale", 1000.0f, 50.0f, 1000.0f); + public static final FloatSetting GRID_LOGARITHM_BASE = finiteClampedFloat( + "caustica.rt.sharc.gridLogarithmBase", "sharc.grid-logarithm-base", 2.0f, 1.01f, 16.0f); + public static final FloatSetting GRID_LEVEL_BIAS = finiteClampedFloat( + "caustica.rt.sharc.gridLevelBias", "sharc.grid-level-bias", 0.0f, -16.0f, 16.0f); + /** Additional minimum linear roughness for SHaRC diffuse ownership; zero preserves the mirror cutoff. */ + public static final FloatSetting ROUGHNESS_THRESHOLD = finiteClampedFloat( + "caustica.rt.sharc.roughnessThreshold", "sharc.roughness-threshold", 0.0f, 0.0f, 1.0f); + + private Sharc() { + } + } + public static final class Terrain { // External keys retain their historical "per-tick" names for config compatibility; terrain // streaming is render-pass driven and these Java names reflect the actual scheduling unit. @@ -912,6 +946,7 @@ private Sdr() { private static String sanitizeToneMapper(String value) { return dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping.SdrMode.parse(value).canonicalName(); } + } /** Render-frame timing + hitch logging. See {@code RtFrameStats}. */ diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java index c00b9e6f..09e1dbde 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java @@ -18,6 +18,7 @@ public final class CausticaClient implements ClientModInitializer { private static boolean rtInitDone = false; + private static boolean rtRestartRequired = false; @Override public void onInitializeClient() { @@ -32,6 +33,9 @@ public void onInitializeClient() { // The GpuDevice exists well before the first tick, so a one-shot at tick start // runs on the render thread with the device idle between frames. ClientTickEvents.START_CLIENT_TICK.register(client -> { + if (rtRestartRequired) { + return; + } if (!VanillaRenderController.rtRuntimeWorkRequested()) { if (rtInitDone) { shutdownRt(); @@ -100,7 +104,7 @@ private static void shutdownRt() { if (ctx != null) { RtEntities.INSTANCE.shutdown(); } - RtComposite.INSTANCE.destroy(); + boolean rrReleased = RtComposite.INSTANCE.destroy(); RtEntityTextures.INSTANCE.reset(); RtBlockMaterials.INSTANCE.destroy(); dev.comfyfluffy.caustica.rt.pipeline.RtDlssFg.INSTANCE.destroy(); @@ -108,11 +112,26 @@ private static void shutdownRt() { dev.comfyfluffy.caustica.rt.RtFramePresenter.INSTANCE.destroy(ctx.device()); dev.comfyfluffy.caustica.rt.RtReflex.INSTANCE.destroy(ctx.device().vkDevice()); } - // Shut NGX down once, after every feature (RR + FG) has been released above. - dev.comfyfluffy.caustica.ngx.NgxRuntime.INSTANCE.shutdown(); - if (ctx != null) { + // NGX and the Vulkan device may be destroyed only after every native feature released its owner. + boolean ngxReleased = rrReleased + && dev.comfyfluffy.caustica.ngx.NgxRuntime.INSTANCE.shutdown(); + boolean restartRequired = teardownRequiresRestart(rrReleased, ngxReleased); + if (ctx != null && !restartRequired) { ctx.destroy(); } rtInitDone = false; + if (restartRequired) { + rtRestartRequired = true; + CausticaMod.LOGGER.error("RT teardown retained native NGX ownership; restart is required"); + } + } + + static boolean teardownRequiresRestart(boolean rrReleased, boolean ngxReleased) { + return !rrReleased || !ngxReleased; + } + + /** Native teardown ownership failures keep RT off until process restart. */ + public static boolean rtRestartRequired() { + return rtRestartRequired; } } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaJitter.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaJitter.java index b8cac74b..f4c96848 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/CausticaJitter.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaJitter.java @@ -12,6 +12,7 @@ public final class CausticaJitter { public static final CausticaJitter INSTANCE = new CausticaJitter(); private int frameIndex; + private int phaseCount; private float pixelsX; private float pixelsY; @@ -19,13 +20,18 @@ private CausticaJitter() { } /** Advance one frame. Call once per frame before the level projection is built. */ - public void prepare(int renderWidth, int renderHeight, int displayWidth) { - int phaseCount = jitterPhaseCount(renderWidth, displayWidth); - int index = (this.frameIndex++ % phaseCount) + 1; // Halton(0) is degenerate + public void prepare(int renderWidth, int renderHeight, int displayWidth, int displayHeight) { + this.phaseCount = jitterPhaseCount(renderWidth, renderHeight, displayWidth, displayHeight); + int index = (this.frameIndex++ % this.phaseCount) + 1; // Halton(0) is degenerate this.pixelsX = halton(index, 2) - 0.5f; this.pixelsY = halton(index, 3) - 0.5f; } + /** Advance using a square display scale for callers that do not have the display height. */ + public void prepare(int renderWidth, int renderHeight, int displayWidth) { + prepare(renderWidth, renderHeight, displayWidth, displayWidth); + } + /** Jitter offset in render-pixel space, applied to the primary ray and reported to RR evaluate. */ public float jitterPixelsX() { return this.pixelsX; @@ -35,8 +41,22 @@ public float jitterPixelsY() { return this.pixelsY; } - private static int jitterPhaseCount(int renderWidth, int displayWidth) { - float ratio = (float) displayWidth / Math.max(1, renderWidth); + /** Reset the sequence and clear the current offset before a temporal A/B comparison. */ + public void reset() { + this.frameIndex = 0; + this.phaseCount = 0; + this.pixelsX = 0.0f; + this.pixelsY = 0.0f; + } + + public int currentPhaseCount() { + return this.phaseCount; + } + + static int jitterPhaseCount(int renderWidth, int renderHeight, int displayWidth, int displayHeight) { + float ratioX = (float) displayWidth / Math.max(1, renderWidth); + float ratioY = (float) displayHeight / Math.max(1, renderHeight); + float ratio = Math.max(ratioX, ratioY); return Math.max(32, (int) Math.ceil(8.0f * ratio * ratio)); } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtSharcOptionsScreen.java b/src/main/java/dev/comfyfluffy/caustica/client/RtSharcOptionsScreen.java new file mode 100644 index 00000000..827d2d60 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtSharcOptionsScreen.java @@ -0,0 +1,178 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.CausticaConfig.FloatSetting; +import dev.comfyfluffy.caustica.CausticaConfig.IntSetting; +import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.rt.RtSharcCache; +import java.util.List; +import java.util.Locale; +import net.minecraft.client.Minecraft; +import net.minecraft.client.OptionInstance; +import net.minecraft.client.Options; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.options.OptionsSubScreen; +import net.minecraft.network.chat.Component; + +/** Dedicated SHaRC controls for the directional-SH runtime that this jar actually packages. */ +public final class RtSharcOptionsScreen extends OptionsSubScreen { + private final Screen parentScreen; + + public RtSharcOptionsScreen(Screen parentScreen) { + super(parentScreen, Minecraft.getInstance().options, + Component.translatable("caustica.options.rt.sharcMenu.title")); + this.parentScreen = parentScreen; + } + + @Override + protected void addOptions() { + list.addHeader(Component.translatable("caustica.options.rt.sharcMenu.runtimeHeader")); + list.addSmall(sharcEnabled(), cacheExponent()); + list.addSmall(primarySurfaceDebug(), antiFirefly()); + list.addSmall(updateTileSize(), accumulationFrames()); + list.addSmall(staleFrames(), sceneScale()); + list.addSmall(radianceScale(), roughnessThreshold()); + list.addSmall(gridLogarithmBase(), gridLevelBias()); + + list.addHeader(Component.translatable("caustica.options.rt.sharcMenu.statusHeader")); + list.addSmall(List.of(disabledButton(Component.translatable( + "caustica.options.rt.sharcMenu.status.runtime", RtComposite.INSTANCE.sharcStatus())))); + long memoryMiB = RtSharcCache.memoryBytesForExponent(CausticaConfig.Rt.Sharc.CACHE_EXPONENT.value()) + / (1024L * 1024L); + list.addSmall(List.of(disabledButton(Component.translatable( + "caustica.options.rt.sharcMenu.status.memory", memoryMiB)))); + list.addSmall(List.of(disabledButton(Component.translatable( + "caustica.options.rt.sharcMenu.status.layout")))); + + list.addHeader(Component.translatable("caustica.options.rt.sharcMenu.actionsHeader")); + list.addSmall(List.of( + Button.builder(Component.translatable("caustica.options.rt.sharcMenu.clear"), button -> { + RtComposite.INSTANCE.requestSharcReset(); + Minecraft.getInstance().setScreenAndShow(new RtSharcOptionsScreen(parentScreen)); + }).build(), + Button.builder(Component.translatable("caustica.options.rt.sharcMenu.defaults"), button -> { + restoreDefaults(); + Minecraft.getInstance().setScreenAndShow(new RtSharcOptionsScreen(parentScreen)); + }).build())); + } + + @Override + public void removed() { + CausticaConfig.save(); + super.removed(); + } + + private static OptionInstance sharcEnabled() { + return bool("caustica.options.rt.sharcEnabled", CausticaConfig.Rt.Sharc.ENABLED); + } + + private static OptionInstance cacheExponent() { + IntSetting setting = CausticaConfig.Rt.Sharc.CACHE_EXPONENT; + return integer("caustica.options.rt.sharcCacheExponent", 16, 23, setting.value(), setting::set, + value -> "2^" + value); + } + + private static OptionInstance primarySurfaceDebug() { + return bool("caustica.options.rt.sharcPrimarySurfaceDebug", + CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG); + } + + private static OptionInstance antiFirefly() { + return bool("caustica.options.rt.sharcAntiFirefly", CausticaConfig.Rt.Sharc.ANTI_FIREFLY); + } + + private static OptionInstance updateTileSize() { + IntSetting setting = CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE; + return integer("caustica.options.rt.sharcUpdateTileSize", 2, 64, setting.value(), setting::set, + value -> value + "x" + value); + } + + private static OptionInstance accumulationFrames() { + IntSetting setting = CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES; + return integer("caustica.options.rt.sharcAccumulationFrames", 1, 1024, setting.value(), setting::set, + Object::toString); + } + + private static OptionInstance staleFrames() { + IntSetting setting = CausticaConfig.Rt.Sharc.STALE_FRAMES; + return integer("caustica.options.rt.sharcStaleFrames", 8, 1024, setting.value(), setting::set, + Object::toString); + } + + private static OptionInstance sceneScale() { + FloatSetting setting = CausticaConfig.Rt.Sharc.SCENE_SCALE; + return integer("caustica.options.rt.sharcSceneScale", 100, 10000, + Math.round(setting.value() * 100.0f), value -> setting.set(value / 100.0f), + value -> String.format(Locale.ROOT, "%.2f", value / 100.0f)); + } + + private static OptionInstance radianceScale() { + FloatSetting setting = CausticaConfig.Rt.Sharc.RADIANCE_SCALE; + return integer("caustica.options.rt.sharcRadianceScale", 50, 1000, + Math.round(setting.value()), value -> setting.set((float) value), Object::toString); + } + + private static OptionInstance roughnessThreshold() { + FloatSetting setting = CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD; + return integer("caustica.options.rt.sharcRoughnessThreshold", 0, 100, + Math.round(setting.value() * 100.0f), value -> setting.set(value / 100.0f), + value -> String.format(Locale.ROOT, "%.2f", value / 100.0f)); + } + + private static OptionInstance gridLogarithmBase() { + FloatSetting setting = CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE; + return integer("caustica.options.rt.sharcGridLogarithmBase", 101, 1600, + Math.round(setting.value() * 100.0f), value -> setting.set(value / 100.0f), + value -> String.format(Locale.ROOT, "%.2f", value / 100.0f)); + } + + private static OptionInstance gridLevelBias() { + FloatSetting setting = CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS; + return integer("caustica.options.rt.sharcGridLevelBias", -160, 160, + Math.round(setting.value() * 10.0f), value -> setting.set(value / 10.0f), + value -> String.format(Locale.ROOT, "%.1f", value / 10.0f)); + } + + private static OptionInstance bool(String key, CausticaConfig.BooleanSetting setting) { + return OptionInstance.createBoolean(key, + OptionInstance.cachedConstantTooltip(Component.translatable(key + ".tooltip")), + setting.value(), setting::set); + } + + private static OptionInstance integer(String key, int minimum, int maximum, int initial, + java.util.function.IntConsumer setter, + java.util.function.Function formatter) { + return new OptionInstance<>(key, + OptionInstance.cachedConstantTooltip(Component.translatable(key + ".tooltip")), + (caption, value) -> Options.genericValueLabel(caption, Component.literal(formatter.apply(value))), + new OptionInstance.IntRange(minimum, maximum), Math.clamp(initial, minimum, maximum), + setter::accept); + } + + private static Button disabledButton(Component label) { + Button button = Button.builder(label, ignored -> { }).build(); + button.active = false; + return button; + } + + private static void restoreDefaults() { + CausticaConfig.Rt.Sharc.ENABLED.set(CausticaConfig.Rt.Sharc.ENABLED.defaultValue()); + CausticaConfig.Rt.Sharc.CACHE_EXPONENT.set(CausticaConfig.Rt.Sharc.CACHE_EXPONENT.defaultValue()); + CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.set( + CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.defaultValue()); + CausticaConfig.Rt.Sharc.ANTI_FIREFLY.set(CausticaConfig.Rt.Sharc.ANTI_FIREFLY.defaultValue()); + CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.set(CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.defaultValue()); + CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.set( + CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.defaultValue()); + CausticaConfig.Rt.Sharc.STALE_FRAMES.set(CausticaConfig.Rt.Sharc.STALE_FRAMES.defaultValue()); + CausticaConfig.Rt.Sharc.SCENE_SCALE.set(CausticaConfig.Rt.Sharc.SCENE_SCALE.defaultValue()); + CausticaConfig.Rt.Sharc.RADIANCE_SCALE.set(CausticaConfig.Rt.Sharc.RADIANCE_SCALE.defaultValue()); + CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.set( + CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.defaultValue()); + CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.set(CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.defaultValue()); + CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.set( + CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.defaultValue()); + RtComposite.INSTANCE.requestSharcReset(); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java index 505dd4d6..f0df43e4 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/RtVideoOptions.java @@ -484,7 +484,7 @@ public static Button toneMappingButton(Screen parent, Runnable beforeOpen) { .build(); } - private static OptionInstance debugView() { + public static OptionInstance debugView() { IntSetting setting = CausticaConfig.Rt.Composite.DEBUG_VIEW; return new OptionInstance<>( "caustica.options.rt.debugView", @@ -492,8 +492,9 @@ private static OptionInstance debugView() { // CycleButton (used for Enum values) already prepends "caption: " itself (DisplayState. // NAME_AND_VALUE), so this must return only the value's text, not caption + value again. (caption, value) -> Component.translatable("caustica.options.rt.debugView." + value), - new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), Codec.INT), - Math.clamp(setting.value(), 0, 9), + new OptionInstance.Enum<>(List.of(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, + CausticaConfig.Rt.Composite.RAW_DEBUG_VIEW), Codec.INT), + Math.clamp(setting.value(), 0, CausticaConfig.Rt.Composite.RAW_DEBUG_VIEW), setting::set); } diff --git a/src/main/java/dev/comfyfluffy/caustica/client/VanillaRenderController.java b/src/main/java/dev/comfyfluffy/caustica/client/VanillaRenderController.java index 26eb31e9..c09f1e18 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/VanillaRenderController.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/VanillaRenderController.java @@ -31,7 +31,7 @@ public void beginFrame(RenderTarget mainTarget) { this.worldSkipped = false; this.baseReady = false; this.inactiveReason = null; - this.rtActive = RtComposite.enabled(); + this.rtActive = rtRuntimeWorkRequested(); if (!Boolean.valueOf(this.rtActive).equals(this.lastLoggedRtActive)) { this.lastLoggedRtActive = this.rtActive; @@ -105,7 +105,7 @@ public boolean shouldCompositeRt() { /** Runtime work switch for per-frame RT work; mirrors {@link RtComposite#enabled()}. */ public static boolean rtRuntimeWorkRequested() { - return RtComposite.enabled(); + return !CausticaClient.rtRestartRequired() && RtComposite.enabled(); } public void markRtCompositeResult(boolean success) { diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java index 15a7593b..30e725cb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java @@ -138,9 +138,9 @@ public abstract class GameRendererMixin { return projection; } - var cameraState = this.gameRenderState().levelRenderState.cameraRenderState; - RtComposite.INSTANCE.captureFrame(projection, cameraState.viewRotationMatrix, - cameraState.pos.x, cameraState.pos.y, cameraState.pos.z); + var cameraState = this.gameRenderState().levelRenderState.cameraRenderState; + RtComposite.INSTANCE.captureFrame(projection, cameraState.viewRotationMatrix, + cameraState.pos.x, cameraState.pos.y, cameraState.pos.z, cameraState.fogData); VanillaRenderController.INSTANCE.markProjectionCaptured(); return projection; } diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java index b7bf361e..4fe44c06 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VideoSettingsScreenMixin.java @@ -1,12 +1,14 @@ package dev.comfyfluffy.caustica.mixin; import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.client.RtSharcOptionsScreen; import dev.comfyfluffy.caustica.client.RtVideoOptions; import java.util.ArrayList; import java.util.List; import net.minecraft.client.Minecraft; import net.minecraft.client.OptionInstance; import net.minecraft.client.Options; +import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.OptionsList; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.options.VideoSettingsScreen; @@ -71,12 +73,19 @@ private static OptionInstance[] qualityOptions(Options options) { } list.addHeader(CAUSTICA$RT_HEADER); list.addSmall(RtVideoOptions.runtimeOptions()); - list.addSmall(List.of(RtVideoOptions.toneMappingButton( - (Screen) (Object) this, - () -> { - list.applyUnsavedChanges(); - CausticaConfig.save(); - }))); + Minecraft minecraft = Minecraft.getInstance(); + list.addSmall( + RtVideoOptions.debugView().createButton(minecraft.options), + RtVideoOptions.toneMappingButton( + (Screen) (Object) this, + () -> { + list.applyUnsavedChanges(); + CausticaConfig.save(); + })); + list.addSmall(List.of(Button.builder( + Component.translatable("caustica.options.rt.sharcMenu.open"), button -> + minecraft.setScreenAndShow(new RtSharcOptionsScreen((Screen) (Object) this))) + .build())); } @Inject(method = "removed", at = @At("TAIL")) diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java index eaf2334b..905a47cf 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanBackendMixin.java @@ -10,6 +10,7 @@ import com.mojang.blaze3d.vulkan.VulkanPhysicalDevice; import com.mojang.blaze3d.vulkan.init.VulkanFeature; import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.ngx.NgxRuntime; import dev.comfyfluffy.caustica.rt.RtDeviceBringup; import dev.comfyfluffy.caustica.rt.RtHdr; import dev.comfyfluffy.caustica.rt.VulkanDiagnostics; @@ -55,16 +56,9 @@ public abstract class VulkanBackendMixin { new VulkanFeature(VulkanBackend.VK10_FEATURES_STRUCT, "shaderInt16", VkPhysicalDeviceFeatures.SHADERINT16), new VulkanFeature(VulkanBackend.VK12_FEATURES_STRUCT, "shaderFloat16", VkPhysicalDeviceVulkan12Features.SHADERFLOAT16)); - private static final List CAUSTICA_WANTED_EXTENSIONS = List.of( - // FFX (FSR) + private static final List FFX_WANTED_EXTENSIONS = List.of( "VK_KHR_get_memory_requirements2", - "VK_KHR_dedicated_allocation", - // NGX (DLSS) — NVIDIA-only; skipped on other vendors. (The NGX instance - // extension VK_KHR_get_physical_device_properties2 needs an instance hook; - // DLSS relies on it being core/enabled at instance level.) - "VK_NVX_binary_import", - "VK_NVX_image_view_handle", - "VK_KHR_push_descriptor"); + "VK_KHR_dedicated_allocation"); private static final Set loggedMissingSdkFeatures = new HashSet<>(); @@ -90,7 +84,7 @@ public abstract class VulkanBackendMixin { Collection requested = args.get(0); var augmented = new ArrayList<>(requested); - for (String extension : CAUSTICA_WANTED_EXTENSIONS) { + for (String extension : FFX_WANTED_EXTENSIONS) { if (augmented.contains(extension)) { continue; } @@ -102,6 +96,10 @@ public abstract class VulkanBackendMixin { extension, physicalDevice.deviceName()); } } + if ("NVIDIA".equals(physicalDevice.vendorName())) { + NgxRuntime.INSTANCE.negotiateRequiredExtensions(true, augmented, + physicalDevice::hasDeviceExtension); + } VulkanDiagnostics.addDeviceFaultExtension(augmented, physicalDevice); RtHdr.addDeviceExtension(augmented, physicalDevice); RtDeviceBringup.addExtensions(augmented, physicalDevice); diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java index a0966e0d..3728ba9b 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/VulkanInstanceMixin.java @@ -3,6 +3,7 @@ import com.llamalad7.mixinextras.sugar.Local; import com.mojang.blaze3d.vulkan.VulkanInstance; import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.ngx.NgxRuntime; import dev.comfyfluffy.caustica.rt.VulkanDiagnostics; import java.util.Set; import org.lwjgl.vulkan.VkInstanceCreateInfo; @@ -15,10 +16,9 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; /** - * Enables {@code VK_EXT_swapchain_colorspace} at instance creation when the platform supports it. The - * extension exposes extended/HDR color spaces to {@code vkGetPhysicalDeviceSurfaceFormatsKHR}, allowing - * {@code VulkanGpuSurfaceMixin} to select an HDR10/PQ swapchain pair. The extension only adds color-space - * enum values; swapchain creation still explicitly chooses the active pair. + * Adds Caustica's supported Vulkan instance extensions before instance creation. Swapchain colorspace + * exposes HDR color spaces to {@code vkGetPhysicalDeviceSurfaceFormatsKHR}; NGX requirements come from the + * selected shim so its instance and device contracts stay in sync. * *

Gated on availability — requesting an unsupported instance extension would fail {@code vkCreateInstance} * and crash startup. @@ -41,6 +41,8 @@ public abstract class VulkanInstanceMixin { } else { CausticaMod.LOGGER.warn("Instance extension {} unavailable; HDR color spaces will not be queryable on this platform", SWAPCHAIN_COLORSPACE); } + NgxRuntime.INSTANCE.negotiateRequiredExtensions(false, this.enabledExtensions, + availableExtensions::contains); } @ModifyArg( diff --git a/src/main/java/dev/comfyfluffy/caustica/ngx/NgxLibrary.java b/src/main/java/dev/comfyfluffy/caustica/ngx/NgxLibrary.java index bcfea2a2..164a5924 100644 --- a/src/main/java/dev/comfyfluffy/caustica/ngx/NgxLibrary.java +++ b/src/main/java/dev/comfyfluffy/caustica/ngx/NgxLibrary.java @@ -18,8 +18,10 @@ * Vulkan handles (as {@code long} addresses). */ public final class NgxLibrary { + private static final int ABI_VERSION = 1; private static final Linker LINKER = Linker.nativeLinker(); + private final MethodHandle abiVersion; private final MethodHandle requiredExtensions; private final MethodHandle init; private final MethodHandle dlssAvailable; @@ -39,6 +41,8 @@ public final class NgxLibrary { private final MethodHandle lastResult; private NgxLibrary(SymbolLookup lookup) { + this.abiVersion = handle(lookup, "ngxshim_abi_version", + FunctionDescriptor.of(ValueLayout.JAVA_INT)); // int ngxshim_required_extensions(int wantDevice, char* outBuf, int bufLen) this.requiredExtensions = handle(lookup, "ngxshim_required_extensions", FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_INT)); @@ -79,8 +83,8 @@ private NgxLibrary(SymbolLookup lookup) { this.createDlssd = handle(lookup, "ngxshim_create_dlssd", FunctionDescriptor.of(ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT)); - // int ngxshim_evaluate_dlssd(cmd, feature, [color/depth/mv/diffAlbedo/specAlbedo/normals/specMotion/specHit/out: view,img,fmt]*9, rw,rh,dw,dh, jx,jy,mvsx,mvsy, reset, frameMs, matrices) - this.evaluateDlssd = handle(lookup, "ngxshim_evaluate_dlssd", + // int ngxshim_evaluate_dlssd_v2(cmd, feature, [color/depth/mv/diffAlbedo/specAlbedo/normals/specMotion/particle/responsivity/out: view,img,fmt]*10, rw,rh,dw,dh, jx,jy,mvsx,mvsy, reset, frameMs, matrices) + this.evaluateDlssd = handle(lookup, "ngxshim_evaluate_dlssd_v2", FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, @@ -92,6 +96,7 @@ private NgxLibrary(SymbolLookup lookup) { ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, + ValueLayout.JAVA_LONG, ValueLayout.JAVA_LONG, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_INT, ValueLayout.JAVA_FLOAT, ValueLayout.JAVA_FLOAT, ValueLayout.JAVA_FLOAT, ValueLayout.JAVA_FLOAT, ValueLayout.JAVA_INT, ValueLayout.JAVA_FLOAT, ValueLayout.ADDRESS, ValueLayout.ADDRESS)); @@ -124,13 +129,18 @@ private NgxLibrary(SymbolLookup lookup) { this.release = handle(lookup, "ngxshim_release", FunctionDescriptor.ofVoid(ValueLayout.ADDRESS)); this.shutdown = handle(lookup, "ngxshim_shutdown", - FunctionDescriptor.ofVoid(ValueLayout.JAVA_LONG)); + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.JAVA_LONG)); this.lastResult = handle(lookup, "ngxshim_last_result", FunctionDescriptor.of(ValueLayout.JAVA_INT)); } public static NgxLibrary load(Path dll) { - return new NgxLibrary(SymbolLookup.libraryLookup(dll, Arena.global())); + NgxLibrary library = new NgxLibrary(SymbolLookup.libraryLookup(dll, Arena.global())); + int actual = library.abiVersion(); + if (actual != ABI_VERSION) { + throw new IllegalStateException("ngxshim ABI mismatch: expected " + ABI_VERSION + ", got " + actual); + } + return library; } private static MethodHandle handle(SymbolLookup lookup, String name, FunctionDescriptor desc) { @@ -139,13 +149,19 @@ private static MethodHandle handle(SymbolLookup lookup, String name, FunctionDes desc); } - // For exports added later than the core ABI (e.g. DLSSG): a stale locally-built ngxshim.dll (the DLL is - // not rebuilt by gradle, only copied) must still load so DLSS-RR keeps working — the newer feature just - // reports unavailable. Returns null when the symbol is absent. + // Optional feature exports may be absent while the core shim ABI remains compatible. private static MethodHandle optionalHandle(SymbolLookup lookup, String name, FunctionDescriptor desc) { return lookup.find(name).map(sym -> LINKER.downcallHandle(sym, desc)).orElse(null); } + private int abiVersion() { + try { + return (int) this.abiVersion.invokeExact(); + } catch (Throwable t) { + throw new RuntimeException("ngxshim_abi_version failed", t); + } + } + public int requiredExtensions(boolean wantDevice, MemorySegment outBuf, int bufLen) { try { return (int) this.requiredExtensions.invokeExact(wantDevice ? 1 : 0, outBuf, bufLen); @@ -257,7 +273,8 @@ public int evaluateDlssd(long cmd, MemorySegment feature, long specularAlbedoView, long specularAlbedoImage, int specularAlbedoFormat, long normalsView, long normalsImage, int normalsFormat, long specularMotionView, long specularMotionImage, int specularMotionFormat, - long specularHitDistanceView, long specularHitDistanceImage, int specularHitDistanceFormat, + long particleMaskView, long particleMaskImage, int particleMaskFormat, + long responsivityMaskView, long responsivityMaskImage, int responsivityMaskFormat, long outputView, long outputImage, int outputFormat, int renderWidth, int renderHeight, int displayWidth, int displayHeight, float jitterX, float jitterY, float mvScaleX, float mvScaleY, @@ -272,13 +289,14 @@ public int evaluateDlssd(long cmd, MemorySegment feature, specularAlbedoView, specularAlbedoImage, specularAlbedoFormat, normalsView, normalsImage, normalsFormat, specularMotionView, specularMotionImage, specularMotionFormat, - specularHitDistanceView, specularHitDistanceImage, specularHitDistanceFormat, + particleMaskView, particleMaskImage, particleMaskFormat, + responsivityMaskView, responsivityMaskImage, responsivityMaskFormat, outputView, outputImage, outputFormat, renderWidth, renderHeight, displayWidth, displayHeight, jitterX, jitterY, mvScaleX, mvScaleY, reset, frameTimeMs, worldToViewMatrix, viewToClipMatrix); } catch (Throwable t) { - throw new RuntimeException("ngxshim_evaluate_dlssd failed", t); + throw new RuntimeException("ngxshim_evaluate_dlssd_v2 failed", t); } } @@ -359,9 +377,9 @@ public void release(MemorySegment feature) { } } - public void shutdown(long vkDevice) { + public int shutdown(long vkDevice) { try { - this.shutdown.invokeExact(vkDevice); + return (int) this.shutdown.invokeExact(vkDevice); } catch (Throwable t) { throw new RuntimeException("ngxshim_shutdown failed", t); } diff --git a/src/main/java/dev/comfyfluffy/caustica/ngx/NgxRuntime.java b/src/main/java/dev/comfyfluffy/caustica/ngx/NgxRuntime.java index a5cf77ba..27f5bd8f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/ngx/NgxRuntime.java +++ b/src/main/java/dev/comfyfluffy/caustica/ngx/NgxRuntime.java @@ -1,15 +1,13 @@ package dev.comfyfluffy.caustica.ngx; -import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.vulkan.VulkanDevice; import dev.comfyfluffy.caustica.CausticaConfig; import dev.comfyfluffy.caustica.CausticaMod; -import dev.comfyfluffy.caustica.mixin.GpuDeviceAccessor; - import net.fabricmc.loader.api.FabricLoader; import org.lwjgl.system.MemoryStack; +import org.lwjgl.vulkan.VK; import org.lwjgl.vulkan.VK10; import org.lwjgl.vulkan.VkInstance; @@ -25,7 +23,11 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Stream; /** @@ -43,18 +45,27 @@ public final class NgxRuntime { private NgxLibrary lib; private boolean initialized; private boolean failed; + private long initializedDevice; + private boolean instanceExtensionsNegotiated; + private boolean deviceExtensionsNegotiated; + private boolean extensionNegotiationFailed; private NgxRuntime() { } /** * Ensure NGX is loaded and initialized for {@code device}, returning the shared {@link NgxLibrary}, or - * {@code null} if it is unavailable. Idempotent; latches failure so it isn't retried every frame - * (cleared by {@link #shutdown()} so a fresh device can re-init). + * {@code null} if it is unavailable. Idempotent; latches initialization failure so it is not retried + * every frame. Extension negotiation remains fail-closed for the current Vulkan instance. */ public synchronized NgxLibrary acquire(VulkanDevice device) { if (initialized) { - return lib; + if (initializedDevice == device.vkDevice().address()) { + return lib; + } + if (!shutdown()) { + return null; + } } if (failed) { return null; @@ -62,9 +73,11 @@ public synchronized NgxLibrary acquire(VulkanDevice device) { try { init(device); initialized = true; + initializedDevice = device.vkDevice().address(); return lib; } catch (Throwable t) { failed = true; + initializedDevice = 0L; lib = null; CausticaMod.LOGGER.error("NGX init failed; DLSS features disabled", t); return null; @@ -75,27 +88,85 @@ public synchronized boolean isInitialized() { return initialized; } - /** The shared library once {@link #acquire} has succeeded, else {@code null}. */ - public NgxLibrary library() { - return lib; + /** Allow an explicit render-state recovery action to retry a failed shared NGX initialization. */ + public synchronized void resetFailureLatch() { + if (!initialized && !extensionNegotiationFailed) { + failed = false; + } + } + + /** Query the shim before Vulkan creation and add every NGX-required extension only as one valid set. */ + public synchronized void negotiateRequiredExtensions(boolean deviceExtensions, + Collection requested, + Predicate supported) { + if (!deviceExtensions && !initialized) { + instanceExtensionsNegotiated = false; + deviceExtensionsNegotiated = false; + extensionNegotiationFailed = false; + failed = false; + } + if (extensionNegotiationFailed) { + return; + } + String scope = deviceExtensions ? "device" : "instance"; + try { + if (!PLATFORM_NATIVES.supported()) { + throw new IllegalStateException("NGX natives are not bundled for " + + PLATFORM_NATIVES.platformDir()); + } + Path shim = locateShim(); + if (shim == null) { + throw new IllegalStateException(PLATFORM_NATIVES.shimName() + " is unavailable"); + } + if (lib == null) { + lib = NgxLibrary.load(shim); + } + List required = queryRequiredExtensions(lib, deviceExtensions); + List missing = required.stream().filter(extension -> !supported.test(extension)).toList(); + if (!missing.isEmpty()) { + throw new IllegalStateException("required " + scope + " extensions are unavailable: " + missing); + } + for (String extension : required) { + if (requested.add(extension)) { + CausticaMod.LOGGER.info("Enabling {} extension {} required by NGX", scope, extension); + } + } + if (deviceExtensions) { + deviceExtensionsNegotiated = true; + } else { + instanceExtensionsNegotiated = true; + } + } catch (Throwable t) { + extensionNegotiationFailed = true; + failed = true; + lib = null; + CausticaMod.LOGGER.warn("NGX {} extension negotiation failed; DLSS features disabled", scope, t); + } } /** - * Shut down NGX. Call only at device teardown, after every feature has been released. Resolves the - * device from the current render backend; no-op if NGX was never initialized. + * Shut down NGX. Call only at device teardown, after every feature has been released. Uses the + * device captured at initialization; no-op if NGX was never initialized. Returns false while NGX + * retains native device ownership and the Vulkan device must stay alive. */ - public synchronized void shutdown() { - if (lib != null && initialized - && ((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device) { + public synchronized boolean shutdown() { + if (lib != null && initialized && initializedDevice != 0L) { try { - lib.shutdown(device.vkDevice().address()); + int result = lib.shutdown(initializedDevice); + if (ngxFailed(result)) { + throw new IllegalStateException("ngxshim_shutdown returned 0x" + + Integer.toHexString(result)); + } } catch (Throwable t) { - CausticaMod.LOGGER.warn("NGX shutdown failed", t); + CausticaMod.LOGGER.warn("NGX shutdown failed; native ownership is retained until restart", t); + return false; } } initialized = false; - failed = false; + failed = extensionNegotiationFailed; + initializedDevice = 0L; lib = null; + return true; } /** NVSDK_NGX_Result: failure when the top 12 bits == 0xBAD. Shared by all NGX feature wrappers. */ @@ -104,6 +175,9 @@ public static boolean ngxFailed(int result) { } private void init(VulkanDevice device) { + if (extensionNegotiationFailed || !instanceExtensionsNegotiated || !deviceExtensionsNegotiated) { + throw new IllegalStateException("NGX Vulkan extensions were not negotiated before device creation"); + } if (!PLATFORM_NATIVES.supported()) { throw new IllegalStateException("NGX natives are not bundled for " + PLATFORM_NATIVES.platformDir()); } @@ -132,13 +206,17 @@ private void init(VulkanDevice device) { VkInstance instance = device.vkDevice().getPhysicalDevice().getInstance(); try (Arena arena = Arena.ofConfined()) { + long gipa = VK.getFunctionProvider().getFunctionAddress("vkGetInstanceProcAddr"); + if (gipa == 0L) { + throw new IllegalStateException("vkGetInstanceProcAddr is unavailable"); + } long gdpa; try (MemoryStack stack = MemoryStack.stackPush()) { gdpa = VK10.vkGetInstanceProcAddr(instance, stack.ASCII("vkGetDeviceProcAddr")); } int rc = lib.init(0L, wideString(arena, dataPath.toString()), instance.address(), device.vkDevice().getPhysicalDevice().address(), device.vkDevice().address(), - 0L, gdpa, wideString(arena, nativesDir == null ? "" : nativesDir.toString())); + gipa, gdpa, wideString(arena, nativesDir == null ? "" : nativesDir.toString())); if (ngxFailed(rc)) { throw new IllegalStateException("ngxshim_init failed: 0x" + Integer.toHexString(rc) + " last=0x" + Integer.toHexString(lib.lastResult())); @@ -147,6 +225,29 @@ private void init(VulkanDevice device) { CausticaMod.LOGGER.info("NGX initialized (shim {})", shim); } + private static List queryRequiredExtensions(NgxLibrary library, boolean deviceExtensions) { + final int capacity = 8192; + try (Arena arena = Arena.ofConfined()) { + MemorySegment buffer = arena.allocate(capacity, 1); + int count = library.requiredExtensions(deviceExtensions, buffer, capacity); + if (count < 0) { + throw new IllegalStateException("ngxshim_required_extensions returned " + count); + } + byte[] bytes = buffer.toArray(ValueLayout.JAVA_BYTE); + int length = 0; + while (length < bytes.length && bytes[length] != 0) { + length++; + } + List extensions = new String(bytes, 0, length, StandardCharsets.UTF_8).lines() + .map(String::strip).filter(name -> !name.isEmpty()).toList(); + if (extensions.size() != count) { + throw new IllegalStateException("NGX extension list was truncated or malformed: expected " + + count + " names, got " + extensions.size()); + } + return extensions; + } + } + private static Path locateShim() { String override = CausticaConfig.Ngx.PATH.get(); if (override != null && !override.isBlank()) { @@ -189,11 +290,26 @@ private static boolean extractBundledNative(String name, Path dst) throws IOExce } private static void extractBundledFeatureLibraries(Path dir) throws IOException { + Set current = new HashSet<>(); for (String name : PLATFORM_NATIVES.exactFeatureNames()) { - extractBundledNative(name, dir.resolve(name)); + if (extractBundledNative(name, dir.resolve(name))) { + current.add(name); + } } for (String name : bundledFeatureLibraryNames()) { - extractBundledNative(name, dir.resolve(name)); + if (extractBundledNative(name, dir.resolve(name))) { + current.add(name); + } + } + List stale; + try (Stream files = Files.list(dir)) { + stale = files.filter(Files::isRegularFile) + .filter(path -> PLATFORM_NATIVES.isFeatureLibrary(path.getFileName().toString())) + .filter(path -> !current.contains(path.getFileName().toString())) + .toList(); + } + for (Path path : stale) { + Files.deleteIfExists(path); } } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index f4ca0a6c..e8464f21 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -5,6 +5,7 @@ import com.mojang.blaze3d.textures.GpuTexture; import com.mojang.blaze3d.textures.GpuTextureView; import com.mojang.blaze3d.vulkan.VulkanCommandEncoder; +import com.mojang.blaze3d.vulkan.VulkanGpuSampler; import com.mojang.blaze3d.vulkan.VulkanGpuTexture; import com.mojang.blaze3d.vulkan.VulkanGpuTextureView; import dev.comfyfluffy.caustica.CausticaConfig; @@ -13,6 +14,7 @@ import dev.comfyfluffy.caustica.mixin.CommandEncoderAccessor; import dev.comfyfluffy.caustica.rt.gen.WorldPushConstantsData; import dev.comfyfluffy.caustica.rt.gen.WorldPushData; +import dev.comfyfluffy.caustica.rt.gen.SharcPushConstantsData; import dev.comfyfluffy.caustica.rt.gen.WorldPushData.BreakEntry; import dev.comfyfluffy.caustica.rt.gen.WorldPushData.Float2; import dev.comfyfluffy.caustica.rt.gen.WorldPushData.Float3; @@ -20,6 +22,9 @@ import dev.comfyfluffy.caustica.rt.gen.WorldPushData.Int4; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.BiomeColors; +import net.minecraft.client.renderer.EndFlashState; +import net.minecraft.client.renderer.fog.FogData; +import net.minecraft.client.renderer.texture.AbstractTexture; import net.minecraft.client.renderer.texture.TextureAtlas; import net.minecraft.client.renderer.texture.TextureAtlasSprite; import net.minecraft.client.resources.model.ModelBakery; @@ -30,6 +35,7 @@ import net.minecraft.util.Mth; import net.minecraft.world.attribute.EnvironmentAttributes; import net.minecraft.world.level.MoonPhase; +import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.material.FluidState; import org.joml.Matrix4f; import org.joml.Matrix4fc; @@ -67,9 +73,10 @@ import dev.comfyfluffy.caustica.rt.pipeline.RtHdrCompositePipeline; import dev.comfyfluffy.caustica.rt.pipeline.RtSdrPresentPipeline; import dev.comfyfluffy.caustica.rt.pipeline.RtExposure; -import dev.comfyfluffy.caustica.rt.pipeline.RtPathSamplerData; import dev.comfyfluffy.caustica.rt.pipeline.RtPipeline; +import dev.comfyfluffy.caustica.rt.pipeline.RtPathSamplerData; import dev.comfyfluffy.caustica.rt.pipeline.RtToneLut; +import dev.comfyfluffy.caustica.rt.pipeline.RtSharcResolvePipeline; import dev.comfyfluffy.caustica.rt.pipeline.RtToneMapping; import dev.comfyfluffy.caustica.rt.terrain.RtTerrain; @@ -94,6 +101,8 @@ */ public final class RtComposite { public static final RtComposite INSTANCE = new RtComposite(); + /** Debug value that exposes the path-traced image before DLSS-RR/reconstruction. */ + public static final int RAW_DEBUG_VIEW = CausticaConfig.Rt.Composite.RAW_DEBUG_VIEW; public static boolean enabled() { return CausticaConfig.Rt.ENABLED.value(); @@ -114,6 +123,10 @@ private static int debugView() { return CausticaConfig.Rt.Composite.DEBUG_VIEW.value(); } + private static boolean rawDebugView() { + return debugView() == RAW_DEBUG_VIEW; + } + private static int spp() { return CausticaConfig.Rt.Composite.SPP.value(); } @@ -138,6 +151,8 @@ private static boolean waterWaves() { // package's angular radii, which only jitter the shadow ray and so only set penumbra softness. private static final RtLookPackage LOOK = RtLookPackage.current(); private static final Identifier SUN_ID = Identifier.withDefaultNamespace("sun"); + private static final Identifier END_FLASH_ID = Identifier.withDefaultNamespace("end_flash"); + private static final Identifier END_SKY_ID = Identifier.withDefaultNamespace("textures/environment/end_sky.png"); private static final Identifier[] MOON_IDS = createMoonIds(); // Sign of the sub-pixel jitter as reported to DLSS-RR + applied to the primary ray, mirroring the // validated DLSS-SR convention (Vulkan flipped clip space wants Y negated). @@ -157,11 +172,26 @@ public static long frameCounter() { } private RtPipeline worldPipeline; - private RtPathSamplerData pathSamplerData; - private long pathSampleCursor; - private int pathSampleEpoch; - private boolean pathSamplerResetPending = true; - private long pathSamplingPolicySignature = Long.MIN_VALUE; + private RtPipeline sharcQueryPipeline; + private RtPipeline sharcUpdatePipeline; + private RtSharcResolvePipeline sharcResolvePipeline; + private RtSharcCache sharcCache; + private int sharcResourceExponent = -1; + private boolean sharcUsesSer; + private Object sharcWorldIdentity; + private Object sharcDimensionIdentity; + private int sharcTerrainX; + private int sharcTerrainY; + private int sharcTerrainZ; + private long sharcMaterialEpoch = -1L; + private long sharcSettingsSignature = Long.MIN_VALUE; + private int sharcRenderWidth = -1; + private int sharcRenderHeight = -1; + private double sharcLastCameraX; + private double sharcLastCameraY; + private double sharcLastCameraZ; + private boolean sharcLastCameraValid; + private SharcSkyState sharcLastSkyState; // Set at the HEAD of Minecraft.reloadResourcePacks() (mixin): a resource reload recreates the block // atlas + entity textures. We tear down the world pipeline there (drops all descriptor references) and // rebuild it once the NEW atlas is in place — detected by the atlas view handle changing away from @@ -196,6 +226,11 @@ public static long frameCounter() { // Packed primary -> indirect continuations. Pass A is fixed at one sample and owns two records per // render pixel (base + optional transmission); Pass B resamples them at the configured SPP. private RtBuffer continuationQueue; + private RtPathSamplerData pathSamplerData; + private long pathSampleCursor; + private int pathSampleEpoch; + private boolean pathSamplerResetPending = true; + private long pathSamplingPolicySignature = Long.MIN_VALUE; private RtImage displayImage; // Bloom pyramid, finest first: level 0 is half display resolution and each level halves again. The // display mapper reads level 0, which the upsample sweep leaves holding the sum of every band. @@ -229,6 +264,7 @@ private static final class PushSlot { this.buffer = buffer; } } + // Menu/non-RT present: converts the SDR main target (sRGB) to PQ-encoded at paper white so menus, // the title panorama and the loading screen present correctly to the PQ swapchain instead of being // raw-copied (misdisplayed). Lazily created; the image is sized to the swapchain. @@ -247,13 +283,17 @@ private static final class PushSlot { private final Matrix4f fgPrevToClip = new Matrix4f(); private final Matrix4f fgMatTmp = new Matrix4f(); // Guide buffers (first-hit attributes for DLSS-RR): normal+roughness, albedo, depth, motion, - // specular albedo, and reflection motion. + // specular albedo, reflection motion, DLSSD responsivity, primary-sky display classification, + // and particle classification. private RtImage gNormal; private RtImage gAlbedo; private RtImage gDepth; private RtImage gMotion; private RtImage gSpecAlbedo; private RtImage gSpecMotion; + private RtImage gResponsivity; + private RtImage gParticleMask; + private RtImage gSkyClassification; // 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; @@ -306,6 +346,18 @@ private static final class PushSlot { private float moonV0; private float moonU1 = 1f; private float moonV1 = 1f; + private float endFlashU0; + private float endFlashV0; + private float endFlashU1 = 1f; + private float endFlashV1 = 1f; + private int frameSkyboxMode = RtSkyMath.SKYBOX_OVERWORLD; + private boolean frameSkyboxValid; + private float frameSkyColorR; + private float frameSkyColorG; + private float frameSkyColorB; + private float frameSkyColorA = 1.0f; + private boolean endFlashStateValid; + private boolean previousEndFlashActive; // Per-frame TLAS resources, rebuilt in place from a small ring of persistent slots (see // RtAccel.TlasRing — replaces the old create-and-defer-destroy-per-frame churn whose VMA slow path @@ -476,6 +528,10 @@ public boolean requiresVanillaWorldFallback() { if (worldPipeline == null || !materialBindingsReady) { return true; } + EndSkyBinding endSky = endSkyBinding(); + if (endSky.view() == 0L || endSky.sampler() == 0L) { + return true; + } if (materialEpochTraceGate) { return true; } @@ -499,22 +555,78 @@ public void resetFailureLatch() { failed = false; CausticaMod.LOGGER.info("RT failure latch cleared by render-state invalidation; retrying RT"); } + RtDlssRr.INSTANCE.resetFailureLatch(); } - /** Capture the frame's camera for the next composite. Called from GameRendererMixin. */ - public void captureFrame(Matrix4f projection, Matrix4fc viewRotation, double cameraX, double cameraY, double cameraZ) { + /** Capture one coherent camera, dimension-sky, and vanilla sky-color snapshot for the next composite. */ + public void captureFrame(Matrix4f projection, Matrix4fc viewRotation, double cameraX, double cameraY, double cameraZ, + FogData vanillaFogData) { frameProjection.set(projection); frameViewRotation.set(viewRotation); camX = cameraX; camY = cameraY; camZ = cameraZ; + Minecraft mc = Minecraft.getInstance(); + int skybox = RtSkyMath.skyboxMode(mc.level == null + ? DimensionType.Skybox.OVERWORLD : mc.level.dimensionType().skybox()); + if (frameSkyboxValid && frameSkyboxMode != skybox) { + RtDlssRr.INSTANCE.requestHistoryReset(); + } + frameSkyboxMode = skybox; + frameSkyboxValid = true; + captureSkyColor(vanillaFogData); frameCaptured = true; } + /** Read vanilla's resolved sky color for End-sky compositing without modifying the fog pipeline. */ + private void captureSkyColor(FogData vanillaFogData) { + float skyR = 0.0f; + float skyG = 0.0f; + float skyB = 0.0f; + float skyA = 1.0f; + if (vanillaFogData != null && vanillaFogData.color != null) { + var color = vanillaFogData.color; + skyR = RtSkyMath.srgbToLinear(finiteColor(color.x())); + skyG = RtSkyMath.srgbToLinear(finiteColor(color.y())); + skyB = RtSkyMath.srgbToLinear(finiteColor(color.z())); + skyA = finiteColor(color.w()); + } + frameSkyColorR = skyR; + frameSkyColorG = skyG; + frameSkyColorB = skyB; + frameSkyColorA = skyA; + } + + private static float finiteColor(float value) { + return Float.isFinite(value) ? Math.clamp(value, 0.0f, 1.0f) : 0.0f; + } + /** Reset exposure filtering after an explicit render-state invalidation such as F3+A. */ public void resetExposureHistory() { + requestTemporalReset(); + } + + /** Clear every temporal input before a controlled renderer comparison or explicit scene invalidation. */ + public void requestTemporalReset() { + requestTemporalReset(true); + } + + /** Reset reconstruction while optionally retaining the valid exposure image and latch. */ + public void requestTemporalReset(boolean resetExposureHistory) { pathSamplerResetPending = true; - exposure.requestReset(); + resetTemporalConsumers(resetExposureHistory); + } + + private void resetTemporalConsumers(boolean resetExposureHistory) { + CausticaJitter.INSTANCE.reset(); + RtDlssRr.INSTANCE.requestHistoryReset(); + if (resetExposureHistory) { + exposure.requestReset(); + } + requestSharcReset(); + mvHasPrev = false; + waterWaveTimeValid = false; + fgReset = true; } private void refreshPathSamplingPolicy(int frameSpp) { @@ -523,11 +635,12 @@ private void refreshPathSamplingPolicy(int frameSpp) { if (pathSamplingPolicySignature != signature) { pathSamplingPolicySignature = signature; pathSamplerResetPending = true; - resetPathSamplingConsumers(); + // SPP and estimator-shape changes invalidate reconstruction but not the exposure estimate. + resetTemporalConsumers(false); } if (!pathSamplerResetPending && pathSampleCursor > PATH_SAMPLE_INDEX_LIMIT - reservation) { pathSamplerResetPending = true; - resetPathSamplingConsumers(); + resetTemporalConsumers(false); } if (pathSamplerResetPending) { pathSampleCursor = 0L; @@ -539,12 +652,6 @@ private void refreshPathSamplingPolicy(int frameSpp) { } } - private void resetPathSamplingConsumers() { - mvHasPrev = false; - waterWaveTimeValid = false; - fgReset = true; - } - private long pathSamplingPolicySignature(int frameSpp) { int bounceCount = maxBounces(); if (bounceCount < 0 || bounceCount > RtPathSamplerData.MAX_SUPPORTED_BOUNCE) { @@ -560,6 +667,7 @@ private long pathSamplingPolicySignature(int frameSpp) { signature = signature * 31L + frameSpp; signature = signature * 31L + bounceCount; signature = signature * 31L + risCandidates; + signature = signature * 31L + (CausticaConfig.Rt.Sharc.ENABLED.value() ? 1L : 0L); return signature; } @@ -647,13 +755,12 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { if (ctx == null) { return false; } - ctx.gpuExecutor().throwIfFailed(); // Count-bounded terrain streaming (dispatch/drain/build kick) runs here once per render frame — before // the ready gate below, because it is what MAKES terrain ready during the initial fill. try { + ctx.gpuExecutor().throwIfFailed(); RtTerrain.frame(ctx); } catch (Throwable t) { - ctx.gpuExecutor().throwIfFailed(); failed = true; CausticaMod.LOGGER.error("RT terrain streaming failed; reverting to vanilla path", t); return false; @@ -728,9 +835,17 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { // hdrToneLut/lookLut may have been hot-swapped just above; setImages is a no-op if the bound // views already match, so this is cheap on every other frame. RtToneLut boundLookLut = lookLut; + EndSkyBinding endSky = requireEndSkyBinding(); + long fallbackAtlasView = blockAlbedoAtlasView(); + long celestialsView = celestialsAtlasView(); + long atlasSamplerHandle = atlasSampler(ctx); displayPipeline.setImages(displayImage.view, rrOutput.view, exposure.image().view, hdrDisplayImage.view, sdrToneLut.view(), sdrToneLut.sampler(), hdrToneLut.view(), hdrToneLut.sampler(), - boundLookLut.view(), boundLookLut.sampler(), bloomLevels[0].view, bloomPipeline.sampler()); + boundLookLut.view(), boundLookLut.sampler(), bloomLevels[0].view, bloomPipeline.sampler(), + gSkyClassification.view, + endSky.view(), endSky.sampler(), + celestialsView != 0L ? celestialsView : fallbackAtlasView, + atlasSamplerHandle); bloomPipeline.setImages(rrOutput.view, exposure.image().view, bloomLevels); debugPresentPipeline.setImages(displayImage.view, gNormal.view, gAlbedo.view, gDepth.view, gMotion.view, gSpecAlbedo.view, gSpecMotion.view, rrOutput.view, exposure.image().view, @@ -746,6 +861,7 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { return false; } refreshMaterialBindingsIfNeeded(ctx); + syncSharcResources(ctx); int frameSpp = spp(); refreshPathSamplingPolicy(frameSpp); updateMotion(); @@ -755,8 +871,9 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { CausticaMod.LOGGER.info("RT composite active (terrain): {}x{}, RT output replaces the world target", width, height); } return true; + } catch (EndSkyUnavailableException e) { + return false; } catch (Throwable t) { - ctx.gpuExecutor().throwIfFailed(); failed = true; CausticaMod.LOGGER.error("RT composite failed; reverting to vanilla path", t); return false; @@ -780,6 +897,8 @@ public void ensureResourcesReady(RtContext ctx) { } try { ensureWorld(ctx); + } catch (EndSkyUnavailableException e) { + CausticaMod.LOGGER.debug("RT resource bring-up waiting for the vanilla End sky texture"); } catch (Throwable t) { failed = true; CausticaMod.LOGGER.error("RT resource bring-up failed; reverting to vanilla path", t); @@ -829,6 +948,217 @@ private RtPipeline ensureWorld(RtContext ctx) { return worldPipeline; } + private boolean sharcRequested() { + return CausticaConfig.Rt.Sharc.ENABLED.value(); + } + + private boolean sharcActive() { + return sharcRequested() && debugView() == 0 && RtSharcSupport.available() + && sharcCache != null && sharcQueryPipeline != null + && sharcUpdatePipeline != null && sharcResolvePipeline != null; + } + + /** User-facing effective state for the dedicated SHaRC options page. */ + public String sharcStatus() { + if (!RtSharcSupport.available()) { + return RtSharcSupport.status(); + } + if (!sharcRequested()) { + return "off"; + } + if (debugView() != 0) { + return "paused while a renderer debug view is selected"; + } + if (sharcActive()) { + return CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.value() + ? "active - primary-surface debug" : "active - secondary paths"; + } + return sharcResourcesPresent() ? "initializing" : "ready - activates while rendering"; + } + + /** Request a timeline-safe clear; harmless while the lazy SHaRC cache is not allocated. */ + public void requestSharcReset() { + if (sharcCache != null) { + sharcCache.requestReset(); + } + } + + private boolean sharcResourcesPresent() { + return sharcCache != null || sharcQueryPipeline != null + || sharcUpdatePipeline != null || sharcResolvePipeline != null; + } + + private void syncSharcResources(RtContext ctx) { + boolean present = sharcCache != null || sharcQueryPipeline != null + || sharcUpdatePipeline != null || sharcResolvePipeline != null; + if (!sharcRequested() || !RtSharcSupport.available()) { + if (present) { + ctx.waitIdle(); + destroySharcResources(); + } + return; + } + RtTerrain terrain = RtTerrain.currentOrNull(); + if (worldPipeline == null || output == null || gNormal == null || !materialBindingsReady || terrain == null) { + return; + } + int exponent = CausticaConfig.Rt.Sharc.CACHE_EXPONENT.value(); + boolean ser = RtDeviceBringup.serExtEnabled(); + boolean recreate = !present || sharcResourceExponent != exponent || sharcUsesSer != ser + || sharcRenderWidth != renderW || sharcRenderHeight != renderH; + if (!recreate) { + return; + } + if (present) { + ctx.waitIdle(); + destroySharcResources(); + } + try { + String query = ser ? "indirect_sharc_ser_query.rgen.spv" : "indirect_sharc_query.rgen.spv"; + String update = ser ? "indirect_sharc_ser_update.rgen.spv" : "indirect_sharc_update.rgen.spv"; + sharcQueryPipeline = RtPipeline.create(ctx, new String[]{query}, + new String[]{"sky.rmiss.spv", "guide.rmiss.spv"}, + "closest_hit.rchit.spv", "any_hit.rahit.spv", + SharcPushConstantsData.BYTE_SIZE, bindlessTextureCapacity); + sharcUpdatePipeline = RtPipeline.create(ctx, new String[]{update}, + new String[]{"sky.rmiss.spv", "guide.rmiss.spv"}, + "closest_hit.rchit.spv", "any_hit.rahit.spv", + SharcPushConstantsData.BYTE_SIZE, bindlessTextureCapacity); + sharcResolvePipeline = RtSharcResolvePipeline.create(ctx); + sharcCache = RtSharcCache.create(ctx, exponent); + long sampler = atlasSampler(ctx); + long atlas = blockAlbedoAtlasView(); + bindSharcPipeline(sharcQueryPipeline, sampler, atlas); + bindSharcPipeline(sharcUpdatePipeline, sampler, atlas); + RtEntityTextures.INSTANCE.uploadAll(sampler, worldPipeline, sharcQueryPipeline, sharcUpdatePipeline); + sharcResourceExponent = sharcCache.exponent(); + sharcUsesSer = ser; + sharcRenderWidth = renderW; + sharcRenderHeight = renderH; + sharcWorldIdentity = Minecraft.getInstance().level; + sharcDimensionIdentity = Minecraft.getInstance().level.dimension(); + sharcTerrainX = terrain.blockX; + sharcTerrainY = terrain.blockY; + sharcTerrainZ = terrain.blockZ; + sharcMaterialEpoch = RtMaterialRegistry.INSTANCE.epoch(); + sharcSettingsSignature = sharcSettingsSignature(); + sharcLastCameraValid = false; + sharcLastSkyState = null; + sharcCache.requestReset(); + CausticaMod.LOGGER.info("SHaRC 1.8 directional resources enabled: exponent={}, capacity={}, SER={}", + sharcResourceExponent, sharcCache.capacity(), ser); + } catch (Throwable t) { + try { + destroySharcResources(); + } catch (Throwable cleanupFailure) { + t.addSuppressed(cleanupFailure); + } + RtSharcSupport.fail("resource or pipeline creation failed", t); + } + } + + private void bindSharcPipeline(RtPipeline pipeline, long sampler, long atlasView) { + pipeline.setStorageImage(output.view); + bindGuideImages(pipeline); + pipeline.setBlockAlbedoAtlas(atlasView, sampler); + pipeline.setEntityAlbedoTexture(0, atlasView, sampler); + RtBlockMaterials.INSTANCE.bindPages(sampler, pipeline); + long celestials = celestialsAtlasView(); + pipeline.setSkyAtlas(celestials != 0L ? celestials : atlasView, sampler); + EndSkyBinding endSky = requireEndSkyBinding(); + pipeline.setEndSkyTexture(endSky.view(), endSky.sampler()); + if (skyLut != null) { + pipeline.setSkyLuts(skyLut.skyViewView(), skyLut.transmittanceView(), skyLut.sampler()); + } + } + + private void destroySharcResources() { + if (sharcResolvePipeline != null) { + sharcResolvePipeline.destroy(); + sharcResolvePipeline = null; + } + if (sharcUpdatePipeline != null) { + sharcUpdatePipeline.destroy(); + sharcUpdatePipeline = null; + } + if (sharcQueryPipeline != null) { + sharcQueryPipeline.destroy(); + sharcQueryPipeline = null; + } + if (sharcCache != null) { + sharcCache.destroy(); + sharcCache = null; + } + sharcResourceExponent = -1; + sharcUsesSer = false; + sharcWorldIdentity = null; + sharcDimensionIdentity = null; + sharcMaterialEpoch = -1L; + sharcSettingsSignature = Long.MIN_VALUE; + sharcRenderWidth = -1; + sharcRenderHeight = -1; + sharcLastCameraValid = false; + sharcLastSkyState = null; + } + + private long sharcSettingsSignature() { + long signature = 17L; + signature = signature * 31L + spp(); + signature = signature * 31L + maxBounces(); + signature = signature * 31L + (waterWaves() ? 1L : 0L); + signature = signature * 31L + CausticaConfig.Rt.Lights.RIS_CANDIDATES.value(); + signature = signature * 31L + (CausticaConfig.Rt.Sharc.ANTI_FIREFLY.value() ? 1L : 0L); + signature = signature * 31L + (CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.value() ? 1L : 0L); + signature = signature * 31L + CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.value(); + signature = signature * 31L + CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.value(); + signature = signature * 31L + CausticaConfig.Rt.Sharc.STALE_FRAMES.value(); + signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.SCENE_SCALE.value()); + signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.RADIANCE_SCALE.value()); + signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.value()); + signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.value()); + signature = signature * 31L + Float.floatToIntBits(CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.value()); + return signature; + } + + private void updateSharcResetPolicy(RtTerrain terrain, SkyPush sky) { + if (sharcCache == null) return; + var level = Minecraft.getInstance().level; + Object dimension = level != null ? level.dimension() : null; + if (sharcWorldIdentity != level || !Objects.equals(sharcDimensionIdentity, dimension) + || sharcTerrainX != terrain.blockX || sharcTerrainY != terrain.blockY || sharcTerrainZ != terrain.blockZ + || sharcMaterialEpoch != RtMaterialRegistry.INSTANCE.epoch() + || sharcSettingsSignature != sharcSettingsSignature() + || sharcRenderWidth != renderW || sharcRenderHeight != renderH) { + sharcCache.requestReset(); + } + if (!sharcLastCameraValid || !Double.isFinite(camX) || !Double.isFinite(camY) || !Double.isFinite(camZ)) { + if (sharcLastCameraValid) sharcCache.requestReset(); + } else { + double dx = camX - sharcLastCameraX; + double dy = camY - sharcLastCameraY; + double dz = camZ - sharcLastCameraZ; + if (dx * dx + dy * dy + dz * dz > 64.0 * 64.0) sharcCache.requestReset(); + } + SharcSkyState skyState = SharcSkyState.from(sky); + if (hardSkyDiscontinuity(sharcLastSkyState, skyState)) { + sharcCache.requestReset(); + } + sharcWorldIdentity = level; + sharcDimensionIdentity = dimension; + sharcTerrainX = terrain.blockX; + sharcTerrainY = terrain.blockY; + sharcTerrainZ = terrain.blockZ; + sharcMaterialEpoch = RtMaterialRegistry.INSTANCE.epoch(); + sharcSettingsSignature = sharcSettingsSignature(); + sharcRenderWidth = renderW; + sharcRenderHeight = renderH; + sharcLastCameraX = camX; + sharcLastCameraY = camY; + sharcLastCameraZ = camZ; + sharcLastCameraValid = Double.isFinite(camX) && Double.isFinite(camY) && Double.isFinite(camZ); + sharcLastSkyState = skyState; + } + private void refreshPipelineShapeIfNeeded(RtContext ctx) { if (worldPipeline == null || reloadRebindRequested) { return; @@ -838,6 +1168,7 @@ private void refreshPipelineShapeIfNeeded(RtContext ctx) { return; } ctx.waitIdle(); + destroySharcResources(); worldPipeline.destroy(); worldPipeline = null; bindlessTextureCapacity = 0; @@ -851,9 +1182,9 @@ private void refreshPipelineShapeIfNeeded(RtContext ctx) { * the shared material registry, and invalidates old-epoch geometry before tracing resumes. */ private void bindWorldTextures(RtContext ctx) { + EndSkyBinding endSky = requireEndSkyBinding(); long sampler = atlasSampler(ctx); long atlasView = blockAlbedoAtlasView(); - boundBlockAlbedoAtlasHandle = atlasView; // remember what we bound so a reload can detect the new atlas worldPipeline.setBlockAlbedoAtlas(atlasView, sampler); // Bindless slot 0 = fallback texture (the block atlas) so an entity whose texture can't be // resolved samples something defined rather than an unbound (partially-bound) descriptor. @@ -865,7 +1196,6 @@ private void bindWorldTextures(RtContext ctx) { worldPipeline.setEntityAlbedoTexture(0, atlasView, sampler); RtBlockMaterials.INSTANCE.bindPages(worldPipeline, sampler); RtMaterialRegistry.INSTANCE.rebuild(ctx, RtBlockMaterials.INSTANCE, materialOverrides); - materialBindingsReady = true; // Sky rewrite: bind the vanilla celestials atlas (sun + moon phases) for world.rmiss. The view // handle is stable across frames; the shader only samples it inside the sun/moon discs (sky // directions), so the block-atlas fallback is never read if the celestials atlas isn't ready. @@ -879,11 +1209,14 @@ private void bindWorldTextures(RtContext ctx) { skyLut.sampler()); } } + worldPipeline.setEndSkyTexture(endSky.view(), endSky.sampler()); setCelestialUvAtlas(celView); // Atlas UVs and material IDs are one resource epoch. Drop old terrain as a unit rather than // incrementally displaying old UVs/IDs against the new atlas/table. RtTerrain.requestFullClear(); materialEpochTraceGate = true; + boundBlockAlbedoAtlasHandle = atlasView; + materialBindingsReady = true; } private void refreshMaterialBindingsIfNeeded(RtContext ctx) { @@ -924,6 +1257,11 @@ public void onResourceReloadStart() { RtContext ctx = RtContext.currentOrNull(); if (ctx != null) { ctx.waitIdle(); + destroySharcResources(); + if (displayPipeline != null) { + displayPipeline.destroy(); + displayPipeline = null; + } if (worldPipeline != null) { worldPipeline.destroy(); worldPipeline = null; @@ -935,15 +1273,24 @@ public void onResourceReloadStart() { /** Bind the guide buffers into the world pipeline's extra storage-image slots. */ private void bindGuideImages() { - if (worldPipeline == null || gNormal == null) { + bindGuideImages(worldPipeline); + bindGuideImages(sharcQueryPipeline); + bindGuideImages(sharcUpdatePipeline); + } + + private void bindGuideImages(RtPipeline pipeline) { + if (pipeline == null || gNormal == null) { return; } - worldPipeline.setExtraStorageImage(0, gNormal.view); - worldPipeline.setExtraStorageImage(1, gAlbedo.view); - worldPipeline.setExtraStorageImage(2, gDepth.view); - worldPipeline.setExtraStorageImage(3, gMotion.view); - worldPipeline.setExtraStorageImage(4, gSpecAlbedo.view); - worldPipeline.setExtraStorageImage(5, gSpecMotion.view); + pipeline.setExtraStorageImage(0, gNormal.view); + pipeline.setExtraStorageImage(1, gAlbedo.view); + pipeline.setExtraStorageImage(2, gDepth.view); + pipeline.setExtraStorageImage(3, gMotion.view); + pipeline.setExtraStorageImage(4, gSpecAlbedo.view); + pipeline.setExtraStorageImage(5, gSpecMotion.view); + pipeline.setExtraStorageImage(6, gResponsivity.view); + pipeline.setExtraStorageImage(7, gParticleMask.view); + pipeline.setExtraStorageImage(8, gSkyClassification.view); } private void destroyGuideImages() { @@ -971,6 +1318,18 @@ private void destroyGuideImages() { gSpecMotion.destroy(); gSpecMotion = null; } + if (gResponsivity != null) { + gResponsivity.destroy(); + gResponsivity = null; + } + if (gParticleMask != null) { + gParticleMask.destroy(); + gParticleMask = null; + } + if (gSkyClassification != null) { + gSkyClassification.destroy(); + gSkyClassification = null; + } if (rrOutput != null) { rrOutput.destroy(); rrOutput = null; @@ -978,10 +1337,10 @@ private void destroyGuideImages() { } private void ensureOutput(RtContext ctx, int width, int height) { - // Debug presentation is downstream of the ordinary frame graph and must not change the image - // being inspected. In particular, toggling it must not rebuild at native resolution or disable - // the RR path whose render-resolution guide inputs the debug pass visualizes. - boolean rrEnabled = RtDlssRr.enabled(); + // The raw debug view is a deliberate pre-reconstruction reference. It must trace at display + // resolution and must not create/use the RR path, otherwise it would only be another reconstructed image. + boolean rrRequested = RtDlssRr.enabled() && !rawDebugView(); + boolean rrEnabled = rrRequested && !RtDlssRr.INSTANCE.hasFailed(); int rrQuality = rrEnabled ? RtDlssRr.quality() : Integer.MIN_VALUE; if (output != null && continuationQueue != null && displayImage != null && hdrDisplayImage != null && rrOutput != null @@ -990,7 +1349,20 @@ private void ensureOutput(RtContext ctx, int width, int height) { && renderSizeRrEnabled == rrEnabled && renderSizeRrQuality == rrQuality) { return; } - ctx.waitIdle(); // resize is rare; no in-flight frame may use the old image/descriptor + int[] optimal = rrEnabled ? RtDlssRr.INSTANCE.queryOptimalRenderSize(width, height) : null; + boolean useRr = optimal != null; + int activeRrQuality = useRr ? rrQuality : Integer.MIN_VALUE; + if (output != null && displayW == width && displayH == height + && renderSizeRrEnabled == useRr && renderSizeRrQuality == activeRrQuality + && continuationQueue != null && displayImage != null && hdrDisplayImage != null + && rrOutput != null && bloomLevels.length > 0 && exposure.ready()) { + return; + } + ctx.waitIdle(); // resize is rare; no in-flight frame may use the old images or descriptors + if (output != null) { + RtDlssRr.INSTANCE.requestHistoryReset(); + } + destroySharcResources(); if (displayImage != null) { displayImage.destroy(); } @@ -1009,34 +1381,24 @@ private void ensureOutput(RtContext ctx, int width, int height) { displayW = width; displayH = height; - // The path tracer + its guide buffers run at render res; DLSS-RR (or a fallback blit) upscales - // to display res. With RR off there is no reconstruction pass, so trace at 1:1 for a faithful reference. - // With RR on, ask NGX what render resolution its chosen quality mode actually expects rather - // than assuming a fixed ratio: different quality modes (and driver versions) use different - // ratios, and DLSSD's own optimal-settings query is the source of truth for what it will accept. - int[] optimal = rrEnabled ? RtDlssRr.INSTANCE.queryOptimalRenderSize(width, height) : null; - renderW = optimal != null ? optimal[0] : width; - renderH = optimal != null ? optimal[1] : height; - renderSizeRrEnabled = rrEnabled; - renderSizeRrQuality = rrQuality; - - // RT traces and DLSS-RR reconstruct scene-linear ACEScg in an HDR R16G16B16A16_SFLOAT target, - // so radiance > 1 and wide-gamut colour survive to the display seam. displayImage stays - // R8G8B8A8 to match the main target it is copied into - // (vkCmdCopyImage requires texel-size-compatible formats). - output = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH); + renderW = useRr ? optimal[0] : width; + renderH = useRr ? optimal[1] : height; + renderSizeRrEnabled = useRr; + renderSizeRrQuality = activeRrQuality; + + output = ctx.createStorageImage(renderW, renderH, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "trace color " + renderW + "x" + renderH); long pixelRecords = Math.multiplyExact((long) renderW, (long) renderH); long continuationBytes = Math.multiplyExact( Math.multiplyExact(pixelRecords, (long) PATH_SEGMENTS_PER_PIXEL), PATH_RECORD_BYTES); continuationQueue = ctx.createBuffer(continuationBytes, VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, false, "path continuation queue " + renderW + "x" + renderH + "x" + PATH_SEGMENTS_PER_PIXEL); - displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM, "RT display image " + width + "x" + height); - // PQ-encoded ([0,1], ST.2084) HDR display image, written in parallel by display.comp when HDR mode is active. - hdrDisplayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height); - // Bloom pyramid. Level 0 is half display resolution (the prefilter's 13-tap already covers a 5x5 - // display-pixel footprint, so nothing is lost by starting there); each further level halves again - // until the look package's level count or the smallest useful size is reached. + displayImage = ctx.createStorageImage(width, height, VK10.VK_FORMAT_R8G8B8A8_UNORM, + "RT display image " + width + "x" + height); + hdrDisplayImage = ctx.createStorageImage(width, height, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "RT HDR display image " + width + "x" + height); + int bloomWidth = Math.max(1, (width + 1) / 2); int bloomHeight = Math.max(1, (height + 1) / 2); int bloomLevelCount = RtBloomPipeline.levelsFor(bloomWidth, bloomHeight, LOOK.bloom().levels()); @@ -1048,27 +1410,52 @@ private void ensureOutput(RtContext ctx, int width, int height) { bloomWidth = Math.max(1, bloomWidth / 2); bloomHeight = Math.max(1, bloomHeight / 2); } - // Guide buffers match the trace (render) resolution; DLSS-RR consumes them at render res. - gNormal = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide normal roughness " + renderW + "x" + renderH); - gAlbedo = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide diffuse albedo " + renderW + "x" + renderH); - gDepth = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R32_SFLOAT, "guide linear depth " + renderW + "x" + renderH); - 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); - // 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); + + gNormal = ctx.createStorageImage(renderW, renderH, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide normal roughness " + renderW + "x" + renderH); + gAlbedo = ctx.createStorageImage(renderW, renderH, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "guide diffuse albedo " + renderW + "x" + renderH); + gDepth = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R32_SFLOAT, + "guide linear depth " + renderW + "x" + renderH); + 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); + gResponsivity = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16_SFLOAT, + "guide responsivity " + renderW + "x" + renderH); + gParticleMask = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R8_UINT, + "guide particle mask " + renderW + "x" + renderH); + gSkyClassification = ctx.createStorageImage(renderW, renderH, VK10.VK_FORMAT_R16_SFLOAT, + "guide primary-sky classification " + renderW + "x" + renderH); + rrOutput = ctx.createStorageImage(width, height, + VK10.VK_FORMAT_R16G16B16A16_SFLOAT, "DLSS-RR output " + width + "x" + height); exposure.ensureResources(ctx); - mvHasPrev = false; // recreated images -> first MV frame is zero + mvHasPrev = false; waterWaveTimeValid = false; if (worldPipeline != null) { worldPipeline.setStorageImage(output.view); bindGuideImages(); } - RtToneLut boundLookLut = lookLut; + EndSkyBinding endSky = requireEndSkyBinding(); + long fallbackAtlasView = blockAlbedoAtlasView(); + long celestialsView = celestialsAtlasView(); + bindPresentationDescriptors(lookLut, endSky, fallbackAtlasView, + celestialsView, atlasSampler(ctx)); + } + + private void bindPresentationDescriptors(RtToneLut boundLookLut, EndSkyBinding endSky, + long fallbackAtlasView, long celestialsView, + long atlasSamplerHandle) { displayPipeline.setImages(displayImage.view, rrOutput.view, exposure.image().view, hdrDisplayImage.view, sdrToneLut.view(), sdrToneLut.sampler(), hdrToneLut.view(), hdrToneLut.sampler(), - boundLookLut.view(), boundLookLut.sampler(), bloomLevels[0].view, bloomPipeline.sampler()); + boundLookLut.view(), boundLookLut.sampler(), bloomLevels[0].view, bloomPipeline.sampler(), + gSkyClassification.view, + endSky.view(), endSky.sampler(), + celestialsView != 0L ? celestialsView : fallbackAtlasView, + atlasSamplerHandle); bloomPipeline.setImages(rrOutput.view, exposure.image().view, bloomLevels); debugPresentPipeline.setImages(displayImage.view, gNormal.view, gAlbedo.view, gDepth.view, gMotion.view, gSpecAlbedo.view, gSpecMotion.view, rrOutput.view, exposure.image().view, @@ -1136,14 +1523,17 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_COMMAND_BUFFER, cmd.address(), "composite command buffer"); int debugView = debugView(); RtTerrain terrain = RtTerrain.currentOrNull(); + boolean sharcOn = sharcActive() && terrain != null; try (MemoryStack stack = MemoryStack.stackPush(); RtDebugLabels.Scope frameLabel = RtDebugLabels.scope(ctx, cmd, "composite frame")) { - // RR drives the upscale: trace + jitter at render res, DLSS-RR denoises+upscales to display. - // A debug view observes this ordinary path; it never changes jitter or disables RR. - boolean rrPath = RtDlssRr.enabled(); + // RR drives the ordinary upscale. Raw debug is the explicit exception: it traces at full + // display resolution, uses no jitter, and never enters DLSS-RR or the debug-present compositor. + boolean rawDebug = rawDebugView(); + boolean rrPath = RtDlssRr.enabled() && !RtDlssRr.INSTANCE.hasFailed() && !rawDebug; + float mipMapBias = rrPath ? RtDlssRr.recommendedMipMapBias(renderW, displayW) : 0.0f; float jitterX = 0f; float jitterY = 0f; if (rrPath) { - CausticaJitter.INSTANCE.prepare(renderW, renderH, displayW); + CausticaJitter.INSTANCE.prepare(renderW, renderH, displayW, displayH); jitterX = CausticaJitter.INSTANCE.jitterPixelsX() * jitterSignX(); jitterY = CausticaJitter.INSTANCE.jitterPixelsY() * jitterSignY(); } @@ -1219,6 +1609,9 @@ 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(); + if (sharcOn) { + updateSharcResetPolicy(terrain, sky); + } new WorldPushData( frameInvViewProj, new Float3((float) (camX - terrain.blockX), (float) (camY - terrain.blockY), @@ -1252,16 +1645,28 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo new Int4(terrain.lightGridDimX(), terrain.lightGridDimY(), terrain.lightGridDimZ(), 0), terrain.lightCount(), CausticaConfig.Rt.Lights.RIS_CANDIDATES.value(), + mipMapBias, // 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(), pathSampleBase, pathSampleEpoch, - pathSampleAddress + pathSampleAddress, + sky.skybox(), + sky.skyFlags(), + sky.skyColor(), + sky.skyParams(), + sky.endFlashUv() ).write(push); pushBuf.flush(0L, WORLD_PUSH_SIZE); // Upload any entity textures registered this frame into the bindless set before the trace. - RtEntityTextures.INSTANCE.uploadPending(active, atlasSampler(ctx)); + long textureSampler = atlasSampler(ctx); + if (sharcQueryPipeline != null && sharcUpdatePipeline != null) { + RtEntityTextures.INSTANCE.uploadPending(textureSampler, active, + sharcQueryPipeline, sharcUpdatePipeline); + } else { + RtEntityTextures.INSTANCE.uploadPending(active, textureSampler); + } // Build the entity BLAS, the TLAS that references it and the terrain BLAS, then the trace. // Barriers separate each stage; the graphics-use timeline guards resource reuse. if (!fe.blas().isEmpty()) { @@ -1276,6 +1681,10 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo graphicsUse); } active.setTlas(frameTlas.accel.handle, graphicsUse, graphicsUseWaiter); + if (sharcOn) { + sharcUpdatePipeline.setTlas(frameTlas.accel.handle, graphicsUse, graphicsUseWaiter); + sharcQueryPipeline.setTlas(frameTlas.accel.handle, graphicsUse, graphicsUseWaiter); + } currentTlasHandle = frameTlas.accel.handle; try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.recordTlas")) { RtAccel.recordTlasBuild(ctx, cmd, frameTlas); @@ -1294,6 +1703,25 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo terrain.lightLocalAliasBufferAddress(), terrain.lightGridCellBufferAddress(), terrain.lightGridSpanBufferAddress(), continuationQueue.deviceAddress, (int) frameCounter).write(pushConstants); + long sharcFrameAddress = 0L; + ByteBuffer sharcPushConstants = null; + int sharcTileSize = 0; + if (sharcOn) { + sharcTileSize = RtSharcCache.updateTileSize(); + sharcFrameAddress = sharcCache.beginFrame(frameCounter, + (float) (camX - terrain.blockX), (float) (camY - terrain.blockY), + (float) (camZ - terrain.blockZ), graphicsUseWaiter); + sharcPushConstants = stack.malloc(SharcPushConstantsData.BYTE_SIZE); + new SharcPushConstantsData(pushBuf.deviceAddress, terrain.tableAddress(), fe.geomTableAddr(), + RtMaterialRegistry.INSTANCE.tableAddress(), terrain.lightBufferAddress(), + terrain.lightAliasBufferAddress(), terrain.lightLocalAliasBufferAddress(), + terrain.lightGridCellBufferAddress(), terrain.lightGridSpanBufferAddress(), + continuationQueue.deviceAddress, (int) frameCounter, sharcFrameAddress, + sharcTileSize, renderW, renderH, + CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.value(), + CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.value() ? 1 : 0) + .write(sharcPushConstants); + } // Sky LUTs, from the same WorldPush slot the trace is about to read: the sky the LUT holds and // the sky the frame shades are built from one set of angles, not two. Recorded here (after the // push flush, before the trace) so the miss shader's very first fetch sees this frame's dome. @@ -1307,9 +1735,35 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo active.trace(cmd, renderW, renderH, pushConstants, 0); } VulkanCommandEncoder.memoryBarrier(cmd, stack); // continuation/guide writes visible to pass B - try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world indirect trace"); - RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.traceIndirect")) { - active.trace(cmd, renderW, renderH, pushConstants, 1); + if (sharcOn) { + sharcCache.recordPendingClear(cmd, stack); + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "SHaRC sparse update"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.sharcUpdate")) { + sharcUpdatePipeline.trace(cmd, (renderW + sharcTileSize - 1) / sharcTileSize, + (renderH + sharcTileSize - 1) / sharcTileSize, sharcPushConstants, 0); + } + if (sharcCache.queryReady()) { + sharcCache.updateToResolveBarrier(cmd, stack); + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "SHaRC resolve"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.sharcResolve")) { + sharcResolvePipeline.dispatch(cmd, sharcFrameAddress, sharcCache.capacity()); + } + sharcCache.resolveToQueryBarrier(cmd, stack); + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "SHaRC query"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.sharcQuery")) { + sharcQueryPipeline.trace(cmd, renderW, renderH, sharcPushConstants, 0); + } + } else { + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world indirect trace (SHaRC warmup)"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.traceIndirect")) { + active.trace(cmd, renderW, renderH, pushConstants, 1); + } + } + } else { + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "world indirect trace"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.traceIndirect")) { + active.trace(cmd, renderW, renderH, pushConstants, 1); + } } VulkanCommandEncoder.memoryBarrier(cmd, stack); // RT writes visible to DLSS reads // DLSS-RR denoise + upscale. The RT pass wrote noisy color (render res) + guides; @@ -1318,7 +1772,8 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "DLSS-RR evaluate"); RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.dlssRr")) { rrDone = RtDlssRr.INSTANCE.evaluate(cmd.address(), output, gDepth, gMotion, gAlbedo, - gSpecAlbedo, gNormal, gSpecMotion, rrOutput, renderW, renderH, displayW, displayH, + gSpecAlbedo, gNormal, gSpecMotion, gParticleMask, gResponsivity, rrOutput, + renderW, renderH, displayW, displayH, -jitterX, -jitterY, frameViewRotation, frameProjection); } } @@ -1364,12 +1819,16 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo int displayPeakNits = CausticaConfig.Rt.Hdr.effectivePeakNits(); displayPipeline.dispatch(cmd, displayW, displayH, RtToneMapping.current(), sdrToneLut.size, CausticaConfig.Rt.Tonemap.GAMMA.value(), displayPeakNits, - true, lookLut.size, LOOK.bloom().strength() / bloomLevels.length); + true, lookLut.size, LOOK.bloom().strength() / bloomLevels.length, + frameInvViewProj, sky.skybox(), sky.skyFlags(), + sky.skyColor().x(), sky.skyColor().y(), sky.skyColor().z(), sky.skyColor().w(), + sky.skyParams().y(), sky.skyParams().z(), sky.skyParams().w(), + sky.endFlashUv().x(), sky.endFlashUv().y(), sky.endFlashUv().z(), sky.endFlashUv().w()); } hdrWrittenThisFrame = CausticaConfig.Rt.Hdr.enabled(); VulkanCommandEncoder.memoryBarrier(cmd, stack); // display output visible to debug composite - if (debugView != 0) { + if (debugView != 0 && !rawDebug) { // Debug content is composited only after the real scene has completed trace, RR/fallback, // exposure, and display mapping. It therefore observes the renderer without perturbing // exposure history or feeding literal diagnostic colors through ACES. Debug presentation @@ -1391,14 +1850,17 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo } VulkanCommandEncoder.memoryBarrier(cmd, stack); } - if (VK10.vkEndCommandBuffer(cmd) != VK10.VK_SUCCESS) { - throw new IllegalStateException("vkEndCommandBuffer(rt composite) failed"); - } - encoder.execute(cmd); // deferred into the frame's submission — correct for per-frame work - // Do not attach a merely reserved token: failed recording may never signal it. Once execute succeeds, - // every owner in this frame's manifest is protected through the final overlay consumer. - RtEntities.INSTANCE.markGraphicsUse(frameEntities, graphicsUse); - exposure.markStateReadbackUse(graphicsUse); + if (VK10.vkEndCommandBuffer(cmd) != VK10.VK_SUCCESS) { + throw new IllegalStateException("vkEndCommandBuffer(rt composite) failed"); + } + encoder.execute(cmd); // deferred into the frame's submission — correct for per-frame work + // Do not attach a merely reserved token: failed recording may never signal it. Once execute succeeds, + // every owner in this frame's manifest is protected through the final overlay consumer. + RtEntities.INSTANCE.markGraphicsUse(frameEntities, graphicsUse); + exposure.markStateReadbackUse(graphicsUse); + if (sharcOn) { + sharcCache.commitFrameUse(graphicsUse); + } } /** @@ -1436,10 +1898,54 @@ private BreakEntry[] breakingEntries(RtTerrain terrain) { return count == result.length ? result : java.util.Arrays.copyOf(result, count); } - private record SkyPush(Float4 celestial, Float4 look0, Float4 look1, Float4 look2, Float4 look3, - Float4 sunUv, Float4 moonUv) {} + static final float SHARC_SKY_ANGLE_JUMP_RADIANS = 0.1f; + static final float SHARC_SKY_VALUE_JUMP = 0.25f; + + record SharcSkyState(int skybox, int skyFlags, float sunAngle, float moonAngle, float starAngle, + float starBrightness, int moonPhase, float skyR, float skyG, float skyB) { + private static SharcSkyState from(SkyPush sky) { + return new SharcSkyState(sky.skybox(), sky.skyFlags(), sky.celestial().x(), sky.celestial().y(), + sky.celestial().z(), sky.celestial().w(), Math.round(sky.look3().w()), + sky.skyColor().x(), sky.skyColor().y(), sky.skyColor().z()); + } + } + + static boolean hardSkyDiscontinuity(SharcSkyState previous, SharcSkyState current) { + if (previous == null) { + return false; + } + if (previous.skybox() != current.skybox() || previous.skyFlags() != current.skyFlags() + || previous.moonPhase() != current.moonPhase()) { + return true; + } + return angularDistance(previous.sunAngle(), current.sunAngle()) > SHARC_SKY_ANGLE_JUMP_RADIANS + || angularDistance(previous.moonAngle(), current.moonAngle()) > SHARC_SKY_ANGLE_JUMP_RADIANS + || angularDistance(previous.starAngle(), current.starAngle()) > SHARC_SKY_ANGLE_JUMP_RADIANS + || finiteDistance(previous.starBrightness(), current.starBrightness()) > SHARC_SKY_VALUE_JUMP + || finiteDistance(previous.skyR(), current.skyR()) > SHARC_SKY_VALUE_JUMP + || finiteDistance(previous.skyG(), current.skyG()) > SHARC_SKY_VALUE_JUMP + || finiteDistance(previous.skyB(), current.skyB()) > SHARC_SKY_VALUE_JUMP; + } + + private static float angularDistance(float first, float second) { + if (!Float.isFinite(first) || !Float.isFinite(second)) { + return Float.POSITIVE_INFINITY; + } + float fullTurn = (float) (Math.PI * 2.0); + float difference = Math.abs(first - second) % fullTurn; + return Math.min(difference, fullTurn - difference); + } + + private static float finiteDistance(float first, float second) { + return Float.isFinite(first) && Float.isFinite(second) + ? Math.abs(first - second) : Float.POSITIVE_INFINITY; + } + + private record SkyPush(int skybox, int skyFlags, Float4 skyColor, Float4 skyParams, + Float4 endFlashUv, Float4 celestial, Float4 look0, Float4 look1, Float4 look2, + Float4 look3, Float4 sunUv, Float4 moonUv) {} - private record CelestialUv(Float4 sun, Float4 moon) {} + private record CelestialUv(Float4 sun, Float4 moon, Float4 endFlash) {} /** * This frame's sky state: Minecraft's four eased celestial angles, its star brightness, the moon @@ -1466,6 +1972,35 @@ private record CelestialUv(Float4 sun, Float4 moon) {} private SkyPush skyPush() { Minecraft mc = Minecraft.getInstance(); float partial = mc.getDeltaTracker().getGameTimeDeltaPartialTick(false); + int mode = frameSkyboxMode; + EndFlashState endFlash = mode == RtSkyMath.SKYBOX_END && mc.level != null + ? mc.level.endFlashState() : null; + float endFlashIntensity = endFlash == null ? 0.0f : finiteColor(endFlash.getIntensity(partial)); + boolean endFlashActive = mode == RtSkyMath.SKYBOX_END && endFlashIntensity > 1.0e-4f; + if (!endFlashStateValid || previousEndFlashActive != endFlashActive) { + if (endFlashStateValid) { + RtDlssRr.INSTANCE.requestHistoryReset(); + } + previousEndFlashActive = endFlashActive; + endFlashStateValid = true; + } + float endFlashX = endFlash == null || !Float.isFinite(endFlash.getXAngle()) + ? 0.0f : endFlash.getXAngle() * (float) (Math.PI / 180.0); + float endFlashY = endFlash == null || !Float.isFinite(endFlash.getYAngle()) + ? 0.0f : endFlash.getYAngle() * (float) (Math.PI / 180.0); + Float4 skyColor = new Float4(frameSkyColorR, frameSkyColorG, frameSkyColorB, frameSkyColorA); + Float4 skyParams = new Float4(0.0f, endFlashIntensity, endFlashX, endFlashY); + RtLookPackage.Sky sky = LOOK.sky(); + RtLookPackage.Lighting lighting = LOOK.lighting(); + if (mode != RtSkyMath.SKYBOX_OVERWORLD) { + CelestialUv uv = celestialUv(0.0f); + return new SkyPush( + mode, endFlashActive ? RtSkyMath.SKY_FLAG_END_FLASH : 0, skyColor, skyParams, + uv.endFlash(), + new Float4(0f, 0f, 0f, 0f), new Float4(0f, 0f, 0f, 0f), + new Float4(0f, 0f, 0f, 0f), new Float4(0f, 0f, 0f, 0f), + new Float4(0f, 0f, 0f, 0f), uv.sun(), uv.moon()); + } var probe = mc.gameRenderer.mainCamera().attributeProbe(); int seaLevel = mc.level != null ? mc.level.getSeaLevel() : 0; float viewerAltitudeKm = Math.clamp((float) ((camY - seaLevel) / 100.0), 0.0f, 99.0f); @@ -1479,10 +2014,9 @@ private SkyPush skyPush() { float starBrightness = probe.getValue(EnvironmentAttributes.STAR_BRIGHTNESS, partial); float moonPhase = probe.getValue(EnvironmentAttributes.MOON_PHASE, partial).index(); // 0 full .. 4 new - RtLookPackage.Sky sky = LOOK.sky(); - RtLookPackage.Lighting lighting = LOOK.lighting(); CelestialUv uv = celestialUv(moonPhase); return new SkyPush( + mode, 0, skyColor, skyParams, uv.endFlash(), new Float4(sunAngle, moonAngle, starAngle, starBrightness), new Float4(lighting.sunIlluminanceLux(), lighting.moonIlluminanceLux(), lighting.nightAirglowLuminanceCdM2(), lighting.starLuminanceCdM2()), @@ -1513,7 +2047,8 @@ private CelestialUv celestialUv(float moonPhaseIndex) { } return new CelestialUv( new Float4(sunU0, sunV0, sunU1, sunV1), - new Float4(moonU0, moonV0, moonU1, moonV1)); + new Float4(moonU0, moonV0, moonU1, moonV1), + new Float4(endFlashU0, endFlashV0, endFlashU1, endFlashV1)); } private void setCelestialUvAtlas(long atlasHandle) { @@ -1524,11 +2059,13 @@ private void setCelestialUvAtlas(long atlasHandle) { celestialUvMoonPhase = -1; sunU0 = 0f; sunV0 = 0f; sunU1 = 1f; sunV1 = 1f; moonU0 = 0f; moonV0 = 0f; moonU1 = 1f; moonV1 = 1f; + endFlashU0 = 0f; endFlashV0 = 0f; endFlashU1 = 1f; endFlashV1 = 1f; } private void refreshCelestialUvCache(int moonPhase) { sunU0 = 0f; sunV0 = 0f; sunU1 = 1f; sunV1 = 1f; moonU0 = 0f; moonV0 = 0f; moonU1 = 1f; moonV1 = 1f; + endFlashU0 = 0f; endFlashV0 = 0f; endFlashU1 = 1f; endFlashV1 = 1f; try { if (celestialUvAtlasHandle != 0L) { TextureAtlas atlas = Minecraft.getInstance().getAtlasManager().getAtlasOrThrow(AtlasIds.CELESTIALS); @@ -1536,6 +2073,9 @@ private void refreshCelestialUvCache(int moonPhase) { sunU0 = sun.getU0(); sunV0 = sun.getV0(); sunU1 = sun.getU1(); sunV1 = sun.getV1(); TextureAtlasSprite moon = atlas.getSprite(MOON_IDS[moonPhase]); moonU0 = moon.getU0(); moonV0 = moon.getV0(); moonU1 = moon.getU1(); moonV1 = moon.getV1(); + TextureAtlasSprite endFlash = atlas.getSprite(END_FLASH_ID); + endFlashU0 = endFlash.getU0(); endFlashV0 = endFlash.getV0(); + endFlashU1 = endFlash.getU1(); endFlashV1 = endFlash.getV1(); } } catch (Exception ignored) { // celestials atlas not yet loaded — keep full-range UVs (fallback texture is the block atlas) @@ -1562,13 +2102,12 @@ private static double srgbToLinear(double value) { : Math.pow((value + 0.055) / 1.055, 2.4); } - public void destroy() { + /** Destroy compositor resources and report whether the native RR feature released its device ownership. */ + public boolean destroy() { // Teardown runs after the device is idle (CLIENT_STOPPING waits), so the TLAS ring's slots are no // longer in flight and can be freed immediately. tlasRing.destroy(); - if (RtDlssRr.enabled()) { - RtDlssRr.INSTANCE.destroy(); - } + boolean rrReleased = RtDlssRr.INSTANCE.destroy(); if (displayImage != null) { displayImage.destroy(); displayImage = null; @@ -1586,7 +2125,7 @@ public void destroy() { fgHdrHudlessImage.destroy(); fgHdrHudlessImage = null; } - RtWorldOverlay.INSTANCE.destroy(); // overlay features/pipelines/scratch live on the same device lifetime + RtWorldOverlay.INSTANCE.destroy(); if (output != null) { output.destroy(); output = null; @@ -1662,6 +2201,7 @@ public void destroy() { fgInterpW = -1; fgInterpH = -1; fgInterpFormat = Integer.MIN_VALUE; + destroySharcResources(); if (worldPipeline != null) { worldPipeline.destroy(); worldPipeline = null; @@ -1685,6 +2225,7 @@ public void destroy() { } atlasSampler = 0L; } + return rrReleased; } private long atlasSampler(RtContext ctx) { @@ -2031,6 +2572,36 @@ public void captureFgHudless(RenderTarget main) { encoder.execute(cmd); } + /** The End sky is a standalone Minecraft texture, not a sprite in the celestials atlas. */ + private record EndSkyBinding(long view, long sampler) {} + + private static final class EndSkyUnavailableException extends RuntimeException { + private EndSkyUnavailableException() { + super("Minecraft End sky texture has no Vulkan view/sampler"); + } + } + + private static EndSkyBinding requireEndSkyBinding() { + EndSkyBinding binding = endSkyBinding(); + if (binding.view() == 0L || binding.sampler() == 0L) { + throw new EndSkyUnavailableException(); + } + return binding; + } + + private static EndSkyBinding endSkyBinding() { + try { + AbstractTexture texture = Minecraft.getInstance().getTextureManager().getTexture(END_SKY_ID); + if (!(texture.getTextureView() instanceof VulkanGpuTextureView view) + || !(texture.getSampler() instanceof VulkanGpuSampler sampler)) { + return new EndSkyBinding(0L, 0L); + } + return new EndSkyBinding(view.vkImageView(), sampler.vkSampler()); + } catch (Throwable ignored) { + return new EndSkyBinding(0L, 0L); + } + } + /** * HDR counterpart of {@link #captureFgHudless} — copies {@code src} (this frame's {@code hdrDisplayImage}, * before the combined UI overlay is blended in) into {@link #fgHdrHudlessImage} for {@link diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java index 386eb2c6..3d6353f9 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtContext.java @@ -563,9 +563,13 @@ private void ensurePool() { } public static void check(int rc, String what) { + check(instance != null ? instance.device : null, rc, what); + } + + public static void check(VulkanDevice owner, int rc, String what) { if (rc != VK10.VK_SUCCESS) { - if (rc == VK10.VK_ERROR_DEVICE_LOST && instance != null) { - VulkanDiagnostics.reportDeviceLost(instance.device, what); + if (rc == VK10.VK_ERROR_DEVICE_LOST && owner != null) { + VulkanDiagnostics.reportDeviceLost(owner, what); } throw new IllegalStateException(what + " failed: " + rc); } diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java index b9bcc97e..1cd426f2 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtDeviceBringup.java @@ -24,6 +24,7 @@ import org.lwjgl.vulkan.VkPhysicalDeviceRayTracingInvocationReorderFeaturesEXT; import org.lwjgl.vulkan.VkPhysicalDeviceFeatures; import org.lwjgl.vulkan.VkPhysicalDeviceVulkan12Features; +import org.lwjgl.vulkan.VkPhysicalDeviceVulkan11Features; import org.lwjgl.vulkan.VkPhysicalDeviceOpacityMicromapFeaturesEXT; import org.lwjgl.vulkan.VkPhysicalDeviceOpacityMicromapPropertiesEXT; import org.lwjgl.vulkan.VkPhysicalDevicePresentIdFeaturesKHR; @@ -146,6 +147,9 @@ public static boolean enabledByProperty() { private static final VulkanPNextStruct PRESENT_ID_FEATURES_STRUCT = new VulkanPNextStruct( VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PRESENT_ID_FEATURES_KHR, VkPhysicalDevicePresentIdFeaturesKHR.SIZEOF); + private static final VulkanPNextStruct VULKAN_11_FEATURES_STRUCT = new VulkanPNextStruct( + VK12.VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_1_FEATURES, + VkPhysicalDeviceVulkan11Features.SIZEOF); private static final VulkanFeature BUFFER_DEVICE_ADDRESS_FEATURE = new VulkanFeature( VulkanBackend.VK12_FEATURES_STRUCT, "bufferDeviceAddress", @@ -162,6 +166,11 @@ public static boolean enabledByProperty() { private static final VulkanFeature SAMPLED_IMAGE_UPDATE_AFTER_BIND_FEATURE = new VulkanFeature( VulkanBackend.VK12_FEATURES_STRUCT, "descriptorBindingSampledImageUpdateAfterBind", VkPhysicalDeviceVulkan12Features.DESCRIPTORBINDINGSAMPLEDIMAGEUPDATEAFTERBIND); + private static final VulkanFeature SHADER_INT16_FEATURE = new VulkanFeature( + VulkanBackend.VK10_FEATURES_STRUCT, "shaderInt16", VkPhysicalDeviceFeatures.SHADERINT16); + private static final VulkanFeature SHADER_FLOAT16_FEATURE = new VulkanFeature( + VulkanBackend.VK12_FEATURES_STRUCT, "shaderFloat16", + VkPhysicalDeviceVulkan12Features.SHADERFLOAT16); private static final VulkanFeature SHADER_INT64_FEATURE = new VulkanFeature( VulkanBackend.VK10_FEATURES_STRUCT, "shaderInt64", VkPhysicalDeviceFeatures.SHADERINT64); private static final VulkanFeature ACCELERATION_STRUCTURE_FEATURE = new VulkanFeature( @@ -184,6 +193,12 @@ public static boolean enabledByProperty() { PRESENT_ID_FEATURES_STRUCT, "presentId", VkPhysicalDevicePresentIdFeaturesKHR.PRESENTID); private static final VulkanFeature WIDE_LINES_FEATURE = new VulkanFeature( VulkanBackend.VK10_FEATURES_STRUCT, "wideLines", VkPhysicalDeviceFeatures.WIDELINES); + private static final VulkanFeature SHARC_BUFFER_INT64_ATOMICS_FEATURE = new VulkanFeature( + VulkanBackend.VK12_FEATURES_STRUCT, "shaderBufferInt64Atomics", + VkPhysicalDeviceVulkan12Features.SHADERBUFFERINT64ATOMICS); + private static final VulkanFeature SHARC_STORAGE_BUFFER_16_FEATURE = new VulkanFeature( + VULKAN_11_FEATURES_STRUCT, "storageBuffer16BitAccess", + VkPhysicalDeviceVulkan11Features.STORAGEBUFFER16BITACCESS); private static final List REQUIRED_RT_FEATURES = List.of( BUFFER_DEVICE_ADDRESS_FEATURE, @@ -191,6 +206,8 @@ public static boolean enabledByProperty() { SAMPLED_IMAGE_NON_UNIFORM_FEATURE, DESCRIPTOR_PARTIALLY_BOUND_FEATURE, SAMPLED_IMAGE_UPDATE_AFTER_BIND_FEATURE, + SHADER_INT16_FEATURE, + SHADER_FLOAT16_FEATURE, SHADER_INT64_FEATURE, ACCELERATION_STRUCTURE_FEATURE, RAY_TRACING_PIPELINE_FEATURE, @@ -217,7 +234,8 @@ private enum SerBackend { } private record FeatureSupport(List missingRequired, SerBackend serBackend, - boolean omm, boolean presentId, boolean wideLines) { + boolean omm, boolean presentId, boolean wideLines, + boolean sharcInt64Atomics, boolean sharcStorageBuffer16) { boolean supportsRt() { return missingRequired.isEmpty(); } @@ -458,6 +476,12 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD } WIDE_LINES_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); + boolean querySharc = RtSharcSupport.packaged(); + if (querySharc) { + SHARC_BUFFER_INT64_ATOMICS_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); + SHARC_STORAGE_BUFFER_16_FEATURE.struct().findOrCreateStructInPNextChain(available, stack); + } + VK12.vkGetPhysicalDeviceFeatures2(physicalDevice.vkPhysicalDevice(), available); List missing = new ArrayList<>(); @@ -471,7 +495,9 @@ private static FeatureSupport queryFeatureSupport(VulkanPhysicalDevice physicalD return new FeatureSupport(missing, supportedSer, queryOmm && OMM_FEATURE.get(available), queryPresentId && PRESENT_ID_FEATURE.get(available), - WIDE_LINES_FEATURE.get(available)); + WIDE_LINES_FEATURE.get(available), + querySharc && SHARC_BUFFER_INT64_ATOMICS_FEATURE.get(available), + querySharc && SHARC_STORAGE_BUFFER_16_FEATURE.get(available)); } } @@ -521,6 +547,8 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { reflexEnabled = false; presentIdEnabled = false; wideLinesEnabled = false; + RtSharcSupport.setDeviceFeaturesEnabled(false, false, false); + RtSharcSupport.clearFailure(); maxLineWidth = 1.0f; String missingExtension = firstUnsupportedExtension(physicalDevice); if (missingExtension != null) { @@ -546,6 +574,14 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { // Core features merge into vanilla's VK10/VK12 structs; extension features create their matching // pNext structs. Every boolean here was verified by queryFeatureSupport above. features.addAll(REQUIRED_RT_FEATURES); + boolean sharcPackaged = RtSharcSupport.packaged(); + boolean sharcFeatures = sharcPackaged && support.sharcInt64Atomics + && support.sharcStorageBuffer16; + // SHaRC's optional feature set is atomic: a partial set follows the ordinary RT path. + if (sharcFeatures) { + features.add(SHARC_BUFFER_INT64_ATOMICS_FEATURE); + features.add(SHARC_STORAGE_BUFFER_16_FEATURE); + } // Bindless entity textures: a runtime-sized sampler2D[] indexed non-uniformly in the hit shader, // with partially-bound + update-after-bind slots (a growing per-RenderType registry). Core on the // VK 1.4 device; just needs enabling alongside bufferDeviceAddress on the same struct. @@ -595,6 +631,11 @@ public static void addFeatures(Args args, VulkanPhysicalDevice physicalDevice) { rtRequested = true; serBackend = support.serBackend; + RtSharcSupport.setDeviceFeaturesEnabled(sharcFeatures, sharcFeatures, sharcFeatures); + if (sharcPackaged && !sharcFeatures) { + CausticaMod.LOGGER.info("Optional SHaRC unavailable; keeping the ordinary RT path: {}", + RtSharcSupport.status()); + } List optionalExtensions = supportedOptionalExtensions(physicalDevice, support); CausticaMod.LOGGER.info( "Ray tracing: enabling {}{}{} + features [bufferDeviceAddress, accelerationStructure, rayTracingPipeline, rayQuery, SER={}" diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java index 7722b8fd..729cc964 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtFrameStats.java @@ -67,6 +67,9 @@ public final class RtFrameStats { "frame.skyLut", // Wavefront trace and downstream debug stages. "frame.tracePrimary", + "frame.sharcUpdate", + "frame.sharcResolve", + "frame.sharcQuery", "frame.traceIndirect", "frame.exposure", "frame.dlssRr", diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcCache.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcCache.java new file mode 100644 index 00000000..1118d901 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcCache.java @@ -0,0 +1,239 @@ +package dev.comfyfluffy.caustica.rt; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.rt.accel.RtBuffer; +import dev.comfyfluffy.caustica.rt.gen.SharcFrameData; +import dev.comfyfluffy.caustica.rt.gen.SharcFrameData.Float3; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.VkBufferMemoryBarrier2; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkDependencyInfo; + +import java.nio.ByteBuffer; + +import static org.lwjgl.vulkan.KHRSynchronization2.VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR; +import static org.lwjgl.vulkan.KHRSynchronization2.vkCmdPipelineBarrier2KHR; +import static org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; +import static org.lwjgl.vulkan.VK10.VK_BUFFER_USAGE_TRANSFER_DST_BIT; +import static org.lwjgl.vulkan.VK10.VK_QUEUE_FAMILY_IGNORED; +import static org.lwjgl.vulkan.VK13.VK_ACCESS_2_SHADER_READ_BIT; +import static org.lwjgl.vulkan.VK13.VK_ACCESS_2_SHADER_WRITE_BIT; +import static org.lwjgl.vulkan.VK13.VK_ACCESS_2_TRANSFER_WRITE_BIT; +import static org.lwjgl.vulkan.VK13.VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT; +import static org.lwjgl.vulkan.VK13.VK_PIPELINE_STAGE_2_TRANSFER_BIT; + +/** Persistent directional-SH tables plus a timeline-safe mapped SHaRC frame ring. */ +public final class RtSharcCache { + public static final int MIN_EXPONENT = 16; + public static final int MAX_EXPONENT = 23; + public static final int RING = 6; + private static final int QUERY_WARMUP_FRAMES = 16; + private static final int ACCUMULATION_STRIDE = 32; + private static final int RESOLVED_STRIDE = 24; + private static final float SHARC_WORLD_LIMIT = 1.0e6f; + public static final long MAX_TABLE_BYTES = 768L * 1024L * 1024L; + + private final RtBuffer hashEntries; + private final RtBuffer accumulation; + private final RtBuffer resolved; + private final RtBuffer[] tables; + private final RtBuffer[] queryTables; + private final RtBuffer[] frames; + private final RtGpuExecutor.TrackedGraphicsUse[] frameUses; + private final int exponent; + private final int capacity; + private int slot = -1; + private boolean pendingClear = true; + private int framesSinceReset; + private Float3 previousCamera; + private boolean destroyed; + + private RtSharcCache(RtBuffer hashEntries, RtBuffer accumulation, RtBuffer resolved, + RtBuffer[] frames, int exponent, int capacity) { + this.hashEntries = hashEntries; + this.accumulation = accumulation; + this.resolved = resolved; + this.tables = new RtBuffer[]{hashEntries, accumulation, resolved}; + this.queryTables = new RtBuffer[]{hashEntries, resolved}; + this.frames = frames; + this.frameUses = new RtGpuExecutor.TrackedGraphicsUse[RING]; + for (int i = 0; i < RING; i++) frameUses[i] = new RtGpuExecutor.TrackedGraphicsUse(); + this.exponent = exponent; + this.capacity = capacity; + } + + public static RtSharcCache create(RtContext ctx, int requestedExponent) { + int exponent = clampExponent(requestedExponent); + int capacity = 1 << exponent; + long tableBytes = tableBytesForExponent(exponent); + if (tableBytes > MAX_TABLE_BYTES) { + throw new IllegalArgumentException("SHaRC cache exponent " + exponent + " requires " + + tableBytes + " bytes, above the " + MAX_TABLE_BYTES + " byte safety limit"); + } + + int usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT; + RtBuffer hash = null; + RtBuffer accum = null; + RtBuffer packed = null; + RtBuffer[] frameRing = new RtBuffer[RING]; + try { + hash = ctx.createBuffer((long) capacity * 8L, usage, false, "SHaRC hash entries"); + accum = ctx.createBuffer((long) capacity * ACCUMULATION_STRIDE, usage, false, + "SHaRC directional-SH accumulation"); + packed = ctx.createBuffer((long) capacity * RESOLVED_STRIDE, usage, false, + "SHaRC directional-SH resolved"); + for (int i = 0; i < RING; i++) { + frameRing[i] = ctx.createBuffer(SharcFrameData.BYTE_SIZE, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT, + true, "SHaRC frame " + i); + MemoryUtil.memSet(frameRing[i].mapped, 0, SharcFrameData.BYTE_SIZE); + frameRing[i].flush(0L, SharcFrameData.BYTE_SIZE); + } + return new RtSharcCache(hash, accum, packed, frameRing, exponent, capacity); + } catch (Throwable t) { + if (hash != null) hash.destroy(); + if (accum != null) accum.destroy(); + if (packed != null) packed.destroy(); + for (RtBuffer frame : frameRing) if (frame != null) frame.destroy(); + throw t; + } + } + + public static int clampExponent(int requestedExponent) { + return Math.clamp(requestedExponent, MIN_EXPONENT, MAX_EXPONENT); + } + + public static long tableBytesForExponent(int requestedExponent) { + int exponent = clampExponent(requestedExponent); + int capacity = 1 << exponent; + try { + return Math.addExact(Math.multiplyExact((long) capacity, 8L), + Math.addExact(Math.multiplyExact((long) capacity, ACCUMULATION_STRIDE), + Math.multiplyExact((long) capacity, RESOLVED_STRIDE))); + } catch (ArithmeticException e) { + throw new IllegalArgumentException("SHaRC cache size overflow for exponent " + exponent, e); + } + } + + /** Estimated persistent SHaRC buffer footprint, including the mapped frame ring. */ + public static long memoryBytesForExponent(int requestedExponent) { + int exponent = clampExponent(requestedExponent); + try { + return Math.addExact(tableBytesForExponent(exponent), + Math.multiplyExact((long) RING, SharcFrameData.BYTE_SIZE)); + } catch (ArithmeticException e) { + throw new IllegalArgumentException("SHaRC memory estimate overflow for exponent " + exponent, e); + } + } + + public int exponent() { + return exponent; + } + + public int capacity() { + return capacity; + } + + public static int updateTileSize() { + return CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.value(); + } + + static float sanitizeCameraCoordinate(float value) { + return Float.isFinite(value) && Math.abs(value) <= SHARC_WORLD_LIMIT ? value : 0.0f; + } + + public void requestReset() { + pendingClear = true; + framesSinceReset = 0; + previousCamera = null; + } + + public boolean queryReady() { + return framesSinceReset >= QUERY_WARMUP_FRAMES; + } + + /** Advance the frame ring after waiting for its exact prior graphics use. */ + public long beginFrame(long frameIndex, float cameraX, float cameraY, float cameraZ, + RtGpuExecutor.GraphicsUseWaiter waiter) { + slot = (slot + 1) % RING; + waiter.await(frameUses[slot]); + Float3 camera = new Float3(sanitizeCameraCoordinate(cameraX), + sanitizeCameraCoordinate(cameraY), sanitizeCameraCoordinate(cameraZ)); + Float3 prior = previousCamera == null || pendingClear ? camera : previousCamera; + ByteBuffer mapped = MemoryUtil.memByteBuffer(frames[slot].mapped, SharcFrameData.BYTE_SIZE); + new SharcFrameData(hashEntries.deviceAddress, accumulation.deviceAddress, resolved.deviceAddress, + camera, capacity, prior, (int) frameIndex, + CausticaConfig.Rt.Sharc.SCENE_SCALE.value(), + CausticaConfig.Rt.Sharc.RADIANCE_SCALE.value(), + CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.value(), + CausticaConfig.Rt.Sharc.STALE_FRAMES.value(), + CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.value(), + CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.value(), + CausticaConfig.Rt.Sharc.ANTI_FIREFLY.value() ? 1 : 0).write(mapped); + frames[slot].flush(0L, SharcFrameData.BYTE_SIZE); + previousCamera = camera; + framesSinceReset = Math.min(framesSinceReset + 1, QUERY_WARMUP_FRAMES + 1); + return frames[slot].deviceAddress; + } + + public void commitFrameUse(RtGpuExecutor.GraphicsUse graphicsUse) { + frameUses[slot].mark(graphicsUse); + // A clear becomes authoritative only after the command buffer was accepted for submission. If + // recording or submission fails after recordPendingClear(), leave it pending so the next frame + // cannot accidentally reuse the old tables. + pendingClear = false; + } + + /** Clear all persistent tables before the sparse update when a reset was requested. */ + public void recordPendingClear(VkCommandBuffer cmd, MemoryStack stack) { + if (!pendingClear) return; + org.lwjgl.vulkan.VK10.vkCmdFillBuffer(cmd, hashEntries.handle, 0L, hashEntries.size, 0); + org.lwjgl.vulkan.VK10.vkCmdFillBuffer(cmd, accumulation.handle, 0L, accumulation.size, 0); + org.lwjgl.vulkan.VK10.vkCmdFillBuffer(cmd, resolved.handle, 0L, resolved.size, 0); + barrier(cmd, stack, VK_PIPELINE_STAGE_2_TRANSFER_BIT, VK_ACCESS_2_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR, + VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT); + } + + public void updateToResolveBarrier(VkCommandBuffer cmd, MemoryStack stack) { + barrier(cmd, stack, VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR, + VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, + VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT); + } + + public void resolveToQueryBarrier(VkCommandBuffer cmd, MemoryStack stack) { + barrier(cmd, stack, VK_PIPELINE_STAGE_2_COMPUTE_SHADER_BIT, + VK_ACCESS_2_SHADER_READ_BIT | VK_ACCESS_2_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_2_RAY_TRACING_SHADER_BIT_KHR, + VK_ACCESS_2_SHADER_READ_BIT, queryTables); + } + + private void barrier(VkCommandBuffer cmd, MemoryStack stack, long srcStage, long srcAccess, + long dstStage, long dstAccess) { + barrier(cmd, stack, srcStage, srcAccess, dstStage, dstAccess, tables); + } + + private void barrier(VkCommandBuffer cmd, MemoryStack stack, long srcStage, long srcAccess, + long dstStage, long dstAccess, RtBuffer[] buffers) { + VkBufferMemoryBarrier2.Buffer barriers = VkBufferMemoryBarrier2.calloc(buffers.length, stack); + for (int i = 0; i < buffers.length; i++) { + barriers.get(i).sType$Default().srcStageMask(srcStage).srcAccessMask(srcAccess) + .dstStageMask(dstStage).dstAccessMask(dstAccess) + .srcQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED).dstQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED) + .buffer(buffers[i].handle).offset(0L).size(buffers[i].size); + } + VkDependencyInfo dependency = VkDependencyInfo.calloc(stack).sType$Default() + .pBufferMemoryBarriers(barriers); + vkCmdPipelineBarrier2KHR(cmd, dependency); + } + + public void destroy() { + if (destroyed) return; + hashEntries.destroy(); + accumulation.destroy(); + resolved.destroy(); + for (RtBuffer frame : frames) frame.destroy(); + destroyed = true; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcSupport.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcSupport.java new file mode 100644 index 00000000..3c919dc0 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtSharcSupport.java @@ -0,0 +1,102 @@ +package dev.comfyfluffy.caustica.rt; + +import dev.comfyfluffy.caustica.CausticaMod; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** Optional-build and device-capability gate for the pinned NVIDIA SHaRC 1.8 shader family. */ +public final class RtSharcSupport { + public static final String VERSION = "1.8.0.0"; + public static final String COMMIT = "e19ccacd511f42a3df6f850052d508c13c9e9737"; + + private static final Properties METADATA = loadMetadata(); + private static final boolean ARTIFACTS_PRESENT = verifyArtifacts(); + private static volatile boolean shaderBufferInt64Atomics; + private static volatile boolean shaderFloat16; + private static volatile boolean storageBuffer16BitAccess; + private static volatile String failure; + + private RtSharcSupport() { + } + + /** True only when this jar was built with the exact SDK and contains SHaRC artifacts/license metadata. */ + public static boolean packaged() { + return "true".equalsIgnoreCase(METADATA.getProperty("artifacts")) + && VERSION.equals(METADATA.getProperty("version")) + && COMMIT.equalsIgnoreCase(METADATA.getProperty("commit")) + && "true".equalsIgnoreCase(METADATA.getProperty("directionalSh")) + && ARTIFACTS_PRESENT; + } + + /** Called during Vulkan device creation after the optional feature bits were queried. */ + public static void setDeviceFeaturesEnabled(boolean int64Atomics, boolean float16, boolean storage16) { + shaderBufferInt64Atomics = int64Atomics; + shaderFloat16 = float16; + storageBuffer16BitAccess = storage16; + } + + /** Latched runtime failure disables only SHaRC; ordinary Caustica RT continues. */ + public static void fail(String reason, Throwable cause) { + failure = reason; + if (cause == null) { + CausticaMod.LOGGER.warn("SHaRC disabled: {}", reason); + } else { + CausticaMod.LOGGER.warn("SHaRC disabled: " + reason, cause); + } + } + + public static void clearFailure() { + failure = null; + } + + public static boolean available() { + return packaged() && RtDeviceBringup.rtRequested() + && shaderBufferInt64Atomics && shaderFloat16 && storageBuffer16BitAccess + && failure == null; + } + + public static String status() { + if (!packaged()) return "unavailable (jar has no SHaRC artifacts)"; + if (!RtDeviceBringup.rtRequested()) return "unavailable (ray tracing device not enabled)"; + if (!shaderBufferInt64Atomics) return "unavailable (shaderBufferInt64Atomics unsupported)"; + if (!shaderFloat16) return "unavailable (shaderFloat16 unsupported)"; + if (!storageBuffer16BitAccess) return "unavailable (storageBuffer16BitAccess unsupported)"; + return failure == null ? "available (SHaRC " + VERSION + ")" : "unavailable (" + failure + ")"; + } + + private static Properties loadMetadata() { + Properties properties = new Properties(); + try (InputStream in = RtSharcSupport.class.getResourceAsStream("/caustica/sharc.properties")) { + if (in != null) properties.load(in); + } catch (IOException e) { + CausticaMod.LOGGER.warn("Could not read SHaRC build metadata", e); + } + return properties; + } + + /** Validate the fixed SHaRC resource set once when the class is initialized, never per frame. */ + private static boolean verifyArtifacts() { + String[] resources = { + "/caustica/shaders/pipelines/world/indirect_sharc_query.rgen.spv", + "/caustica/shaders/pipelines/world/indirect_sharc_ser_query.rgen.spv", + "/caustica/shaders/pipelines/world/indirect_sharc_update.rgen.spv", + "/caustica/shaders/pipelines/world/indirect_sharc_ser_update.rgen.spv", + "/caustica/shaders/sharc/sharc_resolve.comp.spv", + "/META-INF/licenses/nvidia/NVIDIA-SHARC-SDK.txt" + }; + for (String resource : resources) { + try (InputStream in = RtSharcSupport.class.getResourceAsStream(resource)) { + if (in == null || in.read() < 0) { + CausticaMod.LOGGER.warn("Missing SHaRC packaged resource: {}", resource); + return false; + } + } catch (IOException e) { + CausticaMod.LOGGER.warn("Could not validate SHaRC packaged resource: " + resource, e); + return false; + } + } + return true; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtSkyMath.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtSkyMath.java new file mode 100644 index 00000000..793483c1 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtSkyMath.java @@ -0,0 +1,32 @@ +package dev.comfyfluffy.caustica.rt; + +import net.minecraft.world.level.dimension.DimensionType; + +/** Shared CPU-side mapping for Minecraft's dimension sky modes and fog color conversion. */ +public final class RtSkyMath { + public static final int SKYBOX_NONE = 0; + public static final int SKYBOX_OVERWORLD = 1; + public static final int SKYBOX_END = 2; + public static final int SKY_FLAG_END_FLASH = 1; + + private RtSkyMath() { + } + + public static int skyboxMode(DimensionType.Skybox skybox) { + if (skybox == DimensionType.Skybox.NONE) { + return SKYBOX_NONE; + } + if (skybox == DimensionType.Skybox.END) { + return SKYBOX_END; + } + return SKYBOX_OVERWORLD; + } + + /** Minecraft fog colors are authored as sRGB values; the RT sky payload is linear BT.709. */ + public static float srgbToLinear(float value) { + value = Math.clamp(value, 0.0f, 1.0f); + return value <= 0.04045f + ? value / 12.92f + : (float) Math.pow((value + 0.055f) / 1.055f, 2.4f); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java index 42e0c6bd..52ef6950 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -187,7 +187,7 @@ private static int beBuildsPerFrame() { private CameraRenderState cameraState; // Particle capture: a VertexConsumer adapter that funnels MC's billboard quads into `capture` (the // shared entity mesh). We extract each live particle into `particleScratch`, accumulate per-vertex - // motion-vector displacements in `particleDisp`, and key the previous-frame center off particle + // motion-vector displacements in `particleDisp`, and key previous captured positions off particle // identity in `particlePrev` (rebuilt each frame → prunes dead particles). private final RtParticleCapture particleCapture = new RtParticleCapture(capture); private final QuadParticleRenderState particleScratch = new QuadParticleRenderState(); @@ -196,15 +196,17 @@ private static int beBuildsPerFrame() { private IdentityHashMap particleCur = new IdentityHashMap<>(); private final float[] particleCenterScratch = new float[3]; - /** Previous frame's particle center (rebase-space) + that frame's rebase origin, for the MV diff. */ + /** Previous frame's particle vertices (rebase-space) + that frame's rebase origin, for the MV diff. */ private static final class ParticlePrev { - float cx, cy, cz; + float[] vertices = new float[0]; int rbx, rby, rbz; - void set(float cx, float cy, float cz, int rbx, int rby, int rbz) { - this.cx = cx; - this.cy = cy; - this.cz = cz; + void set(float[] current, int vertBefore, int vertAfter, int rbx, int rby, int rbz) { + int count = (vertAfter - vertBefore) * 3; + if (vertices.length != count) { + vertices = new float[count]; + } + System.arraycopy(current, vertBefore * 3, vertices, 0, count); this.rbx = rbx; this.rby = rby; this.rbz = rbz; @@ -929,9 +931,11 @@ private static float[] buildDisp(float[] cur, int curSize, float[] prev, float s * Capture this frame's billboard particles as ONE combined mesh + BLAS (cutout, camera-only receiver), * with per-particle motion vectors. We iterate the LIVE {@code Particle} objects (via accessor mixins) * rather than the public packed render state, because only the live objects carry stable identity — - * needed to diff each particle's center against last frame for the MV. Each particle is extracted into + * needed to diff each particle's captured vertices against last frame for the MV. Each particle is + * extracted into * {@link #particleScratch} (its billboard quad), funneled through {@link #particleCapture} into the - * shared {@code capture}, and its quad center cached by identity in {@link #particlePrev}. Per-layer + * shared {@code capture}, and its captured positions cached by identity in {@link #particlePrev}. + * Per-layer * texture slot comes from the layer's atlas (block/item/particle) via the bindless registry. One * {@code PARTICLE_BIT} instance with mask {@link #PARTICLE_MASK} (primary-ray only). */ @@ -1006,7 +1010,7 @@ private void captureParticles(RtContext ctx, FrameBuild build, Minecraft mc, flo capture.alphaBuckets.size(abb); continue; } - appendParticleMv(p, particleCenterScratch, vertBefore, vertAfter, rbx, rby, rbz, cur); + appendParticleMv(p, vertBefore, vertAfter, rbx, rby, rbz, cur); build.logicalCount++; particlesCaptured++; } @@ -1044,27 +1048,32 @@ private void particleCenter(int vertBefore, int vertAfter, float[] out) { } /** - * Compute one particle's motion-vector displacement (its quad center vs. last frame's, keyed by - * identity) and write it for each of the particle's vertices into {@link #particleDisp}. All four - * billboard verts share the center displacement (per-particle-rigid MV). + * Compute one particle's per-vertex motion-vector displacement against its last captured geometry, + * keyed by identity, and write it into {@link #particleDisp}. A new particle or a changed vertex + * layout has no reliable correspondence and therefore gets zero motion for that frame. */ - private void appendParticleMv(Particle p, float[] center, int vertBefore, int vertAfter, + private void appendParticleMv(Particle p, int vertBefore, int vertAfter, int rbx, int rby, int rbz, IdentityHashMap cur) { ParticlePrev prev = particlePrev.remove(p); - // World displacement = (curCenter − prevCenter) + (rebaseCur − rebasePrev). New particle ⇒ 0 (no MV). - float dx = prev == null ? 0f : (center[0] - prev.cx) + (rbx - prev.rbx); - float dy = prev == null ? 0f : (center[1] - prev.cy) + (rby - prev.rby); - float dz = prev == null ? 0f : (center[2] - prev.cz) + (rbz - prev.rbz); + // World displacement is current-minus-previous vertex position plus the rebase-origin delta. + float[] vertices = capture.verts.elements(); + int count = vertAfter - vertBefore; + boolean matched = prev != null && prev.vertices.length == count * 3; + float rebasedDx = prev == null ? 0f : rbx - prev.rbx; + float rebasedDy = prev == null ? 0f : rby - prev.rby; + float rebasedDz = prev == null ? 0f : rbz - prev.rbz; for (int i = vertBefore; i < vertAfter; i++) { - particleDisp.add(dx); - particleDisp.add(dy); - particleDisp.add(dz); + int current = i * 3; + int old = (i - vertBefore) * 3; + particleDisp.add(matched ? vertices[current] - prev.vertices[old] + rebasedDx : 0f); + particleDisp.add(matched ? vertices[current + 1] - prev.vertices[old + 1] + rebasedDy : 0f); + particleDisp.add(matched ? vertices[current + 2] - prev.vertices[old + 2] + rebasedDz : 0f); particleDisp.add(0f); } if (prev == null) { prev = new ParticlePrev(); } - prev.set(center[0], center[1], center[2], rbx, rby, rbz); + prev.set(vertices, vertBefore, vertAfter, rbx, rby, rbz); cur.put(p, prev); } @@ -1230,7 +1239,7 @@ private BeEntry buildBe(RtContext ctx, FrameBuild build, BlockEntity be, long ha return e; } - /** FNV-1a hash of the currently captured mesh (positions + indices + per-prim data) for rebuild detection. */ + /** FNV-1a hash of the currently captured mesh (positions, indices, UVs, and per-prim data) for rebuild detection. */ private long meshHash() { long h = 1469598103934665603L; float[] v = capture.verts.elements(); @@ -1238,6 +1247,12 @@ private long meshHash() { for (int i = 0; i < vn; i++) { h = (h ^ (Float.floatToRawIntBits(v[i]) & 0xffffffffL)) * 1099511628211L; } + float[] uv = capture.uvList.elements(); + int un = capture.uvList.size(); + h = (h ^ (un & 0xffffffffL)) * 1099511628211L; + for (int i = 0; i < un; i++) { + h = (h ^ (Float.floatToRawIntBits(uv[i]) & 0xffffffffL)) * 1099511628211L; + } int[] x = capture.idx.elements(); int xn = capture.idx.size(); for (int i = 0; i < xn; i++) { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntityTextures.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntityTextures.java index 2f22e068..f433c830 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntityTextures.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntityTextures.java @@ -177,15 +177,38 @@ private int slotForView(long view) { /** Write any newly-registered entity textures into the pipeline's bindless set (before the trace). */ public void uploadPending(RtPipeline pipeline, long sampler) { + uploadPending(sampler, pipeline); + } + + /** Write newly registered textures into every pipeline that shares this texture epoch. */ + public void uploadPending(long sampler, RtPipeline... pipelines) { if (pending.isEmpty()) { return; } for (Pending p : pending) { - pipeline.setEntityAlbedoTexture(p.slot(), p.view(), sampler); + for (RtPipeline pipeline : pipelines) { + if (pipeline != null) { + pipeline.setEntityAlbedoTexture(p.slot(), p.view(), sampler); + } + } } pending.clear(); } + /** Populate all slots into a newly created pipeline before the pending queue is cleared. */ + public void uploadAll(long sampler, RtPipeline... pipelines) { + for (Map.Entry entry : viewSlotCache.entrySet()) { + long view = entry.getKey(); + int slot = entry.getValue(); + for (RtPipeline pipeline : pipelines) { + if (pipeline != null) { + pipeline.setEntityAlbedoTexture(slot, view, sampler); + } + } + } + uploadPending(sampler, pipelines); + } + /** Drop the registry (call when the world pipeline / bindless set is recreated, or textures reload). */ public void reset() { reset(maxTextures()); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java index 5e7aff67..cde89028 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/material/RtBlockMaterials.java @@ -296,9 +296,18 @@ public void prepareAll(RtContext ctx, int materialPageCapacity, RtEmissionSemant } public void bindPages(RtPipeline pipeline, long sampler) { + bindPages(sampler, pipeline); + } + + /** Bind the same resource-epoch material pages into every world-compatible pipeline. */ + public void bindPages(long sampler, RtPipeline... pipelines) { for (Page page : pages) { - pipeline.setMaterialPage(page.index(), page.surface0().view(), page.normalAo().view(), - page.surface1().view(), sampler); + for (RtPipeline pipeline : pipelines) { + if (pipeline != null) { + pipeline.setMaterialPage(page.index(), page.surface0().view(), page.normalAo().view(), + page.surface1().view(), sampler); + } + } } } 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 6438d673..1214e8b8 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayPipeline.java @@ -17,6 +17,7 @@ import org.lwjgl.vulkan.VkPushConstantRange; import org.lwjgl.vulkan.VkShaderModuleCreateInfo; import org.lwjgl.vulkan.VkWriteDescriptorSet; +import org.joml.Matrix4fc; import java.io.IOException; import java.io.InputStream; @@ -54,6 +55,11 @@ public final class RtDisplayPipeline { private long boundLookLutSampler; private long boundBloomView; private long boundBloomSampler; + private long boundSkyClassificationView; + private long boundEndSkyView; + private long boundEndSkySampler; + private long boundCelestialsView; + private long boundCelestialsSampler; private boolean destroyed; private RtDisplayPipeline(RtContext ctx, long dsl, long pool, long set, long layout, long pipeline) { @@ -90,6 +96,12 @@ public static RtDisplayPipeline create(RtContext ctx) { .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); binds.get(DISPLAY_BLOOM).binding(DISPLAY_BLOOM).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + binds.get(DISPLAY_SKY_CLASSIFICATION).binding(DISPLAY_SKY_CLASSIFICATION).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + binds.get(DISPLAY_END_SKY).binding(DISPLAY_END_SKY).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); + binds.get(DISPLAY_CELESTIALS).binding(DISPLAY_CELESTIALS).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1).stageFlags(VK10.VK_SHADER_STAGE_COMPUTE_BIT); VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); LongBuffer p = stack.mallocLong(1); @@ -98,8 +110,8 @@ public static RtDisplayPipeline create(RtContext ctx) { RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_DESCRIPTOR_SET_LAYOUT, dsl, "display descriptor set layout"); VkDescriptorPoolSize.Buffer poolSizes = VkDescriptorPoolSize.calloc(2, stack); - poolSizes.get(0).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(4); - poolSizes.get(1).type(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).descriptorCount(4); + poolSizes.get(0).type(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).descriptorCount(5); + poolSizes.get(1).type(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER).descriptorCount(6); VkDescriptorPoolCreateInfo dpci = VkDescriptorPoolCreateInfo.calloc(stack).sType$Default().maxSets(1).pPoolSizes(poolSizes); check(VK10.vkCreateDescriptorPool(vk, dpci, null, p), "vkCreateDescriptorPool(rt display)"); long pool = p.get(0); @@ -138,13 +150,18 @@ public static RtDisplayPipeline create(RtContext ctx) { public void setImages(long outputImageView, long rtImageView, long exposureImageView, long hdrImageView, long lutView, long lutSampler, long hdrLutView, long hdrLutSampler, - long lookLutView, long lookLutSampler, long bloomView, long bloomSampler) { + long lookLutView, long lookLutSampler, long bloomView, long bloomSampler, + long skyClassificationView, long endSkyView, long endSkySampler, + long celestialsView, long celestialsSampler) { if (boundOutputView == outputImageView && boundRtView == rtImageView && boundExposureView == exposureImageView && boundHdrView == hdrImageView && boundLutView == lutView && boundLutSampler == lutSampler && boundHdrLutView == hdrLutView && boundHdrLutSampler == hdrLutSampler && boundLookLutView == lookLutView && boundLookLutSampler == lookLutSampler - && boundBloomView == bloomView && boundBloomSampler == bloomSampler) { + && boundBloomView == bloomView && boundBloomSampler == bloomSampler + && boundSkyClassificationView == skyClassificationView && boundEndSkyView == endSkyView + && boundEndSkySampler == endSkySampler && boundCelestialsView == celestialsView + && boundCelestialsSampler == celestialsSampler) { return; } try (MemoryStack stack = MemoryStack.stackPush()) { @@ -165,6 +182,14 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage VkDescriptorImageInfo.Buffer bloomInfo = VkDescriptorImageInfo.calloc(1, stack); bloomInfo.get(0).imageView(bloomView).sampler(bloomSampler) .imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkDescriptorImageInfo.Buffer skyClassificationInfo = VkDescriptorImageInfo.calloc(1, stack); + skyClassificationInfo.get(0).imageView(skyClassificationView).imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkDescriptorImageInfo.Buffer endSkyInfo = VkDescriptorImageInfo.calloc(1, stack); + endSkyInfo.get(0).imageView(endSkyView).sampler(endSkySampler) + .imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); + VkDescriptorImageInfo.Buffer celestialsInfo = VkDescriptorImageInfo.calloc(1, stack); + celestialsInfo.get(0).imageView(celestialsView).sampler(celestialsSampler) + .imageLayout(VK10.VK_IMAGE_LAYOUT_GENERAL); VkWriteDescriptorSet.Buffer writes = VkWriteDescriptorSet.calloc(DISPLAY_BINDING_COUNT, stack); writes.get(DISPLAY_OUTPUT).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_OUTPUT) @@ -184,6 +209,14 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage writes.get(DISPLAY_BLOOM).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_BLOOM) .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) .pImageInfo(bloomInfo); + writes.get(DISPLAY_SKY_CLASSIFICATION).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_SKY_CLASSIFICATION) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE).pImageInfo(skyClassificationInfo); + writes.get(DISPLAY_END_SKY).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_END_SKY) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .pImageInfo(endSkyInfo); + writes.get(DISPLAY_CELESTIALS).sType$Default().dstSet(descriptorSet).dstBinding(DISPLAY_CELESTIALS) + .descriptorCount(1).descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .pImageInfo(celestialsInfo); VK10.vkUpdateDescriptorSets(ctx.vk(), writes, null); } boundOutputView = outputImageView; @@ -198,6 +231,11 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage boundLookLutSampler = lookLutSampler; boundBloomView = bloomView; boundBloomSampler = bloomSampler; + boundSkyClassificationView = skyClassificationView; + boundEndSkyView = endSkyView; + boundEndSkySampler = endSkySampler; + boundCelestialsView = celestialsView; + boundCelestialsSampler = celestialsSampler; } /** @@ -207,7 +245,10 @@ public void setImages(long outputImageView, long rtImageView, long exposureImage */ public void dispatch(VkCommandBuffer cmd, int width, int height, RtToneMapping.Settings toneMapping, int lutSize, float gamma, float hdrPeakNits, boolean lookEnabled, int lookLutSize, - float bloomStrength) { + float bloomStrength, Matrix4fc invViewProj, int skybox, int skyFlags, + float skyR, float skyG, float skyB, float skyA, + float endFlashIntensity, float endFlashX, float endFlashY, + float flashU0, float flashV0, float flashU1, float flashV1) { 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); @@ -229,7 +270,13 @@ public void dispatch(VkCommandBuffer cmd, int width, int height, RtToneMapping.S sdr.param0(), sdr.param1(), sdr.param2(), sdr.param3(), sdr.param4(), sdr.param5(), sdr.param6(), sdr.param7(), hdr.param0(), hdr.param1(), hdr.param2(), hdr.param3(), - hdr.param4(), hdr.param5(), hdr.param6(), hdr.param7()).write(push); + hdr.param4(), hdr.param5(), hdr.param6(), hdr.param7(), + new DisplayPushData.Float4(skyR, skyG, skyB, skyA), + invViewProj, + skybox, + skyFlags, + new DisplayPushData.Float4(0.0f, endFlashIntensity, endFlashX, endFlashY), + new DisplayPushData.Float4(flashU0, flashV0, flashU1, flashV1)).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/RtDlssRr.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java index 5977a299..f6250621 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java @@ -19,7 +19,8 @@ /** * DLSS Ray Reconstruction backend for the RT renderer. Runs the DLSSD (Ray Reconstruction) feature * over path-traced color + guide buffers (normals/roughness, diffuse/specular albedo, depth, motion - * vectors, reflection motion vectors), denoising and upscaling (render res → display res) in one pass. + * vectors, reflection motion vectors, sky responsivity, and particle classification), denoising and + * upscaling (render res → display res) in one pass. */ public final class RtDlssRr { public static final RtDlssRr INSTANCE = new RtDlssRr(); @@ -48,10 +49,62 @@ public static int quality() { return CausticaConfig.Rt.DlssRr.QUALITY.value(); } + public boolean hasFailed() { + return failed; + } + + /** NVIDIA's recommended texture LOD offset for the active DLSS render/display resolution pair. */ + public static float recommendedMipMapBias(int renderWidth, int displayWidth) { + if (renderWidth <= 0 || displayWidth <= 0) { + return 0.0f; + } + double bias = Math.log((double) renderWidth / (double) displayWidth) / Math.log(2.0) - 1.0; + return Double.isFinite(bias) ? (float) bias : 0.0f; + } + + public void resetFailureLatch() { + boolean canRetry = true; + if (!isNull(feature)) { + try { + VulkanDevice device = featureDevice != null ? featureDevice : currentDeviceOrNull(); + if (device == null) { + canRetry = false; + } else { + releaseFeature(device); + } + } catch (Throwable t) { + canRetry = false; + CausticaMod.LOGGER.warn("DLSS-RR feature reset could not release the old native handle", t); + } + } + if (!canRetry) { + failed = true; + featureInvalid = true; + return; + } + failed = false; + featureInvalid = false; + requestHistoryReset(); + NgxRuntime.INSTANCE.resetFailureLatch(); + } + + /** + * Request a reset on the next successful DLSSD evaluation. Callers must reserve this for a hard + * temporal discontinuity, such as a dimension/skybox transition, output or feature recreation, an + * explicit render-state invalidation, or recovery from a failed feature. Ordinary lighting and setting + * transitions keep history so DLSSD can smooth them without a visible reconstruction flash. + */ + public void requestHistoryReset() { + resetHistory = true; + lastFrameNanos = 0L; + } + private NgxLibrary lib; private MemorySegment feature = MemorySegment.NULL; + private VulkanDevice featureDevice; private boolean initialized; private boolean failed; + private boolean featureInvalid; private boolean loggedAvailable; private int featureRenderWidth = -1; @@ -79,7 +132,8 @@ public boolean isReady() { */ public boolean evaluate(long cmd, RtImage color, RtImage depth, RtImage motion, RtImage diffuseAlbedo, RtImage specularAlbedo, RtImage normals, - RtImage specularMotion, RtImage out, + RtImage specularMotion, RtImage particleMask, RtImage responsivityMask, + RtImage out, int renderWidth, int renderHeight, int displayWidth, int displayHeight, float jitterX, float jitterY, Matrix4fc worldToView, Matrix4fc viewToClip) { if (!isReady()) { @@ -105,18 +159,19 @@ public boolean evaluate(long cmd, RtImage color, RtImage depth, RtImage motion, specularAlbedo.view, specularAlbedo.image, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, normals.view, normals.image, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, specularMotion.view, specularMotion.image, VK10.VK_FORMAT_R16G16_SFLOAT, - 0L, 0L, 0, + particleMask.view, particleMask.image, VK10.VK_FORMAT_R8_UINT, + responsivityMask.view, responsivityMask.image, VK10.VK_FORMAT_R16_SFLOAT, out.view, out.image, VK10.VK_FORMAT_R16G16B16A16_SFLOAT, renderWidth, renderHeight, displayWidth, displayHeight, // jitter in render pixels; MVs are already in render-pixel units, so MV scale = 1. jitterX, jitterY, 1.0f, 1.0f, resetHistory ? 1 : 0, frameMs, worldToViewMatrix, viewToClipMatrix); } - resetHistory = false; if (NgxRuntime.ngxFailed(rc)) { throw new IllegalStateException("ngxshim_evaluate_dlssd failed: 0x" + Integer.toHexString(rc) + " last=0x" + Integer.toHexString(lib.lastResult())); } + resetHistory = false; return true; } catch (Throwable t) { failed = true; @@ -129,38 +184,78 @@ public boolean evaluate(long cmd, RtImage color, RtImage depth, RtImage motion, * Asks NGX what render resolution the current quality mode expects for the given display size. * Returns {@code null} only when RR is off (or already disabled from an earlier failure elsewhere) * — in that state there is no feature to query and the caller should trace at full resolution. - * Once RR is active, a failed query (stale shim, old driver, bad NGX result) throws instead of - * silently falling back, so a broken render/display sync is never masked. + * A failed query (stale shim, old driver, or bad NGX result) disables only RR; the compositor + * traces at display resolution and uses its normal non-RR blit path. */ public int[] queryOptimalRenderSize(int displayWidth, int displayHeight) { if (!enabled() || failed) { return null; } - if (!(((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device)) { + try { + if (!(((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device)) { + disableForQuery("Vulkan device backend is unavailable", null); + return null; + } + ensureInitialized(device); + if (!lib.hasQueryOptimalDlssd()) { + disableForQuery("ngxshim is missing ngxshim_query_optimal_dlssd (stale native shim)", null); + return null; + } + try (Arena arena = Arena.ofConfined()) { + MemorySegment outWidth = arena.allocate(ValueLayout.JAVA_INT); + MemorySegment outHeight = arena.allocate(ValueLayout.JAVA_INT); + MemorySegment outSharpness = arena.allocate(ValueLayout.JAVA_FLOAT); + int rc = lib.queryOptimalDlssd(displayWidth, displayHeight, quality(), outWidth, outHeight, outSharpness); + if (NgxRuntime.ngxFailed(rc)) { + disableForQuery("ngxshim_query_optimal_dlssd failed: 0x" + Integer.toHexString(rc), null); + return null; + } + int renderWidth = outWidth.get(ValueLayout.JAVA_INT, 0); + int renderHeight = outHeight.get(ValueLayout.JAVA_INT, 0); + if (!validRenderSize(renderWidth, renderHeight, displayWidth, displayHeight)) { + disableForQuery("ngxshim_query_optimal_dlssd returned invalid render size " + + renderWidth + "x" + renderHeight, null); + return null; + } + return new int[] { renderWidth, renderHeight }; + } + } catch (Throwable t) { + disableForQuery("ngxshim_query_optimal_dlssd threw", t); return null; } - ensureInitialized(device); - if (!lib.hasQueryOptimalDlssd()) { - throw new IllegalStateException("ngxshim is missing ngxshim_query_optimal_dlssd (stale native shim)"); - } - try (Arena arena = Arena.ofConfined()) { - MemorySegment outWidth = arena.allocate(ValueLayout.JAVA_INT); - MemorySegment outHeight = arena.allocate(ValueLayout.JAVA_INT); - MemorySegment outSharpness = arena.allocate(ValueLayout.JAVA_FLOAT); - int rc = lib.queryOptimalDlssd(displayWidth, displayHeight, quality(), outWidth, outHeight, outSharpness); - if (NgxRuntime.ngxFailed(rc)) { - throw new IllegalStateException("ngxshim_query_optimal_dlssd failed: 0x" + Integer.toHexString(rc)); - } - int renderWidth = outWidth.get(ValueLayout.JAVA_INT, 0); - int renderHeight = outHeight.get(ValueLayout.JAVA_INT, 0); - if (renderWidth <= 0 || renderHeight <= 0) { - throw new IllegalStateException( - "ngxshim_query_optimal_dlssd returned invalid render size " + renderWidth + "x" + renderHeight); + } + + private void disableForQuery(String reason, Throwable cause) { + failed = true; + featureInvalid = !isNull(feature); + if (featureInvalid) { + try { + VulkanDevice device = featureDevice != null ? featureDevice : currentDeviceOrNull(); + if (device != null) { + releaseFeature(device); + featureInvalid = false; + } + } catch (Throwable t) { + CausticaMod.LOGGER.warn("DLSS-RR query failure could not release the live native feature", t); } - return new int[] { renderWidth, renderHeight }; + } + requestHistoryReset(); + if (cause == null) { + CausticaMod.LOGGER.warn("DLSS-RR disabled; using full-resolution RT fallback: {}", reason); + } else { + CausticaMod.LOGGER.warn("DLSS-RR disabled; using full-resolution RT fallback: " + reason, cause); } } + private static boolean validRenderSize(int renderWidth, int renderHeight, int displayWidth, int displayHeight) { + if (displayWidth <= 0 || displayHeight <= 0 || renderWidth <= 0 || renderHeight <= 0 + || renderWidth > displayWidth || renderHeight > displayHeight) { + return false; + } + long aspectDelta = Math.abs((long) renderWidth * displayHeight - (long) displayWidth * renderHeight); + return aspectDelta <= Math.max(displayWidth, displayHeight); + } + /** * Ensure NGX is initialized and an RR feature exists for the given resolutions, creating it into * the supplied recording command buffer. Returns false (and disables itself) on any failure so the @@ -170,16 +265,19 @@ public boolean ensureFeature(long cmd, int renderWidth, int renderHeight, int di if (!enabled() || failed) { return false; } - if (!(((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device)) { - return false; - } try { + if (!(((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device)) { + failed = true; + requestHistoryReset(); + CausticaMod.LOGGER.warn("DLSS-RR disabled; Vulkan device backend is unavailable"); + return false; + } ensureInitialized(device); int quality = quality(); int preset = renderPreset(); if (featureRenderWidth != renderWidth || featureRenderHeight != renderHeight || featureDisplayWidth != displayWidth || featureDisplayHeight != displayHeight - || featureQuality != quality || featurePreset != preset + || featureQuality != quality || featurePreset != preset || featureInvalid || isNull(feature)) { releaseFeature(device); feature = lib.createDlssd(cmd, renderWidth, renderHeight, displayWidth, displayHeight, @@ -194,6 +292,7 @@ public boolean ensureFeature(long cmd, int renderWidth, int renderHeight, int di featureDisplayHeight = displayHeight; featureQuality = quality; featurePreset = preset; + featureDevice = device; resetHistory = true; // a fresh feature has no temporal history CausticaMod.LOGGER.info("DLSS-RR feature created: {}x{} -> {}x{} (quality {}, preset {})", renderWidth, renderHeight, displayWidth, displayHeight, quality, preset); @@ -230,25 +329,67 @@ private void ensureInitialized(VulkanDevice device) { /** * Release the RR feature. Does NOT shut down NGX — that is the shared {@link NgxRuntime}'s job at device * teardown ({@code NgxRuntime.shutdown()} in {@code CausticaClient.shutdownRt}), so FG can keep using NGX. + * Returns false while the native feature remains owned and the Vulkan device must stay alive. */ - public void destroy() { - if (((GpuDeviceAccessor) RenderSystem.getDevice()).caustica$getBackend() instanceof VulkanDevice device) { - releaseFeature(device); + public boolean destroy() { + try { + if (!isNull(feature)) { + VulkanDevice device = featureDevice != null ? featureDevice : currentDeviceOrNull(); + if (device == null) { + throw new IllegalStateException("DLSS-RR feature owner device is unavailable"); + } + releaseFeature(device); + } + } catch (Throwable t) { + CausticaMod.LOGGER.warn("DLSS-RR teardown failed; native ownership is retained until restart", t); } initialized = false; - lib = null; + if (isNull(feature)) { + lib = null; + featureDevice = null; + featureInvalid = false; + failed = false; + resetHistory = false; + lastFrameNanos = 0L; + loggedAvailable = false; + } else { + failed = true; + featureInvalid = true; + } + return isNull(feature); + } + + private static VulkanDevice currentDeviceOrNull() { + RtContext ctx = RtContext.currentOrNull(); + if (ctx != null) { + return ctx.device(); + } + if (RenderSystem.getDevice() instanceof GpuDeviceAccessor accessor + && accessor.caustica$getBackend() instanceof VulkanDevice device) { + return device; + } + return null; } private void releaseFeature(VulkanDevice device) { if (!isNull(feature)) { + VulkanDevice owner = featureDevice != null ? featureDevice : device; + if (owner == null) { + throw new IllegalStateException("DLSS-RR feature owner device is unavailable"); + } + if (lib == null) { + throw new IllegalStateException("DLSS-RR feature library is unavailable"); + } RtContext ctx = RtContext.currentOrNull(); - if (ctx != null && ctx.device() == device) { + if (ctx != null && ctx.device() == owner) { ctx.waitIdle(); } else { - VK10.vkDeviceWaitIdle(device.vkDevice()); + RtContext.check(owner, VK10.vkDeviceWaitIdle(owner.vkDevice()), + "vkDeviceWaitIdle before DLSS-RR release"); } lib.release(feature); feature = MemorySegment.NULL; + featureDevice = null; } featureRenderWidth = -1; featureRenderHeight = -1; diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java index ad37d880..34b7e7c7 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPathSamplerData.java @@ -34,7 +34,7 @@ public final class RtPathSamplerData { static final int BOUNCE_COUNT = MAX_SUPPORTED_BOUNCE + 1; public static final int MAX_RIS_CANDIDATES = 32; - static final int GROUP_COUNT = 3 + 1 + MAX_RIS_CANDIDATES * 2 + 1; + static final int GROUP_COUNT = 3 + 1 + MAX_RIS_CANDIDATES * 2 + 2; static final int ROOTS_PER_GROUP = 1 + DIMENSIONS * 2; static final int DIRECTION_TABLE_OFFSET = 0; 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 9694ffe6..a30e0659 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtPipeline.java @@ -154,7 +154,7 @@ public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, St binds.get(WORLD_BLOCK_ALBEDO).binding(WORLD_BLOCK_ALBEDO) .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) .descriptorCount(1).stageFlags(atlasStages); - for (int binding = WORLD_G_NORMAL; binding <= WORLD_G_SPEC_MOTION; binding++) { + for (int binding = WORLD_G_NORMAL; binding <= WORLD_G_SKY_CLASSIFICATION; binding++) { binds.get(binding).binding(binding).descriptorType(VK10.VK_DESCRIPTOR_TYPE_STORAGE_IMAGE) .descriptorCount(1).stageFlags(VK_SHADER_STAGE_RAYGEN_BIT_KHR); } @@ -168,6 +168,9 @@ public static RtPipeline create(RtContext ctx, String[] rgen, String[] rmiss, St .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) .descriptorCount(1) .stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR | VK_SHADER_STAGE_RAYGEN_BIT_KHR); + binds.get(WORLD_END_SKY).binding(WORLD_END_SKY) + .descriptorType(VK10.VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER) + .descriptorCount(1).stageFlags(VK_SHADER_STAGE_MISS_BIT_KHR); VkDescriptorSetLayoutCreateInfo dslci = VkDescriptorSetLayoutCreateInfo.calloc(stack).sType$Default().pBindings(binds); LongBuffer p = stack.mallocLong(1); check(VK10.vkCreateDescriptorSetLayout(vk, dslci, null, p), "vkCreateDescriptorSetLayout"); @@ -446,6 +449,11 @@ public boolean hasSkyAtlas() { return true; } + /** Bind Minecraft's standalone End sky texture for dimension-specific ray misses. */ + public void setEndSkyTexture(long imageView, long sampler) { + writeAtlasBinding(WORLD_END_SKY, imageView, sampler); + } + /** Bind this frame's atmosphere LUTs (see {@link RtSkyLut}); both share the LUT's own sampler. */ public void setSkyLuts(long skyViewImageView, long transmittanceImageView, long sampler) { writeAtlasBinding(WORLD_SKY_VIEW, skyViewImageView, sampler); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSharcResolvePipeline.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSharcResolvePipeline.java new file mode 100644 index 00000000..18f23f81 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtSharcResolvePipeline.java @@ -0,0 +1,120 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import dev.comfyfluffy.caustica.rt.RtContext; +import dev.comfyfluffy.caustica.rt.RtDebugLabels; +import org.lwjgl.system.MemoryStack; +import org.lwjgl.system.MemoryUtil; +import org.lwjgl.vulkan.VkCommandBuffer; +import org.lwjgl.vulkan.VkComputePipelineCreateInfo; +import org.lwjgl.vulkan.VkPipelineLayoutCreateInfo; +import org.lwjgl.vulkan.VkPipelineShaderStageCreateInfo; +import org.lwjgl.vulkan.VkPushConstantRange; +import org.lwjgl.vulkan.VkShaderModuleCreateInfo; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.LongBuffer; + +import static dev.comfyfluffy.caustica.rt.RtContext.check; +import static org.lwjgl.vulkan.VK10.VK_NULL_HANDLE; +import static org.lwjgl.vulkan.VK10.VK_PIPELINE_BIND_POINT_COMPUTE; +import static org.lwjgl.vulkan.VK10.VK_SHADER_STAGE_COMPUTE_BIT; +import static org.lwjgl.vulkan.VK10.vkCmdBindPipeline; +import static org.lwjgl.vulkan.VK10.vkCmdDispatch; +import static org.lwjgl.vulkan.VK10.vkCmdPushConstants; +import static org.lwjgl.vulkan.VK10.vkCreateComputePipelines; +import static org.lwjgl.vulkan.VK10.vkCreatePipelineLayout; +import static org.lwjgl.vulkan.VK10.vkCreateShaderModule; +import static org.lwjgl.vulkan.VK10.vkDestroyPipeline; +import static org.lwjgl.vulkan.VK10.vkDestroyPipelineLayout; +import static org.lwjgl.vulkan.VK10.vkDestroyShaderModule; + +/** Descriptor-free directional-SH resolve pass. */ +public final class RtSharcResolvePipeline { + private static final String SHADER = "/caustica/shaders/sharc/sharc_resolve.comp.spv"; + private static final int PUSH_BYTES = Long.BYTES; + + private final RtContext ctx; + private final long layout; + private final long pipeline; + private boolean destroyed; + + private RtSharcResolvePipeline(RtContext ctx, long layout, long pipeline) { + this.ctx = ctx; + this.layout = layout; + this.pipeline = pipeline; + } + + public static RtSharcResolvePipeline create(RtContext ctx) { + long layout = 0L; + long module = 0L; + long pipeline = 0L; + try (MemoryStack stack = MemoryStack.stackPush()) { + VkPushConstantRange.Buffer range = VkPushConstantRange.calloc(1, stack) + .stageFlags(VK_SHADER_STAGE_COMPUTE_BIT).offset(0).size(PUSH_BYTES); + VkPipelineLayoutCreateInfo layoutInfo = VkPipelineLayoutCreateInfo.calloc(stack).sType$Default() + .pPushConstantRanges(range); + LongBuffer p = stack.mallocLong(1); + check(vkCreatePipelineLayout(ctx.vk(), layoutInfo, null, p), + "vkCreatePipelineLayout(SHaRC resolve)"); + layout = p.get(0); + RtDebugLabels.name(ctx, org.lwjgl.vulkan.VK10.VK_OBJECT_TYPE_PIPELINE_LAYOUT, layout, + "SHaRC resolve layout"); + module = loadModule(ctx, stack); + VkPipelineShaderStageCreateInfo stage = VkPipelineShaderStageCreateInfo.calloc(stack).sType$Default() + .stage(VK_SHADER_STAGE_COMPUTE_BIT).module(module).pName(stack.UTF8("main")); + VkComputePipelineCreateInfo.Buffer createInfo = VkComputePipelineCreateInfo.calloc(1, stack); + createInfo.get(0).sType$Default().stage(stage).layout(layout); + check(vkCreateComputePipelines(ctx.vk(), VK_NULL_HANDLE, createInfo, null, p), + "vkCreateComputePipelines(SHaRC resolve)"); + pipeline = p.get(0); + vkDestroyShaderModule(ctx.vk(), module, null); + module = 0L; + RtDebugLabels.name(ctx, org.lwjgl.vulkan.VK10.VK_OBJECT_TYPE_PIPELINE, pipeline, + "SHaRC resolve"); + return new RtSharcResolvePipeline(ctx, layout, pipeline); + } catch (Throwable t) { + if (module != 0L) vkDestroyShaderModule(ctx.vk(), module, null); + if (pipeline != 0L) vkDestroyPipeline(ctx.vk(), pipeline, null); + if (layout != 0L) vkDestroyPipelineLayout(ctx.vk(), layout, null); + throw t; + } + } + + public void dispatch(VkCommandBuffer cmd, long frameAddress, int capacity) { + try (MemoryStack stack = MemoryStack.stackPush(); + RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "SHaRC resolve")) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline); + ByteBuffer push = stack.malloc(PUSH_BYTES).putLong(0, frameAddress); + vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, push); + vkCmdDispatch(cmd, (capacity + 255) / 256, 1, 1); + } + } + + public void destroy() { + if (destroyed) return; + vkDestroyPipeline(ctx.vk(), pipeline, null); + vkDestroyPipelineLayout(ctx.vk(), layout, null); + destroyed = true; + } + + private static long loadModule(RtContext ctx, MemoryStack stack) { + byte[] bytes; + try (InputStream in = RtSharcResolvePipeline.class.getResourceAsStream(SHADER)) { + if (in == null) throw new IllegalStateException("missing SHaRC SPIR-V resource: " + SHADER); + bytes = in.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("failed to read " + SHADER, e); + } + ByteBuffer code = MemoryUtil.memAlloc(bytes.length).put(bytes).flip(); + try { + VkShaderModuleCreateInfo info = VkShaderModuleCreateInfo.calloc(stack).sType$Default().pCode(code); + LongBuffer p = stack.mallocLong(1); + check(vkCreateShaderModule(ctx.vk(), info, null, p), "vkCreateShaderModule(SHaRC resolve)"); + return p.get(0); + } finally { + MemoryUtil.memFree(code); + } + } +} diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index 1661588a..c36c7c6c 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -151,5 +151,42 @@ "caustica.options.rt.debugView.6": "Specular", "caustica.options.rt.debugView.7": "Specular Motion", "caustica.options.rt.debugView.8": "Exposure False Color", - "caustica.options.rt.debugView.9": "Metering Weight" + "caustica.options.rt.debugView.9": "Metering Weight", + "caustica.options.rt.debugView.10": "Raw Path Trace", + + "caustica.options.rt.sharcMenu.open": "SHaRC Settings...", + "caustica.options.rt.sharcMenu.title": "SHaRC Settings", + "caustica.options.rt.sharcMenu.runtimeHeader": "Runtime Controls", + "caustica.options.rt.sharcMenu.statusHeader": "Status", + "caustica.options.rt.sharcMenu.status.runtime": "SHaRC: %s", + "caustica.options.rt.sharcMenu.status.memory": "SHaRC memory estimate: %s MiB", + "caustica.options.rt.sharcMenu.status.layout": "Compiled layout: directional SH; primary debug is opt-in", + "caustica.options.rt.sharcMenu.actionsHeader": "Cache Actions", + "caustica.options.rt.sharcMenu.clear": "Clear SHaRC Cache", + "caustica.options.rt.sharcMenu.defaults": "Restore Safe Defaults", + + "caustica.options.rt.sharcEnabled": "SHaRC Cache", + "caustica.options.rt.sharcEnabled.tooltip": "Use the optional NVIDIA SHaRC 1.8 directional-SH cache for eligible diffuse indirect lighting.", + "caustica.options.rt.sharcCacheExponent": "SHaRC Cache Size", + "caustica.options.rt.sharcCacheExponent.tooltip": "Number of SHaRC hash-grid entries as a power of two. Larger values consume more GPU memory and clear the cache when changed.", + "caustica.options.rt.sharcPrimarySurfaceDebug": "Primary Surface Debug", + "caustica.options.rt.sharcPrimarySurfaceDebug.tooltip": "Developer comparison mode that permits SHaRC on the camera-visible terminal surface. Off keeps the primary surface live.", + "caustica.options.rt.sharcAntiFirefly": "Anti-Firefly Weighting", + "caustica.options.rt.sharcAntiFirefly.tooltip": "Apply confidence-based weighting only to SHaRC cache updates; the live path estimator is unchanged.", + "caustica.options.rt.sharcUpdateTileSize": "Update Tile Size", + "caustica.options.rt.sharcUpdateTileSize.tooltip": "Pixels covered by one sparse SHaRC update ray. Larger tiles reduce update cost but refresh fewer entries per frame.", + "caustica.options.rt.sharcAccumulationFrames": "Accumulation Frames", + "caustica.options.rt.sharcAccumulationFrames.tooltip": "Temporal accumulation window used by the SHaRC cache.", + "caustica.options.rt.sharcStaleFrames": "Stale Frame Limit", + "caustica.options.rt.sharcStaleFrames.tooltip": "Frames without a new sample before SHaRC may evict an entry.", + "caustica.options.rt.sharcSceneScale": "Scene Scale", + "caustica.options.rt.sharcSceneScale.tooltip": "World scale used when selecting SHaRC spatial hash-grid levels.", + "caustica.options.rt.sharcRadianceScale": "Radiance Scale", + "caustica.options.rt.sharcRadianceScale.tooltip": "Quantization scale at the directional-radiance encoding boundary.", + "caustica.options.rt.sharcRoughnessThreshold": "Roughness Threshold", + "caustica.options.rt.sharcRoughnessThreshold.tooltip": "Additional minimum linear roughness for diffuse SHaRC ownership. Zero preserves the mirror cutoff.", + "caustica.options.rt.sharcGridLogarithmBase": "Grid Logarithm Base", + "caustica.options.rt.sharcGridLogarithmBase.tooltip": "Base used to select the SHaRC hash-grid level from distance.", + "caustica.options.rt.sharcGridLevelBias": "Grid Level Bias", + "caustica.options.rt.sharcGridLevelBias.tooltip": "Bias applied to the selected SHaRC hash-grid level." } diff --git a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java index d4c7b67d..deb9065f 100644 --- a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java @@ -80,6 +80,18 @@ void registersSamplingSettingsForConfigRoundTrips() { assertTrue(hasSetting("caustica.rt.risCandidates")); } + @Test + void dlssPresetDefaultsToSdkSelection() { + assertEquals(0, CausticaConfig.Rt.DlssRr.PRESET.defaultValue()); + } + + @Test + void registersSharcSettingsForConfigRoundTrips() { + CausticaConfig.ensureRegistered(); + assertTrue(hasSetting("caustica.rt.sharc.enabled")); + assertTrue(CausticaConfig.Rt.Sharc.ENABLED.defaultValue()); + } + @Test void paperWhiteCannotExceedTheSelectedPeak() { var paperWhite = CausticaConfig.Rt.Hdr.PAPER_WHITE_NITS; @@ -96,7 +108,6 @@ void paperWhiteCannotExceedTheSelectedPeak() { peak.set(previousPeak); } } - private static boolean hasSetting(String key) { return CausticaConfig.settings().stream().anyMatch(setting -> setting.key().equals(key)); } diff --git a/src/test/java/dev/comfyfluffy/caustica/client/CausticaClientTest.java b/src/test/java/dev/comfyfluffy/caustica/client/CausticaClientTest.java new file mode 100644 index 00000000..d2fdbfe5 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/client/CausticaClientTest.java @@ -0,0 +1,16 @@ +package dev.comfyfluffy.caustica.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class CausticaClientTest { + @Test + void nativeOwnershipMustFullyReleaseBeforeDeviceDestruction() { + assertFalse(CausticaClient.teardownRequiresRestart(true, true)); + assertTrue(CausticaClient.teardownRequiresRestart(false, true)); + assertTrue(CausticaClient.teardownRequiresRestart(true, false)); + assertTrue(CausticaClient.teardownRequiresRestart(false, false)); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/client/CausticaJitterTest.java b/src/test/java/dev/comfyfluffy/caustica/client/CausticaJitterTest.java new file mode 100644 index 00000000..e96850b3 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/client/CausticaJitterTest.java @@ -0,0 +1,32 @@ +package dev.comfyfluffy.caustica.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class CausticaJitterTest { + @Test + void dlaaUsesTheActualNativeResolutionAndThirtyTwoPhases() { + assertEquals(32, CausticaJitter.jitterPhaseCount(3840, 2160, 3840, 2160)); + } + + @Test + void phaseCountUsesTheLargestActualAxisRatio() { + assertEquals(72, CausticaJitter.jitterPhaseCount(1280, 720, 3840, 1080)); + assertEquals(72, CausticaJitter.jitterPhaseCount(1920, 360, 3840, 1080)); + } + + @Test + void resetRestartsTheSequence() { + CausticaJitter jitter = CausticaJitter.INSTANCE; + jitter.reset(); + jitter.prepare(1920, 1080, 1920, 1080); + float firstX = jitter.jitterPixelsX(); + float firstY = jitter.jitterPixelsY(); + jitter.prepare(1920, 1080, 1920, 1080); + jitter.reset(); + jitter.prepare(1920, 1080, 1920, 1080); + assertEquals(firstX, jitter.jitterPixelsX()); + assertEquals(firstY, jitter.jitterPixelsY()); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcSkyResetTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcSkyResetTest.java new file mode 100644 index 00000000..f9f82464 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcSkyResetTest.java @@ -0,0 +1,36 @@ +package dev.comfyfluffy.caustica.rt; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class RtSharcSkyResetTest { + @Test + void ordinaryMotionAndAngleWrapDoNotReset() { + RtComposite.SharcSkyState before = state(0.02f, 0.03f, 0.04f, 0.5f, 2); + assertFalse(RtComposite.hardSkyDiscontinuity(before, state(0.021f, 0.031f, 0.041f, 0.501f, 2))); + + float fullTurn = (float) (Math.PI * 2.0); + assertFalse(RtComposite.hardSkyDiscontinuity( + state(fullTurn - 0.01f, 0.0f, 0.0f, 0.5f, 2), + state(0.01f, 0.0f, 0.0f, 0.5f, 2))); + } + + @Test + void hardCelestialAndLightingChangesReset() { + RtComposite.SharcSkyState before = state(0.0f, 0.0f, 0.0f, 0.5f, 2); + assertTrue(RtComposite.hardSkyDiscontinuity(before, + state(RtComposite.SHARC_SKY_ANGLE_JUMP_RADIANS + 0.01f, 0.0f, 0.0f, 0.5f, 2))); + assertTrue(RtComposite.hardSkyDiscontinuity(before, state(0.0f, 0.0f, 0.0f, 0.5f, 3))); + assertTrue(RtComposite.hardSkyDiscontinuity(before, + new RtComposite.SharcSkyState(0, 1, 0.0f, 0.0f, 0.0f, 0.5f, 2, + 0.2f, 0.3f, 0.4f))); + } + + private static RtComposite.SharcSkyState state( + float sun, float moon, float stars, float brightness, int moonPhase) { + return new RtComposite.SharcSkyState(0, 0, sun, moon, stars, brightness, moonPhase, + 0.2f, 0.3f, 0.4f); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcTest.java new file mode 100644 index 00000000..2cb1803b --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/RtSharcTest.java @@ -0,0 +1,19 @@ +package dev.comfyfluffy.caustica.rt; + +import dev.comfyfluffy.caustica.rt.gen.SharcFrameData; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class RtSharcTest { + @Test + void clampsTheTableExponentAndAccountsForTheFrameRing() { + assertEquals(64L * 65536L, RtSharcCache.tableBytesForExponent(16)); + assertEquals(RtSharcCache.tableBytesForExponent(16) + + (long) RtSharcCache.RING * SharcFrameData.BYTE_SIZE, + RtSharcCache.memoryBytesForExponent(16)); + assertEquals(RtSharcCache.MIN_EXPONENT, RtSharcCache.clampExponent(1)); + assertEquals(RtSharcCache.MAX_EXPONENT, RtSharcCache.clampExponent(99)); + assertEquals(20, RtSharcCache.clampExponent(20)); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java index a49be8ac..fc1ecaf5 100644 --- a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDisplayShaderContractTest.java @@ -17,11 +17,11 @@ final class RtDisplayShaderContractTest { void acesAndAnalyticalModesUseTheirOwnedSceneSignals() throws IOException { String source = Files.readString(DISPLAY_SHADER).replaceAll("\\s+", " "); - assertTrue(source.contains("float3 exposedAcesCg = max(rt.rgb * exposure, float3(0.0));")); + assertTrue(source.contains("float3 exposedAcesCg = sceneLinearAcesCg * exposure;")); assertTrue(source.contains("exposedAcesCg += sampleBloom(pix, w, h) * max(pc.bloomStrength, 0.0);")); assertTrue(source.contains("if (pc.sdrMode == 0 || (pc.hdrEnabled != 0 && pc.hdrMode == 0)) { " + "lookedAcesCg = applyLook(exposedAcesCg); }")); - assertTrue(source.contains("? float4(tonemap(lookedAcesCg), 1.0) : float4(localSdrToneMap(exposedAcesCg), 1.0);")); + assertTrue(source.contains("? tonemap(lookedAcesCg) : localSdrToneMap(exposedAcesCg);")); assertTrue(source.contains("? float4(tonemapHdr(lookedAcesCg), 1.0) : float4(displayGammaHdr(localHdrToneMap(exposedAcesCg)), 1.0);")); assertFalse(source.contains("localSdrToneMap(lookedAcesCg)")); assertFalse(source.contains("localHdrToneMap(lookedAcesCg)")); diff --git a/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRrTest.java b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRrTest.java new file mode 100644 index 00000000..cb59dfc3 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRrTest.java @@ -0,0 +1,16 @@ +package dev.comfyfluffy.caustica.rt.pipeline; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class RtDlssRrTest { + @Test + void recommendedMipMapBiasUsesNvidiaResolutionFormula() { + assertEquals(-1.0f, RtDlssRr.recommendedMipMapBias(1920, 1920), 1.0e-6f); + assertEquals(-2.0f, RtDlssRr.recommendedMipMapBias(960, 1920), 1.0e-6f); + assertEquals(-1.5849625f, RtDlssRr.recommendedMipMapBias(1280, 1920), 1.0e-5f); + assertEquals(0.0f, RtDlssRr.recommendedMipMapBias(0, 1920), 0.0f); + assertEquals(0.0f, RtDlssRr.recommendedMipMapBias(1920, 0), 0.0f); + } +} From e38fab3c3e8aa0d307cace6fb1fc4c712ce96197 Mon Sep 17 00:00:00 2001 From: PEQHUB Date: Mon, 10 Aug 2026 11:06:37 -0500 Subject: [PATCH 4/6] feat: add immutable Ultra capture ownership --- .../comfyfluffy/caustica/CausticaConfig.java | 2 +- .../caustica/client/CaptureProgress.java | 48 ++++ .../caustica/client/CaptureSession.java | 234 ++++++++++++++++ .../caustica/client/CausticaClient.java | 20 +- .../caustica/client/CausticaKeyMappings.java | 18 ++ .../caustica/client/UltraScreenshot.java | 264 ++++++++++++++++++ .../caustica/mixin/GameRendererMixin.java | 3 + .../comfyfluffy/caustica/mixin/GuiMixin.java | 20 ++ .../caustica/mixin/KeyboardHandlerMixin.java | 33 +++ .../caustica/mixin/KeyboardInputMixin.java | 23 ++ .../caustica/mixin/MinecraftMixin.java | 57 ++++ .../caustica/mixin/MinecraftReloadMixin.java | 3 + .../caustica/mixin/MouseHandlerMixin.java | 26 ++ .../caustica/mixin/OptionsMixin.java | 30 ++ .../caustica/mixin/ScreenshotMixin.java | 92 ++++-- .../caustica/mixin/TextureAtlasMixin.java | 19 ++ .../comfyfluffy/caustica/rt/RtComposite.java | 125 ++++++++- .../caustica/rt/accel/RtAccel.java | 11 +- .../caustica/rt/entity/RtEntities.java | 39 ++- .../caustica/rt/pipeline/RtDlssRr.java | 3 +- .../caustica/rt/pipeline/RtExposure.java | 34 ++- .../resources/assets/caustica/lang/en_us.json | 13 + src/main/resources/caustica.mixins.json | 6 + .../caustica/client/CaptureProgressTest.java | 34 +++ .../client/CaptureSessionPolicyTest.java | 95 +++++++ .../mixin/KeyboardHandlerMixinTest.java | 20 ++ 26 files changed, 1222 insertions(+), 50 deletions(-) create mode 100644 src/main/java/dev/comfyfluffy/caustica/client/CaptureProgress.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/client/CaptureSession.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/client/CausticaKeyMappings.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/client/UltraScreenshot.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/mixin/GuiMixin.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixin.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardInputMixin.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/mixin/MouseHandlerMixin.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java create mode 100644 src/main/java/dev/comfyfluffy/caustica/mixin/TextureAtlasMixin.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/client/CaptureProgressTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/client/CaptureSessionPolicyTest.java create mode 100644 src/test/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixinTest.java diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index f5effc04..1c2de26f 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -957,7 +957,7 @@ private FrameStats() { } } - /** Optional high-dynamic-range screenshot output paired with vanilla's F2 PNG. */ + /** Optional scene-linear HDR screenshot output paired with vanilla's F2 PNG. */ public static final class Screenshots { public static final BooleanSetting EXR_ENABLED = bool("caustica.rt.screenshots.exr", "screenshots.exr-enabled", false); diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CaptureProgress.java b/src/main/java/dev/comfyfluffy/caustica/client/CaptureProgress.java new file mode 100644 index 00000000..409308c6 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/CaptureProgress.java @@ -0,0 +1,48 @@ +package dev.comfyfluffy.caustica.client; + +/** Counts only fresh renderer frames in one fixed phase and resolution. */ +final class CaptureProgress { + enum Result { + WAITING, + COMPLETE, + INVALID_PHASE, + DIMENSIONS_CHANGED + } + + private int freshFrames; + private int targetFrames; + private int width; + private int height; + + Result acceptFreshFrame(int phaseCount, int frameWidth, int frameHeight) { + if (phaseCount <= 0) { + return Result.INVALID_PHASE; + } + if (targetFrames == 0) { + targetFrames = phaseCount; + width = frameWidth; + height = frameHeight; + } else if (phaseCount != targetFrames || frameWidth != width || frameHeight != height) { + return Result.DIMENSIONS_CHANGED; + } + if (freshFrames >= targetFrames) { + return Result.WAITING; + } + return ++freshFrames == targetFrames ? Result.COMPLETE : Result.WAITING; + } + + void reset() { + freshFrames = 0; + targetFrames = 0; + width = 0; + height = 0; + } + + int freshFrames() { + return freshFrames; + } + + int targetFrames() { + return targetFrames; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CaptureSession.java b/src/main/java/dev/comfyfluffy/caustica/client/CaptureSession.java new file mode 100644 index 00000000..2467fc98 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/CaptureSession.java @@ -0,0 +1,234 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.rt.entity.RtEntities; +import dev.comfyfluffy.caustica.rt.terrain.RtTerrain; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.server.IntegratedServer; + +/** Owns the single immutable renderer snapshot used by finite capture modes. */ +public final class CaptureSession { + public enum Owner { + ULTRA_SCREENSHOT + } + + private static Owner owner; + private static Object levelIdentity; + private static int settingsSignature; + private static boolean remoteSnapshot; + private static long nextScreenshotToken; + private static long screenshotToken; + private static boolean screenshotIsUltra; + private static final ThreadLocal SCREENSHOT_THREAD_TOKEN = new ThreadLocal<>(); + + private CaptureSession() { + } + + public static boolean begin(Minecraft minecraft, Owner requestedOwner) { + if (owner != null || requestedOwner == null || minecraft.level == null || minecraft.player == null) { + return false; + } + Object nextLevelIdentity = minecraft.level; + int nextSettingsSignature = settingsSignature(minecraft); + boolean nextRemoteSnapshot = !shouldPauseIntegratedServer(minecraft); + boolean entitiesStarted = false; + boolean compositeStarted = false; + try { + if (minecraft.gameMode != null) { + minecraft.gameMode.stopDestroyBlock(); + } + entitiesStarted = true; + RtEntities.INSTANCE.beginCaptureSession(); + compositeStarted = true; + RtComposite.INSTANCE.beginCaptureSession(); + KeyMapping.releaseAll(); + owner = requestedOwner; + levelIdentity = nextLevelIdentity; + settingsSignature = nextSettingsSignature; + remoteSnapshot = nextRemoteSnapshot; + return true; + } catch (Throwable failure) { + Throwable cleanupFailure = null; + if (compositeStarted) { + try { + RtComposite.INSTANCE.endCaptureSession(); + } catch (Throwable t) { + cleanupFailure = appendFailure(cleanupFailure, t); + } + } + if (entitiesStarted) { + try { + RtEntities.INSTANCE.endCaptureSession(); + } catch (Throwable t) { + cleanupFailure = appendFailure(cleanupFailure, t); + } + } + if (nextRemoteSnapshot) { + try { + RtTerrain.requestFullClear(); + } catch (Throwable t) { + cleanupFailure = appendFailure(cleanupFailure, t); + } + } + if (cleanupFailure != null) { + failure.addSuppressed(cleanupFailure); + } + throwUnchecked(failure); + return false; + } + } + + public static void end() { + if (owner == null) { + return; + } + boolean rebuildRemoteScene = remoteSnapshot; + Throwable failure = null; + try { + RtComposite.INSTANCE.endCaptureSession(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + try { + RtEntities.INSTANCE.endCaptureSession(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + if (rebuildRemoteScene) { + try { + RtTerrain.requestFullClear(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + } + owner = null; + levelIdentity = null; + remoteSnapshot = false; + if (failure != null) { + throwUnchecked(failure); + } + } + + public static boolean active() { + return owner != null; + } + + public static boolean ownedBy(Owner expected) { + return owner == expected; + } + + public static boolean valid(Minecraft minecraft) { + return active() + && minecraft.level != null + && minecraft.player != null + && minecraft.level == levelIdentity + && minecraft.gui.screen() == null + && settingsSignature == settingsSignature(minecraft); + } + + public static boolean shouldPause(Minecraft minecraft) { + return active() && shouldPauseIntegratedServer(minecraft); + } + + /** Serializes asynchronous manual PNG writes and rejects callbacks from an older renderer instance. */ + public static synchronized long acquireScreenshot(boolean ultra) { + if (screenshotToken != 0L) { + return 0L; + } + screenshotToken = ++nextScreenshotToken; + screenshotIsUltra = ultra; + return screenshotToken; + } + + public static synchronized boolean screenshotIsUltra(long token) { + return token != 0L && token == screenshotToken && screenshotIsUltra; + } + + /** Releases the lease from the final vanilla PNG callback or a cancelled F4 capture. */ + public static synchronized void releaseScreenshot(long token) { + if (token != 0L && token == screenshotToken) { + screenshotToken = 0L; + screenshotIsUltra = false; + } + } + + public static synchronized void discardScreenshotsForShutdown() { + screenshotToken = 0L; + screenshotIsUltra = false; + SCREENSHOT_THREAD_TOKEN.remove(); + } + + public static long screenshotThreadToken() { + Long token = SCREENSHOT_THREAD_TOKEN.get(); + return token == null ? 0L : token; + } + + public static void bindScreenshotThreadToken(long token) { + SCREENSHOT_THREAD_TOKEN.set(token); + } + + public static void clearScreenshotThreadToken(long token) { + if (screenshotThreadToken() == token) { + SCREENSHOT_THREAD_TOKEN.remove(); + } + } + + /** Runtime-only SPP override; the configured setting is never mutated or serialized. */ + public static int effectiveSpp(int configured) { + return owner == Owner.ULTRA_SCREENSHOT ? UltraScreenshot.SCREENSHOT_SPP : configured; + } + + /** Runtime-only DLSS quality override; quality 5 is NVIDIA's DLAA mode. */ + public static int effectiveDlssQuality(int configured) { + return owner == Owner.ULTRA_SCREENSHOT ? UltraScreenshot.DLAA_QUALITY : configured; + } + + private static boolean shouldPauseIntegratedServer(Minecraft minecraft) { + if (!minecraft.hasSingleplayerServer()) { + return false; + } + IntegratedServer server = minecraft.getSingleplayerServer(); + return server != null && shouldPauseIntegratedServer(true, server.isPublished()); + } + + static boolean shouldPauseIntegratedServer(boolean hasIntegratedServer, boolean publishedToLan) { + return hasIntegratedServer && !publishedToLan; + } + + private static int settingsSignature(Minecraft minecraft) { + CausticaConfig.ensureRegistered(); + int hash = 1; + for (CausticaConfig.RuntimeSetting setting : CausticaConfig.settings()) { + Object value = setting.get(); + hash = 31 * hash + setting.key().hashCode(); + hash = 31 * hash + (value == null ? 0 : value.hashCode()); + } + hash = 31 * hash + minecraft.options.fov().get().hashCode(); + hash = 31 * hash + minecraft.options.renderDistance().get().hashCode(); + hash = 31 * hash + minecraft.options.entityDistanceScaling().get().hashCode(); + hash = 31 * hash + minecraft.options.biomeBlendRadius().get().hashCode(); + hash = 31 * hash + minecraft.options.gamma().get().hashCode(); + hash = 31 * hash + minecraft.options.getCameraType().hashCode(); + return hash; + } + + private static Throwable appendFailure(Throwable first, Throwable next) { + if (first == null) { + return next; + } + first.addSuppressed(next); + return first; + } + + private static void throwUnchecked(Throwable failure) { + if (failure instanceof RuntimeException runtime) { + throw runtime; + } + if (failure instanceof Error error) { + throw error; + } + throw new RuntimeException(failure); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java index 09e1dbde..078b6dc5 100644 --- a/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaClient.java @@ -15,6 +15,7 @@ import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.rendering.v1.InvalidateRenderStateCallback; +import net.minecraft.network.chat.Component; public final class CausticaClient implements ClientModInitializer { private static boolean rtInitDone = false; @@ -38,7 +39,7 @@ public void onInitializeClient() { } if (!VanillaRenderController.rtRuntimeWorkRequested()) { if (rtInitDone) { - shutdownRt(); + shutdownRt(false); } return; } @@ -61,7 +62,9 @@ public void onInitializeClient() { // material flags resolve from the first section (PBR on join, no re-extract). No-op // until we're in a world with the block atlas loaded, or once already created. RtComposite.INSTANCE.ensureResourcesReady(ctx); - RtTerrain.update(ctx); + if (!CaptureSession.active()) { + RtTerrain.update(ctx); + } // Log DLSS-FG availability once when frame generation is enabled (capability query only; // the present-loop integration that consumes it is built separately). if (dev.comfyfluffy.caustica.rt.pipeline.RtDlssFg.enabled()) { @@ -76,17 +79,26 @@ public void onInitializeClient() { // world. Fixes stale geometry persisting across an End→Overworld switch (coords alone aren't // world-unique). Resource reloads do NOT fire this; that path is handled separately. InvalidateRenderStateCallback.EVENT.register(() -> { + UltraScreenshot.INSTANCE.abort( + Component.translatable("caustica.status.ultraScreenshot.invalidated")); RtTerrain.requestFullClear(); RtComposite.INSTANCE.resetExposureHistory(); RtComposite.INSTANCE.resetFailureLatch(); // F3+A doubles as manual RT recovery after a latched failure }); ClientLifecycleEvents.CLIENT_STOPPING.register(client -> { - shutdownRt(); + shutdownRt(true); }); } - private static void shutdownRt() { + private static void shutdownRt(boolean finalClientStop) { + if (finalClientStop) { + UltraScreenshot.INSTANCE.shutdown(); + } else { + // A temporary RT teardown must not release a pending vanilla PNG write. Its callback + // remains the owner of the shared lease, preventing a second capture after RT restarts. + UltraScreenshot.INSTANCE.stopRenderer(); + } WorldRenderScaler.INSTANCE.destroy(); RtUiOverlay.destroy(); // GUI redirect is not gated by rtInitDone; always release its TextureTarget if (!rtInitDone) { diff --git a/src/main/java/dev/comfyfluffy/caustica/client/CausticaKeyMappings.java b/src/main/java/dev/comfyfluffy/caustica/client/CausticaKeyMappings.java new file mode 100644 index 00000000..c096513c --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/CausticaKeyMappings.java @@ -0,0 +1,18 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaMod; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; + +/** Player-facing Caustica key mappings grouped in Minecraft's Controls screen. */ +public final class CausticaKeyMappings { + public static final KeyMapping.Category CATEGORY = KeyMapping.Category.register( + Identifier.fromNamespaceAndPath(CausticaMod.MOD_ID, "controls")); + + private CausticaKeyMappings() { + } + + public static KeyMapping[] all() { + return new KeyMapping[] {UltraScreenshot.KEY}; + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/client/UltraScreenshot.java b/src/main/java/dev/comfyfluffy/caustica/client/UltraScreenshot.java new file mode 100644 index 00000000..6a4532f5 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/client/UltraScreenshot.java @@ -0,0 +1,264 @@ +package dev.comfyfluffy.caustica.client; + +import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.rt.pipeline.RtDlssRr; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; + +/** One-shot DLAA screenshot capture over one complete low-discrepancy jitter phase. */ +public final class UltraScreenshot { + public static final UltraScreenshot INSTANCE = new UltraScreenshot(); + public static final KeyMapping KEY = new KeyMapping( + "key.caustica.ultra_screenshot", GLFW.GLFW_KEY_F4, CausticaKeyMappings.CATEGORY); + + static final int DLAA_QUALITY = 5; + static final int SCREENSHOT_SPP = 8; + private static final long FRESH_FRAME_TIMEOUT_NANOS = 30_000_000_000L; + + private final CaptureProgress progress = new CaptureProgress(); + private long lastFreshFrameNanos; + private int width; + private int height; + private long captureLease; + + private UltraScreenshot() { + } + + public boolean active() { + return CaptureSession.ownedBy(CaptureSession.Owner.ULTRA_SCREENSHOT); + } + + public void toggle(Minecraft minecraft) { + if (active()) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.cancelled")); + return; + } + if (minecraft.level == null || minecraft.player == null) { + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.requiresWorld")); + return; + } + if (minecraft.gui.screen() != null) { + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.busy")); + return; + } + if (!CausticaConfig.Rt.ENABLED.value() || !CausticaConfig.Rt.DlssRr.ENABLED.value() + || CausticaConfig.Rt.Composite.DEBUG_VIEW.value() != 0 + || !RtComposite.INSTANCE.readyForUltraScreenshot()) { + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.requiresDlssRr")); + return; + } + long lease = CaptureSession.acquireScreenshot(true); + if (lease == 0L) { + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.busy")); + return; + } + try { + if (!CaptureSession.begin(minecraft, CaptureSession.Owner.ULTRA_SCREENSHOT)) { + CaptureSession.releaseScreenshot(lease); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.busy")); + return; + } + } catch (Throwable t) { + CaptureSession.releaseScreenshot(lease); + CausticaMod.LOGGER.error("Ultra screenshot session start failed", t); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + return; + } + captureLease = lease; + try { + progress.reset(); + width = minecraft.getWindow().getWidth(); + height = minecraft.getWindow().getHeight(); + lastFreshFrameNanos = System.nanoTime(); + // Reset jitter and reconstruction history while retaining the valid exposure image owned by the session. + RtComposite.INSTANCE.requestTemporalReset(false); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.started", SCREENSHOT_SPP)); + } catch (Throwable t) { + CausticaMod.LOGGER.error("Ultra screenshot setup failed", t); + restore(); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + } + } + + /** Per-render validation, including frames where the level compositor did not produce an image. */ + public void beginFrame(Minecraft minecraft) { + if (!active()) { + return; + } + if (!CaptureSession.valid(minecraft)) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.invalidated")); + return; + } + if (minecraft.getWindow().getWidth() != width + || minecraft.getWindow().getHeight() != height) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.resized")); + return; + } + if (!RtDlssRr.enabled() || RtDlssRr.INSTANCE.hasFailed() || RtComposite.INSTANCE.hasFailed()) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + return; + } + if (System.nanoTime() - lastFreshFrameNanos > FRESH_FRAME_TIMEOUT_NANOS) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.timedOut")); + } + } + + /** Called from {@code GameRenderer.render} at TAIL, after the final world, hand, and UI image exists. */ + public void frameRendered(Minecraft minecraft) { + if (!active() || !RtComposite.INSTANCE.producedFreshDlssRrFrame()) { + return; + } + CaptureProgress.Result result = progress.acceptFreshFrame( + RtComposite.INSTANCE.currentJitterPhaseCount(), + minecraft.getWindow().getWidth(), minecraft.getWindow().getHeight()); + if (result == CaptureProgress.Result.INVALID_PHASE) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + return; + } + if (result == CaptureProgress.Result.DIMENSIONS_CHANGED) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.resized")); + return; + } + lastFreshFrameNanos = System.nanoTime(); + if (result != CaptureProgress.Result.COMPLETE) { + return; + } + + long lease = captureLease; + if (!CaptureSession.screenshotIsUltra(lease)) { + cancel(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + return; + } + + // The wrapped callback owns the lease after grab queues the asynchronous GPU copy. + boolean screenshotSubmitted = false; + try { + CaptureSession.bindScreenshotThreadToken(lease); + restoreForOutput(); + Screenshot.grab(minecraft, false); + screenshotSubmitted = true; + } catch (Throwable t) { + CausticaMod.LOGGER.error("Ultra screenshot capture failed", t); + notify(minecraft, Component.translatable("caustica.status.ultraScreenshot.failed")); + } finally { + CaptureSession.clearScreenshotThreadToken(lease); + if (!screenshotSubmitted) { + CaptureSession.releaseScreenshot(lease); + } + captureLease = 0L; + } + } + + public void abort(Component reason) { + if (!active()) { + return; + } + Minecraft minecraft = Minecraft.getInstance(); + restore(); + notify(minecraft, reason); + } + + /** Ends an active renderer-owned capture. */ + public void stopRenderer() { + if (active()) { + restore(); + } + } + + public void shutdown() { + stopRenderer(); + captureLease = 0L; + CaptureSession.discardScreenshotsForShutdown(); + } + + private void cancel(Minecraft minecraft, Component reason) { + if (!active()) { + return; + } + restore(); + notify(minecraft, reason); + } + + private void restore() { + restoreState(false); + } + + private void restoreForOutput() { + Throwable failure = restoreState(true); + if (failure != null) { + throw new IllegalStateException("Ultra screenshot cleanup failed", failure); + } + } + + private Throwable restoreState(boolean retainLease) { + boolean recoverRenderer = shouldRecoverRenderer( + RtDlssRr.INSTANCE.hasFailed(), RtComposite.INSTANCE.hasFailed()); + Throwable failure = restoreRendererState( + recoverRenderer, + CaptureSession::end, + RtComposite.INSTANCE::resetFailureLatch, + RtComposite.INSTANCE::requestTemporalReset); + progress.reset(); + lastFreshFrameNanos = 0L; + width = 0; + height = 0; + if (!retainLease && captureLease != 0L) { + CaptureSession.releaseScreenshot(captureLease); + captureLease = 0L; + } + if (failure != null) { + CausticaMod.LOGGER.error("Ultra screenshot cleanup failed", failure); + } + return failure; + } + + static Throwable restoreRendererState(boolean recoverRenderer, + Runnable endCapture, + Runnable resetFailure, + Runnable resetTemporal) { + Throwable failure = null; + try { + endCapture.run(); + } catch (Throwable t) { + failure = t; + } + if (recoverRenderer) { + try { + // CaptureSession.end restores the configured RR quality before ordinary rendering retries. + resetFailure.run(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + } + try { + resetTemporal.run(); + } catch (Throwable t) { + failure = appendFailure(failure, t); + } + return failure; + } + + static boolean shouldRecoverRenderer(boolean rrFailed, boolean compositeFailed) { + return rrFailed || compositeFailed; + } + + private static Throwable appendFailure(Throwable first, Throwable next) { + if (first == null) { + return next; + } + first.addSuppressed(next); + return first; + } + + private static void notify(Minecraft minecraft, Component message) { + if (minecraft.player != null) { + minecraft.player.sendOverlayMessage(message); + } + } + +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java index 30e725cb..9bd991b6 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/GameRendererMixin.java @@ -5,6 +5,7 @@ import com.mojang.blaze3d.pipeline.RenderTarget; import com.mojang.blaze3d.systems.RenderSystem; import com.mojang.blaze3d.vulkan.VulkanDevice; +import dev.comfyfluffy.caustica.client.UltraScreenshot; import dev.comfyfluffy.caustica.client.VanillaRenderController; import dev.comfyfluffy.caustica.client.WorldRenderScaler; import dev.comfyfluffy.caustica.rt.RtComposite; @@ -12,6 +13,7 @@ import dev.comfyfluffy.caustica.rt.RtUiOverlay; import dev.comfyfluffy.caustica.rt.overlay.RtWorldOverlay; import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.GameRenderer; import net.minecraft.client.renderer.SubmitNodeStorage; import net.minecraft.client.renderer.feature.FeatureRenderDispatcher; @@ -62,6 +64,7 @@ public abstract class GameRendererMixin { @Inject(method = "render(Lnet/minecraft/client/DeltaTracker;Z)V", at = @At("TAIL")) private void caustica$endRtFrameStats(DeltaTracker deltaTracker, boolean advanceGameTime, CallbackInfo ci) { RtComposite.INSTANCE.endFrame(); + UltraScreenshot.INSTANCE.frameRendered(Minecraft.getInstance()); } @Inject(method = "render(Lnet/minecraft/client/DeltaTracker;Z)V", diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/GuiMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/GuiMixin.java new file mode 100644 index 00000000..fbb9eb12 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/GuiMixin.java @@ -0,0 +1,20 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** Routes unshared single-player captures through Minecraft's normal pause decision. */ +@Mixin(Gui.class) +public abstract class GuiMixin { + @Inject(method = "isPausing", at = @At("HEAD"), cancellable = true) + private void caustica$pauseLocalCapture(CallbackInfoReturnable cir) { + if (CaptureSession.shouldPause(Minecraft.getInstance())) { + cir.setReturnValue(true); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixin.java new file mode 100644 index 00000000..e0d41a09 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixin.java @@ -0,0 +1,33 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import dev.comfyfluffy.caustica.client.UltraScreenshot; +import net.minecraft.client.KeyboardHandler; +import net.minecraft.client.Minecraft; +import net.minecraft.client.input.KeyEvent; +import org.lwjgl.glfw.GLFW; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Suppresses keyboard mutations during capture while preserving releases and the F4 cancel action. */ +@Mixin(KeyboardHandler.class) +public abstract class KeyboardHandlerMixin { + @Inject(method = "keyPress", at = @At("HEAD"), cancellable = true) + private void caustica$freezeCaptureKeys(long window, int action, KeyEvent event, CallbackInfo ci) { + Minecraft minecraft = Minecraft.getInstance(); + boolean ultraToggle = action == GLFW.GLFW_PRESS + && !minecraft.options.keyDebugModifier.isDown() + && UltraScreenshot.KEY.matches(event); + if (shouldSuppressCaptureKey(CaptureSession.active(), action, ultraToggle)) { + ci.cancel(); + } + } + + static boolean shouldSuppressCaptureKey(boolean captureActive, int action, boolean ultraToggle) { + return captureActive + && action != GLFW.GLFW_RELEASE + && !(action == GLFW.GLFW_PRESS && ultraToggle); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardInputMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardInputMixin.java new file mode 100644 index 00000000..caad5f35 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/KeyboardInputMixin.java @@ -0,0 +1,23 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import net.minecraft.client.player.ClientInput; +import net.minecraft.client.player.KeyboardInput; +import net.minecraft.world.entity.player.Input; +import net.minecraft.world.phys.Vec2; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Prevents a remote capture camera from continuing to drive player movement packets. */ +@Mixin(KeyboardInput.class) +public abstract class KeyboardInputMixin extends ClientInput { + @Inject(method = "tick", at = @At("TAIL")) + private void caustica$freezeCaptureMovement(CallbackInfo ci) { + if (CaptureSession.active()) { + keyPresses = Input.EMPTY; + moveVector = Vec2.ZERO; + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java index cb5da73f..126d577e 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftMixin.java @@ -1,17 +1,26 @@ package dev.comfyfluffy.caustica.mixin; import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.blaze3d.platform.InputConstants; import com.mojang.blaze3d.vulkan.VulkanDevice; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import dev.comfyfluffy.caustica.client.CaptureSession; +import dev.comfyfluffy.caustica.client.UltraScreenshot; import dev.comfyfluffy.caustica.rt.RtReflex; import dev.comfyfluffy.caustica.rt.RtUiOverlay; import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; + +import java.io.File; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; /** * The Reflex per-frame sleep call must run at the very start of the frame, before input @@ -30,9 +39,57 @@ public abstract class MinecraftMixin { @Inject(method = "close", at = @At("HEAD")) private void caustica$destroyUiOverlayBeforeRendererShutdown(CallbackInfo ci) { + UltraScreenshot.INSTANCE.shutdown(); RtUiOverlay.destroy(); } + @Inject(method = "handleGlobalKeyPress", at = @At("HEAD"), cancellable = true) + private void caustica$handleCaptureKeys(InputConstants.Key key, boolean controlDown, + CallbackInfoReturnable cir) { + Minecraft minecraft = (Minecraft) (Object) this; + if (!minecraft.options.keyDebugModifier.isDown() && UltraScreenshot.KEY.matches(key)) { + UltraScreenshot.INSTANCE.toggle(minecraft); + cir.setReturnValue(true); + } + } + + /** Rejects panorama capture before vanilla mutates the camera, window, or render target. */ + @WrapMethod(method = "grabPanoramixScreenshot(Ljava/io/File;)Lnet/minecraft/network/chat/Component;") + private Component caustica$guardPanorama(File directory, Operation original) { + if (CaptureSession.active()) { + return Component.translatable("caustica.status.ultraScreenshot.busy"); + } + return original.call(directory); + } + + @Inject(method = "startAttack", at = @At("HEAD"), cancellable = true) + private void caustica$suppressCaptureAttack(CallbackInfoReturnable cir) { + if (CaptureSession.active()) { + cir.setReturnValue(false); + } + } + + @Inject(method = "continueAttack", at = @At("HEAD"), cancellable = true) + private void caustica$suppressCaptureAttackHold(boolean leftClick, CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } + + @Inject(method = "startUseItem", at = @At("HEAD"), cancellable = true) + private void caustica$suppressCaptureUse(CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } + + @Inject(method = "pickBlockOrEntity", at = @At("HEAD"), cancellable = true) + private void caustica$suppressCapturePick(CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } + @Inject(method = "runTick", at = @At("HEAD")) private void caustica$reflexSleepAndSimStart(boolean advanceGameTime, CallbackInfo ci) { VulkanDevice device = caustica$reflexDevice(); diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftReloadMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftReloadMixin.java index 19b9a512..c2d02eeb 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftReloadMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/MinecraftReloadMixin.java @@ -1,6 +1,8 @@ package dev.comfyfluffy.caustica.mixin; import dev.comfyfluffy.caustica.rt.RtComposite; +import dev.comfyfluffy.caustica.client.UltraScreenshot; +import net.minecraft.network.chat.Component; import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -24,6 +26,7 @@ public class MinecraftReloadMixin { @Inject(method = "reloadResourcePacks()Ljava/util/concurrent/CompletableFuture;", at = @At("HEAD")) private void caustica$rtReloadStart(CallbackInfoReturnable> cir) { + UltraScreenshot.INSTANCE.abort(Component.translatable("caustica.status.ultraScreenshot.invalidated")); RtComposite.INSTANCE.onResourceReloadStart(); } } diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/MouseHandlerMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/MouseHandlerMixin.java new file mode 100644 index 00000000..32dfeb37 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/MouseHandlerMixin.java @@ -0,0 +1,26 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import net.minecraft.client.MouseHandler; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Keeps the capture camera immutable without blocking GLFW event processing. */ +@Mixin(MouseHandler.class) +public abstract class MouseHandlerMixin { + @Inject(method = "onScroll", at = @At("HEAD"), cancellable = true) + private void caustica$freezeCaptureScroll(long window, double horizontal, double vertical, CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } + + @Inject(method = "turnPlayer", at = @At("HEAD"), cancellable = true) + private void caustica$freezeCaptureCamera(double frameTime, CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java new file mode 100644 index 00000000..04226804 --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/OptionsMixin.java @@ -0,0 +1,30 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CausticaKeyMappings; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Options; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.Arrays; + +/** Adds Caustica's capture key to the vanilla Controls screen. */ +@Mixin(Options.class) +public abstract class OptionsMixin { + @Shadow @Final @Mutable public KeyMapping[] keyMappings; + + @Inject(method = "", at = @At(value = "FIELD", + target = "Lnet/minecraft/client/Options;keyMappings:[Lnet/minecraft/client/KeyMapping;", + shift = At.Shift.AFTER)) + private void caustica$addKeyMappings(CallbackInfo ci) { + KeyMapping[] additions = CausticaKeyMappings.all(); + int originalLength = keyMappings.length; + keyMappings = Arrays.copyOf(keyMappings, originalLength + additions.length); + System.arraycopy(additions, 0, keyMappings, originalLength, additions.length); + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java index c1a7dd7d..7f6068b3 100644 --- a/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/ScreenshotMixin.java @@ -1,44 +1,98 @@ package dev.comfyfluffy.caustica.mixin; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.blaze3d.platform.NativeImage; import dev.comfyfluffy.caustica.CausticaConfig; +import dev.comfyfluffy.caustica.client.CaptureSession; import dev.comfyfluffy.caustica.client.RtScreenshotExporter; import net.minecraft.client.Screenshot; import net.minecraft.network.chat.Component; import org.jspecify.annotations.Nullable; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import java.io.File; import java.util.function.Consumer; -/** Hooks only vanilla's auto-named F2 capture; named panorama/debug captures remain PNG-only. */ +/** Preserves the F2 EXR pair while preventing vanilla readbacks during the renderer-owned F4 snapshot. */ @Mixin(Screenshot.class) public abstract class ScreenshotMixin { - @Inject( - method = "grab(Ljava/io/File;Ljava/lang/String;Lcom/mojang/blaze3d/pipeline/RenderTarget;ILjava/util/function/Consumer;)V", - at = @At("HEAD"), - cancellable = true - ) - private static void caustica$exportResidualExposureExr( + @WrapMethod( + method = "grab(Ljava/io/File;Ljava/lang/String;Lcom/mojang/blaze3d/pipeline/RenderTarget;ILjava/util/function/Consumer;)V") + private static void caustica$guardNamedPng( File workDir, @Nullable String forceName, RenderTarget target, int downscaleFactor, Consumer callback, - CallbackInfo ci + Operation original ) { - if (forceName == null && downscaleFactor == 1 - && CausticaConfig.Rt.Screenshots.EXR_ENABLED.value()) { - String pairedPngName = RtScreenshotExporter.exportPaired(workDir, callback); - if (pairedPngName != null) { - // Re-enter vanilla's named path with our reserved PNG name. The non-null name bypasses - // this hook on the nested call and makes both outputs use exactly one basename. - Screenshot.grab(workDir, pairedPngName, target, downscaleFactor, callback); - ci.cancel(); + if (CaptureSession.active()) { + callback.accept(Component.translatable("caustica.status.screenshot.busy")); + return; + } + long token = CaptureSession.screenshotThreadToken(); + boolean inherited = CaptureSession.screenshotIsUltra(token); + if (!inherited) { + token = CaptureSession.acquireScreenshot(false); + if (token == 0L) { + callback.accept(Component.translatable("caustica.status.screenshot.busy")); + return; + } + } + long callbackToken = token; + Consumer leasedCallback = result -> { + try { + callback.accept(result); + } finally { + CaptureSession.releaseScreenshot(callbackToken); + } + }; + try { + String screenshotName = forceName; + if (screenshotName == null && downscaleFactor == 1 + && CausticaConfig.Rt.Screenshots.EXR_ENABLED.value()) { + screenshotName = RtScreenshotExporter.exportPaired(workDir, callback); + } + original.call(workDir, screenshotName, target, downscaleFactor, leasedCallback); + } catch (Throwable t) { + CaptureSession.releaseScreenshot(callbackToken); + throw t; + } + } + + @WrapMethod( + method = "takeScreenshot(Lcom/mojang/blaze3d/pipeline/RenderTarget;Ljava/util/function/Consumer;)V") + private static void caustica$guardAutomaticPng( + RenderTarget target, + Consumer callback, + Operation original + ) { + if (CaptureSession.active()) { + return; + } + long token = CaptureSession.screenshotThreadToken(); + boolean inherited = CaptureSession.screenshotIsUltra(token); + if (!inherited) { + token = CaptureSession.acquireScreenshot(false); + if (token == 0L) { + return; + } + } + long callbackToken = token; + Consumer leasedCallback = image -> { + try { + callback.accept(image); + } finally { + CaptureSession.releaseScreenshot(callbackToken); } + }; + try { + original.call(target, leasedCallback); + } catch (Throwable t) { + CaptureSession.releaseScreenshot(callbackToken); + throw t; } } } diff --git a/src/main/java/dev/comfyfluffy/caustica/mixin/TextureAtlasMixin.java b/src/main/java/dev/comfyfluffy/caustica/mixin/TextureAtlasMixin.java new file mode 100644 index 00000000..34ab6bfe --- /dev/null +++ b/src/main/java/dev/comfyfluffy/caustica/mixin/TextureAtlasMixin.java @@ -0,0 +1,19 @@ +package dev.comfyfluffy.caustica.mixin; + +import dev.comfyfluffy.caustica.client.CaptureSession; +import net.minecraft.client.renderer.texture.TextureAtlas; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** Stops animated scene sprites while a capture owns one immutable scene. */ +@Mixin(TextureAtlas.class) +public abstract class TextureAtlasMixin { + @Inject(method = "tick", at = @At("HEAD"), cancellable = true) + private void caustica$freezeCaptureAnimations(CallbackInfo ci) { + if (CaptureSession.active()) { + ci.cancel(); + } + } +} diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java index e8464f21..cf2a4324 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/RtComposite.java @@ -11,6 +11,8 @@ import dev.comfyfluffy.caustica.CausticaConfig; import dev.comfyfluffy.caustica.CausticaMod; import dev.comfyfluffy.caustica.client.CausticaJitter; +import dev.comfyfluffy.caustica.client.CaptureSession; +import dev.comfyfluffy.caustica.client.UltraScreenshot; import dev.comfyfluffy.caustica.mixin.CommandEncoderAccessor; import dev.comfyfluffy.caustica.rt.gen.WorldPushConstantsData; import dev.comfyfluffy.caustica.rt.gen.WorldPushData; @@ -128,7 +130,7 @@ private static boolean rawDebugView() { } private static int spp() { - return CausticaConfig.Rt.Composite.SPP.value(); + return CaptureSession.effectiveSpp(CausticaConfig.Rt.Composite.SPP.value()); } private static int maxBounces() { @@ -336,6 +338,17 @@ private static final class PushSlot { private double camY; private double camZ; private boolean frameCaptured; + private boolean captureCameraFrozen; + private boolean captureWorldPushFrozen; + private int captureFlags; + private Float4 captureWaterParams; + private Float4 captureWaterAnchor; + private BreakEntry[] captureBreaking; + private SkyPush captureSky; + private RtAccel.PreparedTlas captureTlas; + private boolean freshRtFrame; + private boolean freshDlssRrFrame; + private int jitterPhaseCount; private long celestialUvAtlasHandle; private int celestialUvMoonPhase = -1; private float sunU0; @@ -561,6 +574,10 @@ public void resetFailureLatch() { /** Capture one coherent camera, dimension-sky, and vanilla sky-color snapshot for the next composite. */ public void captureFrame(Matrix4f projection, Matrix4fc viewRotation, double cameraX, double cameraY, double cameraZ, FogData vanillaFogData) { + if (CaptureSession.active() && captureCameraFrozen) { + frameCaptured = true; + return; + } frameProjection.set(projection); frameViewRotation.set(viewRotation); camX = cameraX; @@ -576,6 +593,7 @@ public void captureFrame(Matrix4f projection, Matrix4fc viewRotation, double cam frameSkyboxValid = true; captureSkyColor(vanillaFogData); frameCaptured = true; + captureCameraFrozen = CaptureSession.active(); } /** Read vanilla's resolved sky color for End-sky compositing without modifying the fog pipeline. */ @@ -601,6 +619,42 @@ private static float finiteColor(float value) { return Float.isFinite(value) ? Math.clamp(value, 0.0f, 1.0f) : 0.0f; } + /** Freeze renderer-owned scene inputs for a finite multi-frame capture. */ + public void beginCaptureSession() { + captureCameraFrozen = false; + captureWorldPushFrozen = false; + exposure.beginCapture(); + captureBreaking = null; + captureSky = null; + captureTlas = null; + } + + public void endCaptureSession() { + captureCameraFrozen = false; + captureWorldPushFrozen = false; + exposure.endCapture(); + captureBreaking = null; + captureSky = null; + captureTlas = null; + } + + public boolean producedFreshDlssRrFrame() { + return freshRtFrame && freshDlssRrFrame; + } + + public int currentJitterPhaseCount() { + return jitterPhaseCount; + } + + /** F4 may retain the current renderer only after a valid RT frame and exposure image exist. */ + public boolean readyForUltraScreenshot() { + return freshRtFrame && !failed && worldPipeline != null && materialBindingsReady + && !reloadRebindRequested && output != null && continuationQueue != null + && pathSamplerData != null + && displayPipeline != null && displayImage != null && hdrDisplayImage != null + && rrOutput != null && exposure.ready() && RtTerrain.currentOrNull() != null; + } + /** Reset exposure filtering after an explicit render-state invalidation such as F3+A. */ public void resetExposureHistory() { requestTemporalReset(); @@ -716,6 +770,10 @@ public void beginFrame() { } RtFrameStats.FRAME.beginIfInactive(); hdrWrittenThisFrame = false; + freshRtFrame = false; + freshDlssRrFrame = false; + jitterPhaseCount = 0; + UltraScreenshot.INSTANCE.beginFrame(Minecraft.getInstance()); } /** This frame's completion token, valid until {@link #finishGraphicsUse()} signals it. */ @@ -759,7 +817,9 @@ public boolean composite(GpuTexture nativeColor, int width, int height) { // the ready gate below, because it is what MAKES terrain ready during the initial fill. try { ctx.gpuExecutor().throwIfFailed(); - RtTerrain.frame(ctx); + if (!CaptureSession.active()) { + RtTerrain.frame(ctx); + } } catch (Throwable t) { failed = true; CausticaMod.LOGGER.error("RT terrain streaming failed; reverting to vanilla path", t); @@ -1519,6 +1579,7 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo } pendingGraphicsUse = graphicsUse; RtEntities.FrameEntities frameEntities = null; + boolean rrProduced = false; VkCommandBuffer cmd = encoder.allocateAndBeginTransientCommandBuffer(); RtDebugLabels.name(ctx, VK10.VK_OBJECT_TYPE_COMMAND_BUFFER, cmd.address(), "composite command buffer"); int debugView = debugView(); @@ -1534,6 +1595,7 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo float jitterY = 0f; if (rrPath) { CausticaJitter.INSTANCE.prepare(renderW, renderH, displayW, displayH); + jitterPhaseCount = CausticaJitter.INSTANCE.currentPhaseCount(); jitterX = CausticaJitter.INSTANCE.jitterPixelsX() * jitterSignX(); jitterY = CausticaJitter.INSTANCE.jitterPixelsY() * jitterSignY(); } @@ -1607,8 +1669,26 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo // SAME bindless entity-texture array (destroy_stage_N.png is a standalone Sampler0 texture, // not a block-atlas sprite — see ModelBakery.BREAKING_LOCATIONS/DESTROY_TYPES), so any newly // resolved slot rides along with the uploadPending() call right below. - BreakEntry[] breaking = breakingEntries(terrain); - SkyPush sky = skyPush(); + BreakEntry[] breaking; + SkyPush sky; + if (CaptureSession.active() && captureWorldPushFrozen) { + flags = captureFlags; + waterParams = captureWaterParams; + waterAnchor = captureWaterAnchor; + breaking = captureBreaking; + sky = captureSky; + } else { + breaking = breakingEntries(terrain); + sky = skyPush(); + if (CaptureSession.active()) { + captureFlags = flags; + captureWaterParams = waterParams; + captureWaterAnchor = waterAnchor; + captureBreaking = breaking; + captureSky = sky; + captureWorldPushFrozen = true; + } + } if (sharcOn) { updateSharcResetPolicy(terrain, sky); } @@ -1675,10 +1755,18 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo } VulkanCommandEncoder.memoryBarrier(cmd, stack); // entity BLAS writes visible to the TLAS build } - RtAccel.PreparedTlas frameTlas; - try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.prepareTlas")) { - frameTlas = RtAccel.prepareTlas(ctx, fe.baseInstances(), fe.dynamicInstances(), tlasRing, - graphicsUse); + RtAccel.PreparedTlas frameTlas = captureTlas; + boolean buildTlas = frameTlas == null; + if (buildTlas) { + try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.prepareTlas")) { + frameTlas = RtAccel.prepareTlas(ctx, fe.baseInstances(), fe.dynamicInstances(), tlasRing, + graphicsUse); + } + if (CaptureSession.active()) { + captureTlas = frameTlas; + } + } else { + RtAccel.markTlasUsed(frameTlas, graphicsUse); } active.setTlas(frameTlas.accel.handle, graphicsUse, graphicsUseWaiter); if (sharcOn) { @@ -1686,10 +1774,12 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo sharcQueryPipeline.setTlas(frameTlas.accel.handle, graphicsUse, graphicsUseWaiter); } currentTlasHandle = frameTlas.accel.handle; - try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.recordTlas")) { - RtAccel.recordTlasBuild(ctx, cmd, frameTlas); + if (buildTlas) { + try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("frame.recordTlas")) { + RtAccel.recordTlasBuild(ctx, cmd, frameTlas); + } + VulkanCommandEncoder.memoryBarrier(cmd, stack); // TLAS build visible to the trace } - VulkanCommandEncoder.memoryBarrier(cmd, stack); // TLAS build visible to the trace // Push the BDA ring slot's address plus the small hot subset used directly by the shaders. // Every 64-bit device address the trace needs lives here, not behind worldPushAddr: the @@ -1798,10 +1888,12 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo // the histogram's log-luminance average biased by Monte-Carlo noise (Jensen's inequality // on the concave log()), so the computed exposure drifted with SPP; rrOutput is stable // regardless of SPP, keeping exposure consistent. - try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure"); - RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.exposure")) { - exposure.record(ctx, cmd, stack, rrOutput, gDepth, gAlbedo); - exposure.recordStateReadback(cmd, stack); + if (!exposure.captureFrozen()) { + try (RtDebugLabels.Scope ignored = RtDebugLabels.scope(ctx, cmd, "exposure"); + RtFrameStats.Scope ignoredStats = RtFrameStats.FRAME.stage("frame.exposure")) { + exposure.record(ctx, cmd, stack, rrOutput, gDepth, gAlbedo); + exposure.recordStateReadback(cmd, stack); + } } VulkanCommandEncoder.memoryBarrier(cmd, stack); // exposure image visible to the display mapper @@ -1849,11 +1941,14 @@ private void recordFrame(RtContext ctx, RtPipeline active, GpuTexture nativeColo dstImage, VK10.VK_IMAGE_LAYOUT_GENERAL, copyRegion(stack, displayW, displayH)); } VulkanCommandEncoder.memoryBarrier(cmd, stack); + rrProduced = rrDone; } if (VK10.vkEndCommandBuffer(cmd) != VK10.VK_SUCCESS) { throw new IllegalStateException("vkEndCommandBuffer(rt composite) failed"); } encoder.execute(cmd); // deferred into the frame's submission — correct for per-frame work + freshRtFrame = true; + freshDlssRrFrame = rrProduced; // Do not attach a merely reserved token: failed recording may never signal it. Once execute succeeds, // every owner in this frame's manifest is protected through the final overlay consumer. RtEntities.INSTANCE.markGraphicsUse(frameEntities, graphicsUse); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java b/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java index c7c03cf6..b6f18092 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/accel/RtAccel.java @@ -928,14 +928,16 @@ public static final class PreparedTlas { private final RtBuffer scratch; private final int instanceCount; private final String label; + private final TlasRing.Slot slot; private PreparedTlas(RtAccel accel, RtBuffer instanceBuffer, RtBuffer scratch, int instanceCount, - String label) { + String label, TlasRing.Slot slot) { this.accel = accel; this.instanceBuffer = instanceBuffer; this.scratch = scratch; this.instanceCount = instanceCount; this.label = label; + this.slot = slot; } } @@ -1009,7 +1011,12 @@ public static PreparedTlas prepareTlas(RtContext ctx, List baseInstanc } slot.graphicsUse.mark(graphicsUse); return new PreparedTlas(slot.accel, slot.instanceBuffer, slot.scratch, count, - "frame TLAS " + count + " instances"); + "frame TLAS " + count + " instances", slot); + } + + /** Extends a retained TLAS slot's lifetime through another graphics submission without rebuilding it. */ + public static void markTlasUsed(PreparedTlas tlas, GraphicsUse graphicsUse) { + tlas.slot.graphicsUse.mark(graphicsUse); } // Wrap the mapped Vulkan array in LWJGL structs so its generated accessors own the native ABI/bitfields. diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java index 52ef6950..de42f102 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/entity/RtEntities.java @@ -218,6 +218,8 @@ void set(float[] current, int vertBefore, int vertAfter, int rbx, int rby, int r private int tableSlot; private final FrameLists[] frameLists = new FrameLists[FRAME_LIST_RING]; + private boolean captureSession; + private FrameEntities captureSnapshot; // Previous frame's captured entity-local vertex positions + its interpolated world anchor, keyed by // entity id. Maps are swapped/reused each frame: entries not seen this frame fall out, while visible @@ -609,13 +611,16 @@ boolean full() { */ public FrameEntities beginFrame(RtContext ctx, List base, int rbx, int rby, int rbz, double camX, double camY, double camZ, Matrix4f projection, Matrix4f viewRotation) { + if (captureSnapshot != null) { + return captureSnapshot; + } if (!enabled()) { - return new FrameEntities(base, List.of(), List.of(), 0L, null); + return retainCaptureSnapshot(new FrameEntities(base, List.of(), List.of(), 0L, null)); } Minecraft mc = Minecraft.getInstance(); ClientLevel level = mc.level; if (level == null) { - return new FrameEntities(base, List.of(), List.of(), 0L, null); + return retainCaptureSnapshot(new FrameEntities(base, List.of(), List.of(), 0L, null)); } float partial = mc.getDeltaTracker().getGameTimeDeltaPartialTick(false); setCamera(camX, camY, camZ, projection, viewRotation); @@ -643,7 +648,7 @@ public FrameEntities beginFrame(RtContext ctx, List base, int RtFrameStats.FRAME.count("entityRetainedGeometryBytes", retainedGeometryBytes); if (build.instances == null) { - return new FrameEntities(base, List.of(), List.of(), 0L, null); + return retainCaptureSnapshot(new FrameEntities(base, List.of(), List.of(), 0L, null)); } try (RtFrameStats.Scope ignored = RtFrameStats.FRAME.stage("entity.uploadFlush")) { build.motion.flushWrites(); @@ -652,8 +657,28 @@ public FrameEntities beginFrame(RtContext ctx, List base, int RtFrameStats.FRAME.count("entityTableFlushes", 1); } } - return new FrameEntities(base, build.instances, build.blas, build.geomTableAddr, + FrameEntities frame = new FrameEntities(base, build.instances, build.blas, build.geomTableAddr, new FrameUse(build.lists, build.table)); + return retainCaptureSnapshot(frame); + } + + public void beginCaptureSession() { + captureSession = true; + captureSnapshot = null; + } + + public void endCaptureSession() { + captureSession = false; + captureSnapshot = null; + } + + private FrameEntities retainCaptureSnapshot(FrameEntities frame) { + if (captureSession) { + // BLAS work belongs to the first frame. Later frames reuse the same guarded resources. + captureSnapshot = new FrameEntities(frame.baseInstances, frame.dynamicInstances, List.of(), + frame.geomTableAddr, frame.use); + } + return frame; } /** Associate every resource returned for a successfully enqueued frame with its graphics completion. */ @@ -1731,6 +1756,12 @@ private void writeTableEntry(FrameBuild build, long primAddr, long idxAddr, long if (bucketTris == null || bucketTris.length != RtAccel.ENTITY_BUCKETS) { throw new IllegalArgumentException("Missing entity BLAS bucket counts"); } + // An immutable capture reuses this table across its whole accumulation phase. Publish the frozen + // geometry as stationary so reconstruction does not reapply the first frame's live displacement. + if (captureSession) { + dispAddr = 0L; + rigidX = rigidY = rigidZ = 0f; + } long entry = build.tableBase + (long) build.count * TABLE_ENTRY_BYTES; MemoryUtil.memPutLong(entry, primAddr); MemoryUtil.memPutLong(entry + 8, idxAddr); diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java index f6250621..6145c610 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtDlssRr.java @@ -4,6 +4,7 @@ import com.mojang.blaze3d.vulkan.VulkanDevice; import dev.comfyfluffy.caustica.CausticaConfig; import dev.comfyfluffy.caustica.CausticaMod; +import dev.comfyfluffy.caustica.client.CaptureSession; import dev.comfyfluffy.caustica.rt.RtContext; import dev.comfyfluffy.caustica.rt.accel.RtImage; import dev.comfyfluffy.caustica.mixin.GpuDeviceAccessor; @@ -46,7 +47,7 @@ private static int renderPreset() { } public static int quality() { - return CausticaConfig.Rt.DlssRr.QUALITY.value(); + return CaptureSession.effectiveDlssQuality(CausticaConfig.Rt.DlssRr.QUALITY.value()); } public boolean hasFailed() { diff --git a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java index 14d3a03f..1b396847 100644 --- a/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java +++ b/src/main/java/dev/comfyfluffy/caustica/rt/pipeline/RtExposure.java @@ -44,6 +44,9 @@ public final class RtExposure { private Mode previousMode; /** This frame's latched pre-exposure; see {@link #beginFrame(RtGpuExecutor.GraphicsUseWaiter)}. */ private float framePreExposure = 1.0f; + /** Immutable pre-exposure used by every frame in one finite screenshot capture. */ + private float capturePreExposure = 1.0f; + private boolean captureFrozen; private static final long DIAG_LOG_INTERVAL_NANOS = 1_000_000_000L; private static final int STATE_READBACK_RING = 6; @@ -71,6 +74,22 @@ public RtBuffer stateBuffer() { return state; } + /** Freeze the current display exposure for a finite multi-frame capture. */ + public void beginCapture() { + capturePreExposure = framePreExposure; + captureFrozen = true; + } + + /** Release the capture latch after the screenshot readback has been scheduled. */ + public void endCapture() { + captureFrozen = false; + capturePreExposure = 1.0f; + } + + public boolean captureFrozen() { + return captureFrozen; + } + /** Immutable exposure values attached to a residual-exposed EXR capture. */ public record CaptureMetadata( float preExposure, @@ -80,8 +99,7 @@ public record CaptureMetadata( float evScene, float evTarget, float evApplied - ) { - } + ) {} /** * Snapshot the controller after the capture copy has completed. @@ -154,6 +172,9 @@ public void ensureResources(RtContext ctx) { public void record(RtContext ctx, VkCommandBuffer cmd, MemoryStack stack, RtImage traceColor, RtImage guideDepth, RtImage guideAlbedo) { + if (captureFrozen) { + return; + } if (image == null) { throw new IllegalStateException("RT exposure image not created"); } @@ -202,6 +223,8 @@ public void destroy() { pendingStateReadback = null; completedState = null; framePreExposure = 1.0f; + capturePreExposure = 1.0f; + captureFrozen = false; previousMode = null; } @@ -234,7 +257,7 @@ private void recordAuto(RtContext ctx, VkCommandBuffer cmd, MemoryStack stack, * consumed until its graphics timeline value completes, so the host never races the live storage buffer. */ public void recordStateReadback(VkCommandBuffer cmd, MemoryStack stack) { - if (mode() != Mode.AUTO || pendingStateReadback == null) { + if (captureFrozen || mode() != Mode.AUTO || pendingStateReadback == null) { return; } VkBufferMemoryBarrier.Buffer toTransfer = VkBufferMemoryBarrier.calloc(1, stack); @@ -435,6 +458,9 @@ private AutoConfig autoConfig() { * ensures both consumers use one prediction; the residual absorbs whatever it failed to predict. */ public void beginFrame(RtGpuExecutor.GraphicsUseWaiter graphicsUseWaiter) { + if (captureFrozen) { + return; + } Mode currentMode = mode(); if (modeTransitionRequiresReset(previousMode, currentMode)) { requestReset(); @@ -482,7 +508,7 @@ public void requestReset() { * no fence is needed. 1.0 disables the mechanism. */ public float preExposure() { - return framePreExposure; + return captureFrozen ? capturePreExposure : framePreExposure; } private float computePreExposure() { diff --git a/src/main/resources/assets/caustica/lang/en_us.json b/src/main/resources/assets/caustica/lang/en_us.json index c36c7c6c..e77f5316 100644 --- a/src/main/resources/assets/caustica/lang/en_us.json +++ b/src/main/resources/assets/caustica/lang/en_us.json @@ -1,4 +1,17 @@ { + "key.category.caustica.controls": "Caustica", + "key.caustica.ultra_screenshot": "Ultra Screenshot", + "caustica.status.ultraScreenshot.cancelled": "Ultra screenshot cancelled", + "caustica.status.ultraScreenshot.requiresWorld": "Ultra screenshot requires an active world", + "caustica.status.ultraScreenshot.requiresDlssRr": "Ultra screenshot requires ray tracing, DLSS Ray Reconstruction, and Debug View Off", + "caustica.status.ultraScreenshot.busy": "Another capture mode is active", + "caustica.status.screenshot.busy": "Screenshot capture is already in progress", + "caustica.status.ultraScreenshot.started": "Ultra screenshot: DLAA at %s SPP", + "caustica.status.ultraScreenshot.invalidated": "Ultra screenshot cancelled because render state changed", + "caustica.status.ultraScreenshot.failed": "Ultra screenshot cancelled because rendering failed", + "caustica.status.ultraScreenshot.timedOut": "Ultra screenshot cancelled after 30 seconds without a fresh DLSS frame", + "caustica.status.ultraScreenshot.resized": "Ultra screenshot cancelled because resolution changed", + "caustica.options.rt.header": "Ray Tracing", "caustica.options.rt.exposureMode": "Exposure", diff --git a/src/main/resources/caustica.mixins.json b/src/main/resources/caustica.mixins.json index f7c6e54e..9765d859 100644 --- a/src/main/resources/caustica.mixins.json +++ b/src/main/resources/caustica.mixins.json @@ -10,18 +10,24 @@ "GameRendererMixin", "GpuDeviceAccessor", "GlxMixin", + "GuiMixin", "GuiRendererMixin", + "KeyboardHandlerMixin", + "KeyboardInputMixin", "LevelRendererMixin", "LevelExtractorMixin", "MinecraftMixin", "MinecraftReloadMixin", + "MouseHandlerMixin", "ModelPartAccessor", + "OptionsMixin", "OptionsSubScreenAccessor", "ParticleEngineAccessor", "RenderSetupAccessor", "RenderTypeAccessor", "ScreenshotMixin", "TextureAtlasAccessor", + "TextureAtlasMixin", "ParticleGroupAccessor", "SpriteContentsAccessor", "VideoSettingsScreenMixin", diff --git a/src/test/java/dev/comfyfluffy/caustica/client/CaptureProgressTest.java b/src/test/java/dev/comfyfluffy/caustica/client/CaptureProgressTest.java new file mode 100644 index 00000000..4c659d62 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/client/CaptureProgressTest.java @@ -0,0 +1,34 @@ +package dev.comfyfluffy.caustica.client; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class CaptureProgressTest { + @Test + void completesOnlyAfterTheDerivedFreshFrameCount() { + CaptureProgress progress = new CaptureProgress(); + for (int i = 1; i < 32; i++) { + assertEquals(CaptureProgress.Result.WAITING, progress.acceptFreshFrame(32, 3840, 2160)); + } + assertEquals(CaptureProgress.Result.COMPLETE, progress.acceptFreshFrame(32, 3840, 2160)); + assertEquals(32, progress.freshFrames()); + assertEquals(32, progress.targetFrames()); + assertEquals(CaptureProgress.Result.WAITING, progress.acceptFreshFrame(32, 3840, 2160)); + assertEquals(32, progress.freshFrames()); + } + + @Test + void rejectsPhaseOrResolutionChangesInsteadOfMixingFrames() { + CaptureProgress progress = new CaptureProgress(); + progress.acceptFreshFrame(32, 3840, 2160); + assertEquals(CaptureProgress.Result.DIMENSIONS_CHANGED, + progress.acceptFreshFrame(33, 3840, 2160)); + progress.reset(); + progress.acceptFreshFrame(32, 3840, 2160); + assertEquals(CaptureProgress.Result.DIMENSIONS_CHANGED, + progress.acceptFreshFrame(32, 2560, 1440)); + assertEquals(CaptureProgress.Result.INVALID_PHASE, + new CaptureProgress().acceptFreshFrame(0, 3840, 2160)); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/client/CaptureSessionPolicyTest.java b/src/test/java/dev/comfyfluffy/caustica/client/CaptureSessionPolicyTest.java new file mode 100644 index 00000000..abc2cc28 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/client/CaptureSessionPolicyTest.java @@ -0,0 +1,95 @@ +package dev.comfyfluffy.caustica.client; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class CaptureSessionPolicyTest { + @AfterEach + void clearScreenshotLease() { + CaptureSession.discardScreenshotsForShutdown(); + } + + @Test + void pausesOnlyAnUnsharedIntegratedServer() { + assertTrue(CaptureSession.shouldPauseIntegratedServer(true, false)); + assertFalse(CaptureSession.shouldPauseIntegratedServer(true, true)); + assertFalse(CaptureSession.shouldPauseIntegratedServer(false, false)); + } + + @Test + void screenshotLeaseBlocksOverlappingWritesUntilCompletion() { + long f4 = CaptureSession.acquireScreenshot(true); + assertNotEquals(0L, f4); + assertTrue(CaptureSession.screenshotIsUltra(f4)); + assertTrue(CaptureSession.acquireScreenshot(false) == 0L); + + CaptureSession.releaseScreenshot(f4); + assertFalse(CaptureSession.screenshotIsUltra(f4)); + } + + @Test + void lateScreenshotCallbackCannotReleaseAReplacement() { + long old = CaptureSession.acquireScreenshot(true); + CaptureSession.discardScreenshotsForShutdown(); + long current = CaptureSession.acquireScreenshot(true); + + CaptureSession.releaseScreenshot(old); + assertNotEquals(0L, current); + assertTrue(CaptureSession.screenshotIsUltra(current)); + } + + @Test + void captureFailureRetriesTheConfiguredRendererAfterRestore() { + assertFalse(UltraScreenshot.shouldRecoverRenderer(false, false)); + assertTrue(UltraScreenshot.shouldRecoverRenderer(true, false)); + assertTrue(UltraScreenshot.shouldRecoverRenderer(false, true)); + assertTrue(UltraScreenshot.shouldRecoverRenderer(true, true)); + + List steps = new ArrayList<>(); + int configuredQuality = 2; + AtomicInteger effectiveQuality = new AtomicInteger(UltraScreenshot.DLAA_QUALITY); + Throwable failure = UltraScreenshot.restoreRendererState( + true, + () -> { + steps.add("end-capture"); + effectiveQuality.set(configuredQuality); + }, + () -> { + steps.add("retry-renderer"); + assertEquals(configuredQuality, effectiveQuality.get()); + }, + () -> steps.add("reset-temporal")); + + assertNull(failure); + assertEquals(List.of("end-capture", "retry-renderer", "reset-temporal"), steps); + } + + @Test + void captureRecoveryFailureRemainsReportedAndStillResetsTemporalState() { + List steps = new ArrayList<>(); + IllegalStateException releaseFailure = new IllegalStateException("native feature retained"); + + Throwable failure = UltraScreenshot.restoreRendererState( + true, + () -> steps.add("end-capture"), + () -> { + steps.add("retry-renderer"); + throw releaseFailure; + }, + () -> steps.add("reset-temporal")); + + assertSame(releaseFailure, failure); + assertEquals(List.of("end-capture", "retry-renderer", "reset-temporal"), steps); + } +} diff --git a/src/test/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixinTest.java b/src/test/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixinTest.java new file mode 100644 index 00000000..99ee12a2 --- /dev/null +++ b/src/test/java/dev/comfyfluffy/caustica/mixin/KeyboardHandlerMixinTest.java @@ -0,0 +1,20 @@ +package dev.comfyfluffy.caustica.mixin; + +import org.junit.jupiter.api.Test; +import org.lwjgl.glfw.GLFW; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class KeyboardHandlerMixinTest { + @Test + void captureAllowsReleasesAndOnlyTheInitialUltraTogglePress() { + assertFalse(KeyboardHandlerMixin.shouldSuppressCaptureKey(false, GLFW.GLFW_PRESS, false)); + assertFalse(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_RELEASE, false)); + assertFalse(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_PRESS, true)); + + assertTrue(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_PRESS, false)); + assertTrue(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_REPEAT, false)); + assertTrue(KeyboardHandlerMixin.shouldSuppressCaptureKey(true, GLFW.GLFW_REPEAT, true)); + } +} From 985b0bc80e82e7fe7f7adfb4abbbbea1a1b54327 Mon Sep 17 00:00:00 2001 From: PEQHUB Date: Tue, 11 Aug 2026 11:22:52 -0500 Subject: [PATCH 5/6] fix: align SHaRC defaults with validated profile --- .../dev/comfyfluffy/caustica/CausticaConfig.java | 12 ++++++------ .../comfyfluffy/caustica/CausticaConfigTest.java | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java index 1c2de26f..da361697 100644 --- a/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java +++ b/src/main/java/dev/comfyfluffy/caustica/CausticaConfig.java @@ -562,24 +562,24 @@ private Composite() { public static final class Sharc { public static final BooleanSetting ENABLED = bool("caustica.rt.sharc.enabled", "sharc.enabled", true); public static final IntSetting CACHE_EXPONENT = - clampedInt("caustica.rt.sharc.cacheExponent", "sharc.cache-exponent", 20, 16, 23); + clampedInt("caustica.rt.sharc.cacheExponent", "sharc.cache-exponent", 22, 16, 23); public static final BooleanSetting ANTI_FIREFLY = bool( "caustica.rt.sharc.antiFirefly", "sharc.anti-firefly", true); /** Developer comparison mode; production keeps camera-visible primary surfaces live. */ public static final BooleanSetting PRIMARY_SURFACE_DEBUG = bool( "caustica.rt.sharc.primarySurfaceDebug", "sharc.primary-surface-debug", false); public static final IntSetting UPDATE_TILE_SIZE = - clampedInt("caustica.rt.sharc.updateTileSize", "sharc.update-tile-size", 8, 2, 64); + clampedInt("caustica.rt.sharc.updateTileSize", "sharc.update-tile-size", 3, 2, 64); public static final IntSetting ACCUMULATION_FRAMES = - clampedInt("caustica.rt.sharc.accumulationFrames", "sharc.accumulation-frames", 8, 1, 1024); + clampedInt("caustica.rt.sharc.accumulationFrames", "sharc.accumulation-frames", 384, 1, 1024); public static final IntSetting STALE_FRAMES = - clampedInt("caustica.rt.sharc.staleFrames", "sharc.stale-frames", 32, 8, 1024); + clampedInt("caustica.rt.sharc.staleFrames", "sharc.stale-frames", 128, 8, 1024); public static final FloatSetting SCENE_SCALE = finiteClampedFloat( - "caustica.rt.sharc.sceneScale", "sharc.scene-scale", 1.0f, 1.0f, 100.0f); + "caustica.rt.sharc.sceneScale", "sharc.scene-scale", 32.0f, 1.0f, 100.0f); public static final FloatSetting RADIANCE_SCALE = finiteClampedFloat( "caustica.rt.sharc.radianceScale", "sharc.radiance-scale", 1000.0f, 50.0f, 1000.0f); public static final FloatSetting GRID_LOGARITHM_BASE = finiteClampedFloat( - "caustica.rt.sharc.gridLogarithmBase", "sharc.grid-logarithm-base", 2.0f, 1.01f, 16.0f); + "caustica.rt.sharc.gridLogarithmBase", "sharc.grid-logarithm-base", 3.0f, 1.01f, 16.0f); public static final FloatSetting GRID_LEVEL_BIAS = finiteClampedFloat( "caustica.rt.sharc.gridLevelBias", "sharc.grid-level-bias", 0.0f, -16.0f, 16.0f); /** Additional minimum linear roughness for SHaRC diffuse ownership; zero preserves the mirror cutoff. */ diff --git a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java index deb9065f..79cd9024 100644 --- a/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java +++ b/src/test/java/dev/comfyfluffy/caustica/CausticaConfigTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; final class CausticaConfigTest { @@ -89,7 +90,22 @@ void dlssPresetDefaultsToSdkSelection() { void registersSharcSettingsForConfigRoundTrips() { CausticaConfig.ensureRegistered(); assertTrue(hasSetting("caustica.rt.sharc.enabled")); + } + + @Test + void sharcDefaultsMatchTheValidatedRuntimeProfile() { assertTrue(CausticaConfig.Rt.Sharc.ENABLED.defaultValue()); + assertEquals(22, CausticaConfig.Rt.Sharc.CACHE_EXPONENT.defaultValue()); + assertTrue(CausticaConfig.Rt.Sharc.ANTI_FIREFLY.defaultValue()); + assertFalse(CausticaConfig.Rt.Sharc.PRIMARY_SURFACE_DEBUG.defaultValue()); + assertEquals(3, CausticaConfig.Rt.Sharc.UPDATE_TILE_SIZE.defaultValue()); + assertEquals(384, CausticaConfig.Rt.Sharc.ACCUMULATION_FRAMES.defaultValue()); + assertEquals(128, CausticaConfig.Rt.Sharc.STALE_FRAMES.defaultValue()); + assertEquals(32.0f, CausticaConfig.Rt.Sharc.SCENE_SCALE.defaultValue()); + assertEquals(1000.0f, CausticaConfig.Rt.Sharc.RADIANCE_SCALE.defaultValue()); + assertEquals(3.0f, CausticaConfig.Rt.Sharc.GRID_LOGARITHM_BASE.defaultValue()); + assertEquals(0.0f, CausticaConfig.Rt.Sharc.GRID_LEVEL_BIAS.defaultValue()); + assertEquals(0.0f, CausticaConfig.Rt.Sharc.ROUGHNESS_THRESHOLD.defaultValue()); } @Test From 7ff400dd638d5be1801853b3567271f87f7abde1 Mon Sep 17 00:00:00 2001 From: PEQHUB Date: Wed, 26 Aug 2026 12:13:30 -0500 Subject: [PATCH 6/6] vendor: pin PsychoV30 Test30 reference source Verbatim copy of the RenoDX PsychoV Test30 shader, kept beside psychov24.slang as the pinned reference for the Caustica Slang adaptation. The file is inert to builds: only *.slang sources are compiled and packaged, and its relative include resolves against the RenoDX tree. THIRD_PARTY_NOTICES documents the MIT provenance (Carlos Lopez). --- THIRD_PARTY_NOTICES.md | 33 + shaders/pipelines/display/test30.hlsl | 1544 +++++++++++++++++++++++++ 2 files changed, 1577 insertions(+) create mode 100644 shaders/pipelines/display/test30.hlsl diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f5ebb8dc..259d71fc 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -35,6 +35,39 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## PsychoV30 Test30 reference source + +`shaders/pipelines/display/test30.hlsl` is a verbatim copy of the RenoDX +PsychoV Test30 tone-mapping shader, kept as the pinned reference for the +Caustica Slang adaptation. It is not compiled by Caustica builds, which +compile `*.slang` sources only; its relative include resolves against the +RenoDX tree. + +Copyright (C) 2026 Carlos Lopez. SPDX-License-Identifier: MIT. + +The reference remains subject to the MIT license. The complete license text is +available at : + +Copyright (c) 2026 Carlos Lopez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## NVIDIA DLSS / NGX SDK Caustica can build and distribute release artifacts that include NVIDIA DLSS/NGX diff --git a/shaders/pipelines/display/test30.hlsl b/shaders/pipelines/display/test30.hlsl new file mode 100644 index 00000000..07c11df0 --- /dev/null +++ b/shaders/pipelines/display/test30.hlsl @@ -0,0 +1,1544 @@ +#ifndef RENODX_SHADERS_TONEMAP_PSYCHOV_TEST30_HLSL_ +#define RENODX_SHADERS_TONEMAP_PSYCHOV_TEST30_HLSL_ + +#include "../../color/rgb.hlsl" + +/* + * Copyright (C) 2026 Carlos Lopez + * SPDX-License-Identifier: MIT + */ + +namespace renodx { +namespace tonemap { +namespace psychov { + +// PsychoV30: selected Mean-A2 / physiological-Yf response +// ========================================================= +// +// Signal contract +// --------------- +// Input and output are direct linear-light BT.709 RGB with D65 white. +// `peak_value` expresses display peak in reference-white-relative units. +// The target volume is the normalized linear BT.709 RGB cube for mode 0 or +// the normalized linear BT.2020 RGB cube for every other mode. Output remains +// represented as linear BT.709 even when the constrained target is BT.2020. +// +// Scientific basis and engineering stages +// --------------------------------------- +// - RGB is transformed to the Stockman/CVRL two-degree LMS basis. +// - The achromatic coordinate is physiological Yf from the +// Stockman-Sharpe LMS-to-XfYfZf transform: +// +// Yf = cL * L + cM * M +// +// Yf is the relative observer coordinate formed by weighted L and M cone +// responses. +// - Purity is direct LMS interpolation toward the adapting neutral while +// retaining the adaptation-relative Yf coordinate. It does not require +// MacLeod-Boynton coordinates or short-wave weighting. +// - CIE 170-2 weighted MacLeod-Boynton chromaticity is isolated to the signed +// fallback's source-boundary continuation. Its metric remains a successor +// candidate for replacement by a coordinate consistent with the A2 path. +// - Adaptation-relative cone ratios are consistent with the early-cone +// background-normalization framework discussed by Stockman and Brainard. +// - The finite endpoint, Mean-A2 direction, and locked-direction target-cube +// projection are the rendering-response and device-mapping stages. +// +// Positive-cone response +// ---------------------- +// Let q_i = LMS_i / anchor_in_i, P_i = peak_value * D65_LMS_i, +// p = contrast * cone_response_exponent, and +// k_i = pow(anchor_out_i / P_i, h). Test30 evaluates: +// +// beta_i = p * h / (1 - k_i) +// e_i = 1 / (1 + (1 / k_i - 1) * pow(q_i, -beta_i)) +// u_i = pow(e_i, 1 / h) +// +// The conceptual response is P_i * u_i. The reciprocal form remains finite +// when the corresponding positive power overflows. It preserves +// anchor_in -> anchor_out, has +// adaptation-point logarithmic slope p, approaches zero as q -> 0, and +// approaches selected peak white as q -> infinity. +// +// Mean-A2 direction +// ----------------- +// A2 denotes this shader's internal orthonormal cone-opponent plane. For +// normalized cone load u: +// +// X = (uL - uM) / sqrt(2) +// C0 = (uL + uM + uS) / sqrt(3) +// Z = (2 * uS - uL - uM) / sqrt(6) +// +// Source A2 direction comes from adaptation-relative q; response A2 direction +// comes from peak-relative post-G u. Normalizing and adding the two directions +// gives their exact angular bisector. Test30 retains the response A2 radius +// and C0, replacing direction only. +// +// Exact target solve +// ------------------ +// With D65 Yf fractions alphaL + alphaM = 1, the normalized physiological +// coordinate represented by (X, C0, Z) is: +// +// A = C0 / sqrt(3) + (alphaL - alphaM) * X / sqrt(2) +// - Z / sqrt(6) +// +// Target RGB is affine in A, X, and Z. Locking the authored A2 direction and +// scaling (X, Z) by s makes all lower/upper RGB-cube planes and the response +// Yf ceiling linear inequalities in (C0, s). The feasible set is a convex +// polygon. Segment projection uses +// +// distance^2 = delta_C0^2 + (X^2 + Z^2) * delta_s^2 +// +// which is exactly Euclidean distance in the original (X, C0, Z) coordinate +// under the locked direction. Full compression analytically finds the nearest +// point inside this fixed-direction model's four-edge feasible polygon. +// +// Cone states containing zero or negative values use the separately documented +// signed linear-A2 fallback with analytic target RGB-cube ray support. +// +// PsychoV research record +// ======================= +// +// This record is carried forward through PsychoV tests so each successor keeps +// the scientific rationale, source attribution, selected implementation, and +// next research directions beside the shader that ships. Test30 extends the +// Test17-Test25 record with Mean-A2 response authoring and an exact +// fixed-direction device-cube projection. +// +// Research objective and system boundary +// -------------------------------------- +// PsychoV studies two coupled systems: +// +// 1. Observer-side organization: receptor coordinates, adaptation-relative +// cone state, achromatic and opponent coordinates, response shaping, and +// visibility/gain mechanisms supported by vision research. +// 2. Device-hull mapping: a joint tone, direction, and target-volume solve +// constrained by display primaries, white, reference-white scale, and peak. +// +// Test30's selected rendering pipeline is: +// +// linear-light BT.709 +// -> Stockman/CVRL LMS +// -> scalar physiological-Yf grading +// -> adaptation-relative LMS purity +// -> adaptation-relative common cone power +// -> anchor-matched finite per-cone G +// -> Mean-A2 direction with post-G radius and C0 +// -> exact fixed-direction target RGB-cube/Yf projection +// -> linear-light BT.709 representation +// +// The caller supplies the current adaptation and desired output-background +// anchors. The runtime signal is reference-white-relative. Absolute retinal +// scale, local/temporal adaptation estimation, visibility thresholds, and +// cortical gain form explicit successor-test research directions below. +// +// 1) Receptor basis and observer coordinates +// ------------------------------------------ +// Brainard's Colorimetry chapter supplies the cone-stage/color-match +// foundation. Stockman and Brainard build on that receptor basis for +// first-site and second-site adaptation. Test30 transforms linear-light +// BT.709 through XYZ to the Stockman/CVRL two-degree LMS fit. +// +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Brainard_Stockman_Colorimetry.pdf +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// +// The published Stockman-Sharpe fundamentals include standard prereceptoral +// lens and macular filtering for an average, mainly foveal two-degree observer. +// CVRL documents the ocular-media and macular-pigment filters, their strong +// short-wavelength absorption, and their observer variation. Successor tests +// can expose age, field size, eccentricity, lens, and macular assumptions when +// personalized observer transforms become an input. +// +// Sources: +// http://www.cvrl.org/background.htm +// http://www.cvrl.org/database/text/intros/intromaclens.htm +// +// Test30's selected positive-cone path carries physiological Yf: +// +// physiological Yf = cL * L + cM * M +// +// where cL and cM come directly from the Yf row of the base +// Stockman-Sharpe LMS-to-XfYfZf transform. The selected purity and response +// stages operate directly in LMS and Yf and do not use an S-cone weight. +// +// The signed fallback separately retains CIE 170-2 weighted +// MacLeod-Boynton chromaticity for source-boundary continuation: +// +// l = Lw / (Lw + Mw) +// s = Sw / (Lw + Mw) +// +// MacLeod-Boynton (1979) supplies the classic weighted-cone chromaticity +// construction. CVRL/CIE physiological data and repository constants supply +// the exact coefficients used here. Psychtoolbox documents a practical +// CIE-based LMS-to-MacLeod-Boynton implementation. Webster and Leonard use a +// modified MB framework for adaptation norms. Mantiuk et al. describe a +// practical LMS scaling whose L+M sum carries an achromatic coordinate. +// +// Sources: +// http://www.cvrl.org/ciexyzpr.htm +// https://psychtoolbox.org/docs/LMSToMacBoyn +// MacLeod & Boynton, JOSA 1979, doi:10.1364/JOSA.69.001183 +// Webster & Leonard, JOSA A 2008, doi:10.1364/JOSAA.25.002817 +// https://pmc.ncbi.nlm.nih.gov/articles/PMC2657039/ +// https://www.cl.cam.ac.uk/~rkm38/pdfs/mantiuk2020practical_csf.pdf +// +// 2) Early cone adaptation +// ------------------------ +// Stockman and Brainard express first-site L-cone contrast as +// +// C_L = delta_L / (L_b + L_0) +// +// with corresponding M- and S-cone forms. Equivalently, the background sets +// the cone gain: +// +// g_L = 1 / (L_b + L_0) +// g_L * (L - L_b) = delta_L / (L_b + L_0) +// +// Test30 receives caller-authored adaptation LMS as `anchor_in` and uses +// q_i = LMS_i / anchor_in_i as its static background-relative state. This +// preserves the architecture of cone-specific normalization while keeping +// adaptation policy in the caller. A successor with image/retinal context can +// estimate L_b, M_b, S_b and semi-saturation L_0, M_0, S_0 over space and time. +// +// Stockman et al. describe first-site regulation across light levels and the +// transition toward bleaching-supported high-light sensitivity regulation. +// Source: JOV 2006, doi:10.1167/6.11.5. +// +// Webster and Leonard distinguish a response norm, the adapting level that +// leaves white judgments unbiased, from a perceptual norm, the stimulus that +// appears white. Their experiments found close tracking between these norms. +// PsychoV uses adapted-background reference for the directly carried cone +// state and retains response/perceptual norms as higher-level interpretations +// of the current neutral coding state. +// Source: JOSA A 2008, doi:10.1364/JOSAA.25.002817. +// +// CVRL documents observing-condition and chromatic-adaptation dependence in +// physiological luminosity functions, while cone spectral sensitivities stay +// stable through ordinary adaptation levels. This supports carrying Yf with +// the current adapted observer state. +// Source: http://www.cvrl.org/database/text/intros/introvl.htm +// +// 2a) Dim cone-noise extension +// ---------------------------- +// Cone-mediated detection reaches a quantal/transduction-noise regime before +// rod-dominated vision. Approximate De Vries-Rose behavior gives threshold +// cone contrast a log-log slope near -0.5 against retinal illuminance. Higher +// adaptation levels approach Weber-like behavior, where threshold contrast is +// approximately constant relative to background. A calibrated successor can +// use retinal illuminance and cone-specific noise to attenuate scene +// differences below this visibility floor before postreceptoral processing. +// +// Stockman and Brainard discuss the range where cone-contrast coordinates +// approach Weber behavior. Angueyra and Rieke measure primate-cone +// phototransduction noise and its contribution to the dim-light threshold. +// Sources: +// https://color2.psych.upenn.edu/brainard/papers/Stockman_Brainard_ColorVision.pdf +// Angueyra & Rieke, Nature Neuroscience 2013, doi:10.1038/nn.3534 +// https://pmc.ncbi.nlm.nih.gov/articles/PMC3815624/ +// +// 2b) High-light bleaching extension +// ----------------------------------- +// A retinal-illuminance-calibrated successor can represent steady-state cone +// pigment availability with the Rushton-Henry form +// +// p_available(I) = 1 / (1 + I / I0) +// +// and the complementary bleached fraction +// +// p_bleached(I) = I / (I + I0), I0 approximately 10^4.3 Td. +// +// Physiological placement follows adaptation-state definition and precedes +// postreceptoral opponent response and pooled gain. A rendering realization +// can apply availability to cone excursions around the adapted-white anchor, +// approaching equal white at the carried achromatic level as availability +// approaches zero. Test30's selected highlight endpoint is the finite-G +// equation documented above; the bleaching equations remain a calibrated +// successor path tied to retinal units. +// +// Sources: +// Stockman et al., JOV 2006, doi:10.1167/6.11.5 +// Stockman et al., JOV 2018, doi:10.1167/18.6.12 +// Rushton & Henry, Vision Research 1968, +// doi:10.1016/0042-6989(68)90040-0 +// http://www.cvrl.org/database/text/intros/introbleaches.htm +// +// 3) Background-normalized opponent organization +// ------------------------------------------------ +// Test30 applies adaptation-relative purity directly in LMS, then constructs +// A2 as an orthonormal decomposition of the three adaptation/peak-normalized +// cone loads. A2 supplies an exact Euclidean metric and sixfold cone-axis +// geometry for Mean-A2 direction authoring and target projection. The signed +// fallback still uses weighted MacLeod-Boynton chromaticity for one +// source-boundary trace; this is not part of the selected positive path and +// should be revisited alongside a fitted ACC/DKL or A2-consistent fallback. +// +// 4) Saturating response research +// ------------------------------- +// Michaelis-Menten/Naka-Rushton response families provide receptor and +// early-cortical contrast models; supersaturating forms capture additional +// cortical response shapes. Peirce analyzes how saturating and supersaturating +// contrast response functions affect visual-cortex interpretation. +// Source: Peirce, JOV 2007, doi:10.1167/7.6.13. +// +// Test30 selects the anchor-preserving finite per-cone G above. Its reciprocal +// parameterization fixes the caller's input/output anchor, logarithmic slope, +// and selected peak endpoint. This creates a controlled rendering response for +// direct comparison with future fitted receptor or cortical response models. +// +// 5) ON/OFF response research +// --------------------------- +// Retinal ON and OFF channels separate increments and decrements around an +// adapted background. Schiller reviews their parallel visual-system roles. +// Yu, Turner, Baudin, and Rieke show that cone adaptation and downstream +// nonlinearities can combine unexpectedly for natural-image structure, +// motivating natural-image validation of any explicit polarity split. +// +// Rahimi-Nasrabadi et al. validate an ONOFF image algorithm on calibrated +// grayscale images and propose color extension through a scalar lightness +// dimension. PsychoV's scalar-Yf highlight/shadow grade follows the analogous +// engineering principle of applying polarity-shaped grades to one achromatic +// coordinate while retaining cone ratios. +// +// Sources: +// Schiller, Trends Neurosci 1992, +// doi:10.1016/0166-2236(92)90017-3 +// Yu et al., eLife 2022, doi:10.7554/eLife.70611 +// Rahimi-Nasrabadi et al., Cell Reports 2021, +// doi:10.1016/j.celrep.2021.108692 +// +// Test30's automatic finite-G curve uses a centered static log-range prior. +// A successor ON/OFF stage can fit separate increment/decrement responses and +// preserve the same adaptation anchor and device-hull coupling. +// +// 6) Pooled divisive gain research +// -------------------------------- +// Divisive normalization models pooled neural response as a channel drive +// divided by a semi-saturated measure of neighboring/population activity. +// This supplies a research path for coupled achromatic/opponent energy, +// spatial context, and contrast-dependent gain after polarity processing. +// +// Sources: +// Heeger, Visual Neuroscience 1992, +// doi:10.1017/S0952523800009640 +// Carandini & Heeger, Nature Reviews Neuroscience 2012, +// doi:10.1038/nrn3136 +// Bun & Horwitz, Color Research & Application 2023, +// doi:10.1002/col.22903 +// +// A successor implementation can add fitted pooling neighborhoods and +// semi-saturation constants after a selected opponent/ON-OFF stage. Test30 +// supplies a static per-pixel response baseline for that comparison. +// +// 7) Unified device-hull tone and gamut mapping +// --------------------------------------------- +// Display mapping is constrained by the complete target RGB volume. In +// normalized target coordinates this is +// +// 0 <= R,G,B <= 1. +// +// Lower and upper channel planes, faces, edges, corners, and neutral-axis +// capacity participate in one device-hull problem. High-purity directions can +// reach a target face at a lower achromatic level than D65, so a joint solve +// trades radial opponent distance and achromatic coordinate according to the +// selected metric. ITU-R BT.2408 supplies the practical HDR Reference White +// framing that keeps reference/diffuse white distinct from display peak. +// Source: https://www.itu.int/pub/R-REP-BT.2408 +// +// Test30 fixes the Mean-A2 authored direction and projects exactly in the full +// orthonormal (X,C0,Z) metric over the resulting convex target-cube/Yf polygon. +// This extends Test25's numerical ray support into an analytic nearest-point +// solve for the selected direction. BT.709 and BT.2020 modes share the same +// D65 cone normalization and use their respective complete RGB cubes. +// +// A successor sectional solve can search multiple directions within the +// active cone-axis sextant, include a fitted postreceptoral metric, and compare +// face/edge/interior candidates. Mean-A2 remains the preferred authored +// trajectory candidate and Test30 remains the exact fixed-direction baseline. +// +// 7a) Hue-objective research inside the hull solve +// ------------------------------------------------ +// Mizokami et al. and O'Neil et al. study a functional account of the Abney +// effect based on an equivalent Gaussian spectral peak. For short and medium +// wavelengths, the equivalent-peak parameter can provide a hue objective as +// purity changes. A future spectral precomputation can map weighted-LMS/MB +// chromaticity to mu_eq and evaluate mu_eq alongside A2/ACC direction during +// target-hull optimization while carrying Yf separately. +// +// Sources: +// Mizokami et al., JOV 2006, doi:10.1167/6.9.12 +// O'Neil et al., JOSA A 2012, doi:10.1364/JOSAA.29.00A165 +// +// 7b) Simultaneous-range auto-compression +// --------------------------------------- +// `compression == 0` uses a static centered simultaneous-range reference: +// +// side_range = reference_range_log10 / 2 +// h = max(side_range / log10(peak_Yf / anchor_Yf), 1) +// +// Kunkel and Reinhard report approximately 3.7 log10 units under their adapted +// test conditions. Jiang and Fairchild directly measured bright/dark +// simultaneous range on an Apple Pro Display XDR: approximately 3.3 log10 for +// the average observer and 3.47 for one observer at 1600 cd/m^2 with a +// 3.4-degree stimulus. Their fitted maxima were approximately 3.24 at +// 452 cd/m^2 and 3.40 at 1600 cd/m^2. These condition-dependent measurements +// motivate future display-, surround-, field-size-, and glare-aware range +// selection. Test30 keeps 3.7 as its static baseline for direct continuity +// with Test22-Test25. +// +// Sources: +// Kunkel & Reinhard, APGV 2010, doi:10.1145/1836248.1836251 +// Jiang & Fairchild, JIST 2021, +// doi:10.2352/J.ImagingSci.Technol.2021.65.5.050401 +// +static const float PSYCHO30_EPSILON = 1e-6f; +static const float PSYCHO30_EPSILON2 = PSYCHO30_EPSILON * PSYCHO30_EPSILON; +static const float PSYCHO30_MAX_FINITE_INPUT = 65504.f; +static const float PSYCHO30_AUTO_COMPRESSION_SENTINEL = 0.f; +static const float PSYCHO30_LARGE_SUPPORT = 1e20f; +// Kunkel/Reinhard report approximately 3.7 log10 units under their adapted +// simultaneous-range test conditions. Test30 treats half that total range as +// the range above adaptation and half as the range below adaptation. +// Jiang/Fairchild report stimulus- and display-dependent simultaneous values. +static const float PSYCHO30_REFERENCE_SIMULTANEOUS_RANGE_LOG10 = 3.7f; +static const float PSYCHO30_HIGHLIGHT_GRADE_REFERENCE_WHITE = 1.f; +static const float PSYCHO30_SHADOW_GRADE_RANGE_STOPS = 4.f; + +static const float3x3 PSYCHO30_BT709_TO_LMS_MAT = mul( + renodx::color::STOCKMAN_CVRL_XYZ_TO_LMS_2DEG_FIT, + renodx::color::BT709_TO_XYZ_MAT); +static const float3x3 PSYCHO30_LMS_TO_BT709_MAT = mul( + renodx::color::XYZ_TO_BT709_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); +static const float3x3 PSYCHO30_LMS_TO_BT2020_MAT = mul( + renodx::color::XYZ_TO_BT2020_MAT, + renodx::color::STOCKMAN_CVRL_LMS_TO_XYZ_2DEG_FIT); + +static const float3 PSYCHO30_SOURCE_YF_COEFFICIENTS = mul( + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1], + PSYCHO30_BT709_TO_LMS_MAT); +static const float3 PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS = max( + PSYCHO30_SOURCE_YF_COEFFICIENTS, + float3(0.f, 0.f, 0.f)); +static const float3 PSYCHO30_SOURCE_YF_WEIGHTS = + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS + / max( + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.x + + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.y + + PSYCHO30_SOURCE_YF_POSITIVE_COEFFICIENTS.z, + PSYCHO30_EPSILON); + +// BT.709 and BT.2020 share D65. These alpha values partition normalized Yf +// between the L and M cone loads and sum to one. +static const float3 PSYCHO30_D65_WHITE_LMS = mul( + PSYCHO30_BT709_TO_LMS_MAT, + float3(1.f, 1.f, 1.f)); +static const float PSYCHO30_D65_WHITE_YF = dot( + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1], + PSYCHO30_D65_WHITE_LMS); +static const float PSYCHO30_D65_ALPHA_L = + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][0] + * PSYCHO30_D65_WHITE_LMS.x + / PSYCHO30_D65_WHITE_YF; +static const float PSYCHO30_D65_ALPHA_M = + renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][1] + * PSYCHO30_D65_WHITE_LMS.y + / PSYCHO30_D65_WHITE_YF; +static const float PSYCHO30_D65_ALPHA_DELTA = + PSYCHO30_D65_ALPHA_L - PSYCHO30_D65_ALPHA_M; +// Direct target basis at fixed normalized physiological coordinate A: +// +// target_rgb = A + X * A2_X_RGB + Z * A2_Z_RGB +// +// These are the symbolic inverse orthonormal-cone transform followed by the +// selected LMS-to-RGB matrix; they avoid reconstructing LMS per pixel. +static const float3 PSYCHO30_BT709_A2_X_RGB = mul( + PSYCHO30_LMS_TO_BT709_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT709_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_BT709_MAT, + float3( + 0.f, + 0.f, + sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT2020_A2_X_RGB = mul( + PSYCHO30_LMS_TO_BT2020_MAT, + float3( + sqrt(2.f) * PSYCHO30_D65_ALPHA_M + * PSYCHO30_D65_WHITE_LMS.x, + -sqrt(2.f) * PSYCHO30_D65_ALPHA_L + * PSYCHO30_D65_WHITE_LMS.y, + rsqrt(2.f) + * (PSYCHO30_D65_ALPHA_M + - PSYCHO30_D65_ALPHA_L) + * PSYCHO30_D65_WHITE_LMS.z)); +static const float3 PSYCHO30_BT2020_A2_Z_RGB = mul( + PSYCHO30_LMS_TO_BT2020_MAT, + float3( + 0.f, + 0.f, + sqrt(6.f) * 0.5f * PSYCHO30_D65_WHITE_LMS.z)); + +// Anchor-preserving, slope-normalized finite endpoint. In scalar form, with +// q=x/anchor, k=(anchor/peak)^h, beta=h/(1-k): +// +// F(x) = peak * [1 + (1/k - 1) * q^(-beta)]^(-1/h) +// +// Thus F(anchor)=anchor, dF/dx at the anchor is one, F(0)=0, and the positive +// asymptote is `peak`. MeanA2Response fuses the common cone power into beta. +float psycho30_FiniteEndpoint( + float x, + float anchor, + float peak, + float h) { + bool uniform_response = h == 1.f; + float anchor_power = uniform_response + ? anchor / peak + : pow(anchor / peak, h); + anchor_power = max(anchor_power, 1e-37f); + float slope_normalization = max(1.f - anchor_power, PSYCHO30_EPSILON); + float normalized_input = max(x / anchor, 0.f); + if (!(normalized_input > 0.f)) return 0.f; + + float encoded = rcp( + 1.f + + (rcp(anchor_power) - 1.f) + * pow(normalized_input, -h / slope_normalization)); + return peak + * (uniform_response + ? encoded + : pow(max(encoded, 0.f), rcp(h))); +} + +// Automatic h centers the chosen simultaneous log10 range around adaptation: +// +// h = max((reference_range / 2) / log10(peak_yf / anchor_yf), 1) +// +// Manual positive h is passed through unchanged by the public entry point. +float psycho30_AutoCompressionPower(float anchor_yf, float peak_yf) { + float above_adaptation_range = log10(peak_yf / anchor_yf); + return max( + (PSYCHO30_REFERENCE_SIMULTANEOUS_RANGE_LOG10 * 0.5f) + / above_adaptation_range, + 1.f); +} + +// Preserve positive source-total bookkeeping while retaining the source RGB +// direction as far as its first lower RGB-cube boundary. This keeps finite +// signed/wide-gamut inputs defined by one direction-preserving boundary trace. +float3 psycho30_AnchorSourcePositiveTotalToYf(float3 source_rgb) { + float source_total = dot( + max(source_rgb, float3(0.f, 0.f, 0.f)), + PSYCHO30_SOURCE_YF_WEIGHTS); + if (!(source_total > PSYCHO30_EPSILON) + || isnan(source_total) + || isinf(source_total)) { + return float3(0.f, 0.f, 0.f); + } + + [branch] + if (all(source_rgb >= float3(0.f, 0.f, 0.f))) { + return mul(PSYCHO30_BT709_TO_LMS_MAT, source_rgb); + } + + float3 residual = source_rgb - source_total; + float3 lower_fraction = renodx::math::Select( + residual < float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON), + source_total / max(-residual, float3(PSYCHO30_EPSILON, PSYCHO30_EPSILON, PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + float boundary_fraction = min(1.f, renodx::math::Min(lower_fraction)); + float3 bounded_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + source_total + residual * boundary_fraction); + float bounded_yf = renodx::color::yf::from::LMS(bounded_lms); + return bounded_yf > PSYCHO30_EPSILON + && !isnan(bounded_yf) + && !isinf(bounded_yf) + ? bounded_lms + * (source_total * PSYCHO30_D65_WHITE_YF / bounded_yf) + : PSYCHO30_D65_WHITE_LMS * source_total; +} + +float psycho30_GradeQuinticUnitRamp(float t) { + t = saturate(t); + return t * t * t * (t * (t * 6.f - 15.f) + 10.f); +} + +float psycho30_HighlightsScalar( + float x, + float highlights, + float adapted_anchor_yf) { + if (highlights == 1.f) return x; + + float t = 0.f; + if (x > adapted_anchor_yf) { + t = saturate( + log2(x / adapted_anchor_yf) + / log2( + PSYCHO30_HIGHLIGHT_GRADE_REFERENCE_WHITE + / adapted_anchor_yf)); + } + t = psycho30_GradeQuinticUnitRamp(t); + + float ratio = max( + x / adapted_anchor_yf, + PSYCHO30_EPSILON); + if (highlights > 1.f) { + return lerp( + x, + adapted_anchor_yf * pow(ratio, highlights), + t); + } + + float compressed = adapted_anchor_yf * pow(ratio, 2.f - highlights); + return renodx::math::DivideSafe( + x * x, + lerp(x, compressed, t), + x); +} + +float psycho30_ShadowsScalar( + float x, + float shadows, + float adapted_anchor_yf) { + if (shadows == 1.f) return x; + + float ratio = max(x / adapted_anchor_yf, 0.f); + float base_term = x * adapted_anchor_yf; + float base_scale = renodx::math::DivideSafe(base_term, ratio, 0.f); + float shadow_floor = adapted_anchor_yf + * exp2(-PSYCHO30_SHADOW_GRADE_RANGE_STOPS); + float t = x > shadow_floor + ? saturate( + log2(x / adapted_anchor_yf) + / log2(shadow_floor / adapted_anchor_yf)) + : 1.f; + t = psycho30_GradeQuinticUnitRamp(t); + + if (shadows > 1.f) { + float raised = x * (1.f + renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO30_EPSILON), shadows), 0.f)); + return x + (raised - x * (1.f + base_scale)) * t; + } + + float lowered = x * (1.f - renodx::math::DivideSafe(base_term, pow(max(ratio, PSYCHO30_EPSILON), 2.f - shadows), 0.f)); + return x + (lowered - x * (1.f - base_scale)) * t; +} + +// Direct LMS interpolation toward the adapting neutral at fixed +// adaptation-relative physiological Yf. The selected purity path does not +// require MacLeod-Boynton coordinates or an S-cone weight. +float3 psycho30_ApplyAdaptiveLMSPurity( + float3 input_lms, + float3 adaptive_lms, + float purity_delta) { + if (abs(purity_delta - 1.f) <= 1e-5f) return input_lms; + + float relative_yf = max( + renodx::color::yf::from::LMS(input_lms / adaptive_lms), + 0.f); + if (!(relative_yf > 0.f)) return float3(0.f, 0.f, 0.f); + + float neutral_scale = relative_yf + / renodx::color::yf::from::LMS( + float3(1.f, 1.f, 1.f)); + return lerp( + adaptive_lms * neutral_scale, + input_lms, + purity_delta); +} + +// Signed-fallback MacLeod-Boynton coordinate helpers. The selected positive +// path does not call this block. +float2 psycho30_AdaptiveNeutralMB() { + float lm_weight_sum = + renodx::color::CIE1702_MB_CIE_WEIGHTS.x + + renodx::color::CIE1702_MB_CIE_WEIGHTS.y; + return float2( + renodx::color::CIE1702_MB_CIE_WEIGHTS.x, + renodx::color::CIE1702_MB_CIE_WEIGHTS.z) + / lm_weight_sum; +} + +float3 psycho30_LMSFromYfOpponent( + float yf, + float rg, + float bv, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float lm_anchor_mix = mad( + anchor_lms.x, + neutral_mb.x, + anchor_lms.y * (1.f - neutral_mb.x)); + float denominator = + (yf - (anchor_lms.x - anchor_lms.y) * rg) + / lm_anchor_mix; + float3 relative_weighted = float3( + neutral_mb.x * denominator + rg, + (1.f - neutral_mb.x) * denominator - rg, + neutral_mb.y * denominator + bv); + return relative_weighted * anchor_lms + / renodx::color::CIE1702_MB_CIE_WEIGHTS; +} + +float3 psycho30_LMSFromPhysicalYfMB( + float yf, + float2 mb, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float2 offset = mb - neutral_mb; + float lm_anchor_mix = mad( + anchor_lms.x, + neutral_mb.x, + anchor_lms.y * (1.f - neutral_mb.x)); + float anchor_delta = anchor_lms.x - anchor_lms.y; + float relative_denominator = renodx::math::DivideSafe( + yf, + lm_anchor_mix + anchor_delta * offset.x, + 0.f); + return psycho30_LMSFromYfOpponent( + yf, + relative_denominator * offset.x, + relative_denominator * offset.y, + anchor_lms); +} + +float3 psycho30_TargetRGBFromLMS( + float3 lms, + int target_gamut_mode) { + float3 target_rgb; + [branch] + if (target_gamut_mode == 0) { + target_rgb = mul(PSYCHO30_LMS_TO_BT709_MAT, lms); + } else { + target_rgb = mul(PSYCHO30_LMS_TO_BT2020_MAT, lms); + } + return target_rgb; +} + +float psycho30_TargetNeutralYfLimit( + float target_rgb_peak, + float3 anchor_lms, + int target_gamut_mode) { + float anchor_yf = renodx::color::yf::from::LMS(anchor_lms); + if (!(anchor_yf > PSYCHO30_EPSILON)) return 0.f; + float3 rgb_per_yf = psycho30_TargetRGBFromLMS( + anchor_lms, + target_gamut_mode) + / anchor_yf; + float max_rgb_per_yf = renodx::math::Max(rgb_per_yf); + return all(rgb_per_yf >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && max_rgb_per_yf > PSYCHO30_EPSILON + ? target_rgb_peak / max_rgb_per_yf + : 0.f; +} + +// Weighted MacLeod-Boynton chromaticity is retained only for the signed +// fallback's source-boundary continuation. +float2 psycho30_MBFromRelativeLMS( + float3 relative_lms, + out uint valid) { + const float3 weights = renodx::color::CIE1702_MB_CIE_WEIGHTS; + float yf = renodx::color::yf::from::LMS(relative_lms); + valid = yf > PSYCHO30_EPSILON + && !isnan(yf) + && !isinf(yf) + && !any(isnan(relative_lms)) + && !any(isinf(relative_lms)) + ? 1u + : 0u; + if (valid == 0u) { + return psycho30_AdaptiveNeutralMB(); + } + float inverse_yf = rcp(yf); + return float2( + relative_lms.x * weights.x * inverse_yf, + relative_lms.z * weights.z * inverse_yf); +} + +float3 psycho30_ApplySignedConeResponseFallback( + float3 source_relative_lms, + float response_power) { + if (abs(response_power - 1.f) <= PSYCHO30_EPSILON) { + return source_relative_lms; + } + return sign(source_relative_lms) + * pow( + abs(source_relative_lms), + float3(response_power, response_power, response_power)); +} + +// Build the selected response coordinate directly from normalized response u: +// source q authors one A2 direction, finite-G u authors the other direction +// and supplies radius, C0, and normalized physiological Yf. Equal normalized +// direction weights form the exact angular midpoint when both are defined. +float3 psycho30_MeanA2Response( + float3 input_lms, + float3 anchor_in_lms, + float3 anchor_out_lms, + float3 peak_lms, + float response_power, + float response_h, + out float response_yf, + out uint valid) { + float3 source_q = input_lms / anchor_in_lms; + valid = all(source_q > float3(0.f, 0.f, 0.f)) ? 1u : 0u; + if (valid == 0u) { + response_yf = 0.f; + return float3(0.f, 0.f, 0.f); + } + + bool uniform_response = response_h == 1.f; + float3 anchor_power; + [branch] + if (uniform_response) { + anchor_power = anchor_out_lms / peak_lms; + } else { + anchor_power = pow( + anchor_out_lms / peak_lms, + float3(response_h, response_h, response_h)); + } + anchor_power = max( + anchor_power, + float3(1e-37f, 1e-37f, 1e-37f)); + float3 slope_normalization = max( + float3(1.f, 1.f, 1.f) - anchor_power, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)); + float3 input_exponent = response_power * response_h / slope_normalization; + float3 encoded = rcp( + float3(1.f, 1.f, 1.f) + + (rcp(anchor_power) - float3(1.f, 1.f, 1.f)) + * pow(source_q, -input_exponent)); + float3 response_u; + [branch] + if (uniform_response) { + response_u = encoded; + } else { + float inverse_response_h = rcp(response_h); + response_u = pow( + max(encoded, float3(0.f, 0.f, 0.f)), + float3( + inverse_response_h, + inverse_response_h, + inverse_response_h)); + } + float2 source_a2 = float2( + (source_q.x - source_q.y) * rsqrt(2.f), + (2.f * source_q.z - source_q.x - source_q.y) + * rsqrt(6.f)); + float2 response_a2 = float2( + (response_u.x - response_u.y) * rsqrt(2.f), + (2.f * response_u.z - response_u.x - response_u.y) + * rsqrt(6.f)); + float2 authored_a2 = response_a2; + float source_radius2 = dot(source_a2, source_a2); + float response_radius2 = dot(response_a2, response_a2); + + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_source_radius = rsqrt(source_radius2); + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 mean_direction = source_a2 * inverse_source_radius + + response_a2 * inverse_response_radius; + float mean_radius2 = dot(mean_direction, mean_direction); + if (mean_radius2 > PSYCHO30_EPSILON2) { + authored_a2 = mean_direction + * rsqrt(mean_radius2) + * response_radius; + } + } + + response_yf = PSYCHO30_D65_ALPHA_L * response_u.x + + PSYCHO30_D65_ALPHA_M * response_u.y; + float3 desired_ortho = float3( + authored_a2.x, + (response_u.x + response_u.y + response_u.z) * rsqrt(3.f), + authored_a2.y); + return desired_ortho; +} + +float2 psycho30_ClosestPointOnScaleSegment( + float desired_c0, + float desired_rho2, + float2 segment_start, + float2 segment_end) { + float2 segment = segment_end - segment_start; + float denominator = segment.x * segment.x + + desired_rho2 * segment.y * segment.y; + if (!(denominator > PSYCHO30_EPSILON2)) return segment_start; + float numerator = (desired_c0 - segment_start.x) * segment.x + + desired_rho2 * (1.f - segment_start.y) * segment.y; + float t = saturate(numerator / denominator); + return segment_start + segment * t; +} + +// Exact nearest point for the fixed authored A2 direction. The RGB cube and +// A<=response_yf ceiling become a four-edge convex polygon in (C0, radial +// scale). `desired_rho2` in the segment metric preserves ordinary Euclidean +// distance in (X,C0,Z). +// For radial target RGB r, n=max(-r), and p=max(r), feasibility is exactly: +// +// scale * n <= A <= 1 - scale * p +// 0 <= A <= min(response_yf, 1) +float3 psycho30_YfCeilingSolve( + float3 desired_coord, + float response_yf, + int target_gamut_mode, + out uint valid) { + valid = !any(isnan(desired_coord)) + && !any(isinf(desired_coord)) + && !isnan(response_yf) + && !isinf(response_yf) + ? 1u + : 0u; + if (valid == 0u) return float3(0.f, 0.f, 0.f); + + float max_a = saturate(response_yf); + float radial_yf = PSYCHO30_D65_ALPHA_DELTA + * desired_coord.x * rsqrt(2.f) + - desired_coord.z * rsqrt(6.f); + float desired_a = desired_coord.y * rsqrt(3.f) + radial_yf; + float3 radial_rgb; + [branch] + if (target_gamut_mode == 0) { + radial_rgb = desired_coord.x * PSYCHO30_BT709_A2_X_RGB + + desired_coord.z * PSYCHO30_BT709_A2_Z_RGB; + } else { + radial_rgb = desired_coord.x * PSYCHO30_BT2020_A2_X_RGB + + desired_coord.z * PSYCHO30_BT2020_A2_Z_RGB; + } + float3 desired_target_rgb = desired_a + radial_rgb; + if (desired_a >= 0.f + && desired_a <= max_a + && all(desired_target_rgb >= float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON)) + && all(desired_target_rgb <= float3( + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON, + 1.f + PSYCHO30_EPSILON))) { + return desired_coord; + } + + float desired_rho2 = dot(desired_coord.xz, desired_coord.xz); + if (!(desired_rho2 > PSYCHO30_EPSILON2)) { + return float3( + 0.f, + clamp(desired_coord.y, 0.f, sqrt(3.f) * max_a), + 0.f); + } + + float positive_pressure = renodx::math::Max(radial_rgb); + float negative_pressure = -renodx::math::Min(radial_rgb); + if (!(positive_pressure > 0.f) + || !(negative_pressure > 0.f)) { + valid = 0u; + return float3(0.f, 0.f, 0.f); + } + + float inverse_positive = rcp(positive_pressure); + float inverse_negative = rcp(negative_pressure); + float inverse_pressure_sum = rcp( + positive_pressure + negative_pressure); + float apex_a = negative_pressure * inverse_pressure_sum; + float upper_a = min(max_a, apex_a); + float max_a_scale = min( + max_a * inverse_negative, + (1.f - max_a) * inverse_positive); + float upper_scale = min( + max_a * inverse_negative, + inverse_pressure_sum); + float2 vertex0 = float2(0.f, 0.f); + float2 vertex1 = float2(sqrt(3.f) * max_a, 0.f); + float2 vertex2 = float2( + sqrt(3.f) * (max_a - radial_yf * max_a_scale), + max_a_scale); + float2 vertex3 = float2( + sqrt(3.f) * (upper_a - radial_yf * upper_scale), + upper_scale); + float2 best_c0_scale = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex1); + float2 best_delta = best_c0_scale - float2(desired_coord.y, 1.f); + float best_cost = best_delta.x * best_delta.x + + desired_rho2 * best_delta.y * best_delta.y; + + float2 candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex1, + vertex2); + float2 candidate_delta = candidate - float2(desired_coord.y, 1.f); + float candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex2, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + best_cost = candidate_cost; + } + + candidate = psycho30_ClosestPointOnScaleSegment( + desired_coord.y, + desired_rho2, + vertex0, + vertex3); + candidate_delta = candidate - float2(desired_coord.y, 1.f); + candidate_cost = candidate_delta.x * candidate_delta.x + + desired_rho2 * candidate_delta.y * candidate_delta.y; + if (candidate_cost < best_cost) { + best_c0_scale = candidate; + } + + float3 solved_coord = float3( + desired_coord.x * max(best_c0_scale.y, 0.f), + best_c0_scale.x, + desired_coord.z * max(best_c0_scale.y, 0.f)); + valid = !any(isnan(solved_coord)) && !any(isinf(solved_coord)) ? 1u : 0u; + return valid != 0u ? solved_coord : float3(0.f, 0.f, 0.f); +} + +float2 psycho30_LinearA2Opponent( + float3 lms, + float3 anchor_lms) { + float3 q = lms / anchor_lms; + return float2( + (q.x - q.y) * rsqrt(2.f), + (2.f * q.z - q.x - q.y) * rsqrt(6.f)); +} + +float3 psycho30_LMSFromLinearA2Opponent( + float2 opponent, + float physical_yf, + float3 anchor_lms) { + float difference = sqrt(2.f) * opponent.x; + float a_l = renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][0] + * anchor_lms.x; + float a_m = renodx::color::STOCKMAN_SHARP_LMS_TO_XFYFZF_MAT[1][1] + * anchor_lms.y; + float q_m = (physical_yf - a_l * difference) / (a_l + a_m); + float q_l = q_m + difference; + float q_s = 0.5f * (sqrt(6.f) * opponent.y + q_l + q_m); + return float3(q_l, q_m, q_s) * anchor_lms; +} + +float psycho30_LinearA2TargetSupport( + float2 direction, + float clip_magnitude, + float physical_yf, + float3 anchor_lms, + int target_gamut_mode, + float target_rgb_peak) { + if (!(clip_magnitude > PSYCHO30_EPSILON)) return 0.f; + + float3 neutral_target = psycho30_TargetRGBFromLMS( + psycho30_LMSFromLinearA2Opponent( + float2(0.f, 0.f), + physical_yf, + anchor_lms), + target_gamut_mode); + if (any(isnan(neutral_target)) + || any(isinf(neutral_target)) + || any(neutral_target < float3(0.f, 0.f, 0.f)) + || any(neutral_target > float3( + target_rgb_peak, + target_rgb_peak, + target_rgb_peak))) { + return 0.f; + } + + float3 unit_target = psycho30_TargetRGBFromLMS( + psycho30_LMSFromLinearA2Opponent( + direction, + physical_yf, + anchor_lms), + target_gamut_mode); + float3 delta_target = unit_target - neutral_target; + if (any(isnan(delta_target)) || any(isinf(delta_target))) return 0.f; + + float3 upper_support = renodx::math::Select( + delta_target > float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON), + (float3(target_rgb_peak, target_rgb_peak, target_rgb_peak) - neutral_target) + / max( + delta_target, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + float3 lower_support = renodx::math::Select( + delta_target < float3( + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON, + -PSYCHO30_EPSILON), + neutral_target + / max( + -delta_target, + float3( + PSYCHO30_EPSILON, + PSYCHO30_EPSILON, + PSYCHO30_EPSILON)), + float3( + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT, + PSYCHO30_LARGE_SUPPORT)); + return max( + min( + clip_magnitude, + min( + renodx::math::Min(upper_support), + renodx::math::Min(lower_support))), + 0.f); +} + +float psycho30_Cross2(float2 a, float2 b) { + return a.x * b.y - a.y * b.x; +} + +float psycho30_RaySegmentRadius( + float2 origin, + float2 direction, + float2 a, + float2 b) { + float2 edge = b - a; + float denominator = psycho30_Cross2(direction, edge); + if (abs(denominator) <= PSYCHO30_EPSILON) return PSYCHO30_LARGE_SUPPORT; + float2 ao = a - origin; + float t = psycho30_Cross2(ao, edge) / denominator; + float u = psycho30_Cross2(ao, direction) / denominator; + return t >= 0.f && u >= 0.f && u <= 1.f + ? t + : PSYCHO30_LARGE_SUPPORT; +} + +float psycho30_TransformedSourceClipLinearA2Magnitude( + float2 source_mb, + float response_power, + float3 response_anchor_ratio, + float physical_yf, + float3 anchor_lms) { + float2 neutral_mb = psycho30_AdaptiveNeutralMB(); + float2 source_offset = source_mb - neutral_mb; + float source_radius2 = dot(source_offset, source_offset); + if (!(source_radius2 > PSYCHO30_EPSILON2)) return 0.f; + + float2 vertices[3]; + [unroll] + for (int channel = 0; channel < 3; ++channel) { + float3 primary_lms = float3( + PSYCHO30_BT709_TO_LMS_MAT[0][channel], + PSYCHO30_BT709_TO_LMS_MAT[1][channel], + PSYCHO30_BT709_TO_LMS_MAT[2][channel]); + uint primary_valid; + vertices[channel] = psycho30_MBFromRelativeLMS( + primary_lms / anchor_lms, + primary_valid); + } + float2 source_direction = source_offset * rsqrt(source_radius2); + float source_boundary_radius = min( + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[0], + vertices[1]), + min( + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[1], + vertices[2]), + psycho30_RaySegmentRadius( + neutral_mb, + source_direction, + vertices[2], + vertices[0]))); + if (!(source_boundary_radius < PSYCHO30_LARGE_SUPPORT)) return 0.f; + + float2 boundary_mb = neutral_mb + + source_direction * max(source_boundary_radius, 0.f); + const float3 weights = renodx::color::CIE1702_MB_CIE_WEIGHTS; + float m_fraction = 1.f - boundary_mb.x; + if (!(boundary_mb.x > PSYCHO30_EPSILON) + || !(m_fraction > PSYCHO30_EPSILON) + || !(boundary_mb.y > PSYCHO30_EPSILON)) { + return 0.f; + } + float inverse_m_fraction = rcp(m_fraction); + float2 response_ratio = exp2( + log2(max( + float2( + boundary_mb.x * weights.y * inverse_m_fraction / weights.x, + boundary_mb.y * weights.y * inverse_m_fraction / weights.z), + float2(PSYCHO30_EPSILON, PSYCHO30_EPSILON))) + * response_power); + response_ratio *= float2( + response_anchor_ratio.x / response_anchor_ratio.y, + response_anchor_ratio.z / response_anchor_ratio.y); + float lm_ratio = (weights.x / weights.y) * response_ratio.x; + float sm_ratio = (weights.z / weights.y) * response_ratio.y; + float inverse_denominator = rcp(1.f + lm_ratio); + float2 response_boundary_mb = float2( + lm_ratio * inverse_denominator, + sm_ratio * inverse_denominator); + float3 boundary_lms = psycho30_LMSFromPhysicalYfMB( + physical_yf, + response_boundary_mb, + anchor_lms); + return length(psycho30_LinearA2Opponent(boundary_lms, anchor_lms)); +} + +float psycho30_NeutwoWithClip( + float x, + float peak, + float clip, + float h) { + x = max(x, 0.f); + peak = max(peak, 0.f); + if (!(peak > PSYCHO30_EPSILON)) return 0.f; + clip = max(clip, peak); + if (clip <= peak * (1.f + PSYCHO30_EPSILON)) return min(x, peak); + float q = saturate(x / clip); + float k = saturate(peak / clip); + float qh = pow(max(q, 0.f), h); + float kh = max(pow(max(k, PSYCHO30_EPSILON), h), 1e-37f); + float denominator = pow( + max(qh * (1.f - kh) + kh, 1e-37f), + rcp(h)); + return peak * q / max(denominator, PSYCHO30_EPSILON); +} + +// Defined-domain fallback for signed adaptation-relative LMS containing a zero +// or negative cone value. +// It uses sign-preserving cone power, linear A2 direction authoring, scalar Yf +// compression, weighted-MB source-boundary continuation, and analytic +// intersections with all lower and upper selected-target RGB-cube planes. +// This is an engineering continuity and full-strength target containment path. +float3 psycho30_LinearA2Fallback( + float3 input_lms, + float3 anchor_in_lms, + float3 anchor_out_lms, + int target_gamut_mode, + float target_rgb_peak, + float response_power, + float response_h, + float target_compression_strength) { + float3 source_q = input_lms / anchor_in_lms; + float3 response_lms = anchor_out_lms + * psycho30_ApplySignedConeResponseFallback( + source_q, + response_power); + + float2 source_opponent = psycho30_LinearA2Opponent( + input_lms, + anchor_in_lms); + float2 response_opponent = psycho30_LinearA2Opponent( + response_lms, + anchor_in_lms); + float source_radius2 = dot(source_opponent, source_opponent); + float response_radius2 = dot(response_opponent, response_opponent); + float3 authored_lms = response_lms; + if (source_radius2 > PSYCHO30_EPSILON2 + && response_radius2 > PSYCHO30_EPSILON2) { + float inverse_source_radius = rsqrt(source_radius2); + float inverse_response_radius = rsqrt(response_radius2); + float response_radius = response_radius2 * inverse_response_radius; + float2 midpoint = source_opponent * inverse_source_radius + + response_opponent * inverse_response_radius; + float midpoint_length2 = dot(midpoint, midpoint); + if (midpoint_length2 > PSYCHO30_EPSILON2) { + authored_lms = psycho30_LMSFromLinearA2Opponent( + midpoint * rsqrt(midpoint_length2) * response_radius, + max(renodx::color::yf::from::LMS(response_lms), 0.f), + anchor_in_lms); + } + } + + float neutral_yf_limit = psycho30_TargetNeutralYfLimit( + target_rgb_peak, + anchor_in_lms, + target_gamut_mode); + if (!(neutral_yf_limit > PSYCHO30_EPSILON)) { + return float3(0.f, 0.f, 0.f); + } + float anchor_out_yf = renodx::color::yf::from::LMS(anchor_out_lms); + float target_yf = psycho30_FiniteEndpoint( + max(renodx::color::yf::from::LMS(response_lms), 0.f), + anchor_out_yf, + neutral_yf_limit, + response_h); + + uint authored_mb_valid; + float2 authored_mb = psycho30_MBFromRelativeLMS( + authored_lms / anchor_in_lms, + authored_mb_valid); + if (authored_mb_valid == 0u) { + return psycho30_LMSFromYfOpponent( + target_yf, + 0.f, + 0.f, + anchor_in_lms); + } + + float3 desired_lms = psycho30_LMSFromPhysicalYfMB( + target_yf, + authored_mb, + anchor_in_lms); + float2 desired_opponent = psycho30_LinearA2Opponent( + desired_lms, + anchor_in_lms); + float desired_magnitude2 = dot(desired_opponent, desired_opponent); + if (!(desired_magnitude2 > PSYCHO30_EPSILON2)) { + return psycho30_LMSFromYfOpponent( + target_yf, + 0.f, + 0.f, + anchor_in_lms); + } + + float inverse_desired_magnitude = rsqrt(desired_magnitude2); + float desired_magnitude = desired_magnitude2 * inverse_desired_magnitude; + float2 direction = desired_opponent * inverse_desired_magnitude; + float source_clip_magnitude = max( + psycho30_TransformedSourceClipLinearA2Magnitude( + psycho30_MBFromRelativeLMS(source_q, authored_mb_valid), + response_power, + anchor_out_lms / anchor_in_lms, + target_yf, + anchor_in_lms), + desired_magnitude); + float target_support = psycho30_LinearA2TargetSupport( + direction, + source_clip_magnitude, + target_yf, + anchor_in_lms, + target_gamut_mode, + target_rgb_peak); + float compressed_magnitude = min( + psycho30_NeutwoWithClip( + desired_magnitude, + target_support, + max(source_clip_magnitude, target_support), + response_h), + target_support); + return psycho30_LMSFromLinearA2Opponent( + direction * lerp(desired_magnitude, compressed_magnitude, target_compression_strength), + target_yf, + anchor_in_lms); +} + +float3 psychotm_test30( + // Direct linear-light BT.709 RGB. + // Configuration values are trusted; only the input color is sanitized. + float3 bt709_linear_input, + float peak_value = 1000.f / 203.f, // display peak / reference white + float exposure = 1.f, // linear-light multiplier + float highlights = 1.f, // scalar-Yf highlight grade + float shadows = 1.f, // scalar-Yf shadow grade + float contrast = 1.f, // factor in common cone power p + float purity_scale = 1.f, // adaptation-relative LMS purity + float bleaching_intensity = 1.f, // positional compatibility placeholder + float clip_point = 100.f, // positional compatibility placeholder + float hue_restore = 1.f, // positional compatibility placeholder + float encoded_response_power = 1.f, // positional compatibility placeholder + int white_curve_mode = 0, // positional compatibility placeholder + float cone_response_exponent = 1.f, // second factor in cone power p + float3 current_adaptive_state_bt709 = 0.18f, // input anchor + float3 current_background_state_bt709 = 0.18f, // output anchor + float gamut_compression = 1.f, // target-projection strength + int gamut_compression_mode = 1, // 0 = BT.709, nonzero = BT.2020 + float adaptive_normalization = 1.f, // positional compatibility placeholder + float compression = 0.f) { // positive manual h; 0 = auto + // ------------------------------------------------------------------------- + // Source signal and signed-domain policy. + // ------------------------------------------------------------------------- + float3 sanitized_input = renodx::math::ZeroNaN(bt709_linear_input); + sanitized_input = renodx::math::Select( + isinf(sanitized_input), + renodx::math::CopySign( + float3( + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT, + PSYCHO30_MAX_FINITE_INPUT), + sanitized_input), + sanitized_input); + float3 exposed_input = sanitized_input * exposure; + + float3 anchored_lms = psycho30_AnchorSourcePositiveTotalToYf(exposed_input); + if (all(anchored_lms == float3(0.f, 0.f, 0.f))) { + return float3(0.f, 0.f, 0.f); + } + + float3 anchor_in_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_adaptive_state_bt709); + float3 anchor_out_lms = mul( + PSYCHO30_BT709_TO_LMS_MAT, + current_background_state_bt709); + + // ------------------------------------------------------------------------- + // Observer-basis controls: scalar physiological-Yf grading followed by + // adaptation-relative LMS purity. These precede the finite cone response. + // ------------------------------------------------------------------------- + float3 graded_lms = anchored_lms; + [branch] + if (highlights != 1.f || shadows != 1.f) { + graded_lms = abs(anchored_lms); + float graded_yf = max( + renodx::color::yf::from::LMS(graded_lms), + PSYCHO30_EPSILON); + float adapted_anchor_yf = renodx::color::yf::from::LMS(anchor_in_lms); + float graded_yf_out = psycho30_HighlightsScalar( + graded_yf, + highlights, + adapted_anchor_yf); + graded_yf_out = psycho30_ShadowsScalar( + graded_yf_out, + shadows, + adapted_anchor_yf); + graded_lms *= renodx::math::DivideSafe( + graded_yf_out, + graded_yf, + 1.f); + graded_lms = renodx::math::CopySign(graded_lms, anchored_lms); + } + + float response_scale = cone_response_exponent; + float response_power = contrast * response_scale; + float purity_delta = renodx::math::DivideSafe( + purity_scale, + contrast, + 1.f); + float3 response_input_lms = psycho30_ApplyAdaptiveLMSPurity( + graded_lms, + anchor_in_lms, + purity_delta); + + // ------------------------------------------------------------------------- + // Positive finite-G response and Mean-A2 direction authoring. + // ------------------------------------------------------------------------- + float target_rgb_peak = peak_value; + float3 target_peak_lms = PSYCHO30_D65_WHITE_LMS * target_rgb_peak; + float response_h = compression; + [branch] + if (compression == PSYCHO30_AUTO_COMPRESSION_SENTINEL) { + response_h = psycho30_AutoCompressionPower( + renodx::color::yf::from::LMS(anchor_out_lms), + psycho30_TargetNeutralYfLimit( + target_rgb_peak, + anchor_in_lms, + gamut_compression_mode)); + } + + float response_yf; + uint response_valid; + float3 desired_coord = psycho30_MeanA2Response( + response_input_lms, + anchor_in_lms, + anchor_out_lms, + target_peak_lms, + response_power, + response_h, + response_yf, + response_valid); + [branch] + if (response_valid == 0u) { + // Signed cone states use the separate defined-domain path. + float3 fallback_lms = psycho30_LinearA2Fallback( + response_input_lms, + anchor_in_lms, + anchor_out_lms, + gamut_compression_mode, + target_rgb_peak, + response_power, + response_h, + gamut_compression); + float3 fallback_bt709 = mul( + PSYCHO30_LMS_TO_BT709_MAT, + fallback_lms); + return !any(isnan(fallback_bt709)) && !any(isinf(fallback_bt709)) + ? fallback_bt709 + : float3(0.f, 0.f, 0.f); + } + + // ------------------------------------------------------------------------- + // Device mapping: exact fixed-direction projection into the selected + // normalized RGB cube with the post-response physiological-Yf ceiling. + // ------------------------------------------------------------------------- + float target_compression_weight = gamut_compression; + float3 selected_coord = desired_coord; + if (target_compression_weight != 0.f) { + uint solve_valid; + float3 solved_coord = psycho30_YfCeilingSolve( + desired_coord, + response_yf, + gamut_compression_mode, + solve_valid); + if (solve_valid == 0u) return float3(0.f, 0.f, 0.f); + selected_coord = target_compression_weight == 1.f + ? solved_coord + : lerp( + desired_coord, + solved_coord, + target_compression_weight); + } + + // Direct inverse A2/Yf basis to linear BT.709. This is algebraically the + // normalized cone-coordinate inverse plus LMS-to-BT.709 matrix product. + float output_a = selected_coord.y * rsqrt(3.f) + + PSYCHO30_D65_ALPHA_DELTA + * selected_coord.x * rsqrt(2.f) + - selected_coord.z * rsqrt(6.f); + float3 output_bt709 = peak_value + * (output_a + + selected_coord.x * PSYCHO30_BT709_A2_X_RGB + + selected_coord.z * PSYCHO30_BT709_A2_Z_RGB); + return !any(isnan(output_bt709)) && !any(isinf(output_bt709)) + ? output_bt709 + : float3(0.f, 0.f, 0.f); +} + +} // namespace psychov +} // namespace tonemap +} // namespace renodx + +#endif // RENODX_SHADERS_TONEMAP_PSYCHOV_TEST30_HLSL_ \ No newline at end of file