【帶著canvas去流浪(9)】粒子動畫

大史不說話發表於2019-05-07

【帶著canvas去流浪(9)】粒子動畫

示例程式碼託管在:http://www.github.com/dashnowords/blogs

部落格園地址:《大史住在大前端》原創博文目錄

華為雲社群地址:【你要的前端打怪升級指南】

一. 粒子特效

粒子特效一般指密集點陣效果,它並不是canvas獨有的,這個名詞更多出現在AEcocos2dUnity相關的教程中,並且提供了方便的編輯外掛讓使用者可以輕鬆地做出例如煙火,流星,光暈等等動態變化的效果,看起來非常酷炫。如果你接觸過Three.js,會發現三維空間的點陣效果看起來更生動。粒子特效的本質還是一個逐幀動畫,所以我們仍然可以使用上一節中提到的動畫程式設計正規化來實現它。本節的教程將實現下面這樣一個粒子效果:

【帶著canvas去流浪(9)】粒子動畫

這是筆者第5個版本,看起來還挺像回事的吧,本篇中我們將逐步實現這樣一個酷炫的粒子動畫,也邀請你一起來看看開發過程中那些各種令人哭笑不得的問號黑人臉時刻。

二. 開發中遇到的問題

2.1 卡頓

想實現上面的動畫,我們首先要做的是構建一個靜態的粒子點陣,構建的過程並不複雜,無非就是xy兩個方向上以固定間距來畫點。如果我們將單個粒子定義為精靈,而不是粒子群,那麼按照上一節的開發正規化,我們會在逐幀動畫的執行函式step中按照如下的方式來更新粒子點陣的狀態:

function step(){
    ...
    particles.map(particle=>{
        particle.update();
        particle.paint();
    })
}

可畫面在粒子點陣動起來後就變得巨卡無比,視覺體驗很差。事實上,每一個精靈狀態的update( )方法僅僅是一些javascript中的計算程式碼,執行速度是非常快的,而paint( )方法中會經歷路徑繪製和渲染這兩個階段才能繪製出粒子,這個過程的高頻執行相對來說就會很耗時,當舞臺上的元素數量較少時並不會有什麼問題,但在粒子點陣這樣一個大量精靈元素的場景下,就很容易達到效能飽和。而解決的方式並不複雜,粒子是平鋪在畫紙上的,繪製的先後次序並不會導致畫面覆蓋,我們可以先描繪出所有粒子的路徑(一個小半徑圓圈),接著再最後呼叫context.stroke()方法一次性將所有粒子的邊線繪製出來,卡頓的問題立刻就解決了。就好像SPA框架中先收集變化並對新舊DOM樹進行diff操作,然後集中進行DOM更新,以取代獨立分散的DOM操作造成的效能損耗。

2.2 軌跡

【帶著canvas去流浪(9)】粒子動畫

構建完靜態粒子陣列後,我希望從簡單的特效還是做起,那就是滑鼠移動到某個位置後,就把固定半徑內所有的粒子沿徑向爆炸開來,粒子將沿滑鼠點和初始位置的連線運動。然而效果是上圖那樣的,雖然看起來還挺酷炫的,但它不是我們期望的效果。這裡只是一個低階錯誤,就是在step( )沒有重繪畫布,canvas就像一張畫紙,你所繪製的一切都保留在上面直到你用底色色塊將其覆蓋然後重繪,由於基本的視覺暫留,高速的重繪就成了動畫。

2.3 復位

【帶著canvas去流浪(9)】粒子動畫

當我們能夠模擬粒子沿爆炸中心炸開的效果後,就需要考慮如何將其復位。起初筆者試圖用彈簧模型來模擬粒子行為,但是出現的問題就如同上圖那樣,有一部分粒子在初始點附近做起了簡諧振動,通過設定最小復位距離來強制復位也很難做到,如果值太小,總會出現捕獲不到的點,如果值太大,又會造成復位效果失真。其實將復位點作為彈簧模型的平衡點是有問題的,因為簡諧振動在過中點的時候雖然不受力,但其速度卻達到最大,這就使得逐幀動畫之間的位移變化很大,所以才會出現上述的最小復位距離很難確定的問題。

