// earth.js - 3D Earth creation module import * as THREE from 'three'; import { CONFIG, EARTH_CONFIG, EARTH_MATERIAL_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 = true; const textureLoader = new THREE.TextureLoader(); let _earthMaterial = null; let _earthShader = null; let _dayNightEnabled = true; 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) => { _earthShader = 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; // 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) { 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: true, 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); // 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); // Shared Fresnel vertex shader for both atmosphere layers const ATMOS_VERTEX_SHADER = ` varying vec3 vNormal; void main() { vNormal = normalize(normalMatrix * normal); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `; // 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); // Texture is loaded separately via loadEarthTexture() for staged loading return earth; } export function createClouds(scene, earthObj) { const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64); const material = new THREE.MeshPhongMaterial({ transparent: true, linewidth: 2, opacity: 0.15, depthTest: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide }); clouds = new THREE.Mesh(geometry, material); earthObj.add(clouds); textureLoader.load( './assets/earth_clouds_1024.png', function(texture) { material.map = texture; material.needsUpdate = true; }, undefined, function(err) { console.log('云层纹理加载失败'); } ); return clouds; } 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, vertexAlphas: 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 = 0.5; 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 = 8000; const starPositions = new Float32Array(starCount * 3); for (let i = 0; i < starCount * 3; i += 3) { const r = 800 + Math.random() * 200; 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: 0xffffff, size: 0.5, 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 = 100.1; const gridMaterial = new THREE.LineBasicMaterial({ color: 0x44aaff, transparent: true, opacity: 0.2, linewidth: 1 }); for (let lat = -75; lat <= 75; lat += 15) { const points = []; for (let lon = -180; lon <= 180; lon += 5) { 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.visible = showGridLines; earthObj.add(line); latitudeLines.push(line); } for (let lon = -180; lon <= 180; lon += 30) { const points = []; for (let lat = -90; lat <= 90; lat += 5) { 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.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 getClouds() { return clouds; } export function clearEarthTexture() { if (!_earthMaterial) return; _earthMaterial.map = null; _earthMaterial.needsUpdate = true; } export function setEarthSunDirection(direction) { if (!direction) return; _earthSunDirection.copy(direction).normalize(); if (_earthShader?.uniforms?.uSunDirectionWorld) { _earthShader.uniforms.uSunDirectionWorld.value.copy(_earthSunDirection); } } export function setDayNightEnabled(enabled) { _dayNightEnabled = enabled; if (_earthShader?.uniforms?.uDayNightEnabled) { _earthShader.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; // use original color as emissive map to show texture uniformly. _earthMaterial.color.setRGB(0, 0, 0); _earthMaterial.emissive.setHex(EARTH_MATERIAL_CONFIG.color); _earthMaterial.emissiveMap = _earthMaterial.map; } _earthMaterial.needsUpdate = true; } } export function loadEarthTexture() { return new Promise((resolve) => { if (!_earthMaterial) { 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; _earthMaterial.map = texture; // If day/night is currently disabled, sync emissiveMap to the newly loaded texture if (!_dayNightEnabled) { _earthMaterial.emissiveMap = texture; } _earthMaterial.needsUpdate = true; resolve(); }, null, () => tryLoad(index + 1), ); }; tryLoad(0); }); }