feat: 环境异步加载——HDR 解码移后台线程,运行时切换不卡主线程

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。
This commit is contained in:
2026-09-24 14:32:39 +08:00
parent 641f79d89e
commit 25a365fce7
9 changed files with 202 additions and 35 deletions
+94 -9
View File
@@ -16,6 +16,7 @@
#include <Engine/ECS/Components.h> #include <Engine/ECS/Components.h>
#include <Engine/Assets/AssetManager.h> #include <Engine/Assets/AssetManager.h>
#include <Engine/Renderer/Renderer.h> #include <Engine/Renderer/Renderer.h>
#include <Engine/Renderer/IblGenerator.h>
#include <imgui.h> #include <imgui.h>
@@ -24,6 +25,9 @@
#include <GLFW/glfw3.h> #include <GLFW/glfw3.h>
#include <atomic>
#include <thread>
using namespace Engine; using namespace Engine;
using namespace Engine::ECS; using namespace Engine::ECS;
@@ -52,6 +56,15 @@ namespace
{ {
} }
~SandboxApp() override
{
// worker 只做 CPU 解码(不碰 Vulkan),在任何 GPU 资源
// 析构之前 join 即安全;派生析构先于基类成员析构执行
if (m_EnvWorker.joinable()) {
m_EnvWorker.join();
}
}
protected: protected:
// ---- 生命周期 ---- // ---- 生命周期 ----
@@ -75,9 +88,17 @@ namespace
const auto model = GetAssets().LoadModel("assets/models/backpack/backpack.obj"); 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"); // const auto model = GetAssets().LoadModel("assets/models/pocket_watch_4k.gltf/pocket_watch_4k.gltf");
m_Spinners.push_back(scene.CreateEntity("backpack")); int countSize = 2;
m_Spinners[0].AddComponent<MeshRendererComponent>(MeshRendererComponent{model}); for (int i = 0; i < countSize; i ++)
m_Spinners[0].GetComponent<TransformComponent>().SetScale(glm::vec3{1.0}); {
for (int j = 0; j < countSize; j ++)
{
auto m = scene.CreateEntity();
m.AddComponent<MeshRendererComponent>(MeshRendererComponent{model});
m.GetComponent<TransformComponent>().SetPosition(glm::vec3{(i - countSize * 0.5) * 2, 0, (j - countSize * 0.5) * 2});
m_Spinners.push_back(m);
}
}
// 地面:程序化纯色平面(半边长 15,中心 y=-2) // 地面:程序化纯色平面(半边长 15,中心 y=-2)
const auto ground = GetAssets().CreatePlane(15.0f, 140, 140, 145); const auto ground = GetAssets().CreatePlane(15.0f, 140, 140, 145);
@@ -134,6 +155,9 @@ namespace
void OnUpdate(f32 deltaTime) override void OnUpdate(f32 deltaTime) override
{ {
// 异步环境加载:每帧收割完成的解码结果
PollEnvironment();
// 暂停时冻结动画时钟(帧历史验证:暂停后 Temporal Diff // 暂停时冻结动画时钟(帧历史验证:暂停后 Temporal Diff
// 视图应在 2 帧内整屏变黑;相机移动同样会被 diff 捕捉到) // 视图应在 2 帧内整屏变黑;相机移动同样会被 diff 捕捉到)
if (!m_PauseAnim) 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 控件的地方)---- // ---- 叠加层 UI(唯一允许出现 ImGui 控件的地方)----
void OnRenderOverlay() override void OnRenderOverlay() override
@@ -240,14 +319,12 @@ namespace
int envIdx = m_EnvIndex; int envIdx = m_EnvIndex;
if (ImGui::Combo("env", &envIdx, kEnvNames, kEnvCount)) if (ImGui::Combo("env", &envIdx, kEnvNames, kEnvCount))
{ {
const auto ref = GetAssets().LoadEnvironment(kEnvPaths[envIdx]); // 异步:解码后台线程,完成后 OnUpdate 里换绑
if (ref.Valid())
{
renderer.SetEnvironment(GetAssets().GetEnvironment(ref));
m_EnvIndex = envIdx; m_EnvIndex = envIdx;
RequestEnvironment(envIdx);
} }
} ImGui::Text(m_EnvLoadBusy ? "(decoding...)" :
ImGui::Text("(switch = compute pre-integrate, ms-level)"); "(switch = compute pre-integrate, ms-level)");
// 调试视图:单看某个 pass 的中间输出(类 UE pass debug) // 调试视图:单看某个 pass 的中间输出(类 UE pass debug)
const auto& viewNames = renderer.GetDebugViewNames(); const auto& viewNames = renderer.GetDebugViewNames();
@@ -376,6 +453,14 @@ namespace
f32 m_Time = 0.0f; f32 m_Time = 0.0f;
bool m_PauseAnim = true; // 冻结动画时钟(帧历史 diff 验证用) bool m_PauseAnim = true; // 冻结动画时钟(帧历史 diff 验证用)
int m_EnvIndex = kDefaultEnv; int m_EnvIndex = kDefaultEnv;
// ---- 异步环境加载状态(单 in-flight + pending 接力)----
std::thread m_EnvWorker;
std::string m_EnvLoadingPath;
DecodedEquirect m_EnvDecoded; // worker 写 → join 后主线程读
std::atomic<bool> m_EnvLoadDone{false};
bool m_EnvLoadBusy = false;
int m_EnvPending = -1;
}; };
} }
+27 -13
View File
@@ -27,13 +27,15 @@
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
namespace Engine { namespace Engine
namespace Assets { {
namespace Assets
{
// ---------- 通用资产缓存(不感知 Vulkan / 磁盘细节由 loader 决定) ---------- // ---------- 通用资产缓存(不感知 Vulkan / 磁盘细节由 loader 决定) ----------
template <typename T> template <typename T>
class AssetCache { class AssetCache
{
public: public:
// loader:path → 资产实例(失败返回 nullptr) // loader:path → 资产实例(失败返回 nullptr)
using LoaderFn = std::function<Scope<T>(const std::string&)>; using LoaderFn = std::function<Scope<T>(const std::string&)>;
@@ -41,23 +43,28 @@ namespace Engine {
// 加载(带路径去重)。失败返回无效句柄。 // 加载(带路径去重)。失败返回无效句柄。
AssetRef<T> Load(const std::string& path, const LoaderFn& loader) AssetRef<T> 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; return it->second;
} }
Scope<T> asset = loader(path); Scope<T> asset = loader(path);
if (!asset) { if (!asset)
{
ENGINE_ERROR("AssetCache: failed to load '{}'", path); ENGINE_ERROR("AssetCache: failed to load '{}'", path);
return {}; return {};
} }
AssetRef<T> h; AssetRef<T> h;
if (!m_Free.empty()) { if (!m_Free.empty())
{
h.index = m_Free.back(); h.index = m_Free.back();
m_Free.pop_back(); m_Free.pop_back();
h.generation = m_Entries[h.index].generation; h.generation = m_Entries[h.index].generation;
m_Entries[h.index] = Entry{path, h.generation, std::move(asset), true}; m_Entries[h.index] = Entry{path, h.generation, std::move(asset), true};
} else { }
else
{
h.index = static_cast<u32>(m_Entries.size()); h.index = static_cast<u32>(m_Entries.size());
h.generation = 1; h.generation = 1;
m_Entries.push_back(Entry{path, h.generation, std::move(asset), true}); m_Entries.push_back(Entry{path, h.generation, std::move(asset), true});
@@ -70,12 +77,14 @@ namespace Engine {
// 句柄 → 资产指针。悬空句柄 FATAL(bug 信号);已卸载返回 nullptr。 // 句柄 → 资产指针。悬空句柄 FATAL(bug 信号);已卸载返回 nullptr。
T* Get(AssetRef<T> h) const T* Get(AssetRef<T> 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={})", ENGINE_FATAL("AssetCache::Get: invalid handle (index={}, gen={})",
h.index, h.generation); h.index, h.generation);
} }
const Entry& e = m_Entries[h.index]; 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 {} != {})", ENGINE_FATAL("AssetCache::Get: stale handle for '{}' (gen {} != {})",
e.path, h.generation, e.generation); e.path, h.generation, e.generation);
} }
@@ -114,7 +123,8 @@ namespace Engine {
} }
private: private:
struct Entry { struct Entry
{
std::string path; std::string path;
u32 generation = 1; u32 generation = 1;
Scope<T> asset; Scope<T> asset;
@@ -128,7 +138,8 @@ namespace Engine {
// ---------- AssetManager:全引擎资产入口 ---------- // ---------- AssetManager:全引擎资产入口 ----------
class AssetManager { class AssetManager
{
public: public:
// 上传服务由组合根(Application)注入:渲染层实现接口, // 上传服务由组合根(Application)注入:渲染层实现接口,
// 资产层不感知 Vulkan 具体类 // 资产层不感知 Vulkan 具体类
@@ -148,6 +159,10 @@ namespace Engine {
// 环境资产(HDR → IBL 预积分四件套)。路径缓存去重, // 环境资产(HDR → IBL 预积分四件套)。路径缓存去重,
// 重复 Load 同一路径零成本(动态切换场景直接反复调用) // 重复 Load 同一路径零成本(动态切换场景直接反复调用)
EnvRef LoadEnvironment(const std::string& path); 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); } Cubemap* GetEnvironment(EnvRef h) const { return m_Environments.Get(h); }
AssetCache<Model>& Models() { return m_Models; } AssetCache<Model>& Models() { return m_Models; }
@@ -161,7 +176,6 @@ namespace Engine {
AssetCache<Model> m_Models; AssetCache<Model> m_Models;
AssetCache<Cubemap> m_Environments; AssetCache<Cubemap> m_Environments;
}; };
} // namespace Assets } // namespace Assets
} // namespace Engine } // namespace Engine
@@ -35,6 +35,10 @@ namespace Engine::Assets {
// 环境 HDR → IBL 预积分四件套(compute 生成,毫秒级;失败返回 nullptr) // 环境 HDR → IBL 预积分四件套(compute 生成,毫秒级;失败返回 nullptr)
virtual Scope<Cubemap> LoadEnvironment(const std::string& path) = 0; virtual Scope<Cubemap> LoadEnvironment(const std::string& path) = 0;
// 异步加载路径:解码已在后台线程完成,此调用只做 GPU 生成(主线程)
virtual Scope<Cubemap> LoadEnvironment(const std::string& path,
DecodedEquirect&& decoded) = 0;
}; };
} // namespace Engine::Assets } // namespace Engine::Assets
+12
View File
@@ -20,11 +20,23 @@
#include <Engine/Core/Types.h> #include <Engine/Core/Types.h>
#include <Engine/Vulkan/MemoryAllocator.h> #include <Engine/Vulkan/MemoryAllocator.h>
#include <vulkan/vulkan.hpp> #include <vulkan/vulkan.hpp>
#include <vector>
namespace Engine { namespace Engine {
class Device; class Device;
// 环境的 CPU 源数据(等距柱状 HDR 解码产物,RGBA float 行优先)。
// 纯数据无线程状态:异步加载时由后台线程产出(DecodeHdr),
// 主线程消费(FromDecoded → GPU 生成)。decode 是切换耗时大头,
// 搬出主线程后运行时切换只剩 GPU 段(~40ms)。
struct DecodedEquirect {
std::vector<f32> pixels;
u32 w = 0;
u32 h = 0;
bool Valid() const { return !pixels.empty() && w > 0 && h > 0; }
};
// GPU 纹理资源三元组(RHI 惯例):image/view/显存分配同生共死, // GPU 纹理资源三元组(RHI 惯例):image/view/显存分配同生共死,
// 析构统一释放——杜绝"建了 image 丢了 view"类半套泄漏。 // 析构统一释放——杜绝"建了 image 丢了 view"类半套泄漏。
// 上传 helper 直接以此为单位产出,消费端只拿引用计数语义的整单元 // 上传 helper 直接以此为单位产出,消费端只拿引用计数语义的整单元
@@ -25,6 +25,7 @@
#define HELLOVULKAN_IBLGENERATOR_H #define HELLOVULKAN_IBLGENERATOR_H
#include <Engine/Core/Types.h> #include <Engine/Core/Types.h>
#include <Engine/Renderer/Cubemap.h> // DecodedEquirect(解码产物数据契约)
#include <Engine/Vulkan/MemoryAllocator.h> #include <Engine/Vulkan/MemoryAllocator.h>
#include <glm/glm.hpp> #include <glm/glm.hpp>
#include <vulkan/vulkan.hpp> #include <vulkan/vulkan.hpp>
@@ -47,9 +48,18 @@ namespace Engine {
IblGenerator(const IblGenerator&) = delete; IblGenerator(const IblGenerator&) = delete;
IblGenerator& operator=(const IblGenerator&) = delete; IblGenerator& operator=(const IblGenerator&) = delete;
// ---- 同步便捷路径(等价 DecodeHdr + FromDecoded)----
// 等距柱状 HDR → 环境四件套(失败返回 nullptr,上层走 Neutral 兜底) // 等距柱状 HDR → 环境四件套(失败返回 nullptr,上层走 Neutral 兜底)
Scope<Cubemap> FromHdrEquirect(const std::string& path, u32 faceSize); Scope<Cubemap> FromHdrEquirect(const std::string& path, u32 faceSize);
// ---- 异步加载两段式(decode 是切换耗时大头,可搬后台线程)----
// 纯 CPU:HDR 文件 → 像素数据。无线程状态,worker 线程可安全调用。
static DecodedEquirect DecodeHdr(const std::string& path);
// GPU 生成(必须主线程):预解码数据 → 四件套。debugName 仅用于日志。
Scope<Cubemap> FromDecoded(const DecodedEquirect& decoded, u32 faceSize,
const std::string& debugName);
// 中性兜底:常数色环境 + 真实 BRDF LUT(描述符恒有效) // 中性兜底:常数色环境 + 真实 BRDF LUT(描述符恒有效)
Scope<Cubemap> Neutral(const glm::vec3& rgb); Scope<Cubemap> Neutral(const glm::vec3& rgb);
@@ -34,6 +34,8 @@ namespace Engine {
Scope<Model> LoadModel(const std::string& path) override; Scope<Model> LoadModel(const std::string& path) override;
Scope<Model> CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) override; Scope<Model> CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) override;
Scope<Cubemap> LoadEnvironment(const std::string& path) override; Scope<Cubemap> LoadEnvironment(const std::string& path) override;
Scope<Cubemap> LoadEnvironment(const std::string& path,
DecodedEquirect&& decoded) override;
private: private:
Device& m_Device; Device& m_Device;
+11
View File
@@ -37,4 +37,15 @@ namespace Engine::Assets {
[this](const std::string& p) { return m_Uploader.LoadEnvironment(p); }); [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 } // namespace Engine::Assets
+33 -11
View File
@@ -197,26 +197,48 @@ namespace Engine
} }
} }
Scope<Cubemap> IblGenerator::FromHdrEquirect(const std::string& path, DecodedEquirect IblGenerator::DecodeHdr(const std::string& path)
u32 faceSize)
{ {
const auto t0 = std::chrono::steady_clock::now(); // stbi_loadf 无共享可变状态(失败原因每次调用独立),可并发调用
int w = 0, h = 0, channels = 0; int w = 0, h = 0, channels = 0;
f32* pixels = stbi_loadf(path.c_str(), &w, &h, &channels, STBI_rgb_alpha); f32* pixels = stbi_loadf(path.c_str(), &w, &h, &channels, STBI_rgb_alpha);
if (!pixels) { if (!pixels) {
ENGINE_ERROR("IblGenerator: failed to load HDR: {}", path); ENGINE_ERROR("IblGenerator: failed to load HDR: {}", path);
return nullptr; return {};
} }
const f32 decodeMs = MsSince(t0); DecodedEquirect decoded;
auto cubemap = Generate(pixels, static_cast<u32>(w), decoded.w = static_cast<u32>(w);
static_cast<u32>(h), faceSize, path); decoded.h = static_cast<u32>(h);
decoded.pixels.assign(pixels,
pixels + static_cast<size_t>(w) * h * 4);
stbi_image_free(pixels); stbi_image_free(pixels);
return decoded;
}
ENGINE_INFO("IblGenerator: HDR decode {:.2f} ms", Scope<Cubemap> IblGenerator::FromDecoded(const DecodedEquirect& decoded,
decodeMs); u32 faceSize,
return cubemap; 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<Cubemap> 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<Cubemap> IblGenerator::Neutral(const glm::vec3& rgb) Scope<Cubemap> IblGenerator::Neutral(const glm::vec3& rgb)
@@ -27,4 +27,11 @@ namespace Engine {
return m_Ibl.FromHdrEquirect(path, 1024); return m_Ibl.FromHdrEquirect(path, 1024);
} }
Scope<Cubemap> VulkanAssetUploader::LoadEnvironment(
const std::string& path, DecodedEquirect&& decoded)
{
// 异步路径:decode 已在 worker 线程完成,此处只剩 GPU 段
return m_Ibl.FromDecoded(decoded, 1024, path);
}
} // namespace Engine } // namespace Engine