From b00ec530860a96d55cbcf017b6cbe1a2039c315f Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 3 Sep 2026 19:40:35 +0200 Subject: [PATCH 1/2] Write live positions back into the spatial index cache during a query The annulus branch of spatialIndexBeginQuery already pays GetUnitX and GetUnitY for a unit whose cached position is stale by up to maxDisp. It used the live position for the distance test and threw it away, so every further query in the same frame read it again. Twelve synchronized range queries over the same crowd paid the two natives twelve times per boundary unit. The live position is now stored back, together with the current sweep tick, but only while the unit is still in its linked cell. Relinking mid-walk is not safe: the unit could move into a cell this walk has not reached yet and be visited and pushed a second time. The same-cell case covers nearly everything, since displacement is bounded well under one cell edge. --- wurst/util/UnitSpatialIndex.wurst | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/wurst/util/UnitSpatialIndex.wurst b/wurst/util/UnitSpatialIndex.wurst index aa38ab93..c478b49b 100644 --- a/wurst/util/UnitSpatialIndex.wurst +++ b/wurst/util/UnitSpatialIndex.wurst @@ -375,8 +375,19 @@ public function spatialIndexBeginQuery(vec2 center, real radius, boolean collisi // Only this annulus needs a live position read. let u = indexedUnit[idx] if u != null - let ux = u.getX() - center.x - let uy = u.getY() - center.y + let liveX = u.getX() + let liveY = u.getY() + // The live read was paid for anyway, so keep it: the next query in the same + // frame then decides this unit from the cache instead of reading it again. + // Only while the unit is still in its linked cell, though. Relinking here + // could move it into a cell this walk has not reached yet, and it would be + // visited and pushed a second time. + if cellOfUnit[idx] == cellAt(liveX, liveY) + 1 + lastX[idx] = liveX + lastY[idx] = liveY + lastSweepTick[idx] = sweepTick + let ux = liveX - center.x + let uy = liveY - center.y if ux * ux + uy * uy <= radiusSq and u.passesEnumerationState() pushMatch(u) idx = next From 40a9c2590c1fbfc3dfd96c1880321ede5241785c Mon Sep 17 00:00:00 2001 From: Frotty Date: Thu, 3 Sep 2026 21:57:40 +0200 Subject: [PATCH 2/2] Pad the annulus by each entry's own staleness, not the registry's worst The write-back stamps lastSweepTick, but nothing read it back: certainlyIn and certainlyOut come from the global maxDisp, which is the worst staleness of any entry in the registry. A stationary unit in the annulus therefore landed in the annulus again on the next query of the same center and radius, and paid GetUnitX and GetUnitY once more. The write-back saved nothing in precisely the case it was written for. The annulus branch now re-tests against the padding the entry itself needs, tickDisplacement times age plus one, before deciding to read. The extra tick covers the write and the query sitting at opposite ends of their own ticks, the same slack displacementBoundTicks already carries over a sweep cycle. Both directions stay conservative: within radius minus that padding the true position cannot be outside, beyond radius plus it cannot be inside. For an entry written back a moment earlier the age is zero, so the band is one tick of movement wide instead of a whole sweep cycle. With 500 units at 128 per tick the bound is five ticks, so the second query's annulus is a fifth as wide and nearly every boundary unit is decided from the cache. The refinement sits inside the annulus branch alone. The cell window and the outer bands stay on the global bound: the window has to be chosen before any entry is known, and the outer bands are the cheap reject that keeps entries out of this arithmetic entirely. --- wurst/util/UnitSpatialIndex.wurst | 61 +++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/wurst/util/UnitSpatialIndex.wurst b/wurst/util/UnitSpatialIndex.wurst index c478b49b..15e0235a 100644 --- a/wurst/util/UnitSpatialIndex.wurst +++ b/wurst/util/UnitSpatialIndex.wurst @@ -99,9 +99,15 @@ function noteRegistryChange() cycleMembershipStable = false displacementBoundTicks = max(displacementBoundTicks, requiredSweepCycleTicks()) -/** Worst-case distance a unit may have travelled since its cached position was written. */ +/** Worst-case distance a unit may travel during a single sweep tick. An entry cached `age` ticks + ago needs `age + 1` of these, because its write and the query may sit at opposite ends of their + own ticks; `displacementBoundTicks` carries the same extra tick over `requiredSweepCycleTicks`. */ +function tickDisplacement() returns real + return SPATIAL_INDEX_MAX_UNIT_SPEED * SPATIAL_INDEX_SWEEP_PERIOD + +/** Worst-case distance any indexed unit may have travelled since its cached position was written. */ function currentMaxDisplacement() returns real - return SPATIAL_INDEX_MAX_UNIT_SPEED * displacementBoundTicks * SPATIAL_INDEX_SWEEP_PERIOD + return tickDisplacement() * displacementBoundTicks // Membership @@ -331,7 +337,8 @@ public function spatialIndexBeginQuery(vec2 center, real radius, boolean collisi queryBase[queryDepth] = snapshotTop queryDepth++ - let maxDisp = currentMaxDisplacement() + let tickDisp = tickDisplacement() + let maxDisp = tickDisp * displacementBoundTicks // The engine test for the collision variant reaches an extra collision radius outward, so the // window has to widen by the largest collision size any unit can have. let reach = radius + (collisionSizeFiltering ? MAX_COLLISION_SIZE : 0.) + maxDisp @@ -372,24 +379,38 @@ public function spatialIndexBeginQuery(vec2 center, real radius, boolean collisi if u != null and u.passesEnumerationState() pushMatch(u) else if cachedDistSq <= certainlyOutSq - // Only this annulus needs a live position read. - let u = indexedUnit[idx] - if u != null - let liveX = u.getX() - let liveY = u.getY() - // The live read was paid for anyway, so keep it: the next query in the same - // frame then decides this unit from the cache instead of reading it again. - // Only while the unit is still in its linked cell, though. Relinking here - // could move it into a cell this walk has not reached yet, and it would be - // visited and pushed a second time. - if cellOfUnit[idx] == cellAt(liveX, liveY) + 1 - lastX[idx] = liveX - lastY[idx] = liveY - lastSweepTick[idx] = sweepTick - let ux = liveX - center.x - let uy = liveY - center.y - if ux * ux + uy * uy <= radiusSq and u.passesEnumerationState() + // The bands above pad by the worst staleness anywhere in the registry. This entry can + // be far fresher than that - the sweep, a move event, or an earlier query in the same + // frame may have written it - so re-test against the padding it actually needs. Inside + // the inner band it matches, outside the outer one it does not, and only what is left + // between them costs two natives. + let entryDisp = tickDisp * (sweepTick - lastSweepTick[idx] + 1) + let entryIn = radius - entryDisp + let entryOut = radius + entryDisp + if entryIn > 0. and cachedDistSq <= entryIn * entryIn + let u = indexedUnit[idx] + if u != null and u.passesEnumerationState() pushMatch(u) + else if cachedDistSq <= entryOut * entryOut + // Only this narrowed annulus needs a live position read. + let u = indexedUnit[idx] + if u != null + let liveX = u.getX() + let liveY = u.getY() + // The live read was paid for anyway, so keep it and stamp the tick: a later query + // in this frame then sees age 0, and its annulus is one tick of movement wide + // rather than a whole sweep cycle, so it decides this unit from the cache. + // Only while the unit is still in its linked cell, though. Relinking here could + // move it into a cell this walk has not reached yet, and it would be visited and + // pushed a second time. + if cellOfUnit[idx] == cellAt(liveX, liveY) + 1 + lastX[idx] = liveX + lastY[idx] = liveY + lastSweepTick[idx] = sweepTick + let ux = liveX - center.x + let uy = liveY - center.y + if ux * ux + uy * uy <= radiusSq and u.passesEnumerationState() + pushMatch(u) idx = next cx++ cy++