我们将把 GLTF 加载分成两部分,第一部分处理节点和网格,第二部分处理材质和纹理。
旧的 GLTF 加载函数将被移除,我们将用新的函数替换它,新的函数可以直接加载场景节点,而不仅仅是网格。您可以保留旧的函数以进行复制粘贴或调试,但不再需要它。
我们首先需要创建 LoadedGLTF 类。这将包含处理单个 GLTF 文件数据所需的所有资源。为了简单起见,我们之前没有正确地进行资源管理,但是有了这个类,它将在销毁时释放网格缓冲区和纹理。通过这样做,我们将从必须单独处理资源(例如跟踪要卸载的单个网格/纹理)转变为更简单的方案,在更简单的方案中,我们将它们分组。这样做的目标是,用户将加载一个 GLTF 作为关卡,并且该 gltf 包含整个关卡所需的所有纹理、网格、对象。然后,用户还将加载另一个包含角色或对象的 GLTF,并保持加载状态以供游戏使用。仅仅因为我们将场景加载到一个类中,并不意味着不可能在 LoadedGLTF 中的各个节点上调用 Draw() 函数。
它看起来像这样,将其添加到 vk_loader.h
struct LoadedGLTF : public IRenderable {
// storage for all the data on a given glTF file
std::unordered_map<std::string, std::shared_ptr<MeshAsset>> meshes;
std::unordered_map<std::string, std::shared_ptr<Node>> nodes;
std::unordered_map<std::string, AllocatedImage> images;
std::unordered_map<std::string, std::shared_ptr<GLTFMaterial>> materials;
// nodes that dont have a parent, for iterating through the file in tree order
std::vector<std::shared_ptr<Node>> topNodes;
std::vector<VkSampler> samplers;
DescriptorAllocatorGrowable descriptorPool;
AllocatedBuffer materialDataBuffer;
VulkanEngine* creator;
~LoadedGLTF() { clearAll(); };
virtual void Draw(const glm::mat4& topMatrix, DrawContext& ctx);
private:
void clearAll();
};
GLTF 中的对象有名称,因此我们将把它们全部存储到无序映射中。一切都通过 shared_ptr 存储。这样,我们就可以防止出现未命名节点之类的情况。整个文件将形成一个“图”的共享指针连接,并保持自身存活。
我们有一个网格映射,这将保存网格缓冲区。它与我们在仅网格加载器中拥有的相同。然后我们有一个场景节点映射,它是从文件中的变换树创建的。然后是纹理映射,以及材质映射。纹理和材质是分开的,因为相同的纹理通常用于多个材质。GLTF 文件格式也以这种方式分隔它们。
我们还存储了一个未父节点的向量。这样我们就可以从 LoadedGLTF 中的 Draw() 函数递归地 Draw() 它们。它也可能出于其他原因很有用,例如在编辑器中显示节点。
VkSampler 数组也是如此,这与 GLTF 格式匹配。它很可能只有几个。我们可以将它们哈希并全局存储在引擎中,但是如果我们坚持加载的 gltf 控制所有 vulkan 资源的概念,它会简化引擎。
然后我们有一个专门为这个 glTF 文件制作的描述符池。这样我们就无需在任何时候单独处理描述符集,并且可以释放它以删除文件中所有材质的描述符。
materialDataBuffer 将包含材质数据,如 GLTFMetallicRoughness 材质中所见。我们将使用一个大的缓冲区来存储文件中每个材质的材质数据。
其余的是一些销毁函数、Draw() 函数,以及将 VulkanEngine 存储在文件中,以便 clearAll 可以正确释放资源。如果我们愿意,我们可以使用单例来避免存储此指针。
让我们开始在 vk_loader.cpp 中编写新的加载器代码。我们将从头开始制作,并将所有材质暂时保留为默认值。
std::optional<std::shared_ptr<LoadedGLTF>> loadGltf(VulkanEngine* engine,std::string_view filePath)
{
fmt::print("Loading GLTF: {}", filePath);
std::shared_ptr<LoadedGLTF> scene = std::make_shared<LoadedGLTF>();
scene->creator = engine;
LoadedGLTF& file = *scene.get();
fastgltf::Parser parser {};
constexpr auto gltfOptions = fastgltf::Options::DontRequireValidAssetMember | fastgltf::Options::AllowDouble | fastgltf::Options::LoadGLBBuffers | fastgltf::Options::LoadExternalBuffers;
// fastgltf::Options::LoadExternalImages;
fastgltf::GltfDataBuffer data;
data.loadFromFile(filePath);
fastgltf::Asset gltf;
std::filesystem::path path = filePath;
auto type = fastgltf::determineGltfFileType(&data);
if (type == fastgltf::GltfType::glTF) {
auto load = parser.loadGLTF(&data, path.parent_path(), gltfOptions);
if (load) {
gltf = std::move(load.get());
} else {
std::cerr << "Failed to load glTF: " << fastgltf::to_underlying(load.error()) << std::endl;
return {};
}
} else if (type == fastgltf::GltfType::GLB) {
auto load = parser.loadBinaryGLTF(&data, path.parent_path(), gltfOptions);
if (load) {
gltf = std::move(load.get());
} else {
std::cerr << "Failed to load glTF: " << fastgltf::to_underlying(load.error()) << std::endl;
return {};
}
} else {
std::cerr << "Failed to determine glTF container" << std::endl;
return {};
}
}
我们将首先加载文件。由于这将比仅加载网格的加载器更通用,因此我们添加了检查以在 GLTF 文件和 GLB 文件之间进行更改。除此之外,它与仅网格加载器中的基本相同。
// we can stimate the descriptors we will need accurately
std::vector<DescriptorAllocatorGrowable::PoolSizeRatio> sizes = { { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 3 },
{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 3 },
{ VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1 } };
file.descriptorPool.init(engine->_device, gltf.materials.size(), sizes);
接下来,我们使用所需描述符数量的估计值初始化描述符池。如果我们溢出池,它将使用可增长池,因此它只会根据需要添加更多 VkDescriptorPool。
让我们加载采样器。GLTF 采样器使用来自 OpenGL 的数字和属性,这些数字和属性与 vulkan 的不匹配,因此我们将必须创建一些转换函数。
VkFilter extract_filter(fastgltf::Filter filter)
{
switch (filter) {
// nearest samplers
case fastgltf::Filter::Nearest:
case fastgltf::Filter::NearestMipMapNearest:
case fastgltf::Filter::NearestMipMapLinear:
return VK_FILTER_NEAREST;
// linear samplers
case fastgltf::Filter::Linear:
case fastgltf::Filter::LinearMipMapNearest:
case fastgltf::Filter::LinearMipMapLinear:
default:
return VK_FILTER_LINEAR;
}
}
VkSamplerMipmapMode extract_mipmap_mode(fastgltf::Filter filter)
{
switch (filter) {
case fastgltf::Filter::NearestMipMapNearest:
case fastgltf::Filter::LinearMipMapNearest:
return VK_SAMPLER_MIPMAP_MODE_NEAREST;
case fastgltf::Filter::NearestMipMapLinear:
case fastgltf::Filter::LinearMipMapLinear:
default:
return VK_SAMPLER_MIPMAP_MODE_LINEAR;
}
}
这两个函数是 vk_loader.cpp 上的全局函数。我们不需要在外部使用它们。在 vulkan 中,采样器过滤器与 mipmap 模式是分开的。因此,我们首先从 GLTF 过滤器选项中提取过滤器。extract_filter()
仅查看过滤,因此它返回 VK_FILTER_NEAREST
或 VK_FILTER_LINEAR
。extract_mipmap_mode()
我们查看 mipmap 部分,我们将其返回为 VK_SAMPLER_MIPMAP_MODE_NEAREST
或 VK_SAMPLER_MIPMAP_MODE_LINEAR
。线性将混合 mipmap,而最近邻将使用单个 mipmap 而不进行混合。
现在我们可以从 glTF 文件加载采样器。
// load samplers
for (fastgltf::Sampler& sampler : gltf.samplers) {
VkSamplerCreateInfo sampl = { .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .pNext = nullptr};
sampl.maxLod = VK_LOD_CLAMP_NONE;
sampl.minLod = 0;
sampl.magFilter = extract_filter(sampler.magFilter.value_or(fastgltf::Filter::Nearest));
sampl.minFilter = extract_filter(sampler.minFilter.value_or(fastgltf::Filter::Nearest));
sampl.mipmapMode= extract_mipmap_mode(sampler.minFilter.value_or(fastgltf::Filter::Nearest));
VkSampler newSampler;
vkCreateSampler(engine->_device, &sampl, nullptr, &newSampler);
file.samplers.push_back(newSampler);
}
我们创建一个 VkSamplerCreateInfo
,默认其最大/最小 LOD 设置,并使用上述提取函数设置其过滤设置。与上一章中的默认采样器不同,我们设置了最小/最大 lod,以便我们可以让它们使用 mipmap。我们将直接在 LoadedGLTF 结构上存储 VkSampler。
在我们开始加载网格之前,我们将创建一些数组来保存结构。在 GLTF 文件中,一切都通过索引工作,因此我们需要一种处理它的方法。例如,网格节点将给出网格索引,而不是名称或任何类似的东西。
// temporal arrays for all the objects to use while creating the GLTF data
std::vector<std::shared_ptr<MeshAsset>> meshes;
std::vector<std::shared_ptr<Node>> nodes;
std::vector<AllocatedImage> images;
std::vector<std::shared_ptr<GLTFMaterial>> materials;
现在我们必须按顺序加载所有内容。MeshNodes 依赖于网格,网格依赖于材质,材质依赖于纹理。因此我们需要以正确的顺序创建它们。我们从纹理开始。对于它们,我们只是要复制我们在引擎上拥有的默认纹理,因为我们稍后会加载它们。让我们使用错误棋盘格纹理,因为它将用于所有加载失败的图像。
// load all textures
for (fastgltf::Image& image : gltf.images) {
images.push_back(engine->_errorCheckerboardImage);
}
在材质上,我们必须预先计算我们需要多大的缓冲区来保存所有材质参数。我们只有 1 种材质类型,所以这里没有问题,只需将 uniform 缓冲区结构的大小乘以材质的数量即可
// create buffer to hold the material data
file.materialDataBuffer = engine->create_buffer(sizeof(GLTFMetallic_Roughness::MaterialConstants) * gltf.materials.size(),
VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, VMA_MEMORY_USAGE_CPU_TO_GPU);
int data_index = 0;
GLTFMetallic_Roughness::MaterialConstants* sceneMaterialConstants = (GLTFMetallic_Roughness::MaterialConstants*)file.materialDataBuffer.info.pMappedData;
我们将把映射的指针存储在 sceneMaterialConstants 中以进行写入。
现在编写循环以加载材质。
for (fastgltf::Material& mat : gltf.materials) {
std::shared_ptr<GLTFMaterial> newMat = std::make_shared<GLTFMaterial>();
materials.push_back(newMat);
file.materials[mat.name.c_str()] = newMat;
GLTFMetallic_Roughness::MaterialConstants constants;
constants.colorFactors.x = mat.pbrData.baseColorFactor[0];
constants.colorFactors.y = mat.pbrData.baseColorFactor[1];
constants.colorFactors.z = mat.pbrData.baseColorFactor[2];
constants.colorFactors.w = mat.pbrData.baseColorFactor[3];
constants.metal_rough_factors.x = mat.pbrData.metallicFactor;
constants.metal_rough_factors.y = mat.pbrData.roughnessFactor;
// write material parameters to buffer
sceneMaterialConstants[data_index] = constants;
MaterialPass passType = MaterialPass::MainColor;
if (mat.alphaMode == fastgltf::AlphaMode::Blend) {
passType = MaterialPass::Transparent;
}
GLTFMetallic_Roughness::MaterialResources materialResources;
// default the material textures
materialResources.colorImage = engine->_whiteImage;
materialResources.colorSampler = engine->_defaultSamplerLinear;
materialResources.metalRoughImage = engine->_whiteImage;
materialResources.metalRoughSampler = engine->_defaultSamplerLinear;
// set the uniform buffer for the material data
materialResources.dataBuffer = file.materialDataBuffer.buffer;
materialResources.dataBufferOffset = data_index * sizeof(GLTFMetallic_Roughness::MaterialConstants);
// grab textures from gltf file
if (mat.pbrData.baseColorTexture.has_value()) {
size_t img = gltf.textures[mat.pbrData.baseColorTexture.value().textureIndex].imageIndex.value();
size_t sampler = gltf.textures[mat.pbrData.baseColorTexture.value().textureIndex].samplerIndex.value();
materialResources.colorImage = images[img];
materialResources.colorSampler = file.samplers[sampler];
}
// build material
newMat->data = engine->metalRoughMaterial.write_material(engine->_device, passType, materialResources, file.descriptorPool);
data_index++;
}
首先,我们开始编写 MaterialConstants,从 GLTF 中的材质信息加载它们。我们加载基础颜色因子和金属/粗糙度因子。
然后,我们需要填充 MaterialResources 结构。我们将把纹理和采样器默认设置为默认的白色纹理和采样器。然后,我们在正确的数据偏移量处挂钩 materialDataBuffer。然后,我们检查材质是否具有颜色纹理(GLTF 材质上的纹理是可选的。如果未设置,它们通常默认为白色)。如果有颜色纹理,我们按索引挂钩纹理,并按索引挂钩采样器。我们还检查材质是否透明,如果是这种情况,则将 MaterialPass 设置为 Blend。
一旦我们拥有了一切,我们就将参数传递到 metaRoughMaterial 类并写入材质。
接下来是加载网格。我们将或多或少地做我们在旧加载器中所做的相同的事情,不同之处在于我们以不同的方式存储网格
// use the same vectors for all meshes so that the memory doesnt reallocate as
// often
std::vector<uint32_t> indices;
std::vector<Vertex> vertices;
for (fastgltf::Mesh& mesh : gltf.meshes) {
std::shared_ptr<MeshAsset> newmesh = std::make_shared<MeshAsset>();
meshes.push_back(newmesh);
file.meshes[mesh.name.c_str()] = newmesh;
newmesh->name = mesh.name;
// clear the mesh arrays each mesh, we dont want to merge them by error
indices.clear();
vertices.clear();
for (auto&& p : mesh.primitives) {
GeoSurface newSurface;
newSurface.startIndex = (uint32_t)indices.size();
newSurface.count = (uint32_t)gltf.accessors[p.indicesAccessor.value()].count;
size_t initial_vtx = vertices.size();
// load indexes
{
fastgltf::Accessor& indexaccessor = gltf.accessors[p.indicesAccessor.value()];
indices.reserve(indices.size() + indexaccessor.count);
fastgltf::iterateAccessor<std::uint32_t>(gltf, indexaccessor,
[&](std::uint32_t idx) {
indices.push_back(idx + initial_vtx);
});
}
// load vertex positions
{
fastgltf::Accessor& posAccessor = gltf.accessors[p.findAttribute("POSITION")->second];
vertices.resize(vertices.size() + posAccessor.count);
fastgltf::iterateAccessorWithIndex<glm::vec3>(gltf, posAccessor,
[&](glm::vec3 v, size_t index) {
Vertex newvtx;
newvtx.position = v;
newvtx.normal = { 1, 0, 0 };
newvtx.color = glm::vec4 { 1.f };
newvtx.uv_x = 0;
newvtx.uv_y = 0;
vertices[initial_vtx + index] = newvtx;
});
}
// load vertex normals
auto normals = p.findAttribute("NORMAL");
if (normals != p.attributes.end()) {
fastgltf::iterateAccessorWithIndex<glm::vec3>(gltf, gltf.accessors[(*normals).second],
[&](glm::vec3 v, size_t index) {
vertices[initial_vtx + index].normal = v;
});
}
// load UVs
auto uv = p.findAttribute("TEXCOORD_0");
if (uv != p.attributes.end()) {
fastgltf::iterateAccessorWithIndex<glm::vec2>(gltf, gltf.accessors[(*uv).second],
[&](glm::vec2 v, size_t index) {
vertices[initial_vtx + index].uv_x = v.x;
vertices[initial_vtx + index].uv_y = v.y;
});
}
// load vertex colors
auto colors = p.findAttribute("COLOR_0");
if (colors != p.attributes.end()) {
fastgltf::iterateAccessorWithIndex<glm::vec4>(gltf, gltf.accessors[(*colors).second],
[&](glm::vec4 v, size_t index) {
vertices[initial_vtx + index].color = v;
});
}
if (p.materialIndex.has_value()) {
newSurface.material = materials[p.materialIndex.value()];
} else {
newSurface.material = materials[0];
}
newmesh->surfaces.push_back(newSurface);
}
newmesh->meshBuffers = engine->uploadMesh(indices, vertices);
}
不同之处在于,在最后,我们处理材质索引。如果没有材质,我们将默认使用文件中的第一个材质。网格没有材质索引的情况很少见,即使它在文件中是可选的,因此我们不会太在意它。
现在我们将加载节点
// load all nodes and their meshes
for (fastgltf::Node& node : gltf.nodes) {
std::shared_ptr<Node> newNode;
// find if the node has a mesh, and if it does hook it to the mesh pointer and allocate it with the meshnode class
if (node.meshIndex.has_value()) {
newNode = std::make_shared<MeshNode>();
static_cast<MeshNode*>(newNode.get())->mesh = meshes[*node.meshIndex];
} else {
newNode = std::make_shared<Node>();
}
nodes.push_back(newNode);
file.nodes[node.name.c_str()];
std::visit(fastgltf::visitor { [&](fastgltf::Node::TransformMatrix matrix) {
memcpy(&newNode->localTransform, matrix.data(), sizeof(matrix));
},
[&](fastgltf::Node::TRS transform) {
glm::vec3 tl(transform.translation[0], transform.translation[1],
transform.translation[2]);
glm::quat rot(transform.rotation[3], transform.rotation[0], transform.rotation[1],
transform.rotation[2]);
glm::vec3 sc(transform.scale[0], transform.scale[1], transform.scale[2]);
glm::mat4 tm = glm::translate(glm::mat4(1.f), tl);
glm::mat4 rm = glm::toMat4(rot);
glm::mat4 sm = glm::scale(glm::mat4(1.f), sc);
newNode->localTransform = tm * rm * sm;
} },
node.transform);
}
节点加载将分为两个阶段。第一次,我们创建节点,无论是作为基本 Node 类,还是 MeshNode 类,具体取决于节点是否具有网格。然后我们需要计算其局部矩阵,为此,我们加载 GLTF 变换数据,并将其转换为 gltf 最终变换矩阵。
加载节点后,我们需要设置它们的父子关系以构建场景图
// run loop again to setup transform hierarchy
for (int i = 0; i < gltf.nodes.size(); i++) {
fastgltf::Node& node = gltf.nodes[i];
std::shared_ptr<Node>& sceneNode = nodes[i];
for (auto& c : node.children) {
sceneNode->children.push_back(nodes[c]);
nodes[c]->parent = sceneNode;
}
}
// find the top nodes, with no parents
for (auto& node : nodes) {
if (node->parent.lock() == nullptr) {
file.topNodes.push_back(node);
node->refreshTransform(glm::mat4 { 1.f });
}
}
return scene;
首先,我们循环遍历每个节点,查找它是否具有子节点,并设置父/子指针。然后我们再次循环,但是我们找到没有父节点的节点,将它们添加到 topNodes 数组,并刷新它们的变换。请记住上一章中的 refreshTransform 将根据父子关系递归地重新计算世界矩阵,因此所有父节点的内容也将被刷新。
这样,我们就完成了整个场景的加载。并且可以尝试渲染它们。让我们填写 Draw() 函数。
void LoadedGLTF::Draw(const glm::mat4& topMatrix, DrawContext& ctx)
{
// create renderables from the scenenodes
for (auto& n : topNodes) {
n->Draw(topMatrix, ctx);
}
}
Draw 函数仅循环遍历顶部节点并在其上调用 Draw,这将传播到它们的子节点。
暂时将 clearAll() 函数留空。我们尚未正确处理纹理,因此它将是半成品。
让我们将其连接到 VulkanEngine 类。
我们将按名称将加载的 gltf 存储到无序映射中
std::unordered_map<std::string, std::shared_ptr<LoadedGLTF>> loadedScenes;
然后让我们尝试加载一个。这个包含在项目中,它是一个包含 1500 个要绘制的网格的大场景。在调试模式下,加载它需要一两秒钟,所以请耐心等待。
在 init() 函数的末尾添加此代码。
std::string structurePath = { "..\\..\\assets\\structure.glb" };
auto structureFile = loadGltf(this,structurePath);
assert(structureFile.has_value());
loadedScenes["structure"] = *structureFile;
我们加载该资源,然后将其存储在哈希映射中以供以后使用。
以后使用它是在 update_scene() 函数中调用 Draw() 作为一部分
loadedScenes["structure"]->Draw(glm::mat4{ 1.f }, mainDrawContext);
这是一个很大的场景,因此我们可以通过在不同位置多次加载它来将其用于基准测试。
清除场景时,我们需要在适当的时刻进行。在 clear() 函数中,添加此代码。我们将在 WaitIdle 调用之后,在 vulkan cleanup() 的开始处,清除哈希映射,这将清除所有映射。
// make sure the gpu has stopped doing its things
vkDeviceWaitIdle(_device);
loadedScenes.clear();
现在尝试运行项目,看看是否可以探索地图。它很大,但您现在可以移动相机。对于相机的“良好”默认位置,您可以将相机初始位置更改为
mainCamera.position = glm::vec3(30.f, -00.f, -085.f);
在 init() 函数中。这将是一个很好的默认位置。
让我们在下一篇文章中完成它,加载纹理。
下一步: GLTF 纹理