Three.js前端三维图形开发案例集锦,GIS场景如何应用?(附:源码)

编程与开发
Dr.GIS
wowwwai GIS研习社 · 工具流程与项目排障

Three.js前端三维图形开发案例集锦,GIS场景如何应用?(附:源码)这篇文章面向正在做 WebGIS、三维可视化或数字孪生入门的读者,重点不是泛泛介绍 Three.js,而是把它放到 GIS 场景里:如何加载地形、建筑、点位、轨迹和专题数据,如何与地图坐标系统衔接,以及一个可运行的基础源码骨架应该怎么写。

Three.js前端三维图形开发案例集锦 GIS场景三维可视化应用流程
Three.js 在 GIS 场景中的典型应用流程:空间数据准备、坐标转换、三维场景构建与交互展示。

引言:为什么 GIS 前端会用到 Three.js

很多 WebGIS 项目最初只需要二维地图:底图、点线面、弹窗、查询统计。但当需求变成三维建筑、地形起伏、飞线、轨迹回放、地下管线、园区数字孪生时,传统二维地图组件就不够用了。

Three.js 是一个基于 WebGL 的前端三维图形库,适合在浏览器中构建三维场景。它本身不是 GIS 平台,不直接理解投影坐标、瓦片规则、空间索引和地理要素语义,但它非常适合承担 GIS 三维可视化中的“渲染层”工作。

在 GIS 项目中,Three.js 常见应用包括:

  • 将 GeoJSON 面数据拉伸成三维建筑或行政区块。
  • 根据 DEM 高程数据生成三维地形。
  • 展示三维点位、设备、传感器和告警状态。
  • 绘制飞线、迁徙线、车辆轨迹和管线走向。
  • 加载 glTF、OBJ、FBX 等三维模型,用于园区、厂区和城市部件展示。
  • 与 Leaflet、OpenLayers、Mapbox GL JS 等 WebGIS 框架联动。

背景:Three.js 做 GIS 场景时最容易卡在哪里

很多同学能很快用 Three.js 创建一个旋转立方体,但一接触 GIS 数据就会遇到问题。原因很简单:GIS 数据关心的是地理坐标、投影、尺度和属性,而 Three.js 关心的是三维空间中的点、线、面、材质、灯光和相机。

常见问题集中在以下几类:

  • 经纬度不能直接当作 Three.js 坐标使用:经纬度单位是度,不是米,直接放进场景会导致比例异常。
  • 数据范围太大:真实世界坐标可能是几百万级,直接用于 Three.js 容易出现精度抖动。
  • 高程和水平单位不一致:经纬度、高程米、投影米混在一起,模型会被拉伸或压扁。
  • GeoJSON 面转三维体不简单:需要处理外环、内洞、顺逆时针、三角剖分和高度属性。
  • 大量要素性能差:成千上万个 Mesh 会造成浏览器卡顿,需要合并几何体或使用 InstancedMesh。
  • 与二维地图同步困难:相机、缩放、中心点和坐标转换必须保持一致。

因此,GIS 场景使用 Three.js 的关键不在于会不会写三维动画,而在于能否正确处理空间数据到三维坐标的转换。

原理:把 GIS 数据变成 Three.js 场景的核心思路

Three.js 的坐标系统可以理解为一个本地三维坐标系,常用的轴向约定是 X 表示横向,Y 表示高度,Z 表示纵深。GIS 数据则通常来自经纬度坐标系或投影坐标系。

实际项目中建议采用以下思路:

  1. 先确定项目数据坐标系,例如 WGS84 经纬度、Web Mercator 或地方投影坐标。
  2. 选择一个场景原点,通常使用项目中心点作为局部坐标原点。
  3. 把所有 GIS 坐标转换为相对于原点的局部米制坐标。
  4. 将局部坐标映射到 Three.js 的 X、Y、Z。
  5. 根据属性字段生成高度、颜色、材质和交互信息。

例如,一个常见映射方式是:

GIS 含义 Three.js 坐标 说明
东西方向距离 X 以项目中心为原点,向东为正
高度或海拔 Y 建筑高度、DEM 高程、模型高度
南北方向距离 Z 通常向北为负或正,需在项目中统一

