Skip to content
Edit on GitHub

Terrain generation

import BiomeClimateMatrix from ‘../../components/BiomeClimateMatrix.astro’;

verifiedAgainst f1eaefe · verifiedOn 2026-09-10 · the automated staleness banner is planned, not built.

Terrain is not generated per chunk in the conceptual sense. It is a pure function of world seed, generator version, settings, and absolute world coordinate; chunks are only 16³ windows that evaluate that function in parallel. WorldGenerationSettings is the authored, versioned ScriptableObject; TerrainGenerationParameters.FromSettings() flattens it into a blittable snapshot for Burst. The managed TerrainGenerator is the readable parity oracle for tools and direct queries. The streamed path precomputes each relevant X/Z column’s climate and surface height, discovers deterministic tree roots, then runs TerrainGenerationJob once per voxel. Both paths use the same coordinate-hashed noise, salt values, operation order, geology rules, and feature precedence. Saved edits are deliberately absent from this page’s pure base-world answer: streaming applies WorldEditOverlay after the job has returned ChunkData.

High-altitude aerial survey of the procedural voxel world Figure 1: High-altitude survey of world seed -1L rendered in HDRP 17.6.0 with physical sunlight (EV 13.7). Snow peaks, mountain ridges, and river reaches generated purely from coordinate-hashed Burst jobs.

Stratified terracotta and clay geological layers in the badlands biome Figure 2: Geological strata in the Badlands biome. Continuous horizontal sedimentary banding resolved deterministically across chunk seams without seam discontinuities.

Symbol File Responsibility
WorldGenerationSettings Data/Generation/WorldGenerationSettings.cs:11 Versioned authoring data: seed, profile knobs, palette, biome catalogue, and tree catalogue. Changing it changes the base world.
TerrainGenerationParameters.FromSettings World/Generation/TerrainGenerationParameters.cs:232 Converts managed authoring references into scalar fields and stable block ids that a Burst job may safely read.
TerrainColumnMath World/Generation/TerrainColumnMath.cs:18 Shared, Burst-safe climate, macro-relief, river, noise, hash, and negative-coordinate math.
BiomeClassifier World/Generation/BiomeClassifier.cs:11 Managed climate-window classifier; the Burst equivalent is TerrainColumnMath.ClassifyBiomeIndex.
TerrainGenerator World/Generation/TerrainGenerator.cs:19 Managed oracle for GenerateChunk, direct block queries, editor tooling, and test assertions.
TerrainColumnJob / TerrainColumnSample World/Generation/TerrainColumnJob.cs:19 · TerrainColumnSample.cs:3 Burst pre-pass: surface height and biome index for the chunk and a ten-block X/Z margin.
TreeRootDiscoveryJob World/Generation/TreeRootDiscoveryJob.cs:20 Finds each valid cell-owned tree root within canopy reach exactly once.
TerrainGenerationJob World/Generation/TerrainGenerationJob.cs:19 Burst IJobParallelFor that maps every local voxel index to one stable ushort block id.
OreDistributionMath World/Generation/OreDistributionMath.cs:46 Shared deposit predicate: depth window, host-rock/fracture signal, biome exoticness, and sparse outlier districts.
ChunkStreamingRuntime.ProcessGeneration World/Presentation/ChunkStreamingRuntime.cs:404 Schedules the three jobs with dependencies, then turns completed output into a ChunkData and applies the edit overlay.
flowchart LR
  S["WorldGenerationSettings\nseed + generator version + authored catalogues"] --> P["TerrainGenerationParameters\nblittable snapshot"]
  P --> C["TerrainColumnJob\nheight + biome, 16x16 + margin"]
  C --> T["TreeRootDiscoveryJob\ncell-owned roots in reach"]
  T --> G["TerrainGenerationJob\nparallel voxel evaluation"]
  C --> G
  G --> D["ChunkData\nbase generated blocks"]
  D --> E["WorldEditOverlay\nsparse player changes last"]
  E --> M["Chunk meshing + view pool\npresentation only"]
  P -. same fields/salts/order .-> O["TerrainGenerator\nmanaged oracle + tools/tests"]

The runtime takes a fresh parameter snapshot before scheduling work. It allocates the column table, schedules TerrainColumnJob; schedules TreeRootDiscoveryJob after that handle; then schedules TerrainGenerationJob after the tree handle (ChunkStreamingRuntime.cs:413). There is no synchronous gap between those jobs. On completion, the runtime copies the flat ushort output to ChunkData, applies WorldEditOverlay, retains it under the chunk coordinate, and refreshes affected meshes (ChunkStreamingRuntime.cs:458).

WorldGenerationSettings owns the seed and generatorVersion along with all tuning fields (WorldGenerationSettings.cs:5). It also points to the GenerationBlockPalette, BiomeCatalog, and TreeSpeciesCatalog. None may leak into a worker job. FromSettings() resolves those catalogues to scalar configuration and stable ushort block ids, including one surface block and tree-shape record per known biome/species.

This is why a terrain change has two parts: author the value in Data, then make it part of TerrainGenerationParameters. Adding a field only to WorldGenerationSettings makes it invisible to Burst; adding one only to the snapshot makes it impossible to author. A base-world-affecting change also needs an intentional generator-version compatibility decision; a saved world records the seed and generator version, rather than serializing all generated terrain.

2. X/Z context: climate, relief, and rivers

Section titled “2. X/Z context: climate, relief, and rivers”

For every world X/Z column, the code first derives a biome and final surface height. Climate is two independent coordinate-hashed fields: temperature and moisture. The managed classifier tests the authored BiomeCatalog.ClassificationOrder windows and falls back to Plains, so catalogue order is part of the classification contract (BiomeClassifier.cs:16). The Burst path produces the same choice as a compact 0–8 index, not a BiomeType flags value (TerrainColumnMath.cs:45).

Uncarved height is the sum of a base height, continent signal, biome/mountain uplift, peakness, tectonic ridge, structural basin, broad gradient noise, and detail gradient noise. Erosion changes the relief amplitude rather than selecting a biome. Rugged Badlands quantize the result into terraces (TerrainColumnMath.cs:120). Five separate macro signals—continentalness, erosion, peakness, tectonic ridge, and basin depth—are sampled before biome decoration (TerrainColumnMath.cs:154).

River routing deliberately queries uncarved height to choose viable highland springs and sea-level mouths; it cannot recursively consult a river it is deciding to create. The selected route supplies both a narrow channel and a broader dry valley carve. The final GetSurfaceHeight applies those carves after the base height (TerrainColumnMath.cs:102).

3. Why the column and tree pre-passes exist

Section titled “3. Why the column and tree pre-passes exist”

A 16³ chunk has 4,096 voxels but only 256 own X/Z columns. Re-evaluating climate and height once per voxel would repeat Y-independent noise sixteen times per column. TerrainColumnJob computes the 16×16 footprint plus a margin of ten blocks on each side, a 36×36 table, so nearby tree-root and slope queries can reuse it (TerrainColumnJob.cs:19). Queries outside that deliberately generous table compute directly through the same TerrainColumnMath, so the margin is an optimization rather than a different generation rule.

Trees are owned by deterministic cells, not by whichever chunk happens to load first. TreeRootDiscoveryJob searches every root cell whose canopy might touch the chunk, rejects candidates in river channels, wrong biomes, underwater terrain, or non-flat ground, then chooses an allowed species and trunk height from stable hashes (TreeRootDiscoveryJob.cs:52). The voxel job only tests each voxel against that small discovered-root list. That is both faster and the rule that prevents seams, duplicate trees, and missing canopies at chunk borders.

The following is the existing order inside TerrainGenerationJob.GetGeneratedBlock, not a suggested order (TerrainGenerationJob.cs:83). A new pass needs an explicit place in this ladder; inserting it casually changes what it can overwrite.

  1. Below Y 0: air. The bedrock floor itself is at Y 0.
  2. Hidden structure: if a valid buried discovery owns this voxel, return its block before ordinary terrain.
  3. Above surface: ocean water fills low terrain to sea level; river water fills a carved channel; a qualifying column gets a one-block snow cap; otherwise air.
  4. At or below surface: bedrock at Y 0; then caves, organic caverns, and ravines carve to air while respecting floor/surface clearance.
  5. Surface material: exposed rock wins on non-sandy steep slopes; otherwise coordinate-stable sand sediment or the biome’s authored surface block.
  6. Subsurface: Badlands gets its sand/stone bands; then sand or dirt filler to its stable depth; deeper material resolves to a natural deposit or stone.
  7. Above-ground features: a tree may replace a snow-cap block; a landmark may occupy otherwise-air; then a tree trunk/leaves may occupy otherwise-air.

The core terrain branch is visible together in TerrainGenerationJob.cs:158. The managed path uses the same precedence in TerrainGenerator.GetGeneratedBlockForColumn; when the two implementations disagree, that is a correctness bug, not an acceptable preview difference.

