用 Three.js 手搓一个 GIS 引擎(一):总览、架构与坐标系统
系列导航:本文是《用 Three.js 手搓一个 GIS 引擎》系列的第一篇。后续会依次讲 栅格瓦片与请求调度 → 地形引擎 → 3D Tiles 与白模 → 矢量/图元/绘制 → 自研 LOD 数据管线。
这个库叫
three-gis(源自next-three项目的lib/封装),框架无关,运行时除了three没有别的依赖。
为什么要"手搓"一个 GIS 库?
Three.js 擅长画,但不懂"地理"。把经纬度、瓦片、地形、3D Tiles 这些 GIS 概念硬塞进一个裸 Three.js 场景,新手最容易卡在三件事上:
- 坐标:经纬度是度数,瓦片是像素金字塔,相机是世界坐标------三者怎么对齐?
- 精度:直接拿 WGS84 的米级 ECEF 坐标当 Three.js 顶点,几公里外就开始抖(浮点误差)。
- 调度:瓦片、地形、3D Tiles 都要按视口动态加载/卸载,否则内存和请求数立刻爆。
three-gis 就是围绕这三个痛点搭的一套轻量引擎。它不是 Cesium 的替代品,更像"当你只需要 Three.js 那种渲染自由度、又想要 GIS 图层能力时"的中间层。
一、能力地图(本系列会逐一拆开讲)
| 能力 | 核心模块 | 一句话说明 |
|---|---|---|
| 坐标系 | WebMercatorGIS |
WGS84 ↔ Web Mercator ↔ 局部 Z-up,用局部原点抗浮点误差 |
| 运行时 | Scene / Map / CameraController |
Three.js 场景 + 相机飞行 + Z-up GIS 控制器 |
| 栅格瓦片 | TileLayer / TMS / WMS / WMTS |
XYZ/Google、TMS、WMS、WMTS 全覆盖,带请求调度 |
| 地形 | CesiumTerrainLayer |
Cesium quantized-mesh,Worker 解压 + 影像贴合 |
| 3D Tiles | Tiles3DLayer |
b3dm / glTF、屏幕空间误差调度、白模样式 |
| 矢量/图元/绘制 | GeoJSONLayer / Primitive* / DrawingManager |
水面/道路/铁路、点线面体图元、交互绘制 |
| LOD 数据管线 | lib/sources/lod |
采集 → 点云(pnts) → 倾斜摄影网格(b3dm) 自研闭环 |
二、三行代码跑起来
库提供了 Cesium 风格的一站式入口 createViewer,把"建场景 / 装图层 / 起帧循环"都包了:
ts
import { createViewer } from "three-gis";
const viewer = await createViewer("app", {
center: [116.4, 39.9], // 北京
height: 1_500_000, // 视高(米)
imagery: "https://mt{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}",
});
viewer.flyTo({ center: [118.1371, 24.49], height: 120_000 }); // 飞到厦门
createViewer 内部依次做了:按 center 派生局部原点 → 建 Scene → 等渲染器就绪 → 装配影像/地形/3D Tiles/GeoJSON 图层 → 设置视口 → 注册帧循环(按节流间隔推动图层选片与更新)。
如果你想要更细的控制,可以绕过 createViewer 直接用底层原语:
ts
import { Scene } from "three-gis";
import { WebMercatorGIS } from "three-gis";
const gis = new WebMercatorGIS(118.1371, 24.49);
const scene = new Scene(container, { gis });
await scene.ready();
scene.flyTo(118.1371, 24.49, 10000);
// React/Vue 的 cleanup 里别忘了销毁
scene.destroy();
⚠️ 三维运行时代码依赖浏览器 API,必须在 Client Component 的
useEffect/onMounted里初始化并清理,不要在 SSR 阶段调用。
三、坐标系统:整套引擎的地基
这是 GIS 库最容易劝退人的地方,也是 three-gis 设计上最值得讲的一点。
1. 三套坐标,一个转换中枢
WebMercatorGIS 把三套坐标统一管起来:
- WGS84 经纬度:人类语言,度。
- Web Mercator 米:球面墨卡托投影,单位米,全球统一。
- 局部 Three.js 坐标 :以某个原点为中心的小范围坐标,Z-up(X=东,Y=北,Z=上)。
ts
const gis = new WebMercatorGIS(118.1371, 24.49); // 以厦门为局部原点
// 经度/纬度/海拔(米) → 局部坐标(X 东, Y 北, Z 上)
const p = gis.lngLatToThree(118.14, 24.50, 120);
// 局部坐标 → 经纬度海拔
const ll = gis.threeToLngLat(p);
墨卡托转换就是经典公式(库里也是这么实现的):
ts
// 经纬度 → Web Mercator 米
const mx = R * deg2rad(lng);
const my = R * Math.log(Math.tan(Math.PI / 4 + deg2rad(lat) / 2));
// Web Mercator 米 → 局部坐标(减去原点)
return new THREE.Vector3(mx - originMx, my - originMy, altitude);
2. 为什么一定要"局部原点"?
这是新手最大的坑。不要把全球米坐标直接喂给 Three.js。
WebGL 顶点是 Float32,有效精度大约 7 位十进制。ECEF 或全球墨卡托坐标动辄 6,000,000 米量级,尾数只能表示到分米甚至米级,相机稍微拉远模型就"抽搐"。
three-gis 的做法是:把局部原点设在关注区域附近(比如城市中心),所有顶点只存"离原点的偏移",数值通常在几百到几万米内,精度立刻回到毫米级。
ts
// 好:原点就在目标城市,顶点数值小
const gis = new WebMercatorGIS(118.1371, 24.49);
// 坏:原点放 (0,0) 赤道原点,厦门的顶点都会带着 6,000,000 的偏移
const gisBad = new WebMercatorGIS(0, 0);
createViewer 里 origin 默认等于 center,就是这个道理。只有当你要在"大范围漫游且需要更小浮点误差"时才需要单独指定 origin。
3. 相机与控制器:Z-up 的 GIS 视角
Three.js 默认 Y-up,但地理场景天然是 Z-up(海拔向上)。three-gis 的 GISOrbitController 直接按 Z-up 建模,并提供地理语义的视口接口:
ts
// setView / flyTo 用的是地理参数,不用你手写相机矩阵
viewer.setView({ center: [116.4, 39.9], height: 5000, pitch: 45, heading: 30 });
// pitch=90 正俯视,pitch=45 斜视;heading=0 正北朝上
局部坐标系约定:X 向东、Y 向北、Z 向上 。比如相机在目标正南、俯视看正北,就是 offset = (-sin(bearing)·h, -cos(bearing)·h, height)------这层换算 createViewer 已经帮你封装好了。
四、本篇亮点小结
- 框架无关 :除了
three零运行时依赖,原生 HTML / Vue / React 都能用,可独立打包成 npm 包。 - 坐标中枢单一 :所有"地理 → 屏幕"的转换都收口在
WebMercatorGIS,避免各模块各算各的。 - 局部原点抗精度:这是让 Three.js 扛得住真实地理尺度的关键一招,后面所有图层都建立在这套局部 Z-up 坐标上。
下一篇预告
有了坐标地基,下一步自然是往场景里"铺地砖"------栅格瓦片 。下一篇《栅格瓦片与请求调度》会讲清楚 XYZ/Google、TMS、WMS、WMTS 四种协议怎么统一封装,以及库自带的 RequestScheduler(按域名限并发、优先级、取消、子域轮换)和"入场节拍"淡入这种体验细节。
如果你对某一篇的主题想提前看,或者觉得这个拆分粒度不合适,欢迎留言告诉我。