JavaScript 遊戲開發:手把手實現碰撞物理引擎

峰華前端工程師發表於2021-02-26


年前我看到合成大西瓜小遊戲火了,想到之前從來沒有研究過遊戲方面的開發,這次就想趁著這個機會看看 JavaScript 遊戲開發,從原生角度上如何實現遊戲裡的物理特性,例如運動、碰撞。雖然之前研究過物理相關的動畫庫,但是我打算試試不用框架編寫一個簡單的 JavaScript 物理引擎,實現小球的碰撞效果。

為什麼不用現成的遊戲庫呢?因為我覺得在瞭解底層的實現原理之後,才能更有效的理解框架上的概念和使用方法,在解決 BUG 的時候能夠更有效率,同時對自己的編碼技能也是一種提升。在對 JavaScript 物理引擎的研究過程中,發現寫程式碼是次要的,最主要的是理解相關的物理、數學公式和概念,雖然我是理科生,但是數學和物理從來不是我的強項,我不是把知識還給老師了,而是壓根就沒掌握過 o。過年期間花了有小半個月的時間在學習物理知識,現在仍然對某些概念和推導過程沒有太大的自信,不過最後還算是做出了一個簡單的、比較滿意的結果,見下圖。

gravity.gif

接下來看一下怎麼實現這樣的效果。

基礎結構

我們這裡使用 canvas 來實現 JavaScript 物理引擎。首先準備專案的基礎檔案和樣式,新建一個 index.html、index.js 和 style.css 檔案,分別用於編寫 canvas 的 html 結構、引擎程式碼和畫布樣式。

在 index.html 的 <head /> 標籤中引入樣式檔案:

<link rel="stylesheet" href="./style.css" />

<body /> 中,新增 canvas 元素、載入 index.js 檔案:

<main>
  <canvas id="gameboard"></canvas>
</main>
<script src="./index.js"></script>

這段程式碼定義了 idgameboard<canvas /> 元素,並放在了 <main /> 元素下, <main /> 元素主要是用來設定背景色和畫布大小。在 <main/> 元素的下方引入 index.js 檔案,這樣可以在 DOM 載入完成之後再執行 JS 中的程式碼。

style.css 中的程式碼如下:

* {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
  font-family: sans-serif;
}

main {
  width: 100vw;
  height: 100vh;
  background: hsl(0deg, 0%, 10%);
}

樣式很簡單,去掉所有元素的外邊距、內間距,並把 <main/> 元素的寬高設定為與瀏覽器可視區域相同,背景色為深灰色。

hsl(hue, saturation, brightness) 為 css 顏色表示法之一,引數分別為色相,飽和度和亮度,可通過我之前出過的視訊進行學習。

繪製小球

接下來繪製小球,主要用到了 canvas 相關的 api。

在 index.js 中,編寫如下程式碼:

const canvas = document.getElementById("gameboard");
const ctx = canvas.getContext("2d");

canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

let width = canvas.width;
let height = canvas.height;

ctx.fillStyle = "hsl(170, 100%, 50%)";
ctx.beginPath();
ctx.arc(100, 100, 60, 0, 2 * Math.PI);
ctx.fill();

程式碼中主要利用了二維 context 進行繪圖操作:

  • 通過 canvas 的 id 獲取 canvas 元素物件。
  • 通過 canvas 元素物件獲取繪圖 context, getContext() 需要一個引數,用於表明是繪製 2d 影像,還是使用 webgl 繪製 3d 圖象,這裡選擇 2d。context 就類似是一支畫筆,可以改變它的顏色和繪製基本的形狀。
  • 給 canvas 的寬高設定為瀏覽器可視區域的寬高,並儲存到 widthheight 變數中方便後續使用。
  • 給 context 設定顏色,然後呼叫 beginPath() 開始繪圖。
  • 使用 arc() 方法繪製圓形,它接收 5 個引數,前兩個為圓心的 x、y 座標,第 3 個為半徑長度, 第 4 個和第 5 個分別是起始角度和結束角度,因為 arc() 其實是用來繪製一段圓弧,這裡讓它畫一段 0 到 360 度的圓弧,就形成了一個圓形。這裡的角度是使用 radian 形式表示的,0 到 360 度可以用 0 到 2 * Math.PI 來表示。
  • 最後使用 ctx.fill() 給圓形填上顏色。

這樣就成功的繪製了一個圓形,我們在這把它當作一個小球:

image.png

移動小球

不過,這個時候的小球還是靜止的,如果想讓它移動,那麼得修改它的圓心座標,具體修改的數值則與運動速度有關。在移動小球之前,先看一下 canvas 進行動畫的原理:

