前端视觉交互——顶点动画实现图片过度

少女dtysky

世界Skill

时刻2018.07.21

Code: github.com/dtysky/paradise/tree/master/src/collection/ImageFragmentTransition

Demo: paradise.dtysky.moe/effect/image-fragment-transition

原理

这是一个非典型的顶点着色器实现的顶点动画的例子。它构造了一个可被打碎的平面,使得我可以在碎片化的过程中对两张图片做平滑过渡。

使用顶点构成三角片

看到这里,需要读者对OpenGL图形绘制底层有基本的了解。我们知道,OpenGL绘制一个曲面,本质上绘制的是一个个顶点构成的一个个三角面,这一个个三角面组合起来,便形成了整体的曲面。而这些顶点数据都是存储在buffer中的,我们需要一开始生成一个buffer,然后将其作为attributes一次性提交给GPU,之后就可以通过少量uniform变量控制渲染啦。

以ThreeJS为例,为了绘制一个三角形,我们需要进行几个步骤:

  1. 构造基于Buffer的几何体并生成顶点数据:
function disposeArray() {
  this.array = null;
}

const geometry = new THREE.BufferGeometry();
const positions = [x0, y0, z0, x1, y1, z1, x2, y2, z2];

geometry.addAttribute('position', new THREE.Float32BufferAttribute(positions, 3).onUpload(disposeArray));

注意这里最后一句,我们将positions中的每三个数据为一组(x, y, z),将其作为attribute position 提交给GPU,并在提交后回收掉CPU这边的数组,避免内存浪费。提交之后,我们便可以在shader中使用数据了,注意shader中是针对每个顶点做处理,所以拿到的自然是vec3变量:

attribute vec3 position;
  1. 编写shader,构造材质:
const material = new THREE.RawShaderMaterial({
    uniforms,
    vertexShader: shaders.vertex,
    fragmentShader: shaders.fragment
  });
material.needsUpdate = true;  

在这里,我们传入构造好的uniforms(主要是为了传入纹理和各个矩阵,有时也是为了动画),加之vertexShader和fragmentShader,便可以完成材质的构造。

  1. 生成曲面:
const mesh = new THREE.Mesh(geometry, material);

将曲面添加到场景后,启动渲染,便可以看到一个三角片了。

使用三角片构造平面

光有一个三角片当然不够,接下来我们要构造一个平面。这其实也很简单,依样画葫芦重复构造三角片就OK。让我们想想——一个矩形该如何用三角形构造?当然是用两个对称的三角形拼起来啦:

const positions = [l, t, 0, r, t, 0, l, b, 0, l, b, 0, r, b, 0, r, t, 0];

这里我构造了两个拼在一起的三角形,拼在一起就构成了矩形,其中l、r、t、b分别表示矩形的左右上下边界,由于是在xy平面,z都是0。

然而只是这样还是不够,我们需要的是碎片,很多很多碎片,不过这也不难办,只要将一个大矩形分隔成若干个小矩形,再将每个小矩形分割成两个三角片不就好了嘛:

const stepX = .1;
const stepY = .05;
const hStepX = stepX;
const hStepY = stepY;

for (let x = left; x < right; x += stepX) {
  for (let y = top; y < bottom; y += stepY) {
    const xL = x;
    const xR = x + hStepX;
    const yT = y;
    const yB = y + hStepY;

    positions.push(xL, yT, 0);
    positions.push(xL, yB, 0);
    positions.push(xR, yB, 0);
    positions.push(xL, yT, 0);
    positions.push(xR, yT, 0);
    positions.push(xR, yB, 0);
  }
}

其中stepX和stepY分别为小矩形的宽度和高度,通过这段代码,我们便生成了很多个小碎三角片构成的大矩形。

着色

上面说的都是如何生成顶点,但还有很重要的一步没说——如何给三角片着色?有过一定基础的同学应该都知道,在fragment shader中我们一般是通过uv坐标来采样纹理输出颜色,大家有没想过这个uv是从哪来的?不错,这个uv实际上是从vertex shader中的attribute变量传来的,而这个attribute和position一样,都是在CPU中算好(存储在模型顶点数据中)的:

for (let x = left; x < right; x += stepX) {
  for (let y = top; y < bottom; y += stepY) {
    // positions
    ......

    uvs.push((xL + right) / width, (yT + bottom) / height);
    uvs.push((xL + right) / width, (yB + bottom) / height);
    uvs.push((xR + right) / width, (yB + bottom) / height);
    uvs.push((xL + right) / width, (yT + bottom) / height);
    uvs.push((xR + right) / width, (yT + bottom) / height);
    uvs.push((xR + right) / width, (yB + bottom) / height);
  }
}

geometry.addAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2).onUpload(disposeArray));

在这里,我们计算了每个顶点的uv坐标,并一并作为uv这个attribute提交给了GPU,然后便可以在shader中使用:

attribute vec2 uv;

