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

646 lines
18 KiB
JavaScript

import * as THREE from "three";
import * as Astronomy from "astronomy-engine";
import {
CELESTIAL_CONFIG,
EARTH_CONFIG,
SCENE_LIGHT_CONFIG,
} from "./constants.js";
import { latLonToVector3 } from "./utils.js";
const textureLoader = new THREE.TextureLoader();
const defaultSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
const defaultMoonDirection = new THREE.Vector3(-0.6, 0.45, -0.2).normalize();
let celestialRoot = null;
let skySphere = null;
let brightStarsGroup = null;
let sunSprite = null;
let moonSprite = null;
let sunHaloSprite = null;
let moonHaloSprite = null;
let brightStarTexture = null;
let sunDirection = defaultSunDirection.clone();
let moonDirection = defaultMoonDirection.clone();
let lastUpdatedAt = 0;
let linkedSunLight = null;
let linkedBackLight = null;
let linkedAmbientLight = null;
let linkedPointLight = null;
let linkedCamera = null;
let linkedEarth = null;
let dayNightLightingEnabled = true;
let brightStarSprites = [];
let celestialRotationQuaternion = new THREE.Quaternion();
let celestialViewQuaternion = new THREE.Quaternion();
let runtimeOrientationEuler = {
...CELESTIAL_CONFIG.orientationEulerRad,
};
let runtimeFollowConfig = {
...CELESTIAL_CONFIG.followEarthRotation,
};
const scratchEuler = new THREE.Euler(0, 0, 0, "YXZ");
function normalizeDegrees180(value) {
let normalized = value;
while (normalized <= -180) normalized += 360;
while (normalized > 180) normalized -= 360;
return normalized;
}
function computeGreenwichMeanSiderealDegrees(date) {
const jd = date.getTime() / 86400000 + 2440587.5;
const t = (jd - 2451545.0) / 36525.0;
const gmst =
280.46061837 +
360.98564736629 * (jd - 2451545.0) +
0.000387933 * t * t -
(t * t * t) / 38710000;
return THREE.MathUtils.euclideanModulo(gmst, 360);
}
function computeSubsolarLocalDirection(date) {
const vector = Astronomy.GeoVector(Astronomy.Body.Sun, date, false);
const radius = Math.sqrt(
vector.x * vector.x +
vector.y * vector.y +
vector.z * vector.z,
);
if (!Number.isFinite(radius) || radius === 0) {
return defaultSunDirection.clone();
}
const rightAscensionDeg = THREE.MathUtils.radToDeg(
Math.atan2(vector.y, vector.x),
);
const declinationDeg = THREE.MathUtils.radToDeg(
Math.asin(THREE.MathUtils.clamp(vector.z / radius, -1, 1)),
);
const gmstDeg = computeGreenwichMeanSiderealDegrees(date);
const subsolarLonDeg = normalizeDegrees180(rightAscensionDeg - gmstDeg);
return latLonToVector3(declinationDeg, subsolarLonDeg, 1).normalize();
}
function getPhysicalSunDirection(date = new Date()) {
const localSunDirection = computeSubsolarLocalDirection(date);
if (!linkedEarth) {
return localSunDirection;
}
return localSunDirection
.clone()
.applyQuaternion(linkedEarth.quaternion)
.normalize();
}
function getCelestialEuler() {
const { x, y, z } = runtimeOrientationEuler;
return new THREE.Euler(x, y, z, "YXZ");
}
function refreshCelestialOrientation() {
celestialRotationQuaternion.setFromEuler(getCelestialEuler());
refreshCelestialView();
}
function refreshCelestialView() {
if (linkedEarth && runtimeFollowConfig.enabled) {
const followX = runtimeFollowConfig.x
? (linkedEarth.rotation.x - EARTH_CONFIG.tiltRad) * (runtimeFollowConfig.invertX ? -1 : 1)
: 0;
const followY = runtimeFollowConfig.y
? linkedEarth.rotation.y * (runtimeFollowConfig.invertY ? -1 : 1)
: 0;
const followZ = runtimeFollowConfig.z
? linkedEarth.rotation.z * (runtimeFollowConfig.invertZ ? -1 : 1)
: 0;
scratchEuler.set(
followX,
followY,
followZ,
"YXZ",
);
celestialViewQuaternion.setFromEuler(scratchEuler);
} else {
celestialViewQuaternion.identity();
}
if (celestialRoot) {
celestialRoot.quaternion
.copy(celestialRotationQuaternion)
.multiply(celestialViewQuaternion);
}
}
function applyCelestialOrientation(direction) {
return direction
.clone()
.applyQuaternion(celestialRotationQuaternion)
.applyQuaternion(celestialViewQuaternion)
.normalize();
}
function configureTextureEncoding(texture) {
if (!texture) return;
if ("colorSpace" in texture && THREE.SRGBColorSpace) {
texture.colorSpace = THREE.SRGBColorSpace;
} else if ("encoding" in texture && THREE.sRGBEncoding) {
texture.encoding = THREE.sRGBEncoding;
}
}
function createDiscTexture(stops, size = 256) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const gradient = ctx.createRadialGradient(
size / 2,
size / 2,
0,
size / 2,
size / 2,
size / 2,
);
stops.forEach(([offset, color]) => gradient.addColorStop(offset, color));
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, size);
const texture = new THREE.CanvasTexture(canvas);
configureTextureEncoding(texture);
texture.needsUpdate = true;
return texture;
}
function createSprite({ texture, scale, opacity = 1, name, color = 0xffffff }) {
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity,
depthWrite: false,
depthTest: true,
toneMapped: false,
color,
});
const sprite = new THREE.Sprite(material);
sprite.name = name;
sprite.scale.setScalar(scale);
sprite.renderOrder = 100;
return sprite;
}
function createSkySphere() {
const geometry = new THREE.SphereGeometry(
CELESTIAL_CONFIG.skyRadius,
64,
64,
);
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
side: THREE.BackSide,
transparent: CELESTIAL_CONFIG.skyOpacity < 1,
opacity: CELESTIAL_CONFIG.skyOpacity,
depthWrite: false,
depthTest: false,
fog: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = "celestial-sky-sphere";
mesh.renderOrder = -1000;
mesh.raycast = () => {};
textureLoader.load(
CELESTIAL_CONFIG.starMapUrl,
(texture) => {
configureTextureEncoding(texture);
material.map = texture;
material.needsUpdate = true;
},
undefined,
() => {
console.warn("Failed to load celestial star map texture");
},
);
return mesh;
}
function geoVectorToWorldDirection(vector) {
return new THREE.Vector3(vector.x, vector.z, vector.y).normalize();
}
function raDecToWorldDirection(raDeg, decDeg) {
const raRad = THREE.MathUtils.degToRad(raDeg);
const decRad = THREE.MathUtils.degToRad(decDeg);
const x = Math.cos(decRad) * Math.cos(raRad);
const y = Math.sin(decRad);
const z = Math.cos(decRad) * Math.sin(raRad);
return new THREE.Vector3(x, z, y).normalize();
}
function colorFromBvIndex(colorIndex) {
if (colorIndex <= -0.2) return 0xa8c9ff;
if (colorIndex <= 0.0) return 0xc8dcff;
if (colorIndex <= 0.3) return 0xf4f7ff;
if (colorIndex <= 0.7) return 0xfff4dc;
if (colorIndex <= 1.1) return 0xffdfb0;
if (colorIndex <= 1.5) return 0xffc47f;
return 0xffa35e;
}
function scaleFromMagnitude(mag) {
const brightness = THREE.MathUtils.clamp(
1 - (mag - (-1.5)) / (CELESTIAL_CONFIG.brightStarMinMag - (-1.5)),
0,
1,
);
return THREE.MathUtils.lerp(
CELESTIAL_CONFIG.brightStarMinScale,
CELESTIAL_CONFIG.brightStarMaxScale,
Math.pow(brightness, 0.72),
);
}
function createBrightStarTexture() {
return createDiscTexture([
[0, "rgba(255,255,255,1)"],
[0.18, "rgba(255,255,255,0.98)"],
[0.46, "rgba(215,230,255,0.42)"],
[1, "rgba(128,168,255,0)"],
], 128);
}
function createBrightStarsGroup() {
const group = new THREE.Group();
group.name = "bright-stars-group";
group.visible = true;
return group;
}
async function loadBrightStars() {
try {
const response = await fetch(CELESTIAL_CONFIG.brightStarsUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const stars = await response.json();
const filteredStars = stars
.filter((star) => Number.isFinite(star?.raDeg) && Number.isFinite(star?.decDeg))
.filter((star) => star.mag <= CELESTIAL_CONFIG.brightStarMinMag)
.sort((a, b) => a.mag - b.mag)
.slice(0, CELESTIAL_CONFIG.brightStarMaxCount);
brightStarTexture = createBrightStarTexture();
brightStarSprites = filteredStars.map((star) => {
const sprite = createSprite({
texture: brightStarTexture,
scale: scaleFromMagnitude(star.mag),
opacity: CELESTIAL_CONFIG.brightStarOpacity,
name: `bright-star-${star.name}`,
color: colorFromBvIndex(star.colorIndex ?? 0.4),
});
sprite.position
.copy(raDecToWorldDirection(star.raDeg, star.decDeg))
.multiplyScalar(CELESTIAL_CONFIG.brightStarDistance);
sprite.userData.star = star;
return sprite;
});
brightStarSprites.forEach((sprite) => brightStarsGroup?.add(sprite));
} catch (error) {
console.warn("Failed to load bright star layer", error);
}
}
function computeBodyDirection(body, date) {
const vector = Astronomy.GeoVector(body, date, false);
return geoVectorToWorldDirection(vector);
}
function updateSpritePositions() {
if (sunSprite) {
sunSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
}
if (sunHaloSprite) {
sunHaloSprite.position.copy(sunDirection).multiplyScalar(CELESTIAL_CONFIG.sunDistance);
}
if (moonSprite) {
moonSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
}
if (moonHaloSprite) {
moonHaloSprite.position.copy(moonDirection).multiplyScalar(CELESTIAL_CONFIG.moonDistance);
}
}
function updateLighting() {
if (!dayNightLightingEnabled && CELESTIAL_CONFIG.inspectionLighting?.enabled) {
const inspection = CELESTIAL_CONFIG.inspectionLighting;
const cameraDirection = linkedCamera
? linkedCamera.position.clone().normalize()
: defaultSunDirection.clone();
const worldUp = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(worldUp, cameraDirection);
if (right.lengthSq() < 1e-6) {
right.set(1, 0, 0);
} else {
right.normalize();
}
const adjustedUp = new THREE.Vector3()
.crossVectors(cameraDirection, right)
.normalize();
const resolveInspectionDirection = (offset) =>
cameraDirection
.clone()
.multiplyScalar(offset.z)
.add(right.clone().multiplyScalar(offset.x))
.add(adjustedUp.clone().multiplyScalar(offset.y))
.normalize();
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(inspection.ambientColor);
linkedAmbientLight.intensity = inspection.ambientIntensity;
}
if (linkedSunLight) {
linkedSunLight.color.setHex(inspection.keyLightColor);
linkedSunLight.intensity = inspection.keyLightIntensity;
linkedSunLight.position
.copy(resolveInspectionDirection(inspection.keyLightOffset))
.multiplyScalar(inspection.keyLightDistance);
}
if (linkedBackLight) {
linkedBackLight.color.setHex(inspection.backLightColor);
linkedBackLight.intensity = inspection.backLightIntensity;
linkedBackLight.position
.copy(resolveInspectionDirection(inspection.backLightOffset))
.multiplyScalar(inspection.backLightDistance);
}
if (linkedPointLight) {
linkedPointLight.color.setHex(inspection.pointLightColor);
linkedPointLight.intensity = inspection.pointLightIntensity;
linkedPointLight.position
.copy(resolveInspectionDirection(inspection.pointLightOffset))
.multiplyScalar(inspection.pointLightDistance);
}
return;
}
const physicalSunDirection = getPhysicalSunDirection(
new Date(lastUpdatedAt || Date.now()),
);
if (linkedAmbientLight) {
linkedAmbientLight.color.setHex(SCENE_LIGHT_CONFIG.ambient.color);
linkedAmbientLight.intensity = SCENE_LIGHT_CONFIG.ambient.intensity;
}
if (linkedSunLight) {
linkedSunLight.color.setHex(CELESTIAL_CONFIG.sunLightColor);
linkedSunLight.intensity = CELESTIAL_CONFIG.sunLightIntensity;
linkedSunLight.position
.copy(physicalSunDirection)
.multiplyScalar(CELESTIAL_CONFIG.sunLightDistance);
}
if (linkedBackLight) {
linkedBackLight.color.setHex(CELESTIAL_CONFIG.backLightColor);
linkedBackLight.intensity = CELESTIAL_CONFIG.backLightIntensity;
linkedBackLight.position
.copy(physicalSunDirection)
.multiplyScalar(-CELESTIAL_CONFIG.sunLightDistance * 0.7);
}
if (linkedPointLight) {
linkedPointLight.color.setHex(SCENE_LIGHT_CONFIG.point.color);
linkedPointLight.intensity = SCENE_LIGHT_CONFIG.point.intensity;
linkedPointLight.position.set(
SCENE_LIGHT_CONFIG.point.position.x,
SCENE_LIGHT_CONFIG.point.position.y,
SCENE_LIGHT_CONFIG.point.position.z,
);
}
}
function computeCelestialState(date = new Date()) {
sunDirection = computeBodyDirection(Astronomy.Body.Sun, date);
moonDirection = computeBodyDirection(Astronomy.Body.Moon, date);
updateSpritePositions();
updateLighting();
lastUpdatedAt = date.getTime();
}
export function initCelestialLayer(
scene,
{
camera = null,
sunLight = null,
backLight = null,
ambientLight = null,
pointLight = null,
earth = null,
} = {},
) {
if (!scene || !CELESTIAL_CONFIG.enabled) return null;
disposeCelestialLayer();
linkedCamera = camera;
linkedSunLight = sunLight;
linkedBackLight = backLight;
linkedAmbientLight = ambientLight;
linkedPointLight = pointLight;
linkedEarth = earth;
celestialRoot = new THREE.Group();
celestialRoot.name = "celestial-root";
celestialRoot.position.set(0, 0, 0);
refreshCelestialOrientation();
skySphere = createSkySphere();
brightStarsGroup = createBrightStarsGroup();
const sunTexture = createDiscTexture([
[0, "rgba(255,255,255,1)"],
[0.08, "rgba(255,251,242,1)"],
[0.22, "rgba(255,242,205,0.98)"],
[0.52, "rgba(255,211,117,0.9)"],
[0.82, "rgba(255,162,54,0.18)"],
[1, "rgba(255,120,32,0)"],
]);
const sunHaloTexture = createDiscTexture([
[0, "rgba(255,245,214,0.9)"],
[0.2, "rgba(255,220,154,0.54)"],
[0.52, "rgba(255,160,72,0.16)"],
[1, "rgba(255,120,32,0)"],
], 512);
const moonTexture = createDiscTexture([
[0, "rgba(255,255,255,0.98)"],
[0.42, "rgba(229,236,248,0.92)"],
[0.76, "rgba(164,178,202,0.34)"],
[1, "rgba(80,92,118,0)"],
]);
const moonHaloTexture = createDiscTexture([
[0, "rgba(226,235,250,0.42)"],
[0.38, "rgba(188,203,230,0.16)"],
[1, "rgba(120,136,170,0)"],
], 384);
sunSprite = createSprite({
texture: sunTexture,
scale: CELESTIAL_CONFIG.sunScale,
name: "sun-sprite",
});
sunHaloSprite = createSprite({
texture: sunHaloTexture,
scale: CELESTIAL_CONFIG.sunHaloScale,
opacity: 0.78,
name: "sun-halo-sprite",
});
moonSprite = createSprite({
texture: moonTexture,
scale: CELESTIAL_CONFIG.moonScale,
opacity: 0.98,
name: "moon-sprite",
});
moonHaloSprite = createSprite({
texture: moonHaloTexture,
scale: CELESTIAL_CONFIG.moonHaloScale,
opacity: 0.52,
name: "moon-halo-sprite",
});
sunHaloSprite.renderOrder = 98;
moonHaloSprite.renderOrder = 98;
celestialRoot.add(skySphere);
celestialRoot.add(brightStarsGroup);
celestialRoot.add(sunHaloSprite);
celestialRoot.add(moonHaloSprite);
celestialRoot.add(sunSprite);
celestialRoot.add(moonSprite);
scene.add(celestialRoot);
computeCelestialState(new Date());
loadBrightStars();
return {
root: celestialRoot,
getSunDirection: () => sunDirection.clone(),
getMoonDirection: () => moonDirection.clone(),
};
}
export function updateCelestialLayer(date = new Date(), camera = null) {
if (!celestialRoot) return;
if (camera) {
linkedCamera = camera;
}
refreshCelestialView();
const now = date.getTime();
if (!lastUpdatedAt || now - lastUpdatedAt >= CELESTIAL_CONFIG.updateIntervalMs) {
computeCelestialState(date);
} else {
updateLighting();
updateSpritePositions();
}
}
export function getSunDirection() {
return getPhysicalSunDirection(new Date(lastUpdatedAt || Date.now()));
}
export function getMoonDirection() {
return applyCelestialOrientation(moonDirection);
}
export function getCelestialDebugState() {
return {
orientationEulerRad: { ...runtimeOrientationEuler },
followEarthRotation: { ...runtimeFollowConfig },
};
}
export function setCelestialOrientation(nextEuler = {}) {
runtimeOrientationEuler = {
...runtimeOrientationEuler,
...nextEuler,
};
refreshCelestialOrientation();
updateLighting();
return getCelestialDebugState();
}
export function setCelestialFollow(nextFollow = {}) {
runtimeFollowConfig = {
...runtimeFollowConfig,
...nextFollow,
};
refreshCelestialView();
updateLighting();
return getCelestialDebugState();
}
export function setCelestialDayNightEnabled(enabled) {
dayNightLightingEnabled = enabled;
updateLighting();
}
export function disposeCelestialLayer() {
if (celestialRoot?.parent) {
celestialRoot.parent.remove(celestialRoot);
}
[skySphere, sunSprite, moonSprite, sunHaloSprite, moonHaloSprite, ...brightStarSprites].forEach((object) => {
if (!object) return;
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (object.material.map) object.material.map.dispose?.();
object.material.dispose();
}
});
skySphere = null;
brightStarsGroup = null;
sunSprite = null;
moonSprite = null;
sunHaloSprite = null;
moonHaloSprite = null;
celestialRoot = null;
brightStarSprites = [];
brightStarTexture?.dispose?.();
brightStarTexture = null;
lastUpdatedAt = 0;
linkedSunLight = null;
linkedBackLight = null;
linkedAmbientLight = null;
linkedPointLight = null;
linkedCamera = null;
linkedEarth = null;
dayNightLightingEnabled = true;
sunDirection.copy(defaultSunDirection);
moonDirection.copy(defaultMoonDirection);
runtimeOrientationEuler = {
...CELESTIAL_CONFIG.orientationEulerRad,
};
runtimeFollowConfig = {
...CELESTIAL_CONFIG.followEarthRotation,
};
}