蒜物语——HTML与WebGL的融合之旅

2,390 阅读8分钟

哟哈喽!这里是alphardex。

Lycoris Recoil,又称“莉可丽丝”或“石蒜”,是笔者最近追完的一部番。这部番主要还是看美少女贴贴的日常,不论是OP的击股之交,泷奈的Sakana~还是千束神一般的闪避能力等都令我记忆犹新。尽管后面几集剧情可能有点争议,但不影响我对这部番的喜爱。

目前还有在追的一部新番孤独摇滚也不错,女主社恐的性格真的跟我十分相似(悲)。

最近我心血来潮,想为石蒜这部动画做一个自制的人物介绍页面,素材借用的是动画官网的素材,原本想做一个简单的包含CSS动画的Swiper滑动展示页,但转念一想,既然一直在研究WebGL,为何不搞点更炫的东西呢?于是乎,借助我的框架kokomi.js之力,我成功地将一个普通的HTML页面一步步地转化为了一个拥有酷炫交互的WebGL页面。

页面链接在下方,点击右上的logo全屏观看,效果最佳: code.juejin.cn/pen/7159562…

本文并不会详细地去教大家如何完整地做出整个页面的效果,而是来谈谈HTML转WebGL的方法以及WebGL特效的一些常用技法。

HTML

HTML是网页最基本的框架,相信大部分时候我们前端都是在跟她打交道吧~

首先,我像往常一样,勾勒了页面最基本的形态,将静态页面排好

由于是角色展示页,有多个角色的信息需要展示在一个页上,我使用了Swiper作为页面滑动的核心,再加上一些CSS动画,一个基本的原型就完成了

细心的读者会发现,如果将--webgl--上方的2行代码解除注释,就会看到最基本的Swiper滑动页效果

swiper.gif

这部分比较简单,因此一笔带过。接下来,让我们开始向重点——WebGL迈进

图片全屏动画特效

在页面上有这么一个交互:点击一张缩略图时,它会自动放大至占满全屏,并且中间的动画效果比较3D化(类似MAC的那种效果)

借助kokomi.js的Gallery组件,我们就能很方便地将所有的img图片元素转化到WebGL的世界中,并用顶点着色器和片元着色器来实现特效

const gallary = new kokomi.Gallery(base, {
  vertexShader, // 顶点着色器
  fragmentShader, // 片元着色器
  materialParams: {
    transparent: true, // 开启透明通道
  },
  scroller, // 设定页面滚动控制器
  elList: [...document.querySelectorAll("img:not(.webgl-fixed)")], // 需要同步的HTML元素
  uniforms: {
    // uniform变量
    ...
  },
});

首先,我们先把2个数据作为uniform传递给shader:图片在DOM世界的大小uMeshSize和图片位置uMeshPosition

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函数

vec3 fullscreen(vec3 p){
    // get progress
    float pr=uProgress;
    
    // scale to view size
    vec2 scale=mix(vec2(1.),iResolution/uMeshSize,pr);
    p.xy*=scale;
    
    // move to center
    p.x+=-uMeshPosition.x*pr;
    p.y+=-uMeshPosition.y*pr;
    
    // z
    p.z+=pr;
    
    return p;
}

从小图缩放到大图,最主要的还是一个缩放比例的插值过程,从原本的1变化到全屏与图片本身的比例,即iResolution/uMeshSize,再用动画进度pr进行插值即可

缩放至全屏后,图片依旧是停留在原地的,我们需要将它挪到画面的中心,给图片的xy坐标分别减去图片位置uMeshPosition的xy与动画进度pr的积

js中利用gsap控制动画进度即可

1-1.gif

nice,已经成功地实现了全屏动画效果,但这样还是有点普通,可以再稍微灵动一下