Canvas 進行動畫的原理與傳統的電影膠片類似,在一段時間內,繪製影像、更新影像位置或形狀、清除畫布,重新繪製影像,當在 1 秒內連續執行 60 次或以上這樣的操作時,即以 60 幀的速度,就可以產生連續的畫面。

那麼在 JavaScript 中,瀏覽器提供了 window.requestAnimationFrame() 方法,它接收一個回撥函式作為引數,每一次執行回撥函式就相當於 1 幀動畫,我們需要通過遞迴或迴圈連續呼叫它,瀏覽器會盡可能的在 1 秒內執行 60 次回撥函式。那麼利用它,我們就可以對 canvas 進行重繪,以實現小球的移動效果。

由於 window.requestAnimationFrame() 的呼叫基本是持續進行的,所以我們也可以把它稱為遊戲迴圈(Game loop)。

接下來我們來看如何編寫動畫的基礎結構:

function process() {
  window.requestAnimationFrame(process);
}
window.requestAnimationFrame(process);

這裡的 process() 函式就是 1 秒鐘要執行 60 次的回撥函式,每次執行完畢後繼續呼叫 window.requestAnimationFrame(process)進行下一次迴圈。如果要移動小球,那麼就需要把繪製小球和修改圓心 x、y 座標的程式碼寫到 process() 函式中。

為了方便更新座標,我們把小球的圓心座標儲存到變數中,以方便對它們進行修改,然後再定義兩個新的變數,分別表示在 x 軸方向上的速度 vx,和 y 軸方向上的速度 vy,然後把 context 相關的繪圖操作放到 process() 中:

let x = 100;
let y = 100;
let vx = 12;
let vy = 25;

function process() {
  ctx.fillStyle = "hsl(170, 100%, 50%)";
  ctx.beginPath();
  ctx.arc(x, y, 60, 0, 2 * Math.PI);
  ctx.fill();
  window.requestAnimationFrame(process);
}
window.requestAnimationFrame(process);

要計算圓心座標 x、y 的移動距離,我們需要速度和時間,速度這裡有了, 那麼時間要怎麼獲取呢? window.requestAnimationFrame() 會把當前時間的毫秒數(即時間戳)傳遞給回撥函式,我們可以把本次呼叫的時間戳儲存起來,然後在下一次呼叫時計算出執行這 1 幀動畫消耗了多少秒,然後根據這個秒數和 x、y 軸方向上的速度去計算移動距離,分別加到 x 和 y 上,以獲得最新的位置。注意這裡的時間是上一次函式呼叫和本次函式呼叫的時間間隔,並不是第 1 次函式呼叫到當前函式呼叫總共過去了多少秒,所以相當於是時間增量,需要在之前 x 和 y 的值的基礎上進行相加,程式碼如下:

let startTime;

function process(now) {
  if (!startTime) {
    startTime = now;
  }
  let seconds = (now - startTime) / 1000;
  startTime = now;

  // 更新位置
  x += vx * seconds;
  y += vy * seconds;

  // 清除畫布
  ctx.clearRect(0, 0, width, height);
  // 繪製小球
  ctx.fillStyle = "hsl(170, 100%, 50%)";
  ctx.beginPath();
  ctx.arc(x, y, 60, 0, 2 * Math.PI);
  ctx.fill();

  window.requestAnimationFrame(process);
}

process() 現在接收當前時間戳作為引數,然後做了下面這些操作:

  • 計算上次函式呼叫與本次函式呼叫的時間間隔,以秒計,記錄本次呼叫的時間戳用於下一次計算。
  • 根據 x、y 方向上的速度,和剛剛計算出來的時間,計算出移動距離。
  • 呼叫 clearRect() 清除矩形區域畫布,這裡的引數,前兩個是左上角座標,後兩個是寬高,把 canvas 的寬高傳進去就會把整個畫布清除。
  • 重新繪製小球。

現在小球就可以移動了:

moving-ball.gif

重構程式碼

上邊的程式碼適合只有一個小球的情況,如果有多個小球需要繪製,就得編寫大量重複的程式碼,這時我們可以把小球抽象成一個類,裡邊有繪圖、更新位置等操作,還有座標、速度、半徑等屬性,重構後的程式碼如下:

class Circle {
  constructor(context, x, y, r, vx, vy) {
    this.context = context;
    this.x = x;
    this.y = y;
    this.r = r;
    this.vx = vx;
    this.vy = vy;
  }
  
    // 繪製小球
  draw() {
    this.context.fillStyle = "hsl(170, 100%, 50%)";
    this.context.beginPath();
    this.context.arc(this.x, this.y, this.r, 0, 2 * Math.PI);
    this.context.fill();
  }

