1806 字
9 分钟

Bevy 下实现 Marching Squares 算法

背景#

近期我在研究怎么实现 2d 可破坏地形。不同于泰拉瑞亚和我的世界的体素破坏,我想实现的是平滑的破坏效果,发现 Marching Squares 算法可以用来实现这个效果。本文将介绍在 Bevy 引擎中简单实现该算法,不包括可破坏地形的实现。可破坏地形的实现将在另外一篇文章中介绍。

Marching Squares 算法#

原理#

我们可以把二维地图看作二维标量场,每个点有一个标量值(例如密度),通过设定一个阈值,我们可以区分该点是否选中。例如超过阈值就代表选中,低于阈值代表未选中。Marching Squares 算法通过遍历每一个网格单元(由四个相邻的点组成),根据四个点的选中状态,共有 16 种可能的组合,每种组合对应不同的边界线段绘制方式。通过插值计算边界线段的位置,可以生成平滑的等值线。

等值线的端点设置为选中的点和未选中的点之间的中点位置,之后可以通过权值插值来计算更精确的端点位置,从而实现更平滑的边界。

下图为几种情况的示意图,绿色代表选中的点,红色代表未选中的点,蓝色代表画出的等值线:

左上

左上加左下

在 Bevy 中实现#

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

初始化项目#

首先我们先创建一个新的 Bevy 项目:

Terminal window
cargo new bevy_marching_squares
cd bevy_marching_squares

然后在 Cargo.toml 中添加 Bevy 依赖:

[package]
name = "bevy_marching_squares"
version = "0.1.0"
edition = "2024"
[dependencies]
bevy = { version = "0.18.0", features = ["dynamic_linking"] }
# Enable a small amount of optimization in the dev profile.
[profile.dev]
opt-level = 1
# Enable a large amount of optimization in the dev profile for dependencies.
[profile.dev.package."*"]
opt-level = 3

配置常量#

const VOXEL_SIZE: f32 = 8.0; // 每个格子的大小
const GRID_WIDTH: usize = 100; // 地图宽(格子数)
const GRID_HEIGHT: usize = 80; // 地图高(格子数)
const ISO_LEVEL: f32 = 0.5; // 阈值:密度 > 0.5 认为是墙,< 0.5 是空气

定义地图资源#

在 Bevy 中,资源表示全局可访问的数据。简单起见,我们用静态数组来表示地图数据。

// 地图的定义
#[derive(Resource)]
struct VoxelMap {
data: Vec<f32>, // 扁平化的一维数组,比起二维数组有更好的性能
width: usize,
height: usize,
}
impl VoxelMap {
fn new(width: usize, height: usize) -> Self {
Self {
data: vec![1.0; width * height], // 初始化全是 1.0 (实心)
width,
height,
}
}
// 辅助:获取世界坐标对应的网格坐标
fn world_to_grid(&self, world_pos: Vec2) -> (i32, i32) {
// 地图居中显示,所以需要加上宽高的一半作为偏移
let offset_x = (self.width as f32 * VOXEL_SIZE) / 2.0;
let offset_y = (self.height as f32 * VOXEL_SIZE) / 2.0;
let x = ((world_pos.x + offset_x) / VOXEL_SIZE).floor() as i32;
let y = ((world_pos.y + offset_y) / VOXEL_SIZE).floor() as i32;
(x, y)
}
// 安全获取密度(越界返回 0.0)
fn get_density(&self, x: i32, y: i32) -> f32 {
if x < 0 || x >= self.width as i32 || y < 0 || y >= self.height as i32 {
return 0.0;
}
self.data[y as usize * self.width + x as usize]
}
// 修改密度
fn modify_density(&mut self, x: i32, y: i32, amount: f32) {
if x >= 0 && x < self.width as i32 && y >= 0 && y < self.height as i32 {
let idx = y as usize * self.width + x as usize;
self.data[idx] = (self.data[idx] + amount).clamp(0.0, 1.0);
}
}
}

处理输入#

左键就是挖,右键是填。我们根据鼠标位置修改地图数据。

fn handle_input(
buttons: Res<ButtonInput<MouseButton>>,
q_window: Query<&Window>,
q_camera: Query<(&Camera, &GlobalTransform)>,
mut map: ResMut<VoxelMap>,
) {
let Ok(window) = q_window.single() else {
return;
};
let Ok((camera, camera_transform)) = q_camera.single() else {
return;
};
// 如果按下了左键(挖) 或 右键(填)
let is_digging = buttons.pressed(MouseButton::Left);
let is_building = buttons.pressed(MouseButton::Right);
if is_digging || is_building {
if let Some(cursor_pos) = window.cursor_position() {
if let Ok(world_pos) = camera.viewport_to_world_2d(camera_transform, cursor_pos) {
// 找到鼠标所在的格子
let (gx, gy) = map.world_to_grid(world_pos);
let radius = 4; // 影响半径
// 遍历周围的格子进行修改
for dy in -radius..=radius {
for dx in -radius..=radius {
let dist = ((dx * dx + dy * dy) as f32).sqrt();
if dist <= radius as f32 {
// 简单的挖掘力度计算
let amount = if is_digging { -0.1 } else { 0.1 };
map.modify_density(gx + dx, gy + dy, amount);
}
}
}
}
}
}
}