5. Caves, deposits, and grade are separate questions

Section titled “5. Caves, deposits, and grade are separate questions”

Granular caves use 3D value noise under a threshold, bounded away from the bedrock floor and surface. Organic caverns and biome-limited ravines are additional carve predicates; any one returns air before sediment or ore selection. A cave does not delete an ore record—it means that coordinate’s final base block is air.

Deep geology starts only after filler depth. OreDistributionMath checks profiles rarest first, so overlap deterministically chooses the more valuable material (OreDistributionMath.cs:126). A profile has a vertical likelihood band, host-rock and fracture fields, a biome-exoticness penalty, and a sparse outlier district that can make an otherwise poor local occurrence. The resolver then maps OreDepositKind to the profile’s stable palette ids (TerrainGenerationJob.cs:221). Only natural geological materials appear in this list—never refined products.

Ore grade is not stored in a voxel or a second block id. TerrainGenerator.GetOreGradeAt first asks whether a deposit body occurs there, then computes the pure OreGradeMath field on demand; it returns zero outside a body (TerrainGenerator.cs:82). That separation lets the same visual ore body contain a lean halo and richer core without making chunks carry an extra channel.

Terrain generation is one section of a larger ownership chain. The important fact is that information moves in one direction at each boundary: authored settings become an immutable job snapshot; job output becomes mutable loaded-world data; that data becomes a view/collider; player actions become sparse overrides; a save records only those overrides. No presentation component writes directly into the generator, and no generator pass reaches into a scene object.

sequenceDiagram
  participant S as GameSessionRuntime\nslot + seed owner
  participant W as ChunkStreamingRuntime\nloaded-world owner
  participant Q as ChunkScheduler\nversioned work policy
  participant J as Generation jobs\ncolumn → roots → voxels
  participant D as ChunkData + overlay\nworld model
  participant M as Meshing job\nvisible-face data
  participant V as ChunkView pool\nmesh + collider
  participant G as Gameplay/presentation\npublic world API
  S->>W: configure selected/saved seed
  W->>Q: apply desired chunk plan
  Q->>J: begin token-budgeted generation
  J-->>W: completed base blocks + token
  W->>D: copy output, apply overrides
  W->>M: snapshot blocks + six borders
  M-->>W: visible faces
  W->>V: upload mesh, collider, activate
  W-->>G: ChunkActivated / GetBlockOrAir
  G->>W: validated edit or query
  W->>D: mutate loaded data + delta
  S->>D: save/load sparse overrides

1. A slot chooses the world before any chunk exists. GameSessionRuntime asks the save service whether the selected slot has metadata. For a new slot it converts a user seed, calls ConfigureWorldSeed() before streaming has started, creates an empty WorldSaveData, and records its internal seed and generator version. For an existing slot it installs the saved seed, then loads its edits (GameSessionRuntime.cs:184). ChunkStreamingRuntime.ConfigureWorldSeed() clones the inspector-authored settings before changing its seed and refuses to run once a plan, chunk, or job exists (ChunkStreamingRuntime.cs:181). That prevents one play session from mutating the authored asset—or from silently changing the formula underneath loaded data.

2. Player movement chooses which coordinates to evaluate, not what they contain. The focus transform drives the streaming plan. The scheduler returns versioned tokens under a per-frame budget; the runtime uses each token to schedule the generation chain described above. The generator itself only sees absolute coordinates and parameters. This separation is why walking quickly may defer a distant chunk, but can never make that chunk generate differently.

3. ChunkData is the hand-off from pure generation to the mutable loaded world. Completed job output is copied out of native memory, then the overlay is applied. Only after that does meshing see it. ChunkMeshingJob receives immutable snapshots of the 16³ blocks and all six one-voxel neighbor borders, emits only visible faces, and creates no Unity mesh (ChunkMeshingJob.cs:49). The main thread turns those faces into a mesh/collider and only raises ChunkActivated after the lifecycle reaches Active (ChunkStreamingRuntime.cs:585). Environment, spawn, audio, and presentation companions therefore react to an active public boundary rather than polling or owning the dictionaries themselves.

