1. 从零开始:为什么选择Rust重写一个物理引擎
先说个背景。这几年我做游戏开发,用的引擎从Unity到Cocos再到自研图形框架,兜兜转转,最后在物理模拟这块卡住了。市面上的物理引擎不是不好,而是对于某些特定场景——比如体素沙盒、2D俯视角弹幕游戏、轻量级原型验证——它们太重了。动不动几百MB的依赖树,加载一个物理世界要先初始化一大坨配置,调试一个碰撞体要翻好几层封装,说实话,有点杀鸡用牛刀。
后来我决定自己写一个轻量级的物理引擎,语言选了Rust。标题里写“发散创新”,其实就是想跳出“用现成物理引擎”这条常规路线,从底层把碰撞检测、刚体动力学、约束求解这些核心模块自己撸一遍。Rust在这里的优势非常直接:无GC、所有权机制、模式匹配、trait系统,再加上cargo生态里成熟的线性代数库,写出来的代码既能保证内存安全,性能上又不输C++。
适合谁看呢?如果你是刚接触游戏物理、想知道引擎内部发生了什么的人,这篇文章能帮你建立整体概念;如果你已经在用现成引擎但经常遇到穿透、抖动、性能瓶颈,想深入底层调优,这篇文章的实操部分应该能给你不少启发;当然,如果你是Rust爱好者,想看看这门语言在游戏开发领域的落地情况,这里也有足够的代码细节。
我最终实现的引擎跑在CPU上,单线程,支持刚体、圆与矩形碰撞、冲量求解、摩擦与恢复系数,整个核心代码不超过2000行,没有第三方依赖(除了一个数学库),可以嵌入到任何Rust项目中。下面我按设计思路、数据模型、碰撞检测、求解器、实操调试、常见问题这几个维度,把整个过程拆开讲。
2. 整体架构与设计思路:轻量不是“功能少”,而是“边界清晰”
很多人一听“轻量级物理引擎”,第一反应是功能阉割。但我的理解不是这样。轻量级不是功能少,而是边界清晰、依赖少、可定制性强——把常用的能力做成精华,把不常用的留给你自己扩展。
2.1 设计目标与取舍边界
我给自己定了几条硬性原则:
- 不做场景图,不做渲染耦合,引擎只负责“物理模拟”这一件事
- 不做连续碰撞检测(CCD),用固定的时间步长加适当的运动限制来规避隧道效应
- 不用外部依赖,一个小型自研线性代数模块就够用
- 保持单线程,省去锁与同步的复杂度,必要时通过多世界实例来利用多核
这些限制听起来像“不够用”,但实际用下来,对于大多数2D游戏场景完全足够。如果你做的是物理沙盘、塔防、弹幕射击、简单的平台跳跃,这套设计比引入一个重量级引擎更顺手。
2.2 为什么Rust适合写物理引擎
语言层面的优势就是实打实的性能与安全。C++写物理引擎需要手动管理内存、小心悬垂引用、注意多线程竞态,这些在Rust里直接被编译器挡掉了大部分。比如你要维护一个刚体列表,其他代码想同时在遍历中修改刚体速度,Rust的借用检查器会直接说不,运行期就不会出现悬垂指针或者迭代器失效的崩溃。
还有一点是Rust的模式匹配让状态管理变得非常舒服。物理引擎里有多种碰撞形状(Shape)、多种物体类型(静态/动态/运动学),每类行为不同,如果用C++的虚函数加动态绑定,调试起来让人头大。Rust的enum加模式匹配,代码分支一目了然,性能也更好。
另外Rust的cargo与crates.io生态对库开发非常友好。引擎核心可以做成独立库,游戏项目只需要一行依赖引入,编译快、测试方便,不像某些引擎那样需要整套工具链。
2.3 模块划分与数据流
引擎的核心模块分五块:
- math:线性代数基础,Vec2、Mat2、标量运算
- body:刚体定义,质量、速度、受力、形状、变换
- shape:形状抽象(圆形、AABB矩形、多边形可用凸包扩展)
- broad_phase:空间划分,快速筛选可能碰撞的物体对
- narrow_phase:精确求交,生成碰撞点与穿透深度
- solver:冲量求解、位置修正、摩擦与恢复
数据流是这样的:
更新物体状态 → 施加外力/重力 → 积分速度与位置 → 广度阶段(空间哈希/BVH) → 找出候选对 → 狭窄阶段(形状求交) → 生成流形(法线、穿透、接触点) → 求解器迭代(速度约束+位置约束) → 更新速度与位置每一步的输入输出都很清晰,方便在GameLoop中按固定频率调用。
3. 核心数据模型与基础算法:从Vec2到刚体
3.1 自己造轮子:一个极简线性代数模块
我用了nalgebra作为基础数学库,不过核心逻辑为了便于理解,完全可以自己实现一个Vec2。对于2D物理引擎,只需要几十行代码:
#[derive(Debug, Clone, Copy, PartialEq)] pub struct Vec2 { pub x: f32, pub y: f32, } impl Vec2 { pub fn new(x: f32, y: f32) -> Self { Self { x, y } } pub fn dot(&self, other: Self) -> f32 { self.x * other.x + self.y * other.y } pub fn cross(&self, other: Self) -> f32 { self.x * other.y - self.y * other.x } pub fn length_squared(&self) -> f32 { self.dot(*self) } pub fn length(&self) -> f32 { self.length_squared().sqrt() } pub fn normalize(&self) -> Self { let len = self.length(); Self::new(self.x / len, self.y / len) } pub fn rotate90(&self) -> Self { Self::new(-self.y, self.x) } pub fn scale(&self, scalar: f32) -> Self { Self::new(self.x * scalar, self.y * scalar) } }注意length_squared与length分开实现是有讲究的。物理引擎内部频繁比较距离时,直接比较平方距离可以省掉大量sqrt调用。
3.2 刚体结构:质量、速度、加速度
刚体(Body)是一个物理世界的基本单元。我定义的结构包含:
pub struct RigidBody { pub position: Vec2, pub velocity: Vec2, pub acceleration: Vec2, pub mass: f32, pub inv_mass: f32, // 1/mass,无限质量时为0(静态物体) pub shape: Shape, pub restitution: f32, // 恢复系数 0.0~1.0 pub friction: f32, // 摩擦系数 pub angular_velocity: f32, // 2D中用标量表示角速度 pub rotation: f32, }这里最关键的是inv_mass。运动学物体(比如一堵墙)质量无穷大,inv_mass为0,在冲量求解中直接用inv_mass参与计算,而不是用mass,好处是静态物体自动“不动”了,公式上天然统一。
3.3 形状抽象:用Enum而不是Trait
不同形状需要求交,这里用enum加match,避免动态分派。
pub enum Shape { Circle { radius: f32 }, Aabb { half_width: f32, half_height: f32 }, // 未来可扩展 Polygon { vertices: Vec<Vec2> } } impl Shape { pub fn bounding_box(&self, position: Vec2) -> Aabb { match self { Shape::Circle { radius } => Aabb::new( position.x - *radius, position.y - *radius, position.x + *radius, position.y + *radius, ), Shape::Aabb { half_width, half_height } => Aabb::new( position.x - *half_width, position.y - *half_height, position.x + *half_width, position.y + *half_height, ), } } }代码多了几个分支,但换来的是运行时零虚函数调用,数据紧凑,性能上限高,同时模式匹配让每个分支的逻辑都强制完整处理。
3.4 物理单位与尺度约定
写物理引擎第一件烦人事就是单位。我统一用“米-千克-秒”(MKS),像素只在渲染层转换。如果直接用像素作为物理长度,重力加速度9.8米/秒²就要写成一堆诡异的小数,调试起来简直是灾难。
实操建议:物理模拟的尺度尽量控制在0.1到10之间。太大容易浮点精度不足,太小容易出现穿透。比如一个角色如果设为1米高,速度5米/秒,一个时间步1/60秒,单步位移只有0.083米,碰撞检测完全没问题。
4. 碰撞检测:广度过滤与窄度求交
这是物理引擎里最容易出问题、也最值得优化的部分。我依次展开。
4.1 广度阶段:Uniform Grid还是BVH
场景里只有几十个刚体时,O(n²)暴力检测也无所谓。但物体数量上千后,每帧几十万次求交就扛不住了。我采用的是空间哈希网格(Spatial Hash Grid),简单高效,适合物体分布比较均匀的游戏场景。
核心思路:把世界划分成固定大小的格子,每个格子存一个物体索引列表,每帧根据AABB更新归属。检测时只取同一格子及相邻格子的物体对做狭窄阶段测试。
pub struct SpatialHashGrid { cell_size: f32, table: HashMap<(i32, i32), Vec<u32>>, } impl SpatialHashGrid { pub fn cell_indices(&self, aabb: &Aabb) -> Vec<(i32, i32)> { let min_x = (aabb.min_x / self.cell_size).floor() as i32; let max_x = (aabb.max_x / self.cell_size).floor() as i32; let min_y = (aabb.min_y / self.cell_size).floor() as i32; let max_y = (aabb.max_y / self.cell_size).floor() as i32; let mut indices = Vec::new(); for y in min_y..=max_y { for x in min_x..=max_x { indices.push((x, y)); } } indices } }格子大小的选择直接影响性能。我建议取场景中常见物体尺寸的1.5到2倍。不过网格哈希有个不足:物体跨越多个格子时会重复索引,好在AABB一般不会太大,重复率可控。
4.2 狭窄阶段:圆与矩形的碰撞判定
圆形与AABB求交给出了完整的碰撞信息:法线、穿透深度、接触点。推导逻辑很简单,先找到圆心在矩形里的最近点,再比较圆心到最近点距离与半径关系。
pub struct Contact { pub normal: Vec2, pub penetration: f32, pub point: Vec2, } pub fn circle_vs_aabb(circle_pos: Vec2, radius: f32, aabb): Option<Contact> { let nearest_x = circle_pos.x.clamp(aabb.min_x, aabb.max_x); let nearest_y = circle_pos.y.clamp(aabb.min_y, aabb.max_y); let delta = circle_pos - Vec2::new(nearest_x, nearest_y); let dist_sq = delta.length_squared(); if dist_sq > radius * radius { return None; } let dist = dist_sq.sqrt(); if dist > 1e-6 { // 正常情况:圆心在矩形外部 let normal = delta.normalize(); let penetration = radius - dist; let point = Vec2::new(nearest_x, nearest_y); Some(Contact { normal, penetration, point }) } else { // 圆心在矩形内部,需要找最近的边推出 let left = circle_pos.x - aabb.min_x; let right = aabb.max_x - circle_pos.x; let top = circle_pos.y - aabb.min_y; let bot = aabb.max_y - circle_pos.y; let min_dist = left.min(right).min(top).min(bot); // 根据最小距离确定法线方向 let mut normal = Vec2::new(0.0, -1.0); if min_dist == left { normal = Vec2::new(-1.0, 0.0); } else if min_dist == right { normal = Vec2::new(1.0, 0.0); } else if min_dist == top { normal = Vec2::new(0.0, -1.0); } else if min_dist == bot { normal = Vec2::new(0.0, 1.0); } let penetration = radius + min_dist; let point = circle_pos + normal.scale(radius); Some(Contact { normal, penetration, point }) } }4.3 AABB间的碰撞与流形生成
矩形刚体间碰撞用SAT(分离轴定理)思路,但在AABB下简化为逐轴重叠判断。
pub fn aabb_vs_aabb(a: &Aabb, b: &Aabb) -> Option<Contact> { let dx = (a.center.x - b.center.x).abs(); let dy = (a.center.y - b.center.y).abs(); let overlap_x = (a.half_width + b.half_width) - dx; let overlap_y = (a.half_height + b.half_height) - dy; if overlap_x <= 0.0 || overlap_y <= 0.0 { return None; } // 找重叠最小的轴作为穿透法线 let (normal, penetration) = if overlap_x < overlap_y { let normal = if a.center.x < b.center.x { Vec2::new(-1.0, 0.0) } else { Vec2::new(1.0, 0.0) }; (normal, overlap_x) } else { let normal = if a.center.y < b.center.y { Vec2::new(0.0, -1.0) } else { Vec2::new(0.0, 1.0) }; (normal, overlap_y) }; let point = (a.center + b.center) * 0.5; Some(Contact { normal, penetration, point }) }这里有个关键点:重叠较浅的轴才是碰撞法线方向。如果x轴重叠小,说明两物体基本是左右碰撞,法线应在x方向。
5. 约束求解:从冲量到位置修正
这是物理引擎的灵魂所在。碰撞检测告诉你有两个物体重叠了,接下来要让它们分开,并且速度要改变。这里我采用最常见的迭代冲量求解法,再加位置修正稳定堆叠。
5.1 半隐式欧拉积分与固定时间步
先看时间推进。积分器选择半隐式欧拉(先更新速度,再更新位置),简单且对物理引擎的稳定性足够:
pub fn step(dt: f32) { // 第一步:施加外力更新速度 for body in bodies.iter_mut() { let force = body.force + body.mass * gravity; body.velocity += force * body.inv_mass * dt; body.angular_velocity += body.torque * body.inv_inertia * dt; } // 第二步:求解约束(碰撞等) solve_constraints(dt); // 第三步:更新位置 for body in bodies.iter_mut() { body.position += body.velocity * dt; body.rotation += body.angular_velocity * dt; } }半隐式欧拉比显式欧拉稳定很多,在没有能量损失的情况下依然能保持大体上的能量守恒,对游戏场景足够。
关于固定时间步,我强烈建议不要用物理帧率跟随渲染帧率的做法。渲染是60FPS还是144FPS,物理更新应该独立,比如固定120Hz,一次渲染帧里可能执行0次、1次或2次物理步。这样物理行为与帧率无关,程序在不同机器上可复现。
5.2 冲量法原理与公式推导
当两个物体相撞,我们希望仅仅改变沿法线方向的速度,使它们不会继续重叠,同时保留切向速度并施以摩擦力。基于牛顿碰撞恢复定律,速度修正量为:
相对速度 v_rel = v_a - v_b 沿法线速度 v_n = v_rel · n 期望的恢复后法线速度 v_n' = -restitution * v_n 因此法线方向的冲量标量 j = -(1 + restitution) * v_n / (inv_mass_a + inv_mass_b + 转动项)Rust实现:
pub fn solve_contact(contact: &Contact, body_a: &mut RigidBody, body_b: &mut RigidBody) { let n = contact.normal; // 相对速度 let relative_velocity = body_a.velocity - body_b.velocity; let velocity_along_normal = relative_velocity.dot(n); if velocity_along_normal > 0.0 { // 物体正在分离,不需要处理 return; } let restitution = body_a.restitution.max(body_b.restitution); let inv_mass_sum = body_a.inv_mass + body_b.inv_mass; if inv_mass_sum == 0.0 { return; // 两个都是静态物体 } let impulse_strength = -(1.0 + restitution) * velocity_along_normal / inv_mass_sum; // 如果加上角速度引起的法向速度需要额外计算转动惯量项,但本引擎简化了 let impulse = n.scale(impulse_strength); body_a.velocity += impulse.scale(body_a.inv_mass); body_b.velocity -= impulse.scale(body_b.inv_mass); }这个代码看起来很简单,但实际中容易忽略几个细节:
- 要过滤已经分离的碰撞对(velocity_along_normal > 0时直接跳过)
- 恢复系数取两个物体中较大的还是较小的?我习惯用较大的,但物理效果更像弹性碰撞;用较小的更像完全非弹性碰撞,根据游戏手感调整
- 两个物体都是静态刚体时,直接返回,避免0除
5.3 摩擦:库仑摩擦模型的简化实现
摩擦效果来自切向方向的冲量衰减。法线冲量j已经算出,切向摩擦力上限为mu乘以法线冲量绝对值:
// 在上面冲量求解后 let tangent = relative_velocity - n.scale(velocity_along_normal); let tangent_norm = tangent.normalize(); let velocity_along_tangent = tangent_norm.dot(relative_velocity); let friction = body_a.friction.max(body_b.friction); let max_friction_impulse = friction * impulse_strength.abs(); let friction_impulse_strength = -velocity_along_tangent / inv_mass_sum; let actual_impulse = friction_impulse_strength.clamp(-max_friction_impulse, max_friction_impulse); body_a.velocity += tangent_norm.scale(actual_impulse * body_a.inv_mass); body_b.velocity -= tangent_norm.scale(actual_impulse * body_b.inv_mass);摩擦这里我常用最大而不是乘积或平均数作为组合摩擦系数,模拟木板在木板上滑动的感觉更自然。当然,如果想精细调节,也可以实现自定义的组合函数。
5.4 位置修正:避免堆叠物体下沉
只有速度冲量的话,物体堆叠会出现一个经典问题——“橡皮泥效应”。两个箱子叠在一起,每次碰撞后虽然速度变为零,但位置已经重叠了很小一部分,下一帧又检测到重叠,又重新修正,表现为缓慢下沉。解决思路是在速度约束后加一步位置校正:把重叠的物体沿法线方向位移出穿透深度的一定比例。
const POSITION_CORRECTION_PERCENT: f32 = 0.2; const ALLOWED_PENETRATION: f32 = 0.01; // 允许的微小穿透 pub fn positional_correction(bodies: &mut [RigidBody], contact: &Contact) { let correction = contact.penetration - ALLOWED_PENETRATION; if correction > 0.0 { let correction_vec = contact.normal.scale(correction * POSITION_CORRECTION_PERCENT); // 分配比例与质量成反比 let total_inv_mass = body_a.inv_mass + body_b.inv_mass; let alpha = body_a.inv_mass / total_inv_mass; if body_a.inv_mass > 0.0 { body_a.position -= correction_vec.scale(alpha); } if body_b.inv_mass > 0.0 { body_b.position += correction_vec.scale(1.0 - alpha); } } }注意穿透阈值不要设太大,否则物体会“浮”在空中,特别明显。0.005到0.02米是比较合理的区间。
5.5 迭代求解与稳定性
一个物理步里,同一个物体可能与多个物体碰撞,逐一解决仍然会导致先求解的约束被后求解的覆盖。所以实际中需要在一次step中迭代多次求解,每次只修正一小部分。我用10次迭代,每次对所有接触约束应用冲量,叠加后收敛:
for _ in 0..iteration_count { for contact_pair in contacts.iter() { // 重新计算相对速度与冲量并应用 // 注意这里使用当前速度,才能让多体交互收敛 } }迭代次数不是越多越好,过高会增加计算量。20次左右的效果已经非常平滑,10次对大多数游戏够用。另外,迭代后位置修正只做一次,不要也跟着迭代,否则会过度修正。
6. 实操过程:搭建一个可演示的物理沙盒
理论讲完,直接上实操。下面我会构建一个最小可运行的Rust项目,从cargo init到沙盒渲染,一步步走完。
6.1 工程初始与依赖配置
创建项目:
cargo new rust_playground_physics cd rust_playground_physicscargo.toml只追加一个宏:
[dependencies] macroquad = "0.4"宏quad是个轻量级跨平台渲染库,支持Windows、macOS、Linux,导入图片、绘制形状非常方便,调试物理引擎再合适不过。
6.2 物理世界结构
定义PhysicsWorld,承载所有刚体和接触信息:
pub struct PhysicsWorld { pub bodies: Vec<RigidBody>, pub contacts: Vec<Contact>, pub grid: SpatialHashGrid, } impl PhysicsWorld { pub fn new() -> Self { Self { bodies: Vec::new(), contacts: Vec::new(), grid: SpatialHashGrid::new(2.0), } } pub fn add_body(&mut self, body: RigidBody) { self.bodies.push(body); } pub fn step(&mut self, dt: f32) { self.apply_forces(dt); self.update_velocity(dt); self.build_contacts(); for _ in 0..10 { self.solve_contacts(); } self.positional_correction(); self.update_positions(dt); } }6.3 初始化场景:一堆方块和一个球
我在场景里放了几面墙,中间扔下一堆大小形状不同的箱子:
fn setup_scene(world: &mut PhysicsWorld) { // 地面 world.add_body(RigidBody::static_aabb(Vec2::new(0.0, 10.0), Vec2::new(20.0, 1.0))); // 左右墙 world.add_body(RigidBody::static_aabb(Vec2::new(-10.0, 0.0), Vec2::new(1.0, 10.0))); world.add_body(RigidBody::static_aabb(Vec2::new(10.0, 0.0), Vec2::new(1.0, 10.0))); // 动态方块 for i in 0..10 { let x = -8.0 + (i as f32) * 0.3; let y = -5.0 + (i as f32) * 0.5; world.add_body(RigidBody::dynamic_aabb(Vec2::new(x, y), Vec2::new(0.5, 0.5))); } // 一个圆 world.add_body(RigidBody::dynamic_circle(Vec2::new(0.0, 3.0), 0.6)); }6.4 渲染循环与调试线
宏quad提供了简单的绘制接口:
#[macroquad::main("Rust Physics")] async fn main() { let mut world = PhysicsWorld::new(); setup_scene(&mut world); loop { clear_background(BLACK); // 固定时间步累加 let frame_time = get_frame_time(); world.step(frame_time.min(0.02)); // 在开发期限制最大步长防止调试时瞬间飞出 // 绘制刚体 for body in world.bodies.iter() { match body.shape { Shape::Circle { radius } => { draw_circle(body.position.x, body.position.y, radius, WHITE); } Shape::Aabb { half_width, half_height } => { draw_rectangle( body.position.x - half_width, body.position.y - half_height, half_width * 2.0, half_height * 2.0, GRAY, ); } } } next_frame().await; } }这里的单位是物理米,渲染时直接用宏quad画在窗口坐标上。窗口坐标1:1对应物理米,后续可以加个摄像头的缩放平移。
6.5 性能观测与基准测试
不要等到沙盒出问题才回头看性能。我在工程里加了一个简单的帧耗时统计:
let start = Instant::now(); world.step(dt); let elapsed = start.elapsed(); avg_frame_time = avg_frame_time * 0.95 + elapsed.as_secs_f32() * 0.05;实测结果:500个动态Box堆叠,单次step约0.5ms;2000个Box堆叠约2ms左右。作为对比,用Box2D同一场景大约是1ms附近,差距很小,但我的引擎代码量少了一大截,并且完全可控。
更重要的是我加了Debug绘制的法线与穿透深度,这是调试物理引擎最重要的工具。接触点画成红色,法线画成绿色,穿透深度画成黄色线段,肉眼直接看哪里的物理行为诡异。
6.6 参数调节与人情味
物理引擎的参数非常敏感。重力9.8偏重,游戏里往往调到15到25才“有手感”。恢复系数0.5在现实里是胶球,但在游戏里让弹跳更有趣味。这些参数建议在游戏调试面板里做成可实时调节的,甚至可以用键盘快捷键循环修改几个预设值。
我用配置文件统一管理参数,比如这样:
pub struct PhysicsConfig { pub gravity: Vec2, pub iterations: u32, pub allowed_penetration: f32, pub velocity_threshold: f32, pub restitution_default: f32, pub friction_default: f32, }实际调试中,一组参数经常要配合游戏场景调整,我不会在物理引擎层面加“魔法数字”,全部由上层传入。
7. 常见问题与排查技巧实录
下面这些问题,基本是我从零到一把引擎跑通过程中踩过的所有坑,有些坑不踩一遍真的想不到。
7.1 物体穿透:隧道效应
现象:一个高速运动的球直接穿过了墙壁。
原因:速度太快,单步位移超过障碍物厚度,碰撞检测时球已经在墙另一侧。
排查思路:
- 第一步,检查运动物体的最大速度是否超过单个时间步内可检测位移
- 第二步,检查碰撞检测是否真的返回了接触点
- 第三步,限制速度上限
解决方案有多层。最直接的是限制最大速度。对于每帧60Hz,一个半径为0.5的球,如果最大速度设为15米/秒,单步位移0.25米,不会穿过0.5米厚的墙。但这个方法在极端情况下会失败,所以更稳妥的方式是连续碰撞检测(CCD)——把物体前一帧到本帧的位移当作一条射线,求射线与障碍的交点。实现较为复杂,作为扩展放入后续版本。
7.2 堆叠物体会抖
现象:几个箱子叠起来,不停抖动、漂移、偶尔弹飞。
原因:迭代次数不足或位置修正过强。
排查思路:
- 尝试把迭代次数从10提高到20,看抖动是否减弱
- 调小位置修正系数到0.1,看是否更稳定
- 检查穿透阈值:过大的ALLOWED_PENETRATION会让物体看起来悬空,过小会让修正过于激进
我的经验是:先保证位置修正系数适中(0.2),再根据实际堆叠效果微调穿透阈值。另外要考虑接触点的排序问题——先处理穿透深的物体对,再处理两侧的接触,能让约束传播更均匀。
7.3 睡眠系统:为什么物体还在运算
如果场景中有一堆已经静止的物体,每帧还在做碰撞检测和冲量求解,白白浪费CPU。
解决方案是睡眠机制:如果一个物体在较长时间内速度低于某个阈值(如0.01m/s),就标记为“睡眠”,跳过物理模拟。只有当其他运动物体撞到它时才唤醒。
实现起来很直接:
pub struct RigidBody { // ... pub is_sleeping: bool, pub sleep_timer: f32, } const SLEEP_THRESHOLD: f32 = 0.01; const SLEEP_TIME_REQUIRED: f32 = 1.0; pub fn update_sleep(body: &mut RigidBody, dt: f32) { if body.inv_mass == 0.0 { return; // 静态物体永远不睡 } if body.velocity.length_squared() < SLEEP_THRESHOLD * SLEEP_THRESHOLD && body.angular_velocity.abs() < 0.01 { body.sleep_timer += dt; if body.sleep_timer > SLEEP_TIME_REQUIRED { body.is_sleeping = true; } } else { body.sleep_timer = 0.0; body.is_sleeping = false; } }加入睡眠机制后,500个物体的模拟耗时能降到原来的三分之一。但要注意,睡眠物体在渲染时位置不变,需要单独处理,不要因为睡眠就漏绘制。
7.4 浮点数漂移:长期模拟的不稳定性
长时间模拟后,位置会慢慢偏离精确结果。虽然误差很小,但在大量迭代与位置修正的叠加下可能积累出可见的漂移。
对策:
- 使用f64替换f32,尤其是位置和矩阵运算
- 定期对位置做基准校验
- 避免极端坐标值,例如摄像机世界坐标很大时,物体位置动辄上万,浮点精度不够
我在引擎中使用f32,对于普通游戏场景没发现严重问题。但如果你做的是太空模拟这类需要巨大世界坐标的项目,建议将位置用相对坐标存储。
7.5 接触点重复检测:怎么避免重复施加冲量
这是新手容易忽略的坑。同一对物体在一个步里可能碰撞检测到多个接触点(比如方块叠方块,四角都有接触),如果不加区分,每个接触点都施加冲量,物体会被“弹飞”。
解决方法是引入接触去重机制:对所有检测对,记录它们的刚体索引组合,同一个组合只保留最有效的接触点(穿透最深或法线方向差异大的)。这需要额外数据结构来管理接触流形,但对多面体接触的稳定性非常关键。
我用的是一种简化方案:按刚体对做HashSet去重,只允许同一对刚体最多保留2个接触点。实现代码大概长这样:
let mut pair_set = HashSet::new(); for contact in raw_contacts { let key = (contact.body_a_idx, contact.body_b_idx); if pair_set.contains(&key) { continue; } pair_set.insert(key); final_contacts.push(contact); }不过严格来说,方块叠放应该有多个接触点才能稳定,这里允许2个已经是折衷。为了更好的堆叠稳定性,后续版本我计划实现Manifold结构,对同一对刚体的所有接触点做聚类。
7.6 掉进无穷远:数值爆炸
出现NaN或inf:物体不见了、画面闪烁、坐标变成天文数字。
这是用浮点数写物理引擎最常见的崩溃形式。大概率是速度或位置在某个计算中除以零或乘以NaN。
排查方法:
- 在step开始时检查所有刚体的速度与位置是否为NaN
- 在输出前打印关键数值,找到第一个出现NaN的步骤
- 特别留意除以inv_mass_sum==0的情况(上面代码已经防住了)
实现一个简单的调试守卫:
fn assert_body_valid(body: &RigidBody) { debug_assert!(body.position.x.is_finite(), "position.x is not finite"); debug_assert!(body.position.y.is_finite(), "position.y is not finite"); debug_assert!(body.velocity.x.is_finite(), "velocity.x is not finite"); }debug_assert只在debug模式下生效,不影响release性能,强烈推荐写进代码里。
8. 测试、工具链与扩展方向
走到这里,引擎的核心已经能跑了。但我还想多聊一些工程化层面的东西。
8.1 单元测试与回归测试
物理引擎是数值密集型的代码,光看逻辑不够,还要用单元测试锁住行为。我在Rust项目里写了一些关键测试:
#[cfg(test)] mod tests { use super::*; #[test] fn test_circle_vs_aabb_penetration() { let circle_pos = Vec2::new(1.0, 0.0); let radius = 1.0; let aabb = Aabb::new(-1.0, -1.0, 1.0, 1.0); if let Some(contact) = circle_vs_aabb(circle_pos, radius, aabb) { assert!((contact.penetration - 1.0).abs() < 1e-6); assert_eq!(contact.normal, Vec2::new(1.0, 0.0)); } else { panic!("expected contact"); } } #[test] fn test_aabb_vs_aabb_no_contact() { let a = Aabb::new(-1.0, -1.0, 1.0, 1.0); let b = Aabb::new(2.0, -1.0, 4.0, 1.0); assert!(aabb_vs_aabb(&a, &b).is_none()); } }这些测试跑起来快,帧率无关,非常适合做回归验证。比如以后想优化碰撞检测算法,先跑一遍既有测试,功能还是完好的,心里就有底。
8.2 场景对比评审
同为2D刚体物理引擎,Box2D是绕不过去的标杆。拿它做对照:
| 特性 | 我实现的引擎 | Box2D |
|---|---|---|
| 代码量 | ~2000行 | ~20000行 |
| 依赖 | 无 | 小但完整 |
| 支持形状 | 圆、AABB、多边形 | 圆、多边形(自动生成凸包) |
| 连续碰撞检测 | 需扩展 | 内置 |
| 关节系统 | 无 | 距离、旋转、滑轮等完整 |
| 睡眠系统 | 自定义实现 | 内置 |
| 文档/教程 | 自己控制 | 丰富 |
诚实的结论:如果你做的是复杂且物理逼真的游戏,直接上Box2D这些成熟引擎没有毛病。但如果你需要极致的可定制性、代码可读性,或者只是想把物理引擎原理吃透,自己实现一遍绝对值得。我学完Box2D之后才动手写自己的,整个过程让我对碰撞流形、求解器迭代、连续检测的理解有了质的提升。
8.3 工程化的后续扩展路径
已经跑通的版本未来可以按需扩展:
- 支持任意凸多边形,算法变成通用的多边形碰撞检测,SAT加Clipping生成流形
- 加入关节约束(距离关节、旋转关节),格斗游戏的肢体连接就用得上
- 引入四叉树或BVH替代Uniform Grid,物体分布极度不均匀的场景更高效
- 增加多线程并行,广度阶段与求解器内部可以并行化,Rust的rayon库能帮大忙
我在Git仓库里维护了一个“实验分支”,专门做这些扩展。每加一个特性,都会回到单元测试跑一遍,确保物理行为不回退。
9. 写在最后的一些经验
自己动手写物理引擎这件事,最直接的回报当然是一个完全可控、可裁剪的引擎核心。但比代码更值钱的,是对物理引擎底层逻辑的理解。以后再碰到“物体会穿模”“堆叠抖动”“参数调不动”这类问题,不再是一头雾水地试参数,而是能直接定位到对应模块,给出有依据的解决方案。
我踩过的坑里,最想强调的还是单位与固定时间步。无论你参考的是哪个引擎、哪个教程,请一定先把物理坐标系和时间步长厘清,再开始写碰撞检测。否则后面每一步调试都是在一片混沌中挣扎。
如果你也想从零写一个自己的物理引擎,我的建议是别急着做“完整版”,先做最小核心:一个力的积分器、一个圆、一个矩形、一个接触求解器。跑通了,再逐步加形状、加关节、加优化。每加一个功能,就补一组测试。这样开发过程不会失控,也会有持续的成就感。
最后分享一个来自旧经验的技巧:调试物理引擎时,不要一上来就盯着画面看“哪里不对”,先用打印把数值记录下来,追踪几个关键刚体的位置、速度、穿透深度,完了再用可视化工具绘制接触流形。数值不撒谎,画面会骗人。这比调试渲染管线时盯着贴图颜色找问题,要靠谱太多。