大概在年初我接手了一个城市级三维可视化系统的存量项目。前端技术栈锁死在 Vue2地图可视化这边除了 Leaflet 和 OpenLayers 之外还需要一块能支撑倾斜摄影模型和三维瓦片场景的能力。当时摆在我面前的选择其实不多Three.js 做 GIS 底子和坐标系衔接太费劲Mapbox 在真三维模型调度上支持有限最终只能老老实实上 Cesium 加 3DTiles。这篇内容不是什么教科书式的教程而是我把整个实践过程重新捋了一遍之后整理出来的完整记录覆盖环境配置、基础加载、坐标处理、性能优化以及大量只有真正跑起来才会遇到的坑。适合接手 Vue2 Cesium 存量项目的人看也适合准备评估这套技术栈的人做参考。我会尽量把为什么这样做说清楚而不只是堆步骤。1. 技术选型阶段为什么三件套这样搭配1.1 一个真实项目带来的技术约束先说背景。这个项目是一个面向城市管理场景的存量系统核心诉求是在网页里加载大面积倾斜摄影模型做量测、标注、图层切换和基础查询。现有系统已经用 Vue2 Element UI 开发了两三年业务模块非常多不可能因为引入一个三维库就把整个前端框架升级到 Vue3。很多人会有疑问现在新项目都上 Vue3 了为什么还要研究 Vue2 和 Cesium 的配合答案很简单存量系统里 Vue2 的代码量可能已经上万行第三方插件、内部组件库、权限系统全部耦合在一起重构成本远大于兼容成本。这个项目里我选择的不是最优技术而是最合适当前团队成本的技术。如果你也是接手类似的存量项目第一步不是抱怨技术栈老而是确认哪些东西不能动哪些东西可以以最小侵入方式加进来。对比一下备选方案Three.js 虽然三维渲染能力很强但地理坐标系、海拔高程、地形数据、相机控制这些 GIS 能力基本要自己造轮子OpenLayers 擅长二维切片对接三维模型非常吃力Mapbox 对矢量切片和样式的支持很好但在倾斜摄影模型的流式加载上会被 Cesium 甩开一大截。最终选型就是用 3DTiles 作为数据组织格式用 Cesium 作为渲染引擎Vue2 只负责把场景实例管理好、把交互组件拼接起来。1.2 拆解 Cesium 和 3DTiles 的职责边界第一件需要想清楚的事是Cesium 和 3DTiles 并不是同一个层面的东西。Cesium 是一个基于 WebGL 的三维地球和地图渲染引擎它负责的是相机、场景、图层、实体对象、地形、拾取事件这些基础设施3DTiles 则是一种面向大数据量三维空间数据的开放格式它可以承载倾斜摄影模型、建筑白模、点云、实例化模型等多种数据形态。类比一下Cesium 是一台电视机3DTiles 是优盘里的电影文件。电视机负责播放、调音量、切换画面电影文件负责承载具体内容。如果两者不匹配比如数据格式版本太旧或者渲染引擎兼容性有问题播放自然不顺利。3DTiles 特别适合大规模场景的关键在于它把模型切成了多层次细节的瓦片。用户在一个地方停留时引擎只需要加载当前视野范围内、距离合适的那些瓦片而不是一次性把所有模型数据全塞进浏览器。这一点决定了 Cesium 能撑起整个城市级别的模型浏览而普通三维模型格式做不到。1.3 版本搭配锁定 Cesium 版本的惨痛教训我第一次搭这个项目的时候正是 Cesium 更新非常频繁的阶段小版本升级带来的破坏性变更并不少见。随便执行npm install cesium拿到一个最新版本很可能在后续部署时发现默认底图服务变化导致内网环境底图加载失败API 废弃后控制台警告一堆某些第三方插件基于旧版本开发无法兼容新版引擎。所以我的建议非常明确一开始就选一个适合 Vue2 3DTiles 场景的稳定版本然后将package.json里的版本号锁定等场景验证完毕再决定是否升级。锁定版本这个动作我踩过一次大坑某次因为依赖升级导致所有 3DTiles 瓦片出现渲染异常排查了很久才发现是引擎版本问题。此后我每次升级都会先在分支上完整跑一遍所有场景用例再考虑合入主干。版本匹配这件事没有标准的最佳答案因为不同时期推荐的稳定版本不一样但思路是一致的先用一个已知稳定的大版本跑通全链路不要迷信最新就是最好。2. 从空白工程到场景可见环境配置的完整链路2.1 依赖安装与版本锁定的实际操作创建 Vue2 工程没什么特别的直接用vue create生成即可。接下来是安装 Cesium 依赖npm install cesium1.9x --save注意这个1.9x是我个人实践时锁定的一个大版本区间具体小版本可以根据你接入时的稳定版为准。安装完之后不要马上写代码先把node_modules/cesium的目录结构过一遍。你会发现目录下面有Build、Source、Widgets、Assets、Workers这些目录其中Workers是给 Web Worker 用的脚本Assets是图片、shader 之类的资源Widgets是 Cesium 自带控件的样式和结构。这三个目录后面全部要在 Web 服务器上以静态资源方式提供出去。我还建议把cesium直接写到package.json的 dependencies 里并且使用精确版本号而不用^或~。因为 Cesium 的副版本升级也可能引入不兼容改动在存量系统里不值得冒这个险。如果你用 yarn 或 pnpm 可以多一层 lock 文件保护但最稳的做法还是手动锁定。2.2 Webpack 配置Cesium 静态资源与全局变量的坑Cesium 在打包层面有个特点它的.js文件会通过相对路径加载 Worker、图片和 CSS 资源而且很多资源路径是在运行时拼接出来的普通打包器不一定能正确把这些动态资源解析出来。常见的解决方案是手动拷贝 Cesium 的静态资源到项目的public目录同时告诉 Cesium 一个全局的资源根路径。在vue.config.js里我这样配置const path require(path) const CopyWebpackPlugin require(copy-webpack-plugin) const webpack require(webpack) const cesiumSource node_modules/cesium/Build/Cesium module.exports { configureWebpack: { plugins: [ new webpack.DefinePlugin({ CESIUM_BASE_URL: JSON.stringify(/cesium/) }), new CopyWebpackPlugin({ patterns: [ { from: path.join(__dirname, cesiumSource, Workers), to: cesium/Workers }, { from: path.join(__dirname, cesiumSource, Assets), to: cesium/Assets }, { from: path.join(__dirname, cesiumSource, Widgets), to: cesium/Widgets } ] }) ] }, devServer: { client: { overlay: false } } }这段配置里最重要的就是CESIUM_BASE_URL。Cesium 会在运行时通过这个全局变量拼接出 Worker 脚本、控件皮肤等资源的完整地址如果缺失你会发现场景虽然能初始化但视角控制、底图加载都会陆续出问题甚至黑白屏。CopyWebpackPlugin则把 Cesium 在运行时依赖的静态资源同步到打包后的cesium/目录部署时也保持一致。这里还有一个特别容易踩的小坑不同版本的copy-webpack-plugin配置格式不同。如果你用的是新版本数组参数可能不再被支持必须写成上面这种patterns形式。遇到插件配置报错先去查当前插件的版本别在旧写法上纠结。2.3 封装全局 Viewer 组件的最佳实践不要在每个页面里单独创建 Cesium 实例那样内存开销会翻倍而且多个实例之间的相机、事件状态很难同步。我采用的是全局单例模式在 Vue2 里封装一个CesiumViewer组件页面通过 props 传递容器 id组件的 mounted 钩子里创建 Viewerdestroyed 钩子里销毁实例。组件封装的几个关键点容器 div 必须有明确的宽高否则 canvas 初始化为 0Viewer 创建前先清空上一次的实例防止重复初始化组件销毁时调用viewer.destroy()同时移除绑定的 resize 监听通过Vue.prototype.$cesium暴露全局访问入口业务代码随时能拿到 viewer 实例。封装之后业务开发基本可以忽略怎么创建场景这件事只关注我要往场景里加什么数据。这也是 Vue 框架在三维可视化项目里最核心的价值把复杂的生命周期管理收敛到一处避免散落在路由和页面里。3. 3DTiles 加载与场景搭建从空场景到完整模型3.1 初始化 Viewer 时的几个关键选项创建 Viewer 时很多人习惯直接用默认配置结果初始化之后还要花大量时间去关掉不需要的控件。我建议从一开始就明确哪些 UI 需要、哪些不需要import * as Cesium from cesium const viewer new Cesium.Viewer(cesiumContainer, { animation: false, // 动画控件 timeline: false, // 时间轴 baseLayerPicker: false, // 底图选择 geocoder: false, // 搜索框 homeButton: false, // 回到默认视角 sceneModePicker: false, // 2D/3D切换 navigationHelpButton: false, // 操作帮助 infoBox: false, // 信息泡 selectionIndicator: false, // 选中指示 shouldAnimate: true }) viewer.scene.globe.baseColor Cesium.Color.fromCssColorString(#1d1f26)关闭这些控件除了让界面更干净还能减少不必要的 DOM 和事件绑定开销。另外如果不需要 Cesium 默认的全球地形和在线底图就给 Viewer 设置baseLayer为false然后自己叠加数据源。这个决策要看具体业务城市级场景通常需要底图辅助定位如果只是展示纯模型直接把底图关掉反而能避免无关瓦片的网络请求。3.2 加载 3DTiles 并放置到目标位置加载瓦片数据的标准流程是先创建Cesium.Cesium3DTileset对象指定 tileset.json 的地址再把它添加到scene.primitives中const tileset new Cesium.Cesium3DTileset({ url: /data/your-city/tileset.json }) viewer.scene.primitives.add(tileset) tileset.readyPromise.then(() { const position Cesium.Cartesian3.fromDegrees(经度, 纬度, 高度) const heading Cesium.Math.toRadians(角度) const hpr new Cesium.HeadingPitchRoll(heading, 0, 0) tileset.modelMatrix Cesium.Transforms.headingPitchRollToFixedFrame(position, hpr) viewer.zoomTo(tileset, new Cesium.HeadingPitchRange(0, -0.5, 500)) })这里有个非常值得强调的点modelMatrix是控制整个瓦片数据放置位置的钥匙。倾斜摄影数据在建模软件里通常使用本地坐标系导出成 3DTiles 后数据本身并没有地理参考信息。所以加载之后必须通过modelMatrix把本地坐标的原点平移到目标经纬度并通过 heading 调整朝向。一开始我直接用tileset.root.transform去改位置结果模型经常出现偏移或者旋转错乱。后来才逐步把操作收敛到modelMatrix上。两者的区别在于root.transform是数据内部坐标系的一部分而modelMatrix是整个瓦片集在外层世界坐标系中的变换业务上你只需要掌握后者。3.3 从瓦片数据中获取模型属性和图层信息3DTiles 瓦片里往往携带了属性信息比如建筑名称、楼层数、权属单位等。这些属性可以在前端直接拿到不需要额外接口。遍历属性的基础方式是通过瓦片集本身的tilePropertyNames或者直接监听点击拾取时的 feature 对象viewer.screenSpaceEventHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK) viewer.screenSpaceEventHandler.setInputAction((event) { const picked viewer.scene.pick(event.position) if (picked picked instanceof Cesium.Cesium3DTileFeature) { picked.getPropertyNames().forEach((name) { console.log(name, picked.getProperty(name)) }) picked.color Cesium.Color.YELLOW } }, Cesium.ScreenSpaceEventType.LEFT_CLICK)上面这段代码实现了点击模型区域把命中的建筑或构件高亮为黄色同时打印出它的全部属性。实际项目里你可以把特征 id 和属性做一个映射表接到业务系统的弹窗、面板、台账里。要注意的是Cesium3DTileFeature并不一定只对应一个建筑物它也可能对应一个瓦片内部的构件集合。如果发现高亮范围比预期大检查一下数据建模阶段是否把属性挂到了正确层级。数据侧的问题不要想着全靠前端调前端只能调整显示方式属性归属错误只能回去重新处理数据。4. 处理过的几个真坑加载失败、偏色、偏移与卡顿的完整排查过程4.1 瓦片数据加载失败从跨域到路径再到 MIME 类型的排查项目第一次部署到生产环境三维场景一片空白控制台提示加载 tileset.json 失败。这个问题通常不是单点原因我排查的顺序是这样的先看网络请求确认tileset.json是否能够被浏览器成功请求。如果状态码 404 或 403大概率是public目录复制数据和部署路径对不上检查打包后cesium/目录中的瓦片数据是否完整。再看请求本身是否被跨域拦截。Cesium 加载瓦片会发大量异步请求去拿b3dm、pnts文件如果静态服务器没有响应Access-Control-Allow-Origin头浏览器会直接拦截响应。开发环境下常见于把瓦片数据放在另一个端口时生产环境常见于地图服务和数据服务分离时。还有一个非常隐蔽的坑是 IIS 对.b3dm文件不认识默认按application/octet-stream处理会导致某些代理或者浏览器行为异常甚至直接下载而不是加载。配置静态服务时要把以下 MIME 类型加进去.3tz、.b3dm、.pnts、.i3dm、.cmpt、.json。这是我当时在所有静态资源服务配置里反复检查的问题最终在 IIS 的 MIME 配置里补齐之后才彻底解决。如果以上都没问题再回过来检查数据路径是否包含特殊字符或中文目录。Cesium 对 URL 的处理比较敏感目录带空格、中文或者未编码的特殊符号都可能导致请求失败。最后强调一点出现加载失败问题时不要一上来就认为是 Cesium 的问题先把网络面板里每个请求的状态码和响应头看清楚。绝大多数加载问题在数据服务和基础配置层面而不是引擎层面。4.2 模型整体偏暗偏色光照与后处理带来的反直觉现象加载成功后又发现模型整体发暗阳光下应该亮的部分灰蒙蒙的白墙变成了灰色调。这个问题一度让我怀疑是数据纹理压缩导致的。后来对比发现这是 Cesium 默认开启的色调映射和图像光照设置造成的。Cesium 在处理 HDR 渲染时会使用 tone mapping 和后处理效果这些算法默认值往往不适合所有数据。不同建模软件导出的纹理亮度差异很大如果直接套用默认光照就会出现偏暗或偏色的现象。我的调整方向是关闭不必要的后处理和基于图像的光照同时微调曝光viewer.scene.highDynamicRange false viewer.scene.postProcessStages.fxaa.enabled false viewer.scene.postProcessStages.bloom.enabled false viewer.scene.globe.enableLighting true viewer.scene.imageBasedLightingFactor new Cesium.Cartesian2(0, 0) viewer.scene.moon.show falseenableLighting开启之后模型会收到太阳光的照射看起来会真实很多但是如果你不需要真实日照角度对模型的动态影响也可以直接关掉让模型显示原始纹理颜色很多业务看板其实更希望看到的是贴近真实纹理的稳定效果。偏色问题没有唯一解最终全靠肉眼加业务需求确认切忌盲目套用网上某篇文章的参数。4.3 模型不在预期位置坐标偏移与模型矩阵的处理数据加载后没有出现在目标位置而是跑到海里或者偏移了几百米这是倾斜摄影项目里最常见的故障。原因通常是数据坐标系与前端放置坐标系不一致。一种情况是数据本身带了地理参考比如 EPSG:4547 或类似地方坐标系但 Cesium 认的是 WGS84 经纬度。这时候你要在数据源转换时就把坐标系统一好不能在前端做二次换算否则精度很难保证。另一种情况是数据是纯本地坐标建模时原点在模型中心或者某个控制点。前端要拿到这个控制点在 WGS84 下的经纬度再计算出模型的 center 点在本地坐标中的偏移量然后用modelMatrix对瓦片集进行平移旋转。实际操作中我习惯在项目里提供一个模型位置调试面板可以在界面上手动调整经纬度、高度、heading、pitch、roll 这几个参数实时看模型位置和姿态变化。调试满意之后再把参数固化到配置文件里。这个面板花费半天时间开发但后续换数据、校准模型时能节省数倍的排查成本。另外要注意瓦片集如果非常大直接使用fromDegrees定位还会遇到高度为 0 导致模型扎进地形的问题。可以先关闭viewer.scene.globe.depthTestAgainstTerrain让模型显示在处理地形之上等位置姿态调整完成后再决定是否开启地形检测。4.4 操作卡顿与内存上涨瓦片调度和监听器清理模型加载成功、位置也对上了但用户一旦快速旋转相机页面开始掉帧卡顿明显甚至内存不断上升。这个问题首先需要在排除其他因素后确认是哪一层的问题。第一步看是不是浏览器插件或业务代码拖慢排除掉无关因素后重点集中在瓦片调度上。Cesium3DTileset有一个重要参数叫maximumScreenSpaceError它决定瓦片在屏幕上占据多大误差时继续细分加载更精细的层级。值越小加载的瓦片越多、更精细也越卡值越大加载更粗速度更快。针对大面积倾斜模型我一般会先把maximumScreenSpaceError调到 8 到 16 之间的值同时设置dynamicScreenSpaceErrortileset.maximumScreenSpaceError 8 tileset.dynamicScreenSpaceError true tileset.dynamicScreenSpaceErrorFactor 4这样可以让远处的模型降低加载密度近处的模型保持清晰。你可以想象成一个画家先画大色块再逐步细化近处的细节而不是把整张画布上所有像素同时画到位。第二步是检查监听器。Cesium 的screenSpaceEventHandler、scene.preUpdate事件、以及通过viewer.clock.onTick注册的订阅如果没有在组件销毁时正确移除就会变成内存泄漏点。我在排查内存上涨时最常发现的就是每个页面都绑定了一遍鼠标事件切换路由后这些监听器还在后台运行。第三步是检查业务侧是否做了大量每帧的 DOM 操作。有些同事喜欢在onTick回调里实时更新 Vue 组件数据这样会在每秒 60 帧的频率里触发很多次 Vue 响应式数据更新性能会立刻崩盘。正确的做法是节流到每秒几次或者改为直接操作原生 DOM 相关属性。5. 从能跑到好用交互细节、调度策略和数据侧优化5.1 点击拾取、悬浮高亮和场景业务联动三维场景接入业务系统最核心的交互就是点选和关联信息。在 3.3 节里已经写了点击拾取的基础代码这里补充两个在实际业务里反复用到的细节。第一个是悬浮识别。鼠标移动时如果悬停在模型上可以改变光标形状并显示提示框需要注册MOUSE_MOVE事件viewer.screenSpaceEventHandler.setInputAction((event) { const picked viewer.scene.pick(event.endPosition) if (picked picked instanceof Cesium.Cesium3DTileFeature) { viewer.canvas.style.cursor pointer } else { viewer.canvas.style.cursor default } }, Cesium.ScreenSpaceEventType.MOUSE_MOVE)注意不要用scene.pick的返回对象直接做 比较因为它是每个拾取周期临时创建的正确方式是比较picked.primitive是否同一个瓦片集。第二个是业务联动。拿到feature后通过属性字段如id、code去匹配业务数据表然后用viewer.flyTo相机飞入某个位置。这里最佳的联动方式是给每个瓦片集的 feature 绑定一个唯一的业务主键不要在前端做模糊匹配。5.2 多个瓦片集共存时的调度优先级与按需加载策略城市级项目里通常不会只有一个 3DTiles 数据源。你可能有一个整个城市的大范围粗模几个重点区域的高精细倾斜模型还有些单独的建筑白模。多个瓦片集放进scene.primitives之后Cesium 会统一调度它们但不同瓦片集的优先级只靠添加顺序来决定不够精细。我通常在手动作业时手动管理它们的显隐特别是图层开关function setTilesetVisible(tileset, visible) { tileset.show visible // 不可见的瓦片集最好也从 primitives 中暂时移除减少调度压力 if (!visible) { viewer.scene.primitives.remove(tileset) } else if (!viewer.scene.primitives.contains(tileset)) { viewer.scene.primitives.add(tileset) } }按需加载的核心是用户能看到什么就加载什么。如果业务场景是区域概览可以先把高精度模型隐藏只显示粗模用户点击某栋楼后才动态加载对应的精细瓦片并飞入。这样做既减少初始加载时间也避免所有数据同时抢占请求带宽。还有一种做法是给瓦片集设置一个boundingSphere判断可见性但默认的包围球是由数据内容推算出来的有时候不准。如果发现某些瓦片集在远离视野时依然被加载可以手动根据业务位置构造更精确的包围球这也是一种有效的性能优化手段。5.3 数据侧预处理的几个建议前端能做的优化始终有上限。如果数据本身动辄几十个 GB瓦片切分不合理纹理没有压缩那么前端再怎么调参也只是减轻症状。这里分享我在实际项目中总结下来的数据侧建议倾斜摄影原始模型在转为 3DTiles 之前尽量做减面处理尤其是远处可以看到的侧面纹理图集尽量合并减少材质切换次数避免出现大量 1K×1K 以上的单张小纹理数据发布最好保持目录结构稳定不要频繁改动 tileset.json 内部相对路径如果数据量巨大考虑按区域切分多个 tileset前端按行政区或视野范围动态切换不同精度的数据可以做成两级 LOD粗模用于全局精模用于局部聚焦。这些建议并不是为了把数据做得多规范而是为了避免前端被无用瓦片请求淹没。好的三维可视化项目功夫有一半是在数据侧前端只是最后展示的那部分。5.4 后续扩展与一点个人体会如果你已经把 3DTiles 加载跑通接下来可以继续扩展的方向还有对瓦片进行属性过滤和条件着色、加载动态点云、接入实时定位数据、对接空间查询接口、实现剖面分析和视域分析。这些功能的底层都已经具备Cesium 在三维空间计算和渲染上提供了相当扎实的基础。我实际使用下来体会最深的还是那句话这套技术栈能跑起来不难但要在一个真实系统里稳定地跑真正难的是版本管理、资源路径、坐标体系和瓦片调度这些细节。很多问题在开发环境永远不会出现部署到客户的本地服务器上才会爆发。所以建议你尽早把部署环境和数据服务环境搭起来别在开发机上拖太久。把配置、坐标和静态资源这些基础问题提前验证一遍后续业务功能的开发会顺畅很多。