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

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

如果你正在搜索“Three.js地理空间可视化如何实现?城乡规划三维场景构建实战(附:GIS数据对接源码)”,大概率不是想看一个旋转立方体 Demo,而是想把真实 GIS 数据接入 Three.js,构建可交互、可扩展的城乡规划三维场景。本文以城乡规划常见的建筑、道路、地块边界和底图数据为例,讲清楚 Three.js 地理空间可视化的核心流程、坐标处理方法、数据对接代码和常见坑。

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

Three.js 是一个基于 WebGL 的 JavaScript 三维渲染库。它本身不是 GIS 平台,但非常适合做轻量级、定制化的三维地理空间可视化,例如城乡规划方案展示、建筑体量分析、道路廊道表达、地块开发强度对比和三维专题图。

在城乡规划场景中,常见需求包括:

  • 把建筑轮廓面拉伸为三维建筑模型。
  • 把道路中心线或道路面显示为三维场景中的交通骨架。
  • 将地块、用地性质、控规指标做颜色分级。
  • 支持鼠标点击查询地块编号、建筑高度、用地类型等属性。
  • 与后端 GIS 数据服务对接,动态加载 GeoJSON 或瓦片数据。

本文的目标不是替代 Cesium 或专业三维 GIS 平台,而是给你一套可落地的 Three.js 地理空间可视化实现思路:从 GIS 数据准备、坐标转换、场景搭建、建筑拉伸,到属性查询和性能优化。

Three.js地理空间可视化与城乡规划三维场景构建流程
Three.js 地理空间可视化的典型流程:GIS 数据准备、坐标转换、三维建模、交互查询与性能优化。

背景:城乡规划三维场景为什么不能直接套普通 Three.js Demo

很多 Three.js 入门示例使用的是任意笛卡尔坐标,例如 x、y、z 都在几十或几百以内。但 GIS 数据通常使用经纬度坐标、投影坐标或地方坐标,数值范围大、单位复杂、坐标轴方向也不一定符合 WebGL 场景习惯。

如果直接把经纬度坐标作为 Three.js 的 x、y 坐标,你会遇到几个典型问题:

  • 场景比例严重变形,建筑宽度、高度和道路长度不一致。
  • 模型离原点太远,出现抖动、闪烁或点击不准。
  • GeoJSON 坐标顺序、投影坐标单位和 Three.js 坐标轴对应关系混乱。
  • 建筑拉伸方向不对,二维面无法正确生成三维体块。
  • 数据量稍大后,浏览器帧率明显下降。

所以,Three.js 地理空间可视化的关键不是“如何画一个三维物体”,而是“如何把 GIS 数据稳定、正确、高性能地映射到 Three.js 场景”。

原理:Three.js地理空间可视化的坐标与数据模型

1. 坐标转换:先把 GIS 坐标变成场景局部坐标

GIS 数据通常有两类坐标:

  • 经纬度坐标:例如 EPSG:4326,单位是度。
  • 投影坐标:例如 Web Mercator EPSG:3857、CGCS2000 高斯投影、地方独立坐标,单位通常是米。

Three.js 场景更适合使用局部平面坐标,单位可以理解为米。常见做法是:

  1. 将经纬度转换为投影坐标。
  2. 选取项目范围中心点作为局部原点。
  3. 所有点坐标减去原点坐标,得到局部 x、y。
  4. 将高度字段映射到 Three.js 的 z 或 y 轴,取决于你的坐标轴约定。

在 Three.js 中,很多开发者习惯使用 x 表示东西方向,z 表示南北方向,y 表示高度。本文示例采用这种约定。

2. 数据模型:GeoJSON 是最适合入门的数据对接格式

城乡规划三维场景常见数据可以先整理成 GeoJSON:

  • 建筑面:Polygon 或 MultiPolygon,属性包含 height、floor、name。
  • 道路数据:LineString、MultiLineString 或 Polygon,属性包含 road_type、width。
  • 地块边界:Polygon,属性包含 landuse、plot_id、far、density。

GeoJSON 易读、易调试,适合中小范围项目原型。如果数据量很大,可以进一步考虑矢量瓦片、3D Tiles、二进制格式或后端分块加载。

3. 三维表达:建筑拉伸、道路铺设、地块着色

Three.js 地理空间可视化中,最常用的三种表达方式是:

  • 建筑拉伸:将二维建筑轮廓 Polygon 转为 Shape,再用 ExtrudeGeometry 按高度拉伸。
  • 道路表达:线数据可用 Line、TubeGeometry 或根据道路宽度生成面。
  • 地块表达:将 Polygon 转为平面 Mesh,并按用地性质或指标分级设色。

