Class LagCompensatedQuery
Spatial queries resolved against where characters were when the caster's client saw them.
public static class LagCompensatedQuery
- Inheritance
-
LagCompensatedQuery
- Inherited Members
Remarks
Every hit-resolving query in the ability system should route through here rather than calling UnityEngine.PhysicsScene directly. Both entry points execute the query eagerly inside the rewind scope and return a count, so no caller can accidentally hold characters displaced while it enumerates results — the failure that would apply damage and run ECA actions against a world several hundred milliseconds stale.
Anything that decides WHICH hits survive belongs inside the scope too, which is why
OverlapSphereNearest(EventData, GameObject, Vector3, float, LayerMask, int, bool, List<CompensatedHit>) ranks, deduplicates and caps in there rather than handing
back a raw buffer. A caller that selects its candidates from the rewound world and then ranks
them by live positions is reading two different worlds; at 300 ms that is metres, and it
picks a different victim than the one the caster was looking at. The selectors solve the same
problem with TargetSelector.GatherRewound.
When there is nothing to compensate — a server-driven caster, a client whose tick bookkeeping is not yet established, or a scene with no recorded history — the query runs uncompensated. That is the behaviour these call sites had before, so an unregistered character degrades accuracy instead of dropping the hit.
Methods
OverlapSphereNearest(EventData, GameObject, Vector3, float, LayerMask, int, bool, List<CompensatedHit>)
The characters nearest center, resolved against the caster's view of the
world, deduplicated per character and capped at maxHits.
public static int OverlapSphereNearest(EventData eventData, GameObject context, Vector3 center, float radius, LayerMask mask, int maxHits, bool charactersOnly, List<LagCompensatedQuery.CompensatedHit> results)
Parameters
eventDataEventDataEvent whose
Initiatoris the caster whose view is reconstructed.contextGameObjectObject whose scene is queried and rewound.
centerVector3Centre of the query, and the point distances are measured from.
radiusfloatQuery radius.
maskLayerMaskLayers to query.
maxHitsintMaximum distinct bodies to keep. Zero or less means no cap.
charactersOnlyboolDrop hits that resolve to no ICharacter before they consume a slot of the cap. True for anything that resolves damage:
TargetLayerMaskdefaults to every layer, so a blast let off beside terrain would otherwise spend its whole cap on scenery and hit nobody.resultsList<LagCompensatedQuery.CompensatedHit>Receives the hits, nearest first. Cleared first.
Returns
- int
The number of hits written to
results.
Remarks
Everything that decides the answer happens inside one rewind scope. The query, the
distance measurement and the ranking all read the same displaced world. Splitting them was
the trap this method exists to close: the previous shape returned a raw buffer with the
scope already shut, so the caller selected its candidates from the world the caster saw and
then ranked them by where those characters are NOW — two different worlds, differing by the
target's speed times the caster's latency, which at 300 ms is metres. It is the same
failure TargetSelector.GatherRewound was built for, and the reason
RewoundOverlapSphere was deleted rather than left available.
Ordered by distance, not by identity. A cap is only meaningful over an ordered set, and the order a blast radius means is "nearest first". Identity order is equally reproducible and gameplay nonsense: truncating it keeps the lowest ObjectIds, so a three-target AoE in a crowd hit the same three earliest-spawned characters every time and never the ones standing on the impact point. Ties fall through to CompareStable(TargetRank, TargetRank), which every peer computes identically.
One entry per character. Keyed through ResolveHitKey(Collider, out ICharacter), so a body with two hitboxes costs one hit and one slot of the cap — otherwise the same ability hits a different NUMBER of characters depending on how its targets happen to be rigged, and a character rigged with its collider on a child is dropped entirely. The nearest collider on a body is the one kept, which falls out of walking the distance order.
The cap is applied by early exit, not by truncation. Walking the sorted ranks and
stopping at maxHits distinct characters means the per-candidate component
resolution runs for roughly the cap rather than for the whole crowd. That is where the
saving in an area query is — not in narrowing the physics query, which is one broadphase
traversal either way.
RaycastNearest(EventData, GameObject, Vector3, Vector3, float, LayerMask, int, bool, List<CompensatedHit>, Transform)
The bodies a ray passes through, in order along it, deduplicated per character and capped
at maxHits.
public static int RaycastNearest(EventData eventData, GameObject context, Vector3 origin, Vector3 direction, float distance, LayerMask mask, int maxHits, bool charactersOnly, List<LagCompensatedQuery.CompensatedHit> results, Transform ignoreRoot = null)
Parameters
eventDataEventDataEvent whose
Initiatoris the caster whose view is reconstructed.contextGameObjectObject whose scene is queried and rewound.
originVector3Where the ray starts.
directionVector3Ray direction. Normalised here; a degenerate vector returns nothing.
distancefloatRay length.
maskLayerMaskLayers to query.
maxHitsintMaximum distinct bodies to keep. Zero or less means no cap.
charactersOnlyboolDrop hits that resolve to no ICharacter before they consume a slot of the cap. See the note on OverlapSphereNearest(EventData, GameObject, Vector3, float, LayerMask, int, bool, List<CompensatedHit>); for a ray this also means scenery does not stop a shot, so set it false when a wall should block one.
resultsList<LagCompensatedQuery.CompensatedHit>Receives the hits, nearest first. Cleared first.
ignoreRootTransformOptional transform whose own colliders and children are skipped BEFORE the cap is charged — for a projectile excluding itself, whose collider sits on the ray origin.
Returns
- int
The number of hits written to
results.
Remarks
The hitscan path, and the one that needs compensation most. A ray is infinitely thin, so unlike a volume it has no tolerance to absorb the staleness of a live-position query: the difference between a hit and a miss is the target's own width. At 300 ms a running peer is metres from where the shooter saw it, which is simply a miss.
Ordered along the ray. Distance leads and identity only breaks exact ties — a line is a sequence and every effect authored on one (pierce, falloff, "the first thing you hit") reads it that way. Unity's non-allocating overload promises no order at all, so without this the pierce cap chose its victims arbitrarily. This is the one query shape where the boundary CAN pick the order, because a ray admits only one reading; the overlap above deliberately does not, which is why its identity sort was removed.
One entry per character, so a body with two hitboxes costs one pierce rather than
two, and the nearest of its colliders is the one reported — which falls out of walking the
ray order. A beam re-queried every tick therefore hits each target once per tick, which is
what a beam means; the per-lifetime hit set that stops a projectile draining its hit count
into one victim belongs to AbilityObject and is deliberately not duplicated here.
TryResolveRewind(EventData, out ICharacter, out RewindTarget)
Resolves the caster and the tick its client was rendering peers at.
public static bool TryResolveRewind(EventData eventData, out ICharacter caster, out RewindTarget target)
Parameters
eventDataEventDatacasterICharactertargetRewindTarget