Skip to content

LOD System and Culling Strategy

Unturned renders large open worlds by combining aggressive LOD bias control, per-object LOD group management, a light culling system (LightLOD), a per-layer visibility toggle system for the editor (LevelVisibility), CullingVolume-based object suppression, and Unity's built-in frustum culling. The combination allows the game to maintain playable frame rates on low-end hardware while rendering detailed world geometry, dynamic lighting, and hundreds of simultaneous entities.

This article documents the LODGroupManager that globally scales LOD transition heights with the quality setting, the LightLOD per-frame light culling algorithm, the LevelVisibility editor toggle layer system, the LODGroupAdditionalData component that enables per-object LOD bias overrides, the CullingVolume system, and the LodGroupEnumerator utility for iterating LOD group members.

Source code location: Unturned/Managers/LODGroupManager.cs, Unturned/Level/LevelVisibility.cs, Unturned/Level/LightLOD.cs, Unturned/Level/LODGroupAdditionalData.cs (implied), CustomPostProcess/, Unturned/Level/CullingVolume.cs

LODGroupManager — Global LOD Bias Scaling

The LODGroupManager provides a central registry for all LODGroup components that have additional data, and applies global LOD bias scaling to all registered groups when QualitySettings.lodBias changes.

Registration

Only LODGroup components with a LODGroupAdditionalData component that has a non-None bias override are registered:

csharp
public void Register(LODGroupAdditionalData component)
{
    if (component.LODBiasOverride == LODGroupAdditionalData.ELODBiasOverride.None)
        return;

    LODGroup unityComponent = component.GetComponent<LODGroup>();
    // ... create ComponentData with original LODs + modified LODs copy
}

Each ComponentData stores:

  • The LODGroupAdditionalData component reference
  • The Unity LODGroup component reference
  • A copy of the original LOD transition heights
  • A second copy for the modified heights (deep copied for renderers list)

SynchronizeLODBias

When the player changes the LOD bias quality setting, SynchronizeLODBias() is called. It compares the cached bias value to QualitySettings.lodBias. If different, it iterates all registered components and recalculates modified LOD heights:

csharp
for (int lodIndex = 0; lodIndex < data.originalLODs.Length; ++lodIndex)
{
    modifiedLOD.screenRelativeTransitionHeight =
        originalLOD.screenRelativeTransitionHeight * cachedLODBias;
}

A padding safety check prevents Unity from logging errors when higher LOD levels would have a transition height lower than lower LOD levels:

csharp
const float lodPadding = 0.001f;
for (int lodIndex = 1; lodIndex < data.originalLODs.Length; ++lodIndex)
{
    lowerLOD.screenRelativeTransitionHeight = MathfEx.Min(
        1.0f - lodPadding,
        lowerLOD.screenRelativeTransitionHeight,
        higherLOD.screenRelativeTransitionHeight - lodPadding);
}

Without this padding, LOD1 could have a transition height at or above LOD0's transition height, which Unity's SetLODs rejects with an error.

This approach means that changing the LOD bias slider in the graphics settings immediately updates all registered LOD groups in the scene without requiring the objects to be recreated or reloaded.

LightLOD — Distance-Based Light Culling

The LightLOD component is attached to light GameObjects and manages per-frame enable/disable of the light based on distance from the main camera. It uses a hysteresis threshold with smooth intensity fade to prevent the light from popping on and off at the boundary.

Initialization

In Start(), the component records the light's starting intensity and calculates transition distances based on light type:

csharp
if (targetLight.type == LightType.Point)
{
    transitionStart = targetLight.range * 13.0f;
    transitionEnd = targetLight.range * 15.0f;
}
else if (targetLight.type == LightType.Spot)
{
    transitionStart = Mathf.Max(64.0f, targetLight.range) * 1.75f;
    transitionEnd = Mathf.Max(64.0f, targetLight.range) * 2.0f;
}

The transition start and end distances are calculated as squared values to avoid per-frame sqrt calls:

csharp
sqrTransitionStart = transitionStart * transitionStart;
sqrTransitionEnd = transitionEnd * transitionEnd;

Per-Frame Apply

