Terrain generator
Procedural terrain generator written in Rust. It builds a fractal-noise heightmap, shapes it into an island with noisy coastlines, carves it with particle-based hydraulic erosion, and routes water over the result so that rivers follow the eroded valleys and depressions become lakes.
It is usable five ways:
- as a CLI that renders maps to PNG,
- as a desktop GUI for trying settings with a live preview,
- as a Rust library (
cargodependency), - as an Elixir library via a Rustler NIF (
mixdependency), - as a Java library via the Foreign Function & Memory API — and, through the same C ABI, from C, C++, C# or anything else with an FFI.
Generation is fully deterministic: the same seed and parameters always
produce the same terrain, from every entry point. It is single-threaded
and erosion dominates the cost: on a typical desktop core, expect roughly
a second for a 512×512 map and a few seconds for 1024×1024. Lower
--droplets for faster previews.
Pipeline
- Base heightmap — fractal (fBm) OpenSimplex noise from the
noisecrate. - Land mask — a single radial island, or an archipelago of several, perturbed by a secondary noise field for natural coastlines.
- Hydraulic erosion — droplet simulation (Beyer 2015): rain droplets roll downhill, pick up sediment and deposit it on flats, carving valleys and drainage networks.
- Basin breaching (opt-in) — cuts outlets through the rims of oversized closed basins so they drain instead of becoming inland seas.
- Hydrology — priority-flood depression filling (Barnes et al. 2014) plus D8 flow accumulation; high-flow cells become rivers, filled depressions become lakes, and border-connected sea becomes the ocean mask.
The output is not just an image: you get elevation, raw flow accumulation (upstream catchment per cell), and river, lake and ocean masks as separate raw layers for use in games or simulations.
Installing the CLI
Prebuilt binaries
Grab an archive for your platform from the latest release:
| Platform | File |
|---|---|
| Linux x86_64 (static, runs on any distro) | terrain-generator-<tag>-x86_64-unknown-linux-musl.tar.gz |
| macOS Apple Silicon | terrain-generator-<tag>-aarch64-apple-darwin.tar.gz |
| macOS Intel | terrain-generator-<tag>-x86_64-apple-darwin.tar.gz |
| Windows x86_64 | terrain-generator-<tag>-x86_64-pc-windows-msvc.zip |
Unpack it and run the terrain-generator binary inside — there are no
other files to install. SHA256SUMS.txt on the release page has the
checksums.
With cargo
Requires a Rust toolchain (1.91 or newer):
cargo install terrain-generator
Add --git https://github.com/fabio-t/terrain-generator for the
unreleased state of main.
From a clone
git clone https://github.com/fabio-t/terrain-generator
cd terrain-generator
cargo run --release # generates map0.png in the current directory
Using the CLI
terrain-generator # one 512x512 map, seed 0 -> map0.png
terrain-generator -s 10 -n 5 # five maps, seeds 10..14
terrain-generator -W 1024 -H 1024 # bigger map
terrain-generator --rivers 0.02 --island 0.7 --droplets 2000000
terrain-generator --grey -o heightmaps/ # greyscale heightmaps into a directory
terrain-generator --help # all options
Every pipeline stage can be tuned (--octaves, --frequency, --island,
--coast-noise, --droplets, --rivers, --sea-level, ...) or disabled
(--no-island, --no-erosion, --no-rivers). The same seed with the
same options always reproduces the same map.
What the options do
Every map below uses seed 1 at 512×512; each row changes a single
option against the defaults. The same knobs exist under the same names in
the Rust API (IslandParams, ErosionParams, ...) and the Elixir API
(:island, :coast_noise, :erosion, :rivers, :sea_level, ...).
Island shape — --coast-noise
How strongly noise perturbs the radial island falloff: 0 is a perfectly
round island, 1 (the default) carves bays, peninsulas and offshore
islets.
--coast-noise 0 |
--coast-noise 0.5 |
--coast-noise 1 (default) |
Island size — --island / --no-island
Island diameter as a fraction of the shorter map dimension, or no island mask at all (terrain runs to the borders — useful for tiling a larger world out of maps).
--island 0.7 |
--island 1.0 (default) |
--no-island |
Erosion — --no-erosion / --droplets
Without erosion the raw fractal terrain is smooth and blobby. The default simulates one droplet per cell; more droplets keep carving — valleys deepen, sediment builds plains, coastlines erode.
--no-erosion |
default (262k droplets) |
--droplets 1000000 |
River density — --rivers
The approximate fraction of land covered by rivers (extracted from flow accumulation, so they always follow the valleys). Shown as 1:1 crops so the individual rivers are visible.
--rivers 0.003 |
--rivers 0.01 (default) |
--rivers 0.04 |
A share of the land gives a different river network at every map size.
Two thresholds hold steady instead, and either replaces --rivers:
--river-min-upstream N makes a river wherever at least N cells drain
through, for when a cell has a fixed real-world size, and
--river-per-edge K wherever the catchment reaches K times the longer
map edge, for a same-looking network at every resolution.
Sea level — --sea-level
Cells at or below this elevation are ocean. Raising it drowns the lowlands and turns valley systems into fjord-like inlets.
--sea-level 0.03 |
--sea-level 0.08 (default) |
--sea-level 0.2 |
Base noise — --frequency / --octaves
--frequency is the number of large features across the map (defaults to
2.5): lower gives one big landmass, higher gives busier, more broken
terrain. --octaves (default 8) controls fine detail; fewer octaves
give smoother, softer relief. Between octaves, --lacunarity (default
2) multiplies the frequency and --persistence (default 0.5) the
amplitude: a higher persistence keeps more of the fine detail.
--frequency 1.5 |
--frequency 4 |
--octaves 4 |
Archipelagos — --archipelago
--archipelago N places N islands instead of one, with --islands-size,
--islands-spacing, --islands-spread and --islands-variation to shape
the group. Placement comes from the seed, so it is reproducible; the count
is approximate, because neighbours can merge and coastline noise can split
an island in two. Erosion and rivers work per landmass.
single island (default) |
--archipelago 5 |
--archipelago 9 --islands-size 0.22 |
Draining big basins — --breach-basins
Droplet erosion fills pits but cannot cut through a high rim, so large
interior depressions survive and become inland seas. --breach-basins F
carves an outlet from every closed basin bigger than fraction F of the
map, along the path where water would wear through first, leaving valleys
instead of lakes.
default (basins stay lakes) |
--breach-basins 0.002 |
Output style — --grey
The shaded render is for looking at; the greyscale render is the raw heightmap (black = 0, white = 1), ready to import into game engines and other tools.
default (shaded) |
--grey |
Getting the data out — --layers
One generation can emit every layer as its own file, for feeding into other tools:
terrain-generator --layers height,flow,rivers,lakes,ocean -o out/
This writes map0_height.png, map0_flow.png and so on. Elevation and
flow are written as 16-bit greyscale so nothing is lost to
quantisation (8 bits would terrace the elevation); the masks are 8-bit
black/white. shaded is also available as a layer name, and remains the
default when --layers is not given.
Desktop GUI
cargo run --release --manifest-path gui/Cargo.toml
Every CLI generation option as a control, a preview that shows the height
and water type of the cell under the pointer, and export of the picture,
the 16-bit heightmap or all layers. Generate builds the map on a
background thread; the CLI command panel gives the terrain-generator
invocation for the current settings, and its exports match the CLI's
byte for byte. Use --release: erosion is many times slower in a debug
build.
On Linux the file dialogs go through the desktop portal
(xdg-desktop-portal), which every desktop session runs.
Contributed by @romangrigorii in #8.
Rust library
[dependencies]
# add features = ["png"] if you want PNG file output; without it the
# library's only dependency is the `noise` crate
terrain-generator = { version = "0.3", default-features = false }
API documentation is on docs.rs.
use terrain_generator::{RenderStyle, Terrain};
let terrain = Terrain::builder().size(512, 512).seed(42).generate();
let elevation = terrain.heightmap[(100, 100)]; // [0, 1]
let moisture = terrain.flow_at(100, 100); // [0, 1], from flow accumulation
let river = terrain.is_river(100, 100);
let lake = terrain.is_lake(100, 100);
// with the `png` feature:
terrain.save_png("map.png", RenderStyle::Shaded)?;
Consumers who run their own hydrology can call .no_rivers(): flow
routing is skipped entirely and heightmap is the single source of truth.
The ocean mask is still computed, so "sea or inland basin?" stays
answerable without it.
Full reference: docs/rust_api.md, or cargo doc --open.
Determinism and stored worlds
The same seed and parameters always produce the same terrain. If you
persist worlds as seeds and regenerate them on load, pin an exact version
of this crate and commit your Cargo.lock. Any change to the output is a
breaking change for your saves. The output also depends on the noise
crate, which this crate pins to an exact version, so noise changes
only when this crate does.
One exception to cross-platform bit-exactness: the normalised flow layer
is computed with f32::ln, whose last-bit rounding can differ between
platforms. heightmap, flow_accumulation, rivers, lakes and ocean
use only IEEE-exact operations and are identical everywhere.
Elixir library
A Hex package wraps the same Rust core through Rustler. The NIF compiles from source when you fetch it, so Erlang/OTP 24+, Elixir 1.15+ and Rust 1.91+ (required by Rustler 0.38) are needed at compile time.
def deps do
[{:terrain_generator, "~> 0.3"}]
end
{:ok, terrain} = TerrainGenerator.generate(width: 512, height: 512, seed: 42)
TerrainGenerator.Terrain.height_at(terrain, 100, 100)
#=> 0.53
:ok = TerrainGenerator.render_png("map.png", seed: 42)
Heightmap, flow, river and lake buffers are returned as plain binaries (32-bit floats / bytes), cheap to pass around the BEAM. The NIF runs on a dirty CPU scheduler, so generation never blocks your schedulers.
Full reference: HexDocs, or docs/elixir_api.md.
Java library
The generator exposes a plain C ABI, which Java 22+ binds directly through the Foreign Function & Memory API — no JNI shim, and no Java-specific code inside the Rust library. The Java sources have no dependencies, so they drop into Maven, Gradle or a plain classpath.
Using the released jar
Every release attaches terrain-generator-<version>.jar, which carries a
native library for each supported platform inside it:
| Linux | x86-64 (glibc 2.35+) |
| macOS | Apple Silicon and Intel |
| Windows | x86-64 |
Put it on the classpath and you are done — no Rust toolchain, nothing to install, nothing to build:
java --enable-native-access=ALL-UNNAMED -cp terrain-generator-0.3.1.jar:. YourApp
The binding loads its native library from the first of these that works:
- the path in the
tergen.librarysystem property, if set; - the default library search path;
- the copy bundled in the jar for this platform, extracted to a temporary file.
So a locally built library still wins while you are working on the Rust side, and everyone else gets step 3 without knowing it exists. The jar is compiled for Java 22, and mismatched jar/native combinations are caught at class-init by an ABI-version check rather than by misreading memory.
Building from source
./java/build.sh # builds the native library, packs the jar, runs the binding tests
That runs the tests twice: once against the freshly built library and once against the jar alone, so the extraction path is covered too.
import com.github.fabioticconi.tergen.*;
TerrainConfig config = TerrainConfig.defaults().size(512, 512).seed(42);
try (Terrain terrain = TerrainGenerator.generate(config)) {
float elevation = terrain.heightAt(100, 100);
boolean river = terrain.isRiver(100, 100);
terrain.savePng(Layer.SHADED, Path.of("map.png"));
}
Layers can be copied into Java arrays or borrowed as a zero-copy
MemorySegment. The binding tests assert that a map generated from Java
hashes to the same checksum as the Rust test suite pins, so the three
language bindings are known to produce identical terrain.
Full reference: docs/java_api.md. The C interface
itself is include/terrain_generator.h, if
you would rather use jextract or call it from C/C++.
Development
cargo test --workspace # Rust unit, integration and doc tests
cargo clippy --workspace # lints
mix test # Elixir tests (needs Elixir + Rust)
./java/build.sh # Java jar + binding tests (needs JDK 22+ and Rust)
CI (formatting, clippy, both test suites) runs automatically on pushes
to main, and on demand for any other branch via Run workflow on
the Actions tab
— pull requests are not built automatically. Releases
are cut by running the Release workflow on main with cut, which tags
main at its version, or by pushing a tag: v1.2.3 (or 1.2.3)
publishes a release with
CLI binaries for Linux, macOS and Windows attached, plus the Java jar with
every platform's native library inside it, and release notes taken from
that version's CHANGELOG section, then publishes to crates.io and Hex; a
pre-release tag like v1.2.3-rc1 publishes a GitHub pre-release only.
License
Apache-2.0