1. 地图截图这件事为什么值得单独拎出来讲做三维地图开发的人都有一个共识Cesium 这个库功能强归强但一旦涉及到“把当前画面截下来”这种看似简单的需求坑就来了。我最早接触这个需求是在一个三维地理信息可视化项目里当时产品经理跑过来说“用户想把这个三维场景保存成图片最好还能带上标注和图层信息。”听起来是不是特别简单浏览器不是有canvas.toDataURL()吗直接调一下不就完了结果一上手就发现事情远没有想象中那么直接。Cesium 的渲染机制和普通 Canvas 完全不是一回事。它底层用的是 WebGL而且默认开启了preserveDrawingBuffer: false这意味着你直接去读 Canvas 的像素数据大概率拿到一张全黑的图。再加上 Vue 的响应式系统和 Cesium 的生命周期交织在一起什么时候截、怎么截、截完怎么处理每一步都有讲究。这篇文章就是把这个需求从头到尾拆一遍。我会讲清楚 Cesium 截图的核心原理、Vue 环境下怎么组织代码、不同截图方案的取舍、以及我在实际项目里踩过的那些坑。适合已经有 Cesium 基础、正在做三维地图相关开发的朋友参考也适合刚接触 Cesium 但需要快速实现截图功能的同学。读完你至少能拿到一套可以直接用的截图方案并且知道每种方案在什么场景下该用、什么场景下不该用。2. 先搞清楚 Cesium 的渲染管线截图才有的放矢2.1 WebGL 渲染与 Canvas 读取的基本矛盾Cesium 的 Scene 最终是把内容画在一个 Canvas 元素上的这个 Canvas 通过 WebGL 上下文来渲染。问题就出在这里WebGL 的绘制命令是异步提交给 GPU 的浏览器为了性能默认不会保留每一帧绘制完成后的缓冲区数据。也就是说当你在下一帧去读取 Canvas 像素的时候GPU 可能已经把上一帧的数据清掉了。这就是为什么很多人第一次尝试截图代码写的是const canvas viewer.scene.canvas; const dataUrl canvas.toDataURL(image/png);结果拿到一张纯黑或者纯透明的图片。不是代码写错了是读取时机和缓冲区配置的问题。解决思路有两条路第一条是在初始化 Viewer 的时候就开启preserveDrawingBuffer让 GPU 保留绘制缓冲区第二条是不依赖 Canvas 直接读取而是通过 Cesium 提供的Scene级别的渲染接口来主动触发一次渲染并抓取像素。两条路各有优劣后面会详细对比。2.2 Cesium 的 Scene 渲染流程简析Cesium 的 Scene 在每一帧的渲染过程中大致会经历这几个阶段首先是preRender处理相机、地形、影像图层的更新然后是render执行实际的 WebGL 绘制命令最后是postRender做一些清理和事件触发。截图操作最理想的介入时机是在postRender之后因为这时候当前帧的绘制已经完成缓冲区里的数据是完整的。但这里还有一个细节Cesium 默认是连续渲染模式也就是每一帧都在重绘。如果你在postRender事件里直接去读 Canvas理论上能拿到当前帧的数据。但如果你用的是requestRenderMode按需渲染模式那就只有在场景发生变化时才会触发渲染这时候你就需要先主动调用scene.requestRender()等下一帧渲染完成后再截图。2.3 不同截图需求的分类在实际项目里截图需求并不是单一的。我大致把它分为三类纯画面截图只要三维场景的视觉内容不需要额外的 UI 元素。带标注截图需要把地图上的标注、弹窗、图例等 HTML 元素也合成进去。高分辨率截图需要输出比屏幕分辨率更高的图片用于打印或者高清展示。这三种需求的实现方案是不一样的。纯画面截图最简单带标注截图需要额外处理 DOM 到 Canvas 的合成高分辨率截图则需要调整 Cesium 的渲染分辨率或者使用离屏渲染。下面会逐一展开。3. Vue 环境下集成 Cesium 截图功能的工程化思路3.1 组件结构设计在 Vue 项目里我通常会把 Cesium 的初始化和截图逻辑封装在一个独立的组件或者 composable 里。这样做的好处是截图逻辑和地图初始化逻辑解耦方便复用和测试。一个典型的组件结构是这样的template div classmap-container div refcesiumContainer classcesium-viewer/div div classtoolbar button clickhandleScreenshot截图/button /div /div /templateCesium 的 Viewer 实例挂载在cesiumContainer这个 div 上截图按钮触发handleScreenshot方法。这里要注意的是Cesium 的 Canvas 是动态插入到这个 div 里的所以你不能直接在模板里写 Canvas 元素必须通过 Viewer 来管理。3.2 生命周期与 Viewer 实例管理Vue 3 的 Composition API 里我一般会在onMounted里初始化 Viewer在onUnmounted里销毁它。截图相关的状态比如是否正在截图、截图结果等用ref来管理。import { ref, onMounted, onUnmounted } from vue; import * as Cesium from cesium; const cesiumContainer ref(null); const viewer ref(null); const isCapturing ref(false); onMounted(() { viewer.value new Cesium.Viewer(cesiumContainer.value, { // 配置项 }); }); onUnmounted(() { if (viewer.value !viewer.value.isDestroyed()) { viewer.value.destroy(); } });这里有一个容易忽略的点Vue 的响应式系统会给viewer这个对象加上 Proxy 代理而 Cesium 的很多内部对象是不兼容 Proxy 的。所以我在实际项目里会把 Viewer 实例存在一个非响应式的变量里或者用shallowRef来避免深层代理。3.3 截图方法的封装截图方法我一般封装成一个独立的函数接收一些配置参数返回一个 Promise。这样调用方不需要关心内部的实现细节。async function captureScene(options {}) { const { format image/png, quality 0.92, includeOverlay false, } options; // 具体实现 }参数设计上format支持 PNG 和 JPEGquality只在 JPEG 下生效includeOverlay控制是否合成 HTML 覆盖层。这样的接口设计足够灵活也容易扩展。4. 核心截图方案的实现与对比4.1 方案一开启 preserveDrawingBuffer 直接读取这是最直观的方案。在初始化 Viewer 的时候把contextOptions里的preserveDrawingBuffer设为trueconst viewer new Cesium.Viewer(container, { contextOptions: { webgl: { preserveDrawingBuffer: true, }, }, });然后截图就变得很简单function captureByCanvas() { const canvas viewer.scene.canvas; return canvas.toDataURL(image/png); }这个方案的优点是实现简单不需要额外的渲染操作。缺点是会带来一定的性能开销因为 GPU 需要一直保留绘制缓冲区显存占用会增加。在低端设备或者大场景下这个开销可能比较明显。注意如果你的项目对性能比较敏感或者需要在移动端运行建议谨慎使用这个方案。可以在截图前动态开启preserveDrawingBuffer截图后再关闭但 Cesium 不支持运行时修改这个配置所以实际上做不到动态切换。4.2 方案二通过 Scene 的 render 接口主动抓取Cesium 提供了一个更优雅的方式scene.render()配合scene.canvas的读取。但直接调render()并不能保证缓冲区数据可读还是需要配合preserveDrawingBuffer。所以这个方案本质上和方案一是一样的。真正不同的是使用Cesium.Scene的postRender事件来确保读取时机viewer.scene.postRender.addEventListener(() { if (needCapture) { const canvas viewer.scene.canvas; const dataUrl canvas.toDataURL(image/png); needCapture false; // 处理截图结果 } });这个方案的好处是读取时机有保证不会出现读到空帧的情况。但依然需要preserveDrawingBuffer: true。4.3 方案三使用 Cesium 的 Scene 截图 APICesium 从某个版本开始提供了一个Scene级别的截图方法但它的行为依赖于浏览器的实现而且在不同版本里 API 有变化。我实测下来这个方案的兼容性不如前两个方案稳定所以不太推荐在生产环境里使用。4.4 三种方案的对比与选型建议方案实现难度性能影响兼容性推荐场景preserveDrawingBuffer低中等好截图频率低、对性能要求不高的场景postRender preserveDrawingBuffer中中等好需要精确控制截图时机的场景Scene 截图 API低低一般不推荐生产环境使用我的建议是如果你的项目截图频率不高比如用户手动触发直接用方案一就够了。如果需要程序化批量截图方案二的时机控制更可靠。方案三除非你有特殊需求否则可以忽略。5. 带标注截图与高分辨率截图的进阶处理5.1 HTML 覆盖层的合成Cesium 的标注、弹窗、图例这些通常是 HTML 元素浮在 Canvas 上面。直接截 Canvas 是拿不到这些元素的。要合成它们需要用到html2canvas这类库。思路是这样的先用 Cesium 的 Canvas 拿到三维场景的图片然后用html2canvas把覆盖层 DOM 也转成图片最后用 Canvas 的drawImage把两张图合成到一起。import html2canvas from html2canvas; async function captureWithOverlay(viewer, overlayElement) { const sceneCanvas viewer.scene.canvas; const sceneDataUrl sceneCanvas.toDataURL(image/png); const overlayCanvas await html2canvas(overlayElement, { backgroundColor: null, scale: window.devicePixelRatio, }); const finalCanvas document.createElement(canvas); finalCanvas.width sceneCanvas.width; finalCanvas.height sceneCanvas.height; const ctx finalCanvas.getContext(2d); const sceneImage new Image(); sceneImage.src sceneDataUrl; await new Promise(resolve { sceneImage.onload resolve; }); ctx.drawImage(sceneImage, 0, 0); ctx.drawImage(overlayCanvas, 0, 0, finalCanvas.width, finalCanvas.height); return finalCanvas.toDataURL(image/png); }这里有几个细节要注意html2canvas的scale参数要和 Cesium 的devicePixelRatio匹配否则合成出来的图会有偏移。另外如果覆盖层里有跨域图片html2canvas可能会失败需要提前处理 CORS。5.2 高分辨率截图的实现高分辨率截图的核心思路是提高渲染分辨率。Cesium 提供了一个viewer.resolutionScale属性可以控制渲染分辨率相对于 CSS 尺寸的比例。默认是 1.0设为 2.0 就是两倍分辨率。async function captureHighRes(viewer, scale 2) { const originalScale viewer.resolutionScale; viewer.resolutionScale scale; // 等待一帧渲染完成 await new Promise(resolve { viewer.scene.postRender.addEventListener(function handler() { viewer.scene.postRender.removeEventListener(handler); resolve(); }); }); const dataUrl viewer.scene.canvas.toDataURL(image/png); viewer.resolutionScale originalScale; return dataUrl; }这个方案的问题是提高分辨率会显著增加 GPU 的负担如果场景比较复杂可能会导致卡顿甚至崩溃。所以 scale 的值要根据实际场景来定一般 2 倍就够了不要盲目追求 4 倍、8 倍。提示在调整resolutionScale之后一定要等至少一帧渲染完成再截图否则拿到的还是旧分辨率的画面。5.3 截图结果的导出与下载拿到 dataURL 之后导出成文件很简单function downloadImage(dataUrl, filename screenshot.png) { const link document.createElement(a); link.href dataUrl; link.download filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); }如果是大图dataURL 可能会非常长直接放在href里可能会有性能问题。这时候可以转成 Blobfunction dataUrlToBlob(dataUrl) { const [header, data] dataUrl.split(,); const mime header.match(/:(.*?);/)[1]; const binary atob(data); const array new Uint8Array(binary.length); for (let i 0; i binary.length; i) { array[i] binary.charCodeAt(i); } return new Blob([array], { type: mime }); }然后用URL.createObjectURL来生成下载链接用完记得revokeObjectURL释放内存。6. 常见问题与排查技巧实录6.1 截图全黑或全透明这是最常见的问题原因几乎都是preserveDrawingBuffer没有开启。检查一下 Viewer 的初始化配置确保contextOptions.webgl.preserveDrawingBuffer是true。如果已经开了还是黑屏那可能是截图时机不对试试在postRender事件里截图。6.2 截图内容不完整或错位这种情况通常出现在带标注截图的场景里。检查html2canvas的scale参数是否和 Cesium 的devicePixelRatio一致。另外如果覆盖层有 CSS transform 或者动画html2canvas可能渲染不正确建议截图前先把动画暂停。6.3 高分辨率截图导致页面卡死resolutionScale设得太高GPU 扛不住。建议从 1.5 倍开始试逐步往上加找到设备和场景能承受的上限。另外截图完成后一定要把resolutionScale恢复原值否则后续交互会一直卡。6.4 截图速度慢如果截图频率很高每次都要等postRender整体速度会比较慢。可以考虑用离屏 Canvas 来预渲染或者降低截图的分辨率。另外JPEG 格式的编码速度比 PNG 快如果不需要透明通道用 JPEG 会更好。6.5 常见问题速查表问题现象可能原因解决方法截图全黑preserveDrawingBuffer 未开启初始化时设为 true截图全透明同上或截图时机不对在 postRender 中截图标注丢失未合成 HTML 覆盖层使用 html2canvas 合成图片错位scale 参数不匹配统一 devicePixelRatio页面卡死resolutionScale 过高降低倍数截图后恢复截图慢分辨率高或格式重降分辨率改用 JPEG6.6 几个我踩过的坑第一个坑是以为preserveDrawingBuffer可以在运行时修改。实际上这个配置是在 WebGL 上下文创建时确定的一旦创建就不能改。所以如果你一开始没开后面想截图就只能重建 Viewer代价很大。第二个坑是在 Vue 的watch里直接监听 Viewer 实例。前面说过Vue 的响应式代理会让 Cesium 的内部对象出问题。我当时的做法是用shallowRef或者markRaw来避免代理。第三个坑是截图后忘记恢复resolutionScale导致用户后续操作一直卡顿。这个 bug 排查了很久最后发现是截图逻辑里漏了一行恢复代码。第四个坑是html2canvas对 CSS 变量和现代 CSS 特性的支持不完善有些样式在合成图里会丢失。解决办法是尽量用内联样式或者简单的 CSS 类避免复杂的样式计算。7. 一些实战中的优化建议截图这个功能看起来简单但在实际项目里要考虑的东西不少。我的经验是尽量把截图逻辑做成可配置的把分辨率、格式、是否包含覆盖层这些参数暴露出去让调用方根据场景来决定。另外截图操作最好加一个 loading 状态因为高分辨率截图可能需要几百毫秒甚至更久用户需要知道系统正在处理。还有一个细节是内存管理。dataURL 和 Blob 都会占用内存如果频繁截图记得及时释放。特别是在移动端内存本来就紧张不注意的话很容易导致页面崩溃。最后说一个我最近在用的技巧如果只是需要缩略图不需要全分辨率可以在截图前临时把resolutionScale降到 0.5截完再恢复。这样速度会快很多图片体积也小适合用在列表展示或者预览场景。
阅读完成 · 觉得有帮助?