什么是glTF?

glTF(GL 传输格式)是由 Khronos 集团开发的一种标准三维文件格式(该组织也是 OpenGL 和 Vulkan 的背后)。它常被称为“3D的JPEG”,因为它旨在成为一种通用且高效的3D内容格式。

glTF的主要目的是弥合3D内容创作工具(如Blender、Maya、3ds Max)与实时渲染应用(如游戏和可视化工具)之间的差距。在 glTF 出现之前,开发者常常需要创建自定义导出器或使用未针对实时渲染优化的中间格式。

glTF的主要优势包括:

  • 效率:优化加载速度和渲染性能,同时处理最少

  • 完整性:包含几何体、材质、纹理、动画和场景层级,统一格式

  • PBR支持:内置支持现代物理渲染材质

  • 标准化:广泛应用于整个行业,减少了对定制出口商的需求

  • 可扩展性:支持厂商特定功能的扩展,同时保持兼容性

glTF 文件结构与数据组织

glTF 文件包含几个结构化的关键组件:

  • 场景与节点:用于组织场景图中对象的层级结构

  • 网格:3D 几何数据(顶点、索引、属性如法线和 UV)

  • 材质:使用物理基础渲染(PBR)模型的表面属性

  • 纹理与图像:材质的可视化数据,支持多种纹理类型

  • 动画:用于节点动画的关键帧数据(位置、旋转、缩放)

  • 皮肤:用于骨骼动画的数据(关节层级和顶点权重)

  • 相机:透视或正交相机定义

缓冲系统:高效的二进制数据存储

glTF最强大的功能之一是其三级缓冲系统:

  1. 缓冲区:原始二进制数据块(如磁盘文件)

  2. BufferViews:对缓冲区进行特定偏移和长度的视图

  3. 访问器:描述如何解释缓冲视图中的数据(类型、组件类型、计数等)

该系统允许不同属性(位置、法线、UV)共享同一底层缓冲区,从而减少内存占用和文件大小。例如:

  • 单个缓冲区可能包含所有顶点数据

  • 一个缓冲区视图指向该缓冲区内的位置数据

  • 另一个缓冲区视图指向正常数据

  • 访问器描述如何解释每个缓冲视图(例如,作为 vec3 float)

使用 tinygltf 库进行高效解析

与其从零开始编写 glTF 解析器(这将是一项重大工程),我们将使用 tinygltf 库:

  • 它是一个轻量级、仅带头部的 C++ 库,易于集成

  • 它可以透明地处理 .gltf 和 .glb 格式

  • 它管理解析 JSON 和二进制数据的复杂任务

  • 它提供了一个干净的 API,用于访问所有 glTF 组件

  • 它处理缓冲系统的细节,包括 base64 编码的数据

使用 tinygltf 让我们能够专注于将解析数据转换为引擎结构的更高层次任务,而不是处理解析 JSON 和二进制数据的底层细节。

实现稳健的 glTF 加载器

在实现生产准备的 glTF 加载器时,需要考虑以下几个因素:

  • 错误处理:对格式错误文件的稳健处理和优雅的失败

  • 格式检测:支持.gltf和.glb两种格式

  • 内存管理:高效分配和处理大数据

  • 扩展支持:处理可选的 glTF 扩展

让我们看看我们是如何实现初始文件加载的:

void loadModel(const std::string& modelPath) {
    // Create a tinygltf loader
    tinygltf::Model gltfModel;
    tinygltf::TinyGLTF loader;
    std::string err, warn;

    // Detect file extension to determine which loader to use
    bool ret = false;
    std::string extension = modelPath.substr(modelPath.find_last_of(".") + 1);
    std::transform(extension.begin(), extension.end(), extension.begin(), ::tolower);

    if (extension == "glb") {
        ret = loader.LoadBinaryFromFile(&gltfModel, &err, &warn, modelPath);
    } else if (extension == "gltf") {
        ret = loader.LoadASCIIFromFile(&gltfModel, &err, &warn, modelPath);
    } else {
        err = "Unsupported file extension: " + extension + ". Expected .gltf or .glb";
    }

    // Handle errors and warnings
    if (!warn.empty()) {
        std::cout << "glTF warning: " << warn << std::endl;
    }
    if (!err.empty()) {
        std::cout << "glTF error: " << err << std::endl;
    }
    if (!ret) {
        throw std::runtime_error("Failed to load glTF model");
    }

    // Clear existing model data
    model = Model();

    // Process the loaded data (covered in the following sections)
}

