431 lines
12 KiB
JavaScript
431 lines
12 KiB
JavaScript
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 buildTerrainTileKey(z, x, y) {
|
|
return `${z}/${x}/${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 decodeTerrainBlob(blob, cacheKey) {
|
|
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;
|
|
}
|
|
|
|
function base64ToBlob(base64, contentType = "image/png") {
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return new Blob([bytes], { type: contentType });
|
|
}
|
|
|
|
async function decodeTerrainTile(z, x, y) {
|
|
const cacheKey = buildTerrainTileKey(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}`);
|
|
}
|
|
|
|
return decodeTerrainBlob(await response.blob(), cacheKey);
|
|
})();
|
|
|
|
terrainTileCache.set(cacheKey, tilePromise);
|
|
return tilePromise;
|
|
}
|
|
|
|
async function fetchTerrainTileBatch(keys) {
|
|
const uncachedKeys = keys.filter((key) => !terrainTileCache.has(key));
|
|
if (uncachedKeys.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!TERRAIN_CONFIG.batchUrl || typeof atob !== "function") {
|
|
uncachedKeys.forEach((key) => {
|
|
const [z, x, y] = key.split("/").map(Number);
|
|
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
|
|
});
|
|
return;
|
|
}
|
|
|
|
const batchPromise = (async () => {
|
|
const response = await fetch(TERRAIN_CONFIG.batchUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
tiles: uncachedKeys.map((key) => {
|
|
const [z, x, y] = key.split("/").map(Number);
|
|
return { z, x, y };
|
|
}),
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status} for terrain tile batch`);
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const tiles = Array.isArray(payload?.tiles) ? payload.tiles : [];
|
|
const returnedKeys = new Set();
|
|
|
|
await Promise.all(
|
|
tiles.map(async (tile) => {
|
|
const z = Number(tile?.z);
|
|
const x = Number(tile?.x);
|
|
const y = Number(tile?.y);
|
|
const data = typeof tile?.data === "string" ? tile.data : "";
|
|
if (!Number.isFinite(z) || !Number.isFinite(x) || !Number.isFinite(y) || !data) {
|
|
return;
|
|
}
|
|
const key = buildTerrainTileKey(z, x, y);
|
|
returnedKeys.add(key);
|
|
const blob = base64ToBlob(data, tile.content_type || "image/png");
|
|
terrainTileCache.set(key, decodeTerrainBlob(blob, key));
|
|
}),
|
|
);
|
|
|
|
uncachedKeys.forEach((key) => {
|
|
if (returnedKeys.has(key)) return;
|
|
const [z, x, y] = key.split("/").map(Number);
|
|
terrainTileCache.delete(key);
|
|
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
|
|
});
|
|
})().catch((error) => {
|
|
console.warn("批量地形瓦片加载失败,回退到单瓦片请求:", error);
|
|
uncachedKeys.forEach((key) => {
|
|
terrainTileCache.delete(key);
|
|
const [z, x, y] = key.split("/").map(Number);
|
|
terrainTileCache.set(key, decodeTerrainTile(z, x, y));
|
|
});
|
|
});
|
|
|
|
uncachedKeys.forEach((key) => {
|
|
terrainTileCache.set(
|
|
key,
|
|
batchPromise.then(() => terrainTileCache.get(key)),
|
|
);
|
|
});
|
|
|
|
await batchPromise;
|
|
}
|
|
|
|
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) =>
|
|
buildTerrainTileKey(TERRAIN_CONFIG.baseZoom, sample.tileX, sample.tileY),
|
|
),
|
|
),
|
|
);
|
|
const resolvedTiles = new Map();
|
|
const batchSize = Math.max(1, Number(TERRAIN_CONFIG.batchRequestSize) || 1);
|
|
const batchChunks = [];
|
|
for (let i = 0; i < uniqueKeys.length; i += batchSize) {
|
|
batchChunks.push(uniqueKeys.slice(i, i + batchSize));
|
|
}
|
|
|
|
await runWithConcurrency(
|
|
batchChunks,
|
|
TERRAIN_CONFIG.maxConcurrentRequests,
|
|
async (keys) => {
|
|
await fetchTerrainTileBatch(keys);
|
|
},
|
|
);
|
|
|
|
await Promise.all(
|
|
uniqueKeys.map(async (key) => {
|
|
const tilePromise = terrainTileCache.get(key);
|
|
if (!tilePromise) {
|
|
const [z, x, y] = key.split("/").map(Number);
|
|
resolvedTiles.set(key, await decodeTerrainTile(z, x, y));
|
|
return;
|
|
}
|
|
resolvedTiles.set(key, await tilePromise);
|
|
}),
|
|
);
|
|
|
|
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;
|
|
}
|