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

文章更新(可能 ❌ / 一定 ✅)没那么频繁,欢迎加入「可视化交流群」进行交流。加古柳微信「xiaoaizhj」备注「可视化加群」即可,也有机会围观古柳朋友圈,实时追踪最新动态!另外,大家可以点下"赞"和"在看",这样每次新文章推送,就会第一时间出现在你的订阅号列表里。

本系列教程的代码将开源到该仓库,前几篇文章的代码也会陆续补上,欢迎大家 Star:github.com/DesertsX/th...

本文的代码也同时放到了 Codepen,欢迎学习: codepen.io/GuLiu/pen/y...

此外,之前和之后的所有文章的例子都将更新到这里,方便大家和古柳一起见证本系列内容的不断壮大与完善过程:www.canva.com/design/DAF3...

放一条最近新加上的群友的赞扬!好听爱听,希望大家多多分享真实阅读感受,古柳也能更有动力更新。

正文

上一篇文章古柳带大家开启第二阶段顶点着色器的学习,通过应用 sin、random、noise 等函数使顶点产生不同的偏移效果,从而改变几何体形状。

有两点之前没来得及提,因此在本文一开始先讲下。

动起来

其一,上回我们将 position 乘以不同大小的数值,使传给 noise 的坐标"相邻"程度不同,会发现几何体从较接近球体变化到较接近 random 的效果。

