Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
19 changes: 19 additions & 0 deletions src/Deferred.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#include "Features/DynamicCubemaps.h"
#include "Features/IBL.h"
#include "Features/InteriorSun.h"
#include "Features/ScreenSpaceGI.h"
#include "Features/Skylighting.h"
#include "Features/SubsurfaceScattering.h"
Expand Down Expand Up @@ -208,6 +209,24 @@ void Deferred::CopyShadowData()

context->CSSetShader(nullptr, nullptr, 0);

// Apply interior sun single cascade fix by modifying the shadow buffer that shaders actually read
if (globals::features::interiorSun.loaded && globals::features::interiorSun.isInteriorWithSun &&
globals::features::interiorSun.settings.ForceSingleShadowCascade) {
D3D11_MAPPED_SUBRESOURCE mapped;
if (SUCCEEDED(context->Map(perShadow->resource.get(), 0, D3D11_MAP_READ_WRITE, 0, &mapped))) {
auto* shadowData = static_cast<PerGeometry*>(mapped.pData);

// Use the same interior shadow distance that we set in SetFrameCamera for consistency
const float maxDistance = *globals::features::interiorSun.gInteriorShadowDistance;

// Override the split distances in the buffer that shaders read from
shadowData->EndSplitDistances = { maxDistance, maxDistance, maxDistance, shadowData->EndSplitDistances.w };
shadowData->StartSplitDistances = { 0.0f, maxDistance, maxDistance, shadowData->StartSplitDistances.w };

context->Unmap(perShadow->resource.get(), 0);
}
}

{
context->PSGetShaderResources(4, 1, &shadowView);

Expand Down
36 changes: 25 additions & 11 deletions src/EngineFixes/ShadowmapCascadeCullingFix.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#include "ShadowmapCascadeCullingFix.h"
#include "Features/InteriorSun.h"
#include "Globals.h"

void ShadowmapCascadeCullingFix::Install()
{
Expand All @@ -11,19 +13,31 @@ void ShadowmapCascadeCullingFix::BSShadowDirectionalLight_SetFrameCamera_BuildCa
{
func(dirLight, outPlanes, frustumSplit, splitCornerIndices, numSplitCornerIndices, lightDir, cameraPos, cornerOffsetIndex);

// This fix pulls the far face corners back towards the near face corners by double fSplitOverlap to provide an effective overlap of 1 * fSplitOverlap in each direction.
// This corrects the vanilla behaviour which sets nearFace = farFace for the next cascade camera, where nearFace already includes +fSplitOverlap
// which incorrectly pushes out the culling for the next cascade camera causing shadow gaps even at the default fSplitOverlap of 100.
// This newly calculated farFace is not immediately used but will be copied into the nearFace for the next cascade camera and provide effective overlap.
auto& interiorSun = globals::features::interiorSun;
const bool isInteriorSun = interiorSun.loaded && interiorSun.isInteriorWithSun;
const bool singleCascadeMode = isInteriorSun && interiorSun.settings.ForceSingleShadowCascade;

const float splitOverlap = *gfSplitOverlap * 2.0f;
if (isInteriorSun) {
// DISABLE frustum culling entirely for ALL interior sun scenarios
// This allows geometry outside the view (like windows above/behind the camera)
// to still cast shadows and produce volumetric lighting effects
// The room/portal system already handles coarse culling via DirShadowLightCulling
outPlanes.activePlanes = static_cast<RE::NiFrustumPlanes::ActivePlane>(0);
} else if (!singleCascadeMode) {
// This fix pulls the far face corners back towards the near face corners by double fSplitOverlap to provide an effective overlap of 1 * fSplitOverlap in each direction.
// This corrects the vanilla behaviour which sets nearFace = farFace for the next cascade camera, where nearFace already includes +fSplitOverlap
// which incorrectly pushes out the culling for the next cascade camera causing shadow gaps even at the default fSplitOverlap of 100.
// This newly calculated farFace is not immediately used but will be copied into the nearFace for the next cascade camera and provide effective overlap.

for (uint32_t i = 0; i < 4; ++i) {
auto& nearCorner = frustumSplit.nearFace[i];
auto& farCorner = frustumSplit.farFace[i];
auto dir = farCorner - nearCorner;
dir.Unitize();
const float splitOverlap = *gfSplitOverlap * 2.0f;

farCorner -= dir * splitOverlap;
for (uint32_t i = 0; i < 4; ++i) {
auto& nearCorner = frustumSplit.nearFace[i];
auto& farCorner = frustumSplit.farFace[i];
auto dir = farCorner - nearCorner;
dir.Unitize();

farCorner -= dir * splitOverlap;
}
}
}
211 changes: 199 additions & 12 deletions src/Features/InteriorSun.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE_WITH_DEFAULT(
InteriorSun::Settings,
ForceDoubleSidedRendering,
ForceSingleShadowCascade,
InteriorShadowDistance)

void InteriorSun::DrawSettings()
Expand All @@ -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.");
}
}
}

Expand All @@ -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));
Expand All @@ -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)
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Copy link

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?

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;

Copy link

Choose a reason for hiding this comment

The 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);
Copy link

Choose a reason for hiding this comment

The 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
Copy link

Choose a reason for hiding this comment

The 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;
}
Loading