支持.gltf和.glb格式,为艺术家的工作流程提供了灵活性。

glTF有两种格式,各有优势:

  • .gltf:基于JSON的格式,包含外部二进制和图像文件

    • 易读且更容易调试

    • 允许更轻松地管理资产(纹理作为独立文件)

    • 更适合开发工作流程

  • .glb:一种将所有内容合并到单一文件中的二进制格式

    • 更紧凑且更高效地分布

    • 减少加载过程中的文件操作次数

    • 更适合部署和分发

理解基于物理的渲染(PBR)材料

本节简要介绍了PBR材料与glTF加载相关的内容。如需更全面的PBR概念和光照模型解释,请参阅光照材质章节中的物理基础渲染部分。

材质决定了表面渲染时的外观。现代游戏和引擎使用基于物理的渲染(PBR),它基于物理原理模拟光与现实材料的相互作用。

物质系统的演化

三维图形中的材质系统已经有了显著的发展:

  1. Basic Materials(1990年代):简单的漫反射色彩,可选高光

  2. 多纹理材质(2000年代):多种贴图组合以实现不同效果

  3. 基于着色器的材质(2000年代末):用于高级效果的自定义着色器程序

  4. 基于物理的渲染(2010年代):基于现实世界表面物理属性的材料

PBR代表了当前的实时图形技术。它在不同光照条件下都能提供更真实的效果,并确保无论环境如何都能保持一致的视觉效果。

PBR关键材料性能

glTF中的PBR模型基于“金属粗糙度”工作流程,利用以下关键特性:

  • 底色:表面的反照率或漫反射色(RGB或纹理)

  • 金属性:表面的金属程度(0.0 = 非金属,1.0 = 金属)

    • 金属没有漫反射,但镜面反射较高

    • 非金属(介质)具有漫反射和极小的镜面反射

  • 粗糙度:表面的光滑或粗糙程度(0.0 = 镜面状,1.0 = 粗糙)

    • 控制导致光散射的微表面细节

    • 影响反射和镜面高光的锐利度

  • 法线贴图:增加表面细节而不添加额外几何体

    • 扰动表面法线以制造额外细节的错觉

    • 比添加实际几何结构更高效

  • 遮挡图:近似表面缝隙内的自阴影

    • 会让那些环境光线较少的区域变暗

    • 增强深度和细节的感知

  • 发射:使表面发出光(RGB或纹理)

    • 用于发光物体,如屏幕、灯光或霓虹灯

    • 场景光照不会影响

这些属性可以指定为常数值,也可以作为 表面上的空间变化。我们将在 接下来几章。

展示复杂PBR材料的书架——展示木材

纹理格式与压缩

在我们的引擎中,纹理使用带有 Basis Universal 压缩的 KTX2。这种方法具有多项优势:

  • 文件大小减少:Basis Universal 压缩显著减少纹理大小,同时保持视觉质量

  • GPU兼容格式:KTX2纹理可以直接转码为特定平台的GPU格式

  • 跨平台兼容性:Basis Universal 纹理可在不同平台和图形 API 上运行

  • Mipmap 支持:KTX2 支持mipmaps,提升渲染质量和性能

glTF/glb 中的嵌入纹理

glTF 格式支持两种包含纹理的方式:

  1. 外部参考:.gltf 文件引用外部图像文件

  2. 嵌入数据:图像直接嵌入在 .glb 文件中,作为二进制数据

在我们的引擎中,我们使用 .glb 格式并嵌入 KTX2 纹理。这种方法:

  • 减少加载过程中的文件操作次数

  • 确保所有纹理始终与模型一同可用

  • 简化资产管理和分配

glTF 规范通过图像对象的属性支持嵌入纹理。使用 KTX2 纹理时,将 设置为 以表示格式。bufferViewmimeType"image/ktx2"

