【一統江湖的大前端(8)】matter.js 經典物理

  • 2020 年 3 月 10 日
  • 筆記

【一統江湖的大前端(8)】matter.js 經典物理

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

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

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

在前端開發領域,物理引擎是一個相對小眾的話題,它通常都是作為遊戲開發引擎的附屬工具而出現的,獨立的功能演示作品常常給人好玩但是無處可用的感覺。模擬就是在電腦的虛擬世界中模擬物體在真實世界的表現(動力學模擬最為常見)。模擬能讓畫面中物體的運動表現更符合玩家對現實世界的認知,比如在《憤怒的小鳥》遊戲中被彈弓發射出去小鳥或是因為被撞擊而坍塌的物體堆,還有在《割繩子》小遊戲中割斷繩子後物體所發生的單擺或是墜落運動,都和現實世界的表現近乎相同,遊戲體驗通常也會更好。

用物理引擎可以幫助開發者更快速地實現諸如碰撞反彈、摩擦力、單擺、彈簧、布料等等不同類型的模擬效果。物理引擎通常並不需要處理和畫面渲染相關的事務,而只需要完成計算模擬的部分就可以了,你可以把它理解成MVC模型中的M層,它和用於渲染畫面的V層理論上是獨立。matter.js提供了基於canvas2D API的渲染引擎,p2.js在示例程式碼中提供了一個基於WebGL實現的渲染器,在開發社區也可以找到p2.js與CreateJS或Egret聯合使用的示例。遊戲引擎和物理引擎的聯合使用並沒有想像中那麼複雜,實際上只需要完成不同引擎之間的坐標系映射就可以了,熟練地開發者可能會喜歡這種「低耦合」帶來的靈活性,但對於初級開發者而言無疑又提高了使用門檻。

一.經典力學回顧

經典力學的基本定律就是牛頓三大運動定律或與其相關的力學原理,它可以用來描述宏觀世界低速狀態下的物體運動規律,也為遊戲開發中的物理模擬提供了計算依據,大多數模擬都是基於經典力學的公式或其簡化形式進行計算模擬的,使用率較高的公式定律包括:

  • 牛頓第一定律

    牛頓第一定律又稱慣性定律,它指出任何物體都要保持勻速直線運動或靜止狀態,直到外力迫使它改變運動狀態為止。

  • 牛頓第二定律

    牛頓第二定律是指物體的加速度與它所受的外力成正比,與物體的品質成反比,加速度的方向與物體所受合外力速度相同,它可以模擬物體加速減速的過程,計算公式為(F為合外力,m為物體品質,a為加速度):

  • 動量守恆定理

    如果一個系統不受外力或所受外力的矢量和為零,那麼這個系統的總動量保持不變,動量即品質m和速度v的乘積,它通常被用於模擬兩物體碰撞,動量守恆定律的計算公式可以由牛頓第二定律推導得出(F為合外力,t為作用時長,m為物體品質,v2為末速度,v1為初速度):

  • 動能定理
    合外力對物體所做的功,等於物體動能的變化量,公式表達如下(W為合外力做功,m為物體品質,v2為末速度,v1為初速度):

當合外力為一個恆定的力時,它所做的功可以通過如下公式進行計算(W為合外力做功,F為合外力大小,S為物體運動的距離):

  • 胡克定律

    胡克定律指出當彈簧發生彈性形變時,彈簧的彈力F和其伸長量(或壓縮量)x成正比,它是物理模擬中進行彈性相關計算的主要依據,相關公式如下(F表示彈力,k表示彈性係數,x表示彈簧長度和無彈力時的長度差):

利用經典力學的相關原理,就可以在電腦中模擬物體的物理特性,對於勻速圓周運動、單擺、電磁場等的模擬都可以依據相關的物理原理進行模擬,本節中不再展開。

二. 模擬的實現原理

2.1 基本動力學模擬

