Table of Contents

Struct AabbClusterEnumerator

Namespace
Typhon.Engine
Assembly
Typhon.Engine.dll

Zero-allocation AABB query enumerator over the per-cell cluster spatial index of a single archetype (issue #230 Phase 3). Shared between the game-facing generic entry point AABB<TBox>(in TBox, uint) and the engine-facing non-generic entry point QueryAabb(SpatialGrid, double, double, double, double, double, double, uint). Handles all four cluster storage tiers — 2D and 3D, f32 and f64 — through a single state machine: the 3D overlap test is a strict superset of the 2D test, and 2D archetypes are queried with an infinite Z range that trivially passes the Z component of the overlap check.

public ref struct AabbClusterEnumerator
Inherited Members

Remarks

The state machine is three-phase per call to MoveNext(): (1) drain the current cluster's occupancy bits and test each entity's tight bounds against the query AABB (narrowphase), (2) advance to the next cluster in the current cell's Typhon.Engine.Internals.CellSpatialIndex and apply broadphase AABB + category mask filtering, (3) advance to the next cell in the query's overlap range and look up the archetype's per-cell slot.

Tier handling. The narrowphase runs one loop per FieldType, through a bounds reader per field type that decodes exactly as Typhon.Engine.Internals.SpatialMaintainer.ReadAndValidateBoundsFromPtr(System.Byte*,Typhon.Engine.SpatialFieldInfo,System.Span{System.Double}) does and widens to doubles — 2D types leave Z to the query's range, 3D types carry their own. Widening an f32 bound is exact, and the comparison stays in f64 because the query box is f64 (#914, SQ-06). The storage layer (ClusterSpatialAabb / Typhon.Engine.Internals.CellSpatialIndex) stays unified 6-float CELL-RELATIVE storage per C15, so the broadphase overlap test always runs in 3D f32 and implicitly handles 2D via infinite Z sentinels.

Epoch scope. The caller must be inside an Typhon.Engine.Internals.EpochGuard scope. The narrowphase reads entity bounds through this thread's warm Typhon.Engine.Internals.ChunkAccessor<TStore> over the cluster segment, rented from Typhon.Engine.Internals.SpatialQueryAccessorCache when the first cluster is opened (or a promoted half's tree yields its first hit) and handed back when the query is exhausted or disposed: a query that opens no cluster rents nothing, and consecutive queries on a thread keep their page window.

Phase 3 history. Originally introduced as a nested ClusterSpatialQuery<TArch>.AABBEnumerator ref struct, hoisted out of the generic outer type as Aabb2fEnumerator during Phase 3 scaffolding, then unified to handle both 2D and 3D when the 3D-blocker scope discovery showed existing cluster archetypes depend on 3D bounds via the legacy per-entity tree.

Properties

Current

The most recently yielded result. Valid only after MoveNext() returns true.

public ClusterSpatialQueryResult Current { get; }

Property Value

ClusterSpatialQueryResult

IsAabb2F

Whether the spatial field is the flat f32 box a shared snapshot can copy.

public bool IsAabb2F { get; }

Property Value

bool

SpatialFieldsOffset

Byte offset of the spatial field's column from a cluster's base.

public int SpatialFieldsOffset { get; }

Property Value

int

SpatialStride

The spatial column's stride.

public int SpatialStride { get; }

Property Value

int

Methods

Count()

Drain the rest of the query and return how many entities matched.

public int Count()

Returns

int

Remarks

The whole state machine runs in this one frame: no call per hit, and no result written. Continues from wherever the enumeration stands, so after k successful MoveNext() calls it returns the total minus k. The enumerator is exhausted afterwards; Current is unspecified.

Dispose()

Hand the rented page window back, if one was taken. Called automatically by foreach on a ref struct that implements this method.

public void Dispose()

Remarks

A promoted half's tree enumerator no longer outlives Typhon.Engine.AabbClusterEnumerator.CollectTreeHits(Typhon.Engine.Internals.CellClusterTree,System.ReadOnlySpan{System.Double}), so breaking out of a query mid-cell leaves nothing else to release.

Fill(scoped Span<ClusterSpatialQueryResult>)

Write up to destination.Length further matches, in MoveNext() order, and return how many were written.

public int Fill(scoped Span<ClusterSpatialQueryResult> destination)

Parameters

destination Span<ClusterSpatialQueryResult>

Returns

int

Remarks

Returns 0 once the query is exhausted, or for an empty destination. Resumable: the next call continues where this one stopped, and calls may be mixed with MoveNext(). Current is unspecified after a call.

FillClusters(scoped Span<ClusterBroadphaseHit>)

Write up to destination.Length further CLUSTERS the broadphase admits, and return how many were written.

public int FillClusters(scoped Span<ClusterBroadphaseHit> destination)

Parameters

destination Span<ClusterBroadphaseHit>

Where to write them.

Returns

int

How many were written; 0 once the query is exhausted, or for an empty destination.

Remarks

No entity is read. The broadphase — the per-cell cluster index, its SIMD half, the cell tree and the escaped-cluster tail — runs exactly as it does for MoveNext(), and the narrowphase that would split each admitted cluster back into entities does not. For a caller whose unit of interest is the cluster that is the whole query: measured on the SWG demo at d06 with 200 sessions, the entity-level form reports about 416 000 hits per tick where this reports about 19 000 clusters, and the 54 ns each of those hits costs is what it removes.