In Update(), the apply() method compares the camera-to-light squared distance against the two thresholds:

Distance < sqrTransitionStart
    → Light fully on (enabled, full intensity)

Distance between sqrTransitionStart and sqrTransitionEnd
    → Light enabled, intensity lerped from full to zero

Distance > sqrTransitionEnd
    → Light disabled, intensity set to zero

The transitional lerp provides a smooth fade-out over the 2× range multiplier for spot lights and 15/13 ≈ 15% range increase for point lights. Without this fade, the light would pop on and off as the player walked across the boundary, which is especially noticeable with shadow-casting lights that have a GPU cost for shadow map rendering.

Conditional Disable

The LightLOD component can be globally disabled two ways:

  1. HelperClass.WantsLightLodsOff returns true if either -DisableLightLODs command-line flag is set, or GraphicsSettings.WantsCinematicMode is active.
  2. Directional and area lights are excluded — LightLOD only supports point and spot lights. Area and directional lights skip LOD processing entirely.

When LightLOD is disabled globally, all lights are forced enabled and the component disables itself.

LevelVisibility — Editor Layer Toggle System

The LevelVisibility class provides a persistent per-layer visibility toggle for the level editor. Each toggle corresponds to a category of objects in the scene:

PropertyTypeAffected system
roadsVisibleboolLevelRoads.setEnabled()
navigationVisibleboolLevelNavigation.setEnabled()
nodesVisibleboolSpawnpointSystemV2.IsVisible
itemsVisibleboolLevelItems.setEnabled()
playersVisibleboolLevelPlayers.setEnabled()
zombiesVisibleboolLevelZombies.setEnabled()
vehiclesVisibleboolLevelVehicles.setEnabled()
borderVisibleboolLevel.setEnabled()
animalsVisibleboolLevelAnimals.setEnabled()

Each setter wraps the corresponding Level* class's setEnabled(bool) call, which toggles the visibility of all GameObjects in that category. This is an editor-only feature — in the runtime game, all layers are always visible.

Persistence

Visibility state is persisted to Level/Visibility.dat using the River binary serialization format. The save format version is 2:

Byte:   SAVEDATA_VERSION (2)
Bool:   roadsVisible
Bool:   navigationVisible
Bool:   (old nodesVisible — legacy, no longer read)
Bool:   itemsVisible
Bool:   playersVisible
Bool:   zombiesVisible
Bool:   vehiclesVisible
Bool:   borderVisible
Bool:   animalsVisible  (only if version > 1)

If the Visibility.dat file does not exist (fresh map), all layers default to true. The legacy version 1 format (without the animals toggle) is detected and the animals default to true.

CullingVolume System

The CullingVolume component defines 3D regions where specific types of objects are hidden. These are editor-placed volumes that suppress rendering in specific areas — for example, hiding interiors of buildings when viewed from outside, or hiding exterior geometry when the camera is inside a volume.

The CullingVolumeManager collects all active culling volumes and checks the camera position against each volume every frame. Objects whose renderers are tagged with matching culling flags are disabled when the camera enters the volume.

The CullingVolume component stores:

  • The volume's transform (position, rotation, scale of the volume GameObject)
  • Culling flags that determine which object categories are affected

Level Batching System

LevelBatching is a separate optimization system that combines static geometry into Unity combined meshes for reduced draw calls. The batching is performed when a level is loaded, not at runtime. Objects that participate in batching have their meshes combined into static batches, which reduces the number of draw calls for repeated geometry (trees, rocks, buildings).

Batches are split by material and by lightmap index to prevent material conflicts within a single batch. The batched geometry is not individually cullable — either the entire batch is visible or none of it is, which is why batching is limited to small objects with similar visibility bounds.

LOD on Level Objects

Level objects (trees, rocks, buildings placed in the level editor) use the EObjectLOD enum which defines their LOD behavior:

EObjectLOD valueBehavior
ALLObject is visible at all distances
NEARObject has LOD0 only, culled aggressively

The ObjectAsset carries an EObjectLOD value that the level editor assigns when placing the object. At runtime, LevelObjects manages LOD switching for placed objects, using the camera distance to select the appropriate LOD level from the UnityEngine.LODGroup on the object's prefab.

