From af4065e149f53fd88bd564c92ee34f8b7a0ed8e8 Mon Sep 17 00:00:00 2001 From: Matthew Deville Date: Mon, 28 Sep 2026 22:58:54 +0200 Subject: [PATCH] wip --- Cargo.lock | 2 + Cargo.toml | 2 +- src/boids.rs | 206 ++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 42 +++++++++ src/main.rs | 169 ++---------------------------------- src/spatial_grid.rs | 44 ++++++++++ src/ui.rs | 145 +++++++++++++++++++++++++++++++ 7 files changed, 449 insertions(+), 161 deletions(-) create mode 100644 src/boids.rs create mode 100644 src/config.rs create mode 100644 src/spatial_grid.rs create mode 100644 src/ui.rs diff --git a/Cargo.lock b/Cargo.lock index 5ee90f6..1ee1507 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -943,6 +943,8 @@ dependencies = [ "bevy_time", "bevy_transform", "bevy_ui", + "bevy_ui_render", + "bevy_ui_widgets", "bevy_utils", "bevy_window", "bevy_winit", diff --git a/Cargo.toml b/Cargo.toml index 6994992..e064b57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" [dependencies] - bevy = { version = "0.19", default-features = false, features = ["2d"] } + bevy = { version = "0.19", default-features = false, features = ["2d", "ui"] } [profile.dev.package."*"] opt-level = 3 diff --git a/src/boids.rs b/src/boids.rs new file mode 100644 index 0000000..83d8866 --- /dev/null +++ b/src/boids.rs @@ -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::() + .add_systems(Startup, setup) + .add_systems( + Update, + sync_boid_count.run_if(resource_changed::), + ) + .add_systems(FixedUpdate, (update_spatial_grid, update_boids).chain()); + } +} + +#[derive(Component)] +pub struct Boid { + velocity: Vec2, +} + +#[derive(Resource)] +struct BoidAssets { + mesh: Mesh2d, + material: MeshMaterial2d, +} + +fn setup( + model_config: Res, + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + 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, + 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, + assets: Option>, + mut commands: Commands, + boids: Query>, +) { + 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, + window: Single<&Window, With>, + time: Res>, + grid: Res, + 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)); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9e64942 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,42 @@ +use bevy::prelude::*; + +pub struct ConfigPlugin; + +impl Plugin for ConfigPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .register_type::(); + } +} + +#[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, + } + } +} diff --git a/src/main.rs b/src/main.rs index 9442a7d..f349b58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,35 +1,9 @@ -use bevy::{platform::collections::HashMap, prelude::*}; -use std::f32::consts::PI; +mod boids; +mod config; +mod spatial_grid; +mod ui; -#[derive(Reflect, Resource)] -#[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, - } - } -} +use bevy::prelude::*; fn main() { App::new() @@ -44,134 +18,9 @@ fn main() { }), ..default() })) - .init_resource::() - .register_type::() - .add_systems(Startup, setup) - .add_systems(FixedUpdate, update_boids) + .add_plugins((config::ConfigPlugin, boids::BoidsPlugin, ui::ConfigUiPlugin)) + .add_systems(Startup, |mut commands: Commands| { + commands.spawn(Camera2d); + }) .run(); } - -#[derive(Component)] -struct Boid { - velocity: Vec2, -} - -fn setup( - model_config: Res, - mut commands: Commands, - mut meshes: ResMut>, - mut materials: ResMut>, -) { - 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, - window: Single<&Window, With>, - time: Res>, - 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)); - } -} diff --git a/src/spatial_grid.rs b/src/spatial_grid.rs new file mode 100644 index 0000000..ec90a00 --- /dev/null +++ b/src/spatial_grid.rs @@ -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 { + 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, + mut grid: ResMut, + boids: Query<(Entity, &Transform), With>, +) { + 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)); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..2b03db8 --- /dev/null +++ b/src/ui.rs @@ -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::), + ), + ); + } +} + +/// 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::() { + format!("{v:.2}") + } else if let Some(v) = field.try_downcast_ref::() { + 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::() { + *v = (*v + delta).max(0.0); + } else if let Some(v) = field.try_downcast_mut::() { + *v = v.saturating_add_signed(delta as isize); + } +} + +fn setup_ui(mut commands: Commands, model_config: Res) { + 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>, mut config: ResMut| { + step_field(&mut config, field, delta); + }, + ); +} + +/// Shows or hides the config panel when M is pressed. +fn toggle_panel( + keys: Res>, + mut panel: Single<&mut Visibility, With>, +) { + if keys.just_pressed(KeyCode::KeyM) { + panel.toggle_visible_hidden(); + } +} + +fn update_config_labels( + model_config: Res, + mut labels: Query<(&ConfigLabel, &mut Text)>, +) { + for (label, mut text) in &mut labels { + text.0 = read_field(&model_config, label.0); + } +}