Skip to content

fix: give each dx12 shadow caster its own transform constants - #186

Merged
CanReader merged 3 commits into
mainfrom
fix/dx12-shadow-transform-cb
Oct 8, 2026
Merged

CanReader merged 3 commits into
mainfrom
fix/dx12-shadow-transform-cb

Conversation

@CanReader

Copy link
Copy Markdown
Owner

What this changes

The DX12 shadow pass now gets a fresh 256 byte slice of upload memory for every caster's LightVP * World instead of writing them all into the single m_shadowTransformCB. The slices come from a new DirectX12UploadRing, a small per-frame linear allocator over persistently mapped upload heap pages. m_shadowTransformCB and m_shadowTransformMapped are gone.

Why

BindConstantBuffer in shadow mode memcpy'd each caster's matrix into the same mapped buffer and recorded the same GPU VA for every draw. Nothing runs until ExecuteCommandLists in EndRender, so by the time the GPU reads it the buffer only holds the last caster's matrix, and every shadow draw rasterizes with that one. This is the Vulkan push constant path ported over as is, except push constants are baked into the command buffer per draw and a root CBV is just a pointer.

The ring keeps one set of pages per frame in flight. BeginRender rewinds the current frameIndex's pages right after the fence wait, in the same spot the command allocator for that slot gets reset, so it relies on exactly the same guarantee the allocator already does (the GPU is done with the last frame that used this slot). Allocations are aligned to D3D12_CONSTANT_BUFFER_DATA_PLACEMENT_ALIGNMENT. Pages are 64 KB (256 shadow transforms each) and a frame that runs out just grabs another page, which is kept and reused from then on, so there is no hard cap on caster count. Everything is freed in Cleanup after WaitForGPU.

I kept it separate from the renderer on purpose so other transient per-draw constants can go through m_uploadRing.Allocate later. Only the shadow transform uses it in this PR.

Not covered here: #165 (per-slot fence values signalled into one shared fence). I checked whether the ring needs it fixed first and it does not. BeginRender waits on the max of both slots, and with the current counters that still always covers the previous frame that used the same slot (it is the allocator reset guarantee again), it just fails to also cover the other slot on every second frame. The ring only touches the current slot's pages, so it is safe either way. The races #165 describes on the other shared upload buffers are still there.

Fixes #166

How it was verified

  • Builds on Linux (DX12 sources are Windows only)
  • Builds on Windows (left to Windows CI on this PR)
  • Not runtime tested

I do not have a Windows machine, so this was only checked by reading and by the Windows CI build. Nothing was run on D3D12. Worth a look on Windows with the debug layer on: a scene with several casters should now show all of them in the shadow map instead of one shape repeated.

Checklist

  • Public headers documented with /// comments (new header is private, documented anyway)
  • No new includes from src/ or include/private/ in public headers
  • No game-specific vocabulary added to engine API

Shadow transforms now come from a per-frame linear upload ring instead of one shared 256 byte buffer, so each caster's draw reads its own LightVP * World.
@CanReader
CanReader merged commit dc85e00 into main Oct 8, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DX12 shadow pass shares one 256-byte upload buffer across every shadow caster

1 participant