Процедурна генерація рельєфу з симуляцією ерозії
Звичайний шум Перліна дає горбисту, одноманітну місцевість — без хребтів, долин і річкових дельт справжнього ландшафту. У цьому уроці ми побудуємо повний конвеєр генерації рельєфу: багатошаровий Simplex-шум для базової карти висот, спотворення простору (domain warping) для органічних форм, симуляцію гідравлічної ерозії на основі краплин для реалістичних долин і наносів, термічну ерозію для осипів на схилах, і меш з нормалями, готовий до рендеру в Three.js.
1. Представлення карти висот
Карта висот (heightfield) — це 2D-сітка чисел з плаваючою комою
height[x][z], що представляє висоту рельєфу. Для
роздільної здатності N×N масив Float32Array довжиною
N² є значно дешевшим для симуляції, ніж повноцінний воксельний
об'єм, і напряму відображається на PlaneGeometry в
Three.js шляхом зміщення кожної вершини вздовж осі Y.
class Heightfield {
constructor(size) {
this.size = size; // сітка розміром size × size
this.data = new Float32Array(size * size);
}
get(x, z) {
x = Math.min(Math.max(x, 0), this.size - 1);
z = Math.min(Math.max(z, 0), this.size - 1);
return this.data[z * this.size + x];
}
set(x, z, h) {
this.data[z * this.size + x] = h;
}
// Білінійна вибірка у дробових координатах — активно використовується ерозією
sample(x, z) {
const x0 = Math.floor(x), z0 = Math.floor(z);
const fx = x - x0, fz = z - z0;
const h00 = this.get(x0, z0), h10 = this.get(x0+1, z0);
const h01 = this.get(x0, z0+1), h11 = this.get(x0+1, z0+1);
const a = h00 * (1-fx) + h10 * fx;
const b = h01 * (1-fx) + h11 * fx;
return a * (1-fz) + b * fz;
}
}
2. Шаруватий шум: фрактальний броунівський рух
Одна октава Simplex-шуму дає гладкі, округлі пагорби без дрібних деталей. Фрактальний броунівський рух (fBm) сумує кілька октав шуму зі зростаючою частотою та спадаючою амплітудою, додаючи великі масиви суходолу, середні пагорби й дрібну нерівність за один прохід:
function fbm(x, z, {
octaves = 6, lacunarity = 2.0, persistence = 0.5, scale = 0.01
} = {}) {
let total = 0, amplitude = 1, frequency = 1, maxAmp = 0;
for (let o = 0; o < octaves; o++) {
total += simplex2(x * scale * frequency, z * scale * frequency) * amplitude;
maxAmp += amplitude;
amplitude *= persistence;
frequency *= lacunarity;
}
return total / maxAmp; // нормалізовано приблизно до [-1, 1]
}
1 - Math.abs(noise) у кожній октаві замість
звичайного шуму. Це "згортає" долини у гострі хребти й лежить в
основі більшості "генераторів гір".
3. Спотворення простору (domain warping)
Звичайний fBm все ще виглядає прив'язаним до сітки й повторюваним при уважному погляді. Спотворення простору зміщує вхідні координати іншим полем шуму перед вибіркою, розбиваючи прямі хребти на органічні, звивисті форми — техніка, популяризована Inigo Quilez:
function warpedHeight(x, z) {
// Вибираємо низькочастотне поле шуму, щоб отримати вектор спотворення
const warpX = fbm(x + 5.2, z + 1.3, { octaves: 3, scale: 0.02 });
const warpZ = fbm(x + 9.1, z + 7.4, { octaves: 3, scale: 0.02 });
const strength = 40; // амплітуда спотворення у світових одиницях
return fbm(
x + warpX * strength,
z + warpZ * strength,
{ octaves: 6, scale: 0.01 }
);
}
4. Гідравлічна ерозія: симуляція краплин
Справжній рельєф вирізаний мільйонами років дощів. Найшвидше практичне наближення — це ерозія на основі частинок (краплин) (алгоритм краплин Ганса Байєра, також використовується у World Machine та в інструментах рельєфу Unity): симулюємо краплю дощу, яка стікає вниз схилом, підхоплює осад там, де рухається швидко, і відкладає осад там, де сповільнюється.
function simulateDroplet(field, x, z, params) {
let pos = { x, z };
let dir = { x: 0, z: 0 };
let speed = 1, water = 1, sediment = 0;
for (let step = 0; step < params.maxSteps; step++) {
const grad = gradient(field, pos.x, pos.z);
dir.x = dir.x * params.inertia - grad.x * (1 - params.inertia);
dir.z = dir.z * params.inertia - grad.z * (1 - params.inertia);
const len = Math.hypot(dir.x, dir.z) || 1;
dir.x /= len; dir.z /= len;
const oldHeight = field.sample(pos.x, pos.z);
pos.x += dir.x; pos.z += dir.z;
if (!inBounds(field, pos)) break;
const newHeight = field.sample(pos.x, pos.z);
const deltaH = newHeight - oldHeight;
const capacity = Math.max(-deltaH, params.minSlope)
* speed * water * params.sedimentCapacity;
if (deltaH > 0 || sediment > capacity) {
// вгору схилом або перевантажено → відкладаємо
const deposit = deltaH > 0
? Math.min(deltaH, sediment)
: (sediment - capacity) * params.depositionRate;
sediment -= deposit;
addHeight(field, pos.x, pos.z, deposit);
} else {
// вниз схилом і нижче ємності → розмиваємо
const erode = Math.min((capacity - sediment) * params.erosionRate, -deltaH);
sediment += erode;
addHeight(field, pos.x, pos.z, -erode);
}
speed = Math.sqrt(Math.max(0, speed * speed + deltaH * params.gravity));
water *= (1 - params.evaporation);
if (water < 0.01) break;
}
}
// Запускайте 50 000–200 000 краплин з випадкових точок поверхні для карти 512²
function erode(field, dropletCount, params) {
for (let i = 0; i < dropletCount; i++) {
const x = Math.random() * field.size;
const z = Math.random() * field.size;
simulateDroplet(field, x, z, params);
}
}
addHeight розподіляє відкладену/розмиту величину між 4
сусідніми клітинками сітки з тими самими білінійними вагами, що й
при вибірці — інакше крапля "прилипне" до цілих клітинок і
залишить видимі артефакти сітки.
5. Термічна ерозія: осипання схилів
Сама лише гідравлічна ерозія залишає схили крутішими, ніж може утримати будь-який реальний матеріал. Термічна ерозія моделює переміщення сипкого матеріалу вниз під дією гравітації щоразу, коли схил перевищує кут природного укосу (talus angle) — близько 30-40° для сухого ґрунту та кам'яного осипу:
function thermalErosion(field, talusAngle = 0.6, amount = 0.5) {
const next = field.data.slice(); // подвійна буферизація: читаємо старе, пишемо нове
for (let z = 1; z < field.size - 1; z++) {
for (let x = 1; x < field.size - 1; x++) {
const h = field.get(x, z);
let maxDrop = 0, targetX = x, targetZ = z;
// Знаходимо найкрутішого сусіда вниз схилом серед 8 сусідів
for (const [dx, dz] of NEIGHBORS_8) {
const nh = field.get(x + dx, z + dz);
const drop = h - nh;
if (drop > maxDrop) { maxDrop = drop; targetX = x + dx; targetZ = z + dz; }
}
if (maxDrop > talusAngle) {
// Переміщуємо половину надлишку понад кут укосу до найнижчого сусіда
const transfer = (maxDrop - talusAngle) * amount * 0.5;
next[z * field.size + x] -= transfer;
next[targetZ * field.size + targetX] += transfer;
}
}
}
field.data = next;
}
6. Акумуляція потоку та річки
Щоб відобразити видимі річки, обчисліть карту акумуляції потоку: обробляйте клітинки від найвищої до найнижчої висоти й на кожній клітинці передавайте її кількість води найкрутішому сусіду вниз схилом (алгоритм D8). Клітинки з великим накопиченим потоком — це річки, решта — схилова місцевість.
function flowAccumulation(field) {
const n = field.size * field.size;
const flow = new Float32Array(n).fill(1); // кожна клітинка починає з 1 одиниці дощу
const order = [...Array(n).keys()].sort(
(a, b) => field.data[b] - field.data[a] // спершу найвищі
);
for (const i of order) {
const x = i % field.size, z = Math.floor(i / field.size);
const h = field.data[i];
let steepest = -Infinity, targetIdx = -1;
for (const [dx, dz] of NEIGHBORS_8) {
const nx = x + dx, nz = z + dz;
if (!inBoundsXZ(field, nx, nz)) continue;
const drop = (h - field.get(nx, nz)) / Math.hypot(dx, dz);
if (drop > steepest) { steepest = drop; targetIdx = nz * field.size + nx; }
}
if (targetIdx >= 0 && steepest > 0) flow[targetIdx] += flow[i];
}
return flow; // log(flow) > поріг ⇒ малювати як річку на текстурі рельєфу
}
7. Побудова мешу для Three.js
Перетворіть розмиту карту висот на BufferGeometry та
обчисліть нормалі для кожної вершини методом кінцевих різниць,
щоб матеріал коректно затінювався без додаткового проходу з
normal map:
function buildTerrainMesh(field, worldSize) {
const geo = new THREE.PlaneGeometry(
worldSize, worldSize, field.size - 1, field.size - 1
);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = i % field.size, z = Math.floor(i / field.size);
pos.setY(i, field.get(x, z) * 40); // масштаб висоти у світових одиницях
}
geo.computeVertexNormals(); // виводить нормалі затінення з деформованої сітки
const mat = new THREE.MeshStandardMaterial({
vertexColors: true, roughness: 0.95, flatShading: false
});
return new THREE.Mesh(geo, mat);
}
dot(normal, up)) як фактор
змішування між вершинним кольором трави на рівних ділянках і
кольором каменю на крутих схилах — дешева альтернатива повному
triplanar-шейдеру.
8. Збираємо все разом
Повний конвеєр генерації виконується один раз, офлайн (у Web Worker для карти 512², щоб основний потік залишався відповідальним), і видає статичний меш:
function generateTerrain(size = 512) {
const field = new Heightfield(size);
// 1. Базова форма зі спотвореного fBm
for (let z = 0; z < size; z++)
for (let x = 0; x < size; x++)
field.set(x, z, warpedHeight(x, z));
// 2. Чергуємо гідравлічну та термічну ерозію партіями
const erosionParams = {
maxSteps: 64, inertia: 0.05, minSlope: 0.01,
sedimentCapacity: 4, depositionRate: 0.3, erosionRate: 0.3,
gravity: 4, evaporation: 0.02
};
for (let batch = 0; batch < 100; batch++) {
erode(field, 1000, erosionParams);
thermalErosion(field, 0.6, 0.5);
}
// 3. Виводимо річки для текстурування
const flow = flowAccumulation(field);
return { mesh: buildTerrainMesh(field, 1000), field, flow };
}
Float32Array назад через
Transferable без копіювання, щоб не блокувати цикл
рендерингу.