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

762 阅读9分钟

文章更新(可能 ❌ / 一定 ✅)没那么频繁,欢迎加入「可视化交流群」进行交流。加古柳微信「xiaoaizhj」备注「可视化加群」即可,也有机会围观古柳朋友圈,实时追踪最新动态!

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

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

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

前言

「手把手带你入门 Three.js Shader 系列(九)」一文在2024.7.1更新后,又过去了10个月之久。

上个月恢复更新时,本想先写完这种类似“折射”小球效果的实现文章,再来更新入门系列第10篇及后续更多内容,但收尾工作不太顺利就卡住了,不过基于其中用到的一些知识在 plane 平面上改变 uv 以制作图片错位效果的文章早就写完了,本来打算等小球的文章写完后,作为付费的小系列一起更新,但犹豫后还是免费公开吧。

考虑到图片错位效果很适合放到入门系列里,且写完吃灰了快一个月、想赶五一前马上发布掉,所以虽然目前第10篇内容还没更新,但鉴于这几篇不依赖于第10篇,和前几篇不太相关,就先更新第11、12篇,等回头再填第10篇的坑。怕大家觉得奇怪,故此进行说明。

Plane 平面

让我们从简单的例子讲起,在 1x1 的 plane 平面上用 uv 采样并显示纹理图片,其中图片宽高也用1:1的。图片链接在这里。

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

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

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

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

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

  void main() {
    // gl_FragColor = vec4(vUv, 0.0, 1.0);
    gl_FragColor = texture2D(uTexture, vUv);
  }
