Three.js中使用着色器绘制飞线

2,797 阅读7分钟

WechatIMG845.jpeg

前言

好一段时间没有更新文章了,今天要分享的功能是threejs实现飞线的最佳实践,其实这个功能早在去年在另外一个项目中已经去实现过了,但是当时的实现反反复复改了好几版,效果也都不尽人意,最后也就那样用着了,最近又在另外一个项目中要实现类似的功能,索性就一次研究个透彻,也就趁着这次还算满意的实现更新一下文章。接下来就一步步去分析实现这个功能吧。

先放效果图

7月10日.gif

需求分析

关于threejs飞线这个功能,相信各位在开发threejs地图的时候基本上都会遇到这个需求,要说用处吗,其实就是让这些关系类的数据更加直观的展现出来。
我们这边可以简单的把飞线这个功能拆成两块:

  1. 底线(轨道线)
  2. 飞线(动态线)

了解这个需求我们就可以来着手进行实现了。 我这边的示例的代码是使用React来写的,不过也不用担心,在threejs使用方面,Vue、Html、React在使用方法上没有任何区别,这套方案可以在任何前端框架中使用。

底线(轨道线)

在数学中我们都学过点成线、线成面...在threejs中也一样,如果我们想要绘制出一条线的话,首先根据我们这条飞线的三个点来获取n个点,在根据这n个点去绘制出来一条完整的抛物线。

// 根据起点和终点获取曲线的坐标点
  const getCurvePoint = useCallback((coord: CoordinateItem) => {
    const { x0, y0, x1, y1 } = coord;
    // 凸起高度 也就是飞线的高度
    const convexZ = 45;
    // 起点坐标
    const v0 = new Vector3(x0, y0, 0);
    // 控制点1坐标
    const v1 = new Vector3(
      x0 + (x1 - x0) / 4, // 在起点的基础上,控制点1的坐标为起点的1/4
      y0 + (y1 - y0) / 4, // 在起点的基础上,控制点1的坐标为起点的1/4
      convexZ
    );
    // 控制点2坐标
    const v2 = new Vector3(
      x0 + ((x1 - x0) * 3) / 4, // 在起点的基础上,控制点2的坐标为起点的3/4
      y0 + ((y1 - y0) * 3) / 4, // 在起点的基础上,控制点2的坐标为起点的3/4
      convexZ
    );
    // 终点坐标
    const v3 = new Vector3(x1, y1, 0);
    // 使用3次贝塞尔曲线来绘制曲线
    const lineCurve = new CubicBezierCurve3(v0, v1, v2, v3);
    // 获取曲线上的点 暂时定为获取1000个点 后续绘制流线动画的时候更好的计算
    return lineCurve.getPoints(divisions);
  }, [divisions]);
  1. 在这边我们使用threejs的三次贝塞尔曲线来提取出来1000个点的坐标,这里有有一点需要解释的是为什么要提取出来1000个点呢,其实提取多少都是可以的,点越多你绘制出来线的还原度就更好,然后这个方法和后续绘制飞线是通用的,所以我才标了注释取1000个点,后续会解释1000个点如何取计算流线的动画。

提取完点之后我们就需要来进行画线了,在threejs中绘制线有很多种图形可以选择,我这边试了几个,最适合做飞线这个功能的其实就是TubeGeometry-管道缓冲几何体 简单说明理由的话,你的轨道线如果是一个管道的话,你的飞线如果小于这个管道的宽,那么飞线其实就是在管道内部进行流动,大于管道的宽则就是包着管道在滚动,这样不会出现飞线和底线重合而出现的视觉偏差问题。

const lineMesh = useMemo(() => {
    // 获取当前线的坐标点信息
    const points = getCurvePoint(item);
    const curve = new CatmullRomCurve3(points);
    const lineColor = transformColor(runwayLine.color);
    const tubeGeometry = new TubeGeometry(
      curve, // 一个由基类Curve继承而来的3D路径。
      256, // 组成这一管道的分段数,默认值为64。
      width * 2, // 管道半径。宽度比设置的大一点是为了让流线从管道内部穿过
      8, // 管道横截面的分段数目,默认值为8。
      false // 管道的两端是否闭合。
    );
    const material = new MeshBasicMaterial({
      color: lineColor.value,
      transparent,
      opacity: 1,
      depthTest: false,
    });
    const mesh = new Mesh(tubeGeometry, material);
    return mesh;
  }, [getCurvePoint, item, runwayLine.color, transparent, width]);

