Skip to content

Authoring primitives

Primitives are the small vocabulary a model kernel calls for world geometry, neighbourhoods and random draws. Most of them exist twice, once in Rust for CPU models and once in WGSL for GPU models. offsets, for_each_neighbor, mix_seed and next_index are Rust only, and neighbor_count and neighbor_offset are WGSL only. The two generators, xorshift64 and pcg_hash, fill the same role under different names, and pcg_hash has a Rust twin for seeding a GPU buffer.

use henad::authoring::primitives::space::{Boundary, dist_sq, offset_cell};
use henad::authoring::primitives::rng::{next_bits, random_float};
#import henad::space::{TORUS, dist_sq, offset_cell}
#import henad::rng::{next_bits, random_float}

Where a primitive exists in both languages, the names match. henad::authoring::prelude holds every Rust draw, and Boundary, cell_index, offset_cell, dist_sq and the three offset tables of the space helpers. A model imports the other space helpers from henad::authoring::primitives::space. Each entry below gives the Rust signature, and calls out the WGSL signature wherever it differs.

Index

Group Primitives
Space Boundary, wrap_index, wrap_coord, cell_index, offset_cell, axis_delta, dist_sq, heading_octant
Neighbourhoods MOORE_ROW_MAJOR, MOORE_COLUMN_MAJOR, VON_NEUMANN, offsets, for_each_neighbor, neighbor_count, neighbor_offset
Random xorshift64, pcg_hash, mix_seed, next_bits, random_float, next_float, next_index, below, choice3, reservoir_accept

Space

henad::authoring::primitives::space in Rust, and henad::space in WGSL.

Positions are f32 in world units, cells are u32 indices into a grid w by h. The y axis points down, matching the display, and dy therefore runs south.

Boundary

enum Boundary { Torus, Bounded }

The world's edge behaviour, passed to every primitive that can cross an edge. Under Torus both axes wrap. Under Bounded each edge is a wall.

The two agree everywhere except at an edge, so a model can switch between them without changing its interior behaviour.

WGSL has no enums and takes a u32 instead.

const TORUS: u32 = 0u;
const BOUNDED: u32 = 1u;

See also: offset_cell, axis_delta, dist_sq.

wrap_index

fn wrap_index(v: i32, m: i32) -> i32

Wraps v into 0..m.

wrap_index(-1, 8)   // 7
wrap_index(8, 8)    // 0
wrap_index(3, 8)    // 3

Panics when m is 0. The WGSL twin is undefined there instead.

See also: wrap_coord, offset_cell.

wrap_coord

fn wrap_coord(v: f32, world: f32) -> f32

Wraps v into 0.0..world, the position wrap that an agent leaving one edge needs to re-enter at the opposite edge.

wrap_coord(10.5, 10.0)   // 0.5
wrap_coord(-0.5, 10.0)   // 9.5

See also: wrap_index, axis_delta.

cell_index

fn cell_index(x: u32, y: u32, w: u32) -> u32

Flat index of cell (x, y) in a grid w wide, as y * w + x. Every grid buffer in the engine is row-major with y as the slow axis.

cell_index(3, 2, 10)   // 23

An agent's cell is cell_index of its truncated position.

See also: offset_cell.

offset_cell

fn offset_cell(x: u32, y: u32, dx: i32, dy: i32, w: u32, h: u32, boundary: Boundary) -> Option<(u32, u32)>

The cell (dx, dy) away from (x, y). Under Torus the result is always Some, since both axes wrap. Under Bounded a step past any edge returns None.

offset_cell(0, 0, -1, 0, 8, 8, Boundary::Torus)     // Some((7, 0))
offset_cell(0, 0, -1, 0, 8, 8, Boundary::Bounded)   // None
offset_cell(3, 3, 1, 1, 8, 8, Boundary::Bounded)    // Some((4, 4))

WGSL has no Option, so the twin packs the flag into a third component. .z is non-zero when the cell exists, and .xy is meaningless when it is not.

fn offset_cell(x: u32, y: u32, dx: i32, dy: i32, w: u32, h: u32, boundary: u32) -> vec3<i32>

See also: for_each_neighbor, wrap_index, MOORE_ROW_MAJOR.

axis_delta

fn axis_delta(a: f32, b: f32, world: f32, boundary: Boundary) -> f32

