diff --git a/EppoEditor/Projects/Test/Assets/AssetRegistry.json b/EppoEditor/Projects/Test/Assets/AssetRegistry.json index e0cc43cd..2380d7fc 100644 --- a/EppoEditor/Projects/Test/Assets/AssetRegistry.json +++ b/EppoEditor/Projects/Test/Assets/AssetRegistry.json @@ -6,34 +6,34 @@ "Type": "Texture" }, { - "Filepath": "Meshes\\player.glb", - "Handle": 17559226157527298045, + "Filepath": "Meshes\\main_sponza\\NewSponza_Main_glTF_003.gltf", + "Handle": 13075392022024015801, "Type": "Mesh" }, { - "Filepath": "Meshes\\pbr_showcase.gltf", - "Handle": 16319850260215055169, + "Filepath": "Meshes\\sponza_curtains\\NewSponza_Curtains_glTF.gltf", + "Handle": 13075392022024015802, "Type": "Mesh" }, { - "Filepath": "Meshes\\main_sponza\\NewSponza_Main_glTF_003.gltf", - "Handle": 13075392022024015801, + "Filepath": "Scenes\\Sponza.epscene", + "Handle": 13075392022024015803, + "Type": "Scene" + }, + { + "Filepath": "Meshes\\pbr_showcase.gltf", + "Handle": 16319850260215055169, "Type": "Mesh" }, { - "Filepath": "Meshes\\sponza_curtains\\NewSponza_Curtains_glTF.gltf", - "Handle": 13075392022024015802, + "Filepath": "Meshes\\player.glb", + "Handle": 17559226157527298045, "Type": "Mesh" }, { "Filepath": "Scenes\\Def.epscene", "Handle": 18159251541775323644, "Type": "Scene" - }, - { - "Filepath": "Scenes\\Sponza.epscene", - "Handle": 13075392022024015803, - "Type": "Scene" } ] -} +} \ No newline at end of file diff --git a/EppoEditor/Projects/Test/Assets/Scenes/Def.epscene b/EppoEditor/Projects/Test/Assets/Scenes/Def.epscene index 2639c2bf..336d4d81 100644 --- a/EppoEditor/Projects/Test/Assets/Scenes/Def.epscene +++ b/EppoEditor/Projects/Test/Assets/Scenes/Def.epscene @@ -552,34 +552,6 @@ ] } }, - { - "IDComponent": { - "ID": 16319850260215055170 - }, - "MeshComponent": { - "MeshHandle": 16319850260215055169 - }, - "TagComponent": { - "Tag": "AO + Emissive Showcase" - }, - "TransformComponent": { - "Rotation": [ - 0.0, - 0.0, - 0.0 - ], - "Scale": [ - 0.3499999940395355, - 0.3499999940395355, - 0.3499999940395355 - ], - "Translation": [ - -0.8999999761581421, - 1.350000023841858, - 5.0 - ] - } - }, { "IDComponent": { "ID": 1013 @@ -769,6 +741,34 @@ 0.0 ] } + }, + { + "IDComponent": { + "ID": 16319850260215055170 + }, + "MeshComponent": { + "MeshHandle": 16319850260215055169 + }, + "TagComponent": { + "Tag": "AO + Emissive Showcase" + }, + "TransformComponent": { + "Rotation": [ + 0.0, + 0.0, + 0.0 + ], + "Scale": [ + 0.3499999940395355, + 0.3499999940395355, + 0.3499999940395355 + ], + "Translation": [ + -0.8999999761581421, + 1.350000023841858, + 5.0 + ] + } } ], "Environment": { @@ -791,6 +791,12 @@ ] }, "Handle": 18159251541775323644, - "Name": "Def" + "Name": "Def", + "Ssao": { + "Bias": 0.02500000037252903, + "Intensity": 1.0, + "Power": 1.5, + "Radius": 0.5 + } } -} +} \ No newline at end of file diff --git a/EppoEditor/Projects/Test/Assets/Scenes/Sponza.epscene b/EppoEditor/Projects/Test/Assets/Scenes/Sponza.epscene index 4449f4fe..7617838e 100644 --- a/EppoEditor/Projects/Test/Assets/Scenes/Sponza.epscene +++ b/EppoEditor/Projects/Test/Assets/Scenes/Sponza.epscene @@ -148,6 +148,12 @@ ] }, "Handle": 13075392022024015803, - "Name": "Sponza" + "Name": "Sponza", + "Ssao": { + "Bias": 0.02500000037252903, + "Intensity": 1.0, + "Power": 1.5, + "Radius": 0.5 + } } -} +} \ No newline at end of file diff --git a/EppoEditor/Projects/Test/Test.epproj b/EppoEditor/Projects/Test/Test.epproj index 191c5f56..3a2de5a5 100644 --- a/EppoEditor/Projects/Test/Test.epproj +++ b/EppoEditor/Projects/Test/Test.epproj @@ -1,7 +1,7 @@ { "Project": { "Name": "Test", - "ProjectDirectory": "C:\\Users\\niels\\Dev\\Projects\\Eppo\\EppoEditor\\Projects\\Test", + "ProjectDirectory": "C:\\Users\\niels\\Dev\\Projects\\EppoEngine\\EppoEditor\\Projects\\Test", "StartScene": 13075392022024015803 } -} +} \ No newline at end of file diff --git a/EppoEditor/Source/EditorLayer.cpp b/EppoEditor/Source/EditorLayer.cpp index 5328a9da..8b98140e 100644 --- a/EppoEditor/Source/EditorLayer.cpp +++ b/EppoEditor/Source/EditorLayer.cpp @@ -11,244 +11,256 @@ namespace Eppo { - namespace - { - constexpr const char* CONTENT_BROWSER_PANEL = "Content Browser"; - constexpr const char* LOG_PANEL = "Log"; - constexpr const char* PROPERTY_PANEL = "Property"; - constexpr const char* SCENE_HIERARCHY_PANEL = "Scene Hierarchy"; - constexpr const char* SCENE_SETTINGS_PANEL = "Scene Settings"; - } - - auto EditorLayer::OnAttach() -> void - { - m_PanelManager = CreateRef(); - m_PanelManager->AddPanel(PROPERTY_PANEL, true); - m_PanelManager->AddPanel(SCENE_HIERARCHY_PANEL, true); - m_PanelManager->AddPanel(CONTENT_BROWSER_PANEL, true); - m_PanelManager->AddPanel(LOG_PANEL, true); - m_PanelManager->AddPanel(SCENE_SETTINGS_PANEL, true); - - // Route scene opening through EditorLayer so scripting is rebuilt and the - // editor/active scene bookkeeping stays authoritative. - m_PanelManager->GetPanel(CONTENT_BROWSER_PANEL) - ->SetOpenSceneCallback([this](const AssetHandle handle) -> void { OpenScene(handle); }); - - m_EditorCamera = EditorCamera(glm::vec3(0.0f, 5.0f, 20.0f), -25.0f, -90.0f); - - const auto loadIcon = [](const char* fileName) -> Ref - { - const auto path = FS::GetResourcesDirectory() / "Icons" / fileName; - if (!FS::Exists(path)) - { - Log::Error("Toolbar icon not found: '{}'", path); - return nullptr; - } - - ImageSpecification spec; - spec.ImageFormat = nvrhi::Format::SRGBA8_UNORM; - spec.DebugName = fileName; - - return CreateRef(spec, ImageSource(path)); - }; - - m_PlayIcon = loadIcon("PlayButton.png"); - m_StopIcon = loadIcon("StopButton.png"); - m_PauseIcon = loadIcon("PauseButton.png"); - - // Use the pictorial logo as the OS window/taskbar icon (best-effort). It is - // intentionally not drawn in the menu bar; the wordmark is the in-app brand - const auto logoPath = FS::GetResourcesDirectory() / "Icons" / "Logo.png"; - if (FS::Exists(logoPath)) - Application::Get().GetWindow()->SetIcon(logoPath); - - const auto& args = Application::Get().GetParams().Args; - const auto defaultProject = Project::GetProjectsDirectory() / "Test" / "Test.epproj"; - const auto startupProject = args.Argc > 1 ? std::filesystem::path(args[1]) : defaultProject; - - // Startup must establish a scene synchronously (the SceneRenderer below needs one), - // so a failed open falls back to a fresh project rather than an async file dialog. - if (!OpenProject(startupProject)) - NewProject("Test"); - - m_SceneRenderer = CreateRef(m_ActiveScene, SceneRendererSpecification{ - .Width = m_ViewportWidth, - .Height = m_ViewportHeight, - .EnableDebugRendering = true, - }); - } - - auto EditorLayer::OnDetach() -> void - { - if (m_ExportFuture.valid()) - m_ExportFuture.wait(); - ScriptEngine::Shutdown(); - Project::SetActive(nullptr); - } - - auto EditorLayer::OnUpdate(const float timestep) -> void - { - EP_PROFILE_FN("EditorLayer::OnUpdate"); - - // Sync the selected entity from the panel manager before any rendering code - // reads it. The panel manager's selection is set during the previous frame's - // UI pass (when the user clicks in the hierarchy), so this is always one - // frame behind — intentional and invisible for wireframe / gizmo feedback. - m_SelectedEntity = m_PanelManager->GetSelectedEntity(); - - if (m_ViewportWidth > 0 && m_ViewportHeight > 0) - { - m_EditorCamera.SetViewportSize(m_ViewportWidth, m_ViewportHeight); - m_ActiveScene->SetViewportSize(m_ViewportWidth, m_ViewportHeight); - m_EditorScene->SetViewportSize(m_ViewportWidth, m_ViewportHeight); - m_SceneRenderer->Resize(m_ViewportWidth, m_ViewportHeight); - } + namespace + { + constexpr const char* CONTENT_BROWSER_PANEL = "Content Browser"; + constexpr const char* LOG_PANEL = "Log"; + constexpr const char* PROPERTY_PANEL = "Property"; + constexpr const char* SCENE_HIERARCHY_PANEL = "Scene Hierarchy"; + constexpr const char* SCENE_SETTINGS_PANEL = "Scene Settings"; + } + + auto EditorLayer::OnAttach() -> void + { + m_PanelManager = CreateRef(); + m_PanelManager->AddPanel(PROPERTY_PANEL, true); + m_PanelManager->AddPanel(SCENE_HIERARCHY_PANEL, true); + m_PanelManager->AddPanel(CONTENT_BROWSER_PANEL, true); + m_PanelManager->AddPanel(LOG_PANEL, true); + m_PanelManager->AddPanel(SCENE_SETTINGS_PANEL, true); + + // Route scene opening through EditorLayer so scripting is rebuilt and the + // editor/active scene bookkeeping stays authoritative. + m_PanelManager->GetPanel(CONTENT_BROWSER_PANEL) + ->SetOpenSceneCallback( + [this](const AssetHandle handle) -> void + { + OpenScene(handle); + } + ); + + m_EditorCamera = EditorCamera(glm::vec3(-8.0f, 3.0f, 0.0f), 0.0f, 0.0f); + + const auto LoadIcon = [](const char* fileName) -> Ref + { + const auto path = FS::GetResourcesDirectory() / "Icons" / fileName; + if (!FS::Exists(path)) + { + Log::Error("Toolbar icon not found: '{}'", path); + return nullptr; + } + + ImageSpecification spec; + spec.ImageFormat = nvrhi::Format::SRGBA8_UNORM; + spec.DebugName = fileName; + + return CreateRef(spec, ImageSource(path)); + }; + + m_PlayIcon = LoadIcon("PlayButton.png"); + m_StopIcon = LoadIcon("StopButton.png"); + m_PauseIcon = LoadIcon("PauseButton.png"); + + // Use the pictorial logo as the OS window/taskbar icon (best-effort). It is + // intentionally not drawn in the menu bar; the wordmark is the in-app brand + const auto logoPath = FS::GetResourcesDirectory() / "Icons" / "Logo.png"; + if (FS::Exists(logoPath)) + Application::Get().GetWindow()->SetIcon(logoPath); + + const auto& args = Application::Get().GetParams().Args; + const auto defaultProject = Project::GetProjectsDirectory() / "Test" / "Test.epproj"; + const auto startupProject = args.Argc > 1 ? std::filesystem::path(args[1]) : defaultProject; + + // Startup must establish a scene synchronously (the SceneRenderer below needs one), + // so a failed open falls back to a fresh project rather than an async file dialog. + if (!OpenProject(startupProject)) + NewProject("Test"); + + m_SceneRenderer = CreateRef( + m_ActiveScene, + SceneRendererSpecification{ + .Width = m_ViewportWidth, + .Height = m_ViewportHeight, + .EnableDebugRendering = true, + } + ); + } + + auto EditorLayer::OnDetach() -> void + { + if (m_ExportFuture.valid()) + m_ExportFuture.wait(); + ScriptEngine::Shutdown(); + Project::SetActive(nullptr); + } + + auto EditorLayer::OnUpdate(const float timestep) -> void + { + EP_PROFILE_FN("EditorLayer::OnUpdate"); + + // Sync the selected entity from the panel manager before any rendering code + // reads it. The panel manager's selection is set during the previous frame's + // UI pass (when the user clicks in the hierarchy), so this is always one + // frame behind — intentional and invisible for wireframe / gizmo feedback. + m_SelectedEntity = m_PanelManager->GetSelectedEntity(); + + if (m_ViewportWidth > 0 && m_ViewportHeight > 0) + { + m_EditorCamera.SetViewportSize(m_ViewportWidth, m_ViewportHeight); + m_ActiveScene->SetViewportSize(m_ViewportWidth, m_ViewportHeight); + m_EditorScene->SetViewportSize(m_ViewportWidth, m_ViewportHeight); + m_SceneRenderer->Resize(m_ViewportWidth, m_ViewportHeight); + } // Edit mode follows viewport focus; play mode owns input until it is stopped. Input::SetViewportInputEnabled(m_SceneState == SceneState::Play || m_ViewportFocused); - // Outline the selection in edit mode only; clear it while playing. - m_SceneRenderer->SetScene(m_ActiveScene); - m_SceneRenderer->SetHighlightedEntity(m_SceneState == SceneState::Edit ? m_SelectedEntity : Entity{}); - - if (ScriptEngine::IsInitialized()) - ScriptEngine::Get().VerifyRuntime(); - - switch (m_SceneState) - { - case SceneState::Edit: - { - m_MissingPrimaryCamera = false; - - if (m_ViewportFocused && !ImGuizmo::IsUsing()) - m_EditorCamera.OnUpdate(timestep); - - m_ActiveScene->OnRenderEditor(m_SceneRenderer, m_EditorCamera); - break; - } - - case SceneState::Play: - { - m_ActiveScene->OnUpdateRuntime(timestep); - - // The runtime view renders through the scene's primary camera. Without - // one, OnRenderRuntime would draw nothing and leave a stale frame, - // making live component edits look ignored. Fall back to the editor - // camera so the scene (and edits) stay visible, and flag a notice. - if (m_ActiveScene->GetPrimaryCameraEntity()) - { - m_MissingPrimaryCamera = false; - m_ActiveScene->OnRenderRuntime(m_SceneRenderer); - } - else - { - m_MissingPrimaryCamera = true; - m_ActiveScene->OnRenderEditor(m_SceneRenderer, m_EditorCamera); - } - break; - } - } - } - - auto EditorLayer::OnUIRender() -> void - { - EP_PROFILE_FN("EditorLayer::OnUIRender"); - - // Apply a requested layout restore before any window Begin this frame, so the - // docked windows pick up the restored dock nodes as they are submitted below. - // Doing this mid-frame (from the menu handler) leaves windows already placed - // and corrupts the docking layout, especially with multi-viewport enabled. - if (m_RestoreLayoutRequested) - { - RestoreDefaultLayout(); - m_RestoreLayoutRequested = false; - } - - // From ImGui docking example - bool dockspaceOpen = true; - constexpr ImGuiDockNodeFlags dockspaceFlags = ImGuiDockNodeFlags_None; - - ImGuiWindowFlags windowFlags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking; - const ImGuiViewport* viewport = ImGui::GetMainViewport(); - ImGui::SetNextWindowPos(viewport->Pos); - ImGui::SetNextWindowSize(viewport->Size); - ImGui::SetNextWindowViewport(viewport->ID); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); - windowFlags |= ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove; - windowFlags |= ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus; - - if (dockspaceFlags & ImGuiDockNodeFlags_PassthruCentralNode) - windowFlags |= ImGuiWindowFlags_NoBackground; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); - ImGui::Begin("DockSpace", &dockspaceOpen, windowFlags); - ImGui::PopStyleVar(3); - - const ImGuiIO& io = ImGui::GetIO(); - ImGuiStyle& style = ImGui::GetStyle(); - - const float minWinSizeX = style.WindowMinSize.x; - style.WindowMinSize.x = 200.0f; - if (io.ConfigFlags & ImGuiConfigFlags_DockingEnable) - { - const ImGuiID dockspaceId = ImGui::GetID("MyDockSpace"); - ImGui::DockSpace(dockspaceId, ImVec2(0.0f, 0.0f), dockspaceFlags); - } - - style.WindowMinSize.x = minWinSizeX; - - // Menu bar - if (ImGui::BeginMenuBar()) - { - // Branding: an accent-colored wordmark, vertically centered in the bar, - // then a subtle divider before the menus. AlignTextToFramePadding lines - // the text up with the framed menu labels instead of top-aligning it. - ImGui::AlignTextToFramePadding(); - ImGui::TextColored(ImVec4(0.91f, 0.39f, 0.11f, 1.0f), "EppoEditor"); - ImGui::SameLine(0.0f, 12.0f); - - const ImVec2 dividerPos = ImGui::GetCursorScreenPos(); - const float dividerHeight = ImGui::GetFrameHeight(); - ImGui::GetWindowDrawList()->AddLine( - { dividerPos.x, dividerPos.y + 4.0f }, - { dividerPos.x, dividerPos.y + dividerHeight - 4.0f }, - ImGui::GetColorU32(ImGuiCol_Separator)); - ImGui::SameLine(0.0f, 12.0f); - - if (ImGui::BeginMenu("File")) - { - if (ImGui::MenuItem("New Project", "CTRL+N")) - m_NewProjectPopup = true; - - if (ImGui::MenuItem("Save Project", "CTRL+S")) - SaveProject(); - - if (ImGui::MenuItem("Export Game...", nullptr, false, Project::GetActive() != nullptr && !m_ExportInProgress)) - m_ExportOptionsPopup = true; - - if (ImGui::MenuItem("Open Project", "CTRL+O")) - OpenProject(); - - if (ImGui::MenuItem("Close Project")) - CloseProject(); - - if (ImGui::MenuItem("New Scene")) - NewScene(); - - if (ImGui::MenuItem("Save Scene")) - SaveScene(); - - if (ImGui::MenuItem("Open Scene")) - OpenScene(); - - if (ImGui::MenuItem("Close")) - Application::Get().Close(); - - ImGui::EndMenu(); - } - - if (ImGui::BeginMenu("Debug")) + // Outline the selection in edit mode only; clear it while playing. + m_SceneRenderer->SetScene(m_ActiveScene); + m_SceneRenderer->SetHighlightedEntity(m_SceneState == SceneState::Edit ? m_SelectedEntity : Entity{}); + + if (ScriptEngine::IsInitialized()) + ScriptEngine::Get().VerifyRuntime(); + + switch (m_SceneState) + { + case SceneState::Edit: + { + m_MissingPrimaryCamera = false; + + if (m_ViewportFocused && !ImGuizmo::IsUsing()) + m_EditorCamera.OnUpdate(timestep); + + m_ActiveScene->OnRenderEditor(m_SceneRenderer, m_EditorCamera); + break; + } + + case SceneState::Play: + { + m_ActiveScene->OnUpdateRuntime(timestep); + + // The runtime view renders through the scene's primary camera. Without + // one, OnRenderRuntime would draw nothing and leave a stale frame, + // making live component edits look ignored. Fall back to the editor + // camera so the scene (and edits) stay visible, and flag a notice. + if (m_ActiveScene->GetPrimaryCameraEntity()) + { + m_MissingPrimaryCamera = false; + m_ActiveScene->OnRenderRuntime(m_SceneRenderer); + } + else + { + m_MissingPrimaryCamera = true; + m_ActiveScene->OnRenderEditor(m_SceneRenderer, m_EditorCamera); + } + break; + } + } + } + + auto EditorLayer::OnUIRender() -> void + { + EP_PROFILE_FN("EditorLayer::OnUIRender"); + + // Apply a requested layout restore before any window Begin this frame, so the + // docked windows pick up the restored dock nodes as they are submitted below. + // Doing this mid-frame (from the menu handler) leaves windows already placed + // and corrupts the docking layout, especially with multi-viewport enabled. + if (m_RestoreLayoutRequested) + { + RestoreDefaultLayout(); + m_RestoreLayoutRequested = false; + } + + // From ImGui docking example + bool dockspaceOpen = true; + constexpr ImGuiDockNodeFlags dockspaceFlags = ImGuiDockNodeFlags_None; + + ImGuiWindowFlags windowFlags = ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking; + const ImGuiViewport* viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->Pos); + ImGui::SetNextWindowSize(viewport->Size); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + windowFlags |= ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove; + windowFlags |= ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus; + + if (dockspaceFlags & ImGuiDockNodeFlags_PassthruCentralNode) + windowFlags |= ImGuiWindowFlags_NoBackground; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::Begin("DockSpace", &dockspaceOpen, windowFlags); + ImGui::PopStyleVar(3); + + const ImGuiIO& io = ImGui::GetIO(); + ImGuiStyle& style = ImGui::GetStyle(); + + const float minWinSizeX = style.WindowMinSize.x; + style.WindowMinSize.x = 200.0f; + if (io.ConfigFlags & ImGuiConfigFlags_DockingEnable) + { + const ImGuiID dockspaceId = ImGui::GetID("MyDockSpace"); + // Reserve a bottom strip for the non-dockable status bar so docked panels + // cannot overlap it; the bar is rendered into the freed space below. + const float statusBarHeight = 24.0f; + const ImVec2 avail = ImGui::GetContentRegionAvail(); + ImGui::DockSpace(dockspaceId, ImVec2(0.0f, std::max(0.0f, avail.y - statusBarHeight)), dockspaceFlags); + } + + style.WindowMinSize.x = minWinSizeX; + + // Menu bar + if (ImGui::BeginMenuBar()) + { + // Branding: an accent-colored wordmark, vertically centered in the bar, + // then a subtle divider before the menus. AlignTextToFramePadding lines + // the text up with the framed menu labels instead of top-aligning it. + ImGui::AlignTextToFramePadding(); + ImGui::TextColored(ImVec4(0.91f, 0.39f, 0.11f, 1.0f), "EppoEditor"); + ImGui::SameLine(0.0f, 12.0f); + + const ImVec2 dividerPos = ImGui::GetCursorScreenPos(); + const float dividerHeight = ImGui::GetFrameHeight(); + ImGui::GetWindowDrawList()->AddLine( + { dividerPos.x, dividerPos.y + 4.0f }, { dividerPos.x, dividerPos.y + dividerHeight - 4.0f }, + ImGui::GetColorU32(ImGuiCol_Separator) + ); + ImGui::SameLine(0.0f, 12.0f); + + if (ImGui::BeginMenu("File")) + { + if (ImGui::MenuItem("New Project", "CTRL+N")) + m_NewProjectPopup = true; + + if (ImGui::MenuItem("Save Project", "CTRL+S")) + SaveProject(); + + if (ImGui::MenuItem("Export Game...", nullptr, false, Project::GetActive() != nullptr && !m_ExportInProgress)) + m_ExportOptionsPopup = true; + + if (ImGui::MenuItem("Open Project", "CTRL+O")) + OpenProject(); + + if (ImGui::MenuItem("Close Project")) + CloseProject(); + + if (ImGui::MenuItem("New Scene")) + NewScene(); + + if (ImGui::MenuItem("Save Scene")) + SaveScene(); + + if (ImGui::MenuItem("Open Scene")) + OpenScene(); + + if (ImGui::MenuItem("Close")) + Application::Get().Close(); + + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Debug")) { if (ImGui::MenuItem("Enable Debug Rendering", nullptr, m_SceneRenderer->IsDebugRenderingEnabled())) m_SceneRenderer->SetDebugRenderingEnabled(!m_SceneRenderer->IsDebugRenderingEnabled()); @@ -266,545 +278,563 @@ namespace Eppo ImGui::EndMenu(); } - if (ImGui::BeginMenu("Window")) - { - if (ImGui::MenuItem("Content Browser", nullptr, m_PanelManager->IsPanelOpen(CONTENT_BROWSER_PANEL))) - m_PanelManager->TogglePanel(CONTENT_BROWSER_PANEL); + if (ImGui::BeginMenu("Window")) + { + if (ImGui::MenuItem("Content Browser", nullptr, m_PanelManager->IsPanelOpen(CONTENT_BROWSER_PANEL))) + m_PanelManager->TogglePanel(CONTENT_BROWSER_PANEL); - if (ImGui::MenuItem("Log", nullptr, m_PanelManager->IsPanelOpen(LOG_PANEL))) - m_PanelManager->TogglePanel(LOG_PANEL); + if (ImGui::MenuItem("Log", nullptr, m_PanelManager->IsPanelOpen(LOG_PANEL))) + m_PanelManager->TogglePanel(LOG_PANEL); - if (ImGui::MenuItem("Properties", nullptr, m_PanelManager->IsPanelOpen(PROPERTY_PANEL))) - m_PanelManager->TogglePanel(PROPERTY_PANEL); + if (ImGui::MenuItem("Properties", nullptr, m_PanelManager->IsPanelOpen(PROPERTY_PANEL))) + m_PanelManager->TogglePanel(PROPERTY_PANEL); - if (ImGui::MenuItem("Scene Hierarchy", nullptr, m_PanelManager->IsPanelOpen(SCENE_HIERARCHY_PANEL))) - m_PanelManager->TogglePanel(SCENE_HIERARCHY_PANEL); + if (ImGui::MenuItem("Scene Hierarchy", nullptr, m_PanelManager->IsPanelOpen(SCENE_HIERARCHY_PANEL))) + m_PanelManager->TogglePanel(SCENE_HIERARCHY_PANEL); - if (ImGui::MenuItem("Scene Settings", nullptr, m_PanelManager->IsPanelOpen(SCENE_SETTINGS_PANEL))) - m_PanelManager->TogglePanel(SCENE_SETTINGS_PANEL); + if (ImGui::MenuItem("Scene Settings", nullptr, m_PanelManager->IsPanelOpen(SCENE_SETTINGS_PANEL))) + m_PanelManager->TogglePanel(SCENE_SETTINGS_PANEL); - ImGui::Separator(); + ImGui::Separator(); - if (ImGui::MenuItem("Restore window layout")) - m_RestoreLayoutRequested = true; + if (ImGui::MenuItem("Restore window layout")) + m_RestoreLayoutRequested = true; - ImGui::EndMenu(); - } + ImGui::EndMenu(); + } - ImGui::EndMenuBar(); - } + ImGui::EndMenuBar(); + } - // Popups - if (m_NewProjectPopup) - { - constexpr ImGuiPopupFlags popupFlags = ImGuiPopupFlags_NoOpenOverExistingPopup; - ImGui::OpenPopup("New Project", popupFlags); - m_NewProjectPopup = false; - } + // Popups + if (m_NewProjectPopup) + { + constexpr ImGuiPopupFlags popupFlags = ImGuiPopupFlags_NoOpenOverExistingPopup; + ImGui::OpenPopup("New Project", popupFlags); + m_NewProjectPopup = false; + } - // Popups - UI_NewProjectPopup(); - UI_RelationshipRepairPopup(); - UI_ExportOptionsPopup(); - UI_ExportProgressPopup(); - UI_ExportResultPopup(); + // Popups + UI_NewProjectPopup(); + UI_RelationshipRepairPopup(); + UI_ExportOptionsPopup(); + UI_ExportProgressPopup(); + UI_ExportResultPopup(); - // Drives every queued file dialog (editor + content browser) and fires its callback. - FileDialog::Render(); + // Drives every queued file dialog (editor + content browser) and fires its callback. + FileDialog::Render(); - // Scene render - m_SceneRenderer->RenderGui(); + // Scene render + m_SceneRenderer->RenderGui(); - // Panels - m_PanelManager->RenderGui(); + // Panels + m_PanelManager->RenderGui(); - // Viewport - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); - ImGui::Begin("Viewport"); + // Viewport + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + ImGui::Begin("Viewport"); - m_ViewportFocused = ImGui::IsWindowFocused(); - m_ViewportHovered = ImGui::IsWindowHovered(); - const auto& app = Application::Get(); + m_ViewportFocused = ImGui::IsWindowFocused(); + m_ViewportHovered = ImGui::IsWindowHovered(); + const auto& app = Application::Get(); app.GetImGuiLayer()->BlockEvents(m_SceneState == SceneState::Edit && !m_ViewportHovered); - const ImVec2 viewportSize = ImGui::GetContentRegionAvail(); - m_ViewportWidth = static_cast(viewportSize.x); - m_ViewportHeight = static_cast(viewportSize.y); - - const auto& finalImage = m_SceneRenderer->GetFinalImage(); - ImGui::Image(ImGuiEx::CreateTextureRef(finalImage->GetTexture()), ImVec2(static_cast(m_ViewportWidth), static_cast(m_ViewportHeight))); - - // Imguizmo - UpdateImGuizmo(); - - // UI - UI_Toolbar(); - UI_ViewportNotices(); - - ImGui::End(); // Viewport - ImGui::PopStyleVar(); - - ImGui::End(); // DockSpace - } - - auto EditorLayer::OnEvent(Event& e) -> void - { - EP_PROFILE_FN("EditorLayer::OnEvent"); - - EventDispatcher dispatcher(e); - dispatcher.Dispatch(std::bind_front(&EditorLayer::OnKeyPressed, this)); - } - - auto EditorLayer::OnKeyPressed(const KeyPressedEvent& e) -> bool - { - EP_PROFILE_FN("EditorLayer::OnKeyPressed"); - - if (m_ExportInProgress) - return false; - - if (e.IsRepeat()) - return false; - - // Read modifiers ungated: editor accelerators must work regardless of whether - // the viewport currently owns gameplay input. - [[maybe_unused]] const bool alt = Input::IsKeyPressedRaw(Key::LeftAlt) || Input::IsKeyPressedRaw(Key::RightAlt); - [[maybe_unused]] const bool control = Input::IsKeyPressedRaw(Key::LeftControl) || Input::IsKeyPressedRaw(Key::RightControl); - [[maybe_unused]] const bool shift = Input::IsKeyPressedRaw(Key::LeftShift) || Input::IsKeyPressedRaw(Key::RightShift); - - switch (e.GetKeyCode()) - { - case Key::N: - { - if (control) - m_NewProjectPopup = true; - break; - } - - case Key::O: - { - if (control) - OpenProject(); - break; - } - - case Key::S: - { - if (control) - SaveProject(); - break; - } - - case Key::W: - { - if (!alt && !control && !shift && m_SceneState == SceneState::Edit) - m_GizmoType = ImGuizmo::TRANSLATE; - break; - } - - case Key::E: - { - if (!alt && !control && !shift && m_SceneState == SceneState::Edit) - m_GizmoType = ImGuizmo::ROTATE; - break; - } - - case Key::R: - { - if (!alt && !control && !shift && m_SceneState == SceneState::Edit) - m_GizmoType = ImGuizmo::SCALE; - break; - } - - case Key::Escape: - { - if (m_SceneState == SceneState::Play) - OnSceneStop(); - } - } - - return false; - } - - auto EditorLayer::OnScenePlay() -> void - { - EP_PROFILE_FN("EditorLayer::OnScenePlay"); - - if (!m_EditorScene) - return; - - // Backs up the disabled toolbar button. - if (!ScriptEngine::IsUserAssemblyValid()) - { - Log::Warn("Cannot enter play mode: the project's scripts failed to compile."); - return; - } - - // Capture the UUID while the current scene is still alive. Scene::Copy - // creates a new registry (handles don't survive), but UUIDs do. - const UUID selectedUUID = m_PanelManager->GetSelectedEntity() - ? m_PanelManager->GetSelectedEntity().GetUUID() : UUID{}; - - m_SceneState = SceneState::Play; - m_ActiveScene = Scene::Copy(m_EditorScene); - m_PanelManager->SetSceneContext(m_ActiveScene); - - m_SelectedEntity = selectedUUID ? m_ActiveScene->GetEntityByUUID(selectedUUID) : Entity{}; - m_PanelManager->SetSelectedEntity(m_SelectedEntity); - - if (!ScriptEngine::IsInitialized() || !ScriptEngine::Get().IsRuntimeLoaded()) + const ImVec2 viewportSize = ImGui::GetContentRegionAvail(); + m_ViewportWidth = static_cast(viewportSize.x); + m_ViewportHeight = static_cast(viewportSize.y); + + const auto& finalImage = m_SceneRenderer->GetFinalImage(); + ImGui::Image( + ImGuiEx::CreateTextureRef(finalImage->GetTexture()), + ImVec2(static_cast(m_ViewportWidth), static_cast(m_ViewportHeight)) + ); + + // Imguizmo + UpdateImGuizmo(); + + // UI + UI_Toolbar(); + UI_ViewportNotices(); + + ImGui::End(); // Viewport + ImGui::PopStyleVar(); + + // Status bar occupies the bottom strip reserved above; drawn here so the + // DockSpace host window is still current. + m_StatusBar.Render(); + + ImGui::End(); // DockSpace + } + + auto EditorLayer::OnEvent(Event& e) -> void + { + EP_PROFILE_FN("EditorLayer::OnEvent"); + + EventDispatcher dispatcher(e); + dispatcher.Dispatch(std::bind_front(&EditorLayer::OnKeyPressed, this)); + } + + auto EditorLayer::OnKeyPressed(const KeyPressedEvent& e) -> bool + { + EP_PROFILE_FN("EditorLayer::OnKeyPressed"); + + if (m_ExportInProgress) + return false; + + if (e.IsRepeat()) + return false; + + // Read modifiers ungated: editor accelerators must work regardless of whether + // the viewport currently owns gameplay input. + [[maybe_unused]] const bool alt = Input::IsKeyPressedRaw(Key::LeftAlt) || Input::IsKeyPressedRaw(Key::RightAlt); + [[maybe_unused]] const bool control = Input::IsKeyPressedRaw(Key::LeftControl) || Input::IsKeyPressedRaw(Key::RightControl); + [[maybe_unused]] const bool shift = Input::IsKeyPressedRaw(Key::LeftShift) || Input::IsKeyPressedRaw(Key::RightShift); + + switch (e.GetKeyCode()) + { + case Key::N: + { + if (control) + m_NewProjectPopup = true; + break; + } + + case Key::O: + { + if (control) + OpenProject(); + break; + } + + case Key::S: + { + if (control) + SaveProject(); + break; + } + + case Key::W: + { + if (!alt && !control && !shift && m_SceneState == SceneState::Edit) + m_GizmoType = ImGuizmo::TRANSLATE; + break; + } + + case Key::E: + { + if (!alt && !control && !shift && m_SceneState == SceneState::Edit) + m_GizmoType = ImGuizmo::ROTATE; + break; + } + + case Key::R: + { + if (!alt && !control && !shift && m_SceneState == SceneState::Edit) + m_GizmoType = ImGuizmo::SCALE; + break; + } + + case Key::Escape: + { + if (m_SceneState == SceneState::Play) + OnSceneStop(); + } + } + + return false; + } + + auto EditorLayer::OnScenePlay() -> void + { + EP_PROFILE_FN("EditorLayer::OnScenePlay"); + + if (!m_EditorScene) + return; + + // Backs up the disabled toolbar button. + if (ScriptEngine::IsUserAssemblyCompiling()) + { + Log::Warn("Cannot enter play mode: the project's scripts are currently compiling."); + return; + } + + if (!ScriptEngine::IsUserAssemblyValid()) + { + Log::Warn("Cannot enter play mode: the project's scripts failed to compile."); + return; + } + + // Capture the UUID while the current scene is still alive. Scene::Copy + // creates a new registry (handles don't survive), but UUIDs do. + const UUID selectedUUID = m_PanelManager->GetSelectedEntity() ? m_PanelManager->GetSelectedEntity().GetUUID() : UUID{}; + + m_SceneState = SceneState::Play; + m_ActiveScene = Scene::Copy(m_EditorScene); + m_PanelManager->SetSceneContext(m_ActiveScene); + + m_SelectedEntity = selectedUUID ? m_ActiveScene->GetEntityByUUID(selectedUUID) : Entity{}; + m_PanelManager->SetSelectedEntity(m_SelectedEntity); + + if (!ScriptEngine::IsInitialized() || !ScriptEngine::Get().IsRuntimeLoaded()) Log::Warn("Scripting backend not initialized, not running scripts."); - m_ActiveScene->OnRuntimeStart(); + m_ActiveScene->OnRuntimeStart(); const auto& app = Application::Get(); app.GetImGuiLayer()->BlockEvents(false); app.GetImGuiLayer()->SetMouseInputEnabled(false); app.GetWindow()->SetCursorMode(CursorMode::Disabled); - } + } - auto EditorLayer::OnSceneStop() -> void - { - EP_PROFILE_FN("EditorLayer::OnSceneStop"); + auto EditorLayer::OnSceneStop() -> void + { + EP_PROFILE_FN("EditorLayer::OnSceneStop"); - if (!m_ActiveScene) - return; + if (!m_ActiveScene) + return; const auto& app = Application::Get(); app.GetWindow()->SetCursorMode(CursorMode::Normal); app.GetImGuiLayer()->SetMouseInputEnabled(true); - // Clears the scripting scene context once OnDestroy has run. - m_ActiveScene->OnRuntimeStop(); + // Clears the scripting scene context once OnDestroy has run. + m_ActiveScene->OnRuntimeStop(); - // Capture the UUID while the play scene is still alive. The Entity's - // raw Scene* becomes dangling as soon as we drop the Ref below, so we - // must extract the UUID value now and re-resolve it in the editor scene. - const UUID selectedUUID = m_PanelManager->GetSelectedEntity() - ? m_PanelManager->GetSelectedEntity().GetUUID() : UUID{}; + // Capture the UUID while the play scene is still alive. The Entity's + // raw Scene* becomes dangling as soon as we drop the Ref below, so we + // must extract the UUID value now and re-resolve it in the editor scene. + const UUID selectedUUID = m_PanelManager->GetSelectedEntity() ? m_PanelManager->GetSelectedEntity().GetUUID() : UUID{}; - m_SceneState = SceneState::Edit; - m_ActiveScene = m_EditorScene; - m_PanelManager->SetSceneContext(m_ActiveScene); + m_SceneState = SceneState::Edit; + m_ActiveScene = m_EditorScene; + m_PanelManager->SetSceneContext(m_ActiveScene); - m_SelectedEntity = selectedUUID ? m_ActiveScene->GetEntityByUUID(selectedUUID) : Entity{}; - m_PanelManager->SetSelectedEntity(m_SelectedEntity); + m_SelectedEntity = selectedUUID ? m_ActiveScene->GetEntityByUUID(selectedUUID) : Entity{}; + m_PanelManager->SetSelectedEntity(m_SelectedEntity); app.GetImGuiLayer()->BlockEvents(!m_ViewportHovered); - } - - auto EditorLayer::RestoreDefaultLayout() -> void - { - const auto path = FS::GetResourcesDirectory() / "Layouts" / "DefaultLayout.ini"; - if (!FS::Exists(path)) - { - Log::Error("Cannot restore window layout: default layout not found at '{}'", path); - return; - } - - const std::string layout = FS::ReadText(path); - if (layout.empty()) - return; // FS::ReadText already logged the failure. - - ImGui::LoadIniSettingsFromMemory(layout.c_str(), layout.size()); - - // The docking layout references panel windows by name, so reopen every panel - // to guarantee the restored dock nodes have their windows to populate. - m_PanelManager->SetPanelOpen(CONTENT_BROWSER_PANEL, true); - m_PanelManager->SetPanelOpen(LOG_PANEL, true); - m_PanelManager->SetPanelOpen(PROPERTY_PANEL, true); - m_PanelManager->SetPanelOpen(SCENE_HIERARCHY_PANEL, true); - m_PanelManager->SetPanelOpen(SCENE_SETTINGS_PANEL, true); - } - - auto EditorLayer::CloseProject() -> void - { - EP_PROFILE_FN("EditorLayer::CloseProject"); - - // Unload the per-project user assembly (collectible), but keep the - // scripting runtime + core assembly alive for the next project. - if (ScriptEngine::IsInitialized()) - ScriptEngine::Get().UnloadUserAssembly(); - - SaveProject(); - - auto scene = CreateRef(); - - m_PanelManager->SetSceneContext(scene); - - if (Project::GetActive()) - Project::SetActive(nullptr); - - m_EditorScene = scene; - m_ActiveScene = scene; - } - - auto EditorLayer::NewProject(const std::string& name) -> void - { - EP_PROFILE_FN("EditorLayer::NewProject"); - - // Create project directory - const auto projectPath = Project::GetProjectsDirectory() / name; - FS::CreateDir(projectPath); - - // Create asset directories - FS::CreateDir(projectPath / "Assets" / "Meshes"); - FS::CreateDir(projectPath / "Assets" / "Scenes"); - FS::CreateDir(projectPath / "Assets" / "Scripts"); - - // Copy new project template - FS::Copy(FS::GetResourcesDirectory() / "Templates" / "NewProject", projectPath); - - // Replace tokens - constexpr auto ReplaceToken = [](std::string& input, const char* token, const std::string& value) -> void - { - size_t pos = 0; - while ((pos = input.find(token, pos)) != std::string::npos) - { - input.replace(pos, strlen(token), value); - pos += strlen(token); - } - }; - - { - auto inputStr = FS::ReadText(projectPath / "project.epproj"); - ReplaceToken(inputStr, "$PROJECT_NAME$", name); - FS::WriteText(projectPath / "project.epproj", inputStr, true); - FS::Move(projectPath / "project.epproj", projectPath / std::filesystem::path(name + ".epproj")); - } - - { - auto inputStr = FS::ReadText(projectPath / "premake5.lua"); - ReplaceToken(inputStr, "$PROJECT_NAME$", name); - FS::WriteText(projectPath / "premake5.lua", inputStr, true); - } - - // Rename the scripts project after the project. The EppoScriptCore - // reference is resolved at build time via $(CoreManagedDll) (passed by - // OpenProject), so there is no path to bake in here. - { - const auto templateCsproj = projectPath / "Scripts" / "Scripts.csproj"; - FS::Move(templateCsproj, projectPath / "Scripts" / std::filesystem::path(name + ".csproj")); - } - - OpenProject(projectPath / std::filesystem::path(name + ".epproj")); - } - - auto EditorLayer::OpenProject() -> void - { - EP_PROFILE_FN("EditorLayer::OpenProject"); - - FileDialog::OpenFile( - "OpenProject", "Open Project", FileDialog::BuildFilter("EppoEngine Project", { "epproj" }), - Project::GetProjectsDirectory(), [this](const std::filesystem::path& path) { OpenProject(path); } - ); - } - - auto EditorLayer::OpenProject(const std::filesystem::path& path) -> bool - { - EP_PROFILE_FN("EditorLayer::OpenProject"); - - if (path.extension().string() != ".epproj") - { - Log::Error("Could not load '{}' because it is not a project file!", path); - return false; - } - - if (Project::GetActive()) - CloseProject(); - - if (Project::Open(path)) - { - const auto& projSpec = Project::GetActive()->GetSpecification(); - - // Before opening the scene: deserialization populates ScriptEngine's field - // storage. Init runs even when the user's scripts fail to build, or scene - // load would drop every serialized field value and the next save would - // write them back out empty. - const auto runtimeConfigPath = FS::GetExecutableDirectory() / "runtimeconfig.json"; - if (!ScriptEngine::Init(runtimeConfigPath)) - Log::Error("Failed to initialize the script runtime for project '{}'.", projSpec.Name); - else - ScriptEngine::Get().ReloadProjectAssembly(); - - // Now that scripting is ready, open the start scene. - if (projSpec.StartScene) - OpenScene(projSpec.StartScene); - else - NewScene(); - } - - return true; - } - - auto EditorLayer::SaveProject() -> bool - { - EP_PROFILE_FN("EditorLayer::SaveProject"); - - SaveScene(); - - if (!Project::GetActive()->GetSpecification().StartScene) - Project::GetActive()->GetSpecification().StartScene = m_ActiveScene->Handle; - - return Project::SaveActive(); - } - - auto EditorLayer::ExportGame() -> void - { - const Ref project = Project::GetActive(); - if (!project) - return; - - if (!SaveProject()) - { - m_ExportResult = {}; - m_ExportResult.Errors.emplace_back("The project could not be saved before export."); - m_ExportResultPopup = true; - return; - } - - FileDialog::OpenFolder( - "ExportGame", "Export To", project->GetSpecification().ProjectDirectory.parent_path(), - [this, project](const std::filesystem::path& parentDirectory) - { - ProjectExportOptions options{ - .ParentDirectory = parentDirectory, - .SourceDirectory = std::filesystem::current_path().parent_path(), - .ExportDebug = m_ExportDebug, - .ExportRelease = m_ExportRelease, - }; - - options.ProgressCallback = [this](const float progress, const std::string_view phase) - { - const std::scoped_lock lock(m_ExportProgressMutex); - m_ExportProgress = progress; - m_ExportPhase = phase; - }; - - { - const std::scoped_lock lock(m_ExportProgressMutex); - m_ExportProgress = 0.0f; - m_ExportPhase = "Starting export"; - } - - m_ExportInProgress = true; - try - { - m_ExportFuture = std::async(std::launch::async, [project, options = std::move(options)]() mutable -> ProjectExportResult - { - return ProjectExporter(project).Export(options); - } - ); - m_ExportProgressPopup = true; - } - catch (const std::exception& exception) - { - m_ExportInProgress = false; - m_ExportResult = {}; - m_ExportResult.Errors.emplace_back(std::format("Failed to start export: {}", exception.what())); - m_ExportResultPopup = true; - } - } - ); - } - - auto EditorLayer::NewScene() -> void - { - EP_PROFILE_FN("EditorLayer::NewScene"); - - m_EditorScene = CreateRef(); - m_ActiveScene = m_EditorScene; - m_ActiveScenePath = std::filesystem::path(); - m_PanelManager->SetSceneContext(m_ActiveScene); - } - - auto EditorLayer::OpenScene() -> void - { - EP_PROFILE_FN("EditorLayer::OpenScene"); - - FileDialog::OpenFile( - "OpenScene", "Open Scene", FileDialog::BuildFilter("EppoEngine Scene", { "epscene" }), - Project::GetAssetsDirectory(), [this](const std::filesystem::path& path) { OpenScene(path); } - ); - } - - auto EditorLayer::OpenScene(const std::filesystem::path& path) -> bool - { - EP_PROFILE_FN("EditorLayer::OpenScene"); - - if (path.extension().string() != ".epscene") - { - Log::Error("Could not load '{}' because it is not a scene file!", path); - return false; - } - - const auto scene = CreateRef(); - const SceneSerializer serializer(scene); - - if (serializer.Deserialize(path)) - { - m_EditorScene = scene; - m_ActiveScene = m_EditorScene; - m_ActiveScenePath = Project::GetAssetFilepath(path); - m_PanelManager->SetSceneContext(m_ActiveScene); - } - else - { - Log::Error("Failed to deserialize scene '{}'!", path); - return false; - } - - return true; - } - - auto EditorLayer::OpenScene(AssetHandle handle) -> void - { - EP_PROFILE_FN("EditorLayer::OpenScene"); - - const auto& assetManager = Project::GetActive()->GetAssetManager(); - m_EditorScene = std::static_pointer_cast(assetManager->GetOrLoadAsset(handle)); - m_ActiveScene = m_EditorScene; - m_ActiveScenePath = Project::GetAssetFilepath(assetManager->GetMetadata(handle).Filepath); - - m_PanelManager->SetSceneContext(m_ActiveScene); - } - - auto EditorLayer::SaveScene() -> void - { - EP_PROFILE_FN("EditorLayer::SaveScene"); - - // An unsaved scene has no path yet; route through the async Save-As dialog, which - // sets the path and calls back into here once the user confirms. - if (m_ActiveScenePath.empty()) - { - SaveSceneAs(); - return; - } - - const SceneSerializer serializer(m_ActiveScene); - serializer.Serialize(m_ActiveScenePath); - - const auto& assetManager = Project::GetActive()->GetAssetManager(); - if (assetManager && !assetManager->HasAssetData(m_ActiveScene->Handle)) - assetManager->CreateAsset(m_ActiveScenePath, m_ActiveScene); - } - - auto EditorLayer::SaveSceneAs() -> void - { - EP_PROFILE_FN("EditorLayer::SaveSceneAs"); - - FileDialog::SaveFile( - "SaveSceneAs", "Save Scene As", FileDialog::BuildFilter("EppoEngine Scene", { "epscene" }), - Project::GetAssetsDirectory(), - [this](const std::filesystem::path& path) - { - m_ActiveScenePath = path; - SaveScene(); - } - ); - } + } + + auto EditorLayer::RestoreDefaultLayout() -> void + { + const auto path = FS::GetResourcesDirectory() / "Layouts" / "DefaultLayout.ini"; + if (!FS::Exists(path)) + { + Log::Error("Cannot restore window layout: default layout not found at '{}'", path); + return; + } + + const std::string layout = FS::ReadText(path); + if (layout.empty()) + return; // FS::ReadText already logged the failure. + + ImGui::LoadIniSettingsFromMemory(layout.c_str(), layout.size()); + + // The docking layout references panel windows by name, so reopen every panel + // to guarantee the restored dock nodes have their windows to populate. + m_PanelManager->SetPanelOpen(CONTENT_BROWSER_PANEL, true); + m_PanelManager->SetPanelOpen(LOG_PANEL, true); + m_PanelManager->SetPanelOpen(PROPERTY_PANEL, true); + m_PanelManager->SetPanelOpen(SCENE_HIERARCHY_PANEL, true); + m_PanelManager->SetPanelOpen(SCENE_SETTINGS_PANEL, true); + } + + auto EditorLayer::CloseProject() -> void + { + EP_PROFILE_FN("EditorLayer::CloseProject"); + + // Unload the per-project user assembly (collectible), but keep the + // scripting runtime + core assembly alive for the next project. + if (ScriptEngine::IsInitialized()) + ScriptEngine::Get().UnloadUserAssembly(); + + SaveProject(); + + auto scene = CreateRef(); + + m_PanelManager->SetSceneContext(scene); + + if (Project::GetActive()) + Project::SetActive(nullptr); + + m_EditorScene = scene; + m_ActiveScene = scene; + } + + auto EditorLayer::NewProject(const std::string& name) -> void + { + EP_PROFILE_FN("EditorLayer::NewProject"); + + // Create project directory + const auto projectPath = Project::GetProjectsDirectory() / name; + FS::CreateDir(projectPath); + + // Create asset directories + FS::CreateDir(projectPath / "Assets" / "Meshes"); + FS::CreateDir(projectPath / "Assets" / "Scenes"); + FS::CreateDir(projectPath / "Assets" / "Scripts"); + + // Copy new project template + FS::Copy(FS::GetResourcesDirectory() / "Templates" / "NewProject", projectPath); + + // Replace tokens + constexpr auto ReplaceToken = [](std::string& input, const char* token, const std::string& value) -> void + { + size_t pos = 0; + while ((pos = input.find(token, pos)) != std::string::npos) + { + input.replace(pos, strlen(token), value); + pos += strlen(token); + } + }; + + { + auto inputStr = FS::ReadText(projectPath / "project.epproj"); + ReplaceToken(inputStr, "$PROJECT_NAME$", name); + FS::WriteText(projectPath / "project.epproj", inputStr, true); + FS::Move(projectPath / "project.epproj", projectPath / std::filesystem::path(name + ".epproj")); + } + + { + auto inputStr = FS::ReadText(projectPath / "premake5.lua"); + ReplaceToken(inputStr, "$PROJECT_NAME$", name); + FS::WriteText(projectPath / "premake5.lua", inputStr, true); + } + + // Rename the scripts project after the project. The EppoScriptCore + // reference is resolved at build time via $(CoreManagedDll) (passed by + // OpenProject), so there is no path to bake in here. + { + const auto templateCsproj = projectPath / "Scripts" / "Scripts.csproj"; + FS::Move(templateCsproj, projectPath / "Scripts" / std::filesystem::path(name + ".csproj")); + } + + OpenProject(projectPath / std::filesystem::path(name + ".epproj")); + } + + auto EditorLayer::OpenProject() -> void + { + EP_PROFILE_FN("EditorLayer::OpenProject"); + + FileDialog::OpenFile( + "OpenProject", "Open Project", FileDialog::BuildFilter("EppoEngine Project", { "epproj" }), Project::GetProjectsDirectory(), + [this](const std::filesystem::path& path) + { + OpenProject(path); + } + ); + } + + auto EditorLayer::OpenProject(const std::filesystem::path& path) -> bool + { + EP_PROFILE_FN("EditorLayer::OpenProject"); + + if (path.extension().string() != ".epproj") + { + Log::Error("Could not load '{}' because it is not a project file!", path); + return false; + } + + if (Project::GetActive()) + CloseProject(); + + if (Project::Open(path)) + { + const auto& projSpec = Project::GetActive()->GetSpecification(); + + // Before opening the scene: deserialization populates ScriptEngine's field + // storage. Init runs even when the user's scripts fail to build, or scene + // load would drop every serialized field value and the next save would + // write them back out empty. + const auto runtimeConfigPath = FS::GetExecutableDirectory() / "runtimeconfig.json"; + if (!ScriptEngine::Init(runtimeConfigPath)) + Log::Error("Failed to initialize the script runtime for project '{}'.", projSpec.Name); + else + ScriptEngine::Get().ReloadProjectAssembly(); + + // Now that scripting is ready, open the start scene. + if (projSpec.StartScene) + OpenScene(projSpec.StartScene); + else + NewScene(); + } + + return true; + } + + auto EditorLayer::SaveProject() -> bool + { + EP_PROFILE_FN("EditorLayer::SaveProject"); + + SaveScene(); + + if (!Project::GetActive()->GetSpecification().StartScene) + Project::GetActive()->GetSpecification().StartScene = m_ActiveScene->Handle; + + return Project::SaveActive(); + } + + auto EditorLayer::ExportGame() -> void + { + const Ref project = Project::GetActive(); + if (!project) + return; + + if (!SaveProject()) + { + m_ExportResult = {}; + m_ExportResult.Errors.emplace_back("The project could not be saved before export."); + m_ExportResultPopup = true; + return; + } + + FileDialog::OpenFolder( + "ExportGame", "Export To", project->GetSpecification().ProjectDirectory.parent_path(), + [this, project](const std::filesystem::path& parentDirectory) + { + ProjectExportOptions options{ + .ParentDirectory = parentDirectory, + .SourceDirectory = std::filesystem::current_path().parent_path(), + .ExportDebug = m_ExportDebug, + .ExportRelease = m_ExportRelease, + }; + + options.ProgressCallback = [this](const float progress, const std::string_view phase) + { + const std::scoped_lock lock(m_ExportProgressMutex); + m_ExportProgress = progress; + m_ExportPhase = phase; + }; + + { + const std::scoped_lock lock(m_ExportProgressMutex); + m_ExportProgress = 0.0f; + m_ExportPhase = "Starting export"; + } + + m_ExportInProgress = true; + try + { + m_ExportFuture = std::async( + std::launch::async, + [project, options = std::move(options)]() mutable -> ProjectExportResult + { + return ProjectExporter(project).Export(options); + } + ); + m_ExportProgressPopup = true; + } + catch (const std::exception& exception) + { + m_ExportInProgress = false; + m_ExportResult = {}; + m_ExportResult.Errors.emplace_back(std::format("Failed to start export: {}", exception.what())); + m_ExportResultPopup = true; + } + } + ); + } + + auto EditorLayer::NewScene() -> void + { + EP_PROFILE_FN("EditorLayer::NewScene"); + + m_EditorScene = CreateRef(); + m_ActiveScene = m_EditorScene; + m_ActiveScenePath = std::filesystem::path(); + m_PanelManager->SetSceneContext(m_ActiveScene); + } + + auto EditorLayer::OpenScene() -> void + { + EP_PROFILE_FN("EditorLayer::OpenScene"); + + FileDialog::OpenFile( + "OpenScene", "Open Scene", FileDialog::BuildFilter("EppoEngine Scene", { "epscene" }), Project::GetAssetsDirectory(), + [this](const std::filesystem::path& path) + { + OpenScene(path); + } + ); + } + + auto EditorLayer::OpenScene(const std::filesystem::path& path) -> bool + { + EP_PROFILE_FN("EditorLayer::OpenScene"); + + if (path.extension().string() != ".epscene") + { + Log::Error("Could not load '{}' because it is not a scene file!", path); + return false; + } + + const auto scene = CreateRef(); + const SceneSerializer serializer(scene); + + if (serializer.Deserialize(path)) + { + m_EditorScene = scene; + m_ActiveScene = m_EditorScene; + m_ActiveScenePath = Project::GetAssetFilepath(path); + m_PanelManager->SetSceneContext(m_ActiveScene); + } + else + { + Log::Error("Failed to deserialize scene '{}'!", path); + return false; + } + + return true; + } + + auto EditorLayer::OpenScene(AssetHandle handle) -> void + { + EP_PROFILE_FN("EditorLayer::OpenScene"); + + const auto& assetManager = Project::GetActive()->GetAssetManager(); + m_EditorScene = std::static_pointer_cast(assetManager->GetOrLoadAsset(handle)); + m_ActiveScene = m_EditorScene; + m_ActiveScenePath = Project::GetAssetFilepath(assetManager->GetMetadata(handle).Filepath); + + m_PanelManager->SetSceneContext(m_ActiveScene); + } + + auto EditorLayer::SaveScene() -> void + { + EP_PROFILE_FN("EditorLayer::SaveScene"); + + // An unsaved scene has no path yet; route through the async Save-As dialog, which + // sets the path and calls back into here once the user confirms. + if (m_ActiveScenePath.empty()) + { + SaveSceneAs(); + return; + } + + const SceneSerializer serializer(m_ActiveScene); + serializer.Serialize(m_ActiveScenePath); + + const auto& assetManager = Project::GetActive()->GetAssetManager(); + if (assetManager && !assetManager->HasAssetData(m_ActiveScene->Handle)) + assetManager->CreateAsset(m_ActiveScenePath, m_ActiveScene); + } + + auto EditorLayer::SaveSceneAs() -> void + { + EP_PROFILE_FN("EditorLayer::SaveSceneAs"); + + FileDialog::SaveFile( + "SaveSceneAs", "Save Scene As", FileDialog::BuildFilter("EppoEngine Scene", { "epscene" }), Project::GetAssetsDirectory(), + [this](const std::filesystem::path& path) + { + m_ActiveScenePath = path; + SaveScene(); + } + ); + } auto EditorLayer::UpdateImGuizmo() -> void { - if (m_SceneState != SceneState::Edit) - return; + if (m_SceneState != SceneState::Edit) + return; ImGuizmo::SetOrthographic(false); ImGuizmo::SetDrawlist(); @@ -823,16 +853,19 @@ namespace Eppo if (m_SelectedEntity && m_SelectedEntity.HasComponent()) { auto& tc = m_SelectedEntity.GetComponent(); - glm::mat4 transform = glm::translate(glm::mat4(1.0f), tc.Translation) - * glm::mat4_cast(glm::quat(tc.Rotation)) - * glm::scale(glm::mat4(1.0f), tc.Scale); + glm::mat4 transform = glm::translate(glm::mat4(1.0f), tc.Translation) * glm::mat4_cast(glm::quat(tc.Rotation)) * + glm::scale(glm::mat4(1.0f), tc.Scale); - ImGuizmo::Manipulate(glm::value_ptr(view), glm::value_ptr(proj), m_GizmoType, ImGuizmo::LOCAL, glm::value_ptr(transform), nullptr, nullptr); + ImGuizmo::Manipulate( + glm::value_ptr(view), glm::value_ptr(proj), m_GizmoType, ImGuizmo::LOCAL, glm::value_ptr(transform), nullptr, nullptr + ); if (ImGuizmo::IsUsing()) { glm::vec3 translation, rotation, scale; - ImGuizmo::DecomposeMatrixToComponents(glm::value_ptr(transform), glm::value_ptr(translation), glm::value_ptr(rotation), glm::value_ptr(scale)); + ImGuizmo::DecomposeMatrixToComponents( + glm::value_ptr(transform), glm::value_ptr(translation), glm::value_ptr(rotation), glm::value_ptr(scale) + ); tc.Translation = translation; tc.Rotation = glm::radians(rotation); @@ -843,363 +876,381 @@ namespace Eppo // View orientation indicator in the top-right corner (display-only — // interactivity would require syncing back into EditorCamera's internal // position/pitch/yaw, adding fragility for little gain). - ImGuizmo::ViewManipulate(glm::value_ptr(view), 8.0f, - ImVec2(imageMax.x - 128.0f, imageMin.y), - ImVec2(128.0f, 128.0f), - 0x00000000); + ImGuizmo::ViewManipulate(glm::value_ptr(view), 8.0f, ImVec2(imageMax.x - 128.0f, imageMin.y), ImVec2(128.0f, 128.0f), 0x00000000); } auto EditorLayer::UI_Toolbar() -> void - { - constexpr float buttonSize = 30.0f; - constexpr float rounding = 8.0f; - constexpr float topMargin = 24.0f; - - enum class ToolbarAction { None, Play, Stop }; - struct ToolbarButton - { - const char* Id; - Ref Icon; - const char* Fallback; - bool Enabled; - ToolbarAction Action; - }; - - // Buttons are packed edge-to-edge (no padding/spacing); the panel supplies - // the rounded corners. Pause is shown during play but intentionally not - // wired up yet, so it renders disabled. - std::vector buttons; - switch (m_SceneState) - { - case SceneState::Edit: - buttons.push_back({ "##Play", m_PlayIcon, "Play", ScriptEngine::IsUserAssemblyValid(), ToolbarAction::Play }); - break; - case SceneState::Play: - buttons.push_back({ "##Pause", m_PauseIcon, "II", false, ToolbarAction::None }); - buttons.push_back({ "##Stop", m_StopIcon, "Stop", true, ToolbarAction::Stop }); - break; - } - - if (buttons.empty()) - return; - - const float panelWidth = buttonSize * static_cast(buttons.size()); - const ImVec2 winPos = ImGui::GetWindowPos(); - const ImVec2 winSize = ImGui::GetWindowSize(); - const ImVec2 panelMin = { winPos.x + (winSize.x - panelWidth) * 0.5f, winPos.y + topMargin }; - const ImVec2 panelMax = { panelMin.x + panelWidth, panelMin.y + buttonSize }; - - ImDrawList* drawList = ImGui::GetWindowDrawList(); - drawList->AddRectFilled(panelMin, panelMax, ImGui::GetColorU32(ImVec4(0.09f, 0.09f, 0.10f, 0.85f)), rounding); - - for (size_t i = 0; i < buttons.size(); i++) - { - const ToolbarButton& button = buttons[i]; - - const ImVec2 p0 = { panelMin.x + buttonSize * static_cast(i), panelMin.y }; - const ImVec2 p1 = { p0.x + buttonSize, p0.y + buttonSize }; - - // Round only the corners this button shares with the panel. - ImDrawFlags corners = ImDrawFlags_RoundCornersNone; - if (i == 0) - corners |= ImDrawFlags_RoundCornersLeft; - if (i == buttons.size() - 1) - corners |= ImDrawFlags_RoundCornersRight; - - ImGui::SetCursorScreenPos(p0); - ImGui::InvisibleButton(button.Id, ImVec2(buttonSize, buttonSize)); - - // The hitbox is rectangular; ignore hovers/clicks in the rounded corner - // arcs so the outer, non-button region doesn't activate. - const bool inside = Utils::IsInsideRoundedRect(ImGui::GetIO().MousePos, panelMin, panelMax, rounding); - const bool hovered = button.Enabled && inside && ImGui::IsItemHovered(); - const bool held = hovered && ImGui::IsItemActive(); - const bool clicked = button.Enabled && inside && ImGui::IsItemClicked(); - - if (held) - drawList->AddRectFilled(p0, p1, ImGui::GetColorU32(ImVec4(0.91f, 0.39f, 0.11f, 0.90f)), rounding, corners); - else if (hovered) - drawList->AddRectFilled(p0, p1, ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, 0.14f)), rounding, corners); - - const ImU32 tint = button.Enabled ? IM_COL32_WHITE : IM_COL32(255, 255, 255, 70); - if (button.Icon) - { - constexpr float pad = 6.0f; - drawList->AddImage(ImGuiEx::CreateTextureRef(button.Icon->GetTexture()), - { p0.x + pad, p0.y + pad }, { p1.x - pad, p1.y - pad }, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), tint); - } - else - { - const ImVec2 ts = ImGui::CalcTextSize(button.Fallback); - drawList->AddText({ p0.x + (buttonSize - ts.x) * 0.5f, p0.y + (buttonSize - ts.y) * 0.5f }, tint, button.Fallback); - } - - if (clicked) - { - switch (button.Action) - { - case ToolbarAction::Play: OnScenePlay(); break; - case ToolbarAction::Stop: OnSceneStop(); break; - case ToolbarAction::None: break; - } - break; // scene state changed; stop iterating this frame's snapshot - } - } - } - - auto EditorLayer::UI_NewProjectPopup() -> void - { - constexpr ImGuiWindowFlags windowFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; - if (ImGui::BeginPopupModal("New Project", nullptr, windowFlags)) - { - static std::string projectName; - - ImGui::Text("Project Name"); - ImGui::InputText("##ProjectName", &projectName); - - const auto projectPath = Project::GetProjectsDirectory() / projectName; - const bool projectExists = !projectName.empty() && FS::Exists(projectPath); - - if (projectExists) - { - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.8f, 0.1f, 0.1f, 1.0f)); - ImGui::Text("Project name already exists!"); - ImGui::PopStyleColor(); - } - else if (!projectName.empty()) - { - ImGui::Text("Project path: \n%s", projectPath.string().c_str()); - } - - if (ImGui::Button("Cancel", ImVec2(100, 30))) - ImGui::CloseCurrentPopup(); - - ImGui::SameLine(); - - if (projectExists) - ImGui::BeginDisabled(); - - if (ImGui::Button("Create", ImVec2(100, 30))) - { - NewProject(projectName); - ImGui::CloseCurrentPopup(); - } - - if (projectExists) - ImGui::EndDisabled(); - - ImGui::EndPopup(); - } - } - - auto EditorLayer::UI_RelationshipRepairPopup() -> void - { - auto notices = SceneSerializer::ConsumeRelationshipRepairNotices(); - m_RelationshipRepairNotices.insert(m_RelationshipRepairNotices.end(), notices.begin(), notices.end()); - if (!m_RelationshipRepairNotices.empty() && !ImGui::IsPopupOpen("Scene hierarchy repaired")) - ImGui::OpenPopup("Scene hierarchy repaired"); - - constexpr ImGuiWindowFlags windowFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - ImGui::SetNextWindowSizeConstraints(ImVec2(420.0f, 0.0f), ImVec2(700.0f, FLT_MAX)); - if (ImGui::BeginPopupModal("Scene hierarchy repaired", nullptr, windowFlags)) - { - ImGui::TextWrapped("Invalid parent/child links were removed while loading the scene. No entities were deleted."); - ImGui::Separator(); - for (const std::string& notice : m_RelationshipRepairNotices) - ImGui::BulletText("%s", notice.c_str()); - - if (ImGui::Button("OK", ImVec2(100.0f, 30.0f))) - { - m_RelationshipRepairNotices.clear(); - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - } - - auto EditorLayer::UI_ExportOptionsPopup() -> void - { - if (m_ExportOptionsPopup) - { - ImGui::OpenPopup("Export Game"); - m_ExportOptionsPopup = false; - } - - constexpr ImGuiWindowFlags windowFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - if (ImGui::BeginPopupModal("Export Game", nullptr, windowFlags)) - { - ImGui::TextUnformatted("Configurations"); - ImGui::Checkbox("Debug", &m_ExportDebug); - ImGui::Checkbox("Release", &m_ExportRelease); - ImGui::Separator(); - - ImGui::BeginDisabled(!m_ExportDebug && !m_ExportRelease); - if (ImGui::Button("Export", ImVec2(100.0f, 30.0f))) - { - ImGui::CloseCurrentPopup(); - ExportGame(); - } - ImGui::EndDisabled(); - ImGui::SameLine(); - if (ImGui::Button("Cancel", ImVec2(100.0f, 30.0f))) - ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - } - } - - auto EditorLayer::UI_ExportProgressPopup() -> void - { - if (m_ExportProgressPopup) - { - ImGui::OpenPopup("Exporting Game"); - m_ExportProgressPopup = false; - } - - constexpr ImGuiWindowFlags windowFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - if (ImGui::BeginPopupModal("Exporting Game", nullptr, windowFlags)) - { - float progress = 0.0f; - std::string phase; - { - const std::scoped_lock lock(m_ExportProgressMutex); - progress = m_ExportProgress; - phase = m_ExportPhase; - } - - ImGui::TextUnformatted(phase.c_str()); - const auto progressText = std::format("{:.0f}%", progress * 100.0f); - ImGui::ProgressBar(progress, ImVec2(440.0f, 0.0f), progressText.c_str()); - - if (m_ExportFuture.valid() && m_ExportFuture.wait_for(std::chrono::seconds(0)) == std::future_status::ready) - { - try - { - m_ExportResult = m_ExportFuture.get(); - } - catch (const std::exception& exception) - { - m_ExportResult = {}; - m_ExportResult.Errors.emplace_back(std::format("Export failed unexpectedly: {}", exception.what())); - } - m_ExportInProgress = false; - m_ExportResultPopup = true; - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - } - - auto EditorLayer::UI_ExportResultPopup() -> void - { - if (m_ExportResultPopup) - { - ImGui::OpenPopup("Export Result"); - m_ExportResultPopup = false; - } - - constexpr ImGuiWindowFlags windowFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; - ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); - ImGui::SetNextWindowSizeConstraints(ImVec2(460.0f, 0.0f), ImVec2(760.0f, FLT_MAX)); - if (ImGui::BeginPopupModal("Export Result", nullptr, windowFlags)) - { - ImGui::TextUnformatted(m_ExportResult.Success ? "Export succeeded" : "Export failed"); - if (m_ExportResult.Success) - ImGui::TextWrapped("Output: %s", m_ExportResult.OutputPath.string().c_str()); - - for (const auto& warning : m_ExportResult.Warnings) - ImGui::BulletText("Warning: %s", warning.c_str()); - for (const auto& error : m_ExportResult.Errors) - ImGui::BulletText("Error: %s", error.c_str()); - - if (ImGui::Button("OK", ImVec2(100.0f, 30.0f))) - ImGui::CloseCurrentPopup(); - ImGui::EndPopup(); - } - } - - auto EditorLayer::UI_ViewportNotices() const -> void - { - ImDrawList* drawList = ImGui::GetWindowDrawList(); - const ImVec2 imageMin = ImGui::GetItemRectMin(); - constexpr ImVec2 pad = { 8.0f, 5.0f }; - float y = imageMin.y + 10.0f; - const auto drawNotice = [&](const char* notice) - { - const ImVec2 textPos = { imageMin.x + 10.0f, y }; - const ImVec2 textSize = ImGui::CalcTextSize(notice); - drawList->AddRectFilled( - { textPos.x - pad.x, textPos.y - pad.y }, - { textPos.x + textSize.x + pad.x, textPos.y + textSize.y + pad.y }, - IM_COL32(18, 18, 20, 205), 4.0f); - drawList->AddText(textPos, IM_COL32(232, 150, 60, 255), notice); - y += textSize.y + pad.y * 2.0f + 4.0f; - }; - - // Shown in edit mode too, unlike the notices below. - if (!ScriptEngine::IsUserAssemblyValid()) - drawNotice("Scripts failed to compile - see the log. Play is disabled until the build succeeds."); - - if (m_SceneState != SceneState::Play) - return; - - if (m_MissingPrimaryCamera) - { - bool hasCameraEntity = false; - m_ActiveScene->ForEachEntity([&](Entity entity) - { - if (!entity.HasComponent()) - return; - - hasCameraEntity = true; - const std::string notice = "Camera entity '" + entity.GetName() + "' is not primary - showing editor view"; - drawNotice(notice.c_str()); - }); - - if (!hasCameraEntity) - { - const std::string sceneName = m_ActiveScenePath.empty() ? "Untitled" : m_ActiveScenePath.stem().string(); - const std::string notice = "Scene '" + sceneName + "' has no camera entity - showing editor view"; - drawNotice(notice.c_str()); - } - } - - for (const std::string& entityName : m_ActiveScene->GetColliderlessRigidBodies()) - { - const std::string notice = "Entity '" + entityName + "' has a rigid body without a collider - it is still simulated"; - drawNotice(notice.c_str()); - } - } + { + constexpr float buttonSize = 30.0f; + constexpr float rounding = 8.0f; + constexpr float topMargin = 24.0f; + + enum class ToolbarAction + { + None, + Play, + Stop + }; + struct ToolbarButton + { + const char* Id; + Ref Icon; + const char* Fallback; + bool Enabled; + ToolbarAction Action; + }; + + // Buttons are packed edge-to-edge (no padding/spacing); the panel supplies + // the rounded corners. Pause is shown during play but intentionally not + // wired up yet, so it renders disabled. + std::vector buttons; + switch (m_SceneState) + { + case SceneState::Edit: + buttons.push_back({ "##Play", m_PlayIcon, "Play", ScriptEngine::IsUserAssemblyValid(), ToolbarAction::Play }); + break; + case SceneState::Play: + buttons.push_back({ "##Pause", m_PauseIcon, "II", false, ToolbarAction::None }); + buttons.push_back({ "##Stop", m_StopIcon, "Stop", true, ToolbarAction::Stop }); + break; + } + + if (buttons.empty()) + return; + + const float panelWidth = buttonSize * static_cast(buttons.size()); + const ImVec2 winPos = ImGui::GetWindowPos(); + const ImVec2 winSize = ImGui::GetWindowSize(); + const ImVec2 panelMin = { winPos.x + (winSize.x - panelWidth) * 0.5f, winPos.y + topMargin }; + const ImVec2 panelMax = { panelMin.x + panelWidth, panelMin.y + buttonSize }; + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddRectFilled(panelMin, panelMax, ImGui::GetColorU32(ImVec4(0.09f, 0.09f, 0.10f, 0.85f)), rounding); + + for (size_t i = 0; i < buttons.size(); i++) + { + const ToolbarButton& button = buttons[i]; + + const ImVec2 p0 = { panelMin.x + buttonSize * static_cast(i), panelMin.y }; + const ImVec2 p1 = { p0.x + buttonSize, p0.y + buttonSize }; + + // Round only the corners this button shares with the panel. + ImDrawFlags corners = ImDrawFlags_RoundCornersNone; + if (i == 0) + corners |= ImDrawFlags_RoundCornersLeft; + if (i == buttons.size() - 1) + corners |= ImDrawFlags_RoundCornersRight; + + ImGui::SetCursorScreenPos(p0); + ImGui::InvisibleButton(button.Id, ImVec2(buttonSize, buttonSize)); + + // The hitbox is rectangular; ignore hovers/clicks in the rounded corner + // arcs so the outer, non-button region doesn't activate. + const bool inside = Utils::IsInsideRoundedRect(ImGui::GetIO().MousePos, panelMin, panelMax, rounding); + const bool hovered = button.Enabled && inside && ImGui::IsItemHovered(); + const bool held = hovered && ImGui::IsItemActive(); + const bool clicked = button.Enabled && inside && ImGui::IsItemClicked(); + + if (held) + drawList->AddRectFilled(p0, p1, ImGui::GetColorU32(ImVec4(0.91f, 0.39f, 0.11f, 0.90f)), rounding, corners); + else if (hovered) + drawList->AddRectFilled(p0, p1, ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, 0.14f)), rounding, corners); + + const ImU32 tint = button.Enabled ? IM_COL32_WHITE : IM_COL32(255, 255, 255, 70); + if (button.Icon) + { + constexpr float pad = 6.0f; + drawList->AddImage( + ImGuiEx::CreateTextureRef(button.Icon->GetTexture()), { p0.x + pad, p0.y + pad }, { p1.x - pad, p1.y - pad }, + ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), tint + ); + } + else + { + const ImVec2 ts = ImGui::CalcTextSize(button.Fallback); + drawList->AddText({ p0.x + (buttonSize - ts.x) * 0.5f, p0.y + (buttonSize - ts.y) * 0.5f }, tint, button.Fallback); + } + + if (clicked) + { + switch (button.Action) + { + case ToolbarAction::Play: + OnScenePlay(); + break; + case ToolbarAction::Stop: + OnSceneStop(); + break; + case ToolbarAction::None: + break; + } + break; // scene state changed; stop iterating this frame's snapshot + } + } + } + + auto EditorLayer::UI_NewProjectPopup() -> void + { + constexpr ImGuiWindowFlags windowFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; + if (ImGui::BeginPopupModal("New Project", nullptr, windowFlags)) + { + static std::string projectName; + + ImGui::Text("Project Name"); + ImGui::InputText("##ProjectName", &projectName); + + const auto projectPath = Project::GetProjectsDirectory() / projectName; + const bool projectExists = !projectName.empty() && FS::Exists(projectPath); + + if (projectExists) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.8f, 0.1f, 0.1f, 1.0f)); + ImGui::Text("Project name already exists!"); + ImGui::PopStyleColor(); + } + else if (!projectName.empty()) + { + ImGui::Text("Project path: \n%s", projectPath.string().c_str()); + } + + if (ImGui::Button("Cancel", ImVec2(100, 30))) + ImGui::CloseCurrentPopup(); + + ImGui::SameLine(); + + if (projectExists) + ImGui::BeginDisabled(); + + if (ImGui::Button("Create", ImVec2(100, 30))) + { + NewProject(projectName); + ImGui::CloseCurrentPopup(); + } + + if (projectExists) + ImGui::EndDisabled(); + + ImGui::EndPopup(); + } + } + + auto EditorLayer::UI_RelationshipRepairPopup() -> void + { + auto notices = SceneSerializer::ConsumeRelationshipRepairNotices(); + m_RelationshipRepairNotices.insert(m_RelationshipRepairNotices.end(), notices.begin(), notices.end()); + if (!m_RelationshipRepairNotices.empty() && !ImGui::IsPopupOpen("Scene hierarchy repaired")) + ImGui::OpenPopup("Scene hierarchy repaired"); + + constexpr ImGuiWindowFlags windowFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSizeConstraints(ImVec2(420.0f, 0.0f), ImVec2(700.0f, FLT_MAX)); + if (ImGui::BeginPopupModal("Scene hierarchy repaired", nullptr, windowFlags)) + { + ImGui::TextWrapped("Invalid parent/child links were removed while loading the scene. No entities were deleted."); + ImGui::Separator(); + for (const std::string& notice : m_RelationshipRepairNotices) + ImGui::BulletText("%s", notice.c_str()); + + if (ImGui::Button("OK", ImVec2(100.0f, 30.0f))) + { + m_RelationshipRepairNotices.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + + auto EditorLayer::UI_ExportOptionsPopup() -> void + { + if (m_ExportOptionsPopup) + { + ImGui::OpenPopup("Export Game"); + m_ExportOptionsPopup = false; + } + + constexpr ImGuiWindowFlags windowFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (ImGui::BeginPopupModal("Export Game", nullptr, windowFlags)) + { + ImGui::TextUnformatted("Configurations"); + ImGui::Checkbox("Debug", &m_ExportDebug); + ImGui::Checkbox("Release", &m_ExportRelease); + ImGui::Separator(); + + ImGui::BeginDisabled(!m_ExportDebug && !m_ExportRelease); + if (ImGui::Button("Export", ImVec2(100.0f, 30.0f))) + { + ImGui::CloseCurrentPopup(); + ExportGame(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(100.0f, 30.0f))) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + } + + auto EditorLayer::UI_ExportProgressPopup() -> void + { + if (m_ExportProgressPopup) + { + ImGui::OpenPopup("Exporting Game"); + m_ExportProgressPopup = false; + } + + constexpr ImGuiWindowFlags windowFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (ImGui::BeginPopupModal("Exporting Game", nullptr, windowFlags)) + { + float progress = 0.0f; + std::string phase; + { + const std::scoped_lock lock(m_ExportProgressMutex); + progress = m_ExportProgress; + phase = m_ExportPhase; + } + + ImGui::TextUnformatted(phase.c_str()); + const auto progressText = std::format("{:.0f}%", progress * 100.0f); + ImGui::ProgressBar(progress, ImVec2(440.0f, 0.0f), progressText.c_str()); + + if (m_ExportFuture.valid() && m_ExportFuture.wait_for(std::chrono::seconds(0)) == std::future_status::ready) + { + try + { + m_ExportResult = m_ExportFuture.get(); + } + catch (const std::exception& exception) + { + m_ExportResult = {}; + m_ExportResult.Errors.emplace_back(std::format("Export failed unexpectedly: {}", exception.what())); + } + m_ExportInProgress = false; + m_ExportResultPopup = true; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + + auto EditorLayer::UI_ExportResultPopup() -> void + { + if (m_ExportResultPopup) + { + ImGui::OpenPopup("Export Result"); + m_ExportResultPopup = false; + } + + constexpr ImGuiWindowFlags windowFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize; + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSizeConstraints(ImVec2(460.0f, 0.0f), ImVec2(760.0f, FLT_MAX)); + if (ImGui::BeginPopupModal("Export Result", nullptr, windowFlags)) + { + ImGui::TextUnformatted(m_ExportResult.Success ? "Export succeeded" : "Export failed"); + if (m_ExportResult.Success) + ImGui::TextWrapped("Output: %s", m_ExportResult.OutputPath.string().c_str()); + + for (const auto& warning : m_ExportResult.Warnings) + ImGui::BulletText("Warning: %s", warning.c_str()); + for (const auto& error : m_ExportResult.Errors) + ImGui::BulletText("Error: %s", error.c_str()); + + if (ImGui::Button("OK", ImVec2(100.0f, 30.0f))) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + } + + auto EditorLayer::UI_ViewportNotices() const -> void + { + ImDrawList* drawList = ImGui::GetWindowDrawList(); + const ImVec2 imageMin = ImGui::GetItemRectMin(); + constexpr ImVec2 pad = { 8.0f, 5.0f }; + float y = imageMin.y + 10.0f; + const auto DrawNotice = [&](const char* notice) + { + const ImVec2 textPos = { imageMin.x + 10.0f, y }; + const ImVec2 textSize = ImGui::CalcTextSize(notice); + drawList->AddRectFilled( + { textPos.x - pad.x, textPos.y - pad.y }, { textPos.x + textSize.x + pad.x, textPos.y + textSize.y + pad.y }, + IM_COL32(18, 18, 20, 205), 4.0f + ); + drawList->AddText(textPos, IM_COL32(232, 150, 60, 255), notice); + y += textSize.y + pad.y * 2.0f + 4.0f; + }; + + // Shown in edit mode too, unlike the notices below. + if (ScriptEngine::IsUserAssemblyCompiling()) + DrawNotice("Scripts are currently compiling. Play is disabled until the build succeeds."); + else if (!ScriptEngine::IsUserAssemblyValid()) + DrawNotice("Scripts failed to compile - see the log. Play is disabled until the build succeeds."); + + if (m_SceneState != SceneState::Play) + return; + + if (m_MissingPrimaryCamera) + { + bool hasCameraEntity = false; + m_ActiveScene->ForEachEntity( + [&](Entity entity) + { + if (!entity.HasComponent()) + return; + + hasCameraEntity = true; + const std::string notice = "Camera entity '" + entity.GetName() + "' is not primary - showing editor view"; + DrawNotice(notice.c_str()); + } + ); + + if (!hasCameraEntity) + { + const std::string sceneName = m_ActiveScenePath.empty() ? "Untitled" : m_ActiveScenePath.stem().string(); + const std::string notice = "Scene '" + sceneName + "' has no camera entity - showing editor view"; + DrawNotice(notice.c_str()); + } + } + + for (const std::string& entityName : m_ActiveScene->GetColliderlessRigidBodies()) + { + const std::string notice = "Entity '" + entityName + "' has a rigid body without a collider - it is still simulated"; + DrawNotice(notice.c_str()); + } + } namespace Utils - { - // Point-in-rounded-rect test. Used by the toolbar so clicks that land in the - // transparent corner arcs (outside the visual rounded panel but inside the - // rectangular widget hitbox) are ignored. - auto IsInsideRoundedRect(const ImVec2& p, const ImVec2& min, const ImVec2& max, float radius) -> bool - { - if (p.x < min.x || p.x > max.x || p.y < min.y || p.y > max.y) - return false; - - const auto outsideCorner = [&](float cx, float cy) -> bool - { - const float dx = p.x - cx; - const float dy = p.y - cy; - return dx * dx + dy * dy > radius * radius; - }; - - if (p.x < min.x + radius && p.y < min.y + radius) // top-left - return !outsideCorner(min.x + radius, min.y + radius); - if (p.x > max.x - radius && p.y < min.y + radius) // top-right - return !outsideCorner(max.x - radius, min.y + radius); - if (p.x < min.x + radius && p.y > max.y - radius) // bottom-left - return !outsideCorner(min.x + radius, max.y - radius); - if (p.x > max.x - radius && p.y > max.y - radius) // bottom-right - return !outsideCorner(max.x - radius, max.y - radius); - - return true; - } - } + { + // Point-in-rounded-rect test. Used by the toolbar so clicks that land in the + // transparent corner arcs (outside the visual rounded panel but inside the + // rectangular widget hitbox) are ignored. + auto IsInsideRoundedRect(const ImVec2& p, const ImVec2& min, const ImVec2& max, float radius) -> bool + { + if (p.x < min.x || p.x > max.x || p.y < min.y || p.y > max.y) + return false; + + const auto outsideCorner = [&](float cx, float cy) -> bool + { + const float dx = p.x - cx; + const float dy = p.y - cy; + return dx * dx + dy * dy > radius * radius; + }; + + if (p.x < min.x + radius && p.y < min.y + radius) // top-left + return !outsideCorner(min.x + radius, min.y + radius); + if (p.x > max.x - radius && p.y < min.y + radius) // top-right + return !outsideCorner(max.x - radius, min.y + radius); + if (p.x < min.x + radius && p.y > max.y - radius) // bottom-left + return !outsideCorner(min.x + radius, max.y - radius); + if (p.x > max.x - radius && p.y > max.y - radius) // bottom-right + return !outsideCorner(max.x - radius, max.y - radius); + + return true; + } + } } diff --git a/EppoEditor/Source/EditorLayer.h b/EppoEditor/Source/EditorLayer.h index e2cd34f1..d5d4e6c7 100644 --- a/EppoEditor/Source/EditorLayer.h +++ b/EppoEditor/Source/EditorLayer.h @@ -1,6 +1,7 @@ #pragma once #include "Panels/PanelManager.h" +#include "StatusBar.h" #include @@ -98,6 +99,9 @@ namespace Eppo // Gizmo ImGuizmo::OPERATION m_GizmoType = ImGuizmo::TRANSLATE; + + // Status bar (non-dockable bottom strip) + StatusBar m_StatusBar; }; namespace Utils diff --git a/EppoEditor/Source/StatusBar.cpp b/EppoEditor/Source/StatusBar.cpp new file mode 100644 index 00000000..d271e7fd --- /dev/null +++ b/EppoEditor/Source/StatusBar.cpp @@ -0,0 +1,173 @@ +#include "StatusBar.h" + +#include + +#include + +#include +#include +#include + +namespace Eppo +{ + namespace + { + constexpr float StatusBarHeight = 24.0f; + constexpr float PopupWidth = 360.0f; + constexpr float PopupMaxHeight = 240.0f; + } + + auto StatusBar::Render() -> void + { + // GetPendingTasksCount() is the authoritative "is the pool busy" signal: it + // counts every queued/in-flight task via atomics, including unnamed tasks + // that do not appear in any snapshot, and returns to 0 when the pool is idle. + // Snapshots drive only the per-group detail in the drop-up. + auto& threadPool = *Application::Get().GetThreadPool(); + const uint32_t inFlight = threadPool.GetPendingTasksCount(); + std::unordered_map snapshots = threadPool.GetTaskGroupSnapshots(); + + // Drop finished groups from the popup list so it doesn't grow unbounded. + std::erase_if( + snapshots, + [](const auto& kv) + { + return kv.second.IsFinished(); + } + ); + + const bool hasTasks = inFlight > 0; + + // The strip occupies the bottom of the DockSpace host window (the host is + // the current window here; EditorLayer reserves the space by sizing the + // DockSpace smaller and calling Render() before the host's End()). + const ImVec2 winPos = ImGui::GetWindowPos(); + const ImVec2 winSize = ImGui::GetWindowSize(); + const ImVec2 barMin = { winPos.x, winPos.y + winSize.y - StatusBarHeight }; + const ImVec2 barMax = { winPos.x + winSize.x, winPos.y + winSize.y }; + + ImDrawList* drawList = ImGui::GetWindowDrawList(); + drawList->AddRectFilled(barMin, barMax, ImGui::GetColorU32(ImGuiCol_MenuBarBg)); + drawList->AddLine(barMin, { barMax.x, barMin.y }, ImGui::GetColorU32(ImGuiCol_Separator)); + + // Running indicator: an accent dot to the left of the label when busy. + if (hasTasks) + { + const ImVec2 dot = { barMin.x + 12.0f, barMin.y + StatusBarHeight * 0.5f }; + drawList->AddCircleFilled(dot, 4.0f, ImGui::GetColorU32(ImVec4(0.91f, 0.39f, 0.11f, 1.0f))); + } + + // Clickable flat button spanning the bar. Only opens the drop-up when there + // are tasks to show; an idle bar stays non-interactive. + const std::string label = hasTasks ? std::format("{} background task{} running##statusbar", inFlight, inFlight == 1 ? "" : "s") + : std::string("No background tasks running##statusbar"); + + ImGui::SetCursorScreenPos(barMin); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1.0f, 1.0f, 1.0f, 0.10f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1.0f, 1.0f, 1.0f, 0.16f)); + ImGui::Button(label.c_str(), ImVec2(winSize.x, StatusBarHeight)); + ImGui::PopStyleColor(3); + + // Open the drop-up on click. The anchor (top-left of the popup, growing + // upward) is recomputed and re-applied every frame the popup is open so it + // stays locked to the bar instead of drifting. + const ImVec2 popupAnchor = { barMin.x, barMin.y - 1.0f }; + + if (hasTasks && ImGui::IsItemClicked() && !m_PopupOpen) + { + ImGui::OpenPopup("##TaskListPopup"); + m_PopupOpen = true; + } + + DrawTaskListPopup(popupAnchor, std::move(snapshots), inFlight); + } + + auto + StatusBar::DrawTaskListPopup(const ImVec2& anchorPos, std::unordered_map snapshots, uint32_t inFlight) + -> void + { + if (!m_PopupOpen) + return; + + // Re-pin the popup above the bar every frame: pivot (0,1) places the + // window's bottom-left at anchorPos so it grows upward and stays locked. + ImGui::SetNextWindowPos(anchorPos, ImGuiCond_Always, ImVec2(0.0f, 1.0f)); + ImGui::SetNextWindowSizeConstraints(ImVec2(PopupWidth, 0.0f), ImVec2(PopupWidth, PopupMaxHeight)); + + constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize; + + if (ImGui::BeginPopup("##TaskListPopup", flags)) + { + ImGui::AlignTextToFramePadding(); + ImGui::TextDisabled("Background Tasks"); + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextDisabled("(%u in flight)", inFlight); + ImGui::Separator(); + + if (snapshots.empty()) + { + ImGui::TextDisabled("No background tasks"); + if (inFlight > 0) + ImGui::TextDisabled("%u unnamed task(s) running", inFlight); + } + else + { + for (const auto& [name, snapshot] : snapshots) + { + const auto pending = snapshot.Pending.load(std::memory_order_relaxed); + const auto running = snapshot.Running.load(std::memory_order_relaxed); + const auto completed = snapshot.Completed.load(std::memory_order_relaxed); + const auto failed = snapshot.Failed.load(std::memory_order_relaxed); + const auto cancelled = snapshot.Cancelled.load(std::memory_order_relaxed); + const auto total = snapshot.Total.load(std::memory_order_relaxed); + + ImGui::TextUnformatted(name.c_str()); + // Progress = completed / total. Pending and Running are shown as + // a status line so a partially-dispatched group is distinguishable + // from a stalled one. + const float frac = total > 0 ? static_cast(completed) / static_cast(total) : 0.0f; + ImGui::ProgressBar(frac, ImVec2(-1.0f, 0.0f)); + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextDisabled("%u/%u", completed, total); + + std::string status; + if (running > 0) + status += std::format("{} running", running); + if (pending > 0) + { + if (!status.empty()) + status += ", "; + status += std::format("{} pending", pending); + } + if (failed > 0) + { + if (!status.empty()) + status += ", "; + status += std::format("{} failed", failed); + } + if (cancelled > 0) + { + if (!status.empty()) + status += ", "; + status += std::format("{} cancelled", cancelled); + } + if (!status.empty()) + { + ImGui::Indent(); + ImGui::TextDisabled("%s", status.c_str()); + ImGui::Unindent(); + } + } + } + + ImGui::EndPopup(); + } + else + { + // BeginPopup returned false: closed by outside click or Esc. + m_PopupOpen = false; + } + } +} diff --git a/EppoEditor/Source/StatusBar.h b/EppoEditor/Source/StatusBar.h new file mode 100644 index 00000000..48917009 --- /dev/null +++ b/EppoEditor/Source/StatusBar.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include + +namespace Eppo +{ + class StatusBar + { + public: + auto Render() -> void; + + private: + auto DrawTaskListPopup(const ImVec2& anchorPos, std::unordered_map snapshots, uint32_t inFlight) + -> void; + + bool m_PopupOpen = false; + }; +} diff --git a/EppoEngine/Source/Asset/Asset.h b/EppoEngine/Source/Asset/Asset.h index 1254c24b..052d94c1 100644 --- a/EppoEngine/Source/Asset/Asset.h +++ b/EppoEngine/Source/Asset/Asset.h @@ -13,9 +13,11 @@ namespace Eppo virtual ~Asset() = default; AssetHandle Handle; + std::atomic IsLoaded = false; + static auto GetStaticType() -> AssetType { return AssetType::None; } virtual auto operator==(const Asset& other) const -> bool { return Handle == other.Handle; } virtual auto operator!=(const Asset& other) const -> bool { return !(*this == other); } }; -} \ No newline at end of file +} diff --git a/EppoEngine/Source/Asset/AssetManager.cpp b/EppoEngine/Source/Asset/AssetManager.cpp index a55408f7..381695d5 100644 --- a/EppoEngine/Source/Asset/AssetManager.cpp +++ b/EppoEngine/Source/Asset/AssetManager.cpp @@ -61,9 +61,9 @@ namespace Eppo return true; } - auto AssetManager::GetOrLoadAsset(AssetHandle handle, bool async) -> Ref + auto AssetManager::GetOrLoadAsset(AssetHandle handle, const bool async) -> Ref { - EP_PROFILE_FN("AssetManager::LoadAsset"); + EP_PROFILE_FN("AssetManager::GetOrLoadAsset"); std::scoped_lock lock(m_Mutex); @@ -73,8 +73,8 @@ namespace Eppo Ref asset = nullptr; - // Create generated asset if handle is reserved - if (auto id = static_cast(handle); id < 100) + // Get placeholder asset if handle is reserved + if (auto id = static_cast(handle); id < 11) asset = GenerateAsset(handle); // Create asset instance @@ -133,12 +133,14 @@ namespace Eppo auto AssetManager::HasAssetData(AssetHandle handle) const -> bool { + std::shared_lock lock(m_Mutex); return m_AssetData.contains(handle); } auto AssetManager::IsAssetLoaded(AssetHandle handle) const -> bool { - return m_AssetData.contains(handle) && m_LoadedAssets.contains(handle); + std::shared_lock lock(m_Mutex); + return m_LoadedAssets.contains(handle); } auto AssetManager::GetMetadata(AssetHandle handle) -> AssetMetadata& @@ -185,6 +187,39 @@ namespace Eppo SerializeAssetRegistry(); } + auto AssetManager::GetPlaceholderAsset(AssetType type) -> Ref + { + // Reserved id's listed in UUID.cpp + switch (type) + { + case AssetType::Mesh: + { + return GetOrLoadAsset(static_cast(MeshPrimitiveType::Cube)); + break; + } + + case AssetType::Scene: + { + EP_ASSERT(false); + break; + } + + case AssetType::Script: + { + EP_ASSERT(false); + break; + } + + case AssetType::Texture: + { + return GetOrLoadAsset(10); + break; + } + } + + return nullptr; + } + auto AssetManager::SerializeAssetRegistry() const -> void { EP_PROFILE_FN("AssetManager::SerializeAssetRegistry"); @@ -274,6 +309,21 @@ namespace Eppo return mesh; } + if (id == 10) + { + Ref image = Image::GenerateFallbackImage(); + + const AssetMetadata metadata{ + .Handle = handle, + .Type = AssetType::Texture, + .IsRuntimeAsset = true, + }; + + m_AssetData[handle] = metadata; + + return image; + } + return nullptr; } } diff --git a/EppoEngine/Source/Asset/AssetManager.h b/EppoEngine/Source/Asset/AssetManager.h index 89722bb1..ce49fa37 100644 --- a/EppoEngine/Source/Asset/AssetManager.h +++ b/EppoEngine/Source/Asset/AssetManager.h @@ -49,6 +49,8 @@ namespace Eppo // and the content browser (for picking icons on unregistered files). [[nodiscard]] static auto GetAssetTypeFromPath(const std::filesystem::path& path) -> AssetType; + auto GetPlaceholderAsset(AssetType type) -> Ref; + auto SerializeAssetRegistry() const -> void; auto DeserializeAssetRegistry() -> bool; diff --git a/EppoEngine/Source/Core/Application.cpp b/EppoEngine/Source/Core/Application.cpp index abc0c191..7fa138f6 100644 --- a/EppoEngine/Source/Core/Application.cpp +++ b/EppoEngine/Source/Core/Application.cpp @@ -41,6 +41,9 @@ namespace Eppo m_DeviceManager = DeviceManager::Create(m_Window, deviceParams); m_DeviceManager->Init(); + + m_ThreadPool = CreateRef(); + m_DeviceManager->InitRenderer(); // A deployed runtime hands over the shaders it read from its game package; the editor and tests @@ -56,6 +59,8 @@ namespace Eppo { Log::Info("Application shutting down..."); + m_ThreadPool->Shutdown(true); + m_DeviceManager->WaitIdle(); m_ImGuiLayer.reset(); for (auto it = m_LayerStack.begin(); it != m_LayerStack.end();) @@ -92,13 +97,15 @@ namespace Eppo EP_PROFILE_FN("Application::StepFrame") m_Window->ProcessEvents(); + m_ThreadPool->Flush(); if (!m_IsMinimized && m_DeviceManager->BeginFrame()) { - // Render work + // Run layer updates - possibly enqueuing render commands for (const auto& layer : m_LayerStack) layer->OnUpdate(timestep); + // Enqueue imgui render if (m_ImGuiLayer) { m_ImGuiLayer->PrepareRender(); @@ -109,6 +116,9 @@ namespace Eppo m_ImGuiLayer->Render(); } + // Execute render commands + Renderer::ExecuteRenderCommands(); + // Present m_DeviceManager->Present(); } diff --git a/EppoEngine/Source/Core/Application.h b/EppoEngine/Source/Core/Application.h index 32fd4176..7883124a 100644 --- a/EppoEngine/Source/Core/Application.h +++ b/EppoEngine/Source/Core/Application.h @@ -1,6 +1,7 @@ #pragma once #include "Core/Layer.h" +#include "Core/Threading/ThreadPool.h" #include "Core/Window.h" #include "Event/ApplicationEvent.h" #include "ImGui/ImGuiLayer.h" @@ -81,6 +82,7 @@ namespace Eppo [[nodiscard]] constexpr auto GetParams() const -> const ApplicationParams& { return m_Params; } [[nodiscard]] constexpr auto GetWindow() const -> const Ref& { return m_Window; } [[nodiscard]] constexpr auto GetDeviceManager() const -> const Ref& { return m_DeviceManager; } + [[nodiscard]] constexpr auto GetThreadPool() const -> const Ref& { return m_ThreadPool; } [[nodiscard]] constexpr auto GetImGuiLayer() const -> const Ref& { return m_ImGuiLayer; } static auto Get() -> Application& { return *s_Instance; } @@ -95,6 +97,7 @@ namespace Eppo private: Ref m_Window = nullptr; Ref m_DeviceManager = nullptr; + Ref m_ThreadPool = nullptr; std::vector> m_LayerStack; Ref m_ImGuiLayer = nullptr; diff --git a/EppoEngine/Source/Core/Base.h b/EppoEngine/Source/Core/Base.h index 6505f2ba..5a078dd1 100644 --- a/EppoEngine/Source/Core/Base.h +++ b/EppoEngine/Source/Core/Base.h @@ -48,9 +48,11 @@ namespace Eppo #if defined(TRACY_ENABLE) #define EP_FRAME_MARK FrameMark #define EP_PROFILE_FN(name) ZoneScopedN(name) + #define EP_TAG_THREAD(name) tracy::SetThreadName(name) #else #define EP_FRAME_MARK #define EP_PROFILE_FN(name) + #define EP_TAG_THREAD(name) #endif template diff --git a/EppoEngine/Source/Core/Log.h b/EppoEngine/Source/Core/Log.h index 7c806a34..0e04dd76 100644 --- a/EppoEngine/Source/Core/Log.h +++ b/EppoEngine/Source/Core/Log.h @@ -2,6 +2,10 @@ #include "Core/UUID.h" +#include +#define GLM_ENABLE_EXPERIMENTAL +#include +#include #include #include #include @@ -194,6 +198,17 @@ struct fmt::formatter : formatter } }; +template<> +struct fmt::formatter : formatter +{ + auto format(const std::thread::id& v, format_context& ctx) const -> format_context::iterator + { + std::stringstream ss; + ss << v; + return formatter::format(ss.str(), ctx); + } +}; + template<> struct fmt::formatter : formatter { @@ -201,4 +216,34 @@ struct fmt::formatter : formatter { return formatter::format(static_cast(v), ctx); } -}; \ No newline at end of file +}; + +template<> +struct fmt::formatter : formatter +{ + auto format(const glm::vec2& v, format_context& ctx) const -> format_context::iterator + { + return formatter::format(glm::to_string(v), ctx); + } +}; + +template<> +struct fmt::formatter : formatter +{ + auto format(const glm::vec3& v, format_context& ctx) const -> format_context::iterator + { + return formatter::format(glm::to_string(v), ctx); + } +}; + +template +concept GlmType = glm::type::is_vec || glm::type::is_mat || glm::type::is_quat; + +template +struct fmt::formatter : formatter +{ + auto format(const T& v, format_context& ctx) const -> format_context::iterator + { + return formatter::format(glm::to_string(v), ctx); + } +}; diff --git a/EppoEngine/Source/Core/Threading/ThreadPool.cpp b/EppoEngine/Source/Core/Threading/ThreadPool.cpp new file mode 100644 index 00000000..88dc1cd8 --- /dev/null +++ b/EppoEngine/Source/Core/Threading/ThreadPool.cpp @@ -0,0 +1,575 @@ +#include "pch.h" +#include "Core/Threading/ThreadPool.h" + +#include + +namespace Eppo +{ + TaskGroupSnapshot::TaskGroupSnapshot(const TaskGroupSnapshot& other) + { + *this = other; + } + + auto TaskGroupSnapshot::operator=(const TaskGroupSnapshot& other) -> TaskGroupSnapshot& + { + if (this == &other) + return *this; + + while (true) + { + const auto version = other.m_Version.load(std::memory_order_seq_cst); + if ((version & 1u) != 0) + { + std::this_thread::yield(); + continue; + } + + const auto name = other.Name; + const auto pending = other.Pending.load(std::memory_order_seq_cst); + const auto running = other.Running.load(std::memory_order_seq_cst); + const auto completed = other.Completed.load(std::memory_order_seq_cst); + const auto failed = other.Failed.load(std::memory_order_seq_cst); + const auto cancelled = other.Cancelled.load(std::memory_order_seq_cst); + const auto total = other.Total.load(std::memory_order_seq_cst); + + if (version != other.m_Version.load(std::memory_order_seq_cst)) + continue; + + Name = name; + Pending.store(pending, std::memory_order_relaxed); + Running.store(running, std::memory_order_relaxed); + Completed.store(completed, std::memory_order_relaxed); + Failed.store(failed, std::memory_order_relaxed); + Cancelled.store(cancelled, std::memory_order_relaxed); + Total.store(total, std::memory_order_relaxed); + m_Version.store(0, std::memory_order_relaxed); + return *this; + } + } + + ThreadPool::ThreadPool() + : m_OwnerThread(std::this_thread::get_id()) + { + const uint32_t hardwareThreadCount = std::thread::hardware_concurrency(); + const uint32_t threadCount = hardwareThreadCount > 2 ? hardwareThreadCount - 2 : 1; + m_Threads.reserve(threadCount); + + for (uint32_t i = 0; i < threadCount; i++) + { + m_Threads.emplace_back( + [this, i]() -> void + { + WorkerLoop(i); + } + ); + } + } + + ThreadPool::~ThreadPool() + { + Shutdown(true); + } + + auto ThreadPool::QueueTask(TaskFn taskFn, CompletionFn completionFn, TaskPriority priority) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTask") + return QueueTaskInternal(std::move(taskFn), std::move(completionFn), {}, priority); + } + + auto ThreadPool::QueueTask(std::string name, TaskFn taskFn, CompletionFn completionFn, TaskPriority priority) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTask") + return QueueTaskInternal(std::move(name), std::move(taskFn), std::move(completionFn), {}, priority); + } + + auto ThreadPool::QueueTaskWithDependencies( + TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority + ) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTaskWithDependencies") + return QueueTaskInternal(std::move(taskFn), std::move(completionFn), dependencies, priority); + } + + auto ThreadPool::QueueTaskWithDependencies( + std::string name, TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority + ) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTaskWithDependencies") + return QueueTaskInternal(std::move(name), std::move(taskFn), std::move(completionFn), dependencies, priority); + } + + auto + ThreadPool::QueueTaskInternal(TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority) + -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTaskInternal") + + const TaskId id = m_NextTaskId.fetch_add(1, std::memory_order_relaxed); + + auto task = CreateRef(); + task->Id = id; + task->Fn = std::move(taskFn); + task->OnComplete = std::move(completionFn); + task->Priority = priority; + + // NOTE: Currently if dependencies have a low priority, it might take a long while for a high priority dependent to run + bool isReady = false; + { + std::scoped_lock lock(m_PendingMutex); + if (!m_IsRunning.load(std::memory_order_relaxed)) + { + Log::Warn("Tried to queue task after thread pool shutdown!"); + return 0; + } + + for (const auto dependencyId : dependencies) + { + if (dependencyId >= id) + { + Log::Error("Task with id {} depends on task id {} which was never issued!", id, dependencyId); + return 0; + } + } + + uint32_t remainingDeps = 0; + for (const auto dependencyId : dependencies) + { + const auto dependencyIt = m_AllTasks.find(dependencyId); + if (dependencyIt == m_AllTasks.end()) + continue; + + const auto status = dependencyIt->second->Status.load(std::memory_order_relaxed); + if (status == TaskStatus::Completed || status == TaskStatus::Failed || status == TaskStatus::Cancelled) + continue; + + dependencyIt->second->Dependents.emplace_back(task); + remainingDeps++; + } + + task->RemainingDeps.store(remainingDeps, std::memory_order_relaxed); + isReady = remainingDeps == 0; + if (isReady) + m_PendingTasks.at(static_cast(priority)).emplace_back(task); + m_AllTasks[id] = task; + m_TasksPending.fetch_add(1, std::memory_order_seq_cst); + } + + if (isReady) + m_WorkAvailableCV.notify_one(); + return id; + } + + auto ThreadPool::QueueTaskInternal( + std::string name, TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority + ) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTaskInternal") + + const TaskId id = m_NextTaskId.fetch_add(1, std::memory_order_relaxed); + + auto task = CreateRef(); + task->Id = id; + task->Name = std::move(name); + task->Fn = std::move(taskFn); + task->OnComplete = std::move(completionFn); + task->Priority = priority; + + // NOTE: Currently if dependencies have a low priority, it might take a long while for a high priority dependent to run + bool isReady = false; + { + std::scoped_lock lock(m_PendingMutex); + if (!m_IsRunning.load(std::memory_order_relaxed)) + { + Log::Warn("Tried to queue task '{}' after thread pool shutdown!", task->Name); + return 0; + } + + for (const auto dependencyId : dependencies) + { + if (dependencyId >= id) + { + Log::Error("Task '{}' depends on task id {} which was never issued!", task->Name, dependencyId); + return 0; + } + } + + uint32_t remainingDeps = 0; + for (const auto dependencyId : dependencies) + { + const auto dependencyIt = m_AllTasks.find(dependencyId); + if (dependencyIt == m_AllTasks.end()) + continue; + + const auto status = dependencyIt->second->Status.load(std::memory_order_relaxed); + if (status == TaskStatus::Completed || status == TaskStatus::Failed || status == TaskStatus::Cancelled) + continue; + + dependencyIt->second->Dependents.emplace_back(task); + remainingDeps++; + } + + { + std::scoped_lock snapshotLock(m_SnapshotMutex); + const auto [snapshotIt, inserted] = m_Snapshots.try_emplace(task->Name); + if (inserted) + { + snapshotIt->second = CreateRef(); + snapshotIt->second->Name = task->Name; + } + task->Group = snapshotIt->second; + } + UpdateTaskGroup(task->Group, TaskStatus::Pending); + + task->RemainingDeps.store(remainingDeps, std::memory_order_relaxed); + isReady = remainingDeps == 0; + if (isReady) + m_PendingTasks.at(static_cast(priority)).emplace_back(task); + m_AllTasks[id] = task; + m_TasksPending.fetch_add(1, std::memory_order_seq_cst); + } + + if (isReady) + m_WorkAvailableCV.notify_one(); + return id; + } + + + auto ThreadPool::GetTaskGroupSnapshots() -> std::unordered_map + { + EP_PROFILE_FN("ThreadPool::GetTaskGroupSnapshots") + + std::shared_lock lock(m_SnapshotMutex); + + std::unordered_map snapshots; + snapshots.reserve(m_Snapshots.size()); + for (const auto& [name, snapshot] : m_Snapshots) + snapshots.emplace(name, *snapshot); + + return snapshots; + } + + auto ThreadPool::UpdateTaskGroup(const Ref& group, const TaskStatus status) -> void + { + if (!group) + return; + + auto version = group->m_Version.load(std::memory_order_seq_cst); + while (true) + { + if ((version & 1u) != 0) + { + std::this_thread::yield(); + version = group->m_Version.load(std::memory_order_seq_cst); + continue; + } + + if (group->m_Version.compare_exchange_weak(version, version + 1, std::memory_order_seq_cst, std::memory_order_seq_cst)) + break; + } + + switch (status) + { + case TaskStatus::Pending: + group->Pending.fetch_add(1, std::memory_order_seq_cst); + group->Total.fetch_add(1, std::memory_order_seq_cst); + break; + case TaskStatus::Running: + EP_ASSERT(group->Pending.load(std::memory_order_relaxed) > 0, "Task group has no pending task to start!"); + group->Pending.fetch_sub(1, std::memory_order_seq_cst); + group->Running.fetch_add(1, std::memory_order_seq_cst); + break; + case TaskStatus::Completed: + EP_ASSERT(group->Running.load(std::memory_order_relaxed) > 0, "Task group has no running task to complete!"); + group->Running.fetch_sub(1, std::memory_order_seq_cst); + group->Completed.fetch_add(1, std::memory_order_seq_cst); + break; + case TaskStatus::Failed: + EP_ASSERT(group->Running.load(std::memory_order_relaxed) > 0, "Task group has no running task to fail!"); + group->Running.fetch_sub(1, std::memory_order_seq_cst); + group->Failed.fetch_add(1, std::memory_order_seq_cst); + break; + case TaskStatus::Cancelled: + EP_ASSERT(group->Pending.load(std::memory_order_relaxed) > 0, "Task group has no pending task to cancel!"); + group->Pending.fetch_sub(1, std::memory_order_seq_cst); + group->Cancelled.fetch_add(1, std::memory_order_seq_cst); + break; + } + + group->m_Version.store(version + 2, std::memory_order_seq_cst); + } + + auto ThreadPool::FinalizeTask(const Ref& task, const TaskStatus status) -> void + { + std::vector> dependents; + { + std::scoped_lock lock(m_PendingMutex); + if (status != TaskStatus::Cancelled) + task->Status.store(status, std::memory_order_relaxed); + dependents = std::move(task->Dependents); + } + + std::vector> readyTasks; + readyTasks.reserve(dependents.size()); + for (const auto& dependent : dependents) + { + const auto remaining = dependent->RemainingDeps.fetch_sub(1, std::memory_order_acq_rel); + EP_ASSERT(remaining > 0, "Task dependency counter underflowed!"); + if (remaining == 1) + readyTasks.emplace_back(dependent); + } + + if (!readyTasks.empty()) + { + { + std::scoped_lock lock(m_PendingMutex); + for (const auto& readyTask : readyTasks) + { + if (readyTask->Status.load(std::memory_order_relaxed) != TaskStatus::Pending) + continue; + m_PendingTasks.at(static_cast(readyTask->Priority)).emplace_back(readyTask); + } + } + + m_WorkAvailableCV.notify_all(); + } + + CompleteTask(task, status); + } + + auto ThreadPool::CompleteTask(const Ref& task, const TaskStatus status) -> void + { + UpdateTaskGroup(task->Group, status); + + { + std::scoped_lock lock(m_CompletedMutex); + m_CompletedTasks.emplace_back(task); + } + + if (status == TaskStatus::Cancelled) + m_TasksPending.fetch_sub(1, std::memory_order_seq_cst); + else + m_TasksInFlight.fetch_sub(1, std::memory_order_seq_cst); + } + + auto ThreadPool::Flush() -> uint32_t + { + EP_PROFILE_FN("ThreadPool::Flush") + EP_ASSERT(std::this_thread::get_id() == m_OwnerThread, "ThreadPool::Flush is main thread only!"); + + std::vector> batch; + { + std::scoped_lock lock(m_CompletedMutex); + const auto count = m_CompletedTasks.size(); + batch.reserve(count); + for (size_t i = 0; i < count; i++) + { + batch.emplace_back(std::move(m_CompletedTasks.front())); + m_CompletedTasks.pop_front(); + } + } + + std::vector completedTaskIds(batch.size()); + for (size_t i = 0; i < batch.size(); i++) + { + auto& task = batch.at(i); + + if (task->OnComplete) + { + try + { + task->OnComplete(task->Status.load(std::memory_order_relaxed)); + } + catch (const std::exception& e) + { + Log::Error("Completion callback for task '{}' with id {} threw: {}", task->Name, task->Id, e.what()); + } + catch (...) + { + Log::Error("Completion callback for task '{}' with id {} threw unknown exception!", task->Name, task->Id); + } + } + + completedTaskIds[i] = task->Id; + } + + { + std::scoped_lock lock(m_PendingMutex); + for (const auto taskId : completedTaskIds) + m_AllTasks.erase(taskId); + } + + return static_cast(batch.size()); + } + + auto ThreadPool::CancelTask(TaskId taskId) -> bool + { + EP_PROFILE_FN("ThreadPool::CancelTask") + + Ref task = nullptr; + + { + std::scoped_lock lock(m_PendingMutex); + + const auto taskIt = m_AllTasks.find(taskId); + if (taskIt == m_AllTasks.end()) + return false; + task = taskIt->second; + } + + auto expected = TaskStatus::Pending; + if (!task->Status.compare_exchange_strong(expected, TaskStatus::Cancelled, std::memory_order_relaxed)) + return false; + + FinalizeTask(task, TaskStatus::Cancelled); + return true; + } + + auto ThreadPool::CancelAll() -> void + { + EP_PROFILE_FN("ThreadPool::CancelAll") + + std::vector> cancelledTasks; + { + std::scoped_lock lock(m_PendingMutex); + + cancelledTasks.reserve(m_AllTasks.size()); + for (const auto& [taskId, task] : m_AllTasks) + { + auto expected = TaskStatus::Pending; + if (task->Status.compare_exchange_strong(expected, TaskStatus::Cancelled, std::memory_order_relaxed)) + cancelledTasks.emplace_back(task); + } + } + + for (const auto& task : cancelledTasks) + FinalizeTask(task, TaskStatus::Cancelled); + } + + auto ThreadPool::Shutdown(bool cancelPending) -> void + { + EP_PROFILE_FN("ThreadPool::Shutdown") + + std::vector> cancelledTasks; + { + std::scoped_lock lock(m_PendingMutex); + m_IsRunning.store(false, std::memory_order_relaxed); + + if (cancelPending) + { + cancelledTasks.reserve(m_AllTasks.size()); + for (const auto& [taskId, task] : m_AllTasks) + { + auto expected = TaskStatus::Pending; + if (task->Status.compare_exchange_strong(expected, TaskStatus::Cancelled, std::memory_order_relaxed)) + cancelledTasks.emplace_back(task); + } + } + } + + for (const auto& task : cancelledTasks) + FinalizeTask(task, TaskStatus::Cancelled); + + m_WorkAvailableCV.notify_all(); + + for (auto& thread : m_Threads) + thread.join(); + m_Threads.clear(); + + // We are now single threaded so we can safely access tasks without a mutex + Flush(); + } + + auto ThreadPool::GetPendingTasksCount() const -> uint32_t + { + return m_TasksPending.load(std::memory_order_seq_cst) + m_TasksInFlight.load(std::memory_order_seq_cst); + } + + auto ThreadPool::WorkerLoop(uint32_t index) -> void + { + const auto tag = std::format("Worker {}", index); + EP_TAG_THREAD(tag.c_str()); + + while (true) + { + Ref task; + + // Wait for task + { + std::unique_lock lock(m_PendingMutex); + m_WorkAvailableCV.wait( + lock, + [this]() -> bool + { + return !m_IsRunning.load(std::memory_order_relaxed) || HasPendingTasks(); + } + ); + + if (!m_IsRunning.load(std::memory_order_relaxed) && !HasPendingTasks()) + return; + + task = GetNextTask(); + } + + if (!task) + continue; + + UpdateTaskGroup(task->Group, TaskStatus::Running); + + auto taskStatus = TaskStatus::Completed; + try + { + task->Fn(); + } + catch (const std::exception& e) + { + Log::Error("Task '{}' with id {} threw: {}", task->Name, task->Id, e.what()); + taskStatus = TaskStatus::Failed; + } + catch (...) + { + Log::Error("Task '{}' with id {} threw unknown exception!", task->Name, task->Id); + taskStatus = TaskStatus::Failed; + } + + FinalizeTask(task, taskStatus); + } + } + + auto ThreadPool::HasPendingTasks() const -> bool + { + // Called inside a lock, no lock needed + for (const auto& queue : m_PendingTasks) + { + if (!queue.empty()) + return true; + } + + return false; + } + + auto ThreadPool::GetNextTask() -> Ref + { + EP_PROFILE_FN("ThreadPool::GetNextTask") + + // Called inside a lock, no lock needed + // Run in reverse so highest priority gets selected first + for (auto it = m_PendingTasks.rbegin(); it != m_PendingTasks.rend(); ++it) + { + while (!it->empty()) + { + auto task = std::move(it->front()); + it->pop_front(); + + auto expected = TaskStatus::Pending; + if (!task->Status.compare_exchange_strong(expected, TaskStatus::Running, std::memory_order_relaxed)) + continue; + + m_TasksInFlight.fetch_add(1, std::memory_order_seq_cst); + m_TasksPending.fetch_sub(1, std::memory_order_seq_cst); + return task; + } + } + + return nullptr; + } +} diff --git a/EppoEngine/Source/Core/Threading/ThreadPool.h b/EppoEngine/Source/Core/Threading/ThreadPool.h new file mode 100644 index 00000000..a2f41677 --- /dev/null +++ b/EppoEngine/Source/Core/Threading/ThreadPool.h @@ -0,0 +1,150 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Eppo +{ + using TaskId = uint64_t; + + enum class TaskPriority : uint8_t + { + Low = 0, + Medium, + High, + }; + + enum class TaskStatus : uint8_t + { + Pending = 0, + Running, + Completed, + Failed, + Cancelled, + }; + + struct TaskGroupSnapshot + { + TaskGroupSnapshot() = default; + TaskGroupSnapshot(const TaskGroupSnapshot& other); + auto operator=(const TaskGroupSnapshot& other) -> TaskGroupSnapshot&; + + std::string Name; + std::atomic Pending = 0; + std::atomic Running = 0; + std::atomic Completed = 0; + std::atomic Failed = 0; + std::atomic Cancelled = 0; + std::atomic Total = 0; + + auto IsFinished() const -> bool + { + const auto total = Total.load(std::memory_order_relaxed); + const auto finished = Completed.load(std::memory_order_relaxed) + Failed.load(std::memory_order_relaxed) + + Cancelled.load(std::memory_order_relaxed); + return finished >= total; + } + + private: + std::atomic m_Version = 0; + + friend class ThreadPool; + }; + + template + using TaskResult = std::optional; + + using TaskFn = std::function; + using CompletionFn = std::function; + + class ThreadPool + { + public: + ThreadPool(); + ~ThreadPool(); + + // Callable: All threads + auto QueueTask(TaskFn taskFn, CompletionFn completionFn, TaskPriority priority = TaskPriority::Medium) -> TaskId; + auto QueueTask(std::string name, TaskFn taskFn, CompletionFn completionFn, TaskPriority priority = TaskPriority::Medium) -> TaskId; + auto QueueTaskWithDependencies( + TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority = TaskPriority::Medium + ) -> TaskId; + auto QueueTaskWithDependencies( + std::string name, TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, + TaskPriority priority = TaskPriority::Medium + ) -> TaskId; + + // Callable: All threads + auto GetTaskGroupSnapshots() -> std::unordered_map; + + // Callable: Main thread + auto Flush() -> uint32_t; + + // Callable: All threads + auto CancelTask(TaskId taskId) -> bool; + + // Callable: Main thread + auto CancelAll() -> void; + + // Callable: Main thread + auto Shutdown(bool cancelPending) -> void; + + // Callable: All threads + [[nodiscard]] auto GetPendingTasksCount() const -> uint32_t; + + private: + struct Task + { + TaskId Id = 0; + std::string Name; + TaskFn Fn; + CompletionFn OnComplete; + TaskPriority Priority = TaskPriority::Medium; + std::atomic Status = TaskStatus::Pending; + + // Dependencies + std::vector> Dependents; + std::atomic RemainingDeps = 0; + + Ref Group; + }; + + auto QueueTaskInternal(TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority) + -> TaskId; + auto QueueTaskInternal( + std::string name, TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority + ) -> TaskId; + auto UpdateTaskGroup(const Ref& group, TaskStatus status) -> void; + auto FinalizeTask(const Ref& task, TaskStatus status) -> void; + auto CompleteTask(const Ref& task, TaskStatus status) -> void; + auto WorkerLoop(uint32_t index) -> void; + [[nodiscard]] auto HasPendingTasks() const -> bool; + [[nodiscard]] auto GetNextTask() -> Ref; + + private: + // Pending tasks + std::mutex m_PendingMutex; + std::atomic m_TasksPending = 0; + std::array>, 3> m_PendingTasks{}; + std::unordered_map> m_AllTasks; + + // Completed tasks + std::mutex m_CompletedMutex; + std::deque> m_CompletedTasks; + + // Snapshotting + std::shared_mutex m_SnapshotMutex; + std::unordered_map> m_Snapshots; + + // Workpool + std::vector m_Threads; + std::condition_variable m_WorkAvailableCV; + std::atomic m_IsRunning = true; + std::atomic m_TasksInFlight = 0; + std::atomic m_NextTaskId = 1; + std::thread::id m_OwnerThread; + }; +} diff --git a/EppoEngine/Source/Core/UUID.cpp b/EppoEngine/Source/Core/UUID.cpp index 35ae8050..2e4d0044 100644 --- a/EppoEngine/Source/Core/UUID.cpp +++ b/EppoEngine/Source/Core/UUID.cpp @@ -4,7 +4,13 @@ namespace Eppo { // Reserved UUIDs (1 - 99) - // 1 - 9: Mesh Primitives + // 1 = Cone Mesh + // 2 = Cube Mesh + // 3 = Cylinder Mesh + // 4 = Sphere Mesh + // 5 = Capsule Mesh + // 6-9 reserved for mesh primitives + // 10 = Placeholder Texture UUID::UUID() { @@ -15,4 +21,4 @@ namespace Eppo UUID::UUID(uint64_t id) : m_UUID(id) {} -} \ No newline at end of file +} diff --git a/EppoEngine/Source/EppoEngine.h b/EppoEngine/Source/EppoEngine.h index 5ef83682..b99fae5a 100644 --- a/EppoEngine/Source/EppoEngine.h +++ b/EppoEngine/Source/EppoEngine.h @@ -12,6 +12,7 @@ #include "Core/Buffer/BufferWriter.h" #include "Core/Buffer/FileStreamReader.h" #include "Core/Buffer/FileStreamWriter.h" +#include "Core/Threading/ThreadPool.h" #include "Core/Input.h" #include "Core/KeyCodes.h" #include "Core/Layer.h" @@ -47,4 +48,4 @@ #include "Utility/Filesystem.h" #include "Utility/FileWatcher.h" #include "Utility/Json.h" -#include "Utility/Random.h" \ No newline at end of file +#include "Utility/Random.h" diff --git a/EppoEngine/Source/ImGui/ImGuiLayer.cpp b/EppoEngine/Source/ImGui/ImGuiLayer.cpp index 8400eeca..caab83a5 100644 --- a/EppoEngine/Source/ImGui/ImGuiLayer.cpp +++ b/EppoEngine/Source/ImGui/ImGuiLayer.cpp @@ -19,7 +19,8 @@ namespace Eppo struct ImGuiViewportData { bool WindowOwned = false; - ScopedPtr Swapchain = nullptr; + bool FrameAcquired = false; + Ref Swapchain = nullptr; ScopedPtr Renderer = nullptr; }; @@ -275,19 +276,33 @@ namespace Eppo auto ImGuiLayer::ImGuiRenderer_RenderWindow(ImGuiViewport* viewport, void*) -> void { - EP_PROFILE_FN("ImGuiLayer::ImGuiRenderer_RenderWindow") - - const auto* vd = static_cast(viewport->RendererUserData); - vd->Swapchain->BeginFrame(); - vd->Renderer->UpdateFontTexture(); - vd->Renderer->RenderToSwapchain(viewport, vd->Swapchain); + Renderer::Submit( + [viewport]() + { + EP_PROFILE_FN("ImGuiLayer::ImGuiRenderer_RenderWindow") + + auto* vd = static_cast(viewport->RendererUserData); + vd->FrameAcquired = vd->Swapchain->BeginFrame(); + if (!vd->FrameAcquired) + return; + vd->Renderer->UpdateFontTexture(); + vd->Renderer->RenderToSwapchain(viewport, vd->Swapchain); + } + ); } auto ImGuiLayer::ImGuiRenderer_SwapBuffers(ImGuiViewport* viewport, void*) -> void { - EP_PROFILE_FN("ImGuiLayer::ImGuiRenderer_SwapBuffers") + Renderer::Submit( + [viewport]() + { + EP_PROFILE_FN("ImGuiLayer::ImGuiRenderer_SwapBuffers") - const auto* vd = static_cast(viewport->RendererUserData); - vd->Swapchain->Present(); + auto* vd = static_cast(viewport->RendererUserData); + if (vd->FrameAcquired) + vd->Swapchain->Present(); + vd->FrameAcquired = false; + } + ); } } diff --git a/EppoEngine/Source/ImGui/ImGuiRenderer.cpp b/EppoEngine/Source/ImGui/ImGuiRenderer.cpp index b84eed14..72331211 100644 --- a/EppoEngine/Source/ImGui/ImGuiRenderer.cpp +++ b/EppoEngine/Source/ImGui/ImGuiRenderer.cpp @@ -17,7 +17,8 @@ namespace Eppo struct ImGuiViewportData { bool WindowOwned = false; - ScopedPtr Swapchain = nullptr; + bool FrameAcquired = false; + Ref Swapchain = nullptr; ScopedPtr Renderer = nullptr; }; @@ -112,12 +113,15 @@ namespace Eppo io.Fonts->TexRef = ImTextureRef(m_FontTexture.Get()); } - auto ImGuiRenderer::RenderToSwapchain(ImGuiViewport* viewport, const ScopedPtr& swapchain, const bool clearSwapchainTarget) - -> void + auto ImGuiRenderer::RenderToSwapchain(ImGuiViewport* viewport, const Ref& swapchain, const bool clearSwapchainTarget) -> void { - EP_PROFILE_FN("ImGuiRenderer::RenderToSwapchain") - - Render(viewport, GetOrCreateRenderPass(swapchain), clearSwapchainTarget); + Renderer::Submit( + [this, viewport, swapchain, clearSwapchainTarget]() + { + EP_PROFILE_FN("ImGuiRenderer::RenderToSwapchain"); + Render(viewport, GetOrCreateRenderPass(swapchain), clearSwapchainTarget); + } + ); } auto ImGuiRenderer::Render(ImGuiViewport* viewport, const Ref& renderPass, const bool clearTarget) -> void @@ -345,7 +349,7 @@ namespace Eppo return device->createBuffer(bufferDesc); } - auto ImGuiRenderer::GetOrCreateRenderPass(const ScopedPtr& swapchain) -> const Ref& + auto ImGuiRenderer::GetOrCreateRenderPass(const Ref& swapchain) -> const Ref& { EP_PROFILE_FN("ImGuiRenderer::GetOrCreateRenderPass") diff --git a/EppoEngine/Source/ImGui/ImGuiRenderer.h b/EppoEngine/Source/ImGui/ImGuiRenderer.h index 7bae67af..8e957c4d 100644 --- a/EppoEngine/Source/ImGui/ImGuiRenderer.h +++ b/EppoEngine/Source/ImGui/ImGuiRenderer.h @@ -20,7 +20,7 @@ namespace Eppo auto Resize() -> void; auto UpdateFontTexture() -> void; - auto RenderToSwapchain(ImGuiViewport* viewport, const ScopedPtr& swapchain, bool clearSwapchainTarget = true) -> void; + auto RenderToSwapchain(ImGuiViewport* viewport, const Ref& swapchain, bool clearSwapchainTarget = true) -> void; auto Render(ImGuiViewport* viewport, const Ref& renderPass, bool clearTarget = true) -> void; [[nodiscard]] auto GetGPUTime(uint32_t frameIndex) const -> float; @@ -31,7 +31,7 @@ namespace Eppo private: auto UpdateGeometry(ImDrawData* drawData) -> void; auto ReallocateBuffer(uint64_t size, bool indexBuffer) -> nvrhi::BufferHandle; - auto GetOrCreateRenderPass(const ScopedPtr& swapchain) -> const Ref&; + auto GetOrCreateRenderPass(const Ref& swapchain) -> const Ref&; auto GetOrCreateBindingSet(const nvrhi::TextureHandle& texture) -> nvrhi::BindingSetHandle; private: diff --git a/EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.cpp b/EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.cpp index 87e08338..85aaa687 100644 --- a/EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.cpp +++ b/EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.cpp @@ -23,12 +23,13 @@ namespace Eppo VK_CHECK(glfwCreateWindowSurface(m_Instance, m_Window->GetNative(), nullptr, &surface), "Failed to create window surface!"); EP_ASSERT(surface); - m_Swapchain = CreateScopedPtr(surface); + m_Swapchain = CreateRef(surface); m_Swapchain->CreateSwapchain(); } auto DeviceManagerVK::Shutdown() -> void { + WaitIdle(); GpuProfiler::Shutdown(); m_Renderer = nullptr; @@ -164,4 +165,4 @@ namespace Eppo if (g_EnableValidationLayers) m_ValidationLayer = nvrhi::validation::createValidationLayer(m_Device); } -} \ No newline at end of file +} diff --git a/EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.h b/EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.h index bbeed5bb..4033229c 100644 --- a/EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.h +++ b/EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.h @@ -26,6 +26,8 @@ namespace Eppo auto BeginFrame() -> bool override; auto Present() -> bool override; + [[nodiscard]] virtual auto GetCurrentFrameIndex() const -> uint32_t override { return m_Swapchain->GetCurrentFrameIndex(); } + [[nodiscard]] virtual auto GetMaxFramesInFlight() const -> uint32_t override { return m_Swapchain->GetMaxFramesInFlight(); } [[nodiscard]] auto GetCurrentBackBufferIndex() const -> uint32_t override { return m_Swapchain->GetCurrentBackBufferIndex(); } [[nodiscard]] auto GetBackBufferCount() const -> uint32_t override { return m_Swapchain->GetImageCount(); } auto GetCurrentSwapchainImage() -> const SwapchainImage& override { return m_Swapchain->GetCurrentSwapchainImage(); } @@ -33,7 +35,7 @@ namespace Eppo [[nodiscard]] constexpr auto GetVulkanInstance() const -> VkInstance { return m_Instance; } [[nodiscard]] constexpr auto GetPhysicalDevice() const -> const ScopedPtr& { return m_PhysicalDevice; } [[nodiscard]] constexpr auto GetLogicalDevice() const -> const ScopedPtr& { return m_LogicalDevice; } - [[nodiscard]] constexpr auto GetSwapchain() const -> const ScopedPtr& { return m_Swapchain; } + [[nodiscard]] constexpr auto GetSwapchain() const -> const Ref& { return m_Swapchain; } private: auto CreateVulkanInstance() -> void; @@ -48,6 +50,6 @@ namespace Eppo ScopedPtr m_PhysicalDevice = nullptr; ScopedPtr m_LogicalDevice = nullptr; - ScopedPtr m_Swapchain = nullptr; + Ref m_Swapchain = nullptr; }; } diff --git a/EppoEngine/Source/Platform/Vulkan/Swapchain.cpp b/EppoEngine/Source/Platform/Vulkan/Swapchain.cpp index 0f0f9ba3..e1f4ebe1 100644 --- a/EppoEngine/Source/Platform/Vulkan/Swapchain.cpp +++ b/EppoEngine/Source/Platform/Vulkan/Swapchain.cpp @@ -34,7 +34,6 @@ namespace Eppo : m_Surface(surface) { const auto& dm = std::static_pointer_cast(DeviceManager::Get()); - VkDevice device = dm->GetLogicalDevice()->GetNative(); // Get swapchain support details auto [capabilities, formats, presentModes] = QuerySwapchainSupportDetails(); @@ -42,14 +41,6 @@ namespace Eppo m_PresentMode = SelectPresentMode(presentModes, dm->GetParams().VSync); m_Format = m_SurfaceFormat.format; m_Extent = SelectExtent(capabilities); - - // Create acquire semaphores - VkSemaphoreCreateInfo semaphoreInfo{ - .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, - }; - - for (uint32_t i = 0; i < g_MaxFramesInFlight; i++) - VK_CHECK(vkCreateSemaphore(device, &semaphoreInfo, nullptr, &m_AcquireSemaphores[i]), "Failed to create semaphore!"); } Swapchain::~Swapchain() @@ -62,8 +53,8 @@ namespace Eppo for (size_t i = 0; i < m_PresentSemaphores.size(); i++) vkDestroySemaphore(device, m_PresentSemaphores.at(i), nullptr); - for (size_t i = 0; i < m_AcquireSemaphores.size(); i++) - vkDestroySemaphore(device, m_AcquireSemaphores.at(i), nullptr); + for (auto& frame : m_FrameSyncData) + vkDestroySemaphore(device, frame.AcquireSemaphore, nullptr); vkDestroySwapchainKHR(device, m_Swapchain, nullptr); vkDestroySurfaceKHR(dm->GetVulkanInstance(), m_Surface, nullptr); @@ -71,54 +62,69 @@ namespace Eppo auto Swapchain::BeginFrame() -> bool { - EP_PROFILE_FN("Swapchain::BeginFrame") + EP_PROFILE_FN("Swapchain::BeginFrame"); + EP_ASSERT(!m_FrameActive, "BeginFrame was called while a swapchain frame is already active!"); const auto& dm = std::static_pointer_cast(DeviceManager::Get()); VkDevice device = dm->GetLogicalDevice()->GetNative(); - - const auto& semaphore = m_AcquireSemaphores.at(m_AcquireIndex); + nvrhi::vulkan::IDevice* vkNvrhiDevice = dm->GetDevice()->getNativeObject(nvrhi::ObjectTypes::Nvrhi_VK_Device); constexpr uint32_t maxAttempts = 3; VkResult result; for (uint32_t attempt = 0; attempt < maxAttempts; attempt++) // switch to ++attempt { - result = vkAcquireNextImageKHR(device, m_Swapchain, UINT64_MAX, semaphore, nullptr, &m_SwapchainIndex); + if (m_ResizePending) + Resize(); - if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR) + auto& frame = m_FrameSyncData.at(m_CurrentFrameIndex); + if (frame.InFlight) { - // Resize swapchain - CreateSwapchain(); + vkNvrhiDevice->waitEventQuery(frame.CompletionQuery); + vkNvrhiDevice->resetEventQuery(frame.CompletionQuery); + frame.InFlight = false; } - else + + result = vkAcquireNextImageKHR(device, m_Swapchain, UINT64_MAX, frame.AcquireSemaphore, nullptr, &m_SwapchainImageIndex); + + if (result == VK_ERROR_OUT_OF_DATE_KHR) { - break; + m_ResizePending = true; + continue; } - } - m_AcquireIndex = (m_AcquireIndex + 1) % m_AcquireSemaphores.size(); + if (result == VK_SUBOPTIMAL_KHR) + m_ResizePending = true; + else if (result != VK_SUCCESS) + { + Log::Error(LogSource::Vulkan, "Failed to acquire a swapchain image: VkResult {}", static_cast(result)); + return false; + } - if (result == VK_SUCCESS || result == VK_SUBOPTIMAL_KHR) - { - nvrhi::vulkan::IDevice* vkNvrhiDevice(dm->GetDevice()->getNativeObject(nvrhi::ObjectTypes::Nvrhi_VK_Device)); - vkNvrhiDevice->queueWaitForSemaphore(nvrhi::CommandQueue::Graphics, semaphore, 0); + vkNvrhiDevice->queueWaitForSemaphore(nvrhi::CommandQueue::Graphics, frame.AcquireSemaphore, 0); + m_FrameActive = true; return true; } + Log::Error(LogSource::Vulkan, "Failed to acquire a swapchain image!"); return false; } auto Swapchain::Present() -> bool { - EP_PROFILE_FN("Swapchain::Present") + EP_PROFILE_FN("Swapchain::Present"); + EP_ASSERT(m_FrameActive, "Present was called without an active swapchain frame!"); const auto& dm = std::static_pointer_cast(DeviceManager::Get()); nvrhi::vulkan::IDevice* vkNvrhiDevice(dm->GetDevice()->getNativeObject(nvrhi::ObjectTypes::Nvrhi_VK_Device)); - const auto& semaphore = m_PresentSemaphores.at(m_SwapchainIndex); + auto& frame = m_FrameSyncData.at(m_CurrentFrameIndex); + const auto& semaphore = m_PresentSemaphores.at(m_SwapchainImageIndex); vkNvrhiDevice->queueSignalSemaphore(nvrhi::CommandQueue::Graphics, semaphore, 0); vkNvrhiDevice->executeCommandLists(nullptr, 0); + vkNvrhiDevice->setEventQuery(frame.CompletionQuery, nvrhi::CommandQueue::Graphics); + frame.InFlight = true; VkPresentInfoKHR presentInfo{ .sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, @@ -126,41 +132,24 @@ namespace Eppo .pWaitSemaphores = &semaphore, .swapchainCount = 1, .pSwapchains = &m_Swapchain, - .pImageIndices = &m_SwapchainIndex, + .pImageIndices = &m_SwapchainImageIndex, }; VkResult result = vkQueuePresentKHR(dm->GetLogicalDevice()->GetPresentQueue(), &presentInfo); - if (!(result == VK_SUCCESS || result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR)) - return false; - - // Explicit sync - vkQueueWaitIdle(dm->GetLogicalDevice()->GetPresentQueue()); - - while (m_FramesInFlight.size() >= g_MaxFramesInFlight) - { - auto query = m_FramesInFlight.front(); - m_FramesInFlight.pop(); + m_FrameActive = false; + m_CurrentFrameIndex = (m_CurrentFrameIndex + 1) % m_MaxFramesInFlight; - vkNvrhiDevice->waitEventQuery(query); - m_QueryPool.emplace_back(query); - } + if (result == VK_SUCCESS) + return true; - nvrhi::EventQueryHandle query; - if (!m_QueryPool.empty()) - { - query = m_QueryPool.back(); - m_QueryPool.pop_back(); - } - else + if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR) { - query = vkNvrhiDevice->createEventQuery(); + m_ResizePending = true; + return true; } - vkNvrhiDevice->resetEventQuery(query); - vkNvrhiDevice->setEventQuery(query, nvrhi::CommandQueue::Graphics); - m_FramesInFlight.push(query); - - return true; + Log::Error(LogSource::Vulkan, "Failed to present a swapchain image: VkResult {}", static_cast(result)); + return false; } auto Swapchain::CreateSwapchain(uint32_t width, uint32_t height) -> void @@ -168,6 +157,15 @@ namespace Eppo const auto& dm = std::static_pointer_cast(DeviceManager::Get()); VkDevice device = dm->GetLogicalDevice()->GetNative(); + if (m_Swapchain) + { + VK_CHECK(vkDeviceWaitIdle(device), "Failed to wait for vulkan device"); + + for (const VkSemaphore semaphore : m_PresentSemaphores) + vkDestroySemaphore(device, semaphore, nullptr); + m_PresentSemaphores.clear(); + } + m_Images.clear(); auto [capabilities, formats, presentModes] = QuerySwapchainSupportDetails(); @@ -208,6 +206,7 @@ namespace Eppo VK_CHECK(vkGetSwapchainImagesKHR(device, m_Swapchain, &swapchainImageCount, nullptr), "Failed to get swapchain images!"); EP_ASSERT(swapchainImageCount >= 2); m_PresentSemaphores.resize(swapchainImageCount); + m_MaxFramesInFlight = std::min(DeviceManager::Get()->GetParams().MaxFramesInFlight, swapchainImageCount); std::vector images(swapchainImageCount); VK_CHECK(vkGetSwapchainImagesKHR(device, m_Swapchain, &swapchainImageCount, images.data()), "Failed to get swapchain images!"); @@ -216,6 +215,37 @@ namespace Eppo .sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, }; + if (m_FrameSyncData.size() != m_MaxFramesInFlight) + { + for (const auto& frame : m_FrameSyncData) + vkDestroySemaphore(device, frame.AcquireSemaphore, nullptr); + + m_FrameSyncData.clear(); + m_FrameSyncData.resize(m_MaxFramesInFlight); + + for (auto& frame : m_FrameSyncData) + { + VK_CHECK( + vkCreateSemaphore(device, &semaphoreInfo, nullptr, &frame.AcquireSemaphore), "Failed to create acquire semaphore!" + ); + frame.CompletionQuery = dm->GetDevice()->createEventQuery(); + EP_ASSERT(frame.CompletionQuery != nullptr, "Failed to create frame completion query."); + frame.InFlight = false; + } + } + else + { + for (auto& frame : m_FrameSyncData) + { + if (frame.InFlight) + dm->GetDevice()->resetEventQuery(frame.CompletionQuery); + frame.InFlight = false; + } + } + + m_CurrentFrameIndex = 0; + m_FrameActive = false; + for (uint32_t i = 0; i < swapchainImageCount; i++) { // Create image views diff --git a/EppoEngine/Source/Platform/Vulkan/Swapchain.h b/EppoEngine/Source/Platform/Vulkan/Swapchain.h index 2b4ba731..3831c7a7 100644 --- a/EppoEngine/Source/Platform/Vulkan/Swapchain.h +++ b/EppoEngine/Source/Platform/Vulkan/Swapchain.h @@ -3,8 +3,6 @@ #include "Platform/Vulkan/Vulkan.h" #include "Renderer/DeviceManager.h" -#include - namespace Eppo { struct SwapchainSupportDetails @@ -24,11 +22,13 @@ namespace Eppo auto Present() -> bool; auto CreateSwapchain(uint32_t width = 0, uint32_t height = 0) -> void; - auto Resize(uint32_t width, uint32_t height) -> void; + auto Resize(uint32_t width = 0, uint32_t height = 0) -> void; - auto GetCurrentBackBufferIndex() const -> uint32_t { return m_SwapchainIndex; } + auto GetCurrentFrameIndex() const -> uint32_t { return m_CurrentFrameIndex; } + auto GetMaxFramesInFlight() const -> uint32_t { return m_MaxFramesInFlight; } + auto GetCurrentBackBufferIndex() const -> uint32_t { return m_SwapchainImageIndex; } auto GetImageCount() const -> uint32_t { return static_cast(m_Images.size()); } - auto GetCurrentSwapchainImage() -> const SwapchainImage& { return m_Images.at(m_SwapchainIndex); } + auto GetCurrentSwapchainImage() -> const SwapchainImage& { return m_Images.at(m_SwapchainImageIndex); } private: [[nodiscard]] auto QuerySwapchainSupportDetails() const -> SwapchainSupportDetails; @@ -47,12 +47,19 @@ namespace Eppo VkPresentModeKHR m_PresentMode = VK_PRESENT_MODE_FIFO_KHR; VkSurfaceFormatKHR m_SurfaceFormat; - std::array m_AcquireSemaphores{}; - std::vector m_PresentSemaphores; - std::queue m_FramesInFlight; - std::vector m_QueryPool; + struct FrameSync + { + VkSemaphore AcquireSemaphore = nullptr; + nvrhi::EventQueryHandle CompletionQuery = nullptr; + bool InFlight = false; + }; - uint32_t m_AcquireIndex = 0; - uint32_t m_SwapchainIndex = 0; + std::vector m_FrameSyncData; + std::vector m_PresentSemaphores; + bool m_FrameActive = false; + bool m_ResizePending = false; + uint32_t m_CurrentFrameIndex = 0; // Index into m_FrameSyncData + uint32_t m_MaxFramesInFlight = 1; + uint32_t m_SwapchainImageIndex = 0; // Index into m_Images }; } diff --git a/EppoEngine/Source/Platform/Vulkan/Vulkan.h b/EppoEngine/Source/Platform/Vulkan/Vulkan.h index 389e9227..3a628e9e 100644 --- a/EppoEngine/Source/Platform/Vulkan/Vulkan.h +++ b/EppoEngine/Source/Platform/Vulkan/Vulkan.h @@ -14,7 +14,6 @@ namespace Eppo constexpr bool g_EnableValidationLayers = false; #endif - constexpr uint32_t g_MaxFramesInFlight = 2; constexpr std::array g_ValidationLayers = { "VK_LAYER_KHRONOS_validation" }; constexpr std::array g_DeviceExtensions = { VK_KHR_SWAPCHAIN_EXTENSION_NAME, VK_KHR_MAINTENANCE_1_EXTENSION_NAME, VK_GOOGLE_HLSL_FUNCTIONALITY_1_EXTENSION_NAME, VK_GOOGLE_USER_TYPE_EXTENSION_NAME, @@ -101,4 +100,4 @@ namespace Eppo return fn(instance, debugMessenger, pAllocator); } } -} \ No newline at end of file +} diff --git a/EppoEngine/Source/Platform/Vulkan/VulkanShader.cpp b/EppoEngine/Source/Platform/Vulkan/VulkanShader.cpp index b4330df4..4d11f04b 100644 --- a/EppoEngine/Source/Platform/Vulkan/VulkanShader.cpp +++ b/EppoEngine/Source/Platform/Vulkan/VulkanShader.cpp @@ -228,6 +228,8 @@ namespace Eppo Log::Info("=================================="); CreateBindingLayout(); + + m_IsLoaded.store(true, std::memory_order_release); } auto VulkanShader::CompileOrGetCache() -> bool diff --git a/EppoEngine/Source/Renderer/DeviceManager.cpp b/EppoEngine/Source/Renderer/DeviceManager.cpp index 55cd4499..a5fd393f 100644 --- a/EppoEngine/Source/Renderer/DeviceManager.cpp +++ b/EppoEngine/Source/Renderer/DeviceManager.cpp @@ -19,6 +19,7 @@ namespace Eppo EP_ASSERT(params.API != RendererAPI::DX11, "DX11 renderer api selected on a non windows target!"); EP_ASSERT(params.API != RendererAPI::DX12, "DX12 renderer api selected on a non windows target!"); #endif + EP_ASSERT(params.MaxFramesInFlight >= 2); switch (params.API) { diff --git a/EppoEngine/Source/Renderer/DeviceManager.h b/EppoEngine/Source/Renderer/DeviceManager.h index b53ec09e..b2a5135e 100644 --- a/EppoEngine/Source/Renderer/DeviceManager.h +++ b/EppoEngine/Source/Renderer/DeviceManager.h @@ -66,7 +66,6 @@ namespace Eppo uint32_t Width = 1600; uint32_t Height = 900; uint32_t MaxFramesInFlight = 2; - uint32_t SwapchainImageCount = 3; bool VSync = false; bool EnableComputeQueue = true; @@ -97,6 +96,8 @@ namespace Eppo [[nodiscard]] constexpr auto GetRenderer() const -> const ScopedPtr& { return m_Renderer; } // Swapchain/Nvrhi device + [[nodiscard]] virtual auto GetCurrentFrameIndex() const -> uint32_t = 0; + [[nodiscard]] virtual auto GetMaxFramesInFlight() const -> uint32_t = 0; [[nodiscard]] virtual auto GetCurrentBackBufferIndex() const -> uint32_t = 0; [[nodiscard]] virtual auto GetBackBufferCount() const -> uint32_t = 0; virtual auto GetCurrentSwapchainImage() -> const SwapchainImage& = 0; diff --git a/EppoEngine/Source/Renderer/Image.cpp b/EppoEngine/Source/Renderer/Image.cpp index 3f866df0..625ecb4b 100644 --- a/EppoEngine/Source/Renderer/Image.cpp +++ b/EppoEngine/Source/Renderer/Image.cpp @@ -127,17 +127,17 @@ namespace Eppo EP_ASSERT(m_Stride > 0); const auto device = DeviceManager::Get()->GetDevice(); - const auto cmd = cmdList ? cmdList : device->createCommandList({ .queueType = nvrhi::CommandQueue::Copy }); + const auto cmd = cmdList ? cmdList : device->createCommandList(); if (!cmdList) cmd->open(); - cmdList->writeTexture(m_Texture, 0, 0, data, m_Stride); + cmd->writeTexture(m_Texture, 0, 0, data, m_Stride); if (!cmdList) { cmd->close(); - device->executeCommandList(cmd, nvrhi::CommandQueue::Copy); + device->executeCommandList(cmd); } } @@ -163,6 +163,15 @@ namespace Eppo return buffer; } + auto Image::CalculateMipLevels(const uint32_t width, const uint32_t height) -> uint32_t + { + EP_ASSERT(width > 0 && height > 0); + const uint32_t mipLevels = + 1 + static_cast(glm::floor(glm::log2(glm::max(static_cast(width), static_cast(height))))); + EP_ASSERT(mipLevels > 0); + return mipLevels; + } + auto Image::GetMipWidth(const uint32_t mipLevel) const -> uint32_t { EP_ASSERT(mipLevel < m_MipLevels); @@ -181,28 +190,59 @@ namespace Eppo m_Specification.ImageFormat == nvrhi::Format::D32 || m_Specification.ImageFormat == nvrhi::Format::D32S8; } + auto Image::RegisterBindlessIndex(const nvrhi::TextureSubresourceSet& subresources) -> uint32_t + { + const auto resolved = subresources.resolve(m_Texture->getDesc(), false); + if (m_BindlessHandles.contains(resolved)) + return m_BindlessHandles.at(resolved)->Index; + + const auto& descriptorManager = DeviceManager::Get()->GetRenderer()->GetDescriptorManager(); + m_BindlessHandles.emplace(resolved, CreateRef(descriptorManager->Register(shared_from_this(), resolved))); + + return m_BindlessHandles.at(resolved)->Index; + } + auto Image::GetBindlessIndex(const nvrhi::TextureSubresourceSet& subresources) -> uint32_t { const auto resolved = subresources.resolve(m_Texture->getDesc(), false); + if (m_BindlessHandles.contains(resolved)) + return m_BindlessHandles.at(resolved)->Index; - auto it = m_BindlessHandles.find(resolved); - if (it == m_BindlessHandles.end()) - { - const auto& descriptorManager = DeviceManager::Get()->GetRenderer()->GetDescriptorManager(); - it = m_BindlessHandles.emplace(resolved, CreateRef(descriptorManager->Register(shared_from_this(), resolved))) - .first; - } + Log::Warn("GetBindlessIndex called on a image that did not yet have a bindless index, registering now..."); - return it->second->Index; + return RegisterBindlessIndex(subresources); } - auto Image::CalculateMipLevels(const uint32_t width, const uint32_t height) -> uint32_t + auto Image::GenerateFallbackImage() -> Ref { - EP_ASSERT(width > 0 && height > 0); - const uint32_t mipLevels = - 1 + static_cast(glm::floor(glm::log2(glm::max(static_cast(width), static_cast(height))))); - EP_ASSERT(mipLevels > 0); - return mipLevels; + constexpr uint32_t imageSize = 16; + ScopedBuffer buffer(imageSize * imageSize * 4); + + for (uint32_t y = 0; y < imageSize; y++) + { + for (uint32_t x = 0; x < imageSize; x++) + { + const bool magenta = ((x / 2) + (y / 2)) % 2 == 0; + uint8_t* p = buffer.Data() + (y * imageSize + x) * 4; + p[0] = magenta ? 255 : 0; + p[1] = 0; + p[2] = magenta ? 255 : 0; + p[3] = 255; + } + } + + const ImageSpecification spec{ + .ImageFormat = nvrhi::Format::SRGBA8_UNORM, + .Width = imageSize, + .Height = imageSize, + .DebugName = "Fallback Image", + }; + + auto image = CreateRef(spec); + image->SetData(buffer.Data(), buffer.Size()); + image->RegisterBindlessIndex(); + + return image; } auto Image::DecodeImageData(const ImageSource& source, uint32_t& outChannels, bool& outIsHdr) -> void* diff --git a/EppoEngine/Source/Renderer/Image.h b/EppoEngine/Source/Renderer/Image.h index 281820ac..607ca6bb 100644 --- a/EppoEngine/Source/Renderer/Image.h +++ b/EppoEngine/Source/Renderer/Image.h @@ -55,10 +55,12 @@ namespace Eppo [[nodiscard]] auto GetFormat() const -> nvrhi::Format { return m_Specification.ImageFormat; } [[nodiscard]] auto IsDepthImage() const -> bool; - // Registers a bindless SRV for the requested subresource range on first request and returns its slot. - // The whole-image default and an equivalent explicit range resolve to one shared slot; distinct mip/array views get distinct slots. + auto RegisterBindlessIndex(const nvrhi::TextureSubresourceSet& subresources = nvrhi::AllSubresources) -> uint32_t; [[nodiscard]] auto GetBindlessIndex(const nvrhi::TextureSubresourceSet& subresources = nvrhi::AllSubresources) -> uint32_t; + // Fallback image used by asset manager + static auto GenerateFallbackImage() -> Ref; + private: [[nodiscard]] auto DecodeImageData(const ImageSource& source, uint32_t& outChannels, bool& outIsHdr) -> void*; auto SelectFormat(uint32_t channels, bool isHdr = false) -> nvrhi::Format; diff --git a/EppoEngine/Source/Renderer/RenderCommandBuffer.cpp b/EppoEngine/Source/Renderer/RenderCommandBuffer.cpp index bebd7df6..f027eb98 100644 --- a/EppoEngine/Source/Renderer/RenderCommandBuffer.cpp +++ b/EppoEngine/Source/Renderer/RenderCommandBuffer.cpp @@ -7,15 +7,35 @@ namespace Eppo { RenderCommandBuffer::RenderCommandBuffer() { - EnsureBackBufferCapacity(DeviceManager::Get()->GetBackBufferCount()); + EnsureFrameCapacity(DeviceManager::Get()->GetMaxFramesInFlight()); } auto RenderCommandBuffer::Begin(const std::string_view name) -> void { const auto& dm = DeviceManager::Get(); - EnsureBackBufferCapacity(dm->GetBackBufferCount()); - const uint32_t frameIndex = dm->GetCurrentBackBufferIndex(); + EnsureFrameCapacity(dm->GetMaxFramesInFlight()); + + const uint32_t frameIndex = dm->GetCurrentFrameIndex(); EP_ASSERT(frameIndex < m_CommandLists.size()); + EP_ASSERT(m_ActiveFrameIndex == UINT32_MAX); + m_ActiveFrameIndex = frameIndex; + + if (m_FrameSubmitted.at(frameIndex)) + { + const auto device = dm->GetDevice(); + m_Timestamps.at(frameIndex) = device->getTimerQueryTime(m_TimerQueries.at(frameIndex)); + device->resetTimerQuery(m_TimerQueries.at(frameIndex)); + + for (const auto& timerName : m_SubmittedNamedTimerQueries.at(frameIndex)) + { + const auto& timerQuery = m_NamedTimerQueries.at(frameIndex).at(timerName); + m_NamedTimestamps.at(frameIndex)[timerName] = device->getTimerQueryTime(timerQuery); + device->resetTimerQuery(timerQuery); + } + + m_SubmittedNamedTimerQueries.at(frameIndex).clear(); + m_FrameSubmitted.at(frameIndex) = false; + } m_ActiveCommandList = m_CommandLists.at(frameIndex); EP_ASSERT(m_ActiveCommandList); @@ -53,46 +73,46 @@ namespace Eppo auto RenderCommandBuffer::Submit() -> void { + EP_ASSERT(m_ActiveCommandList); + EP_ASSERT(m_ActiveFrameIndex != UINT32_MAX); + const uint32_t frameIndex = m_ActiveFrameIndex; + EP_ASSERT(frameIndex < m_TimerQueries.size()); + const auto& dm = DeviceManager::Get(); const auto device = dm->GetDevice(); - const uint32_t frameIndex = dm->GetCurrentBackBufferIndex(); - EP_ASSERT(frameIndex < m_TimerQueries.size()); - EP_ASSERT(m_ActiveCommandList); m_ActiveCommandList->close(); device->executeCommandList(m_ActiveCommandList); - - m_Timestamps.at(frameIndex) = device->getTimerQueryTime(m_TimerQueries.at(frameIndex)); - device->resetTimerQuery(m_TimerQueries.at(frameIndex)); - - for (const auto& [name, timerQuery] : m_NamedTimerQueries.at(frameIndex)) - { - m_NamedTimestamps.at(frameIndex)[name] = device->getTimerQueryTime(timerQuery); - device->resetTimerQuery(timerQuery); - } + m_FrameSubmitted.at(frameIndex) = true; m_ActiveCommandList = nullptr; m_ActiveTimerQuery = nullptr; + m_ActiveFrameIndex = UINT32_MAX; } auto RenderCommandBuffer::BeginTimerQuery(const std::string& name) -> void { + EP_ASSERT(m_ActiveCommandList); + EP_ASSERT(m_ActiveFrameIndex != UINT32_MAX); + const uint32_t frameIndex = m_ActiveFrameIndex; + EP_ASSERT(frameIndex < m_NamedTimerQueries.size()); + const auto& dm = DeviceManager::Get(); const auto device = dm->GetDevice(); - const uint32_t frameIndex = dm->GetCurrentBackBufferIndex(); - EP_ASSERT(frameIndex < m_NamedTimerQueries.size()); auto& timerQuery = m_NamedTimerQueries.at(frameIndex)[name]; if (!timerQuery) timerQuery = device->createTimerQuery(); + m_SubmittedNamedTimerQueries.at(frameIndex).insert(name); m_ActiveCommandList->beginTimerQuery(timerQuery); } auto RenderCommandBuffer::EndTimerQuery(const std::string& name) const -> void { - const auto& dm = DeviceManager::Get(); - const uint32_t frameIndex = dm->GetCurrentBackBufferIndex(); + EP_ASSERT(m_ActiveCommandList); + EP_ASSERT(m_ActiveFrameIndex != UINT32_MAX); + const uint32_t frameIndex = m_ActiveFrameIndex; EP_ASSERT(frameIndex < m_NamedTimerQueries.size()); const auto it = m_NamedTimerQueries.at(frameIndex).find(name); @@ -122,20 +142,22 @@ namespace Eppo return 0.0f; } - auto RenderCommandBuffer::EnsureBackBufferCapacity(const uint32_t backBufferCount) -> void + auto RenderCommandBuffer::EnsureFrameCapacity(const uint32_t frameCount) -> void { - if (backBufferCount <= m_CommandLists.size()) + if (frameCount <= m_CommandLists.size()) return; const auto device = DeviceManager::Get()->GetDevice(); const size_t previousCount = m_CommandLists.size(); - m_CommandLists.resize(backBufferCount); - m_TimerQueries.resize(backBufferCount); - m_Timestamps.resize(backBufferCount); - m_NamedTimerQueries.resize(backBufferCount); - m_NamedTimestamps.resize(backBufferCount); - - for (size_t i = previousCount; i < backBufferCount; i++) + m_CommandLists.resize(frameCount); + m_TimerQueries.resize(frameCount); + m_Timestamps.resize(frameCount); + m_NamedTimerQueries.resize(frameCount); + m_NamedTimestamps.resize(frameCount); + m_FrameSubmitted.resize(frameCount, false); + m_SubmittedNamedTimerQueries.resize(frameCount); + + for (size_t i = previousCount; i < frameCount; i++) { m_CommandLists.at(i) = device->createCommandList(); m_TimerQueries.at(i) = device->createTimerQuery(); diff --git a/EppoEngine/Source/Renderer/RenderCommandBuffer.h b/EppoEngine/Source/Renderer/RenderCommandBuffer.h index 9d81bccc..81086149 100644 --- a/EppoEngine/Source/Renderer/RenderCommandBuffer.h +++ b/EppoEngine/Source/Renderer/RenderCommandBuffer.h @@ -3,6 +3,7 @@ #include #include +#include namespace Eppo { @@ -44,11 +45,12 @@ namespace Eppo } private: - auto EnsureBackBufferCapacity(uint32_t backBufferCount) -> void; + auto EnsureFrameCapacity(uint32_t frameCount) -> void; private: std::vector m_CommandLists; nvrhi::CommandListHandle m_ActiveCommandList = nullptr; + uint32_t m_ActiveFrameIndex = UINT32_MAX; std::vector m_TimerQueries; nvrhi::TimerQueryHandle m_ActiveTimerQuery = nullptr; @@ -56,6 +58,8 @@ namespace Eppo std::vector> m_NamedTimerQueries; std::vector> m_NamedTimestamps; + std::vector m_FrameSubmitted; + std::vector> m_SubmittedNamedTimerQueries; nvrhi::GraphicsState m_GraphicsState{}; bool m_HasActiveMarker = false; diff --git a/EppoEngine/Source/Renderer/RenderCommandQueue.cpp b/EppoEngine/Source/Renderer/RenderCommandQueue.cpp new file mode 100644 index 00000000..89f76c8d --- /dev/null +++ b/EppoEngine/Source/Renderer/RenderCommandQueue.cpp @@ -0,0 +1,24 @@ +#include "pch.h" +#include "Renderer/RenderCommandQueue.h" + +namespace Eppo +{ + auto RenderCommandQueue::AddCommand(RenderCommand&& fn) -> void + { + m_CommandQueue.emplace_back(std::move(fn)); + } + + auto RenderCommandQueue::Execute() -> void + { + std::vector commands; + commands.swap(m_CommandQueue); + + for (auto& command : commands) + command(); + } + + auto RenderCommandQueue::Clear() -> void + { + m_CommandQueue.clear(); + } +} diff --git a/EppoEngine/Source/Renderer/RenderCommandQueue.h b/EppoEngine/Source/Renderer/RenderCommandQueue.h new file mode 100644 index 00000000..0cb2ea83 --- /dev/null +++ b/EppoEngine/Source/Renderer/RenderCommandQueue.h @@ -0,0 +1,24 @@ +#pragma once + +namespace Eppo +{ + using RenderCommand = std::function; + + class RenderCommandQueue + { + public: + RenderCommandQueue() = default; + ~RenderCommandQueue() = default; + RenderCommandQueue(const RenderCommandQueue&) = delete; + auto operator=(const RenderCommandQueue&) -> RenderCommandQueue& = delete; + RenderCommandQueue(RenderCommandQueue&&) noexcept = default; + auto operator=(RenderCommandQueue&&) noexcept -> RenderCommandQueue& = default; + + auto AddCommand(RenderCommand&& fn) -> void; + auto Execute() -> void; + auto Clear() -> void; + + private: + std::vector m_CommandQueue; + }; +} diff --git a/EppoEngine/Source/Renderer/Renderer.cpp b/EppoEngine/Source/Renderer/Renderer.cpp index 44bd1817..07b8cd27 100644 --- a/EppoEngine/Source/Renderer/Renderer.cpp +++ b/EppoEngine/Source/Renderer/Renderer.cpp @@ -28,8 +28,7 @@ namespace Eppo m_DescriptorManager = CreateRef(); } - auto Renderer::LoadShaders(const std::map& packed, const std::map& includes) - -> void + auto Renderer::LoadShaders(const std::map& packed, const std::map& includes) -> void { for (const auto* name : s_EngineShaderNames) { @@ -66,6 +65,20 @@ namespace Eppo m_CompositeCommandBuffer = CreateRef(); } + auto Renderer::Submit(RenderCommand command) -> void + { + const auto& renderer = DeviceManager::Get()->GetRenderer(); + EP_ASSERT(renderer != nullptr); + renderer->m_RenderCommandQueue.AddCommand(std::move(command)); + } + + auto Renderer::ExecuteRenderCommands() -> void + { + const auto& renderer = DeviceManager::Get()->GetRenderer(); + EP_ASSERT(renderer != nullptr); + renderer->m_RenderCommandQueue.Execute(); + } + auto Renderer::BeginRenderPass(const Ref& commandBuffer, const Ref& renderPass) -> void { const auto& cmd = commandBuffer->GetCommandList(); @@ -118,60 +131,64 @@ namespace Eppo auto Renderer::CompositeToSwapchain(const Ref& image) -> void { - EP_PROFILE_FN("Renderer::CompositeToSwapchain") - + EP_PROFILE_FN("Renderer::CompositeToSwapchain"); EP_ASSERT(image != nullptr, "Cannot composite a null image to the swapchain."); - const auto& dm = DeviceManager::Get(); - const uint32_t backBufferCount = dm->GetBackBufferCount(); - const uint32_t backBufferIndex = dm->GetCurrentBackBufferIndex(); - EP_ASSERT(backBufferIndex < backBufferCount, "The current swapchain back buffer index is invalid."); + Submit( + [this, image]() + { + const auto& dm = DeviceManager::Get(); + const uint32_t backBufferCount = dm->GetBackBufferCount(); + const uint32_t backBufferIndex = dm->GetCurrentBackBufferIndex(); + EP_ASSERT(backBufferIndex < backBufferCount, "The current swapchain back buffer index is invalid."); - if (m_CompositePasses.size() != backBufferCount) - { - m_CompositePasses.resize(backBufferCount); - m_CompositeFramebuffers.resize(backBufferCount); - } + if (m_CompositePasses.size() != backBufferCount) + { + m_CompositePasses.resize(backBufferCount); + m_CompositeFramebuffers.resize(backBufferCount); + } - const auto& framebuffer = dm->GetCurrentSwapchainImage().Framebuffer; - const nvrhi::FramebufferHandle framebufferHandle = framebuffer->GetFramebuffer(); - Ref& renderPass = m_CompositePasses.at(backBufferIndex); + const auto& framebuffer = dm->GetCurrentSwapchainImage().Framebuffer; + const nvrhi::FramebufferHandle framebufferHandle = framebuffer->GetFramebuffer(); + Ref& renderPass = m_CompositePasses.at(backBufferIndex); - // Lazy load pipeline since this is only used in the runtime - if (!renderPass || m_CompositeFramebuffers.at(backBufferIndex) != framebufferHandle) - { - const PipelineSpecification pipelineSpec{ - .Shader = m_ShaderLibrary.Get("composite"), - .CullMode = nvrhi::RasterCullMode::None, - }; - - renderPass = CreateRef(RenderPassSpecification{ - .Name = "Composite", - .Pipeline = CreateRef(pipelineSpec, framebufferHandle->getFramebufferInfo()), - .Framebuffer = framebuffer, - .OwnsFramebuffer = false, - }); - - m_CompositeFramebuffers.at(backBufferIndex) = framebufferHandle; - } + // Lazy load pipeline since this is only used in the runtime + if (!renderPass || m_CompositeFramebuffers.at(backBufferIndex) != framebufferHandle) + { + const PipelineSpecification pipelineSpec{ + .Shader = m_ShaderLibrary.Get("composite"), + .CullMode = nvrhi::RasterCullMode::None, + }; + + renderPass = CreateRef(RenderPassSpecification{ + .Name = "Composite", + .Pipeline = CreateRef(pipelineSpec, framebufferHandle->getFramebufferInfo()), + .Framebuffer = framebuffer, + .OwnsFramebuffer = false, + }); + + m_CompositeFramebuffers.at(backBufferIndex) = framebufferHandle; + } + + renderPass->SetInput(0, 0, image); + renderPass->SetInput(0, 0, m_CompositeSampler); + renderPass->Bake(); - renderPass->SetInput(0, 0, image); - renderPass->SetInput(0, 0, m_CompositeSampler); - renderPass->Bake(); + m_CompositeCommandBuffer->Begin("Composite"); + BeginRenderPass(m_CompositeCommandBuffer, renderPass); - m_CompositeCommandBuffer->Begin("Composite"); - BeginRenderPass(m_CompositeCommandBuffer, renderPass); + m_CompositeCommandBuffer->GetCommandList()->draw( + nvrhi::DrawArguments{ + .vertexCount = 3, + .instanceCount = 1, + } + ); - m_CompositeCommandBuffer->GetCommandList()->draw( - nvrhi::DrawArguments{ - .vertexCount = 3, - .instanceCount = 1, + EndRenderPass(m_CompositeCommandBuffer); + m_CompositeCommandBuffer->End(); + m_CompositeCommandBuffer->Submit(); } ); - - EndRenderPass(m_CompositeCommandBuffer); - m_CompositeCommandBuffer->End(); - m_CompositeCommandBuffer->Submit(); } auto Renderer::GetShader(const std::string& name) const -> Ref diff --git a/EppoEngine/Source/Renderer/Renderer.h b/EppoEngine/Source/Renderer/Renderer.h index 27497c4f..d382f575 100644 --- a/EppoEngine/Source/Renderer/Renderer.h +++ b/EppoEngine/Source/Renderer/Renderer.h @@ -2,6 +2,7 @@ #include "Renderer/Image.h" #include "Renderer/RenderCommandBuffer.h" +#include "Renderer/RenderCommandQueue.h" #include "Renderer/RenderPass.h" #include "Renderer/ShaderLibrary.h" @@ -20,6 +21,9 @@ namespace Eppo auto LoadShaders(const std::map& packed = {}, const std::map& includes = {}) -> void; + static auto Submit(RenderCommand command) -> void; + static auto ExecuteRenderCommands() -> void; + static auto BeginRenderPass(const Ref& commandBuffer, const Ref& renderPass) -> void; static auto EndRenderPass(const Ref& commandBuffer) -> void; auto CompositeToSwapchain(const Ref& image) -> void; @@ -36,5 +40,7 @@ namespace Eppo Ref m_CompositeSampler = nullptr; std::vector> m_CompositePasses; std::vector m_CompositeFramebuffers; + + RenderCommandQueue m_RenderCommandQueue; }; } diff --git a/EppoEngine/Source/Renderer/SceneRenderer.cpp b/EppoEngine/Source/Renderer/SceneRenderer.cpp index 75242194..6b7a5c20 100644 --- a/EppoEngine/Source/Renderer/SceneRenderer.cpp +++ b/EppoEngine/Source/Renderer/SceneRenderer.cpp @@ -509,14 +509,14 @@ namespace Eppo auto SceneRenderer::RenderGui() const -> void { - EP_PROFILE_FN("SceneRenderer::RenderGui") + EP_PROFILE_FN("SceneRenderer::RenderGui"); const auto& app = Application::Get(); if (!app.GetImGuiLayer()) return; const auto& dm = DeviceManager::Get(); - const uint32_t frameIndex = dm->GetCurrentBackBufferIndex(); - EP_ASSERT(frameIndex < dm->GetBackBufferCount()); + const uint32_t frameIndex = dm->GetCurrentFrameIndex(); + EP_ASSERT(frameIndex < dm->GetMaxFramesInFlight()); const auto& imguiRenderer = app.GetImGuiLayer()->GetMainImGuiRenderer(); @@ -639,6 +639,9 @@ namespace Eppo ImGui::Text("Vertices: %u", result.Stats.Vertices + uiStats.Vertices); ImGui::Text("Indices: %u", result.Stats.Indices + uiStats.Indices); + ImGui::SeparatorText("Camera"); + ImGui::Text("Position: x: %.2f, y: %.2f, z: %.2f", m_CameraData.Position.x, m_CameraData.Position.y, m_CameraData.Position.z); + ImGui::End(); } @@ -676,10 +679,16 @@ namespace Eppo EnsureColliderMeshes(); GatherWireframes(); + PrepareRenderData(); - m_RenderCommandBuffer->Begin(); - PrepareRender(); + Renderer::Submit( + [this]() + { + m_RenderCommandBuffer->Begin(); + } + ); + UploadRenderData(); ShadowDepthPass(); SsaoPass(); GeometryPass(); @@ -688,11 +697,14 @@ namespace Eppo TonemapPass(); WireframePass(); - // Collect resolves query results, which is illegal inside a render pass; every pass above closes its own. - EP_GPU_COLLECT(m_RenderCommandBuffer); - - m_RenderCommandBuffer->End(); - m_RenderCommandBuffer->Submit(); + Renderer::Submit( + [this]() + { + EP_GPU_COLLECT(m_RenderCommandBuffer); + m_RenderCommandBuffer->End(); + m_RenderCommandBuffer->Submit(); + } + ); } auto SceneRenderer::GetFinalImage() const -> const Ref& @@ -834,20 +846,25 @@ namespace Eppo auto SceneRenderer::BeginSceneInternal() -> void { - EP_PROFILE_FN("SceneRenderer::BeginSceneInternal") - - std::memset(&m_ShadowDepthPass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_SsaoPrePass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_SsaoEvaluationPass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_SsaoBlurHorizontalPass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_SsaoBlurVerticalPass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_GeometryPass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_SkyPass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_BloomDownSamplePass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_BloomUpSamplePass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_BloomCompositePass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_WireframePass->GetStatistics(), 0, sizeof(PassStatistics)); - std::memset(&m_TonemapPass->GetStatistics(), 0, sizeof(PassStatistics)); + EP_PROFILE_FN("SceneRenderer::BeginSceneInternal"); + + Renderer::Submit( + [this]() + { + std::memset(&m_ShadowDepthPass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_SsaoPrePass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_SsaoEvaluationPass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_SsaoBlurHorizontalPass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_SsaoBlurVerticalPass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_GeometryPass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_SkyPass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_BloomDownSamplePass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_BloomUpSamplePass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_BloomCompositePass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_WireframePass->GetStatistics(), 0, sizeof(PassStatistics)); + std::memset(&m_TonemapPass->GetStatistics(), 0, sizeof(PassStatistics)); + } + ); m_DrawCommands.clear(); m_LightData.NumLights = 0; @@ -920,8 +937,8 @@ namespace Eppo return; const glm::mat4 worldTransform = m_Scene->GetWorldTransform(entity); - const glm::mat4 lightTransform = glm::translate(glm::mat4(1.0f), glm::vec3(worldTransform[3])) * - glm::mat4_cast(m_Scene->GetWorldRotation(entity)); + const glm::mat4 lightTransform = + glm::translate(glm::mat4(1.0f), glm::vec3(worldTransform[3])) * glm::mat4_cast(m_Scene->GetWorldRotation(entity)); markerDraw.Transforms.emplace_back(lightTransform * glm::scale(glm::mat4(1.0f), glm::vec3(0.3f))); shaftDraw.Transforms.emplace_back( lightTransform * glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, -0.6f, 0.0f)) * @@ -1042,47 +1059,32 @@ namespace Eppo } } - auto SceneRenderer::PrepareRender() -> void + auto SceneRenderer::PrepareRenderData() -> void { - EP_PROFILE_FN("SceneRenderer::PrepareRender") - - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + EP_PROFILE_FN("SceneRenderer::PrepareRenderData") // Shadow depth FillShadowData(); - m_ShadowDepthUB->SetData(cmdList, &m_ShadowDepthData, sizeof(m_ShadowDepthData)); // Ssao data m_SsaoData.Params = glm::vec4(m_SsaoSettings.Radius, m_SsaoSettings.Bias, m_SsaoSettings.Power, m_SsaoSettings.Intensity); m_SsaoData.InvSize = glm::vec4(1.0f / m_Width, 1.0f / m_Height, 0.0f, 0.0f); - m_SsaoUB->SetData(cmdList, &m_SsaoData, sizeof(m_SsaoData)); - - // Camera, light and environment uniforms - m_CameraUB->SetData(cmdList, &m_CameraData, sizeof(CameraData)); - m_LightsUB->SetData(cmdList, &m_LightData, sizeof(LightData)); - m_EnvironmentUB->SetData(cmdList, &m_EnvironmentData, sizeof(EnvironmentData)); // Instance storage buffer - std::vector instanceTransforms; + m_InstanceTransforms.clear(); for (auto& drawCmd : m_DrawCommands | std::views::values) { - drawCmd.InstanceOffset = static_cast(instanceTransforms.size()); - instanceTransforms.insert(instanceTransforms.end(), drawCmd.Transforms.begin(), drawCmd.Transforms.end()); + drawCmd.InstanceOffset = static_cast(m_InstanceTransforms.size()); + m_InstanceTransforms.insert(m_InstanceTransforms.end(), drawCmd.Transforms.begin(), drawCmd.Transforms.end()); } - const uint64_t requiredSize = instanceTransforms.size() * sizeof(glm::mat4); - m_InstanceTransformsSB->SetData(cmdList, instanceTransforms.data(), requiredSize); - - std::vector wireframeTransforms; + m_WireframeTransforms.clear(); for (auto& draw : m_WireframeDrawCommands) { - draw.InstanceOffset = static_cast(wireframeTransforms.size()); - wireframeTransforms.insert(wireframeTransforms.end(), draw.Transforms.begin(), draw.Transforms.end()); + draw.InstanceOffset = static_cast(m_WireframeTransforms.size()); + m_WireframeTransforms.insert(m_WireframeTransforms.end(), draw.Transforms.begin(), draw.Transforms.end()); } - const uint64_t wireframeSize = wireframeTransforms.size() * sizeof(glm::mat4); - m_WireframeInstanceSB->SetData(cmdList, wireframeTransforms.data(), wireframeSize); - // Prepare draw/material storage buffers m_DrawData.clear(); m_MaterialData.clear(); @@ -1123,44 +1125,72 @@ namespace Eppo } } } + } - m_DrawDataSB->SetData(cmdList, m_DrawData.data(), m_DrawData.size() * sizeof(DrawData)); - m_MaterialDataSB->SetData(cmdList, m_MaterialData.data(), m_MaterialData.size() * sizeof(MaterialData)); - - // Framebuffer attachments are recreated on resize. - const auto& prepassDepth = m_SsaoPrePass->GetFramebuffer()->GetDepthImage(); - const auto& prepassNormal = m_SsaoPrePass->GetFramebuffer()->GetFinalImage(); - - m_SsaoEvaluationPass->SetInput(0, 0, prepassDepth); - m_SsaoEvaluationPass->SetInput(0, 1, prepassNormal); - m_SsaoBlurHorizontalPass->SetInput(0, 0, m_SsaoEvaluationPass->GetFramebuffer()->GetFinalImage()); - m_SsaoBlurHorizontalPass->SetInput(0, 1, prepassDepth); - m_SsaoBlurVerticalPass->SetInput(0, 0, m_SsaoBlurHorizontalPass->GetFramebuffer()->GetFinalImage()); - m_SsaoBlurVerticalPass->SetInput(0, 1, prepassDepth); - m_GeometryPass->SetInput(0, 3, m_SsaoBlurVerticalPass->GetFramebuffer()->GetFinalImage()); - m_TonemapPass->SetInput(0, 0, m_BloomCompositePass->GetFramebuffer()->GetFinalImage()); - m_WireframePass->SetInput(0, 2, m_GeometryPass->GetFramebuffer()->GetDepthImage()); - - // Rebuild binding sets for any pass whose resource handles changed. - m_ShadowDepthPass->Bake(); - m_SsaoPrePass->Bake(); - m_SsaoEvaluationPass->Bake(); - m_SsaoBlurHorizontalPass->Bake(); - m_SsaoBlurVerticalPass->Bake(); - m_GeometryPass->Bake(); - m_SkyPass->Bake(); - m_BloomDownSamplePass->Bake(); - m_BloomUpSamplePass->Bake(); - m_BloomCompositePass->Bake(); - m_TonemapPass->Bake(); - m_WireframePass->Bake(); - - // Pre-register the subresources bloom samples bindlessly: GetBindlessIndex lazily writes the bindless - // table on first use, which is illegal once an earlier pass has bound it, so warm the cache here first. - static_cast(m_GeometryPass->GetFramebuffer()->GetFinalImage()->GetBindlessIndex(nvrhi::TextureSubresourceSet(0, 1, 0, 1))); - const auto& bloomPyramid = m_BloomPyramidFramebuffer->GetFinalImage(); - for (uint32_t mip = 0; mip < m_BloomMipLevels; mip++) - static_cast(bloomPyramid->GetBindlessIndex(nvrhi::TextureSubresourceSet(mip, 1, 0, 1))); + auto SceneRenderer::UploadRenderData() -> void + { + Renderer::Submit( + [this]() + { + EP_PROFILE_FN("SceneRenderer::UploadRenderData"); + + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + + // Uniform buffers + m_ShadowDepthUB->SetData(cmdList, &m_ShadowDepthData, sizeof(ShadowDepthData)); + m_SsaoUB->SetData(cmdList, &m_SsaoData, sizeof(SsaoData)); + m_CameraUB->SetData(cmdList, &m_CameraData, sizeof(CameraData)); + m_LightsUB->SetData(cmdList, &m_LightData, sizeof(LightData)); + m_EnvironmentUB->SetData(cmdList, &m_EnvironmentData, sizeof(EnvironmentData)); + + // Storage buffers + const uint64_t requiredSize = m_InstanceTransforms.size() * sizeof(glm::mat4); + m_InstanceTransformsSB->SetData(cmdList, m_InstanceTransforms.data(), requiredSize); + const uint64_t wireframeSize = m_WireframeTransforms.size() * sizeof(glm::mat4); + m_WireframeInstanceSB->SetData(cmdList, m_WireframeTransforms.data(), wireframeSize); + m_DrawDataSB->SetData(cmdList, m_DrawData.data(), m_DrawData.size() * sizeof(DrawData)); + m_MaterialDataSB->SetData(cmdList, m_MaterialData.data(), m_MaterialData.size() * sizeof(MaterialData)); + + // Descriptors + const auto& shadowMap = m_ShadowDepthPass->GetFramebuffer()->GetDepthImage(); + m_ShadowDepthData.ShadowMapIndex = shadowMap->GetBindlessIndex(nvrhi::TextureSubresourceSet(0, 1, 0, s_ShadowCascadeCount)); + m_ShadowDepthData.ShadowSamplerIndex = m_ClampAllFiltersFalseSampler->GetBindlessIndex(); + + m_GeometryPass->GetFramebuffer()->GetFinalImage()->RegisterBindlessIndex(nvrhi::TextureSubresourceSet(0, 1, 0, 1)); + + const auto& bloomPyramid = m_BloomPyramidFramebuffer->GetFinalImage(); + for (uint32_t mip = 0; mip < m_BloomMipLevels; mip++) + bloomPyramid->RegisterBindlessIndex(nvrhi::TextureSubresourceSet(mip, 1, 0, 1)); + + // Framebuffer attachments are recreated on resize. + const auto& prepassDepth = m_SsaoPrePass->GetFramebuffer()->GetDepthImage(); + const auto& prepassNormal = m_SsaoPrePass->GetFramebuffer()->GetFinalImage(); + + m_SsaoEvaluationPass->SetInput(0, 0, prepassDepth); + m_SsaoEvaluationPass->SetInput(0, 1, prepassNormal); + m_SsaoBlurHorizontalPass->SetInput(0, 0, m_SsaoEvaluationPass->GetFramebuffer()->GetFinalImage()); + m_SsaoBlurHorizontalPass->SetInput(0, 1, prepassDepth); + m_SsaoBlurVerticalPass->SetInput(0, 0, m_SsaoBlurHorizontalPass->GetFramebuffer()->GetFinalImage()); + m_SsaoBlurVerticalPass->SetInput(0, 1, prepassDepth); + m_GeometryPass->SetInput(0, 3, m_SsaoBlurVerticalPass->GetFramebuffer()->GetFinalImage()); + m_TonemapPass->SetInput(0, 0, m_BloomCompositePass->GetFramebuffer()->GetFinalImage()); + m_WireframePass->SetInput(0, 2, m_GeometryPass->GetFramebuffer()->GetDepthImage()); + + // Rebuild binding sets for any pass whose resource handles changed. + m_ShadowDepthPass->Bake(); + m_SsaoPrePass->Bake(); + m_SsaoEvaluationPass->Bake(); + m_SsaoBlurHorizontalPass->Bake(); + m_SsaoBlurVerticalPass->Bake(); + m_GeometryPass->Bake(); + m_SkyPass->Bake(); + m_BloomDownSamplePass->Bake(); + m_BloomUpSamplePass->Bake(); + m_BloomCompositePass->Bake(); + m_TonemapPass->Bake(); + m_WireframePass->Bake(); + } + ); } auto SceneRenderer::FillShadowData() -> void @@ -1207,8 +1237,9 @@ namespace Eppo for (uint32_t y = 0; y < 2; y++) for (uint32_t x = 0; x < 2; x++) { - const glm::vec4 ndc(static_cast(x) * 2.0f - 1.0f, static_cast(y) * 2.0f - 1.0f, - static_cast(z), 1.0f); + const glm::vec4 ndc( + static_cast(x) * 2.0f - 1.0f, static_cast(y) * 2.0f - 1.0f, static_cast(z), 1.0f + ); const glm::vec4 world = m_CameraData.InverseViewProjection * ndc; frustumCorners[cornerIndex++] = glm::vec3(world) / world.w; } @@ -1261,591 +1292,623 @@ namespace Eppo cascadeNear = cascadeFar; } - - const auto& shadowMap = m_ShadowDepthPass->GetFramebuffer()->GetDepthImage(); - m_ShadowDepthData.ShadowMapIndex = shadowMap->GetBindlessIndex(nvrhi::TextureSubresourceSet(0, 1, 0, s_ShadowCascadeCount)); - m_ShadowDepthData.ShadowSamplerIndex = m_ClampAllFiltersFalseSampler->GetBindlessIndex(); } auto SceneRenderer::ShadowDepthPass() -> void { - EP_PROFILE_FN("SceneRenderer::ShadowDepthPass") - EP_GPU_ZONE(m_RenderCommandBuffer, "ShadowDepthPass") + Renderer::Submit( + [this]() + { + EP_PROFILE_FN("SceneRenderer::ShadowDepthPass") + EP_GPU_ZONE(m_RenderCommandBuffer, "ShadowDepthPass") - struct PC - { - glm::mat4 Transform; - uint32_t InstanceOffset; - } pushConstants{}; + struct PC + { + glm::mat4 Transform; + uint32_t InstanceOffset; + } pushConstants{}; - auto& statistics = m_ShadowDepthPass->GetStatistics(); - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + auto& statistics = m_ShadowDepthPass->GetStatistics(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - m_RenderCommandBuffer->BeginTimerQuery(m_ShadowDepthPass->GetName()); - m_RenderCommandBuffer->BeginMarker(m_ShadowDepthPass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_ShadowDepthPass); + m_RenderCommandBuffer->BeginTimerQuery(m_ShadowDepthPass->GetName()); + m_RenderCommandBuffer->BeginMarker(m_ShadowDepthPass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_ShadowDepthPass); - auto& state = m_RenderCommandBuffer->GetGraphicsState(); + auto& state = m_RenderCommandBuffer->GetGraphicsState(); - for (const auto& drawCmd : m_DrawCommands | std::views::values) - { - const auto instanceCount = static_cast(drawCmd.Transforms.size()); - if (instanceCount == 0) - continue; + for (const auto& drawCmd : m_DrawCommands | std::views::values) + { + const auto instanceCount = static_cast(drawCmd.Transforms.size()); + if (instanceCount == 0) + continue; - // Each object instance is drawn once per cascade; the vertex shader derives the cascade - // and object index from SV_InstanceID and writes SV_RenderTargetArrayIndex. - const uint32_t layeredInstanceCount = instanceCount * s_ShadowCascadeCount; + // Each object instance is drawn once per cascade; the vertex shader derives the cascade + // and object index from SV_InstanceID and writes SV_RenderTargetArrayIndex. + const uint32_t layeredInstanceCount = instanceCount * s_ShadowCascadeCount; - for (const auto& submesh : drawCmd.Mesh->GetSubmeshes()) - { - const nvrhi::VertexBufferBinding vtxBufBinding{ - .buffer = submesh.VertexBuffer->GetBuffer(), - .slot = 0, - .offset = 0, - }; + for (const auto& submesh : drawCmd.Mesh->GetSubmeshes()) + { + const nvrhi::VertexBufferBinding vtxBufBinding{ + .buffer = submesh.VertexBuffer->GetBuffer(), + .slot = 0, + .offset = 0, + }; - state.vertexBuffers.resize(1); - state.vertexBuffers[0] = vtxBufBinding; - state.indexBuffer.buffer = submesh.IndexBuffer->GetBuffer(); - state.indexBuffer.format = nvrhi::Format::R32_UINT; - state.indexBuffer.offset = 0; - m_RenderCommandBuffer->CommitGraphicsState(); + state.vertexBuffers.resize(1); + state.vertexBuffers[0] = vtxBufBinding; + state.indexBuffer.buffer = submesh.IndexBuffer->GetBuffer(); + state.indexBuffer.format = nvrhi::Format::R32_UINT; + state.indexBuffer.offset = 0; + m_RenderCommandBuffer->CommitGraphicsState(); - pushConstants.Transform = submesh.LocalTransform; - pushConstants.InstanceOffset = drawCmd.InstanceOffset; + pushConstants.Transform = submesh.LocalTransform; + pushConstants.InstanceOffset = drawCmd.InstanceOffset; - for (const auto& [firstVertex, firstIndex, vertexCount, indexCount, material] : submesh.Primitives) - { - cmdList->setPushConstants(&pushConstants, sizeof(PC)); + for (const auto& [firstVertex, firstIndex, vertexCount, indexCount, material] : submesh.Primitives) + { + cmdList->setPushConstants(&pushConstants, sizeof(PC)); - nvrhi::DrawArguments drawArgs{ - .vertexCount = static_cast(indexCount), - .instanceCount = layeredInstanceCount, - .startIndexLocation = firstIndex, - .startVertexLocation = firstVertex, - }; + nvrhi::DrawArguments drawArgs{ + .vertexCount = static_cast(indexCount), + .instanceCount = layeredInstanceCount, + .startIndexLocation = firstIndex, + .startVertexLocation = firstVertex, + }; - cmdList->drawIndexed(drawArgs); + cmdList->drawIndexed(drawArgs); - statistics.DrawCalls++; - statistics.Vertices += static_cast(vertexCount) * layeredInstanceCount; - statistics.Indices += static_cast(indexCount) * layeredInstanceCount; + statistics.DrawCalls++; + statistics.Vertices += static_cast(vertexCount) * layeredInstanceCount; + statistics.Indices += static_cast(indexCount) * layeredInstanceCount; + } + statistics.Submeshes++; + } + statistics.Instances += layeredInstanceCount; + statistics.Meshes++; } - statistics.Submeshes++; - } - statistics.Instances += layeredInstanceCount; - statistics.Meshes++; - } - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndMarker(); - m_RenderCommandBuffer->EndTimerQuery(m_ShadowDepthPass->GetName()); + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndMarker(); + m_RenderCommandBuffer->EndTimerQuery(m_ShadowDepthPass->GetName()); + } + ); } auto SceneRenderer::SsaoPass() -> void { - EP_PROFILE_FN("SceneRenderer::SsaoPass") - EP_GPU_ZONE(m_RenderCommandBuffer, "SsaoPass") - - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - m_RenderCommandBuffer->BeginMarker("SSAO"); - - // Prepass - { - struct PC + Renderer::Submit( + [this]() { - uint32_t DrawIndex; - } pushConstants{}; + EP_PROFILE_FN("SceneRenderer::SsaoPass") + EP_GPU_ZONE(m_RenderCommandBuffer, "SsaoPass") - auto& statistics = m_SsaoPrePass->GetStatistics(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + m_RenderCommandBuffer->BeginMarker("SSAO"); - m_RenderCommandBuffer->BeginTimerQuery(m_SsaoPrePass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoPrePass); - - auto& state = m_RenderCommandBuffer->GetGraphicsState(); - - uint32_t drawIndex = 0; - for (const auto& drawCmd : m_DrawCommands | std::views::values) - { - const auto instanceCount = static_cast(drawCmd.Transforms.size()); - if (instanceCount == 0) - continue; - - for (const auto& submesh : drawCmd.Mesh->GetSubmeshes()) + // Prepass { - const nvrhi::VertexBufferBinding vtxBufBinding{ - .buffer = submesh.VertexBuffer->GetBuffer(), - .slot = 0, - .offset = 0, - }; - - state.vertexBuffers.resize(1); - state.vertexBuffers[0] = vtxBufBinding; - state.indexBuffer.buffer = submesh.IndexBuffer->GetBuffer(); - state.indexBuffer.format = nvrhi::Format::R32_UINT; - state.indexBuffer.offset = 0; - m_RenderCommandBuffer->CommitGraphicsState(); - - for (const auto& [firstVertex, firstIndex, vertexCount, indexCount, material] : submesh.Primitives) + struct PC { - pushConstants.DrawIndex = drawIndex++; - cmdList->setPushConstants(&pushConstants, sizeof(PC)); + uint32_t DrawIndex; + } pushConstants{}; - nvrhi::DrawArguments drawArgs{ - .vertexCount = static_cast(indexCount), - .instanceCount = instanceCount, - .startIndexLocation = firstIndex, - .startVertexLocation = firstVertex, - }; + auto& statistics = m_SsaoPrePass->GetStatistics(); - cmdList->drawIndexed(drawArgs); + m_RenderCommandBuffer->BeginTimerQuery(m_SsaoPrePass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoPrePass); - statistics.DrawCalls++; - statistics.Vertices += static_cast(vertexCount) * instanceCount; - statistics.Indices += static_cast(indexCount) * instanceCount; + auto& state = m_RenderCommandBuffer->GetGraphicsState(); + + uint32_t drawIndex = 0; + for (const auto& drawCmd : m_DrawCommands | std::views::values) + { + const auto instanceCount = static_cast(drawCmd.Transforms.size()); + if (instanceCount == 0) + continue; + + for (const auto& submesh : drawCmd.Mesh->GetSubmeshes()) + { + const nvrhi::VertexBufferBinding vtxBufBinding{ + .buffer = submesh.VertexBuffer->GetBuffer(), + .slot = 0, + .offset = 0, + }; + + state.vertexBuffers.resize(1); + state.vertexBuffers[0] = vtxBufBinding; + state.indexBuffer.buffer = submesh.IndexBuffer->GetBuffer(); + state.indexBuffer.format = nvrhi::Format::R32_UINT; + state.indexBuffer.offset = 0; + m_RenderCommandBuffer->CommitGraphicsState(); + + for (const auto& [firstVertex, firstIndex, vertexCount, indexCount, material] : submesh.Primitives) + { + pushConstants.DrawIndex = drawIndex++; + cmdList->setPushConstants(&pushConstants, sizeof(PC)); + + nvrhi::DrawArguments drawArgs{ + .vertexCount = static_cast(indexCount), + .instanceCount = instanceCount, + .startIndexLocation = firstIndex, + .startVertexLocation = firstVertex, + }; + + cmdList->drawIndexed(drawArgs); + + statistics.DrawCalls++; + statistics.Vertices += static_cast(vertexCount) * instanceCount; + statistics.Indices += static_cast(indexCount) * instanceCount; + } + statistics.Submeshes++; + } + statistics.Instances += instanceCount; + statistics.Meshes++; } - statistics.Submeshes++; - } - statistics.Instances += instanceCount; - statistics.Meshes++; - } - EP_ASSERT(drawIndex == m_DrawData.size()); + EP_ASSERT(drawIndex == m_DrawData.size()); - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndTimerQuery(m_SsaoPrePass->GetName()); - } + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndTimerQuery(m_SsaoPrePass->GetName()); + } - // Explicit transition - const auto& prepassDepth = m_SsaoPrePass->GetFramebuffer()->GetDepthImage(); - const auto& prepassNormal = m_SsaoPrePass->GetFramebuffer()->GetFinalImage(); - cmdList->setTextureState(prepassDepth->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); - cmdList->setTextureState(prepassNormal->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); + // Explicit transition + const auto& prepassDepth = m_SsaoPrePass->GetFramebuffer()->GetDepthImage(); + const auto& prepassNormal = m_SsaoPrePass->GetFramebuffer()->GetFinalImage(); + cmdList->setTextureState(prepassDepth->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); + cmdList->setTextureState(prepassNormal->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); - // Evaluation - { - m_RenderCommandBuffer->BeginTimerQuery(m_SsaoEvaluationPass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoEvaluationPass); + // Evaluation + { + m_RenderCommandBuffer->BeginTimerQuery(m_SsaoEvaluationPass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoEvaluationPass); - constexpr nvrhi::DrawArguments drawArgs{ - .vertexCount = 3, - .instanceCount = 1, - }; - cmdList->draw(drawArgs); + constexpr nvrhi::DrawArguments drawArgs{ + .vertexCount = 3, + .instanceCount = 1, + }; + cmdList->draw(drawArgs); - auto& statistics = m_SsaoEvaluationPass->GetStatistics(); - statistics.DrawCalls++; - statistics.Vertices += drawArgs.vertexCount; + auto& statistics = m_SsaoEvaluationPass->GetStatistics(); + statistics.DrawCalls++; + statistics.Vertices += drawArgs.vertexCount; - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndTimerQuery(m_SsaoEvaluationPass->GetName()); - } + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndTimerQuery(m_SsaoEvaluationPass->GetName()); + } - const auto& evaluationImage = m_SsaoEvaluationPass->GetFramebuffer()->GetFinalImage(); - cmdList->setTextureState(evaluationImage->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); + const auto& evaluationImage = m_SsaoEvaluationPass->GetFramebuffer()->GetFinalImage(); + cmdList->setTextureState(evaluationImage->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); - // Horizontal blur - { - struct PC - { - glm::vec2 Direction = glm::vec2(1.0f, 0.0f); - glm::vec2 InvSize; - } pushConstants{}; - pushConstants.InvSize = glm::vec2(1.0f / m_Width, 1.0f / m_Height); + // Horizontal blur + { + struct PC + { + glm::vec2 Direction = glm::vec2(1.0f, 0.0f); + glm::vec2 InvSize; + } pushConstants{}; + pushConstants.InvSize = glm::vec2(1.0f / m_Width, 1.0f / m_Height); - m_RenderCommandBuffer->BeginTimerQuery(m_SsaoBlurHorizontalPass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoBlurHorizontalPass); + m_RenderCommandBuffer->BeginTimerQuery(m_SsaoBlurHorizontalPass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoBlurHorizontalPass); - cmdList->setPushConstants(&pushConstants, sizeof(PC)); + cmdList->setPushConstants(&pushConstants, sizeof(PC)); - constexpr nvrhi::DrawArguments drawArgs{ - .vertexCount = 3, - .instanceCount = 1, - }; - cmdList->draw(drawArgs); + constexpr nvrhi::DrawArguments drawArgs{ + .vertexCount = 3, + .instanceCount = 1, + }; + cmdList->draw(drawArgs); - auto& statistics = m_SsaoBlurHorizontalPass->GetStatistics(); - statistics.DrawCalls++; - statistics.Vertices += drawArgs.vertexCount; + auto& statistics = m_SsaoBlurHorizontalPass->GetStatistics(); + statistics.DrawCalls++; + statistics.Vertices += drawArgs.vertexCount; - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndTimerQuery(m_SsaoBlurHorizontalPass->GetName()); - } + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndTimerQuery(m_SsaoBlurHorizontalPass->GetName()); + } - // Vertical blur - { - struct PC - { - glm::vec2 Direction = glm::vec2(0.0f, 1.0f); - glm::vec2 InvSize; - } pushConstants{}; - pushConstants.InvSize = glm::vec2(1.0f / m_Width, 1.0f / m_Height); + // Vertical blur + { + struct PC + { + glm::vec2 Direction = glm::vec2(0.0f, 1.0f); + glm::vec2 InvSize; + } pushConstants{}; + pushConstants.InvSize = glm::vec2(1.0f / m_Width, 1.0f / m_Height); - m_RenderCommandBuffer->BeginTimerQuery(m_SsaoBlurVerticalPass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoBlurVerticalPass); + m_RenderCommandBuffer->BeginTimerQuery(m_SsaoBlurVerticalPass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoBlurVerticalPass); - cmdList->setPushConstants(&pushConstants, sizeof(PC)); + cmdList->setPushConstants(&pushConstants, sizeof(PC)); - constexpr nvrhi::DrawArguments drawArgs{ - .vertexCount = 3, - .instanceCount = 1, - }; - cmdList->draw(drawArgs); + constexpr nvrhi::DrawArguments drawArgs{ + .vertexCount = 3, + .instanceCount = 1, + }; + cmdList->draw(drawArgs); - auto& statistics = m_SsaoBlurVerticalPass->GetStatistics(); - statistics.DrawCalls++; - statistics.Vertices += drawArgs.vertexCount; + auto& statistics = m_SsaoBlurVerticalPass->GetStatistics(); + statistics.DrawCalls++; + statistics.Vertices += drawArgs.vertexCount; - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndTimerQuery(m_SsaoBlurVerticalPass->GetName()); - } + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndTimerQuery(m_SsaoBlurVerticalPass->GetName()); + } - m_RenderCommandBuffer->EndMarker(); + m_RenderCommandBuffer->EndMarker(); + } + ); } auto SceneRenderer::GeometryPass() -> void { - EP_PROFILE_FN("SceneRenderer::GeometryPass") - EP_GPU_ZONE(m_RenderCommandBuffer, "GeometryPass") - - struct PC - { - uint32_t DrawIndex; - } pushConstants{}; + Renderer::Submit( + [this]() + { + EP_PROFILE_FN("SceneRenderer::GeometryPass") + EP_GPU_ZONE(m_RenderCommandBuffer, "GeometryPass") - auto& statistics = m_GeometryPass->GetStatistics(); - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + struct PC + { + uint32_t DrawIndex; + } pushConstants{}; - m_RenderCommandBuffer->BeginTimerQuery(m_GeometryPass->GetName()); + auto& statistics = m_GeometryPass->GetStatistics(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - const auto& shadowMap = m_ShadowDepthPass->GetFramebuffer()->GetDepthImage(); - cmdList->setTextureState(shadowMap->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); + m_RenderCommandBuffer->BeginTimerQuery(m_GeometryPass->GetName()); - m_RenderCommandBuffer->BeginMarker(m_GeometryPass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_GeometryPass); + const auto& shadowMap = m_ShadowDepthPass->GetFramebuffer()->GetDepthImage(); + cmdList->setTextureState(shadowMap->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); - auto& state = m_RenderCommandBuffer->GetGraphicsState(); + m_RenderCommandBuffer->BeginMarker(m_GeometryPass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_GeometryPass); - uint32_t drawIndex = 0; - for (const auto& drawCmd : m_DrawCommands | std::views::values) - { - const auto instanceCount = static_cast(drawCmd.Transforms.size()); - if (instanceCount == 0) - continue; + auto& state = m_RenderCommandBuffer->GetGraphicsState(); - for (const auto& submesh : drawCmd.Mesh->GetSubmeshes()) - { - const nvrhi::VertexBufferBinding vtxBufBinding{ - .buffer = submesh.VertexBuffer->GetBuffer(), - .slot = 0, - .offset = 0, - }; - - state.vertexBuffers.resize(1); - state.vertexBuffers[0] = vtxBufBinding; - state.indexBuffer.buffer = submesh.IndexBuffer->GetBuffer(); - state.indexBuffer.format = nvrhi::Format::R32_UINT; - state.indexBuffer.offset = 0; - m_RenderCommandBuffer->CommitGraphicsState(); - - for (const auto& [firstVertex, firstIndex, vertexCount, indexCount, material] : submesh.Primitives) + uint32_t drawIndex = 0; + for (const auto& drawCmd : m_DrawCommands | std::views::values) { - pushConstants.DrawIndex = drawIndex++; - cmdList->setPushConstants(&pushConstants, sizeof(PC)); + const auto instanceCount = static_cast(drawCmd.Transforms.size()); + if (instanceCount == 0) + continue; - nvrhi::DrawArguments drawArgs{ - .vertexCount = static_cast(indexCount), - .instanceCount = instanceCount, - .startIndexLocation = firstIndex, - .startVertexLocation = firstVertex, - }; - - cmdList->drawIndexed(drawArgs); + for (const auto& submesh : drawCmd.Mesh->GetSubmeshes()) + { + const nvrhi::VertexBufferBinding vtxBufBinding{ + .buffer = submesh.VertexBuffer->GetBuffer(), + .slot = 0, + .offset = 0, + }; - statistics.DrawCalls++; - statistics.Vertices += static_cast(vertexCount) * instanceCount; - statistics.Indices += static_cast(indexCount) * instanceCount; + state.vertexBuffers.resize(1); + state.vertexBuffers[0] = vtxBufBinding; + state.indexBuffer.buffer = submesh.IndexBuffer->GetBuffer(); + state.indexBuffer.format = nvrhi::Format::R32_UINT; + state.indexBuffer.offset = 0; + m_RenderCommandBuffer->CommitGraphicsState(); + + for (const auto& [firstVertex, firstIndex, vertexCount, indexCount, material] : submesh.Primitives) + { + pushConstants.DrawIndex = drawIndex++; + cmdList->setPushConstants(&pushConstants, sizeof(PC)); + + nvrhi::DrawArguments drawArgs{ + .vertexCount = static_cast(indexCount), + .instanceCount = instanceCount, + .startIndexLocation = firstIndex, + .startVertexLocation = firstVertex, + }; + + cmdList->drawIndexed(drawArgs); + + statistics.DrawCalls++; + statistics.Vertices += static_cast(vertexCount) * instanceCount; + statistics.Indices += static_cast(indexCount) * instanceCount; + } + statistics.Submeshes++; + } + statistics.Instances += instanceCount; + statistics.Meshes++; } - statistics.Submeshes++; - } - statistics.Instances += instanceCount; - statistics.Meshes++; - } - EP_ASSERT(drawIndex == m_DrawData.size()); + EP_ASSERT(drawIndex == m_DrawData.size()); - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndMarker(); - m_RenderCommandBuffer->EndTimerQuery(m_GeometryPass->GetName()); + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndMarker(); + m_RenderCommandBuffer->EndTimerQuery(m_GeometryPass->GetName()); + } + ); } auto SceneRenderer::SkyPass() const -> void { - EP_PROFILE_FN("SceneRenderer::SkyPass") - EP_GPU_ZONE(m_RenderCommandBuffer, "SkyPass") + Renderer::Submit( + [this]() + { + EP_PROFILE_FN("SceneRenderer::SkyPass") + EP_GPU_ZONE(m_RenderCommandBuffer, "SkyPass") - auto& statistics = m_SkyPass->GetStatistics(); - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + auto& statistics = m_SkyPass->GetStatistics(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - m_RenderCommandBuffer->BeginTimerQuery(m_SkyPass->GetName()); - m_RenderCommandBuffer->BeginMarker(m_SkyPass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SkyPass); + m_RenderCommandBuffer->BeginTimerQuery(m_SkyPass->GetName()); + m_RenderCommandBuffer->BeginMarker(m_SkyPass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SkyPass); - constexpr nvrhi::DrawArguments drawArgs{ - .vertexCount = 3, - .instanceCount = 1, - }; - cmdList->draw(drawArgs); + constexpr nvrhi::DrawArguments drawArgs{ + .vertexCount = 3, + .instanceCount = 1, + }; + cmdList->draw(drawArgs); - statistics.DrawCalls++; - statistics.Vertices += drawArgs.vertexCount; + statistics.DrawCalls++; + statistics.Vertices += drawArgs.vertexCount; - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndMarker(); - m_RenderCommandBuffer->EndTimerQuery(m_SkyPass->GetName()); + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndMarker(); + m_RenderCommandBuffer->EndTimerQuery(m_SkyPass->GetName()); + } + ); } auto SceneRenderer::BloomPass() -> void { - EP_PROFILE_FN("SceneRenderer::BloomPass") - EP_GPU_ZONE(m_RenderCommandBuffer, "BloomPass") + Renderer::Submit( + [this]() + { + EP_PROFILE_FN("SceneRenderer::BloomPass") + EP_GPU_ZONE(m_RenderCommandBuffer, "BloomPass") - struct PC - { - glm::vec4 Params; - glm::uvec4 Indices; - } pushConstants{}; + struct PC + { + glm::vec4 Params; + glm::uvec4 Indices; + } pushConstants{}; - constexpr nvrhi::DrawArguments drawArgs{ - .vertexCount = 3, - .instanceCount = 1, - }; + constexpr nvrhi::DrawArguments drawArgs{ + .vertexCount = 3, + .instanceCount = 1, + }; - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - const auto& sceneImage = m_GeometryPass->GetFramebuffer()->GetFinalImage(); - const auto& pyramid = m_BloomPyramidFramebuffer->GetFinalImage(); - const uint32_t samplerIndex = m_ClampAllFiltersTrueSampler->GetBindlessIndex(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + const auto& sceneImage = m_GeometryPass->GetFramebuffer()->GetFinalImage(); + const auto& pyramid = m_BloomPyramidFramebuffer->GetFinalImage(); + const uint32_t samplerIndex = m_ClampAllFiltersTrueSampler->GetBindlessIndex(); - m_RenderCommandBuffer->BeginMarker("Bloom"); + m_RenderCommandBuffer->BeginMarker("Bloom"); - // Downsample - { - // Push constants: - // Params: inverse source size xy, threshold, knee - // Indices: source image, sampler, apply threshold, unused - m_RenderCommandBuffer->BeginTimerQuery(m_BloomDownSamplePass->GetName()); - for (uint32_t dest = 0; dest < m_BloomMipLevels; dest++) - { - const bool base = dest == 0; - const Ref& source = base ? sceneImage : pyramid; - const uint32_t sourceMip = base ? 0u : dest - 1u; - const nvrhi::TextureSubresourceSet sourceSub(sourceMip, 1, 0, 1); + // Downsample + { + // Push constants: + // Params: inverse source size xy, threshold, knee + // Indices: source image, sampler, apply threshold, unused + m_RenderCommandBuffer->BeginTimerQuery(m_BloomDownSamplePass->GetName()); + for (uint32_t dest = 0; dest < m_BloomMipLevels; dest++) + { + const bool base = dest == 0; + const Ref& source = base ? sceneImage : pyramid; + const uint32_t sourceMip = base ? 0u : dest - 1u; + const nvrhi::TextureSubresourceSet sourceSub(sourceMip, 1, 0, 1); - cmdList->setTextureState(source->GetTexture(), sourceSub, nvrhi::ResourceStates::ShaderResource); - m_BloomDownSamplePass->SetSubresources(nvrhi::TextureSubresourceSet(dest, 1, 0, 1)); + cmdList->setTextureState(source->GetTexture(), sourceSub, nvrhi::ResourceStates::ShaderResource); + m_BloomDownSamplePass->SetSubresources(nvrhi::TextureSubresourceSet(dest, 1, 0, 1)); - m_RenderCommandBuffer->BeginMarker(m_BloomDownSamplePass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_BloomDownSamplePass); + m_RenderCommandBuffer->BeginMarker(m_BloomDownSamplePass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_BloomDownSamplePass); - pushConstants.Params = glm::vec4( - 1.0f / source->GetMipWidth(sourceMip), 1.0f / source->GetMipHeight(sourceMip), m_BloomSettings.Threshold, - m_BloomSettings.Knee - ); - pushConstants.Indices = glm::uvec4(source->GetBindlessIndex(sourceSub), samplerIndex, base ? 1u : 0u, 0u); + pushConstants.Params = glm::vec4( + 1.0f / source->GetMipWidth(sourceMip), 1.0f / source->GetMipHeight(sourceMip), m_BloomSettings.Threshold, + m_BloomSettings.Knee + ); + pushConstants.Indices = glm::uvec4(source->GetBindlessIndex(sourceSub), samplerIndex, base ? 1u : 0u, 0u); - cmdList->setPushConstants(&pushConstants, sizeof(PC)); - cmdList->draw(drawArgs); + cmdList->setPushConstants(&pushConstants, sizeof(PC)); + cmdList->draw(drawArgs); - auto& statistics = m_BloomDownSamplePass->GetStatistics(); - statistics.DrawCalls++; - statistics.Vertices += drawArgs.vertexCount; + auto& statistics = m_BloomDownSamplePass->GetStatistics(); + statistics.DrawCalls++; + statistics.Vertices += drawArgs.vertexCount; - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndMarker(); - } - m_RenderCommandBuffer->EndTimerQuery(m_BloomDownSamplePass->GetName()); - } + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndMarker(); + } + m_RenderCommandBuffer->EndTimerQuery(m_BloomDownSamplePass->GetName()); + } - // Upsample - { - // Push constants - // Params: inverse source size xy, radius, unused - // Indices: source image, sampler, unused, unused - m_RenderCommandBuffer->BeginTimerQuery(m_BloomUpSamplePass->GetName()); - for (uint32_t sourceMip = m_BloomMipLevels - 1; sourceMip > 0; sourceMip--) - { - const uint32_t dest = sourceMip - 1; - const nvrhi::TextureSubresourceSet sourceSub(sourceMip, 1, 0, 1); + // Upsample + { + // Push constants + // Params: inverse source size xy, radius, unused + // Indices: source image, sampler, unused, unused + m_RenderCommandBuffer->BeginTimerQuery(m_BloomUpSamplePass->GetName()); + for (uint32_t sourceMip = m_BloomMipLevels - 1; sourceMip > 0; sourceMip--) + { + const uint32_t dest = sourceMip - 1; + const nvrhi::TextureSubresourceSet sourceSub(sourceMip, 1, 0, 1); - cmdList->setTextureState(pyramid->GetTexture(), sourceSub, nvrhi::ResourceStates::ShaderResource); - m_BloomUpSamplePass->SetSubresources(nvrhi::TextureSubresourceSet(dest, 1, 0, 1)); + cmdList->setTextureState(pyramid->GetTexture(), sourceSub, nvrhi::ResourceStates::ShaderResource); + m_BloomUpSamplePass->SetSubresources(nvrhi::TextureSubresourceSet(dest, 1, 0, 1)); - m_RenderCommandBuffer->BeginMarker(m_BloomUpSamplePass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_BloomUpSamplePass); + m_RenderCommandBuffer->BeginMarker(m_BloomUpSamplePass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_BloomUpSamplePass); - pushConstants.Params = glm::vec4( - 1.0f / pyramid->GetMipWidth(sourceMip), 1.0f / pyramid->GetMipHeight(sourceMip), m_BloomSettings.Radius, 0.0f - ); - pushConstants.Indices = glm::uvec4(pyramid->GetBindlessIndex(sourceSub), samplerIndex, 0u, 0u); + pushConstants.Params = glm::vec4( + 1.0f / pyramid->GetMipWidth(sourceMip), 1.0f / pyramid->GetMipHeight(sourceMip), m_BloomSettings.Radius, 0.0f + ); + pushConstants.Indices = glm::uvec4(pyramid->GetBindlessIndex(sourceSub), samplerIndex, 0u, 0u); - cmdList->setPushConstants(&pushConstants, sizeof(PC)); - cmdList->draw(drawArgs); + cmdList->setPushConstants(&pushConstants, sizeof(PC)); + cmdList->draw(drawArgs); - auto& statistics = m_BloomUpSamplePass->GetStatistics(); - statistics.DrawCalls++; - statistics.Vertices += drawArgs.vertexCount; + auto& statistics = m_BloomUpSamplePass->GetStatistics(); + statistics.DrawCalls++; + statistics.Vertices += drawArgs.vertexCount; - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndMarker(); - } - m_RenderCommandBuffer->EndTimerQuery(m_BloomUpSamplePass->GetName()); - } + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndMarker(); + } + m_RenderCommandBuffer->EndTimerQuery(m_BloomUpSamplePass->GetName()); + } - // Composite - { - // Push constants - // Params: intensity, unused, unused, unused - // Indices: scene image, bloom image, sampler, unused - const nvrhi::TextureSubresourceSet mipZero(0, 1, 0, 1); + // Composite + { + // Push constants + // Params: intensity, unused, unused, unused + // Indices: scene image, bloom image, sampler, unused + const nvrhi::TextureSubresourceSet mipZero(0, 1, 0, 1); - m_RenderCommandBuffer->BeginTimerQuery(m_BloomCompositePass->GetName()); + m_RenderCommandBuffer->BeginTimerQuery(m_BloomCompositePass->GetName()); - cmdList->setTextureState(sceneImage->GetTexture(), mipZero, nvrhi::ResourceStates::ShaderResource); - cmdList->setTextureState(pyramid->GetTexture(), mipZero, nvrhi::ResourceStates::ShaderResource); + cmdList->setTextureState(sceneImage->GetTexture(), mipZero, nvrhi::ResourceStates::ShaderResource); + cmdList->setTextureState(pyramid->GetTexture(), mipZero, nvrhi::ResourceStates::ShaderResource); - m_RenderCommandBuffer->BeginMarker(m_BloomCompositePass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_BloomCompositePass); + m_RenderCommandBuffer->BeginMarker(m_BloomCompositePass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_BloomCompositePass); - pushConstants.Params = glm::vec4(m_BloomSettings.Intensity, 0.0f, 0.0f, 0.0f); - pushConstants.Indices = glm::uvec4(sceneImage->GetBindlessIndex(mipZero), pyramid->GetBindlessIndex(mipZero), samplerIndex, 0u); + pushConstants.Params = glm::vec4(m_BloomSettings.Intensity, 0.0f, 0.0f, 0.0f); + pushConstants.Indices = + glm::uvec4(sceneImage->GetBindlessIndex(mipZero), pyramid->GetBindlessIndex(mipZero), samplerIndex, 0u); - cmdList->setPushConstants(&pushConstants, sizeof(PC)); - cmdList->draw(drawArgs); + cmdList->setPushConstants(&pushConstants, sizeof(PC)); + cmdList->draw(drawArgs); - auto& statistics = m_BloomCompositePass->GetStatistics(); - statistics.DrawCalls++; - statistics.Vertices += drawArgs.vertexCount; + auto& statistics = m_BloomCompositePass->GetStatistics(); + statistics.DrawCalls++; + statistics.Vertices += drawArgs.vertexCount; - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndMarker(); - m_RenderCommandBuffer->EndTimerQuery(m_BloomCompositePass->GetName()); - } + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndMarker(); + m_RenderCommandBuffer->EndTimerQuery(m_BloomCompositePass->GetName()); + } - m_RenderCommandBuffer->EndMarker(); + m_RenderCommandBuffer->EndMarker(); + } + ); } auto SceneRenderer::TonemapPass() const -> void { - EP_PROFILE_FN("SceneRenderer::TonemapPass") - EP_GPU_ZONE(m_RenderCommandBuffer, "TonemapPass") + Renderer::Submit( + [this]() + { + EP_PROFILE_FN("SceneRenderer::TonemapPass") + EP_GPU_ZONE(m_RenderCommandBuffer, "TonemapPass") - constexpr struct PC - { - float Exposure = 1.0f; - } pushConstants{}; + constexpr struct PC + { + float Exposure = 1.0f; + } pushConstants{}; - auto& statistics = m_TonemapPass->GetStatistics(); - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + auto& statistics = m_TonemapPass->GetStatistics(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - m_RenderCommandBuffer->BeginTimerQuery(m_TonemapPass->GetName()); - m_RenderCommandBuffer->BeginMarker(m_TonemapPass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_TonemapPass); - cmdList->setPushConstants(&pushConstants, sizeof(PC)); + m_RenderCommandBuffer->BeginTimerQuery(m_TonemapPass->GetName()); + m_RenderCommandBuffer->BeginMarker(m_TonemapPass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_TonemapPass); + cmdList->setPushConstants(&pushConstants, sizeof(PC)); - constexpr nvrhi::DrawArguments drawArgs{ - .vertexCount = 3, - .instanceCount = 1, - }; - cmdList->draw(drawArgs); + constexpr nvrhi::DrawArguments drawArgs{ + .vertexCount = 3, + .instanceCount = 1, + }; + cmdList->draw(drawArgs); - statistics.DrawCalls++; - statistics.Vertices += drawArgs.vertexCount; + statistics.DrawCalls++; + statistics.Vertices += drawArgs.vertexCount; - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndMarker(); - m_RenderCommandBuffer->EndTimerQuery(m_TonemapPass->GetName()); + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndMarker(); + m_RenderCommandBuffer->EndTimerQuery(m_TonemapPass->GetName()); + } + ); } auto SceneRenderer::WireframePass() const -> void { - EP_PROFILE_FN("SceneRenderer::WireframePass") - EP_GPU_ZONE(m_RenderCommandBuffer, "WireframePass") - - if (!m_DebugRenderingEnabled || m_WireframeDrawCommands.empty()) - { - m_RenderCommandBuffer->BeginTimerQuery(m_WireframePass->GetName()); - m_RenderCommandBuffer->EndTimerQuery(m_WireframePass->GetName()); - return; - } - - struct PC - { - glm::mat4 Transform; - glm::vec4 Color; - uint32_t InstanceOffset; - } pushConstants{}; - - auto& statistics = m_WireframePass->GetStatistics(); - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - - m_RenderCommandBuffer->BeginTimerQuery(m_WireframePass->GetName()); - m_RenderCommandBuffer->BeginMarker(m_WireframePass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_WireframePass); + Renderer::Submit( + [this]() + { + EP_PROFILE_FN("SceneRenderer::WireframePass") + EP_GPU_ZONE(m_RenderCommandBuffer, "WireframePass") - auto& state = m_RenderCommandBuffer->GetGraphicsState(); + if (!m_DebugRenderingEnabled || m_WireframeDrawCommands.empty()) + { + m_RenderCommandBuffer->BeginTimerQuery(m_WireframePass->GetName()); + m_RenderCommandBuffer->EndTimerQuery(m_WireframePass->GetName()); + return; + } - for (const auto& drawCmd : m_WireframeDrawCommands) - { - const auto instanceCount = static_cast(drawCmd.Transforms.size()); - if (instanceCount == 0) - continue; + struct PC + { + glm::mat4 Transform; + glm::vec4 Color; + uint32_t InstanceOffset; + } pushConstants{}; - for (const auto& submesh : drawCmd.Mesh->GetSubmeshes()) - { - const nvrhi::VertexBufferBinding vtxBufBinding{ - .buffer = submesh.VertexBuffer->GetBuffer(), - .slot = 0, - .offset = 0, - }; + auto& statistics = m_WireframePass->GetStatistics(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - state.vertexBuffers.resize(1); - state.vertexBuffers[0] = vtxBufBinding; - state.indexBuffer.buffer = submesh.IndexBuffer->GetBuffer(); - state.indexBuffer.format = nvrhi::Format::R32_UINT; - state.indexBuffer.offset = 0; - m_RenderCommandBuffer->CommitGraphicsState(); + m_RenderCommandBuffer->BeginTimerQuery(m_WireframePass->GetName()); + m_RenderCommandBuffer->BeginMarker(m_WireframePass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_WireframePass); - pushConstants.Transform = submesh.LocalTransform; - pushConstants.Color = drawCmd.Color; - pushConstants.InstanceOffset = drawCmd.InstanceOffset; + auto& state = m_RenderCommandBuffer->GetGraphicsState(); - for (const auto& [firstVertex, firstIndex, vertexCount, indexCount, material] : submesh.Primitives) + for (const auto& drawCmd : m_WireframeDrawCommands) { - cmdList->setPushConstants(&pushConstants, sizeof(PC)); - - nvrhi::DrawArguments drawArgs{ - .vertexCount = static_cast(indexCount), - .instanceCount = instanceCount, - .startIndexLocation = firstIndex, - .startVertexLocation = firstVertex, - }; + const auto instanceCount = static_cast(drawCmd.Transforms.size()); + if (instanceCount == 0) + continue; - cmdList->drawIndexed(drawArgs); + for (const auto& submesh : drawCmd.Mesh->GetSubmeshes()) + { + const nvrhi::VertexBufferBinding vtxBufBinding{ + .buffer = submesh.VertexBuffer->GetBuffer(), + .slot = 0, + .offset = 0, + }; - statistics.DrawCalls++; - statistics.Vertices += static_cast(vertexCount); - statistics.Indices += static_cast(indexCount); + state.vertexBuffers.resize(1); + state.vertexBuffers[0] = vtxBufBinding; + state.indexBuffer.buffer = submesh.IndexBuffer->GetBuffer(); + state.indexBuffer.format = nvrhi::Format::R32_UINT; + state.indexBuffer.offset = 0; + m_RenderCommandBuffer->CommitGraphicsState(); + + pushConstants.Transform = submesh.LocalTransform; + pushConstants.Color = drawCmd.Color; + pushConstants.InstanceOffset = drawCmd.InstanceOffset; + + for (const auto& [firstVertex, firstIndex, vertexCount, indexCount, material] : submesh.Primitives) + { + cmdList->setPushConstants(&pushConstants, sizeof(PC)); + + nvrhi::DrawArguments drawArgs{ + .vertexCount = static_cast(indexCount), + .instanceCount = instanceCount, + .startIndexLocation = firstIndex, + .startVertexLocation = firstVertex, + }; + + cmdList->drawIndexed(drawArgs); + + statistics.DrawCalls++; + statistics.Vertices += static_cast(vertexCount); + statistics.Indices += static_cast(indexCount); + } + statistics.Submeshes++; + } + statistics.Meshes++; + statistics.Instances += instanceCount; } - statistics.Submeshes++; - } - statistics.Meshes++; - statistics.Instances += instanceCount; - } - Renderer::EndRenderPass(m_RenderCommandBuffer); - m_RenderCommandBuffer->EndMarker(); - m_RenderCommandBuffer->EndTimerQuery(m_WireframePass->GetName()); + Renderer::EndRenderPass(m_RenderCommandBuffer); + m_RenderCommandBuffer->EndMarker(); + m_RenderCommandBuffer->EndTimerQuery(m_WireframePass->GetName()); + } + ); } auto SceneRenderer::EnsureIblResources() -> void diff --git a/EppoEngine/Source/Renderer/SceneRenderer.h b/EppoEngine/Source/Renderer/SceneRenderer.h index b761c534..0fcc3773 100644 --- a/EppoEngine/Source/Renderer/SceneRenderer.h +++ b/EppoEngine/Source/Renderer/SceneRenderer.h @@ -63,7 +63,8 @@ namespace Eppo auto BeginSceneInternal() -> void; auto EnsureColliderMeshes() -> void; auto GatherWireframes() -> void; - auto PrepareRender() -> void; + auto PrepareRenderData() -> void; + auto UploadRenderData() -> void; auto FillShadowData() -> void; auto ShadowDepthPass() -> void; @@ -226,7 +227,9 @@ namespace Eppo }; std::map m_DrawCommands; + std::vector m_InstanceTransforms; Ref m_InstanceTransformsSB = nullptr; + std::vector m_WireframeTransforms; std::vector m_WireframeDrawCommands; Ref m_WireframeInstanceSB = nullptr; diff --git a/EppoEngine/Source/Renderer/Shader.cpp b/EppoEngine/Source/Renderer/Shader.cpp index 9ba2482f..14a05f02 100644 --- a/EppoEngine/Source/Renderer/Shader.cpp +++ b/EppoEngine/Source/Renderer/Shader.cpp @@ -87,6 +87,11 @@ namespace Eppo } } + auto Shader::IsLoaded() const -> bool + { + return m_IsLoaded.load(std::memory_order_acquire); + } + auto Shader::CreateShaderHandles() -> void { const auto& dm = DeviceManager::Get(); diff --git a/EppoEngine/Source/Renderer/Shader.h b/EppoEngine/Source/Renderer/Shader.h index 71b9a499..0e87a182 100644 --- a/EppoEngine/Source/Renderer/Shader.h +++ b/EppoEngine/Source/Renderer/Shader.h @@ -69,6 +69,7 @@ namespace Eppo [[nodiscard]] auto GetPushConstants() const -> const PushConstantRange& { return m_PushConstants; } [[nodiscard]] constexpr auto GetName() const -> const std::string& { return m_Specification.Name; } + [[nodiscard]] auto IsLoaded() const -> bool; static auto Create(ShaderSpecification spec) -> Ref; @@ -79,6 +80,7 @@ namespace Eppo protected: ShaderSpecification m_Specification; + std::atomic m_IsLoaded = false; std::unordered_map m_ShaderHandles; diff --git a/EppoEngine/Source/Scripting/ScriptEngine.cpp b/EppoEngine/Source/Scripting/ScriptEngine.cpp index e7fc168d..571c2b8c 100644 --- a/EppoEngine/Source/Scripting/ScriptEngine.cpp +++ b/EppoEngine/Source/Scripting/ScriptEngine.cpp @@ -1,6 +1,7 @@ #include "pch.h" #include "Scripting/ScriptEngine.h" +#include "Core/Application.h" #include "Project/Project.h" #include "Utility/Process.h" @@ -110,6 +111,8 @@ namespace Eppo auto ScriptEngine::VerifyRuntime() -> void { + EP_PROFILE_FN("ScriptEngine::VerifyRuntime"); + if (!m_ScriptWatcher || !Project::GetActive()) return; @@ -124,8 +127,8 @@ namespace Eppo if (!m_ReloadPending || GetSceneContext()) return; - ReloadProjectAssembly(); - m_ReloadPending = false; + if (ReloadProjectAssembly()) + m_ReloadPending = false; } auto ScriptEngine::Get() -> ScriptEngine& @@ -184,35 +187,118 @@ namespace Eppo } const auto outputDirectory = Project::GetCacheDirectory() / "Scripts"; - const int32_t exitCode = RunProcess( - "dotnet", - { "build", projectFile.string(), "-c", "Debug", "-o", outputDirectory.string(), - // Point the project at this build's core assembly instead of a baked-in - // path that goes stale when the output layout changes. - "-p:CoreManagedDll=" + (FS::GetExecutableDirectory() / "EppoScriptCore.dll").string(), "--nologo" } - ); - if (exitCode != 0) - { - Log::Error(LogSource::Script, "Script build for '{}' failed with exit code {}; see the build output above.", name, exitCode); - return false; - } + // Queue async build if needed + const auto& threadPool = Application::Get().GetThreadPool(); - const auto assemblyPath = outputDirectory / (name + ".dll"); - if (!FS::Exists(assemblyPath)) + if (m_BuildTaskId != 0) { - Log::Error(LogSource::Script, "Script build for '{}' produced no assembly at '{}'.", name, assemblyPath); - return false; - } + // Build already queued, cancel if possible + bool cancelled = threadPool->CancelTask(m_BuildTaskId); - UnloadUserAssembly(); - if (!LoadUserAssembly(assemblyPath)) - { - Log::Error(LogSource::Script, "Failed to load script assembly for '{}'.", name); - return false; + // Build already running, check back later + if (!cancelled) + { + m_ReloadPending = true; + return false; + } } - Log::Info(LogSource::Script, "Loaded script assembly for '{}'.", name); + auto result = CreateRef>(); + m_BuildTaskId = threadPool->QueueTask( + "Compiling .NET Runtime", + [result, projectFile, outputDirectory]() -> void + { + const auto tempDir = outputDirectory / "Temp"; + + const int32_t exitCode = RunProcess( + "dotnet", + { "build", projectFile.string(), "-c", "Debug", "-o", tempDir.string(), + // Point the project at this build's core assembly instead of a baked-in + // path that goes stale when the output layout changes. + "-p:CoreManagedDll=" + (FS::GetExecutableDirectory() / "EppoScriptCore.dll").string(), "--nologo" } + ); + + *result = exitCode; + }, + [this, result, name, outputDirectory, project](TaskStatus status) -> void + { + const auto tempDir = outputDirectory / "Temp"; + + if (status == TaskStatus::Cancelled) + { + Log::Error(LogSource::Script, "Script build for '{}' failed because the worker task was cancelled!", name); + return; + } + + m_BuildTaskId = 0; + + if (Project::GetActive() != project) + { + Log::Error(LogSource::Script, "Script build for '{}' failed because the project isn't currently loaded!", name); + return; + } + + if (status != TaskStatus::Completed) + { + Log::Error(LogSource::Script, "Script build for '{}' failed because the worker task failed!", name); + return; + } + + const int32_t exitCode = result->has_value() ? result->value() : -1; + + if (exitCode != 0) + { + Log::Error( + LogSource::Script, "Script build for '{}' failed with exit code {}; see the build output above.", name, exitCode + ); + return; + } + + const auto oldAssemblyPath = outputDirectory / (name + ".dll"); + const auto backupAssemblyPath = outputDirectory / (name + ".dll.bak"); + const auto newAssemblyPath = tempDir / (name + ".dll"); + + if (!FS::Exists(newAssemblyPath)) + { + Log::Error(LogSource::Script, "Script build for '{}' produced no assembly at '{}'.", name, newAssemblyPath); + return; + } + + bool hasBackupAssembly = FS::Exists(oldAssemblyPath); + if (!FS::Move(oldAssemblyPath, backupAssemblyPath)) + hasBackupAssembly = false; + + if (!FS::Move(newAssemblyPath, oldAssemblyPath) && hasBackupAssembly) + { + FS::Move(backupAssemblyPath, oldAssemblyPath); + FS::RemoveAll(tempDir); + return; + } + + UnloadUserAssembly(); + + if (!LoadUserAssembly(oldAssemblyPath)) + { + Log::Error(LogSource::Script, "Failed to load script assembly for '{}', restoring old assembly if possible.", name); + + if (hasBackupAssembly) + { + FS::Move(backupAssemblyPath, oldAssemblyPath); + if (!LoadUserAssembly(oldAssemblyPath)) + Log::Error(LogSource::Script, "Failed to load previous script assembly!"); + else + Log::Info(LogSource::Script, "Restored previous script assembly."); + } + + FS::RemoveAll(tempDir); + + return; + } + + Log::Info(LogSource::Script, "Loaded script assembly for '{}'.", name); + } + ); return true; } @@ -222,6 +308,16 @@ namespace Eppo return s_Instance && s_Instance->m_UserAssemblyValid; } + auto ScriptEngine::IsUserAssemblyCompiling() -> bool + { + return s_Instance && s_Instance->m_BuildTaskId != 0; + } + + auto ScriptEngine::IsUserAssemblyReloadPending() -> bool + { + return s_Instance && s_Instance->m_ReloadPending; + } + auto ScriptEngine::SetSceneContext(const Ref& scene) -> void { if (!scene) @@ -361,9 +457,8 @@ namespace Eppo return nullptr; } - auto ScriptEngine::GetFieldValueOrDefault( - const UUID& entityId, const int32_t classIndex, const int32_t fieldIndex - ) const -> ScriptFieldValue + auto ScriptEngine::GetFieldValueOrDefault(const UUID& entityId, const int32_t classIndex, const int32_t fieldIndex) const + -> ScriptFieldValue { if (!m_CoreAssembly || classIndex < 0 || classIndex >= static_cast(GetClasses().size())) return {}; diff --git a/EppoEngine/Source/Scripting/ScriptEngine.h b/EppoEngine/Source/Scripting/ScriptEngine.h index 760e0ac8..3825e993 100644 --- a/EppoEngine/Source/Scripting/ScriptEngine.h +++ b/EppoEngine/Source/Scripting/ScriptEngine.h @@ -1,5 +1,6 @@ #pragma once +#include "Core/Threading/ThreadPool.h" #include "Scene/Entity.h" #include "Scripting/Assembly.h" #include "Scripting/ScriptClass.h" @@ -38,6 +39,8 @@ namespace Eppo [[nodiscard]] auto IsRuntimeLoaded() const -> bool; [[nodiscard]] static auto IsUserAssemblyValid() -> bool; + [[nodiscard]] static auto IsUserAssemblyCompiling() -> bool; + [[nodiscard]] static auto IsUserAssemblyReloadPending() -> bool; // Class metadata [[nodiscard]] auto GetClasses() const -> const std::vector&; @@ -81,6 +84,7 @@ namespace Eppo std::filesystem::path m_WatchedScriptsDirectory; bool m_UserAssemblyValid = false; bool m_ReloadPending = false; + TaskId m_BuildTaskId = 0; WeakRef m_ActivePhysicsWorld; WeakRef m_SceneContext; diff --git a/EppoEngine/Source/Utility/Random.h b/EppoEngine/Source/Utility/Random.h index c34aa270..78f5ec4e 100644 --- a/EppoEngine/Source/Utility/Random.h +++ b/EppoEngine/Source/Utility/Random.h @@ -6,8 +6,8 @@ namespace Eppo::Utils { namespace { - std::random_device s_RandomDevice; - std::mt19937_64 s_Engine64(s_RandomDevice()); + thread_local std::random_device s_RandomDevice; + thread_local std::mt19937_64 s_Engine64(s_RandomDevice()); } inline auto GenerateRandomInt64(const int64_t min = INT64_MIN, const int64_t max = INT64_MAX) -> int64_t diff --git a/EppoEngineTesting/Source/Core/Application.cpp b/EppoEngineTesting/Source/Core/Application.cpp index 4c088394..a27aadba 100644 --- a/EppoEngineTesting/Source/Core/Application.cpp +++ b/EppoEngineTesting/Source/Core/Application.cpp @@ -3,6 +3,8 @@ #include "Event/KeyEvent.h" #include "ImGui/ImGuiLayer.h" +#include "Renderer/DeviceManager.h" +#include "Renderer/Renderer.h" #include @@ -50,6 +52,100 @@ class UITrackingLayer : public Layer uint32_t ResizeHeight = 0; }; +struct ThreadPoolTeardownState +{ + bool Detached = false; + bool CompletionCalled = false; + bool CompletionBeforeDetach = false; +}; + +class ThreadPoolTeardownLayer : public Layer +{ +public: + explicit ThreadPoolTeardownLayer(Ref state) + : m_State(std::move(state)) + {} + + auto OnAttach() -> void override + { + Application::Get().GetThreadPool()->QueueTask( + "Teardown order", + []() -> void + { + }, + [state = m_State](TaskStatus) -> void + { + state->CompletionCalled = true; + state->CompletionBeforeDetach = !state->Detached; + } + ); + } + + auto OnDetach() -> void override { m_State->Detached = true; } + +private: + Ref m_State; +}; + +struct RenderCommandTrackingState +{ + bool UpdateCompleted = false; + bool UICompleted = false; + bool CommandExecuted = false; + bool ExecutedAfterUpdate = false; + bool ExecutedAfterUI = false; +}; + +class RenderCommandTrackingLayer : public Layer +{ +public: + explicit RenderCommandTrackingLayer(Ref state) + : m_State(std::move(state)) + {} + + auto OnUpdate(float) -> void override + { + m_State->UpdateCompleted = true; + Renderer::Submit( + [state = m_State]() -> void + { + state->CommandExecuted = true; + state->ExecutedAfterUpdate = state->UpdateCompleted; + state->ExecutedAfterUI = state->UICompleted; + } + ); + } + + auto OnUIRender() -> void override { m_State->UICompleted = true; } + +private: + Ref m_State; +}; + +struct FrameIndexTrackingState +{ + std::vector FrameIndices; + std::vector BackBufferIndices; +}; + +class FrameIndexTrackingLayer : public Layer +{ +public: + explicit FrameIndexTrackingLayer(Ref state) + : m_State(std::move(state)) + {} + + auto OnUpdate(float) -> void override + { + const auto& deviceManager = DeviceManager::Get(); + m_State->FrameIndices.emplace_back(deviceManager->GetCurrentFrameIndex()); + m_State->BackBufferIndices.emplace_back(deviceManager->GetCurrentBackBufferIndex()); + } + +private: + Ref m_State; +}; + TEST(App, Application_Boot_ProducesWindowAndDevice) { Application* app = Testing::AppHarness::Get(); @@ -182,3 +278,135 @@ TEST(App, Application_WindowResizePropagatesToLayers) EXPECT_EQ(1280, layer->ResizeWidth); EXPECT_EQ(720, layer->ResizeHeight); } + +TEST(App, Application_ShutdownFlushesTaskCompletionsBeforeDetachingLayers) +{ + Testing::AppHarness::Shutdown(); + ApplicationParams params{ + .Args = CommandLineArgs(0, nullptr), + .EnableImGui = false, + }; + Application* app = Testing::AppHarness::Get(std::move(params)); + EP_REQUIRE(app != nullptr); + + const auto state = CreateRef(); + app->PushLayer(state); + + Testing::AppHarness::Shutdown(); + + EXPECT_TRUE(state->CompletionCalled); + EXPECT_TRUE(state->CompletionBeforeDetach); + EXPECT_TRUE(state->Detached); +} + +TEST(App, Application_StepFrame_ExecutesSubmittedRenderCommandsAfterUI) +{ + Testing::AppHarness::Shutdown(); + Application* app = Testing::AppHarness::Get(); + EP_REQUIRE(app != nullptr); + + const auto state = CreateRef(); + app->PushLayer(state); + + Testing::AppHarness::AdvanceFrames(1); + + EXPECT_TRUE(state->UpdateCompleted); + EXPECT_TRUE(state->UICompleted); + EXPECT_TRUE(state->CommandExecuted); + EXPECT_TRUE(state->ExecutedAfterUpdate); + EXPECT_TRUE(state->ExecutedAfterUI); +} + +TEST(App, Application_StepFrame_ExecutesSubmittedRenderCommandsWithoutImGui) +{ + Testing::AppHarness::Shutdown(); + ApplicationParams params{ + .Args = CommandLineArgs(0, nullptr), + .EnableImGui = false, + }; + Application* app = Testing::AppHarness::Get(std::move(params)); + EP_REQUIRE(app != nullptr); + + const auto state = CreateRef(); + app->PushLayer(state); + + Testing::AppHarness::AdvanceFrames(1); + + EXPECT_TRUE(state->UpdateCompleted); + EXPECT_FALSE(state->UICompleted); + EXPECT_TRUE(state->CommandExecuted); + EXPECT_TRUE(state->ExecutedAfterUpdate); + EXPECT_FALSE(state->ExecutedAfterUI); +} + +TEST(App, DeviceManager_FrameAndBackBufferCounts_AreValidIndependentRanges) +{ + Testing::AppHarness::Shutdown(); + ApplicationParams params{ + .Args = CommandLineArgs(0, nullptr), + .EnableImGui = false, + }; + Application* app = Testing::AppHarness::Get(std::move(params)); + EP_REQUIRE(app != nullptr); + + const auto& deviceManager = app->GetDeviceManager(); + const uint32_t backBufferCount = deviceManager->GetBackBufferCount(); + const uint32_t expectedFramesInFlight = std::min(deviceManager->GetParams().MaxFramesInFlight, backBufferCount); + + EXPECT_GE(backBufferCount, 2u); + EXPECT_EQ(expectedFramesInFlight, deviceManager->GetMaxFramesInFlight()); + EXPECT_LT(deviceManager->GetCurrentFrameIndex(), deviceManager->GetMaxFramesInFlight()); + EXPECT_LT(deviceManager->GetCurrentBackBufferIndex(), backBufferCount); +} + +TEST(App, DeviceManager_CurrentFrameIndex_RotatesAcrossFramesInFlight) +{ + Testing::AppHarness::Shutdown(); + ApplicationParams params{ + .Args = CommandLineArgs(0, nullptr), + .EnableImGui = false, + }; + Application* app = Testing::AppHarness::Get(std::move(params)); + EP_REQUIRE(app != nullptr); + + const auto& deviceManager = app->GetDeviceManager(); + const uint32_t maxFramesInFlight = deviceManager->GetMaxFramesInFlight(); + EP_REQUIRE(maxFramesInFlight > 0); + + const auto state = CreateRef(); + app->PushLayer(state); + Testing::AppHarness::AdvanceFrames(maxFramesInFlight * 2 + 1); + + EP_REQUIRE_EQ(maxFramesInFlight * 2 + 1, state->FrameIndices.size()); + EXPECT_EQ(state->FrameIndices.size(), state->BackBufferIndices.size()); + + const uint32_t firstFrameIndex = state->FrameIndices.front(); + for (size_t i = 0; i < state->FrameIndices.size(); i++) + { + EXPECT_EQ((firstFrameIndex + i) % maxFramesInFlight, state->FrameIndices.at(i)); + EXPECT_LT(state->BackBufferIndices.at(i), deviceManager->GetBackBufferCount()); + } +} + +TEST(App, Application_Shutdown_ReleasesPendingRenderCommandCaptures) +{ + Testing::AppHarness::Shutdown(); + ApplicationParams params{ + .Args = CommandLineArgs(0, nullptr), + .EnableImGui = false, + }; + Application* app = Testing::AppHarness::Get(std::move(params)); + EP_REQUIRE(app != nullptr); + + auto capturedState = CreateRef(42); + const WeakRef weakState = capturedState; + + Renderer::Submit([capturedState]() -> void {}); + capturedState.reset(); + + EXPECT_FALSE(weakState.expired()); + + Testing::AppHarness::Shutdown(); + + EXPECT_TRUE(weakState.expired()); +} diff --git a/EppoEngineTesting/Source/Core/ThreadPool.cpp b/EppoEngineTesting/Source/Core/ThreadPool.cpp new file mode 100644 index 00000000..847778fa --- /dev/null +++ b/EppoEngineTesting/Source/Core/ThreadPool.cpp @@ -0,0 +1,1207 @@ +#include "TestSupport/EppoTest.h" +#include "Core/Threading/ThreadPool.h" + +#include +#include +#include +#include + +using Eppo::TaskFn; +using Eppo::TaskId; +using Eppo::TaskResult; +using Eppo::TaskStatus; +using Eppo::ThreadPool; + +// Every wait here is deadline-bounded. A wedged pool must fail its test, not hang the +// whole CTest run, so nothing in this file blocks on a condition that may never hold. +namespace +{ + constexpr auto s_WaitTimeout = std::chrono::seconds(1); + + auto WaitUntil(const std::function& predicate) -> bool + { + const auto deadline = std::chrono::steady_clock::now() + s_WaitTimeout; + while (std::chrono::steady_clock::now() < deadline) + { + if (predicate()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + return predicate(); + } + + // Completion callbacks only run inside Flush, so anything waiting on a callback has + // to keep pumping the pool from the calling (owner) thread. + auto FlushUntil(ThreadPool& pool, const std::function& predicate) -> bool + { + const auto deadline = std::chrono::steady_clock::now() + s_WaitTimeout; + while (std::chrono::steady_clock::now() < deadline) + { + pool.Flush(); + if (predicate()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + pool.Flush(); + return predicate(); + } + + // Blocks a worker until the gate opens, so a test can hold tasks in a known state. + // Self-releasing on the same deadline: a failed test must not deadlock shutdown. + auto WaitForGate(const std::atomic& gate) -> void + { + const auto deadline = std::chrono::steady_clock::now() + s_WaitTimeout; + while (!gate.load(std::memory_order_acquire) && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + + [[nodiscard]] auto GetThreadPoolWorkerCount() -> uint32_t + { + const uint32_t hardwareThreadCount = std::thread::hardware_concurrency(); + return hardwareThreadCount > 2 ? hardwareThreadCount - 2 : 1; + } +} + +TEST(Core, ThreadPool_QueueTask_ReturnsNonZeroTaskId) +{ + ThreadPool pool; + + const auto id = pool.QueueTask( + "Task", + []() -> void + { + }, + nullptr + ); + + EXPECT_NE(0u, id); +} + +TEST(Core, ThreadPool_QueueTask_AssignsUniqueTaskIds) +{ + ThreadPool pool; + + const auto first = pool.QueueTask( + "First", + []() -> void + { + }, + nullptr + ); + const auto second = pool.QueueTask( + "Second", + []() -> void + { + }, + nullptr + ); + + EXPECT_NE(first, second); +} + +TEST(Core, ThreadPool_Flush_InvokesCompletionOnCallingThread) +{ + const auto callingThread = std::this_thread::get_id(); + + std::atomic invoked = false; + std::atomic onCallingThread = false; + ThreadPool pool; + + pool.QueueTask( + "Task", + []() -> void + { + }, + [&invoked, &onCallingThread, callingThread](const TaskStatus status) -> void + { + onCallingThread.store(std::this_thread::get_id() == callingThread && status == TaskStatus::Completed); + invoked.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&invoked]() -> bool + { + return invoked.load(); + } + )); + EXPECT_TRUE(onCallingThread.load()); +} + +TEST(Core, ThreadPool_QueueTask_ThrowingTaskReportsFailedStatus) +{ + std::atomic invoked = false; + std::atomic reported = TaskStatus::Pending; + ThreadPool pool; + + pool.QueueTask( + "Throwing", + []() -> void + { + throw std::runtime_error("expected"); + }, + [&invoked, &reported](const TaskStatus status) -> void + { + reported.store(status); + invoked.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&invoked]() -> bool + { + return invoked.load(); + } + )); + EXPECT_EQ(TaskStatus::Failed, reported.load()); +} + +TEST(Core, ThreadPool_CancelAll_FiresCompletionsWithCancelledStatus) +{ + constexpr uint32_t taskCount = 500; + + std::atomic gate = false; + std::atomic ran = 0; + std::atomic cancelled = 0; + std::atomic completed = 0; + ThreadPool pool; + + // The gate holds every worker, so the rest of the tasks are guaranteed to still be + // queued when CancelAll runs. Without it the pool drains all 500 first and there is + // nothing left to cancel. + for (uint32_t i = 0; i < taskCount; i++) + { + pool.QueueTask( + "Task", + [&gate, &ran]() -> void + { + WaitForGate(gate); + ran++; + }, + [&cancelled, &completed](const TaskStatus status) -> void + { + if (status == TaskStatus::Cancelled) + cancelled++; + else + completed++; + } + ); + } + + pool.CancelAll(); + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(FlushUntil( + pool, + [&cancelled, &completed]() -> bool + { + return cancelled.load() + completed.load() == taskCount; + } + )); + + // Far more tasks than any plausible worker count, so some must still have been queued. + EXPECT_GT(cancelled.load(), 0u); + EXPECT_EQ(taskCount, cancelled.load() + completed.load()); + + // A cancelled task must never have executed its body. + EXPECT_EQ(completed.load(), ran.load()); +} + +TEST(Core, ThreadPool_Shutdown_WithoutCancelDrainsQueuedTasks) +{ + constexpr uint32_t taskCount = 200; + + std::atomic ran = 0; + ThreadPool pool; + + for (uint32_t i = 0; i < taskCount; i++) + pool.QueueTask( + "Task", + [&ran]() -> void + { + ran++; + }, + nullptr + ); + + pool.Shutdown(false); + + EXPECT_EQ(taskCount, ran.load()); + EXPECT_EQ(0u, pool.GetPendingTasksCount()); +} + +TEST(Core, ThreadPool_Shutdown_FlushesCompletionCallbacksBeforeReturning) +{ + std::atomic invoked = 0; + ThreadPool pool; + + for (uint32_t i = 0; i < 50; i++) + pool.QueueTask( + "Task", + []() -> void + { + }, + [&invoked](TaskStatus) -> void + { + invoked++; + } + ); + + pool.Shutdown(false); + + EXPECT_EQ(50u, invoked.load()); +} + +TEST(Core, ThreadPool_Flush_ContinuesAfterCompletionCallbackThrows) +{ + std::atomic subsequentCallbackInvoked = false; + ThreadPool pool; + + pool.QueueTask( + "Throwing completion", + []() -> void + { + }, + [](TaskStatus) -> void + { + throw std::runtime_error("Completion failed"); + } + ); + pool.QueueTask( + "Subsequent completion", + []() -> void + { + }, + [&subsequentCallbackInvoked](TaskStatus) -> void + { + subsequentCallbackInvoked.store(true); + } + ); + + EXPECT_NO_THROW(pool.Shutdown(false)); + EXPECT_TRUE(subsequentCallbackInvoked.load()); +} + +TEST(Core, ThreadPool_Shutdown_RejectsNewTasks) +{ + std::atomic ran = false; + ThreadPool pool; + + pool.Shutdown(true); + + const auto id = pool.QueueTask( + "Late task", + [&ran]() -> void + { + ran.store(true); + }, + nullptr + ); + + EXPECT_EQ(0u, id); + EXPECT_FALSE(ran.load()); + EXPECT_EQ(0u, pool.GetPendingTasksCount()); +} + +TEST(Core, ThreadPool_GetPendingTasksCount_ReflectsQueuedAndInFlightTasks) +{ + constexpr uint32_t taskCount = 50; + + std::atomic gate = false; + ThreadPool pool; + + EXPECT_EQ(0u, pool.GetPendingTasksCount()); + + for (uint32_t i = 0; i < taskCount; i++) + pool.QueueTask( + "Gated", + [&gate]() -> void + { + WaitForGate(gate); + }, + nullptr + ); + + // Gated tasks cannot complete, so queued + in-flight stays at the full count. + EXPECT_EQ(taskCount, pool.GetPendingTasksCount()); + + gate.store(true, std::memory_order_release); + + EXPECT_TRUE(WaitUntil( + [&pool]() -> bool + { + return pool.GetPendingTasksCount() == 0; + } + )); +} + +TEST(Core, ThreadPool_QueueTaskWithDependencies_RunsAfterAllDependenciesComplete) +{ + std::atomic dependenciesRun = 0; + std::atomic countWhenDependentRan = 0; + std::atomic dependentRan = false; + ThreadPool pool; + + std::vector dependencies; + for (uint32_t i = 0; i < 3; i++) + dependencies.emplace_back(pool.QueueTask( + "Dependency", + [&dependenciesRun]() -> void + { + dependenciesRun++; + }, + nullptr + )); + + pool.QueueTaskWithDependencies( + "Dependent", + [&dependenciesRun, &countWhenDependentRan, &dependentRan]() -> void + { + countWhenDependentRan.store(dependenciesRun.load()); + dependentRan.store(true); + }, + nullptr, dependencies + ); + + ASSERT_TRUE(WaitUntil( + [&dependentRan]() -> bool + { + return dependentRan.load(); + } + )); + EXPECT_EQ(3u, countWhenDependentRan.load()); +} + +TEST(Core, ThreadPool_QueueTaskWithDependencies_FiresCompletionCallback) +{ + std::atomic invoked = false; + std::atomic reported = TaskStatus::Pending; + ThreadPool pool; + + const auto dependency = pool.QueueTask( + "Dependency", + []() -> void + { + }, + nullptr + ); + + pool.QueueTaskWithDependencies( + "Dependent", + []() -> void + { + }, + [&invoked, &reported](const TaskStatus status) -> void + { + reported.store(status); + invoked.store(true); + }, + { dependency } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&invoked]() -> bool + { + return invoked.load(); + } + )); + EXPECT_EQ(TaskStatus::Completed, reported.load()); +} + +TEST(Core, ThreadPool_QueueTaskWithDependencies_TreatsCompletedDependencyAsSatisfied) +{ + std::atomic dependentRan = false; + ThreadPool pool; + + const auto dependency = pool.QueueTask( + "Dependency", + []() -> void + { + }, + nullptr + ); + + // Wait for the work to finish without flushing, so the dependency is complete but + // still present in the pool's task table. + ASSERT_TRUE(WaitUntil( + [&pool]() -> bool + { + return pool.GetPendingTasksCount() == 0; + } + )); + + pool.QueueTaskWithDependencies( + "Dependent", + [&dependentRan]() -> void + { + dependentRan.store(true); + }, + nullptr, { dependency } + ); + + EXPECT_TRUE(WaitUntil( + [&dependentRan]() -> bool + { + return dependentRan.load(); + } + )); +} + +TEST(Core, ThreadPool_QueueTaskWithDependencies_TreatsFlushedDependencyAsSatisfied) +{ + std::atomic dependencyRan = false; + std::atomic dependentRan = false; + ThreadPool pool; + + const auto dependency = pool.QueueTask( + "Dependency", + [&dependencyRan]() -> void + { + dependencyRan.store(true); + }, + nullptr + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&dependencyRan]() -> bool + { + return dependencyRan.load(); + } + )); + + pool.QueueTaskWithDependencies( + "Dependent", + [&dependentRan]() -> void + { + dependentRan.store(true); + }, + nullptr, { dependency } + ); + + EXPECT_TRUE(WaitUntil( + [&dependentRan]() -> bool + { + return dependentRan.load(); + } + )); +} + +TEST(Core, ThreadPool_QueueTaskWithDependencies_RejectsUnknownDependency) +{ + std::atomic ran = false; + ThreadPool pool; + + constexpr TaskId neverIssued = 999999; + + TaskId id = 1; + EXPECT_NO_THROW( + id = pool.QueueTaskWithDependencies( + "Dependent", + [&ran]() -> void + { + ran.store(true); + }, + nullptr, { neverIssued } + ) + ); + + EXPECT_EQ(0u, id); + EXPECT_FALSE(ran.load()); +} + +TEST(Core, ThreadPool_QueueTaskWithDependencies_RejectsUnknownDependencyWithoutCorruptingValidOnes) +{ + std::atomic gate = false; + std::atomic ran = false; + ThreadPool pool; + + constexpr TaskId neverIssued = 999999; + const auto valid = pool.QueueTask( + "Gated", + [&gate]() -> void + { + WaitForGate(gate); + }, + nullptr + ); + + TaskId id = 1; + EXPECT_NO_THROW( + id = pool.QueueTaskWithDependencies( + "Dependent", + [&ran]() -> void + { + ran.store(true); + }, + nullptr, { valid, neverIssued } + ) + ); + + EXPECT_EQ(0u, id); + + // The rejected task must not have registered against the valid dependency: releasing + // that dependency must not fault when the worker walks its dependents. + gate.store(true, std::memory_order_release); + + EXPECT_TRUE(WaitUntil( + [&pool]() -> bool + { + return pool.GetPendingTasksCount() == 0; + } + )); + EXPECT_FALSE(ran.load()); +} + +TEST(Core, ThreadPool_QueueTaskWithDependencies_ChainOfThousandTasksCompletesInOrder) +{ + constexpr uint32_t chainLength = 1000; + + std::atomic nextExpected = 0; + std::atomic outOfOrder = 0; + std::atomic ran = 0; + ThreadPool pool; + + const auto step = [&nextExpected, &outOfOrder, &ran](const uint32_t index) -> TaskFn + { + return [&nextExpected, &outOfOrder, &ran, index]() -> void + { + if (nextExpected.fetch_add(1) != index) + outOfOrder++; + ran++; + }; + }; + + auto previous = pool.QueueTask("Chain", step(0), nullptr); + for (uint32_t i = 1; i < chainLength; i++) + previous = pool.QueueTaskWithDependencies("Chain", step(i), nullptr, { previous }); + + ASSERT_TRUE(WaitUntil( + [&ran]() -> bool + { + return ran.load() == chainLength; + } + )); + EXPECT_EQ(0u, outOfOrder.load()); +} + +// CancelTask must flip a pending task to Cancelled so its body never runs and its +// completion fires with Cancelled status. Filling all workers with gated tasks +// guarantees the target is still queued when CancelTask runs. +TEST(Core, ThreadPool_CancelTask_CancelsPendingTask) +{ + const auto workerCount = GetThreadPoolWorkerCount(); + const auto taskCount = workerCount + 1; + + std::atomic gate = false; + std::atomic running = 0; + std::atomic targetBodyRan = false; + std::atomic targetStatus = TaskStatus::Pending; + std::atomic targetCompletionFired = false; + std::atomic otherCompleted = 0; + ThreadPool pool; + + std::vector ids; + ids.reserve(taskCount); + + for (uint32_t i = 0; i < taskCount; i++) + { + ids.emplace_back(pool.QueueTask( + "Gated", + [&gate, &running, &targetBodyRan, i, taskCount]() -> void + { + running.fetch_add(1, std::memory_order_release); + if (i == taskCount - 1) + targetBodyRan.store(true); + WaitForGate(gate); + }, + [&targetCompletionFired, &targetStatus, &otherCompleted, i, taskCount](const TaskStatus status) -> void + { + if (i == taskCount - 1) + { + targetStatus.store(status); + targetCompletionFired.store(true); + } + else if (status == TaskStatus::Completed) + { + otherCompleted.fetch_add(1, std::memory_order_relaxed); + } + } + )); + } + + ASSERT_TRUE(WaitUntil( + [&running, &workerCount]() -> bool + { + return running.load(std::memory_order_acquire) >= workerCount; + } + )); + + EXPECT_TRUE(pool.CancelTask(ids.back())); + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(FlushUntil( + pool, + [&targetCompletionFired, &otherCompleted, &workerCount]() -> bool + { + return targetCompletionFired.load() && otherCompleted.load() == workerCount; + } + )); + + EXPECT_EQ(TaskStatus::Cancelled, targetStatus.load()); + EXPECT_FALSE(targetBodyRan.load()); + EXPECT_EQ(workerCount, otherCompleted.load()); +} + +// A task that has already been claimed by a worker cannot be cancelled. +TEST(Core, ThreadPool_CancelTask_IgnoresRunningTask) +{ + std::atomic started = false; + std::atomic gate = false; + std::atomic invoked = false; + std::atomic reported = TaskStatus::Pending; + ThreadPool pool; + + const auto id = pool.QueueTask( + "Gated", + [&started, &gate]() -> void + { + started.store(true, std::memory_order_release); + WaitForGate(gate); + }, + [&invoked, &reported](const TaskStatus status) -> void + { + reported.store(status); + invoked.store(true); + } + ); + + ASSERT_TRUE(WaitUntil( + [&started]() -> bool + { + return started.load(std::memory_order_acquire); + } + )); + + EXPECT_FALSE(pool.CancelTask(id)); + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(FlushUntil( + pool, + [&invoked]() -> bool + { + return invoked.load(); + } + )); + EXPECT_EQ(TaskStatus::Completed, reported.load()); +} + +// CancelTask on a task that already completed is a no-op. +TEST(Core, ThreadPool_CancelTask_ReturnsFalseForCompletedTask) +{ + std::atomic invoked = false; + ThreadPool pool; + + const auto id = pool.QueueTask( + "Done", + []() -> void + { + }, + [&invoked](TaskStatus) -> void + { + invoked.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&invoked]() -> bool + { + return invoked.load(); + } + )); + + EXPECT_FALSE(pool.CancelTask(id)); +} + +// CancelTask on an unknown ID must not crash or hang. +TEST(Core, ThreadPool_CancelTask_ReturnsFalseForUnknownId) +{ + ThreadPool pool; + + EXPECT_FALSE(pool.CancelTask(999999)); +} + +// --------------------------------------------------------------------------- +// CancelTask: snapshot integration +// --------------------------------------------------------------------------- + +// Cancelling a pending task must update the group snapshot: Pending decrements and +// Cancelled increments. The snapshot is the editor's progress UI source of truth. +TEST(Core, ThreadPool_CancelTask_UpdatesGroupSnapshot) +{ + const auto workerCount = GetThreadPoolWorkerCount(); + const auto taskCount = workerCount + 2; + + std::atomic gate = false; + std::atomic running = 0; + std::atomic completions = 0; + ThreadPool pool; + + std::vector ids; + ids.reserve(taskCount); + + for (uint32_t i = 0; i < taskCount; i++) + { + ids.emplace_back(pool.QueueTask( + "SnapshotProbe", + [&gate, &running]() -> void + { + running.fetch_add(1, std::memory_order_release); + WaitForGate(gate); + }, + [&completions](TaskStatus) -> void + { + completions.fetch_add(1, std::memory_order_relaxed); + } + )); + } + + ASSERT_TRUE(WaitUntil( + [&running, &workerCount]() -> bool + { + return running.load(std::memory_order_acquire) >= workerCount; + } + )); + + const auto before = pool.GetTaskGroupSnapshots().at("SnapshotProbe"); + EXPECT_EQ(taskCount, before.Total.load(std::memory_order_relaxed)); + EXPECT_EQ(workerCount, before.Running.load(std::memory_order_relaxed)); + EXPECT_EQ(taskCount - workerCount, before.Pending.load(std::memory_order_relaxed)); + + EXPECT_TRUE(pool.CancelTask(ids[workerCount])); + EXPECT_TRUE(pool.CancelTask(ids[workerCount + 1])); + + const auto afterCancel = pool.GetTaskGroupSnapshots().at("SnapshotProbe"); + EXPECT_EQ(2u, afterCancel.Cancelled.load(std::memory_order_relaxed)); + EXPECT_EQ(0u, afterCancel.Pending.load(std::memory_order_relaxed)); + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(FlushUntil( + pool, + [&completions, &taskCount]() -> bool + { + return completions.load(std::memory_order_relaxed) == taskCount; + } + )); + + const auto afterFlush = pool.GetTaskGroupSnapshots().at("SnapshotProbe"); + EXPECT_EQ(workerCount, afterFlush.Completed.load(std::memory_order_relaxed)); + EXPECT_EQ(2u, afterFlush.Cancelled.load(std::memory_order_relaxed)); + EXPECT_EQ(taskCount, afterFlush.Total.load(std::memory_order_relaxed)); + EXPECT_TRUE(afterFlush.IsFinished()); +} + +// Cancelling a task must decrement GetPendingTasksCount so the StatusBar busy +// signal clears when the last task is cancelled, not when a worker picks it up. +TEST(Core, ThreadPool_CancelTask_DecrementsPendingCount) +{ + const auto workerCount = GetThreadPoolWorkerCount(); + const auto taskCount = workerCount + 1; + + std::atomic gate = false; + std::atomic running = 0; + ThreadPool pool; + + std::vector ids; + ids.reserve(taskCount); + + for (uint32_t i = 0; i < taskCount; i++) + { + ids.emplace_back(pool.QueueTask( + "CountProbe", + [&gate, &running]() -> void + { + running.fetch_add(1, std::memory_order_release); + WaitForGate(gate); + }, + nullptr + )); + } + + ASSERT_TRUE(WaitUntil( + [&running, &workerCount]() -> bool + { + return running.load(std::memory_order_acquire) >= workerCount; + } + )); + + const auto countBefore = pool.GetPendingTasksCount(); + EXPECT_EQ(taskCount, countBefore); + + EXPECT_TRUE(pool.CancelTask(ids.back())); + + EXPECT_EQ(countBefore - 1, pool.GetPendingTasksCount()); + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(WaitUntil( + [&pool]() -> bool + { + return pool.GetPendingTasksCount() == 0; + } + )); +} + +// --------------------------------------------------------------------------- +// CancelTask: idempotency and terminal states +// --------------------------------------------------------------------------- + +// Cancelling an already-cancelled task is a no-op, not a double-cancel. +TEST(Core, ThreadPool_CancelTask_AlreadyCancelledReturnsFalse) +{ + const auto workerCount = GetThreadPoolWorkerCount(); + const auto taskCount = workerCount + 1; + + std::atomic gate = false; + std::atomic running = 0; + std::atomic cancelledCompletions = 0; + ThreadPool pool; + + std::vector ids; + ids.reserve(taskCount); + + for (uint32_t i = 0; i < taskCount; i++) + { + ids.emplace_back(pool.QueueTask( + "IdempotencyProbe", + [&gate, &running]() -> void + { + running.fetch_add(1, std::memory_order_release); + WaitForGate(gate); + }, + [&cancelledCompletions](const TaskStatus status) -> void + { + if (status == TaskStatus::Cancelled) + cancelledCompletions.fetch_add(1, std::memory_order_relaxed); + } + )); + } + + ASSERT_TRUE(WaitUntil( + [&running, &workerCount]() -> bool + { + return running.load(std::memory_order_acquire) >= workerCount; + } + )); + + EXPECT_TRUE(pool.CancelTask(ids.back())); + EXPECT_FALSE(pool.CancelTask(ids.back())); + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(FlushUntil( + pool, + [&cancelledCompletions]() -> bool + { + return cancelledCompletions.load() == 1; + } + )); + + EXPECT_EQ(1u, cancelledCompletions.load()); +} + +// Cancelling a failed task returns false — the task already ran and threw. +TEST(Core, ThreadPool_CancelTask_FailedTaskReturnsFalse) +{ + std::atomic invoked = false; + ThreadPool pool; + + const auto id = pool.QueueTask( + "Throwing", + []() -> void + { + throw std::runtime_error("expected"); + }, + [&invoked](TaskStatus) -> void + { + invoked.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&invoked]() -> bool + { + return invoked.load(); + } + )); + + EXPECT_FALSE(pool.CancelTask(id)); +} + +// --------------------------------------------------------------------------- +// CancelTask: dependency chain interaction +// --------------------------------------------------------------------------- + +// Cancelling a dependency must still resolve its dependents so they don't hang. +// The dependent should run (or be cancellable separately) — it must not deadlock. +TEST(Core, ThreadPool_CancelTask_DependentStillResolvesAfterDependencyCancelled) +{ + const auto workerCount = GetThreadPoolWorkerCount(); + const auto fillerCount = workerCount; + + std::atomic gate = false; + std::atomic running = 0; + std::atomic dependentRan = false; + std::atomic dependencyCompletionFired = false; + std::atomic dependencyStatus = TaskStatus::Pending; + ThreadPool pool; + + // Fill all workers so the dependency stays pending. + for (uint32_t i = 0; i < fillerCount; i++) + { + pool.QueueTask( + "Filler", + [&gate, &running]() -> void + { + running.fetch_add(1, std::memory_order_release); + WaitForGate(gate); + }, + nullptr + ); + } + + ASSERT_TRUE(WaitUntil( + [&running, &workerCount]() -> bool + { + return running.load(std::memory_order_acquire) >= workerCount; + } + )); + + // Queue a dependency that will be cancelled while pending. + const auto depId = pool.QueueTask( + "Dependency", + []() -> void + { + }, + [&dependencyCompletionFired, &dependencyStatus](const TaskStatus status) -> void + { + dependencyStatus.store(status); + dependencyCompletionFired.store(true); + } + ); + + // Queue a dependent on it. + pool.QueueTaskWithDependencies( + "Dependent", + [&dependentRan]() -> void + { + dependentRan.store(true); + }, + nullptr, { depId } + ); + + // Cancel the dependency while it's still pending. + EXPECT_TRUE(pool.CancelTask(depId)); + + // The cancelled dependency's completion fires, and the dependent is released. + ASSERT_TRUE(FlushUntil( + pool, + [&dependencyCompletionFired]() -> bool + { + return dependencyCompletionFired.load(); + } + )); + EXPECT_EQ(TaskStatus::Cancelled, dependencyStatus.load()); + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(WaitUntil( + [&dependentRan]() -> bool + { + return dependentRan.load(); + } + )); + EXPECT_TRUE(dependentRan.load()); +} + +TEST(Core, ThreadPool_TaskResult_IsEmptyUntilWorkerProducesPayload) +{ + const TaskResult result; + + EXPECT_FALSE(result.has_value()); +} + +TEST(Core, ThreadPool_TaskResult_HandsWorkerPayloadToCompletion) +{ + struct Payload + { + int32_t Value = 0; + std::string Text; + }; + + const auto result = Eppo::CreateRef>(); + std::atomic completionFired = false; + TaskStatus reported = TaskStatus::Pending; + Payload captured; + ThreadPool pool; + + pool.QueueTask( + "StructProbe", + [result]() -> void + { + result->emplace(Payload{ .Value = 42, .Text = "worker payload" }); + }, + [&completionFired, &reported, &captured, result](const TaskStatus status) -> void + { + reported = status; + if (result->has_value()) + captured = result->value(); + completionFired.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + EXPECT_EQ(TaskStatus::Completed, reported); + EXPECT_EQ(42, captured.Value); + EXPECT_EQ("worker payload", captured.Text); +} + +TEST(Core, ThreadPool_TaskResult_DoesNotEncodeTaskOutcome) +{ + const auto result = Eppo::CreateRef>(); + std::atomic completionFired = false; + TaskStatus reported = TaskStatus::Pending; + ThreadPool pool; + + pool.QueueTask( + "FailureProbe", + [result]() -> void + { + result->emplace("diagnostic payload"); + throw std::runtime_error("expected"); + }, + [&completionFired, &reported](const TaskStatus status) -> void + { + reported = status; + completionFired.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + ASSERT_TRUE(result->has_value()); + EXPECT_EQ("diagnostic payload", result->value()); + EXPECT_EQ(TaskStatus::Failed, reported); +} + +TEST(Core, ThreadPool_GetTaskGroupSnapshots_RemainsCoherentDuringConcurrentTransitions) +{ + constexpr uint32_t taskCount = 512; + + std::atomic gate = false; + std::atomic completions = 0; + ThreadPool pool; + + for (uint32_t i = 0; i < taskCount; i++) + { + pool.QueueTask( + "CoherentSnapshot", + [&gate]() -> void + { + WaitForGate(gate); + }, + [&completions](TaskStatus) -> void + { + completions.fetch_add(1, std::memory_order_relaxed); + } + ); + } + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(FlushUntil( + pool, + [&pool, &completions]() -> bool + { + const auto snapshot = pool.GetTaskGroupSnapshots().at("CoherentSnapshot"); + const auto accounted = snapshot.Pending.load(std::memory_order_relaxed) + snapshot.Running.load(std::memory_order_relaxed) + + snapshot.Completed.load(std::memory_order_relaxed) + snapshot.Failed.load(std::memory_order_relaxed) + + snapshot.Cancelled.load(std::memory_order_relaxed); + EXPECT_EQ(snapshot.Total.load(std::memory_order_relaxed), accounted); + return completions.load(std::memory_order_relaxed) == taskCount; + } + )); +} + +TEST(Core, ThreadPool_QueueTaskWithDependencies_PublishesAllDependencyWrites) +{ + constexpr uint32_t dependencyCount = 64; + + std::array values{}; + std::atomic gate = false; + std::atomic completionFired = false; + bool observedAllWrites = false; + ThreadPool pool; + + std::vector dependencies; + dependencies.reserve(dependencyCount); + for (uint32_t i = 0; i < dependencyCount; i++) + { + dependencies.emplace_back(pool.QueueTask( + [&gate, &values, i]() -> void + { + WaitForGate(gate); + values[i] = i + 1; + }, + nullptr + )); + } + + pool.QueueTaskWithDependencies( + [&values, &observedAllWrites]() -> void + { + observedAllWrites = true; + for (uint32_t i = 0; i < values.size(); i++) + observedAllWrites &= values[i] == i + 1; + }, + [&completionFired](TaskStatus) -> void + { + completionFired.store(true); + }, + dependencies + ); + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + EXPECT_TRUE(observedAllWrites); +} diff --git a/EppoEngineTesting/Source/Renderer/Image.cpp b/EppoEngineTesting/Source/Renderer/Image.cpp index fad96931..c12f703d 100644 --- a/EppoEngineTesting/Source/Renderer/Image.cpp +++ b/EppoEngineTesting/Source/Renderer/Image.cpp @@ -119,6 +119,21 @@ TEST(Renderer, Image_CubemapRenderTargetCreatesSixSlicesAndRequestedMips) EXPECT_EQ(128u, desc.height); } +TEST(Renderer, Image_FallbackImageUploadsAndRegistersBindlessIndex) +{ + if (!Testing::AppHarness::IsAvailable()) + return; + + const Ref image = Image::GenerateFallbackImage(); + + EP_REQUIRE(image != nullptr); + EP_REQUIRE(image->GetTexture() != nullptr); + EXPECT_EQ(16u, image->GetWidth()); + EXPECT_EQ(16u, image->GetHeight()); + EXPECT_TRUE(image->GetFormat() == nvrhi::Format::SRGBA8_UNORM); + EXPECT_NE(std::numeric_limits::max(), image->GetBindlessIndex()); +} + TEST(Renderer, DescriptorManager_CubemapRegistersAsResource) { if (!Testing::AppHarness::IsAvailable()) diff --git a/EppoEngineTesting/Source/Renderer/RenderCommandBuffer.cpp b/EppoEngineTesting/Source/Renderer/RenderCommandBuffer.cpp index 3291b6be..7121a653 100644 --- a/EppoEngineTesting/Source/Renderer/RenderCommandBuffer.cpp +++ b/EppoEngineTesting/Source/Renderer/RenderCommandBuffer.cpp @@ -6,18 +6,62 @@ using namespace Eppo; -TEST(Renderer, RenderCommandBuffer_AllocatesTimingDataForEveryBackBuffer) +struct RenderCommandBufferTimingState +{ + std::vector FrameIndices; + bool ReusedFrameSlot = false; + float TimeAfterReuse = 0.0f; + float NamedTimeAfterReuse = 0.0f; +}; + +class RenderCommandBufferTimingLayer : public Layer +{ +public: + explicit RenderCommandBufferTimingLayer(Ref state) + : m_State(std::move(state)) + {} + + auto OnUpdate(float) -> void override + { + const uint32_t frameIndex = DeviceManager::Get()->GetCurrentFrameIndex(); + const bool frameSlotWasUsed = std::ranges::find(m_State->FrameIndices, frameIndex) != m_State->FrameIndices.end(); + + m_RenderCommandBuffer.Begin("Timing test"); + + if (frameSlotWasUsed) + { + m_State->ReusedFrameSlot = true; + m_State->TimeAfterReuse = m_RenderCommandBuffer.GetTime(frameIndex); + m_State->NamedTimeAfterReuse = m_RenderCommandBuffer.GetTime("Pass", frameIndex); + } + + m_RenderCommandBuffer.BeginTimerQuery("Pass"); + m_RenderCommandBuffer.EndTimerQuery("Pass"); + m_RenderCommandBuffer.End(); + m_RenderCommandBuffer.Submit(); + + m_State->FrameIndices.emplace_back(frameIndex); + } + +private: + Ref m_State; + RenderCommandBuffer m_RenderCommandBuffer; +}; + +TEST(Renderer, RenderCommandBuffer_AllocatesTimingDataForEveryFrameInFlight) { if (!Testing::AppHarness::IsAvailable()) return; const RenderCommandBuffer renderCommandBuffer; - const uint32_t frameIndex = DeviceManager::Get()->GetBackBufferCount() - 1; + const uint32_t maxFramesInFlight = DeviceManager::Get()->GetMaxFramesInFlight(); + EP_REQUIRE(maxFramesInFlight > 0); + const uint32_t frameIndex = maxFramesInFlight - 1; EXPECT_EQ(0.0f, renderCommandBuffer.GetTime(frameIndex)); EXPECT_EQ(0.0f, renderCommandBuffer.GetTimeMs(frameIndex)); - EXPECT_EQ(0.0f, renderCommandBuffer.GetTime(frameIndex + 1)); - EXPECT_EQ(0.0f, renderCommandBuffer.GetTime("Unrecorded", frameIndex + 1)); + EXPECT_EQ(0.0f, renderCommandBuffer.GetTime(maxFramesInFlight)); + EXPECT_EQ(0.0f, renderCommandBuffer.GetTime("Unrecorded", maxFramesInFlight)); } TEST(Renderer, RenderCommandBuffer_BeginEndSubmitCompletes) @@ -26,7 +70,7 @@ TEST(Renderer, RenderCommandBuffer_BeginEndSubmitCompletes) return; RenderCommandBuffer renderCommandBuffer; - const uint32_t frameIndex = DeviceManager::Get()->GetCurrentBackBufferIndex(); + const uint32_t frameIndex = DeviceManager::Get()->GetCurrentFrameIndex(); renderCommandBuffer.Begin("Test"); EXPECT_TRUE(renderCommandBuffer.GetCommandList()); @@ -42,7 +86,7 @@ TEST(Renderer, RenderCommandBuffer_NamedTimerIsReadableAfterSubmit) return; RenderCommandBuffer renderCommandBuffer; - const uint32_t frameIndex = DeviceManager::Get()->GetCurrentBackBufferIndex(); + const uint32_t frameIndex = DeviceManager::Get()->GetCurrentFrameIndex(); renderCommandBuffer.Begin(); renderCommandBuffer.BeginTimerQuery("Pass"); @@ -52,3 +96,25 @@ TEST(Renderer, RenderCommandBuffer_NamedTimerIsReadableAfterSubmit) EXPECT_TRUE(renderCommandBuffer.GetTime("Pass", frameIndex) >= 0.0f); } + +TEST(Renderer, RenderCommandBuffer_TimingIsReadableAfterFrameSlotReuse) +{ + Testing::AppHarness::Shutdown(); + ApplicationParams params{ + .Args = CommandLineArgs(0, nullptr), + .EnableImGui = false, + }; + Application* app = Testing::AppHarness::Get(std::move(params)); + EP_REQUIRE(app != nullptr); + + const uint32_t maxFramesInFlight = app->GetDeviceManager()->GetMaxFramesInFlight(); + EP_REQUIRE(maxFramesInFlight > 0); + + const auto state = CreateRef(); + app->PushLayer(state); + Testing::AppHarness::AdvanceFrames(maxFramesInFlight + 1); + + EXPECT_TRUE(state->ReusedFrameSlot); + EXPECT_TRUE(state->TimeAfterReuse >= 0.0f); + EXPECT_TRUE(state->NamedTimeAfterReuse >= 0.0f); +} diff --git a/EppoEngineTesting/Source/Renderer/RenderCommandQueue.cpp b/EppoEngineTesting/Source/Renderer/RenderCommandQueue.cpp new file mode 100644 index 00000000..af982844 --- /dev/null +++ b/EppoEngineTesting/Source/Renderer/RenderCommandQueue.cpp @@ -0,0 +1,87 @@ +#include "TestSupport/EppoTest.h" +#include "TestSupport/AppHarness.h" + +#include "Renderer/RenderCommandQueue.h" +#include "Renderer/Renderer.h" + +using namespace Eppo; + +TEST(Core, RenderCommandQueue_Execute_RunsCommandsInSubmissionOrder) +{ + RenderCommandQueue queue; + std::vector executionOrder; + + queue.AddCommand([&executionOrder]() -> void { executionOrder.emplace_back(1); }); + queue.AddCommand([&executionOrder]() -> void { executionOrder.emplace_back(2); }); + queue.AddCommand([&executionOrder]() -> void { executionOrder.emplace_back(3); }); + + queue.Execute(); + + const std::vector expected{ 1, 2, 3 }; + EXPECT_EQ(expected, executionOrder); +} + +TEST(Core, RenderCommandQueue_Execute_ExecutesEachCommandOnce) +{ + RenderCommandQueue queue; + uint32_t executionCount = 0; + + queue.AddCommand([&executionCount]() -> void { executionCount++; }); + + queue.Execute(); + queue.Execute(); + + EXPECT_EQ(1u, executionCount); +} + +TEST(Core, RenderCommandQueue_Clear_DiscardsPendingCommands) +{ + RenderCommandQueue queue; + bool commandExecuted = false; + + queue.AddCommand([&commandExecuted]() -> void { commandExecuted = true; }); + queue.Clear(); + queue.Execute(); + + EXPECT_FALSE(commandExecuted); +} + +TEST(Core, RenderCommandQueue_CommandSubmittedDuringExecution_WaitsForNextBatch) +{ + RenderCommandQueue queue; + std::vector executionOrder; + + queue.AddCommand( + [&queue, &executionOrder]() -> void + { + executionOrder.emplace_back(1); + queue.AddCommand([&executionOrder]() -> void { executionOrder.emplace_back(2); }); + } + ); + + queue.Execute(); + EXPECT_EQ(std::vector{ 1 }, executionOrder); + + queue.Execute(); + EXPECT_EQ((std::vector{ 1, 2 }), executionOrder); +} + +TEST(App, Renderer_Submit_QueuesUntilRenderCommandsAreExecuted) +{ + Testing::AppHarness::Shutdown(); + ApplicationParams params{ + .Args = CommandLineArgs(0, nullptr), + .EnableImGui = false, + }; + Application* app = Testing::AppHarness::Get(std::move(params)); + EP_REQUIRE(app != nullptr); + + bool commandExecuted = false; + Renderer::Submit([&commandExecuted]() -> void { commandExecuted = true; }); + + EXPECT_FALSE(commandExecuted); + + Renderer::ExecuteRenderCommands(); + + EXPECT_TRUE(commandExecuted); +} diff --git a/EppoEngineTesting/Source/Renderer/SceneRendering.cpp b/EppoEngineTesting/Source/Renderer/SceneRendering.cpp index 1b9202d5..1971ffa4 100644 --- a/EppoEngineTesting/Source/Renderer/SceneRendering.cpp +++ b/EppoEngineTesting/Source/Renderer/SceneRendering.cpp @@ -159,17 +159,15 @@ namespace EP_REQUIRE(rowPitch >= packedRowSize); for (uint32_t y = 0; y < readback.Height; y++) std::memcpy( - readback.Pixels.data() + static_cast(y) * packedRowSize, mapped + static_cast(y) * rowPitch, - packedRowSize + readback.Pixels.data() + static_cast(y) * packedRowSize, mapped + static_cast(y) * rowPitch, packedRowSize ); device->unmapStagingTexture(stagingTexture); return readback; } - [[nodiscard]] auto ProjectToPixel( - const EditorCamera& camera, const glm::vec3& worldPosition, const uint32_t width, const uint32_t height - ) -> glm::ivec2 + [[nodiscard]] auto + ProjectToPixel(const EditorCamera& camera, const glm::vec3& worldPosition, const uint32_t width, const uint32_t height) -> glm::ivec2 { const glm::vec4 clip = camera.GetViewProjection() * glm::vec4(worldPosition, 1.0f); EP_REQUIRE(clip.w > 0.0f); @@ -245,9 +243,7 @@ namespace }; } - [[nodiscard]] auto AverageNeighbourLuminanceDelta( - const Rgba8Readback& readback, const glm::ivec2 center, const int32_t radius - ) -> float + [[nodiscard]] auto AverageNeighbourLuminanceDelta(const Rgba8Readback& readback, const glm::ivec2 center, const int32_t radius) -> float { float delta = 0.0f; uint32_t sampleCount = 0; @@ -315,11 +311,23 @@ TEST(Renderer, SceneRenderer_BloomIntensityRaisesNeighbourLuminance) EditorCamera camera(glm::vec3(0.0f, 0.0f, 4.0f), 0.0f, -90.0f); camera.SetViewportSize(width, height); - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback disabled = ReadRgba8(sceneRenderer->GetFinalImage()); bloom.Intensity = 0.55f; - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback enabled = ReadRgba8(sceneRenderer->GetFinalImage()); const glm::ivec2 neighbour = ProjectToPixel(camera, glm::vec3(0.5f, 0.0f, 0.0f), width, height); @@ -349,11 +357,23 @@ TEST(Renderer, SceneRenderer_BloomRadiusWidensHalo) EditorCamera camera(glm::vec3(0.0f, 0.0f, 4.0f), 0.0f, -90.0f); camera.SetViewportSize(width, height); - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback narrow = ReadRgba8(sceneRenderer->GetFinalImage()); bloom.Radius = 4.0f; - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback wide = ReadRgba8(sceneRenderer->GetFinalImage()); const glm::ivec2 farNeighbour = ProjectToPixel(camera, glm::vec3(1.1f, 0.0f, 0.0f), width, height); @@ -380,7 +400,13 @@ TEST(Renderer, SceneRenderer_PointLightAndGradientSky_RendersWithoutError) const EditorCamera camera(glm::vec3(0.0f, 2.0f, 6.0f), 0.0f, 0.0f); // Render across several real frames to cycle the frames-in-flight indices. - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); EXPECT_TRUE(sceneRenderer->GetFinalImage() != nullptr); EXPECT_TRUE(Testing::AppHarness::Get()->IsRunning()); @@ -393,18 +419,29 @@ TEST(Renderer, SceneRenderer_HdrSceneTonemapsBeforeDepthAwareWireframes) return; const Ref scene = ctx.GetScene(); - const Ref sceneRenderer = - CreateRef(scene, SceneRendererSpecification{ .Width = 64u, .Height = 64u }); + const Ref sceneRenderer = CreateRef(scene, SceneRendererSpecification{ .Width = 64u, .Height = 64u }); Entity overlay = scene->CreateEntity("Depth-aware wireframe"); overlay.AddComponent(); sceneRenderer->SetDebugRenderingEnabled(true); sceneRenderer->SetShowColliders(true); const EditorCamera camera(glm::vec3(0.0f, 2.0f, 6.0f), 0.0f, 0.0f); - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); sceneRenderer->Resize(96u, 48u); - ctx.AdvanceFrames(1, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 1, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Ref& finalImage = sceneRenderer->GetFinalImage(); EP_REQUIRE(finalImage != nullptr); @@ -450,19 +487,30 @@ TEST(Renderer, SceneRenderer_DirectionalShadowDarkensReceiverAndSurvivesResize) EditorCamera camera(glm::vec3(0.0f, 5.0f, 16.0f), -32.0f, -90.0f); camera.SetViewportSize(initialWidth, initialHeight); - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback readback = ReadRgba8(sceneRenderer->GetFinalImage()); const float shadowed = AverageLuminance(readback, ProjectToPixel(camera, glm::vec3(0.3f, 0.001f, 0.15f), initialWidth, initialHeight)); - const float lit = - AverageLuminance(readback, ProjectToPixel(camera, glm::vec3(-1.5f, 0.001f, 0.0f), initialWidth, initialHeight)); + const float lit = AverageLuminance(readback, ProjectToPixel(camera, glm::vec3(-1.5f, 0.001f, 0.0f), initialWidth, initialHeight)); EXPECT_TRUE(lit > shadowed + 0.05f); constexpr uint32_t resizedWidth = 320u; constexpr uint32_t resizedHeight = 180u; sceneRenderer->Resize(resizedWidth, resizedHeight); camera.SetViewportSize(resizedWidth, resizedHeight); - ctx.AdvanceFrames(2, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 2, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Ref& finalImage = sceneRenderer->GetFinalImage(); EP_REQUIRE(finalImage != nullptr); @@ -489,15 +537,24 @@ TEST(Renderer, SceneRenderer_DebugDirectionalLightArrowFollowsTransformRotation) sun.GetComponent().Scale = glm::vec3(2.0f, 0.0f, -3.0f); constexpr uint32_t size = 256u; - const Ref sceneRenderer = CreateRef(scene, SceneRendererSpecification{ - .Width = size, - .Height = size, - .EnableDebugRendering = true, - }); + const Ref sceneRenderer = CreateRef( + scene, + SceneRendererSpecification{ + .Width = size, + .Height = size, + .EnableDebugRendering = true, + } + ); EditorCamera camera(glm::vec3(0.0f, 0.0f, 5.0f), 0.0f, -90.0f); camera.SetViewportSize(size, size); - ctx.AdvanceFrames(2, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 2, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback down = ReadRgba8(sceneRenderer->GetFinalImage()); const glm::ivec2 sunSample = ProjectToPixel(camera, glm::vec3(0.28f, 0.0f, 0.0f), size, size); const glm::ivec2 downSample = ProjectToPixel(camera, glm::vec3(0.0f, -0.75f, 0.0f), size, size); @@ -507,7 +564,13 @@ TEST(Renderer, SceneRenderer_DebugDirectionalLightArrowFollowsTransformRotation) EXPECT_TRUE(MaxLuminance(down, rightSample) < 0.1f); sun.GetComponent().Rotation.z = glm::half_pi(); - ctx.AdvanceFrames(2, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 2, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback right = ReadRgba8(sceneRenderer->GetFinalImage()); EXPECT_TRUE(MaxLuminance(right, rightSample) > 0.3f); EXPECT_TRUE(MaxLuminance(right, downSample) < 0.1f); @@ -548,14 +611,19 @@ TEST(Renderer, SceneRenderer_SkyboxEnvironment_BakesIblAndRendersCleanly) Entity sphere = scene->CreateEntity("Sphere"); sphere.AddComponent().MeshHandle = static_cast(MeshPrimitiveType::Sphere); - const Ref sceneRenderer = - CreateRef(scene, SceneRendererSpecification{ .Width = 128u, .Height = 128u }); + const Ref sceneRenderer = CreateRef(scene, SceneRendererSpecification{ .Width = 128u, .Height = 128u }); EditorCamera camera(glm::vec3(0.0f, 0.0f, 4.0f), 0.0f, -90.0f); camera.SetViewportSize(128u, 128u); // The bake fires on the first frame's SubmitEnvironment; rendering several frames sends the // baked cubes/LUT through both the geometry and skybox passes under the validation layer. - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Ref& finalImage = sceneRenderer->GetFinalImage(); EP_REQUIRE(finalImage != nullptr); @@ -594,26 +662,49 @@ TEST(Renderer, SceneRenderer_EquirectangularSkyMapsTopToPositiveY) scene->GetBloomSettings().Intensity = 0.0f; constexpr uint32_t size = 64u; - const Ref sceneRenderer = - CreateRef(scene, SceneRendererSpecification{ .Width = size, .Height = size }); + const Ref sceneRenderer = CreateRef(scene, SceneRendererSpecification{ .Width = size, .Height = size }); EditorCamera upward(glm::vec3(0.0f), 89.0f, 0.0f); upward.SetViewportSize(size, size); - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, upward); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, upward); + } + ); const glm::vec3 upColor = ReadPixel(ReadRgba8(sceneRenderer->GetFinalImage()), glm::ivec2(size / 2u)); EditorCamera downward(glm::vec3(0.0f), -89.0f, 0.0f); downward.SetViewportSize(size, size); - ctx.AdvanceFrames(2, [&](float) { scene->OnRenderEditor(sceneRenderer, downward); }); + ctx.AdvanceFrames( + 2, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, downward); + } + ); const glm::vec3 downColor = ReadPixel(ReadRgba8(sceneRenderer->GetFinalImage()), glm::ivec2(size / 2u)); EditorCamera upperSide(glm::vec3(0.0f), 30.0f, 0.0f); upperSide.SetViewportSize(size, size); - ctx.AdvanceFrames(2, [&](float) { scene->OnRenderEditor(sceneRenderer, upperSide); }); + ctx.AdvanceFrames( + 2, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, upperSide); + } + ); const glm::vec3 upperSideColor = ReadPixel(ReadRgba8(sceneRenderer->GetFinalImage()), glm::ivec2(size / 2u)); EditorCamera lowerSide(glm::vec3(0.0f), -30.0f, 0.0f); lowerSide.SetViewportSize(size, size); - ctx.AdvanceFrames(2, [&](float) { scene->OnRenderEditor(sceneRenderer, lowerSide); }); + ctx.AdvanceFrames( + 2, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, lowerSide); + } + ); const glm::vec3 lowerSideColor = ReadPixel(ReadRgba8(sceneRenderer->GetFinalImage()), glm::ivec2(size / 2u)); EXPECT_TRUE(upColor.r > upColor.b + 0.25f); @@ -655,11 +746,16 @@ TEST(Renderer, SceneRenderer_RoughIblSuppressesHighFrequencyFireflies) sphere.AddComponent().MeshHandle = static_cast(MeshPrimitiveType::Sphere); constexpr uint32_t size = 128u; - const Ref sceneRenderer = - CreateRef(scene, SceneRendererSpecification{ .Width = size, .Height = size }); + const Ref sceneRenderer = CreateRef(scene, SceneRendererSpecification{ .Width = size, .Height = size }); EditorCamera camera(glm::vec3(4.0f, 0.0f, 0.0f), 0.0f, 180.0f); camera.SetViewportSize(size, size); - ctx.AdvanceFrames(3, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 3, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback readback = ReadRgba8(sceneRenderer->GetFinalImage()); const float neighbourDelta = AverageNeighbourLuminanceDelta(readback, glm::ivec2(size / 2u), 20); @@ -667,7 +763,13 @@ TEST(Renderer, SceneRenderer_RoughIblSuppressesHighFrequencyFireflies) mesh->GetMaterial(0)->Metallic = 1.0f; mesh->GetMaterial(0)->Roughness = 0.5f; - ctx.AdvanceFrames(2, [&](float) { scene->OnRenderEditor(sceneRenderer, camera); }); + ctx.AdvanceFrames( + 2, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + } + ); const Rgba8Readback specularReadback = ReadRgba8(sceneRenderer->GetFinalImage()); const float specularDelta = AverageNeighbourLuminanceDelta(specularReadback, glm::ivec2(size / 2u), 20); @@ -709,23 +811,29 @@ TEST(Renderer, Renderer_CompositeToSwapchain_SurvivesImageCyclingAndResize) uint32_t renderedFrames = 0; app->GetImGuiLayer()->SetClearMainSwapchainTarget(false); - ctx.AdvanceFrames(imageCount + 2, [&](float) - { - scene->OnRenderEditor(sceneRenderer, camera); - app->GetDeviceManager()->GetRenderer()->CompositeToSwapchain(sceneRenderer->GetFinalImage()); - renderedFrames++; - }); + ctx.AdvanceFrames( + imageCount + 2, + [&](float) + { + scene->OnRenderEditor(sceneRenderer, camera); + app->GetDeviceManager()->GetRenderer()->CompositeToSwapchain(sceneRenderer->GetFinalImage()); + renderedFrames++; + } + ); glfwSetWindowSize(app->GetWindow()->GetNative(), 960, 540); - ctx.AdvanceFrames(imageCount + 2, [&](float) - { - const auto [width, height] = app->GetWindow()->GetFramebufferSize(); - if (width > 0 && height > 0) - sceneRenderer->Resize(width, height); - scene->OnRenderEditor(sceneRenderer, camera); - app->GetDeviceManager()->GetRenderer()->CompositeToSwapchain(sceneRenderer->GetFinalImage()); - renderedFrames++; - }); + ctx.AdvanceFrames( + imageCount + 2, + [&](float) + { + const auto [width, height] = app->GetWindow()->GetFramebufferSize(); + if (width > 0 && height > 0) + sceneRenderer->Resize(width, height); + scene->OnRenderEditor(sceneRenderer, camera); + app->GetDeviceManager()->GetRenderer()->CompositeToSwapchain(sceneRenderer->GetFinalImage()); + renderedFrames++; + } + ); EXPECT_EQ((imageCount + 2) * 2, renderedFrames); EXPECT_TRUE(app->IsRunning()); diff --git a/EppoEngineTesting/Source/Scripting/Scripting.cpp b/EppoEngineTesting/Source/Scripting/Scripting.cpp index f2b538e6..0e4059ac 100644 --- a/EppoEngineTesting/Source/Scripting/Scripting.cpp +++ b/EppoEngineTesting/Source/Scripting/Scripting.cpp @@ -1,6 +1,7 @@ #include "TestSupport/EppoTest.h" #include "TestSupport/GlmCheck.h" #include "TestSupport/TempDir.h" +#include "TestSupport/AppHarness.h" #include "Asset/Asset.h" #include "Asset/AssetManager.h" #include "Physics/PhysicsWorld.h" @@ -12,6 +13,7 @@ #include "Scripting/ScriptEngine.h" #include +#include #include using namespace Eppo; @@ -117,6 +119,22 @@ namespace ); return found; } + + auto ReloadProjectAssemblyAndWait() -> bool + { + if (!ScriptEngine::Get().ReloadProjectAssembly()) + return false; + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (ScriptEngine::IsUserAssemblyCompiling() && std::chrono::steady_clock::now() < deadline) + { + Testing::AppHarness::AdvanceFrames(1); + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + } + + Testing::AppHarness::AdvanceFrames(1); + return !ScriptEngine::IsUserAssemblyCompiling(); + } } // --- Class metadata: the runtime comes up and reflects the user class. --- @@ -613,9 +631,11 @@ TEST(Scripting, ScriptEngine_ReloadProjectAssembly_WithoutAnActiveProject_Fails) // Editor-managed projects live under the root directory, so a script build that // writes above them compiles an empty assembly: the .NET SDK excludes everything // under OutputPath from the default compile glob, and reports success anyway. -TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ForProjectUnderTheProjectsDirectory_DiscoversScriptClasses) +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_ForProjectUnderTheProjectsDirectory_DiscoversScriptClasses) { EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; @@ -664,7 +684,7 @@ public class ProbeScript : Entity } ); - const bool reloaded = ScriptEngine::Get().ReloadProjectAssembly(); + const bool reloaded = ReloadProjectAssemblyAndWait(); const bool discovered = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript"); // The suite shares one runtime, so hand the harness assembly back before @@ -676,6 +696,392 @@ public class ProbeScript : Entity EXPECT_EQ(true, discovered); } +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_WithBrokenScript_LeavesPreviousAssemblyLoadedButInvalid) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; + std::filesystem::create_directories(scriptsDirectory / "Source"); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "ScriptProbe.csproj", R"( + +net10.0 +enable + + + + $(CoreManagedDll) + false + + + +)", + true + ) + ); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "Source" / "Broken.cs", R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class Broken { { +} +)", + true + ) + ); + + Project::New( + ProjectSpecification{ + .Name = "ScriptProbe", + .ProjectDirectory = projectDirectory.Path(), + } + ); + + const bool reloaded = ReloadProjectAssemblyAndWait(); + const bool valid = ScriptEngine::IsUserAssemblyValid(); + const bool previousClassValid = ScriptEngine::Get().IsValidScriptClass(kUserClass); + + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + + EXPECT_TRUE(reloaded); + EXPECT_FALSE(valid); + EXPECT_TRUE(previousClassValid); +} + +TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ProjectWithoutCsproj_Succeeds) +{ + EP_REQUIRE(EnsureRuntime()); + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; + std::filesystem::create_directories(scriptsDirectory); + + Project::New( + ProjectSpecification{ + .Name = "ScriptProbe", + .ProjectDirectory = projectDirectory.Path(), + } + ); + + const bool reloaded = ScriptEngine::Get().ReloadProjectAssembly(); + const bool valid = ScriptEngine::IsUserAssemblyValid(); + + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + + EXPECT_TRUE(reloaded); + EXPECT_TRUE(valid); +} + +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_ReplacesOldClassesWithNewOnes) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; + std::filesystem::create_directories(scriptsDirectory / "Source"); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "ScriptProbe.csproj", R"( + +net10.0 +enable + + + + $(CoreManagedDll) + false + + + +)", + true + ) + ); + + const auto scriptFile = scriptsDirectory / "Source" / "ProbeScriptA.cs"; + + EP_REQUIRE( + FS::WriteText( + scriptFile, R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class ProbeScriptA : Entity +{ +} +} +)", + true + ) + ); + + Project::New( + ProjectSpecification{ + .Name = "ScriptProbe", + .ProjectDirectory = projectDirectory.Path(), + } + ); + + EP_REQUIRE(ReloadProjectAssemblyAndWait()); + EXPECT_TRUE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScriptA")); + + EP_REQUIRE( + FS::WriteText( + scriptFile, R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class ProbeScriptB : Entity +{ +} +} +)", + true + ) + ); + + EP_REQUIRE(ReloadProjectAssemblyAndWait()); + + const bool oldGone = !ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScriptA"); + const bool newPresent = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScriptB"); + + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + + EXPECT_TRUE(oldGone); + EXPECT_TRUE(newPresent); +} + +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_ClearsEntityInstances) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; + std::filesystem::create_directories(scriptsDirectory / "Source"); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "ScriptProbe.csproj", R"( + +net10.0 +enable + + + + $(CoreManagedDll) + false + + + +)", + true + ) + ); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "Source" / "ProbeScript.cs", R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class ProbeScript : Entity +{ +} +} +)", + true + ) + ); + + Project::New( + ProjectSpecification{ + .Name = "ScriptProbe", + .ProjectDirectory = projectDirectory.Path(), + } + ); + + EP_REQUIRE(ReloadProjectAssemblyAndWait()); + + const Ref scene = CreateRef(); + Entity entity = scene->CreateEntity("Scripted"); + entity.AddComponent(std::string("EppoTesting.ProbeScript")); + ScriptEngine::Get().OnCreateEntity(entity); + + EXPECT_TRUE(ScriptEngine::Get().GetEntityInstance(entity.GetUUID()) != nullptr); + + EP_REQUIRE(ReloadProjectAssemblyAndWait()); + + const bool instanceCleared = ScriptEngine::Get().GetEntityInstance(entity.GetUUID()) == nullptr; + + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + + EXPECT_TRUE(instanceCleared); +} + +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_PreservesFieldStorage) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; + std::filesystem::create_directories(scriptsDirectory / "Source"); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "ScriptProbe.csproj", R"( + +net10.0 +enable + + + + $(CoreManagedDll) + false + + + +)", + true + ) + ); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "Source" / "ProbeScript.cs", R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class ProbeScript : Entity +{ + public int Value = 0; +} +} +)", + true + ) + ); + + Project::New( + ProjectSpecification{ + .Name = "ScriptProbe", + .ProjectDirectory = projectDirectory.Path(), + } + ); + + EP_REQUIRE(ReloadProjectAssemblyAndWait()); + + const Ref scene = CreateRef(); + Entity entity = scene->CreateEntity("Scripted"); + entity.AddComponent(std::string("EppoTesting.ProbeScript")); + + ScriptFieldValue stored; + stored.Type = ScriptFieldType::Int32; + stored.Set(42); + ScriptEngine::Get().GetFieldMap(entity.GetUUID())["Value"] = stored; + + EP_REQUIRE(ReloadProjectAssemblyAndWait()); + + const ScriptFieldMap* reloaded = ScriptEngine::Get().TryGetFieldMap(entity.GetUUID()); + bool valueSurvived = false; + if (reloaded != nullptr) + { + const auto it = reloaded->find("Value"); + if (it != reloaded->end()) + valueSurvived = it->second.Type == ScriptFieldType::Int32 && it->second.Get() == 42; + } + + ScriptEngine::Get().RemoveFieldMap(entity.GetUUID()); + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + + EXPECT_TRUE(reloaded != nullptr); + EXPECT_TRUE(valueSurvived); +} + +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_MultipleReloadsInSequence) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; + std::filesystem::create_directories(scriptsDirectory / "Source"); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "ScriptProbe.csproj", R"( + +net10.0 +enable + + + + $(CoreManagedDll) + false + + + +)", + true + ) + ); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "Source" / "ProbeScript.cs", R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class ProbeScript : Entity +{ +} +} +)", + true + ) + ); + + Project::New( + ProjectSpecification{ + .Name = "ScriptProbe", + .ProjectDirectory = projectDirectory.Path(), + } + ); + + const bool first = ReloadProjectAssemblyAndWait(); + const bool second = ReloadProjectAssemblyAndWait(); + const bool third = ReloadProjectAssemblyAndWait(); + const bool discovered = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript"); + + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + + EXPECT_TRUE(first); + EXPECT_TRUE(second); + EXPECT_TRUE(third); + EXPECT_TRUE(discovered); +} + // Covers the addition case only, and passes with or without the snapshot: entt's // storage is paged and views iterate in reverse, so appends fall outside the walk. // Component removal is the genuinely unsafe mutation and is not covered here. @@ -2148,3 +2554,314 @@ TEST(Scripting, Scene_DestroyEntity_SelfDuringUpdate_IsSafe) engine.OnDestroyEntity(survivor); } + +// --- Async hot reload via VerifyRuntime (needs a real Application + ThreadPool). --- + +namespace +{ + constexpr const char* kProbeScriptSource = R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class ProbeScript : Entity +{ +} +} +)"; + + constexpr const char* kReplacementScriptSource = R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class ReplacementScript : Entity +{ +} +} +)"; + + auto StageProbeProject(const Testing::TempDir& projectDirectory) -> std::filesystem::path + { + const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; + std::filesystem::create_directories(scriptsDirectory / "Source"); + + EP_REQUIRE( + FS::WriteText( + scriptsDirectory / "ScriptProbe.csproj", R"( + +net10.0 +enable + + + + $(CoreManagedDll) + false + + + +)", + true + ) + ); + + const auto scriptFile = scriptsDirectory / "Source" / "ProbeScript.cs"; + EP_REQUIRE(FS::WriteText(scriptFile, kProbeScriptSource, true)); + + Project::New( + ProjectSpecification{ + .Name = "ScriptProbe", + .ProjectDirectory = projectDirectory.Path(), + } + ); + + EP_REQUIRE(ReloadProjectAssemblyAndWait()); + EP_REQUIRE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); + + return scriptFile; + } + + auto TouchScript(const std::filesystem::path& scriptFile, const std::string& marker) -> bool + { + return FS::WriteText(scriptFile, std::string(kProbeScriptSource) + "// " + marker + "\n", true); + } + + auto WaitForBuildStart() -> bool + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (std::chrono::steady_clock::now() < deadline) + { + ScriptEngine::Get().VerifyRuntime(); + if (ScriptEngine::IsUserAssemblyCompiling()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + return false; + } + + auto WaitForDeferredReload() -> bool + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (std::chrono::steady_clock::now() < deadline) + { + ScriptEngine::Get().VerifyRuntime(); + if (ScriptEngine::IsUserAssemblyReloadPending()) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + return false; + } + + auto AdvanceUntilBuildFinishes() -> bool + { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (ScriptEngine::IsUserAssemblyCompiling() && std::chrono::steady_clock::now() < deadline) + { + Testing::AppHarness::AdvanceFrames(1); + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + } + + Testing::AppHarness::AdvanceFrames(1); + return !ScriptEngine::IsUserAssemblyCompiling(); + } + + auto RestoreHarnessAssembly() -> void + { + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + } +} + +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_DoesNotBlockMainThread) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + Application* app = Testing::AppHarness::Get(); + EP_REQUIRE(app != nullptr); + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptFile = StageProbeProject(projectDirectory); + + EXPECT_TRUE(ScriptEngine::IsUserAssemblyValid()); + EXPECT_FALSE(ScriptEngine::IsUserAssemblyCompiling()); + + EP_REQUIRE(TouchScript(scriptFile, "touched")); + const bool buildStarted = WaitForBuildStart(); + + const auto start = std::chrono::steady_clock::now(); + ScriptEngine::Get().VerifyRuntime(); + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start + ); + const bool compilingAfterReturn = ScriptEngine::IsUserAssemblyCompiling(); + const bool validWhileCompiling = ScriptEngine::IsUserAssemblyValid(); + const bool finished = AdvanceUntilBuildFinishes(); + const bool validAfterBuild = ScriptEngine::IsUserAssemblyValid(); + + RestoreHarnessAssembly(); + + EXPECT_TRUE(buildStarted); + EXPECT_TRUE(compilingAfterReturn); + EXPECT_FALSE(validWhileCompiling); + EXPECT_LT(elapsed.count(), 500); + EXPECT_TRUE(finished); + EXPECT_TRUE(validAfterBuild); +} + +// After the build completes, a normal application frame must publish the replacement +// assembly so its script classes become available. +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_SuccessfulBuildReplacesAssembly) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + Application* app = Testing::AppHarness::Get(); + EP_REQUIRE(app != nullptr); + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptFile = StageProbeProject(projectDirectory); + + EP_REQUIRE(FS::WriteText(scriptFile, kReplacementScriptSource, true)); + const bool buildStarted = WaitForBuildStart(); + ScriptEngine::Get().VerifyRuntime(); + const bool compilingObserved = ScriptEngine::IsUserAssemblyCompiling(); + const bool validWhileCompiling = ScriptEngine::IsUserAssemblyValid(); + const bool finished = AdvanceUntilBuildFinishes(); + const bool validAfterBuild = ScriptEngine::IsUserAssemblyValid(); + const bool oldClassValid = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript"); + const bool replacementClassValid = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ReplacementScript"); + + RestoreHarnessAssembly(); + + EXPECT_TRUE(buildStarted); + EXPECT_TRUE(compilingObserved); + EXPECT_FALSE(validWhileCompiling); + EXPECT_TRUE(finished); + EXPECT_TRUE(validAfterBuild); + EXPECT_FALSE(oldClassValid); + EXPECT_TRUE(replacementClassValid); +} + +// While a scene context is set (play mode), VerifyRuntime must not trigger a build. +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_DoesNotReloadDuringPlayMode) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + Application* app = Testing::AppHarness::Get(); + EP_REQUIRE(app != nullptr); + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptFile = StageProbeProject(projectDirectory); + + const Ref playScene = CreateRef(); + ScriptEngine::Get().SetSceneContext(playScene); + + EP_REQUIRE(FS::WriteText(scriptFile, kReplacementScriptSource, true)); + const bool deferredObserved = WaitForDeferredReload(); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + const bool compilingDuringPlay = ScriptEngine::IsUserAssemblyCompiling(); + const bool validDuringPlay = ScriptEngine::IsUserAssemblyValid(); + + ScriptEngine::Get().SetSceneContext(nullptr); + ScriptEngine::Get().VerifyRuntime(); + const bool compilingAfterStop = ScriptEngine::IsUserAssemblyCompiling(); + const bool finished = AdvanceUntilBuildFinishes(); + const bool replacementClassValid = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ReplacementScript"); + + RestoreHarnessAssembly(); + + EXPECT_TRUE(deferredObserved); + EXPECT_FALSE(compilingDuringPlay); + EXPECT_TRUE(validDuringPlay); + EXPECT_TRUE(compilingAfterStop); + EXPECT_TRUE(finished); + EXPECT_TRUE(replacementClassValid); +} + +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_BuildFailureLeavesPreviousAssemblyLoadedButInvalid) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + Application* app = Testing::AppHarness::Get(); + EP_REQUIRE(app != nullptr); + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptFile = StageProbeProject(projectDirectory); + + const std::string brokenSource = std::string(kProbeScriptSource) + "\n{{{{ garbage\n"; + EP_REQUIRE(FS::WriteText(scriptFile, brokenSource, true)); + const bool buildStarted = WaitForBuildStart(); + ScriptEngine::Get().VerifyRuntime(); + const bool compilingObserved = ScriptEngine::IsUserAssemblyCompiling(); + const bool validWhileCompiling = ScriptEngine::IsUserAssemblyValid(); + const bool finished = AdvanceUntilBuildFinishes(); + const bool validAfterFailure = ScriptEngine::IsUserAssemblyValid(); + const bool previousClassValid = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript"); + + const Ref scene = CreateRef(); + Entity entity = scene->CreateEntity("RetainedAssembly"); + entity.AddComponent(std::string("EppoTesting.ProbeScript")); + ScriptEngine::Get().OnCreateEntity(entity); + const bool previousClassUsable = ScriptEngine::Get().GetEntityInstance(entity.GetUUID()) != nullptr; + if (previousClassUsable) + ScriptEngine::Get().OnDestroyEntity(entity); + + RestoreHarnessAssembly(); + + EXPECT_TRUE(buildStarted); + EXPECT_TRUE(compilingObserved); + EXPECT_FALSE(validWhileCompiling); + EXPECT_TRUE(finished); + EXPECT_FALSE(validAfterFailure); + EXPECT_TRUE(previousClassValid); + EXPECT_TRUE(previousClassUsable); +} + +// Five rapid saves must collapse into a single pending build task, not five. +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_BurstOfChangesCollapsesIntoOneBuild) +{ + EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; + + Application* app = Testing::AppHarness::Get(); + EP_REQUIRE(app != nullptr); + const auto threadPool = app->GetThreadPool(); + EP_REQUIRE(threadPool != nullptr); + + const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); + const auto scriptFile = StageProbeProject(projectDirectory); + const auto pendingBefore = threadPool->GetPendingTasksCount(); + + for (int i = 0; i < 5; ++i) + EP_REQUIRE(TouchScript(scriptFile, "burst-" + std::to_string(i))); + + const bool buildStarted = WaitForBuildStart(); + ScriptEngine::Get().VerifyRuntime(); + const bool compilingObserved = ScriptEngine::IsUserAssemblyCompiling(); + const auto pendingAfterStart = threadPool->GetPendingTasksCount(); + + for (int i = 0; i < 5; ++i) + ScriptEngine::Get().VerifyRuntime(); + + const auto pendingAfterRepeatedVerification = threadPool->GetPendingTasksCount(); + const bool finished = AdvanceUntilBuildFinishes(); + const bool validAfterBuild = ScriptEngine::IsUserAssemblyValid(); + + RestoreHarnessAssembly(); + + EXPECT_TRUE(buildStarted); + EXPECT_TRUE(compilingObserved); + EXPECT_EQ(pendingBefore + 1, pendingAfterStart); + EXPECT_EQ(pendingAfterStart, pendingAfterRepeatedVerification); + EXPECT_TRUE(finished); + EXPECT_TRUE(validAfterBuild); +} diff --git a/RENDER_THREAD_PREPARATION_PLAN.md b/RENDER_THREAD_PREPARATION_PLAN.md new file mode 100644 index 00000000..0461ee49 --- /dev/null +++ b/RENDER_THREAD_PREPARATION_PLAN.md @@ -0,0 +1,1092 @@ +# Render-thread preparation: implementation plan + +## Purpose + +This document describes the implementation that establishes a render-command boundary, corrects the distinction between frames in flight and swapchain images, and removes the current routine GPU synchronization stalls. The first completed phase still executes render commands on the main thread. Its purpose is to make render work enter through one explicit API and execute at one explicit point so that a later change can move the consumer to a dedicated render thread without redesigning every caller again. + +The implementation is intentionally limited to rendering. Mesh import, asset loading, shader compilation, and general resource streaming are not migrated to the render queue in this phase. + +## Agreed decisions + +- `Renderer::Submit(RenderCommand)` is the public static façade for deferred render work. +- `Renderer` owns the `RenderCommandQueue`; a second renderer singleton is not introduced. +- `Application::StepFrame` executes the queued render commands once per successfully acquired frame. +- Render commands execute on the main thread in this phase. +- `DeviceParams::MaxFramesInFlight` defaults to two and values below two are invalid. +- Vulkan does not request a hard-coded three-image swapchain. It supplies the surface minimum in `VkSwapchainCreateInfoKHR::minImageCount`, queries the actual images returned by Vulkan, asserts that at least two were returned, and uses that result. +- Frames in flight and swapchain backbuffers are independent concepts with independent indices. +- Native swapchain setup and synchronization stay platform-specific. The main application continues to see them through `DeviceManager`. +- Routine presentation proceeds without waiting for the entire Vulkan queue to become idle. +- Render submission returns without resolving GPU timer data; results are resolved after the corresponding frame context completes. +- Resize, swapchain recreation, and shutdown are allowed to wait for idle because they destroy or replace resources that may still be referenced by the GPU. +- This phase does not introduce the actual render thread, mutex-protected queues, scene render packets, or copied ImGui draw data. + +## Required end state + +The normal frame order must be: + +```text +Application::StepFrame + Window::ProcessEvents + ThreadPool::Flush + + DeviceManager::BeginFrame + wait only if the selected frame context is still in flight + acquire the current native swapchain image + register the native acquire semaphore as a graphics-queue wait + + Layer::OnUpdate + collect scene state + prepare CPU render data + Renderer::Submit(scene render command) + Renderer::Submit(runtime composite command, when applicable) + + ImGuiLayer::PrepareRender + Layer::OnUIRender + ImGuiLayer::Render + finalize ImGui CPU draw data + submit main viewport render command + submit secondary viewport acquire/render/present commands + + Renderer::ExecuteRenderCommands + execute exactly one detached FIFO batch on the main thread + + DeviceManager::Present + make the final graphics submission signal the presentation semaphore + mark the current frame context as in flight + queue native presentation + advance the frame-context index + + NVRHI garbage collection +``` + +The GPU may still be processing frame N when the CPU begins collecting and recording frame N+1. With the default two frame contexts, the CPU returns to frame context zero at frame N+2 and waits only if that context has not completed yet. This is bounded CPU/GPU overlap. A later render thread adds a separate kind of overlap: main-thread update for N+1 running concurrently with CPU render recording for N. + +## Vocabulary and indexing rules + +Use the following terms consistently in names and comments. + +### Frame context + +A frame context is one reusable set of CPU/GPU submission state. The configured maximum is `DeviceParams::MaxFramesInFlight`; it defaults to two and must never be lower than two. With the default, the current frame context rotates predictably: + +```text +0, 1, 0, 1, ... +``` + +Frame-indexed state includes: + +- NVRHI command lists owned by `RenderCommandBuffer` +- Overall and named GPU timer queries +- Vulkan acquire semaphores +- NVRHI completion event queries +- Future transient upload arenas and per-frame descriptor allocators + +### Backbuffer + +A backbuffer is an image returned by the native swapchain. Vulkan chooses the actual count, which this engine requires to be at least two. Acquisition chooses the current backbuffer index independently of the frame-context sequence. + +Backbuffer-indexed state includes: + +- Native swapchain images +- NVRHI image and framebuffer wrappers for those native images +- Per-image presentation semaphores +- Runtime composite passes and cached framebuffer handles + +### Invariant + +Never size frame-context arrays from `GetBackBufferCount()`, and never select a swapchain framebuffer with `GetCurrentFrameIndex()`. + +The count contracts are: + +- `DeviceParams::MaxFramesInFlight >= 2`. +- The actual swapchain image count returned by Vulkan is `>= 2`. +- The effective frame-context count is `min(MaxFramesInFlight, actual swapchain image count)` and is therefore also `>= 2`. + +## Implementation order + +Implement the work in the following increments. Build after every increment that is expected to compile. The tests are intentionally committed first and may keep the branch red until the corresponding production increment is complete. + +--- + +## Increment A: render-command queue semantics + +### Files + +- `EppoEngine/Source/Renderer/RenderCommandQueue.h` +- `EppoEngine/Source/Renderer/RenderCommandQueue.cpp` +- `EppoEngineTesting/Source/Renderer/RenderCommandQueue.cpp` + +### Required behavior + +- Commands execute in submission order. +- A command executes at most once. +- `Clear()` destroys pending commands without invoking them. +- Commands submitted during execution enter the next detached batch. + +### Localized implementation change + +Replace the body of `RenderCommandQueue::Execute`. + +Swap `m_CommandQueue` into a local vector before iteration: + +```cpp +std::vector commands; +commands.swap(m_CommandQueue); + +for (auto& command : commands) + command(); +``` + +After the local batch finishes, `m_CommandQueue` retains any commands submitted during execution for the next call. + +`RenderCommandQueue` remains single-threaded in this phase because submission and execution both occur on the main thread. The detached batch defines the producer/consumer handoff that the future render thread will synchronize. + +### Failure and exception behavior + +If a render command throws, the exception propagates to `Application` or the runtime error boundary. Stack unwinding destroys the remaining commands in the detached local batch, while commands submitted into `m_CommandQueue` during the failing batch remain pending. + +--- + +## Increment B: static renderer submission façade + +### Files + +- `EppoEngine/Source/Renderer/Renderer.h` +- `EppoEngine/Source/Renderer/Renderer.cpp` + +### Header contract + +The public declarations are: + +```cpp +static auto Submit(RenderCommand command) -> void; +static auto ExecuteRenderCommands() -> void; +``` + +The `Renderer` instance owns: + +```cpp +RenderCommandQueue m_RenderCommandQueue; +``` + +The queue remains an instance member. `Renderer::Submit` is a static façade that forwards to the renderer owned by `DeviceManager`. + +Declare `m_RenderCommandQueue` after the renderer services captured commands may reference. Reverse member-destruction order then destroys pending lambdas before those services, while the NVRHI device is still alive. + +### `Renderer::Submit` + +In `Renderer.cpp`, resolve the active renderer through this ownership chain: + +```text +Application singleton + -> DeviceManager + -> owned Renderer + -> RenderCommandQueue +``` + +Inside `Renderer::Submit`, insert: + +```cpp +const auto& renderer = DeviceManager::Get()->GetRenderer(); +EP_ASSERT(renderer != nullptr, "Cannot submit render work before renderer initialization."); +renderer->m_RenderCommandQueue.AddCommand(std::move(command)); +``` + +Render-command submission begins after `DeviceManager::InitRenderer` publishes the renderer instance. + +### `Renderer::ExecuteRenderCommands` + +Resolve the same active renderer, assert it exists, and call `m_RenderCommandQueue.Execute()`. + +`Application::StepFrame` is the only production call site that drains the queue. Tests may call it directly to verify façade semantics. + +### Shutdown behavior + +If commands remain pending because no valid frame was acquired, `Renderer` destruction destroys those lambdas and releases their captured `Ref` objects without invoking them. + +--- + +## Increment C: Application execution boundary + +### File + +- `EppoEngine/Source/Core/Application.cpp` + +### `Application::StepFrame` + +Inside the successful `BeginFrame()` branch, insert this line immediately after the complete ImGui block and immediately before `m_DeviceManager->Present()`: + +```cpp +Renderer::ExecuteRenderCommands(); +``` + +The drain exists only inside the successful `BeginFrame()` branch, after update/UI collection and immediately before `Present()`. This placement supplies an acquired backbuffer and current frame index to the batch, and ensures presentation waits on the generated submissions. Minimized and failed-acquisition frames leave the queue pending. + +### Commands submitted outside a frame + +Commands submitted during layer attachment or another non-frame phase remain pending until the next successfully acquired frame. Renderer shutdown destroys them if no later frame drains them. + +--- + +## Increment D: split SceneRenderer CPU preparation and GPU execution + +### Files + +- `EppoEngine/Source/Renderer/SceneRenderer.h` +- `EppoEngine/Source/Renderer/SceneRenderer.cpp` + +### Lifetime + +`SceneRenderer` inherits `std::enable_shared_from_this`. Every known construction site uses `CreateRef`. Preserve that invariant; constructing a `SceneRenderer` directly on the stack and then calling `EndScene()` will make `shared_from_this()` invalid. + +### Responsibilities + +Use three distinct methods: + +```cpp +auto PrepareRenderData() -> void; +auto UploadRenderData() -> void; +auto ExecuteRender() -> void; +``` + +`PrepareRenderData` inspects the scene, meshes, submeshes, and materials and populates ordinary CPU structures without an open NVRHI command list. + +`UploadRenderData` assumes the render command buffer is open. It performs buffer uploads, descriptor/binding updates, and pass baking. + +`ExecuteRender` records the complete pass sequence and submits the NVRHI command list. + +### `SceneRenderer::EndScene` + +Perform this work immediately: + +```text +EnsureColliderMeshes +GatherWireframes +PrepareRenderData +``` + +Then retain the renderer and submit the GPU phase: + +```cpp +const Ref sceneRenderer = shared_from_this(); +Renderer::Submit( + [sceneRenderer]() -> void + { + sceneRenderer->ExecuteRender(); + } +); +``` + +The prepared members retain their values from the `Submit` call until the batch drains. + +### Exact contents of `PrepareRenderData` + +Place the following CPU work here: + +1. Reset and calculate shadow cascade matrices and split distances. +2. Calculate SSAO scalar/vector values: + - radius + - bias + - power + - intensity + - inverse render size +3. Clear `m_InstanceTransforms`. +4. Iterate `m_DrawCommands` in stable map order. +5. Assign each draw command's `InstanceOffset` from the current flattened transform count. +6. Append that command's transforms to `m_InstanceTransforms`. +7. Clear `m_WireframeTransforms`. +8. Assign wireframe instance offsets and flatten their transforms. +9. Clear `m_DrawData` and `m_MaterialData`. +10. Iterate each draw command, submesh, and primitive. +11. Create `DrawData` entries from the submesh local transform, instance offset, and new material index. +12. Create `MaterialData` entries from the material's bindless texture indices and scalar/vector properties. + +Move these GPU/resource operations from `PrepareRenderData` to `UploadRenderData`: + +- `UniformBuffer::SetData` +- `StorageBuffer::SetData` +- `RenderPass::Bake` +- `Image::RegisterBindlessIndex` +- Render-pass input rebinding after resize + +The last two lines of the current `FillShadowData` also cross into renderer-resource state: + +```cpp +m_ShadowDepthData.ShadowMapIndex = ...GetBindlessIndex(...); +m_ShadowDepthData.ShadowSamplerIndex = ...GetBindlessIndex(); +``` + +Move those assignments to `UploadRenderData`. `FillShadowData` calculates only CPU-visible shadow data. + +### Exact contents of `UploadRenderData` + +With `m_RenderCommandBuffer->GetCommandList()` active: + +1. Resolve the shadow map and sampler bindless indices into `m_ShadowDepthData`. +2. Upload `m_ShadowDepthData` to `m_ShadowDepthUB`. +3. Upload `m_SsaoData` to `m_SsaoUB`. +4. Upload camera, lights, and environment structures. +5. Upload `m_InstanceTransforms`. +6. Upload `m_WireframeTransforms`. +7. Upload `m_DrawData`. +8. Upload `m_MaterialData`. +9. Refresh every pass input that may reference a framebuffer attachment recreated by resize. +10. Bake every pass after its inputs are current. +11. Pre-register the geometry output and bloom mip subresources in the bindless table before any pass binds that table. + +Use `vector.data()` for uploads so zero-length vectors follow the storage-buffer API's existing zero-byte behavior. + +### `ExecuteRender` + +The exact order is: + +```text +RenderCommandBuffer::Begin +UploadRenderData +ShadowDepthPass +SsaoPass +GeometryPass +SkyPass +BloomPass +TonemapPass +WireframePass +EP_GPU_COLLECT +RenderCommandBuffer::End +RenderCommandBuffer::Submit +``` + +Place `EP_GPU_COLLECT` after every render pass has ended and before ending the command buffer. + +### Resize remains immediate in this phase + +`SceneRenderer::Resize` remains synchronous in this phase. The editor creates an ImGui texture reference from `GetFinalImage()` during UI construction; queued framebuffer recreation could replace that handle before the UI renders. + +Treat resize as resource-lifecycle work until the future render packet also owns a stable viewport texture reference. + +### Future-thread warning + +The queued lambda currently reads mutable `SceneRenderer` members and mesh render handles. This is safe only because `Application` drains the queue before the next main-thread frame begins. The actual render-thread phase must snapshot this data into an immutable or double-buffered packet before allowing main-thread frame N+1 to mutate the same renderer. + +--- + +## Increment E: queue runtime compositing + +### Files + +- `EppoEngine/Source/Renderer/Renderer.h` +- `EppoEngine/Source/Renderer/Renderer.cpp` + +### Split the method + +The public API used by `RuntimeLayer` remains: + +```cpp +auto CompositeToSwapchain(const Ref& image) -> void; +``` + +Add this private immediate method: + +```cpp +auto ExecuteCompositeToSwapchain(const Ref& image) -> void; +``` + +The public method validates and captures the image: + +```cpp +EP_ASSERT(image != nullptr, "Cannot composite a null image to the swapchain."); + +Submit( + [this, image]() -> void + { + ExecuteCompositeToSwapchain(image); + } +); +``` + +Move the existing body that accesses the current backbuffer, creates or refreshes the composite pass, records the three-vertex draw, and submits the command buffer into the private immediate method. + +### Ordering + +`RuntimeLayer::OnUpdate` produces this queue order: + +```text +SceneRenderer::ExecuteRender +Renderer::ExecuteCompositeToSwapchain +ImGui main viewport render, if enabled +``` + +FIFO submission to the same NVRHI graphics queue provides the dependency between these three commands. + +### Backbuffer caches + +Size `m_CompositePasses` and `m_CompositeFramebuffers` from the actual swapchain backbuffer count and select entries with `GetCurrentBackBufferIndex()`. Their framebuffer compatibility is tied to the acquired native image. + +--- + +## Increment F: queue ImGui GPU work without moving ImGui CPU state + +### Files + +- `EppoEngine/Source/ImGui/ImGuiLayer.cpp` +- `EppoEngine/Source/ImGui/ImGuiRenderer.h` +- `EppoEngine/Source/ImGui/ImGuiRenderer.cpp` + +### Critical boundary + +`ImGui::Render()` finalizes ImGui's CPU draw data. It stays on the main thread and runs before the render-command batch is executed. + +Only the NVRHI buffer upload, command recording, submission, and native secondary-viewport presentation are queued. + +### Main viewport API split + +Add this public collection method: + +```cpp +auto SubmitToSwapchain(ImGuiViewport* viewport, Swapchain* swapchain, bool clearSwapchainTarget = true) -> void; +``` + +Add this private immediate method: + +```cpp +auto RenderToSwapchainImmediate(ImGuiViewport* viewport, Swapchain* swapchain, bool clearSwapchainTarget) -> void; +``` + +The public collection method captures the pointers and submits a lambda. The private immediate method performs the current `GetOrCreateRenderPass` and `Render` calls without submitting another render command. + +The queued viewport command calls the immediate rendering method directly. A nested `Renderer::Submit` would enter the next detached batch and delay the viewport by one frame. + +### `ImGuiLayer::Render` + +Perform these operations synchronously: + +```text +ImGui::Render +submit main viewport GPU rendering +ImGui::UpdatePlatformWindows +ImGui::RenderPlatformWindowsDefault +``` + +The platform renderer callbacks invoked by `RenderPlatformWindowsDefault` must collect secondary-viewport work rather than execute it immediately. + +### Shared `ImGuiViewportData` layout + +The struct is currently repeated in `ImGuiLayer.cpp` and `ImGuiRenderer.cpp`. Add the same field in both definitions: + +```cpp +bool FrameAcquired = false; +``` + +Both definitions must remain token-equivalent. + +### Secondary `Renderer_RenderWindow` callback + +Submit one render command that: + +1. Calls the viewport swapchain's `BeginFrame()`. +2. Stores the result in `FrameAcquired`. +3. Returns immediately if acquisition failed. +4. Updates the viewport renderer's font texture if required. +5. Calls the immediate viewport rendering method. + +The outer callback calls the immediate viewport rendering method directly. + +### Secondary `Renderer_SwapBuffers` callback + +Submit a following render command that: + +1. Checks `FrameAcquired`. +2. Calls the viewport swapchain's `Present()` only if acquisition succeeded. +3. Clears `FrameAcquired` afterward. + +FIFO ordering guarantees that acquisition/render executes before presentation for that viewport. + +### Same-frame lifetime constraint + +Raw ImGui viewport, renderer, and swapchain pointer captures are valid only while commands drain before `Application::StepFrame` returns to platform-window mutation. The next phase copies `ImDrawData` and retains platform viewport rendering state independently of the live ImGui context. + +--- + +## Increment G: DeviceManager frame-context API + +### Files + +- `EppoEngine/Source/Renderer/DeviceManager.h` +- `EppoEngine/Source/Renderer/DeviceManager.cpp` +- `EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.h` + +### `DeviceParams` + +`DeviceParams` contains: + +```cpp +uint32_t MaxFramesInFlight = 2; +``` + +Remove: + +```cpp +uint32_t SwapchainImageCount = 3; +``` + +In `DeviceManager::Create`, change only the existing `MaxFramesInFlight` validation to: + +```cpp +EP_ASSERT(params.MaxFramesInFlight >= 2, "MaxFramesInFlight must be at least two."); +``` + +The effective value may be clamped down to the actual swapchain image count. Because both inputs are required to be at least two, clamping cannot produce a one-frame context. + +### Abstract getters + +Add or implement: + +```cpp +[[nodiscard]] virtual auto GetCurrentFrameIndex() const -> uint32_t = 0; +[[nodiscard]] virtual auto GetMaxFramesInFlight() const -> uint32_t = 0; +``` + +The backbuffer getters remain separate from the frame-context getters. + +### Vulkan forwarding + +`DeviceManagerVK` forwards: + +```cpp +GetCurrentFrameIndex() -> m_Swapchain->GetCurrentFrameIndex() +GetMaxFramesInFlight() -> m_Swapchain->GetMaxFramesInFlight() +``` + +`GetMaxFramesInFlight()` returns the swapchain's effective count after clamping, which remains at least two. + +--- + +## Increment H: Vulkan frame synchronization + +### Files + +- `EppoEngine/Source/Platform/Vulkan/Swapchain.h` +- `EppoEngine/Source/Platform/Vulkan/Swapchain.cpp` + +### Remove the constructor's unused local + +Delete this unused line from `Swapchain::Swapchain`: + +```cpp +VkDevice device = dm->GetLogicalDevice()->GetNative(); +``` + +### Separate `CreateSwapchain` from resize orchestration + +`CreateSwapchain` owns native swapchain-dependent resource creation and replacement. Initial swapchain and ImGui viewport construction call it directly. Every later replacement goes through `Resize`, which owns the device-idle wait, forwards the requested or current surface extent, and clears the pending-resize state after successful recreation. + +Inside the existing `if (m_Swapchain)` branch, delete: + +```cpp +VK_CHECK(vkDeviceWaitIdle(device), "Failed to wait for vulkan device"); +``` + +The callers that replace an existing swapchain enter through `Resize`, so `CreateSwapchain` no longer performs resize synchronization itself. + +Inside the recreation branch, add this statement immediately after the presentation-semaphore destruction loop: + +```cpp +m_PresentSemaphores.clear(); +``` + +Frame synchronization remains independent of native swapchain-image recreation and is updated by the count-dependent block below. + +### Creating frame synchronization objects + +After the existing `semaphoreInfo` declaration and before the existing image loop, insert: + +```cpp +const auto nvrhiDevice = dm->GetDevice(); + +if (m_FrameSyncData.size() != m_MaxFramesInFlight) +{ + for (const auto& frame : m_FrameSyncData) + vkDestroySemaphore(device, frame.AcquireSemaphore, nullptr); + + m_FrameSyncData.clear(); + m_FrameSyncData.resize(m_MaxFramesInFlight); + + for (auto& frame : m_FrameSyncData) + { + VK_CHECK(vkCreateSemaphore(device, &semaphoreInfo, nullptr, &frame.AcquireSemaphore), "Failed to create acquire semaphore!"); + frame.CompletionQuery = nvrhiDevice->createEventQuery(); + EP_ASSERT(frame.CompletionQuery != nullptr, "Failed to create frame completion query."); + frame.InFlight = false; + } +} +else +{ + for (auto& frame : m_FrameSyncData) + { + if (frame.InFlight) + nvrhiDevice->resetEventQuery(frame.CompletionQuery); + frame.InFlight = false; + } +} + +m_CurrentFrameIndex = 0; +m_FrameActive = false; +``` + +The first branch runs during initial creation and when a later swapchain image count changes the effective frames-in-flight count. The second branch preserves acquire semaphores and event queries across ordinary recreation. + +### `BeginFrame` sequence + +Make these changes inside the existing `Swapchain::BeginFrame` body: + +1. After `EP_PROFILE_FN`, insert: + + ```cpp + EP_ASSERT(!m_FrameActive, "BeginFrame was called while a swapchain frame is already active."); + ``` + +2. After the existing `dm` and `device` locals, insert: + + ```cpp + nvrhi::vulkan::IDevice* vkNvrhiDevice(dm->GetDevice()->getNativeObject(nvrhi::ObjectTypes::Nvrhi_VK_Device)); + ``` + + Delete the obsolete local that indexes `m_AcquireSemaphores` with `m_AcquireIndex`. + +3. At the start of the existing acquire-attempt loop, insert: + + ```cpp + if (m_ResizePending) + Resize(); + + auto& frame = m_FrameSyncData.at(m_CurrentFrameIndex); + if (frame.InFlight) + { + vkNvrhiDevice->waitEventQuery(frame.CompletionQuery); + vkNvrhiDevice->resetEventQuery(frame.CompletionQuery); + frame.InFlight = false; + } + ``` + +4. Replace the current `vkAcquireNextImageKHR` call and the complete result branch immediately following it with: + + ```cpp + result = vkAcquireNextImageKHR(device, m_Swapchain, UINT64_MAX, frame.AcquireSemaphore, nullptr, &m_SwapchainImageIndex); + + if (result == VK_ERROR_OUT_OF_DATE_KHR) + { + m_ResizePending = true; + continue; + } + + if (result != VK_SUCCESS && result != VK_SUBOPTIMAL_KHR) + { + Log::Error("Failed to acquire a swapchain image: VkResult {}.", static_cast(result)); + return false; + } + + if (result == VK_SUBOPTIMAL_KHR) + m_ResizePending = true; + + vkNvrhiDevice->queueWaitForSemaphore(nvrhi::CommandQueue::Graphics, frame.AcquireSemaphore, 0); + m_FrameActive = true; + return true; + ``` + +5. After the acquire-attempt loop, replace the block beginning with the old `m_AcquireIndex` increment and ending with the existing final `return false` with: + + ```cpp + Log::Error("Failed to acquire a swapchain image after {} attempts.", maxAttempts); + return false; + ``` + +`VK_ERROR_OUT_OF_DATE_KHR` reaches `continue` before queuing a semaphore wait because no image was acquired. The next loop iteration enters `Resize()` through the pending flag and then retries acquisition. `VK_SUBOPTIMAL_KHR` represents a successfully acquired image, so it marks the resize for the next frame, queues the acquire-semaphore wait, activates the frame, and returns immediately. Successful acquisition no longer falls through to a second result check after the loop. + +### `Resize` sequence + +In `Swapchain.h`, change the declaration to: + +```cpp +auto Resize(uint32_t width = 0, uint32_t height = 0) -> void; +``` + +Zero dimensions select the current framebuffer extent through the existing `CreateSwapchain`/`SelectExtent` path. This gives deferred acquire/present recovery a valid `Resize()` call while preserving explicit dimensions for window and ImGui viewport resize callbacks. + +Immediately after the existing `CreateSwapchain(width, height)` call in `Swapchain::Resize`, add: + +```cpp +m_ResizePending = false; +``` + +The parameterless call from `BeginFrame` therefore waits in `Resize`, forwards `(0, 0)` to `CreateSwapchain`, selects the current framebuffer extent, and clears the pending state before acquisition retries. + +### `Present` sequence + +Make these changes inside the existing `Swapchain::Present` body: + +1. After `EP_PROFILE_FN`, insert: + + ```cpp + EP_ASSERT(m_FrameActive, "Present was called without an active swapchain frame."); + ``` + +2. After the existing `vkNvrhiDevice` local, insert: + + ```cpp + auto& frame = m_FrameSyncData.at(m_CurrentFrameIndex); + ``` + +3. Immediately after the existing `executeCommandLists(nullptr, 0)` call, insert: + + ```cpp + vkNvrhiDevice->setEventQuery(frame.CompletionQuery, nvrhi::CommandQueue::Graphics); + frame.InFlight = true; + ``` + +4. Immediately after the `vkQueuePresentKHR` result, delete the combined result check, `vkQueueWaitIdle`, `m_FramesInFlight` loop, `m_QueryPool` reuse/allocation, query reset/set, and queue push. + +5. In the deleted code's place, insert this result-and-advance block: + + ```cpp + m_FrameActive = false; + m_CurrentFrameIndex = (m_CurrentFrameIndex + 1) % m_MaxFramesInFlight; + + if (result == VK_SUCCESS) + return true; + + if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR) + { + m_ResizePending = true; + return true; + } + + Log::Error("Failed to present a swapchain image: VkResult {}.", static_cast(result)); + return false; + ``` + +The event query is set after NVRHI emits the final graphics submission, so it represents completion of all render commands and the semaphore signal for this frame. There is no routine `vkQueueWaitIdle` and no replacement FIFO. + +### Destruction + +In `Swapchain::~Swapchain`, replace only the obsolete loop over `m_AcquireSemaphores` with: + +```cpp +for (auto& frame : m_FrameSyncData) +{ + vkDestroySemaphore(device, frame.AcquireSemaphore, nullptr); + frame.CompletionQuery = nullptr; +} +m_FrameSyncData.clear(); +``` + +--- + +## Increment I: frame-indexed, asynchronous RenderCommandBuffer timing + +### Files + +- `EppoEngine/Source/Renderer/RenderCommandBuffer.h` +- `EppoEngine/Source/Renderer/RenderCommandBuffer.cpp` +- `EppoEngine/Source/Renderer/SceneRenderer.cpp` + +### `RenderCommandBuffer.h` + +Rename the existing private declaration from `EnsureBackBufferCapacity(uint32_t backBufferCount)` to `EnsureFrameCapacity(uint32_t frameCount)`. + +After `m_NamedTimestamps`, add the state needed to distinguish completed queries from queries that were never submitted: + +```cpp +std::vector m_FrameSubmitted; +std::vector> m_SubmittedNamedTimerQueries; +``` + +Immediately after `m_ActiveCommandList`, add: + +```cpp +uint32_t m_ActiveFrameIndex = UINT32_MAX; +``` + +Resolve completed timing directly in `Begin`, where reuse of a synchronized frame slot is known to be safe. + +### Constructor and capacity method + +In the constructor, change only the existing capacity call: + +```cpp +EnsureFrameCapacity(DeviceManager::Get()->GetMaxFramesInFlight()); +``` + +Rename the `EnsureBackBufferCapacity` definition and its parameter to `EnsureFrameCapacity(const uint32_t frameCount)`. Inside that method: + +1. Replace the early-return condition with: + + ```cpp + if (frameCount <= m_CommandLists.size()) + return; + ``` + +2. Replace the five existing vector-resize statements with: + + ```cpp + m_CommandLists.resize(frameCount); + m_TimerQueries.resize(frameCount); + m_Timestamps.resize(frameCount); + m_NamedTimerQueries.resize(frameCount); + m_NamedTimestamps.resize(frameCount); + m_FrameSubmitted.resize(frameCount, false); + m_SubmittedNamedTimerQueries.resize(frameCount); + ``` + +3. Replace the existing command-list/query creation loop header with: + + ```cpp + for (size_t i = previousCount; i < frameCount; i++) + ``` + +### `Begin` + +Make these localized changes in `RenderCommandBuffer::Begin`: + +1. Change the capacity source and frame-index source: + + ```cpp + EnsureFrameCapacity(dm->GetMaxFramesInFlight()); + const uint32_t frameIndex = dm->GetCurrentFrameIndex(); + ``` + +2. After the existing range assertion and before assigning `m_ActiveCommandList`, insert: + + ```cpp + EP_ASSERT(m_ActiveFrameIndex == UINT32_MAX); + m_ActiveFrameIndex = frameIndex; + + if (m_FrameSubmitted.at(frameIndex)) + { + const auto device = dm->GetDevice(); + m_Timestamps.at(frameIndex) = device->getTimerQueryTime(m_TimerQueries.at(frameIndex)); + device->resetTimerQuery(m_TimerQueries.at(frameIndex)); + + for (const auto& timerName : m_SubmittedNamedTimerQueries.at(frameIndex)) + { + const auto& timerQuery = m_NamedTimerQueries.at(frameIndex).at(timerName); + m_NamedTimestamps.at(frameIndex)[timerName] = device->getTimerQueryTime(timerQuery); + device->resetTimerQuery(timerQuery); + } + + m_SubmittedNamedTimerQueries.at(frameIndex).clear(); + m_FrameSubmitted.at(frameIndex) = false; + } + ``` + +The swapchain's `BeginFrame` has completed the wait for this frame context before render-command execution reaches this point, so the block reads and resets only this slot's previously submitted queries. + +### Move timing resolution out of `Submit` + +Make these changes in `RenderCommandBuffer::Submit`: + +1. Replace the locals and range assertion from `const auto& dm = DeviceManager::Get()` through `EP_ASSERT(frameIndex < m_TimerQueries.size())` with: + + ```cpp + const auto device = DeviceManager::Get()->GetDevice(); + EP_ASSERT(m_ActiveFrameIndex != UINT32_MAX); + const uint32_t frameIndex = m_ActiveFrameIndex; + EP_ASSERT(frameIndex < m_TimerQueries.size()); + ``` + +2. Immediately after the existing `device->executeCommandList(m_ActiveCommandList)` statement, insert: + + ```cpp + m_FrameSubmitted.at(frameIndex) = true; + ``` + +3. Delete the block beginning with the assignment to `m_Timestamps.at(frameIndex)` and ending after the loop that resets every entry in `m_NamedTimerQueries.at(frameIndex)`. + +4. Immediately after the existing `m_ActiveTimerQuery = nullptr` statement, insert: + + ```cpp + m_ActiveFrameIndex = UINT32_MAX; + ``` + +The readback moves to synchronized slot reuse because NVRHI's Vulkan `getTimerQueryTime` waits for availability. Calling it in `Submit` would serialize the CPU with the frame just submitted. + +### Named timer methods + +In `BeginTimerQuery`: + +1. Replace the locals and range assertion from `const auto& dm = DeviceManager::Get()` through `EP_ASSERT(frameIndex < m_NamedTimerQueries.size())` with: + + ```cpp + EP_ASSERT(m_ActiveCommandList); + EP_ASSERT(m_ActiveFrameIndex != UINT32_MAX); + const auto device = DeviceManager::Get()->GetDevice(); + const uint32_t frameIndex = m_ActiveFrameIndex; + EP_ASSERT(frameIndex < m_NamedTimerQueries.size()); + ``` + +2. After creating or retrieving `timerQuery` and before beginning it, insert: + + ```cpp + m_SubmittedNamedTimerQueries.at(frameIndex).insert(name); + ``` + +In `EndTimerQuery`, replace the locals and range assertion from `const auto& dm = DeviceManager::Get()` through `EP_ASSERT(frameIndex < m_NamedTimerQueries.size())` with: + +```cpp +EP_ASSERT(m_ActiveCommandList); +EP_ASSERT(m_ActiveFrameIndex != UINT32_MAX); +const uint32_t frameIndex = m_ActiveFrameIndex; +EP_ASSERT(frameIndex < m_NamedTimerQueries.size()); +``` + +### Timing UI + +In `SceneRenderer::RenderGui`, change only these two existing lines: + +```cpp +const uint32_t frameIndex = dm->GetCurrentFrameIndex(); +EP_ASSERT(frameIndex < dm->GetMaxFramesInFlight()); +``` + +The displayed data is intentionally delayed. A frame context shows the completed timing from the previous time that slot was used. Profiling UI must never stall current rendering for fresher numbers. + +--- + +## Increment J: shutdown and resource lifetime + +### Files + +- `EppoEngine/Source/Core/Application.cpp` +- `EppoEngine/Source/Platform/Vulkan/DeviceManagerVK.cpp` + +### Application destruction + +In `Application::~Application`, immediately after the existing `m_ThreadPool->Shutdown(true)` call and before `m_ImGuiLayer.reset()`, insert: + +```cpp +EP_ASSERT(m_DeviceManager->WaitIdle(), "Failed to wait for the rendering device during application shutdown."); +``` + +This ensures that layer destruction can release scene renderers, ImGui buffers, detached viewport swapchains, and framebuffer resources without the GPU still referencing them. + +### Vulkan device-manager shutdown + +At the beginning of `DeviceManagerVK::Shutdown`, before `GpuProfiler::Shutdown()`, insert: + +```cpp +EP_ASSERT(WaitIdle(), "Failed to wait for the Vulkan device during shutdown."); +``` + +The application-level wait covers the expected lifecycle. The backend-level wait protects alternate owners and partial initialization/shutdown paths. + +--- + +## Automated test contract + +The tests are implemented before production completion and are expected to keep the branch red until the contracts above exist. + +### Queue tests + +- FIFO execution +- Execute-once semantics +- Clear discards without invoking +- Submission during execution waits for the next detached batch + +### Renderer façade test + +- `Renderer::Submit` does not execute immediately +- `Renderer::ExecuteRenderCommands` drains the active renderer's queue + +### Application boundary test + +A layer submits a command during `OnUpdate`. The test verifies that the command observes both update and UI collection as complete. This pins the drain after `OnUIRender` and before `StepFrame` returns. + +A second application instance disables ImGui. Its layer submits from `OnUpdate`, and the command must still execute in the same successful frame. This prevents the queue drain from being placed accidentally inside the `if (m_ImGuiLayer)` block. + +### SceneRenderer lifetime test + +Render one scene frame, release the caller's final `Ref` immediately after `EndScene`, and verify that the queued render command retains the renderer until the application drains the batch. Verify that the renderer is released after execution. This pins both deferred execution and the `shared_from_this` capture required by the first phase. + +### Frame/backbuffer contract tests + +- Assert `GetBackBufferCount() >= 2`. +- Assert `DeviceParams::MaxFramesInFlight >= 2`. +- Assert `GetMaxFramesInFlight() == min(DeviceParams::MaxFramesInFlight, GetBackBufferCount())`. +- Assert `GetMaxFramesInFlight() >= 2`. +- Both current indices remain inside their respective ranges. +- The frame index rotates modulo the effective frame-context count. +- No test assumes a deterministic Vulkan backbuffer acquisition sequence. + +### Pending-capture shutdown test + +Submit a command that captures a `Ref`, destroy the application before advancing a frame, and verify that renderer-queue destruction releases the captured object. + +### RenderCommandBuffer tests + +- Timing storage is sized by frames in flight, not backbuffers. +- Begin/end/submit completes with the current frame index. +- Named timers use the frame index. +- Reusing a frame slot resolves and exposes its previous timing without invalid command-list or query reuse. + +### Existing renderer regression + +Run the composite cycling/resize scenario for more frames than the actual backbuffer count and verify that cached swapchain framebuffer state survives image cycling and recreation. + +## Manual verification matrix + +After the code compiles and automated tests pass, verify the following with a real display and Vulkan validation enabled. + +| Scenario | Expected result | +|---|---| +| Editor idle for several hundred frames | No validation errors; frame index continues rotating. | +| Heavy scene | CPU can enter later frames while prior GPU work remains queued. | +| Main-window resize drag | Recreation may stall, then rendering resumes with refreshed framebuffer caches. | +| Minimize and restore | No rendering while zero-sized/minimized; valid acquisition resumes afterward. | +| Detached ImGui viewport | Secondary acquire, rendering, and present execute in FIFO order. | +| Close detached viewport | No queued command references destroyed viewport data. | +| Runtime compositing | Scene submission precedes composite submission for every frame. | +| Application close | Device becomes idle before layer and swapchain resources are released. | +| GPU timing panel | Values update with a delay while frame pacing remains unaffected. | + +Pay particular attention to validation messages concerning: + +- Reusing a binary acquire semaphore before its wait submission +- Reusing a command buffer or command pool while in flight +- Destroying a framebuffer or swapchain image while referenced +- Presenting an image without waiting on the rendering-complete semaphore +- Signalling or waiting on a binary semaphore twice without the opposite operation +- Resetting timer queries that have not completed + +## Build and test sequence + +Build `EppoEngineTesting` in Debug using the generated Visual Studio solution. Then run focused suites in this order: + +```powershell +ctest --test-dir build/bin/Debug-windows-x86_64 --output-on-failure -R Core +ctest --test-dir build/bin/Debug-windows-x86_64 --output-on-failure -R App +ctest --test-dir build/bin/Debug-windows-x86_64 --output-on-failure -R Renderer +``` + +Run the broader non-graphical set: + +```powershell +ctest --test-dir build/bin/Debug-windows-x86_64 --output-on-failure --label-exclude graphical +``` + +The `App` and `Renderer` suites require a display and GPU. Run them through CTest so the working directory remains `EppoEditor/` and shader resources resolve correctly. + +Finally launch the editor from `EppoEditor/`, exercise the manual matrix, and inspect both stdout and `latest.log`. + +## Deliberately deferred next phase + +The implementation above creates a queue boundary but does not make the render workload safe to run concurrently with main-thread mutation. + +Before starting a dedicated render thread, implement: + +1. Two render-command batches or an equivalent producer/consumer exchange. +2. A render-thread lifecycle owned by `Application` or `Renderer` with explicit startup, wake, frame completion, and shutdown. +3. Immutable or double-buffered `SceneRenderPacket` data containing camera, lights, environment, draw metadata, transform arrays, and retained GPU resource handles. +4. Copied ImGui draw lists and retained per-viewport state so the render thread never reads a live ImGui context while the main thread begins the next frame. +5. A resource-retirement mechanism keyed to completed frame contexts. +6. Thread-affinity assertions for native window operations, renderer submission, queue execution, and resource creation/destruction. + +At that point the main thread can collect frame N+1 while the render thread records/submits frame N, and the GPU can simultaneously execute frame N-1. The Vulkan frame-context synchronization implemented in this plan remains the GPU backpressure mechanism for that phase. diff --git a/Scripts/Premake/Testing.lua b/Scripts/Premake/Testing.lua index b46444a6..6552ece1 100644 --- a/Scripts/Premake/Testing.lua +++ b/Scripts/Premake/Testing.lua @@ -6,6 +6,7 @@ local suites = { { "Scene", "core" }, { "Scripting", "scripting" }, { "ScriptMarshalling", "scripting" }, + { "ScriptReloadGraphical", "graphical" }, { "App", "graphical" }, { "CoreGraphical", "graphical" }, { "ProjectExport", "graphical" }, diff --git a/_docs/README.md b/_docs/README.md index ee556749..5d4bbac5 100644 --- a/_docs/README.md +++ b/_docs/README.md @@ -7,7 +7,7 @@ usually means the type quietly vanishes from copy, save, packing, or the inspector rather than failing to build. File paths are stable; line numbers are not — grep for the anchor symbols named -in each doc. When a change spans a subsystem, read the matching `.claude/skills/` +in each doc. When a change spans a subsystem, read the matching `.agents/skills/` skill too. - [Adding an asset type](adding-an-asset-type.md) diff --git a/_docs/adding-a-collider-shape.md b/_docs/adding-a-collider-shape.md index b0b4cc05..5366893e 100644 --- a/_docs/adding-a-collider-shape.md +++ b/_docs/adding-a-collider-shape.md @@ -21,7 +21,7 @@ inherits the whole [component checklist](adding-a-component.md). Read the 3. **`AppendColliders`** — translate the new collider *component* into a `ColliderData{ .Shape = ColliderShape::Xxx, … }`. This is what feeds `GatherColliders` and the compound body. Remember the two-walk-direction - gotcha in CLAUDE.md — parenting/relationship repair affects which colliders get + gotcha in AGENTS.md — parenting/relationship repair affects which colliders get gathered. 4. **`Scene::FitColliderToMesh`** — add the overload for the new collider