Tuning the Spatial Grid
The partition is not self-configuring. On a badly chosen cell size a world pays heavy migrations every tick and ends with looser clusters than it started with. Derive the settings from your world, then read the telemetry that names the wrong one.
Status: โ Implemented ยท Visibility: Public ยท Level: ๐ฃ Advanced ยท Category: Spatial
๐ฏ What it solves
A cluster holds up to 64 entities of one archetype in one grid cell, and a query tests the cluster's bounding box before opening it โ so query cost tracks how tight those boxes are, not how many entities exist. Motion loosens them. A slot is claimed at spawn by taking the first cluster in the cell with room, with no regard for position, so under sustained movement a cluster's box grows until it covers most of its cell. The measured mean is 90 % of the cell, at which a narrow query opens all ~1 563 clusters of a 100 K-entity cell instead of the ~64 it needs. Three mechanisms push back, and every one of them is governed by a number you supply. This page is how you supply them.
Two facts decide how you use this page, so they come first.
The configuration is set once and cannot be changed at runtime. ConfigureSpatialGrid must be called before InitializeArchetypes and throws if called twice or afterwards. There is no live knob, no per-archetype override, and no way to sweep a value inside one process. Every tuning experiment is a rebuild and a rerun, which is why the parameter table below is organised around the symptom that tells you which number to change rather than around trial and error.
RepairNsPerEntity is a seed for a runtime controller, not a cost constant. Measurement (ClusterRepairTests.MeasureRepairCostPerEntity, Release, 2 000 entities in 41 clusters) puts a warm repair at roughly a microsecond per entity โ ~1 300 ns of tick fence per repaired entity โ which projects a 100 K-entity cell at ~130 ms for one full re-sort. The default of 1 500 seeds an EWMA (ฮฑ = 0.25) of the measured migration and planner cost, clamped to [seed / 10, seed ร 20], so after the first few ticks the engine is spending against its own measurement. Read MeasuredNsPerEntity for the live value; change the seed only if the first ticks of a run behave badly.
โ๏ธ How it works (in brief)
Degradation is answered at three levels, cheapest first. Each has its own ratio, and they are separate numbers because they govern different events.
One cell over time. Hysteresis absorbs a jitter across a cell boundary, relocation moves a single drifted entity into a better cluster, and repair re-sorts the whole cell in Morton order.
Level 1 โ hysteresis absorbs jitter. MigrationHysteresisRatio ร CellSize is a dead zone outside each cell face. An entity whose centre crosses a boundary but stays inside the margin is left where it is, so an entity walking along an edge does not migrate every tick. This is inter-cell only: both detectors emit only when the cell key actually changes, so hysteresis can absorb nothing for a move that stays inside one cell. Cost: six float compares per moved entity, and absorbing costs nothing beyond them.
Level 2 โ relocation moves an outlier. Detection runs inside the AABB-refresh phase of the fence, over written clusters only; a cluster nobody touched is skipped entirely. It is two-level. Per cluster, the largest axis extent is compared against CellSize ร ClusterTargetExtentRatio โ three float compares, and a tight cluster is never entered, so a healthy world does no per-entity work at all. Only inside a cluster that fails that gate does the per-entity test run: an entity is a drifter when it overshoots a target-sized box around the cluster's centroid by more than ClusterDriftMarginRatio ร CellSize. The centroid, not the box centre, is the behaviour that matters โ a box centre sits halfway between the extremes, so thirty entities at x โ 12 and one at x = 90 put it at 50, in empty space, and the rule would then flag the thirty and relocate the majority away from where the cluster actually is. Cost: a relocation is a full intra-cell migration of one entity, priced by the same estimator repair uses.
The cluster gate is what makes broad detection affordable: a tight cluster costs three compares and its entities are never examined.
Level 3 โ repair re-sorts the cell. A cell is nominated when a cluster's extent exceeds CellSize ร ClusterRepairExtentRatio, which sits deliberately above the drift target: repair is for degradation relocation cannot undo. Candidates go into a persistent per-cell queue ranked by degradation, tier weight, cluster count and an age factor, and the planner admits what the budget affords. A repair unit is RepairWorstClustersPerUnit of the cell's worst clusters, re-sorted by 21-bit-per-axis Morton order into pinned destination slots. Cost: roughly a microsecond per entity, and one pass over 2 000 entities took mean cluster extent from 87.0 to 23.0 against a theoretical optimum of 15.6, narrowing the cell's total zone-map width to 22 % of what it was.
Repair is the only mechanism that ever narrows a zone map, and the only one that can undo a cluster whose entities are genuinely scattered across its cell.
๐ Choosing the cell size
This is the one consequential decision, and the unit matters: a cell holds clusters, not entities. Cell size's real job is to bound how wide a cluster may grow โ the cap is max(density-derived target, ClusterTargetExtentRatio) ร GrowthCapSlack ร CellSize, with the ratio at 0.25 by default and the engine tightening the target further as a cell fills. So choosing a cell size is choosing how tight a cluster is allowed to be, in world units. An entity count per cell is a consequence of that and of your population; it is not a lever, and it is not what either measurement below varies.
Two costs pull against each other:
- Too large and the cluster gate stops pruning: a cluster box spans much of its cell, so every hit opens a wide box and the entities inside are examined anyway.
- Too small and the walks pay instead: ray and frustum walks are cell-major and probe the grid once per coordinate swept, while the cluster extent is capped far below the query radius, so neither the cell nor the cluster prunes what the other did not. Finer is not safer.
There is no portable number here, and the two workloads measured so far disagree by an order of magnitude. Do not derive a cell size from a rule of thumb โ measure it on your own workload.
A small synthetic world โ SpatialPartitionMatrix Matrix C: 16 000 entities in a 1 000-unit 3D world, eight workers, cruise motion. Measured before the #941 query rebuild, so treat the absolute numbers as historical.
| cell size | fence | small AABB query | ray query |
|---|---|---|---|
| 63 | 2.67 ms | 1.7 ยตs | 58.4 ยตs |
| 100 | 1.77 ms | 1.3 ยตs | 15.1 ยตs |
| 159 | 2.19 ms | 1.6 ยตs | 9.6 ยตs |
| 252 | 2.26 ms | 3.3 ยตs | 13.9 ยตs |
| 400 | 2.62 ms | 7.5 ยตs | 23.3 ยตs |
| 635 | 3.59 ms | 19.2 ยตs | 62.3 ยตs |
Both failure directions are visible: the small AABB query costs 14.8ร more at cell 635 than at 100, and the ray costs 6.1ร more at cell 63 than at 159.
A game-shaped world at scale โ the SWG Tatooine demo on current main: 268 944 entities (173 504 of them moving), 10 Hz, five archetypes and nine systems, dominant query radius 192 m. Median tick over three passes, cell sizes quoted at the real planet's scale:
| world | 64 m | 128 m | 256 m | 512 m | 1 024 m |
|---|---|---|---|---|---|
| 16 km | 8.20 ms | 4.43 ms | 3.58 ms | 3.34 ms | 3.34 ms |
| 128 km | 7.62 ms | 4.22 ms | 3.30 ms | 3.02 ms | 3.18 ms |
Here the optimum is a plateau at the large end, and the finest cell costs 2.3โ2.5ร the best at every population measured. Walking fewer, fatter cells wins once a cell walk is cheap: the SQ-01 reach that can fall, the SIMD broadphase and batched radius queries all arrived after the synthetic matrix above.
Where to start, then measure. Take your dominant query radius and give the cluster gate room to work at that scale: with the default ClusterTargetExtentRatio of 0.25, a cell of roughly four times your dominant query radius caps cluster extent near the radius your queries actually test. That is a starting point rather than a rule โ the SWG optimum sits around it, and nothing has yet isolated cell size from cluster extent as separate variables. Sweep it: demo/SwgTatooine --sweep is the worked instrument, and its report is one example of the output to look for.
Size for occupied density and ignore empty volume: the grid is sparse and materialises a cell only once something occupies it, measured at 3.9 MiB dense against 1.2 MiB resident at 20 % occupancy. Keep the grid inside the 32-bit cell-key limit the constructor enforces.
One cost that is not a tuning preference: cell membership is decided by an entity's centre, so a cluster's box can overhang its cell. Every box, radius, ray and frustum query grows its cell range by the archetype's cluster reach (ClusterReach in spatial telemetry): the largest overhang among ordinary clusters, recomputed at every tick fence, so it falls again once the cluster that raised it is fixed. The few clusters that reach much further โ an entity teleported and not yet migrated, an oversized entity โ are not widened for; they are named (up to 16, EscapedClusterCount) and every query tests them directly. Past 16 the reach widens to cover the rest. Only the part of a box inside the world counts. Keep your largest entities small against the cell size all the same: a reach of a large fraction of a cell makes every query walk the neighbouring cells.
๐ป Usage
// Once, before InitializeArchetypes. Every value below is the default; name only what you change.
dbe.ConfigureSpatialGrid(new SpatialGridConfig(
worldMin: new Vector3(0f, 0f, 0f),
worldMax: new Vector3(4000f, 4000f, 4000f),
cellSize: 200f, // ~4x a 50-unit dominant query radius; then swept
migrationHysteresisRatio: 0.05f, // inter-cell dead zone
clusterTargetExtentRatio: 0.25f, // drift gate
clusterDriftMarginRatio: 0.05f, // intra-cell dead zone
clusterRepairExtentRatio: 0.75f, // repair nomination
reclusterBudgetMs: 1.0f, // per tick, per archetype
repairNsPerEntity: 1500f, // EWMA seed, not a constant
repairWorstClustersPerUnit: 8, // 0 means the whole cell
clusterRepairCriticalExtentRatio: 1.0f, // safety valve; 0 disables it
repairAgingRatePerTick: 0.05f, // anti-starvation
repairQueueMaxCells: 4096));
dbe.InitializeArchetypes();
// A flat world takes the same knobs; Z becomes one cell deep.
dbe.ConfigureSpatialGrid(SpatialGridConfig.Flat(
worldMin: new Vector2(0f, 0f),
worldMax: new Vector2(4000f, 4000f),
cellSize: 100f,
reclusterBudgetMs: 4f));
๐๏ธ Every parameter
All twenty-one settings, in constructor order (WorldMin and WorldMax share a row). Cliff marks a value where a small change flips behaviour or throws; the rest are dials that move a cost smoothly.
| Parameter | Default | Unit | Controls | Safe range | Symptom when wrong |
|---|---|---|---|---|---|
WorldMin / WorldMax |
required | world units | Grid extent; the max corner is exclusive | max strictly greater on all three axes | Throws at config time. An entity that leaves the world is clamped into the nearest edge cell, not rejected, so it silently piles up at the boundary |
CellSize |
required | world units | Clusters per cell, the cluster extent cap, memory, query selectivity, migration rate โ everything above | cliff ยท workload-specific, start at ~4ร the dominant query radius and sweep | Too large: small-AABB cost climbs 14.8ร across the synthetic range. Too small: ray and frustum climb 6.1ร, and the game-shaped workload pays 2.3โ2.5ร at its finest cell. Cells ร axes must fit a 32-bit key or the constructor throws |
MigrationHysteresisRatio |
0.05 |
fraction of cell | Dead zone past a cell face before a crossing migrates | dial ยท 0.02โ0.15, unvalidated | Near-zero HysteresisAbsorbedCount against a high MigrationCount โ entities oscillating on a boundary migrate every tick |
ClusterTargetExtentRatio |
0.25 |
fraction of cell | The box a cluster should stay inside; the gate that admits a cluster to per-entity drift testing | dial ยท 0.1โ0.5, cliff above ~1.05 | Too tight and every written cluster enters a per-entity walk, producing drifters the budget then drops โ pure cost, no tightness. Above ~1.05 no cluster can exceed it and drift detection silently stops |
ClusterDriftMarginRatio |
0.05 |
fraction of cell | Dead zone around that target region | dial ยท 0.02โ0.15 | Near-zero DriftAbsorbedCount โ entities relocated every tick to move a few units |
ClusterRepairExtentRatio |
0.75 |
fraction of cell | Extent past which a cell is nominated for a full re-sort | cliff ยท above the drift target, below 1.2 | At or above 1.2 the constructor throws: a cluster holding only its own cell's entities tops out near 1.05, so the threshold could never fire |
ReclusterBudgetMs |
1.0 |
ms/tick/archetype | Admission threshold for relocations and repair units | cliff ยท see the budget table below | 0 disables repair and all throttle enforcement. Sustained RelocationsThrottled > 0 on ticks granted the whole budget (ReclusterBudgetGrantedMs) means it sits under the cliff; below that grant the controller is withholding it by design |
RepairNsPerEntity |
1500 |
ns | The exchange rate the budget is spent at; seed for the runtime EWMA | dial ยท 500โ5 000 | Only the first ticks use it. Too low and those ticks admit units costing many times what was projected. Read MeasuredNsPerEntity for the live value |
RepairWorstClustersPerUnit |
8 |
clusters | Size of one indivisible repair unit; 0 means the whole cell |
dial ยท 2โ16 | Too large and no unit is affordable: RepairUnitsRefused climbs while RepairUnitCount stays at 0. A whole-cell unit on a 100 K-entity cell projects to ~130 ms |
ClusterRepairCriticalExtentRatio |
1.0 |
fraction of cell | Degradation at which a cell jumps the queue despite the budget | cliff ยท strictly between the repair ratio and 1.2, or 0 to disable |
Outside that band the constructor throws. At or below the repair ratio every nominated cell is critical and the valve overshoots the budget on every tick, for ever |
RepairAgingRatePerTick |
0.05 |
rank growth/tick | How fast a waiting candidate's rank climbs | dial ยท 0.01โ0.2 | 0 disables ageing, and a permanently outranked cell is then never repaired |
RepairQueueMaxCells |
4096 |
cells | Cap on queued repair candidates | dial ยท at or above the count of simultaneously degraded cells | RepairQueueEvicted growing while RepairQueueDepth sits at the cap โ candidates are being forgotten |
ClusterTargetPackingSlack |
1.5 |
multiplier | Multiplier on the per-cell packing bound that derives the target extent from live density, instead of taking ClusterTargetExtentRatio as a constant |
dial ยท 1.2โ2.0 | 0 pins the target at ClusterTargetExtentRatio and also disables the throttle's drift boost and the repair-first exclusion |
LeastEnlargementPlacement |
false |
flag | Place an arrival in the cluster whose bound grows least to admit it, rather than the first with a free slot | opt-in | Measured a wash where repair runs and a loss where it does not โ leave off unless you have disabled repair |
GrowthCapPlacement |
false |
flag | Open a fresh cluster when the best candidate would stretch past the target ร GrowthCapSlack. Implies least-enlargement ranking |
opt-in | The only write-path mechanism that creates tightness under motion (7โ16 points), paid for in +4โ29 % fence and โ4 to โ17 points of slot occupancy |
GrowthCapSlack |
1.25 |
multiplier | How far past the target a candidate may stretch before a fresh cluster is opened instead | dial ยท 1.1โ1.5 | Too low and nearly every arrival opens a cluster, exhausting MaxOpenClustersPerCell and scattering entities across half-empty clusters |
MaxOpenClustersPerCell |
4 |
clusters | Open (non-full) clusters the growth cap may hold per cell before it falls back to least enlargement | dial ยท 2โ8 | Constructor throws below 1. Too high and occupancy fragments without buying tightness |
BatchSpawnSortThreshold |
128 |
entities | A transaction spawning at least this many places them in per-cell Morton order, so a bulk load is born at the packing bound | dial; 0 disables |
A large load is born at the full extent of every cell it touches, handing the repair queue work that placement could have avoided |
RepairCooldownTicks |
50 |
ticks | How long a cell whose repair moved entities stays out of the repair queue. Nominations meanwhile are held, and the cell returns when the cooldown ends | dial ยท 50โ200 measured; 0 and 1 disable it |
0: under motion the budget goes to re-sorting the same cells every tick. On the SWG Tatooine workload the default took a median 6 %, 20 % and 37 % off the tick at 64ร, 16ร and 4ร population (three paired 20 s runs each), with query cost within 3 %; nothing in a mostly still world. Too long: QueryCandidatesPerHit climbs while RepairCellsCooling stays high |
QueryEfficiencyTolerance |
0.1 |
ratio | How much of ReclusterBudgetMs each archetype gets, from its range queries: next to nothing while their candidates per hit sit at the best they have shown, the whole budget once they are this far above it |
dial ยท 0.1 measured; 0 disables. The experiment behind it, with a 4ร ceiling and a 200-tick cooldown, landed 0.05โ0.3 within 3 % of each other |
0: the configured budget every tick, spent whether or not the queries need it. On the SWG Tatooine workload the default took a median 10 % off the tick at 4ร population, and 4.7 % and 1.9 % at 16ร and 64ร over 3 000 ticks, against 0 (three paired runs each, both with the cooldown), with queries testing 0.6โ1.4 % more entities per match and no slower per query. Too high: maintenance waits until queries have degraded a long way. It holds the best it has seen and does not seek better, so a world that starts loose stays near its start. An archetype queried only by nearest-neighbour, ray or frustum queries keeps the configured budget |
๐ Diagnosing a misconfigured world
Read GetSpatialTelemetry(archetypeId) or GetSpatialTelemetryTotal() from the tick loop after the fence. Per-tick members reset at every fence, so a background scrape samples one arbitrary tick. Ten counters reach OpenTelemetry as typhon.ecs.spatial.*, but four of the ones that matter most here โ RelocationsThrottled, RepairUnitsRefused, RepairQueueDepth and MeasuredNsPerEntity โ are API-only. See Reading Spatial Telemetry for the full counter set.
Migration storm. MigrationCount is a large fraction of the population every tick. Check HysteresisAbsorbedCount first: near zero means the margin is too narrow, so raise MigrationHysteresisRatio. If absorption is healthy the world is genuinely crossing cells and the fix is a larger cell. A high migration rate is not by itself a fault โ coherent swarms measured 7 090 migrations per tick at 32 000 entities and were the best-partitioned case in the whole matrix, at 63.0 % tightness.
Budget starvation, the budget buying zero units. Read these on ticks granted the whole budget: with QueryEfficiencyTolerance on, a ReclusterBudgetGrantedMs below ReclusterBudgetMs means the controller chose not to spend, and throttling is then the point. RelocationsThrottled sustained above zero at the whole grant means the budget is below the world's drift rate. RepairUnitsRefused above zero while RepairUnitCount stays at zero means it cannot afford even the smallest unit on offer. The arithmetic is unforgiving: at ~1 500 ns per entity a 1 ms budget buys ~667 entities, and a default unit of eight clusters at ~49 occupied slots is 392 โ so the planner admits one unit or none. Matrix B, 16 000 entities at 512 per cell, eight workers:
ReclusterBudgetMs |
relocations throttled/tick | tightness | active clusters |
|---|---|---|---|
| 0 | 0 | 102.2 % | 363 |
| 0.25 | 11 513 | 88.8 % | 363 |
| 1 | 11 044 | 92.6 % | 378 |
| 4 | 6 215 | 97.2 % | 381 |
| 8 | 0 | 80.9 % | 507 |
| 16 | 0 | 82.3 % | 518 |
Below the cliff, across a 16ร range of budget, tightness moves from 89 % to 97 % โ worse โ and query cost with it. Between 4 and 8 ms everything moves at once: throttling stops, and cluster count jumps from 381 to 507 as cells genuinely subdivide instead of holding one loose box each. Double the budget until RelocationsThrottled reaches zero on ticks granted the whole budget, then stop. (This table predates the controller: it was measured with a fixed budget.) Note the first row: 0 means "no throttle enforcement", not "no re-clustering", and it produced the worst tightness in the table.
Clusters that never repair. RepairQueueDepth grows while RepairUnitCount stays at zero. Rule out the budget, then three structural causes: the spatial field is not SpatialMode.Dynamic, so the planner exits early; nothing wrote to the archetype, and a still archetype is never planned; or ClusterRepairExtentRatio sits above the degradation this world actually reaches. Persistent RepairValveFires means degradation is outrunning the budget โ raise the budget rather than treating the valve as a steady state. Not this fault: RepairUnitCount and RepairQueueDepth both at zero with RepairCellsCooling above it. The degraded cells were repaired less than RepairCooldownTicks ago, and each returns to the queue when its cooldown ends.
Cells holding too many clusters. Divide ActiveClusterCount by GetSpatialGridOccupancy().OccupiedCellCount. A per-cell broadphase is a linear scan over the cell's clusters, and for ordinary densities that is the right structure: it beats a per-cell tree at every selectivity up to 512 clusters in a cell. Past that a per-cell tree can take over, but only if you opt in: Spatial.CellTreePromoteThreshold defaults to int.MaxValue, which keeps every cell on the linear scan whatever its density. Set a count (the calibrated value is around 1024) to let a dense cell promote โ it then also has to pass Spatial.CellTreePromoteTightness. The default is off because the tree speeds up the broadphase but the whole query gains at most ~1.1ร (the narrowphase dominates), while its upkeep runs in the fence's serial tail.
Promotion is gated on tightness as well as count. Spatial.CellTreePromoteTightness (0.10 by default) is the mean cluster extent, as a fraction of the cell edge, at or below which a cell's clusters are far enough apart for a tree to prune between them; a promoted half falls back at twice that value. The count alone was not enough: the sweep that chose 1 024 laid its clusters at 3.8 % of the cell, while a cell under motion runs at 63โ103 %, where every cluster is hit by every query and the tree returns all of them after paying for the traversal. Set it to 1 to restore count-only promotion.
That does not make a high mean per cell something to ignore. Promotion caps the query cost of a dense cell; it does not make the cell a good shape. If the mean is climbing, the grid is coarse for this world and CellSize is the fix โ a tree per cell is a fallback for the pockets a correctly-sized grid still leaves dense, not a substitute for sizing it. Lower the threshold if your queries are far more frequent than your movement, raise it if the reverse; int.MaxValue keeps every cell on the scan. DriftersUnplaced climbing is the same fault seen from the cluster side: drifters detected, every cluster in the cell already full.
An occupied cell holds a static and a dynamic half, each a linear cluster array. Promotion to a per-cell tree is implemented but off by default, which is why cell population is a cell-size decision.
A counter that looks alarming and is not. RelocationsSuperseded counts relocations dropped because a cell crossing already claimed the entity, which is the common case on a moving world.
Where the time goes, before you reach for more threads. The fence's Prep phase is 52 % of fence time at eight workers and scales only 1.28ร from one worker to eight, so the fence tops out near 2.1ร and workers past eight buy nothing. Migration is about 10 %. Cell size and budget move the fence; thread count does not.
๐ฆ Starting recipes
Dense 2D top-down (RTS, MOBA). SpatialGridConfig.Flat, cell about 4ร your dominant query radius, everything else default. Watch RelocationsThrottled over the first few hundred ticks and double ReclusterBudgetMs until it reads zero whenever ReclusterBudgetGrantedMs is the whole budget.
Sparse 3D volumetric (space sim, voxel world, ray-heavy queries). Full constructor, cell about 4ร the dominant query radius. Resist going finer for precision: the ray walk is cell-major and cost 58.4 ยตs at cell 63 against 9.6 ยตs at cell 159 in the synthetic sweep. Sparsity in the world is free, because empty cells are never materialised; sparsity in a cell is not.
Mostly static with a moving minority. Defaults throughout. This shape is cheap by construction: at 1 % of entities moving, Prep costs 0.40 ms and the whole fence 1.46 ms, against 9.82 ms and 16.97 ms with everything moving at 64 000 entities. Clusters do amplify the minority โ a quarter of entities moving dirties about nine tenths of the clusters, since a cluster is dirty if any one of its entities moved.
High-churn spawn and destroy. Cell about 4ร the dominant query radius, ReclusterBudgetMs at 2โ4 ms to start. Churn moves no entity but it frees slots, and a freed slot refills first-fit with no regard for position, so the drift path carries more load here than in a purely kinematic world. Watch DriftersUnplaced and RepairQueueEvicted. An aged world measured 30โ35 % faster than a fresh one at equal migration count and tightness, which is unexplained โ do not tune against a freshly spawned world and expect the numbers to hold.
Whatever the shape, the acceptance test is the same: run at the real population, read RelocationsThrottled and RepairUnitsRefused after a few hundred ticks on the ticks granted the whole budget, and do not ship until both are zero there in the steady state. On ticks the controller grants less, both may sit above zero: that is it choosing not to spend, and QueryCandidatesPerHit holding steady is the check that it chose well.
โ ๏ธ Guarantees & limits
- Set once, immutable after โ
ConfigureSpatialGridthrowsInvalidOperationExceptionif called twice or afterInitializeArchetypes. There is no runtime knob and no per-archetype override, so a tuning cycle is rebuild-and-rerun. - Only cell identity is persisted โ the bootstrap record stores world bounds, cell size and
MigrationHysteresisRatio, because those decide which cell a position maps to. The nine re-clustering knobs are deliberately absent, so a tool-only opener such as the Workbench ortyphon checkruns them at their defaults. An application callingConfigureSpatialGridalways wins; a tuned value is never silently replaced by a stored one. ReclusterBudgetMs = 0is overloaded โ it disables repair and disables relocation throttling, so every detected relocation then runs unbudgeted. It is not "switch the feature off".- The budget admits whole units, never a fraction of one โ a Morton sort cannot be halved, and a partly re-sorted cell is worse than an untouched one because the cost is paid and the benefit is not. A unit the remaining budget cannot finish is not begun.
- Throttled relocations are dropped, not deferred โ the tick's migration array is fixed when the plan closes, so a deferred tail would re-execute against slots its entities have already left. Refused repairs are remembered, in the persistent per-cell queue.
- The admission estimator runs ahead of reality โ the measured spend it learns from brackets the migrant loop only, and the secondary-index update alone is about 48 % of a migration, so the controller can over-admit. Treat
ReclusterBudgetUsedMsas a lower bound on what a tick actually spent. - Three settings are validated at construction โ
ClusterRepairExtentRatiomust be below 1.2;ClusterRepairCriticalExtentRatiomust be0or strictly inside(ClusterRepairExtentRatio, 1.2);CellSizemust be positive and the derived cell count must fit a 32-bit key. Each throws with its reason at config time rather than at the first fence. - A position outside the world is clamped, not rejected โ an out-of-world position folds into the nearest edge cell, so an escaping entity silently piles into the boundary cell. NaN and infinite coordinates throw.
- Cells are never destroyed โ occupied-cell count and resident bytes only grow. A world that sweeps a large region and leaves keeps every cell it ever touched until the cell state is reset.
- Promotion is observable only indirectly โ nothing reports how many cells are promoted or how dense the densest cell is, so the boundary has to be reasoned about from
ActiveClusterCountover occupied cells rather than watched. - The safe ranges in the table are guidance, not measurement โ only the cell-size occupancy basin and
ReclusterBudgetMshave measured curves behind them. The drift and repair ratios are marked provisional in the source, and matching cell size to query radius is unmeasured.
๐งช Tests
- ClusterRepairTests โ
MeasureRepairCostPerEntity(the ~1 300 ns fence-per-repaired-entity figure),ARepairPassTightensADegradedCell,ARepairIsNeverBegunWithoutTheBudgetToFinishIt,ARepairNarrowsTheZoneMapsOfTheCellItRepacks - ClusterThrottleBudgetTests โ
NoTickAdmitsMoreRelocationsThanTheBudgetPaysFor,EveryDetectedDrifterIsAccountedForExactlyOnce,AZeroBudgetKeepsRelocatingAndKeepsEveryQueueBounded(the0overload) - ClusterDriftDetectionTests โ
ATightClusterYieldsNoDrifters_EvenWhenEveryEntityMoves(the cluster gate that keeps detection cheap),SpreadClusters_DetectDriftersMatchingTheOracle - ClusterRepairQueueTests โ
AgeingCarriesEveryCandidateToTheHeadOfTheQueue,WithoutAgeingTheWorstCandidateStarvesEveryoneElse,ACriticalCellIsServicedEvenWhenTheBudgetCannotAffordIt,TheQueueStopsAtItsCapAndReportsTheEvictions - ClusterRepairConvergenceTests โ
ARepairedCellIsNotRepairedAgainWhileNothingMoves, which is why the budget is a ceiling on a rare event rather than a per-tick tax
๐ Related
- Source: src/Typhon.Engine/Spatial/public/SpatialGridConfig.cs โ every default, with the rationale for each in its XML doc
- Source: src/Typhon.Engine/Ecs/public/SpatialMigrationTelemetry.cs
- Harness: SpatialPartitionMatrix.cs โ the density, budget and worker sweeps quoted above
- Related catalog entry: What Spatial Costs You โ why these costs exist, and which structure serves your query
- Related catalog entry: Reading Spatial Telemetry โ every counter, paired with the parameter it tunes
- Related catalog entry: Spatial Grid Configuration & Tier Control โ the configuration call itself, and per-cell tier assignment
- Related catalog entry: Spatially-Coherent Entity Clustering โ the cluster-cell invariant these thresholds defend