1. 从一个静态球到活天气的转变思路做Cesium开发的朋友大概都有过这种经历辛辛苦苦搭完一个三维场景地形、模型、倾斜摄影都加载好了结果抬头一看天空永远是一片干干净净的蓝。放到项目汇报里客户第一句话往往是能不能加点云——场景瞬间从演示Demo变成了真实世界。这篇文章要聊的就是怎么在Cesium里做一套可交互的多云天气效果并且用一个slider滑块实时控制云层的浓度。项目本身不复杂但里面涉及到的技术点很典型Primitive API的使用、GLSL着色器的编写、动态纹理的生成、以及Cesium渲染管线和UI组件的联动。适合已经能跑通基本Cesium场景、想做点进阶视觉效果的开发者参考。先说我踩过的坑。最开始我想得很简单直接挂一个带透明通道的平面纹理上去模仿云层。结果一运行云是有了但视角一低就穿帮——云层变成一块贴在地上的花布。后来换思路用Primitive自定义几何体加Shader的方案把云层做在天空穹顶上再配合动态生成的多层噪声纹理效果一下子真实了很多。这也正是本文要讲的方案。整套实现的核心逻辑可以拆成三层几何层创建一个覆盖视野的半球或平面网格作为云层的载体材质层用Shader去采样动态生成的噪声纹理根据噪声值决定云层的透明度与颜色控制层slider滑块的数值通过Uniform变量传入Shader动态改变噪声阈值从而实时调整云层浓度。这个结构的好处在于渲染压力和UI响应完全解耦。云层浓度变化只是改一个uniform值不需要重建几何体也不需要重新上传纹理性能开销几乎可以忽略。接下来我把每一步的细节和原理拆开讲。2. 选型分析为什么用PrimitiveShader而不是Entity或后期处理在动手之前先花点篇幅讲清楚方案选型。因为Cesium里做云层效果至少有三种思路选错方向后面会非常痛苦。方案一Entity图片纹理。这是新手最常走的路径。创建一个Entity给它一个带云层图片的矩形或平面然后贴到场景里。优点是非常简单十分钟就能跑通。缺点是云层没有体积感视角一低就会穿帮云层不会随风向变化。如果你只需要一个从上往下看的俯视效果比如智慧园区的宏观展示这个方案够用。但要做第一人称漫游直接放弃。方案二后处理特效。Cesium本身支持自定义Post Process Stage可以在屏幕空间叠加云层效果。这个方案的效果上限最高可以实现体积云但开发成本也最高。你需要理解Cesium的PostProcessStage接口写屏幕空间的RayMarching代码还要考虑深度重建、相机矩阵、性能优化一系列问题。网上很多体积云教程都是这个路子但没有一定Shader功底很难啃下来。方案三Primitive自定义Shader。这是本文采用的做法。用Primitive创建自定义几何体通过Material的fabric或直接指定Shader代码把云层渲染到天空穹顶或者一个覆盖场景的平面上。它比方案一真实得多又比方案二简单得多是性价比最高的折中方案。这里多说一句为什么不用Entity。Entity API是Cesium面向业务开发者封装的高级接口它把Primitive和Material的细节藏起来了方便是方便但如果你想控制Shader层面的东西Entity反而束手束脚。比如我们要实现slider动态控制云量需要在渲染循环里实时修改uniform值。用Primitive可以直接拿到Material的Uniform对象来改用Entity就得绕好几层有些版本甚至根本不暴露这些接口。再看为什么不直接用现成的天空盒。Cesium的SkyBox可以换天空纹理确实能做出今天多云的静态效果但slider控制浓度这个需求就实现不了了。SkyBox本质是六张静态贴图你要动态改变云量等于运行时去重新生成六张贴图再重新赋值费时费力还容易闪屏。所以最终敲定方案三。接下来进入正题先搞定云层纹理的生成这是整个效果的核心。3. 核心纹理生成用Perlin噪声模拟真实云层形态3.1 噪声叠加的基本原理与实现云层的视觉核心不是什么高深算法而是分形噪声FBMFractal Brownian Motion。简单讲云不是均匀的一团而是由不同尺度的团块叠加而成大尺度决定云的轮廓中尺度决定云的纹理褶皱小尺度让云的边缘看起来毛茸茸的。FBM就是把多个频率、不同振幅的噪声叠加在一起的效果。所有代码示例基于JavaScript和CesiumShader基于GLSL。我用的是JavaScript版本的Perlin噪声实现然后生成一张512x512的纹理。为什么不用Simplex Noise不是不行而是Perlin的JavaScript实现网上很多采样效果也够用做纹理这种离线生成场景性能不是瓶颈。// Perlin噪声实现的核心部分 class PerlinNoise { constructor(seed) { this.permutation []; this.p new Uint8Array(512); // 用种子初始化排列表 for (let i 0; i 256; i) { this.permutation[i] i; } // 洗牌算法打乱 for (let i 255; i 0; i--) { const j Math.floor(this.random(seed i) * (i 1)); [this.permutation[i], this.permutation[j]] [this.permutation[j], this.permutation[i]]; } for (let i 0; i 512; i) { this.p[i] this.permutation[i 255]; } } random(seed) { // 一个简单的伪随机数生成器 const x Math.sin(seed * 127.1 311.7) * 43758.5453; return x - Math.floor(x); } fade(t) { return t * t * t * (t * (t * 6 - 15) 10); } lerp(a, b, t) { return a t * (b - a); } grad(hash, x, y) { const h hash 7; const u h 4 ? x : y; const v h 4 ? y : x; return ((h 1) 0 ? u : -u) ((h 2) 0 ? v : -v); } noise2D(x, y) { const X Math.floor(x) 255; const Y Math.floor(y) 255; x - Math.floor(x); y - Math.floor(y); const u this.fade(x); const v this.fade(y); const A this.p[X] Y; const B this.p[X 1] Y; return this.lerp( this.lerp(this.grad(this.p[A], x, y), this.grad(this.p[B], x - 1, y), u), this.lerp(this.grad(this.p[A 1], x, y - 1), this.grad(this.p[B 1], x - 1, y - 1), u), v ); } }这段代码的核心是排列表Permutation Table和梯度计算。你可以把排列表想象成一本字典给定一个坐标点查表得到一个随机梯度方向然后根据该点与周围四个格点的距离做平滑插值最终得到一个在[-1,1]区间平滑变化的连续值。fade函数用的是Perlin原始论文里的五次缓动曲线目的是让插值结果在格点边界处导数为零避免出现不自然的接缝。3.2 叠加多层噪声生成云纹理有了基础噪声函数接下来就是FBM叠加并生成最终的云层纹理。这里需要注意一个关键点直接输出的Perlin噪声值在[-1,1]而纹理通道需要[0,1]所以必须在叠加过程中做范围映射。function generateCloudTexture(size, seed) { const perlin new PerlinNoise(seed); const canvas document.createElement(canvas); canvas.width size; canvas.height size; const ctx canvas.getContext(2d); const imageData ctx.createImageData(size, size); const data imageData.data; // FBM参数频率和振幅逐层递减 const octaves 5; const baseFrequency 4; const baseAmplitude 0.5; const lacunarity 2.0; // 频率增量 const gain 0.5; // 振幅衰减 for (let y 0; y size; y) { for (let x 0; x size; x) { let noiseVal 0; let amplitude baseAmplitude; let frequency baseFrequency; let maxValue 0; for (let octave 0; octave octaves; octave) { // 使用归一化坐标避免不同频率间坐标尺度混乱 const nx x / size * frequency; const ny y / size * frequency; noiseVal perlin.noise2D(nx, ny) * amplitude; maxValue amplitude; amplitude * gain; frequency * lacunarity; } // 归一化到[0,1]范围然后做非线性映射增强对比度 let normalized (noiseVal / maxValue 1) / 2; // 关键步骤用幂函数增强对比度让云层边缘更清晰 normalized Math.pow(normalized, 1.5); const idx (y * size x) * 4; // 灰度值写入RGB同时写入Alpha通道 // 这里用normalized控制Alpha形成边缘透明、中心稠密的效果 const alpha normalized; data[idx] normalized * 255; data[idx 1] normalized * 255; data[idx 2] normalized * 255; data[idx 3] alpha * 255; } } ctx.putImageData(imageData, 0, 0); return canvas; }这段代码里有几个细节值得展开说。第一lacunarity 2.0意味着每一层噪声的频率是上一层的两倍gain 0.5意味着振幅减半。这个组合是FBM最经典的参数它保证了各层噪声的贡献大致均衡叠加出来的纹理既有大尺度结构又有小尺度细节。第二为什么最后用Math.pow(normalized, 1.5)做非线性映射。Perlin噪声叠加后的值虽然归一化到了[0,1]但分布集中在0.5附近直接当透明度用的话整个云层会呈现出到处都有一点云的均匀灰蒙蒙效果不真实。做一次幂函数映射可以把中间值往0方向压让低值区域更接近透明高值区域更接近不透明云层边缘就清晰了。1.5这个指数是我调出来的你可以根据想要的效果微调指数越大云层越碎。第三把RGB三个通道都写成同一个灰度值同时Alpha也等于灰度值。为什么要这样做因为在Shader里采样纹理时我们可以自由选择用哪个通道。如果只靠RGB判断浓度遇到纹理过滤的边缘采样可能会发黑RGBA同时携带相同信息可以交叉验证避免某些GPU驱动对Alpha通道的特殊处理导致意外结果。3.3 动态纹理与静态纹理的取舍生成纹理时有两条路一是程序化生成一张静态图二是运行时逐帧更新纹理模拟云的飘动。静态纹理的好处是性能开销极低生成一次GPU采样即可。坏处是云是死的不会飘。对于多云效果slider调整浓度这个需求静态纹理配合Shader里的UV偏移可以在视觉上模拟出缓慢飘动的效果性价比非常高。逐帧动态纹理比如每帧更新一张噪声图技术上完全可行但性能代价是灾难级的。CPU生成一张512x512的纹理每帧需要计算26万个像素点还要调用texImage2D上传到GPU一次两次没问题60帧每秒就是1600万次像素运算和60次纹理上传再强的机器也扛不住而且会造成严重的CPU-GPU同步阻塞。我的建议是用静态纹理Shader里的UV滚动来实现云的飘动。做法很简单在Vertex Shader里给UV坐标加一个随时间变化的偏移量即可。// 在Material的Shader中增加UV偏移实现云层缓慢飘动 vec2 uv fract(v_st vec2(u_time * 0.01, u_time * 0.005));fract函数是关键它让UV坐标在超出[0,1]后自动循环纹理就能无缝重复。偏移方向取(0.01, 0.005)让云层沿一个方向缓慢移动两个方向的速度不同看起来更自然不会像平移贴图那么生硬。u_time从Cesium的帧回调里获取实时传入。顺便提一个纹理平铺的细节如果不想云层缩放后出现明显的边界接缝生成纹理时应使用Cesium.ClampToEdge的采样方式而不是Cesium.Repeat。Repeat模式在非2的幂纹理上可能出现采样异常ClampToEdge则会让边缘像素自然延展配合UV偏移的fract循环视觉上更平滑。4. 云层几何体的构建半球壳与自定义Primitive4.1 用几何体搭配Primitive创建云层穹顶云层纹理搞定了接下来要决定贴在哪里。我最终选择的是覆盖在场景上空的半球壳Hemisphere Shell而不是平面。为什么不用平面因为平面在视角倾斜时会产生严重的透视压缩云层会扁成一条线。半球壳的好处是无论相机如何旋转云层距离相机的距离大致恒定透视效果自然而且云层边缘可以自然融入地平线附近的大气效果。构建半球壳的思路不复杂用经纬度网格生成顶点每个顶点根据经纬度映射到球面坐标。为了不让云层遮挡地面半球半径要设得足够大但又不能大于Cesium的scene.camera.frustum.far否则会被裁剪掉。function createCloudGeometry(radius, segments) { const positions []; const uvs []; const indices []; for (let i 0; i segments; i) { // 纬度从0到90度只做上半球 const lat (i / segments) * Math.PI / 2; for (let j 0; j segments * 2; j) { const lon (j / (segments * 2)) * Math.PI * 2; const x radius * Math.cos(lat) * Math.cos(lon); const y radius * Math.cos(lat) * Math.sin(lon); const z radius * Math.sin(lat); positions.push(x, y, z); // UV横向为经度纵向为纬度 uvs.push(j / (segments * 2), 1.0 - i / segments); } } // 索引将网格拓扑连接为三角形 for (let i 0; i segments; i) { for (let j 0; j segments * 2; j) { const a i * (segments * 2 1) j; const b a 1; const c a segments * 2 1; const d c 1; indices.push(a, b, c); indices.push(b, d, c); } } return { positions: positions, uvs: uvs, indices: indices }; }这里有几个工程细节。第一为什么纬度和经度的循环次数不对称经度需要覆盖360度所以是segments * 2纬度只需要90度所以是segments。第二UV的纵向为什么要用1.0 - i / segments颠倒一下因为纹理坐标原点在左下角而顶点循环是从下往上走的如果不翻转纹理在球面上会上下颠倒。第三索引连接的顺序很重要三角形绕序决定了面的朝向。上面代码里(a, b, c)和(b, d, c)的绕序保证三角面片的法线朝外也就是朝向相机方向。如果你发现云层从下面看是正常的、从上面看却消失了多半就是绕序反了把三角形顶点顺序颠倒即可。4.2 几何体加载进Primitive的完整流程有了顶点、UV和索引数据接下来要走Cesium的标准流程创建几何体Geometry- 打包成GeometryInstance - 用Primitive添加到场景。function createCloudPrimitive(radius, segments, texture) { const cloudGeom createCloudGeometry(radius, segments); const geometry new Cesium.Geometry({ attributes: { position: new Cesium.GeometryAttribute({ componentDatatype: Cesium.ComponentDatatype.DOUBLE, componentsPerAttribute: 3, values: new Float64Array(cloudGeom.positions) }), st: new Cesium.GeometryAttribute({ componentDatatype: Cesium.ComponentDatatype.FLOAT, componentsPerAttribute: 2, values: new Float32Array(cloudGeom.uvs) }) }, indices: new Uint32Array(cloudGeom.indices), primitiveType: Cesium.PrimitiveType.TRIANGLES, boundingSphere: Cesium.BoundingSphere.fromVertices(cloudGeom.positions) }); const instance new Cesium.GeometryInstance({ geometry: geometry }); const primitive new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.MaterialAppearance({ material: createCloudMaterial(texture), faceForward: true }), asynchronous: false, modelMatrix: Cesium.Matrix4.IDENTITY }); scene.primitives.add(primitive); return primitive; }关于这个流程有几个容易翻车的点必须提醒。asynchronous: false是第一个坑。默认情况下Cesium会把几何体创建放在工作线程里异步执行这本来是性能优化但在开发阶段调试时异步加载会导致你修改完代码刷新页面后几何体偶尔不显示或者显示的是旧数据。开发阶段设成false强制同步创建方便排查问题。boundingSphere是第二个坑。Cesium的Primitive在渲染时会做视锥裁剪如果boundingSphere没有正确计算或者缺失会导致Primitive在某些视角下突然消失。上面代码用fromVertices从顶点数据计算包围球是最稳妥的方式。如果你自定义几何体时偷懒传一个空的BoundingSphere云层会在你旋转相机时闪现消失排查起来极其痛苦。Cesium.Matrix4.IDENTITY作为modelMatrix意味着几何体坐标直接是世界坐标。这意味着createCloudGeometry里的半径必须和真实世界坐标一致比如半径填10000就代表1万米。Cesium场景中1个单位约等于1米所以如果你在球心附近浏览半球半径至少得大于相机远裁剪面通常8500左右比较合适。5. 云层材质Shader从透明到浓度可调的渲染逻辑5.1 理解Cesium的Material APICesium的材质系统经历过一次升级。早期的Cesium使用Fabric语法用JSON描述材质链现在新版本推荐直接用MaterialAppearance配合自定义Shader代码。这里我用的是后者的思路它更接近原生WebGL的写法可读性和可维护性都更好。Cesium的MaterialAppearance本质上是帮你搭好了一个渲染管线的外壳顶点位置变换、法线计算、光照计算这些它可以帮你做也可以由你完全接管。但做云层这种半透明效果我建议关闭默认光照自己控制颜色和透明度输出。原因很简单云层是自发光的感觉不该受平行光方向影响否则会出现背光面云层突然变黑的违和现象。创建材质的核心代码如下function createCloudMaterial(texture) { return new Cesium.Material({ fabric: { type: CloudMaterial, uniforms: { u_cloudTexture: texture, u_opacity: 0.6, // 云层不透明度 u_cloudDensity: 0.5, // 云层浓度slider绑定 u_cloudScale: 4.0, // 云层缩放控制云块大小 u_time: 0.0 // 时间用于云层飘动 }, source: // 顶点着色器 czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st * u_cloudScale; vec2 offset vec2(u_time * 0.01, u_time * 0.005); st offset; vec4 cloudColor texture2D(u_cloudTexture, fract(st)); // 云层浓度控制将噪声值减去一个阈值负数截断为0 float cloudFactor cloudColor.r - (1.0 - u_cloudDensity); cloudFactor clamp(cloudFactor * 4.0, 0.0, 1.0); material.diffuse vec3(1.0, 1.0, 1.0); material.alpha cloudFactor * u_opacity; return material; } } }); }5.2 浓度控制的核心算法解析这段Shader里最关键的一行是float cloudFactor cloudColor.r - (1.0 - u_cloudDensity); cloudFactor clamp(cloudFactor * 4.0, 0.0, 1.0);这行的逻辑值得仔细讲一讲它回答了slider到底是怎么控制云量的问题。纹理采样得到的cloudColor.r的范围是[0,1]值越高表示该处越云。当我们说云层浓度时本质上是在定义一个阈值噪声值高于阈值的地方是云低于阈值的地方不是云。当u_cloudDensity从0调到1时1.0 - u_cloudDensity从1降到0阈值不断降低于是更多低噪声区域被划入云的范围视觉上云层就变浓了。后面的* 4.0是一个平滑过渡的系数。如果没有这个系数cloudFactor会是一个硬阶跃函数高于阈值就是1低于阈值就是0云层边缘会呈现明显的锯齿感。乘以4.0之后阈值附近的一段噪声值范围约为0.25会被平滑映射到[0,1]云层边缘就有了柔和过渡。我给u_cloudDensity设置了0~1的数值范围对应slider的min和max。从底层逻辑看slider的每次变化本质上是把Uniform对象里u_cloudDensity属性的值改掉// slider事件监听 slider.addEventListener(input, function(e) { const density parseFloat(e.target.value); cloudMaterial.uniforms.u_cloudDensity density; });这个操作的性能开销几乎为零不需要重新编译Shader、不需要重建几何体、不需要重新上传纹理只是把GPU里的一个浮点寄存器改掉下一帧渲染就会立刻生效。5.3 半透明渲染顺序与深度测试的坑云层是半透明的而半透明物体在WebGL渲染管线里是个老大难问题Cesium也不例外。主要坑两个渲染顺序和深度测试。Cesium渲染所有Primitive时会先渲染不透明物体再渲染半透明物体。半透明物体之间按视距排序从远到近绘制。但Cesium对Primitive和3D Tiles的半透明排序往往是按包围盒的中心点距离计算的当一个半透明云层半球包裹着半透明的其他特效比如雨雪粒子时排序可能会不正确导致透视错乱。更常见的坑是云层半球壳如果从相机下方看它的背面会被Cesium默认剔除Back Face Culling于是你低头看地面时云层会消失。解决方式有两种一是把CullFace关闭二是把半球改成双面渲染。在Cesium里可以通过MaterialAppearance的translucent属性和Primitive的cull属性配合控制。const primitive new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.MaterialAppearance({ material: createCloudMaterial(texture), faceForward: true, translucent: true }), asynchronous: false, // 关闭背面剔除让半球反面也能显示 cull: false });cull: false是我调试了大半天才发现的。默认情况下Primitive会做背面剔除对于封闭的球体没问题但对于半球壳这种单面模型一旦相机跑到模型背面整个云层就消失了。所以做云层穹顶cull: false是必须的。另一个和深度测试相关的坑是Cesium默认使用无限深度测试也就是说半透明的云层在渲染时会和不透明地形做深度比较。如果云层离相机远但地形离相机近地形会遮挡云层这没问题。但如果云层设计成包裹整个场景云层会被地形完全挡住等于白做。解决办法是让云层半径足够大大于视锥远裁剪面让云层在地平线附近不与地形产生深度交叉或者用scene.globe.depthTestAgainstTerrain false关闭地形的深度测试注意这个全局设置会影响其他物体的遮挡关系使用前要评估项目需求。6. Slider交互集成从UI组件到运行时参数联动6.1 页面布局与Slider初始化UI层不需要搞得多花哨一个标准的HTML range输入控件就能满足需求。为了演示效果和调参方便我一般会把Slider和一些辅助信息放在一起做成一个半透明的控制面板。div idcontrolPanel label forcloudDensity云层浓度/label input typerange idcloudDensity min0 max1 step0.01 value0.5 span iddensityValue0.5/span /div#controlPanel { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.6); color: #fff; padding: 10px 20px; border-radius: 8px; z-index: 999; font-size: 14px; }这个面板的定位用绝对定位覆盖在Cesium容器上z-index要保证在Cesium canvas之上。这里有一个体验细节如果不把容器的pointer-events正确设置Slider的拖动事件会被Cesium的鼠标事件处理器截获导致拉不动滑块。解决办法是给控制面板加上pointer-events: autoCesium容器的事件监听器会忽略来自这个DOM元素的事件。6.2 事件绑定与Uniform实时更新UI和渲染之间的桥梁就是slider的input事件。使用input而不是change事件是因为input在拖动过程中会连续触发能实现边拖边看效果的实时反馈change只在松开鼠标时触发交互感差很多。const slider document.getElementById(cloudDensity); const densityValue document.getElementById(densityValue); slider.addEventListener(input, function(e) { const density parseFloat(e.target.value); densityValue.textContent density.toFixed(2); // 更新材质uniform if (cloudMaterial) { cloudMaterial.uniforms.u_cloudDensity density; } });这段代码表面看很简单但有一个隐藏问题cloudMaterial对象什么时候才能拿到如果按照前面的流程Primitive创建后Material就创建了那没问题。但如果你的代码里Primitive用了asynchronous: trueMaterial可能在场景渲染一帧之后才真正创建完毕此时直接访问cloudMaterial.uniforms会报错。所以务必保证Primitive的asynchronous: false或者对cloudMaterial做空值判断。6.3 联调性能观察与浏览器控制台验证写完代码打开浏览器第一件事不是看效果而是打开控制台确认没有报错。然后开Performance面板重点观察两个方面一是Scripting耗时二是Rendering耗时。如果滑块拖动时Rendering耗时飙升说明Shader在每次uniform更新时做了不必要的重编译这是个别GPU驱动的优化策略问题可以在Shader里避免使用#ifdef动态分支尽量用clamp、mix这类不会导致分支重编译的内建函数。另一个实用验证技巧是在控制台手动改uniform值确认变化规律符合预期。// 在控制台手动调参验证Shader逻辑 cloudMaterial.uniforms.u_cloudDensity 0.8; // 云应该变浓 cloudMaterial.uniforms.u_cloudDensity 0.1; // 云应该变淡如果在控制台改值没有反应优先排查cloudMaterial对象是不是你创建的那个——比如你创建了一个新材质但没有替换掉Primitive的旧材质旧的仍然在渲染。7. 调试经验云层不显示、边缘闪烁、浓度不变化等问题的修复写到这里把我在实际调试中遇到的两类高频问题集中梳理一下这些都是排查时实实在在走过的弯路。问题一云层完全不显示。排查链路大概是这样的先确认Primitive加入了scene.primitives然后console.log(primitive.show)确认没被隐藏接着看primitive.ready是否为true如果是undefined说明Primitive的异步加载没完成直接把asynchronous改成false再检查材质里的Shader代码是否有编译错误Cesium的Material编译错误不会直接抛异常而是静默失败你需要在控制台搜索WebGL或Shader相关的warning。这几步走完90%的不显示都能解决。问题二云层边缘闪烁、有锯齿。这通常是半透明混合和深度冲突共同导致的。云层半球壳和天空盒或者地形的表面在像素级别上重合时深度缓冲区的精度不够就会产生Z-Fighting。解决方式有三把半球壳半径稍微调大一点和地形拉开距离给Shader里的顶点位置做微小偏移或者把depthTestAgainstTerrain设置为需要的值。我最终是用第一种方式解决的半径从8500调到8800闪烁就消失了。问题三slider拖到最右边云层浓度没有达到预期。这个问题的根源多半在于Shader里的阈值公式。我之前提到cloudFactor clamp(cloudColor.r - (1.0 - u_cloudDensity), 0.0, 1.0)如果u_cloudDensity到了1阈值是0也就是说所有噪声值大于0的像素都被判定为云。但噪声纹理本身经过归一化后有大量像素的噪声值可能本来就是0左右所以即便是100%浓度天空也不会完全被云覆盖而是到处都有云但某些地方仍然透亮。这在视觉上其实是合理的多云不代表阴天。如果你真的想要乌云密布的效果需要把阈值公式改成cloudColor.r - (0.3 - u_cloudDensity * 0.3)让最高浓度时阈值比0更高确保所有像素都进入云的判定。8. 进阶扩展方向把多云效果从能看做到好看如果说前面几章解决的是从0到1的问题最后这部分聊聊从1到N的优化空间。动态时间光照联动。现在的云层是纯白色自发光没有方向性。如果要配合太阳光可以在Shader里把太阳方向作为uniform传进去云层的高光区域跟随太阳位置变化。这个效果会让场景真实感大幅提升但要注意云层是半透明的光照计算要叠加在material.diffuse上而不是直接替换。多层云系。现实中的云层通常分高云、中云、低云形态、透明度、移动速度各不相同。你可以在场景中挂两个Primitive一个用较大的噪声尺度渲染稀疏的高云一个用较小的噪声尺度渲染低云各自的slider控制浓度。Web Worker生成纹理。512x512的纹理生成一次大约需要100~200毫秒虽然不算严重但如果做多张纹理比如不同云系可以考虑把噪声计算放到Web Worker里避免阻塞主线程导致UI卡顿。Cesium ion或第三方3D Tiles的配合。云层效果一旦跑通它可以叠加在任何Cesium场景之上无论是加载了3D Tiles倾斜摄影的智慧城市项目还是基于CesiumForUnreal或Cesium for Unity渲染的数字孪生场景。你可以把它当作一个独立的云层特效模块封装起来在新项目里直接复用。就个人经验而言做这种视觉特效最容易陷入的误区是过度追求Shader炫技忘了效果的本质是服务场景叙事。一个有多云效果的智慧园区、一个能看到云影移动的山区地形漫游比单纯堆砌粒子特效的赛博天空更打动人。希望这篇文章能帮你把天空这个常常被忽略的维度真正做起来。