Trigger Volumes (Enter / Leave / Stay)
Per-region occupant diffing against the R-Tree(s) emits Enter/Leave/Stay events at a configurable per-region frequency.
Status: ✅ Implemented · Visibility: Internal · Category: Spatial
🎯 What it solves
Games need transition events — the moment an entity enters or leaves a region — not just a point-in-time occupancy snapshot: capture zones, stealth detection, environmental hazards, loading boundaries all key off the edge, not the level. Polling every region against every nearby entity each tick to derive that edge by hand is wasteful and easy to get wrong (duplicate or missed events at the boundary). Trigger Volumes turn a region into a maintained query that does the diffing for the caller and only at the cadence the caller asks for.
⚙️ How it works (in brief)
A trigger region is a lightweight config slot (bounds, category mask, evaluation frequency, target-tree mode) — not an ECS entity or component. Each evaluation queries the spatial tree(s) for the region's AABB and category mask, builds a bitmap of current occupants, and XORs it against the bitmap captured at the region's previous evaluation: set-but-not-previously-set bits are Enter, previously-set-but-not-set bits are Leave, the rest are Stay (count only, not materialized). Evaluation is frequency-gated per region — a region is skipped unless currentTick - lastEvaluatedTick >= EvaluationFrequency — so cheap ambient zones and expensive per-tick damage fields can share the same system at different cadences. TargetTreeMode controls which tree(s) are queried: DynamicOnly (default) never touches the static tree; Both/StaticOnly query the static tree once and cache the result, only re-querying it when the static tree mutates or the region's bounds/category change. Spatial-grid cluster archetypes registered on the same index are diffed too, tracked by EntityId rather than by R-Tree chunk id.
💻 Usage
Trigger regions are reached through the per-table spatial index state, which is engine-internal today (see Guarantees below) — this is the actual API, shown as the test suite exercises it:
var table = dbe.GetComponentTable<Position>();
var triggers = table.SpatialIndex.GetOrCreateTriggerSystem(table);
double[] zoneBounds = { -10, -10, -10, 50, 50, 50 }; // minX,minY,minZ,maxX,maxY,maxZ
var zone = triggers.CreateRegion(zoneBounds, categoryMask: (uint)Faction.Player,
evaluationFrequency: 5, targetTree: TargetTreeMode.DynamicOnly);
// once per tick, per region:
SpatialTriggerResult r = triggers.EvaluateRegion(zone, currentTick);
if (r.WasEvaluated)
{
foreach (long entityId in r.Entered) { /* fire OnEnter */ }
foreach (long entityId in r.Left) { /* fire OnLeave */ }
// r.StayCount — occupants unchanged since the previous evaluation
}
triggers.UpdateRegionBounds(zone, newBounds); // invalidates static-tree cache
triggers.UpdateRegionCategoryMask(zone, newMask); // invalidates static-tree cache
triggers.DestroyRegion(zone);
TargetTreeMode |
Default | Effect |
|---|---|---|
DynamicOnly |
yes | Queries the dynamic tree only; static tree never touched |
Both |
— | Dynamic tree every evaluation + cached static-tree result, merged |
StaticOnly |
— | Static tree only, fully cached after the first evaluation |
⚠️ Guarantees & limits
- Engine-internal only today —
SpatialTriggerSystem,ComponentTable.SpatialIndex, andGetOrCreateTriggerSystemare allinternal; there is no publicDatabaseEngine/ECS entry point yet, so application code outside the engine assembly cannot create or evaluate trigger regions.SpatialRegionHandle,SpatialTriggerResult, andTargetTreeModeare public types, ready for a future public wrapper. - Event completeness (rule TV-01) — every outside→inside transition between two evaluations produces exactly one Enter, every inside→outside produces exactly one Leave; an entity inside at both evaluations produces neither (it's counted in
StayCountonly). - Frequency contract (rule TV-02) —
EvaluationFrequency = Nguarantees at most one evaluation every N ticks. A skipped evaluation returnsSpatialTriggerResult.Skipped(WasEvaluated == false;Entered/Left/StayCountare not meaningful). A freshly created region always evaluates on its first call regardless of N. - Result spans are transient —
Entered/LeftareReadOnlySpan<long>over pooled buffers, valid only until the nextEvaluateRegioncall on that system; copy entity ids out before yielding control if they need to outlive the call. - Static-tree caching, not re-querying —
Both/StaticOnlyregions re-run the static query only when the static tree's mutation version changes or the region's bounds/category mask are updated; steady-state evaluations reuse the cached bitmap. - Cluster archetypes are included automatically — if the table's spatial index has spatial-grid cluster archetypes registered, their entities are queried and diffed in the same
EvaluateRegioncall, tracked by EntityId in a separate set from the R-Tree bitmap. - Destroyed handles are rejected —
DestroyRegionbumps the slot's generation; any further use of the old handle (including a double-destroy) throwsArgumentException. - Designed for ~800ns per region at ~50 occupants (AABB query + bitmap diff); cost tracks occupant count and tree depth, and per-tick total cost is bounded by staggering
EvaluationFrequencyacross regions rather than evaluating all regions every tick.
🧪 Tests
- SpatialTriggerTests — region lifecycle + generation-checked handle reuse,
Enter/Leave/StayInsideevent completeness,EvalFrequency_SkipsTicks
🔗 Related
- Source: src/Typhon.Engine/Spatial/internals/SpatialTriggerSystem.cs
- Source: src/Typhon.Engine/Spatial/public/SpatialRegionHandle.cs (
TargetTreeMode) - Source: src/Typhon.Engine/Spatial/public/SpatialTriggerResult.cs
- Related catalog entry: Spatial Category Filtering, Spatial R-Tree Index