Files
planet/frontend/public/earth/js/earth.js
2026-04-22 03:52:09 +08:00

421 lines
13 KiB
JavaScript

// 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;
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 <common>",
`#include <common>
varying vec3 vWorldNormal;`,
).replace(
"#include <begin_vertex>",
`#include <begin_vertex>
vWorldNormal = normalize(mat3(modelMatrix) * normal);`,
);
shader.fragmentShader = shader.fragmentShader.replace(
"#include <common>",
`#include <common>
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 <output_fragment>",
`
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 <output_fragment>
`,
);
};
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 };
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 };
earthObj.add(line);
longitudeLines.push(line);
}
}
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);
});
}