Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
21 changes: 5 additions & 16 deletions src/Features/InverseSquareLighting.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "InverseSquareLighting.h"
#include "Features/InverseSquareLighting/Common.h"
#include "Features/InverseSquareLighting/RadiusMath.h"
#include "LightLimitFix.h"
#include <numbers>

Expand Down Expand Up @@ -75,8 +76,8 @@ void InverseSquareLighting::ProcessLight(LightLimitFix::LightData& light, RE::BS
light.radius = CalculateRadius(intensity, ShadowCasterManager::IsShadowLightType(bsLight), runtimeData->cutoffOverride, runtimeData->size);
runtimeData->radius = light.radius;
light.invRadius = 1.f / light.radius;
light.fadeZone = 1.f / (light.radius * std::clamp(FadeZoneBase * light.invRadius, 0.f, 1.f));
light.sizeBias = ScaledUnitsSq * runtimeData->size * runtimeData->size * 0.5f;
light.fadeZone = 1.f / (light.radius * std::clamp(ISLMath::FadeZoneBase * light.invRadius, 0.f, 1.f));
light.sizeBias = ISLMath::ScaledUnitsSq * runtimeData->size * runtimeData->size * 0.5f;
// light.color *= intensity;
light.fade = intensity;
} else {
Expand All @@ -89,24 +90,12 @@ void InverseSquareLighting::ProcessLight(LightLimitFix::LightData& light, RE::BS

float InverseSquareLighting::CalculateRadius(const float intensity, const bool shadowCaster, const float cutoffOverride, const float size)
{
float cutoff = shadowCaster ? DefaultShadowCasterCutoff : DefaultCutoff;
cutoff = cutoffOverride == 1.f ? cutoff : cutoffOverride;
const float radius = std::sqrt(ScaledUnitsSq * ((2 * intensity - cutoff * size * size) / (2 * cutoff)));
return isnan(radius) ? 1.f : radius;
}

inline float InverseSquareLighting::SmoothStep(const float edge0, const float edge1, const float x)
{
const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t);
return ISLMath::CalculateRadius(intensity, shadowCaster, cutoffOverride, size);
}

float InverseSquareLighting::GetAttenuation(const float distance, const float radius, const float size)
{
const float attenuation = ScaledUnitsSq / (distance * distance + ScaledUnitsSq * size * size / 2);
const float fadeZone = std::clamp(FadeZoneBase / radius, 0.0f, 1.0f);
const float fade = SmoothStep(0, radius * fadeZone, radius - distance);
return attenuation * fade;
return ISLMath::GetAttenuation(distance, radius, size);
}

float InverseSquareLighting::BSLight_GetLuminance::thunk(RE::BSLight* bsLight, RE::NiPoint3* targetPosition, RE::NiLight* refLight)
Expand Down
11 changes: 1 addition & 10 deletions src/Features/InverseSquareLighting.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,7 @@ struct InverseSquareLighting : Feature
private:
LightEditor editor = LightEditor();

static constexpr float DefaultCutoff = 0.05f;
static constexpr float DefaultShadowCasterCutoff = 0.022f;

static constexpr float Scale = 0.8f;
static constexpr float MetresToUnits = 70.f;
static constexpr float MetresToUnitsSq = MetresToUnits * MetresToUnits;
static constexpr float ScaledUnitsSq = Scale * MetresToUnitsSq;
static constexpr float FadeZoneBase = 4.5f * Scale * MetresToUnits;
// Constants + math live in RadiusMath.h (ISLMath) for unit testing.

static void SetExtLightData(RE::NiLight* niLight, const RE::TESObjectLIGH* ligh);

static inline float SmoothStep(float edge0, float edge1, float x);
};
46 changes: 46 additions & 0 deletions src/Features/InverseSquareLighting/RadiusMath.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#pragma once

#include <algorithm>
#include <cmath>

// Pure ISL radius/attenuation math, extracted so it can be unit-tested without
// the game/RE runtime. InverseSquareLighting's static methods delegate here.
namespace ISLMath
{
inline constexpr float DefaultCutoff = 0.05f;
inline constexpr float DefaultShadowCasterCutoff = 0.022f;

inline constexpr float Scale = 0.8f;
inline constexpr float MetresToUnits = 70.f;
inline constexpr float MetresToUnitsSq = MetresToUnits * MetresToUnits;
inline constexpr float ScaledUnitsSq = Scale * MetresToUnitsSq;
inline constexpr float FadeZoneBase = 4.5f * Scale * MetresToUnits;

inline float SmoothStep(float edge0, float edge1, float x)
{
const float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f);
return t * t * (3.0f - 2.0f * t);
}

