1169 字
6 分钟

Bevy 下利用 Verlet 积分算法实现 2d 绳索物理效果的模拟

简单描述 Verlet 算法#

Verlet 积分算法是一种数值积分方法,常用于物理模拟中,特别适合处理粒子系统和刚体动力学。它通过跟踪粒子的位置和前一位置来计算新的位置,从而避免了速度的直接计算。这种方法在模拟绳子、布料等柔性物体时表现出色,因为它能够自然地处理约束条件。

简单来说,我们可以把绳子看作一系列质点,每两个相邻质点之间有一条约束,可以把这条约束看作一根杆子,用来确保相邻两个质点之间的距离不变,这样我们就可以实现“牵一发而动全身”。至于质点的运动主要是靠两个位置来决定的——当前位置和旧位置。杆子的约束用来更新质点的当前位置,而质点可以根据verlet算法通过旧位置和当前位置来推出新位置,从而模拟出真实的物理效果。

Bevy 中实现#

完整项目请见 👇

mengh04
/
verlet
Waiting for api.github.com...
00K
0K
0K
Waiting...

定义组件#

这是质点组件

#[derive(Component)]
struct Particle {
current_pos: Vec2,
old_pos: Vec2,
is_locked: bool, // 这里方便后续确定是否是固定点
}

这是代表杆子的组件

#[derive(Component)]
struct Stick {
p1: Entity,
p2: Entity,
length: f32,
}

这个是代表锁定的组件,这样后续筛选锁住的质点的时候就不需要遍历整个质点了,而是通过 ECS 系统直接找到包含 Locked 组件的质点,有利于性能的提升。

#[derive(Component)]
struct Locked;

初始化#

fn setup(mut commands: Commands) {
// 生成一个 2D 摄像机
commands.spawn(Camera2d::default());
// 初始化一系列质点
let mut prev_particle = None;
for i in 0..30 {
let particle = commands
.spawn((Particle {
current_pos: Vec2::new(0.0, 300.0 - i as f32 * 10.0), // 每两个质点之间间隔 10 个像素
old_pos: Vec2::new(0.0, 300.0 - i as f32 * 10.0),
is_locked: if i == 0 { true } else { false }, // 在这个 demo 里我把第一个点标记为了固定点跟随鼠标移动
},))
.id();
if i == 0 {
commands.entity(particle).insert(Locked);
}
if let Some(prev_particle) = prev_particle { // 在这里初始化 “杆子”
commands.spawn(Stick {
p1: prev_particle,
p2: particle,
length: 10.0,
});
}
prev_particle = Some(particle);
}
}

设置固定点跟随鼠标#

fn follow_mouse(
mut query: Query<&mut Particle, With<Locked>>,
q_window: Query<&Window>,
q_camera: Query<(&Camera, &GlobalTransform)>,
) {
let Ok(window) = q_window.single() else {
return;
};
let Ok((camera, camera_transform)) = q_camera.single() else {
return;
};
if let Some(cursor_pos) = window.cursor_position() {
if let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_pos) {
let Ok(mut locked_particle) = query.single_mut() else {
return;
};
locked_particle.current_pos = world_pos;
}
}
}

更新质点位置#

这里就是利用了 verlet 算法的地方

Verlet 积分的核心在于利用位置函数 x(t)x(t) 在时间点 tt 附近的两个方向的 泰勒级数(Taylor Series) 展开。

1. 向前展开(预测未来)#

我们预测下一时刻 t+Δtt + \Delta t 的位置:

x(t+Δt)=x(t)+dxdtΔt+12d2xdt2Δt2+16d3xdt3Δt3+O(Δt4)x(t + \Delta t) = x(t) + \frac{dx}{dt}\Delta t + \frac{1}{2}\frac{d^2x}{dt^2}\Delta t^2 + \frac{1}{6}\frac{d^3x}{dt^3}\Delta t^3 + O(\Delta t^4)

使用物理符号简化表示(vv 为速度,aa 为加速度):

