从零构建轻量级C++物理引擎:2D刚体碰撞与性能优化实战

📅 2026/8/2 8:57:16 👁️ 阅读次数 📝 编程学习
从零构建轻量级C++物理引擎:2D刚体碰撞与性能优化实战

1. 项目概述:为什么我们需要一个轻量级的C++物理引擎?

在游戏开发这个行当里,物理引擎的地位,有点像是汽车里的发动机。你当然可以买一辆现成的、性能卓越的整车,但对于那些想造一辆独一无二赛车,或者仅仅是想搞清楚引擎盖下每一个零件如何协同工作的工程师来说,自己动手“攒”一台发动机,是必经之路。市面上的商业物理引擎,比如PhysX、Havok,功能强大、稳定可靠,就像一台V8双涡轮增压引擎,动力澎湃。但问题也随之而来:它们体积庞大、授权复杂、定制困难,对于独立开发者、教育项目、特定平台(如移动端、WebAssembly)或者某些对性能有极致要求的核心玩法来说,这套“重武器”有时显得过于笨重。

这就是“轻量级游戏物理引擎”存在的意义。它不是一个功能阉割版,而是一个设计哲学上的取舍。我们的目标不是复现所有物理现象,而是用最精简的代码,实现游戏最需要的那部分物理交互——通常是刚体动力学。这就像为你的卡丁车定制一台高转速的小排量引擎,轻巧、响应快、完全可控。选择C++来实现,几乎是必然的。C++提供了对内存和计算资源的直接控制能力,零成本抽象的理念让我们能在高级逻辑和底层性能之间找到完美的平衡点,这对于实时模拟每秒60帧甚至更高频率的物理世界至关重要。

这个项目,就是一次从零开始的“造引擎”之旅。我们将聚焦于2D刚体物理,因为这是绝大多数游戏物理交互的基石。通过设计并实现一个轻量级的物理引擎,你不仅能深入理解碰撞检测、积分求解这些核心算法,更能掌握一套构建高性能、模块化C++系统的工程方法。无论你是想为你的独立游戏注入灵魂,还是希望深入游戏开发底层原理,这次实战都将是一次宝贵的经历。

2. 引擎核心架构与设计思路拆解

一个物理引擎,无论轻重,其核心架构都可以抽象为几个紧密协作的模块。我们的轻量级设计将遵循“高内聚、低耦合”的原则,确保每个模块职责清晰,便于理解、调试和扩展。

2.1 模块化架构设计

我们的引擎主要由四大核心模块构成:

  1. 数学库(Math Library):这是引擎的“语言”。所有物理计算都建立在向量、矩阵、几何形状等数学概念之上。一个高效、准确的数学库是基石。我们将实现Vec2(二维向量)、Mat22(2x2矩阵,用于旋转)、Transform(变换,包含位置和旋转)以及基本的几何体如AABB(轴对齐包围盒)、Circle(圆形)、Polygon(多边形)。

  2. 刚体组件(RigidBody):这是物理世界的“演员”。它封装了一个物体的物理属性,包括质量(mass)、转动惯量(inertia)、位置(position)、旋转(rotation)、速度(velocity)、角速度(angular velocity)等。它不关心物体的具体形状,形状信息由碰撞体持有。

  3. 碰撞体与碰撞检测(Collider & Detection):这是引擎的“感官系统”。Collider组件描述物体的几何形状(如圆形、矩形、凸多边形),并关联到一个RigidBody。碰撞检测模块负责快速判断两个碰撞体是否相交,它通常分为两个阶段:Broad Phase(粗略检测)Narrow Phase(精确检测)。Broad Phase 使用空间分割算法(如动态AABB树)快速筛选出可能发生碰撞的物体对,极大提升效率。Narrow Phase 则对筛选后的物体对进行精确的几何相交测试。

  4. 解析与响应(Resolution & Response):这是引擎的“决策系统”。当检测到碰撞后,此模块负责计算碰撞点、法向量、穿透深度,并据此计算出冲量(impulse),应用在发生碰撞的刚体上,改变它们的速度和角速度,从而模拟出碰撞后的反弹、滑动等效果。同时,它也要处理持续的接触,实现稳定的堆叠和静摩擦力。