// Radius at which the inverse-square falloff reaches `cutoff`. A cutoffOverride
// of exactly 1.0 means "use the default"; anything else overrides it. Any
// degenerate result clamps to 1.0 so a light never gets a bad radius: a
// negative sqrt argument yields NaN, an exact-zero argument yields 0 (callers
// divide by radius, so 0 would drive a 0/0 SmoothStep), and a zero
// cutoffOverride yields +inf -- a finite-and-positive check guards all three.
inline float CalculateRadius(float intensity, bool shadowCaster, float cutoffOverride, float size)
{
float cutoff = shadowCaster ? DefaultShadowCasterCutoff : DefaultCutoff;
cutoff = cutoffOverride == 1.f ? cutoff : cutoffOverride;
const float radius = std::sqrt(ScaledUnitsSq * ((2 * intensity - cutoff * size * size) / (2 * cutoff)));
return std::isfinite(radius) && radius > 0.0f ? radius : 1.0f;
}

inline float GetAttenuation(float distance, float radius, float size)
{
const float attenuation = ScaledUnitsSq / (distance * distance + ScaledUnitsSq * size * size / 2);
const float fadeZone = std::clamp(FadeZoneBase / radius, 0.0f, 1.0f);
const float fade = SmoothStep(0, radius * fadeZone, radius - distance);
return attenuation * fade;
Comment thread
alandtse marked this conversation as resolved.
}
}
3 changes: 2 additions & 1 deletion src/Features/LightLimitFix.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#include "LightLimitFix.h"
#include "Features/InverseSquareLighting/Common.h"
#include "Features/LightLimitFix/SettingsSanitize.h"
#include "Globals.h"
#include "InverseSquareLighting.h"
#include "LinearLighting.h"
Expand Down Expand Up @@ -380,7 +381,7 @@ LightLimitFix::PerFrame LightLimitFix::GetCommonBufferData()
// 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;
return LightLimitFixSanitize::SanitizeFloat(v, lo, hi);
};

PerFrame perFrame{};
Expand Down
24 changes: 24 additions & 0 deletions src/Features/LightLimitFix/SettingsSanitize.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#pragma once

#include <algorithm>
#include <cmath>

// Pure settings-sanitization helper extracted from LightLimitFix so it can be
// unit-tested without the game/RE runtime.
namespace LightLimitFixSanitize
{
// Clamp a user/config float to [lo, hi]. std::clamp passes NaN through
// unchanged (every NaN comparison is false), which would let a non-finite
// value reach the GPU and cause divisions / infinite loops / corruption, so
// reject non-finite inputs explicitly and fall back to the lower bound for
// degraded-but-stable behavior.
//
// Precondition: lo and hi are finite with lo <= hi. Only the value is
// untrusted; the bounds are compile-time constants at every call site (the
// feature's fixed setting ranges), so they are not re-validated here -- a
// NaN lo would defeat the fallback and hi < lo is std::clamp UB.
inline float SanitizeFloat(float v, float lo, float hi)
{
return std::isfinite(v) ? std::clamp(v, lo, hi) : lo;
}
}
9 changes: 1 addition & 8 deletions src/Features/LightLimitFix/ShadowCasterManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,7 @@ namespace ShadowCasterManager

static float ComputeFrameTimePercentile90()
{
if (s_ftCount == 0)
return 16.67f; // fallback: 60 fps target
const int n = std::min(s_ftCount, kFrameWindow);
float tmp[kFrameWindow];
std::copy(s_ftRing, s_ftRing + n, tmp);
const int idx = static_cast<int>(n * 0.9f);
std::nth_element(tmp, tmp + idx, tmp + n);
return tmp[idx];
return FrameTimePercentile90(s_ftRing, s_ftCount);
}
Comment thread
alandtse marked this conversation as resolved.

// Maximum ShadowLightCount the installed infrastructure supports.
Expand Down
6 changes: 3 additions & 3 deletions src/Features/LightLimitFix/ShadowCasterManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
#include "RE/B/BSShadowLight.h"
#include "RE/S/ShadowSceneNode.h"

#include "Features/LightLimitFix/ShadowCasterMath.h"

struct ImVec4;

