gameterrain / src /postprocess /terrain_ops.py
paroschina's picture
GameTerrain demo: CVAE terrain generator
efcb433 verified
Raw
History Blame Contribute Delete
15.8 kB
"""terrain_ops.py — слой ПОСТОБРАБОТКИ heightmap между CVAE и рендером.
Зачем: выход CVAE гладкий/низкочастотный (пиксельный лосс + усреднение латента →
размытость — известное свойство VAE). Эти операции добавляют геоморфологическую
структуру (долины, гребни, осыпи, изломанные хребты) ПОВЕРХ макроформы CVAE.
Концептуальная рамка (честно для защиты): CVAE задаёт управляемую МАКРОСТРУКТУРУ
(где горы, какой тип, какой масштаб — это её вклад и метрики). Постобработка
УСИЛИВАЕТ эту форму, а не создаёт свою (так делают StyleDEM и pro terrain-tools).
Эрозия держится умеренной; проверяется, что низкочастотная форма осталась от модели
(см. macroform_correlation).
Всё — чистый numpy/scipy, без Blender. Каждая операция принимает heightmap [H,W]
float и возвращает обработанный heightmap того же размера.
Алгоритм гидравлической эрозии адаптирован из общеизвестных реализаций
(Sebastian Lague / dandrino terrain-erosion-3-ways / Job Talle): droplet-based —
капля катится по градиенту, набирает/осаждает осадок, прорезает русла и гребни.
"""
from __future__ import annotations
import numpy as np
from scipy.ndimage import gaussian_filter
# --------------------------------------------------------------------------
def normalize01(h: np.ndarray) -> np.ndarray:
h = h.astype(np.float64)
lo, hi = float(h.min()), float(h.max())
return (h - lo) / (hi - lo + 1e-12)
def _make_brush(radius: int, shape):
"""Веса кисти эрозии (распределяем эрозию по окрестности — без пик-артефактов).
Возвращает (offsets [K,2] int, weights [K]) — смещения вокруг ячейки и веса
(1 - dist/radius), нормированные к сумме 1.
"""
H, W = shape
offs, wts = [], []
r = int(radius)
for dy in range(-r, r + 1):
for dx in range(-r, r + 1):
d = np.hypot(dx, dy)
if d <= radius:
offs.append((dy, dx))
wts.append(1.0 - d / radius)
offs = np.array(offs, np.int64)
wts = np.array(wts, np.float64)
wts /= wts.sum()
return offs, wts
def _height_and_grad(h, x, y):
"""Билинейная высота + градиент в точке (x, y) непрерывных координат."""
H, W = h.shape
x0 = int(x); y0 = int(y)
fx = x - x0; fy = y - y0
x1 = min(x0 + 1, W - 1); y1 = min(y0 + 1, H - 1)
h00 = h[y0, x0]; h10 = h[y0, x1]; h01 = h[y1, x0]; h11 = h[y1, x1]
# градиент (по Лагью): интерполяция разностей по краям ячейки
gx = (h10 - h00) * (1 - fy) + (h11 - h01) * fy
gy = (h01 - h00) * (1 - fx) + (h11 - h10) * fx
height = (h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy)
+ h01 * (1 - fx) * fy + h11 * fx * fy)
return height, gx, gy
# --------------------------------------------------------------------------
def hydraulic_erosion(
h: np.ndarray,
num_droplets: int = 60000,
*,
seed: int = 0,
lifetime: int = 30,
inertia: float = 0.05,
sediment_capacity: float = 4.0,
min_slope: float = 0.01,
erode_speed: float = 0.3,
deposit_speed: float = 0.3,
evaporation: float = 0.01,
gravity: float = 4.0,
erosion_radius: int = 3,
init_speed: float = 1.0,
init_water: float = 1.0,
) -> np.ndarray:
"""Droplet-based гидравлическая эрозия. Вход/выход — heightmap [H,W].
Нормируется в [0,1] внутри (параметры калиброваны под этот диапазон), на выходе
масштабируется обратно в исходный диапазон значений входа.
num_droplets ~ 30-60k на 256x256. Больше капель -> выраженнее русла/гребни.
"""
lo, hi = float(h.min()), float(h.max())
H, W = h.shape
hm = ((h.astype(np.float64) - lo) / (hi - lo + 1e-12)).copy() # [0,1]
rng = np.random.default_rng(seed)
brush_off, brush_w = _make_brush(erosion_radius, (H, W))
for _ in range(num_droplets):
x = rng.uniform(1, W - 2)
y = rng.uniform(1, H - 2)
dx = dy = 0.0
speed = init_speed
water = init_water
sediment = 0.0
for _step in range(lifetime):
x0 = int(x); y0 = int(y)
fx = x - x0; fy = y - y0
height, gx, gy = _height_and_grad(hm, x, y)
# направление с инерцией
dx = dx * inertia - gx * (1 - inertia)
dy = dy * inertia - gy * (1 - inertia)
mag = np.hypot(dx, dy)
if mag < 1e-8:
break
dx /= mag; dy /= mag
nx = x + dx; ny = y + dy
if nx < 1 or nx >= W - 1 or ny < 1 or ny >= H - 1:
break
new_height, _, _ = _height_and_grad(hm, nx, ny)
dh = new_height - height # <0 = вниз
capacity = max(-dh, min_slope) * speed * water * sediment_capacity
if sediment > capacity or dh > 0:
# осаждение (вверх — сбрасываем; избыток осадка — осаждаем)
if dh > 0:
amt = min(dh, sediment)
else:
amt = (sediment - capacity) * deposit_speed
sediment -= amt
# билинейно в 4 ячейки текущей позиции
hm[y0, x0] += amt * (1 - fx) * (1 - fy)
hm[y0, min(x0 + 1, W - 1)] += amt * fx * (1 - fy)
hm[min(y0 + 1, H - 1), x0] += amt * (1 - fx) * fy
hm[min(y0 + 1, H - 1), min(x0 + 1, W - 1)] += amt * fx * fy
else:
# эрозия (не больше, чем перепад вниз; распределяем кистью)
amt = min((capacity - sediment) * erode_speed, -dh)
ys = y0 + brush_off[:, 0]; xs = x0 + brush_off[:, 1]
ok = (ys >= 0) & (ys < H) & (xs >= 0) & (xs < W)
take = amt * brush_w[ok]
# не уносим ниже нуля
take = np.minimum(take, hm[ys[ok], xs[ok]])
hm[ys[ok], xs[ok]] -= take
sediment += take.sum()
speed = np.sqrt(max(0.0, speed * speed + dh * gravity))
water *= (1 - evaporation)
x, y = nx, ny
hm = np.clip(hm, 0.0, None)
hm = (hm - hm.min()) / (hm.max() - hm.min() + 1e-12) # ренормируем [0,1]
return (hm * (hi - lo) + lo).astype(np.float32) # обратно в диапазон входа
# --- 3. термальная эрозия (осыпи на крутых склонах) -----------------------
def thermal_erosion(h, iterations=60, talus=0.004, factor=0.5):
"""Где локальный перепад к соседу > угла естественного откоса (talus) — часть
материала осыпается вниз. Несколько итераций -> прямые осыпные (scree) склоны.
Векторизовано (8 соседей через np.roll), материал сохраняется."""
lo, hi = float(h.min()), float(h.max())
hm = ((h.astype(np.float64) - lo) / (hi - lo + 1e-12)).copy()
for _ in range(iterations):
out = np.zeros_like(hm)
inflow = np.zeros_like(hm)
for axis, sh in [(1, 1), (1, -1), (0, 1), (0, -1)]:
nb = np.roll(hm, -sh, axis=axis) # сосед в направлении +sh
d = hm - nb
move = np.where(d > talus, (d - talus) * factor * 0.25, 0.0)
out += move
inflow += np.roll(move, sh, axis=axis) # материал приходит к соседу
hm = hm - out + inflow
hm = (hm - hm.min()) / (hm.max() - hm.min() + 1e-12)
return (hm * (hi - lo) + lo).astype(np.float32)
# --- 4. нелинейное усиление пиков (power transform) -----------------------
def power_transform(h, gamma=0.7):
"""h_norm ** gamma. gamma<1 поднимает вершины (острые пики), gamma>1 прижимает.
Долины (near 0) почти не меняются, вершины резко выше."""
lo, hi = float(h.min()), float(h.max())
hn = np.clip((h.astype(np.float64) - lo) / (hi - lo + 1e-12), 0, 1)
return (hn ** float(gamma) * (hi - lo) + lo).astype(np.float32)
# --- 5. domain warping (изломанные гребни вместо плавных волн) -------------
def domain_warp(h, strength=7.0, noise_scale=16.0, seed=0):
"""Сэмплируем h по ИСКАЖЁННЫМ координатам: warped(x,y)=h(x+a*n1, y+a*n2).
Гладкие волны -> изломанные гребни. strength в пикселях, noise_scale=sigma шума."""
from scipy.ndimage import map_coordinates
H, W = h.shape
r = np.random.default_rng(seed)
ox = gaussian_filter(r.standard_normal((H, W)), noise_scale)
oy = gaussian_filter(r.standard_normal((H, W)), noise_scale)
ox /= (np.abs(ox).max() + 1e-9); oy /= (np.abs(oy).max() + 1e-9)
yy, xx = np.mgrid[0:H, 0:W].astype(np.float64)
warped = map_coordinates(h.astype(np.float64),
[yy + strength * oy, xx + strength * ox],
order=1, mode="reflect")
return warped.astype(np.float32)
# --- 6. маска деталей по уклону/кривизне (детали в логичных местах) --------
def detail_by_curvature(h, amp=0.06, detail_sigma=1.1, slope_gain=2.2, seed=0):
"""Высокочастотная деталь ТОЛЬКО на склонах/гребнях (маска по уклону), равнины и
долины остаются гладкими -> бьёт в «шум везде как пластилин»."""
lo, hi = float(h.min()), float(h.max())
hn = (h.astype(np.float64) - lo) / (hi - lo + 1e-12)
gy, gx = np.gradient(hn)
slope = np.hypot(gx, gy)
mask = np.clip(slope / (slope.max() + 1e-9) * slope_gain, 0, 1)
r = np.random.default_rng(seed)
noise = gaussian_filter(r.standard_normal(h.shape), detail_sigma)
noise -= noise.mean(); noise /= (np.abs(noise).max() + 1e-9)
out = hn + amp * mask * noise
out = (out - out.min()) / (out.max() - out.min() + 1e-12)
return (out * (hi - lo) + lo).astype(np.float32)
# --- комбинированный конвейер ---------------------------------------------
def full_pipeline(h, *, gamma=0.8, warp_strength=6.0, droplets=70000,
thermal_iters=50, detail_amp=0.05, seed=0):
"""Лучшая комбинация (порядок важен): warp -> power -> hydraulic -> thermal -> detail.
Сначала ломаем гребни и поднимаем пики, потом режем русла, осыпаем склоны и
добавляем мелкую фактуру только на склонах."""
x = domain_warp(h, strength=warp_strength, seed=seed)
x = power_transform(x, gamma=gamma)
x = hydraulic_erosion(x, num_droplets=droplets, seed=seed, lifetime=40,
erode_speed=0.4, sediment_capacity=6.0, min_slope=0.001,
erosion_radius=2, deposit_speed=0.25)
x = thermal_erosion(x, iterations=thermal_iters, talus=0.005, factor=0.5)
x = detail_by_curvature(x, amp=detail_amp, seed=seed)
return x
# --- 2. сила обработки ПО КЛАССУ (решает «типы не различаются») ------------
def process_by_class(h, terrain_type, seed=0):
"""Разная постобработка по типу рельефа -> типы становятся СТРУКТУРНО разными,
не только по амплитуде (по мотивам Minecraft worldgen: эрозия решает плоское
vs гористое). Термальная держится ЛЁГКОЙ, чтобы не съесть русла эрозии.
flat — почти не трогаем (gamma прижимает в равнину, чуть эрозии);
hilly — слабая эрозия + мягкая gamma -> пологие складки;
mountain — warp + острые пики + сильная эрозия -> выраженные гребни/долины.
"""
t = terrain_type
if t == "flat":
x = power_transform(h, gamma=2.2)
x = hydraulic_erosion(x, num_droplets=8000, seed=seed, lifetime=28,
erode_speed=0.3, sediment_capacity=4.0, min_slope=0.002,
erosion_radius=2)
return detail_by_curvature(x, amp=0.02, seed=seed)
if t == "hilly":
x = domain_warp(h, strength=5.0, noise_scale=16.0, seed=seed)
x = power_transform(x, gamma=1.4)
x = hydraulic_erosion(x, num_droplets=35000, seed=seed, lifetime=35,
erode_speed=0.35, sediment_capacity=5.0, min_slope=0.0015,
erosion_radius=2)
x = thermal_erosion(x, iterations=6, talus=0.006, factor=0.5)
return detail_by_curvature(x, amp=0.035, seed=seed)
# mountain
x = domain_warp(h, strength=7.0, noise_scale=13.0, seed=seed)
x = power_transform(x, gamma=0.72)
x = hydraulic_erosion(x, num_droplets=85000, seed=seed, lifetime=42,
erode_speed=0.45, sediment_capacity=7.0, min_slope=0.001,
erosion_radius=2, deposit_speed=0.25)
x = thermal_erosion(x, iterations=6, talus=0.007, factor=0.5) # очень лёгкая: не съесть русла
return detail_by_curvature(x, amp=0.05, seed=seed)
# --------------------------------------------------------------------------
def macroform_correlation(h_raw: np.ndarray, h_proc: np.ndarray, sigma: float = 8.0) -> float:
"""Корреляция НИЗКОЧАСТОТНЫХ форм raw и обработанного (контроль: эрозия усилила,
а не подменила макроформу CVAE). Близко к 1 = крупная форма сохранена."""
a = gaussian_filter(normalize01(h_raw), sigma)
b = gaussian_filter(normalize01(h_proc), sigma)
a = a - a.mean(); b = b - b.mean()
denom = np.sqrt((a * a).sum() * (b * b).sum()) + 1e-12
return float((a * b).sum() / denom)
# --------------------------------------------------------------------------
def quick_stats(h: np.ndarray) -> dict:
"""Грубые метрики для быстрой проверки (полный набор — в terrain_metrics.py)."""
hn = normalize01(h)
gy, gx = np.gradient(hn)
slope = np.hypot(gx, gy)
return {
"roughness_grad": float(slope.mean()),
"slope_p90": float(np.quantile(slope, 0.90)),
"std_local": float(hn.std()),
}