  /**
   * 更新畫布
   * @param {number} seconds
   */
  update(seconds) {
    this.x += this.vx * seconds;
    this.y += this.vy * seconds;
  }
}

裡邊的程式碼跟之前的一樣,這裡就不再贅述了,需要注意的是,Circle 類的 context 畫筆屬性是通過建構函式傳遞進來的,更新位置的程式碼放到了 update() 方法中。

對於整個 canvas 的繪製過程,也可以抽象成一個類,當作是遊戲或引擎控制器,例如把它放到一個叫 Gameboard 的類中:

class Gameboard {
  constructor() {
    this.startTime;
    this.init();
  }

  init() {
    this.circles = [
      new Circle(ctx, 100, 100, 60, 12, 25),
      new Circle(ctx, 180, 180, 30, 70, 45),
    ];
    window.requestAnimationFrame(this.process.bind(this));
  }

  process(now) {
    if (!this.startTime) {
      this.startTime = now;
    }
    let seconds = (now - this.startTime) / 1000;
    this.startTime = now;

    for (let i = 0; i < this.circles.length; i++) {
      this.circles[i].update(seconds);
    }
    ctx.clearRect(0, 0, width, height);

    for (let i = 0; i < this.circles.length; i++) {
      this.circles[i].draw(ctx);
    }
    window.requestAnimationFrame(this.process.bind(this));
  }
}

new Gameboard();

在 Gameboard 類中:

  • startTime 儲存了上次函式執行的時間戳的屬性,放到了建構函式中。
  • init() 方法建立了一個 circles 陣列,裡邊放了兩個示例的小球,這裡先不涉及碰撞問題。然後呼叫 window.requestAnimationFrame() 開啟動畫。注意這裡使用了 bind() 來把 Gameboard 的 this 繫結到回撥函式中,以便於訪問 Gameboard 中的方法和屬性。
  • process() 方法也寫到了這裡邊,每次執行時會遍歷小球陣列,對每個小球進行位置更新,然後清除畫布,再重新繪製每個小球。
  • 最後初始化 Gameboard 物件就可以開始執行動畫了。

這個時候有兩個小球在移動了。

two-moving-balls.gif

碰撞檢測

為了實現模擬的物理特性,多個物體間碰撞會有相應的反應,第一步就是要先檢測碰撞。我們先再多加幾個小球,以便於碰撞的發生,在 Gameboard 類的 init() 方法中再新增幾個小球:

this.circles = [
  new Circle(ctx, 30, 50, 30, -100, 390),
  new Circle(ctx, 60, 180, 20, 180, -275),
  new Circle(ctx, 120, 100, 60, 120, 262),
  new Circle(ctx, 150, 180, 10, -130, 138),
  new Circle(ctx, 190, 210, 10, 138, -280),
  new Circle(ctx, 220, 240, 10, 142, 350),
  new Circle(ctx, 100, 260, 10, 135, -460),
  new Circle(ctx, 120, 285, 10, -165, 370),
  new Circle(ctx, 140, 290, 10, 125, 230),
  new Circle(ctx, 160, 380, 10, -175, -180),
  new Circle(ctx, 180, 310, 10, 115, 440),
  new Circle(ctx, 100, 310, 10, -195, -325),
  new Circle(ctx, 60, 150, 10, -138, 420),
  new Circle(ctx, 70, 430, 45, 135, -230),
  new Circle(ctx, 250, 290, 40, -140, 335),
];

然後給小球新增一個碰撞狀態,在碰撞時,給兩個小球設定為不同的顏色:

class Circle {
  constructor(context, x, y, r, vx, vy) {
    // 其它程式碼
    this.colliding = false;
  }
  draw() {
    this.context.fillStyle = this.colliding
      ? "hsl(300, 100%, 70%)"
      : "hsl(170, 100%, 50%)";
    // 其它程式碼
  }
}

現在來判斷小球之間是否發生了碰撞,這個條件很簡單,判斷兩個小球圓心的距離是否小於兩個小球的半徑之和就可以了,如果小於等於則發生了碰撞,大於則沒有發生碰撞。圓心的距離即計算兩個座標點的距離,可以用公式:

( x 1 − x 2 ) 2 + ( y 1 − y 2 ) 2 \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} (x1x2)2+(y1y2)2

x1、y1 和 x2、y2 分別兩個小球的圓心座標。在比較時,可以對半徑和進行平方運算,進而省略對距離的開方運算,也就是可以用下方的公式進行比較:

( x 1 − x 2 ) 2 + ( y 1 − y 2 ) 2 ≤ ( r 1 + r 2 ) 2 (x_1 - x_2)^2 + (y_1 - y_2)^2 \leq (r_1 + r_2)^2 (x1x2)2+(y1y2)2(r1+r2)2

r1 和 r2 為兩球的半徑。

在 Circle 類中,先新增一個isCircleCollided(other)方法,接收另一個小球物件作為引數,返回比較結果:

isCircleCollided(other) {
  let squareDistance =
      (this.x - other.x) * (this.x - other.x) +
      (this.y - other.y) * (this.y - other.y);
  let squareRadius = (this.r + other.r) * (this.r + other.r);
  return squareDistance <= squareRadius;
}

再新增 checkCollideWith(other) 方法,呼叫 isCircleCollided(other) 判斷碰撞後,把兩球的碰撞狀態設定為 true:

checkCollideWith(other) {
  if (this.isCircleCollided(other)) {
    this.colliding = true;
    other.colliding = true;
  }
}

接著我們需要使用雙迴圈兩兩比對小球是否發生了碰撞,由於小球陣列存放在 Gameboard 物件中,我們給它新增一個 checkCollision() 方法來檢測碰撞:

checkCollision() {
  // 重置碰撞狀態
  this.circles.forEach((circle) => (circle.colliding = false));

  for (let i = 0; i < this.circles.length; i++) {
    for (let j = i + 1; j < this.circles.length; j++) {
      this.circles[i].checkCollideWith(this.circles[j]);
    }
  }
}

因為小球在碰撞後就應立即彈開,所以我們一開始要把所有小球的碰撞狀態設定為 false,之後在迴圈中,對每個小球進行檢測。這裡注意到內層迴圈是從 i + 1 開始的,這是因為在判斷 1 球和 2 球是否碰撞後,就無須再判斷 2 球 和 1 球了。

之後在 process() 方法中,執行檢測,注意檢測應該發生在使用 for 迴圈更新小球位置的後邊才準確:

for (let i = 0; i < this.circles.length; i++) {
  this.circles[i].update(seconds);
}
this.checkCollision();

現在,可以看到小球在碰撞時,會改變顏色了。

collision-detect.gif

邊界碰撞

上邊的程式碼在執行之後,小球都會穿過邊界跑到外邊去,那麼我們先處理一下邊界碰撞的問題。檢測邊界碰撞需要把四個面全部都處理到,根據圓心座標和半徑來判斷是否和邊界發生了碰撞。例如跟左邊界發生碰撞時,圓心的 x 座標是小於或等於半徑長度的,而跟右邊界發生碰撞時,圓心 x 座標應該大於或等於畫布最右側座標(即寬度值)減去半徑的長度。上邊界和下邊界類似,只是使用圓心 y 座標和畫布的高度值。在水平方向上(即左右邊界)發生碰撞時,小球的運動方向發生改變,只需要把垂直方向上的速度 vy 值取反即可,在垂直方向上碰撞則把 vx 取反。

edge-collision-diagram.png

現在看一下程式碼的實現,在 Gameboard 類中新增一個 checkEdgeCollision() 方法,根據上邊描述的規則編寫如下程式碼:

checkEdgeCollision() {
  this.circles.forEach((circle) => {
    // 左右牆壁碰撞
    if (circle.x < circle.r) {
      circle.vx = -circle.vx;
      circle.x = circle.r;
    } else if (circle.x > width - circle.r) {
      circle.vx = -circle.vx;
      circle.x = width - circle.r;
    }

    // 上下牆壁碰撞
    if (circle.y < circle.r) {
      circle.vy = -circle.vy;
      circle.y = circle.r;
    } else if (circle.y > height - circle.r) {
      circle.vy = -circle.vy;
      circle.y = height - circle.r;
    }
  });
}

在程式碼中,碰撞時,除了對速度進行取反操作之外,還把小球的座標修改為緊臨邊界,防止超出。接下來在 process() 中新增對邊界碰撞的檢測:

this.checkEdgeCollision();
this.checkCollision();

這時候可以看到小球在碰到邊界時,可以反彈了:

edge-collision.gif

但是小球間的碰撞還沒有處理,在處理之前,先複習一下向量的基本操作,數學好的同學可以直接跳過,只看相關的程式碼。

向量的基本操作

由於在碰撞時,需要對速度向量(或稱為向量)進行操作,向量是使用類似座標的形式表示的,例如 < 3, 5 > (這裡用 <> 表示向量),它有長度和方向,對於它的運算有一定的規則,本教程中需要用到向量的加法、減法、乘法、點乘和標準化操作。

