An infinite 3D procedural landscape generated with Perlin and ridged noise, featuring a first-person immersive experience, implemented in Scala 3 (Swing/AWT) with real-time software 3D visualization.
The same generator provides a Decline-powered command-line tool that exports seeded PNG maps, ANSI-coloured ASCII, and layered SVG contours.
| Example Window | Example rendered PNG |
|---|---|
![]() |
![]() |
- Scala 3.9: renderer, map generator, CLI, camera and UI are all Scala
- Scala code runner: no Gradle/Maven build; configuration lives in
project.scala. Recent Scala distributions ship the Scala CLI runner asscala, so no separatescala-clipackage is required. - Nix flake: dev shell, package, app and checks
- Scalafmt + Scalafix: shared formatting/config and an automated lint check
- Hand-written graphics: Swing/AWT and
javax.imageiostay JDK-only - Decline CLI: typed options, subcommands and library-quality help output (Cats is pulled in transitively by Decline)
- Munit tests: noise reference vectors, smoothness properties, biome tables, determinism, exporters and execution strategies
- WASD movement: forward, backward and strafe relative to view direction
- Mouse look: yaw and pitch camera control
- Vertical flight: Q/E to ascend and descend
- Collision detection: keeps the camera above the terrain surface
- Bilinear height interpolation: smooth movement across grid cells
- Truly procedural world: infinite terrain generated on demand; chunks are created as you explore and evicted from an LRU cache when far away
- Layered noise: domain-warped continentalness, erosion, rolling hills and ridged multifractal mountains combine for natural terrain
- Biomes: temperature and moisture fields drive beaches, deserts, grasslands, forests, savanna, rock and snow
- Flat oceans: water is rendered at sea level while the underlying height still controls deep vs. shallow coloring
- Deterministic seeds: every location is a pure function of its coordinates and seed, so revisiting an area regenerates it identically
- Relief-aware spawn: new worlds start on nearby rolling terrain rather than underwater or on a cliff
- Procedural regeneration: "Regenerate Terrain" creates a new infinite world with a different seed
- Real-time software 3D: perspective projection and painter's algorithm
- Chunk streaming: only chunks near the player are generated and cached
- Culling: near/far depth checks plus screen-space bounding-box rejection
- Software rasterizer: scanline triangle fills and Bresenham wireframes
- One blit per frame: the terrain is composed in a reusable pixel buffer
- Color-coded terrain:
- Deep blue: deep water
- Light blue: shallow water
- Tan: sandy beaches
- Pale yellow: deserts
- Light green: grasslands
- Dark green: forests
- Olive: savanna
- Brown: rocky mountains
- White: snow-capped peaks
- HUD statistics: player position, FPS and viewing direction
- Local mini-map: a scrolling 160×160-unit window around the player
- Compass rose: cardinal directions and heading indicator
- Crosshair and mouse-capture instructions
- Modern styling: dark theme, blue accents and decorative borders
- Responsive controls: visual feedback on the regenerate button
- Opaque domain types:
WorldCoordinate,GridCell,Altitude,MoistureandLatitudekeep units from being mixed at module boundaries - Composable noise values:
Noise[A]layers withfBm,ridged,warp,mix/blendandmap - Two-channel terrain: altitude plus moisture drive an exhaustive
enum Biomeclassification - PNG export: biome-coloured maps through
javax.imageio - ANSI ASCII export: a height ramp with 24-bit colour, or plain text
- SVG export: layered marching-squares terrain contours
- Seeded and deterministic: every map is a pure function of its seed and parameters
- Sequential: straightforward single-threaded generation
- Parallel: JVM parallel-stream fan-out
- Chunked:
Futurefan-out sized toRuntime.getRuntime.availableProcessors - Benchmark command: JVM warm-up, repeated runs, min/median timings and speedup ratios against the sequential baseline
.
├── project.scala - Scala code-runner directives (Scala 3.9, test dep)
├── .scalafmt.conf - shared Scalafmt configuration
├── .scalafix.conf - Scalafix rules (DisableSyntax, imports, for-comprehensions)
├── flake.nix - Nix dev shell, package, app and checks
├── flake.lock
├── scripts/
│ └── check.sh - compile + format + Scalafix + test check
├── docs/
│ └── example-map.png - example CLI-generated map
├── src/main/scala/perlin/
│ ├── app/
│ │ └── Main.scala - GUI entry point and CLI dispatch
│ ├── cli/
│ │ ├── CliMain.scala - generate/render/benchmark/gui subcommands
│ │ └── Benchmark.scala - warm-up, repetitions and speedup table
│ ├── core/
│ │ └── Units.scala - opaque WorldCoordinate/GridCell/Altitude units
│ ├── noise/
│ │ ├── PerlinNoise.scala - seeded Perlin, octave and ridged noise
│ │ └── Noise.scala - composable Noise[A] and combinators
│ ├── world/
│ │ ├── Biome.scala - exhaustive Biome enum and classifier
│ │ ├── WorldMap.scala - parameters, samples, maps and chunks
│ │ └── WorldGenerator.scala - pure seeded altitude/moisture pipeline
│ ├── execution/
│ │ └── ExecutionStrategy.scala - sequential, parallel and chunked strategies
│ ├── output/
│ │ ├── Palette.scala - shared biome colours and ASCII ramp
│ │ ├── PngExporter.scala - javax.imageio PNG writer
│ │ ├── AsciiExporter.scala - ANSI/plain ASCII renderer
│ │ └── SvgExporter.scala - marching-squares SVG contours
│ ├── terrain/
│ │ └── Terrain.scala - infinite chunked GUI terrain and legacy palette
│ ├── camera/
│ │ ├── Camera.scala - first-person camera with WASD + mouse look
│ │ └── CollisionDetector.scala - terrain collision and camera constraints
│ ├── render/
│ │ ├── Renderer3D.scala - projection, culling, painter ordering and frame loop
│ │ ├── SoftwareRasterizer.scala - pixel buffer, scanline fills and wireframe lines
│ │ └── ViewFrustum.scala - camera frustum helpers
│ └── ui/
│ └── HeadsUpDisplay.scala - HUD rendering with stats and local mini-map
└── src/test/scala/perlin/
├── noise/ - reference vectors, smoothness and composition
├── world/ - biome table and deterministic generation
├── execution/ - strategy equivalence
└── output/ - PNG/ASCII/SVG smoke tests
Sources use the standard code-runner layout (src/main/scala, src/test/scala).
The root package is perlin. The interactive viewer lives in perlin.app,
perlin.terrain, perlin.camera, perlin.render and perlin.ui; the new map
pipeline lives in perlin.core, perlin.noise, perlin.world,
perlin.execution and perlin.output.
If you only want to try the latest revision without cloning:
nix run github:4ster-light/perlinA specific branch, tag or commit can be selected with:
nix run github:4ster-light/perlin/<ref>Build without launching it:
nix build github:4ster-light/perlingit clone https://github.com/4ster-light/perlin.git
cd perlin
nix run .If you don't have Nix, as long as Scala runner (Scala 3.5+, prefer 3.9.0 for this project) or scala-cli is available:
scala run . # or scala-cli run .Scala version, compiler options and the main class are declared in
project.scala, so no separate build script is needed.
nix build
./result/bin/perlin-landscapeThe flake uses scala --power package --assembly (the Scala CLI runner built
into the scala distribution) to produce a self-contained JAR and wraps it with
the Nix-provided JDK. The same command works with
nix build github:4ster-light/perlin.
scala run . with no arguments launches the interactive viewer. With arguments
it dispatches to the CLI, so the same entry point supports both workflows:
# Show every subcommand and option
scala run . -- --help
# Launch the viewer explicitly
scala run . -- gui
# Generate a map and print a biome histogram
scala run . -- generate --seed 42 --width 128 --height 96
# Export PNG (the default format)
scala run . -- render --seed 42 --width 1024 --height 1024 --output map.png
# Export plain ASCII
scala run . -- render --seed 42 --width 120 --height 60 --format ascii --no-color
# Export layered SVG contours
scala run . -- render --seed 42 --format svg --levels 12 --output contours.svg
# Benchmark all three execution strategies
scala run . -- benchmark --width 1024 --height 1024 --octaves 6 --reps 5The packaged launcher accepts the same arguments:
nix run . -- render --seed 42 --width 1024 --height 1024 --output map.png
./result/bin/perlin-landscape benchmark --width 1024 --height 1024The CLI is built with Decline, so subcommands,
typed option parsing, validation errors and --help output come from the
library rather than a hand-written parser. Cats is pulled in transitively by
Decline. The renderer, terrain code and exporters remain hand-written on JDK
APIs.
The map pipeline is separate from the legacy interactive terrain and is built from pure values:
- Opaque units in
perlin.core:WorldCoordinate,GridCell,Altitude,MoistureandLatitude.AltitudeandMoistureclamp to[0, 1]at the boundary. Noise[A]inperlin.noise: a function from a world coordinate to a value, withmap,fBm,ridged,warp,mixandblendcombinators.WorldGeneratorinperlin.world: combines continentalness, hills, ridged mountains and domain warping into an altitude field, then samples a separate moisture field.WorldMaprows areLazyLists, andgenerateChunksrealises bounded chunks on demand.enum Biome:Ocean,Beach,Grassland,Forest,Desert,Tundra,MountainandSnowPeak, classified by exhaustive pattern matching over altitude, moisture and latitude.- Determinism: the same
TerrainParametersalways produces identical samples. There is no unseeded randomness in the map pipeline.
A layer can be composed directly:
import perlin.noise.Noise
import perlin.noise.Noise.*
val perlin = Noise.perlin(42L)
val terrain = Noise
.fBm(perlin, 5)
.blend(Noise.ridged(perlin, 4))
.map(value => value.pow(1.3))Measured with perlin benchmark --width 1024 --height 1024 --octaves 6 --reps 3
on a 14-processor machine, after one full warm-up run per strategy:
| Strategy | Min (ms) | Median (ms) | Min speedup | Median speedup |
|---|---|---|---|---|
| sequential | 793.2 | 825.4 | 1.00x | 1.00x |
| parallel | 212.9 | 218.7 | 3.73x | 3.77x |
| chunked | 202.7 | 207.9 | 3.91x | 3.97x |
The checksum was identical for all three strategies (971378.4), confirming
that parallelism changes only execution, not the generated world.
Install the application into your Nix profile:
nix profile add github:4ster-light/perlin
perlin-landscapeRemove it later with:
nix profile remove perlin-landscapeFor a NixOS or Home Manager configuration, add the flake as an input:
# flake.nix
inputs.perlin.url = "github:4ster-light/perlin";Then expose the package:
# configuration.nix / home.nix
{ pkgs, inputs, ... }:
{
environment.systemPackages = [
inputs.perlin.packages.${pkgs.stdenv.hostPlatform.system}.default
];
# Home Manager alternative:
# home.packages = [
# inputs.perlin.packages.${pkgs.stdenv.hostPlatform.system}.default
# ];
}From a local checkout, nix profile add . installs the same package.
| Input | Action |
|---|---|
| W | Move forward |
| S | Move backward |
| A | Strafe left |
| D | Strafe right |
| Q | Ascend |
| E | Descend |
| Mouse Movement | Look around / rotate camera |
| Esc | Release captured mouse |
| Button Click | Generate a new world with a different seed |
Click the viewport to capture the mouse. The crosshair marks the view center.
- Current
X,Y,Zworld coordinates - Painted-frame FPS counter
- Current bearing in degrees
- Local top-down view colored by height and biome
- Yellow crosshair: your position (always at the map center)
- Blue line: your viewing direction
- Gray grid: orientation reference
- The map scrolls and rebuilds as you explore
- Cardinal directions
N,E,S,W - Dot showing your current heading
- Quick reference for movement, mouse look and regeneration
- Start high: press Q after spawning for a better overview
- Follow water: coastlines often lead to varied terrain
- Find mountains: look for brown and white regions on the mini-map
- Use the compass to avoid getting turned around
- Watch FPS and reduce render distance if performance drops
- Try different seeds: every regeneration creates a new infinite world
The world is infinite and chunked. Surface colors combine elevation with temperature and moisture:
- Deep water (
#1E50B4) and shallow water (#3C78C8) - Beaches (
#D6BE91) - Deserts (
#DECC91) - Grasslands (
#60A04A) - Forests (
#286930) - Savanna (
#A0A552) - Rock (
#8B5A2B) - Snow (
#F5F7FF)
Oceans are flat at sea level, while the underlying noise still decides whether water is deep or shallow. Terrain height comes from four noise layers:
- Continentalness: large ocean basins and land masses
- Erosion: broad flat areas vs. rough terrain
- Hills: local rolling relief
- Ridged noise: sharp mountain crests
The CLI map pipeline uses the exhaustive Biome enum instead of palette
indices: Ocean, Beach, Grassland, Forest, Desert, Tundra,
Mountain and SnowPeak. Classification is a pure function of altitude,
moisture and latitude, and the compiler rejects non-exhaustive matches.
- Opaque domain types for coordinates, grid cells, altitude, moisture and latitude
Noise[A]as a pure, composable value withfBm,ridged,warpandblendcombinators- Scala 3
enums for input keys and biomes LazyListrows and chunked generation so the whole map never has to exist in memory- Immutable
case classvalue types for HUD snapshots and camera vectors - Java Swing/AWT interop for windows, timers and graphics
- Hot rendering loops use primitive
Arrays andwhileloops
- Permutation table with Fisher-Yates shuffle for pseudo-randomness
- Explicit seeds only; no unseeded
scala.util.Randomin the map pipeline - Gradient interpolation with smooth fade curves
- Octave layering for fractal detail
- Ridged multifractal noise for mountain ridges
- Domain warping to break up grid-aligned patterns
- Masked
mix/blendfor combining layers
ExecutionStrategy.Sequential: plainmapExecutionStrategy.Parallel:IntStream.parallelExecutionStrategy.Chunked:Futurefan-out with a fixed pool sized to the available processors- All strategies share the same interface and are checked for output equality
- Full 6-DOF movement (forward/back, left/right, up/down)
- Euler angle rotation (pitch and yaw) from mouse input
- Camera height offset from the terrain surface
- Infinite traversal with only a numeric world-limit safety net
- Timer-driven update/paint on the Swing EDT
- Chunk streaming: only chunks visible around the player are generated and cached; the rest of the infinite world does not exist in memory
- Render distance: ~120 world units by default
- Culling: near/far checks plus screen-space bounding-box rejection
- Software rasterizer: scanline triangle fills and Bresenham wireframes avoid per-triangle Java2D overhead
- Reusable buffers: vertex, triangle and frame buffers are retained between frames, making the hot path allocation-free
- Primitive depth sort: packed
longkeys replace per-triangle objects and comparator sorting - Cached HUD artwork: the local mini-map is rebuilt only when the player moves; fonts, metrics and palettes are cached
- Uniform wireframe mesh: every triangle carries its border, giving the demo its signature polygon-y look
Choose a seed in src/main/scala/perlin/app/Main.scala:
val seed = System.currentTimeMillis()
val terrain = new Terrain(seed)The interesting worldgen lives in src/main/scala/perlin/terrain/Terrain.scala:
private val CHUNK_SIZE = 16 // Height samples per chunk edge
private val SEA_LEVEL = 16.0 // Flat ocean height
private val MAX_CACHED_CHUNKS = 4096generateHeight(...) combines continentalness, erosion, hills and ridged
mountains. surfaceColorIndex(...) maps elevation, temperature and moisture to
biomes. Try changing the smoothstep thresholds or the multipliers on hills
and ridge to make the world flatter, more mountainous, or more island-like.
Edit src/main/scala/perlin/camera/Camera.scala:
private val MOVEMENT_SPEED = 1.2 // Units per frame
private val VERTICAL_SPEED = 0.8 // Up/down speed
private val MOUSE_SENSITIVITY = 0.15 // Degrees per pixelEdit src/main/scala/perlin/render/Renderer3D.scala:
private val FOV = 70.0 // Field of view in degrees
val RENDER_DISTANCE = 120 // How far to render (world units)The test suite uses Munit and lives in src/test/scala. It covers:
- hardcoded permutation-table and noise reference vectors
- gradient smoothness and range properties
- biome classification table cases
- seeded determinism and chunk/map equivalence
Noisecomposition, blending and warping- PNG, ASCII and SVG exporter smoke tests
- equality of sequential, parallel and chunked generation
scala test .Formatting is configured in .scalafmt.conf and linting in .scalafix.conf.
The Scalafix rules include DisableSyntax, LeakingImplicitClassVal,
NoValInForComprehension and OrganizeImports. project.scala enables
SemanticDB, unused-import warnings, -Werror and Scalafix metadata.
scala fmt is a wrapper around Scalafmt, so it is fine for everyday formatting.
The flake still pins the standalone scalafmt binary so nix flake check can
format-check without downloading the formatter inside the sandbox. Scalafix is a
separate tool, not part of the Scala runner; it needs SemanticDB from a prior
compile and an explicit --scala-version, otherwise it parses Scala 3 sources as
Scala 2 and fails.
# format sources (either works)
scala fmt .
scalafmt .
# compile first so Scalafix can find SemanticDB
scala compile .
# apply fixes (including import organisation)
scalafix --auto-classpath --scalac-options -Wunused:imports \
--scala-version 3.9.0 src/main/scala
# or only verify
scalafix --auto-classpath --scalac-options -Wunused:imports \
--scala-version 3.9.0 --check src/main/scalaThe helper script runs the whole local check:
scala run scripts/check.scala
# or, inside the Nix shell:
nix develop -c scala run scripts/check.scalaRun the full Nix check suite:
nix flake checkThis compiles the project, runs scalafmt --test and runs scalafix --check.
Tests are run separately with scala test . because they use a test-scoped
Munit dependency.
- Reduce
RENDER_DISTANCEto 60–80 - Reduce
CHUNK_SIZEif you prefer smaller streaming hitches - Lower
MAX_CACHED_CHUNKSto cap memory use more aggressively - Reduce
PRELOAD_RADIUSinMain.scala
- Increase
RENDER_DISTANCEto 160–200 - Increase
MAX_CACHED_CHUNKSif you explore large areas - Raise
PRELOAD_RADIUSinMain.scalafor a larger initial buffer
# Enter the Nix dev shell and launch from there
nix develop -c scala run .
# Or run the packaged launcher
nix run github:4ster-light/perlin
nix build
./result/bin/perlin-landscapeMake sure you have Scala 3.9+ and JDK 21+. The easiest path is:
nix develop
scala compile .On a headless machine, run the GUI under Xvfb:
nix shell nixpkgs#scala_3 nixpkgs#xvfb-run -c xvfb-run -a scala run .- Reduce
RENDER_DISTANCE - Reduce
PRELOAD_RADIUSinMain.scala - Lower movement/mouse sensitivity if input feels laggy
- Make sure the window is focused and you are not hovering over a button
- Check that WASD is not captured by another application
- Click the viewport to capture the mouse
- Adjust
MOUSE_SENSITIVITY - Adjust
MOVEMENT_SPEEDif navigation feels too slow or fast
- Increase the
ridgemultiplier ingenerateHeight(...) - Lower the mountain
smoothstepthresholds so mountains appear more often - Increase the
hillsmultiplier or amplitude for rougher ground
- Ensure the window is large enough
- Try moving with WASD or adjusting the camera with the mouse
- Check that you have not flown too far above the landscape
- Nix with flakes enabled (recommended), or
- Scala 3.9+ with a JDK 21+ on
PATH(the Nix shell uses JDK 21) - Runtime dependencies are limited to Decline/Cats for the CLI; Munit is test-only, and the renderer/exporters use JDK APIs
This project is licensed under the MIT License - see the LICENSE file for details.

