从现在开始,我们的学习就要步入顶点着色器阶段,也就是我们期待已久的 3D 世界了。

我思考了很久要怎么写这个阶段的开场词,要如何无缝地衔接之前的知识点到现在,作为承上启下的作用呢?

后来,我在逛 shaderToy 的时候看到了一个这样的巧克力着色器:

灵机一动,形象地说:我们之前学习的片元着色器决定了一个巧克力的颜色与图案,那么,接下来决定一个巧克力的形状的,那就是我们要学习的顶点着色器

欢迎来到 3D 的世界!

3D

虽然前面说,想要跨过threejs,做符合自己个性的东西,但是我在写这篇文章的时候还是意识到了这个库在3D方面的帮助性,在用的需求较大的如今,我们需要一个完善的工具库进行帮助学习,所以我还是找到了对其进行二次封装的一个优秀的三方库:kokomi.js

根据原作者介绍,这个框架封装了很多实用的函数与类,并且支持组件化的编写3D,也即是,three.jsjQuery

接下来我们创建一个文件夹,取名为3d,之后新建一个html文件,重置一下浏览器的样式,设置背景为黑色,取名叫vertexShader.html

记得导入两个 CDN:

    <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>

整体来说就是这样的:

PS:请注意截图里的代码,我们需要一个专门的 div 来定义画布的容器标签

<div id="sketch"></div>

准备好了之后,我们就准备创建一个空白的3D世界了。

我们对vertexShader.js编写如下的内容:

class Sketch extends kokomi.Base {
  create() {}
}

const sketch = new Sketch("#sketch");
sketch.create();

Sketch所继承的kokomi.Base类给我们自动创建了场景scene、渲染器renderer、相机camera、动画循环以及其他的一些组件。

QS:场景,渲染器,相机都是什么?
有兴趣的话可以自行搜索一下 threejs 的相关知识,官方的解释也是非常的详细了,我这边贴一张图来通俗易懂的讲解一下这三者的关系:

好,我们接下来就继续在create函数里编写代码了。

我们先设置下相机的初始位置,引入轨道控制组件kokomi.OrbitControls,此组件的功能是让我们可以通过拖拽鼠标来自由地观察全部的场景。

this.camera.position.set(0, 0, 5);
new kokomi.OrbitControls(this);

如此以来,我们就搭建了一个非常简单的3D画布,里面暂时啥也没有。

网格

我们来创建一个3D物体,在three.js里,3D被称之为网格Mesh,网格则是有两部分:

1.几何体geometry:定义了形状 2.材质material:定义了外观

我们来整一个球体:

const geometry = new THREE.SphereGeometry(2, 64, 64);
const material = new THREE.MeshBasicMaterial({
  color: "#ffffff",
});
const mesh = new THREE.Mesh(geometry, material);
this.scene.add(mesh);

有两个陌生的方法:
SphereGeometry 指定了几何体为球体,半径为 2,宽高的细分皆为 64;
MeshBasicMaterial制定了材质为基础材质(没有光照,只有颜色或纹理的材质),颜色为白色。效果如下:

这些东西是three.js的内容,封装程度正如我最开始介绍 shader 的时候说的那样,是非常的高的,我们这个白色圆形就是一个基础材质,都是用 shader 实现的。

虽然,three.js的内置材质本身是很好用的,但是也跟我提到的一样,如果失去了自身的个性,那就跟死去了没什么区别。这个框架的内置材质本身也是有局限性的,我们如果要实现自己想要的自定义效果,内置材质是很少能满足的,我们要怎么做?诶,其实有一种材质,可以完完全全地自定义Shader,并且我们可以趁机用上之前学习的Shader知识。

自定义 Shader

PS.从这里开始,会多出来很多的计算机图形学的名词,但是对于我们来说不会太重要,如果实在是好奇,我会贴上解释的文章,有兴趣可以自行查看

ShaderMaterial能够让你自定义顶点着色器和片元着色器。

我们把刚刚的球体材质换成ShaderMaterial,并且替换成这段代码:

<!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 material = new THREE.ShaderMaterial({
            vertexShader: /* glsl */ `
              void main(){
                  vec4 modelPosition=modelMatrix*vec4(position,1.);
                  vec4 viewPosition=viewMatrix*modelPosition;
                  vec4 projectedPosition=projectionMatrix*viewPosition;
                  gl_Position=projectedPosition;
              }
            `,
            fragmentShader: /* glsl */ `
              void main() {
                vec3 color = vec3(1., 0., 0.);
                gl_FragColor = vec4(color, 1.0);
              }
            `,
          });

          const mesh = new THREE.Mesh(geometry, material);
          this.scene.add(mesh);
        }
      }
      const sketch = new Sketch("#sketch");
      sketch.create();
    </script>
  </body>
</html>

他的效果是这样的:

