Vue3+Cesium实现阴天乌云+下雨天气特效

前言:

在使用 Cesium 做三维可视化开发时,经常需要给场景增加天气氛围。但 Cesium 本身并没有内置阴天、降雨这类天气效果,想要实现就需要使用后处理 PostProcessStage,对已经渲染完成的画面,再跑一遍自定义片元着色器做二次处理。

本文实现两个独立可开关的效果:

  1. 动态流动乌云:多层 FBM 噪声模拟云层,支持 0‑1 浓度调节。
  2. 下雨雨丝特效:随机生成下落雨丝动画,滑块控制雨量大小。

本案例中Cesium版本为1.141.0

演示动态图效果如下:

演示静态图效果如下:

1. 乌云效果

2. 下雨效果

3. 下雨时有乌云效果

技术原理详解:

1. Cesium 后处理执行流程

Cesium 场景渲染顺序:场景渲染 → 得到屏幕纹理 colorTexture → 执行自定义片元着色器 → 输出最终画面

我们所有的乌云、下雨效果,都是对最终屏幕图像做二次像素重绘

2. 乌云效果实现思路

  • 使用 FBM 分形噪声 多层叠加,模拟真实云层纹理。
  • 通过 time 动态偏移 UV 坐标,实现云层缓慢流动。
  • 使用 skyMask 像素掩码,只渲染天空乌云,地面完全不受影响
  • 通过 uniform 外部传入浓度,支持动态变浓 / 变淡。

3. 下雨效果实现思路

  • 利用 Hash 随机算法生成屏幕随机雨丝位置。
  • 利用时间变量驱动雨丝下落动画。
  • 通过雨量系数混合画面,实现小雨 → 暴雨过渡。
  • 兼容 GLSL300-ES 标准,适配新版 Cesium。

完整代码

1. template 结构:

xml 复制代码
<template>
    <div class="main">

    <!-- Cesium地球渲染容器 -->
    <div class="content" ref="content" id="earth"></div>

    <div class="btn-border-column" v-if="isLoading">

        <!-- 乌云控制行:滑块 + 开关按钮 -->
        <div class="btn-row">
            <div class="slider-border" v-if="isCloud">
                <div class="slider-label">乌云浓度:{{ Number(cloudStrength).toFixed(2) }}</div>
                <el-slider
                    class="cloud-slider"
                    v-model.number="cloudStrength"
                    :min="0"
                    :max="1.0"
                    :step="0.01"
                    @input="updateCloudStrength"
                />
            </div>
            <el-button type="primary" size="default" class="btn-control" @click="cloudControl">
                {{ isCloud ? '关闭阴天乌云' : '开启阴天乌云' }}
            </el-button>
          </div>

          <!-- 下雨控制行:滑块 + 开关按钮 -->
          <div class="btn-row">
            <div class="slider-border" v-if="isRain">
                <div class="slider-label">雨量大小:{{ Number(rainStrength).toFixed(2) }}</div>
                <el-slider
                    class="rain-slider"
                    v-model.number="rainStrength"
                    :min="0"
                    :max="1.0"
                    :step="0.01"
                    @input="updateRainStrength"
                />
            </div>
            <el-button type="primary" size="default" class="btn-control" @click="rainControl">{{ isRain ? '关闭下雨' : '开启下雨' }}</el-button>
            </div>

            <!-- 镜头复位按钮 -->
            <div class="btn-row">
               <el-button type="primary" size="default" class="btn-control" @click="flyTo">初始位置</el-button>
            </div>

        </div>

        <!-- 地图加载提示,初始化未完成显示 -->
        <div class="loading" v-if="!isLoading">Loading...</div>
            
    </div>
</template>

2. script 代码:

ini 复制代码
<script setup>
import { onMounted, nextTick, ref, onUnmounted } from 'vue';
import { token } from '../../utils/common.js';

