简介:这份资源面向在Cesium中实现全球体积云效果的WebGL开发者与图形学爱好者,针对原生积云仅适合小范围离散云朵、难以生成全球连续云层的痛点,给出了一套基于体渲染的可行方案。压缩包共4个文件,约9KB,以2个JavaScript脚本为核心,分别承担云层体数据生成与体积云渲染逻辑,另含1个HTML页面用于效果演示与调试,以及1个inscode工程配置文件,便于快速运行与二次修改。方案将perlin噪声与worley噪声组合,并引入分形布朗运动增强云层细节,体数据由CPU生成改为GPU渲染到纹理加速,配合光线步进与射线球体求交优化性能,且不依赖外部噪声纹理,实现步骤简洁。目前已有444人学习下载,适合仿真要求不高、希望快速上手体积云渲染的读者参考,可从中获取噪声组合、三维纹理生成与光线步进渲染的完整代码思路。
1. 全球体积云在 Cesium 里到底难在哪
第一次在 Cesium 里做全球体积云,很多人会下意识觉得“不就是贴一层带透明度的云图吗”。真上手才发现,平面云图和体积云完全是两回事:平面云图是二维贴图,视角一压低就穿帮;体积云要在三维空间里做光线步进,全球尺度下还要处理球面坐标、大气散射、昼夜过渡和相机远近切换。标题里的“全球体积云方案”,说的就是一套能在 Cesium 地球上跑、覆盖全球、随视角和光照实时变化的云层渲染实现,通常以自定义 Primitive 或后处理阶段的形式挂进场景。
它解决的问题很具体:让地球表面上方那层云有厚度、有自阴影、有边缘透光,而不是一张贴图。适合谁?适合已经能用 Cesium 加载地形影像、写过自定义着色器、想给地球加一层可信大气氛围的开发者。如果你连 Cesium 的 Scene、Camera、Primitive 还没摸熟,建议先把官方沙盒里的自定义几何体跑通再回来。这一章先把概念和边界立住,后面几章直接上代码和参数。
2. 体积云的核心原理与 Cesium 集成选型
2.1 光线步进为什么是体积云的地基
体积云的本质,是在视线方向上对云层密度场做积分。相机发出的一条射线穿过云层区间,我们把它切成若干小段,每段采样一次密度,累加透射率和散射贡献。这个“切段采样”就是光线步进(Ray Marching)。密度场通常用三维噪声生成,最常用的是 Perlin-Worley 组合:Worley 噪声负责云团的絮状边缘,Perlin 噪声负责大尺度起伏。采样点密度由噪声值、高度衰减、覆盖度共同决定。
在 Cesium 里做这件事,关键是把射线从屏幕空间转到地球局部空间。Cesium 的相机在世界坐标系(ECEF)里,地球是个椭球,所以射线和云层的求交不能简单用平面。常见做法是:在片元着色器里拿到世界坐标和相机位置,构造射线,与云层内外两个球面求交,得到步进区间,再在区间内循环采样。这个循环次数直接决定画质和帧率,是后面调参的主战场。
2.2 用 CustomPrimitive 还是 PostProcess:选型理由
Cesium 提供两条路:一是自定义 Primitive,把云当成一个包住地球的几何体渲染;二是 PostProcessStage,在全屏后处理里做。两者差别很大。
自定义 Primitive 的优点是深度关系正确,云能被地形和建筑遮挡,适合做“云在地球上方”的物理感。缺点是几何体要足够大,且要处理背面剔除和相机进入云层的情况。PostProcessStage 的优点是实现简单,直接拿深度缓冲重建世界坐标,不用管几何体。缺点是它默认在全屏上做,云和地形的遮挡要靠深度判断,稍不注意云就糊在山前面。
我一般会选自定义 Primitive 方案,因为全球体积云最怕的就是“云飘在地形前面”这种穿帮。下面给一个最小可跑的骨架,用 Cesium 的 CustomPrimitive 思路,实际项目里可以封装成自己的 Primitive 类。
// 云层外壳几何体:一个比地球略大的球,包住整个地球 const cloudRadius = 6470000; // 地球平均半径约 6371km,云层顶再高约 100km const cloudGeometry = new Cesium.SphereGeometry({ radius: cloudRadius, vertexFormat: Cesium.VertexFormat.POSITION_ONLY }); // 自定义外观:把着色器塞进去 const cloudAppearance = new Cesium.MaterialAppearance({ material: new Cesium.Material({ fabric: { type: 'CloudVolume', uniforms: { uTime: 0.0, uCloudCoverage: 0.45, // 云量,0~1,越大云越多 uCloudDensity: 0.02, // 密度系数,影响不透明度 uStepCount: 64, // 步进次数,画质与性能的平衡点 uLightDir: new Cesium.Cartesian3(1.0, 0.5, 0.3) }, source: cloudShaderSource // 见下一节的 GLSL } }), translucent: true, closed: false }); const cloudPrimitive = new Cesium.Primitive({ geometryInstances: new Cesium.GeometryInstance({ geometry: cloudGeometry }), appearance: cloudAppearance, asynchronous: false }); viewer.scene.primitives.add(cloudPrimitive);这段代码做了三件事:造一个比地球大的球体作为云层容器;用 Material 挂自定义着色器;把 Primitive 加进场景。参数里uCloudCoverage控制云量,uCloudDensity控制浓淡,uStepCount是步进次数。注意translucent: true必须开,否则云不透明。closed: false是为了让相机在球内时也能看到云,避免整个球被剔除。
2.3 着色器里的射线求交与步进循环
着色器是这套方案的核心。下面这段 GLSL 是简化版,重点看射线与球面求交、步进循环和密度采样三部分。
// 云层内外半径,单位米 const float INNER_RADIUS = 6371000.0; const float OUTER_RADIUS = 6470000.0; // 射线与球求交,返回最近和最远距离 vec2 raySphereIntersect(vec3 rayOrigin, vec3 rayDir, float radius) { float b = dot(rayOrigin, rayDir); float c = dot(rayOrigin, rayOrigin) - radius * radius; float d = b * b - c; if (d < 0.0) return vec2(-1.0); d = sqrt(d); return vec2(-b - d, -b + d); } // 简化的密度采样:用高度和噪声模拟云 float sampleDensity(vec3 pos, float time) { float height = length(pos); // 高度衰减:云集中在中间层 float h = (height - INNER_RADIUS) / (OUTER_RADIUS - INNER_RADIUS); float heightFalloff = smoothstep(0.0, 0.3, h) * smoothstep(1.0, 0.6, h); // 三维噪声,实际项目用纹理或程序化噪声 float noise = fract(sin(dot(pos.xyz + time, vec3(12.9898, 78.233, 45.164))) * 43758.5453); float density = max(0.0, noise - (1.0 - uCloudCoverage)) * heightFalloff * uCloudDensity; return density; } void main() { vec3 rayOrigin = czm_viewerPositionWC; vec3 rayDir = normalize(czm_windowToEyeCoordinates(gl_FragCoord.xy).xyz); // 转到世界方向 rayDir = normalize((czm_inverseView * vec4(rayDir, 0.0)).xyz); vec2 inner = raySphereIntersect(rayOrigin, rayDir, INNER_RADIUS); vec2 outer = raySphereIntersect(rayOrigin, rayDir, OUTER_RADIUS); float tNear = max(inner.x, outer.x); float tFar = min(inner.y, outer.y); if (tNear > tFar || tFar < 0.0) discard; float stepSize = (tFar - tNear) / float(uStepCount); float transmittance = 1.0; vec3 scatteredLight = vec3(0.0); for (int i = 0; i < 128; i++) { if (i >= uStepCount) break; float t = tNear + (float(i) + 0.5) * stepSize; vec3 pos = rayOrigin + rayDir * t; float density = sampleDensity(pos, uTime); if (density > 0.0) { // 简单的光照累积,实际要加自阴影和相位函数 float lightAtten = exp(-density * stepSize * 10.0); scatteredLight += transmittance * density * stepSize * uLightDir * lightAtten; transmittance *= lightAtten; if (transmittance < 0.01) break; } } gl_FragColor = vec4(scatteredLight, 1.0 - transmittance); }逻辑说明:先算射线和内外球面的交点,得到有效步进区间;然后在区间内均匀步进,每个采样点算密度,累加散射光并衰减透射率。uStepCount控制循环次数,代码里写死 128 上限是为了兼容部分 GPU 的循环限制,实际用 uniform 控制。参数上,INNER_RADIUS和OUTER_RADIUS决定云层厚度,heightFalloff决定云集中在哪个高度,uCloudCoverage决定云量。注意czm_viewerPositionWC和czm_inverseView是 Cesium 内置变量,直接用即可。
3. 把全球体积云跑起来:从噪声到光照的完整链路
3.1 三维噪声纹理的生成与采样
上一节的噪声是凑数的,真实云层必须用三维噪声纹理。常见做法是预生成一张 128x128x128 的 3D 纹理,里面存 Perlin-Worley 组合噪声。生成可以在 CPU 端用脚本做,也可以运行时用计算着色器,但 Cesium 环境里最稳的是预生成后作为纹理传入。
// 用 canvas 模拟生成一张 2D 噪声图,实际项目应生成 3D 纹理 function generateNoiseTexture(size) { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(size, size); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const i = (y * size + x) * 4; // 简化的值噪声,实际用 Worley + Perlin 组合 const v = Math.random() * 255; imageData.data[i] = v; imageData.data[i + 1] = v; imageData.data[i + 2] = v; imageData.data[i + 3] = 255; } } ctx.putImageData(imageData, 0, 0); return new Cesium.Material.ImageUniform({ image: canvas, repeat: new Cesium.Cartesian2(4.0, 4.0) }); }这段代码只是示意,真实项目里我会用离线工具生成 3D 纹理,再通过Cesium.Texture或自定义 uniform 传入。参数上,纹理分辨率 128 够用,256 更细腻但显存翻倍。采样时用texture(noiseTex, pos * scale),scale控制云团大小,一般 0.0001 到 0.001 之间调。
3.2 光照模型:自阴影与相位函数
体积云好看的关键在光照。最简单的做法是只算太阳方向的环境光,云会显得平。要出效果,得加自阴影:从每个采样点再向光源方向步进几次,看有多少密度挡住光。这就是所谓的“二次步进”,性能开销大,但云会有立体感。
// 向光源方向做短距离步进,估算阴影 float lightMarch(vec3 pos, vec3 lightDir, float stepSize) { float shadow = 0.0; float t = 0.0; for (int i = 0; i < 6; i++) { vec3 p = pos + lightDir * t; shadow += sampleDensity(p, uTime) * stepSize; t += stepSize * 2.0; } return exp(-shadow * 3.0); }参数上,二次步进次数一般 4 到 8 次,再多帧率扛不住。相位函数用 Henyey-Greenstein 近似,控制云边缘的银边效果,g值取 0.6 到 0.8 之间,越大银边越集中。
3.3 昼夜过渡与大气散射的融合
全球云不能只有白天。太阳方向变化时,云的受光面要跟着变。Cesium 自带czm_sunDirectionWC,直接拿来用。夜晚云不能全黑,要加一点环境光,否则地球暗面云就消失了。常见做法是混合一个极低的环境色,再叠加城市灯光反射的暖色。
vec3 sunDir = czm_sunDirectionWC; float sunAmount = max(dot(rayDir, sunDir), 0.0); vec3 dayColor = vec3(1.0, 0.98, 0.95); vec3 nightColor = vec3(0.05, 0.06, 0.1); vec3 ambient = mix(nightColor, dayColor, smoothstep(-0.1, 0.2, sunDir.z));这段把太阳方向和视线方向点乘,决定云的受光强度,再用smoothstep做昼夜过渡。参数-0.1和0.2是过渡区间,调大过渡更柔和。注意czm_sunDirectionWC是 Cesium 内置的,不用自己算。
4. 性能与画质的平衡:参数怎么调不翻车
4.1 步进次数、分辨率与帧率的三角关系
体积云是典型的“拿 GPU 换画质”。步进次数从 32 提到 64,画质提升明显;64 到 128,提升变小但帧率掉一半。我的经验是:桌面端 64 次起步,移动端 32 次,配合时间抖动(Temporal Dithering)用低步数也能出可接受的效果。分辨率上,不要全屏跑,可以半分辨率渲染再上采样,Cesium 的 PostProcessStage 支持textureScale,设 0.5 能省一半像素。
// 半分辨率后处理,降低体积云像素开销 const cloudPostProcess = new Cesium.PostProcessStage({ fragmentShader: cloudCompositeShader, textureScale: 0.5, // 0.5 表示半分辨率 uniforms: { uCloudTexture: () => cloudRenderTarget, uStepCount: 48 } }); viewer.scene.postProcessStages.add(cloudPostProcess);textureScale是性价比最高的参数,0.5 到 0.75 之间肉眼几乎看不出差别,帧率能涨 30% 以上。uStepCount配合时间抖动,每帧偏移采样起点,用几帧累积出平滑效果。
4.2 相机高度与云层厚度的联动
全球体积云有个特殊问题:相机从地面拉到太空,云层在屏幕上的占比变化巨大。如果云层厚度固定,近地时云糊满屏,太空时云薄成一条线。常见做法是根据相机高度动态调整云层外半径和步进区间。相机低时,云层外半径调小,步进区间缩短;相机高时,外半径调大,步进区间拉长。
// 根据相机高度动态调整云层参数 function updateCloudParams(cameraHeight) { const t = Cesium.Math.clamp(cameraHeight / 2000000.0, 0.0, 1.0); const outerRadius = Cesium.Math.lerp(6400000, 6600000, t); const stepCount = Math.floor(Cesium.Math.lerp(32, 96, t)); cloudAppearance.material.uniforms.uOuterRadius = outerRadius; cloudAppearance.material.uniforms.uStepCount = stepCount; }这段逻辑是:相机越高,云层外半径越大,步进次数越多,保证太空视角下云层有足够采样。lerp的区间根据实际地球半径调,别照抄。注意每帧更新 uniform 有开销,可以隔几帧更新一次。
4.3 用时间抖动掩盖低步数伪影
低步数最明显的伪影是层状条纹。时间抖动(Temporal Jitter)在每帧给采样起点加一个随机偏移,多帧累积后条纹被平均掉。实现很简单,在着色器里加一个基于帧号的随机数。
// 基于帧号的时间抖动,打散步进起点 float jitter = fract(sin(float(uFrameNumber) * 12.9898) * 43758.5453); float t = tNear + (float(i) + jitter) * stepSize;uFrameNumber每帧递增,jitter在 0 到 1 之间。这样每帧采样位置错开,人眼看到的是平均结果。注意抖动要配合 TAA 或简单的帧混合,否则会有噪点。Cesium 没有内置 TAA,可以自己用一个累积缓冲,把上一帧和当前帧按 0.9 和 0.1 混合。
5. 避坑与排查:全球体积云最容易翻车的 5 个点
5.1 云飘在地形前面,遮挡关系全乱
现象:山体明明在云下面,云却画在山前面,或者云穿过建筑。原因:自定义 Primitive 的深度测试没开,或者云层几何体的深度写入被关了。解决:确保appearance.translucent为 true 的同时,深度测试保持开启,closed设 false 让相机进云时也能渲染。如果还是不对,检查云层几何体是否真的包住了地形,半径别设太小。
5.2 步进循环次数一高就黑屏或花屏
现象:把uStepCount调到 128 以上,画面直接黑掉或出现彩色噪点。原因:部分 GPU 对循环次数有硬限制,或者着色器编译超时。解决:把循环写成固定上限加 break,别用动态循环条件;步进次数用 uniform 控制,但循环体里判断if (i >= uStepCount) break;。另外检查显卡驱动,老驱动对长循环支持差。
5.3 云层边缘出现明显锯齿或硬边
现象:云和天空交界处像刀切一样。原因:密度场在边界处没有平滑衰减,或者步进区间算错导致突然截断。解决:在sampleDensity里加高度衰减的smoothstep,让密度在云层顶底平滑归零;射线求交时如果tNear和tFar太接近,直接 discard 避免硬边。
5.4 帧率骤降但 GPU 占用不高
现象:帧率掉到 20 但 GPU 使用率只有 50%。原因:CPU 端每帧更新 uniform 或重建几何体,造成 CPU 瓶颈。解决:把 uniform 更新频率降下来,几何体只建一次;用 Cesium 的Primitive而不是每帧重建。另外检查是不是在preRender里做了重计算。
5.5 昼夜过渡时云突然闪一下
现象:太阳方向变化时,云的颜色跳变。原因:smoothstep的过渡区间太窄,或者太阳方向向量没归一化。解决:把过渡区间拉宽,比如smoothstep(-0.2, 0.3, sunDir.z);确保czm_sunDirectionWC是归一化的,如果不是就手动 normalize。
6. 进阶:让全球体积云更可信的两个技巧
第一个技巧是分层云。真实大气里云不止一层,低层积云、高层卷云,密度和高度都不同。可以在着色器里叠两套密度场,一套低层厚实,一套高层稀薄,用不同的高度衰减和覆盖度。这样地球边缘的云会有层次感,而不是一坨。实现上就是sampleDensity里调两次噪声,按高度加权混合。参数上,低层覆盖度 0.5,高层 0.2,高度区间错开。
第二个技巧是用预计算的透射率查找表(LUT)替代实时二次步进。二次步进太贵,可以预生成一张 2D LUT,横轴是高度,纵轴是太阳角度,存透射率。运行时直接采样 LUT,省掉内层循环。LUT 分辨率 256x64 就够,生成一次可以一直用。这个做法在业内很常见,代价是 LUT 生成要写个离线脚本,但跑起来帧率能翻倍。
验证方法很简单:把相机拉到不同高度、不同经纬度,看云层是否始终贴合地球曲率,昼夜线扫过时云的颜色是否平滑过渡。如果某个角度云突然消失或变黑,多半是射线求交的边界条件没处理好。我自己的习惯是,每调一次参数就截三张图:赤道白天、极地夜晚、高空俯视,对比着看。这套方案值不值得做?如果你要的是能随视角和光照变化的全球云层,它比贴图方案贵,但效果是另一个量级。先从 32 步进跑通,再往上加,别一上来就拉满。希望帮到你。
本文还有配套的精品资源,点击获取