ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Cesium视频融合实战:把监控画面钉在三维GIS场景中

Cesium视频融合实战:把监控画面钉在三维GIS场景中

简介:这是一份基于Cesium实现三维GIS场景搭建与场景视频融合的完整资源包,主要面向WebGIS开发工程师、三维可视化学习者以及智慧城市、数字孪生项目的技术从业者。Cesium凭借WebGL零插件、渲染能力强和优秀的数据处理能力,已成为三维WebGIS领域的主流框架;整包内容恰好覆盖从基础场景构建到监控视频融合应用的完整路径。压缩包共2000个文件,大小约54.64MB,其中507个JavaScript脚本、486个HTML页面和178个CSS样式构成前端项目骨架,328个Java类文件承担后端服务逻辑,451张PNG图片与61张JPG图片用于界面纹理及示例展示,33个JSON、14个Map及10个b3dm文件则提供了地图配置、场景布局与三维模型数据。已有986人学习下载,适合希望系统掌握Cesium三维场景初始化、地形/影像加载、模型添加以及视频画面与三维空间融合的开发者。借助源码和现成示例,可以快速理解三维GIS的数据组织流程,并直接复用其中场景搭建、视频融合、图层管理等模块,降低项目落地门槛。

1. 用 Cesium 把监控视频钉在三维地图上:这个资源到底在解决什么问题

做 WebGIS 交付的开发者应该都遇到过这种需求:甲方拿着一堆海康、大华的 RTSP 流,说“我要在三维场景里看到这些监控位置,最好点击点位能直接看画面”。传统做法是弹窗塞一个 video 标签,但弹窗和地图是割裂的,领导汇报时总感觉“画面是画面、地图是地图,不融合”。这份资源解决的核心问题就是把视频画面作为纹理直接贴到三维 GIS 场景里的指定位置——比如贴到一栋楼的墙面、一个河道断面或者一个监控杆的 billboard 上,真正实现“实景视频和三维地球叠加”。

资源本身是一个完整的三维 GIS 场景搭建示例包,基于 Cesium 实现,覆盖场景初始化、影像地形加载、GeoJSON 数据叠加、视频融合这几条主线。适合三类人:一是刚接触 Cesium 想快速跑通一个三维 GIS Demo 的前端开发;二是项目里已经有二维地图、准备往三维升级的 WebGIS 工程师;三是做可视化大屏集成的开发者,想把视频融合进三维场景又不想自己踩一遍材质坑。

2. 为什么选 Cesium 做三维 GIS:选型依据与最小环境准备

2.1 三维 GIS 选型:Cesium、MapBox GL、Three.js 怎么选

做三维 GIS 场景,市面上能选的主流方案有 Cesium、MapBox GL(配合地形插件)、Three.js 自绘。我自己的选型标准很简单:如果场景里有 GIS 数据叠加需求(GeoJSON、矢量切片、地形、全球影像),优先 Cesium;如果是纯模型展示、不含坐标系概念,才考虑 Three.js。Cesium 自带 WGS84 坐标系和 Web Mercator 投影转换,支持全球级别的影像、地形服务接入,相机的飞行、旋转、俯仰已经封装好了,不需要自己写投影矩阵。

MapBox GL 也能做三维,但它更偏样式化表达和交互,地形和建筑物 extrusion 可以做,但要真正贴合球体地球、支持全球瓦片和精确的经纬度拾取,Cesium 的成熟度明显更高。在模拟项目 X 的三维 GIS 改造里,我最早用 MapBox GL 试过一轮,地形精度和坐标拾取总差一口气,后来切到 Cesium 才顺下来。

2.2 viewer 初始化的最小配置与参数拆解

Cesium 的入口是 Viewer,最常用的用法是new Cesium.Viewer(container, options)。但默认 Viewer 会带上时间轴、动画控件、Geocoder 搜索框这些 UI 组件,交付项目中往往不需要,所以初始化时要显式关闭。下面是我一般会用的最小完整配置:

