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

示例代碼託管在:http://www.github.com/dashnowords/blogshtml

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

華爲雲社區地址:【你要的前端打怪升級指南】java

一. 粒子特效

粒子特效通常指密集點陣效果,它並非canvas獨有的,這個名詞更多出如今AEcocos2dUnity相關的教程中,而且提供了方便的編輯插件讓使用者能夠輕鬆地作出例如煙火,流星,光暈等等動態變化的效果,看起來很是酷炫。若是你接觸過Three.js,會發現三維空間的點陣效果看起來更生動。粒子特效的本質仍是一個逐幀動畫,因此咱們仍然可使用上一節中提到的動畫編程範式來實現它。本節的教程將實現下面這樣一個粒子效果:git

這是筆者第5個版本,看起來還挺像回事的吧,本篇中咱們將逐步實現這樣一個酷炫的粒子動畫,也邀請你一塊兒來看看開發過程當中那些各類使人啼笑皆非的問號黑人臉時刻。github

二. 開發中遇到的問題

2.1 卡頓

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

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

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

2.2 軌跡

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

2.3 復位

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

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

2.4 防禦層

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

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

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

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倍半徑的地方,以下圖所示:

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)));
    }
}

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

相關文章
相關標籤/搜索