Three.js 利用 shader 实现六边形网格扫光

3,891 阅读5分钟

最终的效果

2025-06-09 20-59-29.gif

将以上的蜂窝网格作为大屏背景,效果如下:

Image\_20250609202902.png

这个蜂窝的纹理会不断扩散, 直到最后消失。

灵感来源

其实做这个特效,我参考了很多网站,例如 DataV、 EasyV , 这些专门做大屏的公司,

给出了很多大屏模板,不少模板的背景,就是蜂窝网格。

案例一: 地球外层的扫光,用了六边形网格

image.png

案例二:中国地图,背景也用了六边形网格

image.png

所需贴图

因为六边形网格,需要无限延伸,所以贴图要做的非常有技巧,

就是水平方向、垂直方向,重复排列,都能完美合拍。

贴图如下

image.png

three.js 中, 用着色器编程, 结合上面的贴图,思路如下。

1、静态平面

构造一个平面, 将贴图水平方向复制 N 份, 垂直方向也复制 N 份,形成了一个蜂窝网格平面

2、动态平面

构造另一个平面,使用 ShaderMaterial 创建着色器材质, 通过传入参数, 给平面动态着色

3、关键帧动画

不断地调整扩散半径, 渐变透明度,周而复始,形成扫光动画

着色原理

如下图所示, 蓝色圆环表示当前的扩散圆环

(注意:这个圆环实际上不会被全部染色,因为圆环是六边形纹理的),

其中 OA 表示扩散半径, O为圆心, A为环形中间位置,

假设扩散半径 OA 的长度为 r, 圆环的染色部位是 CD, CD 的长度是 2 * halfR,

而且 CD 的渐变不透明度是从 0 ——> 1 ——> 0 ,也就是两边接近透明,中间最亮,

那么,我们只需要计算平面上的点是否落在圆环区域 ——

是,则需要考虑染色; 不是,即返回透明色。

image.png

如果平面上的点, 不在圆环区域内,那么直接返回 gl_FragColor = vec4(0,0,0,0) 表示透明色。

如果平面的点, 在圆环区域内, 则需要考虑, 是否在蜂窝贴图的白色线上面 ——

是,则需要染色; 不是,则直接用透明色。

image.png

例如,上图的 A 点,需要染色, B 点则不需要要染色。

以A点为例, 通过读取贴图的纹理,得到颜色:

vec4 colors = texture2D(gridTexture, vUv * repeat);

这时候, colors 结构是 (r, g, b, a), 其中, r、 g、 b、 a 均是 0 至 1 的数字,

由于贴图是白色透明的, A点在白色线上, 所以 r、g、b 这三个数,基本上都是接近或等于 1,

而 a 这个分量,则可能不是 1, 因为白色线可以是渐变的, 白色线边缘越接近透明, a 越接近 0。

如果这时,需要将 A点 染成某个颜色 diffuseColor , 则需要下面这句:

gl_FragColor = vec4(diffuseColor, colos.a);

这样, diffuseColor 这个颜色,就取代了白色, 并且具有贴图那样,边沿呈现稍微的渐变效果。

但是,这样一来, 整个圆环着色部位,基本上都是 diffuseColor 这个颜色, 渐变不明显。

如果想要圆环边沿颜色较淡, 中间颜色较深,那么需要调整下染色策略。

这时, 可以计算着色点在圆环中,靠近圆环中心的程度:

1、越靠近中心, 不透明度越接近 1

2、越靠近圆环边沿, 不透明度越接近 0

这样就计算出新的不透明度 a2;

为了让圆环呈现出渐变, 将 colos.a 乘以 a2 并作为新的不透明度,这样圆环就呈现出渐变效果。

也就是 gl_FragColor = vec4(diffuseColor, colos.a * a2);

复杂化

以上分析,已经将基本形态做好了,但是如果希望半径越大, 扩散圆环的颜色越淡,

则可以再传入一个不透明度的系数 opacity, 也就是最终的着色计算是:

gl_FragColor = vec4(diffuseColor, colos.a * a2 * opacity);

同时, 扩散半径越大, 圆环越细小, 都可以通过参数传入,来改变形态,

也就是上面所说的 halfR, 这个参数可以适当随扩散半径增大而减少, 视觉效果更好。

整体代码如下。

完整代码


import {
  AdditiveBlending,
  Color,
  Group,
  Mesh,
  MeshBasicMaterial,
  PlaneGeometry,
  RepeatWrapping,
  ShaderMaterial,
  Texture,
} from 'three';

type floorParams = {
  group: Group;
  grid: Texture;
  gridBlack: Texture;
};

type floorUniform = {
  diffuseColor: { value: Color };
  r: { value: number };
  halfR: { value: number };
  gridTexture: { value: Texture };
  repeat: { value: number };
  opacity: { value: number };
};