const viewer = new Cesium.Viewer('cesiumContainer', { animation: false, // 关闭左下角动画控件 baseLayerPicker: false, // 关闭图层选择器 fullscreenButton: false, // 关闭全屏按钮 geocoder: false, // 关闭搜索框 homeButton: false, // 关闭首页按钮 infoBox: false, // 关闭点击实体时的信息框 sceneModePicker: false, // 关闭 2D/3D 切换控件 selectionIndicator: false, // 关闭选中指示器 timeline: false, // 关闭底部时间轴 shouldAnimate: true, terrainProvider: Cesium.createWorldTerrainAsync({ requestVertexNormals: true, // 开启顶点法线,光照才正常 requestWaterMask: true // 开启水面效果(需注意性能) }) });

这段配置里最关键的是terrainProvider。如果不设置地形,默认是一个椭球体表面,所有三维地物都贴在光滑球面上,视觉上很“假”。createWorldTerrainAsync是 Cesium 的全球地形服务,requestVertexNormals必须开,否则地形光照会出现明显的块状感。requestWaterMask建议在演示场景开启,但在实际项目里如果瓦片量大、客户端机器性能一般,可以关掉,因为这个效果的成本肉眼可见。

shouldAnimate: true是给后续视频纹理和动态效果准备的,如果只是静态展示,设不设无所谓。但如果后面要做粒子特效或者让时间轴驱动光照变化,这个必须开。

2.3 本地开发环境:不要直接用 file:// 打开

Cesium 加载影像、地形这些外部资源时会走网络请求,如果直接用 file:// 协议打开 HTML,浏览器会拦截跨域请求,最常见的错误是 CORS 报错,场景一片黑。我一般把项目丢到一个本地静态服务器里跑,比如 node 环境直接装 http-server,或者用 Vite / Webpack 的 devServer。

npm install -g http-server cd your-project-directory http-server -p 8080

浏览器访问http://localhost:8080即可。有几个坑要提前说:一是 Vite devServer 下如果开了 strictPort 会遇到端口冲突,改一下端口就行;二是 Cesium 在 vite 里直接import * as Cesium from 'cesium'是没问题的,但要把CESIUM_BASE_URL指对,否则静态资源 404;三是 Cesium 官方默认的 Ion token 在本地调试是可以用的,但如果部署到公网环境,强烈建议到 Cesium Ion 官网申请自己的 token,否则可能出现服务不可用或配额超限。

// 在引入 Cesium 后、new Viewer 之前设置 token Cesium.Ion.defaultAccessToken = 'your-cesium-ion-token';

3. 搭建三维 GIS 场景:影像、地形与数据叠加的完整步骤

3.1 加载影像底图:ArcGIS、天地图与本地瓦片选型

Cesium 的底图通过baseLayer或imageryProvider控制。官方默认是 Bing 影像,但在国内项目中访问不稳定,我一般换成 ArcGIS World Imagery 或者天地图。ArcGIS 的服务无需 token,直接配置即可,适合快速验证;天地图必须申请 tk,但国内节点多、加载快,适合交付环境。

// 方式一:ArcGIS 全球影像(适合快速验证) const viewer = new Cesium.Viewer('cesiumContainer', { baseLayer: Cesium.ImageryLayer.fromProviderAsync( Cesium.ArcGisMapServerImageryProvider.fromUrl( 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer' ) ), // ... 其余参数同前 }); // 方式二:天地图(适合国内生产环境) const tiandituToken = 'your-tianditu-token'; const viewer = new Cesium.Viewer('cesiumContainer', { baseLayer: Cesium.ImageryLayer.fromProviderAsync( Cesium.UrlTemplateImageryProvider.fromURL( 'https://t{s}.tianditu.gov.cn/img_w/wmts?service=WMTS&request=GetTile&version=1.0.0' + '&LAYER=img&tileMatrixSet=w&TileMatrix={z}&TileRow={y}&TileCol={x}' + '&style=default&format=tiles&tk=' + tiandituToken, { subdomains: ['0', '1', '2', '3', '4', '5', '6', '7'] } ) ) });

这里的参数要注意:天地图的img_w是影像底图(不含注记),如果想叠加中文地名标注,需要再加载一个cia_w图层作为中文字注记层。t{s}是子域占位符,Cesium 的UrlTemplateImageryProvider支持用subdomains数组替换{s},如果不配置会默认只用一个子域,并发请求受限、加载明显变慢。

3.2 用 GeoJSON 把业务区域和数据边界叠到地球上

场景搭建的第二大步是叠加自己的业务数据。最常见的格式是 GeoJSON,Cesium 提供GeoJsonDataSource.load()一行加载。但在实际项目里,我基本不用默认样式,因为默认是随机色块,没法表达业务语义。

const dataSource = await Cesium.GeoJsonDataSource.load('/data/xxx-region.geojson', { stroke: Cesium.Color.fromCssColorString('#00ccff'), strokeWidth: 2, fill: Cesium.Color.fromCssColorString('#00ccff').withAlpha(0.15), clampToGround: true // 关键参数:贴合地形,而不是悬浮在地表 }); viewer.dataSources.add(dataSource); // 场景相机飞行定位到数据范围 const boundingSphere = Cesium.BoundingSphere.fromBoundingSphere(dataSource.entities.values[0].boundingSphere); viewer.camera.flyToBoundingSphere(boundingSphere, { duration: 2, offset: new Cesium.HeadingPitchRange(0, Cesium.Math.toRadians(-45), 5000) });

clampToGround是这里最容易忽略的参数。如果不设为 true,多边形的点会贴着椭球体地表,在地形起伏区域会出现“悬空”或“陷入地下”的视觉问题——尤其是在山区做模拟项目 X 的三维展示时特别明显。三维 GIS 场景里凡是面状或线状数据都建议开clampToGround,代价是 Cesium 会动态采样地形高度,加载速度稍慢,但正确性优先。

flyToBoundingSphere的第三个参数offset里的数值分别是 heading、pitch、range。pitch 用Math.toRadians(-45)表示俯仰 45 度视角,太垂直(-90)时地物像平面图,太水平(0 附近)时则看不到场景全貌,一般 -30 到 -60 之间比较合适。range 是相机到目标的距离,5000 米适合市域级区域,具体数值按数据范围调整。

3.3 添加点位标注:billboard 与 label 组合

在视频融合之前,先把监控点位作为 billboard 叠加到场景里,这个步骤可以验证坐标对不对,也方便后续把视频纹理直接挂到这些点位上。

const position = Cesium.Cartesian3.fromDegrees(116.3917, 39.9042, 0); const entity = viewer.entities.add({ position: position, billboard: { image: '/assets/camera-marker.png', width: 40, height: 40, verticalOrigin: Cesium.VerticalOrigin.BOTTOM, // 锚点设在底部,让图标立在地表 scaleByDistance: new Cesium.NearFarScalar(1500, 1.0, 5000, 0.6) // 视距变化控制缩放 }, label: { text: 'A-01 河道监控', font: '14px sans-serif', fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, pixelOffset: new Cesium.Cartesian2(0, -50), distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0, 8000) } });

verticalOrigin: BOTTOM如果不设置,图标默认锚点居中,贴在地表时图标看起来像是陷在地里一半。scaleByDistance是控制远近视距的缩放,近处看图标大、远处看图标小,避免远景时图标糊成一团。distanceDisplayCondition是 label 的显示距离范围,超过 8 公里就不显示文字,只显示图标,这个在监控点位密集区域很有用,否则文字会互相遮挡。

这一步跑通之后,场景就是“三维地表 + 业务面 + 点位标注”的完整 GIS 场景了,可以开始接视频。

4. 场景视频融合:VideoTexture 的两种实战用法

4.1 视频融合的实现原理:从 video 元素到 WebGL 纹理

Cesium 的视频融合本质上是把 HTMLVideoElement 作为纹理源传入材质系统。浏览器里 video 解码出每一帧图像,Cesium 在每一帧渲染时把图像上传到 GPU 纹理,再贴到几何体表面。这个链路里最关键的一点是:Cesium 不会自动更新视频帧,需要手动调用texture.update()或者在回调里重置image属性,否则画面会卡在某一帧或者黑屏。

所有基于视频的材质都依赖同一个底层组件:Cesium.VideoTexture。它有stopped状态,视频暂停或结束后纹理不再更新。实际项目中,视频源可能是 RTSP 流经后端转成 HLS/HTTP-FLV,也可能是摄像头抓拍的 MP4 片段。HTMLVideoElement 支持 src 指向 blob URL 或普通 http 地址,具体用什么源取决于后端推流方式。

4.2 把视频贴到 billboard 上:实现监控画面上墙

最常见的场景是:监控点位已经以 billboard 形式放到三维场景中了,现在点击点位或默认状态下就要看到该点位的实时视频画面。最直接有效的方式是创建一张视频 billboard,把视频纹理作为图标显示。这里有一个关键操作——必须先把 HTMLVideoElement 加到 DOM 里让浏览器解码,再传给 Cesium,直接传一个未播放的 video 会黑屏。

// 步骤 1:创建 video 元素并播放 const videoElement = document.createElement('video'); videoElement.src = 'https://your-server/live/a-01.m3u8'; videoElement.crossOrigin = 'anonymous'; // 跨域拉流必须设置,否则纹理会被污染 videoElement.autoplay = true; videoElement.loop = true; videoElement.muted = true; // 浏览器自动播放策略要求 muted videoElement.style.display = 'none'; // 隐藏 DOM,画面只在地图里显示 document.body.appendChild(videoElement); videoElement.play().catch(err => console.error('video play error:', err)); // 步骤 2:创建视频纹理,并挂到实体上 const videoTexture = new Cesium.VideoTexture({ context: viewer.scene.context, video: videoElement }); const entity = viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.3917, 39.9042, 0), billboard: { // 关键:此处传入视频纹理而不是 image url image: videoTexture, width: 320, height: 180, verticalOrigin: Cesium.VerticalOrigin.CENTER } });

这里有一个很隐蔽的坑:videoElement.play()返回的是一个 Promise,如果浏览器阻止了自动播放(没有 muted),这个 Promise 会 reject,并且 Cesium 的 VideoTexture 拿不到帧,最终表现就是黑屏。所以muted = true不只是为了静音降噪,它同时是保证视频能在无用户交互时自动播放的前提。crossOrigin = 'anonymous'是第二个常见坑的根源:如果视频地址跨域且没设 crossOrigin,视频帧虽能正常播放,但 Canvas/WebGL 读取时会因为跨域环境安全限制报错,轻则画面上不了纹理,重则直接拿到安全错误。

4.3 把视频贴到 Wall 表面:适合河道断面、建筑立面、大屏映射

billboard 视频适合点位型展示,但如果视频内容是河道水面、建筑立面或者某个固定断面的监视画面,用 billboard 会显得很单薄。Cesium 里更适合的方式是创建一面墙体(Wall)并用视频纹理作为材质。这个做法在模拟项目 X 的河道水位监控里用过:把河道某个横断面的实时监控贴到一面透明墙体的曲面上,从远处看,墙体和河道走向严丝合缝,监控画面就像直接钉在了河面上。

// 创建一面墙,用视频纹理做材质 const wallEntity = viewer.entities.add({ name: '河面视频融合', wall: { positions: Cesium.Cartesian3.fromDegreesArray([ 116.3917, 39.9042, 116.3925, 39.9042, 116.3925, 39.9048, 116.3917, 39.9048 ]), material: new Cesium.ImageMaterialProperty({ image: videoTexture, transparent: true, // 允许透明度,让纹理外区域可见 repeat: new Cesium.Cartesian2(1, 1) }) } });

运行这段代码前有个隐患:墙体的高度为零时,从侧面看就是一条线,视频自然看不见。必须给wall设置height和extrudedHeight,让它变成一面从地面到指定高度的立体墙体。另外ImageMaterialProperty对视频纹理的yaxisUp默认值是 false,很多视频源的图像坐标系是左撇子坐标系,贴到墙上视频会是倒的。处理方式是在 Material 属性里显式设置或对视频源做 canvas 翻转。

实际改进版的用法是创建非对称墙体来规避视角变薄的问题。更常用的方案是直接用Cesium.Entity的wall配合曲面:把视频贴到一块朝南的半透明立面上,配合底下的河道实体,观感比 billboard 强很多。这在三维 GIS 场景交付里是一个重要的表现技巧,比纯平面视频回放更有“融合感”。

5. 避坑:视频融合最常见的 5 个踩坑记录与排查路径

做视频融合这类需求,我踩过的坑比一次成功跑通的次数多得多。这里挑 5 个高发问题,每条按现象、原因、解决三个层次写清楚,希望帮到正要动手的人。

5.1 画面一直黑屏,但浏览器里 video 元素能正常播放

现象:DOM 里的 video 画面正常,但 Cesium 场景中 billboard 或 wall 上的视频纹理始终是黑的。

原因:VideoTexture的帧更新依赖 Cesium 渲染循环,而循环不会自动强制更新纹理。排查时会发现第一次渲染能显示一帧,然后就一直保持黑屏,因为后续帧没有同步到 GPU。

解决:不要在业务代码里手动调纹理更新——如果确实需要手动控制更新时机,就给 VideoTexture 加一个update回调,或者确保纹理被设置为动态更新属性。我一般会让 video 元素保持 muted 和 loop 状态,并在场景渲染循环里不加额外处理,因为 Cesium 内部会做 30 FPS 以下的帧同步,但如果写了不恰当的synchronize逻辑反倒会掐断更新链路。最保险的做法是用 Cesium 官方推荐的回调方式强制刷新纹理,或彻底去掉不属于 Cesium 的帧更新逻辑。

5.2 跨域视频源画面能播,但控制台报跨域安全错误

现象:video 能播放,但浏览器 console 里出现与 cross-origin 或 CORS 相关的报错,纹理获取不到画面数据。

原因:视频源与当前页面不同源,跨域请求没有被服务端放行,或者客户端没设crossOrigin = 'anonymous'。

解决:如果后端是自己控制的推流服务,必须在响应头上加Access-Control-Allow-Origin: *或指定域;如果视频源来自第三方,就把视频转发到自己的服务端再拉流。前端记得给 video 元素设置crossOrigin,否则哪怕服务端响应头正确,WebGL 读取纹理时仍然会被浏览器拦截。我也遇到过一种情况:服务端响应头没问题、前端也设了 crossOrigin,但用的是带鉴权 token 的 URL 地址,token 在请求头和 CORS 预检不匹配也会报错。这种要检查预检请求(OPTIONS)有没有正确响应。

5.3 视频贴到 Wall 后是倒的

现象:视频纹理上墙后上下颠倒,或者左右镜像,怎么调角度都正不回来。

原因:ImageMaterialProperty的yaxisUp属性。Cesium 默认的纹理坐标系 y 轴朝上,但 HTMLVideoElement 的视频帧坐标系是左上角为原点,y 轴朝下。两者叠加后就会产生倒置。

解决:在ImageMaterialProperty里显式把yaxisUp: true设上。如果设了还是倒的,再用repeat把一个向量设为(1, -1)做二次修正。这个问题的麻烦之处在于不同浏览器对 video 纹理的坐标系处理不完全一致,同样一段代码在 Chrome 里正常、在 Firefox 里可能就是倒的,所以交付前最好在不同浏览器都看一眼。

5.4 墙面视频远处模糊、近处正常

现象:相机拉近时视频清晰,拉远后墙面上的视频像被打了一层马赛克,文字完全看不清。

原因:视频纹理本身的像素有限,墙面在三维世界里的尺寸又比较大,拉伸后像素密度不够。

解决:两种手段配合使用。首先提高视频片源的分辨率,720p 是底线,1080p 才比较稳;其次调整墙面(Wall)的几何尺寸,不要比视频实际覆盖范围大太多。如果视频内容和墙面面积的比例严重失衡,比如 4:3 的视频贴到一个 1:8 的超宽墙面上,画面会被压扁得无法辨认。正确做法是让墙面的宽高比尽量接近视频源本身的宽高比(16:9 或 4:3),或者在材质里调整 repeat 参数把图像裁剪到合适区域。

5.5 视频融合的位置偏差:cameras 画面和 GIS 点位对不上

现象:视频画面已经显示,但明显感觉画面内容和墙面的实际位置偏移了几米到十几米,监看点位和实际目标对不上。

原因:视频融合的位置是固定经纬度标注的,但现场施工时的经纬度是人工采集的,存在偏差;或者墙体的四个顶点经纬度是手敲的,本身就不精确。

解决:在融合调试阶段预留一个位置微调的 UI,通常是箭头键按 0.00001 度步进微调。0.00001 度约为 1 米左右,这样在现场配合卫星影像做微调最直接。我一般在代码里临时绑定键盘事件做上下左右平移,调好位置后再把坐标写死在配置里。如果是墙体类型,直接调四个顶点的经纬度,调完刷新即可实时看到效果。这个过程很像电视里卫星云图叠加的“配准”操作,急不得,差一米在三维场景里都看得出来。

6. 进阶:让视频融合真正“贴”在场景上,而不只是叠上去

把视频贴到场景里只是第一步,第二步是让它看起来像“长在场景里”。一个常用的进阶技巧是用多边形 + 贴地方式替代墙体:把视频纹理贴在GroundPrimitive或entity.polygon上,这样视频画面可以直接贴合地形起伏。尤其在河道、边坡这种地形有角度变化的场景,墙体是直的,地形是斜的,会有明显的悬空感。用 polygon 方式,Cesium 会按照地形高度做贴合,画面自然过渡到地表形态。

const videoPolygon = viewer.entities.add({ polygon: { hierarchy: Cesium.Cartesian3.fromDegreesArray([ 116.3917, 39.9042, 116.3925, 39.9042, 116.3925, 39.9048, 116.3917, 39.9048 ]), material: new Cesium.ImageMaterialProperty({ image: videoTexture, transparent: true, yaxisUp: true, repeat: new Cesium.Cartesian2(1, 1) }), classificationType: Cesium.ClassificationType.BOTH } });

classificationType这个参数值得多说一句。默认的 polygon 会贴到地形表面,但如果设置不当,可能把视频融合区域内的所有实体(如之前添加的 billboard 和 label)一起压在地表下面,导致点位看不到了。设成BOTH表示既做地形贴合也做实体分类,一般不会压掉其他实体。如果是建在模型上的项目,建议设成TERRAIN,只贴地形不碰模型。

另外,视频融合久了之后性能会慢慢下滑,尤其是在移动端。视频纹理每一帧都要上传 GPU,如果页面里同时挂了三四个视频纹理,帧率会肉眼可见地往下跌。我处理的办法是:在viewer.scene.postRender事件里判断当前相机距离,距离超出一个阈值就把视频纹理停掉,接近时再恢复。这样兼顾了场景的完整性,也不会让显卡一直空转。

viewer.scene.postRender.addEventListener(() => { const dist = Cesium.Cartesian3.distance( viewer.camera.positionWC, wallEntity.position.getValue(viewer.clock.currentTime) ); if (dist > 8000 && !videoElement.paused) { videoElement.pause(); } else if (dist <= 8000 && videoElement.paused) { videoElement.play(); } });

这个距离阈值根据场景比例来调。远场景(城市级)建议 8000 到 12000 米,近场景(园区级)3000 米左右就够了。从这以后,我做任何视频融合项目都会强制走一遍这个“位置偏差排查 + 距离降载”的流程,把视频真正当成三维场景的一个图层来对待,而不是一个贴上去的页面元素。每次调试时,我都会把相机拉到视频融合位置的正上方垂直往下看——如果视频没有随着地形起伏而变形,说明融合方式选错了,这种检查花不了几秒钟,但能省下半天现场返工的功夫。希望这篇拆解能帮你把监控视频干净利落地“钉”到三维 GIS 场景里。

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

RELATED READING

延伸阅读

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