Cluster Spatial Queries
Per-cell broadphase + per-entity narrowphase AABB/Radius queries for cluster-eligible archetypes.
Status: ๐ง Partial ยท Visibility: Internal ยท Category: Spatial
๐ฏ What it solves
Entity clusters (see Entity Clusters) pack many entities of one archetype into a single contiguous chunk for cache-friendly storage. A per-entity spatial index defeats that locality โ every entity move triggers an index update, and queries scatter-read leaf nodes across the tree. Cluster Spatial Queries indexes cluster bounding boxes instead of individual entities, so spatial lookups (find nearby units, find food in radius, AI sensing) stay cheap even as entity counts scale into the hundreds of thousands, without re-introducing per-entity index maintenance cost.
โ๏ธ How it works (in brief)
Each grid cell (see Spatial Grid & Cells) holds a small linear array of cluster AABBs for each cluster-eligible archetype occupying it โ typically 15-80 entries. A query first expands its region into the overlapping cells, scans each cell's cluster AABBs (broadphase), and for every cluster whose AABB overlaps, scans that cluster's live entities individually (narrowphase). Cluster AABBs are recomputed once per tick for clusters that changed, not on every entity write. Static and dynamic clusters are indexed separately and both are checked by every query. This path entirely replaces the legacy per-entity R-Tree for cluster-eligible archetypes โ there is no fallback.
๐ป Usage
[Component("Game.Position", 1)]
public struct Position
{
[SpatialIndex(margin: 5.0f)]
public AABB2F Bounds;
}
using var guard = EpochGuard.Enter(dbe.EpochManager);
// AABB โ direct per-cell query, zero allocation
var box = new AABB2F { MinX = 0, MinY = 0, MaxX = 50, MaxY = 50 };
foreach (var hit in dbe.ClusterSpatialQuery<Ant>().AABB<AABB2F>(in box, categoryMask: 0x3))
{
// hit.EntityId, hit.ClusterChunkId, hit.SlotIndex, hit.MinX/Y/Z, hit.MaxX/Y/Z
}
// Radius โ same per-cell path, sphere distance filter
var sphere = new BSphere2F { CenterX = 10, CenterY = 10, Radius = 15 };
foreach (var hit in dbe.ClusterSpatialQuery<Ant>().Radius(in sphere))
{
// hit.DistanceSq is populated (closest point on entity AABB to sphere center)
}
// Equivalent Radius/Nearest access via the fluent ECS query (routes through the same cluster path)
using var t = dbe.CreateQuickTransaction();
var nearby = t.Query<AntArch>()
.WhereNearby<Position>(centerX: 10, centerY: 10, centerZ: 0, radius: 15)
.Execute();
TBox must be AABB2F or AABB3F and must match the archetype's declared spatial field dimensionality/precision exactly โ a mismatch throws InvalidOperationException at the call site, not silently truncating coordinates.
โ ๏ธ Guarantees & limits
- Raw enumerator requires an
EpochGuardscope โClusterSpatialQuery<TArch>reads cluster pages directly, so the caller must holdEpochGuard.Enter(dbe.EpochManager)for the call's duration.EpochGuardisinternal; reaching it directly (as above) requires the sameInternalsVisibleToboundary as Cluster Dormancy. Application code without that access goes throughEcsQuery.WhereNearby/WhereInAABB, which manages the epoch scope internally. - Requires
ConfigureSpatialGridbeforeInitializeArchetypesโ a cluster-eligible archetype with a[SpatialIndex]field and no configured grid throws at archetype initialization, not at first query. - AABB and Radius only โ Ray and Frustum queries against cluster archetypes throw
NotSupportedException; use the per-entity Spatial Query API for non-cluster archetypes that need those shapes. - f32 only โ
AABB2D/AABB3D(f64) throwNotSupportedException; onlyAABB2F/AABB3Fare implemented. - No false negatives โ cluster AABBs always contain every live entity in the cluster (recomputed at the tick fence for dirty clusters), so a query never misses a geometrically-matching entity (rule CA-01).
- Category mask is "any bit overlaps" โ a non-zero mask skips a cluster only if none of its entities' OR'd category bits intersect the query mask; pass
uint.MaxValue(default) for no filtering. - Zero-allocation enumerator โ
AABB<TBox>andRadiusboth return aref structenumerator; no heap allocation for the scan itself.EcsQuery.WhereNearby/WhereInAABBmaterialize results into aHashSet<EntityId>because the fluent API composes with archetype/Where filters. - ~2x slower per query than the old per-entity tree, but eliminates all per-entity index maintenance โ broadphase+narrowphase costs roughly 300-400ns for a typical 4-cell query vs ~150-200ns for the legacy tree traversal; the legacy tree's ~25ns-per-entity-per-tick update cost is gone entirely.
- No public
Nearest/kNN onClusterSpatialQuery<TArch>yet โ k-nearest is only reachable internally (consumed byEcsQuery.WhereNearby's radius-expansion path); a dedicated public.Nearest()wrapper is deferred. - No overflow tree / SIMD narrowphase โ broadphase is always a linear scan regardless of cluster count; both are profiling-gated future optimizations, not current bottlenecks at typical cell populations (โค80 clusters).
๐งช Tests
- ClusterSpatialTests โ
SpatialQuery_AABB_ReturnsClusterEntities/SpatialQuery_Radius_ReturnsClusterEntities(2D broadphase+narrowphase correctness) - ClusterSpatial3DTests โ 3D Z-axis AABB/Radius filtering, Z-boundary overlap edge cases
- CellSpatialIndexTests โ per-cell cluster-AABB array growth/swap-removal, category-union computation backing the broadphase
๐ Related
- Source: src/Typhon.Engine/Spatial/public/ClusterSpatialQuery.cs
- Source: src/Typhon.Engine/Spatial/public/AabbClusterEnumerator.cs
- Source: src/Typhon.Engine/Spatial/public/ClusterSpatialAabb.cs
- Source: src/Typhon.Engine/Spatial/internals/CellSpatialIndex.cs
- Related catalog entry: Spatial Query API (the per-entity R-Tree path for non-cluster archetypes)
- Related catalog entry: Entity Clusters