HeadClipGuard
Tsvrc.Player.HeadClipGuard stops a VR player's head from clipping through solid geometry
by teleporting them back out along the axis their body most likely entered from. Its
generated shadow is TsHeadClipGuard. It's a local-only, per-client behaviour (no network
sync) that runs every frame in
PostLateUpdate, fired
"near the end of the frame after IK has been calculated," so tracking and IK have
already settled by the time it runs. See Stop VR players clipping through
walls for a task-oriented walkthrough.
Getting an instance
HeadClipGuard ships as one of TsVRC's own default factory
entries (TsBuiltinConfig).
- Default:
_ts.CreateHeadClipGuard(parent)just works, nothing to register in Configure first. - Manual: skip the factory entirely.
AddComponentit (or drag the shippedHeadClipGuardprefab into your scene) and callBegindirectly, noTsConstructcall needed first.Beginitself re-caches the localVRCPlayerApireference if it's still unset, the same referenceTsStartwould otherwise cache, so a never-constructed instance still guards correctly.
Usage
guard.Begin(solidColliders, count);
// ... later, when the guarded region no longer applies:
guard.End();
Begin bakes each BoxCollider's world-space center, rotation, and half-extents once
(a one-time cost, not repeated per frame) and starts the guard. Null entries in the
array are skipped rather than rejected. End fully resets all runtime state and is safe to
call before a later Begin on the same instance, so one HeadClipGuard can be reused
across multiple guarded sessions rather than requiring a fresh instance each time.
How it decides which way to push
Detection happens in world space against oriented bounding boxes (not axis-aligned: each
collider's own rotation is respected), each expanded by a configurable _margin so the head
stays a small distance from the wall surface instead of resting exactly on it. When the head
is inside a violated box, the push direction is chosen by which axis the player's body
(the VRChat capsule position, not the head) most exceeds the box's raw extents on. The
assumption is that whichever direction the body is pushing from is the direction the
head entered through, and the correct way to eject it. If the body is also fully inside the
same box (both head and body clipped, a rarer case), it falls back to pushing the head
toward its own single nearest face instead, since there's no body-direction signal to use.
Multiple simultaneous violations (a corner, where two OBBs overlap) accumulate their push
vectors. If two pushes happen to cancel out exactly (the head trapped symmetrically
between two opposing walls), the guard falls back to the last known safe capsule
position instead of teleporting to a net-zero displacement.
Performance design
Every frame potentially scanning every collider in a scene as a full OBB test would be wasteful, so the guard layers three progressively cheaper checks:
- Movement gate — if the head hasn't moved more than a small threshold (default 5mm, comfortably above VR tracking jitter) on any axis since the last processed frame, and no violation was active, the entire frame's work is skipped.
- Rolling batch scan — instead of testing every collider's AABB against the head every
frame, only a rotating slice (
1/_batchFramesof the full list) is re-evaluated each frame, added to or removed from a compact candidate list in O(1) via swap-with-last. A collider only needs re-checking on the cadence set by_batchFrames, expanded by_batchScanExpansionto cover how far the head could plausibly move between two scans of the same collider. - Tight AABB pre-reject, then full OBB test — only candidates surviving the batch scan get a cheap axis-aligned bounds check, and only those get the full oriented-box test.
Configuration invariants
_batchScanExpansionmust cover the maximum realistic head speed times the batch period: the field's own tooltip states the relationship asbatchScanExpansion >= maxSpeed * batchFrames / minFPS. Set too small, a fast-moving head could pass a collider's stale batch AABB and get a false negative between scans._batchFrames < 1is silently clamped to1rather than causing a division error.