哟哈喽!这里是 alphardex。
Lycoris Recoil,又称“莉可丽丝”或“石蒜”,是笔者最近追完的一部番。这部番主要还是看美少女贴贴的日常,不论是 OP 的击股之交,泷奈的 Sakana~还是千束神一般的闪避能力等都令我记忆犹新。尽管后面几集剧情可能有点争议,但不影响我对这部番的喜爱。
目前还有在追的一部新番孤独摇滚也不错,女主社恐的性格真的跟我十分相似(悲)。
最近我心血来潮,想为石蒜这部动画做一个自制的人物介绍页面,素材借用的是动画官网的素材,原本想做一个简单的包含 CSS 动画的 Swiper 滑动展示页,但转念一想,既然一直在研究 WebGL,为何不搞点更炫的东西呢?于是乎,借助我的框架kokomi.js之力,我成功地将一个普通的 HTML 页面一步步地转化为了一个拥有酷炫交互的 WebGL 页面。
页面链接在下方,点击右上的 logo 全屏观看,效果最佳:
https://code.juejin.cn/pen/7159562292110032900
本文并不会详细地去教大家如何完整地做出整个页面的效果,而是来谈谈 HTML 转 WebGL 的方法以及 WebGL 特效的一些常用技法。
HTML
HTML 是网页最基本的框架,相信大部分时候我们前端都是在跟她打交道吧~
首先,我像往常一样,勾勒了页面最基本的形态,将静态页面排好
由于是角色展示页,有多个角色的信息需要展示在一个页上,我使用了Swiper作为页面滑动的核心,再加上一些 CSS 动画,一个基本的原型就完成了
细心的读者会发现,如果将--webgl--上方的 2 行代码解除注释,就会看到最基本的 Swiper 滑动页效果

这部分比较简单,因此一笔带过。接下来,让我们开始向重点——WebGL 迈进
图片全屏动画特效
在页面上有这么一个交互:点击一张缩略图时,它会自动放大至占满全屏,并且中间的动画效果比较 3D 化(类似 MAC 的那种效果)
借助 kokomi.js 的Gallery 组件,我们就能很方便地将所有的img图片元素转化到 WebGL 的世界中,并用顶点着色器和片元着色器来实现特效
1 2 3 4 5 6 7 8 9 10 11 12 13
   | const gallary = new kokomi.Gallery(base, {   vertexShader,    fragmentShader,    materialParams: {     transparent: true,    },   scroller,    elList: [...document.querySelectorAll("img:not(.webgl-fixed)")],    uniforms: {          ...   }, });
  | 
 
首先,我们先把 2 个数据作为 uniform 传递给 shader:图片在 DOM 世界的大小uMeshSize和图片位置uMeshPosition
1 2 3 4 5 6 7 8 9 10
   | this.gallary.makuGroup.makus.forEach((maku) => {   maku.mesh.material.uniforms.uMeshSize.value = new THREE.Vector2(     maku.el.clientWidth,     maku.el.clientHeight   );   maku.mesh.material.uniforms.uMeshPosition.value = new THREE.Vector2(     maku.mesh.position.x,     maku.mesh.position.y   ); });
  | 
 
并且声明一个uProgress的变量,表示该动画的进度
接下来主要编写顶点着色器vertexShader,实现全屏动画fullscreen函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
   | vec3 fullscreen(vec3 p){          float pr=uProgress;
           vec2 scale=mix(vec2(1.),iResolution/uMeshSize,pr);     p.xy*=scale;
           p.x+=-uMeshPosition.x*pr;     p.y+=-uMeshPosition.y*pr;
           p.z+=pr;
      return p; }
  | 
 
从小图缩放到大图,最主要的还是一个缩放比例的插值过程,从原本的 1 变化到全屏与图片本身的比例,即iResolution/uMeshSize,再用动画进度pr进行插值即可
缩放至全屏后,图片依旧是停留在原地的,我们需要将它挪到画面的中心,给图片的 xy 坐标分别减去图片位置uMeshPosition的 xy 与动画进度pr的积
js 中利用 gsap 控制动画进度即可

nice,已经成功地实现了全屏动画效果,但这样还是有点普通,可以再稍微灵动一下
目前的动画进度pr是比较同步的,我们需要让它更加交错一点,可以尝试用图片 uv 坐标的 x 轴来交错它
1 2 3 4 5 6 7
   | float getProgress2(float pr,vec2 uv){     float activation=uv.x;     float latestStart=.5;     float startAt=activation*latestStart;     pr=smoothstep(startAt,1.,pr);     return pr; }
  | 
 
