Frontend Development 15 min read

Implementing Realistic Rain Effects on a Map Using Three.js and Custom Shaders

This article explains how to create a realistic, map‑based rain effect with Three.js by designing a 3D particle system, writing custom vertex and fragment shaders, integrating the layer into a map scene, and extending it to support wind and snow effects.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Implementing Realistic Rain Effects on a Map Using Three.js and Custom Shaders

Preface

A client from a water‑resources bureau requested a weather‑simulation feature—rain, flood, and related effects—on a map, prompting the author to explore a Three.js implementation.

Requirement Description

Render rain on a map with realistic sky and cloud changes, automatically adjusting wind speed, direction, and precipitation intensity based on local forecasts.

Requirement Analysis

Solution 1: Global Rain

Add a 2‑D rain plane in front of the viewport. Simple to implement but only works from limited viewpoints.

Solution 2: Localized 3‑D Rain

Define a 3‑D volume synchronized with the map’s coordinate system; rain particles have depth, work with any zoom level, but require more parameters (shape of the volume, coordination with sky‑box and building layers).

Implementation Idea

The author chose Solution 2, using a cubic volume centered on the map, ignoring wind, and letting raindrops fall as free‑fall particles. A custom shader leverages GPU parallelism, and auxiliary lines help visualize the volume.

Basic Code Implementation

Below are the essential functions.

1. Create Geometry

createGeometry (){ // 影响范围:只需要设定好立方体的size [width/2, depth/2, height/2]
 const { count, scale, ratio } = this._conf.particleStyle
 const { size } = this._conf.bound
 const box = new THREE.Box3(
   new THREE.Vector3(-size[0], -size[1], 0),
   new THREE.Vector3(size[0], size[1], size[2])
 )
 const geometry = new THREE.BufferGeometry()
 const vertices = []
 const normals = []
 const uvs = []
 const indices = []
 for (let i = 0; i < count; i++) {
   const pos = new THREE.Vector3()
   pos.x = Math.random() * (box.max.x - box.min.x) + box.min.x
   pos.y = Math.random() * (box.max.y - box.min.y) + box.min.y
   pos.z = Math.random() * (box.max.z - box.min.z) + box.min.z
   const height = (box.max.z - box.min.z) * scale / 15
   const width = height * ratio
   const rect = [
     pos.x + width, pos.y, pos.z + height/2,
     pos.x - width, pos.y, pos.z + height/2,
     pos.x - width, pos.y, pos.z - height/2,
     pos.x + width, pos.y, pos.z - height/2
   ]
   vertices.push(...rect)
   normals.push(
     pos.x, pos.y, pos.z,
     pos.x, pos.y, pos.z,
     pos.x, pos.y, pos.z,
     pos.x, pos.y, pos.z
   )
   uvs.push(1,1,0,1,0,0,1,0)
   indices.push(
     i*4+0, i*4+1, i*4+2,
     i*4+0, i*4+2, i*4+3
   )
 }
 geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3))
 geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), 3))
 geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), 2))
 geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1))
 return geometry
}

2. Create Material

createMaterial (){
 // 粒子透明度、贴图地址
 const { opacity, textureUrl } = this._conf.particleStyle
 const material = new THREE.MeshBasicMaterial({
   transparent: true,
   opacity,
   alphaMap: new THREE.TextureLoader().load(textureUrl),
   map: new THREE.TextureLoader().load(textureUrl),
   depthWrite: false,
   side: THREE.DoubleSide
 })
 const top = this._conf.bound.size[2]
 material.onBeforeCompile = function(shader, renderer){
   const getFoot = `
     uniform float top; // 天花板高度
     uniform float bottom; // 地面高度
     uniform float time; // 时间轴进度[0,1]
     #include
float angle(float x, float y){
       return atan(y, x);
     }
     vec2 getFoot(vec2 camera,vec2 normal,vec2 pos){
       vec2 position;
       float distanceLen = distance(pos, normal);
       float a = angle(camera.x - normal.x, camera.y - normal.y);
       pos.x > normal.x ? a -= 0.785 : a += 0.785;
       position.x = cos(a) * distanceLen;
       position.y = sin(a) * distanceLen;
       return position + normal;
     }
   `
   const begin_vertex = `
     vec2 foot = getFoot(vec2(cameraPosition.x, cameraPosition.y), vec2(normal.x, normal.y), vec2(position.x, position.y));
     float height = top - bottom;
     float z = normal.z - bottom - height * time;
     z = z + (z < 0.0 ? height : 0.0);
     float ratio = (1.0 - z / height) * (1.0 - z / height);
     z = height * (1.0 - ratio);
     z += bottom;
     z += position.z - normal.z;
     vec3 transformed = vec3( foot.x, foot.y, z );
   `
   shader.vertexShader = shader.vertexShader.replace('#include
', getFoot)
   shader.vertexShader = shader.vertexShader.replace('#include
', begin_vertex)
   shader.uniforms.cameraPosition = { value: new THREE.Vector3(0,0,0) }
   shader.uniforms.top = { value: top }
   shader.uniforms.bottom = { value: 0 }
   shader.uniforms.time = { value: 0 }
   material.uniforms = shader.uniforms
 }
 this._material = material
 return material
}

3. Create Model

createScope (){
   const material = this.createMaterial()
   const geometry = this.createGeometry()
   const mesh = new THREE.Mesh(geometry, material)
   this.scene.add(mesh)
 }

4. Update Parameters

_clock = new THREE.Clock()
update () {
   const { _conf, _time, _clock, _material, camera } = this
   this._time = _clock.getElapsedTime() * _conf.particleStyle.speed / 2 % 1
   if (_material.uniforms) {
     _material.uniforms.cameraPosition.value = camera.position
     _material.uniforms.time.value = _time
   }
 }
animate (time) {
   if (this.update) this.update(time)
   if (this.map) this.map.render()
   requestAnimationFrame(() => { this.animate() })
 }

Optimization Adjustments

By exposing particle, wind, and other parameters through a configuration object, the same layer can render snow or other weather effects with minimal code changes.

const layer = new ParticleLayer({
  map: getMap(),
  center: mapConf.center,
  zooms: [4,30],
  bound: { type: 'cube', size: [500,500,500] },
  particleStyle: {
    textureUrl: './static/texture/snowflake.png',
    ratio: 0.9,
    speed: 0.04,
    scale: 0.2,
    opacity: 0.5,
    count: 1000
  }
})

Adding Wind Influence

Introduce wind direction and strength to offset particle positions during the free‑fall calculation.

const begin_vertex = `
   ...
   float ratio = (1.0 - z / height) * (1.0 - z / height);
   z = height * (1.0 - ratio);
   float x = foot.x + 200.0 * ratio; // wind offset on X
   float y = foot.y + 200.0 * ratio; // wind offset on Y
   vec3 transformed = vec3( x, y, z );
`

Remaining Issues

When rotating particle planes around the vertical axis to simulate wind, the built‑in "face camera" shader logic keeps the original tilt, causing visual inconsistencies that need further shader adjustments.

Related Links

1. THREE.JS rain advanced version – face Y‑axis to camera: https://www.wjceo.com/blog/threejs2/2019-02-28/185.html

2. Online demo: https://jsfiddle.net/gyratesky/5em3rckq/17/

frontendThree.jsWebGLShaderMap IntegrationRain Effect
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.