App下載

JavaScript 游戲開發(fā):手把手實(shí)現(xiàn)碰撞物理引擎

猿友 2021-03-02 13:33:29 瀏覽數(shù) (9307)
反饋

v1nAfter 和 v2nAfter 分別是兩小球碰撞后的速度,現(xiàn)在可以先判斷一下,如果 v1nAfter 小于 v2nAfter,那么第 1 個小球和第 2 個小球會越來越遠(yuǎn),此時不用處理碰撞:年前我看到合成大西瓜小游戲火了,想到之前從來沒有研究過游戲方面的開發(fā),這次就想趁著這個機(jī)會看看 JavaScript 游戲開發(fā),從原生角度上如何實(shí)現(xiàn)游戲里的物理特性,例如運(yùn)動、碰撞。雖然之前研究過物理相關(guān)的動畫庫,但是我打算試試不用框架編寫一個簡單的 JavaScript 物理引擎,實(shí)現(xiàn)小球的碰撞效果。

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

gravity.gif

接下來看一下怎么實(shí)現(xiàn)這樣的效果。

基礎(chǔ)結(jié)構(gòu)

我們這里使用 canvas 來實(shí)現(xiàn) JavaScript 物理引擎。首先準(zhǔn)備項目的基礎(chǔ)文件和樣式,新建一個 index.html、index.js 和 style.css 文件,分別用于編寫 canvas 的 html 結(jié)構(gòu)、引擎代碼和畫布樣式。

在 index.html 的 ?<head /> ?標(biāo)簽中引入樣式文件:

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

在 <body /> 中,添加 canvas 元素、加載 index.js 文件:

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

這段代碼定義了? id? 為 ?gameboard? 的 ?<canvas /> ?元素,并放在了 ?<main />? 元素下, ?<main />? 元素主要是用來設(shè)置背景色和畫布大小。在 ?<main/>? 元素的下方引入 index.js 文件,這樣可以在 DOM 加載完成之后再執(zhí)行 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%);
}

樣式很簡單,去掉所有元素的外邊距、內(nèi)間距,并把 ?<main/>? 元素的寬高設(shè)置為與瀏覽器可視區(qū)域相同,背景色為深灰色。

hsl(hue, saturation, brightness) 為 css 顏色表示法之一,參數(shù)分別為色相,飽和度和亮度。

繪制小球

接下來繪制小球,主要用到了 canvas 相關(guān)的 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 進(jìn)行繪圖操作:

  • 通過 canvas 的 id 獲取 canvas 元素對象。
  • 通過 canvas 元素對象獲取繪圖 context, ?getContext()? 需要一個參數(shù),用于表明是繪制 2d 圖像,還是使用 webgl 繪制 3d 圖象,這里選擇 2d。context 就類似是一支畫筆,可以改變它的顏色和繪制基本的形狀。
  • 給 canvas 的寬高設(shè)置為瀏覽器可視區(qū)域的寬高,并保存到 ?width? 和 ?height? 變量中方便后續(xù)使用。
  • 給 context 設(shè)置顏色,然后調(diào)用 ?beginPath()? 開始繪圖。
  • 使用 ?arc()? 方法繪制圓形,它接收 5 個參數(shù),前兩個為圓心的 x、y 坐標(biāo),第 3 個為半徑長度, 第 4 個和第 5 個分別是起始角度和結(jié)束角度,因?yàn)?nbsp;?arc()? 其實(shí)是用來繪制一段圓弧,這里讓它畫一段 0 到 360 度的圓弧,就形成了一個圓形。這里的角度是使用 radian 形式表示的,0 到 360 度可以用 0 到 2 * Math.PI 來表示。
  • 最后使用 ?ctx.fill()? 給圓形填上顏色。

這樣就成功的繪制了一個圓形,我們在這把它當(dāng)作一個小球:

image.png

移動小球

不過,這個時候的小球還是靜止的,如果想讓它移動,那么得修改它的圓心坐標(biāo),具體修改的數(shù)值則與運(yùn)動速度有關(guān)。在移動小球之前,先看一下 canvas 進(jìn)行動畫的原理:

Canvas 進(jìn)行動畫的原理與傳統(tǒng)的電影膠片類似,在一段時間內(nèi),繪制圖像、更新圖像位置或形狀、清除畫布,重新繪制圖像,當(dāng)在 1 秒內(nèi)連續(xù)執(zhí)行 60 次或以上這樣的操作時,即以 60 幀的速度,就可以產(chǎn)生連續(xù)的畫面。