再者,我们也可以对图片坐标本身进行一些变换,比如翻转,尝试将坐标的 x 轴翻转下试试,同时,也别忘了翻转下 uv 坐标的 x,不然图片会倒过来显示哦
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
   | vec3 flipX(vec3 p,float pr){     p.x=mix(p.x,-p.x,pr);     return p; }
  vec2 flipUvX(vec2 uv,float pr){     uv.x=mix(uv.x,1.-uv.x,pr);     return uv; }
  vec3 fullscreen(vec3 p){          vec2 newUv=uv;
           float pr=getProgress2(uProgress,uv);
           vec2 scale=mix(vec2(1.),iResolution/uMeshSize,pr);     p.xy*=scale;
           p=flipX(p,pr);
      float latestStart=.5;     float stepVal=latestStart-pow(latestStart,3.);     newUv=flipUvX(newUv,step(stepVal,pr));
           vUv=newUv;
           p.x+=-uMeshPosition.x*pr;     p.y+=-uMeshPosition.y*pr;
           p.z+=pr;
      return p; }
  | 
 

如此,我们的全屏动画效果就完美地实现了,当然,除了翻转效果外还会有很多其他的派生变体,这就等读者去自己发掘啦~
文字网格式显现特效
页面刚显现时我们可以看到,文字会以网格带阴影的形式显现出来,有点赛博朋克的风格
网页一般是由图片和文字组成的,既然图片能同步到 WebGL 世界,那么文字其实也可以,这里就要用到 kokomi.js 的MojiGroup 组件,用法和 Gallery 组件大同小异,只不过是同步对象变成了文字而已
这里为什么不用中文字体?因为暂时找不到中文字体的 cdn T_T
1 2 3 4 5 6 7 8
   | const mg = new kokomi.MojiGroup(base, {   vertexShader: vertexShader,   fragmentShader: fragmentShader,   scroller,   uniforms: {     ...   }, });
  | 
 
将句子长度uGridSize传入 shader
1 2 3 4
   | this.mg.mojis.forEach((moji) => {   moji.textMesh.mesh.material.uniforms.uGridSize.value =     moji.textMesh.mesh._private_text.length; });
  | 
 
利用噪声函数配合floor函数来形成网格状图案
1 2 3 4
   | vec2 grid=uGrid; grid.x*=uGridSize; vec2 gridP=vec2(floor(grid.x*p.x),floor(grid.y*p.y)); float pattern=noise(gridP);
   | 
 
定义动画进度uProgress,并用网格状图案对文字颜色uTextColor进行插值处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
   | float map(float value,float min1,float max1,float min2,float max2){     return min2+(value-min1)*(max2-min2)/(max1-min1); }
  float saturate(float a){     return clamp(a,0.,1.); }
  float getMixer(vec2 p,float pr,float pattern){     float width=.5;     pr=map(pr,0.,1.,-width,1.);     pr=smoothstep(pr,pr+width,p.x);     float mixer=1.-saturate(pr*2.-pattern);     return mixer; }
  void main(){     ...     vec4 col=vec4(0.);
      vec4 l0=vec4(uShadowColor,1.);     float pr0=uProgress;     float m0=getMixer(p,pr0,pattern);     col=mix(col,l0,m0);
      ... }
  | 
 
同样的可以定义文字阴影uShadowColor,以同样的方式定义另一个动画进度uProgress1并进行插值处理
1 2 3 4
   | vec4 l1=vec4(uTextColor,1.); float pr1=uProgress1; float m1=getMixer(p,pr1,pattern); col=mix(col,l1,m1);
   | 
 
js 中利用 gsap 控制 2 个动画进度即可,可以用stagger属性来错开文字阴影的进度,以显得更加生动
最后的效果是这样的:

背景微粒特效
我们通过观察,可以注意到画面的背景是朦胧的微粒漂浮效果,能给页面整体进行一种良好的点缀
可以通过 kokomi.js 的CustomPoints 组件来实现微粒特效
首先要定义好微粒的geometry,这里传入了随机的顶点位置数据
1 2 3 4 5 6 7 8 9 10 11 12
   | const geometry = new THREE.BufferGeometry();
  const posBuffer = kokomi.makeBuffer(count, () =>   THREE.MathUtils.randFloatSpread(3) ); kokomi.iterateBuffer(posBuffer, posBuffer.length, (arr, axis) => {   arr[axis.x] = THREE.MathUtils.randFloatSpread(3);   arr[axis.y] = THREE.MathUtils.randFloatSpread(3);   arr[axis.z] = 0; });
  geometry.setAttribute("position", new THREE.BufferAttribute(posBuffer, 3));
   | 
 
初始化微粒对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14
   | const cm = new kokomi.CustomPoints(base, {   baseMaterial: new THREE.ShaderMaterial(),   geometry,   vertexShader: vertexShader,   fragmentShader: fragmentShader,   materialParams: {     side: THREE.DoubleSide,     transparent: true,     depthWrite: false,   },   uniforms: {     ...   }, });
  | 
 
