procedural-landscapes
npx skills add https://github.com/ck42bb/procedural-landscapes-threejs --skill procedural-landscapes
Agent 安装分布
Skill 文档
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/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/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).