Dynamic Weather and Solar Simulation in Cesium Using Post-Processing

Overview

Integrating atmospheric conditions and solar positioning into a 3D geospatial application significantly enhances realism. This approach leverages Cesium's post-processing pipeline to apply full-screen fragment shaders, enabling real-time rain, snow, and depth-based fog effects. Additionally, it demonstrates how to manipulate the scene's time clock to simulate accurate shadow casting based on solar position.

Important: The provided fragment shaders target the WebGL 1.0 specification. When initializing the Cesium viewer, ensure the rendering context is explicitly configured for this version to guarantee shader compatibility.

Interface Controller (Vue 3)

The following component manages user interaction for toggling weather modes and adjusting the simulation clock. It communicates directly with a centralized Cesium store and dynamically registers or removes post-processing stages.

<template>
  <div class="environment-controls">
    <header class="panel-header">Environmental Simulation</header>
    <section class="weather-selector">
      <span class="section-label">Precipitation & Visibility</span>
      <div class="button-group">
        <button 
          v-for="(mode, idx) in weatherModes" 
          :key="idx"
          :class="{ active: selectedMode === idx }"
          @click="toggleEnvironment(idx)"
        >{{ mode }}</button>
      </div>
    </section>
    <section class="time-controller">
      <span class="section-label">Solar Positioning</span>
      <div class="timestamp">{{ formattedDate }}</div>
      <input 
        type="range" 
        :min="0" 
        :max="24" 
        v-model.number="solarHour"
        @input="updateSolarTime"
        class="hour-slider"
      />
      <div class="slider-labels">
        <span>00:00</span>
        <span>24:00</span>
      </div>
    </section>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useCesiumStore } from '@/store/cesium';
import AtmosphericHaze from './AtmosphericHaze';
import rainShader from './shaders/rain.glsl';
import snowShader from './shaders/snow.glsl';

const cesiumStore = useCesiumStore();
let currentPostStage: any = null;
let hazeController: AtmosphericHaze | null = null;

const weatherModes = ['Clear', 'Rain', 'Snow', 'Heavy Fog'];
const selectedMode = ref<number>(-1);
const solarHour = ref<number>(12);

const formattedDate = computed(() => {
  const now = new Date();
  now.setHours(solarHour.value, 0, 0, 0);
  return now.toLocaleDateString();
});

onMounted(() => {
  const viewer = cesiumStore.getViewer();
  hazeController = new AtmosphericHaze({
    enabled: false,
    targetViewer: viewer,
    altitudeThreshold: 35000,
    densityCurve: new Cesium.Cartesian4(150, 0.0, 8500, 0.85),
    tint: Cesium.Color.WHITE
  });
});

const toggleEnvironment = (modeIndex: number) => {
  selectedMode.value = selectedMode.value === modeIndex ? -1 : modeIndex;
  clearPostStage();

  switch (selectedMode.value) {
    case 0:
      solarHour.value = 12;
      updateSolarTime();
      break;
    case 1:
      applyRain();
      break;
    case 2:
      applySnow();
      break;
    case 3:
      hazeController!.enable();
      break;
  }
};

const applyRain = () => {
  const viewer = cesiumStore.getViewer();
  currentPostStage = viewer.scene.postProcessStages.add(
    new Cesium.PostProcessStage({ fragmentShader: rainShader })
  );
};

const applySnow = () => {
  const viewer = cesiumStore.getViewer();
  currentPostStage = viewer.scene.postProcessStages.add(
    new Cesium.PostProcessStage({ fragmentShader: snowShader })
  );
};

const updateSolarTime = () => {
  const viewer = cesiumStore.getViewer();
  viewer.scene.globe.enableLighting = true;
  const adjustedTime = new Date(new Date().setHours(solarHour.value) - (8 * 3600000));
  viewer.clock.currentTime = Cesium.JulianDate.fromDate(adjustedTime);
};

const clearPostStage = () => {
  const viewer = cesiumStore.getViewer();
  if (currentPostStage) {
    viewer.scene.postProcessStages.remove(currentPostStage);
    currentPostStage = null;
  }
  hazeController?.disable();
};
</script>

