diff --git a/extern/CommonLibSSE-NG b/extern/CommonLibSSE-NG index 8c4025b01f..efacfb90cc 160000 --- a/extern/CommonLibSSE-NG +++ b/extern/CommonLibSSE-NG @@ -1 +1 @@ -Subproject commit 8c4025b01fac2bea1bbe73a3a9da7b4fde338343 +Subproject commit efacfb90cc7a7eb3712bb39651c3869beff3ab82 diff --git a/features/Light Limit Fix/Shaders/LightLimitFix/Common.hlsli b/features/Light Limit Fix/Shaders/LightLimitFix/Common.hlsli index 4baa0e8389..1884d76388 100644 --- a/features/Light Limit Fix/Shaders/LightLimitFix/Common.hlsli +++ b/features/Light Limit Fix/Shaders/LightLimitFix/Common.hlsli @@ -17,6 +17,7 @@ namespace LightFlags static const uint Disabled = (1 << 9); static const uint InverseSquare = (1 << 10); static const uint Linear = (1 << 11); + static const uint Particle = (1 << 12); } struct ClusterAABB diff --git a/package/Shaders/Common/SharedData.hlsli b/package/Shaders/Common/SharedData.hlsli index 8530f295d6..78df0422fd 100644 --- a/package/Shaders/Common/SharedData.hlsli +++ b/package/Shaders/Common/SharedData.hlsli @@ -88,7 +88,8 @@ namespace SharedData // Debug (last) uint EnableLightsVisualisation; uint LightsVisualisationMode; - uint2 pad0; + uint EnableParticleContactShadows; + uint pad0; }; struct WetnessEffectsSettings diff --git a/package/Shaders/Lighting.hlsl b/package/Shaders/Lighting.hlsl index 09266cf2a6..73cd29e4e6 100644 --- a/package/Shaders/Lighting.hlsl +++ b/package/Shaders/Lighting.hlsl @@ -2801,8 +2801,15 @@ PS_OUTPUT main(PS_INPUT input, bool frontFace : SV_IsFrontFace) # endif } + // Particle lights carry both Simple and Particle bits. Simple-only lights are + // clustered fallbacks (no real emitter) and never trace; particle lights trace + // only when the user opted in via EnableParticleContactShadows. + const bool isParticleLight = (light.lightFlags & LightLimitFix::LightFlags::Particle) != 0; + const bool canShadow = isParticleLight ? + SharedData::lightLimitFixSettings.EnableParticleContactShadows : + !(light.lightFlags & LightLimitFix::LightFlags::Simple); [branch] if ( - !(light.lightFlags & LightLimitFix::LightFlags::Simple) && + canShadow && shadowComponent != 0.0 && lightAngle > 0.0 && passesIntensityGate) diff --git a/src/Features/LightLimitFix.cpp b/src/Features/LightLimitFix.cpp index 52d62cbfee..e7555b1904 100644 --- a/src/Features/LightLimitFix.cpp +++ b/src/Features/LightLimitFix.cpp @@ -1,5 +1,7 @@ #include "LightLimitFix.h" +#include "Globals.h" #include "InverseSquareLighting.h" +#include "Features/InverseSquareLighting/Common.h" #include "LinearLighting.h" #include "Utils/UI.h" @@ -10,11 +12,74 @@ #include "Util.h" #include "Utils/ExternalEmittance.h" +#include +#include +#include +#include + +namespace +{ + constexpr float kParticleLightsSaturationMin = 1.0f; + constexpr float kParticleLightsSaturationMax = 2.0f; + constexpr float kParticleBrightnessMin = 0.0f; + constexpr float kParticleBrightnessMax = 10.0f; + constexpr float kParticleRadiusMin = 0.0f; + constexpr float kParticleRadiusMax = 10.0f; + constexpr float kBillboardBrightnessMin = 0.0f; + constexpr float kBillboardBrightnessMax = 10.0f; + constexpr float kBillboardRadiusMin = 0.0f; + constexpr float kBillboardRadiusMax = 10.0f; + constexpr float kParticleClusterThresholdMin = 8.0f; + constexpr float kParticleClusterThresholdMax = 128.0f; + constexpr int kMaxParticlesPerEmitterMin = 32; + constexpr int kMaxParticlesPerEmitterMax = 2048; + constexpr float kMaxParticleDistanceMin = 0.0f; + constexpr float kMaxParticleDistanceMax = 20000.0f; + constexpr float kJsonPlacedLightIntensityMin = 0.0f; + constexpr float kJsonPlacedLightIntensityMax = 8.0f; + + float ClampFiniteOrDefault(float a_value, float a_min, float a_max, float a_default) + { + if (!std::isfinite(a_value)) + return a_default; + return std::clamp(a_value, a_min, a_max); + } + + void SanitizeSettings(LightLimitFix::Settings& a_settings) + { + a_settings.ParticleLightsSaturation = + ClampFiniteOrDefault(a_settings.ParticleLightsSaturation, kParticleLightsSaturationMin, kParticleLightsSaturationMax, 1.0f); + a_settings.ParticleBrightness = + ClampFiniteOrDefault(a_settings.ParticleBrightness, kParticleBrightnessMin, kParticleBrightnessMax, 1.0f); + a_settings.ParticleRadius = + ClampFiniteOrDefault(a_settings.ParticleRadius, kParticleRadiusMin, kParticleRadiusMax, 1.0f); + a_settings.BillboardBrightness = + ClampFiniteOrDefault(a_settings.BillboardBrightness, kBillboardBrightnessMin, kBillboardBrightnessMax, 1.0f); + a_settings.BillboardRadius = + ClampFiniteOrDefault(a_settings.BillboardRadius, kBillboardRadiusMin, kBillboardRadiusMax, 1.0f); + a_settings.ParticleClusterThreshold = + ClampFiniteOrDefault(a_settings.ParticleClusterThreshold, kParticleClusterThresholdMin, kParticleClusterThresholdMax, 32.0f); + a_settings.MaxParticlesPerEmitter = std::clamp(a_settings.MaxParticlesPerEmitter, kMaxParticlesPerEmitterMin, kMaxParticlesPerEmitterMax); + a_settings.MaxParticleDistance = + ClampFiniteOrDefault(a_settings.MaxParticleDistance, kMaxParticleDistanceMin, kMaxParticleDistanceMax, 6000.0f); + a_settings.JsonPlacedLightIntensity = + ClampFiniteOrDefault(a_settings.JsonPlacedLightIntensity, kJsonPlacedLightIntensityMin, kJsonPlacedLightIntensityMax, 1.0f); + } + + void ClearStrictLightData(LightLimitFix::StrictLightDataCB& a_data, bool a_resetRoomIndex) noexcept + { + a_data.NumStrictLights = 0; + a_data.ShadowBitMask = 0; + if (a_resetRoomIndex) + a_data.RoomIndex = -1; + } +} + // Debug visualisation state (EnableLightsVisualisation / LightsVisualisationMode) -// is intentionally NOT in Settings -- it lives as instance members on the -// LightLimitFix class so it resets per session and can't accidentally end -// up in a shipped JSON config that would force every load to compile the -// heavier LLFDEBUG shader permutation. +// is intentionally NOT serialized -- it lives as instance members on the +// LightLimitFix class so it resets per session and can't accidentally end up in +// a shipped JSON config that would force every load to compile the heavier +// LLFDEBUG shader permutation. NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( LightLimitFix::Settings, EnableContactShadows, @@ -25,7 +90,23 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( ContactShadowDepthFade, ContactShadowMinIntensity, ShowShadowOverlay, - ShadowSettings) + ShadowSettings, + EnableParticleContactShadows, + EnableParticleLights, + EnableParticleLightsCulling, + EnableParticleLightsDetection, + EnableParticleLightsOptimization, + ParticleLightsSaturation, + ParticleBrightness, + ParticleRadius, + BillboardBrightness, + BillboardRadius, + ParticleClusterThreshold, + MaxParticlesPerEmitter, + MaxParticleDistance, + JsonPlacedLightIntensity, + JsonPlacedLightsInteriorsOnly, + JsonPlacedLightsPortalStrictOnly) void LightLimitFix::DrawSettings() { @@ -33,6 +114,12 @@ void LightLimitFix::DrawSettings() ShadowCasterManager::DrawSettings(settings.ShadowSettings); + if (ImGui::TreeNodeEx("Statistics", ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Text(std::format("Clustered Light Count : {}", lightCount).c_str()); + ImGui::Text(std::format("Particle Lights Count : {}", currentParticleLights.size()).c_str()); + ImGui::TreePop(); + } + // ---- Active Shadow Casters -------------------------------------- // One cohesive section: overlay toggle, then ALL the stats grouped // together (summary + scheduler stats + budget verdict), then the @@ -56,7 +143,7 @@ void LightLimitFix::DrawSettings() ShadowCasterManager::DrawShadowLightTable(true, false); /////////////////////////////// - ImGui::SeparatorText("Shadows"); + ImGui::SeparatorText("Contact Shadows"); ImGui::Checkbox("Enable Contact Shadows", &settings.EnableContactShadows); if (auto _tt = Util::HoverTooltipWrapper()) { @@ -110,6 +197,124 @@ void LightLimitFix::DrawSettings() ImGui::TreePop(); } + ImGui::BeginDisabled(!settings.EnableContactShadows); + ImGui::Checkbox("Enable Particle Contact Shadows", &settings.EnableParticleContactShadows); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Also cast contact shadows from particle lights. Larger performance impact in fire/magic-heavy scenes."); + } + ImGui::EndDisabled(); + + ImGui::SeparatorText("Particle Lights"); + + ImGui::TextWrapped( + "Turns configured particle effects (candles, braziers, torches, magic) into dynamic lights. " + "Requires a particle-light config pack shipping Data\\ParticleLights\\*.ini (e.g. Embers HD, " + "Lanterns of Skyrim); with no pack installed this section has no effect."); + ImGui::TextWrapped( + "Particle lights are additive emitters and do NOT cast shadow-map shadows, so they never appear " + "in the shadow caster table above. Turn on \"Enable Particle Contact Shadows\" in the Contact " + "Shadows section for short screen-space contact shadows."); + ImGui::Spacing(); + + ImGui::Checkbox("Enable Particle Lights", &settings.EnableParticleLights); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Master toggle for the particle-light feature."); + } + + if (ImGui::TreeNode("Performance##particles")) { + ImGui::Checkbox("Enable Culling", &settings.EnableParticleLightsCulling); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Significantly improves performance by not rendering empty textures. Only disable if you are encountering issues."); + } + + ImGui::Checkbox("Enable Detection", &settings.EnableParticleLightsDetection); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Adds particle lights to the player light level so that NPCs detect them for stealth and gameplay."); + } + + ImGui::Checkbox("Enable Optimization", &settings.EnableParticleLightsOptimization); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Merges vertices which are close enough to each other to improve performance."); + } + + ImGui::SliderFloat("Cluster Threshold", &settings.ParticleClusterThreshold, kParticleClusterThresholdMin, kParticleClusterThresholdMax, "%.1f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text( + "Distance+radius similarity threshold for merging particles into one light.\n" + "Higher = more merging, better performance, blurrier lights.\n" + "Lower = less merging, more precise, more expensive."); + } + + ImGui::SliderInt("Max Particles per Emitter", &settings.MaxParticlesPerEmitter, kMaxParticlesPerEmitterMin, kMaxParticlesPerEmitterMax); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text( + "Maximum number of particles sampled per emitter per frame.\n" + "Higher = closer to the real particle system but more CPU work.\n" + "Lower = faster, especially for very dense effects."); + } + + ImGui::SliderFloat("Max Particle Distance", &settings.MaxParticleDistance, 1000.0f, kMaxParticleDistanceMax, "%.0f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text( + "Particle lights beyond this distance from the camera are skipped entirely.\n" + "Lower = better performance, but distant effects won't contribute light.\n" + "Higher = more distant particle lighting, but more cost."); + } + + ImGui::TreePop(); + } + + if (ImGui::TreeNode("Appearance##particles")) { + ImGui::SliderFloat("Saturation", &settings.ParticleLightsSaturation, kParticleLightsSaturationMin, kParticleLightsSaturationMax, "%.2f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Color saturation of particle/billboard lights. 1.0 = source color; higher = more vivid."); + } + ImGui::SliderFloat("Particle Brightness", &settings.ParticleBrightness, kParticleBrightnessMin, kParticleBrightnessMax, "%.2f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Intensity multiplier for particle-system emitters (fire, sparks, magic)."); + } + ImGui::SliderFloat("Particle Radius", &settings.ParticleRadius, kParticleRadiusMin, kParticleRadiusMax, "%.2f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Radius multiplier for particle-system emitters. Larger = light reaches further."); + } + ImGui::SliderFloat("Billboard Brightness", &settings.BillboardBrightness, kBillboardBrightnessMin, kBillboardBrightnessMax, "%.2f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Intensity multiplier for billboard (single-quad) emitters such as candle flames."); + } + ImGui::SliderFloat("Billboard Radius", &settings.BillboardRadius, kBillboardRadiusMin, kBillboardRadiusMax, "%.2f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text("Radius multiplier for billboard emitters. Larger = light reaches further."); + } + + ImGui::TreePop(); + } + + ImGui::SeparatorText("Placed Lights (JSON)"); + + ImGui::TextWrapped( + "Scales the intensity of runtime lights attached from Light records by Light Placer-style mods. " + "Separate from particle lights; requires Inverse Square Lighting for the runtime metadata."); + ImGui::Spacing(); + + { + const bool jsonPlacedLightsSupported = globals::features::inverseSquareLighting.loaded; + ImGui::BeginDisabled(!jsonPlacedLightsSupported); + ImGui::SliderFloat("Intensity Scale", &settings.JsonPlacedLightIntensity, kJsonPlacedLightIntensityMin, kJsonPlacedLightIntensityMax, "%.2f"); + if (auto _tt = Util::HoverTooltipWrapper()) { + ImGui::Text( + "Scales intensity for attached runtime lights generated from Light records.\n" + "Primarily targets Light Placer-style JSON lights.\n" + "Requires Inverse Square Lighting runtime metadata."); + } + + ImGui::Checkbox("Interiors Only", &settings.JsonPlacedLightsInteriorsOnly); + ImGui::Checkbox("Portal Strict Only", &settings.JsonPlacedLightsPortalStrictOnly); + ImGui::EndDisabled(); + + if (!jsonPlacedLightsSupported) + ImGui::TextDisabled("Requires Inverse Square Lighting to identify JSON-placed runtime lights."); + } + /////////////////////////////// ImGui::SeparatorText("Debug"); @@ -187,6 +392,7 @@ LightLimitFix::PerFrame LightLimitFix::GetCommonBufferData() perFrame.ContactShadowDepthFade = sanitizeFloat(settings.ContactShadowDepthFade, 0.0f, 1.0f); perFrame.ContactShadowMinIntensity = sanitizeFloat(settings.ContactShadowMinIntensity, 0.0f, 1.0f); perFrame.ShadowMapSlots = ShadowCasterManager::GetInstalledSlotCount(); + perFrame.EnableParticleContactShadows = settings.EnableContactShadows && settings.EnableParticleContactShadows; std::copy(clusterSize, clusterSize + 3, perFrame.ClusterSize); perFrame.EnableLightsVisualisation = EnableLightsVisualisation; perFrame.LightsVisualisationMode = LightsVisualisationMode; @@ -293,14 +499,33 @@ void LightLimitFix::SetupResources() } } -void LightLimitFix::RestoreDefaultSettings() +void LightLimitFix::Reset() { - settings = {}; + std::lock_guard queueLock{ particleLightsQueueMutex }; + + for (auto& particleLight : currentParticleLights) { + if (!particleLight.node) + continue; + + if (!particleLight.billboard) { + if (const auto particleSystem = static_cast(particleLight.node)) { + if (auto particleData = particleSystem->GetParticlesRuntimeData().particleData.get()) + particleData->DecRefCount(); + } + } + particleLight.node->DecRefCount(); + } + currentParticleLights.clear(); + std::swap(currentParticleLights, queuedParticleLights); + // References are keyed by transient pass geometry pointers; rebuild every frame to avoid stale entries. + particleLightsReferences.clear(); + jsonPlacedLightCache.clear(); } void LightLimitFix::LoadSettings(json& o_json) { settings = o_json; + SanitizeSettings(settings); // iShadowMapResolution:Display is owned by Skyrim's INI, not our JSON. ShadowCasterManager::LoadINISettings(); @@ -311,23 +536,28 @@ void LightLimitFix::LoadSettings(json& o_json) void LightLimitFix::SaveSettings(json& o_json) { + SanitizeSettings(settings); o_json = settings; ShadowCasterManager::SaveINISettings(); } +void LightLimitFix::RestoreDefaultSettings() +{ + settings = {}; + SanitizeSettings(settings); +} + RE::NiNode* GetParentRoomNode(RE::NiAVObject* object) { - if (object == nullptr) { + if (object == nullptr) return nullptr; - } static const auto* roomRtti = REL::Relocation{ RE::NiRTTI_BSMultiBoundRoom }.get(); static const auto* portalRtti = REL::Relocation{ RE::NiRTTI_BSPortalSharedNode }.get(); const auto* rtti = object->GetRTTI(); - if (rtti == roomRtti || rtti == portalRtti) { + if (rtti == roomRtti || rtti == portalRtti) return static_cast(object); - } return GetParentRoomNode(object->parent); } @@ -339,84 +569,120 @@ void LightLimitFix::BSLightingShader_SetupGeometry_Before(RE::BSRenderPass* a_pa if (!shaderCache->IsEnabled()) return; - strictLightDataTemp.NumStrictLights = 0; - strictLightDataTemp.ShadowBitMask = 0; + ClearStrictLightData(strictLightDataTemp, true); + + if (!a_pass || !a_pass->geometry) + return; - strictLightDataTemp.RoomIndex = -1; if (!roomNodes.empty()) { if (RE::NiNode* roomNode = GetParentRoomNode(a_pass->geometry)) { - if (auto it = roomNodes.find(roomNode); it != roomNodes.cend()) { + if (auto it = roomNodes.find(roomNode); it != roomNodes.cend()) strictLightDataTemp.RoomIndex = it->second; - } } } } void LightLimitFix::BSLightingShader_SetupGeometry_GeometrySetupConstantPointLights(RE::BSRenderPass* a_pass) { + if (!a_pass || !a_pass->sceneLights) { + ClearStrictLightData(strictLightDataTemp, false); + return; + } + + auto smState = globals::game::smState; + if (!smState) { + ClearStrictLightData(strictLightDataTemp, false); + return; + } + auto& isl = globals::features::inverseSquareLighting; auto accumulator = *globals::game::currentAccumulator.get(); - bool inWorld = accumulator->GetRuntimeData().activeShadowSceneNode == globals::game::smState->shadowSceneNode[0]; + if (!accumulator) { + ClearStrictLightData(strictLightDataTemp, false); + return; + } - strictLightDataTemp.NumStrictLights = inWorld ? 0 : (a_pass->numLights - 1); + bool inWorld = accumulator->GetRuntimeData().activeShadowSceneNode == smState->shadowSceneNode[0]; + const bool isInterior = Util::IsInterior(); - uint32_t writeIdx = 0; - for (uint32_t i = 0; i < strictLightDataTemp.NumStrictLights; i++) { - auto bsLight = a_pass->sceneLights[i + 1]; - if (!bsLight) - continue; - auto niLight = bsLight->light.get(); - if (!niLight) - continue; + constexpr uint32_t kStrictLightCapacity = 15; + const uint32_t availableSceneLights = a_pass->numLights > 0 ? (a_pass->numLights - 1) : 0; + const uint32_t requestedStrictLights = inWorld ? 0u : availableSceneLights; + const uint32_t strictLightCount = std::min(requestedStrictLights, kStrictLightCapacity); + const uint32_t strictShadowLightCount = std::min(static_cast(a_pass->numShadowLights), availableSceneLights); + RefreshJsonPlacedLightCacheFrame(); - auto& runtimeData = niLight->GetLightRuntimeData(); + ClearStrictLightData(strictLightDataTemp, false); - LightData light{}; - light.color = { runtimeData.diffuse.red, runtimeData.diffuse.green, runtimeData.diffuse.blue }; - light.lightFlags = std::bit_cast(runtimeData.ambient.red); + uint32_t outIndex = 0; +#if defined(_MSC_VER) + __try +#endif + { + for (uint32_t i = 0; i < strictLightCount; i++) { + auto bsLight = a_pass->sceneLights[i + 1]; + if (!bsLight) + continue; + auto niLight = bsLight->light.get(); + if (!niLight) + continue; + + auto& runtimeData = niLight->GetLightRuntimeData(); + + LightData light{}; + light.color = { runtimeData.diffuse.red, runtimeData.diffuse.green, runtimeData.diffuse.blue }; + light.lightFlags = std::bit_cast(runtimeData.ambient.red); + + if (isl.loaded) { + isl.ProcessLight(light, bsLight, niLight); + } else { + light.radius = runtimeData.radius.x; + light.fade = runtimeData.fade; + } - if (isl.loaded) { - isl.ProcessLight(light, bsLight, niLight); - } else { - light.radius = runtimeData.radius.x; - // light.color *= runtimeData.fade; - light.fade = runtimeData.fade; - } - - light.fade *= bsLight->lodDimmer; - - SetLightPosition(light, niLight->world.translate, inWorld); - - if (i < a_pass->numShadowLights) { - auto* shadowLight = static_cast(bsLight); - // Use SCM's stable container-slot index instead of reading the - // live `shadowmapDescriptors[0].shadowmapIndex`. The descriptor - // field can be corrupted mid-frame by ReturnShadowmaps() (called - // via Hook_DisableColorMask) after ScheduleShadowCasters fixed - // it but before this strict-light setup runs -- a stale-but-in - // -range index would still pass an upper-bound check yet point - // strict-light shader sampling at the wrong kSHADOWMAPS slice. - // GetShadowSlot reads from the SCM's own pool (s_lights, set in - // ScheduleShadowCasters and never touched by ReturnShadowmaps), - // so it stays consistent with CopyShadowLightData and - // UpdateLights, which also key off it. Returns -1 for the sun - // or inactive lights; both cases skip setting the Shadow flag. - const int32_t slot = ShadowCasterManager::GetShadowSlot(shadowLight); - if (slot >= 0 && static_cast(slot) < ShadowCasterManager::GetInstalledSlotCount()) { - light.shadowMapIndex = static_cast(slot); - light.lightFlags.set(LightFlags::Shadow); + light.fade *= bsLight->lodDimmer; + const bool isPortalStrict = !IsGlobalLight(bsLight); + ApplyJsonPlacedLightIntensityScale(light, bsLight, niLight, isPortalStrict, isInterior); + + SetLightPosition(light, niLight->world.translate, inWorld); + + if (i < strictShadowLightCount && bsLight->IsShadowLight()) { + auto* shadowLight = static_cast(bsLight); + // Use SCM's stable container-slot index instead of reading the + // live `shadowmapDescriptors[0].shadowmapIndex`. The descriptor + // field can be corrupted mid-frame by ReturnShadowmaps() (called + // via Hook_DisableColorMask) after ScheduleShadowCasters fixed + // it but before this strict-light setup runs -- a stale-but-in + // -range index would still pass an upper-bound check yet point + // strict-light shader sampling at the wrong kSHADOWMAPS slice. + // GetShadowSlot reads from the SCM's own pool (s_lights, set in + // ScheduleShadowCasters and never touched by ReturnShadowmaps), + // so it stays consistent with CopyShadowLightData and + // UpdateLights, which also key off it. Returns -1 for the sun + // or inactive lights; both cases skip setting the Shadow flag. + const int32_t slot = ShadowCasterManager::GetShadowSlot(shadowLight); + if (slot >= 0 && static_cast(slot) < ShadowCasterManager::GetInstalledSlotCount()) { + light.shadowMapIndex = static_cast(slot); + light.lightFlags.set(LightFlags::Shadow); + } } + + strictLightDataTemp.StrictLights[outIndex++] = light; } + strictLightDataTemp.NumStrictLights = outIndex; - strictLightDataTemp.StrictLights[writeIdx++] = light; + // Don't build strictLightDataTemp.ShadowBitMask: no shader reads it (the + // IsLightIgnored bit-mask branch was replaced by per-light shadowMapIndex + // sampling, set inline above). The field stays for cbuffer ABI stability + // and is zero-initialised by ClearStrictLightData. } - strictLightDataTemp.NumStrictLights = writeIdx; - - // Don't reinstate a build loop for strictLightDataTemp.ShadowBitMask: - // no shader reads it (the IsLightIgnored bit-mask branch was replaced by - // per-light shadowMapIndex sampling). The field stays for cbuffer ABI - // stability and is zero-initialised above. +#if defined(_MSC_VER) + __except (1) + { + ClearStrictLightData(strictLightDataTemp, false); + } +#endif } void LightLimitFix::BSLightingShader_SetupGeometry_After(RE::BSRenderPass*) @@ -428,7 +694,12 @@ void LightLimitFix::BSLightingShader_SetupGeometry_After(RE::BSRenderPass*) if (!shaderCache->IsEnabled()) return; + if (!smState || !strictLightDataCB) + return; + auto accumulator = *globals::game::currentAccumulator.get(); + if (!accumulator) + return; auto shadowSceneNode = smState->shadowSceneNode[0]; @@ -454,11 +725,10 @@ void LightLimitFix::SetLightPosition(LightLimitFix::LightData& a_light, RE::NiPo for (int eyeIndex = 0; eyeIndex < eyeCount; eyeIndex++) { RE::NiPoint3 eyePosition; - if (a_cached) { + if (a_cached) eyePosition = eyePositionCached[eyeIndex]; - } else { + else eyePosition = Util::GetEyePosition(eyeIndex); - } auto worldPos = a_initialPosition - eyePosition; a_light.positionWS[eyeIndex].data.x = worldPos.x; @@ -467,6 +737,55 @@ void LightLimitFix::SetLightPosition(LightLimitFix::LightData& a_light, RE::NiPo } } +void LightLimitFix::RefreshJsonPlacedLightCacheFrame() +{ + if (jsonPlacedLightCacheFrameChecker.IsNewFrame()) + jsonPlacedLightCache.clear(); +} + +bool LightLimitFix::IsJsonPlacedLight(RE::BSLight* a_bsLight, RE::NiLight* a_niLight) +{ + if (!a_bsLight || !a_niLight || !a_bsLight->pointLight) + return false; + if (!globals::features::inverseSquareLighting.loaded) + return false; + if (const auto it = jsonPlacedLightCache.find(a_niLight); it != jsonPlacedLightCache.end()) + return it->second; + + bool isJsonPlacedLight = false; + if (const auto ownerRef = a_niLight->GetUserData()) { + if (const auto ownerBase = ownerRef->GetObjectReference(); ownerBase && ownerBase->GetFormType() != RE::FormType::Light) { + const auto runtimeData = ISLCommon::RuntimeLightDataExt::Get(a_niLight); + if (runtimeData && runtimeData->lighFormId != 0) { + const auto lighForm = RE::TESForm::LookupByID(runtimeData->lighFormId); + isJsonPlacedLight = lighForm && lighForm->GetFormType() == RE::FormType::Light; + } + } + } + + jsonPlacedLightCache.insert_or_assign(a_niLight, isJsonPlacedLight); + return isJsonPlacedLight; +} + +void LightLimitFix::ApplyJsonPlacedLightIntensityScale( + LightData& a_light, + RE::BSLight* a_bsLight, + RE::NiLight* a_niLight, + bool a_isPortalStrict, + bool a_isInterior) +{ + if (std::abs(settings.JsonPlacedLightIntensity - 1.0f) <= 1e-4f) + return; + if (settings.JsonPlacedLightsInteriorsOnly && !a_isInterior) + return; + if (settings.JsonPlacedLightsPortalStrictOnly && !a_isPortalStrict) + return; + if (!IsJsonPlacedLight(a_bsLight, a_niLight)) + return; + + a_light.fade *= settings.JsonPlacedLightIntensity; +} + void LightLimitFix::Prepass() { auto context = globals::d3d::context; @@ -495,11 +814,12 @@ bool LightLimitFix::IsValidLight(RE::BSLight* a_light) bool LightLimitFix::IsGlobalLight(RE::BSLight* a_light) { - return !(a_light->portalStrict || !a_light->portalGraph); + return a_light && !(a_light->portalStrict || !a_light->portalGraph); } void LightLimitFix::PostPostLoad() { + particleLights.GetConfigs(); Hooks::Install(); ShadowCasterManager::Init(settings.ShadowSettings); ShadowCasterManager::Install(settings.ShadowSettings); @@ -507,9 +827,12 @@ void LightLimitFix::PostPostLoad() void LightLimitFix::DataLoaded() { - auto iMagicLightMaxCount = globals::game::gameSettingCollection->GetSetting("iMagicLightMaxCount"); - iMagicLightMaxCount->data.i = MAXINT32; - logger::info("[LLF] Unlocked magic light limit"); + if (auto gameSettings = globals::game::gameSettingCollection) { + if (auto iMagicLightMaxCount = gameSettings->GetSetting("iMagicLightMaxCount")) { + iMagicLightMaxCount->data.i = MAXINT32; + logger::info("[LLF] Unlocked magic light limit"); + } + } } void LightLimitFix::ClearShaderCache() @@ -532,13 +855,42 @@ void LightLimitFix::ClearShaderCache() void LightLimitFix::UpdateLights() { ZoneScopedN("LLF::UpdateLights"); + + auto context = globals::d3d::context; + if (!context || !lights || !lights->resource) { + // Drop last frame's particle lights so AddParticleLightLuminance (gameplay + // thread) can't keep feeding stale lights into NPC detection on this early-out. + std::lock_guard lk{ cachedParticleLightsMutex }; + cachedParticleLights.clear(); + return; + } + auto smState = globals::game::smState; auto& isl = globals::features::inverseSquareLighting; + auto clearAndUpdate = [&]() { + lightCount = 0; + // Drop last frame's particle lights too: AddParticleLightLuminance reads + // cachedParticleLights on the gameplay thread, so a bare early-return here + // would leave stale lights contributing to NPC light-level detection. + { + std::lock_guard lk{ cachedParticleLightsMutex }; + cachedParticleLights.clear(); + } + UpdateStructure(); + }; + + if (!smState) { + clearAndUpdate(); + return; + } auto shadowSceneNode = smState->shadowSceneNode[0]; + if (!shadowSceneNode) { + clearAndUpdate(); + return; + } // Cache data since cameraData can become invalid in first-person - for (int eyeIndex = 0; eyeIndex < eyeCount; eyeIndex++) { auto eyePosition = globals::game::frameBufferCached.GetCameraPosAdjust(eyeIndex); eyePositionCached[eyeIndex] = { eyePosition.x, eyePosition.y, eyePosition.z }; @@ -546,14 +898,22 @@ void LightLimitFix::UpdateLights() eastl::vector lightsData{}; lightsData.reserve(MAX_LIGHTS); + const bool isInterior = Util::IsInterior(); + RefreshJsonPlacedLightCacheFrame(); // Process point lights roomNodes.clear(); auto addRoom = [&](RE::NiNode* node, LightData& light) { + if (!node) + return; + + constexpr std::size_t kMaxRoomFlags = 128; uint8_t roomIndex = 0; if (auto it = roomNodes.find(node); it == roomNodes.cend()) { + if (roomNodes.size() >= kMaxRoomFlags) + return; roomIndex = static_cast(roomNodes.size()); roomNodes.insert_or_assign(node, roomIndex); } else { @@ -594,23 +954,24 @@ void LightLimitFix::UpdateLights() isl.ProcessLight(light, bsLight, niLight); } else { light.radius = runtimeData.radius.x; - // light.color *= runtimeData.fade; light.fade = runtimeData.fade; } light.fade *= bsLight->lodDimmer; + const bool isPortalStrict = !IsGlobalLight(bsLight); - if (!IsGlobalLight(bsLight)) { - // List of BSMultiBoundRooms affected by a light + if (isPortalStrict) { for (const auto& roomPtr : bsLight->rooms) { - addRoom(roomPtr, light); + if (roomPtr) + addRoom(static_cast(roomPtr), light); } - // List of BSPortals affected by a light for (const auto& portalPtr : bsLight->portals) { - addRoom(portalPtr->portalSharedNode.get(), light); + if (portalPtr && portalPtr->portalSharedNode) + addRoom(static_cast(portalPtr->portalSharedNode.get()), light); } light.lightFlags.set(LightFlags::PortalStrict); } + ApplyJsonPlacedLightIntensityScale(light, bsLight, niLight, isPortalStrict, isInterior); SetLightPosition(light, niLight->world.translate); @@ -759,14 +1120,15 @@ void LightLimitFix::UpdateLights() addLight(RE::NiPointer(asBs)); }); - auto context = globals::d3d::context; + ProcessQueuedParticleLights(lightsData); lightCount = std::min((uint)lightsData.size(), MAX_LIGHTS); D3D11_MAPPED_SUBRESOURCE mapped; DX::ThrowIfFailed(context->Map(lights->resource.get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped)); size_t bytes = sizeof(LightData) * lightCount; - memcpy_s(mapped.pData, bytes, lightsData.data(), bytes); + if (bytes > 0) + memcpy_s(mapped.pData, bytes, lightsData.data(), bytes); context->Unmap(lights->resource.get(), 0); UpdateStructure(); @@ -922,7 +1284,7 @@ void LightLimitFix::Hooks::BSEffectShader_SetupGeometry::thunk(RE::BSShader* Thi auto& singleton = globals::features::lightLimitFix; singleton.BSLightingShader_SetupGeometry_Before(Pass); singleton.BSLightingShader_SetupGeometry_After(Pass); -}; +} void LightLimitFix::Hooks::BSWaterShader_SetupGeometry::thunk(RE::BSShader* This, RE::BSRenderPass* Pass, uint32_t RenderFlags) { @@ -930,4 +1292,5 @@ void LightLimitFix::Hooks::BSWaterShader_SetupGeometry::thunk(RE::BSShader* This auto& singleton = globals::features::lightLimitFix; singleton.BSLightingShader_SetupGeometry_Before(Pass); singleton.BSLightingShader_SetupGeometry_After(Pass); -}; +} + diff --git a/src/Features/LightLimitFix.h b/src/Features/LightLimitFix.h index c71b0dc736..7583352f4f 100644 --- a/src/Features/LightLimitFix.h +++ b/src/Features/LightLimitFix.h @@ -1,9 +1,13 @@ #pragma once #include "Buffer.h" +#include "Features/LightLimitFix/ParticleLights.h" #include "LightLimitFix/ShadowCasterManager.h" #include "OverlayFeature.h" +#include +#include + struct LightLimitFix : OverlayFeature { private: @@ -24,7 +28,8 @@ struct LightLimitFix : OverlayFeature { "Removes 4-light limit", "Unlimited dynamic lights", "Shadow support for point and spot lights", - "Improved lighting quality" } + "Improved lighting quality", + "Particle lights from configurable INI" } }; } @@ -40,6 +45,7 @@ struct LightLimitFix : OverlayFeature Disabled = (1 << 9), InverseSquare = (1 << 10), Linear = (1 << 11), + Particle = (1 << 12), }; struct PositionOpt @@ -110,7 +116,8 @@ struct LightLimitFix : OverlayFeature // Debug (last) uint EnableLightsVisualisation; uint LightsVisualisationMode; - uint pad0[2]; + uint EnableParticleContactShadows; + uint pad0; }; STATIC_ASSERT_ALIGNAS_16(PerFrame); // Compile-time size lock catches CPU/GPU cbuffer layout drift. STATIC_ASSERT_ALIGNAS_16 @@ -137,6 +144,50 @@ struct LightLimitFix : OverlayFeature StrictLightDataCB strictLightDataTemp; + struct CachedParticleLight + { + float grey; + RE::NiPoint3 position; + float radius; + }; + + struct ParticleLightInfo + { + bool billboard; + RE::BSGeometry* node; + RE::NiColorA color; + float radiusMult = 1.0f; + }; + + struct ParticleLightReference + { + bool valid = false; + bool billboard = false; + // keeps billboard effect-material/emittance tint after detection + bool applyEffectMaterialTint = true; + ParticleLights::Config config{}; + bool hasGradientConfig = false; + ParticleLights::GradientConfig gradientConfig{}; + RE::NiColorA baseColor{ 1.0f, 1.0f, 1.0f, 1.0f }; + std::uint64_t configVersion = 0; + }; + + // INI-driven particle-light configs (loaded in PostPostLoad). Lives on the feature + // rather than in globals since only LightLimitFix code consumes it. + ParticleLights particleLights; + + eastl::hash_map particleLightsReferences; + eastl::vector queuedParticleLights; + eastl::vector currentParticleLights; + std::mutex particleLightsQueueMutex; + + std::shared_mutex cachedParticleLightsMutex; + eastl::vector cachedParticleLights; + + // JSON-placed light cache (rebuilt per frame); paired with InverseSquareLighting metadata. + eastl::hash_map jsonPlacedLightCache; + Util::FrameChecker jsonPlacedLightCacheFrameChecker; + ConstantBuffer* strictLightDataCB = nullptr; int eyeCount = !REL::Module::IsVR() ? 1 : 2; @@ -189,11 +240,13 @@ struct LightLimitFix : OverlayFeature std::string BuildShadowSlotColorLegend() const; virtual void SetupResources() override; + virtual void Reset() override; - virtual void RestoreDefaultSettings() override; virtual void LoadSettings(json& o_json) override; virtual void SaveSettings(json& o_json) override; + virtual void RestoreDefaultSettings() override; + virtual void DrawSettings() override; virtual void DrawOverlay() override; virtual bool IsOverlayVisible() const override @@ -207,7 +260,16 @@ struct LightLimitFix : OverlayFeature virtual void ClearShaderCache() override; float CalculateLightDistance(float3 a_lightPosition, float a_radius); + void AddCachedParticleLights(eastl::vector& lightsData, LightLimitFix::LightData& light); void SetLightPosition(LightLimitFix::LightData& a_light, RE::NiPoint3 a_initialPosition, bool a_cached = true); + void RefreshJsonPlacedLightCacheFrame(); + bool IsJsonPlacedLight(RE::BSLight* a_bsLight, RE::NiLight* a_niLight); + void ApplyJsonPlacedLightIntensityScale( + LightData& a_light, + RE::BSLight* a_bsLight, + RE::NiLight* a_niLight, + bool a_isPortalStrict, + bool a_isInterior); void UpdateLights(); void UpdateStructure(); virtual void EarlyPrepass() override; @@ -216,7 +278,24 @@ struct LightLimitFix : OverlayFeature // Shadow rendering helpers (implemented in LightLimitFix/ShadowRenderer.cpp) - static inline float3 Saturation(float3 color, float saturation); + float CalculateLuminance(CachedParticleLight& light, RE::NiPoint3& point); + void AddParticleLightLuminance(RE::NiPoint3& targetPosition, int& numHits, float& lightLevel); + + ParticleLightReference GetParticleLightConfigs(RE::BSRenderPass* a_pass); + bool AddParticleLight(RE::BSRenderPass* a_pass, ParticleLightReference a_reference); + bool CheckParticleLights(RE::BSRenderPass* a_pass, uint32_t a_technique); + void ProcessQueuedParticleLights(eastl::vector& lightsData); + + // Inline-defined because Particle.cpp calls this; static-inline class-scope helpers must have + // the body visible in every translation unit that uses them. + static inline float3 Saturation(float3 color, float saturation) + { + const float grey = color.Dot(float3(0.3f, 0.59f, 0.11f)); + color.x = std::max(std::lerp(grey, color.x, saturation), 0.0f); + color.y = std::max(std::lerp(grey, color.y, saturation), 0.0f); + color.z = std::max(std::lerp(grey, color.z, saturation), 0.0f); + return color; + } static inline bool IsValidLight(RE::BSLight* a_light); static inline bool IsGlobalLight(RE::BSLight* a_light); @@ -247,6 +326,27 @@ struct LightLimitFix : OverlayFeature // Shadow caster scheduling (ShadowCasterManager) ShadowCasterManager::Settings ShadowSettings; + + bool EnableParticleContactShadows = false; + + // Particle Lights. + bool EnableParticleLights = true; + bool EnableParticleLightsCulling = true; + bool EnableParticleLightsDetection = true; + bool EnableParticleLightsOptimization = true; + float ParticleLightsSaturation = 1.0f; + float ParticleBrightness = 1.0f; + float ParticleRadius = 1.0f; + float BillboardBrightness = 1.0f; + float BillboardRadius = 1.0f; + float ParticleClusterThreshold = 32.0f; + int MaxParticlesPerEmitter = 256; + float MaxParticleDistance = 6000.0f; + + // JSON-placed light intensity (requires Inverse Square Lighting runtime metadata). + float JsonPlacedLightIntensity = 1.0f; + bool JsonPlacedLightsInteriorsOnly = false; + bool JsonPlacedLightsPortalStrictOnly = false; }; uint clusterSize[3] = { 16 }; @@ -281,6 +381,18 @@ struct LightLimitFix : OverlayFeature static inline REL::Relocation func; }; + struct AIProcess_CalculateLightValue_GetLuminance + { + static float thunk(RE::ShadowSceneNode* shadowSceneNode, + RE::NiPoint3& targetPosition, + int& numHits, + float& sunLightLevel, + float& lightLevel, + RE::NiLight& refLight, + int32_t shadowBitMask); + static inline REL::Relocation func; + }; + template struct ValidLight { @@ -297,6 +409,8 @@ struct LightLimitFix : OverlayFeature static void Install() { + stl::write_thunk_call( + REL::RelocationID(38900, 39946).address() + REL::Relocate(0x1C9, 0x1D3)); stl::write_vfunc<0x6, BSLightingShader_SetupGeometry>(RE::VTABLE_BSLightingShader[0]); stl::write_vfunc<0x6, BSEffectShader_SetupGeometry>(RE::VTABLE_BSEffectShader[0]); stl::write_vfunc<0x6, BSWaterShader_SetupGeometry>(RE::VTABLE_BSWaterShader[0]); diff --git a/src/Features/LightLimitFix/Particle.cpp b/src/Features/LightLimitFix/Particle.cpp new file mode 100644 index 0000000000..4fe42ad5fd --- /dev/null +++ b/src/Features/LightLimitFix/Particle.cpp @@ -0,0 +1,750 @@ +// Particle-light recognition + JSON-placed light scaling, split from +// LightLimitFix.cpp to keep the core clustering pipeline focused. + +#include "Features/LightLimitFix.h" + +#include "Globals.h" +#include "Shadercache.h" +#include "Util.h" + +#include +#include +#include +#include + +namespace +{ + constexpr uint MAX_LIGHTS = 1024; + + char ToLowerAscii(char a_char) + { + return static_cast(std::tolower(static_cast(a_char))); + } + + bool EndsWithDdsInsensitive(std::string_view a_filename) + { + if (a_filename.size() < 4) + return false; + const std::string_view ext = a_filename.substr(a_filename.size() - 4); + return ToLowerAscii(ext[0]) == '.' && + ToLowerAscii(ext[1]) == 'd' && + ToLowerAscii(ext[2]) == 'd' && + ToLowerAscii(ext[3]) == 's'; + } + + bool IsNearWhiteTint(const RE::NiColorA& a_color) + { + const float avg = (a_color.red + a_color.green + a_color.blue) / 3.0f; + return std::abs(a_color.red - avg) < 0.02f && + std::abs(a_color.green - avg) < 0.02f && + std::abs(a_color.blue - avg) < 0.02f && + avg > 0.92f; + } + + struct EmissiveTintCandidate + { + bool valid = false; + float distanceSq = std::numeric_limits::max(); + float luma = -1.0f; + RE::NiColorA tint{}; + }; + + void UpdateEmissiveTintCandidate(EmissiveTintCandidate& a_candidate, float a_distanceSq, float a_luma, const RE::NiColorA& a_tint) + { + const bool isCloser = a_distanceSq + 1e-3f < a_candidate.distanceSq; + const bool sameDistance = std::abs(a_distanceSq - a_candidate.distanceSq) <= 1e-3f; + if (!a_candidate.valid || isCloser || (sameDistance && a_luma > a_candidate.luma)) { + a_candidate.valid = true; + a_candidate.distanceSq = a_distanceSq; + a_candidate.luma = a_luma; + a_candidate.tint = a_tint; + } + } + + RE::NiColorA BuildBillboardFallbackTint( + const ParticleLights::Config& a_config, + bool a_hasGradientConfig, + const ParticleLights::GradientConfig& a_gradientConfig) + { + RE::NiColorA fallback{ 1.0f, 1.0f, 1.0f, 1.0f }; + if (a_hasGradientConfig) { + fallback.red = a_gradientConfig.color.red; + fallback.green = a_gradientConfig.color.green; + fallback.blue = a_gradientConfig.color.blue; + } else { + fallback.red = a_config.colorMult.red; + fallback.green = a_config.colorMult.green; + fallback.blue = a_config.colorMult.blue; + } + return fallback; + } + + RE::BSLightingShaderProperty* GetLightingShaderProperty(RE::NiProperty* a_property) + { + if (!a_property || a_property->GetRTTI() != globals::rtti::BSLightingShaderPropertyRTTI.get()) + return nullptr; + return static_cast(a_property); + } + + void ConsiderLightingEmissiveTint( + RE::BSGeometry* a_geometry, + RE::BSGeometry* a_ignoreGeometry, + const RE::NiPoint3& a_targetPosition, + EmissiveTintCandidate& a_bestAnyTint, + EmissiveTintCandidate& a_bestNonWhiteTint) + { + if (!a_geometry || a_geometry == a_ignoreGeometry) + return; + + auto* lightingProperty = GetLightingShaderProperty(a_geometry->GetGeometryRuntimeData().shaderProperty.get()); + if (!lightingProperty || !lightingProperty->emissiveColor || lightingProperty->emissiveMult <= 1e-4f) + return; + + RE::NiColorA emissiveTint{ + std::max(lightingProperty->emissiveColor->red, 0.0f) * lightingProperty->emissiveMult, + std::max(lightingProperty->emissiveColor->green, 0.0f) * lightingProperty->emissiveMult, + std::max(lightingProperty->emissiveColor->blue, 0.0f) * lightingProperty->emissiveMult, + 1.0f + }; + + const float emissiveLuma = + std::max(emissiveTint.red, 0.0f) + + std::max(emissiveTint.green, 0.0f) + + std::max(emissiveTint.blue, 0.0f); + if (emissiveLuma <= 1e-4f) + return; + + const auto& center = a_geometry->worldBound.center; + const float dx = center.x - a_targetPosition.x; + const float dy = center.y - a_targetPosition.y; + const float dz = center.z - a_targetPosition.z; + const float distanceSq = (dx * dx) + (dy * dy) + (dz * dz); + UpdateEmissiveTintCandidate(a_bestAnyTint, distanceSq, emissiveLuma, emissiveTint); + if (!IsNearWhiteTint(emissiveTint)) + UpdateEmissiveTintCandidate(a_bestNonWhiteTint, distanceSq, emissiveLuma, emissiveTint); + } + + void CollectNearbyLightingTint( + RE::NiNode* a_root, + RE::BSGeometry* a_ignoreGeometry, + std::uint32_t a_depthRemaining, + const RE::NiPoint3& a_targetPosition, + EmissiveTintCandidate& a_bestAnyTint, + EmissiveTintCandidate& a_bestNonWhiteTint) + { + if (!a_root) + return; + + for (const auto& child : a_root->GetChildren()) { + auto* childObject = child.get(); + if (!childObject) + continue; + + if (auto* childGeometry = childObject->AsGeometry()) + ConsiderLightingEmissiveTint(childGeometry, a_ignoreGeometry, a_targetPosition, a_bestAnyTint, a_bestNonWhiteTint); + + if (a_depthRemaining > 0) { + if (auto* childNode = childObject->AsNode()) + CollectNearbyLightingTint(childNode, a_ignoreGeometry, a_depthRemaining - 1, a_targetPosition, a_bestAnyTint, a_bestNonWhiteTint); + } + } + } + + bool TryGetBillboardSiblingEmissiveTint(RE::BSGeometry* a_billboardGeometry, RE::NiColorA& a_outTint) + { + if (!a_billboardGeometry) + return false; + + auto* billboardParentNode = a_billboardGeometry->parent ? a_billboardGeometry->parent->AsNode() : nullptr; + if (!billboardParentNode) + return false; + + RE::NiNode* searchRoot = billboardParentNode; + if (auto* ownerNode = billboardParentNode->parent ? billboardParentNode->parent->AsNode() : nullptr) + searchRoot = ownerNode; + + const RE::NiPoint3 targetPosition = a_billboardGeometry->world.translate; + EmissiveTintCandidate bestAnyTint{}; + EmissiveTintCandidate bestNonWhiteTint{}; + CollectNearbyLightingTint(searchRoot, a_billboardGeometry, 2u, targetPosition, bestAnyTint, bestNonWhiteTint); + if (!bestAnyTint.valid) + return false; + + // Prefer non-white sibling emissive tint when available; fall back to closest emissive tint otherwise. + a_outTint = bestNonWhiteTint.valid ? bestNonWhiteTint.tint : bestAnyTint.tint; + return true; + } + + RE::NiColorA BuildEffectMaterialEmissiveTint(RE::BSEffectShaderMaterial* a_material, RE::BSEffectShaderProperty* a_shaderProperty) + { + RE::NiColorA materialEmissiveTint{ + a_material->baseColor.red * a_material->baseColorScale, + a_material->baseColor.green * a_material->baseColorScale, + a_material->baseColor.blue * a_material->baseColorScale, + 1.0f + }; + // Fold in the runtime external-emittance override (set for kExternalEmittance effects) + // so the particle light matches the tint vanilla renders. See Utils/ExternalEmittance.cpp. + if (auto emittance = a_shaderProperty->emittanceColor) { + materialEmissiveTint.red *= emittance->red; + materialEmissiveTint.green *= emittance->green; + materialEmissiveTint.blue *= emittance->blue; + } + return materialEmissiveTint; + } + + float GetEmissiveTintLuma(const RE::NiColorA& a_tint) + { + return std::max(a_tint.red, 0.0f) + + std::max(a_tint.green, 0.0f) + + std::max(a_tint.blue, 0.0f); + } + + std::string ExtractTextureStem(std::string_view a_path) + { + if (a_path.empty()) + return {}; + + auto lastSeparatorPos = a_path.find_last_of("\\/"); + std::string_view filename = (lastSeparatorPos == std::string::npos) ? a_path : a_path.substr(lastSeparatorPos + 1); + if (filename.empty() || !EndsWithDdsInsensitive(filename)) + return {}; + + filename.remove_suffix(4); // Remove ".dds" + if (filename.empty()) + return {}; + + std::string textureName{}; + textureName.reserve(filename.size()); + for (char c : filename) + textureName.push_back(ToLowerAscii(c)); + + return textureName; + } + + struct VertexColor + { + std::uint8_t data[4]; + }; + + bool TryGetMaxAlphaVertexColor(const std::uint8_t* a_rawVertexData, std::uint32_t a_vertexSize, std::uint32_t a_colorOffset, std::uint32_t a_vertexCount, VertexColor& a_outVertexColor) + { + if (!a_rawVertexData || a_vertexSize < sizeof(VertexColor) || a_vertexCount == 0) + return false; + if (a_colorOffset > (a_vertexSize - sizeof(VertexColor))) + return false; + + std::uint8_t maxAlpha = 0; + bool found = false; + VertexColor bestColor{}; + +#if defined(_MSC_VER) + __try +#endif + { + for (std::uint32_t v = 0; v < a_vertexCount; ++v) { + const std::size_t byteOffset = static_cast(a_vertexSize) * static_cast(v) + static_cast(a_colorOffset); + const auto* vertex = reinterpret_cast(a_rawVertexData + byteOffset); + const std::uint8_t alpha = vertex->data[3]; + if (alpha > maxAlpha) { + maxAlpha = alpha; + bestColor = *vertex; + found = true; + } + } + } +#if defined(_MSC_VER) + __except (1) + { + return false; + } +#endif + + if (found) + a_outVertexColor = bestColor; + return found; + } +} + +float LightLimitFix::CalculateLightDistance(float3 a_lightPosition, float a_radius) +{ + return (a_lightPosition.x * a_lightPosition.x) + (a_lightPosition.y * a_lightPosition.y) + (a_lightPosition.z * a_lightPosition.z) - (a_radius * a_radius); +} + +float LightLimitFix::CalculateLuminance(CachedParticleLight& light, RE::NiPoint3& point) +{ + // Mirrors BSLight::CalculateLuminance — keeps NPC detection identical to the GPU lighting math. + auto lightDirection = light.position - point; + float lightDist = lightDirection.Length(); + float intensityFactor = std::clamp(lightDist / light.radius, 0.0f, 1.0f); + float intensityMultiplier = 1 - intensityFactor * intensityFactor; + return light.grey * intensityMultiplier; +} + +void LightLimitFix::AddParticleLightLuminance(RE::NiPoint3& targetPosition, int& numHits, float& lightLevel) +{ + auto shaderCache = globals::shaderCache; + if (!shaderCache->IsEnabled()) + return; + + std::shared_lock lk{ cachedParticleLightsMutex }; + int particleLightsDetectionHits = 0; + if (settings.EnableParticleLightsDetection) { + for (auto& light : cachedParticleLights) { + auto luminance = CalculateLuminance(light, targetPosition); + lightLevel += luminance; + if (luminance > 0.0) + particleLightsDetectionHits++; + } + } + numHits += particleLightsDetectionHits; +} + +LightLimitFix::ParticleLightReference LightLimitFix::GetParticleLightConfigs(RE::BSRenderPass* a_pass) +{ + if (!a_pass || !a_pass->geometry || !a_pass->shaderProperty) + return {}; + + auto cacheInvalidReference = [&](RE::BSGeometry* node) { + ParticleLightReference invalidReference{}; + invalidReference.valid = false; + invalidReference.configVersion = particleLights.configVersion; + std::lock_guard queueLock{ particleLightsQueueMutex }; + particleLightsReferences[node] = invalidReference; + return invalidReference; + }; + + // see https://www.nexusmods.com/skyrimspecialedition/articles/1391 + if (!settings.EnableParticleLights) + return {}; + + auto shaderProperty = a_pass->shaderProperty->GetRTTI() == globals::rtti::BSEffectShaderPropertyRTTI.get() ? + static_cast(a_pass->shaderProperty) : + nullptr; + if (!shaderProperty || shaderProperty->lightData) + return {}; + + auto material = shaderProperty->GetMaterial(); + if (!material) + return {}; + + bool billboard = a_pass->geometry->GetRTTI() != globals::rtti::NiParticleSystemRTTI.get(); + if (billboard) { + auto parent = a_pass->geometry->parent; + if (!parent || parent->GetRTTI() != globals::rtti::NiBillboardNodeRTTI.get()) + return {}; + } + + auto* node = a_pass->geometry; + + { + std::lock_guard queueLock{ particleLightsQueueMutex }; + auto it = particleLightsReferences.find(node); + if (it != particleLightsReferences.end()) { + if (it->second.configVersion == particleLights.configVersion) + return it->second; + particleLightsReferences.erase(it); + } + } + + if (material->sourceTexturePath.empty()) + return {}; + + std::string textureName = ExtractTextureStem(material->sourceTexturePath.c_str()); + if (textureName.empty()) + return cacheInvalidReference(node); + + auto& configs = particleLights.particleLightConfigs; + auto it = configs.find(textureName); + if (it == configs.end()) + return cacheInvalidReference(node); + + ParticleLights::Config config = it->second; + bool hasGradientConfig = false; + ParticleLights::GradientConfig gradientConfig{}; + if (!material->greyscaleTexturePath.empty()) { + // Gradients are an optional override: a missing entry falls back to the base + // config rather than disabling the particle light entirely. + const std::string gradientName = ExtractTextureStem(material->greyscaleTexturePath.c_str()); + if (!gradientName.empty()) { + auto& gradientConfigs = particleLights.particleLightGradientConfigs; + if (auto itGradient = gradientConfigs.find(gradientName); itGradient != gradientConfigs.end()) { + hasGradientConfig = true; + gradientConfig = itGradient->second; + } + } + } + + ParticleLightReference reference{}; + reference.valid = true; + reference.billboard = billboard; + reference.applyEffectMaterialTint = true; + reference.config = config; + reference.hasGradientConfig = hasGradientConfig; + reference.gradientConfig = gradientConfig; + reference.baseColor = { 1, 1, 1, 1 }; + reference.configVersion = particleLights.configVersion; + + if (billboard) { + bool hasVertexTint = false; + if (auto rendererData = a_pass->geometry->GetGeometryRuntimeData().rendererData) { + if (auto triShape = a_pass->geometry->AsTriShape()) { + const std::uint32_t vertexSize = rendererData->vertexDesc.GetSize(); + if (rendererData->vertexDesc.HasFlag(RE::BSGraphics::Vertex::Flags::VF_COLORS) && rendererData->rawVertexData && vertexSize > 0u) { + const std::uint32_t offset = rendererData->vertexDesc.GetAttributeOffset(RE::BSGraphics::Vertex::Attribute::VA_COLOR); + const std::uint32_t vertexCount = static_cast(triShape->GetTrishapeRuntimeData().vertexCount); + + VertexColor maxAlphaVertexColor{}; + if (TryGetMaxAlphaVertexColor(rendererData->rawVertexData, vertexSize, offset, vertexCount, maxAlphaVertexColor)) { + reference.baseColor.red *= maxAlphaVertexColor.data[0] / 255.f; + reference.baseColor.green *= maxAlphaVertexColor.data[1] / 255.f; + reference.baseColor.blue *= maxAlphaVertexColor.data[2] / 255.f; + hasVertexTint = true; + if (shaderProperty->flags.any(RE::BSShaderProperty::EShaderPropertyFlag::kVertexAlpha)) + reference.baseColor.alpha *= maxAlphaVertexColor.data[3] / 255.f; + } + } + } + } + + RE::NiColorA siblingEmissiveTint{}; + bool hasSiblingEmissiveTint = false; + const bool vertexTintLooksWhite = hasVertexTint && IsNearWhiteTint(reference.baseColor); + if (!hasVertexTint || vertexTintLooksWhite) { + hasSiblingEmissiveTint = TryGetBillboardSiblingEmissiveTint(node, siblingEmissiveTint); + const bool siblingTintIsNonWhite = hasSiblingEmissiveTint && !IsNearWhiteTint(siblingEmissiveTint); + + const RE::NiColorA materialEmissiveTint = BuildEffectMaterialEmissiveTint(material, shaderProperty); + const float materialEmissiveLuma = GetEmissiveTintLuma(materialEmissiveTint); + const bool hasMaterialEmissiveTint = materialEmissiveLuma > 1e-4f; + const bool materialTintIsNonWhite = hasMaterialEmissiveTint && !IsNearWhiteTint(materialEmissiveTint); + + // Resolve the fallback tint from a single source so a white tint never gets re-tinted + // from an adjacent emissive — that double-application produces washed-out particle lights. + if (materialTintIsNonWhite) { + reference.baseColor = materialEmissiveTint; + reference.applyEffectMaterialTint = false; + } else if (siblingTintIsNonWhite) { + reference.baseColor = siblingEmissiveTint; + reference.applyEffectMaterialTint = false; + } else if (hasMaterialEmissiveTint) { + reference.baseColor = materialEmissiveTint; + reference.applyEffectMaterialTint = false; + } else if (hasSiblingEmissiveTint) { + reference.baseColor = siblingEmissiveTint; + reference.applyEffectMaterialTint = false; + } else { + reference.baseColor = BuildBillboardFallbackTint(config, hasGradientConfig, gradientConfig); + reference.applyEffectMaterialTint = true; + } + } + } + + { + std::lock_guard queueLock{ particleLightsQueueMutex }; + particleLightsReferences[node] = reference; + } + return reference; +} + +bool LightLimitFix::CheckParticleLights(RE::BSRenderPass* a_pass, uint32_t) +{ + if (!a_pass || !a_pass->geometry || !a_pass->shaderProperty) + return true; + + auto shaderCache = globals::shaderCache; + if (!shaderCache->IsEnabled()) + return true; + + auto reference = GetParticleLightConfigs(a_pass); + if (reference.valid) { + if (AddParticleLight(a_pass, reference)) + return !(settings.EnableParticleLightsCulling && reference.config.cull); + } + return true; +} + +bool LightLimitFix::AddParticleLight(RE::BSRenderPass* a_pass, ParticleLightReference a_reference) +{ + if (!a_pass || !a_pass->geometry || !a_pass->shaderProperty) + return false; + + auto shaderProperty = a_pass->shaderProperty->GetRTTI() == globals::rtti::BSEffectShaderPropertyRTTI.get() ? + static_cast(a_pass->shaderProperty) : + nullptr; + if (!shaderProperty) + return false; + + auto material = shaderProperty->GetMaterial(); + if (!material) + return false; + const auto& config = a_reference.config; + + a_pass->geometry->IncRefCount(); + + if (!a_reference.billboard) { + if (auto particleSystem = static_cast(a_pass->geometry)) { + if (auto particleData = particleSystem->GetParticlesRuntimeData().particleData.get()) + particleData->IncRefCount(); + } + } + + RE::NiColorA color = a_reference.baseColor; + if (a_reference.applyEffectMaterialTint) { + color.red *= material->baseColor.red * material->baseColorScale; + color.green *= material->baseColor.green * material->baseColorScale; + color.blue *= material->baseColor.blue * material->baseColorScale; + + if (auto emittance = shaderProperty->emittanceColor) { + color.red *= emittance->red; + color.green *= emittance->green; + color.blue *= emittance->blue; + } + } + + if (a_reference.hasGradientConfig) { + auto grey = float3(config.colorMult.red, config.colorMult.green, config.colorMult.blue).Dot(float3(0.3f, 0.59f, 0.11f)); + color.red *= grey * a_reference.gradientConfig.color.red; + color.green *= grey * a_reference.gradientConfig.color.green; + color.blue *= grey * a_reference.gradientConfig.color.blue; + } else { + color.red *= config.colorMult.red; + color.green *= config.colorMult.green; + color.blue *= config.colorMult.blue; + } + // Stash radiusMult as alpha for the downstream cluster pass. + color.alpha = std::max(config.radiusMult, 0.0f); + + ParticleLightInfo info; + info.billboard = a_reference.billboard; + info.node = a_pass->geometry; + info.color = color; + info.radiusMult = config.radiusMult; + + bool enqueued = false; + { + std::lock_guard queueLock{ particleLightsQueueMutex }; + constexpr std::size_t kMaxQueuedParticleLights = static_cast(MAX_LIGHTS) * 16u; + if (queuedParticleLights.size() < kMaxQueuedParticleLights) { + queuedParticleLights.push_back(info); + enqueued = true; + } + } + + if (!enqueued) { + if (!a_reference.billboard) { + if (auto particleSystem = static_cast(a_pass->geometry)) { + if (auto particleData = particleSystem->GetParticlesRuntimeData().particleData.get()) + particleData->DecRefCount(); + } + } + a_pass->geometry->DecRefCount(); + return false; + } + + return true; +} + +void LightLimitFix::AddCachedParticleLights(eastl::vector& lightsData, LightLimitFix::LightData& light) +{ + if (lightsData.size() >= MAX_LIGHTS) + return; + + static float& lightFadeStart = *reinterpret_cast(REL::RelocationID(527668, 414582).address()); + static float& lightFadeEnd = *reinterpret_cast(REL::RelocationID(527669, 414583).address()); + const float3 luminanceWeights = float3(0.3f, 0.59f, 0.11f); + + if (settings.MaxParticleDistance > 0.0f) { + const float maxDistSq = settings.MaxParticleDistance * settings.MaxParticleDistance; + const auto& pos = light.positionWS[0].data; // camera-relative + const float distSq = (pos.x * pos.x) + (pos.y * pos.y) + (pos.z * pos.z); + if (distSq > maxDistSq) + return; + } + + float distance = CalculateLightDistance(light.positionWS[0].data, light.radius); + + float dimmer = 0.0f; + if (distance < lightFadeStart || lightFadeEnd == 0.0f) + dimmer = 1.0f; + else if (distance <= lightFadeEnd) + dimmer = 1.0f - ((distance - lightFadeStart) / (lightFadeEnd - lightFadeStart)); + else + dimmer = 0.0f; + + light.fade *= dimmer; + const float luminanceScale = light.fade; + if ((light.color.x + light.color.y + light.color.z) * luminanceScale > 1e-4 && light.radius > 1e-4) { + light.invRadius = 1.f / light.radius; + lightsData.push_back(light); + + if (cachedParticleLights.size() < MAX_LIGHTS) { + CachedParticleLight cachedParticleLight{}; + cachedParticleLight.grey = float3(light.color.x, light.color.y, light.color.z).Dot(luminanceWeights) * luminanceScale; + cachedParticleLight.radius = light.radius; + cachedParticleLight.position = { + light.positionWS[0].data.x + eyePositionCached[0].x, + light.positionWS[0].data.y + eyePositionCached[0].y, + light.positionWS[0].data.z + eyePositionCached[0].z + }; + cachedParticleLights.push_back(cachedParticleLight); + } + } +} + +void LightLimitFix::ProcessQueuedParticleLights(eastl::vector& lightsData) +{ + std::lock_guard lk{ cachedParticleLightsMutex }; + cachedParticleLights.clear(); + + LightData clusteredLight{}; + uint32_t clusteredLights = 0; + + auto flushClusteredLight = [&]() { + if (!clusteredLights) + return; + + const float clusterCount = static_cast(clusteredLights); + clusteredLight.radius /= clusterCount; + clusteredLight.positionWS[0].data /= clusterCount; + clusteredLight.positionWS[1].data = clusteredLight.positionWS[0].data; + + if (eyeCount == 2) { + // Second-eye cache is only populated in VR; read it only here. + const auto eyePositionOffset = eyePositionCached[0] - eyePositionCached[1]; + clusteredLight.positionWS[1].data.x += eyePositionOffset.x; + clusteredLight.positionWS[1].data.y += eyePositionOffset.y; + clusteredLight.positionWS[1].data.z += eyePositionOffset.z; + } + + clusteredLight.lightFlags.set(LightFlags::Simple); + clusteredLight.lightFlags.set(LightFlags::Particle); + AddCachedParticleLights(lightsData, clusteredLight); + + clusteredLights = 0; + clusteredLight = {}; + }; + + std::lock_guard queueLock{ particleLightsQueueMutex }; + for (const auto& particleLight : currentParticleLights) { + if (!particleLight.node) + continue; + + if (!particleLight.billboard) { + auto particleSystem = static_cast(particleLight.node); + if (particleSystem && particleSystem->GetParticlesRuntimeData().particleData.get()) { + auto particleData = particleSystem->GetParticlesRuntimeData().particleData.get(); + auto& particleSystemRuntimeData = particleSystem->GetParticleSystemRuntimeData(); + auto& particleRuntimeData = particleData->GetParticlesRuntimeData(); + + if (!particleRuntimeData.radii || !particleRuntimeData.sizes || !particleRuntimeData.positions) + continue; + + std::uint32_t numVertices = static_cast(particleData->GetActiveVertexCount()); + const std::uint32_t runtimeMaxVertices = static_cast(particleRuntimeData.maxNumVertices); + const std::uint32_t runtimeNumVertices = static_cast(particleRuntimeData.numVertices); + if (runtimeMaxVertices == 0) + continue; + numVertices = std::min(numVertices, runtimeMaxVertices); + if (runtimeNumVertices > 0) + numVertices = std::min(numVertices, runtimeNumVertices); + + std::uint32_t maxPerEmitter = static_cast(std::max(1, settings.MaxParticlesPerEmitter)); + if (numVertices > maxPerEmitter) + numVertices = maxPerEmitter; + + for (std::uint32_t p = 0; p < numVertices; p++) { + float radius = particleRuntimeData.radii[p] * particleRuntimeData.sizes[p]; + + auto initialPosition = particleRuntimeData.positions[p]; + if (!particleSystemRuntimeData.isWorldspace) { + // First-person meshes report a scaled model bound vs world bound mismatch. + if ((particleLight.node->GetModelData().modelBound.radius * particleLight.node->world.scale) != particleLight.node->worldBound.radius) { + const auto& center = particleLight.node->worldBound.center; + initialPosition = { initialPosition.x + center.x, initialPosition.y + center.y, initialPosition.z + center.z }; + } else { + const auto& translate = particleLight.node->world.translate; + initialPosition = { initialPosition.x + translate.x, initialPosition.y + translate.y, initialPosition.z + translate.z }; + } + } + + RE::NiPoint3 positionWS{ + initialPosition.x - eyePositionCached[0].x, + initialPosition.y - eyePositionCached[0].y, + initialPosition.z - eyePositionCached[0].z + }; + + if (clusteredLights) { + auto averageRadius = clusteredLight.radius / (float)clusteredLights; + float radiusDiff = abs(averageRadius - radius); + + auto averagePosition = clusteredLight.positionWS[0].data / (float)clusteredLights; + float positionDiff = positionWS.GetDistance({ averagePosition.x, averagePosition.y, averagePosition.z }); + + if ((radiusDiff + positionDiff) > settings.ParticleClusterThreshold || + !settings.EnableParticleLightsOptimization) { + flushClusteredLight(); + } + } + + float alpha = particleLight.color.alpha; + float3 color{ + particleLight.color.red, + particleLight.color.green, + particleLight.color.blue + }; + if (particleRuntimeData.color) { + alpha *= particleRuntimeData.color[p].alpha; + color.x *= particleRuntimeData.color[p].red; + color.y *= particleRuntimeData.color[p].green; + color.z *= particleRuntimeData.color[p].blue; + } + clusteredLight.color += Saturation(color, settings.ParticleLightsSaturation) * alpha * settings.ParticleBrightness; + + clusteredLight.radius += radius * particleLight.radiusMult * settings.ParticleRadius; + + clusteredLight.positionWS[0].data.x += positionWS.x; + clusteredLight.positionWS[0].data.y += positionWS.y; + clusteredLight.positionWS[0].data.z += positionWS.z; + + clusteredLights++; + } + } + } else { + LightData light{}; + + light.color.x = particleLight.color.red; + light.color.y = particleLight.color.green; + light.color.z = particleLight.color.blue; + + light.color = Saturation(light.color, settings.ParticleLightsSaturation); + + light.color *= particleLight.color.alpha * settings.BillboardBrightness; + light.radius = particleLight.node->worldBound.radius * particleLight.radiusMult * settings.BillboardRadius * 0.5f; + + auto position = particleLight.node->world.translate; + SetLightPosition(light, position); + + light.lightFlags.set(LightFlags::Simple); + light.lightFlags.set(LightFlags::Particle); + + AddCachedParticleLights(lightsData, light); + } + } + + flushClusteredLight(); +} + +float LightLimitFix::Hooks::AIProcess_CalculateLightValue_GetLuminance::thunk( + RE::ShadowSceneNode* shadowSceneNode, + RE::NiPoint3& targetPosition, + int& numHits, + float& sunLightLevel, + float& lightLevel, + RE::NiLight& refLight, + int32_t shadowBitMask) +{ + auto ret = func(shadowSceneNode, targetPosition, numHits, sunLightLevel, lightLevel, refLight, shadowBitMask); + globals::features::lightLimitFix.AddParticleLightLuminance(targetPosition, numHits, ret); + return ret; +} diff --git a/src/Features/LightLimitFix/ParticleLights.cpp b/src/Features/LightLimitFix/ParticleLights.cpp new file mode 100644 index 0000000000..64355a92ca --- /dev/null +++ b/src/Features/LightLimitFix/ParticleLights.cpp @@ -0,0 +1,168 @@ +#include "Features/LightLimitFix/ParticleLights.h" + +#include +#include +#include +#include +#include + +namespace +{ + // User-editable INI values feed light radius/color math; reject non-finite and + // clamp to a sane range so a malformed entry can't destabilize the pipeline. + float SanitizeIniFloat(double a_value, float a_min, float a_max, float a_default) + { + const float f = static_cast(a_value); + if (!std::isfinite(f)) + return a_default; + return std::clamp(f, a_min, a_max); + } + + std::optional ExtractIniStem(const std::string& path) + { + auto lastSeparatorPos = path.find_last_of("\\/"); + if (lastSeparatorPos == std::string::npos) { + logger::error("[LLF] Path incomplete"); + return std::nullopt; + } + + std::string filename = path.substr(lastSeparatorPos + 1); + if (filename.size() < 4) { + logger::error("[LLF] Path too short"); + return std::nullopt; + } + + filename.erase(filename.length() - 4); // Remove ".ini" + std::transform(filename.begin(), filename.end(), filename.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return filename; + } +} + +void ParticleLights::GetConfigs() +{ + ++configVersion; + particleLightConfigs.clear(); + particleLightGradientConfigs.clear(); + + particleLightConfigs["default"] = Config{}; + + if (std::filesystem::exists("Data\\ParticleLights")) { + logger::info("[LLF] Loading particle lights configs"); + + auto configs = clib_util::distribution::get_configs("Data\\ParticleLights", "", ".ini"); + + if (configs.empty()) { + logger::warn("[LLF] No .ini files were found within the Data\\ParticleLights folder, aborting..."); + } else { + logger::info("[LLF] {} matching inis found", configs.size()); + + for (auto& path : configs) { + logger::info("[LLF] loading ini : {}", path); + + CSimpleIniA ini; + ini.SetUnicode(); + ini.SetMultiKey(); + + if (const auto rc = ini.LoadFile(path.c_str()); rc < 0) { + logger::error("\t\t[LLF] couldn't read INI"); + continue; + } + + Config data{}; + + data.cull = ini.GetBoolValue("Light", "Cull", false); + data.colorMult.red = SanitizeIniFloat(ini.GetDoubleValue("Light", "ColorMultRed", 1.0), 0.0f, 16.0f, 1.0f); + data.colorMult.green = SanitizeIniFloat(ini.GetDoubleValue("Light", "ColorMultGreen", 1.0), 0.0f, 16.0f, 1.0f); + data.colorMult.blue = SanitizeIniFloat(ini.GetDoubleValue("Light", "ColorMultBlue", 1.0), 0.0f, 16.0f, 1.0f); + data.radiusMult = SanitizeIniFloat(ini.GetDoubleValue("Light", "RadiusMult", 1.0), 0.0f, 16.0f, 1.0f); + + const auto filename = ExtractIniStem(path); + if (!filename) { + continue; + } + + // Legacy first-win policy: keep behavior compatible with older particle light packs. + if (auto it = particleLightConfigs.find(*filename); it != particleLightConfigs.end()) { + logger::warn("[LLF] Duplicate particle config '{}'; keeping first entry, ignoring {}", *filename, path); + continue; + } + + logger::debug("[LLF] Inserting {}", *filename); + particleLightConfigs.emplace(*filename, data); + } + } + } + + if (std::filesystem::exists("Data\\ParticleLights\\Gradients")) { + logger::info("[LLF] Loading particle lights gradients configs"); + + auto configs = clib_util::distribution::get_configs("Data\\ParticleLights\\Gradients", "", ".ini"); + + if (configs.empty()) { + logger::warn("[LLF] No .ini files were found within the Data\\ParticleLights\\Gradients folder, aborting..."); + return; + } + + logger::info("[LLF] {} matching inis found", configs.size()); + + for (auto& path : configs) { + logger::info("[LLF] loading ini : {}", path); + + CSimpleIniA ini; + ini.SetUnicode(); + ini.SetMultiKey(); + + if (const auto rc = ini.LoadFile(path.c_str()); rc < 0) { + logger::error("\t\t[LLF] couldn't read INI"); + continue; + } + + GradientConfig data{}; + constexpr std::string_view prefix1 = "0x"; + constexpr std::string_view prefix2 = "#"; + constexpr std::string_view cset = "0123456789ABCDEFabcdef"; + + const char* value = ini.GetValue("Gradient", "Color"); + if (value && strcmp(value, "") != 0) { + std::string_view str = value; + + if (str.starts_with(prefix1)) + str.remove_prefix(prefix1.size()); + if (str.starts_with(prefix2)) + str.remove_prefix(prefix2.size()); + + bool matches = std::strspn(str.data(), cset.data()) == str.size(); + + if (matches) { + try { + uint32_t color = static_cast(std::stoul(std::string(str), nullptr, 16)); + data.color = color; + } catch (const std::exception&) { + logger::error("[LLF] invalid color"); + continue; + } + } else { + logger::error("[LLF] invalid color"); + continue; + } + } else { + logger::error("[LLF] missing color"); + continue; + } + + const auto filename = ExtractIniStem(path); + if (!filename) { + continue; + } + + if (auto it = particleLightGradientConfigs.find(*filename); it != particleLightGradientConfigs.end()) { + logger::warn("[LLF] Duplicate particle gradient config '{}'; keeping first entry, ignoring {}", *filename, path); + continue; + } + + logger::debug("[LLF] Inserting {}", *filename); + particleLightGradientConfigs.emplace(*filename, data); + } + } +} diff --git a/src/Features/LightLimitFix/ParticleLights.h b/src/Features/LightLimitFix/ParticleLights.h new file mode 100644 index 0000000000..63fdb5ba09 --- /dev/null +++ b/src/Features/LightLimitFix/ParticleLights.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +class ParticleLights +{ +public: + struct Config + { + bool cull = false; + RE::NiColor colorMult{ 1.0f, 1.0f, 1.0f }; + float radiusMult = 1.0f; + }; + + struct GradientConfig + { + RE::NiColor color; + }; + + ankerl::unordered_dense::map particleLightConfigs; + ankerl::unordered_dense::map particleLightGradientConfigs; + std::uint64_t configVersion = 0; + + void GetConfigs(); +}; diff --git a/src/Hooks.cpp b/src/Hooks.cpp index 35963c05f5..3685a05823 100644 --- a/src/Hooks.cpp +++ b/src/Hooks.cpp @@ -864,6 +864,62 @@ namespace Hooks static inline REL::Relocation func; }; + // Returns true when LightLimitFix wants to cull this render pass because the geometry was + // recognized as a particle light marked with `Cull = true` in its INI config. The SEH guard + // fails open so a transient bad render-pass pointer cannot crash a render-thread hook. + bool ShouldSkipRenderPassForParticleLights(RE::BSRenderPass* a_pass, uint32_t a_technique) + { +#if defined(_MSC_VER) + __try +#endif + { + return globals::features::lightLimitFix.loaded && + !globals::features::lightLimitFix.CheckParticleLights(a_pass, a_technique); + } +#if defined(_MSC_VER) + __except (1) + { + return false; + } +#endif + } + + void BSBatchRenderer_RenderPassImmediately1::thunk( + RE::BSRenderPass* a_pass, + uint32_t a_technique, + bool a_alphaTest, + uint32_t a_renderFlags) + { + if (ShouldSkipRenderPassForParticleLights(a_pass, a_technique)) + return; + + func(a_pass, a_technique, a_alphaTest, a_renderFlags); + } + + void BSBatchRenderer_RenderPassImmediately2::thunk( + RE::BSRenderPass* a_pass, + uint32_t a_technique, + bool a_alphaTest, + uint32_t a_renderFlags) + { + if (ShouldSkipRenderPassForParticleLights(a_pass, a_technique)) + return; + + func(a_pass, a_technique, a_alphaTest, a_renderFlags); + } + + void BSBatchRenderer_RenderPassImmediately3::thunk( + RE::BSRenderPass* a_pass, + uint32_t a_technique, + bool a_alphaTest, + uint32_t a_renderFlags) + { + if (ShouldSkipRenderPassForParticleLights(a_pass, a_technique)) + return; + + func(a_pass, a_technique, a_alphaTest, a_renderFlags); + } + /** * @brief Installs hooks, detours, and memory patches for graphics, input, and rendering subsystems. * @@ -964,6 +1020,14 @@ namespace Hooks } stl::write_thunk_call(REL::RelocationID(100565, 107300).address() + REL::Relocate(0x523, 0xB0E, 0x5FE)); + + logger::info("Hooking BSBatchRenderer::RenderPassImmediately"); + stl::write_thunk_call( + REL::RelocationID(100877, 107673).address() + REL::Relocate(0x1E5, 0x1EE)); + stl::write_thunk_call( + REL::RelocationID(100852, 107642).address() + REL::Relocate(0x29E, 0x28F)); + stl::write_thunk_call( + REL::RelocationID(100871, 107667).address() + REL::Relocate(0xEE, 0xED)); } void InstallEarlyHooks() diff --git a/src/Hooks.h b/src/Hooks.h index 335a7df7d8..fb23eb1049 100644 --- a/src/Hooks.h +++ b/src/Hooks.h @@ -19,6 +19,18 @@ namespace Hooks static inline REL::Relocation func; }; + struct BSBatchRenderer_RenderPassImmediately2 + { + static void thunk(RE::BSRenderPass* pass, uint32_t technique, bool alphaTest, uint32_t renderFlags); + static inline REL::Relocation func; + }; + + struct BSBatchRenderer_RenderPassImmediately3 + { + static void thunk(RE::BSRenderPass* pass, uint32_t technique, bool alphaTest, uint32_t renderFlags); + static inline REL::Relocation func; + }; + void Install(); void InstallEarlyHooks(); }