如果项目范围较小,例如园区、校园、厂区、一个街区,可以用近似局部坐标转换。如果范围较大,例如城市级、省级或全国级三维可视化,应优先使用成熟地图引擎或三维地理引擎,并谨慎处理地球曲率和精度问题。

步骤:Three.js GIS 三维场景基础案例源码

下面给出一个基础案例:在 Three.js 中创建 GIS 场景,模拟加载建筑面数据,将建筑按高度拉伸,并支持鼠标点击查询属性。这个案例适合理解 Three.js 在 GIS 场景中的基础组织方式。

1. 准备页面容器和依赖

如果使用 Vite、Webpack 或其他前端工程环境,可以通过 npm 安装 Three.js。为了便于理解,下面用模块化写法展示核心逻辑。

npm install three

HTML 页面只需要一个容器:

<div id="map3d"></div>

容器样式建议设置为全屏或固定高度:

#map3d {
  width: 100%;
  height: 600px;
  overflow: hidden;
}

2. 初始化 Three.js 场景、相机和渲染器

import * as THREE from 'three';

const container = document.getElementById('map3d');

const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf2f6fa);

const camera = new THREE.PerspectiveCamera(
  45,
  container.clientWidth / container.clientHeight,
  1,
  100000
);

camera.position.set(0, 800, 1200);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({
  antialias: true
});

renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(300, 800, 500);
scene.add(light);

const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

这里的相机位置不是固定答案。GIS 场景中应该根据数据范围动态设置相机高度和观察点,否则很容易出现“数据加载了但看不见”的问题。

3. 设计一个经纬度转本地坐标函数

为了演示,这里使用适合小范围场景的近似转换。它不适合跨城市、跨省的大范围分析,但适合园区、街区级三维展示。

const origin = {
  lng: 116.391,
  lat: 39.907
};

function lngLatToLocal(lng, lat) {
  const earthRadius = 6378137;
  const dLng = (lng - origin.lng) * Math.PI / 180;
  const dLat = (lat - origin.lat) * Math.PI / 180;

  const x = dLng * earthRadius * Math.cos(origin.lat * Math.PI / 180);
  const z = -dLat * earthRadius;

  return { x, z };
}

注意这里将纬度方向映射到了 Z 轴,并使用负号让地图北方向在屏幕中更符合常见观察习惯。实际项目中方向可以调整,但必须全项目统一。

4. 准备一组模拟 GeoJSON 建筑数据

真实项目中可以从后端接口、GeoServer、PostGIS、静态 GeoJSON 文件或矢量瓦片中读取数据。这里用简化数据说明结构。

const buildings = [
  {
    name: 'A座办公楼',
    height: 80,
    coordinates: [
      [116.3905, 39.9072],
      [116.3912, 39.9072],
      [116.3912, 39.9067],
      [116.3905, 39.9067]
    ]
  },
  {
    name: 'B座实验楼',
    height: 45,
    coordinates: [
      [116.3914, 39.9071],
      [116.3920, 39.9071],
      [116.3920, 39.9066],
      [116.3914, 39.9066]
    ]
  }
];

5. 将建筑面拉伸为三维体

Three.js 可以通过 Shape 和 ExtrudeGeometry 将二维多边形拉伸为三维体。注意,这里为简单案例,没有处理内洞和复杂多边形。真实 GeoJSON 中如果存在 MultiPolygon、洞、多环面,需要额外处理。

function createBuilding(building) {
  const shape = new THREE.Shape();

  building.coordinates.forEach((coord, index) => {
    const p = lngLatToLocal(coord[0], coord[1]);

    if (index === 0) {
      shape.moveTo(p.x, p.z);
    } else {
      shape.lineTo(p.x, p.z);
    }
  });

  shape.closePath();

  const geometry = new THREE.ExtrudeGeometry(shape, {
    depth: building.height,
    bevelEnabled: false
  });

  geometry.rotateX(Math.PI / 2);

  const material = new THREE.MeshLambertMaterial({
    color: 0x3b82f6,
    transparent: true,
    opacity: 0.85
  });

  const mesh = new THREE.Mesh(geometry, material);
  mesh.userData = {
    name: building.name,
    height: building.height
  };

  scene.add(mesh);
}

buildings.forEach(createBuilding);