2.2 为什么选择分离式架构?

将碰撞体(Collider)从刚体(RigidBody)中分离,是一个关键的设计决策。这种设计带来了巨大的灵活性:

  • 一个刚体可以拥有多个碰撞体:你可以轻松组合简单形状来构建复杂角色(比如一个人物由多个圆形和矩形组成)。
  • 碰撞体可以作为传感器(Sensor):只检测碰撞而不产生物理响应,常用于触发器区域。
  • 职责清晰RigidBody只管理运动状态,Collider只管理几何形状和碰撞查询。这使得代码更易于维护和测试。

2.3 时间步进与积分器选择

物理世界是连续的,但计算机是离散的。我们需要通过“积分”来模拟时间的流逝。对于轻量级引擎,半隐式欧拉法(Semi-Implicit Euler, 或称Symplectic Euler)是一个在简单性、速度和稳定性之间取得很好平衡的选择。 它的更新顺序通常是:

  1. 根据当前速度和力,更新速度。
  2. 根据新速度,更新位置。
// 伪代码示意 void Integrate(RigidBody& body, float dt) { // 更新线速度 (v = v + a * dt) body.velocity += (body.force * body.invMass + gravity) * dt; // 更新角速度 (ω = ω + α * dt) body.angularVelocity += body.torque * body.invInertia * dt; // 更新位置 (x = x + v * dt) body.position += body.velocity * dt; // 更新旋转 (θ = θ + ω * dt) body.rotation += body.angularVelocity * dt; // 清除累积的力和扭矩 body.force.SetZero(); body.torque = 0.0f; }

这种积分器能量守恒性较好,能有效防止数值误差导致的系统能量无限增长(数值爆炸)。

3. 核心细节解析与实操要点

3.1 碰撞检测的深度优化:Broad Phase 与 AABB 树

暴力遍历所有物体对进行碰撞检测,其时间复杂度是O(n²),在物体数量稍多时就会成为性能瓶颈。Broad Phase 的目标就是将这个复杂度降下来。

动态AABB树(Dynamic Bounding Volume Hierarchy Tree)是实现Broad Phase的经典方法。AABB(Axis-Aligned Bounding Box)即轴对齐包围盒,它是一个紧紧包裹住物体、且边与坐标轴平行的矩形。树的每个叶子节点存储一个物体的AABB,内部节点存储其子节点AABB的合并(Union)。每一帧,当物体移动后,我们更新其AABB在树中的位置。

查询时,我们从根节点开始,如果一个内部节点的AABB与查询范围不相交,那么其所有子节点都不可能相交,整个子树都可以被跳过。这样,我们只需要检查少数几个可能相交的叶子节点即可。

实操心得:AABB的膨胀(Fat AABB)在动态场景中,物体高速运动可能导致“隧道效应”(Tunneling)——即因为一帧内位移过大,从AABB的一侧直接穿越到另一侧,中间过程没有检测到碰撞。一个有效的技巧是使用“膨胀的AABB”。在构建或更新AABB时,不是紧紧包裹物体,而是根据物体可能的最大速度(或一个固定值)向外扩展一定的边界。这样,即使物体高速移动,在连续两帧之间其运动轨迹也更可能被膨胀后的AABB所覆盖,从而被Broad Phase捕获,交给Narrow Phase进行精确检测。

3.2 碰撞响应的核心:冲量法(Impulse Method)

当Narrow Phase确定两个刚体发生了穿透,我们需要解决两个问题:1. 将它们分开(解决穿透);2. 根据物理定律改变它们的运动状态。

冲量法是一种非常直观且高效的方法。冲量(J)可以理解为瞬间作用的力,它直接改变物体的动量。对于碰撞,我们主要计算沿碰撞法线方向的冲量。

计算过程基于动量守恒和恢复系数(Coefficient of Restitution, COR):

  1. 计算相对速度在碰撞法线方向上的分量。
  2. 考虑恢复系数(0为完全非弹性碰撞,1为完全弹性碰撞),计算期望的相对法向速度变化。
  3. 根据两个刚体的质量和碰撞点的几何关系(涉及转动惯量),计算出一个标量冲量大小j
  4. 将冲量j * 碰撞法线分别应用到两个刚体的线速度和角速度上。
// 简化版的碰撞冲量计算伪代码 void ResolveCollision(Contact& contact) { Vec2 relativeVel = contact.bodyB->GetVelocityAt(contact.point) - contact.bodyA->GetVelocityAt(contact.point); float velAlongNormal = Dot(relativeVel, contact.normal); // 如果物体正在分离,则不处理 if (velAlongNormal > 0) return; float e = min(contact.bodyA->restitution, contact.bodyB->restitution); // 恢复系数 float j = -(1 + e) * velAlongNormal; j /= contact.bodyA->invMass + contact.bodyB->invMass; // 简化计算,忽略旋转影响 Vec2 impulse = j * contact.normal; contact.bodyA->ApplyImpulse(-impulse, contact.point); contact.bodyB->ApplyImpulse( impulse, contact.point); }

注意事项:迭代求解与位置校正上面的简单冲量法解决了速度问题,但可能无法完全消除穿透深度。在多个物体堆叠或复杂接触时,一次计算往往不够。工业级引擎会使用如Sequential Impulse(顺序冲量)Projected Gauss-Seidel (PGS)等迭代求解器,在一个时间步内多次应用冲量,逐步逼近正确解。同时,对于微小的穿透(称为“深度睡眠”),我们还需要一个“位置校正”步骤(如Baumgarte稳定化),轻微地直接调整物体的位置来修正穿透,避免物体“粘”在一起。

3.3 内存管理与数据布局

性能是轻量级引擎的生命线。除了算法优化,内存访问模式也至关重要。

  • 使用连续内存存储(SoA):与其定义一个RigidBody类,然后创建一个std::vector<RigidBody>(数组结构,AoS),不如考虑将属性分开存储为多个数组(结构数组,SoA),例如std::vector<Vec2> positions,std::vector<float> rotations,std::vector<Vec2> velocities。这样在批量进行相同操作(如积分)时,CPU缓存命中率更高,能利用SIMD指令进行加速。
  • 对象池(Object Pool):物理世界中的物体频繁创建和销毁。使用对象池预先分配一大块内存来管理刚体和碰撞体,可以避免频繁的内存分配和释放,减少内存碎片,显著提升性能。

4. 实操过程:从零构建一个2D物理沙盒

让我们动手,搭建一个可以交互的2D物理沙盒演示程序。我们将使用SDL2或SFML这样的轻量级多媒体库来处理窗口、输入和渲染。

4.1 第一步:搭建项目框架与数学基础

首先,创建一个干净的C++项目。确保你的编译器和构建系统(如CMake)配置正确。然后,从实现数学库开始。

Vec2.h/cpp:

// Vec2.h #pragma once #include <cmath> class Vec2 { public: float x, y; Vec2() : x(0.0f), y(0.0f) {} Vec2(float x, float y) : x(x), y(y) {} // 重载常用运算符:+, -, *, /, +=, -=, *=, /= Vec2 operator+(const Vec2& other) const { return Vec2(x + other.x, y + other.y); } Vec2 operator-(const Vec2& other) const { return Vec2(x - other.x, y - other.y); } Vec2 operator*(float scalar) const { return Vec2(x * scalar, y * scalar); } // 点积、叉积、归一化、长度等核心函数 float Dot(const Vec2& other) const { return x * other.x + y * other.y; } float Cross(const Vec2& other) const { return x * other.y - y * other.x; } float LengthSquared() const { return x*x + y*y; } float Length() const { return std::sqrt(LengthSquared()); } Vec2 Normalized() const { float len = Length(); return (len > 0.0f) ? (*this * (1.0f / len)) : Vec2(); } void SetZero() { x = y = 0.0f; } };

类似地,实现Mat22(用于旋转)和TransformTransform类可以提供一个将局部坐标转换为世界坐标的成员函数,这在渲染和碰撞检测中非常有用。

