Struct SpatialGridConfig
Immutable configuration for the engine-wide spatial grid. Set once via ConfigureSpatialGrid(SpatialGridConfig) before archetypes are initialized.
public readonly struct SpatialGridConfig
- Inherited Members
Remarks
All spatial archetypes share a single coarse grid with one cell size. Per-archetype differences are expressed at the system level, through tier filters, rather than at the grid level.
The grid is three-dimensional, and a flat world is simply a grid one cell deep. There is deliberately no 2D overload set: a 2D/3D pair would give you a call site where the Z coordinate is silently dropped, collapsing every entity onto the z = 0 plane. That does not raise — it returns spatial query results that are quietly wrong. Use Flat(Vector2, Vector2, double, float, float, float, float, float, float, int, float, float, int, float, bool, bool, float, int, int, int, float) to build a one-cell-deep world in a single call, and keep one code path.
Grid dimensions are derived per axis from (WorldMax - WorldMin) / CellSize, rounded up, and cell keys are plain row-major
(z * GridHeight + y) * GridWidth + x. There is no Morton encoding and no power-of-two padding: a 32-bit 3D Morton key would cap the world at 1 024
cells per axis, and the square key space its 2D predecessor needed would have made the descriptor count KeySpaceDim³ — over a billion cells for a
1024 x 1024 x 1 world.
Constructors
SpatialGridConfig(Vector3D, Vector3D, double, float, float, float, float, float, float, int, float, float, int, float, bool, bool, float, int, int, int, float)
Build a grid configuration and precompute the derived cell dimensions. World bounds are half-open: worldMin is inclusive,
worldMax is exclusive.
public SpatialGridConfig(Vector3D worldMin, Vector3D worldMax, double cellSize, float migrationHysteresisRatio = 0.05, float clusterTargetExtentRatio = 0.25, float clusterDriftMarginRatio = 0.05, float clusterRepairExtentRatio = 0.75, float reclusterBudgetMs = 1, float repairNsPerEntity = 1500, int repairWorstClustersPerUnit = 8, float clusterRepairCriticalExtentRatio = 1, float repairAgingRatePerTick = 0.05, int repairQueueMaxCells = 4096, float clusterTargetPackingSlack = 1.5, bool leastEnlargementPlacement = false, bool growthCapPlacement = false, float growthCapSlack = 1.25, int maxOpenClustersPerCell = 4, int batchSpawnSortThreshold = 128, int repairCooldownTicks = 50, float queryEfficiencyTolerance = 0.1)
Parameters
worldMinVector3DWorld-space minimum corner (inclusive).
worldMaxVector3DWorld-space maximum corner (exclusive); must be strictly greater than
worldMinon all three axes.cellSizedoubleCell size in world units; must be > 0.
migrationHysteresisRatiofloatPer-axis dead zone as a fraction of cell size (default 0.05).
clusterTargetExtentRatiofloatTarget cluster extent as a fraction of cell size — P4 (default 0.25).
clusterDriftMarginRatiofloatIntra-cell drift dead zone as a fraction of cell size (default 0.05).
clusterRepairExtentRatiofloatExtent past which a cell is nominated for a full re-sort — P7 (default 0.75).
reclusterBudgetMsfloatPer-tick repair budget in milliseconds; 0 disables repair (default 1.0).
repairNsPerEntityfloatProjected repair cost per entity in nanoseconds (default 1500, measured).
repairWorstClustersPerUnitintClusters per repair unit; 0 means the whole cell (default 8).
clusterRepairCriticalExtentRatiofloatDegradation at which a cell jumps the queue regardless of budget; 0 disables it (default 1.0).
repairAgingRatePerTickfloatRank growth per tick a candidate waits; 0 disables ageing (default 0.05).
repairQueueMaxCellsintHard cap on queued repair candidates (default 4096).
clusterTargetPackingSlackfloatMultiplier on the per-cell packing bound that sets the intra-cell target; 0 keeps the constant (default 1.5).
leastEnlargementPlacementboolPlace arrivals in the least-enlargement cluster of their cell (default false — opt-in).
growthCapPlacementboolOpen a fresh cluster when an arrival would stretch past the cap (default false).
growthCapSlackfloatMultiplier on the density target the cap allows (default 1.25).
maxOpenClustersPerCellintOpen clusters the cap may hold per cell (default 4).
batchSpawnSortThresholdintSpawns per transaction above which the batch is placed in Morton order; 0 disables (default 128).
repairCooldownTicksintTicks during which a just-repaired cell is not repaired again; 0 disables (default 50).
queryEfficiencyTolerancefloatDistance above the best candidates per hit at which maintenance gets the whole budget; 0 grants the configured budget every tick (default 0.1).
Exceptions
- ArgumentOutOfRangeException
cellSizeis not positive, or the derived cell count does not fit a 32-bit cell key.- ArgumentException
worldMaxis not strictly greater thanworldMinon all three axes.
Fields
BatchSpawnSortThreshold
A transaction that spawns at least this many entities places them in per-cell Morton order, so a bulk load is born at the packing bound rather
than at the full extent of every cell it touches. Default 128; 0 disables the ordering.
public readonly int BatchSpawnSortThreshold
Field Value
Remarks
Placement is first fit within a cell in visiting order, and a random-order batch fills each cluster with whatever arrived next: measured 99 % of the cell at 250 entities per cell, against a bound of 63 %. Ordering the batch along the intra-cell Morton curve costs one O(n log n) sort over the batch and reads only the staged spatial field — no accessor — and leaves the batch born at 1.24× the bound at 250 per cell (1.52× at 512: Z-order runs that straddle a quadrant boundary are what keeps it off the ideal). Below the threshold a batch cannot fill more than one cluster of a cell, so the sort would buy nothing.
CellCount
Total number of cell descriptor slots: GridWidth × GridHeight × GridDepth.
public readonly int CellCount
Field Value
CellSize
Size of a single grid cell, in world units. Cells are cubic. Must be > 0.
public readonly double CellSize
Field Value
Remarks
f64 so that origin = WorldMin + cellCoord × CellSize is exact at world extent — the product is what overflows f32's mantissa, not the cell size
itself. A cell size that happens to be f32-exact is unaffected.
ClusterDriftMarginRatio
Dead zone around the target region, as a fraction of cell size, below which a drifting entity is left alone. Default 0.05.
public readonly float ClusterDriftMarginRatio
Field Value
Remarks
The intra-cell counterpart of MigrationHysteresisRatio, and deliberately a separate number: that one governs cell crossing, is
measured by LastTickHysteresisAbsorbedCount, and both existing detectors emit only when the cell key actually changes — so neither can
absorb anything for a move that stays inside one cell. Without its own margin, an entity sitting on the target-region boundary would be relocated
every tick it jitters across, paying a full migration to move a few units.
ClusterRepairCriticalExtentRatio
Degradation at which a cell may jump the repair queue and be serviced even when the budget cannot cover it. Default 1.0. Zero disables the valve.
public readonly float ClusterRepairCriticalExtentRatio
Field Value
Remarks
§5.6's safety valve: "degradation must be bounded". A budget that never keeps up would otherwise let a cell degrade without limit,
because ranking only decides who goes FIRST, not who goes at all. At 1.0 the trigger is a cluster whose bound covers its entire cell — the worst
state reachable without the outlier guard firing, since a cluster holding only its own cell's entities tops out near
1 + MigrationHysteresisRatio. Strictly above ClusterRepairExtentRatio's 0.75, so a critical cell has always been an ordinary
candidate first.
The overshoot this permits is CAPPED, and the cap is not optional. AC-11.1 allows exceeding the budget "by more than one
indivisible unit", which reads as licence until one notices that a whole cell is one indivisible unit and a 100 K-entity cell was measured at
~133 ms — a 133x overrun that would still claim compliance. So a valve admission forces the unit down to
RepairWorstClustersPerUnit clusters and fires at most once per tick per archetype. Nothing else in the planner may exceed the
budget at all.
ClusterRepairExtentRatio
The extent past which a cluster is considered beyond the delta path's reach and its cell is nominated for a full re-sort, as a fraction of cell size — design parameter P7. Default 0.75.
public readonly float ClusterRepairExtentRatio
Field Value
Remarks
P7 as the design states it cannot fire, and this is the correction. The design says to start at "the existing cellSize x 1.2
extent check". That check belongs to the OUTLIER GUARD, and it is looking for a different fault: a cluster whose bound has grown past its own cell,
which happens only when it holds entities that should have migrated out and did not. A cluster whose entities all genuinely belong to its cell cannot
exceed the cell by more than the hysteresis margin — MigrationHysteresisRatio, 5 % by default — so its extent tops out near
1.05 x cellSize and the 1.2 threshold is unreachable. The scenario AC-12.1 names, AABBs at some 90 % of the cell, sits comfortably
below it. Wiring repair to that trigger would have produced a repair path that never runs, and a green test suite saying nothing.
Why 0.75 and not the drift target. ClusterTargetExtentRatio (0.25) is where step 10 starts RELOCATING, and nominating there would ask for a re-sort of every cluster the delta path is already working on — the opposite of rare. Repair is for degradation relocation cannot undo, so its threshold belongs well above the drift target and below the cell: three quarters of a cell means the cluster is opened by three quarters of the queries that touch the cell, and no greedy per-entity move is going to change that. It sits between the two existing gates by construction, so a cluster that nominates has always been drift-gated too.
Provisional, like P4. The value that resolves it comes from step 11's budget/tightness curve.
ClusterTargetExtentRatio
The extent a cluster's AABB is expected to stay within, as a fraction of cell size — the target region of §5.2, and parameter P4 of the design's open-parameter table. Default 0.25.
public readonly float ClusterTargetExtentRatio
Field Value
Remarks
What it gates. Intra-cell drift detection (#872 step 10) is two-level. A cluster whose largest axis extent is within this bound is tight enough and its entities are never examined; only inside a cluster that exceeds it does the per-entity test run. That is what makes "detect broadly" affordable — a healthy world pays three float compares per written cluster and nothing per entity.
Why it is not the cluster's own AABB. The write-time CAS in ClusterRef.MaybeGrowAndFlagShrink grows that bound to contain every
entity it holds, so "outside my cluster's AABB" is never true of anything. The target region has to be an independent, tighter box or it detects
nothing.
This is the floor of the target, not the target (step 14). The extent a full cluster can reach in a cell of E entities is bounded
by geometry: (slotsPerCluster / E)^(1/d) of the cell edge, 1.0 in a cell that fits one cluster and 0.5 at 512 entities in 3D. A constant
0.25 asked for the impossible everywhere the density guidance sends users, so the gate fired on every written cluster on every tick and every
entity beyond the target was a drifter with nowhere to go. The operative target is clamp(ClusterTargetPackingSlack × bound, this, 1),
evaluated per cell from its live population — see ClusterTargetPackingSlack. This value only matters in cells dense enough for the
bound to fall below it, which at the default slack is about 3 500 entities per cell in 3D and 1 500 in 2D.
ClusterTargetPackingSlack
Multiplier on the per-cell packing bound that sets the intra-cell target extent: target = clamp(slack × (slotsPerCluster / E)^(1/d),
ClusterTargetExtentRatio, 1) of the cell edge, where E is the cell's live entity count. Default 1.5. 0 disables the
derivation, the throttle's boost and the repair-first exclusion, and makes ClusterTargetExtentRatio the constant it used to be.
public readonly float ClusterTargetPackingSlack
Field Value
Remarks
Why a slack at all. The bound is what a perfect Morton tiling reaches; an online packing under motion sits above it, and a gate at exactly the bound would fire on clusters no relocation can improve. 1.5 is the factor the R-Tree crossover sweep itself tiles at, so a cell that holds the target is one whose tree can prune.
A target of 1 means off. In a cell whose population fits one cluster the bound is the cell, so the gate never fires, the drift scan
never runs and no relocation is ever filed — which is the correct amount of intra-cell maintenance for that cell, and was measured to be where every
uniform configuration in the recommended 16–64 entities/cell basin lives. Repair nominates at max(target, ClusterRepairExtentRatio) for the
same reason: a re-sort cannot beat the bound either.
GridDepth
Number of cells along the Z axis. 1 for a flat world built with
Flat(Vector2, Vector2, double, float, float, float, float, float, float, int, float, float, int, float, bool, bool, float, int, int, int, float).
public readonly int GridDepth
Field Value
GridHeight
Number of cells along the Y axis.
public readonly int GridHeight
Field Value
GridWidth
Number of cells along the X axis — derived from (WorldMax.X - WorldMin.X) / CellSize, rounded up.
public readonly int GridWidth
Field Value
GrowthCapPlacement
Open a fresh cluster for an arrival whose best candidate would stretch past the cell's density-derived target × GrowthCapSlack,
up to MaxOpenClustersPerCell open clusters per cell. Implies least-enlargement ranking of the candidates. Default false.
public readonly bool GrowthCapPlacement
Field Value
Remarks
The one write-path mechanism that CREATES tightness under motion — 7 to 16 points tighter than first fit at 40 ticks, the best sustained arm of the step-15 measurement — and it is paid for in occupancy and per-cluster fence work: +4–29 % fence, −4 to −17 points of slot occupancy at 250 entities per cell. At birth on an unsorted fill it buys only ~4 points (ranking finds candidates that fit under the cap, so few clusters open); birth tightness is BatchSpawnSortThreshold's job. A workload trade, not a default — the case for it is a dense, slow pocket queried with boxes no larger than a tenth of a cell. Never fires in a cell whose population fits one cluster.
GrowthCapSlack
Multiplier on the density-derived target that a growth-capped arrival may stretch its cluster to. Default 1.25.
public readonly float GrowthCapSlack
Field Value
InverseCellSize
Precomputed 1 / CellSize.
public readonly double InverseCellSize
Field Value
LeastEnlargementPlacement
Place an arriving entity — a spawn, or a cell crossing at drain time — in the cluster of its cell whose bound grows least to admit it (ties to the
smallest resulting box), rather than in the first cluster with a free slot. Default false: opt-in.
public readonly bool LeastEnlargementPlacement
Field Value
Remarks
Off by default because it measured as a wash where repair runs and a loss where it does not. Against first fit, with the batch ordering on in both arms (step 15, 16 000 entities at 250 per cell, W = 8, medians): Drift at 8 ms 77.4 vs 77.6 %, Drift at 1 ms 85.7 vs 84.5 %, Cruise at 8 ms 89.3 vs 80.2 % — worse. First fit concentrates arrivals into one cursor cluster per cell that the planner then re-sorts; least enlargement spreads them over every cluster, each a little wider, and under heavy crossings that is the mean going up. The one thing it does unambiguously is place a lone arrival beside its neighbours (asserted), which a workload with rare arrivals into a dense, slow pocket may want.
What is NOT optional is the ordering of batch spawns (BatchSpawnSortThreshold) — that is where the measured gain lives.
MaxOpenClustersPerCell
Open (non-full) clusters the growth cap may hold per cell before it falls back to least enlargement. Default 4.
public readonly int MaxOpenClustersPerCell
Field Value
MigrationHysteresisRatio
Fractional dead zone applied per axis during entity migration, as a fraction of cell size. Default 0.05 (5 % of cell size).
public readonly float MigrationHysteresisRatio
Field Value
QueryEfficiencyTolerance
How far above the best candidates per hit an archetype's range queries have shown they may drift before its maintenance gets the whole of
ReclusterBudgetMs. Default 0.1; 0 turns the controller off and grants the configured budget every tick.
public readonly float QueryEfficiencyTolerance
Field Value
Remarks
The budget follows what maintenance buys (rule TH-04). Each tick an archetype's budget is ReclusterBudgetMs times
(smoothed / best - 1) / tolerance, clamped to (0, 1], where smoothed is its queries' candidates per hit over the last twenty ticks or so
and best the lowest that has reached: next to nothing while its queries test as few entities per match as they ever have, the whole budget
once they test 10 % more. ReclusterBudgetMs is the ceiling, and its 0 still means no enforcement.
Measured on the SWG Tatooine workload with RepairCooldownTicks at 50, against a tolerance of 0 (2026-09-16, three paired runs each at 50 Hz): a median 10 % off the tick at 4× population over 1 000 ticks, and 4.7 % and 1.9 % at 16× and 64× over 3 000 (9.5 % and 2.6 % over the first 1 000, before the controller has had to spend). Migrations per tick fall 40–85 %, and queries test 0.6–1.4 % more entities per match with the time per query within 1 %.
Only range queries steer it — the counters rule SO-02 defines. An archetype without such queries, none at all or only nearest-neighbour, ray and frustum ones, keeps the configured budget.
Dimensionless, which is the point. A controller priced in time per wasted candidate had to be re-tuned for each population: the same constant was the best balance at 16× and 9 % worse than a fixed budget at 64×, because the waste grows with the query volume and what maintenance costs does not. In the experiment behind this one — four times the budget as its ceiling, and a 200-tick cooldown — one distance from the archetype's own best held at 16× and 64×, and 0.05 to 0.3 landed within 3 % of each other. This controller has been measured at 0.1 only.
It holds the best; it does not seek it. A world whose clusters start loose shows that as its best, and the controller then spends almost nothing improving it: spawn placement and the safety valve (ClusterRepairCriticalExtentRatio) bound that case. The best is only ever lowered, so a slow decline raises the budget as surely as a fast one; after 200 ticks at the whole budget the queries' present level becomes the best, so a decline past the tolerance that maintenance cannot undo is accepted, and one it can undo is not. A lasting shift within the tolerance is never accepted: it keeps its share of the budget, erring toward spending.
ReclusterBudgetMs
Per-tick, per-archetype wall-clock budget for the repair path — the full Morton re-sort of §5.2 — in milliseconds. 0 disables repair
entirely. Default 1.0.
public readonly float ReclusterBudgetMs
Field Value
Remarks
Whole units, never a fraction of one (§5.6). A Morton sort cannot be halved: a partly re-sorted cell is worse than an untouched
one, because the cost is paid and the benefit is not. So this budget gates whether a unit is started, and a unit the remaining budget cannot
finish is not begun (AC-12.5). That makes the budget an admission threshold rather than a stopping condition, which is the opposite of how
the delta path in step 10 spends — that one is resumable per entity.
The estimate, not the measurement, decides. Cost is projected as
entities x RepairNsPerEntity before anything moves, since the decision has to precede the work. The measured spend lands in
SpatialMigrationTelemetry.ReclusterBudgetUsedMs, which is what tells you whether the projection is honest.
Step 11 replaces the constant with a controller. The design's budget is "adjusted at runtime from the previous tick's measured cost"; this is the static knob that controller will drive, and the seam a test uses to pin the budget to just-below-cost.
1.0 ms is sized against the measured cost, not chosen. At RepairNsPerEntity it admits ~670 entities, which covers one default unit of eight 49-slot clusters (392 entities) with room to spare. The first value tried was 0.25 ms, which — once the cost was measured rather than assumed — could not afford a single unit, so the feature would have shipped switched on and never run. A re-packed cell is refused on every later tick until its geometry actually changes — but under motion that is the next tick, which is why RepairCooldownTicks exists: without it this budget was measured going, tick after tick, to re-sorting the same cells.
RepairAgingRatePerTick
How much a queued cell's rank grows per tick spent waiting. Default 0.05 — a candidate doubles its score after 20 ticks. Zero disables ageing.
public readonly float RepairAgingRatePerTick
Field Value
Remarks
Ranking alone starves, and AC-11.3 forbids that. §5.6 asks for candidates ranked by expected selectivity gain and is explicit
that "round-robin is the wrong policy" — but a pure ranking never services a cell that is permanently outranked. The age factor is unbounded in
the tick count, so whatever a candidate's base score, enough waiting carries it to the head. That makes no-starvation a property of the arithmetic
rather than a hope about the workload.
0.05 is slow relative to the rate at which repairs actually happen: a cell that genuinely deserves servicing gets it long before ageing matters, and ageing only decides the order among candidates the budget has been unable to reach.
RepairCooldownTicks
Ticks during which a cell whose repair unit has just moved entities is not repaired again. Default 50; 0 disables the cooldown.
public readonly int RepairCooldownTicks
Field Value
Remarks
What the planner spent its budget on without it was churn. Under motion a re-packed cell spreads out again within a few ticks, is nominated again and is re-packed again: the same cells, tick after tick, each time for a gain the next ticks undo. Measured on the SWG Tatooine workload (2026-09-15), intra-cell maintenance cost 13–51 % of the tick. This cooldown took a median 6 %, 20 % and 37 % off the tick at 64×, 16× and 4× population (three paired 20 s runs each), with the queries' cost within 3 %; the experiment before it found nothing to take in a mostly still world.
Nominations are held, not dropped. A cell nominated during its cooldown re-enters the repair queue when the cooldown ends, at the worst degradation seen meanwhile, whether or not it is nominated again — so a cell that stops moving while it cools is still repaired, the next time its archetype is planned. Until then it is not a queue candidate: not ranked, not counted against RepairQueueMaxCells, and not eligible for the safety valve, whose bound on degradation therefore stretches by at most this many ticks.
In ticks, like RepairAgingRatePerTick: 2.5 s at 20 Hz. A cell repaired on tick T is eligible again from tick T + this value, so 1 restricts nothing, like 0.
RepairNsPerEntity
Projected cost of moving one entity on the repair path, in nanoseconds — the exchange rate ReclusterBudgetMs is spent at. Default 1500.
public readonly float RepairNsPerEntity
Field Value
Remarks
Not §5.2's 60 ns. That figure does not survive measurement. §5.2 budgets a batched relocation at ~60 ns/entity and derives the ~6 ms
per 100 K-entity cell that makes repair the rare path rather than the per-tick one. AC-12.7's measurement
(ClusterRepairTests.MeasureRepairCostPerEntity, Release, 2 000 entities in 41 clusters, six consecutive repairs on a warm engine) reports
1 331 to 6 992 ns/entity — 22x to 117x the estimate — which projects to ~133 ms for a 100 K-entity cell rather than ~6 ms. The first
repair a process performs measured 20 681 ns/entity and is warm-up, not signal.
What that changes. Repair being the rare path is, if anything, more true than the design argued: at ~133 ms a 100 K-entity cell cannot be re-sorted whole inside any tick, which is exactly why §5.6's preferred unit is one cell's N worst clusters and why RepairWorstClustersPerUnit defaults to 8 rather than to the whole cell. What it does invalidate is a budget calibrated on 60: it would admit units costing twenty times what it thinks, and the per-tick spend AC-11.1 bounds would be exceeded by that factor.
1 500 is the warm BEST, deliberately, not the worst. The number is an admission threshold, and the value that matters for step 11 is the one a controller will converge on from real measurements; seeding it with the worst observed sample would refuse units the machine can comfortably afford. Exposed rather than hard-coded because it is a property of the machine and of the archetype's component width, not of the design.
RepairQueueMaxCells
Hard cap on cells waiting in the repair queue. Default 4096. Beyond it the worst-ranked candidate is evicted to admit a better one.
public readonly int RepairQueueMaxCells
Field Value
Remarks
AC-11.8: the queue must not grow without bound. Step 11's queue is persistent — that is what stops a refused nomination being
forgotten — so it needs an explicit ceiling that a per-tick list did not. Eviction is by score, so a full queue sheds the candidates whose repair
would buy least, and the eviction count is published: a non-zero rate against a full queue is the reading that says the cap is below what the world
actually degrades.
RepairWorstClustersPerUnit
How many of a cell's worst clusters one repair unit re-packs. Default 8; 0 or more than the cell holds means the whole cell.
public readonly int RepairWorstClustersPerUnit
Field Value
Remarks
§5.6's preferred unit — "one cell's N worst clusters" — with the whole cell as the documented fallback. Finer than a whole cell, still
internally coherent (the entities re-sorted are exactly the ones re-packed), and it targets the clusters actually costing selectivity instead of
spending a 100 K-entity budget to fix eight bad bounds. "Worst" is the largest maximum axis extent, which is the same quantity the
cellSize x 1.2 trigger reads.
WorldMax
World-space maximum corner (exclusive — the grid excludes the max edge).
public readonly Vector3D WorldMax
Field Value
Remarks
f64 since #914, and that is what makes the world big. Stored bounds stay f32 and cell-relative (C15); it is the FRAME they are measured
from that has to carry the magnitude, because a cell origin at 10⁹ has more mantissa than f32 holds. Widening from Vector3 is implicit
and exact, so an f32 world configures exactly as it did.
WorldMin
World-space minimum corner (inclusive).
public readonly Vector3D WorldMin
Field Value
Remarks
f64 since #914, and that is what makes the world big. Stored bounds stay f32 and cell-relative (C15); it is the FRAME they are measured
from that has to carry the magnitude, because a cell origin at 10⁹ has more mantissa than f32 holds. Widening from Vector3 is implicit
and exact, so an f32 world configures exactly as it did.
Methods
Flat(Vector2, Vector2, double, float, float, float, float, float, float, int, float, float, int, float, bool, bool, float, int, int, int, float)
Build a configuration for a flat world — one cell deep on Z, which is how a 2D game expresses itself to a 3D grid (C16). Z coordinates outside the single cell clamp into it, which is exactly what the grid did for every entity before it gained a third axis.
public static SpatialGridConfig Flat(Vector2 worldMin, Vector2 worldMax, double cellSize, float migrationHysteresisRatio = 0.05, float clusterTargetExtentRatio = 0.25, float clusterDriftMarginRatio = 0.05, float clusterRepairExtentRatio = 0.75, float reclusterBudgetMs = 1, float repairNsPerEntity = 1500, int repairWorstClustersPerUnit = 8, float clusterRepairCriticalExtentRatio = 1, float repairAgingRatePerTick = 0.05, int repairQueueMaxCells = 4096, float clusterTargetPackingSlack = 1.5, bool leastEnlargementPlacement = false, bool growthCapPlacement = false, float growthCapSlack = 1.25, int maxOpenClustersPerCell = 4, int batchSpawnSortThreshold = 128, int repairCooldownTicks = 50, float queryEfficiencyTolerance = 0.1)
Parameters
worldMinVector2World-space minimum corner on X and Y (inclusive). Z is taken as 0.
worldMaxVector2World-space maximum corner on X and Y (exclusive).
cellSizedoubleCell size in world units; must be > 0.
migrationHysteresisRatiofloatPer-axis dead zone as a fraction of cell size (default 0.05).
clusterTargetExtentRatiofloatTarget cluster extent as a fraction of cell size — P4 (default 0.25).
clusterDriftMarginRatiofloatIntra-cell drift dead zone as a fraction of cell size (default 0.05).
clusterRepairExtentRatiofloatExtent past which a cell is nominated for a full re-sort — P7 (default 0.75).
reclusterBudgetMsfloatPer-tick repair budget in milliseconds; 0 disables repair (default 1.0).
repairNsPerEntityfloatProjected repair cost per entity in nanoseconds (default 1500, measured).
repairWorstClustersPerUnitintClusters per repair unit; 0 means the whole cell (default 8).
clusterRepairCriticalExtentRatiofloatDegradation at which a cell jumps the queue regardless of budget; 0 disables it (default 1.0).
repairAgingRatePerTickfloatRank growth per tick a candidate waits; 0 disables ageing (default 0.05).
repairQueueMaxCellsintHard cap on queued repair candidates (default 4096).
clusterTargetPackingSlackfloatMultiplier on the per-cell packing bound that sets the intra-cell target; 0 keeps the constant (default 1.5).
leastEnlargementPlacementboolPlace arrivals in the least-enlargement cluster of their cell (default false — opt-in).
growthCapPlacementboolOpen a fresh cluster when an arrival would stretch past the cap (default false).
growthCapSlackfloatMultiplier on the density target the cap allows (default 1.25).
maxOpenClustersPerCellintOpen clusters the cap may hold per cell (default 4).
batchSpawnSortThresholdintSpawns per transaction above which the batch is placed in Morton order; 0 disables (default 128).
repairCooldownTicksintTicks during which a just-repaired cell is not repaired again; 0 disables (default 50).
queryEfficiencyTolerancefloatDistance above the best candidates per hit at which maintenance gets the whole budget; 0 grants the configured budget every tick (default 0.1).