Процедурна генерація рельєфу з симуляцією ерозії
Звичайний шум Перліна дає горбисту, одноманітну місцевість — без хребтів, долин і річкових дельт справжнього ландшафту. У цьому уроці ми побудуємо повний конвеєр генерації рельєфу: багатошаровий 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 без копіювання, щоб не блокувати цикл
рендерингу.
Часті запитання
Чого я навчуся в цьому уроці?
Створіть реалістичний 3D-рельєф з нуля: багатошарова карта висот на Simplex-шумі, спотворення простору (domain warping), гідравлічна ерозія краплинами, термічна ерозія та меш з нормалями для Three.js.
Які теми розглядаються в цьому уроці?
Цей урок охоплює такі теми: Представлення карти висот, Шаруватий шум: фрактальний броунівський рух, Спотворення простору (domain warping), Гідравлічна ерозія: симуляція краплин, Термічна ерозія: осипання схилів, Акумуляція потоку та річки, Побудова мешу для Three.js, Збираємо все разом.
Скільки часу займає цей урок?
Цей урок займає приблизно 28 хв.
Які попередні знання потрібні?
Це урок рівня «Середній – Просунутий» — окрема попередня підготовка, крім базового JavaScript, не потрібна.