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/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; } 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/Platform/Vulkan/DescriptorSetManager.cpp b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.cpp index 6e7e7451..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; @@ -364,27 +415,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 +620,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 @@ -768,6 +827,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 @@ -878,7 +944,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..a392fe42 100644 --- a/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h +++ b/Core/Source/Lux/Platform/Vulkan/DescriptorSetManager.h @@ -248,6 +248,14 @@ 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); + // 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/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; 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/ComputePass.cpp b/Core/Source/Lux/Renderer/ComputePass.cpp index ecff9a1b..fb2d2d74 100644 --- a/Core/Source/Lux/Renderer/ComputePass.cpp +++ b/Core/Source/Lux/Renderer/ComputePass.cpp @@ -17,6 +17,22 @@ 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; + // 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/ComputePass.h b/Core/Source/Lux/Renderer/ComputePass.h index 963cdea7..438bd7d2 100644 --- a/Core/Source/Lux/Renderer/ComputePass.h +++ b/Core/Source/Lux/Renderer/ComputePass.h @@ -44,8 +44,11 @@ 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); } + nvrhi::BindingSetVector GetBindingSets(uint32_t frameIndex) const { return m_DescriptorSetManager.GetBindingSets(frameIndex); } virtual Ref GetPipeline() const; 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/Image.cpp b/Core/Source/Lux/Renderer/Image.cpp index 21ebd11c..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) @@ -329,15 +342,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/Image.h b/Core/Source/Lux/Renderer/Image.h index 89772459..7c3af6d4 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; @@ -436,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/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/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/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/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp b/Core/Source/Lux/Renderer/RenderCommandBuffer.cpp index c2950094..08850b75 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(); } @@ -223,6 +229,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_TimerQueriesEnabled) @@ -243,9 +254,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 3c534e47..3b7d7a3e 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; @@ -62,6 +69,9 @@ namespace Lux { RenderCommandBuffer(uint32_t count, bool enableQueries, const std::string& debugName); virtual ~RenderCommandBuffer(); 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/RenderGraph.h b/Core/Source/Lux/Renderer/RenderGraph.h index ce35e1b4..68e16eb7 100644 --- a/Core/Source/Lux/Renderer/RenderGraph.h +++ b/Core/Source/Lux/Renderer/RenderGraph.h @@ -84,6 +84,12 @@ namespace Lux { 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 diff --git a/Core/Source/Lux/Renderer/RenderPass.cpp b/Core/Source/Lux/Renderer/RenderPass.cpp index 0c5560de..96688eed 100644 --- a/Core/Source/Lux/Renderer/RenderPass.cpp +++ b/Core/Source/Lux/Renderer/RenderPass.cpp @@ -17,6 +17,22 @@ 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; + // 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 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 3e01cd0b..c0a14fd4 100644 --- a/Core/Source/Lux/Renderer/Renderer.cpp +++ b/Core/Source/Lux/Renderer/Renderer.cpp @@ -199,11 +199,19 @@ 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; 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 +301,41 @@ 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::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; + 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; @@ -304,6 +347,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 +369,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() @@ -356,6 +416,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()) @@ -614,6 +678,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; @@ -624,6 +695,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 +862,43 @@ 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). + + 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; @@ -815,7 +927,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]); @@ -852,10 +965,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()) diff --git a/Core/Source/Lux/Renderer/Renderer.h b/Core/Source/Lux/Renderer/Renderer.h index 3dda2b8d..4abb50dd 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) { @@ -215,8 +228,23 @@ 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); + // 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); + + // 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.cpp b/Core/Source/Lux/Renderer/SceneRenderer.cpp index 423d8660..ff2629fd 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.cpp +++ b/Core/Source/Lux/Renderer/SceneRenderer.cpp @@ -175,7 +175,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; @@ -214,12 +214,19 @@ 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; 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; @@ -235,7 +242,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; @@ -254,7 +261,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; @@ -453,6 +463,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{}); @@ -714,7 +728,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; @@ -798,14 +814,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; @@ -815,12 +831,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; @@ -866,7 +881,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; @@ -903,6 +918,31 @@ namespace Lux { LUX_CORE_VERIFY(m_GeometryPassTransparent->Validate()); m_GeometryPassTransparent->Bake(); + // 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; + 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; @@ -935,50 +975,35 @@ 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 ─────────────────────────────────────────────── { - 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"; @@ -1024,8 +1049,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"; @@ -1077,6 +1100,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; @@ -1102,6 +1128,7 @@ namespace Lux { LUX_CORE_VERIFY(m_AODebugPass->Validate()); m_AODebugPass->Bake(); m_AODebugMaterial = Material::Create(aoPipelineSpec.Shader, "AO-Debug"); + } } // ── SSR ──────────────────────────────────────────────────────────────── @@ -1189,6 +1216,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; @@ -1222,6 +1251,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; @@ -1277,6 +1309,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; @@ -1342,189 +1377,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) ───────────────────────── { @@ -1532,7 +1392,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; @@ -1584,31 +1446,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) ─────────────────── { @@ -1690,6 +1536,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; @@ -1724,6 +1574,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) ────── @@ -1819,7 +1670,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 @@ -1951,8 +1813,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)) @@ -2612,6 +2473,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 @@ -2670,6 +2532,9 @@ namespace Lux { if (meshCullingActive) 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; if (preIntegrationActive) { @@ -2715,9 +2580,9 @@ namespace Lux { std::vector geometryOutputs = gbufferOutputs; appendResources(geometryOutputs, sceneColorCurrent); - { - addPass("GBuffer", preDepthOutputs, gbufferOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GBufferPass)); + addPass("GBuffer", preDepthOutputs, gbufferOutputs, RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GBufferPass)); + { std::vector deferredReads = gbufferOutputs; appendResources(deferredReads, preDepthOutputs); appendResources(deferredReads, shadowOutputs); @@ -2730,13 +2595,8 @@ namespace Lux { appendResources(geometryOutputs, sceneColorCurrent); } - if (UsesGBufferDebugPass(m_DebugViewMode)) - { - std::vector debugReads = gbufferOutputs; - appendResources(debugReads, sceneColorCurrent); - addPass("GBuffer Debug", debugReads, addRenderPassResources("GBuffer Debug", m_GBufferDebugPass), RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::GBufferDebugPass)); - } - + // 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) { @@ -2759,16 +2619,34 @@ namespace Lux { aoFinalOutputs.push_back(gtaoHistoryB); } - 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; + 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; + } - if (m_DebugViewMode == DebugViewMode::AO) - addPass("AO Debug", aoCompositeReads, addRenderPassResources("AO Debug", m_AODebugPass), RenderGraph::PassFlags::Graphics, makeExecute(&SceneRenderer::AODebugPass)); + 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)); + } + + 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; @@ -3053,8 +2931,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(); @@ -3241,13 +3122,22 @@ 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); + // 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) + 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); @@ -3258,6 +3148,53 @@ namespace Lux { m_ClusterAABBsDirty = 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. + { + // 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()) + { + LUX_CORE_WARN_TAG("Renderer", "Framebuffer '{}' had stale attachment handles - re-invalidating", name); + framebuffer->Invalidate(); + } + }; + auto repairPassIfStale = [&repairIfStale](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 ───────────────────────────────────────────── @@ -3410,7 +3347,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; @@ -3593,6 +3557,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); @@ -3725,6 +3693,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); @@ -3866,12 +3842,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 ────────────────────────────────────── @@ -4194,7 +4183,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; @@ -4604,18 +4595,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); @@ -4643,6 +4654,18 @@ 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()); @@ -4655,10 +4678,13 @@ namespace Lux { 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")) @@ -4672,18 +4698,57 @@ namespace Lux { } m_GPUMaterialTextureBoundCount = uploadedTextureCount; - std::vector& gpuMaterialData = m_ScratchMaterialData; - if (submittedMaterialScene) + if (fullTextureResolve) { - const std::vector& src = submittedMaterialScene->GetMaterials(); - gpuMaterialData.assign(src.begin(), src.end()); + for (uint32_t textureIndex = 0; textureIndex < MaxGPUTextureSceneTextures; textureIndex++) + resolveSlot(textureIndex); + m_MissingTextureDescriptorCount = missingTextureDescriptorCount; } else { - gpuMaterialData.clear(); + // 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; + // 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) + { + 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(); if (persistentMaterialCount != m_PersistentMaterialUploadedCount || (submittedMaterialScene && submittedMaterialScene->HasDirtyMaterials())) @@ -4866,6 +4931,8 @@ namespace Lux { if (ShouldCollectFullRendererDiagnostics(Renderer::GetConfig())) { + m_GPUSceneDebugSnapshotRequested = false; + GPUSceneDebugSnapshot snapshot; snapshot.PersistentInstanceCount = persistentGPUSceneInstanceCount; snapshot.TransientInstanceCount = (uint32_t)transientGPUSceneData.size(); @@ -5028,6 +5095,7 @@ namespace Lux { || !meshCullDrawData.empty() || !indirectDrawData.empty() || !gpuSceneInstanceData.empty() + || !gpuSceneRangeRows.empty() || !transientGPUSceneData.empty() || !gpuMaterialUploadData.empty() || !transientGPUMaterialData.empty()) @@ -5110,6 +5178,20 @@ namespace Lux { 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)); @@ -5154,6 +5236,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(); @@ -5226,6 +5325,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; @@ -5286,10 +5399,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); }); } @@ -5339,10 +5453,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); }); } } @@ -5369,13 +5484,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); }); } @@ -5520,6 +5635,14 @@ namespace Lux { if (!m_ClusterAABBsDirty) 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 @@ -5554,9 +5677,31 @@ 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 = cb; + 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; + Ref commandBuffer = cb; Ref counter = m_SBSClusterLightCounter; Renderer::Submit([commandBuffer, counter]() mutable { @@ -5566,17 +5711,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() @@ -5779,6 +5924,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; @@ -5794,12 +5942,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()); }); } @@ -5823,12 +5971,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()); }); } @@ -5868,12 +6016,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()); }); } @@ -5885,6 +6033,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()) @@ -5904,12 +6055,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()); }); } @@ -5925,12 +6076,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()); }); } @@ -6030,7 +6181,7 @@ namespace Lux { void SceneRenderer::RT_DrawStaticMesh( Ref cmd, const StaticDrawCommand& dc, - const TransformMapData& tmd, + MeshDrawParams params, bool bindMaterial, uint32_t lightIndex, bool useVisibleObjectIndexes, @@ -6105,27 +6256,32 @@ 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); 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; @@ -6169,17 +6325,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/Core/Source/Lux/Renderer/SceneRenderer.h b/Core/Source/Lux/Renderer/SceneRenderer.h index 6ad367c6..586d2978 100644 --- a/Core/Source/Lux/Renderer/SceneRenderer.h +++ b/Core/Source/Lux/Renderer/SceneRenderer.h @@ -193,7 +193,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; @@ -217,6 +217,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; @@ -283,6 +288,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; }; // ───────────────────────────────────────────────────────────────────────── @@ -655,6 +666,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; } @@ -736,6 +750,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 MeshDrawSortEntry @@ -813,6 +831,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; @@ -1014,7 +1054,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, @@ -1063,6 +1103,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]; @@ -1152,6 +1198,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 }; @@ -1207,8 +1261,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; @@ -1229,6 +1284,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 }; @@ -1501,6 +1557,45 @@ namespace Lux { uint32_t m_PersistentMaterialUploadedCount = 0; uint32_t m_GPUMaterialTextureBoundCount = 0; + // 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; + + // 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 + // 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 + // 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/Texture.cpp b/Core/Source/Lux/Renderer/Texture.cpp index f109efe1..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(); @@ -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 @@ -1230,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(); 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; 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) 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; } 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/Editor/Resources/Shaders/DeferredLighting.glsl b/Editor/Resources/Shaders/DeferredLighting.glsl index 7b61e95f..e0302339 100644 --- a/Editor/Resources/Shaders/DeferredLighting.glsl +++ b/Editor/Resources/Shaders/DeferredLighting.glsl @@ -39,8 +39,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; @@ -176,7 +175,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; } 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 diff --git a/Editor/Source/Panels/RendererDebuggerPanel.cpp b/Editor/Source/Panels/RendererDebuggerPanel.cpp index 52cb3e5e..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) @@ -617,6 +651,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)) { diff --git a/Editor/Source/Panels/SceneRendererPanel.cpp b/Editor/Source/Panels/SceneRendererPanel.cpp index 385b9281..526f8ed1 100644 --- a/Editor/Source/Panels/SceneRendererPanel.cpp +++ b/Editor/Source/Panels/SceneRendererPanel.cpp @@ -367,15 +367,16 @@ 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(); 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(); } diff --git a/Lux-Runtime/src/RuntimeApplication.cpp b/Lux-Runtime/src/RuntimeApplication.cpp index dffa1508..a643af81 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" @@ -138,6 +139,10 @@ namespace Lux : Application(specification), m_ProjectPath(std::move(projectPath)), m_BenchmarkConfig(std::move(benchmarkConfig)) { 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 diff --git a/Lux-Runtime/src/RuntimeLayer.cpp b/Lux-Runtime/src/RuntimeLayer.cpp index cab04a56..2ed6fd6e 100644 --- a/Lux-Runtime/src/RuntimeLayer.cpp +++ b/Lux-Runtime/src/RuntimeLayer.cpp @@ -131,6 +131,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); diff --git a/README.md b/README.md index 2ba2dd67..cbe8b2b3 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, 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** — 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. +- **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. diff --git a/docs/ENGINE_OPTIMIZATION_PLAN.md b/docs/ENGINE_OPTIMIZATION_PLAN.md index f4cb2bc1..fb83e494 100644 --- a/docs/ENGINE_OPTIMIZATION_PLAN.md +++ b/docs/ENGINE_OPTIMIZATION_PLAN.md @@ -13,6 +13,209 @@ 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 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):** + +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. + +**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.)* + +**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 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 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 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 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):** + +- **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. +- 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. +- Correctness/sync audit (thread handoff, upload races, barrier semantics) — still + pending; the audit session for it was cut short. + +--- + ## 0. Where LuxEngine actually stands **Already has (genuinely modern):** @@ -79,12 +282,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 +404,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..453375b3 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,51 @@ 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. + +## 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. diff --git a/premake5.lua b/premake5.lua index c77aed31..a3886326 100644 --- a/premake5.lua +++ b/premake5.lua @@ -55,6 +55,7 @@ workspace "Lux" optimize "Full" symbols "Off" defines { "NDEBUG" } + linktimeoptimization "On" filter "system:windows" buildoptions { "/EHsc", "/Zc:preprocessor", "/Zc:__cplusplus" }