这里有一个容易混淆的点:ExtrudeGeometry 默认沿局部 Z 方向拉伸,而我们希望高度在 Three.js 的 Y 方向,所以使用了 rotateX 进行轴向调整。实际项目中也可以通过统一坐标映射方式避免多处旋转。

6. 添加地面网格辅助观察

const grid = new THREE.GridHelper(1000, 20, 0x999999, 0xdddddd);
scene.add(grid);

GIS 三维调试时,地面网格很有用。它可以帮助你判断模型是否倒置、是否偏移、是否高度异常。

7. 添加点击查询交互

GIS 场景通常不只是展示,还需要点击查询属性。Three.js 中常用 Raycaster 进行拾取。

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

container.addEventListener('click', function (event) {
  const rect = container.getBoundingClientRect();

  mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

  raycaster.setFromCamera(mouse, camera);

  const intersects = raycaster.intersectObjects(scene.children, true);

  if (intersects.length > 0) {
    const object = intersects[0].object;

    if (object.userData && object.userData.name) {
      console.log('名称:', object.userData.name);
      console.log('高度:', object.userData.height);
    }
  }
});

在正式 WebGIS 项目中,可以把查询结果显示在信息面板、弹窗或属性表中。如果要和二维地图联动,还可以反向把 Three.js 拾取结果定位到二维地图中心。

8. 渲染循环和窗口自适应

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

animate();

window.addEventListener('resize', function () {
  camera.aspect = container.clientWidth / container.clientHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(container.clientWidth, container.clientHeight);
});

到这里,一个最小可运行的 Three.js GIS 三维建筑案例就完成了。它虽然简单,但已经包含了 GIS 三维场景的核心流程:数据坐标转换、几何构建、属性挂载、渲染和交互。

步骤:更多 Three.js 前端三维图形开发案例在 GIS 中怎么用

案例一:GeoJSON 行政区块三维拉伸

适用场景包括人口密度、GDP、土地利用强度、风险等级等专题图。做法是把面要素按字段值拉伸成不同高度,并用颜色表达指标等级。

  • 数据源:GeoJSON、PostGIS 查询结果、矢量瓦片。
  • 关键字段:行政区名称、统计值、分类等级。
  • Three.js 对象:Shape、ExtrudeGeometry、MeshLambertMaterial。
  • 注意事项:复杂行政区边界需要简化,否则三角面数量会非常大。

案例二:三维地形 DEM 可视化

DEM 是数字高程模型,常见格式有 GeoTIFF、ASC、栅格瓦片等。Three.js 可以用 PlaneGeometry 创建网格平面,再把每个顶点的 Y 值设置为高程。

function createTerrain(width, height, elevationArray) {
  const geometry = new THREE.PlaneGeometry(1000, 1000, width - 1, height - 1);
  geometry.rotateX(-Math.PI / 2);

  const position = geometry.attributes.position;

  for (let i = 0; i < position.count; i++) {
    const elevation = elevationArray[i] || 0;
    position.setY(i, elevation * 1.5);
  }

  position.needsUpdate = true;
  geometry.computeVertexNormals();

  const material = new THREE.MeshLambertMaterial({
    color: 0x8bbf72,
    wireframe: false
  });

  return new THREE.Mesh(geometry, material);
}

需要注意的是,DEM 数据通常不能直接在浏览器中读取全部原始文件。生产项目更常见的做法是后端预处理为切片、简化网格或按视野动态加载。

案例三:城市飞线和迁徙线

飞线常用于人口迁徙、物流流向、航线、网络链路等可视化。实现方式通常是用起点和终点构造一条三维贝塞尔曲线,再用 Line 或 TubeGeometry 渲染。

function createFlyLine(start, end) {
  const startLocal = lngLatToLocal(start.lng, start.lat);
  const endLocal = lngLatToLocal(end.lng, end.lat);

  const p0 = new THREE.Vector3(startLocal.x, 10, startLocal.z);
  const p2 = new THREE.Vector3(endLocal.x, 10, endLocal.z);

  const distance = p0.distanceTo(p2);
  const p1 = new THREE.Vector3(
    (p0.x + p2.x) / 2,
    distance * 0.25,
    (p0.z + p2.z) / 2
  );

  const curve = new THREE.QuadraticBezierCurve3(p0, p1, p2);
  const points = curve.getPoints(80);

  const geometry = new THREE.BufferGeometry().setFromPoints(points);
  const material = new THREE.LineBasicMaterial({
    color: 0xff9900
  });

  return new THREE.Line(geometry, material);
}

飞线效果看起来炫,但 GIS 项目中不要滥用。线太多会遮挡空间关系,也会增加渲染压力。建议加入筛选、聚合、分级显示和时间过滤。

案例四:轨迹回放与车辆移动

轨迹回放适用于车辆监管、巡检路线、船舶轨迹、人员定位等场景。核心是根据时间字段按顺序插值,让模型沿轨迹移动。

  • 轨迹点必须有时间字段。
  • 前端应按时间排序。
  • 经纬度点需要转换为 Three.js 本地坐标。
  • 模型朝向应根据相邻点方向计算。
  • 长轨迹建议抽稀,避免一次渲染过多点。

案例五:园区三维模型加载

如果已有建筑白模、设备模型或厂区模型,可以使用 glTF 格式。glTF 是 Web 三维中常用的模型格式,适合浏览器加载。

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

const loader = new GLTFLoader();

loader.load('/models/park.glb', function (gltf) {
  const model = gltf.scene;
  model.scale.set(1, 1, 1);
  model.position.set(0, 0, 0);
  scene.add(model);
});

模型加载后,最常见的问题是比例不对、坐标不对、方向不对。建议在建模阶段就约定单位、原点、朝向和楼层高度,不要全部留给前端临时修。

常见坑:Three.js GIS 场景调试重点

1. 经纬度直接作为 x、z 坐标

这是最常见错误。经纬度是角度单位,不是米。直接使用会导致模型比例不真实、距离不正确、场景操作困难。至少应转换为局部米制坐标。

2. 世界坐标过大导致画面抖动

如果直接使用投影坐标,例如 3857 或地方坐标中的百万级数值,Three.js 可能出现精度问题。解决方法是使用项目中心点作为原点,将所有坐标平移到本地小范围坐标。

3. 建筑高度字段单位混乱

有些数据中的高度字段是米,有些是层数,有些是字符串。拉伸前要确认字段含义。例如层数应乘以平均层高,而不是直接当米使用。

4. 面数据方向和内洞处理不正确

复杂 GeoJSON 面可能包含内洞,例如湖泊、院落或建筑中庭。简单 Shape 只能处理基础外环,遇到 holes 需要使用 shape.holes 添加内环,否则会把洞填满。

5. Mesh 数量太多导致卡顿

每个建筑、每个点位都单独创建 Mesh,数量上万时性能会明显下降。可以考虑合并几何体、使用 InstancedMesh、按视野加载、按层级简化。

6. 相机 near 和 far 设置不合理

如果 near 太小、far 太大,深度缓冲精度会变差,可能出现闪烁和穿插。GIS 场景中应根据数据范围设置相机裁剪面,而不是随便写一个很大的 far。

7. 忘记释放资源

在 Vue、React 或单页面应用中切换页面时,需要释放 geometry、material、texture 和 renderer,否则可能造成内存泄漏。

function disposeObject(object) {
  object.traverse(function (child) {
    if (child.geometry) {
      child.geometry.dispose();
    }

    if (child.material) {
      if (Array.isArray(child.material)) {
        child.material.forEach(function (m) {
          m.dispose();
        });
      } else {
        child.material.dispose();
      }
    }
  });
}

方法比较:Three.js、Cesium、Mapbox GL JS 怎么选

GIS 三维项目不一定都应该用 Three.js。选型要看项目目标。如果是纯地理三维地球、海量倾斜摄影、3D Tiles 和全球尺度数据,Cesium 通常更合适。如果是二维地图加少量三维效果,Mapbox GL JS 或 OpenLayers 结合自定义图层也可能更省事。

方案 适合场景 优势 限制
Three.js 园区三维、专题三维、模型展示、定制交互 渲染自由度高,效果可控,适合前端定制 不自带 GIS 坐标、瓦片、地球和空间分析能力
Cesium 三维地球、3D Tiles、倾斜摄影、全球地形 地理三维能力强,支持大范围地球场景 定制非地理三维交互时成本较高
Mapbox GL JS 二维地图加三维建筑、矢量瓦片可视化 地图渲染和样式能力强,可叠加自定义图层 复杂三维模型和自由场景不如 Three.js 灵活
OpenLayers 加 Three.js 已有 OpenLayers WebGIS 系统扩展三维效果 可复用现有二维地图能力 相机同步、坐标转换和性能优化需要额外处理

简单判断可以这样做:如果你的核心是“地图和地球”,优先考虑 GIS 引擎;如果核心是“自定义三维场景和模型效果”,Three.js 更灵活;如果两者都需要,常见做法是地图引擎负责地理底座,Three.js 负责三维专题图层。

检查清单:上线前必须确认的 GIS 三维问题

  • 是否明确了所有数据的坐标系?
  • 经纬度是否已转换为本地米制坐标?
  • 是否设置了统一的项目原点?
  • Three.js 的 X、Y、Z 与 GIS 东西、南北、高度是否对应清楚?
  • 建筑高度字段是否经过单位校验?
  • GeoJSON 是否存在 MultiPolygon、内洞和异常面?
  • 是否对复杂边界做了简化?
  • 是否避免一次性创建过多 Mesh?
  • 点击拾取是否能正确返回 GIS 属性?
  • 相机视角是否能根据数据范围自动适配?
  • 窗口 resize 后画面比例是否正常?
  • 路由切换或组件销毁时是否释放 Three.js 资源?
  • 移动端是否测试过帧率、内存和交互体验?

FAQ:Three.js GIS 应用常见问题

Q1:Three.js 能不能直接加载 Shapefile?

Three.js 不能直接理解 Shapefile 的 GIS 语义。通常做法是先用 QGIS、GDAL、GeoPandas 或后端服务把 Shapefile 转为 GeoJSON、矢量瓦片或接口数据,再在前端转换为 Three.js 几何体。

Q2:Three.js 能不能代替 Cesium?

不能简单替代。Three.js 是通用三维渲染库,Cesium 是面向三维地理空间的引擎。如果项目重点是全球地球、地形、影像、3D Tiles 和倾斜摄影,Cesium 更合适。如果项目重点是园区、厂区、设备模型和高度定制效果,Three.js 更灵活。

Q3:Three.js GIS 场景为什么模型会偏移?

常见原因包括坐标系不一致、原点设置错误、经纬度没有转换、模型自身原点不在预期位置、单位不一致。排查时先确认数据坐标系,再确认局部坐标转换函数,最后检查模型导出单位和方向。

Q4:GeoJSON 面拉伸后为什么形状不对?

可能是坐标顺序错误、环方向错误、存在内洞没有处理、MultiPolygon 被当成 Polygon、坐标没有闭合,或者数据本身有自相交。建议先在 QGIS 中检查几何有效性,再进行前端渲染。

Q5:Three.js 加载很多 GIS 点位很卡怎么办?

不要为每个点位创建一个独立 Mesh。可以使用 InstancedMesh、合并 BufferGeometry、按瓦片或视野加载、点位聚合、LOD 分级显示。对于几十万级点数据,前端还需要结合后端抽稀和空间索引。

Q6:能否把 Three.js 叠加到 OpenLayers 或 Leaflet 上?

可以,但需要处理坐标转换、相机同步、地图缩放比例和图层层级。简单项目可以使用覆盖容器叠加,复杂项目建议封装专门的自定义图层,并保持地图中心、缩放和 Three.js 场景原点同步。

结论:Three.js 适合做 GIS 三维专题,但坐标和性能必须先想清楚

Three.js 前端三维图形开发在 GIS 场景中很有价值,尤其适合园区三维、建筑拉伸、飞线、轨迹、设备模型和专题三维可视化。它的优势是自由、灵活、效果可定制,但它不会自动解决 GIS 坐标系、投影、空间数据组织和大规模数据加载问题。

实际开发时,建议先从一个小而完整的案例入手:确定坐标系,建立局部坐标原点,加载少量 GeoJSON,完成三维拉伸和点击查询。等这条链路跑通后,再扩展 DEM 地形、模型加载、轨迹回放、图层控制和性能优化。

如果你是 GIS 学生或初级 WebGIS 开发者,记住一个原则:Three.js 负责把东西画得漂亮,GIS 逻辑负责让东西画得准确。只有两者结合好,三维 GIS 场景才既能看,也能用。