纹理加载过程包含多个复杂步骤,弥合了glTF的抽象纹理引用与Vulkan底层GPU资源之间的差距。

纹理加载:glTF 纹理迭代与元数据提取

首先,我们遍历 glTF 模型的纹理定义,提取定位和识别每个纹理资源所需的基本信息。

// First, load all textures from the model
std::vector<Texture> textures;
for (size_t i = 0; i < gltfModel.textures.size(); i++) {
    const auto& texture = gltfModel.textures[i];
    const auto& image = gltfModel.images[texture.source];

    Texture tex;
    tex.name = image.name.empty() ? "texture_" + std::to_string(i) : image.name;

glTF 纹理系统采用间接方法,纹理引用图像,图像包含实际像素数据或对其的引用。这种分离允许多个纹理共享相同的图像数据,但采样参数不同(如不同的滤波或包裹模式)。我们的迭代过程会建立一个全面的材质资源清单,这些材质最终将被引用。

命名策略提供了必要的调试和资产管理功能。当艺术家在3D应用中创建纹理时,有意义的名称帮助开发者识别哪些纹理在开发过程中具有哪些用途。备用命名方案确保每个纹理都有唯一标识符,即使艺术家没有提供描述性名称。

纹理加载:格式检测与缓冲区访问

接下来,我们需要确定纹理是否嵌入在glTF文件中,并识别其格式,为适当的加载策略奠定基础。

    // Check if the image is embedded as KTX2
    if (image.mimeType == "image/ktx2" && image.bufferView >= 0) {
        // Get the buffer view that contains the KTX2 data
        const auto& bufferView = gltfModel.bufferViews[image.bufferView];
        const auto& buffer = gltfModel.buffers[bufferView.buffer];

        // Extract the KTX2 data from the buffer
        const uint8_t* ktx2Data = buffer.data.data() + bufferView.byteOffset;
        size_t ktx2Size = bufferView.byteLength;

MIME类型检测确保我们专门使用KTX2格式,这相比传统图像格式如PNG或JPEG有多个优势。KTX2 专为 GPU 纹理设计,支持基础通用压缩、多层 mipmap 级别和直接 GPU 格式兼容性等高级功能。bufferView 检查确认图像数据嵌入在 glTF 文件中,而非外部引用。

缓冲区访问模式展示了 glTF 复杂的数据组织系统。我们没有不必要地复制数据,而是直接获得指向已加载glTF缓冲区内KTX2数据的指针。这种方法最大限度地减少内存使用,避免昂贵的复制操作,这在处理大量纹理数据集时尤为重要,这些数据集容易占用数百兆字节。

纹理加载:KTX2解析与验证

现在我们需要使用专门的KTX软件库加载KTX2纹理数据,并进行初步验证以确保纹理数据可用。

        // Load the KTX2 texture using KTX-Software library
        ktxTexture2* ktxTexture = nullptr;
        KTX_error_code result = ktxTexture2_CreateFromMemory(
            ktx2Data, ktx2Size,
            KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT,
            &ktxTexture
        );

        if (result != KTX_SUCCESS) {
            std::cerr << "Failed to load KTX2 texture: " << ktxErrorString(result) << std::endl;
            continue;
        }

KTX软件库提供了复杂的KTX2格式的强有力解析,处理了多个mipmap级别、各种像素格式以及从零开始实现极其复杂的元数据等细节。该标志指示库立即将实际像素数据加载到内存中,为后续处理步骤做准备。KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT

此阶段的错误处理至关重要,因为纹理文件在资产流水线处理或文件传输过程中可能损坏。当下一个纹理加载失败时继续进行,我们确保单个有问题的纹理不会阻止整个模型加载。这种优雅的降级方法对于稳健的生产系统至关重要,内容问题不应导致应用崩溃。

纹理加载:基础通用转码

接下来,我们处理将Basis Universal压缩纹理转换为GPU原生格式以实现最佳运行时性能的转码过程。

        // If the texture uses Basis Universal compression, transcode it to a GPU-friendly format
        if (ktxTexture->isCompressed && ktxTexture2_NeedsTranscoding(ktxTexture)) {
            // Choose the appropriate format based on GPU capabilities
            ktx_transcode_fmt_e transcodeFmt = KTX_TTF_BC7_RGBA;

            // For devices that don't support BC7, use alternatives
            // if (!deviceSupportsBC7) {
            //     transcodeFmt = KTX_TTF_ASTC_4x4_RGBA;
            // }
            // if (!deviceSupportsASTC) {
            //     transcodeFmt = KTX_TTF_ETC2_RGBA;
            // }

            // Transcode the texture
            result = ktxTexture2_TranscodeBasis(ktxTexture, transcodeFmt, 0);
            if (result != KTX_SUCCESS) {
                std::cerr << "Failed to transcode KTX2 texture: " << ktxErrorString(result) << std::endl;
                ktxTexture2_Destroy(ktxTexture);
                continue;
            }
        }

Basis Universal 代表了一种革命性的纹理压缩方法,解决了跨平台开发中的一个根本问题:不同的 GPU 支持不同的纹理压缩格式。传统方法需要为不同平台存储多个纹理版本,极大地增加了存储需求。Basis Universal 以中间格式存储纹理,加载时可快速转码为任何 GPU 原生格式。

格式选择逻辑(以注释形式显示)展示了生产系统如何处理GPU能力差异。桌面GPU通常支持BC7压缩,提供极佳的画质,而移动GPU则常用ASTC或ETC2格式。转码过程在运行时根据目标GPU的实际能力进行,确保每个平台上的最佳性能和质量。

转码操作本身计算量大,但只在资产加载时执行一次。由此产生的GPU原生格式在渲染过程中显著优于未压缩纹理,使得前期转码成本物有所值。失败的转码尝试会触发部分处理资源的清理,防止错误情况下的内存泄漏。

纹理加载:Vulkan 资源创建与 GPU 上传

最后,创建GPU渲染所需的Vulkan资源,并将处理后的纹理数据上传到视频内存。

        // Create Vulkan image, memory, and view
        vk::Format format = static_cast<vk::Format>(ktxTexture2_GetVkFormat(ktxTexture));
        vk::Extent3D extent{
            static_cast<uint32_t>(ktxTexture->baseWidth),
            static_cast<uint32_t>(ktxTexture->baseHeight),
            static_cast<uint32_t>(ktxTexture->baseDepth)
        };
        uint32_t mipLevels = ktxTexture->numLevels;

        // Create the Vulkan image
        vk::ImageCreateInfo imageCreateInfo{
            .imageType = vk::ImageType::e2D,
            .format = format,
            .extent = extent,
            .mipLevels = mipLevels,
            .arrayLayers = 1,
            .samples = vk::SampleCountFlagBits::e1,
            .tiling = vk::ImageTiling::eOptimal,
            .usage = vk::ImageUsageFlagBits::eSampled | vk::ImageUsageFlagBits::eTransferDst,
            .sharingMode = vk::SharingMode::eExclusive,
            .initialLayout = vk::ImageLayout::eUndefined
        };

        // Create the image, allocate memory, and bind them
        // ... (code omitted for brevity)

        // Upload the texture data to the image
        ktxTexture2_VkUploadEx(ktxTexture, &ktxVulkanTexture, &vkDevice, &vkQueue,
                              &ktxVulkanDeviceMemory, &ktxVulkanImage,
                              &ktxVulkanImageView, &ktxVulkanImageLayout,
                              &ktxVulkanImageMemory);

        // Store the Vulkan resources in our texture object
        tex.image = ktxVulkanImage;
        tex.imageView = ktxVulkanImageView;
        tex.memory = ktxVulkanImageMemory;

        // Clean up KTX resources
        ktxTexture2_Destroy(ktxTexture);
    } else {
        // Handle other image formats or external references
        // ... (code omitted for brevity)
    }

    // Create a sampler for the texture
    VkSamplerCreateInfo samplerInfo = {};
    // ... (code omitted for brevity)

    textures.push_back(tex);
}