export class FloorBg {
  private config: floorParams;
  private uniforms: floorUniform;
  private needToAnimateFloor = false;
  private readonly planeSize = 350;
  private readonly repeatNum: number = 40;
  private readonly translateZ: number = -5;
  private readonly firstDefaultR: number = -50;
  private nextDefaultR: number = -150;
  private readonly maxR: number = 200;
  private readonly halfRBase: number = 5;
  private readonly reduceR: number = 100;

  constructor(params: floorParams) {
    this.config = params;
    this.uniforms = {
      diffuseColor: { value: new Color(0x30dcff) },
      r: { value: this.firstDefaultR },
      halfR: { value: this.halfRBase },
      gridTexture: { value: this.config.grid },
      repeat: { value: this.repeatNum },
      opacity: { value: 1.0 },
    };
  }

  public tick(quickly: boolean) {
    if (this.uniforms && this.needToAnimateFloor) {
      let newR = this.uniforms.r.value;
      let newHalfR = this.halfRBase;
      let opacity = 1.0;

      const step = quickly ? 0.3 : 0.5;
      this.nextDefaultR = quickly ? -200 : -150;

      if (newR >= this.maxR) {
        newR = this.nextDefaultR;
        newHalfR = this.halfRBase;
        opacity = 1.0;
      } else {
        newR += step;
        if (newR <= this.reduceR) {
          newHalfR = this.halfRBase + (10 / this.reduceR) * newR;
          opacity = 1.0;
        } else {
          newHalfR =
            this.halfRBase +
            10 -
            ((this.halfRBase + 10) / (this.maxR - this.reduceR)) * (newR - this.reduceR);
          opacity = 1.0 - (newR - this.reduceR) / (this.maxR - this.reduceR);
        }
      }

      this.uniforms.r.value = newR;
      this.uniforms.halfR.value = newHalfR;
      this.uniforms.opacity.value = opacity;
    }
  }

  public create() {
    const texture = this.config.grid;
    const alphaMap = this.config.gridBlack;
    texture.wrapS = texture.wrapT = alphaMap.wrapS = alphaMap.wrapT = RepeatWrapping;
    texture.repeat.set(this.repeatNum, this.repeatNum);
    alphaMap.repeat.set(this.repeatNum, this.repeatNum);

    const planeBg = new Mesh(
      new PlaneGeometry(this.planeSize, this.planeSize),
      new MeshBasicMaterial({
        map: texture,
        color: 0x00ffff,
        transparent: true,
        opacity: 0.05,
        alphaMap: alphaMap,
        blending: AdditiveBlending,
      }),
    );
    planeBg.translateZ(this.translateZ);
    this.config.group.add(planeBg);

    const shaderMaterial = new ShaderMaterial({
      uniforms: this.uniforms,
      vertexShader: `
        //纹理坐标uv
        varying vec2 vUv;
        //顶点坐标
        varying vec3 vPosition;
        void main(){
          vPosition = position;
          vUv = uv;
          gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
        }
      `,
      fragmentShader: `
        //纹理坐标uv
        varying vec2 vUv;
        //顶点坐标
        varying vec3 vPosition;
        //扩散光圈的颜色
        uniform vec3 diffuseColor;
        //扩散半径
        uniform float r;
        //扩散半径加减一定的范围用来染色
        uniform float halfR;
        //网格的纹理贴图
        uniform sampler2D gridTexture;
        //纹理重复次数
        uniform float repeat;
        //整体的衰减透明度
        uniform float opacity;
        void main(){
          if(r < 0.0){
            //扩散半径太小,一律变成透明
            gl_FragColor = vec4(0,0,0,0);
          }else{
            //圆心原点
            vec2 center = vec2(0.0, 0.0); 
            //距离圆心的距离
            float rDistance = distance(vPosition.xy, center);
            if(rDistance < r - halfR || rDistance > r + halfR){
              //不在光圈范围内,一律变成透明
              gl_FragColor = vec4(0,0,0,0);
            }else{
              float a;
              if(rDistance < r){
                a = (rDistance - (r - halfR)) / halfR;
              }else{
                a = 1.0 - ((rDistance - r) / halfR);
              }
              //因为水平方向、垂直方向都重复了N次,所以乘以N
              vec4 colors = texture2D(gridTexture, vUv * repeat);
              a = a * colors.a * opacity;
              gl_FragColor = vec4(diffuseColor, a);
            }
          }
        }
      `,
      transparent: true,
      depthTest: false,
    });
    const animatedPlaneBg = new Mesh(
      new PlaneGeometry(this.planeSize, this.planeSize),
      shaderMaterial,
    );
    animatedPlaneBg.translateZ(this.translateZ);
    this.config.group.add(animatedPlaneBg);

    this.needToAnimateFloor = true;
  }
}