来解释一下ShaderMaterial材质的参数都是什么:

  • fragmentShader片元着色器,里面的代码就是我们之前做 2D 相关的时候,写的Shader代码。要注意差别:mainImage变成了main,并且没了 in 和 out 的参数,fragColor变成了gl_FragColor
  • vertexShader顶点着色器,是个没见过的内容,但是这个是 3D 方面的主角。看起来代码很长看不懂?没关系,其实意思是很简单的,也就是一个叫做position的变量,一个内置的顶点属性,表示每个顶点的三维坐标,经过了 3 个矩阵的变换,被赋值给了gl_Position,这 3 个矩阵就称作MVP矩阵,分别是

    • Model,模型变换:将物体的本地坐标系转换到世界坐标系,其中这包含了平移,旋转,缩放。模型矩阵(Model Matrix)就描述了这种变换。
    • View,视图变换:将世界坐标系转换到摄像机坐标系。视图矩阵(View Matrix)描述了摄像机的位置与方向。
    • Projection:投影变换:将摄像机坐标系转换到裁剪坐标系,并且应用投影。投影矩阵(Projection Matrix)处理了这一步。

关于世界坐标系等坐标系的定义和关系可以参考我贴的文档链接,这里不再赘述了。

另外,这三个矩阵可以把其中的模型和视图矩阵合并为一个矩阵moelViewMatrix,这样我们就可以把顶点着色器的代码优化成:

void main(){
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
}

MVP矩阵变换这个操作基本上都是固定的,我们真正需要在意的是那个变量position,我们可以对这个变量进行各种各样的操作,将它作为一个单独的变量p提出来,让我们开始吧:

void main(){
    vec3 p=position;
    gl_Position=projectionMatrix*modelViewMatrix*vec4(p,1.);
}

varying

在 2D 的环节,我们用到的最多的就是uv,现在来到了 3D,它在哪里呢?

它就在顶点着色器里,变量名就叫做uv。然而,它是一个attribute变量,并不是uniform,这就意味着,它不是全局的变量,不存在与片元着色器之内。那我咋整?我能不能把这个着色器传递给另一个着色器?

在文章GLSL 语言中,我们有提到过变量限定符这个概念,有一种叫做varying的限定符可以在两个着色器之间传递变量。

我现在顶点着色器顶部声明一个varying变量vertexUv,注意不要写在main

varying vec2 vertexUv;

然后在顶点着色器的main函数内部的末尾,把变量uv赋值给vertexUv

void main() {
    ...
    vertexUv = uv;
}

在片元着色器的顶部也声明一下vertexUv

varying vec2 vertexUv;

在片元着色器的main函数内部,将vertexUv赋值给uv变量,直接输出颜色。

void main() {
    vec2 uv = vertexUv;
    gl_FragColor = vec4(uv, 0., 1.);
}

为了验证一下我们是不是真的得到了 UV 坐标,我们先把几何体改成平面验证一下PlaneGeometry

总体代码是这样的:

    <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 */ `
              varying vec2 vertexUv;
              void main(){
                vertexUv = uv;
                vec3 p = position;
                  gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
              }
            `,
            fragmentShader: /* glsl */ `
              varying vec2 vertexUv;
              void main() {
                vec2 uv = vertexUv;
                gl_FragColor = vec4(uv, 0., 1.);

              }
            `,
          });

          const mesh = new THREE.Mesh(geometry, material);
          this.scene.add(mesh);
        }
      }
      const sketch = new Sketch("#sketch");
      sketch.create();
    </script>


看起来我们的确获得了uv坐标,如果是球形的话,它就可以看作是被uv包裹起来的球体:

uniform

那,之前 2D 阶段的uniform变量呢?比如说iTime等等?

答案是,一起没有了,因为之前的环境是Shadertoy,现在在three.js里,默认是没有Shadertoyuniform变量的,不过,我找到的三方库kokomijs提供了一个类:UniformInjector,可以直接注入那些变量到ShaderMaterialuniform中。

const uj = new kokomi.UniformInjector(this);
material.uniforms = {
  ...material.uniforms,
  ...uj.shadertoyUniforms,
};
this.update(() => {
  uj.injectShadertoyUniforms(material.uniforms);
});

大概流程就是:实例化一个UniformInjector对象,将uj.shadertoyUniforms并入material.uniforms,随后在动画循环this.update调用它的injectShadertoyUniforms方法,就可以完成注入了。之后,我们还需要在两个着色器上面加上uniform变量的显示声明。

全代码:

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;
              varying vec2 vertexUv;
              void main(){
                vertexUv = uv;
                vec3 p = position;
                  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 = vertexUv;
                gl_FragColor = vec4(uv, 0., 1.);

              }
            `,
    });

    const mesh = new THREE.Mesh(geometry, material);
    this.scene.add(mesh);

    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();

顶点扭曲

对于顶点,我们先来一个简单的操作,那就是扭曲。改变顶点的 position 来改变整个图形的形状

在顶点着色器中,创建一个扭曲函数distort

  vec3 distort(vec3 p){
    p.x+=sin(p.y*100.+iTime)*.25;
    return p;
  }

很简单,我们就是稍微扭曲了一下位置 p,利用 sin 函数来达到一个周期的效果,当然,p 有 xyz 三个轴,你可以随便尝试。

接着,我们在主函数 main 中调用:

 void main(){
    vertexUv = uv;
    vec3 p = position;
    p = distort(p);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
  }

现在他是这样的:

还记得我们之前提到的随机与噪声吧?那里有一个超长的cnoise函数,对吗?我们拿过来玩一玩,对了,随便一提,要让噪声在每个方向都生效的话,我们还需要乘上normal,表示的是法向量,乘上它之后可以让噪声在球体的每一个方向都发挥它的效果!

float noise=cnoise(p+iTime);
p+=noise*normal*.3;

我们拿到了一个被噪声扭曲的球体!看!

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