ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Cesium立体电子围栏:Vue3自定义材质实现瀑布流光墙面

Cesium立体电子围栏:Vue3自定义材质实现瀑布流光墙面 最近在做一个园区安防三维可视化的项目甲方提了个很具体的要求电子围栏不能是那种平平的二维 polygon得来一面能“立起来”的立体墙而且墙面上要有瀑布一样的流光滚动效果最好再带一道周期性扫描的高亮带。稍微懂行的人一听就明白纯 Cesium 默认 API 给不了这个得自己写材质和着色器。这篇帖子就把完整方案记录下来覆盖基于 Vue3 Cesium 的动态立体墙电子围栏实现从环境搭建、Wall 墙体几何体构建到自定义 Fabric 材质写出瀑布滚动高亮动效再到多围栏管理、性能优化和实战避坑。正在做园区、工地、机场、大屏安防类项目的朋友可以直接照着抄作业Cesium 刚入门想学自定义材质的也适合读一读。这是系列的第一篇先把墙体动效讲透。后面如果有时间我还会接着写雷达扫描、告警粒子、模型联动这些效果算是给自己项目里的公共组件留个文档。1. 需求拆解与技术选型1.1 电子围栏为什么要做成“立体墙”传统 2D 电子围栏在地图上画一个多边形填充半透明颜色就完事了。但放到 3D 场景里这种表达有几个明显短板视角一拉平地面上那块色块就几乎看不见了没有高度信息在园区楼宇之间很难表达“垂直边界”这种语义动态告警感也弱做不到让运维人员一眼看出这个区域处于布防状态。立体墙则完全不一样。它是垂直于地面的光幕相当于把二维的“圈地”变成了三维的“罩子”。在 Cesium 里最常见的实现载体就是 Wall 几何体——一面从地面向上延伸的墙体。配合动态材质后这面墙看起来就像一块流动的能量屏障非常适合表达电子围栏、飞行禁区、临时管制的视觉语义。从业务角度看立体墙对用户还有一个隐性价值它自带“高度”维度你可以在墙上叠加告警等级、布防状态这类信息。颜色一换整面墙的氛围就变了比单纯改一个多边形描边要有冲击力得多。1.2 两条实现路线Entity 还是 PrimitiveCesium 里画墙体有两条路先说结论要做瀑布滚动高亮这种自定义动效老老实实走 Primitive WallGeometry 自定义 Material别在 Entity API 里折腾。路线优点缺点适用场景Entity 的 Wall 图形代码少数据绑定方便和 pick 事件集成好材质只能用内置类型或 Color自定义 Fabric 着色器麻烦快速原型、简单纯色围栏Primitive WallGeometry能直接用自定义 Fabric 材质合并实例省 draw call控制力强代码量多需要手动管理生命周期动态流光、扫描高亮、批量围栏Entity API 的wall描述对象material属性本质上接的是 MaterialProperty 体系你直接塞一个Cesium.Material进去内部会走createMaterialProperty这一层转换改 uniforms 的路径也非常绕。而 Stereoscopic 需求比如瀑布流动本质上是 GPU 片元着色器的事用 Primitive 路线把材质对象捏在自己手里才是正统做法。2. 环境准备与 Vue3 接入2.1 初始化 Vue3 Cesium 工程我用的 Vite 模板命令很常规npm create vitelatest cesium-fence -- --template vue cd cesium-fence npm install npm install cesium接下来处理 Cesium 的静态资源。最省事的方式是用vite-plugin-cesium它会自动帮你处理 Worker、Assets、Widgets 这些静态资源的拷贝和CESIUM_BASE_URL的注入npm install -D vite-plugin-cesium然后在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()] })如果你的项目是内网离线部署没法用 Vite 插件辅助那就手动拷贝cp -r node_modules/cesium/Build/Cesium/Workers public/cesium/Workers cp -r node_modules/cesium/Build/Cesium/Assets public/cesium/Assets cp -r node_modules/cesium/Build/Cesium/Widgets public/cesium/Widgets cp -r node_modules/cesium/Build/Cesium/ThirdParty public/cesium/ThirdParty然后在入口文件里手动指定import * as Cesium from cesium import cesium/Build/Cesium/Widgets/widgets.css window.CESIUM_BASE_URL /cesium/这里容易踩的坑是Cesium 版本升级后静态资源目录结构可能微调离线包一定记得整个 Build/Cesium 目录完整拷别只拷一两个子目录否则运行时会报一堆“无法加载 Worker”之类的错。2.2 不依赖 Ion Token 的影像配置很多人第一次跑 Cesium 遇到白屏八成是默认影像服务需要 Ion Token。如果你不想注册账号直接把默认影像层清掉换成 ArcGIS 的免费影像服务viewer.imageryLayers.removeAll() const arcGis await Cesium.ArcGisMapServerImageryProvider.fromUrl( https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer ) viewer.imageryLayers.addImageryProvider(arcGis)注意新版本fromUrl返回的是 Promise需要在 async 函数里 await老版本写法是new Cesium.ArcGisMapServerImageryProvider({ url })具体看你的包版本。2.3 Viewer 初始化与 Vue3 响应式陷阱Vue3 里初始化 Viewer首先要清掉工具栏上默认那一堆按钮毕竟做业务大屏基本用不到viewer.value markRaw(new Cesium.Viewer(container, { animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, infoBox: false, selectionIndicator: false, fullscreenButton: false }))这里有个特别值得说的细节Cesium 对象千万别放进 Vue3 的 reactive 响应式系统。Cesium 内部对象图非常庞大WebGL 相关的对象更是没法做深层代理一旦被reactive包裹轻则性能肉眼可见地变慢重则直接抛异常。我习惯用shallowRef存 viewer再配合markRaw双保险import { shallowRef, markRaw } from vue const viewer shallowRef(null) onMounted(async () { viewer.value markRaw(new Cesium.Viewer(...)) // ... })组件卸载时记得销毁 viewer否则热更新反复挂载会泄漏 WebGL context浏览器标签页迟早变黑或直接崩溃onBeforeUnmount(() { viewer.value?.destroy() })3. 立体墙围栏核心实现3.1 墙体坐标与 WallGeometry 构建先准备墙体的范围坐标。Cesium 的Cartesian3.fromDegreesArray接收的是[经度, 纬度]交替的扁平数组注意是经度在前很多人第一次写反墙体直接跑到另一个半球去了const coords [ [116.391, 39.907], [116.401, 39.907], [116.401, 39.917], [116.391, 39.917] ] const degArr [] coords.forEach(p { degArr.push(p[0], p[1]) }) // 手动闭合让墙首尾相连 degArr.push(coords[0][0], coords[0][1])然后构造 WallGeometry。这里有个关键点minimumHeights和maximumHeights数组的长度必须和 positions 顶点数一致我们手动加了闭合点所以高度数组也要多一个const vertexCount coords.length 1 const geometry new Cesium.WallGeometry({ positions: Cesium.Cartesian3.fromDegreesArray(degArr), minimumHeights: new Array(vertexCount).fill(0), maximumHeights: new Array(vertexCount).fill(80), // 墙高 80 米 granularity: Cesium.Math.toRadians(0.5), vertexFormat: Cesium.VertexFormat.POSITION_AND_ST })解释一下参数墙体从地面 0 米延伸到 80 米高空这就是“立体”二字的来源。granularity控制墙体沿边界方向的细分粒度值越小顶点越多墙体越平滑但性能也越差。如果是规则的直线围栏0.5 度级别的粒度完全够用如果墙体边界是贴着道路的复杂折线可以调小到 0.01 级别。vertexFormat这里指定POSITION_AND_ST是为了给后面的自定义材质提供 UV 坐标st。注意如果你的材质要取法线做光照就得加 NORMAL但我们这个动效不需要能省就省。3.2 自定义 Fabric 材质瀑布滚动 高亮扫描核心来了。Cesium 内置材质没有“流动”这种动态效果我们需要写一个自定义 Fabric 材质。Fabric 是 Cesium 定义材质的一种 JSON 结构可以简单理解为uniforms声明传给 shader 的变量source写 GLSL 片元着色器代码。先看 st 坐标的含义st.x 是墙体沿边界方向的水平位置st.y 是墙体的高度方向0 在墙底1 在墙顶。瀑布要沿着高度方向流动所以核心操作都围绕 st.y 展开。const material new Cesium.Material({ fabric: { type: WaterfallWallMaterial, uniforms: { uBaseColor: Cesium.Color.fromCssColorString(#00E5FF).withAlpha(0.35), uFlowColor: Cesium.Color.fromCssColorString(#00FFFF), uFlowSpeed: 0.6, uBands: 4.0, uScanSpeed: 0.8, uScanWidth: 0.08 }, source: uniform vec4 uBaseColor; uniform vec4 uFlowColor; uniform float uFlowSpeed; uniform float uBands; uniform float uScanSpeed; uniform float uScanWidth; czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st; float time czm_frameNumber / 60.0; // 瀑布流动沿高度方向滚动出多层光带 float wave sin(st.x * 18.0 time * 6.0) * 0.04; float flowCoord fract(st.y * uBands wave - time * uFlowSpeed); float flowGlow 1.0 - abs(flowCoord - 0.5) * 2.0; flowGlow pow(flowGlow, 2.0); // 高亮扫描一道亮光从墙底向墙顶扫过 float scanPos fract(time * uScanSpeed); float scanGlow 1.0 - smoothstep(0.0, uScanWidth, abs(st.y - scanPos)); // 合并亮度 float lum max(flowGlow * 0.7, scanGlow); vec3 finalColor mix(uBaseColor.rgb, uFlowColor.rgb, lum); float finalAlpha uBaseColor.a lum * 0.6; material.diffuse finalColor; material.alpha finalAlpha; return material; } }, translucent: true })这段 GLSL 里有几个地方值得单独讲第一czm_frameNumber是 Cesium 注入的帧计数器每帧自动 1。用它做时间基准完全不需要在 JS 侧每帧去更新 uniform这是做材质动效最省事也最高效的方式。想换成真实秒数除 60 就行。第二fract(st.y * uBands - time * uFlowSpeed)是瀑布滚动的核心。因为 st.y 从 0 到 1乘以 uBands 后变成多段周期time 增大时这个值整体往小的方向移视觉上光带就是从墙顶往墙底“下落”——正好是瀑布的效果。你要是想做成“从下往上冒”把减号改成加号即可。加的那一行sin(st.x * 18.0 time * 6.0)是给光带边缘加了一点横向抖动让它看起来更像水流而不是机械的横条。第三flowGlow用1.0 - abs(t - 0.5) * 2.0构造了一个 0 到 1 再回落的波峰pow(..., 2.0)让波峰更集中更亮。scanGlow用smoothstep做了一道窄窄的高亮带和瀑布叠加后视觉效果就是墙体自身有流动的光幕同时还有一道明显的亮带周期性地扫过。第四最终颜色是基础色和高亮色的 mixalpha 也做了联动保证高亮扫过的地方更亮更实。如果想要更“数码屏”的感觉可以在 UNLIT 光照之外再把高亮结果叠加到material.emission上但要注意UNLIT 分支只取 diffuseemission 需要有光照模型才生效所以别把主要颜色全都塞进 emission否则在 UNLIT 下墙体直接全黑。3.3 挂载材质MaterialAppearance 与 Primitive材质写好了怎么让它生效用MaterialAppearance作为 Primitive 的外观并把材质放进去const instance new Cesium.GeometryInstance({ geometry: geometry, id: fence-demo }) const appearance new Cesium.MaterialAppearance({ material: material, materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED, lightingModel: Cesium.LightingModel.UNLIT, translucent: true }) const primitive new Cesium.Primitive({ geometryInstances: instance, appearance: appearance, cull: false, asynchronous: false, releaseGeometryInstances: true }) viewer.scene.primitives.add(primitive)几个参数逐个说清楚materialSupport: TEXTURED告诉渲染管线这个材质需要纹理坐标st这就是为什么前面 WallGeometry 要指定vertexFormat: POSITION_AND_ST。不加这个st 在 shader 里是拿不到的材质会退化成一坨纯色甚至黑色。lightingModel: UNLIT让墙体完全不受场景光照影响颜色就是我们写进去的原色。数字围栏这种“自发光屏幕感”的效果UNLIT 是最稳的选择。translucent: true必须同时出现在 Material 和 MaterialAppearance 上否则渲染器会把它当不透明物体处理透明混合、排序、深度写入都会出问题。cull: false在部分 Cesium 版本里影响视锥裁剪更重要的是配合双面显示解决“绕到墙内侧看不见墙”的问题如果设置完还是单面消失检查一下appearance.cullFace改成Cesium.CullFace.NONE强制关闭背面剔除。最后加一个相机定位方便看效果viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(116.396, 39.912, 600), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 } })4. 业务集成多围栏管理与动效控制4.1 多围栏数据管理实际项目里不可能只画一堵墙往往是十几个园区、几十个风险区域。我建议把 Cesium 对象和业务数据分开管理纯业务字段围栏 id、坐标、高度、状态放 Vue3 的reactive数组里方便表格、筛选、表单联动Cesium 的 primitive、material 这类对象放在一个普通Map里不要进 reactive原因前面讲过避免 Vue 的深层代理破坏 WebGL 对象。const fenceStore new Map() // id - { primitive, material, meta } const fenceList reactive([]) // 仅存业务字段 function addFence(data) { const { id, coords, height, color, speed } data const degArr [] coords.forEach(p degArr.push(p[0], p[1])) degArr.push(degArr[0], degArr[1]) const vertexCount coords.length 1 const geometry new Cesium.WallGeometry({ positions: Cesium.Cartesian3.fromDegreesArray(degArr), minimumHeights: new Array(vertexCount).fill(0), maximumHeights: new Array(vertexCount).fill(height || 80), vertexFormat: Cesium.VertexFormat.POSITION_AND_ST }) const material new Cesium.Material({ fabric: { type: WaterfallWallMaterial, uniforms: { uBaseColor: Cesium.Color.fromCssColorString(color || #00E5FF).withAlpha(0.35), uFlowColor: Cesium.Color.fromCssColorString(color || #00FFFF), uFlowSpeed: speed || 0.6, uBands: 4.0, uScanSpeed: 0.8, uScanWidth: 0.08 }, source: WALL_SHADER_SOURCE // 提取成公共常量 }, translucent: true }) const appearance new Cesium.MaterialAppearance({ material, materialSupport: Cesium.MaterialAppearance.MaterialSupport.TEXTURED, lightingModel: Cesium.LightingModel.UNLIT, translucent: true }) const primitive new Cesium.Primitive({ geometryInstances: new Cesium.GeometryInstance({ geometry, id }), appearance, cull: false, asynchronous: false, releaseGeometryInstances: true }) viewer.scene.primitives.add(primitive) fenceStore.set(id, { primitive, material, meta: { ...data } }) fenceList.push({ id, coords, height, color, speed }) } function removeFence(id) { const item fenceStore.get(id) if (!item) return viewer.scene.primitives.remove(item.primitive) item.primitive.destroy() fenceStore.delete(id) const idx fenceList.findIndex(f f.id id) if (idx -1) fenceList.splice(idx, 1) }4.2 布防状态切换只改 uniforms 不动几何体电子围栏最常用的业务动作是状态切换布防、撤防、告警。传统做法是删掉实体重建这很浪费。因为我们把 material 引用存在了 store 里直接改 uniforms 数组里的颜色即可function updateFenceColor(id, cssColor) { const item fenceStore.get(id) if (!item) return item.material.uniforms.uBaseColor Cesium.Color.fromCssColorString(cssColor).withAlpha(0.35) item.material.uniforms.uFlowColor Cesium.Color.fromCssColorString(cssColor) }比如布防状态用青色/绿色告警变成红色/橙色整个动画的颜色在下一帧就即时切换几何体、GPU 资源完全不用动流畅度自然就上来了。这里有个细节uniforms里存的是 Cesium Color 对象直接替换引用是没问题的Cesium 在渲染时会重新读取。千万不要图省事去改color.red这样的分量值容易遇到 Float32Array 深拷贝或颜色空间转换的坑整对象替换最干净。4.3 性能优化要点动效类场景最怕掉帧我把实际调优的经验列一下第一利用czm_frameNumber省掉 JS 每帧更新。这是性能的绝对大头。如果你用 JS 的requestAnimationFrame每帧去改 uniform 里的时间等于把 GPU 的活揽回 CPU几十个围栏就会卡。让 shader 自己拿帧号CPU 全程零负担。第二合并同类围栏的 draw call。如果多个围栏共享同一套材质和颜色可以把它们的 GeometryInstance 放在同一个数组里一个 Primitive 一次绘制const instances fencesData.map(f new Cesium.GeometryInstance({ geometry: createWallGeometry(f), id: f.id })) const primitive new Cesium.Primitive({ geometryInstances: instances, appearance: sharedAppearance, // ... })代价是这些围栏只能共享同一个外观不能各自变色。取舍逻辑很简单围栏数量少十几个以内就分开维护换颜色灵活数量多且颜色统一就合并省 draw call。第三注意requestRenderMode。如果 viewer 开启了requestRenderMode: true场景只会在有请求时重绘动画会直接卡死。要么别开要么在preUpdate事件里持续请求渲染viewer.scene.preUpdate.addEventListener(() { viewer.scene.requestRender() })排查性能问题的时候直接在开发环境打开帧率显示肉眼可见效果变化viewer.scene.debugShowFramesPerSecond true别小看这一行项目里很多人调参半天觉得卡其实数字一出来就知道瓶颈在哪。5. 实战问题排查与避坑整理5.1 常见问题速查表把我在不同版本 Cesium 上踩过的坑整理成了表格遇到问题可以对着查。现象原因解决办法墙体只有一面可见背面剔除未关闭appearance.cullFace Cesium.CullFace.NONE或cull: false墙体被地形/模型埋住minimumHeights 设成 0 低于地形表面按区域地形抬高底高或保持depthTestAgainstTerrain: false动效完全不动开了requestRenderMode但没请求重绘在preUpdate里持续requestRender()墙体全黑看不到流光材质没有拿到 st或光照模型不对确认MaterialSupport.TEXTUREDvertexFormat: POSITION_AND_STUNLIT半透明墙体排序错乱透明物体按距离粗排多个墙体互相穿插尽量让墙体之间不重叠必要时用scene.globe.translucency微调自定义材质报 type 不存在materialType 缓存里没有这个类型用new Cesium.Material({fabric})会自动注册或先手动注册一次墙体出现在别的半球经纬度数组写反了fromDegreesArray的入参顺序是[经度, 纬度]Vue3 热更新后白屏WebGL context 泄漏旧 viewer 没销毁onBeforeUnmount调viewer.destroy()或对地图组件禁用热更新围栏多后帧率骤降顶点过多或 draw call 过多调大granularity合并 GeometryInstance动态更新颜色没反应改的是 uniforms 里的分量而不是替换对象整对象替换如uniforms.uBaseColor new Cesium.Color(...)5.2 三个容易忽略的细节第一个是墙体顶点顺序。Cesium 的 WallGeometry 跟大多数前端图形库一样顶点顺序会影响面的朝向。如果墙体是从外往里看是正常的但从里往外看消失或者发黑除了开关 cullFace还要检查坐标是不是按顺时针/逆时针绕的。不同版本对 WallGeometry 的 winding 表现不完全一致最保险的做法是直接把双面显示打开别跟它较劲。第二个是 st 坐标方向的验证。材质里的瀑布方向要是反了从下往上冒别急着改 shader先把材质临时换成纯色然后对照materialInput.st画一条分界线确认 st.y 的 0 端在底还是顶。我吃过这个亏文档里写着 0 在底某个 Cesium 版本里实际墙底对应的却是 st.y 1最后只能靠实测定方向。第三个是 shader 报错的定位方法。GLSL 编译报错不像 JS 那么友好但 Cesium 会把错误信息抛出来。在初始化 viewer 后挂一个监听能在开发期少死很多脑细胞viewer.scene.renderError.addEventListener((scene, error) { console.error([Cesium renderError], error) })一旦材质语法有问题renderError 会带着 GLSL 编译日志出现照着行号改就行。我写水体材质时靠这个抓出过不少肉眼根本看不出来的分号错误。5.3 调参心得瀑布不是越亮越好最后说点审美上的经验。瀑布动效最容易犯的毛病是“光污染”——亮度拉满透明度拉满结果墙体亮得看不清底下的地图和建筑。我目前的参数组合是基础色 alpha 0.35流光亮度系数 0.7扫描带 0.6emission 系数 1.2 左右uBands取 3 到 5。uBands 太小瀑布只有一条带太呆板太大视觉上全是细密条纹看久了眼晕。扫描带uScanWidth建议 0.06 到 0.1太宽就成整面墙闪一下失去了“扫过”的指向性。还有一个控制节奏的窍门瀑布速度和扫描速度不要取整数倍的同步值。比如瀑布每秒 0.6 个周期扫描每秒 0.8 次两者错开相位视觉效果会一直有变化而不是陷入稳定的循环让人乏味。这个细节是我在甲方现场调试时悟出来的他们当时坐在大屏前十分钟突然说了句“这个效果有生命力”。另外如果墙体是要跟着实时业务数据走比如告警区域坐标由后端下发建议把坐标解析、几何体构建抽成纯函数数据变了只重建 geometry 和 instance材质和 appearance 复用。重建 Primitive 时记得先把旧的从scene.primitives移除再 destroy不然 GPU 显存会悄悄涨下去跑一晚上内存就爆了。
返回列表