Skip to main content

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;
  • FieldSolidPass wraps 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 via ImplicitCave, a valid SolidField3D.
  • 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 optional canopy_diffusion (default 0.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, and 0.0 reproduces the original output.
  • Landscape (biomes.landscape) — a river-valley plain. Every river bed is monotonically downhill from spring to mouth, enforced with np.minimum.accumulate rather 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 same height/x/y/resolution interface 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]² with gcd(|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. Larger k gives 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 than max_grade can't be climbed, water deeper than max_water_depth can'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 / embankmentcarve_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:

  1. 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.
  2. 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.
  3. 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.
  4. 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 DenseVoxelVolume meshes 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_surface scatters 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 .ply or .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 / turntable spin around a point; flythrough interpolates a smooth path through Keyframe waypoints; frame_scene and orbit_scene auto-fit the camera to a scene's bounding box.
  • Renderers. The default SoftwareRenderer is pure numpy — a z-buffered point splatter (depth / normal / colour shading) and a flat-shaded triangle rasteriser. PyVistaRenderer provides solid VTK renders, and MatplotlibRenderer provides 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 .vox and 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyworldgen-0.1.0.tar.gz (125.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyworldgen-0.1.0-py3-none-any.whl (130.0 kB view details)

Uploaded Python 3

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

Hashes for pyworldgen-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e648c4dd5f433b9e5014528bca6fc74cd28fa47b580996548d4eaf54d492738b
MD5 651875539739d162139eadace2dfc449
BLAKE2b-256 95736967f0ecec07a6c7894c75ed98860366097c12846b7136fb0fabaffc0e3d

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyworldgen-0.1.0.tar.gz:

Publisher: publish.yml on mmkuznecov/pyworldgen

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for pyworldgen-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 aed5c08c2281048b557fd60917f0e0631cd50b8f338c64a595da33098eb74f22
MD5 4bd566658bd798e7dd50c91276715621
BLAKE2b-256 4ea08027885a0304bd9d38e9090578b5e4f42d163c039e0d5d57e56d2e4a83a3

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyworldgen-0.1.0-py3-none-any.whl:

Publisher: publish.yml on mmkuznecov/pyworldgen

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page