feat: enhanced procedural terrain for floating island (#233)
- Domain-warped fBm (5 octaves + ridged octave) replaces the previous plain 4-octave fBm — warp pass displaces sample coordinates with a low-frequency Perlin offset, breaking grid regularity and producing more organic ridgelines - Rim boundary uses a small noise-driven undulation instead of a hard radial cutoff - 5-zone vertex colour gradient: wet dark earth → rocky brown → stone grey → pale limestone → blue-violet crystal peaks - Emissive crystal spire clusters (MeshStandardMaterial with accent emissive) procedurally spawned at high-terrain locations using the same seeded Perlin noise; cluster size, rotation, and spread are all noise-driven for reproducibility without randomness - Noise-displaced rocky underside replaces the plain cylinder: radial Perlin displacement plus stalactite-style vertical pull on lower vertices; sealed with a bottom cap disc Fixes #233 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
197
app.js
197
app.js
@@ -354,70 +354,207 @@ const perlin = createPerlinNoise();
|
||||
|
||||
// === FLOATING ISLAND TERRAIN ===
|
||||
// Procedural terrain below the glass platform, shaped like a floating rock island.
|
||||
// Heights generated via fBm (fractional Brownian motion) layered Perlin noise.
|
||||
// Heights generated via domain-warped fBm (5 octaves) + ridged Perlin noise,
|
||||
// with 5-zone vertex colours, emissive crystal spires, and a noise-displaced
|
||||
// rocky underside with stalactite drips.
|
||||
|
||||
(function buildFloatingIsland() {
|
||||
const ISLAND_RADIUS = 9.5;
|
||||
const SEGMENTS = 90;
|
||||
const SEGMENTS = 96;
|
||||
const SIZE = ISLAND_RADIUS * 2;
|
||||
|
||||
// --- Domain-warped fBm ---
|
||||
// First evaluates a low-frequency warp offset, then runs 5-octave fBm on the
|
||||
// warped coordinates. A ridged octave is blended in to create sharp ridges.
|
||||
function islandFBm(nx, nz) {
|
||||
// Warp pass — displaces sample point for organic shapes
|
||||
const wx = perlin(nx * 0.5 + 3.7, nz * 0.5 + 1.2) * 0.55;
|
||||
const wz = perlin(nx * 0.5 + 8.3, nz * 0.5 + 5.9) * 0.55;
|
||||
const px = nx + wx, pz = nz + wz;
|
||||
|
||||
// 5-octave fBm
|
||||
let h = 0;
|
||||
h += perlin(px, pz ) * 1.000;
|
||||
h += perlin(px * 2, pz * 2 ) * 0.500;
|
||||
h += perlin(px * 4, pz * 4 ) * 0.250;
|
||||
h += perlin(px * 8, pz * 8 ) * 0.125;
|
||||
h += perlin(px * 16, pz * 16 ) * 0.063;
|
||||
h /= 1.938; // normalise to ~[-1, 1]
|
||||
|
||||
// Ridged octave — 1−|noise| creates sharp ridge lines
|
||||
const ridge = 1.0 - Math.abs(perlin(px * 3.1 + 5.0, pz * 3.1 + 7.0));
|
||||
return h * 0.78 + ridge * 0.22;
|
||||
}
|
||||
|
||||
const geo = new THREE.PlaneGeometry(SIZE, SIZE, SEGMENTS, SEGMENTS);
|
||||
geo.rotateX(-Math.PI / 2);
|
||||
const pos = geo.attributes.position;
|
||||
const count = pos.count;
|
||||
const vCount = pos.count;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Store heights for colour pass and crystal placement
|
||||
const rawHeights = new Float32Array(vCount);
|
||||
|
||||
for (let i = 0; i < vCount; i++) {
|
||||
const x = pos.getX(i);
|
||||
const z = pos.getZ(i);
|
||||
const dist = Math.sqrt(x * x + z * z) / ISLAND_RADIUS;
|
||||
|
||||
// Island edge taper — smooth falloff toward rim
|
||||
const edgeFactor = Math.max(0, 1 - Math.pow(dist, 2.2));
|
||||
// Edge taper with slight noise-driven rim undulation
|
||||
const rimNoise = perlin(x * 0.38 + 10, z * 0.38 + 10) * 0.10;
|
||||
const edgeFactor = Math.max(0, 1 - Math.pow(Math.max(0, dist - rimNoise), 2.4));
|
||||
|
||||
// fBm: four octaves of Perlin noise
|
||||
const nx = x * 0.17, nz = z * 0.17;
|
||||
let h = 0;
|
||||
h += perlin(nx, nz ) * 1.000;
|
||||
h += perlin(nx * 2, nz * 2 ) * 0.500;
|
||||
h += perlin(nx * 4, nz * 4 ) * 0.250;
|
||||
h += perlin(nx * 8, nz * 8 ) * 0.125;
|
||||
h /= 1.875; // normalise to ~[-1, 1]
|
||||
|
||||
const height = ((h + 1) * 0.5) * edgeFactor * 2.6;
|
||||
const h = islandFBm(x * 0.15, z * 0.15);
|
||||
const height = ((h + 1) * 0.5) * edgeFactor * 3.2;
|
||||
pos.setY(i, height);
|
||||
rawHeights[i] = height;
|
||||
}
|
||||
|
||||
geo.computeVertexNormals();
|
||||
|
||||
// Vertex colours: low=dark earth, mid=dusty stone, high=pale rock
|
||||
const colors = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = Math.min(1, pos.getY(i) / 2.0);
|
||||
colors[i * 3] = 0.18 + t * 0.22; // R
|
||||
colors[i * 3 + 1] = 0.14 + t * 0.16; // G
|
||||
colors[i * 3 + 2] = 0.10 + t * 0.15; // B
|
||||
// --- 5-zone vertex colours ---
|
||||
// 0: wet dark earth (h < 0.25)
|
||||
// 1: rocky brown (0.25 – 0.75)
|
||||
// 2: stone grey (0.75 – 1.4)
|
||||
// 3: pale limestone (1.4 – 2.2)
|
||||
// 4: crystal peak (> 2.2) — blue-tinted
|
||||
const colBuf = new Float32Array(vCount * 3);
|
||||
for (let i = 0; i < vCount; i++) {
|
||||
const h = rawHeights[i];
|
||||
let r, g, b;
|
||||
if (h < 0.25) {
|
||||
r = 0.11; g = 0.09; b = 0.07;
|
||||
} else if (h < 0.75) {
|
||||
const t = (h - 0.25) / 0.50;
|
||||
r = 0.11 + t * 0.13; g = 0.09 + t * 0.09; b = 0.07 + t * 0.06;
|
||||
} else if (h < 1.4) {
|
||||
const t = (h - 0.75) / 0.65;
|
||||
r = 0.24 + t * 0.12; g = 0.18 + t * 0.10; b = 0.13 + t * 0.10;
|
||||
} else if (h < 2.2) {
|
||||
const t = (h - 1.4) / 0.80;
|
||||
r = 0.36 + t * 0.14; g = 0.28 + t * 0.11; b = 0.23 + t * 0.13;
|
||||
} else {
|
||||
// Crystal peaks — blue-tinted pale rock
|
||||
const t = Math.min(1, (h - 2.2) / 0.9);
|
||||
r = 0.50 + t * 0.05;
|
||||
g = 0.39 + t * 0.10;
|
||||
b = 0.36 + t * 0.28; // distinct blue-violet shift
|
||||
}
|
||||
colBuf[i * 3] = r;
|
||||
colBuf[i * 3 + 1] = g;
|
||||
colBuf[i * 3 + 2] = b;
|
||||
}
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(colBuf, 3));
|
||||
|
||||
const topMat = new THREE.MeshStandardMaterial({
|
||||
vertexColors: true,
|
||||
roughness: 0.88,
|
||||
metalness: 0.04,
|
||||
roughness: 0.86,
|
||||
metalness: 0.05,
|
||||
});
|
||||
const topMesh = new THREE.Mesh(geo, topMat);
|
||||
topMesh.castShadow = true;
|
||||
topMesh.receiveShadow = true;
|
||||
|
||||
// Underside — tapered cylinder giving the island its rocky underbelly
|
||||
const bottomGeo = new THREE.CylinderGeometry(ISLAND_RADIUS * 0.82, ISLAND_RADIUS * 0.35, 2.0, 64, 1);
|
||||
const bottomMat = new THREE.MeshStandardMaterial({ color: 0x0c0a08, roughness: 0.92, metalness: 0.03 });
|
||||
// --- Crystal spire formations ---
|
||||
// Scatter emissive crystal clusters at high terrain points,
|
||||
// seeded from the same Perlin noise for reproducibility.
|
||||
const crystalMat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.55),
|
||||
emissive: new THREE.Color(NEXUS.colors.accent),
|
||||
emissiveIntensity: 0.5,
|
||||
roughness: 0.08,
|
||||
metalness: 0.25,
|
||||
transparent: true,
|
||||
opacity: 0.80,
|
||||
});
|
||||
|
||||
const CRYSTAL_MIN_H = 2.05; // only spawn on high terrain
|
||||
const crystalGroup = new THREE.Group();
|
||||
|
||||
for (let row = -5; row <= 5; row++) {
|
||||
for (let col = -5; col <= 5; col++) {
|
||||
const bx = col * 1.75, bz = row * 1.75;
|
||||
if (Math.sqrt(bx * bx + bz * bz) > ISLAND_RADIUS * 0.72) continue;
|
||||
|
||||
// Evaluate terrain height at this candidate location
|
||||
const edF = Math.max(0, 1 - Math.pow(Math.sqrt(bx * bx + bz * bz) / ISLAND_RADIUS, 2.4));
|
||||
const candidateH = ((islandFBm(bx * 0.15, bz * 0.15) + 1) * 0.5) * edF * 3.2;
|
||||
if (candidateH < CRYSTAL_MIN_H) continue;
|
||||
|
||||
// Jitter spawn position using noise
|
||||
const jx = bx + perlin(bx * 0.7 + 20, bz * 0.7 + 20) * 0.55;
|
||||
const jz = bz + perlin(bx * 0.7 + 30, bz * 0.7 + 30) * 0.55;
|
||||
if (Math.sqrt(jx * jx + jz * jz) > ISLAND_RADIUS * 0.68) continue;
|
||||
|
||||
// Cluster of 2–4 spires
|
||||
const clusterSize = 2 + Math.floor(Math.abs(perlin(bx * 0.5 + 40, bz * 0.5 + 40)) * 3);
|
||||
for (let c = 0; c < clusterSize; c++) {
|
||||
const angle = (c / clusterSize) * Math.PI * 2 + perlin(bx + c, bz + c) * 1.4;
|
||||
const spread = 0.08 + Math.abs(perlin(bx + c * 5, bz + c * 5)) * 0.22;
|
||||
const sx = jx + Math.cos(angle) * spread;
|
||||
const sz = jz + Math.sin(angle) * spread;
|
||||
const spireScale = 0.14 + (candidateH - CRYSTAL_MIN_H) * 0.11;
|
||||
const spireH = spireScale * (0.8 + Math.abs(perlin(sx, sz)) * 0.45);
|
||||
const spireR = spireH * 0.17;
|
||||
|
||||
const spireGeo = new THREE.ConeGeometry(spireR, spireH * 2.8, 5);
|
||||
const spire = new THREE.Mesh(spireGeo, crystalMat);
|
||||
spire.position.set(sx, candidateH + spireH * 0.5, sz);
|
||||
spire.rotation.z = perlin(sx * 2, sz * 2) * 0.28;
|
||||
spire.rotation.x = perlin(sx * 3 + 1, sz * 3 + 1) * 0.18;
|
||||
spire.castShadow = true;
|
||||
crystalGroup.add(spire);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Noise-displaced rocky underside with stalactite drips ---
|
||||
// CylinderGeometry with open top (openEnded=true) so only the tapered side
|
||||
// wall is visible. Vertices are radially and vertically displaced by Perlin
|
||||
// noise to break the smooth cylinder into jagged rock.
|
||||
const BOTTOM_SEGS_R = 52;
|
||||
const BOTTOM_SEGS_V = 10;
|
||||
const BOTTOM_HEIGHT = 2.6;
|
||||
const bottomGeo = new THREE.CylinderGeometry(
|
||||
ISLAND_RADIUS * 0.80, ISLAND_RADIUS * 0.28,
|
||||
BOTTOM_HEIGHT, BOTTOM_SEGS_R, BOTTOM_SEGS_V, true
|
||||
);
|
||||
const bPos = bottomGeo.attributes.position;
|
||||
for (let i = 0; i < bPos.count; i++) {
|
||||
const bx = bPos.getX(i);
|
||||
const bz = bPos.getZ(i);
|
||||
const by = bPos.getY(i);
|
||||
const angle = Math.atan2(bz, bx);
|
||||
const r = Math.sqrt(bx * bx + bz * bz);
|
||||
|
||||
// Radial displacement — jagged surface detail
|
||||
const radDisp = perlin(Math.cos(angle) * 1.6 + 50, Math.sin(angle) * 1.6 + 50) * 0.65;
|
||||
// Vertical stalactite pull — lower vertices dragged further downward
|
||||
const vNorm = (by + BOTTOM_HEIGHT * 0.5) / BOTTOM_HEIGHT; // 0=bottom 1=top
|
||||
const stalDisp = (1 - vNorm) * Math.abs(perlin(bx * 0.35 + 70, by * 0.7 + bz * 0.35)) * 0.9;
|
||||
|
||||
const newR = r + radDisp;
|
||||
bPos.setX(i, (bx / r) * newR);
|
||||
bPos.setZ(i, (bz / r) * newR);
|
||||
bPos.setY(i, by - stalDisp);
|
||||
}
|
||||
bottomGeo.computeVertexNormals();
|
||||
|
||||
const bottomMat = new THREE.MeshStandardMaterial({ color: 0x0c0a08, roughness: 0.93, metalness: 0.02 });
|
||||
const bottomMesh = new THREE.Mesh(bottomGeo, bottomMat);
|
||||
bottomMesh.position.y = -1.0;
|
||||
bottomMesh.position.y = -BOTTOM_HEIGHT * 0.5;
|
||||
bottomMesh.castShadow = true;
|
||||
|
||||
// Bottom cap — seals the underside of the cylinder
|
||||
const capGeo = new THREE.CircleGeometry(ISLAND_RADIUS * 0.28, 48);
|
||||
capGeo.rotateX(Math.PI / 2);
|
||||
const capMesh = new THREE.Mesh(capGeo, bottomMat);
|
||||
capMesh.position.y = -(BOTTOM_HEIGHT + 0.1);
|
||||
|
||||
const islandGroup = new THREE.Group();
|
||||
islandGroup.add(topMesh);
|
||||
islandGroup.add(crystalGroup);
|
||||
islandGroup.add(bottomMesh);
|
||||
islandGroup.add(capMesh);
|
||||
islandGroup.position.y = -2.8; // float below the glass platform
|
||||
scene.add(islandGroup);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user