PlayerPositionOverlay
Tsvrc.UI.PlayerPositionOverlay is a PlayerTracker that
periodically reports each tracked player's world position on a blinking show/hide cycle:
think a radar sweep or a minimap that pulses rather than staying static. Its generated
shadow is TsPlayerPositionOverlay. It's a pure backend: it never draws anything itself,
only reports positions to a pluggable PlayerMarkerRenderer.
Everything about rendering is local and unsynced: each client runs its own independent tick
loop and decides its own presentation.
Usage
overlay.Renderer = someRendererInstance;
overlay.StartOverlay(playerIds);
// ... AddTrackedPlayers/RemoveTrackedPlayers to change the set while running ...
overlay.StopOverlay();
Subscribe to OnOverlayUpdatedEvent to react after every cycle (both a show and a hide
count as a cycle, so this fires roughly twice per blink period). It also fires once more
when the overlay stops or completes, after the final clear, so a subscriber doesn't need a
separate hook just to know when markers are gone for good.
Two independent tick loops
- The remote tick (
RemoteUpdateInterval, default 0.3s) resolves every tracked player ID to a liveVRCPlayerApiand caches its position, heading, and identity. It never touches the renderer directly. - The blink tick (
MarkerOnDuration/MarkerOffDuration) alternates between presenting every cached position throughRenderer.OnMarkerVisibleand presenting nothing, always finishing with exactly oneRenderer.OnPresent()call per cycle regardless of how many markers were shown.
Splitting these two matters for cost: resolving player IDs to live references and reading
positions happens only once per RemoteUpdateInterval, not once per blink transition, and
Renderer.UsesHeading gates a GetRotation() call (a comparatively expensive
quaternion-to-Euler conversion) so a renderer that never uses heading never pays for it.
The local player is the one exception to "everything comes from the cache": it's re-read
fresh at blink-show time from Networking.LocalPlayer, rather than through the cached
remote-tick snapshot, since the local player's own position is essentially free to read and
benefits from being as current as possible.
Stale-tick discarding
Both tick loops use SendCustomEventDelayedSeconds, which (like elsewhere in the codebase)
can't be canceled once scheduled. Each maintains its own counter, incremented on every
schedule and decremented on every fire; a tick only actually runs its body if its counter has
returned to zero, meaning no newer tick of the same kind is already queued behind it. This is
what makes StartOverlay/StopOverlay/OnTrackingDeserialization restarting the loop
mid-flight safe: an old, now-superseded tick fires, sees a nonzero counter, and does
nothing.
Buffers are pre-sized to VRChat's own cap
Every internal buffer (cached positions, headings, player IDs, live player references) is
sized to 82, the worst-case absolute maximum instance
size (80 is VRChat's
configurable hard cap; the world author and instance creator can each still join on top of
that), allocated once, lazily, on first use, and reused every tick rather than reallocated.
_ResolveTrackedPlayers is zero-allocation
by design: matching happens by comparing each tracked ID's parsed numeric suffix directly
against VRCPlayerApi.playerId, never by calling
TsPlayer.GetPlayerID to build a comparison string per player. A
malformed tracked ID (no parseable numeric suffix) is skipped rather than causing an error.