ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cesium全球体积云实战:光线步进与3D噪声纹理

Cesium全球体积云实战:光线步进与3D噪声纹理

简介:这份资源面向在Cesium中实现全球体积云效果的WebGL开发者与图形学爱好者,针对原生积云仅适合小范围离散云朵、难以生成全球连续云层的痛点,给出了一套基于体渲染的可行方案。压缩包共4个文件,约9KB,以2个JavaScript脚本为核心,分别承担云层体数据生成与体积云渲染逻辑,另含1个HTML页面用于演示与调试,以及1个inscode工程配置文件,便于直接运行和二次修改。方案将perlin噪声与worley噪声组合,并引入分形布朗运动增强云层细节,体数据由CPU生成改为GPU渲染到纹理加速,配合光线步进与射线球体求交优化性能,且不依赖外部噪声纹理,实现步骤简洁。目前已有444人学习下载,适合仿真要求不高的场景参考,读者可从中获取噪声组合、三维纹理生成与光线步进渲染的完整代码思路,并据此调整性能与视觉效果的平衡。

1. 从一张“假云”截图说起:Cesium 全球体积云到底解决了什么

去年有个做气象可视化的朋友发来一张截图,问我为什么他的 Cesium 场景里云层像贴了一层塑料膜。我一看就明白了——他用的是传统的 billboard 贴图云,几十张半透明 PNG 叠在一起,相机一旋转就穿帮,云层没有厚度、没有自阴影、靠近了看就是一张纸。这不是他一个人的问题,几乎所有在 Cesium 里做全球云层的团队都卡在同一个地方:想要真实的体积感,又不想把浏览器跑崩。

这份 Cesium 全球体积云方案就是冲着这个矛盾来的。它用 WebGL 的体渲染(Volume Rendering)思路,把云层当作一个真正有厚度的三维介质来处理,而不是一堆贴片。核心手段是光线步进(Ray Marching)配合 3D 噪声纹理,在片元着色器里沿着视线方向采样密度场,累积出透射率和散射颜色。最终效果是:相机可以穿进云里,云会随视角变化产生正确的遮挡和明暗,全球尺度下也能保持可接受的帧率。

适合谁用?如果你正在做气象、飞行模拟、大场景数字孪生这类需要全球云层的 Cesium 项目,并且团队里有人能读懂 GLSL 着色器代码,这份资源能帮你省掉从零推导体渲染积分公式的时间。如果你只是想给场景加几朵装饰性的云,那用传统粒子或贴图方案更划算,不必上体渲染这套重武器。

2. 体渲染在 Cesium 里怎么落地:从噪声纹理到光线步进

2.1 为什么不用贴图云,体渲染的选型理由

传统贴图云的本质是二维图像叠加,它的光照是预先烘焙在纹理里的,一旦相机角度变化,高光方向和阴影位置就对不上。体渲染不一样,它在运行时沿着每条视线做积分,光照是实时算出来的。代价是计算量大——每条光线要采样几十到上百次,每次采样都要查 3D 噪声纹理、算密度、算光照。

那为什么还要选它?因为全球云层这个场景对真实感的要求很高。飞机穿过云层时,云应该有明显的体积遮挡;卫星视角俯瞰时,云顶和云底应该有亮度差异。这些效果贴图方案做不到。体渲染的另一个好处是可参数化——云量、云高、云厚、风速都可以做成 uniform 变量,运行时动态调整,不用重新烘焙纹理。

常见做法是用 Perlin 噪声或 Worley 噪声生成 3D 纹理。Perlin 噪声适合做连续平滑的密度变化,Worley 噪声适合做云朵的团块感。实际项目里往往两者叠加:Worley 定团块轮廓,Perlin 加细节扰动。纹理分辨率一般用 128³ 或 64³,再高显存吃不消,再低云会糊成一片。

2.2 生成 3D 噪声纹理:CPU 端预计算与打包

体渲染需要一张 3D 纹理作为密度场的基础。WebGL 2.0 支持sampler3D,但 Cesium 默认走 WebGL 1.0 的兼容路径,所以常见做法是把 3D 噪声切片成 2D 图集(Texture Atlas),在着色器里手动做三层插值。下面这段代码用 Python 生成 Worley 噪声并打包成 2D 图集:

import numpy as np from scipy.ndimage import distance_transform_edt def worley_3d(size=64, points=16, seed=42): """生成 3D Worley 噪声,返回 [0,1] 范围的密度场""" rng = np.random.default_rng(seed) # 随机撒点 pts = rng.random((points, 3)) * size # 构建网格 grid = np.zeros((size, size, size), dtype=np.float32) xx, yy, zz = np.mgrid[0:size, 0:size, 0:size] coords = np.stack([xx, yy, zz], axis=-1).astype(np.float32) # 对每个点算距离,取最小值 min_dist = np.full((size, size, size), np.inf, dtype=np.float32) for p in pts: d = np.linalg.norm(coords - p, axis=-1) min_dist = np.minimum(min_dist, d) # 归一化并反转,让团块中心值高 grid = 1.0 - (min_dist / min_dist.max()) return grid def pack_to_atlas(volume, tile=64): """把 3D 体数据按 Z 轴切片,拼成 8x8 的 2D 图集""" size = volume.shape[0] cols = 8 atlas = np.zeros((tile * cols, tile * cols), dtype=np.uint8) for z in range(size): row = z // cols col = z % cols slice_2d = (volume[z] * 255).astype(np.uint8) atlas[row*tile:(row+1)*tile, col*tile:(col+1)*tile] = slice_2d return atlas vol = worley_3d(size=64, points=16) atlas = pack_to_atlas(vol, tile=64) # 保存为 PNG,后续在 Cesium 里作为纹理加载 from PIL import Image Image.fromarray(atlas).save("cloud_noise_atlas.png")

这段代码的逻辑分三步:先生成 64³ 的 Worley 噪声体数据,每个体素的值代表该点到最近随机点的距离,反转后团块中心接近 1、边缘接近 0;然后按 Z 轴切成 64 张 64×64 的切片,拼成 512×512 的图集;最后存成 PNG 供前端加载。

参数方面,points控制团块数量,值越大云越碎,16 到 32 之间比较合适。size是体数据分辨率,64 够用,128 更细腻但生成时间和显存翻四倍。tile必须和切片尺寸一致,否则采样时坐标对不上。注意图集的行列数要能整除切片总数,64 张切片排成 8×8 正好。

2.3 在 Cesium 自定义 Primitive 里挂载体渲染材质

Cesium 的Primitive支持自定义Material,但体渲染需要控制整个屏幕空间的步进,所以更常见的做法是写一个自定义PostProcessStage或者直接挂一个全屏 quad。下面是一个简化的 Cesium 自定义 Primitive 骨架:

const viewer = new Cesium.Viewer('cesiumContainer'); // 加载噪声图集 const noiseTexture = new Cesium.Texture({ context: viewer.scene.context, source: 'cloud_noise_atlas.png', sampler: new Cesium.Sampler({ minificationFilter: Cesium.TextureMinificationFilter.LINEAR, magnificationFilter: Cesium.TextureMagnificationFilter.LINEAR, wrapS: Cesium.TextureWrap.CLAMP, wrapT: Cesium.TextureWrap.CLAMP }) }); // 自定义体渲染命令 const cloudStage = new Cesium.PostProcessStage({ fragmentShader: ` uniform sampler2D u_noiseAtlas; uniform vec3 u_cameraPos; uniform float u_time; in vec2 v_textureCoordinates; // 从图集中采样 3D 噪声,p 是 [0,1] 范围的三维坐标 float sampleNoise(vec3 p) { float z = p.z * 63.0; float z0 = floor(z); float z1 = min(z0 + 1.0, 63.0); float f = z - z0; // 计算两个切片在图集中的 UV vec2 uv0 = vec2( (mod(z0, 8.0) + p.x) / 8.0, (floor(z0 / 8.0) + p.y) / 8.0 ); vec2 uv1 = vec2( (mod(z1, 8.0) + p.x) / 8.0, (floor(z1 / 8.0) + p.y) / 8.0 ); float n0 = texture(u_noiseAtlas, uv0).r; float n1 = texture(u_noiseAtlas, uv1).r; return mix(n0, n1, f); } void main() { // 这里做光线步进,累积密度和颜色 // 具体积分逻辑见下一节 vec3 color = vec3(0.0); float alpha = 0.0; // ... 步进循环 out_FragColor = vec4(color, alpha); } `, uniforms: { u_noiseAtlas: () => noiseTexture, u_cameraPos: () => viewer.camera.positionWC, u_time: () => performance.now() / 1000.0 } }); viewer.scene.postProcessStages.add(cloudStage);

这里的关键点是sampleNoise函数——它把三维坐标映射到二维图集上,先算 Z 轴落在哪两个切片,再分别采样后做线性插值。mod(z0, 8.0)和floor(z0 / 8.0)负责把切片索引转成图集上的行列位置。

参数说明:u_cameraPos每帧更新,用于计算视线方向;u_time驱动云的运动,可以在噪声采样时给坐标加一个时间偏移。注意PostProcessStage的着色器里不能直接访问 Cesium 的相机矩阵,需要自己通过 uniform 传进去。如果场景里还有其他后处理效果,注意 stage 的添加顺序,体渲染一般放在最后。

