手把手带你入门 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,备注「可视化加群」即可。

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

相关推荐
噢,我明白了2 小时前
同源策略:为什么XMLHttpRequest不能跨域请求资源?
javascript·跨域
sanguine__2 小时前
APIs-day2
javascript·css·css3
关你西红柿子3 小时前
小程序app封装公用顶部筛选区uv-drop-down
前端·javascript·vue.js·小程序·uv
济南小草根3 小时前
把一个Vue项目的页面打包后再另一个项目中使用
前端·javascript·vue.js
小木_.3 小时前
【python 逆向分析某有道翻译】分析有道翻译公开的密文内容,webpack类型,全程扣代码,最后实现接口调用翻译,仅供学习参考
javascript·python·学习·webpack·分享·逆向分析
Aphasia3113 小时前
一次搞懂 JS 对象转换,从此告别类型错误!
javascript·面试
m0_748256564 小时前
Vue - axios的使用
前端·javascript·vue.js
m0_748256344 小时前
QWebChannel实现与JS的交互
java·javascript·交互
胡西风_foxww4 小时前
【es6复习笔记】函数参数的默认值(6)
javascript·笔记·es6·参数·函数·默认值
胡西风_foxww4 小时前
【es6复习笔记】生成器(11)
javascript·笔记·es6·实例·生成器·函数·gen