Skip to content

Level Terrain Generation

LevelGround (1601 lines in Unturned/Level/LevelGround.cs) is the terrain subsystem for the Unturned level pipeline. It manages terrain heightmap and alphamap data, six global triplanar shader parameters that control terrain texturing, resource tree loading with region-based spatial storage and dual visibility trackers (model + skybox LOD), the landscape tile system integration, and the legacy dual-terrain conversion pipeline. Together with SDG.Framework.Landscapes.Landscape, it forms the foundation for all terrain rendering, height queries, and foliage placement.

Unlike runtime managers, LevelGround is a MonoBehaviour-based singleton that coordinates loading through coroutines, static event delegates, and direct file I/O through the River/Block serialization system.

Source code location: Unturned/Level/LevelGround.cs

Architecture Overview

LevelGround manages two TerrainData objects (_data and _data2) — a legacy holdover from before the landscape system was unified. The modern landscape system (SDG.Framework.Landscapes.Landscape) handles tile-based heightfields, material blends, and foliage instances. LevelGround bridges these two systems through a comprehensive legacy conversion pipeline.

Key architectural components:

  • Triplanar shader parameters: Six Shader.SetGlobalFloat calls control the scale and blend weight of three terrain texture layers.
  • Terrain data: Two TerrainData instances with heightmap and alphamap accessors, plus splatmap arrays (alphamapHQ, alphamap2HQ).
  • Resource tree storage: RegionDictionary<ResourceSpawnpoint> for O(1) region-based spatial tree lookups.
  • Legacy conversion: hasLegacyDataForConversion, doesLegacyDataIncludeSplatmapWeights, and legacyMaterialGuids flags drive the migration from old dual-terrain to modern landscape.
  • Visibility trackers: regionTracker and skyboxRegionTracker for incremental region activation of tree models and distant LODs.

Triplanar Shader Parameters

Global Shader Configuration

Six global floats set via Shader.SetGlobalFloat:

csharp
// Primary (detail) texture — close-up detail
Shader.SetGlobalFloat("_Triplanar_Primary_Size", triplanarPrimarySize);     // Default: 16
Shader.SetGlobalFloat("_Triplanar_Primary_Weight", triplanarPrimaryWeight);  // Default: 0.4

// Secondary (macro) texture — mid-distance blending
Shader.SetGlobalFloat("_Triplanar_Secondary_Size", triplanarSecondarySize);     // Default: 64
Shader.SetGlobalFloat("_Triplanar_Secondary_Weight", triplanarSecondaryWeight);  // Default: 0.4

// Tertiary (micro-detail) texture — distant blend overlay
Shader.SetGlobalFloat("_Triplanar_Tertiary_Size", triplanarTertiarySize);     // Default: 4
Shader.SetGlobalFloat("_Triplanar_Tertiary_Weight", triplanarTertiaryWeight);  // Default: 0.2

How Triplanar Texturing Works

Triplanar mapping projects three planar textures (top-down, front-back, left-right) onto the terrain surface, blending them based on the surface normal. The three layers provide:

  1. Primary (size 16): Close-up detail — grass blades, pebbles, soil texture.
  2. Secondary (size 64): Mid-distance macro blending — biomes, large color transitions.
  3. Tertiary (size 4): Distant micro-detail overlay — additional noise/grain at far distances.

The weights (0.4, 0.4, 0.2) determine the blend ratio. The tertiary's low weight (0.2) ensures it's a subtle overlay, not a dominant layer.

Heightmap and Alphamap

Terrain Data Objects

Two TerrainData instances (_data and _data2) with accessors:

AccessorImplementationNotes
getHeight(Vector3)Landscape.getWorldHeightModern landscapes only
getNormal(Vector3)Landscape.getNormalSurface normal for placement/rotation
getAlphamap_X/YTerrain positionCoordinate translation to splatmap
getHeightmap_X/YTerrain positionCoordinate translation to heightmap

Splatmap Arrays

csharp
private static float[,,] alphamapHQ;    // Primary terrain splatmap
private static float[,,] alphamap2HQ;   // Secondary terrain splatmap
public static readonly byte ALPHAMAPS = 2;

