Урок · Середній рівень · ~60 хв
Three.js · Шум Перліна · Ерозія

Процедурний рельєф із гідравлічною ерозією

Згенеруйте гірський ландшафт у браузері за допомогою фрактального броунівського руху, а потім запустіть симуляцію гідравлічної ерозії на основі крапель, щоб вирізьбити реалістичні долини, річки та осипні конуси — і все це без сервера чи кроку збірки.

1Генеруємо карту висот за допомогою fBm

Фрактальний броунівський рух (fBm) накладає кілька частот шуму Перліна (їх називають октавами). Кожна октава масштабується коефіцієнтом стійкості (persistence) та множником лакунарності (lacunarity):

// Мінімальний 2D-шум у стилі Перліна (для стислості — value noise) function hash(x, y) { const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return n - Math.floor(n); } function smoothNoise(x, y) { const ix = Math.floor(x), iy = Math.floor(y); const fx = x - ix, fy = y - iy; const ux = fx * fx * (3 - 2 * fx); const uy = fy * fy * (3 - 2 * fy); const a = hash(ix, iy), b = hash(ix+1, iy); const c = hash(ix, iy+1), d = hash(ix+1, iy+1); return a + (b-a)*ux + (c-a)*uy + (b-a+a-b-c+d)*ux*uy; } function fbm(x, y, octaves = 6, persistence = 0.5, lacunarity = 2.0) { let value = 0, amplitude = 1, frequency = 1, maxValue = 0; for (let i = 0; i < octaves; i++) { value += amplitude * smoothNoise(x * frequency, y * frequency); maxValue += amplitude; amplitude *= persistence; frequency *= lacunarity; } return value / maxValue; // нормалізовано 0…1 } // Будуємо пласку карту висот Float32Array const N = 512; const heightMap = new Float32Array(N * N); for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) heightMap[y * N + x] = fbm(x / 128, y / 128);
Порада: Для природнішого вигляду застосуйте викривлення області (domain warping) перед семплюванням fBm: fbm(x + fbm(x+0.1, y+1.3), y + fbm(x+5.2, y+1.7)). Це надає рельєфу «скрученого» вигляду — чудово підходить для безплідних земель та вулканічних ландшафтів.

2Будуємо меш рельєфу у Three.js

Використайте PlaneGeometry та оновіть його буфер вершин, щоб відобразити карту висот. Зміщуйте кожну вершину вздовж осі Y:

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; const SEGMENTS = 511; // N-1 для сітки з N точок const SIZE = 100; const HEIGHT = 20; // коефіцієнт вертикального масштабу const geometry = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS); geometry.rotateX(-Math.PI / 2); // робимо площину горизонтальною const positions = geometry.attributes.position; for (let i = 0; i < positions.count; i++) { const h = heightMap[i]; // вже нормалізовано 0…1 positions.setY(i, h * HEIGHT); } positions.needsUpdate = true; geometry.computeVertexNormals(); const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial({ vertexColors: true, wireframe: false })); scene.add(mesh);

3Розфарбовуємо за висотою

Додайте до геометрії атрибут кольорів. Зіставте діапазони висот із біомами рельєфу — глибока вода, мілкий пляж, трава, скелі, сніг:

const palette = [ { t: 0.00, r: 0.02, g: 0.07, b: 0.35 }, // глибока вода { t: 0.25, r: 0.05, g: 0.18, b: 0.55 }, // мілка вода { t: 0.30, r: 0.82, g: 0.78, b: 0.58 }, // пісок { t: 0.40, r: 0.34, g: 0.61, b: 0.26 }, // трава { t: 0.65, r: 0.40, g: 0.35, b: 0.30 }, // скелі { t: 1.00, r: 0.95, g: 0.95, b: 0.98 } // сніг ]; function samplePalette(t) { for (let i = 0; i < palette.length - 1; i++) { const lo = palette[i], hi = palette[i + 1]; if (t <= hi.t) { const s = (t - lo.t) / (hi.t - lo.t); return { r: lo.r + (hi.r-lo.r)*s, g: lo.g + (hi.g-lo.g)*s, b: lo.b + (hi.b-lo.b)*s }; } } return palette[palette.length - 1]; } const colors = new Float32Array(positions.count * 3); for (let i = 0; i < positions.count; i++) { const h = heightMap[i]; const c = samplePalette(h); colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b; } geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

4Симуляція гідравлічної ерозії

Скиньте віртуальну краплю води у випадковій позиції. Вона ковзає вниз по найкрутішому градієнту, підхоплює осад і відкладає його, коли сповільнюється. Повторіть для 50 000–150 000 крапель:

function erode(heightMap, N, numDroplets = 80000) { for (let d = 0; d < numDroplets; d++) { let px = Math.random() * (N - 2) + 1; let py = Math.random() * (N - 2) + 1; let vx = 0, vy = 0; // швидкість let water = 1, sediment = 0; const inertia = 0.05, capacity = 8, erosion = 0.3, deposition = 0.3, gravity = 4, evaporation = 0.01; for (let step = 0; step < 128; step++) { const ix = Math.floor(px), iy = Math.floor(py); if (ix < 1 || ix >= N-1 || iy < 1 || iy >= N-1) break; // Білінійна висота та градієнт const fx = px - ix, fy = py - iy; const h00 = heightMap[iy*N+ix], h10 = heightMap[iy*N+ix+1]; const h01 = heightMap[(iy+1)*N+ix], h11 = heightMap[(iy+1)*N+ix+1]; const gx = (h10-h00)*(1-fy) + (h11-h01)*fy; const gy = (h01-h00)*(1-fx) + (h11-h10)*fx; vx = vx * inertia - gx * (1 - inertia); vy = vy * inertia - gy * (1 - inertia); const speed = Math.sqrt(vx*vx + vy*vy); if (speed < 1e-6) break; vx /= speed; vy /= speed; const sedCap = Math.max(-( gx*vx + gy*vy ), 0.01) * speed * water * capacity; if (sediment > sedCap) { // відкладення const dep = (sediment - sedCap) * deposition; sediment -= dep; heightMap[iy*N+ix] += dep * (1-fx)*(1-fy); heightMap[iy*N+ix+1] += dep * fx*(1-fy); heightMap[(iy+1)*N+ix] += dep * (1-fx)*fy; heightMap[(iy+1)*N+ix+1] += dep * fx*fy; } else { // ерозія const er = Math.min((sedCap - sediment) * erosion, 0.05); sediment += er; heightMap[iy*N+ix] -= er * (1-fx)*(1-fy); heightMap[iy*N+ix+1] -= er * fx*(1-fy); heightMap[(iy+1)*N+ix] -= er * (1-fx)*fy; heightMap[(iy+1)*N+ix+1] -= er * fx*fy; } px += vx; py += vy; water *= (1 - evaporation); } } }
Продуктивність: Обробка 80 000 крапель на CPU займає 200–500 мс. Перенесіть цикл у Web Worker, щоб потік UI залишався чутливим під час обчислення ерозії. Передавайте буфер Float32Array через postMessage(..., [buffer]) для передачі без копіювання.

5Додаємо водну поверхню та освітлення

// Пласка напівпрозора водна поверхня на рівні моря const waterGeo = new THREE.PlaneGeometry(SIZE, SIZE); waterGeo.rotateX(-Math.PI / 2); const waterMat = new THREE.MeshStandardMaterial({ color: 0x1a6fa8, transparent: true, opacity: 0.75, roughness: 0.05 }); const water = new THREE.Mesh(waterGeo, waterMat); water.position.y = HEIGHT * 0.27; // 27% від макс. висоти = рівень моря scene.add(water); // Направлене (сонячне) світло з тінями const sun = new THREE.DirectionalLight(0xfff5e0, 2.5); sun.position.set(60, 80, 40); sun.castShadow = true; sun.shadow.mapSize.width = sun.shadow.mapSize.height = 2048; sun.shadow.camera.near = 0.5; sun.shadow.camera.far = 300; sun.shadow.camera.left = sun.shadow.camera.bottom = -80; sun.shadow.camera.right = sun.shadow.camera.top = 80; scene.add(sun); scene.add(new THREE.AmbientLight(0x8090a0, 0.4)); renderer.shadowMap.enabled = true;

6Продуктивність та нотатки про LOD