在地图上制作光圈图层haloLayer

4,362 阅读13分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

介绍

在数据可视化展示中,静态的地图场景通常需要一些动效元素的支持,以增强其表现力和用户体验。例如,光圈效果可以用来突出特定的地理位置或数据点,从而吸引观众的注意力。此外,动效元素还可以包括逐渐变化的颜色、闪烁的标记、流动的路径线等,这些效果不仅能使地图更加生动有趣,还能更直观地展示数据变化和趋势。

通过合理运用这些动态元素,可以使观众更容易理解复杂的数据关系,提升整体的视觉冲击力和信息传达的效率。今天给大家带来一些实现动态光圈的思路,我们从易到繁来实现效果。

Honeycam 2024-06-11 08-09-22.gif

工程代码演示

准备工作

本次分享使用地图是高德地图JSAPI 2.0,THREE为0.157版本;

图片素材是在花瓣上收集的,只要输入“光圈”或“魔法阵”之类的关键词就能搜到非常多可用素材,下载后把webp转为png格式才能做二次处理。

新手阶段

如何你只是一个刚入门的萌新,连three.js文档都是一知半解的,只是想要一个好看的光圈交差。那么可以看看下面这个最简单的光圈制作过程。

开发思路

  1. 首先在网上找几个好看的光圈素材图片,支持alpha透明文件格式的(比如png),图片宽高比为1:1,确保图案在正中间且靠近边界1px的区域是完全透明的。

    1.jpg

  2. 创建地图实例,并通过GLCustomLayer图层创建THREE的三维场景,这块内容在之前文章已经分享过大量示例教程了,也可以直接使用高德的官方示例作修改。

  3. 在地图场景中添加一个平面几何体geometry,指定宽高一致就行,不需要细分网格。

    Untitled.png

  4. 使用找到的生成素材图片作为纹理texture,制作一个基础材质material,这里需要注意将纹理中心相对于画布垂直和水平居中,并声明纹理的包裹模式为ClampToEdgeWrapping,意思是如果纹理填充不满当前网格,那么会取它最后一个像素将延伸到网格的边缘。使用geometry和material组合创建网格体plane,我们就可以得到一个静态的光圈图层了。

    Untitled 1.png

  5. 最后一步,在浏览器逐帧渲染函数中不断增加纹理texture的旋转角度值,就可以得到一个动态的光圈了。一个不够还可以多叠加几个,此时我们再配置一些参数让每个实例有所差异,比如半径、中心位置、位置高度、动画速度、旋转方向、色相等等,就能出效果。

    haloLayer4.gif

代码实现

以下就是基于开发思路编写的核心代码逻辑,为方便萌新们学习我把源代码也放到了演示平台上,可供大家科学地在线查看调试,也可以下载。

  1. 创建网格体

    async createMesh () {
        /**
         *  @param {Number} [config.radius=1000] 网格体的半径
         *  @param {Number} [config.altitude=0] 网格体的海拔位置高度
         */ 
        const { radius, altitude } = this._conf
    
        // 几何体,声明了宽高
        const geometry = new THREE.PlaneGeometry(radius * 2, radius * 2)
        // 材质
        let material = await this.generateRotateMaterial()
        // 网格体
        const plane = new THREE.Mesh(geometry, material)
        
        // 位置居中到场景中心
        plane.position.set(0, 0, altitude)
        // 添加到场景
        this.scene.add(plane)
    
        this._mesh = plane
    }
    
  2. 创建光圈的材质

    async generateRotateMaterial () {
      const { textureMapURL, color, opacity } = this._conf
      const texture = await new THREE.TextureLoader().load(textureMapURL)
      // 设置纹理包裹方式
      texture.wrapS = THREE.ClampToEdgeWrapping
      texture.wrapT = THREE.ClampToEdgeWrapping
      // 纹理图居中
      texture.center = new THREE.Vector2(0.5, 0.5)
    
      const material = new THREE.MeshBasicMaterial({
        side: THREE.DoubleSide,
        map: texture,
        transparent: true,
        alphaTest: 0.01,
        opacity,
        color
      })
    
      return material
    }
    
  3. 逐帧更新旋转角度,通过修改纹理的rotation属性就可以做到,注意在这之前我们已经将texture.center设置为(0.5,0.5)即画布中心,让纹理绕着画布中心旋转。

    update (deltaTime) {
      const { rotateOptions } = this._conf
    
      // 材质还没创建好
      if (this?._mesh?.material === undefined) {
        return
      }
    
      // 当前旋转角度超过360则回到初始值
      if (this._angle > 360) {
        this._angle = rotateOptions.initAngle
      } else {
        this._angle += 0.1 * this._conf.speed * rotateOptions.direction
      }
      // 把角度转为弧度,再赋值给rotation属性
      this._mesh.material.map.rotation = this._angle * Math.PI / 180
    }
    