The shortest signed delta from a to b along one axis. Under Bounded this is b - a. Under Torus the axis is a ring of circumference world, and the result is the representative of b - a in [-world / 2, world / 2).

axis_delta(1.0, 9.0, 10.0, Boundary::Bounded)   //  8.0
axis_delta(1.0, 9.0, 10.0, Boundary::Torus)     // -2.0, backwards round the ring is shorter
axis_delta(0.0, 5.0, 10.0, Boundary::Torus)     // -5.0, the half-open range makes antipodes negative

Inputs outside [0, world) are wrapped, so a caller need not normalise first. The WGSL twin assumes the two positions are within one world of each other. Every position the engine produces satisfies that.

Both versions use a and b as argument names. from and target are reserved words in WGSL.

See also: dist_sq, wrap_coord.

dist_sq

fn dist_sq(ax: f32, ay: f32, bx: f32, by: f32, world_w: f32, world_h: f32, boundary: Boundary) -> f32

Squared distance between two points, wrapping each axis with axis_delta. Compare it against a squared radius rather than taking a sqrt.

dist_sq(0.0, 0.0, 3.0, 4.0, 100.0, 100.0, Boundary::Bounded)   // 25.0
dist_sq(0.5, 0.5, 9.5, 9.5, 10.0, 10.0, Boundary::Torus)       //  2.0, across the seam

The WGSL twin takes vectors instead of scalars, matching how the agent buffers are packed.

fn dist_sq(a: vec2<f32>, b: vec2<f32>, world: vec2<f32>, boundary: u32) -> f32

See also: axis_delta.

heading_octant

fn heading_octant(vx: f32, vy: f32) -> u8

A velocity discretised into one of eight octants, running clockwise from east because the display's y axis points down. Spans are half-open, and the result comes from comparisons rather than atan2.

Octant Span Direction
0 0° to 45° East to south-east
1 45° to 90° South-east to south
2 90° to 135° South to south-west
3 135° to 180° South-west to west
4 180° to 225° West to north-west
5 225° to 270° North-west to north
6 270° to 315° North to north-east
7 315° to 360° North-east to east
heading_octant(1.0, 0.0)   // 0
heading_octant(0.0, 1.0)   // 1

The WGSL twin returns u32.

See also: axis_delta.

Neighbourhoods

The offset tables and the two ways of walking them. Rust holds each table as a const slice, WGSL as an id plus an accessor.

MOORE_ROW_MAJOR

const MOORE_ROW_MAJOR: [(i32, i32); 8]

The 8 surrounding cells, dy outer and dx inner.

(-1, -1)  (0, -1)  (1, -1)  (-1, 0)  (1, 0)  (-1, 1)  (0, 1)  (1, 1)

This is the order of the neighbors slice that GridModel::step_cell receives. A model indexes that slice by position, so the order is published API.

See also: MOORE_COLUMN_MAJOR, VON_NEUMANN, offsets.

MOORE_COLUMN_MAJOR

const MOORE_COLUMN_MAJOR: [(i32, i32); 8]

The same 8 cells, dx outer and dy inner.

(-1, -1)  (-1, 0)  (-1, 1)  (0, -1)  (0, 1)  (1, -1)  (1, 0)  (1, 1)

The ants model walks this order.

The two Moore orders are not interchangeable

A kernel that breaks ties between equally good neighbours draws from the visit order. Swapping one table for the other then changes its results, even though both cover the same 8 cells.

See also: MOORE_ROW_MAJOR, reservoir_accept.

VON_NEUMANN

const VON_NEUMANN: [(i32, i32); 4]

The 4 orthogonal cells, in step_cell order.

(0, -1)  (-1, 0)  (1, 0)  (0, 1)

See also: MOORE_ROW_MAJOR, offsets.

offsets

fn offsets(kind: NeighborhoodKind) -> &'static [(i32, i32)]

The table for a NeighborhoodKind, in step_cell order. Moore returns MOORE_ROW_MAJOR and VonNeumann returns VON_NEUMANN.

NeighborhoodKind is at henad::authoring::NeighborhoodKind, and the authoring prelude holds it.

Rust only. A shader refers to a table by its id instead, as in neighbor_count.

See also: for_each_neighbor.

for_each_neighbor

fn for_each_neighbor(
    x: u32,
    y: u32,
    w: u32,
    h: u32,
    offsets: &[(i32, i32)],
    boundary: Boundary,
    f: impl FnMut(u32, u32),
)

