1. 为什么一定要用Rust来写物理引擎
1.1 物理引擎对语言的核心诉求
做游戏物理引擎这件事,大部分人的第一反应是C++。Box2D、Bullet、PhysX这些老牌引擎确实都是C++写的,但这不代表C++是这个领域唯一的选择。我之所以在正式动手写之前认真考虑Rust,是因为物理引擎有一个非常特殊的矛盾:它既追求极致的性能,又要求极高的正确性。
物理模拟本质上是每一帧对大量刚体做积分运算、碰撞检测和约束求解。一个场景里几十个物体、每帧上千次碰撞检测、每次碰撞要做若干次向量运算和矩阵变换,这种工作量决定了语言的运行时开销必须足够低。Rust没有GC暂停,零成本抽象能保证你写出来的高级代码和手写C差不多快,这两点在性能上先站住了。
但更关键的是正确性。物理引擎里最让人头疼的bug,往往不是算法理解错了,而是内存问题。碰撞对列表的构建和销毁发生得非常频繁,如果指针管理不当,轻则数据错乱导致物体穿模,重则直接崩溃。用C++写这类系统,我几乎每周都要花时间在排查悬垂指针和越界访问上。而Rust的所有权系统和借用检查,能在编译阶段就把这类问题挡住大部分。
1.2 所有权与借用检查在物理模拟中的实际价值
很多人学Rust的时候觉得所有权、借用、生命周期这些概念很抽象,但放在物理引擎的场景里,它们的作用立刻变得具体。
拿碰撞检测管线来说:宽相检测生成一堆候选碰撞对,然后交给窄相检测做精确判断。在C++里,你可能会用一个std::vector<CollisionPair>,这个vector会在多个模块之间传来传去,谁负责释放、谁有权修改,全靠约定。在Rust里,你可以直接把碰撞对的所有权在一次函数调用链中传递下去,比如:
pub struct CollisionPair { pub a: usize, pub b: usize, } pub struct NarrowPhaseResult { // 包含接触点、法线、穿透深度等 } pub fn detect_collisions( bodies: &[RigidBody], broad_phase_pairs: Vec<CollisionPair>, ) -> Vec<Contact> { broad_phase_pairs .into_iter() .filter_map(|pair| narrow_phase_detect(bodies, pair)) .collect() }这个代码里broad_phase_pairs被整个传入,内部用into_iter()消费掉它。代码的读者一眼就能看出碰撞对在这个函数之后不会再被其他地方使用,不需要去追踪生命周期。这就是所有权的价值:数据流向写进类型系统里,编译器帮你验证,而不是靠人脑记忆。
借用检查在物理引擎里最典型的应用场景,是碰撞回调中修改刚体状态。你可能想在两个物体碰撞时给它们应用一个冲量,同时又要读取它们当前的线速度。在Rust里,如果你把刚体数组直接传给碰撞求解器,借用检查器会严格限制你不能同时以可变引用访问两个不同的数组元素。这时你会被迫思考一个更好的设计:把求解过程分成"读取碰撞信息"和"写入速度状态"两个阶段,或者用split_at_mut这类手段明确划分可变区域。这个约束在初期让人觉得束手束脚,但实际写下来,它避免了一整类"两辆车同时通过一个路口"的时序问题。
生命周期参数在物理引擎中主要体现在引用关系上。比如一个World结构体持有所有刚体数据,SubStep对象在单步模拟过程中借用这些数据。你可以显式声明:
pub struct SubStep<'a> { bodies: &'a mut Vec<RigidBody>, contacts: Vec<Contact>, }这等于在类型层面告诉编译器:SubStep 只能在 World 的数据存在期间使用,一旦 World 被释放或重新分配,所有相关的 SubStep 都不再有效。对物理模拟这种每帧大量创建临时对象的场景,这种约束能有效防止"悬挂引用"这种极其隐蔽的错误。
1.3 生命周期设计给物理资源管理带来的约束与收益
因为我最初是在一个游戏Demo里集成物理模块,所以对生命周期这个问题感受特别深。游戏的主循环是这样的:处理输入 -> 更新物理 -> 渲染。物理World需要被持续持有,但每帧的模拟状态是临时的。
我一开始的方案是把World设计得很大,包含了所有刚体、碰撞数据、约束求解器状态。后来发现一个问题:物理模块被其他系统(比如网络同步、AI寻路)调用时,别人只想读取刚体的位置信息,但World的可变借用会阻塞这些系统的运行。
后来我按借用的粒度重新设计了接口。World只负责存储数据,对外暴露两类方法:一类是&self的查询方法,用于读取位置、速度、碰撞形状等数据;另一类是&mut self的模拟方法,只在固定步长更新时才调用。这样做的好处是,渲染线程可以通过只读借用获取物体位置用于插值,而模拟线程在同一帧内持有多帧碰撞缓存。如果是在C++里,你得靠加锁或者读写锁来处理这种并发访问,但Rust的借用检查在编译期就保证了这个设计是安全的。
另外,Rust的生命周期标注还有一个容易被低估的收益:它迫使你把资源的存活范围想清楚。物理引擎里有大量缓存,比如宽相检测的SAP排序数组、窄相检测的接触点缓存,如果生命周期含糊,这些缓存在哪里分配、在哪里失效、什么时候需要清理,都会成为问题。生命周期标注让这些边界变得明确,写出来的代码结构也因此更清晰。
2. 核心模块架构与数据布局:先想清楚再写代码
2.1 模块边界:一个轻量引擎应该拆成几块
写物理引擎最忌惮的事情就是所有代码堆在一起。看着只是一个"给小球加上重力然后让它落地"的功能,一旦要扩展到多边形碰撞、摩擦、堆叠,代码复杂度会指数级上升。我对这个项目的模块划分是这样的:
- 数学库(math):
Vec2、Mat2(二维向量与矩阵),以及点在多边形内的判定、线段相交检测等基础几何函数。 - 刚体组件(rigid_body):定义刚体的质量、位置、线速度、角速度、受力、碰撞形状、摩擦系数、恢复系数等。
- 碰撞检测(collision):分为宽相(Broad Phase)和窄相(Narrow Phase)。宽相负责快速排除明显不相交的物体对,窄相负责给出精确的接触点、接触法线、穿透深度。
- 约束求解(solver):把接触转化为速度冲量,处理碰撞响应和持续性接触。
- 世界管理(world):持有上述所有模块的数据,提供
step(dt)接口供游戏主循环调用。
模块划分不是越多越好,但上面这几个边界基本是所有物理引擎的共识。关键是每个模块的依赖方向必须单向:world -> solver -> collision -> rigid_body -> math。任何反向依赖都会让后续的测试和重构变得痛苦。
2.2 SOA还是AOS:缓存友好与Rust实现的取舍
物理引擎每帧要处理大量刚体,数据布局直接影响性能表现。这里有一个经典话题:数组结构(AOS)还是结构数组(SOA)。
AOS的意思是每个刚体是一个结构体,多个刚体组成一个结构体数组:
pub struct BodyAos { pub position: Vec2, pub velocity: Vec2, pub mass: f32, pub shape: Shape, } pub type BodyListAos = Vec<BodyAos>;SOA的做法是每个字段放在独立的数组里:
pub struct BodySoa { pub positions: Vec<Vec2>, pub velocities: Vec<Vec2>, pub masses: Vec<f32>, pub shapes: Vec<Shape>, }从缓存性能的角度,SOA通常更好,因为物理引擎的各个子系统往往只访问刚体的部分字段。碰撞检测只需要位置和形状,约束求解只需要速度和位置,质量只在初始化时被读取。SOA让CPU缓存行里装的全是当前子系统需要的数据,而不是每个刚体都读一遍然后再跳过一堆用不到的其他字段。
但纯SOA在Rust里写起来有点别扭,因为借用检查器不太喜欢你同时操作多个Vec中的同一索引。我的方案是折中:刚体数量不超过几百个的轻量级引擎,直接用AOS,简单清晰;如果目标是几千个物体的大规模模拟,再考虑迁移到SOA。做这个项目时,我一开始就写了AOS版本跑通功能,后续性能测试发现瓶颈不在数据布局,而在碰撞检测的遍历本身,所以AOS在轻量级场景下完全够用。
2.3 组件的组织方式:用结构体数组管理刚体
我实际使用的刚体数据结构长这样:
pub struct RigidBody { pub id: u32, pub shape: CollisionShape, pub position: Vec2, pub rotation: f32, pub velocity: Vec2, pub angular_velocity: f32, pub force: Vec2, pub torque: f32, pub mass: f32, pub inv_mass: f32, pub restitution: f32, pub friction: f32, pub is_static: bool, }注意我同时存了mass和inv_mass(质量的倒数)。物理引擎里几乎所有计算都用inv_mass,因为如果静力学中质量趋向于无穷大,倒数值趋向于0,这样在代码里就可以统一处理静态物体和动态物体——静态物体的inv_mass = 0,冲量更新对它无效,它自然就不会动。这比什么is_static分支判断要优雅得多。
还有一个重要的设计是给刚体一个稳定的id。在整个模拟过程中,这个id不会改变。碰撞检测生成的碰撞对、约束求解器管理的接触点,都是保存id而不是保存指针或索引。这样做的原因是,在模拟过程中如果你需要动态增删刚体(比如生成一个炮弹然后销毁它),基于索引的引用会失效,而基于id的查找可以确保数据一致性。当然,id查找比索引慢一点,所以我会先在World里维护一个从id到索引的HashMap或者在刚体数组里的元素移动时更新对应索引,确保热路径上用索引访问,冷路径上用id做逻辑引用。
3. 刚体动力学与积分器选型:半隐式欧拉如何撑起稳定性
3.1 刚体运动学的基本量与状态表示
在二维物理引擎里,刚体的运动状态由以下几部分组成:位置position(二维向量)、旋转角度rotation(标量)、线速度velocity(二维向量)、角速度angular_velocity(标量)。物体受到的力合成为线性的force和转动的torque。
每个时间步,物理引擎要做的事情是:
- 根据当前状态和受力计算加速度。
- 更新速度。
- 根据更新的速度更新位置。
- 处理碰撞和约束。
这是物理模拟最核心的流程。难点在于第2步和第3步之间如何处理速度与位置更新的顺序,以及如何保证系统稳定。
3.2 为什么中小型引擎默认选半隐式欧拉而不是RK4
很多刚接触物理模拟的人会问:为什么不用更高阶的数值积分方法,比如RK4或者Verlet?
RK4确实更精确,但物理引擎里存在大量的非光滑事件——碰撞瞬间速度发生跳变、接触约束是硬约束——这些情况下高阶方法的优势会被削弱。而且RK4每个时间步要评估多次导数,开销大,代码复杂度也高。对一个每帧需要处理几十上百个接触点的引擎,这不是一个划算的交易。
半隐式欧拉(Semi-implicit Euler)才是游戏物理引擎的事实标准。它的做法是:
// 更新速度:根据当前受力和质量计算加速度 velocity += (force * inv_mass) * dt; angular_velocity += torque * inv_mass * dt; // 更新位置:使用更新后的速度 position += velocity * dt; rotation += angular_velocity * dt;关键在于第二步更新位置时,使用的是已经更新过的速度,而不是帧开始时的旧速度。这个"半隐式"的特点让系统在能量上表现得更加稳定,物体不会像显式欧拉那样越蹦越高。
Box2D和大多数2D物理引擎都使用半隐式欧拉。我在项目里做了一个对比实验:显式欧拉在模拟一个从高处掉落的球时,弹跳高度会逐渐增加,最终导致物体飞出屏幕;换成半隐式欧拉之后,弹跳高度逐步衰减,模拟一个篮球落地大概能稳定跑几百秒不爆飞。
3.3 力、速度、位置更新的代码实现与关键细节
实际的物理更新被拆成两个方法,一个是apply_force,一个是step。apply_force用来给刚体施加瞬时力,比如重力、风力、玩家的输入推力。step负责推进模拟:
impl RigidBody { pub fn step(&mut self, dt: f32, gravity: Vec2) { if self.is_static { return; } // 重力通常直接加到速度上而不是通过force积累 let total_acc = gravity + self.force * self.inv_mass; self.velocity += total_acc * dt; self.angular_velocity += self.torque * self.inv_mass * dt; self.position += self.velocity * dt; self.rotation += self.angular_velocity * dt; // 每帧结束需要清空外力,因为力是每帧重新计算的 self.force = Vec2::ZERO; self.torque = 0.0; } }这里有个细节要注意:力(force)每帧结束必须清零。物理引擎中外力通常是"瞬时"的,比如玩家按一下跳跃键,给角色一个向上的冲量。如果不清零,这个力会在下一帧被再次应用,导致物体持续加速,这显然不符合物理规律。正确的手法是每帧开始由外部系统重新计算并施加所有持久力,然后在帧末清空。
重力我选择直接加到加速度上而不是通过force字段。原因很简单:重力是恒定的,把它计算在force里意味着每帧都要先 force += mass * gravity 再做积分,白白多一次乘法和一次加法。直接加到速度上更简洁。
还有一个关于固定时间步的问题。物理模拟强烈建议使用固定的dt,而不是直接使用渲染帧的实时间隔。渲染帧的间隔是不稳定的,有时候是16ms,有时候是33ms,如果用这个不稳定值直接作为物理dt,模拟结果会出现抖动。标准做法是累加真实流逝的时间,按固定的1/60秒切片多次更新物理:
fn fixed_update(world: &mut World, accumulator: &mut f32, dt_real: f32) { *accumulator += dt_real; let fixed_dt = 1.0 / 60.0; while *accumulator >= fixed_dt { world.step(fixed_dt); *accumulator -= fixed_dt; } }这个循环里,如果一帧时间特别长,可能会连续执行多次物理更新,保证物理模拟的节奏始终一致。我在项目里还加了最大迭代次数限制,防止"死亡螺旋"——就是物理更新跟不上真实时间导致无限循环追帧的坑。
4. 碰撞检测从宽到窄:几十个物体也能流畅跑的优化思路
4.1 Broad Phase:Sweep and Prune的增量排序技巧
碰撞检测最笨的办法是遍历所有物体对,时间复杂度 O(n^2)。十几个物体还好,一旦到一百个,每帧要比较接近5000对物体,性能就开始吃紧。宽相检测的目的,就是用廉价的计算排除掉明显不可能碰撞的物体对,把精确计算量降下来。
我选择的是Sweep and Prune(SAP)算法。它的核心思想是:把每个物体投影到X轴上得到一个区间[min_x, max_x],对区间列表按min_x排序;然后遍历排序后的数组,只跟那些min_x在当前物体max_x之前的物体做碰撞对候选。如果两个物体在X轴上的投影区间都不重叠,那它们在二维平面上也不可能重叠。
SAP最妙的地方在于增量更新的便捷性:上一帧已经排序好了,新一帧物体移动量通常很小,只需要做一次近乎O(n)的插入排序。我实现的代码大致如下:
pub struct SweepAndPrune { pub proxies: Vec<Proxy>, // 每个Proxy持有物体id和当前帧的AABB } impl SweepAndPrune { pub fn update(&mut self, bodies: &[RigidBody]) -> Vec<CollisionPair> { // 更新每个proxies的AABB for proxy in self.proxies.iter_mut() { let body = &bodies[proxy.body_index]; proxy.min_x = body.position.x - body.bounding_radius(); proxy.max_x = body.position.x + body.bounding_radius(); } // 基于min_x做增量插入排序(上一帧基本有序) for i in 1..self.proxies.len() { let mut j = i; while j > 0 && self.proxies[j].min_x < self.proxies[j - 1].min_x { self.proxies.swap(j, j - 1); j -= 1; } } // 输出重叠对 let mut pairs = Vec::new(); for i in 0..self.proxies.len() { let a = &self.proxies[i]; for j in (i + 1)..self.proxies.len() { let b = &self.proxies[j]; if b.min_x > a.max_x { break; } // 进一步检查Y轴AABB重叠 if aabb_overlap(a, b) { pairs.push(CollisionPair { a: a.body_index, b: b.body_index }); } } } pairs } }这里一个容易被忽略的细节是,每个物体我还保存了一个bounding_radius(),用于直接生成包围球。物理引擎中很多物体不是AABB形状,但宽相检测阶段可以用包围球/包围盒来近似判断,误差可以接受,速度却快得多。
4.2 Narrow Phase:圆与多边形碰撞的几何判定
窄相检测的任务,是精确判断两个具体形状是否相交,并给出接触点、法线和穿透深度。
对于轻量级2D引擎,我实现了三种组合:
- 圆 vs 圆:判断两圆心距离是否小于半径之和。
- 圆 vs 多边形(凸多边形):找到圆心到多边形每条边的最近点,计算距离是否小于半径。
- 多边形 vs 多边形:用分离轴定理(SAT)判断是否存在一条分离轴,如果存在就不相交。
圆 vs 多边形的实现是这些里面最容易出错的,因为要考虑"圆心是否在多边形内部"的情况。如果圆心在多边形内部,最近点距离应该从圆心到某条边计算,但此时距离为0,碰撞法线方向需要特殊处理。我踩过这个坑:一开始忽略了圆心在内部的情况,导致小球直接穿过矩形板子。后来把"圆心是否在内部"作为单独分支处理,问题才解决。
多边形之间的SAT判定是物理引擎的经典算法。核心步骤是:
- 取多边形A的所有边的法线,取多边形B的所有边的法线(在二维中,法线方向与边垂直,通常取外法线方向)。
- 对每条法线,把所有顶点投影上去,得到两个投影区间。
- 如果两个投影区间不重叠,就找到了一条分离轴,两个多边形不相交。
- 如果所有法线上投影都重叠,说明相交;此时最小的重叠量对应的轴就是碰撞法线,重叠量就是穿透深度。
实际写代码时,我还会把SAT的结果封装成一个Contact结构体,包含碰撞法线、穿透深度、接触点位置。接触点一般取两个多边形重叠区域的重心或者两个最近点的中点,这会影响后续碰撞响应的稳定性。
4.3 用Rust的迭代器链实现碰撞对收集管线
在Rust里实现碰撞检测管线,体验比C++舒适不少。得益于迭代器和闭包,整个流程可以写作一条流畅的链式调用:
let contacts: Vec<Contact> = world .broad_phase() .detect_pairs(&world.bodies) .into_iter() .map(|pair| narrow_phase_detect(&world.bodies, pair)) .filter_map(|result| result) .collect();这样的代码比一堆for循环嵌套要清晰得多。每个阶段都成为一个独立的变换,你可以轻松地在中间插入.inspect()来打印调试信息,或者用.filter()排除某些层级的物体。这在C++里不是不能做,但Rust的迭代器是零成本的,所以这种写法不会带来任何运行时开销,写得优雅的同时也保持了性能。
不过要用好这个管线,需要提前把你的数据结构设计成"读取刚体数组,输出接触点Vec",不要在碰撞检测函数里去修改世界状态。这也是Rust借用检查器逼出来的好习惯:碰撞检测是只读阶段,碰撞求解是写入阶段,这两个阶段分离是物理引擎的正确架构,分离后可以并行化(部分环节可以用rayon做并行遍历),也能更快排查问题。
5. 碰撞响应与接触求解:从反弹系数到摩擦的冲量计算
5.1 碰撞响应的物理推导:相对速度、法向冲量、恢复系数
碰撞检测给了我们接触点和法线,但真正让物体分开、反弹的效果,要靠碰撞响应模块来实现。这里最常用的方法叫冲量法(Impulse Method)。
冲量法的核心是计算一个瞬时冲量j,把这个冲量施加在法线方向上,从而改变两个物体的速度。计算基于牛顿碰撞定律:碰撞前后,沿法线方向的相对速度满足恢复系数e的关系。
公式是这样的。设两个物体 A 和 B,法线单位向量为n(从A指向B),碰撞前的相对速度为:
v_rel = v_B - v_A沿法线方向的分量:
v_rel_n = v_rel · n如果v_rel_n > 0,说明物体正在分离,不需要处理。如果v_rel_n < 0,说明物体在接近,需要施加冲量。根据恢复系数:
v_rel_n_after = -e * v_rel_n冲量大小由动量守恒推导出来:
j = -(1 + e) * v_rel_n / (inv_mass_a + inv_mass_b)然后分别更新两个物体的速度:
v_A -= j * n * inv_mass_a v_B += j * n * inv_mass_b注意这里的正负号:法线方向是从A指向B的,所以A被推回(减去法线方向冲量),B被推走(加上)。
在Rust里实现:
pub fn resolve_contact(contact: &Contact, a: &mut RigidBody, b: &mut RigidBody) { let n = contact.normal; let relative_vel = b.velocity - a.velocity; let vel_along_normal = relative_vel.dot(n); if vel_along_normal > 0.0 { return; } let e = a.restitution.max(b.restitution); // 或者用乘积,看你的调性 let inv_mass_sum = a.inv_mass + b.inv_mass; if inv_mass_sum == 0.0 { return; // 两个物体都是静态的,不处理 } let j = -(1.0 + e) * vel_along_normal / inv_mass_sum; let impulse = n * j; a.velocity -= impulse * a.inv_mass; b.velocity += impulse * b.inv_mass; }5.2 库仑摩擦:切向冲量的钳制
现实中物体碰撞时不仅有法向的作用力,还有摩擦力。最常见的摩擦模型是库仑摩擦:摩擦力方向与切向相对速度相反,大小不超过μ * 法向力。
在冲量法框架下,我先把法向冲量算出来,然后在切线方向计算一个冲量,并限制它的最大值:
pub fn apply_friction(contact: &Contact, a: &mut RigidBody, b: &mut RigidBody, normal_impulse_magnitude: f32) { let n = contact.normal; let tangent = Vec2::new(-n.y, n.x); // 把法线旋转90度得到切线 let relative_vel = b.velocity - a.velocity; let vel_along_tangent = relative_vel.dot(tangent); // 切向冲量 let inv_mass_sum = a.inv_mass + b.inv_mass; let mut jt = -vel_along_tangent / inv_mass_sum; // 摩擦系数 let mu = (a.friction + b.friction) * 0.5; // 简单取平均 let max_friction = mu * normal_impulse_magnitude; jt = jt.clamp(-max_friction, max_friction); let friction_impulse = tangent * jt; a.velocity -= friction_impulse * a.inv_mass; b.velocity += friction_impulse * b.inv_mass; }这个实现的物理含义是:切向冲量不能无限大,它受到法向压力的限制。这就是为什么摩擦力在物体即将分离时(法向冲量变小)会自动减弱。
摩擦还有一个常见坑:如果接触点的切向速度很小(比如物体静止在地面上),纯冲量摩擦会导致物体微小抖动。在实际项目里,我加了速度阈值判断,只有当切向速度超过某个极小值(比如 0.01 m/s)时才应用摩擦冲量,否则直接把切向速度置零。这做法在游戏里表现更稳定,不会出现物体"发抖"的怪相。
5.3 连续接触与位置修正:避免物体下陷的实用方案
碰撞响应分成两类:一类是两个物体刚刚发生碰撞(冲击碰撞),另一类是物体持续接触并相对静止(比如方块堆叠在地面上)。后者用单个冲量解决不了,因为重力每一帧都在作用,物体刚被推回一点点,下一帧又压进去了,视觉上会出现"物体埋进地面一半"的下陷问题。
处理持续接触的标准做法是迭代求解器。把所有的接触点收集起来,在一个小循环里反复迭代求解多次。每轮迭代都会按当前速度状态修正速度,经过若干轮(通常4~10轮),整体速度趋于收敛。Box2D、Chipmunk都是这么做的。
我之前用纯Rust写了个简化的迭代求解器:
pub fn solve_contacts(contacts: &[Contact], bodies: &mut [RigidBody], iterations: u32) { for _ in 0..iterations { for contact in contacts { // 这里要小心索引问题:contact里存的是body id // 需要先通过id找到bodies里的索引 let (a_idx, b_idx) = find_body_indices(contact); let (a_ptr, b_ptr) = bodies.split_at_mut(a_idx.max(b_idx)); // 借用检查器允许这样拆 // 然后进行法向冲量和摩擦冲量结算 } } }这个代码里的find_body_indices是我封装的一个查询:Contact 里存的是body id,但求解时要用索引访问bodies数组。虽然多了一步查找,但我实测对几十个物理体的场景影响微乎其微,换来的是核心逻辑的清晰和不借用检查器打架。
除了速度迭代,还需要做位置修正(Position Correction)。因为即使速度被修正,已经发生的穿透如果太深,视觉上依然很怪。最常用的位置修正是Baumgarte稳定化,在速度冲刺之后,直接把两个物体的位置沿法线方向推开一定比例:
let percent = 0.2; // 每帧修正20%的穿透 let slop = 0.01; // 容忍的微小穿透量 let correction = (penetration - slop).max(0.0) * percent; a.position -= n * correction * a.inv_mass / inv_mass_sum; b.position += n * correction * b.inv_mass / inv_mass_sum;slop是一个非常重要的经验参数。如果我们要求位置修正完全消除穿透,物体会出现明显的抖动,因为刚修正完下一帧又因为重力轻微穿透。允许一个微小的穿透阈值(通常1-2毫米)让模拟更稳定,就像现实中的柔性接触一样。这个参数太大会让人感觉物体是"浮"在地面上的,太小则会导致抖动,我通常会调到0.01这个量级。
5.4 堆叠稳定性:迭代次数与顺序的影响
模拟方块堆叠是这个项目里最直观的稳定性测试。两三个方块叠在一起,如果迭代次数太少,底部方块会慢慢被压穿;如果迭代次数够多(我一般是10次),就能稳定地堆起来。
迭代顺序也影响结果。我的经验是:先处理法向冲量,再处理摩擦;接触点较多时,从穿透最深的接触点开始处理效果更好。这类似Projected Gauss-Seidel求解器的思路,每轮迭代里接触点之间会互相影响,按某种顺序更新能让收敛速度更快。
另外有个实用技巧:把接触点按照body id排序后,每个接触点的求解只涉及两个刚体,多个互不影响的接触点其实可以并行处理。Rust的rayon库天然适合做这件事,但考虑到项目规模不大,我就没有引入并行。如果你要做上千个接触点的大场景,这是一条重要的优化路径。
6. 把Demo跑起来:验证、调试与后续扩展的真实体验
6.1 复现Demo:方块堆叠与弹跳小球
写完了核心模块,第一个要验证的就是基础场景。我做了两个Demo:
第一个是弹跳小球:一个小球从高处落下,落到地面上弹起,然后逐渐衰减高度。这个场景验证的是重力、积分器和法向冲量是否正确。如果小球弹跳高度逐帧增加,说明积分器写成了显式欧拉——八成是把速度更新放在了位置更新之后但用了旧速度;如果小球穿透地面,说明碰撞检测的穿透深度算错了,或者恢复系数设得太大导致速度修正不足。
第二个是方块堆叠:往地面上放一排方块,再在上面叠几个方块,观察它们是否稳定。这个场景验证的是接触迭代求解器、摩擦力和位置修正。如果方块相互穿插,说明位置修正的slop或percent参数不合理;如果堆叠后一直滑落,说明摩擦系数太低。
我建议你按这个顺序来验证项目:先做单物体自由落体,再做单物体碰撞反弹,再做两个物体的正碰、斜碰,最后再做堆叠。每步都确认符合物理直觉后再往下一步走。跨步骤调试会非常痛苦,因为错误来源不明确。
6.2 调试物理引擎的辅助可视化经验
物理引擎的调试是出了名的难,因为错误通常表现为"看起来不对劲",而不是直接报错。我的经验是必须做辅助可视化,不然在纯数字日志里几乎不可能定位问题。
可视化有两种方案。一种是在游戏引擎/渲染器里画调试图元,比如把碰撞法线画成箭头,把AABB画成矩形框,把接触点画成小圆点。另一种是把物理状态导出成文本或JSON,用脚本分析特定帧的状态变化。我在开发过程中两者都用了。
尤其推荐把"碰撞法线"和"接触点位置"画出来。很多时候物体飞出去的原因不是冲量算错了,而是碰撞法线方向反了——这会让原本应该把物体推开的冲量变成了把物体拉向对方。法线方向看代码很难发现,但一可视化立刻就能看出来。
另一个实用技巧是"帧回放"。我实现了一个简单的回放功能:每一帧记录所有刚体的位置、速度和当前触发的碰撞对。当bug出现时,暂停游戏,倒回几帧,逐帧推进观察物体状态的变化。这个功能对排查"物体突然穿模"这类问题特别有效,比一遍遍重跑然后看日志定位要快得多。
6.3 性能优化方向与参考
轻量级物理引擎在几十个物体的规模下,性能几乎不是问题,但这不代表可以无视性能。我做完Demo后做了一次性能剖析,发现热点集中在两处:碰撞检测的窄相SAT计算和接触求解的迭代循环。
针对SAT,我做了两个优化:一是利用第一帧的分离轴缓存。SAT算法有个特点,上一个时间步找到的分离轴,在当前帧很可能仍然是分离轴。所以我在每个碰撞对的缓存里保存最近一次成功的分离轴,下次检测时先从那个轴开始检查,很多情况下第一轮就能判定不相交,省掉大量投影计算。二是在SAT之前先用AABB快速剔除,如果两个多边形的包围盒都不重叠,直接返回不相交。
这些优化写起来很简单,效果却非常明显。优化前后,我在一个包含100个随机多边形的场景里测试,窄相检测耗时降了大约60%。
6.4 未来扩展方向:从2D到3D、从CPU到GPU
物理引擎这个项目做完2D版本后,扩展方向大概是这几条:
一是引入关节约束。刚体之间的旋转关节、距离关节是物理引擎的重要功能,用于实现布娃娃系统、链条、摇杆等机制。关节本质上是一类额外的约束方程,可以和接触约束一起放到迭代求解器里。我的模块设计里约束求解器已经做成了迭代循环,往里面加新类型的约束只是写新的约束解析代码,不需要改动整个架构。
二是转向3D。2D到3D的跨越比想象中大,因为3D里旋转用四元数表示,角速度从标量变成三维向量,碰撞检测的算法也从多边形变成多面体(凸包)。物理数学库需要大量扩展,但这套"宽相+窄相+约束求解"的架构在3D里完全适用,Box2D到Bullet就是这个思路的延续。
三是性能极限方向。Rust生态里已经有bevy这样的引擎,以及parry、rapier这样的物理库。如果你想继续深挖性能,可以参考rapier的实现思路,它在数据布局、并行化、宽相算法上做了大量优化。我的项目从可读性和教学性的角度更接近box2d-rs的内核。
还有一个值得关注的方向是把物理引擎接到嵌入式/WebAssembly环境里。Rust可以编译成wasm,这套物理引擎如果剪裁得当,完全可以在浏览器里跑一个2D物理沙盒,或者部署到嵌入式设备上做简单的碰撞检测。Rust的跨平台能力让这件事变得异常轻松。
我之前还看到有人在讨论Rust的async生态是否能用于物理引擎的并行化。我的看法是,游戏物理引擎的step通常是同步的、有严格时序的,async更适合处理异步IO或网络同步,直接拿async来做物理并行不太合适。更合理的方案是像前面说的用rayon做数据并行,或者用多线程把不同区域的物理模拟分给不同线程。
这就是我从零实现一个轻量级物理引擎核心模块的全部过程和经验。回看整个项目,最让我觉得值得的是,Rust的类型系统在每一步都在帮你把物理引擎的架构往正确的方向推:借用让数据流清晰,生命周期让资源边界明确,迭代器让碰撞管线可读。这些不是一个独立的"优点",而是贯穿整个开发过程的底层支撑。如果你正在犹豫用Rust做游戏相关开发,或者想深入理解物理引擎的原理,照着这个模块拆解走一遍,你会比看一百篇教程收获都大。