提升阶段

有动手能力强的盆友可能会说了,就这?也忒简单了吧。 那咱们开始加点东西,把光圈做成一种逐渐扩散型的效果。

haloLayer2.gif

开发思路

  1. 创建地图实例,添加THREE场景,然后添加平面几何体,操作步骤跟刚才是一样的
  2. 制作扩散的效果,有两种思路:

(1)直接用光圈图片把网格体填满,然后逐帧控制网格体的尺寸,让它逐渐放大或缩小

(2)网格体尺寸不变,创建一种自定义材质,能够随着时间调整图案的尺寸

  1. 考虑到后面可能会把旋转效果和扩散效果整合在一起做成可配置,我们选择第二种。另外我还想加一在扩散光圈的基础上叠加一层纹理,让它看上去更有科技感。

  2. 本阶段的重点就在自定义材质怎么实现的,我们搬出着色器材质(ShaderMaterial),在它上面用GLSL编写材质,这部代码实现在后面逐行注释。

    Untitled 2.png

    (1)把一些可配置项比如圆环半径、圆环宽度、纹理图、颜色等等,作为参数uniform从js传到GLSL,后面我们通过调整这些参数达到动画效果。

    (2)创建一个动态圆环

    (3)叠加纹理图和颜色

    (4)动态计算透明度,当动画进度到达50%时,逐渐降低透明度,直到100%时透明度为0,以此循环往复。

  3. 在浏览器逐帧函数中,调整参数innerWidth,达到扩散效果

    haloLayer1.gif

  4. 看着这个动画过程感觉有点不自然,因为它的动画过程太过均匀了没有啥动感,因此我们改进一下逐帧函数的更新模式,引入缓动逻辑进来,这才是最终形态。

    Honeycam_2024-06-10_09-14-03.gif