那么在 JavaScript 中,瀏覽器提供了 ?window.requestAnimationFrame()? 方法,它接收一個回調(diào)函數(shù)作為參數(shù),每一次執(zhí)行回調(diào)函數(shù)就相當(dāng)于 1 幀動畫,我們需要通過遞歸或循環(huán)連續(xù)調(diào)用它,瀏覽器會盡可能的在 1 秒內(nèi)執(zhí)行 60 次回調(diào)函數(shù)。那么利用它,我們就可以對 canvas 進(jìn)行重繪,以實(shí)現(xiàn)小球的移動效果。

由于 ?window.requestAnimationFrame() ?的調(diào)用基本是持續(xù)進(jìn)行的,所以我們也可以把它稱為游戲循環(huán)(Game loop)。

接下來我們來看如何編寫動畫的基礎(chǔ)結(jié)構(gòu):

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

這里的 ?process() ?函數(shù)就是 1 秒鐘要執(zhí)行 60 次的回調(diào)函數(shù),每次執(zhí)行完畢后繼續(xù)調(diào)用 ?window.requestAnimationFrame(process)?進(jìn)行下一次循環(huán)。如果要移動小球,那么就需要把繪制小球和修改圓心 x、y 坐標(biāo)的代碼寫到 ?process()? 函數(shù)中。

為了方便更新坐標(biāo),我們把小球的圓心坐標(biāo)保存到變量中,以方便對它們進(jìn)行修改,然后再定義兩個新的變量,分別表示在 x 軸方向上的速度?vx?,和 y 軸方向上的速度 ?vy?,然后把 context 相關(guān)的繪圖操作放到 ?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);

要計算圓心坐標(biāo) x、y 的移動距離,我們需要速度和時間,速度這里有了, 那么時間要怎么獲取呢? ?window.requestAnimationFrame() ?會把當(dāng)前時間的毫秒數(shù)(即時間戳)傳遞給回調(diào)函數(shù),我們可以把本次調(diào)用的時間戳保存起來,然后在下一次調(diào)用時計算出執(zhí)行這 1 幀動畫消耗了多少秒,然后根據(jù)這個秒數(shù)和 x、y 軸方向上的速度去計算移動距離,分別加到 x 和 y 上,以獲得最新的位置。注意這里的時間是上一次函數(shù)調(diào)用和本次函數(shù)調(diào)用的時間間隔,并不是第 1 次函數(shù)調(diào)用到當(dāng)前函數(shù)調(diào)用總共過去了多少秒,所以相當(dāng)于是時間增量,需要在之前 x 和 y 的值的基礎(chǔ)上進(jìn)行相加,代碼如下:

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() ?現(xiàn)在接收當(dāng)前時間戳作為參數(shù),然后做了下面這些操作:

  • 計算上次函數(shù)調(diào)用與本次函數(shù)調(diào)用的時間間隔,以秒計,記錄本次調(diào)用的時間戳用于下一次計算。
  • 根據(jù) x、y 方向上的速度,和剛剛計算出來的時間,計算出移動距離。
  • 調(diào)用 ?clearRect()? 清除矩形區(qū)域畫布,這里的參數(shù),前兩個是左上角坐標(biāo),后兩個是寬高,把 canvas 的寬高傳進(jìn)去就會把整個畫布清除。
  • 重新繪制小球。

現(xiàn)在小球就可以移動了:

moving-ball.gif

重構(gòu)代碼

上邊的代碼適合只有一個小球的情況,如果有多個小球需要繪制,就得編寫大量重復(fù)的代碼,這時我們可以把小球抽象成一個類,里邊有繪圖、更新位置等操作,還有坐標(biāo)、速度、半徑等屬性,重構(gòu)后的代碼如下:

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 畫筆屬性是通過構(gòu)造函數(shù)傳遞進(jìn)來的,更新位置的代碼放到了 ?update() ?方法中。