4. Gameplay reads and changes the loaded-world boundary, never a generator result. ChunkStreamingRuntime.GetBlockOrAir() returns from an already-loaded ChunkData and returns air outside the loaded set (ChunkStreamingRuntime.cs:337). A break/place request enters BlockEditService, which re-reads the raycast target and rejects a stale mesh hit before it calls IVoxelBlockWorld.TrySetBlock() (BlockEditService.cs:11). The runtime updates the loaded block, compares it against TerrainGenerator.GetGeneratedBlock(), records only a true difference in WorldEditOverlay, and queues remeshing for the edited chunk plus all six neighbors. The neighbors matter because one changed border voxel can expose or hide a face on either side.

5. Save/load closes the loop by preserving differences, not an enormous terrain snapshot. WorldEditOverlay.SetOverride() removes an entry if the player puts the generated block back; otherwise it records a local block id under its chunk coordinate (WorldEditOverlay.cs:21). It serializes deterministically in coordinate order. On load, TryRestoreWorldSaveData() rejects a mismatched seed or generator version, validates the sparse data, regenerates currently loaded chunks from the compatible generator, reapplies their overrides, then remeshes them (ChunkStreamingRuntime.cs:245). The result is the same player-modified world without ever treating a rendered mesh or a streamed chunk cache as authoritative save data.

  1. Base generation is coordinate-pure. Same seed, generator version, settings, and world coordinate must yield the same block regardless of chunk load order, player position, time, or worker scheduling. Never introduce UnityEngine.Random or mutable global RNG state.
  2. The managed and Burst paths are one algorithm. Preserve salts, rounding, precedence, biome-index mapping, and block resolution in both. TerrainGenerationJobTests explicitly compare job output with TerrainGenerator at negative coordinates and across representative settings (Tests/EditMode/World/TerrainGenerationJobTests.cs:16).
  3. All job configuration enters through TerrainGenerationParameters. No ScriptableObject, managed catalogue, or Unity object is legal in a Burst job. Extend FromSettings() and its tests whenever you add an authored generation input.
  4. Features are world-owned, never chunk-owned. Tree, landmark, river, cavern, and structure candidates derive from absolute coordinate cells and query beyond a chunk’s edge. Do not add a one tree per chunk shortcut.
  5. Keep biome indices distinct from BiomeType flags. The Burst/sediment index is a fixed 0–8 ordering; casting a flags enum would silently select the wrong surface or deposit behavior. The managed mapping documents this trap (TerrainGenerator.cs:289).
  6. Generated base terrain is never persisted. Apply sparse WorldEditOverlay changes after generation and serialize only those changes with seed/version metadata. Regeneration is the save format.
  7. World-gen deposits must be geological. Add a real ore, mineral, evaporite, clay, native element, or analogous natural occurrence; never spawn a refined alloy or manufactured chemistry block as terrain.
  • Authoring: GenerationBlockPalette, BiomeCatalog, and TreeSpeciesCatalog live in VoxelSandbox.Data; WorldGenerationSettings is their runtime-facing bundle. Catalogues & authoring explains the broader authored-data boundary.
  • Streaming: Chunk streaming owns job lifetime, plan priority, ChunkData, the edit-overlay application point, and subsequent mesh refresh. Terrain code itself knows nothing about a player or a view.
  • Meshing: the generated ChunkData and its neighboring boundary snapshots feed ChunkMeshingJob, then GreedyMeshMerger and ChunkView. Meshing is presentation; it must not affect a future base-world query.
  • Session, interaction, and saves: GameSessionRuntime chooses the seed / slot and orchestrates save/load; BlockEditService turns a validated raycast intent into a loaded-world mutation; WorldEditOverlay is the durable delta. The detailed loop is in §6 above.
  • Science and progression: OreDistributionMath determines where a deposit body exists; OreGradeMath supplies its measured grade to the mined-material and vessel chain. See Docs/MASTERPLAN.md §36.4a and §39.7–39.9 for the design contract behind that resource geography.
  • Tests and tools: TerrainGeneratorTests, TerrainGenerationJobTests, TerrainColumnJobTests, TreeRootDiscoveryJobTests, RiverFieldTests, and TerrainGeographyFieldsTests protect deterministic seams and parity. The Voxel Workshop’s World Studio is the intended tuning surface, not ad-hoc constants in jobs.
  • Chunk streaming — the runtime that schedules the generation pipeline and consumes its output.
  • Add a voxel block type — a worked example touching the palette, deposit ordering, managed resolver, Burst resolver, and generation tests together.
  • Synced Masterplan §8 (determinism and procedural-generation direction), §36.4a (ore-grade evidence chain), and §39 (chemical geography).

User-contributed notes

Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works: **bold**, *italic*, `code`, and links.

Notes policy

Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked nofollow.

  1. Loading notes…