前端視覺交互——頂點動畫實現圖片過度
來自專欄 Paradise - 前端視覺交互研究28 人贊了文章
在我的BLOG上觀看獲得最佳體驗。
Code: github.com/dtysky/paradise/tree/master/src/collection/ImageFragmentTransition
Demo: paradise.dtysky.moe/effect/image-fragment-transition
https://www.zhihu.com/video/1004130596435726336
原理
這是一個非典型的頂點著色器實現的頂點動畫的例子。它構造了一個可被打碎的平面,使得我可以在碎片化的過程中對兩張圖片做平滑過渡。
使用頂點構成三角片
看到這裡,需要讀者對OpenGL圖形繪製底層有基本的了解。我們知道,OpenGL繪製一個曲面,本質上繪製的是一個個頂點構成的一個個三角面,這一個個三角面組合起來,便形成了整體的曲面。而這些頂點數據都是存儲在buffer中的,我們需要一開始生成一個buffer,然後將其作為attributes一次性提交給GPU,之後就可以通過少量uniform變數控制渲染啦。
以ThreeJS為例,為了繪製一個三角形,我們需要進行幾個步驟:
- 構造基於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;
- 編寫shader,構造材質:
const material = new THREE.RawShaderMaterial({ uniforms, vertexShader: shaders.vertex, fragmentShader: shaders.fragment });material.needsUpdate = true;
在這裡,我們傳入構造好的uniforms(主要是為了傳入紋理和各個矩陣,有時也是為了動畫),加之vertexShader和fragmentShader,便可以完成材質的構造。
- 生成曲面:
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
,將兩個紋理按照不同的權重混色起來即可。
推薦閱讀: