Deterministic procedural world generation: biomes, noise, roads, and smooth multi-biome composition.
Project description
pyworldgen
A unified, deterministic procedural biome-generation library. It brings three previously separate code bases under one roof and makes them share a single foundation:
- a chunked voxel world with a generation-pass framework (formerly
procgen3d); - pure-function biome generators — caves, forests, landscapes (formerly
biomegen_lab); - the streamed implicit cave with its cross-language hash.
The idea that unifies everything
The three code bases looked different on the surface — one was an array-backed
chunk engine, the others were pure mathematical fields — but they all obey the
same principle: a compact deterministic source of truth is sampled into
arrays, which are exported as views. A seed plus parameters is the world;
voxel grids, meshes, point clouds and saved chunks are all regenerable views of
that source. pyworldgen makes that principle explicit and builds three shared
mechanisms on top of it.
1. One canonical cross-language hash
Every generator's randomness now flows through a single module,
pyworldgen.core.hash. It is an integer multiply-and-mix hash (xxHash-style
constants, Math.imul semantics) whose only floating-point work happens after
hashing, so a world is reproducible bit-for-bit from a seed on any backend —
Python, JavaScript, GLSL, C++. The shader idiom frac(sin(x)*43758.5453) is
deliberately avoided because sin disagrees on its last bits across platforms
and the hash amplifies that into a different world.
This replaced three duplicated hash implementations. In doing so it fixed a
real latent bug: the original cavegen and landscapegen copies performed the
32-bit multiply in signed int64 and relied on silent overflow wraparound,
while forestgen used uint64. The three did not always agree. The unified
module standardises on the correct uint64 path (masked to 32 bits), and a test
pins the output against an independent pure-Python reference of the same
construction so the NumPy path can never drift from the cross-language contract.
2. The field abstraction — where the two frameworks meet
pyworldgen.fields is the conceptual bridge. A biome can be expressed as a pure
function is_solid(x, y, z) with no stored grid (this is exactly what the
implicit cave is). The same function can also fill chunks in the array-backed
world:
voxelize(field, ...)samples a field into a dense occupancy array;fill_chunk_from_field(field, chunk, ...)samples it straight into a chunk;FieldSolidPasswraps a field as a generation pass.
So a streamed implicit cave drops into the same scheduler as hand-authored
terrain, and can be meshed, saved and streamed like any other chunk. The cave's
spec↔world coordinate swap lives inside its own is_solid, so the generic fill
logic stays agnostic.
3. Compact source, heavy views
Seeds, parameters, tree graphs and river polylines are the source of truth. Meshes and OBJ files are produced on demand from them. For the pure fields you often don't need to store geometry at all — only measured or hand-edited data needs persisting.
Layer map
| Layer | Lines | What's in it |
|---|---|---|
core |
527 | canonical hash + noise, RNG seeding, vec/AABB/ray, chunk coordinates |
spatial |
833 | SpatialHashGrid, BVH, voxel DDA, Quadtree, Octree, Graph+Dijkstra, KDTree, RTree, farthest point sampling |
fields |
71 | SolidField3D protocol, voxelize, chunk fill |
voxel |
99 | block registry, sunlight pass |
world |
315 | block storage, chunk, chunk map, deferred edits, world, queries |
mesh |
392 | naive + greedy chunk meshers, dense-volume mesher |
generation |
294 | pass base, dependency-checked scheduler, terrain/biome/cave/feature/structure passes |
runtime |
95 | chunk streaming, grid A* navigation |
biomes |
1456 | the signature generators — cave, forest, landscape, flat terrain |
roads |
723 | weighted anisotropic least-cost roads (Galin et al.): path segment masks, line-integral cost, bridges, curvature, terrain grading |
compose |
356 | multi-biome composition: partition-of-unity blending, smooth transitions, integrated forest + roads |
pointcloud |
164 | mesh surface sampling, FPS downsampling, point→voxel rasterisation |
rendering |
762 | numpy pinhole camera, orbit/keyframe paths, software renderer, PyVista/matplotlib backends, GIF/MP4 export |
io |
445 | chunk (de)serialization, region store, OBJ / MagicaVoxel .vox / PLY / XYZ export |
The two spatial structures the reference "core structures" guide lists but neither original project had — a KDTree (nearest-neighbour over static points) and an STR-bulk-loaded RTree (map/polygon window queries) — were added and tested against brute force.
The three biomes and their invariants
Each generator preserves the defining property of its original, and each has a
validate_* function and tests that assert the invariant directly:
- Implicit cave (
biomes.cave) — a pure positional field with a guaranteed always-open wandering main corridor, unioned with worm tunnels and caverns, roughened walls and optional speleothems. The corridor's openness is strictly positive along its axis, so the whole streamed length stays navigable. Bridges to the world viaImplicitCave, a validSolidField3D. - Forest (
biomes.forest) — a Voronoi canopy graph. Every canopy cell has exactly one owner tree, so crowns never overlap by construction; each tree is a recursive root→trunk→branch→terminal graph with bottom-up branch radii (r_parent^γ = Σ r_child^γ) that stays inside its owned cell. An optionalcanopy_diffusion(default0.0, i.e. hard power-diagram edges) domain-warps the ownership so neighbouring crowns interlock with organic, diffuse boundaries — still exactly one owner per cell, and0.0reproduces the original output. - Landscape (
biomes.landscape) — a river-valley plain. Every river bed is monotonically downhill from spring to mouth, enforced withnp.minimum.accumulaterather than hoped for; tributaries join their main river, valleys and channels are carved, ponds fill low ground, and materials are classified water→upland. - Flat terrain (
biomes.flat) — the neutral default: a heightfield that is perfectly flat unless you add tilt or undulation. It exposes the sameheight/x/y/resolutioninterface as the landscape, so roads, the terrain mesher, and biome composition all work on it unchanged.
Roads
Roads (pyworldgen.roads) are laid over any terrain (FlatTerrain,
LandscapeWorld, or a raw height grid) as weighted anisotropic least-cost
paths, following Galin et al., Procedural Generation of Roads (Eurographics
2010). The routing of a single connection is a faithful port of the paper; laying
a network of hubs (minimum spanning tree plus a few loop edges) is the
pyworldgen extension on top of it.
The paper's two central ideas are both implemented:
- Path segment masks
M_k(path_segment_mask) — instead of 4/8-connectivity (which limits road directions to multiples of 45°, the "limit-on-direction problem"), each grid point connects to every(i, j) ∈ [-k, k]²withgcd(|i|, |j|) = 1. The mask sizes reproduce the paper's Table 1 exactly (k=1→8 directions,k=3→32,k=5→80), and the tests assert it. Largerkgives finer angle resolution and straighter roads;mask_radius(default 3) selects it. - Line-integral segment cost with transfer functions — because a mask
segment spans several cells, its cost is the integral of a local cost sampled
along it. Each characteristic (slope along the travel direction, water depth)
passes through a transfer function with a threshold
κ₀beyond which the cost is∞(a forbidden region): grades steeper thanmax_gradecan't be climbed, water deeper thanmax_water_depthcan't be forded.
On top of that: bridges — deep water is crossed at a bridge_cost surcharge
rather than forbidden (a long mask segment over a river is a bridge), and the
ribbon geometry lifts the bridge deck onto the water surface; curvature —
setting curvature_weight > 0 augments the search state with the incoming
direction (the paper's oriented Ω×[0,2π] grid) so sharp turns are penalised
(opt-in, and slower, as in the paper's Table 3); corridor reuse — later roads
are discounted along earlier ones so the network bundles; and excavation /
embankment — carve_road_terrain cuts/fills the ground to the road within the
asphalt half-width and blends back to the terrain over a shoulder region (§6.2),
leaving bridge spans untouched.
from pyworldgen.biomes.landscape import generate_landscape
from pyworldgen.roads import generate_road_network, road_ribbon_mesh, carve_road_terrain
land = generate_landscape(seed="rhine") # rivers to bridge
roads = generate_road_network(land, seed="roads", num_hubs=11, mask_radius=4)
roads.road_mask # (ny, nx) boolean road footprint
roads.bridge_mask() # road cells carried over water on a bridge
verts, faces = road_ribbon_mesh(roads) # deck lifted over water, ready for OBJ / render
graded = carve_road_terrain(roads) # terrain with cuts, fills and shoulders
bf.generate("roads", seed=..., num_hubs=...) is a convenience that lays roads
over a flat base. The result is a RoadNetwork: hubs, smoothed centreline
segments, the rasterised mask, the water it bridges, and helpers for 3D polylines
and ribbon meshes. Routed roads are much straighter than 8-connectivity (checked
against the mask table) and follow the terrain at a far gentler grade than a
straight line — both asserted in the tests.
Honest gaps versus the paper: smoothing uses Chaikin corner-cutting rather than clothoid splines (§6.1), tunnels are not implemented (only bridges), and the stochastic bridge/tunnel sampling of §5.4 is not used — all noted as future work.
Composition: many biomes in one world
pyworldgen.compose blends several biomes into a single coherent world with
smooth transitions, then lays the forest and roads over the same composed
terrain. The algorithm is a partition-of-unity blend:
- Region seeds + classification. Scatter region centres (seeded dart-throw) and classify each into a biome by its niche in a low-frequency (elevation, moisture) control field — a Whittaker-style assignment, so mountains cluster on high ground and water in the basins.
- Smooth weights. Each biome type is a global terrain profile
h_T(x, y). For every cell, a domain-warped Gaussian gives a weight to each seed; weights are summed per biome and normalised so they sum to 1 everywhere (a partition of unity). This is what makes the result smooth — every blended field is a convex combination of continuous fields, so there are no seams. - Blend.
height = Σ_T W_T · h_T, and likewise water depth, a tree-density field, and biome colour. Transitions read as foothills between mountains and plains, and shorelines around lakes. - Integrated overlays. Forest is scattered on the composed terrain, gated by
the blended density field (so it fades out at biome edges) and re-draped onto
the composed height; roads are routed over the composed
(height, water_depth)— which is exactly the interface the road generator already consumes, so they bridge the composed lakes and avoid the composed mountains with no special casing.
from pyworldgen.compose import compose_world
world = compose_world(seed="atlas", num_regions=16, num_hubs=12)
world.height, world.water_depth # blended terrain (same interface as a biome)
world.weights["forest"] # per-biome partition-of-unity weight field
world.color, world.dominant # blended biome colour / dominant-biome map
world.trees, world.roads # forest + road network on the composed terrain
bf.generate("composite", seed=...) returns a CompositeWorld, which exposes the
height/water_depth/x/y/resolution terrain interface — so the terrain and
water meshers and the road generator all consume it directly. The built-in
palette is water, plains, forest, hills and mountains; a custom palette of
BiomeTypes (each a terrain profile, colour, tree density and niche) can be
passed in. The weights are verified in the tests to be a true partition of unity
with no hard seams, trees to sit exactly on the composed terrain and never in
water, and mountains to dominate higher ground than water.
The current palette models water as lakes/wetlands (basins filled to a level);
per-region river hydrology from the landscape generator can be slotted in through
the same (height, water_depth) interface as a future extension.
Meshes, voxels and point clouds
Every biome can be exported in all three geometry representations, because each is just another view of the compact source. The three are interchangeable:
- Voxels. The cave is native voxels (a
DenseVoxelVolume); the chunked world is voxels too. Export either to MagicaVoxel.vox(readable by MagicaVoxel, Blender, Godot, three.js) or a portable.npz. - Meshes. A
DenseVoxelVolumemeshes with a vectorised naive culled mesher (volume_to_mesh); the forest and landscape mesh from their graph / height field. All go to Wavefront.obj. - Point clouds.
sample_mesh_surfacescatters points over any mesh with area-weighted density (uniform regardless of tessellation), each carrying its triangle's normal. The cloud thins to an even, blue-noise-like subset with farthest point sampling, then exports to.plyor.xyz.
So the pipeline biome → mesh → point cloud → FPS-downsampled cloud works for any biome:
from pyworldgen.mesh import volume_to_mesh
from pyworldgen.pointcloud import sample_mesh_surface
from pyworldgen.io import volume_to_vox, mesh_to_obj, write_ply
cave = bf.generate("implicit_cave", seed="lux", length=90.0)
volume_to_vox("cave.vox", cave) # voxels
mesh = volume_to_mesh(cave)
mesh_to_obj("cave.obj", mesh) # mesh
cloud = sample_mesh_surface(mesh, 40_000, seed=1) # dense surface cloud
even = cloud.farthest_point_downsample(4_096) # evenly spread subset
write_ply("cave_points.ply", even) # point cloud (with normals)
Farthest point sampling ships with both a self-contained, deterministic NumPy
backend built on the same layer as the KD-tree and the
fpsample Rust QuickFPS/NPDU backend for
very large clouds. The NumPy backend is the default. The reverse direction,
point cloud → voxels, is points_to_occupancy, so a mesh-native biome (forest,
landscape) can also be voxelised via a surface sampling.
Rendering and fly-throughs
Any geometry view can be rendered to images and animations. A render is just another view of the source, so the same biome flows all the way through: source → mesh / point cloud → camera → frames.
- Camera. A numpy pinhole camera (
Camera.look_at,project,lerp) with intrinsics from field of view — the standard self-driving pinhole model, without a torch/Lie-group dependency, so interpolating a path is a plain lerp. - Paths.
orbit/turntablespin around a point;flythroughinterpolates a smooth path throughKeyframewaypoints;frame_sceneandorbit_sceneauto-fit the camera to a scene's bounding box. - Renderers. The default
SoftwareRendereris pure numpy — a z-buffered point splatter (depth / normal / colour shading) and a flat-shaded triangle rasteriser.PyVistaRendererprovides solid VTK renders, andMatplotlibRendererprovides projected scatter renders. - Output.
save_png,save_gif(Pillow),save_mp4(imageio), plus a dependency-free PPM writer.
A turntable fly-through of a biome is one call:
import pyworldgen as bf
import pyworldgen.rendering as rr
from pyworldgen.mesh import volume_to_mesh
from pyworldgen.pointcloud import sample_mesh_surface
cave = bf.generate("implicit_cave", seed="lux", length=70.0)
cloud = sample_mesh_surface(volume_to_mesh(cave), 60_000, seed=1)
frames = rr.render_turntable(cloud, n_frames=48, elevation=18) # fly-around
rr.save_gif(frames, "cave_orbit.gif", fps=20)
# solid mesh still from a chosen angle
still = rr.SoftwareRenderer().render(volume_to_mesh(cave),
rr.frame_scene(cave, azimuth=40, elevation=30))
rr.save_png(still, "cave.png")
Point-cloud fly-throughs are fast (tens of milliseconds per frame); the triangle
rasteriser is best for solid stills. For very large meshes and extra polish,
pass renderer=rr.PyVistaRenderer().
Quick start
import pyworldgen as bf
bf.list_generators() # ['composite', 'flat', 'forest', 'implicit_cave', 'landscape', 'roads']
forest = bf.generate("forest", seed="grenoble", target_tree_count=60)
cave = bf.generate("implicit_cave", seed="luxembourg", length=90.0)
land = bf.generate("landscape", seed="rhine")
Drive the chunked world with a pipeline of passes:
from pyworldgen.world import World
from pyworldgen.core.coords import ChunkCoord
from pyworldgen.generation import (
GenerationScheduler, SimpleTerrainPass, SimpleBiomePass,
SimpleCavePass, SimpleTreeFeaturePass, ApplyDeferredEditsPass,
)
from pyworldgen.voxel.lighting import SimpleSunlightPass
world = World.create("demo")
scheduler = GenerationScheduler([
SimpleTerrainPass(), SimpleBiomePass(), SimpleCavePass(),
SimpleTreeFeaturePass(), ApplyDeferredEditsPass(), SimpleSunlightPass(),
])
chunk = world.chunks.get_or_create(ChunkCoord(0, 0, 0))
scheduler.generate_chunk(world, chunk)
Stream a whole implicit cave into chunks through the field bridge:
from pyworldgen.biomes.cave import ImplicitCave
from pyworldgen.generation import FieldSolidPass, GenerationScheduler
cave = ImplicitCave.from_seed("luxembourg")
scheduler = GenerationScheduler([FieldSolidPass(cave, name="cave")])
Visualization scripts
Camera-based stills, multi-view sheets, turntables, fly-throughs, biome debug maps, and a noise gallery are available in scripts/.
python scripts/visualize_all.py --quick --out sample_visualizations
python scripts/visualize_all.py --quick --animations --out sample_visualizations
python scripts/render_biome.py composite --seed atlas --quick
Install and dependencies
pip install -e . # installs runtime dependencies
pip install -e ".[dev]" # adds pytest
Runtime dependencies include numpy, scipy, fpsample, matplotlib,
pillow, imageio, and pyvista. scikit-image ([viz]) and
shapely/trimesh/mapbox-earcut ([geometry]) remain extras for marching
cubes and structure-generation extension points.
Testing
Coverage spans the hash's cross-language bit-contract and the absence of integer
overflow, the spatial structures (KDTree/RTree checked against brute force),
block storage and world queries, both meshers plus the dense-volume mesher, the
field↔chunk bridge, the generation scheduler's dependency validation and
reproducibility, all three biome invariants, serialization round-trips, grid A*,
the geometry exporters, and the rendering layer — farthest point sampling is
checked for exact behaviour on a 1-D line and for spreading points better than a
random subset; surface sampling for lying on the mesh and for area-weighting; the
.vox, .ply and .xyz writers by parsing their output back; the camera's
projection axes, orbit radius, fly-through endpoints, the renderer's z-buffer
(nearest wins), and PNG/GIF/PPM output; and the roads layer — the path segment
mask sizes against the paper's Table 1, staircase removal (a (3,1) run with
zero direction changes), least-cost routing that detours around a ridge and
follows terrain at a gentler grade than a straight line, bridges that cross deep
water only when allowed, curvature that reduces turns, terrain grading that
touches ground near roads but not far from them, network connectivity across all
hubs, endpoint pinning, and determinism, plus the flat terrain being flat by
default; and the composition layer — weights that form a true partition of unity
with no hard colour seams, coherent niche classification (mountains above water),
trees draped exactly on the composed terrain and never in water, roads consuming
the composed terrain interface, and determinism.
What was ported compactly (transparency)
To keep the merge coherent and tested, some elaborate paths from the originals were folded down rather than dropped:
- The forest's per-species table is folded into a single parameter set; multi-species is a documented extension point (per-site branch depth/radii).
- The landscape's multi-layer hydrology solver is expressed as a single carve pass driven by the drainage graph; catchment/erosion iteration is an extension point.
- Marching-cubes cave meshing and the CLI from the originals are kept out of the
tested core; geometry output is covered by the OBJ, MagicaVoxel
.voxand PLY/XYZ exporters and a vectorised naive voxel mesher (rather than marching cubes), and the rendering layer ships a self-contained numpy renderer plus PyVista/matplotlib backends. - Legacy grid/morphology cave variants (which needed
scipy) are omitted in favour of the faithful implicit field.
The road/building/story structure generators from the PCG reference are not implemented here; the graph and spatial layers they would build on are in place.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyworldgen-0.1.0.tar.gz.
File metadata
- Download URL: pyworldgen-0.1.0.tar.gz
- Upload date:
- Size: 125.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e648c4dd5f433b9e5014528bca6fc74cd28fa47b580996548d4eaf54d492738b
|
|
| MD5 |
651875539739d162139eadace2dfc449
|
|
| BLAKE2b-256 |
95736967f0ecec07a6c7894c75ed98860366097c12846b7136fb0fabaffc0e3d
|
Provenance
The following attestation bundles were made for pyworldgen-0.1.0.tar.gz:
Publisher:
publish.yml on mmkuznecov/pyworldgen
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyworldgen-0.1.0.tar.gz -
Subject digest:
e648c4dd5f433b9e5014528bca6fc74cd28fa47b580996548d4eaf54d492738b - Sigstore transparency entry: 2174676860
- Sigstore integration time:
-
Permalink:
mmkuznecov/pyworldgen@af7629a773c32928096da89a898cdc622ba818cb -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/mmkuznecov
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@af7629a773c32928096da89a898cdc622ba818cb -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyworldgen-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pyworldgen-0.1.0-py3-none-any.whl
- Upload date:
- Size: 130.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aed5c08c2281048b557fd60917f0e0631cd50b8f338c64a595da33098eb74f22
|
|
| MD5 |
4bd566658bd798e7dd50c91276715621
|
|
| BLAKE2b-256 |
4ea08027885a0304bd9d38e9090578b5e4f42d163c039e0d5d57e56d2e4a83a3
|
Provenance
The following attestation bundles were made for pyworldgen-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on mmkuznecov/pyworldgen
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyworldgen-0.1.0-py3-none-any.whl -
Subject digest:
aed5c08c2281048b557fd60917f0e0631cd50b8f338c64a595da33098eb74f22 - Sigstore transparency entry: 2174677204
- Sigstore integration time:
-
Permalink:
mmkuznecov/pyworldgen@af7629a773c32928096da89a898cdc622ba818cb -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/mmkuznecov
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@af7629a773c32928096da89a898cdc622ba818cb -
Trigger Event:
push
-
Statement type: