使用 Matter.js 2D 物理引擎製作動畫


Posted by ArvinH on 2020-11-07

前言

大約是在前陣子 GitHub 的 profile readme 很夯的時候,我在網路上看到了 matter.js 這個套件的作品,腦袋中就萌生一個點子想試試看,但因為真的沒有實際用處,也不確定效果好不好,就被我一直擱置,直到這個週末的空閒時間才決定要來實現它。

整體想法是這樣的,我想從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。

這邊用我老婆☺️ 的名字作為範例先給大家看看成果:

wife

而放到 GitHub 頁面的效果如下:

github

效果跟我想像的還是有點差異,不過也有八成像了,今天就利用我製作的小玩具來介紹一下 matter.js 的基本使用方式。

基本介紹

matterjs-website

matter.js 是一套由 JavaScript 撰寫的物理引擎,讓你能透過 JS 在瀏覽器上模擬物理反應,可以輕易調整物體重量、質量、速度,甚至是密度、摩擦力等等變量,非常適合用在需要呈現物理效果的 2D 遊戲中。

其提供的 API 也設計得簡單好用,只是雖然每個 API 都有文件,但內容都不太實用,如果你需要調整細節的話,要馬就自己慢慢更動嘗試,不然就得查看其原始碼會比較清楚。

而至於支援度部分也無須擔心,瀏覽器支援 IE8+,手機的觸控 Event 也不成問題。我覺得是另一個如同 GSAP 一樣值得花點時間學習把玩的前端工具。

在進入我們的範例製作解析前,我想先條列介紹 matter.js 中的常用套件,除了先了解整體的 Context 外,也能當作之後說明實作內容時的 reference。

Matter.js 的通用模組

matter.js 的 API 定義的很易懂,既然是做物理模擬,當然就要有 WorldBodyConstraint,而這些也是你使用 matter.js 所需要的基礎元件。

World: matter.js 透過此模組來創建一個模擬世界,可以微調世界中的一些屬性,像是重力、邊界等等,而一個世界當然是由多個 Bodies 所組成。

Bodies: Bodies 模組提供你方法去生成一些物體,像是圓形物體、方形物體等等,你也可以傳入 svg、img 去客製化物體形狀與樣式。產生的物體放入 World 中後就可以被 render 在畫面上。

Body: 利用 Bodies 產生的物件可以利用 Body 模組來進行進一步的操控。透過 Body,你可以旋轉、縮放、位移你的物體,也可以更改物體本身的密度、速度等等。換句話說,Body 讓你調整物體的物理特性。

Engine: 引擎,顧名思義就是驅動整個模擬物理世界的動力,根據 Body 的物理性質來精準掌控 WorldBody 彼此間的物理現象,確保能模擬出符合設定的反應。是 matter.js 的核心。主要的程式碼意外的沒有很長,可以大略看出 Engine 會負責控制 Bodies 之間的狀態更新。

Render: matter.js 有提供一個 Canvas based 的 Renderer,讓你能將 Engine 所催動的結果繪製出來,這個內建的 Render 模組主要是讓你用在開發與除錯上的,但對於簡單的動畫或遊戲,還是可以使用。另外要注意的是,該模組預設只會繪製出 wirefram 與向量,你要主動將 render.options.wireframes 設為 false,否則,以今天的模組為例(我們今天的範例也是用此模組開發。),他會變成這樣:

matterjs-wireframe

不過照這樣看來,依照官方的意思,如果你要使用 matter.js 來製作遊戲等等,基本上應該要自己實作 Render,你才能更好的控制畫面的變化。官方有提供一些 Renderer 的範例,也可以從其原始碼參考。

Composites: 這個模組有點像是 Bodies 模組,差別在於 Bodies 模組讓你創建出 ”一個“ 物體,而 Composites 提供方法讓你創建出多個物體所組合而成的物體,像是 Stack、Pyramid 或甚至是 Car, Chain 等等常用的內建組合。

Composite: 如同 Body 對應於 Bodies,Composite 就是對應於 Composites 的模組,讓你控制由 Composites 創建出的組合物體的物理特性。

Constraint: Constraint 模組讓你能為兩個物體之間增加物理限制,像是兩物體一定要間隔一定距離等等。這個模組在我們這次的範例中我沒有用到,不過官網有不少範例都有使用,像是 Newton's Cradle

