Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[Info]
Version = 3-1-0
Version = 3-2-0

[Nexus]
autoupload = false
47 changes: 29 additions & 18 deletions features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
Comment thread
alandtse marked this conversation as resolved.
Comment thread
alandtse marked this conversation as resolved.

// Offset starting position with interleaved gradient noise
viewPosition += lightDirectionVS * noise2D;
Expand All @@ -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;
}
Expand Down
8 changes: 8 additions & 0 deletions package/Shaders/Common/SharedData.hlsli
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down
51 changes: 38 additions & 13 deletions package/Shaders/Lighting.hlsl
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
90 changes: 90 additions & 0 deletions src/Features/LightLimitFix.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -29,6 +44,50 @@ 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.");
}

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 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");
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 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");

Expand Down Expand Up @@ -73,8 +132,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,
Comment thread
alandtse marked this conversation as resolved.
// 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<uint32_t>(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);
Expand Down Expand Up @@ -186,6 +266,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) {
Expand Down
33 changes: 32 additions & 1 deletion src/Features/LightLimitFix.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
alandtse marked this conversation as resolved.
// 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.");
Comment thread
alandtse marked this conversation as resolved.
Outdated

PerFrame GetCommonBufferData();

Expand Down Expand Up @@ -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;
Expand All @@ -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;
};
Expand Down
Loading