Each splatmap is a 3D array [x, y, layer] where layer indexes the terrain material. The getConversionWeight method reads the appropriate alphamap:

csharp
private static float GetConversionWeight(Vector3 point, int layerIndex)
{
    int x = getAlphamap_X(point);
    int y = getAlphamap_Y(point);

    // Determine which terrain is higher
    float height1 = _terrain.SampleHeight(point);
    float height2 = _terrain2.SampleHeight(point);

    if (height1 >= height2)
        return alphamapHQ[x, y, layerIndex];
    else
        return alphamap2HQ[x, y, layerIndex];
}

Height Query System — Three Paths

MethodSystemWhen Used
getHeight(Vector3)Landscape.getWorldHeightModern maps with landscape tiles
getConversionHeight(Vector3)Dual terrain.SampleHeight + maxLegacy maps during conversion
terrain.SampleHeight(Vector3)Unity Terrain APIPre-landscape fallback, editor

The getConversionHeight method takes the maximum of two SampleHeight calls:

csharp
public static float getConversionHeight(Vector3 point)
{
    float height1 = _terrain != null ? _terrain.SampleHeight(point) : 0f;
    float height2 = _terrain2 != null ? _terrain2.SampleHeight(point) : 0f;
    return Mathf.Max(height1, height2);
}

The max-union approach handles overlapping terrains where two terrain meshes were used for separate biomes.

Landscape System Integration

Landscape Tile Structure

Landscape tiles replace the old monolithic terrain. Each tile contains:

csharp
// Conceptual landscape tile
public class LandscapeTile
{
    public float[,] heights;           // Heightfield (resolution configurable)
    public float[,,] materialWeights;  // Material blend weights (material count × resolution)
    public FoliageTile foliage;        // Per-tile foliage instances
    public Bounds worldBounds;         // World-space bounding box
}

Key Interactions

InteractionAPINotes
Get heightLandscape.getWorldHeight(point)Bilinear interpolation across tiles
Get normalLandscape.getNormal(point)Derived from neighboring height samples
Loaded eventLandscape.loadedLevelRoads hooks to rebuild road meshes
Tile bakingLandscape.bakeTile(coordinates)Editor-triggered terrain generation

Terrain Collider

The terrain collider is part of the landscape system and is consumed by:

  • Player movement: Ground snapping via CharacterController + getHeight.
  • Resource placement: addSpawn uses getHeight for tree base determination.
  • Road building: Road vertex Y = landscape height + offset.
  • Spawn validation: Spawnpoints must be near terrain surface.
  • Underground whitelist: UndergroundAllowlist.AdjustPosition prevents Y-push below terrain.

Resource Tree Loading

Spatial Storage

csharp
private static RegionDictionary<ResourceSpawnpoint> _regionTrees;

Trees are stored in a region-based dictionary. The RESOURCE_REGIONS constant (3 by default, 16 in BEAUTIFUL builds) determines the granularity of spatial lookups.

addSpawn — Full Implementation

csharp
public static void addSpawn(Vector3 point, Quaternion rotation, Vector3 scale,
    System.Guid guid, bool isGenerated = false)
{
    // Step 1: Create spawnpoint
    ResourceSpawnpoint tree = new ResourceSpawnpoint(point, rotation, scale, guid);
    tree.isGenerated = isGenerated;

    // Step 2: Region assignment
    byte x, y;
    if (!Regions.tryGetCoordinate(point, out x, out y)) return;
    _regionTrees.Add(x, y, tree);

    // Step 3: Activation (model + stump + skybox)
    tree.SetIsActiveInRegion(true);

    // Step 4: Legacy backward compatibility
    _trees.Add(tree); // Flat list for old code
    treesHash = null;  // Invalidate hash for recalculation
}

removeSpawn

