上一份工作接手一个园区的三维可视化大屏,需求方丢过来一句"要能转、能放大、能点开看数据",我当时第一反应是 Three.js,第二反应是算了。山体、建筑、管线这些东西得挂在真实地理坐标上,还要有地形和影像底图,用 Three.js 等于从地图投影开始自己搓一套轮子。换成 Cesium.js 配上 Vue 做业务层,前后折腾了大概三周才算把第一版跑顺。这篇就把这几年反复用到的初始化流程、组件封装写法、打包上线踩的坑,一次整理清楚。不管你是刚接触 Cesium.js 的前端,还是已经在 Vue 项目里用得半生不熟想系统梳理一遍,下面的内容基本能覆盖从空目录到可交付的完整链路。
1. 先把边界划清楚:Cesium 到底在 Vue 项目里扮演什么角色
1.1 从一个高频误判说起
很多人第一次在 Vue 里用 Cesium.js,习惯性地把它当成一个"组件库"来对待,想着按 Vue 的思路给它传 props、监听事件、配合响应式数据自动更新。这个思路从一开始就跑偏了。Cesium 本质上是一个命令式的三维渲染引擎,它自己管理着一整套 WebGL 上下文、渲染循环、场景图和内存对象池。Vue 管的是 DOM 与数据流,Cesium 管的是画布里的像素,两者是并行运行的两套系统,只有交界处那薄薄的一层才需要打通。
想清楚这一点,后面很多设计就顺了。你不需要给 Cesium 写响应式封装,需要做的是:在onMounted里把引擎实例化出来,把它的句柄存好,之后所有的业务数据变化,通过明确的函数调用"推"进引擎,而不是指望引擎自己"看"到数据变了。我在第一个项目里就犯过这个错,把一个 entity 数组放进了ref,然后用watch深度监听去同步增删,结果数据量一上千,整个页面就卡成一帧一秒。原因很简单,Vue 的深度监听把 Cesium 内部那些互相引用的复杂对象全遍历了一遍,代价高得离谱。
1.2 两套系统各自的职责切分
说得具体一些,这段边界我一般这样切:
- Vue 负责:页面布局、工具栏、弹窗、列表、表单、路由、状态管理、接口请求、权限控制、主题切换。这些都是常规 Web 前端活,跟普通后台项目没区别。
- Cesium 负责:地球渲染、地形与影像加载、相机运动、坐标换算、实体绘制、空间拾取、时间轴与动画驱动。
- 交界层负责:初始化参数传递、业务数据转成 Cesium 实体、Cesium 事件回传给 Vue、生命周期销毁。
交界层最好收敛在一到两个 composable 文件里,比如useCesiumViewer.js,全项目只从这里进出。我见过一些项目把 Cesium 的调用散落在十来个组件里,每个组件各拿一份 viewer 引用,谁都能往里加东西,最后没人敢删代码,因为不知道删了会不会影响别处。这种项目维护成本会随着功能增加指数级上升。
1.3 方案对比与选型依据
在动手之前值得把候选方案摆出来比一比,因为三维这块一旦选错,返工代价很大。我按自己实际用过的几个方案做了个横向对照:
| 方案 | 上手成本 | 地理坐标支持 | 地形与影像 | 生态成熟度 | 适用场景 |
|---|---|---|---|---|---|
| Cesium.js | 中高 | 原生 WGS84,开箱可用 | 内置,多源可切换 | 高,社区文档完善 | 真实地理场景、大范围、需要精确坐标 |
| Three.js | 中 | 需自行实现投影 | 无,需自建 | 极高 | 小型三维展示、产品模型、无地理需求 |
| 地图 SDK 三维模式 | 低 | 依赖厂商 | 内置 | 依赖厂商生态 | 轻量业务、快速上线、可接受厂商锁定 |
| 自研 WebGL | 极高 | 全部自研 | 全部自研 | 低 | 特殊渲染需求、有专职图形团队 |
判断标准其实就三条:场景有没有真实地理含义,范围是不是超过一个园区,未来要不要接倾斜摄影或地形数据。三条里中两条,Cesium 基本就是当前最优解。它的坐标系统是按国际通用的地心直角坐标和大地坐标设计的,建筑、管线、车辆都能挂在同一个真实坐标系里,不会出现"三维模型和地图对不上"这种尴尬。
还有一点容易被忽略:Cesium 的渲染精度处理做得比较好,大范围场景下的深度冲突问题比裸写 WebGL 少很多。你在一个城市尺度上叠几十万个点,用自研方案很容易出现远处闪烁、近处穿模,Cesium 默认的深度缓冲策略能把这些基本压住。当然代价是它的内部结构复杂,出问题时排查链路长,这也是后面几节重点要讲的部分。
2. 环境搭建:从空目录到能跑起来的第一帧三维地球
2.1 创建工程与安装依赖
我用 Vue 3 + Vite 的组合,构建速度快,Cesium 的静态资源处理也简单。Vue 2 的工程也能用,只是构建配置换成 webpack 的写法,后面 2.3 节会提。
# 创建 Vue 3 工程,模板选 vue npm create vite@latest cesium-demo -- --template vue cd cesium-demo npm install # 安装 Cesium 本体 npm install cesium # 安装社区维护的构建插件,用来处理 Worker、Assets 等静态资源 npm install vite-plugin-cesium -D这里有个细节值得说清楚。Cesium 的包体积不小,压缩后主包大概在 1MB 上下,加上它依赖的一堆 Worker 脚本和 Assets 资源,一次性全量引入对首屏不友好。但它的模块划分其实挺清楚,按需引入是可行的,只是要小心内部依赖。我的做法是首屏先用动态import()把三维组件切出去,让地球模块单独成一个 chunk,主界面先出来,地球后加载,用户感知上会好很多。
// router/index.js 片段 { path: '/globe', component: () => import('../views/GlobeView.vue') }至于插件版本,建议和 Cesium 主版本保持同步升级节奏。我遇到过插件落后两三个大版本,导致构建出来的 Worker 路径和引擎内部预期不一致,控制台一堆 404,排查了半天才发现是版本没对齐。
2.2 构建配置:为什么推荐这种静态资源托管方式
vite-plugin-cesium的核心工作就两件事:把 Cesium 的Workers、Assets、ThirdParty、Widgets四个目录原样拷进构建产物,并且帮你在运行时指定一个基础路径环境变量,让引擎知道去哪里找这些资源。
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import cesium from 'vite-plugin-cesium' export default defineConfig({ plugins: [vue(), cesium()], server: { port: 5173, open: true } })为什么必须拷贝而不是直接引用node_modules?因为 Worker 脚本是运行时要动态加载的,打包器没法静态分析出来,不拷贝的话线上就是一片 404。这也是很多人在开发环境跑得好好的、一打包就白屏的根因。
2.3 不用插件时的手动配置方案
有些团队有内部构建规范,不允许引入额外插件,那就得自己处理这套资源拷贝。思路是借助静态拷贝插件,加上构建期的常量替换:
// vite.config.js —— 手动方案 import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { viteStaticCopy } from 'vite-plugin-static-copy' export default defineConfig({ plugins: [ vue(), viteStaticCopy({ targets: [ { src: 'node_modules/cesium/Build/Cesium/Workers', dest: 'cesium' }, { src: 'node_modules/cesium/Build/Cesium/ThirdParty', dest: 'cesium' }, { src: 'node_modules/cesium/Build/Cesium/Assets', dest: 'cesium' }, { src: 'node_modules/cesium/Build/Cesium/Widgets', dest: 'cesium' } ] }) ], define: { // 告诉运行时资源前缀 CESIUM_BASE_URL: JSON.stringify('/cesium') } })注意CESIUM_BASE_URL这个值必须和拷贝的目标路径一致,而且如果项目部署在子路径下(比如/app/),这里要跟着改成/app/cesium,否则生产环境照样 404。Vue 2 的工程用 webpack 时,对应的是copy-webpack-plugin加DefinePlugin,逻辑完全一样,只是写法不同。
2.4 样式引入与容器尺寸这两个隐藏雷区
样式只有一行,但漏了就会出大问题:
import 'cesium/Build/Cesium/Widgets/widgets.css'不引入这行,地球能渲染出来,但所有控件的位置、图标、半透明底衬全乱套,按钮可能叠在一起,时间轴宽度撑满屏幕。很多人排查"打包后布局异常"时往构建配置上想,其实是样式没引。
容器尺寸是第二个雷区。Cesium 初始化时读的是容器当时的高度,如果容器高度算出来是 0,画布就是 0 高,你看不到任何东西,控制台也不报错。
<template> <div class="globe-wrapper"> <div ref="containerRef" class="cesium-container"></div> </div> </template> <style> .globe-wrapper { width: 100%; height: 100%; position: relative; } .cesium-container { width: 100%; height: 100%; } </style>关键在父级链路上每一层都得有确定高度。我见过最常见的情况是:html, body, #app中间有一层用了min-height或者flex: 1,结果算出来是自适应内容高度,容器塌成 0。解决方式是在index.html或全局样式里把根链路的height: 100%补全,或者干脆给三维容器写死一个height: 100vh。
还有个更隐蔽的情况:容器在标签页里,初始是display: none,切过去的时候尺寸才出来。这时候得手动触发一次重算:
viewer.resize() viewer.scene.requestRender()我在一个大屏项目里就是因为这个,用户切到第二个标签页才看到地球,而且鼠标位置和拾取结果偏移,折腾了半天。
3. Cesium 核心概念拆解:Viewer、坐标、相机、数据源
3.1 Viewer 与 Scene 的关系,别只记构造参数
Viewer是绝大多数人接触到的第一个类,它其实是个"全家桶"。构造的时候它顺手创建了CesiumWidget、Scene、Clock、DataSourceCollection、EntityCollection,还有一堆默认 UI 控件。
const viewer = new Cesium.Viewer(container, { animation: false, timeline: false, geocoder: false, homeButton: false, sceneModePicker: false, baseLayerPicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false, selectionIndicator: false, baseLayer: false })这一长串false不是随便写的。默认控件面向的是"给普通人用的地图工具",而业务大屏里这些控件基本都用不上,留着不仅占地方,还会带来额外的资源请求和 DOM 节点。geocoder会请求外部地理编码服务,baseLayerPicker会加载默认底图列表,在内网环境下这些都是失败请求,控制台一片红。
baseLayer这个参数要注意版本差异。较新的版本用baseLayer: false表示不加载默认底图,较早的版本是imageryProvider: false。两者语义一样,写错了不会报错,只是底图照旧加载,容易让人以为参数没生效。
真正的渲染主体是viewer.scene,几乎所有跟画面有关的操作都在这里:scene.globe管地表,scene.camera管视角,scene.primitives管图元,scene.postProcessStages管后处理。搞清楚viewer是壳、scene是核,后面查文档能省很多时间。
3.2 坐标系:为什么不能把经纬度直接塞进去
Cesium 内部用的是地心直角坐标系(ECEF),单位是米。经度纬度只是人类习惯的表达方式,引擎渲染时一律转成三维空间中的点。所以任何业务坐标进引擎之前都得过一道转换:
// 经纬度 + 高度(米) => 空间直角坐标 const position = Cesium.Cartesian3.fromDegrees(116.397, 39.909, 120)这里的第三个参数是高度,相对椭球面算,单位米。建筑模型的高度、管线的埋深,都从这个参数走。
还有几个坐标概念在实际项目里躲不开:
Cartographic:弧度制的大地坐标,Cesium.Cartographic.fromDegrees()从经纬度来,经常用在需要精确算距离、算范围的场景。Cartesian2:屏幕像素坐标,鼠标事件给的就是它,拾取、绘制标注都用。Matrix4:变换矩阵,模型的姿态、缩放、旋转靠它描述。
一个踩过的坑:把经度纬度写反。fromDegrees签名是(经度, 纬度, 高度),和很多人习惯的"纬度在前"相反。写反了不会报错,结果就是点跑到地球另一边去了,第一次遇到会怀疑人生。
3.3 相机控制与视角数学
相机的三个姿态角是heading、pitch、roll,单位是弧度。heading是从正北顺时针算的方位角,pitch是俯仰角,负数表示向下俯视,roll是翻滚角,日常基本设 0。
viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(116.397, 39.909, 3000), orientation: { heading: Cesium.Math.toRadians(0), pitch: Cesium.Math.toRadians(-45), roll: 0 }, duration: 2, easingFunction: Cesium.EasingFunction.QUADRATIC_IN_OUT })duration是飞行时长,单位秒。设太短会有明显顿挫感,设太长用户等得着急。我的经验值是:跨省跳转给 2 到 3 秒,同城视角调整给 1 到 1.5 秒,层级微调用setView或者zoomTo直接跳,不要给动画。
pitch设为 -90 度就是正俯视,看起来像二维地图;-30 到 -45 度之间是大部分三维大屏的默认视角,既有立体感又能看清地面要素。这个区间我是试了很多次才定下来的,再平一点建筑全挤在一起,再陡一点地形起伏看不出来。
3.4 Entity 与 DataSource 的取舍
Cesium 提供两套绘制路径:高层 API 是Entity,底层 API 是Primitive。
Entity好用,声明式,一个对象描述一个要素,位置、样式、标签、模型全写在一起,还自带时间属性支持。缺点是要素多了性能下滑明显,每个实体都要走一遍属性求值和更新流程。实测下来,几千个实体是舒服的,上万个开始掉帧,几万个基本没法看。
Primitive性能好得多,尤其是配合GeometryInstance批量提交时,几万个点也能保持流畅。代价是写法繁琐,每个几何体要自己算位置、自己拼属性、自己管批次,样式调整没有 Entity 那么灵活。
我的选择规则很直白:交互频繁、数量在几千以内的用 Entity;纯展示、数量上万的用 Primitive 或者PointPrimitiveCollection。如果两类都有,就分开管理,别混在一个集合里。
DataSource是 Entity 的容器,最实用的场景是批量加载标准地理数据格式:
const dataSource = await Cesium.GeoJsonDataSource.load('/data/region.json', { stroke: Cesium.Color.CYAN, fill: Cesium.Color.CYAN.withAlpha(0.25), strokeWidth: 2 }) viewer.dataSources.add(dataSource) await viewer.zoomTo(dataSource)GeoJson、KML、CZML 都有对应的 DataSource,省去了手动解析和构图的活。zoomTo会自动算包围盒并调整相机,加载完直接定位过去,体验上很自然。
4. 落地实现:封装一个可复用的 Vue 3 Cesium 组件
4.1 组件骨架与初始化参数
我把三维部分统一封成一个组件,对外只暴露必要的配置项和事件。骨架大概是这样:
<template> <div ref="containerRef" class="cesium-container"></div> </template> <script setup> import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue' import * as Cesium from 'cesium' import 'cesium/Build/Cesium/Widgets/widgets.css' const props = defineProps({ token: { type: String, default: '' }, initialCamera: { type: Object, default: () => ({ lng: 116.397, lat: 39.909, height: 8000 }) } }) const emit = defineEmits(['ready', 'pick']) const containerRef = ref(null) // 关键:shallowRef,不要让 Vue 深度代理 Cesium 对象 const viewer = shallowRef(null) onMounted(async () => { if (!containerRef.value) return if (props.token) { Cesium.Ion.defaultAccessToken = props.token } const instance = new Cesium.Viewer(containerRef.value, { animation: false, timeline: false, geocoder: false, homeButton: false, sceneModePicker: false, baseLayerPicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false, selectionIndicator: false, requestRenderMode: true, maximumRenderTimeChange: Infinity, baseLayer: false }) instance.scene.globe.depthTestAgainstTerrain = true instance.camera.setView({ destination: Cesium.Cartesian3.fromDegrees( props.initialCamera.lng, props.initialCamera.lat, props.initialCamera.height ) }) viewer.value = instance emit('ready', instance) }) onBeforeUnmount(() => { if (viewer.value && !viewer.value.isDestroyed()) { viewer.value.destroy() } viewer.value = null }) </script> <style scoped> .cesium-container { width: 100%; height: 100%; } </style>shallowRef那一行是整段代码里最重要的。用普通ref,Vue 会递归地把 Cesium 内部对象全部包成 Proxy,触发次数和内存占用会成倍增长,轻则卡顿重则直接崩浏览器。这类"外部大型对象"一律shallowRef或markRaw,这是我在第二个项目才彻底改过来的习惯。
4.2 响应式数据怎么推给引擎才不别扭
前面说了不要 watch Cesium 对象,那业务数据变化怎么办?答案是走显式的函数调用。在父组件里监听自己的业务数据,变化时调用子组件暴露出来的方法:
// 父组件 const globeRef = ref(null) watch(() => deviceList.value, (list) => { globeRef.value?.updateDevices(list) }, { deep: true }) // 子组件通过 defineExpose 暴露 defineExpose({ updateDevices, focusDevice, clearAll })子组件里的updateDevices先清掉旧实体再重建:
function updateDevices(list) { const instance = viewer.value if (!instance) return instance.entities.removeAll() list.forEach(item => { instance.entities.add({ id: `device-${item.id}`, name: item.name, position: Cesium.Cartesian3.fromDegrees(item.lng, item.lat, item.height || 0), point: { pixelSize: item.alarm ? 14 : 10, color: item.alarm ? Cesium.Color.RED : Cesium.Color.ORANGE, outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY }, label: { text: item.name, font: '14px sans-serif', fillColor: Cesium.Color.WHITE, pixelOffset: new Cesium.Cartesian2(0, -26), showBackground: true, backgroundColor: new Cesium.Color(0, 0, 0, 0.6) } }) }) instance.scene.requestRender() }disableDepthTestDistance设成无穷大,是为了让点始终画在地形和建筑上方,不会被山挡住。报警点用红色并加大像素,视觉上一眼能扫到。这些看起来是小事,但用户在真实使用中就是靠这些差异快速定位问题。
数据量上千时,removeAll加重建的开销会变得明显。这时候改成增量更新:维护一个 id 到 entity 的映射,只处理新增、删除、变化的项。我一般在这个量级就开始做增量,不然滑动列表时地图会明显一顿。
4.3 加载业务数据的几种典型形态
除了点位,常见的还有面状区域、线状管线、三维模型。区域用 GeoJSON 加载最省事,管线用PolylineGraphics,模型用ModelGraphics。
// 管线 instance.entities.add({ id: 'pipe-001', polyline: { positions: Cesium.Cartesian3.fromDegreesArrayHeights([ 116.390, 39.905, 0, 116.400, 39.910, 0, 116.408, 39.912, 0 ]), width: 4, material: Cesium.Color.CYAN, clampToGround: false } })clampToGround: true会让线贴地,看起来更自然,但对地形精度依赖大,地形数据粗糙时线会一段段陷进地里。我的做法是:管线埋深用真实高度,不做贴地;地面上可见的路径用贴地。
模型加载要注意坐标系和比例。业务给的模型通常是本地坐标,得先确认建模时用的坐标系和单位,再决定是否需要额外的变换矩阵。我遇到过一次模型尺寸差了一千倍,原因是导出时单位是毫米而引擎按米算,加个scale: 0.001就解决了。
4.4 点击拾取与 Vue 层面的联动
拾取是三维大屏最核心的交互。流程是:Cesium 拿到点击事件,从画布上"射线"找到对应的要素,把要素 id 抛给 Vue,Vue 去弹窗或者高亮。
let handler = null function bindPick(instance) { handler = new Cesium.ScreenSpaceEventHandler(instance.scene.canvas) handler.setInputAction((movement) => { const picked = instance.scene.pick(movement.position) if (Cesium.defined(picked) && picked.id) { emit('pick', { id: picked.id.id, name: picked.id.name }) } else { emit('pick', null) } }, Cesium.ScreenSpaceEventType.LEFT_CLICK) }scene.pick会拾取所有可拾取对象,包括影像图层和地形。如果只想拾取自己加的实体,可以用scene.drillPick拿到结果数组后过滤,或者给拾取加条件判断。
ScreenSpaceEventHandler是手动创建的,销毁时必须单独处理,否则组件卸载后事件还在,会持有一份 canvas 引用,造成泄漏:
onBeforeUnmount(() => { if (handler) { handler.destroy() handler = null } })另外,picked.id.id和picked.id.name这两个属性容易混。id是你添加实体时指定的字符串,name是显示名。我习惯用 id 做业务主键,name 只用于展示,这样点击后能直接拿去查详情。
4.5 销毁与内存治理,这块最容易被跳过
三维项目跑久了页面变卡,八成是没做好销毁。要处理的东西有这几样:
viewer.destroy(),会释放 WebGL 上下文、移除 DOM、清理内部集合- 手动创建的
ScreenSpaceEventHandler - 自己起的定时器、
requestAnimationFrame循环 - 订阅的事件监听,比如相机变化监听
- 组件里缓存的大数组、模型对象引用
WebGL 上下文数量是浏览器级别的硬限制,Chrome 大概同时存在十几个。如果每次进入页面都新建一个 viewer 而不销毁,进进出出几次就会看到"上下文丢失"的提示,画面全黑。而且 Cesium 内部有个destroy前的检查,重复调用会抛错,所以销毁前加isDestroyed()判断是有必要的。
如果项目里三维页面会被频繁切换,更稳的做法是不销毁 viewer,而是复用它,只清空数据。用一个全局单例持有 viewer,切换时把它挂到不同容器的位置上。这样能完全绕开上下文创建销毁的开销。代价是内存常驻,需要自己管好数据清理。
5. 打包上线必踩的坑与排查手册
5.1 打包后白屏、静态资源 404
这是出现频率最高的一类问题,表现形式是开发正常、构建产物打开一片黑或者只有星空。排查顺序我一般这样走:
第一步,看控制台有没有一堆 404,路径里带Workers、Assets字样。有的话就是静态资源没拷过去,回去检查构建配置里的拷贝目标和CESIUM_BASE_URL。
第二步,看路径前缀对不对。部署在子目录时最容易出这个问题,/cesium/xxx.js请求到了站点根目录去了。这时候把CESIUM_BASE_URL改成带前缀的相对路径,或者用import.meta.env.BASE_URL拼出来。
第三步,看是否存在重复引入两份 Cesium。有时候是因为某段代码直接引了cesium/Build/Cesium/Cesium.js全量包,而其他地方走的是 npm 模块,两份实例同时初始化,WebGL 上下文直接冲突。统一走 npm 模块引入,能规避这类问题。
第四步,看网络面板里widgets.css有没有加载成功。打包时如果样式被单独抽取到一个未被引用的 chunk,页面就不会带上它,控件布局会全乱。
5.2 打包后布局异常,重点查这几处
"打包后布局异常"这个说法很笼统,实际有好几种不同的症状,对应完全不同的原因。
一种是控件错位、图标巨大。多半是widgets.css没进来,或者被其他全局样式覆盖了。Cesium 控件的类名前缀统一是cesium-,如果你的项目里有类似* { box-sizing: ... }或者对div的全局尺寸设定,可能就把控件顶歪了。检查办法是在浏览器里直接看控件的计算样式,一眼能看出哪条规则生效了。
一种是画布尺寸不对,比如只有一半宽,或者高度算成了 0。这通常是容器尺寸问题,跟构建无关。重点看容器父级链路上有没有height: 100%断掉,或者是否有元素在初始化时还是display: none。
还有一种最隐蔽的:大屏项目为了适配不同分辨率,外层用了transform: scale(0.8)之类的整体缩放。这会导致 Cesium 内部的鼠标坐标换算跟视觉位置对不上,点击位置偏移。原因是scale改变的是视觉呈现,而画布内部拿到的clientX/clientY是未缩放的原始值。解决办法有三个:一是去掉整体缩放,改用vw/vh或者rem做响应式;二是缩放后手动修正拾取坐标,把偏移量按比例还原;三是把 Cesium 容器放在缩放层之外单独处理。我个人推荐第一种,从源头避免。
5.3 性能问题的几个高发点与优化手段
三维场景卡顿,原因通常在渲染负载和主线程阻塞两类里。
渲染负载方面,最直接的手段是开启按需渲染:
const viewer = new Cesium.Viewer(container, { requestRenderMode: true, maximumRenderTimeChange: Infinity })开启之后,场景只在内容变化或你主动调用scene.requestRender()时才重绘,静止时几乎不占 GPU。大屏场景经常是"看的时候在动,不看的时候静止",这个开关收益非常明显,我测过几个项目,静止状态下 GPU 占用能降八成以上。代价是每处数据变更后都得记得调用一次requestRender(),忘了就会出现"数据加了但画面没变"的诡异现象。
数据负载方面,控制实体的数量和复杂度。点要素用PointPrimitiveCollection,面要素合并成少量几何体,标签用LabelCollection而不是每个实体带一个 label。贴图分辨率也别无脑堆高,一张 4096 的贴图在低端设备上能明显拖慢帧率。
主线程阻塞方面,常见的是在渲染循环里做重计算,或者一次性构造几万个实体。我的做法是分批构建,每批几百个,用requestIdleCallback或者setTimeout(0)串起来,界面上加个进度提示,用户能接受,主线程也不会长时间卡死。
5.4 问题速查表
把上面这些症状和成因整理成一张表,出问题时按表对照,能省不少排查时间。
| 症状 | 高概率原因 | 处理方向 |
|---|---|---|
| 打包后纯黑,控制台有资源 404 | 静态资源未拷贝或路径前缀错 | 检查拷贝配置与CESIUM_BASE_URL |
| 画面正常但控件错位、图标变形 | widgets.css未引入或被全局样式覆盖 | 补样式引入,排查全局选择器冲突 |
| 画布高度为 0,看不到地球 | 容器父级链路高度断掉 | 补齐根链路height: 100% |
| 点击位置偏移、拾取不准 | 外层元素transform缩放 | 去缩放或修正拾取坐标 |
| 静止时 GPU 占用高 | 未开启按需渲染 | 打开requestRenderMode |
| 运行越久越卡,最后黑屏 | Viewer 未销毁,WebGL 上下文耗尽 | 组件卸载时销毁,或复用单例 |
| 数据更新了但画面没变 | 按需渲染模式下未请求重绘 | 每次变更后调scene.requestRender() |
| 切标签页回来后画面错乱 | 容器尺寸变化未同步 | 调用viewer.resize() |
| 点飞到地球另一侧 | 经纬度参数写反 | 核对fromDegrees参数顺序 |
| 线状要素一段段陷进地面 | 贴地渲染与地形精度不匹配 | 关闭贴地或提高地形精度 |
6. 几个真实场景下的参数调优经验
6.1 瓦片层级和相机高度的换算关系
选初始视角的时候,经常需要判断"相机放多高能看到多大范围"。这里有个粗略换算可以借用:在墨卡托投影体系下,层级 L 对应的地面分辨率大约是156543.03 / 2^L米每像素,这个值是赤道位置的,纬度越高实际分辨率越小。
拿它反推,如果屏幕高 900 像素,相机高度约为:
高度 ≈ 分辨率 × 屏幕高度比如想让一个 3 公里的园区铺满屏幕,用 900 像素高来算,需要分辨率约 3.3 米每像素,对应层级大概 15 到 16 之间。那么相机放在 3000 米左右高度俯视,视觉上差不多就是这个效果。这只是估算,实际还要看俯仰角和视场角,但拿来定初值足够用了,比盲目试参数快得多。
我一般会把这个换算写成一个工具函数,在项目里给几个常用的缩放级别预设好高度值,比如"全省视角 200 公里""全市视角 30 公里""园区视角 3 公里",工具栏按钮直接调,用户点击后体验很顺。
6.2 海量点位的渲染策略选择
点要素上量之后,光是换渲染方式还不够,得配合显示策略。
第一层是距离过滤。远处的点没必要画出来,根据相机高度动态设置显示阈值,相机拉高时只显示重要度高的点。这个逻辑放在camera.changed事件里做,节流一下别每次都算。
第二层是聚合。位置接近的点合并成一个带数量的聚合点,点进去再展开。Cesium 本身没有内置聚合,得自己按空间网格算,或者借助EntityCluster。EntityCluster用起来简单,给实体集合开启聚合加上像素范围就行:
instance.entities.add({ // ... }) // 也可在 DataSource 层面打开 dataSource.clustering.enabled = true dataSource.clustering.pixelRange = 40 dataSource.clustering.minimumClusterSize = 5pixelRange是聚合的屏幕像素半径,值越大聚得越狠,一般 40 到 60 之间比较合适。太小了聚不起来,太大了点会黏成一坨看不出分布。
第三层是分页加载。业务数据上万条时,别一次全拉,按可视范围请求。相机移动后重新计算可视范围,再向后端要这个范围内的数据。这个方案在真实项目里效果最好,但需要后端配合,接口得支持范围查询。
6.3 时间轴、动画控件的取舍与替代方案
Cesium 自带的时间轴和动画控件功能完整,但对业务大屏来说往往过于"专业"。它面向的是轨迹回放、卫星轨道这类需要精确时间控制的场景,而业务大屏通常只需要"播放、暂停、倍速"三个按钮。
我的做法是关掉内置控件,用 Vue 自己写一套工具栏,通过接口去操作时钟:
const clock = instance.clock clock.shouldAnimate = true clock.multiplier = 4 clock.currentTime = Cesium.JulianDate.fromDate(new Date()) // 监听时间变化,把当前时间同步到界面 instance.clock.onTick.addEventListener((c) => { currentTimeText.value = Cesium.JulianDate.toDate(c.currentTime).toLocaleTimeString() })这样界面风格和业务系统统一,操作逻辑也更简单。注意onTick是每帧都触发的,在里面做重活会直接拖垮帧率,一定要节流,比如限制到每秒更新一次文本。
clock.multiplier是时间倍速,负数可以倒放。做设备历史轨迹回放时,配合 CZML 数据里的时间戳,能做出类似"倒带"的效果,实测比一次性把轨迹画成静态线要直观得多。
最后分享一个我这几年的真实体会:Cesium 这个库的坑,绝大多数不在它自己身上,而在于"用什么姿势把它接进前端框架"。我见过太多项目把精力花在调渲染参数上,结果真正的问题出在响应式代理、样式隔离、资源路径这些前端范畴的事情上。所以我的建议是,先把 Vue 和 Cesium 的边界那一层封得干干净净,一个 composable 管初始化,一个组件管渲染,事件和数据的出入口都收口到明确的方法上。这一层立住了,后面不管是加倾斜摄影、加管线、加轨迹,都只是在既有框架里填内容,改动可控。反过来,如果一开始就让 Cesium 的调用散在十几个组件里,那么每加一个功能,你都得多花一倍时间去确认没改坏别的地方。