3. 光线步进积分:密度采样、透射率与光照累积

3.1 步进循环的写法和步长选择

光线步进的核心是一个循环:从相机出发,沿视线方向每次前进一个步长,采样密度,累积颜色和透明度。下面是一个典型的积分循环:

const int MAX_STEPS = 64; const float STEP_SIZE = 0.02; // 单位:世界坐标归一化后的比例 vec4 raymarch(vec3 rayOrigin, vec3 rayDir) { vec3 pos = rayOrigin; vec3 accumColor = vec3(0.0); float accumAlpha = 0.0; for (int i = 0; i < MAX_STEPS; i++) { // 只在大气层范围内采样 if (pos.y < CLOUD_BOTTOM || pos.y > CLOUD_TOP) { pos += rayDir * STEP_SIZE; continue; } // 把世界坐标映射到 [0,1] 的噪声空间 vec3 noiseCoord = (pos - CLOUD_ORIGIN) / CLOUD_SCALE; noiseCoord.x += u_time * 0.01; // 风吹效果 float density = sampleNoise(noiseCoord); // 密度阈值裁剪,低于阈值的直接跳过 density = max(0.0, density - DENSITY_THRESHOLD); density *= DENSITY_MULTIPLIER; if (density > 0.001) { // 计算这一步的透射率 float transmittance = exp(-density * STEP_SIZE * ABSORPTION); // 简单光照:朝向太阳的方向再采样一次 float lightDensity = sampleNoise(noiseCoord + SUN_DIR * 0.05); float lightTransmittance = exp(-lightDensity * LIGHT_ABSORPTION); vec3 stepColor = SUN_COLOR * lightTransmittance * density; // 能量累积 accumColor += stepColor * accumAlpha * transmittance; accumAlpha += (1.0 - accumAlpha) * (1.0 - transmittance); if (accumAlpha > 0.99) break; // 完全不透明,提前退出 } pos += rayDir * STEP_SIZE; } return vec4(accumColor, accumAlpha); }

逻辑上分四步:先判断当前采样点是否在云层高度范围内,不在就跳过;然后把世界坐标转成噪声纹理坐标,加上时间偏移实现云的运动;接着采样密度,做阈值裁剪和倍率缩放;最后算透射率和光照,累积到颜色和透明度上。

步长STEP_SIZE是最关键的参数。太小(比如 0.005)画面细腻但帧率暴跌,太大(比如 0.1)云会出现明显的层状条纹。经验值是在 0.01 到 0.03 之间,配合MAX_STEPS64 到 128 步,能覆盖大多数全球视角。如果相机贴近云层,可以动态减小步长;如果拉远到太空视角,可以增大步长并减少步数。

3.2 透射率计算和提前退出优化

透射率transmittance表示光线穿过这段介质后还剩多少能量,用 Beer-Lambert 定律算:exp(-density * distance * absorption)。absorption是吸收系数,值越大云越不透明。这个参数需要和DENSITY_MULTIPLIER配合调——密度倍率控制云的“厚实感”,吸收系数控制光的衰减速度。

提前退出是性能优化的关键。当accumAlpha接近 1 时,后面的采样已经看不见了,直接break能省掉大量无效计算。另一个优化是空区域跳过:如果连续几步密度都低于阈值,可以加大步长快速穿过。常见做法是维护一个emptySteps计数器,超过阈值就把步长翻倍。

还有一个容易忽略的点是抖动(Dithering)。固定步长会在云边缘产生规则的条带伪影,解决办法是给每条光线的起始偏移加一个随机抖动:

float dither = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453); vec3 pos = rayOrigin + rayDir * STEP_SIZE * dither;

这行代码用屏幕坐标生成一个伪随机数,让每条光线的起始位置错开,条带就被打散了。代价是画面会有轻微噪点,可以配合时间累积(TAA)来消除。

4. 避坑与排查:体渲染在 Cesium 里最容易翻车的五个地方

4.1 云层边缘出现硬切边

现象:云和天空的交界处有一条明显的直线,像被刀切过。

原因:密度阈值裁剪太粗暴。density = max(0.0, density - DENSITY_THRESHOLD)这行在密度刚好等于阈值时会产生突变,加上步长较大,边缘就变成了硬边。

解决:把硬阈值改成平滑过渡,用smoothstep替代max:

density = smoothstep(DENSITY_THRESHOLD, DENSITY_THRESHOLD + 0.1, density);

同时把步长减小到 0.015 以下,边缘会柔和很多。

4.2 相机穿进云里后画面全白或全黑

