From 647392cbc705a1419d0021b95610feecb56041db Mon Sep 17 00:00:00 2001 From: Niels Eppenhof Date: Wed, 5 Aug 2026 22:50:01 +0200 Subject: [PATCH 1/7] P1: ThreadPool with both task queueing and task with dependency queueing. --- EppoEngine/Source/Core/Application.cpp | 5 + EppoEngine/Source/Core/Application.h | 2 + EppoEngine/Source/Core/ThreadPool.cpp | 293 ++++++++++ EppoEngine/Source/Core/ThreadPool.h | 95 ++++ EppoEngineTesting/Source/Core/ThreadPool.cpp | 532 +++++++++++++++++++ 5 files changed, 927 insertions(+) create mode 100644 EppoEngine/Source/Core/ThreadPool.cpp create mode 100644 EppoEngine/Source/Core/ThreadPool.h create mode 100644 EppoEngineTesting/Source/Core/ThreadPool.cpp diff --git a/EppoEngine/Source/Core/Application.cpp b/EppoEngine/Source/Core/Application.cpp index abc0c191..d7485e44 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 @@ -65,6 +68,7 @@ namespace Eppo it = m_LayerStack.erase(it); } + m_ThreadPool->Shutdown(true); m_DeviceManager->Shutdown(); m_Window->Shutdown(); @@ -92,6 +96,7 @@ namespace Eppo EP_PROFILE_FN("Application::StepFrame") m_Window->ProcessEvents(); + m_ThreadPool->Flush(); if (!m_IsMinimized && m_DeviceManager->BeginFrame()) { diff --git a/EppoEngine/Source/Core/Application.h b/EppoEngine/Source/Core/Application.h index 32fd4176..23926efb 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/ThreadPool.h" #include "Core/Window.h" #include "Event/ApplicationEvent.h" #include "ImGui/ImGuiLayer.h" @@ -95,6 +96,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/ThreadPool.cpp b/EppoEngine/Source/Core/ThreadPool.cpp new file mode 100644 index 00000000..cf69b1c0 --- /dev/null +++ b/EppoEngine/Source/Core/ThreadPool.cpp @@ -0,0 +1,293 @@ +#include "pch.h" +#include "Core/ThreadPool.h" + +#include + +namespace Eppo +{ + ThreadPool::ThreadPool() + : m_OwnerThread(std::this_thread::get_id()) + { + const uint32_t threadCount = std::max(1u, std::thread::hardware_concurrency() - 1); + m_Threads.reserve(threadCount); + + for (uint32_t i = 0; i < threadCount; i++) + { + m_Threads.emplace_back( + [this]() -> void + { + WorkerLoop(); + } + ); + } + } + + ThreadPool::~ThreadPool() + { + Shutdown(true); + } + + auto ThreadPool::QueueTask(std::string name, TaskFn taskFn, CompletionFn completionFn, TaskPriority priority) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTask") + + const TaskId id = m_NextTaskId.fetch_add(1); + + auto task = CreateRef(); + task->Id = id; + task->Name = std::move(name); + task->Priority = priority; + task->Fn = std::move(taskFn); + task->OnComplete = std::move(completionFn); + + { + std::scoped_lock lock(m_PendingMutex); + m_PendingTasks.at(static_cast(priority)).emplace_back(task); + m_AllTasks[id] = task; + m_TasksPending++; + } + + m_WorkAvailableCV.notify_one(); + return id; + } + + auto ThreadPool::QueueTaskWithDependencies( + std::string name, TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority + ) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTaskWithDependencies") + + const TaskId id = m_NextTaskId.fetch_add(1); + + 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 + { + std::scoped_lock lock(m_PendingMutex); + + for (const auto& dependencyId : dependencies) + { + if (dependencyId == 0 || 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) + { + if (m_AllTasks.contains(dependencyId)) + continue; + + const auto status = m_AllTasks.at(dependencyId)->Status.load(std::memory_order_relaxed); + if (status == TaskStatus::Completed || status == TaskStatus::Failed || status == TaskStatus::Cancelled) + continue; + + m_AllTasks.at(dependencyId)->Dependents.emplace_back(id); + remainingDeps++; + } + + task->RemainingDeps = remainingDeps; + if (remainingDeps == 0) + m_PendingTasks.at(static_cast(priority)).emplace_back(task); + m_AllTasks[id] = task; + m_TasksPending++; + } + + m_WorkAvailableCV.notify_one(); + return id; + } + + 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) + task->OnComplete(task->Status); + + completedTaskIds[i] = task->Id; + } + + std::scoped_lock lock(m_PendingMutex); + for (size_t i = 0; i < completedTaskIds.size(); i++) + m_AllTasks.erase(completedTaskIds.at(i)); + + return static_cast(batch.size()); + } + + auto ThreadPool::CancelAll() -> void + { + EP_PROFILE_FN("ThreadPool::CancelAll") + + std::scoped_lock lock(m_PendingMutex); + + for (auto& [taskId, task] : m_AllTasks) + { + if (task->Status == TaskStatus::Pending) + task->Status = TaskStatus::Cancelled; + } + } + + auto ThreadPool::Shutdown(bool cancelPending) -> void + { + EP_PROFILE_FN("ThreadPool::Shutdown") + + if (cancelPending) + CancelAll(); + + { + std::scoped_lock lock(m_PendingMutex); + m_IsRunning = false; + } + 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_relaxed) + m_TasksInFlight.load(std::memory_order_relaxed); + } + + auto ThreadPool::WorkerLoop() -> void + { + while (true) + { + Ref task; + + // Wait for task + { + // TODO: Why unique? + std::unique_lock lock(m_PendingMutex); + m_WorkAvailableCV.wait( + lock, + [this]() -> bool + { + return !m_IsRunning || HasPendingTasks(); + } + ); + + if (!m_IsRunning && !HasPendingTasks()) + return; + + task = GetNextTask(); + } + + if (task->Status.load(std::memory_order_relaxed) == TaskStatus::Running) + { + try + { + task->Fn(); + task->Status.store(TaskStatus::Completed, std::memory_order_relaxed); + } + catch (const std::exception& e) + { + Log::Error("Task '{}' with id {} threw: {}", task->Name, task->Id, e.what()); + task->Status.store(TaskStatus::Failed, std::memory_order_relaxed); + } + catch (...) + { + Log::Error("Task '{}' with id {} threw unknown exception!", task->Name, task->Id); + task->Status.store(TaskStatus::Failed, std::memory_order_relaxed); + } + } + + // Process task dependencies + if (!task->Dependents.empty()) + { + std::scoped_lock lock(m_PendingMutex); + for (const auto& dependentId : task->Dependents) + { + if (m_AllTasks.contains(dependentId)) + continue; + + auto& dependentTask = m_AllTasks.at(dependentId); + + // fetch_sub returns the value from *before* the subtraction, so the last dependency + // to resolve sees 1, not 0. + const uint32_t remaining = dependentTask->RemainingDeps.fetch_sub(1, std::memory_order_relaxed); + if (remaining == 1) + { + m_PendingTasks.at(static_cast(dependentTask->Priority)).emplace_back(dependentTask); + m_WorkAvailableCV.notify_one(); + } + } + } + + // Add to completed tasks + { + std::scoped_lock lock(m_CompletedMutex); + m_CompletedTasks.emplace_back(task); + m_TasksInFlight.fetch_sub(1, std::memory_order_relaxed); + } + } + } + + 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) + { + if (it->empty()) + continue; + + auto task = std::move(it->front()); + it->pop_front(); + + // Claim it while the queue lock is still held, so CancelAll can no longer reach it. A task it + // already cancelled keeps that status and its body is skipped. + auto expected = TaskStatus::Pending; + task->Status.compare_exchange_strong(expected, TaskStatus::Running, std::memory_order_relaxed); + + m_TasksInFlight.fetch_add(1, std::memory_order_relaxed); + m_TasksPending.fetch_sub(1, std::memory_order_relaxed); + return task; + } + + return nullptr; + } +} diff --git a/EppoEngine/Source/Core/ThreadPool.h b/EppoEngine/Source/Core/ThreadPool.h new file mode 100644 index 00000000..dd585b45 --- /dev/null +++ b/EppoEngine/Source/Core/ThreadPool.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace Eppo +{ + enum class TaskPriority : uint8_t + { + Low = 0, + Medium, + High, + }; + + enum class TaskStatus : uint8_t + { + Pending = 0, + Running, + Completed, + Failed, + Cancelled, + }; + + using TaskId = uint64_t; + using TaskFn = std::function; + using CompletionFn = std::function; + + class ThreadPool + { + public: + ThreadPool(); + ~ThreadPool(); + + // Callable: All threads + auto QueueTask(std::string name, TaskFn taskFn, CompletionFn completionFn, TaskPriority priority = TaskPriority::Medium) -> TaskId; + auto QueueTaskWithDependencies( + std::string name, TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, + TaskPriority priority = TaskPriority::Medium + ) -> TaskId; + + // Callable: Main thread + auto Flush() -> uint32_t; + + // 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; + std::atomic Queued = false; + }; + + auto WorkerLoop() -> void; + [[nodiscard]] auto HasPendingTasks() const -> bool; + [[nodiscard]] auto GetNextTask() -> Ref; + + private: + // Pending tasks + std::mutex m_PendingMutex; + std::condition_variable m_WorkAvailableCV; + 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; + + // Workpool + std::vector m_Threads; + std::atomic m_IsRunning = true; + std::atomic m_TasksInFlight = 0; + std::atomic m_NextTaskId = 1; + std::thread::id m_OwnerThread; + }; +} diff --git a/EppoEngineTesting/Source/Core/ThreadPool.cpp b/EppoEngineTesting/Source/Core/ThreadPool.cpp new file mode 100644 index 00000000..8aa2a78e --- /dev/null +++ b/EppoEngineTesting/Source/Core/ThreadPool.cpp @@ -0,0 +1,532 @@ +#include "TestSupport/EppoTest.h" +#include "Core/ThreadPool.h" + +#include +#include +#include + +using Eppo::TaskFn; +using Eppo::TaskId; +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(5); + + 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)); + } +} + +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_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()); +} From dff96ae298153e503272c7efed92ee6b5a92c958 Mon Sep 17 00:00:00 2001 From: Niels Eppenhof Date: Mon, 10 Aug 2026 22:14:39 +0200 Subject: [PATCH 2/7] Added background task UI --- .claude/settings.json | 5 - .../eppo-application-framework/SKILL.md | 25 - .../references/architecture.md | 107 - .../skills/eppo-assets-and-projects/SKILL.md | 28 - .../references/architecture.md | 117 - .../skills/eppo-editor-development/SKILL.md | 25 - .../references/architecture.md | 119 - .../skills/eppo-physics-integration/SKILL.md | 25 - .../references/architecture.md | 101 - .../skills/eppo-rendering-pipeline/SKILL.md | 25 - .../references/architecture.md | 113 - .../skills/eppo-scene-ecs-lifecycle/SKILL.md | 25 - .../references/architecture.md | 130 - .../eppo-scripting-integration/SKILL.md | 27 - .../references/architecture.md | 116 - CLAUDE.md | 151 -- .../Projects/Test/Assets/AssetRegistry.json | 18 +- .../Test/Assets/Scenes/Sponza.epscene | 131 + EppoEditor/Source/EditorLayer.cpp | 2233 +++++++++-------- EppoEditor/Source/EditorLayer.h | 4 + EppoEditor/Source/StatusBar.cpp | 167 ++ EppoEditor/Source/StatusBar.h | 20 + EppoEngine/Source/Asset/AssetManager.cpp | 52 +- EppoEngine/Source/Asset/AssetManager.h | 2 + EppoEngine/Source/Core/Application.cpp | 3 +- EppoEngine/Source/Core/Application.h | 3 +- .../Core/{ => ThreadPool}/ThreadPool.cpp | 219 +- .../Source/Core/{ => ThreadPool}/ThreadPool.h | 34 +- EppoEngine/Source/Core/UUID.cpp | 10 +- EppoEngine/Source/EppoEngine.h | 3 +- EppoEngine/Source/Renderer/Image.cpp | 74 +- EppoEngine/Source/Renderer/Image.h | 6 +- EppoEngine/Source/Renderer/SceneRenderer.cpp | 14 +- EppoEngine/Source/Utility/Random.h | 4 +- EppoEngineTesting/Source/Core/Application.cpp | 55 + EppoEngineTesting/Source/Core/ThreadPool.cpp | 53 +- EppoEngineTesting/Source/Renderer/Image.cpp | 15 + 37 files changed, 1969 insertions(+), 2290 deletions(-) delete mode 100644 .claude/settings.json delete mode 100644 .claude/skills/eppo-application-framework/SKILL.md delete mode 100644 .claude/skills/eppo-application-framework/references/architecture.md delete mode 100644 .claude/skills/eppo-assets-and-projects/SKILL.md delete mode 100644 .claude/skills/eppo-assets-and-projects/references/architecture.md delete mode 100644 .claude/skills/eppo-editor-development/SKILL.md delete mode 100644 .claude/skills/eppo-editor-development/references/architecture.md delete mode 100644 .claude/skills/eppo-physics-integration/SKILL.md delete mode 100644 .claude/skills/eppo-physics-integration/references/architecture.md delete mode 100644 .claude/skills/eppo-rendering-pipeline/SKILL.md delete mode 100644 .claude/skills/eppo-rendering-pipeline/references/architecture.md delete mode 100644 .claude/skills/eppo-scene-ecs-lifecycle/SKILL.md delete mode 100644 .claude/skills/eppo-scene-ecs-lifecycle/references/architecture.md delete mode 100644 .claude/skills/eppo-scripting-integration/SKILL.md delete mode 100644 .claude/skills/eppo-scripting-integration/references/architecture.md delete mode 100644 CLAUDE.md create mode 100644 EppoEditor/Projects/Test/Assets/Scenes/Sponza.epscene create mode 100644 EppoEditor/Source/StatusBar.cpp create mode 100644 EppoEditor/Source/StatusBar.h rename EppoEngine/Source/Core/{ => ThreadPool}/ThreadPool.cpp (55%) rename EppoEngine/Source/Core/{ => ThreadPool}/ThreadPool.h (71%) diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 1f73a2b0..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "worktree": { - "bgIsolation": "none" - } -} diff --git a/.claude/skills/eppo-application-framework/SKILL.md b/.claude/skills/eppo-application-framework/SKILL.md deleted file mode 100644 index e042b518..00000000 --- a/.claude/skills/eppo-application-framework/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: eppo-application-framework -description: Develop and diagnose Eppo's application framework across entry-point creation, Application and layer lifecycle, frame ordering, window and GLFW event delivery, input backends and simulated input, device and renderer startup, resize and minimization, ImGui frame integration, writable and resource directory resolution, the deployed EppoRuntime player, and the App harness. Use for changes to Application, Window, Layer, EntryPoint, Input, SimulatedInput, Event, ImGui, platform window/input code, EppoEditor/Source/EppoEditor.cpp, EppoRuntime/Source, or application-level tests; do not trigger for unrelated Core utilities such as UUID or Hash. ---- - -# Eppo Application Framework - -Read [references/architecture.md](references/architecture.md) before changing startup, frame order, events, input, or ImGui integration. Follow ownership from `main` through `CreateApplication`, `Application`, the window/device, layers, and shutdown. - -## Workflow - -1. Trace the exact lifecycle phase affected: construction, layer attach, event pump, update, ImGui frame, render submission, present, resize, close, or destruction. -2. Preserve the order dependencies between window creation, required Vulkan extensions, device initialization, renderer initialization, ImGui attachment, and user layers. -3. Keep event-driven state and polled input coherent. Update the real and simulated input paths together when adding input behavior. -4. Add deterministic coverage through `Application::StepFrame` and the support harness when the behavior can be observed by frame count or state. -5. Use headless unit tests for isolated core types; use `App` for real window, device, and repeated-frame behavior, and the `Renderer` suite's `SceneRendering` tests for renderer, input, or camera behavior across frames. -6. Run the editor from `EppoEditor/` or tests through CTest so source resources resolve from the working directory; runtime data remains executable-relative. - -## Guardrails - -- Maintain the single live `Application` invariant. -- Do not update or present while minimized or after frame acquisition fails. -- Dispatch events through layers in their current stack order and stop once handled. -- Gate gameplay/editor polled input through the established viewport-input mechanism. -- Shut down GPU and ImGui users before destroying the window or device they depend on. diff --git a/.claude/skills/eppo-application-framework/references/architecture.md b/.claude/skills/eppo-application-framework/references/architecture.md deleted file mode 100644 index d02fa85a..00000000 --- a/.claude/skills/eppo-application-framework/references/architecture.md +++ /dev/null @@ -1,107 +0,0 @@ -# Application framework architecture - -## Startup and ownership - -`Core/EntryPoint.h` supplies `Eppo::RunApplication(argc, argv)` — initialize logging, call the application-specific `CreateApplication(argc, argv)`, own the result in a `ScopedPtr`, `Run`, destroy — plus a default `main` that calls it. Defining `EP_CUSTOM_ENTRY_POINT` before including the header suppresses that `main` so a target can supply its own. - -Two targets implement the factory: - -- `EppoEditor/Source/EppoEditor.cpp` uses the default `main` and pushes `EditorLayer`. -- `EppoRuntime/Source/EppoRuntime.cpp` defines `EP_CUSTOM_ENTRY_POINT` and calls `RunApplication` from its own `WinMain` (Windows) or `main`, so startup sits inside a try/catch that reports failures through `ErrorDialog` instead of terminating silently. Its `CreateApplication` deserializes `Game.eppak` **before** constructing the application, moves the packed shaders and includes into `ApplicationParams`, and hands the remaining `GameData` to `RuntimeLayer`. Reading the package cannot be deferred to the layer: the shaders are consumed during `Application` construction. - -`Application` is a singleton during its lifetime and owns, in dependency order: - -- `Window` and its platform backend; -- `DeviceManager` and NVRHI renderer; -- application layers; -- `ImGuiLayer` and its renderer integration. - -`Layer` exposes attach, detach, update, UI render, and event hooks. `PushLayer` constructs a layer, stores shared ownership, and immediately calls `OnAttach`. - -## Construction order - -1. Set the singleton and initialize logging/profiling prerequisites. -2. Create the GLFW-backed `Window` and install the application event callback. -3. Create the API-specific `DeviceManager`; the Vulkan backend gathers GLFW's required instance extensions while constructing its instance. -4. Initialize the device manager's surface and swapchain. -5. Initialize `Renderer` after the NVRHI device is live. -6. Call `Renderer::LoadShaders` with `ApplicationParams::PackedShaders` / `PackedShaderIncludes` (empty in the editor and tests, which compile from `Resources/Shaders`). This must precede ImGui, whose renderer resolves `GetShader("imgui")` during `OnAttach`. -7. Create/attach `ImGuiLayer` after renderer services exist. -8. Let the application factory push editor/runtime layers. - -Reverse dependency order during destruction. Wait for GPU idle before releasing GPU users when required. - -## Frame order - -`Run` computes a wall-clock timestep and repeatedly calls `StepFrame`. `StepFrame` exists so tests can drive deterministic fixed timesteps and frame counts. - -The effective frame phases are: - -1. Poll window events. -2. If minimized, avoid normal device/update/present work. -3. Begin/acquire the device frame. -4. Call `OnUpdate(timestep)` on layers in insertion order. -5. Begin ImGui. -6. Call `OnUIRender()` on layers. -7. End/render ImGui. -8. Present the device frame. - -Respect a failed `BeginFrame`; do not record or present against an unavailable swapchain image. - -## Event flow - -Window callbacks construct typed events such as resize, close, key, mouse button, mouse move, and scroll. `Application::OnEvent` first dispatches application-owned events, then forwards remaining events through the layer stack in insertion order. `ImGuiLayer` is pushed during application construction, so this order lets it capture input before later layers. Stop propagation when `Handled` becomes true. - -Window close marks the app not running. Resize currently updates minimized state only; swapchain recreation is handled by its own acquire/present behavior rather than directly from `Application::OnWindowResize`. - -When adding an event: - -1. Define its type/category and payload under `Event`. -2. Emit it from the platform window callback. -3. Update stateful input backend data if applicable. -4. Handle it in application/ImGui/layers in the correct priority order. -5. Add unit or application-harness coverage. - -## Input model - -`Input` exposes static polled queries through an `InputBackend`. The normal backend reads platform/GLFW state. `SimulatedInput` supports deterministic tests and controlled scenarios. - -Editor code gates polled input through `Input::SetViewportInputEnabled`: editor camera and running scripts should remain inactive while users type or click in other panels. Event delivery and polled input are related but not interchangeable; preserve both when adding keys/buttons. - -Key and mouse numeric values are shared with C# scripting. Update `Core/KeyCodes.h`, managed `KeyCodes.cs`, platform mapping, and tests together when changing them. - -## Window and filesystem assumptions - -`Window` owns the native GLFW window and provides framebuffer size, event callback, VSync/fullscreen/decorated state, native handle, and icon operations. Vulkan surface extensions and framebuffer sizing originate here. - -The deployed runtime resolves `Game.eppak`, loose assets, managed files, logs, and its shader cache relative to the executable directory. The editor resolves `Resources/` and `Projects/` from its `EppoEditor/` working directory, while managed files remain executable-relative. CTest uses `EppoEditor/` as the test working directory and loads its deployed managed assemblies beside the test executable. - -Writes are separately configurable. `FS::ConfigureWritableDirectory(path)` establishes the root returned by `FS::GetWritableDirectory`, which `FS::GetShaderCacheDirectory` and logging resolve against; unconfigured, the shader cache falls back to `Resources/Shaders/Cache`. The runtime configures it to `FS::GetExecutableDirectory()` before anything else runs, so a shipped game keeps its log and shader cache beside itself rather than inside a read-only install tree. Configure it before the first write, not after. - -## ImGui integration - -`ImGuiLayer` owns context/frame setup, docking and multi-viewport configuration, event blocking policy, and `ImGuiRenderer`. `ImGuiRenderer` translates draw lists into NVRHI buffers, pipeline bindings, scissor rectangles, texture descriptors, command recording, and swapchain framebuffer output. - -Keep ImGui GPU resources synchronized with back-buffer count and viewport/swapchain changes. Application UI phase must enclose every layer's `OnUIRender`. - -## Core conventions - -`Core/Base.h` defines the ownership and style vocabulary used across the engine: `Ref`/`CreateRef` (shared_ptr), `ScopedPtr`/`CreateScopedPtr` (unique_ptr), `WeakRef` (weak_ptr), `EP_ASSERT(cond, msg)` — a `constexpr` function, not a macro — config macros `EP_DEBUG`/`EP_RELEASE`/`EP_DIST`, and Tracy profiling (`EP_PROFILE_FN`). Engine code uses trailing-return-type style (`auto Foo() -> void`) universally. Sibling core utilities: `Core/Log.h`, `UUID.h`, `Hash.h`. - -`Core/Buffer/` is the binary serialization layer the rest of the engine writes through. `Buffer` is the raw owning byte span; `StreamWriter`/`StreamReader` are the abstract interfaces, implemented by `BufferWriter`/`BufferReader` (in memory) and `FileStreamWriter`/`FileStreamReader` (on disk). Both bases offer `WriteRaw`/`ReadRaw` for trivially-copyable values, `WriteString`/`ReadString`, `WriteBuffer`/`ReadBuffer`, and `WriteMap`/`ReadMap` that dispatch per element on `std::is_trivially_copyable_v`. Non-trivial types opt in through the paired `StreamSerializable` / `StreamDeserializable` concepts by providing static `Serialize(writer, value)` / `Deserialize(reader, value)`, reached via `WriteObject`/`ReadObject`. Every operation returns `bool`; callers propagate failure rather than asserting, which is what lets `GameData` reject a truncated package cleanly. - -## Testing infrastructure - -`EppoEngineTesting/Source/Support/AppHarness` boots a real `Application`, window, device, renderer, and resources. It can advance a deterministic number of frames. `TestContext` and `ScenarioLayer` build on it for multi-frame scenarios and simulated input; they are consumed by the `Renderer` suite's `SceneRendering` tests. - -Test routing: - -- headless `Core`: buffers, streams, hashes, UUIDs, filesystem, process/file-watch, and isolated non-window logic; -- graphical `App`: boot, live window/device, and repeated frame advancement; -- graphical `Renderer`: direct GPU abstraction behavior, plus the `SceneRendering` tests covering state changes across frames, editor camera, input, scene loading, and rendering. - -Graphical suites require a real display and GPU and are excluded by headless CI. Run them from CTest so the configured working directory is correct. - -## Change checklist - -For frame/startup changes, verify construction and destruction order, minimized and failed-acquire paths, repeated fixed-step frames, and renderer availability. For input/event changes, verify native callbacks, event propagation/handling, polled state, simulated state, viewport gating, and managed key-code parity. For ImGui changes, verify application frame bracketing, back-buffer resource ownership, docking/multi-viewport behavior, and resize. diff --git a/.claude/skills/eppo-assets-and-projects/SKILL.md b/.claude/skills/eppo-assets-and-projects/SKILL.md deleted file mode 100644 index 2822670b..00000000 --- a/.claude/skills/eppo-assets-and-projects/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: eppo-assets-and-projects -description: Develop and diagnose Eppo asset and project workflows across project lifecycle, asset handles and metadata, registry persistence, relative path normalization, lazy loading, import/export dispatch, generated runtime assets, scene ownership, ContentBrowserPanel operations, project templates, asset-related serialization, and Game.eppak packaging and export. Use for changes under EppoEngine/Source/Asset, EppoEngine/Source/Project, EppoEditor/Source/Panels/ContentBrowserPanel, project templates, asset registry behavior, or the pack format consumed by EppoRuntime. ---- - -# Eppo Assets and Projects - -Read [references/architecture.md](references/architecture.md) before changing handles, paths, registry persistence, importers, or content-browser mutations. Treat the file on disk, registry metadata, loaded object, and editor presentation as distinct states. - -## Workflow - -1. Identify which identity is authoritative: project path, asset-relative path, stable `AssetHandle`, loaded `Asset`, or generated reserved handle. -2. Define disk and registry effects before editing. Keep move, rename, delete, import, and save operations consistent across both. -3. Add or extend engine APIs for reusable behavior; keep file-picker and ImGui orchestration in the editor. -4. Update type deduction, importer/exporter dispatch, icons, serialization, and opening behavior together when adding an asset type. -5. Preserve active-project preconditions and avoid holding references across project close or replacement. -6. When changing what gets packed, update `GameData`'s layout, its documented byte map, the matching `PackFormat` version, and the runtime's consumption together. -7. Test path/registry logic headlessly in `Project`; use graphical `ProjectExport` only when a live renderer is genuinely required. - -## Guardrails - -- Store asset paths relative to the active project's `Assets` directory. -- Reserve handle `0` as null and low handles for generated runtime primitives. -- Never delete a source file merely by removing registry metadata. -- Serialize registry mutations after releasing its mutex. -- Do not treat a registered asset as necessarily loaded, or a filesystem entry as necessarily registered. -- Gather packed data inside `Export` like every other section; do not special-case a payload with its own option flag, constructor parameter, or out-of-band capture. -- Bump the relevant `PackFormat` version with any layout change, and keep reads failing cleanly rather than asserting on a truncated or foreign package. diff --git a/.claude/skills/eppo-assets-and-projects/references/architecture.md b/.claude/skills/eppo-assets-and-projects/references/architecture.md deleted file mode 100644 index 81969e3a..00000000 --- a/.claude/skills/eppo-assets-and-projects/references/architecture.md +++ /dev/null @@ -1,117 +0,0 @@ -# Asset and project architecture - -## Core identities - -Keep four states distinct: - -1. A filesystem entry under a project's `Assets` directory. -2. `AssetMetadata` in `AssetRegistry.json` with handle, type, and relative path. -3. A loaded `Asset` object in `AssetManager::m_LoadedAssets`. -4. An editor representation in `ContentBrowserPanel`. - -A file may be unregistered. Registered metadata may be unloaded. A generated asset may have no file or serialized registry entry. - -`AssetHandle` is a UUID value. Handle `0` is null. Reserved low values generate built-in mesh primitives at runtime; normal imported assets use generated UUIDs. - -## File map - -| Area | Files | -| --- | --- | -| Asset base/types | `Asset/Asset.h`, `AssetType.h`, `AssetMetadata.h` | -| Registry/cache | `Asset/AssetManager.*` | -| Dispatch | `Asset/AssetImporter.*` | -| Project context | `Project/Project.*`, `ProjectSerializer.*` | -| Packaging | `Project/GameData.*`, `Project/ProjectExporter.*`, `Asset/PackFormat.h` | -| Scene asset format | `Scene/SceneSerializer.*` | -| Editor filesystem UI | `EppoEditor/Source/Panels/ContentBrowserPanel.*` | -| Templates | `EppoEditor/Resources/Templates/NewProject` | - -## Project lifecycle - -`Project` holds `ProjectSpecification` and one `AssetManager`; `Project::s_ActiveProject` is the global project context used by path and asset APIs. - -Opening a project deserializes the `.epproj`, sets its directory, publishes it as active, constructs the asset manager, and loads `Assets/AssetRegistry.json`. The editor then builds/loads scripts and opens the start scene. - -Saving serializes registered scene assets, the asset registry, and the project specification. Closing saves, unloads the user assembly, clears active editor scenes, and releases the active project. - -Functions such as `GetAssetsDirectory` assert an active project. Guard UI/background paths that can run during startup, failed open, or close. - -## Path contract - -- Project file: `/.epproj`. -- Assets root: `/Assets`. -- Scripts root: `/Scripts`. -- Registry metadata stores paths relative to `Assets`. -- `Project::GetAssetFilepath` maps metadata to disk. -- `Project::GetAssetRelativeFilepath` normalizes absolute editor selections before registration. - -Normalize at API boundaries. Do not compare an absolute content-browser path directly with stored relative metadata. - -## Registry and loading - -`CreateAsset` deduces type from extension, assigns the existing object's handle or a new UUID, inserts metadata under a mutex, then serializes the registry. `GetOrLoadAsset` returns cached objects, generates reserved primitives, or invokes the importer selected by metadata type. - -`RemoveAsset` removes metadata and any loaded cache entry, then serializes. It does not delete the source file. `UpdateAssetPath` changes metadata after a disk move/rename and then serializes. - -Registry serialization skips empty paths and runtime-generated assets. Release the registry lock before filesystem writes to avoid extending critical sections or deadlocking through future callbacks. - -The asynchronous loading parameter and `Tick` are currently scaffolding; do not claim async loading works without implementing synchronization, completion publication, and tests. - -## Import/export dispatch - -`AssetImporter` holds three dispatch tables keyed by `AssetType`: disk import, **packed** import, and export. Scene is implemented in all three via `SceneSerializer`; Mesh has disk import/export but **no packed importer**, so meshes cannot yet be loaded out of a package even though `PackFormat::Mesh` reserves a magic for them. A packed-mesh path needs the artifact model decided first (processed mesh data, not the glTF source) — do not wire a registration that would resolve to an unimplemented reader. - -Adding an asset type normally requires: - -1. Add enum/string conversions and extension deduction. -2. Add metadata/import/export dispatch. -3. Implement the actual asset class and loader. -4. Add content-browser icon and open behavior. -5. Add serialization/reference behavior for consumers. -6. Add registry round-trip and load tests. - -Do not register an extension as supported if its importer always returns null. - -## Content browser coordination - -The content browser synchronizes its root/current directory when the active project changes. It displays directories and files, assigns icons by registered or inferred type, and provides import/open/move/rename/delete operations. - -Mutation sequence matters: - -- Move/rename on disk first only if failure can be handled; then update metadata for registered assets. -- Delete the selected disk path and remove metadata when registered; do not conflate the two operations. -- Import external files into the project assets tree before registering the project-relative destination. -- Open scenes through the callback owned by `EditorLayer`, not by replacing panel context locally. - -## Packaging to `Game.eppak` - -`GameData` is the in-memory form of the package and the authority on its byte layout, which is documented as a field-by-field map in the header comment of `Project/GameData.h` — update that comment with any change. Every section is read and written through `Core/Buffer/` streams (`FileStreamWriter`/`FileStreamReader`), so a truncated or foreign file fails as a `false` return rather than an assert or a crash. - -`Asset/PackFormat.h` holds the four-character magic + version pairs: `EPAK` (package), `ESHD` (shaders), `EMSH` (mesh), `ESCN` (scene). Bump the version of whichever payload you changed. - -`ProjectExporter::Export` is a single ordered pass; `ProjectExportOptions` carries the configurations, build toggles and a progress callback, and `ProjectExportResult` accumulates warnings and errors instead of throwing: - -1. `ValidateProject` — name, configurations, start scene, output path. Nothing touches the filesystem until it passes. -2. Pack scenes. A packed scene is the `.epscene` file's **raw bytes** carried as a `PackedAssetData` payload; runtime-generated assets are skipped. -3. Pack shaders from the live renderer (`GetAllShaders()` — their sources are already in memory), then walk `Resources/Shaders` for `.hlsli` includes, keyed by path relative to that directory because that is how the sources `#include` them. This walk must stay in step with `ReadIncludesFromDisk` in `VulkanShader.cpp`, which hashes the same set for the shader cache key. -4. Build (optional) and validate the standalone runtime per configuration. -5. Create the output tree. **From here on every failure wipes the partial export** through the local `fail()` helper — preserve that, a half-written game directory is worse than none. -6. Per configuration: compile the user's C# scripts into the output via `dotnet`, stage the runtime executable + native dependencies + managed core, copy the loose `Assets` tree, then `gameData.Serialize(outputDirectory / GameData::Filename)`. - -Two consequences worth holding on to. First, packing shaders needs a live renderer, which is why exporter tests are graphical (`ProjectExport`) rather than unit. Second, gathering happens **inside** `Export` for every payload — do not add an option flag, constructor parameter, or externally-captured argument for one section, because that makes it the odd one out. - -## Consuming the package - -`EppoRuntime` deserializes `Game.eppak` inside `CreateApplication`, before the `Application` exists, then splits it: shaders and includes move into `ApplicationParams` (the renderer owns them from that point), and the rest goes to `RuntimeLayer`. - -`AssetManager` has a packed constructor taking owned metadata and `PackedAssetData` payloads. In that mode `GetOrLoadAsset` deserializes lazily from the in-memory payload instead of reading disk. There is no `PackedAssetManager` class — `EppoEngineTesting/Source/Project/PackedAssetManager.cpp` exercises `AssetManager`'s packed mode, and its tests are named accordingly. - -## Scene asset ownership - -`Scene` derives from `Asset` and carries its handle. Saving a previously unregistered scene creates registry metadata using that existing handle, then loads/caches it. Editor active-scene paths and project start-scene handles must remain consistent when using Save As or opening by filesystem path versus handle. - -## Testing strategy - -The headless `Project` suite (`EppoEngineTesting/Source/Project/`) owns `GameData` round-trips, packed-asset loading through `AssetManager`, and registry/path behavior. The graphical `ProjectExport` suite owns `ProjectExporter`, because packing shaders reads them from a live renderer; each of its tests guards on `Testing::AppHarness::IsAvailable()` and returns early when no GPU is present. - -Use `Testing::TempDir` for project directories and restore the previously active project after each test. Never mutate checked-in editor projects or their registries. Scene persistence belongs in `Scene`; end-to-end opening and rendering belongs in the `Renderer` suite's `SceneRendering` tests. diff --git a/.claude/skills/eppo-editor-development/SKILL.md b/.claude/skills/eppo-editor-development/SKILL.md deleted file mode 100644 index d857af9a..00000000 --- a/.claude/skills/eppo-editor-development/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: eppo-editor-development -description: Extend and diagnose EppoEditor workflows across EditorLayer, edit/play scene state, panels and shared selection, viewport rendering and input focus, gizmos, project and scene commands, content browsing, docking, editor resources, and editor-to-engine boundaries. Use for changes under EppoEditor/Source or EppoEditor/Resources and for engine APIs introduced specifically to support editor behavior. ---- - -# Eppo Editor Development - -Read [references/architecture.md](references/architecture.md) before changing `EditorLayer` or a panel. Decide first whether the behavior belongs in the reusable engine or only in the editor shell. - -## Workflow - -1. Place reusable scene, asset, physics, scripting, or rendering behavior in `EppoEngine`; keep orchestration and authoring UI in `EppoEditor`. -2. Trace editor state through `m_EditorScene`, `m_ActiveScene`, `SceneState`, `PanelManager` scene context, selection, and `SceneRenderer` scene context. -3. Preserve UUID-based remapping whenever a scene copy or replacement invalidates EnTT handles and `Entity` wrappers. -4. Route panel-wide scene context and selection through `PanelManager`. Route operations requiring editor authority, such as opening a scene, back through `EditorLayer` callbacks. -5. Keep polled input gated by viewport focus and keep gizmo interaction from also moving the editor camera. -6. Test extracted engine behavior in its matching headless suite. Use `App` for real boot and frame advancement, or the `Renderer` suite's `SceneRendering` tests when the change requires the real renderer, viewport, or editor-camera path. - -## Guardrails - -- Start play from a copy of the editor scene; never mutate the authored scene as runtime state. -- Stop runtime and clear script contexts before dropping the runtime scene. -- Resolve editor resources and projects relative to the `EppoEditor/` working directory; keep managed binaries executable-relative. -- Apply docking-layout restoration before submitting windows for the frame. -- Preserve panel names referenced by the default docking layout. diff --git a/.claude/skills/eppo-editor-development/references/architecture.md b/.claude/skills/eppo-editor-development/references/architecture.md deleted file mode 100644 index a8ad1a56..00000000 --- a/.claude/skills/eppo-editor-development/references/architecture.md +++ /dev/null @@ -1,119 +0,0 @@ -# Editor architecture - -## Ownership map - -`EppoEditor.cpp` implements `CreateApplication` and pushes `EditorLayer`. `EditorLayer` is the editor shell and owns: - -- `m_EditorScene`: the authored scene; -- `m_ActiveScene`: the scene currently displayed and updated; -- `m_SceneState`: edit or play; -- `SceneRenderer` and `EditorCamera`; -- `PanelManager`, toolbar icons, viewport state, gizmo state, and project/scene commands; -- the export-game command, which collects `ProjectExportOptions` from the UI and hands them to `ProjectExporter(project).Export(options)` — the editor supplies paths and toggles and reports progress, it does not gather packed payloads itself. - -`PanelManager` owns panels and centralizes scene context plus selected `Entity`. Panels receive a non-owning manager pointer through `Panel`. Current panels are: - -- `SceneHierarchyPanel`: tree display, selection, entity creation/deletion, and hierarchy interaction; -- `PropertyPanel`: component editing, script fields, component addition/removal, collider fitting; -- `ContentBrowserPanel`: filesystem navigation, asset icons, importing, moving/renaming/deleting, and opening scenes through a callback; -- `LogPanel`: level/source/text filtering over engine log output. - -`LogPanel` reads through `LogSink`, an editor-only bounded ring buffer (`LOG_BUFFER_CAPACITY`) attached to the loggers via `Log::AddSink`. A shipped runtime never installs it, so it never retains log text in memory — keep it that way. The panel syncs from the sink by version rather than re-reading every frame, and recomputes its filtered index list only when the filter or the entry set changed; preserve that when adding filters. - -## Edit/play state machine - -In edit state, `m_ActiveScene == m_EditorScene`. The editor camera renders the authored scene, selection highlighting is active, and editing commands operate on authored data. - -Play transition: - -1. Capture selected UUID before replacing the scene. -2. Set state to play. -3. `Scene::Copy(m_EditorScene)` into `m_ActiveScene`. -4. Update panel scene context. -5. Resolve selection by UUID in the runtime copy. -6. Start runtime physics and scripts. -7. Set script scene context to the runtime scene. - -Play update steps runtime, then renders from the primary scene camera. If no primary camera exists, the editor camera renders as a fallback and the viewport displays a notice rather than a stale frame. - -Stop transition: - -1. Clear script scene context while the runtime scene is alive. -2. Stop the runtime scene. -3. Capture the selected runtime UUID before releasing the copied scene. -4. Restore `m_ActiveScene = m_EditorScene` and edit state. -5. Update panels and resolve selection by UUID in the authored scene. - -Never retain an `Entity` across scene replacement: it contains an EnTT handle and raw `Scene*`. - -## Per-frame ordering - -`OnUpdate` consumes UI state from the previous UI pass. It: - -1. Pulls selection from `PanelManager`. -2. Propagates viewport size to cameras, both scenes, and `SceneRenderer`. -3. Gates polled input using last frame's viewport-focus state. -4. Refreshes the renderer's scene reference and edit-mode highlight. -5. Updates the editor camera or runtime scene and renders. - -`OnUIRender` applies deferred layout restoration before any windows begin, builds the dockspace/menu, renders the viewport image, records viewport bounds/focus/hover, draws toolbar/notices, updates panels, and handles popups. - -One-frame lag for focus or selection is intentional where documented. Avoid mixing same-frame UI mutation into render-update state unless the ordering is deliberately redesigned. - -## Project and scene flow - -Opening a project: - -1. Close/save the previous project and unload its collectible user assembly. -2. Deserialize the `.epproj` and asset registry. -3. Build the project C# assembly with the current `EppoScriptCore.dll` path. -4. Initialize scripting and load the user assembly. -5. Open the start scene only after script class metadata exists, so script fields deserialize correctly. - -Saving a project saves the active scene, assigns the start scene if absent, serializes registered scenes, writes the asset registry, and writes the project file. - -Opening scenes must go through `EditorLayer`, even when initiated in `ContentBrowserPanel`, because the layer owns active/editor scene bookkeeping and scripting assumptions. - -## Panel extension checklist - -To add a panel: - -1. Derive from `Panel` and implement `RenderGui`. -2. Register it in `EditorLayer::OnAttach` through `PanelManager::AddPanel`. -3. Use manager-provided scene and selection instead of storing a divergent authoritative copy. -4. Add its window toggle to the editor menu. -5. If it belongs in the default dock layout, update `Resources/Layouts/DefaultLayout.ini` and keep its window name stable. -6. Load icons/resources from `FS::GetResourcesDirectory()`. - -Use callbacks to request editor-authoritative operations rather than giving a panel broad access to `EditorLayer` internals. - -Entity duplication is already routed from `SceneHierarchyPanel` to `Scene::DuplicateEntity`; extend the engine operation and its tests before adding editor-side duplication logic. - -## Property editing checklist - -When adding a component editor: - -- Match the component's native units and coordinate conventions. -- Use `DrawComponent` for consistent header/removal behavior. -- Disable or guard operations that require another component or loaded asset. -- Route collider auto-fit to `Scene`, where runtime-independent mesh-bound logic belongs. -- For script fields, edit `ScriptEngine`'s stored field map, not a live runtime instance. -- Consider whether editing should be allowed in play mode and whether it should persist after stop. - -## Content browser model - -The content browser displays both registered and unregistered filesystem entries. `AssetManager::GetHandleForPath` determines registration. File mutations must coordinate filesystem state with registry state: - -- import/create registers supported types; -- move/rename updates registered metadata paths; -- delete removes registry metadata and separately deletes the selected filesystem entry; -- opening a scene calls back into `EditorLayer` by handle; -- icons derive from `AssetType`, with generic file/directory fallbacks. - -## Resources and sample project - -`EppoEditor/Resources/` owns shaders, fonts, icons, layouts, and project templates. The editor and graphical tests run with `EppoEditor/` as their working directory and read these files in place through `FS::GetResourcesDirectory()`; builds never stage a copy. Panels use the engine's `ImGui/ScopedBegin.h` and `ImExt.h` helpers; toolbar hit-testing goes through `Utils::IsInsideRoundedRect` so clicks in rounded-corner gaps are ignored. A sample project lives at `EppoEditor/Projects/Test/Test.epproj` and is likewise opened directly from the source tree. - -## Testing boundaries - -Prefer tests in engine suites for behavior extracted from editor UI: scene hierarchy, serialization, asset registry, collider fitting, scripting fields, and input semantics. Use the `Renderer` suite's `SceneRendering` tests for editor-camera and scene-render behavior over frames. `App` verifies real application/window/device boot. Direct editor UI automation is not currently part of the repository test harness, so keep UI handlers thin and engine behavior testable. diff --git a/.claude/skills/eppo-physics-integration/SKILL.md b/.claude/skills/eppo-physics-integration/SKILL.md deleted file mode 100644 index 2938f61e..00000000 --- a/.claude/skills/eppo-physics-integration/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: eppo-physics-integration -description: Develop and diagnose Eppo's Box3D integration across rigid bodies, collider shapes, hierarchy-aware collider gathering, world/local transform and scale conversion, runtime simulation and scene synchronization, collider fitting and debug rendering, managed physics APIs, and physics regressions. Use for changes under EppoEngine/Source/Physics, physics-related scene components or runtime code, physics ScriptGlue APIs, property-panel collider editing, or Physics tests. ---- - -# Eppo Physics Integration - -Read [references/architecture.md](references/architecture.md) before changing collider dimensions, hierarchy traversal, pose conversion, or physics scripting. Most physics regressions are transform-contract regressions rather than Box3D API mistakes. - -## Workflow - -1. State the coordinate space for every pose, offset, rotation, and dimension involved: authored local, composed world, rigid-body local, or Box3D world. -2. Add a focused regression in `EppoEngineTesting/Source/Physics/PhysicsWorld.cpp`; cover hierarchy, rotation, non-uniform or mirrored scale, and degenerate dimensions when relevant. -3. Keep `PhysicsWorld` responsible for Box3D handles and operations. Keep scene traversal, collider aggregation, and ECS synchronization in `Scene`. -4. Update editor component controls, serialization, debug rendering, and C# APIs when changing a physics component. -5. Preserve safe no-op/default behavior for missing bodies, expired worlds, invalid entities, and absent runtime contexts. -6. Run `Physics`; also run `Scene`, `Scripting`, or the graphical `Renderer` suite when their boundary changes. - -## Guardrails - -- Build one Box3D body per `RigidBodyComponent`; gather descendant colliders until another rigid-body boundary. -- Exclude entity scale from the body pose and apply composed scale to collider geometry and offsets. -- Step physics before scripts so scripts observe the current simulated pose. -- Synchronize parent bodies before children and convert world poses back to authored local transforms. -- Keep bodies without colliders valid and report them without suppressing simulation. diff --git a/.claude/skills/eppo-physics-integration/references/architecture.md b/.claude/skills/eppo-physics-integration/references/architecture.md deleted file mode 100644 index 42347adb..00000000 --- a/.claude/skills/eppo-physics-integration/references/architecture.md +++ /dev/null @@ -1,101 +0,0 @@ -# Physics integration architecture - -## Responsibility split - -`PhysicsWorld` wraps Box3D and owns the Box3D world plus the entity-UUID-to-body map. It creates bodies, attaches already-described colliders, steps simulation, and exposes safe body operations. - -`Scene` owns ECS interpretation: - -- compose hierarchy transforms; -- find rigid-body roots; -- gather descendant collider components; -- convert authored transforms into rigid-body-local collider data; -- create bodies at runtime start; -- synchronize Box3D world poses back to ECS local transforms after each step. - -Keep this split so collider derivation remains reusable outside the editor and Box3D details do not leak across scene code. - -## Data model - -`RigidBodyComponent` defines static, kinematic, or dynamic body type plus gravity scale and damping. Collider components define authored dimensions, local offset, density, friction, and restitution: - -- box: half-size; -- sphere: radius; -- capsule: radius and cylindrical height; -- cylinder: radius and height. - -`ColliderData` is the scene-to-physics normalized description. It includes shape type, transformed dimensions/offset, rotation, and material properties. `PhysicsWorld::AttachCollider` maps it to the appropriate Box3D shape definition. - -One `RigidBodyComponent` produces one Box3D body, even when it has no colliders. - -## Collider gathering - -At runtime start, for each rigid-body entity: - -1. Compose the entity's world transform and decompose translation, rotation, and scale. -2. Build the Box3D body pose from translation and rotation only. -3. Traverse the rigid-body entity and descendants. -4. Stop traversal when reaching a descendant with its own `RigidBodyComponent`; that node starts a new body boundary. -5. For each collider, compute its pose relative to the root body and apply the composed hierarchy scale to shape dimensions and offsets. -6. Track visited UUIDs to prevent malformed hierarchy cycles from recursing forever. -7. Attach every gathered shape to the root body. - -Scale is authored ECS geometry, not part of the Box3D body pose. Mirrored scale must mirror offsets while dimensions remain physically valid magnitudes. Nested rotations rotate collider offsets and local axes into body space for shear-free transforms. Current decomposition approximates rotation when non-uniform scale and rotation compose into shear; do not claim exact collider poses for that case without redesigning the transform representation and adding focused tests. - -## Simulation synchronization - -`Scene::OnUpdateRuntime` steps physics before scripts. It collects bodies with hierarchy depth, sorts parents before children, and reads each Box3D world pose. - -For a root entity, write simulated translation/rotation directly while preserving authored scale. For a parented body, multiply the world pose by the inverse parent world transform, decompose it, and write the resulting local translation/rotation. Parent-first order ensures the inverse uses the current frame's parent pose. - -Scripts then observe current transforms and can query or mutate body velocity/impulses through the active physics world. - -## Degenerate and missing data - -- A body with no collider still simulates; the scene records a warning name for editor display. -- Missing entity/body operations return safe defaults or no-op. -- An expired scripting physics-world weak reference makes managed callbacks safe no-ops. -- Zero/near-zero collider dimensions are clamped or converted according to existing shape behavior; preserve tests such as zero-height capsule and zero-extent box. -- A collider without any rigid-body ancestor creates no body. - -## Collider fitting - -`Scene::FitColliderToMesh` reads reusable mesh primitive bounds through the active project asset manager and derives authored collider dimensions: - -- box from bounds half-extents; -- sphere from the largest relevant extent; -- capsule/cylinder from vertical extent plus radial horizontal extent. - -Keep fitting in `Scene`, not `PropertyPanel`, so a runtime or future standalone tool can reuse it. The property panel only triggers the operation. - -## Cross-system checklist - -When adding or changing a physics property or shape, inspect: - -1. `Scene/Components.h` schema/defaults. -2. `Physics/PhysicsTypes.h` and `PhysicsWorld` Box3D mapping. -3. Scene collider gathering, scale, offsets, and runtime sync. -4. Scene copy/duplicate and JSON serialization. -5. `PropertyPanel` editing and fit controls. -6. `SceneRenderer` collider debug meshes/wireframes. -7. C# component API, `Physics` API, internal-call delegates, native callbacks, and registration. -8. Physics and scripting tests. - -## Regression matrix - -`EppoEngineTesting/Source/Physics/PhysicsWorld.cpp` is intentionally broad. Choose cases from the matrix that match the risk: - -| Risk | Cases | -| --- | --- | -| Basic dynamics | gravity, impulse, damping, kinematic velocity, static body | -| Shape mapping | sphere, capsule, cylinder, rotated box, material/dimension behavior | -| Degenerate geometry | zero capsule height, zero box extent | -| Authored scale | scaled root, nested scale, mirrored scale | -| Hierarchy | child colliders, multiple children, nested rotation, nested rigid-body boundary | -| Pose sync | parented dynamic root, child body under moving parent | -| Persistence/copy | serialized physics components, copied scene collider gathering | -| Asset-derived shape | fit every collider type to primitive mesh bounds | - -Also run `Scripting` when a managed property or physics call changes, and the graphical `Renderer` suite when debug rendering or frame-level behavior changes. - -Use `PhysicsWorld::HasBody`, `GetShapeCount`, and `GetPosition` to observe body boundaries, gathered shapes, and authored-to-world pose mapping without reaching into Box3D internals. diff --git a/.claude/skills/eppo-rendering-pipeline/SKILL.md b/.claude/skills/eppo-rendering-pipeline/SKILL.md deleted file mode 100644 index 36acf5a4..00000000 --- a/.claude/skills/eppo-rendering-pipeline/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: eppo-rendering-pipeline -description: Develop and diagnose Eppo's Vulkan and NVRHI renderer, including device and swapchain lifecycle, shader compilation and reflection, binding layouts, bindless descriptors, GPU resources, pipelines, render passes, command buffers, SceneRenderer passes, swapchain compositing, ImGui rendering, and graphical tests. Use for changes under EppoEngine/Source/Renderer, EppoEngine/Source/Platform/Vulkan, renderer-facing ImGui code, editor shaders, or the Renderer test suite. ---- - -# Eppo Rendering Pipeline - -Read [references/architecture.md](references/architecture.md) before changing renderer initialization, bindings, pass construction, or frame submission. Follow a resource from creation through ownership, descriptor registration, binding, command recording, submission, and release. - -## Workflow - -1. Identify the layer that owns the change: Vulkan platform setup, NVRHI abstraction, reusable GPU resource, shader/reflection contract, render pass, scene submission, or editor presentation. -2. Trace initialization and frame order before editing. Respect the publication order between `DeviceManager`, `Renderer`, the descriptor manager, shader loading, swapchain images, and ImGui. -3. For shader changes, update source, reflected resource expectations, C++ set/binding declarations, pipeline layouts, pass inputs, and tests together. -4. For resources, define lifetime and resize behavior. Preserve bindless handle move-only ownership and avoid retaining stale framebuffer or descriptor handles. -5. Add the smallest renderer regression. Use non-graphical construction tests only where no live device is required; otherwise use the `Renderer` graphical suite, whose `SceneRendering` tests drive end-to-end `Scene -> SceneRenderer` behavior through `TestContext`/`ScenarioLayer`. -6. Build before running graphical tests. Run from `EppoEditor/` or through CTest so shaders resolve directly from the source resources. - -## Guardrails - -- Treat descriptor set ordering as an ABI: NVRHI legacy mode maps set numbers to layout-vector indices. -- Keep resource and sampler bindless heaps independent. -- Do not assume swapchain image count equals frames in flight. -- Skip zero-sized viewport resize work and tolerate minimized windows. -- Keep Vulkan-specific code below the renderer abstraction unless the API genuinely cannot express it. diff --git a/.claude/skills/eppo-rendering-pipeline/references/architecture.md b/.claude/skills/eppo-rendering-pipeline/references/architecture.md deleted file mode 100644 index bc2082df..00000000 --- a/.claude/skills/eppo-rendering-pipeline/references/architecture.md +++ /dev/null @@ -1,113 +0,0 @@ -# Rendering architecture - -## Layer map - -| Layer | Primary files | Responsibility | -| --- | --- | --- | -| Application ownership | `Core/Application.*`, `Core/Window.*` | Create the window, device manager, renderer, ImGui layer, and drive frames. | -| API-neutral device | `Renderer/DeviceManager.*` | Select the renderer API, expose NVRHI device/swapchain state, and own `Renderer`. | -| Vulkan backend | `Platform/Vulkan/DeviceManagerVK.*`, `PhysicalDevice.*`, `LogicalDevice.*`, `Swapchain.*`, `Vulkan.h` | Create Vulkan instance/device/surface/swapchain and wrap them with NVRHI. | -| Shader backend | `Renderer/Shader.*`, `ShaderLibrary.*`, `Platform/Vulkan/VulkanShader.*` | Load sources, compile/cache SPIR-V through DXC, reflect resources, and create NVRHI shader/layout handles. | -| Resources | `Image`, `Sampler`, vertex/index/uniform/storage buffers, `Framebuffer` | Own NVRHI resources, upload data, resize, and participate in descriptors. | -| Binding | `DescriptorManager.*`, `RenderPass.*` | Own global bindless tables and pass-local binding sets/push constants. | -| Execution | `Pipeline.*`, `RenderCommandBuffer.*`, `Renderer.*` | Create graphics pipelines, record commands/timers, begin/end passes, and composite a final image to the swapchain. | -| Scene orchestration | `SceneRenderer.*`, editor shader resources | Batch scene submissions and execute geometry, sky, and wireframe passes. | -| UI | `ImGui/ImGuiRenderer.*`, `ImGuiLayer.*` | Render ImGui draw data through the same NVRHI device and bindless infrastructure. | - -## Initialization order - -1. `Application` creates a GLFW window. -2. `DeviceManager::Create` chooses `DeviceManagerVK`; its constructor gathers GLFW's required instance extensions, creates the Vulkan instance and physical/logical devices, and creates the NVRHI device. -3. `DeviceManagerVK::Init` creates the window surface and swapchain resources. -4. `DeviceManager::InitRenderer` publishes a `Renderer` owned by the device manager. The `Renderer` **constructor** creates the descriptor manager, so its global binding layouts exist before anything else runs; `Renderer::Init` then creates the swapchain composite sampler and composite command buffer. -5. `Application` calls `Renderer::LoadShaders(packedShaders, packedIncludes)` — a separate, explicit step, not part of `Renderer::Init`. It iterates the fixed `s_EngineShaderNames` set (`composite`, `geometry`, `imgui`, `skybox`, `wireframe`). Empty arguments mean compile from `Resources/Shaders`; a non-empty packed set that is missing a name, or has an entry with no sources, is an error rather than a silent disk fallback. -6. ImGui attaches **after** shader loading, because `ImGuiRenderer` grabs `GetShader("imgui")` in its constructor during `ImGuiLayer::OnAttach`. Editor layers may then create images and `SceneRenderer` resources. - -Do not move shader/resource construction earlier without rechecking calls to `DeviceManager::Get()` and `GetRenderer()->GetDescriptorManager()`, and do not fold `LoadShaders` back into `Renderer::Init` — the runtime needs to supply packed sources between the two. - -## Frame flow - -`Application::StepFrame` pumps events and, when not minimized, updates layers and submits UI around the device frame: - -1. Acquire/begin the current swapchain frame. -2. Update application layers; the editor asks `Scene` to submit to `SceneRenderer`. -3. Begin ImGui, render layer UIs, and end/record ImGui. -4. Submit recorded command lists. -5. Present the acquired swapchain image. - -Keep acquisition failure and zero-size/minimized paths safe. Swapchain resize recreates image/framebuffer state; anything caching those handles must be refreshed. - -## SceneRenderer flow - -`Scene::RenderScene` visits mesh and point-light components and submits composed world transforms plus environment data. `SceneRenderer` separates collection from execution: - -- `BeginScene` selects editor or scene camera data and resets per-frame submission state. -- `SubmitMesh` batches instances by mesh/draw key. -- `SubmitPointLight` and `SubmitEnvironment` fill scene buffers. -- `EndScene` flattens and uploads instance transforms before command recording; `PrepareRender` then uploads camera, light, and environment buffers. -- `GeometryPass` renders material geometry to the main framebuffer. -- `SkyPass` draws the environment. -- `WireframePass` draws debug colliders, selected-entity highlights, and mesh wireframes when enabled. -- `EndScene` records/submits the command buffer and exposes the final image to the editor viewport. - -`EditorLayer` calls `SetScene` every frame because edit/play transitions replace the active scene while the renderer object survives. - -## Presenting the final image - -The editor displays `SceneRenderer`'s final image as an ImGui viewport texture. A deployed runtime has no such panel, so `RuntimeLayer` calls `Renderer::CompositeToSwapchain(image)` instead: a full-screen three-vertex draw through the `composite` shader straight into the current swapchain framebuffer. - -Its pass state is **per back buffer and lazily built** — `m_CompositePasses` / `m_CompositeFramebuffers` are sized to the back-buffer count, and an entry is rebuilt when it is empty or when the swapchain handed back a different `nvrhi::FramebufferHandle` (which is what a resize looks like from here). Anything caching a framebuffer handle must follow the same compare-and-rebuild rule. - -## Shader and binding contract - -Shader sources live in `EppoEditor/Resources/Shaders`, with includes under `Resources/Shaders/Includes`. The editor and graphical tests read them directly from the `EppoEditor/` working directory. Compiled SPIR-V is cached in `FS::GetShaderCacheDirectory()` — `Resources/Shaders/Cache` when no writable directory is configured, otherwise `/ShaderCache`. - -A `ShaderSpecification` carrying `Sources` is packed: it compiles those, and resolves `#include`s only from its `Includes` map through a handler that never touches the filesystem. A deployed runtime ships no shader files, so an include missing from the pack fails the compile rather than finding a stray file on disk. Without `Sources` the shader is compiled from `Resources/Shaders` with DXC's default (disk-reading) include handler. `Renderer::LoadShaders` takes the packed set or nothing, and treats a packed entry that has no sources as missing rather than letting it degrade into a disk compile. A failed compile logs and asserts in the constructor: it means a broken editor build, and `EP_ASSERT` throws under `EP_DIST`, so a deployed game surfaces it through the runtime error dialog. - -`VulkanShader` compiles and reflects each stage. Reflection populates: - -- vertex input attributes and stride; -- resource bindings grouped by descriptor set; -- push-constant range; -- NVRHI binding layouts ordered by ascending set. - -NVRHI legacy Vulkan binding mode treats the layout vector index as the Vulkan descriptor-set number. A missing set in the middle shifts every later set. `Shader::GetBindingLayouts` therefore returns an ordered map, and `RenderPass::Bake` merges static pass bindings with global bindless layouts without gaps. - -When changing a shader binding: - -1. Update the shader declaration and stage usage. -2. Confirm reflection recognizes its NVRHI resource type and array size. -3. Update pass `SetInput(set, binding, resource)` or bindless registration. -4. Update push-constant declaration if applicable. -5. Confirm pipeline layout order and pass baking. -6. Editing a file under `Resources/Shaders/Includes` invalidates the cache on its own: the cache hash covers the top-level `.vert`/`.frag` source plus every include's contents. -7. Add or update `Shader`, `Pipeline`, or `RenderPass` tests. - -## Bindless ownership - -`DescriptorManager` owns separate resource and sampler heaps. Each heap has a binding layout, descriptor table, capacity, next sequential slot, free list, and mutex. - -`BindlessHandle` is move-only RAII. Destruction or move-assignment releases an owned slot to the originating manager through a weak reference. Preserve these invariants: - -- invalid index is `uint32_t` max; -- released slots are preferred before heap growth; -- resource and sampler indices are independent; -- growth cannot exceed the declared maximum table capacity; -- moving a handle transfers ownership exactly once; -- a resource must not outlive the descriptor data it points at unless the descriptor is rewritten or released. - -Images, uniform buffers, storage buffers, and samplers register through the appropriate heap. Materials store bindless indices rather than owning the global tables. - -## Resource and resize rules - -- `Framebuffer` owns its color/depth images and rebuilds them on resize. -- `Pipeline` derives current size from its framebuffer; resize through the owning pass/pipeline path. -- Buffer resize must preserve intended usage flags and rewrite descriptors when the underlying NVRHI handle changes. -- `RenderCommandBuffer` allocates timing data per back buffer, not merely per frame in flight. -- Use NVRHI handles for lifetime management; use raw native Vulkan handles only inside the backend and swapchain bridge. - -## Tests - -Renderer tests are registered as graphical because most require a live Vulkan/NVRHI device. The suite covers device availability, descriptor allocation/lifetime/growth, pipeline layout order, pass binding-set baking, shader layouts, framebuffer creation, command submission/timers, sampler ownership, and mesh material indices. - -Behavior that must traverse `Scene -> SceneRenderer` or editor-camera input belongs in the same suite's `SceneRendering` tests, which drive multiple frames through `TestContext`/`ScenarioLayer`. Run graphical suites only with a real display and GPU. Headless CI excludes the `graphical` label, so report any unexecuted graphical coverage explicitly. diff --git a/.claude/skills/eppo-scene-ecs-lifecycle/SKILL.md b/.claude/skills/eppo-scene-ecs-lifecycle/SKILL.md deleted file mode 100644 index 94f21d1d..00000000 --- a/.claude/skills/eppo-scene-ecs-lifecycle/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: eppo-scene-ecs-lifecycle -description: Develop and diagnose Eppo scenes and ECS behavior across EnTT entities, component ownership, UUID identity, parent-child relationships, world transforms, duplication and copying, scene serialization and repair, runtime start/update/stop, render submission, physics and scripting coordination, and scene tests. Use for changes under EppoEngine/Source/Scene or any feature that adds or changes scene components. ---- - -# Eppo Scene and ECS Lifecycle - -Read [references/architecture.md](references/architecture.md) before adding components or changing hierarchy, copy, serialization, or runtime behavior. Treat component definition, copying, persistence, editor exposure, scripting exposure, and tests as one feature surface. - -## Workflow - -1. Establish the identity and ownership effects: transient EnTT handle, stable UUID, scene pointer, asset handle, relationship UUID, or runtime-side object. -2. Add a regression first in `EppoEngineTesting/Source/Scene/`, or in Physics/Scripting when the behavior crosses those runtime systems. -3. Update every component touchpoint: `Components.h`, scene creation/copy/duplicate logic, serializer read/write, editor property UI, and managed wrappers/internal calls when exposed to scripts. -4. Preserve hierarchy consistency and world transforms through reparenting, repair, duplication, deletion, scene copy, and physics synchronization. -5. Keep runtime start/update/stop symmetric. Create runtime-only state on start and release it on stop without leaking values into the authored scene. -6. Run `Scene` plus every affected integration suite. - -## Guardrails - -- Use UUIDs across scene copies and serialization; never persist EnTT handles or `Entity` wrappers. -- Keep `m_EntityMap` synchronized with the registry. -- Treat `RelationshipComponent` as sparse: roots need not carry it. -- Iterate the whole scene through `ForEachEntity`; sort by UUID before deterministic serialization. -- Make malformed relationship data recoverable without discarding otherwise valid entities. diff --git a/.claude/skills/eppo-scene-ecs-lifecycle/references/architecture.md b/.claude/skills/eppo-scene-ecs-lifecycle/references/architecture.md deleted file mode 100644 index 257c0d78..00000000 --- a/.claude/skills/eppo-scene-ecs-lifecycle/references/architecture.md +++ /dev/null @@ -1,130 +0,0 @@ -# Scene and ECS architecture - -## Core model - -`Scene` is both an `Asset` and the owner of an `entt::registry`. It maintains an `m_EntityMap` from stable `UUID` to transient EnTT handle. `Entity` is a lightweight pair of handle and raw `Scene*`; it does not own either. - -Every entity receives: - -- `IDComponent` with stable UUID; -- `TagComponent` with display name; -- `TransformComponent` with authored local translation, Euler rotation, and scale. - -Other components are optional. `RelationshipComponent` is intentionally sparse and stores parent/children as UUIDs, not registry handles. - -## File map - -| File | Responsibility | -| --- | --- | -| `Scene/Components.h` | Native component schemas and defaults. | -| `Scene/Entity.*` | Type-safe component access over an EnTT handle. | -| `Scene/Scene.*` | Entity lifecycle, hierarchy, transforms, runtime coordination, copying, and render submission. | -| `Scene/SceneSerializer.*` | JSON persistence, field persistence, deterministic ordering, and relationship repair. | -| `Renderer/Camera/SceneCamera.*` | Projection state stored by `CameraComponent`. | -| `EppoEngineTesting/Source/Scene` | ECS, hierarchy, copy/duplicate, and malformed-serialization regressions. | - -## Identity rules - -- EnTT handles are valid only within one registry lifetime. -- `Entity` equality includes both handle and scene pointer. -- UUIDs survive serialization and `Scene::Copy` and are the only supported cross-scene identity. -- `m_EntityMap` must be updated during create, deserialize, copy, and destroy. -- Asset handles identify referenced assets such as meshes or skyboxes; they are separate from entity UUIDs. - -Capture UUID values before releasing a scene. Never dereference or inspect an `Entity` after its scene is destroyed. - -## Hierarchy and transforms - -`RelationshipComponent` stores `Parent` and `Children`. Roots normally have no relationship component. `Scene::SetParent`: - -1. Rejects self-parenting and cycles. -2. Captures the child's current world transform. -3. Removes the child from its previous parent's child list. -4. Adds/removes sparse relationship components as needed. -5. Recomputes the child's local transform beneath the new parent so the world pose stays fixed. - -`GetWorldTransform` composes local transforms up the UUID parent chain. Protect new traversal code against missing parents and cycles; malformed serialized relationships are repaired, but runtime code should not hang if invariants are temporarily broken. - -Deletion of a subtree must detach its root from the external parent, recursively destroy descendants, remove entity-map entries and script field maps, and tolerate deletion during full-scene enumeration. - -## Serialization model - -`SceneSerializer` writes scene environment and entities. Entities are sorted by UUID to produce deterministic output. Component data is stored explicitly rather than by raw memory layout. - -Deserialization creates entities by serialized UUID, populates components, restores script field values when scripting metadata is available, and then repairs relationships. Repair handles: - -- a parent that does not list the child; -- a child list that names an entity with a different parent; -- missing parent UUIDs; -- invalid parent while valid children remain; -- duplicate children; -- collider nodes detached by missing ancestry. - -Repair preserves world transforms when detaching. Notices are collected for the editor to display after load. - -When adding a component, update serialization and deserialization together. Use optional-key handling for backward compatibility when older scenes legitimately lack new properties. Defaults should produce sensible behavior. - -## Copy and duplication - -`Scene::Copy` creates a new registry and maps each source UUID to a new EnTT handle with the same UUID. Component-copy helpers copy supported component types and environment state. Script field storage remains in `ScriptEngine` under the preserved UUID, so the runtime copy reuses the authored values without copying the side table. Entity handles must never be copied directly. - -`DuplicateEntity` creates new UUIDs for the source subtree, copies copyable components and independent script field maps, recreates relationships among the duplicate nodes, and attaches the new subtree consistently. `ScriptFieldType::Entity` values are currently copied as raw UUIDs, so references still point at the original entity even when the target is inside the duplicated subtree. Decide and test whether a feature should preserve or remap those references before changing duplication semantics. - -Current recursive duplication has no visited set. It assumes a valid acyclic relationship tree; harden it before relying on duplication of malformed runtime data. Add new component types to both full-scene copy and duplicate paths. - -## Runtime lifecycle - -Runtime state belongs to the copied play scene: - -### Start - -- Warn if there is no primary camera. -- Create a `PhysicsWorld` and bodies/colliders from authored components. -- Publish the active physics world to scripting. -- Create and invoke scripts for entities with `ScriptComponent`. - -### Update - -- Step physics. -- Synchronize body poses into transforms, parents before children. -- Invoke script updates after physics. - -### Stop - -- Release physics and warnings. -- Invoke script destruction and clear instance state/context through the editor/script lifecycle. - -Keep start and stop symmetric when introducing runtime-only systems. - -## Rendering boundary - -`OnRenderEditor` uses `EditorCamera`. `OnRenderRuntime` resolves the primary `CameraComponent`; it does nothing without one. Both call `RenderScene`, which: - -- submits mesh instances using composed world transforms; -- submits point lights in world space; -- submits environment settings; -- lets `SceneRenderer` own GPU details. - -Do not place NVRHI/Vulkan command logic in `Scene`. - -## Adding a component - -Check every applicable surface: - -1. Native schema/default/copy semantics in `Components.h`. -2. Scene copy and subtree duplication. -3. JSON serialize/deserialize and backward-compatible defaults. -4. Property panel authoring and add/remove UI. -5. Runtime initialization/update/cleanup. -6. Render submission or physics integration. -7. C# wrapper, internal calls, and native registration. -8. Core umbrella header if it is a public engine type. -9. Focused suite tests plus serialization round-trip coverage. - -## Test routing - -- `Scene`: entity APIs, sparse relationships, repair, deletion during iteration, and duplication. -- `Physics`: runtime bodies, hierarchy/scale transforms, collider serialization, and copied-scene behavior. -- `Scripting`: component wrappers and script field storage, including duplicate field-map independence under the suite's shared CoreCLR harness. -- graphical `Renderer` (its `SceneRendering` tests): camera movement, loaded scene behavior, and scene-to-renderer integration. -- `Project`: scene persistence as a packed asset, when a change affects what `Game.eppak` carries. diff --git a/.claude/skills/eppo-scripting-integration/SKILL.md b/.claude/skills/eppo-scripting-integration/SKILL.md deleted file mode 100644 index f72b8a4f..00000000 --- a/.claude/skills/eppo-scripting-integration/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: eppo-scripting-integration -description: Develop and diagnose Eppo's C++/C# scripting integration across CoreCLR hosting, managed assembly discovery, native internal calls, field and method marshalling, entity script lifecycle, script hot reload and the managed build, deployment, and scripting tests. Use for changes under EppoEngine/Source/Scripting, EppoScriptCore, script-aware scene/editor code, Utility/FileWatcher or Process when driving script rebuilds, EppoScriptCore/premake5.lua, or the Scripting and ScriptMarshalling suites. ---- - -# Eppo Scripting Integration - -Read [references/architecture.md](references/architecture.md) before changing the scripting boundary. Treat the native declarations, managed exports, internal-call registration, serialized field storage, and managed test harness as one contract. - -## Workflow - -1. Trace the request through every affected layer: C# public API, managed `ScriptGlue`, native `ManagedFunctions` or `ScriptGlue`, `Assembly`, `ScriptEngine`, scene/editor lifecycle, and deployment. -2. Define the ABI before editing. Keep type widths, enum ordinals, calling conventions, entry-point names, argument order, ownership, and string allocation/freeing identical on both sides. -3. Add or update the smallest regression in `EppoEngineTesting/Source/Scripting/`. Extend `EppoEngineTesting/TestData/Scripts/Source/HarnessScript.cs` when managed user code is required. -4. Implement both sides of a cross-boundary change in the same change set. Preserve guarded behavior when the runtime, scene context, entity, component, or physics world is unavailable. -5. Rebuild `EppoEngineTesting` after any C# edit so the generated build (VS or Ninja) rebuilds and deploys both managed assemblies. -6. Run `Scripting` and `ScriptMarshalling`; run the broader headless set when lifecycle, scene, physics, or build wiring changes. - -## Guardrails - -- Initialize CoreCLR once per process; do not design tests around repeated runtime initialization. -- Keep editor field storage authoritative. Push it into new managed instances at runtime start; do not serialize transient managed values. -- Keep `ScriptEngine`'s entity-instance registry authoritative for live script existence. -- Clear scene and physics contexts before their native objects can expire. -- Never reload the user assembly while a scene context is published; defer to a later frame instead of unloading under live managed instances. -- A project with no `.csproj` is a valid, script-free project — do not turn its absence into an error that blocks play. -- Route reusable runtime APIs through `EppoEngine` and `EppoScriptCore`, not the editor. diff --git a/.claude/skills/eppo-scripting-integration/references/architecture.md b/.claude/skills/eppo-scripting-integration/references/architecture.md deleted file mode 100644 index 0de01d7e..00000000 --- a/.claude/skills/eppo-scripting-integration/references/architecture.md +++ /dev/null @@ -1,116 +0,0 @@ -# Scripting architecture - -## File map - -| Area | Primary files | Responsibility | -| --- | --- | --- | -| Runtime host | `EppoEngine/Source/Scripting/RuntimeHost.*`, `Platform.h`, vendored `hostfxr.h` and `coreclr_delegates.h` | Locate hostfxr, load it, initialize from `runtimeconfig.json`, and resolve unmanaged entry points. | -| Managed assembly facade | `Assembly.*`, `ManagedFunctions.h` | Resolve the exported managed function table, register native internal calls, load/unload the user assembly, and cache reflected metadata. | -| Engine lifecycle | `ScriptEngine.*`, `ScriptInstance.*`, `ScriptClass.*`, `ScriptField.h` | Own the core assembly facade, live entity instances, editor field storage, and active scene/physics contexts. | -| Native callbacks | `ScriptGlue.*` | Implement callbacks invoked by managed public APIs and publish the name/function table consumed by `Assembly::RegisterInternalCalls`. | -| Managed bridge | `EppoScriptCore/Source/Core/ScriptGlue.cs`, `InternalCalls.cs` | Export unmanaged entry points, discover user types, own managed instances, marshal calls, and store registered native pointers. | -| Public C# API | `EppoScriptCore/Source/Scene`, `Physics`, `Core`, `Math` | Present user-facing entities, components, input, logging, physics, key codes, and blittable vector types. | -| Build/deploy | `EppoScriptCore/premake5.lua`, `EppoEngineTesting/TestData/Scripts/premake5.lua`, target `premake5.lua` files, `EppoEditor/runtimeconfig.json` | Build `EppoScriptCore.dll`, build the test user assembly, and copy managed outputs beside native executables. | -| Tests | `EppoEngineTesting/Source/Scripting`, `TestData/Scripts` | Exercise reflection, lifecycle, fields, method invocation, internal calls, exceptions, and layout. | - -## Boot and assembly flow - -1. `EditorLayer::OpenProject` builds the project's C# assembly before opening its start scene. -2. `ScriptEngine::Init(runtimeConfigPath)` constructs the singleton and its `Assembly`. -3. `Assembly` constructs `RuntimeHost`, resolves `EppoScriptCore.ScriptGlue` exports, bootstraps managed state, and registers every native internal call by name. -4. `LoadUserAssembly` enters a collectible managed load context, discovers non-core subclasses of `Eppo.Scene.Entity`, and rebuilds native `ScriptClass` metadata. -5. Scene deserialization can then restore editor-time field storage against available field metadata. It does not reject unknown class names; the property panel and runtime instance creation report class validity later. - -CoreCLR is process-global in practice. `ScriptEngine::Shutdown` ends engine ownership, but tests must share one initialization rather than repeatedly booting CoreCLR. - -## Build and hot reload - -`ReloadProjectAssembly` is the single path that turns C# sources into a loaded assembly, used both for the initial project open and for reloads. It: - -1. Clears `m_UserAssemblyValid` up front, so a failure anywhere below leaves scripting explicitly invalid rather than stale-but-apparently-fine. -2. Treats a project with no `.csproj` as a **valid** state — logs, marks valid, returns true. Absence of scripts must not block play. -3. Installs the `FileWatcher` on `Project::GetScriptsDirectory()` *before* building, so a project that opens with broken sources still reloads once the user fixes them. -4. Runs `dotnet build` through `Utility/Process::RunProcess` into `Project::GetCacheDirectory() / "Scripts"`, passing `-p:CoreManagedDll=` pointed at this build's `EppoScriptCore.dll` rather than a baked-in path. -5. Verifies the assembly exists, then `UnloadUserAssembly` (which also clears `m_EntityInstances`) followed by `LoadUserAssembly`. - -`ScriptEngine::VerifyRuntime`, called per frame, is the reload trigger and deliberately does nothing eagerly: - -- A change reported by `FileWatcher::ConsumeChange` only sets `m_ReloadPending` and returns, so a burst of editor saves collapses into a single build one frame later. -- A pending reload is skipped entirely while `GetSceneContext()` is non-null — that means play mode, and swapping assemblies under live managed instances is not supported. - -Editor field storage (`m_FieldStorage`) survives a reload because it is keyed by entity UUID and lives outside the assembly; live `ScriptInstance`s do not. Reflected `ScriptClass` metadata is rebuilt from scratch, so any cached class index is invalid after a reload. - -## Runtime entity flow - -`EditorLayer::OnScenePlay` copies the authored scene. `Scene::OnRuntimeStart` creates physics, then calls `ScriptEngine::OnCreateEntity` for each `ScriptComponent`. Creation resolves the class index, creates a managed instance keyed by entity UUID, copies serialized editor fields into it, and invokes `OnCreate`. - -`Scene` owns publishing both scripting contexts. `Scene::OnRuntimeStart` sets the active physics world and the scene context (via `shared_from_this`) *before* the `OnCreateEntity` loop, so entity, component and physics internal calls all resolve from managed `OnCreate`. Hosts must not publish the scene context themselves; `EditorLayer` deliberately does not. - -Each runtime update steps physics first and then invokes `OnUpdate`. On stop, `Scene::OnRuntimeStop` invokes `OnDestroy` and destroys live instances *first* — while both the scene context and the physics world are still published — then clears the scene context and releases physics. Managed `OnDestroy` can therefore still resolve its entity, other entities, components and the running simulation. Treat any reordering of these four steps as a lifecycle change requiring explicit tests; the `Scene_OnRuntimeStart_*` / `Scene_OnRuntimeStop_*` tests in the Scripting suite cover it. - -The ownership split is deliberate: - -- `ScriptEngine` owns one native `ScriptInstance` per running entity. -- Managed `ScriptGlue` owns the actual C# object in an entity-ID keyed registry. -- The native handle stores the assembly pointer, raw 64-bit entity ID, and class index. -- The scene owns `ScriptComponent`; editor field values live in `ScriptEngine::m_FieldStorage`, keyed by stable UUID. - -## ABI contracts - -Keep these synchronized: - -- Export names in `[UnmanagedCallersOnly(EntryPoint = ...)]`, the function-pointer lookup strings in `Assembly`, typedefs in `ManagedFunctions.h`, and call sites. -- `ScriptFieldType` member order and underlying byte width in C++ and C#. -- Blittable layouts for `Vector2`, `Vector3`, `Vector4`, primitive field types, entity IDs, and method argument/return buffers. Managed vectors use sequential layout; preserve the native assumptions such as a 12-byte `glm::vec3`/managed `Vector3` contract. -- C# internal-call delegate signatures, registration names, and C++ callback signatures. -- Boolean and character widths; do not assume C++ `bool` or `char` matches an arbitrary managed declaration without an explicit existing contract. -- Native strings returned by managed exports: managed allocation must be released through the exported `FreeString` path. - -`ScriptFieldValue` stores up to 16 bytes with 8-byte alignment. `ScriptFieldTypeSize` is the shared native width authority. `ScriptMarshalling` must cover any new field type. - -## Adding a managed component API - -1. Add or confirm the native scene component. -2. Add the managed wrapper property or method in `Components.cs`. -3. Add the internal-call delegate and invocation in `InternalCalls.cs`. -4. Add the C++ callback in `ScriptGlue.h/.cpp` and publish it under the exact managed name from `ScriptGlue::GetInternalCalls`. -5. Leave `Assembly::RegisterInternalCalls` as the generic table consumer unless the registration mechanism itself changes. -6. Validate scene context, entity lookup, and component presence in the callback. Follow existing safe defaults for reads and no-op writes. -7. Add C# harness behavior only when the call must originate from user code; otherwise direct method invocation through reflected harness methods may suffice. -8. Exercise the public C# wrapper in the managed harness rather than testing only a raw `InternalCalls` method. Test getters and setters independently, plus missing-context behavior when meaningful. - -`TransformComponent::Rotation` is authored as XYZ Euler radians and passed to `glm::quat`; do not silently expose degrees or a quaternion in C#. Direct transform setters mutate ECS state only. They do not teleport an active physics body, whose simulated pose can overwrite the component on a later runtime update. - -## Adding an exported managed operation - -1. Define the managed `[UnmanagedCallersOnly]` method and keep exceptions behind the managed guard. -2. Add the matching typedef and field to `ManagedFunctions`. -3. Resolve it in `Assembly::ResolveManagedFunctions`; scripting is unavailable when required functions cannot bind. -4. Add the guarded `Assembly` facade operation and then expose it through `ScriptEngine`, `ScriptClass`, or `ScriptInstance` as appropriate. -5. Add an ABI/lifecycle regression. - -## Field persistence - -- Reflected `ScriptField` metadata describes a class field; it does not store an entity value. -- `ScriptEngine`'s `ScriptFieldMap` is the serialized editor value side table. -- Play-scene copy preserves UUIDs, so it intentionally resolves the same stored editor field map without cloning it. -- Entity duplication must copy the source field map to the new UUID. -- Entity destruction must remove its field storage. -- Runtime edits affect the live instance; replay starts again from stored editor values. -- Scene serialization should only write values compatible with the currently reflected field type. - -## Build and test details - -Use the required order: - -```text -Scripts\Setup.bat --action vs2026 # generate (sh Scripts/setup.sh on Linux) -# build EppoEngineTesting: in the generated VS solution, or `ninja EppoEngineTesting_Debug_x64` -ctest --test-dir build/bin/Debug-windows-x86_64 -R "Scripting|ScriptMarshalling" --output-on-failure -``` - -Each target's `premake5.lua` post-build commands place `EppoScriptCore.dll`, PDB/deps files, and `runtimeconfig.json` beside the executable, and the harness project (`EppoEngineTesting/TestData/Scripts/premake5.lua`) builds and deploys `EppoTesting.Scripts.dll`. Those managed files are resolved through `FS::GetExecutableDirectory()`, independent of cwd. Run tests through CTest so their editor resources resolve from the configured `EppoEditor/` working directory. - -Use `Scripting` for discovery, invocation, lifecycle, field values, internal calls, managed exceptions, and C# API behavior. Use `ScriptMarshalling` for enum widths and buffer layout. Also run `Scene` for serialization/copy changes and `Physics` for managed physics changes. - -Field-map tests that need reflected field metadata require the suite's shared initialized CoreCLR harness; place them in `Scripting`, not a standalone headless Scene test that initializes and tears down the runtime independently. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c430d861..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,151 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project - -EppoEngine — a C++20 cross-platform (Windows/Linux) game engine + editor with C# scripting via CoreCLR (.NET 10) and Vulkan rendering through NVRHI. Built with Premake 5.0.0-beta8 + vcpkg (manifest mode). AGENTS.md holds the same core guidance for other agents; keep the two in sync when editing either. - -## Prerequisites (validated by `Scripts/Setup.py`) - -- **Vulkan SDK** with `dxc` (`VULKAN_SDK` set). -- **vcpkg** is discovered through `VCPKG_ROOT`, then `PATH`, or provisioned locally with permission. Manifest mode; overlay ports are in `Dependencies/Ports` (imguifiledialog, nvrhi, tinygltf). -- **.NET SDK 10** with `DOTNET_ROOT` set (managed core targets `net10.0`). -- **compiler**: Windows defaults to MSVC and optionally supports Clang; Linux uses `clang`/`clang++`. -- **Ninja 1.6+** on Linux. -- **CTest 3.21+** for running the generated standalone test manifests. -- Linux also needs `uuid-dev` to build Premake and X11/GL dev libs: `libxinerama-dev libxcursor-dev xorg-dev libglu1-mesa-dev pkg-config`. Ubuntu's `cmake` package supplies the standalone CTest executable; Eppo does not use CMake to generate or build. - -## Commands - -Run `Scripts\Setup.bat` on Windows and choose VS2022 or VS2026. Run `sh Scripts/setup.sh` on Linux for Ninja + Clang. Windows generates one `EppoEngine` solution; its real C# projects are grouped under EppoScriptCore and map solution Dist to managed Release. Generated solutions/build files are used directly; there is no build wrapper. Binary dirs are `build/bin/--x86_64/`. -Run `Scripts\GenerateBuildFiles.bat` on Windows or `sh Scripts/generatebuildfiles.sh` on Linux to only rerun Premake (`Setup.py --generate-only`) after the first setup: it reuses the action, compiler, Premake and vcpkg root recorded in `.eppo/build.json`, skips tool provisioning and `vcpkg install`, and never prompts. `--action`/`--compiler` still override. -Run `Scripts\Clean.bat` on Windows or `sh Scripts/clean.sh` on Linux to remove all setup and build outputs, including locally provisioned tools under `.eppo`. - -```bash -Scripts\Setup.bat --action vs2026 # generate Visual Studio 2026 on Windows -Scripts\GenerateBuildFiles.bat # regenerate only, reusing the recorded setup -# Build EppoEngineTesting in the generated solution -ctest --test-dir build/bin/Debug-windows-x86_64 --output-on-failure -ctest --test-dir build/bin/Debug-windows-x86_64 --label-exclude graphical -ctest --test-dir build/bin/Debug-windows-x86_64 -R Scripting -``` - -Run a suite directly from `EppoEditor/` so source resources resolve correctly: `../build/bin/Debug-windows-x86_64/EppoEngineTesting/EppoEngineTesting --gtest_filter=Scripting.*` (Google Test; suite = the first `TEST(Suite, Name)` argument). CTest passes exactly this filter per suite. -Suites and labels (registered in `Scripts/Premake/Testing.lua`): `Core`, `Physics`, `Scene` (`core`); `Project` (`unit`); `Scripting`, `ScriptMarshalling` (`scripting`); `App`, `ProjectExport`, `Renderer` (`graphical`). -Visual Studio's built-in Test Adapter for Google Test discovers the suite in Test Explorer with no per-developer setup. The runner's `main.cpp` `chdir`s to `EppoEditor` on startup (via the premake-baked `EP_TEST_WORKING_DIR`), so `Resources/`/`Projects/`/`TestData/` resolve for graphical and data-driven suites regardless of how the exe is launched (Test Explorer runs it from the output dir; CTest also sets `WORKING_DIRECTORY`). - -Required order: **generate → build → test**. After editing C# only, rebuild the `EppoEngineTesting` (or `EppoEditor`) target so the dotnet custom commands re-run and DLLs are re-copied. - -## Architecture - -### Targets - -- `EppoEngine/` — static library, the engine. `Source/` modules: `Asset`, `Core`, `Event`, `ImGui`, `Physics`, `Platform`, `Project`, `Renderer`, `Scene`, `Scripting`, `Utility`. Public umbrella header `Source/EppoEngine.h`; PCH `Source/pch.h`. `Core/Buffer/` is the binary serialization substrate: abstract `StreamWriter`/`StreamReader` with `Buffer*` (in-memory) and `FileStream*` (on-disk) implementations, plus paired `StreamSerializable`/`StreamDeserializable` concepts backing `WriteObject`/`ReadObject`. `GameData` is built on it. -- `EppoEditor/` — editor executable (`EppoEditor.cpp` → `EditorLayer`). Depends on `EppoEngine` + `EppoScriptCore`. Owns `Resources/` and `runtimeconfig.json`. -- `EppoScriptCore/` — C# class library (net10.0). Visual Studio exposes the real `.csproj` in the EppoScriptCore solution group and maps solution Dist to managed Release. Ninja invokes `dotnet` through the project Premake definition. Namespaces mirror the folder path minus `Source/`. -- `EppoEngineTesting/` — Google Test runner (custom `main.cpp` wraps `RUN_ALL_TESTS` with logging + `AppHarness::Shutdown`). `Source/` suites mirror engine modules; `Source/Support/` has `AppHarness` (boots a real `Application` for graphical suites), `TestContext` + `ScenarioLayer` (multi-frame scene/camera scenarios, used by the `Renderer` suite), and the `EppoTest.h` / `GlmCheck.h` / `TempDir.h` helpers. `EppoTest.h` provides `EP_REQUIRE`/`EP_REQUIRE_EQ` (a fatal check usable in value-returning helpers where `ASSERT_*` cannot) and `EP_EXPECT_ARRAY_EQ`; `GlmCheck.h` keeps `CHECK_VEC*/MAT4_CLOSE` on `EXPECT_NEAR`. `TestData/Scripts/` builds the `EppoTesting.Scripts.dll` harness the Scripting suite loads. Suites are registered in `Scripts/Premake/Testing.lua`. -- `EppoRuntime/` — standalone player. Reads `Game.eppak` before creating the application, since its engine shaders come from there. It stages **no** `Resources/`: shader sources and their includes travel in the pack, and it never reads them from disk. Logs and its shader cache are written beside the executable. -- `Scripts/Premake/` — shared dependency names and standalone CTest manifest generation. Each native target owns a `premake5.lua`; vcpkg overlays live under `Dependencies/Ports`. - -Key libraries: entt (ECS), NVRHI (Vulkan RHI), GLFW + ImGui (docking), glm, box3d (physics), spdlog, tinygltf, Tracy, Google Test. - -### Packaging and the deployed runtime (spans Project, Asset, Renderer, EppoRuntime) - -- `ProjectExporter::Export` stages the runtime executable and writes `Game.eppak`. `GameData` owns the byte layout — it is documented in full at the top of `Project/GameData.h`, and every section is read/written through the `Core/Buffer/` streams. -- `Asset/PackFormat.h` holds the four-character magics and versions (`EPAK` package, `ESHD` shaders, `EMSH` mesh, `ESCN` scene). Bump the version whenever a layout changes. -- Shaders are packed as **text**, keyed by name, alongside their `#include` sources keyed by path relative to `Resources/Shaders`. A packed `ShaderSpecification` carries both and resolves includes through a handler that never touches the filesystem, so a deployed game cannot silently fall back to a disk compile. -- `AssetManager` has a packed mode (constructed with owned `PackedAssetData` payloads) that loads lazily from memory instead of disk. There is no `PackedAssetManager` class — the test file of that name exercises `AssetManager`. -- `ApplicationParams::PackedShaders` / `PackedShaderIncludes` carry the shader text from the pack into `Renderer::LoadShaders`, which the `Application` constructor calls after `InitRenderer()` and **before** ImGui attaches (`ImGuiRenderer` grabs `GetShader("imgui")` during `ImGuiLayer::OnAttach`). Empty means "compile from `Resources/Shaders`", which is what the editor and tests do. - -### C#↔C++ scripting bridge (spans both languages — read as one system) - -- `Scripting/RuntimeHost` boots CoreCLR via hostfxr using the `runtimeconfig.json` next to the exe; **CoreCLR initializes once per process** and cannot be re-initialized. -- `ScriptEngine` (singleton, `Init`/`Shutdown`) loads `EppoScriptCore.dll` (core assembly) plus a user assembly, holds per-entity `ScriptInstance`s keyed by entity UUID, and owns the editor-time field side table (`ScriptFieldMap`) — the authoritative, serialized copy of script fields, pushed into the managed instance on create. -- `ScriptGlue.cpp` registers the native functions; on the C# side `EppoScriptCore/Source/Core/InternalCalls.cs` is the **sole unsafe hub** — all `[UnmanagedCallersOnly]`/extern glue lives there, wrapped by friendly APIs (`Entity`, `Components`, `Input`, `Log`, `Physics`). -- Internal-call conventions: structs passed by pointer, entity UUID is the first argument, the live scene is resolved through `ScriptEngine`'s scene context (set on play, cleared on stop/unload). The active `PhysicsWorld` is held weakly so callbacks no-op after scene stop. -- The scene drives per-entity script lifecycle (`OnCreateEntity`/`OnUpdateEntity`/`OnDestroyEntity`). -- **Hot reload:** `ScriptEngine` owns a `FileWatcher` over the project's `Scripts` directory, polled from `VerifyRuntime`. A detected change only sets `m_ReloadPending` and returns — the rebuild happens on a later frame, so a burst of saves collapses into one build, and it is skipped entirely while a scene context is set (i.e. during play). `ReloadProjectAssembly` shells out to `dotnet build` via `Utility/Process`, then unloads and reloads the collectible user assembly. Editor field storage survives; live managed instances do not. - -## Gotchas - -- **Run the editor from `EppoEditor/`; run tests through CTest.** The editor and graphical tests resolve `Resources/` and `Projects/` from the working directory, while `runtimeconfig.json`, `EppoScriptCore.dll`, and test assemblies resolve beside their executable. CTest sets the source working directory automatically. -- **Managed projects follow the generated build system.** Visual Studio builds the real `.csproj` projects; Ninja invokes `dotnet` custom rules. Post-build steps copy managed outputs beside the native executable. -- **Graphical suites (`App`, `ProjectExport`, `Renderer`) need a real display + GPU.** They early-return if `AppHarness` can't boot; on headless/CI use `--label-exclude graphical`. -- **"SPIR-V CodeGen not available"** at runtime means the Microsoft `dxcompiler.dll` is shadowing the Vulkan SDK one; copy the Vulkan SDK's `dxcompiler.dll` next to the exe. -- **`EppoRuntime` owns its entry point.** It defines `EP_CUSTOM_ENTRY_POINT` (suppressing the `main` in `Core/EntryPoint.h`) and calls `Eppo::RunApplication` from its own `WinMain`/`main`, so it can wrap startup in a try/catch that reports through `ErrorDialog`. It reads `Game.eppak` inside `CreateApplication` — before the `Application` exists — because the shaders it hands to `ApplicationParams` are needed during construction. -- **Where files get written is configured, not assumed.** `FS::ConfigureWritableDirectory` sets the root that `FS::GetWritableDirectory` and `FS::GetShaderCacheDirectory` resolve against; the runtime points it at its own executable directory so logs and the shader cache land beside the game. Unconfigured, the shader cache falls back to `Resources/Shaders/Cache`. -- **Platform/config macros:** `EP_PLATFORM_WINDOWS`/`EP_PLATFORM_LINUX`; `EP_DEBUG`/`EP_RELEASE`/`EP_DIST`; `TRACY_ENABLE` in Debug and RelWithDebInfo. Linux defines `__EMULATE_UUID`. -- **`UUID::operator bool` is explicit.** Use `static_cast(uuid)` to get the raw id; implicit numeric conversion is a compile error by design. -- **`RelationshipComponent` is optional.** An entity with no parent or children has no relationship component. Readers guard its absence with `HasComponent`; parenting adds it lazily and unparenting removes it when empty. -- **Scene graph has two walk directions that can disagree.** The hierarchy panel and `Scene::GatherColliders` walk **down** via `Children`; `GetWorldTransform` and the collider wireframe pass walk **up** via `Parent` / iterate the whole registry (`ForEachEntity`). A one-directional link (child names a parent that doesn't list it back, e.g. a scene stored with only the child's `Parent`) is invisible to the down-walkers but still rendered — an entity you can't select/delete whose collider keeps drawing, and whose collider never joins the compound body. `SceneSerializer::Deserialize` must reconcile both directions on load. -- **Launch the editor and capture its startup log to observe runtime state.** This is *not* headless — it spins up the full GUI app (real window, Vulkan swapchain, ImGui, file dialogs); there is no headless editor run (it needs a display + GPU, same as the graphical test harness). From `EppoEditor/`, run `../build/bin/Debug-windows-x86_64/EppoEditor/EppoEditor.exe > out.txt 2>&1 &`, wait a few seconds, `taskkill //IM EppoEditor.exe //F`. It loads the project default scene and logs to `latest.log` + stdout; useful for confirming startup or a fix in the real app rather than trusting tests alone. Describe such runs as "launched the editor and checked its log," never as "headless." -- **Drive the real editor for visual feature verification.** On Windows, launch the built `EppoEditor.exe` with `EppoEditor/` as its working directory, focus its window, and use OS input automation (`user32` cursor/mouse calls plus `SendKeys`) to exercise ImGui. Select the hierarchy entity before coordinate-based property edits; double-click numeric drag fields to enter text. Keep verification edits unsaved, capture the editor window with `GetWindowRect` + `Graphics.CopyFromScreen`, and select a different entity when you need to distinguish persistent scene visualization from ImGuizmo. Stop only the editor process you launched and keep captures outside the repository. - -## Style - -Conventions below are near-universal in `Core`, `Platform/Vulkan` and `Renderer` — treat a deviation as a mistake, not a choice. - -**Formatting** - -- `.clang-format`: 4-space indent, 140-col limit, Allman braces, pointer left (`int* p`), `SortIncludes: Never`, namespaces indented. -- **Indentation is 4 spaces.** Remaining tabs in the older engine files are legacy, not a convention, and are pending a one-time repo-wide conversion. Write new and edited code with spaces; don't copy a tab-indented neighbour's whitespace. -- `.clang-tidy`: `bugprone-*`, `clang-diagnostic-*`, `clang-analyzer-*`, `cppcoreguidelines-*`, `modernize-*`, `misc-use-anonymous-namespace`. - -**Declarations** - -- **Trailing return types, everywhere**: `auto Name(args) -> T`, including `-> void`. This covers members, free functions, lambdas (`[this](Event& e) -> void`), `main`, `WinMain`, and friend declarations. There is not one classic `bool Foo()` declaration in the engine. -- `[[nodiscard]]` on const getters and anything returning a computed value; not on mutating `-> void`. Trivial getters are `constexpr` and defined inline in the header. -- `const` on by-value params in definitions (`auto WriteData(const char* data, const size_t size)`); `const auto` for locals by default. -- Concepts over SFINAE — `StreamSerializable`, `ResourceType`, `requires(std::derived_from)`. - -**Naming and layout** - -- `m_` members, `s_` statics and file-scope constants, `g_` globals. PascalCase for methods and public struct fields; camelCase for params and locals. Getters are `Get*` or `Is*`. -- Headers use `#pragma once`, never include guards. Class body order is `public:` → `private:` methods → a **second** `private:` for data members. -- Configurable types take one `XSpecification`/`XParams` struct with PascalCase fields and in-class defaults, constructed at the call site with designated initializers (`WindowSpecification{ .Title = ..., .Width = ... }`). -- File-local helpers go in an anonymous namespace nested inside `namespace Eppo` — never `static` free functions. - -**Includes** - -- `.cpp` files open with `#include "pch.h"`, then the file's own header, blank line, then project headers (quoted, module-relative from `Source/`), then third-party `<...>`, then std `<...>`. `SortIncludes: Never`, so this order is hand-maintained. -- `pch.h` already supplies the common std headers plus `Core/Base.h`, `Core/Buffer/Buffer.h`, `Core/Hash.h`, `Core/UUID.h`, `Utility/Filesystem.h`, `Utility/Random.h`. Engine headers rely on it (`Renderer/Image.h` names `Buffer` and `std::filesystem::path` with no include of its own) — don't add redundant includes for these. -- Forward-declare only to break include cycles; otherwise `#include`. - -**Engine vocabulary** (`Core/Base.h`) - -- `Ref`/`CreateRef` (shared), `ScopedPtr`/`CreateScopedPtr` (unique), `WeakRef`. Use `static auto Create(...)` factories where the constructor is private or construction can fail (`Sampler`, `Shader`, `DeviceManager`). -- **`EP_ASSERT` is a `constexpr` function, not a macro**: `EP_ASSERT(cond, "message")`. Do not copy the older `EP_ASSERT(false && "msg")` form still present in a few files — the `&&` collapses to a plain `false` and the message is silently discarded. -- `EP_PROFILE_FN("Scope::Name")` as the first statement of a hot function, no trailing semicolon. - -**Prose and error handling** - -- Comments: zero is the default. Add one only for a non-obvious "why", max 1 line. Never restate what the code does. `///` doc comments are rare — reserved for public serialization/lifecycle APIs. -- On error paths, log via `Log::` rather than silently returning. Guard clauses with early return; no braces around single-statement bodies. -- Don't add synonym APIs — if equivalent functionality exists, point the caller at it. - -## Domain skills - -Seven domain skills live in `.claude/skills/` (each `SKILL.md` + `references/architecture.md`). Read the matching skill before investigating or changing a major subsystem; use every applicable skill for cross-system work. They are full copies of the Codex skills in `.agents/skills/` — when editing a skill, apply the same change to both trees. - -- `eppo-scripting-integration` — CoreCLR hosting, native/managed ABI, assemblies, ScriptGlue, fields, lifecycle, deployment, and scripting tests. -- `eppo-rendering-pipeline` — Vulkan/NVRHI devices, shaders, descriptors, GPU resources, render passes, SceneRenderer, and graphical tests. -- `eppo-editor-development` — EditorLayer state, edit/play transitions, panels, viewport input, gizmos, projects, scenes, and content browsing. -- `eppo-scene-ecs-lifecycle` — EnTT entities, UUIDs, relationships, transforms, copy/duplication, serialization, runtime systems, and scene tests. -- `eppo-physics-integration` — Box3D bodies, hierarchy-aware colliders, transform conversion, runtime synchronization, scripting, and physics tests. -- `eppo-assets-and-projects` — asset handles, registry persistence, paths, loading/import/export, project lifecycle, `Game.eppak` packaging, and content-browser coordination. -- `eppo-application-framework` — application/frame lifecycle, layers, windows, events, input, ImGui, startup order, the deployed runtime, and application harnesses. - -## Workflow rules (required) - -- **Discover worktrees first.** Before inspecting, editing, building, or testing, run `git worktree list` from the repository and identify the worktree that contains the task. Never assume the primary checkout is the target; use the selected worktree consistently for every command. -- **Plan before code.** For anything beyond a trivial change, write a plan first and confirm key decisions (including naming/layout choices) with the user before implementing. -- **Test-driven development.** Write the test first as `TEST(Suite, Name)` (Google Test) in the matching `EppoEngineTesting/Source//` file; a suite is just the shared first argument, so a new suite must also be registered in `Scripts/Premake/Testing.lua`. Use `EXPECT_*`/`ASSERT_*`, `EP_REQUIRE` for fatal checks inside value-returning helpers, and the `CHECK_VEC*_CLOSE` glm helpers. Name suites/tests after the class/behaviour under test, not the goal ("Smoke"/"Sanity" are banned). Critical bug fixes get a regression test. -- **Systematic debugging.** Root cause before fix; no patching symptoms. -- **Code review via subagent** after substantial changes — do not review your own work. -- **No formatting changes to existing code.** Don't reindent or reflow lines you aren't otherwise editing, and never run clang-format across a file you didn't create. New and edited lines use 4 spaces (see Style); the tab-to-space conversion of legacy files is a deliberate, separately-run pass, not something to do as a drive-by. -- **Verify before claiming done.** Run the relevant build + `ctest` and confirm it passes. A green build alone does not verify editor/GUI behaviour — state what was actually verified. - -## CI - -GitLab CI (`.gitlab-ci.yml`): runs on MRs, `master`, `develop`, `feature/*`, `test/*`. On Linux it generates Ninja with Premake beta8, builds only `EppoEngineTesting_Debug_x64`, runs `ctest --label-exclude graphical`, and publishes JUnit. The toolchain is baked into `.gitlab/ci/Dockerfile`; the vcpkg binary cache is keyed on `vcpkg.json` + `vcpkg-configuration.json`. Use `glab` CLI for MR operations. diff --git a/EppoEditor/Projects/Test/Assets/AssetRegistry.json b/EppoEditor/Projects/Test/Assets/AssetRegistry.json index 1f3c7b35..136b2111 100644 --- a/EppoEditor/Projects/Test/Assets/AssetRegistry.json +++ b/EppoEditor/Projects/Test/Assets/AssetRegistry.json @@ -1,24 +1,34 @@ { "Assets": [ + { + "Filepath": "Meshes\\main_sponza\\NewSponza_Main_glTF_003.gltf", + "Handle": 2615731145679736945, + "Type": "Mesh" + }, { "Filepath": "Textures\\kloofendal_48d_partly_cloudy_puresky_2k.hdr", "Handle": 9446329736917411582, "Type": "Texture" }, { - "Filepath": "Meshes\\player.glb", - "Handle": 17559226157527298045, - "Type": "Mesh" + "Filepath": "Scenes\\Sponza.epscene", + "Handle": 11902558459493855131, + "Type": "Scene" }, { "Filepath": "Meshes\\pbr_showcase.gltf", "Handle": 16319850260215055169, "Type": "Mesh" }, + { + "Filepath": "Meshes\\player.glb", + "Handle": 17559226157527298045, + "Type": "Mesh" + }, { "Filepath": "Scenes\\Def.epscene", "Handle": 18159251541775323644, "Type": "Scene" } ] -} +} \ No newline at end of file diff --git a/EppoEditor/Projects/Test/Assets/Scenes/Sponza.epscene b/EppoEditor/Projects/Test/Assets/Scenes/Sponza.epscene new file mode 100644 index 00000000..b6867bf1 --- /dev/null +++ b/EppoEditor/Projects/Test/Assets/Scenes/Sponza.epscene @@ -0,0 +1,131 @@ +{ + "Scene": { + "Bloom": { + "Intensity": 0.03999999910593033, + "Knee": 0.25, + "Radius": 1.0, + "Threshold": 0.5 + }, + "Entities": [ + { + "DirectionalLightComponent": { + "Color": [ + 1.0, + 0.6704390645027161, + 0.14893615245819092 + ], + "Intensity": 1.0 + }, + "IDComponent": { + "ID": 3471340695254829780 + }, + "TagComponent": { + "Tag": "Sun" + }, + "TransformComponent": { + "Rotation": [ + 0.0, + 0.0, + 0.0 + ], + "Scale": [ + 1.0, + 1.0, + 1.0 + ], + "Translation": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + { + "IDComponent": { + "ID": 4122832582716333473 + }, + "MeshComponent": { + "MeshHandle": 2615731145679736945 + }, + "TagComponent": { + "Tag": "Sponza" + }, + "TransformComponent": { + "Rotation": [ + 0.0, + 0.0, + 0.0 + ], + "Scale": [ + 1.0, + 1.0, + 1.0 + ], + "Translation": [ + 0.0, + 0.0, + 0.0 + ] + } + }, + { + "CameraComponent": { + "FarClip": 1000.0, + "NearClip": 0.10000000149011612, + "Primary": true, + "VerticalFov": 45.0 + }, + "IDComponent": { + "ID": 8137969319758298661 + }, + "TagComponent": { + "Tag": "Camera" + }, + "TransformComponent": { + "Rotation": [ + 0.0, + 0.0, + 0.0 + ], + "Scale": [ + 1.0, + 1.0, + 1.0 + ], + "Translation": [ + 0.0, + 0.0, + 0.0 + ] + } + } + ], + "Environment": { + "AmbientIntensity": 1.0, + "GroundColor": [ + 0.20000000298023224, + 0.17000000178813934, + 0.12999999523162842 + ], + "HorizonColor": [ + 0.6499999761581421, + 0.6600000262260437, + 0.6700000166893005 + ], + "SkyboxHandle": 0, + "ZenithColor": [ + 0.3499999940395355, + 0.44999998807907104, + 0.550000011920929 + ] + }, + "Handle": 11902558459493855131, + "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/Source/EditorLayer.cpp b/EppoEditor/Source/EditorLayer.cpp index 5328a9da..a0f4d8b6 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(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); + } // 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,557 @@ 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::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 +847,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 +870,379 @@ 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::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..d446b348 --- /dev/null +++ b/EppoEditor/Source/StatusBar.cpp @@ -0,0 +1,167 @@ +#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) + { + 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 = + snapshot.Total > 0 ? static_cast(snapshot.Completed) / static_cast(snapshot.Total) : 0.0f; + ImGui::ProgressBar(frac, ImVec2(-1.0f, 0.0f)); + ImGui::SameLine(0.0f, 8.0f); + ImGui::TextDisabled("%u/%u", snapshot.Completed, snapshot.Total); + + std::string status; + if (snapshot.Running > 0) + status += std::format("{} running", snapshot.Running); + if (snapshot.Pending > 0) + { + if (!status.empty()) + status += ", "; + status += std::format("{} pending", snapshot.Pending); + } + if (snapshot.Failed > 0) + { + if (!status.empty()) + status += ", "; + status += std::format("{} failed", snapshot.Failed); + } + if (snapshot.Cancelled > 0) + { + if (!status.empty()) + status += ", "; + status += std::format("{} cancelled", snapshot.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/AssetManager.cpp b/EppoEngine/Source/Asset/AssetManager.cpp index a55408f7..0f04536c 100644 --- a/EppoEngine/Source/Asset/AssetManager.cpp +++ b/EppoEngine/Source/Asset/AssetManager.cpp @@ -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 @@ -185,6 +185,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 +307,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 d7485e44..00a6ba96 100644 --- a/EppoEngine/Source/Core/Application.cpp +++ b/EppoEngine/Source/Core/Application.cpp @@ -59,6 +59,8 @@ namespace Eppo { Log::Info("Application shutting down..."); + m_ThreadPool->Shutdown(true); + m_ImGuiLayer.reset(); for (auto it = m_LayerStack.begin(); it != m_LayerStack.end();) @@ -68,7 +70,6 @@ namespace Eppo it = m_LayerStack.erase(it); } - m_ThreadPool->Shutdown(true); m_DeviceManager->Shutdown(); m_Window->Shutdown(); diff --git a/EppoEngine/Source/Core/Application.h b/EppoEngine/Source/Core/Application.h index 23926efb..0478728c 100644 --- a/EppoEngine/Source/Core/Application.h +++ b/EppoEngine/Source/Core/Application.h @@ -1,7 +1,7 @@ #pragma once #include "Core/Layer.h" -#include "Core/ThreadPool.h" +#include "Core/ThreadPool/ThreadPool.h" #include "Core/Window.h" #include "Event/ApplicationEvent.h" #include "ImGui/ImGuiLayer.h" @@ -82,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; } diff --git a/EppoEngine/Source/Core/ThreadPool.cpp b/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp similarity index 55% rename from EppoEngine/Source/Core/ThreadPool.cpp rename to EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp index cf69b1c0..f4aa6ea8 100644 --- a/EppoEngine/Source/Core/ThreadPool.cpp +++ b/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp @@ -1,5 +1,5 @@ #include "pch.h" -#include "Core/ThreadPool.h" +#include "Core/ThreadPool/ThreadPool.h" #include @@ -27,10 +27,45 @@ namespace Eppo Shutdown(true); } + auto ThreadPool::QueueTask(TaskFn taskFn, CompletionFn completionFn, TaskPriority priority) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTask") + + if (!m_IsRunning.load(std::memory_order_relaxed)) + { + Log::Warn("Tried to queue task after thread pool shutdown!"); + return 0; + } + + const TaskId id = m_NextTaskId.fetch_add(1); + + auto task = CreateRef(); + task->Id = id; + task->Priority = priority; + task->Fn = std::move(taskFn); + task->OnComplete = std::move(completionFn); + + { + std::scoped_lock lock(m_PendingMutex); + m_PendingTasks.at(static_cast(priority)).emplace_back(task); + m_AllTasks[id] = task; + m_TasksPending++; + } + + m_WorkAvailableCV.notify_one(); + return id; + } + auto ThreadPool::QueueTask(std::string name, TaskFn taskFn, CompletionFn completionFn, TaskPriority priority) -> TaskId { EP_PROFILE_FN("ThreadPool::QueueTask") + if (!m_IsRunning.load(std::memory_order_relaxed)) + { + Log::Warn("Tried to queue task '{}' after thread pool shutdown!", name); + return 0; + } + const TaskId id = m_NextTaskId.fetch_add(1); auto task = CreateRef(); @@ -40,6 +75,25 @@ namespace Eppo task->Fn = std::move(taskFn); task->OnComplete = std::move(completionFn); + { + std::scoped_lock lock(m_SnapshotMutex); + if (m_Snapshots.contains(task->Name)) + { + // Add task to group + m_Snapshots.at(task->Name).Total++; + m_Snapshots.at(task->Name).Pending++; + } + else + { + // New group + m_Snapshots[task->Name] = TaskGroupSnapshot{ + .Name = task->Name, + .Pending = 1, + .Total = 1, + }; + } + } + { std::scoped_lock lock(m_PendingMutex); m_PendingTasks.at(static_cast(priority)).emplace_back(task); @@ -51,12 +105,76 @@ namespace Eppo return id; } + auto ThreadPool::QueueTaskWithDependencies( + TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority + ) -> TaskId + { + EP_PROFILE_FN("ThreadPool::QueueTaskWithDependencies") + + if (!m_IsRunning.load(std::memory_order_relaxed)) + { + Log::Warn("Tried to queue task after thread pool shutdown!"); + return 0; + } + + const TaskId id = m_NextTaskId.fetch_add(1); + + 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 + { + std::scoped_lock lock(m_PendingMutex); + + 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) + { + if (!m_AllTasks.contains(dependencyId)) + continue; + + const auto status = m_AllTasks.at(dependencyId)->Status.load(std::memory_order_relaxed); + if (status == TaskStatus::Completed || status == TaskStatus::Failed || status == TaskStatus::Cancelled) + continue; + + m_AllTasks.at(dependencyId)->Dependents.emplace_back(id); + remainingDeps++; + } + + task->RemainingDeps = remainingDeps; + if (remainingDeps == 0) + m_PendingTasks.at(static_cast(priority)).emplace_back(task); + m_AllTasks[id] = task; + m_TasksPending++; + } + + m_WorkAvailableCV.notify_one(); + return id; + } + auto ThreadPool::QueueTaskWithDependencies( std::string name, TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority ) -> TaskId { EP_PROFILE_FN("ThreadPool::QueueTaskWithDependencies") + if (!m_IsRunning.load(std::memory_order_relaxed)) + { + Log::Warn("Tried to queue task '{}' after thread pool shutdown!", name); + return 0; + } + const TaskId id = m_NextTaskId.fetch_add(1); auto task = CreateRef(); @@ -72,7 +190,7 @@ namespace Eppo for (const auto& dependencyId : dependencies) { - if (dependencyId == 0 || dependencyId >= id) + if (dependencyId >= id) { Log::Error("Task '{}' depends on task id {} which was never issued!", task->Name, dependencyId); return 0; @@ -82,7 +200,7 @@ namespace Eppo uint32_t remainingDeps = 0; for (const auto& dependencyId : dependencies) { - if (m_AllTasks.contains(dependencyId)) + if (!m_AllTasks.contains(dependencyId)) continue; const auto status = m_AllTasks.at(dependencyId)->Status.load(std::memory_order_relaxed); @@ -98,12 +216,41 @@ namespace Eppo m_PendingTasks.at(static_cast(priority)).emplace_back(task); m_AllTasks[id] = task; m_TasksPending++; + + std::scoped_lock snapshotLock(m_SnapshotMutex); + if (m_Snapshots.contains(task->Name)) + { + m_Snapshots.at(task->Name).Total++; + m_Snapshots.at(task->Name).Pending++; + } + else + { + m_Snapshots[task->Name] = TaskGroupSnapshot{ + .Name = task->Name, + .Pending = 1, + .Total = 1, + }; + } } 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; + for (const auto& [name, snapshot] : m_Snapshots) + snapshots[name] = snapshot; + + return snapshots; + } + auto ThreadPool::Flush() -> uint32_t { EP_PROFILE_FN("ThreadPool::Flush") @@ -127,7 +274,20 @@ namespace Eppo auto& task = batch.at(i); if (task->OnComplete) - task->OnComplete(task->Status); + { + try + { + task->OnComplete(task->Status); + } + 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; } @@ -136,6 +296,25 @@ namespace Eppo for (size_t i = 0; i < completedTaskIds.size(); i++) m_AllTasks.erase(completedTaskIds.at(i)); + { + std::scoped_lock lock(m_SnapshotMutex); + for (size_t i = 0; i < batch.size(); i++) + { + auto& task = batch.at(i); + + if (m_Snapshots.contains(task->Name)) + { + m_Snapshots.at(task->Name).Running--; + if (task->Status.load(std::memory_order_relaxed) == TaskStatus::Completed) + m_Snapshots.at(task->Name).Completed++; + if (task->Status.load(std::memory_order_relaxed) == TaskStatus::Cancelled) + m_Snapshots.at(task->Name).Cancelled++; + if (task->Status.load(std::memory_order_relaxed) == TaskStatus::Failed) + m_Snapshots.at(task->Name).Failed++; + } + } + } + return static_cast(batch.size()); } @@ -156,13 +335,11 @@ namespace Eppo { EP_PROFILE_FN("ThreadPool::Shutdown") + m_IsRunning.store(false, std::memory_order_relaxed); + if (cancelPending) CancelAll(); - { - std::scoped_lock lock(m_PendingMutex); - m_IsRunning = false; - } m_WorkAvailableCV.notify_all(); for (auto& thread : m_Threads) @@ -202,32 +379,37 @@ namespace Eppo task = GetNextTask(); } - if (task->Status.load(std::memory_order_relaxed) == TaskStatus::Running) + if (!task) + continue; + + TaskStatus taskStatus = task->Status.load(std::memory_order_relaxed); + if (taskStatus == TaskStatus::Running) { try { task->Fn(); - task->Status.store(TaskStatus::Completed, std::memory_order_relaxed); + taskStatus = TaskStatus::Completed; } catch (const std::exception& e) { Log::Error("Task '{}' with id {} threw: {}", task->Name, task->Id, e.what()); - task->Status.store(TaskStatus::Failed, std::memory_order_relaxed); + taskStatus = TaskStatus::Failed; } catch (...) { Log::Error("Task '{}' with id {} threw unknown exception!", task->Name, task->Id); - task->Status.store(TaskStatus::Failed, std::memory_order_relaxed); + taskStatus = TaskStatus::Failed; } } // Process task dependencies - if (!task->Dependents.empty()) { std::scoped_lock lock(m_PendingMutex); + task->Status.store(taskStatus, std::memory_order_relaxed); + for (const auto& dependentId : task->Dependents) { - if (m_AllTasks.contains(dependentId)) + if (!m_AllTasks.contains(dependentId)) continue; auto& dependentTask = m_AllTasks.at(dependentId); @@ -283,6 +465,15 @@ namespace Eppo auto expected = TaskStatus::Pending; task->Status.compare_exchange_strong(expected, TaskStatus::Running, std::memory_order_relaxed); + { + std::scoped_lock lock(m_SnapshotMutex); + if (m_Snapshots.contains(task->Name)) + { + m_Snapshots.at(task->Name).Pending--; + m_Snapshots.at(task->Name).Running++; + } + } + m_TasksInFlight.fetch_add(1, std::memory_order_relaxed); m_TasksPending.fetch_sub(1, std::memory_order_relaxed); return task; diff --git a/EppoEngine/Source/Core/ThreadPool.h b/EppoEngine/Source/Core/ThreadPool/ThreadPool.h similarity index 71% rename from EppoEngine/Source/Core/ThreadPool.h rename to EppoEngine/Source/Core/ThreadPool/ThreadPool.h index dd585b45..ac450433 100644 --- a/EppoEngine/Source/Core/ThreadPool.h +++ b/EppoEngine/Source/Core/ThreadPool/ThreadPool.h @@ -8,6 +8,8 @@ namespace Eppo { + using TaskId = uint64_t; + enum class TaskPriority : uint8_t { Low = 0, @@ -24,7 +26,23 @@ namespace Eppo Cancelled, }; - using TaskId = uint64_t; + struct TaskGroupSnapshot + { + std::string Name; + uint32_t Pending = 0; + uint32_t Running = 0; + uint32_t Completed = 0; + uint32_t Failed = 0; + uint32_t Cancelled = 0; + uint32_t Total = 0; + + auto IsFinished() const -> bool + { + auto remaining = Total - Completed - Failed - Cancelled; + return remaining == 0; + } + }; + using TaskFn = std::function; using CompletionFn = std::function; @@ -35,12 +53,19 @@ namespace Eppo ~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; @@ -66,7 +91,6 @@ namespace Eppo // Dependencies std::vector Dependents; std::atomic RemainingDeps = 0; - std::atomic Queued = false; }; auto WorkerLoop() -> void; @@ -76,7 +100,6 @@ namespace Eppo private: // Pending tasks std::mutex m_PendingMutex; - std::condition_variable m_WorkAvailableCV; std::atomic m_TasksPending = 0; std::array>, 3> m_PendingTasks{}; std::unordered_map> m_AllTasks; @@ -85,8 +108,13 @@ namespace Eppo 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; 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..ecd5eda6 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/ThreadPool/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/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/SceneRenderer.cpp b/EppoEngine/Source/Renderer/SceneRenderer.cpp index 75242194..5981370b 100644 --- a/EppoEngine/Source/Renderer/SceneRenderer.cpp +++ b/EppoEngine/Source/Renderer/SceneRenderer.cpp @@ -920,8 +920,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)) * @@ -1157,10 +1157,11 @@ namespace Eppo // 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))); + 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++) - static_cast(bloomPyramid->GetBindlessIndex(nvrhi::TextureSubresourceSet(mip, 1, 0, 1))); + bloomPyramid->RegisterBindlessIndex(nvrhi::TextureSubresourceSet(mip, 1, 0, 1)); } auto SceneRenderer::FillShadowData() -> void @@ -1207,8 +1208,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; } 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..0effc12d 100644 --- a/EppoEngineTesting/Source/Core/Application.cpp +++ b/EppoEngineTesting/Source/Core/Application.cpp @@ -50,6 +50,41 @@ 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; +}; + TEST(App, Application_Boot_ProducesWindowAndDevice) { Application* app = Testing::AppHarness::Get(); @@ -182,3 +217,23 @@ 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); +} diff --git a/EppoEngineTesting/Source/Core/ThreadPool.cpp b/EppoEngineTesting/Source/Core/ThreadPool.cpp index 8aa2a78e..846e2cb3 100644 --- a/EppoEngineTesting/Source/Core/ThreadPool.cpp +++ b/EppoEngineTesting/Source/Core/ThreadPool.cpp @@ -1,5 +1,5 @@ #include "TestSupport/EppoTest.h" -#include "Core/ThreadPool.h" +#include "Core/ThreadPool/ThreadPool.h" #include #include @@ -248,6 +248,57 @@ TEST(Core, ThreadPool_Shutdown_FlushesCompletionCallbacksBeforeReturning) 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; 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()) From a3a587ef7e81dc5891ecd2758221d98a0fb18ba1 Mon Sep 17 00:00:00 2001 From: Niels Eppenhof Date: Tue, 11 Aug 2026 06:28:35 +0200 Subject: [PATCH 3/7] Reworked atomic/mutex situation --- .../eppo-application-framework/SKILL.md | 2 +- .../references/architecture.md | 12 +- .../skills/eppo-editor-development/SKILL.md | 2 +- .../references/architecture.md | 3 + AGENTS.md | 24 +- EppoEditor/Source/StatusBar.cpp | 28 +- .../Source/Core/ThreadPool/ThreadPool.cpp | 512 ++++++---- .../Source/Core/ThreadPool/ThreadPool.h | 51 +- EppoEngine/Source/Scripting/ScriptEngine.cpp | 7 +- EppoEngineTesting/Source/Core/ThreadPool.cpp | 915 ++++++++++++++++++ .../Source/Scripting/Scripting.cpp | 875 +++++++++++++++++ _docs/README.md | 2 +- _docs/adding-a-collider-shape.md | 2 +- 13 files changed, 2186 insertions(+), 249 deletions(-) diff --git a/.agents/skills/eppo-application-framework/SKILL.md b/.agents/skills/eppo-application-framework/SKILL.md index e042b518..e90a3e3a 100644 --- a/.agents/skills/eppo-application-framework/SKILL.md +++ b/.agents/skills/eppo-application-framework/SKILL.md @@ -1,6 +1,6 @@ --- name: eppo-application-framework -description: Develop and diagnose Eppo's application framework across entry-point creation, Application and layer lifecycle, frame ordering, window and GLFW event delivery, input backends and simulated input, device and renderer startup, resize and minimization, ImGui frame integration, writable and resource directory resolution, the deployed EppoRuntime player, and the App harness. Use for changes to Application, Window, Layer, EntryPoint, Input, SimulatedInput, Event, ImGui, platform window/input code, EppoEditor/Source/EppoEditor.cpp, EppoRuntime/Source, or application-level tests; do not trigger for unrelated Core utilities such as UUID or Hash. +description: Develop and diagnose Eppo's application framework across entry-point creation, Application and layer lifecycle, frame ordering, window and GLFW event delivery, input backends and simulated input, device and renderer startup, resize and minimization, ImGui frame integration, background-task scheduling (Core/ThreadPool), writable and resource directory resolution, the deployed EppoRuntime player, and the App harness. Use for changes to Application, Window, Layer, EntryPoint, Input, SimulatedInput, Event, ImGui, Core/ThreadPool, platform window/input code, EppoEditor/Source/EppoEditor.cpp, EppoRuntime/Source, or application-level tests; do not trigger for unrelated Core utilities such as UUID or Hash. --- # Eppo Application Framework diff --git a/.agents/skills/eppo-application-framework/references/architecture.md b/.agents/skills/eppo-application-framework/references/architecture.md index d02fa85a..1475a629 100644 --- a/.agents/skills/eppo-application-framework/references/architecture.md +++ b/.agents/skills/eppo-application-framework/references/architecture.md @@ -13,6 +13,7 @@ Two targets implement the factory: - `Window` and its platform backend; - `DeviceManager` and NVRHI renderer; +- `ThreadPool` (created after device init, shut down before layer detach); - application layers; - `ImGuiLayer` and its renderer integration. @@ -33,7 +34,11 @@ Reverse dependency order during destruction. Wait for GPU idle before releasing ## Frame order -`Run` computes a wall-clock timestep and repeatedly calls `StepFrame`. `StepFrame` exists so tests can drive deterministic fixed timesteps and frame counts. +`Run` computes a wall-clock timestep and repeatedly calls `StepFrame`. `StepFrame` exists so tests can drive deterministic fixed timesteps and frame counts. It calls `ThreadPool::Flush` each frame so queued-task completions run on the main thread. + +## Background task pool + +`Core/ThreadPool/` (`Application::GetThreadPool()`) executes priority/dependency-queued background tasks. All threads may call `QueueTask` (with optional name and dependencies), `GetTaskGroupSnapshots`, and `GetPendingTasksCount`; `Flush`, `CancelAll`, and `Shutdown` are main-thread only. Named tasks aggregate into `TaskGroupSnapshot` statistics (`Pending`/`Running`/`Completed`/`Failed`/`Cancelled`/`Total`) that the editor `StatusBar` renders. The destructor calls `Shutdown(true)` before detaching layers. The effective frame phases are: @@ -92,12 +97,13 @@ Keep ImGui GPU resources synchronized with back-buffer count and viewport/swapch ## Testing infrastructure -`EppoEngineTesting/Source/Support/AppHarness` boots a real `Application`, window, device, renderer, and resources. It can advance a deterministic number of frames. `TestContext` and `ScenarioLayer` build on it for multi-frame scenarios and simulated input; they are consumed by the `Renderer` suite's `SceneRendering` tests. +`EppoEngineTesting/Source/TestSupport/AppHarness` boots a real `Application`, window, device, renderer, and resources. It can advance a deterministic number of frames. `TestContext` and `ScenarioLayer` build on it for multi-frame scenarios and simulated input; they are consumed by the `Renderer` suite's `SceneRendering` tests. Test routing: -- headless `Core`: buffers, streams, hashes, UUIDs, filesystem, process/file-watch, and isolated non-window logic; +- headless `Core`: buffers, streams, hashes, UUIDs, filesystem, process/file-watch, thread-pool scheduling, and isolated non-window logic (the `App` suite, despite its name, lives in `EppoEngineTesting/Source/Core/Application.cpp`); - graphical `App`: boot, live window/device, and repeated frame advancement; +- graphical `CoreGraphical`: window boot, cursor modes, and icon behavior; - graphical `Renderer`: direct GPU abstraction behavior, plus the `SceneRendering` tests covering state changes across frames, editor camera, input, scene loading, and rendering. Graphical suites require a real display and GPU and are excluded by headless CI. Run them from CTest so the configured working directory is correct. diff --git a/.agents/skills/eppo-editor-development/SKILL.md b/.agents/skills/eppo-editor-development/SKILL.md index d857af9a..328787ad 100644 --- a/.agents/skills/eppo-editor-development/SKILL.md +++ b/.agents/skills/eppo-editor-development/SKILL.md @@ -1,6 +1,6 @@ --- name: eppo-editor-development -description: Extend and diagnose EppoEditor workflows across EditorLayer, edit/play scene state, panels and shared selection, viewport rendering and input focus, gizmos, project and scene commands, content browsing, docking, editor resources, and editor-to-engine boundaries. Use for changes under EppoEditor/Source or EppoEditor/Resources and for engine APIs introduced specifically to support editor behavior. +description: Extend and diagnose EppoEditor workflows across EditorLayer, edit/play scene state, panels and shared selection, viewport rendering and input focus, gizmos, project and scene commands, content browsing, docking, the status bar's background-task UI, editor resources, and editor-to-engine boundaries. Use for changes under EppoEditor/Source or EppoEditor/Resources and for engine APIs introduced specifically to support editor behavior. --- # Eppo Editor Development diff --git a/.agents/skills/eppo-editor-development/references/architecture.md b/.agents/skills/eppo-editor-development/references/architecture.md index a8ad1a56..41a967d3 100644 --- a/.agents/skills/eppo-editor-development/references/architecture.md +++ b/.agents/skills/eppo-editor-development/references/architecture.md @@ -9,6 +9,7 @@ - `m_SceneState`: edit or play; - `SceneRenderer` and `EditorCamera`; - `PanelManager`, toolbar icons, viewport state, gizmo state, and project/scene commands; +- `StatusBar`, which renders background-task progress from `Application::GetThreadPool()`; - the export-game command, which collects `ProjectExportOptions` from the UI and hands them to `ProjectExporter(project).Export(options)` — the editor supplies paths and toggles and reports progress, it does not gather packed payloads itself. `PanelManager` owns panels and centralizes scene context plus selected `Entity`. Panels receive a non-owning manager pointer through `Panel`. Current panels are: @@ -58,6 +59,8 @@ Never retain an `Entity` across scene replacement: it contains an EnTT handle an `OnUIRender` applies deferred layout restoration before any windows begin, builds the dockspace/menu, renders the viewport image, records viewport bounds/focus/hover, draws toolbar/notices, updates panels, and handles popups. +The `StatusBar` occupies a strip at the bottom of the DockSpace host window: `EditorLayer` sizes the DockSpace `statusBarHeight` (24px) shorter and calls `m_StatusBar.Render()` while the host window is still current, before `End()`. It shows `ThreadPool::GetPendingTasksCount()` (the authoritative busy signal) and a drop-up of per-group `TaskGroupSnapshot`s, dropping finished groups so the list stays bounded. + One-frame lag for focus or selection is intentional where documented. Avoid mixing same-frame UI mutation into render-update state unless the ordering is deliberately redesigned. ## Project and scene flow diff --git a/AGENTS.md b/AGENTS.md index f829b167..19fba51b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Compact guide for agents working in this repo. Read before editing. ## Project -EppoEngine — a C++20 cross-platform (Windows/Linux) game engine + editor with C# scripting via CoreCLR (.NET 10) and Vulkan rendering through NVRHI. Built with Premake 5.0.0-beta8 + vcpkg (manifest mode). CLAUDE.md holds the same core guidance for Claude Code; keep the two in sync when editing either. +EppoEngine — a C++20 cross-platform (Windows/Linux) game engine + editor with C# scripting via CoreCLR (.NET 10) and Vulkan rendering through NVRHI. Built with Premake 5.0.0-beta8 + vcpkg (manifest mode). ## Prerequisites (validated by `Scripts/Setup.py`) @@ -32,7 +32,7 @@ ctest --test-dir build/bin/Debug-windows-x86_64 -R Scripting ``` Run a suite directly from `EppoEditor/` so source resources resolve correctly: `../build/bin/Debug-windows-x86_64/EppoEngineTesting/EppoEngineTesting --gtest_filter=Scripting.*` (Google Test; suite = the first `TEST(Suite, Name)` argument). CTest passes exactly this filter per suite. -Suites and labels (registered in `Scripts/Premake/Testing.lua`): `Core`, `Physics`, `Scene` (`core`); `Project` (`unit`); `Scripting`, `ScriptMarshalling` (`scripting`); `App`, `ProjectExport`, `Renderer` (`graphical`). +Suites and labels (registered in `Scripts/Premake/Testing.lua`): `Core`, `Physics`, `Scene` (`core`); `FileDialogFilter`, `Project` (`unit`); `Scripting`, `ScriptMarshalling` (`scripting`); `App`, `CoreGraphical`, `ProjectExport`, `Renderer` (`graphical`). Visual Studio's built-in Test Adapter for Google Test discovers the suite in Test Explorer with no per-developer setup. The runner's `main.cpp` `chdir`s to `EppoEditor` on startup (via the premake-baked `EP_TEST_WORKING_DIR`), so `Resources/`/`Projects/`/`TestData/` resolve for graphical and data-driven suites regardless of how the exe is launched (Test Explorer runs it from the output dir; CTest also sets `WORKING_DIRECTORY`). Required order: **generate → build → test**. After editing C# only, rebuild the `EppoEngineTesting` (or `EppoEditor`) target so the dotnet custom commands re-run and DLLs are re-copied. @@ -41,10 +41,10 @@ Required order: **generate → build → test**. After editing C# only, rebuild ### Targets -- `EppoEngine/` — static library, the engine. `Source/` modules: `Asset`, `Core`, `Event`, `ImGui`, `Physics`, `Platform`, `Project`, `Renderer`, `Scene`, `Scripting`, `Utility`. Public umbrella header `Source/EppoEngine.h`; PCH `Source/pch.h`. `Core/Buffer/` is the binary serialization substrate: abstract `StreamWriter`/`StreamReader` with `Buffer*` (in-memory) and `FileStream*` (on-disk) implementations, plus paired `StreamSerializable`/`StreamDeserializable` concepts backing `WriteObject`/`ReadObject`. `GameData` is built on it. +- `EppoEngine/` — static library, the engine. `Source/` modules: `Asset`, `Core`, `Event`, `ImGui`, `Physics`, `Platform`, `Project`, `Renderer`, `Scene`, `Scripting`, `Utility`. Public umbrella header `Source/EppoEngine.h`; PCH `Source/pch.h`. `Core/Buffer/` is the binary serialization substrate: abstract `StreamWriter`/`StreamReader` with `Buffer*` (in-memory) and `FileStream*` (on-disk) implementations, plus paired `StreamSerializable`/`StreamDeserializable` concepts backing `WriteObject`/`ReadObject`. `GameData` is built on it. `Core/ThreadPool/` is the background-task pool owned by `Application` (`GetThreadPool()`): priority/dependency-queued tasks, named task groups with `TaskGroupSnapshot` statistics, and main-thread `Flush`/`CancelAll`/`Shutdown`; the editor's `StatusBar` renders its live state. - `EppoEditor/` — editor executable (`EppoEditor.cpp` → `EditorLayer`). Depends on `EppoEngine` + `EppoScriptCore`. Owns `Resources/` and `runtimeconfig.json`. - `EppoScriptCore/` — C# class library (net10.0). Visual Studio exposes the real `.csproj` in the EppoScriptCore solution group and maps solution Dist to managed Release. Ninja invokes `dotnet` through the project Premake definition. Namespaces mirror the folder path minus `Source/`. -- `EppoEngineTesting/` — Google Test runner (custom `main.cpp` wraps `RUN_ALL_TESTS` with logging + `AppHarness::Shutdown`). `Source/` suites mirror engine modules; `Source/Support/` has `AppHarness` (boots a real `Application` for graphical suites), `TestContext` + `ScenarioLayer` (multi-frame scene/camera scenarios, used by the `Renderer` suite), and the `EppoTest.h` / `GlmCheck.h` / `TempDir.h` helpers. `EppoTest.h` provides `EP_REQUIRE`/`EP_REQUIRE_EQ` (a fatal check usable in value-returning helpers where `ASSERT_*` cannot) and `EP_EXPECT_ARRAY_EQ`; `GlmCheck.h` keeps `CHECK_VEC*/MAT4_CLOSE` on `EXPECT_NEAR`. `TestData/Scripts/` builds the `EppoTesting.Scripts.dll` harness the Scripting suite loads. Suites are registered in `Scripts/Premake/Testing.lua`. +- `EppoEngineTesting/` — Google Test runner (custom `main.cpp` wraps `RUN_ALL_TESTS` with logging + `AppHarness::Shutdown`). `Source/` suites mirror engine modules; `Source/TestSupport/` has `AppHarness` (boots a real `Application` for graphical suites), `TestContext` + `ScenarioLayer` (multi-frame scene/camera scenarios, used by the `Renderer` suite), and the `EppoTest.h` / `GlmCheck.h` / `TempDir.h` helpers. `EppoTest.h` provides `EP_REQUIRE`/`EP_REQUIRE_EQ` (a fatal check usable in value-returning helpers where `ASSERT_*` cannot) and `EP_EXPECT_ARRAY_EQ`; `GlmCheck.h` keeps `CHECK_VEC*/MAT4_CLOSE` on `EXPECT_NEAR`. `TestData/Scripts/` builds the `EppoTesting.Scripts.dll` harness the Scripting suite loads. Suites are registered in `Scripts/Premake/Testing.lua`. - `EppoRuntime/` — standalone player. Reads `Game.eppak` before creating the application, since its engine shaders come from there. It stages **no** `Resources/`: shader sources and their includes travel in the pack, and it never reads them from disk. Logs and its shader cache are written beside the executable. - `Scripts/Premake/` — shared dependency names and standalone CTest manifest generation. Each native target owns a `premake5.lua`; vcpkg overlays live under `Dependencies/Ports`. @@ -71,7 +71,7 @@ Key libraries: entt (ECS), NVRHI (Vulkan RHI), GLFW + ImGui (docking), glm, box3 - **Run the editor from `EppoEditor/`; run tests through CTest.** The editor and graphical tests resolve `Resources/` and `Projects/` from the working directory, while `runtimeconfig.json`, `EppoScriptCore.dll`, and test assemblies resolve beside their executable. CTest sets the source working directory automatically. - **Managed projects follow the generated build system.** Visual Studio builds the real `.csproj` projects; Ninja invokes `dotnet` custom rules. Post-build steps copy managed outputs beside the native executable. -- **Graphical suites (`App`, `ProjectExport`, `Renderer`) need a real display + GPU.** They early-return if `AppHarness` can't boot; on headless/CI use `--label-exclude graphical`. +- **Graphical suites (`App`, `CoreGraphical`, `ProjectExport`, `Renderer`) need a real display + GPU.** They early-return if `AppHarness` can't boot; on headless/CI use `--label-exclude graphical`. - **"SPIR-V CodeGen not available"** at runtime means the Microsoft `dxcompiler.dll` is shadowing the Vulkan SDK one; copy the Vulkan SDK's `dxcompiler.dll` next to the exe. - **`EppoRuntime` owns its entry point.** It defines `EP_CUSTOM_ENTRY_POINT` (suppressing the `main` in `Core/EntryPoint.h`) and calls `Eppo::RunApplication` from its own `WinMain`/`main`, so it can wrap startup in a try/catch that reports through `ErrorDialog`. It reads `Game.eppak` inside `CreateApplication` — before the `Application` exists — because the shaders it hands to `ApplicationParams` are needed during construction. - **Where files get written is configured, not assumed.** `FS::ConfigureWritableDirectory` sets the root that `FS::GetWritableDirectory` and `FS::GetShaderCacheDirectory` resolve against; the runtime points it at its own executable directory so logs and the shader cache land beside the game. Unconfigured, the shader cache falls back to `Resources/Shaders/Cache`. @@ -126,7 +126,9 @@ Conventions below are near-universal in `Core`, `Platform/Vulkan` and `Renderer` ## Domain skills -Seven domain skills live in `.agents/skills/` (each `SKILL.md` + `references/architecture.md`). Read the matching skill before investigating or changing a major subsystem; use every applicable skill for cross-system work. Claude Code loads full copies of the same skills from `.claude/skills/` — when editing a skill, apply the same change to both trees. +Seven domain skills live in `.agents/skills/` (each `SKILL.md` + `references/architecture.md`, plus an `agents/openai.yaml` agent definition). Read the matching skill before investigating or changing a major subsystem; use every applicable skill for cross-system work. There is no mirrored skill tree — `.agents/skills/` is the single source of truth, and the openai.yaml sits alongside its skill so both stay consistent. + +Seven domain skills: - `eppo-scripting-integration` — CoreCLR hosting, native/managed ABI, assemblies, ScriptGlue, fields, lifecycle, deployment, and scripting tests. - `eppo-rendering-pipeline` — Vulkan/NVRHI devices, shaders, descriptors, GPU resources, render passes, SceneRenderer, and graphical tests. @@ -136,6 +138,14 @@ Seven domain skills live in `.agents/skills/` (each `SKILL.md` + `references/arc - `eppo-assets-and-projects` — asset handles, registry persistence, paths, loading/import/export, project lifecycle, `Game.eppak` packaging, and content-browser coordination. - `eppo-application-framework` — application/frame lifecycle, layers, windows, events, input, ImGui, startup order, the deployed runtime, and application harnesses. +### Agents and skills outside the repo + +The repo skills sit alongside global, user-level definitions that are not committed here: + +- **Subagents** (`build` primary, plus `planner`, `coder`, `reviewer`, `researcher`, `junior` subagents) are defined in the global opencode config `~/.config/opencode/opencode.json` and apply to any project. Their descriptions are deliberately project-agnostic — use them in this repo, and supply project facts through this file and the domain skills. +- **General workflow skills** live in `~/.agents/skills/` (each `SKILL.md` + `agents/openai.yaml`): `build-test-verification`, `git-worktree-workflow`, `plan-and-confirm`, `preserve-local-style`, `requesting-code-review`, `systematic-debugging`, `test-driven-development`. They are deliberately workflow-only — no project-specific commands; project facts (build/test commands, suite names, paths) belong in this file and the domain skills. +- **Generic opencode user skills** live in `~/.config/opencode/skills/`. Same-named entries there shadow `~/.agents/skills/` — when a name exists in both trees, the opencode copy is the one loaded. + ## Workflow rules (required) - **Discover worktrees first.** Before inspecting, editing, building, or testing, run `git worktree list` from the repository and identify the worktree that contains the task. Never assume the primary checkout is the target; use the selected worktree consistently for every command. @@ -145,6 +155,8 @@ Seven domain skills live in `.agents/skills/` (each `SKILL.md` + `references/arc - **Code review via subagent** after substantial changes — do not review your own work. - **No formatting changes to existing code.** Don't reindent or reflow lines you aren't otherwise editing, and never run clang-format across a file you didn't create. New and edited lines use 4 spaces (see Style); the tab-to-space conversion of legacy files is a deliberate, separately-run pass, not something to do as a drive-by. - **Verify before claiming done.** Run the relevant build + `ctest` and confirm it passes. A green build alone does not verify editor/GUI behaviour — state what was actually verified. +- **Be honest, not agreeable.** Do not reflexively agree with the user. If you think they are wrong, say so and explain why. Sycophancy ("you're right", "fair", "good point") without independent judgment is a failure mode. Disagreement must be substantive — do not manufacture contrarianism either. The user pays you to think, not to nod. +- **Don't invalidate the build cache by default.** The user's build cache is expensive to rebuild. Do not run `Scripts\Setup.bat`/`setup.sh` (re-provisions tools and re-runs `vcpkg install`), `Scripts\Clean.bat`/`clean.sh` (wipes all build outputs and provisioned tools), or `Scripts\GenerateBuildFiles.bat`/`generatebuildfiles.sh` (re-runs Premake) by default. Do not delete or touch the `build/` tree or `.eppo/` directly. Routine edits do not require regeneration; `GenerateBuildFiles` is only needed when premake inputs change (e.g., adding/removing/renaming files). If a cache-invalidating step is genuinely necessary, just do it. ## CI diff --git a/EppoEditor/Source/StatusBar.cpp b/EppoEditor/Source/StatusBar.cpp index d446b348..d271e7fd 100644 --- a/EppoEditor/Source/StatusBar.cpp +++ b/EppoEditor/Source/StatusBar.cpp @@ -116,36 +116,42 @@ namespace Eppo { 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 = - snapshot.Total > 0 ? static_cast(snapshot.Completed) / static_cast(snapshot.Total) : 0.0f; + 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", snapshot.Completed, snapshot.Total); + ImGui::TextDisabled("%u/%u", completed, total); std::string status; - if (snapshot.Running > 0) - status += std::format("{} running", snapshot.Running); - if (snapshot.Pending > 0) + if (running > 0) + status += std::format("{} running", running); + if (pending > 0) { if (!status.empty()) status += ", "; - status += std::format("{} pending", snapshot.Pending); + status += std::format("{} pending", pending); } - if (snapshot.Failed > 0) + if (failed > 0) { if (!status.empty()) status += ", "; - status += std::format("{} failed", snapshot.Failed); + status += std::format("{} failed", failed); } - if (snapshot.Cancelled > 0) + if (cancelled > 0) { if (!status.empty()) status += ", "; - status += std::format("{} cancelled", snapshot.Cancelled); + status += std::format("{} cancelled", cancelled); } if (!status.empty()) { diff --git a/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp b/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp index f4aa6ea8..7bc399d6 100644 --- a/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp +++ b/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp @@ -5,6 +5,48 @@ 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()) { @@ -30,79 +72,13 @@ namespace Eppo auto ThreadPool::QueueTask(TaskFn taskFn, CompletionFn completionFn, TaskPriority priority) -> TaskId { EP_PROFILE_FN("ThreadPool::QueueTask") - - if (!m_IsRunning.load(std::memory_order_relaxed)) - { - Log::Warn("Tried to queue task after thread pool shutdown!"); - return 0; - } - - const TaskId id = m_NextTaskId.fetch_add(1); - - auto task = CreateRef(); - task->Id = id; - task->Priority = priority; - task->Fn = std::move(taskFn); - task->OnComplete = std::move(completionFn); - - { - std::scoped_lock lock(m_PendingMutex); - m_PendingTasks.at(static_cast(priority)).emplace_back(task); - m_AllTasks[id] = task; - m_TasksPending++; - } - - m_WorkAvailableCV.notify_one(); - return id; + 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") - - if (!m_IsRunning.load(std::memory_order_relaxed)) - { - Log::Warn("Tried to queue task '{}' after thread pool shutdown!", name); - return 0; - } - - const TaskId id = m_NextTaskId.fetch_add(1); - - auto task = CreateRef(); - task->Id = id; - task->Name = std::move(name); - task->Priority = priority; - task->Fn = std::move(taskFn); - task->OnComplete = std::move(completionFn); - - { - std::scoped_lock lock(m_SnapshotMutex); - if (m_Snapshots.contains(task->Name)) - { - // Add task to group - m_Snapshots.at(task->Name).Total++; - m_Snapshots.at(task->Name).Pending++; - } - else - { - // New group - m_Snapshots[task->Name] = TaskGroupSnapshot{ - .Name = task->Name, - .Pending = 1, - .Total = 1, - }; - } - } - - { - std::scoped_lock lock(m_PendingMutex); - m_PendingTasks.at(static_cast(priority)).emplace_back(task); - m_AllTasks[id] = task; - m_TasksPending++; - } - - m_WorkAvailableCV.notify_one(); - return id; + return QueueTaskInternal(std::move(name), std::move(taskFn), std::move(completionFn), {}, priority); } auto ThreadPool::QueueTaskWithDependencies( @@ -110,14 +86,24 @@ namespace Eppo ) -> TaskId { EP_PROFILE_FN("ThreadPool::QueueTaskWithDependencies") + return QueueTaskInternal(std::move(taskFn), std::move(completionFn), dependencies, priority); + } - if (!m_IsRunning.load(std::memory_order_relaxed)) - { - Log::Warn("Tried to queue task after thread pool shutdown!"); - return 0; - } + 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); + const TaskId id = m_NextTaskId.fetch_add(1, std::memory_order_relaxed); auto task = CreateRef(); task->Id = id; @@ -126,56 +112,59 @@ namespace Eppo 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) + for (const auto dependencyId : dependencies) { if (dependencyId >= id) { - Log::Error("Task '{}' depends on task id {} which was never issued!", task->Name, dependencyId); + 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) + for (const auto dependencyId : dependencies) { - if (!m_AllTasks.contains(dependencyId)) + const auto dependencyIt = m_AllTasks.find(dependencyId); + if (dependencyIt == m_AllTasks.end()) continue; - const auto status = m_AllTasks.at(dependencyId)->Status.load(std::memory_order_relaxed); + const auto status = dependencyIt->second->Status.load(std::memory_order_relaxed); if (status == TaskStatus::Completed || status == TaskStatus::Failed || status == TaskStatus::Cancelled) continue; - m_AllTasks.at(dependencyId)->Dependents.emplace_back(id); + dependencyIt->second->Dependents.emplace_back(task); remainingDeps++; } - task->RemainingDeps = remainingDeps; - if (remainingDeps == 0) + 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++; + m_TasksPending.fetch_add(1, std::memory_order_seq_cst); } - m_WorkAvailableCV.notify_one(); + if (isReady) + m_WorkAvailableCV.notify_one(); return id; } - auto ThreadPool::QueueTaskWithDependencies( + auto ThreadPool::QueueTaskInternal( std::string name, TaskFn taskFn, CompletionFn completionFn, const std::vector& dependencies, TaskPriority priority ) -> TaskId { - EP_PROFILE_FN("ThreadPool::QueueTaskWithDependencies") - - if (!m_IsRunning.load(std::memory_order_relaxed)) - { - Log::Warn("Tried to queue task '{}' after thread pool shutdown!", name); - return 0; - } + EP_PROFILE_FN("ThreadPool::QueueTaskInternal") - const TaskId id = m_NextTaskId.fetch_add(1); + const TaskId id = m_NextTaskId.fetch_add(1, std::memory_order_relaxed); auto task = CreateRef(); task->Id = id; @@ -185,10 +174,16 @@ namespace Eppo 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) + for (const auto dependencyId : dependencies) { if (dependencyId >= id) { @@ -198,42 +193,42 @@ namespace Eppo } uint32_t remainingDeps = 0; - for (const auto& dependencyId : dependencies) + for (const auto dependencyId : dependencies) { - if (!m_AllTasks.contains(dependencyId)) + const auto dependencyIt = m_AllTasks.find(dependencyId); + if (dependencyIt == m_AllTasks.end()) continue; - const auto status = m_AllTasks.at(dependencyId)->Status.load(std::memory_order_relaxed); + const auto status = dependencyIt->second->Status.load(std::memory_order_relaxed); if (status == TaskStatus::Completed || status == TaskStatus::Failed || status == TaskStatus::Cancelled) continue; - m_AllTasks.at(dependencyId)->Dependents.emplace_back(id); + dependencyIt->second->Dependents.emplace_back(task); remainingDeps++; } - task->RemainingDeps = remainingDeps; - if (remainingDeps == 0) - m_PendingTasks.at(static_cast(priority)).emplace_back(task); - m_AllTasks[id] = task; - m_TasksPending++; - - std::scoped_lock snapshotLock(m_SnapshotMutex); - if (m_Snapshots.contains(task->Name)) - { - m_Snapshots.at(task->Name).Total++; - m_Snapshots.at(task->Name).Pending++; - } - else { - m_Snapshots[task->Name] = TaskGroupSnapshot{ - .Name = task->Name, - .Pending = 1, - .Total = 1, - }; + 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); } - m_WorkAvailableCV.notify_one(); + if (isReady) + m_WorkAvailableCV.notify_one(); return id; } @@ -245,12 +240,116 @@ namespace Eppo std::shared_lock lock(m_SnapshotMutex); std::unordered_map snapshots; + snapshots.reserve(m_Snapshots.size()); for (const auto& [name, snapshot] : m_Snapshots) - snapshots[name] = snapshot; + 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") @@ -277,7 +376,7 @@ namespace Eppo { try { - task->OnComplete(task->Status); + task->OnComplete(task->Status.load(std::memory_order_relaxed)); } catch (const std::exception& e) { @@ -292,53 +391,82 @@ namespace Eppo completedTaskIds[i] = task->Id; } - std::scoped_lock lock(m_PendingMutex); - for (size_t i = 0; i < completedTaskIds.size(); i++) - m_AllTasks.erase(completedTaskIds.at(i)); + { + 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_SnapshotMutex); - for (size_t i = 0; i < batch.size(); i++) - { - auto& task = batch.at(i); + std::scoped_lock lock(m_PendingMutex); - if (m_Snapshots.contains(task->Name)) - { - m_Snapshots.at(task->Name).Running--; - if (task->Status.load(std::memory_order_relaxed) == TaskStatus::Completed) - m_Snapshots.at(task->Name).Completed++; - if (task->Status.load(std::memory_order_relaxed) == TaskStatus::Cancelled) - m_Snapshots.at(task->Name).Cancelled++; - if (task->Status.load(std::memory_order_relaxed) == TaskStatus::Failed) - m_Snapshots.at(task->Name).Failed++; - } - } + const auto taskIt = m_AllTasks.find(taskId); + if (taskIt == m_AllTasks.end()) + return false; + task = taskIt->second; } - return static_cast(batch.size()); + 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::scoped_lock lock(m_PendingMutex); - - for (auto& [taskId, task] : m_AllTasks) + std::vector> cancelledTasks; { - if (task->Status == TaskStatus::Pending) - task->Status = TaskStatus::Cancelled; + 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") - m_IsRunning.store(false, std::memory_order_relaxed); + 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); + } + } + } - if (cancelPending) - CancelAll(); + for (const auto& task : cancelledTasks) + FinalizeTask(task, TaskStatus::Cancelled); m_WorkAvailableCV.notify_all(); @@ -352,7 +480,7 @@ namespace Eppo auto ThreadPool::GetPendingTasksCount() const -> uint32_t { - return m_TasksPending.load(std::memory_order_relaxed) + m_TasksInFlight.load(std::memory_order_relaxed); + return m_TasksPending.load(std::memory_order_seq_cst) + m_TasksInFlight.load(std::memory_order_seq_cst); } auto ThreadPool::WorkerLoop() -> void @@ -369,11 +497,11 @@ namespace Eppo lock, [this]() -> bool { - return !m_IsRunning || HasPendingTasks(); + return !m_IsRunning.load(std::memory_order_relaxed) || HasPendingTasks(); } ); - if (!m_IsRunning && !HasPendingTasks()) + if (!m_IsRunning.load(std::memory_order_relaxed) && !HasPendingTasks()) return; task = GetNextTask(); @@ -382,55 +510,25 @@ namespace Eppo if (!task) continue; - TaskStatus taskStatus = task->Status.load(std::memory_order_relaxed); - if (taskStatus == TaskStatus::Running) + UpdateTaskGroup(task->Group, TaskStatus::Running); + + auto taskStatus = TaskStatus::Completed; + try { - try - { - task->Fn(); - taskStatus = TaskStatus::Completed; - } - 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; - } + task->Fn(); } - - // Process task dependencies + catch (const std::exception& e) { - std::scoped_lock lock(m_PendingMutex); - task->Status.store(taskStatus, std::memory_order_relaxed); - - for (const auto& dependentId : task->Dependents) - { - if (!m_AllTasks.contains(dependentId)) - continue; - - auto& dependentTask = m_AllTasks.at(dependentId); - - // fetch_sub returns the value from *before* the subtraction, so the last dependency - // to resolve sees 1, not 0. - const uint32_t remaining = dependentTask->RemainingDeps.fetch_sub(1, std::memory_order_relaxed); - if (remaining == 1) - { - m_PendingTasks.at(static_cast(dependentTask->Priority)).emplace_back(dependentTask); - m_WorkAvailableCV.notify_one(); - } - } + Log::Error("Task '{}' with id {} threw: {}", task->Name, task->Id, e.what()); + taskStatus = TaskStatus::Failed; } - - // Add to completed tasks + catch (...) { - std::scoped_lock lock(m_CompletedMutex); - m_CompletedTasks.emplace_back(task); - m_TasksInFlight.fetch_sub(1, std::memory_order_relaxed); + Log::Error("Task '{}' with id {} threw unknown exception!", task->Name, task->Id); + taskStatus = TaskStatus::Failed; } + + FinalizeTask(task, taskStatus); } } @@ -454,29 +552,19 @@ namespace Eppo // Run in reverse so highest priority gets selected first for (auto it = m_PendingTasks.rbegin(); it != m_PendingTasks.rend(); ++it) { - if (it->empty()) - continue; - - auto task = std::move(it->front()); - it->pop_front(); + while (!it->empty()) + { + auto task = std::move(it->front()); + it->pop_front(); - // Claim it while the queue lock is still held, so CancelAll can no longer reach it. A task it - // already cancelled keeps that status and its body is skipped. - auto expected = TaskStatus::Pending; - task->Status.compare_exchange_strong(expected, TaskStatus::Running, std::memory_order_relaxed); + auto expected = TaskStatus::Pending; + if (!task->Status.compare_exchange_strong(expected, TaskStatus::Running, std::memory_order_relaxed)) + continue; - { - std::scoped_lock lock(m_SnapshotMutex); - if (m_Snapshots.contains(task->Name)) - { - m_Snapshots.at(task->Name).Pending--; - m_Snapshots.at(task->Name).Running++; - } + m_TasksInFlight.fetch_add(1, std::memory_order_seq_cst); + m_TasksPending.fetch_sub(1, std::memory_order_seq_cst); + return task; } - - m_TasksInFlight.fetch_add(1, std::memory_order_relaxed); - m_TasksPending.fetch_sub(1, std::memory_order_relaxed); - return task; } return nullptr; diff --git a/EppoEngine/Source/Core/ThreadPool/ThreadPool.h b/EppoEngine/Source/Core/ThreadPool/ThreadPool.h index ac450433..d3972857 100644 --- a/EppoEngine/Source/Core/ThreadPool/ThreadPool.h +++ b/EppoEngine/Source/Core/ThreadPool/ThreadPool.h @@ -28,19 +28,37 @@ namespace Eppo struct TaskGroupSnapshot { + TaskGroupSnapshot() = default; + TaskGroupSnapshot(const TaskGroupSnapshot& other); + auto operator=(const TaskGroupSnapshot& other) -> TaskGroupSnapshot&; + std::string Name; - uint32_t Pending = 0; - uint32_t Running = 0; - uint32_t Completed = 0; - uint32_t Failed = 0; - uint32_t Cancelled = 0; - uint32_t Total = 0; + 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 { - auto remaining = Total - Completed - Failed - Cancelled; - return remaining == 0; + 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 + struct TaskResult + { + TaskStatus Status = TaskStatus::Pending; + T Data{}; }; using TaskFn = std::function; @@ -69,6 +87,9 @@ namespace Eppo // Callable: Main thread auto Flush() -> uint32_t; + // Callable: All threads + auto CancelTask(TaskId taskId) -> bool; + // Callable: Main thread auto CancelAll() -> void; @@ -89,10 +110,20 @@ namespace Eppo std::atomic Status = TaskStatus::Pending; // Dependencies - std::vector Dependents; + 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() -> void; [[nodiscard]] auto HasPendingTasks() const -> bool; [[nodiscard]] auto GetNextTask() -> Ref; @@ -110,7 +141,7 @@ namespace Eppo // Snapshotting std::shared_mutex m_SnapshotMutex; - std::unordered_map m_Snapshots; + std::unordered_map> m_Snapshots; // Workpool std::vector m_Threads; diff --git a/EppoEngine/Source/Scripting/ScriptEngine.cpp b/EppoEngine/Source/Scripting/ScriptEngine.cpp index e7fc168d..99ce6f8f 100644 --- a/EppoEngine/Source/Scripting/ScriptEngine.cpp +++ b/EppoEngine/Source/Scripting/ScriptEngine.cpp @@ -110,6 +110,8 @@ namespace Eppo auto ScriptEngine::VerifyRuntime() -> void { + EP_PROFILE_FN("ScriptEngine::VerifyRuntime"); + if (!m_ScriptWatcher || !Project::GetActive()) return; @@ -361,9 +363,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/EppoEngineTesting/Source/Core/ThreadPool.cpp b/EppoEngineTesting/Source/Core/ThreadPool.cpp index 846e2cb3..ee8ac3f1 100644 --- a/EppoEngineTesting/Source/Core/ThreadPool.cpp +++ b/EppoEngineTesting/Source/Core/ThreadPool.cpp @@ -4,10 +4,12 @@ #include #include #include +#include using Eppo::TaskFn; using Eppo::TaskId; using Eppo::TaskStatus; +using Eppo::TaskResult; using Eppo::ThreadPool; // Every wait here is deadline-bounded. A wedged pool must fail its test, not hang the @@ -581,3 +583,916 @@ TEST(Core, ThreadPool_QueueTaskWithDependencies_ChainOfThousandTasksCompletesInO )); 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 = std::max(1u, std::thread::hardware_concurrency() - 1); + 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 = std::max(1u, std::thread::hardware_concurrency() - 1); + 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 = std::max(1u, std::thread::hardware_concurrency() - 1); + 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 = std::max(1u, std::thread::hardware_concurrency() - 1); + 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 = std::max(1u, std::thread::hardware_concurrency() - 1); + 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()); +} + +// --------------------------------------------------------------------------- +// TaskResult: shared-state data propagation +// --------------------------------------------------------------------------- + +// Worker writes Data; completion reads it on the main thread via Flush. +TEST(Core, ThreadPool_TaskResult_PropagatesPrimitiveFromWorkerToCompletion) +{ + auto result = Eppo::CreateRef>(); + result->Data = -1; + + std::atomic completionFired = false; + int32_t completionValue = -1; + ThreadPool pool; + + pool.QueueTask( + "ResultProbe", + [result]() -> void + { + result->Data = 42; + }, + [&completionFired, &completionValue, result](const TaskStatus status) -> void + { + if (status == TaskStatus::Completed) + completionValue = result->Data; + completionFired.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + EXPECT_EQ(42, completionValue); + EXPECT_EQ(42, result->Data); +} + +// TaskResult with a non-trivial type — verifies the template works for structs. +TEST(Core, ThreadPool_TaskResult_PropagatesStructFromWorkerToCompletion) +{ + struct Payload + { + int32_t Int = 0; + std::string Text; + }; + + auto result = Eppo::CreateRef>(); + + std::atomic completionFired = false; + Payload captured{}; + ThreadPool pool; + + pool.QueueTask( + "StructProbe", + [result]() -> void + { + result->Data.Int = 7; + result->Data.Text = "hello"; + }, + [&completionFired, &captured, result](const TaskStatus status) -> void + { + if (status == TaskStatus::Completed) + captured = result->Data; + completionFired.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + EXPECT_EQ(7, captured.Int); + EXPECT_EQ("hello", captured.Text); +} + +// TaskResult with a large payload — verifies no size limit beyond memory. +TEST(Core, ThreadPool_TaskResult_PropagatesLargeVectorFromWorkerToCompletion) +{ + auto result = Eppo::CreateRef>>(); + + std::atomic completionFired = false; + std::vector captured; + ThreadPool pool; + + constexpr size_t kSize = 10000; + + pool.QueueTask( + "VectorProbe", + [result]() -> void + { + result->Data.resize(kSize); + for (size_t i = 0; i < kSize; i++) + result->Data[i] = static_cast(i); + }, + [&completionFired, &captured, result](const TaskStatus status) -> void + { + if (status == TaskStatus::Completed) + captured = result->Data; + completionFired.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + EXPECT_EQ(kSize, captured.size()); + for (size_t i = 0; i < kSize; i++) + EXPECT_EQ(static_cast(i), captured[i]); +} + +// --------------------------------------------------------------------------- +// TaskResult: Status field +// --------------------------------------------------------------------------- + +// Worker sets Status to Completed; completion reads it from the shared result +// rather than relying solely on the TaskStatus argument. +TEST(Core, ThreadPool_TaskResult_WorkerSetsStatusCompleted) +{ + auto result = Eppo::CreateRef>(); + result->Status = TaskStatus::Pending; + + std::atomic completionFired = false; + TaskStatus resultStatus = TaskStatus::Pending; + ThreadPool pool; + + pool.QueueTask( + "StatusProbe", + [result]() -> void + { + result->Status = TaskStatus::Completed; + result->Data = 1; + }, + [&completionFired, &resultStatus, result](const TaskStatus status) -> void + { + if (status == TaskStatus::Completed) + resultStatus = result->Status; + completionFired.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + EXPECT_EQ(TaskStatus::Completed, resultStatus); +} + +// Worker sets Status to Failed; completion reads it and the Data payload +// (error info) even though the task threw. +TEST(Core, ThreadPool_TaskResult_WorkerSetsStatusFailedAndDeliversPartialData) +{ + struct ErrorInfo + { + int32_t Code = 0; + std::string Message; + }; + + auto result = Eppo::CreateRef>(); + result->Status = TaskStatus::Pending; + + std::atomic completionFired = false; + TaskStatus resultStatus = TaskStatus::Pending; + ErrorInfo captured{}; + ThreadPool pool; + + pool.QueueTask( + "FailureProbe", + [result]() -> void + { + result->Status = TaskStatus::Failed; + result->Data.Code = 42; + result->Data.Message = "build failed"; + throw std::runtime_error("worker error"); + }, + [&completionFired, &resultStatus, &captured, result](const TaskStatus status) -> void + { + resultStatus = result->Status; + captured = result->Data; + completionFired.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + // The CompletionFn receives Failed from the pool, and the TaskResult carries + // the worker's own status plus the error payload. + EXPECT_EQ(TaskStatus::Failed, resultStatus); + EXPECT_EQ(42, captured.Code); + EXPECT_EQ("build failed", captured.Message); +} + +// --------------------------------------------------------------------------- +// TaskResult: default initialization +// --------------------------------------------------------------------------- + +// TaskResult must value-initialize Data so an unread field is predictable. +TEST(Core, ThreadPool_TaskResult_DefaultInitializesPrimitiveData) +{ + auto result = Eppo::CreateRef>(); + EXPECT_EQ(0, result->Data); + EXPECT_EQ(TaskStatus::Pending, result->Status); +} + +// TaskResult must call the Data type's default constructor. +TEST(Core, ThreadPool_TaskResult_DefaultInitializesStructData) +{ + struct Payload + { + int32_t Int = 99; + std::string Text = "default"; + }; + + auto result = Eppo::CreateRef>(); + EXPECT_EQ(99, result->Data.Int); + EXPECT_EQ("default", result->Data.Text); +} + +// --------------------------------------------------------------------------- +// TaskResult: lifetime and multiple-task sharing +// --------------------------------------------------------------------------- + +// The Ref outlives the task — the caller can still read it after +// Flush has drained the task from the pool. +TEST(Core, ThreadPool_TaskResult_RemainsValidAfterFlushDrainsTask) +{ + auto result = Eppo::CreateRef>(); + std::atomic completionFired = false; + ThreadPool pool; + + pool.QueueTask( + "LifetimeProbe", + [result]() -> void + { + result->Data = 77; + }, + [&completionFired, result](TaskStatus) -> void + { + completionFired.store(true); + } + ); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + // The pool has erased the task, but the Ref keeps the result alive. + EXPECT_EQ(77, result->Data); + EXPECT_EQ(0u, pool.GetPendingTasksCount()); +} + +// Multiple tasks write to the same TaskResult (fan-in). The completion of the +// last task observes the accumulated data. +TEST(Core, ThreadPool_TaskResult_MultipleTasksShareOneResult) +{ + auto result = Eppo::CreateRef>>(); + result->Data.resize(3, 0); + + std::atomic completionCount = 0; + ThreadPool pool; + + for (int32_t i = 0; i < 3; i++) + { + pool.QueueTask( + "FanIn", + [result, i]() -> void + { + result->Data[i] = i * 10; + }, + [&completionCount, result](TaskStatus status) -> void + { + if (status == TaskStatus::Completed) + completionCount.fetch_add(1, std::memory_order_relaxed); + } + ); + } + + ASSERT_TRUE(FlushUntil( + pool, + [&completionCount]() -> bool + { + return completionCount.load() == 3; + } + )); + + // All three workers wrote to the same vector. Each element holds its value. + EXPECT_EQ(3, result->Data.size()); + EXPECT_EQ(0, result->Data[0]); + EXPECT_EQ(10, result->Data[1]); + EXPECT_EQ(20, result->Data[2]); +} + +// --------------------------------------------------------------------------- +// TaskResult: cancelled task interaction +// --------------------------------------------------------------------------- + +// A cancelled task's result Data stays at its default — the worker never ran. +TEST(Core, ThreadPool_TaskResult_CancelledTaskLeavesDataUnchanged) +{ + const auto workerCount = std::max(1u, std::thread::hardware_concurrency() - 1); + + auto result = Eppo::CreateRef>(); + result->Data = -999; + + std::atomic gate = false; + std::atomic running = 0; + std::atomic completionFired = false; + std::atomic reported = TaskStatus::Pending; + ThreadPool pool; + + for (uint32_t i = 0; i < workerCount; 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; + } + )); + + const auto id = pool.QueueTask( + "CancelledResult", + [result]() -> void + { + result->Data = 123; + }, + [&completionFired, &reported, result](const TaskStatus status) -> void + { + reported.store(status); + completionFired.store(true); + } + ); + + EXPECT_TRUE(pool.CancelTask(id)); + + gate.store(true, std::memory_order_release); + + ASSERT_TRUE(FlushUntil( + pool, + [&completionFired]() -> bool + { + return completionFired.load(); + } + )); + + EXPECT_EQ(TaskStatus::Cancelled, reported.load()); + EXPECT_EQ(-999, result->Data); +} + +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/Scripting/Scripting.cpp b/EppoEngineTesting/Source/Scripting/Scripting.cpp index 13f11829..54f51d4e 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; @@ -676,6 +678,380 @@ public class ProbeScript : Entity EXPECT_EQ(true, discovered); } +TEST(Scripting, ScriptEngine_ReloadProjectAssembly_WithBrokenScript_FailsAndLogsError) +{ + EP_REQUIRE(EnsureRuntime()); + + 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 = ScriptEngine::Get().ReloadProjectAssembly(); + const bool valid = ScriptEngine::IsUserAssemblyValid(); + + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + + EXPECT_FALSE(reloaded); + EXPECT_FALSE(valid); +} + +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(Scripting, ScriptEngine_ReloadProjectAssembly_ReplacesOldClassesWithNewOnes) +{ + EP_REQUIRE(EnsureRuntime()); + + 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(ScriptEngine::Get().ReloadProjectAssembly()); + 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(ScriptEngine::Get().ReloadProjectAssembly()); + + 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(Scripting, ScriptEngine_ReloadProjectAssembly_ClearsEntityInstances) +{ + EP_REQUIRE(EnsureRuntime()); + + 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(ScriptEngine::Get().ReloadProjectAssembly()); + + 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(ScriptEngine::Get().ReloadProjectAssembly()); + + const bool instanceCleared = ScriptEngine::Get().GetEntityInstance(entity.GetUUID()) == nullptr; + + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + + EXPECT_TRUE(instanceCleared); +} + +TEST(Scripting, ScriptEngine_ReloadProjectAssembly_PreservesFieldStorage) +{ + EP_REQUIRE(EnsureRuntime()); + + 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(ScriptEngine::Get().ReloadProjectAssembly()); + + 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(ScriptEngine::Get().ReloadProjectAssembly()); + + 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(Scripting, ScriptEngine_ReloadProjectAssembly_MultipleReloadsInSequence) +{ + EP_REQUIRE(EnsureRuntime()); + + 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(), + } + ); + + bool first = ScriptEngine::Get().ReloadProjectAssembly(); + bool second = ScriptEngine::Get().ReloadProjectAssembly(); + bool third = ScriptEngine::Get().ReloadProjectAssembly(); + 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. @@ -2120,3 +2496,502 @@ 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 +{ +} +} +)"; + + 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(ScriptEngine::Get().ReloadProjectAssembly()); + 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 RestoreHarnessAssembly() -> void + { + Project::SetActive(nullptr); + ScriptEngine::Get().LoadUserAssembly(FS::GetExecutableDirectory() / "EppoTesting.Scripts.dll"); + } +} + +// VerifyRuntime must not block on dotnet build. The first call consumes the file +// watcher change and sets the pending flag; the second call triggers the build. +// The second call must return in milliseconds, not seconds. +TEST(Scripting, 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); + + EP_REQUIRE(TouchScript(scriptFile, "touched")); + ScriptEngine::Get().VerifyRuntime(); + + const auto start = std::chrono::steady_clock::now(); + ScriptEngine::Get().VerifyRuntime(); + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start + ); + + RestoreHarnessAssembly(); + + EXPECT_LT(elapsed.count(), 100); +} + +// After the build completes, the completion callback (delivered via Flush) must +// unload the old assembly and load the new one so script classes are discovered. +TEST(Scripting, ScriptEngine_VerifyRuntime_BuildCompletesAndLoadsAssembly) +{ + 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); + + EP_REQUIRE(TouchScript(scriptFile, "touched")); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + bool discovered = false; + while (std::chrono::steady_clock::now() < deadline) + { + threadPool->Flush(); + if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) + { + discovered = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + RestoreHarnessAssembly(); + + EXPECT_TRUE(discovered); +} + +// While a scene context is set (play mode), VerifyRuntime must not trigger a build. +TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotReloadDuringPlayMode) +{ + 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 Ref playScene = CreateRef(); + ScriptEngine::Get().SetSceneContext(playScene); + + EP_REQUIRE(TouchScript(scriptFile, "play-mode-change")); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + threadPool->Flush(); + + const bool buildWasQueued = threadPool->GetPendingTasksCount() > 0; + + ScriptEngine::Get().SetSceneContext(nullptr); + + RestoreHarnessAssembly(); + + EXPECT_FALSE(buildWasQueued); +} + +// A second file change while the first build is still pending must cancel the +// first build and queue a fresh one. Only one build task should be in flight. +TEST(Scripting, ScriptEngine_VerifyRuntime_CancelsPendingBuildAndQueuesFresh) +{ + 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); + + EP_REQUIRE(TouchScript(scriptFile, "first")); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + EP_REQUIRE(TouchScript(scriptFile, "second")); + ScriptEngine::Get().VerifyRuntime(); + + const auto pendingAfterCancel = threadPool->GetPendingTasksCount(); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + bool discovered = false; + while (std::chrono::steady_clock::now() < deadline) + { + threadPool->Flush(); + if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) + { + discovered = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + RestoreHarnessAssembly(); + + EXPECT_EQ(1u, pendingAfterCancel); + EXPECT_TRUE(discovered); +} + +// A failed build must unload the old assembly but not load a broken one: the class +// disappears and the user assembly is marked invalid. +TEST(Scripting, ScriptEngine_VerifyRuntime_BuildFailureDoesNotLoadBrokenAssembly) +{ + 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); + + EP_REQUIRE(TouchScript(scriptFile, "first")); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + const auto firstDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (std::chrono::steady_clock::now() < firstDeadline) + { + threadPool->Flush(); + if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + EP_REQUIRE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); + + const std::string brokenSource = std::string(kProbeScriptSource) + "\n{{{{ garbage\n"; + EP_REQUIRE(FS::WriteText(scriptFile, brokenSource, true)); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + const auto secondDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (std::chrono::steady_clock::now() < secondDeadline) + { + threadPool->Flush(); + if (!ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + RestoreHarnessAssembly(); + + EXPECT_FALSE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); + EXPECT_FALSE(ScriptEngine::Get().IsUserAssemblyValid()); +} + +// A change detected during play mode defers the build; it triggers only after the +// scene context is cleared, queuing exactly one build task at that point. +TEST(Scripting, ScriptEngine_VerifyRuntime_BuildDeferredDuringPlayModeTriggersAfterStop) +{ + 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 Ref playScene = CreateRef(); + ScriptEngine::Get().SetSceneContext(playScene); + + EP_REQUIRE(TouchScript(scriptFile, "play-mode-change")); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + threadPool->Flush(); + EXPECT_EQ(0u, threadPool->GetPendingTasksCount()); + + ScriptEngine::Get().SetSceneContext(nullptr); + ScriptEngine::Get().VerifyRuntime(); + + EXPECT_GT(threadPool->GetPendingTasksCount(), 0u); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + bool discovered = false; + while (std::chrono::steady_clock::now() < deadline) + { + threadPool->Flush(); + if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) + { + discovered = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + RestoreHarnessAssembly(); + + EXPECT_TRUE(discovered); +} + +// Five rapid saves must collapse into a single pending build task, not five. +TEST(Scripting, 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); + + for (int i = 0; i < 5; ++i) + { + EP_REQUIRE(TouchScript(scriptFile, "burst-" + std::to_string(i))); + ScriptEngine::Get().VerifyRuntime(); + } + + EXPECT_EQ(1u, threadPool->GetPendingTasksCount()); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + bool discovered = false; + while (std::chrono::steady_clock::now() < deadline) + { + threadPool->Flush(); + if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) + { + discovered = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + RestoreHarnessAssembly(); + + EXPECT_TRUE(discovered); +} + +// The build task must register a "ScriptBuild" task-group snapshot that reports +// at least one task while running and is finished once the build completes. +TEST(Scripting, ScriptEngine_VerifyRuntime_UpdatesSnapshotForBuildTask) +{ + 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); + + EP_REQUIRE(TouchScript(scriptFile, "snapshot")); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + const auto snapshotsBefore = threadPool->GetTaskGroupSnapshots(); + auto itBefore = snapshotsBefore.find("ScriptBuild"); + ASSERT_NE(itBefore, snapshotsBefore.end()); + EXPECT_GE(itBefore->second.Total.load(std::memory_order_relaxed), 1u); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + bool finished = false; + while (std::chrono::steady_clock::now() < deadline) + { + threadPool->Flush(); + const auto snapshots = threadPool->GetTaskGroupSnapshots(); + auto it = snapshots.find("ScriptBuild"); + if (it != snapshots.end() && it->second.IsFinished()) + { + finished = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + const auto snapshotsAfter = threadPool->GetTaskGroupSnapshots(); + auto itAfter = snapshotsAfter.find("ScriptBuild"); + ASSERT_NE(itAfter, snapshotsAfter.end()); + EXPECT_GE(itAfter->second.Completed.load(std::memory_order_relaxed), 1u); + + RestoreHarnessAssembly(); + + EXPECT_TRUE(finished); +} + +// After a reload that renames the class, the old class name must be gone and the +// new one discovered. +TEST(Scripting, ScriptEngine_VerifyRuntime_OldClassesGoneAfterReloadWithChangedClassName) +{ + 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); + + EP_REQUIRE(TouchScript(scriptFile, "first")); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + const auto firstDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (std::chrono::steady_clock::now() < firstDeadline) + { + threadPool->Flush(); + if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + EP_REQUIRE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); + + const std::string renamedSource = R"(using EppoScriptCore.Scene; + +namespace EppoTesting +{ +public class RenamedScript : Entity +{ +} +} +)"; + EP_REQUIRE(FS::WriteText(scriptFile, renamedSource, true)); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + const auto secondDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (std::chrono::steady_clock::now() < secondDeadline) + { + threadPool->Flush(); + if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.RenamedScript")) + break; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + RestoreHarnessAssembly(); + + EXPECT_FALSE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); + EXPECT_TRUE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.RenamedScript")); +} + +// Three successive async reload cycles must each complete and discover the class. +TEST(Scripting, ScriptEngine_VerifyRuntime_MultipleSuccessiveAsyncReloads) +{ + 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); + + for (int cycle = 0; cycle < 3; ++cycle) + { + EP_REQUIRE(TouchScript(scriptFile, "cycle-" + std::to_string(cycle))); + ScriptEngine::Get().VerifyRuntime(); + ScriptEngine::Get().VerifyRuntime(); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + bool discovered = false; + while (std::chrono::steady_clock::now() < deadline) + { + threadPool->Flush(); + if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) + { + discovered = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + EP_REQUIRE(discovered); + } + + RestoreHarnessAssembly(); + + EXPECT_TRUE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); +} 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 From 51be16adf3cb5e24893746dc6e1ad7840c7ceb11 Mon Sep 17 00:00:00 2001 From: Niels Eppenhof Date: Fri, 14 Aug 2026 01:59:47 +0200 Subject: [PATCH 4/7] Compile dotnet binaries async --- EppoEditor/Source/EditorLayer.cpp | 20 +- .../Source/Core/ThreadPool/ThreadPool.cpp | 1 - .../Source/Core/ThreadPool/ThreadPool.h | 6 +- EppoEngine/Source/Scripting/ScriptEngine.cpp | 144 ++++- EppoEngine/Source/Scripting/ScriptEngine.h | 4 + EppoEngineTesting/Source/Core/ThreadPool.cpp | 353 +----------- .../Source/Scripting/Scripting.cpp | 508 ++++++------------ 7 files changed, 339 insertions(+), 697 deletions(-) diff --git a/EppoEditor/Source/EditorLayer.cpp b/EppoEditor/Source/EditorLayer.cpp index a0f4d8b6..f17f6a5b 100644 --- a/EppoEditor/Source/EditorLayer.cpp +++ b/EppoEditor/Source/EditorLayer.cpp @@ -452,6 +452,12 @@ namespace Eppo 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."); @@ -1167,7 +1173,7 @@ namespace Eppo 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 auto DrawNotice = [&](const char* notice) { const ImVec2 textPos = { imageMin.x + 10.0f, y }; const ImVec2 textSize = ImGui::CalcTextSize(notice); @@ -1180,8 +1186,10 @@ namespace Eppo }; // 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 (!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; @@ -1197,7 +1205,7 @@ namespace Eppo hasCameraEntity = true; const std::string notice = "Camera entity '" + entity.GetName() + "' is not primary - showing editor view"; - drawNotice(notice.c_str()); + DrawNotice(notice.c_str()); } ); @@ -1205,14 +1213,14 @@ namespace Eppo { 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()); + 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()); + DrawNotice(notice.c_str()); } } diff --git a/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp b/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp index 7bc399d6..159597d1 100644 --- a/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp +++ b/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp @@ -491,7 +491,6 @@ namespace Eppo // Wait for task { - // TODO: Why unique? std::unique_lock lock(m_PendingMutex); m_WorkAvailableCV.wait( lock, diff --git a/EppoEngine/Source/Core/ThreadPool/ThreadPool.h b/EppoEngine/Source/Core/ThreadPool/ThreadPool.h index d3972857..06a640ee 100644 --- a/EppoEngine/Source/Core/ThreadPool/ThreadPool.h +++ b/EppoEngine/Source/Core/ThreadPool/ThreadPool.h @@ -55,11 +55,7 @@ namespace Eppo }; template - struct TaskResult - { - TaskStatus Status = TaskStatus::Pending; - T Data{}; - }; + using TaskResult = std::optional; using TaskFn = std::function; using CompletionFn = std::function; diff --git a/EppoEngine/Source/Scripting/ScriptEngine.cpp b/EppoEngine/Source/Scripting/ScriptEngine.cpp index 99ce6f8f..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" @@ -126,8 +127,8 @@ namespace Eppo if (!m_ReloadPending || GetSceneContext()) return; - ReloadProjectAssembly(); - m_ReloadPending = false; + if (ReloadProjectAssembly()) + m_ReloadPending = false; } auto ScriptEngine::Get() -> ScriptEngine& @@ -186,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; } @@ -224,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) diff --git a/EppoEngine/Source/Scripting/ScriptEngine.h b/EppoEngine/Source/Scripting/ScriptEngine.h index 760e0ac8..656d297a 100644 --- a/EppoEngine/Source/Scripting/ScriptEngine.h +++ b/EppoEngine/Source/Scripting/ScriptEngine.h @@ -1,5 +1,6 @@ #pragma once +#include "Core/ThreadPool/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/EppoEngineTesting/Source/Core/ThreadPool.cpp b/EppoEngineTesting/Source/Core/ThreadPool.cpp index ee8ac3f1..4c82e76d 100644 --- a/EppoEngineTesting/Source/Core/ThreadPool.cpp +++ b/EppoEngineTesting/Source/Core/ThreadPool.cpp @@ -1024,72 +1024,38 @@ TEST(Core, ThreadPool_CancelTask_DependentStillResolvesAfterDependencyCancelled) EXPECT_TRUE(dependentRan.load()); } -// --------------------------------------------------------------------------- -// TaskResult: shared-state data propagation -// --------------------------------------------------------------------------- - -// Worker writes Data; completion reads it on the main thread via Flush. -TEST(Core, ThreadPool_TaskResult_PropagatesPrimitiveFromWorkerToCompletion) +TEST(Core, ThreadPool_TaskResult_IsEmptyUntilWorkerProducesPayload) { - auto result = Eppo::CreateRef>(); - result->Data = -1; + const TaskResult result; - std::atomic completionFired = false; - int32_t completionValue = -1; - ThreadPool pool; - - pool.QueueTask( - "ResultProbe", - [result]() -> void - { - result->Data = 42; - }, - [&completionFired, &completionValue, result](const TaskStatus status) -> void - { - if (status == TaskStatus::Completed) - completionValue = result->Data; - completionFired.store(true); - } - ); - - ASSERT_TRUE(FlushUntil( - pool, - [&completionFired]() -> bool - { - return completionFired.load(); - } - )); - - EXPECT_EQ(42, completionValue); - EXPECT_EQ(42, result->Data); + EXPECT_FALSE(result.has_value()); } -// TaskResult with a non-trivial type — verifies the template works for structs. -TEST(Core, ThreadPool_TaskResult_PropagatesStructFromWorkerToCompletion) +TEST(Core, ThreadPool_TaskResult_HandsWorkerPayloadToCompletion) { struct Payload { - int32_t Int = 0; + int32_t Value = 0; std::string Text; }; - auto result = Eppo::CreateRef>(); - + const auto result = Eppo::CreateRef>(); std::atomic completionFired = false; - Payload captured{}; + TaskStatus reported = TaskStatus::Pending; + Payload captured; ThreadPool pool; pool.QueueTask( "StructProbe", [result]() -> void { - result->Data.Int = 7; - result->Data.Text = "hello"; + result->emplace(Payload{ .Value = 42, .Text = "worker payload" }); }, - [&completionFired, &captured, result](const TaskStatus status) -> void + [&completionFired, &reported, &captured, result](const TaskStatus status) -> void { - if (status == TaskStatus::Completed) - captured = result->Data; + reported = status; + if (result->has_value()) + captured = result->value(); completionFired.store(true); } ); @@ -1102,300 +1068,32 @@ TEST(Core, ThreadPool_TaskResult_PropagatesStructFromWorkerToCompletion) } )); - EXPECT_EQ(7, captured.Int); - EXPECT_EQ("hello", captured.Text); + EXPECT_EQ(TaskStatus::Completed, reported); + EXPECT_EQ(42, captured.Value); + EXPECT_EQ("worker payload", captured.Text); } -// TaskResult with a large payload — verifies no size limit beyond memory. -TEST(Core, ThreadPool_TaskResult_PropagatesLargeVectorFromWorkerToCompletion) +TEST(Core, ThreadPool_TaskResult_DoesNotEncodeTaskOutcome) { - auto result = Eppo::CreateRef>>(); - + const auto result = Eppo::CreateRef>(); std::atomic completionFired = false; - std::vector captured; - ThreadPool pool; - - constexpr size_t kSize = 10000; - - pool.QueueTask( - "VectorProbe", - [result]() -> void - { - result->Data.resize(kSize); - for (size_t i = 0; i < kSize; i++) - result->Data[i] = static_cast(i); - }, - [&completionFired, &captured, result](const TaskStatus status) -> void - { - if (status == TaskStatus::Completed) - captured = result->Data; - completionFired.store(true); - } - ); - - ASSERT_TRUE(FlushUntil( - pool, - [&completionFired]() -> bool - { - return completionFired.load(); - } - )); - - EXPECT_EQ(kSize, captured.size()); - for (size_t i = 0; i < kSize; i++) - EXPECT_EQ(static_cast(i), captured[i]); -} - -// --------------------------------------------------------------------------- -// TaskResult: Status field -// --------------------------------------------------------------------------- - -// Worker sets Status to Completed; completion reads it from the shared result -// rather than relying solely on the TaskStatus argument. -TEST(Core, ThreadPool_TaskResult_WorkerSetsStatusCompleted) -{ - auto result = Eppo::CreateRef>(); - result->Status = TaskStatus::Pending; - - std::atomic completionFired = false; - TaskStatus resultStatus = TaskStatus::Pending; - ThreadPool pool; - - pool.QueueTask( - "StatusProbe", - [result]() -> void - { - result->Status = TaskStatus::Completed; - result->Data = 1; - }, - [&completionFired, &resultStatus, result](const TaskStatus status) -> void - { - if (status == TaskStatus::Completed) - resultStatus = result->Status; - completionFired.store(true); - } - ); - - ASSERT_TRUE(FlushUntil( - pool, - [&completionFired]() -> bool - { - return completionFired.load(); - } - )); - - EXPECT_EQ(TaskStatus::Completed, resultStatus); -} - -// Worker sets Status to Failed; completion reads it and the Data payload -// (error info) even though the task threw. -TEST(Core, ThreadPool_TaskResult_WorkerSetsStatusFailedAndDeliversPartialData) -{ - struct ErrorInfo - { - int32_t Code = 0; - std::string Message; - }; - - auto result = Eppo::CreateRef>(); - result->Status = TaskStatus::Pending; - - std::atomic completionFired = false; - TaskStatus resultStatus = TaskStatus::Pending; - ErrorInfo captured{}; + TaskStatus reported = TaskStatus::Pending; ThreadPool pool; pool.QueueTask( "FailureProbe", [result]() -> void { - result->Status = TaskStatus::Failed; - result->Data.Code = 42; - result->Data.Message = "build failed"; - throw std::runtime_error("worker error"); - }, - [&completionFired, &resultStatus, &captured, result](const TaskStatus status) -> void - { - resultStatus = result->Status; - captured = result->Data; - completionFired.store(true); - } - ); - - ASSERT_TRUE(FlushUntil( - pool, - [&completionFired]() -> bool - { - return completionFired.load(); - } - )); - - // The CompletionFn receives Failed from the pool, and the TaskResult carries - // the worker's own status plus the error payload. - EXPECT_EQ(TaskStatus::Failed, resultStatus); - EXPECT_EQ(42, captured.Code); - EXPECT_EQ("build failed", captured.Message); -} - -// --------------------------------------------------------------------------- -// TaskResult: default initialization -// --------------------------------------------------------------------------- - -// TaskResult must value-initialize Data so an unread field is predictable. -TEST(Core, ThreadPool_TaskResult_DefaultInitializesPrimitiveData) -{ - auto result = Eppo::CreateRef>(); - EXPECT_EQ(0, result->Data); - EXPECT_EQ(TaskStatus::Pending, result->Status); -} - -// TaskResult must call the Data type's default constructor. -TEST(Core, ThreadPool_TaskResult_DefaultInitializesStructData) -{ - struct Payload - { - int32_t Int = 99; - std::string Text = "default"; - }; - - auto result = Eppo::CreateRef>(); - EXPECT_EQ(99, result->Data.Int); - EXPECT_EQ("default", result->Data.Text); -} - -// --------------------------------------------------------------------------- -// TaskResult: lifetime and multiple-task sharing -// --------------------------------------------------------------------------- - -// The Ref outlives the task — the caller can still read it after -// Flush has drained the task from the pool. -TEST(Core, ThreadPool_TaskResult_RemainsValidAfterFlushDrainsTask) -{ - auto result = Eppo::CreateRef>(); - std::atomic completionFired = false; - ThreadPool pool; - - pool.QueueTask( - "LifetimeProbe", - [result]() -> void - { - result->Data = 77; - }, - [&completionFired, result](TaskStatus) -> void - { - completionFired.store(true); - } - ); - - ASSERT_TRUE(FlushUntil( - pool, - [&completionFired]() -> bool - { - return completionFired.load(); - } - )); - - // The pool has erased the task, but the Ref keeps the result alive. - EXPECT_EQ(77, result->Data); - EXPECT_EQ(0u, pool.GetPendingTasksCount()); -} - -// Multiple tasks write to the same TaskResult (fan-in). The completion of the -// last task observes the accumulated data. -TEST(Core, ThreadPool_TaskResult_MultipleTasksShareOneResult) -{ - auto result = Eppo::CreateRef>>(); - result->Data.resize(3, 0); - - std::atomic completionCount = 0; - ThreadPool pool; - - for (int32_t i = 0; i < 3; i++) - { - pool.QueueTask( - "FanIn", - [result, i]() -> void - { - result->Data[i] = i * 10; - }, - [&completionCount, result](TaskStatus status) -> void - { - if (status == TaskStatus::Completed) - completionCount.fetch_add(1, std::memory_order_relaxed); - } - ); - } - - ASSERT_TRUE(FlushUntil( - pool, - [&completionCount]() -> bool - { - return completionCount.load() == 3; - } - )); - - // All three workers wrote to the same vector. Each element holds its value. - EXPECT_EQ(3, result->Data.size()); - EXPECT_EQ(0, result->Data[0]); - EXPECT_EQ(10, result->Data[1]); - EXPECT_EQ(20, result->Data[2]); -} - -// --------------------------------------------------------------------------- -// TaskResult: cancelled task interaction -// --------------------------------------------------------------------------- - -// A cancelled task's result Data stays at its default — the worker never ran. -TEST(Core, ThreadPool_TaskResult_CancelledTaskLeavesDataUnchanged) -{ - const auto workerCount = std::max(1u, std::thread::hardware_concurrency() - 1); - - auto result = Eppo::CreateRef>(); - result->Data = -999; - - std::atomic gate = false; - std::atomic running = 0; - std::atomic completionFired = false; - std::atomic reported = TaskStatus::Pending; - ThreadPool pool; - - for (uint32_t i = 0; i < workerCount; 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; - } - )); - - const auto id = pool.QueueTask( - "CancelledResult", - [result]() -> void - { - result->Data = 123; + result->emplace("diagnostic payload"); + throw std::runtime_error("expected"); }, - [&completionFired, &reported, result](const TaskStatus status) -> void + [&completionFired, &reported](const TaskStatus status) -> void { - reported.store(status); + reported = status; completionFired.store(true); } ); - EXPECT_TRUE(pool.CancelTask(id)); - - gate.store(true, std::memory_order_release); - ASSERT_TRUE(FlushUntil( pool, [&completionFired]() -> bool @@ -1404,8 +1102,9 @@ TEST(Core, ThreadPool_TaskResult_CancelledTaskLeavesDataUnchanged) } )); - EXPECT_EQ(TaskStatus::Cancelled, reported.load()); - EXPECT_EQ(-999, result->Data); + ASSERT_TRUE(result->has_value()); + EXPECT_EQ("diagnostic payload", result->value()); + EXPECT_EQ(TaskStatus::Failed, reported); } TEST(Core, ThreadPool_GetTaskGroupSnapshots_RemainsCoherentDuringConcurrentTransitions) diff --git a/EppoEngineTesting/Source/Scripting/Scripting.cpp b/EppoEngineTesting/Source/Scripting/Scripting.cpp index 54f51d4e..024d660f 100644 --- a/EppoEngineTesting/Source/Scripting/Scripting.cpp +++ b/EppoEngineTesting/Source/Scripting/Scripting.cpp @@ -119,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. --- @@ -618,6 +634,8 @@ TEST(Scripting, ScriptEngine_ReloadProjectAssembly_WithoutAnActiveProject_Fails) TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ForProjectUnderTheProjectsDirectory_DiscoversScriptClasses) { EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; @@ -666,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 @@ -678,9 +696,11 @@ public class ProbeScript : Entity EXPECT_EQ(true, discovered); } -TEST(Scripting, ScriptEngine_ReloadProjectAssembly_WithBrokenScript_FailsAndLogsError) +TEST(Scripting, ScriptEngine_ReloadProjectAssembly_WithBrokenScript_LeavesPreviousAssemblyLoadedButInvalid) { EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; @@ -725,14 +745,16 @@ public class Broken { { } ); - const bool reloaded = ScriptEngine::Get().ReloadProjectAssembly(); + 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_FALSE(reloaded); + EXPECT_TRUE(reloaded); EXPECT_FALSE(valid); + EXPECT_TRUE(previousClassValid); } TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ProjectWithoutCsproj_Succeeds) @@ -763,6 +785,8 @@ TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ProjectWithoutCsproj_Succeeds TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ReplacesOldClassesWithNewOnes) { EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; @@ -811,7 +835,7 @@ public class ProbeScriptA : Entity } ); - EP_REQUIRE(ScriptEngine::Get().ReloadProjectAssembly()); + EP_REQUIRE(ReloadProjectAssemblyAndWait()); EXPECT_TRUE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScriptA")); EP_REQUIRE( @@ -829,7 +853,7 @@ public class ProbeScriptB : Entity ) ); - EP_REQUIRE(ScriptEngine::Get().ReloadProjectAssembly()); + EP_REQUIRE(ReloadProjectAssemblyAndWait()); const bool oldGone = !ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScriptA"); const bool newPresent = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScriptB"); @@ -844,6 +868,8 @@ public class ProbeScriptB : Entity TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ClearsEntityInstances) { EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; @@ -890,7 +916,7 @@ public class ProbeScript : Entity } ); - EP_REQUIRE(ScriptEngine::Get().ReloadProjectAssembly()); + EP_REQUIRE(ReloadProjectAssemblyAndWait()); const Ref scene = CreateRef(); Entity entity = scene->CreateEntity("Scripted"); @@ -899,7 +925,7 @@ public class ProbeScript : Entity EXPECT_TRUE(ScriptEngine::Get().GetEntityInstance(entity.GetUUID()) != nullptr); - EP_REQUIRE(ScriptEngine::Get().ReloadProjectAssembly()); + EP_REQUIRE(ReloadProjectAssemblyAndWait()); const bool instanceCleared = ScriptEngine::Get().GetEntityInstance(entity.GetUUID()) == nullptr; @@ -912,6 +938,8 @@ public class ProbeScript : Entity TEST(Scripting, ScriptEngine_ReloadProjectAssembly_PreservesFieldStorage) { EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; @@ -959,7 +987,7 @@ public class ProbeScript : Entity } ); - EP_REQUIRE(ScriptEngine::Get().ReloadProjectAssembly()); + EP_REQUIRE(ReloadProjectAssemblyAndWait()); const Ref scene = CreateRef(); Entity entity = scene->CreateEntity("Scripted"); @@ -970,7 +998,7 @@ public class ProbeScript : Entity stored.Set(42); ScriptEngine::Get().GetFieldMap(entity.GetUUID())["Value"] = stored; - EP_REQUIRE(ScriptEngine::Get().ReloadProjectAssembly()); + EP_REQUIRE(ReloadProjectAssemblyAndWait()); const ScriptFieldMap* reloaded = ScriptEngine::Get().TryGetFieldMap(entity.GetUUID()); bool valueSurvived = false; @@ -992,6 +1020,8 @@ public class ProbeScript : Entity TEST(Scripting, ScriptEngine_ReloadProjectAssembly_MultipleReloadsInSequence) { EP_REQUIRE(EnsureRuntime()); + if (!Testing::AppHarness::IsAvailable()) + return; const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); const auto scriptsDirectory = projectDirectory.Path() / "Scripts"; @@ -1038,9 +1068,9 @@ public class ProbeScript : Entity } ); - bool first = ScriptEngine::Get().ReloadProjectAssembly(); - bool second = ScriptEngine::Get().ReloadProjectAssembly(); - bool third = ScriptEngine::Get().ReloadProjectAssembly(); + const bool first = ReloadProjectAssemblyAndWait(); + const bool second = ReloadProjectAssemblyAndWait(); + const bool third = ReloadProjectAssemblyAndWait(); const bool discovered = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript"); Project::SetActive(nullptr); @@ -2509,6 +2539,16 @@ 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 @@ -2545,7 +2585,7 @@ public class ProbeScript : Entity } ); - EP_REQUIRE(ScriptEngine::Get().ReloadProjectAssembly()); + EP_REQUIRE(ReloadProjectAssemblyAndWait()); EP_REQUIRE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); return scriptFile; @@ -2556,6 +2596,47 @@ public class ProbeScript : Entity 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); @@ -2563,9 +2644,6 @@ public class ProbeScript : Entity } } -// VerifyRuntime must not block on dotnet build. The first call consumes the file -// watcher change and sets the pending flag; the second call triggers the build. -// The second call must return in milliseconds, not seconds. TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotBlockMainThread) { EP_REQUIRE(EnsureRuntime()); @@ -2578,23 +2656,35 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotBlockMainThread) const Testing::TempDir projectDirectory(Project::GetProjectsDirectory()); const auto scriptFile = StageProbeProject(projectDirectory); + EXPECT_TRUE(ScriptEngine::IsUserAssemblyValid()); + EXPECT_FALSE(ScriptEngine::IsUserAssemblyCompiling()); + EP_REQUIRE(TouchScript(scriptFile, "touched")); - ScriptEngine::Get().VerifyRuntime(); + 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_LT(elapsed.count(), 100); + EXPECT_TRUE(buildStarted); + EXPECT_TRUE(compilingAfterReturn); + EXPECT_FALSE(validWhileCompiling); + EXPECT_LT(elapsed.count(), 500); + EXPECT_TRUE(finished); + EXPECT_TRUE(validAfterBuild); } -// After the build completes, the completion callback (delivered via Flush) must -// unload the old assembly and load the new one so script classes are discovered. -TEST(Scripting, ScriptEngine_VerifyRuntime_BuildCompletesAndLoadsAssembly) +// After the build completes, a normal application frame must publish the replacement +// assembly so its script classes become available. +TEST(Scripting, ScriptEngine_VerifyRuntime_SuccessfulBuildReplacesAssembly) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -2602,32 +2692,29 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_BuildCompletesAndLoadsAssembly) 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); - EP_REQUIRE(TouchScript(scriptFile, "touched")); - ScriptEngine::Get().VerifyRuntime(); + EP_REQUIRE(FS::WriteText(scriptFile, kReplacementScriptSource, true)); + const bool buildStarted = WaitForBuildStart(); ScriptEngine::Get().VerifyRuntime(); - - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - bool discovered = false; - while (std::chrono::steady_clock::now() < deadline) - { - threadPool->Flush(); - if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) - { - discovered = true; - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } + 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(discovered); + 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. @@ -2639,8 +2726,6 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotReloadDuringPlayMode) 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); @@ -2648,68 +2733,30 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotReloadDuringPlayMode) const Ref playScene = CreateRef(); ScriptEngine::Get().SetSceneContext(playScene); - EP_REQUIRE(TouchScript(scriptFile, "play-mode-change")); + EP_REQUIRE(FS::WriteText(scriptFile, kReplacementScriptSource, true)); + const bool deferredObserved = WaitForDeferredReload(); ScriptEngine::Get().VerifyRuntime(); ScriptEngine::Get().VerifyRuntime(); - - threadPool->Flush(); - - const bool buildWasQueued = threadPool->GetPendingTasksCount() > 0; + const bool compilingDuringPlay = ScriptEngine::IsUserAssemblyCompiling(); + const bool validDuringPlay = ScriptEngine::IsUserAssemblyValid(); ScriptEngine::Get().SetSceneContext(nullptr); - - RestoreHarnessAssembly(); - - EXPECT_FALSE(buildWasQueued); -} - -// A second file change while the first build is still pending must cancel the -// first build and queue a fresh one. Only one build task should be in flight. -TEST(Scripting, ScriptEngine_VerifyRuntime_CancelsPendingBuildAndQueuesFresh) -{ - 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); - - EP_REQUIRE(TouchScript(scriptFile, "first")); ScriptEngine::Get().VerifyRuntime(); - ScriptEngine::Get().VerifyRuntime(); - - EP_REQUIRE(TouchScript(scriptFile, "second")); - ScriptEngine::Get().VerifyRuntime(); - - const auto pendingAfterCancel = threadPool->GetPendingTasksCount(); - - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - bool discovered = false; - while (std::chrono::steady_clock::now() < deadline) - { - threadPool->Flush(); - if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) - { - discovered = true; - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } + const bool compilingAfterStop = ScriptEngine::IsUserAssemblyCompiling(); + const bool finished = AdvanceUntilBuildFinishes(); + const bool replacementClassValid = ScriptEngine::Get().IsValidScriptClass("EppoTesting.ReplacementScript"); RestoreHarnessAssembly(); - EXPECT_EQ(1u, pendingAfterCancel); - EXPECT_TRUE(discovered); + EXPECT_TRUE(deferredObserved); + EXPECT_FALSE(compilingDuringPlay); + EXPECT_TRUE(validDuringPlay); + EXPECT_TRUE(compilingAfterStop); + EXPECT_TRUE(finished); + EXPECT_TRUE(replacementClassValid); } -// A failed build must unload the old assembly but not load a broken one: the class -// disappears and the user assembly is marked invalid. -TEST(Scripting, ScriptEngine_VerifyRuntime_BuildFailureDoesNotLoadBrokenAssembly) +TEST(Scripting, ScriptEngine_VerifyRuntime_BuildFailureLeavesPreviousAssemblyLoadedButInvalid) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -2717,93 +2764,37 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_BuildFailureDoesNotLoadBrokenAssembly 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); - EP_REQUIRE(TouchScript(scriptFile, "first")); - ScriptEngine::Get().VerifyRuntime(); - ScriptEngine::Get().VerifyRuntime(); - - const auto firstDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - while (std::chrono::steady_clock::now() < firstDeadline) - { - threadPool->Flush(); - if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) - break; - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - EP_REQUIRE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); - const std::string brokenSource = std::string(kProbeScriptSource) + "\n{{{{ garbage\n"; EP_REQUIRE(FS::WriteText(scriptFile, brokenSource, true)); + const bool buildStarted = WaitForBuildStart(); ScriptEngine::Get().VerifyRuntime(); - ScriptEngine::Get().VerifyRuntime(); - - const auto secondDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - while (std::chrono::steady_clock::now() < secondDeadline) - { - threadPool->Flush(); - if (!ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) - break; - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - - RestoreHarnessAssembly(); - - EXPECT_FALSE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); - EXPECT_FALSE(ScriptEngine::Get().IsUserAssemblyValid()); -} - -// A change detected during play mode defers the build; it triggers only after the -// scene context is cleared, queuing exactly one build task at that point. -TEST(Scripting, ScriptEngine_VerifyRuntime_BuildDeferredDuringPlayModeTriggersAfterStop) -{ - 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 Ref playScene = CreateRef(); - ScriptEngine::Get().SetSceneContext(playScene); - - EP_REQUIRE(TouchScript(scriptFile, "play-mode-change")); - ScriptEngine::Get().VerifyRuntime(); - ScriptEngine::Get().VerifyRuntime(); - - threadPool->Flush(); - EXPECT_EQ(0u, threadPool->GetPendingTasksCount()); - - ScriptEngine::Get().SetSceneContext(nullptr); - 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"); - EXPECT_GT(threadPool->GetPendingTasksCount(), 0u); - - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - bool discovered = false; - while (std::chrono::steady_clock::now() < deadline) - { - threadPool->Flush(); - if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) - { - discovered = true; - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } + 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(discovered); + 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. @@ -2820,178 +2811,29 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_BurstOfChangesCollapsesIntoOneBuild) 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))); - ScriptEngine::Get().VerifyRuntime(); - } - - EXPECT_EQ(1u, threadPool->GetPendingTasksCount()); - - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - bool discovered = false; - while (std::chrono::steady_clock::now() < deadline) - { - threadPool->Flush(); - if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) - { - discovered = true; - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - - RestoreHarnessAssembly(); - - EXPECT_TRUE(discovered); -} - -// The build task must register a "ScriptBuild" task-group snapshot that reports -// at least one task while running and is finished once the build completes. -TEST(Scripting, ScriptEngine_VerifyRuntime_UpdatesSnapshotForBuildTask) -{ - 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); - - EP_REQUIRE(TouchScript(scriptFile, "snapshot")); - ScriptEngine::Get().VerifyRuntime(); - ScriptEngine::Get().VerifyRuntime(); - - const auto snapshotsBefore = threadPool->GetTaskGroupSnapshots(); - auto itBefore = snapshotsBefore.find("ScriptBuild"); - ASSERT_NE(itBefore, snapshotsBefore.end()); - EXPECT_GE(itBefore->second.Total.load(std::memory_order_relaxed), 1u); - - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - bool finished = false; - while (std::chrono::steady_clock::now() < deadline) - { - threadPool->Flush(); - const auto snapshots = threadPool->GetTaskGroupSnapshots(); - auto it = snapshots.find("ScriptBuild"); - if (it != snapshots.end() && it->second.IsFinished()) - { - finished = true; - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - - const auto snapshotsAfter = threadPool->GetTaskGroupSnapshots(); - auto itAfter = snapshotsAfter.find("ScriptBuild"); - ASSERT_NE(itAfter, snapshotsAfter.end()); - EXPECT_GE(itAfter->second.Completed.load(std::memory_order_relaxed), 1u); - - RestoreHarnessAssembly(); - - EXPECT_TRUE(finished); -} - -// After a reload that renames the class, the old class name must be gone and the -// new one discovered. -TEST(Scripting, ScriptEngine_VerifyRuntime_OldClassesGoneAfterReloadWithChangedClassName) -{ - 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); - - EP_REQUIRE(TouchScript(scriptFile, "first")); - ScriptEngine::Get().VerifyRuntime(); - ScriptEngine::Get().VerifyRuntime(); - - const auto firstDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - while (std::chrono::steady_clock::now() < firstDeadline) - { - threadPool->Flush(); - if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) - break; - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - EP_REQUIRE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); - - const std::string renamedSource = R"(using EppoScriptCore.Scene; - -namespace EppoTesting -{ -public class RenamedScript : Entity -{ -} -} -)"; - EP_REQUIRE(FS::WriteText(scriptFile, renamedSource, true)); - ScriptEngine::Get().VerifyRuntime(); + const bool buildStarted = WaitForBuildStart(); ScriptEngine::Get().VerifyRuntime(); + const bool compilingObserved = ScriptEngine::IsUserAssemblyCompiling(); + const auto pendingAfterStart = threadPool->GetPendingTasksCount(); - const auto secondDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - while (std::chrono::steady_clock::now() < secondDeadline) - { - threadPool->Flush(); - if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.RenamedScript")) - break; - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - - RestoreHarnessAssembly(); - - EXPECT_FALSE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); - EXPECT_TRUE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.RenamedScript")); -} - -// Three successive async reload cycles must each complete and discover the class. -TEST(Scripting, ScriptEngine_VerifyRuntime_MultipleSuccessiveAsyncReloads) -{ - 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); - - for (int cycle = 0; cycle < 3; ++cycle) - { - EP_REQUIRE(TouchScript(scriptFile, "cycle-" + std::to_string(cycle))); - ScriptEngine::Get().VerifyRuntime(); + for (int i = 0; i < 5; ++i) ScriptEngine::Get().VerifyRuntime(); - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); - bool discovered = false; - while (std::chrono::steady_clock::now() < deadline) - { - threadPool->Flush(); - if (ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")) - { - discovered = true; - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - EP_REQUIRE(discovered); - } + const auto pendingAfterRepeatedVerification = threadPool->GetPendingTasksCount(); + const bool finished = AdvanceUntilBuildFinishes(); + const bool validAfterBuild = ScriptEngine::IsUserAssemblyValid(); RestoreHarnessAssembly(); - EXPECT_TRUE(ScriptEngine::Get().IsValidScriptClass("EppoTesting.ProbeScript")); + EXPECT_TRUE(buildStarted); + EXPECT_TRUE(compilingObserved); + EXPECT_EQ(pendingBefore + 1, pendingAfterStart); + EXPECT_EQ(pendingAfterStart, pendingAfterRepeatedVerification); + EXPECT_TRUE(finished); + EXPECT_TRUE(validAfterBuild); } From 28779ce8171d7c5601c0c791886278628208e468 Mon Sep 17 00:00:00 2001 From: Niels Eppenhof Date: Sun, 23 Aug 2026 04:27:02 +0200 Subject: [PATCH 5/7] Added RenderCommandQueue --- EppoEditor/Projects/Test/Test.epproj | 2 +- EppoEditor/Source/EditorLayer.cpp | 4 ++-- EppoEngine/Source/Asset/Asset.h | 4 +++- EppoEngine/Source/Asset/AssetManager.cpp | 8 ++++--- EppoEngine/Source/Core/Application.h | 2 +- EppoEngine/Source/Core/Base.h | 2 ++ EppoEngine/Source/Core/Log.h | 13 +++++++++- .../{ThreadPool => Threading}/ThreadPool.cpp | 14 +++++++---- .../{ThreadPool => Threading}/ThreadPool.h | 2 +- EppoEngine/Source/EppoEngine.h | 2 +- .../Source/Platform/Vulkan/VulkanShader.cpp | 2 ++ .../Source/Renderer/RenderCommandQueue.cpp | 23 ++++++++++++++++++ .../Source/Renderer/RenderCommandQueue.h | 24 +++++++++++++++++++ EppoEngine/Source/Renderer/Shader.cpp | 5 ++++ EppoEngine/Source/Renderer/Shader.h | 2 ++ EppoEngine/Source/Scripting/ScriptEngine.h | 2 +- EppoEngineTesting/Source/Core/ThreadPool.cpp | 2 +- 17 files changed, 95 insertions(+), 18 deletions(-) rename EppoEngine/Source/Core/{ThreadPool => Threading}/ThreadPool.cpp (97%) rename EppoEngine/Source/Core/{ThreadPool => Threading}/ThreadPool.h (98%) create mode 100644 EppoEngine/Source/Renderer/RenderCommandQueue.cpp create mode 100644 EppoEngine/Source/Renderer/RenderCommandQueue.h diff --git a/EppoEditor/Projects/Test/Test.epproj b/EppoEditor/Projects/Test/Test.epproj index 0a868ed6..28a39c5b 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": 18159251541775323644 } } \ No newline at end of file diff --git a/EppoEditor/Source/EditorLayer.cpp b/EppoEditor/Source/EditorLayer.cpp index f17f6a5b..69046e92 100644 --- a/EppoEditor/Source/EditorLayer.cpp +++ b/EppoEditor/Source/EditorLayer.cpp @@ -452,7 +452,7 @@ namespace Eppo return; // Backs up the disabled toolbar button. - if (!ScriptEngine::IsUserAssemblyCompiling()) + if (ScriptEngine::IsUserAssemblyCompiling()) { Log::Warn("Cannot enter play mode: the project's scripts are currently compiling."); return; @@ -1186,7 +1186,7 @@ namespace Eppo }; // Shown in edit mode too, unlike the notices below. - if (!ScriptEngine::IsUserAssemblyCompiling()) + 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."); 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 0f04536c..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); @@ -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& diff --git a/EppoEngine/Source/Core/Application.h b/EppoEngine/Source/Core/Application.h index 0478728c..7883124a 100644 --- a/EppoEngine/Source/Core/Application.h +++ b/EppoEngine/Source/Core/Application.h @@ -1,7 +1,7 @@ #pragma once #include "Core/Layer.h" -#include "Core/ThreadPool/ThreadPool.h" +#include "Core/Threading/ThreadPool.h" #include "Core/Window.h" #include "Event/ApplicationEvent.h" #include "ImGui/ImGuiLayer.h" diff --git a/EppoEngine/Source/Core/Base.h b/EppoEngine/Source/Core/Base.h index 309a7053..947c5e9c 100644 --- a/EppoEngine/Source/Core/Base.h +++ b/EppoEngine/Source/Core/Base.h @@ -43,9 +43,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..372574fb 100644 --- a/EppoEngine/Source/Core/Log.h +++ b/EppoEngine/Source/Core/Log.h @@ -194,6 +194,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 +212,4 @@ struct fmt::formatter : formatter { return formatter::format(static_cast(v), ctx); } -}; \ No newline at end of file +}; diff --git a/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp b/EppoEngine/Source/Core/Threading/ThreadPool.cpp similarity index 97% rename from EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp rename to EppoEngine/Source/Core/Threading/ThreadPool.cpp index 159597d1..88dc1cd8 100644 --- a/EppoEngine/Source/Core/ThreadPool/ThreadPool.cpp +++ b/EppoEngine/Source/Core/Threading/ThreadPool.cpp @@ -1,5 +1,5 @@ #include "pch.h" -#include "Core/ThreadPool/ThreadPool.h" +#include "Core/Threading/ThreadPool.h" #include @@ -50,15 +50,16 @@ namespace Eppo ThreadPool::ThreadPool() : m_OwnerThread(std::this_thread::get_id()) { - const uint32_t threadCount = std::max(1u, std::thread::hardware_concurrency() - 1); + 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]() -> void + [this, i]() -> void { - WorkerLoop(); + WorkerLoop(i); } ); } @@ -483,8 +484,11 @@ namespace Eppo return m_TasksPending.load(std::memory_order_seq_cst) + m_TasksInFlight.load(std::memory_order_seq_cst); } - auto ThreadPool::WorkerLoop() -> void + auto ThreadPool::WorkerLoop(uint32_t index) -> void { + const auto tag = std::format("Worker {}", index); + EP_TAG_THREAD(tag.c_str()); + while (true) { Ref task; diff --git a/EppoEngine/Source/Core/ThreadPool/ThreadPool.h b/EppoEngine/Source/Core/Threading/ThreadPool.h similarity index 98% rename from EppoEngine/Source/Core/ThreadPool/ThreadPool.h rename to EppoEngine/Source/Core/Threading/ThreadPool.h index 06a640ee..a2f41677 100644 --- a/EppoEngine/Source/Core/ThreadPool/ThreadPool.h +++ b/EppoEngine/Source/Core/Threading/ThreadPool.h @@ -120,7 +120,7 @@ namespace Eppo 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() -> void; + auto WorkerLoop(uint32_t index) -> void; [[nodiscard]] auto HasPendingTasks() const -> bool; [[nodiscard]] auto GetNextTask() -> Ref; diff --git a/EppoEngine/Source/EppoEngine.h b/EppoEngine/Source/EppoEngine.h index ecd5eda6..b99fae5a 100644 --- a/EppoEngine/Source/EppoEngine.h +++ b/EppoEngine/Source/EppoEngine.h @@ -12,7 +12,7 @@ #include "Core/Buffer/BufferWriter.h" #include "Core/Buffer/FileStreamReader.h" #include "Core/Buffer/FileStreamWriter.h" -#include "Core/ThreadPool/ThreadPool.h" +#include "Core/Threading/ThreadPool.h" #include "Core/Input.h" #include "Core/KeyCodes.h" #include "Core/Layer.h" 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/RenderCommandQueue.cpp b/EppoEngine/Source/Renderer/RenderCommandQueue.cpp new file mode 100644 index 00000000..caf6cd19 --- /dev/null +++ b/EppoEngine/Source/Renderer/RenderCommandQueue.cpp @@ -0,0 +1,23 @@ +#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 + { + for (size_t i = 0; i < m_CommandQueue.size(); i++) + m_CommandQueue.at(i)(); + + m_CommandQueue.clear(); + } + + 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/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.h b/EppoEngine/Source/Scripting/ScriptEngine.h index 656d297a..3825e993 100644 --- a/EppoEngine/Source/Scripting/ScriptEngine.h +++ b/EppoEngine/Source/Scripting/ScriptEngine.h @@ -1,6 +1,6 @@ #pragma once -#include "Core/ThreadPool/ThreadPool.h" +#include "Core/Threading/ThreadPool.h" #include "Scene/Entity.h" #include "Scripting/Assembly.h" #include "Scripting/ScriptClass.h" diff --git a/EppoEngineTesting/Source/Core/ThreadPool.cpp b/EppoEngineTesting/Source/Core/ThreadPool.cpp index 4c82e76d..fe5ee5f1 100644 --- a/EppoEngineTesting/Source/Core/ThreadPool.cpp +++ b/EppoEngineTesting/Source/Core/ThreadPool.cpp @@ -1,5 +1,5 @@ #include "TestSupport/EppoTest.h" -#include "Core/ThreadPool/ThreadPool.h" +#include "Core/Threading/ThreadPool.h" #include #include From d30267d11c09f22cc581c43e18f8709b855394c4 Mon Sep 17 00:00:00 2001 From: Niels Eppenhof Date: Mon, 24 Aug 2026 00:38:31 +0200 Subject: [PATCH 6/7] Added RenderCommandQueue and render commands batching Preparation for dedicated render thread --- EppoEngine/Source/Core/Application.cpp | 8 +- EppoEngine/Source/ImGui/ImGuiLayer.cpp | 35 +- EppoEngine/Source/ImGui/ImGuiRenderer.cpp | 18 +- EppoEngine/Source/ImGui/ImGuiRenderer.h | 4 +- .../Platform/Vulkan/DeviceManagerVK.cpp | 5 +- .../Source/Platform/Vulkan/DeviceManagerVK.h | 6 +- .../Source/Platform/Vulkan/Swapchain.cpp | 140 +- EppoEngine/Source/Platform/Vulkan/Swapchain.h | 29 +- EppoEngine/Source/Platform/Vulkan/Vulkan.h | 3 +- EppoEngine/Source/Renderer/DeviceManager.cpp | 1 + EppoEngine/Source/Renderer/DeviceManager.h | 3 +- .../Source/Renderer/RenderCommandBuffer.cpp | 78 +- .../Source/Renderer/RenderCommandBuffer.h | 6 +- .../Source/Renderer/RenderCommandQueue.cpp | 7 +- EppoEngine/Source/Renderer/Renderer.cpp | 109 +- EppoEngine/Source/Renderer/Renderer.h | 6 + EppoEngine/Source/Renderer/SceneRenderer.cpp | 1126 +++++++++-------- EppoEngine/Source/Renderer/SceneRenderer.h | 5 +- EppoEngineTesting/Source/Core/Application.cpp | 173 +++ EppoEngineTesting/Source/Core/ThreadPool.cpp | 32 +- .../Source/Renderer/RenderCommandBuffer.cpp | 78 +- .../Source/Renderer/RenderCommandQueue.cpp | 87 ++ .../Source/Renderer/SceneRendering.cpp | 220 +++- RENDER_THREAD_PREPARATION_PLAN.md | 1092 ++++++++++++++++ 24 files changed, 2491 insertions(+), 780 deletions(-) create mode 100644 EppoEngineTesting/Source/Renderer/RenderCommandQueue.cpp create mode 100644 RENDER_THREAD_PREPARATION_PLAN.md diff --git a/EppoEngine/Source/Core/Application.cpp b/EppoEngine/Source/Core/Application.cpp index 00a6ba96..7fa138f6 100644 --- a/EppoEngine/Source/Core/Application.cpp +++ b/EppoEngine/Source/Core/Application.cpp @@ -60,7 +60,7 @@ 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();) @@ -101,10 +101,11 @@ namespace Eppo 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(); @@ -115,6 +116,9 @@ namespace Eppo m_ImGuiLayer->Render(); } + // Execute render commands + Renderer::ExecuteRenderCommands(); + // Present m_DeviceManager->Present(); } 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/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/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 index caf6cd19..89f76c8d 100644 --- a/EppoEngine/Source/Renderer/RenderCommandQueue.cpp +++ b/EppoEngine/Source/Renderer/RenderCommandQueue.cpp @@ -10,10 +10,11 @@ namespace Eppo auto RenderCommandQueue::Execute() -> void { - for (size_t i = 0; i < m_CommandQueue.size(); i++) - m_CommandQueue.at(i)(); + std::vector commands; + commands.swap(m_CommandQueue); - m_CommandQueue.clear(); + for (auto& command : commands) + command(); } auto RenderCommandQueue::Clear() -> void 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 5981370b..0547c53a 100644 --- a/EppoEngine/Source/Renderer/SceneRenderer.cpp +++ b/EppoEngine/Source/Renderer/SceneRenderer.cpp @@ -515,8 +515,8 @@ namespace Eppo 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(); @@ -676,10 +676,16 @@ namespace Eppo EnsureColliderMeshes(); GatherWireframes(); + PrepareRenderData(); - m_RenderCommandBuffer->Begin(); - PrepareRender(); + Renderer::Submit( + [this]() + { + m_RenderCommandBuffer->Begin(); + } + ); + UploadRenderData(); ShadowDepthPass(); SsaoPass(); GeometryPass(); @@ -688,11 +694,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 +843,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; @@ -1042,47 +1056,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,45 +1122,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. - 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)); + 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 @@ -1263,591 +1289,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{}; - - auto& statistics = m_SsaoPrePass->GetStatistics(); - - m_RenderCommandBuffer->BeginTimerQuery(m_SsaoPrePass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_SsaoPrePass); + EP_PROFILE_FN("SceneRenderer::SsaoPass") + EP_GPU_ZONE(m_RenderCommandBuffer, "SsaoPass") - auto& state = m_RenderCommandBuffer->GetGraphicsState(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + m_RenderCommandBuffer->BeginMarker("SSAO"); - 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{}; - - auto& statistics = m_GeometryPass->GetStatistics(); - const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); + Renderer::Submit( + [this]() + { + EP_PROFILE_FN("SceneRenderer::GeometryPass") + EP_GPU_ZONE(m_RenderCommandBuffer, "GeometryPass") - m_RenderCommandBuffer->BeginTimerQuery(m_GeometryPass->GetName()); + struct PC + { + uint32_t DrawIndex; + } pushConstants{}; - const auto& shadowMap = m_ShadowDepthPass->GetFramebuffer()->GetDepthImage(); - cmdList->setTextureState(shadowMap->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); + auto& statistics = m_GeometryPass->GetStatistics(); + const auto& cmdList = m_RenderCommandBuffer->GetCommandList(); - m_RenderCommandBuffer->BeginMarker(m_GeometryPass->GetName()); - Renderer::BeginRenderPass(m_RenderCommandBuffer, m_GeometryPass); + m_RenderCommandBuffer->BeginTimerQuery(m_GeometryPass->GetName()); - auto& state = m_RenderCommandBuffer->GetGraphicsState(); + const auto& shadowMap = m_ShadowDepthPass->GetFramebuffer()->GetDepthImage(); + cmdList->setTextureState(shadowMap->GetTexture(), nvrhi::AllSubresources, nvrhi::ResourceStates::ShaderResource); - 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, - }; + m_RenderCommandBuffer->BeginMarker(m_GeometryPass->GetName()); + Renderer::BeginRenderPass(m_RenderCommandBuffer, m_GeometryPass); - 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(); + auto& state = m_RenderCommandBuffer->GetGraphicsState(); - 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/EppoEngineTesting/Source/Core/Application.cpp b/EppoEngineTesting/Source/Core/Application.cpp index 0effc12d..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 @@ -85,6 +87,65 @@ class ThreadPoolTeardownLayer : public Layer 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(); @@ -237,3 +298,115 @@ TEST(App, Application_ShutdownFlushesTaskCompletionsBeforeDetachingLayers) 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 index fe5ee5f1..847778fa 100644 --- a/EppoEngineTesting/Source/Core/ThreadPool.cpp +++ b/EppoEngineTesting/Source/Core/ThreadPool.cpp @@ -8,15 +8,15 @@ using Eppo::TaskFn; using Eppo::TaskId; -using Eppo::TaskStatus; 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(5); + constexpr auto s_WaitTimeout = std::chrono::seconds(1); auto WaitUntil(const std::function& predicate) -> bool { @@ -56,6 +56,12 @@ namespace 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) @@ -589,7 +595,7 @@ TEST(Core, ThreadPool_QueueTaskWithDependencies_ChainOfThousandTasksCompletesInO // guarantees the target is still queued when CancelTask runs. TEST(Core, ThreadPool_CancelTask_CancelsPendingTask) { - const auto workerCount = std::max(1u, std::thread::hardware_concurrency() - 1); + const auto workerCount = GetThreadPoolWorkerCount(); const auto taskCount = workerCount + 1; std::atomic gate = false; @@ -705,7 +711,9 @@ TEST(Core, ThreadPool_CancelTask_ReturnsFalseForCompletedTask) const auto id = pool.QueueTask( "Done", - []() -> void {}, + []() -> void + { + }, [&invoked](TaskStatus) -> void { invoked.store(true); @@ -739,7 +747,7 @@ TEST(Core, ThreadPool_CancelTask_ReturnsFalseForUnknownId) // Cancelled increments. The snapshot is the editor's progress UI source of truth. TEST(Core, ThreadPool_CancelTask_UpdatesGroupSnapshot) { - const auto workerCount = std::max(1u, std::thread::hardware_concurrency() - 1); + const auto workerCount = GetThreadPoolWorkerCount(); const auto taskCount = workerCount + 2; std::atomic gate = false; @@ -806,7 +814,7 @@ TEST(Core, ThreadPool_CancelTask_UpdatesGroupSnapshot) // signal clears when the last task is cancelled, not when a worker picks it up. TEST(Core, ThreadPool_CancelTask_DecrementsPendingCount) { - const auto workerCount = std::max(1u, std::thread::hardware_concurrency() - 1); + const auto workerCount = GetThreadPoolWorkerCount(); const auto taskCount = workerCount + 1; std::atomic gate = false; @@ -860,7 +868,7 @@ TEST(Core, ThreadPool_CancelTask_DecrementsPendingCount) // Cancelling an already-cancelled task is a no-op, not a double-cancel. TEST(Core, ThreadPool_CancelTask_AlreadyCancelledReturnsFalse) { - const auto workerCount = std::max(1u, std::thread::hardware_concurrency() - 1); + const auto workerCount = GetThreadPoolWorkerCount(); const auto taskCount = workerCount + 1; std::atomic gate = false; @@ -948,7 +956,7 @@ TEST(Core, ThreadPool_CancelTask_FailedTaskReturnsFalse) // The dependent should run (or be cancellable separately) — it must not deadlock. TEST(Core, ThreadPool_CancelTask_DependentStillResolvesAfterDependencyCancelled) { - const auto workerCount = std::max(1u, std::thread::hardware_concurrency() - 1); + const auto workerCount = GetThreadPoolWorkerCount(); const auto fillerCount = workerCount; std::atomic gate = false; @@ -982,7 +990,9 @@ TEST(Core, ThreadPool_CancelTask_DependentStillResolvesAfterDependencyCancelled) // Queue a dependency that will be cancelled while pending. const auto depId = pool.QueueTask( "Dependency", - []() -> void {}, + []() -> void + { + }, [&dependencyCompletionFired, &dependencyStatus](const TaskStatus status) -> void { dependencyStatus.store(status); @@ -1138,8 +1148,8 @@ TEST(Core, ThreadPool_GetTaskGroupSnapshots_RemainsCoherentDuringConcurrentTrans { 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); + 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; } 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/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. From d1f7fac4aad2ecaa89a10bdecfc378e8b3707185 Mon Sep 17 00:00:00 2001 From: Niels Eppenhof Date: Mon, 24 Aug 2026 01:56:44 +0200 Subject: [PATCH 7/7] Fix CI Extracted tests that needed the graphical application for scripting into their own graphical suite --- .../Source/Scripting/Scripting.cpp | 22 +++++++++---------- Scripts/Premake/Testing.lua | 1 + 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/EppoEngineTesting/Source/Scripting/Scripting.cpp b/EppoEngineTesting/Source/Scripting/Scripting.cpp index 024d660f..f5a9cb1c 100644 --- a/EppoEngineTesting/Source/Scripting/Scripting.cpp +++ b/EppoEngineTesting/Source/Scripting/Scripting.cpp @@ -631,7 +631,7 @@ 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()) @@ -696,7 +696,7 @@ public class ProbeScript : Entity EXPECT_EQ(true, discovered); } -TEST(Scripting, ScriptEngine_ReloadProjectAssembly_WithBrokenScript_LeavesPreviousAssemblyLoadedButInvalid) +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_WithBrokenScript_LeavesPreviousAssemblyLoadedButInvalid) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -782,7 +782,7 @@ TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ProjectWithoutCsproj_Succeeds EXPECT_TRUE(valid); } -TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ReplacesOldClassesWithNewOnes) +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_ReplacesOldClassesWithNewOnes) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -865,7 +865,7 @@ public class ProbeScriptB : Entity EXPECT_TRUE(newPresent); } -TEST(Scripting, ScriptEngine_ReloadProjectAssembly_ClearsEntityInstances) +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_ClearsEntityInstances) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -935,7 +935,7 @@ public class ProbeScript : Entity EXPECT_TRUE(instanceCleared); } -TEST(Scripting, ScriptEngine_ReloadProjectAssembly_PreservesFieldStorage) +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_PreservesFieldStorage) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -1017,7 +1017,7 @@ public class ProbeScript : Entity EXPECT_TRUE(valueSurvived); } -TEST(Scripting, ScriptEngine_ReloadProjectAssembly_MultipleReloadsInSequence) +TEST(ScriptReloadGraphical, ScriptEngine_ReloadProjectAssembly_MultipleReloadsInSequence) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -2644,7 +2644,7 @@ public class ReplacementScript : Entity } } -TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotBlockMainThread) +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_DoesNotBlockMainThread) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -2684,7 +2684,7 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotBlockMainThread) // After the build completes, a normal application frame must publish the replacement // assembly so its script classes become available. -TEST(Scripting, ScriptEngine_VerifyRuntime_SuccessfulBuildReplacesAssembly) +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_SuccessfulBuildReplacesAssembly) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -2718,7 +2718,7 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_SuccessfulBuildReplacesAssembly) } // While a scene context is set (play mode), VerifyRuntime must not trigger a build. -TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotReloadDuringPlayMode) +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_DoesNotReloadDuringPlayMode) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -2756,7 +2756,7 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_DoesNotReloadDuringPlayMode) EXPECT_TRUE(replacementClassValid); } -TEST(Scripting, ScriptEngine_VerifyRuntime_BuildFailureLeavesPreviousAssemblyLoadedButInvalid) +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_BuildFailureLeavesPreviousAssemblyLoadedButInvalid) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) @@ -2798,7 +2798,7 @@ TEST(Scripting, ScriptEngine_VerifyRuntime_BuildFailureLeavesPreviousAssemblyLoa } // Five rapid saves must collapse into a single pending build task, not five. -TEST(Scripting, ScriptEngine_VerifyRuntime_BurstOfChangesCollapsesIntoOneBuild) +TEST(ScriptReloadGraphical, ScriptEngine_VerifyRuntime_BurstOfChangesCollapsesIntoOneBuild) { EP_REQUIRE(EnsureRuntime()); if (!Testing::AppHarness::IsAvailable()) 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" },