csharp
public static void removeSpawn(Vector3 point, float radius)
{
    float sqrRadius = radius * radius;
    List<RegionCoordinate> searchRegions = Regions.GetRegionSearchCoordinates(point, radius);

    foreach (var coord in searchRegions)
    {
        List<ResourceSpawnpoint> trees = _regionTrees.Get(coord.x, coord.y);
        for (int i = trees.Count - 1; i >= 0; i--)
        {
            if ((trees[i].position - point).sqrMagnitude <= sqrRadius)
            {
                trees[i].destroy(); // Model, stump, skybox all destroyed
                _trees.Remove(trees[i]);
                _regionTrees.Remove(coord.x, coord.y, trees[i]);
            }
        }
    }
    treesHash = null;
}

handlePreBakeTile

csharp
protected static void handlePreBakeTile(FoliageBakeSettings bakeSettings, FoliageTile foliageTile)
{
    if (!bakeSettings.bakeResources) return;

    Bounds tileBounds = foliageTile.worldBounds;
    List<RegionCoordinate> regions = Regions.GetCoordinateBoundsInt(tileBounds);

    foreach (var coord in regions)
    {
        List<ResourceSpawnpoint> trees = _regionTrees.Get(coord.x, coord.y);
        for (int i = trees.Count - 1; i >= 0; i--)
        {
            if (trees[i].isGenerated && tileBounds.Contains(trees[i].position))
            {
                trees[i].destroy();
                _regionTrees.Remove(coord.x, coord.y, trees[i]);
            }
        }
    }
}

Only generated trees (not hand-placed) are removed during foliage baking. This prevents duplication while preserving editor-placed tree placement.

treeHash

csharp
public static byte[] treesHash { get; private set; }

SHA1 hash of Trees.dat, computed after save. Used for anti-cheat level integrity. Invalidated whenever trees are added or removed.

Visibility Trackers

csharp
private static RegionIncrementalVisibilityTracker regionTracker;     // Main models
private static RegionIncrementalVisibilityTracker skyboxRegionTracker; // Distant LOD

Two separate trackers:

  • regionTracker: Controls SetIsActiveInRegion for the full tree model. Activated when player is within region range.
  • skyboxRegionTracker: Controls skybox LOD model. Activated when tree is outside region range but within landmark quality distance. Only active at medium+ landmark quality settings.

ForceUpdateSkyboxActive refreshes all tree skybox states — called when landmark quality settings change.

Material Loading Pipeline

The full material loading path:

csharp
// Step 1: Load asset bundle
string bundlePath = Level.info.path + "/Terrain/Materials.unity3d";
Bundle materialBundle = Bundles.getBundle(bundlePath, false);

// Step 2: Compute hash for comparison
byte[] bundleHash = Hash.SHA1(ReadAllBytes(bundlePath));

// Step 3: Check against known material packs
if (bundleHash.Matches(PEI_MATERIALS_HASH))
    legacyMaterialGuids = PEI_MATERIAL_GUIDS;
else if (bundleHash.Matches(RUSSIA_MATERIALS_HASH))
    legacyMaterialGuids = RUSSIA_MATERIAL_GUIDS;

// Step 4: Extract textures and assign GUID mapping
Texture2D[] materialTextures = materialBundle.loadAll<Texture2D>();
for (int i = 0; i < materialTextures.Length; i++)
{
    string textureName = materialTextures[i].name;
    // Map texture name -> LandscapeMaterialAsset GUID
    legacyMaterialGuids[i] = ResolveMaterialGuid(textureName);
}

// Step 5: If conversion needed, migrate alphamap -> landscape weights
if (hasLegacyDataForConversion)
    MigrateAlphamapToLandscape();

The hash comparison auto-detects known vanilla maps (PEI, Russia, Washington, Yukon, etc.) and maps their material bundles to modern LandscapeMaterialAsset GUIDs.

Legacy Terrain Conversion

Conversion Pipeline

