PlayerPositionOverlay
Tsvrc.UI.PlayerPositionOverlay is a PlayerTracker that
periodically reports the world position of each tracked player 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 position of the local player costs almost nothing to read and
benefits from being as current as possible.
Stale-tick discarding
Both tick loops use SendCustomEventDelayedSeconds, which 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 the VRChat 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 the VRChat
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 allocates nothing: it compares the number after the last # of each
tracked ID with VRCPlayerApi.playerId, without building an ID string per player. A malformed
tracked ID, with no number after a #, is skipped without an error.