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
+28 -14
View File
@@ -27,13 +27,15 @@
#include <unordered_map>
#include <vector>
namespace Engine {
namespace Assets {
namespace Engine
{
namespace Assets
{
// ---------- 通用资产缓存(不感知 Vulkan / 磁盘细节由 loader 决定) ----------
template<typename T>
class AssetCache {
template <typename T>
class AssetCache
{
public:
// loader:path → 资产实例(失败返回 nullptr)
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)
{
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<T> asset = loader(path);
if (!asset) {
if (!asset)
{
ENGINE_ERROR("AssetCache: failed to load '{}'", path);
return {};
}
AssetRef<T> 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<u32>(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<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={})",
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<T> 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<Model>& Models() { return m_Models; }
@@ -161,7 +176,6 @@ namespace Engine {
AssetCache<Model> m_Models;
AssetCache<Cubemap> m_Environments;
};
} // namespace Assets
} // namespace Engine
@@ -35,6 +35,10 @@ namespace Engine::Assets {
// 环境 HDR → IBL 预积分四件套(compute 生成,毫秒级;失败返回 nullptr)
virtual Scope<Cubemap> LoadEnvironment(const std::string& path) = 0;
// 异步加载路径:解码已在后台线程完成,此调用只做 GPU 生成(主线程)
virtual Scope<Cubemap> LoadEnvironment(const std::string& path,
DecodedEquirect&& decoded) = 0;
};
} // namespace Engine::Assets
+12
View File
@@ -20,11 +20,23 @@
#include <Engine/Core/Types.h>
#include <Engine/Vulkan/MemoryAllocator.h>
#include <vulkan/vulkan.hpp>
#include <vector>
namespace Engine {
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/显存分配同生共死,
// 析构统一释放——杜绝"建了 image 丢了 view"类半套泄漏。
// 上传 helper 直接以此为单位产出,消费端只拿引用计数语义的整单元
@@ -25,6 +25,7 @@
#define HELLOVULKAN_IBLGENERATOR_H
#include <Engine/Core/Types.h>
#include <Engine/Renderer/Cubemap.h> // DecodedEquirect(解码产物数据契约)
#include <Engine/Vulkan/MemoryAllocator.h>
#include <glm/glm.hpp>
#include <vulkan/vulkan.hpp>
@@ -47,9 +48,18 @@ namespace Engine {
IblGenerator(const IblGenerator&) = delete;
IblGenerator& operator=(const IblGenerator&) = delete;
// ---- 同步便捷路径(等价 DecodeHdr + FromDecoded)----
// 等距柱状 HDR → 环境四件套(失败返回 nullptr,上层走 Neutral 兜底)
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(描述符恒有效)
Scope<Cubemap> Neutral(const glm::vec3& rgb);
@@ -34,6 +34,8 @@ namespace Engine {
Scope<Model> LoadModel(const std::string& path) 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,
DecodedEquirect&& decoded) override;
private:
Device& m_Device;
+11
View File
@@ -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
+33 -11
View File
@@ -197,26 +197,48 @@ namespace Engine
}
}
Scope<Cubemap> 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<u32>(w),
static_cast<u32>(h), faceSize, path);
DecodedEquirect decoded;
decoded.w = static_cast<u32>(w);
decoded.h = static_cast<u32>(h);
decoded.pixels.assign(pixels,
pixels + static_cast<size_t>(w) * h * 4);
stbi_image_free(pixels);
return decoded;
}
ENGINE_INFO("IblGenerator: HDR decode {:.2f} ms",
decodeMs);
return cubemap;
Scope<Cubemap> 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<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)
@@ -27,4 +27,11 @@ namespace Engine {
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