Skip to content

perf: let dx12 frames overlap instead of waiting on every frame - #202

Merged
CanReader merged 6 commits into
mainfrom
perf/dx12-frames-in-flight
Oct 8, 2026
Merged

CanReader merged 6 commits into
mainfrom
perf/dx12-frames-in-flight

Conversation

@CanReader

Copy link
Copy Markdown
Owner

What this changes

DX12 can now actually have two frames in flight. BeginRender waits only for the frame that last recorded into the slot it is about to reuse, and everything the CPU rewrites per frame was moved somewhere that is safe to overlap:

  • Fences (DX12 per-slot fence counters signalled into a single shared fence #165). One m_fenceValue that only goes up. EndRender signals ++m_fenceValue and stores it in fenceValues[frameIndex], BeginRender waits for fenceValues[frameIndex] only, and WaitForGPU signals a fresh value instead of rewriting the per-slot ones.
  • Constant buffers. DirectX12Buffer keeps the contents of Constant buffers on the CPU. BindConstantBuffer copies them into the current frame's DirectX12UploadRing slice on the first bind after an update and caches that address for the rest of the frame. The light/fog constants (m_lightUBO) go the same way, and UpdateShadowLightUBO rebinds b2 when it lands mid frame.
  • Deferred deletes. DeferCleanup entries are stamped with the fence value of the frame that queued them and released once that value completes, instead of "one frame later". Textures and cubemaps now go through it too.
  • Uploads. New DirectX12UploadContext: one command list, recycled allocators, staging buffers kept alive until their batch's fence passes. Buffers, textures and cubemaps record into it instead of each creating an allocator, list and fence and blocking on the queue. The renderer submits it at BeginRender, right before the main list in EndRender, and in WaitForGPU. Code that builds these classes without an uploader still works, it just flushes a local one (same blocking behavior as before).
  • PSOs. CreateShader no longer overwrites a single pipelineState. PSOs come from a small cache keyed by shader bytecode hash, vertex format, cull/depth bias/depth clip, depth write/func, blend, topology and RTV/DSV format. Skybox, debug lines and the shadow pass each build their own explicit key, and the shadow pass compiles default_shader_dx12.hlsl for its VS instead of borrowing whatever shader was created last. DirectX12Shader::bind goes through BindShaderPipeline, which leaves a skybox or debug line PSO alone while that pass is active.
  • Mips. DirectX12Texture builds a full box filtered mip chain on the CPU and uploads every level, like GL (glGenerateMipmap) and Vulkan already do.

Why

The old BeginRender waited on the max of both slot counters, which was meant to be "wait for everything" so the shared mapped constant buffers could be rewritten safely. With #165 the two counters were signalled into one fence and advanced independently, so on every second frame the max was a value the fence had already passed and the wait did nothing while the CPU overwrote mapped memory the previous frame was still reading. On the other frames it really did wait for everything, so the CPU and GPU never overlapped at all. Fixing only the counters would keep that full stall, so I fixed the shared memory instead and dropped the wait to the one slot.

The constant buffer race was the same one #186 fixed for the shadow transform: every transform/material CB was one mapped UPLOAD buffer written with memcpy and bound by GPU VA, so the GPU reads whatever was written last, from this frame or the next. MeshBatch even rewrites s_batchTransformBuffer several times per frame. With the ring each bind sees the contents it had at bind time.

The PSO slot was a correctness bug as much as a perf one. Skybox and debug line creation gated on pipelineState, EndSkyboxPass restored the last created shader's PSO, the shadow PSO used the last created shader's VS (a skybox VS there would put every caster at the far plane), and DebugLineRenderer calls shader->bind() after BeginDebugLinePass, which swapped the line PSO back to a triangle PSO and drew LINELIST against it. I also restore the default PSO after the shadow pass so nothing draws with the depth-only PSO by accident.

Not covered here: ConfigureRenderFace/ConfigureRenderMode are still no-ops, the cache makes cull/fill variants cheap to add but no caller asks for them on DX12 today. Cubemaps stay at one mip (skybox only). DX12 still ignores custom vertex formats (vertexFormat is in the key, but only the default Vertex layout is built). Material::Bind calls Update() on its CB, which has always been a no-op on DX12, so the material CB at b1 is still never bound there. Separate bug.

Fixes #165

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 and GPU based validation on: frame time should drop now that CPU and GPU overlap, a scene with several moving objects should not flicker or show other objects' transforms, debug lines should draw as lines, and textured surfaces at a distance should stop shimmering now that they have mips.

Stacked on #186 (uses its DirectX12UploadRing).

Checklist

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

BeginRender now waits only for the frame that last used the slot it is about to reuse, against one monotonically increasing fence. Constant buffers and the light constants are copied into the per-frame upload ring at bind time, buffer and texture uploads are batched into one shared command list, PSOs come from a small cache, and DX12 textures get a mip chain.
…to perf/dx12-frames-in-flight

# Conflicts:
#	include/private/Graphics/DirectX12/DirectX12Renderer.hpp
@CanReader
CanReader changed the base branch from fix/dx12-shadow-transform-cb to main October 8, 2026 06:51
@CanReader
CanReader merged commit 8794daa 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 per-slot fence counters signalled into a single shared fence

1 participant