在高德地图上实现城市扫光效果
前言
在设计和实现数据可视化大屏时,有一个常见的需求就是要实现一个周期性脉冲效果,让光墙定时掠过城市的每一个建筑。这种效果不仅能够吸引人们的眼球,还能够增强可视化大屏的视觉冲击力和信息传递效果。实现这个效果需要运用到WebGL和数学知识,下面我们浅谈一下实现过程。
需求说明
- 支持将GeoJSON格式的建筑面数据,转换为地图上的简易建筑模型,数据量10万以内能够流畅运行
- 建筑外观可配置,即支持在模型面上贴相同的纹理,顶部与侧面分开处理
- 扫光效果为平面环状,可以配置初始位置、最大尺寸、环带宽度、颜色、扫光速度
实现思路
1.GeoJSON格式的平面转换成挤压几何体是常规操作了,很多可视化图层都会用到,实现原理就是通过面的多边形坐标和已设定高度,生成对应的侧面和顶部面,相关代码可以看之前写的文章在高德地图实现可定制立体区块。如下图所示,每个建筑都是这样处理,需要注意的是有些建筑可以能是个组合型的几何体,建筑面积由多个平面构成,需要与单体建筑区分处理。
2.生成白模后贴纹理图,可以侧面和顶面分开使用材质;也可以仅使用一个材质,在着色器集体实现时对顶面和侧面分开贴图。
3.处理扫光效果,在片元着色器上实现,针对每个片元,判断它是否处于圆环波动带上。如果是,则取圆环和建筑纹理的叠加状态;如果不是,则取建筑本身的纹理状态。
4.逐帧变化圆环的半径,就形成了动画效果
名词解释
法线:在计算机图形学中,法线(Normal)通常指的是表面法线,是指垂直于表面的单位向量,用于描述一个几何体或几何形状的表面方向。在渲染图形时,表面法线对于光照和阴影的计算非常重要,因为它确定了每个点表面的朝向,从而影响表面的亮度和颜色。
顶点着色器:顶点着色器(Vertex Shader)是在图形渲染管线中的一个可编程的阶段,用于处理输入的顶点数据并输出变换后的顶点位置和其他属性信息。顶点着色器通常用于执行一些基本的几何变换,如旋转、缩放、平移和投影,以及计算每个顶点的法向量、纹理坐标和颜色等信息。
片元着色器:片元着色器(Fragment Shader)是在图形渲染管线中的一个可编程的阶段,用于计算每个像素的最终颜色值。它接收由顶点着色器传递过来的数据,如纹理坐标、法向量、顶点颜色等,并根据这些数据计算每个像素的颜色值。
代码实现
1.创建几何体
createPolygon () {
let sideGeometryArr = []
let topGeometryArr = []
this._data.forEach((item, index) => {
const { geometry, properties } = item
const { type, coordinates } = geometry
// 对象为单体多边形
if (type === 'Polygon') {
const { sides, tops } = this._createPolygon(coordinates, properties)
sideGeometryArr = sides
topGeometryArr = tops
}
//...独享为多体多边形
})
// 合并侧边
const mesh0 = new THREE.Mesh(mergeBufferGeometries(sideGeometryArr, false), this._mt)
this.scene.add(mesh0)
// 合并顶部
const mesh1 = new THREE.Mesh(mergeBufferGeometries(topGeometryArr, false), this._mt)
this.scene.add(mesh1)
}
/**
* 根据路径绘制单个多边几何体
* @param paths
* @param properties
* @returns {{tops: Array, sides: Array}}
* @private
*/
_createPolygon (paths = [], properties) {
const sides = []; const tops = []
paths.forEach(path => {
if (this._limitCount >= this._limitMax) {
return
}
// 绘制侧边几何体,这个之前文章讲过了不展开
const side = this.drawSide(path, properties)
sides.push(side)
// 绘制顶部几何体,这个之前文章讲过了不展开
const top = this.drawTop(path, properties)
tops.push(top)
this._limitCount++
})
return { sides, tops }
}
2.创建自定义材质,把需要配置的参数设定好
_uniforms = {
// 建筑顶部贴图
topMap: {
value: null
},
// 建筑侧边贴图
sideMap: {
value: null
},
innerCircleWidth: {
value: 0
},
// 波动环宽度
circleWidth: {
value: null
},
// 圆环颜色
color: {
value: null
},
// 圆环透明度
opacity: {
value: 0.8
},
// 环圆心位置
center: {
value: null
}
}
this._mt = new THREE.ShaderMaterial({
uniforms:_uniforms,
vertexShader,
fragmentShader,
side: THREE.DoubleSide,
depthTest: true,
transparent: true
})
3.本文的重点来了!着色器的实现。
在片元着色器主函数中,首先计算当前片元与中心点center的距离,判断是否在圆环范围内,如果是则取圆环和纹理的叠加态,否则取纹理;
其次判断这里的纹理是取顶面纹理还是侧面纹理,则根据当前点的法线方向做判断,法线朝上表示当前所在面为顶面,否则为侧面。
const shader ={
//顶点着色器
vertexShader: `
varying vec2 vUv;
varying vec3 v_position;
varying vec3 vNormal; //点法线向量
void main() {
vUv = uv;
vNormal = normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
v_position = vec3(modelMatrix * vec4(position, 1.0));
}
`,
//片元着色器
fragmentShader: `
varying vec2 vUv;
varying vec3 v_position;
varying vec3 vNormal;
uniform float innerCircleWidth;
uniform float circleWidth;
uniform float opacity;
uniform vec3 center;
uniform vec3 color;
uniform sampler2D topMap;
uniform sampler2D sideMap;
void main() {
float dis = length(v_position - center);
// 与波动有交集
if(dis < (innerCircleWidth + circleWidth) && dis > innerCircleWidth) {
float r = (dis - innerCircleWidth) / circleWidth;
if (vNormal.z > 0.0) {
// 位于顶部
gl_FragColor = mix(texture2D(topMap, vUv), vec4(color, opacity), r);
}else{
// 位于侧面
gl_FragColor = mix(texture2D(sideMap, vUv), vec4(color, opacity), r);
}
}else {
if (vNormal.z > 0.0) {
gl_FragColor = texture2D( topMap, vUv);
}else{
gl_FragColor = texture2D( sideMap, vUv);
}
}
}
`
}
4.在逐帧函数中修改圆环宽度
animate () {
if (this.update) {
this.update()
}
requestAnimationFrame(() => {
this.animate()
})
}
update () {
if (!this._isAnimate) {
return
}
const { innerCircleWidth, circleWidth, opacity } = this._uniforms
// 每帧半径增加20
innerCircleWidth.value += 20
// 内半径超过最大值时重置
**if (innerCircleWidth.value > this._conf.maxRadius) {
innerCircleWidth.value = 0
}
}
注意点
- 这里有个非常重要的技巧,生成模型后需要将所有的几何体做一次合并,这样做的好处是极大提升性能,如果不合并几万个几何体会卡成狗,注意看下图左上角的帧率性能指标。合并后的模型会丢失独立信息,不过可以通其他方法补偿。
合并前卡成狗
合并后纵享丝滑
2.从上面代码实现第二步可以看到事实上我把所有几何体合并成了2个,一个是总体侧边,一个是总体顶部,这样做只是为了方便以后直接替换顶部材质。具体实现时也可以有其他合并方法。
还可以做哪些改进
1.增加鼠标交互,选中的建筑高亮。
这一步还没实现,鼠标选中对象是单个几何体,有自己的材质,则很简单,将材质中pick属性设置为true,则表示鼠标选中,材质变成高亮状态;然而为了性能考虑我已经将几万个几何体合并了,需要另外想办法,基本原理我猜想是把鼠标坐标EventPoint传入到着色其中,判断几何体顶点和事件点EventPoint的距离,如果在约定范围内则将对应的所有片元颜色高亮,还在调整中。有没有大佬分享下经验。
2.建筑楼面的材质,可以使用着色器写得更真实,比如antvL7中的图层,以阵列的方式绘制了矩形并赋予指定的颜色作为纹理,需要更多的GLSL和数学知识,不过有了chatGPT的加持这一块难度会极大降低。
相关链接
在高德地图实现可定制立体区块
用three.js实现炫酷的城市扫光效果
three.js实战-shader实现antv L7城市扫光效果