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..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) @@ -69,15 +67,28 @@ namespace LightLimitFix #endif } + // 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; + + // 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; - float2 depthDeltaMult = float2(0.20, 0.05); - - // Extend contact shadow distance - lightDirectionVS *= 2.0; + // 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; @@ -99,8 +110,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..72ad97d490 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,19 +2742,43 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) float contactShadow = 1.0; # if defined(DEFERRED) - [branch] if ( - SharedData::lightLimitFixSettings.EnableContactShadows && - !(light.lightFlags & LightLimitFix::LightFlags::Simple) && - shadowComponent != 0.0 && - lightAngle > 0.0) + // 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) { - // 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. - 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); + // 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) + float falloffFactor = saturate(lightDist * light.invRadius); + passesIntensityGate = (1.0 - falloffFactor * falloffFactor) > + SharedData::lightLimitFixSettings.ContactShadowMinIntensity; +# else + passesIntensityGate = intensityMultiplier > + SharedData::lightLimitFixSettings.ContactShadowMinIntensity; +# endif + } + + [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 diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index a18f52361c..ed1f28d529 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -8,6 +8,21 @@ #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, + ContactShadowMaxSteps, + ContactShadowMaxDistance, + ContactShadowStride, + ContactShadowThickness, + ContactShadowDepthFade, + ContactShadowMinIntensity) + static constexpr uint CLUSTER_MAX_LIGHTS = 128; static constexpr uint MAX_LIGHTS = 1024; @@ -29,6 +44,53 @@ 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")) { + // 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."); + } + + // 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", 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", 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", 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", ImGuiSliderFlags_AlwaysClamp); + if (auto _tt = Util::HoverTooltipWrapper()) { + 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(); + } + /////////////////////////////// ImGui::SeparatorText("Debug"); @@ -73,8 +135,29 @@ void LightLimitFix::DrawOverlay() 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 (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 + // at the shader boundary rather than trusting upstream callers. + // + // std::clamp passes NaN through unchanged (every NaN comparison is false), + // 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; + }; + PerFrame perFrame{}; perFrame.EnableContactShadows = settings.EnableContactShadows; + perFrame.ContactShadowMaxSteps = std::clamp(settings.ContactShadowMaxSteps, 1u, 16u); + 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); @@ -186,6 +269,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 a1569f2de9..375019d2b5 100644 --- a/src/Features/LightLimitFix.h +++ b/src/Features/LightLimitFix.h @@ -95,12 +95,27 @@ 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); + // 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-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 SharedData::LightLimitFixSettings in package/Shaders/Common/SharedData.hlsli to match, then update this assert."); PerFrame GetCommonBufferData(); @@ -149,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; @@ -171,6 +188,20 @@ 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 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; };