打碎!添加attribute连接顶点

到此为止,我们应该可以渲染出来一张正常的图片了,我知道你们想说什么:费这么大事就为了渲染张图片?不要急,我们接下来只需要一点小技巧,便可以实现一个简单的碎片化效果:

vec3 new_position = position;
new_position.z += position.x;

gl_Position = projectionMatrix * modelViewMatrix * vec4(new_position, 1.0);

通过这几句代码,我们将每个顶点的z坐标位移了其x坐标的距离,理论上,我这么写想达到一个“从左到右,三角片一层一层铺开”的效果。然而事与愿违,如果你去运行这段代码,会发现整个图片还是连续的,只不过发生了在x-z平面的斜切罢了。想一想,会发生这种状况的原因是什么?其实很简单,对于两个相邻的三角片,它们的三个顶点中有两个是重合的,重合的顶点自然会如果不加处理,它们的位置变换将会保持一致,这样一来,所有重合的顶点其实可以视为一个顶点,所以才会导致变换后的图像仍然连续。

为了解决这个问题,我们要给同不同三角片的各个顶点不同的新attribute变量,来表明它们不同于重合点的属性。比如在3D模型中,有法线normal这个属性,它表明顶点的法线方向。在这个例子中,我们可以构造个叫做centre的属性,其表明每个三角片的中心点,然后再让三个顶点都拥有同样的centre属性:

for (let x = left; x < right; x += stepX) {
  for (let y = top; y < bottom; y += stepY) {
    // positions, uvs
    ......

    for (let i = 0; i < 3; i += 1) {
      centres.push(xL + (xR - xL) / 4, (yT + yB) / 2, 0);
    }

    for (let i = 0; i < 3; i += 1) {
      centres.push(xR - (xR - xL) / 4, (yT + yB) / 2, 0);
    }
  }
}

geometry.addAttribute('centre', new THREE.Float32BufferAttribute(centres, 3).onUpload(disposeArray));

之后顶点的变换都以这个中心点的位置为基准,如此一来,便可以区分每个重合但不在同一个三角面的顶点了:

new_position.z += centre.x;

动起来

现在,我们已经可以静态地打碎一张我们为其赋予纹理的图片了,但怎么让这个打碎的过程动起来呢?

这里的方案是引入外部uniform变量progress,这个变量是一个范围是0~1的自增变量,它表明运动的进度。结合这个变量和一些其他的变量,加之自己喜欢的顶点变换逻辑(公式),我们便可以实现很多惊艳的效果,比如此例中,我以图片中心点和顶点的距离为基准,结合progress,使用三角函数来控制x、y坐标,并赋予每个顶点的z坐标一定的差值,最终加上旋转让整个效果更富有动感:

attribute vec3 position;
attribute vec3 centre;
attribute vec2 uv;

uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform float progress;
uniform float top;
uniform float left;
uniform float width;
uniform float height;

varying vec2 vUv;

vec3 rotate_around_z_in_degrees(vec3 vertex, float degree) {
    float alpha = degree * 3.14 / 180.0;
    float sina = sin(alpha);
    float cosa = cos(alpha);
    mat2 m = mat2(cosa, -sina, sina, cosa);
    return vec3(m * vertex.xy, vertex.z).xyz;
}

void main() {
    vUv = uv;
    vec3 new_position = position;
    vec3 center = vec3(left + width * 0.5, top + height * 0.5, 0);
    vec3 dist = center - centre;
    float len = length(dist);
    float factor;

    if (progress < 0.5) {
        factor = progress;
    } else {
        factor = (1. - progress);
    }

    float factor1 = len * factor * 10.;
    new_position.x -= sin(dist.x * factor1);
    new_position.y -= sin(dist.y * factor1);
    new_position.z += factor1;
    new_position = rotate_around_z_in_degrees(new_position, progress * 360.);

    gl_Position = projectionMatrix * modelViewMatrix * vec4(new_position, 1.0);
}

两张图片自然过渡

顶点动画到这里就结束了,对于本效果,最后还需要考虑的一点是如何让两张图片能自然得过渡。其实这一点在上面的vertex shader中就有所体现了——我以0.5为分界点,将整个运动的周期分为了两部分,第一部分平面逐渐被打碎,第二部分碎片逐渐收缩回平面。

而配合vertex shader,适当得编写fragment shader便可以轻松完成两个纹理的自然过渡:

uniform sampler2D image1;
uniform sampler2D image2;
uniform float progress;

varying vec2 vUv;

void main() {
  vec4 t_image;
  vec4 t_image1 = texture2D(image1, vUv);
  vec4 t_image2 = texture2D(image2, vUv);

  t_image = progress * t_image1 + (1. - progress) * t_image2;

  gl_FragColor = t_image;
}

利用progress,将两个纹理按照不同的权重混色起来即可。

如果不是自己的创作,少女是会标识出来的,所以要告诉别人是少女写的哦。