Three.js地理空间可视化如何实现?城乡规划三维场景构建实战(附:GIS数据对接源码)

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

Three.js地理空间可视化如何实现?城乡规划三维场景构建实战(附:GIS数据对接源码)这类问题,通常不是“会不会写 Three.js”这么简单,而是要解决 GIS 数据坐标、格式转换、三维表达、性能优化和规划业务呈现之间的衔接。本文以城乡规划三维场景为例,讲清楚如何把常见 GIS 数据接入 Three.js,并构建一个可交互的地理空间可视化原型。

引言:Three.js地理空间可视化适合解决什么问题

在城乡规划、国土空间规划、城市更新、园区管理和三维管控平台中,常见需求包括:加载建筑轮廓、展示地块边界、叠加道路水系、表达建筑高度、查看规划方案影响范围等。

传统二维 WebGIS 可以解决位置查询和图层叠加问题,但当业务需要表达建筑体量、空间高度、日照遮挡、视线通廊或三维方案对比时,Three.js 地理空间可视化就很有价值。

不过,Three.js 本身不是 GIS 引擎。它擅长三维渲染,但不直接理解 EPSG 坐标系、Shapefile、GeoJSON 拓扑关系、瓦片服务或投影转换。因此,实际项目中要把 GIS 数据先处理成 Three.js 能理解的几何对象,再建立一套适合规划场景的坐标映射和图层组织方式。

Three.js地理空间可视化与GIS数据对接流程图
Three.js 地理空间可视化的核心流程:先处理 GIS 数据,再构建三维场景。

背景:城乡规划三维场景为什么不能直接加载原始 GIS 数据

很多初学者会把 GeoJSON 坐标直接传给 Three.js,然后发现模型不显示、画面抖动、线条变形,或者建筑体块位置完全不对。根本原因通常有四类。

  • 坐标值过大:经纬度或投影坐标直接进入 Three.js,会导致浮点精度问题,场景出现抖动或渲染异常。
  • 坐标系不统一:建筑、道路、地块、水系可能来自不同坐标系,叠加前必须统一投影。
  • 二维 GIS 数据没有高度:建筑轮廓一般只有平面多边形,需要根据高度字段或规划指标进行拉伸。
  • 数据量过大:完整行政区 GeoJSON、复杂道路网或高密度建筑面会造成浏览器卡顿。

所以,Three.js 地理空间可视化的关键并不是“把 GeoJSON 画出来”,而是建立一条稳定的数据处理链路:GIS 数据清洗、坐标转换、局部坐标归一化、几何体生成、材质设置、交互查询和性能优化。

原理:Three.js地理空间可视化的坐标映射逻辑

Three.js 使用的是三维笛卡尔坐标系,通常用 xyz 表示空间位置。而 GIS 数据常见坐标有两类:经纬度坐标和投影平面坐标。

在城乡规划项目中,推荐的处理方式是:

  1. 把所有 GIS 数据统一到适合本地测量的投影坐标系,例如 CGCS2000 高斯投影、UTM 或本地工程坐标。
  2. 选取项目中心点作为 Three.js 场景原点。
  3. 将 GIS 坐标减去中心点坐标,得到相对坐标。
  4. 把二维平面坐标映射到 Three.js 的 xz 轴,把高度映射到 y 轴。

常用映射关系如下:

GIS 含义 GIS 字段或坐标 Three.js 对应
东向坐标 X / Longitude 投影后坐标 x
北向坐标 Y / Latitude 投影后坐标 z
建筑高度 height、floor、规划限高 y
项目中心点 centerX、centerY 场景原点偏移

这种方式的好处是:既保留了 GIS 数据之间的空间关系,又避免 Three.js 直接处理超大坐标值。

步骤:从 GeoJSON 构建城乡规划三维场景

步骤一:准备 GIS 数据

本文示例使用三类常见城乡规划数据:

  • 建筑轮廓:Polygon 或 MultiPolygon,字段包含 heightfloor
  • 规划地块:Polygon,字段包含 landuseplot_idfar
  • 道路中心线:LineString 或 MultiLineString,字段包含 road_nameroad_level

如果原始数据是 Shapefile,可以先用 QGIS 或 GDAL 转为 GeoJSON。注意输出前统一坐标系,避免前端再承担复杂投影转换。

ogr2ogr -f GeoJSON buildings.geojson buildings.shp -t_srs EPSG:4547
ogr2ogr -f GeoJSON parcels.geojson parcels.shp -t_srs EPSG:4547
ogr2ogr -f GeoJSON roads.geojson roads.shp -t_srs EPSG:4547

