
Cesium 离屏渲染 Framebuffer 实现
解析如何在 Cesium 中自建 Framebuffer,把指定视角渲染成离屏纹理并读回为 ImageData。
Kevin
这篇文章解决什么问题
这个 Demo 想解决的是一个很典型的三维引擎问题:如果不把画面直接画到浏览器正在显示的 canvas 上,能不能在后台再渲染一次 Cesium 场景,并把结果作为普通图像数据拿出来?
答案是可以。WebGL 里这类能力通常通过 Framebuffer 完成。它像一个离屏画布,颜色结果写入纹理,深度测试写入深度附件,最后再通过 readPixels() 把 GPU 里的像素读回 CPU。
在 Cesium 里难点不只是创建 WebGL 资源。Cesium 自己维护了相机、视图、帧状态、命令列表、地球瓦片和 3D Tiles 渲染通道。要让离屏渲染得到和正常场景一致的结果,必须把这些状态按 Cesium 的渲染链路重新串起来。
核心难点
第一个难点是渲染目标要完整。只有颜色纹理还不够,场景里的地球、模型和瓦片仍然需要深度测试,所以离屏 Framebuffer 需要同时挂载颜色纹理和深度模板纹理。
第二个难点是相机不能简单依赖页面当前帧。Demo 使用 Cesium 的离屏 view,捕获时把相机克隆到这个 view 上,再把 view 的 viewport 改成捕获尺寸。这样页面主视图可以继续显示,离屏结果则按指定尺寸输出。
第三个难点是读回方向。WebGL 像素原点在左下角,canvas 2D 的 ImageData 通常按左上角理解。如果直接展示 readPixels() 的结果,图像会上下颠倒,所以读回后需要做一次行翻转。
实现思路
整体链路可以拆成四步。
第一步,按捕获尺寸创建或复用 Texture + Framebuffer。尺寸不变时直接复用,尺寸变化时销毁旧资源后重建,避免每次点击都创建新的 WebGL 对象。
第二步,准备离屏 view 和相机。默认克隆当前相机,也可以传入独立的 cameraView。view 的 viewport 会被改成离屏图像尺寸,passState 的 framebuffer 指向工具类管理的离屏缓冲区。
第三步,按 Cesium 正常渲染流程更新帧状态、环境和命令列表,再调用 updateAndExecuteCommands() 把场景命令真正画到离屏 framebuffer。
第四步,通过 context.readPixels() 读取 RGBA 像素,翻转行顺序后包装成 ImageData,交给页面预览 canvas 展示。
技术流程图
正在渲染流程图...
关键代码解析
离屏缓冲区需要颜色和深度两个附件。颜色纹理负责最终截图,深度模板纹理负责让地球、模型和 3D Tiles 的遮挡关系仍然成立。
this.colorTexture = new Cesium.Texture({
context,
width,
height,
pixelFormat: Cesium.PixelFormat.RGBA,
pixelDatatype
})
this.depthStencilTexture = new Cesium.Texture({
context,
width,
height,
pixelFormat: Cesium.PixelFormat.DEPTH_STENCIL,
pixelDatatype: Cesium.PixelDatatype.UNSIGNED_INT_24_8
})
this.framebuffer = new Cesium.Framebuffer({
context,
colorTextures: [this.colorTexture],
depthStencilTexture: this.depthStencilTexture,
destroyAttachments: false
})
真正渲染前,要把 passState 的 framebuffer 指向离屏缓冲区,并把 viewport 设置成捕获尺寸。这样 Cesium 后续执行的 draw command 会进入这块离屏目标,而不是进入屏幕 canvas。
viewport.x = 0
viewport.y = 0
viewport.width = width
viewport.height = height
passState.viewport = Cesium.BoundingRectangle.clone(viewport, passState.viewport)
passState.framebuffer = framebuffer
核心渲染步骤仍然沿用 Cesium 的场景管线:更新 frameState,设置 render pass,更新 uniformState,然后执行命令列表。这里没有手写模型或瓦片绘制逻辑,而是让 Cesium 自己生成并执行当前场景的渲染命令。
scene.updateFrameState()
frameState.passes.render = true
frameState.passes.offscreen = true
frameState.tilesetPassState = new Cesium.Cesium3DTilePassState({
pass: Cesium.Cesium3DTilePass.RENDER
})
uniformState.update(frameState)
scene.updateEnvironment()
scene.updateAndExecuteCommands(passState, clearColor)
scene.resolveFramebuffers(passState)
最后一步是读回像素。readPixels() 返回的是 WebGL 坐标方向的数据,所以 Demo 在生成 ImageData 前先把每一行反过来,避免预览图上下颠倒。
const rawPixels = scene.context.readPixels({
framebuffer,
width,
height
})
const imageData = new ImageData(
flipPixelsVertically(rawPixels, width, height),
width,
height
)
效果与边界
这个 Demo 输出的是一次手动捕获的离屏图像,适合做截图预览、后台采样、反射探针的基础实验,或者作为后续批量拾取、深度图反解的底层入口。
它不是实时视频流方案。readPixels() 本身会造成 GPU 到 CPU 的同步读回,尺寸越大,耗时越明显。因此第一版选择手动触发,并把默认尺寸限制在 512 x 512。
当前实现也使用了 Cesium 的私有渲染入口,例如离屏 view、frameState 和命令执行方法。这类代码适合封装在工具类内部,并在 Cesium 升级后集中验证,不应该散落在页面逻辑里。
小结
Cesium 离屏渲染的核心不是截取当前 canvas,而是重新指定渲染目标。工具类创建自己的 Framebuffer,把 Cesium 场景命令画进去,再把颜色纹理读回成普通图像数据。
这条链路可以概括为:准备离屏纹理和深度附件,配置离屏 view 和 passState,执行 Cesium 场景渲染命令,最后 readPixels() 得到 ImageData。
当这套能力稳定后,后续的拾取图、深度图、反射探针和后台渲染缓存,都可以在同一个基础上继续扩展。