越貼近真實效果,粒子力場模型就會越複雜,如果感興趣,你可以自行建立力場模型來進行模擬。本章的示例程式碼中我們採用一種簡化的處理方式,就是在爆炸後,直接將粒子置於一個較遠的位置,並以一個線性遞減的速度來靠近其初始位置,越靠近初始位置速度就越小,當其距離小於最小復位距離時將其歸位。

2.4 防護層

當能夠實現炸開的粒子復位後,最後要實現的效果就是防護圈,你可以想象一個透明的球體被扔進水裡的效果,水在外圍運動卻無法穿透防護進入球體。

【帶著canvas去流浪(9)】粒子動畫

筆者首次建模後得到效果是上圖這樣的,使用的模型是一個碰撞衰減模型,也就是將防護層當做鋼體表面,當粒子在復位過程中進入防護層後,就將其速度向量進行反向,並乘以衰減係數,當其離開防護層後再重新將速度方向指向初始位置。那麼這個模型有什麼問題呢?其實上面的動畫中已經能夠看出,由於時間間隔的選擇問題,粒子在兩幀之間所移動的距離可能會非常大,導致粒子會突然穿透防護層;另一方面,當爆炸中心移動後,粒子復位時的速度方向和它與爆炸中心的連線可能並不重合,單純地將速度沿原方向取反顯然是失真的。

實際上在防護層邊界的處理上,需要對上述模型進行一些調整。我們換個角度思考一下,假如將防護罩展開成一個平面,那麼粒子的運動軌跡就變得清晰了,如果爆炸中心沒有移動,那麼粒子的復位其實就相當於垂直下落的,如果爆炸中心和復位中心不重合,那麼總可以將小球的速度分解為沿爆炸中心徑向和沿爆炸中心切向,它的運動表現就和具有水平初速度和垂直加速度的物體遇到反彈平面時是一致的,為了簡化模擬處理,當小球即將和防護層碰撞時,可以直接將其沿爆炸中心徑向的速度清零,只保留切向速度,這樣當粒子碰到防護層而無法歸位時,就會沿著防護層表面運動,這樣粒子就不會穿透防護層了(示例程式碼中採用了更簡化的模擬策略,下文會提及)。

2.5 二維向量類

在圖形學的計算中,向量的使用頻率是極高的,在計算距離或是判斷點線面之間的關係等等場景中都會應用到,canvas只是一張畫布,其中的關係和距離等等都需要通過手動計算才能獲得。如果不對常見的向量操作進行封裝,程式碼中就會充斥著各種諸如用Math.sqrt(A.x * A.x + A.y * A.y)求模運算這種細節完全暴露的程式碼,不僅書寫起來非常繁瑣,閱讀和理解的困難也很高,所以我們需要建立一個二維向量類,把向量的求模,反向,相加,相減等常見操作掛載在原型鏈上,這就使得程式碼本身更具有意義,下面給出一個常見的二維向量類的實現,你可以根據自己的需求對其進行改造,後面的示例中我們也將直接使用這個類:

//二維向量類定義
Vector2 = function(x, y) { this.x = x; this.y = y; };
Vector2.prototype = {
    copy: function() { return new Vector2(this.x, this.y); },
    length: function() { return Math.sqrt(this.x * this.x + this.y * this.y); },
    sqrLength: function() { return this.x * this.x + this.y * this.y; },
    normalize: function() { var inv = 1 / this.length(); return new Vector2(this.x * inv, this.y * inv); },
    negate: function() { return new Vector2(-this.x, -this.y); },
    add: function(v) { return new Vector2(this.x + v.x, this.y + v.y); },
    subtract: function(v) { return new Vector2(this.x - v.x, this.y - v.y); },
    multiply: function(f) { return new Vector2(this.x * f, this.y * f); },
    divide: function(f) { var invf = 1 / f; return new Vector2(this.x * invf, this.y * invf); },
    dot: function(v) { return this.x * v.x + this.y * v.y; }
};

三. 實現講解

