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(size64, points16, seed42): 生成 3D Worley 噪声返回 [0,1] 范围的密度场 rng np.random.default_rng(seed) # 随机撒点 pts rng.random((points, 3)) * size # 构建网格 grid np.zeros((size, size, size), dtypenp.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, dtypenp.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, tile64): 把 3D 体数据按 Z 轴切片拼成 8x8 的 2D 图集 size volume.shape[0] cols 8 atlas np.zeros((tile * cols, tile * cols), dtypenp.uint8) for z in range(size): row z // cols col z % cols slice_2d (volume[z] * 255).astype(np.uint8) atlas[row*tile:(row1)*tile, col*tile:(col1)*tile] slice_2d return atlas vol worley_3d(size64, points16) atlas pack_to_atlas(vol, tile64) # 保存为 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替代maxdensity 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.positionWCrayDir要通过逆视图投影矩阵从屏幕坐标算出来。在 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 噪声纹理都强制走一遍边界检查先渲染一个纯噪声的可视化层确认没有接缝和重复再往上叠光照和积分。这个习惯帮我省了不少后悔药。希望帮到你。本文还有配套的精品资源点击获取