书接上文,除了网格Mesh之外,three.js支持另一种物体,那就是这次的主题之一:粒子。

粒子

之前提到的网格,是一个用三角形构成的实体;但是粒子,就是由许多的颗粒组成的一个比价分散的结构

我们要创建一个例子,只需要把之前的Mesh改成Points就可以了。

const points = new THREE.Points(geometry, material);
this.scene.add(points);

但是要注意,在顶点着色器中,我们还要给粒子的大小设置一下,通过的变量是gl_PointSize

            vertexShader: /* glsl */ `
              uniform float iTime;
              uniform vec3 iResolution;
              uniform vec4 iMouse;
              varying vec2 vertexUv;
              void main(){
                vec3 p = position;
                gl_PointSize=10.;
                vertexUv = uv;
                gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
              }
            `,


现在就可以看到粒子了,这就是默认的样子,但是这样不是很好看,我们可以根据学的 uv 的基础知识里的发光效果来制作发光的粒子。

const material = new THREE.ShaderMaterial({
  vertexShader: /* glsl */ `
              uniform float iTime;
              uniform vec3 iResolution;
              uniform vec4 iMouse;
              varying vec2 vertexUv;
              void main(){
                vec3 p = position;
                gl_PointSize=10.;
                vertexUv = uv;
                gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
              }
            `,
  fragmentShader: /* glsl */ `
              uniform float iTime;
              uniform vec3 iResolution;
              uniform vec4 iMouse;
              varying vec2 vertexUv;
              void main() {
                vec2 uv = gl_PointCoord;
                uv=(uv-.5)*2.;
                float d=length(uv);
                float c=.05/d;
                c=pow(c,2.);
                gl_FragColor=vec4(vec3(1.),c);

              }
            `,
  transparent: true,
});

结束了之后大概长这样:

学到这里,我有几个疑问,这边记录一下:

  • 为什么我使用的是gl_PointCoord而不是vertexUv
    这主要与渲染点的方式以及gl_PointCoord的用途有关:

    • gl_PointCoord
      是一个内置的变量,也是Points的专有变量,在片元着色器专门用于处理点精灵(point sprites)。点精灵是一种使用点的几何图元进行渲染的方法。每个点精灵在渲染时会被扩展为一个正方形(通常用于粒子效果)。这个变量就提供了片元着色器当前片元在点精灵内的坐标,范围是[0.0, 1.0]。
    • vertexUv
      这里的变量是我们自定义的,通过顶点着色器传递给片段着色器的UV坐标。在使用普通用单元(平面,球体)的时候,我们通常使用 UV 坐标在片元着色器中进行纹理采样和其他的操作。
  • 为什么不再使用比例修正代码uv.x*=iResolution.x/iResolution.y;了?

    • 因为这个形状Points是长宽相等的,默认不用修正比例
  • transparent是什么?

    • 因为three.js的默认材质不是透明的,为了让透明度设置生效,我们要手动在材质中开启透明transparent。设置为true
  • 为什么计算结果c放在了透明度的第四维度?

    • 3D世界下,shader更多地关注透明度的控制,因此计算出的值用作 alpha 通道,颜色值固定。

现在,我们的透明度也有了,但是整体还是比较暗淡,咱们来给材质加两个参数吧:

  1. blending: THREE.AdditiveBlending
  2. depthWrite: false

来解释一下这两个的作用:

  • blending: THREE.AdditiveBlending
    混合模式,控制材质的混合模式,也就是渲染对象与背景或者其他对象如何混合。而AdditiveBlending就是加法混合模式。新绘制的颜色值会与已有的颜色值相加。在我们的粒子球体中,产生了一个累加的光效,让它看起来像是在发光。
  • depthWrite: false
    depthWrite 属性控制材质是否会写入深度缓冲区(Depth Buffer)。渲染对象不会写入深度缓冲区。这意味着即使这个对象在场景中的位置较远,它也不会影响其他对象的深度排序。这种设置通常用于需要重叠或半透明效果的对象,使其不干扰场景中其他对象的渲染顺序。在我们的球体中,关闭深度写入使得点不会阻挡或覆盖其他点,无论它们的绘制顺序如何。这对创建透明或半透明效果特别有用,因为它确保了所有点都能正确显示,而不会因深度冲突而被错误遮挡。

