-
Notifications
You must be signed in to change notification settings - Fork 94
feat(interior sun): force single shadow cascade option #1589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
c9b6657
8e3c771
0b88924
e305a66
5075bbe
5bf8c8b
b2060c2
1338dbb
1f4aa25
7f7d988
a53bdf4
9a94c16
eb7790b
5437e22
e315017
2557dc2
58ef500
bbe0efd
a39906f
e3b1741
bc4b998
b0fa21c
0cff30a
e795591
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT( | ||
| InteriorSun::Settings, | ||
| ForceDoubleSidedRendering, | ||
| ForceSingleShadowCascade, | ||
| InteriorShadowDistance) | ||
|
|
||
| void InteriorSun::DrawSettings() | ||
|
|
@@ -16,15 +17,56 @@ void InteriorSun::DrawSettings() | |
| "Disables backface culling during sun shadowmap rendering in interiors. " | ||
| "Will prevent most light leaking through unmasked/unprepared interiors at a small performance cost. "); | ||
| } | ||
| if (ImGui::SliderFloat("Interior Shadow Distance", &settings.InteriorShadowDistance, 1000.0f, 8000.0f)) { | ||
| *gInteriorShadowDistance = settings.InteriorShadowDistance; | ||
| auto tes = RE::TES::GetSingleton(); | ||
| SetShadowDistance(tes && tes->interiorCell); | ||
| ImGui::Checkbox("Force Single Shadow Cascade", &settings.ForceSingleShadowCascade); | ||
| if (auto _tt = Util::HoverTooltipWrapper()) { | ||
| ImGui::Text( | ||
| "Forces the use of a single high-quality shadow cascade for interiors instead of multiple cascades. " | ||
| "Prevents shadow quality degradation at distance, allowing smaller light-blocking masks. " | ||
| "Recommended for properly prepared interior spaces."); | ||
| } | ||
| if (ImGui::SliderFloat("Interior Shadow Distance", &settings.InteriorShadowDistance, MIN_SHADOW_DISTANCE, MAX_SHADOW_DISTANCE, "%.0f")) { | ||
| // Update both shadow distance pointers when slider changes | ||
| if (gShadowDistance && gInteriorShadowDistance) { | ||
| *gShadowDistance = settings.InteriorShadowDistance; | ||
| *gInteriorShadowDistance = settings.InteriorShadowDistance; | ||
| } | ||
| } | ||
| if (auto _tt = Util::HoverTooltipWrapper()) { | ||
| ImGui::Text( | ||
| "Sets the distance shadows are rendered at in interiors. " | ||
| "Lower values provide higher quality shadows and improved performance but may cause distant interior spaces to light up incorrectly. "); | ||
| "Maximum distance for interior sun shadows. " | ||
| "Higher values cover larger areas but reduce shadow quality. " | ||
| "Lower values improve quality but may not cover entire interior."); | ||
| } | ||
|
|
||
| ImGui::Spacing(); | ||
| ImGui::Separator(); | ||
| ImGui::Text("Shadow Quality Settings (Advanced)"); | ||
|
|
||
| if (iShadowMapResolution) { | ||
| int shadowMapRes = iShadowMapResolution->GetSInt(); | ||
| if (ImGui::SliderInt("Shadow Map Resolution", &shadowMapRes, 512, 8192, "%d")) { | ||
| iShadowMapResolution->data.i = shadowMapRes; | ||
| } | ||
| if (auto _tt = Util::HoverTooltipWrapper()) { | ||
| ImGui::Text( | ||
| "Resolution of the shadow map texture. " | ||
| "Higher values = sharper shadows but lower performance. " | ||
| "Affects ALL shadows in the game, not just interiors. " | ||
| "Requires game restart to take full effect."); | ||
| } | ||
| } | ||
|
|
||
| if (fFirstSliceDistance) { | ||
| float firstSlice = fFirstSliceDistance->GetFloat(); | ||
| if (ImGui::SliderFloat("First Slice Distance", &firstSlice, 100.0f, 10000.0f, "%.0f")) { | ||
| fFirstSliceDistance->data.f = firstSlice; | ||
| } | ||
| if (auto _tt = Util::HoverTooltipWrapper()) { | ||
| ImGui::Text( | ||
| "Distance of the first shadow cascade slice. " | ||
| "Lower values = better close-range shadow quality. " | ||
| "Only applies when not using Force Single Shadow Cascade."); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -43,6 +85,51 @@ void InteriorSun::RestoreDefaultSettings() | |
| settings = {}; | ||
| } | ||
|
|
||
| void InteriorSun::Load() | ||
| { | ||
| // Get BOTH shadow distance pointers | ||
| gShadowDistance = reinterpret_cast<float*>(REL::RelocationID(528314, 415263).address()); | ||
| gInteriorShadowDistance = reinterpret_cast<float*>(REL::RelocationID(513755, 391724).address()); | ||
|
|
||
| // Get INI settings | ||
| iShadowMapResolution = RE::GetINISetting("iShadowMapResolution:Display"); | ||
| fFirstSliceDistance = RE::GetINISetting("fFirstSliceDistance:Display"); | ||
|
|
||
| logger::info("[Interior Sun] gShadowDistance: {:X} = {}", | ||
| reinterpret_cast<uintptr_t>(gShadowDistance), *gShadowDistance); | ||
| logger::info("[Interior Sun] gInteriorShadowDistance: {:X} = {}", | ||
| reinterpret_cast<uintptr_t>(gInteriorShadowDistance), *gInteriorShadowDistance); | ||
|
|
||
| if (iShadowMapResolution) { | ||
| logger::info("[Interior Sun] iShadowMapResolution = {}", iShadowMapResolution->GetSInt()); | ||
| } | ||
| if (fFirstSliceDistance) { | ||
| logger::info("[Interior Sun] fFirstSliceDistance = {}", fFirstSliceDistance->GetFloat()); | ||
| } | ||
|
|
||
| // Force BOTH to user setting - the game might be reading from either one | ||
| *gShadowDistance = settings.InteriorShadowDistance; | ||
| *gInteriorShadowDistance = settings.InteriorShadowDistance; | ||
|
|
||
| logger::info("[Interior Sun] Forced both shadow distances to {}", settings.InteriorShadowDistance); | ||
| } | ||
|
|
||
| void InteriorSun::DataLoaded() | ||
| { | ||
| // This is called AFTER kDataLoaded, which is when the game loads INI values | ||
| // Force shadow distances again to override the INI values | ||
| if (gShadowDistance && gInteriorShadowDistance) { | ||
| logger::info("[Interior Sun] DataLoaded - Before: gShadowDistance={}, gInteriorShadowDistance={}", | ||
| *gShadowDistance, *gInteriorShadowDistance); | ||
|
|
||
| *gShadowDistance = settings.InteriorShadowDistance; | ||
| *gInteriorShadowDistance = settings.InteriorShadowDistance; | ||
|
|
||
| logger::info("[Interior Sun] DataLoaded - After: gShadowDistance={}, gInteriorShadowDistance={}", | ||
| *gShadowDistance, *gInteriorShadowDistance); | ||
| } | ||
| } | ||
|
|
||
| void InteriorSun::PostPostLoad() | ||
| { | ||
| stl::write_thunk_call<BSBatchRenderer_RenderPassImmediately>(REL::RelocationID(100852, 107642).address() + REL::Relocate(0x29E, 0x28F)); | ||
|
|
@@ -61,21 +148,37 @@ void InteriorSun::PostPostLoad() | |
| REL::safe_fill(REL::RelocationID(38900, 39946).address() + REL::Relocate(0x2CA, 0x22B), REL::NOP, REL::Module::IsAE() ? 6 : 2); | ||
|
|
||
| gShadowDistance = reinterpret_cast<float*>(REL::RelocationID(528314, 415263).address()); | ||
| gInteriorShadowDistance = reinterpret_cast<float*>(REL::RelocationID(513755, 391724).address()); | ||
|
|
||
| // Patches BSShadowDirectionalLight::SetFrameCamera to read the correct shadow distance value in interior cells | ||
| // This redirects the vanilla code to use gInteriorShadowDistance instead of gShadowDistance for interiors | ||
| const std::uintptr_t address = REL::RelocationID(101499, 108496).address() + REL::Relocate(0xD62, 0xE6C, 0xE72); | ||
| const std::int32_t displacement = static_cast<std::int32_t>(reinterpret_cast<std::uintptr_t>(gShadowDistance) - (address + 8)); | ||
| const std::int32_t displacement = static_cast<std::int32_t>(reinterpret_cast<std::uintptr_t>(gInteriorShadowDistance) - (address + 8)); | ||
| REL::safe_write(address + 4, &displacement, sizeof(displacement)); | ||
|
|
||
| // Hook SetFrameCamera to modify shadow split distances for interior sun | ||
| stl::write_vfunc<0x10, BSShadowDirectionalLight_SetFrameCamera>(RE::VTABLE_BSShadowDirectionalLight[0]); | ||
|
|
||
| rasterStateCullMode = globals::game::isVR ? &globals::game::shadowState->GetVRRuntimeData().rasterStateCullMode : &globals::game::shadowState->GetRuntimeData().rasterStateCullMode; | ||
|
|
||
| // Force both shadow distances again in case game loaded INI after Load() | ||
| *gShadowDistance = settings.InteriorShadowDistance; | ||
| *gInteriorShadowDistance = settings.InteriorShadowDistance; | ||
| logger::info("[Interior Sun] Re-forced shadow distances in PostPostLoad: gShadowDistance={}, gInteriorShadowDistance={}", | ||
| *gShadowDistance, *gInteriorShadowDistance); | ||
|
|
||
| logger::info("[Interior Sun] Installed hooks"); | ||
| } | ||
|
|
||
| void InteriorSun::EarlyPrepass() | ||
| { | ||
| isInteriorWithSun = IsInteriorWithSun(RE::TES::GetSingleton()->interiorCell); | ||
|
|
||
| // Continuously force interior shadow distance to override INI value | ||
| if (gInteriorShadowDistance && *gInteriorShadowDistance != settings.InteriorShadowDistance) { | ||
| logger::info("[Interior Sun] EarlyPrepass detected wrong shadow distance: {}, forcing to {}", | ||
| *gInteriorShadowDistance, settings.InteriorShadowDistance); | ||
| *gInteriorShadowDistance = settings.InteriorShadowDistance; | ||
| } | ||
| } | ||
|
|
||
| inline bool InteriorSun::IsInteriorWithSun(const RE::TESObjectCELL* cell) | ||
|
|
@@ -180,11 +283,20 @@ void InteriorSun::PopulateReplacementJobArrays(RE::TESObjectCELL* cell, const RE | |
|
|
||
| // Add extra rooms and portals that are in the direction of the sun | ||
| for (const auto& object : currentCellRoomsAndPortals) { | ||
| if (addedSet.find(object.get()) != addedSet.end() || !IsInSunDirectionAndWithinShadowDistance(object, lightDir, playerPos)) | ||
| if (addedSet.find(object.get()) != addedSet.end()) | ||
| continue; | ||
|
|
||
| addedSet.insert(object.get()); | ||
| replacementJobArrays[count++ % jobArraySize].push_back(object); | ||
| // For single cascade mode, include ALL rooms/portals within shadow distance | ||
| // regardless of sun direction to prevent view-dependent culling issues | ||
| if (settings.ForceSingleShadowCascade) { | ||
| addedSet.insert(object.get()); | ||
| replacementJobArrays[count++ % jobArraySize].push_back(object); | ||
| } else { | ||
| if (IsInSunDirectionAndWithinShadowDistance(object, lightDir, playerPos)) { | ||
| addedSet.insert(object.get()); | ||
| replacementJobArrays[count++ % jobArraySize].push_back(object); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| arraysCleared = false; | ||
|
|
@@ -209,12 +321,87 @@ bool InteriorSun::IsInSunDirectionAndWithinShadowDistance(const RE::NiPointer<RE | |
| const auto diff = object->worldBound.center - playerPos; | ||
| const float distance = diff.Length(); | ||
| const float projection = lightDir.Dot(diff); | ||
| return projection >= -radius && (distance - radius) <= *gShadowDistance; | ||
| return projection >= -radius && (distance - radius) <= *gInteriorShadowDistance; | ||
| } | ||
|
|
||
| void InteriorSun::SetShadowDistance(bool inInterior) | ||
| { | ||
| using func_t = decltype(SetShadowDistance); | ||
| static REL::Relocation<func_t> func{ REL::RelocationID(98978, 105631).address() }; | ||
| func(inInterior); | ||
| } | ||
|
|
||
| bool InteriorSun::BSShadowDirectionalLight_SetFrameCamera::thunk(RE::BSShadowDirectionalLight* a_light, const RE::NiCamera& a_camera) | ||
| { | ||
| auto& singleton = globals::features::interiorSun; | ||
|
|
||
| // Call original function first - it calculates everything | ||
| bool result = func(a_light, a_camera); | ||
|
|
||
| // AFTER SetFrameCamera completes, tighten the frustum bounds for cascade 0 in interior sun mode | ||
| if (result && singleton.loaded && singleton.isInteriorWithSun && singleton.settings.ForceSingleShadowCascade) { | ||
| // Access the first cascade camera | ||
| RE::NiPointer<RE::NiCamera> cascadeCamera; | ||
| if (globals::game::isVR) { | ||
| auto& shadowData = a_light->GetVRRuntimeData(); | ||
| if (!shadowData.shadowmapDescriptors.empty()) { | ||
| cascadeCamera = shadowData.shadowmapDescriptors[0].camera; | ||
| } | ||
| } else { | ||
| auto& shadowData = a_light->GetRuntimeData(); | ||
| if (!shadowData.shadowmapDescriptors.empty()) { | ||
| cascadeCamera = shadowData.shadowmapDescriptors[0].camera; | ||
| } | ||
| } | ||
|
|
||
| if (cascadeCamera) { | ||
| auto& frustum = cascadeCamera->GetRuntimeData2().viewFrustum; | ||
|
|
||
| if (frustum.bOrtho) { | ||
| // Calculate current frustum dimensions | ||
| const float currentWidth = frustum.fRight - frustum.fLeft; | ||
| const float currentHeight = frustum.fTop - frustum.fBottom; | ||
|
|
||
| // Scale factor to tighten frustum - smaller = higher texel density | ||
| // Use a percentage of the shadow distance for dynamic scaling | ||
| const float targetScale = 0.4f; // 40% of original size = 2.5x texel density boost | ||
|
|
||
| // Calculate center point | ||
| const float centerX = (frustum.fLeft + frustum.fRight) * 0.5f; | ||
| const float centerY = (frustum.fTop + frustum.fBottom) * 0.5f; | ||
|
|
||
| // Apply scaling around center point | ||
| const float newHalfWidth = (currentWidth * targetScale) * 0.5f; | ||
| const float newHalfHeight = (currentHeight * targetScale) * 0.5f; | ||
|
|
||
| frustum.fLeft = centerX - newHalfWidth; | ||
| frustum.fRight = centerX + newHalfWidth; | ||
| frustum.fTop = centerY + newHalfHeight; | ||
| frustum.fBottom = centerY - newHalfHeight; | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does the Z axis need to be scaled too? |
||
| // Update the camera with modified frustum | ||
| RE::NiUpdateData updateData; | ||
| cascadeCamera->Update(updateData); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this scaling also be applied to culling camera frustums? |
||
| } | ||
| } | ||
| } | ||
|
|
||
| // AFTER SetFrameCamera calculates splits, override them for interior sun if enabled | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These values get updated at the beginning of the cascade loop so changes here will only propagate to subsequent reads e.g. shader buffer updates. |
||
| if (result && singleton.loaded && singleton.isInteriorWithSun && singleton.settings.ForceSingleShadowCascade) { | ||
| auto& runtimeData = a_light->GetShadowDirectionalLightRuntimeData(); | ||
| const float maxDistance = *singleton.gInteriorShadowDistance; | ||
|
|
||
| // Single cascade mode: cascade 0 covers the full range from 0 to maxDistance | ||
| runtimeData.startSplitDistances[0] = 0.0f; | ||
| runtimeData.endSplitDistances[0] = maxDistance; | ||
|
|
||
| // Disable cascades 1 and 2 by setting their ranges to maxDistance | ||
| runtimeData.startSplitDistances[1] = maxDistance; | ||
| runtimeData.endSplitDistances[1] = maxDistance; | ||
|
|
||
| runtimeData.startSplitDistances[2] = maxDistance; | ||
| runtimeData.endSplitDistances[2] = maxDistance; | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where does this frustum get used?