88、C++项目实战:游戏引擎(物理碰撞)
物理碰撞,说白了就是让游戏里的物体能「真实地」撞在一起。你想想看,如果没有碰撞检测,子弹会穿过墙壁,角色会掉进地板——那还玩什么?
我在做这个项目之前,一直觉得物理引擎是那些数学天才才能碰的东西。直到自己动手撸了一个简易版,才发现核心原理其实没那么玄乎。今天我就带你一步步拆解,从碰撞检测到碰撞响应,把整个流程走通。
物理碰撞的核心问题
游戏引擎里的碰撞,本质上就两个问题:
- 有没有撞上?——碰撞检测
- 撞上之后怎么办?——碰撞响应
嗯,听起来简单。但实际做起来,坑多得很。我曾经在一个项目里,因为碰撞检测的精度没处理好,导致角色卡在墙角里疯狂抖动——那画面,简直像在跳霹雳舞。
碰撞检测:从简单到复杂
我们先从最简单的开始。最常用的碰撞检测方法,就是包围盒(AABB)。说白了,就是用矩形把物体框起来,然后判断两个矩形有没有重叠。
核心思路:两个轴对齐矩形,如果它们在X轴和Y轴上的投影都有重叠,那它们就撞上了。
struct AABB {
float x, y; // 中心坐标
float w, h; // 宽高
bool overlaps(const AABB& other) const {
// 检查X轴是否有重叠
bool xOverlap = abs(x - other.x) < (w/2 + other.w/2);
// 检查Y轴是否有重叠
bool yOverlap = abs(y - other.y) < (h/2 + other.h/2);
return xOverlap && yOverlap;
}
};
这段代码我写过不下十遍。你注意看,这里用的是「中心坐标+半宽高」的表示法。我个人习惯用这种,因为做碰撞响应时计算穿透深度特别方便。
小技巧:如果物体是圆形的,用圆-圆碰撞检测更简单——直接判断两个圆心距离是否小于半径之和。我在做弹幕游戏时就用这个,性能好得很。
碰撞响应:推出去,别卡住
检测到碰撞之后,下一步就是「怎么处理」。最简单的做法是:把物体沿着碰撞法线方向推出去。
为什么会这样?你想想看,两个物体重叠了,说明它们「不该在同一个位置」。我们要做的就是计算一个最小的位移,让它们刚好分开。
struct CollisionResult {
bool collided;
float penetration; // 穿透深度
float normalX, normalY; // 碰撞法线方向
};
CollisionResult resolveAABB(const AABB& a, const AABB& b) {
CollisionResult result = {false, 0, 0, 0};
// 计算各个方向上的重叠量
float overlapX = (a.w/2 + b.w/2) - abs(a.x - b.x);
float overlapY = (a.h/2 + b.h/2) - abs(a.y - b.y);
if (overlapX > 0 && overlapY > 0) {
result.collided = true;
// 选择重叠量最小的方向作为分离方向
if (overlapX < overlapY) {
result.penetration = overlapX;
result.normalX = (a.x < b.x) ? -1.0f : 1.0f;
result.normalY = 0;
} else {
result.penetration = overlapY;
result.normalX = 0;
result.normalY = (a.y < b.y) ? -1.0f : 1.0f;
}
}
return result;
}
这里有个关键点:选择重叠量最小的方向。我曾经踩过这个坑——如果随便选一个方向推,物体会「跳」到奇怪的位置。比如你从侧面撞墙,结果被推到天上去了,那体验就太糟糕了。
注意:碰撞响应之后,一定要更新物体的位置。否则下一帧检测时,它们还是重叠的,就会反复推来推去——这就是我之前说的「抖动」问题。
物理碰撞的完整流程
把上面这些串起来,一个完整的物理碰撞系统大概长这样:
这个流程看起来简单,但实际项目中,你还要考虑性能优化。比如,如果场景里有1000个物体,两两检测就是50万次计算——这谁顶得住?
空间划分:别傻傻地全量检测
我建议你引入网格空间划分。把场景分成一个个格子,每个物体只跟同格子里的物体做碰撞检测。这样能把O(n²)降到接近O(n)。
class SpatialGrid {
int cellSize;
std::unordered_map<int, std::vector<Entity*>> cells;
int getCellKey(float x, float y) const {
int cx = static_cast<int>(x / cellSize);
int cy = static_cast<int>(y / cellSize);
return cx * 10000 + cy; // 简单的哈希
}
void addEntity(Entity* e) {
int key = getCellKey(e->x, e->y);
cells[key].push_back(e);
}
std::vector<Entity*> getNearby(Entity* e) {
int key = getCellKey(e->x, e->y);
return cells[key]; // 只检测同格子的物体
}
};
经验之谈:格子大小怎么选?我一般取物体平均大小的2-3倍。太小了格子太多,太大了又退化成全量检测。这个值需要根据你的场景调优。
碰撞响应的进阶:反弹与摩擦
如果你想让碰撞更「真实」,可以加入弹性系数和摩擦力。比如,一个皮球撞到地面,它会弹起来;一个箱子推过去,它会滑行一段距离。
struct PhysicsMaterial {
float restitution; // 弹性系数,0~1
float friction; // 摩擦系数
};
void applyCollisionResponse(Entity* a, Entity* b,
const CollisionResult& result) {
// 分离物体
a->x += result.normalX * result.penetration * 0.5f;
a->y += result.normalY * result.penetration * 0.5f;
b->x -= result.normalX * result.penetration * 0.5f;
b->y -= result.normalY * result.penetration * 0.5f;
// 计算相对速度
float relVx = a->vx - b->vx;
float relVy = a->vy - b->vy;
// 沿法线方向的速度分量
float normalV = relVx * result.normalX + relVy * result.normalY;
// 如果正在远离,不处理
if (normalV > 0) return;
// 应用弹性系数
float e = min(a->material.restitution, b->material.restitution);
float impulse = -(1 + e) * normalV / 2;
a->vx += impulse * result.normalX;
a->vy += impulse * result.normalY;
b->vx -= impulse * result.normalX;
b->vy -= impulse * result.normalY;
}
这段代码里,restitution 是关键。值为1时是完美弹性碰撞(像台球),值为0时是完全非弹性碰撞(像两团泥巴粘在一起)。我在做物理小游戏时,一般把地面设为0.3,角色设为0.1——这样既不会弹得太夸张,也不会完全粘住。
常见坑点与避坑指南
| 坑点 | 现象 | 解决方案 |
|---|---|---|
| 物体穿透 | 高速物体直接穿过薄墙 | 使用连续碰撞检测(CCD),或限制最大速度 |
| 抖动 | 物体卡在墙角疯狂震动 | 加入「休眠」机制,速度很小时直接置零 |
| 堆叠不稳定 | 物体堆叠时慢慢滑落 | 使用迭代求解,多次处理碰撞 |
| 性能爆炸 | 物体一多就卡顿 | 空间划分 + 多线程并行检测 |
我曾经踩过的坑:有一次做平台跳跃游戏,角色从高处落下时,因为速度太快,直接穿过了地面。查了半天才发现,是碰撞检测只做了「当前帧的位置」,没有考虑「两帧之间的运动轨迹」。后来我改用连续碰撞检测,把运动路径分段检测,才彻底解决。
总结一下
物理碰撞系统,说白了就是三件事:
- 检测——用AABB或圆-圆判断是否重叠
- 响应——沿法线方向推开,并调整速度
- 优化——用空间划分避免全量检测
嗯,其实核心代码加起来也就两三百行。但要把这些代码打磨得稳定、高效、不出bug,那才是真正考验功底的地方。我建议你从最简单的矩形碰撞开始,跑通了再慢慢加弹性、摩擦、空间划分这些特性。一口吃不成胖子,物理引擎也是一样。
公众号:蓝海资料掘金营,微信deep3321