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};
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¶
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¶
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.
See also: offset_cell, axis_delta, dist_sq.
wrap_index¶
Wraps v into 0..m.
Panics when m is 0.
The WGSL twin is undefined there instead.
See also: wrap_coord, offset_cell.
wrap_coord¶
Wraps v into 0.0..world, the position wrap that an agent leaving one edge needs to re-enter at the opposite edge.
See also: wrap_index, axis_delta.
cell_index¶
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.
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.
See also: for_each_neighbor, wrap_index, MOORE_ROW_MAJOR.
axis_delta¶
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.
See also: axis_delta.
heading_octant¶
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 |
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¶
The 8 surrounding cells, dy outer and dx inner.
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¶
The same 8 cells, dx outer and dy inner.
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¶
The 4 orthogonal cells, in step_cell order.
See also: MOORE_ROW_MAJOR, offsets.
offsets¶
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¶
How many offsets a table holds, 4 for VON_NEUMANN and 8 for either Moore table.
WGSL only.
Tables are identified by id there.
See also: neighbor_offset.
neighbor_offset¶
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¶
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¶
The GPU generator, and the WGSL counterpart of xorshift64.
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¶
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¶
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.
See also: next_float, xorshift64.
random_float¶
A uniform float in [0, max), built from the top 24 bits of bits.
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¶
Advances rng, then draws with random_float.
The two backends draw from different streams, since the generators differ.
See also: random_float, next_bits.
next_index¶
Advances rng and returns a uniform integer in [0, n), like NetLogo's random n.
Returns 0 when n is 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¶
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¶
One of -1, 0 or +1.
See also: reservoir_accept.
reservoir_accept¶
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.
Related, elsewhere¶
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.
SpatialHashis 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::spawnandNodes::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.