Table of Contents

Struct ClusterSpatialQuery<TArch>

Namespace
Typhon.Engine
Assembly
Typhon.Engine.dll

Zero-allocation per-cell cluster AABB query for a single archetype (issue #230). The query expands the requested AABB into the overlapping grid cells, iterates each cell's Typhon.Engine.Internals.CellSpatialIndex as a linear broadphase, and for each broadphase-hit cluster performs a narrowphase scan over its occupied entity slots.

public readonly ref struct ClusterSpatialQuery<TArch> where TArch : Archetype<TArch>, new()

Type Parameters

TArch
Inherited Members

Remarks

Opt-in. Requires the game to have called ConfigureSpatialGrid(SpatialGridConfig) before InitializeArchetypes(). Without it, the per-cell index is never populated and querying throws InvalidOperationException.

Current scope. All four tiers since #914 — 2D and 3D, f32 and f64 (AABB2F/AABB3F/AABB2D/AABB3D and the matching BSphere variants). Queries traverse both the per-cell dynamic and static indexes. No overflow R-Tree — the broadphase is a linear scan over all clusters in each cell, which is optimal for typical AntHill cell populations (≤80 clusters).

Implementation. This generic entry point exists to provide the tier-aware public API surface with a JIT-specialized dispatch path per concrete box type. The actual state machine lives on AabbClusterEnumerator, which is also consumed directly by engine-internal non-generic consumers via QueryAabb(SpatialGrid, double, double, double, double, double, double, uint). Both entry points drive the same iterator — the generic layer adds tier validation; the non-generic layer is used by consumers that iterate cluster archetypes at runtime (SpatialTriggerSystem, EcsQuery).

Epoch scope: call this from a system body and there is nothing to do. The enumerator creates a Typhon.Engine.Internals.ChunkAccessor<TStore> on the cluster segment to read entity bounds during the narrowphase, so the cluster pages must not be reclaimed under it — and rule RT-01 makes the framework responsible for that: every system body, of every shape, runs inside an epoch scope the dispatcher opened. That is why this no longer names a precondition the caller has no public way to express (#909).

The obligation that comes with it: do not block inside a system body (PS-09). The scope pins an epoch for as long as the body runs, and page eviction and view-buffer reclamation wait on the oldest live epoch. A caller that is not a system body — a tool, or a diagnostic run outside the tick — has no scope and cannot open one through the public API; such a caller belongs inside a system.

Methods

AABB<TBox>(in TBox, uint)

Query all entities in this archetype whose spatial bounds intersect the axis-aligned box carried by box. The generic type parameter TBox determines the dimensionality and precision of the query region; it must match the archetype's cluster storage tier or an InvalidOperationException is thrown (issue #230 Phase 2.5).

public AabbClusterEnumerator AABB<TBox>(in TBox box, uint categoryMask = 4294967295) where TBox : struct, ISpatialBox

Parameters

box TBox

Query region, passed by in to avoid a defensive copy.

categoryMask uint

Category bitmask; a cluster is skipped if its union mask does not intersect. Pass MaxValue (default) to accept every cluster.

Returns

AabbClusterEnumerator

A zero-allocation enumerator suitable for foreach.

Type Parameters

TBox

One of AABB2F, AABB3F, AABB2D, AABB3D. The generic constraint narrows to ISpatialBox, and the JIT specializes this method per concrete TBox so each monomorphized version contains only the code path for its concrete type (all other dispatch branches are dead-code-eliminated at specialization time).

Remarks

AggressiveInlining is load-bearing and was measured, not assumed (#919 AC-6). Adding the two f64 dispatch branches took this method's IL from 716 to 814 bytes. The JIT decides inlining from IL size before it folds the typeof(TBox) == typeof(...) tests, so the specialised body that actually runs stayed small while the method stopped being inlined — and because it RETURNS a AabbClusterEnumerator by value, that cost every query one extra copy of a 1.6 KB ref struct. Measured interleaved against the pre-#914 build: +44 ns on every query, fixed rather than per-cell, which at a 3×3-cell query is a tenth of the whole thing. The attribute takes it back to +6 ns.

So: if another box variant is added here, re-measure. The branches are free once specialised, but each one spends IL budget that the inliner counts before it can know that.

Exceptions

InvalidOperationException

Thrown when the archetype has no spatial index, or when TBox's tier does not match the archetype's cluster storage tier.

NotSupportedException

Thrown only for an ISpatialBox implementer with no dispatch branch here. All four box types that exist are supported since #914.

CountRadius(ReadOnlySpan<BSphere2F>, Span<int>, uint)

For each sphere in members, how many entities Radius(in BSphere2F, uint) returns for it: counts[j] == Radius(members[j], categoryMask).Count(). The cells, cell halves and clusters the members share are walked and opened once for the whole set instead of once per member. The archetype must be 2D (tier Tier2F).

public void CountRadius(ReadOnlySpan<BSphere2F> members, Span<int> counts, uint categoryMask = 4294967295)

Parameters

members ReadOnlySpan<BSphere2F>

The query spheres, in world units.

counts Span<int>

Receives one count per member, in members' order; entries past its length are left alone.

categoryMask uint

As for Radius(in BSphere2F, uint), for every member.

Remarks

When it pays. When the members are close together — one source cluster's entities, a squad, a crowd's observers — their own queries walk nearly the same cells and open the same clusters, which the batch then does once; each member is still tested only against the clusters its own box overlaps. A member far from the others costs what its own query costs: no cell outside a member's own range is visited.

At most 64 members. The caller must be inside an Typhon.Engine.Internals.EpochGuard scope, as for a single query.

Exceptions

ArgumentException

More than 64 members, a member that is not finite, or counts shorter than members. Checked before anything is written: counts is then left as it was.

InvalidOperationException

The archetype has no spatial index, or is not 2D f32.

ForEachInRadius<TSink>(ReadOnlySpan<BSphere2F>, ref TSink, uint)

For each sphere in members, the entities Radius(in BSphere2F, uint) returns for it, handed to sink as (member index, hit): each member's hits in its own query's MoveNext order, with the same bounds and squared distance. The cells, cell halves and clusters the members share are walked and opened once for the whole set. The archetype must be 2D (tier Tier2F).

public void ForEachInRadius<TSink>(ReadOnlySpan<BSphere2F> members, ref TSink sink, uint categoryMask = 4294967295) where TSink : struct, IRadiusBatchSink, allows ref struct

Parameters

members ReadOnlySpan<BSphere2F>

The query spheres, in world units.

sink TSink

Receives the hits. Copied into the batch and copied back when it returns or throws, so read its state after the call, not from inside a Hit(int, in ClusterSpatialQueryResult) through another reference to the caller's variable.

categoryMask uint

As for Radius(in BSphere2F, uint), for every member.

Type Parameters

TSink

Remarks

The sink returns false to retire a member: it receives nothing further, and the others carry on. Members' hits interleave cluster by cluster; only each member's own sequence is ordered.

The sink may run its own spatial queries: they take a page window of their own. If it throws, the batch hands its window back and the exception propagates; sink then holds the state it had reached.

At most 64 members. The caller must be inside an Typhon.Engine.Internals.EpochGuard scope, as for a single query. See CountRadius(ReadOnlySpan<BSphere2F>, Span<int>, uint) for when a batch pays.

Exceptions

ArgumentException

More than 64 members, or a member that is not finite; checked before any hit is delivered.

InvalidOperationException

The archetype has no spatial index, or is not 2D f32.

Radius(in BSphere2D, uint)

f64 counterpart of Radius(in BSphere2F, uint) (#914). The archetype must be 2D f64 (tier Tier2D).

public AabbClusterEnumerator Radius(in BSphere2D sphere, uint categoryMask = 4294967295)

Parameters

sphere BSphere2D
categoryMask uint

Returns

AabbClusterEnumerator

Radius(in BSphere2F, uint)

Query all entities in this archetype whose spatial bounds are within sphere's radius of its center, using the closest-point-on-AABB distance semantic. DistanceSq is populated for each hit, letting callers sort by distance without re-reading bounds. The archetype must be 2D (tier Tier2F); 3D archetypes use the BSphere3F overload.

public AabbClusterEnumerator Radius(in BSphere2F sphere, uint categoryMask = 4294967295)

Parameters

sphere BSphere2F

Query sphere (center + radius), in world units.

categoryMask uint

Category bitmask; a cluster is skipped if its union mask does not intersect. Pass MaxValue (default) to accept every cluster.

Returns

AabbClusterEnumerator

Radius(in BSphere3D, uint)

f64 counterpart of Radius(in BSphere3F, uint) (#914). The archetype must be 3D f64 (tier Tier3D).

public AabbClusterEnumerator Radius(in BSphere3D sphere, uint categoryMask = 4294967295)

Parameters

sphere BSphere3D
categoryMask uint

Returns

AabbClusterEnumerator

Radius(in BSphere3F, uint)

3D variant of Radius(in BSphere2F, uint). The archetype must be 3D (tier Tier3F).

public AabbClusterEnumerator Radius(in BSphere3F sphere, uint categoryMask = 4294967295)

Parameters

sphere BSphere3F
categoryMask uint

Returns

AabbClusterEnumerator