ZOA Terrain
Deterministic procedural terrain that stamps the tactical anchors the AI reads.
Terrain builds a Unity Terrain from an authored profile and then scatters things onto it. The second half is where it parts company with a generic heightmap generator: alongside trees and rocks, the scatterer stamps cover anchors, high-ground vantage markers and spawn zones. Those markers are the contract com.zoa.ai reads when it evaluates where a sniper should sit or where a squad should break line of sight, so terrain shape and tactical behaviour come from the same source.
Determinism is a hard requirement. The same profile with the same seed produces a byte-identical heightmap and identical vantage candidate ordering, and a given layout with a given seed produces identical placement counts and positions. The builder is therefore scenario-testable, and a team can share a terrain by quoting a seed.
Generation is edit-time only. The runtime sees a baked Unity Terrain GameObject and a handful of marker components; there is no runtime terrain editing, and no runtime cost from the generation pipeline. Authoring a variant means cloning a profile and tweaking it, never hand-editing the generated terrain after the fact, because the next regeneration would discard the edit.
Depends on (3)
Depended on by (3)
How it works
Concepts
Profiles drive everything
TerrainProfileDefinition is a DefinitionBase asset carrying the whole configuration: a size preset, a deterministic seed, the mountain-shape parameters, a biome reference, a feature layout reference, atmosphere settings, and the tactical hints.
Shape comes from four numbers. RidgePower controls how much sharp ridge shaping is applied over the base octaves. ValleyDepth carves between the ridges. PeakHeightMeters caps elevation above the base. HeightFalloff is an AnimationCurve over normalised distance from the centre, which is how the terrain tapers at its edges instead of ending in a cliff. PlateauCount and PlateauRadius insert flat high-ground patches, and each plateau becomes a vantage candidate.
The tactical hints are DesiredVantageMarkers, DesiredCoverNetworks and SpawnClearRadius. The first two tell the scatterer how many high-ground markers and cover clusters to produce; the third is the radius around the suggested spawn that must stay clear of features so a CharacterController does not pin against a rock on frame zero.
Size presets, and what they cost
TerrainSizePreset names four scales and an escape hatch. Small is a 257 heightmap over 250 metres, one encounter's worth. Medium is 513 over 500 metres, the demo and showcase scale. Large is 1025 over 1000 metres, which is the full mountainous AI encounter scale. Huge is 2049 over 2000 metres for survival-extraction scale and is gated behind a confirmation dialog. Custom uses the profile's own resolution and world-size fields.
The extension methods on the preset resolve heightmap, alphamap and detail resolutions and world size, and EstimatedPeakMemoryMb reports the cost before you commit. ResolveResolution and ResolveWorldSize on the profile apply the Custom override when one is set, so callers never branch on the preset themselves.
Build then scatter
ITerrainBuilder takes a profile and a target scene and returns a TerrainBuildResult: the terrain root GameObject, world bounds, a suggested player spawn already snapped to the surface and cleared of overhead geometry, the candidate vantage positions, the resolution and world size actually used, and any non-fatal issues. The default implementation is multi-octave fbm plus ridge shaping, valley carving, plateau insertion and a spawn-clear pass.
IFeatureScatterer then takes that result plus the profile and a seed and returns a FeatureScatterResult with per-category placement counts. Placement is Poisson-disc, so features respect a minimum spacing rather than clumping.
One rule overrides everything else in the scatterer: any placement within the profile's SpawnClearRadius of the suggested spawn is rejected regardless of what the feature kind's own rules say. The rule is absolute because a rock intersecting the spawn capsule produces a character controller that cannot move, and the symptom reads as a movement bug.
TerrainBuildResult is a snapshot, not a live handle. Mutating the Unity Terrain after a build is supported since the Terrain object stays live, but the next build produces a fresh result independent of the old one.
Feature kinds place things and stamp intent
A FeatureKindDefinition describes one thing the scatterer can place: a category, a prefab, a semantic id, altitude and slope bands, a density per hundred square metres, a minimum spacing, an instance cap, and rotation and scale jitter. A FeatureLayoutDefinition composes kinds into layers with a per-layer density scale, and applies a global density multiplier and instance cap on top.
FeatureKindCategory decides what placement actually means. Vegetation is painted into TerrainData.treeInstances. Detail is painted into the detail layer. Rock and Structure are instantiated as prefab children of the terrain.
The remaining categories place nothing visible and stamp intent instead. CoverPoint stamps a cover anchor read by the AI cover provider. VantageMarker stamps a VantagePointMarker read by the tactical evaluator. AiSpawnZone, LootAnchor and HeroSpawnAnchor stamp SceneSeedAnchor values in the matching category, which the SceneSeed applier in com.zoa.gameplay picks up during Apply.
Anchors carry metadata only
VantagePointMarker carries a prominence value normalised to 0-1, a plateau radius, and a flag saying whether a human authored it. It subscribes to nothing, registers with nothing, and does no runtime work. The tactical evaluator uses prominence as a tiebreaker between vantage candidates with comparable line of sight.
TerrainSpawnZoneAnchor carries a ZoneRole, a zone radius and an optional tag. The SceneSeed applier finds them, ensure-adds an AiSpawnZone component, and sizes the zone from the radius. It is a typed wrapper over the same job SceneSeedAnchor does, with the radius as the terrain-specific addition.
ProceduralStructureMarker tags every structure the factory builds with its kind, footprint radius and estimated height. It exists so later passes such as the ProBuilder integrator can find procedural landmarks with GetComponentsInChildren rather than parsing names, and so the marker survives renames and asset moves.
Biome and atmosphere
TerrainBiomeDefinition holds ground layers, grass entries and tree entries, each identified by a semantic id rather than a direct material reference, plus a fog tint and density. Ground layers carry altitude and slope bands, so rock appears on steep high slopes and grass on shallow low ones without anyone painting a splatmap.
TimeOfDayPreset offers Dawn, Day, Dusk, Night and Storm. Resolve turns a preset plus the biome's fog tint and density into an AtmosphericProfile: sun colour, intensity and rotation, ambient light, fog colour and density, and a skybox tint. EnvironmentApplier applies that to the directional light and RenderSettings, and is pure C# so the headless runner can call it directly.
The profile also drives the enhancement passes: an optional water plane at a normalised altitude, weather intensity feeding particle counts, and an optional URP volume with post-processing.
The generator pipeline is headless-first
DemoSceneGeneratorRunner.Generate is the whole pipeline in one call: build terrain, scatter features, apply the SceneSeed, bake NavMesh, save. It takes a DemoSceneGeneratorContext, a plain data record, and returns a DemoSceneGeneratorResult with the scene path, the scene, both sub-results and a success flag.
The wizard window is a FoundryWizardShell over that same runner with five steps: Profile, Size & Seed, Population, Extensions, Generate. Tests construct a context directly and call the runner, so the tested path and the button path are the same code.
RunEnhancementPasses is exposed as a public static so a generator that builds its terrain inline, as the demo package's Full Showcase does, can still run foliage, grass, water, weather and volume passes without going through the whole runner.
In the editor
Screens
Screenshot pending
/screenshots/terrain-generator-wizard.png
The wizard on its Size & Seed step: step rail on the left with Profile completed, size preset and seed fields in the middle with the memory estimate visible, and the preview dock summarising resolution and world size.
Screenshot pending
/screenshots/terrain-generated-scene.png
Scene view of a Large mountainous terrain with scattered trees and rocks, plateaus visible, and the gizmos of several vantage markers and cover anchors drawn on the high ground and at chokepoints.
Screenshot pending
/screenshots/terrain-profile-inspector.png
Inspector on a TerrainProfileDefinition showing the shape parameters with the height falloff curve expanded, and the tactical hints section with vantage, cover and spawn-clear values.
Setup
Workflow
- 01
Install the bundled profiles
Tools > ZOA > Advanced > Generate > Terrain > Bundled Profiles > Ensure Assets writes the shipped profiles and biomes to disk so you have something to generate from before authoring your own.
- 02
Author or clone a profile
Create a TerrainProfileDefinition, or duplicate a bundled one. Pick a size preset with the memory estimate in mind, set a seed, and tune ridge power, valley depth, peak height and the falloff curve until the silhouette reads the way you want.
- 03
Attach a biome and a layout
Assign a TerrainBiomeDefinition for ground layers, grass and trees, and a FeatureLayoutDefinition naming the feature kinds to scatter. A profile without a layout produces bare terrain, which is a legitimate starting point.
- 04
Set the tactical hints
DesiredVantageMarkers and DesiredCoverNetworks decide how much tactical structure the scatterer stamps. SpawnClearRadius should comfortably exceed the player capsule radius, since the scatterer treats it as an absolute exclusion.
- 05
Generate
Open the Demo Scene Generator from the Terrain Tools page in the Workbench and walk Profile, Size & Seed, Population, Extensions, Generate. The wizard's issue tray shows build warnings and the scatter telemetry. From code, fill a DemoSceneGeneratorContext and call DemoSceneGeneratorRunner.Generate.
- 06
Wire the AI to the anchors
The generated scene already carries VantagePointMarker and cover anchors. com.zoa.ai reads them as its candidate sets, so a tactical AI dropped into the scene has vantage and cover data without further authoring.
Surface
Key types
ITerrainBuilder
interface
Pure-C# terrain building. Implementations must be deterministic: the same profile and seed produce a byte-identical heightmap and identical vantage ordering.
- TerrainBuildResult Build(TerrainProfileDefinition profile, Scene targetScene)
TerrainBuilder
class
The shipped builder: multi-octave fbm, ridge shaping, valley carve, plateau insertion and a spawn-clear pass. Edit-time only, and can take seconds on a Huge profile.
TerrainBuildResult
struct
A snapshot of one build. Carries the terrain root, bounds, the cleared spawn position, the vantage candidates, the resolution and world size actually used, and the issues raised.
- GameObject TerrainRoot
- Bounds Bounds
- Vector3 SuggestedPlayerSpawn
- IReadOnlyList<Vector3> VantageCandidates
- IReadOnlyList<TerrainBuildIssue> Issues
- int HeightmapResolution
- float WorldSizeMeters
- bool HasErrors { get; }
IFeatureScatterer
interface
Places features on a built terrain and stamps the anchor components the layout's kinds direct. Deterministic for a given layout and seed.
- FeatureScatterResult Scatter(TerrainBuildResult terrain, TerrainProfileDefinition profile, int seed)
FeatureScatterResult
struct
Per-category placement counts plus non-fatal issues, so the wizard can show a telemetry summary in its issue tray.
- int TotalPlaced
- IReadOnlyDictionary<FeatureKindCategory, int> CountByCategory
- IReadOnlyList<TerrainBuildIssue> Issues
- int CountFor(FeatureKindCategory category)
TerrainBuildIssue
struct
One build-time finding with a severity, a machine-readable code and a message. A warning lets the build proceed; an error aborts and leaves the scene without a terrain root.
- TerrainBuildSeverity Severity
- string Code
- string Message
- static TerrainBuildIssue Info(string code, string message)
- static TerrainBuildIssue Warning(string code, string message)
- static TerrainBuildIssue Error(string code, string message)
TerrainSizePreset
enum
Small (257 / 250m), Medium (513 / 500m), Large (1025 / 1000m), Huge (2049 / 2000m, behind a confirm dialog), Custom.
- static int HeightmapResolution(this TerrainSizePreset preset)
- static int AlphamapResolution(this TerrainSizePreset preset)
- static int DetailResolution(this TerrainSizePreset preset)
- static float WorldSizeMeters(this TerrainSizePreset preset)
- static float EstimatedPeakMemoryMb(this TerrainSizePreset preset)
FeatureKindCategory
enum
What a placement means. Vegetation, Detail, Rock and Structure place geometry; CoverPoint, VantageMarker, AiSpawnZone, LootAnchor and HeroSpawnAnchor stamp markers other systems read.
TimeOfDayPreset
enum
Dawn, Day, Dusk, Night, Storm. Resolve combines a preset with the biome's fog tint and density into an AtmosphericProfile.
- static AtmosphericProfile Resolve(this TimeOfDayPreset preset, Color biomeFogTint, float biomeFogDensity)
AtmosphericProfile
struct
The resolved lighting and fog settings for one time of day, applied by EnvironmentApplier.
- Color SunColor
- float SunIntensity
- Vector3 SunRotationEulers
- Color AmbientLight
- Color FogColor
- float FogDensity
- Color SkyboxTint
EnvironmentApplier
class
Applies an AtmosphericProfile to the directional light and RenderSettings. Pure C#, and skips gracefully when there is no active terrain.
- static void Apply(AtmosphericProfile atmos, Light directionalLight)
VantagePointMarker
component
Metadata-only marker at a peak, plateau or ridge with elevation advantage. The tactical AI evaluator reads these as its candidate set.
- Vector3 Position { get; }
- float Prominence { get; }
- float PlateauRadius { get; }
- bool SniperAuthored { get; }
- void Configure(float prominence, float plateauRadius, bool authored = false)
TerrainSpawnZoneAnchor
component
Marker at a candidate AI, hero or loot spawn. The SceneSeed applier ensure-adds an AiSpawnZone and sizes it from ZoneRadius.
- ZoneRole Role { get; }
- float ZoneRadius { get; }
- string AnchorTag { get; }
- Vector3 Position { get; }
- void Configure(ZoneRole role, float zoneRadius, string anchorTag = null)
ProceduralStructureMarker
component
Identity tag on every structure the factory builds, so later passes locate landmarks by component rather than by name pattern.
- StructurePrefabFactory.StructureKind Kind { get; }
- float FootprintRadius { get; }
- float EstimatedHeight { get; }
DemoSceneGeneratorRunner
class
The headless pipeline: build, scatter, apply the seed, bake NavMesh, save. The wizard's Generate button and the tests call the same method.
- static DemoSceneGeneratorResult Generate(DemoSceneGeneratorContext context)
- static void RunEnhancementPasses(...)
DemoSceneGeneratorContext
class
The request. Wizard steps mutate one and hand the final snapshot to the runner; a test constructs one directly.
- TerrainProfileDefinition Profile
- string SceneName
- string SceneFolder
- SceneSeedDefinition Seed
- int SeedOverride
- bool SkipNavMeshBake
- bool OpenAfter
DemoSceneGeneratorResult
class
Outcome of one generate: the saved scene path and scene, both sub-results, and any cross-cutting issues.
- string ScenePath
- Scene Scene
- TerrainBuildResult TerrainResult
- FeatureScatterResult ScatterResult
- List<TerrainBuildIssue> AdditionalIssues
- bool Succeeded
BundledTerrainProfiles
class
In-memory profiles and biomes used when no authored asset exists at the expected path. AI Encounters, Forest and Desert profiles; forest, desert and mountain biomes.
- static TerrainProfileDefinition BuildAiEncounters()
- static TerrainProfileDefinition BuildForest()
- static TerrainProfileDefinition BuildDesert()
- static TerrainBiomeDefinition BuildForestBiome()
- static TerrainBiomeDefinition BuildDesertBiome()
- static TerrainBiomeDefinition BuildMountainBiome()
Surface
Authoring assets
TerrainProfileDefinition
asset
The whole terrain configuration in one asset. Assets > Create > ZOA > Terrain > Terrain Profile.
- TerrainSizePreset Size { get; }
- int Seed { get; }
- float RidgePower, ValleyDepth, PeakHeightMeters { get; }
- AnimationCurve HeightFalloff { get; }
- int PlateauCount { get; }
- float PlateauRadius { get; }
- TerrainBiomeDefinition Biome { get; }
- FeatureLayoutDefinition FeatureLayout { get; }
- TimeOfDayPreset TimeOfDay { get; }
- bool PlaceWater { get; }
- float WaterAltitudeNormalised, WeatherIntensity { get; }
- bool ApplyUrpVolume { get; }
- int DesiredVantageMarkers, DesiredCoverNetworks { get; }
- float SpawnClearRadius { get; }
- int ResolveResolution()
- float ResolveWorldSize()
TerrainBiomeDefinition
asset
Ground layers, grass and tree entries keyed by semantic id, plus fog tint and density. Assets > Create > ZOA > Terrain > Terrain Biome.
- GroundLayer[] GroundLayers { get; }
- GrassEntry[] Grass { get; }
- TreeEntry[] Trees { get; }
- Color FogTint { get; }
- float FogDensity { get; }
- struct GroundLayer { string MaterialSemanticId; float MinAltitude; float MaxAltitude; float MinSlopeDegrees; float MaxSlopeDegrees; }
FeatureLayoutDefinition
asset
Composes feature kinds into layers with per-layer density scaling and global caps. Assets > Create > ZOA > Terrain > Feature Layout.
- FeatureLayer[] Layers { get; }
- float GlobalDensityMultiplier { get; }
- int GlobalMaxInstances { get; }
- struct FeatureLayer { FeatureKindDefinition Kind; float DensityScale; string InstanceTag; }
FeatureKindDefinition
asset
One placeable thing: category, prefab, semantic id, altitude and slope bands, density, spacing, caps and jitter. Assets > Create > ZOA > Terrain > Feature Kind.
- FeatureKindCategory Category { get; }
- GameObject Prefab { get; }
- string SemanticId { get; }
- float MinAltitude, MaxAltitude { get; }
- float MinSlopeDegrees, MaxSlopeDegrees { get; }
- float DensityPer100SqMeters { get; }
- float MinSpacing { get; }
- int MaxInstances { get; }
- float YRotationJitter { get; }
- Vector2 ScaleRange { get; }
Usage
Examples
using UnityEditor;
using UnityEngine.SceneManagement;
using ZOA.Terrain.Unity.Builders;
using ZOA.Terrain.Unity.Profiles;
var profile = AssetDatabase.LoadAssetAtPath<TerrainProfileDefinition>(
"Assets/ZOA/Generated/Terrain/Profile_AiEncounters.asset");
ITerrainBuilder builder = new TerrainBuilder();
var terrain = builder.Build(profile, SceneManager.GetActiveScene());
// An error means the build was incomplete and left no terrain root.
if (terrain.HasErrors)
{
foreach (var issue in terrain.Issues)
UnityEngine.Debug.LogError(issue);
return;
}
IFeatureScatterer scatterer = new FeatureScatterer();
var scatter = scatterer.Scatter(terrain, profile, profile.Seed);
UnityEngine.Debug.Log(
$"{scatter.TotalPlaced} features, " +
$"{scatter.CountFor(FeatureKindCategory.VantageMarker)} vantage markers, " +
$"{scatter.CountFor(FeatureKindCategory.CoverPoint)} cover points.");using ZOA.Terrain.Unity.Editor.Generators;
var context = new DemoSceneGeneratorContext
{
Profile = profile,
SceneName = "ZOA_Demo_RidgeAssault",
SceneFolder = "Assets/ZOA/Generated/Demo/Scenes",
SeedOverride = 91733,
SkipNavMeshBake = false,
OpenAfter = false,
};
var result = DemoSceneGeneratorRunner.Generate(context);
if (!result.Succeeded)
{
foreach (var issue in result.AdditionalIssues)
UnityEngine.Debug.LogError(issue);
}
else
{
UnityEngine.Debug.Log(
$"Saved {result.ScenePath} " +
$"({result.TerrainResult.WorldSizeMeters}m, {result.ScatterResult.TotalPlaced} features).");
}using System.Linq;
using UnityEngine;
using ZOA.Terrain.Unity.Anchors;
public sealed class SniperPostSelector : MonoBehaviour
{
// Prominence is the tiebreaker the tactical evaluator uses between
// candidates with comparable line of sight: 1 is the highest plateau
// on the terrain, 0.5 a mid-ridge.
public Vector3 PickPost(Vector3 from, float maxDistance)
{
var markers = FindObjectsByType<VantagePointMarker>(FindObjectsSortMode.None);
var best = markers
.Where(m => Vector3.Distance(from, m.Position) <= maxDistance)
.OrderByDescending(m => m.Prominence)
.FirstOrDefault();
return best != null ? best.Position : from;
}
}Tooling
Editor tools
Terrain Tools
Workbench > Environment > Terrain Tools
The Workbench page: an embedded Demo Scene Generator plus a profile editor that lists profiles on the left, exposes their fields on the right, and offers Generate Now against the selected one.
Demo Scene Generator
Workbench > Environment > Terrain Tools > Demo Scene Generator
The five-step wizard over DemoSceneGeneratorRunner: Profile, Size & Seed, Population, Extensions, Generate. Build warnings and scatter telemetry land in the issue tray.
Bundled Profiles
Tools > ZOA > Advanced > Generate > Terrain > Bundled Profiles > Ensure Assets
Writes the shipped terrain profiles and biomes to disk so a fresh project has something to generate from.
Extension Registry
Tools > ZOA > Advanced > Generate > Terrain > Extension Registry > Ensure Assets
Writes the bundled ExtensionRegistry and its entries, which back the terrain integrators for ProBuilder, Splines, Cinemachine, VFX Graph and the sample asset packs.
Read this
Notes and caveats
See also