Skip to content

Commit 00cc6c9

Browse files
committed
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
1 parent 0740651 commit 00cc6c9

File tree

6 files changed

+1185
-207
lines changed

6 files changed

+1185
-207
lines changed

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)