在 Cesium 中用 Three.js 实现气象雷达三维体渲染------从原理到性能优化
一、先看效果

上图是真实气象雷达数据在 Cesium 地球上的三维体渲染结果。蓝绿色区域是中低强度回波,右侧那个黄色亮斑是高反射率核心(>40 dBZ),对应强对流区域。整个体积是半透明的,边缘自然羽化,可以透过云团看到下方的地形地貌。
这不是用 Cesium 原生 API 堆出来的,也不是用体素点云凑出来的------是在 Cesium 内部嵌了一个 Three.js 渲染管线,用 Ray Marching 做的真正体积渲染。
二、为什么这个需求很难做
气象雷达输出的是三维体数据。一个扫描体积里有几十层仰角,每层都是极坐标的反射率/径向速度数据,转换到笛卡尔坐标后是一个三维网格,每个格点有一个 dBZ 值。
传统做法是切片------取一个水平截面(CAPPI)或垂直截面展示。这样做确实省事,但信息损失非常大,对流系统的三维结构完全看不出来。
想把完整的三维体在地球上可视化,面临几个问题:
Cesium 没有体渲染能力。 Cesium 是做地球可视化的,它的渲染管线是为地形、影像、矢量这类数据设计的,没有 3D 纹理采样和 Ray Marching 这套东西。
Three.js 能做体渲染,但不在地球上。 Three.js 的世界是一个笛卡尔空间,而 Cesium 的世界是一个椭球体,两者的坐标系、相机模型、渲染循环全都不一样。
把两个引擎合到一起,坐标对齐是最大的坑。 Cesium 用 WGS84 椭球坐标,Three.js 用米为单位的局部坐标,一旦转换矩阵算错,渲染出来的体就会飘在地球旁边某个不知道哪里的地方。
所以这整件事本质上是:在 Cesium 的渲染循环里嵌一个 Three.js 的渲染管线,用自定义 Shader 做体积渲染,同时每帧保持两个引擎的相机同步。
三、核心原理:Ray Marching 体积渲染
体积渲染的核心思路是光线步进(Ray Marching) :从相机出发,沿视线方向发射一条光线穿透整个体数据,每隔一段距离采样一次颜色和透明度,最后把所有采样点的颜色按照前向混合累积起来,得到最终像素颜色。
3.1 光线与包围盒求交
体数据被装在一个 BoxGeometry 里,渲染时需要先算出光线进入和离开这个盒子的距离:
ini
vec2 intersectAABB(vec3 ro, vec3 rd, vec3 aabbMin, vec3 aabbMax) {
vec3 invR = 1.0 / (rd + 1e-6);
vec3 t0 = (aabbMin - ro) * invR;
vec3 t1 = (aabbMax - ro) * invR;
vec3 tmin = min(t0, t1);
vec3 tmax = max(t0, t1);
float tn = max(max(tmin.x, tmin.y), tmin.z);
float tf = min(min(tmax.x, tmax.y), tmax.z);
return vec2(tn, tf);
}
这是标准的 AABB 射线求交算法,返回的 tn 是入射距离,tf 是出射距离。tNear > tFar 说明光线没有击中盒子,直接 discard。
有一个特殊情况需要处理:当相机在体数据内部时,tNear 是负数,这时候采样起点应该是 0(相机位置本身),而不是 tNear:
css
float tStart = uCameraInside ? 0.0 : max(tNear, 0.0);
3.2 抖动偏移消除分层条纹
如果从 tStart 开始用固定步长采样,每个像素的第一个采样点都在同一平面上,渲染出来会有非常明显的分层条带。解决方法是给每个像素加一个随机偏移:
ini
vec3 hash33(vec3 p) {
p = fract(p * vec3(0.1031, 0.1030, 0.0973));
p += dot(p, p.yxz + 33.33);
return fract((p.xxy + p.yxx) * p.zyx);
}
float tOffset = hash33(gl_FragCoord.xyz).x * stepSize;
vec3 p = rayOrigin + rayDir * (tStart + tOffset);
用 gl_FragCoord 作为哈希输入,同一帧内每个像素的偏移都不同,条带就变成了噪点,视觉上几乎察觉不到。
3.3 前向累积混色
每步采样颜色之后,用标准的前向 Alpha 合成(front-to-back compositing)累积:
ini
float weight = alpha * (1.0 - alphaAccum);
colAcc.rgb += weight * color;
alphaAccum += weight;
(1.0 - alphaAccum) 是关键:越靠前的采样点权重越大,后面被遮挡的部分贡献越小。当 alphaAccum > 0.95 时基本上已经不透明了,后续采样没有意义,直接 break 提前退出。
四、数据处理:把 .dat 文件变成 3D 纹理
4.1 Three.js 的 Data3DTexture
普通 Texture 是二维的,GPU 通过 (u, v) 坐标采样。Data3DTexture 是三维的,GPU 通过 (u, v, w) 坐标采样,正好可以用来存三维体数据,Shader 里的 sampler3D 可以直接采样。
ini
const texture = new THREE.Data3DTexture(textureData, width, height, depth);
texture.format = THREE.RedFormat; // 只用 R 单通道
texture.type = THREE.FloatType;
texture.minFilter = THREE.LinearFilter; // 线性插值,采样更平滑
texture.magFilter = THREE.LinearFilter;
texture.unpackAlignment = 1;
texture.needsUpdate = true;
4.2 为什么只用单通道
气象回波数据本质上是一个标量场------每个格点只有一个 dBZ 值。如果用 RGBA 四通道纹理,等于浪费了 75% 的 GPU 显存带宽。用 RedFormat 单通道,纹理体积缩小 4 倍,采样带宽也减少 4 倍,在大分辨率数据下这个优化效果很显著。
4.3 降采样
原始数据的水平分辨率很高,但 GPU 能处理的 3D 纹理尺寸是有限的,直接全量上传会撑爆显存。
ini
const strideX = 4;
const strideY = 4;
const strideZ = 1; // 高度方向层数本来就不多,不降采样
水平方向每 4 个格点取 1 个,垂直方向保留全部层数。这个比例是根据实际数据尺寸和视觉效果反复试出来的,不是固定公式。
4.4 数值归一化
dBZ 范围是 -10 到 80,需要归一化到 0, 1 存进纹理,同时要处理填充值(无效格点):
ini
if (Math.abs(rawVal - fillVal) > 0.001 && realVal > minVal) {
normVal = (realVal - minVal) / range;
normVal = Math.max(0, Math.min(1, normVal));
} else {
normVal = 0; // 填充值和低于阈值的格点归零
}
fillVal 是气象数据格式里用来标记"无效数据"的特殊值,必须排除掉,否则会渲染出一堆杂乱的噪点。
五、地理坐标对齐:最容易踩坑的部分
5.1 建立局部坐标系
地球是一个椭球体,在不同地理位置,"上"的方向是不同的(法向量方向)。如果直接把体数据摆在三维坐标原点,它会浮在地球外面某个随机位置。
Cesium 提供了 eastNorthUpToFixedFrame,可以在任意地理位置建立一个局部东北天坐标系(X=东,Y=北,Z=上),返回一个 4x4 变换矩阵:
kotlin
this.eastNorthUpTransform = Cesium.Transforms.eastNorthUpToFixedFrame(
this.centerCartesian
);
用这个矩阵把 Three.js 的 Group 变换到正确的地理位置,体数据就会贴合地球表面了。
5.2 Cesium 和 Three.js 的矩阵行列序差异
这里有一个很隐蔽的坑:Cesium 的 Matrix4 是列主序(column-major)存储,Three.js 的 Matrix4 也是列主序,但两者的元素排列方式在手动转换时需要注意索引顺序。
Cesium 的矩阵按 [m00, m10, m20, m30, m01, m11, ...] 存储(列优先),对应到 Three.js 的 matrix.set() 时要按行来填:
kotlin
matrix.set(
this.eastNorthUpTransform[0], this.eastNorthUpTransform[4],
this.eastNorthUpTransform[8], this.eastNorthUpTransform[12],
this.eastNorthUpTransform[1], this.eastNorthUpTransform[5],
this.eastNorthUpTransform[9], this.eastNorthUpTransform[13],
this.eastNorthUpTransform[2], this.eastNorthUpTransform[6],
this.eastNorthUpTransform[10], this.eastNorthUpTransform[14],
this.eastNorthUpTransform[3], this.eastNorthUpTransform[7],
this.eastNorthUpTransform[11], this.eastNorthUpTransform[15]
);
这里如果行列搞反,渲染出来的体会在地球上某个完全错误的位置,而且姿态也是歪的,初次遇到这个问题排查起来很费时间。
5.3 每帧同步相机位置
Ray Marching Shader 需要知道相机在体数据局部坐标系中的位置(不是世界坐标,是局部坐标),才能正确计算光线方向:
kotlin
const cameraPosLocal = Cesium.Matrix4.multiplyByPoint(
this.inverseTransform,
cameraPosition,
this.scratchCartesian
);
mat.uniforms.uCameraLocalPos.value.set(
cameraPosLocal.x,
cameraPosLocal.y,
cameraPosLocal.z
);
用逆变换矩阵把 Cesium 相机的 ECEF 坐标变换到局部空间,每帧更新 uniform。这个每帧都要算,但 Cesium.Matrix4.multiplyByPoint 可以复用 scratch 对象避免每帧分配内存。
六、色标设计
气象雷达有一套约定俗成的配色方案,低反射率用蓝色(弱降水),中等强度用绿色(中等降水),高强度用黄红色(强降水),极强用紫色/白色(冰雹区域)。这套配色是气象行业标准,不能随意发挥。
6.1 用 Canvas 生成 ColorMap 纹理
把色标做成一张 512×1 的纹理,横轴对应归一化的 dBZ 值:
ini
private createColorMap(): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 1;
const ctx = canvas.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 512, 0);
COLOR_STOPS.forEach((stop) => {
let t = (stop.value - COLOR_SCALE_MIN) / (COLOR_SCALE_MAX - COLOR_SCALE_MIN);
grad.addColorStop(Math.max(0, Math.min(1, t)), stop.color);
});
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 512, 1);
// ...
}
Shader 里通过 texture2D(uColorMap, vec2(val, 0.5)) 查颜色,val 是归一化的 dBZ 值,直接映射到色标横轴。
6.2 Alpha 通道控制分类可见性
除了颜色,每个分类还有独立的透明度控制。实现方式是直接把 alpha 写进 ColorMap 纹理的 A 通道------可见的分类写实际 opacity,隐藏的分类写 0:
ini
const alphaVal = cat.visible ? cat.opacity : 0;
data[i * 4 + 3] = Math.floor(alphaVal * 255);
这样用户切换某个回波强度分类的显示状态时,只需要重新生成 ColorMap 纹理并更新 uniform,不需要重新上传体数据,响应很快。
七、三个关键性能优化
体渲染天然是高消耗的操作,不做优化在生产环境里根本用不了。
优化①:动态步数
问题:从正上方俯视时,光线穿过体数据的路径很短(就是体数据的高度);从侧面水平看过去,光线穿过的路径可能是高度的几倍甚至十几倍。固定步数在侧视角时采样密度严重不足,体数据会出现明显的透视穿透感,像一个透明的豆腐块。
解法:根据当前光线长度和体数据最小维度的比值,动态调整步数:
ini
float minDim = min(uBoxSize.z, min(uBoxSize.x, uBoxSize.y));
float ratio = rayLength / minDim;
// clamp 防止极端视角下步数爆炸导致 GPU 超载
float stepsVal = uSteps * clamp(ratio, 1.0, 6.0);
同时把循环上限从 200 改到 1000,保证动态增加的步数能跑完:
css
for(int i = 0; i < 1000; i++) {
if(float(i) >= stepsVal || alphaAccum > 0.95) break;
// ...
}
优化②:离屏半分辨率 FBO
问题:体渲染每个像素都要跑几百次纹理采样,全屏全分辨率每帧跑一遍,帧率直接掉到个位数。
解法:把体渲染绘制到一个 0.75 分辨率的离屏 RenderTarget,再用一个全屏四边形把结果合成回主场景:
php
// 离屏 FBO,75% 分辨率
this.renderTarget = new THREE.WebGLRenderTarget(w, h, {
format: THREE.RGBAFormat,
type: THREE.UnsignedByteType,
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: false, // 不需要深度缓冲,减少显存占用
stencilBuffer: false,
});
合成 Shader 非常简单,就是把 FBO 纹理贴到全屏四边形上,alpha 小于阈值的像素直接丢弃:
ini
void main() {
vec4 color = texture2D(uFBOTexture, vUv);
if (color.a < 0.001) discard;
gl_FragColor = color;
}
0.75 分辨率意味着像素数量只有原来的 56%,体渲染的计算量直接降了将近一半,而且 LinearFilter 放大时视觉上几乎看不出差别。
优化③:相机静止跳帧
问题:Cesium 的渲染循环每帧都在跑,但如果用户没有操作地球(相机静止),体渲染的结果和上一帧完全一样,重新渲染纯粹是浪费。
解法:缓存上一帧的相机位置和方向,计算变化量,低于阈值时直接跳过 FBO 渲染,复用上一帧的结果:
kotlin
const dPos =
(camPos.x - this.lastCamPosX) ** 2 +
(camPos.y - this.lastCamPosY) ** 2 +
(camPos.z - this.lastCamPosZ) ** 2;
if (dPos > POS_THRESHOLD || dDir > DIR_THRESHOLD || isNaN(this.lastCamPosX)) {
this.needsRedraw = true;
// 更新缓存
}
FBO 的合成通道(把上一帧缓存贴到屏幕)每帧还是要跑的,但体渲染本体只在相机移动时才执行。实测在相机静止时 GPU 占用从 40%+ 降到 5% 以下。
另外加了一个 markDirty() 方法,色标变化、数据更新等外部触发重绘的场景可以手动调用:
csharp
public markDirty(): void {
this.needsRedraw = true;
}
八、踩过的坑
坑一:相机在体内部时 BackSide 渲染失效
体数据用的是 THREE.BackSide------从盒子内部看,渲染背面。这样从外部看盒子时,Shader 里的 vLocalPos 是盒子背面的位置,光线从相机出发打向背面,刚好覆盖了整个体积。
但当相机进入盒子内部时,此时相机已经越过了背面,intersectAABB 算出来的 tNear 是负数,按正常逻辑处理会导致从相机背后开始采样。所以需要在 CPU 侧判断相机是否在盒子内,传一个 uCameraInside 给 Shader 处理边界情况。
arduino
const isInsideBox =
Math.abs(cameraPosLocal.x) < this.scratchHalfSize.x + margin &&
Math.abs(cameraPosLocal.y) < this.scratchHalfSize.y + margin &&
Math.abs(cameraPosLocal.z) < this.scratchHalfSize.z + margin;
mat.uniforms.uCameraInside.value = isInsideBox;
坑二:高度方向"太扁"
气象雷达的扫描体积,水平范围几百公里,垂直高度只有十几公里。按真实比例渲染出来,体数据就是一个极度扁平的薄饼,从侧面看几乎就是一条线,视觉上完全看不出三维结构。
解决方案是在处理数据时把高度拉伸 2 倍:
ini
hDist = hDist * 2;
hCenter = hCenter + (hDist - originalHeight) / 2;
这是一个刻意的视觉夸张,不是真实比例,但在可视化领域这是常见做法。中心点也要同步上移,否则体数据的底部会嵌进地形里。
坑三:Cesium 矩阵列序导致体数据位置偏移
前面提到了这个问题。Cesium 的 Matrix4 内部是列主序,matrix[0] 是第一列第一行,matrix[1] 是第一列第二行。在手动转换到 Three.js 的 matrix.set() 时,set() 的参数顺序是行主序(第一行从左到右依次填入),所以 Cesium 的 [0,4,8,12] 对应的是矩阵的第一行,不是第一列。
如果直接按 0-15 顺序传入 matrix.set(),实际上是把矩阵转置了,体数据会出现在地球上某个错误位置,而且姿态是倒的。
九、总结
整个方案的核心链路是:
气象数据 → Float32Array → Data3DTexture → Ray Marching Shader → FBO 离屏渲染 → 合成到 Cesium 主场景
Three.js 和 Cesium 双引擎共存的关键是:共享同一个 WebGL Context,Cesium 管地球渲染,Three.js 管体积渲染,两者通过坐标系变换矩阵和每帧相机同步保持对齐。
三个性能优化组合起来,在中端显卡上可以跑到流畅的帧率(不同数据分辨率和视角会有差异):动态步数保证各视角下的渲染质量,半分辨率 FBO 降低计算量,跳帧优化减少不必要的重绘。
这套方案理论上不限于气象数据,医疗 CT、地质体、流体仿真等任何三维标量场数据都可以套用同样的架构。
如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区交流,接 Three.js / Cesium 相关的开发也可以私信。