与地理数据结合
three.js 不认识经纬度。地理数据要进入三维场景,必须先回答三个问题: 用球面还是平面、一米等于几个场景单位、原点放在哪里。这三个决定会影响精度、性能与后续所有换算。
三种常见做法
| 做法 | 坐标处理 | 适合 | 不适合 |
|---|---|---|---|
| 球面地球 | 经纬度换算到半径为 R 的球面 | 全球尺度、航线、点位分布、示意性地球 | 街道级精度、贴合地形的细节 |
| 局部平面 | 投影坐标减去原点,按米缩放 | 一个城市、一个园区、单栋建筑与周边 | 跨省跨国的大范围连续场景 |
| 瓦片流式加载 | 由引擎按需调度切片 | 城市级倾斜摄影、BIM 与地形 | 没有现成调度器时,手写成本很高 |
球面:经纬度到三维坐标
把地球视为半径 R 的球,纬度从赤道起算,经度从本初子午线起算。下面的换算把
lon = 0 放在 +X 方向,北极为 +Y,与 three 的 Y 向上一致。
function lonLatToVector3(lon, lat, altitude, radius) {
const phi = (90 - lat) * Math.PI / 180; // 极角
const theta = (lon + 180) * Math.PI / 180; // 方位角
const r = radius + altitude; // altitude 与 radius 同单位
return new THREE.Vector3(
-r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(theta)
);
}
// 半径取 1 时,地球半径 6371 km 被压缩成 1 个场景单位
const beijing = lonLatToVector3(116.39, 39.91, 0.02, 1.0);
altitude 必须与 radius 用同一单位。半径取 1 时,
海拔 1000 米大约是 0.000157,这个量级下「把点抬高一点」很容易被浮点误差吃掉,
所以点位通常用固定抬升(如半径的 1.005 倍)而不是真实海拔。
局部平面:只关心一个片区
城市级场景用球面反而麻烦:所有模型都要跟着曲率旋转。更实用的做法是选定一个原点, 把经纬度按近似公式换算成米,再统一缩放。
const ORIGIN = { lon: 116.391, lat: 39.907 };
// 经纬度差转米:纬度 1 度约 110540 米,经度需乘 cos(纬度)
function toLocalMeters(lon, lat, height) {
const dx = (lon - ORIGIN.lon) * Math.cos(ORIGIN.lat * Math.PI / 180) * 111320;
const dz = (lat - ORIGIN.lat) * 110540;
return {
east: dx,
north: dz,
up: height
};
}
// 场景单位:1 单位 = 100 米
const SCALE = 100;
const p = toLocalMeters(116.404, 39.915, 12);
const v = new THREE.Vector3(p.east / SCALE, p.up / SCALE, -p.north / SCALE);
这里把「北」映射到 −Z,是为了保持 three 的 Y 向上、同时让俯视时北方朝屏幕上方。 如果团队更习惯 Z 向上,也可以整体旋转 −90 度,把平面放到 XY,但要注意之后所有相机与光照方向都要跟着调整。
上述近似在几十千米内误差可控,跨省后会明显偏离。大范围数据应当使用正式的投影 (如 Web 墨卡托或高斯-克吕格),把投影后的平面坐标减去原点再缩放,而不是继续用经纬度差乘常数。
GeoJSON 怎么进场景
GeoJSON 的坐标顺序是 [经度, 纬度],与 Leaflet 的
[纬度, 经度]相反,也和 three 的 (x, y, z) 完全不同。
转换时先把每个坐标映射到局部平面,再决定放到哪两个轴上。
function lineFromGeoJson(geojson) {
const positions = [];
geojson.features.forEach(function (feature) {
if (feature.geometry.type !== 'LineString') return;
const coords = feature.geometry.coordinates;
for (let i = 0; i < coords.length - 1; i++) {
const a = toLocalMeters(coords[i][0], coords[i][1], 2);
const b = toLocalMeters(coords[i + 1][0], coords[i + 1][1], 2);
positions.push(
a.east / SCALE, a.up / SCALE, -a.north / SCALE,
b.east / SCALE, b.up / SCALE, -b.north / SCALE
);
}
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
return new THREE.LineSegments(
geometry,
new THREE.LineBasicMaterial({ color: 0x4fc3ff })
);
}
面要素需要三角化。规则多边形可以用 THREE.ShapeUtils.triangulateShape,
带洞的面要分别传入外环与内环;直接按顶点顺序画三角形,凹多边形会出现错误的填充。
地形与高程
DEM 是规则栅格,最直接的用法是把每个像元的高程写进规则网格的顶点, 和「地形网格」示例的做法一致。需要留意三件事:
- 栅格分辨率决定三角形数量,直接铺满全国 30 米 DEM 会生成无法渲染的顶点量。
- 垂直夸张是常见手段,但水平与垂直必须能用同一个比例解释,否则坡度分析会失真。
- DEM 高程通常是正常高(如 1985 国家高程基准),GPS 给的是椭球高,两者相差几十米,混用前要统一。
浮点精度:三维地理场景的硬约束
Float32 大约有 7 位有效数字。地球半径 6378137 米,用 Float32 表示时
最小可分辨间隔约 0.5 米;投影坐标动辄上百万米,精度会更差。表现是模型边缘抖动、
相近表面闪烁、点位轻微跳动。
| 策略 | 做法 | 效果 |
|---|---|---|
| 局部原点 | 所有坐标先减去场地中心,场景内坐标控制在千米级 | 最常用,改动最小 |
| 分块 | 按区域切块,每块各自有局部原点 | 适合城市群,需处理块间接缝 |
| 高精度位置分离 | 顶点存相对位置,模型矩阵用双精度在 CPU 侧计算 | 实现复杂,接近 Cesium 的做法 |
什么时候该换工具
需要全球尺度、地形切片、影像瓦片、3D Tiles 流式调度时,Cesium 已经把这些做完; 需要大规模矢量数据的 GPU 聚合与图层体系时,deck.gl 更合适。 three.js 的价值在于完全可控的渲染管线:自定义着色器、特殊材质、非地理的示意场景, 以及和其他三维内容的混合。选型时先看需求里有多少「地图基础设施」, 再决定是否值得自己搭。
统一坐标入口。项目中只保留一个函数负责「业务坐标 → 场景坐标」, 所有图层、模型、标注都走它。一旦出现两套换算,偏移与旋转问题会反复出现, 而且很难定位是哪一层算错了。
经纬志(2026)。《与地理数据结合》。https://www.jingweizhi.com/three/tutorial/geo-data.html(访问日期 2026-11-03)。