Skip to content

feat: unlock shadow limit#1941

Draft
doodlum wants to merge 272 commits into
devfrom
shadow-limit-fix
Draft

feat: unlock shadow limit#1941
doodlum wants to merge 272 commits into
devfrom
shadow-limit-fix

Conversation

@doodlum
Copy link
Copy Markdown
Collaborator

@doodlum doodlum commented Feb 25, 2026

Release comment

  • Add actual shadow sampling
  • Integrate Intellightent code into CS itself
  • Optimise and cleanup code
  • Remove debugging code when everything is known to work

Current state.
CS from this branch.

coc intellightest (note this test esp will break blackreach so not for playthrough). Grab from release assets.

Working generally.

  • Some lights turn off and get stuck off.

Other ways to generate shadows:

  • Add multiple shadow casting torches.
  • Add actual content with multiple lights.
  • Enable under Light Limit Fix -> Shadow Limit Fix -> Light Conversion -> Promote Normal Lights to Shadow Casters (this is only intended for testing until there's more content).
image

Defaults to 16 shadow lights and uses an auto budget system to try to maintain your target fps (but always allows 4 shadow casters to be drawn). If shadows aren't updating every frame, you may be exhausting the budget.

Summary by CodeRabbit

  • New Features

    • Expanded shadow rendering: PCF and PCSS filtering options, per-light shadow slots, and improved directional/spot/omni sampling.
    • New shadow visualization modes and per-light debug visualization.
    • Adjustable shadow sampling parameters (filter mode, kernel scale, light size) with persistent settings.
    • Intelligent shadow scheduling to prioritize and cap shadow updates for performance.
  • Other

    • General shader and runtime plumbing to support the above features.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 25, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ffcb493c-acf7-4e41-9add-e3833e2d04c3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR overhauls shadow handling: it moves shadow-data assembly from a GPU compute pass to CPU-populated structured buffers, adds a ShadowCasterManager for slot scheduling/budgeting, extends shader shadow-sampling APIs (PCF/PCSS), and wires directional/point shadow uploads and debug/visualization into the renderer and LightLimitFix feature.

Changes

Cohort / File(s) Summary
Build Configuration
CMakeLists.txt, vcpkg.json, extern/CommonLibSSE-NG
Added ExprTk include discovery and exprtk vcpkg dependency; updated CommonLibSSE-NG submodule pointer.
Deferred Renderer Core
src/Deferred.h, src/Deferred.cpp
Added DirectionalShadowData/ShadowData structs, shadowMapSlots, perDirectionalShadow buffer; implemented CopyShadowData() and SetShadowCascadeParameters() to upload directional shadow data during early prepass.
Volumetric Shadows
features/Volumetric Shadows/Shaders/..., src/Features/VolumetricShadows.h, src/Features/VolumetricShadows.cpp
Removed compute shader CopyShadowDataCS and PerGeometry buffer; updated HLSL to accept DirectionalShadowData parameters and simplified CPU copy/binding logic.
Shadow Sampling Framework
package/Shaders/Common/ShadowSampling.hlsli
Added Directional/Shadow data types and global resources; introduced gather/PCF/PCSS sampling functions and per-slot evaluators; updated calls to pass directional data.
Light Limit Fix Shaders & Integration
features/Light Limit Fix/Shaders/..., package/Shaders/Lighting.hlsl, package/Shaders/RunGrass.hlsl
Replaced shadowLightIndexshadowMapIndex, adjusted padding/layout; added LLF debug accumulation/visualization; replaced shadow-mask sampling with ShadowSampling APIs and PCF rotation usage; updated character noise sampling.
LightLimitFix Feature & Shadow System
src/Features/LightLimitFix.h, src/Features/LightLimitFix.cpp, src/Features/LightLimitFix/ShadowCasterManager.h, src/Features/LightLimitFix/ShadowRenderer.cpp
Added ShadowCasterManager (scheduling, budgets, slot management), shadow settings persistence, UI/settings, shadow resource lifecycle, and per-slot shadow uploads; refactored UpdateLights to use shadow manager and stable slots.
Shared Data Layout
package/Shaders/Common/SharedData.hlsli
Augmented LightLimitFixSettings with FilterMode, KernelScale, LightSize, ShadowMapSlots and adjusted padding/ClusterSize placement.
Globals & State
src/Globals.h, src/Globals.cpp, src/State.cpp
Added exported runtime pointers (frameCounter, viewWidth/height, drawStereo, normal/readOnly depth buffers) and call sites to populate/use them; added conditional volumetric CopyShadowData call in State.
Misc & Utilities
src/Features/VolumetricShadows.*, src/Features/RenderDoc.cpp, src/Menu/FeatureListRenderer.cpp, src/XSEPlugin.cpp
Removed perShadow buffer handling, integrated shadow-slot legend into RenderDoc comments, minor menu syntax fix, increased SKSE trampoline allocation.

Sequence Diagram(s)

sequenceDiagram
    participant App as App / Game
    participant SCM as ShadowCasterManager
    participant Deferred as Deferred Renderer
    participant GPU as GPU Shaders (HLSL)

    App->>SCM: ForEachShadowLight() / assign slots
    activate SCM
    SCM-->>App: shadowMapIndex per light
    deactivate SCM

    App->>Deferred: EarlyPrepass()
    activate Deferred
    Deferred->>Deferred: Build DirectionalShadowData (CPU)
    Deferred->>GPU: Bind StructuredBuffer t98/t99 and ShadowMaps t101
    deactivate Deferred

    App->>GPU: Draw frame (Lighting)
    activate GPU
    GPU->>GPU: ShadowSampling::GetLightingShadow(DirectionalShadows[0], ...)
    GPU->>GPU: GetShadowLightShadow(shadowMapIndex, ...)
    GPU-->>App: Shaded pixels with PCF/PCSS and LLF debug data
    deactivate GPU
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • #1874 — Refactors volumetric shadow architecture; overlaps in removing compute shader and reorganizing shadow data flow.
  • #1834 — Alters LightLimitFix settings/persistence; intersects with added Load/Save and settings fields.
  • #1951 — Adjusts SKSE trampoline allocation in the same plugin-init area (src/XSEPlugin.cpp).

Suggested reviewers

  • doodlum
  • davo0411

Poem

"I hop where shadows used to sleep,
CPU picks them up from heap,
Slots and PCF twirl with glee,
Debug colors bloom for me,
A rabbit cheers the shadowed sweep." 🐇✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.07% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: unlock shadow limit' accurately reflects the main objective of the changeset, which is to add actual shadow sampling and unlock the shadow light limit.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch shadow-limit-fix

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 25, 2026

No actionable suggestions for changed features.

@alandtse alandtse changed the title feat: unlocked shadow limit feat: unlock shadow limit Feb 28, 2026
cyberrumor added a commit to cyberrumor/based-lighting-configs that referenced this pull request Mar 8, 2026
With this PR:
- community-shaders/skyrim-community-shaders#1941

We're getting close to the shadow limit being increased. The only reason
LPO doesn't already include shadow casting lights everywhere is because
of the shadow limit.

Add an option for shadow lights to the fomod installer, and add shadow
light configs.

This prepares LPO for the impending shadow light limit increase.
@alandtse

This comment was marked as resolved.

@alandtse alandtse force-pushed the shadow-limit-fix branch 2 times, most recently from efc619d to ed692a5 Compare March 24, 2026 08:06
@alandtse

This comment was marked as resolved.

@alandtse

This comment was marked as outdated.

@alandtse alandtse marked this pull request as ready for review March 29, 2026 09:48
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/Globals.h (1)

221-221: ⚠️ Potential issue | 🟡 Minor

Duplicate declaration of tes.

RE::TES* tes is already declared at line 215. This duplicate declaration at line 221 should be removed.

🧹 Proposed fix
 		extern RE::Setting* shadowMaskQuarter;
 		extern REL::Relocation<ID3D11Buffer**> perFrame;
 		extern REL::Relocation<RE::BSGraphics::BSShaderAccumulator**> currentAccumulator;
-		extern RE::TES* tes;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Globals.h` at line 221, Remove the duplicate extern declaration of
RE::TES* tes in Globals.h by deleting the second occurrence (the redundant line
declaring extern RE::TES* tes) so only the original declaration remains; ensure
no other code depends on two externs and rebuild to confirm no linker changes
are necessary.
features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli (1)

153-166: ⚠️ Potential issue | 🟠 Major

Remove transpose from GetVSMShadow2D() matrix multiplication.

Lines 154 and 166 use mul(transpose(shadowProj), ...) while GetVSMShadow3D() (lines 92, 108) and ShadowSampling::GetLightingShadow() both use mul(shadowProj, ...) with the same DirectionalShadowData.ShadowProj. This inconsistency causes incorrect light space transformation.

Proposed fix
- float3 positionLS = mul(transpose(shadowProj), float4(position, 1)).xyz;
+ float3 positionLS = mul(shadowProj, float4(position, 1)).xyz;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli around lines 153 -
166, The matrix multiplication in GetVSMShadow2D uses mul(transpose(shadowProj),
float4(position, 1)) for both primary and secondary cascades which is
inconsistent with GetVSMShadow3D and ShadowSampling::GetLightingShadow; change
both occurrences to use mul(shadowProj, float4(position, 1)) so the same
DirectionalShadowData.ShadowProj convention is used (affects the calls around
SampleVSMCascade2D, the secondary cascade block using secondaryCascade = 1 -
primaryCascade, and the positionLS computation).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli:
- Around line 12-15: GetShadowDepth and the other shadow-depth code are
incorrectly hard-coding FrameBuffer::CameraPosAdjust[0]; update all uses to
index with the provided eyeIndex (e.g., FrameBuffer::CameraPosAdjust[eyeIndex])
so the camera-adjusted depth, cascade selection, and ray transforms use the
correct eye. Locate GetShadowDepth(float3 positionWS, uint eyeIndex = 0) and the
nearby functions that accept eyeIndex (the shadow depth / cascade selection path
around the other occurrences) and replace any CameraPosAdjust[0] accesses with
CameraPosAdjust[eyeIndex], keeping the rest of the logic intact.

In `@package/Shaders/Common/ShadowSampling.hlsli`:
- Around line 45-46: The two SRV declarations conflict when GRASS_COLLISION is
enabled because ShadowSampling.hlsli declares StructuredBuffer<ShadowData>
Shadows : register(t100) while GrassCollision.hlsli declares Texture2D<float4>
Collision : register(t100); update the ShadowSampling.hlsli resource bindings to
avoid t100 (e.g., shift Shadows and ShadowMaps to unused consecutive SRV
registers such as t102/t103 or a dedicated shadow SRV range), and ensure
RunGrass.hlsl/any includes using those new registers; adjust any shader code
that references Shadows or ShadowMaps to the new register names so the
grass+shadow-light variant has no binding overlap.
- Line 21: SharedShadowMap currently unguarded binds to register(t80) colliding
with TRUE_PBR's TexLandDisplacement0Sampler; locate the declaration of
Texture2D<float2> SharedShadowMap in ShadowSampling.hlsli and either wrap it
with `#if` defined(VOLUMETRIC_SHADOWS) / `#endif` so it only exists when volumetric
shadows are enabled, or move its register to an unused slot (e.g., change
register(t80) to register(t98) or register(t99)) to avoid the conflict with
Lighting.hlsl's TexLandDisplacement0Sampler under TRUE_PBR.

In `@package/Shaders/Lighting.hlsl`:
- Around line 2627-2652: The debug code treats overflowed lights as unshadowed
because UpdateLights() strips LightFlags::Shadow; change the logic to detect
overflow by inspecting light.shadowMapIndex before depending on
light.lightFlags: first check if light.shadowMapIndex >=
SharedData::lightLimitFixSettings.ShadowMapSlots and increment
debugOverflowCount (and handle as overflow), else proceed to test
(light.lightFlags & LightLimitFix::LightFlags::Shadow) to increment
debugPLShadowCount / debugUnshadowedPLCount and use shadowMapIndex to classify
types; update the block around debugOverflowCount, debugPLShadowCount,
debugUnshadowedPLCount and the shadow-type classification that currently reads
Shadows[light.shadowMapIndex].ShadowParam.x so overflowed indices are never
dereferenced.

In `@src/Deferred.cpp`:
- Around line 118-131: The code leaves shadowMapSlots unchanged when the
kSHADOWMAPS SRV is null or not a Texture2DArray/has ArraySize==0, causing later
code to treat stale non-zero slots as valid; in SetupResources (Deferred.cpp)
after checking
renderer->GetDepthStencilData().depthStencils[RE::RENDER_TARGET_DEPTHSTENCIL::kSHADOWMAPS].depthSRV,
set shadowMapSlots = 0 on both failure branches (null SRV and the else branch
where desc.ViewDimension != D3D11_SRV_DIMENSION_TEXTURE2DARRAY or ArraySize ==
0) and update the corresponding logger messages to reflect the reset so later
code using shadowMapSlots no longer assumes an array exists.
- Around line 172-189: The perDirectionalShadow pointer is overwritten in
SetupResources without releasing the previous GPU Buffer, leaking DirectX
resources; before assigning a new Buffer instance in the block that constructs
sbDesc/srvDesc, release or delete the existing perDirectionalShadow (or
preferably switch perDirectionalShadow to a smart pointer like
std::unique_ptr/ComPtr) and null-check it, then create the new Buffer and call
CreateSRV; update usages of perDirectionalShadow accordingly (Buffer,
perDirectionalShadow, CreateSRV) to ensure no leaks and safe reinitialization
when render targets are recreated.

In `@src/Features/LightLimitFix.cpp`:
- Around line 598-614: The loop currently uses bufferIndex (per-light count) to
decide shadowing against globals::deferred->shadowMapSlots, which is wrong for
multi-slice lights; change the logic to track slicesUsed (start 0) and for each
light (shadowSceneNode->GetRuntimeData().shadowLightsAccum[mapIndex]) compute
canShadow = (slicesUsed + light->shadowMapCount) <=
(int)globals::deferred->shadowMapSlots, pass canShadow to addShadowLight instead
of bufferIndex check, then if canShadow do slicesUsed += light->shadowMapCount;
still increment mapIndex by light->shadowMapCount and bufferIndex only if you
need the original per-light index (or remove bufferIndex entirely).

In `@src/Features/LightLimitFix/ShadowCasterManager.h`:
- Around line 64-86: FormulaHelper currently holds a raw pointer _ptr to a
dynamically allocated FormulaWrapper and is copyable by default, which causes
shallow copies and double-free; make FormulaHelper non-copyable by explicitly
deleting the copy constructor and copy assignment operator (e.g., declare
FormulaHelper(const FormulaHelper&) = delete; and FormulaHelper& operator=(const
FormulaHelper&) = delete;) so copies cannot be made; leave or implement move
operations only if ownership transfer is needed, but at minimum delete copy
operations in the FormulaHelper declaration to prevent double-delete.
- Around line 103-120: Validate and clamp the combined slot count when loading
or initializing settings: in LoadSettings() or Init() (not just Install()),
compute total = Settings::ShadowLightCount + Settings::ConvertedShadowSlots + 1
(for sun) and clamp it to the renderer's supported maximum before allocating the
LightContainer or related DX11 resources; update Settings::ShadowLightCount
and/or Settings::ConvertedShadowSlots (or derive effective counts) so subsequent
code uses the capped values, and ensure allocation failures degrade gracefully
(release any partially created DirectX11 resources and log an error) to prevent
crashes from malformed JSON.

In `@src/Features/LightLimitFix/ShadowRenderer.cpp`:
- Around line 25-38: SetupShadowResources currently recreates the D3D11 sampler
without releasing the previous COM object; before calling
globals::d3d::device->CreateSamplerState(&cmpDesc, &shadowCmpSampler) in
LightLimitFix::SetupShadowResources, check if shadowCmpSampler is non-null and
call shadowCmpSampler->Release() (and set it to nullptr) to avoid leaking the
old sampler, and ensure that on CreateSamplerState failure the member is left
null to prevent dangling references.
- Around line 102-129: The current guard uses "plCount < slots" to allow writes
into sd[depthSlot], but for dual-paraboloid lights (shadowMapCount==2) depthSlot
can index past sd; change the condition to validate the actual depthSlot range
before writing — e.g., compute depthSlot using light->GetVRRuntimeData() /
GetRuntimeData() as done, then ensure (depthSlot + light->shadowMapCount) <=
slots (or depthSlot < slots and depthSlot + light->shadowMapCount - 1 < slots)
before touching sd[depthSlot] and calling SetShadowParameters /
ShadowCasterManager::RecordSlot so you never perform out-of-bounds writes for
lights with shadowMapCount == 2 (references: sd, plCount, slots, depthSlot,
shadowMapCount, shadowAccum, ShadowCasterManager, GetRuntimeData,
GetVRRuntimeData, SetShadowParameters).

In `@src/Features/VolumetricShadows.cpp`:
- Around line 276-279: The current binding logic prefers shadowCopySRV and
therefore rebinds a previous-frame SRV when the current source (shadowView) is
missing; change the selection to only bind a valid current-frame SRV and bind
null otherwise. Specifically, in the block that sets PS slot 18 (using
shadowCopySRV, shadowView and context->PSSetShaderResources), choose shadowView
if present and bind nullptr when shadowView is null (do not fall back to
shadowCopySRV), so the VSM path is effectively disabled for this frame; ensure
you pass a null ID3D11ShaderResourceView* to PSSetShaderResources when no
current SRV is available.

---

Outside diff comments:
In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli:
- Around line 153-166: The matrix multiplication in GetVSMShadow2D uses
mul(transpose(shadowProj), float4(position, 1)) for both primary and secondary
cascades which is inconsistent with GetVSMShadow3D and
ShadowSampling::GetLightingShadow; change both occurrences to use
mul(shadowProj, float4(position, 1)) so the same
DirectionalShadowData.ShadowProj convention is used (affects the calls around
SampleVSMCascade2D, the secondary cascade block using secondaryCascade = 1 -
primaryCascade, and the positionLS computation).

In `@src/Globals.h`:
- Line 221: Remove the duplicate extern declaration of RE::TES* tes in Globals.h
by deleting the second occurrence (the redundant line declaring extern RE::TES*
tes) so only the original declaration remains; ensure no other code depends on
two externs and rebuild to confirm no linker changes are necessary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ac942958-f423-4cdd-a37d-aebe012b1bdb

📥 Commits

Reviewing files that changed from the base of the PR and between d42bcf6 and b1089cd.

📒 Files selected for processing (26)
  • CMakeLists.txt
  • extern/CommonLibSSE-NG
  • features/Light Limit Fix/Shaders/LightLimitFix/Common.hlsli
  • features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli
  • features/Volumetric Shadows/Shaders/VolumetricShadows/CopyShadowDataCS.hlsl
  • features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli
  • package/Shaders/Common/ShadowSampling.hlsli
  • package/Shaders/Common/SharedData.hlsli
  • package/Shaders/Lighting.hlsl
  • package/Shaders/RunGrass.hlsl
  • src/Deferred.cpp
  • src/Deferred.h
  • src/Features/LightLimitFix.cpp
  • src/Features/LightLimitFix.h
  • src/Features/LightLimitFix/ShadowCasterManager.cpp
  • src/Features/LightLimitFix/ShadowCasterManager.h
  • src/Features/LightLimitFix/ShadowRenderer.cpp
  • src/Features/RenderDoc.cpp
  • src/Features/VolumetricShadows.cpp
  • src/Features/VolumetricShadows.h
  • src/Globals.cpp
  • src/Globals.h
  • src/Menu/FeatureListRenderer.cpp
  • src/State.cpp
  • src/XSEPlugin.cpp
  • vcpkg.json
💤 Files with no reviewable changes (3)
  • features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli
  • src/Features/VolumetricShadows.h
  • features/Volumetric Shadows/Shaders/VolumetricShadows/CopyShadowDataCS.hlsl

Comment thread features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli Outdated
Comment thread package/Shaders/Common/ShadowSampling.hlsli Outdated
Comment thread package/Shaders/Common/ShadowSampling.hlsli Outdated
Comment thread package/Shaders/Lighting.hlsl
Comment thread src/Deferred.cpp Outdated
Comment thread src/Features/LightLimitFix/ShadowCasterManager.h Outdated
Comment thread src/Features/LightLimitFix/ShadowCasterManager.h Outdated
Comment thread src/Features/LightLimitFix/ShadowRenderer.cpp Outdated
Comment thread src/Features/LightLimitFix/ShadowRenderer.cpp Outdated
Comment thread src/Features/VolumetricShadows.cpp Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (8)
src/Deferred.cpp (2)

172-189: ⚠️ Potential issue | 🟠 Major

Release the old perDirectionalShadow buffer before recreating it.

Deferred::SetupResources() can rerun when render targets are recreated. Line 187 overwrites the raw pointer without deleting the previous Buffer, so each reset leaks one GPU resource.

Proposed fix
-		perDirectionalShadow = new Buffer(sbDesc);
+		delete perDirectionalShadow;
+		perDirectionalShadow = nullptr;
+		perDirectionalShadow = new Buffer(sbDesc);
 		perDirectionalShadow->CreateSRV(srvDesc);

As per coding guidelines, "Include proper resource management and graceful degradation for DirectX 11 resources and user input validation to prevent crashes from malformed configurations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Deferred.cpp` around lines 172 - 189, Deferred::SetupResources currently
overwrites the raw perDirectionalShadow pointer without releasing the previous
GPU resource, leaking a Buffer; before assigning a new Buffer(sbDesc) call
delete (or release) on the existing perDirectionalShadow if it's non-null and
set it to nullptr, then recreate it and call
perDirectionalShadow->CreateSRV(srvDesc); update error paths to leave a valid
state if creation fails and ensure the Buffer destructor/release path frees the
underlying SRV and D3D resources via the Buffer class.

118-131: ⚠️ Potential issue | 🟠 Major

Reset shadowMapSlots when kSHADOWMAPS discovery fails.

Keeping the previous value here lets later passes keep treating a stale shadow-array capacity as valid after a target recreation or setup failure.

Proposed fix
 		if (desc.ViewDimension == D3D11_SRV_DIMENSION_TEXTURE2DARRAY && desc.Texture2DArray.ArraySize > 0) {
 			shadowMapSlots = desc.Texture2DArray.ArraySize;
 			logger::info("[Deferred] kSHADOWMAPS ArraySize = {}, effective shadowMapSlots = {}", desc.Texture2DArray.ArraySize, shadowMapSlots);
 		} else {
-			logger::warn("[Deferred] kSHADOWMAPS SRV not a Texture2DArray or ArraySize=0; keeping shadowMapSlots = {}", shadowMapSlots);
+			shadowMapSlots = 0;
+			logger::warn("[Deferred] kSHADOWMAPS SRV not a Texture2DArray or ArraySize=0; resetting shadowMapSlots to 0");
 		}
 	} else {
-		logger::warn("[Deferred] kSHADOWMAPS depthSRV is null at SetupResources; keeping shadowMapSlots = {}", shadowMapSlots);
+		shadowMapSlots = 0;
+		logger::warn("[Deferred] kSHADOWMAPS depthSRV is null at SetupResources; resetting shadowMapSlots to 0");
 	}

As per coding guidelines, "Include proper resource management and graceful degradation for DirectX 11 resources and user input validation to prevent crashes from malformed configurations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Deferred.cpp` around lines 118 - 131, When kSHADOWMAPS discovery fails in
SetupResources (i.e., depthSRV is null or desc.ViewDimension !=
D3D11_SRV_DIMENSION_TEXTURE2DARRAY or desc.Texture2DArray.ArraySize == 0), reset
shadowMapSlots to 0 instead of preserving the prior value; update the branches
that currently log warnings (the else branches around
renderer->GetDepthStencilData().depthStencils[RE::RENDER_TARGET_DEPTHSTENCIL::kSHADOWMAPS].depthSRV
and the ViewDimension/ArraySize check) to assign shadowMapSlots = 0 before
logging so downstream passes don’t use a stale capacity.
features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli (1)

12-15: ⚠️ Potential issue | 🟠 Major

Use eyeIndex consistently in the camera-adjusted depth path.

Lines 14, 65-66, and 69 hard-code FrameBuffer::CameraPosAdjust[0]. In VR the right eye will pick cascades and ray endpoints from the left-eye origin, which is a direct stereo mismatch near split boundaries.

Proposed fix
 	float GetShadowDepth(float3 positionWS, uint eyeIndex = 0)
 	{
-		return length(positionWS - FrameBuffer::CameraPosAdjust[0].xyz);
+		return length(positionWS - FrameBuffer::CameraPosAdjust[eyeIndex].xyz);
 	}
@@
-		startPosition += FrameBuffer::CameraPosAdjust[0].xyz;
-		endPosition += FrameBuffer::CameraPosAdjust[0].xyz;
+		startPosition += FrameBuffer::CameraPosAdjust[eyeIndex].xyz;
+		endPosition += FrameBuffer::CameraPosAdjust[eyeIndex].xyz;
@@
-		float shadowMapDepth = length(midPosition - FrameBuffer::CameraPosAdjust[0].xyz);
+		float shadowMapDepth = length(midPosition - FrameBuffer::CameraPosAdjust[eyeIndex].xyz);

