C++游戏开发:实时碰撞检测优化与均匀网格实现
1. 项目概述:为什么实时碰撞检测是游戏性能的“命门”?
做游戏开发,尤其是动作、射击、赛车这类对响应速度要求极高的实时游戏,性能优化是个永恒的话题。而在所有性能开销里,碰撞检测往往是那个最容易被忽视,却又最可能突然给你“致命一击”的模块。我见过太多项目,画面渲染做得美轮美奂,物理特效也相当逼真,但一到复杂场景,帧率就断崖式下跌,一查Profile,80%的CPU时间都耗在了那看似简单的“两个物体有没有碰到一起”的判断上。这就像一辆跑车,引擎再强,如果刹车和转向系统反应迟钝,也根本跑不起来。
这个项目要解决的,就是如何在C++环境下,为实时游戏构建一套既精确又高效的碰撞检测系统。它不仅仅是调用某个物理引擎API那么简单,而是要从底层数据结构、算法选择、到与游戏循环的深度集成,进行全方位的设计和优化。目标很明确:在每帧16.6毫秒(对应60FPS)甚至更短的预算内,稳定处理成百上千个动态物体的碰撞查询,确保游戏体验丝滑流畅。无论你是正在开发自己的独立游戏,还是希望深入理解大型游戏引擎(如Unreal、Unity底层)的物理模块工作原理,这套优化方案都能给你提供扎实的、可直接落地的思路和代码实践。
2. 核心思路与架构设计:从“蛮力计算”到“空间分割”
在深入代码之前,我们必须先理清思路。最原始的碰撞检测就是“蛮力法”(Brute-Force):每一帧,遍历场景中所有物体,让每个物体都与其他所有物体进行一次碰撞检测。如果有N个物体,复杂度就是O(N²)。当N=1000时,就需要进行近50万次检测,这显然是无法接受的。因此,优化的核心思路就变成了:如何快速排除那些明显不可能发生碰撞的物体对,只对“潜在可能碰撞”的物体进行精确检测。
2.1 分层检测策略:Broad Phase与Narrow Phase
这是业界标准的优化架构,将碰撞检测分为两个阶段:
- Broad Phase(宽阶段): 也称为“粗略检测”。它的任务不是判断是否碰撞,而是快速找出所有可能发生碰撞的物体对(Pair)。这一阶段追求极致的速度,允许一定的误报(即把不会碰撞的物体对也报上来),但绝不能漏报。常用的技术就是空间分割(Spatial Partitioning)。
- Narrow Phase(窄阶段): 在Broad Phase生成的潜在碰撞对列表基础上,进行精确的几何相交测试。这一阶段追求准确性,使用如分离轴定理(SAT)、吉尔伯特-约翰逊-基尔蒂(GJK)等算法,判断两个物体的形状(包围盒、凸包、网格)是否真正相交。
我们的优化重心,90%都在Broad Phase。一个高效的Broad Phase算法,能将需要进入Narrow Phase的物体对数量降低1到2个数量级。
2.2 Broad Phase核心算法选型:四叉树、网格与Sweep and Prune
市面上主流的Broad Phase算法有好几种,选择哪一种取决于你的游戏类型和物体分布。
1. 均匀网格(Uniform Grid)这是最简单、最快,也最容易被低估的方法。将游戏世界划分为固定大小的单元格(比如每格128x128像素)。每个物体根据其位置,被放入一个或多个(如果物体比格子大)单元格中。检测时,只需检查每个物体所在格子及相邻8个格子内的其他物体即可。
- 优点: 实现简单,查询速度极快(O(1)的格子访问),内存访问模式连续,对CPU缓存友好。
- 缺点: 不适合物体大小差异极大的场景(大物体会占据太多格子)。如果物体分布极度不均匀(比如全部挤在角落),会产生大量空格子,浪费内存。
- 适用场景: 2D射击游戏(如弹幕游戏)、粒子系统、物体大小相对均匀的俯视角游戏。
2. 四叉树/八叉树(Quadtree/Octree)这是最经典的空间分割数据结构。四叉树用于2D,八叉树用于3D。它递归地将空间划分为四个(或八个)子区域,直到每个区域内的物体数量低于某个阈值。树的结构自适应于物体的分布。
- 优点: 能很好地处理物体分布不均匀的情况,动态增删物体的效率尚可。
- 缺点: 实现比网格复杂。动态物体频繁移动时,需要从树中移除再重新插入,可能引起树的频繁重构(Rebalancing),带来性能波动。查询时需要遍历树节点,常数开销比网格大。
- 适用场景: 大型开放世界游戏的地形静态物体管理、RTS游戏中单位的管理。
3. 动态AABB树(Dynamic Bounding Volume Hierarchy - DBVH)这是很多现代物理引擎(如Box2D, Bullet)的选择。它本质上是一棵二叉树,每个节点存储一个能包围其所有子节点物体的AABB(轴向包围盒)。它的插入、删除和更新操作都经过高度优化,能很好地支持大量动态物体。
- 优点: 对完全动态的场景支持最好,更新效率高,查询效率稳定。
- 缺点: 实现最为复杂。树的结构为了保持平衡(如使用表面积启发式SAH),构建和维护开销较大。
- 适用场景: 物理引擎核心、包含大量自由运动刚体的游戏。
4. Sweep and Prune(SAP)这个算法非常巧妙。它分别对物体在X、Y、Z轴上的AABB投影区间进行排序。只有当两个物体在三个轴上的投影区间都重叠时,它们才可能碰撞。通过维护排序列表,可以在物体移动时增量式地更新,高效地找出重叠对。
- 优点: 对于物体主要沿一个方向运动(如横向卷轴游戏)的场景非常高效。增量更新速度快。
- 缺点: 在物体运动剧烈、方向随机的3D场景中,排序列表的维护开销会变大。
- 适用场景: 2D平台游戏、横向卷轴游戏。
我的经验选择: 对于大多数中小型实时游戏项目,我首推均匀网格。它的性能可预测性最强,实现简单,在CPU缓存层面的优势巨大。除非你的游戏世界巨大且物体分布极度稀疏,否则网格的简单性带来的稳定帧率,远比四叉树那一点理论上的内存节省更有价值。我们可以先实现网格,用Profile工具验证瓶颈,如果网格真的成为瓶颈,再考虑升级到更复杂的结构也不迟。
3. C++高性能实现:均匀网格实战
理论说再多,不如一行代码。我们以2D均匀网格为例,用现代C++(C++17/20)来实现一个生产可用的Broad Phase系统。
3.1 基础数据结构定义
首先,定义我们的物体和网格单元。
// Collidable.h #pragma once #include <cstdint> #include <vector> // 一个简单的轴向包围盒 struct AABB { float minX, minY; float maxX, maxY; bool intersects(const AABB& other) const { return !(maxX < other.minX || minX > other.maxX || maxY < other.minY || minY > other.maxY); } }; // 游戏中的可碰撞物体 struct Collidable { uint32_t id; // 唯一标识符 AABB worldAABB; // 世界坐标系下的包围盒 // ... 其他物理或渲染数据 // 标记它当前占据了哪些网格单元格(用于快速从网格中移除) std::vector<std::pair<int, int>> occupiedCells; };接下来是网格本身。我们使用一个二维数组(std::vector的std::vector)来表示网格,每个单元格存储一个物体ID的列表。
// SpatialGrid.h #pragma once #include "Collidable.h" #include <unordered_map> #include <vector> class SpatialGrid { public: SpatialGrid(float worldWidth, float worldHeight, float cellSize); void insert(Collidable& obj); void update(Collidable& obj); // 物体移动后调用 void remove(uint32_t objId); // 获取可能与给定物体发生碰撞的所有其他物体ID std::vector<uint32_t> getPotentialCollisions(const Collidable& obj); // 获取所有潜在的碰撞对(用于每帧全局检测) std::vector<std::pair<uint32_t, uint32_t>> getAllPotentialPairs(); private: float m_cellSize; int m_gridWidth, m_gridHeight; float m_invCellSize; // 用于快速将世界坐标转换为网格坐标 // 核心数据结构:网格。每个单元格是一个动态数组。 std::vector<std::vector<std::vector<uint32_t>>> m_grid; // 快速查询物体所在的单元格 std::unordered_map<uint32_t, std::vector<std::pair<int, int>>> m_objCellMap; // 将世界坐标转换为网格坐标 inline std::pair<int, int> worldToCell(float x, float y) const { // 使用floor确保坐标在格子内,注意处理负坐标 int cellX = static_cast<int>((x) * m_invCellSize); int cellY = static_cast<int>((y) * m_invCellSize); // 夹紧到网格范围内 cellX = std::clamp(cellX, 0, m_gridWidth - 1); cellY = std::clamp(cellY, 0, m_gridHeight - 1); return {cellX, cellY}; } // 计算一个AABB覆盖了哪些网格单元格 std::vector<std::pair<int, int>> computeOccupiedCells(const AABB& aabb) const; };3.2 关键操作实现:插入、更新与查询
插入操作的实现: 当一个新物体加入世界时,我们需要计算它的AABB覆盖了哪些格子,并将其ID加入到这些格子的列表中。
// SpatialGrid.cpp (部分) void SpatialGrid::insert(Collidable& obj) { auto cells = computeOccupiedCells(obj.worldAABB); obj.occupiedCells = cells; // 让物体自己记住它在哪 m_objCellMap[obj.id] = cells; for (const auto& [cellX, cellY] : cells) { m_grid[cellY][cellX].push_back(obj.id); } } std::vector<std::pair<int, int>> SpatialGrid::computeOccupiedCells(const AABB& aabb) const { std::vector<std::pair<int, int>> cells; auto [minCellX, minCellY] = worldToCell(aabb.minX, aabb.minY); auto [maxCellX, maxCellY] = worldToCell(aabb.maxX, aabb.maxY); // 一个物体可能覆盖多个格子 for (int y = minCellY; y <= maxCellY; ++y) { for (int x = minCellX; x <= maxCellX; ++x) { cells.emplace_back(x, y); } } return cells; }更新操作的实现: 这是性能关键。物体移动后,其AABB改变,它在网格中的位置也可能改变。最笨的方法是先remove再insert,但这样会做两次完整的格子计算和列表操作。我们可以优化:比较新旧格子集合,只更新发生变化的部分。
void SpatialGrid::update(Collidable& obj) { auto& oldCells = m_objCellMap[obj.id]; auto newCells = computeOccupiedCells(obj.worldAABB); // 找出需要移除的格子和需要添加的格子 std::vector<std::pair<int, int>> cellsToRemove, cellsToAdd; // 简单实现:使用排序后比较(实际项目可用unordered_set提升性能) std::sort(oldCells.begin(), oldCells.end()); std::sort(newCells.begin(), newCells.end()); std::set_difference(oldCells.begin(), oldCells.end(), newCells.begin(), newCells.end(), std::back_inserter(cellsToRemove)); std::set_difference(newCells.begin(), newCells.end(), oldCells.begin(), oldCells.end(), std::back_inserter(cellsToAdd)); // 执行更新 for (const auto& cell : cellsToRemove) { auto& cellList = m_grid[cell.second][cell.first]; cellList.erase(std::remove(cellList.begin(), cellList.end(), obj.id), cellList.end()); } for (const auto& cell : cellsToAdd) { m_grid[cell.second][cell.first].push_back(obj.id); } // 更新记录 obj.occupiedCells = newCells; m_objCellMap[obj.id] = std::move(newCells); }查询操作的实现: 给定一个物体,找出所有可能和它碰撞的其他物体。
std::vector<uint32_t> SpatialGrid::getPotentialCollisions(const Collidable& obj) { std::vector<uint32_t> potentials; std::unordered_set<uint32_t> uniquePotentials; // 用于去重,因为一个物体可能出现在多个相邻格子 for (const auto& [cellX, cellY] : obj.occupiedCells) { // 检查该格子及周围的8个格子(即3x3区域) for (int dy = -1; dy <= 1; ++dy) { for (int dx = -1; dx <= 1; ++dx) { int x = cellX + dx; int y = cellY + dy; if (x >= 0 && x < m_gridWidth && y >= 0 && y < m_gridHeight) { for (uint32_t otherId : m_grid[y][x]) { if (otherId != obj.id) { // 排除自己 uniquePotentials.insert(otherId); } } } } } } potentials.assign(uniquePotentials.begin(), uniquePotentials.end()); return potentials; }获取所有碰撞对: 这是每帧Broad Phase的入口函数。我们需要遍历所有格子,收集格子内部所有物体两两组成的对,并去重。
std::vector<std::pair<uint32_t, uint32_t>> SpatialGrid::getAllPotentialPairs() { std::vector<std::pair<uint32_t, uint32_t>> pairs; // 使用一个哈希集合来去重,键为 (minId, maxId) std::unordered_set<uint64_t> pairHashSet; for (int y = 0; y < m_gridHeight; ++y) { for (int x = 0; x < m_gridWidth; ++x) { const auto& cellList = m_grid[y][x]; size_t count = cellList.size(); // 只检查格子内部的物体对。相邻格子的物体对会在遍历到对方格子时被捕获。 // 为了避免重复,我们约定只生成 id1 < id2 的对。 for (size_t i = 0; i < count; ++i) { for (size_t j = i + 1; j < count; ++j) { uint32_t id1 = cellList[i]; uint32_t id2 = cellList[j]; if (id1 > id2) std::swap(id1, id2); // 确保 id1 < id2 uint64_t hash = (static_cast<uint64_t>(id1) << 32) | id2; if (pairHashSet.insert(hash).second) { pairs.emplace_back(id1, id2); } } } } } return pairs; }3.3 性能优化技巧与陷阱
格子大小(Cell Size)的选择: 这是均匀网格最重要的参数。格子太小,物体会占据太多格子,更新和查询开销大;格子太大,每个格子内物体过多,失去了空间分割的意义。一个经验法则是:让格子尺寸略大于场景中典型物体的平均尺寸。例如,如果你的游戏角色大小约为50x50像素,那么格子尺寸设为64或128是比较合适的。可以通过性能分析工具(如Visual Studio Profiler, Tracy)来微调这个值。
内存布局与缓存友好性: 注意
m_grid的数据类型是std::vector<std::vector<std::vector<uint32_t>>>。这是一个“向量中的向量中的向量”,可能导致内存碎片化。更优的做法是使用一个一维的大数组来模拟二维网格,并通过索引计算来访问。或者,如果格子内物体数量不多(比如平均少于10个),可以考虑使用std::array或静态大小的数组。核心思想是让内存访问尽量连续,提高CPU缓存命中率。对象池(Object Pooling):
Collidable对象和其内部的occupiedCells向量应该使用对象池进行管理,避免频繁的内存分配和释放。特别是在移动端或主机平台,内存分配开销巨大。并行化:
getAllPotentialPairs函数中,对不同格子的遍历是相互独立的,非常适合并行化。可以使用C++17的std::for_each配合std::execution::par,或者使用任务系统(如EnkiTS, Intel TBB)来并行处理不同的网格行或区域。增量式更新与脏标记: 不是所有物体每帧都在移动。可以为
Collidable添加一个bool isDirty标志。只有位置或大小发生变化的物体才需要调用update。这可以大幅减少不必要的计算。
4. Narrow Phase精讲:分离轴定理(SAT)的实现与优化
Broad Phase为我们筛选出了潜在的碰撞对。接下来,Narrow Phase需要给出确切的答案:它们到底碰上了没有?对于2D的凸多边形(这是游戏中最常见的碰撞形状),分离轴定理(Separating Axis Theorem, SAT)是一个完美选择:它精确、高效,并且能提供碰撞法向量和穿透深度,这些信息对于碰撞响应(反弹、滑动)至关重要。
SAT的原理很简单:如果能找到一条直线(轴),使得两个多边形在该直线上的投影不重叠,那么这两个多边形就一定没有碰撞。我们需要检查的轴,就是每个多边形的每条边的法线。
4.1 SAT基础实现
// SATCollision.h #pragma once #include <vector> #include <cmath> struct Vector2 { float x, y; Vector2 operator-(const Vector2& other) const { return {x - other.x, y - other.y}; } float dot(const Vector2& other) const { return x * other.x + y * other.y; } Vector2 perpendicular() const { return {-y, x}; } // 获取法向量(垂直) Vector2 normalize() const { float len = std::sqrt(x*x + y*y); return {x/len, y/len}; } }; struct Projection { float min, max; bool overlaps(const Projection& other) const { return !(max < other.min || min > other.max); } float getOverlap(const Projection& other) const { return std::min(max, other.max) - std::max(min, other.min); } }; class ConvexPolygon { public: std::vector<Vector2> vertices; // 顶点,假定按顺时针或逆时针顺序排列 // 将多边形投影到一条轴上 Projection project(const Vector2& axis) const { float min = axis.dot(vertices[0]); float max = min; for (size_t i = 1; i < vertices.size(); ++i) { float proj = axis.dot(vertices[i]); if (proj < min) min = proj; if (proj > max) max = proj; } return {min, max}; } }; struct CollisionResult { bool isColliding = false; Vector2 normal; // 碰撞法向量,从A指向B float depth = 0.0f; // 穿透深度 }; CollisionResult checkSAT(const ConvexPolygon& polyA, const ConvexPolygon& polyB) { CollisionResult result; float minOverlap = std::numeric_limits<float>::max(); Vector2 smallestAxis; // 检查多边形A的边 for (size_t i = 0; i < polyA.vertices.size(); ++i) { Vector2 v1 = polyA.vertices[i]; Vector2 v2 = polyA.vertices[(i + 1) % polyA.vertices.size()]; Vector2 edge = v2 - v1; Vector2 axis = edge.perpendicular().normalize(); // 边的法线作为轴 Projection projA = polyA.project(axis); Projection projB = polyB.project(axis); if (!projA.overlaps(projB)) { result.isColliding = false; return result; // 发现分离轴,立即返回 } float overlap = projA.getOverlap(projB); if (overlap < minOverlap) { minOverlap = overlap; smallestAxis = axis; } } // 检查多边形B的边 for (size_t i = 0; i < polyB.vertices.size(); ++i) { Vector2 v1 = polyB.vertices[i]; Vector2 v2 = polyB.vertices[(i + 1) % polyB.vertices.size()]; Vector2 edge = v2 - v1; Vector2 axis = edge.perpendicular().normalize(); Projection projA = polyA.project(axis); Projection projB = polyB.project(axis); if (!projA.overlaps(projB)) { result.isColliding = false; return result; } float overlap = projA.getOverlap(projB); if (overlap < minOverlap) { minOverlap = overlap; smallestAxis = axis; } } // 所有轴都重叠,发生碰撞 result.isColliding = true; result.depth = minOverlap; // 确保法向量方向是从A指向B Vector2 centerA = polyA.getCenter(); // 需要实现求中心点函数 Vector2 centerB = polyB.getCenter(); Vector2 direction = centerB - centerA; if (direction.dot(smallestAxis) < 0) { smallestAxis = {-smallestAxis.x, -smallestAxis.y}; } result.normal = smallestAxis; return result; }4.2 SAT的优化策略
基础的SAT已经能用,但在高性能场景下还有优化空间:
缓存分离轴(Caching Separating Axes): 对于刚体,其形状的分离轴(即每条边的法线)是固定的,不会随旋转和平移而改变方向(只改变位置)。我们可以预先计算并存储这些轴,在碰撞检测时直接使用,避免每帧都进行
perpendicular()和normalize()计算。注意,当物体旋转时,需要将预存的轴用旋转矩阵进行变换。支持圆与多边形、圆与圆的碰撞: SAT主要针对凸多边形。对于圆形,碰撞检测更简单。圆与圆的碰撞只需比较圆心距离和半径之和。圆与多边形的碰撞,可以看作是在多边形的SAT检测基础上,增加一条从多边形最近点到圆心的轴作为分离轴进行检查。
使用GJK算法进行更复杂的形状检测: 对于3D凸体,或者形状特别复杂(顶点数很多)的2D多边形,SAT需要检查的轴数量是两者边数之和,可能效率不高。吉尔伯特-约翰逊-基尔蒂(GJK)算法通过迭代寻找两个凸形状的闵可夫斯基差(Minkowski Difference)的原点包含性,通常能在更少的迭代内得出是否碰撞的结论,并且可以很容易地扩展出穿透向量(EPA算法)。对于复杂的3D碰撞,GJK+EPA是工业标准。
5. 系统集成与性能剖析
有了高效的Broad Phase(均匀网格)和精确的Narrow Phase(SAT),我们需要将它们整合到游戏主循环中,并学会用工具找出性能瓶颈。
5.1 游戏循环中的集成
一个典型的、集成了碰撞检测的游戏物理循环如下:
void Game::update(float deltaTime) { // 1. 应用玩家输入、AI决策,更新物体位置和速度(预测位置) for (auto& obj : m_gameObjects) { obj.integrate(deltaTime); // 例如:pos += velocity * deltaTime; obj.updateAABB(); // 根据新的位置和旋转更新世界AABB } // 2. Broad Phase: 更新空间网格,并获取所有潜在碰撞对 m_spatialGrid.updateAllDynamicObjects(m_dynamicObjects); // 增量更新脏物体 auto potentialPairs = m_spatialGrid.getAllPotentialPairs(); // 3. Narrow Phase: 精确检测 std::vector<CollisionManifold> collisions; // 碰撞信息集合 for (const auto& [idA, idB] : potentialPairs) { auto& objA = getObject(idA); auto& objB = getObject(idB); // 先进行快速的AABB重叠测试(二次筛选,可选但推荐) if (!objA.worldAABB.intersects(objB.worldAABB)) { continue; } // 精确的几何检测(SAT/GJK) auto result = checkCollision(objA.collisionShape, objB.collisionShape); if (result.isColliding) { collisions.push_back({idA, idB, result.normal, result.depth}); } } // 4. 碰撞解析(Resolution):解决穿透,计算冲量 // 可能需要迭代多次,特别是多个物体堆叠时 for (int i = 0; i < m_solverIterations; ++i) { for (const auto& manifold : collisions) { resolveCollision(manifold); } } // 5. 积分最终位置(如果使用半隐式欧拉等需要修正位置的方法) // ... }5.2 性能剖析与常见瓶颈
即使采用了优化方案,碰撞检测仍可能成为瓶颈。你需要熟练使用性能分析工具。
- 工具: 在Windows上,我强烈推荐Visual Studio的性能探查器(Performance Profiler)和Tracy。在Linux/macOS上,Valgrind的Callgrind和perf是利器。
- 看什么:
- 热点函数(Hot Path): 找到CPU耗时最长的函数。是
getAllPotentialPairs?还是checkSAT?或者是update中的格子计算? - 缓存命中率(Cache Miss): 如果
L1/L2 Cache Miss很高,说明你的数据内存访问模式不连续。考虑将std::vector<std::vector<...>>改为单一内存块,或者使用SoA(Struct of Arrays)而不是AoS(Array of Structs)来存储物体数据。 - 内存分配(Memory Allocation): 在Profiler中查看
new/delete或malloc/free的调用。每帧在getPotentialCollisions中创建大量的临时std::vector和std::unordered_set会导致严重的分配器开销。使用内存池或每帧复用的临时缓冲区。
- 热点函数(Hot Path): 找到CPU耗时最长的函数。是
- 常见性能陷阱与解决方案:
- 陷阱1: 每帧都在Broad Phase中清空和重建整个网格。这是新手常犯的错误,完全丧失了空间分割的意义。必须实现增量更新。
- 陷阱2: Narrow Phase中进行了不必要的昂贵检测。比如,两个都是子弹(很小很快)的物体,可能用简单的圆形或AABB检测就足够了,你却用了复杂的多边形SAT。应该根据物体的“碰撞层”或形状类型,选择不同复杂度的检测函数。
- 陷阱3: 忽略了静态几何。场景中的墙壁、地板等静态物体永远不会移动。应该将它们单独放在一个静态碰撞网格中,这个网格只需要构建一次。动态物体检测时,同时查询动态网格和静态网格即可。这能极大减少动态网格的更新开销。
6. 进阶话题与扩展方向
当你的基础系统稳定运行后,可以考虑以下进阶优化,以应对更复杂的场景。
6.1 多线程与任务并行
现代CPU都是多核心的。碰撞检测是“令人尴尬的并行(Embarrassingly Parallel)”问题——不同的碰撞对之间通常没有数据依赖。
- 方案: 将Broad Phase生成的潜在碰撞对列表分块,交给多个工作线程并行执行Narrow Phase检测。可以使用线程池(如
std::async配合线程池库)来管理。注意确保每个碰撞对只被一个线程处理,并且碰撞结果的收集需要线程安全(如使用锁或原子操作,但最好每个线程输出到独立的列表,最后合并)。
6.2 连续碰撞检测(CCD)
对于高速运动的物体(如子弹),可能会在单帧内穿越另一个薄物体,导致“隧道效应(Tunneling)”。CCD通过计算物体在本帧时间区间内的运动轨迹(通常是线性或抛物线),与目标物体进行时间上的碰撞检测,找到最早的碰撞时间点(TOI)。
- 实现思路: 将运动物体视为一个“扫掠体”(Swept Volume),例如将一个运动中的球体看作一个“胶囊体”。在Broad Phase阶段,物体的AABB应该根据其速度进行扩展(变成“膨胀AABB”)。在Narrow Phase,使用专门的扫掠形状相交算法,或者将运动离散化成多个子步进行检测(性能开销大)。
6.3 与渲染的交互:调试绘制
一个可视化的碰撞调试系统至关重要。你应该能实时看到每个物体的AABB、网格的格子划分、Broad Phase找出的碰撞对、以及Narrow Phase计算出的碰撞法线和穿透深度。
- 实现: 在渲染循环中,添加一个调试绘制层。用线条画出所有AABB和网格线。用不同颜色(如红色)高亮显示正在发生碰撞的物体对。用箭头画出碰撞法线。这不仅能帮你调试逻辑错误,还能直观地理解碰撞检测系统的运行状态。
6.4 从零到一与使用引擎的权衡
我们花了大篇幅从零实现,这有助于深刻理解原理。但在实际商业项目中,除非有极特殊的定制需求(如对特定主机平台进行极限优化),否则直接使用成熟的物理引擎是更明智的选择。
- Box2D(2D): 轻量、高效、源码可读性强,是2D游戏的不二之选。它的Broad Phase使用动态AABB树,Narrow Phase非常成熟。
- Bullet(3D): 开源、功能强大,被许多3D游戏使用。同样使用DBVH等高级数据结构。
- PhysX(NVIDIA): 行业标准,被大量3A游戏和Unity引擎使用。对GPU加速支持好,功能最全,但相对庞大。
使用这些引擎,你只需关注定义碰撞形状、设置物理属性(质量、摩擦力等)和接收碰撞回调。它们内部的优化已经做到了极致。我们的学习价值在于,当引擎成为瓶颈或行为不符合预期时,你知道问题可能出在哪里,甚至有能力去修改或扩展它。
最后,性能优化没有银弹。最好的方案永远是测量、分析、优化、再测量。用真实游戏场景的数据来驱动你的决策,而不是凭空猜测。这套从Broad Phase到Narrow Phase的优化方案,为你提供了一个坚实且可扩展的起点,足以应对绝大多数实时游戏对碰撞检测的性能挑战。