向量相加只需要把兩個向量的 x 座標和 y 座標相加即可,例如: < 3 , 5 > + < 1 , 2 > = < 4 , 7 > <3, 5> + <1, 2> = <4, 7> <3,5>+<1,2>=<4,7>
減法與加法類似,把 x 座標和 y 座標相減,例如: < 3 , 5 > − < 1 , 2 > = < 2 , 3 > <3, 5> - <1, 2> = <2, 3> <3,5><1,2>=<2,3>

乘法,這裡指的是向量和標量的乘法,標量指的就是普通的數字,結果是把 x 和 y 分別和標量相乘,例如: 3 × < 3 , 5 > = < 9 , 15 > 3\times<3, 5> = <9, 15> 3×<3,5>=<9,15>

點乘是兩個向量相乘的一種方式,類似的還有叉乘,但是在本示例中用不到,點乘其實計算的是一個向量在另一個向量上的投影,它的計算方式為兩個向量的 x 的積加上 y 的積,它返回的是一個標量,即第 1 個向量在第 2 個向量上投影的長度,例如: < 3 , 5 > ⋅ < 1 , 2 > = 3 × 1 + 5 × 2 = 13 <3, 5> \cdot <1, 2> = 3 \times 1 + 5 \times 2 = 13 <3,5><1,2>=3×1+5×2=13

標準化是除掉向量的長度,只剩下方向,這樣的向量它的長度為 1,稱為單位向量,標準化的過程是讓 x 和 y 分別除以向量的長度,因為向量表示的是和原點(0, 0)的距離,所以可以直接使用 ( x 2 + y 2 ) \sqrt{(x^2 + y^2)} (x2+y2) 計算長度,例如 < 3, 4 > 標準化後的結果為: < 3 , 5 > ⋅ < 1 , 2 > = 3 × 1 + 5 × 2 = 13 <3, 5> \cdot <1, 2> = 3 \times 1 + 5 \times 2 = 13 <3,5><1,2>=3×1+5×2=13

瞭解了向量的基本運算後,我們來建立一個 Vector 工具類,來方便我們進行向量的運算,它的程式碼就是實現了這些運算規則:

class Vector {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  /**
   * 向量加法
   * @param {Vector} v
   */
  add(v) {
    return new Vector(this.x + v.x, this.y + v.y);
  }

  /**
   * 向量減法
   * @param {Vector} v
   */
  substract(v) {
    return new Vector(this.x - v.x, this.y - v.y);
  }

  /**
   * 向量與標量乘法
   * @param {Vector} s
   */
  multiply(s) {
    return new Vector(this.x * s, this.y * s);
  }

  /**
   * 向量與向量點乘(投影)
   * @param {Vector} v
   */
  dot(v) {
    return this.x * v.x + this.y * v.y;
  }

  /**
   * 向量標準化(除去長度)
   * @param {number} distance
   */
  normalize() {
    let distance = Math.sqrt(this.x * this.x + this.y * this.y);
    return new Vector(this.x / distance, this.y / distance);
  }
}

程式碼中沒有什麼特殊的語法和操作,這裡就不再贅述了,接下來我們看一下小球的碰撞問題。

碰撞處理

碰撞處理最主要的部分就是計算碰撞後的速度和方向。通常最簡單的碰撞問題是在同一個水平面上的兩個物體的碰撞,稱為一維碰撞,因為此時只需要計算同一方向上的速度,而我們現在的程式小球是在一個二維平面內運動的,小球之間發生正面相碰(即在同一運動方向)的概率很小,大部分是斜碰(在不同運動方向上擦肩相碰),需要同時計算水平和垂直方向上的速度和方向,這就屬於是二維碰撞問題。不過,其實小球之間的碰撞,只有在連心線(兩個圓心的連線)上有作用力,而在碰撞接觸的切線方向上沒有作用力,那麼我們只需要知道連心線方向的速度變化就可以了,這樣就轉換成了一維碰撞。

collision-diagram.png

計算碰撞後的速度時,遵守動量守恆定律和動能守恆定律,公式分別為:

動量守恆定律

m 1 v 1 + m 2 v 2 = m 1 v 1 ′ + m 2 v 2 ′ m_1v_1 + m_2v_2 = m_1v_1' + m_2v_2' m1v1+m2v2=m1v1+m2v2

動能守恆定律

1 2 m 1 v 1 2 + 1 2 m 2 v 2 2 = 1 2 m 1 v 1 ′ 2 + 1 2 m 2 v 2 ′ 2 \frac{1}{2}m_1v_1^2+\frac{1}{2}m_2v_2^2=\frac{1}{2}m_1v_1'^2+\frac{1}{2}m_2v_2'^2 21m1v12+21m2v22=21m1v12+21m2v22

