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

上篇文章「手把手带你入门 Three.js Shader 系列(四) - 牛衣古柳 - 20231121」古柳教大家如何用 GLSL 里的内置函数 mix 在之前学过的黑白条纹圆圈等基础上插值出不同颜色,并且在实现对角线渐变时提到不同图案相结合时的常用方法。

文章最后依旧是本系列教程的一个特色,会把当篇文章讲过的所有例子配图"全家照"放上,方便大家对照图片去回忆知识点和不看代码自己实现相关效果。不知道大家都看到最后没有,掌握的如何?如果有不理解的地方,或对古柳的教程有任何意见建议,欢迎评论区或群里提。(欢迎加入「可视化交流群」进行交流,加古柳微信「xiaoaizhj」备注「可视化加群」即可)

棋盘格

之前提到古柳看到这张图里的棋盘格图案,觉得可以讲下如何实现,虽然当时还没动手尝试,但大致思路有的,所以这篇文章就先实现下这个。

上面的棋盘格不够直观,网上随便找张平铺的效果方便去分析。对于这样的网格图案,我们可以尝试按行和按列去拆分,理想情况下将拆分后的图案用之前提到的"加减乘除或取最大最小值"等结合方式套下没准就成功了。

是否真的可行,马上试试看!首先在水平和垂直方向上分别实现3组重复的黑白条纹。经过前几篇文章的学习,相信这里的代码对大家而言已经小菜一碟。

C# 复制代码
varying vec2 vUv;

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

接着对两种图案直接"无脑"套下六种常见的组合方式,下图从左到右、从上到下依次就是加、减、乘、除、取最小值、取最大值的结果......

C# 复制代码
void main() {
  vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
  vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
  vec3 color = mask1 + mask2;
  // vec3 color = mask1 - mask2;
  // vec3 color = mask1 * mask2;
  // vec3 color = mask1 / mask2;
  // vec3 color = min(mask1, mask2);
  // vec3 color = max(mask1, mask2);
  gl_FragColor = vec4(color, 1.0);
}

很遗憾,虽然有几个看着比较接近,但没有完全符合我们想要效果的。不知道大家看到这样的结果,第一反应会是什么?古柳最初觉得没准方法还是对的,或许用于组合的两种图案黑白位置偏移下就行。

C# 复制代码
vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
// vec3 mask1 = vec3(step(0.5, 1.0 - fract(vUv.x * 3.0)));
// vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
vec3 mask2 = vec3(step(0.5, 1.0 - fract(vUv.y * 3.0)));

于是对掉黑白0-1位置后再一顿"无脑"套,发现依旧是效果接近但不是想要的。看来脑子🧠下线还是不行,偏移的思路并不正确,得冷静下来分析下每个区域的数值,看看问题到底出在哪里。

解决 bug

上面的图案看着还是复杂、让人头晕,我们不妨将其简化成只剩一组黑白,毕竟其余部分可通过这组图案的重复得到。

C# 复制代码
void main() {
  vec3 mask1 = vec3(step(0.5, vUv.x));
  vec3 mask2 = vec3(step(0.5, vUv.y));
  vec3 color = mask1;
  // vec3 color = mask2;
  // vec3 color = mask1 + mask2;
  gl_FragColor = vec4(color, 1.0);
}

然后我们最最最后一次看看组合后的效果。

也就是说现在我们希望基于简化后的图案组合出下图任意一个效果。

我们将图案分成四块区域,以0或1数值的方式重新审视基础图案和组合后的图案。这里古柳就不画图了,大家直接看下面的分析应该就能懂。先看相加的结果,如果有办法把2的位置变成0那么相加就能生效,但似乎没有特别直观的方法可以对每个位置都操作而只把2变成0且其他位置保持不变,暂时想不到那就先跳过;

diff 复制代码
基础图案1 / mask1:
0  1
0  1
基础图案2 / mask2:
1  1
0  0

相加后 / mask1 + mask2:
1  2
0  1
相减后 / mask1 - mask2:
-1  0
0  1