Calls f with each neighbour of (x, y) that exists, in table order. Neighbours outside a Bounded grid are skipped rather than clamped, so f runs fewer than offsets.len() times at an edge.

// A corner of a bounded grid has 3 Moore neighbours. A corner of a torus has 8.
for_each_neighbor(0, 0, 4, 4, &MOORE_ROW_MAJOR, Boundary::Bounded, |nx, ny| { .. });

Rust only, since WGSL has no closures. A shader loops over neighbor_offset to the same effect.

See also: offset_cell, offsets.

neighbor_count

fn neighbor_count(table: u32) -> u32

How many offsets a table holds, 4 for VON_NEUMANN and 8 for either Moore table. WGSL only.

Tables are identified by id there.

const MOORE_ROW_MAJOR: u32 = 0u;
const MOORE_COLUMN_MAJOR: u32 = 1u;
const VON_NEUMANN: u32 = 2u;

See also: neighbor_offset.

neighbor_offset

fn neighbor_offset(table: u32, n: u32) -> vec2<i32>

Offset n of a table, in the same order as the Rust const of that name. WGSL only, and the loop form of for_each_neighbor.

for (var n = 0u; n < neighbor_count(MOORE_ROW_MAJOR); n++) {
    let d = neighbor_offset(MOORE_ROW_MAJOR, n);
    let cell = offset_cell(x, y, d.x, d.y, w, h, TORUS);
}

See also: neighbor_count, offset_cell.

Random

henad::authoring::primitives::rng in Rust, and henad::rng in WGSL.

A draw takes a raw u32 word and is pure. A next_* form advances a generator and then calls the pure form.

The generators themselves differ between backends. Rust runs xorshift64 over u64 state, WGSL runs pcg_hash over u32, since WGSL has no 64-bit integers. The two fill the same role and produce different streams.

Every draw needs its own word

Feeding one next_bits result to two draws correlates them, and neither the compiler nor any test catches it.

xorshift64

fn xorshift64(state: u64) -> u64

The CPU generator. Takes a state and returns the next one, never 0. The state must never be 0 either. mix_seed guarantees that.

See also: next_bits, pcg_hash.

pcg_hash

fn pcg_hash(input: u32) -> u32

The GPU generator, and the WGSL counterpart of xorshift64.

fn pcg_hash(input: u32) -> u32

The Rust twin, bit-equal to the WGSL version. A GPU port calls it to seed a state buffer that the shader then draws from. The authoring prelude brings it in.

See also: next_bits.

mix_seed

fn mix_seed(seed: u64) -> u64

Scrambles a user-supplied seed into a usable xorshift64 state. Guards against the absorbing zero state, and decorrelates adjacent seeds such as 1, 2 and 3.

Rust only.

See also: xorshift64.

next_bits

fn next_bits(rng: &mut u64) -> u32

Advances rng and returns 32 fresh bits, taken from the top half of the state.

The WGSL twin advances a pointer to the caller's own u32.

fn next_bits(r: ptr<function, u32>) -> u32

See also: next_float, xorshift64.

random_float

fn random_float(bits: u32, max: f32) -> f32

A uniform float in [0, max), built from the top 24 bits of bits.

random_float(0, 1.0)          // 0.0
random_float(u32::MAX, 1.0)   // strictly under 1.0

24 bits is the f32 mantissa width, so every value the draw can produce is exact and the range really is half-open. Both backends run the same form, and their results are bit-equal.

See also: next_float, below, reservoir_accept.

next_float

fn next_float(rng: &mut u64, max: f32) -> f32

Advances rng, then draws with random_float.

fn next_float(r: ptr<function, u32>, max: f32) -> f32

The two backends draw from different streams, since the generators differ.

See also: random_float, next_bits.

next_index

fn next_index(rng: &mut u64, n: u32) -> u32

Advances rng and returns a uniform integer in [0, n), like NetLogo's random n. Returns 0 when n is 0.

next_index(&mut rng, 10)   // one of 0 to 9
next_index(&mut rng, 1)    // 0

