Terrain generator

CI Latest Github release crates.io Hex.pm docs.rs

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.

example map

It is usable five ways:

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

  1. Base heightmap — fractal (fBm) OpenSimplex noise from the noise crate.
  2. Land mask — a single radial island, or an archipelago of several, perturbed by a secondary noise field for natural coastlines.
  3. Hydraulic erosion — droplet simulation (Beyer 2015): rain droplets roll downhill, pick up sediment and deposit it on flats, carving valleys and drainage networks.
  4. Basin breaching (opt-in) — cuts outlets through the rims of oversized closed basins so they drain instead of becoming inland seas.
  5. 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

the 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:

  1. the path in the tergen.library system property, if set;
  2. the default library search path;
  3. 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