Skip to content

Commit adda260

Browse files
authored
Merge pull request #72 from makepath/stashed-changes
Add terrain skirt and weather data fetching
2 parents 1e5a2f5 + 9b30627 commit adda260

10 files changed

Lines changed: 941 additions & 24 deletions

File tree

examples/explore_perlin.py

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
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")

examples/port_jefferson.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Port Jefferson, NY — 1 m USGS 3DEP LiDAR DEM exploration.
2+
3+
Downloads USGS 1-meter LiDAR-derived DEM tiles for Port Jefferson
4+
harbor and the surrounding village on Long Island's north shore,
5+
then launches the interactive rtxpy viewer with buildings, roads,
6+
and satellite imagery.
7+
8+
First run downloads the DEM tiles from USGS (~30 MB per 10 km tile)
9+
and caches them as a zarr store. Subsequent runs load from cache.
10+
11+
Usage:
12+
python examples/port_jefferson.py
13+
"""
14+
from rtxpy import quickstart
15+
16+
# Port Jefferson harbor + village — tight bbox to keep 1m DEM manageable
17+
# ~3.5 km E-W x ~3 km N-S
18+
quickstart(
19+
name='port_jefferson',
20+
bounds=(-73.10, 40.92, -73.04, 40.97),
21+
crs='EPSG:32618',
22+
source='usgs_1m',
23+
features=['buildings', 'roads', 'water'],
24+
tiles='satellite',
25+
hydro=True,
26+
wind=True,
27+
weather=True,
28+
)

examples/trinidad.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
crs='EPSG:32620',
88
features=['buildings', 'roads', 'water', 'fire', 'places',
99
'infrastructure', 'land_use'],
10+
weather=True,
1011
hydro=True,
1112
coast_distance=True,
1213
ao_samples=1,

0 commit comments

Comments
 (0)