接着看相减,原来左上角的黑色是-1,那么只需通过 abs() 函数取绝对值变成1即白色就行,同时其他位置取绝对值后保持原样,原来离我们想要的效果就差在这一点上!

于是我们回到最初的3组黑白图案,在相减后取绝对值就如愿实现出棋盘格效果。

C# 复制代码
void main() {
  vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
  vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
  vec3 color = abs(mask1 - mask2);
  gl_FragColor = vec4(color, 1.0);
}

彩色格子

接着我们将上面的0/1数值作为 mixer 去插值颜色,彩色棋盘格就轻松搞定。下图用了前篇文章的两种颜色和黑色白色,并且改变了几何体和重复次数。需要注意的是:球体上的条纹也好、棋盘格也好,因为 uv 的缘故一直看着比较怪,因为这个问题不是当前的重点,所以留待以后有机会再去解决。

C# 复制代码
void main() {
  vec3 color1 = vec3(1.0, 1.0, 0.0);
  // vec3 color1 = vec3(0.0, 0.0, 0.0);
  vec3 color2 = vec3(0.0, 1.0, 1.0);
  vec3 mask1 = vec3(step(0.5, fract(vUv.x * 3.0)));
  vec3 mask2 = vec3(step(0.5, fract(vUv.y * 3.0)));
  // vec3 color = abs(mask1 - mask2);
  vec3 mixer = abs(mask1 - mask2);
  vec3 color = mix(color1, color2, mixer);
  gl_FragColor = vec4(color, 1.0);
}

棋盘格的实现可能还有其他方法,古柳自己随便搜索和询问GPT发现给出的代码也与上述不同。其他方法就留给大家自行了解了,当然欢迎评论区或群里提供你的思路。最后放个图,不知道大家是否想起某位"故人"某个角色呢?

通过图形去理解和培养直觉

上面花了好多篇幅讲解古柳在实现棋盘格过程中使用的方法、遇到的问题和解决思路,之所以这样写,也是古柳觉得这比直接告诉大家几行代码然后效果就出来了有意义地多。

而且 shader 里无法像 JavaScript 那样 console.log() 打印数据或 debug 调试,所以大家碰到问题可能会不知道如何解决,此时将数值转换成颜色,多通过图形实际的效果去理解和培养直觉或许是个不错的学习方式。

abs+圆环

这一点刚好和古柳自己前些天重新看 Three.js Journey、看到里面 shader 章节讲圆环实现用到 abs 时同样受到启发,并且正好也用到 abs。虽然取绝对值大家再熟悉不过,但我们还是看看实际 shader 里会在哪用到 abs。

下图我们计算了 vUv 离(0.5, 0.5)位置的距离并以此作为颜色,左图表示了数值从中心0.0往四周增大的效果。

C# 复制代码
void main() {
  float strength = distance(vUv, vec2(0.5));
  // float strength = step(0.2, distance(vUv, vec2(0.5)));
  vec3 color = vec3(strength);
  gl_FragColor = vec4(color, 1.0);
}

假如我们把数值减去0.2,此时数值就从-0.2向四周增大到正数。此时中间区域都是全黑的,图中看着像有灰色的,可能是错觉,验证过用负数vec3(-0.1)作为颜色确实就是全黑的。

C# 复制代码
float strength = distance(vUv, vec2(0.5)) - 0.2;

此时取绝对值就变成从离中心0.2的位置数值为0.0而向外和向内都是增大。中心区域因为数值大于0.0出现灰色。

C# 复制代码
float strength = abs(distance(vUv, vec2(0.5)) - 0.2);

然后对一个较小的数取 step 就能画出圆环效果,圆环上是数值小于0.01的变成0.0,外部和内部是大于0.01的变成1.0。

C# 复制代码
float strength = step(0.01, abs(distance(vUv, vec2(0.5)) - 0.2));
// float strength = 1.0 - step(0.01, abs(distance(vUv, vec2(0.5)) - 0.2));

一些启发

大家也能发现在 shader 里绘制一些效果,和一般诸如 svg、canvas 2D 等大家更熟悉更常规的方式非常不同。当看到一些新效果如这里的圆环,一开始大家可能觉得前几章没学过这个,对于怎么绘制完全没头绪,但如果建立起从图形去看到背后数值分布,再到如何从我们之前学过的效果进行变化的思维,或许思路就打开了。

