Files
planet/frontend/public/earth/js/earth.js
2026-04-28 04:27:18 +08:00

580 lines
18 KiB
JavaScript

// 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 _textureVisible = true;
let _earthRimGlow = 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 <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;
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 <output_fragment>
`,
);
};
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 = showClouds;
earthObj.add(clouds);
textureLoader.load(
CLOUD_LAYER_CONFIG.textureUrl,
function(texture) {
material.map = texture;
material.needsUpdate = true;
},
undefined,
function(err) {
console.log('云层纹理加载失败');
}
);
return clouds;
}
export function toggleClouds(visible) {
showClouds = Boolean(visible);
if (clouds) {
clouds.visible = showClouds;
}
}
export function getShowClouds() {
return showClouds;
}
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;
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() {
return 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();
},
null,
() => tryLoad(index + 1),
);
};
tryLoad(0);
});
}
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;
}