Struct ClusterSpatialQuery<TArch>
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
boxTBoxQuery region, passed by
into avoid a defensive copy.categoryMaskuintCategory 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
TBoxOne of AABB2F, AABB3F, AABB2D, AABB3D. The generic constraint narrows to ISpatialBox, and the JIT specializes this method per concrete
TBoxso 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
membersReadOnlySpan<BSphere2F>The query spheres, in world units.
countsSpan<int>Receives one count per member, in
members' order; entries past its length are left alone.categoryMaskuintAs 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
countsshorter thanmembers. Checked before anything is written:countsis 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
membersReadOnlySpan<BSphere2F>The query spheres, in world units.
sinkTSinkReceives 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.
categoryMaskuintAs 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
Returns
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
sphereBSphere2FQuery sphere (center + radius), in world units.
categoryMaskuintCategory bitmask; a cluster is skipped if its union mask does not intersect. Pass MaxValue (default) to accept every cluster.
Returns
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
Returns
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)