Also applies to: 63-70

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli around lines 12 - 15,
GetShadowDepth and nearby camera-adjusted depth/cascade/ray-endpoint code use
FrameBuffer::CameraPosAdjust[0] instead of the provided eyeIndex, causing stereo
mismatch; update GetShadowDepth to index CameraPosAdjust with the eyeIndex
parameter and change the other hard-coded CameraPosAdjust[0] accesses in the
same block (cascade selection and ray endpoint calculations) to use the eyeIndex
variable (or pass the correct eyeIndex through helpers) so each eye uses its own
CameraPosAdjust entry.
src/Features/LightLimitFix.cpp (1)

598-614: ⚠️ Potential issue | 🟠 Major

Track consumed shadow-map slices, not shadowed lights.

shadowMapSlots is array-slice capacity. Comparing it to bufferIndex lets a multi-slice light (shadowMapCount == 2) keep its Shadow flag after the array is already full, so the shader can sample a stale or out-of-range shadowMapIndex.

Proposed fix
-		int bufferIndex = 0;
-		int mapIndex = 0;
+		uint32_t mapIndex = 0;
 		while (true) {
 			RE::BSShadowLight* light = shadowSceneNode->GetRuntimeData().shadowLightsAccum[mapIndex];
 			if (!light)
 				break;
 
-			// Only set Shadow flag for lights with a valid written slot.
-			// Overflow lights still use addShadowLight for correct color/radius setup,
-			// but without the Shadow flag so the HLSL does not do a shadow map lookup
-			// with a stale or out-of-range shadowMapIndex.
-			addShadowLight(light, bufferIndex < (int)globals::deferred->shadowMapSlots);
-
-			mapIndex += light->shadowMapCount;
-			bufferIndex++;
+			const uint32_t shadowMapCount = light->shadowMapCount;
+			const bool hasWrittenSlots = mapIndex + shadowMapCount <= globals::deferred->shadowMapSlots;
+
+			// Only set Shadow for lights whose slices fit inside the written array.
+			addShadowLight(light, hasWrittenSlots);
+			mapIndex += shadowMapCount;
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/LightLimitFix.cpp` around lines 598 - 614, The loop currently
compares bufferIndex (counting lights) against
globals::deferred->shadowMapSlots, which allows multi-slice lights
(shadowMapCount>1) to be marked Shadow even when the slice capacity is
exhausted; change the logic to track consumed shadow-map slices instead:
introduce a consumedSlices (or reuse bufferIndex as slice counter) and for each
RE::BSShadowLight* light from
shadowSceneNode->GetRuntimeData().shadowLightsAccum use consumedSlices and
light->shadowMapCount to decide the flag passed to addShadowLight (true only if
consumedSlices + light->shadowMapCount <=
(int)globals::deferred->shadowMapSlots), then increment consumedSlices by
light->shadowMapCount; keep mapIndex += light->shadowMapCount to advance the
source array.
src/Features/VolumetricShadows.cpp (1)

276-279: ⚠️ Potential issue | 🟠 Major

Bind slot 18 only when this frame has a valid source shadow SRV.

Line 278 falls back to shadowCopySRV when shadowView is null, so a frame with no current source keeps sampling the previous frame’s blurred VSM texture instead of disabling the VSM path for that frame.

Proposed fix
-		ID3D11ShaderResourceView* srv = shadowCopySRV ? shadowCopySRV : shadowView;
+		ID3D11ShaderResourceView* srv = shadowView ? shadowCopySRV : nullptr;
 		context->PSSetShaderResources(18, 1, &srv);

As per coding guidelines, "Include proper resource management and graceful degradation for DirectX 11 resources and user input validation to prevent crashes from malformed configurations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/VolumetricShadows.cpp` around lines 276 - 279, The current
binding falls back to shadowCopySRV when shadowView is null causing stale VSM
sampling; change the logic around the PSSetShaderResources call so you only bind
a VSM SRV to slot 18 when the current-frame source SRV (shadowView) is valid —
if shadowView is null, bind a null/empty SRV to slot 18 (do not use
shadowCopySRV as fallback) while leaving the shadow data structured buffer
binding (Deferred::CopyShadowData()) unchanged; update the code around the
shadowView/shadowCopySRV variables and the context->PSSetShaderResources(18, 1,
...) call accordingly.
package/Shaders/Common/ShadowSampling.hlsli (1)

45-46: ⚠️ Potential issue | 🔴 Critical

Shadows still conflicts with the grass-collision SRV slot.

features/Grass Collision/Shaders/GrassCollision/GrassCollision.hlsli already binds Collision to t100. Keeping Shadows at t100 makes the grass + shadow-light permutation invalid, so the shadow-light SRVs need a dedicated range and the matching CPU binds updated with it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package/Shaders/Common/ShadowSampling.hlsli` around lines 45 - 46, The Shadow
SRV binding for StructuredBuffer<ShadowData> named Shadows is conflicting with
the grass collision SRV (Collision bound to t100); move the shadow SRV range
away from t100 (e.g., choose an unused register range for Shadows and ShadowMaps
instead of t100/t101) and update the HLSL declarations (Shadows and ShadowMaps)
to the new registers, then update the matching CPU-side bind code that sets
these SRVs so the new register indices are used; ensure you also update any
permutation or binding table that assumes t100 to avoid the grass+shadow-light
permutation failure (refer to Shadows, ShadowMaps, and Collision to locate all
usages).
src/Features/LightLimitFix/ShadowCasterManager.h (2)

116-120: ⚠️ Potential issue | 🟠 Major

Clamp the combined shadow-slot budget before allocation.

These two fields share the same renderer limit, so malformed JSON can still push ShadowLightCount + ConvertedShadowSlots + 1 past the supported slot count even if each field is individually "valid". Please cap the combined total in the load/init path before allocating the light container or DX11 resources. As per coding guidelines, "Include proper resource management and graceful degradation for DirectX 11 resources and user input validation to prevent crashes from malformed configurations".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/LightLimitFix/ShadowCasterManager.h` around lines 116 - 120,
Clamp the combined shadow-slot budget before any allocation: in the manager's
load/init code where ShadowLightCount and ConvertedShadowSlots are
parsed/validated, compute total = ShadowLightCount + ConvertedShadowSlots + 1,
then cap total to the renderer's max slots and reduce ConvertedShadowSlots (or
ShadowLightCount) accordingly so allocations for the light container and DX11
resources never request more than the supported slot count; ensure the capped
values are used for subsequent resource creation and add a safe fallback path
that logs the adjustment and avoids creating oversized DX11 resources.

64-67: 🛠️ Refactor suggestion | 🟠 Major

Delete FormulaHelper copy operations.

_ptr is owning state torn down in ~FormulaHelper(), so the compiler-generated copy members would shallow-copy it and double-delete on the first accidental copy. Please delete copy/move here unless you implement real transfer semantics.

🛠️ Example fix
 	struct FormulaHelper
 	{
 		FormulaHelper();
 		~FormulaHelper();
+		FormulaHelper(const FormulaHelper&) = delete;
+		FormulaHelper& operator=(const FormulaHelper&) = delete;
+		FormulaHelper(FormulaHelper&&) = delete;
+		FormulaHelper& operator=(FormulaHelper&&) = delete;
 
 		bool Parse(const std::string& input);
 		double Calculate();
@@
 	private:
 		void* _ptr;
 	};