// 地图是否加载完成
let isLoading = ref(false);
// 乌云效果开关状态
let isCloud = ref(false);
// 下雨效果开关状态
let isRain = ref(false);
// 乌云浓度 0~1
let cloudStrength = ref(1);
// 雨量大小 0~1
let rainStrength = ref(0.55);

let myMar = null;

/**
 * 组件销毁生命周期:统一销毁Cesium实例、后处理效果,防止内存泄漏
 */
onUnmounted(() => {
    // 销毁乌云后处理
    if (isCloud.value && window.cloudStage) {
        window.viewer.scene.postProcessStages.remove(window.cloudStage);
        window.cloudStage = null;
        isCloud.value = false;
    }
    // 销毁下雨后处理
    if (isRain.value && window.rain) {
        window.viewer.scene.postProcessStages.remove(window.rain);
        window.rain = null;
        isRain.value = false;
    }
    // 销毁Cesium实例
    if (window.viewer) {
        window.viewer.destroy();
        window.viewer = null;
    }

    if (myMar) {
        clearTimeout(myMar);
        myMar = null;
    }
});

/**
 * DOM挂载完成,等待DOM渲染完毕初始化地图
 */
onMounted(() => {
    nextTick(() => {
        initMap();
    });
});

/**
 * 初始化Cesium Viewer实例
 */
const initMap = async () => {
    Cesium.Ion.defaultAccessToken = token;

    // 设置相机默认视口范围
    Cesium.Camera.DEFAULT_VIEW_RECTANGLE = Cesium.Rectangle.fromDegrees(89.5, 20.4, 110.4, 61.2);

    // 加载世界地形,开启水面蒙版、地形法线
    const terrainProvider = await Cesium.createWorldTerrainAsync({
        requestWaterMask: true,
        requestVertexNormals: true
    });

    // 创建Viewer实例,关闭多余UI控件
    window.viewer = new Cesium.Viewer('earth', {
        terrainProvider: terrainProvider,
        animation: false,
        timeline: false,
        infoBox: false,
        geocoder: false,
        homeButton: false,
        sceneModePicker: false,
        baseLayerPicker: false,
        navigationHelpButton: false,
        fullscreenButton: false,
        selectionIndicator: false,
        shouldAnimate: false,
        contextOptions: {
            webgl: {
                powerPreference: "high-performance",
                preserveDrawingBuffer: false
            }
        }
    });

    Cesium.JulianDate.fromDate(new Date('2026/05/02 23:00:00'));

    // 延时3秒,标记地图加载完成,显示操作按钮,并且飞到预设初始视角
    myMar = setTimeout(() => {
        isLoading.value = true;
        flyTo();
    }, 3000);
};

// 飞到预设初始视角的方法
const flyTo = () => {
    window.viewer.camera.flyTo({
        destination: Cesium.Cartesian3.fromDegrees(117.66293312354773, 26.00085216052459, 1796.8781247739746),
        orientation: {
            heading: Cesium.Math.toRadians(38.280907385928664),
            pitch: Cesium.Math.toRadians(-4.0671391165843245),
            roll: Cesium.Math.toRadians(0.0009439239381974838)
        },
        duration: 6
    });
};

// 更新雨量uniform,滑块拖动实时生效,雨量值 0‑1
const updateRainStrength = (val) => {
    if (window.rain) {
        window.rain.uniforms.rainStrength = Number(val);
    }
};

/**
 * 下雨效果开关:开启/关闭后处理
 */
const rainControl = () => {
    if (isRain.value) {
        // 关闭下雨,移除后处理阶段
        window.viewer.scene.postProcessStages.remove(window.rain);
        window.rain = null;
        isRain.value = false;
    } else {
        // GLSL300‑ES 片元着色器,Cesium PostProcessStage专用语法
        const Rain = `
            uniform sampler2D colorTexture;
            uniform float rainStrength;
            uniform float time;
            in vec2 v_textureCoordinates;
            out vec4 fragColor;

            float hash(float x) {
                return fract(sin(x * 133.3) * 13.13);
            }

            void main(void) {
                vec4 sceneColor = texture(colorTexture, v_textureCoordinates);
                float t = time;
                vec2 uv = gl_FragCoord.xy;

                float a = -0.4;
                float si = sin(a);
                float co = cos(a);

                vec2 res = czm_viewport.zw;
                uv = (uv * 2.0 - res.xy) / min(res.x, res.y);
                uv *= mat2(co, -si, si, co);
                uv *= length(uv + vec2(0.0,4.9)) * 0.3 + 1.0;

                float v = 1.0 - sin(hash(floor(uv.x * 100.0)) * 2.0);
                float b = clamp(abs(sin(20.0 * t * v + uv.y * (5.0 / (2.0 + v)))) - 0.95, 0.0, 1.0) * 20.0;
                vec3 rainCol = vec3(0.6,0.7,0.8) * v * b;

                float rainMix = clamp(rainStrength,0.0,1.0);
                vec3 finalRGB = mix(sceneColor.rgb, sceneColor.rgb * (1.0 - rainMix*0.2) + rainCol, rainMix*0.45);

                fragColor = vec4(finalRGB, sceneColor.a);
            }
        `;

        // 创建下雨后处理
        window.rain = new Cesium.PostProcessStage({
            name: 'czm_rain',
            fragmentShader: Rain,
            uniforms:{
                rainStrength: rainStrength.value,
                time:0.0
            }
        });
        // requestAnimationFrame驱动时间,实现雨滴下落动画
        const tickRain = ()=>{
            if(window.rain){
                window.rain.uniforms.time += 0.016;
                requestAnimationFrame(tickRain);
            }
        };
        tickRain();

        // 添加到场景后处理管线
        window.viewer.scene.postProcessStages.add(window.rain);
        isRain.value = true;
    }
};

// 更新乌云浓度,滑块拖动实时修改uniform,乌云浓度 0‑1
const updateCloudStrength = (val) => {
    if(window.cloudStage){
        window.cloudStage.uniforms.cloudStrength = Number(val);
    }
};

// 乌云阴天效果开关
const cloudControl = () => {
    if(isCloud.value){
        // // 关闭乌云,移除后处理
        window.viewer.scene.postProcessStages.remove(window.cloudStage);
        window.cloudStage = null;
        isCloud.value = false;
    }else{
        const CloudShader = `
            uniform sampler2D colorTexture;
            uniform float cloudStrength;
            uniform float time;
            in vec2 v_textureCoordinates;
            out vec4 fragColor;

            float noise(vec2 uv){
                return fract(sin(dot(uv,vec2(12.9898,78.233)))*43758.5453);
            }

            float smoothNoise(vec2 uv){
                vec2 i = floor(uv);
                vec2 f = fract(uv);
                float a = noise(i);
                float b = noise(i + vec2(1.,0.));
                float c = noise(i + vec2(0.,1.));
                float d = noise(i + vec2(1.,1.));
                vec2 u = f * f * (3.0 - 2.0 * f);
                return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
            }

            float fbm(vec2 uv){
                float total = 0.0;
                float amp = 0.52;
                for(int i = 0; i < 6; i++){
                    total += smoothNoise(uv) * amp;
                    uv *= 2.2;
                    amp *= 0.46;
                }
                return total;
            }

            void main(void){
                vec4 origin = texture(colorTexture, v_textureCoordinates);
                vec3 originRGB = origin.rgb;

                float skyMask = smoothstep(0.12, 0.98, v_textureCoordinates.y);

                vec2 cloudUV = v_textureCoordinates * 1.6 + vec2(time * 0.00045, time * 0.00018);
                float cloudNoise = fbm(cloudUV);
                float cloudDensity = smoothstep(0.04, 0.94, cloudNoise);

                vec2 cloudUV2 = v_textureCoordinates * 2.9 + vec2(time * 0.0008, time * -0.0003);
                float cloudNoise2 = fbm(cloudUV2);
                float cloudDensity2 = smoothstep(0.06, 0.93, cloudNoise2);

                vec2 cloudUV3 = v_textureCoordinates * 4.2 + vec2(time * 0.0010, time * 0.00022);
                float cloudNoise3 = fbm(cloudUV3);
                float cloudDensity3 = smoothstep(0.07, 0.91, cloudNoise3);

                vec2 cloudUV4 = v_textureCoordinates * 6.0 + vec2(time * 0.0013, time * -0.0004);
                float cloudNoise4 = fbm(cloudUV4);
                float cloudDensity4 = smoothstep(0.09, 0.88, cloudNoise4);

                float finalCloudDensity = max(cloudDensity, max(cloudDensity2*0.75, max(cloudDensity3*0.60, cloudDensity4*0.45)));

                vec3 cloudGray = vec3(0.17, 0.19, 0.22);

                float cloudAlpha = finalCloudDensity * skyMask * cloudStrength;

                vec3 finalColor = mix(originRGB, cloudGray, cloudAlpha);

                fragColor = vec4(finalColor, origin.a);
            }
        `;

        // 创建乌云后处理阶段
        window.cloudStage = new Cesium.PostProcessStage({
            name:'overcast_cloud',
            fragmentShader:CloudShader,
            uniforms:{
                cloudStrength: cloudStrength.value,
                time:0.0
            }
        });

        // 驱动云层流动动画
        const tick = ()=>{
            if(window.cloudStage){
                window.cloudStage.uniforms.time +=0.016;
                requestAnimationFrame(tick);
            }
        };
        tick();

        window.viewer.scene.postProcessStages.add(window.cloudStage);
        isCloud.value = true;
    }
};
</script>

