import * as THREE from "three"; import { CONFIG, TERRAIN_CONFIG } from "./constants.js"; import { vector3ToLatLon } from "./utils.js"; const EARTH_RADIUS_METERS = 6371000; const TERRAIN_COLOR_STOPS = [ { height: 0, color: new THREE.Color(0x5f7f5b) }, { height: 800, color: new THREE.Color(0x7f9564) }, { height: 1800, color: new THREE.Color(0x9e956c) }, { height: 3200, color: new THREE.Color(0x9f866a) }, { height: 5200, color: new THREE.Color(0xc6c0b1) }, { height: 7800, color: new THREE.Color(0xe8e5de) }, ]; let terrainMesh = null; let terrainLoadPromise = null; let terrainReady = false; let terrainFailed = false; let terrainTileCache = new Map(); let resolvedTileCache = new Map(); let terrainVertexSamples = null; let terrainOpacity = TERRAIN_CONFIG.opacity; function clampLatitude(lat) { return THREE.MathUtils.clamp(lat, -85.05112878, 85.05112878); } function buildTileUrl(z, x, y) { return TERRAIN_CONFIG.urlTemplate .replace("{z}", String(z)) .replace("{x}", String(x)) .replace("{y}", String(y)); } function getTerrainCanvas(size) { if (typeof OffscreenCanvas !== "undefined") { return new OffscreenCanvas(size, size); } const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; return canvas; } async function decodeTerrainTile(z, x, y) { const cacheKey = `${z}/${x}/${y}`; if (terrainTileCache.has(cacheKey)) { return terrainTileCache.get(cacheKey); } const tilePromise = (async () => { const response = await fetch(buildTileUrl(z, x, y), { mode: "cors" }); if (!response.ok) { throw new Error(`HTTP ${response.status} for terrain tile ${cacheKey}`); } const blob = await response.blob(); const bitmap = await createImageBitmap(blob); const canvas = getTerrainCanvas(TERRAIN_CONFIG.tileSize); const ctx = canvas.getContext("2d", { willReadFrequently: true }); ctx.drawImage(bitmap, 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize); bitmap.close?.(); const { data, width, height } = ctx.getImageData( 0, 0, TERRAIN_CONFIG.tileSize, TERRAIN_CONFIG.tileSize, ); const tileData = { data, width, height }; resolvedTileCache.set(cacheKey, tileData); return tileData; })(); terrainTileCache.set(cacheKey, tilePromise); return tilePromise; } function decodeTerrariumHeight(tile, pixelX, pixelY) { const safeX = THREE.MathUtils.clamp(pixelX, 0, tile.width - 1); const safeY = THREE.MathUtils.clamp(pixelY, 0, tile.height - 1); const index = (safeY * tile.width + safeX) * 4; const r = tile.data[index]; const g = tile.data[index + 1]; const b = tile.data[index + 2]; return (r * 256 + g + b / 256) - 32768; } function latLonToTileSample(lat, lon, z, tileSize) { const n = 2 ** z; const clampedLat = clampLatitude(lat); const latRad = THREE.MathUtils.degToRad(clampedLat); const normalizedX = ((lon + 180) / 360) * n; const normalizedY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n; const tileX = THREE.MathUtils.euclideanModulo( Math.floor(normalizedX), n, ); const tileY = THREE.MathUtils.clamp(Math.floor(normalizedY), 0, n - 1); const pixelX = Math.floor((normalizedX - Math.floor(normalizedX)) * tileSize); const pixelY = Math.floor((normalizedY - Math.floor(normalizedY)) * tileSize); return { tileX, tileY, pixelX, pixelY, }; } function buildTerrainVertexSamples(positionAttribute) { const z = TERRAIN_CONFIG.baseZoom; const tileSize = TERRAIN_CONFIG.tileSize; const samples = []; for (let i = 0; i < positionAttribute.count; i++) { const direction = new THREE.Vector3( positionAttribute.getX(i), positionAttribute.getY(i), positionAttribute.getZ(i), ).normalize(); const { lat, lon } = vector3ToLatLon(direction); const sample = latLonToTileSample(lat, lon, z, tileSize); samples.push({ index: i, direction, ...sample, }); } return samples; } function sampleTerrainColor(heightMeters) { if (heightMeters <= TERRAIN_COLOR_STOPS[0].height) { return TERRAIN_COLOR_STOPS[0].color; } for (let i = 1; i < TERRAIN_COLOR_STOPS.length; i++) { const lower = TERRAIN_COLOR_STOPS[i - 1]; const upper = TERRAIN_COLOR_STOPS[i]; if (heightMeters <= upper.height) { const t = (heightMeters - lower.height) / Math.max(upper.height - lower.height, 1); return lower.color.clone().lerp(upper.color, t); } } return TERRAIN_COLOR_STOPS[TERRAIN_COLOR_STOPS.length - 1].color; } async function runWithConcurrency(items, limit, worker) { const queue = [...items]; const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => { while (queue.length > 0) { const item = queue.shift(); await worker(item); } }); await Promise.all(workers); } async function fetchRequiredTiles(samples) { const uniqueKeys = Array.from( new Set(samples.map((sample) => `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`)), ); const resolvedTiles = new Map(); await runWithConcurrency( uniqueKeys, TERRAIN_CONFIG.maxConcurrentRequests, async (key) => { const [z, x, y] = key.split("/").map(Number); resolvedTiles.set(key, await decodeTerrainTile(z, x, y)); }, ); return resolvedTiles; } function applyTerrainDisplacement(samples, mesh, resolvedTiles) { const geometry = mesh.geometry; const positionAttribute = geometry.getAttribute("position"); let colorAttribute = geometry.getAttribute("color"); if (!colorAttribute || colorAttribute.itemSize !== 4) { colorAttribute = new THREE.BufferAttribute( new Float32Array(positionAttribute.count * 4), 4, ); geometry.setAttribute("color", colorAttribute); } const baseRadius = CONFIG.earthRadius + TERRAIN_CONFIG.baseRadiusOffset; samples.forEach((sample) => { const tileKey = `${TERRAIN_CONFIG.baseZoom}/${sample.tileX}/${sample.tileY}`; const tile = resolvedTiles.get(tileKey); if (!tile) return; const rawElevationMeters = decodeTerrariumHeight( tile, sample.pixelX, sample.pixelY, ); const elevationMeters = Math.max(0, rawElevationMeters); const heightWorld = (elevationMeters / EARTH_RADIUS_METERS) * CONFIG.earthRadius * TERRAIN_CONFIG.exaggeration; const radius = baseRadius + heightWorld; const tint = sampleTerrainColor(elevationMeters); const landAlpha = THREE.MathUtils.clamp( elevationMeters / Math.max(TERRAIN_CONFIG.landRevealFadeMeters, 1), 0, 1, ); positionAttribute.setXYZ( sample.index, sample.direction.x * radius, sample.direction.y * radius, sample.direction.z * radius, ); colorAttribute.setXYZW(sample.index, tint.r, tint.g, tint.b, landAlpha); }); positionAttribute.needsUpdate = true; colorAttribute.needsUpdate = true; geometry.computeVertexNormals(); geometry.computeBoundingSphere(); } export function registerTerrainMesh(mesh) { terrainMesh = mesh; terrainReady = false; terrainFailed = false; terrainLoadPromise = null; terrainTileCache = new Map(); resolvedTileCache = new Map(); terrainOpacity = TERRAIN_CONFIG.opacity; if (terrainMesh?.material) { terrainMesh.material.opacity = terrainOpacity; terrainMesh.material.needsUpdate = true; } terrainVertexSamples = mesh ? buildTerrainVertexSamples(mesh.geometry.getAttribute("position")) : null; } export function isTerrainReady() { return terrainReady; } export async function ensureTerrainReady() { if (!terrainMesh || !TERRAIN_CONFIG.enabled) { return false; } if (terrainReady) { return true; } if (terrainLoadPromise) { return terrainLoadPromise; } terrainLoadPromise = (async () => { try { const resolvedTiles = await fetchRequiredTiles(terrainVertexSamples); applyTerrainDisplacement(terrainVertexSamples, terrainMesh, resolvedTiles); terrainReady = true; terrainFailed = false; return true; } catch (error) { terrainFailed = true; console.error("加载真实地形失败:", error); throw error; } finally { terrainLoadPromise = null; } })(); return terrainLoadPromise; } export function clearTerrainData() { terrainMesh = null; terrainLoadPromise = null; terrainReady = false; terrainFailed = false; terrainVertexSamples = null; terrainTileCache = new Map(); resolvedTileCache = new Map(); terrainOpacity = TERRAIN_CONFIG.opacity; } export function sampleElevationAt(lat, lon) { if (!terrainReady) return null; const z = TERRAIN_CONFIG.baseZoom; const { tileX, tileY, pixelX, pixelY } = latLonToTileSample(lat, lon, z, TERRAIN_CONFIG.tileSize); const tile = resolvedTileCache.get(`${z}/${tileX}/${tileY}`); if (!tile) return null; return Math.max(0, decodeTerrariumHeight(tile, pixelX, pixelY)); } export function setTerrainOpacity(nextOpacity) { terrainOpacity = THREE.MathUtils.clamp(nextOpacity, 0.05, 1); if (terrainMesh?.material) { terrainMesh.material.opacity = terrainOpacity; terrainMesh.material.needsUpdate = true; } return terrainOpacity; } export function getTerrainOpacity() { return terrainOpacity; }