Three.js Shader 实现酷炫折射小球效果(上)

1,141 阅读8分钟

前情回顾

一个月前,古柳做了个类似“折射小球”的3D效果,然后发布到 X 上并收获了75个喜欢。

这个效果是从古柳远在乌克兰的 Shader 师父 Yuri Artiukh 的一期复现某网站效果的油管教程中学到的。

老群友也许还记得3前年古柳就打卡学习过这期视频,但当时刚接触 shader 没几个月,完全无法理解背后的原理,代码里一堆新函数或概念,如 dFdx、fwidth、refract 折射、fresnel effect 菲涅耳效果,以及各种不知道为什么那么用的坐标或计算过程,学得云里雾里、留下了阴影。

近期重新看了遍教程,发现能理解的地方多了不少,虽然有些步骤还是解释不清,但还是尽量在文章里写写,有对这个效果感兴趣的朋友也能参考下。代码见:

不过,文章内容并非完全按油管教程里的步骤来写的,而是加了不少古柳自己的想法,另外涉及的 hash 函数、不同区域生成不同数值等知识点,已提前抽离到前两篇文章里讲解过,因此建议大家学习后再看本文:「手把手带你入门 Three.js Shader 系列(十一)- 20250427」「手把手带你入门 Three.js Shader 系列(十二)- 20250428」

闲言少叙,进入正题。

二十面体

为了实现本次的效果,首先需要一个球体。

因为 SphereGeometry 几何体上顶点分布不均匀、每个面大小不一,而 IcosahedronGeometry 二十面体的顶点分布均匀、每个面大小相同,所以使用后者作为基础的球体,能确保后续在每个面上显示图片时效果更好。(最后实现出想要的效果后,会切换回 SphereGemoetry 进行对比)

先使用 MeshNormalMaterial 作为材质,并设置 flatShading: true,可以看到每个面上因为法线相同所以颜色也相同,后续会用每个面的法线去进行一些计算,所以这里先看看 flatShading 时的效果,先对此有个印象。

const geometry = new THREE.IcosahedronGeometry(1, 2);
const material = new THREE.MeshNormalMaterial({
  flatShading: true,
});

具体效果需要在 Shader 里实现,因此使用 ShaderMaterial 替代 MeshNormalMaterial。

贴图 texture 和上一篇文章一样设置成镜像重复 MirroredRepeatWrapping

后续 shader 里会使用内置函数 dFdx、dFdy 计算偏导数,需要设置 derivatives 属性。这些都提前设置好方便后续使用。

renderer.setClearColor("#111111", 1);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.01, 100);
camera.position.set(0, 0, 1.8);

const loader = new THREE.TextureLoader();
const texture = loader.load("./assets/flower.jpg");
texture.wrapS = texture.wrapT = THREE.MirroredRepeatWrapping;

const geometry = new THREE.IcosahedronGeometry(1, 2);

const vertexShader = /* GLSL */ `
  varying vec2 vUv;
  varying vec3 vNormal;

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

const fragmentShader = /* GLSL */ `
  uniform float uTime;
  uniform sampler2D uTexture;
  
  varying vec2 vUv;
  varying vec3 vNormal;

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

const material = new THREE.ShaderMaterial({
  extensions: {
    derivatives: "#extension GL_OES_standard_derivatives : enable",
  },
  uniforms: {
    uTime: { value: 0 },
    uTexture: { value: texture },
  },
  vertexShader,
  fragmentShader,
});

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

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

render();

将 uv 和 normal 都从顶点着色器传递到片元着色器,先用 vUv 作为颜色和显示纹理图,效果想来大家都熟悉。

// vertex shader
varying vec2 vUv;
varying vec3 vNormal;

void main() {
  vUv = uv;
  vNormal = normal;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
  
// fragment shader
uniform float uTime;
uniform sampler2D uTexture;

varying vec2 vUv;
varying vec3 vNormal;

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

接着用 vNormal 法线作为颜色,可以看到颜色是连续的,这是 平滑着色 Smooth Shading 的效果。以前古柳和大家讲过,本来 position、uv、normal 这些数据是每个顶点上才有的,但通过 varying 从顶点着色器传递到片元着色器后,会在每个片元/像素上插值出数值,因此 vNormal 就是插值后的顶点法线,。

Flat Shading 平坦着色

但这里希望像前面 flatShading 一样得到每个面的法向量。谷歌搜索 glsl compute normal dFdx 能找到类似的实现方法,比如通过下面步骤计算出的新 normal 就符合需求。

varying vec3 vNormal;

void main() {
  // gl_FragColor = vec4(vNormal, 1.0);
  // 通过屏幕空间导数计算面法线
  vec3 X = dFdx(vNormal);
  vec3 Y = dFdy(vNormal);
  vec3 normal = normalize(cross(X, Y));  
  gl_FragColor = vec4(normal, 1.0);
}

也可以从 three.js 仓库源码里找 meshnormal 相关的 shader 代码片段,看看官方是怎么实现的。下面就是一些源码,可以看到当定义了 FLAT_SHADED 后每个面法线的计算步骤和上面的类似,只不过这里用的 vViewPosition,即顶点在视图空间(Camera Space)中的位置,以确保计算的法线与视角无关,不过把这里的 normal 作为颜色后发现效果和上面的一样,所以方便起见接下来还是用 vNormal 版本的)