`;
  
const geometry = new THREE.PlaneGeometry(1, 1);
const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
    uTexture: { value: texture },
  },
  vertexShader,
  fragmentShader,
});

const mesh = new THREE.Mesh(geometry, material);
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();

在此前 Shader 入门系列的九篇文章里,还没怎么涉及图片上的各种操作,如扭曲图片,但有讲过改变 uv 可以实现重复条纹等效果,实际上很多图片“特效”,背后就是通过对 uv 的各种变化来实现的

void main() {
  // gl_FragColor = vec4(vec3(fract(vUv.x * 3.0)), 1.0);
  gl_FragColor = vec4(vec3(step(0.5, fract(vUv.x * 3.0))), 1.0);
}

Mosh

比如「断更19个月,携 Three.js Shader 归来!(下) - 牛衣古柳 - 20230421」一文里,古柳提过的 Photo Mosh(现在叫 Mosh)这个可以给图片加很多效果的工具网站,里面的效果就是用 shader 实现的。

这是2年前古柳在网站源码里找 shader 代码时所截的图,从 fragmentShader 里可以大致看到,vUv 经过一定转换后变成 noiseUv,再用 noiseUv 对 tDiffuse 图片进行采样,就实现出 Melt 效果。

uniform sampler2D tDiffuse;
uniform float time;
uniform float scale;
uniform float amount;
uniform float speed;
varying vec2 vUv;

float noise2d(vec2 v){ ... }

float getNoise(vec2 uv, float t){
    //generate multi-octave noise based on uv position and time
    //move noise  over time
    //scale noise position relative to center
    uv -= 0.5;
    //octave 1
    float scl = 4.0 * scale;
    float noise = noise2d( vec2(uv.x * scl ,uv.y * scl - t * speed ));
    //octave 2
    scl = 16.0 * scale;
    noise += noise2d( vec2(uv.x * scl + t* speed ,uv.y * scl )) * 0.2 ;
    //octave 3
    scl = 26.0 * scale;
    noise += noise2d( vec2(uv.x * scl + t* speed ,uv.y * scl )) * 0.2 ;
    return noise;
}

void main() {
    vec2 uv = vUv;
    float noise = getNoise(uv, time * 24.0);
    vec2 noiseUv = uv + amount * noise;
    //wrap
    noiseUv = fract(noiseUv);
    gl_FragColor = texture2D(tDiffuse, noiseUv);
}

具体这里 Melt 效果对应的 uv 是怎么变化的,因为不是本文的重点,所以暂时不展开讲,感兴趣的可以自行理解下。

其实古柳一直想把这个网站里的27种图片 shader 效果都整理出来,教下大家,虽然还没研究过,很多效果能不能搞懂、能不能讲明白还未可知,但姑且提一嘴、留个坑待后续再填。

当然大家对图片“特效”感兴趣的,可以用「这个强大的插件能让网页里的 Shader 代码一览无余 - 20250316」一文里讲过的 Spector.js 插件去看看 Mosh 网站里每个效果对应的 Shader 源码。

或者直接从浏览器开发者工具里的 Sources 源码文件里找也行,一般 Shader 部分都是字符串,并不会因为上线打包混淆而无法辨认。

图片缩放与纹理重复

言归正传,看看最简单的效果,改变 uv 范围后图片会发生缩放。

将 vUv 乘以小于1.0的数值后,uv 范围变小,对应只采样一小块区域的图片,所以图片会放大;反之,uv 范围变大,只需原本一部分的 uv 区域就能采样完所有图片,所以图片会缩小。

这里需要注意,uv 超过 1.0 的部分默认都采样边缘最后一个像素,所以出现类似“拉丝”的效果。

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

不希望拉丝的话,可以将 texture 的 wrapSwrapT 改成 RepeatWrapping 重复或 MirroredRepeatWrapping 镜像重复。

  • 链接:https://threejs.org/docs/#api/en/textures/Texture.wrapS
const texture = loader.load("./assets/flower.jpg");
texture.wrapS = texture.wrapT = THREE.MirroredRepeatWrapping; // RepeatWrapping

直接重复的能看到明显的边缘,左右上下都镜像重复的则拼接融合地更丝滑。实际需要哪个可自行决定,本文后续例子将采用 MirroredRepeatWrapping

像素动起来

接着,不对图片进行缩放,而是给 uv.x 加上 uTime 让图片动起来的,由于每个像素是一起加上 uTime 的,类似一个方阵里的人以相同的速度前进,所以图片是整体平移。

void main() {
    vec2 newUV = vUv;
    newUV.x += uTime;
    gl_FragColor = texture2D(uTexture, newUV);
}

上面的效果太单调乏味,但只需再加上几行代码就能做出蛮亮眼的效果。

划分区域、错位效果

我们可以将图片划分成不同区域,不同区域之间数值不同,区域内数值相同,用这样的数值作为像素偏移的距离,就能产生图片错位的效果。

将 vUv.x 乘以3.0再用 floor 取整,这样原本0.0-1.0连续的数值变成了3个离散的数值0.0、1.0、2.0,再除以3.0变回到0.0-1.0范围内,将该数值以灰度值颜色显示就是下面的效果。此时相当于水平方向划成3块区域,每块区域里都有固定数值。

void main() {
    float block = 3.0; // 6.0
    float x = floor(vUv.x * block) / block;
    gl_FragColor = vec4(vec3(x), 1.0);
}

将 x 数值累加到 uv.x 上,就会看到图片错位的效果。

void main() {
    float block = 3.0;
    float x = floor(vUv.x * block) / block;
    
    vec2 newUV = vUv;
    // newUV.x += uTime;
    newUV.x += x;
    
    gl_FragColor = texture2D(uTexture, newUV);
}

再把 uTime 加上,一种类似玻璃或镜子的漂亮效果就诞生了。

vec2 newUV = vUv;
// newUV.x += uTime;
// newUV.x += x;
newUV.x += x + uTime;

不同速率,压缩拉伸

如果不对图片错位,而是将 x 作为不同的移动速率和 uTime 相乘,效果又会不同。此时不同区域移动速率不同,会有类似压缩或拉伸的视觉效果。

vec2 newUV = vUv;
newUV.x += x * uTime;
// newUV.x += (1.0 - x) * uTime;

小结

通过几行简单的代码来改变 uv 再应用到图片上就能做出有趣好看的效果,下一篇文章将继续讲解更多图片错位的效果,敬请期待。

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

最后照旧是本文的所有例子合集。

相关阅读

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

照例

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

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

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