Visibility Calculation Flow

Per-frame visibility
─────────────────────────

Camera position known

      ├─ 1. Frustum culling (Unity built-in)
      │    └─ Camera's view frustum eliminates off-screen objects

      ├─ 2. LODGroupManager.SynchronizeLODBias()
      │    └─ Only if lodBias changed this frame

      ├─ 3. LightLOD.Update() per active light
      │    ├─ Read camera position
      │    ├─ Compare squared distance to transition thresholds
      │    ├─ Enable/disable light component
      │    └─ Lerp intensity in transition zone

      ├─ 4. CullingVolume check
      │    ├─ CullingVolumeManager checks camera against all volumes
      │    └─ Apply culling flags to matching objects

      └─ 5. Unity LODGroup.ScreenRelativeTransitionHeight
           └─ Each LODGroup evaluates its transition heights
                (modified by LODGroupManager if bias has changed)

Performance Characteristics

Unturned's LOD and culling strategy is designed for large open worlds with the following performance profile:

OptimizationGPU savingsCPU cost
LODGroupManager bias scalingReduces drawn triangles at low qualityNegligible (only recalculates on bias change)
LightLODEliminates light rendering + shadow maps for distant lights~1 μs per light (distance comparison)
Frustum cullingEliminates off-screen geometry entirelyUnity-built-in
CullingVolumeHides interiors/exteriors in specific zones~10 μs per volume check
Level batchingReduces draw calls for static geometryO(1) after initial batch

LODGroupAdditionalData Reference

The LODGroupAdditionalData component is an extension component added to GameObjects with LODGroup components. It provides per-object bias override and gets registered in LODGroupManager when the object loads.

FieldTypeDefaultPurpose
LODBiasOverrideenumNoneOverrides the global LOD bias for this specific group. Options: None, Low, High

Objects with LODBiasOverride.None are not registered with LODGroupManager and use Unity's default LOD bias calculation without modification. Objects with Low or High overrides have their original heights scaled differently from the global bias.

Region-Based World Partitioning

Unturned organizes its world into a region grid for efficient spatial queries and rendering management. The Regions class defines the world size and region dimensions:

csharp
public static class Regions
{
    public static readonly byte WORLD_SIZE = 16;    // Region grid is 16×16
    public static readonly int REGION_SIZE = 128;    // Each region is 128×128 units
    public static readonly int LOADED_SIZE = 12;    // Regions loaded around the player
}

The world is 16×16 regions, each 128×128 game units, for a total world area of 2048×2048 units. The player's position determines which regions are loaded — the LOADED_SIZE constant means regions within a 12-region radius (centered on the player's region) are loaded and rendered.

Objects are placed into regions based on their world position. The Objects class assigns each placed object to its containing region. When the player moves to a different region, the LevelObjects system unloads distant regions and loads newly-approached ones.

The region system extends to dynamic entities as well:

  • ItemRegion — dropped items
  • ObjectRegion — level-placed objects
  • BarricadeRegion — player-placed barricades
  • StructureRegion — player-built structures
  • ResourceRegion — resource nodes
  • ZombieRegion — zombie entities

Each region type manages its own loaded/unloaded state and visibility culling independently.

Dynamic Object Instantiation

Level objects are not all instantiated at load time. The LevelObjects system instantiates objects lazily as regions become active:

Player enters region

      ├─ Region manager checks if region is loaded
      │    └─ If not loaded:
      │         ├─ Iterate placed objects in region
      │         ├─ For each object:
      │         │    ├─ Instantiate prefab from ObjectAsset
      │         │    ├─ Apply transform (position/rotation/scale)
      │         │    ├─ Apply material overrides
      │         │    ├─ Set LODGroup if applicable
      │         │    └─ Register for LightLOD if applicable
      │         └─ Mark region as loaded

      └─ Region enters rending path

The lazy instantiation means the game can have a 2048×2048 world with thousands of placed object references without keeping all GameObjects in memory simultaneously. Memory usage scales with the visible region radius, not the total world size.

Grid-Based Visibility Management