步骤:从GIS数据对接到Three.js城乡规划三维场景

步骤一:准备建筑 GeoJSON 数据

建议先用 QGIS、ArcGIS Pro 或 GeoPandas 对数据做清洗。最低要求如下:

  • 几何类型为 Polygon 或 MultiPolygon。
  • 坐标系统一,推荐先投影到米制坐标系。
  • 建筑高度字段存在,例如 height。
  • 几何无自相交、无空几何、无异常小碎面。
  • 属性字段命名简洁,便于前端读取。

一个简化后的建筑 GeoJSON 结构如下:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": {
        "name": "规划住宅楼A",
        "height": 54,
        "floor": 18,
        "landuse": "居住用地"
      },
      "geometry": {
        "type": "Polygon",
        "coordinates": [
          [
            [120.123456, 30.123456],
            [120.123756, 30.123456],
            [120.123756, 30.123756],
            [120.123456, 30.123756],
            [120.123456, 30.123456]
          ]
        ]
      }
    }
  ]
}

步骤二:安装前端依赖

如果你使用 Vite 创建前端项目,可以安装 Three.js 和坐标转换库 proj4:

npm install three proj4

Three.js 负责三维渲染,proj4 负责坐标转换。对于城乡规划项目,如果数据已经是米制投影坐标,也可以暂时不使用 proj4,只做局部原点平移。

步骤三:创建基础 Three.js 场景

下面代码创建一个基础三维场景,包括相机、光照、渲染器和控制器。为了让 GIS 场景更直观,示例使用 y 轴表示高度,x 和 z 表示平面位置。

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

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

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

const camera = new THREE.PerspectiveCamera(
  45,
  container.clientWidth / container.clientHeight,
  1,
  100000
);
camera.position.set(800, 900, 1200);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.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.7);
scene.add(ambientLight);

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

const grid = new THREE.GridHelper(2000, 40, 0x999999, 0dddddd);
scene.add(grid);

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

animate();

步骤四:定义坐标转换函数

如果源数据是 EPSG:4326 经纬度,可以先转为 EPSG:3857,再减去项目中心点。实际生产中,最好使用当地适合的米制投影坐标,而不是盲目使用 EPSG:3857。

import proj4 from 'proj4';

const WGS84 = 'EPSG:4326';
const WEB_MERCATOR = 'EPSG:3857';

proj4.defs(WGS84, '+proj=longlat +datum=WGS84 +no_defs');
proj4.defs(WEB_MERCATOR, '+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs');

// 项目局部原点,建议使用项目范围中心点
const originLngLat = [120.123600, 30.123600];
const originMercator = proj4(WGS84, WEB_MERCATOR, originLngLat);

function lngLatToScene(lng, lat) {
  const p = proj4(WGS84, WEB_MERCATOR, [lng, lat]);

  const x = p[0] - originMercator[0];
  const z = -(p[1] - originMercator[1]);

  return new THREE.Vector2(x, z);
}

这里将北向坐标取负,是为了让屏幕观察方向更符合常见 Three.js 场景习惯。你也可以不取负,但必须在道路、地块、建筑和底图中保持同一套规则。

步骤五:把建筑 Polygon 拉伸成三维模型

Three.js 的 Shape 可以根据二维点构建面,再通过 ExtrudeGeometry 拉伸为三维建筑。下面示例处理单个 Polygon,MultiPolygon 可以拆成多个 Polygon 分别处理。

