diff --git a/.claude/docs/Threading.md b/.claude/docs/Threading.md index 2db6f27e..6e33270b 100644 --- a/.claude/docs/Threading.md +++ b/.claude/docs/Threading.md @@ -167,6 +167,15 @@ outside the main loop (e.g. during a resize). `Renderer::WaitAndRender(RenderThread*)` is the render thread's body: it swaps the double-buffered command queues (`Renderer::SwapQueues`) and executes the submission queue. +**Never `Kick()` while the render thread is `Busy`.** `Set(Kick)` overwrites `Busy`; the render thread +then sets `Idle` when it finishes and the kick is lost. Every kick must follow +`BlockUntilRenderComplete()`. `Terminate()` used to break this rule. When the previous frame was still +running at exit, the final submitted frame never ran on the render thread, and +`Renderer::Shutdown` replayed it on the main thread after the renderer was torn down. The result was an +intermittent exit AV in `CmdSetPipelineLayout` from the nulled `StorageImageClear` layout. +`Terminate()` now waits, pumps that last frame on the render thread, then kicks once more so the thread +sees `IsRunning() == false` and returns. + --- ## JobSystem diff --git a/Core/Platform/Linux/LinuxRenderThread.cpp b/Core/Platform/Linux/LinuxRenderThread.cpp index 6f124265..2c21cb00 100644 --- a/Core/Platform/Linux/LinuxRenderThread.cpp +++ b/Core/Platform/Linux/LinuxRenderThread.cpp @@ -44,11 +44,23 @@ namespace Lux { void RenderThread::Terminate() { - m_IsRunning = false; + // The frame kicked last may still be executing. Kicking over its Busy state let the render + // thread set Idle and exit with the final submitted frame never run, so Renderer::Shutdown + // replayed that whole frame on the main thread after the renderer was torn down (null + // StorageImageClear layout -> AV in CmdSetPipelineLayout). + BlockUntilRenderComplete(); + + // Run the final submitted frame on the render thread while the renderer is still alive. Pump(); + m_IsRunning = false; if (m_ThreadingPolicy == ThreadingPolicy::MultiThreaded) + { + // Release the render thread from its wait for the next kick. It executes the empty render + // queue (nothing was submitted since the pump), then sees m_IsRunning and returns. + Kick(); m_RenderThread.Join(); + } s_RenderThreadID = std::thread::id(); } diff --git a/Core/Platform/Windows/WindowsRenderThread.cpp b/Core/Platform/Windows/WindowsRenderThread.cpp index bdd48638..11b32dd5 100644 --- a/Core/Platform/Windows/WindowsRenderThread.cpp +++ b/Core/Platform/Windows/WindowsRenderThread.cpp @@ -51,11 +51,23 @@ namespace Lux { void RenderThread::Terminate() { - m_IsRunning = false; + // The frame kicked last may still be executing. Kicking over its Busy state let the render + // thread set Idle and exit with the final submitted frame never run, so Renderer::Shutdown + // replayed that whole frame on the main thread after the renderer was torn down (null + // StorageImageClear layout -> AV in CmdSetPipelineLayout). + BlockUntilRenderComplete(); + + // Run the final submitted frame on the render thread while the renderer is still alive. Pump(); + m_IsRunning = false; if (m_ThreadingPolicy == ThreadingPolicy::MultiThreaded) + { + // Release the render thread from its wait for the next kick. It executes the empty render + // queue (nothing was submitted since the pump), then sees m_IsRunning and returns. + Kick(); m_RenderThread.Join(); + } s_RenderThreadID = std::thread::id(); } diff --git a/Core/Source/Lux/Core/RenderThread.h b/Core/Source/Lux/Core/RenderThread.h index 5da3759c..c5f232a2 100644 --- a/Core/Source/Lux/Core/RenderThread.h +++ b/Core/Source/Lux/Core/RenderThread.h @@ -52,7 +52,7 @@ namespace Lux { ~RenderThread(); void Run(); - bool IsRunning() const { return m_IsRunning; } + bool IsRunning() const { return m_IsRunning.load(std::memory_order_acquire); } void Terminate(); void Wait(State waitForState); @@ -72,7 +72,7 @@ namespace Lux { Thread m_RenderThread; - bool m_IsRunning = false; + std::atomic m_IsRunning = false; std::atomic m_AppThreadFrame = 0; };