这里的 EPSG:4547 只是示例,实际项目应根据所在城市和数据标准选择正确坐标系。

步骤二:建立 Three.js 基础场景

先创建场景、相机、渲染器和基础光照。规划三维场景通常需要较大的视野范围,因此可以使用透视相机,并适当设置远裁剪面。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

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

const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  1,
  100000
);

camera.position.set(800, 900, 1200);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.update();

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

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(500, 1000, 800);
scene.add(directionalLight);

步骤三:定义 GIS 坐标到 Three.js 坐标的转换函数

假设 GeoJSON 已经是投影坐标,单位为米。我们选取项目范围中心作为原点。

const origin = {
  x: 39450000,
  y: 3452000
};

function gisToThree(coord) {
  const x = coord[0] - origin.x;
  const z = -(coord[1] - origin.y);
  return new THREE.Vector2(x, z);
}

这里把 GIS 的 Y 轴取负映射到 Three.js 的 Z 轴,是为了让屏幕观察方向更符合常见地图“上北下南”的习惯。你也可以不取负,但必须在所有图层中保持一致。

步骤四:加载 GeoJSON 建筑面并拉伸成三维体块

Three.js 可以使用 ShapeExtrudeGeometry 将二维多边形拉伸为三维建筑。下面示例处理普通 Polygon。如果存在 MultiPolygon,需要循环拆分。

async function loadBuildings(url) {
  const res = await fetch(url);
  const geojson = await res.json();

  geojson.features.forEach(feature => {
    const geometry = feature.geometry;
    const properties = feature.properties || {};

    if (geometry.type === 'Polygon') {
      createBuildingFromPolygon(geometry.coordinates, properties);
    }

    if (geometry.type === 'MultiPolygon') {
      geometry.coordinates.forEach(polygonCoords => {
        createBuildingFromPolygon(polygonCoords, properties);
      });
    }
  });
}

function createBuildingFromPolygon(coordinates, properties) {
  const outerRing = coordinates[0];
  const shape = new THREE.Shape();

  outerRing.forEach((coord, index) => {
    const point = gisToThree(coord);
    if (index === 0) {
      shape.moveTo(point.x, point.y);
    } else {
      shape.lineTo(point.x, point.y);
    }
  });

  const height = Number(properties.height) || Number(properties.floor) * 3 || 18;

  const extrudeSettings = {
    depth: height,
    bevelEnabled: false
  };

  const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
  geometry.rotateX(Math.PI / 2);

  const material = new THREE.MeshLambertMaterial({
    color: getBuildingColor(height),
    transparent: true,
    opacity: 0.9
  });

  const mesh = new THREE.Mesh(geometry, material);
  mesh.userData = properties;
  scene.add(mesh);
}

function getBuildingColor(height) {
  if (height >= 80) return 0x8c1d18;
  if (height >= 40) return 0xd95f02;
  if (height >= 20) return 0xfdb863;
  return 0xb2abd2;
}

loadBuildings('/data/buildings.geojson');

这段代码完成了 GIS 数据对接源码中最核心的一步:把建筑面要素转换为可渲染的 Three.js 三维体块。

步骤五:加载规划地块并按用地性质着色

规划地块一般不需要拉伸很高,可以作为底图面叠加在地面上,用颜色表达用地性质。

function createParcelFromPolygon(coordinates, properties) {
  const outerRing = coordinates[0];
  const shape = new THREE.Shape();

  outerRing.forEach((coord, index) => {
    const point = gisToThree(coord);
    if (index === 0) {
      shape.moveTo(point.x, point.y);
    } else {
      shape.lineTo(point.x, point.y);
    }
  });

  const geometry = new THREE.ShapeGeometry(shape);
  geometry.rotateX(-Math.PI / 2);

  const material = new THREE.MeshBasicMaterial({
    color: getLanduseColor(properties.landuse),
    transparent: true,
    opacity: 0.45,
    side: THREE.DoubleSide
  });

  const mesh = new THREE.Mesh(geometry, material);
  mesh.position.y = 0.2;
  mesh.userData = properties;
  scene.add(mesh);
}

function getLanduseColor(landuse) {
  const colors = {
    residential: 0xffd6d6,
    commercial: 0xffcc66,
    industrial: 0xb0b0b0,
    green: 0x74c476,
    public: 0x9ecae1
  };
  return colors[landuse] || 0xdddddd;
}