比如上述过程,从中心向四周数值增大的操作我们已经会了、那么先减去一个数值变化出负数范围、再取绝对值变成从某个位置向内向外数值增大,最后 step 一下圆环就成功了;反过来,看到圆环黑色的为0,向外往内都是白色为1,数值往两个方向增大,就会想起先减去数值变化出负数再取绝对值的操作......

自2022年4月古柳知道 shader 至今已1年半多,但如上所说,也是前些天看到圆环的实现时重新有了这样的启发。(欢迎加入「可视化交流群」进行交流,加古柳微信「xiaoaizhj」备注「可视化加群」即可,也有机会围观古柳朋友圈,实时追踪最新动态!)

想起自学以来很长一段时间,看过很多 shader 教程后,常常感觉离了代码后学过的知识完全用不起来,好像每个教程之间都是割裂的,也许就是因为一些 shader 特殊的思维还没建立起来。如果大家看这个系列文章时也觉得一下画圆圈、一下画条纹、一下画棋盘格比较割裂,不如用上面的方式,多站在图形实际效果和背后数值分布的思路上去想想,或者对 shader 里的实现思路能有更进一步地理解和掌握。

对角线渐变用 abs 再度实现

上面讲了这么多,听起来还是有点虚是不是,不如看点实际的。不知道大家有没有想起上篇文章里我们想实现的对角线上0到1再到0的渐变方式,重新看是不是就发现和圆环效果是类似的,我们希望中间白色即数值1,往两边变化到黑色即数值0,这不就和上面 abs 取绝对值操作一样嘛,此时根本不需要通过两组图案去组合。

C# 复制代码
void main() {
  float mixer1 = vUv.x + vUv.y;
  float mixer2 = 2.0 - (vUv.x + vUv.y);
  float mixer = min(mixer1, mixer2);
  vec3 color = vec3(mixer);
  gl_FragColor = vec4(color, 1.0);
}

vUv.x+vUv.y 从左下角到右上角范围是0.0-2.0,我们先减去1.0变化到-1.0-1.0,再取绝对值变成1.0-0.0-1.0,最后用1.0减去变成0.0-1.0-0.0就是所需的效果了。是不是有种豁然开朗的感觉。

C# 复制代码
void main() {
  // float mixer = vUv.x + vUv.y;
  // float mixer = vUv.x + vUv.y - 1.0;
  // float mixer = abs((vUv.x + vUv.y - 1.0));
  float mixer = 1.0 - abs((vUv.x + vUv.y - 1.0));
  vec3 color = vec3(mixer);
  gl_FragColor = vec4(color, 1.0);
}

额外例子

如果大家还好奇 abs 在哪用到,这里再放个古柳前不久看到的视频教程,虽然里面涉及的顶点着色器我们在下一篇才讲到,但相信有些人本身就有基础那么提前去拓展学习下也是不错的。当然后续文章可能也会讲解这个例子到底如何实现,还看不懂的朋友不必着急,一步步学下去就行。

抗锯齿 aastep

最后还有个问题一直留着没带大家解决,就是类似下面颜色突变图形的边缘存在明显锯齿现象。

C# 复制代码
varying vec2 vUv;

void main() {
  float strength = step(0.25, distance(vUv, vec2(0.5)));
  // float strength = step(0.01, abs(distance(vUv, vec2(0.5)) - 0.2));
  vec3 color = vec3(strength);
  gl_FragColor = vec4(color, 1.0);
}

在 Three.js 里我们一般通过设置 renderer 里的 antialias 为 true 和设置 pixel ratio 来开启抗锯齿。

