Skip to content

Commit 00317a1

Browse files
authored
Add enhanced terrain generation features (#929)
* Add enhanced terrain generation with ridged noise, domain warping, Worley blending, and hydraulic erosion New generate_terrain parameters: octaves, lacunarity, persistence, noise_mode ('fbm'/'ridged'), warp_strength, warp_octaves, worley_blend, worley_seed, erode, erosion_iterations, erosion_params. Defaults reproduce existing behavior. - perlin.py: add _perlin_gpu_xy kernel for non-linear coordinate arrays - worley.py: new Worley (cellular) noise module with 4-backend support - erosion.py: new particle-based hydraulic erosion module - terrain.py: configurable octave loop, ridged multifractal, domain warping, Worley blending, erosion post-pass across all 4 backends - __init__.py: export erode - tests: 24 tests covering each feature, cross-backend matching, combined smoke tests, and input validation * Fix dask double-compute and GPU temporary allocation issues - perlin.py: persist noise before computing min/ptp in both dask+numpy and dask+cupy paths, preventing a full recompute on normalization - terrain.py: persist warped coordinates before the octave loop so each iteration doesn't rebuild the warp subgraph; persist worley noise before min/max so the blend doesn't recompute it - terrain.py: pre-allocate scaled_x/scaled_y GPU buffers and use cupy.multiply(out=) instead of allocating temporaries per iteration * Fix dask edge effects in noise functions Use floor instead of int truncation for cell coordinates in perlin and worley noise. int() truncates toward zero, which gives wrong cell indices and negative fractional parts for coordinates below zero (common with domain warping). Switched to np.floor / math.floor throughout _perlin, _perlin_gpu, _perlin_gpu_xy, _worley_cpu, _worley_gpu, and _worley_gpu_xy. Fix _worley_gpu writing last-computed dist instead of min_dist. Fix per-chunk worley normalization in the dask+cupy terrain backend. Each chunk was normalizing worley noise with its own min/max, producing visible seams at chunk boundaries. Added a worley pre-pass that computes global min/max before the main terrain computation, and a worley_norm_range parameter on _terrain_gpu to accept externally computed bounds. Added chunk-boundary continuity tests with odd chunk sizes (13x17) for warp, worley, and combined features. * Update README feature matrix with new terrain modules Add Worley noise and hydraulic erosion rows. Update Terrain Generation description to mention ridged noise, domain warping, Worley blending, and erosion options.
1 parent 0740651 commit 00317a1

File tree

8 files changed

+1350
-205
lines changed

8 files changed

+1350
-205
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,14 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e
243243
| [Hillshade](xrspatial/hillshade.py) | Simulates terrain illumination from a given sun angle and azimuth | ✅️ | ✅️ | ✅️ | ✅️ |
244244
| [Roughness](xrspatial/terrain_metrics.py) | Computes local relief as max minus min elevation in a 3×3 window | ✅️ | ✅️ | ✅️ | ✅️ |
245245
| [Slope](xrspatial/slope.py) | Computes terrain gradient steepness at each cell in degrees | ✅️ | ✅️ | ✅️ | ✅️ |
246-
| [Terrain Generation](xrspatial/terrain.py) | Generates synthetic terrain elevation using fractal noise | ✅️ | ✅️ | ✅️ | ✅️ |
246+
| [Terrain Generation](xrspatial/terrain.py) | Generates synthetic terrain from fBm or ridged fractal noise with optional domain warping, Worley blending, and hydraulic erosion | ✅️ | ✅️ | ✅️ | ✅️ |
247247
| [TPI](xrspatial/terrain_metrics.py) | Computes Topographic Position Index (center minus mean of neighbors) | ✅️ | ✅️ | ✅️ | ✅️ |
248248
| [TRI](xrspatial/terrain_metrics.py) | Computes Terrain Ruggedness Index (local elevation variation) | ✅️ | ✅️ | ✅️ | ✅️ |
249249
| [Viewshed](xrspatial/viewshed.py) | Determines visible cells from a given observer point on terrain | ✅️ | ✅️ | ✅️ | ✅️ |
250250
| [Min Observable Height](xrspatial/experimental/min_observable_height.py) | Finds the minimum observer height needed to see each cell *(experimental)* | ✅️ | | | |
251251
| [Perlin Noise](xrspatial/perlin.py) | Generates smooth continuous random noise for procedural textures | ✅️ | ✅️ | ✅️ | ✅️ |
252+
| [Worley Noise](xrspatial/worley.py) | Generates cellular (Voronoi) noise returning distance to the nearest feature point | ✅️ | ✅️ | ✅️ | ✅️ |
253+
| [Hydraulic Erosion](xrspatial/erosion.py) | Simulates particle-based water erosion to carve valleys and deposit sediment | ✅️ | 🔄 | 🔄 | 🔄 |
252254
| [Bump Mapping](xrspatial/bump.py) | Adds randomized bump features to simulate natural terrain variation | ✅️ | ✅️ | ✅️ | ✅️ |
253255

254256
-----------

xrspatial/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from xrspatial.classify import reclassify # noqa
1515
from xrspatial.curvature import curvature # noqa
1616
from xrspatial.emerging_hotspots import emerging_hotspots # noqa
17+
from xrspatial.erosion import erode # noqa
1718
from xrspatial.fill import fill # noqa
1819
from xrspatial.fire import burn_severity_class # noqa
1920
from xrspatial.fire import dnbr # noqa

xrspatial/erosion.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
import xarray as xr
5+
from numba import jit
6+
7+
try:
8+
import cupy
9+
from numba import cuda
10+
except ImportError:
11+
class cupy(object):
12+
ndarray = False
13+
cuda = None
14+
15+
try:
16+
import dask.array as da
17+
except ImportError:
18+
da = None
19+
20+
from xrspatial.utils import has_cuda_and_cupy, is_cupy_array, is_dask_cupy
21+
22+
# Default erosion parameters
23+
_DEFAULT_PARAMS = dict(
24+
inertia=0.05,
25+
capacity=4.0,
26+
deposition=0.3,
27+
erosion=0.3,
28+
evaporation=0.01,
29+
gravity=4.0,
30+
min_slope=0.01,
31+
radius=3,
32+
max_lifetime=30,
33+
)
34+
35+
36+
def _build_brush(radius):
37+
"""Precompute brush offsets and weights for the erosion kernel."""
38+
offsets_y = []
39+
offsets_x = []
40+
weights = []
41+
for dy in range(-radius, radius + 1):
42+
for dx in range(-radius, radius + 1):
43+
dist2 = dx * dx + dy * dy
44+
if dist2 <= radius * radius:
45+
w = max(0.0, radius - dist2 ** 0.5)
46+
offsets_y.append(dy)
47+
offsets_x.append(dx)
48+
weights.append(w)
49+
weight_sum = sum(weights)
50+
boy = np.array(offsets_y, dtype=np.int32)
51+
box = np.array(offsets_x, dtype=np.int32)
52+
bw = np.array([w / weight_sum for w in weights], dtype=np.float64)
53+
return boy, box, bw
54+
55+
56+
@jit(nopython=True, nogil=True)
57+
def _erode_cpu(heightmap, random_pos, boy, box, bw,
58+
inertia, capacity, deposition, erosion_rate,
59+
evaporation, gravity, min_slope, max_lifetime):
60+
"""Particle-based hydraulic erosion on a 2D heightmap (CPU).
61+
62+
random_pos : float64 array of shape (n_iterations, 2) with pre-generated
63+
random starting positions in [0, 1).
64+
"""
65+
height, width = heightmap.shape
66+
n_iterations = random_pos.shape[0]
67+
n_brush = bw.shape[0]
68+
69+
for iteration in range(n_iterations):
70+
pos_x = random_pos[iteration, 0] * (width - 3) + 1
71+
pos_y = random_pos[iteration, 1] * (height - 3) + 1
72+
dir_x = 0.0
73+
dir_y = 0.0
74+
speed = 1.0
75+
water = 1.0
76+
sediment = 0.0
77+
78+
for step in range(max_lifetime):
79+
node_x = int(pos_x)
80+
node_y = int(pos_y)
81+
82+
if node_x < 1 or node_x >= width - 2 or node_y < 1 or node_y >= height - 2:
83+
break
84+
85+
fx = pos_x - node_x
86+
fy = pos_y - node_y
87+
88+
h00 = heightmap[node_y, node_x]
89+
h10 = heightmap[node_y, node_x + 1]
90+
h01 = heightmap[node_y + 1, node_x]
91+
h11 = heightmap[node_y + 1, node_x + 1]
92+
93+
grad_x = (h10 - h00) * (1 - fy) + (h11 - h01) * fy
94+
grad_y = (h01 - h00) * (1 - fx) + (h11 - h10) * fx
95+
96+
dir_x = dir_x * inertia - grad_x * (1 - inertia)
97+
dir_y = dir_y * inertia - grad_y * (1 - inertia)
98+
99+
dir_len = (dir_x * dir_x + dir_y * dir_y) ** 0.5
100+
if dir_len < 1e-10:
101+
break
102+
dir_x /= dir_len
103+
dir_y /= dir_len
104+
105+
new_x = pos_x + dir_x
106+
new_y = pos_y + dir_y
107+
108+
if new_x < 1 or new_x >= width - 2 or new_y < 1 or new_y >= height - 2:
109+
break
110+
111+
h_old = h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + \
112+
h01 * (1 - fx) * fy + h11 * fx * fy
113+
114+
new_node_x = int(new_x)
115+
new_node_y = int(new_y)
116+
new_fx = new_x - new_node_x
117+
new_fy = new_y - new_node_y
118+
h_new = (heightmap[new_node_y, new_node_x] * (1 - new_fx) * (1 - new_fy) +
119+
heightmap[new_node_y, new_node_x + 1] * new_fx * (1 - new_fy) +
120+
heightmap[new_node_y + 1, new_node_x] * (1 - new_fx) * new_fy +
121+
heightmap[new_node_y + 1, new_node_x + 1] * new_fx * new_fy)
122+
123+
h_diff = h_new - h_old
124+
125+
sed_capacity = max(-h_diff, min_slope) * speed * water * capacity
126+
127+
if sediment > sed_capacity or h_diff > 0:
128+
if h_diff > 0:
129+
amount = min(h_diff, sediment)
130+
else:
131+
amount = (sediment - sed_capacity) * deposition
132+
133+
sediment -= amount
134+
135+
heightmap[node_y, node_x] += amount * (1 - fx) * (1 - fy)
136+
heightmap[node_y, node_x + 1] += amount * fx * (1 - fy)
137+
heightmap[node_y + 1, node_x] += amount * (1 - fx) * fy
138+
heightmap[node_y + 1, node_x + 1] += amount * fx * fy
139+
else:
140+
amount = min((sed_capacity - sediment) * erosion_rate, -h_diff)
141+
142+
for k in range(n_brush):
143+
ey = node_y + boy[k]
144+
ex = node_x + box[k]
145+
if 0 <= ey < height and 0 <= ex < width:
146+
heightmap[ey, ex] -= amount * bw[k]
147+
148+
sediment += amount
149+
150+
speed_sq = speed * speed + h_diff * gravity
151+
speed = speed_sq ** 0.5 if speed_sq > 0 else 0.0
152+
water *= (1 - evaporation)
153+
154+
pos_x = new_x
155+
pos_y = new_y
156+
157+
return heightmap
158+
159+
160+
def erode(agg, iterations=50000, seed=42, params=None):
161+
"""Apply particle-based hydraulic erosion to a terrain DataArray.
162+
163+
Erosion is a global operation that cannot be chunked, so dask arrays
164+
are materialized before processing and re-wrapped afterwards.
165+
166+
Parameters
167+
----------
168+
agg : xr.DataArray
169+
2D terrain heightmap.
170+
iterations : int
171+
Number of water droplets to simulate.
172+
seed : int
173+
Random seed for droplet placement.
174+
params : dict, optional
175+
Override default erosion constants. Keys:
176+
inertia, capacity, deposition, erosion, evaporation,
177+
gravity, min_slope, radius, max_lifetime.
178+
179+
Returns
180+
-------
181+
xr.DataArray
182+
Eroded terrain.
183+
"""
184+
p = dict(_DEFAULT_PARAMS)
185+
if params is not None:
186+
p.update(params)
187+
188+
data = agg.data
189+
is_dask = da is not None and isinstance(data, da.Array)
190+
is_gpu = False
191+
192+
if is_dask:
193+
if is_dask_cupy(agg):
194+
is_gpu = True
195+
data = data.compute() # cupy ndarray
196+
else:
197+
data = data.compute() # numpy ndarray
198+
199+
if has_cuda_and_cupy() and is_cupy_array(data):
200+
is_gpu = True
201+
202+
# work on a copy
203+
if is_gpu:
204+
hm = cupy.asnumpy(data).astype(np.float64).copy()
205+
else:
206+
hm = data.astype(np.float64).copy()
207+
208+
# precompute brush and random positions outside JIT
209+
boy, box, bw = _build_brush(int(p['radius']))
210+
rng = np.random.RandomState(seed)
211+
random_pos = rng.random((iterations, 2))
212+
213+
hm = _erode_cpu(
214+
hm, random_pos, boy, box, bw,
215+
p['inertia'], p['capacity'], p['deposition'], p['erosion'],
216+
p['evaporation'], p['gravity'], p['min_slope'],
217+
int(p['max_lifetime']),
218+
)
219+
220+
result_data = hm.astype(np.float32)
221+
if is_gpu:
222+
result_data = cupy.asarray(result_data)
223+
224+
if is_dask:
225+
if is_gpu:
226+
result_data = da.from_array(result_data,
227+
chunks=agg.data.chunksize,
228+
meta=cupy.array((), dtype=cupy.float32))
229+
else:
230+
result_data = da.from_array(result_data, chunks=agg.data.chunksize)
231+
232+
return xr.DataArray(result_data, dims=agg.dims, coords=agg.coords,
233+
attrs=agg.attrs, name=agg.name)

0 commit comments

Comments
 (0)