Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Spatial queries

“Who is near me?” is the core question of most simulations. Checking every pair is O(n²); a spatial grid answers it by looking only at nearby cells.

The built-in grid

Turn it on with a cell size near your usual query radius:

#![allow(unused)]
fn main() {
Game::new("Boids", 1280, 720)
    .spatial_grid(24.0)
    .sim_system(flock);
}

At the start of every tick, every entity with a Transform2D is indexed into ctx.spatial. Query it from any sim system:

#![allow(unused)]
fn main() {
struct Neighbors(usize);

fn count_neighbors(ctx: &mut Ctx) {
    let grid = &ctx.spatial;
    ctx.world
        .query::<(Entity, &Transform2D, &mut Neighbors)>()
        .par_for_each(|(me, t, n)| {
            n.0 = 0;
            grid.for_each_in_radius(t.position, 10.0, |o| {
                if o.entity != me { n.0 += 1; }
            });
        });
}
}
// crabby::game("Boids", 1280, 720).spatial_grid(24).sim_system(count_neighbors);
static void count_neighbors(crabby::ctx& ctx) {
    const auto grid = ctx.spatial_view();
    ctx.world().each_par([&](crabby::entity me, const crabby::transform2d& t, Neighbors& n) {
        n.count = 0;
        grid.for_each_in_radius(t.position, 10, [&](const crabby::grid_hit& o) {
            if (crabby::bits(o.entity) != me) ++n.count;
        });
    });
}

The grid is read-only while you query the world, so this runs on all cores. In C++, ctx.spatial_view() gives a view that walks the grid inline (no call into the engine per query); ctx.spatial_radius(center, r, hits) fills a vector instead, which is simpler but slower in hot loops.

MethodVisits
for_each_in_radius(center, r, f)entries within r of center
for_each_in_rect(min, max, f)entries inside the rectangle
count_in_radius(center, r)how many entries are within r
nearest(center, max_r, accept)the closest entry accept agrees to

Each visit gives a GridEntry with entity and position.

Every 32 ticks (SPATIAL_SORT_EVERY) the built-in system also reorders entities by grid cell (World::sort_by with spatial_key), so neighbors in space are neighbors in memory. With a million boids this cut a tick from 62 ms to 41 ms.

Your own grid, with data

The built-in grid stores only entity and position. When neighbors need more data, for example a boid’s velocity, keep your own SpatialGrid<T> inside the system and fill it with rebuild_with_data:

#![allow(unused)]
fn main() {
/// The flocking system. It owns a grid that stores each boid's velocity, so
/// neighbors' velocities can be read while the world is busy with the query.
fn flocking(bounds: Vec2) -> impl FnMut(&mut Ctx) {
    let mut grid = SpatialGrid::with_data(RADIUS);
    move |ctx: &mut Ctx| {
        let dt = ctx.dt();
        // Keep boids that are close in space close in memory, so neighbor
        // lookups hit the cache (1M boids: 62 -> 40 ms/tick).
        if ctx.sim_tick().is_multiple_of(SPATIAL_SORT_EVERY) {
            ctx.world.sort_by::<Transform2D>(|t| spatial_key(t.position, RADIUS));
        }
        grid.rebuild_with_data(
            ctx.world
                .query::<(Entity, &Transform2D, &Vel)>()
                .iter()
                .map(|(e, t, v)| (e, t.position, v.0)),
        );
        let grid = &grid;
        ctx.world
            .query::<(Entity, &mut Transform2D, &mut Vel)>()
            .par_for_each(|(me, t, v)| {
                let (mut away, mut heading, mut center, mut n) = (Vec2::ZERO, Vec2::ZERO, Vec2::ZERO, 0.0);
                grid.for_each_in_radius(t.position, RADIUS, |o| {
                    if o.entity == me {
                        return;
                    }
                    let d = t.position - o.position;
                    away += d / d.length_squared().max(1.0);
                    heading += o.data;
                    center += o.position;
                    n += 1.0;
                });
                let mut steer = away * 900.0;
                if n > 0.0 {
                    steer += (heading / n - v.0) * 1.5 + (center / n - t.position) * 1.2;
                }
                let speed = (v.0 + steer * dt).length().clamp(MIN_SPEED, MAX_SPEED);
                v.0 = (v.0 + steer * dt).normalize_or(Vec2::X) * speed;
                t.position = (t.position + v.0 * dt).rem_euclid(bounds);
                t.rotation = v.0.to_angle();
            });
    }
}
}
// The flocking system. Its grid stores each boid's index into `velocities`,
// so neighbors' velocities can be read while the query borrows the world.
struct Flocking {
    explicit Flocking(vec2 world_size) : bounds(world_size) {}
    vec2 bounds;
    std::shared_ptr<crabby::grid> grid = std::make_shared<crabby::grid>(RADIUS);
    std::shared_ptr<std::vector<crabby::grid_item>> items = std::make_shared<std::vector<crabby::grid_item>>();
    std::shared_ptr<std::vector<vec2>> velocities = std::make_shared<std::vector<vec2>>();

    void operator()(crabby::ctx& ctx) {
        auto world = ctx.world();
        const float dt = ctx.dt();
        // Keep boids that are close in space close in memory (cache hits).
        if (ctx.sim_tick() % SPATIAL_SORT_EVERY == 0) world.sort_spatial(RADIUS);
        items->clear();
        velocities->clear();
        world.each([&](crabby::entity e, const crabby::transform2d& t, const Vel& v) {
            items->push_back({crabby::entity_of(e), t.position, std::uint32_t(velocities->size())});
            velocities->push_back(v.v);
        });
        grid->rebuild(*items);

        const auto view = grid->view();
        const auto& vels = *velocities;
        const vec2 b = bounds;
        world.each_par([&](crabby::entity me, crabby::transform2d& t, Vel& v) {
            vec2 away{0, 0}, heading{0, 0}, center{0, 0};
            float n = 0;
            view.for_each_in_radius(t.position, RADIUS, [&](const crabby::grid_item& o) {
                if (crabby::bits(o.entity) == me) return;
                const vec2 d = t.position - o.position;
                away += d / std::max(crabby::length_squared(d), 1.0f);
                heading += vels[o.data];
                center += o.position;
                n += 1;
            });
            vec2 steer = away * 900.0f;
            if (n > 0) steer += (heading / n - v.v) * 1.5f + (center / n - t.position) * 1.2f;
            const vec2 next = v.v + steer * dt;
            const float speed = std::min(std::max(crabby::length(next), MIN_SPEED), MAX_SPEED);
            v.v = crabby::normalize_or(next, {1, 0}) * speed;
            t.position = crabby::rem_euclid(t.position + v.v * dt, b);
            t.rotation = crabby::to_angle(v.v);
        });
    }
};

C++ uses a crabby::grid, whose items carry a uint32_t of your own (here an index into a velocity array), and walks it with grid->view().

Choosing a cell size

  • About your query radius is a good start: a radius query then reads a 3×3 block of cells.
  • Much smaller cells mean more cells to visit; much larger means more entries per cell to reject.
  • Queries much larger than the world fall back to a plain scan, so huge radii stay correct and don’t get slow.