Also applies to: 84-85

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/LightLimitFix/ShadowCasterManager.h` around lines 64 - 67,
FormulaHelper owns `_ptr` and defines a destructor, so you must prevent implicit
shallow copies; declare the copy constructor and copy-assignment operator as
deleted (FormulaHelper(const FormulaHelper&) = delete; FormulaHelper&
operator=(const FormulaHelper&) = delete;) and also delete the move constructor
and move-assignment operator unless you implement transfer semantics
(FormulaHelper(FormulaHelper&&) = delete; FormulaHelper&
operator=(FormulaHelper&&) = delete;). Apply the same deletion to the other
owning struct in this file that holds `_ptr`.
🧹 Nitpick comments (1)
package/Shaders/RunGrass.hlsl (1)

598-599: Compute the PCF rotation matrix lazily.

Both pixel shader variants build this before they know whether the tile contains any shadowed point lights. In grass-heavy scenes that adds per-fragment work even when the clustered-light loop exits early or every clustered light is unshadowed. Compute it on the first LightLimitFix::LightFlags::Shadow hit instead.

Also applies to: 873-874

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package/Shaders/RunGrass.hlsl` around lines 598 - 599, Currently the
rotationMatrix is computed unconditionally with
ShadowSampling::GetPCFRotationMatrix(input.WorldPosition.xyz); move that call so
it is computed lazily the first time a light with
LightLimitFix::LightFlags::Shadow is encountered in the per-pixel
clustered-light loop: introduce a local flag (e.g. bool pcfComputed) and a
float2x2 pcfRotation local, then on the first shadow-flag hit call
ShadowSampling::GetPCFRotationMatrix(input.WorldPosition.xyz) to populate
pcfRotation and set pcfComputed, and use pcfRotation thereafter instead of the
eagerly-created rotationMatrix; apply the same change in both pixel shader
variants (also update the other occurrence around lines 873-874).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package/Shaders/Common/ShadowSampling.hlsli`:
- Around line 156-157: The current fade uses the full range (fade =
saturate(shadowMapDepth / shadow.EndSplitDistances.y)), causing early fading;
change it to a narrow edge-only ramp: compute a small window near
shadow.EndSplitDistances.y (e.g., margin either a small constant or a fraction
of the cascade size using shadow.StartSplitDistances.y and
shadow.EndSplitDistances.y) and remap shadowMapDepth into that window using a
smooth ramp (smoothstep or equivalent) so fading only starts inside that narrow
boundary; update the calculation that produces fade (referencing fade,
shadowMapDepth, shadow.StartSplitDistances.y, and shadow.EndSplitDistances.y)
accordingly.

In `@src/Deferred.cpp`:
- Around line 580-589: The early return guarded by shadowMapSlots causes
directional shadow cascade upload to be skipped and leaves stale SRV bindings in
t98/t99; instead, stop gating the sun cascade upload on shadowMapSlots and gate
on the presence of sunShadowLight and the cascade SRVs (e.g., check
globals::game::smState->shadowSceneNode,
shadowSceneNode->GetRuntimeData().sunShadowDirLight and the ESram cascade SRVs),
and on any path where the cascade data is unavailable explicitly bind null to
shader slots t98 and t99 to clear previous-frame bindings before returning;
update the logic around shadowMapSlots, shadowSceneNode, sunShadowLight and the
code paths referenced (lines handling t98/t99 binding at the end of the block)
to implement these checks and null-bind clears.

In `@src/Features/LightLimitFix/ShadowCasterManager.h`:
- Around line 76-82: The Validate/SetParam/GetParam trio currently shares a
global parameter table allowing light-scoped symbols in RedrawBudgetFormula;
update Validate(const std::string& input, std::string& errorOut) to enforce a
symbol whitelist when validating expressions for RedrawBudgetFormula (only allow
frametime, frametarget, stableframes, camera/environment params) and reject or
rewrite any light-scoped symbols (lightindex, lightintensity, etc.), or
alternatively split the backing parameter storage into separate tables per
formula type and have Validate check/associate the correct table; also ensure
SetParam and GetParam operate against the per-formula table (or respect the same
symbol restrictions) so runtime reads/writes cannot access stale light-scoped
values for RedrawBudgetFormula.

---

Duplicate comments:
In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli:
- Around line 12-15: GetShadowDepth and nearby camera-adjusted
depth/cascade/ray-endpoint code use FrameBuffer::CameraPosAdjust[0] instead of
the provided eyeIndex, causing stereo mismatch; update GetShadowDepth to index
CameraPosAdjust with the eyeIndex parameter and change the other hard-coded
CameraPosAdjust[0] accesses in the same block (cascade selection and ray
endpoint calculations) to use the eyeIndex variable (or pass the correct
eyeIndex through helpers) so each eye uses its own CameraPosAdjust entry.

In `@package/Shaders/Common/ShadowSampling.hlsli`:
- Around line 45-46: The Shadow SRV binding for StructuredBuffer<ShadowData>
named Shadows is conflicting with the grass collision SRV (Collision bound to
t100); move the shadow SRV range away from t100 (e.g., choose an unused register
range for Shadows and ShadowMaps instead of t100/t101) and update the HLSL
declarations (Shadows and ShadowMaps) to the new registers, then update the
matching CPU-side bind code that sets these SRVs so the new register indices are
used; ensure you also update any permutation or binding table that assumes t100
to avoid the grass+shadow-light permutation failure (refer to Shadows,
ShadowMaps, and Collision to locate all usages).

In `@src/Deferred.cpp`:
- Around line 172-189: Deferred::SetupResources currently overwrites the raw
perDirectionalShadow pointer without releasing the previous GPU resource,
leaking a Buffer; before assigning a new Buffer(sbDesc) call delete (or release)
on the existing perDirectionalShadow if it's non-null and set it to nullptr,
then recreate it and call perDirectionalShadow->CreateSRV(srvDesc); update error
paths to leave a valid state if creation fails and ensure the Buffer
destructor/release path frees the underlying SRV and D3D resources via the
Buffer class.
- Around line 118-131: When kSHADOWMAPS discovery fails in SetupResources (i.e.,
depthSRV is null or desc.ViewDimension != D3D11_SRV_DIMENSION_TEXTURE2DARRAY or
desc.Texture2DArray.ArraySize == 0), reset shadowMapSlots to 0 instead of
preserving the prior value; update the branches that currently log warnings (the
else branches around
renderer->GetDepthStencilData().depthStencils[RE::RENDER_TARGET_DEPTHSTENCIL::kSHADOWMAPS].depthSRV
and the ViewDimension/ArraySize check) to assign shadowMapSlots = 0 before
logging so downstream passes don’t use a stale capacity.

In `@src/Features/LightLimitFix.cpp`:
- Around line 598-614: The loop currently compares bufferIndex (counting lights)
against globals::deferred->shadowMapSlots, which allows multi-slice lights
(shadowMapCount>1) to be marked Shadow even when the slice capacity is
exhausted; change the logic to track consumed shadow-map slices instead:
introduce a consumedSlices (or reuse bufferIndex as slice counter) and for each
RE::BSShadowLight* light from
shadowSceneNode->GetRuntimeData().shadowLightsAccum use consumedSlices and
light->shadowMapCount to decide the flag passed to addShadowLight (true only if
consumedSlices + light->shadowMapCount <=
(int)globals::deferred->shadowMapSlots), then increment consumedSlices by
light->shadowMapCount; keep mapIndex += light->shadowMapCount to advance the
source array.

In `@src/Features/LightLimitFix/ShadowCasterManager.h`:
- Around line 116-120: Clamp the combined shadow-slot budget before any
allocation: in the manager's load/init code where ShadowLightCount and
ConvertedShadowSlots are parsed/validated, compute total = ShadowLightCount +
ConvertedShadowSlots + 1, then cap total to the renderer's max slots and reduce
ConvertedShadowSlots (or ShadowLightCount) accordingly so allocations for the
light container and DX11 resources never request more than the supported slot
count; ensure the capped values are used for subsequent resource creation and
add a safe fallback path that logs the adjustment and avoids creating oversized
DX11 resources.
- Around line 64-67: FormulaHelper owns `_ptr` and defines a destructor, so you
must prevent implicit shallow copies; declare the copy constructor and
copy-assignment operator as deleted (FormulaHelper(const FormulaHelper&) =
delete; FormulaHelper& operator=(const FormulaHelper&) = delete;) and also
delete the move constructor and move-assignment operator unless you implement
transfer semantics (FormulaHelper(FormulaHelper&&) = delete; FormulaHelper&
operator=(FormulaHelper&&) = delete;). Apply the same deletion to the other
owning struct in this file that holds `_ptr`.

In `@src/Features/VolumetricShadows.cpp`:
- Around line 276-279: The current binding falls back to shadowCopySRV when
shadowView is null causing stale VSM sampling; change the logic around the
PSSetShaderResources call so you only bind a VSM SRV to slot 18 when the
current-frame source SRV (shadowView) is valid — if shadowView is null, bind a
null/empty SRV to slot 18 (do not use shadowCopySRV as fallback) while leaving
the shadow data structured buffer binding (Deferred::CopyShadowData())
unchanged; update the code around the shadowView/shadowCopySRV variables and the
context->PSSetShaderResources(18, 1, ...) call accordingly.

---

Nitpick comments:
In `@package/Shaders/RunGrass.hlsl`:
- Around line 598-599: Currently the rotationMatrix is computed unconditionally
with ShadowSampling::GetPCFRotationMatrix(input.WorldPosition.xyz); move that
call so it is computed lazily the first time a light with
LightLimitFix::LightFlags::Shadow is encountered in the per-pixel
clustered-light loop: introduce a local flag (e.g. bool pcfComputed) and a
float2x2 pcfRotation local, then on the first shadow-flag hit call
ShadowSampling::GetPCFRotationMatrix(input.WorldPosition.xyz) to populate
pcfRotation and set pcfComputed, and use pcfRotation thereafter instead of the
eagerly-created rotationMatrix; apply the same change in both pixel shader
variants (also update the other occurrence around lines 873-874).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 49bb58e4-c1dd-470b-917f-2d1f850315d4

📥 Commits

Reviewing files that changed from the base of the PR and between b1089cd and 5f27c69.

📒 Files selected for processing (26)
  • CMakeLists.txt
  • extern/CommonLibSSE-NG
  • features/Light Limit Fix/Shaders/LightLimitFix/Common.hlsli
  • features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli
  • features/Volumetric Shadows/Shaders/VolumetricShadows/CopyShadowDataCS.hlsl
  • features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli
  • package/Shaders/Common/ShadowSampling.hlsli
  • package/Shaders/Common/SharedData.hlsli
  • package/Shaders/Lighting.hlsl
  • package/Shaders/RunGrass.hlsl
  • src/Deferred.cpp
  • src/Deferred.h
  • src/Features/LightLimitFix.cpp
  • src/Features/LightLimitFix.h
  • src/Features/LightLimitFix/ShadowCasterManager.cpp
  • src/Features/LightLimitFix/ShadowCasterManager.h
  • src/Features/LightLimitFix/ShadowRenderer.cpp
  • src/Features/RenderDoc.cpp
  • src/Features/VolumetricShadows.cpp
  • src/Features/VolumetricShadows.h
  • src/Globals.cpp
  • src/Globals.h
  • src/Menu/FeatureListRenderer.cpp
  • src/State.cpp
  • src/XSEPlugin.cpp
  • vcpkg.json
💤 Files with no reviewable changes (3)
  • features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli
  • src/Features/VolumetricShadows.h
  • features/Volumetric Shadows/Shaders/VolumetricShadows/CopyShadowDataCS.hlsl
✅ Files skipped from review due to trivial changes (7)
  • extern/CommonLibSSE-NG
  • vcpkg.json
  • src/State.cpp
  • src/Menu/FeatureListRenderer.cpp
  • src/XSEPlugin.cpp
  • src/Globals.h
  • package/Shaders/Lighting.hlsl
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/Features/RenderDoc.cpp
  • features/Light Limit Fix/Shaders/LightLimitFix/Common.hlsli
  • src/Globals.cpp
  • package/Shaders/Common/SharedData.hlsli
  • src/Features/LightLimitFix/ShadowRenderer.cpp

Comment thread package/Shaders/Common/ShadowSampling.hlsli Outdated
Comment thread package/Shaders/Common/ShadowSampling.hlsli Outdated
Comment thread src/Deferred.cpp Outdated
Comment thread src/Features/LightLimitFix/ShadowCasterManager.h Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (1)
src/Features/VolumetricShadows.cpp (1)

276-279: ⚠️ Potential issue | 🟠 Major

Bind null when the current shadow SRV is missing.

If Line 83 returns nullptr, this still rebinds the previous shadowCopySRV, so slot 18 samples stale shadow data from an earlier frame/scene instead of disabling the VSM path for this frame.

Suggested fix
-		ID3D11ShaderResourceView* srv = shadowCopySRV ? shadowCopySRV : shadowView;
+		ID3D11ShaderResourceView* srv = shadowView ? (shadowCopySRV ? shadowCopySRV : shadowView) : nullptr;
 		context->PSSetShaderResources(18, 1, &srv);

As per coding guidelines, "Include proper resource management and graceful degradation for DirectX 11 resources and user input validation to prevent crashes from malformed configurations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/VolumetricShadows.cpp` around lines 276 - 279, The current
binding logic uses ID3D11ShaderResourceView* srv = shadowCopySRV ? shadowCopySRV
: shadowView; which can rebind a stale shadowCopySRV when both are null; change
it so that srv is explicitly nullptr if neither shadowCopySRV nor shadowView is
valid and then call context->PSSetShaderResources(18, 1, &srv) with that nullptr
to unbind slot 18; update the code path around shadowCopySRV, shadowView and the
PSSetShaderResources call to ensure slot 18 is explicitly cleared when no
current SRV exists (use a local ID3D11ShaderResourceView* nullSrv = nullptr and
pass its address).
🧹 Nitpick comments (3)
src/Features/LightLimitFix.h (1)

228-228: Partial array initialization.

uint clusterSize[3] = { 16 } initializes only the first element; the remaining two elements are zero-initialized. If the intent is {16, 16, 16}, make it explicit. If {16, 0, 0} is intended, a comment would clarify.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/LightLimitFix.h` at line 228, The array clusterSize defined as
"uint clusterSize[3] = { 16 }" only initializes the first element and leaves the
other two as zero; update the initializer to explicitly set all three entries
(e.g., change the initializer to {16, 16, 16} if that's intended) or keep {16,
0, 0} but add a clarifying comment next to clusterSize to document the intended
shape so future readers know whether all three dimensions should be 16 or zeros
are deliberate.
features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli (1)

120-122: Fade curve difference between 2D and 3D paths.

GetVSMShadow3D uses pow(fade * fade, 8) (line 120), while GetVSMShadow2D uses pow(fade, 8) (line 174). The 3D path effectively applies pow(fade, 16) — a much sharper falloff. If this is intentional for volumetric vs surface shadowing, consider adding a comment; otherwise align them.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli around lines 120 -
122, GetVSMShadow3D uses pow(fade * fade, 8) producing an effective pow(fade,16)
which differs from GetVSMShadow2D's pow(fade,8); either align the 3D path by
replacing pow(fade * fade, 8) with pow(fade, 8) (and update
fadeFactor/surfaceShadow usage accordingly) or add a brief comment in
VolumetricShadows.hlsli next to GetVSMShadow3D/fadeFactor explaining the
intentional stronger falloff for volumetric shadows so reviewers know the
discrepancy is deliberate.
package/Shaders/Common/ShadowSampling.hlsli (1)

380-387: Magic sentinel values for slot state could use named constants.

ShadowParam.y == 0 (unwritten), < 0 (suppressed), > 0 (valid) are checked inline. Consider extracting these as named constants for clarity, though the comments document the semantics adequately.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package/Shaders/Common/ShadowSampling.hlsli` around lines 380 - 387, Replace
the inline magic checks against shadow.ShadowParam.y with well-named constants
to clarify slot states: define symbolic names for the unwritten sentinel (== 0),
the suppressed sentinel (< 0) and the valid-positive case (> 0) and use those
constants in the comparisons where ShadowParam.y is tested; update the nearby
comment to reference the constants (and their meanings) so future readers see
intent instead of raw numbers. Ensure the constants are visible to the same
shader scope (e.g., top of ShadowSampling.hlsli or shared header) and keep the
comparison logic in the same function using the new names.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli:
- Around line 153-154: GetVSMShadow2D is incorrectly using
mul(transpose(shadowProj), float4(position,1)) while GetVSMShadow3D and other
shadow code (in ShadowSampling.hlsli) consistently use mul(shadowProj,
float4(...,1)) for the column_major ShadowProj matrix; update GetVSMShadow2D to
remove transpose and use mul(shadowProj, float4(position, 1)) (and the analogous
change at the other occurrence) so both 2D and 3D paths use the same matrix
multiplication convention with ShadowProj/primaryCascade.

In `@package/Shaders/Lighting.hlsl`:
- Around line 2433-2435: The SharedShadowMap register t80 declared in
Common/ShadowSampling.hlsli collides with TexLandDisplacement0Sampler (t80–t85)
used when LANDSCAPE and TRUE_PBR are defined; update the shader declarations so
they don't overlap: either wrap the SharedShadowMap (and any related Shared*
resources) register assignment in a conditional (`#if` !defined(LANDSCAPE) ||
!defined(TRUE_PBR)) or pick a nonconflicting register when both LANDSCAPE and
TRUE_PBR are defined (e.g., move SharedShadowMap to a free register and update
its declaration in Common/ShadowSampling.hlsli and any call sites like
ShadowSampling::GetLightingShadow), ensuring all references in Lighting.hlsl and
ShadowSampling.hlsli use the matching symbol names (SharedShadowMap,
ShadowSampling::GetLightingShadow) so permutations compile without register
collisions.

