Skip to content

Terrain Generation System

This document outlines the architecture for RentEarth’s world terrain generation system. The terrain pool manages procedural terrain chunks using shared FastNoise parameters between server and client, ensuring 1:1 terrain parity for this Dwarf Fortress/RimWorld-style MMO.


┌─────────────────────────────────────────────────────────────────┐
│ TERRAIN DATA FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SERVER CLIENT │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Supabase DB │ │ Title Screen │ │
│ │ world_config │───────────►│ ConnectResponse │ │
│ │ (terrain_config) │ proto │ terrain_config │ │
│ └──────────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌──────────────────┐ ┌────────▼─────────┐ │
│ │ FastNoise Lite │ │ FastNoise Lite │ │
│ │ (Rust) │ ═══════════│ (C#) │ │
│ │ Same params │ IDENTICAL │ Same params │ │
│ └──────────────────┘ └────────┬─────────┘ │
│ │ │
│ ┌──────────────────┐ ┌────────▼─────────┐ │
│ │ chunk_mods table │───────────►│ TerrainChunkCache│ │
│ │ (sparse deltas) │ on-demand │ (pre-generated) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘

  1. Deterministic Generation: Same FastNoise config produces identical terrain on server/client
  2. Pre-generation at Title Screen: Generate terrain while player is in menus
  3. Tiered Chunk Management: Hot (pre-gen), Warm (cached), Cold (on-demand)
  4. Pooled Renderers: Generic TerrainChunkRenderer GameObjects recycled via PoolService
  5. DB-backed Modifications: Player changes (terraforming) stored as sparse deltas
  6. 128x128 Chunks: Large chunks for MMO-scale terrain with mesh + collider

PropertyValueNotes
Chunk Size128x128 units~128m² per chunk
Vertex Resolution129x129For proper edge stitching
Core Region8x8 chunks64 chunks, ~1km² playable
Active Chunks3x3 = 9Around player
Pool Size12-16 renderers9 active + buffer

┌─────────────────────────────────────────────────────────────────────────────┐
│ WORLD STRUCTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────┐ │
│ │ CORE REGION (Pre-gen) │ │
│ │ ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ │
│ │ │ │ │ │ │ │ │ │ │ │ 8x8 = 64 chunks │
│ │ ├───┼───┼───┼───┼───┼───┼───┼───┤ │ @ 128x128 each │
│ │ │ │ │ S │ S │ S │ │ │ │ │ = 1024x1024 world units │
│ │ ├───┼───┼───┼───┼───┼───┼───┼───┤ │ │
│ │ │ │ │ S │ T │ S │ │ │ │ │ S = Spawn areas │
│ │ ├───┼───┼───┼───┼───┼───┼───┼───┤ │ T = Town/hub │
│ │ │ │ │ S │ S │ S │ │ │ │ │ │
│ │ ├───┼───┼───┼───┼───┼───┼───┼───┤ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │
│ │ ├───┼───┼───┼───┼───┼───┼───┼───┤ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │
│ │ ├───┼───┼───┼───┼───┼───┼───┼───┤ │ │
│ │ │ │ │ │ │ │ │ │ │ │ │
│ │ └───┴───┴───┴───┴───┴───┴───┴───┘ │ │
│ └─────────────────────────────────────┘ │
│ │
│ EXPANSION REGIONS (Generated on-demand, cached) │
│ - Generated when player approaches edge │
│ - Cached after first generation │
│ - Evicted via LRU when memory pressure │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

TierWhen GeneratedCache PolicyExample
HotServer/Client startupAlways in memorySpawn zones, towns, main paths
WarmFirst player enters regionLRU cache, persist to diskAdjacent regions, quest areas
ColdOn-demandGenerate → use → evictFar exploration areas

┌─────────────────────────────────────────────────────────────────────────────┐
│ ACTIVE CHUNK GRID (3x3 around player) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────┬─────┬─────┐ │
│ │ LOD1│ LOD1│ LOD1│ LOD Levels: │
│ ├─────┼─────┼─────┤ - LOD0: Full detail (player chunk) │
│ │ LOD1│ P │ LOD1│ - LOD1: Medium (adjacent 8 chunks) │
│ ├─────┼─────┼─────┤ - Culled: Beyond 3x3 grid │
│ │ LOD1│ LOD1│ LOD1│ │
│ └─────┴─────┴─────┘ P = Player chunk (LOD0) │
│ │
│ Chunk Size: 128x128 units (129x129 vertices) │
│ Total Active: 9 chunks │
│ View Range: ~384 units (3 chunks × 128 units) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│ TERRAIN GENERATION PIPELINE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ TerrainSeed │────►│ NoiseGenerator │────►│ HeightmapData │ │
│ │ (from server) │ │ (Perlin/Simplex)│ │ (float[,]) │ │
│ └─────────────────┘ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BiomeMapper │────►│ TerrainMesher │────►│ ChunkMesh │ │
│ │ (biome at pos) │ │ (mesh from hm) │ │ (GameObject) │ │
│ └─────────────────┘ └─────────────────┘ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ObjectPlacer │────►│ Pool Oracle │────►│ Environment │ │
│ │ (spawn points) │ │ (prefab pool) │ │ Objects │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Pre-generate terrain during the title screen to eliminate load times when entering the game.

┌─────────────────────────────────────────────────────────────────┐
│ STARTUP FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ TITLE SCREEN GAME SCENE │
│ ┌─────────────────────────┐ ┌─────────────────────┐ │
│ │ │ │ │ │
│ │ 1. Connect to server │ │ 5. Spawn player │ │
│ │ ↓ │ │ ↓ │ │
│ │ 2. Receive terrain_cfg │ │ 6. Get 9 chunks │ │
│ │ (ConnectResponse) │ ────► │ from cache │ │
│ │ ↓ │ │ ↓ │ │
│ │ 3. Pre-gen 64 chunks │ │ 7. Pull renderers │ │
│ │ (background) │ │ from pool │ │
│ │ ↓ │ │ ↓ │ │
│ │ 4. Prewarm terrain │ │ 8. Instant terrain │ │
│ │ pool (renderers) │ │ │ │
│ │ │ │ │ │
│ └─────────────────────────┘ └─────────────────────┘ │
│ │
│ Player sees: Menu/News Player sees: World │
│ Actually doing: Terrain gen Actually doing: Cache hit │
│ │
└─────────────────────────────────────────────────────────────────┘

RootLifetimeScope (App Start, DontDestroyOnLoad)
├── TerrainChunkCache (CPU-side mesh data cache)
├── TerrainService (FastNoise generation)
├── PoolService (object pool logic)
└── PoolBehaviour (MonoBehaviour containers)
TitleLifetimeScope (Title Screen)
├── Receives ConnectResponse with terrain_config
├── Triggers TerrainService.PreGenerateHotChunksAsync()
├── Prewarms terrain renderer pool
└── Shows loading progress to player (optional)
GameLifetimeScope (Game Scene, DontDestroyOnLoad)
├── CameraManager (Cinemachine)
├── TerrainStreamManager (future - pulls from cache)
└── Instant terrain display (no generation)

Why Pool + Terrain in RootLifetimeScope?

  • Pool containers exist before game scene loads
  • Terrain pre-generation can start during title screen
  • Objects persist across scene transitions
  • TerrainService can immediately use PoolService

public enum BiomeType
{
Grassland, // Default, flat-ish with grass
Forest, // Dense trees, rolling hills
Desert, // Sandy, sparse vegetation
Mountain, // Rocky, steep terrain
Swamp, // Low, wet, murky
Tundra, // Cold, sparse, flat
Coast // Beach transition zones
}
[CreateAssetMenu(fileName = "BiomeConfig", menuName = "RentEarth/Terrain/Biome Config")]
public class BiomeConfig : ScriptableObject
{
public BiomeDefinition[] biomes;
[Header("Biome Mapping")]
public float temperatureScale = 200f; // World units per temperature cycle
public float moistureScale = 150f; // World units per moisture cycle
}
[Serializable]
public class BiomeDefinition
{
public BiomeType type;
public string displayName;
[Header("Climate Range")]
[Range(0, 1)] public float minTemperature;
[Range(0, 1)] public float maxTemperature;
[Range(0, 1)] public float minMoisture;
[Range(0, 1)] public float maxMoisture;
[Header("Terrain Modifiers")]
public float heightScale = 1f; // Multiply base height
public float roughness = 1f; // Additional noise detail
[Header("Object Spawning")]
public BiomeSpawnTable spawnTable; // What objects spawn here
}

Biome Selection (Temperature/Moisture Mapping)

Section titled “Biome Selection (Temperature/Moisture Mapping)”
public BiomeType GetBiomeAt(float worldX, float worldY)
{
// Generate temperature and moisture from noise
float temperature = GetTemperature(worldX, worldY);
float moisture = GetMoisture(worldX, worldY);
// Find matching biome
foreach (var biome in _config.biomes)
{
if (temperature >= biome.minTemperature &&
temperature <= biome.maxTemperature &&
moisture >= biome.minMoisture &&
moisture <= biome.maxMoisture)
{
return biome.type;
}
}
return BiomeType.Grassland; // Default
}

When terrain chunks go out of view and come back, we must NOT destroy and recreate the terrain mesh. This causes:

  • Mesh Generation Cost: Building vertices, triangles, normals is expensive
  • Collider Baking: MeshCollider.sharedMesh assignment triggers physics baking
  • GC Pressure: Mesh allocations create garbage
  • Stutter: Frame drops when generating new chunks

Terrain chunks are pooled just like environment objects. When a chunk goes out of view, its GameObject is deactivated and returned to the pool - the mesh stays intact.

┌─────────────────────────────────────────────────────────────────────────────┐
│ TERRAIN CHUNK POOLING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Player moves from chunk (0,0) to (1,0): │
│ │
│ BEFORE AFTER │
│ ┌─────┬─────┬─────┐ ┌─────┬─────┬─────┐ │
│ │-1,-1│ 0,-1│ 1,-1│ │ 0,-1│ 1,-1│ 2,-1│ │
│ ├─────┼─────┼─────┤ ├─────┼─────┼─────┤ │
│ │-1,0 │ P │ 1,0 │ ───────► │ 0,0 │ P │ 2,0 │ │
│ ├─────┼─────┼─────┤ ├─────┼─────┼─────┤ │
│ │-1,1 │ 0,1 │ 1,1 │ │ 0,1 │ 1,1 │ 2,1 │ │
│ └─────┴─────┴─────┘ └─────┴─────┴─────┘ │
│ │
│ Chunks (-1,-1), (-1,0), (-1,1) → POOL (not destroyed!) │
│ Chunks (2,-1), (2,0), (2,1) ← POOL or GENERATE if new │
│ │
│ CHUNK POOL: │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ [ChunkGO] [ChunkGO] [ChunkGO] [ChunkGO] ← Inactive, mesh preserved │ │
│ │ ↓ ↓ ↓ ↓ │ │
│ │ Mesh Mesh Mesh Mesh ← NO regeneration needed! │ │
│ │ Collider Collider Collider Collider ← NO rebaking needed! │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Terrain objects use the existing Pool Oracle system. When chunks unload, objects return to the pool. When chunks reload, objects are retrieved from the pool (not instantiated).

┌─────────────────────────────────────────────────────────────────────────────┐
│ TERRAIN OBJECT LIFECYCLE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ CHUNK LOADS CHUNK UNLOADS │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ TerrainChunk │ │ TerrainChunk │ │
│ │ .Activate() │ │ .Deactivate() │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ChunkObjectMgr │ │ ChunkObjectMgr │ │
│ │ .SpawnObjects() │ │ .DespawnObjects() │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ PoolService │ │ PoolService │ │
│ │ .Get(prefab) │◄────────────────►│ .Return(obj) │ │
│ │ (from pool!) │ RECYCLED │ (to pool!) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Objects are NEVER destroyed - only deactivated and returned to pool │
│ │
└─────────────────────────────────────────────────────────────────────────────┘

Natural object distribution using Poisson disk sampling for even spacing:

public List<Vector2> PlaceObjects(
TerrainChunk chunk,
BiomeSpawnTable spawnTable,
float[,] heightmap)
{
var placed = new List<PlacedObject>();
foreach (var entry in spawnTable.entries)
{
// Calculate target count based on density
float chunkArea = chunk.WorldBounds.size.x * chunk.WorldBounds.size.z;
int targetCount = Mathf.RoundToInt(chunkArea * entry.density);
// Poisson disk sampling (deterministic with chunk seed)
var spawnPoints = PoissonDiskSampling.Generate(
chunk.WorldBounds,
entry.minSpacing,
targetCount,
chunk.ChunkCoord.GetHashCode()
);
foreach (var point in spawnPoints)
{
// Check height/slope constraints
if (!ValidatePlacement(point, entry, heightmap)) continue;
// Spawn from pool (NOT instantiate!)
var obj = _poolService.Get(entry.poolKey, point.Position, point.Rotation);
placed.Add(obj);
}
}
return placed;
}
[Serializable]
public class SpawnEntry
{
public string prefabId; // Pool Oracle prefab ID
public float density; // Objects per unit area
public float minSpacing; // Minimum distance between objects
[Header("Placement Rules")]
public float minHeight; // Terrain height range
public float maxHeight;
public float minSlope; // Terrain slope range (degrees)
public float maxSlope;
[Header("Variation")]
public float scaleMin = 0.8f;
public float scaleMax = 1.2f;
public bool randomRotationY = true;
}

Per Chunk (128x128, LOD0 = 129x129 vertices)

Section titled “Per Chunk (128x128, LOD0 = 129x129 vertices)”
DataCalculationSize
Vertices129×129 × 12 bytes (Vector3)~200KB
Triangles128×128×2×3 × 4 bytes (int)~393KB
Normals129×129 × 12 bytes~200KB
UVs129×129 × 8 bytes (Vector2)~133KB
Total~925KB per chunk
ResourceAllocation
64 Hot chunks pre-generated~60MB mesh data (CPU cache)
9 Active chunks on GPU~8MB VRAM
Total heap (typical)256-512MB
LRU eviction threshold> 100 chunks cached

LOD LevelStepResolution (128x128)TrianglesUse Case
LOD01129x129 = 16,641 verts32,768Player chunk
LOD1265x65 = 4,225 verts8,192Adjacent chunks
LOD2433x33 = 1,089 verts2,048Distant chunks

-- Single row per world, stores terrain generation parameters
CREATE TABLE rentearth.world_config (
world_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
world_seed BIGINT NOT NULL,
terrain_config BYTEA NOT NULL, -- Serialized WorldTerrainConfig proto
chunk_size INTEGER NOT NULL DEFAULT 128,
world_bounds INTEGER[] NOT NULL, -- [min_x, max_x, min_z, max_z] in chunks
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Sparse storage: only chunks with player modifications
CREATE TABLE rentearth.chunk_modifications (
chunk_x INTEGER NOT NULL,
chunk_z INTEGER NOT NULL,
world_id UUID NOT NULL REFERENCES rentearth.world_config(world_id),
mod_data BYTEA NOT NULL, -- Serialized ChunkModifications proto
modified_at TIMESTAMPTZ DEFAULT NOW(),
modified_by UUID REFERENCES auth.users(id),
PRIMARY KEY (world_id, chunk_x, chunk_z)
);

[Serializable]
public class NoiseSettings
{
[Header("Base Noise")]
public float baseScale = 50f; // World units per noise period
public int octaves = 4; // Number of noise layers
public float persistence = 0.5f; // Amplitude reduction per octave
public float lacunarity = 2f; // Frequency increase per octave
[Header("Height")]
public float heightMultiplier = 20f; // Max terrain height
public AnimationCurve heightCurve; // Remap noise to height
[Header("Variation")]
public float domainWarpStrength = 0f; // Warping for organic look
public Vector2 offset; // Global offset
}

Based on code review, the following improvements are recommended for the TerrainManager:

  1. Budgeted Streaming: Spawn/despawn with per-frame limits to prevent frame spikes
  2. Near-to-Center Ordering: Prioritize spawning chunks closest to player first
  3. Deterministic RNG: Use seeded random for reproducible title screen scenes
  4. Thread-Safe Generation: Off-thread mesh generation for non-WebGL builds
  5. Layer Enforcement: Ensure terrain layer is set recursively on chunk objects
  6. Hysteresis: Slightly larger “loaded radius” than “visible radius” to reduce churn
public int SpawnBudgetPerFrame { get; set; } = 2;
public int DespawnBudgetPerFrame { get; set; } = 4;
private async UniTaskVoid StreamingLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
// Despawn first (frees pool objects quickly)
for (int i = 0; i < DespawnBudgetPerFrame && _despawnQueue.Count > 0; i++)
{
var coord = _despawnQueue.Dequeue();
DespawnChunkInternal(coord);
}
// Spawn next (near-to-center order)
for (int i = 0; i < SpawnBudgetPerFrame && _spawnQueue.Count > 0; i++)
{
var coord = _spawnQueue.Dequeue();
await SpawnChunkAsync(coord, token);
}
await UniTask.Yield(token);
}
}

