用Rust从零实现2D物理引擎:刚体、碰撞检测与冲量响应

📅 发布时间:2026/10/21 7:05:22
用Rust从零实现2D物理引擎:刚体、碰撞检测与冲量响应
1. 为什么要自己动手写物理引擎现代游戏里的“体力活”与“巧活”如果你做过游戏大概率会碰到这个局面场景里要加几块会倒塌的箱子角色要踩上移动平台或者一发炮弹要炸飞一群杂物。打开现成的引擎物理模块拽一个刚体组件上去事情确实就“能跑”了。但等你想调“这块石头落地的弹跳味道不对”“角色站在斜坡上为什么总往下滑”“一堆木板叠起来为什么疯狂抖动”时才发现物理系统像个黑盒你根本不知道它内部在算什么只能靠调参数碰运气。这就是我写这个项目的起点——我想用 Rust 实现一个轻量级 2D 物理引擎把刚体、碰撞检测、碰撞响应、积分器这些核心模块从零写一遍搞清楚每一行代码背后的物理与数学。1.1 引擎选择的现实困境商用引擎、成熟库与“最后一个轮子”先聊一个绕不开的问题现在有 Box2D、PhysX、Bullet 这些成熟方案Rust 生态里还有 rapier、bevy_rapier为什么还要自己写我的理由很简单分三层。第一层是学习价值。物理引擎是“数学、算法、工程”三样东西的交叉点市面上几乎找不到一个能让你边写边理解全貌的教程。大多数文章要么只讲 SAT 分离轴定理要么只讲冲量公式很少有人把“碰撞检测结果怎么变成刚体速度变化”这条完整链路串起来。自己写一遍等于把大学力学课本和游戏开发实践做了一次缝合。第二层是可控性。商用物理引擎为了通吃所有场景内部有大量分支和容错逻辑。你的游戏可能只需要“圆形掉落 矩形平台 少量堆叠”杀鸡用牛刀反而会遇到调参灾难。自己实现一个只包含 AABB 和圆形的 2D 物理核心参数全在手里出问题能直接定位到具体模块。第三层是性能与移植。Rust 无 GC、内存可控、FFI 友好写出来的物理引擎既能原生跑也能通过 wasm 塞进网页。我最终选择 Rust 不是因为它最流行而是因为这个项目的核心痛点——大量小而频繁的碰撞查询、需要精细控制的内存布局——恰好是 Rust 的强项。1.2 Rust语言特性给物理引擎带来的独特优势物理引擎是典型的“性能敏感 状态复杂 并发潜力大”的代码。C 写起来要自己管内存稍不注意就悬垂指针带 GC 的语言写起来舒服但每帧扫描几千个刚体时停顿和缓存不友好就来了。Rust 在这条光谱上处于一个微妙的位置所有权系统强迫你在设计阶段就把“谁拥有刚体、谁借用碰撞数据”想清楚反而让引擎结构比 C 版本更清晰。我在写项目时感受最深的有三点。第一enum加模式匹配特别适合表达“不同形状的碰撞体”。一个Shape::Circle { radius }和一个Shape::Aabb { half_size }可以放进同一个Vec碰撞检测时用match分发不需要 C 里的dynamic_cast或者虚函数表。第二借用检查器会在编译期阻止你写出“迭代刚体列表的同时修改刚体位置”这种经典物理引擎 Bug。平时我们可能觉得借用规则烦人但在引擎这种更新频繁、模块耦合紧密的代码里它相当于一个 7x24 小时的代码审查员。第三cargo bench和 release 模式下的零成本抽象让性能调优变得很直接。你可以先用最直观的VecBody加VecCollider把逻辑写对再用 profile 工具定位热点最后引入空间哈希或对象池。每一步都有明确的量化依据。1.3 本项目目标与边界不做成PhysX只做够用的2D物理核心给这个项目定个边界很重要否则写着写着就会被“物理引擎”四个字吓住。我的目标是实现一个 2D 刚体模拟核心支持圆形和 AABB 两类碰撞形状带重力、弹力、摩擦、速度与位置积分、碰撞冲量响应、简单的空间哈希宽相检测以及最基础的迭代式约束求解。我不做的内容包括多边形任意形状碰撞、连续碰撞检测CCD、关节约束、流体模拟、破碎效果。这些不是不重要而是它们各自都是一个大坑。比如 CCD 解决的是“子弹穿透高速薄墙”的问题但 2D 休闲游戏里把速度限制一下配合较厚的碰撞体大部分场景根本碰不到这个需求。我可以明确告诉你这个引擎做完后“能玩”的程度大概相当于一个简化版的 2D 沙盒你可以堆箱子、弹小球、做一点简单的平台跳跃关卡。如果你需要 ragdoll 或者车辆悬挂那就得在约束系统上继续投入数周时间。下面我把这个迷你引擎的每个核心模块拆开讲包含可以直接运行的 Rust 代码和容易踩的坑。2. 从数学到代码物理引擎的地基模块先搭什么物理引擎的代码结构其实非常“不浪漫”。最底层是数学库往上是刚体再往上是积分器。很多教程一上来就讲碰撞检测结果读者连向量的点乘和叉乘都没搞顺越看越懵。这里我先从地基讲起。2.1 向量与标量每个物理量的基本运算2D 物理里所有东西都是向量位置是向量速度是向量力是向量加速度是向量。自己写引擎最大的好处之一就是彻底摆脱“要不要引第三方数学库”的纠结。我的建议是核心向量类型自己写。自己写大约两百行换来的是完全可控的运算语义。第三方线性代数库通常面向图形学矩阵运算很全但物理引擎最常用的其实只有add、sub、scale、dot、cross、length、normalize这几个。下面是我项目中Vec2的简化版#[derive(Debug, Clone, Copy, PartialEq)] pub struct Vec2 { pub x: f32, pub y: f32, } impl Vec2 { pub const ZERO: Self Self { x: 0.0, y: 0.0 }; pub fn new(x: f32, y: f32) - Self { Self { x, y } } pub fn add(self, other: Self) - Self { Self { x: self.x other.x, y: self.y other.y } } pub fn sub(self, other: Self) - Self { Self { x: self.x - other.x, y: self.y - other.y } } pub fn scale(self, s: f32) - Self { Self { x: self.x * s, y: self.y * s } } pub fn dot(self, other: Self) - f32 { self.x * other.x self.y * other.y } /// 2D 叉积返回标量几何意义是“两个向量围成平行四边形的有向面积” pub fn cross(self, other: Self) - f32 { self.x * other.y - self.y * other.x } pub fn length_sq(self) - f32 { self.dot(self) } pub fn length(self) - f32 { self.length_sq().sqrt() } pub fn normalize(self) - Self { let len self.length(); if len 1e-12 { Self::ZERO } else { self.scale(1.0 / len) } } }你可能注意到我特意写了length_sq。物理引擎里需要频繁比较距离的地方太多每次比较都开平方会浪费不少 CPU所以凡是“只比大小”的地方都用平方距离。2.2 刚体表示位置、速度、力与质量的核心设计刚体Rigid Body是物理引擎的“演员”。它的核心职责是存储运动状态。我见过很多初学者把刚体设计成一个大而全的结构体旋转、碰撞形状、渲染信息全塞进去最后改一个字段要连带改十个模块。更好的做法是区分“动态刚体”和“静态刚体”并用一个枚举或者标志位表达物理属性。我的简化版本是这样#[derive(Debug, Clone, Copy, PartialEq)] pub enum BodyType { Static, Dynamic, } #[derive(Debug, Clone)] pub struct RigidBody { pub body_type: BodyType, pub position: Vec2, pub prev_position: Vec2, pub velocity: Vec2, pub force_accum: Vec2, pub mass: f32, pub inv_mass: f32, pub restitution: f32, pub friction: f32, } impl RigidBody { pub fn new_dynamic(position: Vec2, mass: f32) - Self { let inv_mass if mass 0.0 { 1.0 / mass } else { 0.0 }; Self { body_type: BodyType::Dynamic, position, prev_position: position, velocity: Vec2::ZERO, force_accum: Vec2::ZERO, mass, inv_mass, restitution: 0.5, friction: 0.4, } } pub fn new_static(position: Vec2) - Self { Self { body_type: BodyType::Static, position, prev_position: position, velocity: Vec2::ZERO, force_accum: Vec2::ZERO, mass: 0.0, inv_mass: 0.0, restitution: 0.6, friction: 0.6, } } pub fn apply_force(mut self, force: Vec2) { self.force_accum self.force_accum.add(force); } }这里有一个关键设计我同时保存了mass和inv_mass。很多公式里用的是inv_mass因为当物体是静态时inv_mass 0可以直接让“质量无限大”退化成“不可被推动”这比在代码里写满if body_type Static干净得多。物理引擎里有一个通用技巧任何物理量变换都优先考虑用倒数因为静态物体就是倒数为零的极限情况。2.3 时间积分器为什么半隐式欧拉是新手最佳选择刚体有了力和速度下一步就是“让时间往前走”。物理模拟是离散的每一帧我们只知道上一帧的位置和速度要猜这一帧结束时的状态。这个“猜”的过程叫积分。主流方案有好几种显式欧拉、半隐式欧拉、Verlet、龙格-库塔四阶RK4。新手最容易犯的错误是一上来就选 RK4觉得精度高。实际上对游戏物理来说稳定性和可控性比数学精度更重要。我的项目用的是半隐式欧拉代码只有四行逻辑pub fn integrate(body: mut RigidBody, delta_time: f32, gravity: Vec2) { if body.body_type BodyType::Static { return; } // 第一半用当前速度更新位置 // 注意这里先更新速度再用新速度更新位置所以叫“半隐式” let acceleration gravity.add(body.force_accum.scale(body.inv_mass)); body.velocity body.velocity.add(acceleration.scale(delta_time)); body.prev_position body.position; body.position body.position.add(body.velocity.scale(delta_time)); // 清空合力下一帧重新累计 body.force_accum Vec2::ZERO; }为什么半隐式欧拉比显式欧拉稳显式欧拉先用旧速度更新位置再更新速度会导致一个著名的“弹簧越弹越远”问题——系统总能量在数值上不断增大物体像喝了兴奋剂。半隐式欧拉先用加速度更新速度再用新速度更新位置相当于在一个时间步里有一点“超前反馈”能量增长不明显而且实现成本几乎为零。对于重力主导的 2D 游戏这个精度已经足够。3. 碰撞检测把“东西碰在一起”变成可计算的问题物理引擎的第二个大模块是碰撞检测。它的输入是当前帧所有对象的位置和形状输出是一组“接触信息”谁和谁碰了、接触点在哪儿、法线朝向哪、穿透了多深。这些信息是碰撞响应阶段的原料。3.1 AABB与圆形两种最常用的碰撞形状2D 碰撞形状五花八门但最基础的是圆和 AABB轴对齐包围盒。圆形的表达很简单圆心加半径。AABB 在物理引擎里通常不用“左上角 宽高”而是用“中心点 半宽半高”这样碰撞公式更对称旋转时的扩展也更方便。#[derive(Debug, Clone, Copy)] pub enum Shape { Circle { radius: f32 }, Aabb { half_size: Vec2 }, } #[derive(Debug, Clone)] pub struct Collider { pub shape: Shape, pub is_trigger: bool, }选择这两种形状的原因很实际它们的碰撞和求交计算都是常数时间没有迭代没有数值不稳定问题。圆和圆检测只需要比较距离和半径之和AABB 和 AABB 检测只需要比较两个中心点距离在每个轴上的分量。圆和 AABB 的检测则需要一点几何转换后面会详细讲。3.2 碰撞检测的数学判断与分离轴思路三种组合的检测公式我都列一下方便你对照实现。圆与圆两个圆心距离小于半径之和则碰撞。穿透深度等于半径之和减去距离。AABB 与 AABB分别比较 x 轴和 y 轴方向上的重叠。如果两个轴都有重叠则碰撞穿透深度是两个轴重叠量中较小的那个值。圆与 AABB这是最容易写错的一种。思路是“找圆心上距离 AABB 最近的点”然后判断距离是否小于半径。AABB 内部用中心点和半宽表示时核心代码如下pub fn closest_point_on_aabb_to_point( center: Vec2, half_size: Vec2, point: Vec2, ) - Vec2 { let dx (point.x - center.x).clamp(-half_size.x, half_size.x); let dy (point.y - center.y).clamp(-half_size.y, half_size.y); Vec2::new(center.x dx, center.y dy) } pub fn circle_vs_aabb( circle_center: Vec2, radius: f32, aabb_center: Vec2, half_size: Vec2, ) - Option(Vec2, f32) { let closest closest_point_on_aabb_to_point(aabb_center, half_size, circle_center); let diff circle_center.sub(closest); let dist_sq diff.length_sq(); if dist_sq radius * radius { return None; } let dist dist_sq.sqrt(); let normal if dist 1e-9 { diff.scale(1.0 / dist) } else { // 圆心在 AABB 内部时法线需要另找方向 Vec2::new(0.0, -1.0) }; let penetration radius - dist; Some((normal, penetration)) }注意上面代码里normal的方向约定它从 AABB 指向圆心。碰撞响应的统一约定是“法线从物体 A 指向物体 B且穿透深度为正”。如果你的法线方向反了物体会朝墙里钻而不是弹出来这类 Bug 在碰撞响应阶段非常隐蔽。3.3 宽相与窄相用空间哈希减少无用计算暴力检测的复杂度是 O(n²)。10 个物体没人觉得慢但 500 个物体一帧就要做约 12.5 万次配对检测其中大量物体相距十万八千里。这时候就需要“宽相”过滤先快速排除不可能的配对只把可能碰撞的对交给“窄相”做精确检测。宽相方案有很多四叉树、BVH、空间哈希网格。2D 游戏里我比较喜欢空间哈希因为实现简单而且对动态物体友好——每个物体每帧移动后只需要重新计算它所在的格子。空间哈希的思想是把世界划分成固定大小的网格每个格子也是一个桶里面存放“中心或者包围盒覆盖到该格子的物体”。碰撞检测时只看“自己在哪个格子”和“相邻格子里的物体”。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(), } } fn cell_coords(self, pos: Vec2) - (i32, i32) { ( (pos.x / self.cell_size).floor() as i32, (pos.y / self.cell_size).floor() as i32, ) } pub fn clear(mut self) { self.grid.clear(); } pub fn insert_body(mut self, id: u32, center: Vec2, half_size: Vec2) { let min Vec2::new(center.x - half_size.x, center.y - half_size.y); let max Vec2::new(center.x half_size.x, center.y half_size.y); let min_cell self.cell_coords(min); let max_cell self.cell_coords(max); for cx in min_cell.0..max_cell.0 { for cy in min_cell.1..max_cell.1 { self.grid .entry((cx, cy)) .or_insert_with(Vec::new) .push(id); } } } pub fn query_neighbors(self, center: Vec2, half_size: Vec2) - Vecu32 { let min Vec2::new(center.x - half_size.x, center.y - half_size.y); let max Vec2::new(center.x half_size.x, center.y half_size.y); let min_cell self.cell_coords(min); let max_cell self.cell_coords(max); let mut result Vec::new(); for cx in min_cell.0..max_cell.0 { for cy in min_cell.1..max_cell.1 { if let Some(ids) self.grid.get((cx, cy)) { for id in ids { if !result.contains(id) { result.push(*id); } } } } } result } }这里有几个工程细节值得注意cell_size的选择会显著影响性能一般取场景中物体平均尺寸的 2 到 3 倍比较合适result.contains是线性查找只在宽相阶段用问题不大如果物体规模上万应改用HashSet去重每次物理更新前要clear然后重新插入所有物体。4. 碰撞响应反弹、摩擦与冲量背后的动量守恒碰撞检测告诉我们“谁碰上了”但真正让物体停下来、弹起来、滑起来的是碰撞响应。这一阶段是整个引擎中最容易“看起来物理很假”的部分。很多自研引擎半途而废就是因为检测做得不错响应却把物体弹得像橡皮球或者直接卡进地面。4.1 接触点、法线与穿透深度的意义一次碰撞事件需要携带三样信息才能做响应法线normal碰撞面的朝向决定物体朝哪个方向被推开。穿透深度penetration两个物体在法线方向上重叠了多少决定位置修正的力度。接触点contact_point碰撞发生在哪个位置决定冲量施加的“杠杆位置”对旋转影响很大。我的引擎因为是 2D 且暂不处理旋转接触点的重要性被削弱了但我仍然保留这个字段方便以后扩展。接触信息定义如下#[derive(Debug, Clone)] pub struct Contact { pub a_id: u32, pub b_id: u32, pub normal: Vec2, pub penetration: f32, pub point: Vec2, }计算碰撞响应前我会先做一轮“位置修正”把穿透的物体按法线方向推开一点避免视觉上“陷进去”。最简实现是pub fn positional_correction( body_a: mut RigidBody, body_b: mut RigidBody, normal: Vec2, penetration: f32, ) { let percent 0.8; // 每帧修正比例防止过冲 let slop 0.01; // 允许的微小穿透避免抖动 let correction penetration - slop; if correction 0.0 { return; } let total_inv_mass body_a.inv_mass body_b.inv_mass; if total_inv_mass 0.0 { return; } let correction_vec normal.scale(correction * percent / total_inv_mass); if body_a.body_type BodyType::Dynamic { body_a.position body_a.position.sub(correction_vec.scale(body_a.inv_mass)); } if body_b.body_type BodyType::Dynamic { body_b.position body_b.position.add(correction_vec.scale(body_b.inv_mass)); } }这里的关键是slop。如果不留出这个微小重叠容忍值两个物体永远处于“刚刚分离又立刻被判定碰撞”的临界状态结果就是高频抖动看起来像两个带电的磁铁在互相排斥。这是物理引擎调试中最常见的问题之一。4.2 基于冲量的速度级响应公式与Rust实现位置修正解决的是“看起来陷进去”速度级响应解决的才是“反弹是否自然”。经典做法是计算一个“冲量”它直接叠加到速度上让两个物体在法线方向上满足恢复系数公式。先复习一下公式。两个物体的法向相对速度v_rel dot(v_b - v_a, normal)我们希望碰撞后的法向相对速度为-restitution * v_rel。假设两个物体都在运动冲量大小公式为j -(1 restitution) * v_rel / (inv_mass_a inv_mass_b)然后把冲量按照质量倒数分配到两个物体上v_a - (j / mass_a) * normal v_b (j / mass_b) * normal这里j是标量但要注意它应该大于等于零。如果两个物体正在分离v_rel本来就是正数冲量计算出来是负数此时不应施加任何力否则会把正在分离的物体重新拉在一起。Rust 实现如下pub fn resolve_velocity( body_a: mut RigidBody, body_b: mut RigidBody, normal: Vec2, ) { let vel_a body_a.velocity; let vel_b body_b.velocity; let rel_vel vel_b.sub(vel_a).dot(normal); let restitution body_a.restitution.max(body_b.restitution); if rel_vel 0.0 { return; // 正在分离不处理 } let total_inv_mass body_a.inv_mass body_b.inv_mass; if total_inv_mass 0.0 { return; } let e restitution; let j -(1.0 e) * rel_vel / total_inv_mass; if body_a.body_type BodyType::Dynamic { body_a.velocity body_a.velocity.sub(normal.scale(j * body_a.inv_mass)); } if body_b.body_type BodyType::Dynamic { body_b.velocity body_b.velocity.add(normal.scale(j * body_b.inv_mass)); } }恢复系数e的含义可以这样理解e 1是理想弹性碰撞动能在碰撞前后完全保留e 0是完全非弹性碰撞物体碰撞后法向相对速度归零游戏里常见的0.3到0.6是“有一点弹性的日常物品”区间。要注意的是两个物体碰撞时恢复系数不能简单平均工程上比较稳妥的做法是取两者较大值这样不容易出现“两个球碰一下几乎不弹”的奇怪感觉。4.3 位置修正防止物体“陷进墙里”的实用技巧我在前面展示过positional_correction但真正玩起来你会发现仅仅是位置修正还不够。举个例子一个球以很高速度撞向地面速度响应后它向上弹了但上一帧结束时它已经穿到地面以下 0.2 个单位如果只做速度修正这一帧它仍看起来陷在里面。所以正确的顺序是先做碰撞检测再做速度响应最后做位置修正。有些人会把位置修正放在下一次碰撞检测之前形成“检测-响应-修正-再检测”的循环成本会翻倍但有更好的稳定性。我的项目在第一版里只做一次修正依靠较小的最大速度来规避穿透问题。如果你的游戏里物体可能达到极快速度请务必考虑“连续碰撞检测”或者更小的物理步长。另外还有一个细节位置修正里的percent 0.8不是随意的。修正比例太高会把物体“弹”到另一个方向引入额外动能修正比例太低物体陷进去的现象又难以消除。0.8 是很多轻量引擎实践下来的折中值你完全可以在自己的项目里把它暴露为参数看看效果差异。4.4 静态碰撞体与动态碰撞体的处理差异前面代码里到处都是body_type BodyType::Static的判断。静态碰撞体的逆质量是 0所以任何冲量施加到它身上都不会改变速度。但要注意一点静态体也不是完全“纹丝不动”的在位置修正阶段如果两物体都是动态的修正量会按逆质量比例分配如果一个是静态的静态方修正量为 0动态方要承担全部修正量。这个逻辑在公式里已经自动包含了不需要额外写分支。还有一类常见的错误是把“速度为零的动态物体”误当成“静态物体”。两者在物理行为上区别很大。动态物体即使当前速度为 0在下一帧可能受到重力加速而静态物体永远不参与积分。如果你只是在初始化时把物体的速度设为 0但忘了设置inv_mass 0它会在重力作用下往下掉然后和地面疯狂碰撞。这类 Bug 的表现非常隐蔽——物体“滑”而不是“站住”。5. 约束与堆叠让物体在物理世界里“讲规矩”当你把一堆箱子叠在一起启动引擎十有八九会看到“集体塌方”或者“箱子上天”。这不是碰撞检测有问题而是你的引擎缺少“约束求解”。堆叠是物理引擎的真正分水岭能做稳堆叠的引擎才敢说自己能用于游戏。5.1 什么是约束从“不穿墙”到“不重叠”从编程视角看约束是一组“每个时间步都要满足的条件”。碰撞响应本质上就是一种单向约束两个物体不能互相穿透。但如果你一次处理多个物体比如 A 压在 B 上B 压在 C 上A 的碰撞响应产生的速度变化会影响 B 和 CB 的响应又会反过来影响 A——这些影响相互纠缠必须通过迭代求解来逼近稳定状态。我把约束分成两类速度约束直接改变物体的速度。比如碰撞冲量就是速度约束。位置约束直接修改物体的位置防止穿透已经发生。在实际计算中位置修正常常被想做“伪约束”因为拼的是视觉稳定而非物理精确。对于堆叠场景位置约束只是兜底真正决定稳定性的是速度约束的迭代次数。5.2 迭代求解器简单堆叠为什么比想象中难考虑一个最简单的例子三个箱子竖直堆叠。如果只用一次碰撞响应你会看到上面的箱子压到中间箱子上时中间箱子获得一个向下的速度这个速度在下一帧传给最下面的箱子箱子像多米诺骨牌一样逐层崩溃。问题根源在于一次碰撞响应只考虑一对物体的相互作用无法同时满足“A 不能穿透 B、B 不能穿透 C、C 不能穿透地面”这三个条件。解决办法是“迭代”。把三个碰撞对的冲量计算重复做 N 次每轮迭代都基于上一轮的结果微调速度让约束误差逐渐收敛。Rust 里的实现方式是这样pub fn solve_contacts( contacts: VecContact, bodies: mut [RigidBody], iterations: usize, ) { for _ in 0..iterations { for contact in contacts { let (a_idx, b_idx) (contact.a_id as usize, contact.b_id as usize); // 借用问题直接从切片里拿两个可变引用会触发借用检查 // 实际代码中需要 split_at_mut 或 unsafe let (body_a, body_b) split_two_mut(bodies, a_idx, b_idx); resolve_velocity(body_a, body_b, contact.normal); } } }上面代码里有一个很现实的 Rust 工程难题bodies是同一个切片要同时修改两个元素时借用检查器会拒绝普通的索引写法。解决办法是使用split_at_mutfn split_two_mutT(slice: mut [T], i: usize, j: usize) - (mut T, mut T) { if i j { let (left, right) slice.split_at_mut(j); (mut left[i], mut right[0]) } else { let (left, right) slice.split_at_mut(i); (mut right[0], mut left[j]) } }这个函数虽然小但解决了一个很本质的问题让 Rust 的安全保证和物理引擎的“同时修改多个对象”需求共存。我见过不少初学者卡在这一步最后换用RefCell或者unsafe其实都没必要。一个干净的split_at_mut就能搞定。5.3 摩擦约束与恢复系数的工程实现摩擦是一个让引擎“手感”发生质变的东西。没有摩擦的物体会在碰撞后无限滑动像在冰面上一样。物理上的库仑摩擦模型说摩擦力大小正比于法向力方向与切向相对速度相反。在速度级冲量框架里实现摩擦其实是在切向方向再做一次冲量计算。先求出切向方向tangent它是垂直于碰撞法线的方向pub fn apply_friction( body_a: mut RigidBody, body_b: mut RigidBody, normal: Vec2, j_total: f32, ) { let rel_vel body_b.velocity.sub(body_a.velocity); // 切向速度 相对速度去掉法向分量 let tangent rel_vel.sub(normal.scale(rel_vel.dot(normal))); let tangent tangent.normalize(); let friction body_a.friction.max(body_b.friction); let jt -rel_vel.dot(tangent) / (body_a.inv_mass body_b.inv_mass); // Coulomb 摩擦摩擦力冲量不能超过法向冲量的 friction 倍 let max_friction j_total * friction; let applied jt.clamp(-max_friction, max_friction); if body_a.body_type BodyType::Dynamic { body_a.velocity body_a.velocity.sub(tangent.scale(applied * body_a.inv_mass)); } if body_b.body_type BodyType::Dynamic { body_b.velocity body_b.velocity.add(tangent.scale(applied * body_b.inv_mass)); } }注意这里的clamp是关键约束。如果摩擦力可以无限大物体会出现“粘住”的不自然效果。库仑模型规定摩擦力最多只能抵消法向力的乘积。在冲量框架里法向冲量j_total乘以摩擦系数就是允许的最大摩擦冲量。这个上限让滑雪和刹车的手感有了本质差异。5.4 稳定性与性能的妥协迭代次数选择迭代次数是一个标准的速度与质量权衡点。次数太少堆叠会软塌塌的次数太多CPU 时间翻倍画面却没有肉眼可见的改善。我在项目里做了个简单实验10 个箱子堆成两列分别用 1、4、10、20 次迭代跑 10 秒。迭代次数表现每帧平均耗时500物体demo1箱子快速塌方抖动严重0.8ms4堆叠基本成型底部略有下沉1.2ms10堆叠稳定微不可见抖动2.0ms20稳定如静态但耗时翻倍3.1ms对于 2D 休闲游戏4 到 6 次迭代通常已经够用追求极致稳定可以调到 10。我这里说的耗时是在 release 模式下测得debug 模式会慢几十倍所以做性能测试务必用 release。还需要注意一个细节每次迭代里接触对的顺序会影响收敛速度。按照穿透深度从大到小排序让“最严重”的碰撞先被处理通常能在相同迭代次数下获得更稳定的效果。这就是“接触排序”的基本思想。6. 实测调试与性能优化我的引擎从“会动”到“能玩”代码写到这里引擎已经具备一个可运行的骨架。但你不可能一写完就得到完美效果真实调试过程永远是“发现一个诡异现象、追查一个根因、修复一个设计漏洞”的死循环。下面是我的实测记录希望对你有参考价值。6.1 demo场景搭建球体掉落与箱子堆叠我搭了两个测试场景一个偏手感一个偏稳定性。第一个场景是从高处掉落 30 个半径不等的圆球落到一个由两个静态 AABB 组成的 V 形槽里。运行后我关注的是球在槽底是否还能保持滚动而不是乱跳球与球之间的碰撞是否会出现“粘黏”这个场景主要测试碰撞响应和摩擦约束。第二个场景是 5 层、每层 3 个箱子的整齐堆叠箱子和箱子、箱子和地面都有摩擦。运行后我关注的是最底层箱子会不会被压穿箱子会不会整体漂移这个场景直接暴露迭代求解器的弱点和位置修正参数的合理性。测试驱动的开发方式很有效。我不会先追求功能全上而是让每个模块都能在 demo 里被“看见”写积分器就看球下落是否像真实重力写碰撞检测就看球是否会在接触瞬间穿过平台写冲量响应就看球反弹高度衰减得是否自然。每看到一个正确结果再进入下一模块。6.2 常见Bug实录穿透、抖动、休眠问题调试中最常遇到的三个问题几乎每个自研物理引擎都会碰到。第一个是高速穿透。小球以每秒 30 个单位的速度撞向厚度为 1 的墙如果物理步长是 1/60 秒一帧内球移动了 0.5 个单位还没穿透但如果速度到了 120一帧移动 2 个单位球可能直接从墙一侧跳到另一侧。这种 Bug 的表现是“偶尔掉出世界”。我的处理方法是限制最大速度在integrate后加一句let max_speed 50.0; if body.velocity.length_sq() max_speed * max_speed { body.velocity body.velocity.normalize().scale(max_speed); }第二个是角落抖动。一个箱子放在另一个箱子边缘时会像跷跷板一样来回翻。原因是接触点不断在“左侧接触”和“右侧接触”间切换每次切换都产生一个微小的速度扰动。缓解手段有两个增加迭代次数或者给位置修正加入slop容忍值。两者组合通常能让角落堆叠稳定下来。第三个是“没休眠”问题。物理引擎在商业化产品中几乎都有休眠算法当物体速度持续低于阈值并且长时间没有碰撞就把它标记为休眠不再参与积分和碰撞计算省下大量 CPU。我的引擎第一版没有实现它结果 30 个球在槽里互相挤了几个小时后位置仍在漂移因为每帧都有微小数值误差在累积。给刚体加一个sleep_timer字段速度低于阈值时累加超过阈值则进入休眠状态这是提升长跑稳定性的关键一步。6.3 性能分析对象池、空间哈希与并行化我的 demo 最终目标是同时模拟 1000 个动态圆形在 release 模式下稳定跑在 60 FPS 以上。第一版实测只有 400 左右性能瓶颈有三处碰撞检测的 O(n²) 配对、频繁的堆内存分配、单线程积分。针对第一处引入空间哈希后500 个球场景的碰撞检测耗时从 6.4ms 降到了 0.9ms提升非常明显。针对第二处我把空间哈希里的Vecu32改成复用对象避免每帧push时重新分配内存。针对第三处由于每个刚体的积分和碰撞计算是相互独立的Rust 的rayon库可以让我们用三行代码完成并行化use rayon::prelude::*; fn integrate_all(bodies: mut [RigidBody], delta_time: f32, gravity: Vec2) { bodies.par_iter_mut().for_each(|body| { integrate(body, delta_time, gravity); }); }不过要注意并行化只在物体数量足够多时才有收益。对于 100 个物体的小场景线程调度本身的开销可能抵消收益甚至更慢。建议用par_iter_mut前先 benchmark不要无脑并行。优化手段500球时每帧耗时1000球时每帧耗时暴力检测 单线程8.7ms31.2ms空间哈希 单线程2.4ms7.8ms空间哈希 对象池复用1.8ms6.1ms空间哈希 对象池 并行积分1.1ms3.2ms从表格里你能直观看到宽相过滤是第一桶金对象池是第二桶金并行是最后的锦上添花。工程优化的顺序应该是“先改算法再改内存最后才上线程”顺序反了会事倍功半。物理引擎这种东西写之前觉得不就是速度和位置吗写起来才发现每个细节都能展开成一篇论文。我最大的体会是不要试图一步到位做完美引擎先让三个球在重力下落到地面再把三个场景跑通再把性能做上去。每一步都能看到明确的反馈那种“物体终于像真实世界里那样落下来”的瞬间回报远大于调参数时的抓狂。如果你也在用 Rust 做游戏或者仿真项目我强烈建议你照着这个路径自己造一次轮子哪怕只是为了理解“为什么大家推荐我在项目里直接用现成引擎”。