henad-models 0.3.0

Example models for Henad, a parallel agent-based modelling engine.
Documentation
use henad_core::authoring::model::agent_model::{AgentModel as _, StepCtx};
use henad_core::authoring::primitives::space::{heading_octant, wrap_coord};

use crate::boids::lanes::{BoidChunk, BoidLanes, BoidRead};
use crate::boids::{BoidParams, BoidsModel};

pub(crate) fn run(lanes: &mut BoidLanes, ctx: &StepCtx<'_, BoidsModel>, seed: u64, tick: u64) {
    let hash = ctx.index;
    let params = ctx.params;
    lanes.run_pass(BoidsModel::CHUNK, seed, tick, |i, k, read, chunk, _rng| {
        step_agent(i, k, read, chunk, hash, params);
    });
}

#[inline]
fn step_agent(
    i: usize,
    k: usize,
    read: BoidRead<'_>,
    out: &mut BoidChunk<'_>,
    hash: &henad_core::spatial_hash::SpatialHash,
    params: &BoidParams,
) {
    let (pos_x, pos_y, vel_x, vel_y) = (read.pos_x, read.pos_y, read.vel_x, read.vel_y);

    let mut close_dx = 0.0;
    let mut close_dy = 0.0;
    let mut avg_vx = 0.0;
    let mut avg_vy = 0.0;
    let mut avg_px = 0.0;
    let mut avg_py = 0.0;
    let mut count = 0u32;

    // The index already computed both deltas to reject anything out of range, so the sums below
    // reuse them instead of computing them again.
    let (xi, yi) = (pos_x[i], pos_y[i]);
    hash.for_each_within(xi, yi, params.visual_range, pos_x, pos_y, |j, dx, dy, dist_sq| {
        if j == i as u32 {
            return;
        }
        if dist_sq < params.protected_sq {
            close_dx -= dx;
            close_dy -= dy;
        }

        if dist_sq < params.visual_sq {
            avg_vx += vel_x[j as usize];
            avg_vy += vel_y[j as usize];
            avg_px += xi + dx;
            avg_py += yi + dy;
            count += 1;
        }
    });

    let mut new_vx = vel_x[i] + close_dx * params.separation_factor;
    let mut new_vy = vel_y[i] + close_dy * params.separation_factor;

    if count > 0 {
        let count_inv = 1.0 / count as f32;
        new_vx += (avg_vx * count_inv - vel_x[i]) * params.alignment_factor
            + (avg_px * count_inv - pos_x[i]) * params.cohesion_factor;
        new_vy += (avg_vy * count_inv - vel_y[i]) * params.alignment_factor
            + (avg_py * count_inv - pos_y[i]) * params.cohesion_factor;
    }

    let speed_sq = new_vx * new_vx + new_vy * new_vy;
    if speed_sq > 0.0 {
        let speed = speed_sq.sqrt();
        if speed > params.max_speed {
            new_vx = new_vx / speed * params.max_speed;
            new_vy = new_vy / speed * params.max_speed;
        } else if speed < params.min_speed {
            new_vx = new_vx / speed * params.min_speed;
            new_vy = new_vy / speed * params.min_speed;
        }
    } else {
        new_vx = params.min_speed;
        new_vy = 0.0;
    }

    out.pos_x[k] = wrap_coord(pos_x[i] + new_vx, params.world_w);
    out.pos_y[k] = wrap_coord(pos_y[i] + new_vy, params.world_h);
    out.vel_x[k] = new_vx;
    out.vel_y[k] = new_vy;
    out.color[k] = heading_octant(new_vx, new_vy);
}

#[cfg(test)]
mod tests {
    use henad_core::authoring::primitives::space::heading_octant;

    use crate::boids::HEADING_PALETTE;

    /// Every octant must land inside the palette, or the renderer silently falls back to entry 0.
    #[test]
    fn every_heading_indexes_the_palette() {
        for step in 0..64u8 {
            let angle = f32::from(step) * std::f32::consts::TAU / 64.0;
            let octant = heading_octant(angle.cos(), angle.sin());
            assert!(
                (octant as usize) < HEADING_PALETTE.len(),
                "angle {angle} gave octant {octant}"
            );
        }
    }
}