對于整個 canvas 的繪制過程,也可以抽象成一個類,當(dāng)作是游戲或引擎控制器,例如把它放到一個叫 ?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? 保存了上次函數(shù)執(zhí)行的時間戳的屬性,放到了構(gòu)造函數(shù)中。
  • ?init()? 方法創(chuàng)建了一個 ?circles? 數(shù)組,里邊放了兩個示例的小球,這里先不涉及碰撞問題。然后調(diào)用 ?window.requestAnimationFrame()? 開啟動畫。注意這里使用了 ?bind()? 來把 ?Gameboard? 的 this 綁定到回調(diào)函數(shù)中,以便于訪問 ?Gameboard? 中的方法和屬性。
  • ?process()? 方法也寫到了這里邊,每次執(zhí)行時會遍歷小球數(shù)組,對每個小球進(jìn)行位置更新,然后清除畫布,再重新繪制每個小球。
  • 最后初始化 ?Gameboard? 對象就可以開始執(zhí)行動畫了。

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

two-moving-balls.gif

碰撞檢測

為了實(shí)現(xiàn)仿真的物理特性,多個物體間碰撞會有相應(yīng)的反應(yīng),第一步就是要先檢測碰撞。我們先再多加幾個小球,以便于碰撞的發(fā)生,在 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),
];

然后給小球添加一個碰撞狀態(tài),在碰撞時,給兩個小球設(shè)置為不同的顏色:

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%)";
    // 其它代碼
  }
}

現(xiàn)在來判斷小球之間是否發(fā)生了碰撞,這個條件很簡單,判斷兩個小球圓心的距離是否小于兩個小球的半徑之和就可以了,如果小于等于則發(fā)生了碰撞,大于則沒有發(fā)生碰撞。圓心的距離即計算兩個坐標(biāo)點(diǎn)的距離,可以用公式:

微信截圖_20210302094152

x1、y1 和 x2、y2 分別兩個小球的圓心坐標(biāo)。在比較時,可以對半徑和進(jìn)行平方運(yùn)算,進(jìn)而省略對距離的開方運(yùn)算,也就是可以用下方的公式進(jìn)行比較:

微信截圖_20210302094200

r1 和 r2 為兩球的半徑。

在 Circle 類中,先添加一個?isCircleCollided(other)?方法,接收另一個小球?qū)ο笞鳛閰?shù),返回比較結(jié)果:

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) 方法,調(diào)用 isCircleCollided(other) 判斷碰撞后,把兩球的碰撞狀態(tài)設(shè)置為 true:

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

接著我們需要使用雙循環(huán)兩兩比對小球是否發(fā)生了碰撞,由于小球數(shù)組存放在 Gameboard 對象中,我們給它添加一個 ?checkCollision()? 方法來檢測碰撞:

checkCollision() {
  // 重置碰撞狀態(tài)
  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]);
    }
  }
}

因?yàn)樾∏蛟谂鲎埠缶蛻?yīng)立即彈開,所以我們一開始要把所有小球的碰撞狀態(tài)設(shè)置為 false,之后在循環(huán)中,對每個小球進(jìn)行檢測。這里注意到內(nèi)層循環(huán)是從 i + 1 開始的,這是因?yàn)樵谂袛?nbsp;1 球和 2 球是否碰撞后,就無須再判斷 2 球 和 1 球了。

之后在? process()? 方法中,執(zhí)行檢測,注意檢測應(yīng)該發(fā)生在使用 for 循環(huán)更新小球位置的后邊才準(zhǔn)確:

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

現(xiàn)在,可以看到小球在碰撞時,會改變顏色了。

collision-detect.gif

邊界碰撞

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

6dbee00a60c8d050a5d677040091c1b7

現(xiàn)在看一下代碼的實(shí)現(xiàn),在 Gameboard 類中添加一個 checkEdgeCollision() 方法,根據(jù)上邊描述的規(guī)則編寫如下代碼:

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

在代碼中,碰撞時,除了對速度進(jìn)行取反操作之外,還把小球的坐標(biāo)修改為緊臨邊界,防止超出。接下來在 process() 中添加對邊界碰撞的檢測:

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

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

edge-collision.gif

但是小球間的碰撞還沒有處理,在處理之前,先復(fù)習(xí)一下向量的基本操作,數(shù)學(xué)好的同學(xué)可以直接跳過,只看相關(guān)的代碼。

向量的基本操作

由于在碰撞時,需要對速度向量(或稱為矢量)進(jìn)行操作,向量是使用類似坐標(biāo)的形式表示的,例如 < 3, 5 > (這里用 <> 表示向量),它有長度和方向,對于它的運(yùn)算有一定的規(guī)則,本教程中需要用到向量的加法、減法、乘法、點(diǎn)乘和標(biāo)準(zhǔn)化操作。

