Add a procedural biome
import BiomeClimateMatrix from ‘../../../components/BiomeClimateMatrix.astro’;
verifiedAgainst 625483a · verifiedOn 2026-09-10.
Terrain in Voxamine is classified through a two-dimensional continuous climate coordinate plane: Temperature ($T \in [-1.0, +1.0]$) and Moisture ($M \in [-1.0, +1.0]$). Every voxel column is sampled deterministically from the world seed, and resolved against a sequential chain of climate envelopes in BiomeCatalog.ClassificationOrder.
Adding a biome requires understanding how carve-outs work: specialized biomes steal territory from wider parent biomes. If their priority is inverted, the parent biome swallows the specialized biome and it will never generate in the world.
This guide walks through creating and wiring a brand-new procedural biome end to end, then distills the pattern into a checklist.
What you will build
Section titled “What you will build”The Volcanic Basalt Shelf (BiomeType.BasaltShelf) — a severe, high-temperature, low-moisture geological formation featuring exposed volcanic stone plateaus:
- Climate Window: High temperature ($T \in [0.7, 1.0]$), extreme aridity ($M \in [-1.0, -0.4]$).
- Territory Origin: Carved out of the hottest, driest corner of Badlands and Desert.
- Surface Material:
BiomeSurfaceBlock.Stone(dark volcanic basalt rock instead of sand or grass). - Topography: Elevated plateau formation ($1.4\times$ height amplitude multiplier).
- Vegetation: Barren ($0.0\times$ tree density multiplier).
- Weather Response: Arid susceptibility ($0.04$), never freezes.
- Ambience Bed: Dry gusting wind with elevated nocturnal presence.
Where this sits
Section titled “Where this sits”Terrain classification connects the pure generation math with runtime environment profiles:
flowchart TD
SEED["World Seed + Salts"] --> NOISE["DeterministicValueNoise.Sample2D\nTemperature & Moisture in [-1, +1]"]
NOISE --> CLASSIFIER["BiomeClassifier.Classify()\nevaluates BiomeCatalog.ClassificationOrder"]
subgraph Data ["VoxelSandbox.Data"]
CATALOG["BiomeCatalog\nauthored windows & precedence order"]
PALETTE["GenerationBlockPalette\nsurface block & color tint resolution"]
end
CATALOG --> CLASSIFIER
CLASSIFIER --> BURST["TerrainColumnMath & TerrainGenerationJob\nBurst-compiled sequential biome index (0..9)"]
PALETTE --> BURST
BURST --> VOXELS["IVoxelBlockWorld\nfinal block placement & meshing"]
CLASSIFIER --> WEATHER["BiomeWeatherProfile\nprecipitation susceptibility & freezing"]
CLASSIFIER --> AUDIO["BiomeAmbienceProfile\nspatial audio looping beds"]
classDef core fill:#1e1e24,stroke:#e58a63,stroke-width:1px,color:#f8f9fa;
classDef data fill:#0c4f48,stroke:#6fd8c6,stroke-width:1px,color:#f8f9fa;
class SEED,NOISE,CLASSIFIER,BURST,VOXELS core;
class CATALOG,PALETTE,WEATHER,AUDIO data;
Invariants
Section titled “Invariants”- Carve-out precedence order: A child envelope that steals territory from a parent envelope must be listed before the parent in
BiomeCatalog.ClassificationOrder.Plainsspans $[-1.0, +1.0]$ on both axes and is checked last as the universal fallback. - Deterministic noise range: Temperature and moisture noise samples are bounded in $[-1.0, +1.0]$. A window outside this range will never match.
- Sequential indices vs. Flags enum: In managed C#,
BiomeTypeis a[Flags]bitmask enum (1 << 0,1 << 1, etc.) so it can be combined for tree species masks. In Burst jobs and sediment calculations, biomes use a sequential compact index (0..N). Never cast(int)BiomeTypeto get a job index.
Before you start
Section titled “Before you start”Reading manifest
Section titled “Reading manifest”Read these files at pinned commit 625483a before authoring:
| # | File | Symbol / What to extract |
|---|---|---|
| 1 | Assets/_Game/Scripts/Data/Generation/BiomeType.cs |
BiomeType flags values and BiomeSurfaceBlock enum entries. |
| 2 | Assets/_Game/Scripts/Data/Generation/BiomeDefinition.cs |
Matches(temp, moisture) bounding checks and Validate() clamping. |
| 3 | Assets/_Game/Scripts/Data/Generation/BiomeCatalog.cs |
ClassificationOrder array order and default parameter construction. |
| 4 | Assets/_Game/Scripts/World/Generation/TerrainColumnMath.cs |
ClassifyBiomeIndex Burst implementation and priority order. |
| 5 | Assets/_Game/Scripts/World/Generation/TerrainGenerator.cs |
GetSedimentBiomeIndex mapping from BiomeType flag to sequential index. |
| 6 | Assets/_Game/Scripts/World/Environment/BiomeWeatherProfile.cs |
Atmospheric weather response table. |
| 7 | Assets/_Game/Scripts/Player/BiomeAmbienceProfile.cs |
Audio ambience bed assignments and diurnal gains. |
| 8 | Assets/_Game/Tests/EditMode/World/BiomeClassifierTests.cs |
Representative area classification and carve-out non-regression tests. |
Intake decisions
Section titled “Intake decisions”| Decision | Choice | Why |
|---|---|---|
| Enum Flag | BasaltShelf = 1 << 9 ($512$) |
Next unallocated bit in BiomeType flags. |
| Temperature Range | $[+0.7, +1.0]$ | Torrid volcanic thermal band. |
| Moisture Range | $[-1.0, -0.4]$ | Extreme arid moisture band. |
| Parent Biomes | Badlands ($[0.5, 1.0] \times [-1.0, -0.6]$) and Desert ($[0.3, 1.0] \times [-1.0, -0.1]$) |
The basalt shelf carves out the ultra-hot corner from both. |
| Evaluation Order | First (index 0 in ClassificationOrder) |
Must be checked before Badlands and Desert, otherwise Badlands matches first. |
| Surface Block | BiomeSurfaceBlock.Stone |
Solid rock ground without topsoil. |
| Height Multiplier | $1.4\times$ | Steep stepped volcanic plateau topography. |
| Tree Density | $0.0\times$ | No organic tree growth on barren basalt crust. |
The files, in order
Section titled “The files, in order”Step 1: Register the Enum Flag in BiomeType.cs
Section titled “Step 1: Register the Enum Flag in BiomeType.cs”Open Assets/_Game/Scripts/Data/Generation/BiomeType.cs and allocate bit 9:
[Flags] public enum BiomeType { None = 0, Plains = 1 << 0, Forest = 1 << 1, Desert = 1 << 2, Taiga = 1 << 3, Savanna = 1 << 4, Badlands = 1 << 5, Mountains = 1 << 6, Jungle = 1 << 7, Tundra = 1 << 8, BasaltShelf = 1 << 9 }Step 2: Configure the Envelope & Precedence in BiomeCatalog.cs
Section titled “Step 2: Configure the Envelope & Precedence in BiomeCatalog.cs”Open Assets/_Game/Scripts/Data/Generation/BiomeCatalog.cs.
- Add the serialized definition field and getter:
[SerializeField] private BiomeDefinition basaltShelf = BiomeDefinition.CreateDefault( BiomeType.BasaltShelf, BiomeSurfaceBlock.Stone, heightAmplitudeMultiplier: 1.4f, treeDensityMultiplier: 0f, minTemperature: 0.7f, maxTemperature: 1f, minMoisture: -1f, maxMoisture: -0.4f);
public BiomeDefinition BasaltShelf => basaltShelf;- Crucial: Insert
basaltShelfat the very start ofClassificationOrderbeforebadlandsanddesert:
/// <summary> /// Priority order for classification; Plains' full-range window is the guaranteed /// fallback. BasaltShelf, Badlands, and Mountains are narrower windows carved out of /// Desert's and Taiga's windows — each must be checked before the window(s) it steals /// territory from. /// </summary> public IReadOnlyList<BiomeDefinition> ClassificationOrder => new[] { basaltShelf, tundra, badlands, desert, mountains, taiga, jungle, savanna, forest, plains };- Update
Validate()to ensure the instance is hydrated and clamped:
public void Validate() { basaltShelf ??= BiomeDefinition.CreateDefault(BiomeType.BasaltShelf, BiomeSurfaceBlock.Stone, 1.4f, 0f, 0.7f, 1f, -1f, -0.4f); tundra ??= BiomeDefinition.CreateDefault(BiomeType.Tundra, BiomeSurfaceBlock.Snow, 0.53f, 0f, -1f, -0.7f, -1f, -0.3f); // ... basaltShelf.Validate(); tundra.Validate(); // ... }Step 3: Keep the Burst Classification in Parity in TerrainColumnMath.cs
Section titled “Step 3: Keep the Burst Classification in Parity in TerrainColumnMath.cs”Burst jobs cannot allocate or iterate managed collections. In Assets/_Game/Scripts/World/Generation/TerrainColumnMath.cs, the classification order is unrolled in code.
Assign sequential index 9 to BasaltShelf and check it first:
/// <summary> /// Biomes: index 0=Badlands, 1=Desert, 2=Mountains, 3=Taiga, 4=Savanna, 5=Forest, /// 6=Tundra, 7=Jungle, 8=Plains (fallback), 9=BasaltShelf. /// BasaltShelf is checked first because it carves out of Badlands and Desert. /// </summary> internal static int ClassifyBiomeIndex(int worldX, int worldZ, in TerrainGenerationParameters parameters) { SampleClimate(worldX, worldZ, parameters, out float temperature, out float moisture);
// Carve-out check 1: Basalt Shelf if (MatchesBiomeWindow(temperature, moisture, parameters.BasaltShelfMinTemperature, parameters.BasaltShelfMaxTemperature, parameters.BasaltShelfMinMoisture, parameters.BasaltShelfMaxMoisture)) { return 9; }
if (MatchesBiomeWindow(temperature, moisture, parameters.TundraMinTemperature, parameters.TundraMaxTemperature, parameters.TundraMinMoisture, parameters.TundraMaxMoisture)) { return 6; }
// ... subsequent parent biome checks ...(Remember to expose BasaltShelfMinTemperature etc. in TerrainGenerationParameters.cs when bundling settings for Burst).
Step 4: Map the Sequential Index in TerrainGenerator.cs
Section titled “Step 4: Map the Sequential Index in TerrainGenerator.cs”In Assets/_Game/Scripts/World/Generation/TerrainGenerator.cs, ensure the managed switch maps BiomeType.BasaltShelf to 9:
private static int GetSedimentBiomeIndex(BiomeType biome) { return biome switch { BiomeType.Badlands => 0, BiomeType.Desert => 1, BiomeType.Mountains => 2, BiomeType.Taiga => 3, BiomeType.Savanna => 4, BiomeType.Forest => 5, BiomeType.Tundra => 6, BiomeType.Jungle => 7, BiomeType.BasaltShelf => 9, _ => 8 }; }Step 5: Add Weather & Ambience Profiles
Section titled “Step 5: Add Weather & Ambience Profiles” public static BiomeWeatherProfile For(BiomeType biome) { return biome switch { BiomeType.BasaltShelf => new BiomeWeatherProfile(0.04f, false), BiomeType.Desert => new BiomeWeatherProfile(0.05f, false), // ... public static BiomeAmbienceBed For(BiomeType biome) { return biome switch { // Volcanic shelf uses dry gusting desert wind with higher nighttime presence BiomeType.BasaltShelf => new BiomeAmbienceBed(Desert, 0.75f, 0.65f), BiomeType.Desert => new BiomeAmbienceBed(Desert, 0.70f, 0.62f), // ...Step 6: Add Non-Regression Tests in BiomeClassifierTests.cs
Section titled “Step 6: Add Non-Regression Tests in BiomeClassifierTests.cs”Open Assets/_Game/Tests/EditMode/World/BiomeClassifierTests.cs and append assertions verifying reachability and carve-out boundaries:
[Test] public void Classify_CanProduceBasaltShelfAcrossARepresentativeArea() { var found = new HashSet<BiomeType>(); for (int z = -1536; z <= 1536; z += 48) { for (int x = -1536; x <= 1536; x += 48) { found.Add(BiomeClassifier.Classify(x, z, settings).Biome); } }
Assert.That(found, Does.Contain(BiomeType.BasaltShelf), "Expected BasaltShelf — a hot, arid volcanic plateau carved out of Desert and Badlands — to be reachable."); }
[Test] public void Classify_BasaltShelfIsCarvedOutOfBadlandsAndDesertWithoutLeavingGaps() { for (int z = -1536; z <= 1536; z += 48) { for (int x = -1536; x <= 1536; x += 48) { BiomeClassifier.SampleClimate(x, z, settings, out float temperature, out float moisture); bool inBasaltWindow = settings.Biomes.BasaltShelf.Matches(temperature, moisture); if (!inBasaltWindow) { continue; }
BiomeType classified = BiomeClassifier.Classify(x, z, settings).Biome; Assert.That(classified, Is.EqualTo(BiomeType.BasaltShelf), "BasaltShelf must take precedence over Desert and Badlands inside its climate window."); } } }Interactive Climate Matrix
Section titled “Interactive Climate Matrix”Explore how biomes tile the continuous temperature-versus-moisture plane. Hover over any biome envelope or toggle the Basalt Shelf carve-out to inspect classification precedence:
Verify
Section titled “Verify”1. Run EditMode Tests Headlessly
Section titled “1. Run EditMode Tests Headlessly”Run the world generation test suite via Unity batchmode:
/home/soulwax/Unity/Hub/Editor/6000.6.0f1/Editor/Unity \ -batchmode -nographics \ -projectPath . \ -runTests \ -testPlatform EditMode \ -testCategory World \ -testResults Logs/biome-test-results.xml \ -logFile Logs/biome-tests.logConfirm that BiomeClassifierTests passes with 0 failures.
2. Preview in the Voxel Workshop World Studio
Section titled “2. Preview in the Voxel Workshop World Studio”- In the Unity Editor menu, select Voxel Workshop → World Studio.
- Select Biome Classification Map.
- Move the preview viewport over high-temperature coordinates and verify dark stone plateau cells render where $T > 0.7$ and $M < -0.4$.
Now do your own
Section titled “Now do your own”Follow this checklist whenever authoring a new biome:
- Pick climate bounds inside $[-1.0, +1.0]$:
- Frigid polar: $T \in [-1.0, -0.6]$.
- Temperate: $T \in [-0.3, +0.3]$.
- Torrid tropical: $T \in [+0.5, +1.0]$.
- Arid: $M \in [-1.0, -0.3]$.
- Humid: $M \in [+0.4, +1.0]$.
- Identify overlapping parent envelopes:
- Determine which existing biomes’ windows cover this coordinate region.
- Position in
ClassificationOrder:- Place child biomes before parent biomes.
- Never move
Plainsfrom the final position.
- Synchronize Burst & Managed indices:
- Add the sequential index to
TerrainColumnMath.ClassifyBiomeIndex. - Update
TerrainGenerator.GetSedimentBiomeIndex.
- Add the sequential index to
- Add weather & audio profiles:
- Configure precipitation threshold and audio looping bed.
- Author carve-out unit tests:
- Verify non-empty sampling in representative areas.
- Assert child takes precedence over parent inside its window.
Pitfalls
Section titled “Pitfalls”Pitfall 1: Parent envelope listed before child carve-out
Section titled “Pitfall 1: Parent envelope listed before child carve-out”- Symptom: The new biome never spawns anywhere in the world, even though its climate window is valid.
- Cause: In
BiomeCatalog.ClassificationOrder, the parent envelope (e.g.,DesertorTaiga) appears before the child envelope. The first matching envelope terminates the search. - Fix: Place the narrower child biome higher in
ClassificationOrder.
Pitfall 2: Direct cast from BiomeType flags to job index
Section titled “Pitfall 2: Direct cast from BiomeType flags to job index”- Symptom: Surface blocks fail to generate or default to dirt, or deposit exoticness curves return incorrect values in Burst jobs.
- Cause: Casting
(int)BiomeType.BasaltShelfproduces512(1 << 9), whereasSurfaceSedimentMathexpects sequential index0..9. - Fix: Always pass through
GetSedimentBiomeIndex(biome)or maintain the exact unrolled switch.
Pitfall 3: Setting climate coordinates outside $[-1.0, +1.0]$
Section titled “Pitfall 3: Setting climate coordinates outside $[-1.0, +1.0]$”- Symptom: The biome matches during manual unit tests with artificial numbers, but never appears during world generation.
- Cause:
DeterministicValueNoise.Sample2Dproduces values strictly between $-1.0$ and $+1.0$. SettingmaxTemperature: 1.5fwill never match beyond $1.0$. - Fix: Restrict all temperature and moisture ranges strictly to $[-1.0, +1.0]$.
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.