m1、m2 分別為兩小球的質量,v1 和 v2 為兩小球碰撞前的速度向量,v1’ 和 v2’ 為碰撞後的速度向量。根據這兩個公式可以推匯出兩小球碰撞後的速度公式:

v 1 ′ = v 1 ( m 1 − m 2 ) + 2 m 2 v 2 m 1 + m 2 v_1'=\frac{v_1(m_1-m_2)+2m_2v_2}{m_1+m_2} v1=m1+m2v1(m1m2)+2m2v2

v 2 ′ = v 2 ( m 2 − m 1 ) + 2 m 1 v 1 m 1 + m 2 v_2'=\frac{v_2(m_2-m_1)+2m_1v_1}{m_1+m_2} v2=m1+m2v2(m2m1)+2m1v1

如果不考慮小球的質量,或質量相同,其實就是兩小球速度互換,即:

v 1 ′ = v 2 v_1'=v_2 v1=v2

v 2 ′ = v 1 v_2'=v_1 v2=v1

這裡我們給小球加上質量,然後套用公式來計算小球碰撞後速度,先在 Circle 類中給小球加上質量 mass 屬性:

class Circle {
  constructor(context, x, y, r, vx, vy, mass = 1) {
    // 其它程式碼
    this.mass = mass;
  }
}

然後在 Gameboard 類的初始化小球處,給每個小球新增質量:

this.circles = [
  new Circle(ctx, 30, 50, 30, -100, 390, 30),
  new Circle(ctx, 60, 180, 20, 180, -275, 20),
  new Circle(ctx, 120, 100, 60, 120, 262, 100),
  new Circle(ctx, 150, 180, 10, -130, 138, 10),
  new Circle(ctx, 190, 210, 10, 138, -280, 10),
  new Circle(ctx, 220, 240, 10, 142, 350, 10),
  new Circle(ctx, 100, 260, 10, 135, -460, 10),
  new Circle(ctx, 120, 285, 10, -165, 370, 10),
  new Circle(ctx, 140, 290, 10, 125, 230, 10),
  new Circle(ctx, 160, 380, 10, -175, -180, 10),
  new Circle(ctx, 180, 310, 10, 115, 440, 10),
  new Circle(ctx, 100, 310, 10, -195, -325, 10),
  new Circle(ctx, 60, 150, 10, -138, 420, 10),
  new Circle(ctx, 70, 430, 45, 135, -230, 45),
  new Circle(ctx, 250, 290, 40, -140, 335, 40),
];

在 Circle 類中加上 changeVelocityAndDirection(other) 方法來計算碰撞後的速度,它接收另一個小球物件作為引數,同時計算這兩個小球碰撞厚的速度和方向,這個是整個引擎的核心,我們一點一點的來看它是如何實現的。首先把兩個小球的速度使用 Vector 向量來表示:

  changeVelocityAndDirection(other) {
    // 建立兩小球的速度向量
    let velocity1 = new Vector(this.vx, this.vy);
    let velocity2 = new Vector(other.vx, other.vy);
  }

因為我們本身就已經使用 vx 和 vy 來表示水平和垂直方向上的速度向量了,所以直接把它們傳給 Vector 的建構函式就可以了。velocity1velocity2 分別代表當前小球和碰撞小球的速度向量。

接下來獲取連心線方向的向量,也就是兩個圓心座標的差:

let vNorm = new Vector(this.x - other.x, this.y - other.y);

接下來獲取連心線方向的單位向量和切線方向上的單位向量,這些單位向量代表的是連心線和切線的方向:

let unitVNorm = vNorm.normalize();
let unitVTan = new Vector(-unitVNorm.y, unitVNorm.x);

unitVNorm 是連心線方向單位向量,unitVTan 是切線方向單位向量,切線方向其實就是把連心線向量的 x、y 座標互換,並把 y 座標取反。根據這兩個單位向量,使用點乘計算小球速度在這兩個方向上的投影:

let v1n = velocity1.dot(unitVNorm);
let v1t = velocity1.dot(unitVTan);

let v2n = velocity2.dot(unitVNorm);
let v2t = velocity2.dot(unitVTan);

計算結果是一個標量,也就是沒有方向的速度值。v1nv1t 表示當前小球在連心線和切線方向的速度值,v2nv2t 則表示的是碰撞小球 的速度值。在計算出兩小球的速度值之後,我們就有了碰撞後的速度公式所需要的變數值了,直接用程式碼把公式套用進去:

let v1nAfter = (v1n * (this.mass - other.mass) + 2 * other.mass * v2n) / (this.mass + other.mass);
let v2nAfter = (v2n * (other.mass - this.mass) + 2 * this.mass * v1n) / (this.mass + other.mass);

v1nAfterv2nAfter 分別是兩小球碰撞後的速度,現在可以先判斷一下,如果 v1nAfter 小於 v2nAfter,那麼第 1 個小球和第 2 個小球會越來越遠,此時不用處理碰撞:

if (v1nAfter < v2nAfter) {
  return;
}

然後再給碰撞後的速度加上方向,計算在連心線方向和切線方向上的速度,只需要讓速度標量跟連心線單位向量和切線單位向量相乘:

let v1VectorNorm = unitVNorm.multiply(v1nAfter);
let v1VectorTan = unitVTan.multiply(v1t);

let v2VectorNorm = unitVNorm.multiply(v2nAfter);
let v2VectorTan = unitVTan.multiply(v2t);

這樣有了兩個小球連心線上的新速度向量和切線方向上的新速度向量,最後把連心線上的速度向量和切線方向的速度向量進行加法操作,就能獲得碰撞後小球的速度向量:

let velocity1After = v1VectorNorm.add(v1VectorTan);
let velocity2After = v2VectorNorm.add(v2VectorTan);

之後我們把向量中的 x 和 y 分別還原到小球的 vx 和 vy 屬性中:

this.vx = velocity1After.x;
this.vy = velocity1After.y;

other.vx = velocity2After.x;
other.vy = velocity2After.y;

最後在 checkCollideWith() 方法的 if 語句中呼叫此方法,即在檢測到碰撞時:

checkCollideWith(other) {
  if (this.isCircleCollided(other)) {
    this.colliding = true;
    other.colliding = true;
    this.changeVelocityAndDirection(other); // 在這裡呼叫
  }
}

這時,小球的碰撞效果就實現了。

ball-collision.gif

非彈性碰撞

現在小球之間的碰撞屬於完全彈性碰撞,即碰撞之後不會有能量損失,這樣小球永遠不會停止運動,我們可以讓小球在碰撞之後損失一點能量,來模擬更真實的物理效果。要讓小球碰撞後有能量損失,可以使用恢復係數,它是一個取值範圍為 0 到 1 的數值,每次碰撞後,乘以它就可以減慢速度,如果恢復係數為 1 則為完全彈性碰撞,為 0 則是完全非彈性碰撞,之間的數值為非彈性碰撞,現實生活中的碰撞都是非彈性碰撞。

先看一下邊界碰撞,這個比較簡單,假設邊界的恢復係數為 0.8,然後在每次對速度取反的時候乘以它就可以了,把 Gameboard checkEdgeCollision()方法作如下改動:

  checkEdgeCollision() {
    const cor = 0.8;                  // 設定恢復系統
    this.circles.forEach((circle) => {
      // 左右牆壁碰撞
      if (circle.x < circle.r) {
        circle.vx = -circle.vx * cor; // 加上恢復係數
        circle.x = circle.r;
      } else if (circle.x > width - circle.r) {
        circle.vx = -circle.vx * cor; // 加上恢復係數
        circle.x = width - circle.r;
      }

      // 上下牆壁碰撞
      if (circle.y < circle.r) {
        circle.vy = -circle.vy * cor; // 加上恢復係數
        circle.y = circle.r;
      } else if (circle.y > height - circle.r) {
        circle.vy = -circle.vy * cor; // 加上恢復係數
        circle.y = height - circle.r;
      }
    });
  }

接下來設定小球的恢復係數,給 Circle 類再加上一個恢復係數 cor 屬性,每個小球可以設定不同的數值,來讓它們有不同的彈性,然後在初始化小球時設定隨意的恢復係數:

class Circle {
  constructor(context, x, y, r, vx, vy, mass = 1, cor = 1) {
    // 其它程式碼
    this.cor = cor;
  }
}

