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

2,096 阅读10分钟

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

本系列的代码同时放到了 Codepen Collection,欢迎学习:codepen.io/collection/…

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

正文

时隔四个月更新的第9篇来啦!

前几篇文章里我们主要在 sphere 球体上应用 sin、random、noise 等函数来偏移顶点、改变几何体形状。

本文讲解对 plane 平面偏移顶点,看看一些需要注意的地方;重新详细讲解下 smoothstep 平滑函数,以及用 smoothstep 来实现在一定区域范围内才生效偏移等内容。

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

Plane

让我们从 plane 平面开始讲起,线框模式下方便看到顶点偏移后的效果。长宽都设为1,细分数设为30,默认情况下平面以 3D 坐标原点为中心放置、面朝 z 轴正方向,xy 范围都是-0.5到0.5。

const camera = new THREE.PerspectiveCamera(75, 1, 0.01, 100);
camera.position.set(0, 0, 1.4);

const vertexShader = /* GLSL */ `
  uniform float uTime;
  varying vec2 vUv;

  void main() {
    vUv = uv;
    vec3 newPos = position;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
  }
`;

const fragmentShader = /* GLSL */ `
  varying vec2 vUv;

  void main() {
    gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
  }
`;
  
const geometry = new THREE.PlaneGeometry(1, 1, 30, 30);
const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
  },
  vertexShader,
  fragmentShader,
  wireframe: true,
  // side: THREE.DoubleSide,
});

const mesh = new THREE.Mesh(geometry, material);
// mesh.rotation.x = -Math.PI / 2;
scene.add(mesh);

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

render();

将 sin(x) 作为偏移值加到顶点 z 坐标上,将 x 由-0.5到0.5变成0到2xPI,这样刚好是一个 sin 周期。转动角度后 sin 波的形状更明显。

uniform float uTime;
varying vec2 vUv;

const float PI = 3.1415926;

void main() {
  vUv = uv;
  vec3 newPos = position;
  newPos.z += 0.5 * sin(position.x * PI * 2.0);
  gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(newPos, 1.0);
}

本地坐标与世界坐标

需要注意的是不论 mesh 怎么旋转、平移、缩放,顶点着色器里的 position 都是 PlaneGeometry 一开始设置完成后初始状态所对应的顶点坐标数值。比如旋转成朝向上方 y 轴正方向后,想在 y 轴上偏移顶点,其实还是对顶点的 z 坐标进行操作,和未旋转前的对 z 值改变一样。

const geometry = new THREE.PlaneGeometry(1, 1, 30, 30);

const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2;
// mesh.rotation.y = Math.PI / 2;
scene.add(mesh);

对于 mesh 的旋转、平移、缩放其实会以 modelMatrix 模型矩阵的形式作用到顶点上,从而使几何体的顶点从本地/模型坐标空间(local space/model space/local coordinates)变成当前场景的世界坐标空间(world space/world coordinates),每个物体由基于自身的坐标(position)变成3D场景里坐标(modelPosition)。

  • 链接:https://learnopengl.com/Getting-started/Coordinate-Systems

此时对于面朝 y 轴正方向的 plane 想在 y 轴偏移顶点就是对 modelPosition.y 进行改动,这比上面对原始 position 的改动更直观好懂一点。当然这里例子比较简单,大家喜欢用哪个都行。

// mesh.rotation.x = -Math.PI / 2;

void main() {
  vec4 modelPosition = modelMatrix * vec4(position, 1.0);
  modelPosition.y += 0.5 * sin(modelPosition.x * PI * 2.0);
  // modelPosition.y += 0.5 * sin(modelPosition.x * PI * 2.0 + uTime);
  gl_Position = projectionMatrix * viewMatrix * modelPosition;
}

古柳记得以前看 Bruno SimonThree.js Journey 教程时看到他常用 modelPosition 就觉得很奇怪,因为自己一直喜欢对 position 直接进行操作,就很纠结他这么厉害的大佬在课程里多次都用 modelPosition 到底是有什么特别的考虑......后来发现大同小异,只是个人习惯而已。

飘扬的旗帜

在 Bruno 的教程里有个小例子是连用两次 sin 来做出飘扬旗帜的效果,比如像这里分别对 xz 用不同参数就能做出类似效果。

void main() {
  vec4 modelPosition = modelMatrix * vec4(position, 1.0);
  modelPosition.y += 0.08 * sin(modelPosition.x * PI * 2.0 + uTime);
  modelPosition.y += 0.1 * sin(modelPosition.z * PI * 1.5 + uTime);
  gl_Position = projectionMatrix * viewMatrix * modelPosition;
}

我们加载图片纹理作为 uTexture 传给 shader,把 plane 的长宽比改成和图片 800x534 一致的比例。图片这里用西班牙国旗,最近欧洲杯⚽️正在火热进行中,小组赛刚刚结束(文章一拖延,1/8决赛也已经开始了),西班牙踢得不错,不知道最后会不会夺冠,姑且期待一下。

// const geometry = new THREE.PlaneGeometry(1, 1, 30, 30);
const geometry = new THREE.PlaneGeometry(0.8, 0.534, 30, 30);

const material = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0 },
    uTexture: {
      value: new THREE.TextureLoader().load("./assets/spain-flag.webp"),
    },
  },
});

在片元着色器里用 texture2D 函数通过 vUv 采样纹理图颜色并设置到 gl_FragColor 上即可显示图片。这样飘扬的旗帜就做出来了。

uniform sampler2D uTexture;
varying vec2 vUv;

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