namespace ShadowCasterManager
Expand Down Expand Up @@ -92,10 +94,8 @@ namespace ShadowCasterManager
std::uint32_t idx = 0;
while (idx < maxIdx) {
RE::BSShadowLight* light = accum[idx];
if (!light)
break;
const auto raw = reinterpret_cast<std::uintptr_t>(light);
if (raw >= 0x0000800000000000ull || (raw & 0x7) != 0)
if (!IsPlausibleShadowLightPtr(raw)) // null / misaligned / non-canonical
break;
fn(light);
const std::uint32_t step = light->shadowMapCount;
Expand Down
41 changes: 41 additions & 0 deletions src/Features/LightLimitFix/ShadowCasterMath.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#pragma once

#include <algorithm>
#include <cstdint>

// Pure helpers extracted from ShadowCasterManager so they can be unit-tested
// without the game/RE runtime.
namespace ShadowCasterManager
{
// A shadow-light accumulator slot can hold heap garbage between our prepass
// and the engine's read. Treat a pointer as plausible only if it is at or
// above the low 64 KiB (the Windows x64 null-guard region is never a valid
// user mapping, so a near-null garbage value like 0x8 must be rejected --
// dereferencing it is a guaranteed AV/CTD), inside the x64 user-mode
// canonical range, and 8-byte aligned (BSShadowLight is pointer-aligned).
// ForEachShadowLight stops iterating at the first implausible entry rather
// than dereferencing garbage.
inline bool IsPlausibleShadowLightPtr(std::uintptr_t raw) noexcept
{
return raw >= 0x10000ull && raw < 0x0000800000000000ull && (raw & 0x7) == 0;
}
Comment thread
alandtse marked this conversation as resolved.

// 90th-percentile of the most-recent min(count, Window) frame-time samples
// in `ring`. Percentile is order-independent, so the first `n` entries are
// sampled directly (ring head/wraparound doesn't matter). Returns the 60fps
// fallback (16.67 ms) before any samples exist. A non-positive count (no
// samples, or a corrupt/negative value) takes the fallback -- a negative n
// would otherwise drive std::copy / std::nth_element out of bounds.
template <int Window>
inline float FrameTimePercentile90(const float (&ring)[Window], int count)
{
if (count <= 0)
return 16.67f;
const int n = std::min(count, Window);
float tmp[Window];
std::copy(ring, ring + n, tmp);
const int idx = static_cast<int>(n * 0.9f);
std::nth_element(tmp, tmp + idx, tmp + n);
return tmp[idx];
}
}
3 changes: 3 additions & 0 deletions tests/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ add_executable(cpp_tests
test_sphericalharmonics.cpp
test_input.cpp
test_stringutils.cpp
test_isl_radiusmath.cpp
test_shadowcaster_math.cpp
test_llf_sanitize.cpp
# Compile the unit-under-test directly into the test binary so we don't
# depend on the plugin DLL build (which pulls in FFX/Streamline/etc.).
"${CMAKE_SOURCE_DIR}/src/Utils/Subrect.cpp"
Expand Down
71 changes: 71 additions & 0 deletions tests/cpp/test_isl_radiusmath.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Unit tests for ISL radius/attenuation math (RadiusMath.h).

#include "Features/InverseSquareLighting/RadiusMath.h"

#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>

using Catch::Approx;

TEST_CASE("SmoothStep is a clamped Hermite ramp", "[isl]")
{
REQUIRE(ISLMath::SmoothStep(0.0f, 1.0f, 0.0f) == Approx(0.0f));
REQUIRE(ISLMath::SmoothStep(0.0f, 1.0f, 1.0f) == Approx(1.0f));
REQUIRE(ISLMath::SmoothStep(0.0f, 1.0f, 0.5f) == Approx(0.5f)); // t^2(3-2t) at 0.5
// Clamps outside [edge0, edge1].
REQUIRE(ISLMath::SmoothStep(0.0f, 1.0f, -1.0f) == Approx(0.0f));
REQUIRE(ISLMath::SmoothStep(0.0f, 1.0f, 2.0f) == Approx(1.0f));
}

TEST_CASE("CalculateRadius matches the closed form for default cutoff", "[isl]")
{
// cutoffOverride == 1 -> default cutoff (0.05 for non-shadow); size 0.
// radius = sqrt(ScaledUnitsSq * (2*intensity / (2*0.05))) = sqrt(3920 * 20) = 280.
REQUIRE(ISLMath::CalculateRadius(1.0f, false, 1.0f, 0.0f) == Approx(280.0f));
}

TEST_CASE("Shadow casters get a larger radius (smaller cutoff)", "[isl]")
{
const float normal = ISLMath::CalculateRadius(1.0f, false, 1.0f, 0.0f);
const float shadow = ISLMath::CalculateRadius(1.0f, true, 1.0f, 0.0f);
REQUIRE(shadow > normal);
}

TEST_CASE("cutoffOverride replaces the default regardless of shadow flag", "[isl]")
{
// Override of 0.05 forces the non-shadow default value even for a shadow caster.
REQUIRE(ISLMath::CalculateRadius(1.0f, true, 0.05f, 0.0f) == Approx(280.0f));
}

TEST_CASE("CalculateRadius clamps degenerate results to 1", "[isl]")
{
// NaN: intensity 0 with a large size makes the sqrt argument negative.
REQUIRE(ISLMath::CalculateRadius(0.0f, false, 1.0f, 10.0f) == Approx(1.0f));
// Exact zero: 2*intensity == cutoff*size^2 (0.05 * 2^2 = 0.2, intensity 0.1)
// makes the sqrt argument 0. A 0 radius would drive a 0/0 SmoothStep downstream.
REQUIRE(ISLMath::CalculateRadius(0.1f, false, 1.0f, 2.0f) == Approx(1.0f));
// +inf: a zero cutoffOverride divides by 2*cutoff == 0. isnan() alone misses
// this (sqrt(+inf) is +inf, not NaN); the finite check catches it. The cutoff
// is read through a volatile so MSVC can't constant-fold the literal /0 into a
// compile-time C4723 -- at runtime the float divide yields +inf as intended.
volatile float zeroCutoff = 0.0f;
REQUIRE(ISLMath::CalculateRadius(1.0f, false, zeroCutoff, 0.0f) == Approx(1.0f));
}

TEST_CASE("GetAttenuation peaks at the source and vanishes past the radius", "[isl]")
{
// distance 0, radius 280, size 1: attenuation = 3920/(0 + 3920/2) = 2.0;
// fade is 1 at the source.
REQUIRE(ISLMath::GetAttenuation(0.0f, 280.0f, 1.0f) == Approx(2.0f));
// Beyond the radius the SmoothStep fade clamps to zero.
REQUIRE(ISLMath::GetAttenuation(300.0f, 280.0f, 1.0f) == Approx(0.0f));
}

TEST_CASE("GetAttenuation decreases monotonically with distance in range", "[isl]")
{
// 'near'/'far' are legacy Windows.h macros, so use distinct names.
const float attNear = ISLMath::GetAttenuation(10.0f, 280.0f, 1.0f);
const float attFar = ISLMath::GetAttenuation(150.0f, 280.0f, 1.0f);
REQUIRE(attNear > attFar);
REQUIRE(attFar > 0.0f);
}
31 changes: 31 additions & 0 deletions tests/cpp/test_llf_sanitize.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Unit tests for the LightLimitFix settings sanitizer
// (src/Features/LightLimitFix/SettingsSanitize.h).

#include "Features/LightLimitFix/SettingsSanitize.h"

#include <catch2/catch_approx.hpp>
#include <catch2/catch_test_macros.hpp>

#include <cmath>
#include <limits>

using Catch::Approx;
using LightLimitFixSanitize::SanitizeFloat;

TEST_CASE("SanitizeFloat clamps in-range, low, and high inputs", "[llf]")
{
REQUIRE(SanitizeFloat(0.5f, 0.0f, 1.0f) == Approx(0.5f));
REQUIRE(SanitizeFloat(-1.0f, 0.0f, 1.0f) == Approx(0.0f));
REQUIRE(SanitizeFloat(2.0f, 0.0f, 1.0f) == Approx(1.0f));
REQUIRE(SanitizeFloat(64.0f, 64.0f, 4096.0f) == Approx(64.0f)); // exact lower bound
REQUIRE(SanitizeFloat(4096.0f, 64.0f, 4096.0f) == Approx(4096.0f)); // exact upper bound
}

TEST_CASE("SanitizeFloat falls back to the lower bound on non-finite input", "[llf]")
{
const float nan = std::numeric_limits<float>::quiet_NaN();
const float inf = std::numeric_limits<float>::infinity();
REQUIRE(SanitizeFloat(nan, 0.5f, 8.0f) == Approx(0.5f));
REQUIRE(SanitizeFloat(inf, 0.5f, 8.0f) == Approx(0.5f));
REQUIRE(SanitizeFloat(-inf, 0.5f, 8.0f) == Approx(0.5f));
}
Loading
Loading