首页 / 资讯中心 / 文章详情

Vue3+Cesium三维场景录制全攻略:canvas.captureStream与MediaRecorder实践

Vue3+Cesium三维场景录制全攻略:canvas.captureStream与MediaRecorder实践 ★ FEATURED ARTICLE
1. 需求拆解先搞清楚录什么、在哪录、怎么录前阵子接了个模拟项目X需求一句话——“把三维场景里的操作过程录下来生成视频”。但配置里藏着不少细节录的是Cesium场景交互相机漫游、图层切换、测量标绘要能在浏览器里一键开始/停止最后输出mp4或webm下载。技术栈限定Vue3 Vite Cesium。这类需求在三维GIS项目里很常见比如巡检记录、汇报演示、操作留痕。很多人第一反应是“直接装个录屏软件不就行了”但对于面向用户的功能来说必须让录制能力长在应用内部。选型时我对比了三条路系统级录屏依赖第三方软件无法嵌入Web应用也拿不到内部交互的时间轴放弃。getDisplayMedia整个屏幕/窗口能在浏览器里做但弹窗授权体验相对生硬而且录到的是整个窗口内容分辨率不可控还容易录到无关信息。canvas.captureStream()MediaRecorder直接捕获Cesium渲染用的canvas录的是纯场景画面分辨率可控、帧率可控能跟业务逻辑深度绑定。我选了第三条。原因很实际Cesium本身就把每一帧画在一个canvas上captureStream能直接拿到这个canvas的帧流再喂给MediaRecorder编码成视频整个过程零侵入性能开销也可控。这也是目前Web端三维场景录制的主流方案。适合谁看这篇文章Vue3 Cesium的开发者或者正在做WebGIS录屏功能的同学。基础操作我会讲但重点放在那些文档里查不到、踩了才知道的坑上。2. 环境准备与Cesium场景搭建的细节坑先用Vite搭一个Vue3项目装Cesium依赖。这里有个老生常谈但依然容易翻车的点千万别用淘汰的cesium包名去配老配置。npm create vitelatest vue3-cesium-recorder -- --template vue cd vue3-cesium-recorder npm install cesium npm install vite-plugin-cesium新版vite-plugin-cesium会自动处理静态资源、全局变量和worker问题省掉一堆手工配置。装上之后在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-plugin-cesium会默认帮你把build产物里的Cesium静态资源一起打包但如果项目里用了自定义的VueCesium这类封装组件资源路径偶尔会不对。保险做法是在index.html里手动指定script window.CESIUM_BASE_URL /cesium/ /script然后初始化Viewer。这里我建议手动控制canvas的创建时机而不是完全交给Cesium.Viewer默认容器。原因后面说录制的时候你就明白了。template div refviewerContainer classh-full w-full relative canvas refrenderCanvas classabsolute inset-0/canvas /div /template script setup import * as Cesium from cesium import { onMounted, ref } from vue const viewerContainer ref(null) const renderCanvas ref(null) let viewer null onMounted(async () { viewer new Cesium.Viewer(viewerContainer.value, { animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false, selectionIndicator: false, creditContainer: document.createElement(div) }) // 关键拿到Cesium内部渲染用的canvas const cesiumCanvas viewer.canvas const context cesiumCanvas.getContext(webgl2) const gl context console.log(cesium canvas, cesiumCanvas.width, cesiumCanvas.height) }) /script这段代码里有个隐藏坑viewer.canvas拿到的canvas尺寸默认是500x500但Cesium会根据容器大小自动resize。如果你在onMounted里立刻去看canvas.width大概率是500根本不是你视觉上看到的容器大小。真正的实际分辨率在首次render之后才会更新。所以录制配置里我建议不要直接用viewer.canvas.width去设定视频尺寸而是用viewer.scene.drawingBufferWidth和drawingBufferHeight。这两个值是GL动态缓冲区的真实尺寸代表scene正在渲染的实际分辨率。const width viewer.scene.drawingBufferWidth const height viewer.scene.drawingBufferHeight const stream viewer.canvas.captureStream(30)这里30是帧率参数稍后展开讲。另外一个坑是设备像素比devicePixelRatio。默认Cesium会按devicePixelRatio去放大绘制分辨率比如Mac上dpr2canvas缓冲区就是CSS像素的两倍宽高。这样录出来的视频非常清晰但码率和CPU占用会翻倍。如果只是做录屏汇报我建议手动限制viewer.resolutionScale 0.5 // 或者 viewportHeight半分辨率这一点对性能敏感的项目尤其重要。测试的时候开着全分辨率录制 场景相机平滑缩放CPU占用直接飙到80%以上把resolutionScale降到0.5后肉眼几乎看不出差别但CPU占用掉了近一半。3. 核心核心canvas.captureStream MediaRecorder 录制流程3.1 基础录制实现简称就是三件事拿canvas流、喂给MediaRecorder、监听数据存成Blob。核心代码如下function startRecording() { if (!viewer || recording) return const canvas viewer.canvas const stream canvas.captureStream(30) // 可选把麦克风音轨混入 const audioStreamPromise navigator.mediaDevices.getDisplayMedia({ video: false, audio: true }) recorder new MediaRecorder(stream, { mimeType: video/webm;codecsvp9, videoBitsPerSecond: 8_000_000 }) chunks [] recorder.ondataavailable (event) { if (event.data.size 0) chunks.push(event.data) } recorder.onstop () { const blob new Blob(chunks, { type: video/webm }) const url URL.createObjectURL(blob) const a document.createElement(a) a.href url a.download recording_${Date.now()}.webm a.click() URL.revokeObjectURL(url) } recorder.start(1000) // 每秒触发一次 dataavailable }recorder.start(1000)是每1000毫秒产出一块数据。如果设成0或undefinedMediaRecorder会等录制结束时一次性产出整段数据浏览器可能hold不住大内存。1000毫秒是比较均衡的选择。3.2 帧率陷阱为什么录出来一卡一卡的基础版能录但很多人会录出“跳帧”“卡顿”“第一帧之后完美暂停”之类的效果。元凶是captureStream(30)这个参数。Cesium本身是按requestAnimationFrame跑渲染循环的但它有个requestRenderMode优化机制当场景静止不动时Cesium不会一直重绘而是等到有数据变化时才重绘一帧。captureStream(30)是要求浏览器每秒钟给MediaRecorder 30帧但Cesium不画帧的时候captureStream就没东西可给于是MediaRecorder拿到的可能是“没有新帧”的空窗期最终表现就是录制画面抽搐、卡顿、不流畅。解决思路有两套方案A强制Cesium每帧渲染。最直接的办法是关闭requestRenderMode优化viewer.scene.requestRenderMode false这样Cesium保持高帧率实时渲染MediaRecorder拿到的流永远是满的。代价是GPU占用高、功耗高尤其是长时间挂机时明显不划算。方案B仅在录制时切换到连续渲染。更灵活录制开始时空开录制结束后恢复function startRecording() { viewer.scene.requestRenderMode false viewer.scene.shouldAnimate true // 解锁时钟 // ... 创建 recorder 等 } function stopRecording() { // ... 停止 recorder viewer.scene.requestRenderMode true viewer.scene.shouldAnimate false }注意shouldAnimate控制的是时钟是否自动前进。如果模拟时钟停在一个静态时刻场景不变化Cesium同样不会重绘。Cesium 1.90以上的版本对时钟驱动做了更严格的优化所以录制时必须保证时钟在走。但光是requestRenderModefalse还不够还有个隐藏问题captureStream 30帧Cesium可能要渲染60帧、90帧甚至120帧。MediaRecorder会自己丢帧但这会造成一种“不均匀采样”的感觉画面偶尔像快进。更稳的做法是设置一个录制专用的渲染帧率上限// 在Cesium初始化时设置 viewer.scene.maximumRenderTimeChange 0.033 // 约30fpsmaximumRenderTimeChange是Cesium的一个时间阈值意思是如果距离上一次渲染的时间差还没超过这个阈值就不主动渲染。设为1/30就是限制Cesium最多每秒渲染约30帧。这样captureStream的30帧输入和Cesium的实际渲染频率能对齐录制出来的画面会更平稳。实际测试下来25fps是“体积和流畅度”最平衡的点。30fps看起来更顺但同样时长下文件体积增加约40%。如果只是记录操作过程不是精细动画25fps足够用。3.3 时间轴与录制进度的心跳用户在录制时通常需要知道“现在录了多久”。MediaRecorder没有直接的elapsed属性需要自己在setInterval里记let startTime 0 let timer null function startRecording() { startTime Date.now() timer setInterval(() { const seconds Math.floor((Date.now() - startTime) / 1000) recordElapsed.value seconds }, 500) } function stopRecording() { clearInterval(timer) }这里有个小坑如果录制时UI有v-if切换、路由跳转导致Cesium容器被重挂载canvas会换新的但captureStream拿到的还是旧的canvas引用画面就停了。所以录制期间要锁住界面路由或者至少保持容器稳定。3.4 让canvas适应容器尺寸变化录制过程中如果窗口resizeCesium会自动调整canvas的缓冲区大小但captureStream已经创建好的流的轨道不会自动跟随。表现就是视频里出现黑边、画面拉伸或者分辨率突变。稳妥做法是录制开始前锁定一个固定分辨率而不是依赖实时resizeconst targetWidth 1920 const targetHeight 1080 viewer.resolutionScale Math.min(targetWidth / viewer.container.clientWidth, targetHeight / viewer.container.clientHeight)这段代码把Cesium的分辨率强行设置到目标比例附近录出来的视频尺寸稳定。代价是窗口拉大后画面可能略虚但录制场景下值得。4. 音频混入与多轨合成不要只录了个哑巴视频纯场景录制成片传给客户/评审对方第一句话大概率是“这视频咋没声音”。业务汇报场景里操作者同步讲解音轨必不可少。4.1 麦克风录音captureStream()默认只提供canvas的视频轨道。要加入麦克风用getUserMedia拿麦克风采集流const audioStream await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } })然后把麦克风的音轨加到录制流中const combinedStream new MediaStream([ ...videoStream.getVideoTracks(), ...audioStream.getAudioTracks() ]) recorder new MediaRecorder(combinedStream, options)这里必须要用new MediaStream()重新组合直接stream.addTrack()也可以但前者逻辑更清晰。注意如果用户拒绝授权getUserMedia会抛异常准备工作应该在开始录制前完成不是直接在try里包所有逻辑。4.2 音画同步问题Web Audio和captureStream的视频轨是独立时钟长时间录制超过5分钟后音画可能出现几百毫秒的漂移。要解决这个正规做法是给视频加时间戳校准但对业务汇报足够用了。实践中最有效的办法是录制前一次性合成好音轨不要用多个音频源叠加。比如用户同时开着系统声音和麦克风getUserMedia是拿不到系统声音的这是浏览器安全限制只能要么录麦克风、要么用getDisplayMedia录桌面声音二选一。如果你非要两个都要那才是大工程——要用Web Audio API把两个流混音成一个AudioContext的destination。注意getDisplayMedia({ audio: true })会弹独立的授权框功能上是“录制整个系统的音频”而不是特定某个窗口的声音这一点要跟用户说清楚。4.3 不要忘记释放音轨很多初版实现忘了停止麦克风导致录制结束后图标一直闪烁、摄像头红点不灭。停止录制时要穷尽释放function stopRecording() { recorder.stop() videoStream.getVideoTracks().forEach(track track.stop()) audioStream audioStream.getAudioTracks().forEach(track track.stop()) }5. 做了个“视频水印和时间戳叠加”的高级版纯录制你已经在第一步实现了但业务场景往往要求“能追溯”——比如某次巡检记录得知道这个画面是什么时间、哪个项目、第几次录制。给视频直接叠一个水印最直观。做法不是修改原canvas会影响录制源的分辨率而是用离屏canvas把Cesium的canvas每一帧画到离屏canvas上同时把水印文字和时间戳画在离屏canvas的角落然后对离屏canvas做captureStream。这个方案的原理是原canvas保持干净页面显示不受影响。离屏canvas专门用于录制可以附加任何UI信息变更灵活。采集的是离屏canvas的流MediaRecorder拿到的就是带水印的最终视频。流程如下const offscreenCanvas document.createElement(canvas) offscreenCanvas.width width offscreenCanvas.height height const offscreenCtx offscreenCanvas.getContext(2d) let watermarkTimer null function setupWatermark() { // 每帧绘制一次水印 const drawWatermark () { offscreenCtx.drawImage(viewer.canvas, 0, 0, width, height) offscreenCtx.font 20px Arial offscreenCtx.fillStyle rgba(255,255,255,0.8) offscreenCtx.fillText(巡检记录 · ${formatDate(new Date())}, 20, height - 30) offscreenCtx.fillText(录制时长${recordElapsed.value}s, 20, height - 60) watermarkTimer requestAnimationFrame(drawWatermark) } watermarkTimer requestAnimationFrame(drawWatermark) } function stopWatermark() { cancelAnimationFrame(watermarkTimer) }设置这个方案时还有一个提速体验用requestAnimationFrame去画离屏canvas和Cesium的渲染循环解耦。这样即使Cesium掉帧水印层也不会跟着卡顿太多。离屏canvas要设置width/height属性跟录制目标分辨率一致不能依赖CSS撑尺寸否则不清晰。实测下来多一个drawImage图表操作对性能影响约5-8%高分辨率下稍多在接受范围内。6. 录制过程中最常翻车的6个坑6.1 录制出来的视频是绿屏/花屏这个问题我排查了很久。现象视频能录但画面大面积绿色或花屏。根因是Cesium的canvas背景色是透明还是黑色没处理好。Cesium默认Viewer的背景是深蓝色天空scene.backgroundColor但如果你的场景scene.globe.show没开或者scene.skyBox.showfalse画出来的是透明像素。captureStream录透明背景WebM编码器在色度子采样时容易出怪色块。解法录制前强制设置一个纯色背景viewer.scene.backgroundColor Cesium.Color.fromCssColorString(#0b1c2e)6.2 录到一半画面冻结但时间戳还在跳基本可以确定是requestRenderMode没关干净。Cesium的requestRenderModetrue时canvas只在“场景内容变化”时重绘。如果相机不动、图层不变Cesium就不渲染但MediaRecorder还在抢帧抢不到就一直拿旧帧画面就被“冻结”在最后一帧。一个检测技巧录制时拖一下相机如果画面恢复那就是requestRenderMode的问题。6.3 视频丢帧严重像PPT播放除了requestRenderMode另一个常见原因是浏览器节流。当页面处于后台标签页时requestAnimationFrame会被浏览器强制降频甚至暂停。录制期间一定要提醒用户“不要把标签页切到后台”或者用Web Worker OfflineAudioContext保活复杂且不推荐。另外检查一下是不是开了saveData模式或省电模式这两个会自动降低canvas的采集帧率。6.4 MediaRecorder在部分浏览器上不支持vp9编码写mimeType: video/webm;codecsvp9之前先判断浏览器支不支持const supportedTypes [ video/webm;codecsvp9, video/webm;codecsvp8, video/webm ] const mimeType supportedTypes.find(type MediaRecorder.isTypeSupported(type)) if (!mimeType) { alert(当前浏览器不支持视频录制请使用最新版Chrome/Edge/Firefox) return }实测Firefox支持vp8和vp9但码率控制不如Chrome平滑Safari对MediaRecorder的支持一直比较晚旧版本基本不能用。业务上如果有跨浏览器需求务必做能力检测并降级。6.5 录制结束视频下载链接失效URL.createObjectURL(blob)创建的临时链接在页面刷新或跳转后就失效。如果只是下载没问题但如果要“先预览再下载”建议用blob直接生成到页面里或者上传到服务器存为文件。预览可用videoEl.src URL.createObjectURL(blob)这里有个细节URL.revokeObjectURL调用时机不要太早。如果revoke后videoEl.src已经引用了有些浏览器会直接停止播放。安全的做法是等视频loadedmetadata后再revoke或者干脆不revoke页面刷新自动释放。6.6 录制开始前canvas还没ready用户点击“开始录制”时可能canvas刚初始化但尺寸还没定尤其是onMounted里异步加载底图。此时captureStream拿到的可能是0x0的流录出来全是黑。加个判断function waitForCanvasReady() { return new Promise((resolve) { const check () { if (viewer.scene.drawingBufferWidth 0 viewer.scene.drawingBufferHeight 0) { resolve() } else { setTimeout(check, 100) } } check() }) }7. 性能优化与编码参数调优录制功能上线前要用真机数据调一下码率和编码参数。我实测过的几组数据1920x108030秒30fps参数组合文件体积CPU占用画面质量vp9 2Mbps约8MB低可察觉轻微模糊vp9 6Mbps约22MB中清晰vp9 12Mbps约45MB中高非常清晰vp8 6Mbps约25MB更高清晰但噪点略多我的建议是默认8Mbps这是清晰度与体积的均衡值。如果场景里有大量文字标注、精细3D模型或线划图建议提高到12Mbps否则压缩会导致文字边缘糊。另外可以再加一个降噪/抗锯齿参数录制前把Cesium的fxaa打开viewer.scene.postProcessStages.fxaa.enabled trueFXAA抗锯齿对线划图的边缘质量提升非常明显代价是GPU占用略增值得在录制开始时开启、录制后关闭。还有一个鲜少人提起的tip录制前把viewer.resolutionScale设置为1的整数倍。如果resolutionScale是非整数比如0.75Cesium在重采样时会出现轻微的摩尔纹对录制视频的影响比肉眼直接看的更明显。8. 代码归档与工程化封装单个组件里写完所有逻辑容易但工程后期如果其他项目也要用建议抽成一个独立hook或工具类。我的封装思路是这样的src/ composables/ useRecorder.js useCesiumViewer.js utils/ recorderHelper.jsuseRecorder对外暴露四个方法export function useRecorder() { async function startRecording({ withAudio false, fps 25 } {}) {} async function stopRecording() {} function pauseRecording() {} function resumeRecording() {} function onRecorderStateChange(callback) {} }注意pauseRecording()和resumeRecording()用的是MediaRecorder原生方法可以做到暂停后再恢复。但有一个已知问题暂停期间captureStream的帧会被丢弃恢复后会出现一个短暂卡顿。目前没找到完美解决方案只能建议用户尽量少暂停。封装时还有一个建议把“录制按钮的状态”用reactive统一管理避免多个组件各自维护状态互相打架。const recorderState reactive({ isRecording: false, isPaused: false, elapsed: 0, error: null })9. 录制拓展方向不只是“录屏”功能做完了可以想想还能怎么扩展。我在模拟项目里顺手做了几个增强效果不错9.1 标记录制关键帧录制过程中用户可能在某个位置点了“打点”前端记录当前相机姿态camera.position和camera.heading/pitch/roll输出JSON。这样不仅录了视频还能事后“回到现场”。9.2 按时间切片保存配合后端每30秒一个分片上传最终由后端合并。这样即使录制中途页面崩溃已录的部分也不丢。实现上注意后端得支持分片上传和合并。9.3 自动降级预览录制结束生成视频后顺便调用canvas截图生成一张预览缩略图用于列表展示不比扒视频帧省事const dataUrl viewer.canvas.toDataURL(image/jpeg, 0.8)这里要用canvas.toDataURL注意Cesium渲染的canvas默认可能开了preserveDrawingBuffer: false也就是渲染完一帧后缓冲区会被清除导致toDataURL截到空画面。解决办法是初始化时设置new Cesium.Viewer(container, { contextOptions: { webgl: { preserveDrawingBuffer: true } } })但这会牺牲一点点性能所以只做预览不做视频时可以在需要截图的瞬间临时设置。10. 收尾调试建议最后给几条实践经验都是我反复踩过泥坑后的体会。录制功能开发完成后不要只在Chrome上测。打开Safari和Firefox各跑一遍完整录制流程你会发现问题分布很不均匀Safari的MediaRecorder支持很晚才有Firefox的vp9编码器和Chrome完全不同。如果客户环境固定那另说否则要贴一个“浏览器支持矩阵”在需求文档里。其次录制开始时先录5秒停掉后立刻播放检查再进入正式录制。这个“试录-试放-正式录”流程成本极低但能避免录了半小时发现参数配错。第三善用MediaRecorder.state监听。录制中途用户可能切标签页、锁屏或者浏览器弹权限框打断这些都会让录制状态悄悄变掉。做一个“录制意外中断”提示框比默默录成一堆坏数据要好。这就是整个Vue3 Cesium屏幕录制功能的落地过程。从基础录制到音轨混入从水印叠加到性能调优核心其实就是把“canvas流”管理好再处理好Cesium特有的渲染节奏。按这套思路走大部分场景都能稳稳交付。
阅读完成 · 觉得有帮助?
咨询建站