4.2 第二步:定义刚体与碰撞体

RigidBody.h:

#pragma once #include "Vec2.h" class RigidBody { public: // 物理属性 float mass; // 质量 float invMass; // 质量的倒数,用于计算加速度(避免除零) float inertia; // 转动惯量 float invInertia; // 转动惯量的倒数 float restitution; // 恢复系数 (弹性) float friction; // 摩擦系数 // 运动状态 Vec2 position; float rotation; Vec2 velocity; float angularVelocity; // 累积的力/扭矩 Vec2 force; float torque; // 标识 bool isStatic; // 静态物体(质量无穷大,不参与动力学计算) RigidBody(); void SetMass(float m); void ApplyForce(const Vec2& f); void ApplyImpulse(const Vec2& impulse, const Vec2& contactPoint); Vec2 GetVelocityAt(const Vec2& worldPoint) const; };

Collider可以作为一个基类,派生出CircleCollider,BoxCollider等。每个Collider包含其几何参数和一个指向所属RigidBody的指针(或ID)。

4.3 第三步:实现碰撞检测

这是最复杂但也最有趣的部分。我们先实现几种基本形状的Narrow Phase检测。

圆形 vs 圆形:最简单,判断圆心距是否小于半径之和。

bool CheckCircleVsCircle(const CircleCollider* a, const CircleCollider* b, Contact& contact) { Vec2 delta = b->worldCenter - a->worldCenter; float distSq = delta.LengthSquared(); float radiusSum = a->radius + b->radius; if (distSq > radiusSum * radiusSum) return false; // 计算接触信息 float dist = std::sqrt(distSq); contact.normal = (dist > 0.0f) ? (delta * (1.0f / dist)) : Vec2(1.0f, 0.0f); contact.penetration = radiusSum - dist; contact.point = a->worldCenter + contact.normal * a->radius; // 近似接触点 return true; }

分离轴定理(SAT)用于凸多边形 vs 凸多边形:这是检测凸多边形碰撞的通用且强大的算法。其核心思想是:如果能找到一条轴,使得两个多边形在该轴上的投影不重叠,则它们一定没有碰撞。我们需要测试每个多边形的每条边法线作为潜在的分离轴。

实现SAT需要一些几何知识,但一旦实现,它可以处理矩形、任意凸多边形之间的碰撞,并直接给出最小穿透深度和分离轴(即碰撞法线),这对碰撞响应至关重要。

4.4 第四步:组装物理世界与主循环

创建一个PhysicsWorld类来管理所有的RigidBodyCollider,并驱动整个物理模拟流程。

每一帧,PhysicsWorld::Step(float dt)执行以下操作:

  1. Broad Phase:更新所有动态物体的AABB,并利用AABB树或简单排序扫描(对于轻量级演示,可以先使用暴力法或简单的空间网格)生成潜在碰撞对列表。
  2. Narrow Phase:遍历潜在碰撞对,进行精确的几何碰撞检测,生成Contact(接触点)列表。
  3. 积分预计算:对每个刚体,根据重力和其他力计算速度变化(但不更新位置)。
  4. 碰撞响应求解:遍历Contact列表,迭代多次(如10次)应用冲量,解决碰撞和接触约束。
  5. 积分:用修正后的速度,更新所有刚体的位置和旋转。
  6. 清除状态:清除所有刚体上累积的力。

在主渲染循环中,调用PhysicsWorld::Step,然后遍历所有刚体,根据其位置和旋转,调用图形库API绘制对应的形状。

5. 常见问题与排查技巧实录

在开发过程中,你几乎一定会遇到下面这些问题。这里记录了我的排查思路和解决方法。

5.1 物体抖动或穿透(Jitter / Tunneling)

  • 现象:堆叠的物体不停轻微抖动,或者高速运动的物体穿过了另一个物体。
  • 排查
    1. 检查时间步长(dt)dt是否过大?尝试固定时间步长(如1/60秒),并使用累积时间进行插值更新,避免帧率波动影响物理稳定性。
    2. 检查积分顺序:确保是先更新速度,再更新位置(半隐式欧拉)。顺序反了会导致能量异常。
    3. 检查恢复系数(COR):设为1(完全弹性)在堆叠时容易引起持续振荡。尝试设为0到0.5之间,增加能量耗散。
    4. 引入位置校正(Baumgarte Stabilization):在冲量求解中,加入一个基于穿透深度的轻微位置修正项,可以有效地消除微小穿透和抖动。公式类似于在速度修正中加入一个(beta / dt) * penetration的项,其中beta是一个0到1之间的参数(如0.2)。
    5. 针对隧道效应:确保Broad Phase使用了“膨胀AABB”(Fat AABB)。对于极端高速,可能需要用到连续碰撞检测(CCD),但这在轻量级引擎中较复杂,通常用膨胀AABB和减小时间步长来缓解。

5.2 性能突然下降

  • 现象:当场景中物体增多到几十个时,帧率显著下降。
  • 排查
    1. Profile(性能剖析):使用工具(如Visual Studio Profiler,chrono库手动打点)定位耗时最长的函数。99%的概率是碰撞检测,尤其是Broad Phase。
    2. 验证Broad Phase:你是否还在使用O(n²)的暴力检测?立即实现或优化你的空间分割结构(如动态AABB树)。即使是一个简单的均匀网格(Spatial Grid),也能在物体分布均匀时带来巨大提升。
    3. 检查不必要的复制:在Contact生成、传递过程中,是否产生了大量临时对象?尝试使用对象池或引用传递。
    4. 渲染开销:确保你的渲染代码是高效的。例如,不要每帧都重新编译着色器或创建纹理。

5.3 旋转行为异常

  • 现象:物体碰撞后旋转速度奇怪,或者施加力后不按预期旋转。
  • 排查
    1. 检查转动惯量(Inertia)计算:对于常见形状(矩形、圆盘),转动惯量有公式。确保你为BoxCollider正确计算了绕质心的转动惯量(1/12) * mass * (width^2 + height^2)
    2. 检查冲量作用点ApplyImpulse函数是否正确处理了扭矩?冲量不仅改变线速度,如果作用点不在质心上,还会产生一个扭矩Δω = (r × impulse) * invInertia,其中r是从质心到作用点的向量,×是叉积。
    3. 验证GetVelocityAt函数:这个函数用于计算刚体上某一点的世界速度,公式为v_point = v + ω × r。它在碰撞响应中用于计算相对速度,如果出错,整个碰撞响应都会错。

5.4 堆叠不稳定(Stack Toppling)

  • 现象:几个盒子堆起来,最下面的盒子会莫名抖动,甚至整个塔慢慢倒塌。
  • 排查
    1. 增加求解器迭代次数:碰撞和接触约束通常需要多次迭代才能收敛到稳定解。将你的速度迭代次数(用于冲量求解)从10次提高到20次或30次试试。
    2. 分开处理碰撞和接触:对于新的碰撞(穿透深度较大),使用正常的恢复系数;对于持续接触(穿透深度很小),使用一个更小的恢复系数(甚至为0),并启用静摩擦力。这有助于稳定堆叠。
    3. 调整摩擦模型:实现一个简单的库仑摩擦模型。静摩擦力可以防止堆叠的物体在微小扰动下开始滑动。
    4. 检查数值精度:使用double类型可能比float更稳定,但会牺牲一些性能。对于轻量级2D引擎,float通常足够,但要确保你的数学运算(如归一化)有防止除零的保护。

开发自己的物理引擎是一个不断迭代和调试的过程。我的建议是,从一个最简单的场景开始——比如两个下落的球。让它们碰撞、反弹,确保行为符合预期。然后逐渐增加复杂度:加入矩形,加入堆叠,加入旋转。每增加一个功能,就进行充分的测试。使用渲染器绘制出碰撞法线、AABB等调试信息,会让你对引擎内部状态一目了然,极大提升调试效率。当你看到自己编写的代码让一堆物体稳定地堆叠、滚动、碰撞时,那种成就感是使用现成引擎无法比拟的。这不仅仅是实现了一个工具,更是真正理解了虚拟世界是如何“动”起来的。