向量相加只需要把兩個向量的 x 坐標(biāo)和 y 坐標(biāo)相加即可,例如:< 3 , 5 > + < 1 , 2 > = < 4 , 7 > <3, 5> + <1, 2> = <4, 7><3,5>+<1,2>=<4,7>

減法與加法類似,把 x 坐標(biāo)和 y 坐標(biāo)相減,例如:< 3 , 5 > ? < 1 , 2 > = < 2 , 3 > <3, 5> - <1, 2> = <2, 3><3,5>?<1,2>=<2,3>

乘法,這里指的是向量和標(biāo)量的乘法,標(biāo)量指的就是普通的數(shù)字,結(jié)果是把 x 和 y 分別和標(biāo)量相乘,例如:3 × < 3 , 5 > = < 9 , 15 > 3\times<3, 5> = <9, 15>3×<3,5>=<9,15>。

點(diǎn)乘是兩個向量相乘的一種方式,類似的還有叉乘,但是在本示例中用不到,點(diǎn)乘其實(shí)計算的是一個向量在另一個向量上的投影,它的計算方式為兩個向量的 x 的積加上 y 的積,它返回的是一個標(biāo)量,即第 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

標(biāo)準(zhǔn)化是除掉向量的長度,只剩下方向,這樣的向量它的長度為 1,稱為單位向量,標(biāo)準(zhǔn)化的過程是讓 x 和 y 分別除以向量的長度,因?yàn)橄蛄勘硎镜氖呛驮c(diǎn)(0, 0)的距離,所以可以直接使用 微信截圖_20210302094517 計算長度,例如 < 3, 4 > 標(biāo)準(zhǔn)化后的結(jié)果為:< 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。

