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.
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/
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.