Canvas動畫是一個逐幀繪製的過程,物理引擎作用的原理就是為抽象實體增加物理屬性,在每一幀中更新它們的值並計算這些物理量造成的影響,然後再執行繪製的命令。對物體進行動力學模擬時需要使用到品質、合外力、速度、加速度等屬性,其中品質是標量值(即沒有方向的值),而合外力、速度、加速度都是矢量值(有方向的值)。無論在2D還是3D圖形學計算中,向量計算的頻率都是極高的,如果不進行封裝,程式碼中可能就會充斥著大量底層數學計算程式碼,影響程式碼的可讀性,為了方便計算,我們先將二維向量的常見操作封裝起來:

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

為了讓物體實例都擁有模擬必要的屬性結構,可以定義一個抽象類,再用物體的類去繼承它就可以了,這和你平時編寫React應用時用自定義類繼承React.Component是一樣的,偽程式碼示例如下:

class AbstractSprite{      constructor(){              this.mass = 1; //物體品質              this.velocity = new Vector2(0, 0);//速度              this.force = new Vector2(0, 0);//合外力              this.position = new Vector2(0, 0);//物體的初始位置              this.rotate = 0; //物體相對於自己對稱中心的旋轉角度      }  }

我們並沒有在其中添加加速度屬性,使用合外力和品質就可以計算出它,position屬性用來確定對象繪製的位置,rotate屬性用來確定對象的偏轉角度,上面列舉的屬性在計算常見的線性運動場景中就足夠了。事實上屬性的取捨並沒有統一的標準,比如要模擬天體運動,可能還需要添加自轉角速度、公轉角速度等,如果要模擬彈簧,可能就需要添加彈性係數、平衡長度等,如果要模擬撞球滾動時的表現,就需要添加摩擦力,所選取的屬性通常都是直接或間接影響物體在畫布上最終可見形態的,你可以在子類中聲明這些特定場景中才會使用到的屬性。聲明一個新的物體類的示例程式碼如下:

class AirPlane extends AbstractSprite{      constructor(props){          super(props);          /* 聲明一些子屬性 */          this.someProp = props.someProps;      }      /* 定義如何更新參數 */      update(){}      /* 定義如何繪製 */      paint(){}  }

上面的模板程式碼相信你已經非常熟悉了,狀態屬性的更新程式碼編寫在update函數中即可,更新函數理論上的執行間隔大約是16.7ms,計算過程中可以近似認為屬性是不變的。我們知道加速度在時間維度的積累影響了速度,速度在時間上的積累影響位移:

模擬中過程中的Δt是自定義的,你可以根據期望的視覺效果去調整它,Δt越大,同樣大小的物理量在每一幀中造成的可見影響就越顯著,更新時使用向量計算來進行:

this.velocity = this.velocity.add(this.force.divide(this.mass).multiply(t));  this.position = this.position.add(this.velocity.multiply(t));

運動模擬中需要對那些體積較小但速度較快的物體多加留意,因為基於包圍盒的檢測很可能會失效,例如在粒子模擬相關的場景中,粒子是基於引力作用而運動的,初始距離較遠的粒子在相互靠近的過程中速度是越來越快的,這就可能使得在連續的兩幀計算中,兩個粒子的包圍盒都沒有重疊,但實際上它們已經發生過碰撞了,而電腦模擬中就會因為逐幀動畫的離散性而錯過碰撞的畫面,這時兩個粒子又會開始做減速運動而相互遠離,整體的運動狀態就呈現為簡諧振動的形式。所以在針對粒子系統的碰撞檢測時,除了包圍盒以外,通常還會結合速度和加速度的數值和方向變化來進行綜合判定。

2.2 碰撞模擬

碰撞,是指兩個或兩個物體在運動中相互靠近或發生接觸時,在較短的時間內發生強相互作用的過程,它通常都會造成物體運動狀態的變化。碰撞模擬一般使用完全彈性碰撞來進行計算,它是一種假定碰撞過程中不發生能量損失的理想狀況,這樣的碰撞過程就可以利用動量守恆定律和動能守恆定律進行計算:

公式中只有V1』和V2』是未知量,聯立方程就可以求得碰撞後速度的計算公式:

在引擎檢測到碰撞發生時只需要根據公式來計算碰撞後的速度就可以了,可以看到公式中使用到的屬性都已經在抽象物體類中進行了聲明,需要注意的是速度合成需要進行矢量運算。完全彈性碰撞只是為了方便計算的假設情況,大多數情況下我們並不需要知道碰撞造成的能量損失的確切數值,所以如果想要模擬碰撞造成的能量損失,可以在每次碰撞後將系統的總動能乘以0~1之間的係數來達到目的。

另一種典型的場景是物體之間發生非對心碰撞,也就是物體運動方向的延長線並不經過另一個物體的質心,運動模擬時為了簡化計算通常會忽略物體因碰撞造成的旋轉,將物體的速度先分解為指向另一物體質心方向的分量和垂直於該連線的分量,接著使用彈性對心碰撞的公式來求解對心碰撞的部分,最後再將碰撞後的速度與之前的垂直分量進行合成得到碰撞後的速度。

你不必擔心物理模擬中繁瑣的計算細節,大多數常用的場景都可以使用物理引擎快速實現,學習原理並不是為了重複去製造一些簡陋的「輪子」,而是讓你在面對引擎不適用的場景時可以自己去實現相應的開發。

三. 物理引擎matter.js

3.1 《憤怒的小鳥》的物理特性分析

《憤怒的小鳥》是一款物理元素非常豐富的遊戲,本節中以此為例進行一個簡易的練習。遊戲中首先需要實現一個模擬的地面,否則所有物體就會直接墜落到畫布以外,接著需要製作一個彈弓,當玩家在彈弓上按下滑鼠並向左拖動時,彈弓皮筋就會被拉長,且中間部位就會出現一隻即將被彈射出去的小鳥。當玩家鬆開滑鼠時,彈弓皮筋由於拉長而積蓄的彈性勢能會逐漸轉變成小鳥的動能,從而將小鳥發射出去,這時小鳥的初速度是向斜上方的,在後續的運動過程中會因為受到重力和空氣阻力的影響而逐漸改變,重力垂直向下且大小不變,而空氣阻力與合速度方向相反,整個飛行過程中就需要在每一幀中更新小鳥的速度。畫面的右側通常是一個由各種各樣不同材質的物體布景和綠色的豬頭組成的靜態物體堆,當小鳥撞擊到物體堆後,物體堆會發生坍塌,物體堆的各個組成部分都會遵循物理定律的約束而改版狀態,從而呈現出模擬的效果,坍塌的物體堆壓到綠色豬頭後會將其消滅,當所有的豬頭都被消滅後,就可以進入下一關了。

我們先使用matter.js為整個場景建立物理模型,然後再使用CreateJS建立渲染模型,通過坐標和角度同步來為各個物理模型添加靜態或動態的貼圖。為了降低建模的難度,本節的示例中將彈弓皮筋的模型簡化為一個彈簧,只要可以將小鳥彈射出去即可。

3.2 使用matter.js 構建物理模型

matter.js的官方網站提供的示例程式碼如下,它可以幫助開發者熟悉基本概念和開發流程,你可以在【官方程式碼倉】中找到更多示例程式碼:

var Engine = Matter.Engine,       Render = Matter.Render,     World = Matter.World,       Bodies = Matter.Bodies;    // create an engine  var engine = Engine.create();    // create a renderer  var render = Render.create({      element: document.body,      engine: engine  });    // create two boxes and a ground  var boxA = Bodies.rectangle(400, 200, 80, 80);  var boxB = Bodies.rectangle(450, 50, 80, 80);  var ground = Bodies.rectangle(400, 610, 810, 60, { isStatic: true });    // add all of the bodies to the world  World.add(engine.world, [boxA, boxB, ground]);    // run the engine  Engine.run(engine);    // run the renderer  Render.run(render);

示例程式碼中使用到的主要概念包括負責物理計算的Engine(引擎)、負責渲染畫面的Render(渲染器)、負責管理對象的World(世界)以及用於剛體繪製的Bodies(物體),當然這只是matter.js的基本功能。Matter.Render通過改變傳入的參數,就可以在畫面中標記處物體的速度、加速度、方向及其他調試資訊,也可以直接將物體渲染為線框模型,它在調試環境或一些簡單場景中非常易用,但面對諸如精靈動畫管理等更為複雜的需求時,就需要對其進行手動擴展或是直接替換渲染器。

在《憤怒的小鳥》物理建模過程中,static屬性設置為true的剛體都默認擁有無限大的品質,這類剛體不參與碰撞計算,只會將碰到它們的物體反彈回去,如果你不想讓世界中的物體飛出畫布的邊界,只需要在畫布的4個邊分別添加靜態剛體就可以了。物體堆的建立也非常容易,常用的矩形、圓、多邊形等輪廓都可以使用Bodies對象直接創建,位置坐標默認的參考點是物體的中心。當世界中的物體初始位置已經發生區域重疊時,引擎就會在工作時直接依據碰撞來處理,這可能就會導致一些物體擁有意料之外的初速度,在調試過程中,可以通過激活剛體模型的isStatic屬性來將其聲明為靜態剛體,靜態剛體就會停留在自己的位置上而不會因為碰撞檢測的關係發生運動,這樣就可以對模型的初始狀態進行檢測了,如下圖所示:

構建彈簧模型的技術被稱為「約束」,相關的方法保存在約束模組Matter.Constraint上。單獨存在的約束並沒有什麼實際意義,它需要關聯兩個物體,用來表示被關聯物體之間的約束關係,如果只關聯了一個物體,則表示這個物體和固定錨點坐標之間的約束關係,固定坐標默認為(0,0),可以通過pointA或pointB屬性調整固定錨點的位置,《憤怒的小鳥》中使用的彈簧模型就是後一種單端固定的形式。我們只需要找到小鳥被彈射出去時經過彈弓橫切面的位置,建立一個包含坐標值的對象作為錨點,然後再建立一個動態剛體B作為滑鼠拉動彈簧時小鳥圖案的附著點,最後在這兩個對象之間創建約束就可以了,創建約束時需要聲明彈性係數stiffness,它表明了約束髮生形變的難易程度。這個示例中約束兩端的平衡位置是重合在一起的,當玩家使用滑鼠拖動小鳥圖案附著點離開平衡位置後,就可以看到畫面上渲染出的兩點之間的彈簧約束,當用戶鬆開滑鼠後,彈簧就收縮,附著點就會回到初始位置,回彈的過程是一個類似於阻尼振動的過程,約束的彈性係數越大,端點回彈時在平衡位置波動就越小。當需要模擬彈簧被壓縮時,就需要通過length屬性來定義約束的平衡距離,約束復原時就會恢復到這個平衡距離。示例程式碼如下:

birdOptions = { mass: 10 },  bird = Matter.Bodies.circle(200, 340, 16, birdOptions),  anchor = { x: 200, y: 340 },  elastic = Matter.Constraint.create({              pointA: anchor,              bodyB: bird,              length: 0.01,              stiffness: 0.25          });

滑鼠模組Matter.Mouse和滑鼠約束模組Matter.MouseConstraint提供了滑鼠事件跟蹤與用戶交互相關的能力,配合Matter.Events模組就可以對滑鼠的移動、點擊、物體拖拽等典型事件進行監聽,使用方式相對固定,你只需要瀏覽一下官方文檔,熟悉一下引擎支援的事件就可以了,相關示例程式碼如下:

//創建滑鼠對象  var mouse = Mouse.create(render.canvas);    //創建滑鼠約束  Var mouseConstraint = MouseConstraint.create(engine, {              mouse: mouse,              constraint: {                  stiffness: 0.2,                  render: {                      visible: false                  }              }          });     //監聽全局滑鼠拖拽事件  Events.on(mouseConstraint, 'startdrag', function(event){      console.log(event);  })

物理引擎的更新也是逐幀進行的,可以利用Matter.Events模組來監聽引擎發出的事件,以每次更新計算後發出的afterUpdate事件為例,在回調函數中判斷是否需要將小鳥彈射出去。彈射是在玩家使用滑鼠向畫面左下方拖動並鬆開滑鼠後發生的,我們可以依據小鳥附著點的位置進行彈射判定,當小鳥處於錨點右上側並超過一定距離時,將其判定為可發射,發射的邏輯是生成一個新的小鳥附著點,將原約束中的bodyB進行替換,原本的附著點在約束解除後就表現為具有一定初速度的拋物運動,飛向物體堆。示例程式碼如下:

const ejectDistance = 4; //定義彈射判斷的位移閾值  const anchorX = 200; //定義彈簧錨點的x坐標  const anchorY = 350; //定義彈簧錨點的y坐標    //每輪更新後判斷是否需要更新約束  Events.on(engine, 'afterUpdate', function () {       if (mouseConstraint.mouse.button === -1  && bird.position.x > (anchorX + ejectDistance)  && bird.position.y < (anchorY - ejectDistance)) {                bird = Bodies.circle(anchorX, anchorY, 16, birdOptions);                World.add(engine.world, bird);                elastic.bodyB = bird;          }      });

需要注意的是matter.js構建的剛體模型會以物體幾何中心作為定位參考點的。至此,簡易的物理模型就構建好了,線框圖效果如下所示:

儘管看起來有些簡陋,但它已經可以模擬很多物理特性了,下一小節我們為模型進行貼圖後,它就會看起來就比較像遊戲了,物理模型的完整程式碼可以在我的程式碼倉庫中獲取到。

3.3 物理引擎牽手遊戲引擎

matter.js提供的渲染器模組Matter.Render非常適合物理模型的調試,但在面對遊戲製作時還不夠強大,比如原生Render模組為模型貼圖時僅支援靜態圖片,而遊戲中則往往會大量使用精靈動畫來增加趣味性,這時將物理引擎和遊戲引擎聯合起來使用就是非常好的選擇。

當你將Matter.Render相關的程式碼都刪除後,頁面上就不再繪製圖案了,但是如果你在控制台輸出一些資訊的話,就會發現示例中監聽afterUpdate事件的監聽器函數仍然在不斷執行,這就意味著物理引擎仍然在持續工作,不斷刷新著模型的物理屬性數值,只是沒有將畫面渲染到畫布上而已。渲染的工作,自然就要交給渲染引擎來處理,當使用CreateJS來開發遊戲時,渲染引擎使用的就是Easel.js。首先,使用Easel.js對所有保存在物理空間engine.world.bodies數組中的模型建立對應的視圖模型,所謂視圖模型,就是指物體的可見外觀,比如一個長方形,可能代表木頭,也可能代表石塊,這取決於你使用什麼樣的貼圖來表示它,視圖模型可以是精靈表、點陣圖或是自定義圖形等任何Easel.js支援的圖形,建立後將它們依次添加到舞台實例stage中。這樣每個物體實際上有兩個模型與之對應,物理空間中的模型依靠物理引擎更新,負責在每一幀中為對應物體提供位置坐標和旋轉角度,並確保變化趨勢符合物理定律;渲染舞台中的模型保存著物體的外觀樣式,依靠渲染引擎來更新和繪製,你只需要在每一幀更新物體屬性時將物理模型的關鍵資訊(通常是位置坐標和旋轉角度)同步給渲染模型就可以了。基本的邏輯流程如下所示:

按照上面的流程擴展之前的程式碼並不困難,完成後的遊戲畫面看起來有趣多了:

完整的程式碼已上傳至程式碼倉庫。相信你已經發現,最終畫面里的物體布局和物理引擎中的布局是一樣的,物理引擎的本質,就是為每個渲染模型提供正確的坐標和角度,並保證這些數據在逐幀更新過程中的變化和相互影響符合物理定律。如果第三方物理引擎無法滿足你的需求,那麼動手去實現自己的引擎吧,相信你已經知道該如何開始了。