import * as THREE from "three"; import * as Astronomy from "astronomy-engine"; import { CELESTIAL_CONFIG, EARTH_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 linkedEarth = null; 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() { const physicalSunDirection = getPhysicalSunDirection( new Date(lastUpdatedAt || Date.now()), ); 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); } } 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, earth = null } = {}, ) { if (!scene || !CELESTIAL_CONFIG.enabled) return null; disposeCelestialLayer(); linkedSunLight = sunLight; linkedBackLight = backLight; 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; 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 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; linkedEarth = null; sunDirection.copy(defaultSunDirection); moonDirection.copy(defaultMoonDirection); runtimeOrientationEuler = { ...CELESTIAL_CONFIG.orientationEulerRad, }; runtimeFollowConfig = { ...CELESTIAL_CONFIG.followEarthRotation, }; }