2026/8/2 8:57:09

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

从零构建轻量级C++物理引擎:2D刚体碰撞与性能优化实战 1. 项目概述为什么我们需要一个轻量级的C物理引擎在游戏开发这个行当里物理引擎的地位有点像是汽车里的发动机。你当然可以买一辆现成的、性能卓越的整车但对于那些想造一辆独一无二赛车或者仅仅是想搞清楚引擎盖下每一个零件如何协同工作的工程师来说自己动手“攒”一台发动机是必经之路。市面上的商业物理引擎比如PhysX、Havok功能强大、稳定可靠就像一台V8双涡轮增压引擎动力澎湃。但问题也随之而来它们体积庞大、授权复杂、定制困难对于独立开发者、教育项目、特定平台如移动端、WebAssembly或者某些对性能有极致要求的核心玩法来说这套“重武器”有时显得过于笨重。这就是“轻量级游戏物理引擎”存在的意义。它不是一个功能阉割版而是一个设计哲学上的取舍。我们的目标不是复现所有物理现象而是用最精简的代码实现游戏最需要的那部分物理交互——通常是刚体动力学。这就像为你的卡丁车定制一台高转速的小排量引擎轻巧、响应快、完全可控。选择C来实现几乎是必然的。C提供了对内存和计算资源的直接控制能力零成本抽象的理念让我们能在高级逻辑和底层性能之间找到完美的平衡点这对于实时模拟每秒60帧甚至更高频率的物理世界至关重要。这个项目就是一次从零开始的“造引擎”之旅。我们将聚焦于2D刚体物理因为这是绝大多数游戏物理交互的基石。通过设计并实现一个轻量级的物理引擎你不仅能深入理解碰撞检测、积分求解这些核心算法更能掌握一套构建高性能、模块化C系统的工程方法。无论你是想为你的独立游戏注入灵魂还是希望深入游戏开发底层原理这次实战都将是一次宝贵的经历。2. 引擎核心架构与设计思路拆解一个物理引擎无论轻重其核心架构都可以抽象为几个紧密协作的模块。我们的轻量级设计将遵循“高内聚、低耦合”的原则确保每个模块职责清晰便于理解、调试和扩展。2.1 模块化架构设计我们的引擎主要由四大核心模块构成数学库Math Library这是引擎的“语言”。所有物理计算都建立在向量、矩阵、几何形状等数学概念之上。一个高效、准确的数学库是基石。我们将实现Vec2二维向量、Mat222x2矩阵用于旋转、Transform变换包含位置和旋转以及基本的几何体如AABB轴对齐包围盒、Circle圆形、Polygon多边形。刚体组件RigidBody这是物理世界的“演员”。它封装了一个物体的物理属性包括质量mass、转动惯量inertia、位置position、旋转rotation、速度velocity、角速度angular velocity等。它不关心物体的具体形状形状信息由碰撞体持有。碰撞体与碰撞检测Collider Detection这是引擎的“感官系统”。Collider组件描述物体的几何形状如圆形、矩形、凸多边形并关联到一个RigidBody。碰撞检测模块负责快速判断两个碰撞体是否相交它通常分为两个阶段Broad Phase粗略检测和Narrow Phase精确检测。Broad Phase 使用空间分割算法如动态AABB树快速筛选出可能发生碰撞的物体对极大提升效率。Narrow Phase 则对筛选后的物体对进行精确的几何相交测试。解析与响应Resolution Response这是引擎的“决策系统”。当检测到碰撞后此模块负责计算碰撞点、法向量、穿透深度并据此计算出冲量impulse应用在发生碰撞的刚体上改变它们的速度和角速度从而模拟出碰撞后的反弹、滑动等效果。同时它也要处理持续的接触实现稳定的堆叠和静摩擦力。2.2 为什么选择分离式架构将碰撞体Collider从刚体RigidBody中分离是一个关键的设计决策。这种设计带来了巨大的灵活性一个刚体可以拥有多个碰撞体你可以轻松组合简单形状来构建复杂角色比如一个人物由多个圆形和矩形组成。碰撞体可以作为传感器Sensor只检测碰撞而不产生物理响应常用于触发器区域。职责清晰RigidBody只管理运动状态Collider只管理几何形状和碰撞查询。这使得代码更易于维护和测试。2.3 时间步进与积分器选择物理世界是连续的但计算机是离散的。我们需要通过“积分”来模拟时间的流逝。对于轻量级引擎半隐式欧拉法Semi-Implicit Euler 或称Symplectic Euler是一个在简单性、速度和稳定性之间取得很好平衡的选择。 它的更新顺序通常是根据当前速度和力更新速度。根据新速度更新位置。// 伪代码示意 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的经典方法。AABBAxis-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计算相对速度在碰撞法线方向上的分量。考虑恢复系数0为完全非弹性碰撞1为完全弹性碰撞计算期望的相对法向速度变化。根据两个刚体的质量和碰撞点的几何关系涉及转动惯量计算出一个标量冲量大小j。将冲量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::vectorRigidBody数组结构AoS不如考虑将属性分开存储为多个数组结构数组SoA例如std::vectorVec2 positions,std::vectorfloat rotations,std::vectorVec2 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用于旋转和Transform。Transform类可以提供一个将局部坐标转换为世界坐标的成员函数这在渲染和碰撞检测中非常有用。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类来管理所有的RigidBody和Collider并驱动整个物理模拟流程。每一帧PhysicsWorld::Step(float dt)执行以下操作Broad Phase更新所有动态物体的AABB并利用AABB树或简单排序扫描对于轻量级演示可以先使用暴力法或简单的空间网格生成潜在碰撞对列表。Narrow Phase遍历潜在碰撞对进行精确的几何碰撞检测生成Contact接触点列表。积分预计算对每个刚体根据重力和其他力计算速度变化但不更新位置。碰撞响应求解遍历Contact列表迭代多次如10次应用冲量解决碰撞和接触约束。积分用修正后的速度更新所有刚体的位置和旋转。清除状态清除所有刚体上累积的力。在主渲染循环中调用PhysicsWorld::Step然后遍历所有刚体根据其位置和旋转调用图形库API绘制对应的形状。5. 常见问题与排查技巧实录在开发过程中你几乎一定会遇到下面这些问题。这里记录了我的排查思路和解决方法。5.1 物体抖动或穿透Jitter / Tunneling现象堆叠的物体不停轻微抖动或者高速运动的物体穿过了另一个物体。排查检查时间步长dtdt是否过大尝试固定时间步长如1/60秒并使用累积时间进行插值更新避免帧率波动影响物理稳定性。检查积分顺序确保是先更新速度再更新位置半隐式欧拉。顺序反了会导致能量异常。检查恢复系数COR设为1完全弹性在堆叠时容易引起持续振荡。尝试设为0到0.5之间增加能量耗散。引入位置校正Baumgarte Stabilization在冲量求解中加入一个基于穿透深度的轻微位置修正项可以有效地消除微小穿透和抖动。公式类似于在速度修正中加入一个(beta / dt) * penetration的项其中beta是一个0到1之间的参数如0.2。针对隧道效应确保Broad Phase使用了“膨胀AABB”Fat AABB。对于极端高速可能需要用到连续碰撞检测CCD但这在轻量级引擎中较复杂通常用膨胀AABB和减小时间步长来缓解。5.2 性能突然下降现象当场景中物体增多到几十个时帧率显著下降。排查Profile性能剖析使用工具如Visual Studio Profiler,chrono库手动打点定位耗时最长的函数。99%的概率是碰撞检测尤其是Broad Phase。验证Broad Phase你是否还在使用O(n²)的暴力检测立即实现或优化你的空间分割结构如动态AABB树。即使是一个简单的均匀网格Spatial Grid也能在物体分布均匀时带来巨大提升。检查不必要的复制在Contact生成、传递过程中是否产生了大量临时对象尝试使用对象池或引用传递。渲染开销确保你的渲染代码是高效的。例如不要每帧都重新编译着色器或创建纹理。5.3 旋转行为异常现象物体碰撞后旋转速度奇怪或者施加力后不按预期旋转。排查检查转动惯量Inertia计算对于常见形状矩形、圆盘转动惯量有公式。确保你为BoxCollider正确计算了绕质心的转动惯量(1/12) * mass * (width^2 height^2)。检查冲量作用点ApplyImpulse函数是否正确处理了扭矩冲量不仅改变线速度如果作用点不在质心上还会产生一个扭矩Δω (r × impulse) * invInertia其中r是从质心到作用点的向量×是叉积。验证GetVelocityAt函数这个函数用于计算刚体上某一点的世界速度公式为v_point v ω × r。它在碰撞响应中用于计算相对速度如果出错整个碰撞响应都会错。5.4 堆叠不稳定Stack Toppling现象几个盒子堆起来最下面的盒子会莫名抖动甚至整个塔慢慢倒塌。排查增加求解器迭代次数碰撞和接触约束通常需要多次迭代才能收敛到稳定解。将你的速度迭代次数用于冲量求解从10次提高到20次或30次试试。分开处理碰撞和接触对于新的碰撞穿透深度较大使用正常的恢复系数对于持续接触穿透深度很小使用一个更小的恢复系数甚至为0并启用静摩擦力。这有助于稳定堆叠。调整摩擦模型实现一个简单的库仑摩擦模型。静摩擦力可以防止堆叠的物体在微小扰动下开始滑动。检查数值精度使用double类型可能比float更稳定但会牺牲一些性能。对于轻量级2D引擎float通常足够但要确保你的数学运算如归一化有防止除零的保护。开发自己的物理引擎是一个不断迭代和调试的过程。我的建议是从一个最简单的场景开始——比如两个下落的球。让它们碰撞、反弹确保行为符合预期。然后逐渐增加复杂度加入矩形加入堆叠加入旋转。每增加一个功能就进行充分的测试。使用渲染器绘制出碰撞法线、AABB等调试信息会让你对引擎内部状态一目了然极大提升调试效率。当你看到自己编写的代码让一堆物体稳定地堆叠、滚动、碰撞时那种成就感是使用现成引擎无法比拟的。这不仅仅是实现了一个工具更是真正理解了虚拟世界是如何“动”起来的。