This commit is contained in:
Matthew Deville 2026-09-28 22:58:54 +02:00
parent 2ce0307b3c
commit af4065e149
7 changed files with 449 additions and 161 deletions

2
Cargo.lock generated
View file

@ -943,6 +943,8 @@ dependencies = [
"bevy_time", "bevy_time",
"bevy_transform", "bevy_transform",
"bevy_ui", "bevy_ui",
"bevy_ui_render",
"bevy_ui_widgets",
"bevy_utils", "bevy_utils",
"bevy_window", "bevy_window",
"bevy_winit", "bevy_winit",

View file

@ -4,7 +4,7 @@
version = "0.1.0" version = "0.1.0"
[dependencies] [dependencies]
bevy = { version = "0.19", default-features = false, features = ["2d"] } bevy = { version = "0.19", default-features = false, features = ["2d", "ui"] }
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 3 opt-level = 3

206
src/boids.rs Normal file
View file

@ -0,0 +1,206 @@
use bevy::{platform::collections::HashMap, prelude::*};
use std::f32::consts::PI;
use crate::{
config::ModelConfig,
spatial_grid::{SpatialGrid, update_spatial_grid},
};
pub struct BoidsPlugin;
impl Plugin for BoidsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SpatialGrid>()
.add_systems(Startup, setup)
.add_systems(
Update,
sync_boid_count.run_if(resource_changed::<ModelConfig>),
)
.add_systems(FixedUpdate, (update_spatial_grid, update_boids).chain());
}
}
#[derive(Component)]
pub struct Boid {
velocity: Vec2,
}
#[derive(Resource)]
struct BoidAssets {
mesh: Mesh2d,
material: MeshMaterial2d<ColorMaterial>,
}
fn setup(
model_config: Res<ModelConfig>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
let ModelConfig {
num_boids,
max_velocity,
..
} = *model_config;
// Triangle pointing right
let assets = BoidAssets {
mesh: Mesh2d(meshes.add(Triangle2d::new(
Vec2::new(10.0, 0.0),
Vec2::new(-3.0, 3.0),
Vec2::new(-3.0, -3.0),
))),
material: MeshMaterial2d(materials.add(Color::WHITE)),
};
spawn_boids(
&mut commands,
&assets,
0..num_boids,
num_boids,
max_velocity,
);
commands.insert_resource(assets);
}
fn spawn_boids(
commands: &mut Commands,
assets: &BoidAssets,
range: std::ops::Range<usize>,
num_boids: usize,
max_velocity: f32,
) {
let mesh = assets.mesh.clone();
let material = assets.material.clone();
commands.spawn_batch(range.map(move |n| {
let angle = 2.0 * PI / (num_boids as f32) * n as f32;
(
Boid {
velocity: Vec2::from_angle(angle) * max_velocity,
},
mesh.clone(),
material.clone(),
Transform::default().rotate(Quat::from_rotation_z(angle)),
)
}));
}
/// Spawns or despawns boids so that their count matches `ModelConfig::num_boids`.
fn sync_boid_count(
model_config: Res<ModelConfig>,
assets: Option<Res<BoidAssets>>,
mut commands: Commands,
boids: Query<Entity, With<Boid>>,
) {
let Some(assets) = assets else {
return;
};
let current = boids.iter().len();
let target = model_config.num_boids;
if target > current {
spawn_boids(
&mut commands,
&assets,
current..target,
target,
model_config.max_velocity,
);
} else {
for entity in boids.iter().take(current - target) {
commands.entity(entity).despawn();
}
}
}
struct Model {
separation: Vec2,
alignment: Vec2,
cohesion: Vec2,
}
fn update_boids(
model_config: Res<ModelConfig>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
time: Res<Time<Fixed>>,
grid: Res<SpatialGrid>,
mut query: Query<(Entity, &mut Boid, &mut Transform)>,
) {
let dt = time.delta_secs();
let x_bound = window.width() * model_config.x_bound;
let y_bound = window.height() * model_config.y_bound;
let mut model_updates = HashMap::new();
for (entity, boid, transform) in query.iter() {
let mut nb_neighbors: usize = 0;
let mut separation = Vec2::new(0.0, 0.0);
let mut avg_speed = Vec2::new(0.0, 0.0);
let mut avg_pos = Vec2::new(0.0, 0.0);
for &(other_entity, other_position) in grid.neighbors(transform.translation.xy()) {
if entity == other_entity {
continue;
}
let delta = transform.translation.xy() - other_position;
let distance = delta.length();
if distance < model_config.protected_range {
if distance > f32::EPSILON {
let direction = delta / distance;
let strength = 1.0 - distance / model_config.protected_range;
separation += direction * strength;
}
} else if distance < model_config.visible_range {
let Ok((_, other_boid, _)) = query.get(other_entity) else {
continue;
};
avg_speed += other_boid.velocity;
avg_pos += other_position;
nb_neighbors += 1;
}
}
if nb_neighbors > 0 {
avg_speed /= nb_neighbors as f32;
avg_pos /= nb_neighbors as f32;
}
model_updates.insert(
entity,
Model {
separation,
alignment: avg_speed - boid.velocity,
cohesion: avg_pos - transform.translation.xy(),
},
);
}
for (entity, mut boid, mut transform) in query.iter_mut() {
let mut out_of_bounds_acceleration = Vec2::new(0.0, 0.0);
if transform.translation.x < -x_bound / 2.0 {
out_of_bounds_acceleration.x = 1.0;
} else if transform.translation.x > x_bound / 2.0 {
out_of_bounds_acceleration.x = -1.0;
}
if transform.translation.y < -y_bound / 2.0 {
out_of_bounds_acceleration.y = 1.0;
} else if transform.translation.y > y_bound / 2.0 {
out_of_bounds_acceleration.y = -1.0;
}
let out_of_bounds_acceleration = out_of_bounds_acceleration.normalize_or_zero();
let model_update = model_updates.get(&entity).unwrap();
let boundary_force = out_of_bounds_acceleration * model_config.max_velocity;
let separation_force = model_update.separation * model_config.separation_factor;
let alignment_force = model_update.alignment * model_config.alignment_factor;
let cohesion_force = model_update.cohesion * model_config.cohesion_factor;
// Push each boid forward along its current heading
let self_force = boid.velocity.normalize_or_zero() * model_config.self_acceleration;
let acceleration =
boundary_force + separation_force + alignment_force + cohesion_force + self_force;
boid.velocity += acceleration * dt;
boid.velocity = boid.velocity.clamp_length_max(model_config.max_velocity);
transform.translation.x += boid.velocity.x * dt;
transform.translation.y += boid.velocity.y * dt;
transform.rotation = Quat::from_rotation_z(boid.velocity.y.atan2(boid.velocity.x));
}
}