In `@src/Features/LightLimitFix.cpp`:
- Around line 206-208: LightLimitFix::LoadSettings currently assigns the
incoming json directly ("settings = o_json") which throws on malformed or
type-mismatched user config; change LoadSettings to validate and merge instead:
keep the existing defaults in the member "settings", check for presence and
correct types on keys in "o_json" (use contains and is_* checks), only overwrite
validated fields, and wrap the parse/merge in a try/catch to log errors and
leave defaults intact on failure so startup does not crash.

In `@src/Features/LightLimitFix.h`:
- Around line 134-135: previousEnableLightsVisualisation and
currentEnableLightsVisualisation are being initialized from
settings.EnableLightsVisualisation before Settings settings is declared, causing
a forward-reference to uninitialized data; fix by ensuring settings is
initialized before those members—either move the declaration of Settings
settings so it appears before
previousEnableLightsVisualisation/currentEnableLightsVisualisation, or stop
using member initializer expressions and instead set
previousEnableLightsVisualisation and currentEnableLightsVisualisation from
settings.EnableLightsVisualisation inside the class constructor (use Settings
settings as the source and assign to
previousEnableLightsVisualisation/currentEnableLightsVisualisation there).

In `@src/Features/LightLimitFix/ShadowCasterManager.h`:
- Around line 40-51: ForEachShadowLight currently assumes accum is
null-terminated and can read out of bounds; update the loop to check the array
length via accum.size() (or its equivalent) before accessing accum[idx] and stop
when idx >= accum.size(); also handle the case where accum[idx] is nullptr by
breaking or continuing as intended, and ensure idx increments by
light->shadowMapCount only after confirming light is non-null—make these changes
in the ForEachShadowLight template to prevent OOB reads.

In `@src/Features/VolumetricShadows.cpp`:
- Around line 77-79: Update the two outdated comments that refer to slot 19 to
reflect the actual PS register bindings: change mentions of "t19" / "slot 19" to
"t98/t99" or "slots 98 and 99" as appropriate; specifically update the comment
near the top that references Deferred::CopyShadowData() and the later comment
referencing the shadow data structured buffer so they state that
Deferred::CopyShadowData() binds the directional shadow structured buffer to PS
slot 98 and the cascade texture to PS slot 99 (matching ShadowSampling.hlsli and
the DirectionalShadows/DirectionalShadowCascades declarations).

---

Duplicate comments:
In `@src/Features/VolumetricShadows.cpp`:
- Around line 276-279: The current binding logic uses ID3D11ShaderResourceView*
srv = shadowCopySRV ? shadowCopySRV : shadowView; which can rebind a stale
shadowCopySRV when both are null; change it so that srv is explicitly nullptr if
neither shadowCopySRV nor shadowView is valid and then call
context->PSSetShaderResources(18, 1, &srv) with that nullptr to unbind slot 18;
update the code path around shadowCopySRV, shadowView and the
PSSetShaderResources call to ensure slot 18 is explicitly cleared when no
current SRV exists (use a local ID3D11ShaderResourceView* nullSrv = nullptr and
pass its address).

---

Nitpick comments:
In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli:
- Around line 120-122: GetVSMShadow3D uses pow(fade * fade, 8) producing an
effective pow(fade,16) which differs from GetVSMShadow2D's pow(fade,8); either
align the 3D path by replacing pow(fade * fade, 8) with pow(fade, 8) (and update
fadeFactor/surfaceShadow usage accordingly) or add a brief comment in
VolumetricShadows.hlsli next to GetVSMShadow3D/fadeFactor explaining the
intentional stronger falloff for volumetric shadows so reviewers know the
discrepancy is deliberate.

In `@package/Shaders/Common/ShadowSampling.hlsli`:
- Around line 380-387: Replace the inline magic checks against
shadow.ShadowParam.y with well-named constants to clarify slot states: define
symbolic names for the unwritten sentinel (== 0), the suppressed sentinel (< 0)
and the valid-positive case (> 0) and use those constants in the comparisons
where ShadowParam.y is tested; update the nearby comment to reference the
constants (and their meanings) so future readers see intent instead of raw
numbers. Ensure the constants are visible to the same shader scope (e.g., top of
ShadowSampling.hlsli or shared header) and keep the comparison logic in the same
function using the new names.

