From a014bf2340d247103e849420f9d7f91f46563044 Mon Sep 17 00:00:00 2001 From: Pier-Olivier Boulianne Date: Sun, 18 Oct 2026 22:07:55 -0400 Subject: [PATCH 1/2] RenderThread: finish the in-flight frame before the final pump in Terminate Terminate() kicked while the render thread could still be Busy with the previous frame. Set(Kick) overwrote Busy, the render thread set Idle when done and exited, so the last submitted frame never ran on it and Renderer::Shutdown replayed the whole frame on the main thread after the renderer was torn down. Wait for Idle first, pump the last frame on the render thread, then kick once more so it observes IsRunning() == false and returns. m_IsRunning is atomic. --- .claude/docs/Threading.md | 9 +++++++++ Core/Platform/Linux/LinuxRenderThread.cpp | 18 +++++++++++++++++- Core/Platform/Windows/WindowsRenderThread.cpp | 18 +++++++++++++++++- Core/Source/Lux/Core/RenderThread.h | 4 ++-- 4 files changed, 45 insertions(+), 4 deletions(-) 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..51614b6e 100644 --- a/Core/Platform/Linux/LinuxRenderThread.cpp +++ b/Core/Platform/Linux/LinuxRenderThread.cpp @@ -44,11 +44,27 @@ 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(); 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. + m_IsRunning = false; + Kick(); m_RenderThread.Join(); + } + else + { + m_IsRunning = false; + } s_RenderThreadID = std::thread::id(); } diff --git a/Core/Platform/Windows/WindowsRenderThread.cpp b/Core/Platform/Windows/WindowsRenderThread.cpp index bdd48638..d1697b82 100644 --- a/Core/Platform/Windows/WindowsRenderThread.cpp +++ b/Core/Platform/Windows/WindowsRenderThread.cpp @@ -51,11 +51,27 @@ 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(); 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. + m_IsRunning = false; + Kick(); m_RenderThread.Join(); + } + else + { + m_IsRunning = false; + } 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; }; From b764393af5a1dcb834c3fd41b47f344499c3926f Mon Sep 17 00:00:00 2001 From: Pier-Olivier Boulianne Date: Mon, 19 Oct 2026 14:06:08 -0400 Subject: [PATCH 2/2] RenderThread: set m_IsRunning once in Terminate --- Core/Platform/Linux/LinuxRenderThread.cpp | 6 +----- Core/Platform/Windows/WindowsRenderThread.cpp | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/Core/Platform/Linux/LinuxRenderThread.cpp b/Core/Platform/Linux/LinuxRenderThread.cpp index 51614b6e..2c21cb00 100644 --- a/Core/Platform/Linux/LinuxRenderThread.cpp +++ b/Core/Platform/Linux/LinuxRenderThread.cpp @@ -53,18 +53,14 @@ namespace Lux { // 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. - m_IsRunning = false; Kick(); m_RenderThread.Join(); } - else - { - m_IsRunning = false; - } s_RenderThreadID = std::thread::id(); } diff --git a/Core/Platform/Windows/WindowsRenderThread.cpp b/Core/Platform/Windows/WindowsRenderThread.cpp index d1697b82..11b32dd5 100644 --- a/Core/Platform/Windows/WindowsRenderThread.cpp +++ b/Core/Platform/Windows/WindowsRenderThread.cpp @@ -60,18 +60,14 @@ namespace Lux { // 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. - m_IsRunning = false; Kick(); m_RenderThread.Join(); } - else - { - m_IsRunning = false; - } s_RenderThreadID = std::thread::id(); }