代码实现

  1. 创建网格体,包括几何体和自定义材质

    async createMesh () {
        const { radius, altitude, mode } = this._conf
    
        // 几何体
        const geometry = new THREE.PlaneGeometry(radius * 2, radius * 2)
    
        // 材质
        let material = await this.generateSpreadMaterial()
    
        // 网格体
        const plane = new THREE.Mesh(geometry, material)
        plane.position.set(0, 0, altitude)
        this.scene.add(plane)
    
        this._mesh = plane
      }
    
  2. 编写自定义材质,定义着色器参数

    /**
     * 扩散动画配置
     * @typedef {Object} SpreadOptions
     * @property {number} [initialRadius=0] - 扩散初始半径,默认为0
     * @property {number} [maxRadius] - 扩散最大半径,不指定则没有限制
     * @property {number} [radiusIncrement=1] - 每次扩散半径的增量
     * @property {number} [circleWidth] - 环形的宽度,不指定则默认为半径的1/10
     */
    spreadOptions: {
      initRadius: 0,
      circleWidth: null,
      center: null
    }
    
    /**
     * 创建扩散动画的材质
     * @private
     */
    async generateSpreadMaterial () {
      // 纹理图地址 半径大小 扩散动画配置 颜色
      const { textureMapURL, radius, spreadOptions, color } = this._conf
      const { initRadius, circleWidth, center, repeat } = spreadOptions
      const texture = await new THREE.TextureLoader().load(textureMapURL)
      texture.wrapS = THREE.RepeatWrapping
      texture.wrapT = THREE.RepeatWrapping
      texture.center = new THREE.Vector2(0.5, 0.5)
    
      const { vertexShader, fragmentShader } = SpreadShader
      const material = new THREE.ShaderMaterial({
        uniforms: {
          innerCircleWidth: { value: initRadius },
          // 当前圆环半径
          circleWidth: { value: circleWidth },
          color: { value: new THREE.Color(color || 0xffffff) },
          opacity: { value: 0.8 },
          center: { value: new THREE.Vector3(center[0], center[1]) },
          // 最大半径
          radius: { value: radius },
          // 纹理图
          textureMa: { value: texture },
          // 纹理的重复次数
          repeat: { value: new THREE.Vector2(repeat[0], repeat[1]) }
        },
        vertexShader,
        fragmentShader,
        transparent: true
      })
      return material
    }
    
  3. 顶点着色器

    varying vec2 vUv;
    varying vec3 v_position;
    void main() {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        v_position = vec3(modelMatrix * vec4(position, 1.0));
    }
    
  4. 片元着色器,需要注意这里有两个透明度。alpha是控制真个图层的透明度,r则是控制每个像素点在其所处位置上应该渲染的透明度。

    varying vec2 vUv;
    varying vec3 v_position;
    
    uniform float innerCircleWidth;
    uniform float circleWidth;
    uniform float radius;
    uniform float opacity;
    uniform vec3 center;
    uniform vec3 color;
    uniform sampler2D textureMap;
    uniform vec2 repeat;
    
    void main() {
     float dis = length(v_position - center);
    
     // 不超过半径范围,且与波动有交集
     if( dis < radius && dis < (innerCircleWidth + circleWidth) && dis > innerCircleWidth) {
        // 计算当前片元的位置占整个圆环宽度的比例
        float r = (dis - innerCircleWidth) / circleWidth;
    
        // 透明度衰减起始点50%, 终止点 100%
        float startDecay = radius * 0.5;
        float endDecay = radius;
    
        // 计算透明度
        float alpha = 1.0;
        if (dis > startDecay) {
            alpha = 1.0 - (dis - startDecay) / (endDecay - startDecay);
        }
        if (dis >= endDecay) {
            alpha = 0.0;
        }         
        
        // 纹理和颜色混合
        gl_FragColor = mix(texture2D(textureMap, vUv * repeat), vec4(color, opacity), r);
        // 叠加 过程透明度 和 位置透明度  
        gl_FragColor.a *= alpha * r;
    
     }else {
        // 丢弃片元不渲染
        discard;
     }        
    
    }
    
  5. 升级逐帧更新的逻辑,引入缓动函数

    
    this._tweenInstance = new TWEEN.Tween(target)
    .to({ t: 1 }, duration)
    .easing(TWEEN.Easing.Cubic.InOut)
    .easing(easing)
    .onUpdate(() => {
    		// 调整当前圆环的半径
        const { innerCircleWidth } = this._mesh.material.uniforms
        innerCircleWidth.value = spreadOptions.initRadius + target.t * (radius - spreadOptions.initRadius)
        if (innerCircleWidth.value > radius) {
          // 重置半径
          innerCircleWidth.value = spreadOptions.initRadius
        }
      }
    })
    .onComplete(() => {
      // 播放结束后重新开始
      target.t = 0
      this._tweenInstance
        .stop()
        .to({ t: 1 }, duration)
        .start()
    })
    .start()
          
    /**
     * 逐帧更新图层
     * @private
     */
    update (deltaTime) {
      if (this?._mesh?.material === undefined) {
        return
      }
      if (this._tweenInstance) {
        this._tweenInstance.update()
      }
    }
    
    

大师阶段

这时候有位大师过来了,大师看了看觉得这也没啥呀,GLSL用的太浅了,要不我给你们整点儿花活儿,用GLSL写一个酷炫的雷达扫描效果。不愧是大师,马上搬出来一套我也没太看得懂的代码,这是薅了多少头发学到的编程技能啊。

大师说不用担心,我已经把毕生绝学写到Web秘籍里了,拿来就能用,于是大师掏出了他的武林绝学速成。可把我高兴坏了,要是能把这里面的花活儿都学到手,岂不是要上天?

开发思路

  1. 让我们来分解一下这个雷达效果的结构

(1)主体框架由中心点和3个像时钟刻盘一样的同心圆组成,从圆心外射8条线段

(2)绕着中心点转动的扇形扫描区域,这个区域除了有透明度还带着网格纹理

(3)在扇形区域时隐时现的红色三角物体和蓝色圆形物体

  1. 我们只要传入时间和纹理图即可,其他内容全部通过GLSL编程绘制

    haloLayer3.gif