我们再把gl_PointSize设置大一点,设置为20.,这样一来,我们的球体就更加的明显了,长这样:

但是现在有一个问题,如果我拉近摄像机,我们就几乎无法看清楚近处的粒子了,因为就算视角变了,但是粒子的大小依然是一个固定值,没有随之变化,我们该怎么解决这个问题呢?

理想的情况应该是:越靠近相机的粒子越大,远离相机的粒子越小,我们需要对gl_PointSize做一个矩阵变化的操作。这个操作three.js 封装的 shader 里面有提到过。我们可以稍微修改一下。

gl_PointSize=50.;
vec4 mvPosition=modelViewMatrix*vec4(p,1.);
gl_PointSize*=(1./-mvPosition.z);

接下来我来讲解一下这个步骤的原理:
之前的一篇关于坐标系的文章里有提到过转换的过程。其中,讲到了物体坐标系到摄像机坐标系的转换,其实
vec4 mvPosition=modelViewMatrix*vec4(p,1.);这一步就是还原这个转换过程的。

gl_PointSize *= (1. / -mvPosition.z)这行代码进行视角缩放调整。具体来说,它根据顶点到相机的距离来调整点的大小,以实现透视缩放效果。

  • mvPosition.z:顶点在视图坐标系中的 z 坐标,表示顶点到相机的距离。这个值通常是负的,因为在摄像机坐标系中,相机朝向 -Z 方向。
  • 1.0 / -mvPosition.z:计算视角缩放因子。距离越远,缩放因子越小,点的大小也就越小,反之亦然。
  • gl_PointSize = 50.:这一步就是把粒子调大一点这样我们可以看的更清楚一些

现在我们就看的比较清楚了!近点的粒子

那么我们还剩下最后一个问题,粒子的大小是跟屏幕的像素比有关的,这会导致我们在不同尺寸的屏幕上看到粒子大小会不同。

现象就如同这张图:

该怎么办?

我们需要获取像素比,作为一个uniform变量传入到Shader

const material = new THREE.ShaderMaterial({
  ...,
  uniforms: {
    uPixelRatio: {
      value: this.renderer.getPixelRatio(),
    },
  },
});

在顶点着色器中,顶部声明像素比的uniform变量,并将它与微粒大小相乘即可。

  uniform float iTime;
  uniform vec3 iResolution;
  uniform vec4 iMouse;
  uniform float uPixelRatio;
  varying vec2 vertexUv;
  void main(){
    vec3 p = position;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
    gl_PointSize=50. * uPixelRatio;
    vertexUv = uv;
    vec4 mvPosition = modelViewMatrix * vec4(p, 1.);
    gl_PointSize *= (1.0 / - mvPosition.z);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
  }
但是需要声明一点,正常来说不会出现像素比可以任意调节的情况,这里用开发者工具演示也只是为了说明有不同设备之间 Shader 渲染出来有差别这个现象。

在这里贴上整个代码:

<!DOCTYPE html>
<html lang="en">
  <style>
    body {
      margin: 0;
      background: black;
    }
  </style>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>vertexShader</title>
  </head>
  <body>
    <div id="sketch"></div>
    <script src="https://unpkg.com/kokomi.js@1.9.78/build/kokomi.umd.js"></script>
    <script src="https://unpkg.com/three@0.154.0/build/three.min.js"></script>
    <script>
      class Sketch extends kokomi.Base {
        create() {
          this.camera.position.set(0, 0, 5);
          new kokomi.OrbitControls(this);
          const geometry = new THREE.SphereGeometry(2, 64, 64);
          // const geometry = new THREE.PlaneGeometry(4, 4);
          const material = new THREE.ShaderMaterial({
            vertexShader: /* glsl */ `
              uniform float iTime;
              uniform vec3 iResolution;
              uniform vec4 iMouse;
              uniform float uPixelRatio;
              varying vec2 vertexUv;
              void main(){
                vec3 p = position;
                gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
                gl_PointSize=50. * uPixelRatio;
                vertexUv = uv;
                vec4 mvPosition = modelViewMatrix * vec4(p, 1.);
                gl_PointSize *= (1.0 / - mvPosition.z);
                gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
              }
            `,
            fragmentShader: /* glsl */ `
              uniform float iTime;
              uniform vec3 iResolution;
              uniform vec4 iMouse;
              varying vec2 vertexUv;
              void main() {
                vec2 uv = gl_PointCoord;
                uv=(uv-.5)*2.;
                float d=length(uv);
                float c=.05/d;
                c=pow(c,2.);
                gl_FragColor=vec4(vec3(1.),c);

              }
            `,
            transparent: true,
            blending: THREE.AdditiveBlending,
            depthWrite: false,
            uniforms: {
              uPixelRatio: {
                value: this.renderer.getPixelRatio(),
              },
            },
          });

          const points = new THREE.Points(geometry, material);
          this.scene.add(points);

          const uj = new kokomi.UniformInjector(this);
          material.uniforms = {
            ...material.uniforms,
            ...uj.shadertoyUniforms,
          };
          this.update(() => {
            uj.injectShadertoyUniforms(material.uniforms);
          });
        }
      }
      const sketch = new Sketch("#sketch");
      sketch.create();
    </script>
  </body>
