Урок · Процедурна генерація · Шум · Three.js
📅 Липень 2026 ⏱ ≈ 28 хв 🎯 Середній – Просунутий

Процедурна генерація рельєфу з симуляцією ерозії

Звичайний шум Перліна дає горбисту, одноманітну місцевість — без хребтів, долин і річкових дельт справжнього ландшафту. У цьому уроці ми побудуємо повний конвеєр генерації рельєфу: багатошаровий 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) сумує кілька октав шуму зі зростаючою частотою та спадаючою амплітудою, додаючи великі масиви суходолу, середні пагорби й дрібну нерівність за один прохід:

fBm(x, z) = Σ (k=0..octaves-1) amplitude_k · noise(x · freq_k, z · freq_k) freq_k = lacunarity^k (зазвичай 2.0 — кожна октава подвоює частоту) amplitude_k = persistence^k (зазвичай 0.5 — кожна октава зменшує внесок удвічі)
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): симулюємо краплю дощу, яка стікає вниз схилом, підхоплює осад там, де рухається швидко, і відкладає осад там, де сповільнюється.

На кожному кроці краплі: 1. Обчислити градієнт ∇h у позиції краплі (білінійно) 2. dir = dir·inertia − ∇h·(1−inertia), нормалізувати 3. Перемістити краплю на dir · stepSize 4. Δh = height(нова) − height(стара) 5. capacity = max(−Δh, minSlope) · speed · water · sedimentCapacity 6. якщо sediment > capacity → відкласти (sediment − capacity) · deposition якщо sediment ≤ capacity → розмити min((capacity − sediment) · erosion, −Δh) 7. speed = sqrt(speed² + Δh·gravity) [якщо вниз схилом] 8. water *= (1 − evaporation)
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;
}
Чергуйте проходи: запускайте кілька ітерацій термічної ерозії після кожної партії з ~1000 краплин. Чергування гідравлічної та термічної ерозії дає характерне поєднання гладких долин і гострих, стабільних хребтів, яке спостерігається в реальних гірських масивах.

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);
}
Текстурування за нахилом: використовуйте Y-компоненту нормалі (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 };
}
Продуктивність: 100 000 краплин на сітці 512² займають приблизно 1-2 секунди на сучасному настільному CPU в один потік. Перенесіть весь конвеєр у Web Worker і передавайте отриманий Float32Array назад через Transferable без копіювання, щоб не блокувати цикл рендерингу.