手把手带你入门 Three.js Shader 系列(二)

4,357 阅读9分钟

又是时隔两个月才更新这第二篇教程,原本以为最多一个星期就能更新出来,但写着写着总觉得有些地方解释的不够清楚,于是写到一半就放在一边,终于觉得不能再拖下去才找出来花两天继续写下去。可能依旧有些地方古柳没解释清楚,加上 shader 对初学者而言略有难度、略抽象,所以如果大家对本文任何地方有疑问,可以在评论区或群里咨询。

上篇文章「手把手带你入门 Three.js Shader 系列(一) - 牛衣古柳 - 20230515」里古柳带大家看了下几何体上的顶点属性,介绍了 GLSL 基础、顶点着色器、片元着色器等内容,相信大家现在对下面最简单的 shader 代码应该没有什么疑问了吧。

const vertex = `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragment = `
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }
`;

// const material = new THREE.MeshBasicMaterial({ color: 0x0ca678 });
const material = new THREE.ShaderMaterial({
  vertexShader: vertex,
  fragmentShader: fragment
});

本文古柳将介绍如何利用顶点上的 UV 纹理坐标并结合 GLSL 里几个常用的内置函数来实现一些简单的效果,带大家初步体验 Shader 编程的神奇之处。

本文代码见 Codepen:codepen.io/GuLiu/pen/o…

三种修饰符 attribute/uniform/varying

之前提到顶点坐标 position、纹理坐标 uv、法线向量 normal 都是顶点上的数据,在 ShaderMaterial 的顶点着色器里能直接使用,因为里面自动声明了;如果使用的是 RawShaderMaterial 就需要手动声明才能使用。

attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;

vec2 vec3 分别是二维、三维向量,特别需要注意的是 GLSL 语言里还需要加上 attribute 修饰符表明这个数据是每个顶点上都不同;此外还可以手动给每个顶点绑定所需的数据,如每个顶点都不同的一个随机值 aRandom,用于对顶点进行不同程度的位置偏移。

attribute float aRandom;

attribute 相对应的是每个顶点或像素数值都相同时变量使用 uniform 修饰符,比如传入统一的时间 uTime

uniform float uTime;

而如果是顶点着色器里的变量想在片元着色器里使用就需要借助 varying 修饰符表示数据的传递,下面演示了如果将 uv 从顶点着色器传递到片元着色器里,需要借助 vUv 变量,在顶点着色器里声明后并赋值,然后在片元着色器里声明后就可以使用了,比如直接放到颜色里去使用,后续会讲解这里的含义。

// Vertex Shader
attribute vec2 uv;

varying vec2 vUv;

void main() {
  vUv = uv;
}

// Fragment Shader
varying vec2 vUv;

void main() {
  gl_FragColor = vec4(vUv, 1.0, 1.0);
}

注意上面不同修饰符对应的变量所采用的命令方式,attribute 的用 a 开头如 aRadomuniform 的用 u 开头如 uTimevarying 的用 v 开头如 vUv,这是古柳觉得比较直观的一种变量命名方式,后续大家看其他教程时不一定都是这种方式,有的可能不用特定修饰符的打头如 time,有的可能用下划线如 u_time 等等,喜欢用哪种大家可自行选择。

将 uv 设置成颜色的多种方式

上面我们将 uv 纹理坐标传递到片元着色器里,接下来结合 GLSL 里一些内置函数,看看利用 uv 设置颜色会有哪些效果。

在上篇文章「手把手带你入门 Three.js Shader 系列(一) - 牛衣古柳 - 20230515」里古柳讲过 uv 值的范围为(0.0, 0.0)到(1.0, 1.0),原本顶点着色器里只有4个顶点才有 uv 值,但通过 varying 传递到片元着色器的变量都会在每个片元/像素位置得到插值后的数值,所以相当于这个 plane 每个像素都有 uv 数值,用作颜色后每个像素都能设置上颜色。

不过 shader 里无法像 JavaScript 里那样打印变量 console.log(uv) 查看数值情况、也不好 debug,所以大家刚开始接触时会觉得有些难受、有些抽象,无法理解一些数值长啥样、如何分布以及是如何产生作用的......

其实把数值在片元着色器里以颜色显示能有助于理解,虽然不是次次管用,但古柳觉得还是蛮有用的一个技巧。

首先回顾下,片元着色器里设置 gl_FragColor 为红色 vec4(1.0, 0.0, 0.0, 1.0),这段代码会通过 GPU 对所有 plane 上的像素执行,于是呈现出来的就是红色的平面。

void main() {
    // 红色 rgba
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }

渐变效果

如果用 vUv 里的 x 或 y 分量分别设置到 rgba 颜色里的 red 通道并赋值给 gl_FragColor,会有左右或者上下的黑色到红色的渐变效果。

varying vec2 vUv;

void main() {
  // gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  // vUv.x => 0.0-1.0
  gl_FragColor = vec4(vUv.x, 0.0, 0.0, 1.0);
}

varying vec2 vUv;

void main() {
  // vUv.y => 0.0-1.0 
  gl_FragColor = vec4(vUv.y, 0.0, 0.0, 1.0);
}

大家如果刚接触 shader 不知道看到这一行代码有何感受?作为过来人,古柳只能尽量设想大家可能存在的困惑(如果有其他困惑可以评论或群里问),就是照理 position 是每个顶点的坐标,uv 是每个顶点的纹理坐标,按一般 JS 里的惯性思维大家会不会觉得 uv 应该是由所有顶点的纹理坐标所组成的数组,类似 [(0.0,0.0), (0.5, 0.5), (1.0, 0.0), ...] 这样的格式。

但其实并不是,shader 里的代码是对每个顶点或片元单独执行的,这里的 vUv 就只是每个片元其各自的数值(每个片元甚至连周围片元的数值是多少都不知道),比如左下角 vUv 为 (0.0, 0.0) 所以对应的 vUv.x=vUv.y=0.0,颜色为 vec4(0.0, 0.0, 0.0, 1.0) 即黑色,同理把左上角 (0.0, 1.0)、右下角 (1.0, 0.0)、右上角 (1.0, 1.0)、最中间 (0.5, 0.5) 等每个位置的数值分别带入上面的代码,就能得到上图的效果。

上面的例子可能理解起来还不复杂、不抽象,但对于刚接触 shader 的朋友来说,尽早养成将不同位置的数值带入代码然后分析可能呈现的效果,这是古柳在入门 shader 时觉得非常重要的一点学习经验。

如果以 vec3(vUv.x)vec3(vUv.y) 的形式分别设置到 rgb 上,也就是三个颜色通道数值相同时,就会是黑白灰的效果。这里 vec3(0.5,0.5,0.5)=vec3(0.5)

varying vec2 vUv;

void main() {
  // 黑白灰
  gl_FragColor = vec4(vec3(vUv.x), 1.0);
}

varying vec2 vUv;

void main() {
  // 黑白灰
  gl_FragColor = vec4(vec3(vUv.y), 1.0);
}

熟悉的青红、蓝粉效果

如果将 vUv 设置到 red 和 green 通道、blue 通道设为0.0,就是这个非常常见的 uv 青色红色颜色效果,如果大家用过其他一些3D软件,应该对这个图并不陌生。

varying vec2 vUv;

void main() {
  gl_FragColor = vec4(vUv, 0.0, 1.0);
}

如果 blue 通道设为1.0,就是这种蓝色粉色的效果,见多了也会觉得很熟悉。

varying vec2 vUv;

void main() {
  gl_FragColor = vec4(vUv, 1.0, 1.0);
}

颜色突变

除了渐变,我们可以结合 GLSL 的内置函数做出颜色突变的效果,借助 step(edge, x) 函数,其会返回0.0或1.0数值,如果 x<edge 返回0.0,如果 x>edge 返回1.0。step(0.5, vUv.x) 通过 vUv.x 和 0.5 比较,小于0.5的返回0.0,大于0.5的返回1.0,并将该 color 变成转换成 vec3() 格式,于是就是黑白突变的格式。

void main() {
  float color = step(0.5, vUv.x);
  gl_FragColor = vec4(vec3(color), 1.0);
}

当然大家可能一开始想到的是用 if(vUv.x > 0.5) 条件判断的方式来进行设置,虽然也能成功,但一般 shader 里能用内置函数实现的都会优先用内置函数,一方面性能可能更好,另一方面也更为地道。

改变 step 第一个参数的数值,黑白突变的位置也会相应移动。

void main() {
  float color = step(0.3, vUv.x);
  gl_FragColor = vec4(vec3(color), 1.0);
}

void main() {
  float color = step(0.7, vUv.x);
  gl_FragColor = vec4(vec3(color), 1.0);
}

有时我们喜欢黑白颜色的位置互换,此时设置成 step(vUv.x, 0.5) 或者 step(0.5, 1.0 - vUv.x) 都能起到相同的效果,当然这里古柳用黑白指代只是顺口的,本质还是我们希望有些位置是0.0,有些位置是1.0,当需求不同时可以通过调整参数的位置使颜色突变的顺序改变。

上面讲了这么多效果,如果大家突然犯晕了,请记得古柳前文所说的,多把每个位置的具体数值带入到代码里去理解,相信就豁然开朗了。

void main() {
  // float color = step(0.5, vUv.x);
  // 黑白突变顺序互换
  // float color = step(0.5, 1.0 - vUv.x);
  // 两种方式都行
  float color = step(vUv.x, 0.5);
  gl_FragColor = vec4(vec3(color), 1.0);
}

重复效果、条纹效果

当我们想实现重复效果时,可以通过对 vUv 乘以一定倍数放大,比如0.0-1.0放大3倍变成0.0-3.0,然后用 fract() 函数取小数使得数值在 0.0-1.0 里循环重复,比如1.1、2.1取小数后都变回0.1,再将该数值转换成 vec3 再设置到颜色上,就会产生重复的黑白渐变效果。

varying vec2 vUv;

void main() {
  gl_FragColor = vec4(vec3(fract(vUv.x * 3.0)), 1.0);
}

将上述重复的0.0-1.0数值先丢给 step() 函数再转换成 vec3,就能产生重复的黑白突变效果。这里不断套娃看着有些复杂,大家可以由里向外一步步理解每步的效果,这种重复的实现方式也是很常见很有用的,有必要熟练掌握,其实也不难。

varying vec2 vUv;

void main() {
  gl_FragColor = vec4(vec3(step(0.5, fract(vUv.x * 3.0))), 1.0);
}

替换几何体

讲着讲着也不少内容了,最初设想是把下图里所有效果都在本文讲完,但怕大家来不及消化,本篇文章就先讲到条纹效果,最后如何绘制圆形图案、如何结合 mix 来混合颜色等内容留到下一篇里继续。

此外,大家可以将平面几何体换成立方体、球体、锥体等等,再对照上图自己尝试不看代码从头敲出每种效果,看看不同几何体、不同 uv 所带来的不同效果,由此加深对本文涉及的简单 shader 代码的理解和掌握。

// const geometry = new THREE.PlaneGeometry(1, 1);
const geometry = new THREE.BoxGeometry(1, 1, 1);
// const geometry = new THREE.SphereGeometry(1, 32, 16);
// const geometry = new THREE.ConeGeometry(1, 2, 16, 1);

本文代码见 Codepen:codepen.io/GuLiu/pen/o…

如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!

照例

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。