FOS UI Culling
Version 1.0.0 · requires FOS Essentials Core 1.0.0 or later
Disables world UIs when the local player is out of range, driven by a single central manager. Requires FOS Essentials Core 1.0.0.
- Create an empty GameObject and add FOS Essentials > UI Culling > UI Culling Manager.
- Click Scan the scene. Every World Space canvas gets a
FOSUICullingTarget, itsCanvasandGraphicRaycasterare wired up, and the manager’s list is filled. - Set Default distance. Override it per UI in the target component when needed.
Re-run the scan after adding new UIs. Existing targets are never overwritten, so any manual tuning survives.
Components
Section titled “Components”| Component | Role |
|---|---|
FOSUICullingManager |
One per scene. Owns the loop and drives everything. |
FOSUICullingTarget |
One per UI. Holds settings only — it does no per-frame work. |
Options
Section titled “Options”| Option | Effect |
|---|---|
| Default distance | Metres below which a UI is shown, unless the target overrides it. |
| Show distance (target) | Per-UI override. -1 means “inherit the manager’s default”. |
| Distance reference (target) | Point the distance is measured from. Empty means the Canvas itself. |
| Hysteresis | Extra metres before hiding again. Stops a UI flickering when the player stands on the limit. |
| Checks per frame | How many UIs are tested each frame. 4 by default. |
| Line of sight | Optional occlusion pass. Off by default. |
| Obstacle layers | Layers that count as blockers for the occlusion pass. |
How a UI decides its distance
Section titled “How a UI decides its distance”Two levels, resolved once when the manager starts:
- The manager’s Default distance applies to every UI.
- A target’s Show distance overrides it — unless it is left at
-1, which means inherit the manager’s value.
float distance = target.showDistance;if (distance < 0f) distance = defaultShowDistance;So a scene with Default distance = 15 and one target set to 8 shows that one UI at
8 metres and everything else at 15.
The target’s inspector spells out the result — “Effective distance: 15 m (inherited from
the manager)” — so you never have to remember what -1 means.
Because the value is read once at Start, changing a distance at runtime requires
calling RefreshTargets() on the manager.
Where the distance is measured from
Section titled “Where the distance is measured from”From the Canvas transform, which for a panel is its pivot, usually its centre. A wide panel therefore appears based on its centre rather than its nearest edge.
When that matters, drop any Transform into Distance reference on the target — an empty child placed at the edge, for example — and the measurement uses that instead.
Design notes
Section titled “Design notes”Why one central manager instead of a script per UI
Section titled “Why one central manager instead of a script per UI”Udon is an interpreted VM, roughly two orders of magnitude slower than C#. The dominant cost is the number of behaviours ticking, not the arithmetic they do. One behaviour comparing thirty squared distances is far cheaper than thirty behaviours doing anything at all.
FOSUICullingTarget therefore holds configuration and nothing else. The manager reads it
once in Start, caches it into flat arrays, and from then on only touches Unity
components directly. Thirty UIs cost exactly one active behaviour.
Why distance and not a raycast
Section titled “Why distance and not a raycast”A ray answers “is something in the way”, not “am I close”. It cannot replace the distance test — you would still need one first. Raycasting has a real use here, but as a second stage: see Line of sight below.
Why distance and not trigger colliders
Section titled “Why distance and not trigger colliders”Trigger volumes are genuinely cheap — event driven, broadphase handled in native code. They lose on three practical points:
- a collider has to be sized per UI, which defeats automatic setup;
OnPlayerTriggerEnteronly fires on the GameObject carrying the collider, forcing a behaviour per UI to receive it;- the player capsule has to actually overlap the volume. A UI high on a wall with a small trigger is missed, and a layer matrix mistake fails silently.
What actually gets disabled
Section titled “What actually gets disabled”The Canvas, the raycaster and the colliders — not SetActive(false).
- Disabling
Canvasstops rendering immediately while keeping the hierarchy alive. - Disabling the colliders is what stops VRChat’s pointer. This one is not optional: see below.
- Disabling the
GraphicRaycastertakes the UI out of Unity’s UI event resolution. SetActive(false)would force a full layout rebuild on the way back and re-run everyOnEnable, Udon included, which can reset the state of unrelated scripts.
Why the colliders matter more than the raycaster
Section titled “Why the colliders matter more than the raycaster”VRChat’s pointer does not go through the GraphicRaycaster. Its own raycaster does a
physics raycast, then looks for a VRC_UiShape on whatever collider it hit:
int hitCount = Physics.RaycastNonAlloc(...); // hits COLLIDERSVRC_UiShape shape = hitObject.GetComponent<VRC_UiShape>();So disabling the Canvas alone hides the UI while leaving its collider in place. The
pointer keeps snapping to it, and its buttons stay clickable — an invisible UI that
still reacts. That is a functional bug, not a cosmetic one.
The scan fills the Colliders list automatically from the canvas GameObject. If a UI has colliders elsewhere, add them by hand; the target inspector warns when it finds colliders that are not listed.
Colliderdoes not derive fromBehaviour— it derives straight fromComponentwith its ownenabledproperty. That is why colliders have their own list instead of sitting in Extra components.
Line of sight
Section titled “Line of sight”When enabled, a UI already within range is hidden if an obstacle blocks the view. One
Physics.Linecast per checked UI, cast from the player’s head — casting from the feet
would graze the floor and catch every bump.
It runs only for UIs that already passed the distance test, so a UI two hundred metres away never costs a ray. Still, leave it off unless the world genuinely needs it.
Staggering
Section titled “Staggering”Targets are checked round-robin, Checks per frame at a time. With 4 checks and 40 UIs the worst-case latency is 10 frames — under a fifth of a second, invisible for a UI appearing at a distance.
The first frame is an exception: everything is evaluated at once, otherwise distant UIs would stay lit until the cursor completed a full lap.
Visibility is only written when it changes, so a stable scene performs zero component writes.
Notes and limits
Section titled “Notes and limits”- One manager per scene. Several managers would fight over the same canvases; the inspector reports it as an error.
- Nested canvases are skipped. Disabling the root canvas already stops its children from rendering, so managing one target per level would only clutter the list.
- Screen Space canvases are skipped by default: they are not displayed to anyone in a VRChat world.
- UIs created at runtime are not picked up automatically. Fill
targetsand callRefreshTargets(). ShowAll()turns everything back on for debugging without removing the manager.
