在高德地图中实现降雨图层
前言
有一天老板跑过来跟我说,我们接到一个水利局的项目,需要做一些天气效果,比如说降雨、河流汛期、洪涝灾害影响啥的,你怎么看。欸,我觉得很有意思,马上开整。
需求说明
在地图上实现降雨效果,画面尽量真实,比如天空、风云的变化与降雨场景契合;
可以结合当地天气预报情况,自动调节风速、风向、降雨量等参数。
需求分析
方案一:全局降雨
在用户视口面前加一层二维的降雨平面层。
优点: 只管二维图层就行了,不需要与地图同步坐标,实现起来比较简单,界面是全局的一劳永逸。
缺点:只适合从某些角度观看,没法再做更多定制了。
方案二:局部地区降雨
指定降雨范围,即一个三维空间,坐标与地图底图同步,仅在空间内实现降雨。
优点:降落的雨滴有远近关系,比较符合现实场景;可适用各种地图缩放程度。
缺点:需要考虑的参数比较多,比如降雨范围一项就必须考虑这个三维空间是什么形状,可能是立方体、圆柱体或者多边形挤压体;需要外部图层的配合,比如说下雨了,那么天空盒子的云层、建筑图层的明度是否跟着调整。
实现思路
根据上面利弊权衡,我选择了方案二进行开发,并尽量减少输入参数,降雨影响范围初步定为以地图中心为坐标中心的立方体,忽略风力影响,雨滴采用自由落体方式运动。
降雨采用自定义着色器的方式实现,充分利用GPU并行计算能力,刚好在网上搜到一位大佬写的three演示代码,改一下坐标轴(threejs空间坐标轴y轴朝上,高德GLCustomLayer空间坐标z轴朝上)就可以直接实现最基础的效果。这里为了演示方便增加坐标轴和影响范围的辅助线。
1.创建影响范围,并在该范围内创建降雨层的几何体Geometry,该几何体的构成就是在影响范围内随机位置的1000个平面,这些平面与地图底面垂直;
2.创建雨滴材质,雨滴不受光照影响,这里使用最基础的MeshBasicMaterial材质即可,半透明化且加上一张图片作为纹理;
3.为实现雨滴随着时间轴降落的动画效果,需要调整几何体的形状尺寸,并对MeshBasicMaterial材质进行改造,使其可以根据当前时间time改变顶点位置;
调整顶点和材质,使其可以根据风力风向改变面的倾斜角度和移动轨迹;
将图层叠加到地图3D场景中
基础代码实现
为降低学习难度,本模块只讲解最基础版本的降雨效果,雨滴做自由落体,忽略风力影响。这里的示例以高德地图上的空间坐标轴为例,即z轴朝上,three.js默认空间坐标系是y轴朝上。我把three.js示例代码演示放到文末链接中。
1.创建影响范围,并在该范围内创建降雨层的几何体Geometry
jsx复制代码createGeometry () { // 影响范围:只需要设定好立方体的size [width/2, depth/2, height/2] // const { count, scale, ratio } = this._conf.particleStyle // 立方体的size [width/2, depth/2, height/2] 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() // 设置几何体的顶点、法线、UV 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) ) // 设置UV属性与顶点顺序一致 geometry.setAttribute( 'uv', new THREE.BufferAttribute(new Float32Array(uvs), 2) ) // 设置基本单元的顶点顺序 geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1)) return geometry }
2.创建材质
jsx复制代码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 <common> 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); // 根据点的位置和法向量的位置调整90度 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 <common>', getFoot ) shader.vertexShader = shader.vertexShader.replace( '#include <begin_vertex>', 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.创建模型
jsx复制代码 createScope () { const material = this.createMaterial() const geometry = this.createGeometry() const mesh = new THREE.Mesh(geometry, material) this.scene.add(mesh) // 便于调试,显示轮廓 // const box1 = new THREE.BoxHelper(mesh, 0xffff00) // this.scene.add(box1) }
4.更新参数
jsx复制代码// 该对象用于跟踪时间 _clock = new THREE.Clock() update () { const { _conf, _time, _clock, _material, camera } = this // 调整时间轴进度,_time都值在[0,1]内不断递增循环 // particleStyle.speed为降落速度倍率,默认值1 // _clock.getElapsedTime() 为获取自时钟启动后的秒数 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() }) }
优化调整
修改场景效果
通过对图层粒子、风力等参数进行封装,只需简单地调整配置就可以实现额外的天气效果,比如让场景下雪也是可以的,广州下雪这种场景,估计有生之年只能在虚拟世界里看到了。
以下是配置数据结构,可供参考
jsx复制代码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, // 直线降落速度倍率,默认值1 scale: 0.2, // 粒子尺寸倍率,默认1 opacity: 0.5, // 粒子透明度,默认0.5 count: 1000 // 粒子数量,默认值10000 } })
添加风力影响
要实现该效果需要添加2个参数:风向和风力,这两个参数决定了粒子在降落过程中水平面上移动的方向和速度。
首先调整一下代码实际那一节步骤2运动的相关代码
jsx复制代码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; // 粒子最终在x轴的位移距离是200 float y = foot.y + 200.0 * ratio; // 粒子最终在y轴的位移距离是200 ... // 生成变换矩阵 vec3 transformed = vec3( foot.x, y, z );
如果粒子是长条形的雨滴,那么它在有风力影响的运动时,粒子就不是垂直地面的平面了,而是与地面有一定倾斜角度的平面,如图所示。
我们调整调整一下代码实际那一节步骤1的代码,实现方式就是让每个粒子平面在创建之后,所有顶点绕着平面的法线中心轴旋转a角度。
本示例旋转轴(x, y, 1)与z轴(0,0,1)平行,这里有个技巧,我们在做平面绕轴旋转的时候先把平面从初始位置orgPos移到坐标原点,绕着z轴旋转后再移回orgPos,会让计算过程简单很多。
jsx复制代码// 创建当前粒子的顶点坐标 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 ] // 定义旋转轴 const axis = new THREE.Vector3(0, 0, 1).normalize(); //定义旋转角度 const angle = Math.PI / 6; // 创建旋转矩阵 const rotationMatrix = new THREE.Matrix4().makeRotationAxis(axis, angle); for(let index =0; index< rect.length; index +=3 ){ const vec = new THREE.Vector3(rect[index], rect[index + 1], rect[index + 2]); //移动到中心点 vec.sub(new THREE.Vector3(pos.x, pos.y,pos.z)) //绕轴旋转 vec.applyMatrix4(rotationMatrix); //移动到原位 vec.add(new THREE.Vector3(pos.x, pos.y, pos.z)) rect[index] = vec.x; rect[index + 1] = vec.y; rect[index + 2] = vec.z; }
待改进的地方
本示例中有个需要完善的地方,就是加入了风力影响之后,如果绕垂直轴旋转一定的角度,会看到如下图的异常,雨点的倾斜角度和运动倾斜角度是水平相反的。
问题的原因是材质着色器中的“让所有面始终朝向相机”方法会一直维持粒子的倾斜状态不变,解决这个问题应该是调整这个方法就可以了。然而作为学渣的我还没摸索出来,果然可视化工程的尽头全是数学Orz。