【GIS系列】打造3维GIS数字孪生效果系统:Cesium+Mapbox+SpringBoot完美实现解析
三年前,我开发了一个基于 Cesium + Mapbox + Spring Boot 的3维GIS系统项目,整体实现效果非常不错。遗憾的是,由于非技术层面的原因,该项目在即将交付时未能如期上线。尽管如此,这个项目曾经还是成为了我面试中的重要亮点,并帮助我获得了许多工作机会。最近,与我一同开发该项目的前端同事联系我,表示希望获取源码用于面试演示(当时整个系统仅由我们两人开发,他负责纯前端界面,我负责全部后端逻辑和 GIS 前端代码的编写)。经过深思熟虑,我决定将这个系统开发过程中的技术经验进行总结与分享,希望为有类似需求的读者提供一些参考与启发。
目录
1. 前言
2. 一些踩坑和吐槽(不感兴趣的可以直接跳过)
3. 实现效果演示
4. 核心技术讲解
4.1. 场景构建
4.2. 表结构及核心代码讲解
4.2.1. 表结构
4.2.2. 场景加载
4.2.2.1. 初始场景加载
4.2.2.2. 加载所有建筑
4.2.2.3. 建筑与窗户分离场景加载(单体化)
4.2.3. 住户信息绑定
4.2.3.1. 绑定初始化
4.2.3.2. 绑定窗户
5. 结语
1. 前言
在数字化转型和智慧城市建设的浪潮中,数字孪生技术逐渐成为提升数据可视化与精准监管能力的重要工具,在各行业的应用前景日益广泛。本文系统初衷是设计一套数据管理系统,以实现对社区特殊人群的精细化管理。我们为此构建了一套基于 Cesium、Mapbox 和 SpringBoot 的数字孪生效果系统,能够通过直观的方式对特殊人群的居住情况进行有效监管。
2. 一些踩坑和吐槽(不感兴趣的可以直接跳过)
环境的坑:
这是三年前的项目了,光是把它跑起来就让我费了不少力气。首先是 Vue 环境的问题:三年前,我的 Node.js 版本在当时算是“高配”,但后来我渐渐不写前端了,前端技术栈也开始“躺平”。去年我写了一个工作流系统,用的 Node.js 是 10 的版本,Vue 也还是 Vue2,老得都快发灰了。而这个3维GIS系统用的是 Vue3,导致前端依赖始终拉不下来,急得我头都大了。
好在,经过一番折腾,我发现了解决之道:nvm!(https://github.com/nvm-sh/nvm)真是个好东西,堪比穿越时空的工具箱,直接把 Node.js 环境切换到项目需要的版本,问题就迎刃而解了。
Mapbox的坑:
还有就是Mapbox底图的问题。如下图所示,建筑下面的底图是Mapbox底图。
把项目跑起来后,我发现 Mapbox 的 token 竟然过期了。行吧,登录 Mapbox 官网去获取新的 token。然而,让我懵圈的是,现在居然需要绑定个人信息才能获取(三年前完全没有这种操作啊!)。我实在不想绑这些东西,只好辗转用了些“特殊手段”搞了一个 Mapbox 账号。
不过,这也没能让我逃过下一个坑——之前的系统底图没了!没办法,只好换了一个现成的底图将就用。好在最终项目总算跑起来了,虽然和当初的效果略有不同,但对我来说这些都不重要,关键是要把实现系统的思路分享给大家。
3. 实现效果演示
这个系统的开发初衷是客户要求实现一个具有数字孪生效果的三维GIS系统,可以查看社区下特殊人员的汇总信息和详细信息(通过点击建筑窗户来查看对应户主的详细信息)。
整体效果:
近距离效果:
查看单栋建筑人员信息:
点击特殊人员种类后对应的窗户联动:
点击窗户查看对应户主详情:
楼栋信息管理:
绑定窗户与对应房号:
4. 核心技术讲解
4.1. 场景构建
整个场景是基于真实的 84地理坐标系 构建的,房屋模型则基于客户提供的房屋 CAD图 制作而成。当时,我们的建模小姐姐凭借强大的耐心和技术,按照 CAD 图 1:1 完美复刻了建筑模型。然而,第一版模型导入 Cesium 后,直接把界面“卡成了PPT”。原因很简单——模型太精细了。
为了解决这个问题,我们对模型进行了抽稀处理,但结果还是不理想,依然卡顿得让人抓狂。客户要求每个窗户都必须单体化,也就是说每扇窗户都是一个独立的要素(这就需要把建筑与窗户进行分离,如下图)。随着建筑数量的增加,前端的加载压力成倍增长,直接把界面“按在地上摩擦”。
最终,为了提升性能,我们不得不进行业务逻辑上的优化。初始场景中,加载的是所有建筑的简易模型(Box加贴图),只有在点击某栋建筑时,才会加载这栋建筑的精细模型和窗户的分离模型。这种按需加载的策略有效缓解了性能问题,同时也让客户的需求得到了妥善实现。虽然过程有点折腾,但也算是用汗水换来了经验。
4.2. 表结构及核心代码讲解
4.2.1. 表结构
整个系统涉及到的表一共有5张,分别为存储社区信息的zp_community、存储楼栋信息的zp_build、存储房屋信息的zp_room、存储窗户信息的zp_window,以及存储户主信息的zp_household。
以下是每张表的具体设计与作用:
1. zp_community
社区表
- 功能:存储社区的基础信息。
- 字段:
zp_community_id
:社区 ID,主键,用于唯一标识每个社区。zp_community_name
:社区名称。
- 关系:每个社区下包含多个楼栋,与
zp_build
表通过zp_community_id
建立一对多关系。
2. zp_build
楼栋表
- 功能:存储楼栋的详细信息。
- 字段:
zp_build_id
:楼栋 ID,主键,用于唯一标识每个楼栋。zp_build_name
:楼栋名称,例如“A5_10”。zp_community_id
:社区 ID,外键,关联到zp_community
表。zp_build_info
:楼栋附加信息,例如描述或备注。
- 关系:与
zp_community
表建立多对一关系,同时与zp_room
表通过zp_build_id
建立一对多关系。
3. zp_room
房屋表
- 功能:存储房屋的具体信息。
- 字段:
zp_room_id
:房屋 ID,主键,用于唯一标识每间房屋。zp_build_id
:楼栋 ID,外键,关联到zp_build
表。zp_unit_name
:单元名称,用于描述房屋所属单元。zp_room_name
:房屋名称,例如“101”。isbind
:房屋绑定状态,标识房屋是否与住户信息绑定。
- 关系:
- 与
zp_build
表建立多对一关系。 - 与
zp_window
表通过zp_room_id
建立一对多关系。 - 与
zp_household
表通过zp_room_id
建立一对多关系。
- 与
4. zp_window
窗户表
- 功能:存储窗户的单体化信息。
- 字段:
zp_window_id
:窗户 ID,主键,用于唯一标识每个窗户。zp_room_id
:房屋 ID,外键,关联到zp_room
表。zp_window_name
:窗户名称。
- 关系:与
zp_room
表建立多对一关系,一个房屋可以包含多个窗户。
5. zp_household
户主表
- 功能:存储住户信息。
- 字段:
zp_household_id
:住户 ID,主键,用于唯一标识每位住户。zp_room_id
:房屋 ID,外键,关联到zp_room
表。zp_household_name
:住户姓名。zp_household_identity
:住户身份,例如“户主”、“租客”、“子女”等。zp_household_phone
:联系电话。- 其他字段:性别、年龄、状态(如危险、正常)等描述住户的其他信息。
- 关系:与
zp_room
表建立多对一关系,一个房屋可对应多个住户。
通过主外键关联,整个数据表的结构从社区到楼栋再到房屋、窗户、住户,形成了一套完整的层次化管理体系:
- 社区 -> 楼栋 -> 房屋 -> 窗户:实现从宏观到微观的空间数据管理。
- 窗户 ->住户:支持窗户与业务数据(住户)的绑定。
4.2.2. 场景加载
4.2.2.1. 初始场景加载
前端代码:
initScene() {
// 初始化 Cesium Viewer
this.viewer = new Cesium.Viewer("cesiumContainer", {
geocoder: false, // 位置查找工具
homeButton: false, // 复位按钮
sceneModePicker: false, // 模式切换
baseLayerPicker: false, // 图层选择
navigationHelpButton: false,
animation: false, // 速度控制
timeline: false, // 时间轴
fullscreenButton: false,// 全屏
infoBox: false,
selectionIndicator: false
});
// 添加 Mapbox 底图
var layer = new Cesium.MapboxStyleImageryProvider({/*...*/});
this.viewer.imageryLayers.addImageryProvider(layer);
}
mapbox底图添加代码:
4.2.2.2. 加载所有建筑
前端代码:
加载所有房屋:
addAllHouse() {
// 遍历建筑物数据,初始化每个建筑
for (let i = 0; i < this.buildingName.length; i++) {
this.initHouse(
"/houseUrl" + this.buildingData[this.buildingName[i]].name + ".gltf",
Cesium.Cartesian3.fromDegrees(/*经纬度位置*/),
this.models,
this.buildingName[i]
);
}
// 添加到场景
this.addModel(this.models, this.entities);
}
这个函数的功能是批量加载所有建筑物模型到场景中。其流程为:
- 遍历 buildingName 数组,获取每个建筑物的配置信息
- 调用 initHouse() 函数,根据建筑物数据创建模型对象,包含:
- 模型路径(gltf文件)
- 位置信息(经纬度坐标)
- 模型数组引用
- 建筑物ID
- 将创建的模型对象存入 models 数组
- 最后调用 addModel() 函数,将所有模型一次性添加到 Cesium 场景的 entities 中进行渲染
这是场景初始化时的重要函数,负责将所有建筑物的 3D 模型加载并显示到地图上。整个流程是:配置数据 -> 模型对象 -> 场景渲染的转换过程。
添加 3D 模型到 Cesium 场景函数:
addModel(models, entities) {
if (models && models.length > 0) {
for (let i = 0; i < models.length; i++) {
let heading = Cesium.Math.toRadians(65);
var pitch = 0;
var roll = 0;
var hpr = new Cesium.HeadingPitchRoll(heading, pitch, roll);
var orientation = Cesium.Transforms.headingPitchRollQuaternion(
models[i].position,
hpr
);
this.locatEntity = entities.add({
name: models[i].name,
id: models[i].id,
position: models[i].position,
orientation: orientation,
model: {
color: null,
silhouetteColor: null,
silhouetteAlpha: 0,
silhouetteSize: 0,
show: true,
uri: models[i].url,
scale: 1.0, // 缩放比例
minimumPixelSize: 1, // 最小像素大小
maximumScale: 1, // 模型的最大比例尺大小。 minimumPixelSize的上限
incrementallyLoadTextures: true, // 加载模型后纹理是否可以继续流入
runAnimations: true, // 是否应启动模型中指定的glTF动画
clampAnimations: true, // 指定glTF动画是否应在没有关键帧的持续时间内保持最后一个姿势
// 指定模型是否投射或接收来自光源的阴影 type:ShadowMode
// DISABLED 对象不投射或接收阴影;ENABLED 对象投射并接收阴影;CAST_ONLY 对象仅投射阴影;RECEIVE_ONLY 对象仅接收阴影
shadows: Cesium.ShadowMode.ENABLED,
heightReference: Cesium.HeightReference.NONE,
},
});
}
}
}
4.2.2.3. 建筑与窗户分离场景加载(单体化)
前端代码:
loadClickHouse(buildingId) {
// 清除现有实体
this.viewer.entities.removeAll();
// 加载分离的窗户模型
axios.get("/redisWindow/windowUrls/" + buildingId)
.then((res) => {
// 加载建筑主体
this.initHouse(/*...*/);
// 加载每个窗户
const urlArr = res.data[buildingId];
for (let i = 0; i < urlArr.length; i++) {
this.initClickWindow(/*...*/);
}
});
}
这个函数实现了点击建筑物后的分户展示功能。其流程为:
- 开启加载状态,设置 2 秒的加载动画
- 清空场景中现有的实体(viewer.entities.removeAll())和模型数组(models = [])
- 通过 axios 请求 /zz/redisWindow/windowUrls/{buildingId} 获取该建筑的窗户数据
- 请求成功后:
- 先加载建筑主体模型(initHouse())
- 遍历窗户数据数组,逐个加载窗户模型(initClickWindow())
- 将所有模型添加到场景(addModel())
- 添加建筑标签和按钮标签
- 最终实现建筑物从整体模型向可交互的分户模型的转换
这是实现建筑物点击交互的核心函数,将整体建筑拆分为可独立操作的窗户单元,为后续的窗户选择和信息绑定提供基础。
后端代码:
在后端我把窗户模型丢到了redis中进行存储,对应的接口为:
public Map<String,Set> getWindowUrls(String buildBH) {
Map<String,Set>windowUrls=new HashMap<>();
Set<Object> windowBH=redisUtils.sGet(buildBH);
windowUrls.put(buildBH,windowBH);
return windowUrls;
}
redis中存的是建筑下对用的窗户编号以及窗户编号对应的gltf模型信息:
A5_10为建筑编号, A5_10_w_1为窗户编号
4.2.3. 住户信息绑定
4.2.3.1. 绑定初始化
前端代码:
bindWindow(viewer, entities, bindWindows) {
// 移除其他事件处理器
this.removeAllHandler();
// 创建绑定事件处理器
this.handlerBind = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
// 处理 Ctrl + 点击事件
this.handlerBind.setInputAction(
function (movement) {
if (this.isInit == 2) { // 绑定模式
var pick = viewer.scene.pick(movement.position);
if (pick && pick.id && pick.id._id.indexOf("_w_") != -1) {
// 处理窗户选择
windowsList.push(pick.id._id);
// 高亮显示
this.lightwindow(entities, windowsList, 0);
}
}
},
Cesium.ScreenSpaceEventType.LEFT_CLICK,
Cesium.KeyboardEventModifier.CTRL
);
}
项目使用 isInit 变量管理不同的交互状态:
- isInit = 0: 初始化状态,可点击建筑
- isInit = 1: 建筑分解状态,可查看窗户信息
- isInit = 2: 绑定模式,可进行窗户绑定
4.2.3.2. 绑定窗户
前端代码:
// 获取窗户信息
this.$axios.getHouseholdInfo({ windowId: windowId })
.then(res => {
if (res.code == 200) {
// 处理返回数据
}
});
// 提交绑定
axios.post("/room/bindWindow", {
roomId: val.name,
windowId: [...this.windowsData]
})
这段代码实现了窗户信息的查询和绑定功能。其流程为:
- 查询窗户信息:
- 通过 getHouseholdInfo 接口,传入 windowId 参数
- 获取该窗户关联的住户信息
- 如果返回 code 为 200,则处理返回的住户数据
- 绑定窗户:
- 通过 /room/bindWindow 接口提交绑定信息
- 传入房间ID (roomId) 和窗户ID数组 (windowId)
- 将选中的窗户与指定房间建立关联关系
后端代码:
public Result bindWindow(BindReq bindReq) {
String roomId=bindReq.getRoomId();
List<String>windowsId=bindReq.getWindowId();
try {
for (int i = 0; i < windowsId.size(); i++) {
roomMapper.updateWindow(roomId,windowsId.get(i));
}
} catch (Exception e) {
e.printStackTrace();
return Result.error("绑定失败");
}
return Result.ok("绑定成功");
}
5. 结语
项目历时一个半月,最终呈现了本文中所展示的效果。希望通过这篇文章的分享,能够为有类似需求的开发者提供一些实用的启示与帮助。如果你也在进行类似的3维GIS项目,或者遇到类似的技术挑战,欢迎与我分享你的经验与困惑。