feat: enhanced procedural terrain for floating island (#233)
Some checks failed
CI / validate (pull_request) Failing after 15s
CI / auto-merge (pull_request) Has been skipped

- 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:
Alexander Whitestone
2026-03-24 01:14:48 -04:00
parent 668a69ecc9
commit 630af621b2

197
app.js
View File

@@ -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 24 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);
})();