js 复制代码
const renderer = new THREE.WebGLRenderer({
  antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

在 shader 里可以通过非内置的 aastep 函数(anti-alias smoothstep utility function)来替换 step 函数,两者接收的参数一致所以很好理解。谷歌搜索 glsl aastep 会找到函数的实现,将其拷贝到我们片元着色器 main 函数前面即可。

C# 复制代码
float aastep(float threshold, float value) {
  #ifdef GL_OES_standard_derivatives
    float afwidth = length(vec2(dFdx(value), dFdy(value))) * 0.70710678118654757;
    return smoothstep(threshold-afwidth, threshold+afwidth, value);
  #else
    return step(threshold, value);
  #endif  
}

void main() {
  // float strength = aastep(0.25, distance(vUv, vec2(0.5)));
  float strength = aastep(0.01, abs(distance(vUv, vec2(0.5)) - 0.2));
  vec3 color = vec3(strength);
  gl_FragColor = vec4(color, 1.0);
}

不过此时需要在 ShaderMaterial 里设置 extensions: { derivatives: true } 才能生效,否则会报错。

js 复制代码
const material = new THREE.ShaderMaterial({
    uniforms: { uTime: { value: 0 } },
    vertexShader: vertex,
    fragmentShader: fragment,
    extensions: {
      derivatives: true,
    },
  });

可以看到效果很好,边缘很平滑!

smoothstep

当然我们看 aastep 函数的实现里面用到了 smoothstep() 内置函数。smoothstep(edge1, edge2, x) 接收3个参数,当 x<=edge1 时返回0.0;当 x>=edge2 时返回1.0;当 edge1<x<edge2 时返回平滑插值的数值。

一个简单的例子看看运用 smoothstep 后 vUv.x 在0.2-0.8之间平滑过渡的效果。

C# 复制代码
varying vec2 vUv;

void main() {
  float strength = smoothstep(0.2, 0.8, vUv.x);
  vec3 color = vec3(strength);
  gl_FragColor = vec4(color, 1.0);
}

原本 smoothstep 和 mix、distance、length、clamp、sin、step 等内置函数一样都是 GLSL 里很常用、随处可见的,但因为古柳一直也还没想到比较好的例子来讲解 smoothstep,所以有意在前面的文章里都没去提起,这里既然出现了,那就先暂时提一嘴,等后续有好的例子再多讲讲。

小结

原本古柳想着从下篇文章开始先讲讲顶点着色器,毕竟讲了好几篇片元着色器可以"换换口味"了,因此一开始合计着本文作为第一阶段片元着色器的最后一篇,可以讲下棋盘格的实现以及下面这种一块块区域里产生一个相同数值的离散化操作,如果内容还不够的话再讲下如何在 shader 里显示纹理贴图。

顺带讲下离散化后的数值可以用于实现这种栅格动画效果,当然具体实现还不会先讲,但吊吊大家胃口,等顶点着色器讲的差不多再看看回过头讲这个例子。

但如大家所见,这些内容暂时来不急讲了(不过胃口还是得吊上,哈哈),本文早已破4千字也该结束了。

其实在最近两篇文章写作中,有太多内容是古柳自己一开始没预料到会那么去写的,想实现的效果、碰上的问题、解决的方法、一些经验的总结与启发,好多是在边实现边写的过程中自然而然地展开的,而这样的过程也让古柳自己受益匪浅,一些此前没有的思路都在不断浮现出来。当然因此导致一些原本想讲的内容来不及讲,不过后续总有机会再讲所以问题倒也不大。

如果本系列只是简单介绍下 GLSL 内置函数如何使用,那么将与其他教程无多大差别,而截至目前所呈现出来的样貌已经令古柳自己也有些满意的,不知道大家经过这五篇文章的学习,对本系列教程的评价又是如何,是否也有所收获呢,欢迎评论区和群里回复;如觉得之前或之后的教程中有没讲清楚的内容或有任何意见建议都欢迎提。

那么下一篇文章开始我们将进入第二阶段的学习,正式上手顶点着色器,这也意味着距离讲解这个让古柳见识到 shader 有多酷的入坑之作 Pepyaka 的实现也快了(再次吊个胃口)。敬请期待!

当然在开启新的征程前,也别忘了回顾本篇教程的所有例子。

照例

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

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

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

相关推荐
腾讯TNTWeb前端团队44 分钟前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰4 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪4 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪4 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy5 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom6 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom6 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom6 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom6 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom6 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试