From 630af621b28fe61da46aff110a95615180b3ba05 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 01:14:48 -0400 Subject: [PATCH] feat: enhanced procedural terrain for floating island (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app.js | 197 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 167 insertions(+), 30 deletions(-) diff --git a/app.js b/app.js index 781d3e7..5312f5c 100644 --- a/app.js +++ b/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); })(); -- 2.43.0