《在 Cesium 中用 Three.js 实现气象雷达三维体渲染——从原理到性能优化》

在 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 相关的开发也可以私信。

相关推荐
牧艺6 小时前
用 Three.js 实现一个浏览器端 3D 看车的项目
前端·three.js
凌涘5 天前
从零掌握 CSS 3D:用几行代码让网页"立"起来
three.js
柳杉5 天前
我用Threejs 搓了一个 3D 中国地图设计器,开箱即用
前端·three.js·数据可视化
郝学胜-神的一滴15 天前
[简化版 GAMES 101] 计算机图形学 12:可见性与 Z‑Buffer 深度缓存
unity·godot·图形渲染·three.js·opengl·unreal
VcB之殇16 天前
[Three.js] 实现两个3D模型之间的粒子化切换
前端·javascript·three.js
郝学胜-神的一滴18 天前
中级OpenGL教程 008:精准控制高光光斑大小与强度
c++·unity·godot·three.js·图形学·opengl·unreal
xier12345621 天前
three-instance-batch 开发笔记
javascript·three.js
一根数据线23 天前
从几何压缩到KTX2纹理压缩:轻装3D的Three.js场景优化进阶
3d模型轻量化·three.js·3d模型·ktx2·轻装3d·纹理压缩
一根数据线24 天前
一键解决ThreeJS3D场景卡顿问题!轻装3D的几何体实例化与合并
3d模型轻量化·three.js·3d模型·轻装3d·实例化渲染·几何体合并