public static class WebGLTerrainOptimizer
{
public const int MaxTerrainMemoryMB = 32;
public const int MaxActiveChunks = 9; // 3x3 grid
public const int MaxPooledChunks = 4; // Minimal pool
public static TerrainSettings GetWebGLSettings()
{
return new TerrainSettings
{
ChunkSize = 128,
HeightmapResolution = 129,
ViewDistance = 1, // 3x3 grid
MaxLODLevel = 1, // Only LOD0 and LOD1
UseSharedMaterials = true,
UseGPUInstancing = true,
CompressMeshes = true,
MaxObjectsPerChunk = 20,
ObjectLODBias = 0.5f
};
}
}

ComponentStatusNotes
TerrainService✅ CompleteFastNoise generation
TerrainChunkCache✅ CompleteCPU-side mesh caching
TerrainManager✅ CompleteMono mode streaming
TerrainStreamingManager✅ CompleteECS mode streaming
Pool Integration✅ CompleteVia PoolService
Biome System🔲 PlannedTemperature/moisture mapping
Object Placement🔲 PlannedPoisson disk sampling
DB Persistence🔲 Plannedchunk_modifications table

QuestionNotes
Biome BlendingHard edges or smooth transitions between biomes?
Collision DetailShould collision mesh match visual LOD or always high detail?
WaterSeparate water plane system or integrated into terrain chunks?
Caves/OverhangsSupport for 3D terrain features or strictly heightmap-based?
FastNoise VersionEnsure Rust and C# FastNoise produce identical output
World ExpansionMigration strategy for new chunks after launch


  • proto/rentearth/terrain.proto - WorldTerrainConfig, TerrainNoiseConfig, ChunkModifications
  • proto/rentearth/pool.proto - POOL_CATEGORY_TERRAIN enum
  • proto/rentearth/snapshot.proto - ConnectResponse.terrain_config

FilePurpose
Assets/Scripts/Terrain/TerrainService.csFastNoise generation, chunk caching
Assets/Scripts/Terrain/TerrainManager.csMono mode streaming
Assets/Scripts/Terrain/TerrainStreamingManager.csECS mode streaming
Assets/Scripts/Terrain/TerrainChunkCache.csCPU-side mesh data cache
Assets/Scripts/Terrain/TerrainChunkBehaviour.csChunk MonoBehaviour