use henad_compute::cpu::agent_engine::{
NUM_AGENTS, WORLD_HEIGHT, WORLD_WIDTH, agent_init_rng, agent_model_param_descriptors, split_params,
};
use henad_core::action::ActionDescriptor;
use henad_core::authoring::model::agent_model::{AgentLanes as _, AgentModel as _};
use henad_core::authoring::model::field::Extent;
use henad_core::authoring::model::gpu_agent_model::{
BufferSpec, Domain, Geometry, GpuAgentAction, GpuAgentModel, PassCtx, PassId, PassSpec, ReduceSpec,
};
use henad_core::helpers::{extract_f32, extract_u32};
use henad_core::params::{ParamDescriptor, ParamValue};
use henad_core::view::{StatDescriptor, StatValue};
use crate::boids::{BoidLanes, BoidsModel, HEADING_PALETTE};
use crate::shader_bindings::gpu_boids::randomise_headings::Params as ActionParams;
use crate::shader_bindings::gpu_boids::reduce::Params as ReduceParams;
use crate::shader_bindings::gpu_boids::step::Params as StepParams;
henad_core::buffers! {
const POS = "pos" double_buffered drawable;
const VEL = "vel" double_buffered;
const COLOR = "color" double_buffered drawable;
}
#[derive(Debug)]
pub struct GpuBoids;
impl GpuAgentModel for GpuBoids {
const NAME: &'static str = "Boids Flocking (GPU)";
const ID: &'static str = "gpu_boids";
const DESCRIPTION: &'static str =
"A simulation of flocking behavior in a group of boids, stepped entirely on the GPU";
const STATS: &'static [StatDescriptor] = BoidsModel::STATS;
const BUFFERS: &'static [BufferSpec] = BUFFER_SPECS;
const POS_BUFFER: usize = POS;
const COLOR_BUFFER: usize = COLOR;
const INDEX: bool = true;
const REPLAYS_EXACTLY: bool = false;
const STEP_PASSES: &'static [PassSpec] = &[PassSpec {
label: "step",
shader: crate::shader_bindings::gpu_boids::step::SHADER_STRING,
bindings: crate::binding_decls::bindings::GPU_BOIDS_STEP,
domain: Domain::Agents,
}];
const ACTIONS: &'static [GpuAgentAction] = &[GpuAgentAction {
desc: ActionDescriptor::new("randomise_headings", "Randomise headings"),
pass: PassSpec {
label: "randomise_headings",
shader: crate::shader_bindings::gpu_boids::randomise_headings::SHADER_STRING,
bindings: crate::binding_decls::bindings::GPU_BOIDS_RANDOMISE_HEADINGS,
domain: Domain::Agents,
},
}];
const REDUCE: ReduceSpec = ReduceSpec {
shader: crate::shader_bindings::gpu_boids::reduce::SHADER_STRING,
bindings: crate::binding_decls::bindings::GPU_BOIDS_REDUCE,
lanes: 3,
domain: Domain::Agents,
};
fn param_descriptors() -> Vec<ParamDescriptor> {
agent_model_param_descriptors::<BoidsModel>()
}
fn dims(params: &[ParamValue]) -> (u32, Extent) {
(
extract_u32(params, NUM_AGENTS, BoidsModel::DEFAULT_AGENTS),
Extent {
w: extract_f32(params, WORLD_WIDTH, BoidsModel::DEFAULT_EXTENT.w),
h: extract_f32(params, WORLD_HEIGHT, BoidsModel::DEFAULT_EXTENT.h),
},
)
}
fn buffer_lens(geom: &Geometry) -> Vec<usize> {
let n = geom.num_agents as usize;
vec![n * 2, n * 2, n]
}
fn seed_buffers(geom: &Geometry, params: &[ParamValue], seed: Option<u64>) -> Vec<Vec<u8>> {
let n = geom.num_agents as usize;
let mut lanes = BoidLanes::alloc(n);
let mut rng = agent_init_rng(seed);
BoidsModel::init(&mut lanes, geom.extent, split_params::<BoidsModel>(params).0, &mut rng);
let colors: Vec<u32> = lanes.color.iter().map(|&c| packed_palette_color(c)).collect();
vec![
bytemuck::cast_slice(&interleave(&lanes.pos_x, &lanes.pos_y)).to_vec(),
bytemuck::cast_slice(&interleave(&lanes.vel_x, &lanes.vel_y)).to_vec(),
bytemuck::cast_slice(&colors).to_vec(),
]
}
fn index_cell_size(params: &[ParamValue]) -> f32 {
BoidsModel::index_cell_size(&BoidsModel::from_params(
split_params::<BoidsModel>(params).0,
Self::dims(params).1,
))
}
fn pass_params_bytes(pass: PassId, ctx: PassCtx<'_>, params: &[ParamValue]) -> Vec<u8> {
if let PassId::Action(_) = pass {
let hot = BoidsModel::from_params(split_params::<BoidsModel>(params).0, Self::dims(params).1);
return bytemuck::bytes_of(&ActionParams {
num_agents: ctx.geom.num_agents,
groups_x: ctx.groups_x,
seed: ctx.seed,
stationary: hot.min_speed.midpoint(hot.max_speed),
palette: packed_heading_palette(),
})
.to_vec();
}
if pass == PassId::Reduce {
return bytemuck::bytes_of(&ReduceParams {
n: ctx.invocations,
lanes: Self::REDUCE.lanes as u32,
groups_x: ctx.groups_x,
_pad: 0,
})
.to_vec();
}
let hot = BoidsModel::from_params(split_params::<BoidsModel>(params).0, Self::dims(params).1);
let grid = ctx.geom.index.expect("INDEX is declared");
bytemuck::bytes_of(&StepParams {
num_agents: ctx.geom.num_agents,
groups_x: ctx.groups_x,
grid_w: grid.grid_w,
grid_h: grid.grid_h,
cell_w: grid.cell_w,
cell_h: grid.cell_h,
cell_w_inv: 1.0 / grid.cell_w,
cell_h_inv: 1.0 / grid.cell_h,
world_w: hot.world_w,
world_h: hot.world_h,
half_w: hot.half_w,
half_h: hot.half_h,
visual_range: hot.visual_range,
visual_sq: hot.visual_sq,
protected_sq: hot.protected_sq,
separation: hot.separation_factor,
alignment: hot.alignment_factor,
cohesion: hot.cohesion_factor,
max_speed: hot.max_speed,
min_speed: hot.min_speed,
palette: packed_heading_palette(),
})
.to_vec()
}
fn stats(sums: &[f32], _counters: &[u32], geom: &Geometry) -> Vec<StatValue> {
let inv = 1.0 / f64::from(geom.num_agents.max(1));
vec![
StatValue::Scalar(f64::from(sums[0]) * inv),
StatValue::Vector2D {
x: f64::from(sums[1]) * inv,
y: f64::from(sums[2]) * inv,
},
]
}
}
fn interleave(xs: &[f32], ys: &[f32]) -> Vec<f32> {
xs.iter().zip(ys).flat_map(|(&x, &y)| [x, y]).collect()
}
fn packed_heading_palette() -> [[u32; 4]; 2] {
let mut packed = [[0u32; 4]; 2];
for (i, rgba) in HEADING_PALETTE.iter().enumerate() {
packed[i / 4][i % 4] = u32::from_le_bytes(*rgba);
}
packed
}
fn packed_palette_color(index: u8) -> u32 {
let rgba = HEADING_PALETTE
.get(index as usize)
.copied()
.unwrap_or(HEADING_PALETTE[0]);
u32::from_le_bytes(rgba)
}
#[cfg(test)]
mod tests {
use super::*;
use henad_compute::cpu::agent_engine::AgentModelState;
use henad_compute::gpu::{GpuAgentState, GpuContext};
use henad_core::model::SimState as _;
use henad_explore::testing::{TestDeviceRequest, headless_test_device};
type State = GpuAgentState<GpuBoids>;
fn headless_context() -> Option<GpuContext> {
headless_test_device(&TestDeviceRequest::baseline())
}
fn params(num_agents: u32, world: f32) -> Vec<ParamValue> {
let mut values: Vec<ParamValue> = GpuBoids::param_descriptors()
.iter()
.map(|desc| desc.kind.default_value())
.collect();
values[NUM_AGENTS] = ParamValue::U32(num_agents);
values[WORLD_WIDTH] = ParamValue::F32(world);
values[WORLD_HEIGHT] = ParamValue::F32(world);
values
}
fn split(interleaved: &[u32]) -> (Vec<f32>, Vec<f32>) {
let floats: Vec<f32> = interleaved.iter().map(|&w| f32::from_bits(w)).collect();
(
floats.iter().step_by(2).copied().collect(),
floats.iter().skip(1).step_by(2).copied().collect(),
)
}
fn lanes(state: &State) -> (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>) {
let (pos_x, pos_y) = split(&state.read_buffer(POS));
let (vel_x, vel_y) = split(&state.read_buffer(VEL));
(pos_x, pos_y, vel_x, vel_y)
}
#[test]
fn boids_stay_inside_the_toroidal_world() {
let Some(ctx) = headless_context() else {
log::warn!("skipping boids_stay_inside_the_toroidal_world: no adapter");
return;
};
let world = 800.0f32;
let mut state = State::new(&ctx, ¶ms(4_000, world));
state.run_batched(60);
let (pos_x, pos_y, _, _) = lanes(&state);
for (i, (&x, &y)) in pos_x.iter().zip(&pos_y).enumerate() {
assert!(
(0.0..world).contains(&x) && (0.0..world).contains(&y),
"boid {i} left the world at ({x}, {y})"
);
}
}
#[test]
fn speeds_stay_within_the_configured_band() {
let Some(ctx) = headless_context() else {
log::warn!("skipping speeds_stay_within_the_configured_band: no adapter");
return;
};
let values = params(4_000, 800.0);
let hot = BoidsModel::from_params(split_params::<BoidsModel>(&values).0, GpuBoids::dims(&values).1);
let (max_speed, min_speed) = (hot.max_speed, hot.min_speed);
let mut state = State::new(&ctx, &values);
state.run_batched(60);
let (_, _, vel_x, vel_y) = lanes(&state);
for (i, (&vx, &vy)) in vel_x.iter().zip(&vel_y).enumerate() {
let speed = vx.hypot(vy);
assert!(
speed <= max_speed * 1.001 && speed >= min_speed * 0.999,
"boid {i} has speed {speed}, outside [{min_speed}, {max_speed}]"
);
}
}
#[test]
fn initial_flock_matches_the_cpu_model() {
let Some(ctx) = headless_context() else {
log::warn!("skipping initial_flock_matches_the_cpu_model: no adapter");
return;
};
let values = params(2_000, 800.0);
for seed in [None, Some(7)] {
let gpu = State::new_seeded(&ctx, &values, seed);
let cpu = AgentModelState::<BoidsModel>::from_params_seeded(&values, seed);
let (pos_x, pos_y, vel_x, vel_y) = lanes(&gpu);
let cpu_lanes = cpu.lanes();
assert_eq!(pos_x, cpu_lanes.pos_x, "initial x positions differ for seed {seed:?}");
assert_eq!(pos_y, cpu_lanes.pos_y, "initial y positions differ for seed {seed:?}");
assert_eq!(vel_x, cpu_lanes.vel_x, "initial x velocities differ for seed {seed:?}");
assert_eq!(vel_y, cpu_lanes.vel_y, "initial y velocities differ for seed {seed:?}");
}
}
#[test]
fn average_speed_is_consistent_with_the_population() {
let Some(ctx) = headless_context() else {
log::warn!("skipping average_speed_is_consistent_with_the_population: no adapter");
return;
};
let values = params(10_000, 1_000.0);
let hot = BoidsModel::from_params(split_params::<BoidsModel>(&values).0, GpuBoids::dims(&values).1);
let (max_speed, min_speed) = (hot.max_speed, hot.min_speed);
let mut state = State::new(&ctx, &values);
state.run_batched(30);
state.refresh_stats();
let stats = state.stats();
let StatValue::Scalar(avg_speed) = stats[0].value else {
panic!("average speed is a scalar");
};
assert!(
avg_speed >= f64::from(min_speed) * 0.999 && avg_speed <= f64::from(max_speed) * 1.001,
"average speed {avg_speed} is outside [{min_speed}, {max_speed}]"
);
let (_, _, vel_x, vel_y) = lanes(&state);
let reference: f64 = vel_x
.iter()
.zip(&vel_y)
.map(|(&x, &y)| f64::from(x.hypot(y)))
.sum::<f64>()
/ vel_x.len() as f64;
assert!(
(avg_speed - reference).abs() <= 1e-3 * reference.abs().max(1.0),
"reduced average speed {avg_speed} disagrees with the lanes: {reference}"
);
}
#[test]
fn a_population_past_one_workgroup_row_still_steps() {
let Some(ctx) = headless_context() else {
log::warn!("skipping a_population_past_one_workgroup_row_still_steps: no adapter");
return;
};
let world = 4_000.0f32;
let mut state = State::new(&ctx, ¶ms(300_037, world));
state.run_batched(5);
let (pos_x, pos_y, _, _) = lanes(&state);
assert_eq!(pos_x.len(), 300_037);
for (i, (&x, &y)) in pos_x.iter().zip(&pos_y).enumerate() {
assert!(
(0.0..world).contains(&x) && (0.0..world).contains(&y),
"boid {i} left the world at ({x}, {y}); the dispatch fold probably missed it"
);
}
}
}