In `@src/Features/LightLimitFix.h`:
- Line 228: The array clusterSize defined as "uint clusterSize[3] = { 16 }" only
initializes the first element and leaves the other two as zero; update the
initializer to explicitly set all three entries (e.g., change the initializer to
{16, 16, 16} if that's intended) or keep {16, 0, 0} but add a clarifying comment
next to clusterSize to document the intended shape so future readers know
whether all three dimensions should be 16 or zeros are deliberate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 60805a5b-faf6-43f6-80a4-ce9ed0678bdd

📥 Commits

Reviewing files that changed from the base of the PR and between 5f27c69 and d373e24.

📒 Files selected for processing (27)
  • .github/configs/shader-validation-vr.yaml
  • CMakeLists.txt
  • extern/CommonLibSSE-NG
  • features/Light Limit Fix/Shaders/LightLimitFix/Common.hlsli
  • features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli
  • features/Volumetric Shadows/Shaders/VolumetricShadows/CopyShadowDataCS.hlsl
  • features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli
  • package/Shaders/Common/ShadowSampling.hlsli
  • package/Shaders/Common/SharedData.hlsli
  • package/Shaders/Lighting.hlsl
  • package/Shaders/RunGrass.hlsl
  • src/Deferred.cpp
  • src/Deferred.h
  • src/Features/LightLimitFix.cpp
  • src/Features/LightLimitFix.h
  • src/Features/LightLimitFix/ShadowCasterManager.cpp
  • src/Features/LightLimitFix/ShadowCasterManager.h
  • src/Features/LightLimitFix/ShadowRenderer.cpp
  • src/Features/RenderDoc.cpp
  • src/Features/VolumetricShadows.cpp
  • src/Features/VolumetricShadows.h
  • src/Globals.cpp
  • src/Globals.h
  • src/Menu/FeatureListRenderer.cpp
  • src/State.cpp
  • src/XSEPlugin.cpp
  • vcpkg.json
💤 Files with no reviewable changes (3)
  • features/Light Limit Fix/Shaders/LightLimitFix/LightLimitFix.hlsli
  • src/Features/VolumetricShadows.h
  • features/Volumetric Shadows/Shaders/VolumetricShadows/CopyShadowDataCS.hlsl
✅ Files skipped from review due to trivial changes (6)
  • src/State.cpp
  • vcpkg.json
  • extern/CommonLibSSE-NG
  • src/XSEPlugin.cpp
  • .github/configs/shader-validation-vr.yaml
  • src/Menu/FeatureListRenderer.cpp
🚧 Files skipped from review as they are similar to previous changes (6)
  • CMakeLists.txt
  • src/Features/RenderDoc.cpp
  • src/Globals.cpp
  • src/Deferred.cpp
  • src/Deferred.h
  • src/Features/LightLimitFix/ShadowRenderer.cpp

Comment thread features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli Outdated
Comment thread package/Shaders/Lighting.hlsl Outdated
Comment thread src/Features/LightLimitFix.cpp Outdated
Comment thread src/Features/LightLimitFix.h Outdated
Comment thread src/Features/LightLimitFix/ShadowCasterManager.h Outdated
Comment thread src/Features/VolumetricShadows.cpp Outdated
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 2, 2026

✅ A pre-release build is available for this PR:
Download

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (5)
src/Features/LightLimitFix/ShadowCasterManager.h (3)

41-50: ⚠️ Potential issue | 🟡 Minor

Bound ForEachShadowLight() by accum.size().

This helper only stops on a null sentinel. If shadowLightsAccum is truncated or malformed, accum[idx] is read out of bounds before the break condition runs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/LightLimitFix/ShadowCasterManager.h` around lines 41 - 50,
ForEachShadowLight reads accum[idx] until a null sentinel and can index past the
array if accum is malformed; change the loop in ForEachShadowLight to check idx
against accum.size() (use size_t) before accessing accum[idx], e.g., loop while
idx < accum.size() and inside break if accum[idx] is null, then call fn and
advance idx by accum[idx]->shadowMapCount; ensure shadowMapCount is validated
(>=1) before using it to avoid infinite loops or zero-advance.

145-157: ⚠️ Potential issue | 🟠 Major

Reserve the sun slot when sizing and clamping the pool.

ShadowLightCount excludes the directional sun, but GetShadowSlot() reserves slot 0 for it while LightContainer::Size is documented as only ShadowLightCount + ConvertedShadowSlots. If Init() allocates or clamps from that total directly, the pool is one slot short before the renderer cap is even considered. Verify that the effective size is ShadowLightCount + ConvertedShadowSlots + 1 and that malformed JSON is clamped before any allocation path. As per coding guidelines "Include proper resource management and graceful degradation for DirectX 11 resources and user input validation to prevent crashes from malformed configurations."

#!/bin/bash
set -euo pipefail

hdr="$(fd '^ShadowCasterManager\.h$' src)"
cpp="$(fd '^ShadowCasterManager\.cpp$' src)"

printf 'Header contract\n'
sed -n '145,157p' "$hdr"
sed -n '288,289p' "$hdr"
sed -n '393,408p' "$hdr"

printf '\nInit / allocation paths\n'
rg -n -C4 'Init\s*\(|Install\s*\(|ShadowLightCount|ConvertedShadowSlots|FindFreeIndex|GetShadowSlot|new\s+LightEntry|resize\(|\bSize\b' "$cpp"

Also applies to: 288-289, 393-396, 405-408

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/LightLimitFix/ShadowCasterManager.h` around lines 145 - 157, The
pool sizing and clamping logic must reserve the sun slot: ensure all allocation
and clamp code (Init(), Install(), any place that builds or resizes
LightContainer::Size, and code path that reads ShadowLightCount or
ConvertedShadowSlots) computes effectiveSize = ShadowLightCount +
ConvertedShadowSlots + 1 (the extra +1 reserves slot 0 used by GetShadowSlot());
validate and clamp malformed JSON values for
ShadowLightCount/ConvertedShadowSlots before any allocation or new
LightEntry/reserve/resize calls so you never allocate one slot too few, and
update any places that compare against the renderer cap or call FindFreeIndex to
use effectiveSize instead of just ShadowLightCount + ConvertedShadowSlots.

106-119: ⚠️ Potential issue | 🟠 Major

RedrawBudgetFormula still needs formula-scoped validation.

The header documents RedrawBudgetFormula as frame-scoped, but Validate / SetParam / GetParam expose one untyped symbol surface. Unless the implementation filters identifiers by formula kind, users can still reference light* variables here and get stale per-light state. Verify that the parser rejects light-scoped symbols for the budget formula; if it does not, this is still a correctness bug. As per coding guidelines "Include proper resource management and graceful degradation for DirectX 11 resources and user input validation to prevent crashes from malformed configurations."

#!/bin/bash
set -euo pipefail

hdr="$(fd '^ShadowCasterManager\.h$' src)"
cpp="$(fd '^ShadowCasterManager\.cpp$' src)"

printf 'Header API\n'
sed -n '106,120p' "$hdr"

printf '\nFormulaHelper implementation\n'
rg -n -C4 'FormulaHelper::(Validate|SetParam|GetParam|Reparse)|register_symbol|symbol_table|kFormulaParam_' "$cpp"

printf '\nFormula call sites\n'
rg -n -C4 'RedrawBudgetFormula|ScoreFormula|RedrawIntervalFormula|Validate\s*\(|Reparse\s*\(' src/Features/LightLimitFix

Also applies to: 200-202

package/Shaders/Common/ShadowSampling.hlsli (2)

21-21: ⚠️ Potential issue | 🔴 Critical

Guard or move SharedShadowMap.

This still binds to t80, which overlaps the TRUE_PBR landscape SRV range when Lighting.hlsl pulls this header in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package/Shaders/Common/ShadowSampling.hlsli` at line 21, The Texture2D
declaration SharedShadowMap currently binds to register(t80) which conflicts
with the TRUE_PBR landscape SRV range when included by Lighting.hlsl; fix by
either moving the binding to a non-conflicting register or removing the
hardcoded register and guarding the declaration with a macro so callers (e.g.,
Lighting.hlsl) can supply a safe register; update the SharedShadowMap
declaration in ShadowSampling.hlsli to use a guarded/nullable binding (or a
different unique t-slot) to avoid overlapping the TRUE_PBR SRV range.

45-46: ⚠️ Potential issue | 🔴 Critical

Move the shadow-light SRVs out of t100.

The GRASS_COLLISION permutation includes both this header and GrassCollision.hlsli, so Shadows : register(t100) still collides with that SRV block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package/Shaders/Common/ShadowSampling.hlsli` around lines 45 - 46, The SRV
bindings for Shadows and ShadowMaps collide with the GrassCollision SRV at t100;
update the StructuredBuffer<ShadowData> Shadows and Texture2DArray<float>
ShadowMaps register declarations to use unused registers (e.g., move from
register(t100)/t101 to a non-conflicting pair like register(t110)/t111 or
another free range) and ensure any shader references to Shadows and ShadowMaps
are adjusted accordingly so the permutation no longer has SRV register
collisions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package/Shaders/Common/ShadowSampling.hlsli`:
- Around line 156-159: The fade window should be computed from the far-cascade
span instead of the full shadow range: compute cascadeSpan =
shadow.EndSplitDistances.y - shadow.StartSplitDistances.y, set FadeWindow =
cascadeSpan * 0.1 (or a named fraction), compute fadeStart =
shadow.EndSplitDistances.y - FadeWindow, and then compute fade =
saturate((shadowMapDepth - fadeStart) / FadeWindow); update uses of FadeWindow,
fadeStart and the final saturate call (referencing FadeWindow, fadeStart,
shadow.EndSplitDistances.y, shadow.StartSplitDistances.y, and shadowMapDepth)
accordingly.

---

Duplicate comments:
In `@package/Shaders/Common/ShadowSampling.hlsli`:
- Line 21: The Texture2D declaration SharedShadowMap currently binds to
register(t80) which conflicts with the TRUE_PBR landscape SRV range when
included by Lighting.hlsl; fix by either moving the binding to a non-conflicting
register or removing the hardcoded register and guarding the declaration with a
macro so callers (e.g., Lighting.hlsl) can supply a safe register; update the
SharedShadowMap declaration in ShadowSampling.hlsli to use a guarded/nullable
binding (or a different unique t-slot) to avoid overlapping the TRUE_PBR SRV
range.
- Around line 45-46: The SRV bindings for Shadows and ShadowMaps collide with
the GrassCollision SRV at t100; update the StructuredBuffer<ShadowData> Shadows
and Texture2DArray<float> ShadowMaps register declarations to use unused
registers (e.g., move from register(t100)/t101 to a non-conflicting pair like
register(t110)/t111 or another free range) and ensure any shader references to
Shadows and ShadowMaps are adjusted accordingly so the permutation no longer has
SRV register collisions.

In `@src/Features/LightLimitFix/ShadowCasterManager.h`:
- Around line 41-50: ForEachShadowLight reads accum[idx] until a null sentinel
and can index past the array if accum is malformed; change the loop in
ForEachShadowLight to check idx against accum.size() (use size_t) before
accessing accum[idx], e.g., loop while idx < accum.size() and inside break if
accum[idx] is null, then call fn and advance idx by accum[idx]->shadowMapCount;
ensure shadowMapCount is validated (>=1) before using it to avoid infinite loops
or zero-advance.
- Around line 145-157: The pool sizing and clamping logic must reserve the sun
slot: ensure all allocation and clamp code (Init(), Install(), any place that
builds or resizes LightContainer::Size, and code path that reads
ShadowLightCount or ConvertedShadowSlots) computes effectiveSize =
ShadowLightCount + ConvertedShadowSlots + 1 (the extra +1 reserves slot 0 used
by GetShadowSlot()); validate and clamp malformed JSON values for
ShadowLightCount/ConvertedShadowSlots before any allocation or new
LightEntry/reserve/resize calls so you never allocate one slot too few, and
update any places that compare against the renderer cap or call FindFreeIndex to
use effectiveSize instead of just ShadowLightCount + ConvertedShadowSlots.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: aed07b78-0e5c-46b3-9fa0-8ea8b5c1aa40

📥 Commits

Reviewing files that changed from the base of the PR and between d373e24 and f0899c2.

📒 Files selected for processing (4)
  • package/Shaders/Common/ShadowSampling.hlsli
  • package/Shaders/Lighting.hlsl
  • src/Deferred.cpp
  • src/Features/LightLimitFix/ShadowCasterManager.h
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/Deferred.cpp
  • package/Shaders/Lighting.hlsl

Comment thread package/Shaders/Common/ShadowSampling.hlsli Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli (1)

153-166: ⚠️ Potential issue | 🟠 Major

Drop the secondary-cascade transpose.

The primary 2D path here and the other directional shadow paths all use mul(shadowProj, ...). Transposing only the secondary cascade changes the matrix convention at the blend seam, so the secondary sample won't line up with the primary one.

Suggested fix
-			positionLS = mul(transpose(shadowProj), float4(position, 1)).xyz;
+			positionLS = mul(shadowProj, float4(position, 1)).xyz;

Run this to compare the remaining ShadowProj multiplications. Expected result: this is the only transpose(shadowProj) left in the directional paths.

#!/bin/bash
rg -n -C2 'transpose\(shadowProj\)|mul\(shadowProj,\s*float4\(' \
  "features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli" \
  "package/Shaders/Common/ShadowSampling.hlsli"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli around lines 153 -
166, The secondary-cascade path incorrectly applies transpose(shadowProj) when
computing positionLS, causing a matrix-convention mismatch vs the primary path
and misaligned samples; change the secondary cascade multiplication to use
mul(shadowProj, float4(position, 1)) (same as the primary) so shadowProj,
positionLS, SampleVSMCascade2D, primaryCascade and secondaryCascade use
consistent matrix multiplication and remove the transpose call.
package/Shaders/Common/ShadowSampling.hlsli (1)

49-50: ⚠️ Potential issue | 🔴 Critical

Shadows still collides with grass collision at t100.

RunGrass.hlsl pulls in both this header and features/Grass Collision/Shaders/GrassCollision/GrassCollision.hlsli for the GRASS_COLLISION permutation, and that header already binds Collision at t100. Leaving Shadows at t100 makes that combined variant impossible to bind correctly. Move the LLF shadow SRV pair to a non-conflicting range and keep the runtime bindings aligned.

Run this to confirm the binding collision and include path. Expected result: both headers declare t100, and RunGrass.hlsl includes both.

#!/bin/bash
rg -n -C2 'register\(t100\)' \
  "package/Shaders/Common/ShadowSampling.hlsli" \
  "features/Grass Collision/Shaders/GrassCollision/GrassCollision.hlsli"

rg -n -C3 'GrassCollision\.hlsli|ShadowSampling\.hlsli' \
  "package/Shaders/RunGrass.hlsl"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package/Shaders/Common/ShadowSampling.hlsli` around lines 49 - 50, The
ShadowSampling.hlsli file binds StructuredBuffer<ShadowData> Shadows and
Texture2DArray<float> ShadowMaps to register(t100/t101) which collides with
Collision in GrassCollision.hlsli; change the LLF shadow SRV pair to a
non-conflicting register range (e.g., move Shadows and ShadowMaps from t100/t101
to an unused pair such as t110/t111) and update any runtime binding tables or
descriptor indices that reference these symbols so the new registers are used at
runtime; ensure RunGrass.hlsl variant that includes both ShadowSampling.hlsli
and GrassCollision.hlsli compiles with the new non-conflicting registers.
src/Features/LightLimitFix/ShadowCasterManager.h (1)

57-91: ⚠️ Potential issue | 🟠 Major

Budget formulas need their own symbol whitelist.

RedrawBudgetFormula is documented as frame-scoped, but Validate()/SetParam()/GetParam() have no formula kind and expose the full parameter set. That lets light-scoped symbols validate here and then depend on whatever light values were last written into the shared table at budget-evaluation time. Please validate/bind budget formulas against a budget-only symbol set.

Run this to confirm whether budget validation/evaluation is formula-type aware. Expected result: the current implementation uses the same FormulaHelper entry points for all three formulas and does not pass a budget-specific symbol set.

#!/bin/bash
rg -n -C3 'FormulaHelper::Validate|FormulaHelper::SetParam|FormulaHelper::GetParam|RedrawBudgetFormula|ScoreFormula|RedrawIntervalFormula' \
  "src/Features/LightLimitFix/ShadowCasterManager.cpp" \
  "src/Features/LightLimitFix/ShadowCasterManager.h"

As per coding guidelines "Include proper resource management and graceful degradation for DirectX 11 resources and user input validation to prevent crashes from malformed configurations".

Also applies to: 117-120, 201-203

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Features/LightLimitFix/ShadowCasterManager.h` around lines 57 - 91, The
budget formulas (RedrawBudgetFormula/ScoreFormula/RedrawIntervalFormula) are
being validated and bound against the full FormulaParams enum via
FormulaHelper::Validate/SetParam/GetParam, allowing light-scoped symbols to leak
into frame-scoped budget evaluation; add a budget-only symbol whitelist and make
formula validation/evaluation formula-type aware by changing the formula API to
accept a symbol set or formula kind. Update RedrawBudgetFormula (and
ScoreFormula/RedrawIntervalFormula) to call
FormulaHelper::Validate/SetParam/GetParam with a new budgetSymbols set (a
reduced subset of FormulaParams containing only frame-scoped/budget-safe names),
or overload the FormulaHelper methods to accept an enum FormulaKind and
internally switch to the correct whitelist; ensure Validate() signature and any
callers in ShadowCasterManager.cpp use the budget-specific set so light-scoped
params cannot be validated for budget formulas.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@features/Volumetric`
Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli:
- Around line 153-166: The secondary-cascade path incorrectly applies
transpose(shadowProj) when computing positionLS, causing a matrix-convention
mismatch vs the primary path and misaligned samples; change the secondary
cascade multiplication to use mul(shadowProj, float4(position, 1)) (same as the
primary) so shadowProj, positionLS, SampleVSMCascade2D, primaryCascade and
secondaryCascade use consistent matrix multiplication and remove the transpose
call.

In `@package/Shaders/Common/ShadowSampling.hlsli`:
- Around line 49-50: The ShadowSampling.hlsli file binds
StructuredBuffer<ShadowData> Shadows and Texture2DArray<float> ShadowMaps to
register(t100/t101) which collides with Collision in GrassCollision.hlsli;
change the LLF shadow SRV pair to a non-conflicting register range (e.g., move
Shadows and ShadowMaps from t100/t101 to an unused pair such as t110/t111) and
update any runtime binding tables or descriptor indices that reference these
symbols so the new registers are used at runtime; ensure RunGrass.hlsl variant
that includes both ShadowSampling.hlsli and GrassCollision.hlsli compiles with
the new non-conflicting registers.

In `@src/Features/LightLimitFix/ShadowCasterManager.h`:
- Around line 57-91: The budget formulas
(RedrawBudgetFormula/ScoreFormula/RedrawIntervalFormula) are being validated and
bound against the full FormulaParams enum via
FormulaHelper::Validate/SetParam/GetParam, allowing light-scoped symbols to leak
into frame-scoped budget evaluation; add a budget-only symbol whitelist and make
formula validation/evaluation formula-type aware by changing the formula API to
accept a symbol set or formula kind. Update RedrawBudgetFormula (and
ScoreFormula/RedrawIntervalFormula) to call
FormulaHelper::Validate/SetParam/GetParam with a new budgetSymbols set (a
reduced subset of FormulaParams containing only frame-scoped/budget-safe names),
or overload the FormulaHelper methods to accept an enum FormulaKind and
internally switch to the correct whitelist; ensure Validate() signature and any
callers in ShadowCasterManager.cpp use the budget-specific set so light-scoped
params cannot be validated for budget formulas.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 56ee20a2-87b1-4948-b085-4e715f0e4228

📥 Commits

Reviewing files that changed from the base of the PR and between f0899c2 and e51b2dc.

📒 Files selected for processing (5)
  • features/Volumetric Shadows/Shaders/VolumetricShadows/VolumetricShadows.hlsli
  • package/Shaders/Common/ShadowSampling.hlsli
  • src/Features/LightLimitFix.h
  • src/Features/LightLimitFix/ShadowCasterManager.cpp
  • src/Features/LightLimitFix/ShadowCasterManager.h

@alandtse alandtse force-pushed the shadow-limit-fix branch 2 times, most recently from e81b0e2 to 89a8dfa Compare April 5, 2026 00:43
Copy link
Copy Markdown
Contributor

@soda3000 soda3000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scope of this specific PR is huge, so I recommend trying to break it down into smaller self-contained PRs if possible.

Comment thread package/Shaders/Common/ShadowSampling.hlsli Outdated
Comment thread package/Shaders/Common/ShadowSampling.hlsli Outdated
Comment thread package/Shaders/Lighting.hlsl Outdated
Comment thread package/Shaders/Lighting.hlsl Outdated
Comment thread package/Shaders/Lighting.hlsl
Comment thread src/Features/VolumetricShadows.cpp Outdated
Comment thread src/Features/VR.cpp
@alandtse

This comment was marked as outdated.

@alandtse alandtse force-pushed the shadow-limit-fix branch 2 times, most recently from 124c1ff to 0243c7c Compare April 9, 2026 01:39
alandtse and others added 26 commits May 25, 2026 17:52
Two hardening guards in the focus shadow sampling loop added in
76ac692:

- ShadowMapSlots upper bound on focusSlice. When ShadowLightCount is
  configured below 8 the texture array has fewer than 8 slices, so
  GatherRed(slice = 4 + fi) can read past the end. Break before the
  sample if the slice would be out of range.
- Epsilon check on focusClip.w before perspective divide. The focus
  matrices are zero until the engine's focus accumulate populates
  lightTransform; without the guard the divide produces NaN that
  poisons the shadow min() and crashes on some drivers.

Diagnosed from a scene-load CTD without crashlog after the focus
sampling went in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EnableLight's focus-shadow block was gated on
s_settings.ShadowLightCount <= 4, leaving focus_shadow matrices
zero in extended mode and starving the LLF directional sampler of
focus data. Replace with s_focusShadowSlots > 0: the per-frame
slot reservation added in 5497ffb already carves out the
[kFocusShadowBaseSlotIndex .. +focusCount) range from the point
light pool, so the original safety net (suppress focus to avoid
texture-array contention with point lights) is now redundant.

With the gate lifted, GameAccumulate(sun) populates
focusShadowmapDescriptors[i].lightTransform, Deferred uploads the
matrices to DirectionalShadowLightData::FocusShadowProj, and the
LLF shader's focus sampling loop (76ac692, guarded in c6fa469)
actually contributes occlusion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the magic epsilon in the focus shadow w-guard with the
named EPSILON_DIVISION from Common/Math.hlsli. Same value, same
behavior.
Project convention is QueryPerformanceCounter (see State.h); std::chrono
is disfavored. The 1Hz throttle on the per-frame shadow count debug log
now uses QPC directly. Same behavior, matches the rest of the codebase.
Re-applies cb78895 (reverted in 3f2954a) under the same rationale:
GetDirectionalShadow only carries cascades 0/1 in its
DirectionalShadowLightData, so depth past EndSplitDistances.y had no
LLF data. Returning 1.0 (fully lit) there caused brightness regressions
in scenes where geometry extends past cascade 1 -- visible as missing
distant shadows.

Pass the engine's pre-rendered 4-cascade mask sample through to
GetDirectionalShadow as engineMaskShadow. Past EndSplitDistances.y the
function returns it; within cascade 1's far edge it blends LLF's PCF
toward it.

Convenience overloads default engineMaskShadow=1.0 for callers without
TexShadowMaskSampler bound (e.g. Particle.hlsl), preserving prior
behavior on those paths.

The original revert was for a VR HMD-rotation artifact in the
engineMaskShadow sampling itself; that artifact is separate from the
brightness regression and not made worse here.
Mirrors Intellightent's two-layer mitigation. In extended mode
(ShadowLightCount > 4), parabolic point/spot lights occupy
kSHADOWMAPS slots [4..7] -- the same range g_focusShadowBaseSlotIndex
reserves for focus shadows. A stale drawFocusShadows flag on a
parabolic light in those slots sends BSShadowParabolicLight::Render
into its focus loop on a non-directional light and CTDs with no
crashlog at scene load.

Two layers of defense (the per-frame scrub alone suffices; the byte
patches harden against any engine path that bypasses the per-light
flag):

- Restore the three byte patches at SetupResources that zero the
  engine's NumFocusShaderLightsPositive gate and stub the focus
  count setters (RIDs 10209/10247, 10207/10245, 513201/390932).
- Scrub drawFocusShadows = false on every activeShadowLights entry
  and on sunShadowDirLight at the top of ScheduleShadowCasters.

Matches Intellightent main.cpp:1411-1420 verbatim. Accepts the
documented Intellightent-era limitation that focus shadows
(high-res player + tracked NPCs) are off in extended mode.
Address 7 actionable Copilot review comments on PR #35:

- CMakeLists.txt: mark `find_path(EXPRTK_INCLUDE_DIRS)` REQUIRED. Without
  it, a missing exprtk silently propagates `-NOTFOUND` into
  target_include_directories and the build fails later with a confusing
  diagnostic instead of at configure time.

- ShadowCasterManager.h: correct GetShadowSlot's docstring. The previous
  text claimed "0 = sun, 1+ = point lights" but the implementation
  returns -1 for both the sun and inactive lights, and 1..N for points.
  The sun lives in kSHADOWMAPS_ESRAM (separate texture from kSHADOWMAPS),
  so it has no slot in the point-light array.

- LightLimitFix.cpp: extend NLOHMANN serialization to include
  EnableContactShadows, EnableLightsVisualisation, and
  LightsVisualisationMode. These debug/UI fields previously didn't
  persist across sessions even though they have JSON-loadable defaults.

- LightLimitFix.hlsli: bounds-check shadowIndex against ShadowMapSlots
  in GetShadowLightShadow before indexing the Shadows StructuredBuffer.
  Overflowed slots (modelled explicitly elsewhere in the PR) would
  otherwise read invalid records or slices; the new guard falls back to
  "unshadowed + no coverage" cleanly.

- ShadowRenderer.cpp: when GetInstalledSlotCount() returns 0, clear
  per-frame slot metadata, counters, and PS bindings at t100/t101
  before early-returning. Previously the stale state lingered across
  zero-slot frames -- the overlay kept showing the previous frame's
  shadow rows and shaders kept sampling the previous frame's records.

- LightLimitFix.cpp: validate shadowmapIndex against
  ShadowCasterManager::GetInstalledSlotCount before setting
  LightFlags::Shadow in BSLightingShader_SetupGeometry. A stale or
  corrupted descriptor mid-frame could otherwise carry an out-of-range
  index and the shader's Shadows[] / ShadowMaps[] reads would sample
  OOB (UB, GPU crash on some drivers).

Three threads resolved without code changes:
- Effect.hlsl GetShadowLightShadow world-space arg: already passes the
  camera-relative -> world-space conversion. Outdated against an earlier
  revision of the file.
- ShadowRenderer.cpp <chrono> include: the file no longer uses
  std::chrono (refactored to QPC per project convention).
- Globals.cpp engine-global pointers: critical relocations via 2-arg
  RelocationID intentionally abort at boot on failure rather than
  silently corrupting -- defense-in-depth wrapper would mask real load
  failures, not catch them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous review-fix commit added EnableLightsVisualisation and
LightsVisualisationMode to the NLOHMANN serialization alongside the
real user setting EnableContactShadows. Those two are debug
visualisations -- they should reset to defaults on each session so
users don't accidentally ship debug overlays in their persisted
config, and the corresponding shader-cache rebuild on toggle would
fire at every cold-start otherwise.

Keep EnableContactShadows in the macro (real persistent setting) and
add an explanatory comment so the omission is intentional, not
something a future contributor "fixes" by re-adding the debug fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shadowmapIndex bounds check added in the PR #35 review-feedback
commit gated `LightFlags::Shadow` on `idx > 0 && idx < installedSlots`.
The `> 0` lower bound was overreach -- slot 0 of kSHADOWMAPS is a
legitimate slice for a point/spot light when the sun isn't in the SCM
pool (per GetShadowSlot's behavior: pool index maps 1:1 to kSHADOWMAPS
slot, and the sun's pool slot returns the -1 sentinel, freeing slot 0
for actual point lights). Excluding slot 0 silently dropped shadow
rendering for any light occupying that slice -- effectively reverting
a working code path.

The review comment only flagged the upper-bound OOB risk (sampling
past the allocated slice count). Keep that check; drop `> 0`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three more Copilot review comments on PR #35 after the initial fix
push:

- Effect.hlsl: gate strict lights behind `inWorld` the same way
  clustered lights are gated. Previously totalLightCount included
  NumStrictLights regardless of inWorld, which meant non-world effect
  passes (UI, blood splatter on screen-space surfaces, etc.) would
  iterate strict lights from the world-space CB and pick up unrelated
  lighting. The clustered branch was already gated; strict matches
  for symmetry now.

- ShadowRenderer.cpp: replace per-frame `std::vector<ShadowLightData>
  sd(slots)` ctor with a `static` vector + `assign(slots, {})` to
  reuse the backing storage. The previous shape heap-allocated every
  frame in CopyShadowLightData's hot path; slot count is stable
  outside resolution / settings reconfigures so reusing storage is
  free win.

- LightLimitFix.cpp: `shadowLightPtrs.reserve(GetInstalledSlotCount()
  + 1)` before the ForEachShadowLight insert loop. The unordered_set
  was constructed empty and grew via inserts each frame, causing
  multiple rehash allocations under high shadow counts. Upper bound
  is the configured kSHADOWMAPS slot count (+1 belt-and-braces for
  the sun's logical entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three more Copilot review comments on PR #35:

- Deferred.cpp focus shadow upload: preserve descriptor->slice
  correspondence by writing FocusShadowProj[i] for descriptor[i]
  instead of compacting densely via dd.FocusShadowCount. The LLF
  shader samples kSHADOWMAPS slice (4 + fi) using fi as the matrix
  index, so packing past disabled holes would pair matrix N with the
  wrong shadow slice. Disabled descriptors now leave their slot at
  the default-zero matrix; the shader's existing
  `focusClip.w <= EPSILON_DIVISION continue` guard treats zero
  matrices as "no actor in this slice" and skips sampling.
  FocusShadowCount is the upper iteration bound (last enabled
  index + 1).

- LightLimitFix.cpp BSLightingShader_SetupGeometry strict-light
  setup: read the kSHADOWMAPS slice from
  ShadowCasterManager::GetShadowSlot() instead of
  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 the upper-bound check yet point strict-light
  shader sampling at the wrong slice. GetShadowSlot reads from the
  SCM's stable s_lights pool (set in ScheduleShadowCasters, never
  touched by ReturnShadowmaps), aligning with how
  CopyShadowLightData and UpdateLights already key off it.

- ShadowCasterManager.h GetShadowSlot docstring: corrected the
  advertised range from "1..ShadowLightCount-1" to the actual
  "0..GetInstalledSlotCount()-1" raw kSHADOWMAPS slice. The previous
  text was off by one and would mislead a future caller into
  skipping slice 0 or adding bogus bias logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions converged to leave the directional sun unshadowed and
either fully bright or fully dark depending on cell type:

1. SCM's Hook_DisableColorMask in ShadowCasterManager.cpp was patching
   out a 5-byte CALL inside Main::RenderShadowmasks (100422/107140)
   under the assumption that the call targeted a "DrawColorMask"
   routine. Ghidra-verified on SE 1.5.97 (+0x90 -> 0x1412e3b80) and
   SkyrimVR (+0x9E -> 0x141323740): the call is to
   RenderShadowLightsWithUtilityShader (100423/107141) -- the function
   that emits every screen-space shadow mask draw. The hook therefore
   killed the entire kSHADOW_MASK RT every frame; the "Shadowmasks"
   engine marker was empty in RenderDoc. Replaced with a detour on
   100423/107141 that writes a null sentinel into shadowLightsAccum at
   the 4-slice boundary, runs vanilla, then restores. The engine's
   iteration terminates cleanly at the sentinel without OOB-reading
   the 4-entry DAT_141861380 blend-mode table for extended-mode slots,
   while slots 0..3 (sun cascades + first vanilla point lights) get
   their mask draws as before.

2. Lighting.hlsl and Particle.hlsl gated the LLF directional shadow
   path on a bare !SharedData::InInterior check, so Interior Sun cells
   (where the engine has valid directional data) couldn't reach the
   cascade + engine-mask sampling. Mirrors #2319's fix on the
   VOLUMETRIC_SHADOWS branch by switching to
   ShadowSampling::HasDirectionalShadows() which evaluates
   !IsInterior() || InteriorSun::IsActive.

Stale comments referencing Hook_DisableColorMask updated in
LightLimitFix.cpp and ShadowRenderer.cpp; the defensive code that
reads the SCM-assigned slot rather than shadowmapDescriptors[0] is
still correct, only its rationale shifted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 147063a72 (Interior Sun directional gate fix). Two changes:

1. ShadowCasterManager.cpp: replace the screen-space mask wrapper body
   with a no-op. Vanilla RenderShadowLightsWithUtilityShader has three
   OOB failure modes that all converge on the same crash site
   (107141+0x319, `mov eax, [rbp+rdx*4]` on the 4-entry per-slot blend-
   mode table DAT_141861380):
     - extended slots whose maskIndex >= 4
     - shadowLightsAccum array reads past .size() (vanilla advances by
       light->shadowMapCount and dereferences whatever's at that index)
     - entries that bypass SLF's EnableLight (engine GameAccumulate path)
       and never get a maskIndex assigned
   Bounded-call wrappers (slice-count cutoff, maskIndex clamp, BSTArray
   padding) each closed one mode but at least one always slipped through.
   Verified crash logs:
     crash-2026-05-25-15-16-25.log RDX=0x3B1F3023
     crash-2026-05-25-15-26-59.log RDX=0x3AA96F53
     crash-2026-05-25-15-28-04.log RDX=0x3A4A3190
     crash-2026-05-25-15-36-15.log RDX=0x3A4B11F5
   Skipping vanilla outright eliminates the call site. Unlike the old
   Hook_DisableColorMask this version does NOT call ReturnShadowmaps;
   that side-effect cleared shadowmapDescriptors and broke
   Deferred::CopyShadowLightData's cascade matrix upload, producing the
   original "no sun shadow + brighter scene" symptom. With
   shadowmapDescriptors preserved, LLF::GetDirectionalShadow samples
   DirectionalShadowCascades directly and the screen-space mask is no
   longer needed under LIGHT_LIMIT_FIX.

2. RunGrass.hlsl: swap `!SharedData::InInterior` for
   `ShadowSampling::HasDirectionalShadows()` on the engine-mask and
   screen-space-shadow gates in both PS_OUTPUT main variants. Mirrors
   the Lighting.hlsl / Particle.hlsl swap so Interior Sun cells receive
   directional shadow attenuation on grass too. World-shadow gates (and
   Effect.hlsl) untouched because GetWorldShadow self-gates to 1.0 in
   interiors.

Trade-off: builds with LIGHT_LIMIT_FIX boot-disabled lose point-light
mask shadows (slots 1-3 in vanilla's RGBA mask). Same trade as the
original Hook_DisableColorMask. Acceptable because the fork ships LLF
loaded by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real findings from the Copilot review pass, the rest either already
addressed in earlier review rounds or intentionally not-fixed with
documented rationale.

1. FormulaHelper::Parse leaked state on compile failure. The previous
   sequence assigned `_ptr = new FormulaWrapper()` BEFORE calling
   parser.compile(), so a failed compile left the helper in a "parsed"
   state: subsequent Parse() calls returned false via the early
   `if (_ptr) return false` guard, and Calculate() would evaluate the
   uncompiled wrapper. Defer the _ptr assignment until compile succeeds,
   and delete the wrapper on failure.

2. ShadowBitMask was dead work. The field is declared in the LLF cbuffer
   struct but no shader reads it (the bit-mask-based IsLightIgnored
   branch was removed when per-light shadowMapIndex sampling replaced
   it). The C++ side was building it in a per-pass hot loop over
   numShadowLights and using it in the change-detect that drives
   strictLightDataCB->Update(). Drop the build loop and remove the
   change-detect comparison; the field stays initialized to 0 above for
   cbuffer ABI stability. Also removes the previousShadowBitMask member.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per CLAUDE.md comment guideline -- the prior block was descriptive ("X was
removed when ..."). Reframe as the regression warning the project's
exception clause sanctions: name the absent code so a future maintainer
knows not to re-add the build loop. Net -1 line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit review 4359346476 caught a pre-existing bug surfaced by the
Interior Sun gate fix: dirSoftShadow and dirDetailedShadow are computed
in the VOLUMETRIC_SHADOWS / LIGHT_LIMIT_FIX paths but never multiplied
into dirLightColor before it accumulates into propertyColor. Particles
in shadow were rendering with full directional light contribution in
both exterior daylight and Interior Sun cells.

Multiply both factors into the accumulation. Only one path writes a
non-1.0 value (the VOLUMETRIC_SHADOWS / LIGHT_LIMIT_FIX branches are
mutually exclusive via #if/#elif), so the result equals whichever path
fired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three CodeRabbit findings posted inside review bodies as "Outside diff
range" / "Nitpick" sections rather than as inline file:line comments,
so the initial gh api pulls/35/comments sweep missed them.

1. LightLimitFix.hlsli GetDirectionalShadow: short-circuit the focus-
   shadow loop when accumulated shadow reaches 0. Once fully occluded,
   remaining focus actors can only multiply by zero; the extra 8-tap
   GatherRed work was wasted on every shadowed pixel.

2. Deferred.h: add sizeof() static_asserts to DirectionalShadowLight-
   Data and ShadowLightData. STATIC_ASSERT_ALIGNAS_16 only verified
   alignment; silent size drift between the C++ structs and their HLSL
   mirrors (ShadowSampling.hlsli / LightLimitFix.hlsli) would corrupt
   every uploaded shadow record. Symbolic expressions (8*float4x4 +
   2*float4 / 2*float4x4 + float4) document the layout so additions
   land in the right place.

3. ShadowRenderer.cpp DrawOverlay: extend the inner table gate to
   honour showOverlay and hasOverrides. The "Show Shadow Overlay"
   toggle opens the window and its tooltip promises the debug controls
   are reachable any time -- previously visualization modes 0-3 left
   shadowRelatedMode false and the table stayed hidden despite the
   user explicitly requesting it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #35 CI surfaced 360 new X4000 "use of potentially uninitialized
variable" warnings, all from LightLimitFix.hlsli:396 (the closing brace
of the shadowIndex>=ShadowMapSlots overflow guard).

The previous pattern was:
    hasCoverage = true;
    if (overflow) { hasCoverage = false; return 1.0; }
    ... rest sets nothing, all returns leave hasCoverage = true.
Semantically correct -- the entry assignment dominates -- but FXC's
dataflow analysis flagged the post-if merge as a potential
uninitialized read on the `out bool hasCoverage` parameter.

Restructure so each return path writes hasCoverage exactly once. The
overflow branch writes false; everything past it writes true (paraboloid
lights always sample, including the safe / suppressed sentinels). The
0 new warnings limit is restored.

Also initialise the caller's `bool shadowCoverage` in Effect.hlsl for
parity with the other call sites (Lighting.hlsl, RunGrass.hlsl x2),
which all use `= false`. Defensive only; FXC doesn't flag the caller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…4000)

Previous attempt (3b4159d) reordered the writes inside the function so
every return path explicitly writes hasCoverage. FXC still emitted 360
new X4000 "use of potentially uninitialized variable" warnings -- the
merge point following the overflow-guard if-block trips FXC's flow
analysis regardless of write ordering when the parameter is `out`.

Change the parameter to `inout`. Every call site already initialises
the bool to false at declaration (Lighting.hlsl, RunGrass.hlsl x2,
Effect.hlsl), so the inout contract is satisfied with no caller
changes. FXC's must-write-before-read check on `out` doesn't apply to
`inout`, so the false-positive at the if-merge stops firing.

Semantics unchanged: every return path still overwrites hasCoverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shader compile (FXC) emitted 360 X4000 "use of potentially uninitialized
variable" warnings at the GetShadowLightShadow overflow-guard. Both `out`
and `inout` signatures tripped the same false positive at the post-if
merge point. Eliminate the early-return path entirely: write hasCoverage
once at function entry from the bounds predicate, and rely on the D3D11
StructuredBuffer OOB-returns-zero spec to make Shadows[shadowIndex] safe
even when shadowIndex >= ShadowMapSlots (the zero record falls into the
ShadowLightParam.y == 0 branch and returns 1.0, with hasCoverage already
false so the caller still discards the sample).

Also address review feedback:
- ShadowRenderer.cpp: shadowSceneNode-null branch now mirrors the
  slots==0 cleanup (BeginSlotFrame(0), reset counters, unbind t100/t101)
  so a null scene node doesn't leave stale shadow data bound to PS.
- LightLimitFix.cpp: switch shadowLightPtrs from std::unordered_set to
  ankerl::unordered_dense::set to avoid per-frame node allocations in
  the UpdateLights hot path.
- ShadowCasterManager.h: Update() doc now correctly describes that
  scheduling lives in the hooked CalculateActiveShadowCasters path, not
  in Update() itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dev merge dropped the "// Cluster config (computed)" and
"// Debug (last)" markers from PerFrame / LightLimitFixSettings and
placed the visualization fields BEFORE ShadowMapSlots / ClusterSize,
breaking the long-standing "Debug fields trail the cbuffer" convention
this PR's prior history established.

Restore the original ordering: contact-shadow + ShadowMapSlots payload
first, then `// Cluster config (computed)` + ClusterSize, then `// Debug
(last)` + EnableLightsVisualisation / LightsVisualisationMode, then
final pad. Total still 64 bytes (sizeof assertion holds): 8 packed
uint/float + ClusterSize[4] + 2 uint visualization + pad[2] = 64.

Matched in the HLSL mirror (SharedData.hlsli) so the field-for-field
contract documented by the size-lock comment continues to hold.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five issues surfaced by a CLAUDE.md-driven review of the in-session work:

- ShadowSampling.hlsli GetWorldShadow: collapse the `#if/#else/#endif` +
  trailing `return worldShadow;` pattern that had two returns where only
  one was reachable per defined permutation. Single trailing return.

- LightLimitFix.cpp DrawSettings: replace `ImGui::Combo("...",
  (int*)&LightsVisualisationMode, ...)` with a temp int round-trip
  clamped to the combo option count. The `(int*)&uint` cast violates
  strict aliasing (the same UB the contact-shadow SliderScalar block
  immediately above explicitly calls out and avoids), and a corrupted
  persisted value could otherwise index past the option array.

- LightLimitFix.cpp GetCommonBufferData: clamp LightsVisualisationMode
  at the cbuffer boundary too. The DrawSettings clamp is UI-only; JSON
  persistence or remote-control writes can land out-of-range and the
  shader's switch would silently treat it as mode 0.

- Particle.hlsl: tighten the dirSoftShadow/dirDetailedShadow comment to
  one line that names the invariant ("paths are mutually exclusive,
  exactly one stays at 1.0") instead of describing what the next line
  does. Per CLAUDE.md "Concise Comments" rule.

- Effect.hlsl: drop the "out-param init for parity" comment on
  `bool shadowCoverage = false;`. The init is literal next-line code,
  and the other callsites (Lighting.hlsl / RunGrass.hlsl) use the same
  pattern without commentary, so the "parity" framing was self-referential.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EnableLightsVisualisation and LightsVisualisationMode were in Settings
but excluded from the NLOHMANN serializer. That contract was fragile:
anyone adding the macro field list could re-include them and a stale
config edit could still trip the LLFDEBUG shader recompile on load.

Move them onto the LightLimitFix instance as plain non-serializable
members alongside the existing previous/currentEnableLightsVisualisation
trackers. The Settings struct now contains only persistable user-facing
configuration; debug toggles are local instance state that resets on
each session.

Touches:
- LightLimitFix.h: drop fields from Settings; add instance members with
  a comment explaining the placement.
- LightLimitFix.cpp: DrawSettings, GetCommonBufferData, IsOverlayVisible
  read the instance fields directly; serializer comment updated.
- ShadowRenderer.cpp: drop `settings.` prefix on the three viz reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A local set's buckets get destroyed and recreated every frame, so the
reserve() call was paying for an allocation every frame instead of
amortising one. Promote to a function-local static: clear() preserves
the bucket array, reserve() is then a no-op once steady state is
reached. Same dense layout, no per-insert node allocations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
t100 is already taken by Grass Collision's Collision texture
(features/Grass Collision/Shaders/GrassCollision/GrassCollision.hlsli),
and RunGrass.hlsl includes both features, so binding LLF's shadow
StructuredBuffer at the same register would have caused an HLSL
compilation failure whenever both features were enabled.

Move LLF's `Shadows` to t102 and `ShadowMaps` to t103 (both free), and
update the matching `PSSetShaderResources(102, ...)` / `(103, ...)` calls
in ShadowRenderer.cpp (PSSetShaderResources with count=2 at t102 still
binds the pair contiguously in the unbind paths).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@SkrubbySkrubInAShrub SkrubbySkrubInAShrub marked this pull request as draft May 26, 2026 08:32
alandtse and others added 3 commits May 27, 2026 11:44
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deferred Defer to next cycle

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants