Procedural landscapes threejs
Generate procedural 3D landscapes and terrain in Three.js using WebGPU compute shaders with automatic WebGL2 fallback.
npx -y skills add CK42BB/procedural-landscapes-threejsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Generate procedural 3D landscapes and terrain in Three.js using WebGPU compute shaders with automatic WebGL2 fallback. Covers heightmap generation via FBM noise, ridged multifractal, and domain warping; chunked LOD terrain meshes; slope-based multi-material texturing; procedural water planes; atmospheric sky rendering; and instanced vegetation scattering. Use when building terrain generators, open-world environments, landscape visualizers, flight simulators, or any 3D scene requiring procedural ground surfaces. Triggers: "procedural terrain", "landscape generator", "heightmap", "terrain mesh", "procedural world", "open world", "terrain LOD", "terrain chunks", "noise terrain", "3D landscape".
SKILL.md
17.2 KB, as published. Nobody here has run it
Procedural Landscapes
Generate performant, visually rich procedural terrain in Three.js with a WebGPU-first architecture and automatic WebGL2 fallback.
Architecture Overview
┌─────────────────────────────────────────────────┐
│ Renderer Init │
│ WebGPU available? ──yes──► WebGPURenderer │
│ │no │
│ └──────────────► WebGLRenderer │
├─────────────────────────────────────────────────┤
│ Terrain Pipeline │
│ 1. Noise Generation (GPU compute or CPU) │
│ 2. Heightmap → Geometry (chunked grid) │
│ 3. Normal Computation (per-vertex) │
│ 4. Material Assignment (slope + height rules) │
│ 5. LOD Management (camera-distance based) │
├─────────────────────────────────────────────────┤
│ Environment Layers │
│ Water plane ─ Sky dome ─ Fog ─ Vegetation │
└─────────────────────────────────────────────────┘
Renderer Setup with Dual Backend
Always attempt WebGPU first, fall back to WebGL2 gracefully.
import * as THREE from 'three';
import WebGPU from 'three/addons/capabilities/WebGPU.js';
import WebGPURenderer from 'three/addons/renderers/webgpu/WebGPURenderer.js';
async function createRenderer(canvas) {
let renderer;
let gpuAvailable = false;
if (WebGPU.isAvailable()) {
renderer = new WebGPURenderer({ canvas, antialias: true });
await renderer.init();
gpuAvailable = true;
} else {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
}
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
return { renderer, gpuAvailable };
}
CDN usage (r170+):
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
}
}
</script>
Noise Generation
CPU Path (WebGL fallback)
Implement FBM noise on CPU when GPU compute is unavailable. Self-contained simplex noise avoids external dependencies.
function createNoise2D(seed = 0) {
const perm = new Uint8Array(512);
let s = seed;
for (let i = 0; i < 256; i++) {
s = (s * 16807 + 0) % 2147483647;
perm[i] = perm[i + 256] = s & 255;
}
const G2 = (3 - Math.sqrt(3)) / 6;
const grad = [[1,1],[-1,1],[1,-1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]];
return function(x, y) {
const s0 = (x + y) * 0.5 * (Math.sqrt(3) - 1);
const i = Math.floor(x + s0), j = Math.floor(y + s0);
const t0 = (i + j) * G2;
const x0 = x - (i - t0), y0 = y - (j - t0);
const i1 = x0 > y0 ? 1 : 0, j1 = x0 > y0 ? 0 : 1;
const x1 = x0 - i1 + G2, y1 = y0 - j1 + G2;
const x2 = x0 - 1 + 2 * G2, y2 = y0 - 1 + 2 * G2;
const ii = i & 255, jj = j & 255;
let n0 = 0, n1 = 0, n2 = 0;
let t = 0.5 - x0*x0 - y0*y0;
if (t > 0) { const g = grad[perm[ii + perm[jj]] & 7]; n0 = t*t*t*t * (g[0]*x0 + g[1]*y0); }
t = 0.5 - x1*x1 - y1*y1;
if (t > 0) { const g = grad[perm[ii+i1 + perm[jj+j1]] & 7]; n1 = t*t*t*t * (g[0]*x1 + g[1]*y1); }
t = 0.5 - x2*x2 - y2*y2;
if (t > 0) { const g = grad[perm[ii+1 + perm[jj+1]] & 7]; n2 = t*t*t*t * (g[0]*x2 + g[1]*y2); }
return 70 * (n0 + n1 + n2);
};
}
FBM and Terrain Noise Functions
function fbm(noise, x, y, octaves = 6, lacunarity = 2.0, gain = 0.5) {
let sum = 0, amp = 1, freq = 1, maxAmp = 0;
for (let i = 0; i < octaves; i++) {
sum += noise(x * freq, y * freq) * amp;
maxAmp += amp;
amp *= gain;
freq *= lacunarity;
}
return sum / maxAmp;
}
function ridgedMultifractal(noise, x, y, octaves = 6, lacunarity = 2.0, gain = 0.5) {
let sum = 0, amp = 1, freq = 1, prev = 1;
for (let i = 0; i < octaves; i++) {
let n = 1 - Math.abs(noise(x * freq, y * freq));
n = n * n * prev;
sum += n * amp;
prev = n;
amp *= gain;
freq *= lacunarity;
}
return sum;
}
function domainWarp(noise, x, y, strength = 0.3) {
const qx = fbm(noise, x, y, 4);
const qy = fbm(noise, x + 5.2, y + 1.3, 4);
return fbm(noise, x + strength * qx, y + strength * qy, 6);
}
GPU Compute Path (WebGPU)
For GPU-accelerated heightmap generation via WGSL compute shaders, see
references/wgsl-shaders.md. The compute path generates a heightmap texture
on the GPU, then samples it in vertex shaders or reads it back for collision.
Terrain Geometry
Single-Chunk Terrain
function createTerrainGeometry(size, segments, heightFn, maxHeight = 50) {
const geometry = new THREE.PlaneGeometry(size, size, segments, segments);
geometry.rotateX(-Math.PI / 2);
const position = geometry.attributes.position;
const vertex = new THREE.Vector3();
for (let i = 0; i < position.count; i++) {
vertex.fromBufferAttribute(position, i);
const nx = vertex.x / size + 0.5;
const nz = vertex.z / size + 0.5;
position.setY(i, heightFn(nx, nz) * maxHeight);
}
geometry.computeVertexNormals();
position.needsUpdate = true;
return geometry;
}
Chunked Terrain with LOD
For larger worlds, divide terrain into chunks with distance-based LOD.
class TerrainChunkManager {
constructor(scene, chunkSize, viewDistance, heightFn, maxHeight) {
this.scene = scene;
this.chunkSize = chunkSize;
this.viewDistance = viewDistance;
this.heightFn = heightFn;
this.maxHeight = maxHeight;
this.chunks = new Map();
this.lodLevels = [
{ distance: chunkSize * 2, segments: 64 },
{ distance: chunkSize * 5, segments: 32 },
{ distance: chunkSize * 10, segments: 16 },
{ distance: Infinity, segments: 8 },
];
}
update(cameraPosition) {
const cx = Math.floor(cameraPosition.x / this.chunkSize);
const cz = Math.floor(cameraPosition.z / this.chunkSize);
const radius = Math.ceil(this.viewDistance / this.chunkSize);
const activeKeys = new Set();
for (let x = cx - radius; x <= cx + radius; x++) {
for (let z = cz - radius; z <= cz + radius; z++) {
const key = `${x},${z}`;
activeKeys.add(key);
const worldX = x * this.chunkSize;
const worldZ = z * this.chunkSize;
const dist = Math.hypot(cameraPosition.x - worldX, cameraPosition.z - worldZ);
if (dist > this.viewDistance) continue;
const lod = this.lodLevels.find(l => dist < l.distance);
const existing = this.chunks.get(key);
if (!existing || existing.lod !== lod.segments) {
if (existing) { this.scene.remove(existing.mesh); existing.mesh.geometry.dispose(); }
const mesh = this._createChunk(x, z, lod.segments);
this.chunks.set(key, { mesh, lod: lod.segments });
this.scene.add(mesh);
}
}
}
for (const [key, chunk] of this.chunks) {
if (!activeKeys.has(key)) {
this.scene.remove(chunk.mesh);
chunk.mesh.geometry.dispose();
this.chunks.delete(key);
}
}
}
_createChunk(cx, cz, segments) {
const geo = createTerrainGeometry(
this.chunkSize, segments,
(nx, nz) => {
const wx = (cx + nx) * this.chunkSize / 500;
const wz = (cz + nz) * this.chunkSize / 500;
return this.heightFn(wx, wz);
},
this.maxHeight
);
const mesh = new THREE.Mesh(geo, createTerrainMaterial());
mesh.position.set(cx * this.chunkSize, 0, cz * this.chunkSize);
mesh.receiveShadow = true;
return mesh;
}
dispose() {
for (const [, chunk] of this.chunks) {
this.scene.remove(chunk.mesh);
chunk.mesh.geometry.dispose();
}
this.chunks.clear();
}
}
Terrain Materials
Slope + Height Shader (WebGL)
function createTerrainMaterial() {
return new THREE.ShaderMaterial({
uniforms: {
waterLevel: { value: 0.05 },
snowLevel: { value: 0.75 },
grassColor: { value: new THREE.Color(0x4a7c3f) },
rockColor: { value: new THREE.Color(0x8b8680) },
sandColor: { value: new THREE.Color(0xc2b280) },
snowColor: { value: new THREE.Color(0xf0f0f5) },
sunDir: { value: new THREE.Vector3(0.5, 0.8, 0.3).normalize() },
maxHeight: { value: 50.0 },
},
vertexShader: `
varying vec3 vWorldPos;
varying vec3 vNormal;
void main() {
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float waterLevel, snowLevel, maxHeight;
uniform vec3 grassColor, rockColor, sandColor, snowColor, sunDir;
varying vec3 vWorldPos;
varying vec3 vNormal;
void main() {
float h = clamp(vWorldPos.y / maxHeight, 0.0, 1.0);
float slope = 1.0 - dot(vNormal, vec3(0, 1, 0));
vec3 color = grassColor;
if (h < waterLevel + 0.05)
color = mix(sandColor, grassColor, smoothstep(waterLevel, waterLevel + 0.05, h));
if (h > snowLevel)
color = mix(color, snowColor, smoothstep(snowLevel, snowLevel + 0.1, h));
color = mix(color, rockColor, smoothstep(0.3, 0.6, slope));
float light = max(dot(vNormal, sunDir), 0.0) * 0.7 + 0.3;
gl_FragColor = vec4(color * light, 1.0);
}
`,
});
}
Node Material (WebGPU TSL)
When using WebGPURenderer, prefer Three.js Shading Language (TSL) node materials:
import { color, normalWorld, positionWorld, mix, smoothstep,
dot, vec3, float as tslFloat, MeshStandardNodeMaterial } from 'three/tsl';
function createTerrainNodeMaterial(maxHeight = 50) {
const material = new MeshStandardNodeMaterial();
const h = positionWorld.y.div(tslFloat(maxHeight)).clamp(0, 1);
const slope = tslFloat(1).sub(dot(normalWorld, vec3(0, 1, 0)));
const grass = color(0x4a7c3f);
const rock = color(0x8b8680);
const sand = color(0xc2b280);
const snow = color(0xf0f0f5);
let c = mix(sand, grass, smoothstep(0.0, 0.1, h));
c = mix(c, snow, smoothstep(0.75, 0.85, h));
c = mix(c, rock, smoothstep(0.3, 0.6, slope));
material.colorNode = c;
material.roughnessNode = mix(tslFloat(0.9), tslFloat(0.5), slope);
return material;
}
Water
function createWater(size, waterLevel = 2.5) {
const geometry = new THREE.PlaneGeometry(size, size, 128, 128);
geometry.rotateX(-Math.PI / 2);
const material = new THREE.MeshPhysicalMaterial({
color: 0x006994, transparent: true, opacity: 0.7,
roughness: 0.1, metalness: 0.1, transmission: 0.3, thickness: 2.0,
});
const water = new THREE.Mesh(geometry, material);
water.position.y = waterLevel;
water.userData.animate = (time) => {
const pos = geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i), z = pos.getZ(i);
pos.setY(i, Math.sin(x * 0.05 + time) * 0.3 + Math.cos(z * 0.08 + time * 0.7) * 0.2);
}
pos.needsUpdate = true;
geometry.computeVertexNormals();
};
return water;
}
Sky & Atmosphere
function createSky() {
const geo = new THREE.SphereGeometry(500, 32, 16);
const mat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false,
uniforms: {
topColor: { value: new THREE.Color(0x0077be) },
bottomColor: { value: new THREE.Color(0xffeebb) },
sunDir: { value: new THREE.Vector3(0.3, 0.5, 0.4).normalize() },
},
vertexShader: `
varying vec3 vDir;
void main() {
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor, bottomColor, sunDir;
varying vec3 vDir;
void main() {
float y = vDir.y * 0.5 + 0.5;
vec3 sky = mix(bottomColor, topColor, pow(y, 0.6));
float sun = smoothstep(0.97, 1.0, dot(vDir, sunDir));
sky += vec3(1.0, 0.95, 0.8) * sun * 0.8;
gl_FragColor = vec4(sky, 1.0);
}
`,
});
return new THREE.Mesh(geo, mat);
}
Vegetation Scattering
Place instanced vegetation using height and slope constraints.
function scatterVegetation(heightFn, terrainSize, maxHeight, count = 5000) {
const trunkGeo = new THREE.CylinderGeometry(0.1, 0.15, 1.5, 6);
const canopyGeo = new THREE.ConeGeometry(0.8, 2.0, 6);
canopyGeo.translate(0, 2.0, 0);
const merged = mergeBufferGeometries(trunkGeo, canopyGeo);
const material = new THREE.MeshStandardMaterial({ color: 0x2d5a27, flatShading: true });
const mesh = new THREE.InstancedMesh(merged, material, count);
mesh.castShadow = true;
const dummy = new THREE.Object3D();
const noise = createNoise2D(42);
let placed = 0;
for (let i = 0; i < count * 3 && placed < count; i++) {
const x = (Math.random() - 0.5) * terrainSize;
const z = (Math.random() - 0.5) * terrainSize;
const h = heightFn(x / terrainSize + 0.5, z / terrainSize + 0.5) * maxHeight;
const nh = h / maxHeight;
if (nh < 0.08 || nh > 0.65) continue;
if (noise(x * 0.01, z * 0.01) < 0.0) continue;
dummy.position.set(x, h, z);
dummy.rotation.y = Math.random() * Math.PI * 2;
dummy.scale.setScalar(0.5 + Math.random());
dummy.updateMatrix();
mesh.setMatrixAt(placed++, dummy.matrix);
}
mesh.count = placed;
mesh.instanceMatrix.needsUpdate = true;
return mesh;
}
function mergeBufferGeometries(a, b) {
const na = a.toNonIndexed(), nb = b.toNonIndexed();
const pA = na.attributes.position.array, pB = nb.attributes.position.array;
const nA = na.attributes.normal.array, nB = nb.attributes.normal.array;
const pos = new Float32Array(pA.length + pB.length);
const nor = new Float32Array(nA.length + nB.length);
pos.set(pA); pos.set(pB, pA.length);
nor.set(nA); nor.set(nB, nA.length);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('normal', new THREE.BufferAttribute(nor, 3));
return geo;
}
Performance Guidelines
- Geometry budget: 64×64 for distant chunks, 256×256 for close. Never exceed 512×512.
- Instanced rendering: Always use
InstancedMeshfor repeated objects. One draw call for 10K instances beats 10K meshes by ~100×. - Dispose aggressively:
.dispose()geometry, materials, textures when removing chunks. - Shadow optimization: One shadow-casting directional light. Use
CSMaddon for large terrains. - Vertex totals: Mobile < 500K, Desktop < 2M across visible scene.
- WebGPU compute: 10–50× faster than CPU for 1024² heightmaps. Use for real-time sculpting.
Noise Selection Guide
| Noise Type | Character | Best For |
|---|---|---|
| FBM | Smooth rolling hills | Meadows, plains |
| Ridged Multifractal | Sharp ridges/valleys | Mountains, canyons |
| Domain Warping | Organic twisted forms | Fantasy, alien terrain |
| Terraced FBM | Stepped plateaus | Mesas, rice paddies |
Combine multiplicatively for complex terrain:
function complexTerrain(noise, x, y) {
const base = fbm(noise, x * 0.5, y * 0.5, 4) * 0.5 + 0.5;
const mountains = ridgedMultifractal(noise, x, y, 6) * 0.4;
const detail = domainWarp(noise, x * 2, y * 2, 0.2) * 0.1;
return Math.max(base * 0.5 + mountains * base + detail, 0);
}
Common Pitfalls
- Normals not recomputed after vertex modification → flat/unlit terrain. Always call
geometry.computeVertexNormals(). - Chunk seams → sample identical world-space noise at shared edges.
- Z-fighting on water → use
polygonOffsetor small Y offset. - Memory leaks → dispose geometries on chunk removal. Monitor with
renderer.info. - WebGPU silent failure → always gate behind
WebGPU.isAvailable().
References
references/wgsl-shaders.md— Complete WGSL compute shaders for GPU heightmap generation, erosion simulation, and normal computation.references/noise-algorithms.md— Mathematical foundations and advanced noise variants (Voronoi, analytical derivatives, curl noise).