87、实战:物理引擎(碰撞检测+刚体运动模拟)
说实话,物理引擎这东西,听起来挺唬人的。
什么牛顿力学、欧拉积分、碰撞响应……一堆术语砸过来,容易让人发懵。但我跟你说,剥开那些华丽的外壳,核心就两件事:物体怎么动,以及物体撞了怎么办。
我当年第一次自己写物理模拟,是在一个2D小游戏项目里。当时图省事,用了现成的引擎库。结果后来需要定制一些奇葩的物理行为——比如“弹性可变的橡皮球”——库的接口根本满足不了。没办法,只能自己撸一套。嗯,从那以后,我对物理引擎的理解就彻底不一样了。
今天咱们就手撕一个最简版的2D物理引擎。不依赖任何第三方库,纯C++,从零开始。
核心知识体系
先给你一张图,把整个物理引擎的脉络理清楚。别急着看代码,先看结构。
整个流程就是一个闭环:每帧先检测有没有碰撞,如果有就计算响应,然后更新所有物体的位置和速度。说白了,就是“检测→响应→更新”的无限循环。
第一步:定义刚体数据结构
先得有东西能“动”起来。我习惯用一个结构体把刚体的所有属性打包在一起。
struct RigidBody {
// 位置与速度
float x, y; // 位置
float vx, vy; // 速度
float mass; // 质量
// 形状信息(这里用圆形,最简单)
float radius; // 半径
// 物理属性
float restitution; // 弹性系数(0~1),1为完全弹性
// 受力(每帧清零)
float fx, fy; // 合力
RigidBody(float x_, float y_, float r_, float m_ = 1.0f)
: x(x_), y(y_), vx(0), vy(0), radius(r_), mass(m_),
restitution(0.8f), fx(0), fy(0) {}
};
这里我特意用了圆形。为什么?因为圆形的碰撞检测最简单——只需要判断两个圆心距离是否小于半径之和。你想想看,如果是矩形,还得算旋转、算分离轴,复杂度直接翻倍。
第二步:碰撞检测——圆形 vs 圆形
检测逻辑其实就一行数学公式。但要注意,我见过很多新手直接比较距离,然后开平方。开平方开销大,能省则省。
bool CheckCircleCollision(const RigidBody& a, const RigidBody& b) {
float dx = b.x - a.x;
float dy = b.y - a.y;
float distSq = dx * dx + dy * dy;
float radSum = a.radius + b.radius;
return distSq <= radSum * radSum; // 用平方比较,避免开根号
}
嗯,这里要注意:distSq <= radSum * radSum 这个条件包含了刚好相切的情况。实际游戏中,相切也算碰撞,所以用 <= 没问题。
第三步:碰撞响应——动量守恒 + 弹性系数
检测到碰撞之后,怎么让两个球弹开?
核心公式来自高中物理:动量守恒和恢复系数。但别怕,代码实现其实很直观。
void ResolveCircleCollision(RigidBody& a, RigidBody& b) {
// 1. 计算法线方向(从a指向b的单位向量)
float dx = b.x - a.x;
float dy = b.y - a.y;
float dist = sqrt(dx * dx + dy * dy);
if (dist == 0) return; // 防止除零
float nx = dx / dist;
float ny = dy / dist;
// 2. 计算相对速度沿法线方向的分量
float dvx = a.vx - b.vx;
float dvy = a.vy - b.vy;
float relVel = dvx * nx + dvy * ny;
// 3. 如果相对速度是分离的,不处理
if (relVel > 0) return;
// 4. 计算冲量大小
float e = std::min(a.restitution, b.restitution);
float impulse = -(1 + e) * relVel / (1.0f / a.mass + 1.0f / b.mass);
// 5. 应用冲量
a.vx += impulse / a.mass * nx;
a.vy += impulse / a.mass * ny;
b.vx -= impulse / b.mass * nx;
b.vy -= impulse / b.mass * ny;
// 6. 位置修正:防止物体嵌入
float overlap = (a.radius + b.radius) - dist;
if (overlap > 0) {
float correction = overlap / 2.0f;
a.x -= correction * nx;
a.y -= correction * ny;
b.x += correction * nx;
b.y += correction * ny;
}
}
if (relVel > 0) return; 特别容易漏掉。如果不加这个判断,两个球明明已经分开了,你还在往里加冲量,它们就会“吸”在一起疯狂抖动。嗯,那画面,简直像鬼畜视频。
第四步:运动模拟——半隐式欧拉积分
物体怎么动?说白了就是每帧根据受力更新速度,再根据速度更新位置。
最简单的做法是显式欧拉积分:
// 显式欧拉(不推荐)
v += (F / m) * dt;
x += v * dt;
但这样做有个问题:你用旧速度算新位置,能量会不守恒。模拟时间一长,物体越弹越高,或者越跑越快。我建议用半隐式欧拉:
void UpdateRigidBody(RigidBody& body, float dt) {
// 半隐式欧拉:先用合力更新速度
body.vx += (body.fx / body.mass) * dt;
body.vy += (body.fy / body.mass) * dt;
// 再用新速度更新位置
body.x += body.vx * dt;
body.y += body.vy * dt;
// 每帧清零受力
body.fx = 0;
body.fy = 0;
}
为什么半隐式更好?因为你先算了速度,再用新速度算位置,相当于用了“未来”的速度。这比显式欧拉稳定得多。我在项目中做过对比,同样的步长,半隐式能跑1000帧不炸,显式欧拉200帧就开始飘了。
第五步:整合——物理世界管理器
有了零件,得有个东西把它们组装起来。我习惯写一个 PhysicsWorld 类来管理所有刚体。
class PhysicsWorld {
public:
std::vector<RigidBody> bodies;
float gravity;
PhysicsWorld(float g = 500.0f) : gravity(g) {}
void Step(float dt) {
// 1. 应用全局力(比如重力)
for (auto& body : bodies) {
body.fy += body.mass * gravity; // 重力向下
}
// 2. 碰撞检测 + 响应
for (size_t i = 0; i < bodies.size(); ++i) {
for (size_t j = i + 1; j < bodies.size(); ++j) {
if (CheckCircleCollision(bodies[i], bodies[j])) {
ResolveCircleCollision(bodies[i], bodies[j]);
}
}
}
// 3. 更新所有物体
for (auto& body : bodies) {
UpdateRigidBody(body, dt);
}
}
};
你看,整个引擎的核心代码,加起来不到100行。是不是比想象中简单?
避坑指南与性能优化
代码写完了,但实际跑起来可能还有坑。我列几个常见的:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 物体穿透 | 小球直接穿过另一个小球 | 减小时间步长,或使用连续碰撞检测(CCD) |
| 抖动/爆炸 | 物体疯狂弹跳,位置发散 | 检查 relVel > 0 的分离条件;限制最大速度 |
| 性能瓶颈 | 物体数量一多就卡顿 | 使用空间分区(网格/四叉树),避免 O(n²) 遍历 |
完整示例:两个小球碰撞
最后,给你一个完整的 main 函数,看看实际效果:
#include <iostream>
#include <vector>
#include <cmath>
// 把上面的 RigidBody、CheckCircleCollision、ResolveCircleCollision、
// UpdateRigidBody、PhysicsWorld 全部复制到这里
int main() {
PhysicsWorld world;
// 创建两个小球
world.bodies.emplace_back(200, 300, 30, 2.0f); // 大球,质量2
world.bodies.emplace_back(600, 300, 20, 1.0f); // 小球,质量1
// 给小球一个初速度
world.bodies[1].vx = -200; // 向左飞
float dt = 1.0f / 60.0f; // 60 FPS
// 模拟100帧
for (int frame = 0; frame < 100; ++frame) {
world.Step(dt);
// 打印位置
std::cout << "Frame " << frame
<< ": A(" << world.bodies[0].x << ", " << world.bodies[0].y
<< ") B(" << world.bodies[1].x << ", " << world.bodies[1].y
<< ")\n";
}
return 0;
}
运行这段代码,你会看到两个球相向而行,碰撞后根据质量和弹性系数各自弹开。位置数据会逐帧打印出来。
说实话,第一次看到自己写的物理引擎让两个球“真实”地弹开,那种成就感是很爽的。虽然它还很简陋——没有摩擦力、没有旋转、没有多边形碰撞——但核心骨架已经搭好了。
剩下的,就是往这个骨架上添肉了。
公众号:蓝海资料掘金营,微信deep3321