class Gameboard {
  init() {
   this.circles = [
      new Circle(ctx, 30, 50, 30, -100, 390, 30, 0.7),
      new Circle(ctx, 60, 180, 20, 180, -275, 20, 0.7),
      new Circle(ctx, 120, 100, 60, 120, 262, 100, 0.3),
      new Circle(ctx, 150, 180, 10, -130, 138, 10, 0.7),
      new Circle(ctx, 190, 210, 10, 138, -280, 10, 0.7),
      new Circle(ctx, 220, 240, 10, 142, 350, 10, 0.7),
      new Circle(ctx, 100, 260, 10, 135, -460, 10, 0.7),
      new Circle(ctx, 120, 285, 10, -165, 370, 10, 0.7),
      new Circle(ctx, 140, 290, 10, 125, 230, 10, 0.7),
      new Circle(ctx, 160, 380, 10, -175, -180, 10, 0.7),
      new Circle(ctx, 180, 310, 10, 115, 440, 10, 0.7),
      new Circle(ctx, 100, 310, 10, -195, -325, 10, 0.7),
      new Circle(ctx, 60, 150, 10, -138, 420, 10, 0.7),
      new Circle(ctx, 70, 430, 45, 135, -230, 45, 0.7),
      new Circle(ctx, 250, 290, 40, -140, 335, 40, 0.7),
    ];
  }
}

加上恢復係數之後,小球碰撞後的速度計算也需要改變一下,可以簡單的讓 v1nAfterv2nAfter 乘以小球的恢復係數,也可以使用帶有恢復係數的速度公式(這兩種方式我暫時還不太清楚區別,有興趣的小夥伴可以自己研究一下),公式如下:

v 1 ′ = m 1 v 1 + m 2 v 2 + m 2 C R ( v 2 − v 1 ) m 1 + m 2 v_1'=\frac{m_1v_1+m_2v_2+m2C_R(v_2-v_1)}{m_1+m_2} v1=m1+m2m1v1+m2v2+m2CR(v2v1)

v 2 ′ = m 1 v 1 + m 2 v 2 + m 1 C R ( v 1 − v 2 ) m 1 + m 2 v_2'=\frac{m_1v_1+m_2v_2+m1C_R(v_1-v_2)}{m_1+m2} v2=m1+m2m1v1+m2v2+m1CR(v1v2)

接著把公式轉換為程式碼,在 Circle 類的 changeVelocityAndDirection() 方法中,替換掉 v1nAfterv2nAfter 的計算公式:

let cor = Math.min(this.cor, other.cor);
let v1nAfter =
    (this.mass * v1n + other.mass * v2n + cor * other.mass * (v2n - v1n)) /
    (this.mass + other.mass);

let v2nAfter =
    (this.mass * v1n + other.mass * v2n + cor * this.mass * (v1n - v2n)) /
    (this.mass + other.mass);

這裡要注意的是兩小球碰撞時的恢復係數應取兩者的最小值,按照常識,彈性小的無論是去撞別人還是別人撞它,都會有同樣的效果。現在小球碰撞後速度會有所減慢,不過還差一點,我們可以加上重力來讓小球自然下落。

coefficient-of-restitution.gif

重力

新增重力比較簡單,先在全域性定義重力加速度常量,然後在小球更新垂直方向上的速度時,累計重力加速度就可以了:

const gravity = 980;

class Circle {
  update(seconds) {
    this.vy += gravity * seconds; // 重力加速度
    this.x += this.vx * seconds;
    this.y += this.vy * seconds;
  }
}

重力加速度大約是 9.8 m / s 2 9.8m/s^2 9.8m/s2,但是由於我們的畫布是以象素為單位的,所以使用 9.8 看起來會像是沒有重力,或者像是從很遠的地方觀察小球,這時候可以把重力加速度放大一定倍數來達到更逼真的效果。

gravity.gif

總結

現在我們這個簡單的 JavaScript 物理引擎就完成了,實現了物理引擎最基本的部分,可以有一個完整的掉落和碰撞的效果,要做一個更逼真的物理引擎還需要考慮更多的因素和更復雜的公式,例如考慮一下摩擦力、空氣阻力、碰撞後的旋轉角度等,並且這個 canvas 的幀率也會有一定的問題,如果有的小球速度過快,但是如果來不及執行下一次回撥函式更新它的位置,那麼它可能就直接穿過碰撞的小球到另一邊了。

來總結一下開發過程:

  • 使用 context 繪製小球。
  • 搭建 Canvas 動畫基礎結構,主要使用 window.requestAnimationFrame方法反覆執行回撥函式。
  • 移動小球,通過小球的速度和函式執行時的時間戳來計算移動距離。
  • 碰撞檢測,通過比對兩個小球的距離和它們半徑的和。
  • 邊界碰撞的檢測和方向改變。
  • 小球之間的碰撞,應用速度公式和向量操作計算出碰撞後的速度和方向。
  • 利用恢復係數實現非彈性碰撞。
  • 新增重力效果。

程式碼可以在以下地址中檢視:

https://github.com/zxuqian/html-css-examples/tree/master/35-collision-physics

希望這篇文章對你有所幫助,如果文章中的程式碼、公式或表述有不正確的地方,請留言指正,感謝閱讀!

相關文章