// earth.js - 3D Earth creation module import * as THREE from 'three'; import { CLOUD_LAYER_CONFIG, CONFIG, EARTH_CONFIG, EARTH_MATERIAL_CONFIG, GRID_CONFIG, STARFIELD_CONFIG, TERRAIN_CONFIG, } from './constants.js'; import { latLonToVector3 } from './utils.js'; export let earth = null; export let clouds = null; export let terrain = null; let showGridLines = false; let showClouds = true; const textureLoader = new THREE.TextureLoader(); let _earthMaterial = null; let _earthTextureOverlay = null; let _earthTextureOverlayMaterial = null; let _earthShaders = []; let _dayNightEnabled = true; let _loadedTexture = null; let _textureLoadPromise = null; let _textureVisible = true; let _earthRimGlow = null; let _cloudTexture = null; let _cloudTextureLoadPromise = null; const _earthSunDirection = new THREE.Vector3( EARTH_MATERIAL_CONFIG.dayNight.sunDirection.x, EARTH_MATERIAL_CONFIG.dayNight.sunDirection.y, EARTH_MATERIAL_CONFIG.dayNight.sunDirection.z, ).normalize(); function applyEarthDayNightShader(material) { if (!material || !EARTH_MATERIAL_CONFIG.dayNight.enabled) return; const twilightColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.twilightColor); const nightTintColor = new THREE.Color(EARTH_MATERIAL_CONFIG.dayNight.nightTintColor); material.onBeforeCompile = (shader) => { _earthShaders.push(shader); shader.uniforms.uSunDirectionWorld = { value: _earthSunDirection.clone() }; shader.uniforms.uNightFloor = { value: EARTH_MATERIAL_CONFIG.dayNight.nightFloor }; shader.uniforms.uDayBoost = { value: EARTH_MATERIAL_CONFIG.dayNight.dayBoost }; shader.uniforms.uTwilightWidth = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightWidth }; shader.uniforms.uTwilightIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.twilightIntensity }; shader.uniforms.uTwilightColor = { value: twilightColor }; shader.uniforms.uNightTintColor = { value: nightTintColor }; shader.uniforms.uNightTintIntensity = { value: EARTH_MATERIAL_CONFIG.dayNight.nightTintIntensity }; shader.uniforms.uDayNightEnabled = { value: _dayNightEnabled ? 1.0 : 0.0 }; shader.vertexShader = shader.vertexShader.replace( "#include ", `#include varying vec3 vWorldNormal;`, ).replace( "#include ", `#include vWorldNormal = normalize(mat3(modelMatrix) * normal);`, ); shader.fragmentShader = shader.fragmentShader.replace( "#include ", `#include varying vec3 vWorldNormal; uniform vec3 uSunDirectionWorld; uniform float uNightFloor; uniform float uDayBoost; uniform float uTwilightWidth; uniform float uTwilightIntensity; uniform vec3 uTwilightColor; uniform vec3 uNightTintColor; uniform float uNightTintIntensity; uniform float uDayNightEnabled;`, ).replace( "#include ", ` vec3 worldNormal = normalize(vWorldNormal); vec3 sunDir = normalize(uSunDirectionWorld); float sunFacing = dot(worldNormal, sunDir); float daylight = smoothstep(-uTwilightWidth, uTwilightWidth, sunFacing); float twilight = 1.0 - smoothstep(0.0, uTwilightWidth, abs(sunFacing)); // Camera-facing diffuse: vNormal and vViewPosition are both in view space. // N·V gives 1.0 at center-facing, 0 at limb — creates depth cue regardless of earth rotation. float nDotV = max(0.0, dot(normalize(vNormal), normalize(vViewPosition))); float cameraBoost = mix(0.62, 1.08, nDotV); float dn = uDayNightEnabled; vec3 dnLight = outgoingLight; dnLight *= mix(uNightFloor, uDayBoost, daylight); dnLight += uTwilightColor * twilight * uTwilightIntensity; dnLight += uNightTintColor * (1.0 - daylight) * uNightTintIntensity; dnLight = dnLight / (vec3(1.0) + max(dnLight - vec3(0.68), vec3(0.0)) * 0.86); // dn=0: emissive base (from material, set in JS) * camera-facing boost → always readable // dn=1: full day/night solar lighting outgoingLight = mix(outgoingLight * cameraBoost, dnLight, dn); #include `, ); }; material.customProgramCacheKey = () => "earth-day-night-v5"; material.needsUpdate = true; } export function createEarth(scene) { _earthShaders = []; const geometry = new THREE.SphereGeometry(CONFIG.earthRadius, 128, 128); const C = EARTH_MATERIAL_CONFIG; const material = new THREE.MeshPhongMaterial({ color: C.color, specular: C.specular, shininess: C.shininess, emissive: C.emissive, transparent: C.opacity < 1, opacity: C.opacity, side: THREE.FrontSide, depthWrite: true, depthTest: true, }); applyEarthDayNightShader(material); _earthMaterial = material; earth = new THREE.Mesh(geometry, material); earth.renderOrder = 0; earth.rotation.x = EARTH_CONFIG.tiltRad; scene.add(earth); const textureOverlayGeometry = new THREE.SphereGeometry( CONFIG.earthRadius + C.textureOverlayAltitudeOffset, 128, 128, ); _earthTextureOverlayMaterial = new THREE.MeshPhongMaterial({ color: 0xffffff, specular: C.textureOverlaySpecular, shininess: C.textureOverlayShininess, transparent: true, opacity: C.textureOverlayOpacity, side: THREE.FrontSide, depthWrite: false, depthTest: true, }); applyEarthDayNightShader(_earthTextureOverlayMaterial); _earthTextureOverlay = new THREE.Mesh( textureOverlayGeometry, _earthTextureOverlayMaterial, ); _earthTextureOverlay.name = "earth-high-res-texture-overlay"; _earthTextureOverlay.renderOrder = C.textureOverlayRenderOrder; _earthTextureOverlay.visible = false; earth.add(_earthTextureOverlay); // Depth-mask occluder — invisible sphere slightly inside the earth, // writes to the depth buffer so far-side cables/satellites are occluded. const occluderGeometry = new THREE.SphereGeometry( CONFIG.earthRadius * C.occluderRadiusFactor, C.occluderSegments, C.occluderSegments, ); const occluderMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, side: THREE.FrontSide, }); const occluder = new THREE.Mesh(occluderGeometry, occluderMaterial); occluder.renderOrder = -1; earth.add(occluder); // Keep the original atmosphere shells on the legacy camera-facing shader so // they stay as a soft edge cue instead of becoming a visible transparent hull // at close zoom levels. const ATMOS_VERTEX_SHADER = ` varying vec3 vNormal; void main() { vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `; const RIM_VERTEX_SHADER = ` varying vec3 vNormal; varying vec3 vViewDirection; void main() { vNormal = normalize(normalMatrix * normal); vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); vViewDirection = normalize(-mvPosition.xyz); gl_Position = projectionMatrix * mvPosition; } `; // Fresnel atmosphere — inner rim const [ir, ig, ib] = C.atmosInnerColor; const atmosInnerGeo = new THREE.SphereGeometry( CONFIG.earthRadius * C.atmosInnerRadiusFactor, C.atmosInnerSegments, C.atmosInnerSegments, ); const atmosInnerMat = new THREE.ShaderMaterial({ vertexShader: ATMOS_VERTEX_SHADER, fragmentShader: ` varying vec3 vNormal; void main() { float rim = 1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))); float intensity = pow(rim, ${C.atmosInnerRimPower.toFixed(1)}); gl_FragColor = vec4(${ir.toFixed(2)}, ${ig.toFixed(2)}, ${ib.toFixed(2)}, intensity * ${C.atmosInnerIntensity.toFixed(2)}); } `, blending: THREE.AdditiveBlending, side: THREE.BackSide, transparent: true, depthWrite: false, }); const atmosInner = new THREE.Mesh(atmosInnerGeo, atmosInnerMat); atmosInner.renderOrder = 1; earth.add(atmosInner); // Fresnel atmosphere — outer corona const [outerR, outerG, outerB] = C.atmosOuterColor; const atmosOuterGeo = new THREE.SphereGeometry( CONFIG.earthRadius * C.atmosOuterRadiusFactor, C.atmosOuterSegments, C.atmosOuterSegments, ); const atmosOuterMat = new THREE.ShaderMaterial({ vertexShader: ATMOS_VERTEX_SHADER, fragmentShader: ` varying vec3 vNormal; void main() { float rim = 1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))); float intensity = pow(rim, ${C.atmosOuterRimPower.toFixed(1)}); gl_FragColor = vec4(${outerR.toFixed(2)}, ${outerG.toFixed(2)}, ${outerB.toFixed(2)}, intensity * ${C.atmosOuterIntensity.toFixed(2)}); } `, blending: THREE.AdditiveBlending, side: THREE.BackSide, transparent: true, depthWrite: false, }); const atmosOuter = new THREE.Mesh(atmosOuterGeo, atmosOuterMat); atmosOuter.renderOrder = 1; earth.add(atmosOuter); // Fresnel rim cue: an outer shell keeps the edge tied to the globe while bypassing // the darker fill layers that can otherwise hide a same-radius glow. Unlike the // legacy atmosphere shells, this one uses the real view direction so its highlight // stays attached to the visible globe edge while zooming. const [rr, rg, rb] = C.rimGlowColor; const rimGlowGeo = new THREE.SphereGeometry( CONFIG.earthRadius * C.rimGlowRadiusFactor, C.rimGlowSegments, C.rimGlowSegments, ); const rimGlowMat = new THREE.ShaderMaterial({ vertexShader: RIM_VERTEX_SHADER, fragmentShader: ` varying vec3 vNormal; varying vec3 vViewDirection; void main() { float viewFacing = max(dot(normalize(vNormal), normalize(vViewDirection)), 0.0); float rim = 1.0 - viewFacing; float alpha = pow(rim, ${C.rimGlowPower.toFixed(1)}) * ${C.rimGlowIntensity.toFixed(2)}; gl_FragColor = vec4(${rr.toFixed(2)}, ${rg.toFixed(2)}, ${rb.toFixed(2)}, alpha); } `, blending: THREE.AdditiveBlending, side: THREE.FrontSide, transparent: true, depthTest: false, depthWrite: false, }); _earthRimGlow = new THREE.Mesh(rimGlowGeo, rimGlowMat); _earthRimGlow.name = "earth-rim-glow"; _earthRimGlow.renderOrder = C.rimGlowRenderOrder; earth.add(_earthRimGlow); // Texture is loaded separately via loadEarthTexture() for staged loading return earth; } export function createClouds(scene, earthObj) { const geometry = new THREE.SphereGeometry( CONFIG.earthRadius + CLOUD_LAYER_CONFIG.radiusOffset, CLOUD_LAYER_CONFIG.widthSegments, CLOUD_LAYER_CONFIG.heightSegments, ); const material = new THREE.MeshPhongMaterial({ transparent: true, opacity: CLOUD_LAYER_CONFIG.opacity, depthTest: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide }); clouds = new THREE.Mesh(geometry, material); clouds.name = "earth-atmosphere-clouds"; clouds.visible = false; earthObj.add(clouds); return clouds; } export function toggleClouds(visible) { showClouds = Boolean(visible); if (clouds) { clouds.visible = showClouds && Boolean(clouds.material?.map); } } export function getShowClouds() { return showClouds; } export function loadCloudTexture() { if (_cloudTexture) return Promise.resolve(_cloudTexture); if (_cloudTextureLoadPromise) return _cloudTextureLoadPromise; _cloudTextureLoadPromise = new Promise((resolve, reject) => { if (!clouds?.material) { resolve(null); return; } textureLoader.load( CLOUD_LAYER_CONFIG.textureUrl, (texture) => { _cloudTexture = texture; clouds.material.map = texture; clouds.material.needsUpdate = true; clouds.visible = showClouds; resolve(texture); }, undefined, (error) => { console.warn("云层纹理加载失败"); reject(error); }, ); }); _cloudTextureLoadPromise.finally(() => { _cloudTextureLoadPromise = null; }); return _cloudTextureLoadPromise; } export function createTerrain(earthObj) { const geometry = new THREE.SphereGeometry( CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset, TERRAIN_CONFIG.geometryWidthSegments, TERRAIN_CONFIG.geometryHeightSegments, ); const material = new THREE.MeshPhongMaterial({ color: TERRAIN_CONFIG.color, emissive: TERRAIN_CONFIG.emissive, specular: TERRAIN_CONFIG.specular, shininess: TERRAIN_CONFIG.shininess, vertexColors: true, transparent: true, opacity: TERRAIN_CONFIG.opacity, flatShading: false, depthWrite: false, depthTest: true, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1, }); terrain = new THREE.Mesh(geometry, material); terrain.name = "earth-real-terrain"; terrain.visible = false; terrain.renderOrder = 1.2; terrain.raycast = () => {}; earthObj.add(terrain); return terrain; } export function toggleTerrain(visible) { if (terrain) { terrain.visible = visible; } } export function createStars(scene) { const starGeometry = new THREE.BufferGeometry(); const starCount = STARFIELD_CONFIG.count; const starPositions = new Float32Array(starCount * 3); for (let i = 0; i < starCount * 3; i += 3) { const r = STARFIELD_CONFIG.minRadius + Math.random() * STARFIELD_CONFIG.radiusJitter; const theta = Math.random() * Math.PI * 2; const phi = Math.acos(2 * Math.random() - 1); starPositions[i] = r * Math.sin(phi) * Math.cos(theta); starPositions[i + 1] = r * Math.sin(phi) * Math.sin(theta); starPositions[i + 2] = r * Math.cos(phi); } starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3)); const starMaterial = new THREE.PointsMaterial({ color: STARFIELD_CONFIG.color, size: STARFIELD_CONFIG.size, transparent: true, blending: THREE.AdditiveBlending }); const stars = new THREE.Points(starGeometry, starMaterial); scene.add(stars); return stars; } let latitudeLines = []; let longitudeLines = []; export function createGridLines(scene, earthObj) { latitudeLines.forEach(line => scene.remove(line)); longitudeLines.forEach(line => scene.remove(line)); latitudeLines = []; longitudeLines = []; const earthRadius = CONFIG.earthRadius + GRID_CONFIG.radiusOffset; const gridMaterial = new THREE.LineBasicMaterial({ color: GRID_CONFIG.color, transparent: true, opacity: GRID_CONFIG.opacity, linewidth: GRID_CONFIG.lineWidth, depthTest: true, depthWrite: false, }); for (let lat = -75; lat <= 75; lat += GRID_CONFIG.latitudeStep) { const points = []; for (let lon = -180; lon <= 180; lon += GRID_CONFIG.segmentStep) { const point = latLonToVector3(lat, lon, earthRadius); points.push(point); } const geometry = new THREE.BufferGeometry().setFromPoints(points); const line = new THREE.Line(geometry, gridMaterial); line.userData = { type: 'latitude', value: lat }; line.renderOrder = GRID_CONFIG.renderOrder; line.visible = showGridLines; earthObj.add(line); latitudeLines.push(line); } for (let lon = -180; lon <= 180; lon += GRID_CONFIG.longitudeStep) { const points = []; for (let lat = -90; lat <= 90; lat += GRID_CONFIG.segmentStep) { const point = latLonToVector3(lat, lon, earthRadius); points.push(point); } const geometry = new THREE.BufferGeometry().setFromPoints(points); const line = new THREE.Line(geometry, gridMaterial); line.userData = { type: 'longitude', value: lon }; line.renderOrder = GRID_CONFIG.renderOrder; line.visible = showGridLines; earthObj.add(line); longitudeLines.push(line); } } export function toggleGridLines(visible) { showGridLines = visible; latitudeLines.forEach((line) => { line.visible = visible; }); longitudeLines.forEach((line) => { line.visible = visible; }); } export function getShowGridLines() { return showGridLines; } export function getEarth() { return earth; } export function getEarthSurfacePickTarget() { return _earthTextureOverlay?.visible ? _earthTextureOverlay : earth; } export function getClouds() { return clouds; } export function clearEarthTexture() { _loadedTexture = null; _textureLoadPromise = null; if (_earthTextureOverlayMaterial) { _earthTextureOverlayMaterial.map = null; _earthTextureOverlayMaterial.needsUpdate = true; } if (_earthTextureOverlay) { _earthTextureOverlay.visible = false; } if (_earthRimGlow) { _earthRimGlow.visible = true; } } export function setEarthSunDirection(direction) { if (!direction) return; _earthSunDirection.copy(direction).normalize(); _earthShaders.forEach((shader) => { shader?.uniforms?.uSunDirectionWorld?.value?.copy(_earthSunDirection); }); } export function setDayNightEnabled(enabled) { _dayNightEnabled = enabled; _earthShaders.forEach((shader) => { if (shader?.uniforms?.uDayNightEnabled) { shader.uniforms.uDayNightEnabled.value = enabled ? 1.0 : 0.0; } }); if (_earthMaterial) { if (enabled) { // Restore normal Phong lighting + custom day/night shader _earthMaterial.color.setHex(EARTH_MATERIAL_CONFIG.color); _earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.emissive); _earthMaterial.emissiveMap = null; } else { // Full bright: zero diffuse so directional light has no effect; _earthMaterial.color.setRGB(0, 0, 0); _earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color); _earthMaterial.emissiveMap = null; } _earthMaterial.needsUpdate = true; } } export function loadEarthTexture() { if (_loadedTexture) return Promise.resolve(_loadedTexture); if (_textureLoadPromise) return _textureLoadPromise; _textureLoadPromise = new Promise((resolve) => { if (!_earthTextureOverlayMaterial) { resolve(); return; } const urls = EARTH_MATERIAL_CONFIG.textureUrls; const tryLoad = (index) => { if (index >= urls.length) { console.warn('所有地球纹理加载失败'); resolve(); return; } textureLoader.load( urls[index], (texture) => { texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; texture.anisotropy = 16; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; _loadedTexture = texture; _earthTextureOverlayMaterial.map = texture; _earthTextureOverlayMaterial.needsUpdate = true; if (_earthTextureOverlay) { _earthTextureOverlay.visible = _textureVisible; } if (_earthRimGlow) { _earthRimGlow.visible = !_textureVisible; } resolve(texture); }, null, () => tryLoad(index + 1), ); }; tryLoad(0); }); _textureLoadPromise.finally(() => { _textureLoadPromise = null; }); return _textureLoadPromise; } export function setEarthTextureVisible(visible) { _textureVisible = Boolean(visible); const textureShowing = _textureVisible && Boolean(_loadedTexture); if (_earthTextureOverlay) { _earthTextureOverlay.visible = textureShowing; } if (_earthTextureOverlayMaterial) { _earthTextureOverlayMaterial.map = _loadedTexture || null; _earthTextureOverlayMaterial.needsUpdate = true; } if (_earthRimGlow) { _earthRimGlow.visible = !textureShowing; } } export function getEarthTextureVisible() { return _textureVisible; }