Three.js漫游如何融入三维GIS?城市级场景实现实战(附:开源代码)

编程与开发
Dr.GIS
wowwwai 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 漫游融入三维 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 应用。