Atmospheric Haze Manager

This module encapsulates the fog post-process stage. It dynamically evaluates the camear's altitude, disabling the effect when the viewpoint exceeds a specified threshold to maintain performance and visual accuracy at high zoom levels.

import * as Cesium from 'cesium';
import hazeShader from './shaders/fog.glsl';

export class AtmosphericHaze {
  private targetViewer: Cesium.Viewer;
  private hazeStage: Cesium.PostProcessStage;
  private altitudeLimit: number;
  private densityParams: Cesium.Cartesian4;
  private isRendering: boolean;
  private cameraListener: () => void;

  constructor(config: {
    enabled: boolean;
    targetViewer: Cesium.Viewer;
    altitudeThreshold: number;
    densityCurve: Cesium.Cartesian4;
    tint: Cesium.Color;
  }) {
    this.targetViewer = config.targetViewer;
    this.altitudeLimit = config.altitudeThreshold;
    this.densityParams = Cesium.defaultValue(config.densityCurve, new Cesium.Cartesian4(10, 0, 1000, 0.9));
    this.isRendering = Cesium.defaultValue(config.enabled, false);

    this.cameraListener = this.onCameraShift.bind(this);
    this.initialize();
  }

  private initialize() {
    this.hazeStage = new Cesium.PostProcessStage({
      fragmentShader: hazeShader,
      uniforms: {
        hazeDensityCurve: () => this.densityParams,
        hazeTint: () => Cesium.defaultValue(this.config?.tint, Cesium.Color.WHITE)
      },
      enabled: this.isRendering
    });

    this.targetViewer.scene.postProcessStages.add(this.hazeStage);
    this.targetViewer.scene.camera.moved.addEventListener(this.cameraListener);
  }

  enable() {
    this.isRendering = true;
    this.updateVisibility();
  }

  disable() {
    this.isRendering = false;
    if (this.hazeStage) this.hazeStage.enabled = false;
  }

  private onCameraShift() {
    this.updateVisibility();
  }

  private updateVisibility() {
    const currentAlt = this.targetViewer.camera.positionCartographic.height;
    if (this.hazeStage) {
      this.hazeStage.enabled = currentAlt < this.altitudeLimit ? this.isRendering : false;
    }
  }

  dispose() {
    this.targetViewer.scene.camera.moved.removeEventListener(this.cameraListener);
    if (this.hazeStage) {
      this.targetViewer.scene.postProcessStages.remove(this.hazeStage);
    }
  }
}

Fragment Shader Implementations

The following shaders operate on the rendered scene texture. They must be imported as raw strings or handled by your bundler's asset pipeline.

Rainfall Simulation

Generates animated streaks by perturbing UV coordinates over time and applying a pseudo-random distribution mask.

uniform sampler2D sceneTexture;
varying vec2 textureCoord;

float pseudoRandom(float seed) {
    return fract(sin(seed * 127.1) * 43758.5453);
}

void main(void) {
    float elapsedFrames = czm_frameNumber / 180.0;
    vec2 screenRes = czm_viewport.zw;
    
    vec2 normalizedUV = (gl_FragCoord.xy * 2.0 - screenRes) / min(screenRes.x, screenRes.y);
    vec3 baseColor = vec3(0.6, 0.7, 0.8);
    
    float angle = -0.35;
    float s = sin(angle), c = cos(angle);
    mat2 rotation = mat2(c, -s, s, c);
    normalizedUV = normalizedUV * rotation;
    normalizedUV *= length(normalizedUV + vec2(0.0, 5.2)) * 0.25 + 1.1;
    
    float dropIntensity = 1.0 - sin(pseudoRandom(floor(normalizedUV.x * 90.0)) * 2.5);
    float dropVisibility = clamp(abs(sin(22.0 * elapsedFrames * dropIntensity + normalizedUV.y * (4.5 / (2.1 + dropIntensity)))) - 0.92, 0.0, 1.0) * 18.0;
    baseColor *= dropIntensity * dropVisibility;
    
    vec4 originalScene = texture2D(sceneTexture, textureCoord);
    gl_FragColor = mix(originalScene, vec4(baseColor, 1.0), 0.55);
}

Snowfall Simulation

Uses layered procedural noise to simulate multiple sizes of falling particles with wind drift and vertical accumulation masking.

uniform sampler2D sceneTexture;
varying vec2 textureCoord;

float computeSnowflake(vec2 pos, float scaleFactor) {
    float time = czm_frameNumber / 45.0;
    float fadeMask = smoothstep(1.2, 0.0, -pos.y * (scaleFactor * 0.12));
    if (fadeMask < 0.05) return 0.0;
    
    pos += vec2(time / scaleFactor);
    pos.y += time * 1.8 / scaleFactor;
    pos.x += sin(pos.y + time * 0.6) / scaleFactor;
    pos *= scaleFactor;
    
    vec2 cell = floor(pos), frac = fract(pos);
    vec2 offset;
    float dist, minDist = 4.0;
    
    for (float i = -1.0; i <= 1.0; i++) {
        for (float j = -1.0; j <= 1.0; j++) {
            vec2 neighbor = vec2(i, j);
            vec2 randomSeed = fract(sin((cell + neighbor) * mat2(5.1, 3.2, 6.8, 7.4)) * 4.9);
            vec2 p = 0.5 + 0.35 * sin(11.0 * fract(randomSeed * 5.0)) - frac;
            p += neighbor;
            dist = length(p);
            if (dist < minDist) minDist = dist;
        }
    }
    return minDist * fadeMask;
}

void main(void) {
    vec2 screenRes = czm_viewport.zw;
    vec2 uv = (gl_FragCoord.xy * 2.0 - screenRes) / min(screenRes.x, screenRes.y);
    float accumulated = 0.0;
    
    accumulated += computeSnowflake(uv, 12.0);
    accumulated += computeSnowflake(uv, 9.0);
    accumulated += computeSnowflake(uv, 7.0);
    accumulated += computeSnowflake(uv, 5.5);
    
    vec3 particleColor = vec3(accumulated);
    vec4 originalScene = texture2D(sceneTexture, textureCoord);
    gl_FragColor = mix(originalScene, vec4(particleColor, 1.0), 0.6);
}

Volumetric Fog Integration

Reads the depth buffer to calculate distance from the camera, interpolating opacity and blending a color overlay to simulate atmospheric depth.

uniform sampler2D sceneTexture;
uniform sampler2D depthBuffer;
uniform vec4 hazeDensityCurve;
uniform vec4 hazeTint;
varying vec2 textureCoord;

float calculateSceneDepth(sampler2D depthTex, vec2 coords) {
    float rawDepth = czm_unpackDepth(texture2D(depthTex, coords));
    if (rawDepth == 0.0) return czm_infinity;
    vec4 eyePos = czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth);
    return -eyePos.z / eyePos.w;
}

float computeFogDensity(vec4 params, float distance) {
    float nearDist = params.x;
    float nearOp = params.y;
    float farDist = params.z;
    float farOp = params.w;
    float ratio = clamp((distance - nearDist) / (farDist - nearDist), 0.0, 1.0);
    return mix(nearOp, farOp, ratio);
}

vec4 compositeOverlay(vec4 top, vec4 bottom) {
    return top * vec4(top.aaa, 1.0) + bottom * (1.0 - top.a);
}

void main(void) {
    float distanceToSurface = calculateSceneDepth(depthBuffer, textureCoord);
    vec4 originalColor = texture2D(sceneTexture, textureCoord);
    
    float density = computeFogDensity(hazeDensityCurve, distanceToSurface);
    vec4 atmosphericLayer = vec4(hazeTint.rgb, hazeTint.a * density);
    
    gl_FragColor = compositeOverlay(atmosphericLayer, originalColor);
}

Tags: cesium WebGL glsl-shaders post-processing 3D-visualization

Posted on Tue, 15 Sep 2026 16:42:54 +0000 by rahuul