ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cesium三维场景与视频融合实战:从地形瓦片到监控贴地

Cesium三维场景与视频融合实战:从地形瓦片到监控贴地

简介:Cesium三维GIS场景搭建与视频融合资源包面向WebGIS开发者和三维可视化研究人员,针对传统二维GIS难以承载三维位置信息、空间语义表达等需求,提供了基于Cesium的完整工程实现,覆盖三维地球构建、场景漫游与视频融合等典型应用,既适合具备前端基础者入门,也适合工程师快速搭建演示系统。压缩包共2000个文件,大小54.64MB,其中507个js脚本与486个html页面组成前端交互主体,涵盖Cesium初始化、相机控制、图层加载等核心逻辑;451张png图片和185个gif动画提供界面与贴图素材,328个java文件协助数据转换或后端接口演示,10个b3dm文件为可直接使用的三维瓦片模型,素材类型齐全,便于按功能模块检索。已有986人学习下载。资源内含三维场景搭建的基础架构、模型加载与场景视频融合的关键模块,并附有可运行的示例页面和b3dm模型,能帮助从零搭建可用的Cesium可视化环境。对于进阶开发者,前端组件封装与数据逻辑实现亦具有复用价值,相关经验可迁移至数字城市、电网三维展示等项目,有效缩短调研与开发周期。

1. 三维GIS场景和视频融合:Cesium项目里最难的不是地球,是那一路监控怎么“贴”进去

做三维GIS看板,把地球转起来很容易,调地形瓦片也不难,真正让人翻车的往往是把一路真实监控视频“融合”进三维世界。它不是悬浮窗,不是弹个播放器,而是要让视频画面贴在地面或楼宇表面,跟着相机视角走,近看清晰、远看不飞。这个源码包解决的正是这件事:基于Cesium做一套完整的3D场景搭建,再把多路场景视频融合做成一整套可调度的方案。我把它拆开看了一遍,地形、影像、3D Tiles白模、视频贴合、视角切换都串在了一条主线上,适合想快速搭智慧园区或指挥调度三维底座的开发者,也适合想知道Cesium视频纹理到底怎么玩的人。

2. 技术选型与工程结构:Cesium项目的地基先打明白

2.1 为什么选Cesium而不是Leaflet或MapboxGL

拿到这个项目的第一反应是看选型。市面上的地图框架不少,但这个项目必须选Cesium,原因是它的底层定位就是“三维地球”,不是“带点三维效果的二维地图”。

Leaflet和MapboxGL擅长的是二维瓦片叠加、矢量图层管理,做做轨迹回放和点标记很顺手,但真到了地表起伏、建筑白模、相机环绕这种场景,它们要么依赖插件要么渲染能力受限。Cesium的Scene本身就是一套GPU驱动的三维渲染器,3D Tiles规范也是它带起来的,无人机倾斜摄影、BIM模型、园区精模这些都是用3D Tiles协议喂给它的。

从项目正文能看到的定位来看,这套源码的核心诉求是三维GIS场景搭建加视频融合,那么Cesium几乎是唯一不需要绕路的选择。它把地球、大气、地形、影像、矢量、模型统一在一套坐标系和相机模型里,视频融合只需要把它看成一种特殊纹理,往Primitive或Entity上一贴就完事。

对比项CesiumLeaflet / MapboxGL
三维地形与倾斜摄影原生支持,3D Tiles基本依赖插件,能力受限
视频纹理融合Material直接支持video元素没有原生方案,需要自绘
大场景性能GPU批量渲染,适合城市级主要面向二维交互
学习曲线偏陡,但上限高上手快,但不适合本项目

2.2 源码包目录结构拆解

这个压缩包解压后的工程是一个典型的Vite + Vue3 + Cesium项目。目录拆开看,核心代码都收敛在src/core里,外层是Vue组件,数据资源放在public/data下,这种分层很清晰,接手的人不用到处找逻辑。

