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.SetGlobalFloatcalls control the scale and blend weight of three terrain texture layers. - Terrain data: Two
TerrainDatainstances 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, andlegacyMaterialGuidsflags drive the migration from old dual-terrain to modern landscape. - Visibility trackers:
regionTrackerandskyboxRegionTrackerfor 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.2How 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:
- Primary (size 16): Close-up detail — grass blades, pebbles, soil texture.
- Secondary (size 64): Mid-distance macro blending — biomes, large color transitions.
- 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:
| Accessor | Implementation | Notes |
|---|---|---|
getHeight(Vector3) | Landscape.getWorldHeight | Modern landscapes only |
getNormal(Vector3) | Landscape.getNormal | Surface normal for placement/rotation |
getAlphamap_X/Y | Terrain position | Coordinate translation to splatmap |
getHeightmap_X/Y | Terrain position | Coordinate 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
| Method | System | When Used |
|---|---|---|
getHeight(Vector3) | Landscape.getWorldHeight | Modern maps with landscape tiles |
getConversionHeight(Vector3) | Dual terrain.SampleHeight + max | Legacy maps during conversion |
terrain.SampleHeight(Vector3) | Unity Terrain API | Pre-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
| Interaction | API | Notes |
|---|---|---|
| Get height | Landscape.getWorldHeight(point) | Bilinear interpolation across tiles |
| Get normal | Landscape.getNormal(point) | Derived from neighboring height samples |
| Loaded event | Landscape.loaded | LevelRoads hooks to rebuild road meshes |
| Tile baking | Landscape.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:
addSpawnusesgetHeightfor tree base determination. - Road building: Road vertex Y = landscape height + offset.
- Spawn validation: Spawnpoints must be near terrain surface.
- Underground whitelist:
UndergroundAllowlist.AdjustPositionprevents 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 LODTwo separate trackers:
- regionTracker: Controls
SetIsActiveInRegionfor 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).
shouldInstantlyLoadcontrols immediate vs. deferred loading.
Dedicated servers skip terrain detail loading entirely — Dedicator.IsDedicatedServer gates the detail instantiation.
Common Issues
Height query mismatch: New landscapes use
getHeight; old terrain usesgetConversionHeightorterrain.SampleHeight. Calling the wrong method for the current terrain system returns incorrect heights (0 or infinity).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.Conversion performance:
ConvertLegacyTerrainis 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.Splatmap precision: Legacy splatmaps use
float[,,]with Unity's alphamap resolution. Converting to landscape material weights may introduce minor blending artifacts at biome boundaries.Foliage bake tree removal: If
handlePreBakeTileremoves 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 byteLandscapeBatching Interaction
When LevelBatching is enabled, terrain tiles interact with the batching system:
- Static batching: Landscape tiles that never change are combined into static batches.
- Dynamic exclusion: Tiles near player-placed structures are excluded from batching (structures modify the terrain).
- 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
PhysicMaterialCustomDatafor 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
Large map tree enumeration: On INSANE maps with hundreds of thousands of trees, the
_treeslegacy flat list causes GC pressure during serialization. Modern code uses_regionTreesregion dictionary exclusively.Skybox LOD update storm:
ForceUpdateSkyboxActiveiterates ALL trees when landmark quality changes. On large maps, this can cause a multi-frame spike.Conversion memory:
ConvertLegacyTerrainallocates temporary arrays of sizeresolution² × materialCountper 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);
}
}
}