3. css样式代码:

css 复制代码
* {
    margin: 0;
    padding: 0;
}

.main {
    width: 100%;
    height: 100vh;
    position: relative;
}

.content {
    width: 100%;
    height: 100%;
    position: relative;
    z-index: 1;
}

.btn-border-column {
    position: absolute;
    right: 24px;
    top: 24px;
    z-index: 2;
}

.btn-row {
    margin-top: 10px;
    height: 52px;
    display: flex;
    justify-content: end;
    align-items: stretch;
}

.slider-border {
    width: 260px;
    margin-right: 20px;
    position: relative;
    top: -9px;
}

.slider-label {
    font-size: 14px;
}

.btn-control {
    width: 116px;
    margin-left: 20px;
    cursor: pointer;
}

.loading {
    width: 100%;
    height: 100%;
    position: absolute;
    left: 0;
    top: 0;
    z-index: 3;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 34px;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 50px;
    color: #000000;
}
相关推荐
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 13 篇:工程化与综合实战(Vite + Todo 应用)
前端·vue.js·react.js
90后的晨仔3 小时前
uni-app 项目目录结构全解:每个文件、每个文件夹的作用与配置详解
vue.js·uni-app
Rysxt_4 小时前
React vs Vue.js:2025年两大主流前端框架深度对比教程
vue.js·react.js·前端框架
by__csdn4 小时前
Vue vs React vs Angular:前端框架终极对决
前端·vue.js·react.js·前端框架·vue·react·angular
反正我还没长大5 小时前
Vue3响应式原理深度解析:揭秘现代前端框架的核心引擎
vue.js·前端框架·vue·proxy模式
2501_926102865 小时前
深入浅出Vue.js前端框架设计:核心原理与实践指南
前端·vue.js·前端框架
小爬的老粉丝16 小时前
Vue 3 文件预览生产排障:Worker/WASM 404、鉴权 Blob 与子路径
javascript·vue.js·wasm
布兰妮甜17 小时前
Vue 状态管理选型:Pinia 完整实战,对比 Vuex,模块化持久化
前端·javascript·vue.js·pinia·vuex
小锋java123418 小时前
【技术专题】Vue3 - 条件渲染
vue.js·vite