核心 marching squares 系统#

在实现方面,我们可以用四位二进制数来表示这四个点的状态(选中或未选中),从而得到 16 种组合。然后根据这些组合来决定如何绘制边界线段。

我们先遍历每一个点作为格子的左下顶点,然后分别获取四个角的密度值,根据密度值计算状态码。接着根据状态码查找对应的边界线段,并使用插值计算线段的端点位置,最后绘制这些线段。

fn draw_marching_squares(map: Res<VoxelMap>, mut gizmos: Gizmos) {
let offset_x = -(map.width as f32 * VOXEL_SIZE) / 2.0;
let offset_y = -(map.height as f32 * VOXEL_SIZE) / 2.0;
// 遍历每一个格子(作为正方形的左下角)
for y in 0..map.height as i32 - 1 {
for x in 0..map.width as i32 - 1 {
// 1. 获取正方形四个角的坐标
let p0 = Vec2::new(x as f32 * VOXEL_SIZE, y as f32 * VOXEL_SIZE)
+ Vec2::new(offset_x, offset_y); // 左下
let p1 = p0 + Vec2::new(VOXEL_SIZE, 0.0); // 右下
let p2 = p0 + Vec2::new(VOXEL_SIZE, VOXEL_SIZE); // 右上
let p3 = p0 + Vec2::new(0.0, VOXEL_SIZE); // 左上
// 2. 获取四个角的密度
let v0 = map.get_density(x, y);
let v1 = map.get_density(x + 1, y);
let v2 = map.get_density(x + 1, y + 1);
let v3 = map.get_density(x, y + 1);
// 调试显示:画出原始数据点(红色小点)
// 如果你把这几行注释掉,就只剩下平滑的线了
if v0 > 0.0 {
gizmos.circle_2d(p0, 1.0 + v0 * 2.0, Color::srgba(1.0, 0.0, 0.0, 0.3));
}
// 3. 计算“状态码” (Case Index)
// 二进制编码:如果角是墙(>0.5)设为1,否则为0
let mut case_index = 0;
if v0 >= ISO_LEVEL {
case_index |= 1;
} // 左下位
if v1 >= ISO_LEVEL {
case_index |= 2;
} // 右下位
if v2 >= ISO_LEVEL {
case_index |= 4;
} // 右上位
if v3 >= ISO_LEVEL {
case_index |= 8;
} // 左上位
// 如果全空(0)或全满(15),不需要画线
if case_index == 0 || case_index == 15 {
continue;
}
// 4. 【平滑的关键】计算插值点
// 我们不取边的中点,而是根据密度比例计算准确位置
let a = interpolate(p0, p3, v0, v3); // 左边
let b = interpolate(p3, p2, v3, v2); // 上边
let c = interpolate(p1, p2, v1, v2); // 右边
let d = interpolate(p0, p1, v0, v1); // 下边
// 5. 根据状态码画线 (这是 Marching Squares 的标准查找表逻辑)
let color = Color::srgb(0.0, 1.0, 0.0); // 绿色墙壁线
match case_index {
1 => gizmos.line_2d(a, d, color),
2 => gizmos.line_2d(d, c, color),
3 => gizmos.line_2d(a, c, color),
4 => gizmos.line_2d(c, b, color),
5 => {
gizmos.line_2d(a, d, color);
gizmos.line_2d(b, c, color);
}
6 => gizmos.line_2d(d, b, color),
7 => gizmos.line_2d(a, b, color),
8 => gizmos.line_2d(a, b, color),
9 => gizmos.line_2d(d, b, color),
10 => {
gizmos.line_2d(a, b, color);
gizmos.line_2d(c, d, color);
}
11 => gizmos.line_2d(c, b, color),
12 => gizmos.line_2d(a, c, color),
13 => gizmos.line_2d(d, c, color),
14 => gizmos.line_2d(a, d, color),
_ => {}
}
}
}
}

计算插值#

// 计算 端点 到底在 p1 和 p2 连线的什么位置
fn interpolate(p1: Vec2, p2: Vec2, v1: f32, v2: f32) -> Vec2 {
if (v2 - v1).abs() < 0.0001 {
return p1;
} // 防止除以0
let t = (ISO_LEVEL - v1) / (v2 - v1);
p1 + (p2 - p1) * t
}

项目演示#

总结#

之后只需要将画线的部分换成 Mesh 生成部分,然后再利用 Mesh 生成碰撞体就可以实现可破坏地形了。

Bevy 下实现 Marching Squares 算法
https://blog.mengh04.top/posts/marching-sqare/
作者
mengh04
发布于
2026-01-25
许可协议
CC BY-NC-SA 4.0