Flocking params as a resource & egui-inspector

The birdoid flocking parameters are now a resource named
`FlockingParameters`. Adjustments can be made using the Egui inspector
widget, although I plan to make a custom UI in the future.
This commit is contained in:
2025-12-21 16:31:26 -06:00
parent 0828518963
commit c2cf100c05
4 changed files with 911 additions and 146 deletions

873
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ license = "AGPL-3.0-only"
[dependencies] [dependencies]
bevy = "0.16.0" bevy = "0.16.0"
bevy-inspector-egui = "0.32"
# Grand-dependency pins # Grand-dependency pins
# ab_glyph = "0.2.16" # ab_glyph = "0.2.16"

View File

@@ -6,14 +6,12 @@ use bevy_spatial::{
}; };
use crate::birdoids::physics::{Force, Velocity, apply_velocity}; use crate::birdoids::physics::{Force, Velocity, apply_velocity};
use bevy_inspector_egui::{InspectorOptions, prelude::ReflectInspectorOptions};
const BACKGROUND_COLOR: Color = Color::srgb(0.4, 0.4, 0.4); const BACKGROUND_COLOR: Color = Color::srgb(0.4, 0.4, 0.4);
const PLAYERBOID_COLOR: Color = Color::srgb(1.0, 0.0, 0.0); const PLAYERBOID_COLOR: Color = Color::srgb(1.0, 0.0, 0.0);
const TURN_FACTOR: f32 = 1.0; const TURN_FACTOR: f32 = 1.0;
const BOID_VIEW_RANGE: f32 = 15.0;
const COHESION_FACTOR: f32 = 1.0;
const SEPARATION_FACTOR: f32 = 5.0;
const ALIGNMENT_FACTOR: f32 = 2.0;
const SPACEBRAKES_COEFFICIENT: f32 = 0.5; const SPACEBRAKES_COEFFICIENT: f32 = 0.5;
const LOW_SPEED_THRESHOLD: f32 = 50.0; const LOW_SPEED_THRESHOLD: f32 = 50.0;
const HIGH_SPEED_THRESHOLD: f32 = 200.0; const HIGH_SPEED_THRESHOLD: f32 = 200.0;
@@ -29,6 +27,8 @@ impl Plugin for BoidsPlugin {
.with_spatial_ds(SpatialStructure::KDTree2), .with_spatial_ds(SpatialStructure::KDTree2),
) )
.insert_resource(ClearColor(BACKGROUND_COLOR)) .insert_resource(ClearColor(BACKGROUND_COLOR))
.insert_resource(FlockingParameters::new())
.register_type::<FlockingParameters>()
.add_systems(Startup, (spawn_camera, spawn_boids)) .add_systems(Startup, (spawn_camera, spawn_boids))
.add_systems( .add_systems(
FixedUpdate, FixedUpdate,
@@ -45,6 +45,26 @@ impl Plugin for BoidsPlugin {
} }
} }
#[derive(InspectorOptions, Reflect, Resource, Debug, Clone, Copy)]
#[reflect(Resource, InspectorOptions)]
pub(crate) struct FlockingParameters {
view_range: f32,
cohesion: f32,
separation: f32,
alignment: f32,
}
impl FlockingParameters {
pub(crate) fn new() -> Self {
Self {
view_range: 15.0,
cohesion: 1.0,
separation: 5.0,
alignment: 2.0,
}
}
}
#[derive(Component)] #[derive(Component)]
#[require(Velocity, Force, TrackedByKdTree)] #[require(Velocity, Force, TrackedByKdTree)]
pub(crate) struct Boid; pub(crate) struct Boid;
@@ -157,6 +177,7 @@ fn check_keyboard(
fn cohesion( fn cohesion(
spatial_tree: Res<KDTree2<TrackedByKdTree>>, spatial_tree: Res<KDTree2<TrackedByKdTree>>,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>, mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
props: Res<FlockingParameters>,
) { ) {
// for each boid // for each boid
// find neighbors // find neighbors
@@ -165,7 +186,7 @@ fn cohesion(
// apply force // apply force
for (this_entt, transform, mut force) in &mut boids { for (this_entt, transform, mut force) in &mut boids {
let (len, sum) = spatial_tree let (len, sum) = spatial_tree
.within_distance(transform.translation.xy(), BOID_VIEW_RANGE) .within_distance(transform.translation.xy(), props.view_range)
.iter() .iter()
.filter_map(|(pos, entt)| { .filter_map(|(pos, entt)| {
// Skip self-comparison. A boid should not try to separate from itself. // Skip self-comparison. A boid should not try to separate from itself.
@@ -183,15 +204,17 @@ fn cohesion(
continue; continue;
}; };
let impulse = cohesive_force(center_of_mass, transform.translation.xy()).expect("damn"); let impulse = cohesive_force(center_of_mass, transform.translation.xy(), props.view_range)
.expect("damn");
force.0 -= impulse.0 * COHESION_FACTOR; force.0 -= impulse.0 * props.cohesion;
} }
} }
fn separation( fn separation(
spatial_tree: Res<KDTree2<TrackedByKdTree>>, spatial_tree: Res<KDTree2<TrackedByKdTree>>,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>, mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
props: Res<FlockingParameters>,
) { ) {
// for each boid // for each boid
// find neighbors // find neighbors
@@ -199,7 +222,7 @@ fn separation(
// apply force // apply force
for (this_entt, tsfm, mut force) in &mut boids { for (this_entt, tsfm, mut force) in &mut boids {
let impulse = spatial_tree let impulse = spatial_tree
.within_distance(tsfm.translation.xy(), BOID_VIEW_RANGE / 4.0) .within_distance(tsfm.translation.xy(), props.view_range / 4.0)
.iter() .iter()
.filter_map(|(pos, entt)| { .filter_map(|(pos, entt)| {
// Skip self-comparison. A boid should not try to separate from itself. // Skip self-comparison. A boid should not try to separate from itself.
@@ -213,10 +236,11 @@ fn separation(
}) })
.fold(Vec3::ZERO, |acc, other| { .fold(Vec3::ZERO, |acc, other| {
// let force = tsfm.translation - other; // let force = tsfm.translation - other;
let force = separation_force(tsfm.translation.xy(), other.xy()).expect("angy"); let force = separation_force(tsfm.translation.xy(), other.xy(), props.view_range)
.expect("angy");
acc + force.0 acc + force.0
}); });
force.0 += impulse * SEPARATION_FACTOR; force.0 += impulse * props.separation;
} }
} }
@@ -224,6 +248,7 @@ fn alignment(
spatial_tree: Res<KDTree2<TrackedByKdTree>>, spatial_tree: Res<KDTree2<TrackedByKdTree>>,
mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>, mut boids: Query<(Entity, &Transform, &mut Force), With<Boid>>,
boid_velocities: Query<&Velocity, With<Boid>>, boid_velocities: Query<&Velocity, With<Boid>>,
props: Res<FlockingParameters>,
) { ) {
// for each boid // for each boid
// find neighbors // find neighbors
@@ -233,7 +258,7 @@ fn alignment(
// apply steering force // apply steering force
for (this_entt, transform, mut force) in &mut boids { for (this_entt, transform, mut force) in &mut boids {
let neighbors = spatial_tree.within_distance(transform.translation.xy(), BOID_VIEW_RANGE); let neighbors = spatial_tree.within_distance(transform.translation.xy(), props.view_range);
// averaging divides by length. Guard against an empty set of neighbors // averaging divides by length. Guard against an empty set of neighbors
let (len, sum) = neighbors let (len, sum) = neighbors
.iter() .iter()
@@ -261,7 +286,7 @@ fn alignment(
}; };
let boid_vel = boid_velocities.get(this_entt).unwrap(); let boid_vel = boid_velocities.get(this_entt).unwrap();
force.0 += (avg.extend(0.0) - boid_vel.0) * ALIGNMENT_FACTOR; force.0 += (avg.extend(0.0) - boid_vel.0) * props.alignment;
} }
} }
@@ -287,13 +312,13 @@ fn average_of_vec2s(points: impl Iterator<Item = Vec2>) -> Option<Vec2> {
} }
// f(x) = 4((x-0.5)^3 + 0.125) // f(x) = 4((x-0.5)^3 + 0.125)
fn cohesive_force(boid: Vec2, target: Vec2) -> Option<Force> { fn cohesive_force(boid: Vec2, target: Vec2, view_range: f32) -> Option<Force> {
let deviation = target - boid; let deviation = target - boid;
/* /*
Scale deviation vector by the boid's view range. The curve is made to Scale deviation vector by the boid's view range. The curve is made to
operate on the range (0, 1), so that needs to be the viewing circle. operate on the range (0, 1), so that needs to be the viewing circle.
*/ */
let scaled = deviation / BOID_VIEW_RANGE; let scaled = deviation / view_range;
let mag = scaled.length(); let mag = scaled.length();
if mag > 0.0 { if mag > 0.0 {
let cube: f32 = (mag - 0.5).powf(3.0); let cube: f32 = (mag - 0.5).powf(3.0);
@@ -310,9 +335,9 @@ fn cohesive_force(boid: Vec2, target: Vec2) -> Option<Force> {
} }
// f(x) = x^2 - 1 // f(x) = x^2 - 1
fn separation_force(boid: Vec2, target: Vec2) -> Option<Force> { fn separation_force(boid: Vec2, target: Vec2, view_range: f32) -> Option<Force> {
// Scale from BOID_VIEW_RANGE to unit space // Scale from BOID_VIEW_RANGE to unit space
let distance_unit = (target - boid) / BOID_VIEW_RANGE; let distance_unit = (target - boid) / view_range;
let mag = distance_unit.length(); let mag = distance_unit.length();
if mag > 0.0 { if mag > 0.0 {
let force_mag = mag.powf(2.0) - 1.0; let force_mag = mag.powf(2.0) - 1.0;
@@ -329,14 +354,15 @@ mod tests {
use crate::birdoids::{cohesive_force, separation_force}; use crate::birdoids::{cohesive_force, separation_force};
use super::{BOID_VIEW_RANGE, physics::Force}; use super::{FlockingParameters, physics::Force};
// forces are relative to the boid's view range, so all // forces are relative to the boid's view range, so all
// distances need to be fractions of that // distances need to be fractions of that
#[test] #[test]
fn check_cohesion_zero_zero() { fn check_cohesion_zero_zero() {
let force = cohesive_force(Vec2::ZERO, Vec2::ZERO); let props = FlockingParameters::new();
let force = cohesive_force(Vec2::ZERO, Vec2::ZERO, props.view_range);
assert!(force.is_none()); assert!(force.is_none());
} }
@@ -347,36 +373,56 @@ mod tests {
#[test] #[test]
fn check_cohesion_midpoint_x_positive() { fn check_cohesion_midpoint_x_positive() {
// Pull right 0.5 units // Pull right 0.5 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.5, 0.0, 0.0))), Some(Force(Vec3::new(0.5, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.5 * BOID_VIEW_RANGE, 0.0),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.5 * props.view_range, 0.0),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_midpoint_x_negative() { fn check_cohesion_midpoint_x_negative() {
// Pull left 0.5 units // Pull left 0.5 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(-0.5, 0.0, 0.0))), Some(Force(Vec3::new(-0.5, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(-0.5 * BOID_VIEW_RANGE, 0.0),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(-0.5 * props.view_range, 0.0),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_edge_x_positive() { fn check_cohesion_edge_x_positive() {
// pull left 1.0 units // pull left 1.0 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(1.0, 0.0, 0.0))), Some(Force(Vec3::new(1.0, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(1.0 * BOID_VIEW_RANGE, 0.0),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(1.0 * props.view_range, 0.0),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_edge_x_negative() { fn check_cohesion_edge_x_negative() {
// pull left 1.0 units // pull left 1.0 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(-1.0, 0.0, 0.0))), Some(Force(Vec3::new(-1.0, 0.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(-1.0 * BOID_VIEW_RANGE, 0.0),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(-1.0 * props.view_range, 0.0),
props.view_range
)
); );
} }
@@ -387,43 +433,64 @@ mod tests {
#[test] #[test]
fn check_cohesion_midpoint_y_positive() { fn check_cohesion_midpoint_y_positive() {
// Pull up 0.5 units // Pull up 0.5 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, 0.5, 0.0))), Some(Force(Vec3::new(0.0, 0.5, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, 0.5 * BOID_VIEW_RANGE),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, 0.5 * props.view_range),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_midpoint_y_negative() { fn check_cohesion_midpoint_y_negative() {
// Pull down 0.5 units // Pull down 0.5 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, -0.5, 0.0))), Some(Force(Vec3::new(0.0, -0.5, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, -0.5 * BOID_VIEW_RANGE),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, -0.5 * props.view_range),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_edge_y_positive() { fn check_cohesion_edge_y_positive() {
// Pull up 1.0 units // Pull up 1.0 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, 1.0, 0.0))), Some(Force(Vec3::new(0.0, 1.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, 1.0 * BOID_VIEW_RANGE)) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, 1.0 * props.view_range),
props.view_range
)
); );
} }
#[test] #[test]
fn check_cohesion_edge_y_negative() { fn check_cohesion_edge_y_negative() {
// pull down 0.2 units // pull down 0.2 units
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, -1.0, 0.0))), Some(Force(Vec3::new(0.0, -1.0, 0.0))),
cohesive_force(Vec2::new(0.0, 0.0), Vec2::new(0.0, -1.0 * BOID_VIEW_RANGE),) cohesive_force(
Vec2::new(0.0, 0.0),
Vec2::new(0.0, -1.0 * props.view_range),
props.view_range
)
); );
} }
// Separation 0,0 test // Separation 0,0 test
#[test] #[test]
fn check_separation_zero_zero() { fn check_separation_zero_zero() {
let force = separation_force(Vec2::ZERO, Vec2::ZERO); let props = FlockingParameters::new();
let force = separation_force(Vec2::ZERO, Vec2::ZERO, props.view_range);
assert!(force.is_none()); assert!(force.is_none());
} }
@@ -432,39 +499,53 @@ mod tests {
// ********************* // *********************
#[test] #[test]
fn check_separation_midpoint_x_positive() { fn check_separation_midpoint_x_positive() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.75, 0.0, 0.0))), // expected force Some(Force(Vec3::new(0.75, 0.0, 0.0))), // expected force
separation_force( separation_force(
Vec2::new(0.5 * BOID_VIEW_RANGE, 0.0), // boid position Vec2::new(0.5 * props.view_range, 0.0), // boid position
Vec2::ZERO, // obstacle position Vec2::ZERO, // obstacle position
props.view_range
) )
); );
} }
#[test] #[test]
fn check_separation_midpoint_x_negative() { fn check_separation_midpoint_x_negative() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(-0.75, 0.0, 0.0))), // expected force Some(Force(Vec3::new(-0.75, 0.0, 0.0))), // expected force
separation_force( separation_force(
Vec2::new(-0.5 * BOID_VIEW_RANGE, 0.0), // boid position Vec2::new(-0.5 * props.view_range, 0.0), // boid position
Vec2::ZERO, // obstacle position Vec2::ZERO, // obstacle position
props.view_range
) )
); );
} }
#[test] #[test]
fn check_separation_edge_x_positive() { fn check_separation_edge_x_positive() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::ZERO)), Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(1.0 * BOID_VIEW_RANGE, 0.0), Vec2::ZERO,), separation_force(
Vec2::new(1.0 * props.view_range, 0.0),
Vec2::ZERO,
props.view_range
),
); );
} }
#[test] #[test]
fn check_separation_edge_x_negative() { fn check_separation_edge_x_negative() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::ZERO)), Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(-1.0 * BOID_VIEW_RANGE, 0.0), Vec2::ZERO,), separation_force(
Vec2::new(-1.0 * props.view_range, 0.0),
Vec2::ZERO,
props.view_range
),
); );
} }
@@ -473,33 +554,53 @@ mod tests {
// ********************* // *********************
#[test] #[test]
fn check_separation_midpoint_y_positive() { fn check_separation_midpoint_y_positive() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, 0.75, 0.0))), Some(Force(Vec3::new(0.0, 0.75, 0.0))),
separation_force(Vec2::new(0.0, 0.5 * BOID_VIEW_RANGE), Vec2::ZERO,) separation_force(
Vec2::new(0.0, 0.5 * props.view_range),
Vec2::ZERO,
props.view_range
)
); );
} }
#[test] #[test]
fn check_separation_midpoint_y_negative() { fn check_separation_midpoint_y_negative() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::new(0.0, -0.75, 0.0))), Some(Force(Vec3::new(0.0, -0.75, 0.0))),
separation_force(Vec2::new(0.0, -0.5 * BOID_VIEW_RANGE), Vec2::ZERO,) separation_force(
Vec2::new(0.0, -0.5 * props.view_range),
Vec2::ZERO,
props.view_range
)
); );
} }
#[test] #[test]
fn check_separation_edge_y_positive() { fn check_separation_edge_y_positive() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::ZERO)), Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(0.0, 1.0 * BOID_VIEW_RANGE), Vec2::ZERO,) separation_force(
Vec2::new(0.0, 1.0 * props.view_range),
Vec2::ZERO,
props.view_range
)
) )
} }
#[test] #[test]
fn check_separation_edge_y_negative() { fn check_separation_edge_y_negative() {
let props = FlockingParameters::new();
assert_eq!( assert_eq!(
Some(Force(Vec3::ZERO)), Some(Force(Vec3::ZERO)),
separation_force(Vec2::new(0.0, -1.0 * BOID_VIEW_RANGE), Vec2::ZERO,) separation_force(
Vec2::new(0.0, -1.0 * props.view_range),
Vec2::ZERO,
props.view_range
)
) )
} }
} }

View File

@@ -3,9 +3,15 @@ use bevy::prelude::*;
mod birdoids; mod birdoids;
mod debug_plugin; mod debug_plugin;
use bevy_inspector_egui::{
bevy_egui::EguiPlugin,
quick::{ResourceInspectorPlugin},
};
use birdoids::BoidsPlugin; use birdoids::BoidsPlugin;
use debug_plugin::BoidsDebugPlugin; use debug_plugin::BoidsDebugPlugin;
use crate::birdoids::FlockingParameters;
fn main() { fn main() {
App::new() App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin { .add_plugins(DefaultPlugins.set(WindowPlugin {
@@ -18,5 +24,7 @@ fn main() {
})) }))
.add_plugins(BoidsDebugPlugin) .add_plugins(BoidsDebugPlugin)
.add_plugins(BoidsPlugin) .add_plugins(BoidsPlugin)
.add_plugins(EguiPlugin::default())
.add_plugins(ResourceInspectorPlugin::<FlockingParameters>::new()) // TODO: monitor only the flocking params resource (once it exists)
.run(); .run();
} }