代码实现

  1. 顶点着色器,常规默认操作

    varying vec2 vUv;
    void main() 
    {
        vUv = uv;
        gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    }
    
  2. 片元着色器,这里面的代码实现太专业领域了,需要亿点点数学知识,不过现在只需要知道每个代码块实现的功能就行

    const float PI = 3.14159265359;
    const float TWO_PI = 6.28318530718;
    // 边长数量
    const int N = 3;
    // 中间圆心的尺寸
    const float r0 = 0.01;
    // 蓝色闪烁点的尺寸
    const float r_blue = 0.005;
    // 红色闪烁点的尺寸
    const float r_red = 0.005;
    // 雷达完整尺寸占画布的比例
    const float edge = 0.95;
    const float offset = 0.05;
    
    // 动画时间
    uniform float time;
    // 扫描扇形范围叠加的纹理
    uniform sampler2D textureMap;
    varying vec2 vUv;
    
    // 绘制辐射直径
    // 参数:当前像素坐标,指定绘制位置, 线宽
    float plot(const vec2 st, const float pct, const float width)
    {
        return smoothstep(pct - width, pct, st.y) -  smoothstep(pct, pct + width, st.y);
    }
    
    // 绘制雷达追踪到的多边形物体
    // 参数:物体中心 边数  半径 画布位置
    float drawPolygon(const vec2 polygonCenter, const int N, const float radius, vec2 pos)
    {
      pos = pos - polygonCenter;
      float d = 0.0;
      float a = atan(pos.x, pos.y);
      float r = TWO_PI / float(N);
      d = cos(floor(0.5 + a / r)*r - a)*length(pos);
      return (1.0 - smoothstep(radius, radius + radius/10.0, d));
    }
    
    // 生成圆盘的刻度
    // 参数:当前角度 a, 刻度数量 gradNum, 圆盘半径 outRad, 刻度长 tickLen,刻度宽 tickWidth, r, 旋转速度
    float gradations(const float a, const float gradNum, const float outRad, const float tickLen, const float tickWidth, const float r, const float move)
    {
    float f = step(0.0, cos((a + move)*gradNum) - tickWidth)*tickLen + (outRad - tickLen);
        return 1.0 - step(f, r) * 1.0 - step(r, outRad - tickLen);
    }
          
    void main(  )
    {
        // 屏幕像素坐标(从0到1)
        vec2 uv = vUv;
        // 圆心位置
        vec2 pos = uv.xy - vec2(0.5, 0.5) ; 
           
        // 扫描区域叠加的叠加纹理图
        vec4 mapcol = texture2D(textureMap,uv) * vec4 (0.0, 0.85, 0.0, 1.0);
          
        vec3 color = vec3(0.0, 0.0, 0.0);
        
        float r = length(pos) * 2.0;
        // 当前像素点的角度
        float a = atan(pos.y, pos.x); 
        // 雷达扫描的角度
        float an = PI - mod(time/ 1.0, TWO_PI); 
        // 被追踪物体的移动速度
        float blipSpd = 3.0; 
        vec2 translate1 = vec2(cos(time/ blipSpd), sin(time/ blipSpd));
        vec2 translate2 = vec2(sin(time/ blipSpd), cos(time/ blipSpd));
        vec2 left1 = translate1 * 0.35;
        vec2 right1 = -translate1 * 0.30;
        vec2 left2 = translate2 * 0.15;
        vec2 right2 = -translate2 * 0.25;
                  
        //  雷达扫描
        float sn = step(PI/2.0, an) * step(-PI/2.0, (a + an)) * step(r, edge) * (1.0 - 0.55 * (a + (TWO_PI) - an));
        float sw = step(an, a) * step(r, edge);
        float s_blade = sw * (1.0 - (a - an) * 20.0);
        float s = sw * (1.0 - 0.55 * (a - an));
        s = max(sn,s);
        float se = step(r, edge - 0.05);
           
        // 外圈圆
        float s1 = smoothstep(edge - 0.00, edge + 0.01, r)* smoothstep(edge + 0.02, edge + 0.01, r);   
           
        // 同心圆:中心点 内圈 中圈          
        float s0 = 1.0 - smoothstep(r0 / 2.0, r0, length(pos));
        float smb = (1.0 - smoothstep(0.2, 0.2 + 0.01, length(pos))) * (1.0 - smoothstep(0.2 +0.01, 0.2, length(pos)));
        float smr = (1.0 - smoothstep(0.3, 0.3 + 0.01, length(pos))) * (1.0 - smoothstep(0.3 +0.01, 0.3, length(pos)));
        
        // Circular concentric gradations
        float gradNum = 120.0;
        float tickWidth = 0.9;
        const float tickLen = 0.04;
        float outRad = edge;
        float move = 0.0;
        // 绘制外圈刻度
        float sm = 0.75*gradations(a, gradNum, outRad, tickLen, tickWidth, r, move);   
                     
        gradNum = 36.0;
        tickWidth = 0.95;
        outRad = 0.6;
        move = sin(time/10.0);
        // 绘制中圈刻度
        smr += 0.5*gradations(a, gradNum, outRad, tickLen, tickWidth, r, move);         
       
        outRad = 0.4;
        move = cos(time/10.0);
        // 绘制内圈刻度
        smb += 0.5*gradations(a, gradNum, outRad, tickLen, tickWidth, r, move);
        
        // 8条辐射线
        float sr = plot(pos, pos.x, 0.003) * step(r, edge - 0.06);
        sr += plot(vec2(0.0, 0.0), pos.x, 0.002) * step(r, edge - 0.06);
        sr += plot(vec2(0.0, 0.0), pos.y, 0.003) * step(r, edge - 0.06);
        sr += plot(-pos, pos.x, 0.003) * step(r, edge - 0.06);
        sr *= 0.75;
    
        // 蓝色圆形被追踪物体
        vec2 st_trace1 = left2;
        float s_trace1 = s * (1.0 - smoothstep(r_blue / 10.0, r_blue, length(pos - st_trace1)));
        s_trace1 += s * (1.0 - smoothstep(r_blue / 10.0, r_blue, length(pos - st_trace1 + vec2(+offset, +offset))));
        s_trace1 += s * (1.0 - smoothstep(r_blue / 10.0, r_blue, length(pos - st_trace1 + vec2(+2.0 *offset, +2.0 *offset))));
        
        vec2 st_trace2 = right1;
        float s_trace2 = s * (1.0 - smoothstep(r_blue / 10.0, r_blue, length(pos - st_trace2)));
        
        // 红色三角形被追踪物体
        vec2 st_trace3 = left1;
        float st1 = s * (drawPolygon(st_trace3, N, r_red , pos));
        st1 += s * (drawPolygon(st_trace3 + vec2(-offset, -offset), N, r_red, pos));
        st1 += s * (drawPolygon(st_trace3 + vec2(+offset, -offset), N, r_red, pos));
        
        vec2 st_trace4 = right2;
        float st2 = s * (drawPolygon(st_trace4, N, r_red, pos));  
            
        // 叠加扫描区域和纹理图
        float s_grn = max(s * mapcol.y, s_blade);
        // 叠加: 扫描区域, 中心点 辐射线 外圈刻度
        s_grn = max(s_grn, (s0 +  sr + sm));
        // 叠加:外 中 内圈  加强颜色:/0.5
        s_grn += (s1  + smb  + smr) / 0.5 ; 
        
        // 将被追踪物体置于顶部
        float s_red = st1*2.0 + st2*2.0 + smr;          
        float s_blue = max(s_trace1 + s_trace2, s_blade) + smb;
        
        if (s_trace1 > 0.0 || s_trace2 > 0.0) { 
          s_blue = max(s, s_blue); 
          s_grn = max(s_grn, s_blue); 
        }
    
        color += vec3(s_red , s_grn, s_blue);            
        vec4 texColor = mapcol * s;
        
        // 过滤掉纯黑色背景
        if (color == vec3(0.0, 0.0, 0.0)) {
            discard;
        }else{
          gl_FragColor = vec4(color, s_red + s_grn + s_blue); 
        }
    
    }
    
  3. 逐帧函数更新time,就能得到连续的动画效果。

    update (deltaTime) {
      const { time } = this._mesh.material.uniforms
      time.value += 0.04
    }
    

