feat: 模型异步加载——assimp 解析移后台线程(SunTemple 3s 卡顿消除)

Model 拆两段式:ParseFromDisk(static 纯 CPU,assimp 解析 → 几何 +
材质引用,worker 可调)/ FromParsed(GPU 构建:纹理上传 / mesh 缓冲 /
材质描述符,主线程),同步路径等价两段串行。第二个异步任务类型落地,
线程收割骨架抽成 Core/AsyncTask<T>(thread + atomic + Take,覆盖式
Run;pending 接力语义留调用方)。

实测(SunTemple 1056 mesh):解析 2.5s 全程在 worker,主线程持续
渲染;GPU 构建 0.56s。assets 层经 IAssetUploader/AssetManager 重载
传预解析产物(路径缓存去重,路径已缓存则产物丢弃)。

已知边界:纹理解码(backpack 4k PNG ~1s)仍在 GPU 段主线程——
Texture 需支持预解码像素注入,后续演进。
This commit is contained in:
2026-09-24 19:31:14 +08:00
parent 6df3be2846
commit 99e6712cdf
9 changed files with 260 additions and 26 deletions
+28 -1
View File
@@ -11,6 +11,7 @@
#include <Engine/Core/Application.h> #include <Engine/Core/Application.h>
#include <Engine/Core/Log.h> #include <Engine/Core/Log.h>
#include <Engine/Core/Stats.h> #include <Engine/Core/Stats.h>
#include <Engine/Core/AsyncTask.h>
#include <Engine/ECS/Scene.h> #include <Engine/ECS/Scene.h>
#include <Engine/ECS/EntityHandle.h> #include <Engine/ECS/EntityHandle.h>
#include <Engine/ECS/Components.h> #include <Engine/ECS/Components.h>
@@ -47,6 +48,7 @@ namespace
}; };
constexpr int kEnvCount = 4; constexpr int kEnvCount = 4;
constexpr int kDefaultEnv = 0; constexpr int kDefaultEnv = 0;
constexpr const char* kTemplePath = "assets/models/SunTemple/SunTemple.fbx";
class SandboxApp final : public Application 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/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");
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 i = 0; i < countSize; i ++)
{ {
for (int j = 0; j < countSize; j ++) for (int j = 0; j < countSize; j ++)
@@ -155,6 +163,21 @@ namespace
void OnUpdate(f32 deltaTime) override 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>(
MeshRendererComponent{ref});
temple.GetComponent<TransformComponent>()
.SetScale(glm::vec3{m_TempleScale});
ENGINE_INFO("[ASYNC] SunTemple ready, entity created");
}
}
// 异步环境加载:每帧收割完成的解码结果 // 异步环境加载:每帧收割完成的解码结果
PollEnvironment(); PollEnvironment();
@@ -473,6 +496,10 @@ namespace
std::atomic<bool> m_EnvLoadDone{false}; std::atomic<bool> m_EnvLoadDone{false};
bool m_EnvLoadBusy = false; bool m_EnvLoadBusy = false;
int m_EnvPending = -1; int m_EnvPending = -1;
// ---- 异步模型加载(SunTemple)----
AsyncTask<ParsedModel> m_TempleTask;
f32 m_TempleScale = 1.0f; // cm 尺度资产 → 米(解析后应用)
}; };
} }
@@ -150,6 +150,8 @@ namespace Engine
AssetManager& operator=(const AssetManager&) = delete; AssetManager& operator=(const AssetManager&) = delete;
ModelRef LoadModel(const std::string& path); ModelRef LoadModel(const std::string& path);
// 异步路径:解析已在后台线程完成(路径已缓存则产物丢弃去重)
ModelRef LoadModel(const std::string& path, ParsedModel&& parsed);
Model* GetModel(ModelRef h) const { return m_Models.Get(h); } Model* GetModel(ModelRef h) const { return m_Models.Get(h); }
void UnloadModel(ModelRef h) { m_Models.Unload(h); } void UnloadModel(ModelRef h) { m_Models.Unload(h); }
@@ -30,6 +30,10 @@ namespace Engine::Assets {
// 磁盘模型 → 解析 + GPU 上传(失败返回 nullptr) // 磁盘模型 → 解析 + GPU 上传(失败返回 nullptr)
virtual Scope<Model> LoadModel(const std::string& path) = 0; virtual Scope<Model> LoadModel(const std::string& path) = 0;
// 异步路径:解析已在后台线程完成,此调用只做 GPU 构建(主线程)
virtual Scope<Model> LoadModel(const std::string& path,
ParsedModel&& parsed) = 0;
// 程序化纯色平面(size 为半边长) // 程序化纯色平面(size 为半边长)
virtual Scope<Model> CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) = 0; virtual Scope<Model> CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) = 0;
+82
View File
@@ -0,0 +1,82 @@
//
// Created by atdunbg on 2026/9/24.
//
// AsyncTask:单 in-flight 异步任务的通用收割工具(header-only)。
//
// 异步加载的公共骨架(已验证两次:环境 DecodeHdr / 模型 ParseFromDisk):
// worker 线程跑纯 CPU 任务 → 原子标志通知 → 主线程每帧轮询收割。
// Vulkan 提交/描述符等 GPU 工作全部留在主线程(设备非线程安全)。
//
// AsyncTask<ParsedModel> 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 <Engine/Core/Types.h>
#include <atomic>
#include <functional>
#include <thread>
#include <utility>
namespace Engine {
template <typename T>
class AsyncTask {
public:
AsyncTask() = default;
~AsyncTask() { Join(); }
AsyncTask(const AsyncTask&) = delete;
AsyncTask& operator=(const AsyncTask&) = delete;
// 发起任务。忙碌时先等待旧任务完成并放弃其结果。
void Run(std::function<T()> 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<bool> m_Done{false};
T m_Result{};
bool m_Busy = false;
};
} // namespace Engine
#endif //HELLOVULKAN_ASYNCTASK_H
+41 -2
View File
@@ -12,6 +12,7 @@
#include <Engine/Renderer/Mesh.h> #include <Engine/Renderer/Mesh.h>
#include <vulkan/vulkan.hpp> #include <vulkan/vulkan.hpp>
#include <array> #include <array>
#include <filesystem>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
@@ -28,14 +29,42 @@ namespace Engine
class Texture; class Texture;
class Material; class Material;
// ---- CPU 解析产物(异步加载的跨线程交棒数据)----
// 纯数据:几何 + 材质槽位引用(路径 + 因子),无任何 GPU 资源。
// worker 线程产出(ParseFromDisk),主线程消费(FromParsed)。
struct ParsedMesh {
std::vector<Vertex> vertices;
std::vector<u32> indices;
std::array<std::string, Material::kSlotCount> texPaths;
MaterialParams params;
};
struct ParsedModel {
std::string path;
bool isGltf = false;
std::vector<ParsedMesh> meshes;
bool Valid() const { return !meshes.empty(); }
};
class Model class Model
{ {
public: public:
// 同步便捷路径(等价 ParseFromDisk + FromParsed,启动期小模型可用)
Model(Device& device, MemoryAllocator& allocator, Model(Device& device, MemoryAllocator& allocator,
DescriptorManager& descriptorManager, DescriptorManager& descriptorManager,
vk::DescriptorSetLayout textureLayout, vk::DescriptorSetLayout textureLayout,
const std::string& path); const std::string& path);
// ---- 异步加载两段式(assimp 解析占大模型耗时大头,可搬后台线程)----
// 纯 CPU:读盘 + assimp 解析 → 几何 + 材质引用。无线程状态。
static ParsedModel ParseFromDisk(const std::string& path);
// GPU 构建(必须主线程):纹理上传 / mesh 缓冲 / 材质描述符。
// 解析失败返回 nullptr。
static Scope<Model> FromParsed(Device& device, MemoryAllocator& allocator,
DescriptorManager& descriptorManager,
vk::DescriptorSetLayout textureLayout,
ParsedModel&& parsed);
// 程序化模型:单位法线朝上的 XZ 平面(纯色贴图),用于地面等。 // 程序化模型:单位法线朝上的 XZ 平面(纯色贴图),用于地面等。
// size 为半边长(平面总边长 = 2*size)。 // size 为半边长(平面总边长 = 2*size)。
struct ProceduralTag {}; struct ProceduralTag {};
@@ -57,9 +86,19 @@ namespace Engine
vk::DescriptorSetLayout textureLayout, vk::DescriptorSetLayout textureLayout,
ProceduralTag, f32 halfSize, const std::array<u8, 3>& color); ProceduralTag, f32 halfSize, const std::array<u8, 3>& color);
// 空模型骨架(FromParsed 用):只建默认纹理,mesh 由调用方填
Model(Device& device, MemoryAllocator& allocator,
DescriptorManager& descriptorManager,
vk::DescriptorSetLayout textureLayout);
void LoadModel(const std::string& path); void LoadModel(const std::string& path);
void ProcessNode(aiNode* node, const aiScene* scene); static void ProcessNode(const aiNode* node, const aiScene* scene,
Scope<Mesh> ProcessMesh(aiMesh* mesh, const aiScene* scene); std::vector<ParsedMesh>& 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(); void InitDefaultTextures();
Texture* GetOrLoadTexture(const std::string& path, bool srgb); Texture* GetOrLoadTexture(const std::string& path, bool srgb);
@@ -32,6 +32,8 @@ namespace Engine {
m_Ibl(ibl) {} m_Ibl(ibl) {}
Scope<Model> LoadModel(const std::string& path) override; Scope<Model> LoadModel(const std::string& path) override;
Scope<Model> LoadModel(const std::string& path,
ParsedModel&& parsed) 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, Scope<Cubemap> LoadEnvironment(const std::string& path,
+11
View File
@@ -20,6 +20,17 @@ namespace Engine::Assets {
[this](const std::string& p) { return m_Uploader.LoadModel(p); }); [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) ModelRef AssetManager::CreatePlane(f32 halfSize, u8 r, u8 g, u8 b)
{ {
// 程序化资产复用同一缓存:键 = 伪路径 + 参数 // 程序化资产复用同一缓存:键 = 伪路径 + 参数
+82 -23
View File
@@ -23,14 +23,22 @@ Model::Model(Device& device, MemoryAllocator& allocator,
DescriptorManager& descriptorManager, DescriptorManager& descriptorManager,
vk::DescriptorSetLayout textureLayout, vk::DescriptorSetLayout textureLayout,
const std::string& path) 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_Device(device)
, m_Allocator(allocator) , m_Allocator(allocator)
, m_DescriptorManager(descriptorManager) , m_DescriptorManager(descriptorManager)
, m_TextureLayout(textureLayout) , m_TextureLayout(textureLayout)
{ {
InitDefaultTextures(); InitDefaultTextures();
LoadModel(path);
} }
Model::~Model() = default; Model::~Model() = default;
@@ -92,9 +100,10 @@ Scope<Model> Model::CreatePlane(Device& device, MemoryAllocator& allocator,
halfSize, std::array<u8, 3>{r, g, b})); halfSize, std::array<u8, 3>{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 翻转按格式区分: // UV 翻转按格式区分:
// OBJ/FBX —— v 原点在底部,需翻到左上惯例(配合"v=0=图像顶"采样) // OBJ/FBX —— v 原点在底部,需翻到左上惯例(配合"v=0=图像顶"采样)
@@ -106,36 +115,84 @@ void Model::LoadModel(const std::string& path)
[](unsigned char c) { return std::tolower(c); }); [](unsigned char c) { return std::tolower(c); });
return s; return s;
}(); }();
m_IsGltf = lower.size() >= 5 && out.isGltf = lower.size() >= 5 &&
(lower.rfind(".gltf") == lower.size() - 5 || (lower.rfind(".gltf") == lower.size() - 5 ||
lower.rfind(".glb") == lower.size() - 4); lower.rfind(".glb") == lower.size() - 4);
Assimp::Importer importer; Assimp::Importer importer;
const aiScene* scene = importer.ReadFile(path, const aiScene* scene = importer.ReadFile(path,
aiProcess_Triangulate | aiProcess_Triangulate |
(m_IsGltf ? 0 : aiProcess_FlipUVs) | (out.isGltf ? 0 : aiProcess_FlipUVs) |
aiProcess_GenSmoothNormals | aiProcess_GenSmoothNormals |
aiProcess_CalcTangentSpace | // 法线贴图 TBN aiProcess_CalcTangentSpace | // 法线贴图 TBN
aiProcess_JoinIdenticalVertices); aiProcess_JoinIdenticalVertices);
if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) { if (!scene || (scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE) || !scene->mRootNode) {
ENGINE_ERROR("Assimp error: {}", importer.GetErrorString()); 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> 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<Model>(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<Mesh>(
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<Mesh>(
m_Device, m_Allocator, std::move(pm.vertices), std::move(pm.indices), mat));
}
ENGINE_INFO("Model loaded: {} ({} meshes, {} textures)", ENGINE_INFO("Model loaded: {} ({} meshes, {} textures)",
path, m_Meshes.size(), m_TextureCache.size()); 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<ParsedMesh>& out,
const std::filesystem::path& modelDir, bool isGltf)
{ {
for (u32 i = 0; i < node->mNumMeshes; ++i) { for (u32 i = 0; i < node->mNumMeshes; ++i) {
aiMesh* mesh = scene->mMeshes[node->mMeshes[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) { 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; return ptr;
} }
Scope<Mesh> 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<Vertex> vertices; ParsedMesh out;
std::vector<u32> indices; std::vector<Vertex>& vertices = out.vertices;
std::vector<u32>& indices = out.indices;
vertices.reserve(mesh->mNumVertices); vertices.reserve(mesh->mNumVertices);
@@ -248,9 +308,7 @@ Scope<Mesh> Model::ProcessMesh(aiMesh* mesh, const aiScene* scene)
MaterialParams params; MaterialParams params;
if (mesh->mMaterialIndex >= 0) { if (mesh->mMaterialIndex >= 0) {
aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex]; const aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex];
const std::filesystem::path modelDir =
std::filesystem::path(m_ModelPath).parent_path();
// glTF2 贴图在 assimp 5.x 的落点:MR 合图 → METALNESS(15)/ // glTF2 贴图在 assimp 5.x 的落点:MR 合图 → METALNESS(15)/
// DIFFUSE_ROUGHNESS(16)(同图双槽);baseColor → BASE_COLOR(12)。 // DIFFUSE_ROUGHNESS(16)(同图双槽);baseColor → BASE_COLOR(12)。
@@ -304,7 +362,7 @@ Scope<Mesh> Model::ProcessMesh(aiMesh* mesh, const aiScene* scene)
// roughness 有独立贴图 → 因子 1(G 通道直达); // roughness 有独立贴图 → 因子 1(G 通道直达);
// 无贴图 → Blinn-Phong Ns 换算 R=sqrt(2/(Ns+2)), // 无贴图 → Blinn-Phong Ns 换算 R=sqrt(2/(Ns+2)),
// 补偿默认 MR 贴图 G=0.5 → 因子 = 2R // 补偿默认 MR 贴图 G=0.5 → 因子 = 2R
if (m_IsGltf) { if (isGltf) {
ai_real metallic = 1.0f, roughness = 1.0f; ai_real metallic = 1.0f, roughness = 1.0f;
material->Get(AI_MATKEY_METALLIC_FACTOR, metallic); material->Get(AI_MATKEY_METALLIC_FACTOR, metallic);
material->Get(AI_MATKEY_ROUGHNESS_FACTOR, roughness); material->Get(AI_MATKEY_ROUGHNESS_FACTOR, roughness);
@@ -323,11 +381,12 @@ Scope<Mesh> Model::ProcessMesh(aiMesh* mesh, const aiScene* scene)
} }
} }
// 材质缓存:同贴图组合+因子 → 同一 Material(一个描述符集) // 材质创建延迟到 GPU 构建段(FromParsed):解析期只留引用
Material* mat = GetOrCreateMaterial(texPaths, params); for (u32 slot = 0; slot < Material::kSlotCount; ++slot) {
out.texPaths[slot] = std::move(texPaths[slot]);
return CreateScope<Mesh>( }
m_Device, m_Allocator, vertices, indices, mat); out.params = params;
return out;
} }
} // namespace Engine } // namespace Engine
@@ -15,6 +15,14 @@ namespace Engine {
m_Device, m_Allocator, m_Descriptors, m_TextureLayout, path); m_Device, m_Allocator, m_Descriptors, m_TextureLayout, path);
} }
Scope<Model> 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<Model> VulkanAssetUploader::CreatePlane(f32 halfSize, u8 r, u8 g, u8 b) Scope<Model> VulkanAssetUploader::CreatePlane(f32 halfSize, u8 r, u8 g, u8 b)
{ {
return Model::CreatePlane(m_Device, m_Allocator, m_Descriptors, return Model::CreatePlane(m_Device, m_Allocator, m_Descriptors,