</html>

自定义形状

我们现在粒子形成的几何体是一个球体,我们有没有什么办法可以把它改成一个自定义的形状?

当然是有的!

在顶点着色器中,有一个变量position,之前我们就提到过,这个变量有多么的重要,我们可以对他进行各种各样的魔改,因为它是一个attribute变量。这个变量是一个用于存储顶点信息的变量,顶点信息主要包括位置、颜色等等。

如果你想声明一个attribute变量,你可以这么写:

attribute vec3 position;

但是three.js里面的ShaderMaterial已经内置了这个声明,所以我们不需要再写第二遍。不过嘛,如果你传入了别的attribute变量的时候,还是需要去生声明一下的。

回到正题,如果我们要创建一个自定义的形状,也就意味着,我们要创建一个新的几何体,并且还要改变这个几何体的位置信息,也就是改变position这个attribute变量。

那么我们就要用到:BufferGeometry 来创建自定义的几何体。

const geometry = new THREE.BufferGeometry();

构造一个用于存储位置信息的Array数组,里面填充随机的数据,为了让亮点看起来多一点,我们把数据设置大一点。

const count = 5000;
let positions = Array.from({ length: count }, () =>
  [1, 1, 1].map(THREE.MathUtils.randFloatSpread)
);
console.log(positions);

创建了 5000 个位置数据,每个数据都有三个维度,用three.js内置的数学函数THREE.MathUtils.randFloatSpread(range)生成随机分布于(-range/2, range/2)之间的值(这里是(-0.5, 0.5)),我们把结果打印一下吧:

oh,我们需要的可不是二维数据,让我们来用flat函数把它扁平化:

positions = positions.flat();

现在就对了!
接下来我们把数组转换为Float32Array类型,这个类型是WebGL缓冲区所支持的类型。

positions = Float32Array.from(positions);

之后我们再将位置信息通过一个方法传递给geometry里。

geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));

这样一来,我们就得到了星海一样的内容了。

但是我们并不需要如此茂密的粒子,我们把数据调整一下:

// 拉近摄像头
this.camera.position.set(0, 0, 1);
...
// 将粒子数量限制到250
const count = 250;
// 将range扩大到2
let positions = Array.from({ length: count }, () =>
  [2, 2, 2].map(THREE.MathUtils.randFloatSpread)
);

我们还是可以像第一节一样,加上噪点来扭曲顶点位置p,这样一来可以营造一种生机的感觉。
全部代码:

<!DOCTYPE html>
<html lang="en">
  <style>
    body {
      margin: 0;
      background: black;
    }
  </style>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>bufferGeometry</title>
  </head>
  <body>
    <div id="sketch"></div>
    <script src="https://unpkg.com/kokomi.js@1.9.78/build/kokomi.umd.js"></script>
    <script src="https://unpkg.com/three@0.154.0/build/three.min.js"></script>
    <script>
      const count = 250;
      let positions = Array.from({ length: count }, () =>
        [2, 2, 2].map(THREE.MathUtils.randFloatSpread)
      );
      positions = positions.flat();
      console.log(positions);
      class Sketch extends kokomi.Base {
        create() {
          this.camera.position.set(0, 0, 1);
          new kokomi.OrbitControls(this);
          positions = Float32Array.from(positions);
          const geometry = new THREE.BufferGeometry();
          geometry.setAttribute(
            "position",
            new THREE.BufferAttribute(positions, 3)
          );
          // const geometry = new THREE.PlaneGeometry(4, 4);
          const material = new THREE.ShaderMaterial({
            vertexShader: /* glsl */ `
              uniform float iTime;
              uniform vec3 iResolution;
              uniform vec4 iMouse;
              uniform float uPixelRatio;
              varying vec2 vertexUv;
                            // GLSL textureless classic 3D noise "cnoise",
              // with an RSL-style periodic variant "pnoise".
              // Author:  Stefan Gustavson (stefan.gustavson@liu.se)
              // Version: 2011-10-11
              //
              // Many thanks to Ian McEwan of Ashima Arts for the
              // ideas for permutation and gradient selection.
              //
              // Copyright (c) 2011 Stefan Gustavson. All rights reserved.
              // Distributed under the MIT license. See LICENSE file.
              // https://github.com/ashima/webgl-noise
              //

              vec3 mod289(vec3 x)
              {
                  return x-floor(x*(1./289.))*289.;
              }

              vec4 mod289(vec4 x)
              {
                  return x-floor(x*(1./289.))*289.;
              }

              vec4 permute(vec4 x)
              {
                  return mod289(((x*34.)+1.)*x);
              }

              vec4 taylorInvSqrt(vec4 r)
              {
                  return 1.79284291400159-.85373472095314*r;
              }

              vec3 fade(vec3 t){
                  return t*t*t*(t*(t*6.-15.)+10.);
              }

              // Classic Perlin noise
              float cnoise(vec3 P)
              {
                  vec3 Pi0=floor(P);// Integer part for indexing
                  vec3 Pi1=Pi0+vec3(1.);// Integer part + 1
                  Pi0=mod289(Pi0);
                  Pi1=mod289(Pi1);
                  vec3 Pf0=fract(P);// Fractional part for interpolation
                  vec3 Pf1=Pf0-vec3(1.);// Fractional part - 1.0
                  vec4 ix=vec4(Pi0.x,Pi1.x,Pi0.x,Pi1.x);
                  vec4 iy=vec4(Pi0.yy,Pi1.yy);
                  vec4 iz0=Pi0.zzzz;
                  vec4 iz1=Pi1.zzzz;

                  vec4 ixy=permute(permute(ix)+iy);
                  vec4 ixy0=permute(ixy+iz0);
                  vec4 ixy1=permute(ixy+iz1);

                  vec4 gx0=ixy0*(1./7.);
                  vec4 gy0=fract(floor(gx0)*(1./7.))-.5;
                  gx0=fract(gx0);
                  vec4 gz0=vec4(.5)-abs(gx0)-abs(gy0);
                  vec4 sz0=step(gz0,vec4(0.));
                  gx0-=sz0*(step(0.,gx0)-.5);
                  gy0-=sz0*(step(0.,gy0)-.5);

                  vec4 gx1=ixy1*(1./7.);
                  vec4 gy1=fract(floor(gx1)*(1./7.))-.5;
                  gx1=fract(gx1);
                  vec4 gz1=vec4(.5)-abs(gx1)-abs(gy1);
                  vec4 sz1=step(gz1,vec4(0.));
                  gx1-=sz1*(step(0.,gx1)-.5);
                  gy1-=sz1*(step(0.,gy1)-.5);

                  vec3 g000=vec3(gx0.x,gy0.x,gz0.x);
                  vec3 g100=vec3(gx0.y,gy0.y,gz0.y);
                  vec3 g010=vec3(gx0.z,gy0.z,gz0.z);
                  vec3 g110=vec3(gx0.w,gy0.w,gz0.w);
                  vec3 g001=vec3(gx1.x,gy1.x,gz1.x);
                  vec3 g101=vec3(gx1.y,gy1.y,gz1.y);
                  vec3 g011=vec3(gx1.z,gy1.z,gz1.z);
                  vec3 g111=vec3(gx1.w,gy1.w,gz1.w);

                  vec4 norm0=taylorInvSqrt(vec4(dot(g000,g000),dot(g010,g010),dot(g100,g100),dot(g110,g110)));
                  g000*=norm0.x;
                  g010*=norm0.y;
                  g100*=norm0.z;
                  g110*=norm0.w;
                  vec4 norm1=taylorInvSqrt(vec4(dot(g001,g001),dot(g011,g011),dot(g101,g101),dot(g111,g111)));
                  g001*=norm1.x;
                  g011*=norm1.y;
                  g101*=norm1.z;
                  g111*=norm1.w;

                  float n000=dot(g000,Pf0);
                  float n100=dot(g100,vec3(Pf1.x,Pf0.yz));
                  float n010=dot(g010,vec3(Pf0.x,Pf1.y,Pf0.z));
                  float n110=dot(g110,vec3(Pf1.xy,Pf0.z));
                  float n001=dot(g001,vec3(Pf0.xy,Pf1.z));
                  float n101=dot(g101,vec3(Pf1.x,Pf0.y,Pf1.z));
                  float n011=dot(g011,vec3(Pf0.x,Pf1.yz));
                  float n111=dot(g111,Pf1);

                  vec3 fade_xyz=fade(Pf0);
                  vec4 n_z=mix(vec4(n000,n100,n010,n110),vec4(n001,n101,n011,n111),fade_xyz.z);
                  vec2 n_yz=mix(n_z.xy,n_z.zw,fade_xyz.y);
                  float n_xyz=mix(n_yz.x,n_yz.y,fade_xyz.x);
                  return 2.2*n_xyz;
              }
              vec3 distort(vec3 p){
              float speed=.1;
              float noise=cnoise(p)*.5;
              p.x+=cos(iTime*speed+p.x*noise*100.)*.2;
              p.y+=sin(iTime*speed+p.x*noise*100.)*.2;
              p.z+=cos(iTime*speed+p.x*noise*100.)*.2;
              return p;
              }
              void main(){
                vec3 p = position;
                p = distort(p);
                gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
                gl_PointSize = 50. * uPixelRatio;
                vec4 mvPosition = modelViewMatrix * vec4(p, 1.);
                gl_PointSize *= (1. / -mvPosition.z);
                vertexUv = uv;
              }
            `,
            fragmentShader: /* glsl */ `
              uniform float iTime;
              uniform vec3 iResolution;
              uniform vec4 iMouse;
              varying vec2 vertexUv;
              void main() {
                vec2 uv = gl_PointCoord;
                uv = (uv - .5) * 2.;
                float d = length(uv);
                float c = .05 / d;
                c = pow(c, 2.);
                gl_FragColor=vec4(vec3(1.), c);

              }
            `,
            transparent: true,
            blending: THREE.AdditiveBlending,
            depthWrite: false,
            uniforms: {
              uPixelRatio: {
                value: this.renderer.getPixelRatio(),
              },
            },
          });

          const points = new THREE.Points(geometry, material);
          this.scene.add(points);

          const uj = new kokomi.UniformInjector(this);
          material.uniforms = {
            ...material.uniforms,
            ...uj.shadertoyUniforms,
          };
          this.update(() => {
            uj.injectShadertoyUniforms(material.uniforms);
          });
        }
      }
      const sketch = new Sketch("#sketch");
      sketch.create();
    </script>
  </body>
