438 lines
14 KiB
JavaScript
438 lines
14 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;
|
|
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 <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 };
|
|
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);
|
|
});
|
|
}
|