目录
1.1 基于已有 glTF 知识拓展
1.2 搭建引擎的模型系统
1.3 定义数据结构
1.4 为何需要场景图
1.4.1 使用场景图的优势
1.4.2 场景图 vs 简单集合
1.4.3 场景图 vs 空间分区系统(游戏地图)
1.4.3.1 常见的空间分区系统
1.4.3.2 主流引擎中的空间分区系统
1.5 架构决策
1.6 开发者如何使用模型系统
1.6.1 加载并初始化模型
1.6.2 更新并动画化模型
1.6.3 渲染模型
1.7 回到教程主线
1.8 实现场景图
1.9 动画相关结构
1.10 Model 类
1.11 下一步:加载 glTF 文件
总结
1.1 基于已有 glTF 知识拓展
正如我们在《glTF 与 KTX2 迁移》章节所学,glTF 是一种现代 3D 格式,支持 PBR 材质、动画、场景层级等丰富特性。本章中,我们将利用这些特性构建一套更健壮的引擎。
上一章仅讲解了加载 glTF 模型的基础方法,而本章会重点关注将加载的数据组织为规范的场景图(scene graph),并实现动画支持。这种设计能让我们创建更复杂、更具动态性的场景。
本章不仅会实现模型加载的技术细节,还会探讨设计背后的架构决策,以及开发者如何在应用中高效使用这套系统。理解这些概念,是构建可维护、可扩展引擎的关键。
1.2 搭建引擎的模型系统
我们将沿用与上一章相同的 tinygltf 库配置:
// 引入 tinygltf 用于模型加载
#include <tiny_gltf.h>
但与直接将模型数据加载到顶点 / 索引缓冲区的方式不同,我们会采用更结构化的设计,通过规范的数据类来表示场景。
1.3 定义数据结构
为了处理 glTF 提供的丰富数据,我们需要定义以下数据结构:
// 包含位置、法线、颜色和纹理坐标的顶点结构
struct Vertex {
glm::vec3 pos;
glm::vec3 normal;
glm::vec3 color;
glm::vec2 texCoord;
// Vulkan 顶点输入绑定描述
static vk::VertexInputBindingDescription getBindingDescription() {
return { 0, sizeof(Vertex), vk::VertexInputRate::eVertex };
}
// Vulkan 顶点输入属性描述
static std::array<vk::VertexInputAttributeDescription, 4> getAttributeDescriptions() {
return {
vk::VertexInputAttributeDescription( 0, 0, vk::Format::eR32G32B32Sfloat, offsetof(Vertex, pos) ),
vk::VertexInputAttributeDescription( 1, 0, vk::Format::eR32G32B32Sfloat, offsetof(Vertex, normal) ),
vk::VertexInputAttributeDescription( 2, 0, vk::Format::eR32G32B32Sfloat, offsetof(Vertex, color) ),
vk::VertexInputAttributeDescription( 3, 0, vk::Format::eR32G32Sfloat, offsetof(Vertex, texCoord) )
};
}
// 用于顶点去重的相等运算符和哈希函数
bool operator==(const Vertex& other) const {
return pos == other.pos && normal == other.normal && color == other.color && texCoord == other.texCoord;
}
};
// PBR 材质属性结构
struct Material {
glm::vec4 baseColorFactor = glm::vec4(1.0f);
float metallicFactor = 1.0f;
float roughnessFactor = 1.0f;
glm::vec3 emissiveFactor = glm::vec3(0.0f);
int baseColorTextureIndex = -1;
int metallicRoughnessTextureIndex = -1;
int normalTextureIndex = -1;
int occlusionTextureIndex = -1;
int emissiveTextureIndex = -1;
};
// 包含顶点、索引和材质的网格结构
struct Mesh {
std::vector<Vertex> vertices;
std::vector<uint32_t> indices;
int materialIndex = -1;
};
1.4 为何需要场景图
场景图是一种树形数据结构,用于组织图形化场景的空间表示。尽管使用简单的集合或映射存储 3D 对象看似便捷,但场景图具备多项关键优势:
1.4.1 使用场景图的优势
- 层级变换(Hierarchical Transformations):场景图允许子对象继承父对象的变换属性。当你移动、旋转或缩放父节点时,其所有子节点会自动相对于父节点完成变换。这对于角色等复杂模型至关重要 —— 移动躯干时,附属的肢体也应随之移动。
- 空间组织(Spatial Organization):场景图基于空间关系组织对象,便于执行剔除(culling)、碰撞检测、细节层级(LOD)管理等操作。
- 动画支持(Animation Support):层级结构是骨骼动画的核心基础,动画动作会沿着骨骼链逐层传递。
- 场景管理(Scene Management):场景图简化了场景的保存 / 加载、实例化(在不同位置复用同一模型)、动态修改等操作。
1.4.2 场景图 vs 简单集合
与简单的对象映射 / 数组不同,场景图:
- 维护对象间的父子关系
- 自动将变换属性沿层级向下传递
- 为遍历算法(渲染、拾取、碰撞检测)提供自然的结构
- 支持局部坐标系到全局坐标系的变换
例如,若使用扁平的对象集合,移动角色及其所有装备时,需要逐个更新每个部件;而使用场景图时,只需移动角色节点,所有附属装备会自动随之移动。
1.4.3 场景图 vs 空间分区系统(游戏地图)
需注意区分场景图与空间分区系统(引擎开发中常称 “游戏地图”):
- 场景图:聚焦于对象间的层级关系和变换逻辑
- 空间分区系统:聚焦于在空间中高效组织对象,以优化碰撞检测、可见性判断、物理计算等操作
场景图基于逻辑关系组织对象(如角色与装备),而空间分区系统基于对象在游戏世界中的物理位置进行组织。
1.4.3.1 常见的空间分区系统
游戏开发中常用的空间分区技术包括:
- 八叉树(Octrees):将 3D 空间递归划分为 8 个相等的八分体,适用于对象分布不均的大型开放世界。八叉树会根据对象密度自适应细分,在对象密集区域划分更细。
- 二叉空间分割(Binary Space Partitioning, BSP):通过平面递归分割空间,对室内场景尤其高效,因《毁灭战士》(Doom)、《雷神之锤》(Quake)等早期第一人称射击游戏而普及。
- 四叉树(Quadtrees):八叉树的 2D 版本,将空间递归划分为 4 个象限,常用于 2D 游戏或 3D 游戏的地形管理。
- 轴对齐包围盒树(Axis-Aligned Bounding Boxes, AABB Trees):基于包围盒组织对象,构建层级结构以实现高效的碰撞检测。
- 门户系统(Portal Systems):将世界划分为由 “门户” 连接的 “房间”,对有明确区域划分的室内场景效果极佳。
- 空间哈希(Spatial Hashing):将 3D 位置映射到哈希表,可在常数时间内查找附近对象,适用于粒子系统等包含大量同尺寸对象的场景。
- 包围体层级(Bounding Volume Hierarchies, BVH):构建嵌套包围体的树形结构,支持高效的射线投射和碰撞检测。
1.4.3.2 主流引擎中的空间分区系统
不同游戏引擎采用的空间分区系统各异,且常组合多种方案:
- Unreal Engine:结合八叉树(全局世界)和 BSP(精细室内场景),还使用名为 “Unreal Visibility Determination” 的自定义系统,整合门户和潜在可见集(PVS)。
- Unity:为物理和渲染实现了四叉树 / 八叉树混合系统;导航系统则使用导航网格(navigation mesh)。
- CryEngine/CRYENGINE:室外场景用八叉树,室内区域用门户系统。
- Godot:物理引擎采用 BVH 树,渲染系统使用八叉树。
- Source Engine(Valve):因结合 BSP 和名为 “Potentially Visible Set(PVS)” 的门户系统而闻名。
- id Tech(id Software):早期版本(Doom、Quake)开创了 BSP 的应用;后续版本结合了 BSP、八叉树和门户系统。
- Frostbite(EA):结合层级网格系统和八叉树,适配大规模可破坏场景。
实际开发中,现代引擎多采用混合方案,根据游戏世界不同区域的需求选择合适的分区系统。
1.5 架构决策
设计模型系统时,我们做出了以下核心架构决策:
- 基于节点的结构(Node-Based Structure):采用节点化设计,每个节点可包含网格、变换属性和子节点,为复杂场景层级提供灵活性。
- 关注点分离(Separation of Concerns):将几何数据(顶点、索引)与材质属性、变换逻辑分离,提升内存使用效率,简化更新操作。
- 动画适配(Animation-Ready):设计专用于动画的结构,支持关键帧插值和不同动画通道(平移、旋转、缩放)。
- 内存管理(Memory Management):采用集中式所有权模型,由 Model 类管理所有节点,简化资源清理流程,避免内存泄漏。
- 高效遍历(Efficient Traversal):同时维护层级结构(nodes)和扁平列表(linearNodes),高效支持不同的遍历模式。
1.6 开发者如何使用模型系统
以下是开发者在应用中使用该模型系统的典型方式:
1.6.1 加载并初始化模型
// 创建并加载模型
Model* characterModel = new Model();
loadFromFile(characterModel, "character.gltf");
// 在模型中查找特定节点
Node* headNode = characterModel->findNode("Head");
Node* weaponAttachPoint = characterModel->findNode("RightHand");
// 为模型附加额外对象
Model* weaponModel = new Model();
loadFromFile(weaponModel, "weapon.gltf");
weaponAttachPoint->children.push_back(weaponModel->nodes[0]);
1.6.2 更新并动画化模型
// 播放动画
float deltaTime = 0.016f; // 16毫秒,约60 FPS
// 注意:实际代码中需基于帧时间动态计算,而非使用常量
// 否则在性能不同的系统上,动画速度会不一致
characterModel->updateAnimation(0, deltaTime); // 播放第一个动画
// 手动变换节点
headNode->rotation = glm::rotate(headNode->rotation, glm::radians(15.0f), glm::vec3(0, 1, 0)); // 头部转向侧面
1.6.3 渲染模型
void renderModel(Model* model, VkCommandBuffer commandBuffer) {
// 遍历模型的所有节点
for (auto& node : model->linearNodes) {
if (node->mesh.indices.size() > 0) {
// 获取全局变换矩阵
glm::mat4 nodeMatrix = node->getGlobalMatrix();
// 更新统一缓冲区(uniform buffer)的节点变换数据
updateUniformBuffer(nodeMatrix);
// 绑定对应的材质
if (node->mesh.materialIndex >= 0) {
bindMaterial(model->materials[node->mesh.materialIndex]);
}
// 绘制网格
vkCmdDrawIndexed(commandBuffer,
static_cast<uint32_t>(node->mesh.indices.size()),
1, 0, 0, 0);
}
}
}
1.7 回到教程主线
了解了开发者视角下模型系统 API 的使用方式后,接下来我们将实现这些功能。后续章节中,我们会逐步指导你实现支撑引擎的场景图、动画系统和 Model 类。
1.8 实现场景图
以下是场景图结构的具体实现:
// 场景图中的节点结构
struct Node {
Node* parent = nullptr;
std::vector<Node*> children;
Mesh mesh;
glm::mat4 matrix = glm::mat4(1.0f);
// 动画相关属性
glm::vec3 translation = glm::vec3(0.0f);
glm::quat rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f);
glm::vec3 scale = glm::vec3(1.0f);
// 获取局部变换矩阵
glm::mat4 getLocalMatrix() {
return glm::translate(glm::mat4(1.0f), translation) *
glm::toMat4(rotation) *
glm::scale(glm::mat4(1.0f), scale) *
matrix;
}
// 获取全局变换矩阵
glm::mat4 getGlobalMatrix() {
glm::mat4 m = getLocalMatrix();
Node* p = parent;
while (p) {
m = p->getLocalMatrix() * m;
p = p->parent;
}
return m;
}
};
1.9 动画相关结构
为支持动画功能,我们需要新增以下结构:
// 动画关键帧结构
struct AnimationChannel {
enum PathType { TRANSLATION, ROTATION, SCALE };
PathType path;
Node* node = nullptr;
uint32_t samplerIndex;
};
// 动画插值结构
struct AnimationSampler {
enum InterpolationType { LINEAR, STEP, CUBICSPLINE };
InterpolationType interpolation;
std::vector<float> inputs; // 关键帧时间戳
std::vector<glm::vec4> outputsVec4; // 关键帧值(用于旋转)
std::vector<glm::vec3> outputsVec3; // 关键帧值(用于平移和缩放)
};
// 动画结构
struct Animation {
std::string name;
std::vector<AnimationSampler> samplers;
std::vector<AnimationChannel> channels;
float start = std::numeric_limits<float>::max();
float end = std::numeric_limits<float>::min();
float currentTime = 0.0f;
};
1.10 Model 类
现在我们可以定义整合所有功能的 Model 类:
// 包含节点、网格、材质、纹理和动画的模型结构
struct Model {
std::vector<Node*> nodes;
std::vector<Node*> linearNodes;
std::vector<Material> materials;
std::vector<Animation> animations;
// 析构函数:释放所有节点内存
~Model() {
for (auto node : linearNodes) {
delete node;
}
}
// 根据名称查找节点
Node* findNode(const std::string& name) {
auto nodeIt = std::ranges::find_if(linearNodes, [&name](auto const& node) {
return node->name == name;
});
return (nodeIt != linearNodes.end()) ? *nodeIt : nullptr;
}
// 更新动画
void updateAnimation(uint32_t index, float deltaTime) {
assert(!animations.empty() && index < animations.size());
Animation& animation = animations[index];
animation.currentTime += deltaTime;
if (animation.currentTime > animation.end) {
animation.currentTime = animation.start;
}
for (auto& channel : animation.channels) {
AnimationSampler& sampler = animation.samplers[channel.samplerIndex];
// 二分查找当前关键帧
auto keyFrameIt = std::ranges::lower_bound(sampler.inputs, animation.currentTime);
if (keyFrameIt != sampler.inputs.end() && keyFrameIt != sampler.inputs.begin()) {
size_t i = std::distance(sampler.inputs.begin(), keyFrameIt) – 1;
float t = (animation.currentTime – sampler.inputs[i]) / (sampler.inputs[i + 1] – sampler.inputs[i]);
switch (channel.path) {
case AnimationChannel::TRANSLATION: {
glm::vec3 start = sampler.outputsVec3[i];
glm::vec3 end = sampler.outputsVec3[i + 1];
channel.node->translation = glm::mix(start, end, t);
break;
}
case AnimationChannel::ROTATION: {
glm::quat start = glm::quat(sampler.outputsVec4[i].w, sampler.outputsVec4[i].x, sampler.outputsVec4[i].y, sampler.outputsVec4[i].z);
glm::quat end = glm::quat(sampler.outputsVec4[i + 1].w, sampler.outputsVec4[i + 1].x, sampler.outputsVec4[i + 1].y, sampler.outputsVec4[i + 1].z);
channel.node->rotation = glm::slerp(start, end, t);
break;
}
case AnimationChannel::SCALE: {
glm::vec3 start = sampler.outputsVec3[i];
glm::vec3 end = sampler.outputsVec3[i + 1];
channel.node->scale = glm::mix(start, end, t);
break;
}
}
break;
}
}
}
};
1.11 下一步:加载 glTF 文件
完成模型系统的架构设计和核心数据结构实现后,下一步是从 glTF 文件中实际加载 3D 模型。下一章中,我们将讲解如何使用 tinygltf 库解析 glTF 文件,并将加载的数据填充到场景图中。我们会学习从 glTF 文件中提取网格、材质、纹理和动画,并将其转换为引擎的内部表示形式。
网硕互联帮助中心




评论前必须登录!
注册