这样我们就能绘制出来了一条平滑的轨道线了。如下图

image.png

飞线(动态线)

  • 轨道线实现后接下来就实现飞线,飞线我们采取着色器来实现,着色器实现的效果比其他方式都比较好,所以我们直接使用着色器来实现,飞线实现的基本思路可以理解为我和轨道线一样去创建出n个点,但是我不把这n个点去渲染成一条线,并且这些点默认是不展示的,我们只需要在合适的时机去让某些连续的点展示,那么这些展示的点在视觉上就是一条流动的飞线了。同理我们在控制这些点的大小就能让这条线展现出小尾巴的效果。
// 获取当前线的坐标点信息
  const points = useMemo(() => {
    return getCurvePoint(item);
  }, [getCurvePoint, item]);

  // 创建物体
  const geometry = useMemo(() => {
    const indexList = points.map((_, index) => index);
    // 根据坐标点数组创建出一个线状几何体
    const bufferGeometry = new BufferGeometry().setFromPoints(points);
    // 给几何体添加自定义的索引标识 用来后续根据索引设置点的透明度
    bufferGeometry.setAttribute(
      "aIndex",
      new Float32BufferAttribute(indexList, 1)
    );
    return bufferGeometry;
  }, [points]);

这样我们创建出来一个飞线的几何体 接下来绘制这个几何体的材质、也就是用着色器实现。

const material = useMemo(() => {
    // 起点颜色
    let color1 = "#FFC107";
    return new ShaderMaterial({
      depthTest: false,
      uniforms: {
        // 线条颜色
        uColor: {
          value: new Color(color1),
        },
        // 时间1-1000
        uTime: {
          value: 0,
        },
        // 水滴宽度
        uWidth: {
          value: flowLine.width,
        },
        // 水滴长度
        uLength: {
          value: flowLine.length,
        },
      },
      vertexShader: /*glsl*/ `
        attribute float aIndex; // 内部属性 浮点 当前序号

        uniform float uTime; // 全局变量 浮点 当前时间

        uniform float uWidth; // 全局变量 浮点 当前时间
        
        uniform vec3 uColor; // 全局变量 颜色 设置的颜色

        varying float vSize; // 片元变量(需要传递到片面着色器) 浮点 尺寸

        uniform float uLength; // 全局变量 浮点 线段长度

        void main(){
            vec4 viewPosition = viewMatrix * modelMatrix * vec4(position,1);

            gl_Position = projectionMatrix * viewPosition; // 顶点矩阵变换 设置各个点的位置

            // 当前顶点的位置处于线段长度内 则设置水滴大小
            if(aIndex >= uTime - uLength && aIndex < uTime){
              // 水滴大小根据当前位置慢慢变小
              // p1 uWidth越大水滴越粗
              // vSize = uWidth * ((aIndex - uTime + uLength) / uLength);
              // p2 uWidth越大水滴越细
              vSize = (aIndex + uLength - uTime) / uWidth;
            }
            gl_PointSize = vSize;
        }
      `,
      fragmentShader: /*glsl*/ `
        varying float vSize;
        uniform vec3 uColor;
        void main(){
            // 透明度根据当前大小确定是否展示
            if(vSize<=0.0){
              gl_FragColor = vec4(1,0,0,0);
            }else{
              gl_FragColor = vec4(uColor,1);
            }
        }
      `,
      transparent: true,
      vertexColors: false,
    });
  }, [flowLine.length, flowLine.width]);

然后我们使用GSAP来实现一个循环动画就可以了,当然你使用Tween.js等其他动画库都是一样的。

gsap.fromTo(
        (pointsRef?.current?.material as any)?.uniforms?.uTime,
        { value: 0 },
        {
          // 实现飞线钻地效果需要让 动画节段数 = 飞线长度 + 飞线点数量
          value: flowLine.length + flowLine.divisions,
          duration: 3,
          repeat: -1,
          delay: 0,
          ease: "none",
          onUpdate: () => {},
        }
      );

这个动画的实现就是给着色器加了一个变量,然后在动画执行的这时间内告诉着色器这个动画现在执行的进度是多少,然后我们在着色器内去根据这个进度来控制让哪些点展示

写在最后

图片效果有点差,后续补一张高清实现图,文章可能写的比较白话,如果有写错的地方欢迎指出,有看不懂的可以私信或者评论。