在顶点着色器中,我们可以通过噪声函数来扭曲微粒的顶点坐标,以实现微粒随机飘动的效果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
   | vec3 distort(vec3 p){     float speed=.1;     float noise=cnoise(p)*.5;     p.x+=cos(iTime*speed+p.x*noise*100.)*.2;     p.y+=sin(iTime*speed+p.x*noise*100.)*.2;     p.z+=cos(iTime*speed+p.x*noise*100.)*.5;     return p; }
  void main(){     vec3 p=position;
      vec3 dp=distort(p);
      csm_Position=dp;
      vUv=uv;
      ... }
  | 
 
在片元着色器中,我们可以定义好微粒的形状和颜色,这里形状用了圆形,颜色是通过 uniform 传入的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
   | float circle(float d,float size,float blur){     float c=smoothstep(size,size*(1.-blur),d);     float ring=smoothstep(size*.8,size,d);     c*=mix(.7,1.,ring);     return c; }
  void main(){     float distanceToCenter=distance(gl_PointCoord,vec2(.5));     float strength=circle(distanceToCenter,.5,.4);
      vec3 col=uColor;
      csm_DiffuseColor=vec4(col,strength); }
  | 
 
由于微粒效果本身的大小并不跟外面的ScreenCamera一致,因此创建了一个ScreenQuad 组件,再通过RenderTexture 组件将其渲染到了整个屏幕上
最后实现效果如下,特地把微粒个数给调多了点,以让它更加明显:

画面凸起特效
当我们点击右上角的头像切换人物或者用鼠标来滚动画面时,我们能看到那画面凸起的转场特效,可以说是很天马行空了
这种凸起特效是用后期处理实现的。kokomi.js 的CustomEffect 组件能轻松实现后期处理的效果,同样只需传入 2 个着色器以及 uniform 变量,不过以下的 2 个特效都只跟片元着色器有关
以圆的方式来扭曲整个屏幕的顶点,这就是凸起效果的要义
居中 uv 坐标,获取中心点向量,并用它对 uv 坐标进行偏移操作,如果直接乘上center是内凹,外凸的话反转一下就行了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
   | vec2 centerUv(vec2 uv){     uv=uv*2.-1.;     return uv; }
  vec2 distort(vec2 p){     vec2 cp=centerUv(p);     float center=distance(p,vec2(.5));     vec2 offset=cp*(1.-center)*uProgress;     p-=offset;     return p; }
  void main(){     vec2 p=vUv;     p=distort(p);
      vec4 tex=texture(tDiffuse,p);
      vec4 col=tex;
      gl_FragColor=col; }
  | 
 

画面 RGB 扭曲特效
当我们用鼠标随意在画面上移动时,可以观察到一种微妙的颜色变换效果,这就是著名的 RGB 扭曲特效
这里也用了全屏的后期处理,实现方法其实很简单:对画面进行 3 次采样,采样前偏移 3 个 uv 坐标,再分别获取采样后的 rgb 三个通道,将其合并即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
   | vec4 RGBShift(sampler2D t,vec2 rUv,vec2 gUv,vec2 bUv){     vec4 color1=texture(t,rUv);     vec4 color2=texture(t,gUv);     vec4 color3=texture(t,bUv);     vec4 color=vec4(color1.r,color2.g,color3.b,color2.a);     return color; }
  void main(){     vec2 p=vUv;     p=distort(p);
      float mask=1.-getCircle(uMaskRadius/uDevicePixelRatio);     float r=mask*uMouseSpeed*.5;     float g=mask*uMouseSpeed*.525;     float b=mask*uMouseSpeed*.55;     vec4 tex=texture(tDiffuse,p);
      vec4 col=tex;
      gl_FragColor=col; }
  | 
 
这里做成了鼠标悬浮到某块区域才触发的特效,因此有了以下的getCircle函数,比较通用,在我之前写过的一些特效中也有用到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
   | float circle(vec2 st,float r,vec2 v){     float d=length(st-v);     float c=smoothstep(r-.2,r+.2,d);     return c; }
  float getCircle(float radius){     vec2 viewportP=gl_FragCoord.xy/iResolution/uDevicePixelRatio;     float aspect=iResolution.x/iResolution.y;
      vec2 m=iMouse.xy/iResolution.xy;
      vec2 maskP=viewportP-m;     maskP/=vec2(1.,aspect);     maskP+=m;
      float r=radius/iResolution.x;     float c=circle(maskP,r,m);
      return c; }
  | 
 

最后
希望本文能给你创作新特效的灵感,keep creating~