x(t+Δt)=x(t)+vΔt+12aΔt2+16jΔt3+O(Δt4)— (式1)x(t + \Delta t) = x(t) + v\Delta t + \frac{1}{2}a\Delta t^2 + \frac{1}{6}j\Delta t^3 + O(\Delta t^4) \quad \text{--- (式1)}

2. 向后展开(回溯过去)#

同理,我们可以写出上一时刻 tΔtt - \Delta t 的位置:

x(tΔt)=x(t)vΔt+12aΔt216jΔt3+O(Δt4)— (式2)x(t - \Delta t) = x(t) - v\Delta t + \frac{1}{2}a\Delta t^2 - \frac{1}{6}j\Delta t^3 + O(\Delta t^4) \quad \text{--- (式2)}

3. 相加消去速度项#

(式1)(式2) 相加,奇数阶导数项(包括速度 vv 和加加速度 jj)由于正负抵消而消失:

x(t+Δt)+x(tΔt)=2x(t)+aΔt2+O(Δt4)x(t + \Delta t) + x(t - \Delta t) = 2x(t) + a\Delta t^2 + O(\Delta t^4)

4. 最终公式#

移项解出 x(t+Δt)x(t + \Delta t),得到 Verlet 积分的标准形式: x(t+Δt)=2x(t)x(tΔt)+aΔt2x(t + \Delta t) = 2x(t) - x(t - \Delta t) + a\Delta t^2

为了对应代码逻辑,将其拆解为: x(t+Δt)=x(t)+(x(t)x(tΔt))惯性 (Velocity)+aΔt2受力 (Acceleration)x(t + \Delta t) = x(t) + \underbrace{(x(t) - x(t - \Delta t))}_{\text{惯性 (Velocity)}} + \underbrace{a\Delta t^2}_{\text{受力 (Acceleration)}}

fn update_particles(time: Res<Time>, mut q_particles: Query<&mut Particle, Without<Locked>>) {
let dt = time.delta_secs();
let dt_square = dt * dt;
let gravity = Vec2::new(0.0, -1500.0);
for mut particle in q_particles.iter_mut() {
let velocity = particle.current_pos - particle.old_pos;
let new_pos = particle.current_pos + velocity + gravity * dt_square;
particle.old_pos = particle.current_pos;
particle.current_pos = new_pos;
}
}

处理约束#

fn handle_sticks(q_sticks: Query<&Stick>, mut q_particles: Query<&mut Particle>) {
for _ in 0..50 {
for stick in q_sticks.iter() {
if let Ok([mut p1, mut p2]) = q_particles.get_many_mut([stick.p1, stick.p2]) {
let delta = p2.current_pos - p1.current_pos;
let distance = delta.length();
if distance == 0.0 {
continue;
}
let diff = (distance - stick.length) / distance;
let offset = delta * diff;
match (p1.is_locked, p2.is_locked) {
(true, true) => {}
(true, false) => {
p2.current_pos -= offset;
}
(false, true) => {
p1.current_pos += offset;
}
(false, false) => {
p1.current_pos += offset * 0.5;
p2.current_pos -= offset * 0.5;
}
}
}
}
}
}

最后用 gizmos 画出来#

fn draw_particles(mut gizmos: Gizmos, query: Query<(&Particle, Has<Locked>)>) {
for (particle, is_locked) in query.iter() {
if is_locked {
gizmos.circle_2d(particle.current_pos, 3.0, Color::srgb(1.0, 0.0, 0.0));
} else {
gizmos.circle_2d(particle.current_pos, 3.0, Color::srgb(0.0, 1.0, 0.0));
}
}
}

主函数#

fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(
FixedUpdate,
(follow_mouse, update_particles, handle_sticks).chain(),
)
.add_systems(Update, draw_particles)
.run();
}

项目演示#

Bevy 下利用 Verlet 积分算法实现 2d 绳索物理效果的模拟
https://blog.mengh04.top/posts/verlet积分算法模拟绳索/
作者
mengh04
发布于
2026-01-26
许可协议
CC BY-NC-SA 4.0