function createBuildingMesh(feature) {
  const coords = feature.geometry.coordinates[0];
  const height = Number(feature.properties.height || 10);

  const shape = new THREE.Shape();

  coords.forEach((coord, index) => {
    const point = lngLatToScene(coord[0], coord[1]);

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

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

  // ExtrudeGeometry 默认沿 z 方向拉伸,这里旋转后让高度进入 y 轴
  geometry.rotateX(Math.PI / 2);

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

  const mesh = new THREE.Mesh(geometry, material);
  mesh.userData = feature.properties;

  return mesh;
}

function getBuildingColor(height) {
  if (height >= 80) return 0xd73027;
  if (height >= 50) return 0xfc8d59;
  if (height >= 25) return 0xfee08b;
  return 0x91cf60;
}

这里的 userData 用于保存 GIS 属性,后续鼠标点击查询时可以直接读取。对于城乡规划展示,按建筑高度、容积率或建筑类型设色都很常见。

步骤六:加载 GeoJSON 并添加到场景

async function loadBuildings() {
  const response = await fetch('/data/planning-buildings.geojson');
  const geojson = await response.json();

  const group = new THREE.Group();
  group.name = 'buildings';

  geojson.features.forEach(feature => {
    if (feature.geometry.type === 'Polygon') {
      const mesh = createBuildingMesh(feature);
      group.add(mesh);
    }

    if (feature.geometry.type === 'MultiPolygon') {
      feature.geometry.coordinates.forEach(polyCoords => {
        const subFeature = {
          ...feature,
          geometry: {
            type: 'Polygon',
            coordinates: polyCoords
          }
        };
        const mesh = createBuildingMesh(subFeature);
        group.add(mesh);
      });
    }
  });

  scene.add(group);
}

loadBuildings();

如果数据来自后端接口,只需要把 fetch 地址换成你的 GIS 服务地址。例如 Node.js、Python Flask、GeoServer、PostGIS API 都可以输出 GeoJSON。

步骤七:对接 PostGIS 输出 GeoJSON

在实际项目中,建筑、道路、地块通常存储在 PostGIS 中。后端可以用 SQL 将结果转成 GeoJSON,再返回给前端。

SELECT json_build_object(
  'type', 'FeatureCollection',
  'features', json_agg(
    json_build_object(
      'type', 'Feature',
      'geometry', ST_AsGeoJSON(ST_Transform(geom, 4326))::json,
      'properties', json_build_object(
        'name', name,
        'height', height,
        'floor', floor,
        'landuse', landuse
      )
    )
  )
) AS geojson
FROM planning_buildings
WHERE project_id = 'demo_001';

如果前端已经约定接收 EPSG:4326,则后端用 ST_Transform 转到 4326。若前端直接使用米制投影坐标,则可以输出项目投影坐标,但必须在接口文档中写清楚坐标系。

步骤八:添加鼠标点击查询 GIS 属性

Three.js 中可以使用 Raycaster 做拾取。点击建筑后读取 mesh.userData,即可显示名称、高度、楼层等属性。

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

function onClick(event) {
  const rect = renderer.domElement.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 buildingGroup = scene.getObjectByName('buildings');
  if (!buildingGroup) return;

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

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

    console.log('建筑名称:', props.name);
    console.log('建筑高度:', props.height);
    console.log('楼层:', props.floor);
    console.log('用地类型:', props.landuse);
  }
}

renderer.domElement.addEventListener('click', onClick);

如果需要在页面上显示弹窗,可以用普通 HTML 面板展示属性,不一定要在 Three.js 中绘制文字。这样更清晰,也更容易适配移动端。

常见坑:Three.js地理空间可视化最容易出错的地方

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

经纬度单位是度,不是米。纬度方向 0.001 度和经度方向 0.001 度对应的实际距离不一样,而且随纬度变化。直接使用经纬度会导致比例错误。正确做法是先投影,再转局部坐标。

坑二:坐标数值太大导致模型抖动

WebGL 使用浮点数计算,坐标值过大时容易出现精度问题。即使数据已经是米制投影坐标,也不要直接把几百万级的坐标传给 Three.js。应减去项目中心点,转换为局部坐标。

坑三:Polygon 环方向和孔洞处理不完整

GeoJSON Polygon 的第一个环通常是外环,后续环可能是孔洞。简单 Demo 只处理外环没有问题,但遇到带庭院、内洞的建筑时会显示错误。生产项目需要将内环作为 Shape 的 holes 处理。

坑四:高度字段单位不统一

有的建筑数据 height 是米,有的只有 floor 楼层数,有的高度字段为空。如果直接拉伸,会出现极高或极低的异常建筑。建议统一规则:优先使用 height;没有 height 时用 floor 乘以标准层高;仍然为空时使用默认高度。

坑五:一次性加载过多 GeoJSON

GeoJSON 可读性好,但体积较大。若建筑超过数万栋,前端解析和生成 Mesh 都会变慢。此时应考虑空间分块、按视域加载、简化几何、合并材质、InstancedMesh 或改用三维瓦片方案。

方法比较:Three.js、Cesium、Mapbox GL 和专业三维GIS怎么选

方案 适合场景 优势 限制
Three.js 定制化城乡规划三维展示、小范围园区、方案汇报系统 渲染自由度高,交互可控,前端生态成熟 GIS 能力需要自行实现,坐标和数据管理成本较高
Cesium 大范围三维地球、倾斜摄影、3D Tiles、城市级三维场景 地理坐标体系完整,适合大范围三维 GIS 界面和渲染逻辑定制成本相对较高
Mapbox GL / MapLibre GL 二维地图、矢量瓦片、轻量三维建筑 地图底图和矢量瓦片能力强,WebGIS 集成方便 复杂三维模型和深度定制不如 Three.js 灵活
专业三维 GIS 平台 城市信息模型、规划审批、BIM+GIS 融合 功能完整,数据管理和业务流程成熟 成本较高,二次开发和前端自由度受平台影响

简单判断:如果你的项目重点是小范围规划场景、建筑体块表达和定制交互,Three.js 地理空间可视化很合适;如果需要全球尺度、真实地球曲率、海量 3D Tiles,则优先考虑 Cesium。

检查清单:上线前如何验证城乡规划三维场景是否可靠

  • 确认所有 GIS 数据坐标系一致,接口文档写明 EPSG 编码或投影参数。
  • 确认前端坐标转换函数只使用一套原点和轴向规则。
  • 抽查 3 到 5 个建筑的平面位置,与 QGIS 或 ArcGIS Pro 中的位置一致。
  • 抽查建筑高度,确认 height 字段单位为米。
  • 检查地块、道路、建筑之间是否存在明显错位。
  • 使用浏览器性能面板观察加载时间、帧率和内存占用。
  • 对大数据量图层启用分块加载或按需加载。
  • 为点击查询保留 feature id,便于从前端追溯到后端数据库记录。
  • 对异常几何进行预处理,避免自相交 Polygon 导致拉伸失败。
  • 在不同屏幕尺寸下测试相机初始视角和交互体验。

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

Three.js 可以直接加载 Shapefile 吗?

不建议在前端直接加载 Shapefile。Shapefile 包含多个文件,编码、投影和几何解析都比较麻烦。更推荐在后端或桌面 GIS 中先转换为 GeoJSON、矢量瓦片或接口服务,再由 Three.js 前端加载。

Three.js 地理空间可视化一定要使用 EPSG:3857 吗?

不一定。EPSG:3857 适合 Web 地图场景,但城乡规划项目更推荐使用当地米制投影坐标,例如国家标准坐标系下的高斯投影或项目指定坐标系。关键是单位要稳定,比例要正确,并且转换为局部坐标后再进入 Three.js。

建筑拉伸后为什么方向倒了?

通常是坐标轴约定不一致导致的。Three.js 中 y 轴常作为高度,但 ExtrudeGeometry 默认拉伸方向可能与你的场景约定不同。可以通过 rotateX、坐标取负或统一 x、y、z 映射规则解决。不要在不同图层中使用不同规则。

GeoJSON 文件太大,Three.js 加载很慢怎么办?

可以从四个方向优化:一是用 QGIS 或 PostGIS 简化几何;二是按项目范围、行政区或网格分块;三是只加载当前视野附近数据;四是合并材质和几何,减少 Draw Call。若数据达到城市级规模,应考虑 Cesium 3D Tiles 或矢量瓦片。

如何把道路也做成三维效果?

如果道路是线数据,可以用 Line 或 TubeGeometry 表达;如果需要真实宽度,建议在 GIS 端先把道路中心线缓冲为面,再在 Three.js 中作为平面 Mesh 显示。规划展示中,道路通常不需要明显高度,只要稍微高于地面,避免 z-fighting 即可。

Three.js 能和 PostGIS 实时联动吗?

可以。常见架构是 PostGIS 存储空间数据,后端接口根据项目范围或视野范围查询数据并返回 GeoJSON,前端 Three.js 负责渲染。需要注意空间索引、接口分页、数据简化和缓存,避免一次性把全库数据传给浏览器。

结论:用Three.js做城乡规划三维场景,重点是GIS数据工程

Three.js 地理空间可视化的核心并不只是三维渲染,而是 GIS 数据到 WebGL 场景之间的可靠转换。对于城乡规划三维场景,建议按“数据清洗、坐标转换、局部原点、建筑拉伸、属性绑定、性能优化”这条主线实施。

如果项目范围较小、交互需求强、界面需要高度定制,Three.js 是非常灵活的选择;如果项目涉及城市级海量三维数据、真实地球坐标和 3D Tiles,则应优先评估 Cesium 或专业三维 GIS 平台。掌握本文这套流程后,你就可以把建筑、道路、地块等 GIS 数据稳定接入 Three.js,构建可用于规划展示和方案分析的三维场景。