经纬志
首页 / three.js / 教程

与地理数据结合

先看结论

three.js 不认识经纬度。地理数据要进入三维场景,必须先回答三个问题: 用球面还是平面、一米等于几个场景单位、原点放在哪里。这三个决定会影响精度、性能与后续所有换算。

最后验证 2026-11-03 · three.js 0.160.0 · 更新 2026-10-27 · 引用本文

三种常见做法

做法坐标处理适合不适合
球面地球 经纬度换算到半径为 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)。