Terrain Generation System
Terrain Generation System
Section titled “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.
Architecture Overview
Section titled “Architecture Overview”┌─────────────────────────────────────────────────────────────────┐│ 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) │ ││ └──────────────────┘ └──────────────────┘ ││ │└─────────────────────────────────────────────────────────────────┘Architecture Goals
Section titled “Architecture Goals”- Deterministic Generation: Same FastNoise config produces identical terrain on server/client
- Pre-generation at Title Screen: Generate terrain while player is in menus
- Tiered Chunk Management: Hot (pre-gen), Warm (cached), Cold (on-demand)
- Pooled Renderers: Generic
TerrainChunkRendererGameObjects recycled viaPoolService - DB-backed Modifications: Player changes (terraforming) stored as sparse deltas
- 128x128 Chunks: Large chunks for MMO-scale terrain with mesh + collider
Chunk Specifications
Section titled “Chunk Specifications”| Property | Value | Notes |
|---|---|---|
| Chunk Size | 128x128 units | ~128m² per chunk |
| Vertex Resolution | 129x129 | For proper edge stitching |
| Core Region | 8x8 chunks | 64 chunks, ~1km² playable |
| Active Chunks | 3x3 = 9 | Around player |
| Pool Size | 12-16 renderers | 9 active + buffer |
World Structure
Section titled “World Structure”┌─────────────────────────────────────────────────────────────────────────────┐│ 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 ││ │└─────────────────────────────────────────────────────────────────────────────┘Tiered Generation Strategy
Section titled “Tiered Generation Strategy”| Tier | When Generated | Cache Policy | Example |
|---|---|---|---|
| Hot | Server/Client startup | Always in memory | Spawn zones, towns, main paths |
| Warm | First player enters region | LRU cache, persist to disk | Adjacent regions, quest areas |
| Cold | On-demand | Generate → use → evict | Far exploration areas |
Active Chunk Grid (Runtime)
Section titled “Active Chunk Grid (Runtime)”┌─────────────────────────────────────────────────────────────────────────────┐│ 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) ││ │└─────────────────────────────────────────────────────────────────────────────┘Generation Pipeline
Section titled “Generation Pipeline”┌─────────────────────────────────────────────────────────────────────────────┐│ 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 │ ││ └─────────────────┘ └─────────────────┘ └─────────────────┘ ││ │└─────────────────────────────────────────────────────────────────────────────┘Startup Flow
Section titled “Startup Flow”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 ││ │└─────────────────────────────────────────────────────────────────┘Lifetime Scope Integration
Section titled “Lifetime Scope Integration”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
Biome System
Section titled “Biome System”Biome Types
Section titled “Biome Types”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}Biome Configuration
Section titled “Biome Configuration”[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}Chunk Pooling
Section titled “Chunk Pooling”The Problem
Section titled “The Problem”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
Solution: Chunk Mesh Pool
Section titled “Solution: Chunk Mesh Pool”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! │ ││ └────────────────────────────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────────────────────────┘Pool Oracle Integration
Section titled “Pool Oracle Integration”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 ││ │└─────────────────────────────────────────────────────────────────────────────┘Object Placement
Section titled “Object Placement”Poisson Disk Sampling
Section titled “Poisson Disk Sampling”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;}Spawn Entry Configuration
Section titled “Spawn Entry Configuration”[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;}Memory Budget
Section titled “Memory Budget”Per Chunk (128x128, LOD0 = 129x129 vertices)
Section titled “Per Chunk (128x128, LOD0 = 129x129 vertices)”| Data | Calculation | Size |
|---|---|---|
| Vertices | 129×129 × 12 bytes (Vector3) | ~200KB |
| Triangles | 128×128×2×3 × 4 bytes (int) | ~393KB |
| Normals | 129×129 × 12 bytes | ~200KB |
| UVs | 129×129 × 8 bytes (Vector2) | ~133KB |
| Total | ~925KB per chunk |
WebGL Budget
Section titled “WebGL Budget”| Resource | Allocation |
|---|---|
| 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 Mesh Resolution
Section titled “LOD Mesh Resolution”| LOD Level | Step | Resolution (128x128) | Triangles | Use Case |
|---|---|---|---|---|
| LOD0 | 1 | 129x129 = 16,641 verts | 32,768 | Player chunk |
| LOD1 | 2 | 65x65 = 4,225 verts | 8,192 | Adjacent chunks |
| LOD2 | 4 | 33x33 = 1,089 verts | 2,048 | Distant chunks |
Database Schema
Section titled “Database Schema”world_config Table
Section titled “world_config Table”-- Single row per world, stores terrain generation parametersCREATE 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());chunk_modifications Table
Section titled “chunk_modifications Table”-- Sparse storage: only chunks with player modificationsCREATE 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));Noise Generation
Section titled “Noise Generation”Multi-Octave Noise Configuration
Section titled “Multi-Octave Noise Configuration”[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}TerrainManager Updates
Section titled “TerrainManager Updates”Recommended Improvements
Section titled “Recommended Improvements”Based on code review, the following improvements are recommended for the TerrainManager:
- Budgeted Streaming: Spawn/despawn with per-frame limits to prevent frame spikes
- Near-to-Center Ordering: Prioritize spawning chunks closest to player first
- Deterministic RNG: Use seeded random for reproducible title screen scenes
- Thread-Safe Generation: Off-thread mesh generation for non-WebGL builds
- Layer Enforcement: Ensure terrain layer is set recursively on chunk objects
- Hysteresis: Slightly larger “loaded radius” than “visible radius” to reduce churn
Streaming Budget Example
Section titled “Streaming Budget Example”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); }}WebGL Optimizations
Section titled “WebGL Optimizations”Memory Constraints
Section titled “Memory Constraints”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 }; }}Implementation Status
Section titled “Implementation Status”| Component | Status | Notes |
|---|---|---|
| TerrainService | ✅ Complete | FastNoise generation |
| TerrainChunkCache | ✅ Complete | CPU-side mesh caching |
| TerrainManager | ✅ Complete | Mono mode streaming |
| TerrainStreamingManager | ✅ Complete | ECS mode streaming |
| Pool Integration | ✅ Complete | Via PoolService |
| Biome System | 🔲 Planned | Temperature/moisture mapping |
| Object Placement | 🔲 Planned | Poisson disk sampling |
| DB Persistence | 🔲 Planned | chunk_modifications table |
Open Questions
Section titled “Open Questions”| Question | Notes |
|---|---|
| Biome Blending | Hard edges or smooth transitions between biomes? |
| Collision Detail | Should collision mesh match visual LOD or always high detail? |
| Water | Separate water plane system or integrated into terrain chunks? |
| Caves/Overhangs | Support for 3D terrain features or strictly heightmap-based? |
| FastNoise Version | Ensure Rust and C# FastNoise produce identical output |
| World Expansion | Migration strategy for new chunks after launch |
Related Documents
Section titled “Related Documents”- Pool Oracle System - Prefab pooling and entity management
- ECS Architecture - Entity Component System design
- Game Design Document - Core architecture overview
Proto Definitions
Section titled “Proto Definitions”proto/rentearth/terrain.proto- WorldTerrainConfig, TerrainNoiseConfig, ChunkModificationsproto/rentearth/pool.proto- POOL_CATEGORY_TERRAIN enumproto/rentearth/snapshot.proto- ConnectResponse.terrain_config
Files Reference
Section titled “Files Reference”| File | Purpose |
|---|---|
Assets/Scripts/Terrain/TerrainService.cs | FastNoise generation, chunk caching |
Assets/Scripts/Terrain/TerrainManager.cs | Mono mode streaming |
Assets/Scripts/Terrain/TerrainStreamingManager.cs | ECS mode streaming |
Assets/Scripts/Terrain/TerrainChunkCache.cs | CPU-side mesh data cache |
Assets/Scripts/Terrain/TerrainChunkBehaviour.cs | Chunk MonoBehaviour |