在真实城乡规划项目中,用地分类字段可能是中文编码,例如 R2B1M1G1。建议在前端建立一张用地分类颜色表,而不是把颜色写死在数据里。

步骤六:加载道路中心线

道路可以用 BufferGeometryLineBasicMaterial 表达。如果要做真实道路面宽,可以在后端先用 GIS 缓冲区工具生成道路面。

async function loadRoads(url) {
  const res = await fetch(url);
  const geojson = await res.json();

  geojson.features.forEach(feature => {
    const geometry = feature.geometry;
    const properties = feature.properties || {};

    if (geometry.type === 'LineString') {
      createRoadLine(geometry.coordinates, properties);
    }

    if (geometry.type === 'MultiLineString') {
      geometry.coordinates.forEach(lineCoords => {
        createRoadLine(lineCoords, properties);
      });
    }
  });
}

function createRoadLine(coordinates, properties) {
  const points = coordinates.map(coord => {
    const p = gisToThree(coord);
    return new THREE.Vector3(p.x, 1, p.y);
  });

  const geometry = new THREE.BufferGeometry().setFromPoints(points);

  const material = new THREE.LineBasicMaterial({
    color: getRoadColor(properties.road_level)
  });

  const line = new THREE.Line(geometry, material);
  line.userData = properties;
  scene.add(line);
}

function getRoadColor(level) {
  if (level === 'expressway') return 0xff0000;
  if (level === 'main') return 0xff9900;
  if (level === 'secondary') return 0xffff00;
  return 0xffffff;
}

loadRoads('/data/roads.geojson');

步骤七:添加对象拾取,实现点击查询属性

城乡规划三维场景不仅要好看,还要能查属性。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;
    console.log('选中对象属性:', object.userData);
  }
});

生产环境中可以把 object.userData 展示到右侧属性面板,例如地块编号、用地性质、建筑高度、容积率、建筑密度和规划状态。

步骤八:渲染循环与窗口自适应

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

animate();

window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

到这里,一个基础的 Three.js 地理空间可视化场景已经完成:建筑可拉伸、地块可着色、道路可叠加、对象可点击查询。

常见坑:Three.js对接GIS数据时最容易出错的地方

坑一:直接使用经纬度坐标

经纬度单位是度,不是米。直接用经纬度构建 Three.js 场景,会导致距离比例不真实,建筑高度和地面尺度不匹配。建议先投影到米制坐标系。

坑二:没有做局部原点偏移

投影坐标常常是几百万甚至几千万级数值。Three.js 使用大坐标时容易产生浮点精度问题。正确做法是用项目中心点做原点,所有坐标转为相对坐标。

坑三:Polygon 内环没有处理

如果建筑或地块存在洞,例如庭院、湖心岛、镂空建筑,GeoJSON Polygon 会包含内环。简单只读取 coordinates[0] 会忽略洞。生产环境应把内环加入 shape.holes

coordinates.slice(1).forEach(holeRing => {
  const holePath = new THREE.Path();

  holeRing.forEach((coord, index) => {
    const point = gisToThree(coord);
    if (index === 0) {
      holePath.moveTo(point.x, point.y);
    } else {
      holePath.lineTo(point.x, point.y);
    }
  });

  shape.holes.push(holePath);
});

坑四:GeoJSON 文件过大

如果一次加载几十 MB 的 GeoJSON,浏览器解析和 Three.js 建模都会卡顿。建议按行政区、瓦片、视域范围或业务图层分块加载。大规模数据更适合使用 3D Tiles、矢量瓦片或后端按范围查询。

坑五:建筑高度字段不规范

有些数据只有层数字段,没有高度字段。可以按 层数 × 层高 估算,但要在系统中明确说明估算规则,避免被误认为精确建筑高度。

坑六:前端承担太多 GIS 运算

前端适合渲染和交互,不适合做大规模投影转换、拓扑修复、面简化和空间查询。复杂处理应放到 QGIS、PostGIS、GDAL 或后端服务中完成。

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

做城乡规划三维场景时,很多读者会纠结 Three.js、Cesium 和 Mapbox GL JS 的选择。它们不是谁替代谁,而是适合不同场景。

