diff --git a/Shaders/Borderlands GOTY Enhanced/BL_Video_0x0E97A4A0.ps_5_0.hlsl b/Shaders/Borderlands GOTY Enhanced/BL_Video_0x0E97A4A0.ps_5_0.hlsl index 6619ee41..4850a04d 100644 --- a/Shaders/Borderlands GOTY Enhanced/BL_Video_0x0E97A4A0.ps_5_0.hlsl +++ b/Shaders/Borderlands GOTY Enhanced/BL_Video_0x0E97A4A0.ps_5_0.hlsl @@ -7,7 +7,9 @@ // paper white. // Kept conservative (videos are low-bitrate, highlight compression artifacts blow up if pushed hard). -#include "../Includes/Common.hlsl" +// clang-format off +#include "Includes/Common.hlsl" // game-local: pulls GameCBuffers (VideoAutoHDR* fields) BEFORE shared Settings +// clang-format on // Light AutoHDR on videos (0 = off → flat SDR at paper white). Peak kept low on purpose. #ifndef ENABLE_VIDEO_AUTO_HDR @@ -19,10 +21,10 @@ cbuffer _Globals : register(b0) { - float4 cmatrix[4] : packoffset(c0); - float4 alpha_mult : packoffset(c4); - float4 hdr : packoffset(c5); - float4 ctcp : packoffset(c6); + float4 cmatrix[4] : packoffset(c0); + float4 alpha_mult : packoffset(c4); + float4 hdr : packoffset(c5); + float4 ctcp : packoffset(c6); } SamplerState YTexSampler_s : register(s0); @@ -31,35 +33,40 @@ Texture2D YTex : register(t0); Texture2D CrCbTex : register(t1); void main( - float2 v0 : TEXCOORD0, - float4 v1 : SV_Position0, - out float4 o0 : SV_Target0) + float2 v0 : TEXCOORD0, + float4 v1 : SV_Position0, + out float4 o0 : SV_Target0) { - float4 r0, r1; + float4 r0, r1; - r0.x = YTex.Sample(YTexSampler_s, v0.xy).x; - r0.yz = CrCbTex.Sample(CrCbTexSampler_s, v0.xy).xy; - r1.xyz = cmatrix[0].xyz * r0.yyy; - r0.xyw = r0.xxx * cmatrix[3].xyz + r1.xyz; - r0.xyz = r0.zzz * cmatrix[1].xyz + r0.xyw; - r0.xyz = cmatrix[2].xyz + r0.xyz; - r0.w = 1; - o0.xyzw = alpha_mult.xyzw * r0.xyzw; - o0.rgb = saturate(o0.rgb); // restore the vanilla 8-bit backbuffer clamp (kills YUV overshoot + negatives) + r0.x = YTex.Sample(YTexSampler_s, v0.xy).x; + r0.yz = CrCbTex.Sample(CrCbTexSampler_s, v0.xy).xy; + r1.xyz = cmatrix[0].xyz * r0.yyy; + r0.xyw = r0.xxx * cmatrix[3].xyz + r1.xyz; + r0.xyz = r0.zzz * cmatrix[1].xyz + r0.xyw; + r0.xyz = cmatrix[2].xyz + r0.xyz; + r0.w = 1; + o0.xyzw = alpha_mult.xyzw * r0.xyzw; + o0.rgb = saturate(o0.rgb); // restore the vanilla 8-bit backbuffer clamp (kills YUV overshoot + negatives) - // Work in linear: AutoHDR (optional) and the paper-white pre-scale both belong in linear space. The - // composition decodes gamma then multiplies by UIPaperWhite (linear), so the pre-scale must pre-compensate - // that linear multiply BEFORE re-encoding — applying it in gamma space diverges for GamePaperWhite!=UIPaperWhite. - float3 lin = gamma_to_linear(o0.rgb); + // Work in linear: AutoHDR (optional) and the paper-white pre-scale both belong in linear space. The + // composition decodes gamma then multiplies by UIPaperWhite (linear), so the pre-scale must pre-compensate + // that linear multiply BEFORE re-encoding — applying it in gamma space diverges for GamePaperWhite!=UIPaperWhite. + float3 lin = gamma_to_linear(o0.rgb); #if ENABLE_VIDEO_AUTO_HDR - // Video is gamma-encoded SDR; expand highlights mildly for HDR. - lin = PumboAutoHDR(lin, VIDEO_AUTO_HDR_PEAK_NITS, LumaSettings.GamePaperWhiteNits); + // Video is gamma-encoded SDR; expand highlights mildly for HDR. Runtime-gated (ImGui "Video AutoHDR"): + // boost 0 = peak at paper white -> PumboAutoHDR no-ops (off); 1 = full VIDEO_AUTO_HDR_PEAK_NITS. + if (LumaSettings.GameSettings.VideoAutoHDREnable > 0.5) + { + const float peakNits = lerp(sRGB_WhiteLevelNits, VIDEO_AUTO_HDR_PEAK_NITS, saturate(LumaSettings.GameSettings.VideoAutoHDRBoost)); + lin = PumboAutoHDR(lin, peakNits, LumaSettings.GamePaperWhiteNits); + } #endif #if UI_DRAW_TYPE >= 2 - // Match the tonemap pass (linear pre-scale, see Luma_BL_Tonemap.hlsl): land full-screen movies at the same - // brightness as in-game after the composition's UIPaperWhite rescale, when UIPaperWhite != GamePaperWhite. - lin *= LumaSettings.GamePaperWhiteNits / max(LumaSettings.UIPaperWhiteNits, 1.0); + // Match the tonemap pass (linear pre-scale, see Luma_BL_Tonemap.hlsl): land full-screen movies at the same + // brightness as in-game after the composition's UIPaperWhite rescale, when UIPaperWhite != GamePaperWhite. + lin *= LumaSettings.GamePaperWhiteNits / max(LumaSettings.UIPaperWhiteNits, 1.0); #endif - o0.rgb = linear_to_gamma(lin); // re-encode for the gamma post buffer - return; + o0.rgb = linear_to_gamma(lin); // re-encode for the gamma post buffer + return; } diff --git a/Shaders/Borderlands GOTY Enhanced/Includes/GameCBuffers.hlsl b/Shaders/Borderlands GOTY Enhanced/Includes/GameCBuffers.hlsl index b6732879..f70ea75f 100644 --- a/Shaders/Borderlands GOTY Enhanced/Includes/GameCBuffers.hlsl +++ b/Shaders/Borderlands GOTY Enhanced/Includes/GameCBuffers.hlsl @@ -9,24 +9,26 @@ // Mirrors c++ name spaces. namespace CB { - // User-facing grade controls, drawn in DrawImGuiSettings (main.cpp) and read in Luma_BL_Tonemap.hlsl. - // All apply only on the HDR tonemap path. SMAA metrics are passed via a dedicated CB at b1, not here. - struct LumaGameSettings - { - float Exposure; // exposure multiplier (1 = vanilla). Applied scene-referred, pre-grade. - float Saturation; // 1 = vanilla. Oklab saturation multiplier on the final HDR color. - float HighlightDechroma; // 0 = off (default; keep color, only mandatory gamut desat applies); higher = bright sources fade to white sooner. Optional perceptual taste. - float BloomIntensity; // 1 = vanilla. Scales the game's bloom contribution in the scene mix. - float Contrast; // 1 = vanilla. Slope contrast around 18% mid-gray on the final HDR color. - float Dithering; // 0/1 toggle. Animated triangular dither at output to break gradient banding. - float FlareOut; // 1 = vanilla. Scales the additive lens-flare/glare overlay (pass 0x010371F2). - }; +// User-facing grade controls, drawn in DrawImGuiSettings (main.cpp) and read in Luma_BL_Tonemap.hlsl. +// All apply only on the HDR tonemap path. SMAA metrics are passed via a dedicated CB at b1, not here. +struct LumaGameSettings +{ + float Exposure; // exposure multiplier (1 = vanilla). Applied scene-referred, pre-grade. + float Saturation; // 1 = vanilla. Oklab saturation multiplier on the final HDR color. + float HighlightDechroma; // 0 = off (default; keep color, only mandatory gamut desat applies); higher = bright sources fade to white sooner. Optional perceptual taste. + float BloomIntensity; // 1 = vanilla. Scales the game's bloom contribution in the scene mix. + float Contrast; // 1 = vanilla. Slope contrast around 18% mid-gray on the final HDR color. + float Dithering; // 0/1 toggle. Animated triangular dither at output to break gradient banding. + float FlareOut; // 1 = vanilla. Scales the additive lens-flare/glare overlay (pass 0x010371F2). + float VideoAutoHDREnable; // 0/1. Light AutoHDR on Bink movies (HDR only; pass 0x0E97A4A0). 0 = flat SDR at paper white. + float VideoAutoHDRBoost; // 0..1 highlight-expansion strength. 0 = off (peak == paper white); 1 = full VIDEO_AUTO_HDR_PEAK_NITS. +}; - // Game specific cbuffer (instance/pass) data. - struct LumaGameData - { - float Dummy; // hlsl doesn't support empty structs - }; -} +// Game specific cbuffer (instance/pass) data. +struct LumaGameData +{ + float Dummy; // hlsl doesn't support empty structs +}; +} // namespace CB #endif // LUMA_GAME_CB_STRUCTS diff --git a/Shaders/Borderlands GOTY Enhanced/Luma_BL_Tonemap.hlsl b/Shaders/Borderlands GOTY Enhanced/Luma_BL_Tonemap.hlsl index 93275003..3fd9d9c8 100644 --- a/Shaders/Borderlands GOTY Enhanced/Luma_BL_Tonemap.hlsl +++ b/Shaders/Borderlands GOTY Enhanced/Luma_BL_Tonemap.hlsl @@ -17,12 +17,18 @@ // pre-scaled by GamePaperWhite/UIPaperWhite so the HUD lands at its own paper white. The core Display // Composition decodes gamma + applies paper-white scaling + scRGB encode + gamut map at present. -#include "Includes/Common.hlsl" // game-local: defines LumaGameSettings (grade sliders) before LumaSettings cbuffer +// clang-format off +// ORDER IS LOAD-BEARING — do not sort. The game-local "Includes/Common.hlsl" MUST come first: it defines +// LUMA_GAME_CB_STRUCTS (via GameCBuffers.hlsl) BEFORE any shared header pulls Settings.hlsl, so +// LumaSettings.GameSettings resolves to the real grade struct rather than the empty dummy. When clang-format +// sorted it below the shared "../Includes/*" block, Settings.hlsl's dummy won -> "invalid subscript 'BloomIntensity'". +#include "Includes/Common.hlsl" // game-local: defines LumaGameSettings (grade sliders) before LumaSettings cbuffer #include "../Includes/Color.hlsl" -#include "../Includes/Reinhard.hlsl" // ReinhardTonemap / DefaultReinhardSettings (NeutralSDR) -#include "../Includes/Tonemap.hlsl" // UpgradeToneMap -#include "../Includes/DICE.hlsl" // DICETonemap / DefaultDICESettings #include "../Includes/ColorGradingLUT.hlsl" // RestoreHueAndChrominance, SimpleGamutClip +#include "../Includes/DICE.hlsl" // DICETonemap / DefaultDICESettings +#include "../Includes/Reinhard.hlsl" // ReinhardTonemap / DefaultReinhardSettings (NeutralSDR) +#include "../Includes/Tonemap.hlsl" // UpgradeToneMap +// clang-format on // HDR / vanilla. 1 = recover real highlights + DICE display map (default). 0 = vanilla clamped SDR reference. #ifndef TONEMAP_TYPE @@ -46,21 +52,21 @@ // --- Game bindings (must match the original shader exactly) --- cbuffer _Globals : register(b0) { - float4 PackedParameters : packoffset(c0); - float2 MinMaxBlurClamp : packoffset(c1); - float4 SceneShadowsAndDesaturation : packoffset(c2); - float4 SceneInverseHighLights : packoffset(c3); - float4 SceneMidTones : packoffset(c4); - float4 SceneScaledLuminanceWeights : packoffset(c5); - float4 GammaColorScaleAndInverse : packoffset(c6); - float4 GammaOverlayColor : packoffset(c7); + float4 PackedParameters : packoffset(c0); + float2 MinMaxBlurClamp : packoffset(c1); + float4 SceneShadowsAndDesaturation : packoffset(c2); + float4 SceneInverseHighLights : packoffset(c3); + float4 SceneMidTones : packoffset(c4); + float4 SceneScaledLuminanceWeights : packoffset(c5); + float4 GammaColorScaleAndInverse : packoffset(c6); + float4 GammaOverlayColor : packoffset(c7); } cbuffer PSOffsetConstants : register(b2) { - float4 ScreenPositionScaleBias : packoffset(c0); - float4 MinZ_MaxZRatio : packoffset(c1); - float4 DynamicScale : packoffset(c2); + float4 ScreenPositionScaleBias : packoffset(c0); + float4 MinZ_MaxZRatio : packoffset(c1); + float4 DynamicScale : packoffset(c2); } SamplerState SceneColorTextureSampler_s : register(s0); @@ -71,142 +77,145 @@ Texture2D BlurredImage : register(t1); // Neutral SDR reference for the highlight-recovery delta. float3 NeutralSDR(float3 color) { - ReinhardSettings settings = DefaultReinhardSettings(); - settings.by_luminance = true; - return ReinhardTonemap(color, 100.f, 100.f, settings); + ReinhardSettings settings = DefaultReinhardSettings(); + settings.by_luminance = true; + return ReinhardTonemap(color, 100.f, 100.f, settings); } // Grade steps 1-3 (shadows -> highlights -> midtones), lifted verbatim from the decompiled shader. Returns the -// post-midtones color: the exact point original 0xB030BAA6 takes its FXAA luma from (verified via disassembly). +// post-midtones color: the exact point original 0xB030BAA6 takes its FXAA luma from. // `clampSDR`: true = vanilla saturate() path; false = unclamped (max 0), keeping highlights' real channel ratio // (hue) instead of the per-channel saturate hue shift. float3 GradeUE3_PostMidtones(float3 scene, bool clampSDR) { - float3 c = clampSDR ? saturate(-SceneShadowsAndDesaturation.xyz + scene) - : max(0.0, -SceneShadowsAndDesaturation.xyz + scene); - c = SceneInverseHighLights.xyz * c; - c = clampSDR ? max(9.99999975e-05, abs(c)) : max(0.0, abs(c)); - c = log2(c); - c = SceneMidTones.xyz * c; - return exp2(c); + float3 c = clampSDR ? saturate(-SceneShadowsAndDesaturation.xyz + scene) + : max(0.0, -SceneShadowsAndDesaturation.xyz + scene); + c = SceneInverseHighLights.xyz * c; + c = clampSDR ? max(9.99999975e-05, abs(c)) : max(0.0, abs(c)); + c = log2(c); + c = SceneMidTones.xyz * c; + return exp2(c); } // Tail of the grade: desat + overlay + scale + gamma encode (verbatim from the back half of the original). float3 GradeUE3_FromPostMidtones(float3 c, bool clampSDR) { - float desat = dot(c, SceneScaledLuminanceWeights.xyz); - c = c * SceneShadowsAndDesaturation.www + GammaOverlayColor.xyz; - c = c + desat; - c = clampSDR ? saturate(GammaColorScaleAndInverse.xyz * c) : max(0.0, GammaColorScaleAndInverse.xyz * c); - c = clampSDR ? max(9.99999975e-05, c) : max(0.0, c); - c = log2(c); - c = GammaColorScaleAndInverse.www * c; - return exp2(c); // gamma-encoded graded color + float desat = dot(c, SceneScaledLuminanceWeights.xyz); + c = c * SceneShadowsAndDesaturation.www + GammaOverlayColor.xyz; + c = c + desat; + c = clampSDR ? saturate(GammaColorScaleAndInverse.xyz * c) : max(0.0, GammaColorScaleAndInverse.xyz * c); + c = clampSDR ? max(9.99999975e-05, c) : max(0.0, c); + c = log2(c); + c = GammaColorScaleAndInverse.www * c; + return exp2(c); // gamma-encoded graded color } float3 GradeUE3(float3 scene, bool clampSDR) { - return GradeUE3_FromPostMidtones(GradeUE3_PostMidtones(scene, clampSDR), clampSDR); + return GradeUE3_FromPostMidtones(GradeUE3_PostMidtones(scene, clampSDR), clampSDR); } // Core tonemap. `v0`/`v1` are the game's interpolators (TEXCOORD0/1). Returns scene-referred linear color // (1.0 = paper white) in `outColor`, and the FXAA luma the game's edge CS expects in `outLuma`. void RunBLTonemap(float4 v0, float2 v1, out float3 outColor, out float outLuma) { - // 1. Scene mix (scene color attenuated by inverse-blur weight, plus bloom) — the real pre-tonemap HDR. - float3 sceneColor = SceneColorTexture.Sample(SceneColorTextureSampler_s, DynamicScale.xy * v0.zw).xyz; - float4 blurred = BlurredImage.Sample(BlurredImageSampler_s, v1.xy); - float3 untonemapped = sceneColor * saturate(1.0 - blurred.w) + blurred.xyz * LumaSettings.GameSettings.BloomIntensity; + // 1. Scene mix (scene color attenuated by inverse-blur weight, plus bloom) — the real pre-tonemap HDR. + float3 sceneColor = SceneColorTexture.Sample(SceneColorTextureSampler_s, DynamicScale.xy * v0.zw).xyz; + float4 blurred = BlurredImage.Sample(BlurredImageSampler_s, v1.xy); + float3 untonemapped = sceneColor * saturate(1.0 - blurred.w) + blurred.xyz * LumaSettings.GameSettings.BloomIntensity; - // Scene exposure (multiplier), scene-referred / pre-grade; the SDR reference below derives from the same - // `untonemapped`, so the grade tracks the exposure change. - untonemapped *= LumaSettings.GameSettings.Exposure; + // Scene exposure (multiplier), scene-referred / pre-grade; the SDR reference below derives from the same + // `untonemapped`, so the grade tracks the exposure change. + untonemapped *= LumaSettings.GameSettings.Exposure; - // 2. The game's own grade (artistic intent), gamma-encoded SDR. Computed once via the split halves so the - // post-midtones intermediate can feed the FXAA luma below. - float3 postMidtones = GradeUE3_PostMidtones(untonemapped, true); - float3 ungraded_sdr_gamma = GradeUE3_FromPostMidtones(postMidtones, true); + // 2. The game's own grade (artistic intent), gamma-encoded SDR. Computed once via the split halves so the + // post-midtones intermediate can feed the FXAA luma below. + float3 postMidtones = GradeUE3_PostMidtones(untonemapped, true); + float3 ungraded_sdr_gamma = GradeUE3_FromPostMidtones(postMidtones, true); - // FXAA luma (game's edge CS reads SV_Target1): BT.709 luma of the post-midtones color (see GradeUE3_PostMidtones). - outLuma = 0.25 * log2(dot(postMidtones, float3(0.212670997, 0.715160012, 0.0721689984)) * 15.0 + 1.0); + // FXAA luma (game's edge CS reads SV_Target1): BT.709 luma of the post-midtones color (see GradeUE3_PostMidtones). + outLuma = 0.25 * log2(dot(postMidtones, float3(0.212670997, 0.715160012, 0.0721689984)) * 15.0 + 1.0); #if TONEMAP_TYPE >= 1 - float3 ungraded_sdr = gamma_to_linear(ungraded_sdr_gamma); // linear SDR reference (1.0 = white) + float3 ungraded_sdr = gamma_to_linear(ungraded_sdr_gamma); // linear SDR reference (1.0 = white) - // 3. Recover the highlight luminance the SDR tonemap clipped, on top of the graded look. - float3 neutral_sdr = NeutralSDR(untonemapped); - float3 recovered = UpgradeToneMap(untonemapped, neutral_sdr, ungraded_sdr); + // 3. Recover the highlight luminance the SDR tonemap clipped, on top of the graded look. + float3 neutral_sdr = NeutralSDR(untonemapped); + float3 recovered = UpgradeToneMap(untonemapped, neutral_sdr, ungraded_sdr); - // 4. Display rolloff to the user's peak/paper-white nits (DICE, hue-preserving by luminance). - const float paperWhite = LumaSettings.GamePaperWhiteNits / sRGB_WhiteLevelNits; - const float peakWhite = LumaSettings.PeakWhiteNits / sRGB_WhiteLevelNits; + // 4. Display rolloff to the user's peak/paper-white nits (DICE, hue-preserving by luminance). + const float paperWhite = LumaSettings.GamePaperWhiteNits / sRGB_WhiteLevelNits; + const float peakWhite = LumaSettings.PeakWhiteNits / sRGB_WhiteLevelNits; #if TONEMAP_IN_WIDER_GAMUT - recovered = BT709_To_BT2020(recovered); + recovered = BT709_To_BT2020(recovered); #endif - // BY_LUMINANCE_PQ tonemaps luminance and scales rgb by the ratio → preserves the channel ratio (hue) of a - // saturated highlight. CORRECT_CHANNELS_BEYOND_PEAK_WHITE instead desaturates over-peak channels toward - // white (blue sources blew out). - DICESettings ds = DefaultDICESettings(DICE_TYPE_BY_LUMINANCE_PQ); - float3 hdr = DICETonemap(recovered * paperWhite, peakWhite, ds) / paperWhite; + // Tonemap luminance in PQ (hue-preserving: rgb scaled by the luminance ratio), then CORRECT_CHANNELS_BEYOND_ + // PEAK_WHITE pulls any channel that still exceeds peak back into range by desaturating it toward white. Modern + // HDR panels clip each rgb channel at peak individually, so an uncorrected saturated highlight (e.g. a bright + // blue) would hard-clip with a hue shift; the correction trades a little highlight saturation for a clean, + // in-range rolloff. DesaturationVsDarkeningRatio 1.0 + // (default) = contain by desaturating, not darkening (darkening flattens detail). + DICESettings ds = DefaultDICESettings(DICE_TYPE_BY_LUMINANCE_PQ_CORRECT_CHANNELS_BEYOND_PEAK_WHITE); + float3 hdr = DICETonemap(recovered * paperWhite, peakWhite, ds) / paperWhite; #if TONEMAP_IN_WIDER_GAMUT - hdr = BT2020_To_BT709(SimpleGamutClip(hdr, true)); + hdr = BT2020_To_BT709(SimpleGamutClip(hdr, true)); #endif - // 5. Lock hue EXACTLY to the un-blown reference (objectively correct: no hue rotation with brightness). + // 5. Lock hue EXACTLY to the un-blown reference (objectively correct: no hue rotation with brightness). #if ENABLE_HUE_RESTORATION - // Reference = the grade run UNCLAMPED: keeps the real highlight channel ratio (a bright blue stays blue), - // unlike the vanilla SDR whose per-channel saturate() shifts the hue at the clip. Hue strength 1.0 (exact), - // chrominance 0.0 — we keep the by-luminance DICE chroma and let gamut mapping (GAMUT_MAPPING_TYPE in the - // composition) roll chroma to the displayable maximum at each luminance. No hand-tuned desaturation. - float3 hueRef = gamma_to_linear(GradeUE3(untonemapped, false)); - hdr = RestoreHueAndChrominance(hdr, hueRef, 1.0, 0.0); + // Reference = the grade run UNCLAMPED: keeps the real highlight channel ratio (a bright blue stays blue), + // unlike the vanilla SDR whose per-channel saturate() shifts the hue at the clip. Hue strength 1.0 (exact), + // chrominance 0.0 — we keep the by-luminance DICE chroma and let gamut mapping (GAMUT_MAPPING_TYPE in the + // composition) roll chroma to the displayable maximum at each luminance. No hand-tuned desaturation. + float3 hueRef = gamma_to_linear(GradeUE3(untonemapped, false)); + hdr = RestoreHueAndChrominance(hdr, hueRef, 1.0, 0.0); #endif - // 6. Perceptual highlight dechroma: bright sources fade toward white as luminance approaches peak (eye/sensor - // saturation). Keeps colored mid-highlights, whitens only the brightest (so warm-tinted white lamps read as - // neutral white at peak). - const float highlightDechroma = LumaSettings.GameSettings.HighlightDechroma; - if (highlightDechroma > 0.0) - { - // Map the slider to an exponent in [1, 0.05]: at the 1.0 max the exponent stays > 0 so mid-tones keep - // their color (dcWeight < 1) and only luminance->peak fades to white. An exponent of exactly 0 would make - // pow(x,0)=1 everywhere -> Saturation(hdr,0) -> full-frame greyscale, which is not what the slider means. - float dcExp = lerp(1.0, 0.05, highlightDechroma); - float dcWeight = saturate(pow(saturate(GetLuminance(hdr) / peakWhite), dcExp)); - hdr = Saturation(hdr, 1.0 - dcWeight); - } - - // User saturation (Oklab, shared helper). 1.0 = vanilla. - hdr = Saturation(hdr, LumaSettings.GameSettings.Saturation); - - // User contrast: slope around 18% mid-gray (linear, 1.0 = paper white). 1.0 = vanilla. Excursions are - // caught by the NaN/clamp tail; > peak highlights are acceptable for a creative slider. - const float midGray = 0.18; - hdr = (hdr - midGray) * LumaSettings.GameSettings.Contrast + midGray; - - outColor = hdr; // linear, 1.0 = paper white + // 6. Perceptual highlight dechroma: bright sources fade toward white as luminance approaches peak (eye/sensor + // saturation). Keeps colored mid-highlights, whitens only the brightest (so warm-tinted white lamps read as + // neutral white at peak). + const float highlightDechroma = LumaSettings.GameSettings.HighlightDechroma; + if (highlightDechroma > 0.0) + { + // Map the slider to an exponent in [1, 0.05]: at the 1.0 max the exponent stays > 0 so mid-tones keep + // their color (dcWeight < 1) and only luminance->peak fades to white. An exponent of exactly 0 would make + // pow(x,0)=1 everywhere -> Saturation(hdr,0) -> full-frame greyscale, which is not what the slider means. + float dcExp = lerp(1.0, 0.05, highlightDechroma); + float dcWeight = saturate(pow(saturate(GetLuminance(hdr) / peakWhite), dcExp)); + hdr = Saturation(hdr, 1.0 - dcWeight); + } + + // User saturation (Oklab, shared helper). 1.0 = vanilla. + hdr = Saturation(hdr, LumaSettings.GameSettings.Saturation); + + // User contrast: slope around 18% mid-gray (linear, 1.0 = paper white). 1.0 = vanilla. Excursions are + // caught by the NaN/clamp tail; > peak highlights are acceptable for a creative slider. + const float midGray = 0.18; + hdr = (hdr - midGray) * LumaSettings.GameSettings.Contrast + midGray; + + outColor = hdr; // linear, 1.0 = paper white #else - // Vanilla reference: linearize the clamped SDR grade. - outColor = gamma_to_linear(saturate(ungraded_sdr_gamma)); + // Vanilla reference: linearize the clamped SDR grade. + outColor = gamma_to_linear(saturate(ungraded_sdr_gamma)); #endif - // --- Common tail: UI paper-white pre-scale + post-process-space encode --- + // --- Common tail: UI paper-white pre-scale + post-process-space encode --- #if UI_DRAW_TYPE >= 2 - // Pre-scale so the gamma-SDR HUD (drawn on top) lands at UIPaperWhite after composition rescales by it. - outColor *= LumaSettings.GamePaperWhiteNits / max(LumaSettings.UIPaperWhiteNits, 1.0); + // Pre-scale so the gamma-SDR HUD (drawn on top) lands at UIPaperWhite after composition rescales by it. + outColor *= LumaSettings.GamePaperWhiteNits / max(LumaSettings.UIPaperWhiteNits, 1.0); #endif - // Sanitize: the scene carries small negative/WCG values; the recovery + hue restore + gamma encode can - // emit NaN or negatives (linear_to_gamma of a negative is NaN). Clamp so no garbage reaches the swapchain. - outColor = (outColor == outColor) ? outColor : 0.0; // NaN -> 0 (NaN != NaN) - outColor = max(0.0, outColor); + // Sanitize: the scene carries small negative/WCG values; the recovery + hue restore + gamma encode can + // emit NaN or negatives (linear_to_gamma of a negative is NaN). Clamp so no garbage reaches the swapchain. + outColor = (outColor == outColor) ? outColor : 0.0; // NaN -> 0 (NaN != NaN) + outColor = max(0.0, outColor); #if POST_PROCESS_SPACE_TYPE == 0 - // Store gamma so the game's gamma-space HUD blends like vanilla; composition decodes + applies paper white. - outColor = linear_to_gamma(outColor); - // Anti-banding dither in the stored gamma space (the core composition does not dither). Animated triangular - // noise; sub-perceptual at bit depth 9 so the later SMAA/RCAS passes don't visibly amplify it. HDR path only. + // Store gamma so the game's gamma-space HUD blends like vanilla; composition decodes + applies paper white. + outColor = linear_to_gamma(outColor); + // Anti-banding dither in the stored gamma space (the core composition does not dither). Animated triangular + // noise; sub-perceptual at bit depth 9 so the later SMAA/RCAS passes don't visibly amplify it. HDR path only. #if TONEMAP_TYPE >= 1 - if (LumaSettings.GameSettings.Dithering > 0.5) - ApplyDithering(outColor, v1.xy, true, 1.0, DITHERING_BIT_DEPTH, LumaSettings.FrameIndex, true); + if (LumaSettings.GameSettings.Dithering > 0.5) + ApplyDithering(outColor, v1.xy, true, 1.0, DITHERING_BIT_DEPTH, LumaSettings.FrameIndex, true); #endif #endif } diff --git a/Shaders/Borderlands GOTY Enhanced/Luma_BL_XeGTAO.hlsl b/Shaders/Borderlands GOTY Enhanced/Luma_BL_XeGTAO.hlsl new file mode 100644 index 00000000..8a2999d3 --- /dev/null +++ b/Shaders/Borderlands GOTY Enhanced/Luma_BL_XeGTAO.hlsl @@ -0,0 +1,753 @@ +// XeGTAO adapted for Borderlands GOTY Enhanced (UE3.5) — replaces the game's NVIDIA HBAO+ (GFSDK_SSAO). +// Source: https://github.com/GameTechDev/XeGTAO +// +// BL GOTY specifics: +// - The native AO pipeline is FULL-RES (3840x2160 @ 4K): deinterleave 0xFFE232A6 -> normals 0xB2B47225 -> +// coarse horizon AO 0xF534EB09 -> bilateral blur 0x4E1BEE34 -> apply-multiply PS 0x44764BF6. We run at +// full res and write the final visibility term into the game's own FINAL AO target (the blur CS u0, a +// r16g16_float; apply blit reads .x). The apply blit (blend dst*src_color) is untouched by us. +// - We bind the game's OWN constant buffers: cb0 = HBAO+ $Globals (ProjInfo = NDC->view, live FOV) and +// cb2 = CSOffsetConstants (MinZ_MaxZRatioCS = depth linearization). +// - Depth input = the game's full-res r24_g8 scene depth (deinterleave 0xFFE232A6 t0), viewed r24_unorm_x8. +// Read with explicit .Load: GatherRed on an r24_unorm_x8 view returns all-zeros on this driver (silently +// kills the AO) — do not "optimize" this back to Gather. +// - Normals input = the game's ViewNormalTex (coarse-AO pass 0xF534EB09 t0), r11g11b10_float: xyz packed +// v*0.5+0.5 (full 3-channel view-space normal, no z reconstruct). View-space already. +// - BL GOTY has NO TAA and no motion vectors: NoiseIndex is FROZEN at 0 (never feed a frame index — the +// pattern must be static or it boils), quality default is Very High and denoise runs twice; all +// stability is spatial. +// - viewZ is in UE3 units (near plane ~10 units) — a huge range; DepthScale (default 50 -> ~meters) +// rescales it into the range XeGTAO's Intel-tuned constants expect (R32F pyramid, so no fp16 precision +// loss at huge Z). DepthScale is a tuning convenience, not a correctness requirement. + +// --- Game constant buffers (bound by the game at the hooked dispatches) --- + +cbuffer _Globals : register(b0) // NVIDIA GFSDK_SSAO $Globals +{ + float RadiusToScreen; // Offset: 0 + float NegInvR2; // Offset: 4 (GFSDK name; this build stores +1/R^2 (measured positive) -> R=sqrt(1/NegInvR2), view units = vanilla AO radius) + float NDotVBias; // Offset: 8 + float2 InvFullResolution; // Offset: 16 (1 / the AO input resolution; native HBAO+ runs full-res here — we size scratch + dispatches from that same depth, so the UV math matches by construction) + float2 InvQuarterResolution; // Offset: 24 + int2 FullResOffset; // Offset: 32 + int2 QuarterResOffset; // Offset: 40 + float AOMultiplier; // Offset: 48 + float PowExponent; // Offset: 52 (vanilla darkness dial, applied in the blur CS) + float4 ProjInfo; // Offset: 64 (viewPos.xy = (uv*ProjInfo.xy + ProjInfo.zw) * viewZ) + float2 Float2Offset; // Offset: 80 + float4 Jitter; // Offset: 96 + int ArrayOffset; // Offset: 112 + float4 JitterCS[8]; // Offset: 128 +} + +cbuffer CSOffsetConstants : register(b2) +{ + float4x4 ViewProjectionMatrixCS; // Offset: 0 + float4 CameraPositionCS; // Offset: 64 + float4 ScreenPositionScaleBiasCS; // Offset: 80 + float4 MinZ_MaxZRatioCS; // Offset: 96 (viewZ = 1 / (d * .z - .w), non-reverse-Z) + float4 DynamicScaleCS; // Offset: 112 +} + +// --- Luma runtime knobs (set from main.cpp; live-tunable via DEV sliders, no recompile) --- +cbuffer LumaGTAO : register(b11) +{ + float FinalValuePowerRT; // primary darkness dial, calibrated to the vanilla AO histogram + float DepthScaleRT; // viewZ divisor (UE3 units -> ~meters); THE dial against broad over-occlusion + float RadiusOverrideRT; // > 0 overrides EFFECT_RADIUS (view units after DepthScale) + float DebugViewRT; // DEVELOPMENT: 0=off 1=depth gradient 2=normals 3=AO x8 4=edges +} + +// clang-format off +#include "Includes/Common.hlsl" // game-local: order load-bearing (pulls GameCBuffers before shared includes) — do not let SortIncludes move it +// clang-format on + +#if XE_GTAO_QUALITY == 0 // Low +#define SLICE_COUNT 4.0 +#elif XE_GTAO_QUALITY == 1 // Medium +#define SLICE_COUNT 7.0 +#elif XE_GTAO_QUALITY == 2 // High +#define SLICE_COUNT 10.0 +#elif XE_GTAO_QUALITY == 3 // Very High +#define SLICE_COUNT 13.0 +#elif XE_GTAO_QUALITY == 4 // Ultra +#define SLICE_COUNT 16.0 +#endif + +// User configurable +// + +#ifndef EFFECT_RADIUS +#define EFFECT_RADIUS 0.64 // anchored to the game's own HBAO+ radius: probed NegInvR2=0.000463 -> R=sqrt(1/NegInvR2)=46.5 UE3-units; chosen so the effective search (EFFECT_RADIUS * RADIUS_MULTIPLIER * DepthScale) = 0.64*1.457*50 = 46.6uu matches native R. RadiusOverrideRT > 0 wins. +#endif + +#ifndef RADIUS_MULTIPLIER +#define RADIUS_MULTIPLIER 1.457 // Default 1.457 +#endif + +#ifndef EFFECT_FALLOFF_RANGE +#define EFFECT_FALLOFF_RANGE 0.005 // punchy hard-edge falloff: full sample weight up to the radius edge = crisp contact AO matching BL's punchy native HBAO+ (vs the soft 0.615/0.95 gradual fade). Pairs with power ~1.0 +#endif + +#ifndef SAMPLE_DISTRIBUTION_POWER +#define SAMPLE_DISTRIBUTION_POWER 1.5 // Default 2.0 +#endif + +#ifndef THIN_OCCLUDER_COMPENSATION +#define THIN_OCCLUDER_COMPENSATION 0.0 // Default 0.0; > 0 causes more mistakes than it fixes on big geometry +#endif + +#ifndef FINAL_VALUE_POWER +#define FINAL_VALUE_POWER 1.0 // Default 2.2; shadow default for FinalValuePowerRT (the CB value is what actually applies) +#endif + +#ifndef DEPTH_MIP_SAMPLING_OFFSET +#define DEPTH_MIP_SAMPLING_OFFSET 3.3 // Default 3.3 +#endif + +#ifndef SLICE_COUNT +#define SLICE_COUNT 3.0 // Default 3.0 +#endif + +#ifndef STEPS_PER_SLICE +#define STEPS_PER_SLICE 3.0 // Default 3.0 +#endif + +#ifndef DENOISE_BLUR_BETA +#define DENOISE_BLUR_BETA 1.2 // Default 1.2 +#endif + +// BL packs the full xyz view-space normal (v*0.5+0.5) — decoded directly, no reconstruction. NORMAL_Z_SIGN +// flips only the decoded z if the view-space handedness needs it (verify via DebugViewRT=2: smooth +// per-surface shading = correct; flip to -1.0 if the shading looks inverted). +#ifndef NORMAL_Z_SIGN +#define NORMAL_Z_SIGN (1.0) +#endif + +// + +#define VIEWPORT_PIXEL_SIZE InvFullResolution + +// GFSDK ProjInfo is exactly the NDC->view mul/add pair (live FOV, dialogue zoom included). +#define NDC_TO_VIEW_MUL ProjInfo.xy +#define NDC_TO_VIEW_ADD ProjInfo.zw +#define NDC_TO_VIEW_MUL_X_PIXEL_SIZE (NDC_TO_VIEW_MUL * VIEWPORT_PIXEL_SIZE) + +#define XE_GTAO_DEPTH_MIP_LEVELS 5.0 +#define XE_GTAO_OCCLUSION_TERM_SCALE 1.5 + +#define XE_GTAO_PI 3.1415926535897932384626433832795 +#define XE_GTAO_PI_HALF 1.5707963267948966192313216916398 + +// Hardware d24 (non-reverse-Z) -> view Z via the game's own MinZ_MaxZRatioCS, then rescaled by +// DepthScaleRT so the tuned XeGTAO constants (radius/falloff, ~meter scale) apply. UE3 near plane ~10 +// units and far -> infinity; without the rescale the huge Z range causes broad over-occlusion +// (mips/falloff assumptions break). +float XeGTAO_ScreenSpaceToViewSpaceDepth(const float screenDepth) +{ + float viewZ = 1.0 / max(1e-7, screenDepth * MinZ_MaxZRatioCS.z - MinZ_MaxZRatioCS.w); + return max(0.0, viewZ) / max(1e-3, DepthScaleRT); +} + +// This is also a good place to do non-linear depth conversion for cases where one wants the 'radius' (effectively the threshold between near-field and far-field GI), +// is required to be non-linear (i.e. very large outdoors environments). +float XeGTAO_ClampDepth(float depth) +{ + return clamp(depth, 0.0, 3.402823466e+38); +} + +float XeGTAO_EffectRadius() +{ + return (RadiusOverrideRT > 0.0 ? RadiusOverrideRT : EFFECT_RADIUS) * RADIUS_MULTIPLIER; +} + +// weighted average depth filter +float XeGTAO_DepthMIPFilter(float depth0, float depth1, float depth2, float depth3) +{ + float maxDepth = max(max(depth0, depth1), max(depth2, depth3)); + + const float depthRangeScaleFactor = 0.75; // found empirically :) + const float effectRadius = depthRangeScaleFactor * XeGTAO_EffectRadius(); + const float falloffRange = EFFECT_FALLOFF_RANGE * effectRadius; + const float falloffFrom = effectRadius * (1.0 - EFFECT_FALLOFF_RANGE); + + // fadeout precompute optimisation + const float falloffMul = -1.0 / falloffRange; + const float falloffAdd = falloffFrom / falloffRange + 1.0; + + float weight0 = saturate((maxDepth - depth0) * falloffMul + falloffAdd); + float weight1 = saturate((maxDepth - depth1) * falloffMul + falloffAdd); + float weight2 = saturate((maxDepth - depth2) * falloffMul + falloffAdd); + float weight3 = saturate((maxDepth - depth3) * falloffMul + falloffAdd); + + float weightSum = weight0 + weight1 + weight2 + weight3; + return (weight0 * depth0 + weight1 * depth1 + weight2 * depth2 + weight3 * depth3) * rcp(weightSum); +} + +groupshared float g_scratchDepths[8][8]; +void XeGTAO_PrefilterDepths16x16(uint2 dispatchThreadID, uint2 groupThreadID, Texture2D sourceNDCDepth, RWTexture2D outDepth0, RWTexture2D outDepth1, RWTexture2D outDepth2, RWTexture2D outDepth3, RWTexture2D outDepth4) +{ + // MIP 0 + const uint2 baseCoord = dispatchThreadID; + const uint2 pixCoord = baseCoord * 2; + // Explicit integer Loads of the 2x2 instead of GatherRed — GatherRed on the r24_unorm_x8 depth + // view returns 0 on some drivers (flat depth -> visibility 1 -> AO silently gone). Loads read real + // values. Out-of-bounds Loads return 0 (D3D11-defined), clamped depth. + float d00 = sourceNDCDepth.Load(int3(pixCoord + uint2(0, 0), 0)).x; + float d10 = sourceNDCDepth.Load(int3(pixCoord + uint2(1, 0), 0)).x; + float d01 = sourceNDCDepth.Load(int3(pixCoord + uint2(0, 1), 0)).x; + float d11 = sourceNDCDepth.Load(int3(pixCoord + uint2(1, 1), 0)).x; + float depth0 = XeGTAO_ClampDepth(XeGTAO_ScreenSpaceToViewSpaceDepth(d00)); + float depth1 = XeGTAO_ClampDepth(XeGTAO_ScreenSpaceToViewSpaceDepth(d10)); + float depth2 = XeGTAO_ClampDepth(XeGTAO_ScreenSpaceToViewSpaceDepth(d01)); + float depth3 = XeGTAO_ClampDepth(XeGTAO_ScreenSpaceToViewSpaceDepth(d11)); + outDepth0[pixCoord + uint2(0, 0)] = depth0; + outDepth0[pixCoord + uint2(1, 0)] = depth1; + outDepth0[pixCoord + uint2(0, 1)] = depth2; + outDepth0[pixCoord + uint2(1, 1)] = depth3; + + // MIP 1 + float dm1 = XeGTAO_DepthMIPFilter(depth0, depth1, depth2, depth3); + outDepth1[baseCoord] = dm1; + g_scratchDepths[groupThreadID.x][groupThreadID.y] = dm1; + + GroupMemoryBarrierWithGroupSync(); + + // MIP 2 + [branch] if (all((groupThreadID.xy % 2) == 0)) + { + float inTL = g_scratchDepths[groupThreadID.x + 0][groupThreadID.y + 0]; + float inTR = g_scratchDepths[groupThreadID.x + 1][groupThreadID.y + 0]; + float inBL = g_scratchDepths[groupThreadID.x + 0][groupThreadID.y + 1]; + float inBR = g_scratchDepths[groupThreadID.x + 1][groupThreadID.y + 1]; + + float dm2 = XeGTAO_DepthMIPFilter(inTL, inTR, inBL, inBR); + outDepth2[baseCoord / 2] = dm2; + g_scratchDepths[groupThreadID.x][groupThreadID.y] = dm2; + } + + GroupMemoryBarrierWithGroupSync(); + + // MIP 3 + [branch] if (all((groupThreadID.xy % 4) == 0)) + { + float inTL = g_scratchDepths[groupThreadID.x + 0][groupThreadID.y + 0]; + float inTR = g_scratchDepths[groupThreadID.x + 2][groupThreadID.y + 0]; + float inBL = g_scratchDepths[groupThreadID.x + 0][groupThreadID.y + 2]; + float inBR = g_scratchDepths[groupThreadID.x + 2][groupThreadID.y + 2]; + + float dm3 = XeGTAO_DepthMIPFilter(inTL, inTR, inBL, inBR); + outDepth3[baseCoord / 4] = dm3; + g_scratchDepths[groupThreadID.x][groupThreadID.y] = dm3; + } + + GroupMemoryBarrierWithGroupSync(); + + // MIP 4 + [branch] if (all((groupThreadID.xy % 8) == 0)) + { + float inTL = g_scratchDepths[groupThreadID.x + 0][groupThreadID.y + 0]; + float inTR = g_scratchDepths[groupThreadID.x + 4][groupThreadID.y + 0]; + float inBL = g_scratchDepths[groupThreadID.x + 0][groupThreadID.y + 4]; + float inBR = g_scratchDepths[groupThreadID.x + 4][groupThreadID.y + 4]; + + float dm4 = XeGTAO_DepthMIPFilter(inTL, inTR, inBL, inBR); + outDepth4[baseCoord / 8] = dm4; + // g_scratchDepths[ groupThreadID.x ][ groupThreadID.y ] = dm4; + } +} + +float4 XeGTAO_CalculateEdges(float centerZ, float leftZ, float rightZ, float topZ, float bottomZ) +{ + float4 edgesLRTB = float4(leftZ, rightZ, topZ, bottomZ) - centerZ; + + float slopeLR = (edgesLRTB.y - edgesLRTB.x) * 0.5; + float slopeTB = (edgesLRTB.w - edgesLRTB.z) * 0.5; + float4 edgesLRTBSlopeAdjusted = edgesLRTB + float4(slopeLR, -slopeLR, slopeTB, -slopeTB); + edgesLRTB = min(abs(edgesLRTB), abs(edgesLRTBSlopeAdjusted)); + return saturate(1.25 - edgesLRTB * rcp(centerZ * 0.011)); +} + +// packing/unpacking for edges; 2 bits per edge mean 4 gradient values (0, 0.33, 0.66, 1) for smoother transitions! +float XeGTAO_PackEdges(float4 edgesLRTB) +{ + edgesLRTB = round(saturate(edgesLRTB) * 2.9); + return dot(edgesLRTB, float4(64.0 / 255.0, 16.0 / 255.0, 4.0 / 255.0, 1.0 / 255.0)); +} + +// Inputs are screen XY and viewspace depth, output is viewspace position +float3 XeGTAO_ComputeViewspacePosition(float2 screenPos, float viewspaceDepth) +{ + float3 ret; + ret.xy = (NDC_TO_VIEW_MUL * screenPos.xy + NDC_TO_VIEW_ADD) * viewspaceDepth; + ret.z = viewspaceDepth; + return ret; +} + +// http://h14s.p5r.org/2012/09/0x5f3759df.html, [Drobot2014a] Low Level Optimizations for GCN, https://blog.selfshadow.com/publications/s2016-shading-course/activision/s2016_pbs_activision_occlusion.pdf slide 63 +float XeGTAO_FastSqrt(float x) +{ + return asfloat(0x1fbd1df5 + (asint(x) >> 1)); +} + +// input [-1, 1] and output [0, PI], from https://seblagarde.wordpress.com/2014/12/01/inverse-trigonometric-functions-gpu-optimization-for-amd-gcn-architecture/ +float XeGTAO_FastACos(float inX) +{ + // NOTE: no local "PI" here — the game-local Common.hlsl include chain defines PI as a macro. + float x = abs(inX); + float res = -0.156583 * x + 1.570796; + res *= XeGTAO_FastSqrt(1.0 - x); + return inX >= 0 ? res : 3.141593 - res; +} + +void XeGTAO_MainPass(uint2 pixCoord, float2 localNoise, float3 viewspaceNormal, Texture2D sourceViewspaceDepth, SamplerState depthSampler, RWTexture2D outWorkingAOTermAndEdges) +{ + float2 normalizedScreenPos = (pixCoord + 0.5) * VIEWPORT_PIXEL_SIZE; + + // Center + cross depths from the prefiltered (already linearized + scaled) mip0. This texture is our + // own R32F, Gather is safe here (the r24 quirk is only on the game's depth view). + float4 valuesUL = sourceViewspaceDepth.GatherRed(depthSampler, float2(pixCoord * VIEWPORT_PIXEL_SIZE)); + float4 valuesBR = sourceViewspaceDepth.GatherRed(depthSampler, float2(pixCoord * VIEWPORT_PIXEL_SIZE), int2(1, 1)); + + // viewspace Z at the center + float viewspaceZ = valuesUL.y; + + // viewspace Zs left top right bottom + const float pixLZ = valuesUL.x; + const float pixTZ = valuesUL.z; + const float pixRZ = valuesBR.z; + const float pixBZ = valuesBR.x; + + float4 edgesLRTB = XeGTAO_CalculateEdges(viewspaceZ, pixLZ, pixRZ, pixTZ, pixBZ); + const float edges = XeGTAO_PackEdges(edgesLRTB); + +#if DEVELOPMENT + // Debug views (visible on screen through the game's own AO apply blit; the final denoise passes raw + // values through when DebugViewRT > 0). + if (DebugViewRT > 0.5 && DebugViewRT < 1.5) // 1 = depth gradient (proves live depth + linearization/scale) + { + outWorkingAOTermAndEdges[pixCoord] = float2(saturate(frac(log2(max(viewspaceZ, 1e-6)))), 1.0); + return; + } + if (DebugViewRT >= 3.5 && DebugViewRT < 4.5) // 4 = edges + { + outWorkingAOTermAndEdges[pixCoord] = float2(dot(edgesLRTB, 0.25), 1.0); + return; + } +#endif + + // Move center pixel slightly towards camera to avoid imprecision artifacts due to depth buffer imprecision; offset depends on depth texture format used + viewspaceZ *= 0.99999; // this is good for FP32 depth buffer + + const float3 pixCenterPos = XeGTAO_ComputeViewspacePosition(normalizedScreenPos, viewspaceZ); + const float3 viewVec = normalize(-pixCenterPos); + + // prevents normals that are facing away from the view vector - xeGTAO struggles with extreme cases, but in Vanilla it seems rare so it's disabled by default + viewspaceNormal = normalize(viewspaceNormal + max(0, -dot(viewspaceNormal, viewVec)) * viewVec); + +#if DEVELOPMENT + if (DebugViewRT >= 1.5 && DebugViewRT < 2.5) // 2 = normals view-facing term (smooth per-surface shading = correct decode) + { + outWorkingAOTermAndEdges[pixCoord] = float2(saturate(abs(dot(viewspaceNormal, viewVec))), 1.0); + return; + } +#endif + + const float effectRadius = XeGTAO_EffectRadius(); + const float sampleDistributionPower = SAMPLE_DISTRIBUTION_POWER; + const float thinOccluderCompensation = THIN_OCCLUDER_COMPENSATION; + const float falloffRange = EFFECT_FALLOFF_RANGE * effectRadius; + const float falloffFrom = effectRadius * (1.0 - EFFECT_FALLOFF_RANGE); + + // fadeout precompute optimisation + const float falloffMul = -1.0 / falloffRange; + const float falloffAdd = falloffFrom / falloffRange + 1.0; + + float visibility = 0.0; + + // see "Algorithm 1" in https://www.activision.com/cdn/research/Practical_Real_Time_Strategies_for_Accurate_Indirect_Occlusion_NEW%20VERSION_COLOR.pdf + { + const float noiseSlice = localNoise.x; + const float noiseSample = localNoise.y; + + // quality settings / tweaks / hacks + const float pixelTooCloseThreshold = 1.3; // if the offset is under approx pixel size (pixelTooCloseThreshold), push it out to the minimum distance + + // approx viewspace pixel size at pixCoord; approximation of NDCToViewspace( normalizedScreenPos.xy + consts.ViewportPixelSize.xy, pixCenterPos.z ).xy - pixCenterPos.xy; + const float2 pixelDirRBViewspaceSizeAtCenterZ = viewspaceZ.xx * NDC_TO_VIEW_MUL_X_PIXEL_SIZE; + + float screenspaceRadius = effectRadius * rcp(abs(pixelDirRBViewspaceSizeAtCenterZ.x)); + + // fade out for small screen radii + visibility += saturate((10.0 - screenspaceRadius) / 100.0) * 0.5; + + // this is the min distance to start sampling from to avoid sampling from the center pixel (no useful data obtained from sampling center pixel) + const float minS = pixelTooCloseThreshold * rcp(screenspaceRadius); + + //[unroll] + for (float slice = 0.0; slice < SLICE_COUNT; slice++) + { + float sliceK = (slice + noiseSlice) / SLICE_COUNT; + // lines 5, 6 from the paper + float phi = sliceK * XE_GTAO_PI; + float cosPhi = cos(phi); + float sinPhi = sin(phi); + float2 omega = float2(cosPhi, -sinPhi); // lpfloat2 on omega causes issues with big radii + + // convert to screen units (pixels) for later use + omega *= screenspaceRadius; + + // line 8 from the paper + const float3 directionVec = float3(cosPhi, sinPhi, 0.0); + + // line 9 from the paper + const float3 orthoDirectionVec = directionVec - (dot(directionVec, viewVec) * viewVec); + + // line 10 from the paper + // axisVec is orthogonal to directionVec and viewVec, used to define projectedNormal + const float3 axisVec = normalize(cross(orthoDirectionVec, viewVec)); + + // line 11 from the paper + float3 projectedNormalVec = viewspaceNormal - axisVec * dot(viewspaceNormal, axisVec); + + // line 13 from the paper + float signNorm = sign(dot(orthoDirectionVec, projectedNormalVec)); + + // line 14 from the paper + float projectedNormalVecLength = length(projectedNormalVec); + float cosNorm = saturate(dot(projectedNormalVec, viewVec) * rcp(projectedNormalVecLength)); + + // line 15 from the paper + float n = signNorm * XeGTAO_FastACos(cosNorm); + + // this is a lower weight target; not using -1 as in the original paper because it is under horizon, so a 'weight' has different meaning based on the normal + const float lowHorizonCos0 = cos(n + XE_GTAO_PI_HALF); + const float lowHorizonCos1 = cos(n - XE_GTAO_PI_HALF); + + // lines 17, 18 from the paper, manually unrolled the 'side' loop + float horizonCos0 = lowHorizonCos0; //-1; + float horizonCos1 = lowHorizonCos1; //-1; + + [unroll] for (float step = 0.0; step < STEPS_PER_SLICE; step++) + { + // R1 sequence (http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/) + const float stepBaseNoise = (slice + step * STEPS_PER_SLICE) * 0.6180339887498948482; // <- this should unroll + float stepNoise = frac(noiseSample + stepBaseNoise); + + // approx line 20 from the paper, with added noise + float s = (step + stepNoise) / STEPS_PER_SLICE; // + (lpfloat2)1e-6f); + + // additional distribution modifier + s = pow(s, sampleDistributionPower); + + // avoid sampling center pixel + s += minS; + + // approx lines 21-22 from the paper, unrolled + float2 sampleOffset = s * omega; + + float sampleOffsetLength = length(sampleOffset); + + // note: when sampling, using point_point_point or point_point_linear sampler works, but linear_linear_linear will cause unwanted interpolation between neighbouring depth values on the same MIP level! + const float mipLevel = clamp(log2(sampleOffsetLength) - DEPTH_MIP_SAMPLING_OFFSET, 0.0, XE_GTAO_DEPTH_MIP_LEVELS); + + // Snap to pixel center (more correct direction math, avoids artifacts due to sampling pos not matching depth texel center - messes up slope - but adds other + // artifacts due to them being pushed off the slice). Also use full precision for high res cases. + sampleOffset = round(sampleOffset) * VIEWPORT_PIXEL_SIZE; + + float2 sampleScreenPos0 = normalizedScreenPos + sampleOffset; + float SZ0 = sourceViewspaceDepth.SampleLevel(depthSampler, sampleScreenPos0, mipLevel).x; + float3 samplePos0 = XeGTAO_ComputeViewspacePosition(sampleScreenPos0, SZ0); + + float2 sampleScreenPos1 = normalizedScreenPos - sampleOffset; + float SZ1 = sourceViewspaceDepth.SampleLevel(depthSampler, sampleScreenPos1, mipLevel).x; + float3 samplePos1 = XeGTAO_ComputeViewspacePosition(sampleScreenPos1, SZ1); + + float3 sampleDelta0 = samplePos0 - pixCenterPos; // using lpfloat for sampleDelta causes precision issues + float3 sampleDelta1 = samplePos1 - pixCenterPos; // using lpfloat for sampleDelta causes precision issues + float sampleDist0 = length(sampleDelta0); + float sampleDist1 = length(sampleDelta1); + + // approx lines 23, 24 from the paper, unrolled + float3 sampleHorizonVec0 = sampleDelta0 * rcp(sampleDist0); + float3 sampleHorizonVec1 = sampleDelta1 * rcp(sampleDist1); + + // any sample out of radius should be discarded - also use fallof range for smooth transitions; this is a modified idea from "4.3 Implementation details, Bounding the sampling area" + // this is our own thickness heuristic that relies on sooner discarding samples behind the center + float falloffBase0 = length(float3(sampleDelta0.x, sampleDelta0.y, sampleDelta0.z * (1.0 + thinOccluderCompensation))); + float falloffBase1 = length(float3(sampleDelta1.x, sampleDelta1.y, sampleDelta1.z * (1.0 + thinOccluderCompensation))); + float weight0 = saturate(falloffBase0 * falloffMul + falloffAdd); + float weight1 = saturate(falloffBase1 * falloffMul + falloffAdd); + + // sample horizon cos + float shc0 = dot(sampleHorizonVec0, viewVec); + float shc1 = dot(sampleHorizonVec1, viewVec); + + // discard unwanted samples + shc0 = lerp(lowHorizonCos0, shc0, weight0); // this would be more correct but too expensive: cos(lerp( acos(lowHorizonCos0), acos(shc0), weight0 )); + shc1 = lerp(lowHorizonCos1, shc1, weight1); // this would be more correct but too expensive: cos(lerp( acos(lowHorizonCos1), acos(shc1), weight1 )); + + // thickness heuristic disabled (THIN_OCCLUDER_COMPENSATION == 0) + horizonCos0 = max(horizonCos0, shc0); + horizonCos1 = max(horizonCos1, shc1); + } + +#if 1 // I can't figure out the slight overdarkening on high slopes, so I'm adding this fudge - in the training set, 0.05 is close (PSNR 21.34) to disabled (PSNR 21.45) + projectedNormalVecLength = lerp(projectedNormalVecLength, 1.0, 0.05); +#endif + + // line ~27, unrolled + float h0 = -XeGTAO_FastACos(horizonCos1); + float h1 = XeGTAO_FastACos(horizonCos0); + float iarc0 = (cosNorm + 2.0 * h0 * sin(n) - cos(2.0 * h0 - n)) / 4.0; + float iarc1 = (cosNorm + 2.0 * h1 * sin(n) - cos(2.0 * h1 - n)) / 4.0; + float localVisibility = projectedNormalVecLength * (iarc0 + iarc1); + visibility += localVisibility; + } + visibility /= SLICE_COUNT; + visibility = pow(visibility, max(0.05, FinalValuePowerRT)); // runtime dial (calibrated to the vanilla AO histogram) + visibility = max(0.03, visibility); // disallow total occlusion (which wouldn't make any sense anyhow since pixel is visible but also helps with packing bent normals) + } + + visibility = saturate(visibility / XE_GTAO_OCCLUSION_TERM_SCALE); + outWorkingAOTermAndEdges[pixCoord] = float2(visibility, edges); +} + +void XeGTAO_DecodeGatherPartial(float4 packedValue, out float outDecoded[4]) +{ + for (int i = 0; i < 4; i++) + { + outDecoded[i] = packedValue[i]; + } +} + +float4 XeGTAO_UnpackEdges(float _packedVal) +{ + uint packedVal = uint(_packedVal * 255.5); + float4 edgesLRTB; + edgesLRTB.x = float((packedVal >> 6) & 0x03) / 3.0; // there's really no need for mask (as it's an 8 bit input) but I'll leave it in so it doesn't cause any trouble in the future + edgesLRTB.y = float((packedVal >> 4) & 0x03) / 3.0; + edgesLRTB.z = float((packedVal >> 2) & 0x03) / 3.0; + edgesLRTB.w = float((packedVal >> 0) & 0x03) / 3.0; + + return saturate(edgesLRTB); +} + +void XeGTAO_AddSample(float ssaoValue, float edgeValue, inout float sum, inout float sumWeight) +{ + float weight = edgeValue; + + sum += weight * ssaoValue; + sumWeight += weight; +} + +void XeGTAO_Denoise(uint2 pixCoordBase, Texture2D sourceAOTermAndEdges, SamplerState texSampler, +#if XE_GTAO_FINAL_APPLY + RWTexture2D outputTexture // the game's final AO target is r16g16_float (apply reads .x) +#else + RWTexture2D outputTexture +#endif +) +{ +#if DEVELOPMENT + // Debug views: pass the raw working value through unblurred so the on-screen viz is exact. + if (DebugViewRT > 0.5) + { + for (int dside = 0; dside < 2; dside++) + { + const uint2 dpix = uint2(pixCoordBase.x + dside, pixCoordBase.y); + float v = sourceAOTermAndEdges.Load(int3(dpix, 0)).x; +#if XE_GTAO_FINAL_APPLY + if (DebugViewRT >= 2.5 && DebugViewRT < 3.5) // 3 = AO x8 amplification (spot broad over-occlusion) + v = saturate(1.0 - (1.0 - v * XE_GTAO_OCCLUSION_TERM_SCALE) * 8.0); + outputTexture[dpix] = float2(v, 0.0); +#else + outputTexture[dpix] = float2(v, sourceAOTermAndEdges.Load(int3(dpix, 0)).y); +#endif + } + return; + } +#endif + +#if XE_GTAO_FINAL_APPLY + const float blurAmount = DENOISE_BLUR_BETA; +#else + const float blurAmount = DENOISE_BLUR_BETA / 5.0; +#endif + + const float diagWeight = 0.85 * 0.5; + + float aoTerm[2]; // pixel pixCoordBase and pixel pixCoordBase + int2( 1, 0 ) + float4 edgesC_LRTB[2]; + float weightTL[2]; + float weightTR[2]; + float weightBL[2]; + float weightBR[2]; + + // gather edge and visibility quads, used later + const float2 gatherCenter = float2(pixCoordBase.x, pixCoordBase.y) * VIEWPORT_PIXEL_SIZE; + float4 edgesQ0 = sourceAOTermAndEdges.GatherGreen(texSampler, gatherCenter, int2(0, 0)); + float4 edgesQ1 = sourceAOTermAndEdges.GatherGreen(texSampler, gatherCenter, int2(2, 0)); + float4 edgesQ2 = sourceAOTermAndEdges.GatherGreen(texSampler, gatherCenter, int2(1, 2)); + + float visQ0[4]; + XeGTAO_DecodeGatherPartial(sourceAOTermAndEdges.GatherRed(texSampler, gatherCenter, int2(0, 0)), visQ0); + float visQ1[4]; + XeGTAO_DecodeGatherPartial(sourceAOTermAndEdges.GatherRed(texSampler, gatherCenter, int2(2, 0)), visQ1); + float visQ2[4]; + XeGTAO_DecodeGatherPartial(sourceAOTermAndEdges.GatherRed(texSampler, gatherCenter, int2(0, 2)), visQ2); + float visQ3[4]; + XeGTAO_DecodeGatherPartial(sourceAOTermAndEdges.GatherRed(texSampler, gatherCenter, int2(2, 2)), visQ3); + + for (int side = 0; side < 2; side++) + { + const int2 pixCoord = int2(pixCoordBase.x + side, pixCoordBase.y); + + float4 edgesL_LRTB = XeGTAO_UnpackEdges(side == 0 ? edgesQ0.x : edgesQ0.y); + float4 edgesT_LRTB = XeGTAO_UnpackEdges(side == 0 ? edgesQ0.z : edgesQ1.w); + float4 edgesR_LRTB = XeGTAO_UnpackEdges(side == 0 ? edgesQ1.x : edgesQ1.y); + float4 edgesB_LRTB = XeGTAO_UnpackEdges(side == 0 ? edgesQ2.w : edgesQ2.z); + + edgesC_LRTB[side] = XeGTAO_UnpackEdges(side == 0 ? edgesQ0.y : edgesQ1.x); + + // Edges aren't perfectly symmetrical: edge detection algorithm does not guarantee that a left edge on the right pixel will match the right edge on the left pixel (although + // they will match in majority of cases). This line further enforces the symmetricity, creating a slightly sharper blur. Works real nice with TAA. + edgesC_LRTB[side] *= float4(edgesL_LRTB.y, edgesR_LRTB.x, edgesT_LRTB.w, edgesB_LRTB.z); + +#if 1 // this allows some small amount of AO leaking from neighbours if there are 3 or 4 edges; this reduces both spatial and temporal aliasing + const float leak_threshold = 2.5; + const float leak_strength = 0.5; + float edginess = (saturate(4.0 - leak_threshold - dot(edgesC_LRTB[side], 1.0)) * rcp(4.0 - leak_threshold)) * leak_strength; + edgesC_LRTB[side] = saturate(edgesC_LRTB[side] + edginess); +#endif + + // for diagonals; used by first and second pass + weightTL[side] = diagWeight * (edgesC_LRTB[side].x * edgesL_LRTB.z + edgesC_LRTB[side].z * edgesT_LRTB.x); + weightTR[side] = diagWeight * (edgesC_LRTB[side].z * edgesT_LRTB.y + edgesC_LRTB[side].y * edgesR_LRTB.z); + weightBL[side] = diagWeight * (edgesC_LRTB[side].w * edgesB_LRTB.x + edgesC_LRTB[side].x * edgesL_LRTB.w); + weightBR[side] = diagWeight * (edgesC_LRTB[side].y * edgesR_LRTB.w + edgesC_LRTB[side].w * edgesB_LRTB.y); + + // first pass + float ssaoValue = side == 0 ? visQ0[1] : visQ1[0]; + float ssaoValueL = side == 0 ? visQ0[0] : visQ0[1]; + float ssaoValueT = side == 0 ? visQ0[2] : visQ1[3]; + float ssaoValueR = side == 0 ? visQ1[0] : visQ1[1]; + float ssaoValueB = side == 0 ? visQ2[2] : visQ3[3]; + float ssaoValueTL = side == 0 ? visQ0[3] : visQ0[2]; + float ssaoValueBR = side == 0 ? visQ3[3] : visQ3[2]; + float ssaoValueTR = side == 0 ? visQ1[3] : visQ1[2]; + float ssaoValueBL = side == 0 ? visQ2[3] : visQ2[2]; + + float sumWeight = blurAmount; + float sum = ssaoValue * sumWeight; + + XeGTAO_AddSample(ssaoValueL, edgesC_LRTB[side].x, sum, sumWeight); + XeGTAO_AddSample(ssaoValueR, edgesC_LRTB[side].y, sum, sumWeight); + XeGTAO_AddSample(ssaoValueT, edgesC_LRTB[side].z, sum, sumWeight); + XeGTAO_AddSample(ssaoValueB, edgesC_LRTB[side].w, sum, sumWeight); + + XeGTAO_AddSample(ssaoValueTL, weightTL[side], sum, sumWeight); + XeGTAO_AddSample(ssaoValueTR, weightTR[side], sum, sumWeight); + XeGTAO_AddSample(ssaoValueBL, weightBL[side], sum, sumWeight); + XeGTAO_AddSample(ssaoValueBR, weightBR[side], sum, sumWeight); + + aoTerm[side] = sum / sumWeight; + +#if XE_GTAO_FINAL_APPLY + // The game's final AO buffer is r16g16_float; the apply blit reads .x. Write AO to .x (.y unused). + outputTexture[pixCoord] = float2(saturate(aoTerm[side] * XE_GTAO_OCCLUSION_TERM_SCALE), 0.0); +#else + outputTexture[pixCoord] = float2(aoTerm[side], side == 0 ? edgesQ0.y : edgesQ1.x); +#endif + } +} + +// Implementation +///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +SamplerState smp : register(s0); // point-clamp, bound by Luma (the game's AO CS uses raw Loads, s0 is not reliably set) + +Texture2D tex0 : register(t0); +Texture2D tex1 : register(t1); + +RWTexture2D out_working_depth_mip0 : register(u0); +RWTexture2D out_working_depth_mip1 : register(u1); +RWTexture2D out_working_depth_mip2 : register(u2); +RWTexture2D out_working_depth_mip3 : register(u3); +RWTexture2D out_working_depth_mip4 : register(u4); +RWTexture2D ao_term_and_edges : register(u0); + +#if XE_GTAO_FINAL_APPLY +RWTexture2D final_output : register(u0); // the game's r16g16_float final AO (apply blit reads .x) +#else +RWTexture2D final_output : register(u0); +#endif + +#define XE_GTAO_NUMTHREADS_X 8 +#define XE_GTAO_NUMTHREADS_Y 8 + +// From https://www.shadertoy.com/view/3tB3z3 - except we're using R2 here +#define XE_HILBERT_LEVEL 6U +#define XE_HILBERT_WIDTH (1U << XE_HILBERT_LEVEL) +#define XE_HILBERT_AREA (XE_HILBERT_WIDTH * XE_HILBERT_WIDTH) +uint HilbertIndex(uint posX, uint posY) +{ + uint index = 0U; + [unroll] for (uint curLevel = XE_HILBERT_WIDTH / 2U; curLevel > 0U; curLevel /= 2U) + { + uint regionX = (posX & curLevel) > 0U; + uint regionY = (posY & curLevel) > 0U; + index += curLevel * curLevel * ((3U * regionX) ^ regionY); + if (regionY == 0U) + { + if (regionX == 1U) + { + posX = XE_HILBERT_WIDTH - 1U - posX; + posY = XE_HILBERT_WIDTH - 1U - posY; + } + uint temp = posX; + posX = posY; + posY = temp; + } + } + return index; +} + +// BL GOTY has no TAA: temporalIndex is ALWAYS 0 (frozen pattern — static noise instead of boiling). +float2 SpatioTemporalNoise(uint2 pixCoord, uint temporalIndex) +{ + float2 noise; + uint index = HilbertIndex(pixCoord.x, pixCoord.y); + index += 288 * (temporalIndex % 64); // why 288? tried out a few and that's the best so far (with XE_HILBERT_LEVEL 6U) - but there's probably better :) + // R2 sequence - see http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences/ + return float2(frac(0.5 + index * float2(0.75487766624669276005, 0.5698402909980532659114))); +} + +[numthreads(8, 8, 1)] // <- hard coded to 8x8; each thread computes 2x2 blocks so processing 16x16 block: Dispatch needs to be called with (width + 16-1) / 16, (height + 16-1) / 16 + void prefilter_depths16x16_cs(uint2 dtid : SV_DispatchThreadID, uint2 gtid : SV_GroupThreadID) { + // tex0 = the game's full-res scene depth (r24_g8, viewed r24_unorm_x8), captured at the deinterleave dispatch + XeGTAO_PrefilterDepths16x16(dtid, gtid, tex0, out_working_depth_mip0, out_working_depth_mip1, out_working_depth_mip2, out_working_depth_mip3, out_working_depth_mip4); + } + + [numthreads(XE_GTAO_NUMTHREADS_X, XE_GTAO_NUMTHREADS_Y, 1)] void main_pass_cs(uint2 dtid : SV_DispatchThreadID) +{ + // tex0 = prefiltered viewspace depth MIP pyramid (our R32F) + // tex1 = the game's ViewNormalTex (r11g11b10_float, captured at the coarse-AO dispatch) + // smp = point-clamp + + // Decode the game's packed view-space normals: r11g11b10_float, xyz in [0,1] -> [-1,1]. Full 3-channel, + // so no z reconstruction; NORMAL_Z_SIGN flips z only if the handedness needs + // it (verify via DebugViewRT=2; flip to -1.0 if shading looks inverted). + float3 n = tex1.Load(int3(dtid, 0)).xyz * 2.0 - 1.0; + n.z *= NORMAL_Z_SIGN; + float3 viewspaceNormal = normalize(n); + + XeGTAO_MainPass(dtid, SpatioTemporalNoise(dtid, 0), viewspaceNormal, tex0, smp, ao_term_and_edges); +} + +[numthreads(XE_GTAO_NUMTHREADS_X, XE_GTAO_NUMTHREADS_Y, 1)] void denoise_pass_cs(uint2 dtid : SV_DispatchThreadID) { + // tex0 = g_srcWorkingAOTerm and g_srcWorkingEdges, packed + // smp = point-clamp + const uint2 pix_coord_base = dtid * uint2(2, 1); // we're computing 2 horizontal pixels at a time (performance optimization) + XeGTAO_Denoise(pix_coord_base, tex0, smp, final_output); +} diff --git a/Source/Games/Borderlands GOTY Enhanced/main.cpp b/Source/Games/Borderlands GOTY Enhanced/main.cpp index aaa73bd3..3905b006 100644 --- a/Source/Games/Borderlands GOTY Enhanced/main.cpp +++ b/Source/Games/Borderlands GOTY Enhanced/main.cpp @@ -1,7 +1,7 @@ // Borderlands GOTY Enhanced — Luma HDR + SMAA mod (Unreal Engine 3.5, D3D11). // - HDR: swapchain -> scRGB fp16; replaced UE3 final-color PS (0xB030BAA6 / 0xFE88487E) recovers the clipped // highlights (UpgradeToneMap) + DICE display map. Core Display Composition does the paper-white scale + encode. -// One HDR mod owns the swapchain -> RenoDX must be removed from the game folder. +// One HDR mod owns the swapchain -> any other HDR mod must be removed from the game folder. // - AA: compute FXAA (3.11 work-queue) -> SMAA (ULTRA + color edge + depth predication) + optional RCAS. Runs on // the HDR-linear scene: sRGB-encoded copy for edge detect, linear copy for blend, CopyResource into swapchain. @@ -14,87 +14,125 @@ #define ENABLE_NGX 0 #define ENABLE_FIDELITY_SK 0 #define GEOMETRY_SHADER_SUPPORT 0 -#define ENABLE_SMAA 1 // auto-registers the 6 "SMAA ..." shaders from Luma_SMAA_impl (see core.hpp) +#define ENABLE_SMAA 1 #include "..\..\Core\core.hpp" #include // ShellExecuteA for About links (system() hangs the render thread in exclusive fullscreen) -// FXAA is a compute work-queue implementation (FXAA 3.11 CS), verified via devkit disassembly: +// FXAA is a compute work-queue implementation (FXAA 3.11 CS): // 0x81CDE53D = pass 1 edge-detect (builds WorkQueue into scratch buffers; leaves color untouched — left running). // 0x08891303 = pass 2 resolve: WorkQueue + Luma + InColor(t2) -> Color(u0, swapchain in-place). Replaced with SMAA. static constexpr uint32_t kFXAAResolveHash = 0x08891303; // FXAA resolve CS — replaced with SMAA static constexpr uint32_t kCelShadingHash = 0x08DC66D1; // cel-shading edge PS — binds scene depth at t0 (predication source) +// AO: XeGTAO replaces the game's native NVIDIA HBAO+ (GFSDK_SSAO). Full-res 4K chain: +// deinterleave 0xFFE232A6 -> normals 0xB2B47225 (left running) -> coarse horizon 0xF534EB09 -> bilateral +// blur 0x4E1BEE34 -> apply-multiply PS 0x44764BF6. We capture scene depth at the deinterleave and the +// packed view normals at the coarse pass (skipping both), then at the blur dispatch run the 4 XeGTAO passes +// into ITS u0 (the game's FINAL r16g16_float AO; apply reads .x) so the apply blit composites our AO +// unchanged. XeGTAO binds the game's own cb0 ($Globals: ProjInfo) + cb2 (MinZ_MaxZRatioCS), still bound at +// the injection point. No TAA -> NoiseIndex frozen 0, spatial denoise x2. +static constexpr uint32_t kAODeinterleaveHash = 0xFFE232A6; // scene depth -> quarter-res array — skipped (we build our own mip pyramid) +static constexpr uint32_t kAOCoarseHash = 0xF534EB09; // HBAO+ horizon march (x2), binds view normals at t0 — skipped (capture normals) +static constexpr uint32_t kAOBlurHash = 0x4E1BEE34; // bilateral blur -> FINAL r16g16_float u0 — replaced with XeGTAO + // User-facing settings (persisted via ReShade config; loaded in LoadConfigs, saved on UI change). static bool g_smaa_enable = true; static float g_rcas_sharpness = 0.f; // RCAS sharpen on SMAA output (0 = off). Conservative — ink outlines already AA'd; higher haloes. -static bool g_hide_ui = false; // hide the game's HUD (skips swapchain-targeting UI draws) — for clean screenshots +static bool g_hide_ui = false; // hide the game's HUD (skips swapchain-targeting UI draws) — for clean screenshots + +// Ambient Occlusion: XeGTAO replaces the native HBAO+ (default ON = supersede it). Persisted as "XeGTAOEnable". +static bool g_gtao_enable = true; +// Runtime XeGTAO knobs (LumaGTAO cb b11), DEV calibration sliders. FinalValuePower = primary darkness dial +// (calibrate to the vanilla HBAO+ histogram — its PowExponent does not transfer numerically). DepthScale = +// viewZ divisor (UE3 units, near plane ~10 -> ~meters) so Intel's tuned radius/falloff apply; the dial +// against broad over-occlusion. RadiusOverride > 0 overrides EFFECT_RADIUS (in scaled units). +static float g_gtao_final_value_power = 1.0f; +static float g_gtao_depth_scale = 50.f; +static float g_gtao_radius_override = 0.f; +#if DEVELOPMENT +static int g_gtao_debug_view = 0; // 0=off 1=depth gradient 2=normals 3=AO x8 4=edges (drawn via the game's apply blit). DEV only: the shader's DebugViewRT blocks are #if DEVELOPMENT. +#endif // Loading-movie memory-leak fix (toggle "Fix Movie Memory Leak" under Fixes; default ON). // The game's Bink movies create D3D11 YUV decode buffers and never release them -> linear RAM // growth -> OOM. The leak is the GAME, not Luma. We drop the game's leaked COM refs on OLD movie // generations (orphaned: a movie's buffers are sampled only during its own playback). Tagged by // creation call-stack RVAs in BorderlandsGOTY.exe (frozen remaster; non-matching build tags nothing -// = safe no-op). Movies keep playing. Diagnosis/validation: Source/Tools/BL Leak Tracker. +// = safe no-op). Movies keep playing. Diagnosis/validation: _tools/BL Leak Tracker. static bool g_fix_movie_leak = true; // default ON; persisted as "FixMovieLeak" namespace BLMovieLeakFix { // Build-specific RVAs (frozen remaster). A non-matching build shifts these -> nothing tags -> // silent no-op; BUILD_CHECK_FRAME drives a one-shot telemetry warning for that case. - constexpr uintptr_t RVA_CREATE_WRAPPER = 0xBFF27; // ret addr after the RHI CreateTexture call - constexpr uintptr_t RVA_STREAM_LO = 0x58A000; // streaming/movie fn span (create call sites) + constexpr uintptr_t RVA_CREATE_WRAPPER = 0xBFF27; // ret addr after the RHI CreateTexture call + constexpr uintptr_t RVA_STREAM_LO = 0x58A000; // streaming/movie fn span (create call sites) constexpr uintptr_t RVA_STREAM_HI = 0x58C000; - constexpr uint32_t BUILD_CHECK_FRAME = 18000; // ~5 min; movies tag well before this if build matches - constexpr uint32_t NEW_GEN_GAP_FRAMES = 90; // frame gap that separates two movies into "generations" - constexpr int MAX_FRAMES = 32, SKIP_FRAMES = 1; - constexpr uint64_t STACKWALK_MIN_BYTES = 2ull * 1024 * 1024; // only walk the stack for big resources (cheap) - constexpr int RELEASE_GEN_LAG = 2; // release only gen <= cur_gen-2 (keep current + previous) - constexpr uint32_t RELEASE_IDLE_FRAMES = 600; // and only after this many frames since creation (~10s) - constexpr uint32_t RELEASE_FLUSH_PERIOD = 120; // flush at most every N present frames - - struct MovieTex { uint64_t bytes; int gen; uint32_t created_frame; }; + constexpr uint32_t BUILD_CHECK_FRAME = 18000; // ~5 min; movies tag well before this if build matches + constexpr uint32_t NEW_GEN_GAP_FRAMES = 90; // frame gap that separates two movies into "generations" + constexpr int MAX_FRAMES = 32, SKIP_FRAMES = 1; + constexpr uint64_t STACKWALK_MIN_BYTES = 2ull * 1024 * 1024; // only walk the stack for big resources (cheap) + constexpr int RELEASE_GEN_LAG = 2; // release only gen <= cur_gen-2 (keep current + previous) + constexpr uint32_t RELEASE_IDLE_FRAMES = 600; // and only after this many frames since creation (~10s) + constexpr uint32_t RELEASE_FLUSH_PERIOD = 120; // flush at most every N present frames + + struct MovieTex + { + uint64_t bytes; + int gen; + uint32_t created_frame; + }; static std::mutex g_mtx; static std::unordered_map g_movie; // resource handle -> info static std::unordered_map g_view2res; // view handle -> resource handle (tagged textures only) static uintptr_t g_exe_base = 0; - static int g_cur_gen = 0; - static uint32_t g_last_movie_frame = 0; - static bool g_first_movie = true; + static int g_cur_gen = 0; + static uint32_t g_last_movie_frame = 0; + static bool g_first_movie = true; // Coarse cross-thread gates (present thread writes g_frame; workers read). atomic = no data race; // real map sync is g_mtx. - static std::atomic g_have_movie{ false }; - static std::atomic g_frame{ 0 }; - static uint64_t g_freed_bytes = 0; - static uint32_t g_freed_tex = 0; - static bool g_build_checked = false; // one-shot build-mismatch telemetry guard (present thread only) + static std::atomic g_have_movie{false}; + static std::atomic g_frame{0}; + static uint64_t g_freed_bytes = 0; + static uint32_t g_freed_tex = 0; + static bool g_build_checked = false; // one-shot build-mismatch telemetry guard (present thread only) inline uint64_t EstimateBytes(const reshade::api::resource_desc& d) { using namespace reshade::api; - if (d.type == resource_type::buffer) return d.buffer.size; + if (d.type == resource_type::buffer) + return d.buffer.size; const uint32_t w = d.texture.width ? d.texture.width : 1; const uint32_t h = d.texture.height ? d.texture.height : 1; const uint32_t layers = d.texture.depth_or_layers ? d.texture.depth_or_layers : 1; const uint32_t levels = d.texture.levels ? d.texture.levels : 1; const uint32_t slice = format_slice_pitch(d.texture.format, format_row_pitch(d.texture.format, w), h); uint64_t s = (uint64_t)slice * layers, total = 0; - for (uint32_t l = 0; l < levels; ++l) { total += s; s = s > 4 ? s / 4 : 1; } + for (uint32_t l = 0; l < levels; ++l) + { + total += s; + s = s > 4 ? s / 4 : 1; + } return total; } inline bool StackIsMovie(void* const* frames, int n) { - if (!g_exe_base) return false; + if (!g_exe_base) + return false; bool has_create = false, has_stream = false; for (int i = 0; i < n; ++i) { const uintptr_t a = reinterpret_cast(frames[i]); - if (a < g_exe_base) continue; + if (a < g_exe_base) + continue; const uintptr_t rva = a - g_exe_base; - if (rva == RVA_CREATE_WRAPPER) has_create = true; - else if (rva >= RVA_STREAM_LO && rva < RVA_STREAM_HI) has_stream = true; + if (rva == RVA_CREATE_WRAPPER) + has_create = true; + else if (rva >= RVA_STREAM_LO && rva < RVA_STREAM_HI) + has_stream = true; } return has_create && has_stream; } @@ -104,37 +142,48 @@ namespace BLMovieLeakFix { // Decode targets are BUFFERS (measured); gating on type excludes the textures that share the // streaming-fn span -> no mistag/UAF, and skips the stack walk for every texture. - if (desc.type != reshade::api::resource_type::buffer) return; + if (desc.type != reshade::api::resource_type::buffer) + return; const uint64_t bytes = EstimateBytes(desc); - if (bytes < STACKWALK_MIN_BYTES) return; + if (bytes < STACKWALK_MIN_BYTES) + return; void* frames[MAX_FRAMES]; const int n = RtlCaptureStackBackTrace(SKIP_FRAMES, MAX_FRAMES, frames, nullptr); - if (n <= 0 || !StackIsMovie(frames, n)) return; + if (n <= 0 || !StackIsMovie(frames, n)) + return; const std::lock_guard lk(g_mtx); const uint32_t f = g_frame; - if (g_first_movie || (f - g_last_movie_frame) > NEW_GEN_GAP_FRAMES) { g_cur_gen += 1; g_first_movie = false; } + if (g_first_movie || (f - g_last_movie_frame) > NEW_GEN_GAP_FRAMES) + { + g_cur_gen += 1; + g_first_movie = false; + } g_last_movie_frame = f; - g_movie[handle.handle] = MovieTex{ bytes, g_cur_gen, f }; + g_movie[handle.handle] = MovieTex{bytes, g_cur_gen, f}; g_have_movie = true; } void OnDestroyResource(reshade::api::device*, reshade::api::resource handle) { - if (!g_have_movie) return; + if (!g_have_movie) + return; const std::lock_guard lk(g_mtx); g_movie.erase(handle.handle); } void OnInitResourceView(reshade::api::device*, reshade::api::resource res, reshade::api::resource_usage, const reshade::api::resource_view_desc&, reshade::api::resource_view view) { - if (!g_have_movie || !view.handle) return; + if (!g_have_movie || !view.handle) + return; const std::lock_guard lk(g_mtx); - if (g_movie.find(res.handle) != g_movie.end()) g_view2res[view.handle] = res.handle; + if (g_movie.find(res.handle) != g_movie.end()) + g_view2res[view.handle] = res.handle; } void OnDestroyResourceView(reshade::api::device*, reshade::api::resource_view view) { - if (!g_have_movie) return; + if (!g_have_movie) + return; const std::lock_guard lk(g_mtx); g_view2res.erase(view.handle); } @@ -143,39 +192,62 @@ namespace BLMovieLeakFix // re-enters OnDestroyResource[View] on this thread -> COLLECT+UNLINK under lock, then RELEASE unlocked. void Flush() { - struct Plan { uintptr_t res; std::vector views; uint64_t bytes; }; + struct Plan + { + uintptr_t res; + std::vector views; + uint64_t bytes; + }; std::vector plans; { const std::lock_guard lk(g_mtx); for (const auto& kv : g_movie) // Phase A: pick victims (keep current + previous gen, must be idle) { const MovieTex& m = kv.second; - if (m.gen > g_cur_gen - RELEASE_GEN_LAG) continue; - if ((g_frame - m.created_frame) < RELEASE_IDLE_FRAMES) continue; - plans.push_back({ kv.first, {}, m.bytes }); + if (m.gen > g_cur_gen - RELEASE_GEN_LAG) + continue; + if ((g_frame - m.created_frame) < RELEASE_IDLE_FRAMES) + continue; + plans.push_back({kv.first, {}, m.bytes}); } - if (plans.empty()) return; + if (plans.empty()) + return; std::unordered_map idx; - for (size_t i = 0; i < plans.size(); ++i) idx[plans[i].res] = i; - for (const auto& vk : g_view2res) { auto it = idx.find(vk.second); if (it != idx.end()) plans[it->second].views.push_back(vk.first); } + for (size_t i = 0; i < plans.size(); ++i) + idx[plans[i].res] = i; + for (const auto& vk : g_view2res) + { + auto it = idx.find(vk.second); + if (it != idx.end()) + plans[it->second].views.push_back(vk.first); + } for (const auto& p : plans) // Phase B: UNLINK before any Release (re-entrant callbacks then find nothing) { - for (uintptr_t v : p.views) g_view2res.erase(v); + for (uintptr_t v : p.views) + g_view2res.erase(v); g_movie.erase(p.res); } } - uint32_t ft = 0; uint64_t fb = 0; // Phase C: RELEASE with the lock released + uint32_t ft = 0; + uint64_t fb = 0; // Phase C: RELEASE with the lock released uint32_t partial = 0; for (const Plan& p : plans) { - for (uintptr_t v : p.views) reinterpret_cast(v)->Release(); + for (uintptr_t v : p.views) + reinterpret_cast(v)->Release(); // rc==0 => actually freed (count it); rc>0 => game holds extra refs, not reclaimed. (rc is a // by-value ULONG, never a deref of a freed object.) const ULONG rc = reinterpret_cast(p.res)->Release(); - if (rc == 0) { ft++; fb += p.bytes; } - else partial++; + if (rc == 0) + { + ft++; + fb += p.bytes; + } + else + partial++; } - g_freed_tex += ft; g_freed_bytes += fb; + g_freed_tex += ft; + g_freed_bytes += fb; #if DEVELOPMENT || TEST if (ft || partial) { @@ -189,7 +261,7 @@ namespace BLMovieLeakFix (void)partial; #endif } -} +} // namespace BLMovieLeakFix struct BorderlandsGotyGameDeviceData final : public GameDeviceData { @@ -229,6 +301,25 @@ struct BorderlandsGotyGameDeviceData final : public GameDeviceData ComPtr tex_rcas_out; ComPtr tex_rcas_out_rtv; uint32_t rcas_out_w = 0, rcas_out_h = 0; + + // --- XeGTAO scratch (all at the game's AO full-res; cached, rebuilt on size change). --- + // Game inputs captured per frame (reset in OnPresent): full-res r24 scene depth (deinterleave t0) and + // the packed view normals (coarse-AO t0). gtao_active_this_frame arms the blur-dispatch takeover. + ComPtr srv_gtao_depth; + ComPtr srv_gtao_normals; + bool gtao_active_this_frame = false; + // Prefiltered viewspace-depth MIP pyramid (R32F, 5 mips) — 5 per-mip UAVs + one full SRV. + ComPtr tex_gtao_depth_mips; + ComPtr gtao_depth_mip_uavs[5]; + ComPtr srv_gtao_depth_mips; + // Two working AO+edges buffers (R8G8_UNORM) ping-ponged by main pass -> denoise 1. + ComPtr tex_gtao_working[2]; + ComPtr uav_gtao_working[2]; + ComPtr srv_gtao_working[2]; + uint32_t gtao_w = 0, gtao_h = 0; + // LumaGTAO knob CB (b11) = (FinalValuePower, DepthScale, RadiusOverride, DebugView); recreated on change. + ComPtr cb_gtao; + float gtao_cb_fvp = -1.f, gtao_cb_depth_scale = -1.f, gtao_cb_radius = -1.f, gtao_cb_debug = -1.f; }; class BorderlandsGoty final : public Game @@ -273,8 +364,7 @@ class BorderlandsGoty final : public Game // Game-specific HDR toggles consumed by the replaced tonemap shaders (Luma_BL_Tonemap.hlsl). std::vector game_shader_defines_data = { {"TONEMAP_TYPE", '1', true, false, "0 - SDR: Vanilla (clamped reference)\n1 - HDR: recover highlights + DICE display map"}, - {"TONEMAP_IN_WIDER_GAMUT", '1', true, false, "Run the display map in a BT.2020 working space (gamut-correct saturated highlights). Not a display-gamut expansion.", 1}, - {"ENABLE_HUE_RESTORATION", '1', true, false, "Lock the HDR result's hue/chroma to the game's SDR grade (preserve artistic intent).", 1}, + {"XE_GTAO_QUALITY", '3', true, false, "Ambient Occlusion (XeGTAO) quality (slice count)\n0 - Low\n1 - Medium\n2 - High\n3 - Very High\n4 - Ultra", 4}, }; shader_defines_data.append_range(game_shader_defines_data); @@ -283,12 +373,12 @@ class BorderlandsGoty final : public Game // (UI_DRAW_TYPE 2); the core composition decodes gamma + applies paper white + scRGB encode. GetShaderDefineData(POST_PROCESS_SPACE_TYPE_HASH).SetDefaultValue('0'); GetShaderDefineData(EARLY_DISPLAY_ENCODING_HASH).SetDefaultValue('0'); - GetShaderDefineData(VANILLA_ENCODING_TYPE_HASH).SetDefaultValue('1'); // game shipped gamma-2.2 SDR + GetShaderDefineData(VANILLA_ENCODING_TYPE_HASH).SetDefaultValue('1'); // game shipped gamma-2.2 SDR GetShaderDefineData(GAMMA_CORRECTION_TYPE_HASH).SetDefaultValue('1'); - GetShaderDefineData(GAMUT_MAPPING_TYPE_HASH).SetDefaultValue('1'); // gamut-map wild colors in composition - GetShaderDefineData(UI_DRAW_TYPE_HASH).SetDefaultValue('2'); // HUD gets its own UIPaperWhite + gamma blend + GetShaderDefineData(GAMUT_MAPPING_TYPE_HASH).SetDefaultValue('1'); // gamut-map wild colors in composition + GetShaderDefineData(UI_DRAW_TYPE_HASH).SetDefaultValue('2'); // HUD gets its own UIPaperWhite + gamma blend - // SMAA linearize helper (the 6 SMAA passes are auto-registered by core when ENABLE_SMAA). + // SMAA linearize helper (the 6 SMAA passes register automatically under ENABLE_SMAA). native_shaders_definitions.emplace(CompileTimeStringHash("SMAA Linear To sRGB CS"), ShaderDefinition("Luma_SMAA_LinearTosRGB_CS", reshade::api::pipeline_subobject_type::compute_shader)); @@ -296,6 +386,18 @@ class BorderlandsGoty final : public Game native_shaders_definitions.emplace(CompileTimeStringHash("BL Sharpen PS"), ShaderDefinition{"Luma_BL_Sharpen", reshade::api::pipeline_subobject_type::pixel_shader, nullptr, "sharpen_ps"}); + // XeGTAO (replaces the game's native HBAO+; see the AO hash block above). 4 compute passes out of one + // file; the two denoise variants differ only by XE_GTAO_FINAL_APPLY (the final one writes the game's + // r16g16_float AO target). + native_shaders_definitions.emplace(CompileTimeStringHash("BL XeGTAO Prefilter Depths CS"), + ShaderDefinition{"Luma_BL_XeGTAO", reshade::api::pipeline_subobject_type::compute_shader, nullptr, "prefilter_depths16x16_cs"}); + native_shaders_definitions.emplace(CompileTimeStringHash("BL XeGTAO Main Pass CS"), + ShaderDefinition{"Luma_BL_XeGTAO", reshade::api::pipeline_subobject_type::compute_shader, nullptr, "main_pass_cs"}); + native_shaders_definitions.emplace(CompileTimeStringHash("BL XeGTAO Denoise Pass 1 CS"), + ShaderDefinition{"Luma_BL_XeGTAO", reshade::api::pipeline_subobject_type::compute_shader, nullptr, "denoise_pass_cs", {{"XE_GTAO_FINAL_APPLY", "0"}}}); + native_shaders_definitions.emplace(CompileTimeStringHash("BL XeGTAO Denoise Pass 2 CS"), + ShaderDefinition{"Luma_BL_XeGTAO", reshade::api::pipeline_subobject_type::compute_shader, nullptr, "denoise_pass_cs", {{"XE_GTAO_FINAL_APPLY", "1"}}}); + // Game uses CB slots b0-b3, so b12/b13 are free for Luma. // luma_data is used by the Display Composition; luma_ui stays off (UI drawn by the game). luma_settings_cbuffer_index = 13; @@ -307,13 +409,15 @@ class BorderlandsGoty final : public Game use_os_reference_white_level = false; // User grade controls (read in Luma_BL_Tonemap.hlsl via LumaSettings.GameSettings). All vanilla by default. - default_luma_global_game_settings.Exposure = 1.f; // multiplier (1x) + default_luma_global_game_settings.Exposure = 1.f; // multiplier (1x) default_luma_global_game_settings.Saturation = 1.f; default_luma_global_game_settings.HighlightDechroma = 0.f; // off by default; only the mandatory DICE/gamut desaturation applies. Slider = optional taste. default_luma_global_game_settings.BloomIntensity = 1.f; default_luma_global_game_settings.Contrast = 1.f; - default_luma_global_game_settings.Dithering = 1.f; // subtle anti-banding on by default - default_luma_global_game_settings.FlareOut = 1.f; // additive lens-flare/glare scale (1 = vanilla) + default_luma_global_game_settings.Dithering = 1.f; // subtle anti-banding on by default + default_luma_global_game_settings.FlareOut = 1.f; // additive lens-flare/glare scale (1 = vanilla) + default_luma_global_game_settings.VideoAutoHDREnable = 1.f; // light AutoHDR on Bink movies, HDR only (on by default) + default_luma_global_game_settings.VideoAutoHDRBoost = 0.5f; // highlight-expansion strength (peak ~165 nits at 0.5) cb_luma_global_settings.GameSettings = default_luma_global_game_settings; } @@ -329,15 +433,33 @@ class BorderlandsGoty final : public Game auto& gd = GetGameDeviceData(device_data); gd.srv_depth.reset(); gd.cb_smaa_metrics.reset(); - gd.srv_input.reset(); gd.tex_input.reset(); - gd.uav_lin.reset(); gd.srv_lin.reset(); gd.tex_lin.reset(); - gd.uav_gam.reset(); gd.srv_gam.reset(); gd.tex_gam.reset(); + gd.srv_input.reset(); + gd.tex_input.reset(); + gd.uav_lin.reset(); + gd.srv_lin.reset(); + gd.tex_lin.reset(); + gd.uav_gam.reset(); + gd.srv_gam.reset(); + gd.tex_gam.reset(); gd.tex_smaa_out.reset(); gd.tex_smaa_out_rtv.reset(); gd.tex_smaa_out_srv.reset(); gd.cb_sharpen.reset(); gd.tex_rcas_out.reset(); gd.tex_rcas_out_rtv.reset(); + gd.srv_gtao_depth.reset(); + gd.srv_gtao_normals.reset(); + gd.tex_gtao_depth_mips.reset(); + for (auto& uav : gd.gtao_depth_mip_uavs) + uav.reset(); + gd.srv_gtao_depth_mips.reset(); + for (int i = 0; i < 2; i++) + { + gd.tex_gtao_working[i].reset(); + gd.uav_gtao_working[i].reset(); + gd.srv_gtao_working[i].reset(); + } + gd.cb_gtao.reset(); } delete device_data.game; device_data.game = nullptr; @@ -364,7 +486,10 @@ class BorderlandsGoty final : public Game if (rtv_res) { bool targeting_swapchain; - { const std::shared_lock lock(device_data.mutex); targeting_swapchain = device_data.back_buffers.contains((uint64_t)rtv_res.get()); } + { + const std::shared_lock lock(device_data.mutex); + targeting_swapchain = device_data.back_buffers.contains((uint64_t)rtv_res.get()); + } if (targeting_swapchain) return DrawOrDispatchOverrideType::Replaced; // drop the UI draw } @@ -382,6 +507,227 @@ class BorderlandsGoty final : public Game } } + // XeGTAO replaces the game's native HBAO+ (chain order: deinterleave x2 -> normals 0xB2B47225 -> + // coarse x2 -> blur -> apply blit). We take the chain over ONLY when everything is ready at the first + // dispatch — a failure there leaves the whole native chain untouched (graceful fallback, like the SMAA + // fp16 guard). The separate normals pass 0xB2B47225 is left running (not hooked) so its ViewNormalTex + // output is valid for our main pass. + if (g_gtao_enable && is_immediate) + { + // (a) Deinterleave: capture the full-res r24 scene depth (t0) + build/validate ALL scratch, then skip + // the native dispatch. Both dispatches of the pair hit this branch (second is a cheap re-capture). + if (original_shader_hashes.Contains(kAODeinterleaveHash, reshade::api::shader_stage::compute)) + { + const bool gtao_shaders_ready = + device_data.native_compute_shaders[CompileTimeStringHash("BL XeGTAO Prefilter Depths CS")].get() != nullptr && + device_data.native_compute_shaders[CompileTimeStringHash("BL XeGTAO Main Pass CS")].get() != nullptr && + device_data.native_compute_shaders[CompileTimeStringHash("BL XeGTAO Denoise Pass 1 CS")].get() != nullptr && + device_data.native_compute_shaders[CompileTimeStringHash("BL XeGTAO Denoise Pass 2 CS")].get() != nullptr; + if (!gtao_shaders_ready) + return DrawOrDispatchOverrideType::None; + + ComPtr srv_d; + native_device_context->CSGetShaderResources(0, 1, srv_d.put()); + if (!srv_d) + return DrawOrDispatchOverrideType::None; + uint4 dinfo{}; + DXGI_FORMAT dfmt = DXGI_FORMAT_UNKNOWN; + GetResourceInfo(srv_d.get(), dinfo, dfmt); + if (dinfo.x == 0 || dinfo.y == 0) + return DrawOrDispatchOverrideType::None; + // Size the scratch (and dispatches) from the input depth desc (the game's HBAO+ full-res). The + // game's own cb0 (InvFullResolution etc.) is content-dimensioned, so the shader's pixel<->UV math + // is correct; the final pass writes the game's AO buffer at identical pixel coords. + const uint32_t w = dinfo.x, h = dinfo.y; + + // (Re)create the scratch at the game's AO full-res (cached; NOT per-frame). + if (gd.gtao_w != w || gd.gtao_h != h || !gd.tex_gtao_depth_mips || !gd.tex_gtao_working[1]) + { + gd.tex_gtao_depth_mips.reset(); + for (auto& uav : gd.gtao_depth_mip_uavs) + uav.reset(); + gd.srv_gtao_depth_mips.reset(); + for (int i = 0; i < 2; i++) + { + gd.tex_gtao_working[i].reset(); + gd.uav_gtao_working[i].reset(); + gd.srv_gtao_working[i].reset(); + } + gd.gtao_w = gd.gtao_h = 0; + + D3D11_TEXTURE2D_DESC td = {}; + td.Width = w; + td.Height = h; + td.MipLevels = 5; // XE_GTAO_DEPTH_MIP_LEVELS + td.ArraySize = 1; + td.Format = DXGI_FORMAT_R32_FLOAT; + td.SampleDesc.Count = 1; + td.Usage = D3D11_USAGE_DEFAULT; + td.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_UNORDERED_ACCESS; + bool ok = SUCCEEDED(native_device->CreateTexture2D(&td, nullptr, gd.tex_gtao_depth_mips.put())); + D3D11_UNORDERED_ACCESS_VIEW_DESC ud = {}; + ud.Format = td.Format; + ud.ViewDimension = D3D11_UAV_DIMENSION_TEXTURE2D; + for (int i = 0; ok && i < 5; i++) + { + ud.Texture2D.MipSlice = i; + ok = SUCCEEDED(native_device->CreateUnorderedAccessView(gd.tex_gtao_depth_mips.get(), &ud, gd.gtao_depth_mip_uavs[i].put())); + } + ok = ok && SUCCEEDED(native_device->CreateShaderResourceView(gd.tex_gtao_depth_mips.get(), nullptr, gd.srv_gtao_depth_mips.put())); + + td.MipLevels = 1; + td.Format = DXGI_FORMAT_R8G8_UNORM; + for (int i = 0; ok && i < 2; i++) + { + ok = ok && SUCCEEDED(native_device->CreateTexture2D(&td, nullptr, gd.tex_gtao_working[i].put())); + ok = ok && SUCCEEDED(native_device->CreateUnorderedAccessView(gd.tex_gtao_working[i].get(), nullptr, gd.uav_gtao_working[i].put())); + ok = ok && SUCCEEDED(native_device->CreateShaderResourceView(gd.tex_gtao_working[i].get(), nullptr, gd.srv_gtao_working[i].put())); + } + if (!ok) + { + gd.tex_gtao_depth_mips.reset(); + for (auto& uav : gd.gtao_depth_mip_uavs) + uav.reset(); + gd.srv_gtao_depth_mips.reset(); + for (int i = 0; i < 2; i++) + { + gd.tex_gtao_working[i].reset(); + gd.uav_gtao_working[i].reset(); + gd.srv_gtao_working[i].reset(); + } + return DrawOrDispatchOverrideType::None; // native chain runs whole + } + gd.gtao_w = w; + gd.gtao_h = h; + } + + // Knob CB (b11): immutable, recreated when a slider moves. +#if DEVELOPMENT + const float dbg = (float)g_gtao_debug_view; +#else + const float dbg = 0.f; // shader DebugViewRT is DEV-only; keep 0 elsewhere so the knob CB never churns +#endif + if (!gd.cb_gtao || gd.gtao_cb_fvp != g_gtao_final_value_power || gd.gtao_cb_depth_scale != g_gtao_depth_scale || + gd.gtao_cb_radius != g_gtao_radius_override || gd.gtao_cb_debug != dbg) + { + const float knobs[4] = {g_gtao_final_value_power, g_gtao_depth_scale, g_gtao_radius_override, dbg}; + if (CreateImmutableCB(native_device, knobs, sizeof(knobs), gd.cb_gtao)) + { + gd.gtao_cb_fvp = g_gtao_final_value_power; + gd.gtao_cb_depth_scale = g_gtao_depth_scale; + gd.gtao_cb_radius = g_gtao_radius_override; + gd.gtao_cb_debug = dbg; + } + } + if (!gd.cb_gtao) + return DrawOrDispatchOverrideType::None; + + gd.srv_gtao_depth = srv_d; + gd.gtao_active_this_frame = true; + + return DrawOrDispatchOverrideType::Replaced; // skip the native deinterleave + } + + // (b) Coarse horizon march: capture the packed view normals (t0), skip the native dispatch. Only when + // we own the chain this frame — otherwise the native pipeline is left fully intact. + if (original_shader_hashes.Contains(kAOCoarseHash, reshade::api::shader_stage::compute)) + { + if (!gd.gtao_active_this_frame) + return DrawOrDispatchOverrideType::None; + ComPtr srv_n; + native_device_context->CSGetShaderResources(0, 1, srv_n.put()); + if (srv_n) + gd.srv_gtao_normals = srv_n; + return DrawOrDispatchOverrideType::Replaced; // skip the native coarse march + } + + // (c) Bilateral blur: ITS u0 is the game's FINAL r16g16_float AO — run the 4 XeGTAO passes into it and + // cancel the native dispatch. The game's apply blit (untouched) then multiplies it into the scene. + if (original_shader_hashes.Contains(kAOBlurHash, reshade::api::shader_stage::compute)) + { + if (!gd.gtao_active_this_frame) + return DrawOrDispatchOverrideType::None; + + ComPtr uav_final; + native_device_context->CSGetUnorderedAccessViews(0, 1, uav_final.put()); + if (!uav_final) + return DrawOrDispatchOverrideType::None; // no output bound (unreachable in practice — the bilateral blur always binds its u0). With no target of our own there's nothing to write either way, so None and Replaced are equivalent here; return None to not cancel the native blur on a state we can't handle. + if (!gd.srv_gtao_depth || !gd.srv_gtao_normals) + { + // Shouldn't happen (chain order is fixed); write "no AO" so a stale buffer can't apply. + const FLOAT ones[4] = {1.f, 1.f, 1.f, 1.f}; + native_device_context->ClearUnorderedAccessViewFloat(uav_final.get(), ones); + return DrawOrDispatchOverrideType::Replaced; + } + + const uint32_t w = gd.gtao_w, h = gd.gtao_h; + DrawStateStack st; + st.Cache(native_device_context, device_data.uav_max_count); + + ID3D11Buffer* kcb = gd.cb_gtao.get(); + native_device_context->CSSetConstantBuffers(11, 1, &kcb); + ID3D11SamplerState* smp = device_data.sampler_state_point.get(); + native_device_context->CSSetSamplers(0, 1, &smp); + // cb0 ($Globals: ProjInfo etc.) and cb2 (MinZ_MaxZRatioCS) are read directly by our shaders at the + // same b0/b2 slots, INHERITED from the game — bound by the deinterleave/coarse passes earlier in the + // same contiguous HBAO+ chain, not rebound here by design (immediate context, fixed pass order, no + // ClearState between them). If a future variant reorders the AO passes, capture+rebind explicitly. + + static constexpr std::array uav_nulls5 = {}; + static constexpr std::array srv_nulls2 = {}; + + // 1) Prefilter: game full-res depth -> our R32F mip pyramid (each thread does 2x2 -> 16x16 per group). + { + native_device_context->CSSetShaderResources(0, 2, srv_nulls2.data()); + ID3D11ShaderResourceView* srv = gd.srv_gtao_depth.get(); + ID3D11UnorderedAccessView* uavs[5] = {gd.gtao_depth_mip_uavs[0].get(), gd.gtao_depth_mip_uavs[1].get(), + gd.gtao_depth_mip_uavs[2].get(), gd.gtao_depth_mip_uavs[3].get(), gd.gtao_depth_mip_uavs[4].get()}; + native_device_context->CSSetUnorderedAccessViews(0, 5, uavs, nullptr); + native_device_context->CSSetShaderResources(0, 1, &srv); + native_device_context->CSSetShader(device_data.native_compute_shaders[CompileTimeStringHash("BL XeGTAO Prefilter Depths CS")].get(), nullptr, 0); + native_device_context->Dispatch((w + 15) / 16, (h + 15) / 16, 1); + native_device_context->CSSetUnorderedAccessViews(0, 5, uav_nulls5.data(), nullptr); + } + // Binding ORDER matters: the UAV must be set BEFORE the SRVs in every pass. Binding an SRV whose + // resource is still bound as a CS UAV (from the previous pass) makes the runtime silently NULL the + // SRV (the existing UAV wins the hazard) — the whole chain then reads zeros while every write + // "succeeds". Setting the new UAV first auto-unbinds the old one, so the SRV bind is clean. + // 2) Main pass: pyramid + game view normals -> AO+edges (working0). + { + native_device_context->CSSetShaderResources(0, 2, srv_nulls2.data()); + ID3D11ShaderResourceView* srvs[2] = {gd.srv_gtao_depth_mips.get(), gd.srv_gtao_normals.get()}; + ID3D11UnorderedAccessView* uav = gd.uav_gtao_working[0].get(); + native_device_context->CSSetUnorderedAccessViews(0, 1, &uav, nullptr); + native_device_context->CSSetShaderResources(0, 2, srvs); + native_device_context->CSSetShader(device_data.native_compute_shaders[CompileTimeStringHash("BL XeGTAO Main Pass CS")].get(), nullptr, 0); + native_device_context->Dispatch((w + 7) / 8, (h + 7) / 8, 1); + } + // 3) Denoise 1: working0 -> working1 (2 horizontal pixels per thread). + { + native_device_context->CSSetShaderResources(0, 2, srv_nulls2.data()); + ID3D11ShaderResourceView* srv = gd.srv_gtao_working[0].get(); + ID3D11UnorderedAccessView* uav = gd.uav_gtao_working[1].get(); + native_device_context->CSSetUnorderedAccessViews(0, 1, &uav, nullptr); + native_device_context->CSSetShaderResources(0, 1, &srv); + native_device_context->CSSetShader(device_data.native_compute_shaders[CompileTimeStringHash("BL XeGTAO Denoise Pass 1 CS")].get(), nullptr, 0); + native_device_context->Dispatch((w + 15) / 16, (h + 7) / 8, 1); + } + // 4) Denoise 2 (final): working1 -> the game's r16g16_float AO target. + { + native_device_context->CSSetShaderResources(0, 2, srv_nulls2.data()); + ID3D11ShaderResourceView* srv = gd.srv_gtao_working[1].get(); + ID3D11UnorderedAccessView* uav = uav_final.get(); + native_device_context->CSSetUnorderedAccessViews(0, 1, &uav, nullptr); + native_device_context->CSSetShaderResources(0, 1, &srv); + native_device_context->CSSetShader(device_data.native_compute_shaders[CompileTimeStringHash("BL XeGTAO Denoise Pass 2 CS")].get(), nullptr, 0); + native_device_context->Dispatch((w + 15) / 16, (h + 7) / 8, 1); + } + + st.Restore(native_device_context); + return DrawOrDispatchOverrideType::Replaced; // cancel the native blur + } + } + #if ENABLE_SMAA // Replace the compute FXAA resolve with SMAA. Replace EVERY occurrence in the frame (the game can run the // resolve more than once — e.g. menu/transition frames have two), each with its own InColor/Color target. @@ -401,7 +747,8 @@ class BorderlandsGoty final : public Game if (!color_res) return DrawOrDispatchOverrideType::None; - uint4 cinfo{}; DXGI_FORMAT cfmt = DXGI_FORMAT_UNKNOWN; + uint4 cinfo{}; + DXGI_FORMAT cfmt = DXGI_FORMAT_UNKNOWN; GetResourceInfo(color_res.get(), cinfo, cfmt); uint32_t w = cinfo.x, h = cinfo.y, color_fmt = (uint32_t)cfmt; if (w == 0 || h == 0) @@ -428,7 +775,8 @@ class BorderlandsGoty final : public Game bool depth_ok = false; if (gd.srv_depth) { - uint4 dinfo{}; DXGI_FORMAT dfmt = DXGI_FORMAT_UNKNOWN; + uint4 dinfo{}; + DXGI_FORMAT dfmt = DXGI_FORMAT_UNKNOWN; GetResourceInfo(gd.srv_depth.get(), dinfo, dfmt); depth_ok = (dinfo.x == w && dinfo.y == h); } @@ -495,9 +843,14 @@ class BorderlandsGoty final : public Game // scene color; tex_lin/tex_gam = linear + sRGB copies the linearize CS writes. if (!gd.tex_input || gd.smaa_temps_w != w || gd.smaa_temps_h != h) { - gd.srv_input.reset(); gd.tex_input.reset(); - gd.uav_lin.reset(); gd.srv_lin.reset(); gd.tex_lin.reset(); - gd.uav_gam.reset(); gd.srv_gam.reset(); gd.tex_gam.reset(); + gd.srv_input.reset(); + gd.tex_input.reset(); + gd.uav_lin.reset(); + gd.srv_lin.reset(); + gd.tex_lin.reset(); + gd.uav_gam.reset(); + gd.srv_gam.reset(); + gd.tex_gam.reset(); if (CreateDefaultRGBA16FTex(native_device, w, h, D3D11_BIND_SHADER_RESOURCE, gd.tex_input) && CreateDefaultRGBA16FTex(native_device, w, h, D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_UNORDERED_ACCESS, gd.tex_lin) && CreateDefaultRGBA16FTex(native_device, w, h, D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_UNORDERED_ACCESS, gd.tex_gam)) @@ -642,6 +995,13 @@ class BorderlandsGoty final : public Game // (menu/transition/reorder) uses NO predication, not last frame's (possibly wrong-size) depth. Null handled at bind. gd.srv_depth.reset(); + // XeGTAO inputs are captured per-frame at the deinterleave/coarse passes; disarm the takeover + drop the + // captured SRVs every present so a frame without the AO chain (AO off in-game / transition) can't apply a + // stale AO buffer. + gd.gtao_active_this_frame = false; + gd.srv_gtao_depth.reset(); + gd.srv_gtao_normals.reset(); + device_data.has_drawn_main_post_processing = true; } @@ -649,6 +1009,7 @@ class BorderlandsGoty final : public Game { reshade::get_config_value(nullptr, NAME, "SMAAEnable", g_smaa_enable); reshade::get_config_value(nullptr, NAME, "RCASSharpness", g_rcas_sharpness); + reshade::get_config_value(nullptr, NAME, "XeGTAOEnable", g_gtao_enable); // Grade sliders (cb_luma_global_settings_dirty is already true at init -> uploaded on first frame). reshade::get_config_value(nullptr, NAME, "Exposure", cb_luma_global_settings.GameSettings.Exposure); reshade::get_config_value(nullptr, NAME, "Saturation", cb_luma_global_settings.GameSettings.Saturation); @@ -657,6 +1018,8 @@ class BorderlandsGoty final : public Game reshade::get_config_value(nullptr, NAME, "Contrast", cb_luma_global_settings.GameSettings.Contrast); reshade::get_config_value(nullptr, NAME, "Dithering", cb_luma_global_settings.GameSettings.Dithering); reshade::get_config_value(nullptr, NAME, "FlareOut", cb_luma_global_settings.GameSettings.FlareOut); + reshade::get_config_value(nullptr, NAME, "VideoAutoHDREnable", cb_luma_global_settings.GameSettings.VideoAutoHDREnable); + reshade::get_config_value(nullptr, NAME, "VideoAutoHDRBoost", cb_luma_global_settings.GameSettings.VideoAutoHDRBoost); reshade::get_config_value(nullptr, NAME, "HideUI", g_hide_ui); reshade::get_config_value(nullptr, NAME, "FixMovieLeak", g_fix_movie_leak); } @@ -671,7 +1034,7 @@ class BorderlandsGoty final : public Game if (ImGui::IsItemDeactivatedAfterEdit()) reshade::set_config_value(nullptr, NAME, "RCASSharpness", g_rcas_sharpness); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) - ImGui::SetTooltip("RCAS sharpening applied to the SMAA output (0 = off)."); + ImGui::SetTooltip("Sharpening applied on top of SMAA (0 = off)."); ImGui::EndDisabled(); // --- HDR grade (read in Luma_BL_Tonemap.hlsl via LumaSettings.GameSettings; HDR tonemap path only) --- @@ -684,7 +1047,7 @@ class BorderlandsGoty final : public Game device_data.cb_luma_global_settings_dirty = true; } if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Scene-referred exposure multiplier (1 = vanilla)."); + ImGui::SetTooltip("Overall image brightness (1 = vanilla)."); if (DrawResetButton(gs.Exposure, default_luma_global_game_settings.Exposure, "Exposure")) device_data.cb_luma_global_settings_dirty = true; @@ -694,7 +1057,7 @@ class BorderlandsGoty final : public Game device_data.cb_luma_global_settings_dirty = true; } if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Slope contrast around 18% mid-gray (1 = vanilla)."); + ImGui::SetTooltip("Overall image contrast, HDR only (1 = vanilla)."); if (DrawResetButton(gs.Contrast, default_luma_global_game_settings.Contrast, "Contrast")) device_data.cb_luma_global_settings_dirty = true; @@ -703,6 +1066,8 @@ class BorderlandsGoty final : public Game reshade::set_config_value(nullptr, NAME, "Saturation", gs.Saturation); device_data.cb_luma_global_settings_dirty = true; } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Color saturation, HDR only (1 = vanilla)."); if (DrawResetButton(gs.Saturation, default_luma_global_game_settings.Saturation, "Saturation")) device_data.cb_luma_global_settings_dirty = true; @@ -712,10 +1077,28 @@ class BorderlandsGoty final : public Game device_data.cb_luma_global_settings_dirty = true; } if (ImGui::IsItemHovered()) - ImGui::SetTooltip("How soon bright sources fade to neutral white (0 = keep color at any brightness)."); + ImGui::SetTooltip("How soon bright sources fade to neutral white, HDR only (0 = keep color at any brightness)."); if (DrawResetButton(gs.HighlightDechroma, default_luma_global_game_settings.HighlightDechroma, "HighlightDechroma")) device_data.cb_luma_global_settings_dirty = true; + // --- Ambient Occlusion: XeGTAO replaces the game's native HBAO+ --- + ImGui::SeparatorText("Ambient Occlusion"); + if (ImGui::Checkbox("XeGTAO Enable", &g_gtao_enable)) + reshade::set_config_value(nullptr, NAME, "XeGTAOEnable", g_gtao_enable); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Replaces the game's HBAO+ with XeGTAO (cleaner, more accurate ambient occlusion)."); +#if DEVELOPMENT || TEST + // DEV calibration only (drive cb_gtao, recreated on change at the deinterleave hook). Not persisted. + ImGui::BeginDisabled(!g_gtao_enable); + ImGui::SliderFloat("GTAO Final Value Power", &g_gtao_final_value_power, 0.3f, 4.5f); // midtone-shadow contrast dial + ImGui::SliderFloat("GTAO Depth Scale", &g_gtao_depth_scale, 1.f, 200.f); // UE3 units -> ~meters; the anti-over-occlusion dial + ImGui::SliderFloat("GTAO Radius Override", &g_gtao_radius_override, 0.f, 5.f); // 0 = use EFFECT_RADIUS +#if DEVELOPMENT // shader DebugViewRT blocks are #if DEVELOPMENT — don't draw a dead combo in TEST + ImGui::Combo("GTAO Debug View", &g_gtao_debug_view, "Off\0Depth gradient\0Normals\0AO x8\0Edges\0"); // diagnostics through the AO apply +#endif + ImGui::EndDisabled(); +#endif + // --- Post-effect scales + output toggle --- ImGui::SeparatorText("Effects"); @@ -724,6 +1107,8 @@ class BorderlandsGoty final : public Game reshade::set_config_value(nullptr, NAME, "BloomIntensity", gs.BloomIntensity); device_data.cb_luma_global_settings_dirty = true; } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Bloom strength (1 = vanilla, 0 = none)."); if (DrawResetButton(gs.BloomIntensity, default_luma_global_game_settings.BloomIntensity, "BloomIntensity")) device_data.cb_luma_global_settings_dirty = true; @@ -733,10 +1118,31 @@ class BorderlandsGoty final : public Game device_data.cb_luma_global_settings_dirty = true; } if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Additive lens-flare / glare overlay strength (1 = vanilla, 0 = off)."); + ImGui::SetTooltip("Lens-flare / glare strength (1 = vanilla, 0 = off)."); if (DrawResetButton(gs.FlareOut, default_luma_global_game_settings.FlareOut, "FlareOut")) device_data.cb_luma_global_settings_dirty = true; + bool video_auto_hdr = gs.VideoAutoHDREnable > 0.5f; + if (ImGui::Checkbox("Video AutoHDR", &video_auto_hdr)) + { + gs.VideoAutoHDREnable = video_auto_hdr ? 1.f : 0.f; + reshade::set_config_value(nullptr, NAME, "VideoAutoHDREnable", gs.VideoAutoHDREnable); + device_data.cb_luma_global_settings_dirty = true; + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Adds HDR highlights to pre-rendered videos (HDR only)."); + + ImGui::BeginDisabled(!video_auto_hdr); + if (ImGui::SliderFloat("Video HDR Boost", &gs.VideoAutoHDRBoost, 0.f, 1.f)) + device_data.cb_luma_global_settings_dirty = true; + if (ImGui::IsItemDeactivatedAfterEdit()) + reshade::set_config_value(nullptr, NAME, "VideoAutoHDRBoost", gs.VideoAutoHDRBoost); + if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip("Video highlight strength (0 = off)."); + if (DrawResetButton(gs.VideoAutoHDRBoost, default_luma_global_game_settings.VideoAutoHDRBoost, "VideoAutoHDRBoost")) + device_data.cb_luma_global_settings_dirty = true; + ImGui::EndDisabled(); + bool dithering = gs.Dithering > 0.5f; if (ImGui::Checkbox("Dithering", &dithering)) { @@ -745,7 +1151,7 @@ class BorderlandsGoty final : public Game device_data.cb_luma_global_settings_dirty = true; } if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Reduces gradient banding."); + ImGui::SetTooltip("Reduces gradient banding (HDR output)."); ImGui::SeparatorText("UI"); if (ImGui::Checkbox("Hide Gameplay UI", &g_hide_ui)) @@ -792,17 +1198,17 @@ class BorderlandsGoty final : public Game ImGui::NewLine(); ImGui::Text("Credits:" - "\n\nMain:" - "\nDristoforColumb" - "\n\nThird Party:" - "\nReShade" - "\nImGui" - "\nRenoDX (HDR tonemap method)" - "\nDICE (HDR tonemapper)" - "\nOklab (hue/chroma restoration)" - "\nSMAA (Iryoku)" - "\nAMD FidelityFX (RCAS)" - , ""); + "\n\nMain:" + "\nDristoforColumb" + "\n\nThird Party:" + "\nReShade" + "\nImGui" + "\nRenoDX (HDR tonemap method)" + "\nDICE (HDR tonemapper)" + "\nOklab (hue/chroma restoration)" + "\nSMAA (Iryoku)" + "\nAMD FidelityFX (RCAS)", + ""); } }; @@ -813,7 +1219,7 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserv const char* project_name = PROJECT_NAME; const char* cleared_project_name = (project_name[0] == '_') ? (project_name + 1) : project_name; - uint32_t mod_version = 2; // bump: AA-only -> native HDR (invalidates cached settings/shaders) + uint32_t mod_version = 3; // clears stale shader-define slots (phantom "Define 13") + invalidates cached settings/shaders Globals::SetGlobals(cleared_project_name, "Borderlands GOTY Enhanced Luma HDR + SMAA mod", "", mod_version); // Native HDR: swapchain -> scRGB fp16; core Display Composition does the paper-white scale + scRGB encode + @@ -821,7 +1227,7 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserv swapchain_format_upgrade_type = TextureFormatUpgradesType::AllowedEnabled; swapchain_upgrade_type = SwapchainUpgradeType::scRGB; // r10g10b10a2 backbuffer -> r16g16b16a16_float texture_format_upgrades_type = TextureFormatUpgradesType::AllowedEnabled; - // Trimmed to a safety minimum (devkit-verified 2026-06-21): the remaster renders its whole post chain in + // Trimmed to a safety minimum: the remaster renders its whole post chain in // fp16 already, and the only low-precision target (r10g10b10a2 backbuffer) is handled by the upgrade above. // The broad r8/b8 set was dead weight (and _srgb->fp16 risks a sampling shift). Keep plausible fp16 intermediates. texture_upgrade_formats = {