用Rust手写轻量级2D物理引擎:碰撞检测与约束求解实战
最近在做一个小的游戏项目碰撞检测和物理响应的效果一直不理想。要么是物体直接穿透要么是堆叠时抖得跟筛子似的。后来干脆自己动手写了一个轻量级的2D物理引擎用Rust实现。之所以选Rust一开始纯粹是想练练这门语言但越写越发现Rust的所有权、零成本抽象、模式匹配这些特性跟物理引擎的诉求匹配度非常高。这篇文章就是我自己从零实现一个物理引擎核心模块的完整复盘包括设计思路、数据结构和关键算法以及踩过的几个坑。适合刚入门Rust、想做一个能跑的Demo的读者也适合想做小型2D游戏、又不想直接引入Box2D这种重量级方案的朋友。我尽量把每个“为什么这么写”都讲清楚而不只是贴代码。1. 整体设计与思路拆解1.1 物理引擎的痛点与Rust的解题思路一个物理引擎最底层的诉求就两件事检测物体之间的碰撞然后计算碰撞之后物体应该怎么动。听起来简单但实际做起来会发现这两个问题各自都有一堆数学和工程上的坑。碰撞检测要考虑效率不能每帧对所有物体两两判断碰撞响应要保证稳定性不能让两个物体越嵌越深也不能一碰就飞出去。Rust在这件事上有几个天然优势。第一个是所有权和借用机制物理引擎里有大量共享数据比如一个刚体同时要被碰撞检测和速度积分访问在C里你可能会用裸指针或者shared_ptr稍不注意就悬垂或者循环引用。Rust的编译器直接把这个问题的复杂度降下来了数据归属搞不清楚就编译不过。第二个是零成本抽象trait和泛型在运行时不引入额外开销枚举加模式匹配非常适合描述不同类型的碰撞体。第三个是测试和调试体验物理引擎的bug多半是边界条件导致的cargo test写起来顺手配合assert能很快锁定问题。我个人觉得写物理引擎是练Rust所有权和工程组织能力最好的项目之一因为它的模块边界清晰数据流明确比写CRUD接口有意思多了。1.2 “轻量级”到底轻在哪说到轻量级很多人的第一反应是代码量少或者依赖少。这两点当然算但更核心的是算法层面的取舍。我做的是2D物理引擎直接砍掉了3D引擎普遍需要的四元数、矩阵栈、凸包三角化这些重量级模块。刚体只有平移和绕质心旋转两个自由度碰撞体只支持圆和AABB轴对齐矩形碰撞检测只用到了空间哈希和基本几何公式没有任何BVH树这种复杂的加速结构。这个取舍带来的直接成果是核心代码只有几百行单帧物理计算在几千个物体时依然能保持在毫秒级。如果你以后想做3D或者需要多边形碰撞这个轻量级的基础架构依然可以作为起点只是在Shape枚举里加变体在检测函数里加分支即可。先跑通核心逻辑再逐步扩展是最不容易劝退的开发路径。2. 核心数据结构与数学基础2.1 二维向量物理引擎的地基物理引擎里90%的运算都是向量运算。位置是向量速度是向量冲量是向量法向量也是向量。所以第一步就是先把二维向量这个基础类型写好。Rust里给基础类型实现运算符重载非常方便std::ops包里的Add、Sub、Mul这些trait一实现代码的可读性会提升一个档次。use std::ops::{Add, Sub, Mul, AddAssign}; #[derive(Debug, Clone, Copy, Default, 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 zero() - Self { Self { x: 0.0, y: 0.0 } } 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_sq(self) - f32 { self.x * self.x self.y * self.y } pub fn length(self) - f32 { self.length_sq().sqrt() } pub fn normalized(self) - Self { let len self.length(); if len f32::EPSILON { Self::zero() } else { Self::new(self.x / len, self.y / len) } } } impl Add for Vec2 { type Output Self; fn add(self, rhs: Self) - Self { Self::new(self.x rhs.x, self.y rhs.y) } } impl Sub for Vec2 { type Output Self; fn sub(self, rhs: Self) - Self { Self::new(self.x - rhs.x, self.y - rhs.y) } } impl Mulf32 for Vec2 { type Output Self; fn mul(self, rhs: f32) - Self { Self::new(self.x * rhs, self.y * rhs) } } impl AddAssign for Vec2 { fn add_assign(mut self, rhs: Self) { self.x rhs.x; self.y rhs.y; } }这个实现看起来简单但有几个细节值得注意。零向量归一化时返回零向量这里用f32::EPSILON做阈值判断。在实际物理模拟中速度极小的情况经常出现如果不做这个保护会出现NaN然后整个模拟就崩了。另一个细节是默认实现了Copy向量是值类型在Rust里复制一份开销极小用Copy会让后续代码少写很多.clone()。2.2 刚体与碰撞体的设计刚体是物理引擎中参与模拟的实体它的核心字段包括位置、速度、质量、恢复系数以及一个碰撞体。这里有个经典的设计选择碰撞体到底用枚举还是traitRust社区里两种方案都有讨论。trait方案更符合面向对象的直觉每个碰撞体类型实现自己的get_aabb和support_point方法但随之而来的是运行时动态分发的开销以及到处都要处理的Boxdyn Shape。枚举方案把选项限定在编译期配合match表达式做分发性能和可读性都更好。轻量级引擎里枚举就是更合适的选择。#[derive(Debug, Clone, Copy)] pub enum Shape { Circle { radius: f32 }, Aabb { half_extents: Vec2 }, } pub struct RigidBody { pub position: Vec2, pub velocity: Vec2, pub mass: f32, pub inv_mass: f32, pub restitution: f32, pub shape: Shape, } impl RigidBody { pub fn new(shape: Shape, position: Vec2, mass: f32) - Self { let inv_mass if mass 0.0 { 0.0 } else { 1.0 / mass }; Self { position, velocity: Vec2::zero(), mass, inv_mass, restitution: 0.5, shape, } } }这里把inv_mass提前算好存下来而不是每次用到的时候现算主要是为了约束求解阶段的性能。物理引擎的约束求解器会在每帧里迭代很多次每次都触碰刚体的质量提前算好倒数能省不少除法。inv_mass为0表示这个物体是静态的比如地面和墙壁。这是物理引擎里非常通用的技巧静态物体在后续的碰撞响应中不会被移动但能推走动态物体。3. 碰撞检测从粗筛到精测3.1 Broad Phase空间哈希网格碰撞检测如果直接对世界里的所有物体两两判断复杂度是O(n²)。当物体数量到几百个时每帧光做检测就会吃掉大量CPU时间。所以我第一步就做了Broad Phase用空间哈希把候选碰撞对筛出来。空间哈希的思想很简单把整个世界划分成固定大小的网格每个物体根据它占用的格子被放进对应的桶里。只有落在同一个格子的物体才可能碰撞只需要对同一个格子的物体对做精确检测。use std::collections::HashMap; pub struct SpatialHash { cell_size: f32, grid: HashMap(i32, i32), Vecu32, } impl SpatialHash { pub fn new(cell_size: f32) - Self { Self { cell_size, grid: HashMap::new(), } } pub fn insert(mut self, id: u32, aabb: Aabb) { 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; for cy in min_y..max_y { for cx in min_x..max_x { self.grid.entry((cx, cy)).or_default().push(id); } } } pub fn get_potential_pairs(self) - Vec(u32, u32) { let mut pairs vec![]; let mut seen std::collections::HashSet::new(); for bucket in self.grid.values() { for i in 0..bucket.len() { for j in (i 1)..bucket.len() { let key if bucket[i] bucket[j] { (bucket[i], bucket[j]) } else { (bucket[j], bucket[i]) }; if seen.insert(key) { pairs.push(key); } } } } pairs } }这里的cell_size选择很关键。太小了会导致一个物体横跨很多格子插入开销变大太大了又会导致broad phase筛不掉多少对。经验值是取场景中最大物体尺寸的1到2倍。用HashMap和(i32, i32)做格子键实现简单性能也够用。3.2 Narrow Phase圆与AABB的碰撞判定过了broad phase的候选对接下来要做精确的几何检测。这里针对三种组合分别实现圆对圆、圆对AABB、AABB对AABB。圆对圆最简单看两个圆心距离是否小于半径之和。pub struct Contact { pub normal: Vec2, pub penetration: f32, pub contact_point: Vec2, } pub fn circle_circle(a: RigidBody, b: RigidBody) - OptionContact { if let (Shape::Circle { radius: ra }, Shape::Circle { radius: rb }) (a.shape, b.shape) { let delta b.position - a.position; let dist_sq delta.length_sq(); let max_dist ra rb; if dist_sq max_dist * max_dist { return None; } let dist dist_sq.sqrt(); let normal if dist 0.0 { delta * (1.0 / dist) } else { Vec2::new(1.0, 0.0) }; let penetration max_dist - dist; let contact_point a.position normal * (ra - penetration * 0.5); Some(Contact { normal, penetration, contact_point, }) } else { None } }一个比较容易忽略的点当两个圆心完全重合时dist是0直接除以0会得到NaN。我在代码里做了处理此时给默认法向量(1,0)。这种情况在真实模拟中不常见但一旦发生没有保护就会让整个物理世界崩掉。圆对AABB的检测用的是“找到圆在AABB上的最近点然后计算距离”的思路。这个最近点就是把圆的x坐标clamp到AABB的min.x和max.x之间把y坐标clamp到min.y和max.y之间。如果距离小于半径说明圆和矩形相交法向量从最近点指向圆心。AABB对AABB更直接区间重叠判断重叠量小的方向就是分离方向。pub fn aabb_aabb(a: RigidBody, b: RigidBody) - OptionContact { if let (Shape::Aabb { half_extents: he_a }, Shape::Aabb { half_extents: he_b }) (a.shape, b.shape) { let dx b.position.x - a.position.x; let dy b.position.y - a.position.y; let overlap_x he_a.x he_b.x - dx.abs(); let overlap_y he_a.y he_b.y - dy.abs(); if overlap_x 0.0 || overlap_y 0.0 { return None; } let normal; let penetration; if overlap_x overlap_y { penetration overlap_x; normal if dx 0.0 { Vec2::new(1.0, 0.0) } else { Vec2::new(-1.0, 0.0) }; } else { penetration overlap_y; normal if dy 0.0 { Vec2::new(0.0, 1.0) } else { Vec2::new(0.0, -1.0) }; } let contact_point Vec2::new( if overlap_x overlap_y { if dx 0.0 { a.position.x he_a.x } else { a.position.x - he_a.x } } else { b.position.x }, if overlap_y overlap_x { if dy 0.0 { a.position.y he_a.y } else { a.position.y - he_a.y } } else { b.position.y } ); Some(Contact { normal, penetration, contact_point }) } else { None } }在重叠x和重叠y相等的时候需要约定一个统一的分支。我在overlap_x overlap_y和时都走x轴分支避免了两个物体在角落位置时法向量来回横跳的抖动问题。4. 动力学求解与约束4.1 半隐式欧拉积分碰撞检测算出了接触信息但物体怎么运动要由积分器来驱动。常见的选择有显式欧拉、半隐式欧拉和Verlet。显式欧拉先更新位置再更新速度实现简单但能量不守恒模拟一把就会越跳越高或者越转越快。半隐式欧拉先更新速度再用新速度更新位置数值稳定性好很多实现也不复杂是轻量级引擎的首选。pub fn step(body: mut RigidBody, gravity: Vec2, dt: f32) { if body.inv_mass 0.0 { return; } body.velocity gravity * dt; body.position body.velocity * dt; }dt的选择直接影响模拟效果。固定时间步长是必须的否则会出现物理表现跟帧率绑定、不同帧率下速度和位移不一致的问题。我习惯用60Hz的固定步长也就是dt1/60然后配合累加器把渲染帧率解耦。具体做法是主循环里每帧计算真实流逝的时间累加到accumulator里然后循环执行step直到accumulator耗尽。这样一个渲染帧可能执行0次、1次或多次物理step但每个step的dt始终是1/60物理表现的确定性有保证。4.2 顺序冲量法与接触约束求解碰撞响应是整个引擎里最核心、也最容易出问题的地方。我采用的是2D物理引擎里应用最广泛的顺序冲量法Sequential ImpulsesBox2D使用的也是这个思路的变体。核心思想是逐对处理接触点每次计算一个冲量并立即应用然后反复迭代多轮让结果收敛。基本原理来自牛顿第二定律的冲量形式。碰撞时两个物体在接触点法线方向上的相对速度必须发生改变才能让它们不再相互穿透。设法线为n碰撞前相对速度为v物理上期望碰撞后的相对速度是-e * v其中e是恢复系数0表示完全非弹性碰撞1表示完全弹性碰撞。对于两个物体的情况法线方向上的冲量大小为j -(1 e) * v_n / (inv_mass_a inv_mass_b)其中v_n是两个物体相对速度在法线方向上的分量。分子的负号是因为我们要抵消穿透方向上的运动。fn apply_impulse(a: mut RigidBody, b: mut RigidBody, contact: Contact, inv_mass_sum: f32) { let rel_vel b.velocity - a.velocity; let vel_along_normal rel_vel.dot(contact.normal); if vel_along_normal 0.0 { return; } let e a.restitution.min(b.restitution); let j -(1.0 e) * vel_along_normal / inv_mass_sum; let impulse contact.normal * j; a.velocity - impulse * a.inv_mass; b.velocity impulse * b.inv_mass; }注意那个vel_along_normal 0.0的判断。在堆叠场景里上一轮迭代可能已经让物体有分离趋势了如果此时还继续施加冲量会把物体往反方向推造成抖动。这个判断就是只对“正在接近”的接触施加冲量已经分离的就不再处理。迭代次数也很讲究。迭代次数太少堆叠的物体看起来是软的会被慢慢压下去迭代次数太多性能下降。我实测下来8到10次是质量和性能的平衡点。如果想更精细可以按“先求解速度约束再求解位置约束”的方式做两轮不过轻量级引擎里10次迭代已经能出比较体面的效果了。4.3 位置修正与穿透的兜底即使有了冲量求解器物体在极端情况下仍然可能嵌入。比如帧率突变、物体速度极大、或者多个物体叠在一起时迭代次数不足。位置修正就是专门的兜底方案。我采用的是一种简化版的Baumgarte稳定化方法。原理很简单如果检测到穿透深度大于0就把两个物体沿着法线方向推开推开量等于一个修正比例乘以穿透深度避免超调。pub fn positional_correction(a: mut RigidBody, b: mut RigidBody, contact: Contact) { let inv_mass_sum a.inv_mass b.inv_mass; if inv_mass_sum 0.0 { return; } let percent 0.8; let correction contact.normal * (contact.penetration * percent / inv_mass_sum); a.position - correction * a.inv_mass; b.position correction * b.inv_mass; }percent取0.8的意思是这个修正每次消除80%的穿透而不是完全消除。这样避免了数值振荡也让堆叠场景看起来更自然。如果穿透特别深可以加一个最大修正距离的钳制防止物体被瞬移。这里我再强调一个原理解释位置修正在物理引擎里不是用来代替速度冲量的它是一个辅助手段。如果物体每帧都穿透你应该回来检查速度约束求解器的参数而不是调大percent。我在调试时遇到过这种情况加了位置修正之后看起来正常了但物体之间的碰撞响应物理上并不正确堆叠看起来软绵绵的。5. 完整工程从零跑通Demo5.1 模块组织与依赖管理整个引擎我按功能拆成了三个模块math、body、world。math放向量和AABBbody放刚体、碰撞体和接触信息world放空间哈希、碰撞检测循环、积分器和约束求解器。测试文件跟着模块走每个模块都有针对性的单元测试。Cargo.toml里核心依赖只有rand一个用于Demo场景里随机初始化物体位置。其他全部用的是标准库。这样编译速度快依赖树干净也能更清楚地看到每一行物理代码在干什么。[package] name tiny-physics version 0.1.0 edition 2021 [dependencies] rand 0.8main.rs里做了一个简单的文本可视化用#表示静态地板用o表示圆用[和]表示AABB每一帧打印一次平面图按任意键推进一帧。虽然简陋但调试物理引擎已经足够了。以后再接SDL2或者minifb做真正的图形界面就可以在此基础上扩展。5.2 World主循环实现World是整个引擎的协调者把所有模块串联起来。完整的step流程分四步清空上一帧的碰撞对、构建空间哈希、执行broad phase和narrow phase、积分速度和应用约束。pub struct World { pub bodies: VecRigidBody, pub gravity: Vec2, pub iterations: u32, } impl World { pub fn new(gravity: Vec2) - Self { Self { bodies: vec![], gravity, iterations: 10, } } pub fn add_body(mut self, body: RigidBody) { self.bodies.push(body); } pub fn step(mut self, dt: f32) { let mut contacts vec![]; for body in self.bodies.iter_mut() { if body.inv_mass 0.0 { body.velocity self.gravity * dt; } } let mut spatial_hash SpatialHash::new(2.0); for (i, body) in self.bodies.iter().enumerate() { let aabb body_aabb(body); spatial_hash.insert(i as u32, aabb); } let pairs spatial_hash.get_potential_pairs(); for (i, j) in pairs { let (a, b) split_bodies(mut self.bodies, i as usize, j as usize); if let Some(contact) detect_collision(a, b) { contacts.push((i as usize, j as usize, contact)); } } for _ in 0..self.iterations { for (i, j, contact) in contacts { let (a, b) split_bodies(mut self.bodies, *i, *j); let inv_mass_sum a.inv_mass b.inv_mass; if inv_mass_sum 0.0 { apply_impulse(a, b, contact, inv_mass_sum); } } } for body in self.bodies.iter_mut() { body.position body.velocity * dt; } for (i, j, contact) in contacts { let (a, b) split_bodies(mut self.bodies, *i, *j); positional_correction(a, b, contact); } } }这一步最容易遇到Rust的借用检查问题对self.bodies同时做不可变借用读碰撞体信息和可变借用改速度时编译器会毫不留情地报错。我用了一个辅助函数split_bodies利用slice::split_at_mut把两个需要修改的刚体安全地分离出来。fn split_bodies(bodies: mut [RigidBody], i: usize, j: usize) - (mut RigidBody, mut RigidBody) { if i j { let (left, right) bodies.split_at_mut(j); (mut left[i], mut right[0]) } else { let (left, right) bodies.split_at_mut(i); (mut right[0], mut left[j]) } }如果你是从C或者Python转过来的这段代码可能需要适应一下。但在Rust里编译器逼着你把数据访问路径理清楚只要编译过了运行时基本不会出现数据竞争或者悬垂引用的问题。5.3 初始化一个Demo场景为了验证引擎效果我初始化了一个简单场景底部放一个静态的大矩形当地面地面上放一个静态的小矩形当台阶然后在空中随机位置放20个不同的圆和矩形让它们做自由落体并发生碰撞。let mut world World::new(Vec2::new(0.0, -9.8)); let ground RigidBody::new( Shape::Aabb { half_extents: Vec2::new(20.0, 1.0) }, Vec2::new(0.0, -10.0), 0.0, ); world.add_body(ground); let mut rng rand::thread_rng(); for _ in 0..20 { let shape if rng.gen_bool(0.5) { Shape::Circle { radius: rng.gen_range(0.2..0.8) } } else { Shape::Aabb { half_extents: Vec2::new(rng.gen_range(0.3..0.7), rng.gen_range(0.3..0.7)) } }; let pos Vec2::new(rng.gen_range(-8.0..8.0), rng.gen_range(5.0..15.0)); world.add_body(RigidBody::new(shape, pos, 1.0)); }跑起来之后可以看到物体在重力作用下下落、和地面碰撞、反弹、堆叠。最让我惊喜的是当把迭代次数从2调到10之后堆叠稳定性肉眼可见地变好物体不再互相嵌入或者堆着堆着就塌了。这个Demo足够说明引擎的核心模块是能正常工作的。6. 常见问题与排查经验6.1 物体穿透不仅是碰撞检测的问题穿透是物理引擎最经典的bug。排查时我先看的不是碰撞检测而是速度约束求解器。一个常见原因是时间步长太大物体在一帧内移动的距离超过了自身尺寸导致narrow phase根本没有检测到碰撞。解决办法有两个一是把dt调小比如从1/60改成1/120二是加CCD连续碰撞检测对高速移动的物体做扫掠检测。轻量级引擎里我更推荐前者简单可控。另一个穿透原因是迭代次数太少。在大量物体堆叠时每对接触只处理一轮冲量是不够的。这时候增加的迭代次数能立竿见影。我的经验是如果10次迭代还不够问题多半出在别的地方而不是盲目继续增加迭代。6.2 物体抖动多半出自约束求解逻辑抖动比穿透更让人头疼因为它看起来“像在正常工作”但观感极差。最常见的抖动来源是法向量方向不稳定特别是两个物体贴在一起时碰撞检测返回的法向量在几帧内来回反转。另一个来源是位置修正的比例过大物体被修正力推过了头下一帧又穿回去如此反复。调试这类问题我的建议是不要靠肉眼猜。把每一帧的接触信息、法向量、穿透深度打印出来配合帧号观察基本能定位到是哪一个阶段出了问题。Rust做这种调试还是比较舒服的结构体默认实现了Debug trait的话println!({:?}, contact)直接就能输出。6.3 性能瓶颈不会出现在约束求解很多初学者担心迭代次数会让性能崩掉实际上在几百个物体规模下约束求解的代价远小于碰撞检测的空间哈希重建和broad phase的pair遍历。如果发现性能下降优先检查空间哈希的cell_size是否合理以及是不是有一些不必要的clone()调用。Rust这里有个优势用cargo build --release配合perf或者criterion做基准测试可以很快找到热点函数。我实测下来在1000个物体规模下空间哈希的插入和查询占了约60%的CPU时间约束求解只占20%。所以如果你想继续优化性能重点应该放在broad phase的数据结构上比如把HashMap换成更紧凑的开放寻址表。6.4 调试技巧可视化是物理引擎的刚需没有可视化物理引擎的调试只能靠猜。我一开始就是纯文本输出后来发现效率太低。后来用minifb库做了一个简单的窗口把刚体画成矩形或圆帧率手动控制配合颜色显示穿透深度很多问题一眼就能看出来。这也是我给所有做物理引擎的朋友的第一条建议先把调试可视化做起来再谈其他优化。7. 后续扩展方向与个人体会核心模块稳定之后我还给它加了摩擦力、阻尼和简单的关节约束。摩擦力的实现是在切向方向施加脉冲跟法向冲量的计算思路一致只是方向换成切向。阻尼就是每帧给速度乘一个略小于1的系数用来模拟空气阻力。关节约束更复杂需要引入广义拉格朗日乘子但基础的思路依然是“在约束方向施加冲量迭代求解”。后续还想加对凸多边形的支持做法是支持点检测把这几种形状统一抽象。Rust的enum可以很好地描述“不同形状的碰撞体”每加一种形状只需要在Shape枚举里增加变体再在检测函数里增加对应的分支编译器会提醒你把所有match都处理完这个体验在C里很难找到。这个项目做完我对Rust这门语言的看法改变了不少。以前用C写物理引擎最难管理的不是数学而是内存和指针。Rust的编译器虽然一开始让人很不适应但它逼着你把数据的归属、借用和生命周期都想清楚。一旦代码编译通过运行时的稳定性远超预期。我强烈建议对物理引擎感兴趣的同学用Rust试一次哪怕是从零开始写个最简单的Demo过程中学到的Rust工程技巧和物理引擎知识量都相当可观。