42
src/config.rs Normal file
View file

@ -0,0 +1,42 @@
use bevy::prelude::*;
pub struct ConfigPlugin;
impl Plugin for ConfigPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ModelConfig>()
.register_type::<ModelConfig>();
}
}
#[derive(Reflect, Resource)]
#[reflect(Resource)]
pub struct ModelConfig {
pub num_boids: usize,
pub x_bound: f32,
pub y_bound: f32,
pub max_velocity: f32,
pub protected_range: f32,
pub visible_range: f32,
pub alignment_factor: f32,
pub cohesion_factor: f32,
pub separation_factor: f32,
pub self_acceleration: f32,
}
impl Default for ModelConfig {
fn default() -> Self {
Self {
num_boids: 300,
x_bound: 0.75,
y_bound: 0.75,
max_velocity: 250.0,
protected_range: 20.0,
visible_range: 230.0,
alignment_factor: 0.3,
cohesion_factor: 0.2,
separation_factor: 500.0,
self_acceleration: 30.0,
}
}
}

View file

@ -1,35 +1,9 @@
use bevy::{platform::collections::HashMap, prelude::*}; mod boids;
use std::f32::consts::PI; mod config;
mod spatial_grid;
mod ui;
#[derive(Reflect, Resource)] use bevy::prelude::*;
#[reflect(Resource)]
struct ModelConfig {
num_boids: usize,
x_bound: f32,
y_bound: f32,
max_velocity: f32,
protected_range: f32,
visible_range: f32,
alignment_factor: f32,
cohesion_factor: f32,
separation_factor: f32,
}
impl Default for ModelConfig {
fn default() -> Self {
Self {
num_boids: 50,
x_bound: 0.7,
y_bound: 0.7,
max_velocity: 200.0,
protected_range: 50.0,
visible_range: 150.0,
alignment_factor: 0.5,
cohesion_factor: 0.5,
separation_factor: 200.0,
}
}
}
fn main() { fn main() {
App::new() App::new()
@ -44,134 +18,9 @@ fn main() {
}), }),
..default() ..default()
})) }))
.init_resource::<ModelConfig>() .add_plugins((config::ConfigPlugin, boids::BoidsPlugin, ui::ConfigUiPlugin))
.register_type::<ModelConfig>() .add_systems(Startup, |mut commands: Commands| {
.add_systems(Startup, setup) commands.spawn(Camera2d);
.add_systems(FixedUpdate, update_boids) })
.run(); .run();
} }
#[derive(Component)]
struct Boid {
velocity: Vec2,
}
fn setup(
model_config: Res<ModelConfig>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
let ModelConfig {
num_boids,
max_velocity,
..
} = *model_config;
commands.spawn(Camera2d);
// Triangle pointing right
let triangle_mesh = Mesh2d(meshes.add(Triangle2d::new(
Vec2::new(10.0, 0.0),
Vec2::new(-3.0, 3.0),
Vec2::new(-3.0, -3.0),
)));
let triangle_material = MeshMaterial2d(materials.add(Color::WHITE));
commands.spawn_batch((0..num_boids).map(move |n| {
let angle = 2.0 * PI / (num_boids as f32) * n as f32;
(
Boid {
velocity: Vec2::from_angle(angle) * max_velocity,
},
triangle_mesh.clone(),
triangle_material.clone(),
Transform::default().rotate(Quat::from_rotation_z(angle)),
)
}));
}
struct Model {
separation: Vec2,
alignment: Vec2,
cohesion: Vec2,
}
fn update_boids(
model_config: Res<ModelConfig>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
time: Res<Time<Fixed>>,
mut query: Query<(Entity, &mut Boid, &mut Transform)>,
) {
let dt = time.delta_secs();
let x_bound = window.width() * model_config.x_bound;
let y_bound = window.height() * model_config.y_bound;
let mut model_updates = HashMap::new();
for (entity, boid, transform) in query.iter() {
let mut nb_neighbors: usize = 0;
let mut separation = Vec2::new(0.0, 0.0);
let mut avg_speed = Vec2::new(0.0, 0.0);
let mut avg_pos = Vec2::new(0.0, 0.0);
for (other_entity, other_boid, other_transform) in query.iter() {
if entity == other_entity {
continue;
}
let delta = transform.translation.xy() - other_transform.translation.xy();
let distance = delta.length();
if distance < model_config.protected_range {
if distance > f32::EPSILON {
let direction = delta / distance;
let strength = 1.0 - distance / model_config.protected_range;
separation += direction * strength;
}
} else if distance < model_config.visible_range {
avg_speed += other_boid.velocity;
avg_pos += other_transform.translation.xy();
nb_neighbors += 1;
}
}
if nb_neighbors > 0 {
avg_speed /= nb_neighbors as f32;
avg_pos /= nb_neighbors as f32;
}
model_updates.insert(
entity,
Model {
separation,
alignment: avg_speed - boid.velocity,
cohesion: avg_pos - transform.translation.xy(),
},
);
}
for (entity, mut boid, mut transform) in query.iter_mut() {
let mut out_of_bounds_acceleration = Vec2::new(0.0, 0.0);
if transform.translation.x < -x_bound / 2.0 {
out_of_bounds_acceleration.x = 1.0;
} else if transform.translation.x > x_bound / 2.0 {
out_of_bounds_acceleration.x = -1.0;
}
if transform.translation.y < -y_bound / 2.0 {
out_of_bounds_acceleration.y = 1.0;
} else if transform.translation.y > y_bound / 2.0 {
out_of_bounds_acceleration.y = -1.0;
}
let out_of_bounds_acceleration = out_of_bounds_acceleration.normalize_or_zero();
let model_update = model_updates.get(&entity).unwrap();
let boundary_force = out_of_bounds_acceleration * model_config.max_velocity;
let separation_force = model_update.separation * model_config.separation_factor;
let alignment_force = model_update.alignment * model_config.alignment_factor;
let cohesion_force = model_update.cohesion * model_config.cohesion_factor;
let acceleration = boundary_force + separation_force + alignment_force + cohesion_force;
boid.velocity += acceleration * dt;
boid.velocity = boid.velocity.clamp_length_max(model_config.max_velocity);
transform.translation.x += boid.velocity.x * dt;
transform.translation.y += boid.velocity.y * dt;
transform.rotation = Quat::from_rotation_z(boid.velocity.y.atan2(boid.velocity.x));
}
}