The LevelVisibility system in the editor works with the region grid. Each visibility toggle calls the corresponding Level* system's setEnabled(bool) method, which iterates over all loaded regions and enables/disables the relevant GameObjects:

LevelVisibility.roadsVisible = false
  → LevelRoads.setEnabled(false)
    → For each loaded region:
         → For each road GameObject in region:
              → roadGameObject.SetActive(false)

This bulk enable/disable is efficient because it operates on already-loaded GameObjects. In the runtime game, visibility toggles are not available (all layers are always visible).

Shader-Based LOD for Objects

In addition to geometric LOD (switching between mesh levels), level objects can use shader-based LOD. The ObjectAsset defines a material LOD distance that switches to a simplified shader variant at distance. The simplified shader removes specular and normal map evaluation, reducing per-pixel fragment shader cost for distant objects.

The shader LOD distance is set per-object in the level editor and stored in the object's placement data. The material LOD system is separate from the Unity LODGroup component and operates independently.

LODGroupAdditionalData Configuration Details

The LODGroupAdditionalData component supports three bias override modes:

ModeEffect
NoneNot registered with LODGroupManager; uses global LOD bias only
LowTransition heights reduced for earlier LOD switching (lower quality)
HighTransition heights increased for later LOD switching (higher quality)

The Low and High values multiply the original transition heights by a fixed factor (different from the global LOD bias). This allows specific objects to have different LOD behavior than the global setting — for example, a landmark building might use High to stay detailed at longer distances while generic bushes use Low to degrade quickly.

Culling Volume Mesh and Trigger Configuration

Culling volumes use Unity's trigger collider system for camera detection. The volume's GameObject must have:

  1. A Collider component (typically BoxCollider or MeshCollider) set to isTrigger = true
  2. A CullingVolume component with configured culling flags
  3. A Rigidbody for trigger detection (typically set to kinematic)

When the camera's Rigidbody (attached to the player or a camera proxy) enters the trigger, the CullingVolumeManager fires the enter event. When it exits, the exit event fires. The events propagate to registered handlers that enable/disable the appropriate object renderers.

The culling flags stored in the CullingVolume component are bitmask-based and can target:

FlagObjects affected
ExteriorAll objects tagged as exterior
InteriorAll objects tagged as interior
UndergroundObjects with underground placement
CustomEditor-assigned custom category

Objects in the level editor are tagged with their culling category. The culling volume's flag mask determines which categories are hidden when the camera is inside the volume.

Appendix: LODGroupManager Register/Unregister Pattern

csharp
// Registration — called when an object with LODGroupAdditionalData is loaded
LODGroupManager.Get().Register(additionalDataComponent);

// Unregistration — called when object is destroyed
LODGroupManager.Get().Unregister(additionalDataComponent);

// Synchronization — called when quality settings LOD bias changes
LODGroupManager.Get().SynchronizeLODBias();

The Register method:

  1. Reads the component's bias override
  2. Returns early if override is None
  3. Gets the Unity LODGroup component (warns if missing)
  4. Creates ComponentData with original LODs and modified LODs
  5. Calls UpdateComponent to apply current bias

The Unregister method:

  1. Searches the components list for the matching extension component
  2. Removes at found index with RemoveAtFast (swap-remove)

LevelObjects Rendering Architecture

The LevelObjects class manages all placed objects in a level. Each placed object is represented by a LevelObject struct that stores:

  • The ObjectAsset reference (determines mesh, material, LOD behavior)
  • World-space transform (position, rotation, scale)
  • Reference to the instantiated GameObject (if loaded)
  • LOD state tracking

Objects are organized into ObjectRegion instances corresponding to the level's 4×4 region grid (defined by LevelVisibility.OBJECT_REGIONS = 4). Each region independently manages its objects' loaded state based on distance from the camera. Regions outside the loaded range have all their objects unloaded from memory — not just culled from rendering, but removed from the scene entirely.

The region loading distance is configurable per level and depends on the LODBias setting. A higher LOD bias loads more distant regions, consuming more memory but reducing pop-in.

Density-Based Object Culling

Beyond distance-based LOD, objects can be culled by density. When many objects of the same type are clustered in a small area, the engine reduces the number of visible objects within the cluster. This prevents distant forests or buildings from rendering at full density, which would waste GPU time on detail that is not distinguishable at distance.