Every result is equally likely for any n. The draw multiplies a fresh word by n and keeps the top 32 bits (Lemire's method), then redraws the few words that would favour some results over the rest. A plain next_bits(rng) % n favours the low results.

Unlike next_float, it has no pure form that takes a raw word. A rejected word needs another draw from the generator.

Rust only. The redraw needs a 64-bit product, and WGSL has no 64-bit integers.

See also: next_bits, next_float.

below

fn below(bits: u32, threshold: u32) -> bool

A Bernoulli trial, true for threshold of the 2^32 possible words. Pass (p * u32::MAX as f32) as u32 for probability p. A threshold of 0 never fires.

The comparison is integer rather than float, so a seeded run cannot drift on a machine that rounds differently.

See also: random_float, next_bits.

choice3

fn choice3(bits: u32) -> i32

One of -1, 0 or +1.

choice3(0)   // -1
choice3(1)   //  0
choice3(2)   //  1

See also: reservoir_accept.

reservoir_accept

fn reservoir_accept(bits: u32, count: u32) -> bool

Accepts the count-th of a run of equally good candidates, with probability 1 / count. count is the candidate's 1-based position. Reservoir sampling over ties, so once n equal candidates have been seen each has been picked with probability 1 / n.

reservoir_accept(u32::MAX, 1)   // true, the first of a run always wins
reservoir_accept(0, 2)          // true
reservoir_accept(u32::MAX, 2)   // false, the second wins half the time

A count of 0 accepts.

See also: random_float, MOORE_COLUMN_MAJOR.

Parity

Each pair is pinned by a parity test. crates/henad-compute/src/gpu/tests/parity.wgsl runs one invocation per case and one dispatch for the whole set, switching on an op code, and crates/henad-compute/src/gpu/tests/parity.rs drives it and compares. Op codes come from the generated bindings.

Integer results must match exactly, and so must random_float. Other float results are compared against a tolerance, because WGSL evaluates float % through a division where Rust implements an exact fmod.

Parity covers the pure functions only. The generators are excluded, along with anything that has no twin.

On a machine with no adapter the test skips silently. Set HENAD_REQUIRE_GPU=1 to turn that skip into a failure.

Some things a kernel reaches for are not primitives, and live with the engine instead.

Need Where
Agents within a radius SpatialHash::query_radius, on henad::authoring::SpatialHash. Takes a caller-provided result buffer, so a query does not allocate
World size henad::authoring::Extent. The engine prepends world size to every agent model's params
A per-chunk RNG The run_pass that agent_lanes! generates passes the kernel its chunk's generator, seeded from the tick and the chunk's index
Counting cells henad::authoring::reduce_chunks
A node's neighbours Network::in_neighbors and Network::out_neighbors, on henad::authoring::Network. Each returns a slice of node indices without allocating. On an undirected graph the two return the same list
Whether two nodes are joined Network::has_edge, or Network::edge_between for the edge's index. A lookup walks one node's neighbours, the shorter list on an undirected graph. On a directed graph it looks only for an edge from the first node to the second
Connected components henad::authoring::label_components. Labels every node with the lowest node index in its component, into a caller-provided buffer, and returns the number of components and the size of the largest. On a directed graph it finds weakly connected components
Arithmetic, trigonometry, min, max, clamp Rust and WGSL both provide these already

Not provided

  • Agentsets. There is no first-class filtered collection of agents, and no ask-style iteration over such a collection. A model filters inside its own kernel, over flat lanes.
  • Global ordering. Nothing sorts agents or picks a global maximum across them.
  • A per-cell list of agents. Nothing keeps a second copy of where each agent stands. SpatialHash is rebuilt from positions each tick and answers the same queries.
  • A global RNG seed. A chunk's RNG is seeded from the tick and the chunk's index. A run is then independent of the thread count. A network model's global pass is sequential and draws from its own single stream.
  • Dynamic populations of agents. An agent model's agents are neither created nor removed mid-run. A network model can add and remove nodes through Nodes::spawn and Nodes::retire.
  • Non-uniform activation. Every agent steps every tick. A model wanting less carries its own phase counter.
  • Dynamic evaluation. Both backends compile ahead of time.

A primitive is written when two real models need it. Anything with one call site stays in that model.

Where these live

crates/henad-core/src/authoring/primitives/space.rs   <->  crates/henad-core/src/authoring/primitives/wgsl/space.wgsl
crates/henad-core/src/authoring/primitives/rng.rs     <->  crates/henad-core/src/authoring/primitives/wgsl/rng.wgsl

Adding a primitive means adding both sides, an op in parity.wgsl and a case builder in the parity driver.