大家好,本文分享的是如何生成簡單動畫讓圖形動起來。
在視覺化展現中,動畫它是強化資料表達,吸引使用者的重要技術手段。
在具體實現動畫之前,我們先來了解一下動畫的三種形式,分別是固定幀動畫、增量動畫和時序動畫。
固定幀動畫的實現,是使用已生成的靜態影像,然後將這些影像依次播放,而後面兩種,增量動畫和時序動畫,都是需要動態繪製影像。可想而知,後面這兩種動畫形式會更靈活一些。
接下來,我們就來了解如何在HTML/CSS和Shader中實現動畫效果。
HTML/CSS
首先,我們來了解如何在HTML/CSS中實現動畫。
固定幀動畫
先來看固定幀動畫的一個例子,這個程式碼實現的是一個飛動的小鳥。
e.g.動態的小鳥
<!-- 固定幀動畫 -->
<div v-show="checkedTab === 0" style="position: relative;">
<div class="fixed-frame"></div>
</div>
/*固定幀動畫*/
.fixed-frame {
position: absolute;
left: 100px;
top: 100px;
width: 86px;
height: 60px;
zoom: 0.5;
background-repeat: no-repeat;
background-image: url("@/assets/bird.png");
background-position: -178px -2px;
animation: flappy .5s step-end infinite;
}
@keyframes flappy {
0% {background-position: -178px -2px;}
33% {background-position: -90px -2px;}
66% {background-position: -2px -2px;}
}
很顯然,在實現這個固定幀動畫之前,我們需要預先準備好靜態圖片,這個例子中我們使用的是雪碧圖,也叫CSS精靈,是將小圖合併在一起形成的圖片,在這裡我們設定background-image來指定背景圖,然後透過animation動態修改background-position來逐幀切換,最終形成一個動態的效果。當然如果我們使用的是多張圖片,直接切換background-image也是可以的。
其中step-end 會使 keyframes 動畫到了定義的關鍵幀處直接突變,沒有變化的過程。
透過這個例子我們能發現,固定幀動畫實現起來非常簡單,比較適合的場景是提供現成圖片的動畫幀影像,如果要去動態繪製影像,就不太合適。如果要生成動態繪製的影像,也就是非固定幀動畫,通常會使用另外兩種方式。
增量動畫
先來看增量動畫,其實從名稱上看,我們就能有一個大致的概念,增量嘛,就是增加數量,所以增量動畫就是在動畫的每一幀給屬性一個增量。
下面是一個簡單的旋轉方塊的動畫例子,是一個旋轉的藍色方塊。
<!-- 增量動畫 -->
<div style="position: relative;">
<div class="increase-frame" ref="increaseRef"></div>
</div>
/*增量動畫*/
.increase-frame {
position: absolute;
left: 100px;
top: 100px;
width: 100px;
height: 100px;
background-color: blue;
transform-origin: 50% 50%;
}
let rotation = 0;
requestAnimationFrame(function update() {
increaseRef.value.style.transform = `rotate(${rotation ++}deg)`;
requestAnimationFrame(update);
});
以上動畫實現的關鍵邏輯就在於修改rotation的值,在每次繪製的時候將它加1。
這種繪製方式實現起來也比較簡單,但是它不太容易去控制動畫的細節,比如動畫週期、變化率、軌跡等等;而且它定義的是狀態變化,也就是根據上一刻的狀態來計算得到下一刻的狀態,這種方式在Shader中實現起來並不太方便,需要像上篇所提到的那樣,去使用後期通道來進行處理,很顯然,這樣做會比較繁瑣。
所以如果是比較複雜的動畫,我們一般透過定義時間和動畫函式來實現,也就是透過時序動畫的方式來實現動畫效果。
時序動畫
關於如何去實現時序動畫,我們也直接來看個例子。
e.g.旋轉的藍色方塊
const startAngle = 0;
const T = 2000; // 週期。旋轉這一週的時間
let startTime = null;
function update() {
startTime = startTime === null ? Date.now() : startTime;
const p = (Date.now() - startTime) / T;
const angle = startAngle + p * 360;
timeOrderRef.value.style.transform = `rotate(${angle}deg)`;
requestAnimationFrame(update);
}
update();
這段程式碼中,我們定義了三個變數,startAngle是起始旋轉角度,T是旋轉週期,代表完成一次動畫、一次旋轉需要的時間,startTime表示每一次動畫的開始時間。
在update函式中,我們透過Date.now() - startTime
去得到當前經過的時間,然後除以週期T,就能得到旋轉進度 p ,最後根據起始旋轉角度和進度 p,計算得到旋轉角度angle,並且賦值給transform屬性,這樣就實現了旋轉動畫。
根據這個例子,我們可以將時序動畫的實現總結為三個步驟:
第一步,定義初始時間和週期;
第二步,在update中計算當前經過的時間和進度;
第三步,透過進度來更新動畫元素的屬性。
時序動畫的優點是,可以更直觀、精確地控制動畫的週期(也是速度)等引數;它的缺點就是寫法相對比較複雜,但是因為它的優點、可以更好控制動畫的效果,所以在動畫實現中最為常用。
標準動畫模型
既然時序動畫是最常用的動畫實現形式,那麼我們可以把它的三個步驟抽象成標準的動畫模型,來方便後續的動畫實現。
-
首先,定義一個類、Timing用於處理時間
/** * 用於處理動畫的時間 */ export class Timing { constructor({duration, iterations = 1} = {}) { this.startTime = Date.now(); this.duration = duration; // 週期 this.iterations = iterations; // 重複次數 } /** * 動畫經過的時間 * @returns {number} */ get time() { return Date.now() - this.startTime; } /** * 動畫進度 * @returns {number|number} */ get p() { // 動畫持續了幾個週期 const progress = Math.min(this.time / this.duration, this.iterations); // 動畫已結束:進度1 // 動畫未結束:0~1 return this.isFinished ? 1 : progress % 1; } /** * 動畫是否已結束 * @returns {boolean} */ get isFinished() { // 動畫持續了幾個週期是否已達到指定次數 return this.time / this.duration >= this.iterations; } }
這幾個方法都很容易理解。
-
然後,實現一個Animator類,用於控制動畫過程。
export class Animator { constructor({duration, iterations}) { this.timingParam = {duration, iterations}; } /** * 執行動畫 * @param target * @param update * @returns {Promise<unknown>} */ animate(target, update) { let frameIndex = 0; // 幀序號 const timing = new Timing(this.timingParam); return new Promise(resolve => { function next() { // 透過執行update更新動畫 if(update({target, frameIndex, timing}) !== false && !timing.isFinished) { requestAnimationFrame(next); } else { resolve(timing); } frameIndex ++; } next(); }) } }
animate
方法,會在執行時建立一個timing物件,最後返回一個promise物件。這裡透過執行update更新動畫,在動畫結束時,resolve這個promise。
現在我們就可以使用這個模型,來嘗試實現動畫效果了。來看下面這個例子。
在這個例子中,我們讓每個方塊轉動的週期是1秒,一共旋轉1.5個週期(也就是540度)。
<div class="container">
<div class="block"></div>
<div class="block"></div>
<div class="block"></div>
<div class="block"></div>
</div>
const blocks = document.querySelectorAll('.block');
const animator = new Animator({duration: 1000, iterations: 1.5});
(async function() {
let i = 0;
while(true) {
await animator.animate(blocks[i++ % 4], ({target, timing}) => {
target.style.transform = `rotate(${timing.p * 360}deg)`;
});
}
}());
.container {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 300px;
}
.block {
width: 100px;
height: 100px;
margin: 20px;
flex-shrink: 0;
transform-origin: 50% 50%;
&:nth-child(1) {background-color: red;}
&:nth-child(2) {background-color: blue;}
&:nth-child(3) {background-color: green;}
&:nth-child(4) {background-color: orange;}
}
可以看到,這個效果我們很方便地透過前面定義的Animator實現了。
插值與緩動函式
在前面的例子中,我們看到的動畫效果都是勻速運動的,影像是勻速變化的,顯然在實際中這是不夠滿足需求的,既然時序動畫可以讓我們更容易地控制動畫的細節,所以它也可以讓我們實現一些不規則的運動。
假設已知元素的起始狀態、結束狀態和運動週期,如果想要讓它進行不規則運動,我們可以使用插值的方式來控制每一幀的展現。
下面我們來看一個動畫:這是一個勻速運動的方塊,我們用Animator實現,讓這個方塊從100px處勻速運動到400px。
const block = document.querySelector('.block');
const animator = new Animator({duration: 3000});
document.addEventListener('click', () => {
animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
這裡我們用了一個線性插值方法:left = start * (1 - p) + end * p
。線性插值可以很方便地實現屬性的均勻變化,所以用它來讓方塊做勻速運動是非常簡單的。
如果要讓方塊進行非勻速運動,比如勻加速運動,我們仍然可以用線性插值的方式,只不過要對引數 p 做一個函式對映。比如要讓方塊做初速度為0的勻加速運動,我們可以將 p 對映為p 的平方;如果要讓方塊做末速度為0的勻減速運動,可以將p對映為p*(2-p)。那為什麼是這樣對映呢?
這就要提到勻加速和勻減速的物理計算公式了。有些小夥伴很久沒接觸物理公式,可能會有些遺忘,這裡簡單回顧一下。
假設,某個物體在做初速度為0的勻加速運動,運動的總時間為T,總位移為S。那麼,它的加速度和在 t 時刻的位移的計算公式是這樣的:
所以在勻加速運動中,我們把 p 對映為 p 的平方。
同樣的,如果物體在做勻減速運動,那麼,它的加速度和在 t 時刻的位移的計算公式是這樣的:
所以在勻減速運動中,我們把 p 對映為 p*(2 - p)。
在實際應用中,我們還可以對p 應用更多對映,來實現不同的動畫效果,為了方便實現更多的效果,我們可以抽象出一個函式來專門處理p的對映,這個函式就叫做緩動函式。
我們可以在Timing類中直接增加一個緩動函式easing,在獲取p 的時候,直接用 this.easing(progress % 1)
取代progress %1
。
現在我們可以來嘗試使用下緩動函式。
const animator2 = new Animator({duration: 3000, easing: p => p ** 2});
document.addEventListener('click', () => {
animator2.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
緩動函式有很多種,實際中比較常用的是貝塞爾曲線緩動,我們可以使用現成的JavaScript庫bezier-easing來生成貝塞爾緩動函式,比如:
const animator3 = new Animator({duration: 3000, easing: BesizerEasing(0.5, -1.5, 0.5, 2.5)});
document.addEventListener('click', () => {
animator3.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
const left = start * (1 - p) + end * p;
el.style.left = `${left}px`;
});
});
貝塞爾緩動函式有很多種,大家可以參考easing.net這個網站,嘗試利用裡面提供的緩動函式。
看到這裡,關於如何去實現動畫,相信大家都有一定的思路了。那麼現在我們也可以嘗試在Shader中去實現動畫效果。
Shader
固定幀動畫
首先我們還是先來看固定幀動畫的實現。
直接來看具體的例子,還是之前那個飛動的小鳥的例子。
// 片元著色器
varying vec2 vUv;
uniform sampler2D tMap;
uniform float fWidth;
uniform vec2 vFrames[3]; // 3個二維向量,二維向量表示每一幀動畫的圖片起始x和結束x座標
uniform int frameIndex;
void main() {
vec2 uv = vUv;
for (int i = 0; i < 3; i ++) {
// 紋理座標ux.x的取值範圍
// 第0幀:[2/272, 88/272] 約等於 [0.007,0.323]
// 第1幀:[90/272, 176/272] 約等於 [0.330,0.647]
// 第2幀:[178/272, 264/272] 約等於 [0.654,0.970]
uv.x = mix(vFrames[i].x, vFrames[i].y, vUv.x) / fWidth; // vUv 到 uv的對映
if(float(i) == mod(float(frameIndex), 3.0)) break; // frameIndex除3的餘數:0-迴圈一次;1-迴圈兩次;2-迴圈三次。(渲染第幾幀)
}
vec4 color = texture2D(tMap, uv); // 按照uv座標取色值
gl_FragColor = color;
}
我們在片元著色器中獲取紋理,透過紋理座標讀取影像上的畫素資訊。
vFrames是一個重要的引數,包含3個二維向量,每一個二維向量表示一幀圖片的起始x和結束x座標。
for迴圈是main函式中的關鍵部分,在迴圈內部,我們用二維向量中的兩個座標,來計算插值,最後除以圖片的總寬度,得到一個 vUv 到 uv 座標對映。
在對紋理進行取樣時,我們就用這個uv的座標值去進行顏色提取。
然後看JavaScript部分的程式碼:
(async function() {
renderer.uniforms.tMap = await renderer.loadTexture(birdpng);
renderer.uniforms.vFrames = [2, 88, 90, 176, 178, 264];
renderer.uniforms.fWidth = 272;
renderer.uniforms.frameIndex = 0;
setInterval(() => {
renderer.uniforms.frameIndex ++;
}, 200);
// 頂點座標(WebGL畫布繪製範圍)
const x = 43 / glRef.value.width; // 每幀的寬度(86/2)
const y = 30 / glRef.value.height; // 每幀的高度(60/2)
renderer.setMeshData([{
positions: [
[-x, -y],
[-x, y],
[x, y],
[x, -y]
],
attributes: {
uv: [
[0, 0],
[0, 1],
[1, 1],
[1, 0]
]
},
cells: [
[0, 1, 2],
[2, 0, 3]
]
}]);
renderer.render();
}());
我們按照每幀圖片的寬高比例設定了頂點座標的範圍,vFrames陣列儲存的是每一幀影像對應的x座標範圍,動畫切換的關鍵程式碼就是setInterval中的frameIndex ++。
可以看到在Shader中實現固定幀動畫也是比較簡單的。
非固定幀動畫
對於非固定幀動畫,因為時序動畫是最常用的實現形式,所以我們直接看時序動畫。
大家都知道,在WebGL中有兩類著色器,那麼對動畫的實現應該寫在哪類著色器中呢?答案是,兩個都可以。
頂點著色器
我們先來看頂點著色器的例子。
attribute vec2 a_vertexPosition;
attribute vec2 uv;
uniform float rotation;
void main() {
gl_PointSize = 1.0;
float c = cos(rotation);
float s = sin(rotation);
mat3 transformMatrix = mat3(
c, s, 0,
-s, c, 0,
0, 0, 1
);
vec3 pos = transformMatrix * vec3(a_vertexPosition, 1); // 對映新的座標
gl_Position = vec4(pos, 1);
}
這段程式碼中我們要實現的是一個旋轉的紅色方塊。在這裡我們用到了旋轉矩陣,對於transform不熟悉的小夥伴可以參考我之前的文章《CSS transform與仿射變換》。
在Shader中會繪製出一個紅色的正方形,然後三維的齊次矩陣會讓這個紅色方塊旋轉起來。我們可以直接透過下面這段JavaScript去動態更新旋轉的角度rotation,就能看到動畫效果了:
// ...
renderer.uniforms.rotation = 0.0;
requestAnimationFrame(function update() {
renderer.uniforms.rotation += 0.05;
requestAnimationFrame(update);
});
// ...
我們也可以使用前面定義的Animator物件去更精確地控制圖形的旋轉效果。
// ...
renderer.uniforms.rotation = 0.0;
const animator = new Animator({duration: 2000, iterations: Infinity});
animator.animate(renderer, ({target, timing}) => {
target.uniforms.rotation = timing.p * 2 * Math.PI;
});
// ...
可以看到,這裡更新uniform屬性和前面更新HTML元素的屬性,這兩種操作從程式碼上看很相似。
片元著色器
接著我們來看片元著色器的例子。
varying vec2 vUv;
uniform vec4 color;
uniform float rotation;
void main() {
vec2 st = 2.0 * (vUv - vec2(0.5));
float c = cos(rotation);
float s = sin(rotation);
mat3 transformMatrix = mat3(
c, s, 0,
-s, c, 0,
0, 0, 1
);
vec3 pos = transformMatrix * vec3(st, 1.0); // 座標系旋轉
float d1 = 1.0 - smoothstep(0.5, 0.505, abs(pos.x)); // abs(x)<0.5 d1=1
float d2 = 1.0 - smoothstep(0.5, 0.505, abs(pos.y)); // abs(y)<0.5 d2=1
gl_FragColor = d1 * d2 * color;
}
這段程式碼中,我們透過距離場著色的方式繪製了正方形,同樣傳遞了rotation來控制方塊的旋轉角度。
我們能很明顯的發現,片元著色器和前面頂點著色器的實現,最終實現的效果上,兩個方塊的旋轉方向不一致。頂點著色器中是逆時針旋轉,片元著色器中是順時針旋轉,這是因為在頂點著色器中,我們是直接改變了頂點座標,透過旋轉矩陣的處理對映到了新的頂點,而在片元著色器中的座標變換,相當於是把座標系做了旋轉,最終繪圖的圖形是相對於新的座標系去計算距離場的距離,所以最終就呈現了相反的旋轉效果。
選擇
那麼既然兩類著色器都能實現動畫效果,在實際使用中我們要怎麼選擇呢?一般來說,動畫如果能使用頂點著色器實現,會盡量在頂點著色器中實現。因為在繪製一幀畫面的時候,頂點著色器的運算量會大大少於片元著色器,所以使用頂點著色器消耗的效能更少。
但是假如我們需要繪製更復雜的效果,比如運用大量的重複、隨機、噪聲,那麼使用片元著色器更合適。
所以具體的,還是要根據我們最終想要達到的效果、去選擇合適的實現方式。
Shader緩動函式
和HTML/CSS中的例子一樣,如果我們想要在Shader中實現非勻速運動,也可以直接使用Animator物件,在JavaScript中使用緩動函式,但是在WebGL中除了這種方式之外,我們也可以選擇直接把緩動函式寫在Shader中,比如下面這個例子:
// vertex
attribute vec2 a_vertexPosition;
uniform vec4 uFromTo;
uniform float uTime;
float easing(in float p) {
// return smoothstep(0.0, 1.0, p);
// return clamp(p * p, 0.0, 1.0); // 勻加速
return clamp(p * (2.0 - p), 0.0, 1.0); // 0->1->0 // 先減速後加速
// if(p < 1.0) return clamp(p * (2.0 - p), 0.0, 1.0);
// else return 1.0;
}
void main() {
gl_PointSize = 1.0;
vec2 from = uFromTo.xy;
vec2 to = uFromTo.zw;
float p = easing(uTime / 2.0);
vec2 translation = mix(from, to, p);
mat3 transformMatrix = mat3(
1, 0, 0,
0, 1, 0,
translation, 1
);
vec3 pos = transformMatrix * vec3(a_vertexPosition, 1);
gl_Position = vec4(pos, 1);
}
可以用smoothstep(0.0, 1.0, p)
來讓方塊做平滑變速運動;也可以替換緩動函式,使用比如clamp(p*p, 0.0, 1.0)
或clamp(p*(2.0-p), 0.0, 1.0)
來實現勻加速、勻減速的運動效果。
總結
以上就是關於動畫實現的分享,主要介紹了動畫的三種實現形式和具體操作,本文中都是比較簡單的一些動畫例子,希望能給到大家一些啟發,去實現更復雜、更有意思的動畫效果。
參考程式碼-HTML/CSS
參考程式碼-Shader
效果預覽-HTML/CSS
效果預覽-Shader