87、实战:物理引擎(碰撞检测+刚体运动模拟)

说实话,物理引擎这东西,听起来挺唬人的。

什么牛顿力学、欧拉积分、碰撞响应……一堆术语砸过来,容易让人发懵。但我跟你说,剥开那些华丽的外壳,核心就两件事:物体怎么动,以及物体撞了怎么办

我当年第一次自己写物理模拟,是在一个2D小游戏项目里。当时图省事,用了现成的引擎库。结果后来需要定制一些奇葩的物理行为——比如“弹性可变的橡皮球”——库的接口根本满足不了。没办法,只能自己撸一套。嗯,从那以后,我对物理引擎的理解就彻底不一样了。

今天咱们就手撕一个最简版的2D物理引擎。不依赖任何第三方库,纯C++,从零开始。

核心知识体系

先给你一张图,把整个物理引擎的脉络理清楚。别急着看代码,先看结构。

2D物理引擎核心架构 输入:力 / 速度 / 位置 刚体运动模拟 位置更新 · 速度更新 · 欧拉积分 碰撞检测 AABB · 圆 · 分离轴 欧拉积分器 约束处理 包围盒测试 碰撞响应 输出:新位置 / 新速度 每帧循环:检测 → 响应 → 更新

整个流程就是一个闭环:每帧先检测有没有碰撞,如果有就计算响应,然后更新所有物体的位置和速度。说白了,就是“检测→响应→更新”的无限循环。

第一步:定义刚体数据结构

先得有东西能“动”起来。我习惯用一个结构体把刚体的所有属性打包在一起。

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;
    }
}
我曾经踩过的坑:第3步的 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²) 遍历
我的优化经验:当物体数量超过50个时,O(n²) 的暴力检测就开始吃力了。我一般会引入一个简单的网格分区:把屏幕分成 10x10 的格子,每个格子只检测同格子或相邻格子里的物体。这样复杂度能降到 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