了解了向量的基本運(yùn)算后,我們來創(chuàng)建一個 Vector 工具類,來方便我們進(jìn)行向量的運(yùn)算,它的代碼就是實(shí)現(xiàn)了這些運(yùn)算規(guī)則:

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

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

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

  /**
   * 向量標(biāo)準(zhǔn)化(除去長度)
   * @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);
  }
}

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

碰撞處理

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

e8938f413922027339bd013a0d061e77

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

動量守恒定律

微信截圖_20210302094633

動能守恒定律

微信截圖_20210302094637

m1、m2 分別為兩小球的質(zhì)量,v1 和 v2 為兩小球碰撞前的速度向量,v1' 和 v2' 為碰撞后的速度向量。根據(jù)這兩個公式可以推導(dǎo)出兩小球碰撞后的速度公式:

微信截圖_20210302094641

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

微信截圖_20210302094645

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

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

然后在 Gameboard 類的初始化小球處,給每個小球添加質(zhì)量:

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)? 方法來計算碰撞后的速度,它接收另一個小球?qū)ο笞鳛閰?shù),同時計算這兩個小球碰撞厚的速度和方向,這個是整個引擎的核心,我們一點(diǎn)一點(diǎn)的來看它是如何實(shí)現(xiàn)的。首先把兩個小球的速度使用 Vector 向量來表示:

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

因?yàn)槲覀儽旧砭鸵呀?jīng)使用 vx 和 vy 來表示水平和垂直方向上的速度向量了,所以直接把它們傳給 Vector 的構(gòu)造函數(shù)就可以了。?velocity1? 和 ?velocity2? 分別代表當(dāng)前小球和碰撞小球的速度向量。

接下來獲取連心線方向的向量,也就是兩個圓心坐標(biāo)的差:

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 是切線方向單位向量,切線方向其實(shí)就是把連心線向量的 x、y 坐標(biāo)互換,并把 y 坐標(biāo)取反。根據(jù)這兩個單位向量,使用點(diǎn)乘計算小球速度在這兩個方向上的投影:

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

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

計算結(jié)果是一個標(biāo)量,也就是沒有方向的速度值。v1n 和 v1t 表示當(dāng)前小球在連心線和切線方向的速度值,v2n 和 v2t 則表示的是碰撞小球 的速度值。在計算出兩小球的速度值之后,我們就有了碰撞后的速度公式所需要的變量值了,直接用代碼把公式套用進(jìn)去:

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

v1nAfter 和 v2nAfter 分別是兩小球碰撞后的速度,現(xiàn)在可以先判斷一下,如果 v1nAfter 小于 v2nAfter,那么第 1 個小球和第 2 個小球會越來越遠(yuǎn),此時不用處理碰撞:

if (v1nAfter < v2nAfter) {
  return;
}

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

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

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

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

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 語句中調(diào)用此方法,即在檢測到碰撞時:

checkCollideWith(other) {
  if (this.isCircleCollided(other)) {
    this.colliding = true;
    other.colliding = true;
    this.changeVelocityAndDirection(other); // 在這里調(diào)用
  }
}

這時,小球的碰撞效果就實(shí)現(xiàn)了。

ball-collision.gif

非彈性碰撞

現(xiàn)在小球之間的碰撞屬于完全彈性碰撞,即碰撞之后不會有能量損失,這樣小球永遠(yuǎn)不會停止運(yùn)動,我們可以讓小球在碰撞之后損失一點(diǎn)能量,來模擬更真實(shí)的物理效果。要讓小球碰撞后有能量損失,可以使用恢復(fù)系數(shù),它是一個取值范圍為 0 到 1 的數(shù)值,每次碰撞后,乘以它就可以減慢速度,如果恢復(fù)系數(shù)為 1 則為完全彈性碰撞,為 0 則是完全非彈性碰撞,之間的數(shù)值為非彈性碰撞,現(xiàn)實(shí)生活中的碰撞都是非彈性碰撞。

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

  checkEdgeCollision() {
    const cor = 0.8;                  // 設(shè)置恢復(fù)系統(tǒng)
    this.circles.forEach((circle) => {
      // 左右墻壁碰撞
      if (circle.x < circle.r) {
        circle.vx = -circle.vx * cor; // 加上恢復(fù)系數(shù)
        circle.x = circle.r;
      } else if (circle.x > width - circle.r) {
        circle.vx = -circle.vx * cor; // 加上恢復(fù)系數(shù)
        circle.x = width - circle.r;
      }

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

接下來設(shè)置小球的恢復(fù)系數(shù),給 Circle 類再加上一個恢復(fù)系數(shù) cor 屬性,每個小球可以設(shè)置不同的數(shù)值,來讓它們有不同的彈性,然后在初始化小球時設(shè)置隨意的恢復(fù)系數(shù):

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

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

微信截圖_20210302095120

接著把公式轉(zhuǎn)換為代碼,在 Circle 類的 changeVelocityAndDirection() 方法中,替換掉 v1nAfter 和 v2nAfter 的計算公式:

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

這里要注意的是兩小球碰撞時的恢復(fù)系數(shù)應(yīng)取兩者的最小值,按照常識,彈性小的無論是去撞別人還是別人撞它,都會有同樣的效果?,F(xiàn)在小球碰撞后速度會有所減慢,不過還差一點(diǎn),我們可以加上重力來讓小球自然下落。 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;
  }
}

重力加速度大約是  微信截圖_20210302095209,但是由于我們的畫布是以象素為單位的,所以使用 9.8 看起來會像是沒有重力,或者像是從很遠(yuǎn)的地方觀察小球,這時候可以把重力加速度放大一定倍數(shù)來達(dá)到更逼真的效果。

gravity.gif

總結(jié)

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

來總結(jié)一下開發(fā)過程:

  • 使用 context 繪制小球。
  • 搭建 Canvas 動畫基礎(chǔ)結(jié)構(gòu),主要使用 ?window.requestAnimationFrame?方法反復(fù)執(zhí)行回調(diào)函數(shù)。
  • 移動小球,通過小球的速度和函數(shù)執(zhí)行時的時間戳來計算移動距離。
  • 碰撞檢測,通過比對兩個小球的距離和它們半徑的和。
  • 邊界碰撞的檢測和方向改變。
  • 小球之間的碰撞,應(yīng)用速度公式和向量操作計算出碰撞后的速度和方向。
  • 利用恢復(fù)系數(shù)實(shí)現(xiàn)非彈性碰撞。
  • 添加重力效果。

代碼可以在以下地址中查看:

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

推薦好課:JavaScript微課、JavaScript基礎(chǔ)實(shí)戰(zhàn)JavaScript面向?qū)ο缶幊?/a>


1 人點(diǎn)贊