From 22c734070551b0ed4d2ba1677b57310176b53469 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 04:16:20 +0000 Subject: [PATCH 01/63] Build: full optimization + LTO for Dist config Core's Dist filter overrode the workspace-level optimize "Full" down to "On"; align it with Editor/Lux-Runtime. Add LinkTimeOptimization (/GL + /LTCG) to the workspace Dist filter so it applies across the engine, apps, and vendored static libs. Release is left untouched to keep Tracy baseline comparability. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/premake5.lua | 2 +- premake5.lua | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/premake5.lua b/Core/premake5.lua index 65cacb7a..a4d0946e 100644 --- a/Core/premake5.lua +++ b/Core/premake5.lua @@ -77,7 +77,7 @@ project "Core" } filter "configurations:Dist" - optimize "On" + optimize "Full" symbols "Off" vectorextensions "AVX2" isaextensions { "BMI", "POPCNT", "LZCNT", "F16C" } diff --git a/premake5.lua b/premake5.lua index a10e639c..a7002627 100644 --- a/premake5.lua +++ b/premake5.lua @@ -54,6 +54,7 @@ workspace "Lux" optimize "Full" symbols "Off" defines { "NDEBUG" } + flags { "LinkTimeOptimization" } filter "system:windows" buildoptions { "/EHsc", "/Zc:preprocessor", "/Zc:__cplusplus" } From 73319a35ae898882e3a6afb7f0ccc16ed1db4691 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 04:17:05 +0000 Subject: [PATCH 02/63] SceneRenderer: reuse push-constant scratch in RT_DrawStaticMesh RT_DrawStaticMesh heap-allocated (and freed) a std::vector for every draw command in every mesh pass on the render thread. Replace it with a persistent render-thread-only scratch member; assign() preserves the zero-fill semantics of the old value-initialized vector while retaining capacity across draws. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 7 ++++++- Core/Source/Lux/Renderer/SceneRenderer.h | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 47d78d19..d8b75eab 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -7819,7 +7819,12 @@ namespace Lux { // ── Push constants ──────────────────────────────────────────────────── Buffer materialUniforms = material ? material->GetUniformStorageBuffer() : Buffer(); const uint64_t pushConstantSize = std::max(sizeof(MeshDrawPushConstants), materialUniforms.Size); - std::vector pushConstants(pushConstantSize); + // Reuse the render-thread scratch instead of heap-allocating per draw. + // assign() zero-fills while retaining capacity; the zero-fill matters for + // the tail bytes when materialUniforms.Size and sizeof(MeshDrawPushConstants) + // differ. + std::vector& pushConstants = m_RTPushConstantScratch; + pushConstants.assign(pushConstantSize, 0); if (materialUniforms) std::memcpy(pushConstants.data(), materialUniforms.Data, materialUniforms.Size); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 0527c244..b2e6b7bb 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -1479,6 +1479,12 @@ namespace Lux { std::vector m_ScratchMaterialData; std::vector m_ScratchTransientMaterialData; + // Render-thread-only scratch for RT_DrawStaticMesh push constants. RT_* + // helpers execute serially inside render-command execution (only lambda + // construction happens on the main thread), so no synchronization is needed. + // Never touch this from the main thread. + std::vector m_RTPushConstantScratch; + // Shadow-specific per-cascade transform tracking. // Index 0 is the only cascade we use currently. struct ShadowTransformMapData From 2de585261c658911e0f9c8d7c1c925da1a03519e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 04:19:00 +0000 Subject: [PATCH 03/63] SceneRenderer: capture MeshDrawParams instead of TransformMapData per draw Every mesh-pass draw captured TransformMapData by value into its Renderer::Submit lambda, heap-copying the ObjectIndices vector once per draw per pass on the main thread. RT_DrawStaticMesh only reads four scalars from it, so snapshot those into a POD MeshDrawParams at submit time and capture that instead. Snapshot semantics are unchanged: the old closure copied the whole struct at submit time; the new one copies the four fields actually read. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 56 +++++++++++----------- Core/Source/Lux/Renderer/SceneRenderer.h | 24 +++++++++- 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index d8b75eab..a1e7d8f4 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -6251,10 +6251,11 @@ namespace Lux { StaticDrawCommand drawCmd = drawIt->second; drawCmd.InstanceCount = instCount; + const MeshDrawParams params(cascadeTmd); Ref instance = this; - Renderer::Submit([instance, drawCmd, cascadeTmd, cascade]() mutable { + Renderer::Submit([instance, drawCmd, params, cascade]() mutable { instance->RT_DrawStaticMesh( - instance->m_CommandBuffer, drawCmd, cascadeTmd, /*bindMaterial=*/false, cascade); + instance->m_CommandBuffer, drawCmd, params, /*bindMaterial=*/false, cascade); }); } @@ -6310,10 +6311,11 @@ namespace Lux { StaticDrawCommand drawCmd = drawIt->second; drawCmd.InstanceCount = instCount; + const MeshDrawParams params(cascadeTmd); Ref instance = this; - Renderer::Submit([instance, drawCmd, cascadeTmd, shadowIndex]() mutable { + Renderer::Submit([instance, drawCmd, params, shadowIndex]() mutable { instance->RT_DrawStaticMesh( - instance->m_CommandBuffer, drawCmd, cascadeTmd, /*bindMaterial=*/false, shadowIndex); + instance->m_CommandBuffer, drawCmd, params, /*bindMaterial=*/false, shadowIndex); }); } } @@ -6341,13 +6343,13 @@ namespace Lux { auto it = m_MeshTransformMap.find(key); if (it == m_MeshTransformMap.end()) continue; - const auto& tmd = it->second; + const MeshDrawParams params(it->second); StaticDrawCommand drawCmd = drawIt->second; Ref instance = this; - Renderer::Submit([instance, drawCmd, tmd]() mutable { + Renderer::Submit([instance, drawCmd, params]() mutable { instance->RT_DrawStaticMesh( - instance->m_CommandBuffer, drawCmd, tmd, /*bindMaterial=*/false, 0, /*useVisibleObjectIndexes=*/false, /*useIndirect=*/false); + instance->m_CommandBuffer, drawCmd, params, /*bindMaterial=*/false, 0, /*useVisibleObjectIndexes=*/false, /*useIndirect=*/false); }); } @@ -6763,12 +6765,12 @@ namespace Lux { if (it == m_MeshTransformMap.end()) continue; StaticDrawCommand drawCmd = drawIt->second; - const auto& tmd = it->second; + const MeshDrawParams params(it->second); Ref instance = this; - Renderer::Submit([instance, drawCmd, tmd]() mutable { + Renderer::Submit([instance, drawCmd, params]() mutable { instance->RT_DrawStaticMesh( - instance->m_CommandBuffer, drawCmd, tmd, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/true, instance->m_Options.EnableGPUDrivenRendering, + instance->m_CommandBuffer, drawCmd, params, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/true, instance->m_Options.EnableGPUDrivenRendering, instance->m_SelectedGeometryPass->GetPipeline()->GetShader()); }); } @@ -6792,12 +6794,12 @@ namespace Lux { if (it == m_MeshTransformMap.end()) continue; StaticDrawCommand drawCmd = drawIt->second; - const auto& tmd = it->second; + const MeshDrawParams params(it->second); Ref instance = this; - Renderer::Submit([instance, drawCmd, tmd]() mutable { + Renderer::Submit([instance, drawCmd, params]() mutable { instance->RT_DrawStaticMesh( - instance->m_CommandBuffer, drawCmd, tmd, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/true, instance->m_Options.EnableGPUDrivenRendering, + instance->m_CommandBuffer, drawCmd, params, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/true, instance->m_Options.EnableGPUDrivenRendering, instance->m_GeometryPass->GetPipeline()->GetShader()); }); } @@ -6837,12 +6839,12 @@ namespace Lux { if (it == m_MeshTransformMap.end()) continue; StaticDrawCommand drawCmd = drawIt->second; - const auto& tmd = it->second; + const MeshDrawParams params(it->second); Ref instance = this; - Renderer::Submit([instance, drawCmd, tmd]() mutable { + Renderer::Submit([instance, drawCmd, params]() mutable { instance->RT_DrawStaticMesh( - instance->m_CommandBuffer, drawCmd, tmd, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/true, instance->m_Options.EnableGPUDrivenRendering, + instance->m_CommandBuffer, drawCmd, params, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/true, instance->m_Options.EnableGPUDrivenRendering, instance->m_GeometryPassTransparent->GetPipeline()->GetShader()); }); } @@ -6873,12 +6875,12 @@ namespace Lux { if (it == m_MeshTransformMap.end()) continue; StaticDrawCommand drawCmd = drawIt->second; - const auto& tmd = it->second; + const MeshDrawParams params(it->second); Ref instance = this; - Renderer::Submit([instance, drawCmd, tmd]() mutable { + Renderer::Submit([instance, drawCmd, params]() mutable { instance->RT_DrawStaticMesh( - instance->m_CommandBuffer, drawCmd, tmd, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/false, false, + instance->m_CommandBuffer, drawCmd, params, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/false, false, instance->m_GeometryWireframePass->GetPipeline()->GetShader()); }); } @@ -6894,12 +6896,12 @@ namespace Lux { if (it == m_MeshTransformMap.end()) continue; StaticDrawCommand drawCmd = drawIt->second; - const auto& tmd = it->second; + const MeshDrawParams params(it->second); Ref instance = this; - Renderer::Submit([instance, drawCmd, tmd]() mutable { + Renderer::Submit([instance, drawCmd, params]() mutable { instance->RT_DrawStaticMesh( - instance->m_CommandBuffer, drawCmd, tmd, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/false, false, + instance->m_CommandBuffer, drawCmd, params, /*bindMaterial=*/true, 0, /*useVisibleObjectIndexes=*/false, false, instance->m_GeometryWireframePass->GetPipeline()->GetShader()); }); } @@ -7744,7 +7746,7 @@ namespace Lux { void SceneRenderer::RT_DrawStaticMesh( Ref cmd, const StaticDrawCommand& dc, - const TransformMapData& tmd, + MeshDrawParams params, bool bindMaterial, uint32_t lightIndex, bool useVisibleObjectIndexes, @@ -7830,21 +7832,21 @@ namespace Lux { std::memcpy(pushConstants.data(), materialUniforms.Data, materialUniforms.Size); auto& pc = *reinterpret_cast(pushConstants.data()); - pc.ObjectIndexBase = useVisibleObjectIndexes ? tmd.VisibleObjectIndexBase : tmd.ObjectIndexBase; + pc.ObjectIndexBase = useVisibleObjectIndexes ? params.VisibleObjectIndexBase : params.ObjectIndexBase; pc.LightIndex = lightIndex; pc.BoneTransformBase = 0; pc.BoneTransformStride = 0; cmd->GetActive()->setPushConstants(pushConstants.data(), pushConstants.size()); - if (useIndirect && tmd.IndirectDrawOffsetBytes != std::numeric_limits::max()) + if (useIndirect && params.IndirectDrawOffsetBytes != std::numeric_limits::max()) { gs.indirectParams = m_SBSIndirectDrawCommands->RT_Get()->GetHandle(); cmd->RT_CommitGraphicsState(); - cmd->GetActive()->drawIndexedIndirect(tmd.IndirectDrawOffsetBytes, 1); + cmd->GetActive()->drawIndexedIndirect(params.IndirectDrawOffsetBytes, 1); return; } - const uint32_t instanceCount = useVisibleObjectIndexes ? tmd.VisibleInstanceCount : dc.InstanceCount; + const uint32_t instanceCount = useVisibleObjectIndexes ? params.VisibleInstanceCount : dc.InstanceCount; if (instanceCount == 0) return; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index b2e6b7bb..ccfda3e3 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -803,6 +803,28 @@ namespace Lux { uint32_t IndirectDrawOffsetBytes = std::numeric_limits::max(); }; + // Snapshot of the TransformMapData scalars RT_DrawStaticMesh needs. + // Captured by value into the render-command lambda instead of the full + // TransformMapData, which would copy the ObjectIndices heap vector per draw. + // Must be a snapshot: ClearFrameMeshPasses wipes the transform map on the + // main thread while the render thread executes a frame behind. + struct MeshDrawParams + { + uint32_t ObjectIndexBase = 0; + uint32_t VisibleObjectIndexBase = 0; + uint32_t VisibleInstanceCount = 0; + uint32_t IndirectDrawOffsetBytes = std::numeric_limits::max(); + + MeshDrawParams() = default; + explicit MeshDrawParams(const TransformMapData& tmd) + : ObjectIndexBase(tmd.ObjectIndexBase) + , VisibleObjectIndexBase(tmd.VisibleObjectIndexBase) + , VisibleInstanceCount(tmd.VisibleInstanceCount) + , IndirectDrawOffsetBytes(tmd.IndirectDrawOffsetBytes) + { + } + }; + struct MeshCullDrawData { uint32_t ObjectIndexBase = 0; @@ -999,7 +1021,7 @@ namespace Lux { // Render-thread draw helper (must be called inside Renderer::Submit). void RT_DrawStaticMesh(Ref cmd, const StaticDrawCommand& dc, - const TransformMapData& tmd, + MeshDrawParams params, bool bindMaterial, uint32_t lightIndex = 0, bool useVisibleObjectIndexes = false, From 14ce49f54fb5097cf98a138d3005d079fb41cb5b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 04:19:40 +0000 Subject: [PATCH 04/63] SceneRenderer: single-copy captures in the GPUScene upload submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upload lambda copied eight per-frame vectors into locals and then copied each local again into the closure — including the full GPUScene instance array. Use lambda init-captures: scratch-backed vectors are copied once (their capacity must stay with the member for next frame), and the frame-local instance/transient GPUScene vectors are moved. The emptiness guard evaluates before closure construction and nothing reads the moved-from locals afterwards. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 23 +++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index a1e7d8f4..f0c72567 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -6015,19 +6015,24 @@ namespace Lux { || !gpuMaterialData.empty() || !transientGPUMaterialData.empty()) { - const auto indexData = objectIndexData; - const auto visibleIndexData = visibleObjectIndexData; - const auto cullDrawData = meshCullDrawData; - const auto indirectCommands = indirectDrawData; - const auto gpuSceneData = gpuSceneInstanceData; - const auto transientSceneData = transientGPUSceneData; - const auto materialData = gpuMaterialData; - const auto transientMaterialData = transientGPUMaterialData; const uint32_t persistentSceneCount = persistentGPUSceneInstanceCount; const uint32_t persistentGPUMaterialCount = persistentMaterialCount; Ref instance = this; - Renderer::Submit([instance, indexData, visibleIndexData, cullDrawData, indirectCommands, gpuSceneData, transientSceneData, materialData, transientMaterialData, persistentSceneCount, persistentGPUMaterialCount]() mutable { + // Init-captures: one copy per vector instead of the previous + // local-copy-then-capture-copy. Scratch-backed vectors (reused next + // frame) are copied; the frame-local GPUScene vectors are moved — + // nothing reads them after this block. + Renderer::Submit([instance, + indexData = objectIndexData, + visibleIndexData = visibleObjectIndexData, + cullDrawData = meshCullDrawData, + indirectCommands = indirectDrawData, + gpuSceneData = std::move(gpuSceneInstanceData), + transientSceneData = std::move(transientGPUSceneData), + materialData = gpuMaterialData, + transientMaterialData = transientGPUMaterialData, + persistentSceneCount, persistentGPUMaterialCount]() mutable { Ref cmd = instance->m_UploadCommandBuffer; From 958c5f5315baba517cebdb37db73050e7bc06881 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 04:20:18 +0000 Subject: [PATCH 05/63] Scene: reuse SyncRenderScene sync-item scratch across frames SyncRenderScene rebuilt a std::vector from scratch every frame (one proxy with several Ref<>s per static mesh). Keep it in a thread_local scratch so capacity is retained; clear it after the upsert loop so no Ref<>s survive past the call. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Scene/Scene.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Core/Source/Lux/Scene/Scene.cpp b/Core/Source/Lux/Scene/Scene.cpp index e86e305f..1d49dd4d 100644 --- a/Core/Source/Lux/Scene/Scene.cpp +++ b/Core/Source/Lux/Scene/Scene.cpp @@ -1465,7 +1465,11 @@ namespace Lux { m_RenderScene->BeginSync(); - std::vector syncItems; + // Per-call scratch: thread_local (not a Scene member) because Scene.h only + // forward-declares the render types. Cleared at both ends of the call so no + // Ref<>s outlive it — only raw capacity is retained across frames. + static thread_local std::vector syncItems; + syncItems.clear(); auto view = m_Registry.view(); for (auto e : view) { @@ -1545,6 +1549,10 @@ namespace Lux { for (StaticMeshSyncItem& syncItem : syncItems) m_RenderScene->UpsertStaticMesh(std::move(syncItem.Proxy)); + // Release the Ref<>s now rather than at thread_local destruction, which + // would race engine shutdown (asset manager teardown, LUX_TRACK_MEMORY). + syncItems.clear(); + m_RenderScene->EndSync(); return m_RenderScene; } From d8528d455d3a331a0050a6f5789dee68ff735de1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 04:22:41 +0000 Subject: [PATCH 06/63] SceneRenderer: version-gate the per-frame texture/material table copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlushDrawList copied the entire TextureScene handle table and MaterialScene GPU-material table into scratch every frame even when nothing changed. Give both scenes a monotonic version counter (bumped in MarkTextureDirty/MarkMaterialDirty and Clear, which every table mutation funnels through) and skip the copy when the same scene instance is submitted with an unchanged version. On unchanged frames the texture scratch is truncated back to its persistent prefix before this frame's transient handles are appended; the material scratch carries over untouched. The texture resolve loop is deliberately left running every frame — it is what picks up async texture-load completions, which do not mark the scene dirty. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/MaterialScene.cpp | 2 + Core/Source/Lux/Renderer/MaterialScene.h | 5 ++ Core/Source/Lux/Renderer/SceneRenderer.cpp | 66 ++++++++++++++++------ Core/Source/Lux/Renderer/SceneRenderer.h | 10 ++++ Core/Source/Lux/Renderer/TextureScene.cpp | 2 + Core/Source/Lux/Renderer/TextureScene.h | 5 ++ 6 files changed, 74 insertions(+), 16 deletions(-) diff --git a/Core/Source/Lux/Renderer/MaterialScene.cpp b/Core/Source/Lux/Renderer/MaterialScene.cpp index 5ee95ba5..8dcc5669 100644 --- a/Core/Source/Lux/Renderer/MaterialScene.cpp +++ b/Core/Source/Lux/Renderer/MaterialScene.cpp @@ -348,6 +348,7 @@ namespace Lux { m_TextureResolver = {}; m_DirtyMaterialIDs.clear(); m_DirtyRanges.clear(); + m_Version++; EnsureFallbackMaterial(); } @@ -358,6 +359,7 @@ namespace Lux { return; m_DirtyMaterialIDs.push_back(materialID); + m_Version++; } } diff --git a/Core/Source/Lux/Renderer/MaterialScene.h b/Core/Source/Lux/Renderer/MaterialScene.h index 27c8a8ce..1ec7ab11 100644 --- a/Core/Source/Lux/Renderer/MaterialScene.h +++ b/Core/Source/Lux/Renderer/MaterialScene.h @@ -92,6 +92,10 @@ namespace Lux { void Clear(); const std::vector& GetMaterials() const { return m_Materials; } + // Monotonic change counter: bumped whenever the material table mutates + // (MarkMaterialDirty / Clear). Lets consumers skip re-copying the table on + // frames where nothing changed. Never reset. + uint64_t GetVersion() const { return m_Version; } const std::vector& GetDirtyRanges() const { return m_DirtyRanges; } bool HasDirtyMaterials() const { return m_DirtyMaterialCount > 0; } uint32_t GetDirtyMaterialCount() const { return m_DirtyMaterialCount; } @@ -139,6 +143,7 @@ namespace Lux { private: uint32_t m_FrameIndex = 0; uint32_t m_DirtyMaterialCount = 0; + uint64_t m_Version = 0; GPUTextureIndex m_NextTextureIndex = 1; std::function m_TextureResolver; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index f0c72567..402d1f91 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -5621,18 +5621,38 @@ namespace Lux { const TextureScene* submittedTextureScene = m_SubmittedRenderScene ? &m_SubmittedRenderScene->GetTextureScene() : nullptr; std::vector& gpuTextureHandles = m_ScratchTextureHandles; - if (submittedTextureScene) + // Skip re-copying the whole texture table when the same TextureScene is + // submitted with an unchanged version — the scratch already holds the + // persistent rows (plus last frame's transients, truncated below). + const bool textureTableChanged = !submittedTextureScene + || (const void*)submittedTextureScene != m_ScratchTextureSceneKey + || submittedTextureScene->GetVersion() != m_ScratchTextureSceneVersion; + if (textureTableChanged) { - const std::vector& src = submittedTextureScene->GetTextureHandles(); - gpuTextureHandles.assign(src.begin(), src.end()); + if (submittedTextureScene) + { + const std::vector& src = submittedTextureScene->GetTextureHandles(); + gpuTextureHandles.assign(src.begin(), src.end()); + } + else + { + gpuTextureHandles.clear(); + } + if (gpuTextureHandles.empty()) + gpuTextureHandles.push_back(AssetHandle(0)); + + m_ScratchTextureSceneKey = (const void*)submittedTextureScene; + m_ScratchTextureSceneVersion = submittedTextureScene + ? submittedTextureScene->GetVersion() + : std::numeric_limits::max(); + m_ScratchPersistentTextureCount = (uint32_t)gpuTextureHandles.size(); } else { - gpuTextureHandles.clear(); + // Unchanged table: just drop the transient handles appended last frame. + gpuTextureHandles.resize(m_ScratchPersistentTextureCount); } - if (gpuTextureHandles.empty()) - gpuTextureHandles.push_back(AssetHandle(0)); - const uint32_t persistentTextureCount = (uint32_t)gpuTextureHandles.size(); + const uint32_t persistentTextureCount = m_ScratchPersistentTextureCount; for (AssetHandle transientTextureHandle : m_TransientGPUTextureHandles) gpuTextureHandles.push_back(transientTextureHandle); @@ -5684,17 +5704,31 @@ namespace Lux { } std::vector& gpuMaterialData = m_ScratchMaterialData; - if (submittedMaterialScene) + // Same version gate as the texture table. Unlike the texture scratch, + // nothing is appended to this one, so an unchanged frame skips the copy + // entirely and the scratch contents carry over bit-identical. + const bool materialTableChanged = !submittedMaterialScene + || (const void*)submittedMaterialScene != m_ScratchMaterialSceneKey + || submittedMaterialScene->GetVersion() != m_ScratchMaterialSceneVersion; + if (materialTableChanged) { - const std::vector& src = submittedMaterialScene->GetMaterials(); - gpuMaterialData.assign(src.begin(), src.end()); - } - else - { - gpuMaterialData.clear(); + if (submittedMaterialScene) + { + const std::vector& src = submittedMaterialScene->GetMaterials(); + gpuMaterialData.assign(src.begin(), src.end()); + } + else + { + gpuMaterialData.clear(); + } + if (gpuMaterialData.empty()) + gpuMaterialData.push_back(MaterialScene::GetFallbackMaterialData()); + + m_ScratchMaterialSceneKey = (const void*)submittedMaterialScene; + m_ScratchMaterialSceneVersion = submittedMaterialScene + ? submittedMaterialScene->GetVersion() + : std::numeric_limits::max(); } - if (gpuMaterialData.empty()) - gpuMaterialData.push_back(MaterialScene::GetFallbackMaterialData()); const uint32_t persistentMaterialCount = (uint32_t)gpuMaterialData.size(); std::vector& transientGPUMaterialData = m_ScratchTransientMaterialData; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index ccfda3e3..2411826f 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -1507,6 +1507,16 @@ namespace Lux { // Never touch this from the main thread. std::vector m_RTPushConstantScratch; + // Change tracking for the texture/material table scratches above: the copy + // from the submitted scene is skipped when the same scene instance is + // submitted with an unchanged version. Version sentinels start at max so + // the first frame always copies. + const void* m_ScratchTextureSceneKey = nullptr; + uint64_t m_ScratchTextureSceneVersion = std::numeric_limits::max(); + uint32_t m_ScratchPersistentTextureCount = 0; + const void* m_ScratchMaterialSceneKey = nullptr; + uint64_t m_ScratchMaterialSceneVersion = std::numeric_limits::max(); + // Shadow-specific per-cascade transform tracking. // Index 0 is the only cascade we use currently. struct ShadowTransformMapData diff --git a/Core/Source/Lux/Renderer/TextureScene.cpp b/Core/Source/Lux/Renderer/TextureScene.cpp index 9fb4efc1..76e25c96 100644 --- a/Core/Source/Lux/Renderer/TextureScene.cpp +++ b/Core/Source/Lux/Renderer/TextureScene.cpp @@ -149,6 +149,7 @@ namespace Lux { m_TextureIndexByHandle.clear(); m_DirtyTextureIndices.clear(); m_DirtyRanges.clear(); + m_Version++; EnsureFallbackTexture(); } @@ -159,6 +160,7 @@ namespace Lux { return; m_DirtyTextureIndices.push_back(textureIndex); + m_Version++; } } diff --git a/Core/Source/Lux/Renderer/TextureScene.h b/Core/Source/Lux/Renderer/TextureScene.h index 7b7eea7e..b1dd2323 100644 --- a/Core/Source/Lux/Renderer/TextureScene.h +++ b/Core/Source/Lux/Renderer/TextureScene.h @@ -29,6 +29,10 @@ namespace Lux { void Clear(); const std::vector& GetTextureHandles() const { return m_TextureHandles; } + // Monotonic change counter: bumped whenever the texture table mutates + // (MarkTextureDirty / Clear). Lets consumers skip re-copying the table on + // frames where nothing changed. Never reset. + uint64_t GetVersion() const { return m_Version; } const std::vector& GetDirtyRanges() const { return m_DirtyRanges; } bool HasDirtyTextures() const { return m_DirtyTextureCount > 0; } uint32_t GetDirtyTextureCount() const { return m_DirtyTextureCount; } @@ -41,6 +45,7 @@ namespace Lux { private: uint32_t m_FrameIndex = 0; uint32_t m_DirtyTextureCount = 0; + uint64_t m_Version = 0; std::vector m_TextureHandles; std::vector m_LastTouchedFrames; From 5e642c69874aa636ec9ca482b9a063061a8295cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 04:23:53 +0000 Subject: [PATCH 07/63] Docs: record Phase 2 (submission-path allocations + build config) Add a status ledger to the optimization plan, correct A1 (the MeshDrawCommandCache is live; the remaining work is retained draw lists, with its blockers spelled out), add the A2-prime dirty-range GPUScene upload item, and update the priority order. Add Phase 2 hypotheses and an After-Phase-2 column to the baseline sheet. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- docs/ENGINE_OPTIMIZATION_PLAN.md | 82 +++++++++++++++++++++++++++----- docs/RENDERER_PERF_BASELINE.md | 40 +++++++++++++--- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index f4cb2bc1..dd45f6ba 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -13,6 +13,42 @@ things slower or just move the cost. We already started this (Tracy + `csvexport --- +## Status ledger + +| Phase | Status | Result | +|---|---|---| +| **Phase 0** — measurement infra | ✅ Done | Tracy CPU zones on all 39 passes; in-engine GPU per-pass timing documented; baseline protocol in `RENDERER_PERF_BASELINE.md`. | +| **Phase 1** — render-graph recompile cache + scratch reuse | ✅ Done | `RenderGraph::Compile` 996/996 frames → 1/3403; `FlushDrawList` 3.09 → 2.56 ms. | +| **Phase 1.5** — lazy graph names, removed double `BuildRenderGraph` | ✅ Done | `FlushDrawList` 2.56 → 2.11 ms (**cumulative −31.7%**). | +| **Phase 2** — submission-path allocations + build config | ✅ Implemented, awaiting Windows measurement | Six fixes below. | + +**Phase 2 fixes (2026-07-02, one commit each):** + +1. **Dist build config** (`Core/premake5.lua`, `premake5.lua`) — Core's Dist filter no + longer overrides `optimize "Full"` down to `"On"`; `LinkTimeOptimization` (/GL+/LTCG) + added workspace-wide for Dist. Release untouched (Tracy baseline build). + *Verify:* regenerate projects, check vcxproj for /GL, FPS spot-check (no Tracy in Dist). +2. **Push-constant scratch** (`SceneRenderer.cpp` `RT_DrawStaticMesh`) — the per-draw + per-pass `std::vector` heap allocation on the render thread is now a reused + render-thread-only member. *Verify:* render-thread `RenderCommandQueue::Execute` zone. +3. **`MeshDrawParams` capture** (`SceneRenderer.cpp`, 8 submit sites) — draw lambdas no + longer copy `TransformMapData` (with its `ObjectIndices` heap vector) per draw; they + capture a 4-scalar POD snapshot. *Verify:* per-pass CPU zones (ShadowMapPass, + PreDepthPass, GBufferPass, TransparentForwardPass). +4. **Upload-lambda init-captures** (`SceneRenderer.cpp` FlushDrawList) — removed the + local-copy-then-capture-copy of 8 vectors/frame; GPUScene instance vectors are moved. + *Verify:* `FlushDrawList` zone. +5. **`SyncRenderScene` scratch** (`Scene.cpp`) — sync-item vector is thread_local scratch, + cleared at both ends. *Verify:* `Scene::SyncRenderScene` zone. +6. **Version-gated table copies** (`TextureScene`, `MaterialScene`, `SceneRenderer`) — the + full texture/material table copies are skipped when the submitted scene's monotonic + version is unchanged. The texture *resolve loop* intentionally still runs every frame + (it picks up async texture-load completions, which don't mark the scene dirty). + *Verify:* `FlushDrawList` zone; add/remove a material + texture at runtime and confirm + the change appears. + +--- + ## 0. Where LuxEngine actually stands **Already has (genuinely modern):** @@ -79,12 +115,27 @@ This is where LuxEngine's measured cost actually is right now (light scenes are ### A1. Persistent draw-command caching (Unreal's biggest CPU win) Unreal's **`FMeshDrawCommand`** pipeline caches the per-draw state and only rebuilds when a -primitive actually changes. LuxEngine rebuilds draw lists every frame. There's already a -`MeshDrawCommandCache` scaffold in `SceneRenderer` — finish it: hash (mesh, material, -pass-state) and only re-record on change. **Win:** removes most of the remaining -`FlushDrawList` CPU. +primitive actually changes. **Status correction:** the `MeshDrawCommandCache` is *not* a +scaffold — it is implemented and live (`SubmitMeshPassDraw`, with age-based pruning at +`MeshDrawCommandCacheRetireAge = 300`). What remains of A1 is **retaining the draw lists +across frames**: `ClearFrameMeshPasses` wipes every pass's `DrawList`/`DrawOrder` each frame +and `BuildSortedDrawCommandOrder` re-sorts every pass every frame. Gate that on +`RenderSceneSyncStats` dirty counts. Three blockers make this a measure-validated change, +not an inspection-safe one: +1. per-frame camera-dependent CPU frustum culling (`isInstanceVisible`) feeds the lists; +2. transient (debug/collider) submissions are interleaved with cached ones; +3. `m_MeshTransformMap` offset assignment assumes a fresh build. +**Win:** removes most of the remaining `FlushDrawList` CPU. **Reference:** Unreal *"Mesh Drawing Pipeline"* docs; The Cherno's Hazel render-pass videos. +### A2-prime. Dirty-range GPUScene uploads +After Phase 2, the largest remaining per-frame memcpy is the **full persistent GPUScene +instance re-upload** in the FlushDrawList upload lambda (the code comment there is explicit: +`StorageBufferSet` owns one buffer per frame-in-flight, so per-slot dirty tracking is needed +before partial uploads are correct). Implement per-frame-in-flight dirty ranges in `GPUScene` +(the `TextureSceneDirtyRange`/`MaterialSceneDirtyRange` machinery is the in-repo pattern to +copy), then upload only dirty rows. Runtime-verification only — do with Tracy running. + ### A2. Job-ify the frame, don't just have a render thread LuxEngine has a render thread + JobSystem but the frame is largely serial (`BuildRenderPacket` → `SubmitMeshes` → `FlushDrawList`). The AAA model is **fibers/jobs**: @@ -186,14 +237,21 @@ streaming). ## Priority order (what to actually do, in sequence) -1. **Phase 0 GPU timing** (RenderDoc/Nsight now; Tracy-Vk later) — unblocks everything GPU. -2. **A1 draw-command caching** + **A2/A4 parallel recording** — biggest *measured* CPU win. -3. **B1 async compute** — biggest GPU win for least risk; the graph already models dependencies. -4. **E structural split + Dist stripping** — makes the rest safe and ships lean. -5. **B2 VRS** — cheap GPU win on the heavy full-screen passes. -6. **D streaming/PSO** — kills hitching (perceived performance). -7. **B3 mesh shaders**, **C clustered shading** — larger architectural upgrades. -8. **B4 ray tracing** — last, largest, most optional. +1. ~~**Phase 0 GPU timing**~~ — done (see Status ledger). +2. ~~**Phase 1/1.5 render-graph caching**~~ — done (−31.7% FlushDrawList). +3. ~~**Phase 2 submission-path allocations + build config**~~ — implemented; validate on + Windows against the baseline before proceeding. +4. **A1 completion (retained draw lists)** — biggest remaining *measured* CPU win; needs + Tracy before/after (see A1 blockers). +5. **A2-prime dirty-range GPUScene uploads** — kills the largest remaining per-frame memcpy. +6. **B1 async compute** — biggest GPU win for least risk; the graph already models dependencies. +7. **E structural split + Dist stripping** — makes the rest safe and ships lean. +8. **B2 VRS** — cheap GPU win on the heavy full-screen passes. +9. **D streaming/PSO** — kills hitching (perceived performance). +10. **B3 mesh shaders**, **C clustered shading** — larger architectural upgrades. + (Note: clustered froxel light culling has since landed in the renderer core — validate + it with GPU timing, then retire the C-bonus item.) +11. **B4 ray tracing** — last, largest, most optional. ## What NOT to do (the discipline part) - Don't chase Nanite/Lumen clones — they're multi-year efforts and overkill here. diff --git a/docs/RENDERER_PERF_BASELINE.md b/docs/RENDERER_PERF_BASELINE.md index d6840eda..2594e566 100644 --- a/docs/RENDERER_PERF_BASELINE.md +++ b/docs/RENDERER_PERF_BASELINE.md @@ -118,12 +118,14 @@ Fill this in once, on the build/scene/camera above. This is the bar Phase 1 must Mean per-frame CPU times (from `tracy-csvexport`, 996 frames): -| Metric | Source | Baseline (mean/frame) | After Phase 1 | -|---|---|---|---| -| `SceneRenderer::FlushDrawList` / `EndScene` | Tracy zone | **3.09 ms** | **2.56 ms (−17.4%)** | -| ↳ render-graph cost | Tracy zone | `BuildAndCompile` **0.853 ms** | `Build` 0.408 + `Compile` ~0 = **0.408 ms (−52%)** | -| ↳ `RenderGraph::Compile` invocations | Tracy count | **996 / 996 frames** | **1 / 3403 frames** | -| `SceneRenderer::BeginScene` | Tracy zone | 0.115 ms | 0.089 ms | +| Metric | Source | Baseline (mean/frame) | After Phase 1 | After Phase 2 | +|---|---|---|---|---| +| `SceneRenderer::FlushDrawList` / `EndScene` | Tracy zone | **3.09 ms** | **2.56 ms (−17.4%)** | | +| ↳ render-graph cost | Tracy zone | `BuildAndCompile` **0.853 ms** | `Build` 0.408 + `Compile` ~0 = **0.408 ms (−52%)** | | +| ↳ `RenderGraph::Compile` invocations | Tracy count | **996 / 996 frames** | **1 / 3403 frames** | | +| `SceneRenderer::BeginScene` | Tracy zone | 0.115 ms | 0.089 ms | | +| Per-pass CPU zones (ShadowMap/PreDepth/GBuffer) | Tracy zones | | (see per-pass table) | | +| `Scene::SyncRenderScene` | Tracy zone | | | | **Phase 1 result (after-trace `traceProfiler2026-06-28-12-45.tracy`, 3403 frames):** - `RenderGraph::Compile` ran **exactly once** for the whole capture instead of every @@ -181,6 +183,32 @@ These are the predicted wins; the baseline exists to prove or disprove them: local `std::vector`s reallocated each frame. - **Bindless table re-bind** — a `MaxGPUTextureSceneTextures`-wide loop calls string-keyed `SetInput` across 5 passes every frame. + *(Resolved before Phase 2: the loop already skips `SetInput` when the resolved + texture is unchanged.)* Re-capture with the identical protocol after each Phase 1 change and fill the "After Phase 1" column. + +## Phase 2 hypotheses to confirm against this baseline + +Phase 2 (submission-path allocations + build config, 2026-07-02) predicts: + +- **Per-pass CPU submission zones drop** (`ShadowMapPass`, `PreDepthPass`, `GBufferPass`, + `TransparentForwardPass`): draw lambdas no longer heap-copy + `TransformMapData::ObjectIndices` per draw (`MeshDrawParams` snapshot instead). + Effect scales with draw count — measure on a Sponza-class scene, not just the + 2-light sample. +- **`FlushDrawList` drops**: the upload submit no longer double-copies 8 vectors per + frame (init-captures + moves), and the full texture/material table copies are skipped + on frames where the scene version is unchanged. +- **Render-thread cost drops** (`RenderCommandQueue::Execute` / render-thread zones): + `RT_DrawStaticMesh` reuses a push-constant scratch instead of a per-draw heap + allocation. +- **`Scene::SyncRenderScene` drops**: sync-item vector is now reused scratch. +- **Dist FPS uplift** from `optimize "Full"` + LTO — FPS-only comparison (Tracy is + compiled out of Dist). Regenerate projects first (`scripts/Win-GenProjects.py`). + +**Correctness gates before reading perf numbers:** identical draw/instance counts in the +Renderer Debugger on the same scene, zero new Vulkan validation messages, and (for the +version-gated tables) verify a runtime material edit and a texture add/remove still show +up on screen. From a27258eaad8f1970f0a60bf778012e3b4e1f2eb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 15:37:37 +0000 Subject: [PATCH 08/63] Build: use linktimeoptimization API instead of removed flags value The vendored premake5 build rejects flags { "LinkTimeOptimization" } (invalid value); the dedicated linktimeoptimization "On" API is the current spelling and produces the same /GL + /LTCG output. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- premake5.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/premake5.lua b/premake5.lua index a7002627..92a69763 100644 --- a/premake5.lua +++ b/premake5.lua @@ -54,7 +54,7 @@ workspace "Lux" optimize "Full" symbols "Off" defines { "NDEBUG" } - flags { "LinkTimeOptimization" } + linktimeoptimization "On" filter "system:windows" buildoptions { "/EHsc", "/Zc:preprocessor", "/Zc:__cplusplus" } From 434305c8e21c05706d334e2a6d3db2c99ba1ccef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 16:25:14 +0000 Subject: [PATCH 09/63] Fix permanent mesh disappearance on quality preset change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the quality preset resized the screen-space-effect framebuffers immediately, before the BeginScene resize block resizes the main framebuffers. Passes whose framebuffers wrap the scene-color image via ExistingImages (DeferredLighting, composites, fog, sky) baked the old scene-color texture handle into their FramebufferDesc; when the resize block then recreated SceneColor, their Framebuffer::Resize early-outed (size already matched) and left them pointing at the orphaned texture. Lit geometry rendered into that orphan forever — draws still submitted, nothing on screen, unrecoverable without a renderer rebuild, and switching the preset back just repeated the cycle one generation behind. RefreshScreenSpaceEffectResources now defers to the BeginScene resize block, which already runs ResizeScreenSpaceEffectResources after the main framebuffers and with aliasing cleared. This covers all callers (SetQualityPreset, ApplyProjectSettings, the editor panel) including resolution-unchanged preset switches. Also harden ClearRenderTargetAliasing: always RT_Invalidate the cleared aliased images. ApplyRenderTargetAliasing excludes invalid images from the rebuilt alias graph, so a storage-stripped image was never re-aliased and never recovered, and fed a null handle into descriptor baking. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 402d1f91..9da7aa56 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -2226,7 +2226,18 @@ namespace Lux { void SceneRenderer::RefreshScreenSpaceEffectResources() { - ResizeScreenSpaceEffectResources(); + // Defer to the BeginScene m_NeedsResize block instead of resizing here. + // Resizing immediately is unsafe: render-target aliasing is still applied + // and the main framebuffers (SceneColor/GBuffer/PreDepth) have not been + // resized yet, so effect framebuffers that wrap them via ExistingImages + // bake the soon-to-be-orphaned texture handles into their FramebufferDesc. + // The resize block's Framebuffer::Resize then early-outs (size already + // matches) and never repairs them — meshes disappear permanently until + // the renderer is recreated. + if (m_ViewportWidth == 0 || m_ViewportHeight == 0) + return; + + m_NeedsResize = true; } glm::uvec2 SceneRenderer::CalculateVolumetricCloudRenderSize() const @@ -4103,8 +4114,11 @@ namespace Lux { continue; image->ClearTransientAliasSource(); - if (recreateResources) - image->RT_Invalidate(); + // Always restore real storage: ApplyRenderTargetAliasing() excludes + // invalid images from the rebuilt alias graph, so a dead image is never + // re-aliased and never recovers — and it feeds a null texture handle + // into DescriptorSetManager::Bake, silently no-oping its passes. + image->RT_Invalidate(); } m_RenderGraphAliasedImages.clear(); From eb54dd8fa838992dcd75ca5780c27a8c3f4acd0e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:08:57 +0000 Subject: [PATCH 10/63] Fix permanent per-frame descriptor set re-Bake after first invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InvalidateAndUpdate added changed inputs to InvalidatedInputResources and called Bake() when non-empty, but never removed entries — the only clear lived in dead #if TODO code. One invalidation (guaranteed by the startup viewport resize) left the set non-empty forever, so every dynamic pass rebuilt all its binding sets for all frames in flight, every frame. Clear the set at the top of InvalidateAndUpdate: the handle-comparison loop re-populates anything actually stale, Bake() re-adds genuinely-null deferred resources for retry, and null-to-valid transitions still bake because the stored handles stay null until a successful bake. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp index 6e7e7451..55c62ec7 100644 --- a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp +++ b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp @@ -768,6 +768,13 @@ namespace Lux { if (m_State == State::Ready) return; + // Start each update from a clean slate. Entries are re-added below by the + // handle-comparison loop (and by Bake() for still-null deferred resources); + // without this clear the set stays non-empty after the first invalidation, + // so every subsequent frame re-Bakes ALL binding sets for ALL frames in + // flight — permanent per-frame descriptor churn across every dynamic pass. + InvalidatedInputResources.clear(); + uint32_t currentFrameIndex = Renderer::RT_GetCurrentFrameIndex(); // Check for invalidated resources From 2ba9445831dd5dc59eb802d18f08087b6094c3fb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:10:28 +0000 Subject: [PATCH 11/63] Build the GPUScene debug snapshot only when the debugger panel asks The snapshot's validation loops (every instance, every material, object indexes twice, plus std::format diagnostics) ran unconditionally in FlushDrawList every frame, including in runtime and Dist builds, purely to populate the editor's Renderer Debugger panel. Gate it behind a per-frame request flag that the panel sets while its GPU Scene section is open; the snapshot is empty (and free) otherwise. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 6 ++++++ Core/Source/Lux/Renderer/SceneRenderer.h | 4 ++++ Editor/Source/Panels/RendererDebuggerPanel.cpp | 3 +++ 3 files changed, 13 insertions(+) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 9da7aa56..91d0a7ba 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -5903,7 +5903,13 @@ namespace Lux { } } + // Built only when the Renderer Debugger panel asked for it this frame — + // the validation loops below are O(instances + materials) CPU work that + // exists purely to populate that panel. + if (m_GPUSceneDebugSnapshotRequested) { + m_GPUSceneDebugSnapshotRequested = false; + GPUSceneDebugSnapshot snapshot; snapshot.PersistentInstanceCount = persistentGPUSceneInstanceCount; snapshot.TransientInstanceCount = (uint32_t)transientGPUSceneData.size(); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 2411826f..0a05aad3 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -654,6 +654,9 @@ namespace Lux { RenderGraphDebugSnapshot GetRenderGraphDebugSnapshot(); RendererFrameDebugSnapshot GetRendererFrameDebugSnapshot() const; const GPUSceneDebugSnapshot& GetGPUSceneDebugSnapshot() const { return m_GPUSceneDebugSnapshot; } + // The snapshot's validation loops cost O(instances + materials) CPU, so it + // is only built on frames where a consumer (Renderer Debugger panel) asks. + void RequestGPUSceneDebugSnapshot() { m_GPUSceneDebugSnapshotRequested = true; } const Frustum& GetCameraFrustum() const { return m_SceneData.CameraFrustum; } bool IsReady() const { return m_ResourcesCreatedGPU; } @@ -1236,6 +1239,7 @@ namespace Lux { Ref m_DebugRenderer; Ref m_SubmittedRenderScene; GPUSceneDebugSnapshot m_GPUSceneDebugSnapshot; + bool m_GPUSceneDebugSnapshotRequested = false; std::function m_WorldOverlayRenderCallback; glm::mat4 m_ScreenSpaceProjectionMatrix{ 1.0f }; diff --git a/Editor/Source/Panels/RendererDebuggerPanel.cpp b/Editor/Source/Panels/RendererDebuggerPanel.cpp index 52cb3e5e..eb8b8849 100644 --- a/Editor/Source/Panels/RendererDebuggerPanel.cpp +++ b/Editor/Source/Panels/RendererDebuggerPanel.cpp @@ -617,6 +617,9 @@ namespace Lux { if (!ImGuiEx::PropertyGridHeader("GPU Scene", true)) return; + // The snapshot is only built on frames where someone asks for it; keep + // requesting while this section is open so next frame's data is fresh. + m_Context->RequestGPUSceneDebugSnapshot(); const SceneRenderer::GPUSceneDebugSnapshot& snapshot = m_Context->GetGPUSceneDebugSnapshot(); if (ImGui::BeginTable("##renderer_debugger_gpu_scene", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp)) { From 37a074313fc51a3cb3d6e8c9f9d189502bbed421 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:11:22 +0000 Subject: [PATCH 12/63] Run PreIntegration only when SSR is enabled The pre-integration visibility pyramid is consumed solely by the SSR pass, but its per-mip compute dispatches ran every frame regardless of EnableSSR. Register the pass and its graph resource only when SSR is on; the SSR node's read-dependency append is already inside the same gate. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 91d0a7ba..159040f3 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -3488,9 +3488,15 @@ namespace Lux { addPass("HZB", preDepthOutputs, hzbOutputs, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::HZBCompute)); addPass("Mesh Culling", hzbOutputs, {}, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::MeshCullingPass)); + // PreIntegration's visibility pyramid is consumed only by SSR — skip the + // whole pass (and its per-mip dispatches) when SSR is off. The vector stays + // in scope because the SSR node below appends it as a read dependency. std::vector preIntegrationOutputs; - preIntegrationOutputs.push_back(m_PreIntegrationVisibilityTexture.Texture ? addTexture("PreIntegration Visibility", m_PreIntegrationVisibilityTexture.Texture->GetImage()) : RenderGraph::InvalidResource); - addPass("PreIntegration", hzbOutputs, preIntegrationOutputs, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::PreIntegration)); + if (m_Options.EnableSSR) + { + preIntegrationOutputs.push_back(m_PreIntegrationVisibilityTexture.Texture ? addTexture("PreIntegration Visibility", m_PreIntegrationVisibilityTexture.Texture->GetImage()) : RenderGraph::InvalidResource); + addPass("PreIntegration", hzbOutputs, preIntegrationOutputs, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::PreIntegration)); + } // Cluster build runs before light culling; it only depends on the camera // projection (SSBO synchronized via a manual barrier inside the pass). From 70b2bf2c79d46fcc3cf9cae1106de4ade64061b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:12:30 +0000 Subject: [PATCH 13/63] Skip cluster froxel build and light culling when no local lights exist Both compute dispatches ran every frame even with zero point/spot lights. Now the build pass early-outs (its AABBs feed only the cull dispatch) and the cull pass zero-fills the per-cluster grids instead of dispatching, so lighting shaders read count=0 everywhere. Lights appearing re-run the full path the same frame since the grid is rebuilt per frame while lights exist. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 159040f3..0b8d5f59 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -6557,6 +6557,14 @@ namespace Lux { if (!m_ClusterBuildPass || m_ViewportWidth == 0 || m_ViewportHeight == 0) return; + // The cluster AABBs are consumed only by the light-culling dispatch, which + // is skipped when there are no local lights (it zero-fills the grids + // instead) — so with no lights the froxel rebuild has no consumer either. + // The grid is rebuilt every frame while lights exist, so lights appearing + // next frame regenerate it before it is read. + if (m_PointLightsUB.Count == 0 && m_SpotLightsUB.Count == 0) + return; + struct ClusterBuildPushConstants { glm::vec4 ScreenSizeNearFar; // xy = render resolution (px), z = zNear, w = zFar @@ -6590,6 +6598,24 @@ namespace Lux { if (!m_ClusterLightCullingPass || m_ViewportWidth == 0 || m_ViewportHeight == 0) return; + // With no local lights, skip the cull dispatch entirely: zero-fill the + // per-cluster grids so the lighting shaders read count=0 everywhere. The + // index lists need no clear — nothing reads past a zero count. + if (m_PointLightsUB.Count == 0 && m_SpotLightsUB.Count == 0) + { + Ref commandBuffer = m_CommandBuffer; + Ref pointGrid = m_SBSPointLightGrid; + Ref spotGrid = m_SBSSpotLightGrid; + Ref counter = m_SBSClusterLightCounter; + Renderer::Submit([commandBuffer, pointGrid, spotGrid, counter]() mutable + { + commandBuffer->GetActive()->clearBufferUInt(pointGrid->RT_Get()->GetHandle(), 0u); + commandBuffer->GetActive()->clearBufferUInt(spotGrid->RT_Get()->GetHandle(), 0u); + commandBuffer->GetActive()->clearBufferUInt(counter->RT_Get()->GetHandle(), 0u); + }); + return; + } + // Reset the dynamic-allocation cursors ([0]=point, [1]=spot) before the // assignment dispatch atomically appends into the packed index lists. Ref commandBuffer = m_CommandBuffer; From bdb2392334fc08bc489148ee5d28de2b85e1c40b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:14:38 +0000 Subject: [PATCH 14/63] Idle the atmosphere UBO rebuild when no atmosphere feature is active The ~40-clamp UB rebuild (plus a 3-layer cloud loop and local-fog volume copies) and its upload ran every frame even with sky, clouds, and fog all disabled. When inactive, write a disabled-flags UB once per frame-in-flight buffer and go idle; active frames run the full path unchanged (the struct carries per-frame time fields, so per-frame upload is correct then) and re-arm the idle counter for the next transition. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 27 ++++++++++++++++++++++ Core/Source/Lux/Renderer/SceneRenderer.h | 8 +++++++ 2 files changed, 35 insertions(+) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 0b8d5f59..a57bdc6e 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -4472,7 +4472,34 @@ namespace Lux { } // ── Atmosphere uniform buffer ──────────────────────────────────────── + // The full rebuild + upload only runs while any atmosphere feature is + // active (the struct has per-frame time fields, so it legitimately changes + // every frame then). When everything is off, a disabled-flags UB is + // written once per frame-in-flight buffer and the block goes idle — + // shaders gate all reads of this UB on Flags/LocalFogParams. + const bool atmosphereActive = m_FrameEnvironment.SkyAtmosphereEnabled + || m_FrameEnvironment.VolumetricCloudsEnabled + || m_FrameEnvironment.HeightFogEnabled + || m_FrameEnvironment.LocalFogEnabled; + if (!atmosphereActive && m_AtmosphereIdleUploadsRemaining > 0) + { + m_AtmosphereUB.Flags = { 0u, 0u, 0u, 0u }; + m_AtmosphereUB.LocalFogParams = { 0u, 0u, 0u, 0u }; + m_AtmosphereIdleUploadsRemaining--; + + auto atmosphereData = m_AtmosphereUB; + Ref instance = this; + Renderer::Submit([instance, atmosphereData]() mutable { + instance->m_UBSAtmosphere->RT_Get()->RT_SetData( + instance->m_UploadCommandBuffer, &atmosphereData, sizeof(UBAtmosphere)); + }); + } + else if (atmosphereActive) { + // Re-arm so the next transition to inactive rewrites every + // frame-in-flight buffer before going idle. + m_AtmosphereIdleUploadsRemaining = Renderer::GetConfig().FramesInFlight; + const SkyAtmosphereSettings& sky = m_FrameEnvironment.Atmosphere.SkyAtmosphere; const VolumetricCloudSettings& clouds = m_FrameEnvironment.Atmosphere.VolumetricClouds; const ExponentialHeightFogSettings& fog = m_FrameEnvironment.Atmosphere.HeightFog; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 0a05aad3..0603c049 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -1162,6 +1162,14 @@ namespace Lux { std::array LocalFogVolumes{}; } m_AtmosphereUB; + // While no atmosphere feature is active, the atmosphere UB is written this + // many more times (once per frame-in-flight buffer, so all copies hold the + // disabled-flags data) and then the whole rebuild+upload goes idle. Active + // frames re-arm it to FramesInFlight. Starts above any realistic + // frames-in-flight count so a scene that begins inactive still initializes + // every buffer. + uint32_t m_AtmosphereIdleUploadsRemaining = 8; + struct CBGTAOData { glm::vec2 NDCToViewMul_x_PixelSize = { 1.0f, 1.0f }; From 25ca02ea6826958d3285ae5350f830396a1f49fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:15:34 +0000 Subject: [PATCH 15/63] Default directional shadows to 2K cascades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 4-layer 4096x4096 D24S8 cascade array costs ~268 MB of VRAM plus the bandwidth to render it — too heavy as the everyday default for a 60-80 FPS target. Default (options, project settings, and the High preset) is now Tier_2K with soft shadows; Ultra raises to 4K and Cinematic to 8K. Existing projects keep whatever their serialized settings say. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Project/Project.h | 2 +- Core/Source/Lux/Renderer/SceneRenderer.cpp | 9 ++++++--- Core/Source/Lux/Renderer/SceneRenderer.h | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Core/Source/Lux/Project/Project.h b/Core/Source/Lux/Project/Project.h index ebfe40f5..239f9ff0 100644 --- a/Core/Source/Lux/Project/Project.h +++ b/Core/Source/Lux/Project/Project.h @@ -100,7 +100,7 @@ namespace Lux float ShadowCascadeNearPlaneOffset = 0.0f; float ShadowCascadeFarPlaneOffset = 50.0f; float ShadowCascadeTransitionFade = 1.0f; - uint32_t ShadowResolution = 2; // SceneRendererOptions::ShadowResolutionTier + uint32_t ShadowResolution = 1; // SceneRendererOptions::ShadowResolutionTier (Tier_2K) bool BloomEnabled = true; uint32_t BloomResolutionScale = 2; // SceneRendererOptions::EffectResolutionScale diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index a57bdc6e..084f48ac 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -574,7 +574,7 @@ namespace Lux { m_Options.DistanceMipBiasEnd = 250.0f; m_Options.DistanceMipBiasMax = 2.0f; m_Options.SoftShadows = true; - m_Options.ShadowResolution = SceneRendererOptions::ShadowResolutionTier::Tier_4K; + m_Options.ShadowResolution = SceneRendererOptions::ShadowResolutionTier::Tier_2K; m_Options.MaxShadowDistance = 200.0f; m_Options.ShadowFade = 25.0f; m_SSROptions.MaxSteps = 70; @@ -616,7 +616,10 @@ namespace Lux { m_Options.ResolutionScaleMode = SceneRendererOptions::RenderResolutionScaleMode::Native; m_Options.TextureMipBias = -0.5f; m_Options.DistanceMipBiasMax = 1.5f; - m_Options.ShadowResolution = SceneRendererOptions::ShadowResolutionTier::Tier_4K; + // 2K + soft shadows as the realtime default; a 4-layer 4K array costs + // ~268 MB and a lot of shadow-render bandwidth. Ultra raises to 4K, + // Cinematic to 8K. + m_Options.ShadowResolution = SceneRendererOptions::ShadowResolutionTier::Tier_2K; break; case QualityPreset::Ultra: m_Options.SSRQuality = SceneRendererOptions::SSRQualityPreset::Full; @@ -632,7 +635,7 @@ namespace Lux { m_Options.DistanceMipBiasStart = 25.0f; m_Options.DistanceMipBiasEnd = 150.0f; m_Options.DistanceMipBiasMax = 1.0f; - m_Options.ShadowResolution = SceneRendererOptions::ShadowResolutionTier::Tier_8K; + m_Options.ShadowResolution = SceneRendererOptions::ShadowResolutionTier::Tier_4K; m_Options.MaxShadowDistance = 300.0f; m_SSROptions.MaxSteps = 96; break; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 0603c049..6bd6b30c 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -192,7 +192,7 @@ namespace Lux { float ShadowCascadeNearPlaneOffset = 0.0f; float ShadowCascadeFarPlaneOffset = 50.0f; float ShadowCascadeTransitionFade = 1.0f; - ShadowResolutionTier ShadowResolution = ShadowResolutionTier::Tier_4K; + ShadowResolutionTier ShadowResolution = ShadowResolutionTier::Tier_2K; QualityPreset Quality = QualityPreset::Medium; bool EnableGTAO = true; bool GTAOBentNormals = false; From 28d127aac2da07f2839a3f92850f13f50db9dac7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:16:19 +0000 Subject: [PATCH 16/63] Disable GPU timer/statistics queries in Dist builds The per-frame timer query begin/end/poll machinery exists to feed the editor's Renderer Debugger panels; Release keeps it, shipping builds now skip it. All readers already early-return when queries are off. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/RenderCommandBuffer.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp index 6b57842b..9f289041 100644 --- a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp +++ b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp @@ -29,6 +29,11 @@ namespace Lux { m_PipelineStatisticsQueryResults.emplace_back(); } +#ifdef LUX_DIST + // GPU timer/statistics queries exist to feed the editor's profiling + // panels; shipping builds skip the per-frame query begin/end/poll cost. + enableQueries = false; +#endif m_QueryEnabled = enableQueries; if (enableQueries) From 0e173bd4bdca0b5ac9ab202c822b33b9536809e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 04:17:33 +0000 Subject: [PATCH 17/63] Docs: record Phase 3 (rendering audit fixes) and Phase 4 candidates Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- docs/ENGINE_OPTIMIZATION_PLAN.md | 53 ++++++++++++++++++++++++++++++++ docs/RENDERER_PERF_BASELINE.md | 19 ++++++++++++ 2 files changed, 72 insertions(+) diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index dd45f6ba..462f7403 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -21,6 +21,7 @@ things slower or just move the cost. We already started this (Tracy + `csvexport | **Phase 1** — render-graph recompile cache + scratch reuse | ✅ Done | `RenderGraph::Compile` 996/996 frames → 1/3403; `FlushDrawList` 3.09 → 2.56 ms. | | **Phase 1.5** — lazy graph names, removed double `BuildRenderGraph` | ✅ Done | `FlushDrawList` 2.56 → 2.11 ms (**cumulative −31.7%**). | | **Phase 2** — submission-path allocations + build config | ✅ Implemented, awaiting Windows measurement | Six fixes below. | +| **Phase 3** — full rendering audit: stability + zero-cost-when-off | ✅ Implemented, awaiting Windows measurement | Seven fixes below (A8 deferred). | **Phase 2 fixes (2026-07-02, one commit each):** @@ -47,6 +48,58 @@ things slower or just move the cost. We already started this (Tracy + `csvexport *Verify:* `FlushDrawList` zone; add/remove a material + texture at runtime and confirm the change appears. +**Phase 3 fixes (2026-07-03, one commit each) — from the full rendering audit:** + +1. **Descriptor re-Bake bug** (`Platform/Vulkan/DescriptorSetManager.cpp`) — + `InvalidatedInputResources` was never cleared in the live path, so the first + invalidation (guaranteed by the startup resize) made every dynamic pass rebuild ALL + its binding sets × frames-in-flight, every frame, forever. Now cleared at the top of + `InvalidateAndUpdate`. **Prime frame-time-instability suspect.** *Verify:* the + `DescriptorSetManager::InvalidateAndUpdate ... updating N descriptors` trace log stops + repeating after warm-up/resize; frame-time graph flattens. +2. **GPUScene debug snapshot on-request** (`SceneRenderer` + `RendererDebuggerPanel`) — + the O(instances+materials) validation loops now run only on frames where the panel's + GPU Scene section requests them. *Verify:* `FlushDrawList` CPU with the panel closed. +3. **PreIntegration gated behind SSR** — its visibility pyramid is consumed only by SSR. +4. **Cluster froxel passes skip with zero local lights** — build early-outs; culling + zero-fills the grids instead of dispatching. +5. **Atmosphere UBO idles when sky/clouds/fog are all off** — disabled-flags UB written + once per frame-in-flight buffer, then no rebuild/upload until a feature activates. +6. **Shadow default 4K→2K** (options + project defaults + High preset; Ultra=4K, + Cinematic=8K) — saves ~200 MB VRAM and shadow-render bandwidth. +7. **GPU timer/pipeline queries disabled in Dist** (`RenderCommandBuffer.cpp`). + +*(A8 — JumpFlood RGBA32F→smaller format — was investigated and deferred: the algorithm +uses all four channels (xy=seed offset, z=distance, w=inside/outside), so only a +precision-reduction to RGBA16F is possible and that needs visual verification.)* + +**Phase 4 candidates (audit findings that need build/measure or shader edits — do with +Tracy + validation on):** + +- **Blanket `isUAV = true` on every color image** (`Image.cpp:190-199`) adds STORAGE usage + to all render targets, likely disabling framebuffer compression → bandwidth tax on every + full-res pass. Audit shaders for storage-image bindings first, then restrict to + `Usage == Storage` + explicit opt-ins. Biggest GPU-bandwidth suspect. +- **Synchronous per-resource GPU uploads** — every mesh/texture load creates its own + command list and submits immediately under a global queue mutex + (`VertexBuffer.cpp:23-27` etc.), and the command list is retained per buffer forever. + Batch into a per-frame upload list / transfer queue. Biggest streaming-hitch suspect. +- Format diets needing shader edits: Bloom + PreConvolution pyramids RGBA32F→RGBA16F; + JumpFlood RGBA32F→RGBA16F (see A8 note); GBuffer normal → octahedral RG16F; merge the + two R32UI id targets. +- Editor-target lazy creation (SelectedGeometry / AO-Debug / GBufferDebug ≈ 80 MB + always resident; JumpFlood ≈ 100 MB). +- Cluster grid caching on resize/projection change (currently rebuilt per frame while + lights exist). +- Empty-pass graph gating (Transparent/Selected/Wireframe still open + clear render + passes when their draw lists are empty) — interacts with render-target aliasing. +- Composite-chain merging: Skybox→Deferred→AO→SSR→Cloud→Fog→Composite→DOF each do a + full-res scene-color read-modify-write; several are mergeable. +- Memory HUD reads a vestigial VMA tracker (`VulkanAllocator.cpp`) — live allocations go + through NVRHI; re-source the stats. +- Correctness/sync audit (thread handoff, upload races, barrier semantics) — still + pending; the audit session for it was cut short. + --- ## 0. Where LuxEngine actually stands diff --git a/docs/RENDERER_PERF_BASELINE.md b/docs/RENDERER_PERF_BASELINE.md index 2594e566..453375b3 100644 --- a/docs/RENDERER_PERF_BASELINE.md +++ b/docs/RENDERER_PERF_BASELINE.md @@ -212,3 +212,22 @@ Phase 2 (submission-path allocations + build config, 2026-07-02) predicts: Renderer Debugger on the same scene, zero new Vulkan validation messages, and (for the version-gated tables) verify a runtime material edit and a texture add/remove still show up on screen. + +## Phase 3 hypotheses (rendering audit, 2026-07-03) + +- **Frame-time variance drops** (descriptor re-Bake fix): the + `DescriptorSetManager::InvalidateAndUpdate (...) - updating N descriptors` trace log + must stop repeating every frame once past startup/resize. Watch the frame-time history + graph in the Renderer Debugger — this fix targets the *instability*, not just the mean. +- **`FlushDrawList` CPU drops** with the Renderer Debugger closed (GPUScene snapshot is + now on-request). +- **GPU frame time drops** on scenes with no point/spot lights (cluster passes skip) and + with SSR off (PreIntegration gated), visible per-pass in the Renderer Debugger. +- **VRAM drops ~200 MB** at the default/High preset (2K shadow cascades) — check the + memory HUD; shadow quality visual check, Ultra restores 4K. +- **Atmosphere-disabled scenes** lose the per-frame atmosphere UBO rebuild+upload + (BeginScene zone). +- **Correctness gates:** toggle lights on/off at runtime (cluster skip must not leave + stale lighting), toggle sky/fog/clouds on/off (atmosphere idle path), open/close the + Renderer Debugger GPU Scene section (snapshot request path), resize + preset cycle + with validation on. From edb9d1e6be457eda8743d5e193b2e972d31b937e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 16:30:38 +0000 Subject: [PATCH 18/63] Self-heal framebuffers whose baked attachment handles went stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Framebuffers bake their attachment images' nvrhi texture handles into their FramebufferDesc at RT_Invalidate. Images are recreated in place (same Ref, new handle) on resize and aliasing changes, and Framebuffer::Resize early-outs purely on size — so a framebuffer that wraps a shared image via ExistingImages can be left rendering into the orphaned old texture with no path that ever repairs it. That is how the exported runtime lost all lit geometry in fullscreen: the deferred lighting framebuffer (which wraps SceneColor) went one generation stale on the fullscreen startup path, so every lit pixel landed in a dead texture while the sky wrote to the live one. Add Framebuffer::HasStaleAttachments (baked handles vs current) and a per-frame sweep in BeginScene that re-invalidates any stale framebuffer, owners before wrappers so repairs converge in one sweep. Cost when clean is a few pointer compares per framebuffer; a repair logs a warning naming the framebuffer so the triggering path can be identified from the field. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/Framebuffer.cpp | 18 +++++++++ Core/Source/Lux/Renderer/Framebuffer.h | 7 ++++ Core/Source/Lux/Renderer/SceneRenderer.cpp | 45 ++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/Core/Source/Lux/Renderer/Framebuffer.cpp b/Core/Source/Lux/Renderer/Framebuffer.cpp index ca8767ba..79454066 100644 --- a/Core/Source/Lux/Renderer/Framebuffer.cpp +++ b/Core/Source/Lux/Renderer/Framebuffer.cpp @@ -238,6 +238,24 @@ namespace Lux { callback(this); } + bool Framebuffer::HasStaleAttachments() const + { + if (!m_Handle) + return false; + + for (size_t i = 0; i < m_AttachmentImages.size() && i < m_FramebufferDesc.colorAttachments.size(); i++) + { + const Ref& image = m_AttachmentImages[i]; + if (image && image->GetHandle().Get() != m_FramebufferDesc.colorAttachments[i].texture) + return true; + } + + if (m_DepthAttachmentImage && m_DepthAttachmentImage->GetHandle().Get() != m_FramebufferDesc.depthAttachment.texture) + return true; + + return false; + } + void Framebuffer::AddResizeCallback(const std::function)>& func) { LUX_PROFILE_FUNCTION_AUTO; diff --git a/Core/Source/Lux/Renderer/Framebuffer.h b/Core/Source/Lux/Renderer/Framebuffer.h index 5173cd0a..3cd8bf1a 100644 --- a/Core/Source/Lux/Renderer/Framebuffer.h +++ b/Core/Source/Lux/Renderer/Framebuffer.h @@ -125,6 +125,13 @@ namespace Lux { size_t GetColorAttachmentCount() const { return m_Specification.SwapChainTarget ? 1 : m_AttachmentImages.size(); } bool HasDepthAttachment() const { return (bool)m_DepthAttachmentImage; } + // True when an attachment image's current GPU texture no longer matches the + // handle baked into this framebuffer's desc. Images are recreated in place + // (same Ref, new nvrhi handle) on resize/aliasing changes; a framebuffer + // that shares them (ExistingImages) keeps rendering into the orphaned old + // texture until it is re-invalidated — this detects that state. + bool HasStaleAttachments() const; + virtual nvrhi::FramebufferHandle GetHandle() const; const nvrhi::FramebufferDesc& GetFramebufferDesc() const { return m_FramebufferDesc; } diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 084f48ac..d43d396d 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -4330,6 +4330,51 @@ namespace Lux { m_DirectionalShadowMapNeedsRender = true; } + // ── Self-heal framebuffers with stale attachment handles ───────────── + // Shared images (FramebufferSpecification::ExistingImages) are recreated + // in place on resize/aliasing changes; a framebuffer wrapping one keeps + // its old baked handle and silently renders into the orphaned texture + // (e.g. runtime-fullscreen startup: deferred lighting writes into a dead + // SceneColor → black geometry under a bright sky). Cheap pointer compares + // per frame; re-invalidation only fires when actually stale. Owners come + // first: repairing one recreates its images, and the wrappers checked + // afterwards pick the new handles up in the same sweep. + { + auto repairIfStale = [](const Ref& framebuffer, const char* name) + { + if (framebuffer && framebuffer->HasStaleAttachments()) + { + LUX_CORE_WARN_TAG("Renderer", "Framebuffer '{}' had stale attachment handles - re-invalidating", name); + framebuffer->Invalidate(); + } + }; + auto repairPassIfStale = [&repairIfStale](const auto& pass, const char* name) + { + if (pass) + repairIfStale(pass->GetTargetFramebuffer(), name); + }; + + repairPassIfStale(m_PreDepthPass, "PreDepth"); + repairIfStale(m_GeometryPassFramebuffer, "GBuffer (owner)"); + repairIfStale(m_SceneColorFramebuffer, "SceneColor"); + repairIfStale(m_CompositingFramebuffer, "Compositing"); + repairPassIfStale(m_GeometryPass, "GBuffer"); + repairPassIfStale(m_GeometryPassTransparent, "TransparentForward"); + repairPassIfStale(m_DeferredLightingPass, "DeferredLighting"); + repairPassIfStale(m_AOCompositePass, "AO-Composite"); + repairPassIfStale(m_SSRCompositePass, "SSR-Composite"); + repairPassIfStale(m_SkyboxPass, "Skybox"); + repairPassIfStale(m_SkyAtmospherePass, "SkyAtmosphere"); + repairPassIfStale(m_VolumetricCloudCompositePass, "VolumetricCloudComposite"); + repairPassIfStale(m_AtmosphericFogPass, "AtmosphericFog"); + repairPassIfStale(m_GBufferDebugPass, "GBufferDebug"); + repairPassIfStale(m_SelectedGeometryPass, "SelectedGeometry"); + repairPassIfStale(m_GeometryWireframePass, "GeometryWireframe"); + repairPassIfStale(m_CompositePass, "Composite"); + repairPassIfStale(m_GridRenderPass, "Grid"); + repairPassIfStale(m_JumpFloodCompositePass, "JumpFloodComposite"); + } + ResizeVolumetricCloudResources(); // ── Camera uniform buffer ───────────────────────────────────────────── From 74a4633738d475a2e324a3cd64737f470f36231c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 16:30:59 +0000 Subject: [PATCH 19/63] Docs: record the stale wrapped-framebuffer self-heal fix Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- docs/ENGINE_OPTIMIZATION_PLAN.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index 462f7403..0739a100 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -73,6 +73,15 @@ things slower or just move the cost. We already started this (Tracy + `csvexport uses all four channels (xy=seed offset, z=distance, w=inside/outside), so only a precision-reduction to RGBA16F is possible and that needs visual verification.)* +**Post-Phase-3 bugfix — stale wrapped-framebuffer self-heal (2026-07-03):** framebuffers +wrapping shared images via `ExistingImages` bake the image's nvrhi handle and were never +re-checked — the runtime-fullscreen "black geometry, bright sky" bug (deferred lighting +writing into an orphaned SceneColor). `Framebuffer::HasStaleAttachments()` + a per-frame +repair sweep in `BeginScene` now self-heals the whole class. *Verify:* fullscreen export +lights correctly; the `had stale attachment handles` warning names the trigger framebuffer +on the first fullscreen frames (report it for the targeted root-cause follow-up) and stays +silent afterwards. + **Phase 4 candidates (audit findings that need build/measure or shader edits — do with Tracy + validation on):** From c066e489c2abf4dc2ea9033552025bced28f521d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 16:34:00 +0000 Subject: [PATCH 20/63] Fix const-propagation compile error in the framebuffer repair sweep Ref<> propagates constness to the pointee, so the const Ref& lambda parameter made the non-const Invalidate() call ill-formed (C2662). Take the smart pointers by value; GetTargetFramebuffer() is non-const too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index d43d396d..fd31cf6e 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -4340,7 +4340,9 @@ namespace Lux { // first: repairing one recreates its images, and the wrappers checked // afterwards pick the new handles up in the same sweep. { - auto repairIfStale = [](const Ref& framebuffer, const char* name) + // By value: Ref<> propagates constness to the pointee, and both + // Invalidate() and GetTargetFramebuffer() are non-const. + auto repairIfStale = [](Ref framebuffer, const char* name) { if (framebuffer && framebuffer->HasStaleAttachments()) { @@ -4348,7 +4350,7 @@ namespace Lux { framebuffer->Invalidate(); } }; - auto repairPassIfStale = [&repairIfStale](const auto& pass, const char* name) + auto repairPassIfStale = [&repairIfStale](auto pass, const char* name) { if (pass) repairIfStale(pass->GetTargetFramebuffer(), name); From 2b06ab109d9400420d4083145c3835fa57b26fd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 16:44:27 +0000 Subject: [PATCH 21/63] Bloom and SSR pre-convolution pyramids: RGBA32F -> RGBA16F Both are blurred HDR color chains; fp16 covers the range and halves the memory and bandwidth of every down/upsample step. Shader storage layouts updated to match. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 8 ++++++-- Editor/Resources/Shaders/PostProcessing/Bloom.glsl | 2 +- .../Resources/Shaders/PostProcessing/Pre-Convolution.glsl | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index fd31cf6e..c105631c 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -1124,7 +1124,9 @@ namespace Lux { m_PreIntegrationPass->Bake(); TextureSpecification preConvolutionSpec; - preConvolutionSpec.Format = ImageFormat::RGBA32F; + // 16F: a blurred scene-color mip chain for SSR needs HDR range, not + // fp32 precision — half the bandwidth/memory. Matches Pre-Convolution.glsl. + preConvolutionSpec.Format = ImageFormat::RGBA16F; preConvolutionSpec.Width = 1; preConvolutionSpec.Height = 1; preConvolutionSpec.SamplerWrap = TextureWrap::Clamp; @@ -1942,7 +1944,9 @@ namespace Lux { m_BloomComputePipeline = PipelineCompute::Create(shader); TextureSpecification spec; - spec.Format = ImageFormat::RGBA32F; + // 16F: bloom is a blurred HDR pyramid — fp32 is 2x the bandwidth for + // no visual gain. Matches Bloom.glsl's image layout. + spec.Format = ImageFormat::RGBA16F; spec.Width = 1; spec.Height = 1; spec.SamplerWrap = TextureWrap::Clamp; diff --git a/Editor/Resources/Shaders/PostProcessing/Bloom.glsl b/Editor/Resources/Shaders/PostProcessing/Bloom.glsl index 5f2fb344..60763c99 100644 --- a/Editor/Resources/Shaders/PostProcessing/Bloom.glsl +++ b/Editor/Resources/Shaders/PostProcessing/Bloom.glsl @@ -3,7 +3,7 @@ #include -layout(binding = 0, rgba32f) restrict writeonly uniform image2D o_Image; +layout(binding = 0, rgba16f) restrict writeonly uniform image2D o_Image; const float Epsilon = 1.0e-4; diff --git a/Editor/Resources/Shaders/PostProcessing/Pre-Convolution.glsl b/Editor/Resources/Shaders/PostProcessing/Pre-Convolution.glsl index d0e45d29..32d499f4 100644 --- a/Editor/Resources/Shaders/PostProcessing/Pre-Convolution.glsl +++ b/Editor/Resources/Shaders/PostProcessing/Pre-Convolution.glsl @@ -3,7 +3,7 @@ #include -layout(binding = 0, rgba32f) restrict writeonly uniform image2D o_Image; +layout(binding = 0, rgba16f) restrict writeonly uniform image2D o_Image; layout(binding = 1) uniform texture2D u_Input; layout(push_constant) uniform Uniforms From 1d478b6f2b6ff32f2241238cb7c516183a50965d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 16:50:36 +0000 Subject: [PATCH 22/63] Batch resource uploads into one shared command list Every mesh vertex/index buffer and texture upload created its own RenderCommandBuffer and called executeCommandList immediately under the global graphics-queue mutex - one vkQueueSubmit per resource, serialized against the render thread mid-frame. Loading a model with dozens of meshes meant dozens of queue submits in one frame: the classic streaming hitch. The per-buffer command lists were also retained for the resource's whole lifetime. Add Renderer::RecordResourceUpload/FlushResourceUploads: a mutex-guarded shared nvrhi command list accumulates uploads from any thread and is submitted once. Ordering is guaranteed structurally: RT_Submit flushes the pending batch before executing any command list, so uploads always reach the queue ahead of every possible consumer (frame rendering, readbacks, env-map bakes). nvrhi stages source data at record time, so caller-side data lifetimes are unchanged. The GPU-blocking readback path (CopyToHostBuffer) keeps its dedicated list. Also downgrade the per-creation TextureCube warning to a trace tag. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/Image.cpp | 15 +++---- Core/Source/Lux/Renderer/IndexBuffer.cpp | 11 ++--- .../Lux/Renderer/RenderCommandBuffer.cpp | 5 +++ Core/Source/Lux/Renderer/Renderer.cpp | 45 +++++++++++++++++++ Core/Source/Lux/Renderer/Renderer.h | 13 ++++++ Core/Source/Lux/Renderer/Texture.cpp | 31 +++++-------- Core/Source/Lux/Renderer/VertexBuffer.cpp | 14 +++--- 7 files changed, 96 insertions(+), 38 deletions(-) diff --git a/Core/Source/Lux/Renderer/Image.cpp b/Core/Source/Lux/Renderer/Image.cpp index 21ebd11c..10f514e1 100644 --- a/Core/Source/Lux/Renderer/Image.cpp +++ b/Core/Source/Lux/Renderer/Image.cpp @@ -329,15 +329,12 @@ namespace Lux { if (buffer) { - if (!m_CommandList) - m_CommandList = RenderCommandBuffer::Create(1, "Image2D"); - - m_CommandList->RT_Begin(); - - m_CommandList->GetActive()->writeTexture(m_Info.ImageHandle, 0, 0, buffer.Data, Utils::GetImageMemoryRowPitch(m_Specification.Format, m_Specification.Width)); - - m_CommandList->RT_End(); - m_CommandList->RT_Submit(); + // Shared upload batch — one vkQueueSubmit per texture causes load + // hitches; see Renderer::RecordResourceUpload. + Renderer::RecordResourceUpload([&](nvrhi::ICommandList* uploadList) + { + uploadList->writeTexture(m_Info.ImageHandle, 0, 0, buffer.Data, Utils::GetImageMemoryRowPitch(m_Specification.Format, m_Specification.Width)); + }); m_Info.State = nvrhi::ResourceStates::ShaderResource; } diff --git a/Core/Source/Lux/Renderer/IndexBuffer.cpp b/Core/Source/Lux/Renderer/IndexBuffer.cpp index e39e03bd..7c930d59 100644 --- a/Core/Source/Lux/Renderer/IndexBuffer.cpp +++ b/Core/Source/Lux/Renderer/IndexBuffer.cpp @@ -2,6 +2,7 @@ #include "IndexBuffer.h" #include "Lux/Core/Application.h" +#include "Lux/Renderer/Renderer.h" namespace Lux { @@ -20,11 +21,11 @@ namespace Lux { nvrhi::DeviceHandle device = Application::GetGraphicsDevice(); m_Handle = device->createBuffer(indexBufferDesc); - m_CommandList = RenderCommandBuffer::Create(1, "IndexBuffer"); - m_CommandList->RT_Begin(); - m_CommandList->GetActive()->writeBuffer(m_Handle, buffer.Data, buffer.Size); - m_CommandList->RT_End(); - m_CommandList->RT_Submit(); + // Shared upload batch — see VertexBuffer(Buffer) for rationale. + Renderer::RecordResourceUpload([&](nvrhi::ICommandList* uploadList) + { + uploadList->writeBuffer(m_Handle, buffer.Data, buffer.Size); + }); } IndexBuffer::IndexBuffer(uint64_t size) diff --git a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp index 9f289041..2ca37dd3 100644 --- a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp +++ b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp @@ -208,6 +208,11 @@ namespace Lux { LUX_PROFILE_FUNCTION_AUTO; LUX_CORE_TRACE_TAG("Renderer", "Submitting Render Command Buffer {}", m_DebugName); + // Flush batched resource uploads first: anything this command list may + // consume (mesh buffers, texture data) must reach the queue ahead of it. + // Cheap no-op when nothing is pending. + Renderer::FlushResourceUploads(); + auto device = Application::GetGraphicsDevice(); if (m_QueryEnabled) diff --git a/Core/Source/Lux/Renderer/Renderer.cpp b/Core/Source/Lux/Renderer/Renderer.cpp index 359ed254..418bc839 100644 --- a/Core/Source/Lux/Renderer/Renderer.cpp +++ b/Core/Source/Lux/Renderer/Renderer.cpp @@ -624,6 +624,10 @@ namespace Lux { vkDeviceWaitIdle(device); } + // Execute any batched uploads still pending, then release the shared list. + FlushResourceUploads(); + s_ResourceUploadCommandList = nullptr; + #if LUX_HAS_SHADER_COMPILER VulkanShaderCompiler::ClearUniformBuffers(); @@ -787,6 +791,47 @@ namespace Lux { Submit(std::move(func)); } + // ── Batched resource uploads ───────────────────────────────────────────── + // One shared command list accumulates initial-data uploads from any thread; + // FlushResourceUploads submits it once. See the declaration in Renderer.h for + // the ordering guarantee (flush runs before every RenderCommandBuffer submit). + + static std::mutex s_ResourceUploadMutex; + static nvrhi::CommandListHandle s_ResourceUploadCommandList; + static bool s_ResourceUploadListOpen = false; + + void Renderer::RecordResourceUpload(const std::function& record) + { + LUX_PROFILE_FUNCTION_AUTO; + std::scoped_lock lock(s_ResourceUploadMutex); + + if (!s_ResourceUploadCommandList) + s_ResourceUploadCommandList = Application::GetGraphicsDevice()->createCommandList(); + + if (!s_ResourceUploadListOpen) + { + s_ResourceUploadCommandList->open(); + s_ResourceUploadListOpen = true; + } + + record(s_ResourceUploadCommandList); + } + + void Renderer::FlushResourceUploads() + { + std::scoped_lock lock(s_ResourceUploadMutex); + if (!s_ResourceUploadListOpen) + return; + + LUX_PROFILE_SCOPE("Renderer::FlushResourceUploads"); + s_ResourceUploadCommandList->close(); + s_ResourceUploadListOpen = false; + + RenderCommandBuffer::LockQueue(); + Application::GetGraphicsDevice()->executeCommandList(s_ResourceUploadCommandList); + RenderCommandBuffer::UnlockQueue(); + } + uint32_t Renderer::GetRenderQueueIndex() { LUX_PROFILE_FUNCTION_AUTO; diff --git a/Core/Source/Lux/Renderer/Renderer.h b/Core/Source/Lux/Renderer/Renderer.h index 3dda2b8d..a5c4ea57 100644 --- a/Core/Source/Lux/Renderer/Renderer.h +++ b/Core/Source/Lux/Renderer/Renderer.h @@ -114,6 +114,19 @@ namespace Lux { // called from the main thread, once per frame, before the frame's render work is submitted. static void ExecuteBackgroundThreadSubmits(); + // ── Batched resource uploads ────────────────────────────────────────── + // Buffer/texture constructors record their initial-data uploads into one + // shared command list instead of creating and submitting a dedicated + // command list per resource (a vkQueueSubmit per mesh/texture is a load + // hitch and contends the graphics queue against the render thread). The + // batch is flushed automatically before every RenderCommandBuffer + // submission (see RT_Submit), so uploads always reach the GPU queue ahead + // of any command list that could consume them. Thread-safe; the record + // callback runs synchronously, so callers may free their CPU data on + // return (nvrhi stages it into the command list at record time). + static void RecordResourceUpload(const std::function& record); + static void FlushResourceUploads(); + template static void SubmitResourceFree(FuncT&& func) { diff --git a/Core/Source/Lux/Renderer/Texture.cpp b/Core/Source/Lux/Renderer/Texture.cpp index f109efe1..4c584e73 100644 --- a/Core/Source/Lux/Renderer/Texture.cpp +++ b/Core/Source/Lux/Renderer/Texture.cpp @@ -989,29 +989,22 @@ namespace Lux { m_Image->RT_Invalidate(); s_TextureCubeReferences[GetHandle().Get()] = this; - LUX_CORE_WARN("Creating TextureCube (LIVE REFS={})", s_TextureCubeReferences.size()); + LUX_CORE_TRACE_TAG("Renderer", "Creating TextureCube (LIVE REFS={})", s_TextureCubeReferences.size()); if (m_LocalStorage) { - if (!m_CommandList) - m_CommandList = RenderCommandBuffer::Create(1, "TextureCube"); - - m_CommandList->RT_Begin(); - - // nvrhi::StagingTextureHandle stagingTexture = device->createStagingTexture(textureDesc, nvrhi::CpuAccessMode::Write); - // device->mapStagingTexture(stagingTexture, ); - - // NOTE(Yan): ONLY WORKS FOR MIP 0! - const uint8_t* data = m_LocalStorage.As(); - uint64_t stride = m_LocalStorage.Size / 6; - for (uint32_t i = 0; i < 6; i++) + // Shared upload batch — see Renderer::RecordResourceUpload. + Renderer::RecordResourceUpload([&](nvrhi::ICommandList* uploadList) { - m_CommandList->GetActive()->writeTexture(GetHandle(), i, 0, data, Utils::GetImageMemoryRowPitch(m_Specification.Format, m_Specification.Width)); - data += stride; - } - - m_CommandList->RT_End(); - m_CommandList->RT_Submit(); + // NOTE(Yan): ONLY WORKS FOR MIP 0! + const uint8_t* data = m_LocalStorage.As(); + uint64_t stride = m_LocalStorage.Size / 6; + for (uint32_t i = 0; i < 6; i++) + { + uploadList->writeTexture(GetHandle(), i, 0, data, Utils::GetImageMemoryRowPitch(m_Specification.Format, m_Specification.Width)); + data += stride; + } + }); } #if OLD diff --git a/Core/Source/Lux/Renderer/VertexBuffer.cpp b/Core/Source/Lux/Renderer/VertexBuffer.cpp index dbf4ef4d..25bd53c0 100644 --- a/Core/Source/Lux/Renderer/VertexBuffer.cpp +++ b/Core/Source/Lux/Renderer/VertexBuffer.cpp @@ -2,6 +2,7 @@ #include "VertexBuffer.h" #include "Lux/Core/Application.h" +#include "Lux/Renderer/Renderer.h" namespace Lux { @@ -20,11 +21,14 @@ namespace Lux { nvrhi::DeviceHandle device = Application::GetGraphicsDevice(); m_Handle = device->createBuffer(vertexBufferDesc); - m_CommandList = RenderCommandBuffer::Create(1, "VertexBuffer"); - m_CommandList->RT_Begin(); - m_CommandList->GetActive()->writeBuffer(m_Handle, buffer.Data, buffer.Size); - m_CommandList->RT_End(); - m_CommandList->RT_Submit(); + // Record into the shared upload batch instead of creating and submitting + // a dedicated command list per buffer (one vkQueueSubmit per mesh causes + // load hitches, and the list used to be retained for the buffer's whole + // lifetime). + Renderer::RecordResourceUpload([&](nvrhi::ICommandList* uploadList) + { + uploadList->writeBuffer(m_Handle, buffer.Data, buffer.Size); + }); } VertexBuffer::VertexBuffer(uint64_t size) From 51293d63f445e28a6da92db2e8c948b2cb040b3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 16:52:27 +0000 Subject: [PATCH 23/63] Docs: record Phase 4 loading/stutter progress and PSO-cache findings Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- docs/ENGINE_OPTIMIZATION_PLAN.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index 0739a100..223a1811 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -82,6 +82,30 @@ lights correctly; the `had stale attachment handles` warning names the trigger f on the first fullscreen frames (report it for the targeted root-cause follow-up) and stays silent afterwards. +**Phase 4 progress (2026-07-03, Loading & Stutter session):** + +1. **Batched resource uploads** (`Renderer::RecordResourceUpload/FlushResourceUploads`; + converted: `VertexBuffer`, `IndexBuffer`, `Image2D::SetData`, `TextureCube`) — one + shared command list replaces a vkQueueSubmit per mesh/texture; the batch flushes + automatically before every `RenderCommandBuffer::RT_Submit`, so uploads always land + ahead of any consumer. Also stops retaining a command list per buffer forever. + *Verify:* load a heavy scene / stream assets while watching the frame-time graph — + load-time spikes should shrink dramatically; visuals identical. +2. **Bloom + SSR pre-convolution pyramids RGBA32F→RGBA16F** (+ shader storage layouts) — + half the bandwidth on every down/upsample. *Verify:* bloom/SSR before-after eyeball. +3. **PSO disk cache — investigated, blocked:** graphics/compute pipelines are created + inside NVRHI (`PipelineCompute` etc. hold nvrhi handles); the vendored `nvrhi` submodule + wasn't checked out in this environment, so whether the fork exposes a + `VkPipelineCache` hook couldn't be verified. Next session with the submodule present: + check `nvrhi::vulkan::DeviceDesc` for a pipeline-cache field; if absent, patch the + fork to create/serialize one (`~/.lux/pipeline.cache`-style). The legacy + `VulkanComputePipeline.cpp` per-pipeline `vkCreatePipelineCache` is dead code (live + compute goes through nvrhi) — remove during the SceneRenderer split. +4. **Upload-race note:** with batched uploads the content-vs-consumer ordering is now + structural. The remaining race is only "buffer object not yet created" on async loads, + which the existing null-guards handle (mesh appears a frame later). A per-mesh ready + flag is the polish item if the one-frame pop-in ever bothers. + **Phase 4 candidates (audit findings that need build/measure or shader edits — do with Tracy + validation on):** From 4faa43253c3a9a35ab60d5c757df32071a646b92 Mon Sep 17 00:00:00 2001 From: Pier-Olivier Boulianne Date: Fri, 3 Jul 2026 12:57:45 -0400 Subject: [PATCH 24/63] Rewrite README to honestly reflect the engine's current state Replaces the stale "not much is implemented" intro with an audited feature list (renderer, physics, scripting, assets, editor, runtime), an explicit limitations section, active development notes, and a technology table. Getting-started and CI sections kept. Co-Authored-By: Claude Fable 5 --- README.md | 127 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 100 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2ba2dd67..5c431b09 100644 --- a/README.md +++ b/README.md @@ -2,45 +2,118 @@ ![LuxEngine](/Resources/Branding/LuxEngineLogo.png?raw=true "LuxEngine") -LuxEngine is primarily an early-stage interactive application and rendering engine for Windows. Currently not much is implemented. +LuxEngine is a C++20, Vulkan-based 3D game engine and editor for Windows, in active development. Its architecture descends from [Hazel](https://github.com/TheCherno/Hazel), but it has grown well beyond that starting point: a deferred, clustered PBR renderer with a render graph, volumetric clouds and physically-based sky, Jolt physics, C# scripting, a UUID-based asset pipeline with runtime asset packs, and a docking ImGui editor with a standalone runtime player. + +This is a solo project that doubles as a learning vehicle for engine architecture. It is not production-ready and does not pretend to be — the sections below say plainly what works, what is partial, and what does not exist yet. + +*** + +## What works today + +### Rendering (Vulkan 1.4) +- **Deferred PBR pipeline** with a G-buffer, clustered (froxel) light culling for point/spot lights, and a separate forward pass for transparents. +- **Render graph** with compile caching and scratch-resource reuse; passes are skipped when their feature is off (zero-cost-when-disabled is an explicit goal). +- **Shadows** — cascaded directional shadow maps (2K default) and spot-light shadow maps. +- **Sky & atmosphere** — physically-based sky atmosphere, Preetham sky, HDR environment maps (equirect → cubemap, irradiance + prefiltered mips), skybox pass. +- **Volumetric clouds** — Nubis/RDR2-style system with baked 3D noise textures (base shape, detail, curl), temporal reprojection, and a composite pass. +- **Volumetric / atmospheric fog** — froxel fog with clustered local-light in-scattering, exponential height fog, and local fog volumes. +- **Post-processing** — GTAO (with temporal + denoise), screen-space reflections (with temporal + composite), TAA, bloom, depth of field, and HZB generation used for occlusion and SSR pre-integration. +- **Physical imaging** — exposure as manual multiplier, manual EV100, physical camera (aperture/shutter/ISO), or histogram auto-exposure; ACES and AgX tonemapping; physical light units. +- **Volume system** — blendable post-process, atmosphere, and fog volumes (box/sphere) that override settings per region. +- **GPU-driven bits** — GPU scene buffers, compute mesh culling, per-pass GPU timing. +- **2D batch renderer** — quads/sprites, circles, lines, and MSDF text rendering (msdf-atlas-gen). +- **Editor rendering** — jump-flood selection outlines, wireframe and G-buffer/AO debug views, infinite grid, debug renderer. + +### Engine systems +- **ECS scenes** (EnTT) with entity hierarchies, prefabs, YAML scene serialization, and editor Play / Simulate / Stop. +- **3D physics** (Jolt) — rigid bodies, box/sphere/capsule/mesh/compound colliders, a character controller, physics layers, and a mesh-cooking cache. **2D physics** (Box2D) — rigid bodies, box and circle colliders. +- **C# scripting** (Mono) — script components with a managed `ScriptCore` assembly; entity, transform, and input bindings. +- **Asset pipeline** — UUID-handle asset manager with editor and runtime variants, an asset registry, Assimp mesh import, texture import, material assets, and binary **asset packs + shader packs** for shipping runtime builds. +- **Audio** (miniaudio) — audio source and listener components; play/stop with basic controls. +- **Standalone runtime** — `Lux-Runtime` plays a packaged project without the editor. +- **Multithreading** — optional dedicated render thread (validated on and off), a job system, and a simulation thread. +- **Tooling & debugging** — Tracy CPU/GPU profiling on every pass, Nvidia Aftermath GPU crash dumps, shader hot-reload with a SPIR-V reflection cache, validation-layer plumbing, memory tracking, and tiering/quality settings serialized per project. + +### Editor +Docking ImGui editor with viewport + ImGuizmo gizmos, content browser with thumbnail cache, material editor, scene renderer and renderer debugger panels, render stats, light settings, asset manager panel, editor console, project settings, and a basic text editor. Ships with a sample project. *** -## Getting Started -Visual Studio 2022 is the recommended CI-compatible target. Visual Studio 2026 generation is also supported for local development when the v145 toolset is installed. LuxEngine is officially untested on other development environments while we focus on a Windows build. +## Honest limitations -**1. Downloading the repository:** +- **Windows only.** Linux paths exist in the build scripts but are untested and almost certainly broken. No macOS, no mobile. +- **Vulkan only.** No DirectX, Metal, or OpenGL backends (NVRHI is vendored but not the active path). +- **No skeletal animation yet.** Skeleton/bone import scaffolding and animated-mesh shader variants exist, but the animation importer and playback system are not wired up. Static meshes only, in practice. +- **No particle system.** +- **No terrain in-tree yet.** A GPU clipmap terrain with Jolt heightfield collision is in development on a branch, not merged. +- **Scripting API is thin.** The C# surface covers entities, transforms, and input — no physics, audio, or renderer bindings yet. +- **Audio is basic.** Play/stop and simple parameters; no mixer, DSP, or spatialization work. +- **No networking, no AI/navigation.** +- **2D is a renderer, not a toolset.** Sprites, circles, lines, and text render fine, but there are no tilemaps or 2D-specific editor workflows. +- **Rough edges everywhere.** One sample project, sparse docs (see `docs/`), no packaged releases — you build from source. -Start by cloning the repository with `git clone --recursive https://github.com/starbounded-dev/LuxEngine`. +*** -If the repository was cloned non-recursively previously, use `git submodule update --init --recursive` to clone the necessary submodules. +## Active development -**2. Configuring the dependencies:** +Current focus is a measured performance campaign (see [docs/ENGINE_OPTIMIZATION_PLAN.md](docs/ENGINE_OPTIMIZATION_PLAN.md)): Tracy-instrumented baselines, render-graph compile caching, eliminating per-frame allocations on the submission path, descriptor-set churn fixes, and LTO'd Dist builds. Recent work also landed the clustered lighting rewrite (the old forward/tiled path was removed) and render-thread validation. -1. Run the [Setup.bat](scripts/Setup.bat) file found in the `scripts` folder. This validates Python packages, checks the Vulkan SDK, pulls Git LFS assets and submodules, and generates project files. -2. One prerequisite is the Vulkan SDK 1.4.x. If it is not installed, the script will download `VulkanSDK.exe` and prompt the user to install the SDK. -3. After installation, run [Setup.bat](scripts/Setup.bat) again. Debug builds require the Vulkan SDK shader debug libraries. -4. The setup script generates the root Visual Studio solution, Editor project files, and Lux-Runtime project files. If changes are made, or if you want to regenerate project files, rerun the [Win-GenProjects.bat](scripts/Win-GenProjects.bat) script file found in the `scripts` folder. +**Next up (roughly in order):** +- Skeletal animation (import → playback → animated passes, which already exist shader-side) +- Merging the procedural terrain system +- Broader C# scripting API +- Asset streaming / async upload hardening +- Particles +- Linux support, eventually — the build system keeps it in mind, nothing more *** -## Continuous Integration -The [Build LuxEngine](.github/workflows/main.yml) workflow builds Debug, Release, and Dist on Windows Server 2025. It checks out LFS assets and submodules recursively, installs Python and the Vulkan SDK, generates Visual Studio 2022 project files with `scripts/Setup.py vs2022`, and builds `Lux.sln` for the `Mixed Platforms` solution platform. Debug and Release builds upload an `editor-` artifact containing the built Editor output plus `Editor/imgui.ini`, `Editor/App.lsettings`, `Editor/LuxSampleProject`, `Editor/Resources`, and `Editor/mono`. MSBuild logs are uploaded for each configuration when the workflow runs. +## Getting started + +Visual Studio 2022 is the recommended CI-compatible target. Visual Studio 2026 generation is also supported for local development when the v145 toolset is installed. Other environments are untested. + +**1. Clone recursively** (submodules are required): + +``` +git clone --recursive https://github.com/starbounded-dev/LuxEngine +``` + +If you cloned non-recursively, run `git submodule update --init --recursive`. + +**2. Configure dependencies:** + +1. Run [Setup.bat](scripts/Setup.bat) in the `scripts` folder. It validates Python packages, checks the Vulkan SDK, pulls Git LFS assets and submodules, and generates project files. +2. The **Vulkan SDK 1.4.x** is required. If missing, the script downloads the installer and prompts you; Debug builds additionally need the SDK's shader debug libraries. +3. After installing the SDK, run [Setup.bat](scripts/Setup.bat) again. +4. To regenerate project files later, run [Win-GenProjects.bat](scripts/Win-GenProjects.bat). + +Then open `Lux.sln` and build. `Editor` is the main workspace app; `Lux-Runtime` is the standalone player. + +*** + +## Continuous integration + +The [Build LuxEngine](.github/workflows/main.yml) workflow builds Debug, Release, and Dist on Windows Server 2025: recursive LFS/submodule checkout, Python + Vulkan SDK install, VS2022 project generation via `scripts/Setup.py vs2022`, and an MSBuild of `Lux.sln`. Debug and Release upload an `editor-` artifact with the built editor, sample project, and resources; MSBuild logs are uploaded per configuration. + +*** + +## Technology + +| Area | Library | +|---|---| +| Graphics | Vulkan 1.4, shaderc, SPIRV-Cross, SPIRV-Tools, DXC | +| Windowing / UI | GLFW, Dear ImGui (docking), ImGuizmo | +| Physics | Jolt Physics (3D), Box2D (2D) | +| ECS | EnTT | +| Scripting | Mono (C#) | +| Assets | Assimp, stb, yaml-cpp | +| Text | msdf-atlas-gen / msdfgen, FreeType | +| Audio | miniaudio | +| Profiling / debug | Tracy, Nvidia Aftermath | +| Math / util | glm, spdlog, magic_enum, choc, FastNoise | *** +## The plan -## The Plan -The plan for LuxEngine is two-fold: to create a powerful 3D engine, but also to serve as an education tool for teaching game engine design and architecture. Because of this the development inside this repository is rather slow, since everything has to be taught and implemented by myself. - -### Main features to come: -- Fast 2D rendering (UI, particles, sprites, etc.) -- High-fidelity Physically-Based 3D rendering (this will be expanded later, 2D to come first) -- Support for Mac, Linux, Android and iOS - - Native rendering API support (DirectX, Vulkan, Metal) -- Fully featured viewer and editor applications -- Fully scripted interaction and behavior -- Integrated 3rd party 2D and 3D physics engine -- Procedural terrain and world generation -- Artificial Intelligence -- Audio system +LuxEngine's purpose is two-fold: to become a capable 3D engine, and to serve as an education vehicle for game engine design and architecture. Everything is learned and implemented by one person, so development is deliberate rather than fast — depth over breadth, and honest status reporting over marketing. From 96150f5cb9d95237a5d18c9906a37bf75bfe982f Mon Sep 17 00:00:00 2001 From: Pier-Olivier Boulianne Date: Fri, 3 Jul 2026 17:24:13 -0400 Subject: [PATCH 25/63] fix: framebuffer attachment type --- Core/Source/Lux/Renderer/Renderer.cpp | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Core/Source/Lux/Renderer/Renderer.cpp b/Core/Source/Lux/Renderer/Renderer.cpp index 418bc839..27710649 100644 --- a/Core/Source/Lux/Renderer/Renderer.cpp +++ b/Core/Source/Lux/Renderer/Renderer.cpp @@ -356,6 +356,10 @@ namespace Lux { static std::vector> s_BackgroundThreadSubmitQueue; static std::mutex s_BackgroundThreadSubmitMutex; + static std::mutex s_ResourceUploadMutex; + static nvrhi::CommandListHandle s_ResourceUploadCommandList; + static bool s_ResourceUploadListOpen = false; + static RendererAPI* InitRendererAPI() { switch (RendererAPI::Current()) @@ -796,10 +800,6 @@ namespace Lux { // FlushResourceUploads submits it once. See the declaration in Renderer.h for // the ordering guarantee (flush runs before every RenderCommandBuffer submit). - static std::mutex s_ResourceUploadMutex; - static nvrhi::CommandListHandle s_ResourceUploadCommandList; - static bool s_ResourceUploadListOpen = false; - void Renderer::RecordResourceUpload(const std::function& record) { LUX_PROFILE_FUNCTION_AUTO; @@ -860,7 +860,8 @@ namespace Lux { if (explicitClear || framebuffer->GetSpecification().ClearColorOnLoad) { - for (size_t i = 0; i < framebuffer->GetColorAttachmentCount(); i++) + const uint32_t colorAttachmentCount = static_cast(framebuffer->GetColorAttachmentCount()); + for (uint32_t i = 0; i < colorAttachmentCount; i++) { nvrhi::Color color = nvrhi::Color(clearValues[i].Color.float32[0], clearValues[i].Color.float32[1], clearValues[i].Color.float32[2], clearValues[i].Color.float32[3]); @@ -897,10 +898,12 @@ namespace Lux { graphicsState.indexBuffer = nvrhi::IndexBufferBinding{}; // Viewport and scissor - float fbWidth = (float)framebuffer->GetWidth(); - float fbHeight = (float)framebuffer->GetHeight(); + const uint32_t framebufferWidth = framebuffer->GetWidth(); + const uint32_t framebufferHeight = framebuffer->GetHeight(); + float fbWidth = (float)framebufferWidth; + float fbHeight = (float)framebufferHeight; graphicsState.viewport.viewports = { nvrhi::Viewport(fbWidth, fbHeight) }; - graphicsState.viewport.scissorRects = { nvrhi::Rect(fbWidth, fbHeight) }; + graphicsState.viewport.scissorRects = { nvrhi::Rect(static_cast(framebufferWidth), static_cast(framebufferHeight)) }; graphicsState.lineWidth = 0.0f; if (renderPass->GetPipeline()->IsDynamicLineWidth()) From 97d1bf33d771889268ae32486aeb5e52f6f5b525 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:27:31 +0000 Subject: [PATCH 26/63] Memory HUD: report driver-truth VRAM usage via VK_EXT_memory_budget GetStats reported Used from a VMA-side tracker that only the dead #if OLD allocation paths ever fed - all live allocations go through NVRHI, so the HUD showed ~0 used. TotalAvailable also summed every heap's budget, including host-visible. Query per-heap usage/budget with VkPhysicalDeviceMemoryBudgetPropertiesEXT (physical-device-level functionality: requires the extension to be supported, not enabled) and sum only DEVICE_LOCAL heaps. This is process-wide driver truth and includes NVRHI's allocations. Falls back to the previous behavior when the extension is unsupported. Both HUD consumers (editor Render Stats, runtime overlay) read Used/TotalAvailable and need no changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- .../Lux/Platform/Vulkan/VulkanAllocator.cpp | 90 ++++++++++++++++--- 1 file changed, 80 insertions(+), 10 deletions(-) diff --git a/Core/Source/Lux/Platform/Vulkan/VulkanAllocator.cpp b/Core/Source/Lux/Platform/Vulkan/VulkanAllocator.cpp index ec0baba1..3b372512 100644 --- a/Core/Source/Lux/Platform/Vulkan/VulkanAllocator.cpp +++ b/Core/Source/Lux/Platform/Vulkan/VulkanAllocator.cpp @@ -41,6 +41,65 @@ namespace Lux { return deviceLocalBudget > 0 ? deviceLocalBudget : totalBudget; } + + // Driver-reported per-heap usage/budget via VK_EXT_memory_budget. This is + // process-wide truth and therefore INCLUDES allocations made by NVRHI's + // internal allocator, which bypass this VMA instance entirely (the local + // tracking below only ever sees the legacy #if OLD paths, i.e. ~nothing). + // Physical-device-level functionality of a device extension only requires + // the extension to be *supported*, not enabled on the device. + bool QueryDeviceLocalMemoryBudget(uint64_t& outUsed, uint64_t& outBudget) + { + nvrhi::DeviceHandle device = Application::GetGraphicsDevice(); + if (!device) + return false; + + VkPhysicalDevice physicalDevice = (VkPhysicalDevice)device->getNativeObject(nvrhi::ObjectTypes::VK_PhysicalDevice); + if (!physicalDevice) + return false; + + static int s_MemoryBudgetSupport = -1; // -1 unknown, 0 no, 1 yes + if (s_MemoryBudgetSupport == -1) + { + s_MemoryBudgetSupport = 0; + uint32_t extensionCount = 0; + vkEnumerateDeviceExtensionProperties(physicalDevice, nullptr, &extensionCount, nullptr); + std::vector extensions(extensionCount); + vkEnumerateDeviceExtensionProperties(physicalDevice, nullptr, &extensionCount, extensions.data()); + for (const VkExtensionProperties& extension : extensions) + { + if (strcmp(extension.extensionName, VK_EXT_MEMORY_BUDGET_EXTENSION_NAME) == 0) + { + s_MemoryBudgetSupport = 1; + break; + } + } + } + + if (s_MemoryBudgetSupport != 1) + return false; + + VkPhysicalDeviceMemoryBudgetPropertiesEXT budgetProperties{}; + budgetProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_BUDGET_PROPERTIES_EXT; + + VkPhysicalDeviceMemoryProperties2 memoryProperties{}; + memoryProperties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_PROPERTIES_2; + memoryProperties.pNext = &budgetProperties; + vkGetPhysicalDeviceMemoryProperties2(physicalDevice, &memoryProperties); + + outUsed = 0; + outBudget = 0; + for (uint32_t heap = 0; heap < memoryProperties.memoryProperties.memoryHeapCount; heap++) + { + if ((memoryProperties.memoryProperties.memoryHeaps[heap].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) == 0) + continue; + + outUsed += budgetProperties.heapUsage[heap]; + outBudget += budgetProperties.heapBudget[heap]; + } + + return outBudget > 0; + } } struct VulkanAllocatorData @@ -271,20 +330,31 @@ namespace Lux { LUX_PROFILE_FUNCTION_AUTO; GPUMemoryStats result; + // Preferred: driver-reported device-local usage/budget (VK_EXT_memory_budget). + // Real allocations go through NVRHI, not this VMA instance, so the local + // tracking below cannot see them — only the driver numbers are truthful. + uint64_t deviceUsed = 0; + uint64_t deviceBudget = 0; + const bool haveDeviceBudget = QueryDeviceLocalMemoryBudget(deviceUsed, deviceBudget); + if (!s_Data || !s_Data->Allocator) { - result.TotalAvailable = GetNativeDeviceLocalMemoryBudget(); + result.Used = haveDeviceBudget ? deviceUsed : 0; + result.TotalAvailable = haveDeviceBudget ? deviceBudget : GetNativeDeviceLocalMemoryBudget(); return result; } - std::array budgets{}; - vmaGetBudget(s_Data->Allocator, budgets.data()); - uint64_t budget = 0; - for (VmaBudget& b : budgets) - budget += b.budget; - if (budget == 0) - budget = GetNativeDeviceLocalMemoryBudget(); + if (!haveDeviceBudget) + { + std::array budgets{}; + vmaGetBudget(s_Data->Allocator, budgets.data()); + + for (VmaBudget& b : budgets) + budget += b.budget; + if (budget == 0) + budget = GetNativeDeviceLocalMemoryBudget(); + } for (const auto& [k, v] : s_AllocationMap) { @@ -301,8 +371,8 @@ namespace Lux { } result.AllocationCount = s_AllocationMap.size(); - result.Used = s_Data->MemoryUsage; - result.TotalAvailable = budget; + result.Used = haveDeviceBudget ? deviceUsed : s_Data->MemoryUsage; + result.TotalAvailable = haveDeviceBudget ? deviceBudget : budget; return result; #if 0 VmaStats stats; From f551a740dc1672b6d38bd5c2f9499847f091b422 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:31:35 +0000 Subject: [PATCH 27/63] Mesh CPU memory: drop dead triangle cache, compact vertices in runtime Two findings from the memory audit: 1. m_TriangleCache stored three full Vertex structs per triangle (~168 B/ triangle) and was built by both the Assimp importer and the runtime mesh deserializer - but GetTriangleCache had zero callers anywhere. Removed the member, accessor, and both build loops. 2. m_Vertices/m_Indices stayed in system RAM forever, doubling every mesh. The only CPU consumers are the two Jolt cooking paths, and they read vertex positions + indices exclusively. The standalone runtime now compacts each MeshSource after GPU upload: positions are kept in a positions-only array (12 B/vertex instead of the full ~56 B vertex), indices are kept for cooking, and the full vertex array is freed. Physics reads through new GetVertexCount/GetVertexPosition accessors that work in both modes. The editor retains full data (mesh export and serialization read it); behavior there is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Asset/AssimpMeshImporter.cpp | 14 ----------- .../Lux/Asset/MeshRuntimeSerializer.cpp | 24 +++---------------- .../Lux/Physics/JoltPhysics/JoltShapes.cpp | 12 ++++++---- Core/Source/Lux/Physics/PhysicsScene.cpp | 14 ++++++----- Core/Source/Lux/Renderer/Mesh.cpp | 21 ++++++++++++++++ Core/Source/Lux/Renderer/Mesh.h | 22 +++++++++++++---- Lux-Runtime/src/RuntimeApplication.cpp | 5 ++++ 7 files changed, 62 insertions(+), 50 deletions(-) diff --git a/Core/Source/Lux/Asset/AssimpMeshImporter.cpp b/Core/Source/Lux/Asset/AssimpMeshImporter.cpp index 212b8e8d..c6a66459 100644 --- a/Core/Source/Lux/Asset/AssimpMeshImporter.cpp +++ b/Core/Source/Lux/Asset/AssimpMeshImporter.cpp @@ -569,20 +569,6 @@ namespace Lux ImportAssimpMaterials(m_Path, scene, meshSource); - // ── Triangle cache ──────────────────────────────────────────────────── - for (uint32_t i = 0; i < (uint32_t)meshSource->m_Submeshes.size(); i++) - { - const Submesh& sm = meshSource->m_Submeshes[i]; - for (uint32_t f = 0; f < sm.IndexCount / 3; f++) - { - const Index& idx = meshSource->m_Indices[sm.BaseIndex / 3 + f]; - meshSource->m_TriangleCache[i].emplace_back( - meshSource->m_Vertices[sm.BaseVertex + idx.V1], - meshSource->m_Vertices[sm.BaseVertex + idx.V2], - meshSource->m_Vertices[sm.BaseVertex + idx.V3]); - } - } - // ── GPU buffers ─────────────────────────────────────────────────────── meshSource->m_VertexBuffer = VertexBuffer::Create( Buffer(meshSource->m_Vertices.data(), diff --git a/Core/Source/Lux/Asset/MeshRuntimeSerializer.cpp b/Core/Source/Lux/Asset/MeshRuntimeSerializer.cpp index cb6e6953..a8f0a103 100644 --- a/Core/Source/Lux/Asset/MeshRuntimeSerializer.cpp +++ b/Core/Source/Lux/Asset/MeshRuntimeSerializer.cpp @@ -252,27 +252,9 @@ namespace Lux if (!meshSource->m_Indices.empty()) meshSource->m_IndexBuffer = IndexBuffer::Create(Buffer(meshSource->m_Indices.data(), (uint32_t)(meshSource->m_Indices.size() * sizeof(Index)))); - for (uint32_t i = 0; i < (uint32_t)meshSource->m_Submeshes.size(); i++) - { - const Submesh& submesh = meshSource->m_Submeshes[i]; - const uint32_t firstTriangle = submesh.BaseIndex / 3; - const uint32_t triangleCount = submesh.IndexCount / 3; - for (uint32_t triangle = 0; triangle < triangleCount; triangle++) - { - const uint32_t indexOffset = firstTriangle + triangle; - if (indexOffset >= meshSource->m_Indices.size()) - break; - - const Index& index = meshSource->m_Indices[indexOffset]; - const uint32_t v0 = submesh.BaseVertex + index.V1; - const uint32_t v1 = submesh.BaseVertex + index.V2; - const uint32_t v2 = submesh.BaseVertex + index.V3; - if (v0 >= meshSource->m_Vertices.size() || v1 >= meshSource->m_Vertices.size() || v2 >= meshSource->m_Vertices.size()) - continue; - - meshSource->m_TriangleCache[i].emplace_back(meshSource->m_Vertices[v0], meshSource->m_Vertices[v1], meshSource->m_Vertices[v2]); - } - } + // In the standalone runtime, drop the full CPU vertex array now that the + // GPU has it (positions + indices are kept for physics cooking). + meshSource->CompactCPUGeometry(); return meshSource; } diff --git a/Core/Source/Lux/Physics/JoltPhysics/JoltShapes.cpp b/Core/Source/Lux/Physics/JoltPhysics/JoltShapes.cpp index 258bf52c..88d096ab 100644 --- a/Core/Source/Lux/Physics/JoltPhysics/JoltShapes.cpp +++ b/Core/Source/Lux/Physics/JoltPhysics/JoltShapes.cpp @@ -52,7 +52,9 @@ namespace Lux { static void AppendSubmeshTriangles(const MeshSource& meshSource, const Submesh& submesh, const glm::vec3& scale, JPH::TriangleList& triangles) { - const auto& vertices = meshSource.GetVertices(); + // Position accessors work whether the mesh kept its full CPU vertex + // array (editor) or was compacted to positions-only (runtime). + const size_t vertexCount = meshSource.GetVertexCount(); const auto& indices = meshSource.GetIndices(); const uint32_t firstTriangle = submesh.BaseIndex / 3; const uint32_t triangleCount = submesh.IndexCount / 3; @@ -64,12 +66,12 @@ namespace Lux { const uint32_t i0 = submesh.BaseVertex + index.V1; const uint32_t i1 = submesh.BaseVertex + index.V2; const uint32_t i2 = submesh.BaseVertex + index.V3; - if (i0 >= vertices.size() || i1 >= vertices.size() || i2 >= vertices.size()) + if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) continue; - const glm::vec3 p0 = glm::vec3(submesh.Transform * glm::vec4(vertices[i0].Position, 1.0f)) * scale; - const glm::vec3 p1 = glm::vec3(submesh.Transform * glm::vec4(vertices[i1].Position, 1.0f)) * scale; - const glm::vec3 p2 = glm::vec3(submesh.Transform * glm::vec4(vertices[i2].Position, 1.0f)) * scale; + const glm::vec3 p0 = glm::vec3(submesh.Transform * glm::vec4(meshSource.GetVertexPosition(i0), 1.0f)) * scale; + const glm::vec3 p1 = glm::vec3(submesh.Transform * glm::vec4(meshSource.GetVertexPosition(i1), 1.0f)) * scale; + const glm::vec3 p2 = glm::vec3(submesh.Transform * glm::vec4(meshSource.GetVertexPosition(i2), 1.0f)) * scale; triangles.emplace_back(JoltUtils::ToJoltVector(p0), JoltUtils::ToJoltVector(p1), JoltUtils::ToJoltVector(p2)); } } diff --git a/Core/Source/Lux/Physics/PhysicsScene.cpp b/Core/Source/Lux/Physics/PhysicsScene.cpp index cd212e83..0a4dc687 100644 --- a/Core/Source/Lux/Physics/PhysicsScene.cpp +++ b/Core/Source/Lux/Physics/PhysicsScene.cpp @@ -354,9 +354,11 @@ namespace Lux { static void AppendMeshTrianglesFromSubmesh(const MeshSource& meshSource, const Submesh& submesh, const glm::vec3& scale, JPH::TriangleList& triangles) { - const auto& vertices = meshSource.GetVertices(); + // Position accessors work whether the mesh kept its full CPU vertex + // array (editor) or was compacted to positions-only (runtime). + const size_t vertexCount = meshSource.GetVertexCount(); const auto& indices = meshSource.GetIndices(); - if (vertices.empty() || indices.empty()) + if (vertexCount == 0 || indices.empty()) return; const uint32_t firstTriangle = submesh.BaseIndex / 3; @@ -369,12 +371,12 @@ namespace Lux { const uint32_t i0 = submesh.BaseVertex + index.V1; const uint32_t i1 = submesh.BaseVertex + index.V2; const uint32_t i2 = submesh.BaseVertex + index.V3; - if (i0 >= vertices.size() || i1 >= vertices.size() || i2 >= vertices.size()) + if (i0 >= vertexCount || i1 >= vertexCount || i2 >= vertexCount) continue; - const glm::vec3 p0 = glm::vec3(submesh.Transform * glm::vec4(vertices[i0].Position, 1.0f)) * scale; - const glm::vec3 p1 = glm::vec3(submesh.Transform * glm::vec4(vertices[i1].Position, 1.0f)) * scale; - const glm::vec3 p2 = glm::vec3(submesh.Transform * glm::vec4(vertices[i2].Position, 1.0f)) * scale; + const glm::vec3 p0 = glm::vec3(submesh.Transform * glm::vec4(meshSource.GetVertexPosition(i0), 1.0f)) * scale; + const glm::vec3 p1 = glm::vec3(submesh.Transform * glm::vec4(meshSource.GetVertexPosition(i1), 1.0f)) * scale; + const glm::vec3 p2 = glm::vec3(submesh.Transform * glm::vec4(meshSource.GetVertexPosition(i2), 1.0f)) * scale; triangles.push_back(JPH::Triangle(ToJoltVector(p0), ToJoltVector(p1), ToJoltVector(p2))); } diff --git a/Core/Source/Lux/Renderer/Mesh.cpp b/Core/Source/Lux/Renderer/Mesh.cpp index ac1e6057..a3e3b8b7 100644 --- a/Core/Source/Lux/Renderer/Mesh.cpp +++ b/Core/Source/Lux/Renderer/Mesh.cpp @@ -34,6 +34,23 @@ namespace Lux //////////////////////////////////////////////////////// // MeshSource ////////////////////////////////////////// //////////////////////////////////////////////////////// + + bool MeshSource::s_RetainFullCPUGeometry = true; + + void MeshSource::CompactCPUGeometry() + { + if (s_RetainFullCPUGeometry || m_Vertices.empty()) + return; + + // Keep positions (physics cooking) and indices (Jolt triangle lists); + // drop the full vertex array — the GPU already has its copy. + m_CollisionPositions.resize(m_Vertices.size()); + for (size_t i = 0; i < m_Vertices.size(); i++) + m_CollisionPositions[i] = m_Vertices[i].Position; + + std::vector().swap(m_Vertices); + } + MeshSource::MeshSource(const std::vector& vertices, const std::vector& indices, const glm::mat4& transform) : m_Vertices(vertices), m_Indices(indices) { @@ -65,6 +82,8 @@ namespace Lux m_BoundingBox.Max.y = glm::max(vertex.Position.y, m_BoundingBox.Max.y); m_BoundingBox.Max.z = glm::max(vertex.Position.z, m_BoundingBox.Max.z); } + + CompactCPUGeometry(); } MeshSource::MeshSource(const std::vector& vertices, const std::vector& indices, const std::vector& submeshes) @@ -89,6 +108,8 @@ namespace Lux m_BoundingBox.Max.y = glm::max(vertex.Position.y, m_BoundingBox.Max.y); m_BoundingBox.Max.z = glm::max(vertex.Position.z, m_BoundingBox.Max.z); } + + CompactCPUGeometry(); } MeshSource::~MeshSource() diff --git a/Core/Source/Lux/Renderer/Mesh.h b/Core/Source/Lux/Renderer/Mesh.h index 39dedde6..b3b498f8 100644 --- a/Core/Source/Lux/Renderer/Mesh.h +++ b/Core/Source/Lux/Renderer/Mesh.h @@ -212,6 +212,19 @@ namespace Lux { const std::vector& GetVertices() const { return m_Vertices; } const std::vector& GetIndices() const { return m_Indices; } + // CPU-side position access that works whether or not the full vertex + // array was compacted away (the standalone runtime keeps positions only — + // physics cooking is the sole CPU consumer and reads positions + indices). + size_t GetVertexCount() const { return m_Vertices.empty() ? m_CollisionPositions.size() : m_Vertices.size(); } + glm::vec3 GetVertexPosition(size_t index) const { return m_Vertices.empty() ? m_CollisionPositions[index] : m_Vertices[index].Position; } + + // When retention is disabled (set once at startup by the standalone + // runtime), CompactCPUGeometry frees the full CPU vertex array after GPU + // upload, keeping positions + indices for physics. The editor retains + // everything (mesh export/serialization reads the full vertices). + static void SetRetainFullCPUGeometry(bool retain) { s_RetainFullCPUGeometry = retain; } + void CompactCPUGeometry(); + //bool HasSkeleton() const { return (bool)m_Skeleton; } //bool IsSubmeshRigged(uint32_t submeshIndex) const { return m_Submeshes[submeshIndex].IsRigged; } //const Skeleton* GetSkeleton() const { return m_Skeleton.get(); } @@ -226,8 +239,6 @@ namespace Lux { const std::vector& GetMaterials() const { return m_Materials; } const std::string& GetFilePath() const { return m_FilePath; } - const std::vector GetTriangleCache(uint32_t index) const { return m_TriangleCache.at(index); } - Ref GetVertexBuffer() { return m_VertexBuffer; } Ref GetBoneInfluenceBuffer() { return m_BoneInfluenceBuffer; } Ref GetIndexBuffer() { return m_IndexBuffer; } @@ -250,6 +261,11 @@ namespace Lux { std::vector m_Vertices; std::vector m_Indices; + // Positions-only fallback populated by CompactCPUGeometry when m_Vertices + // is released (runtime); read via GetVertexPosition/GetVertexCount. + std::vector m_CollisionPositions; + static bool s_RetainFullCPUGeometry; + //std::vector m_BoneInfluences; //std::vector m_BoneInfo; //mutable Scope m_Skeleton; @@ -258,8 +274,6 @@ namespace Lux { std::vector m_Materials; - std::unordered_map> m_TriangleCache; - AABB m_BoundingBox; std::string m_FilePath; diff --git a/Lux-Runtime/src/RuntimeApplication.cpp b/Lux-Runtime/src/RuntimeApplication.cpp index 7e94e3fa..065728ae 100644 --- a/Lux-Runtime/src/RuntimeApplication.cpp +++ b/Lux-Runtime/src/RuntimeApplication.cpp @@ -1,6 +1,7 @@ #include "RuntimeLayer.h" #include "Lux/EntryPoint.h" +#include "Lux/Renderer/Mesh.h" #include "Lux/Utilities/CommandLineParser.h" #include "Lux/Utilities/FileSystem.h" #include "Lux/Core/ApplicationSettings.h" @@ -89,6 +90,10 @@ namespace Lux : Application(specification), m_ProjectPath(std::move(projectPath)) { s_IsRuntime = true; + + // The runtime never exports/serializes meshes, so meshes keep only + // positions + indices on the CPU (physics cooking) after GPU upload. + MeshSource::SetRetainFullCPUGeometry(false); } void OnInit() override From 3f1982cb408a48173dfcf2253221caeab50fc0a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:36:10 +0000 Subject: [PATCH 28/63] Skip editor-only render targets in the standalone runtime (~180 MB) SelectedGeometry (RGBA32F + depth), the three JumpFlood outline targets (RGBA32F), AO-Debug (RGBA32F), GBufferDebug (RGBA16F), and the wireframe target were always allocated at full viewport size even in shipped games, where selection and debug views cannot occur. Add SceneRendererSpecification::EnableEditorRenderTargets (default true; the runtime sets it false) gating their creation. All references null-guard: graph registrations skip absent passes, the resize block and pass bodies check first, and the resize/repair/stats helpers already guarded. Editor behavior is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 104 ++++++++++++++------- Core/Source/Lux/Renderer/SceneRenderer.h | 6 ++ Lux-Runtime/src/RuntimeLayer.cpp | 2 + 3 files changed, 79 insertions(+), 33 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index c105631c..615adef2 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -1347,27 +1347,31 @@ namespace Lux { m_DeferredLightingPass->Bake(); m_DeferredLightingMaterial = Material::Create(deferredPipelineSpec.Shader, "DeferredLighting"); - FramebufferSpecification debugSpec; - debugSpec.Width = m_ViewportWidth; - debugSpec.Height = m_ViewportHeight; - debugSpec.Attachments = { ImageFormat::RGBA16F }; - debugSpec.ClearColor = { 0.0f, 0.0f, 0.0f, 1.0f }; - debugSpec.DebugName = "GBufferDebug"; - - PipelineSpecification debugPipelineSpec = deferredPipelineSpec; - debugPipelineSpec.DebugName = "GBufferDebug"; - debugPipelineSpec.Shader = Renderer::GetShaderLibrary()->Get("GBufferDebug"); - debugPipelineSpec.TargetFramebuffer = Framebuffer::Create(debugSpec); - - rpSpec.DebugName = "GBufferDebugPass"; - rpSpec.Pipeline = Pipeline::Create(debugPipelineSpec); - m_GBufferDebugPass = RenderPass::Create(rpSpec); - BindSceneRenderPassInputs(m_GBufferDebugPass, PassInputCommonScene | PassInputGBuffer | PassInputMaterialScene); - m_GBufferDebugPass->SetInput("r_PointSampler", Renderer::GetPointSampler()); - LUX_CORE_VERIFY(m_GBufferDebugPass->Validate()); - m_GBufferDebugPass->Bake(); - m_GBufferDebugMaterial = Material::Create(debugPipelineSpec.Shader, "GBufferDebug"); - m_GBufferDebugMaterial->Set("u_Uniforms.Mode", 0u); + // Editor-only debug view target — not created in the standalone runtime. + if (m_Specification.EnableEditorRenderTargets) + { + FramebufferSpecification debugSpec; + debugSpec.Width = m_ViewportWidth; + debugSpec.Height = m_ViewportHeight; + debugSpec.Attachments = { ImageFormat::RGBA16F }; + debugSpec.ClearColor = { 0.0f, 0.0f, 0.0f, 1.0f }; + debugSpec.DebugName = "GBufferDebug"; + + PipelineSpecification debugPipelineSpec = deferredPipelineSpec; + debugPipelineSpec.DebugName = "GBufferDebug"; + debugPipelineSpec.Shader = Renderer::GetShaderLibrary()->Get("GBufferDebug"); + debugPipelineSpec.TargetFramebuffer = Framebuffer::Create(debugSpec); + + rpSpec.DebugName = "GBufferDebugPass"; + rpSpec.Pipeline = Pipeline::Create(debugPipelineSpec); + m_GBufferDebugPass = RenderPass::Create(rpSpec); + BindSceneRenderPassInputs(m_GBufferDebugPass, PassInputCommonScene | PassInputGBuffer | PassInputMaterialScene); + m_GBufferDebugPass->SetInput("r_PointSampler", Renderer::GetPointSampler()); + LUX_CORE_VERIFY(m_GBufferDebugPass->Validate()); + m_GBufferDebugPass->Bake(); + m_GBufferDebugMaterial = Material::Create(debugPipelineSpec.Shader, "GBufferDebug"); + m_GBufferDebugMaterial->Set("u_Uniforms.Mode", 0u); + } } // ── GTAO + AO composite ─────────────────────────────────────────────── @@ -1489,6 +1493,9 @@ namespace Lux { m_AOCompositePass->Bake(); m_AOCompositeMaterial = Material::Create(aoPipelineSpec.Shader, "GTAO-Composite"); + // Editor-only AO debug view target — not created in the standalone runtime. + if (m_Specification.EnableEditorRenderTargets) + { FramebufferSpecification aoDebugFramebufferSpec; aoDebugFramebufferSpec.Width = m_ViewportWidth; aoDebugFramebufferSpec.Height = m_ViewportHeight; @@ -1514,6 +1521,7 @@ namespace Lux { LUX_CORE_VERIFY(m_AODebugPass->Validate()); m_AODebugPass->Bake(); m_AODebugMaterial = Material::Create(aoPipelineSpec.Shader, "AO-Debug"); + } } // ── SSR ──────────────────────────────────────────────────────────────── @@ -1601,6 +1609,8 @@ namespace Lux { } // ── Selected geometry (isolation for outline) ───────────────────────── + // Editor-only (selection outline) — not created in the standalone runtime. + if (m_Specification.EnableEditorRenderTargets) { FramebufferTextureSpecification selectedMaskAttachment = ImageFormat::RGBA32F; selectedMaskAttachment.Blend = false; @@ -1634,6 +1644,9 @@ namespace Lux { } // ── Jump flood outline buffers ──────────────────────────────────────── + // Editor-only (selection outline; 3 full-viewport RGBA32F targets) — not + // created in the standalone runtime. + if (m_Specification.EnableEditorRenderTargets) { FramebufferTextureSpecification jumpFloodAttachment = ImageFormat::RGBA32F; jumpFloodAttachment.Blend = false; @@ -1689,6 +1702,9 @@ namespace Lux { } // ── Wireframe pass (on top of geometry, for selected meshes) ────────── + // Editor-only (selection wireframe / collider view) — not created in the + // standalone runtime. + if (m_Specification.EnableEditorRenderTargets) { FramebufferSpecification fbSpec; fbSpec.Width = m_ViewportWidth; @@ -2104,6 +2120,10 @@ namespace Lux { m_DOFPass->Bake(); m_DOFMaterial = Material::Create(dofPipelineSpec.Shader, "DepthOfField"); + // Editor-only (selection outline composite) — not created in the + // standalone runtime. References m_JumpFloodPasses, gated by the same flag. + if (m_Specification.EnableEditorRenderTargets) + { FramebufferSpecification jfCompositeFBSpec; jfCompositeFBSpec.Width = m_ViewportWidth; jfCompositeFBSpec.Height = m_ViewportHeight; @@ -2138,6 +2158,7 @@ namespace Lux { LUX_CORE_VERIFY(m_JumpFloodCompositePass->Validate()); m_JumpFloodCompositePass->Bake(); m_JumpFloodCompositeMaterial = Material::Create(jfCompositePipelineSpec.Shader, "JumpFlood-Composite"); + } } // ── Editor grid (renders into composite output, preserves depth) ────── @@ -3527,8 +3548,12 @@ namespace Lux { sceneColorCurrent = skyAtmosphereOutputs; } - std::vector selectedOutputs = addRenderPassResources("SelectedGeometry", m_SelectedGeometryPass); - addPass("Selected Geometry", preDepthOutputs, selectedOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::SelectedGeometryPass)); + std::vector selectedOutputs; + if (m_SelectedGeometryPass) + { + selectedOutputs = addRenderPassResources("SelectedGeometry", m_SelectedGeometryPass); + addPass("Selected Geometry", preDepthOutputs, selectedOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::SelectedGeometryPass)); + } std::vector geometryOutputs = gbufferOutputs; appendResources(geometryOutputs, sceneColorCurrent); @@ -3548,7 +3573,7 @@ namespace Lux { appendResources(geometryOutputs, sceneColorCurrent); } - if (UsesGBufferDebugPass(m_DebugViewMode)) + if (UsesGBufferDebugPass(m_DebugViewMode) && m_GBufferDebugPass) { std::vector debugReads = gbufferOutputs; appendResources(debugReads, sceneColorCurrent); @@ -3585,7 +3610,7 @@ namespace Lux { addPass("AO Composite", aoCompositeReads, aoCompositeOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::AOComposite)); sceneColorCurrent = aoCompositeOutputs; - if (m_DebugViewMode == DebugViewMode::AO) + if (m_DebugViewMode == DebugViewMode::AO && m_AODebugPass) addPass("AO Debug", aoCompositeReads, addRenderPassResources("AO Debug", m_AODebugPass), RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::AODebugPass)); } @@ -3664,14 +3689,17 @@ namespace Lux { addPass("Transparent Forward", transparentReads, transparentOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::TransparentForwardPass)); sceneColorCurrent = transparentOutputs; - std::vector wireframeReads = sceneColorCurrent; - std::vector wireframeOutputs = addRenderPassResources("Geometry Wireframe", m_GeometryWireframePass); - addPass("Geometry Wireframe", wireframeReads, wireframeOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GeometryWireframePass)); - sceneColorCurrent = wireframeOutputs; + if (m_GeometryWireframePass) + { + std::vector wireframeReads = sceneColorCurrent; + std::vector wireframeOutputs = addRenderPassResources("Geometry Wireframe", m_GeometryWireframePass); + addPass("Geometry Wireframe", wireframeReads, wireframeOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GeometryWireframePass)); + sceneColorCurrent = wireframeOutputs; + } std::vector jumpFloodAOutputs; std::vector jumpFloodBOutputs; - const bool jumpFloodActive = m_Options.EnableJumpFlood && (executable ? !GetMeshPass(MeshPassType::SelectedMask).DrawList.empty() : true); + const bool jumpFloodActive = m_Options.EnableJumpFlood && m_JumpFloodInitPass && (executable ? !GetMeshPass(MeshPassType::SelectedMask).DrawList.empty() : true); if (jumpFloodActive) { std::vector jumpFloodInitOutputs = addRenderPassResources("JumpFlood Init", m_JumpFloodInitPass); @@ -4318,13 +4346,17 @@ namespace Lux { m_GeometryPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_GeometryPassTransparent->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_DeferredLightingPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); - m_GBufferDebugPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); + // Editor-only passes may not exist (EnableEditorRenderTargets=false). + if (m_GBufferDebugPass) + m_GBufferDebugPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_SkyboxPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_SkyAtmospherePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_VolumetricCloudCompositePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_AtmosphericFogPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); - m_SelectedGeometryPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); - m_GeometryWireframePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); + if (m_SelectedGeometryPass) + m_SelectedGeometryPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); + if (m_GeometryWireframePass) + m_GeometryWireframePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_CompositingFramebuffer->Resize(m_ViewportWidth, m_ViewportHeight); m_CompositePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_GridRenderPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); @@ -6922,6 +6954,9 @@ namespace Lux { void SceneRenderer::SelectedGeometryPass() { ScopedCPUProfile cpuProfile(*this, "SelectedGeometryPass"); + if (!m_SelectedGeometryPass) // editor-only target not created (runtime) + return; + const MeshPassState& selectedPass = GetMeshPass(MeshPassType::SelectedMask); if (selectedPass.DrawList.empty()) return; @@ -7028,6 +7063,9 @@ namespace Lux { void SceneRenderer::GeometryWireframePass() { ScopedCPUProfile cpuProfile(*this, "GeometryWireframePass"); + if (!m_GeometryWireframePass) // editor-only target not created (runtime) + return; + const MeshPassState& wireframePass = GetMeshPass(MeshPassType::Wireframe); const MeshPassState& colliderPass = GetMeshPass(MeshPassType::PhysicsCollider); if ((!m_Options.ShowSelectedInWireframe || wireframePass.DrawList.empty()) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 6bd6b30c..2d8ac9dc 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -282,6 +282,12 @@ namespace Lux { uint32_t ViewportWidth = 0; // 0 = use window size uint32_t ViewportHeight = 0; Tiering::Renderer::RendererTieringSettings Tiering; + + // Editor-only render targets (selection outline, wireframe, AO/GBuffer + // debug views) cost ~180 MB of full-viewport images. The standalone + // runtime sets this false so they are never created; their passes + // null-guard and never execute there. + bool EnableEditorRenderTargets = true; }; // ───────────────────────────────────────────────────────────────────────── diff --git a/Lux-Runtime/src/RuntimeLayer.cpp b/Lux-Runtime/src/RuntimeLayer.cpp index 9463e2f1..b156b356 100644 --- a/Lux-Runtime/src/RuntimeLayer.cpp +++ b/Lux-Runtime/src/RuntimeLayer.cpp @@ -50,6 +50,8 @@ namespace Lux SceneRendererSpecification rendererSpec; rendererSpec.ViewportWidth = Application::Get().GetWindow().GetWidth(); rendererSpec.ViewportHeight = Application::Get().GetWindow().GetHeight(); + // No selection/debug views in the shipped game — skip their render targets. + rendererSpec.EnableEditorRenderTargets = false; m_SceneRenderer = Ref::Create(m_RuntimeScene, rendererSpec); m_SceneRenderer->ApplyProjectSettings(m_RuntimeProject->GetConfig().SceneRenderer); From 04421b4c1471342319928cba213e1b50d0c69162 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:37:06 +0000 Subject: [PATCH 29/63] Docs: record memory-diet results and the aliasing-coverage verdict Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- docs/ENGINE_OPTIMIZATION_PLAN.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index 223a1811..e56f0fa3 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -106,6 +106,30 @@ silent afterwards. which the existing null-guards handle (mesh appears a frame later). A per-mesh ready flag is the polish item if the one-frame pop-in ever bothers. +**Phase 4 progress — memory diet (2026-07-04):** + +1. **Editor render targets skipped in runtime (~180 MB)** — + `SceneRendererSpecification::EnableEditorRenderTargets` (runtime sets false) gates + SelectedGeometry, JumpFlood ×3, AO-Debug, GBufferDebug, wireframe target creation; all + references null-guard. *Verify:* runtime VRAM drop; editor selection/debug views + unchanged. +2. **Mesh CPU memory** — removed `m_TriangleCache` entirely (3 full Vertex structs per + triangle, built by importer + runtime deserializer, **zero consumers**); the runtime + additionally compacts every MeshSource after GPU upload to positions + indices + (physics cooking is the only CPU consumer and reads exactly that). Editor retains full + data for export. *Verify:* runtime RAM drop; mesh colliders (incl. spawned at runtime) + identical; editor mesh import/export identical. +3. **Truthful memory HUD** — `VulkanAllocator::GetStats` now reports device-local + usage/budget from `VK_EXT_memory_budget` (driver-truth incl. NVRHI's allocations) + instead of a dead VMA-side tracker that showed ~0. *Verify:* HUD ≈ GPU-Z dedicated + VRAM numbers. +4. **Aliasing coverage of big targets — investigated, REJECTED:** the exclusions in + `IsRenderGraphAliasCandidate` are correctness, not oversight. GTAO/SSR/Cloud history + buffers persist across frames (temporal accumulation reads last frame's result) and + can never be transient; PreDepth/GBuffer/SceneColor are read throughout the frame + (SSR, GTAO, debug views, TAA), leaving no dead window to alias into. Do not extend + aliasing to these. + **Phase 4 candidates (audit findings that need build/measure or shader edits — do with Tracy + validation on):** @@ -120,16 +144,12 @@ Tracy + validation on):** - Format diets needing shader edits: Bloom + PreConvolution pyramids RGBA32F→RGBA16F; JumpFlood RGBA32F→RGBA16F (see A8 note); GBuffer normal → octahedral RG16F; merge the two R32UI id targets. -- Editor-target lazy creation (SelectedGeometry / AO-Debug / GBufferDebug ≈ 80 MB - always resident; JumpFlood ≈ 100 MB). - Cluster grid caching on resize/projection change (currently rebuilt per frame while lights exist). - Empty-pass graph gating (Transparent/Selected/Wireframe still open + clear render passes when their draw lists are empty) — interacts with render-target aliasing. - Composite-chain merging: Skybox→Deferred→AO→SSR→Cloud→Fog→Composite→DOF each do a full-res scene-color read-modify-write; several are mergeable. -- Memory HUD reads a vestigial VMA tracker (`VulkanAllocator.cpp`) — live allocations go - through NVRHI; re-source the stats. - Correctness/sync audit (thread handoff, upload races, barrier semantics) — still pending; the audit session for it was cut short. From 7462019e65afba3e4ebf691bf56481a7467cd3db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:45:32 +0000 Subject: [PATCH 30/63] Trim small always-on per-frame CPU costs - Spot-shadow candidate scoring, sorting, atlas sizing, and per-light matrix building now skip entirely with zero spot lights (shaders read Count=0; the state hash resets so returning lights re-render). - The directional shadow UBO uploads only when the cascade matrices actually changed (memcmp gate), once per frame-in-flight buffer, then idles - it previously uploaded every frame while being mutated only on cascade recompute. - The UpdateStatistics draw-list re-walk compiles out of Dist builds; the functional tail (dynamic render resolution) stays in all builds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 42 ++++++++++++++++++---- Core/Source/Lux/Renderer/SceneRenderer.h | 6 ++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 615adef2..86cd4780 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -4768,6 +4768,10 @@ namespace Lux { uint32_t ResolutionTier = 0; }; + // Candidate scoring/sorting and atlas sizing only matter when spot + // lights exist; with none, skip straight to the (16-byte) UBO upload. + if (m_SpotLightsUB.Count > 0) + { std::vector shadowCandidates; shadowCandidates.reserve(m_SpotLightsUB.Count); @@ -4900,6 +4904,14 @@ namespace Lux { if (!m_SpotShadowMapCacheValid || spotShadowStateHash != m_LastSpotShadowStateHash) m_SpotShadowMapNeedsRender = true; m_LastSpotShadowStateHash = spotShadowStateHash; + } + else + { + // No spot lights: shaders read Count=0; reset the state hash so + // lights reappearing always retrigger a shadow render. + m_SpotShadowUB.Count = 0; + m_LastSpotShadowStateHash = 0; + } auto slData = m_SpotLightsUB; uint32_t slSize = (uint32_t)(16ull + sizeof(SpotLight) * slData.Count); @@ -5041,12 +5053,25 @@ namespace Lux { m_RendererDataUB.CascadeSplits = glm::vec4(-1000000.0f); } - auto shadowData = m_ShadowUB; - Ref instance = this; - Renderer::Submit([instance, shadowData]() mutable { - instance->m_UBSShadow->RT_Get()->RT_SetData( - instance->m_UploadCommandBuffer, &shadowData, sizeof(UBShadow)); - }); + // Upload only when the cascade matrices actually changed, then once per + // frame-in-flight buffer so every copy converges before going idle. + if (std::memcmp(&m_ShadowUB, &m_LastUploadedShadowUB, sizeof(UBShadow)) != 0) + { + m_LastUploadedShadowUB = m_ShadowUB; + m_ShadowUBUploadsRemaining = Renderer::GetConfig().FramesInFlight; + } + + if (m_ShadowUBUploadsRemaining > 0) + { + m_ShadowUBUploadsRemaining--; + + auto shadowData = m_ShadowUB; + Ref instance = this; + Renderer::Submit([instance, shadowData]() mutable { + instance->m_UBSShadow->RT_Get()->RT_SetData( + instance->m_UploadCommandBuffer, &shadowData, sizeof(UBShadow)); + }); + } } // ── Renderer data uniform buffer ────────────────────────────────────── @@ -7888,6 +7913,10 @@ namespace Lux { m_Statistics.SpotlightShadowcasters = 0; m_Statistics.SpotlightShadowsCulled = m_FrameCullingStats.ShadowCulledInstances; +#ifndef LUX_DIST + // Stats-panel counters only: re-walking every draw list has no consumer + // in shipping builds. (UpdateDynamicRenderResolution below is functional + // and stays in all builds.) auto accumulate = [this](const DrawCommandList& drawList, const DrawCommandOrder& drawOrder) { for (const MeshKey& key : drawOrder) @@ -7938,6 +7967,7 @@ namespace Lux { m_Statistics.CulledInstances = lateCulledInstances + m_Statistics.FrustumCulledInstances + m_Statistics.OcclusionCulledInstances; m_Statistics.SpotlightShadowcasters = m_SpotShadowCount; +#endif const uint32_t frameIndex = Renderer::GetCurrentFrameIndex(); m_Statistics.TotalGPUTime = m_CommandBuffer->GetExecutionGPUTime(frameIndex); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 2d8ac9dc..451e2d07 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -1079,6 +1079,12 @@ namespace Lux { glm::mat4 ViewProjection[ShadowCascadeCount]; } m_ShadowUB; + // Shadow UBO upload gate: last uploaded contents + how many more uploads + // remain (one per frame-in-flight buffer after a change; starts above any + // realistic frames-in-flight count so startup initializes every buffer). + UBShadow m_LastUploadedShadowUB{}; + uint32_t m_ShadowUBUploadsRemaining = 8; + struct UBSpotShadow { glm::mat4 ViewProjection[MaxSpotShadows]; From 00c87534c390dc76ecc80b16b7b9bc7009409f61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:49:11 +0000 Subject: [PATCH 31/63] Upload only dirty GPUScene rows instead of the full instance array The persistent GPUScene instance array (~144 B x instances) was copied and re-uploaded in full every frame, per the old comment, because the storage buffer set owns one buffer per frame in flight. GPUScene already tracks per-sync dirty ranges; each sync's ranges now replay once per frame-in-flight buffer (row data re-read fresh each time, so every buffer converges to latest). Full uploads still run on scene switch, instance-count growth (high-water tracked, which also covers the only paths that can trigger a buffer resize), and adaptively when the pending dirty volume would exceed a full array anyway. The debugger snapshot now validates against the GPUScene source array since the full-copy vector is empty on range-only frames. Static scenes drop to zero persistent-row upload; dynamic scenes upload in proportion to what actually moved. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 110 +++++++++++++++++++-- Core/Source/Lux/Renderer/SceneRenderer.h | 15 +++ 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 86cd4780..0ed2269e 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -6028,9 +6028,85 @@ namespace Lux { // ── 2. Upload GPUScene tails, ObjectIndexes, culling data, and indirect args m_UploadCommandBuffer->Begin(); + // Persistent GPUScene rows: upload only the rows dirtied by recent syncs + // instead of the full instance array every frame. A dirty range must be + // written once per frame-in-flight buffer, so each sync's ranges are + // queued as an "epoch" replayed FramesInFlight times with fresh row data + // (newer content in an older slot is still correct — everything converges + // to latest). Full uploads run on scene switch / instance-count change / + // buffer growth (high-water tracked, so a mid-steady-state Resize is + // impossible), and adaptively when the dirty volume exceeds a full array. std::vector gpuSceneInstanceData; + std::vector gpuSceneRangeList; + std::vector gpuSceneRangeRows; if (submittedGPUScene) - gpuSceneInstanceData = submittedGPUScene->GetInstances(); + { + const std::vector& instances = submittedGPUScene->GetInstances(); + + const uint32_t totalInstancesThisFrame = persistentGPUSceneInstanceCount + (uint32_t)m_TransientGPUSceneInstances.size(); + const bool sceneChanged = (const void*)submittedGPUScene != m_LastGPUSceneKey + || persistentGPUSceneInstanceCount != m_LastGPUSceneInstanceCount + || totalInstancesThisFrame > m_GPUSceneMaxTotalInstancesSeen; + if (sceneChanged) + { + m_GPUSceneFullUploadsRemaining = glm::max(m_GPUSceneFullUploadsRemaining, Renderer::GetConfig().FramesInFlight); + m_LastGPUSceneKey = (const void*)submittedGPUScene; + m_LastGPUSceneInstanceCount = persistentGPUSceneInstanceCount; + m_GPUSceneMaxTotalInstancesSeen = glm::max(m_GPUSceneMaxTotalInstancesSeen, totalInstancesThisFrame); + m_PendingGPUSceneRangeUploads.clear(); + } + + if (m_GPUSceneFullUploadsRemaining == 0) + { + const std::vector& dirtyRanges = submittedGPUScene->GetDirtyRanges(); + if (!dirtyRanges.empty()) + { + GPUSceneRangeUploadEpoch& epoch = m_PendingGPUSceneRangeUploads.emplace_back(); + epoch.RemainingUploads = Renderer::GetConfig().FramesInFlight; + epoch.Ranges.assign(dirtyRanges.begin(), dirtyRanges.end()); + } + + // Flatten all pending epochs into one range list + row payload + // (rows re-copied from the current arrays: freshest data wins). + size_t pendingRows = 0; + for (const GPUSceneRangeUploadEpoch& epoch : m_PendingGPUSceneRangeUploads) + for (const GPUSceneDirtyRange& range : epoch.Ranges) + pendingRows += range.InstanceCount; + + if (pendingRows >= instances.size() && !instances.empty()) + { + // Cheaper to re-upload everything. + m_GPUSceneFullUploadsRemaining = Renderer::GetConfig().FramesInFlight; + m_PendingGPUSceneRangeUploads.clear(); + } + else if (pendingRows > 0) + { + gpuSceneRangeRows.reserve(pendingRows); + for (GPUSceneRangeUploadEpoch& epoch : m_PendingGPUSceneRangeUploads) + { + for (const GPUSceneDirtyRange& range : epoch.Ranges) + { + const uint32_t first = glm::min(range.FirstInstance, (uint32_t)instances.size()); + const uint32_t count = glm::min(range.InstanceCount, (uint32_t)instances.size() - first); + if (count == 0) + continue; + + gpuSceneRangeList.push_back({ first, count }); + gpuSceneRangeRows.insert(gpuSceneRangeRows.end(), instances.begin() + first, instances.begin() + first + count); + } + epoch.RemainingUploads--; + } + std::erase_if(m_PendingGPUSceneRangeUploads, [](const GPUSceneRangeUploadEpoch& epoch) { return epoch.RemainingUploads == 0; }); + } + } + + if (m_GPUSceneFullUploadsRemaining > 0) + { + m_GPUSceneFullUploadsRemaining--; + m_PendingGPUSceneRangeUploads.clear(); + gpuSceneInstanceData = instances; + } + } std::vector transientGPUSceneData = m_TransientGPUSceneInstances; for (uint32_t transientIndex = 0; transientIndex < transientGPUSceneData.size(); transientIndex++) @@ -6141,8 +6217,13 @@ namespace Lux { snapshot.MissingPersistentObjectIDCount++; }; - for (uint32_t instanceIndex = 0; instanceIndex < gpuSceneInstanceData.size(); instanceIndex++) - validateInstance(gpuSceneInstanceData[instanceIndex], instanceIndex, true); + // Validate against the GPUScene source array directly — the local + // full-copy vector is empty on dirty-range-only frames. + if (persistentGPUSceneInstances) + { + for (uint32_t instanceIndex = 0; instanceIndex < (uint32_t)persistentGPUSceneInstances->size(); instanceIndex++) + validateInstance((*persistentGPUSceneInstances)[instanceIndex], instanceIndex, true); + } for (uint32_t transientIndex = 0; transientIndex < transientGPUSceneData.size(); transientIndex++) validateInstance(transientGPUSceneData[transientIndex], persistentGPUSceneInstanceCount + transientIndex, false); @@ -6209,6 +6290,7 @@ namespace Lux { || !meshCullDrawData.empty() || !indirectDrawData.empty() || !gpuSceneInstanceData.empty() + || !gpuSceneRangeRows.empty() || !transientGPUSceneData.empty() || !gpuMaterialData.empty() || !transientGPUMaterialData.empty()) @@ -6227,6 +6309,8 @@ namespace Lux { cullDrawData = meshCullDrawData, indirectCommands = indirectDrawData, gpuSceneData = std::move(gpuSceneInstanceData), + sceneRangeList = std::move(gpuSceneRangeList), + sceneRangeRows = std::move(gpuSceneRangeRows), transientSceneData = std::move(transientGPUSceneData), materialData = gpuMaterialData, transientMaterialData = transientGPUMaterialData, @@ -6276,15 +6360,29 @@ namespace Lux { instance->m_SBSGPUSceneInstances->Resize(gpuSceneBytes * 2u); } - // StorageBufferSet owns one buffer per frame-in-flight. Until GPUScene tracks - // dirty ranges per frame buffer, upload the persistent scene rows for the - // current frame to avoid alternating stale GPUScene data. + // Full persistent upload (startup / scene switch / count change / + // dirty volume exceeding a full array), repeated once per + // frame-in-flight buffer by the main-thread counter. if (!gpuSceneData.empty()) { const uint32_t uploadBytes = (uint32_t)(gpuSceneData.size() * sizeof(GPUSceneInstanceData)); instance->m_SBSGPUSceneInstances->RT_Get()->RT_SetData(cmd, gpuSceneData.data(), uploadBytes); } + // Steady state: write only the dirty ranges (flattened epoch + // payload; each sync's ranges replay once per frame in flight). + if (!sceneRangeRows.empty()) + { + size_t sourceRow = 0; + for (const GPUSceneDirtyRange& range : sceneRangeList) + { + const uint32_t uploadBytes = range.InstanceCount * (uint32_t)sizeof(GPUSceneInstanceData); + const uint32_t uploadOffset = range.FirstInstance * (uint32_t)sizeof(GPUSceneInstanceData); + instance->m_SBSGPUSceneInstances->RT_Get()->RT_SetData(cmd, sceneRangeRows.data() + sourceRow, uploadBytes, uploadOffset); + sourceRow += range.InstanceCount; + } + } + if (!transientSceneData.empty()) { const uint32_t uploadBytes = (uint32_t)(transientSceneData.size() * sizeof(GPUSceneInstanceData)); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 451e2d07..08521e32 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -1531,6 +1531,21 @@ namespace Lux { // Never touch this from the main thread. std::vector m_RTPushConstantScratch; + // Dirty-range GPUScene uploads: each sync's dirty ranges replay once per + // frame-in-flight buffer ("epochs"); full uploads run on scene switch / + // instance-count growth (high-water tracked, which also covers buffer + // resizes) and start above any realistic frames-in-flight count. + struct GPUSceneRangeUploadEpoch + { + std::vector Ranges; + uint32_t RemainingUploads = 0; + }; + std::vector m_PendingGPUSceneRangeUploads; + uint32_t m_GPUSceneFullUploadsRemaining = 8; + const void* m_LastGPUSceneKey = nullptr; + uint32_t m_LastGPUSceneInstanceCount = 0; + uint32_t m_GPUSceneMaxTotalInstancesSeen = 0; + // Change tracking for the texture/material table scratches above: the copy // from the submitted scene is skipped when the same scene instance is // submitted with an unchanged version. Version sentinels start at max so From 5c3f5044e53142cec77a8e190f85dc694179cccb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:50:40 +0000 Subject: [PATCH 32/63] Bindless texture resolve: retry only pending slots in steady state The resolve loop did an asset-manager lookup for all 1024 bindless slots every frame, kept deliberately brute-force so slots flip from the white fallback to the real texture when async streaming completes. Steady state now re-resolves only the slots still waiting on a texture (pending list), the per-frame transient region, and runs the full sweep on table version changes plus every 32 frames as a hot-reload safety net (asset reloads swap contents without touching the table version). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 47 +++++++++++++++++++++- Core/Source/Lux/Renderer/SceneRenderer.h | 8 ++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 0ed2269e..8f6ab98b 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -5836,19 +5836,37 @@ namespace Lux { return texture; }; + // Resolving all bindless slots costs an asset-manager lookup per slot per + // frame. Steady state re-resolves only: slots still waiting on a + // streaming texture (pending list), the per-frame transient region, and + // a periodic full sweep as a safety net for asset hot-reloads that swap + // a texture's contents without touching the table version. + constexpr uint32_t TextureResolveSweepInterval = 32; + const bool fullTextureResolve = textureTableChanged || m_TextureResolveSweepCountdown == 0; + if (fullTextureResolve) + m_TextureResolveSweepCountdown = TextureResolveSweepInterval; + else + m_TextureResolveSweepCountdown--; + uint32_t missingTextureDescriptorCount = 0; if (m_GPUMaterialTextures.empty()) m_GPUMaterialTextures.assign(MaxGPUTextureSceneTextures, Renderer::GetWhiteTexture()); - for (uint32_t textureIndex = 0; textureIndex < MaxGPUTextureSceneTextures; textureIndex++) + m_PendingTextureResolveScratch.swap(m_PendingTextureResolveSlots); + m_PendingTextureResolveSlots.clear(); + + auto resolveSlot = [&](uint32_t textureIndex) { const AssetHandle textureHandle = textureIndex < gpuTextureHandles.size() ? gpuTextureHandles[textureIndex] : AssetHandle(0); Ref texture = resolveMaterialTexture(textureHandle); if (textureHandle && texture.Raw() == Renderer::GetWhiteTexture().Raw()) + { missingTextureDescriptorCount++; + m_PendingTextureResolveSlots.push_back(textureIndex); // still streaming — retry next frame + } if (m_GPUMaterialTextures[textureIndex].Raw() == texture.Raw()) - continue; + return; m_GPUMaterialTextures[textureIndex] = texture; if (m_GeometryPass && m_GeometryPass->IsInputValid("u_GPUMaterialTextures")) @@ -5859,6 +5877,31 @@ namespace Lux { m_DeferredLightingPass->SetInput("u_GPUMaterialTextures", texture, textureIndex); if (m_GBufferDebugPass && m_GBufferDebugPass->IsInputValid("u_GPUMaterialTextures")) m_GBufferDebugPass->SetInput("u_GPUMaterialTextures", texture, textureIndex); + }; + + if (fullTextureResolve) + { + for (uint32_t textureIndex = 0; textureIndex < MaxGPUTextureSceneTextures; textureIndex++) + resolveSlot(textureIndex); + m_MissingTextureDescriptorCount = missingTextureDescriptorCount; + } + else + { + // Persistent pending slots (transient-region entries are re-added by + // the transient loop below, avoiding duplicates in the pending list). + for (uint32_t textureIndex : m_PendingTextureResolveScratch) + { + if (textureIndex < persistentTextureCount) + resolveSlot(textureIndex); + } + + // Transient slots change every frame; always resolve their region. + const uint32_t transientEnd = glm::min((uint32_t)gpuTextureHandles.size(), MaxGPUTextureSceneTextures); + for (uint32_t textureIndex = persistentTextureCount; textureIndex < transientEnd; textureIndex++) + resolveSlot(textureIndex); + + // The missing-slot statistic refreshes on full sweeps. + missingTextureDescriptorCount = m_MissingTextureDescriptorCount; } std::vector& gpuMaterialData = m_ScratchMaterialData; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 08521e32..780bd1fc 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -1531,6 +1531,14 @@ namespace Lux { // Never touch this from the main thread. std::vector m_RTPushConstantScratch; + // Bindless texture resolve: slots waiting on streaming textures retry per + // frame; everything else re-resolves only on table changes or the + // periodic safety sweep (hot-reload coverage). + std::vector m_PendingTextureResolveSlots; + std::vector m_PendingTextureResolveScratch; + uint32_t m_TextureResolveSweepCountdown = 0; + uint32_t m_MissingTextureDescriptorCount = 0; + // Dirty-range GPUScene uploads: each sync's dirty ranges replay once per // frame-in-flight buffer ("epochs"); full uploads run on scene switch / // instance-count growth (high-water tracked, which also covers buffer From ca323151be6a20515c7314eb54fcda9c436e0af5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:53:33 +0000 Subject: [PATCH 33/63] Rebake only affected descriptor sets on invalidation A single changed input (e.g. one resized image) previously triggered a full Bake: every binding set of every descriptor set across all frames in flight was re-created. Extract the per-set bake into BakeSet and have InvalidateAndUpdate rebuild only the sets whose inputs actually changed (set indexes snapshotted first, since BakeSet re-queues still-null deferred inputs). Bake() keeps identical behavior for initial/full bakes, including the empty-set-0 fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- .../Platform/Vulkan/DescriptorSetManager.cpp | 69 ++++++++++++------- .../Platform/Vulkan/DescriptorSetManager.h | 3 + 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp index 55c62ec7..b39ac5f3 100644 --- a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp +++ b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp @@ -364,27 +364,48 @@ namespace Lux { LUX_CORE_ERROR_TAG("Renderer", "[RenderPass] Bake - Validate failed! {}", m_Specification.DebugName); return; } - - // If valid, we can create descriptor sets - nvrhi::DeviceHandle device = Application::GetGraphicsDevice(); - auto bufferSets = HasBufferSets(); - bool perFrameInFlight = !bufferSets.empty(); - perFrameInFlight = true; // always uint32_t descriptorSetCount = Renderer::GetConfig().FramesInFlight; - if (!perFrameInFlight) - descriptorSetCount = 1; m_BindingSets.resize(descriptorSetCount); for (auto& set : m_BindingSets) set = {}; - // for (auto& set : m_BindingSetHandles) - // set.clear(); - for (const auto& [set, setData] : InputResources) + BakeSet(set); + + nvrhi::DeviceHandle device = Application::GetGraphicsDevice(); +#if 1 + for (uint32_t frameIndex = 0; frameIndex < descriptorSetCount; frameIndex++) + { + if (!m_BindingSets[frameIndex].empty() && m_BindingSets[frameIndex][0] == nullptr) + { + nvrhi::BindingLayoutHandle bindingLayout = m_Specification.Shader->GetDescriptorSetLayout(0); + + nvrhi::BindingSetDesc bindingSetDesc; + m_BindingSets[frameIndex][0] = device->createBindingSet(bindingSetDesc, bindingLayout); + } + } +#endif + } + + // Rebuilds the binding sets for one descriptor-set index across all frames + // in flight. Bake() calls this for every set; InvalidateAndUpdate calls it + // only for the sets whose inputs actually changed, instead of re-creating + // every binding set of every set on any single change. + void DescriptorSetManager::BakeSet(uint32_t set) + { + auto setIt = InputResources.find(set); + if (setIt == InputResources.end()) + return; + const auto& setData = setIt->second; + + nvrhi::DeviceHandle device = Application::GetGraphicsDevice(); + const uint32_t descriptorSetCount = Renderer::GetConfig().FramesInFlight; + if (m_BindingSets.size() < descriptorSetCount) + m_BindingSets.resize(descriptorSetCount); + { - uint32_t descriptorCountInSet = bufferSets.find(set) != bufferSets.end() ? descriptorSetCount : 1; for (uint32_t frameIndex = 0; frameIndex < descriptorSetCount; frameIndex++) { nvrhi::BindingLayoutHandle bindingLayout = m_Specification.Shader->GetDescriptorSetLayout(set); @@ -548,19 +569,6 @@ namespace Lux { } } -#if 1 - for (uint32_t frameIndex = 0; frameIndex < descriptorSetCount; frameIndex++) - { - if (!m_BindingSets[frameIndex].empty() && m_BindingSets[frameIndex][0] == nullptr) - { - nvrhi::BindingLayoutHandle bindingLayout = m_Specification.Shader->GetDescriptorSetLayout(0); - - nvrhi::BindingSetDesc bindingSetDesc; - m_BindingSets[frameIndex][0] = device->createBindingSet(bindingSetDesc, bindingLayout); - } - } -#endif - #if TODO // Create Descriptor Pool @@ -885,7 +893,16 @@ namespace Lux { if (!InvalidatedInputResources.empty()) { LUX_CORE_TRACE_TAG("Renderer", "DescriptorSetManager::InvalidateAndUpdate ({}) - updating {} descriptors (frameIndex={})", m_Specification.DebugName, InvalidatedInputResources.size(), currentFrameIndex); - Bake(); + + // Rebake only the affected descriptor sets. Snapshot the set indexes + // first: BakeSet may re-insert still-null deferred inputs into + // InvalidatedInputResources while we iterate. + std::vector setsToBake; + setsToBake.reserve(InvalidatedInputResources.size()); + for (const auto& [set, bindings] : InvalidatedInputResources) + setsToBake.push_back(set); + for (uint32_t set : setsToBake) + BakeSet(set); } if (!m_Specification.IsDynamic) diff --git a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h index bed383ff..9681f804 100644 --- a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h +++ b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h @@ -248,6 +248,9 @@ namespace Lux { bool IsInvalidated(uint32_t set, uint32_t binding) const; bool Validate(); void Bake(); + // Rebuilds the binding sets of a single descriptor-set index across all + // frames in flight (granular alternative to a full Bake). + void BakeSet(uint32_t set); std::set HasBufferSets() const; void InvalidateAndUpdate(); From 6e4706a227789146ff74c3ea7163eac917cc8b95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:55:38 +0000 Subject: [PATCH 34/63] Reuse per-pass sorted draw order when the draw-list key set is unchanged Every mesh pass re-sorted its draw list every frame. MeshKey embeds all the sort inputs (pipeline/shader/material/mesh sort keys), so an order-independent fingerprint of the key set (sum+xor of key hashes plus count) fully determines the sorted order: when it matches last frame's, the retained DrawOrder is reused and the sort is skipped. Any membership change reperturbs the fingerprint and triggers a fresh sort; stale keys in a reused order are harmless since every consumer resolves keys with find(). This captures the camera-safe half of the roadmap's retained draw lists item (full FMeshDrawCommand retention stays future work). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 28 ++++++++++++++++++---- Core/Source/Lux/Renderer/SceneRenderer.h | 6 ++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 8f6ab98b..439b65df 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -5207,8 +5207,26 @@ namespace Lux { return false; } - void SceneRenderer::BuildSortedDrawCommandOrder(const DrawCommandList& drawList, DrawCommandOrder& drawOrder) const - { + void SceneRenderer::BuildSortedDrawCommandOrder(const DrawCommandList& drawList, DrawCommandOrder& drawOrder, uint64_t& orderCacheHash) const + { + // Order-independent fingerprint of the draw-list key set. MeshKey's hash + // covers every sort input (pipeline/shader/material/mesh sort keys), so + // an unchanged fingerprint means an unchanged sorted order — reuse last + // frame's DrawOrder instead of re-sorting each pass every frame. + uint64_t hashSum = 0; + uint64_t hashXor = 0; + for (const auto& [key, dc] : drawList) + { + const uint64_t keyHash = (uint64_t)MeshKeyHasher{}(key); + hashSum += keyHash; + hashXor ^= keyHash; + } + const uint64_t fingerprint = hashSum ^ (hashXor * 0x9E3779B97F4A7C15ull) ^ ((uint64_t)drawList.size() << 48); + + if (fingerprint == orderCacheHash && drawOrder.size() == drawList.size()) + return; + orderCacheHash = fingerprint; + drawOrder.clear(); drawOrder.reserve(drawList.size()); @@ -5380,7 +5398,9 @@ namespace Lux { for (MeshPassState& pass : m_MeshPasses) { pass.DrawList.clear(); - pass.DrawOrder.clear(); + // DrawOrder is intentionally retained: BuildSortedDrawCommandOrder + // reuses it when the rebuilt DrawList has the same key set (stale + // keys are harmless — every consumer looks keys up with find()). } m_MeshCullDrawCount = 0; @@ -5770,7 +5790,7 @@ namespace Lux { indirectDrawData.clear(); for (MeshPassState& pass : m_MeshPasses) - BuildSortedDrawCommandOrder(pass.DrawList, pass.DrawOrder); + BuildSortedDrawCommandOrder(pass.DrawList, pass.DrawOrder, pass.OrderCacheHash); const GPUScene* submittedGPUScene = m_SubmittedRenderScene ? &m_SubmittedRenderScene->GetGPUScene() : nullptr; const std::vector* persistentGPUSceneInstances = submittedGPUScene ? &submittedGPUScene->GetInstances() : nullptr; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 780bd1fc..b95cfad1 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -744,6 +744,10 @@ namespace Lux { MeshPassType Type = MeshPassType::Opaque; DrawCommandList DrawList; DrawCommandOrder DrawOrder; + // Fingerprint of the key set DrawOrder was sorted for; when the set + // is unchanged, last frame's sorted order is reused (DrawOrder is + // intentionally retained across frames for this). + uint64_t OrderCacheHash = 0; }; struct MeshDrawCommandCacheKey @@ -859,7 +863,7 @@ namespace Lux { const StaticMeshRenderProxy* renderProxy = nullptr); bool IsMainViewVisible(const BoundingSphere& bounds) const; bool IsShadowCasterVisible(const BoundingSphere& bounds) const; - void BuildSortedDrawCommandOrder(const DrawCommandList& drawList, DrawCommandOrder& drawOrder) const; + void BuildSortedDrawCommandOrder(const DrawCommandList& drawList, DrawCommandOrder& drawOrder, uint64_t& orderCacheHash) const; MeshPassState& GetMeshPass(MeshPassType passType); const MeshPassState& GetMeshPass(MeshPassType passType) const; RenderMaterialID GetOrCreateTransientRenderMaterialID(AssetHandle materialHandle, const Ref& materialAsset, const Ref& overrideMaterial, bool transparent); From 8329179648f659f71beafacbce2de34140a76e58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 21:56:07 +0000 Subject: [PATCH 35/63] Docs: record the CPU frame-cost batch (items 9-14) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- docs/ENGINE_OPTIMIZATION_PLAN.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index e56f0fa3..eef957e0 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -130,6 +130,29 @@ silent afterwards. (SSR, GTAO, debug views, TAA), leaving no dead window to alias into. Do not extend aliasing to these. +**Phase 4 progress — CPU frame cost (2026-07-04):** + +1. **Sort-order cache (A1-lite)** — per-pass draw sorting skipped when the draw-list key + set fingerprint is unchanged (MeshKey embeds all sort inputs). *Verify:* + `FlushDrawList` CPU on a static-membership scene; visuals identical while + adding/removing/selecting meshes. Full FMeshDrawCommand retention remains future work + (camera-driven CPU culling changes list membership every frame on moving cameras). +2. **Dirty-range GPUScene uploads (A2-prime)** — per-sync dirty ranges replayed once per + frame-in-flight buffer; full uploads only on scene switch/count growth or when dirty + volume exceeds a full array. *Verify:* upload closure cost in Tracy; GPUScene debug + snapshot diagnostics stay clean while moving objects. +3. **Pending-slot bindless resolve** — steady state resolves only streaming-pending slots + + transients, with a 32-frame full-sweep hot-reload safety net. *Verify:* texture + streaming still flips white→real; editor texture hot-reload updates within ~32 frames. +4. **Granular descriptor rebake** — `BakeSet` rebuilds only the changed set indexes + instead of every binding set on any invalidation. +5. **Small always-on trims** — spot-shadow machinery skips with zero spot lights; + directional shadow UBO idles when cascades are unchanged (memcmp + per-FIF counter); + the statistics draw-list re-walk compiles out of Dist. +6. **Parallel command recording — NOT attempted here:** the render command queue is + single-producer and NVRHI multi-command-list recording changes the threading model; + needs a build+validation cycle. Revisit with the async-compute (B1) work. + **Phase 4 candidates (audit findings that need build/measure or shader edits — do with Tracy + validation on):** From b4848a1249ca10a0891d806379f565add595eaa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 02:36:22 +0000 Subject: [PATCH 36/63] Grant UAV usage only to compute-written images Every non-depth, non-sRGB, non-compressed image was created with isUAV=true, adding STORAGE usage to all render targets and disabling framebuffer/delta-color compression on many GPUs - a bandwidth tax on every pass. Audit result: every compute-written image in the engine is created with Usage::Storage, and the only other compute writer is the mip generator (a compute pass writing each level of sampled textures). Restrict the flag accordingly: Storage-usage images and mip-chained sampled textures keep UAV; framebuffer attachments lose it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/Image.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/Core/Source/Lux/Renderer/Image.cpp b/Core/Source/Lux/Renderer/Image.cpp index 10f514e1..28c58d2c 100644 --- a/Core/Source/Lux/Renderer/Image.cpp +++ b/Core/Source/Lux/Renderer/Image.cpp @@ -186,8 +186,21 @@ namespace Lux { textureDesc.keepInitialState = true; } - //textureDesc.initialState = textureDesc.initialState | nvrhi::ResourceStates::UnorderedAccess; - if (!Utils::IsDepthFormat(m_Specification.Format) && m_Specification.Format != ImageFormat::SRGB && m_Specification.Format != ImageFormat::SRGBA && !Utils::IsBlockCompressed(m_Specification.Format)) + // UAV (STORAGE usage) disables framebuffer/delta-color compression on many + // GPUs — a bandwidth tax on every render-target read/write. Grant it only + // to images actually written by compute shaders: Storage-usage images, and + // sampled textures with mip chains (Texture2D::GenerateMips is a compute + // pass that writes each level as a storage image). Attachments never + // qualify; every compute-written image in the engine is created with + // Usage::Storage (verified against all shader storage-image bindings). + const bool formatSupportsUAV = !Utils::IsDepthFormat(m_Specification.Format) + && m_Specification.Format != ImageFormat::SRGB + && m_Specification.Format != ImageFormat::SRGBA + && !Utils::IsBlockCompressed(m_Specification.Format); + const bool isComputeMipTarget = m_Specification.Usage != ImageUsage::Attachment + && m_Specification.Usage != ImageUsage::HostRead + && m_Specification.Mips > 1; + if (formatSupportsUAV && (m_Specification.Usage == ImageUsage::Storage || isComputeMipTarget)) textureDesc.isUAV = true; if (textureDesc.isUAV) From 624c95660c4cad53d67f6b31f930034a660a0dbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 02:37:06 +0000 Subject: [PATCH 37/63] Skip empty editor/transparent passes at the graph level Selected Geometry, Transparent Forward, and Geometry Wireframe were always registered; their bodies early-out when empty, but the graph node and pass bookkeeping still ran. Executable graphs now skip the node when the corresponding draw lists are empty (the jumpFloodActive pattern); debug-snapshot graphs keep full topology. Scene-color chaining flows through untouched when a node is skipped. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 26 +++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 439b65df..8dce7fa0 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -3549,7 +3549,7 @@ namespace Lux { } std::vector selectedOutputs; - if (m_SelectedGeometryPass) + if (m_SelectedGeometryPass && (executable ? !GetMeshPass(MeshPassType::SelectedMask).DrawList.empty() : true)) { selectedOutputs = addRenderPassResources("SelectedGeometry", m_SelectedGeometryPass); addPass("Selected Geometry", preDepthOutputs, selectedOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::SelectedGeometryPass)); @@ -3682,14 +3682,24 @@ namespace Lux { sceneColorCurrent = fogOutputs; } - std::vector transparentReads = shadowOutputs; - appendResources(transparentReads, preDepthOutputs); - appendResources(transparentReads, sceneColorCurrent); - std::vector transparentOutputs = addRenderPassResources("Transparent Forward", m_GeometryPassTransparent); - addPass("Transparent Forward", transparentReads, transparentOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::TransparentForwardPass)); - sceneColorCurrent = transparentOutputs; + // Executable graphs skip the node entirely when nothing transparent was + // submitted (avoids the render-pass open/clear); non-executable (debug + // snapshot) graphs keep the full topology. + if (executable ? !GetMeshPass(MeshPassType::Transparent).DrawList.empty() : true) + { + std::vector transparentReads = shadowOutputs; + appendResources(transparentReads, preDepthOutputs); + appendResources(transparentReads, sceneColorCurrent); + std::vector transparentOutputs = addRenderPassResources("Transparent Forward", m_GeometryPassTransparent); + addPass("Transparent Forward", transparentReads, transparentOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::TransparentForwardPass)); + sceneColorCurrent = transparentOutputs; + } - if (m_GeometryWireframePass) + const bool wireframeActive = executable + ? ((m_Options.ShowSelectedInWireframe && !GetMeshPass(MeshPassType::Wireframe).DrawList.empty()) + || (m_Options.ShowPhysicsColliders && !GetMeshPass(MeshPassType::PhysicsCollider).DrawList.empty())) + : true; + if (m_GeometryWireframePass && wireframeActive) { std::vector wireframeReads = sceneColorCurrent; std::vector wireframeOutputs = addRenderPassResources("Geometry Wireframe", m_GeometryWireframePass); From 34f1725688ee353f82f79d04a5f0302b9734de82 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 02:37:43 +0000 Subject: [PATCH 38/63] Effect defaults: 2 GTAO denoise passes, temporal SSR/GTAO at High The realtime baseline ran a 4-pass denoise blur chain and High ran SSR/GTAO at full cost every frame with no temporal amortization (both were Ultra-only). Baseline denoise drops to 2 passes (Ultra/Cinematic keep 6/8); High enables SSR+GTAO temporal accumulation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 8dce7fa0..93dd8903 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -554,7 +554,8 @@ namespace Lux { m_Options.GTAOBentNormals = false; m_Options.EnableGTAOTemporalAccumulation = false; m_Options.GTAOTemporalBlend = 0.85f; - m_Options.GTAODenoisePasses = 4; + // 2 denoise passes as the realtime baseline (Ultra/Cinematic raise to 6/8). + m_Options.GTAODenoisePasses = 2; m_Options.AOShadowTolerance = 1.0f; m_BloomSettings.Enabled = true; m_BloomSettings.ResolutionScale = SceneRendererOptions::EffectResolutionScale::Half; @@ -611,6 +612,10 @@ namespace Lux { break; case QualityPreset::High: m_Options.SSRQuality = SceneRendererOptions::SSRQualityPreset::Full; + // Temporal accumulation amortizes SSR/GTAO cost across frames; was + // Ultra-only. + m_Options.EnableSSRTemporalAccumulation = true; + m_Options.EnableGTAOTemporalAccumulation = true; m_Options.GTAOResolutionScale = SceneRendererOptions::EffectResolutionScale::Full; m_BloomSettings.ResolutionScale = SceneRendererOptions::EffectResolutionScale::Half; m_Options.ResolutionScaleMode = SceneRendererOptions::RenderResolutionScaleMode::Native; From 3a69fc91161636036e26b6c4e957a460f83143ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 02:44:32 +0000 Subject: [PATCH 39/63] Fold the AO composite multiply into the deferred lighting shader The AO-Composite pass performed a full-resolution read-modify-write of scene color every frame (Zero_SrcColor blend of the GTAO term) right after deferred lighting. Deferred lighting now samples u_GTAOTex itself (same DecodeGTAO / bilateral-upscale helpers, gated by the existing __HZ_AO_METHOD permutation macro) and multiplies its output, eliminating the extra pass, its wrapped-scene-color framebuffer, and one full-res scene-color round trip per frame. Graph restructure: the GTAO compute chain now registers between the GBuffer and Deferred Lighting nodes, and deferred reads aoFinalOutputs, so GTAO always executes first (correct under both topological and registration-order execution). The per-frame u_GTAOTex re-binds (denoise parity / temporal ping-pong) target the deferred pass and run before it executes. The AO debug view keeps the AO-Composite shader with its own standalone pipeline. Sky pixels discard in deferred and their GTAO term is ~1, so output is visually unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 164 ++++++------------ Core/Source/Lux/Renderer/SceneRenderer.h | 3 - .../Resources/Shaders/DeferredLighting.glsl | 99 +++++++++++ 3 files changed, 153 insertions(+), 113 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 93dd8903..36717454 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -400,7 +400,6 @@ namespace Lux { "GTAO", "GTAO-Denoise", "GTAO-Temporal", - "AOComposite", "PreConvolution", "SSR", "SSR-Temporal", @@ -1462,41 +1461,12 @@ namespace Lux { LUX_CORE_VERIFY(m_GTAOTemporalPass->Validate()); m_GTAOTemporalPass->Bake(); - FramebufferSpecification aoFramebufferSpec; - aoFramebufferSpec.Width = m_ViewportWidth; - aoFramebufferSpec.Height = m_ViewportHeight; - aoFramebufferSpec.Attachments = { ImageFormat::RGBA16F }; - aoFramebufferSpec.ExistingImages[0] = GetSceneColorOutput(); - aoFramebufferSpec.ClearColorOnLoad = false; - aoFramebufferSpec.Blend = true; - aoFramebufferSpec.BlendMode = FramebufferBlendMode::Zero_SrcColor; - aoFramebufferSpec.DebugName = "AO-Composite"; - - PipelineSpecification aoPipelineSpec; - aoPipelineSpec.DebugName = "AO-Composite"; - aoPipelineSpec.TargetFramebuffer = Framebuffer::Create(aoFramebufferSpec); - aoPipelineSpec.DepthTest = false; - aoPipelineSpec.DepthWrite = false; - aoPipelineSpec.Layout = { - { ShaderDataType::Float3, "a_Position" }, - { ShaderDataType::Float2, "a_TexCoord" }, - }; - aoPipelineSpec.Shader = Renderer::GetShaderLibrary()->Get("AO-Composite"); - - RenderPassSpecification aoRenderPassSpec; - aoRenderPassSpec.DebugName = "AO-Composite"; - aoRenderPassSpec.Pipeline = Pipeline::Create(aoPipelineSpec); - m_AOCompositePass = RenderPass::Create(aoRenderPassSpec); - m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); - m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); - m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); - m_AOCompositePass->SetInput("Camera", m_UBSCamera); - m_AOCompositePass->SetInput("r_DefaultSampler", Renderer::GetDefaultSampler()); - m_AOCompositePass->SetInput("r_PointSampler", Renderer::GetPointSampler()); - m_AOCompositePass->SetInput("r_LinearSampler", Renderer::GetClampSampler()); - LUX_CORE_VERIFY(m_AOCompositePass->Validate()); - m_AOCompositePass->Bake(); - m_AOCompositeMaterial = Material::Create(aoPipelineSpec.Shader, "GTAO-Composite"); + // The screen-space AO multiply is folded into the deferred lighting + // shader (u_GTAOTex below) — the former AO-Composite full-res + // read-modify-write pass is gone. The AO-Composite shader remains in + // use by the editor's AO debug view. + if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) + m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); // Editor-only AO debug view target — not created in the standalone runtime. if (m_Specification.EnableEditorRenderTargets) @@ -1508,9 +1478,16 @@ namespace Lux { aoDebugFramebufferSpec.ClearColor = { 1.0f, 1.0f, 1.0f, 1.0f }; aoDebugFramebufferSpec.DebugName = "AO-Debug"; - PipelineSpecification aoDebugPipelineSpec = aoPipelineSpec; + PipelineSpecification aoDebugPipelineSpec; aoDebugPipelineSpec.DebugName = "AO-Debug"; aoDebugPipelineSpec.TargetFramebuffer = Framebuffer::Create(aoDebugFramebufferSpec); + aoDebugPipelineSpec.DepthTest = false; + aoDebugPipelineSpec.DepthWrite = false; + aoDebugPipelineSpec.Layout = { + { ShaderDataType::Float3, "a_Position" }, + { ShaderDataType::Float2, "a_TexCoord" }, + }; + aoDebugPipelineSpec.Shader = Renderer::GetShaderLibrary()->Get("AO-Composite"); RenderPassSpecification aoDebugRenderPassSpec; aoDebugRenderPassSpec.DebugName = "AO-Debug"; @@ -1525,7 +1502,7 @@ namespace Lux { m_AODebugPass->SetInput("r_LinearSampler", Renderer::GetClampSampler()); LUX_CORE_VERIFY(m_AODebugPass->Validate()); m_AODebugPass->Bake(); - m_AODebugMaterial = Material::Create(aoPipelineSpec.Shader, "AO-Debug"); + m_AODebugMaterial = Material::Create(aoDebugPipelineSpec.Shader, "AO-Debug"); } } @@ -2555,7 +2532,6 @@ namespace Lux { pass->GetTargetFramebuffer()->Resize(size.x, size.y); }; - resizePass(m_AOCompositePass, viewportSize); resizePass(m_AODebugPass, viewportSize); resizePass(m_SSRCompositePass, viewportSize); resizePass(m_DeferredLightingPass, viewportSize); @@ -2645,12 +2621,8 @@ namespace Lux { }; m_GTAOFinalImage = (m_Options.GTAODenoisePasses % 2 != 0) ? m_GTAODenoiseImage : m_GTAOOutputImage; - if (m_AOCompositePass) - { - m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); - m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); - m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); - } + if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) + m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); if (m_AODebugPass) { m_AODebugPass->SetInput("u_GTAOTex", m_GTAOFinalImage); @@ -3255,7 +3227,6 @@ namespace Lux { addRenderPass(pass); addRenderPass(m_SpotShadowMapPass); addRenderPass(m_PreDepthPass); - addRenderPass(m_AOCompositePass); addRenderPass(m_AODebugPass); addRenderPass(m_SSRCompositePass); addRenderPass(m_DOFPass); @@ -3563,28 +3534,11 @@ namespace Lux { std::vector geometryOutputs = gbufferOutputs; appendResources(geometryOutputs, sceneColorCurrent); - { - addPass("GBuffer", preDepthOutputs, gbufferOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GBufferPass)); - - std::vector deferredReads = gbufferOutputs; - appendResources(deferredReads, preDepthOutputs); - appendResources(deferredReads, shadowOutputs); - appendResources(deferredReads, sceneColorCurrent); - std::vector deferredOutputs = addRenderPassResources("Deferred Lighting", m_DeferredLightingPass); - addPass("Deferred Lighting", deferredReads, deferredOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::DeferredLightingPass)); - sceneColorCurrent = deferredOutputs; - - geometryOutputs = gbufferOutputs; - appendResources(geometryOutputs, sceneColorCurrent); - } - - if (UsesGBufferDebugPass(m_DebugViewMode) && m_GBufferDebugPass) - { - std::vector debugReads = gbufferOutputs; - appendResources(debugReads, sceneColorCurrent); - addPass("GBuffer Debug", debugReads, addRenderPassResources("GBuffer Debug", m_GBufferDebugPass), RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GBufferDebugPass)); - } + addPass("GBuffer", preDepthOutputs, gbufferOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GBufferPass)); + // GTAO runs on GBuffer depth/normals and is consumed by the deferred + // lighting shader (the former AO-Composite full-res multiply is folded + // into it), so the GTAO chain registers between GBuffer and Deferred. std::vector aoFinalOutputs; if (m_Options.EnableGTAO) { @@ -3606,17 +3560,35 @@ namespace Lux { aoFinalOutputs.push_back(gtaoHistoryA); aoFinalOutputs.push_back(gtaoHistoryB); } + } + + { + std::vector deferredReads = gbufferOutputs; + appendResources(deferredReads, preDepthOutputs); + appendResources(deferredReads, shadowOutputs); + appendResources(deferredReads, sceneColorCurrent); + appendResources(deferredReads, aoFinalOutputs); + std::vector deferredOutputs = addRenderPassResources("Deferred Lighting", m_DeferredLightingPass); + addPass("Deferred Lighting", deferredReads, deferredOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::DeferredLightingPass)); + sceneColorCurrent = deferredOutputs; - std::vector aoCompositeReads = geometryOutputs; - appendResources(aoCompositeReads, preDepthOutputs); - appendResources(aoCompositeReads, aoFinalOutputs); - appendResources(aoCompositeReads, sceneColorCurrent); - std::vector aoCompositeOutputs = addRenderPassResources("AO Composite", m_AOCompositePass); - addPass("AO Composite", aoCompositeReads, aoCompositeOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::AOComposite)); - sceneColorCurrent = aoCompositeOutputs; + geometryOutputs = gbufferOutputs; + appendResources(geometryOutputs, sceneColorCurrent); + } - if (m_DebugViewMode == DebugViewMode::AO && m_AODebugPass) - addPass("AO Debug", aoCompositeReads, addRenderPassResources("AO Debug", m_AODebugPass), RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::AODebugPass)); + if (UsesGBufferDebugPass(m_DebugViewMode) && m_GBufferDebugPass) + { + std::vector debugReads = gbufferOutputs; + appendResources(debugReads, sceneColorCurrent); + addPass("GBuffer Debug", debugReads, addRenderPassResources("GBuffer Debug", m_GBufferDebugPass), RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GBufferDebugPass)); + } + + if (m_Options.EnableGTAO && m_DebugViewMode == DebugViewMode::AO && m_AODebugPass) + { + std::vector aoDebugReads = geometryOutputs; + appendResources(aoDebugReads, preDepthOutputs); + appendResources(aoDebugReads, aoFinalOutputs); + addPass("AO Debug", aoDebugReads, addRenderPassResources("AO Debug", m_AODebugPass), RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::AODebugPass)); } std::vector ssrOutputs; @@ -3871,7 +3843,6 @@ namespace Lux { if (name == "GBuffer Debug") return "GBufferDebugPass"; if (name == "GTAO Denoise") return "GTAO-Denoise"; if (name == "GTAO Temporal") return "GTAO-Temporal"; - if (name == "AO Composite") return "AOComposite"; if (name == "AO Debug") return "AODebug"; if (name == "Pre-Convolution") return "PreConvolution"; if (name == "SSR Temporal") return "SSR-Temporal"; @@ -4223,7 +4194,6 @@ namespace Lux { recreatePassFramebuffer(m_AtmosphericFogPass); recreatePassFramebuffer(m_SelectedGeometryPass); recreatePassFramebuffer(m_GeometryWireframePass); - recreatePassFramebuffer(m_AOCompositePass); recreatePassFramebuffer(m_AODebugPass); recreatePassFramebuffer(m_SSRCompositePass); recreatePassFramebuffer(m_JumpFloodInitPass); @@ -4414,7 +4384,6 @@ namespace Lux { repairPassIfStale(m_GeometryPass, "GBuffer"); repairPassIfStale(m_GeometryPassTransparent, "TransparentForward"); repairPassIfStale(m_DeferredLightingPass, "DeferredLighting"); - repairPassIfStale(m_AOCompositePass, "AO-Composite"); repairPassIfStale(m_SSRCompositePass, "SSR-Composite"); repairPassIfStale(m_SkyboxPass, "Skybox"); repairPassIfStale(m_SkyAtmospherePass, "SkyAtmosphere"); @@ -7366,12 +7335,8 @@ namespace Lux { if (denoisePasses == 0) { m_GTAOFinalImage = m_GTAOOutputImage; - if (m_AOCompositePass) - { - m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); - m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); - m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); - } + if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) + m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); if (m_SSRPass && m_SSRPass->IsInputValid("u_GTAOTex")) m_SSRPass->SetInput("u_GTAOTex", m_GTAOFinalImage); return; @@ -7394,12 +7359,8 @@ namespace Lux { } m_GTAOFinalImage = (denoisePasses % 2u) != 0u ? m_GTAODenoiseImage : m_GTAOOutputImage; - if (m_AOCompositePass) - { - m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); - m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); - m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); - } + if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) + m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); if (m_SSRPass && m_SSRPass->IsInputValid("u_GTAOTex")) m_SSRPass->SetInput("u_GTAOTex", m_GTAOFinalImage); @@ -7440,29 +7401,12 @@ namespace Lux { m_GTAOHistoryIndex = writeIndex; m_GTAOFinalImage = historyOutput; - if (m_AOCompositePass) - { - m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); - m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); - m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); - } + if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) + m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); if (m_SSRPass && m_SSRPass->IsInputValid("u_GTAOTex")) m_SSRPass->SetInput("u_GTAOTex", m_GTAOFinalImage); } - void SceneRenderer::AOComposite() - { - ScopedCPUProfile cpuProfile(*this, "AOComposite"); - if (!m_AOCompositePass || !m_AOCompositeMaterial || !m_GTAOFinalImage) - return; - - BeginProfiledGPU("AOComposite"); - Renderer::BeginRenderPass(m_CommandBuffer, m_AOCompositePass); - Renderer::SubmitFullscreenQuad(m_CommandBuffer, m_AOCompositePass->GetPipeline(), m_AOCompositeMaterial); - Renderer::EndRenderPass(m_CommandBuffer); - Renderer::EndGPUPerfMarker(m_CommandBuffer); - } - void SceneRenderer::AODebugPass() { ScopedCPUProfile cpuProfile(*this, "AODebug"); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index b95cfad1..e49557af 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -912,7 +912,6 @@ namespace Lux { void GTAOCompute(); void GTAODenoiseCompute(); void GTAOTemporalAccumulationCompute(); - void AOComposite(); void AODebugPass(); void PreConvolutionCompute(); void SSRCompute(); @@ -1367,8 +1366,6 @@ namespace Lux { glm::uvec3 m_GTAOTemporalWorkGroups{ 1 }; uint32_t m_GTAOHistoryIndex = 0; - Ref m_AOCompositePass; - Ref m_AOCompositeMaterial; Ref m_AODebugPass; Ref m_AODebugMaterial; diff --git a/Editor/Resources/Shaders/DeferredLighting.glsl b/Editor/Resources/Shaders/DeferredLighting.glsl index 7b61e95f..8a629696 100644 --- a/Editor/Resources/Shaders/DeferredLighting.glsl +++ b/Editor/Resources/Shaders/DeferredLighting.glsl @@ -26,6 +26,9 @@ void main() #include #include #include +#include + +#define ENABLED_GTAO (__HZ_AO_METHOD & HZ_AO_METHOD_GTAO) layout(location = 0) in vec2 v_TexCoord; layout(location = 1) in vec2 v_ClipPosition; @@ -35,6 +38,9 @@ layout(set = 1, binding = 0) uniform textureCube u_EnvRadianceTex; layout(set = 1, binding = 1) uniform textureCube u_EnvIrradianceTex; layout(set = 1, binding = 2) uniform texture2DArray u_ShadowMapTexture; layout(set = 1, binding = 3) uniform texture2D u_SpotShadowTexture; +#if ENABLED_GTAO +layout(set = 1, binding = 4) uniform utexture2D u_GTAOTex; +#endif layout(set = 1, binding = 11) uniform texture2D u_SceneColor; layout(set = 1, binding = 12) uniform texture2D u_GBufferBaseColor; layout(set = 1, binding = 13) uniform texture2D u_GBufferNormal; @@ -45,6 +51,93 @@ layout(set = 1, binding = 17) uniform texture2D u_DepthTexture; layout(set = 3, binding = 5) uniform texture2D u_BRDFLUTTexture; +#if ENABLED_GTAO +// Screen-space AO sampling, folded in from the former AO-Composite pass (it +// multiplied the whole scene color in a separate full-res read-modify-write). +// Helpers mirror AO-Composite.glsl, adapted to this pass's bindings. +float GTAO_LinearizeDepth(float screenDepth) +{ + float depthLinearizeMul = u_Camera.DepthUnpackConsts.x; + float depthLinearizeAdd = u_Camera.DepthUnpackConsts.y; + return depthLinearizeMul / (depthLinearizeAdd - screenDepth); +} + +float GTAO_ReadDepth(vec2 uv) +{ + return GTAO_LinearizeDepth(texture(sampler2D(u_DepthTexture, r_PointSampler), uv).r); +} + +vec3 GTAO_ReadNormal(vec2 uv) +{ + vec3 normal = texture(sampler2D(u_GBufferNormal, r_PointSampler), uv).xyz; + float normalLength = length(normal); + if (normalLength < 0.0001) + return vec3(0.0, 0.0, 1.0); + + return normal / normalLength; +} + +float DecodeGTAO(uint packedValue) +{ + #if __HZ_GTAO_COMPUTE_BENT_NORMALS + return float(packedValue >> 24u) / 255.0; + #else + return float(packedValue) / 255.0; + #endif +} + +float FetchGTAO(ivec2 texel) +{ + ivec2 aoSize = textureSize(usampler2D(u_GTAOTex, r_PointSampler), 0); + texel = clamp(texel, ivec2(0), max(aoSize - ivec2(1), ivec2(0))); + return DecodeGTAO(texelFetch(usampler2D(u_GTAOTex, r_PointSampler), texel, 0).x); +} + +float UpscaleGTAO(vec2 uv) +{ + ivec2 aoSize = textureSize(usampler2D(u_GTAOTex, r_PointSampler), 0); + ivec2 depthSize = textureSize(sampler2D(u_DepthTexture, r_PointSampler), 0); + if (aoSize.x >= depthSize.x && aoSize.y >= depthSize.y) + return FetchGTAO(ivec2(clamp(uv * vec2(aoSize), vec2(0.0), vec2(aoSize - ivec2(1))))); + + vec2 aoTexel = uv * vec2(aoSize) - vec2(0.5); + ivec2 baseTexel = ivec2(floor(aoTexel)); + float centerDepth = GTAO_ReadDepth(uv); + vec3 centerNormal = GTAO_ReadNormal(uv); + + float weightedAO = 0.0; + float totalWeight = 0.0; + + for (int y = -1; y <= 2; y++) + { + for (int x = -1; x <= 2; x++) + { + ivec2 sampleTexel = baseTexel + ivec2(x, y); + ivec2 clampedTexel = clamp(sampleTexel, ivec2(0), max(aoSize - ivec2(1), ivec2(0))); + vec2 sampleUV = (vec2(clampedTexel) + vec2(0.5)) / vec2(aoSize); + + float sampleDepth = GTAO_ReadDepth(sampleUV); + vec3 sampleNormal = GTAO_ReadNormal(sampleUV); + vec2 spatialOffset = (vec2(sampleTexel) + vec2(0.5)) - aoTexel; + + float relativeDepthDelta = abs(sampleDepth - centerDepth) / max(abs(centerDepth), 1.0); + float depthWeight = exp(-relativeDepthDelta / 0.035); + float normalWeight = pow(clamp(dot(centerNormal, sampleNormal), 0.0, 1.0), 24.0); + float spatialWeight = exp(-dot(spatialOffset, spatialOffset) * 0.55); + float weight = depthWeight * normalWeight * spatialWeight; + + weightedAO += FetchGTAO(clampedTexel) * weight; + totalWeight += weight; + } + } + + if (totalWeight <= 0.0001) + return FetchGTAO(ivec2(round(aoTexel))); + + return weightedAO / totalWeight; +} +#endif + bool ReconstructPositionFromDepth(float deviceDepth, out vec3 worldPosition, out vec3 viewPosition) { vec4 world = u_Camera.InverseViewProjectionMatrix * vec4(v_ClipPosition, deviceDepth, 1.0); @@ -208,5 +301,11 @@ void main() if (u_RendererData.ShowLightComplexity) color = (color * 0.2) + DebugGradient(float(GetPointLightCount() + GetSpotLightCount())); +#if ENABLED_GTAO + // Matches the former AO-Composite Zero_SrcColor multiply of the whole scene + // color. Sky pixels discard above (their GTAO term is ~1 anyway). + color *= min(UpscaleGTAO(v_TexCoord) * XE_GTAO_OCCLUSION_TERM_SCALE, 1.0); +#endif + o_Color = vec4(color, 1.0); } From d3e596b9b1811c9b8a39b5cf2721aa8db2a4e6f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 02:49:10 +0000 Subject: [PATCH 40/63] Merge the GBuffer material/object ID targets into one RG32UI attachment The GBuffer wrote two full-res R32UI targets (material ID, object ID). Pack both into a single RG32UI attachment: one fewer render-target write/clear per geometry pass. Adds ImageFormat::RG32UI; the GBuffer drops to 5 color attachments (velocity shifts to slot 4, shared depth to ExistingImages[5]); Encode/DecodeGBuffer take a single uvec2 target; DeferredLighting/GBufferDebug bind one u_GBufferMaterialObjectID. The object-ID accessor returns the packed image (ID in .y) and had no other consumers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/Image.h | 4 ++++ Core/Source/Lux/Renderer/SceneRenderer.cpp | 24 +++++++++---------- .../Resources/Shaders/DeferredLighting.glsl | 5 ++-- Editor/Resources/Shaders/GBufferDebug.glsl | 5 ++-- Editor/Resources/Shaders/GBuffer_Static.glsl | 7 +++--- .../Shaders/Include/GLSL/LuxGBuffer.glslh | 14 +++++------ 6 files changed, 29 insertions(+), 30 deletions(-) diff --git a/Core/Source/Lux/Renderer/Image.h b/Core/Source/Lux/Renderer/Image.h index 89772459..efefb477 100644 --- a/Core/Source/Lux/Renderer/Image.h +++ b/Core/Source/Lux/Renderer/Image.h @@ -18,6 +18,7 @@ namespace Lux { RED8UI, RED16UI, RED32UI, + RG32UI, RED32F, RG8, RG16F, @@ -245,6 +246,7 @@ namespace Lux { case ImageFormat::RED8UI: return nvrhi::Format::R8_UINT; case ImageFormat::RED16UI: return nvrhi::Format::R16_UINT; case ImageFormat::RED32UI: return nvrhi::Format::R32_UINT; + case ImageFormat::RG32UI: return nvrhi::Format::RG32_UINT; case ImageFormat::RED32F: return nvrhi::Format::R32_FLOAT; case ImageFormat::RG8: return nvrhi::Format::RG8_UNORM; case ImageFormat::RG16F: return nvrhi::Format::RG16_FLOAT; @@ -318,6 +320,7 @@ namespace Lux { case ImageFormat::RED8UI: return 1; case ImageFormat::RED16UI: return 2; case ImageFormat::RED32UI: return 4; + case ImageFormat::RG32UI: return 8; case ImageFormat::RED32F: return 4; case ImageFormat::RG8: return 2; case ImageFormat::RG16F: return 2 * 2; @@ -352,6 +355,7 @@ namespace Lux { { case ImageFormat::RED16UI: case ImageFormat::RED32UI: + case ImageFormat::RG32UI: case ImageFormat::RED8UI: case ImageFormat::DEPTH32FSTENCIL8UINT: return true; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 36717454..363ce680 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -1214,14 +1214,14 @@ namespace Lux { FramebufferTextureSpecification gbufferBaseColor = ImageFormat::RGBA16F; FramebufferTextureSpecification gbufferNormal = ImageFormat::RGBA16F; FramebufferTextureSpecification gbufferMetalRough = ImageFormat::RGBA; - FramebufferTextureSpecification gbufferMaterialID = ImageFormat::RED32UI; - FramebufferTextureSpecification gbufferObjectID = ImageFormat::RED32UI; + // Material + object IDs packed into one RG32UI target (one fewer + // full-res attachment write/clear per frame). + FramebufferTextureSpecification gbufferMaterialObjectID = ImageFormat::RG32UI; FramebufferTextureSpecification gbufferVelocity = ImageFormat::RG16F; gbufferBaseColor.Blend = false; gbufferNormal.Blend = false; gbufferMetalRough.Blend = false; - gbufferMaterialID.Blend = false; - gbufferObjectID.Blend = false; + gbufferMaterialObjectID.Blend = false; gbufferVelocity.Blend = false; FramebufferSpecification gbufferSpec; @@ -1231,12 +1231,11 @@ namespace Lux { gbufferBaseColor, gbufferNormal, gbufferMetalRough, - gbufferMaterialID, - gbufferObjectID, + gbufferMaterialObjectID, gbufferVelocity, ImageFormat::DEPTH32FSTENCIL8UINT }; - gbufferSpec.ExistingImages[6] = m_PreDepthPass->GetDepthOutput(); + gbufferSpec.ExistingImages[5] = m_PreDepthPass->GetDepthOutput(); gbufferSpec.ClearColor = { 0.0f, 0.0f, 0.0f, 0.0f }; gbufferSpec.ClearDepthOnLoad = false; gbufferSpec.Blend = false; @@ -1282,7 +1281,7 @@ namespace Lux { forwardSpec.ExistingImages[0] = m_SceneColorFramebuffer->GetImage(0); forwardSpec.ExistingImages[1] = m_GeometryPassFramebuffer->GetImage(1); forwardSpec.ExistingImages[2] = m_GeometryPassFramebuffer->GetImage(2); - forwardSpec.ExistingImages[3] = m_GeometryPassFramebuffer->GetImage(5); // shared velocity buffer + forwardSpec.ExistingImages[3] = m_GeometryPassFramebuffer->GetImage(4); // shared velocity buffer forwardSpec.ExistingImages[4] = m_PreDepthPass->GetDepthOutput(); forwardSpec.ClearColorOnLoad = false; forwardSpec.ClearDepthOnLoad = false; @@ -2379,8 +2378,7 @@ namespace Lux { SetRenderPassInputIfValid(renderPass, "u_GBufferBaseColor", m_GeometryPass->GetOutput(0)); SetRenderPassInputIfValid(renderPass, "u_GBufferNormal", m_GeometryPass->GetOutput(1)); SetRenderPassInputIfValid(renderPass, "u_GBufferMetalRoughAO", m_GeometryPass->GetOutput(2)); - SetRenderPassInputIfValid(renderPass, "u_GBufferMaterialID", m_GeometryPass->GetOutput(3)); - SetRenderPassInputIfValid(renderPass, "u_GBufferObjectID", m_GeometryPass->GetOutput(4)); + SetRenderPassInputIfValid(renderPass, "u_GBufferMaterialObjectID", m_GeometryPass->GetOutput(3)); SetRenderPassInputIfValid(renderPass, "u_DeferredLighting", GetSceneColorOutput()); } if (hasInput(PassInputSceneColor)) @@ -8250,17 +8248,19 @@ namespace Lux { Ref SceneRenderer::GetGeometryMaterialIDOutput() const { + // Packed RG32UI target: material ID in .x, object ID in .y. return m_GeometryPassFramebuffer ? m_GeometryPassFramebuffer->GetImage(3) : nullptr; } Ref SceneRenderer::GetGeometryObjectIDOutput() const { - return m_GeometryPassFramebuffer ? m_GeometryPassFramebuffer->GetImage(4) : nullptr; + // Same packed RG32UI target as the material IDs (object ID in .y). + return m_GeometryPassFramebuffer ? m_GeometryPassFramebuffer->GetImage(3) : nullptr; } Ref SceneRenderer::GetGeometryVelocityOutput() const { - return m_GeometryPassFramebuffer ? m_GeometryPassFramebuffer->GetImage(5) : nullptr; + return m_GeometryPassFramebuffer ? m_GeometryPassFramebuffer->GetImage(4) : nullptr; } Ref SceneRenderer::GetFinalPassImage() diff --git a/Editor/Resources/Shaders/DeferredLighting.glsl b/Editor/Resources/Shaders/DeferredLighting.glsl index 8a629696..c0449e8b 100644 --- a/Editor/Resources/Shaders/DeferredLighting.glsl +++ b/Editor/Resources/Shaders/DeferredLighting.glsl @@ -45,8 +45,7 @@ layout(set = 1, binding = 11) uniform texture2D u_SceneColor; layout(set = 1, binding = 12) uniform texture2D u_GBufferBaseColor; layout(set = 1, binding = 13) uniform texture2D u_GBufferNormal; layout(set = 1, binding = 14) uniform texture2D u_GBufferMetalRoughAO; -layout(set = 1, binding = 15) uniform utexture2D u_GBufferMaterialID; -layout(set = 1, binding = 16) uniform utexture2D u_GBufferObjectID; +layout(set = 1, binding = 15) uniform utexture2D u_GBufferMaterialObjectID; layout(set = 1, binding = 17) uniform texture2D u_DepthTexture; layout(set = 3, binding = 5) uniform texture2D u_BRDFLUTTexture; @@ -269,7 +268,7 @@ void main() discard; } - LuxGBufferData gbuffer = DecodeGBuffer(u_GBufferBaseColor, u_GBufferNormal, u_GBufferMetalRoughAO, u_GBufferMaterialID, u_GBufferObjectID, v_TexCoord); + LuxGBufferData gbuffer = DecodeGBuffer(u_GBufferBaseColor, u_GBufferNormal, u_GBufferMetalRoughAO, u_GBufferMaterialObjectID, v_TexCoord); vec3 worldNormal = normalize(mat3(u_Camera.InverseViewMatrix) * gbuffer.ViewNormal); m_Params.Albedo = gbuffer.BaseColor; diff --git a/Editor/Resources/Shaders/GBufferDebug.glsl b/Editor/Resources/Shaders/GBufferDebug.glsl index d6d3f9d6..47cbbbe4 100644 --- a/Editor/Resources/Shaders/GBufferDebug.glsl +++ b/Editor/Resources/Shaders/GBufferDebug.glsl @@ -28,8 +28,7 @@ layout(location = 0) out vec4 o_Color; layout(set = 1, binding = 0) uniform texture2D u_GBufferBaseColor; layout(set = 1, binding = 1) uniform texture2D u_GBufferNormal; layout(set = 1, binding = 2) uniform texture2D u_GBufferMetalRoughAO; -layout(set = 1, binding = 3) uniform utexture2D u_GBufferMaterialID; -layout(set = 1, binding = 4) uniform utexture2D u_GBufferObjectID; +layout(set = 1, binding = 3) uniform utexture2D u_GBufferMaterialObjectID; layout(set = 1, binding = 5) uniform texture2D u_DeferredLighting; layout(push_constant) uniform Uniforms @@ -54,7 +53,7 @@ const uint GBUFFER_DEBUG_GPU_MATERIAL_MISSING = 11u; void main() { - LuxGBufferData gbuffer = DecodeGBuffer(u_GBufferBaseColor, u_GBufferNormal, u_GBufferMetalRoughAO, u_GBufferMaterialID, u_GBufferObjectID, v_TexCoord); + LuxGBufferData gbuffer = DecodeGBuffer(u_GBufferBaseColor, u_GBufferNormal, u_GBufferMetalRoughAO, u_GBufferMaterialObjectID, v_TexCoord); if (u_Uniforms.Mode == GBUFFER_DEBUG_BASE_COLOR) { diff --git a/Editor/Resources/Shaders/GBuffer_Static.glsl b/Editor/Resources/Shaders/GBuffer_Static.glsl index 2e80b3f0..c8ffb9a9 100644 --- a/Editor/Resources/Shaders/GBuffer_Static.glsl +++ b/Editor/Resources/Shaders/GBuffer_Static.glsl @@ -100,9 +100,8 @@ layout(location = 20) in vec4 InputPreviousClip; layout(location = 0) out vec4 o_GBufferBaseColor; layout(location = 1) out vec4 o_GBufferViewNormal; layout(location = 2) out vec4 o_GBufferMetalRoughAO; -layout(location = 3) out uint o_GBufferMaterialID; -layout(location = 4) out uint o_GBufferObjectID; -layout(location = 5) out vec2 o_GBufferVelocity; +layout(location = 3) out uvec2 o_GBufferMaterialObjectID; +layout(location = 4) out vec2 o_GBufferVelocity; layout(push_constant) uniform PushConstants { @@ -183,7 +182,7 @@ void main() gbuffer.MaterialID = GetInstanceMaterialIndex(InputObjectIndex); gbuffer.ObjectID = GetInstancePrimitiveID(InputObjectIndex); - EncodeGBuffer(gbuffer, o_GBufferBaseColor, o_GBufferViewNormal, o_GBufferMetalRoughAO, o_GBufferMaterialID, o_GBufferObjectID); + EncodeGBuffer(gbuffer, o_GBufferBaseColor, o_GBufferViewNormal, o_GBufferMetalRoughAO, o_GBufferMaterialObjectID); // Screen-space motion vector (UV delta, current - previous), jitter removed. vec2 currentUV = (InputCurrentClip.xy / InputCurrentClip.w) * 0.5 + 0.5; diff --git a/Editor/Resources/Shaders/Include/GLSL/LuxGBuffer.glslh b/Editor/Resources/Shaders/Include/GLSL/LuxGBuffer.glslh index 6aaa5d75..3fd6ff2b 100644 --- a/Editor/Resources/Shaders/Include/GLSL/LuxGBuffer.glslh +++ b/Editor/Resources/Shaders/Include/GLSL/LuxGBuffer.glslh @@ -22,8 +22,7 @@ void EncodeGBuffer( out vec4 outBaseColorOpacity, out vec4 outViewNormal, out vec4 outMetalRoughAO, - out uint outMaterialID, - out uint outObjectID) + out uvec2 outMaterialObjectID) { outBaseColorOpacity = vec4(max(data.BaseColor, vec3(0.0)), clamp(data.Opacity, 0.0, 1.0)); outViewNormal = vec4(normalize(data.ViewNormal), data.Reserved0); @@ -32,16 +31,14 @@ void EncodeGBuffer( clamp(data.Roughness, 0.0, 1.0), clamp(data.AmbientOcclusion, 0.0, 1.0), clamp(data.Specular, 0.0, 1.0)); - outMaterialID = data.MaterialID; - outObjectID = data.ObjectID; + outMaterialObjectID = uvec2(data.MaterialID, data.ObjectID); } LuxGBufferData DecodeGBuffer( texture2D gBufferBaseColor, texture2D gBufferNormal, texture2D gBufferMetalRoughAO, - utexture2D gBufferMaterialID, - utexture2D gBufferObjectID, + utexture2D gBufferMaterialObjectID, vec2 uv) { LuxGBufferData data; @@ -57,8 +54,9 @@ LuxGBufferData DecodeGBuffer( data.Roughness = max(metalRoughAO.y, 0.05); data.AmbientOcclusion = metalRoughAO.z; data.Specular = metalRoughAO.w; - data.MaterialID = texture(usampler2D(gBufferMaterialID, r_PointSampler), uv).r; - data.ObjectID = texture(usampler2D(gBufferObjectID, r_PointSampler), uv).r; + uvec2 materialObjectID = texture(usampler2D(gBufferMaterialObjectID, r_PointSampler), uv).rg; + data.MaterialID = materialObjectID.x; + data.ObjectID = materialObjectID.y; return data; } From bec0a5a97644c1b97c2d11c77110837b6b45d3d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 02:49:47 +0000 Subject: [PATCH 41/63] Docs: record the GPU bandwidth batch and async-compute/VRS designs Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- docs/ENGINE_OPTIMIZATION_PLAN.md | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index eef957e0..fb83e494 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -153,6 +153,44 @@ silent afterwards. single-producer and NVRHI multi-command-list recording changes the threading model; needs a build+validation cycle. Revisit with the async-compute (B1) work. +**Phase 4 progress — GPU frame cost & bandwidth (2026-07-04):** + +1. **Surgical UAV flag** — STORAGE usage now granted only to Storage-usage images and + mip-chained sampled textures (the compute mip generator); all framebuffer attachments + lose it so framebuffer/DCC compression can re-engage. **The item to measure first** + (RenderDoc: GBuffer/SceneColor no longer report STORAGE; GPU frame time on heavy scenes). +2. **AO composite folded into deferred lighting** — the full-res Zero_SrcColor multiply + pass is gone; deferred samples u_GTAOTex itself (same upscale/decode, same + __HZ_AO_METHOD permutation). GTAO chain now registers between GBuffer and Deferred. + AO debug view unchanged (keeps the AO-Composite shader standalone). +3. **GBuffer ID merge** — material+object IDs packed into one RG32UI attachment + (6→5 color targets; velocity slot 4, depth wrap slot 5). +4. **Empty-pass graph gating** — Selected/Transparent/Wireframe nodes skip registration + when their draw lists are empty (executable graphs only). +5. **Effect defaults** — GTAO denoise baseline 4→2 passes; High preset enables SSR+GTAO + temporal accumulation. + +**Deferred from this batch (design notes):** +- **Octahedral GBuffer normals (RG16F)** — opted-in but deliberately held for its own + session: the normal attachment is read *raw* (`.xyz`) by GTAO.hlsl (HLSL!), SSR.glsl, + SSR-Composite.glsl, AO-Composite.glsl (debug), DeferredLighting's fold helpers, and + written raw by the forward/transparent shader — every one needs the encode/decode pair + landed together, which deserves a fresh, focused diff rather than the tail of this one. + Encode/decode belong in LuxGBuffer.glslh; writers: GBuffer_Static (via EncodeGBuffer) + + the forward PBR shader; readers listed above. +- **B1 async compute (design)**: move GTAO+denoise, cluster light-cull, bloom, and the + cloud raymarch to nvrhi's compute queue, overlapping ShadowMap/PreDepth/GBuffer on + graphics. The render graph already carries the dependency edges — the work is (a) verify + the vendored nvrhi fork's multi-queue API (`CommandQueue::Compute` command lists + + queue semaphores / `executeCommandLists` overloads; submodule wasn't checked out here), + (b) split RT_Submit's single-queue mutex model per queue, (c) insert cross-queue waits + at the graph edges (GTAO→Deferred, cull→lighting, bloom→composite). Validate with the + Renderer Debugger per-pass GPU times: shadow+GBuffer time should absorb the compute. +- **B2 VRS (design)**: raster passes only (cloud/fog/atmosphere composites — the compute + passes can't use VRS); needs the nvrhi fork's variable-rate-shading state API verified. + 2x2 rate on the volumetric composites is the standard cheap win. +- **#8 mesh shaders / ray tracing** — roadmap-final, unchanged. + **Phase 4 candidates (audit findings that need build/measure or shader edits — do with Tracy + validation on):** From 303740b9fed658a6a088fadb480f8c7977bd6c9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 02:59:59 +0000 Subject: [PATCH 42/63] Fix DeferredLighting startup crash from the AO fold Two defects from folding the AO multiply into DeferredLighting.glsl: 1. u_GTAOTex was declared at set 1 binding 4, which collides with the ShadowData UBO from Buffers.glslh (also set 1 binding 4). Vulkan flagged the duplicate descriptor binding and the descriptor manager reported 'Required resource is wrong type' because the two declarations fought over the same slot. Moved to binding 18, the first free slot (4-10 UBOs, 11-17 GBuffer inputs, 22-25 cluster buffers). 2. The deferred-lighting pass Validate() ran before the GTAO images were created, so the reflected u_GTAOTex input was unbound and verification failed fatally. Hoisted the GTAO image creation (and the m_GTAOFinalImage denoise-parity selection) above the deferred-pass creation and bound u_GTAOTex before Validate(). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 59 +++++++++++-------- .../Resources/Shaders/DeferredLighting.glsl | 4 +- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 363ce680..cc6ee4f1 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -1318,6 +1318,32 @@ namespace Lux { LUX_CORE_VERIFY(m_GeometryPassTransparent->Validate()); m_GeometryPassTransparent->Bake(); + // GTAO images are created before the deferred-lighting pass: the AO + // multiply is folded into DeferredLighting.glsl (u_GTAOTex), so the + // pass validates against them at creation. + { + ImageSpecification gtaoImageSpec; + gtaoImageSpec.Format = ImageFormat::RED32UI; + gtaoImageSpec.Usage = ImageUsage::Storage; + gtaoImageSpec.DebugName = "GTAO"; + m_GTAOOutputImage = Image2D::Create(gtaoImageSpec); + + gtaoImageSpec.DebugName = "GTAO-Denoise"; + m_GTAODenoiseImage = Image2D::Create(gtaoImageSpec); + + gtaoImageSpec.Format = ImageFormat::RED8UN; + gtaoImageSpec.DebugName = "GTAO-Edges"; + m_GTAOEdgesOutputImage = Image2D::Create(gtaoImageSpec); + + gtaoImageSpec.Format = ImageFormat::RED32UI; + gtaoImageSpec.DebugName = "GTAO-History-A"; + m_GTAOHistoryImages[0] = Image2D::Create(gtaoImageSpec); + gtaoImageSpec.DebugName = "GTAO-History-B"; + m_GTAOHistoryImages[1] = Image2D::Create(gtaoImageSpec); + + m_GTAOFinalImage = (m_Options.GTAODenoisePasses % 2 != 0) ? m_GTAODenoiseImage : m_GTAOOutputImage; + } + FramebufferSpecification deferredSpec; deferredSpec.Width = m_ViewportWidth; deferredSpec.Height = m_ViewportHeight; @@ -1346,6 +1372,8 @@ namespace Lux { m_DeferredLightingPass->SetInput("u_DepthTexture", m_PreDepthPass->GetDepthOutput()); m_DeferredLightingPass->SetInput("r_PointSampler", Renderer::GetPointSampler()); m_DeferredLightingPass->SetInput("r_LinearSampler", Renderer::GetClampSampler()); + if (m_DeferredLightingPass->IsInputValid("u_GTAOTex")) + m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); LUX_CORE_VERIFY(m_DeferredLightingPass->Validate()); m_DeferredLightingPass->Bake(); m_DeferredLightingMaterial = Material::Create(deferredPipelineSpec.Shader, "DeferredLighting"); @@ -1378,26 +1406,9 @@ namespace Lux { } // ── GTAO + AO composite ─────────────────────────────────────────────── + // (The GTAO images themselves are created earlier, before the + // deferred-lighting pass that samples u_GTAOTex validates.) { - ImageSpecification imageSpec; - imageSpec.Format = ImageFormat::RED32UI; - imageSpec.Usage = ImageUsage::Storage; - imageSpec.DebugName = "GTAO"; - m_GTAOOutputImage = Image2D::Create(imageSpec); - - imageSpec.DebugName = "GTAO-Denoise"; - m_GTAODenoiseImage = Image2D::Create(imageSpec); - - imageSpec.Format = ImageFormat::RED8UN; - imageSpec.DebugName = "GTAO-Edges"; - m_GTAOEdgesOutputImage = Image2D::Create(imageSpec); - - imageSpec.Format = ImageFormat::RED32UI; - imageSpec.DebugName = "GTAO-History-A"; - m_GTAOHistoryImages[0] = Image2D::Create(imageSpec); - imageSpec.DebugName = "GTAO-History-B"; - m_GTAOHistoryImages[1] = Image2D::Create(imageSpec); - Ref gtaoShader = Renderer::GetShaderLibrary()->Get("GTAO"); ComputePassSpecification gtaoSpec; gtaoSpec.DebugName = "GTAO-ComputePass"; @@ -1443,8 +1454,6 @@ namespace Lux { LUX_CORE_VERIFY(m_GTAODenoisePass[1]->Validate()); m_GTAODenoisePass[1]->Bake(); - m_GTAOFinalImage = (m_Options.GTAODenoisePasses % 2 != 0) ? m_GTAODenoiseImage : m_GTAOOutputImage; - Ref gtaoTemporalShader = Renderer::GetShaderLibrary()->Get("GTAO-Temporal"); ComputePassSpecification gtaoTemporalSpec; gtaoTemporalSpec.DebugName = "GTAO-Temporal"; @@ -1461,11 +1470,9 @@ namespace Lux { m_GTAOTemporalPass->Bake(); // The screen-space AO multiply is folded into the deferred lighting - // shader (u_GTAOTex below) — the former AO-Composite full-res - // read-modify-write pass is gone. The AO-Composite shader remains in - // use by the editor's AO debug view. - if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) - m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); + // shader (u_GTAOTex, bound at deferred-pass creation) — the former + // AO-Composite full-res read-modify-write pass is gone. The + // AO-Composite shader remains in use by the editor's AO debug view. // Editor-only AO debug view target — not created in the standalone runtime. if (m_Specification.EnableEditorRenderTargets) diff --git a/Editor/Resources/Shaders/DeferredLighting.glsl b/Editor/Resources/Shaders/DeferredLighting.glsl index c0449e8b..3290d2bd 100644 --- a/Editor/Resources/Shaders/DeferredLighting.glsl +++ b/Editor/Resources/Shaders/DeferredLighting.glsl @@ -39,7 +39,9 @@ layout(set = 1, binding = 1) uniform textureCube u_EnvIrradianceTex; layout(set = 1, binding = 2) uniform texture2DArray u_ShadowMapTexture; layout(set = 1, binding = 3) uniform texture2D u_SpotShadowTexture; #if ENABLED_GTAO -layout(set = 1, binding = 4) uniform utexture2D u_GTAOTex; +// Binding 18: 4-10 are taken by the Buffers.glslh UBOs (ShadowData at 4!), +// 22-25 by the Lighting.glslh cluster buffers, 11-17 by the GBuffer inputs. +layout(set = 1, binding = 18) uniform utexture2D u_GTAOTex; #endif layout(set = 1, binding = 11) uniform texture2D u_SceneColor; layout(set = 1, binding = 12) uniform texture2D u_GBufferBaseColor; From b9af6542906d3dfebc3c008db54b5a53b1e81320 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 16:39:19 +0000 Subject: [PATCH 43/63] Re-bake render/compute pass descriptor sets when their shader reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An in-place shader recompile (VulkanShaderCompiler::TryRecompile) releases the shader's nvrhi binding layouts and rebuilds its reflection, then Renderer::OnShaderReloaded refreshes pipelines and materials — but never the RenderPass/ComputePass descriptor managers. Their baked binding sets kept referencing the released layouts, so the next draw or dispatch through such a pass was a use-after-free. This crashed the editor on first frame after the AO fold: DeferredLighting now references __HZ_AO_METHOD/__HZ_GTAO_COMPUTE_BENT_NORMALS, so when project settings flip a global shader macro at load, DeferredLighting (and SSR, recompiled for the GBuffer include change) reload in place and the deferred pass drew with stale descriptor sets -> 0xC0000005. The landmine was pre-existing for every pass whose shader recompiles at runtime. Fix: passes register in the shader-dependency registry like pipelines and materials, and OnShaderReloaded now calls a new DescriptorSetManager::OnShaderReloaded that rebuilds declarations, input maps and binding-set handle storage from the new reflection, re-applies the previously bound inputs by name (names are stable across permutations, set/binding indexes are not), and re-bakes against the new layouts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- .../Platform/Vulkan/DescriptorSetManager.cpp | 51 +++++++++++++++++++ .../Platform/Vulkan/DescriptorSetManager.h | 5 ++ Core/Source/Lux/Renderer/ComputePass.cpp | 8 +++ Core/Source/Lux/Renderer/ComputePass.h | 3 ++ Core/Source/Lux/Renderer/RenderPass.cpp | 8 +++ Core/Source/Lux/Renderer/RenderPass.h | 3 ++ Core/Source/Lux/Renderer/Renderer.cpp | 33 ++++++++++++ Core/Source/Lux/Renderer/Renderer.h | 2 + 8 files changed, 113 insertions(+) diff --git a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp index b39ac5f3..5852e909 100644 --- a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp +++ b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp @@ -134,6 +134,57 @@ namespace Lux { } } + void DescriptorSetManager::OnShaderReloaded() + { + LUX_PROFILE_FUNCTION_AUTO; + + // An in-place shader recompile released the binding layouts the baked + // sets were created against and may have changed the reflected set/ + // binding map. Rebuild everything from the new reflection, keeping the + // previously bound inputs — names are the stable key across + // permutations, set/binding indexes are not. + std::map savedInputs; + for (const auto& [name, decl] : InputDeclarations) + { + auto setIt = InputResources.find(decl.Set); + if (setIt == InputResources.end()) + continue; + auto bindingIt = setIt->second.find(decl.Binding); + if (bindingIt != setIt->second.end()) + savedInputs[name] = bindingIt->second; + } + + InputDeclarations.clear(); + InputResources.clear(); + InvalidatedInputResources.clear(); + for (auto& frameHandles : m_BindingSetHandles) + frameHandles.clear(); + for (auto& set : m_BindingSets) + set = {}; + + Init(); + + // Re-apply the saved inputs wherever the new reflection still declares + // them. Bindings that vanished from this permutation are dropped; new + // ones keep Init's defaults until the usual SetInput calls fill them. + for (auto& [name, input] : savedInputs) + { + auto declIt = InputDeclarations.find(name); + if (declIt == InputDeclarations.end()) + continue; + const RenderInputDeclaration& decl = declIt->second; + if (input.Input.size() != (size_t)decl.Count) + continue; + + RenderPassInput& target = InputResources[decl.Set][decl.Binding]; + const bool isWriteable = target.IsWriteable; // from the new reflection + target = input; + target.IsWriteable = isWriteable; + } + + Bake(); + } + void DescriptorSetManager::SetInput(std::string_view name, Ref uniformBufferSet) { LUX_PROFILE_FUNCTION_AUTO; diff --git a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h index 9681f804..a392fe42 100644 --- a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h +++ b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h @@ -251,6 +251,11 @@ namespace Lux { // Rebuilds the binding sets of a single descriptor-set index across all // frames in flight (granular alternative to a full Bake). void BakeSet(uint32_t set); + // Rebuilds declarations, input maps and binding sets from the shader's + // current reflection after an in-place shader recompile, preserving + // previously bound inputs by name. Without this, baked binding sets keep + // referencing the binding layouts the recompile released. + void OnShaderReloaded(); std::set HasBufferSets() const; void InvalidateAndUpdate(); diff --git a/Core/Source/Lux/Renderer/ComputePass.cpp b/Core/Source/Lux/Renderer/ComputePass.cpp index ecff9a1b..aa39f23e 100644 --- a/Core/Source/Lux/Renderer/ComputePass.cpp +++ b/Core/Source/Lux/Renderer/ComputePass.cpp @@ -17,6 +17,14 @@ namespace Lux { dmSpec.Shader = spec.Pipeline->GetShader().As(); dmSpec.StartSet = 1; m_DescriptorSetManager = DescriptorSetManager(dmSpec); + + Renderer::RegisterShaderDependency(spec.Pipeline->GetShader(), this); + } + + void ComputePass::OnShaderReloaded() + { + LUX_PROFILE_FUNCTION_AUTO; + m_DescriptorSetManager.OnShaderReloaded(); } bool ComputePass::IsInvalidated(uint32_t set, uint32_t binding) const diff --git a/Core/Source/Lux/Renderer/ComputePass.h b/Core/Source/Lux/Renderer/ComputePass.h index 963cdea7..77166072 100644 --- a/Core/Source/Lux/Renderer/ComputePass.h +++ b/Core/Source/Lux/Renderer/ComputePass.h @@ -44,6 +44,9 @@ namespace Lux { bool Validate(); void Bake(); void Prepare(); + // Called by the Renderer after this pass's shader is recompiled in + // place; re-bakes the descriptor sets against the new binding layouts. + void OnShaderReloaded(); const nvrhi::BindingSetVector& GetBindingSets(uint32_t frameIndex) const { return m_DescriptorSetManager.GetBindingSets(frameIndex); } diff --git a/Core/Source/Lux/Renderer/RenderPass.cpp b/Core/Source/Lux/Renderer/RenderPass.cpp index 0c5560de..d3dd7cae 100644 --- a/Core/Source/Lux/Renderer/RenderPass.cpp +++ b/Core/Source/Lux/Renderer/RenderPass.cpp @@ -17,6 +17,14 @@ namespace Lux { dmSpec.Shader = spec.Pipeline->GetSpecification().Shader.As(); dmSpec.StartSet = spec.StartSet; m_DescriptorSetManager = DescriptorSetManager(dmSpec); + + Renderer::RegisterShaderDependency(spec.Pipeline->GetSpecification().Shader, this); + } + + void RenderPass::OnShaderReloaded() + { + LUX_PROFILE_FUNCTION_AUTO; + m_DescriptorSetManager.OnShaderReloaded(); } bool RenderPass::IsInvalidated(uint32_t set, uint32_t binding) const diff --git a/Core/Source/Lux/Renderer/RenderPass.h b/Core/Source/Lux/Renderer/RenderPass.h index 5f18683d..1df661ea 100644 --- a/Core/Source/Lux/Renderer/RenderPass.h +++ b/Core/Source/Lux/Renderer/RenderPass.h @@ -46,6 +46,9 @@ namespace Lux { bool Validate(); void Bake(); void Prepare(); + // Called by the Renderer after this pass's shader is recompiled in + // place; re-bakes the descriptor sets against the new binding layouts. + void OnShaderReloaded(); bool HasDescriptorSets() const; uint32_t GetBindingSetCount() const { return m_DescriptorSetManager.GetBindingSetCount(); } diff --git a/Core/Source/Lux/Renderer/Renderer.cpp b/Core/Source/Lux/Renderer/Renderer.cpp index 27710649..9ccab3de 100644 --- a/Core/Source/Lux/Renderer/Renderer.cpp +++ b/Core/Source/Lux/Renderer/Renderer.cpp @@ -204,6 +204,8 @@ namespace Lux { std::vector> ComputePipelines; std::vector> Pipelines; std::vector> Materials; + std::vector> Passes; + std::vector> ComputePasses; }; static std::unordered_map s_ShaderDependencies; static std::shared_mutex s_ShaderDependenciesMutex; // ShaderDependencies can be accessed (and modified) from multiple threads, hence require synchronization @@ -293,6 +295,20 @@ namespace Lux { s_ShaderDependencies[shader->GetHash()].Materials.push_back(material); } + void Renderer::RegisterShaderDependency(Ref shader, RenderPass* renderPass) + { + LUX_PROFILE_FUNCTION_AUTO; + std::scoped_lock lock(s_ShaderDependenciesMutex); + s_ShaderDependencies[shader->GetHash()].Passes.push_back(renderPass); + } + + void Renderer::RegisterShaderDependency(Ref shader, ComputePass* computePass) + { + LUX_PROFILE_FUNCTION_AUTO; + std::scoped_lock lock(s_ShaderDependenciesMutex); + s_ShaderDependencies[shader->GetHash()].ComputePasses.push_back(computePass); + } + void Renderer::OnShaderReloaded(size_t hash) { LUX_PROFILE_FUNCTION_AUTO; @@ -304,6 +320,8 @@ namespace Lux { PruneDeadDependencies(it->second.Pipelines); PruneDeadDependencies(it->second.ComputePipelines); PruneDeadDependencies(it->second.Materials); + PruneDeadDependencies(it->second.Passes); + PruneDeadDependencies(it->second.ComputePasses); dependencies = it->second; // Copy weak refs so callbacks run outside the registry lock. } } @@ -324,6 +342,21 @@ namespace Lux { if (material) material->OnShaderReloaded(); } + + // Passes re-bake after the pipelines above are rebuilt: an in-place + // recompile released the binding layouts their baked descriptor sets + // were created against, so drawing with them is a use-after-free. + for (auto& renderPass : dependencies.Passes) + { + if (renderPass) + renderPass->OnShaderReloaded(); + } + + for (auto& computePass : dependencies.ComputePasses) + { + if (computePass) + computePass->OnShaderReloaded(); + } } uint32_t Renderer::RT_GetCurrentFrameIndex() diff --git a/Core/Source/Lux/Renderer/Renderer.h b/Core/Source/Lux/Renderer/Renderer.h index a5c4ea57..2794d77c 100644 --- a/Core/Source/Lux/Renderer/Renderer.h +++ b/Core/Source/Lux/Renderer/Renderer.h @@ -228,6 +228,8 @@ namespace Lux { static void RegisterShaderDependency(Ref shader, PipelineCompute* computePipeline); static void RegisterShaderDependency(Ref shader, Pipeline* pipeline); static void RegisterShaderDependency(Ref shader, Material* material); + static void RegisterShaderDependency(Ref shader, RenderPass* renderPass); + static void RegisterShaderDependency(Ref shader, ComputePass* computePass); static void OnShaderReloaded(size_t hash); static uint32_t GetCurrentFrameIndex(); From 7a6ecfbcde58ebfcd5dc23211a037d48718914cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 16:48:18 +0000 Subject: [PATCH 44/63] Defer pass descriptor rebake on shader reload to the render thread RenderPass::OnShaderReloaded and ComputePass::OnShaderReloaded ran the descriptor-set rebake synchronously on whatever thread triggered the reload (shader hot-reload runs off UpdateDirtyShaders, called directly from Renderer2D::BeginScene rather than through Renderer::Submit). The render thread can still have queued GPU work reading the same pass's binding sets, so mutating them immediately races with that work - exactly the pattern Pipeline::Invalidate and PipelineCompute::CreatePipeline already avoid by deferring their real work via Renderer::Submit (RT_Invalidate / RT_CreatePipeline). This explains why the crash changed shape after the previous fix: the Vulkan validation error went away (the binding/ordering bugs were fixed) but a raw access violation with no validation output took its place - consistent with a CPU-side data race corrupting DescriptorSetManager's state rather than a GPU-visible resource mismatch. Both OnShaderReloaded methods now capture a Ref to themselves and submit the actual m_DescriptorSetManager.OnShaderReloaded() call through Renderer::Submit, mirroring the two existing reload paths exactly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/ComputePass.cpp | 10 +++++++++- Core/Source/Lux/Renderer/RenderPass.cpp | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Core/Source/Lux/Renderer/ComputePass.cpp b/Core/Source/Lux/Renderer/ComputePass.cpp index aa39f23e..fb2d2d74 100644 --- a/Core/Source/Lux/Renderer/ComputePass.cpp +++ b/Core/Source/Lux/Renderer/ComputePass.cpp @@ -24,7 +24,15 @@ namespace Lux { void ComputePass::OnShaderReloaded() { LUX_PROFILE_FUNCTION_AUTO; - m_DescriptorSetManager.OnShaderReloaded(); + // Deferred to the render thread, same as Pipeline::Invalidate and + // PipelineCompute::CreatePipeline: the render thread may still have + // queued GPU work reading this pass's descriptor sets, so rebaking + // them here directly would race with that work. + Ref instance = this; + Renderer::Submit([instance]() mutable + { + instance->m_DescriptorSetManager.OnShaderReloaded(); + }); } bool ComputePass::IsInvalidated(uint32_t set, uint32_t binding) const diff --git a/Core/Source/Lux/Renderer/RenderPass.cpp b/Core/Source/Lux/Renderer/RenderPass.cpp index d3dd7cae..96688eed 100644 --- a/Core/Source/Lux/Renderer/RenderPass.cpp +++ b/Core/Source/Lux/Renderer/RenderPass.cpp @@ -24,7 +24,15 @@ namespace Lux { void RenderPass::OnShaderReloaded() { LUX_PROFILE_FUNCTION_AUTO; - m_DescriptorSetManager.OnShaderReloaded(); + // Deferred to the render thread, same as Pipeline::Invalidate and + // PipelineCompute::CreatePipeline: the render thread may still have + // queued GPU work reading this pass's descriptor sets, so rebaking + // them here directly would race with that work. + Ref instance = this; + Renderer::Submit([instance]() mutable + { + instance->m_DescriptorSetManager.OnShaderReloaded(); + }); } bool RenderPass::IsInvalidated(uint32_t set, uint32_t binding) const From 5178ed1521814b8fed88467b67f1495d6c38ca91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 16:57:47 +0000 Subject: [PATCH 45/63] Diagnostic: log each render-graph pass as it executes on frame 1 The frame-1 access violation has no Vulkan validation output, so it is a CPU-side fault inside a pass execute callback rather than a GPU resource mismatch. Pass names are intentionally not materialized on the per-frame executable graph path (allocation avoidance), so the render-graph warnings only show 'Pass N'. Add a zero-alloc const char* DebugName to PassDesc (pointer to the string-literal name passed to addPass) and log the pass sequence on the first executed frame only. The last 'Executing pass' line before the crash names the faulting pass; a trailing 'executed without crashing' line confirms if frame 1 survived. Temporary - to be removed once the crash is located. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/RenderGraph.cpp | 17 +++++++++++++++++ Core/Source/Lux/Renderer/RenderGraph.h | 4 ++++ Core/Source/Lux/Renderer/SceneRenderer.cpp | 1 + 3 files changed, 22 insertions(+) diff --git a/Core/Source/Lux/Renderer/RenderGraph.cpp b/Core/Source/Lux/Renderer/RenderGraph.cpp index 3ce669bf..b16444b0 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.cpp +++ b/Core/Source/Lux/Renderer/RenderGraph.cpp @@ -603,6 +603,13 @@ namespace Lux { void RenderGraph::Execute(const CompileResult& compileResult) const { + // TEMP crash diagnostic: log the full pass sequence on the first executed + // frame only. The last "Executing pass" line before a crash names the + // pass whose execute callback faulted. Remove once the frame-1 crash is + // resolved. + static bool s_LoggedFirstExecute = false; + const bool logThisExecute = !s_LoggedFirstExecute; + for (uint32_t passIndex : compileResult.ExecutionOrder) { if (passIndex >= m_Passes.size()) @@ -610,7 +617,17 @@ namespace Lux { const PassDesc& pass = m_Passes[passIndex]; if (pass.Execute) + { + if (logThisExecute) + LUX_CORE_INFO_TAG("RenderGraph", "Executing pass [{}] {}", passIndex, pass.DebugName ? pass.DebugName : ""); pass.Execute(); + } + } + + if (logThisExecute) + { + LUX_CORE_INFO_TAG("RenderGraph", "First render-graph frame executed without crashing."); + s_LoggedFirstExecute = true; } } diff --git a/Core/Source/Lux/Renderer/RenderGraph.h b/Core/Source/Lux/Renderer/RenderGraph.h index ce35e1b4..e537f2b0 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.h +++ b/Core/Source/Lux/Renderer/RenderGraph.h @@ -80,6 +80,10 @@ namespace Lux { struct PassDesc { std::string Name; + // Always-set pointer to the pass's string-literal name (zero-alloc, + // unlike Name which is skipped on the per-frame executable path). + // Used for crash diagnostics in Execute(). + const char* DebugName = nullptr; std::vector Reads; std::vector Writes; PassFlags Flags = PassFlags::None; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index cc6ee4f1..7721a6f8 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -3472,6 +3472,7 @@ namespace Lux { RenderGraph::PassDesc pass; pass.Name = graphName(name); + pass.DebugName = name.data(); // string-literal backed; valid for the process lifetime pass.Reads = std::move(reads); pass.Writes = std::move(writes); pass.Flags = execute From 326682f827e62d1887d1dc0e0701ff1246af16d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:08:22 +0000 Subject: [PATCH 46/63] Fix build: move PassDesc.DebugName last so aggregate initializers still compile The diagnostic commit added DebugName after Name in PassDesc, which shifted the positional fields in the render-graph self-test aggregate initializers (graph.AddPass({ "Name", {reads}, {writes}, PassFlags })) and broke the Core build with C2665. Move the field to the end of the struct; the 4-element initializers map to Name/Reads/Writes/Flags again and leave Execute + DebugName defaulted. The addPass lambda sets DebugName via member assignment, so ordering does not affect it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/RenderGraph.h | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Core/Source/Lux/Renderer/RenderGraph.h b/Core/Source/Lux/Renderer/RenderGraph.h index e537f2b0..68e16eb7 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.h +++ b/Core/Source/Lux/Renderer/RenderGraph.h @@ -80,14 +80,16 @@ namespace Lux { struct PassDesc { std::string Name; - // Always-set pointer to the pass's string-literal name (zero-alloc, - // unlike Name which is skipped on the per-frame executable path). - // Used for crash diagnostics in Execute(). - const char* DebugName = nullptr; std::vector Reads; std::vector Writes; PassFlags Flags = PassFlags::None; ExecuteCallback Execute; + // Always-set pointer to the pass's string-literal name (zero-alloc, + // unlike Name which is skipped on the per-frame executable path). + // Used for crash diagnostics in Execute(). Kept LAST so the + // positional aggregate initializers in the self-tests still map to + // Name/Reads/Writes/Flags. + const char* DebugName = nullptr; }; struct ResourceLifetime From 2e261817869676fbca490cd1391e66afc1bf3211 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 17:28:51 +0000 Subject: [PATCH 47/63] Diagnostic: bracket GBuffer BeginRenderPass + draw sub-steps The frame-1 crash is inside the GBuffer pass (pass [9]). Add budget-gated trace logs that bracket every sub-step of the render-pass begin (enter -> per-attachment clear -> depth clear -> commit state -> Prepare -> GetBindingSets -> RT_CommitGraphicsState -> done) and of the GBuffer draw (enter -> bind material -> commit -> drawIndexed[Indirect]). The last logged line before the crash names the exact faulting call. The budgets self-terminate (400 BRP lines, 16 GBuffer-draw lines) so later frames are not spammed; the draw logs are gated to the GBuffer_Static shader so shadow/pre-depth draws do not consume them. Also add the missing ImageFormatToString RG32UI case. Temporary - to be removed with the earlier pass logging once the crash is located. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/Image.h | 1 + Core/Source/Lux/Renderer/Renderer.cpp | 26 ++++++++++++++++++++++ Core/Source/Lux/Renderer/SceneRenderer.cpp | 17 ++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/Core/Source/Lux/Renderer/Image.h b/Core/Source/Lux/Renderer/Image.h index efefb477..7c3af6d4 100644 --- a/Core/Source/Lux/Renderer/Image.h +++ b/Core/Source/Lux/Renderer/Image.h @@ -440,6 +440,7 @@ namespace Lux { case ImageFormat::RED8UI: return "RED8UI"; case ImageFormat::RED16UI: return "RED16UI"; case ImageFormat::RED32UI: return "RED32UI"; + case ImageFormat::RG32UI: return "RG32UI"; case ImageFormat::RED32F: return "RED32F"; case ImageFormat::RG8: return "RG8"; case ImageFormat::RG16F: return "RG16F"; diff --git a/Core/Source/Lux/Renderer/Renderer.cpp b/Core/Source/Lux/Renderer/Renderer.cpp index 9ccab3de..2f6bad04 100644 --- a/Core/Source/Lux/Renderer/Renderer.cpp +++ b/Core/Source/Lux/Renderer/Renderer.cpp @@ -887,6 +887,15 @@ namespace Lux { Ref pipeline = renderPass->GetSpecification().Pipeline; Ref framebuffer = pipeline->GetSpecification().TargetFramebuffer; + // TEMP crash diagnostic (budget-gated so it self-terminates and cannot + // spam later frames). The last "[BRP]" line before a crash names the + // exact sub-step that faulted. Remove once the frame-1 crash is found. + static std::atomic s_BrpLogBudget = 400; + const bool brpLog = s_BrpLogBudget.fetch_sub(1, std::memory_order_relaxed) > 0; + const std::string& brpName = renderPass->GetSpecification().DebugName; + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' enter", brpName); + if (explicitClear || framebuffer->GetSpecification().ClearColorOnLoad || framebuffer->GetSpecification().ClearDepthOnLoad) { const auto& clearValues = framebuffer->GetClearValues(); @@ -894,8 +903,12 @@ namespace Lux { if (explicitClear || framebuffer->GetSpecification().ClearColorOnLoad) { const uint32_t colorAttachmentCount = static_cast(framebuffer->GetColorAttachmentCount()); + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' clearing {} color attachment(s), {} clearValues", brpName, colorAttachmentCount, clearValues.size()); for (uint32_t i = 0; i < colorAttachmentCount; i++) { + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' clear color attachment {}", brpName, i); nvrhi::Color color = nvrhi::Color(clearValues[i].Color.float32[0], clearValues[i].Color.float32[1], clearValues[i].Color.float32[2], clearValues[i].Color.float32[3]); @@ -907,12 +920,17 @@ namespace Lux { { if (framebuffer->HasDepthAttachment()) { + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' clear depth", brpName); const auto& depthStencil = clearValues[clearValues.size() - 1].DepthStencil; nvrhi::utils::ClearDepthStencilAttachment(renderCommandBuffer->GetActive(), framebuffer->GetHandle(), depthStencil.Depth, depthStencil.Stencil); } } } + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' clears done, committing state", brpName); + nvrhi::CommandListHandle commandList = renderCommandBuffer->GetActive(); nvrhi::GraphicsState& graphicsState = renderCommandBuffer->GetGraphicsState(); @@ -942,11 +960,19 @@ namespace Lux { if (renderPass->GetPipeline()->IsDynamicLineWidth()) graphicsState.lineWidth = renderPass->GetPipeline()->GetSpecification().LineWidth; + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' Prepare()", brpName); renderPass->Prepare(); + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' GetBindingSets", brpName); auto bindingSets = renderPass->GetBindingSets(Renderer::RT_GetCurrentFrameIndex()); graphicsState.bindings = bindingSets; + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' RT_CommitGraphicsState", brpName); renderCommandBuffer->RT_CommitGraphicsState(); + if (brpLog) + LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' done", brpName); }); } diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 7721a6f8..b9e4c74d 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -8135,6 +8135,15 @@ namespace Lux { if (!vertexBuffer || !indexBuffer || !vertexBuffer->GetHandle() || !indexBuffer->GetHandle()) return; + // TEMP crash diagnostic: bracket the GBuffer draw sub-steps (gated to the + // GBuffer shader + a small budget so it self-terminates). The last "[DRAW]" + // line before a crash names the faulting sub-step. Remove once resolved. + static std::atomic s_DrawLogBudget = 16; + const bool drawLog = pipelineShader && pipelineShader->GetName() == "GBuffer_Static" + && s_DrawLogBudget.fetch_sub(1, std::memory_order_relaxed) > 0; + if (drawLog) + LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer submesh {} enter", dc.SubmeshIndex); + const auto& submesh = meshSource->GetSubmeshes()[dc.SubmeshIndex]; nvrhi::GraphicsState& gs = cmd->GetGraphicsState(); @@ -8178,10 +8187,14 @@ namespace Lux { if (material) { + if (drawLog) + LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer bind material descriptor set"); Renderer::RT_BindMaterialDescriptorSet(gs.bindings, pipelineShader, material); } } + if (drawLog) + LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer commit graphics state"); cmd->RT_CommitGraphicsState(); // ── Push constants ──────────────────────────────────────────────────── @@ -8206,6 +8219,8 @@ namespace Lux { if (useIndirect && params.IndirectDrawOffsetBytes != std::numeric_limits::max()) { + if (drawLog) + LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer drawIndexedIndirect"); gs.indirectParams = m_SBSIndirectDrawCommands->RT_Get()->GetHandle(); cmd->RT_CommitGraphicsState(); cmd->GetActive()->drawIndexedIndirect(params.IndirectDrawOffsetBytes, 1); @@ -8216,6 +8231,8 @@ namespace Lux { if (instanceCount == 0) return; + if (drawLog) + LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer drawIndexed"); nvrhi::DrawArguments drawArgs{}; drawArgs.vertexCount = submesh.IndexCount; drawArgs.startIndexLocation = submesh.BaseIndex; From c8551a11deee482c4676963bd71c0c6bdb888f0a Mon Sep 17 00:00:00 2001 From: Pier-Olivier Boulianne Date: Sat, 4 Jul 2026 15:38:21 -0400 Subject: [PATCH 48/63] fix: GTAO first-frame crash --- Core/Source/Lux/Renderer/ComputePass.h | 2 +- Core/Source/Lux/Renderer/SceneRenderer.cpp | 152 +++++++++++++----- Core/Source/Lux/Renderer/SceneRenderer.h | 3 + .../Resources/Shaders/DeferredLighting.glsl | 101 ------------ 4 files changed, 115 insertions(+), 143 deletions(-) diff --git a/Core/Source/Lux/Renderer/ComputePass.h b/Core/Source/Lux/Renderer/ComputePass.h index 77166072..438bd7d2 100644 --- a/Core/Source/Lux/Renderer/ComputePass.h +++ b/Core/Source/Lux/Renderer/ComputePass.h @@ -48,7 +48,7 @@ namespace Lux { // place; re-bakes the descriptor sets against the new binding layouts. void OnShaderReloaded(); - const nvrhi::BindingSetVector& GetBindingSets(uint32_t frameIndex) const { return m_DescriptorSetManager.GetBindingSets(frameIndex); } + nvrhi::BindingSetVector GetBindingSets(uint32_t frameIndex) const { return m_DescriptorSetManager.GetBindingSets(frameIndex); } virtual Ref GetPipeline() const; diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index b9e4c74d..6dc92e2e 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -400,6 +400,7 @@ namespace Lux { "GTAO", "GTAO-Denoise", "GTAO-Temporal", + "AOComposite", "PreConvolution", "SSR", "SSR-Temporal", @@ -1318,9 +1319,8 @@ namespace Lux { LUX_CORE_VERIFY(m_GeometryPassTransparent->Validate()); m_GeometryPassTransparent->Bake(); - // GTAO images are created before the deferred-lighting pass: the AO - // multiply is folded into DeferredLighting.glsl (u_GTAOTex), so the - // pass validates against them at creation. + // GTAO images are shared by the compute chain, AO composite, and debug + // views. Keep them alive independently of the optional AO composite pass. { ImageSpecification gtaoImageSpec; gtaoImageSpec.Format = ImageFormat::RED32UI; @@ -1372,8 +1372,6 @@ namespace Lux { m_DeferredLightingPass->SetInput("u_DepthTexture", m_PreDepthPass->GetDepthOutput()); m_DeferredLightingPass->SetInput("r_PointSampler", Renderer::GetPointSampler()); m_DeferredLightingPass->SetInput("r_LinearSampler", Renderer::GetClampSampler()); - if (m_DeferredLightingPass->IsInputValid("u_GTAOTex")) - m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); LUX_CORE_VERIFY(m_DeferredLightingPass->Validate()); m_DeferredLightingPass->Bake(); m_DeferredLightingMaterial = Material::Create(deferredPipelineSpec.Shader, "DeferredLighting"); @@ -1406,8 +1404,6 @@ namespace Lux { } // ── GTAO + AO composite ─────────────────────────────────────────────── - // (The GTAO images themselves are created earlier, before the - // deferred-lighting pass that samples u_GTAOTex validates.) { Ref gtaoShader = Renderer::GetShaderLibrary()->Get("GTAO"); ComputePassSpecification gtaoSpec; @@ -1469,10 +1465,41 @@ namespace Lux { LUX_CORE_VERIFY(m_GTAOTemporalPass->Validate()); m_GTAOTemporalPass->Bake(); - // The screen-space AO multiply is folded into the deferred lighting - // shader (u_GTAOTex, bound at deferred-pass creation) — the former - // AO-Composite full-res read-modify-write pass is gone. The - // AO-Composite shader remains in use by the editor's AO debug view. + FramebufferSpecification aoFramebufferSpec; + aoFramebufferSpec.Width = m_ViewportWidth; + aoFramebufferSpec.Height = m_ViewportHeight; + aoFramebufferSpec.Attachments = { ImageFormat::RGBA16F }; + aoFramebufferSpec.ExistingImages[0] = GetSceneColorOutput(); + aoFramebufferSpec.ClearColorOnLoad = false; + aoFramebufferSpec.Blend = true; + aoFramebufferSpec.BlendMode = FramebufferBlendMode::Zero_SrcColor; + aoFramebufferSpec.DebugName = "AO-Composite"; + + PipelineSpecification aoPipelineSpec; + aoPipelineSpec.DebugName = "AO-Composite"; + aoPipelineSpec.TargetFramebuffer = Framebuffer::Create(aoFramebufferSpec); + aoPipelineSpec.DepthTest = false; + aoPipelineSpec.DepthWrite = false; + aoPipelineSpec.Layout = { + { ShaderDataType::Float3, "a_Position" }, + { ShaderDataType::Float2, "a_TexCoord" }, + }; + aoPipelineSpec.Shader = Renderer::GetShaderLibrary()->Get("AO-Composite"); + + RenderPassSpecification aoRenderPassSpec; + aoRenderPassSpec.DebugName = "AO-Composite"; + aoRenderPassSpec.Pipeline = Pipeline::Create(aoPipelineSpec); + m_AOCompositePass = RenderPass::Create(aoRenderPassSpec); + m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); + m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); + m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); + m_AOCompositePass->SetInput("Camera", m_UBSCamera); + m_AOCompositePass->SetInput("r_DefaultSampler", Renderer::GetDefaultSampler()); + m_AOCompositePass->SetInput("r_PointSampler", Renderer::GetPointSampler()); + m_AOCompositePass->SetInput("r_LinearSampler", Renderer::GetClampSampler()); + LUX_CORE_VERIFY(m_AOCompositePass->Validate()); + m_AOCompositePass->Bake(); + m_AOCompositeMaterial = Material::Create(aoPipelineSpec.Shader, "GTAO-Composite"); // Editor-only AO debug view target — not created in the standalone runtime. if (m_Specification.EnableEditorRenderTargets) @@ -1484,16 +1511,9 @@ namespace Lux { aoDebugFramebufferSpec.ClearColor = { 1.0f, 1.0f, 1.0f, 1.0f }; aoDebugFramebufferSpec.DebugName = "AO-Debug"; - PipelineSpecification aoDebugPipelineSpec; + PipelineSpecification aoDebugPipelineSpec = aoPipelineSpec; aoDebugPipelineSpec.DebugName = "AO-Debug"; aoDebugPipelineSpec.TargetFramebuffer = Framebuffer::Create(aoDebugFramebufferSpec); - aoDebugPipelineSpec.DepthTest = false; - aoDebugPipelineSpec.DepthWrite = false; - aoDebugPipelineSpec.Layout = { - { ShaderDataType::Float3, "a_Position" }, - { ShaderDataType::Float2, "a_TexCoord" }, - }; - aoDebugPipelineSpec.Shader = Renderer::GetShaderLibrary()->Get("AO-Composite"); RenderPassSpecification aoDebugRenderPassSpec; aoDebugRenderPassSpec.DebugName = "AO-Debug"; @@ -1508,7 +1528,7 @@ namespace Lux { m_AODebugPass->SetInput("r_LinearSampler", Renderer::GetClampSampler()); LUX_CORE_VERIFY(m_AODebugPass->Validate()); m_AODebugPass->Bake(); - m_AODebugMaterial = Material::Create(aoDebugPipelineSpec.Shader, "AO-Debug"); + m_AODebugMaterial = Material::Create(aoPipelineSpec.Shader, "AO-Debug"); } } @@ -2537,6 +2557,7 @@ namespace Lux { pass->GetTargetFramebuffer()->Resize(size.x, size.y); }; + resizePass(m_AOCompositePass, viewportSize); resizePass(m_AODebugPass, viewportSize); resizePass(m_SSRCompositePass, viewportSize); resizePass(m_DeferredLightingPass, viewportSize); @@ -2626,8 +2647,12 @@ namespace Lux { }; m_GTAOFinalImage = (m_Options.GTAODenoisePasses % 2 != 0) ? m_GTAODenoiseImage : m_GTAOOutputImage; - if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) - m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); + if (m_AOCompositePass) + { + m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); + m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); + m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); + } if (m_AODebugPass) { m_AODebugPass->SetInput("u_GTAOTex", m_GTAOFinalImage); @@ -3232,6 +3257,7 @@ namespace Lux { addRenderPass(pass); addRenderPass(m_SpotShadowMapPass); addRenderPass(m_PreDepthPass); + addRenderPass(m_AOCompositePass); addRenderPass(m_AODebugPass); addRenderPass(m_SSRCompositePass); addRenderPass(m_DOFPass); @@ -3542,9 +3568,21 @@ namespace Lux { addPass("GBuffer", preDepthOutputs, gbufferOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GBufferPass)); - // GTAO runs on GBuffer depth/normals and is consumed by the deferred - // lighting shader (the former AO-Composite full-res multiply is folded - // into it), so the GTAO chain registers between GBuffer and Deferred. + { + std::vector deferredReads = gbufferOutputs; + appendResources(deferredReads, preDepthOutputs); + appendResources(deferredReads, shadowOutputs); + appendResources(deferredReads, sceneColorCurrent); + std::vector deferredOutputs = addRenderPassResources("Deferred Lighting", m_DeferredLightingPass); + addPass("Deferred Lighting", deferredReads, deferredOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::DeferredLightingPass)); + sceneColorCurrent = deferredOutputs; + + geometryOutputs = gbufferOutputs; + appendResources(geometryOutputs, sceneColorCurrent); + } + + // GTAO runs on GBuffer depth/normals. The fullscreen AO composite then + // multiplies the deferred scene color using the resolved AO image. std::vector aoFinalOutputs; if (m_Options.EnableGTAO) { @@ -3566,17 +3604,17 @@ namespace Lux { aoFinalOutputs.push_back(gtaoHistoryA); aoFinalOutputs.push_back(gtaoHistoryB); } - } - { - std::vector deferredReads = gbufferOutputs; - appendResources(deferredReads, preDepthOutputs); - appendResources(deferredReads, shadowOutputs); - appendResources(deferredReads, sceneColorCurrent); - appendResources(deferredReads, aoFinalOutputs); - std::vector deferredOutputs = addRenderPassResources("Deferred Lighting", m_DeferredLightingPass); - addPass("Deferred Lighting", deferredReads, deferredOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::DeferredLightingPass)); - sceneColorCurrent = deferredOutputs; + if (m_AOCompositePass) + { + std::vector aoCompositeReads = geometryOutputs; + appendResources(aoCompositeReads, preDepthOutputs); + appendResources(aoCompositeReads, aoFinalOutputs); + appendResources(aoCompositeReads, sceneColorCurrent); + std::vector aoCompositeOutputs = addRenderPassResources("AO Composite", m_AOCompositePass); + addPass("AO Composite", aoCompositeReads, aoCompositeOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::AOComposite)); + sceneColorCurrent = aoCompositeOutputs; + } geometryOutputs = gbufferOutputs; appendResources(geometryOutputs, sceneColorCurrent); @@ -3849,6 +3887,7 @@ namespace Lux { if (name == "GBuffer Debug") return "GBufferDebugPass"; if (name == "GTAO Denoise") return "GTAO-Denoise"; if (name == "GTAO Temporal") return "GTAO-Temporal"; + if (name == "AO Composite") return "AOComposite"; if (name == "AO Debug") return "AODebug"; if (name == "Pre-Convolution") return "PreConvolution"; if (name == "SSR Temporal") return "SSR-Temporal"; @@ -4200,6 +4239,7 @@ namespace Lux { recreatePassFramebuffer(m_AtmosphericFogPass); recreatePassFramebuffer(m_SelectedGeometryPass); recreatePassFramebuffer(m_GeometryWireframePass); + recreatePassFramebuffer(m_AOCompositePass); recreatePassFramebuffer(m_AODebugPass); recreatePassFramebuffer(m_SSRCompositePass); recreatePassFramebuffer(m_JumpFloodInitPass); @@ -4390,6 +4430,7 @@ namespace Lux { repairPassIfStale(m_GeometryPass, "GBuffer"); repairPassIfStale(m_GeometryPassTransparent, "TransparentForward"); repairPassIfStale(m_DeferredLightingPass, "DeferredLighting"); + repairPassIfStale(m_AOCompositePass, "AO-Composite"); repairPassIfStale(m_SSRCompositePass, "SSR-Composite"); repairPassIfStale(m_SkyboxPass, "Skybox"); repairPassIfStale(m_SkyAtmospherePass, "SkyAtmosphere"); @@ -7341,8 +7382,12 @@ namespace Lux { if (denoisePasses == 0) { m_GTAOFinalImage = m_GTAOOutputImage; - if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) - m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); + if (m_AOCompositePass) + { + m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); + m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); + m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); + } if (m_SSRPass && m_SSRPass->IsInputValid("u_GTAOTex")) m_SSRPass->SetInput("u_GTAOTex", m_GTAOFinalImage); return; @@ -7365,8 +7410,12 @@ namespace Lux { } m_GTAOFinalImage = (denoisePasses % 2u) != 0u ? m_GTAODenoiseImage : m_GTAOOutputImage; - if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) - m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); + if (m_AOCompositePass) + { + m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); + m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); + m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); + } if (m_SSRPass && m_SSRPass->IsInputValid("u_GTAOTex")) m_SSRPass->SetInput("u_GTAOTex", m_GTAOFinalImage); @@ -7407,12 +7456,33 @@ namespace Lux { m_GTAOHistoryIndex = writeIndex; m_GTAOFinalImage = historyOutput; - if (m_DeferredLightingPass && m_DeferredLightingPass->IsInputValid("u_GTAOTex")) - m_DeferredLightingPass->SetInput("u_GTAOTex", m_GTAOFinalImage); + if (m_AOCompositePass) + { + m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); + m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); + m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); + } if (m_SSRPass && m_SSRPass->IsInputValid("u_GTAOTex")) m_SSRPass->SetInput("u_GTAOTex", m_GTAOFinalImage); } + void SceneRenderer::AOComposite() + { + ScopedCPUProfile cpuProfile(*this, "AOComposite"); + if (!m_AOCompositePass || !m_AOCompositeMaterial || !m_GTAOFinalImage) + return; + + m_AOCompositePass->SetInput("u_GTAOTex", m_GTAOFinalImage); + m_AOCompositePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); + m_AOCompositePass->SetInput("u_Normal", GetGeometryNormalOutput()); + + BeginProfiledGPU("AOComposite"); + Renderer::BeginRenderPass(m_CommandBuffer, m_AOCompositePass); + Renderer::SubmitFullscreenQuad(m_CommandBuffer, m_AOCompositePass->GetPipeline(), m_AOCompositeMaterial); + Renderer::EndRenderPass(m_CommandBuffer); + Renderer::EndGPUPerfMarker(m_CommandBuffer); + } + void SceneRenderer::AODebugPass() { ScopedCPUProfile cpuProfile(*this, "AODebug"); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index e49557af..b95cfad1 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -912,6 +912,7 @@ namespace Lux { void GTAOCompute(); void GTAODenoiseCompute(); void GTAOTemporalAccumulationCompute(); + void AOComposite(); void AODebugPass(); void PreConvolutionCompute(); void SSRCompute(); @@ -1366,6 +1367,8 @@ namespace Lux { glm::uvec3 m_GTAOTemporalWorkGroups{ 1 }; uint32_t m_GTAOHistoryIndex = 0; + Ref m_AOCompositePass; + Ref m_AOCompositeMaterial; Ref m_AODebugPass; Ref m_AODebugMaterial; diff --git a/Editor/Resources/Shaders/DeferredLighting.glsl b/Editor/Resources/Shaders/DeferredLighting.glsl index 3290d2bd..e0302339 100644 --- a/Editor/Resources/Shaders/DeferredLighting.glsl +++ b/Editor/Resources/Shaders/DeferredLighting.glsl @@ -26,9 +26,6 @@ void main() #include #include #include -#include - -#define ENABLED_GTAO (__HZ_AO_METHOD & HZ_AO_METHOD_GTAO) layout(location = 0) in vec2 v_TexCoord; layout(location = 1) in vec2 v_ClipPosition; @@ -38,11 +35,6 @@ layout(set = 1, binding = 0) uniform textureCube u_EnvRadianceTex; layout(set = 1, binding = 1) uniform textureCube u_EnvIrradianceTex; layout(set = 1, binding = 2) uniform texture2DArray u_ShadowMapTexture; layout(set = 1, binding = 3) uniform texture2D u_SpotShadowTexture; -#if ENABLED_GTAO -// Binding 18: 4-10 are taken by the Buffers.glslh UBOs (ShadowData at 4!), -// 22-25 by the Lighting.glslh cluster buffers, 11-17 by the GBuffer inputs. -layout(set = 1, binding = 18) uniform utexture2D u_GTAOTex; -#endif layout(set = 1, binding = 11) uniform texture2D u_SceneColor; layout(set = 1, binding = 12) uniform texture2D u_GBufferBaseColor; layout(set = 1, binding = 13) uniform texture2D u_GBufferNormal; @@ -52,93 +44,6 @@ layout(set = 1, binding = 17) uniform texture2D u_DepthTexture; layout(set = 3, binding = 5) uniform texture2D u_BRDFLUTTexture; -#if ENABLED_GTAO -// Screen-space AO sampling, folded in from the former AO-Composite pass (it -// multiplied the whole scene color in a separate full-res read-modify-write). -// Helpers mirror AO-Composite.glsl, adapted to this pass's bindings. -float GTAO_LinearizeDepth(float screenDepth) -{ - float depthLinearizeMul = u_Camera.DepthUnpackConsts.x; - float depthLinearizeAdd = u_Camera.DepthUnpackConsts.y; - return depthLinearizeMul / (depthLinearizeAdd - screenDepth); -} - -float GTAO_ReadDepth(vec2 uv) -{ - return GTAO_LinearizeDepth(texture(sampler2D(u_DepthTexture, r_PointSampler), uv).r); -} - -vec3 GTAO_ReadNormal(vec2 uv) -{ - vec3 normal = texture(sampler2D(u_GBufferNormal, r_PointSampler), uv).xyz; - float normalLength = length(normal); - if (normalLength < 0.0001) - return vec3(0.0, 0.0, 1.0); - - return normal / normalLength; -} - -float DecodeGTAO(uint packedValue) -{ - #if __HZ_GTAO_COMPUTE_BENT_NORMALS - return float(packedValue >> 24u) / 255.0; - #else - return float(packedValue) / 255.0; - #endif -} - -float FetchGTAO(ivec2 texel) -{ - ivec2 aoSize = textureSize(usampler2D(u_GTAOTex, r_PointSampler), 0); - texel = clamp(texel, ivec2(0), max(aoSize - ivec2(1), ivec2(0))); - return DecodeGTAO(texelFetch(usampler2D(u_GTAOTex, r_PointSampler), texel, 0).x); -} - -float UpscaleGTAO(vec2 uv) -{ - ivec2 aoSize = textureSize(usampler2D(u_GTAOTex, r_PointSampler), 0); - ivec2 depthSize = textureSize(sampler2D(u_DepthTexture, r_PointSampler), 0); - if (aoSize.x >= depthSize.x && aoSize.y >= depthSize.y) - return FetchGTAO(ivec2(clamp(uv * vec2(aoSize), vec2(0.0), vec2(aoSize - ivec2(1))))); - - vec2 aoTexel = uv * vec2(aoSize) - vec2(0.5); - ivec2 baseTexel = ivec2(floor(aoTexel)); - float centerDepth = GTAO_ReadDepth(uv); - vec3 centerNormal = GTAO_ReadNormal(uv); - - float weightedAO = 0.0; - float totalWeight = 0.0; - - for (int y = -1; y <= 2; y++) - { - for (int x = -1; x <= 2; x++) - { - ivec2 sampleTexel = baseTexel + ivec2(x, y); - ivec2 clampedTexel = clamp(sampleTexel, ivec2(0), max(aoSize - ivec2(1), ivec2(0))); - vec2 sampleUV = (vec2(clampedTexel) + vec2(0.5)) / vec2(aoSize); - - float sampleDepth = GTAO_ReadDepth(sampleUV); - vec3 sampleNormal = GTAO_ReadNormal(sampleUV); - vec2 spatialOffset = (vec2(sampleTexel) + vec2(0.5)) - aoTexel; - - float relativeDepthDelta = abs(sampleDepth - centerDepth) / max(abs(centerDepth), 1.0); - float depthWeight = exp(-relativeDepthDelta / 0.035); - float normalWeight = pow(clamp(dot(centerNormal, sampleNormal), 0.0, 1.0), 24.0); - float spatialWeight = exp(-dot(spatialOffset, spatialOffset) * 0.55); - float weight = depthWeight * normalWeight * spatialWeight; - - weightedAO += FetchGTAO(clampedTexel) * weight; - totalWeight += weight; - } - } - - if (totalWeight <= 0.0001) - return FetchGTAO(ivec2(round(aoTexel))); - - return weightedAO / totalWeight; -} -#endif - bool ReconstructPositionFromDepth(float deviceDepth, out vec3 worldPosition, out vec3 viewPosition) { vec4 world = u_Camera.InverseViewProjectionMatrix * vec4(v_ClipPosition, deviceDepth, 1.0); @@ -302,11 +207,5 @@ void main() if (u_RendererData.ShowLightComplexity) color = (color * 0.2) + DebugGradient(float(GetPointLightCount() + GetSpotLightCount())); -#if ENABLED_GTAO - // Matches the former AO-Composite Zero_SrcColor multiply of the whole scene - // color. Sky pixels discard above (their GTAO term is ~1 anyway). - color *= min(UpscaleGTAO(v_TexCoord) * XE_GTAO_OCCLUSION_TERM_SCALE, 1.0); -#endif - o_Color = vec4(color, 1.0); } From da0b97f8c352a66818225caa33dc69f93317a06e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:02:00 +0000 Subject: [PATCH 49/63] Remove TAA: stop creating the resolve pass, buffers, and editor toggle TAA was already off by default (m_Options.EnableTAA defaults false and is not persisted in project settings) and only reachable via the editor's TAA checkbox. Remove it: - Do not create m_TAAResolvePass or the two full-viewport RGBA16F history images at init (saves a compute pipeline + ~2x viewport of VRAM). The members stay null; every consumer null-guards (the resize sweep checks each image, TAAResolvePass early-returns on null pass/history). - Delete the editor TAA toggle (+ its history-blend/sharpness sub-options) so EnableTAA can no longer be set true. Everything else is already gated on m_Options.EnableTAA and goes inert with the flag pinned false: the render-graph TAA node, camera sub-pixel jitter (so the image is no longer jittered), the TAA texture mip-bias, and the resolve dispatch. The TAA shader and TAAResolvePass function are left in place for revertibility. Velocity is untouched (still consumed by the SSR/GTAO/cloud temporal passes). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 34 ++++++--------------- Editor/Source/Panels/SceneRendererPanel.cpp | 8 ++--- 2 files changed, 12 insertions(+), 30 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 6dc92e2e..3d594712 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -2022,31 +2022,15 @@ namespace Lux { m_LuminanceAveragePass->Bake(); } - // ── TAA resolve (history ping-pong, copied back into scene color) ────── - { - ImageSpecification taaSpec; - taaSpec.Format = ImageFormat::RGBA16F; - taaSpec.Usage = ImageUsage::Storage; - taaSpec.DebugName = "TAA-History-A"; - m_TAAHistoryImages[0] = Image2D::Create(taaSpec); - taaSpec.DebugName = "TAA-History-B"; - m_TAAHistoryImages[1] = Image2D::Create(taaSpec); - - ComputePassSpecification taaPassSpec; - taaPassSpec.DebugName = "TAA"; - taaPassSpec.Pipeline = PipelineCompute::Create(Renderer::GetShaderLibrary()->Get("TAA")); - m_TAAResolvePass = ComputePass::Create(taaPassSpec); - m_TAAResolvePass->SetInput("u_SceneColor", GetSceneColorOutput()); - m_TAAResolvePass->SetInput("u_History", m_TAAHistoryImages[0]); - m_TAAResolvePass->SetInput("u_Velocity", GetGeometryVelocityOutput()); - m_TAAResolvePass->SetInput("u_Depth", m_PreDepthPass->GetDepthOutput()); - m_TAAResolvePass->SetInput("o_Resolved", m_TAAHistoryImages[1]); - m_TAAResolvePass->SetInput("Camera", m_UBSCamera); - m_TAAResolvePass->SetInput("r_PointSampler", Renderer::GetPointSampler()); - m_TAAResolvePass->SetInput("r_LinearSampler", Renderer::GetClampSampler()); - LUX_CORE_VERIFY(m_TAAResolvePass->Validate()); - m_TAAResolvePass->Bake(); - } + // ── TAA resolve ─────────────────────────────────────────────────────── + // TAA removed: the resolve compute pass and its two full-viewport history + // images are no longer created (saves the pipeline + ~2×viewport RGBA16F + // of VRAM). m_TAAResolvePass / m_TAAHistoryImages stay null; every TAA + // path is gated on m_Options.EnableTAA (render-graph node, camera jitter, + // texture mip bias, resolve dispatch) and its editor toggle was removed, + // so the flag stays false and all of them are inert. Every consumer of the + // pass/history images is null-guarded (resize sweep + TAAResolvePass early + // return). To restore TAA: recreate the pass here and re-add the toggle. // ── Scene composite (tone-map + exposure + opacity) ─────────────────── { diff --git a/Editor/Source/Panels/SceneRendererPanel.cpp b/Editor/Source/Panels/SceneRendererPanel.cpp index 385b9281..ecca2e2c 100644 --- a/Editor/Source/Panels/SceneRendererPanel.cpp +++ b/Editor/Source/Panels/SceneRendererPanel.cpp @@ -367,11 +367,9 @@ namespace Lux { projectSettingsChanged |= ImGuiEx::Property("SSR Temporal", options.EnableSSRTemporalAccumulation); if (options.EnableSSRTemporalAccumulation) projectSettingsChanged |= ImGuiEx::Property("SSR Temporal Blend", options.SSRTemporalBlend, 0.01f, 0.0f, 0.98f); - projectSettingsChanged |= ImGuiEx::Property("TAA", options.EnableTAA); - if (options.EnableTAA) - projectSettingsChanged |= ImGuiEx::Property("TAA History Blend", options.TAAHistoryBlend, 0.01f, 0.0f, 0.98f); - if (options.EnableTAA) - projectSettingsChanged |= ImGuiEx::Property("TAA Sharpness", options.TAASharpness, 0.01f, 0.0f, 1.0f); + // TAA removed: resolve pass + history buffers are no longer created, + // so the toggle and its blend/sharpness sub-options are gone. EnableTAA + // stays false; re-add these to restore. projectSettingsChanged |= gtaoSettingsChanged; if (gtaoSettingsChanged) m_Context->UpdateGTAOData(); From c9b583c348bf2b733f74af4451f3caf9a8f282a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:21:39 +0000 Subject: [PATCH 50/63] Remove GBuffer-crash diagnostic instrumentation The frame-1 crash is fixed (AO-composite fold reverted). Strip the temporary tracing added to locate it: - RenderGraph::Execute first-frame per-pass logging - [BRP] bracket logs in Renderer::BeginRenderPass - [DRAW] bracket logs in RT_DrawStaticMesh Kept: PassDesc::DebugName (zero-cost, lets the render graph name passes on the executable path for future diagnostics) and the ImageFormatToString RG32UI case (correctness). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/RenderGraph.cpp | 17 -------------- Core/Source/Lux/Renderer/Renderer.cpp | 26 ---------------------- Core/Source/Lux/Renderer/SceneRenderer.cpp | 17 -------------- 3 files changed, 60 deletions(-) diff --git a/Core/Source/Lux/Renderer/RenderGraph.cpp b/Core/Source/Lux/Renderer/RenderGraph.cpp index b16444b0..3ce669bf 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.cpp +++ b/Core/Source/Lux/Renderer/RenderGraph.cpp @@ -603,13 +603,6 @@ namespace Lux { void RenderGraph::Execute(const CompileResult& compileResult) const { - // TEMP crash diagnostic: log the full pass sequence on the first executed - // frame only. The last "Executing pass" line before a crash names the - // pass whose execute callback faulted. Remove once the frame-1 crash is - // resolved. - static bool s_LoggedFirstExecute = false; - const bool logThisExecute = !s_LoggedFirstExecute; - for (uint32_t passIndex : compileResult.ExecutionOrder) { if (passIndex >= m_Passes.size()) @@ -617,17 +610,7 @@ namespace Lux { const PassDesc& pass = m_Passes[passIndex]; if (pass.Execute) - { - if (logThisExecute) - LUX_CORE_INFO_TAG("RenderGraph", "Executing pass [{}] {}", passIndex, pass.DebugName ? pass.DebugName : ""); pass.Execute(); - } - } - - if (logThisExecute) - { - LUX_CORE_INFO_TAG("RenderGraph", "First render-graph frame executed without crashing."); - s_LoggedFirstExecute = true; } } diff --git a/Core/Source/Lux/Renderer/Renderer.cpp b/Core/Source/Lux/Renderer/Renderer.cpp index 2f6bad04..9ccab3de 100644 --- a/Core/Source/Lux/Renderer/Renderer.cpp +++ b/Core/Source/Lux/Renderer/Renderer.cpp @@ -887,15 +887,6 @@ namespace Lux { Ref pipeline = renderPass->GetSpecification().Pipeline; Ref framebuffer = pipeline->GetSpecification().TargetFramebuffer; - // TEMP crash diagnostic (budget-gated so it self-terminates and cannot - // spam later frames). The last "[BRP]" line before a crash names the - // exact sub-step that faulted. Remove once the frame-1 crash is found. - static std::atomic s_BrpLogBudget = 400; - const bool brpLog = s_BrpLogBudget.fetch_sub(1, std::memory_order_relaxed) > 0; - const std::string& brpName = renderPass->GetSpecification().DebugName; - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' enter", brpName); - if (explicitClear || framebuffer->GetSpecification().ClearColorOnLoad || framebuffer->GetSpecification().ClearDepthOnLoad) { const auto& clearValues = framebuffer->GetClearValues(); @@ -903,12 +894,8 @@ namespace Lux { if (explicitClear || framebuffer->GetSpecification().ClearColorOnLoad) { const uint32_t colorAttachmentCount = static_cast(framebuffer->GetColorAttachmentCount()); - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' clearing {} color attachment(s), {} clearValues", brpName, colorAttachmentCount, clearValues.size()); for (uint32_t i = 0; i < colorAttachmentCount; i++) { - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' clear color attachment {}", brpName, i); nvrhi::Color color = nvrhi::Color(clearValues[i].Color.float32[0], clearValues[i].Color.float32[1], clearValues[i].Color.float32[2], clearValues[i].Color.float32[3]); @@ -920,17 +907,12 @@ namespace Lux { { if (framebuffer->HasDepthAttachment()) { - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' clear depth", brpName); const auto& depthStencil = clearValues[clearValues.size() - 1].DepthStencil; nvrhi::utils::ClearDepthStencilAttachment(renderCommandBuffer->GetActive(), framebuffer->GetHandle(), depthStencil.Depth, depthStencil.Stencil); } } } - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' clears done, committing state", brpName); - nvrhi::CommandListHandle commandList = renderCommandBuffer->GetActive(); nvrhi::GraphicsState& graphicsState = renderCommandBuffer->GetGraphicsState(); @@ -960,19 +942,11 @@ namespace Lux { if (renderPass->GetPipeline()->IsDynamicLineWidth()) graphicsState.lineWidth = renderPass->GetPipeline()->GetSpecification().LineWidth; - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' Prepare()", brpName); renderPass->Prepare(); - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' GetBindingSets", brpName); auto bindingSets = renderPass->GetBindingSets(Renderer::RT_GetCurrentFrameIndex()); graphicsState.bindings = bindingSets; - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' RT_CommitGraphicsState", brpName); renderCommandBuffer->RT_CommitGraphicsState(); - if (brpLog) - LUX_CORE_INFO_TAG("Renderer", "[BRP] '{}' done", brpName); }); } diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 3d594712..59d0c579 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -8189,15 +8189,6 @@ namespace Lux { if (!vertexBuffer || !indexBuffer || !vertexBuffer->GetHandle() || !indexBuffer->GetHandle()) return; - // TEMP crash diagnostic: bracket the GBuffer draw sub-steps (gated to the - // GBuffer shader + a small budget so it self-terminates). The last "[DRAW]" - // line before a crash names the faulting sub-step. Remove once resolved. - static std::atomic s_DrawLogBudget = 16; - const bool drawLog = pipelineShader && pipelineShader->GetName() == "GBuffer_Static" - && s_DrawLogBudget.fetch_sub(1, std::memory_order_relaxed) > 0; - if (drawLog) - LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer submesh {} enter", dc.SubmeshIndex); - const auto& submesh = meshSource->GetSubmeshes()[dc.SubmeshIndex]; nvrhi::GraphicsState& gs = cmd->GetGraphicsState(); @@ -8241,14 +8232,10 @@ namespace Lux { if (material) { - if (drawLog) - LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer bind material descriptor set"); Renderer::RT_BindMaterialDescriptorSet(gs.bindings, pipelineShader, material); } } - if (drawLog) - LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer commit graphics state"); cmd->RT_CommitGraphicsState(); // ── Push constants ──────────────────────────────────────────────────── @@ -8273,8 +8260,6 @@ namespace Lux { if (useIndirect && params.IndirectDrawOffsetBytes != std::numeric_limits::max()) { - if (drawLog) - LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer drawIndexedIndirect"); gs.indirectParams = m_SBSIndirectDrawCommands->RT_Get()->GetHandle(); cmd->RT_CommitGraphicsState(); cmd->GetActive()->drawIndexedIndirect(params.IndirectDrawOffsetBytes, 1); @@ -8285,8 +8270,6 @@ namespace Lux { if (instanceCount == 0) return; - if (drawLog) - LUX_CORE_INFO_TAG("Renderer", "[DRAW] GBuffer drawIndexed"); nvrhi::DrawArguments drawArgs{}; drawArgs.vertexCount = submesh.IndexCount; drawArgs.startIndexLocation = submesh.BaseIndex; From d98265cf827316738aa41751254fdc19f62b88a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:31:48 +0000 Subject: [PATCH 51/63] Remove Sky Atmosphere, Volumetric Clouds, and Atmospheric Fog These three share one creation block, so they're cut together. No longer created at init: the sky-atmosphere / cloud / cloud-composite / fog fullscreen passes, the cloud noise-bake passes + three 3D noise volumes (128^3 + 2x 32^3 RGBA16F), the cloud temporal pass + half-res history ping-pong, and all their pipelines/materials/framebuffers. Reclaims that VRAM and removes their per-frame GPU cost (cloud raymarch was the heaviest optional pass). The members stay null and every path is inert: the render-graph nodes gate on the pass existing (+ frame flags), the execute functions early-return on null, and the resize/repair sweeps null-check. Added the one missing guard in RecreateRenderTargetFramebuffers where the three composite framebuffers were resized unconditionally. Shaders and pass functions are kept on disk for revertibility; the skybox path is untouched, so scenes still get a sky. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 202 ++------------------- 1 file changed, 16 insertions(+), 186 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 59d0c579..10b9b01a 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -1778,189 +1778,14 @@ namespace Lux { } // ── Atmosphere, procedural clouds and fog ──────────────────────────── - { - auto createFullscreenPass = [&](const char* debugName, const char* shaderName, const FramebufferSpecification& framebufferSpec, bool bindDepth, const std::function&)>& bindExtra) - { - PipelineSpecification pipelineSpec; - pipelineSpec.DebugName = debugName; - pipelineSpec.Shader = Renderer::GetShaderLibrary()->Get(shaderName); - pipelineSpec.TargetFramebuffer = Framebuffer::Create(framebufferSpec); - pipelineSpec.DepthWrite = false; - pipelineSpec.DepthTest = false; - pipelineSpec.Layout = { - { ShaderDataType::Float3, "a_Position" }, - { ShaderDataType::Float2, "a_TexCoord" } - }; - - Ref pipeline = Pipeline::Create(pipelineSpec); - - RenderPassSpecification rpSpec; - rpSpec.DebugName = debugName; - rpSpec.Pipeline = pipeline; - Ref pass = RenderPass::Create(rpSpec); - BindCommonSceneRenderPassInputs(pass, bindDepth); - if (bindExtra) - bindExtra(pass); - LUX_CORE_VERIFY(pass->Validate()); - pass->Bake(); - - return std::pair, Ref>{ pipeline, pass }; - }; - - auto createSceneColorFramebufferSpec = [&](const char* debugName, bool enableBlending) - { - FramebufferSpecification fbSpec; - fbSpec.Width = m_ViewportWidth; - fbSpec.Height = m_ViewportHeight; - fbSpec.ExistingImages[0] = GetSceneColorOutput(); - fbSpec.Attachments = { ImageFormat::RGBA16F }; - fbSpec.ClearColorOnLoad = false; - fbSpec.ClearDepthOnLoad = false; - fbSpec.Blend = enableBlending; - fbSpec.BlendMode = enableBlending ? FramebufferBlendMode::SrcAlphaOneMinusSrcAlpha : FramebufferBlendMode::OneZero; - fbSpec.DebugName = debugName; - return fbSpec; - }; - - auto [skyPipeline, skyPass] = createFullscreenPass("SkyAtmosphere", "SkyAtmosphere", createSceneColorFramebufferSpec("SkyAtmosphere", false), false, {}); - m_SkyAtmospherePipeline = skyPipeline; - m_SkyAtmospherePass = skyPass; - m_SkyAtmosphereMaterial = Material::Create(skyPipeline->GetShader(), "SkyAtmosphere"); - - m_CloudRenderScale = SanitizeCloudRenderScale(ResolveFrameEnvironment().Atmosphere.VolumetricClouds.RenderScale); - m_CloudRenderSize = CalculateVolumetricCloudRenderSize(); - - // Baked tileable 3D noise volumes (generated once on the first frame). - { - auto createNoiseVolume = [](const char* debugName, uint32_t size) - { - ImageSpecification spec; - spec.DebugName = debugName; - spec.Dimension = nvrhi::TextureDimension::Texture3D; - spec.Format = ImageFormat::RGBA16F; - spec.Usage = ImageUsage::Storage; - spec.Width = size; - spec.Height = size; - spec.Depth = size; - spec.Mips = 1; - spec.Layers = 1; - spec.CreateSampler = false; - Ref image = Image2D::Create(spec); - image->Invalidate(); - return image; - }; - m_CloudBaseShapeVolume = createNoiseVolume("CloudBaseShape", 128); - m_CloudDetailVolume = createNoiseVolume("CloudDetail", 32); - m_CloudCurlVolume = createNoiseVolume("CloudCurl", 32); - - ComputePassSpecification baseBakeSpec; - baseBakeSpec.DebugName = "CloudNoiseBaseShapeBake"; - baseBakeSpec.Pipeline = PipelineCompute::Create(Renderer::GetShaderLibrary()->Get("CloudNoiseBaseShape")); - m_CloudBaseShapeBakePass = ComputePass::Create(baseBakeSpec); - m_CloudBaseShapeBakePass->SetInput("o_NoiseVolume", m_CloudBaseShapeVolume); - - ComputePassSpecification detailBakeSpec; - detailBakeSpec.DebugName = "CloudNoiseDetailBake"; - detailBakeSpec.Pipeline = PipelineCompute::Create(Renderer::GetShaderLibrary()->Get("CloudNoiseDetail")); - m_CloudDetailBakePass = ComputePass::Create(detailBakeSpec); - m_CloudDetailBakePass->SetInput("o_NoiseVolume", m_CloudDetailVolume); - - ComputePassSpecification curlBakeSpec; - curlBakeSpec.DebugName = "CloudNoiseCurlBake"; - curlBakeSpec.Pipeline = PipelineCompute::Create(Renderer::GetShaderLibrary()->Get("CloudNoiseCurl")); - m_CloudCurlBakePass = ComputePass::Create(curlBakeSpec); - m_CloudCurlBakePass->SetInput("o_NoiseVolume", m_CloudCurlVolume); - - if (m_CloudBaseShapeBakePass->Validate()) - m_CloudBaseShapeBakePass->Bake(); - if (m_CloudDetailBakePass->Validate()) - m_CloudDetailBakePass->Bake(); - if (m_CloudCurlBakePass->Validate()) - m_CloudCurlBakePass->Bake(); - m_CloudNoiseBaked = false; - } - - FramebufferSpecification cloudSpec; - cloudSpec.Width = m_CloudRenderSize.x; - cloudSpec.Height = m_CloudRenderSize.y; - cloudSpec.Attachments = { - ImageFormat::RGBA16F, // cloud color + transmittance - ImageFormat::RGBA16F // front depth, scene depth, trace min/max - }; - cloudSpec.ClearColor = { 0.0f, 0.0f, 0.0f, 0.0f }; - cloudSpec.ClearColorOnLoad = true; - cloudSpec.ClearDepthOnLoad = false; - cloudSpec.Blend = false; - cloudSpec.BlendMode = FramebufferBlendMode::OneZero; - cloudSpec.DebugName = "VolumetricClouds"; - - auto [cloudPipeline, cloudPass] = createFullscreenPass("VolumetricClouds", "VolumetricClouds", cloudSpec, true, - [&](const Ref& pass) - { - SetRenderPassInputIfValid(pass, "u_CloudBaseShape", m_CloudBaseShapeVolume); - SetRenderPassInputIfValid(pass, "u_CloudDetail", m_CloudDetailVolume); - SetRenderPassInputIfValid(pass, "u_CloudCurl", m_CloudCurlVolume); - }); - m_VolumetricCloudPipeline = cloudPipeline; - m_VolumetricCloudPass = cloudPass; - m_VolumetricCloudMaterial = Material::Create(cloudPipeline->GetShader(), "VolumetricClouds"); - - // Temporal scattering integration: half-res history (ping-pong) + resolve pass. - { - auto createCloudHistory = [&](const char* debugName) - { - ImageSpecification spec; - spec.DebugName = debugName; - spec.Format = ImageFormat::RGBA16F; - spec.Usage = ImageUsage::Storage; - spec.Width = glm::max(m_CloudRenderSize.x, 1u); - spec.Height = glm::max(m_CloudRenderSize.y, 1u); - Ref image = Image2D::Create(spec); - image->Invalidate(); - return image; - }; - m_CloudHistoryImages[0] = createCloudHistory("CloudHistory-A"); - m_CloudHistoryImages[1] = createCloudHistory("CloudHistory-B"); - - ComputePassSpecification temporalSpec; - temporalSpec.DebugName = "VolumetricCloudTemporal"; - temporalSpec.Pipeline = PipelineCompute::Create(Renderer::GetShaderLibrary()->Get("VolumetricCloudTemporal")); - m_VolumetricCloudTemporalPass = ComputePass::Create(temporalSpec); - m_VolumetricCloudTemporalPass->SetInput("u_CurrentCloud", m_VolumetricCloudPass->GetOutput(0)); - m_VolumetricCloudTemporalPass->SetInput("u_CurrentCloudDepth", m_VolumetricCloudPass->GetOutput(1)); - m_VolumetricCloudTemporalPass->SetInput("u_HistoryCloud", m_CloudHistoryImages[0]); - m_VolumetricCloudTemporalPass->SetInput("o_ResolvedCloud", m_CloudHistoryImages[1]); - m_VolumetricCloudTemporalPass->SetInput("Camera", m_UBSCamera); - m_VolumetricCloudTemporalPass->SetInput("SceneData", m_UBSScene); - m_VolumetricCloudTemporalPass->SetInput("r_PointSampler", Renderer::GetPointSampler()); - m_VolumetricCloudTemporalPass->SetInput("r_LinearSampler", Renderer::GetClampSampler()); - if (m_VolumetricCloudTemporalPass->Validate()) - m_VolumetricCloudTemporalPass->Bake(); - m_CloudHistoryValid = false; - } - - auto [cloudCompositePipeline, cloudCompositePass] = createFullscreenPass("VolumetricCloudComposite", "VolumetricCloudComposite", createSceneColorFramebufferSpec("VolumetricCloudComposite", true), true, - [&](const Ref& pass) - { - SetRenderPassInputIfValid(pass, "u_CloudTexture", m_CloudHistoryImages[1]); - SetRenderPassInputIfValid(pass, "u_CloudDepthTexture", m_VolumetricCloudPass->GetOutput(1)); - }); - m_VolumetricCloudCompositePipeline = cloudCompositePipeline; - m_VolumetricCloudCompositePass = cloudCompositePass; - m_VolumetricCloudCompositeMaterial = Material::Create(cloudCompositePipeline->GetShader(), "VolumetricCloudComposite"); - - auto [fogPipeline, fogPass] = createFullscreenPass("AtmosphericFog", "AtmosphericFog", createSceneColorFramebufferSpec("AtmosphericFog", true), true, - [&](const Ref& pass) - { - // Volumetric fog now scatters clustered point/spot lights (FogClusterLights.glslh); - // the cluster lists + light UBOs already come from PassInputCommonScene, this adds - // the spot shadow atlas so the in-fog spot shafts are shadowed. - BindSceneRenderPassInputs(pass, PassInputShadowMaps); - }); - m_AtmosphericFogPipeline = fogPipeline; - m_AtmosphericFogPass = fogPass; - m_AtmosphericFogMaterial = Material::Create(fogPipeline->GetShader(), "AtmosphericFog"); - } + // REMOVED: Sky Atmosphere, Volumetric Clouds, and Atmospheric/Height Fog + // are cut. Their passes, pipelines, materials, 3D cloud-noise volumes, + // cloud history buffers and framebuffers are no longer created; the + // members stay null and every path goes inert -- the render-graph nodes + // (gated on the pass existing), the execute functions (early-return on + // null) and the resize/repair sweeps (all null-guarded). This reclaims + // their VRAM and per-frame GPU cost. The shaders and pass functions are + // kept on disk; recreate this block to restore. // ── Bloom compute (feeds the scene composite) ───────────────────────── { @@ -4365,9 +4190,14 @@ namespace Lux { if (m_GBufferDebugPass) m_GBufferDebugPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); m_SkyboxPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); - m_SkyAtmospherePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); - m_VolumetricCloudCompositePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); - m_AtmosphericFogPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); + // Sky Atmosphere / Volumetric Clouds / Atmospheric Fog removed — may be + // null now, so guard the resize (the other sweeps already null-check). + if (m_SkyAtmospherePass) + m_SkyAtmospherePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); + if (m_VolumetricCloudCompositePass) + m_VolumetricCloudCompositePass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); + if (m_AtmosphericFogPass) + m_AtmosphericFogPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); if (m_SelectedGeometryPass) m_SelectedGeometryPass->GetTargetFramebuffer()->Resize(m_ViewportWidth, m_ViewportHeight); if (m_GeometryWireframePass) From e2afc27a9e57a41dc75bfbd2fbac38722c676c6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:32:49 +0000 Subject: [PATCH 52/63] Cap Cinematic shadow atlas at 2K (was 8K) The Cinematic quality preset used an 8K directional shadow atlas; drop it to 2K to match the other presets' shadow budget (High=4K, Medium/Low=2K/1K). An 8K atlas is a heavy per-frame shadow-render + memory cost with little visible payoff at this scene scale. Resolution only; cascade count and shadow distance unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 10b9b01a..9220e89d 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -659,7 +659,10 @@ namespace Lux { m_Options.DistanceMipBiasStart = 10.0f; m_Options.DistanceMipBiasEnd = 100.0f; m_Options.DistanceMipBiasMax = 0.5f; - m_Options.ShadowResolution = SceneRendererOptions::ShadowResolutionTier::Tier_8K; + // Shadow atlas capped at 2K even on Cinematic — an 8K directional atlas + // is a large per-frame shadow-pass cost for little visible gain at this + // scene scale. Bump back to Tier_8K here if you need crisper distant shadows. + m_Options.ShadowResolution = SceneRendererOptions::ShadowResolutionTier::Tier_2K; m_Options.MaxShadowDistance = 450.0f; m_Options.ShadowFade = 50.0f; m_DOFSettings.ResolutionScale = SceneRendererOptions::EffectResolutionScale::Full; From b9f2550991d7856191fc14c127efa1514d1855da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 22:48:34 +0000 Subject: [PATCH 53/63] Cache the mip-generation compute pipeline instead of rebuilding it per texture Texture2D/TextureCube::GenerateMips built a fresh LinearSample / LinearSampleUInt PipelineCompute for every texture, so loading a project produced hundreds of redundant 'Creating compute pipeline' driver builds (the bulk of the first-launch/asset-load stutter and log spam). Add Renderer::GetOrCreateMipGenPipeline(shader): a process-wide, mutex-guarded cache keyed by shader hash that builds each mip-gen pipeline once and hands the same object to every GenerateMips call. The per-texture ComputePass + per-mip Material bindings are unchanged, so each texture still mips against its own image views. Cleared in Renderer::Shutdown before device teardown. Net: the compute mip generator is built twice total (float + uint) instead of once per texture -> hundreds fewer pipeline builds at load. (A cross-run on-disk cache for the ~50 real render pipelines is separate: nvrhi owns pipeline creation, so it needs submodule changes.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/Renderer.cpp | 26 ++++++++++++++++++++++++++ Core/Source/Lux/Renderer/Renderer.h | 6 ++++++ Core/Source/Lux/Renderer/Texture.cpp | 4 ++-- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Core/Source/Lux/Renderer/Renderer.cpp b/Core/Source/Lux/Renderer/Renderer.cpp index 9ccab3de..099ed94a 100644 --- a/Core/Source/Lux/Renderer/Renderer.cpp +++ b/Core/Source/Lux/Renderer/Renderer.cpp @@ -199,6 +199,12 @@ namespace Lux { static std::unordered_map> s_PipelineCache; + // Cache of compute pipelines keyed by shader hash, shared across the whole + // process. Currently only the mip generator (LinearSample / LinearSampleUInt) + // uses it; before, GenerateMips built a fresh pipeline per texture. + static std::unordered_map> s_MipGenPipelineCache; + static std::mutex s_MipGenPipelineCacheMutex; + struct ShaderDependencies { std::vector> ComputePipelines; @@ -309,6 +315,19 @@ namespace Lux { s_ShaderDependencies[shader->GetHash()].ComputePasses.push_back(computePass); } + Ref Renderer::GetOrCreateMipGenPipeline(Ref shader) + { + LUX_PROFILE_FUNCTION_AUTO; + const size_t hash = shader->GetHash(); + std::scoped_lock lock(s_MipGenPipelineCacheMutex); + if (auto it = s_MipGenPipelineCache.find(hash); it != s_MipGenPipelineCache.end()) + return it->second; + + Ref pipeline = PipelineCompute::Create(shader); + s_MipGenPipelineCache[hash] = pipeline; + return pipeline; + } + void Renderer::OnShaderReloaded(size_t hash) { LUX_PROFILE_FUNCTION_AUTO; @@ -651,6 +670,13 @@ namespace Lux { s_ShaderDependencies.clear(); } + { + // Release the cached mip-gen compute pipelines before device teardown + // (their deferred frees are drained by the release queues below). + std::scoped_lock lock(s_MipGenPipelineCacheMutex); + s_MipGenPipelineCache.clear(); + } + auto* deviceManager = Application::Get().GetWindow().GetDeviceManager(); nvrhi::DeviceHandle graphicsDevice = deviceManager ? deviceManager->GetDevice() : nullptr; diff --git a/Core/Source/Lux/Renderer/Renderer.h b/Core/Source/Lux/Renderer/Renderer.h index 2794d77c..e1075f9d 100644 --- a/Core/Source/Lux/Renderer/Renderer.h +++ b/Core/Source/Lux/Renderer/Renderer.h @@ -232,6 +232,12 @@ namespace Lux { static void RegisterShaderDependency(Ref shader, ComputePass* computePass); static void OnShaderReloaded(size_t hash); + // Returns a process-wide cached compute pipeline for the given shader, + // creating it on first use. Used by Texture GenerateMips so the + // LinearSample / LinearSampleUInt mip generator is built once instead of + // once per texture (was hundreds of redundant pipeline builds at load). + static Ref GetOrCreateMipGenPipeline(Ref shader); + static uint32_t GetCurrentFrameIndex(); static uint32_t RT_GetCurrentFrameIndex(); diff --git a/Core/Source/Lux/Renderer/Texture.cpp b/Core/Source/Lux/Renderer/Texture.cpp index 4c584e73..f485c6cc 100644 --- a/Core/Source/Lux/Renderer/Texture.cpp +++ b/Core/Source/Lux/Renderer/Texture.cpp @@ -652,7 +652,7 @@ namespace Lux { ComputePassSpecification spec; spec.DebugName = "LinearSample"; - spec.Pipeline = PipelineCompute::Create(shader); + spec.Pipeline = Renderer::GetOrCreateMipGenPipeline(shader); // cached: built once, not per texture Ref computePass = ComputePass::Create(spec); renderCommandBuffer->Begin(); @@ -1223,7 +1223,7 @@ namespace Lux { ComputePassSpecification spec; spec.DebugName = "LinearSample"; - spec.Pipeline = PipelineCompute::Create(shader); + spec.Pipeline = Renderer::GetOrCreateMipGenPipeline(shader); // cached: built once, not per texture Ref computePass = ComputePass::Create(spec); renderCommandBuffer->Begin(); From 445f92f667d1918eb230a3ab2b3d4581014e594b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:13:01 +0000 Subject: [PATCH 54/63] Async compute (1/N): enable the compute queue + cross-queue plumbing Foundation for scheduling independent compute passes on the async compute queue. No pass is wired yet, so runtime behavior is unchanged except that nvrhi is now given a compute queue. - Window.cpp: deviceParams.enableComputeQueue = true, so the device passes a compute VkQueue to nvrhi (previously null -> executeCommandList on the compute queue was impossible). Desktop NVIDIA always has a compute queue family, so device creation still succeeds. - RenderCommandBuffer: can now be created for a specific nvrhi::CommandQueue (Graphics default, so existing buffers are unchanged). Command lists are created with CommandListParameters().setQueueType(queue); RT_Submit runs executeCommandList(list, queue) and records the returned execution instance (GetLastExecutionInstance()). - Renderer::QueueWaitForCommandList(waitQueue, execQueue, instance): thin wrapper over IDevice::queueWaitForCommandList for cross-queue ordering (no manual semaphores). - SceneRendererOptions::EnableAsyncCompute (default false): the master switch the per-pass wiring will honor next. Next: route cluster light-culling onto a compute RenderCommandBuffer with one compute->graphics sync before deferred lighting, gated on the flag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Core/Window.cpp | 5 +++++ .../Lux/Renderer/RenderCommandBuffer.cpp | 19 ++++++++++++++----- .../Source/Lux/Renderer/RenderCommandBuffer.h | 14 ++++++++++++-- Core/Source/Lux/Renderer/Renderer.cpp | 8 ++++++++ Core/Source/Lux/Renderer/Renderer.h | 7 +++++++ Core/Source/Lux/Renderer/SceneRenderer.h | 5 +++++ 6 files changed, 51 insertions(+), 7 deletions(-) diff --git a/Core/Source/Lux/Core/Window.cpp b/Core/Source/Lux/Core/Window.cpp index 2008ce5b..36194058 100644 --- a/Core/Source/Lux/Core/Window.cpp +++ b/Core/Source/Lux/Core/Window.cpp @@ -92,6 +92,11 @@ namespace Lux { deviceParams.Decorated = m_Specification.Decorated; deviceParams.swapChainBufferCount = 3; deviceParams.enableRayTracingExtensions = true; + // Give nvrhi a dedicated compute queue so render passes can be scheduled + // async on it (GTAO / SSR / light-cull / bloom overlapping graphics work). + // Desktop NVIDIA always exposes a compute-capable queue family, so device + // creation still succeeds; nothing submits async until EnableAsyncCompute. + deviceParams.enableComputeQueue = true; deviceParams.maxFramesInFlight = 1; deviceParams.backBufferWidth = m_Specification.Width; deviceParams.backBufferHeight = m_Specification.Height; diff --git a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp index 2ca37dd3..cdf025a2 100644 --- a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp +++ b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp @@ -12,8 +12,8 @@ namespace Lux { static std::mutex s_GraphicsQueueMutex; - RenderCommandBuffer::RenderCommandBuffer(uint32_t count, bool enableQueries, const std::string& debugName) - : m_DebugName(debugName) + RenderCommandBuffer::RenderCommandBuffer(uint32_t count, bool enableQueries, const std::string& debugName, nvrhi::CommandQueue queue) + : m_Queue(queue), m_DebugName(debugName) { if (count == 0) { @@ -23,9 +23,15 @@ namespace Lux { auto device = Application::GetGraphicsDevice(); + // Command lists are bound to a queue type at creation; a Compute list can + // only be executed on the compute queue (COPY/COMPUTE expose a subset of + // methods). Graphics (the default) is unchanged from before. + nvrhi::CommandListParameters clParams; + clParams.setQueueType(m_Queue); + for (uint32_t i = 0; i < count; i++) { - m_CommandLists.push_back(device->createCommandList()); + m_CommandLists.push_back(device->createCommandList(clParams)); m_PipelineStatisticsQueryResults.emplace_back(); } @@ -233,9 +239,12 @@ namespace Lux { if (waitSemaphore) { auto vulkanDevice = (nvrhi::vulkan::IDevice*)device.Get(); - vulkanDevice->queueWaitForSemaphore(nvrhi::CommandQueue::Graphics, waitSemaphore, 0); + vulkanDevice->queueWaitForSemaphore(m_Queue, waitSemaphore, 0); } - device->executeCommandList(m_CommandLists[commandBufferIndex]); + // Execute on this buffer's queue (Graphics unless this is a compute + // command buffer) and keep the returned instance id so another queue can + // wait on it via Renderer::QueueWaitForCommandList. + m_LastExecutionInstance = device->executeCommandList(m_CommandLists[commandBufferIndex], m_Queue); UnlockQueue(); #ifdef CMD_BUFFER_USE_VULKAN_QUERIES diff --git a/Core/Source/Lux/Renderer/RenderCommandBuffer.h b/Core/Source/Lux/Renderer/RenderCommandBuffer.h index 9ab1b5c9..ecc2f8c4 100644 --- a/Core/Source/Lux/Renderer/RenderCommandBuffer.h +++ b/Core/Source/Lux/Renderer/RenderCommandBuffer.h @@ -19,7 +19,7 @@ namespace Lux { class RenderCommandBuffer : public RefCounted { public: - static Ref Create(uint32_t count = 0, const std::string& debugName = "", bool enableQueries = false) { return Ref::Create(count, enableQueries, debugName); } + static Ref Create(uint32_t count = 0, const std::string& debugName = "", bool enableQueries = false, nvrhi::CommandQueue queue = nvrhi::CommandQueue::Graphics) { return Ref::Create(count, enableQueries, debugName, queue); } void Begin(); void End(); @@ -48,6 +48,13 @@ namespace Lux { nvrhi::CommandListHandle GetActive() const { return m_ActiveCommandBuffer; } nvrhi::CommandListHandle Get(uint32_t index = 0) const { LUX_CORE_VERIFY(index < m_CommandLists.size()); return m_CommandLists[index]; } + // The queue this command buffer records/submits on (Graphics by default). + nvrhi::CommandQueue GetQueue() const { return m_Queue; } + // The nvrhi execution-instance id returned by the most recent submit on this + // buffer's queue. Feed it to Renderer::QueueWaitForCommandList so another + // queue can wait for this buffer's work to finish (cross-queue sync). + uint64_t GetLastExecutionInstance() const { return m_LastExecutionInstance; } + float GetExecutionGPUTime(uint32_t frameIndex) const; const PipelineStatistics& GetPipelineStatistics(uint32_t frameIndex) const; @@ -59,9 +66,12 @@ namespace Lux { static void LockQueue(); static void UnlockQueue(); public: - RenderCommandBuffer(uint32_t count, bool enableQueries, const std::string& debugName); + RenderCommandBuffer(uint32_t count, bool enableQueries, const std::string& debugName, nvrhi::CommandQueue queue = nvrhi::CommandQueue::Graphics); virtual ~RenderCommandBuffer() = default; private: + nvrhi::CommandQueue m_Queue = nvrhi::CommandQueue::Graphics; + uint64_t m_LastExecutionInstance = 0; + nvrhi::static_vector m_CommandLists; nvrhi::static_vector m_TimerQueries; nvrhi::static_vector m_GPUWorkTimes; diff --git a/Core/Source/Lux/Renderer/Renderer.cpp b/Core/Source/Lux/Renderer/Renderer.cpp index 099ed94a..d3342f36 100644 --- a/Core/Source/Lux/Renderer/Renderer.cpp +++ b/Core/Source/Lux/Renderer/Renderer.cpp @@ -315,6 +315,14 @@ namespace Lux { s_ShaderDependencies[shader->GetHash()].ComputePasses.push_back(computePass); } + void Renderer::QueueWaitForCommandList(nvrhi::CommandQueue waitQueue, nvrhi::CommandQueue executionQueue, uint64_t instance) + { + LUX_PROFILE_FUNCTION_AUTO; + // nvrhi tracks a completion timeline per queue; this inserts the wait on + // waitQueue for executionQueue's instance without any manual semaphores. + Application::GetGraphicsDevice()->queueWaitForCommandList(waitQueue, executionQueue, instance); + } + Ref Renderer::GetOrCreateMipGenPipeline(Ref shader) { LUX_PROFILE_FUNCTION_AUTO; diff --git a/Core/Source/Lux/Renderer/Renderer.h b/Core/Source/Lux/Renderer/Renderer.h index e1075f9d..4abb50dd 100644 --- a/Core/Source/Lux/Renderer/Renderer.h +++ b/Core/Source/Lux/Renderer/Renderer.h @@ -238,6 +238,13 @@ namespace Lux { // once per texture (was hundreds of redundant pipeline builds at load). static Ref GetOrCreateMipGenPipeline(Ref shader); + // Cross-queue ordering: make the next submission on waitQueue wait until + // the given execution instance (from RenderCommandBuffer::GetLastExecutionInstance, + // i.e. executeCommandList's return) on executionQueue has completed. Must be + // called on the render thread, between the two queues' submits. Used to build + // async-compute overlap (e.g. graphics waits for the compute light-cull). + static void QueueWaitForCommandList(nvrhi::CommandQueue waitQueue, nvrhi::CommandQueue executionQueue, uint64_t instance); + static uint32_t GetCurrentFrameIndex(); static uint32_t RT_GetCurrentFrameIndex(); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index b95cfad1..f8496047 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -216,6 +216,11 @@ namespace Lux { bool EnableTAA = false; float TAAHistoryBlend = 0.90f; // fraction of history kept per frame float TAASharpness = 0.3f; // post-resolve unsharp strength (0 = off) + // Schedule independent compute passes (cluster light-cull, GTAO, SSR, + // bloom) on the async compute queue overlapping graphics work. Off by + // default while the cross-queue path is brought up one pass at a time; + // nothing submits async until a pass is wired to honor this flag. + bool EnableAsyncCompute = false; RenderResolutionScaleMode ResolutionScaleMode = RenderResolutionScaleMode::Native; float DynamicResolutionScale = 1.0f; float DynamicResolutionMinScale = 0.5f; From f2c9b4e302b42126aefa3e8e034da254b46ec90c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:25:41 +0000 Subject: [PATCH 55/63] Async compute (2/N): cluster light-culling on the compute queue (correctness-first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With EnableAsyncCompute (still off by default), cluster build + light culling run on the compute queue instead of inline on graphics: - A dedicated m_ComputeCommandBuffer (compute queue) is created at init. - FlushDrawList records both cluster passes onto it after the uploads, submits it, and the graphics submit waits on that instance via Renderer::QueueWaitForCommandList so deferred lighting reads valid light grids/index lists. - The two cluster passes route their dispatches/barriers/clears to the compute buffer when async, and skip the graphics-buffer GPU perf markers (timer queries live on the graphics buffer). - BuildRenderGraph omits the two cluster nodes when async (they ran on compute); the graph never modeled their SSBOs anyway, so no graph edge is lost — cross-queue ordering is the queue wait. Correctness-first: this is the smallest cross-queue slice and validates that compute-written SSBOs are safely read on graphics. The graphics queue waits up front, so it does NOT overlap graphics work yet (perf-neutral); the graphics-submit split for real overlap is the next step. Off path is unchanged (cluster passes stay in the graph on the graphics buffer). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Renderer/SceneRenderer.cpp | 100 ++++++++++++++++----- Core/Source/Lux/Renderer/SceneRenderer.h | 3 +- 2 files changed, 79 insertions(+), 24 deletions(-) diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 9220e89d..219c8d75 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -871,6 +871,10 @@ namespace Lux { m_CommandBuffer = RenderCommandBuffer::Create(0, "SceneRenderer", /*queries=*/true); m_UploadCommandBuffer = RenderCommandBuffer::Create(0, "SceneRenderer-Upload", /*queries=*/false); + // Async-compute queue command buffer. Created unconditionally (the compute + // queue is enabled at device creation); only used when EnableAsyncCompute + // routes independent compute passes onto it. + m_ComputeCommandBuffer = RenderCommandBuffer::Create(0, "SceneRenderer-AsyncCompute", /*queries=*/false, nvrhi::CommandQueue::Compute); m_Renderer2D = Ref::Create(Renderer2DSpecification{}); m_Renderer2DScreenSpace = Ref::Create(Renderer2DSpecification{}); @@ -3346,13 +3350,22 @@ namespace Lux { addPass("PreIntegration", hzbOutputs, preIntegrationOutputs, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::PreIntegration)); } - // Cluster build runs before light culling; it only depends on the camera - // projection (SSBO synchronized via a manual barrier inside the pass). - addPass("Cluster Build", {}, {}, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::ClusterBuildPass)); + // Cluster build + light culling are depth-independent (they only need the + // camera + light UBOs), so with EnableAsyncCompute they run on the compute + // queue in FlushDrawList *before* this graphics graph and are omitted here. + // Their SSBO outputs (light grids/index lists) feed deferred lighting; the + // cross-queue ordering is a queueWaitForCommandList, not a graph edge (the + // graph never modeled these SSBOs — the nodes had no declared inputs/outputs). + if (!m_Options.EnableAsyncCompute) + { + // Cluster build runs before light culling; it only depends on the camera + // projection (SSBO synchronized via a manual barrier inside the pass). + addPass("Cluster Build", {}, {}, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::ClusterBuildPass)); - // Cluster light assignment depends on the cluster AABBs + the light UBOs; - // it is depth-independent (SSBOs synchronized via manual barriers). - addPass("Cluster Light Culling", {}, {}, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::ClusterLightCullingPass)); + // Cluster light assignment depends on the cluster AABBs + the light UBOs; + // it is depth-independent (SSBOs synchronized via manual barriers). + addPass("Cluster Light Culling", {}, {}, RenderGraph::PassFlags::Compute, makeExecute(&SceneRenderer::ClusterLightCullingPass)); + } std::vector gbufferOutputs = addFramebufferResources("GBuffer", m_GeometryPassFramebuffer); std::vector sceneColorOutputs = addFramebufferResources("SceneColor", m_SceneColorFramebuffer); @@ -6327,6 +6340,23 @@ namespace Lux { m_UploadCommandBuffer->End(); m_UploadCommandBuffer->Submit(); + // ── 2b. Async compute (cluster build + light culling) ───────────────── + // Depth-independent and dependent only on the camera/light UBOs uploaded + // above, so they run on the compute queue. Their SSBO outputs feed deferred + // lighting on the graphics queue; the graphics submit below waits on this + // compute submission (QueueWaitForCommandList) so the reads are safe. + // Correctness-first: the graphics queue waits up front, so this does not yet + // overlap graphics work — that split comes later. Gated + off by default. + const bool asyncCompute = m_Options.EnableAsyncCompute && m_ComputeCommandBuffer; + if (asyncCompute) + { + m_ComputeCommandBuffer->Begin(); + ClusterBuildPass(); + ClusterLightCullingPass(); + m_ComputeCommandBuffer->End(); + m_ComputeCommandBuffer->Submit(); + } + // ── 3. Execute render passes ────────────────────────────────────────── m_CommandBuffer->Begin(); @@ -6399,6 +6429,20 @@ namespace Lux { } m_CommandBuffer->End(); + + // Make the graphics submit wait for the async compute cluster work so + // deferred lighting reads valid light grids/index lists. Enqueued on the + // render thread before the graphics submit; reads the compute execution + // instance at that point (it was set when the compute buffer submitted above). + if (asyncCompute) + { + Ref computeCB = m_ComputeCommandBuffer; + Renderer::Submit([computeCB]() + { + Renderer::QueueWaitForCommandList(nvrhi::CommandQueue::Graphics, nvrhi::CommandQueue::Compute, computeCB->GetLastExecutionInstance()); + }); + } + m_CommandBuffer->Submit(); m_PreviousViewProjection = m_CurrentViewProjection; @@ -6731,12 +6775,18 @@ namespace Lux { constexpr uint32_t kThreadsPerGroup = 64; const glm::uvec3 groups = { (ClusterCount + kThreadsPerGroup - 1u) / kThreadsPerGroup, 1u, 1u }; - BeginProfiledGPU("ClusterBuildPass"); - Renderer::BeginComputePass(m_CommandBuffer, m_ClusterBuildPass); - Renderer::DispatchCompute(m_CommandBuffer, m_ClusterBuildPass, nullptr, groups, Buffer(&push, sizeof(push))); - m_ClusterBuildPass->GetPipeline()->BufferMemoryBarrier(m_CommandBuffer, m_SBSClusterAABBs->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); - Renderer::EndComputePass(m_CommandBuffer, m_ClusterBuildPass); - EndProfiledGPU(); + // When async, this records onto the compute-queue command buffer (submitted + // separately in FlushDrawList); the GPU perf markers/timer queries target the + // graphics command buffer, so skip them on the async path. + const bool async = m_Options.EnableAsyncCompute; + Ref cb = async ? m_ComputeCommandBuffer : m_CommandBuffer; + + if (!async) BeginProfiledGPU("ClusterBuildPass"); + Renderer::BeginComputePass(cb, m_ClusterBuildPass); + Renderer::DispatchCompute(cb, m_ClusterBuildPass, nullptr, groups, Buffer(&push, sizeof(push))); + m_ClusterBuildPass->GetPipeline()->BufferMemoryBarrier(cb, m_SBSClusterAABBs->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); + Renderer::EndComputePass(cb, m_ClusterBuildPass); + if (!async) EndProfiledGPU(); } void SceneRenderer::ClusterLightCullingPass() @@ -6745,12 +6795,16 @@ namespace Lux { if (!m_ClusterLightCullingPass || m_ViewportWidth == 0 || m_ViewportHeight == 0) return; + // When async, record onto the compute-queue command buffer (see ClusterBuildPass). + const bool async = m_Options.EnableAsyncCompute; + Ref cb = async ? m_ComputeCommandBuffer : m_CommandBuffer; + // With no local lights, skip the cull dispatch entirely: zero-fill the // per-cluster grids so the lighting shaders read count=0 everywhere. The // index lists need no clear — nothing reads past a zero count. if (m_PointLightsUB.Count == 0 && m_SpotLightsUB.Count == 0) { - Ref commandBuffer = m_CommandBuffer; + Ref commandBuffer = cb; Ref pointGrid = m_SBSPointLightGrid; Ref spotGrid = m_SBSSpotLightGrid; Ref counter = m_SBSClusterLightCounter; @@ -6765,7 +6819,7 @@ namespace Lux { // Reset the dynamic-allocation cursors ([0]=point, [1]=spot) before the // assignment dispatch atomically appends into the packed index lists. - Ref commandBuffer = m_CommandBuffer; + Ref commandBuffer = cb; Ref counter = m_SBSClusterLightCounter; Renderer::Submit([commandBuffer, counter]() mutable { @@ -6775,17 +6829,17 @@ namespace Lux { constexpr uint32_t kThreadsPerGroup = 64; const glm::uvec3 groups = { (ClusterCount + kThreadsPerGroup - 1u) / kThreadsPerGroup, 1u, 1u }; - BeginProfiledGPU("ClusterLightCullingPass"); - Renderer::BeginComputePass(m_CommandBuffer, m_ClusterLightCullingPass); - Renderer::DispatchCompute(m_CommandBuffer, m_ClusterLightCullingPass, nullptr, groups, Buffer()); + if (!async) BeginProfiledGPU("ClusterLightCullingPass"); + Renderer::BeginComputePass(cb, m_ClusterLightCullingPass); + Renderer::DispatchCompute(cb, m_ClusterLightCullingPass, nullptr, groups, Buffer()); Ref pipeline = m_ClusterLightCullingPass->GetPipeline(); - pipeline->BufferMemoryBarrier(m_CommandBuffer, m_SBSPointLightGrid->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); - pipeline->BufferMemoryBarrier(m_CommandBuffer, m_SBSSpotLightGrid->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); - pipeline->BufferMemoryBarrier(m_CommandBuffer, m_SBSPointLightIndexList->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); - pipeline->BufferMemoryBarrier(m_CommandBuffer, m_SBSSpotLightIndexList->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); - Renderer::EndComputePass(m_CommandBuffer, m_ClusterLightCullingPass); - EndProfiledGPU(); + pipeline->BufferMemoryBarrier(cb, m_SBSPointLightGrid->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); + pipeline->BufferMemoryBarrier(cb, m_SBSSpotLightGrid->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); + pipeline->BufferMemoryBarrier(cb, m_SBSPointLightIndexList->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); + pipeline->BufferMemoryBarrier(cb, m_SBSSpotLightIndexList->Get(), ResourceAccessFlags::ShaderWrite, ResourceAccessFlags::ShaderRead); + Renderer::EndComputePass(cb, m_ClusterLightCullingPass); + if (!async) EndProfiledGPU(); } void SceneRenderer::MeshCullingPass() diff --git a/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index f8496047..e5a82211 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -1246,8 +1246,9 @@ namespace Lux { Ref m_Scene; SceneRendererSpecification m_Specification; - Ref m_CommandBuffer; // render commands + Ref m_CommandBuffer; // render commands (graphics queue) Ref m_UploadCommandBuffer; // UB/SB data uploads + Ref m_ComputeCommandBuffer; // async-compute queue (EnableAsyncCompute) RenderGraph m_RenderGraph; std::vector> m_RenderGraphAliasedImages; bool m_RenderTargetAliasingApplied = false; From 7a44db9c90dccd2379f7498f472830f13bbeb6fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:26:21 +0000 Subject: [PATCH 56/63] Add Async Compute toggle to the renderer panel (experimental) Runtime toggle for EnableAsyncCompute so the compute-queue cluster light-culling path can be exercised without a rebuild. Flipping it re-registers the cluster nodes in/out of the render graph (structure hash change -> one recompile) and routes the dispatches to the compute vs graphics command buffer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Editor/Source/Panels/SceneRendererPanel.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Editor/Source/Panels/SceneRendererPanel.cpp b/Editor/Source/Panels/SceneRendererPanel.cpp index ecca2e2c..526f8ed1 100644 --- a/Editor/Source/Panels/SceneRendererPanel.cpp +++ b/Editor/Source/Panels/SceneRendererPanel.cpp @@ -374,6 +374,9 @@ namespace Lux { if (gtaoSettingsChanged) m_Context->UpdateGTAOData(); projectSettingsChanged |= ImGuiEx::Property("Jump Flood Outline", options.EnableJumpFlood); + // Experimental: schedule cluster light-culling on the async compute + // queue. Correctness-first for now (no graphics overlap yet). + projectSettingsChanged |= ImGuiEx::Property("Async Compute (experimental)", options.EnableAsyncCompute); ImGuiEx::EndPropertyGrid(); ImGui::TreePop(); } From 94e025d1f81353a9b5370e965efa4adfead7f5c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:44:31 +0000 Subject: [PATCH 57/63] Async compute (3/N): split the graphics submit around the compute wait Previously the graphics queue waited for the async cluster culling up front, so the two queues never actually overlapped. Split FlushDrawList's graphics recording at the first consumer of the compute output ("Deferred Lighting"): the pre-lighting half submits with no wait (overlapping the compute cluster work), then queueWaitForCommandList is inserted, then the deferred-onward half submits and waits. - RenderGraph::Execute gains a [begin,end) range overload. - RenderCommandBuffer::Begin/End gain a recordFrameQueries flag so the second half doesn't re-reset the per-frame-in-flight timer/pipeline-stat pool the first half already bracketed (would trip Vulkan validation). - All gated behind EnableAsyncCompute (off by default); the OFF path and the no-Deferred-Lighting fallback keep the original single-submit behavior. Foundation only: cluster culling is cheap, so the win is marginal until the expensive passes (GTAO/SSR) move to compute and shadows reorder after GBuffer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- .../Lux/Renderer/RenderCommandBuffer.cpp | 20 ++--- .../Source/Lux/Renderer/RenderCommandBuffer.h | 15 +++- Core/Source/Lux/Renderer/RenderGraph.cpp | 9 +- Core/Source/Lux/Renderer/RenderGraph.h | 4 + Core/Source/Lux/Renderer/SceneRenderer.cpp | 86 ++++++++++++++++--- docs/ENGINE_OPTIMIZATION_PLAN.md | 18 ++++ 6 files changed, 124 insertions(+), 28 deletions(-) diff --git a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp index cdf025a2..ab236d0c 100644 --- a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp +++ b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp @@ -89,20 +89,20 @@ namespace Lux { } } - void RenderCommandBuffer::Begin() + void RenderCommandBuffer::Begin(bool recordFrameQueries) { LUX_PROFILE_FUNCTION_AUTO; Ref instance = this; - Renderer::Submit([instance]() mutable { - instance->RT_Begin(); + Renderer::Submit([instance, recordFrameQueries]() mutable { + instance->RT_Begin(recordFrameQueries); }); } - void RenderCommandBuffer::End() + void RenderCommandBuffer::End(bool recordFrameQueries) { LUX_PROFILE_FUNCTION_AUTO; Ref instance = this; - Renderer::Submit([instance]() mutable { instance->RT_End(); }); + Renderer::Submit([instance, recordFrameQueries]() mutable { instance->RT_End(recordFrameQueries); }); } void RenderCommandBuffer::Submit() @@ -112,7 +112,7 @@ namespace Lux { Renderer::Submit([instance]() mutable { instance->RT_Submit(); }); } - void RenderCommandBuffer::RT_Begin() + void RenderCommandBuffer::RT_Begin(bool recordFrameQueries) { LUX_PROFILE_FUNCTION_AUTO; uint32_t commandBufferIndex = Renderer::RT_GetCurrentFrameIndex(); @@ -126,7 +126,7 @@ namespace Lux { auto device = Application::GetGraphicsDevice(); - if (m_QueryEnabled) + if (m_QueryEnabled && recordFrameQueries) { m_ActiveTimerQuery = m_TimerQueries[commandBufferIndex]; @@ -167,7 +167,7 @@ namespace Lux { #ifdef CMD_BUFFER_USE_VULKAN_QUERIES - if (m_QueryEnabled) + if (m_QueryEnabled && recordFrameQueries) { vk::CommandBuffer cmd = vk::CommandBuffer(m_ActiveCommandBuffer->getNativeObject(nvrhi::ObjectTypes::VK_CommandBuffer)); @@ -178,12 +178,12 @@ namespace Lux { #endif } - void RenderCommandBuffer::RT_End() + void RenderCommandBuffer::RT_End(bool recordFrameQueries) { LUX_PROFILE_FUNCTION_AUTO; RT_EndMarker(); - if (m_QueryEnabled) + if (m_QueryEnabled && recordFrameQueries) { m_ActiveCommandBuffer->endTimerQuery(m_ActiveTimerQuery); diff --git a/Core/Source/Lux/Renderer/RenderCommandBuffer.h b/Core/Source/Lux/Renderer/RenderCommandBuffer.h index ecc2f8c4..10af53e8 100644 --- a/Core/Source/Lux/Renderer/RenderCommandBuffer.h +++ b/Core/Source/Lux/Renderer/RenderCommandBuffer.h @@ -21,12 +21,19 @@ namespace Lux { public: static Ref Create(uint32_t count = 0, const std::string& debugName = "", bool enableQueries = false, nvrhi::CommandQueue queue = nvrhi::CommandQueue::Graphics) { return Ref::Create(count, enableQueries, debugName, queue); } - void Begin(); - void End(); + // recordFrameQueries controls the frame-level timer/pipeline-statistics query + // bracketing. It must stay true for a normal single-submit frame. When a frame + // is split into two graphics submits (async compute), only ONE of the two + // Begin/End pairs may carry the frame query (the pool is per-frame-in-flight and + // re-resetting it while the first submit is still in flight trips validation); + // pass false on the second pair. Per-pass named queries are independent and keep + // working in both halves. + void Begin(bool recordFrameQueries = true); + void End(bool recordFrameQueries = true); void Submit(); - void RT_Begin(); - void RT_End(); + void RT_Begin(bool recordFrameQueries = true); + void RT_End(bool recordFrameQueries = true); void RT_Submit(); void RT_Submit(VkSemaphore waitSemaphore); diff --git a/Core/Source/Lux/Renderer/RenderGraph.cpp b/Core/Source/Lux/Renderer/RenderGraph.cpp index 3ce669bf..652c221e 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.cpp +++ b/Core/Source/Lux/Renderer/RenderGraph.cpp @@ -603,8 +603,15 @@ namespace Lux { void RenderGraph::Execute(const CompileResult& compileResult) const { - for (uint32_t passIndex : compileResult.ExecutionOrder) + Execute(compileResult, 0, compileResult.ExecutionOrder.size()); + } + + void RenderGraph::Execute(const CompileResult& compileResult, size_t beginIndex, size_t endIndex) const + { + endIndex = std::min(endIndex, compileResult.ExecutionOrder.size()); + for (size_t i = beginIndex; i < endIndex; i++) { + uint32_t passIndex = compileResult.ExecutionOrder[i]; if (passIndex >= m_Passes.size()) continue; diff --git a/Core/Source/Lux/Renderer/RenderGraph.h b/Core/Source/Lux/Renderer/RenderGraph.h index 68e16eb7..6e85f356 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.h +++ b/Core/Source/Lux/Renderer/RenderGraph.h @@ -136,6 +136,10 @@ namespace Lux { uint64_t ComputeStructureHash() const; CompileResult Execute() const; void Execute(const CompileResult& compileResult) const; + // Executes the half-open range [beginIndex, endIndex) of the compiled + // ExecutionOrder. Used to split a frame across two command-buffer submits so a + // cross-queue wait can be inserted between them (async compute overlap). + void Execute(const CompileResult& compileResult, size_t beginIndex, size_t endIndex) const; std::vector BuildAliasPlan() const; static bool RunValidationSelfTests(std::vector* failures = nullptr); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 219c8d75..376855d0 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -6423,27 +6423,87 @@ namespace Lux { { m_LastRenderGraphDiagnosticHash = 0; } + // When async compute is on, split the graphics recording into two submits so the + // cross-queue wait sits BETWEEN them: the pre-lighting graphics work runs without + // waiting (overlapping the compute cluster culling), and only the deferred-lighting + // half — which reads the cluster light grids/index lists — waits on the compute + // submission. The split point is the first pass that consumes that output + // ("Deferred Lighting"). If it isn't present this frame (e.g. a debug view), fall + // back to the single-submit path with an up-front wait. + size_t asyncSplitIndex = SIZE_MAX; + if (asyncCompute) { - LUX_PROFILE_SCOPE("RenderGraph::Execute"); - m_RenderGraph.Execute(renderGraphResult); + const std::vector& passes = m_RenderGraph.GetPasses(); + for (size_t i = 0; i < renderGraphResult.ExecutionOrder.size(); i++) + { + uint32_t passIndex = renderGraphResult.ExecutionOrder[i]; + if (passIndex < passes.size() && passes[passIndex].DebugName && + strcmp(passes[passIndex].DebugName, "Deferred Lighting") == 0) + { + asyncSplitIndex = i; + break; + } + } } - m_CommandBuffer->End(); + const bool splitSubmit = asyncCompute && asyncSplitIndex != SIZE_MAX && asyncSplitIndex > 0; - // Make the graphics submit wait for the async compute cluster work so - // deferred lighting reads valid light grids/index lists. Enqueued on the - // render thread before the graphics submit; reads the compute execution - // instance at that point (it was set when the compute buffer submitted above). - if (asyncCompute) + if (splitSubmit) { - Ref computeCB = m_ComputeCommandBuffer; - Renderer::Submit([computeCB]() + // First graphics submit: everything before deferred lighting. No wait yet, so + // it overlaps the compute cluster culling on the compute queue. { - Renderer::QueueWaitForCommandList(nvrhi::CommandQueue::Graphics, nvrhi::CommandQueue::Compute, computeCB->GetLastExecutionInstance()); - }); + LUX_PROFILE_SCOPE("RenderGraph::Execute (pre-lighting)"); + m_RenderGraph.Execute(renderGraphResult, 0, asyncSplitIndex); + } + m_CommandBuffer->End(); + m_CommandBuffer->Submit(); + + // Cross-queue wait between the two graphics submits: the second submit will + // not begin until the compute cluster culling has completed. + { + Ref computeCB = m_ComputeCommandBuffer; + Renderer::Submit([computeCB]() + { + Renderer::QueueWaitForCommandList(nvrhi::CommandQueue::Graphics, nvrhi::CommandQueue::Compute, computeCB->GetLastExecutionInstance()); + }); + } + + // Second graphics submit: deferred lighting onward. recordFrameQueries=false so + // it does not re-reset the frame timer / pipeline-stat pool the first half + // already bracketed (per-pass named queries still record here). + m_CommandBuffer->Begin(/*recordFrameQueries=*/false); + { + LUX_PROFILE_SCOPE("RenderGraph::Execute (post-lighting)"); + m_RenderGraph.Execute(renderGraphResult, asyncSplitIndex, renderGraphResult.ExecutionOrder.size()); + } + m_CommandBuffer->End(/*recordFrameQueries=*/false); + m_CommandBuffer->Submit(); } + else + { + { + LUX_PROFILE_SCOPE("RenderGraph::Execute"); + m_RenderGraph.Execute(renderGraphResult); + } - m_CommandBuffer->Submit(); + m_CommandBuffer->End(); + + // Make the graphics submit wait for the async compute cluster work so + // deferred lighting reads valid light grids/index lists. Enqueued on the + // render thread before the graphics submit; reads the compute execution + // instance at that point (it was set when the compute buffer submitted above). + if (asyncCompute) + { + Ref computeCB = m_ComputeCommandBuffer; + Renderer::Submit([computeCB]() + { + Renderer::QueueWaitForCommandList(nvrhi::CommandQueue::Graphics, nvrhi::CommandQueue::Compute, computeCB->GetLastExecutionInstance()); + }); + } + + m_CommandBuffer->Submit(); + } m_PreviousViewProjection = m_CurrentViewProjection; m_PreviousJitter = m_CurrentJitter; diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index fb83e494..79dc645b 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -337,6 +337,24 @@ passes to async. 2017) — covers async-compute scheduling on a graph exactly like LuxEngine's; RDR2 / Decima SIGGRAPH course notes on async compute. +**Progress (branch `claude/lux-engine-performance-q69zu4`, gated behind `EnableAsyncCompute`, off by default):** +- (1/N) Compute queue enabled + cross-queue plumbing (`RenderCommandBuffer` queue param, + `Renderer::QueueWaitForCommandList`). +- (2/N) Cluster build + light culling moved to the compute command buffer. Correctness-first: + a single up-front `queueWaitForCommandList` made the graphics queue wait before recording, + so it validated cross-queue SSBO sharing but did **not** overlap yet. +- (3/N) **Graphics-submit split.** `FlushDrawList` now splits the frame's graphics recording at + the first consumer of the compute output ("Deferred Lighting"): the pre-lighting half submits + with no wait (overlapping the compute cluster culling), then `queueWaitForCommandList` is + inserted, then the deferred-lighting-onward half submits and waits. `RenderGraph::Execute` + gained a `[begin,end)` range overload; `RenderCommandBuffer::Begin/End` gained a + `recordFrameQueries` flag so the second half doesn't re-reset the per-frame timer pool. This + is the reusable foundation — the measurable win arrives once the *expensive* passes (GTAO/SSR) + move to compute and shadows are reordered after the GBuffer so real work overlaps. +- **Next (4/N):** move GTAO (needs GBuffer) to the compute queue and reorder the directional/spot + shadow passes to run *after* the GBuffer, so graphics has shadow work to chew on while GTAO + runs on compute. Then SSR after scene color. + ### B2. Variable Rate Shading (cheap, big GPU win on the heavy passes) `VK_KHR_fragment_shading_rate` is enabled and unused. Apply VRS to volumetric clouds, fog, SSR, and bloom — the low-frequency full-screen passes — for a large GPU saving at almost no From ed61d657c6ff21ddb07985f3e01e358b250372cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:56:02 +0000 Subject: [PATCH 58/63] Revert "Async compute (3/N): split the graphics submit around the compute wait" This reverts commit 94e025d1f81353a9b5370e965efa4adfead7f5c8. --- .../Lux/Renderer/RenderCommandBuffer.cpp | 20 ++--- .../Source/Lux/Renderer/RenderCommandBuffer.h | 15 +--- Core/Source/Lux/Renderer/RenderGraph.cpp | 9 +- Core/Source/Lux/Renderer/RenderGraph.h | 4 - Core/Source/Lux/Renderer/SceneRenderer.cpp | 86 +++---------------- docs/ENGINE_OPTIMIZATION_PLAN.md | 18 ---- 6 files changed, 28 insertions(+), 124 deletions(-) diff --git a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp index ab236d0c..cdf025a2 100644 --- a/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp +++ b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp @@ -89,20 +89,20 @@ namespace Lux { } } - void RenderCommandBuffer::Begin(bool recordFrameQueries) + void RenderCommandBuffer::Begin() { LUX_PROFILE_FUNCTION_AUTO; Ref instance = this; - Renderer::Submit([instance, recordFrameQueries]() mutable { - instance->RT_Begin(recordFrameQueries); + Renderer::Submit([instance]() mutable { + instance->RT_Begin(); }); } - void RenderCommandBuffer::End(bool recordFrameQueries) + void RenderCommandBuffer::End() { LUX_PROFILE_FUNCTION_AUTO; Ref instance = this; - Renderer::Submit([instance, recordFrameQueries]() mutable { instance->RT_End(recordFrameQueries); }); + Renderer::Submit([instance]() mutable { instance->RT_End(); }); } void RenderCommandBuffer::Submit() @@ -112,7 +112,7 @@ namespace Lux { Renderer::Submit([instance]() mutable { instance->RT_Submit(); }); } - void RenderCommandBuffer::RT_Begin(bool recordFrameQueries) + void RenderCommandBuffer::RT_Begin() { LUX_PROFILE_FUNCTION_AUTO; uint32_t commandBufferIndex = Renderer::RT_GetCurrentFrameIndex(); @@ -126,7 +126,7 @@ namespace Lux { auto device = Application::GetGraphicsDevice(); - if (m_QueryEnabled && recordFrameQueries) + if (m_QueryEnabled) { m_ActiveTimerQuery = m_TimerQueries[commandBufferIndex]; @@ -167,7 +167,7 @@ namespace Lux { #ifdef CMD_BUFFER_USE_VULKAN_QUERIES - if (m_QueryEnabled && recordFrameQueries) + if (m_QueryEnabled) { vk::CommandBuffer cmd = vk::CommandBuffer(m_ActiveCommandBuffer->getNativeObject(nvrhi::ObjectTypes::VK_CommandBuffer)); @@ -178,12 +178,12 @@ namespace Lux { #endif } - void RenderCommandBuffer::RT_End(bool recordFrameQueries) + void RenderCommandBuffer::RT_End() { LUX_PROFILE_FUNCTION_AUTO; RT_EndMarker(); - if (m_QueryEnabled && recordFrameQueries) + if (m_QueryEnabled) { m_ActiveCommandBuffer->endTimerQuery(m_ActiveTimerQuery); diff --git a/Core/Source/Lux/Renderer/RenderCommandBuffer.h b/Core/Source/Lux/Renderer/RenderCommandBuffer.h index 10af53e8..ecc2f8c4 100644 --- a/Core/Source/Lux/Renderer/RenderCommandBuffer.h +++ b/Core/Source/Lux/Renderer/RenderCommandBuffer.h @@ -21,19 +21,12 @@ namespace Lux { public: static Ref Create(uint32_t count = 0, const std::string& debugName = "", bool enableQueries = false, nvrhi::CommandQueue queue = nvrhi::CommandQueue::Graphics) { return Ref::Create(count, enableQueries, debugName, queue); } - // recordFrameQueries controls the frame-level timer/pipeline-statistics query - // bracketing. It must stay true for a normal single-submit frame. When a frame - // is split into two graphics submits (async compute), only ONE of the two - // Begin/End pairs may carry the frame query (the pool is per-frame-in-flight and - // re-resetting it while the first submit is still in flight trips validation); - // pass false on the second pair. Per-pass named queries are independent and keep - // working in both halves. - void Begin(bool recordFrameQueries = true); - void End(bool recordFrameQueries = true); + void Begin(); + void End(); void Submit(); - void RT_Begin(bool recordFrameQueries = true); - void RT_End(bool recordFrameQueries = true); + void RT_Begin(); + void RT_End(); void RT_Submit(); void RT_Submit(VkSemaphore waitSemaphore); diff --git a/Core/Source/Lux/Renderer/RenderGraph.cpp b/Core/Source/Lux/Renderer/RenderGraph.cpp index 652c221e..3ce669bf 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.cpp +++ b/Core/Source/Lux/Renderer/RenderGraph.cpp @@ -603,15 +603,8 @@ namespace Lux { void RenderGraph::Execute(const CompileResult& compileResult) const { - Execute(compileResult, 0, compileResult.ExecutionOrder.size()); - } - - void RenderGraph::Execute(const CompileResult& compileResult, size_t beginIndex, size_t endIndex) const - { - endIndex = std::min(endIndex, compileResult.ExecutionOrder.size()); - for (size_t i = beginIndex; i < endIndex; i++) + for (uint32_t passIndex : compileResult.ExecutionOrder) { - uint32_t passIndex = compileResult.ExecutionOrder[i]; if (passIndex >= m_Passes.size()) continue; diff --git a/Core/Source/Lux/Renderer/RenderGraph.h b/Core/Source/Lux/Renderer/RenderGraph.h index 6e85f356..68e16eb7 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.h +++ b/Core/Source/Lux/Renderer/RenderGraph.h @@ -136,10 +136,6 @@ namespace Lux { uint64_t ComputeStructureHash() const; CompileResult Execute() const; void Execute(const CompileResult& compileResult) const; - // Executes the half-open range [beginIndex, endIndex) of the compiled - // ExecutionOrder. Used to split a frame across two command-buffer submits so a - // cross-queue wait can be inserted between them (async compute overlap). - void Execute(const CompileResult& compileResult, size_t beginIndex, size_t endIndex) const; std::vector BuildAliasPlan() const; static bool RunValidationSelfTests(std::vector* failures = nullptr); diff --git a/Core/Source/Lux/Renderer/SceneRenderer.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 376855d0..219c8d75 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -6423,87 +6423,27 @@ namespace Lux { { m_LastRenderGraphDiagnosticHash = 0; } - // When async compute is on, split the graphics recording into two submits so the - // cross-queue wait sits BETWEEN them: the pre-lighting graphics work runs without - // waiting (overlapping the compute cluster culling), and only the deferred-lighting - // half — which reads the cluster light grids/index lists — waits on the compute - // submission. The split point is the first pass that consumes that output - // ("Deferred Lighting"). If it isn't present this frame (e.g. a debug view), fall - // back to the single-submit path with an up-front wait. - size_t asyncSplitIndex = SIZE_MAX; - if (asyncCompute) { - const std::vector& passes = m_RenderGraph.GetPasses(); - for (size_t i = 0; i < renderGraphResult.ExecutionOrder.size(); i++) - { - uint32_t passIndex = renderGraphResult.ExecutionOrder[i]; - if (passIndex < passes.size() && passes[passIndex].DebugName && - strcmp(passes[passIndex].DebugName, "Deferred Lighting") == 0) - { - asyncSplitIndex = i; - break; - } - } + LUX_PROFILE_SCOPE("RenderGraph::Execute"); + m_RenderGraph.Execute(renderGraphResult); } - const bool splitSubmit = asyncCompute && asyncSplitIndex != SIZE_MAX && asyncSplitIndex > 0; + m_CommandBuffer->End(); - if (splitSubmit) + // Make the graphics submit wait for the async compute cluster work so + // deferred lighting reads valid light grids/index lists. Enqueued on the + // render thread before the graphics submit; reads the compute execution + // instance at that point (it was set when the compute buffer submitted above). + if (asyncCompute) { - // First graphics submit: everything before deferred lighting. No wait yet, so - // it overlaps the compute cluster culling on the compute queue. - { - LUX_PROFILE_SCOPE("RenderGraph::Execute (pre-lighting)"); - m_RenderGraph.Execute(renderGraphResult, 0, asyncSplitIndex); - } - m_CommandBuffer->End(); - m_CommandBuffer->Submit(); - - // Cross-queue wait between the two graphics submits: the second submit will - // not begin until the compute cluster culling has completed. - { - Ref computeCB = m_ComputeCommandBuffer; - Renderer::Submit([computeCB]() - { - Renderer::QueueWaitForCommandList(nvrhi::CommandQueue::Graphics, nvrhi::CommandQueue::Compute, computeCB->GetLastExecutionInstance()); - }); - } - - // Second graphics submit: deferred lighting onward. recordFrameQueries=false so - // it does not re-reset the frame timer / pipeline-stat pool the first half - // already bracketed (per-pass named queries still record here). - m_CommandBuffer->Begin(/*recordFrameQueries=*/false); + Ref computeCB = m_ComputeCommandBuffer; + Renderer::Submit([computeCB]() { - LUX_PROFILE_SCOPE("RenderGraph::Execute (post-lighting)"); - m_RenderGraph.Execute(renderGraphResult, asyncSplitIndex, renderGraphResult.ExecutionOrder.size()); - } - m_CommandBuffer->End(/*recordFrameQueries=*/false); - m_CommandBuffer->Submit(); + Renderer::QueueWaitForCommandList(nvrhi::CommandQueue::Graphics, nvrhi::CommandQueue::Compute, computeCB->GetLastExecutionInstance()); + }); } - else - { - { - LUX_PROFILE_SCOPE("RenderGraph::Execute"); - m_RenderGraph.Execute(renderGraphResult); - } - m_CommandBuffer->End(); - - // Make the graphics submit wait for the async compute cluster work so - // deferred lighting reads valid light grids/index lists. Enqueued on the - // render thread before the graphics submit; reads the compute execution - // instance at that point (it was set when the compute buffer submitted above). - if (asyncCompute) - { - Ref computeCB = m_ComputeCommandBuffer; - Renderer::Submit([computeCB]() - { - Renderer::QueueWaitForCommandList(nvrhi::CommandQueue::Graphics, nvrhi::CommandQueue::Compute, computeCB->GetLastExecutionInstance()); - }); - } - - m_CommandBuffer->Submit(); - } + m_CommandBuffer->Submit(); m_PreviousViewProjection = m_CurrentViewProjection; m_PreviousJitter = m_CurrentJitter; diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index 79dc645b..fb83e494 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -337,24 +337,6 @@ passes to async. 2017) — covers async-compute scheduling on a graph exactly like LuxEngine's; RDR2 / Decima SIGGRAPH course notes on async compute. -**Progress (branch `claude/lux-engine-performance-q69zu4`, gated behind `EnableAsyncCompute`, off by default):** -- (1/N) Compute queue enabled + cross-queue plumbing (`RenderCommandBuffer` queue param, - `Renderer::QueueWaitForCommandList`). -- (2/N) Cluster build + light culling moved to the compute command buffer. Correctness-first: - a single up-front `queueWaitForCommandList` made the graphics queue wait before recording, - so it validated cross-queue SSBO sharing but did **not** overlap yet. -- (3/N) **Graphics-submit split.** `FlushDrawList` now splits the frame's graphics recording at - the first consumer of the compute output ("Deferred Lighting"): the pre-lighting half submits - with no wait (overlapping the compute cluster culling), then `queueWaitForCommandList` is - inserted, then the deferred-lighting-onward half submits and waits. `RenderGraph::Execute` - gained a `[begin,end)` range overload; `RenderCommandBuffer::Begin/End` gained a - `recordFrameQueries` flag so the second half doesn't re-reset the per-frame timer pool. This - is the reusable foundation — the measurable win arrives once the *expensive* passes (GTAO/SSR) - move to compute and shadows are reordered after the GBuffer so real work overlaps. -- **Next (4/N):** move GTAO (needs GBuffer) to the compute queue and reorder the directional/spot - shadow passes to run *after* the GBuffer, so graphics has shadow work to chew on while GTAO - runs on compute. Then SSR after scene color. - ### B2. Variable Rate Shading (cheap, big GPU win on the heavy passes) `VK_KHR_fragment_shading_rate` is enabled and unused. Apply VRS to volumetric clouds, fog, SSR, and bloom — the low-frequency full-screen passes — for a large GPU saving at almost no From eda6d576401bbae9e4e50e30bd5bb436d6d73a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 03:11:58 +0000 Subject: [PATCH 59/63] Make the Renderer Debugger pass-timing table sortable The per-pass CPU/GPU timing table rendered in registration order, so finding the frame's most expensive passes meant eyeballing ~20 rows. Add ImGui column sorting (tristate, so the default stays registration order): click "GPU ms" to surface the real bottlenecks at a glance. Sorting reorders a display-index list and leaves the underlying stats untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- .../Source/Panels/RendererDebuggerPanel.cpp | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/Editor/Source/Panels/RendererDebuggerPanel.cpp b/Editor/Source/Panels/RendererDebuggerPanel.cpp index eb8b8849..90d1eb44 100644 --- a/Editor/Source/Panels/RendererDebuggerPanel.cpp +++ b/Editor/Source/Panels/RendererDebuggerPanel.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -482,18 +483,51 @@ namespace Lux { ImGui::Spacing(); ImGui::TextUnformatted("Pass Timings"); - if (ImGui::BeginTable("##renderer_debugger_passes", 6, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Resizable)) + if (ImGui::BeginTable("##renderer_debugger_passes", 6, ImGuiTableFlags_RowBg | ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Resizable | ImGuiTableFlags_Sortable | ImGuiTableFlags_SortTristate)) { ImGui::TableSetupColumn("Pass"); - ImGui::TableSetupColumn("CPU ms", ImGuiTableColumnFlags_WidthFixed, 82.0f); - ImGui::TableSetupColumn("CPU %", ImGuiTableColumnFlags_WidthFixed, 62.0f); - ImGui::TableSetupColumn("GPU ms", ImGuiTableColumnFlags_WidthFixed, 82.0f); - ImGui::TableSetupColumn("GPU %", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableSetupColumn("CPU ms", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending, 82.0f); + ImGui::TableSetupColumn("CPU %", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending, 62.0f); + ImGui::TableSetupColumn("GPU ms", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending, 82.0f); + ImGui::TableSetupColumn("GPU %", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending, 62.0f); ImGui::TableSetupColumn("State", ImGuiTableColumnFlags_WidthFixed, 60.0f); ImGui::TableHeadersRow(); - for (const auto& passProfile : stats.PassProfiles) + // Default is registration order (SortTristate → no sort until a header is + // clicked). Clicking a column sorts a display-index list, leaving the + // underlying stats untouched, so the heaviest passes can be surfaced at a + // glance — click "GPU ms" to find the frame's real bottlenecks. + std::vector order; + order.reserve(stats.PassProfiles.size()); + for (size_t i = 0; i < stats.PassProfiles.size(); i++) + order.push_back(i); + + if (ImGuiTableSortSpecs* sortSpecs = ImGui::TableGetSortSpecs()) + { + if (sortSpecs->SpecsCount > 0) + { + const ImGuiTableColumnSortSpecs& spec = sortSpecs->Specs[0]; + const bool ascending = spec.SortDirection == ImGuiSortDirection_Ascending; + std::sort(order.begin(), order.end(), [&](size_t a, size_t b) + { + const auto& pa = stats.PassProfiles[a]; + const auto& pb = stats.PassProfiles[b]; + switch (spec.ColumnIndex) + { + case 0: { const int c = std::strcmp(pa.Name, pb.Name); return ascending ? c < 0 : c > 0; } + case 1: + case 2: return ascending ? pa.CPUTime < pb.CPUTime : pa.CPUTime > pb.CPUTime; + case 3: + case 4: return ascending ? pa.GPUTime < pb.GPUTime : pa.GPUTime > pb.GPUTime; + default: return ascending ? a < b : a > b; + } + }); + } + } + + for (size_t idx : order) { + const auto& passProfile = stats.PassProfiles[idx]; const bool active = passProfile.Active || passProfile.GPUTime > 0.0f; ImGui::TableNextRow(); if (!active) From 808fbc60a4118fee3b5e75edfac7961cfe1740e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 03:25:36 +0000 Subject: [PATCH 60/63] Perf: allow 2 frames in flight and drop the validation layer in Release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two device-creation settings were leaving significant performance on the table: - maxFramesInFlight was 1, so the present loop blocked on full GPU completion every frame — the CPU and GPU ran in lockstep and the triple-buffered swapchain / FramesInFlight=3 resource sets went unused. Raise to 2 so the CPU can stay a frame ahead. Every per-frame resource is already sized for 3, so this is within the existing buffering. - enableDebugRuntime was true unconditionally, loading VK_LAYER_KHRONOS_validation (which validates every Vulkan call) even in Release/Dist. Gate it to LUX_DEBUG so shipping/perf builds don't pay the validation CPU tax; Debug still gets it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/Core/Window.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Core/Source/Lux/Core/Window.cpp b/Core/Source/Lux/Core/Window.cpp index 36194058..7459d5b3 100644 --- a/Core/Source/Lux/Core/Window.cpp +++ b/Core/Source/Lux/Core/Window.cpp @@ -97,11 +97,24 @@ namespace Lux { // Desktop NVIDIA always exposes a compute-capable queue family, so device // creation still succeeds; nothing submits async until EnableAsyncCompute. deviceParams.enableComputeQueue = true; - deviceParams.maxFramesInFlight = 1; + // Let the CPU stay one frame ahead of the GPU. The swapchain is triple-buffered + // and every per-frame resource (command lists, UBO/SSBO sets, descriptor pools) + // is already sized for RendererConfig::FramesInFlight (3), so the CPU and GPU can + // safely overlap. At 1 the present loop blocked on full GPU completion every + // frame — serializing the two and wasting the triple-buffering. 2 is the + // low-latency sweet spot (one frame ahead); 3 trades latency for more throughput. + deviceParams.maxFramesInFlight = 2; deviceParams.backBufferWidth = m_Specification.Width; deviceParams.backBufferHeight = m_Specification.Height; deviceParams.vsyncEnabled = false; + // The Khronos validation layer intercepts every Vulkan call — a large CPU tax in + // draw-heavy scenes. Keep it only in Debug builds; Release/Dist (where FPS is + // measured and shipped) run without it. +#ifdef LUX_DEBUG deviceParams.enableDebugRuntime = true; +#else + deviceParams.enableDebugRuntime = false; +#endif // 0xc81ad50e: pre-existing ignored message. // The remaining three are the PreDepth depth/stencil attachment layout-transition // VUIDs (vkCmdBeginRendering depth/stencil + the matching vkQueueSubmit). They are a From 3c92d3dae6a7ad8e543eb668979595b1bf458ddd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 04:23:32 +0000 Subject: [PATCH 61/63] Editor: redesign the ImGui theme (cool graphite + indigo-violet accent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Hazel's orange/cyan palette with a distinct, cleaner look: - New Colors::Theme palette — cool-graphite neutral surfaces with a single luminous indigo-violet accent (#7C83F8, "Lux" = light), used sparingly for selection, active separators, checkmarks, focus and drag/drop. - Retint the theme's remaining hardcoded Hazel colors (warm tabs, blue separators) onto the accent so the UI is cohesive. - Softer rounding on controls (frames, grabs, tabs, scrollbars, popups) and pill scrollbars; spacing/padding left untouched so panels' hand-tuned cursor offsets still line up. - WindowBg now derives from the palette instead of a flat gray override. Pure colour/style values — no layout or API changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0138sgSFpVegqRsGVXVnjBa8 --- Core/Source/Lux/ImGui/Colors.h | 64 +++++++++++++++++----------- Core/Source/Lux/ImGui/ImGuiLayer.cpp | 53 +++++++++++++++-------- 2 files changed, 76 insertions(+), 41 deletions(-) diff --git a/Core/Source/Lux/ImGui/Colors.h b/Core/Source/Lux/ImGui/Colors.h index be0151ad..a857c84d 100644 --- a/Core/Source/Lux/ImGui/Colors.h +++ b/Core/Source/Lux/ImGui/Colors.h @@ -7,29 +7,45 @@ namespace Colors // members of a static "Theme" class and add a quick ImGui window to adjust the colour values namespace Theme { - constexpr auto accent = IM_COL32(236, 158, 36, 255); - constexpr auto highlight = IM_COL32(39, 185, 242, 255); - constexpr auto niceBlue = IM_COL32(83, 232, 254, 255); - constexpr auto compliment = IM_COL32(78, 151, 166, 255); - constexpr auto background = IM_COL32(36, 36, 36, 255); - constexpr auto backgroundDark = IM_COL32(26, 26, 26, 255); - constexpr auto titlebar = IM_COL32(21, 21, 21, 255); - constexpr auto titlebarOrange = IM_COL32(186, 66, 30, 255); - constexpr auto titlebarGreen = IM_COL32(18, 88, 30, 255); - constexpr auto titlebarRed = IM_COL32(185, 30, 30, 255); - constexpr auto propertyField = IM_COL32(15, 15, 15, 255); - constexpr auto text = IM_COL32(192, 192, 192, 255); - constexpr auto textBrighter = IM_COL32(210, 210, 210, 255); - constexpr auto textDarker = IM_COL32(128, 128, 128, 255); - constexpr auto textError = IM_COL32(230, 51, 51, 255); - constexpr auto muted = IM_COL32(77, 77, 77, 255); - constexpr auto groupHeader = IM_COL32(47, 47, 47, 255); - constexpr auto selection = IM_COL32(237, 192, 119, 255); - constexpr auto selectionMuted = IM_COL32(237, 201, 142, 23); - constexpr auto backgroundPopup = IM_COL32(50, 50, 50, 255); - constexpr auto validPrefab = IM_COL32(82, 179, 222, 255); - constexpr auto invalidPrefab = IM_COL32(222, 43, 43, 255); - constexpr auto missingMesh = IM_COL32(230, 102, 76, 255); - constexpr auto meshNotSet = IM_COL32(250, 101, 23, 255); + // LuxEngine editor theme: a cool-graphite dark base with a single luminous + // indigo-violet accent ("Lux" = light). The accent is used sparingly — selection, + // active separators, focus, checkmarks — so the UI reads calm and clean rather + // than busy. Deliberately distinct from Hazel's orange/cyan palette. + + // Signature accent + its cooler/brighter relatives. + constexpr auto accent = IM_COL32(124, 131, 248, 255); // #7C83F8 indigo-violet + constexpr auto highlight = IM_COL32(139, 156, 255, 255); // brighter periwinkle + constexpr auto niceBlue = IM_COL32(150, 165, 255, 255); + constexpr auto compliment = IM_COL32(116, 126, 168, 255); + + // Neutral graphite surfaces (a faint cool/blue tint reads more premium than flat gray). + constexpr auto background = IM_COL32(30, 31, 37, 255); + constexpr auto backgroundDark = IM_COL32(21, 22, 27, 255); + constexpr auto titlebar = IM_COL32(17, 18, 22, 255); + constexpr auto propertyField = IM_COL32(13, 14, 17, 255); + constexpr auto groupHeader = IM_COL32(38, 40, 48, 255); + constexpr auto backgroundPopup = IM_COL32(40, 42, 50, 255); + + // Play/pause/stop and status titlebars (kept semantic, just refined). + constexpr auto titlebarOrange = IM_COL32(197, 121, 45, 255); + constexpr auto titlebarGreen = IM_COL32(46, 121, 78, 255); + constexpr auto titlebarRed = IM_COL32(192, 64, 64, 255); + + // Text: slightly cool whites with clear hierarchy. + constexpr auto text = IM_COL32(199, 202, 213, 255); + constexpr auto textBrighter = IM_COL32(226, 228, 238, 255); + constexpr auto textDarker = IM_COL32(118, 122, 138, 255); + constexpr auto textError = IM_COL32(232, 84, 84, 255); + constexpr auto muted = IM_COL32(70, 73, 86, 255); + + // Selection derives from the accent so highlighted rows/text stay on-brand. + constexpr auto selection = IM_COL32(124, 131, 248, 255); + constexpr auto selectionMuted = IM_COL32(124, 131, 248, 38); + + // Asset-status semantic colours. + constexpr auto validPrefab = IM_COL32(124, 156, 232, 255); + constexpr auto invalidPrefab = IM_COL32(222, 64, 64, 255); + constexpr auto missingMesh = IM_COL32(226, 112, 86, 255); + constexpr auto meshNotSet = IM_COL32(232, 146, 60, 255); } } diff --git a/Core/Source/Lux/ImGui/ImGuiLayer.cpp b/Core/Source/Lux/ImGui/ImGuiLayer.cpp index f415c6e8..3bded64c 100644 --- a/Core/Source/Lux/ImGui/ImGuiLayer.cpp +++ b/Core/Source/Lux/ImGui/ImGuiLayer.cpp @@ -104,7 +104,13 @@ namespace Lux { style.WindowRounding = 0.0f; style.Colors[ImGuiCol_WindowBg].w = 1.0f; } - style.Colors[ImGuiCol_WindowBg] = ImVec4(0.15f, 0.15f, 0.15f, style.Colors[ImGuiCol_WindowBg].w); + // Docked/window backgrounds sit a touch above the titlebar; use the theme's + // graphite surface (not a flat gray) so it stays cohesive with the palette. + { + ImVec4 windowBg = ImGui::ColorConvertU32ToFloat4(Colors::Theme::background); + windowBg.w = style.Colors[ImGuiCol_WindowBg].w; + style.Colors[ImGuiCol_WindowBg] = windowBg; + } ImGui_ImplGlfw_InitForVulkan((GLFWwindow*)Application::Get().GetWindow().GetNativeWindow(), true); @@ -348,10 +354,10 @@ namespace Lux { colors[ImGuiCol_FrameBgHovered] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::propertyField); colors[ImGuiCol_FrameBgActive] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::propertyField); - // Tabs + // Tabs (accent-tinted so the active tab reads as "selected" on-brand) colors[ImGuiCol_Tab] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::titlebar); - colors[ImGuiCol_TabHovered] = ImColor(255, 225, 135, 30); - colors[ImGuiCol_TabActive] = ImColor(255, 225, 135, 60); + colors[ImGuiCol_TabHovered] = ImColor(124, 131, 248, 45); + colors[ImGuiCol_TabActive] = ImColor(124, 131, 248, 90); colors[ImGuiCol_TabUnfocused] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::titlebar); colors[ImGuiCol_TabUnfocusedActive] = colors[ImGuiCol_TabHovered]; @@ -371,23 +377,21 @@ namespace Lux { colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0.41f, 0.41f, 0.41f, 1.0f); colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0.51f, 0.51f, 0.51f, 1.0f); - // Check Mark - colors[ImGuiCol_CheckMark] = ImColor(200, 200, 200, 255); + // Check Mark (accent so ticks pop cleanly against the deep frame bg) + colors[ImGuiCol_CheckMark] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::accent); - // Slider - colors[ImGuiCol_SliderGrab] = ImVec4(0.51f, 0.51f, 0.51f, 0.7f); - colors[ImGuiCol_SliderGrabActive] = ImVec4(0.66f, 0.66f, 0.66f, 1.0f); + // Slider (neutral grab, accent when actively dragged) + colors[ImGuiCol_SliderGrab] = ImVec4(0.42f, 0.44f, 0.52f, 0.9f); + colors[ImGuiCol_SliderGrabActive] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::accent); // Text colors[ImGuiCol_Text] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::text); + colors[ImGuiCol_TextDisabled] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::textDarker); - // Checkbox - colors[ImGuiCol_CheckMark] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::text); - - // Separator + // Separator (subtle by default, accent when active/hovered) colors[ImGuiCol_Separator] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::backgroundDark); - colors[ImGuiCol_SeparatorActive] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::highlight); - colors[ImGuiCol_SeparatorHovered] = ImColor(39, 185, 242, 150); + colors[ImGuiCol_SeparatorActive] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::accent); + colors[ImGuiCol_SeparatorHovered] = ImColor(124, 131, 248, 150); // Window Background colors[ImGuiCol_WindowBg] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::titlebar); @@ -402,10 +406,25 @@ namespace Lux { // Menubar colors[ImGuiCol_MenuBarBg] = ImVec4{ 0.0f, 0.0f, 0.0f, 0.0f }; + // Accent-driven interaction feedback (text selection, drag/drop, keyboard nav) + colors[ImGuiCol_TextSelectedBg] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::selectionMuted); + colors[ImGuiCol_DragDropTarget] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::accent); + colors[ImGuiCol_NavHighlight] = ImGui::ColorConvertU32ToFloat4(Colors::Theme::accent); + //======================================================== - /// Style - style.FrameRounding = 2.5f; + /// Style — softer, rounder, cleaner than the default. Rounding is applied to the + /// widgets that read as "controls" (frames, grabs, tabs, scrollbars, popups); + /// spacing/padding are left alone so the panels' hand-tuned cursor offsets still line up. + style.FrameRounding = 4.0f; style.FrameBorderSize = 1.0f; + style.GrabRounding = 4.0f; + style.GrabMinSize = 7.0f; + style.TabRounding = 4.0f; + style.ScrollbarRounding = 9.0f; + style.ScrollbarSize = 13.0f; + style.PopupRounding = 6.0f; + style.ChildRounding = 6.0f; + style.PopupBorderSize = 1.0f; style.IndentSpacing = 11.0f; } From 5f376ab288499bf793189672b2934e9d86949ce4 Mon Sep 17 00:00:00 2001 From: sheazywi Date: Sun, 5 Jul 2026 00:52:54 -0400 Subject: [PATCH 62/63] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c431b09..7a5dcad1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![LuxEngine](/Resources/Branding/LuxEngineLogo.png?raw=true "LuxEngine") -LuxEngine is a C++20, Vulkan-based 3D game engine and editor for Windows, in active development. Its architecture descends from [Hazel](https://github.com/TheCherno/Hazel), but it has grown well beyond that starting point: a deferred, clustered PBR renderer with a render graph, volumetric clouds and physically-based sky, Jolt physics, C# scripting, a UUID-based asset pipeline with runtime asset packs, and a docking ImGui editor with a standalone runtime player. +LuxEngine is a C++20, Vulkan-based 3D game engine and editor for Windows, in active development. Its architecture descends from [Hazel](https://github.com/TheCherno/Hazel), but it has grown well beyond that starting point: a deferred, clustered PBR renderer with a render graph, Jolt physics, C# scripting, a UUID-based asset pipeline with runtime asset packs, and a docking ImGui editor with a standalone runtime player. This is a solo project that doubles as a learning vehicle for engine architecture. It is not production-ready and does not pretend to be — the sections below say plainly what works, what is partial, and what does not exist yet. From 72862717dc632ebbcc6886ab4a02bb808148a821 Mon Sep 17 00:00:00 2001 From: sheazywi Date: Sun, 5 Jul 2026 00:53:01 -0400 Subject: [PATCH 63/63] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7a5dcad1..cbe8b2b3 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ This is a solo project that doubles as a learning vehicle for engine architectur - **Deferred PBR pipeline** with a G-buffer, clustered (froxel) light culling for point/spot lights, and a separate forward pass for transparents. - **Render graph** with compile caching and scratch-resource reuse; passes are skipped when their feature is off (zero-cost-when-disabled is an explicit goal). - **Shadows** — cascaded directional shadow maps (2K default) and spot-light shadow maps. -- **Sky & atmosphere** — physically-based sky atmosphere, Preetham sky, HDR environment maps (equirect → cubemap, irradiance + prefiltered mips), skybox pass. -- **Volumetric clouds** — Nubis/RDR2-style system with baked 3D noise textures (base shape, detail, curl), temporal reprojection, and a composite pass. -- **Volumetric / atmospheric fog** — froxel fog with clustered local-light in-scattering, exponential height fog, and local fog volumes. -- **Post-processing** — GTAO (with temporal + denoise), screen-space reflections (with temporal + composite), TAA, bloom, depth of field, and HZB generation used for occlusion and SSR pre-integration. +- **Sky** — Preetham sky, HDR environment maps (equirect → cubemap, irradiance + prefiltered mips), skybox pass. +- **Sky atmosphere / volumetric clouds / fog** — currently removed as part of the renderer simplification pass. +- **Post-processing** — GTAO (with temporal + denoise), screen-space reflections (with temporal + composite), bloom, depth of field, and HZB generation used for occlusion and SSR pre-integration. +- **TAA** — currently removed (resolve pass + history buffers are not created). - **Physical imaging** — exposure as manual multiplier, manual EV100, physical camera (aperture/shutter/ISO), or histogram auto-exposure; ACES and AgX tonemapping; physical light units. - **Volume system** — blendable post-process, atmosphere, and fog volumes (box/sphere) that override settings per region. - **GPU-driven bits** — GPU scene buffers, compute mesh culling, per-pass GPU timing.