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 ​