从现在开始,我们的学习就要步入顶点着色器阶段,也就是我们期待已久的 3D 世界了。
我思考了很久要怎么写这个阶段的开场词,要如何无缝地衔接之前的知识点到现在,作为承上启下的作用呢?
后来,我在逛 shaderToy 的时候看到了一个这样的巧克力着色器:
灵机一动,形象地说:我们之前学习的片元着色器决定了一个巧克力的颜色与图案,那么,接下来决定一个巧克力的形状的,那就是我们要学习的顶点着色器。
欢迎来到 3D 的世界!
3D
虽然前面说,想要跨过threejs
,做符合自己个性的东西,但是我在写这篇文章的时候还是意识到了这个库在3D
方面的帮助性,在用的需求较大的如今,我们需要一个完善的工具库进行帮助学习,所以我还是找到了对其进行二次封装的一个优秀的三方库:kokomi.js
根据原作者介绍,这个框架封装了很多实用的函数与类,并且支持组件化的编写3D
,也即是,three.js
的jQuery
。
接下来我们创建一个文件夹,取名为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
里,默认是没有Shadertoy
的uniform
变量的,不过,我找到的三方库kokomijs
提供了一个类:UniformInjector
,可以直接注入那些变量到ShaderMaterial
的uniform
中。
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;
我们拿到了一个被噪声扭曲的球体!看!