44
src/spatial_grid.rs Normal file
View file

@ -0,0 +1,44 @@
use bevy::{platform::collections::HashMap, prelude::*};
use crate::{boids::Boid, config::ModelConfig};
#[derive(Resource, Default)]
pub struct SpatialGrid {
pub cell_size: f32,
// Maps a grid coordinate (x, y) to the boids (and their positions) in that cell
pub cells: HashMap<(i32, i32), Vec<(Entity, Vec2)>>,
}
impl SpatialGrid {
fn cell(&self, position: Vec2) -> (i32, i32) {
let cell = (position / self.cell_size).floor();
(cell.x as i32, cell.y as i32)
}
/// Entries in the 3x3 block of cells around `position`. With `cell_size` at
/// least the search radius, this covers every boid within that radius.
pub fn neighbors(&self, position: Vec2) -> impl Iterator<Item = &(Entity, Vec2)> {
let (cx, cy) = self.cell(position);
(cx - 1..=cx + 1)
.flat_map(move |x| (cy - 1..=cy + 1).map(move |y| (x, y)))
.filter_map(|key| self.cells.get(&key))
.flatten()
}
}
pub fn update_spatial_grid(
model_config: Res<ModelConfig>,
mut grid: ResMut<SpatialGrid>,
boids: Query<(Entity, &Transform), With<Boid>>,
) {
grid.cell_size = model_config
.visible_range
.max(model_config.protected_range)
.max(1.0);
grid.cells.clear();
for (entity, transform) in &boids {
let position = transform.translation.xy();
let cell = grid.cell(position);
grid.cells.entry(cell).or_default().push((entity, position));
}
}

