Link

我们将把 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_NEARESTVK_FILTER_LINEARextract_mipmap_mode() 我们查看 mipmap 部分,我们将其返回为 VK_SAMPLER_MIPMAP_MODE_NEARESTVK_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 纹理