MouseConstraint: 如同 Constraint,這個模組讓你增加滑鼠與物體之間的”約束”,透過建立物體與滑鼠的限制,就可以讓使用者透過滑鼠與你創建的物體互動。前面的範例中沒用到,但後面我會稍微帶到如何使用。

知道了基本模組,就從做中學吧

const Engine = Matter.Engine;
const Render = Matter.Render;
const Composites = Matter.Composites;
const World = Matter.World;
const Bodies = Matter.Bodies;
const Body = Matter.Body;

起手式就是先將先前介紹過的模組都宣告出來。

// create engine
const engine = Engine.create();
const world = engine.world;
// create renderer
const render = Render.create({
  element: document.body,
  engine: engine,
  options: {
    width: 920,
    height: 600,
  }
});

接著創建 instance,利用 Engine.create() 創造 Engine 實例,而 engine.world 最後會需要傳給 World 模組,可以想像成是此引擎(Engine) 所驅動的世界(world)。

Render 的部分我們要指定使用的 engine、要渲染的 root element,以及寬高等基本選項。更細部的 properties 可以參考官網文件,以我們的範例來說,只需要這樣就夠了。

到目前為止,我們設定好了 EngineRender 的實例,代表我們已經準備好了一個虛擬的世界,然而光是準備好還不夠,我們需要“啟動”它。

所謂的啟動,其實就是要不斷地去呼叫 Engine.update()? 來觸發引擎計算,或是讓 Renderer 更新畫面,執行類似下面的動作:

(function run() {
  window.requestAnimationFrame(run);
  Engine.update(engine, 1000 / 60);
})();

而實際上 matter.js 內有另一個模組 Matter.Runner,可以來幫忙運行引擎與觸發 Render,在 EngineRender 物件內都有個叫 run 的 helper 函式,就是用到此內建 Runner 模組,只要將實例放入,matter.js 的 Runner 就會幫忙執行 Runner 該做的事:

Engine.run(engine);
Render.run(render);

不過,與前面提到的 Matter.Render 類似,依照官網說法,內建的 Matter.Runner 主要也是開發與除錯用途,只適合用在簡單的小應用上。

萬事俱備,只欠東風

Engine 與 Render 都啟動了,虛擬世界已上線,再來就只要往裡面丟入物體就好了。

分析一下我的點子:從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。

大致需要幾個條件:

  • 一堆小方塊來堆疊出 contribution graph。
  • 小方塊要能自由墜落,而代表名字部分的小方塊到某個點時需要停住。
  • 小方塊要能有噴散的效果。

從 matter.js 的官網中可以找到許多範例,從那些範例內,可以大致摸索出自己需要哪些模組才能拼湊出這樣的效果。

首先,可以利用 Composites.stack ref 來製造出堆疊好的 contribution graph:

API: Matter.Composites.stack(xx, yy, columns, rows, columnGap, rowGap, callback)

const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
  // ...略
  const block = Bodies.rectangle(x, y, 15, 15, {
    render: {
      fillStyle: color[~~(Math.random() * 2)], // 隨機給定格子顏色
      strokeStyle: '#fff',
    },
    frictionAir: 0.03,
  });
  // ...略
  return block;
});

Composites.stack 前面六個參數可以定義一個 grid 空間,範例中我們在相對於 Render 設定範圍的 x 軸 125px 與 y 軸 15px 的位置開始放置 stack,並定義該 grid 是 45 x 7 的格子(GitHub 上每行七天,大約 45 週),每個方塊大小 15px x 15px,格子與格子之間我們不需要空格,因此 columnGap 與 rowGap 都填 0。

而最後的 callback 函數中,可以組合多個 body 來擺放在其 grid 空間中。舉例來說,我們想要繪製出 contribution graph 的話,就是在 callback 函式中,利用 Bodies.rectangle 來產生一個個的小方塊,在這個 callback 中可以做很多事情,包含定義方塊的顏色、狀態等等。

到這邊可以繪製出一個還不錯的 contribution graph:

github-graph

那名字呢?

要客製化 contribution graph 好像很不少方式,像是這個,但我沒想那麼多 LOL 畢竟一開始只是想實驗看看,所以就用最土炮的方式,用 pixilart 手動在 45x7 的格子上用 pixel art 的方式寫出名字,然後再慢慢把格子數出來,建立一個雙層陣列來存:

const nameBlock = [
  [7, 8, 9, 10, 'A', 13, 14, 15, 16, 17, 'R', 20, 26, 'V', 28, 29, 30, 31, 32, 'I', 34, 35, 39, 'N'],
  [6, 11, 'A', 13, 18, 'R', 20, 26, 'V', 30, 'I', 34, 35, 36, 39, 'N'],
  [6, 11, 'A', 13, 17, 18, 'R', 20, 26, 'V', 30, 'I', 34, 36, 37, 39, 'N'],
  [6, 7, 8, 9, 10, 11, 'A', 13, 16, 17, 'R', 20, 26, 'V', 30, 'I', 34, 37, 38, 39, 'N'],
  [6, 11, 'A', 13, 15, 16, 'R', 21, 25, 'V', 30, 'I', 34, 38, 39, 'N'],
  [6, 11, 'A', 13, 16, 17, 'R', 22, 24, 'V', 30, 'I', 34, 39, 'N'],
  [6, 11, 'A', 13, 17, 18, 'R', 23, 'V', 28, 29, 30, 31, 32, 'I', 34, 39, 'N'],
];

然後在剛剛的 Composites.stack 的 callback 函數中,我就能判斷當下繪製的 body(rectangle)是不是屬於名字的一部分,進一步做處理:

// 根據當下的 rectangle 位置 (x, y) 與 nameBlock 做比對
const static = (x, y) => {
  const indexX = (x - 125) / 15;
  const indexY = (y - 15) / 15;
  const block = nameBlock[indexY];
  // 若是屬於名字的一部分,設定為 static,然後給予不同的顏色設定
  if (block && block.indexOf(indexX) !== -1) {
    return [true, ['#229A3B', '#196126']];
  }
  return [false, ['#EBEDEF', '#C5E48B']];
};

const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
  const [isStatic, color] = static(x, y);
  const block = Bodies.rectangle(x, y, 15, 15, {
    //...略
  });
  return block;
});

繪製成果:

withName

另外,在上面我自製的 static 函式中,會根據 rectangle 是否屬於名字的一部分,回傳 isStatic 布林值,這個值其實是屬於 Body 的一個 property,若 isStatic 設為 true,則該物體就不會受到其他物體的物理影響,很適合用在製作牆壁之類的物體,也恰好可以用來滿足我希望名字能被定住的需求。

而由於我希望方塊們是在掉落到一半的時候,名字才卡住,而其餘的方塊得隨著地心引力繼續下落,所以我必須要延緩設定 isStatic 的時間點,不能在我使用 Bodies 創建 rectangle 時就設定,需要來個 setTimeout 才行:

setTimeout(() => {
  Body.setStatic(block, isStatic);
}, 800);

由於因為“物理界”的正常現象,方塊會從我們設定的 y 軸 15px 的地方掉落,而在下落的 800ms 時,我們透過 Body.setStatic() 這個 method 讓屬於名字部分的方塊變為 static,這樣就能達到名字掉落一半時定住,其餘方塊繼續掉落的效果:

name-fix-block-drop

增加阻礙、摩擦力與速度

想要的效果達成一半了,就是方塊掉落速度太線性了,而且直直落到畫面外也有點好笑,我們需要製造一點障礙物以及改變物體的速度,產生撞擊的效果。

首先,增加障礙物。

要增加障礙物很簡單,matter.js 的範例裡面很多都有利用 Bodies.rectangle 去創建牆壁,控制物體的活動範圍,這在製作遊戲時也是很重要的一部分。我們也可以如法泡製,增加四面八方的牆壁:

API: Matter.Bodies.rectangle(x, y, width, height, [options])

const wallOption = {
  render: {
    fillStyle: 'transparernt',
    strokeStyle: '#FBFBFB',
  },
  isStatic: true,
};
const topWall = Bodies.rectangle(450, 0, 650, 30, wallOption);
const bottomWall = Bodies.rectangle(450, 500, 600, 30, wallOption);
const rightWall = Bodies.rectangle(880, 10, 30, 420, wallOption);
const leftWall = Bodies.rectangle(110, 10, 30, 420, wallOption);

牆壁的製作就是利用前面提到的 isStatic 屬性,讓他固定住,然後設定好擺放位置與長寬即可。唯一要注意的是,牆壁的長度要調整,不能四面都ㄧ樣長,這樣小方塊撞擊到牆壁後,還能從邊緣掉落或向外噴散,效果會好一點。

