最近在做一个小的游戏项目,碰撞检测和物理响应的效果一直不理想。要么是物体直接穿透,要么是堆叠时抖得跟筛子似的。后来干脆自己动手写了一个轻量级的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 Mul<f32> 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 刚体与碰撞体的设计
刚体是物理引擎中参与模拟的实体,它的核心字段包括位置、速度、质量、恢复系数,以及一个碰撞体。这里有个经典的设计选择:碰撞体到底用枚举还是trait?
Rust社区里两种方案都有讨论。trait方案更符合面向对象的直觉,每个碰撞体类型实现自己的get_aabb和support_point方法,但随之而来的是运行时动态分发的开销,以及到处都要处理的Box<dyn 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), Vec<u32>>, } 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) -> Option<Contact> { 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) -> Option<Contact> { 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的固定步长,也就是dt=1/60,然后配合累加器把渲染帧率解耦。具体做法是:主循环里每帧计算真实流逝的时间,累加到accumulator里,然后循环执行step直到accumulator耗尽。这样一个渲染帧可能执行0次、1次或多次物理step,但每个step的dt始终是1/60,物理表现的确定性有保证。
4.2 顺序冲量法与接触约束求解
碰撞响应是整个引擎里最核心、也最容易出问题的地方。我采用的是2D物理引擎里应用最广泛的顺序冲量法(Sequential Impulses),Box2D使用的也是这个思路的变体。核心思想是逐对处理接触点,每次计算一个冲量并立即应用,然后反复迭代多轮,让结果收敛。
基本原理来自牛顿第二定律的冲量形式。碰撞时,两个物体在接触点法线方向上的相对速度必须发生改变,才能让它们不再相互穿透。设法线为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. 完整工程:从零跑通Demo
5.1 模块组织与依赖管理
整个引擎我按功能拆成了三个模块:math、body、world。math放向量和AABB;body放刚体、碰撞体和接触信息;world放空间哈希、碰撞检测循环、积分器和约束求解器。测试文件跟着模块走,每个模块都有针对性的单元测试。
Cargo.toml里核心依赖只有rand一个,用于Demo场景里随机初始化物体位置。其他全部用的是标准库。这样编译速度快,依赖树干净,也能更清楚地看到每一行物理代码在干什么。
[package] name = "tiny-physics" version = "0.1.0" edition = "2021" [dependencies] rand = "0.8"main.rs里做了一个简单的文本可视化:用#表示静态地板,用o表示圆,用[和]表示AABB,每一帧打印一次平面图,按任意键推进一帧。虽然简陋,但调试物理引擎已经足够了。以后再接SDL2或者minifb做真正的图形界面就可以在此基础上扩展。
5.2 World主循环实现
World是整个引擎的协调者,把所有模块串联起来。完整的step流程分四步:清空上一帧的碰撞对、构建空间哈希、执行broad phase和narrow phase、积分速度和应用约束。
pub struct World { pub bodies: Vec<RigidBody>, 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工程技巧和物理引擎知识量都相当可观。