Created
July 16, 2025 05:18
-
-
Save jamonholmgren/d24465f4913194589f54358328fae643 to your computer and use it in GitHub Desktop.
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>Earth and Moon 3D Simulation</title> | |
<style> | |
body { | |
margin: 0; | |
font-family: Arial, sans-serif; | |
overflow: hidden; | |
} | |
#canvas-container { | |
position: relative; | |
width: 100vw; | |
height: 100vh; | |
} | |
#controls { | |
position: absolute; | |
top: 10px; | |
right: 10px; | |
background: rgba(0, 0, 0, 0.8); | |
color: white; | |
padding: 15px; | |
border-radius: 8px; | |
width: 300px; | |
max-height: 90vh; | |
overflow-y: auto; | |
} | |
#controls h3 { | |
margin-top: 0; | |
margin-bottom: 15px; | |
text-align: center; | |
} | |
.control-group { | |
margin-bottom: 12px; | |
} | |
.control-group label { | |
display: block; | |
margin-bottom: 4px; | |
font-size: 12px; | |
} | |
.control-group input[type="range"] { | |
width: 100%; | |
} | |
.value-display { | |
float: right; | |
font-size: 11px; | |
color: #aaa; | |
} | |
#instructions { | |
position: absolute; | |
top: 10px; | |
left: 10px; | |
color: white; | |
background: rgba(0, 0, 0, 0.7); | |
padding: 10px 15px; | |
border-radius: 5px; | |
font-size: 14px; | |
} | |
</style> | |
<script type="importmap"> | |
{ | |
"imports": { | |
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js", | |
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/" | |
} | |
} | |
</script> | |
</head> | |
<body> | |
<div id="canvas-container"></div> | |
<div id="instructions">Use mouse or touch to orbit, pan, zoom</div> | |
<div id="controls"> | |
<h3>Simulation Controls</h3> | |
<div class="control-group"> | |
<label | |
>Ambient Light Brightness | |
<span class="value-display" id="ambient-value">0.10</span></label | |
> | |
<input | |
type="range" | |
id="ambient-light" | |
min="0" | |
max="0.5" | |
value="0.1" | |
step="0.01" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>Sun Brightness | |
<span class="value-display" id="sun-value">1.20</span></label | |
> | |
<input | |
type="range" | |
id="sun-brightness" | |
min="0" | |
max="3" | |
value="1.2" | |
step="0.1" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>Earth Rotation Speed | |
<span class="value-display" id="rotation-value">0.10</span></label | |
> | |
<input | |
type="range" | |
id="earth-rotation" | |
min="0" | |
max="1" | |
value="0.1" | |
step="0.01" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>Earth Normal Map Intensity | |
<span class="value-display" id="normal-value">0.50</span></label | |
> | |
<input | |
type="range" | |
id="earth-normal" | |
min="0" | |
max="2" | |
value="0.5" | |
step="0.1" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>Earth Specular Intensity | |
<span class="value-display" id="specular-value">0.50</span></label | |
> | |
<input | |
type="range" | |
id="earth-specular" | |
min="0" | |
max="1" | |
value="0.5" | |
step="0.05" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>City Lights Brightness | |
<span class="value-display" id="lights-value">1.00</span></label | |
> | |
<input | |
type="range" | |
id="city-lights" | |
min="0" | |
max="3" | |
value="1" | |
step="0.1" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>Cloud Height | |
<span class="value-display" id="cloud-height-value">1.01</span></label | |
> | |
<input | |
type="range" | |
id="cloud-height" | |
min="1.005" | |
max="1.05" | |
value="1.01" | |
step="0.005" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>Cloud Opacity | |
<span class="value-display" id="cloud-opacity-value" | |
>0.80</span | |
></label | |
> | |
<input | |
type="range" | |
id="cloud-opacity" | |
min="0" | |
max="1" | |
value="0.8" | |
step="0.05" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>Moon Normal Map Intensity | |
<span class="value-display" id="moon-normal-value">0.80</span></label | |
> | |
<input | |
type="range" | |
id="moon-normal" | |
min="0" | |
max="2" | |
value="0.8" | |
step="0.1" | |
/> | |
</div> | |
<div class="control-group"> | |
<label | |
>Star Brightness | |
<span class="value-display" id="star-value">1.00</span></label | |
> | |
<input | |
type="range" | |
id="star-brightness" | |
min="0" | |
max="2" | |
value="1" | |
step="0.1" | |
/> | |
</div> | |
</div> | |
<script type="module"> | |
const assetHost = "https://todds.sfo3.digitaloceanspaces.com"; | |
import * as THREE from "three"; | |
import { OrbitControls } from "three/addons/controls/OrbitControls.js"; | |
// Scene setup | |
const scene = new THREE.Scene(); | |
const camera = new THREE.PerspectiveCamera( | |
75, | |
window.innerWidth / window.innerHeight, | |
0.1, | |
10000 | |
); | |
camera.position.set(0, 0, 30); | |
// Renderer setup with high quality settings | |
const renderer = new THREE.WebGLRenderer({ | |
antialias: true, | |
powerPreference: "high-performance", | |
}); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
renderer.setPixelRatio(window.devicePixelRatio); | |
renderer.shadowMap.enabled = true; | |
renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
renderer.toneMappingExposure = 1; | |
document | |
.getElementById("canvas-container") | |
.appendChild(renderer.domElement); | |
// Controls | |
const controls = new OrbitControls(camera, renderer.domElement); | |
controls.enableDamping = true; | |
controls.dampingFactor = 0.05; | |
controls.minDistance = 15; | |
controls.maxDistance = 100; | |
// Texture loader | |
const textureLoader = new THREE.TextureLoader(); | |
// Lights | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.1); | |
scene.add(ambientLight); | |
const sunLight = new THREE.DirectionalLight(0xffffff, 1.2); | |
sunLight.position.set(50, 0, 0); | |
sunLight.castShadow = true; | |
sunLight.shadow.mapSize.width = 4096; | |
sunLight.shadow.mapSize.height = 4096; | |
sunLight.shadow.camera.near = 0.1; | |
sunLight.shadow.camera.far = 200; | |
sunLight.shadow.camera.left = -50; | |
sunLight.shadow.camera.right = 50; | |
sunLight.shadow.camera.top = 50; | |
sunLight.shadow.camera.bottom = -50; | |
scene.add(sunLight); | |
// Earth group | |
const earthGroup = new THREE.Group(); | |
scene.add(earthGroup); | |
// Load all textures | |
const earthDayTexture = textureLoader.load( | |
assetHost + "/earth-day-10k.jpg" | |
); | |
const earthNightTexture = textureLoader.load( | |
assetHost + "/earth-night-no-lights-10k.jpg" | |
); | |
const earthLightsTexture = textureLoader.load( | |
assetHost + "/earth-night-only-lights-10k.png" | |
); | |
const earthNormalMap = textureLoader.load( | |
assetHost + "/earth-normal-map-8k.jpg" | |
); | |
const earthSpecularMap = textureLoader.load( | |
assetHost + "/earth-specular-map-10k.jpg" | |
); | |
const cloudTexture = textureLoader.load(assetHost + "/clouds-8k.png"); | |
const moonTexture = textureLoader.load(assetHost + "/moon-8k.jpg"); | |
const moonNormalMap = textureLoader.load( | |
assetHost + "/moon-normal-map-4k.jpg" | |
); | |
const starTexture = textureLoader.load(assetHost + "/starmap_16k.jpg"); | |
// Set texture parameters for quality | |
[ | |
earthDayTexture, | |
earthNightTexture, | |
earthLightsTexture, | |
earthNormalMap, | |
earthSpecularMap, | |
cloudTexture, | |
moonTexture, | |
moonNormalMap, | |
starTexture, | |
].forEach((texture) => { | |
texture.anisotropy = renderer.capabilities.getMaxAnisotropy(); | |
}); | |
// Earth radius | |
const earthRadius = 10; | |
// Custom shader material for Earth with day/night cycle and city lights | |
const earthMaterial = new THREE.ShaderMaterial({ | |
uniforms: { | |
dayTexture: { value: earthDayTexture }, | |
nightTexture: { value: earthNightTexture }, | |
lightsTexture: { value: earthLightsTexture }, | |
normalMap: { value: earthNormalMap }, | |
specularMap: { value: earthSpecularMap }, | |
normalScale: { value: 0.5 }, | |
specularIntensity: { value: 0.5 }, | |
lightsIntensity: { value: 1.0 }, | |
sunDirection: { value: new THREE.Vector3(1, 0, 0) }, | |
ambientStrength: { value: 0.1 }, | |
}, | |
vertexShader: ` | |
varying vec2 vUv; | |
varying vec3 vNormal; | |
varying vec3 vPosition; | |
varying vec3 vTangent; | |
varying vec3 vBitangent; | |
void main() { | |
vUv = uv; | |
vNormal = normalize(normalMatrix * normal); | |
vPosition = (modelViewMatrix * vec4(position, 1.0)).xyz; | |
vec3 tangent = vec3(1.0, 0.0, 0.0); | |
vTangent = normalize(normalMatrix * tangent); | |
vBitangent = cross(vNormal, vTangent); | |
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
} | |
`, | |
fragmentShader: ` | |
uniform sampler2D dayTexture; | |
uniform sampler2D nightTexture; | |
uniform sampler2D lightsTexture; | |
uniform sampler2D normalMap; | |
uniform sampler2D specularMap; | |
uniform float normalScale; | |
uniform float specularIntensity; | |
uniform float lightsIntensity; | |
uniform vec3 sunDirection; | |
uniform float ambientStrength; | |
varying vec2 vUv; | |
varying vec3 vNormal; | |
varying vec3 vPosition; | |
varying vec3 vTangent; | |
varying vec3 vBitangent; | |
void main() { | |
// Normal mapping | |
vec3 normalTex = texture2D(normalMap, vUv).xyz * 2.0 - 1.0; | |
normalTex.xy *= normalScale; | |
mat3 tbn = mat3(vTangent, vBitangent, vNormal); | |
vec3 normal = normalize(tbn * normalTex); | |
// Calculate sun direction in view space | |
vec3 sunDir = normalize((viewMatrix * vec4(sunDirection, 0.0)).xyz); | |
// Lighting calculation | |
float sunDot = dot(normal, sunDir); | |
float dayStrength = smoothstep(-0.3, 0.3, sunDot); | |
// Sample textures | |
vec3 dayColor = texture2D(dayTexture, vUv).rgb; | |
vec3 nightColor = texture2D(nightTexture, vUv).rgb; | |
vec4 lightsColor = texture2D(lightsTexture, vUv); | |
float specular = texture2D(specularMap, vUv).r; | |
// Mix day and night | |
vec3 color = mix(nightColor, dayColor, dayStrength); | |
// Add city lights (only visible on night side) | |
float nightStrength = 1.0 - dayStrength; | |
float lightsVisibility = smoothstep(0.3, 0.7, nightStrength); | |
color += lightsColor.rgb * lightsColor.a * lightsIntensity * lightsVisibility; | |
// Specular reflection (only on water/oceans) | |
vec3 viewDir = normalize(-vPosition); | |
vec3 reflectDir = reflect(-sunDir, normal); | |
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); | |
color += vec3(1.0) * spec * specular * specularIntensity * dayStrength; | |
// Add ambient light | |
color += dayColor * ambientStrength; | |
gl_FragColor = vec4(color, 1.0); | |
} | |
`, | |
}); | |
// Create Earth | |
const earthGeometry = new THREE.SphereGeometry(earthRadius, 128, 64); | |
const earth = new THREE.Mesh(earthGeometry, earthMaterial); | |
earth.castShadow = true; | |
earth.receiveShadow = true; | |
earthGroup.add(earth); | |
// Atmosphere | |
const atmosphereGeometry = new THREE.SphereGeometry( | |
earthRadius * 1.015, | |
128, | |
64 | |
); | |
const atmosphereMaterial = new THREE.ShaderMaterial({ | |
uniforms: { | |
sunDirection: { value: new THREE.Vector3(1, 0, 0) }, | |
}, | |
vertexShader: ` | |
varying vec3 vNormal; | |
varying vec3 vPosition; | |
void main() { | |
vNormal = normalize(normalMatrix * normal); | |
vPosition = (modelViewMatrix * vec4(position, 1.0)).xyz; | |
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); | |
} | |
`, | |
fragmentShader: ` | |
uniform vec3 sunDirection; | |
varying vec3 vNormal; | |
varying vec3 vPosition; | |
void main() { | |
vec3 sunDir = normalize((viewMatrix * vec4(sunDirection, 0.0)).xyz); | |
float intensity = pow(0.8 - dot(vNormal, normalize(-vPosition)), 2.0); | |
// Add sun-side glow | |
float sunDot = dot(vNormal, sunDir); | |
float sunGlow = smoothstep(-0.3, 0.7, sunDot) * 0.3; | |
vec3 atmosphereColor = vec3(0.3, 0.6, 1.0); | |
float alpha = intensity * 0.6 + sunGlow; | |
gl_FragColor = vec4(atmosphereColor, alpha); | |
} | |
`, | |
transparent: true, | |
side: THREE.BackSide, | |
blending: THREE.AdditiveBlending, | |
depthWrite: false, | |
}); | |
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial); | |
earthGroup.add(atmosphere); | |
// Clouds | |
const cloudGeometry = new THREE.SphereGeometry( | |
earthRadius * 1.01, | |
128, | |
64 | |
); | |
const cloudMaterial = new THREE.MeshPhongMaterial({ | |
map: cloudTexture, | |
transparent: true, | |
opacity: 0.8, | |
depthWrite: false, | |
side: THREE.DoubleSide, | |
}); | |
const clouds = new THREE.Mesh(cloudGeometry, cloudMaterial); | |
clouds.castShadow = true; | |
earthGroup.add(clouds); | |
// Moon | |
const moonRadius = earthRadius * 0.273; | |
const moonDistance = 40; | |
const moonGeometry = new THREE.SphereGeometry(moonRadius, 64, 32); | |
const moonMaterial = new THREE.MeshPhongMaterial({ | |
map: moonTexture, | |
normalMap: moonNormalMap, | |
normalScale: new THREE.Vector2(0.8, 0.8), | |
shininess: 5, | |
}); | |
const moon = new THREE.Mesh(moonGeometry, moonMaterial); | |
moon.castShadow = true; | |
moon.receiveShadow = true; | |
// Moon group for orbit | |
const moonGroup = new THREE.Group(); | |
moonGroup.add(moon); | |
moon.position.x = moonDistance; | |
scene.add(moonGroup); | |
// Starfield | |
const starGeometry = new THREE.SphereGeometry(500, 64, 32); | |
const starMaterial = new THREE.MeshBasicMaterial({ | |
map: starTexture, | |
side: THREE.BackSide, | |
}); | |
const starfield = new THREE.Mesh(starGeometry, starMaterial); | |
scene.add(starfield); | |
// UI Controls | |
const controls_ui = { | |
ambientLight: document.getElementById("ambient-light"), | |
sunBrightness: document.getElementById("sun-brightness"), | |
earthRotation: document.getElementById("earth-rotation"), | |
earthNormal: document.getElementById("earth-normal"), | |
earthSpecular: document.getElementById("earth-specular"), | |
cityLights: document.getElementById("city-lights"), | |
cloudHeight: document.getElementById("cloud-height"), | |
cloudOpacity: document.getElementById("cloud-opacity"), | |
moonNormal: document.getElementById("moon-normal"), | |
starBrightness: document.getElementById("star-brightness"), | |
}; | |
// Value displays | |
const valueDisplays = { | |
ambient: document.getElementById("ambient-value"), | |
sun: document.getElementById("sun-value"), | |
rotation: document.getElementById("rotation-value"), | |
normal: document.getElementById("normal-value"), | |
specular: document.getElementById("specular-value"), | |
lights: document.getElementById("lights-value"), | |
cloudHeight: document.getElementById("cloud-height-value"), | |
cloudOpacity: document.getElementById("cloud-opacity-value"), | |
moonNormal: document.getElementById("moon-normal-value"), | |
star: document.getElementById("star-value"), | |
}; | |
// Simulation parameters | |
let earthRotationSpeed = 0.1; | |
let cloudRotationMultiplier = 1.2; | |
// UI event listeners | |
controls_ui.ambientLight.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
ambientLight.intensity = value; | |
earthMaterial.uniforms.ambientStrength.value = value; | |
valueDisplays.ambient.textContent = value.toFixed(2); | |
}); | |
controls_ui.sunBrightness.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
sunLight.intensity = value; | |
valueDisplays.sun.textContent = value.toFixed(2); | |
}); | |
controls_ui.earthRotation.addEventListener("input", (e) => { | |
earthRotationSpeed = parseFloat(e.target.value); | |
valueDisplays.rotation.textContent = earthRotationSpeed.toFixed(2); | |
}); | |
controls_ui.earthNormal.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
earthMaterial.uniforms.normalScale.value = value; | |
valueDisplays.normal.textContent = value.toFixed(2); | |
}); | |
controls_ui.earthSpecular.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
earthMaterial.uniforms.specularIntensity.value = value; | |
valueDisplays.specular.textContent = value.toFixed(2); | |
}); | |
controls_ui.cityLights.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
earthMaterial.uniforms.lightsIntensity.value = value; | |
valueDisplays.lights.textContent = value.toFixed(2); | |
}); | |
controls_ui.cloudHeight.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
clouds.scale.setScalar(value); | |
valueDisplays.cloudHeight.textContent = value.toFixed(3); | |
}); | |
controls_ui.cloudOpacity.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
cloudMaterial.opacity = value; | |
valueDisplays.cloudOpacity.textContent = value.toFixed(2); | |
}); | |
controls_ui.moonNormal.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
moonMaterial.normalScale.set(value, value); | |
valueDisplays.moonNormal.textContent = value.toFixed(2); | |
}); | |
controls_ui.starBrightness.addEventListener("input", (e) => { | |
const value = parseFloat(e.target.value); | |
starMaterial.color.setScalar(value); | |
valueDisplays.star.textContent = value.toFixed(2); | |
}); | |
// Animation | |
const clock = new THREE.Clock(); | |
function animate() { | |
requestAnimationFrame(animate); | |
const deltaTime = clock.getDelta(); | |
// Rotate Earth and clouds | |
earth.rotation.y += earthRotationSpeed * deltaTime; | |
clouds.rotation.y += | |
earthRotationSpeed * cloudRotationMultiplier * deltaTime; | |
// Moon orbit (tidally locked) | |
const moonOrbitSpeed = earthRotationSpeed * 0.0366; // Moon orbits once per ~27.3 Earth days | |
moonGroup.rotation.y += moonOrbitSpeed * deltaTime; | |
moon.rotation.y = -moonGroup.rotation.y; // Keep same face toward Earth | |
// Update sun direction for shaders | |
const sunAngle = 0; | |
const sunDir = new THREE.Vector3( | |
Math.cos(sunAngle), | |
0, | |
Math.sin(sunAngle) | |
); | |
earthMaterial.uniforms.sunDirection.value = sunDir; | |
atmosphereMaterial.uniforms.sunDirection.value = sunDir; | |
controls.update(); | |
renderer.render(scene, camera); | |
} | |
// Handle window resize | |
window.addEventListener("resize", () => { | |
camera.aspect = window.innerWidth / window.innerHeight; | |
camera.updateProjectionMatrix(); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
}); | |
// Start animation | |
animate(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment