From 25a365fce7e709c0e19620cf548918242c0b62e1 Mon Sep 17 00:00:00 2001 From: atdunbg Date: Thu, 24 Sep 2026 14:32:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=8E=AF=E5=A2=83=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E2=80=94=E2=80=94HDR=20=E8=A7=A3=E7=A0=81?= =?UTF-8?q?=E7=A7=BB=E5=90=8E=E5=8F=B0=E7=BA=BF=E7=A8=8B=EF=BC=8C=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E5=88=87=E6=8D=A2=E4=B8=8D=E5=8D=A1=E4=B8=BB?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode 是切换耗时大头(4k ≈ 130-160ms),搬出主线程后运行时切换 只剩 GPU 段(~40ms)。IblGenerator 拆两段式:DecodeHdr(static, 纯 CPU 无线程状态,worker 可调)+ FromDecoded(GPU 生成,主线程); FromHdrEquirect 保留为同步便捷路径。Assets 层经 IAssetUploader 重载 + AssetManager 重载传递预解码产物(路径已缓存则丢弃去重)。 App 层单 in-flight 状态机:worker 只产出纯 CPU 像素数据,忙肋期 新请求记 pending 接力(只保留最终意图),OnUpdate 每帧收割 + SetEnvironment 换绑,析构 join。 实测(RTX 5060 Laptop,4 实例网格 + 运行中连切 3 环境): ~180fps 无感知尖峰;0 validation。 --- App/main.cpp | 105 ++++++++++++++++-- Engine/include/Engine/Assets/AssetManager.h | 42 ++++--- Engine/include/Engine/Assets/IAssetUploader.h | 4 + Engine/include/Engine/Renderer/Cubemap.h | 12 ++ Engine/include/Engine/Renderer/IblGenerator.h | 10 ++ .../Engine/Renderer/VulkanAssetUploader.h | 2 + Engine/src/Assets/AssetManager.cpp | 11 ++ Engine/src/Renderer/IblGenerator.cpp | 44 ++++++-- Engine/src/Renderer/VulkanAssetUploader.cpp | 7 ++ 9 files changed, 202 insertions(+), 35 deletions(-) diff --git a/App/main.cpp b/App/main.cpp index 27b86b6..d79ab24 100644 --- a/App/main.cpp +++ b/App/main.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include @@ -24,6 +25,9 @@ #include +#include +#include + using namespace Engine; using namespace Engine::ECS; @@ -52,6 +56,15 @@ namespace { } + ~SandboxApp() override + { + // worker 只做 CPU 解码(不碰 Vulkan),在任何 GPU 资源 + // 析构之前 join 即安全;派生析构先于基类成员析构执行 + if (m_EnvWorker.joinable()) { + m_EnvWorker.join(); + } + } + protected: // ---- 生命周期 ---- @@ -75,9 +88,17 @@ namespace const auto model = GetAssets().LoadModel("assets/models/backpack/backpack.obj"); // const auto model = GetAssets().LoadModel("assets/models/pocket_watch_4k.gltf/pocket_watch_4k.gltf"); - m_Spinners.push_back(scene.CreateEntity("backpack")); - m_Spinners[0].AddComponent(MeshRendererComponent{model}); - m_Spinners[0].GetComponent().SetScale(glm::vec3{1.0}); + int countSize = 2; + for (int i = 0; i < countSize; i ++) + { + for (int j = 0; j < countSize; j ++) + { + auto m = scene.CreateEntity(); + m.AddComponent(MeshRendererComponent{model}); + m.GetComponent().SetPosition(glm::vec3{(i - countSize * 0.5) * 2, 0, (j - countSize * 0.5) * 2}); + m_Spinners.push_back(m); + } + } // 地面:程序化纯色平面(半边长 15,中心 y=-2) const auto ground = GetAssets().CreatePlane(15.0f, 140, 140, 145); @@ -134,6 +155,9 @@ namespace void OnUpdate(f32 deltaTime) override { + // 异步环境加载:每帧收割完成的解码结果 + PollEnvironment(); + // 暂停时冻结动画时钟(帧历史验证:暂停后 Temporal Diff // 视图应在 2 帧内整屏变黑;相机移动同样会被 diff 捕捉到) if (!m_PauseAnim) @@ -157,6 +181,61 @@ namespace // ---- 事件 ---- + // ---- 异步环境加载(decode 后台线程 / GPU 生成主线程)---- + // decode 占切换耗时大头(4k ≈ 130ms);worker 只产出纯 CPU + // 像素数据,Vulkan 生成回主线程(设备非线程安全)。 + // 同一时刻最多一个 in-flight,忙碌期的新请求记为 pending 接力。 + void RequestEnvironment(int idx) + { + if (m_EnvLoadBusy) + { + m_EnvPending = idx; + return; + } + StartEnvDecode(idx); + } + + void StartEnvDecode(int idx) + { + m_EnvLoadBusy = true; + m_EnvLoadDone.store(false); + m_EnvLoadingPath = kEnvPaths[idx]; + m_EnvWorker = std::thread([this, idx]() { + m_EnvDecoded = IblGenerator::DecodeHdr(kEnvPaths[idx]); + m_EnvLoadDone.store(true); // release:解码结果对主线程可见 + }); + } + + void PollEnvironment() + { + if (!m_EnvLoadBusy || !m_EnvLoadDone.load()) + { + return; + } + + m_EnvWorker.join(); // join = 同步点,解码结果此后归主线程 + m_EnvLoadBusy = false; + + if (m_EnvDecoded.Valid()) + { + const auto ref = GetAssets().LoadEnvironment( + m_EnvLoadingPath, std::move(m_EnvDecoded)); + if (ref.Valid()) + { + GetRenderer().SetEnvironment(GetAssets().GetEnvironment(ref)); + } + } + m_EnvDecoded = {}; + + // 忙碌期间的最新请求接力(rapid switch 只保留最终意图) + if (m_EnvPending >= 0) + { + const int next = m_EnvPending; + m_EnvPending = -1; + StartEnvDecode(next); + } + } + // ---- 叠加层 UI(唯一允许出现 ImGui 控件的地方)---- void OnRenderOverlay() override @@ -240,14 +319,12 @@ namespace int envIdx = m_EnvIndex; if (ImGui::Combo("env", &envIdx, kEnvNames, kEnvCount)) { - const auto ref = GetAssets().LoadEnvironment(kEnvPaths[envIdx]); - if (ref.Valid()) - { - renderer.SetEnvironment(GetAssets().GetEnvironment(ref)); - m_EnvIndex = envIdx; - } + // 异步:解码后台线程,完成后 OnUpdate 里换绑 + m_EnvIndex = envIdx; + RequestEnvironment(envIdx); } - ImGui::Text("(switch = compute pre-integrate, ms-level)"); + ImGui::Text(m_EnvLoadBusy ? "(decoding...)" : + "(switch = compute pre-integrate, ms-level)"); // 调试视图:单看某个 pass 的中间输出(类 UE pass debug) const auto& viewNames = renderer.GetDebugViewNames(); @@ -376,6 +453,14 @@ namespace f32 m_Time = 0.0f; bool m_PauseAnim = true; // 冻结动画时钟(帧历史 diff 验证用) int m_EnvIndex = kDefaultEnv; + + // ---- 异步环境加载状态(单 in-flight + pending 接力)---- + std::thread m_EnvWorker; + std::string m_EnvLoadingPath; + DecodedEquirect m_EnvDecoded; // worker 写 → join 后主线程读 + std::atomic m_EnvLoadDone{false}; + bool m_EnvLoadBusy = false; + int m_EnvPending = -1; }; } diff --git a/Engine/include/Engine/Assets/AssetManager.h b/Engine/include/Engine/Assets/AssetManager.h index 12da4b7..3df5c7a 100644 --- a/Engine/include/Engine/Assets/AssetManager.h +++ b/Engine/include/Engine/Assets/AssetManager.h @@ -27,13 +27,15 @@ #include #include -namespace Engine { - namespace Assets { - +namespace Engine +{ + namespace Assets + { // ---------- 通用资产缓存(不感知 Vulkan / 磁盘细节由 loader 决定) ---------- - template - class AssetCache { + template + class AssetCache + { public: // loader:path → 资产实例(失败返回 nullptr) using LoaderFn = std::function(const std::string&)>; @@ -41,23 +43,28 @@ namespace Engine { // 加载(带路径去重)。失败返回无效句柄。 AssetRef Load(const std::string& path, const LoaderFn& loader) { - if (auto it = m_ByPath.find(path); it != m_ByPath.end()) { + if (auto it = m_ByPath.find(path); it != m_ByPath.end()) + { return it->second; } Scope asset = loader(path); - if (!asset) { + if (!asset) + { ENGINE_ERROR("AssetCache: failed to load '{}'", path); return {}; } AssetRef h; - if (!m_Free.empty()) { + if (!m_Free.empty()) + { h.index = m_Free.back(); m_Free.pop_back(); h.generation = m_Entries[h.index].generation; m_Entries[h.index] = Entry{path, h.generation, std::move(asset), true}; - } else { + } + else + { h.index = static_cast(m_Entries.size()); h.generation = 1; m_Entries.push_back(Entry{path, h.generation, std::move(asset), true}); @@ -70,12 +77,14 @@ namespace Engine { // 句柄 → 资产指针。悬空句柄 FATAL(bug 信号);已卸载返回 nullptr。 T* Get(AssetRef h) const { - if (!h.Valid() || h.index >= m_Entries.size()) { + if (!h.Valid() || h.index >= m_Entries.size()) + { ENGINE_FATAL("AssetCache::Get: invalid handle (index={}, gen={})", h.index, h.generation); } const Entry& e = m_Entries[h.index]; - if (e.generation != h.generation) { + if (e.generation != h.generation) + { ENGINE_FATAL("AssetCache::Get: stale handle for '{}' (gen {} != {})", e.path, h.generation, e.generation); } @@ -114,7 +123,8 @@ namespace Engine { } private: - struct Entry { + struct Entry + { std::string path; u32 generation = 1; Scope asset; @@ -128,7 +138,8 @@ namespace Engine { // ---------- AssetManager:全引擎资产入口 ---------- - class AssetManager { + class AssetManager + { public: // 上传服务由组合根(Application)注入:渲染层实现接口, // 资产层不感知 Vulkan 具体类 @@ -148,6 +159,10 @@ namespace Engine { // 环境资产(HDR → IBL 预积分四件套)。路径缓存去重, // 重复 Load 同一路径零成本(动态切换场景直接反复调用) EnvRef LoadEnvironment(const std::string& path); + // 异步路径:解码已在后台线程完成(若路径已缓存,解码产物被 + // 丢弃去重——上层可先查缓存再起 worker,此处不做强约束) + EnvRef LoadEnvironment(const std::string& path, + DecodedEquirect&& decoded); Cubemap* GetEnvironment(EnvRef h) const { return m_Environments.Get(h); } AssetCache& Models() { return m_Models; } @@ -161,7 +176,6 @@ namespace Engine { AssetCache m_Models; AssetCache m_Environments; }; - } // namespace Assets } // namespace Engine diff --git a/Engine/include/Engine/Assets/IAssetUploader.h b/Engine/include/Engine/Assets/IAssetUploader.h index b81f892..6d7b77c 100644 --- a/Engine/include/Engine/Assets/IAssetUploader.h +++ b/Engine/include/Engine/Assets/IAssetUploader.h @@ -35,6 +35,10 @@ namespace Engine::Assets { // 环境 HDR → IBL 预积分四件套(compute 生成,毫秒级;失败返回 nullptr) virtual Scope LoadEnvironment(const std::string& path) = 0; + + // 异步加载路径:解码已在后台线程完成,此调用只做 GPU 生成(主线程) + virtual Scope LoadEnvironment(const std::string& path, + DecodedEquirect&& decoded) = 0; }; } // namespace Engine::Assets diff --git a/Engine/include/Engine/Renderer/Cubemap.h b/Engine/include/Engine/Renderer/Cubemap.h index 04d86b6..45c71b3 100644 --- a/Engine/include/Engine/Renderer/Cubemap.h +++ b/Engine/include/Engine/Renderer/Cubemap.h @@ -20,11 +20,23 @@ #include #include #include +#include namespace Engine { class Device; + // 环境的 CPU 源数据(等距柱状 HDR 解码产物,RGBA float 行优先)。 + // 纯数据无线程状态:异步加载时由后台线程产出(DecodeHdr), + // 主线程消费(FromDecoded → GPU 生成)。decode 是切换耗时大头, + // 搬出主线程后运行时切换只剩 GPU 段(~40ms)。 + struct DecodedEquirect { + std::vector pixels; + u32 w = 0; + u32 h = 0; + bool Valid() const { return !pixels.empty() && w > 0 && h > 0; } + }; + // GPU 纹理资源三元组(RHI 惯例):image/view/显存分配同生共死, // 析构统一释放——杜绝"建了 image 丢了 view"类半套泄漏。 // 上传 helper 直接以此为单位产出,消费端只拿引用计数语义的整单元 diff --git a/Engine/include/Engine/Renderer/IblGenerator.h b/Engine/include/Engine/Renderer/IblGenerator.h index d0f6030..4610afd 100644 --- a/Engine/include/Engine/Renderer/IblGenerator.h +++ b/Engine/include/Engine/Renderer/IblGenerator.h @@ -25,6 +25,7 @@ #define HELLOVULKAN_IBLGENERATOR_H #include +#include // DecodedEquirect(解码产物数据契约) #include #include #include @@ -47,9 +48,18 @@ namespace Engine { IblGenerator(const IblGenerator&) = delete; IblGenerator& operator=(const IblGenerator&) = delete; + // ---- 同步便捷路径(等价 DecodeHdr + FromDecoded)---- // 等距柱状 HDR → 环境四件套(失败返回 nullptr,上层走 Neutral 兜底) Scope FromHdrEquirect(const std::string& path, u32 faceSize); + // ---- 异步加载两段式(decode 是切换耗时大头,可搬后台线程)---- + // 纯 CPU:HDR 文件 → 像素数据。无线程状态,worker 线程可安全调用。 + static DecodedEquirect DecodeHdr(const std::string& path); + + // GPU 生成(必须主线程):预解码数据 → 四件套。debugName 仅用于日志。 + Scope FromDecoded(const DecodedEquirect& decoded, u32 faceSize, + const std::string& debugName); + // 中性兜底:常数色环境 + 真实 BRDF LUT(描述符恒有效) Scope Neutral(const glm::vec3& rgb); diff --git a/Engine/include/Engine/Renderer/VulkanAssetUploader.h b/Engine/include/Engine/Renderer/VulkanAssetUploader.h index 747a9bc..74e0971 100644 --- a/Engine/include/Engine/Renderer/VulkanAssetUploader.h +++ b/Engine/include/Engine/Renderer/VulkanAssetUploader.h @@ -34,6 +34,8 @@ namespace Engine { Scope LoadModel(const std::string& path) override; Scope CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) override; Scope LoadEnvironment(const std::string& path) override; + Scope LoadEnvironment(const std::string& path, + DecodedEquirect&& decoded) override; private: Device& m_Device; diff --git a/Engine/src/Assets/AssetManager.cpp b/Engine/src/Assets/AssetManager.cpp index 7f98716..102447c 100644 --- a/Engine/src/Assets/AssetManager.cpp +++ b/Engine/src/Assets/AssetManager.cpp @@ -37,4 +37,15 @@ namespace Engine::Assets { [this](const std::string& p) { return m_Uploader.LoadEnvironment(p); }); } + EnvRef AssetManager::LoadEnvironment(const std::string& path, + DecodedEquirect&& decoded) + { + // 预解码数据移交 loader(Load 同步调用 lambda,引用捕获安全); + // 路径已缓存则 loader 不执行,decoded 自然丢弃(去重) + return m_Environments.Load(path, + [this, &decoded](const std::string& p) { + return m_Uploader.LoadEnvironment(p, std::move(decoded)); + }); + } + } // namespace Engine::Assets diff --git a/Engine/src/Renderer/IblGenerator.cpp b/Engine/src/Renderer/IblGenerator.cpp index c5566ec..f8d0db9 100644 --- a/Engine/src/Renderer/IblGenerator.cpp +++ b/Engine/src/Renderer/IblGenerator.cpp @@ -197,26 +197,48 @@ namespace Engine } } - Scope IblGenerator::FromHdrEquirect(const std::string& path, - u32 faceSize) + DecodedEquirect IblGenerator::DecodeHdr(const std::string& path) { - const auto t0 = std::chrono::steady_clock::now(); - + // stbi_loadf 无共享可变状态(失败原因每次调用独立),可并发调用 int w = 0, h = 0, channels = 0; f32* pixels = stbi_loadf(path.c_str(), &w, &h, &channels, STBI_rgb_alpha); if (!pixels) { ENGINE_ERROR("IblGenerator: failed to load HDR: {}", path); - return nullptr; + return {}; } - const f32 decodeMs = MsSince(t0); - auto cubemap = Generate(pixels, static_cast(w), - static_cast(h), faceSize, path); + DecodedEquirect decoded; + decoded.w = static_cast(w); + decoded.h = static_cast(h); + decoded.pixels.assign(pixels, + pixels + static_cast(w) * h * 4); stbi_image_free(pixels); + return decoded; + } - ENGINE_INFO("IblGenerator: HDR decode {:.2f} ms", - decodeMs); - return cubemap; + Scope IblGenerator::FromDecoded(const DecodedEquirect& decoded, + u32 faceSize, + const std::string& debugName) + { + if (!decoded.Valid()) { + ENGINE_ERROR("IblGenerator: invalid decoded equirect ({})", + debugName); + return nullptr; + } + return Generate(decoded.pixels.data(), decoded.w, decoded.h, + faceSize, debugName); + } + + Scope IblGenerator::FromHdrEquirect(const std::string& path, + u32 faceSize) + { + const auto t0 = std::chrono::steady_clock::now(); + DecodedEquirect decoded = DecodeHdr(path); + if (!decoded.Valid()) { + return nullptr; + } + ENGINE_INFO("IblGenerator: HDR decode {:.2f} ms", MsSince(t0)); + return FromDecoded(decoded, faceSize, path); } Scope IblGenerator::Neutral(const glm::vec3& rgb) diff --git a/Engine/src/Renderer/VulkanAssetUploader.cpp b/Engine/src/Renderer/VulkanAssetUploader.cpp index ecbe113..9350c21 100644 --- a/Engine/src/Renderer/VulkanAssetUploader.cpp +++ b/Engine/src/Renderer/VulkanAssetUploader.cpp @@ -27,4 +27,11 @@ namespace Engine { return m_Ibl.FromHdrEquirect(path, 1024); } + Scope VulkanAssetUploader::LoadEnvironment( + const std::string& path, DecodedEquirect&& decoded) + { + // 异步路径:decode 已在 worker 线程完成,此处只剩 GPU 段 + return m_Ibl.FromDecoded(decoded, 1024, path); + } + } // namespace Engine