It abandons whatever the previous call left open. This enumeration answers per cluster, so a cluster is reported once and its remaining slots are dropped rather than carried; mixing it with MoveNext() or Fill(scoped Span<ClusterSpatialQueryResult>) on one enumerator therefore loses the entities of the cluster in hand. Nothing forbids it, and nothing needs it.

The bounds are the cluster's own, read back into world space. They come from the archetype's ClusterAabbs in the frame of the cell ClusterCellMap files the cluster under, rather than from whichever of the three broadphase branches admitted it — one conversion that is right for all of them, against three that would each have to be kept right separately.

FillCurrentCluster(scoped Span<ClusterSpatialQueryResult>)

Write up to destination.Length further matches from the CURRENT cluster only, and return how many were written.

public int FillCurrentCluster(scoped Span<ClusterSpatialQueryResult> destination)

Parameters

destination Span<ClusterSpatialQueryResult>

Where to write them.

Returns

int

How many were written; 0 once this cluster is drained.

Remarks

Never advances. Fill(scoped Span<ClusterSpatialQueryResult>) opens the next cluster when the one in hand runs out, which is right for a caller walking the whole query and wrong for one stepping cluster by cluster with MoveNextCluster(out ClusterBroadphaseHit) — there, advancing here would silently skip the box test the caller stepped in order to make. Resumable against one cluster: call until it returns 0, then step.

GetEnumerator()

Enumerator pattern: a ref struct enumerator is its own source.

public AabbClusterEnumerator GetEnumerator()

Returns

AabbClusterEnumerator

MoveNext()

Advance to the next matching entity. Returns false when the query is exhausted.

public bool MoveNext()

Returns

bool

Remarks

One call per hit. A caller that wants the matches in bulk should use Count() or Fill(scoped Span<ClusterSpatialQueryResult>), which run the same state machine without leaving it between hits.

MoveNextCluster(out ClusterBroadphaseHit)

Advance to the next CLUSTER the broadphase admits, reading no entity, and leave it open so the caller may decide whether to drain it.

public bool MoveNextCluster(out ClusterBroadphaseHit cluster)

Parameters

cluster ClusterBroadphaseHit

The cluster: its chunk id, its occupied slots and its tight bounds in world space.

Returns

bool

false once the query is exhausted.

Remarks

The caller-driven half of FillClusters(scoped Span<ClusterBroadphaseHit>). That method answers "every cluster, no entities" in one frame, which suits a caller whose unit of interest is only ever the cluster. This one stops on each cluster so the caller can answer a question about the BOX and then either walk the entities with FillCurrentCluster(scoped Span<ClusterSpatialQueryResult>) or move on, paying the narrowphase for the clusters where the box was not decisive and for no others.

Why that split is worth an API. A subscription's broad phase is centred on an interest CELL rather than on any one observer, so a cluster lying wholly inside the cell's inscribed disc is visible to every member of the cell and one lying outside the enlarged disc to none — in both cases without a single entity being read or tested. On the SWG demo at d06 that is about 838 clusters reached per cell resolution against 13.2 entities each, so the decision this exposes is taken 838 times to avoid up to eleven thousand reads.

It abandons the entities of the cluster in hand, exactly as FillClusters(scoped Span<ClusterBroadphaseHit>) does: advancing is what the caller asked for. The bounds come from the archetype's ClusterAabbs read back into world space, and a cluster whose bounds cannot be read reports an UNBOUNDED box so that the caller's own test admits it and looks inside — narrowing on a missing entry would drop entities, which is SQ-01's silent direction.

MoveNextClusterUnopened(out int, out double, out double, out double, out double)

Advance to the next cluster the broadphase admits WITHOUT opening its page, reporting its id and world-space bounds.

public bool MoveNextClusterUnopened(out int chunkId, out double minX, out double minY, out double maxX, out double maxY)

Parameters

chunkId int

The cluster.

minX double

Bounds minimum X, from the archetype's cluster AABBs — no page is read for it.

minY double

Bounds minimum Y.

maxX double

Bounds maximum X.

maxY double

Bounds maximum Y.

Returns

bool

false once the query is exhausted.

Remarks

The occupancy and the entities are not known here — a caller that needs them calls OpenCluster(int), once, and shares the result. An unbounded box for a cluster whose bounds cannot be read is the safe direction: the caller's own tests then admit it and look inside.

MoveNextClusterUnopened(out int, out double, out double, out double, out double, out double, out double)

MoveNextClusterUnopened(out int, out double, out double, out double, out double) with the Z bounds too, which the cluster AABBs already store: what a volumetric caller prunes on. A 2D archetype's Z bounds are whatever its AABBs hold, so its caller supplies its own.

public bool MoveNextClusterUnopened(out int chunkId, out double minX, out double minY, out double minZ, out double maxX, out double maxY, out double maxZ)

Parameters

chunkId int
minX double
minY double
minZ double
maxX double
maxY double
maxZ double

Returns

bool

OpenCluster(int)

Opens a cluster's page through this enumerator's warm accessor and returns its base address.

public byte* OpenCluster(int chunkId)

Parameters

chunkId int

The cluster.

Returns

byte*

The cluster's base; its first eight bytes are the occupancy word.

TallyOccupancy(ulong)

Adds a cluster's occupancy, read by the caller from its own copy, to the query's candidate tally — which the unopened walk cannot count itself and which feeds the maintenance budget (SO-02).

public void TallyOccupancy(ulong occupancy)

Parameters

occupancy ulong

The cluster's occupancy.