写在最后

最后一步,我们把3个阶段实现的效果封装起来,通过mode配置参数实现“旋转、扩散、雷达扫描”这三种模式的切换,就可以充分提升这个图层的可用性啦。当前在封装的时候主要考虑代码拓展,除了“雷达扫描”,在今后我们也可以把子各种自定义的材质效果加入,从而得到更多的可视化效果,真是太有趣了。

Honeycam_2024-06-10_13-32-48.gif

至此我们今天的光圈图层小知识就分享完了,不知道你的感受如何。

我前段时间在B站学习建模的时候,听老师说在”建模10分效果,有7分靠贴图”,这意味着一个好的建模作品,其视觉效果的大部分来自于贴图的设计与应用,而模型本身的造型与细节则占据了较小的比例。这种说法强调了贴图在建模作品中的重要性,因为贴图可以赋予模型更加真实和生动的外观,增强其视觉吸引力和表现力。

我认为在webGL可视化领域也是一样的道理,大部分情况下可能并不需要很厉害的编程手段,而只是把贴图和纹理工作做好了,就能够达到非常高质量的效果,代码演示工程放到网上了,选择了CodeSandbox这个平台是希望可以持续更新,希望你早日成为大师。

相关链接

Shadertoy Halo Effect

自定义着色器:雷达扫描

代码演示:旋转的光圈