csharp
private static void ConvertLegacyTerrain()
{
    if (!hasLegacyDataForConversion) return;

    // Step 1: Create landscape tiles for the entire map
    int tileCountX = (Level.size / LANDSCAPE_TILE_SIZE);
    int tileCountY = (Level.size / LANDSCAPE_TILE_SIZE);

    for (int tx = 0; tx < tileCountX; tx++)
    for (int ty = 0; ty < tileCountY; ty++)
    {
        LandscapeTile tile = Landscape.CreateTile(tx, ty);

        // Step 2: Migrate heightmap
        for (int x = 0; x < LANDSCAPE_TILE_RESOLUTION; x++)
        for (int y = 0; y < LANDSCAPE_TILE_RESOLUTION; y++)
        {
            Vector3 worldPos = TileToWorld(tx, ty, x, y);
            float height = getConversionHeight(worldPos);
            tile.heights[x, y] = height;
        }

        // Step 3: Migrate alphamap splat weights
        for (int x = 0; x < LANDSCAPE_TILE_RESOLUTION; x++)
        for (int y = 0; y < LANDSCAPE_TILE_RESOLUTION; y++)
        {
            for (int layer = 0; layer < legacyMaterialGuids.Length; layer++)
            {
                float weight = GetConversionWeight(worldPos, layer);
                tile.materialWeights[x, y, layer] = weight;
            }
        }
    }

    // Step 4: Migrate trees
    foreach (var tree in _trees)
    {
        // Convert tree data to resource spawnpoint format
        addSpawn(tree.position, tree.rotation, tree.scale,
            legacyTreeGuidToAssetGuid[tree.assetId], tree.isGenerated);
    }

    // Step 5: Clear conversion flags
    hasLegacyDataForConversion = false;
    doesLegacyDataIncludeSplatmapWeights = false;
}

The conversion preserves visual appearance by migrating four data categories, ensuring existing maps look identical after the terrain system upgrade.

Terrain Detail Rendering

Terrain/Details.unity3d and Terrain/Details.dat store detail configuration:

  • Grass and detail mesh prefabs (Unity DetailPrototype format).
  • Detail density per terrain patch.
  • Detail render distance (configurable in graphics settings).
  • shouldInstantlyLoad controls immediate vs. deferred loading.

Dedicated servers skip terrain detail loading entirely — Dedicator.IsDedicatedServer gates the detail instantiation.

Common Issues

  1. Height query mismatch: New landscapes use getHeight; old terrain uses getConversionHeight or terrain.SampleHeight. Calling the wrong method for the current terrain system returns incorrect heights (0 or infinity).

  2. Tree hash invalidation: Adding or removing trees invalidates treesHash, which triggers hash recalculation on next save. Frequent tree modifications during gameplay can cause save stutter.

  3. Conversion performance: ConvertLegacyTerrain is O(tileCount² × resolution²), which on INSANE maps (8192×8192) can take several seconds on the first load. Conversion results are cached in the level directory.

  4. Splatmap precision: Legacy splatmaps use float[,,] with Unity's alphamap resolution. Converting to landscape material weights may introduce minor blending artifacts at biome boundaries.

  5. Foliage bake tree removal: If handlePreBakeTile removes generated trees but the bake fails (editor crashes), the trees are lost permanently. Mitigated by editor auto-save before bake operations.

Example: Getting Terrain Height

csharp
Vector3 worldPosition = new Vector3(x, 0, z);
float groundHeight = LevelGround.getHeight(worldPosition);
worldPosition.y = groundHeight;

Implementation: addSpawn Edge Cases

When adding a tree spawn, several edge cases are handled:

csharp
// Edge case 1: Invalid region (point outside level)
byte x, y;
if (!Regions.tryGetCoordinate(point, out x, out y))
{
    UnturnedLog.warning($"Tree spawn at {point} is outside level bounds");
    return;
}

// Edge case 2: Duplicate position
List<ResourceSpawnpoint> regionTrees = _regionTrees.Get(x, y);
for (int i = 0; i < regionTrees.Count; i++)
{
    if ((regionTrees[i].position - point).sqrMagnitude < 0.01f)
    {
        // Replace existing tree at exact same position
        regionTrees[i].destroy();
        _regionTrees.Remove(x, y, regionTrees[i]);
        break;
    }
}

