Last active
March 3, 2026 22:52
-
-
Save SMUsamaShah/bab2a3cd2f8d120dd977d23db95c3ce2 to your computer and use it in GitHub Desktop.
Procedural Planet JS HTML by Gemini
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Procedural Planet</title> | |
| <style> | |
| body { margin: 0; overflow: hidden; background-color: #020205; font-family: sans-serif; outline: none; } | |
| canvas { display: block; width: 100vw; height: 100vh; } | |
| #ui-container { | |
| position: absolute; top: 20px; left: 20px; | |
| color: rgba(255, 255, 255, 0.9); | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 15px; border-radius: 8px; pointer-events: none; | |
| backdrop-filter: blur(4px); transition: all 0.3s; | |
| } | |
| h1 { margin: 0 0 5px 0; font-size: 1.2rem; color: #fff; } | |
| p { margin: 0; font-size: 0.9rem; } | |
| #walk-prompt { | |
| position: absolute; bottom: 50px; left: 50%; transform: translateX(-50%); | |
| background: rgba(255, 255, 255, 0.9); color: black; | |
| padding: 10px 20px; border-radius: 20px; font-weight: bold; | |
| display: none; pointer-events: none; text-transform: uppercase; | |
| } | |
| #crosshair { | |
| position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); | |
| width: 4px; height: 4px; background: rgba(255, 255, 255, 0.5); | |
| border-radius: 50%; display: none; pointer-events: none; | |
| } | |
| </style> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script> | |
| </head> | |
| <body tabindex="0"> | |
| <div id="ui-container"> | |
| <h1>Procedural Planet Gen</h1> | |
| <p id="mode-text">Mode: Orbit</p> | |
| <p style="margin-top: 10px; font-size: 0.8rem; color: #aaa;" id="instructions">Left Click: Rotate | Scroll: Zoom to Surface</p> | |
| <div style="margin-top: 15px; display: flex; gap: 5px; pointer-events: auto;"> | |
| <input type="text" id="seed-input" placeholder="Enter seed..." value="RedTeam" style="width: 120px; padding: 4px; border: none; border-radius: 4px; background: rgba(255,255,255,0.8);"> | |
| <button id="seed-btn" style="padding: 4px 8px; border: none; border-radius: 4px; background: #fff; cursor: pointer; font-weight: bold;">Generate</button> | |
| </div> | |
| </div> | |
| <div id="walk-prompt">Click to enter First-Person</div> | |
| <div id="crosshair"></div> | |
| <script> | |
| // --- SCALE METRICS --- | |
| const PLANET_RADIUS = 6000; | |
| const MAX_MOUNTAIN_HEIGHT = PLANET_RADIUS * 0.15; | |
| const MAX_LOD_LEVEL = 10; | |
| // --- SHARED SHADER LOGIC --- | |
| const snoiseChunk = ` | |
| vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } | |
| vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } | |
| vec4 permute(vec4 x) { return mod289(((x*34.0)+1.0)*x); } | |
| vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } | |
| float snoise(vec3 v) { | |
| const vec2 C = vec2(1.0/6.0, 1.0/3.0); | |
| const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); | |
| vec3 i = floor(v + dot(v, C.yyy)); | |
| vec3 x0 = v - i + dot(i, C.xxx); | |
| vec3 g = step(x0.yzx, x0.xyz); | |
| vec3 l = 1.0 - g; | |
| vec3 i1 = min(g.xyz, l.zxy); | |
| vec3 i2 = max(g.xyz, l.zxy); | |
| vec3 x1 = x0 - i1 + C.xxx; | |
| vec3 x2 = x0 - i2 + C.yyy; | |
| vec3 x3 = x0 - D.yyy; | |
| i = mod289(i); | |
| vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0)); | |
| float n_ = 0.142857142857; vec3 ns = n_ * D.wyz - D.xzx; | |
| vec4 j = p - 49.0 * floor(p * ns.z * ns.z); vec4 x_ = floor(j * ns.z); vec4 y_ = floor(j - 7.0 * x_); | |
| vec4 x = x_ * ns.x + ns.yyyy; vec4 y = y_ * ns.x + ns.yyyy; vec4 h = 1.0 - abs(x) - abs(y); | |
| vec4 b0 = vec4(x.xy, y.xy); vec4 b1 = vec4(x.zw, y.zw); vec4 s0 = floor(b0) * 2.0 + 1.0; vec4 s1 = floor(b1) * 2.0 + 1.0; | |
| vec4 sh = -step(h, vec4(0.0)); vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; | |
| vec3 p0 = vec3(a0.xy, h.x); vec3 p1 = vec3(a0.zw, h.y); vec3 p2 = vec3(a1.xy, h.z); vec3 p3 = vec3(a1.zw, h.w); | |
| vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3))); | |
| p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; | |
| vec4 m = max(0.5 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); m = m * m; | |
| return 105.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3))); | |
| } | |
| `; | |
| // --- ATMOSPHERIC SHADER SYSTEM --- | |
| const atmosVertexShader = ` | |
| varying vec3 vNormal; | |
| varying vec3 vPositionWorld; | |
| void main() { | |
| vNormal = normalize(normalMatrix * normal); | |
| vec4 worldPosition = modelMatrix * vec4(position, 1.0); | |
| vPositionWorld = worldPosition.xyz; | |
| gl_Position = projectionMatrix * viewMatrix * worldPosition; | |
| } | |
| `; | |
| const atmosFragmentShader = ` | |
| varying vec3 vNormal; | |
| varying vec3 vPositionWorld; | |
| uniform vec3 u_sunDirection; | |
| uniform float u_radius; | |
| void main() { | |
| // Vector from camera to fragment | |
| vec3 viewDir = normalize(vPositionWorld - cameraPosition); | |
| // Vector from planet center to fragment | |
| vec3 planetNormal = normalize(vPositionWorld); | |
| // Day/Night and sunset masking | |
| float sunLight = dot(planetNormal, u_sunDirection); | |
| float dayMix = smoothstep(-0.2, 0.2, sunLight); | |
| // Atmospheric colors | |
| vec3 zenithColor = vec3(0.02, 0.05, 0.15); | |
| vec3 horizonColorDay = vec3(0.3, 0.6, 1.0); | |
| vec3 horizonColorSunset = vec3(0.9, 0.4, 0.1); | |
| // Shift horizon color to sunset if near terminator line | |
| vec3 horizonColor = mix(horizonColorSunset, horizonColorDay, smoothstep(-0.1, 0.3, sunLight)); | |
| // Optical depth calculates the rim (grazing angles) vs zenith | |
| float viewDot = abs(dot(viewDir, planetNormal)); | |
| float opticalDepth = pow(1.0 - viewDot, 4.0); | |
| // Base gradient | |
| vec3 atmosColor = mix(zenithColor, horizonColor, opticalDepth); | |
| // Rayleigh/Mie sun scattering (sun glare in the sky) | |
| float sunScatter = pow(max(dot(viewDir, u_sunDirection), 0.0), 20.0); | |
| atmosColor += vec3(1.0, 0.9, 0.7) * sunScatter * dayMix; | |
| // Calculate altitude to fade the sky dome away dynamically as we breach orbit | |
| float camAlt = max(length(cameraPosition) - u_radius, 0.0); | |
| float atmosphereThickness = u_radius * 0.25; | |
| // 1.0 when on the ground, 0.0 when deep in space | |
| float groundRatio = 1.0 - clamp(camAlt / atmosphereThickness, 0.0, 1.0); | |
| float baseAlpha = groundRatio * 0.9; // Blocks stars during the day when on ground | |
| // Opacity is high at the rim (opticalDepth), or high everywhere if on the ground (baseAlpha) | |
| float alpha = max(opticalDepth, baseAlpha) * dayMix; | |
| // Add a very faint night-side rim to highlight the atmosphere silhouette | |
| float nightRim = opticalDepth * 0.1 * (1.0 - dayMix); | |
| alpha += nightRim; | |
| gl_FragColor = vec4(atmosColor, alpha); | |
| } | |
| `; | |
| const coreProceduralMath = ` | |
| ${snoiseChunk} | |
| uniform vec3 u_seedOffset; | |
| // --- TECTONIC WORLEY/VORONOI NOISE --- | |
| vec3 hash33(vec3 p) { | |
| p = vec3( dot(p,vec3(127.1,311.7, 74.7)), | |
| dot(p,vec3(269.5,183.3,246.1)), | |
| dot(p,vec3(113.5,271.9,124.6))); | |
| return fract(sin(p)*43758.5453123); | |
| } | |
| vec2 voronoi(vec3 x) { | |
| x += u_seedOffset; | |
| vec3 p = floor(x); | |
| vec3 f = fract(x); | |
| vec2 res = vec2(100.0); | |
| for(int k=-1; k<=1; k++) { | |
| for(int j=-1; j<=1; j++) { | |
| for(int i=-1; i<=1; i++) { | |
| vec3 b = vec3(float(i), float(j), float(k)); | |
| vec3 r = vec3(b) - f + hash33(p + b); | |
| float d = dot(r, r); | |
| if(d < res.x) { res.y = res.x; res.x = d; } | |
| else if(d < res.y) { res.y = d; } | |
| } | |
| } | |
| } | |
| return sqrt(res); | |
| } | |
| float fbm_cheap(vec3 x) { x += u_seedOffset; float v = 0.0; float a = 0.5; vec3 shift = vec3(100.0); for (int i = 0; i < 2; ++i) { v += a * snoise(x); x = x * 2.0 + shift; a *= 0.5; } return v; } | |
| float fbm_macro(vec3 x) { x += u_seedOffset; float v = 0.0; float a = 0.5; vec3 shift = vec3(100.0); for (int i = 0; i < 4; ++i) { v += a * snoise(x); x = x * 2.0 + shift; a *= 0.5; } return v; } | |
| float fbm_micro(vec3 x) { x += u_seedOffset; float v = 0.0; float a = 0.5; vec3 shift = vec3(100.0); for (int i = 0; i < 8; ++i) { v += a * snoise(x); x = x * 2.0 + shift; a *= 0.5; } return v; } | |
| float fbm_ridged(vec3 x) { | |
| x += u_seedOffset; | |
| float v = 0.0; float a = 0.5; vec3 shift = vec3(100.0); | |
| for (int i = 0; i < 6; ++i) { | |
| float n = 1.0 - abs(snoise(x)); | |
| v += a * (n * n); | |
| x = x * 2.0 + shift; a *= 0.5; | |
| } return v; | |
| } | |
| float fbm_canyon(vec3 x) { | |
| x += u_seedOffset; | |
| float v = 0.0; float a = 0.5; vec3 shift = vec3(100.0); | |
| for (int i = 0; i < 4; ++i) { | |
| float n = 1.0 - abs(snoise(x)); | |
| v += a * pow(n, 4.0); | |
| x = x * 2.0 + shift; a *= 0.5; | |
| } return v; | |
| } | |
| // --- GEOLOGICAL MASKING --- | |
| float getRawElevation(vec3 pos) { | |
| vec3 warpOffset = vec3(fbm_macro(pos * 2.0), fbm_macro(pos * 2.0 + 10.0), fbm_macro(pos * 2.0 + 20.0)); | |
| vec3 warpedPos = pos + (warpOffset * 0.4); | |
| vec2 vPlates = voronoi(warpedPos * 1.5); | |
| float plateBoundary = vPlates.y - vPlates.x; | |
| float cellCenter = vPlates.x; | |
| float baseContinent = snoise((warpedPos * 1.0) + u_seedOffset) * 0.5 + 0.5; | |
| baseContinent = mix(baseContinent, cellCenter, 0.4); | |
| float landMask = smoothstep(0.42, 0.52, baseContinent); | |
| float mountainMask = 1.0 - smoothstep(0.0, 0.15, plateBoundary); | |
| mountainMask *= smoothstep(0.3, 0.6, baseContinent) * smoothstep(-0.2, 0.5, snoise((warpedPos * 4.0) + u_seedOffset)); | |
| float peaks = fbm_ridged(warpedPos * 4.0) * 0.6; | |
| vec2 vCanyons = voronoi(warpedPos * 6.0); | |
| float canyonBoundary = vCanyons.y - vCanyons.x; | |
| float canyonMask = (1.0 - smoothstep(0.0, 0.03, canyonBoundary)) * landMask * (1.0 - mountainMask); | |
| float canyonDepth = 0.08; | |
| float plains = fbm_micro(warpedPos * 5.0) * 0.03; | |
| float elevation = baseContinent * 0.12; | |
| elevation += landMask * 0.05; | |
| elevation += plains * landMask; | |
| elevation += mountainMask * peaks; | |
| elevation -= canyonMask * canyonDepth; | |
| return elevation; | |
| } | |
| float getSurfaceElevation(vec3 pos) { | |
| return max(getRawElevation(pos), 0.08); | |
| } | |
| `; | |
| const vertexShader = ` | |
| ${coreProceduralMath} | |
| uniform vec3 u_center; uniform vec3 u_axisA; uniform vec3 u_axisB; uniform float u_radius; | |
| varying vec2 vClimate; varying float vElevation; varying vec3 vWorldPosition; | |
| void main() { | |
| vec3 spherePos = normalize(u_center + (u_axisA * position.x) + (u_axisB * position.y)); | |
| float rawElev = getRawElevation(spherePos); | |
| vElevation = rawElev; | |
| float physicalElevation = getSurfaceElevation(spherePos); | |
| float moisture = (fbm_cheap(spherePos * 3.0 + 50.0) + 1.0) * 0.5; | |
| float finalTemp = clamp((1.0 - abs(spherePos.y)) + (fbm_cheap(spherePos * 4.0) * 0.2) - (physicalElevation * 0.8), 0.0, 1.0); | |
| vClimate = vec2(moisture, finalTemp); | |
| vec3 newPosition = spherePos * (u_radius + (physicalElevation * (u_radius * 0.15))); | |
| vWorldPosition = newPosition; | |
| gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0); | |
| } | |
| `; | |
| const fragmentShader = ` | |
| varying vec2 vClimate; varying float vElevation; varying vec3 vWorldPosition; | |
| uniform vec3 u_sunDirection; | |
| uniform float u_radius; | |
| void main() { | |
| float seaLevel = 0.08; | |
| vec3 hotColor = mix(vec3(0.85, 0.75, 0.5), vec3(0.1, 0.35, 0.15), smoothstep(0.3, 0.7, vClimate.x)); | |
| vec3 temperateColor = mix(vec3(0.35, 0.55, 0.25), vec3(0.1, 0.35, 0.15), smoothstep(0.4, 0.6, vClimate.x)); | |
| vec3 snow = vec3(0.9, 0.95, 1.0); | |
| vec3 landColor = mix(snow, temperateColor, smoothstep(0.15, 0.4, vClimate.y)); | |
| landColor = mix(landColor, hotColor, smoothstep(0.6, 0.8, vClimate.y)); | |
| vec3 dx = dFdx(vWorldPosition); | |
| vec3 dy = dFdy(vWorldPosition); | |
| vec3 crossVec = cross(dx, dy); | |
| float crossLen = length(crossVec); | |
| vec3 normal = crossLen > 0.00000001 ? (crossVec / crossLen) : normalize(vWorldPosition); | |
| float slope = 1.0 - dot(normal, normalize(vWorldPosition)); | |
| vec3 darkRock = vec3(0.2, 0.2, 0.22); | |
| vec3 lightRock = vec3(0.4, 0.4, 0.42); | |
| vec3 rockColor = mix(darkRock, lightRock, step(0.5, fract(vElevation * 60.0))); | |
| float rockBlend = smoothstep(0.1, 0.35, slope); | |
| landColor = mix(landColor, rockColor, rockBlend); | |
| landColor = mix(landColor, snow, smoothstep(0.55, 0.7, vElevation)); | |
| vec3 deepWater = vec3(0.01, 0.08, 0.25); | |
| vec3 shallowWater = vec3(0.05, 0.4, 0.7); | |
| vec3 waterColor = mix(deepWater, shallowWater, smoothstep(0.03, seaLevel, vElevation)); | |
| vec3 coastColor = mix(vec3(0.85, 0.75, 0.5), landColor, smoothstep(seaLevel, seaLevel + 0.01, vElevation)); | |
| vec3 finalColor = mix(waterColor, coastColor, smoothstep(seaLevel - 0.001, seaLevel + 0.001, vElevation)); | |
| float diffuse = max(dot((vElevation < seaLevel - 0.005) ? normalize(vWorldPosition) : normal, u_sunDirection), 0.0); | |
| if (vElevation <= seaLevel + 0.001) { | |
| vec3 viewDir = normalize(cameraPosition - vWorldPosition); | |
| vec3 halfVector = normalize(u_sunDirection + viewDir); | |
| float specular = pow(max(dot(normal, halfVector), 0.0), 128.0) * 1.5; | |
| diffuse += specular; | |
| } | |
| vec3 viewDirRaw = cameraPosition - vWorldPosition; | |
| float viewDist = length(viewDirRaw); | |
| vec3 normView = viewDist > 0.0001 ? (viewDirRaw / viewDist) : normalize(vWorldPosition); | |
| float headlamp = max(dot(normal, normView), 0.0) * 0.15; | |
| float ambient = 0.15; | |
| vec3 litColor = finalColor * (diffuse + ambient + headlamp); | |
| // --- NEW SYNERGISTIC ATMOSPHERIC FOG SYSTEM --- | |
| float sunLight = dot(normalize(vWorldPosition), u_sunDirection); | |
| float dayMix = smoothstep(-0.2, 0.2, sunLight); | |
| // Colors matched perfectly to the sky dome shader | |
| vec3 fogZenith = vec3(0.02, 0.05, 0.15); | |
| vec3 fogHorizonDay = vec3(0.3, 0.6, 1.0); | |
| vec3 fogHorizonSunset = vec3(0.9, 0.4, 0.1); | |
| vec3 fogHorizon = mix(fogHorizonSunset, fogHorizonDay, smoothstep(-0.1, 0.3, sunLight)); | |
| vec3 fogColor = mix(fogZenith, fogHorizon, 0.8); // Distant fog is mostly horizon colored | |
| // Add sun glare to the fog when looking towards the sun | |
| vec3 viewDirOut = -normView; | |
| float sunScatter = pow(max(dot(viewDirOut, u_sunDirection), 0.0), 8.0); | |
| fogColor += vec3(1.0, 0.8, 0.4) * sunScatter * dayMix; | |
| // Dynamically thin the fog based on the camera's true altitude above sea level | |
| float camAlt = max(length(cameraPosition) - u_radius, 0.0); | |
| float atmosphereThickness = u_radius * 0.25; | |
| // Density drops from thick ground air to vacuum space | |
| float density = mix(0.00015, 0.000005, clamp(camAlt / atmosphereThickness, 0.0, 1.0)); | |
| float fogFactor = exp(-density * viewDist); | |
| fogFactor = clamp(fogFactor, 0.0, 1.0); | |
| // If in deep space, fade the mountains to true black instead of blue air | |
| float spaceFade = clamp(1.0 - (camAlt / atmosphereThickness), 0.0, 1.0); | |
| fogColor = mix(vec3(0.01, 0.015, 0.04), fogColor, spaceFade); | |
| gl_FragColor = vec4(mix(fogColor, litColor, fogFactor), 1.0); | |
| } | |
| `; | |
| // --- GPU ELEVATION READER --- | |
| const elevationScene = new THREE.Scene(); | |
| const elevationCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -10, 10); | |
| const elevationTarget = new THREE.WebGLRenderTarget(1, 1, { format: THREE.RGBAFormat, type: THREE.UnsignedByteType }); | |
| const elevationMaterial = new THREE.ShaderMaterial({ | |
| vertexShader: `void main() { gl_Position = vec4(position.xy, 0.0, 1.0); }`, | |
| fragmentShader: ` | |
| ${coreProceduralMath} | |
| uniform vec3 u_pos; | |
| void main() { | |
| float elev = getSurfaceElevation(normalize(u_pos)); | |
| float v = clamp((elev + 2.0) / 5.0, 0.0, 1.0); | |
| vec3 enc = vec3(1.0, 255.0, 65025.0) * v; | |
| enc = fract(enc); | |
| enc.xy -= enc.yz * (1.0/255.0); | |
| gl_FragColor = vec4(enc, 1.0); | |
| } | |
| `, | |
| uniforms: { | |
| u_pos: { value: new THREE.Vector3() }, | |
| u_seedOffset: { value: new THREE.Vector3(0, 0, 0) } | |
| }, | |
| depthWrite: false, depthTest: false | |
| }); | |
| const elevationQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), elevationMaterial); | |
| elevationQuad.frustumCulled = false; | |
| elevationScene.add(elevationQuad); | |
| let lastGoodElev = 0.08; | |
| function getExactElevationAt(normalizedPos, renderer) { | |
| elevationMaterial.uniforms.u_pos.value.copy(normalizedPos); | |
| const currentTarget = renderer.getRenderTarget(); | |
| renderer.setRenderTarget(elevationTarget); | |
| renderer.clear(); | |
| renderer.render(elevationScene, elevationCamera); | |
| const buffer = new Uint8Array(4); | |
| renderer.readRenderTargetPixels(elevationTarget, 0, 0, 1, 1, buffer); | |
| renderer.setRenderTarget(currentTarget); | |
| if (buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 0) return lastGoodElev; | |
| let packedElev = buffer[0]/255 + buffer[1]/65025 + buffer[2]/16581375; | |
| let elev = (packedElev * 5.0) - 2.0; | |
| if (!isNaN(elev) && elev > -1.0) lastGoodElev = elev; | |
| return lastGoodElev; | |
| } | |
| // --- QUADTREE LOD ENGINE --- | |
| const CHUNK_SEGMENTS = 64; | |
| const chunkGeometry = new THREE.PlaneGeometry(1, 1, CHUNK_SEGMENTS, CHUNK_SEGMENTS); | |
| class PlanetChunk { | |
| constructor(scene, center, axisA, axisB, level, materialTemplate) { | |
| this.scene = scene; this.center = center; this.axisA = axisA; this.axisB = axisB; this.level = level; | |
| this.children = []; this.isSubdivided = false; | |
| this.material = materialTemplate.clone(); | |
| this.material.uniforms.u_center.value = this.center; | |
| this.material.uniforms.u_axisA.value = this.axisA; | |
| this.material.uniforms.u_axisB.value = this.axisB; | |
| this.mesh = new THREE.Mesh(chunkGeometry, this.material); | |
| this.mesh.frustumCulled = false; | |
| this.normalizedCenter = this.center.clone().normalize(); | |
| this.scene.add(this.mesh); | |
| } | |
| update(cameraPosition) { | |
| const dot = this.normalizedCenter.dot(cameraPosition.clone().normalize()); | |
| const margin = Math.sin((Math.PI / 2) / Math.pow(2, this.level)) + 0.2; | |
| if (dot < -margin) { | |
| this.mesh.visible = false; | |
| if (this.isSubdivided) this.merge(); return; | |
| } | |
| const cameraRadius = cameraPosition.length(); | |
| const horizontalDistance = cameraPosition.clone().normalize().angleTo(this.normalizedCenter) * PLANET_RADIUS; | |
| const altitudePenalty = Math.max(0, cameraRadius - (PLANET_RADIUS + MAX_MOUNTAIN_HEIGHT)); | |
| const effectiveDistance = Math.sqrt(horizontalDistance * horizontalDistance + altitudePenalty * altitudePenalty); | |
| const subdivisionThreshold = PLANET_RADIUS * (2.8 / Math.pow(1.85, this.level)); | |
| if (this.level < MAX_LOD_LEVEL && effectiveDistance < subdivisionThreshold) { | |
| if (!this.isSubdivided) this.subdivide(); | |
| this.mesh.visible = false; | |
| for (let child of this.children) child.update(cameraPosition); | |
| } else { | |
| if (this.isSubdivided) this.merge(); | |
| this.mesh.visible = true; | |
| } | |
| } | |
| subdivide() { | |
| this.isSubdivided = true; | |
| if (this.children.length === 0) { | |
| const hA = this.axisA.clone().multiplyScalar(0.5); const hB = this.axisB.clone().multiplyScalar(0.5); | |
| const qA = this.axisA.clone().multiplyScalar(0.25); const qB = this.axisB.clone().multiplyScalar(0.25); | |
| const centers = [ | |
| this.center.clone().sub(qA).sub(qB), this.center.clone().add(qA).sub(qB), | |
| this.center.clone().sub(qA).add(qB), this.center.clone().add(qA).add(qB) | |
| ]; | |
| for (let c of centers) this.children.push(new PlanetChunk(this.scene, c, hA, hB, this.level + 1, this.material)); | |
| } else { | |
| for (let child of this.children) child.mesh.visible = true; | |
| } | |
| } | |
| merge() { | |
| this.isSubdivided = false; | |
| for (let child of this.children) { | |
| child.mesh.visible = false; | |
| if(child.isSubdivided) child.merge(); | |
| } | |
| } | |
| destroy() { | |
| this.scene.remove(this.mesh); | |
| this.mesh.material.dispose(); | |
| for (let child of this.children) child.destroy(); | |
| } | |
| } | |
| // --- APPLICATION LOGIC & SPHERICAL FPS --- | |
| let scene, camera, renderer, controls; | |
| let rootChunks = []; | |
| let isWalking = false; | |
| let canWalk = false; | |
| let camYaw = 0; let camPitch = 0; | |
| const keys = { forward: false, backward: false, left: false, right: false }; | |
| const sunDirection = new THREE.Vector3(1.0, 0.8, 0.5).normalize(); | |
| function init() { | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x020308); | |
| camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.01, PLANET_RADIUS * 5); | |
| camera.position.set(0, 0, PLANET_RADIUS * 2.5); | |
| renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); | |
| document.body.appendChild(renderer.domElement); | |
| controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; controls.dampingFactor = 0.05; | |
| controls.maxDistance = PLANET_RADIUS * 4; | |
| const sunGeom = new THREE.SphereGeometry(150, 32, 32); | |
| const sunMat = new THREE.MeshBasicMaterial({color: 0xffeebb}); | |
| const sunMesh = new THREE.Mesh(sunGeom, sunMat); | |
| sunMesh.position.copy(sunDirection).multiplyScalar(PLANET_RADIUS * 3.5); | |
| scene.add(sunMesh); | |
| // RED TEAM FIX: Insert physical Atmospheric Sky Dome | |
| // Rendered mathematically inside-out (BackSide) so it forms a halo from space, and a dome from the ground | |
| const atmosMaterial = new THREE.ShaderMaterial({ | |
| vertexShader: atmosVertexShader, | |
| fragmentShader: atmosFragmentShader, | |
| uniforms: { | |
| u_sunDirection: { value: sunDirection }, | |
| u_radius: { value: PLANET_RADIUS } | |
| }, | |
| transparent: true, | |
| side: THREE.BackSide, | |
| depthWrite: false | |
| }); | |
| // 25% larger than radius perfectly envelopes the highest mountains | |
| const atmosMesh = new THREE.Mesh(new THREE.SphereGeometry(PLANET_RADIUS * 1.25, 64, 64), atmosMaterial); | |
| scene.add(atmosMesh); | |
| const baseMaterial = new THREE.ShaderMaterial({ | |
| vertexShader, fragmentShader, extensions: { derivatives: true }, | |
| side: THREE.DoubleSide, | |
| uniforms: { | |
| u_center: { value: new THREE.Vector3() }, | |
| u_axisA: { value: new THREE.Vector3() }, | |
| u_axisB: { value: new THREE.Vector3() }, | |
| u_radius: { value: PLANET_RADIUS }, | |
| u_sunDirection: { value: sunDirection }, | |
| u_seedOffset: { value: new THREE.Vector3(0, 0, 0) } | |
| } | |
| }); | |
| const faces = [ | |
| { c: new THREE.Vector3(0, 0, 1), a: new THREE.Vector3(2, 0, 0), b: new THREE.Vector3(0, 2, 0) }, | |
| { c: new THREE.Vector3(0, 0, -1), a: new THREE.Vector3(-2, 0, 0), b: new THREE.Vector3(0, 2, 0) }, | |
| { c: new THREE.Vector3(1, 0, 0), a: new THREE.Vector3(0, 0, -2), b: new THREE.Vector3(0, 2, 0) }, | |
| { c: new THREE.Vector3(-1, 0, 0), a: new THREE.Vector3(0, 0, 2), b: new THREE.Vector3(0, 2, 0) }, | |
| { c: new THREE.Vector3(0, 1, 0), a: new THREE.Vector3(2, 0, 0), b: new THREE.Vector3(0, 0, -2) }, | |
| { c: new THREE.Vector3(0, -1, 0), a: new THREE.Vector3(2, 0, 0), b: new THREE.Vector3(0, 0, 2) } | |
| ]; | |
| function generateNewPlanetSeed(seedStr) { | |
| let hash = 0; | |
| for (let i = 0; i < seedStr.length; i++) { | |
| hash = ((hash << 5) - hash) + seedStr.charCodeAt(i); | |
| hash = hash & hash; | |
| } | |
| const rand = (h) => (Math.sin(h) * 10000) % 100; | |
| const offset = new THREE.Vector3(rand(hash), rand(hash + 1), rand(hash + 2)); | |
| baseMaterial.uniforms.u_seedOffset.value.copy(offset); | |
| elevationMaterial.uniforms.u_seedOffset.value.copy(offset); | |
| for (let chunk of rootChunks) chunk.destroy(); | |
| rootChunks = []; | |
| lastGoodElev = 0.08; | |
| for (let face of faces) { | |
| rootChunks.push(new PlanetChunk(scene, face.c, face.a, face.b, 0, baseMaterial)); | |
| } | |
| } | |
| document.getElementById('seed-btn').addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| const seedStr = document.getElementById('seed-input').value || 'RedTeam'; | |
| generateNewPlanetSeed(seedStr); | |
| }); | |
| document.getElementById('seed-input').addEventListener('click', (e) => e.stopPropagation()); | |
| generateNewPlanetSeed('RedTeam'); | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| const setKey = (e, isDown) => { | |
| if (!isWalking) return; | |
| const code = e.code; | |
| if (code === 'KeyW' || code === 'ArrowUp') keys.forward = isDown; | |
| if (code === 'KeyS' || code === 'ArrowDown') keys.backward = isDown; | |
| if (code === 'KeyA' || code === 'ArrowLeft') keys.left = isDown; | |
| if (code === 'KeyD' || code === 'ArrowRight') keys.right = isDown; | |
| }; | |
| document.addEventListener('keydown', (e) => setKey(e, true)); | |
| document.addEventListener('keyup', (e) => setKey(e, false)); | |
| document.addEventListener('mousemove', (e) => { | |
| if (!isWalking || document.pointerLockElement !== document.body) return; | |
| camYaw -= e.movementX * 0.002; | |
| camPitch -= e.movementY * 0.002; | |
| camPitch = Math.max(-Math.PI/2 + 0.1, Math.min(Math.PI/2 - 0.1, camPitch)); | |
| }); | |
| document.addEventListener('click', (e) => { | |
| if(e.target.id === 'seed-input' || e.target.id === 'seed-btn') return; | |
| window.focus(); | |
| document.body.focus(); | |
| if (canWalk && !isWalking) document.body.requestPointerLock(); | |
| }); | |
| document.addEventListener('pointerlockchange', () => { | |
| if (document.pointerLockElement === document.body) { | |
| isWalking = true; controls.enabled = false; | |
| document.getElementById('mode-text').innerText = "Mode: First-Person Walking"; | |
| document.getElementById('instructions').innerText = "WASD/Arrows to Move | ESC to Return to Orbit"; | |
| document.getElementById('walk-prompt').style.display = 'none'; | |
| document.getElementById('crosshair').style.display = 'block'; | |
| camYaw = 0; camPitch = 0; | |
| } else { | |
| isWalking = false; controls.enabled = true; | |
| document.getElementById('mode-text').innerText = "Mode: Orbit"; | |
| document.getElementById('instructions').innerText = "Left Click: Rotate | Scroll: Zoom to Surface"; | |
| document.getElementById('crosshair').style.display = 'none'; | |
| keys.forward = false; keys.backward = false; keys.left = false; keys.right = false; | |
| camera.position.multiplyScalar(1.05); | |
| } | |
| }); | |
| animate(); | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| if (!isWalking) { | |
| controls.update(); | |
| const camNormal = camera.position.clone().normalize(); | |
| const elev = getExactElevationAt(camNormal, renderer); | |
| const groundRadius = PLANET_RADIUS + (elev * MAX_MOUNTAIN_HEIGHT); | |
| controls.minDistance = groundRadius + 2.0; | |
| if (camera.position.length() - groundRadius < 20.0) { | |
| canWalk = true; document.getElementById('walk-prompt').style.display = 'block'; | |
| } else { | |
| canWalk = false; document.getElementById('walk-prompt').style.display = 'none'; | |
| } | |
| } else { | |
| const upVec = camera.position.clone().normalize(); | |
| if (Math.abs(upVec.y + 1.0) < 0.001) upVec.z += 0.001; | |
| upVec.normalize(); | |
| const alignQuat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), upVec); | |
| const yawQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), camYaw); | |
| const pitchQuat = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), camPitch); | |
| camera.quaternion.copy(alignQuat).multiply(yawQuat).multiply(pitchQuat); | |
| const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion); | |
| const right = new THREE.Vector3(1, 0, 0).applyQuaternion(camera.quaternion); | |
| forward.sub(upVec.clone().multiplyScalar(forward.dot(upVec))).normalize(); | |
| right.sub(upVec.clone().multiplyScalar(right.dot(upVec))).normalize(); | |
| const moveVec = new THREE.Vector3(); | |
| if (keys.forward) moveVec.add(forward); | |
| if (keys.backward) moveVec.sub(forward); | |
| if (keys.right) moveVec.add(right); | |
| if (keys.left) moveVec.sub(right); | |
| if (moveVec.lengthSq() > 0) { | |
| moveVec.normalize(); | |
| camera.position.add(moveVec.multiplyScalar(1.0)); | |
| } | |
| const newNormal = camera.position.clone().normalize(); | |
| const currentElev = getExactElevationAt(newNormal, renderer); | |
| const exactGroundRadius = PLANET_RADIUS + (currentElev * MAX_MOUNTAIN_HEIGHT); | |
| const targetRadius = exactGroundRadius + 2.0; | |
| const currentRadius = camera.position.length(); | |
| const smoothedRadius = currentRadius + (targetRadius - currentRadius) * 0.15; | |
| camera.position.copy(newNormal.multiplyScalar(smoothedRadius)); | |
| } | |
| for (let chunk of rootChunks) chunk.update(camera.position); | |
| renderer.render(scene, camera); | |
| } | |
| window.onload = init; | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment