1. 为什么传统粒子系统在天气效果中不够稳定
在Cesium中实现天气效果,官方推荐的方式是使用粒子系统。这种方法看似简单直接,但实际开发中会遇到一个致命问题:当用户调整视角(比如拉近、拉远或旋转场景)时,粒子效果会出现明显抖动甚至突然消失。这种情况在三维地理场景中尤为突出,因为用户会频繁操作视角来查看不同区域。
传统粒子系统的局限性主要体现在三个方面。首先是视角依赖性问题,粒子位置是基于世界坐标系计算的,当相机移动时,每个粒子都需要重新计算位置,这就导致视觉效果不连贯。其次是性能消耗大,每个雨滴或雪花都是一个独立粒子,当需要表现密集天气时,粒子数量会急剧增加。最后是可控性差,想要调整整体效果(如下雪强度或雨滴倾斜角度)时,需要逐个修改粒子参数。
我曾在项目中遇到过这样的案例:当用户快速旋转地球时,原本密集的雪景突然变成了"局部小雪",这种割裂感严重影响了用户体验。后来测试发现,当相机高度超过5000米时,约30%的粒子会异常消失。这就是典型的视角依赖问题。
2. 后处理技术与Shader的完美组合
经过多次尝试,我发现用PostProcessStage配合自定义Fragment Shader才是更优解。这种方法的核心思想是把天气效果作为全屏后处理特效,而不是创建无数个独立粒子。这就好比在镜头前加滤镜,而不是在场景里撒面粉。
后处理技术的优势非常明显。首先是稳定性,无论视角如何变化,特效都能完整覆盖整个屏幕。其次是性能,无论场景复杂度如何,后处理只需要执行一次全屏渲染。最后是灵活性,通过Shader可以精确控制每个像素的表现形式。
这里有个生活化的类比:传统粒子系统就像在房间里真实撒纸屑,而后处理方案则是用投影仪在墙上播放纸屑动画。前者需要处理每片纸屑的物理运动,后者只需要一张动态贴图。
3. 手把手实现雪景特效
让我们以雪景为例,看看具体实现步骤。首先需要创建PostProcessStage实例,这是Cesium提供的后处理容器:
class SnowEffect { constructor(viewer, options) { this.snowStage = new Cesium.PostProcessStage({ name: 'czm_snow', fragmentShader: this.snowShader(), uniforms: { snowSize: () => this.snowSize, snowSpeed: () => this.snowSpeed } }); viewer.scene.postProcessStages.add(this.snowStage); } }关键在Fragment Shader的实现。下面这段代码通过噪声算法模拟雪花飘落:
float snow(vec2 uv, float scale) { float time = czm_frameNumber / snowSpeed; float w = smoothstep(1., 0., -uv.y * (scale/10.)); if(w < .1) return 0.; uv += time / scale; uv.y += time * 2. / scale; uv.x += sin(uv.y + time * .5) / scale; uv *= scale; vec2 s = floor(uv), f = fract(uv), p; float k = 3., d; p = .5 + .35 * sin(11. * fract(sin((s+p+scale)*mat2(7,3,6,5))*5.)) - f; d = length(p); k = min(d, k); return k * w; }这个算法有几个精妙之处:通过czm_frameNumber获取渲染帧数来实现动画效果;用smoothstep控制雪花密度随高度变化;使用噪声函数生成随机分布。你可以通过调整snowSize和snowSpeed这两个uniform变量来控制雪的大小和速度。
4. 更逼真的雨天效果实现
雨天效果相比雪景需要额外考虑雨滴的倾斜角度和拖尾效果。以下是RainEffect的核心代码:
class RainEffect { constructor(viewer, options) { this.rainStage = new Cesium.PostProcessStage({ name: 'czm_rain', fragmentShader: this.rainShader(), uniforms: { tiltAngle: () => this.tiltAngle, rainSize: () => this.rainSize, rainSpeed: () => this.rainSpeed } }); } }对应的Shader重点在于实现雨滴的斜向运动:
float hash(float x) { return fract(sin(x * 133.3) * 13.13); } void main() { float time = czm_frameNumber / rainSpeed; vec2 uv = (gl_FragCoord.xy * 2. - resolution.xy) / min(resolution.x, resolution.y); // 应用倾斜变换 float a = tiltAngle; float si = sin(a), co = cos(a); uv *= mat2(co, -si, si, co); uv *= length(uv + vec2(0, 4.9)) * rainSize + 1.; float v = 1. - sin(hash(floor(uv.x * 100.)) * 2.); float b = clamp(abs(sin(20. * time * v + uv.y * (5. / (2. + v)))) - .95, 0., 1.) * 20.; gl_FragColor = mix(texture2D(colorTexture, v_textureCoordinates), vec4(c, 1), .5); }这里用到了几个关键技巧:hash函数生成伪随机数来分布雨滴;mat2旋转矩阵实现统一的角度倾斜;通过clamp和sin函数组合创建雨滴的拖尾效果。实际项目中,我建议将tiltAngle设为-0.6到0.6之间的值,这样能模拟不同风向的降雨效果。
5. 雾效实现的特殊处理
雾效与其他天气不同,需要深度信息来实现距离衰减。下面是FogEffect的关键实现:
class FogEffect { constructor(viewer, options) { this.fogStage = new Cesium.PostProcessStage({ name: 'czm_fog', fragmentShader: this.fogShader(), uniforms: { visibility: () => this.visibility, fogColor: () => this.color } }); } }对应的Shader需要读取深度纹理:
void main() { vec4 origcolor = texture2D(colorTexture, v_textureCoordinates); float depth = czm_readDepth(depthTexture, v_textureCoordinates); vec4 depthcolor = texture2D(depthTexture, v_textureCoordinates); float f = visibility * (depthcolor.r - 0.3) / 0.2; f = clamp(f, 0.0, 1.0); gl_FragColor = mix(origcolor, fogColor, f); }这里有几个注意事项:必须启用深度纹理(viewer.scene.postProcessStages.fxaa.enabled = true);visibility参数控制雾的浓度,建议范围0.1到0.5;fogColor建议使用带透明度的颜色(如new Cesium.Color(0.8, 0.8, 0.8, 0.5))。在高原地区展示时,适当降低visibility值可以营造出更真实的海拔雾效果。
6. 性能优化与常见问题解决
在实际项目中,我总结了几个性能优化技巧。首先是合理设置uniform变量,比如snowSpeed值越大性能消耗越小,但动画会变慢,需要找到平衡点。其次是控制后处理阶段的数量,当需要同时展示多种天气时,应该合并Shader而不是叠加多个PostProcessStage。
常见问题之一是边缘闪烁,这通常是由于uv坐标计算不精确导致的。解决方法是在Shader开头加入:
vec2 uv = (gl_FragCoord.xy - 0.5) / min(resolution.x, resolution.y);另一个问题是移动端兼容性。部分低端设备可能不支持高精度Shader,这时需要简化算法,比如将snow函数中的多层噪声减少到3-4层。可以通过czm_sceneMode判断运行环境,动态调整效果精度。
内存管理也很重要。当天气效果不再需要时,应该及时调用destroy()方法:
destroy() { this.viewer.scene.postProcessStages.remove(this.snowStage); this.snowStage.destroy(); }7. 动态切换与效果组合
在实际应用中,我们经常需要动态切换天气效果。这里分享一个实用技巧:通过统一的管理类来协调多个天气效果:
class WeatherManager { constructor(viewer) { this.effects = { snow: new SnowEffect(viewer), rain: new RainEffect(viewer), fog: new FogEffect(viewer) }; } setWeather(type, options) { this.clearAll(); this.effects[type].show(true); if(options) this.effects[type].setOptions(options); } clearAll() { Object.values(this.effects).forEach(effect => effect.show(false)); } }更高级的用法是组合多个效果,比如雨雾天气。这时需要注意渲染顺序,通常应该先渲染雨雪再渲染雾效。可以通过调整PostProcessStage的排序来实现:
viewer.scene.postProcessStages.add(rainStage); viewer.scene.postProcessStages.add(fogStage);在Shader层面,也可以通过权重混合来实现效果叠加。比如在雨天基础上添加薄雾:
vec3 weatherEffect = rainEffect * 0.7 + fogEffect * 0.3;8. 进阶技巧:与地形和建筑的交互
要让天气效果更真实,可以考虑与场景物体的交互。比如雪在地面堆积的效果,可以通过读取深度信息来实现:
float groundSnow = smoothstep(0.3, 0.5, depth); finalSnow *= groundSnow;对于建筑表面的雨滴效果,可以结合法线信息:
vec3 normal = czm_getWgs84EllipsoidNormal(positionWC); float verticalFactor = abs(dot(normal, vec3(0,0,1))); rainAmount *= verticalFactor;这些进阶效果需要额外获取场景信息,可能会影响性能,建议根据实际需求选择性实现。我在一个城市级三维项目中,通过LOD技术动态调整天气精度,在保证效果的同时维持了60fps的流畅度。