加了牆壁後,讓小方塊不會直直掉落,有了一些回饋感:

matter-js-wall

接著是物體的速度。

Matter.Body 有提供 setVelocity 這個屬性可以立即增加物體本身的線性速度,調整的方式為給予一個向量,因此可以調整施予速度的方向性:

API: Matter.Body.setVertices(body, vertices), Vertor: { x: 0, y: 0 }

Body.setVelocity(block, {x: 3, y: -10});

這樣就會讓一個小方塊往 x 軸 3,y 軸 -10 的方向增加速度,再加上先前加入的牆壁與固定住的名字方塊,產生的撞擊反彈就能達成這樣的效果:

velocity

除此之外,Bodies.rectangle 在宣告時能夠傳入調整物理特性的 properties,像是 frictionAir 可以改變物體的空氣摩擦力,數值越高,物體掉落越慢,並且都能透過 Matter.Body 去操控,例如:

Body.set(block, { frictionAir: 0 });

相關 API 官網都有條列出來。

將上述調整物體物理特性的函式呼叫搭配適當的 setTimeout,就能夠完成我們今天的範例效果:

const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
  const [isStatic, color] = static(x, y);
  const block = Bodies.rectangle(x, y, 15, 15, {
    // ...略
  });
  setTimeout(() => {
    Body.setStatic(block, isStatic);
  }, 800);
  setTimeout(() => {
    Body.set(block, { frictionAir: 0 });
  }, 600);
  setTimeout(() => {
    if (!isStatic) {
      Body.setVelocity(block, {x: 3, y: -10});
    }
  }, 900);
  return block;
});

喔對了,最後當然要記得把我們產生的 Stack composites 與牆壁放入模擬的世界中:

// const world = engine.world;
World.add(world, [
  stack,
  // walls
  topWall,
  bottomWall,
  rightWall,
  leftWall
]);

One more thing...

matter.js 主打物理引擎,當然不是單純用來製造動畫,而是用來製作遊戲等等,也就是說要能與使用者互動,而方法就是一開始提到過的 MouseConstraint,雖然這次範例用不著這個東西,但還是放個使用方法在這邊供參考:

const Mouse = Matter.Mouse;
const MouseConstraint = Matter.MouseConstraint;
const mouse = Mouse.create(render.canvas);
const mouseConstraint = MouseConstraint.create(engine, {
  mouse: mouse,
  constraint: {
    stiffness: 3,
    render: {
      visible: false
    }
  }
});
World.add(world, mouseConstraint);

用法其實很簡單,其中 constraint 參數 visible 代表著滑鼠的拖拉軌跡會不會呈現出來,而 stiffness 可以算是調整所設定的 constraint 的韌度,調整該值可以影響物體受牽制(與滑鼠互動)後產生的彈性。文字可能有點難以描述,有需要使用的時候可以從官網文件查看可調整的參數值,試試看效果再決定要如何設置。

上述設定的效果如下:

matter-js-mouse

最後放上程式碼連結供各位參考:https://codepen.io/arvin0731/pen/qBNoLQv

結論

Matter.js 應該算是蠻久的一個工具了,以使用上來說非常容易上手,做些小動畫小遊戲蠻適合的,至於要真的用來製作複雜的遊戲的話,可能還是要再多研究他的效能如何,畢竟我這次並沒有觸碰到那塊,就歡迎有接觸過的讀者分享了!

畢竟這個範例也是拼拼湊湊而來的,週末小玩具就是這樣,的確沒辦法理解到他底層是如何實作,但是至少完成了想要的效果,然後也知道了這個工具的一些基本用法,之後有需要時可以快速拿來使用。

不過,提醒自己也提醒大家,要記得撥出時間去理解底層原理,因為這才是能讓你成長的要素,共勉之啦!

資料來源

  1. matter-js website
  2. Getting Started with Matter.js
  3. pixilart

#Web #matterjs #2d physics









Related Posts

完美主義的悲哀:放下就不再煩惱

完美主義的悲哀:放下就不再煩惱

淺談 React Fiber 及其對 lifecycles 造成的影響

淺談 React Fiber 及其對 lifecycles 造成的影響

Why you should or shouldn't use Google DNS?

Why you should or shouldn't use Google DNS?




Newsletter




Comments