From 99e6712cdfffc708ea77eecef539a647d68ca243 Mon Sep 17 00:00:00 2001 From: atdunbg Date: Thu, 24 Sep 2026 19:31:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A8=A1=E5=9E=8B=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E2=80=94=E2=80=94assimp=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E7=A7=BB=E5=90=8E=E5=8F=B0=E7=BA=BF=E7=A8=8B=EF=BC=88SunTemple?= =?UTF-8?q?=203s=20=E5=8D=A1=E9=A1=BF=E6=B6=88=E9=99=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model 拆两段式:ParseFromDisk(static 纯 CPU,assimp 解析 → 几何 + 材质引用,worker 可调)/ FromParsed(GPU 构建:纹理上传 / mesh 缓冲 / 材质描述符,主线程),同步路径等价两段串行。第二个异步任务类型落地, 线程收割骨架抽成 Core/AsyncTask(thread + atomic + Take,覆盖式 Run;pending 接力语义留调用方)。 实测(SunTemple 1056 mesh):解析 2.5s 全程在 worker,主线程持续 渲染;GPU 构建 0.56s。assets 层经 IAssetUploader/AssetManager 重载 传预解析产物(路径缓存去重,路径已缓存则产物丢弃)。 已知边界:纹理解码(backpack 4k PNG ~1s)仍在 GPU 段主线程—— Texture 需支持预解码像素注入,后续演进。 --- App/main.cpp | 29 ++++- Engine/include/Engine/Assets/AssetManager.h | 2 + Engine/include/Engine/Assets/IAssetUploader.h | 4 + Engine/include/Engine/Core/AsyncTask.h | 82 ++++++++++++++ Engine/include/Engine/Renderer/Model.h | 43 ++++++- .../Engine/Renderer/VulkanAssetUploader.h | 2 + Engine/src/Assets/AssetManager.cpp | 11 ++ Engine/src/Renderer/Model.cpp | 105 ++++++++++++++---- Engine/src/Renderer/VulkanAssetUploader.cpp | 8 ++ 9 files changed, 260 insertions(+), 26 deletions(-) create mode 100644 Engine/include/Engine/Core/AsyncTask.h diff --git a/App/main.cpp b/App/main.cpp index 6e03e69..e88e314 100644 --- a/App/main.cpp +++ b/App/main.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +48,7 @@ namespace }; constexpr int kEnvCount = 4; constexpr int kDefaultEnv = 0; + constexpr const char* kTemplePath = "assets/models/SunTemple/SunTemple.fbx"; class SandboxApp final : public Application { @@ -88,7 +90,13 @@ 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"); - int countSize = 10; + // 大模型异步加载:assimp 解析在 worker 线程(SunTemple 约 3s), + // 完成后 OnUpdate 收割 → GPU 构建 → 建实体(cm 尺度 → 0.001) + m_TempleTask.Run([] { return Model::ParseFromDisk(kTemplePath); }); + m_TempleScale = 0.001f; + + + int countSize = 0; for (int i = 0; i < countSize; i ++) { for (int j = 0; j < countSize; j ++) @@ -155,6 +163,21 @@ namespace void OnUpdate(f32 deltaTime) override { + // 异步模型收割:解析完成 → GPU 构建 → 建实体 + if (m_TempleTask.Done()) { + ParsedModel parsed = m_TempleTask.Take(); + const auto ref = GetAssets().LoadModel(kTemplePath, + std::move(parsed)); + if (ref.Valid()) { + auto temple = GetScene().CreateEntity("SunTemple"); + temple.AddComponent( + MeshRendererComponent{ref}); + temple.GetComponent() + .SetScale(glm::vec3{m_TempleScale}); + ENGINE_INFO("[ASYNC] SunTemple ready, entity created"); + } + } + // 异步环境加载:每帧收割完成的解码结果 PollEnvironment(); @@ -473,6 +496,10 @@ namespace std::atomic m_EnvLoadDone{false}; bool m_EnvLoadBusy = false; int m_EnvPending = -1; + + // ---- 异步模型加载(SunTemple)---- + AsyncTask m_TempleTask; + f32 m_TempleScale = 1.0f; // cm 尺度资产 → 米(解析后应用) }; } diff --git a/Engine/include/Engine/Assets/AssetManager.h b/Engine/include/Engine/Assets/AssetManager.h index 3df5c7a..510ad5c 100644 --- a/Engine/include/Engine/Assets/AssetManager.h +++ b/Engine/include/Engine/Assets/AssetManager.h @@ -150,6 +150,8 @@ namespace Engine AssetManager& operator=(const AssetManager&) = delete; ModelRef LoadModel(const std::string& path); + // 异步路径:解析已在后台线程完成(路径已缓存则产物丢弃去重) + ModelRef LoadModel(const std::string& path, ParsedModel&& parsed); Model* GetModel(ModelRef h) const { return m_Models.Get(h); } void UnloadModel(ModelRef h) { m_Models.Unload(h); } diff --git a/Engine/include/Engine/Assets/IAssetUploader.h b/Engine/include/Engine/Assets/IAssetUploader.h index 6d7b77c..5d990ce 100644 --- a/Engine/include/Engine/Assets/IAssetUploader.h +++ b/Engine/include/Engine/Assets/IAssetUploader.h @@ -30,6 +30,10 @@ namespace Engine::Assets { // 磁盘模型 → 解析 + GPU 上传(失败返回 nullptr) virtual Scope LoadModel(const std::string& path) = 0; + // 异步路径:解析已在后台线程完成,此调用只做 GPU 构建(主线程) + virtual Scope LoadModel(const std::string& path, + ParsedModel&& parsed) = 0; + // 程序化纯色平面(size 为半边长) virtual Scope CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) = 0; diff --git a/Engine/include/Engine/Core/AsyncTask.h b/Engine/include/Engine/Core/AsyncTask.h new file mode 100644 index 0000000..9c2541f --- /dev/null +++ b/Engine/include/Engine/Core/AsyncTask.h @@ -0,0 +1,82 @@ +// +// Created by atdunbg on 2026/9/24. +// +// AsyncTask:单 in-flight 异步任务的通用收割工具(header-only)。 +// +// 异步加载的公共骨架(已验证两次:环境 DecodeHdr / 模型 ParseFromDisk): +// worker 线程跑纯 CPU 任务 → 原子标志通知 → 主线程每帧轮询收割。 +// Vulkan 提交/描述符等 GPU 工作全部留在主线程(设备非线程安全)。 +// +// AsyncTask task; +// task.Run([] { return Model::ParseFromDisk(path); }); // 发起 +// if (task.Done()) { auto p = task.Take(); ... } // 每帧收割 +// // 析构自动 join(worker 只做纯 CPU 工作,join 恒安全) +// +// 语义:Run 在忙碌时先 join 并丢弃旧结果(覆盖式);"快速连切只保留 +// 最终意图"的 pending 接力由调用方持有(参照 main.cpp 环境加载)。 +// + +#ifndef HELLOVULKAN_ASYNCTASK_H +#define HELLOVULKAN_ASYNCTASK_H + +#include +#include +#include +#include +#include + +namespace Engine { + + template + class AsyncTask { + public: + AsyncTask() = default; + ~AsyncTask() { Join(); } + + AsyncTask(const AsyncTask&) = delete; + AsyncTask& operator=(const AsyncTask&) = delete; + + // 发起任务。忙碌时先等待旧任务完成并放弃其结果。 + void Run(std::function job) + { + Join(); + m_Busy = true; + m_Done.store(false); + m_Worker = std::thread([this, j = std::move(job)]() { + m_Result = j(); + m_Done.store(true); // release:结果对主线程 acquire 可见 + }); + } + + bool Busy() const { return m_Busy; } + bool Done() const { return m_Busy && m_Done.load(); } + + // 收割:join worker 并移交结果。只在 Done() 为 true 时调用。 + T Take() + { + m_Worker.join(); // join = 同步点,此后结果归主线程 + m_Busy = false; + T out = std::move(m_Result); + m_Result = T{}; + return out; + } + + // 关闭前兜底等待(析构自动调用) + void Join() + { + if (m_Worker.joinable()) { + m_Worker.join(); + } + m_Busy = false; + } + + private: + std::thread m_Worker; + std::atomic m_Done{false}; + T m_Result{}; + bool m_Busy = false; + }; + +} // namespace Engine + +#endif //HELLOVULKAN_ASYNCTASK_H diff --git a/Engine/include/Engine/Renderer/Model.h b/Engine/include/Engine/Renderer/Model.h index 4f61e22..b48645c 100644 --- a/Engine/include/Engine/Renderer/Model.h +++ b/Engine/include/Engine/Renderer/Model.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -28,14 +29,42 @@ namespace Engine class Texture; class Material; + // ---- CPU 解析产物(异步加载的跨线程交棒数据)---- + // 纯数据:几何 + 材质槽位引用(路径 + 因子),无任何 GPU 资源。 + // worker 线程产出(ParseFromDisk),主线程消费(FromParsed)。 + struct ParsedMesh { + std::vector vertices; + std::vector indices; + std::array texPaths; + MaterialParams params; + }; + struct ParsedModel { + std::string path; + bool isGltf = false; + std::vector meshes; + bool Valid() const { return !meshes.empty(); } + }; + class Model { public: + // 同步便捷路径(等价 ParseFromDisk + FromParsed,启动期小模型可用) Model(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, const std::string& path); + // ---- 异步加载两段式(assimp 解析占大模型耗时大头,可搬后台线程)---- + // 纯 CPU:读盘 + assimp 解析 → 几何 + 材质引用。无线程状态。 + static ParsedModel ParseFromDisk(const std::string& path); + + // GPU 构建(必须主线程):纹理上传 / mesh 缓冲 / 材质描述符。 + // 解析失败返回 nullptr。 + static Scope FromParsed(Device& device, MemoryAllocator& allocator, + DescriptorManager& descriptorManager, + vk::DescriptorSetLayout textureLayout, + ParsedModel&& parsed); + // 程序化模型:单位法线朝上的 XZ 平面(纯色贴图),用于地面等。 // size 为半边长(平面总边长 = 2*size)。 struct ProceduralTag {}; @@ -57,9 +86,19 @@ namespace Engine vk::DescriptorSetLayout textureLayout, ProceduralTag, f32 halfSize, const std::array& color); + // 空模型骨架(FromParsed 用):只建默认纹理,mesh 由调用方填 + Model(Device& device, MemoryAllocator& allocator, + DescriptorManager& descriptorManager, + vk::DescriptorSetLayout textureLayout); + void LoadModel(const std::string& path); - void ProcessNode(aiNode* node, const aiScene* scene); - Scope ProcessMesh(aiMesh* mesh, const aiScene* scene); + static void ProcessNode(const aiNode* node, const aiScene* scene, + std::vector& out, + const std::filesystem::path& modelDir, + bool isGltf); + static ParsedMesh ProcessMesh(const aiMesh* mesh, const aiScene* scene, + const std::filesystem::path& modelDir, + bool isGltf); void InitDefaultTextures(); Texture* GetOrLoadTexture(const std::string& path, bool srgb); diff --git a/Engine/include/Engine/Renderer/VulkanAssetUploader.h b/Engine/include/Engine/Renderer/VulkanAssetUploader.h index 74e0971..4450ff0 100644 --- a/Engine/include/Engine/Renderer/VulkanAssetUploader.h +++ b/Engine/include/Engine/Renderer/VulkanAssetUploader.h @@ -32,6 +32,8 @@ namespace Engine { m_Ibl(ibl) {} Scope LoadModel(const std::string& path) override; + Scope LoadModel(const std::string& path, + ParsedModel&& parsed) 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, diff --git a/Engine/src/Assets/AssetManager.cpp b/Engine/src/Assets/AssetManager.cpp index 102447c..673b484 100644 --- a/Engine/src/Assets/AssetManager.cpp +++ b/Engine/src/Assets/AssetManager.cpp @@ -20,6 +20,17 @@ namespace Engine::Assets { [this](const std::string& p) { return m_Uploader.LoadModel(p); }); } + ModelRef AssetManager::LoadModel(const std::string& path, + ParsedModel&& parsed) + { + // 预解析数据移交 loader(Load 同步调用 lambda,引用捕获安全); + // 路径已缓存则 loader 不执行,parsed 自然丢弃(去重) + return m_Models.Load(path, + [this, &parsed](const std::string& p) { + return m_Uploader.LoadModel(p, std::move(parsed)); + }); + } + ModelRef AssetManager::CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) { // 程序化资产复用同一缓存:键 = 伪路径 + 参数 diff --git a/Engine/src/Renderer/Model.cpp b/Engine/src/Renderer/Model.cpp index caa97b4..5ec5ded 100644 --- a/Engine/src/Renderer/Model.cpp +++ b/Engine/src/Renderer/Model.cpp @@ -23,14 +23,22 @@ Model::Model(Device& device, MemoryAllocator& allocator, DescriptorManager& descriptorManager, vk::DescriptorSetLayout textureLayout, const std::string& path) + : Model(device, allocator, descriptorManager, textureLayout) +{ + // 同步便捷路径 = 两段式串行(等价异步收割后的主线程消费) + LoadModel(path); +} + +// 空模型骨架:只建默认纹理;mesh/材质由 FromParsed 填充 +Model::Model(Device& device, MemoryAllocator& allocator, + DescriptorManager& descriptorManager, + vk::DescriptorSetLayout textureLayout) : m_Device(device) , m_Allocator(allocator) , m_DescriptorManager(descriptorManager) , m_TextureLayout(textureLayout) { InitDefaultTextures(); - - LoadModel(path); } Model::~Model() = default; @@ -92,9 +100,10 @@ Scope Model::CreatePlane(Device& device, MemoryAllocator& allocator, halfSize, std::array{r, g, b})); } -void Model::LoadModel(const std::string& path) +ParsedModel Model::ParseFromDisk(const std::string& path) { - m_ModelPath = path; + ParsedModel out; + out.path = path; // UV 翻转按格式区分: // OBJ/FBX —— v 原点在底部,需翻到左上惯例(配合"v=0=图像顶"采样) @@ -106,36 +115,84 @@ void Model::LoadModel(const std::string& path) [](unsigned char c) { return std::tolower(c); }); return s; }(); - m_IsGltf = lower.size() >= 5 && + out.isGltf = lower.size() >= 5 && (lower.rfind(".gltf") == lower.size() - 5 || lower.rfind(".glb") == lower.size() - 4); Assimp::Importer importer; const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | - (m_IsGltf ? 0 : aiProcess_FlipUVs) | + (out.isGltf ? 0 : aiProcess_FlipUVs) | aiProcess_GenSmoothNormals | aiProcess_CalcTangentSpace | // 法线贴图 TBN aiProcess_JoinIdenticalVertices); if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) { ENGINE_ERROR("Assimp error: {}", importer.GetErrorString()); - return; + return out; // meshes 空 = 无效 } - ProcessNode(scene->mRootNode, scene); + const std::filesystem::path modelDir = + std::filesystem::path(path).parent_path(); + ProcessNode(scene->mRootNode, scene, out.meshes, modelDir, out.isGltf); + ENGINE_INFO("Model parsed: {} ({} meshes)", path, out.meshes.size()); + return out; +} + +Scope Model::FromParsed(Device& device, MemoryAllocator& allocator, + DescriptorManager& descriptorManager, + vk::DescriptorSetLayout textureLayout, + ParsedModel&& parsed) +{ + if (!parsed.Valid()) { + ENGINE_ERROR("Model::FromParsed: invalid parsed model ({})", parsed.path); + return nullptr; + } + + auto model = Scope(new Model(device, allocator, + descriptorManager, textureLayout)); + model->m_ModelPath = parsed.path; + model->m_IsGltf = parsed.isGltf; + + // 材质缓存去重 + 纹理上传 + mesh 缓冲(全部 GPU,主线程) + for (auto& pm : parsed.meshes) { + Material* mat = model->GetOrCreateMaterial(pm.texPaths.data(), pm.params); + model->m_Meshes.push_back(CreateScope( + device, allocator, std::move(pm.vertices), std::move(pm.indices), mat)); + } + ENGINE_INFO("Model loaded: {} ({} meshes, {} textures)", + parsed.path, model->m_Meshes.size(), model->m_TextureCache.size()); + return model; +} + +void Model::LoadModel(const std::string& path) +{ + // 同步路径:解析 + GPU 构建串行(与 FromParsed 相同的消费逻辑) + ParsedModel parsed = ParseFromDisk(path); + if (!parsed.Valid()) { + return; + } + m_ModelPath = parsed.path; + m_IsGltf = parsed.isGltf; + for (auto& pm : parsed.meshes) { + Material* mat = GetOrCreateMaterial(pm.texPaths.data(), pm.params); + m_Meshes.push_back(CreateScope( + m_Device, m_Allocator, std::move(pm.vertices), std::move(pm.indices), mat)); + } ENGINE_INFO("Model loaded: {} ({} meshes, {} textures)", path, m_Meshes.size(), m_TextureCache.size()); } -void Model::ProcessNode(aiNode* node, const aiScene* scene) +void Model::ProcessNode(const aiNode* node, const aiScene* scene, + std::vector& out, + const std::filesystem::path& modelDir, bool isGltf) { for (u32 i = 0; i < node->mNumMeshes; ++i) { aiMesh* mesh = scene->mMeshes[node->mMeshes[i]]; - m_Meshes.push_back(ProcessMesh(mesh, scene)); + out.push_back(ProcessMesh(mesh, scene, modelDir, isGltf)); } for (u32 i = 0; i < node->mNumChildren; ++i) { - ProcessNode(node->mChildren[i], scene); + ProcessNode(node->mChildren[i], scene, out, modelDir, isGltf); } } @@ -193,10 +250,13 @@ Material* Model::GetOrCreateMaterial(const std::string paths[Material::kSlotCoun return ptr; } -Scope Model::ProcessMesh(aiMesh* mesh, const aiScene* scene) +ParsedMesh Model::ProcessMesh(const aiMesh* mesh, const aiScene* scene, + const std::filesystem::path& modelDir, + bool isGltf) { - std::vector vertices; - std::vector indices; + ParsedMesh out; + std::vector& vertices = out.vertices; + std::vector& indices = out.indices; vertices.reserve(mesh->mNumVertices); @@ -248,9 +308,7 @@ Scope Model::ProcessMesh(aiMesh* mesh, const aiScene* scene) MaterialParams params; if (mesh->mMaterialIndex >= 0) { - aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; - const std::filesystem::path modelDir = - std::filesystem::path(m_ModelPath).parent_path(); + const aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; // glTF2 贴图在 assimp 5.x 的落点:MR 合图 → METALNESS(15)/ // DIFFUSE_ROUGHNESS(16)(同图双槽);baseColor → BASE_COLOR(12)。 @@ -304,7 +362,7 @@ Scope Model::ProcessMesh(aiMesh* mesh, const aiScene* scene) // roughness 有独立贴图 → 因子 1(G 通道直达); // 无贴图 → Blinn-Phong Ns 换算 R=sqrt(2/(Ns+2)), // 补偿默认 MR 贴图 G=0.5 → 因子 = 2R - if (m_IsGltf) { + if (isGltf) { ai_real metallic = 1.0f, roughness = 1.0f; material->Get(AI_MATKEY_METALLIC_FACTOR, metallic); material->Get(AI_MATKEY_ROUGHNESS_FACTOR, roughness); @@ -323,11 +381,12 @@ Scope Model::ProcessMesh(aiMesh* mesh, const aiScene* scene) } } - // 材质缓存:同贴图组合+因子 → 同一 Material(一个描述符集) - Material* mat = GetOrCreateMaterial(texPaths, params); - - return CreateScope( - m_Device, m_Allocator, vertices, indices, mat); + // 材质创建延迟到 GPU 构建段(FromParsed):解析期只留引用 + for (u32 slot = 0; slot < Material::kSlotCount; ++slot) { + out.texPaths[slot] = std::move(texPaths[slot]); + } + out.params = params; + return out; } } // namespace Engine \ No newline at end of file diff --git a/Engine/src/Renderer/VulkanAssetUploader.cpp b/Engine/src/Renderer/VulkanAssetUploader.cpp index 9350c21..4a9938c 100644 --- a/Engine/src/Renderer/VulkanAssetUploader.cpp +++ b/Engine/src/Renderer/VulkanAssetUploader.cpp @@ -15,6 +15,14 @@ namespace Engine { m_Device, m_Allocator, m_Descriptors, m_TextureLayout, path); } + Scope VulkanAssetUploader::LoadModel(const std::string& path, + ParsedModel&& parsed) + { + // 异步路径:assimp 解析已在 worker 线程完成,此处只剩 GPU 构建 + return Model::FromParsed(m_Device, m_Allocator, m_Descriptors, + m_TextureLayout, std::move(parsed)); + } + Scope VulkanAssetUploader::CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) { return Model::CreatePlane(m_Device, m_Allocator, m_Descriptors,