没有前端能抵抗住的酷炫效果,带你用Three.js Shader一步步实现

13,248 阅读19分钟

前言

上一篇文章「手撸一个星系,送给心爱的姑娘!(Three.js Shader 粒子系统实现)- 牛衣古柳 - 20240417」里古柳带大家用 Three.js Shader 粒子系统实现了这个非常漂亮的星系效果。文章在上周三发布后上了掘金热榜,并且截至目前点赞数、收藏数双双破百,成为本 Shader 系列教程里阅读量最高的一篇(马上破万)。果然大家更喜欢看这种实现完整、实际、酷炫效果的文章,这倒是和我设想的一样。

正好上篇讲到粒子系统,我想不妨继续趁热打铁讲解下这个带我入坑 Shader 的效果 Pepyaka,想来这应该也是很多前端、程序员梦寐以求想要实现的酷炫效果吧。下图是本文最终实现的效果,GIF 不够清晰,大家可以去 Codepen 查看源码和效果,代码后续也会同步到 GitHub。

Pepyaka 这个效果本来出自于 Grant Yi 的个人网站,其实我一直不知道这个词到底啥意思,但就当成该效果的代名词这么叫着。可惜原网站换成了这个效果,虽然仍是 Shader 实现、同样酷炫,但看不到 Pepyaka 还是可惜。

「断更19个月,携 Three.js Shader 归来!(上)- 牛衣古柳 - 20230416」一文里我提过,当初入门 Three.js 后因为对粒子系统感兴趣,于是在油管搜教程,然后看到 Yuri Artiukh 复现 Pepyaka 的教程——「#s3e6 ALL YOUR HTML, Making Pepyaka with Three.js - 20191201」——在视频里残存的片段里窥见到 Pepyaka 如此丝滑、酷炫、漂亮的效果,于是入坑 Shader,再然后有了现在输出 Shader 教程这桩事。

时隔两年古柳终于可以在原视频的基础上融入自己会的一些 Shader 效果,更近一步实现出更贴近原作的各种效果,并通过文章教给大家,让大家也能上手实现这样酷炫的效果。

中心球体

闲言少叙,进入正题。让我们同样从线框模式下的白色球体开始讲起。

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

let w = window.innerWidth;
let h = window.innerHeight;

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(75, w / h, 0.01, 1000);
camera.position.set(0, 0, 4);
camera.lookAt(new THREE.Vector3());

const renderer = new THREE.WebGLRenderer({
  antialias: true,
  // alpha: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(w, h);
renderer.setClearColor(0x0a0a0f, 1);
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);

const sphereGeometry = new THREE.SphereGeometry(1, 32, 32);

const vertexShader = /* GLSL */ `
  uniform float uTime;

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

const fragmentShader = /* GLSL */ `
  void main() {
    gl_FragColor = vec4(vec3(1.0), 1.0);
  }
`;

const sphereMaterial = new THREE.ShaderMaterial({
  vertexShader,
  fragmentShader,
  uniforms: {
    uTime: { value: 0 },
  },
  // wireframe: true,
});
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
scene.add(sphere);

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

render();

顶点偏移

接着我们用 noise 噪声函数对顶点位置进行偏移从而改变球体形状、并用 noise 值控制 HSV 颜色模式里的 Hue 色相值,这些内容在「手把手带你入门 Three.js Shader 系列(六) - 牛衣古柳 - 20231220」「手把手带你入门 Three.js Shader 系列(七) - 牛衣古柳 - 20240206」两篇文章里已经详细地讲过,大家可以去自行学习。

这里简单过一遍,我们谷歌搜索 GLSL noise function,从这里拷贝 Simplex 4D Noise 函数,接着对每个顶点产生一个 noise 数值(不同 noise 函数返回的值范围可能是0-1,也可能是-1-1,大家可以用第七篇里讲的方法去查看,这里不太重要也就不带大家看了),将该数值乘上法线 normal 作为在该偏移方向上偏移的程度,再加上原始 position 就是偏移后的顶点坐标。

// vertex shader
uniform float uTime;

//	Simplex 4D Noise 
//	by Ian McEwan, Ashima Arts
vec4 permute(vec4 x){return mod(((x*34.0)+1.0)*x, 289.0);}
float permute(float x){return floor(mod(((x*34.0)+1.0)*x, 289.0));}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159 - 0.85373472095314 * r;}
float taylorInvSqrt(float r){return 1.79284291400159 - 0.85373472095314 * r;}

vec4 grad4(float j, vec4 ip){
  const vec4 ones = vec4(1.0, 1.0, 1.0, -1.0);
  vec4 p,s;

  p.xyz = floor( fract (vec3(j) * ip.xyz) * 7.0) * ip.z - 1.0;
  p.w = 1.5 - dot(abs(p.xyz), ones.xyz);
  s = vec4(lessThan(p, vec4(0.0)));
  p.xyz = p.xyz + (s.xyz*2.0 - 1.0) * s.www; 

  return p;
}

float snoise(vec4 v){
  const vec2  C = vec2( 0.138196601125010504,  // (5 - sqrt(5))/20  G4
                        0.309016994374947451); // (sqrt(5) - 1)/4   F4
// First corner
  vec4 i  = floor(v + dot(v, C.yyyy) );
  vec4 x0 = v -   i + dot(i, C.xxxx);

// Other corners

// Rank sorting originally contributed by Bill Licea-Kane, AMD (formerly ATI)
  vec4 i0;

  vec3 isX = step( x0.yzw, x0.xxx );
  vec3 isYZ = step( x0.zww, x0.yyz );
//  i0.x = dot( isX, vec3( 1.0 ) );
  i0.x = isX.x + isX.y + isX.z;
  i0.yzw = 1.0 - isX;

//  i0.y += dot( isYZ.xy, vec2( 1.0 ) );
  i0.y += isYZ.x + isYZ.y;
  i0.zw += 1.0 - isYZ.xy;

  i0.z += isYZ.z;
  i0.w += 1.0 - isYZ.z;

  // i0 now contains the unique values 0,1,2,3 in each channel
  vec4 i3 = clamp( i0, 0.0, 1.0 );
  vec4 i2 = clamp( i0-1.0, 0.0, 1.0 );
  vec4 i1 = clamp( i0-2.0, 0.0, 1.0 );

  //  x0 = x0 - 0.0 + 0.0 * C 
  vec4 x1 = x0 - i1 + 1.0 * C.xxxx;
  vec4 x2 = x0 - i2 + 2.0 * C.xxxx;
  vec4 x3 = x0 - i3 + 3.0 * C.xxxx;
  vec4 x4 = x0 - 1.0 + 4.0 * C.xxxx;

// Permutations
  i = mod(i, 289.0); 
  float j0 = permute( permute( permute( permute(i.w) + i.z) + i.y) + i.x);
  vec4 j1 = permute( permute( permute( permute (
             i.w + vec4(i1.w, i2.w, i3.w, 1.0 ))
           + i.z + vec4(i1.z, i2.z, i3.z, 1.0 ))
           + i.y + vec4(i1.y, i2.y, i3.y, 1.0 ))
           + i.x + vec4(i1.x, i2.x, i3.x, 1.0 ));
// Gradients
// ( 7*7*6 points uniformly over a cube, mapped onto a 4-octahedron.)
// 7*7*6 = 294, which is close to the ring size 17*17 = 289.

  vec4 ip = vec4(1.0/294.0, 1.0/49.0, 1.0/7.0, 0.0) ;

  vec4 p0 = grad4(j0,   ip);
  vec4 p1 = grad4(j1.x, ip);
  vec4 p2 = grad4(j1.y, ip);
  vec4 p3 = grad4(j1.z, ip);
  vec4 p4 = grad4(j1.w, ip);

// Normalise gradients
  vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
  p0 *= norm.x;
  p1 *= norm.y;
  p2 *= norm.z;
  p3 *= norm.w;
  p4 *= taylorInvSqrt(dot(p4,p4));

// Mix contributions from the five corners
  vec3 m0 = max(0.6 - vec3(dot(x0,x0), dot(x1,x1), dot(x2,x2)), 0.0);
  vec2 m1 = max(0.6 - vec2(dot(x3,x3), dot(x4,x4)            ), 0.0);
  m0 = m0 * m0;
  m1 = m1 * m1;
  return 49.0 * ( dot(m0*m0, vec3( dot( p0, x0 ), dot( p1, x1 ), dot( p2, x2 )))
               + dot(m1*m1, vec2( dot( p3, x3 ), dot( p4, x4 ) ) ) ) ;
}

void main() {
  float noise = snoise(vec4(position, 0.0));
  vec3 newPos = position + 0.8 * normal * noise;
  // gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

这里用接收 vec4 的 snoise 函数方便后续把 uTime 作为第4个参数,让形状动起来。

用法线 normal 作为颜色方便看变形后的形状。

// vertex shader
uniform float uTime;
varying vec3 vNormal;

float snoise(vec4 c){
  // ...
}

void main() {
  vNormal = normal;
  
  float noise = snoise(vec4(position, 0.0));
  vec3 newPos = position + 0.8 * normal * noise;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

// fragment shader
varying vec3 vNormal;

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

给 position 乘不同值,改变传给 noise 函数的顶点相邻程度,使形状变化更加“剧烈”。

// float noise = snoise(vec4(position * 1.0, 0.0));
// float noise = snoise(vec4(position * 0.3, 0.0));
float noise = snoise(vec4(position * 10.0, 0.0));

增加球体细分数,使球体上有更多顶点可以被用来偏移位置。

// const sphereGeometry = new THREE.SphereGeometry(1, 32, 32);
const sphereGeometry = new THREE.SphereGeometry(1, 200, 200);

将 uTime 作为第4个参数使形状实时发生变化。

float noise = snoise(vec4(position * 10.0, uTime * 0.2));

noise 值作为颜色

noise 数值除了用来偏移顶点坐标,还能用来设置颜色。将 vec3(noise) 灰度值颜色作为 vColor 传到片元着色器进行使用,那么 noise 值越大偏移高度越高、颜色越白;反之值越小高度越低、颜色越黑。

// vertex shader
uniform float uTime;
varying vec3 vNormal;
varying vec3 vColor;

void main() {
  vNormal = normal;
  
  float noise = snoise(vec4(position * 10.0, uTime * 0.2));
  vColor = vec3(noise);
  vec3 newPos = position + 0.8 * normal * noise;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

// fragment shader
varying vec3 vNormal;
varying vec3 vColor;

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

也可以设置成 rgb 里的 red 值,就是红黑效果。

vColor = vec3(noise, 0.0, 0.0);

还可以把 noise 值设置到 HSV 模式里的 hue 色相值(第七篇里都讲过,不过那里用的 HSL 这里用的 HSV,需要注意 HSV=HSB!=HSL),然后转换回 rgb 模式。谷歌搜索 glsl hsv2rgb function 找到现成的实现,拷贝后就能使用,直接把 noise 作为 hue 会是五彩斑斓的效果,因为 noise 值0-1的话会把所有色相值覆盖到。虽然也挺好看,但不是我们这里想要的。

vec3 hsv2rgb(vec3 c) {
  vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
  vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
  return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
  
void main() {
  vNormal = normal;
  
  float noise = snoise(vec4(position * 10.0, uTime * 0.2));
  // vColor = vec3(noise);
  // vColor = vec3(noise, 0.0, 0.0);
  vColor = hsv2rgb(vec3(noise, 1.0, 1.0));
  vec3 newPos = position + 0.8 * normal * noise;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

缩小 noise 范围,使颜色变化不那么剧烈。

vColor = hsv2rgb(vec3(vNoise * 0.1, 1.0, 1.0));

最后微调 hue,改下饱和度,至效果满意即可。

vColor = hsv2rgb(vec3(noise * 0.1 + 0.04, 0.8, 1.0));

球形粒子系统

中心的效果完成后,接着在外面加一层球形的粒子,之前有群友在学第七篇时就有问到,这次终于可以讲解下(欢迎加我「xiaoaizhj」,备注「可视化加群」,一起交流)。

上一篇文章「手撸一个星系,送给心爱的姑娘!(Three.js Shader 粒子系统实现)- 牛衣古柳 - 20240417」对粒子系统已经做过介绍,这里就不再重复。

我们通过 BufferGeometry() 设置粒子的 position,使 radius 稍大于中心球体,这样能包裹着球体。这里想要粒子在球体上均匀分布,从网上找现成的公式即可。

const particleGeometry = new THREE.BufferGeometry();

const N = 4000;
const positions = new Float32Array(N * 3);

const inc = Math.PI * (3 - Math.sqrt(5));
const off = 2 / N;
const radius = 2;

for (let i = 0; i < N; i++) {
  const y = i * off - 1 + off / 2;
  const r = Math.sqrt(1 - y * y);
  const phi = i * inc;

  positions[3 * i] = radius * Math.cos(phi) * r;
  positions[3 * i + 1] = radius * y;
  positions[3 * i + 2] = radius * Math.sin(phi) * r;
}

particleGeometry.setAttribute(
  "position",
  new THREE.BufferAttribute(positions, 3)
);

视频里用的是某博客文章里的公式,由于文章已看不到,这里放截图方便感兴趣的小伙伴看眼,总之把这里 python 代码改成上面 JS 代码即可。

我们也可以搜 evenly distribute points on a spherefibonacci spiral sphere 等关键词,能找到其他大同小异的实现方式,下面是另一种方案,作为参考,可以看到效果都差不多。后续演示仍沿用第一种方案。

// 另一种生成球体上均匀粒子坐标的方式
for (let i = 0; i < N; i++) {
  const k = i + 0.5;
  const phi = Math.acos(1 - (2 * k) / N);
  const theta = Math.PI * (1 + Math.sqrt(5)) * k;
  const x = Math.cos(theta) * Math.sin(phi) * radius;
  const y = Math.sin(theta) * Math.sin(phi) * radius;
  const z = Math.cos(phi) * radius;

  positions.set([x, y, z], i * 3);
}

材质用 ShaderMaterial,粒子颜色设置了透明度,然后和 particleGeometry 一起丢给 Points 就行。

const particleVertex = /* GLSL */ `
  uniform float uTime;

  void main() {
    vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
    gl_PointSize = 6.0 / -mvPosition.z;
    gl_Position = projectionMatrix * mvPosition;
  }
`;

const particleFragment = /* GLSL */ `
  void main() {
    // gl_FragColor = vec4(vec3(1.0), 1.0);
    gl_FragColor = vec4(vec3(1.0), 0.6);
  }
`;

const particleMaterial = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
  },
  vertexShader: particleVertex,
  fragmentShader: particleFragment,
  transparent: true,
  blending: THREE.AdditiveBlending,
});

const particles = new THREE.Points(particleGeometry, particleMaterial);
scene.add(particles);

function render() {
  // ...
  sphereMaterial.uniforms.uTime.value = time;
  particleMaterial.uniforms.uTime.value = time;
}

不设置透明度的话效果如下。还是设置透明度、弱化粒子视觉效果后看着更舒服。

gl_FragColor = vec4(vec3(1.0), 1.0);

粒子上下波动

接着让粒子运动起来,可以通过对 y 坐标取 sin 值再加回到 y 上,这样同一高度的粒子会一起随 sin 波浪上下偏移,整体上就是波浪的效果。

// particleVertex
uniform float uTime;

void main() {

  vec3 newPos = position;
  newPos.y += 0.1 * sin(newPos.y * 6.0 + uTime);
  // newPos.z += 0.05 * sin(newPos.y * 10.0 + uTime);

  // vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  vec4 mvPosition = modelViewMatrix * vec4(newPos, 1.0);
  gl_PointSize = 6.0 / -mvPosition.z;
  gl_Position = projectionMatrix * mvPosition;
}

还可以对 z 坐标进行一定偏移,虽然这里可能效果不明显,但常常会组合的对 xyz 进行 sin/cos 等操作,以后大家也会碰到(实际上后文就多次重复用到),所以这里先演示下。大家也可以自由发挥、随意尝试,不必局限本文所讲到的方法。

newPos.z += 0.05 * sin(newPos.y * 10.0 + uTime);

效果已经很漂亮了,大家可以休息下,喝杯奶茶或咖啡,好好欣赏享受下自己的成果。

背景用随机粒子进行点缀

上面都是油管教程里涉及到的内容,休息结束后,这次让我们加个餐、更进一步把原作里其他一些效果也简单实现下。

首先可以看到背景有点空,我们可以用随机粒子进行点缀,丰富画面效果。

通过 r 使得粒子在中心球体和球形粒子之外的范围,xyz 坐标随机在立方体空间内分布,这里都是简单的设置,所以怎么方便怎么来;sizes 控制每个粒子的随机大小并且会用作粒子移动速度,为了使值不为0,这样粒子速度不为0就不会停着不动,所以加了0.4。

const firefliesGeometry = new THREE.BufferGeometry();
const firefliesCount = 300;
const positions1 = new Float32Array(firefliesCount * 3);
const sizes = new Float32Array(firefliesCount);

for (let i = 0; i < firefliesCount; i++) {
  const r = Math.random() * 5 + 5;
  positions1[i * 3 + 0] = (Math.random() - 0.5) * r;
  positions1[i * 3 + 1] = (Math.random() - 0.5) * r;
  positions1[i * 3 + 2] = (Math.random() - 0.5) * r;

  sizes[i] = Math.random() + 0.4;
}

firefliesGeometry.setAttribute(
  "position",
  new THREE.BufferAttribute(positions1, 3)
);
firefliesGeometry.setAttribute("aSize", new THREE.BufferAttribute(sizes, 1));

顶点着色器里 gl_PointSize 乘上 aSize 改变大小,片元着色器里用每个粒子离中心的距离通过一个反比例函数 0.05/d-0.05*2.0 使得靠近中心为1,往外逐渐变成0,再设置成透明度值,从而实现出光斑、模糊圆形的效果。

const firefliesVertexShader = /* GLSL */ `
    uniform float uTime;
    attribute float aSize;

    void main() {
        vec3 newPos = position;
        // newPos.y += sin(uTime * 0.5 + newPos.x * 100.0) * aSize * 0.2;
        // newPos.x += sin(uTime * 0.5 + newPos.x * 200.0) * aSize * 0.1;
        vec4 mvPosition = modelViewMatrix * vec4(newPos, 1.0);
        gl_PointSize = 70.0 * aSize / -mvPosition.z;
        gl_Position = projectionMatrix * mvPosition;
    }
`;

  const firefliesFragmentShader = /* GLSL */ `
    void main() {
      float d = length(gl_PointCoord - vec2(0.5));
      float strength = clamp(0.05 / d - 0.05 * 2.0, 0.0, 1.0);
      gl_FragColor = vec4(vec3(1.0), strength);
  }
`;

const firefliesMaterial = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0 },
  },
  vertexShader: firefliesVertexShader,
  fragmentShader: firefliesFragmentShader,
  transparent: true,
  blending: THREE.AdditiveBlending,
  depthWrite: false,
});

const fireflies = new THREE.Points(firefliesGeometry, firefliesMaterial);
scene.add(fireflies);

通过曲线图看看透明度 strength 计算方式,当距离d=0.5时数值为0也就是圆圈边缘;d>0.5时数值为负数,通过clamp会变成0,完全透明;d=0.05/1.1=0.045时数值为1;d<0.045时数值大于1会被clamp取1,完全不透明,距离从0.045到0.5透明度快速降为0,从而实现模糊效果。

float d = length(gl_PointCoord - vec2(0.5));
float strength = clamp(0.05 / d - 0.05 * 2.0, 0.0, 1.0);
gl_FragColor = vec4(vec3(1.0), strength);

这种粒子实现方式还蛮常见。记得2022年4-5月份刚接触 shader 那会,碰上浙大125周年校庆,发现当年很特别官方搞了个求是星海的网站效果,就是粒子系统为主,里面粒子颜色透明度就是用这种反比例函数实现。

通过 Chrome 浏览器的 Spector.js 插件就能看到这里粒子的着色器里的 starIntensity() 函数,就是如此实现的。

在该网站输入校友专业和名字、认证通过后会生成每个校友专属的由“灿若繁星”的浙大人姓名的粒子系统组成的效果。网页还在,但需要输入信息才能生成,所以非校友的话看不到,只能看这里截图。

扯回来,和上面球形粒子一样这里用 sin 函数使背景随机粒子也运动起来,参数可以任意修改看效果。

vec3 newPos = position;
newPos.y += sin(uTime * 0.5 + newPos.x * 100.0) * aSize * 0.2;
newPos.x += sin(uTime * 0.5 + newPos.x * 200.0) * aSize * 0.1;

显示文字

接着模仿原作在中心球体和球形粒子之间放上文字。这里通过在长度2宽度1的 Plane 上显示纹理图片实现,准备一张白色文字、背景透明、1024x512 的图片。下面是截图的效果,而不是原图,不要保存这张图去直接使用。原图已传到 postimg 这个网站,下面是链接直接用就行。如果后续想将本文的效果修改后放自己的个人网站,那么这里就可以和原作一样换成自己的名字。

将图片通过 TextureLoader().load() 加载后作为 uTexture 传给 shader,然后通过 uv 采样纹理图,再搭配 textMaterial 设置 transparent 即可显示文字。text mesh 通过设置 position.z=1.7 移动到所需位置。

const textGeometry = new THREE.PlaneGeometry(2, 1, 100, 100);

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

  void main() {
    vUv = uv;

    vec3 newPos = position;
    // newPos.y += 0.06 * sin(newPos.x + uTime);
    // newPos.x += 0.1 * sin(newPos.x * 2.0 + uTime);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
  }
`;

const textFragment = /* GLSL */ `
  uniform sampler2D uTexture;
  varying vec2 vUv;

  void main() {
    vec4 color = texture2D(uTexture, vUv);
    gl_FragColor = color;
  }
`;

const textMaterial = new THREE.ShaderMaterial({
  vertexShader: textVertex,
  fragmentShader: textFragment,
  uniforms: {
    uTime: { value: 0 },
    uTexture: {
      value: new THREE.TextureLoader().load("https://i.postimg.cc/nrSTmrZk/text.png"), // './assets/text.png',
    },
  },
  transparent: true,
});

const text = new THREE.Mesh(textGeometry, textMaterial);
text.position.z = 1.7;
scene.add(text);

function render () {
  // ...
  sphereMaterial.uniforms.uTime.value = time;
  particleMaterial.uniforms.uTime.value = time;
  firefliesMaterial.uniforms.uTime.value = time;
  textMaterial.uniforms.uTime.value = time;
}

接着再一次应用 sin 函数改变顶点位置使 plane 发生扭曲,顶点移动后 uv 也随之移动,用 uv 采样纹理后就会有文字飘浮的效果。

vec3 newPos = position;
newPos.y += 0.06 * sin(newPos.x + uTime);
newPos.x += 0.1 * sin(newPos.x * 2.0 + uTime);

上方光线

最后古柳观察到原作顶部有光线效果,于是也顺带实现了下,不过因为文章篇幅已经不短,加上这部分解释起来比前几个效果复杂些,就暂时不在本文讲了,后续其他文章有机会再讲。大家这里看眼效果即可,感兴趣可自行实现。

小结

至此,古柳带大家把这个自己入坑 shader 的酷炫效果“简单”复现了下,中间偷懒多次用了 sin 函数,所以其实并没有大家想的那么复杂。

虽然和原作比起来还是很粗陋,各种参数、粒子动画、颜色搭配都有很大优化空间,效果远远不如原作漂亮,但是作为一篇教程里的例子,或许已足够让大家学到些东西,其他的优化就留给大家去自由发挥了。

还是可惜原作早就看不到了,只能从油管视频里瞥见一些片段效果,无法自己去交互、去体验、去学习。但两年前初见 Pepyaka、初识 shader 时的那份惊艳却伴随古柳至今,希望更多人看到本文后也能感受到那份惊艳,并能借本教程之力让那份惊艳不再局限于观赏,而是可以自己去一步步实现、一步步靠近,相信大家跟着古柳的系列文章一点点学下来,就不会再觉得这样的效果是自己无法理解、无法实现的了吧。

最后完整源码可见 Codepen。

相关阅读

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

照例

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

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

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