Three.js漫游如何融入三维GIS?城市级场景实现实战(附:开源代码)
本文围绕Three.js漫游如何融入三维GIS?城市级场景实现实战(附:开源代码)这个问题,讲清楚一个可落地的实现思路:如何把 Three.js 的第一人称漫游、相机控制、模型加载和三维 GIS 的坐标、地形、建筑、POI、空间查询结合起来,做成一个城市级三维场景原型。
引言:为什么三维GIS项目里经常需要 Three.js 漫游
很多三维 GIS 项目最开始会选择 Cesium、Mapbox GL JS、OpenLayers 加 3D 扩展,原因是它们天然支持地图坐标、瓦片、地形和空间数据。但当项目进入城市级可视化、园区漫游、地下管线浏览、数字孪生驾驶舱阶段,单纯的地图交互往往不够。
典型需求包括:
- 像游戏一样在城市街区、园区或建筑内部自由行走。
- 控制相机沿道路、管廊或预设路线自动漫游。
- 在三维建筑之间进行碰撞检测,避免相机穿模。
- 加载 glTF、3D Tiles、OBJ、FBX 等模型,并叠加 GIS 属性。
- 点击建筑、道路、设备后查询空间属性和业务信息。
这时,Three.js漫游就可以作为三维 GIS 的渲染与交互补充。它不负责完整的 GIS 平台能力,但非常适合做高自由度三维场景、模型动画、相机运动和沉浸式浏览。

背景:Three.js 与三维GIS的分工边界
在项目设计时,首先要明确一件事:Three.js 是 WebGL 三维渲染库,不是完整的 GIS 引擎。它擅长渲染和交互,但不直接解决坐标系、地图投影、瓦片调度、地理测量等问题。
三维 GIS 则关注地理空间数据的组织和分析,例如坐标系统、地形、矢量图层、栅格瓦片、空间索引、属性查询和专题表达。
因此,比较合理的架构是:
- GIS部分:负责数据来源、坐标转换、空间查询、图层管理、服务接口。
- Three.js部分:负责城市模型渲染、相机漫游、光照材质、动画、拾取交互。
- 业务部分:负责建筑属性、设备状态、统计面板、告警联动。
对于城市级三维场景,常见数据来源包括:
- 建筑白模:GeoJSON、Shapefile、PostGIS 面数据,加高度字段生成拉伸体。
- 精细模型:glTF、GLB、OBJ、FBX。
- 倾斜摄影或大规模模型:3D Tiles、I3S 或自定义分块模型。
- 地形:DEM、Terrain-RGB、Cesium Terrain、GeoTIFF 预处理结果。
- 道路和 POI:GeoJSON、MVT、PostGIS 查询结果。
原理:Three.js漫游如何对接真实地理坐标
Three.js漫游融入三维GIS最容易出问题的地方不是相机控制,而是坐标。GIS 数据通常使用经纬度坐标或投影坐标,而 Three.js 使用的是局部三维笛卡尔坐标。
如果直接把经纬度当作 Three.js 的 x、z 坐标使用,场景会出现比例不对、模型抖动、距离计算错误、相机移动异常等问题。
1. 建议使用局部坐标系
城市级场景通常不建议直接使用全球坐标作为 Three.js 坐标。更稳妥的做法是选一个场景中心点作为原点,把所有 GIS 坐标转换为相对坐标。
例如:
- 先把经纬度转换为 Web Mercator、UTM 或本地投影坐标。
- 选择城市或项目范围中心作为 origin。
- 所有点坐标减去 origin,得到 Three.js 中的局部 x、z。
- 高度字段或 DEM 高程映射到 Three.js 的 y 轴。
const origin = {
x: 12958000.25,
y: 4853200.75
};
function gisToThree(projectedX, projectedY, height = 0) {
return {
x: projectedX - origin.x,
y: height,
z: -(projectedY - origin.y)
};
}
这里把 GIS 的 Y 方向取反,是因为很多 Web 三维场景中希望屏幕前方对应 Three.js 的负 z 方向。是否取反并不固定,关键是整个项目保持一致。
2. 相机漫游本质上是控制 position 和 direction
Three.js 中相机漫游主要控制两个量:
- position:相机所在位置。
- lookAt 或 rotation:相机朝向。
第一人称漫游可以通过键盘控制相机前后左右移动,通过鼠标控制视角旋转。路径漫游则可以把一组 GIS 路径点转换成 Three.js 坐标,再让相机沿曲线插值运动。
3. GIS属性需要和三维对象建立映射关系
要实现“点击建筑查看属性”,每个 Three.js 对象都应该保留一个业务 ID,例如 building_id、parcel_id 或 feature_id。点击拾取到 Mesh 后,再用这个 ID 去前端缓存、GeoJSON 属性或后端接口查询。
mesh.userData = {
featureId: "building_10086",
name: "A座办公楼",
height: 86.5,
source: "postgis_buildings"
};
这一步非常重要。否则场景看起来是三维 GIS,但无法回到 GIS 数据和业务属性。
步骤:城市级 Three.js 三维GIS漫游实现流程
步骤1:准备项目结构
一个简单但清晰的开源代码结构可以这样组织:
three-gis-walkthrough/
├─ public/
│ ├─ data/
│ │ ├─ buildings.geojson
│ │ ├─ roads.geojson
│ │ └─ poi.geojson
│ └─ models/
│ └─ landmark.glb
├─ src/
│ ├─ main.js
│ ├─ scene/
│ │ ├─ createScene.js
│ │ ├─ createCamera.js
│ │ └─ createLights.js
│ ├─ gis/
│ │ ├─ coordinate.js
│ │ └─ loadGeojson.js
│ ├─ controls/
│ │ ├─ firstPersonWalk.js
│ │ └─ routeFly.js
│ └─ interaction/
│ └─ pickFeature.js
├─ package.json
└─ vite.config.js
推荐使用 Vite 初始化项目,适合教学和中小型 WebGIS 原型。
npm create vite@latest three-gis-walkthrough
cd three-gis-walkthrough
npm install
npm install three proj4
npm run dev
步骤2:创建 Three.js 基础场景
基础场景包括 renderer、scene、camera、light 和动画循环。对于城市级三维 GIS,建议一开始就打开抗锯齿,并设置合理的相机远近裁剪面。
import * as THREE from "three";
const container = document.getElementById("app");
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf2f6fa);
const camera = new THREE.PerspectiveCamera(
60,
window.innerWidth / window.innerHeight,
0.5,
20000
);
camera.position.set(0, 120, 300);
const renderer = new THREE.WebGLRenderer({
antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
const light = new THREE.DirectionalLight(0xffffff, 1.2);
light.position.set(500, 800, 300);
scene.add(light);
const ambient = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambient);
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
步骤3:把 GIS 坐标转换为 Three.js 坐标
如果数据是 WGS84 经纬度,可以使用 proj4 转到投影坐标。对于城市尺度项目,常用 Web Mercator 或当地投影坐标。精度要求较高时,应优先选择本地高斯投影、UTM 或项目规定坐标系。
import proj4 from "proj4";
const WGS84 = "EPSG:4326";
const WEB_MERCATOR = "EPSG:3857";
const originLonLat = [116.3913, 39.9075];
const originProjected = proj4(WGS84, WEB_MERCATOR, originLonLat);
export function lonLatToThree(lon, lat, height = 0) {
const p = proj4(WGS84, WEB_MERCATOR, [lon, lat]);
return {
x: p[0] - originProjected[0],
y: height,
z: -(p[1] - originProjected[1])
};
}
这里的 originLonLat 可以设置为项目范围中心点。这样做可以减少大坐标导致的浮点精度问题,避免城市模型在远离原点时出现抖动。
步骤4:加载建筑 GeoJSON 并生成城市白模
很多 GIS 项目早期并没有精细三维模型,只有建筑轮廓面和高度字段。此时可以把 GeoJSON 面数据拉伸成建筑白模,这是三维 GIS 原型最常见的做法。
import * as THREE from "three";
import { lonLatToThree } from "./coordinate.js";
export async function loadBuildings(scene, url) {
const res = await fetch(url);
const geojson = await res.json();
geojson.features.forEach((feature) => {
const coords = feature.geometry.coordinates[0];
const height = Number(feature.properties.height || 30);
const shape = new THREE.Shape();
coords.forEach((coord, index) => {
const p = lonLatToThree(coord[0], coord[1], 0);
if (index === 0) {
shape.moveTo(p.x, p.z);
} else {
shape.lineTo(p.x, p.z);
}
});
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: height,
bevelEnabled: false
});
geometry.rotateX(Math.PI / 2);
const material = new THREE.MeshStandardMaterial({
color: 0x8fb3d9,
roughness: 0.75,
metalness: 0.05
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData = {
featureId: feature.properties.id,
name: feature.properties.name,
height
};
scene.add(mesh);
});
}
注意:GeoJSON 面环方向、洞、多面对象和坐标轴方向都可能影响拉伸结果。生产项目中需要对 Polygon、MultiPolygon、内环洞和非法面进行更严谨处理。
步骤5:实现第一人称 Three.js 漫游
第一人称漫游适合园区、街区、建筑群等场景。实现思路是:按键控制移动方向,鼠标控制相机朝向。
const keys = {
w: false,
a: false,
s: false,
d: false
};
let yaw = 0;
let pitch = 0;
const speed = 2.5;
window.addEventListener("keydown", (e) => {
if (keys[e.key.toLowerCase()] !== undefined) {
keys[e.key.toLowerCase()] = true;
}
});
window.addEventListener("keyup", (e) => {
if (keys[e.key.toLowerCase()] !== undefined) {
keys[e.key.toLowerCase()] = false;
}
});
window.addEventListener("mousemove", (e) => {
if (document.pointerLockElement) {
yaw -= e.movementX * 0.002;
pitch -= e.movementY * 0.002;
pitch = Math.max(-Math.PI / 3, Math.min(Math.PI / 3, pitch));
}
});
renderer.domElement.addEventListener("click", () => {
renderer.domElement.requestPointerLock();
});
function updateWalk(camera) {
camera.rotation.order = "YXZ";
camera.rotation.y = yaw;
camera.rotation.x = pitch;
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
direction.y = 0;
direction.normalize();
const right = new THREE.Vector3();
right.crossVectors(direction, camera.up).normalize();
if (keys.w) camera.position.addScaledVector(direction, speed);
if (keys.s) camera.position.addScaledVector(direction, -speed);
if (keys.a) camera.position.addScaledVector(right, speed);
if (keys.d) camera.position.addScaledVector(right, -speed);
}
然后在动画循环中调用:
function animate() {
requestAnimationFrame(animate);
updateWalk(camera);
renderer.render(scene, camera);
}
这个版本没有碰撞检测,适合入门和原型验证。正式项目中应加入地面高度约束、建筑碰撞、边界限制和速度随尺度变化的控制。
步骤6:实现路径自动漫游
除了自由漫游,三维 GIS 中还常见“沿道路飞行”“沿管线巡检”“沿河道巡视”。实现方法是把 GIS 路径点转换为 Three.js 坐标,再使用 CatmullRomCurve3 做平滑曲线。
const routePoints = [
[116.3901, 39.9068, 40],
[116.3910, 39.9072, 45],
[116.3922, 39.9079, 50],
[116.3930, 39.9085, 45]
];
const curvePoints = routePoints.map((p) => {
const t = lonLatToThree(p[0], p[1], p[2]);
return new THREE.Vector3(t.x, t.y, t.z);
});
const curve = new THREE.CatmullRomCurve3(curvePoints);
let progress = 0;
function updateRouteFly(camera) {
progress += 0.0008;
if (progress > 1) progress = 0;
const position = curve.getPointAt(progress);
const next = curve.getPointAt(Math.min(progress + 0.005, 1));
camera.position.copy(position);
camera.lookAt(next);
}
如果路线来自道路中心线、巡检路线或 PostGIS 查询结果,只要保证坐标转换一致,就可以把 GIS 路径无缝变成 Three.js 漫游路径。
步骤7:添加点击拾取与 GIS 属性查询
三维 GIS 不只是看模型,还要能查属性。Three.js 使用 Raycaster 做鼠标拾取。
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
window.addEventListener("click", (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const object = intersects[0].object;
const data = object.userData;
if (data && data.featureId) {
console.log("选中要素:", data.featureId, data.name);
}
}
});
正式项目可以把 featureId 传给后端接口,例如:
GET /api/buildings/building_10086
后端可以从 PostGIS、GeoServer、业务数据库或缓存服务中返回完整属性,再在前端弹窗展示。
常见坑:Three.js漫游接入三维GIS时最容易踩的坑
1. 直接使用经纬度导致场景比例异常
经纬度单位是度,不是米。Three.js 中移动速度、建筑高度、距离判断通常按线性单位理解。如果把 116.39、39.90 直接作为坐标,场景比例一定不可靠。
正确做法是先投影,再转局部坐标。
2. 大坐标导致模型抖动
WebGL 使用浮点数计算。城市级投影坐标可能达到几百万或上千万,如果直接放进 Three.js,远离原点时容易出现抖动、闪烁、拾取不准。
解决办法是使用局部原点,即所有坐标减去项目中心点。
3. 高度基准不一致
建筑高度、DEM 高程、模型 y 坐标可能来自不同基准。例如建筑高度是层高估算,DEM 是海拔,模型自身又以底面为零点。混用时会出现建筑悬空或埋入地下。
建议在数据预处理阶段统一:
- 建筑底部高程。
- 建筑自身高度。
- 地形高程基准。
- 模型原点位置。
4. 城市级模型一次性加载过多
Three.js 可以渲染大量对象,但并不意味着可以一次性加载全城市精细模型。模型太大时会出现首屏慢、内存高、帧率低和浏览器崩溃。
优化方向包括:
- 按区域分块加载。
- 使用 LOD,即不同距离显示不同精度模型。
- 合并静态建筑 Mesh,减少 draw call。
- 纹理压缩,避免超大 PNG 或 JPG。
- 优先使用 GLB、Draco 压缩或 Meshopt 优化。
5. 相机穿墙和穿地
基础第一人称漫游只移动相机,不会自动知道哪里是建筑、地面或边界。要避免穿模,需要加入碰撞检测。
简单做法是为建筑和地面建立碰撞体,移动前先用 Raycaster 或包围盒检测。复杂项目可以接入 cannon-es、rapier 等物理引擎。
方法比较:Three.js、Cesium 与混合架构怎么选
| 方案 | 适合场景 | 优点 | 限制 |
|---|---|---|---|
| 纯 Three.js | 园区、建筑群、室内外一体化、定制三维漫游 | 渲染自由度高,动画和交互能力强,适合精细模型 | 需要自己处理坐标、瓦片、地形、空间查询 |
| 纯 Cesium | 大范围地球、3D Tiles、倾斜摄影、地形可视化 | GIS 能力强,天然支持地球坐标、地形和三维瓦片 | 深度定制模型动画和游戏式漫游成本较高 |
| Three.js + GIS服务 | 城市级数字孪生、园区平台、专题三维应用 | 兼顾 GIS 数据和沉浸式交互,架构灵活 | 需要明确坐标转换、数据切片和性能优化方案 |
| Cesium + Three.js 混合 | 既有全球 GIS 底座,又要局部精细 Three.js 效果 | 可以利用 Cesium 管理地理空间,用 Three.js 做局部增强 | 相机同步、坐标同步和渲染顺序更复杂 |
如果你的需求是大范围地球浏览、倾斜摄影和地形展示,Cesium 更直接。如果你的重点是园区级、城市街区级的沉浸式漫游和复杂三维交互,Three.js 会更灵活。如果项目同时需要二者,就应采用混合架构,但要预留更多工程成本。
检查清单:上线前如何检查 Three.js 三维GIS漫游项目
- 是否明确了项目使用的坐标系,例如 EPSG:4326、EPSG:3857、CGCS2000 或本地投影?
- 是否把 GIS 坐标转换为局部 Three.js 坐标?
- 是否统一了 x、y、z 轴方向,尤其是高度轴和北方向?
- 是否处理了建筑高度、地形高程和模型原点?
- 是否为每个可点击对象绑定 featureId 或业务 ID?
- 是否避免一次性加载全量城市模型?
- 是否对模型做了压缩、分块或 LOD?
- 第一人称漫游是否限制了高度、边界和碰撞?
- 路径漫游是否使用真实 GIS 路径点,并检查了方向和高度?
- 移动端或低配置电脑是否有降级方案?
- 是否提供了图层开关、重置视角、搜索定位等 GIS 常用交互?
- 是否对浏览器兼容性和 WebGL 支持情况做了提示?
FAQ:Three.js漫游融入三维GIS常见问题
Q1:Three.js 能不能直接做完整三维 GIS 平台?
可以做三维 GIS 前端应用,但不建议把 Three.js 当作完整 GIS 引擎。它缺少内置投影、地形、瓦片调度、空间分析等能力。比较好的做法是用 Three.js 做三维渲染和漫游,用 PostGIS、GeoServer、GDAL、后端服务或其他 GIS 引擎处理空间数据。
Q2:Three.js漫游和 Cesium 漫游有什么区别?
Cesium 漫游更适合全球或城市级地理空间场景,特别是 3D Tiles、地形和影像底图。Three.js 漫游更适合高度定制的局部场景,例如园区、室内、地下空间、精细建筑和游戏式交互。二者不是谁替代谁,而是适用重点不同。
Q3:城市级场景中为什么模型会抖动?
常见原因是直接使用了过大的投影坐标或地球坐标。Three.js 场景远离原点时,浮点精度会下降。解决办法是设置局部原点,把所有 GIS 坐标减去中心点后再进入 Three.js。
Q4:建筑 GeoJSON 拉伸后方向不对怎么办?
先检查坐标转换函数、z 轴是否取反、Shape 点顺序是否正确,以及 ExtrudeGeometry 拉伸方向是否需要旋转。不同数据源的面环方向可能不同,生产项目应对 Polygon、MultiPolygon 和内环洞做统一处理。
Q5:如何把 PostGIS 数据接入 Three.js 场景?
常见做法是后端从 PostGIS 查询建筑、道路、POI 等数据,输出 GeoJSON 或自定义 JSON。前端加载后进行坐标转换,再生成 Three.js 对象。大数据量场景不建议一次性返回全部数据,应按视野、网格或行政区分块请求。
Q6:开源代码应该包含哪些最小功能?
一个适合学习的开源代码至少应包含:基础场景、坐标转换、GeoJSON 建筑拉伸、第一人称漫游、路径漫游、对象拾取、属性弹窗和简单图层管理。这样读者才能完整理解 Three.js 漫游如何融入三维 GIS,而不是只看到一个孤立的三维模型。
结论:Three.js漫游融入三维GIS的关键是坐标、数据和交互闭环
Three.js漫游融入三维GIS不是简单地把模型放进网页,也不是只写一个相机控制器。真正可用的城市级场景,需要完成三个闭环:坐标闭环、数据闭环和交互闭环。
坐标闭环解决“模型放在哪里、比例是否正确”;数据闭环解决“建筑、道路、POI、地形从哪里来”;交互闭环解决“用户点击、漫游、查询、定位后能得到什么结果”。
如果你正在做城市级三维 GIS 原型,建议先从建筑白模、局部坐标、第一人称漫游和属性拾取这四个最小模块开始。等流程跑通后,再逐步加入 3D Tiles、地形、LOD、碰撞检测和业务专题图层。这样项目风险更低,也更容易从 Demo 走向可维护的三维 GIS 应用。