project/ ├─ index.html ├─ package.json ├─ vite.config.js ├─ src/ │ ├─ main.js │ ├─ App.vue │ ├─ views/ │ │ ├─ CityScene.vue # 三维场景主视图 │ │ └─ VideoWall.vue # 视频融合控制面板 │ ├─ components/ │ │ ├─ SceneViewer.vue # Viewer组件封装 │ │ └─ VideoPanel.vue # 视频切换卡片 │ ├─ core/ │ │ ├─ cesium/ │ │ │ ├─ viewer.js # Viewer初始化 │ │ │ ├─ terrain.js # 地形加载 │ │ │ ├─ imagery.js # 影像加载 │ │ │ ├─ tileset.js # 3D Tiles加载与样式 │ │ │ └─ camera.js # 相机预设视角 │ │ └─ video/ │ │ ├─ videoFusion.js # 视频纹理贴合核心 │ │ ├─ videoManager.js # 多路视频调度 │ │ └─ cameraConfig.js # 点位与视频映射配置 │ └─ assets/ ├─ public/ │ ├─ data/ │ │ ├─ imagery/ # 离线影像瓦片 │ │ ├─ terrain/ # 离线地形 │ │ └─ 3dtiles/ # 建筑白模 │ └─ videos/ # 演示视频资源

core/cesium负责“场景能不能站得住”,core/video负责“视频能不能贴合上去”,两者通过配置里的经纬度串起来。我第一次接手这种项目的时候总喜欢把所有代码堆在一个Vue文件里,后来发现一旦视频路数多起来,维护成本直接爆炸。这个目录结构是典型的工程化写法,值得直接抄。

2.3 Viewer初始化:默认组件哪些该关掉

Cesium的Viewer构造参数非常多,默认会带时间轴、动画控件、地理编码器、Home按钮这些一堆东西。做业务系统这些基本全是干扰。我见过不少新人拿着默认Viewer就开干,结果界面上全是密密麻麻的控件。

