Cesium 实时水面镜面反射的实现
通过离屏 Framebuffer 渲染反射相机视角,结合斜裁剪面和水面法线扰动,在 Cesium 中实现实时水面倒影。
Kevin
这篇文章解决什么问题
在三维场景中,水面倒影是提升视觉真实感的关键效果之一。和简单的半透明水面不同,镜面反射需要把场景中的建筑、模型在水面上呈现出正确的倒影——倒影的位置、角度要随相机移动实时变化。
这个效果的核心技术问题是:如何在 Cesium 中实现一个"虚拟镜面",让水面能够实时反映出它上方的场景内容?
最终实现的方案是:每帧计算一个关于水面对称的反射相机,用这个相机把场景渲染到离屏 Framebuffer,再把渲染结果作为纹理贴到水面 Primitive 上。配合水面法线扰动动画,就能得到带有波纹效果的实时倒影。
核心难点
反射相机的计算。反射相机不是简单地把主相机翻转,而是要根据水面的法线方向,把相机的位置、视线方向和上方向分别做镜像变换。在球面坐标系下,水面法线不是固定的 (0,1,0),而是水面中心点的归一化向量,这让反射计算比平面场景复杂得多。
斜裁剪面(Oblique Clipping Plane)。反射相机渲染时,如果不做裁剪,水面以下的内容也会被渲染进反射纹理,导致穿帮。解决方案是修改反射相机的投影矩阵,把近裁剪面替换为水面平面,这样只有水面以上的内容才会出现在反射纹理中。
Cesium 渲染管线的介入。Cesium 不像 Three.js 那样提供公开的多 pass 渲染接口。要实现离屏渲染,必须在 preRender 事件中临时替换场景的默认相机,手动驱动 Cesium 的帧状态更新、命令执行和 Framebuffer 解析流程,渲染完成后再恢复原始状态。
实现思路
第一步是建立反射相机。每帧根据主相机的世界坐标,计算其关于水面平面的镜像位置。同时把相机的视线方向和上方向也做镜像变换,确保反射相机"看到"的内容和真实倒影一致。
第二步是构建斜裁剪面投影矩阵。把水面平面变换到反射相机的视图空间,然后用 Eric Lengyel 的斜裁剪面算法修改投影矩阵的第三行,使近裁剪面与水面对齐。
第三步是离屏渲染。在 preRender 回调中,临时把场景的默认相机替换为反射相机,隐藏水面 Primitive 本身(避免自反射),然后驱动 Cesium 的完整渲染流程把结果写入自建的 Framebuffer。
第四步是纹理映射。把离屏渲染得到的颜色纹理传给水面材质的 shader,在顶点着色器中通过反射相机的 ViewProjection 矩阵把世界坐标投影到纹理坐标,片元着色器直接采样得到倒影颜色,再叠加水面法线扰动和高光。
技术流程图
正在渲染流程图...
关键代码解析
反射相机的镜像计算
// 获取相机指向水面中心的向量
let view = Cesium.Cartesian3.subtract(center, cameraWorldPosition, new Cesium.Cartesian3())
// 相机在水面背面时跳过
if (Cesium.Cartesian3.dot(view, normal) > 0) return false
// 计算反射位置:view 关于法线的反射,再取反方向
view = reflectVector(Cesium, view, normal)
Cesium.Cartesian3.negate(view, view)
Cesium.Cartesian3.add(view, center, view)
reflectCamera.position = view.clone()
核心是向量反射公式 R = V - 2(V·N)N。先计算主相机到水面中心的向量,对其做关于水面法线的反射,得到反射相机的位置。dot > 0 的判断确保相机在水面下方时跳过渲染。
顶点着色器计算反射纹理坐标
uniform mat4 reflectorProjectionMatrix;
uniform mat4 reflectorViewMatrix;
uniform mat4 reflectMatrix;
out vec4 v_uv;
void main() {
vec4 p = czm_computePosition();
mat4 modelView = reflectorViewMatrix * reflectMatrix * czm_model;
modelView[3][0] = 0.0;
modelView[3][1] = 0.0;
modelView[3][2] = 0.0;
v_uv = reflectorProjectionMatrix * modelView * p;
gl_Position = czm_modelViewProjectionRelativeToEye * p;
}
反射纹理坐标在顶点着色器里算好,通过 v_uv 传给片元着色器。将 modelView[3] 的平移分量清零是为了消除 Cesium 相对于眼坐标的偏移。片元着色器只需 v_uv.xy / v_uv.w * 0.5 + 0.5 即可得到采样坐标。
从 Cesium 1.99 移植到 1.141 踩过的坑
这个 Demo 最初参考了 Cesium 1.99 版本的实现,移植到 1.141 时遇到了一系列问题,每一个都指向 Cesium 内部机制的变化。
坑一:Cesium.defaultValue 不再是顶层导出
1.99 的代码里大量使用 Cesium.defaultValue(a, b),在 1.141 的 ES module 构建中这个函数不再挂在 Cesium 命名空间上。直接用 a || b 替换即可。同理,Cesium.Math.sign 和 Cesium.Math.log2 也可以换成原生 Math.sign / Math.log2。
坑二:GLSL 版本升级,varying/attribute 变成保留字
Cesium 1.141 的 shader 全面升级到 GLSL 300 es(WebGL2),顶点着色器里的 attribute 要改成 in,varying 要改成 out;片元着色器里的 varying 要改成 in,texture2D 要改成 texture。
坑三:material source 里不能声明 varying
Cesium 的 Material 系统在组装最终片元着色器时,会把 material.shaderSource(即 czm_getMaterial 函数体)拼接在 Appearance 的 FS 模板之前。这意味着 material source 里的代码比 FS 里的 in vec3 v_positionMC 等声明更早出现,在 GLSL 300 es 里引用一个还没声明的变量会直接报编译错误。
最初的思路是在 material source 里声明 in vec4 v_worldPosition 来传递世界坐标,但这在 1.141 里行不通。最终的解法是换用 MaterialAppearance,把反射纹理坐标的计算移到顶点着色器里完成,输出 v_uv,material source 只需直接读取 v_uv 采样,完全绕开了 varying 声明顺序的问题。
坑四:EllipsoidSurfaceAppearance 的 material uniform 在顶点着色器里不可见
反射矩阵需要在顶点着色器里使用,但 EllipsoidSurfaceAppearance 的 material.uniforms 只注入到片元着色器。换成 MaterialAppearance 后,可以通过 appearance.uniforms(应用层级 uniform)把矩阵传给顶点着色器,这才是正确的做法。
坑五:Picking._pickOffscreenView 是 1×1 像素的离屏 view
为了避免污染主 view,曾尝试用 new Cesium.Picking(scene)._pickOffscreenView 作为离屏渲染的 view。但这个 view 的 viewport 固定是 1×1 像素,用它渲染出来的反射纹理只有 1 个像素,当然看不到任何倒影。正确做法是直接使用 scene._defaultView,临时替换它的 camera 属性,渲染完后恢复。
坑六:context.endFrame() 必须在离屏渲染后调用
preRender 事件在 Cesium 主 render() 函数之前触发,此时 context.beginFrame() 还没被调用。离屏渲染结束后必须调用 context.endFrame(),它会解绑 framebuffer 并清理纹理绑定,让 Cesium 的主渲染流程从干净的 WebGL 状态开始。如果不调用,主渲染会继续往离屏 buffer 写入,导致屏幕全被水面颜色覆盖,或者帧率骤降到 3 FPS。
坑七:反射纹理必须用 Texture.fromFramebuffer 创建
直接把 this._colorTexture(自建的 Cesium.Texture 实例)赋给 material.uniforms.reflectMap 不起作用——水面只显示纯色,没有倒影。Cesium 的 Material 系统对 sampler2D uniform 的识别有特定要求,必须每帧通过 Cesium.Texture.fromFramebuffer({ context, framebuffer }) 创建一个新的纹理对象,才能被正确绑定到 shader 里。
效果与边界
最终效果是水面能够实时反映场景中 3D Tiles 建筑的倒影,倒影随相机移动和旋转正确变化,水面带有法线扰动产生的波纹动画和高光效果,透明度和反射强度均可通过参数调节。
当前实现的边界:
- 每帧需要额外渲染一次完整场景,GPU 开销约翻倍。适合局部水面区域,不适合全球尺度的海洋反射。
- 必须关闭对数深度缓冲(
logarithmicDepthBuffer = false),否则投影矩阵修改会导致深度计算错误。 - 反射只包含 3D Tiles 和 Primitive,默认不包含地球表面(可通过
showGlobe参数开启)。 - 水面是平面近似,不支持曲面水体或有高度变化的水面。
- 猴子补丁修改了 Cesium 原型方法(
UniformState.prototype.updateFrustum和PerspectiveFrustum.prototype.clone),同一页面只能有一个反射工具实例,destroy()时会自动恢复。
小结
水面镜面反射的核心链路是:反射相机镜像计算 → 斜裁剪面投影矩阵 → 离屏 Framebuffer 渲染 → 反射纹理投影采样。从 Cesium 1.99 移植到 1.141 的过程中,GLSL 版本升级、material source 拼接顺序、appearance uniform 可见性、endFrame 调用时机、Texture.fromFramebuffer 的必要性,这些都是容易踩的坑,每一个都需要对 Cesium 内部渲染机制有一定了解才能定位。