现象:相机移动到云层内部时,屏幕要么一片白要么一片黑,什么都看不见。

原因:全白通常是accumAlpha累积过快,几步就饱和到 1;全黑可能是光照方向算反了,或者SUN_DIR没有归一化。

解决:先检查SUN_DIR是否做了normalize。然后在相机进入云层时动态降低DENSITY_MULTIPLIER,或者限制accumAlpha的最大值(比如min(accumAlpha, 0.95)),留一点透光感。如果用的是 Cesium 的PostProcessStage,确认相机的 near/far 平面没有把云层裁掉。

4.3 帧率从 60 掉到 15

现象:加上体渲染后帧率断崖式下跌,尤其是在 4K 屏幕上。

原因:光线步进是逐像素计算的,4K 屏幕有 800 多万像素,每个像素 64 步就是 5 亿次采样,GPU 再强也扛不住。

解决:三个方向。第一,降分辨率渲染——把体渲染画到一半分辨率的 framebuffer 上,再上采样合成,视觉损失很小但性能翻四倍。第二,动态步数——根据相机高度调整MAX_STEPS,高空用 32 步,低空用 64 步。第三,加时间累积——每帧只更新一部分像素,多帧累积出完整画面,适合静态或慢速相机。

4.4 噪声纹理接缝明显

现象:云层在某些角度下出现规则的网格状接缝,像瓷砖拼缝。

原因:图集采样时 UV 计算有误,切片之间的边界没有正确处理。常见错误是mod(z0, 8.0)在 z0 接近 8 的倍数时产生跳变。

解决:确保图集的 wrap 模式设为CLAMP而不是REPEAT,并且在计算 UV 时给切片内部留半个像素的边距:

vec2 uv0 = vec2( (mod(z0, 8.0) + p.x * 0.98 + 0.01) / 8.0, (floor(z0 / 8.0) + p.y * 0.98 + 0.01) / 8.0 );

4.5 云层不随相机旋转变化

现象:转动相机时云层像贴在天上的背景图,没有视差和体积感。

原因:光线步进的起始点或方向算错了,常见于把相机位置传成了世界原点,或者rayDir没有从 NDC 坐标正确反投影。

解决:确认u_cameraPos传的是viewer.camera.positionWC,rayDir要通过逆视图投影矩阵从屏幕坐标算出来。在 Cesium 里可以用viewer.camera.getPickRay(windowPosition)拿到世界空间的光线,比自己手算矩阵更可靠。

5. 进阶调参与验证:让云真正“活”起来

5.1 用高度梯度控制云层形状

基础的 Worley 噪声生成的云是均匀的团块,缺少真实云层的层积感。一个实用技巧是叠加高度梯度:在垂直方向上乘一个权重函数,让云在中部最浓、顶部和底部渐薄。

float heightFactor = smoothstep(CLOUD_BOTTOM, CLOUD_BOTTOM + 0.3, pos.y) * (1.0 - smoothstep(CLOUD_TOP - 0.3, CLOUD_TOP, pos.y)); density *= heightFactor;

CLOUD_BOTTOM和CLOUD_TOP是云层的世界坐标高度范围。这个权重让云层有了自然的上下边界,不再是悬浮的球状团块。调参时先把CLOUD_BOTTOM设在地面以上 1500 米,CLOUD_TOP设在 4000 米,过渡带宽度 0.3 对应大约 750 米。

5.2 验证体渲染正确性的三个检查点

调完参数怎么知道对不对?我一般会走这三个检查:

检查项操作方法预期结果
透射率守恒把SUN_COLOR设为纯白,ABSORPTION设为 0云应该完全透明,画面只剩天空
光照方向固定相机,旋转SUN_DIR一圈云的亮面应该始终朝向太阳方向
深度一致性在云层前后各放一个 Cesium 实体云应该正确遮挡后面的实体,被前面的实体遮挡

第一个检查验证积分公式没有多余的能量增益;第二个检查验证光照计算没有把方向搞反;第三个检查验证体渲染和 Cesium 场景的深度关系是否正确。这三个过了,基本功能就没大问题。

5.3 一个让我返工三次的教训

最早做这个方案的时候,我图省事把噪声纹理的 wrap 模式设成了REPEAT,想着云可以无限平铺。结果全球视角下云层出现了明显的周期性重复,像壁纸一样。更坑的是这个问题在局部视角下看不出来,只有拉到太空视角才暴露。后来改成CLAMP并在着色器里手动做边界衰减,才解决。

从那以后我每次加载 3D 噪声纹理,都强制走一遍边界检查:先渲染一个纯噪声的可视化层,确认没有接缝和重复,再往上叠光照和积分。这个习惯帮我省了不少后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进