視覺化學習:如何生成簡單動畫讓圖形動起來

beckyye發表於2024-06-25

大家好,本文分享的是如何生成簡單動畫讓圖形動起來。

在視覺化展現中,動畫它是強化資料表達,吸引使用者的重要技術手段。

在具體實現動畫之前,我們先來了解一下動畫的三種形式,分別是固定幀動畫、增量動畫和時序動畫。

graph LR A[動畫的三種形式] --> B[固定幀動畫] A --> D[增量動畫] A --> E[時序動畫] B --> F[使用已生成的靜態影像,將影像依次播放] D --> C[動態繪製影像] E --> C

固定幀動畫的實現,是使用已生成的靜態影像,然後將這些影像依次播放,而後面兩種,增量動畫和時序動畫,都是需要動態繪製影像。可想而知,後面這兩種動畫形式會更靈活一些。

接下來,我們就來了解如何在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;}
}

image

很顯然,在實現這個固定幀動畫之前,我們需要預先準備好靜態圖片,這個例子中我們使用的是雪碧圖,也叫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);
});

image

以上動畫實現的關鍵邏輯就在於修改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屬性,這樣就實現了旋轉動畫。

image

根據這個例子,我們可以將時序動畫的實現總結為三個步驟:

第一步,定義初始時間和週期;

第二步,在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;}
}

image

可以看到,這個效果我們很方便地透過前面定義的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`;
    });
});

image

這裡我們用了一個線性插值方法:left = start * (1 - p) + end * p線性插值可以很方便地實現屬性的均勻變化,所以用它來讓方塊做勻速運動是非常簡單的。

如果要讓方塊進行非勻速運動,比如勻加速運動,我們仍然可以用線性插值的方式,只不過要對引數 p 做一個函式對映。比如要讓方塊做初速度為0的勻加速運動,我們可以將 p 對映為p 的平方;如果要讓方塊做末速度為0的勻減速運動,可以將p對映為p*(2-p)。那為什麼是這樣對映呢?

這就要提到勻加速和勻減速的物理計算公式了。有些小夥伴很久沒接觸物理公式,可能會有些遺忘,這裡簡單回顧一下。

假設,某個物體在做初速度為0的勻加速運動,運動的總時間為T,總位移為S。那麼,它的加速度和在 t 時刻的位移的計算公式是這樣的:

\[a = \frac{2S}{T^2} \\ S_t = \frac{1}{2}at^2 = S(\frac{t}{T})^2 = Sp^2 \]

所以在勻加速運動中,我們把 p 對映為 p 的平方。

同樣的,如果物體在做勻減速運動,那麼,它的加速度和在 t 時刻的位移的計算公式是這樣的:

\[a = -\frac{2S}{T^2} \\ S_t = \frac{2S}{T}t - S(\frac{t}{T})^2 = Sp(2 - p) \]

所以在勻減速運動中,我們把 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`;
  });
});

image

緩動函式有很多種,實際中比較常用的是貝塞爾曲線緩動,我們可以使用現成的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`;
  });
});

image

貝塞爾緩動函式有很多種,大家可以參考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

相關文章