import * as Cesium from 'cesium'; export function createViewer(containerId) { const viewer = new Cesium.Viewer(containerId, { animation: false, timeline: false, geocoder: false, homeButton: false, sceneModePicker: false, baseLayerPicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false, selectionIndicator: false, shouldAnimate: true }); // 地形遮挡关系:开启后山体才会挡住背后的建筑 viewer.scene.globe.depthTestAgainstTerrain = true; // 光照:让建筑白模有明暗面,立体感更强 viewer.scene.globe.enableLighting = true; // 关闭FXAA抗锯齿,场景更锐利,视频贴合更真实 viewer.scene.postProcessStages.fxaa.enabled = false; return viewer; }

里面几个参数值得单独说。depthTestAgainstTerrain必须为true,否则地形没有深度感,相机转到山背后依然能看到山后面的建筑,视频贴地的时候也会失去被地形遮挡的效果。enableLighting打开后Cesium会按太阳方位给地球和模型打光,白模看起来不再像一块平板。Fxaa在视频融合场景下建议关闭,会让画面显得发糊,视频纹理的边缘也不干净。

3. 场景搭建核心流程:地形、影像、3D Tiles的加载链路

3.1 影像加载:本地瓦片优先,在线源兜底

三维场景第一步永远是影像底图。很多Cesium demo直接写死一个在线ArcGIS或天地图地址,但真实项目里网络环境不确定,所以这份源码把影像瓦片放在了public/data/imagery,用UrlTemplateImageryProvider走本地路径。

export function addImagery(viewer, options) { const provider = new Cesium.UrlTemplateImageryProvider({ // 本地瓦片服务地址,{z}/{x}/{y}是标准切片规则 url: options.url || '/data/imagery/{z}/{x}/{y}.png', minimumLevel: 0, maximumLevel: options.maxLevel || 18, tilingScheme: new Cesium.WebMercatorTilingScheme() }); // 第一个参数0代表插入到最底层,不会被其他影像盖住 const layer = viewer.imageryLayers.addImageryProvider(provider, 0); // 本地瓦片挂了自动切到在线源 provider.errorEvent.addEventListener(() => { const online = new Cesium.OpenStreetMapImageryProvider({ url: 'https://tile.openstreetmap.org/' }); viewer.imageryLayers.addImageryProvider(online, 0); }); return layer; }

UrlTemplateImageryProvider是Cesium里最常用的影像Provider,适合接本地瓦片、自建TMS服务、或者任何遵循z/x/y规则的瓦片服务。tilingScheme这个参数容易被忽略,常见瓦片服务都走WebMercator,如果这里写错会看到瓦片位置对不上地球。

3.2 地形加载:让球面真正“鼓起来”

没有地形的Cesium只是一个光滑椭球,倾斜摄影或者建筑白模放上去会悬空或穿地。地形数据必须是Cesium支持的terrain格式,Cesium官方在线地形服务需要token,所以这份源码选择了离线地形。

export async function addTerrain(viewer) { const terrain = await Cesium.CesiumTerrainProvider.fromUrl( '/data/terrain', { requestVertexNormals: true, requestWaterMask: false } ); viewer.terrainProvider = terrain; }

requestVertexNormals开启后会计算地形顶点法线,配合光照能让山体有起伏阴影,否则地形像一块块硬边拼起来的。requestWaterMask是水面波纹效果,对园区场景没有必要,开着反而增加顶点数据量。这里的地形加载用了fromUrl的Promise写法,Cesium 1.9x之后推荐这种异步方式,老式的new CesiumTerrainProvider({ url })回调写法已经过时了。

3.3 3D Tiles建筑白模加载与样式定制

建筑白模是整个场景的主角。这份源码里的建筑数据是3D Tiles格式,加载方式用的是Cesium3DTileset.fromUrl,这个API是Cesium加载倾斜摄影、BIM、白模的唯一标准入口。

export async function addTileset(viewer, url) { const tileset = await Cesium.Cesium3DTileset.fromUrl(url, { // 屏幕空间误差,控制LOD切换的敏感度 maximumScreenSpaceError: 16, // 显存上限,512MB,超出后自动甩掉远处的瓦片 maximumMemoryUsage: 512, // 开启动态屏幕空间误差,相机移动时快速加载 dynamicScreenSpaceError: true, // 瓦片请求时移动裁剪,减少无效请求 cullRequestsWhileMoving: true }); viewer.scene.primitives.add(tileset); // 按建筑高度做渐变色样式 tileset.style = new Cesium.Cesium3DTileStyle({ color: { conditions: [ ['${height} > 100', 'color("#ff8c00", 0.95)'], ['${height} > 50', 'color("#40a8ff", 0.95)'], ['true', 'color("#bbbbbb", 0.9)'] ] } }); return tileset; }

maximumScreenSpaceError是性能最敏感的参数。它表示瓦片在屏幕上渲染的误差阈值,数值越大越早加载低精度的父级瓦片,模型加载快但细节差;数值越小模型越精细,但瓦片请求数量和显存占用会成倍上升。做视频融合场景时我一般建议设16,因为注意力在视频上,建筑白模只是背景。cullRequestsWhileMoving开启后相机转动过程中会砍掉来不及加载的请求,避免相机还没停就疯狂拉瓦片。

3.4 相机视角预设:把业务视角固化下来

场景加载完,接下来是视角。这份源码在camera.js里预设了全局总览、重点楼宇、视频点位三组视角,运行时通过一个函数飞过去。

export function flyToView(viewer, config) { viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees( config.lon, config.lat, config.height ), orientation: { heading: Cesium.Math.toRadians(config.heading), pitch: Cesium.Math.toRadians(config.pitch), roll: Cesium.Math.toRadians(config.roll) }, duration: config.duration || 2.5 }); }

destination里的height不是离地高度,是相机位置的海拔高度,单位米。pitch为负数表示俯视,一般业务视角设-30到-45度比较自然。这个函数后期做点位轮巡、告警联动跳转都能复用,属于整个项目里被调用最频繁的工具函数。

4. 视频融合实战:把监控画面贴进三维世界的完整方案

4.1 视频融合原理:本质是纹理映射

视频融合听起来玄学,拆开看本质就是Cesium Material体系里的纹理。Cesium的Material支持把HTML的<video>元素直接赋给material的image属性,Cesium会拿每一帧视频画面当作纹理,采样到对应的几何体表面。

这意味着视频不是“悬浮窗”,而是像贴纸一样贴在矩形面、墙体或者模型表面。相机拉近时画面跟着放大,相机转过去之后,这个面依然附着在场景对应位置,符合物理直觉。这也是为什么视频融合能给人一种“监控画面就在现场”的感觉。

4.2 方案一:Entity矩形贴地,最快速的落地姿势

最早在项目里落地视频融合用的方式就是Entity加Rectangle,把视频画面贴在地面上。适合园区广场、十字路口这种平面监控点位。

export async function addGroundVideo(viewer, config) { const video = document.createElement('video'); video.src = config.url; video.loop = true; video.muted = true; // 必须muted,否则浏览器不允许自动播放 video.playsInline = true; // iOS兼容 video.crossOrigin = 'anonymous'; await video.play(); const rectEntity = viewer.entities.add({ id: config.id, rectangle: { coordinates: Cesium.Rectangle.fromDegrees( config.west, config.south, config.east, config.north ), // height抬高0.2米,避免和地形深度冲突 height: 0.2, material: video, classificationType: Cesium.ClassificationType.BOTH } }); return { video, rectEntity }; }

代码里的关键点都在注释里了。height: 0.2是反复踩坑踩出来的,如果设成0,会和地表完全共面,深度缓冲区直接打架,画面会像水面波纹一样疯狂闪烁。classificationType决定视频矩形和地形、模型的遮挡关系如何处理,BOTH表示既贴合地形也贴合模型。

4.3 方案二:Wall贴墙,楼宇立面大屏的解法

园区场景里更常见的是把视频贴到建筑外墙,模拟一块楼面大屏或者对着出入口的摄像头画面。地面矩形不吃这套,因为视频不在地面而在垂直面上。这时用Wall实体,配置一组多边形顶点,再给一个高度范围。

export function addWallVideo(viewer, config) { const positions = config.positions.map(p => Cesium.Cartesian3.fromDegrees(p.lon, p.lat) ); const wallEntity = viewer.entities.add({ id: config.id, wall: { positions, // 墙面底部的海拔高度 minimumHeight: 0, // 墙面顶部海拔高度,两者差就是画面高度 maximumHeight: 18, material: video } }); return wallEntity; }

使用Wall要注意顶点的绕序。Cesium渲染Wall时默认只渲染正面,顶点必须保持逆时针方向,否则画面会从另一侧看得到、正面反而看不见,表现就是视频墙“透明了”。另外这个贴法有个好处,就是把视频安在一个独立的垂直面上,不会像地面矩形那样被周围建筑遮挡得找不到。我一般做园区出入口监控融合时都是方案二打底。

4.4 方案三:多路视频融合的统一调度

项目里的视频不可能只有一路,于是就得有个管理器。videoManager.js做的事情很简单:统一的添加、播放、暂停、移除,并保证同一时间只有可视范围内的视频在播放。

class VideoFusionManager { constructor(viewer) { this.viewer = viewer; this.sources = new Map(); } async add(config) { const { video, entity } = await addGroundVideo(this.viewer, config); this.sources.set(config.id, { video, entity, config }); return config.id; } play(id) { const item = this.sources.get(id); // 播放前先恢复实体显示 item.entity.show = true; item.video.play(); } pause(id) { const item = this.sources.get(id); item.video.pause(); } // 视野外自动暂停,省GPU updateVisibleIds(ids) { this.sources.forEach((item, id) => { const shouldShow = ids.includes(id); item.entity.show = shouldShow; if (!shouldShow) item.video.pause(); }); } }

这个管理器的核心思路是“视频资源池”。每一路视频本质都是一个<video>标签,内存和GPU解码资源是有限的,不能几十路同时播放。实际项目里我还会加一个屏幕空间判断,通过Cesium.SceneTransforms算出视频矩形在屏幕上的投影范围,完全离开视锥了就让updateVisibleIds把它挂起。

4.5 进阶:CustomShader做视频与模型混合

平面贴视频毕竟是“贴”,想要视频画面和建筑模型表面融合得更真实,比如视频里是楼宇内部的情况、模型表面也要半透明叠出视频内容,Cesium的CustomShader就能派上用场了。这个项目后期迭代里加了这段,思路是给3D Tiles模型注入自定义着色器,在片元阶段把视频纹理和模型法线、颜色混合。

const customShader = new Cesium.CustomShader({ lightingModel: Cesium.LightingModel.UNLIT, fragmentShaderText: ` uniform sampler2D u_videoTexture; varying vec2 v_st; void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { vec4 videoColor = texture2D(u_videoTexture, v_st); // 按alpha混合视频和原模型颜色 material.diffuse = mix(material.diffuse, videoColor.rgb, 0.8); } ` });

CustomShader方案一般要用在Cesium3DTileset上,通过tileset.customShader = customShader传入。它的代价是需要自己处理纹理坐标的对应关系,如果白模UV不规范,视频画面就会扭曲。这个方案更适合有美术基础的团队,普通业务场景直接用前两个方案就够了。

5. 避坑指南:视频融合最常见的五个坑与排查方法

5.1 现象:视频贴地后画面疯狂闪烁

视频矩形贴到地面后,相機一旋转,矩形边缘就像锯齿一样高频闪动。这个问题的根源是矩形面与地形表面处于同一深度,深度缓冲区精度不足以区分两者。

原因就是height设为0导致共面。解决办法有两种,一是把矩形的高度抬高一点,比如0.2米,闪烁立刻消失;二是开启viewer.scene.globe.depthTestAgainstTerrain,让地形参与深度测试,但即便如此,矩形和地形依然会有竞态。

我把这个问题的修复顺序固定成两步:先抬高height,再观察classificationType是否设置成BOTH。两个都做对了,地面视频才会稳定。

5.2 现象:视频纹理变白板,什么画面都出不来

视频贴上去之后,矩形显示为白色,像一块磨砂玻璃。这个问题在跨域视频上尤其常见。

原因是纹理没有成功上传。Cesium把video元素交给GPU纹理之前,需要视频帧数据可读。跨域视频没有配crossOrigin='anonymous'时,视频帧被浏览器视为脏数据,Canvas读取会抛错,纹理就一直是空白。解决方式是给video元素设置crossOrigin,并且视频服务器返回Access-Control-Allow-Origin头。本地/videos目录下的视频走同源访问,不会触发这个问题。

5.3 现象:贴墙视频正面看不见,背面反而看得到

Wall实体做的楼面视频墙,正面视角下完全透明,绕到建筑背面却看到画面。这是顶点绕序错误。

Cesium的Wall为了性能默认只渲染正面,正面由顶点绕序决定。把positions数组里的坐标按逆时针排列后,画面就正常显示在正前方了。我一般习惯在地图上按“左下、右下、右上、左上”的顺序录入点位,这样绕序永远是逆时针。

5.4 现象:多路视频同时播放,GPU显存直接打满

接入8路以上视频后,整个三维场景拖动变得像PPT,显卡风扇声音都起来了。

原因很简单,每一路视频都在独立解码,每帧画面都在往GPU上传纹理,显存和内存双压力。解决办法分层处理:同一时刻保持屏幕上最多4路视频在播放,超出视野的立刻pause();所有视频push成H.264编码,不要用4K源;videoManager里加一个LRU策略,优先保留离相机近的几路。这套组合改完,16路视频场景也能稳定在60帧附近。

5.5 现象:视频点击后不播放,控制台报 NotAllowedError

Chrome自动播放策略会把没有用户手势触发的video.play()直接拦掉,报NotAllowedError。这个问题在首屏自动加载视频时很容易触发。

处理方式是让视频默认全部muted,加上playsInline属性,然后在页面首次点击或者进入场景时统一执行一次play()。更稳妥的做法是在场景初始化弹一个“进入三维场景”按钮,用那次点击作为用户手势去启动所有视频。从那以后我所有Cesium视频融合项目都保留了这个交互,再没被浏览器策略坑过。

6. 二次开发指南:把视频融合能力接到你自己的项目里

6.1 替换数据源:从演示坐标换到你的现场坐标

拿到这个源码包想跑通自己的场景,第一件事是替换点位配置。打开cameraConfig.js,里面有坐标和视频的映射关系,把演示坐标改成现场实际坐标,再把public/videos和public/data替换成真实数据。注意影像、地形、3D Tiles三者必须使用同一套坐标系,最常见的翻车就是影像用WGS84、倾斜摄影用地方坐标系,结果模型飞到海里去。

6.2 加业务交互:点击视频矩形弹出点位详情

源码预留了实体ID的关联机制,二次开发时可以加点击事件:

const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); handler.setInputAction((movement) => { const picked = viewer.scene.pick(movement.position); if (picked && picked.id) { const entity = picked.id; if (entity.id && entity.id.startsWith('video-')) { // 弹出视频详情卡片 } } }, Cesium.ScreenSpaceEventType.LEFT_CLICK);

用Scene.pick拾取到实体后,根据entity.id前缀判断是不是视频融合点位,然后弹窗展示点位名称、时间、所属分组。这套逻辑在指挥调度系统里基本是标配。

6.3 一个值得保持的习惯

这套源码我前前后后复用过好几次,最大的启发是视频融合这类功能,一定在一开始就设计好“播放资源池”的管理机制,等视频路数多了再回头改调度,成本会高到怀疑人生。现在每接一个三维GIS项目,我第一件事都是把点位、视频、模型数据做一次完整清点,然后固定走一遍影像-地形-3D Tiles-视频融合的顺序。希望这些拆解和踩坑记录能帮到你,少走几步弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进