// Now load materials and associate them with textures
for (const auto& material : gltfModel.materials) {
    Material mat;

    // Base color
    if (material.pbrMetallicRoughness.baseColorFactor.size() == 4) {
        mat.baseColorFactor.r = material.pbrMetallicRoughness.baseColorFactor[0];
        mat.baseColorFactor.g = material.pbrMetallicRoughness.baseColorFactor[1];
        mat.baseColorFactor.b = material.pbrMetallicRoughness.baseColorFactor[2];
        mat.baseColorFactor.a = material.pbrMetallicRoughness.baseColorFactor[3];
    }

    // Metallic and roughness factors
    mat.metallicFactor = material.pbrMetallicRoughness.metallicFactor;
    mat.roughnessFactor = material.pbrMetallicRoughness.roughnessFactor;

    // Associate textures with the material
    if (material.pbrMetallicRoughness.baseColorTexture.index >= 0) {
        const auto& texture = gltfModel.textures[material.pbrMetallicRoughness.baseColorTexture.index];
        mat.baseColorTexture = &textures[texture.source];
    }

    if (material.pbrMetallicRoughness.metallicRoughnessTexture.index >= 0) {
        const auto& texture = gltfModel.textures[material.pbrMetallicRoughness.metallicRoughnessTexture.index];
        mat.metallicRoughnessTexture = &textures[texture.source];
    }

    if (material.normalTexture.index >= 0) {
        const auto& texture = gltfModel.textures[material.normalTexture.index];
        mat.normalTexture = &textures[texture.source];
    }

    if (material.occlusionTexture.index >= 0) {
        const auto& texture = gltfModel.textures[material.occlusionTexture.index];
        mat.occlusionTexture = &textures[texture.source];
    }

    if (material.emissiveTexture.index >= 0) {
        const auto& texture = gltfModel.textures[material.emissiveTexture.index];
        mat.emissiveTexture = &textures[texture.source];
    }

    model.materials.push_back(mat);
}

现在,让我们来谈谈这一切如何衔接起来。

理解场景图与层级变换

场景图是一种层级树状数据结构,用于组织三维场景的空间表示。它是计算机图形和游戏引擎中的基本概念,是组织复杂场景的骨干。

场景图为何重要

场景图相较于平面物体集合具有几个关键优势:

  • 层级转变:孩子从父母那里继承转变,因此自然而然地展现复杂的关系

  • 空间组织:物体根据其逻辑关系进行组织,使场景管理更为便捷

  • 动画支持:层级结构对于骨骼动画和复杂运动模式至关重要

  • 高效遍历:实现优化的渲染、剔除和拣选操作

  • 实例化支持:同一对象可以多次出现,但有不同的变换

请看以下实际例子:

  1. 带装备的角色:角色移动时,所有附带的装备(武器、护甲)都应随之移动。在场景图中,你移动角色节点,所有子节点都会自动继承变换。

  2. 带有活动部件的车辆:车辆可能有车轮在整体移动时独立旋转。场景图使得这种运动层级结构自然而然地被表达出来。

  3. 关节动画:拥有骨骼的角色需要相对于父关节运动的关节。场景图直接模拟了这种父子关系。

场景图中的变换

场景图最强大的方面之一是它们如何处理变换:

  • 每个节点相对于其父节点有一个局部变换

  • 全局变换是通过将节点的局部变换与其父节点的全局变换结合计算的

  • 这使得复杂层级运动能够直观地建模

转型流程通常如下:

  1. 每个节点存储其局部变换(平移、旋转、缩放)

  2. 渲染时,我们通过乘以父变换来计算全局变换

  3. 这种全局变换用于将物体定位到世界空间中

以下是我们如何从glTF数据构建场景图的方法:

// First pass: create all nodes
for (size_t i = 0; i < gltfModel.nodes.size(); i++) {
    const auto& node = gltfModel.nodes[i];
    model.linearNodes[i] = new Node();
    model.linearNodes[i]->index = static_cast<uint32_t>(i);
    model.linearNodes[i]->name = node.name;

    // Get transformation data
    if (node.translation.size() == 3) {
        model.linearNodes[i]->translation = glm::vec3(
            node.translation[0], node.translation[1], node.translation[2]
        );
    }
    // ... handle rotation and scale
}

// Second pass: establish parent-child relationships
for (size_t i = 0; i < gltfModel.nodes.size(); i++) {
    const auto& node = gltfModel.nodes[i];
    for (int childIdx : node.children) {
        model.linearNodes[childIdx]->parent = model.linearNodes[i];
        model.linearNodes[i]->children.push_back(model.linearNodes[childIdx]);
    }
}

我们采用双重传递方法,确保所有节点都存在,然后再尝试将它们连接起来。

理解三维几何与网格数据

3D模型以网格形式表示——由顶点、边和面组成的集合,定义了物体的形状。理解这些数据的结构对于高效的渲染至关重要。

三维模型的构建要素

三维几何的基本组成部分包括:

  • 顶点:定义形状的三维空间点

  • 索引:指向定义其连接方式的顶点,形成三角形

  • 属性:与顶点相关的额外数据:

    • 位置:三维坐标(x, y, z)

    • 法线:垂直于表面的方向向量(用于光照计算)

    • 纹理坐标(UV):用于将纹理映射到表面的二维坐标

    • 切线与双切线:用于法线映射的矢量

    • 颜色:每个顶点的颜色数据

    • 蒙皮权重和索引:用于骨骼动画

现代3D图形使用三角形网格的原因:

  • 三角形总是平面的(三点定义一个平面)

  • 三角形是最简单的多边形,可以表示任何曲面

  • 图形硬件针对三角形处理进行了优化

glTF中的网格组织

glTF 以高效的方式组织网格数据,既对存储又有利于渲染:

  • 网格:由原元组成的集合,构成一个逻辑对象

  • 基本元:网格中的独立部分,每个部分都有自己的材质

  • 属性:顶点数据,如位置、法线和纹理坐标

  • 索引:指向定义三角形的顶点的引用

该组织允许:

  • 通过数据共享实现的高效内存使用

  • 单一网格内的材料变化

  • 通过批处理优化渲染

以下是我们提取网格数据的方法:

// Load meshes
for (size_t i = 0; i < gltfModel.nodes.size(); i++) {
    const auto& node = gltfModel.nodes[i];
    if (node.mesh >= 0) {
        const auto& mesh = gltfModel.meshes[node.mesh];

        // Process each primitive
        for (const auto& primitive : mesh.primitives) {
            Mesh newMesh;

            // Set material
            if (primitive.material >= 0) {
                newMesh.materialIndex = primitive.material;
            }

            // Extract vertex positions, normals, and texture coordinates
            // ... (code omitted for brevity)

            // Extract indices that define triangles
            // ... (code omitted for brevity)

            // Assign the mesh to the node
            model.linearNodes[i]->mesh = newMesh;
        }
    }
}

理解动画系统

动画将静态的3D模型转化为我们虚拟世界中有生命、有呼吸的实体。强大的动画系统对于创建引人入胜且动态的3D应用至关重要。

3D图形中的动画技术

三维图形中常用的几种动画技术:

  • 关键帧动画:在特定时间定义特定姿势,并在它们之间进行插值

  • 骨骼动画:使用骨骼层级来变形网格

  • 变形目标动画:在预定义网格形状之间插值

  • 程序动画:通过算法和物理生成动画

  • 粒子系统:用简单规则动画化许多小元素

现代游戏通常结合这些技术,骨骼动画构成角色移动的骨干。

核心动画概念

理解动画系统有几个关键概念:

  • 关键帧:动画值被明确定义的特定时间点

  • 插值:计算关键帧之间的数值以实现平滑运动

  • 通道:针对特定属性(如位置或旋转)进行动画定位

  • 混合:将多个不同权重的动画组合在一起

  • 重定向:将为一个模型制作的动画应用到另一个模型上

glTF动画系统

glTF使用灵活的动画系统,可以表示各种动画技术:

  • 动画:通道和采样器的合集

  • 通道:采样器与节点属性(平移、旋转、缩放)之间的链接

  • 采样器:带有时间戳、数值和插值方法的关键帧数据

  • 目标:被动画化的属性(变形目标的平移、旋转、比例或权重)

glTF支持三种插值方法:

  • 线性:速度恒定的平滑跃迁

  • 步骤:无插值的突然变化

  • 立方曲线:带有加速和减速控制点的平滑曲线

该系统支持复杂的动画,可以独立针对模型的特定部分,实现行走、面部表情和复杂互动等动作。

以下是我们加载动画数据的方式:

// Load animations
for (const auto& anim : gltfModel.animations) {
    Animation animation;
    animation.name = anim.name;

    // Load keyframe data
    for (const auto& sampler : anim.samplers) {
        AnimationSampler animSampler{};

        // Set interpolation type (LINEAR, STEP, or CUBICSPLINE)
        // ... (code omitted for brevity)

        // Extract keyframe times and values
        // ... (code omitted for brevity)

        animation.samplers.push_back(animSampler);
    }

    // Connect samplers to node properties
    for (const auto& channel : anim.channels) {
        AnimationChannel animChannel{};

        // Set target node and property (translation, rotation, or scale)
        // ... (code omitted for brevity)

        animation.channels.push_back(animChannel);
    }

    model.animations.push_back(animation);
}

与渲染管线的集成

既然我们已经加载了模型数据,接下来讨论它如何与渲染流程的其他部分集成。

从资产加载到渲染

从glTF文件到屏幕上像素的过程包含几个阶段:

  1. 资产加载:glTF加载器填充我们的模型、节点、网格和材质结构

  2. 场景管理:引擎维护场景中已加载的模型集合

  3. 更新循环:每一帧动画都会根据经过的时间进行更新

  4. 剔除:引擎决定哪些物体可能被看到

  5. 渲染:遍历场景图,每个可见网格及其材质被渲染

该流水线允许通过动画模型高效渲染复杂场景。

渲染优化

有几种优化可以提升模型渲染的性能:

  • 批处理:将相似对象分组以减少绘制调用

  • 实例化:用不同的变换渲染同一个网格的多个实例

  • 细节级别(LOD):在较远距离使用更简单的模型版本

  • 视锥体剔除:跳过渲染摄像机视野之外的物体

  • 遮挡剔除:跳过隐藏在其他物体后面的渲染对象

内存管理的考虑

在加载模型,尤其是大型模型时,内存管理变得至关重要:

  • 顶点数据:存储在GPU缓冲区中以实现高效渲染

  • 索引:尽可能使用16位索引以节省内存

  • 贴图:使用带有Basis Universal压缩的KTX2,显著减少内存使用。

  • 实例化:将同一模型数据用于多个实例,进行不同的转换

使用 KTX2 和 Basis Universal 的高效纹理内存管理

在3D应用中,纹理通常占用大部分GPU内存。采用Basis Universal压缩的KTX2提供了多项内存优化优势:

  • 超压缩:Basis Universal 可以将纹理大小比无压缩格式减少 4-10 倍

  • GPU原生格式:纹理被转码为GPU可直接采样的格式,避免运行时解压

  • Mipmaps:KTX2支持mipmaps,不仅提升了视觉质量,还减少了远处物体的内存占用

  • 格式选择:转码器可以根据目标GPU的能力选择最佳格式:

    • BC7 桌面显卡(NVIDIA、AMD、英特尔)

    • 移动显卡(ARM,高通)的ASTC应用

    • ETC2 针对老款移动显卡

与Vulkan渲染管线的集成

要高效将KTX2纹理与Vulkan集成:

  1. 描述符集:创建描述符集,将纹理图像视图和采样器绑定到着色器绑定点

  2. 流水线布局:定义包含这些描述符集的流水线布局

  3. 着色器访问:在着色器中,通过合适的绑定点访问纹理

这里有一个简化的PBR纹理描述符集设置示例:

