|
| 1 | +"""Explore procedural Perlin terrain with hydro flow — no data files needed. |
| 2 | +
|
| 3 | +Generates a synthetic island archipelago using xarray-spatial's ridged |
| 4 | +multi-octave Perlin noise with domain warping, all on GPU via dask + cupy. |
| 5 | +Hydraulic erosion carves realistic drainage channels and hydrological flow |
| 6 | +direction, accumulation, stream order, and stream links are recomputed for |
| 7 | +every window — including dynamically loaded ones. |
| 8 | +
|
| 9 | +The terrain is infinite: fly to the edge and a new Perlin window is |
| 10 | +generated on the fly. The fixed normalization (clip → scale) and fixed |
| 11 | +sea level ensure adjacent windows tile seamlessly. |
| 12 | +
|
| 13 | +Controls: |
| 14 | + WASD / arrows Move camera |
| 15 | + Shift+Y Toggle hydro flow particle animation |
| 16 | + G Cycle data layers (elevation, slope, stream_link, ...) |
| 17 | + R / Shift+R Increase / decrease resolution |
| 18 | + Y Cycle color stretch |
| 19 | +
|
| 20 | +Usage: |
| 21 | + python explore_perlin.py |
| 22 | + python explore_perlin.py --size 2048 --chunks 512 --seed 42 |
| 23 | + python explore_perlin.py --noise fbm --warp 0.5 |
| 24 | + python explore_perlin.py --erosion-iters 500000 |
| 25 | +
|
| 26 | +Requirements: |
| 27 | + pip install rtxpy[all] xarray-spatial dask cupy scipy |
| 28 | +""" |
| 29 | + |
| 30 | +import argparse |
| 31 | +import time |
| 32 | + |
| 33 | +import cupy as cp |
| 34 | +import dask.array as da |
| 35 | +import numpy as np |
| 36 | +import xarray as xr |
| 37 | +from xrspatial import ( |
| 38 | + generate_terrain, |
| 39 | + slope, |
| 40 | + aspect, |
| 41 | + fill as xrs_fill, |
| 42 | + flow_direction, |
| 43 | + flow_accumulation, |
| 44 | + stream_order, |
| 45 | + stream_link, |
| 46 | +) |
| 47 | +from xrspatial.erosion import erode |
| 48 | +from scipy.ndimage import uniform_filter |
| 49 | + |
| 50 | +import rtxpy # noqa: F401 — registers .rtx accessor |
| 51 | + |
| 52 | +WINDOW_HALF = 10_000 # half-width of each window in metres (20 km total) |
| 53 | +ZFACTOR = 4000 # raw elevation multiplier |
| 54 | + |
| 55 | +# full_extent == window size → each window covers 1.0 noise-space units, |
| 56 | +# giving proper multi-octave detail. Windows beyond this range still work |
| 57 | +# because Perlin noise is defined for all coordinates. |
| 58 | +FULL_EXTENT = (-WINDOW_HALF, -WINDOW_HALF, WINDOW_HALF, WINDOW_HALF) |
| 59 | + |
| 60 | +# Sea level as a fraction of ZFACTOR. Ridged noise after fixed |
| 61 | +# normalisation sits in roughly [0.5·zf, zf]. Subtracting 0.55·zf |
| 62 | +# puts the coastline where the noise dips, creating an archipelago. |
| 63 | +SEA_LEVEL_FRAC = 0.55 |
| 64 | + |
| 65 | +# Noise parameters (set from CLI args at startup, shared with loader). |
| 66 | +# NOTE: worley_blend is 0 because the dask+cupy path normalises Worley |
| 67 | +# per-chunk (min/max), creating visible grid seams. Domain warping is |
| 68 | +# seamless (uses deterministic Perlin displacement). |
| 69 | +NOISE_PARAMS: dict = {} |
| 70 | + |
| 71 | + |
| 72 | +def _apply_sea_level(terrain): |
| 73 | + """Subtract a fixed sea level and zero out ocean pixels. |
| 74 | +
|
| 75 | + Because the sea level is an absolute constant (not per-window), the |
| 76 | + transform is identical for every window, preserving tile coherence. |
| 77 | + """ |
| 78 | + sea = ZFACTOR * SEA_LEVEL_FRAC |
| 79 | + terrain.data[:] = cp.maximum(terrain.data - sea, 0) |
| 80 | + return terrain |
| 81 | + |
| 82 | + |
| 83 | +def generate_window(size, seed, x_range, y_range, chunks=None): |
| 84 | + """Generate a cupy-backed elevation DataArray from Perlin noise. |
| 85 | +
|
| 86 | + When *chunks* is given, the template is dask+cupy so noise is generated |
| 87 | + in parallel across GPU chunks before being materialised. |
| 88 | + """ |
| 89 | + if chunks is not None: |
| 90 | + data = da.zeros((size, size), dtype=np.float32, |
| 91 | + chunks=(chunks, chunks)) |
| 92 | + data = data.map_blocks(cp.asarray, dtype=np.float32, |
| 93 | + meta=cp.array((), dtype=np.float32)) |
| 94 | + template = xr.DataArray(data, dims=['y', 'x']) |
| 95 | + print(f"Dask template: {size}x{size}, " |
| 96 | + f"chunks={chunks}x{chunks} " |
| 97 | + f"({(size // chunks) ** 2} tasks)") |
| 98 | + else: |
| 99 | + template = xr.DataArray( |
| 100 | + cp.zeros((size, size), dtype=cp.float32), dims=['y', 'x'], |
| 101 | + ) |
| 102 | + |
| 103 | + t0 = time.time() |
| 104 | + terrain = generate_terrain( |
| 105 | + template, |
| 106 | + x_range=x_range, |
| 107 | + y_range=y_range, |
| 108 | + seed=seed, |
| 109 | + zfactor=ZFACTOR, |
| 110 | + full_extent=FULL_EXTENT, |
| 111 | + **NOISE_PARAMS, |
| 112 | + ) |
| 113 | + |
| 114 | + # Materialise dask graph → cupy |
| 115 | + if isinstance(terrain.data, da.Array): |
| 116 | + print("Computing dask graph on GPU...") |
| 117 | + terrain = terrain.compute() |
| 118 | + if not hasattr(terrain.data, 'device'): |
| 119 | + terrain = terrain.copy(data=cp.asarray(terrain.data)) |
| 120 | + |
| 121 | + # Fixed sea-level cutoff (same for every window → seamless tiling) |
| 122 | + terrain = _apply_sea_level(terrain) |
| 123 | + |
| 124 | + dt = time.time() - t0 |
| 125 | + elev_min = float(cp.nanmin(terrain.data)) |
| 126 | + elev_max = float(cp.nanmax(terrain.data)) |
| 127 | + water_pct = float((terrain.data == 0).sum()) / terrain.data.size * 100 |
| 128 | + print(f"Generated {terrain.shape[0]}x{terrain.shape[1]} terrain " |
| 129 | + f"in {dt:.2f}s (elev {elev_min:.0f}–{elev_max:.0f} m, " |
| 130 | + f"{water_pct:.0f}% water)") |
| 131 | + return terrain |
| 132 | + |
| 133 | + |
| 134 | +def erode_terrain(terrain, iterations, seed): |
| 135 | + """Apply hydraulic erosion to carve drainage channels.""" |
| 136 | + print(f"Eroding terrain ({iterations:,} droplets)...") |
| 137 | + t0 = time.time() |
| 138 | + terrain = erode(terrain, iterations=iterations, seed=seed) |
| 139 | + if not hasattr(terrain.data, 'device'): |
| 140 | + terrain = terrain.copy(data=cp.asarray(terrain.data)) |
| 141 | + # Re-clamp: erosion can push values slightly below 0 |
| 142 | + terrain.data[:] = cp.maximum(terrain.data, 0) |
| 143 | + dt = time.time() - t0 |
| 144 | + print(f" Erosion done in {dt:.2f}s") |
| 145 | + return terrain |
| 146 | + |
| 147 | + |
| 148 | +def compute_hydro(terrain): |
| 149 | + """Condition DEM and compute D8 hydro flow layers. |
| 150 | +
|
| 151 | + Returns a hydro dict ready for explore(), plus a stream_link DataArray |
| 152 | + suitable for adding to a Dataset overlay. |
| 153 | + """ |
| 154 | + print("Conditioning DEM for hydrological flow...") |
| 155 | + t0 = time.time() |
| 156 | + |
| 157 | + elev = cp.asnumpy(terrain.data).astype(np.float32) |
| 158 | + |
| 159 | + # Mark water (sea-level pixels are 0) as ocean sentinel |
| 160 | + ocean = (elev == 0.0) | np.isnan(elev) |
| 161 | + elev[ocean] = -100.0 |
| 162 | + |
| 163 | + # Smooth to fill noise pits |
| 164 | + smoothed = uniform_filter(elev, size=15, mode='nearest') |
| 165 | + smoothed[ocean] = -100.0 |
| 166 | + |
| 167 | + sm_gpu = cp.asarray(smoothed) |
| 168 | + filled = xrs_fill(terrain.copy(data=sm_gpu)) |
| 169 | + |
| 170 | + # Resolve flats: small perturbation toward drainage |
| 171 | + delta = filled.data - sm_gpu |
| 172 | + resolved = filled.data + delta * 0.01 |
| 173 | + cp.random.seed(0) |
| 174 | + resolved += cp.random.uniform(0, 0.001, resolved.shape, dtype=cp.float32) |
| 175 | + resolved[cp.asarray(ocean)] = -100.0 |
| 176 | + |
| 177 | + conditioned = terrain.copy(data=resolved) |
| 178 | + fd = flow_direction(conditioned) |
| 179 | + fa = flow_accumulation(fd) |
| 180 | + so = stream_order(fd, fa, threshold=50) |
| 181 | + sl = stream_link(fd, fa, threshold=50) |
| 182 | + |
| 183 | + # Mask ocean back to NaN |
| 184 | + ocean_gpu = cp.asarray(ocean) |
| 185 | + fd.data[ocean_gpu] = cp.nan |
| 186 | + fa.data[ocean_gpu] = cp.nan |
| 187 | + so.data[ocean_gpu] = cp.nan |
| 188 | + sl.data[ocean_gpu] = cp.nan |
| 189 | + |
| 190 | + dt = time.time() - t0 |
| 191 | + n_streams = int(cp.nanmax(sl.data)) |
| 192 | + print(f" Hydro computed in {dt:.2f}s — " |
| 193 | + f"{n_streams} stream segments found") |
| 194 | + |
| 195 | + # Clean stream_link for Dataset overlay (NaN → 0) |
| 196 | + sl_clean = cp.nan_to_num(sl.data, nan=0.0).astype(cp.float32) |
| 197 | + |
| 198 | + hydro = { |
| 199 | + 'flow_dir': fd.data, |
| 200 | + 'flow_accum': fa.data, |
| 201 | + 'stream_order': so.data, |
| 202 | + 'stream_link': sl.data, |
| 203 | + 'accum_threshold': 50, |
| 204 | + 'enabled': False, |
| 205 | + } |
| 206 | + return hydro, terrain.copy(data=sl_clean) |
| 207 | + |
| 208 | + |
| 209 | +def make_terrain_loader(size, seed, chunks, erosion_iters=0, do_hydro=True): |
| 210 | + """Create a callback that generates new Perlin terrain at the camera. |
| 211 | +
|
| 212 | + No clamping — Perlin noise is defined everywhere, so exploration is |
| 213 | + unlimited. The full_extent mapping just sets the scale factor so each |
| 214 | + window covers 1.0 noise-space units. |
| 215 | +
|
| 216 | + Each new window gets its own erosion and hydro computation so flow |
| 217 | + patterns are consistent with the local terrain. Returns a |
| 218 | + ``(terrain_da, hydro_dict)`` tuple that the engine's terrain reload |
| 219 | + handler picks up to reinitialise hydro particles. |
| 220 | + """ |
| 221 | + |
| 222 | + def loader(cam_x, cam_y): |
| 223 | + x_range = (cam_x - WINDOW_HALF, cam_x + WINDOW_HALF) |
| 224 | + y_range = (cam_y - WINDOW_HALF, cam_y + WINDOW_HALF) |
| 225 | + try: |
| 226 | + terrain = generate_window(size, seed, x_range, y_range, |
| 227 | + chunks=chunks) |
| 228 | + if erosion_iters > 0: |
| 229 | + terrain = erode_terrain(terrain, erosion_iters, seed) |
| 230 | + |
| 231 | + hydro = None |
| 232 | + if do_hydro: |
| 233 | + try: |
| 234 | + hydro, _ = compute_hydro(terrain) |
| 235 | + except Exception as e: |
| 236 | + print(f"Loader hydro error: {e}") |
| 237 | + |
| 238 | + return (terrain, hydro) |
| 239 | + except Exception as e: |
| 240 | + print(f"Terrain loader error: {e}") |
| 241 | + return None |
| 242 | + |
| 243 | + return loader |
| 244 | + |
| 245 | + |
| 246 | +if __name__ == "__main__": |
| 247 | + parser = argparse.ArgumentParser( |
| 248 | + description="Explore procedural Perlin terrain with hydro flow.", |
| 249 | + ) |
| 250 | + parser.add_argument("--size", type=int, default=2048, |
| 251 | + help="Grid size in pixels (default: 2048)") |
| 252 | + parser.add_argument("--chunks", type=int, default=512, |
| 253 | + help="Dask chunk size (default: 512, 0=no dask)") |
| 254 | + parser.add_argument("--seed", type=int, default=42, |
| 255 | + help="Perlin noise seed (default: 42)") |
| 256 | + parser.add_argument("--noise", type=str, default='ridged', |
| 257 | + choices=['fbm', 'ridged'], |
| 258 | + help="Noise mode (default: ridged)") |
| 259 | + parser.add_argument("--warp", type=float, default=0.4, |
| 260 | + help="Domain warp strength (default: 0.4)") |
| 261 | + parser.add_argument("--worley", type=float, default=0.0, |
| 262 | + help="Worley cellular noise blend (default: 0.0, " |
| 263 | + "causes chunk seams in dask+cupy path)") |
| 264 | + parser.add_argument("--octaves", type=int, default=6, |
| 265 | + help="Noise octaves (default: 6)") |
| 266 | + parser.add_argument("--erosion-iters", type=int, default=200_000, |
| 267 | + help="Hydraulic erosion droplets (default: 200000)") |
| 268 | + parser.add_argument("--no-erode", action="store_true", |
| 269 | + help="Skip hydraulic erosion") |
| 270 | + parser.add_argument("--no-hydro", action="store_true", |
| 271 | + help="Skip hydro flow computation") |
| 272 | + args = parser.parse_args() |
| 273 | + |
| 274 | + chunks = args.chunks if args.chunks > 0 else None |
| 275 | + |
| 276 | + # Noise params shared between initial window and terrain loader. |
| 277 | + # Erosion is handled separately (not via generate_terrain's erode param) |
| 278 | + # so it runs after sea-level subtraction. |
| 279 | + NOISE_PARAMS = { |
| 280 | + 'noise_mode': args.noise, |
| 281 | + 'warp_strength': args.warp, |
| 282 | + 'worley_blend': args.worley, |
| 283 | + 'octaves': args.octaves, |
| 284 | + } |
| 285 | + desc = [args.noise] |
| 286 | + if args.warp > 0: |
| 287 | + desc.append(f"warp={args.warp}") |
| 288 | + if args.worley > 0: |
| 289 | + desc.append(f"worley={args.worley}") |
| 290 | + if not args.no_erode: |
| 291 | + desc.append(f"erode({args.erosion_iters:,})") |
| 292 | + print(f"Noise: {', '.join(desc)}") |
| 293 | + |
| 294 | + # ---- Generate initial terrain from Perlin noise ---------------------- |
| 295 | + x_range = (-WINDOW_HALF, WINDOW_HALF) |
| 296 | + y_range = (-WINDOW_HALF, WINDOW_HALF) |
| 297 | + terrain = generate_window(args.size, args.seed, x_range, y_range, |
| 298 | + chunks=chunks) |
| 299 | + |
| 300 | + # ---- Hydraulic erosion ------------------------------------------------ |
| 301 | + if not args.no_erode: |
| 302 | + terrain = erode_terrain(terrain, args.erosion_iters, args.seed) |
| 303 | + |
| 304 | + # ---- Build Dataset with analysis layers ------------------------------ |
| 305 | + print("Computing terrain analysis layers...") |
| 306 | + ds = xr.Dataset({ |
| 307 | + 'elevation': terrain.rename(None), |
| 308 | + 'slope': slope(terrain), |
| 309 | + 'aspect': aspect(terrain), |
| 310 | + }) |
| 311 | + |
| 312 | + # ---- Hydro flow ------------------------------------------------------ |
| 313 | + hydro = None |
| 314 | + if not args.no_hydro: |
| 315 | + try: |
| 316 | + hydro, sl_layer = compute_hydro(terrain) |
| 317 | + ds['stream_link'] = sl_layer.rename(None) |
| 318 | + except Exception as e: |
| 319 | + print(f"Skipping hydro: {e}") |
| 320 | + |
| 321 | + # ---- Terrain loader for infinite Perlin exploration ------------------ |
| 322 | + # The loader runs in a background thread (engine does not block the |
| 323 | + # render loop). Cap erosion at 50k for reloaded windows so the load |
| 324 | + # completes in a few seconds instead of 30–60s. |
| 325 | + loader_erosion = 0 if args.no_erode else min(args.erosion_iters, 50_000) |
| 326 | + loader = make_terrain_loader( |
| 327 | + args.size, args.seed, chunks, |
| 328 | + erosion_iters=loader_erosion, |
| 329 | + do_hydro=not args.no_hydro, |
| 330 | + ) |
| 331 | + |
| 332 | + # ---- Launch ---------------------------------------------------------- |
| 333 | + print(f"\nLaunching explore " |
| 334 | + f"(G = cycle layers, Shift+Y = hydro flow)...\n") |
| 335 | + ds.rtx.explore( |
| 336 | + z='elevation', |
| 337 | + width=2048, |
| 338 | + height=1600, |
| 339 | + render_scale=0.5, |
| 340 | + color_stretch='cbrt', |
| 341 | + terrain_loader=loader, |
| 342 | + hydro_data=hydro, |
| 343 | + repl=True, |
| 344 | + ) |
| 345 | + print("Done") |
0 commit comments