方案 适合场景 优势 限制
Three.js 自定义三维规划场景、园区模型、建筑体块表达 三维控制自由,材质、动画和交互灵活 不自带 GIS 坐标系、地图瓦片和空间分析能力
Cesium 大范围三维地球、倾斜摄影、3D Tiles、地形场景 地理坐标体系完整,适合大尺度三维 GIS 自定义局部三维交互和轻量业务面板时成本较高
Mapbox GL JS 二维地图、矢量瓦片、轻量 2.5D 建筑 地图底图和矢量样式能力强 复杂三维模型和规划体块交互不如 Three.js 灵活
OpenLayers + Three.js 二维 GIS 地图叠加局部三维视图 兼顾传统 WebGIS 与自定义三维 坐标同步、相机同步和交互同步需要额外开发

如果你的重点是全球尺度、倾斜摄影和 3D Tiles,优先考虑 Cesium。如果你的重点是城乡规划局部场景、建筑体块、地块指标和三维方案表达,Three.js 更容易做出高度定制的业务效果。

检查清单:上线前必须确认这些问题

  • 坐标系:所有图层是否已经统一到同一个投影坐标系?
  • 原点:是否使用项目中心点做局部坐标偏移?
  • 单位:平面距离和建筑高度是否都是米?
  • 字段:建筑高度、层数、用地性质、地块编号字段是否稳定?
  • 数据量:GeoJSON 是否经过简化、切分或按需加载?
  • 拓扑:建筑面和地块面是否存在自相交、空几何或重复点?
  • 交互:点击拾取是否只对业务图层生效,避免误选灯光和辅助对象?
  • 性能:是否合并静态几何、减少材质数量、避免每帧重复创建对象?
  • 样式:建筑高度分级、用地颜色和道路等级是否符合规划制图习惯?
  • 说明:估算高度、简化边界和坐标转换规则是否在系统中有说明?

FAQ:Three.js地理空间可视化常见问题

Three.js 可以直接读取 Shapefile 吗?

不建议直接在前端读取 Shapefile。虽然可以借助第三方库解析,但实际项目中更推荐先用 QGIS、GDAL 或后端服务转换为 GeoJSON、矢量瓦片或接口 JSON。这样更容易控制坐标系、字段和数据体积。

Three.js 地理空间可视化一定要用投影坐标吗?

强烈建议使用投影坐标,特别是城乡规划这种需要表达距离、面积和高度的场景。经纬度适合定位,不适合直接作为三维建模单位。

建筑拉伸后高度方向不对怎么办?

检查 ExtrudeGeometry 的拉伸方向和 rotateX 旋转角度。本文示例中先在二维平面生成 Shape,再通过旋转让拉伸高度对应 Three.js 的 Y 轴。如果你的场景坐标约定不同,需要统一调整。

GeoJSON 加载慢如何优化?

优先从数据侧优化:删除无用字段、简化几何、按区域切片、按缩放级别加载。其次再考虑前端优化,例如合并几何、减少材质、延迟加载、使用 Web Worker 解析数据。

Three.js 能不能叠加真实地图底图?

可以,但需要处理瓦片坐标、投影和相机同步。简单场景可以把地图截图或瓦片拼接成地面纹理;复杂 WebGIS 场景更建议使用 OpenLayers、Mapbox GL JS 或 Cesium 负责地图底图,再与 Three.js 做联动。

规划地块的面积和容积率可以在 Three.js 里计算吗?

技术上可以,但不推荐把严肃 GIS 指标计算放在 Three.js 中。面积、容积率、建筑密度等指标应由 GIS 软件、PostGIS 或后端服务计算,Three.js 只负责可视化展示和交互查询。

MultiPolygon 建筑显示不完整是什么原因?

常见原因是代码只处理了 Polygon,没有遍历 MultiPolygon。GeoJSON 中 MultiPolygon 比较常见,尤其是一个地块包含多个不连续面时,必须逐个 polygon 生成几何体。

结论:Three.js做城乡规划三维场景的核心是GIS数据工程

Three.js 地理空间可视化的难点不在于画一个立方体,而在于把 GIS 数据正确、稳定、高效地转换成三维场景。城乡规划三维场景构建至少要处理坐标系统一、局部坐标映射、建筑面拉伸、地块分类着色、道路叠加和属性查询。

如果你只是做局部规划方案展示,Three.js 能提供很强的三维表现力和交互自由度。如果你要做大范围三维地球、倾斜摄影和海量 3D Tiles,Cesium 会更合适。实际项目中,也可以采用“GIS 后端负责数据处理,Three.js 前端负责三维表达”的架构,把专业 GIS 能力和浏览器三维渲染能力结合起来。

建议从一个小范围试点开始:先加载一个建筑 GeoJSON、一个地块 GeoJSON 和一条道路图层,把坐标、比例和属性查询跑通,再逐步扩展到完整的城乡规划三维场景。