varying vec3 vViewPosition;

vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;

// normal_fragment_begin.glsl.js
#ifdef FLAT_SHADED
	vec3 fdx = dFdx( vViewPosition );
	vec3 fdy = dFdy( vViewPosition );
	vec3 normal = normalize( cross( fdx, fdy ) );
#else
	vec3 normal = normalize( vNormal );
#endif

如果不明白这里的计算过程,可以让 Deepseek-R1 解释下。或许看完解释还是难以深刻理解,但可以先记住,先继续推进下去实现完整效果。

值得一提的是:dFdxdFdy 是 GLSL 中的内置函数,可用于计算屏幕空间中某个值在水平 X 或垂直 Y 方向上和相邻像素的差值、导数或梯度。它们在片段着色器中广泛应用,尤其是在需要基于屏幕空间局部变化进行计算的场景。后续古柳会单独写文章讲讲它们的用法。

核心用途:通过相邻像素的差异,分析某个值在屏幕空间的变化率(如颜色、深度、位置等)。

总之,不论是自己搜索解决方案,还是让 AI 生成,现在我们得到了每个面上统一的法线。

因为后续效果需要对球体进行旋转,并且下一步将用到法线和平行光计算光照,因此需要在顶点着色器里用 normalMatrix 先将法线从模型空间转换到视图空间 View Space。

void main(){
  // vNormal = normal;
  // 将法线转换到视图空间
  vNormal = normalize(normalMatrix * normal);
}

平行光下的漫反射

接着实现简单的光照效果,定义一个平行光 vec3(1.0) 并归一化处理,使用 dot(normal, lightDirection) 计算光线与面法线的点积值,再通过 max(..., 0.0) 确保数值非负、避免背面变亮。

当法线和光线平行且方向相同时数值最大为1,白色最亮;当两者间角度大于等于90度时数值为0,为黑色。

此时用 diffuse 作为颜色每个面会有不同亮度,并且旋转时朝向光线的一侧的面更亮。

void main(){
  // 通过屏幕空间导数计算面法线
  vec3 X = dFdx(vNormal);
  vec3 Y = dFdy(vNormal);
  vec3 normal = normalize(cross(X, Y));
    
  // 平行光方向
  vec3 lightDirection = normalize(vec3(1.0));
  // 计算漫反射强度(Lambert 光照模型)
  float diffuse = max(dot(lightDirection, normal), 0.0);
  gl_FragColor = vec4(vec3(diffuse), 1.0);
}

用 diffuse 对 uv 进行偏移

上一篇文章「手把手带你入门 Three.js Shader 系列(十二) - 20250428」里讲过可以对 plane 的不同区域生成不同数值,然后用于偏移 uv 就能做出图片错位的效果。

实际上这里并不是真的要用 diffuse 实现光照效果,而只是为了得到每个面上的不同数值。由于球体不像 plane 那么简单,所以得通过面法线和平行光的点积计算来实现。

先直接用 diffuse 对 uv 进行偏移,错位的效果就出来了。

void main() {
  // ...
  
  // vec2 newUV = vUv;
  vec2 newUV = vUv * 2.0;
  newUV += diffuse;
  gl_FragColor = texture2D(uTexture, newUV);
}

当球体旋转时,每个面的法线会不断变化,相应的 diffuse 数值也会变化、uv 偏移程度也会变化,因此每个面上的图片也会随之移动。

小结

虽然看着有点像最终想要的效果,但后续并不用 diffuse 来偏移 uv,而是会应用折射来实现图片错位的效果;另外还需实现球体旋转时,切换每个面里的内容,并且每个面移动方向更随机等效果。

这些会在下一篇文章再讲解,敬请期待。

相关阅读

其他 Three.js Shader 酷炫例子的教程文章见:

照例

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

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

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