目前的动画进度pr是比较同步的,我们需要让它更加交错一点,可以尝试用图片uv坐标的x轴来交错它

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,不然图片会倒过来显示哦

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){
    // copy uv
    vec2 newUv=uv;
    
    // get progress
    float pr=getProgress2(uProgress,uv);
    
    // scale to view size
    vec2 scale=mix(vec2(1.),iResolution/uMeshSize,pr);
    p.xy*=scale;
    
    // other transforms
    p=flipX(p,pr);
    
    float latestStart=.5;
    float stepVal=latestStart-pow(latestStart,3.);
    newUv=flipUvX(newUv,step(stepVal,pr));
    
    // get uv
    vUv=newUv;
    
    // move to center
    p.x+=-uMeshPosition.x*pr;
    p.y+=-uMeshPosition.y*pr;
    
    // z
    p.z+=pr;
    
    return p;
}

1-2.gif

如此,我们的全屏动画效果就完美地实现了,当然,除了翻转效果外还会有很多其他的派生变体,这就等读者去自己发掘啦~

文字网格式显现特效

页面刚显现时我们可以看到,文字会以网格带阴影的形式显现出来,有点赛博朋克的风格

网页一般是由图片和文字组成的,既然图片能同步到WebGL世界,那么文字其实也可以,这里就要用到kokomi.js的MojiGroup组件,用法和Gallery组件大同小异,只不过是同步对象变成了文字而已

这里为什么不用中文字体?因为暂时找不到中文字体的cdn T_T

const mg = new kokomi.MojiGroup(base, {
  vertexShader: vertexShader,
  fragmentShader: fragmentShader,
  scroller,
  uniforms: {
    ...
  },
});

将句子长度uGridSize传入shader

this.mg.mojis.forEach((moji) => {
  moji.textMesh.mesh.material.uniforms.uGridSize.value =
    moji.textMesh.mesh._private_text.length;
});

利用噪声函数配合floor函数来形成网格状图案

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进行插值处理

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并进行插值处理

vec4 l1=vec4(uTextColor,1.);
float pr1=uProgress1;
float m1=getMixer(p,pr1,pattern);
col=mix(col,l1,m1);

js中利用gsap控制2个动画进度即可,可以用stagger属性来错开文字阴影的进度,以显得更加生动

最后的效果是这样的:

2.gif

背景微粒特效

我们通过观察,可以注意到画面的背景是朦胧的微粒漂浮效果,能给页面整体进行一种良好的点缀

可以通过kokomi.js的CustomPoints组件来实现微粒特效

首先要定义好微粒的geometry,这里传入了随机的顶点位置数据

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));

初始化微粒对象

const cm = new kokomi.CustomPoints(base, {
  baseMaterial: new THREE.ShaderMaterial(),
  geometry,
  vertexShader: vertexShader,
  fragmentShader: fragmentShader,
  materialParams: {
    side: THREE.DoubleSide,
    transparent: true,
    depthWrite: false,
  },
  uniforms: {
    ...
  },
});

在顶点着色器中,我们可以通过噪声函数来扭曲微粒的顶点坐标,以实现微粒随机飘动的效果

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传入的

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组件将其渲染到了整个屏幕上

最后实现效果如下,特地把微粒个数给调多了点,以让它更加明显:

3.gif

画面凸起特效

当我们点击右上角的头像切换人物或者用鼠标来滚动画面时,我们能看到那画面凸起的转场特效,可以说是很天马行空了

这种凸起特效是用后期处理实现的。kokomi.js的CustomEffect组件能轻松实现后期处理的效果,同样只需传入2个着色器以及uniform变量,不过以下的2个特效都只跟片元着色器有关

以圆的方式来扭曲整个屏幕的顶点,这就是凸起效果的要义

居中uv坐标,获取中心点向量,并用它对uv坐标进行偏移操作,如果直接乘上center是内凹,外凸的话反转一下就行了

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;
}

4.gif

画面RGB扭曲特效

当我们用鼠标随意在画面上移动时,可以观察到一种微妙的颜色变换效果,这就是著名的RGB扭曲特效

这里也用了全屏的后期处理,实现方法其实很简单:对画面进行3次采样,采样前偏移3个uv坐标,再分别获取采样后的rgb三个通道,将其合并即可

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函数,比较通用,在我之前写过的一些特效中也有用到

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;
}

5.gif

最后

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