本節中針對重點程式碼片段進行講解,完整的示例程式碼可以從【我的github倉庫】中獲取到。

3.1 粒子類的update方法

/*方法中涉及到的位置相關屬性都是Vector2這個向量類的例項
*所以可以呼叫原型鏈方法進行向量計算
*/ 
update(){
        
        let nextPos;//模擬下一次落點

        
        const disV = this.pos0.subtract(this.pos);//當前位置到迴歸點的向量
        const disL = disV.length();//當前位置和初始點距離

        //1.計算速度(設定最小速度避免出現無限接近卻無法歸位的場景),並模擬下一次落點
        this.velocity = disV.multiply(kv * disL < minV ? minV : kv * disL);
        nextPos = this.pos.add(this.velocity.multiply(dt)); 

        //2.判斷下一次落點是否和當前爆破範圍保護層碰撞
        const disToE = nextPos.subtract(explodeCenter); //從爆破中心指向下一次落點的向量
        const disToEL = disToE.length();
        const disVnext = this.pos0.subtract(nextPos);//下一次落點指向迴歸點的向量
        const disLnext = disVnext.length();
        
        if (disToEL < explodeR) {
              //2.1 如果下一次落點會落在當前爆炸中心的範圍內則處理
              nextPos = explodeCenter.add(disToE.normalize().multiply(explodeR * 1.05));
        }else{
              //2.2 如果下一次落點距離迴歸點小於最小回收距離則回收
            if (disLnext < resetDistance ) {
                this.pos = this.pos0;
                return;
            }
        }

        //3.確認更新位置
        this.pos = nextPos;      
    }

上面的位置更新策略的難點在於2.1中的計算方法,也就是粒子迴歸途中碰到防護層表面時的處理。為了避開復雜的向量計算,示例程式碼中對碰撞的處理是直接改變其下一個落點的位置,而不是通過速度和受力來計算其位置,具體的做法是從當前爆炸中心向下一次落點位置連線生成向量,然後強制將當前粒子置於1.05倍半徑的地方,如下圖所示:

【帶著canvas去流浪(9)】粒子動畫

3.2 粒子群的繪製

為了節約渲染時的效能消耗,示例中對逐幀動畫的模式進行了調整,先統一更新粒子狀態,接著繪製所有粒子的路徑,最後一次性呼叫context.fill方法將粒子渲染出來。

//繪製粒子
function paintParticles() {
    ctx.fillStyle = 'white';
    ctx.beginPath();
    for(let i = 0; i < particles.length; i++){
        for(let j =0; j <particles[i].length; j++){
            //更新粒子狀態
            particles[i][j].update();
            //繪製粒子
            ctx.moveTo(particles[i][j].pos.x,particles[i][j].pos.y);
            ctx.arc(particles[i][j].pos.x,particles[i][j].pos.y,0.9,0,Math.PI*2,false);
        }
    }
    ctx.fill();
}

3.3 爆破層的模擬

粒子是否受到爆破中心的影響相對容易判斷,我們只需要計算粒子當前位置距離爆破中心的距離是否小於設定的爆破層半徑即可,本例中依舊使用直接計算位移的方式來替代基於爆破衝擊力的模擬,當爆破發生時將受到影響的粒子直接沿爆破中心與當前位置連線方向移動至大於爆破半徑的隨即位置。

//爆炸時某個點的影響
function explodePoint(p,center) { 
    let factor= Math.random() * 10;
    let dis = new Vector2(p.pos.x - center.x, p.pos.y - center.y).length();
    //核心點炸開
    if (dis < 0.3 * explodeR) {  
        //初始位置
        p.pos = explodeCenter.add(new Vector2(p.pos.x - center.x, p.pos.y - center.y).normalize().multiply(explodeR*(1+Math.random()*6)));
    } else {
       //非核心點炸至半徑附近
        p.pos = explodeCenter.add(new Vector2(p.pos.x - center.x, p.pos.y - center.y).normalize().multiply(explodeR*(1+Math.random()/10)));
    }
}

其餘的部分都是一些常規的逐幀動畫框架程式碼,實現難度並不大,本文不再贅述。

相關文章