哟哈喽!这里是 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~