The density culling is separate from the LOD system and uses the EObjectLOD.NEAR setting. Objects marked as NEAR are subject to more aggressive density-based culling and may not render at all beyond a moderate distance.

Asset Validation During Load

The LODGroupManager.Register method performs validation:

csharp
if (component.LODBiasOverride == LODGroupAdditionalData.ELODBiasOverride.None)
    return;

LODGroup unityComponent = component.GetComponent<LODGroup>();
if (unityComponent == null)
{
    UnturnedLog.warn("Additional Data without LOD Group: {0}", component.GetSceneHierarchyPath());
    return;
}

This validation catches objects that have LODGroupAdditionalData set to a non-None override but are missing the actual LODGroup component. Without this check, the GetLODs() call would throw a NullReferenceException.

LOD Transition Height Adjustment Formula

The LOD bias scaling recalculates transition heights as:

modifiedHeight = originalHeight × cachedLODBias

Where cachedLODBias reflects the player's graphics quality setting for LOD bias. At bias = 1.0 (default), modified heights match originals. At bias = 2.0, all transition distances are doubled, meaning LOD0 (full detail) is used twice as far before switching to LOD1. At bias = 0.5, transitions happen at half distance, degrading detail sooner.

The padding safety check ensures that no transition height exceeds the next-higher LOD's height:

LOD0 height: 0.05
LOD1 height: 0.02 × 2.0 = 0.04  (after bias)
  → But LOD0 is 0.05, so LOD1 would be higher than LOD0
  → Pad: LOD1 = min(0.04, 0.05 - 0.001) = 0.04  (not clamped)
  → But if LOD1 exceeded LOD0: LOD1 = min(0.06, 0.05 - 0.001) = 0.049

The 1.0 - lodPadding cap (0.999) ensures no LOD level reaches 100% screen coverage, which would mean it never transitions to the next level.

LightLOD Performance Impact

LightLOD is particularly important for performance because each enabled light with shadow casting incurs a shadow map render pass. On a dark map with 50+ point lights, disabling distant lights via LightLOD can eliminate 80%+ of shadow map rendering.

The transition zone for point lights (13× range to 15× range) means the light begins fading at 13× range and is fully off at 15× range. For a light with range 10, this means the light is at full intensity within 130 units and fully off beyond 150 units. The 20-unit fade zone prevents popping.

Spot lights use a wider transition (1.75× to 2.0× of max(64, range)) because spot lights have a narrower cone and are more noticeable when they pop on and off. The 64-unit minimum ensures small spot lights (e.g., flashlight range of 8) still have a reasonable fade zone.

CullingVolume Integration with Visibility System

Culling volumes interact with the visibility system through event-driven enable/disable calls. When the camera enters a culling volume, CullingVolumeManager fires events that disable renderers on matching objects. When the camera leaves, the renderers are re-enabled.

This system is distinct from frustum culling: a culling volume can hide objects that are within the camera's view frustum but inside a specific building interior. The volume's trigger shape is typically placed at building entrances, and the interior geometry is tagged with a matching culling flag.

Appendix A: LOD Parameter Reference

ParameterUsed bySourceDefaultEffect
QualitySettings.lodBiasLODGroupManagerGraphics settings1.0Global LOD bias multiplier
LODBiasOverrideLODGroupManagerPer-objectNonePer-object bias exception
light.rangeLightLODLight componentDetermines transition distances
-DisableLightLODsLightLODCommand lineDisabledForce-disable light LOD
WantsCinematicModeLightLODGraphics settingsOffCinematic mode disables light LOD
OBJECT_REGIONSLevelVisibilityConstant4Region grid divisor
SAVEDATA_VERSIONLevelVisibilityConstant2Visibility.dat format version

Appendix B: Editor Layer Toggles Reference

Toggle.dat keyAffected managerDefault
roadsVisible(persisted)LevelRoadstrue
navigationVisible(persisted)LevelNavigationtrue
nodesVisible(persisted)SpawnpointSystemV2true
itemsVisible(persisted)LevelItemstrue
playersVisible(persisted)LevelPlayerstrue
zombiesVisible(persisted)LevelZombiestrue
vehiclesVisible(persisted)LevelVehiclestrue
borderVisible(persisted)Leveltrue
animalsVisible(persisted, v2+)LevelAnimalstrue