眼尖的朋友可能会想起来在「没有前端能抵抗住的酷炫效果,带你用 Three.js Shader 一步步实现) - 牛衣古柳 - 20240427」一文里飘扬的文字就是用同样方式实现的。

smoothstep 平滑过渡

让我们先把上面的效果放在一边,先用 uv.x 作为对 y 坐标对偏移值,并且传到片元着色器作为颜色。因为 uv.x 是0-1线性增长的,所以 plane 从左往右也是线性变高,颜色由黑到白。

// vertex shader
varying float vStrength;

void main() {
  vec4 modelPosition = modelMatrix * vec4(position, 1.0);
  // modelPosition.y += 0.08 * sin(modelPosition.x * PI * 2.0 + uTime);
  // modelPosition.y += 0.1 * sin(modelPosition.z * PI * 1.5 + uTime);
  float strength = uv.x;
  vStrength = strength;
  modelPosition.y += 0.3 * strength;
  gl_Position = projectionMatrix * viewMatrix * modelPosition;
}

// fragment shader
uniform sampler2D uTexture;
varying vec2 vUv;
varying float vStrength;

void main() {
  // gl_FragColor = texture2D(uTexture, vUv);
  vec3 color = vec3(vStrength);
  gl_FragColor = vec4(color, 1.0);
}

用 smoothstep 函数对 uv.x 在0-1之间平滑,此时不再是线性变化,而是两头变化慢、中间变化快。(之前文章里对 smoothstep 讲解还不够细致,这里补上)

// float strength = uv.x;
float strength = smoothstep(0.0, 1.0, uv.x);

改变两端的变化范围,使得 uv.x<0.2 的都为0,>0.8的都为1,0.2-0.8之间平滑过渡。

float strength = smoothstep(0.2, 0.8, uv.x);

两端数值互换就是 uv.x<0.2 的都为1,>0.8的都为0。

float strength = smoothstep(0.8, 0.2, uv.x);

中间往两边变化

这里都还是从一端往另一端单调变化的,我们还可以用「手把手带你入门 Three.js Shader 系列(五) - 牛衣古柳 - 20231126」一文里提到的实现圆环和对角线渐变的方法,使得数值从中间向两边变化。大家还记得如何实现嘛,就是先减去一个数(如0.5)再 abs 取绝对值,这样就从0-1变成-0.5-0.5再变成0.5-0-0.5。

float strength = abs(uv.x - 0.5);

对0-0.5平滑后效果如下,中间为0,两侧为1。

float strength = smoothstep(0.0, 0.5, abs(uv.x - 0.5));

反向后就是中间为1,两端为0。

float strength = smoothstep(0.5, 0.0, abs(uv.x - 0.5));

改变一侧数值,使得0-0.3之间平滑过渡,0.3-0.5之间为0。

float strength = smoothstep(0.3, 0.0, abs(uv.x - 0.5));
modelPosition.y += 0.3 * strength;

在一定范围内生效顶点偏移

不知道大家是否好奇为什么古柳要在这里讲这么多数值如何变化?这样的数值分布有啥实际用处?

在 shader 里常常会涉及各种0-1范围的数值,其用途非常广泛,在前面文章里就涉及了不少,包括但不限于用于直接作为颜色、mix插值不同颜色、mix插值不同的顶点坐标、作为过渡动画的进度值等等。

而这里古柳想讲的其实是用这样的数值可以来控制某个范围内才生效某种效果。单看上面的图,其实可以理解成想要在 uv.x=0.5 的附近才生效顶点偏移。

通过 sin 函数左右移动偏移中心的位置,就是动态的效果。

float strength = smoothstep(0.3, 0.0, abs(uv.x - 0.5 + 0.5 * sin(uTime)));

gl_FragColor = texture2D(uTexture, vUv);

实际中可以通过 raycaster 把鼠标位置传入 shader 从而在鼠标附近产生偏移,并且显示图片后能看到更实际、更“落地”的简单交互效果。

径向效果

经过上面的讲解相信大家对 smoothstep 有了更直观的了解。前面还只是单个轴上的效果,接下来我们用 uv 离中心的距离 dist 来作为偏移强度。

float dist = distance(uv, vec2(0.5));
float strength = dist;
vStrength = strength;
modelPosition.y += 0.3 * strength;

对 dist 进行平滑,使中间强度为1,dist>0.4时为0,0-0.4平滑过渡,这样就在中间区域才生效偏移。

// float strength = dist;
float strength = smoothstep(0.4, 0.0, dist);

如果用 sin 控制每时每刻的偏移强度,就能周期性地下凹上凸变化,看起来很Q弹。放到图片上也会有蛮不同的观感。

modelPosition.y += 0.3 * sin(uTime) * strength;

结合鼠标移动后,非常简单粗陋的3D图片交互效果就出来了。这部分大家可先自行尝试实现,这里暂时不讲留到以后再说。

卷轴效果

在很多用到 shader 的国外酷炫网站里都会在图片上做各种效果,后续教程古柳也会带大家实现更多效果。比如这种类似“卷轴”的效果,放在网页里就比呆板的静态图片展示更让人眼前一亮,不过实现步骤要一步步讲清楚也不容易,感兴趣的可以去看源码研究下。

小结

下一篇文章将会在其它例子上继续应用 smoothstep 使得在一定范围内生效偏移,并且会教大家一种特殊的生成酷炫颜色的方法。

最后照旧是本文的所有例子合集。本文代码见:codepen.io/GuLiu/pen/z…

相关阅读

「Three.js Shader 系列文章」目录如下:

照例

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

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

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