// Edge case 3: Terrain clipping — check if tree base is below surface
float terrainHeight = getHeight(point);
if (point.y < terrainHeight - 0.5f)
{
    point.y = terrainHeight; // Snap to terrain
}

Trees.dat Binary Format

csharp
// Save format
river.writeByte(SAVEDATA_VERSION); // Current: 5

// Per-tree data
for each region:
    for each tree in region:
        river.writeGuid(tree.assetGuid);             // 16 bytes
        river.writeSingleVector3(tree.position);      // 12 bytes
        river.writeQuaternion(tree.rotation);         // 16 bytes (version >= 4)
        // Legacy: rotation stored as 3 bytes euler (pre-version-4)
        river.writeSingleVector3(tree.scale);         // 12 bytes
        river.writeBoolean(tree.isGenerated);         // 1 byte

LandscapeBatching Interaction

When LevelBatching is enabled, terrain tiles interact with the batching system:

  1. Static batching: Landscape tiles that never change are combined into static batches.
  2. Dynamic exclusion: Tiles near player-placed structures are excluded from batching (structures modify the terrain).
  3. Batch invalidation: Adding/removing trees invalidates nearby landscape batches.

Terrain Physics Materials

The terrain's PhysicMaterial controls:

  • Friction: Vehicle traction, player movement speed modifiers.
  • Bounciness: Throwable bouncing behavior.
  • Material name: Used by PhysicMaterialCustomData for gameplay queries (arable soil, oil-bearing rock, etc.).

These materials are defined per-terrain-material in the landscape material asset, not in LevelGround directly.

Common Performance Issues

  1. Large map tree enumeration: On INSANE maps with hundreds of thousands of trees, the _trees legacy flat list causes GC pressure during serialization. Modern code uses _regionTrees region dictionary exclusively.

  2. Skybox LOD update storm: ForceUpdateSkyboxActive iterates ALL trees when landmark quality changes. On large maps, this can cause a multi-frame spike.

  3. Conversion memory: ConvertLegacyTerrain allocates temporary arrays of size resolution² × materialCount per tile. On INSANE maps with many materials, this can exceed 500MB of allocation.

Example: Reading Heights at Multiple Points

csharp
// Efficient batch height query across grid points
Vector3[] points = new Vector3[100];
float[] heights = new float[100];
for (int i = 0; i < 100; i++)
    heights[i] = LevelGround.getHeight(points[i]);

ALPHAMAPS and Splatmap Coordinate Translation

The getAlphamap_X and getAlphamap2_X methods translate world coordinates to splatmap indices. Two versions exist because the dual-terrain system maintained separate alphamaps:

csharp
public static int getAlphamap_X(Vector3 point)
{
    return (int)((point.x - _terrain.transform.position.x) / _data.size.x
        * _data.alphamapResolution);
}

public static int getAlphamap2_X(Vector3 point)
{
    return (int)((point.x - _terrain2.transform.position.x) / _data2.size.x
        * _data2.alphamapResolution);
}

The splatmap resolution is typically 512 or 1024, providing 0.5–2m per-pixel precision for material blending.

Shader Property Change Propagation

When any triplanar property changes, the update is immediate via Shader.SetGlobalFloat, which propagates to all terrain materials referencing the global shader properties. No material instance creation is needed:

csharp
public static float triplanarPrimarySize
{
    get => _triplanarPrimarySize;
    set
    {
        _triplanarPrimarySize = value;
        Shader.SetGlobalFloat("_Triplanar_Primary_Size", value);
    }
}

Resource Tree spawn LOD Selection

When ForceUpdateSkyboxActive() is called, all tree skybox states are recalculated:

csharp
public static void ForceUpdateSkyboxActive()
{
    foreach (var region in EnumerateAllRegions())
    {
        foreach (ResourceSpawnpoint tree in region)
        {
            bool regionActive = regionTracker.IsRegionActive(tree.regionX, tree.regionY);
            bool skyboxActive = !regionActive && IsLandmarkQualitySufficient();
            tree.SetSkyboxActive(skyboxActive);
        }
    }
}

Document history