</html>

这样一来,我们的顶点着色器部分就暂告一段落了。
稍微总结一下吧:

  1. 利用three.jskokomi.js可以快速搭建一个3D世界
  2. 网格Mesh由几何体和材质构成,而自定义材质可以用ShaderMaterial来完成。
  3. varying变量限定符负责将变量从顶点着色器传给片元着色器
  4. 利用kokomi.jsUniformInjector能把Shadertoyuniform变量注入自定义材质。
  5. 可以通过改变position顶点位置变量来实现顶点扭曲,除了用一般的数学计算来扭曲外,也可以用噪声函数进行扭曲。
  6. 粒子Pointsthree.js另一种支持的物体;能通过gl_PointSize来设定粒子的大小;通过改变材质的一些参数,能美化粒子效果的显示。
  7. 利用three.jsBufferGeometry能自定义几何体的形状;通过创建Float32Array类型数组,并将它填充到新的BufferAttribute内,我们就能改变attribute顶点信息变量的值。

多加摸索,从这里开始就变得复杂了,顶点着色器是Shader的不可或缺的一环,跟片元着色器一样,二者缺一不可,一旦掌握到位了,我们就可以做出来很多令人叹为观止的3D图形效果啦!

最后修改:2024 年 08 月 14 日
收款不要了,给孩子补充点点赞数吧