From f49f3442754fc06b8ab26542f8cdebd9a98d3c6b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 19:59:45 +0000 Subject: [PATCH 01/13] feat(llf): expose contact-shadow settings and skip distant lights Replace hardcoded contact-shadow constants (4 steps, 1024 max distance, 0.20/0.05 thickness/fade, 2.0 stride) with user-tunable settings exposed under a new "Contact Shadow Tuning" tree, and add a per-light intensity cutoff so weak clustered lights at their reach edge skip the raymarch. Group A (settings expansion): - New PerFrame fields: ContactShadowMaxSteps, ContactShadowMaxDistance, ContactShadowStride, ContactShadowThickness, ContactShadowDepthFade, ContactShadowMinIntensity. - Repack PerFrame / LightLimitFixSettings to a 64-byte (4-row) layout with three explicit float pads before ClusterSize so CPU and HLSL match. - Magic 16.5 first-person depth gate promoted to a named constant with a comment explaining the depth-range hack and that a viewmodel stencil pass would be more robust. Group B (per-light distance cutoff): - Contact-shadow branch now also requires intensityMultiplier > MinIntensity (default 0.25), skipping the matrix-multiply + raymarch for clustered lights that contribute too little at the pixel to produce a visible shadow. Typical interior cells have many lights at their reach edge; skipping them is the primary perf lever. Bump LightLimitFix.ini 3-1-0 -> 3-2-0. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- .../Shaders/Features/LightLimitFix.ini | 2 +- .../Shaders/LightLimitFix/LightLimitFix.hlsli | 19 ++++++--- package/Shaders/Common/SharedData.hlsli | 8 ++++ package/Shaders/Lighting.hlsl | 11 ++++- src/Features/LightLimitFix.cpp | 40 +++++++++++++++++++ src/Features/LightLimitFix.h | 21 +++++++++- 6 files changed, 92 insertions(+), 9 deletions(-) diff --git a/features/Light Limit Fix/Shaders/Features/LightLimitFix.ini b/features/Light Limit Fix/Shaders/Features/LightLimitFix.ini index 21a23ad267..0cb32375a0 100644 --- a/features/Light Limit Fix/Shaders/Features/LightLimitFix.ini +++ b/features/Light Limit Fix/Shaders/Features/LightLimitFix.ini @@ -1,5 +1,5 @@ [Info] -Version = 3-1-0 +Version = 3-2-0 [Nexus] autoupload = false diff --git a/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli b/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli index 3527bef030..7417f803f3 100644 --- a/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli +++ b/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli @@ -69,15 +69,24 @@ namespace LightLimitFix #endif } + // Skyrim's first-person viewmodel is rendered in a compressed depth range + // (linearized depth < this value). Contact shadows against viewmodel geometry + // produce wrong results (the viewmodel doesn't sit in the world), so we reject + // occluders whose depth falls in that range. A proper viewmodel stencil pass + // would be more robust but is out of scope here. + static const float CONTACT_SHADOW_FIRST_PERSON_MAX_DEPTH = 16.5; + float ContactShadows(float3 viewPosition, float noise2D, float3 lightDirectionVS, uint contactShadowSteps, uint a_eyeIndex = 0) { if (contactShadowSteps == 0) return 1.0; - float2 depthDeltaMult = float2(0.20, 0.05); + float depthDeltaThickness = SharedData::lightLimitFixSettings.ContactShadowThickness; + float depthDeltaFade = SharedData::lightLimitFixSettings.ContactShadowDepthFade; - // Extend contact shadow distance - lightDirectionVS *= 2.0; + // Scale per-step march length in view-space units. Larger -> longer shadow reach, + // coarser detail. Tunable so users can trade reach vs. precision. + lightDirectionVS *= SharedData::lightLimitFixSettings.ContactShadowStride; // Offset starting position with interleaved gradient noise viewPosition += lightDirectionVS * noise2D; @@ -99,8 +108,8 @@ namespace LightLimitFix // Difference between the current ray distance and the marched light float depthDelta = viewPosition.z - rayDepth; - if (rayDepth > 16.5) // First person - contactShadow = max(contactShadow, saturate(depthDelta * depthDeltaMult.x) - saturate(depthDelta * depthDeltaMult.y)); + if (rayDepth > CONTACT_SHADOW_FIRST_PERSON_MAX_DEPTH) + contactShadow = max(contactShadow, saturate(depthDelta * depthDeltaThickness) - saturate(depthDelta * depthDeltaFade)); if (contactShadow == 1.0) break; } diff --git a/package/Shaders/Common/SharedData.hlsli b/package/Shaders/Common/SharedData.hlsli index 13bb01e239..b72dc1c787 100644 --- a/package/Shaders/Common/SharedData.hlsli +++ b/package/Shaders/Common/SharedData.hlsli @@ -76,9 +76,17 @@ namespace SharedData struct LightLimitFixSettings { uint EnableContactShadows; + uint ContactShadowMaxSteps; + float ContactShadowMaxDistance; + float ContactShadowStride; + float ContactShadowThickness; + float ContactShadowDepthFade; + float ContactShadowMinIntensity; uint EnableLightsVisualisation; uint LightsVisualisationMode; float pad0; + float pad1; + float pad2; uint4 ClusterSize; }; diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 55b1c21975..97775d7846 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -2687,7 +2687,8 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) float contactShadowNoise = 0.0; [branch] if (SharedData::lightLimitFixSettings.EnableContactShadows) { - contactShadowSteps = round(4.0 * (1.0 - saturate(viewPosition.z / 1024.0))); + contactShadowSteps = round(SharedData::lightLimitFixSettings.ContactShadowMaxSteps * + (1.0 - saturate(viewPosition.z / SharedData::lightLimitFixSettings.ContactShadowMaxDistance))); // The helper stays stereo-stable in VR — see // LightLimitFix::GetContactShadowNoiseCoord for the eye-buffer math. contactShadowNoise = Random::InterleavedGradientNoise( @@ -2741,11 +2742,17 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) float contactShadow = 1.0; # if defined(DEFERRED) + // Skip contact-shadow raymarch for lights too weak at this pixel to produce a visible + // shadow. intensityMultiplier already captures (1 - (lightDist/radius)^2); when it + // drops below MinIntensity, the shadow contribution is dominated by the dimming and + // not worth the per-step depth fetches. Typical clustered scenes have many lights at + // their reach edge — this cutoff skips them before the matrix multiply and raymarch. [branch] if ( SharedData::lightLimitFixSettings.EnableContactShadows && !(light.lightFlags & LightLimitFix::LightFlags::Simple) && shadowComponent != 0.0 && - lightAngle > 0.0) + lightAngle > 0.0 && + intensityMultiplier > SharedData::lightLimitFixSettings.ContactShadowMinIntensity) { // The current LightLimitFix Light struct stores positionWS only; derive view-space // from CameraView so the raymarch direction matches viewPosition. The pre-removal diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index a18f52361c..dd62514926 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -29,6 +29,40 @@ void LightLimitFix::DrawSettings() ImGui::Text("All point lights (strict and clustered, except simple lights) cast short screen-space shadows. Performance impact."); } + if (settings.EnableContactShadows && ImGui::TreeNode("Contact Shadow Tuning")) { + ImGui::SliderInt("Max Steps", (int*)&settings.ContactShadowMaxSteps, 1, 16, "%d", ImGuiSliderFlags_AlwaysClamp); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Raymarch steps at zero depth. Higher = longer / more accurate contact shadows, linearly more cost.\nVR users should consider 2 to halve per-eye cost."); + } + + ImGui::SliderFloat("Max Distance", &settings.ContactShadowMaxDistance, 64.0f, 4096.0f, "%.0f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("View-space depth at which contact shadows fade to zero steps. Avoids paying for shadows on distant surfaces where they don't read."); + } + + ImGui::SliderFloat("Stride", &settings.ContactShadowStride, 0.5f, 8.0f, "%.2f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Per-step march length in view-space units. Larger = longer shadow reach with coarser detail; smaller = tighter contact, shorter reach."); + } + + ImGui::SliderFloat("Thickness", &settings.ContactShadowThickness, 0.0f, 1.0f, "%.3f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Depth-delta multiplier for shadow onset. Larger = darker contact at occluder edges."); + } + + ImGui::SliderFloat("Depth Fade", &settings.ContactShadowDepthFade, 0.0f, 1.0f, "%.3f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Depth-delta multiplier for shadow falloff. Larger = shadows truncate sooner behind thick occluders."); + } + + ImGui::SliderFloat("Min Light Intensity", &settings.ContactShadowMinIntensity, 0.0f, 1.0f, "%.2f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Skip contact shadows for clustered lights whose normalized intensity at the pixel is below this threshold. Higher = larger perf win, may drop subtle shadows from weak lights at their reach edge."); + } + + ImGui::TreePop(); + } + /////////////////////////////// ImGui::SeparatorText("Debug"); @@ -75,6 +109,12 @@ LightLimitFix::PerFrame LightLimitFix::GetCommonBufferData() { PerFrame perFrame{}; perFrame.EnableContactShadows = settings.EnableContactShadows; + perFrame.ContactShadowMaxSteps = settings.ContactShadowMaxSteps; + perFrame.ContactShadowMaxDistance = settings.ContactShadowMaxDistance; + perFrame.ContactShadowStride = settings.ContactShadowStride; + perFrame.ContactShadowThickness = settings.ContactShadowThickness; + perFrame.ContactShadowDepthFade = settings.ContactShadowDepthFade; + perFrame.ContactShadowMinIntensity = settings.ContactShadowMinIntensity; perFrame.EnableLightsVisualisation = settings.EnableLightsVisualisation; perFrame.LightsVisualisationMode = settings.LightsVisualisationMode; std::copy(clusterSize, clusterSize + 3, perFrame.ClusterSize); diff --git a/src/Features/LightLimitFix.h b/src/Features/LightLimitFix.h index a1569f2de9..a29c9db3e6 100644 --- a/src/Features/LightLimitFix.h +++ b/src/Features/LightLimitFix.h @@ -95,9 +95,15 @@ struct LightLimitFix : OverlayFeature struct alignas(16) PerFrame { uint EnableContactShadows; + uint ContactShadowMaxSteps; + float ContactShadowMaxDistance; + float ContactShadowStride; + float ContactShadowThickness; + float ContactShadowDepthFade; + float ContactShadowMinIntensity; uint EnableLightsVisualisation; uint LightsVisualisationMode; - float pad0; + float pad0[3]; uint ClusterSize[4]; }; STATIC_ASSERT_ALIGNAS_16(PerFrame); @@ -171,6 +177,19 @@ struct LightLimitFix : OverlayFeature struct Settings { bool EnableContactShadows = false; + // Max raymarch steps at zero depth; linearly ramps to 0 at MaxDistance. + uint ContactShadowMaxSteps = 4; + // View-space depth at which contact shadows fade fully off. + float ContactShadowMaxDistance = 1024.0f; + // Per-step march length in view-space units. Larger -> longer shadows, coarser detail. + float ContactShadowStride = 2.0f; + // Depth-delta multiplier for shadow onset (higher -> darker contact). + float ContactShadowThickness = 0.20f; + // Depth-delta multiplier for shadow falloff (higher -> shorter shadow). + float ContactShadowDepthFade = 0.05f; + // Skip contact shadows for lights below this normalized intensity at the pixel + // (intensityMultiplier = 1 - (lightDist/radius)^2). 0 = never skip; 1 = always skip. + float ContactShadowMinIntensity = 0.25f; bool EnableLightsVisualisation = false; uint LightsVisualisationMode = 0; }; From 5c781fb8a5ce4067fb3c4ba9a9eae51ac2e81340 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 20:36:46 +0000 Subject: [PATCH 02/13] refactor(llf): dedupe EnableContactShadows gate, tighten comments Replace the inner EnableContactShadows check with contactShadowSteps > 0: when steps fall to zero (feature off OR pixel past MaxDistance), the inner branch already does the right thing without a second cbuffer read, and we now also skip the matrix-multiply for pixels past the fade cutoff. Trim the new contact-shadow comments to one-line "why" notes per the project's commenting convention. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- .../Shaders/LightLimitFix/LightLimitFix.hlsli | 7 ++----- package/Shaders/Lighting.hlsl | 17 +++++++---------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli b/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli index 7417f803f3..d3d6c9d7ee 100644 --- a/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli +++ b/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli @@ -69,11 +69,8 @@ namespace LightLimitFix #endif } - // Skyrim's first-person viewmodel is rendered in a compressed depth range - // (linearized depth < this value). Contact shadows against viewmodel geometry - // produce wrong results (the viewmodel doesn't sit in the world), so we reject - // occluders whose depth falls in that range. A proper viewmodel stencil pass - // would be more robust but is out of scope here. + // Skyrim's first-person viewmodel renders in a compressed depth range below this + // linearized value; reject occluders there since the viewmodel isn't in the world. static const float CONTACT_SHADOW_FIRST_PERSON_MAX_DEPTH = 16.5; float ContactShadows(float3 viewPosition, float noise2D, float3 lightDirectionVS, uint contactShadowSteps, uint a_eyeIndex = 0) diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 97775d7846..b3c482ca9c 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -2742,22 +2742,19 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) float contactShadow = 1.0; # if defined(DEFERRED) - // Skip contact-shadow raymarch for lights too weak at this pixel to produce a visible - // shadow. intensityMultiplier already captures (1 - (lightDist/radius)^2); when it - // drops below MinIntensity, the shadow contribution is dominated by the dimming and - // not worth the per-step depth fetches. Typical clustered scenes have many lights at - // their reach edge — this cutoff skips them before the matrix multiply and raymarch. + // contactShadowSteps > 0 implies the feature is on AND viewPosition is closer than + // MaxDistance; the MinIntensity gate skips weak lights whose shadow contribution is + // already dimmed past visibility, avoiding the matrix multiply + raymarch for them. [branch] if ( - SharedData::lightLimitFixSettings.EnableContactShadows && + contactShadowSteps > 0 && !(light.lightFlags & LightLimitFix::LightFlags::Simple) && shadowComponent != 0.0 && lightAngle > 0.0 && intensityMultiplier > SharedData::lightLimitFixSettings.ContactShadowMinIntensity) { - // The current LightLimitFix Light struct stores positionWS only; derive view-space - // from CameraView so the raymarch direction matches viewPosition. The pre-removal - // call site referenced light.positionVS, but that field did not exist on the Light - // struct even then — the original code was commented out and unreachable. + // Derive view-space position via CameraView; the Light struct only carries positionWS + // (camera-relative) so the matrix multiply here is the cheapest path until positionVS + // is added to the struct + populated CPU-side. float3 lightPositionVS = mul(FrameBuffer::CameraView[eyeIndex], float4(light.positionWS[eyeIndex].xyz, 1)).xyz; float3 normalizedLightDirectionVS = normalize(lightPositionVS - viewPosition.xyz); contactShadow = LightLimitFix::ContactShadows(viewPosition, contactShadowNoise, normalizedLightDirectionVS, contactShadowSteps, eyeIndex); From fcd241c178df74d6ced7bfb6351e9f953334e338 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 21:20:07 +0000 Subject: [PATCH 03/13] perf(llf): perspective-correct contact-shadow stepping Scale the per-step march length by view-space depth past a reference of 100 units so each step covers roughly constant screen-space distance regardless of how far the recipient is from camera. Without this, far surfaces under- sample badly (a 2-unit step is ~10 pixels at z=50 but <1 pixel at z=500). Inverse-scale the thickness/fade depth-delta band by the same factor so the shadow-onset window tracks the same screen-space extent at depth instead of the wider world-space depthDelta jumps caused by the longer stride. At/below reference depth (typical near-interior recipient distances) the behavior matches the prior code exactly. Beyond reference depth, distant shadows now actually reach into screen pixels they previously skipped. Step count and step count are unchanged, so perf is neutral. Quality lift is largest at mid-range distances (z = 200-1000) where the old code's fixed view-space stride produced essentially invisible shadow halos. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- .../Shaders/LightLimitFix/LightLimitFix.hlsli | 19 +++++++++++++------ src/Features/LightLimitFix.cpp | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli b/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli index d3d6c9d7ee..36229fa8c5 100644 --- a/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli +++ b/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli @@ -73,17 +73,24 @@ namespace LightLimitFix // linearized value; reject occluders there since the viewmodel isn't in the world. static const float CONTACT_SHADOW_FIRST_PERSON_MAX_DEPTH = 16.5; + // Reference view-space depth for perspective-correct stride. At/below this depth, + // stride matches its prior view-space meaning; beyond it, stride and the depth-delta + // band scale linearly with depth so each step covers ~constant screen-space distance + // and the shadow-thickness band tracks the same screen-space extent. + static const float CONTACT_SHADOW_REFERENCE_DEPTH = 100.0; + float ContactShadows(float3 viewPosition, float noise2D, float3 lightDirectionVS, uint contactShadowSteps, uint a_eyeIndex = 0) { if (contactShadowSteps == 0) return 1.0; - float depthDeltaThickness = SharedData::lightLimitFixSettings.ContactShadowThickness; - float depthDeltaFade = SharedData::lightLimitFixSettings.ContactShadowDepthFade; - - // Scale per-step march length in view-space units. Larger -> longer shadow reach, - // coarser detail. Tunable so users can trade reach vs. precision. - lightDirectionVS *= SharedData::lightLimitFixSettings.ContactShadowStride; + // Perspective-correct stride: scale view-space step length with depth so each step + // covers ~constant screen-space distance. Inverse-scale the thickness/fade band so + // the depth-delta window tracks the same screen-space extent across depths. + float perspectiveScale = max(viewPosition.z, CONTACT_SHADOW_REFERENCE_DEPTH) / CONTACT_SHADOW_REFERENCE_DEPTH; + float depthDeltaThickness = SharedData::lightLimitFixSettings.ContactShadowThickness / perspectiveScale; + float depthDeltaFade = SharedData::lightLimitFixSettings.ContactShadowDepthFade / perspectiveScale; + lightDirectionVS *= SharedData::lightLimitFixSettings.ContactShadowStride * perspectiveScale; // Offset starting position with interleaved gradient noise viewPosition += lightDirectionVS * noise2D; diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index dd62514926..8cd02e69cd 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -42,7 +42,7 @@ void LightLimitFix::DrawSettings() ImGui::SliderFloat("Stride", &settings.ContactShadowStride, 0.5f, 8.0f, "%.2f"); if (auto _tt = Util::HoverTooltipWrapper()) { - ImGui::Text("Per-step march length in view-space units. Larger = longer shadow reach with coarser detail; smaller = tighter contact, shorter reach."); + ImGui::Text("Per-step march length in view-space units at near depth (auto-scales linearly past ~100 units so far surfaces don't undersample). Larger = longer screen-space reach with coarser detail."); } ImGui::SliderFloat("Thickness", &settings.ContactShadowThickness, 0.0f, 1.0f, "%.3f"); From 76256ecb7943bb3f92b799e05d85613aa1a7e3fc Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Mon, 25 May 2026 15:33:31 -0700 Subject: [PATCH 04/13] fix(llf): address PR #43 contact-shadow review feedback Address 11 actionable Copilot review comments on PR #43: - Lighting.hlsl contact-shadow gate: compute a separate normalized falloff `(1 - lightDist*invRadius)^2` clamped to [0,1] for the MinIntensity cutoff. The previous gate compared `intensityMultiplier`, which is path-dependent -- non-ISL returns the normalized quadratic falloff but ISL returns `InverseSquareLighting::GetAttenuation()` which can exceed 1 and isn't normalized. Comparing a 0..1 threshold against it meant different things in the two permutations. - Lighting.hlsl gate scope: limit the MinIntensity cutoff to CLUSTERED lights (lightIndex >= NumStrictLights). Strict lights are always raymarched regardless of falloff -- portal-strict lights are user-controlled and should preserve their contact contribution even when their attenuation is weak at the pixel. - LightLimitFix.cpp ContactShadowMaxSteps slider: switch from `SliderInt + (int*)cast` to `SliderScalar` with `ImGuiDataType_U32`. The cast violated strict aliasing (UB under MSVC + Clang) and could also misinterpret transient negative ImGui values before clamp. SliderScalar reads/writes the uint storage directly. - LightLimitFix.cpp Min Light Intensity tooltip: clarified to match the new shader behavior -- "CLUSTERED" capitalised, normalized falloff formula spelled out, strict-lights-always-march noted. - LightLimitFix.cpp GetCommonBufferData: clamp all six contact-shadow settings before they hit the constant buffer. The sliders enforce AlwaysClamp but malformed JSON, hand-edited configs, or schema migrations can deliver out-of-range values; clamping here lets the shader assume sane inputs. - LightLimitFix.h PerFrame: add `static_assert(sizeof(PerFrame) == 64)` so any CPU/GPU cbuffer layout drift fails at compile time. The existing STATIC_ASSERT_ALIGNAS_16 only enforces 16-byte alignment and multiple-of-16 size; a field shuffle that still lands on a 16-byte boundary would silently desync the shader-side cbuffer without it. One thread resolved without code changes: - Stride auto-scaling tooltip claim: the referenced "auto-scales linearly past ~100 units" text isn't present in the current code. Outdated against an earlier draft. Co-Authored-By: Claude Opus 4.7 (1M context) --- package/Shaders/Lighting.hlsl | 26 +++++++++++++++++++++---- src/Features/LightLimitFix.cpp | 35 ++++++++++++++++++++++++++-------- src/Features/LightLimitFix.h | 9 +++++++++ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index b3c482ca9c..d7bd2b55d7 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -2688,7 +2688,7 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) [branch] if (SharedData::lightLimitFixSettings.EnableContactShadows) { contactShadowSteps = round(SharedData::lightLimitFixSettings.ContactShadowMaxSteps * - (1.0 - saturate(viewPosition.z / SharedData::lightLimitFixSettings.ContactShadowMaxDistance))); + (1.0 - saturate(viewPosition.z / SharedData::lightLimitFixSettings.ContactShadowMaxDistance))); // The helper stays stereo-stable in VR — see // LightLimitFix::GetContactShadowNoiseCoord for the eye-buffer math. contactShadowNoise = Random::InterleavedGradientNoise( @@ -2743,14 +2743,32 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) # if defined(DEFERRED) // contactShadowSteps > 0 implies the feature is on AND viewPosition is closer than - // MaxDistance; the MinIntensity gate skips weak lights whose shadow contribution is - // already dimmed past visibility, avoiding the matrix multiply + raymarch for them. + // MaxDistance. The MinIntensity gate skips weak clustered lights whose shadow + // contribution is already dimmed past visibility, avoiding the matrix multiply + + // raymarch for them. + // + // Use a SEPARATE normalized falloff for the cutoff -- intensityMultiplier above + // is path-dependent (ISL returns a non-normalized GetAttenuation() while non-ISL + // returns a normalized 1-(d/r)^2). Comparing intensityMultiplier against a 0..1 + // threshold would mean different things in the two permutations. The normalized + // falloff below is the same `1 - (lightDist/radius)^2` clamped to [0,1] across + // both paths, so the threshold's semantics stay consistent. + // + // Gating is scoped to CLUSTERED lights (lightIndex >= NumStrictLights) -- strict + // lights should always raymarch regardless of falloff to preserve close, weak + // strict-light contact under bias. + const bool isClusteredLight = lightIndex >= LightLimitFix::NumStrictLights; + float normalizedFalloff = saturate(1.0 - lightDist * light.invRadius); + normalizedFalloff *= normalizedFalloff; + const bool passesIntensityGate = !isClusteredLight || + (normalizedFalloff > SharedData::lightLimitFixSettings.ContactShadowMinIntensity); + [branch] if ( contactShadowSteps > 0 && !(light.lightFlags & LightLimitFix::LightFlags::Simple) && shadowComponent != 0.0 && lightAngle > 0.0 && - intensityMultiplier > SharedData::lightLimitFixSettings.ContactShadowMinIntensity) + passesIntensityGate) { // Derive view-space position via CameraView; the Light struct only carries positionWS // (camera-relative) so the matrix multiply here is the cheapest path until positionVS diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index 8cd02e69cd..01796055ad 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -30,7 +30,13 @@ void LightLimitFix::DrawSettings() } if (settings.EnableContactShadows && ImGui::TreeNode("Contact Shadow Tuning")) { - ImGui::SliderInt("Max Steps", (int*)&settings.ContactShadowMaxSteps, 1, 16, "%d", ImGuiSliderFlags_AlwaysClamp); + // SliderScalar with ImGuiDataType_U32 instead of `SliderInt + (int*)cast`: + // the cast violates strict aliasing (UB) and would also misinterpret any + // transient negative value inside ImGui before clamp. SliderScalar + // reads/writes the uint storage directly with explicit min/max bounds. + constexpr uint32_t kMinSteps = 1, kMaxSteps = 16; + ImGui::SliderScalar("Max Steps", ImGuiDataType_U32, &settings.ContactShadowMaxSteps, + &kMinSteps, &kMaxSteps, "%u", ImGuiSliderFlags_AlwaysClamp); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Raymarch steps at zero depth. Higher = longer / more accurate contact shadows, linearly more cost.\nVR users should consider 2 to halve per-eye cost."); } @@ -57,7 +63,11 @@ void LightLimitFix::DrawSettings() ImGui::SliderFloat("Min Light Intensity", &settings.ContactShadowMinIntensity, 0.0f, 1.0f, "%.2f"); if (auto _tt = Util::HoverTooltipWrapper()) { - ImGui::Text("Skip contact shadows for clustered lights whose normalized intensity at the pixel is below this threshold. Higher = larger perf win, may drop subtle shadows from weak lights at their reach edge."); + ImGui::Text( + "Skip contact shadows for CLUSTERED lights whose normalized distance falloff " + "`1 - (lightDist/radius)^2` at the pixel is below this threshold. " + "Strict lights are always raymarched regardless of this threshold. " + "Higher = larger perf win, may drop subtle shadows from weak lights at their reach edge."); } ImGui::TreePop(); @@ -107,14 +117,23 @@ void LightLimitFix::DrawOverlay() LightLimitFix::PerFrame LightLimitFix::GetCommonBufferData() { + // Clamp contact-shadow settings to the slider ranges before they hit the + // constant buffer. The sliders enforce ImGuiSliderFlags_AlwaysClamp, but a + // malformed JSON config (hand-edited, mod conflict, or migration from a + // previous schema) can still arrive here with out-of-range values that + // would break shader math -- in particular ContactShadowMaxDistance is + // compared against a view-space depth, ContactShadowStride scales the + // raymarch length, and ContactShadowMaxSteps gates the loop count. + // Negative / NaN / wildly large values produce divisions, infinite loops, + // or visual corruption; clamp here so the shader can assume sane inputs. PerFrame perFrame{}; perFrame.EnableContactShadows = settings.EnableContactShadows; - perFrame.ContactShadowMaxSteps = settings.ContactShadowMaxSteps; - perFrame.ContactShadowMaxDistance = settings.ContactShadowMaxDistance; - perFrame.ContactShadowStride = settings.ContactShadowStride; - perFrame.ContactShadowThickness = settings.ContactShadowThickness; - perFrame.ContactShadowDepthFade = settings.ContactShadowDepthFade; - perFrame.ContactShadowMinIntensity = settings.ContactShadowMinIntensity; + perFrame.ContactShadowMaxSteps = std::clamp(settings.ContactShadowMaxSteps, 1u, 16u); + perFrame.ContactShadowMaxDistance = std::clamp(settings.ContactShadowMaxDistance, 64.0f, 4096.0f); + perFrame.ContactShadowStride = std::clamp(settings.ContactShadowStride, 0.5f, 8.0f); + perFrame.ContactShadowThickness = std::clamp(settings.ContactShadowThickness, 0.0f, 1.0f); + perFrame.ContactShadowDepthFade = std::clamp(settings.ContactShadowDepthFade, 0.0f, 1.0f); + perFrame.ContactShadowMinIntensity = std::clamp(settings.ContactShadowMinIntensity, 0.0f, 1.0f); perFrame.EnableLightsVisualisation = settings.EnableLightsVisualisation; perFrame.LightsVisualisationMode = settings.LightsVisualisationMode; std::copy(clusterSize, clusterSize + 3, perFrame.ClusterSize); diff --git a/src/Features/LightLimitFix.h b/src/Features/LightLimitFix.h index a29c9db3e6..c032906762 100644 --- a/src/Features/LightLimitFix.h +++ b/src/Features/LightLimitFix.h @@ -107,6 +107,15 @@ struct LightLimitFix : OverlayFeature uint ClusterSize[4]; }; STATIC_ASSERT_ALIGNAS_16(PerFrame); + // Compile-time size lock catches CPU/GPU cbuffer layout drift. STATIC_ASSERT_ALIGNAS_16 + // only enforces the 16-byte alignment / multiple-of-16 contract that HLSL constant + // buffers require; it doesn't notice if a field is added, removed, or resized in a + // way that still happens to land on a 16-byte boundary. The shader's PerFrameLLF + // cbuffer declaration must mirror this layout exactly, so any change here without + // the corresponding shader update is a silent bug. Update both sides when the layout + // changes, then bump this constant. + static_assert(sizeof(PerFrame) == 64, + "LightLimitFix::PerFrame layout drifted -- update the shader-side PerFrameLLF cbuffer to match, then update this assert."); PerFrame GetCommonBufferData(); From 7050c83044fb9e84bf4ff75da2b1109152c8c68b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:10:30 +0000 Subject: [PATCH 05/13] docs(llf): correct VR contact-shadow noise coord comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous comment claimed dropping the 0.5 factor would give "half effective noise resolution"; in fact it over-samples IGN by ~2x in X, producing a higher-frequency noise pattern, not lower. Rewrite as a clear regression warning so the 0.5 doesn't get "cleaned up" later. Comment-only — no behavior change. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- .../Shaders/LightLimitFix/LightLimitFix.hlsli | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli b/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli index 36229fa8c5..0df8c4e977 100644 --- a/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli +++ b/features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli @@ -47,19 +47,17 @@ namespace LightLimitFix return IsSaturated(value.x) && IsSaturated(value.y); } - // Chooses the contact-shadow noise sample coordinate. In VR we derive it - // from screenUV (which FrameBuffer::ViewToUV already returns per-eye via - // CameraProj[eye]) so both eyes sample the same noise pattern at the same - // world position — using the raw rasterized pixel position in VR makes - // each eye hash a different value, producing per-eye jitter that reads as - // flicker on contact-shadow recipients. + // Per-eye stereo-stable IGN coord. In VR we use screenUV (per-eye via + // CameraProj[eye]) instead of SV_Position so both eyes hash the same + // value at the same world pixel — SV_Position differs between eyes in + // a packed stereo buffer, producing per-eye jitter that reads as flicker + // on contact-shadow recipients. // - // BufferDim.x is the full packed stereo width (State::UpdateSharedData - // reads it from the kMAIN texture, which spans both eyes side-by-side), - // so we halve X in VR to match the per-eye pixel grid. Without the - // halving, the per-eye sample steps by ~2 pixels in X — still stereo- - // consistent, but at half the effective noise resolution. Flat keeps the - // raw pixel position to match the original implementation byte-for-byte. + // BufferDim.x is the full packed stereo width (kMAIN spans both eyes + // side-by-side), so the 0.5 factor lands us on the per-eye integer + // pixel grid — same IGN frequency as flat mode for a buffer sized + // (BufferDim.x/2, BufferDim.y). Do not drop the 0.5: that over-samples + // IGN by ~2x in X, giving a higher-frequency noise pattern, not lower. float2 GetContactShadowNoiseCoord(float2 screenPosition, float2 screenUV) { #if defined(VR) From 1e662e92d8328bcedc6e784dc715c4ccd5ddd5d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:20:12 +0000 Subject: [PATCH 06/13] fix(llf): correct normalized falloff formula and handle NaN configs Two Copilot review findings on top of #43: 1. Lighting.hlsl computed (saturate(1 - d/r))^2 but documented and intended 1 - (saturate(d/r))^2. At d=0.5*r the previous code returned 0.25; the intended non-ISL intensityMultiplier formula returns 0.75. The cutoff curve was much steeper than designed, defeating the path- uniformity fix from 5569754. Swap to match the non-ISL formula. 2. LightLimitFix.cpp::GetCommonBufferData clamped with std::clamp, but std::clamp passes NaN through (all NaN comparisons are false), so a malformed config containing NaN would still poison the cbuffer. Add a sanitizeFloat lambda that rejects non-finite values explicitly before clamping, falling back to the lower bound on NaN/inf. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- package/Shaders/Lighting.hlsl | 4 ++-- src/Features/LightLimitFix.cpp | 35 ++++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index d7bd2b55d7..555b091eff 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -2758,8 +2758,8 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) // lights should always raymarch regardless of falloff to preserve close, weak // strict-light contact under bias. const bool isClusteredLight = lightIndex >= LightLimitFix::NumStrictLights; - float normalizedFalloff = saturate(1.0 - lightDist * light.invRadius); - normalizedFalloff *= normalizedFalloff; + float falloffFactor = saturate(lightDist * light.invRadius); + float normalizedFalloff = 1.0 - falloffFactor * falloffFactor; const bool passesIntensityGate = !isClusteredLight || (normalizedFalloff > SharedData::lightLimitFixSettings.ContactShadowMinIntensity); diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index 01796055ad..27a2dcab46 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -117,23 +117,30 @@ void LightLimitFix::DrawOverlay() LightLimitFix::PerFrame LightLimitFix::GetCommonBufferData() { - // Clamp contact-shadow settings to the slider ranges before they hit the - // constant buffer. The sliders enforce ImGuiSliderFlags_AlwaysClamp, but a - // malformed JSON config (hand-edited, mod conflict, or migration from a - // previous schema) can still arrive here with out-of-range values that - // would break shader math -- in particular ContactShadowMaxDistance is - // compared against a view-space depth, ContactShadowStride scales the - // raymarch length, and ContactShadowMaxSteps gates the loop count. - // Negative / NaN / wildly large values produce divisions, infinite loops, - // or visual corruption; clamp here so the shader can assume sane inputs. + // Sanitize contact-shadow settings before they hit the constant buffer. The + // sliders enforce ImGuiSliderFlags_AlwaysClamp, but a malformed JSON config + // (hand-edited, mod conflict, or migration from a previous schema) can still + // arrive here with out-of-range values that would break shader math -- + // ContactShadowMaxDistance is compared against view-space depth, + // ContactShadowStride scales the raymarch length, and ContactShadowMaxSteps + // gates the loop count. + // + // std::clamp passes NaN through unchanged (every NaN comparison is false), + // so a NaN in the config would still poison the cbuffer. Reject non-finite + // values explicitly first; fall back to the lower bound on NaN/inf -- a + // corrupt config produces degraded but stable behavior rather than UB. + auto sanitizeFloat = [](float v, float lo, float hi) { + return std::isfinite(v) ? std::clamp(v, lo, hi) : lo; + }; + PerFrame perFrame{}; perFrame.EnableContactShadows = settings.EnableContactShadows; perFrame.ContactShadowMaxSteps = std::clamp(settings.ContactShadowMaxSteps, 1u, 16u); - perFrame.ContactShadowMaxDistance = std::clamp(settings.ContactShadowMaxDistance, 64.0f, 4096.0f); - perFrame.ContactShadowStride = std::clamp(settings.ContactShadowStride, 0.5f, 8.0f); - perFrame.ContactShadowThickness = std::clamp(settings.ContactShadowThickness, 0.0f, 1.0f); - perFrame.ContactShadowDepthFade = std::clamp(settings.ContactShadowDepthFade, 0.0f, 1.0f); - perFrame.ContactShadowMinIntensity = std::clamp(settings.ContactShadowMinIntensity, 0.0f, 1.0f); + perFrame.ContactShadowMaxDistance = sanitizeFloat(settings.ContactShadowMaxDistance, 64.0f, 4096.0f); + perFrame.ContactShadowStride = sanitizeFloat(settings.ContactShadowStride, 0.5f, 8.0f); + perFrame.ContactShadowThickness = sanitizeFloat(settings.ContactShadowThickness, 0.0f, 1.0f); + perFrame.ContactShadowDepthFade = sanitizeFloat(settings.ContactShadowDepthFade, 0.0f, 1.0f); + perFrame.ContactShadowMinIntensity = sanitizeFloat(settings.ContactShadowMinIntensity, 0.0f, 1.0f); perFrame.EnableLightsVisualisation = settings.EnableLightsVisualisation; perFrame.LightsVisualisationMode = settings.LightsVisualisationMode; std::copy(clusterSize, clusterSize + 3, perFrame.ClusterSize); From a57de695cfa53ba6c53b6676e75bb84129572680 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:46:45 +0000 Subject: [PATCH 07/13] refactor(llf): reuse intensityMultiplier as normalized falloff (non-ISL) The non-ISL branch above already computes intensityMultiplier as `1 - (saturate(d/r))^2` -- the exact value we want for normalizedFalloff. Re-deriving it from saturate(lightDist * invRadius) was a duplicate computation of the same formula under a different variable name. Keep the separate computation only in the ISL branch, where GetAttenuation() isn't [0,1]-normalized and we genuinely need a distinct falloff term for the gate. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- package/Shaders/Lighting.hlsl | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 555b091eff..266d2c6d33 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -2758,8 +2758,16 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) // lights should always raymarch regardless of falloff to preserve close, weak // strict-light contact under bias. const bool isClusteredLight = lightIndex >= LightLimitFix::NumStrictLights; +# if defined(ISL) + // ISL's GetAttenuation isn't [0,1]-normalized; derive a matching normalized + // 1 - (d/r)^2 falloff just for the MinIntensity gate so the threshold has + // the same semantics as the non-ISL path. float falloffFactor = saturate(lightDist * light.invRadius); float normalizedFalloff = 1.0 - falloffFactor * falloffFactor; +# else + // Non-ISL intensityMultiplier above already IS the normalized 1 - (d/r)^2. + float normalizedFalloff = intensityMultiplier; +# endif const bool passesIntensityGate = !isClusteredLight || (normalizedFalloff > SharedData::lightLimitFixSettings.ContactShadowMinIntensity); From 5aab62edc8c6e3eece97dcc9c7c8c1e28b616112 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 00:50:51 +0000 Subject: [PATCH 08/13] docs(llf): correct stale ContactShadowMinIntensity field comment The comment still referenced `intensityMultiplier` (shader-side variable name no longer used on the non-ISL gate path) and didn't note the clustered-only scoping that landed in 5569754. Bring the field doc in line with the shader's actual gate. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- src/Features/LightLimitFix.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Features/LightLimitFix.h b/src/Features/LightLimitFix.h index c032906762..7cea7e777e 100644 --- a/src/Features/LightLimitFix.h +++ b/src/Features/LightLimitFix.h @@ -196,8 +196,9 @@ struct LightLimitFix : OverlayFeature float ContactShadowThickness = 0.20f; // Depth-delta multiplier for shadow falloff (higher -> shorter shadow). float ContactShadowDepthFade = 0.05f; - // Skip contact shadows for lights below this normalized intensity at the pixel - // (intensityMultiplier = 1 - (lightDist/radius)^2). 0 = never skip; 1 = always skip. + // Skip contact shadows for CLUSTERED lights whose normalized distance falloff + // (1 - (lightDist/radius)^2) at the pixel is below this threshold. Strict + // lights always raymarch. 0 = never skip; 1 = always skip. float ContactShadowMinIntensity = 0.25f; bool EnableLightsVisualisation = false; uint LightsVisualisationMode = 0; From fe1ece6b24df7d1a14f2bc935aad156b4da53712 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 01:17:28 +0000 Subject: [PATCH 09/13] perf(llf): gate per-light intensity math on contactShadowSteps > 0 When contact shadows are off (default) or the pixel is past MaxDistance, contactShadowSteps == 0 and the per-light branch below rejects -- but the falloff/passesIntensityGate math still ran unconditionally for every clustered light, costing ~3-6 ops per light per pixel of pure overhead in the common-off case (8-15 clustered lights per cluster typically). Wrap the entire intensity-gate block in [branch] if (contactShadowSteps > 0). Also skip the falloff computation entirely for strict lights (passesIntensityGate is unconditionally true for them) instead of computing normalizedFalloff and OR'ing. No semantic change; default-off pixels and pixels-past-MaxDistance now skip the falloff math entirely. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- package/Shaders/Lighting.hlsl | 71 ++++++++++++++++------------------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 266d2c6d33..72ad97d490 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -2742,48 +2742,43 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) float contactShadow = 1.0; # if defined(DEFERRED) - // contactShadowSteps > 0 implies the feature is on AND viewPosition is closer than - // MaxDistance. The MinIntensity gate skips weak clustered lights whose shadow - // contribution is already dimmed past visibility, avoiding the matrix multiply + - // raymarch for them. - // - // Use a SEPARATE normalized falloff for the cutoff -- intensityMultiplier above - // is path-dependent (ISL returns a non-normalized GetAttenuation() while non-ISL - // returns a normalized 1-(d/r)^2). Comparing intensityMultiplier against a 0..1 - // threshold would mean different things in the two permutations. The normalized - // falloff below is the same `1 - (lightDist/radius)^2` clamped to [0,1] across - // both paths, so the threshold's semantics stay consistent. - // - // Gating is scoped to CLUSTERED lights (lightIndex >= NumStrictLights) -- strict - // lights should always raymarch regardless of falloff to preserve close, weak - // strict-light contact under bias. - const bool isClusteredLight = lightIndex >= LightLimitFix::NumStrictLights; + // Outer guard: contactShadowSteps > 0 covers both "feature off" and "pixel past + // MaxDistance", so all per-light intensity-gate math is paid only when a raymarch + // is actually possible. Without this, the falloff math fires for every clustered + // light even in the default-off case. + [branch] if (contactShadowSteps > 0) + { + // Strict lights always raymarch -- skip the falloff math for them entirely. + // Clustered lights need a normalized falloff to compare against MinIntensity; + // derive it from intensityMultiplier on the non-ISL path (where it IS already + // 1 - (d/r)^2) and re-compute on the ISL path (where GetAttenuation isn't + // [0,1]-normalized, so the threshold would mean different things otherwise). + const bool isClusteredLight = lightIndex >= LightLimitFix::NumStrictLights; + bool passesIntensityGate = !isClusteredLight; + if (isClusteredLight) { # if defined(ISL) - // ISL's GetAttenuation isn't [0,1]-normalized; derive a matching normalized - // 1 - (d/r)^2 falloff just for the MinIntensity gate so the threshold has - // the same semantics as the non-ISL path. - float falloffFactor = saturate(lightDist * light.invRadius); - float normalizedFalloff = 1.0 - falloffFactor * falloffFactor; + float falloffFactor = saturate(lightDist * light.invRadius); + passesIntensityGate = (1.0 - falloffFactor * falloffFactor) > + SharedData::lightLimitFixSettings.ContactShadowMinIntensity; # else - // Non-ISL intensityMultiplier above already IS the normalized 1 - (d/r)^2. - float normalizedFalloff = intensityMultiplier; + passesIntensityGate = intensityMultiplier > + SharedData::lightLimitFixSettings.ContactShadowMinIntensity; # endif - const bool passesIntensityGate = !isClusteredLight || - (normalizedFalloff > SharedData::lightLimitFixSettings.ContactShadowMinIntensity); + } - [branch] if ( - contactShadowSteps > 0 && - !(light.lightFlags & LightLimitFix::LightFlags::Simple) && - shadowComponent != 0.0 && - lightAngle > 0.0 && - passesIntensityGate) - { - // Derive view-space position via CameraView; the Light struct only carries positionWS - // (camera-relative) so the matrix multiply here is the cheapest path until positionVS - // is added to the struct + populated CPU-side. - float3 lightPositionVS = mul(FrameBuffer::CameraView[eyeIndex], float4(light.positionWS[eyeIndex].xyz, 1)).xyz; - float3 normalizedLightDirectionVS = normalize(lightPositionVS - viewPosition.xyz); - contactShadow = LightLimitFix::ContactShadows(viewPosition, contactShadowNoise, normalizedLightDirectionVS, contactShadowSteps, eyeIndex); + [branch] if ( + !(light.lightFlags & LightLimitFix::LightFlags::Simple) && + shadowComponent != 0.0 && + lightAngle > 0.0 && + passesIntensityGate) + { + // Derive view-space position via CameraView; the Light struct only carries positionWS + // (camera-relative) so the matrix multiply here is the cheapest path until positionVS + // is added to the struct + populated CPU-side. + float3 lightPositionVS = mul(FrameBuffer::CameraView[eyeIndex], float4(light.positionWS[eyeIndex].xyz, 1)).xyz; + float3 normalizedLightDirectionVS = normalize(lightPositionVS - viewPosition.xyz); + contactShadow = LightLimitFix::ContactShadows(viewPosition, contactShadowNoise, normalizedLightDirectionVS, contactShadowSteps, eyeIndex); + } } # endif From baa5a444151a0bc33a424c43354b431a07a2c138 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 02:34:58 +0000 Subject: [PATCH 10/13] docs(llf): reword sanitization comment, LLF doesn't persist to JSON The previous comment attributed the threat model to "malformed JSON configs" but LightLimitFix doesn't implement LoadSettings/SaveSettings, so there's no JSON deserialization path delivering values here today. Reword to describe the actual surface (future persistence, mod overrides, remote-control, internal bugs) and frame this as defensive boundary validation rather than a JSON-specific guard. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- src/Features/LightLimitFix.cpp | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index 27a2dcab46..9c4ab7cbfb 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -117,18 +117,17 @@ void LightLimitFix::DrawOverlay() LightLimitFix::PerFrame LightLimitFix::GetCommonBufferData() { - // Sanitize contact-shadow settings before they hit the constant buffer. The - // sliders enforce ImGuiSliderFlags_AlwaysClamp, but a malformed JSON config - // (hand-edited, mod conflict, or migration from a previous schema) can still - // arrive here with out-of-range values that would break shader math -- - // ContactShadowMaxDistance is compared against view-space depth, - // ContactShadowStride scales the raymarch length, and ContactShadowMaxSteps - // gates the loop count. + // Defensive sanitization before the values hit the constant buffer. The + // sliders enforce ImGuiSliderFlags_AlwaysClamp at the UI, but Settings + // can be mutated through other paths (future persistence, mod overrides, + // remote-control / MCP server, or just an internal logic bug) -- a few + // of these fields will produce divisions, infinite loops, or visual + // corruption if they arrive non-finite or out-of-range, so we re-validate + // at the shader boundary rather than trusting upstream callers. // // std::clamp passes NaN through unchanged (every NaN comparison is false), - // so a NaN in the config would still poison the cbuffer. Reject non-finite - // values explicitly first; fall back to the lower bound on NaN/inf -- a - // corrupt config produces degraded but stable behavior rather than UB. + // so reject non-finite values explicitly first; fall back to the lower + // bound on NaN/inf to produce degraded but stable behavior. auto sanitizeFloat = [](float v, float lo, float hi) { return std::isfinite(v) ? std::clamp(v, lo, hi) : lo; }; From dcfade6ab67524c84eaed2ca04648355279b0207 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 03:01:08 +0000 Subject: [PATCH 11/13] feat(llf): persist contact-shadow settings to JSON LightLimitFix didn't override SaveSettings/LoadSettings, so every game launch reset the Settings struct to default-member-initializer values. That was tolerable when the only settings were two debug toggles, but the contact-shadow restoration in #36 added EnableContactShadows and this PR added six more functional tuning fields -- none of which were surviving a restart. Wire up the standard Feature persistence pattern: NLOHMANN_DEFINE_TYPE_ NON_INTRUSIVE_WITH_DEFAULT over the Settings struct (so missing JSON fields fall back to struct defaults, keeping older configs forward- compatible) and trivial LoadSettings/SaveSettings overrides matching the ScreenSpaceGI pattern. GetCommonBufferData stays as the sole sanitization point at the cbuffer boundary, so a corrupt JSON value lands in `settings` unclamped but is clamped on its way to the GPU (and the sliders auto-clamp on the next UI interaction). https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- src/Features/LightLimitFix.cpp | 24 +++++++++++++++++++++++- src/Features/LightLimitFix.h | 2 ++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index 9c4ab7cbfb..4f7d292d7d 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -8,6 +8,18 @@ #include "Util.h" #include "Utils/ExternalEmittance.h" +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( + LightLimitFix::Settings, + EnableContactShadows, + ContactShadowMaxSteps, + ContactShadowMaxDistance, + ContactShadowStride, + ContactShadowThickness, + ContactShadowDepthFade, + ContactShadowMinIntensity, + EnableLightsVisualisation, + LightsVisualisationMode) + static constexpr uint CLUSTER_MAX_LIGHTS = 128; static constexpr uint MAX_LIGHTS = 1024; @@ -119,7 +131,7 @@ LightLimitFix::PerFrame LightLimitFix::GetCommonBufferData() { // Defensive sanitization before the values hit the constant buffer. The // sliders enforce ImGuiSliderFlags_AlwaysClamp at the UI, but Settings - // can be mutated through other paths (future persistence, mod overrides, + // can be mutated through other paths (JSON persistence, mod overrides, // remote-control / MCP server, or just an internal logic bug) -- a few // of these fields will produce divisions, infinite loops, or visual // corruption if they arrive non-finite or out-of-range, so we re-validate @@ -251,6 +263,16 @@ void LightLimitFix::RestoreDefaultSettings() settings = {}; } +void LightLimitFix::LoadSettings(json& o_json) +{ + settings = o_json; +} + +void LightLimitFix::SaveSettings(json& o_json) +{ + o_json = settings; +} + RE::NiNode* GetParentRoomNode(RE::NiAVObject* object) { if (object == nullptr) { diff --git a/src/Features/LightLimitFix.h b/src/Features/LightLimitFix.h index 7cea7e777e..cc07a4bc6d 100644 --- a/src/Features/LightLimitFix.h +++ b/src/Features/LightLimitFix.h @@ -164,6 +164,8 @@ struct LightLimitFix : OverlayFeature virtual void SetupResources() override; virtual void RestoreDefaultSettings() override; + virtual void LoadSettings(json& o_json) override; + virtual void SaveSettings(json& o_json) override; virtual void DrawSettings() override; virtual void DrawOverlay() override; From 45c2d89d8dc446007b5ef704f73f6b311a5f0b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 03:20:11 +0000 Subject: [PATCH 12/13] fix(llf): exclude debug visualisation toggles from persistence EnableLightsVisualisation and LightsVisualisationMode are debug-only toggles -- persisting them means a user who flipped them on to inspect something stays stuck with the overlay after a restart, which is never what's wanted. Drop both from the NLOHMANN field list; the _WITH_DEFAULT variant resets omitted fields to struct defaults on load, which is the intended behaviour for debug state. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- src/Features/LightLimitFix.cpp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index 4f7d292d7d..ed1b2e580b 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -8,6 +8,11 @@ #include "Util.h" #include "Utils/ExternalEmittance.h" +// EnableLightsVisualisation / LightsVisualisationMode are intentionally NOT +// persisted -- they're debug toggles, and a user who enabled visualization +// to inspect something shouldn't get stuck with it on after restart. The +// _WITH_DEFAULT variant of the macro means omitted fields fall back to the +// struct's default-member-initializers on load, which is the desired reset. NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( LightLimitFix::Settings, EnableContactShadows, @@ -16,9 +21,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( ContactShadowStride, ContactShadowThickness, ContactShadowDepthFade, - ContactShadowMinIntensity, - EnableLightsVisualisation, - LightsVisualisationMode) + ContactShadowMinIntensity) static constexpr uint CLUSTER_MAX_LIGHTS = 128; static constexpr uint MAX_LIGHTS = 1024; From a547ffc0280eac98c74bbf82c0bf5549394d7f2b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 03:41:21 +0000 Subject: [PATCH 13/13] fix(llf): AlwaysClamp on float sliders, correct cbuffer-mirror name Two Copilot review findings on 8bd771a: 1. The PerFrame static_assert message and comment referenced a fictional shader-side "PerFrameLLF" cbuffer. The real shader-side mirror is SharedData::LightLimitFixSettings inside the shared FeatureData cbuffer (b6, package/Shaders/Common/SharedData.hlsli). Update both the block comment and the assert message to point at the actual struct so the size-lock failure mode is actionable next time it triggers. 2. Only the MaxSteps SliderScalar passed ImGuiSliderFlags_AlwaysClamp; the five SliderFloat controls did not, so Ctrl+Click text entry could land arbitrary out-of-range values in settings between UI input and the next cbuffer write. Add AlwaysClamp to all five floats so the in-memory settings struct stays bounded. The GetCommonBufferData sanitization remains as defense in depth for non-UI write paths. https://claude.ai/code/session_01QhBc5srHV2VBA1qFNBooJs --- src/Features/LightLimitFix.cpp | 13 ++++++++----- src/Features/LightLimitFix.h | 10 +++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index ed1b2e580b..ed1f28d529 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -56,27 +56,30 @@ void LightLimitFix::DrawSettings() ImGui::Text("Raymarch steps at zero depth. Higher = longer / more accurate contact shadows, linearly more cost.\nVR users should consider 2 to halve per-eye cost."); } - ImGui::SliderFloat("Max Distance", &settings.ContactShadowMaxDistance, 64.0f, 4096.0f, "%.0f"); + // AlwaysClamp on every float slider too: without it, Ctrl+Click text entry can + // land arbitrary out-of-range values in settings before GetCommonBufferData's + // boundary clamp catches them at the GPU side. + ImGui::SliderFloat("Max Distance", &settings.ContactShadowMaxDistance, 64.0f, 4096.0f, "%.0f", ImGuiSliderFlags_AlwaysClamp); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("View-space depth at which contact shadows fade to zero steps. Avoids paying for shadows on distant surfaces where they don't read."); } - ImGui::SliderFloat("Stride", &settings.ContactShadowStride, 0.5f, 8.0f, "%.2f"); + ImGui::SliderFloat("Stride", &settings.ContactShadowStride, 0.5f, 8.0f, "%.2f", ImGuiSliderFlags_AlwaysClamp); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Per-step march length in view-space units at near depth (auto-scales linearly past ~100 units so far surfaces don't undersample). Larger = longer screen-space reach with coarser detail."); } - ImGui::SliderFloat("Thickness", &settings.ContactShadowThickness, 0.0f, 1.0f, "%.3f"); + ImGui::SliderFloat("Thickness", &settings.ContactShadowThickness, 0.0f, 1.0f, "%.3f", ImGuiSliderFlags_AlwaysClamp); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Depth-delta multiplier for shadow onset. Larger = darker contact at occluder edges."); } - ImGui::SliderFloat("Depth Fade", &settings.ContactShadowDepthFade, 0.0f, 1.0f, "%.3f"); + ImGui::SliderFloat("Depth Fade", &settings.ContactShadowDepthFade, 0.0f, 1.0f, "%.3f", ImGuiSliderFlags_AlwaysClamp); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text("Depth-delta multiplier for shadow falloff. Larger = shadows truncate sooner behind thick occluders."); } - ImGui::SliderFloat("Min Light Intensity", &settings.ContactShadowMinIntensity, 0.0f, 1.0f, "%.2f"); + ImGui::SliderFloat("Min Light Intensity", &settings.ContactShadowMinIntensity, 0.0f, 1.0f, "%.2f", ImGuiSliderFlags_AlwaysClamp); if (auto _tt = Util::HoverTooltipWrapper()) { ImGui::Text( "Skip contact shadows for CLUSTERED lights whose normalized distance falloff " diff --git a/src/Features/LightLimitFix.h b/src/Features/LightLimitFix.h index cc07a4bc6d..375019d2b5 100644 --- a/src/Features/LightLimitFix.h +++ b/src/Features/LightLimitFix.h @@ -110,12 +110,12 @@ struct LightLimitFix : OverlayFeature // Compile-time size lock catches CPU/GPU cbuffer layout drift. STATIC_ASSERT_ALIGNAS_16 // only enforces the 16-byte alignment / multiple-of-16 contract that HLSL constant // buffers require; it doesn't notice if a field is added, removed, or resized in a - // way that still happens to land on a 16-byte boundary. The shader's PerFrameLLF - // cbuffer declaration must mirror this layout exactly, so any change here without - // the corresponding shader update is a silent bug. Update both sides when the layout - // changes, then bump this constant. + // way that still happens to land on a 16-byte boundary. The shader-side mirror is + // SharedData::LightLimitFixSettings in package/Shaders/Common/SharedData.hlsli + // (embedded in the shared FeatureData cbuffer at b6), and must match this layout + // field-for-field. Update both sides when the layout changes, then bump this constant. static_assert(sizeof(PerFrame) == 64, - "LightLimitFix::PerFrame layout drifted -- update the shader-side PerFrameLLF cbuffer to match, then update this assert."); + "LightLimitFix::PerFrame layout drifted -- update SharedData::LightLimitFixSettings in package/Shaders/Common/SharedData.hlsli to match, then update this assert."); PerFrame GetCommonBufferData();