145
src/ui.rs Normal file
View file

@ -0,0 +1,145 @@
use bevy::{prelude::*, reflect::structs::Struct};
use crate::config::ModelConfig;
pub struct ConfigUiPlugin;
impl Plugin for ConfigUiPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, setup_ui).add_systems(
Update,
(
toggle_panel,
update_config_labels.run_if(resource_changed::<ModelConfig>),
),
);
}
}
/// Editable `ModelConfig` fields and the amount each -/+ click changes them by.
const CONFIG_FIELDS: &[(&str, f32)] = &[
("num_boids", 5.0),
("x_bound", 0.05),
("y_bound", 0.05),
("max_velocity", 10.0),
("protected_range", 5.0),
("visible_range", 10.0),
("alignment_factor", 0.05),
("cohesion_factor", 0.05),
("separation_factor", 10.0),
("self_acceleration", 5.0),
];
#[derive(Component)]
struct ConfigPanel;
#[derive(Component)]
struct ConfigLabel(&'static str);
fn read_field(config: &ModelConfig, name: &str) -> String {
let field = config.field(name).unwrap();
if let Some(v) = field.try_downcast_ref::<f32>() {
format!("{v:.2}")
} else if let Some(v) = field.try_downcast_ref::<usize>() {
v.to_string()
} else {
unreachable!("unsupported ModelConfig field type for {name}")
}
}
fn step_field(config: &mut ModelConfig, name: &str, delta: f32) {
let field = config.field_mut(name).unwrap();
if let Some(v) = field.try_downcast_mut::<f32>() {
*v = (*v + delta).max(0.0);
} else if let Some(v) = field.try_downcast_mut::<usize>() {
*v = v.saturating_add_signed(delta as isize);
}
}
fn setup_ui(mut commands: Commands, model_config: Res<ModelConfig>) {
let font = TextFont::from_font_size(14.0);
commands
.spawn((
Node {
position_type: PositionType::Absolute,
top: px(10),
left: px(10),
padding: UiRect::all(px(8)),
display: Display::Grid,
// name, -, value, +
grid_template_columns: vec![RepeatedGridTrack::auto(4)],
row_gap: px(4),
column_gap: px(6),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(px(6)),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
Visibility::Hidden,
ConfigPanel,
))
.with_children(|panel| {
for &(name, step) in CONFIG_FIELDS {
panel.spawn((Text::new(name), font.clone()));
spawn_step_button(panel, "-", name, -step, &font);
panel.spawn((
Text::new(read_field(&model_config, name)),
font.clone(),
TextLayout::justify(Justify::Center),
Node {
min_width: px(60),
..default()
},
ConfigLabel(name),
));
spawn_step_button(panel, "+", name, step, &font);
}
});
}
fn spawn_step_button(
parent: &mut ChildSpawnerCommands,
label: &str,
field: &'static str,
delta: f32,
font: &TextFont,
) {
parent
.spawn((
Button,
Node {
width: px(22),
height: px(22),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::all(px(4)),
..default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.4)),
children![(Text::new(label), font.clone())],
))
.observe(
move |_: On<Pointer<Click>>, mut config: ResMut<ModelConfig>| {
step_field(&mut config, field, delta);
},
);
}
/// Shows or hides the config panel when M is pressed.
fn toggle_panel(
keys: Res<ButtonInput<KeyCode>>,
mut panel: Single<&mut Visibility, With<ConfigPanel>>,
) {
if keys.just_pressed(KeyCode::KeyM) {
panel.toggle_visible_hidden();
}
}
fn update_config_labels(
model_config: Res<ModelConfig>,
mut labels: Query<(&ConfigLabel, &mut Text)>,
) {
for (label, mut text) in &mut labels {
text.0 = read_field(&model_config, label.0);
}
}