C# 复制代码
void main() {
  vec3 newPos = position;
  // newPos += normal * cnoise(position * 0.3);
  newPos += normal * cnoise(position * 5.0);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

当我们固定下数值(比如这里用 5.0)后,可通过加上 uTime 使当前效果动起来;还可以使用接收 vec4 格式参数的 noise 函数,将 uTime 作为 position 之后的第四个分量同样可行,即 cnoise(vec4(position * 5.0, uTime)),这里古柳就不另外拷贝对应的 cnoise 函数进行演示了,大家可自行尝试。

C# 复制代码
void main() {
  vec3 newPos = position;
  newPos += normal * cnoise(position * 5.0 + uTime);
  // newPos += normal * cnoise(vec4(position * 5.0, uTime)); // 需拷贝对应接收 vec4 格式参数的 cnoise 函数
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

GUI 控制参数

其二,上回古柳偷懒没用 GUI 而是借助 sin 函数来改变参数以使几何体形状变化,这里讲下如何通过 GUI(图形用户界面 Graphical user interface) 来改变和控制参数再借助 uniform 传到 shader 里进行使用。

我们常会碰到需要调整某些参数以实现最佳效果的情况,如果每次手动改数值再看效果会非常麻烦,此时就可以借助 GUI 来提高效率。以前用的比较多的是 dat.GUI,现在可以用 lil-guitweakpane 等,用法大同小异。

  • 链接:https://lil-gui.georgealways.com/#Guide#Installation

如果是 vite 创建的项目,可以通过 npm install lil-gui 进行引入;不过在本系列第一篇里用了如下的方式引入,那这里也延续这一方式。对 vite、npm、GUI 不熟悉的朋友可以看看这两个链接,尤其 GUI 部分也很简单大家看下第二个视频就能快速入门,这里就不过多讲解了。

首先需要在 uniforms 里添加所需的变量 uFrequencyuStrength 并设置初始值,接着通过 gui 对这些变量的 value 值进行控制,并设置不同的范围。

html 复制代码
<script type="module">
  import * as THREE from 'https://unpkg.com/three@0.152.2/build/three.module.js';
  import GUI from 'https://cdn.jsdelivr.net/npm/lil-gui@0.19/+esm';
  
  // console.log(GUI);
  const gui = new GUI();

  const material = new THREE.ShaderMaterial({
    uniforms: {
      uTime: { value: 0 },
      uStrength: { value: 0.5 },
      uFrequency: { value: 5.0 },
    },
    vertexShader,
    fragmentShader,
  });

  gui
    .add(material.uniforms.uStrength, "value", 0, 1, 0.01)
    .name("uStrength");

  gui
    .add(material.uniforms.uFrequency, "value", 0, 20, 0.01)
    .name("uFrequency");
</script>

出现控制面板即表示 gui 部分成功,不过为了在拖动滑块时几何体也随之变化,还需要在 shader 里声明和使用 uniforms 变量,这和之前传入 uTime 时的操作一样。

这里可以通过 uFrequency 来乘以 position 控制坐标相邻程度,通过 uStrength 控制 noise 函数产生的0-1数值的幅度大小。

C# 复制代码
uniform float uStrength;
uniform float uFrequency;

void main() {
  vec3 newPos = position;
  // newPos += normal * cnoise(position * (sin(uTime) + 1.0) * 4.0);
  // newPos += normal * cnoise(position * 5.0 + uTime);
  // newPos += normal * cnoise(position * uFrequency + uTime);
  newPos += uStrength * normal * cnoise(position * uFrequency + uTime);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

然后分别拖动控制面板上的 uFrequency 和 uStrength 滑块几何体形状就能相应变化了。(注意:受限于古柳本系列所用的导出配图、GIF 动图的工具限制,拖动部分就没一起录制了,大家简单脑补或跟着代码自己尝试就可以了)

提前剧透新系列将推出

截止目前,我们学习了如何在球体每个顶点上计算 noise 数值,并根据该数值将顶点沿各自法线偏移,当然顶点移动后每个位置的法线其实也要进行更新,但法线更新的实现对新手来说可能还是略显复杂,而且在古柳的设想里也没必要太早涉及,毕竟根据二八原则还有蛮多更普遍、更常用的内容可以先讲。

不过最近古柳计划另外再开辟一个小小付费的"进阶"系列(可能是公众号付费合集的形式,还没来得及研究,大家可以理解成掘金小册也好、一本书也好,总之可以先关注起公众号牛衣古柳,也可以加古柳微信 xiaoaizhj 进交流群及跟踪最新动态)以跳脱出本入门系列按部就班输出的限制,一方面在那个系列古柳可以更随心所欲去分享,可以先讲法线更新、讲入坑之作 Pepyaka 的完整实现、分享其他 Yuri 油管视频的笔记、讲解前阵子在群里和朋友圈分享过的 Daily CSS Design 上一个简单却酷炫的效果等......一方面对于本身就有 shader 基础、学有余力的朋友也能提前看到更精彩的内容。当然这里只是提前简单剧透下,后续推出时会做更多说明,敬请期待。

应用颜色

言归正传,且不论顶点的法线是否需要更新,把法线直接作为颜色其实只是教程讲解所需而取巧为之,实际中应该以何种方式更好地应用颜色呢?方法有很多,这里既然讲到 noise 噪声函数,不妨看看如何使用 noise 数值来实现出类似下图中的颜色效果,直接讲也很简单不难理解,但古柳觉得可以先放一放,不如从 noise 数值从头讲起。

将 noise 数值转换成黑白颜色

我们将 cnoise 函数计算的数值单独拿出来,并通过 varying 将 vNoise 传到片元着色器里作为颜色看看,这里暂时偏移顶点所以乘了0.0,球体形态下更方便查看效果。uFrequency 数值如果你不想用 gui 也可以用具体数值如5.0代替。

C# 复制代码
varying float vNoise;

void main() {
  vec3 newPos = position;
  float noise = cnoise(position * uFrequency + uTime);
  newPos += normal * noise * 0.0;
  vNoise = noise;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

在片元着色器里将数值构造成 vec3 灰度值颜色后就可以在视觉上查看效果和数值分布情况。之前古柳就提过 shader 里无法打印数据和 debug,类似这样将数值转换成颜色进行查看就是不错的检查方式。

C# 复制代码
varying float vNoise;

void main() {
  vec3 color = vec3(vNoise);
  gl_FragColor = vec4(color, 1.0);
}

此时球体上出现这种黑白斑驳的效果,古柳猜想所有知道 noise 噪声函数的人应该对该效果都不陌生吧,不熟悉的话谷歌搜索"noise 噪声函数"也能看到很多类似的图片。以前一直不知道如何描述这种效果,当下突然想到一个词"鬼影幢幢",觉得莫名贴切。(这里还对球体 mesh 进行了旋转)

如果将 noise 数值对0.5取 step,使得小于0.5的值变成0.0,大于0.5的值变成1.0,此时颜色只剩黑白两色不再有灰色,不过黑色区域明显占比较大,如果 cnoise 产生的值为0.0-1.0那么黑白区域面积应该接近,可见上篇文章时古柳漏讲了一点:有些 noise 函数会返回0-1,有些会返回-1-1,具体哪些会返回什么古柳也一直没特别搞清楚,印象里返回0-1的比较多,所以之前想当然的没去验证就以为这里用的 cnoise 返回的也是0-1,但问题不大,我们还是可以像这样将数值转换成颜色快速进行确认。

C# 复制代码
varying float vNoise;

void main() {
  vec3 newPos = position;
  float noise = cnoise(position * uFrequency + uTime);
  noise = step(0.5, noise);
  newPos += normal * noise * 0.0;
  vNoise = noise;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

此时对0.0取 step 黑白颜色占比就接近了。多像奶牛上面的图纹。

C# 复制代码
// noise = step(0.5, noise);
noise = step(0.0, noise);

还不相信的话,用-1.0-0.0之间的负数试试,会发现仍有黑色存在,可见 cnoise 函数的返回值确实不是0.0-1.0,而且用越接近-1.0的数值黑色区域越接近消失。反之亦然,用0.0-1.0的数值去测试白色区域也会越来越少。

C# 复制代码
// noise = step(0.0, noise);
noise = step(-0.5, noise);

用 noise 数值 mix 两种颜色

当我们根据 noise 函数得到数值,并对0.0取 step 将数值变成0.0或1.0后,有了这样的数值大家是否觉得似曾相识,还记得第一阶段片元着色器出现过的好多0.0-1.0数值以及用0.0-1.0数值 mix 两种颜色的操作吗?因此可以通过 mix 使 vNoise 为0.0时返回 color1、1.0时返回 color2,从而实现出彩色 noise 小球效果。

C# 复制代码
// noise = step(0.0, noise);

varying float vNoise;

void main() {
  vec3 color1 = vec3(1.0);
  vec3 color2 = vec3(0.0, 1.0, 1.0);
  vec3 color = mix(color1, color2, vNoise);
  gl_FragColor = vec4(color, 1.0);
}

Pantone 2024年度代表色

我们不妨继续用更好看的颜色使 noise 小球更漂亮,这里古柳想到了 Pantone 2024年度代表色「柔和桃」

搜索后在该页面找到了一组调色板。鼠标移动上去会显示对应颜色,直接选两个颜色看看效果,因为 shader 里要将 rgb 颜色转化成0.0-1.0范围,所以每个分量都要除以255.0,其中.0可以将0省略简写成.。

C# 复制代码
void main() {
  vec3 color1 = vec3(96./255., 200./255., 179./255.);
  vec3 color2 = vec3(27./255., 80./255., 145./255.);
  vec3 color = mix(color1, color2, vNoise);
  gl_FragColor = vec4(color, 1.0);
}

上面颜色的写法还是太繁琐,干脆写个简单的函数,接受 r/g/b 后凑成 vec3 再除以 255. 然后返回。这样替换颜色时也更方便。

C# 复制代码
vec3 rgb(float r, float g, float b) {
  return vec3(r, g, b) / 255.; 
}

void main() {
  vec3 color1 = rgb(96., 200., 179.);
  vec3 color2 = rgb(27., 80., 145.);
  vec3 color = mix(color1, color2, vNoise);
  gl_FragColor = vec4(color, 1.0);
}

为了方便大家测试,古柳把这组配色都提供给大家,省得每个人还得去原网站里拷贝再替换,麻烦。

C# 复制代码
rgb(206., 51., 117.)
rgb(232., 129., 166.)
rgb(255., 190., 152.)
rgb(255., 167., 79.)
rgb(96., 200., 179.)
rgb(39., 157., 159.)
rgb(27., 80., 145.)
rgb(110., 161., 212.)

下面是一些其他的颜色搭配。总之呢,noise 小球真的是挺简单但蛮好看的效果。

我们还能应用更多颜色,下图左上角的就是用了上述8种颜色后的效果;其他三张黑色背景的是古柳以前实现的一些彩色 noise 小球效果,如果有人感兴趣的话可以评论或群里说,古柳可以优先放进阶系列里讲讲(随心所欲的好处),入门系列里还有好多其他内容要讲,这个排期还不一定在什么时候(边写边看吧)。这里正巧讲到就提一嘴,给大家看看效果。

通过 HSL 能更好地控制颜色

言归正传,上述内容是古柳觉得讲到 noise 时很有必要涉及的,所以不知不觉讲了挺多,但其实本文的目的是教大家下图中的颜色如何实现,而这并非用的 mix 插值颜色,到底用的啥?不急,马上揭秘。

我们知道颜色有很多种格式可以表示,同样的红色可以分别用关键词 red、16进制 #FF0000、RGB格式 rgb(255, 0, 0)、HSL格式 hsl(0deg, 100%, 50%) 等代替,这个大家学过 CSS 可能都早就知道了。

其中 HSL 格式最为直观好懂、最好控制,HSL 即色相 Hue、饱和度 Saturation、亮度 Lightness 的简称。色相是色彩的基本属性,一般我们说的红色、绿色、紫色指的就是色相,即不同颜色,范围为0-360deg,可以用圆环表示所有的色相;饱和度是指色彩的纯度,饱和度越高色彩越纯越浓,饱和度越低则色彩变灰变淡;亮度是指色彩的明暗程度,越高色彩越白,越低色彩越黑。拿 red 来说,H=0deg,S=100%,L=50%,后两者是一般颜色"正常"时的数值,S越小越灰、L越大越白、L越小越黑。

这些是一般的颜色常识,而现在我们想将其用到 shader 里。当我们有 noise 数值后,最简单的方法就是将数值变成不同色相值,这样颜色就能不同。不过在 shader 里颜色最终还得用 rgb 格式表示,所以我们需要将 hsl 颜色转换成 rgb 颜色,谷歌搜索 glsl hslglsl hsl2rgb 就能找到现成的函数。

C# 复制代码
float hue2rgb(float f1, float f2, float hue) {
    if (hue < 0.0)
        hue += 1.0;
    else if (hue > 1.0)
        hue -= 1.0;
    float res;
    if ((6.0 * hue) < 1.0)
        res = f1 + (f2 - f1) * 6.0 * hue;
    else if ((2.0 * hue) < 1.0)
        res = f2;
    else if ((3.0 * hue) < 2.0)
        res = f1 + (f2 - f1) * ((2.0 / 3.0) - hue) * 6.0;
    else
        res = f1;
    return res;
}

vec3 hsl2rgb(vec3 hsl) {
    vec3 rgb;
    
    if (hsl.y == 0.0) {
        rgb = vec3(hsl.z); // Luminance
    } else {
        float f2;
        
        if (hsl.z < 0.5)
            f2 = hsl.z * (1.0 + hsl.y);
        else
            f2 = hsl.z + hsl.y - hsl.y * hsl.z;
            
        float f1 = 2.0 * hsl.z - f2;
        
        rgb.r = hue2rgb(f1, f2, hsl.x + (1.0/3.0));
        rgb.g = hue2rgb(f1, f2, hsl.x);
        rgb.b = hue2rgb(f1, f2, hsl.x - (1.0/3.0));
    }   
    return rgb;
}

vec3 hsl2rgb(float h, float s, float l) {
    return hsl2rgb(vec3(h, s, l));
}

我们想要红色就可以这样写 hsl2rgb(0.0, 1.0, 0.5),此时三个参数分别表示的就是 h/s/l,且 h 也用0.0-1.0的范围表示,0.0=0deg,1.0=360deg。s=1.0,l=0.5 就是前面说的"正常"颜色的数值。

C# 复制代码
void main() {
  vec3 color = hsl2rgb(0.0, 1.0, 0.5);
  gl_FragColor = vec4(color, 1.0);
}

将 uTime 取小数后确保在0.0-1.0范围后作为 hue 就能使颜色随时间变化。

C# 复制代码
void main() {
  // vec3 color = hsl2rgb(0.0, 1.0, 0.5);
  vec3 color = hsl2rgb(fract(uTime), 1.0, 0.5);
  gl_FragColor = vec4(color, 1.0);
}

将 noise 数值作为 hue 色相值

现在将原始的 noise 数值直接设置成 hue,因为h可以是-1.0-1.0的值,既然不会出错,那就偷个懒不转换到0.0-1.0范围,所以直接作为颜色,看效果是不是挺炸裂的!还记得我们说过 noise 数值是相邻的位置相近,如果直接这样转换成全部色彩的话,显然颜色变化太剧烈了。(当然如果你想要的艺术风格就是如此,那可以直接在此基础上创作)

C# 复制代码
// float noise = cnoise(position * uFrequency + uTime);
// vNoise = noise;

void main() {
  vec3 color = hsl2rgb(vNoise, 1.0, 0.5);
  gl_FragColor = vec4(color, 1.0);
}

我们可能将数值乘以0.1使变化幅度减小,从而使颜色仅对应一小段色相范围,这样整个球体的颜色更为和谐、不会五颜六色很难看。

C# 复制代码
void main() {
  vec3 color = hsl2rgb(vNoise * 0.1, 1.0, 0.5);
  gl_FragColor = vec4(color, 1.0);
}

通过 uTime 可以使主体颜色发生变化(记得取小数)。如果是放在网页里,我们希望颜色变化不要那么剧烈、那么快,缓慢、细微的变化更显优雅,此时可以将时间乘以较小值。这里仅是演示。

C# 复制代码
void main() {
  vec3 color = hsl2rgb(fract(uTime + vNoise * 0.1), 1.0, 0.5);
  gl_FragColor = vec4(color, 1.0);
}

我们将颜色偏移到金色多些的位置,但大家是否会觉得这效果好像还没前面双色、多色的 noise 小球好看?不急,接下来就是见证奇迹的时刻!

C# 复制代码
vec3 color = hsl2rgb(0.1 + vNoise * 0.1, 1.0, 0.5);

我们把 noise 偏移顶点的部分再加回去上,立马好看了很多。此时 noise 数值的大小不仅影响顶点偏移程度,使得球体表面高低不同,而且也决定不同高低位置的颜色差异。

C# 复制代码
void main() {
  vec3 newPos = position;
  float noise = cnoise(position * uFrequency + uTime);
  newPos += normal * noise * 1.0;
  vNoise = noise;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

而这就是本篇想讲的用 noise 数值控制 HSL 中的色相值来实现较为自然、和谐、漂亮颜色效果的方法,这一方法是古柳一开始入坑 shader 时从 Pepyaka 里早早学到的,这回终于可以分享给大家,其实讲起来很简单,三两句就可以讲完,但引申出去讲了不少其他内容,希望大家不厌其烦、觉得有所收获。

另外分享 codepen 上一个蛮漂亮的效果------像是一片片彩色花瓣组成的花球------其中每片花瓣之间有一个不同的主色进行区分并使得整体保持彩色,而每片花瓣内部会在该主色上通过 noise 数值对 hue 进行小幅度的偏移,使每片花瓣不是简单的纯色,而是更为生动自然有细微差异的颜色。感兴趣的朋友可以看下代码,后续在进阶篇或许也可以复现下、讲解下。

小结

本篇文章古柳先教大家如何通过 GUI 控制 shader 里的参数以便能高效便捷地选出效果最佳的数值,这一技巧大家可以灵活应用到之前或之后的例子、shader 里或 shader 外的场景之中,非常实用;

接着古柳带大家看看 noise 数值转换成颜色后是什么效果,通过取 step、mix 插值颜色、应用 Pantone 年度代表色等方式使大家更加熟悉简单又强大的 noise 噪声函数;

最后通过将缩小范围后的 noise 数值作用到 HSL 中的 hue 色相值上,从而实现出漂亮的色彩,结合顶点偏移后就是极简版的 Pepyaka 效果。最终篇幅所限,没带大家实现更进一步的效果,但核心关键的应用颜色的方法已经告诉大家,其他都是次要的。

最后照旧是本文的所有例子合集,大家要好好复习哈。

相关阅读

「手把手带你入门 Three.js Shader 系列」目录如下:

照例

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

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

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

相关推荐
阿伟来咯~17 分钟前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端22 分钟前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱25 分钟前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
guai_guai_guai34 分钟前
uniapp
前端·javascript·vue.js·uni-app
也无晴也无风雨35 分钟前
在JS中, 0 == [0] 吗
开发语言·javascript
王哲晓2 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
理想不理想v2 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
酷酷的阿云2 小时前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
aPurpleBerry3 小时前
JS常用数组方法 reduce filter find forEach
javascript
ZL不懂前端3 小时前
Content Security Policy (CSP)
前端·javascript·面试