Генерація процедурного рельєфу
Генерація ландшафту за допомогою шуму Перліна: пласка площина перетворюється на гористий рельєф шляхом зміщення вершин відповідно до кількох октав багатошарового шуму (fBm). Цей урок охоплює підхід із картою висот на CPU (зміна вершин геометрії), підхід із вершинним шейдером на GPU, перерахунок нормалей для коректного освітлення та колірний градієнт за висотою — від океану до снігу.
- Пройдено Основи Three.js та Вступ до шейдерів WebGL
- Розуміння шуму fBm з уроку Шейдер вогню на GLSL
Шум Перліна у JavaScript
Ми використаємо просту реалізацію градієнтного шуму. npm-пакет
simplex-noise — найпростіший варіант для продакшену, але
задля навчання напишімо ядро з нуля:
// Мінімальний 2D-градієнтний шум (у стилі Перліна)
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
function lerp(a, b, t) { return a + t * (b - a); }
function grad(hash, x, y) {
const h = hash & 3;
return ((h === 0 ? x : h === 1 ? -x : h === 2 ? y : -y)
+ (h < 2 ? y : -y)) * 0.5; // простий набір із 4 градієнтів
}
// Таблиця перестановок
const P = new Uint8Array(512);
const perm = Array.from({length: 256}, (_, i) => i);
for (let i = 255; i > 0; i--) { // перемішування
const j = Math.floor(Math.random() * (i + 1));
[perm[i], perm[j]] = [perm[j], perm[i]];
}
for (let i = 0; i < 512; i++) P[i] = perm[i & 255];
function noise2D(x, y) {
const xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
const xf = x - Math.floor(x), yf = y - Math.floor(y);
const u = fade(xf), v = fade(yf);
const a = P[xi] + yi, b = P[xi+1] + yi;
return lerp(
lerp(grad(P[a], xf, yf), grad(P[b], xf-1, yf), u),
lerp(grad(P[a+1], xf, yf-1), grad(P[b+1], xf-1, yf-1), u),
v
);
}
Для продакшену використовуйте
import { createNoise2D } from 'simplex-noise' —
швидше, краща якість, для цього уроку працює ідентично.
Генерація карти висот fBm
Накладіть 6 октав, щоб отримати природний рельєф з великомасштабними горами та дрібними деталями водночас:
function fbm(x, y, octaves = 6) {
let value = 0, amp = 0.5, freq = 1;
for (let i = 0; i < octaves; i++) {
value += amp * noise2D(x * freq, y * freq);
amp *= 0.5;
freq *= 2.1; // невелика лакунарність >2 додає нерегулярності
}
return value; // діапазон ≈ [-1, 1]
}
// Застосовуємо маску "континенту" — краї звужуються до океану
function heightAt(nx, ny) {
// nx, ny у [-1, 1] — нормалізовані координати області
const edge = 1 - Math.max(Math.abs(nx), Math.abs(ny)); // 0 на краю, 1 у центрі
const terrain = fbm(nx * 3, ny * 3); // масштаб на свій смак
return terrain * edge; // нуль на межах = берегова лінія океану
}
Застосування до вершин PlaneGeometry
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';
const SEG = 200; // роздільність сітки (201×201 вершин)
const SIZE = 100; // розмір світу в одиницях
const HEIGHT_SCALE = 20; // максимальна висота рельєфу
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEG, SEG);
geo.rotateX(-Math.PI / 2); // кладемо площину горизонтально (площина XZ)
const pos = geo.attributes.position;
const colors = [];
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
// Нормалізовані координати [-1, 1]
const nx = x / (SIZE / 2);
const nz = z / (SIZE / 2);
const h = heightAt(nx, nz) * HEIGHT_SCALE;
pos.setY(i, h); // зміщуємо вгору
}
pos.needsUpdate = true;
Перерахунок нормалей
Після зміни позицій вершин початкові нормалі стають некоректними. Three.js може перерахувати їх автоматично:
geo.computeVertexNormals(); // ← перерахунок нормалей зі зміщених позицій
// Тепер MeshStandardMaterial освітлюватиме рельєф коректно
const mat = new THREE.MeshStandardMaterial({
vertexColors: true, // далі призначимо колір кожній вершині
wireframe: false,
side: THREE.FrontSide,
});
Колірний градієнт за висотою
Призначте колір кожній вершині залежно від висоти: темно-синій (вода) → пісок → зелений → скеля → білий (сніг):
const palette = [
{ h: -2.0, col: [0.05, 0.12, 0.40] }, // глибока вода
{ h: -0.5, col: [0.10, 0.25, 0.55] }, // мілка вода
{ h: 0.5, col: [0.82, 0.76, 0.55] }, // пісок
{ h: 3.0, col: [0.25, 0.52, 0.22] }, // трава
{ h: 8.0, col: [0.35, 0.30, 0.25] }, // скелі
{ h: 14.0, col: [0.80, 0.85, 0.90] }, // сніг
{ h: 20.0, col: [1.00, 1.00, 1.00] }, // сніг на вершинах
];
function heightColor(h) {
for (let i = 0; i < palette.length - 1; i++) {
if (h <= palette[i+1].h) {
const t = (h - palette[i].h) / (palette[i+1].h - palette[i].h);
return palette[i].col.map((c, j) => c + t * (palette[i+1].col[j] - c));
}
}
return palette[palette.length - 1].col;
}
for (let i = 0; i < pos.count; i++) {
const [r, g, b] = heightColor(pos.getY(i));
colors.push(r, g, b);
}
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
Підхід із вершинним шейдером на GPU
Для анімованого рельєфу (підняття води, землетрус тощо) або нескінченного прокручування обчислюйте висоту у вершинному шейдері, щоб уникнути повторного завантаження буфера з CPU:
// Запікаємо функцію шуму в GLSL і зміщуємо у вершинному шейдері
const terrainVS = /* glsl */`
uniform float uTime;
uniform float uScale;
varying float vHeight;
// --- вставте сюди GLSL-функції noise + fbm ---
// (та сама реалізація, що в уроці про вогонь, адаптована для 2D)
void main() {
vec2 np = position.xz / uScale; // нормалізовані координати
float h = fbm(np * 3.0 + uTime * 0.02) * 20.0; // анімовано!
// Маска океанського дна на краях
float edgeMask = 1.0 - max(abs(np.x), abs(np.y));
h *= edgeMask;
vHeight = h;
vec3 displaced = vec3(position.x, h, position.z);
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
`;
Розбиття рельєфу на чанки для нескінченних світів
Однієї площини з 400 вершин достатньо для демо. Для нескінченних світів поділіть рельєф на чанки, генеруйте їх на вимогу та повторно використовуйте віддалені чанки:
const CHUNK_SIZE = 50, CHUNK_SEG = 64;
const chunks = new Map();
function chunkKey(cx, cz) { return `${cx},${cz}`; }
function getOrCreateChunk(cx, cz) {
const key = chunkKey(cx, cz);
if (chunks.has(key)) return chunks.get(key);
const geo = new THREE.PlaneGeometry(CHUNK_SIZE, CHUNK_SIZE, CHUNK_SEG, CHUNK_SEG);
geo.rotateX(-Math.PI / 2);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const wx = pos.getX(i) + cx * CHUNK_SIZE;
const wz = pos.getZ(i) + cz * CHUNK_SIZE;
pos.setY(i, fbm(wx * 0.02, wz * 0.02) * 20);
}
pos.needsUpdate = true;
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, terrainMat);
mesh.position.set(cx * CHUNK_SIZE, 0, cz * CHUNK_SIZE);
scene.add(mesh);
chunks.set(key, mesh);
return mesh;
}
// У animate(): завантажуємо чанки навколо камери, відсікаємо віддалені
function updateChunks(camX, camZ) {
const range = 3;
const cx = Math.round(camX / CHUNK_SIZE);
const cz = Math.round(camZ / CHUNK_SIZE);
for (let dx = -range; dx <= range; dx++)
for (let dz = -range; dz <= range; dz++)
getOrCreateChunk(cx + dx, cz + dz);
// (видаляємо віддалені чанки для економії пам'яті)
}