Occlusion (distance-based renderer culling)¶
Stationeers culls Things visually based on distance from the player camera. The system is purely a renderer-visibility mechanism for static Things; physics colliders remain active at any distance for Structure-derived classes. Only DynamicThing instances additionally have their colliders toggled, and that is keyed off the same render-state transition.
The distinction matters whenever a piece of code relies on physics raycasts hitting distant objects. A Physics.Raycast from the player toward a far-away PowerReceiver will register the hit even though that receiver's mesh is no longer drawn, because the receiver is a Structure and Structure's OnStopRender does not touch its colliders.
Core flow¶
OcclusionManager.CheckAllOcclusion (Assembly-CSharp OcclusionManager):
public static void CheckAllOcclusion()
{
GridController.AllStructuresPool.ForEach(SetOcclusionAction);
AllDynamicThings.ForEach(SetOcclusionAction);
}
Both static structures (in GridController.AllStructuresPool) and dynamic things (in AllDynamicThings) are iterated; Entity instances are short-circuited inside SetOcclusion itself.
Thing.SetOcclusion (base):
public virtual void SetOcclusion()
{
if (_renderChangeScheduled || IsBeingDestroyed || (object)InventoryManager.Parent == null) return;
float renderMaxDistanceSquared = GetRenderMaxDistanceSquared();
CurrentCameraDistanceSquared = RootParent.Position.DistanceSquared(InventoryManager.WorldPosition);
bool flag = CurrentCameraDistanceSquared < renderMaxDistanceSquared;
if (flag) CheckShadowOverride();
if (this is Entity) return;
if (!flag)
{
if (!IsOccluded && !IsBeingDestroyed)
{
_renderChangeScheduled = true;
RenderChange(setRenderer: false, this).Forget();
}
}
else if (IsOccluded && !IsBeingDestroyed)
{
_renderChangeScheduled = true;
RenderChange(setRenderer: true, this).Forget();
}
}
Thing.IsOccluded (virtual property setter):
public virtual bool IsOccluded
{
get => _isOccluded;
set
{
_isOccluded = value;
if (!_isOccluded) OnStartRender();
else OnStopRender();
}
}
Thing.OnStopRender (base — used by every Structure subclass that does not override):
public virtual void OnStopRender()
{
if (this is IBatchRendered batchRendered) BatchRenderer.Remove(batchRendered);
else
{
foreach (ThingRenderer renderer in Renderers)
if (renderer != null && renderer.HasRenderer())
renderer.Visible = false;
}
foreach (ThingLight light in Lights) light.Refresh();
}
Note what is absent: no Collider.enabled = false, no gameObject.SetActive(false), no removal from physics scene. The Things' Collider components stay enabled and continue to participate in raycasts.
Render distance¶
Thing.GetRenderMaxDistanceSquared (base):
protected virtual float GetRenderMaxDistanceSquared()
{
return Mathf.Pow(100f * OcclusionManager.RenderDistanceMultiplier, 2f);
}
Default base distance is 100 m along each axis squared. Subclasses override this with values found in vanilla code at 15 m, 20 m, 25 m, 30 m, 40 m, 60 m, 100 m, and 200 m. The multiplier is taken from the user's video setting:
Settings.CurrentData.RenderDistance |
RenderDistanceMultiplier |
|---|---|
Lowest |
0.8 |
Low |
1.0 |
Default (fallback) |
1.0 |
Medium |
1.5 |
High |
2.0 |
Extreme |
2.5 |
OcclusionManager.UpdateRenderDistanceMultiplier reads the setting via Enum.TryParse<RenderDistance>; an unrecognised string falls through the _ => 1f default.
So a Thing whose subclass does not override GetRenderMaxDistanceSquared has its renderer hidden when the camera is more than 100 × multiplier metres from RootParent.Position. At Extreme that is 250 m; at Lowest that is 80 m.
Colliders are the exception, not the rule¶
The only class that disables colliders on render-state transition is DynamicThing. Its overrides in Assembly-CSharp:
public override void OnStartRender()
{
base.OnStartRender();
PhysicsOnRender(true);
}
public override void OnStopRender()
{
base.OnStopRender();
PhysicsOnRender(false);
}
public void PhysicsOnRender(bool isRendered)
{
if (isRendered) SetPhysics(on: true);
else SetPhysics(on: false);
}
public void SetColliders(bool on, bool always = false)
{
foreach (Collider collider in _colliders)
if (always || !collider.gameObject.CompareTag("CollidersAlwaysVisible"))
collider.enabled = on;
foreach (Collider trigger in _triggers) ...
}
SetColliders is declared only on DynamicThing (no other public void SetColliders(bool on, bool always = false) definition exists in Assembly-CSharp). Static Structure subclasses inherit OnStopRender directly from Thing (or override it for their own render-only side-effects, like a console screen toggling the screen mesh on / off) and never reach SetColliders through the occlusion path.
Implication: a raycast against a PowerReceiver.DishTarget, a Wall, an Appliance mounted on a frame, or any other static structure will hit at any distance the player's chunk is loaded at, regardless of the local render-distance setting. The visible mesh disappears at the configured threshold; the collider does not.
Verification history¶
- 2026-04-29: page created during a diagnostic for
PowerTransmitterPlus's long-distance auto-aim. The hypothesis was that distantPowerReceivercolliders might be deactivated by an occlusion / streaming mechanism, causingPowerTransmitter.TryContactReceiver'sPhysics.Raycastto return no hit. ReadingOcclusionManager.CheckAllOcclusion,Thing.SetOcclusion,Thing.OnStopRender, andDynamicThing.PhysicsOnRender/SetCollidersshowed the hypothesis was wrong: onlyDynamicThingtoggles colliders on render-state transitions, andPowerReceiver's inheritance chain (Structure → SmallGrid → Device → ElectricalInputOutput → WirelessPower → PowerReceiver) never enters that path. Verified by direct decompile read and bygrepconfirmingSetCollidersis declared only onDynamicThing.
Open questions¶
- Do interior chunks ever fully unload (deactivate
gameObjectrather than toggle renderer / collider) at very long range?gameObject.SetActive(false)calls on Things were not explicitly searched for in this pass; the occlusion path verified above does not call it, but other systems (chunk streaming, save / load, scene unload) might. A long-distance test that places aPowerReceiverdeliberately far from spawn and observes whetherThing._colliderLookup.TryGetValue(hit.collider, ...)succeeds when the player is close to the transmitter would confirm this end-to-end.