在上一章中,我们为虚拟世界添加了加载静态关卡数据的功能。 现在实例不再需要悬空运行,它们可以获得一个虚拟家园。 首先,我们探讨了模型数据与关卡数据的区别,关卡数据最常用的文件格式,以及如何在互联网上寻找游戏地图。 接着,我们添加了加载关卡数据的代码,并用三维八叉树替代了二维四叉树。 最后,我们实现了关卡数据的渲染,包括线框、八叉树和关卡 AABB 边界线的调试数据。
本章中,我们将扩展前一章的关卡数据。 首先,我们将为关卡数据添加专门的八叉树,并更新代码以支持实例与关卡几何体之间的碰撞检测。 然后,我们会在虚拟世界中添加简化版的重力系统,使实例保持在地面高度而非漂浮在空中。 最后一步,我们将为实例腿部引入逆向运动学,使实例能够以更自然的腿部动作攀爬斜坡和楼梯,并防止脚部穿入地面或悬空漂浮。
在本章中,我们将涵盖以下主题:
通过使用两种不同的八叉树,我们可以解决上述问题。 在添加完所有关卡三角形后,关卡数据八叉树保持不变,两种八叉树根据每个八分体中的数据量各自进行细分,但我们仍可通过关卡八叉树中的实例包围盒来组合这些信息。
作为关卡八叉树的第一步,我们在 struct 文件夹的 MeshTriangle 文件中添加一个名为 OGLRenderData.h 的新 opengl :
- 增强关卡数据的碰撞检测
- 使用重力系统保持实例在地面高度
- 添加逆向运动学
-
增强关卡数据的碰撞检测
为了加速实例与场景几何体之间的碰撞检测,我们将为场景数据创建空间分区(如八叉树)。 不同于将场景三角形添加到实例八叉树中,我们专门为三角形数据单独构建一个八叉树。
添加新型八叉树
为场景数据使用独立的数据结构比尝试在现有八叉树中混合两种数据类型更为合理,原因如下:
- 关卡数据是静态的,而实例位置会频繁变化。 每次实例位置变更时,我们都需要对一个使用率很高的八叉树进行代价高昂的更新,这可能导致在移除和重新添加实例时产生大量额外的分割与合并操作。
- 关卡数据和实例的细分数量可能完全不同,具体取决于关卡的复杂度和实例数量。 当少量实例在细节丰富的关卡中移动时,搜索附近的三角形或实例可能会产生巨大的开销。
- 为简化实现,我们使用八叉树来存储关卡数据,但其他数据结构如 BSP 树或边界体积层次结构(BVH) 更为常见。 由于 BSP 树和 BVH 无法像我们的八叉树那样快速动态更新,因此仍需要将关卡数据与实例分开处理。
struct MeshTriangle {
int index;
std::array<glm::vec3, 3> points;
BoundingBox3D boundingBox;
};
对于 Vulkan,三角形 struct 将被添加到 VkRenderData.h 文件夹中的 vulkan 文件里。
该 index 成员主要用于调试目的,当某些三角形无法加入八叉树时会被添加到日志输出行中。 在 points 数组中,我们保存了三角形三个点的世界坐标位置。 世界坐标用于为三角形创建正确的包围盒,稍后我们也会将世界坐标用于碰撞检测。 而 boundingBox 成员则包含关卡数据网格中每个三角形的 AABB 。
在八叉树中使用包围盒而非实际三角形数据大大简化了查询操作,因为搜索碰撞时无需检查每个三角形的精确轮廓。 使用 AABB 可能导致需要检查更多三角形,但 AABB 检查的成本很低,因为我们最多只需进行六次简单的 float 比较。 由于关卡几何体的大部分不是墙壁就是地面, AABB 的额外尺寸并不重要。
接下来,我们添加名为 TriangleOctree 的新八叉树类。 新的三角形八叉树将实现在 octree 文件夹下的两个新文件 TriangleOctree.h 和 TriangleOctree.cpp 中。
TriangleOctree 类是对普通 Octree 类的复制,但存在几点例外:
- 我们将三角形数据而非实例索引存储在树结构中。
- 由于关卡数据八叉树将保持只读状态,我们不需要更新、 移除对象或合并八分体的方法。
- 在三角形八叉树中,我们仅处理静态数据,对层级间三角形交集的搜索不会返回任何有用信息。 因此,
findAllIntersections()和findIntersectionsInDescendants()方法也可以跳过。
除了添加新八叉树类型章节提到的为层级数据使用独立八叉树的原因外,我们还对无法放入单一八分体的对象采用了不同处理方式。
在实例八叉树中,实例的包围盒极少会大于单个八分体,例如当实例被大幅缩放时。 但在层级八叉树中,许多三角形的包围盒可能无法放入细分后的单个八分体。 关卡创建者会尽量减少关卡中的三角形数量以获得良好渲染性能,这导致关卡某些区域仅由少量大型三角形构成。
我们可以通过以下三种方法之一解决尺寸问题:
- 在八分体中保留一个足够大的三角形,使其能完全包含整个三角形。 这种解决方案会将额外对象存储在父节点中,而不仅限于叶节点。
- 将三角形添加到所有受影响的细分八分体中。 这样数据仅存在于叶节点,但在最坏情况下会将三角形数据重复存储多达 8 次。
- 沿八分体边界分割三角形,仅将子三角形添加到各八分体。 这样每个受影响的八分体都会新增一个三角形,且分割线可能存在舍入误差问题。
为保持代码简洁,我们将采用第一种方法:对于超出细分八分体尺寸的三角形,仅将其添加到父八分体中。
我们可以通过两步检查在 add() 和 split() 方法中实现超大三角形的存储过程。 首先,我们遍历所有子八分体,寻找三角形与子八分体边界的可能相交情况:
int intersectingChildren = 0;
for (int i = 0; i < node->childs.size(); ++i) {
BoundingBox3D childBox = getChildOctant(box, i);
if (childBox.intersects(triangle.boundingBox)) {
intersectingChildren++;
}
}
如果发现与某个子八分体相交,我们就递增 intersectingChildren 变量。 然后对于 add() 方法,我们会检查三角形将与多少个子八分体相交。 当相交的八分体超过一个时,该三角形将保留在当前八分体中:
if (intersectingChildren > 1) {
node->triangles.emplace_back(triangle);
} else {
int i = getOctantId(box, triangle.boundingBox);
if (i != -1) {
add(node->childs.at(i), depth + 1,
getChildOctant(box, i), triangle);
}
}
而如果仅发现与单个子八分体相交,我们会递归地将该三角形传递给子八分体。
对于 split() 方法,我们执行相同操作,当发现与未来子八分体存在多个相交时,就将三角形保留在当前八分体中:
if (intersectingChildren > 1) {
newTriangles.emplace_back(triangle);
} else {
int i = getOctantId(box, triangle.boundingBox);
if (i != -1) {
node->childs.at(i)
->triangles.emplace_back(triangle);
}
}
用于查询三角形八叉树与边界框碰撞的 query() 方法,以及显示八叉树调试线的 getTreeBoxes() 框体,都与原始八叉树保持一致,只需调整 private query() 方法的数据类型。
当 TriangleOctree 准备就绪后,我们可以将关卡数据添加到新八叉树中,并查询该树的碰撞情况。
填充关卡数据八叉树
与实例八叉树类似,我们需要在渲染器头文件中添加 TriangleOctree.h 头文件,然后新增一个名为 mTriangleOctree 的 private 成员变量,以及两个 private 方法 initTriangleOctree() 和 generateLevelOctree() :
std::shared_ptr<TriangleOctree> mTriangleOctree = nullptr;
void initTriangleOctree(int thresholdPerBox, int maxDepth);
void generateLevelOctree();
为了设置阈值和深度的默认值,并能够通过 UI 界面后续控制这些设置,两个名为 rdLevelOctreeThreshold 和 rdLevelOctreeMaxDepth 的新变量被存储在 OGLRenderData struct 中,位于 OGLRenderData.h 文件夹下的 opengl 文件内:
int rdLevelOctreeThreshold = 10;
int rdLevelOctreeMaxDepth = 5;
同样,对于 Vulkan,这两个变量被添加到了 VkRenderData struct 中的 VkRenderData.h 文件里,该文件位于 vulkan 文件夹中。
在渲染器的 init() 方法中, 会调用 initTriangleOctree() 来创建一个具有给定阈值和最大深度的八叉树:
void OGLRenderer::initTriangleOctree(int thresholdPerBox,
int maxDepth) {
mTriangleOctree = std::make_shared<TriangleOctree>(
mWorldBoundaries, thresholdPerBox, maxDepth);
}
世界边界会在关卡 AABB 生成过程中更新,因此我们的三角形八叉树在关卡加载后与关卡数据完全保持相同尺寸。
填充层级数据八叉树的操作在 generateLevelOctree() 方法中完成。 由于外部代码只是对 ModelInstanceCamData 结构体中 micLevels 向量的所有层级进行循环遍历,这里我们仅重点介绍关键部分。
对于 micLevels 中的每个层级,我们获取以优化网格形式存在的层级网格数据用于绘制该层级。 接着,我们遍历层级网格的所有索引:
std::vector<OGLMesh> levelMeshes =
level->getLevelMeshes();
glm::mat4 transformMat =
level->getWorldTransformMatrix();
glm::mat3 normalMat =
level->getNormalTransformMatrix();
for (const auto& mesh : levelMeshes) {
int index = 0;
for (int i = 0; i < mesh.indices.size(); i += 3) {
对于 Vulkan 来说, levelMeshes 向量将包含 VkMesh 数据类型。
由于三角形数据是基于索引存储的,我们必须使用索引来绘制三角形;直接使用顶点将无法提供推导三角形顶点所需的正确信息。 我们还从关卡中获取世界变换矩阵和法线变换矩阵。 法线变换矩阵就是世界变换逆矩阵的转置, getNormalTransformMatrix() 方法已被添加到 AssimpLevel 类中以保持额外的变换。
接下来,我们创建一个空的 MeshTriangle ,并使用关卡的变换矩阵将关卡顶点变换到世界坐标位置:
MeshTriangle tri{};
tri.points.at(0) = transformMat *
glm::vec4(glm::vec3(mesh.vertices.at(
mesh.indices.at(i)).position), 1.0f);
tri.points.at(1) = transformMat *
glm::vec4(glm::vec3(mesh.vertices.at(
mesh.indices.at(i + 1)).position), 1.0f);
tri.points.at(2) = transformMat *
glm::vec4(glm::vec3(mesh.vertices.at(
mesh.indices.at(i + 2)).position), 1.0f);
现在是为每个三角形创建边界的时候了:
AABB triangleAABB;
triangleAABB.clear();
triangleAABB.addPoint(tri.points.at(0));
triangleAABB.addPoint(tri.points.at(1));
triangleAABB.addPoint(tri.points.at(2));
使用 AABB 可以轻松计算边界框坐标。 基于此 AABB ,我们创建 BoundingBox3D 并将结果存储到 MeshTriangle struct 的 boundingBox 成员中:
tri.boundingBox = BoundingBox3D(
triangleAABB.getMinPos() -
glm::vec3(0.0001f),
triangleAABB.getMaxPos() -
triangleAABB.getMinPos() + glm::vec3(0.0002f));
需要添加一个小的偏移量,以保持与关卡中 X 、 Y 或 Z 平面共面的三角形。 如果没有这个偏移量,三角形包围盒在一个或多个维度上的尺寸可能变为零,导致我们无法检测与该三角形的碰撞。
最后,我们存储并递增调试索引号,并将三角形添加到关卡数据的八叉树中:
tri.index = index++;
mTriangleOctree->add(tri);
}
通过调用 generateLevelOctree 来确保每当关卡数据被添加或删除时,我们的八叉树能正确获取所有三角形的世界坐标。 实现关卡变更时更新的最佳方式,是将八叉树更新与已实现的 AABB 关卡数据更新功能相绑定。
为实现这种耦合,我们新增了一个名为 private 的方法 generateLevelVertexData() ,并在其中调用 AABB 和八叉树生成:
void OGLRenderer::generateLevelVertexData() {
generateLevelAABB();
generateLevelOctree();
}
随后,所有出现的 generateLevelAABB() 都会被新的 generateLev


926

被折叠的 条评论
为什么被折叠?



