不懂物理的前端不是好的遊戲開發者(二)—— 物理引擎的學習之路

凹凸實驗室發表於2022-03-17

前言

繼第一篇文章之後已經過去了兩個月,在上一篇文章中介紹了物理引擎是什麼,需要掌握什麼樣子的基礎知識才能繼續往下進行開發。在這樣的基礎上,我們展開了第二篇,探索物理引擎的學習之路。在我們的日常開發當中,自然是用不到非常複雜的物理系統,大部分遊戲都是基於剛體,再在遊戲場景下進行一定的適配,最後模擬出物體在我們常規認識中的運動狀態,使我們覺得這些位移,形變看起來都是理所當然,順應規律的。其中最出名的手機遊戲莫過於《憤怒的小鳥》了。那麼我們如何達到《憤怒的小鳥》中的效果呢?讓我們一步步來探索。

憤怒的小鳥

粒子在遊戲中的運動

首先我們來看一下這樣的一個場景,在現實中,一個普通子彈的速度大約是 300m/s,接近音速,如果將其放在遊戲當中,依然按照正常的速度,那麼射出去的子彈只會在一瞬間消失不見,那射擊遊戲就成了玩家完全無法觀察到彈道的遊戲,橫版過關類的遊戲也變成了只看得到槍口火焰,但是看不到彈幕的恐怖遊戲。

彈幕遊戲

那作為“子彈”的小鳥也是同理,如果是真的按照在空中飛行的速度去計算,那麼我們只能看到一個矯健的身影一閃而過,然後就像子彈一樣打穿建築飛往遠方了。這顯然不是我們想要的效果。

那麼要如何解決這樣的問題呢?顯而易見的,我們應該降低遊戲中高速物體的速度。一般來說,根據地圖的大小,可以將速度限定在 5-25m/s。那麼速度的問題解決了,但是當一個物體的速度從 300 降低到了 25 的時候,它的能量,碰撞的現象,都會有很大的不同。當一個慢悠悠的小鳥撞到一堵牆的時候,可能只會默默掉下來,而牆表示:“剛才什麼東西給我撓了一下癢?”。那如何去解決這樣的問題?

首先我們都知道動能和速度以及質量的關係:$E=\frac{1}{2}mv^2$,那麼當速度下降的時候,我們就需要增加質量,兩者的關係為,質量上升的比例等於速度下降的比例,也就是 $\Delta m$ = $\Delta s^2$。那當一個質量為 5g,速度 300m/s 的子彈加速度降低到 25m/s 的時候,質量應該上升 144 倍,變為 720g。這樣我們便解決了速度降低導致能量降低的問題。此時我們的小鳥已經從一個普普通通的小雞仔變成了一個結實有力的肌肉猛鳥,只需要動一動,就能把野豬撞飛。

但是又出現了新的問題,速度降低隨之而來的是拋物線的變形,原本可以飛更遠的,但是由於初速度過小,沒多遠就下墜到地面了,這下我們的肌肉猛鳥又變成了一個大秤砣。那這個問題如何解決呢?那就是將拋物線恢復成原來的樣子。

當一個物體在做斜拋或者平拋運動時,他的初速度決定了拋物線的形狀,其中高度和垂直方向的初速度決定了重力的作用時間,水平方向的初速度決定了水平方向的位移。當速度降低時,重力作用的時間和水平方向上的位移都會隨之變化,導致軌跡和之前大不相同。我們知道水平方向的位移:$x = v_{水平}t$,而垂直方向上的位移: $y = v_{垂直}t - \frac{1}{2}gt^2$,其中把 t 替換掉,就可以得到拋物線方程,$y = \frac{v_{垂直}}{v_{水平}}x - \frac{1}{2}\frac{g}{v_{水平}^2}x^2$,其中 $\frac{v_{垂直}}{v_{水平}}$ 是物體發射的角度決定的,是一個固定值,那麼影響曲線的就是 $\frac{g}{v_{水平}^2}$, 由此我們可以知道重力加速度的轉換公式為 $g_b = \frac{g_{normal}}{\Delta s^2}$ ,但是事實情況是這樣嗎?並不是,因為我們減少速度的時候,我們的場景其實也在縮小,相同時間下原本300米的距離縮短到了25米,所以 y 和 x 需要有一個同樣的縮放比例才能得到一樣形狀的拋物線。那麼我們可以得出結論,其實真正的重力加速度轉換公式應該為$g_b = \frac{g_{normal}}{\Delta s}$(此處略去一些聯立的無聊等式)。

拋物線

遊戲中的合力

力累加器

接觸過簡單的力學的都知道,當多個力作用在一個物體上的時候,我們可以通過合力與分力,將複雜的多種力的結合處理為我們方便計算的幾個方向上的力。

首先我們明確一點,合力是多個力在向量層面的疊加,那麼就需要用到向量的加減法。其中最常用的是平行四邊形法則,而平行四邊形法則在座標軸裡的處理十分的簡單,就是將兩個向量的座標相加即可。但是力本身並不一定是恆力,比如說空氣阻力,可能隨著速度的增加而增加;浮力,隨著進入水中體積的增加而增加。所以我們需要時刻計算合力,那麼在遊戲中,就是在每一幀的時候都進行一次計算。

合力與分力

我們為此可以建立一個類,專門用於合力,稱其為力累加器。累加器的概念在物件導向程式設計和函數語言程式設計裡面使用的比較多,在 js 裡面也有對應的執行方法—— Array.reduce。它可以直接將陣列中的元素進行累計,此處的累計不僅僅代表累加,可以是任何操作。那麼事情就變得簡單起來,我們只需要將力都彙總在一個陣列中,並且給一個累加的函式即可。我們以二維平面為例來看一下:

class ForceAccum () {
    constructor () { // 初始化力的陣列
        this.forceArray = []
    }

    addForce(force) { // 新增力到陣列中
        this.forceArray.push(force)
    }
    
    clearForceAccum () { // 清除陣列用於下一次計算
        this.forceArray = []
    }
    
    forceReducer (prevForce, currentForce) { // 計算合力
        const x = prevForce.x + currentForce.x
        const y = prevForce.y + currentForce.y
        return {x, y}
    }
    
    getForce () { //獲取合力
        const force = this.forceArray.reduce(forceReducer)
        return force
    }
}

const forceAccum = new ForceAccum() //新建一個力累加器
forrceAccum.addForce(重力) // 增加重力
forrceAccum.addForce(阻力) // 增加阻力
forrceAccum.addForce(推力) // 增加推力
const force = forrceAccum.getForce() //計算合力
forrceAccum.clearForceAccum() // 清除陣列用於下次計算

以上就是最簡單的力累加器和其應用,往系統中增加力,最後獲取合力。獲取到合力之後我們便可以根據牛二定律得到加速度,最後根據加速度積分得到速度,然後根據速度積分得到位置。然後我們每一幀都重複以上操作即可。

而我們在《憤怒的小鳥》裡面需要什麼力呢?可以看出需要的是一個彈弓的彈力導致的推力以及自身的重力,那麼我們只需要將這兩個力加到我們的力累加器中即可計算出我們的肌肉猛鳥受到的力以及在水平和垂直方向上的分力。

![合力與分力](
https://img10.360buyimg.com/l...)

而我們在實際的運動中,其實只有一個重力在起作用(不計空氣阻力),推力在彈弓的彈性形變中最後轉換成了飛行的初速度。所以如果為了簡化計算,可以直接將彈簧的推力轉換成初速度。當然也可以通過沖量和動量進行計算了。

那這樣的每個力我們應該如何在程式碼中得到然後計算呢?這就需要用到另外一個東西——力發生器。

力發生器

力發生器,顧名思義,創造力的裝置。因為我們在運動中,存在著各種各樣的力,有的力是恆定的,例如質量不變時的重力;有些力是根據場景變化的,例如速度不斷變化時的空氣阻力;而有些力是根據玩家操作來變化的,比如說推力,彈力等。

那麼其實我們可以根據這些力的特性,為它們註冊對應的力發生器,這樣可以更好的管理它們。而力發生器的原理非常簡單,我們通過一個類來進行建立。但是各個力的特性不同,所以我們需要針對不同的力進行處理,這裡有兩種不同的方法,一種是將所有需要包括的力都寫在一個類中,需要建立力的時候,使用對應的方法;另一種則是將生成力的方法和引數傳入一個類中,最後返回需要的力,或者直接將力注入到力累加器中。後者的靈活性會使得我們在複雜的力學系統中更好的控制我們的系統。

class ForceRegister () {
    constructor (forceAccum,func, param) {
        this.forceAccum = forceAccum
        this.func = func
        this.param = param
        this.force = null
    }
    
    createForce () { // 返回需要的力
        this.force = this.func(this.param)
        return this.force
    }
    
    addForce () { // 直接將力注入力累加器
        this.createForce()
        forrceAccum.addForce(this.force)
    }
}

上述程式碼中的 func,param 就是我們需要生成的力的方法和引數。例如重力,重力只需要輸入物體質量(或者質量的倒數)和重力加速度,就可以得到對應的力 —— { x:0, y: mg }。阻力也是同理,阻力方程為 $\displaystyle F_{D}\,=\,{\tfrac {1}{2}}\,\rho \,v^{2}\,C_{D}\,A$。其中的引數我們先不去細究,簡化一下可以得到 $F_{D} = av^2$,a 為某個條件下的引數。這樣的話我們阻力生成器的引數就是 a 和 速度 v,然後方向是運動速度的反方向。

有了力生成器和力累加器,我們可以方便地管理遊戲中的力學體系。但是在遊戲中每幀都需要大量的計算和重新整理,對於效能的要求肯定是比較高的。所以便有了各種各樣的優化方式。

比如說我們可以去掉空氣阻力,水流阻力等以節省繁雜的計算,或者只給一個固定值來進行計算。對於比較重要的重力,我們可以通過內建重力的方式,直接將重力加速度存入整個物理體系,而不是將重力納入到每個物體的每次計算當中。

那麼其實我們明白了,我們只需要在系統中設定重力加速度,並且根據使用者操作設定好初速度的向量,就可以快速完成一個小鳥的拋物運動了。

總結

通過簡單的力學知識再加上合適的程式碼,我們就可以建立出一個符合力學規律的超級簡易版《憤怒的小鳥》世界了。但是這其實是遠遠不夠的,一個遊戲中除了力的簡單疊加和位移,還有力矩、碰撞、旋轉、角速度等等,只有加上了這些,我們才能去計算碰撞,得分,去合理的表現物體被撞後的受力、旋轉、移動。這些有趣的內容請期待一下我們物理引擎系列下面的章節~

歡迎關注凹凸實驗室部落格:aotu.io

或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章。

相關文章