Appendix C: LightLOD Transition Zones by Light Type

Light typeTransition startTransition endFade zone width
Pointrange × 13range × 15range × 2
Spotmax(64, range) × 1.75max(64, range) × 2.0max(64, range) × 0.25
DirectionalNot supportedN/AN/A
AreaNot supportedN/AN/A

Appendix D: Common LOD and Culling Issues

SymptomLikely causeResolution
Objects pop in too closeLOD bias too low in quality settingsIncrease LOD bias slider
Lights still visible at extreme distanceLightLOD disabled via -DisableLightLODsRemove command-line flag
Visibility toggle not savingVisibility.dat corrupt or version mismatchDelete Visibility.dat, let it regenerate
Culling volume not workingVolume trigger not overlapping camera pathAdjust volume position in editor
Batch rendering shows wrong objectsLightmap index conflict in batchSplit batch by material/lightmap

LOD Distance Calculation for Level Objects

The LevelObjects system calculates the LOD level for each placed object every frame:

csharp
// Simplified LOD distance calculation for level objects
Vector3 objectPosition = levelObject.transform.position;
float distanceSqr = (cameraPosition - objectPosition).sqrMagnitude;

// Compare against LOD transition distances
// These distances have been scaled by the effective LOD bias
if (distanceSqr < lod0TransitionSqr)
    renderAtLevel(LOD0);
else if (distanceSqr < lod1TransitionSqr)
    renderAtLevel(LOD1);
else if (distanceSqr < lod2TransitionSqr)
    renderAtLevel(LOD2);
else
    cullObject();

The transition distances are stored as squared values to avoid the per-object sqrt call. The comparison uses squared distance throughout the pipeline — from LightLOD to object LOD to region loading.

LOD Cross-Fading

Unity's LODGroup component supports cross-fading between LOD levels. When cross-fading is enabled, the transition between LOD0 and LOD1 is not instantaneous — the two levels are blended over a short distance range. Unity controls cross-fading through the fadeMode property on each LOD level:

Fade modeBehavior
NoneInstant switch at transition threshold
CrossFadeBlend between LOD levels over the fade range
SpeedTreeSpecialized for SpeedTree vegetation

Unturned uses CrossFade for most level objects. The cross-fade range is determined by the LODGroup component's configuration and is not modified by the LODGroupManager.

Static Batching Integration

LevelBatching integrates with Unity's static batching system. When a level is loaded, eligible objects are combined into static batches:

csharp
// Pseudo-code for batch creation
BatchKey key = new BatchKey(material, lightmapIndex);
StaticBatchingUtility.Combine(batchGameObjects);

Batches are split by:

  1. Material — Objects with different materials cannot be batched together
  2. Lightmap index — Objects using different lightmap indices cannot be batched together

The combined batch uses the Mesh.CombineMeshes method internally. After batching, individual objects within the batch cannot be culled independently — the batch's combined bounds are used for frustum culling.

Objects that are not eligible for batching (objects with animation, movable objects, objects with per-instance data) use individual draw calls and benefit from per-object frustum and occlusion culling.

Visibility.dat Persistence Workflow

The LevelVisibility.save() method is called when the level editor saves the level. The LevelVisibility.load() method is called when the level editor loads a level. The workflow:

Editor saves level:
  LevelVisibility.save()
    → Create River writer at Level/Visibility.dat
    → Write SAVEDATA_VERSION (2)
    → Write each toggle boolean
    → Close River

Editor loads level:
  LevelVisibility.load()
    → Check if Visibility.dat exists
    → If yes:
        → Open River reader
        → Read and apply version 1 format
        → Read and apply version 2 additions (animals)
        → Close River
    → If no:
        → Set all toggles to true (default)

The toggle values are editor-only. In-game, LevelVisibility is not called for runtime visibility — all layers are always visible during gameplay.

LightLOD Workflow Summary

Level designer places light in editor

      ├─ LightLOD component added automatically
      │  (or manually configured for custom lights)

      ├─ In Start():
      │    ├─ Check WantsLightLodsOff → disable self if true
      │    ├─ Check light type → disable self if Directional or Area
      │    ├─ Record intensityStart from light.intensity
      │    ├─ Calculate transition distances based on light type
      │    │    ├─ Point:  transitionStart = range × 13;
      │    │    │         transitionEnd   = range × 15
      │    │    └─ Spot:   transitionStart = max(64, range) × 1.75;
      │    │               transitionEnd   = max(64, range) × 2.0
      │    └─ Square transition distances for fast comparison

      ├─ Each frame Update():
      │    ├─ MainCamera.instance exists?
      │    ├─ Calculate offset = lightPos - cameraPos
      │    ├─ sqrDistance = offset.sqrMagnitude
      │    ├─ Compare sqrDistance to transition bounds
      │    └─ Enable/disable light + lerp intensity

      └─ On quality setting change:
           └─ (LightLOD does not recalculate — light range is fixed)

The LightLOD component does not recalculate transition distances when the graphics quality changes. The transition distances are based on the light's range, which does not change at runtime. If a light's range is modified at runtime, the LightLOD component would need to be re-initialized.

Distance-Based Fade Zone Mathematics

The fade zone between full intensity and zero intensity uses linear interpolation:

For point lights:
  fadeStart = range × 13
  fadeEnd = range × 15

  If sqrDistance < fadeStart²:
    intensity = intensityStart (full)
  If sqrDistance > fadeEnd²:
    intensity = 0 (off)
  If fadeStart² ≤ sqrDistance ≤ fadeEnd²:
    magnitude = sqrt(sqrDistance)
    t = (magnitude - fadeStart) / (fadeEnd - fadeStart)
    intensity = lerp(intensityStart, 0, t) = intensityStart × (1 - t)

The lerp produces a linear intensity reduction over the fade zone. At the midpoint of the fade zone (14× range for point lights), the intensity is at 50% of the starting value. The linear fade is smooth enough to prevent visual popping.

For spot lights, the fade zone is wider relative to the range (1.75× to 2.0× vs. 13× to 15× for point lights) because spot lights have a narrower visual profile and their enable/disable is more noticeable. The minimum spot light range of 64 ensures even small spot lights have a visible fade zone.

Object Region Memory Management

Each ObjectRegion tracks the objects within its bounds. The region is responsible for:

  1. Instantiation — Creating GameObjects for placed objects when the region becomes active
  2. Destruction — Destroying GameObjects when the region becomes inactive
  3. Visibility — Enabling/disabling renderers based on culling volume state
  4. LOD management — Switching LOD levels for objects in the region

A region's memory footprint depends on the number and complexity of objects it contains. A dense urban region might contain hundreds of objects, while a sparse forest region might contain dozens. The total memory usage for all loaded regions is bounded by the LOADED_SIZE setting, which limits the number of simultaneously active regions.

Appendix E: LightLOD Optimization Benefits by Scenario

ScenarioTotal lightsLights enabled without LODLights enabled with LODShadow maps saved
Small interior (4 lights)4440
Large interior (10 lights)1010100
Night city (50 lights)5050~8-15~35-42
Day city (50 lights, not emitting)5050050
Dense forest (20 lights)2020~3-8~12-17

LightLOD provides the most benefit in night-time outdoor scenarios where many lights are present but the player can only see a subset at close range. In interior environments where all lights are within the fade zone, LightLOD activates all lights.

Appendix F: LevelVisibility Version Migration

The SAVEDATA_VERSION = 2 addition (animals toggle) required a version check in load():

csharp
if (version > 1)
{
    animalsVisible = river.readBoolean();
}
else
{
    _animalsVisible = true;  // Default for old files
}

This migration pattern allows the visibility system to evolve without breaking existing level saves. New toggles are appended to the end of the binary format with a version gate. Old levels without the new toggle use the default value (usually true).

Document history

VersionDateAuthorNotes
1.02026-07-2757 StudiosInitial publication. LOD bias scaling, LightLOD, LevelVisibility, CullingVolume, batching.