// Create descriptor set layout for PBR textures
std::array<vk::DescriptorSetLayoutBinding, 5> bindings{
    // Base color texture
    vk::DescriptorSetLayoutBinding{
        .binding = 0,
        .descriptorType = vk::DescriptorType::eCombinedImageSampler,
        .descriptorCount = 1,
        .stageFlags = vk::ShaderStageFlagBits::eFragment
    },
    // Metallic-roughness texture
    vk::DescriptorSetLayoutBinding{
        .binding = 1,
        .descriptorType = vk::DescriptorType::eCombinedImageSampler,
        .descriptorCount = 1,
        .stageFlags = vk::ShaderStageFlagBits::eFragment
    },
    // Normal map
    vk::DescriptorSetLayoutBinding{
        .binding = 2,
        .descriptorType = vk::DescriptorType::eCombinedImageSampler,
        .descriptorCount = 1,
        .stageFlags = vk::ShaderStageFlagBits::eFragment
    },
    // Occlusion map
    vk::DescriptorSetLayoutBinding{
        .binding = 3,
        .descriptorType = vk::DescriptorType::eCombinedImageSampler,
        .descriptorCount = 1,
        .stageFlags = vk::ShaderStageFlagBits::eFragment
    },
    // Emissive map
    vk::DescriptorSetLayoutBinding{
        .binding = 4,
        .descriptorType = vk::DescriptorType::eCombinedImageSampler,
        .descriptorCount = 1,
        .stageFlags = vk::ShaderStageFlagBits::eFragment
    }
};

vk::DescriptorSetLayoutCreateInfo layoutInfo{
    .bindingCount = static_cast<uint32_t>(bindings.size()),
    .pBindings = bindings.data()
};

vk::raii::DescriptorSetLayout descriptorSetLayout(device, layoutInfo);

// For each material, create a descriptor set and update it with the material's textures
for (const auto& material : model.materials) {
    // Allocate descriptor set from the descriptor pool
    vk::DescriptorSetAllocateInfo allocInfo{
        .descriptorPool = descriptorPool,
        .descriptorSetCount = 1,
        .pSetLayouts = &*descriptorSetLayout
    };

    vk::raii::DescriptorSet descriptorSet = std::move(vk::raii::DescriptorSets(device, allocInfo).front());

    // Update descriptor set with texture image views and samplers
    std::vector<vk::WriteDescriptorSet> descriptorWrites;

    if (material.baseColorTexture) {
        vk::DescriptorImageInfo imageInfo{
            .sampler = material.baseColorTexture->sampler,
            .imageView = material.baseColorTexture->imageView,
            .imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal
        };

        vk::WriteDescriptorSet write{
            .dstSet = *descriptorSet,
            .dstBinding = 0,
            .dstArrayElement = 0,
            .descriptorCount = 1,
            .descriptorType = vk::DescriptorType::eCombinedImageSampler,
            .pImageInfo = &imageInfo
        };

        descriptorWrites.push_back(write);
    }

    // Similar writes for other textures
    // ...

    device.updateDescriptorSets(descriptorWrites, {});

    // Store the descriptor set with the material for later use during rendering
    material.descriptorSet = *descriptorSet;
}
纹理内存管理的最佳实践

为了优化纹理内存的使用情况:

  1. 纹理图集:将多个小纹理合并成一个更大的纹理,以减少状态变化

  2. Mipmap管理:生成并使用所有纹理的mipmaps,以提升性能和质量

  3. 纹理流:对于非常大的场景,实现纹理流,只在需要时加载高分辨率纹理

  4. 内存预算:实现纹理预算系统,在内存受限时可以降低纹理质量

  5. 格式选择:根据纹理内容选择合适的格式:

    • BC7/ASTC 用于带 alpha 的色彩纹理

    • BC1/ETC1 用于无 alpha 的色彩纹理

    • BC5/ETC2 用于法线贴图

    • BC4/EAC 用于单通道纹理(粗糙度、金属质感等)

摘要与后续步骤

在本章中,我们探讨了从glTF文件加载3D模型并将其组织成场景图的过程。我们已经涵盖了:

  • glTF格式的结构与优势

  • 如何使用 tinygltf 库进行高效的解析

  • 现代渲染中使用的基于物理的材质系统

  • 场景图如何组织物体在层级结构中

  • 网格中三维几何的表示

  • 用于让模型栩栩如生的动画系统

  • 与渲染管线的集成

我们的 glTF 加载器创建一个完整的场景图,包含:

  • 节点按层级组织

  • 连接到节点的网格

  • 定义表面性质的材料

  • 可以随时间改变节点属性的动画

这种结构使我们能够:

  • 渲染复杂的3D场景

  • 动画角色和物体

  • 应用能够在层级中传播的变换

  • 优化渲染以提升性能