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

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

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

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

系列文章

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

正文

通过第一阶段片元着色器的学习相信大家对 shader 语言有入门的感觉了吧。前五篇文章的例子配图多达120+,古柳希望用尽可能多的图帮大家克服 shader 学习时存在的抽象难理解等障碍。

看到新加上的群友边学习边一个个例子地敲还是很欣慰的,不妄古柳每篇文章几千字费心地写、用心地搭配示例图。大家觉得本系列文章写得不错的话,希望能多多点赞评论支持,既是对古柳持续输出优质原创教程的鼓励,也方便推送给更多感兴趣的人。

闲言少叙,书归正传,让古柳带大家开启第二阶段的学习,正式上手顶点着色器,补全之前缺失的另一半,看看能做出多么酷炫的效果。

显示球体

前几篇文章主要用 PlaneGeometry 举例子,这篇文章我们换成 SphereGeometry。单色的球体看着像圆圈,开启线框模式设置 wireframe:true 后稍显立体,同时能看到横纵交织的线段及线段相交处的顶点。

js 复制代码
const vertexShader = /* GLSL */ `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = /* GLSL */ `
  void main() {
    gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
  }
`;
  
const geometry = new THREE.SphereGeometry(1, 32, 16);
const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
  },
  vertexShader,
  fragmentShader,
  wireframe: true,
});

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

// let time = 0;
const clock = new THREE.Clock();
function render() {
  // time += 0.05;
  // material.uniforms.uTime.value += time;
  const time = clock.getElapsedTime();
  material.uniforms.uTime.value = time;
  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

render();

借助顶点着色器我们就能对几何体上每个顶点的位置进行改变从而实现特殊的效果。

但在此之前,我们需要知道将三维空间里的物体显示到二维屏幕上需要通过 MVP 矩阵变换操作,即 Model 模型矩阵、View 视图矩阵和 Projection 投影矩阵(前俩者可以合并为 modelViewMatrix),因此在顶点着色器里这一行代码是必不可少的。

C# 复制代码
void main() {
  gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
  // gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

背后原理非一两句可以解释清楚,感兴趣的朋友可以看看这几篇文章去了解。

不过大家不用担心,古柳对图形学同样知之甚少,我们只需依样画葫芦地敲出这行代码,同时知道这里的 position 就是基于 Geometry 生成的3D物体其创建之初每个顶点的坐标,这些顶点以几何体自身中心为三维坐标原点,因而是每个模型/几何体的局部坐标空间。

例如前面我们创建出半径为1的球体,那么与x轴正轴相交的顶点其坐标就是(1,0,0),不管后续的 mesh 怎么平移旋转缩放,每个顶点的 position 值都是固定不变的,当然这些变换会以模型矩阵 modelMatrix 的形式作用到 position,然后才变到整个场景三维坐标原点的世界坐标空间......扯远了,既然不管物体在哪个地方 position 都是几何体自身的坐标,那么我们直接改变 position,就能改变几何体的形状。

球体放大

最简单的改变顶点坐标的操作可能就是放大缩小。对 position 数值乘以1.5进行放大,然后用新的顶点坐标 newPos 去进行 MVP 矩阵变换,得到的效果就是放大的球体。

C# 复制代码
void main() {
  vec3 newPos = position * 1.5;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

球体大小动态变化

之前在片元着色器部分,我们可以通过传入 uTime 并借助 uv 绘制出半径动态变化的圆圈,同样在这里我们可以用 uTime 和 sin 函数使球体大小周期性动态变化。注意这里 sin 是负数也没事,球体上每个顶点都有关于中心原点对称的另一顶点存在,比如 (1,0,0) 乘以-1.0变成 (-1,0,0),后者也在球体上,反之亦然。每个顶点统一对掉下,对这里的效果而言问题不大。

C# 复制代码
uniform float uTime;

void main() {
  vec3 newPos = position * sin(uTime);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

当然上面的效果直接通过设置 mesh 的 scale 就能实现,shader 所能实现的效果自然远不止如此,我们马上就会见识到。

js 复制代码
function render() {
  const time = clock.getElapsedTime();
  material.uniforms.uTime.value = time;
  mesh.scale.setScalar(Math.sin(time));
}

顶点 y 坐标累加 sin 值

当每个顶点的变化不再步调一致、单调统一时,shader 的威力才开始显现。让我们换个方式使用 sin 函数,用每个顶点的 y 坐标计算 sin 值,然后累加回 y 坐标,此时形状类似纺锤体。

C# 复制代码
void main() {
  vec3 newPos = position;
  newPos.y += sin(position.y);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

我们对 y 坐标乘以不同的数值(如0.1、0.3、0.5、1.0、5.0等)来改变范围后再传给 sin 函数,此时形状越来越有趣,虽然我们不太好解释 sin 函数为何使球体变形出这样的形状,非要说点的话,可以把 sin 理解成波浪,那么不同 y 坐标上下偏移了不同波浪的幅度,当范围越大时重复的波浪也越多,图中往上或往下的起伏也越多......当然无法理解也没事,这些都不重要,我们可以自由发挥去改变参数,只要效果自己满意就行。

C# 复制代码
vec3 newPos = position;
// newPos.y += sin(position.y * 0.5);
newPos.y += sin(position.y * 4.0);

如果不想每次手动调整数值看效果,我们也可以用 GUI 去控制一个变量再通过 uniform 传入到顶点着色器来进行更新,后续文章会讲到,这里偷个懒,继续借助 sin 函数来将 uTime 变化到0.0到10.0再去改变y的值,这样就能动态直观的看到形状如何随数值变化而变化。

C# 复制代码
vec3 newPos = position;
newPos.y += sin(position.y * (sin(uTime) + 1.0) * 5.0);

当然我们也可以固定下数值,将 uTime 加到后面,动画效果同样很有趣。

C# 复制代码
vec3 newPos = position;
newPos.y += sin(position.y * 1.0 + uTime * 2.0);

:::: column ::: column-left

::: ::: column-right

::: ::::

xyz 坐标不同偏移

接着还能对 xyz 坐标进行不同程度的偏移,比如再用 z 坐标计算 sin 值对 x 坐标进行改变,并且 sin 里的参数也可以相应变化...这里可调整改变的地方和对应存在的可能性很多,留给大家自行探索,或许能发现更有趣的效果。

C# 复制代码
vec3 newPos = position;
newPos.y += sin(position.y * 1.0 + uTime * 2.0);
newPos.x += 0.8 * sin(position.z * 0.5 + uTime * 1.0);

sin 函数我们已经用过很多次,大家原本也很熟悉,不过实际作品中可能多个 sin、cos 一组合所产生的图形或视觉效果就不再那么简单直观好解释了,这一点不知道有人是否和古柳一样有同感。

简单强大的 noise 噪声函数

接下来介绍的 noise 噪声函数(Perlin Noise、Simplex Noise 等)可能有些人还没听说过,但其实用起来很简单,而且效果更强大。一言以蔽之借助 noise 函数能使相邻的点(一维、二维、三维的点都行)产生相近的数值,而不是 random 随机函数那种每个位置的数值都和附近无关的效果。

noise 函数不是内置函数但有现成的实现可以使用,我们谷歌搜索 glsl noise function,能在这个链接里找到很多实现。这里我们先演示在三维顶点坐标上使用 noise 的效果,因此先复制粘帖接收 vec3 格式参数的 cnoise() 到 main 函数之前然后进行使用。

C# 复制代码
//	Classic Perlin 3D Noise 
//	by Stefan Gustavson
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}
vec3 fade(vec3 t) {return t*t*t*(t*(t*6.0-15.0)+10.0);}

float cnoise(vec3 P){
  vec3 Pi0 = floor(P); // Integer part for indexing
  vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
  Pi0 = mod(Pi0, 289.0);
  Pi1 = mod(Pi1, 289.0);
  vec3 Pf0 = fract(P); // Fractional part for interpolation
  vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
  vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
  vec4 iy = vec4(Pi0.yy, Pi1.yy);
  vec4 iz0 = Pi0.zzzz;
  vec4 iz1 = Pi1.zzzz;

  vec4 ixy = permute(permute(ix) + iy);
  vec4 ixy0 = permute(ixy + iz0);
  vec4 ixy1 = permute(ixy + iz1);

  vec4 gx0 = ixy0 / 7.0;
  vec4 gy0 = fract(floor(gx0) / 7.0) - 0.5;
  gx0 = fract(gx0);
  vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
  vec4 sz0 = step(gz0, vec4(0.0));
  gx0 -= sz0 * (step(0.0, gx0) - 0.5);
  gy0 -= sz0 * (step(0.0, gy0) - 0.5);

  vec4 gx1 = ixy1 / 7.0;
  vec4 gy1 = fract(floor(gx1) / 7.0) - 0.5;
  gx1 = fract(gx1);
  vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
  vec4 sz1 = step(gz1, vec4(0.0));
  gx1 -= sz1 * (step(0.0, gx1) - 0.5);
  gy1 -= sz1 * (step(0.0, gy1) - 0.5);

  vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
  vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
  vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
  vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
  vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
  vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
  vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
  vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);

  vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
  g000 *= norm0.x;
  g010 *= norm0.y;
  g100 *= norm0.z;
  g110 *= norm0.w;
  vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
  g001 *= norm1.x;
  g011 *= norm1.y;
  g101 *= norm1.z;
  g111 *= norm1.w;

  float n000 = dot(g000, Pf0);
  float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
  float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
  float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
  float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
  float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
  float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
  float n111 = dot(g111, Pf1);

  vec3 fade_xyz = fade(Pf0);
  vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
  vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
  float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x); 
  return 2.2 * n_xyz;
}

现在让我们用 noise 函数对球体上每个顶点生成一个0到1的数值,如果直接加到 position 上,会因为数值是正数且同时加到xyz三个分量上,导致每个顶点都往一个方向偏移,比如左侧顶点(-1,0,0)、右侧顶点(1,0,0)都是加上(0.1,0.1,0.1)。

C# 复制代码
float cnoise(vec3 P){
  // ...
}

void main() {
  vec3 newPos = position;
  newPos += cnoise(position);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

这并不是我们想要的效果,我们希望每个顶点朝自己原本的方向去偏移,左侧的点往左,右侧的点往右,上方的点往上,下方的点往下......此时可以借助每个顶点自带的法线 normal 来达到这个目的。我们之前说过每个顶点自带的属性有 position、uv 和 normal,那么在这里用 normal (其为单位向量)表示偏移方向,用 noise 得到的数值表示偏移幅度,再累加到 position 上即可实现所需效果。

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

normal 作为颜色

上面的形状看着"有机""自然"得多,但单色模式下总显得不够立体。我们可以把法线向量通过 varying 传递到片元着色器并设置成颜色,此时不同位置颜色不同,看着立体些。

C# 复制代码
varying vec3 vNormal;

void main() {
  vec3 newPos = position;
  newPos += normal * cnoise(position);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
  
  vNormal = normal;
}

// Fragment Shader
varying vec3 vNormal;

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

再转动物体,立体感更明显。(当然我们会发现顶点偏移和物体转动后,法线还是原来的值没有自动更新,后续我们会讲如何在顶点着色器里更新法线。)

js 复制代码
function render() {
  const time = clock.getElapsedTime();
  material.uniforms.uTime.value = time;
  mesh.rotation.y = time;
}

random vs noise

那么,使用 random 随机函数的效果又是怎样的?我们谷歌搜索 glsl 3d random function 从 shadertoy 上找到三维的随机函数,同样复制粘帖后,传入 position 得到每个顶点的随机值作为偏移量。可以看出上面 noise 的效果有更多平滑些的部分,因为邻近点的偏移数值或幅度相近,像是山脉一样有自然起伏,而 random 的效果与顶点是否邻近无关,任何位置都是一样的随机,因而骤变的情形更多。

C# 复制代码
// 3D Randomness
float random(vec3 pos){
  return fract(sin(dot(pos, vec3(64.25375463, 23.27536534, 86.29678483))) * 59482.7542);
}

void main() {
  vec3 newPos = position;
  // newPos += normal * cnoise(position);
  newPos += normal * random(position);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

增加几何体细分数

上面 random 效果下的几何体看着蛮有趣,我们可以增加球体的细分数(下面以2的倍数为参数进行设置不必非得如此)使得几何体上有更多的顶点可以被操控、被偏移,此时的效果也蛮漂亮的不是吗?

js 复制代码
// const geometry = new THREE.SphereGeometry(1, 32, 16);
// const geometry = new THREE.SphereGeometry(1, 64, 64);
// const geometry = new THREE.SphereGeometry(1, 128, 128);
const geometry = new THREE.SphereGeometry(1, 256, 256);

noise 改变 position 相邻范围

当在 256x256 细分数时,切换回 noise 效果却发现形状基本没变。

虽然古柳也觉得很奇怪,但我们可以通过给 position 乘以不同数值来达到改变形状的目的。为什么能生效呢?还记得我们说 noise 能对相邻的点产生相近的数值嘛,那么如何定义"相近"这个概念呢,比如相邻的街道、相邻的城市、相邻的省份、相邻的国家......都叫相邻却距离远近各不相同。同样的当细分数固定后,相邻 position 的距离也固定了,但我们剩以不同数值后,相当于范围距离也跟着变了,假入原本是相距0.1单位,可能就变成0.03、0.8、2.7等等,此时再传入到 noise 函数里效果就会不一样。如下图所示,当数值越小,所有球体上顶点都非常接近,此时所有偏移数值都变化不大,就越接近球体本身;当数值越大,每个顶点距离越远,越不相邻,数值越不相关,也就越接近 random 时的效果。

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

动态调整数值

同样这里可以用 GUI 控制参数,但古柳还是继续偷懒用 sin 函数来将 uTime 变化到0.0到8.0再去改变 position 范围,这样就能动态直观的看到形状如何随数值变化而变化。

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

小结

本篇文章古柳教大家如何在顶点着色器里用 sin、random、noise 等函数对顶点坐标进行偏移从而改变几何体形状。大家也能发现除去文章讲解所需的那些步骤以及拷贝一些现成的函数,实际上我们只需几行代码就能通过 shader 做出有趣酷炫的效果。如果说前几篇文章里片元着色器的例子还比较平淡枯燥,那么相信本文的例子能让初学者再一次领会到 shader 的魅力吧!

当然咱们的 Three.js Shader 之旅还只是开了个头,更多酷炫例子后续会依次奉上,古柳一定不会让大家失望(也希望大家多多点赞等支持让古柳更有动力高质量输出内容)。

而当我们实现了上述效果,又该如何更进一步地用不同偏移值去作为颜色,而非用单色或法线 normal。大家别着急,下一篇文章古柳就会教大家如何实现出这个更酷炫的效果。

而这些是古柳最初入坑 Shader 时从 Pepyaka 这个作品的实现里学到的,现在终于也快要教给大家了,敬请期待!

最后也别忘了好好复习本文内容。

照例

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

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

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

相关推荐
迂 幵20 分钟前
vue el-table 超出隐藏移入弹窗显示
javascript·vue.js·elementui
上趣工作室25 分钟前
vue2在el-dialog打开的时候使该el-dialog中的某个输入框获得焦点方法总结
前端·javascript·vue.js
家里有只小肥猫25 分钟前
el-tree 父节点隐藏
前端·javascript·vue.js
zxg_神说要有光1 小时前
自由职业第二年,我忘记了为什么出发
前端·javascript·程序员
亿牛云爬虫专家2 小时前
Puppeteer教程:使用CSS选择器点击和爬取动态数据
javascript·css·爬虫·爬虫代理·puppeteer·代理ip
2401_857610032 小时前
深入探索React合成事件(SyntheticEvent):跨浏览器的事件处理利器
前端·javascript·react.js
熊的猫2 小时前
DOM 规范 — MutationObserver 接口
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
天农学子2 小时前
Easyui ComboBox 数据加载完成之后过滤数据
前端·javascript·easyui
mez_Blog2 小时前
Vue之插槽(slot)
前端·javascript·vue.js·前端框架·插槽
爱睡D小猪2 小时前
vue文本高亮处理
前端·javascript·vue.js