Three.js地理空间可视化如何实现?城乡规划三维场景构建实战(附:GIS数据对接源码)
Three.js地理空间可视化如何实现?城乡规划三维场景构建实战(附:GIS数据对接源码)这类问题,通常不是“会不会写 Three.js”这么简单,而是要解决 GIS 数据坐标、格式转换、三维表达、性能优化和规划业务呈现之间的衔接。本文以城乡规划三维场景为例,讲清楚如何把常见 GIS 数据接入 Three.js,并构建一个可交互的地理空间可视化原型。
引言:Three.js地理空间可视化适合解决什么问题
在城乡规划、国土空间规划、城市更新、园区管理和三维管控平台中,常见需求包括:加载建筑轮廓、展示地块边界、叠加道路水系、表达建筑高度、查看规划方案影响范围等。
传统二维 WebGIS 可以解决位置查询和图层叠加问题,但当业务需要表达建筑体量、空间高度、日照遮挡、视线通廊或三维方案对比时,Three.js 地理空间可视化就很有价值。
不过,Three.js 本身不是 GIS 引擎。它擅长三维渲染,但不直接理解 EPSG 坐标系、Shapefile、GeoJSON 拓扑关系、瓦片服务或投影转换。因此,实际项目中要把 GIS 数据先处理成 Three.js 能理解的几何对象,再建立一套适合规划场景的坐标映射和图层组织方式。

背景:城乡规划三维场景为什么不能直接加载原始 GIS 数据
很多初学者会把 GeoJSON 坐标直接传给 Three.js,然后发现模型不显示、画面抖动、线条变形,或者建筑体块位置完全不对。根本原因通常有四类。
- 坐标值过大:经纬度或投影坐标直接进入 Three.js,会导致浮点精度问题,场景出现抖动或渲染异常。
- 坐标系不统一:建筑、道路、地块、水系可能来自不同坐标系,叠加前必须统一投影。
- 二维 GIS 数据没有高度:建筑轮廓一般只有平面多边形,需要根据高度字段或规划指标进行拉伸。
- 数据量过大:完整行政区 GeoJSON、复杂道路网或高密度建筑面会造成浏览器卡顿。
所以,Three.js 地理空间可视化的关键并不是“把 GeoJSON 画出来”,而是建立一条稳定的数据处理链路:GIS 数据清洗、坐标转换、局部坐标归一化、几何体生成、材质设置、交互查询和性能优化。
原理:Three.js地理空间可视化的坐标映射逻辑
Three.js 使用的是三维笛卡尔坐标系,通常用 x、y、z 表示空间位置。而 GIS 数据常见坐标有两类:经纬度坐标和投影平面坐标。
在城乡规划项目中,推荐的处理方式是:
- 把所有 GIS 数据统一到适合本地测量的投影坐标系,例如 CGCS2000 高斯投影、UTM 或本地工程坐标。
- 选取项目中心点作为 Three.js 场景原点。
- 将 GIS 坐标减去中心点坐标,得到相对坐标。
- 把二维平面坐标映射到 Three.js 的
x和z轴,把高度映射到y轴。
常用映射关系如下:
| GIS 含义 | GIS 字段或坐标 | Three.js 对应 |
|---|---|---|
| 东向坐标 | X / Longitude 投影后坐标 | x |
| 北向坐标 | Y / Latitude 投影后坐标 | z |
| 建筑高度 | height、floor、规划限高 | y |
| 项目中心点 | centerX、centerY | 场景原点偏移 |
这种方式的好处是:既保留了 GIS 数据之间的空间关系,又避免 Three.js 直接处理超大坐标值。
步骤:从 GeoJSON 构建城乡规划三维场景
步骤一:准备 GIS 数据
本文示例使用三类常见城乡规划数据:
- 建筑轮廓:Polygon 或 MultiPolygon,字段包含
height或floor。 - 规划地块:Polygon,字段包含
landuse、plot_id、far。 - 道路中心线:LineString 或 MultiLineString,字段包含
road_name、road_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 可以使用 Shape 和 ExtrudeGeometry 将二维多边形拉伸为三维建筑。下面示例处理普通 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;
}
在真实城乡规划项目中,用地分类字段可能是中文编码,例如 R2、B1、M1、G1。建议在前端建立一张用地分类颜色表,而不是把颜色写死在数据里。
步骤六:加载道路中心线
道路可以用 BufferGeometry 加 LineBasicMaterial 表达。如果要做真实道路面宽,可以在后端先用 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 和一条道路图层,把坐标、比例和属性查询跑通,再逐步扩展到完整的城乡规划三维场景。