物理エンジン「Tiny2D.js」のソースコード詳説
はじめに
本記事では、物理エンジン「Tiny2D.js」のコードを見ながら、どのような構造になっているのかを見てゆきます。まずは、こちらからTiny2D.jsのコードをダウンロードしてください(JSGameRe_Physics.zip)。本記事とコードを見比べながら読み進めることで、より理解が進むでしょう。Visual Studio Codeをインストールしていない人は、拡張子をhtmlやtxtに変換することでコードを見ることができます。
物理エンジン「Tiny2D.js」のコード詳細解説
それでは、さっそくTiny2D.jsのコードを見てみましょう。たった250行程度しかありません。順番に解説していきます。定数の定義
まず、先頭でさまざまな定数を定義しています。
ベクトル用クラスの定義
ベクトル用のクラスの定義は以下の通りです。Vecはプロパティxとyを持ち、プロトタイプにadd、mul、dot、cross、moveという5つのメソッドを定義しています。
矩形/線オブジェクトの定義
矩形オブジェクトの定義は以下の通りです。コンストラクタの引数は左上座標(x, y)と大きさ(width, height)です。
shapeは形状、typeは移動するか(BodyDynamic)、固定するか(BodyStatic)を指定します。矩形はエンジンによる移動対象ではないのでBodyStaticとしています。また、座標(x, y)とサイズ(w, h)というプロパティも設定しています。decelerationは減速度合です。矩形は動かさないので1を指定しています。isHitは衝突判定用のメソッドで、(i, j)が矩形の中にあるときにtrueを返します。
線オブジェクトの定義は以下の通りです。コンストラクタの引数は始点(x0, y0)、終点(x1, y1)、と反発係数restitutionです。
まず、shapeにShapeLineを、typeにBodyStaticを指定しています。プロパティ(x, y)は線分の中点です。また始点・終点もそれぞれプロパティとして保存します。反発係数restitutionは引数で指定された場合はその値を、省略された場合は0.9を使用します。vecは線分ベクトルで、normはその法線方向のベクトルを正規化したものです。xとyを入れ替えて、vecを長さlengthで割ること(=1 / lengthを掛ける)で求めています。
円オブジェクトの定義
円オブジェクトは長いので、分割しながら見てゆきます。まずは定義部分です。
コンストラクタの引数(x, y)は円の中心座標、radiusは半径、typeは移動する円か固定する円かの指定、restitutionは反発係数、decelerationは減速度合です。それぞれの値をプロパティとして設定します。accelは加速度ベクトル、velocityは速度ベクトルです。
moveは円を移動するメソッド、isHitは座標が円に含まれるかを判定するメソッドです。「JavaScriptで簡易物理エンジンを実装する」(https://thinkit.co.jp/article/8466)の“円と円の衝突判定”で解説したように、三平方の定理を使っています。
collidedWithRectメソッドは円(自分)と矩形(引数)の衝突処理を行います。
まず、“円と矩形の衝突判定”(https://thinkit.co.jp/article/8466)で解説した内容をそのまま実装しています。4辺と最も近い座標(nx, ny)を求め、それが円の中になければ衝突なしとしてreturnします。自分にonhitメソッドが定義されている場合、そのメソッドを呼び出します。d2は(nx, ny)と円の中心座標(x, y)の距離の二乗です。この値を使って重なっている距離overlapを求めます。
次に、衝突した場所に応じて円の進行方向を変化させます。(mx, my)は重なり部分の大きさで、0で初期化しておきます。これは、めり込んだ量を元に戻すための変数で、方向を反転するためにも利用します。上辺か下辺に衝突した場合は上下方向へ反転し、左辺か右辺に衝突した場合は左右方向へ反転します。
円の速度が早い場合に(nx, ny)が矩形の中に入る場合もありますが、その際はvelocityのxとyを反転して外に押し戻しています。本来は進入方向を鑑みて外に押し戻したほうが自然な動きになるので、興味のある方は修正してみてください。
その後、this.move(mx, my)で円の中心座標を移動して重なりを解消し、(mx, my)の値に応じてx軸もしくはy軸方向に速度を反転させています。
collidedWithLineは円と線の衝突を処理します。
難しそうに見えるかもしれませんが、“円と線の衝突判定”で解説した内容をそのまま実装に落とし込んでいるだけです。コード中にある変数v0、v1、v2は下図の通りです。
- v0=円の中心から線分の始点へのベクトル
このコードが実行されるときには、既に速度ベクトルが加算されて移動後の場所になっています。よって速度ベクトルを引いていることに注意してください。つまり、x軸について見るとline.x0 – (this.x - this.velocity.x)という計算を行っています。これはline.x0 - this.x + this.velocity.xと同じ意味になります。 - v1=円の速度ベクトル
- v2=線分ベクトル
あとは、ベクトルの外積を求めて衝突判定を行っているだけです。衝突した場合はcrossedがtrueとなります。衝突した場合は円の速度ベクトルを変化させる必要がありますが、その計算は“動く円と静止円の衝突”で解説した内容と同じです。
this.move(-this.velocity.x, -this.velocity.y);
で衝突前の座標に戻し、法線単位ベクトルとの速度の内積を求めることで法線方向の成分を求め、その値を2倍しています。
let dot0 = this.velocity.dot(line.norm); // 法線と速度の内積
let vec0 = line.norm.mul(-2 * dot0);
あとは、もともとのベクトルにその値を加算し、最後に反発係数をかけ合わせています。これらの処理を行うことで速度ベクトルを反射しています。コード見るだけではわかりづらいかもしれませんが、“動く円と静止円の衝突”の解説と照らし合わせながら読んでゆけば理解しやすいと思います。
最後に、円と円の衝突です。この処理も“円と円の衝突判定”、“動く円と静止円の衝突”、“動く円と動く円の衝突”で解説した内容をコードに落とし込んでいるだけです。
自分の円はthis、相手の円はpeerという変数で管理しています。2つの円の中心距離を求め、その値が2つの円の半径の合計よりも大きい場合は衝突していないのでreturnで戻ります。衝突した場合は自分と相手のonhitプロパティを見て、定義されている場合はそのメソッドを呼び出します。
distanceは2つの円の中心の距離を格納する変数です。
let distance = Math.sqrt(d2) || 0.01;
この行には想い入れがあるので、少し詳しく解説します。最初は以下のように実装していました。
let distance = Math.sqrt(d2);
実は、多少複雑なゲームを作ったときに、円オブジェクトがすべて画面から消えてしまうというバグに数日間悩まされました。再現性が低く、決まった再現方法はありませんでした。結局、console.logで情報を出力させつつ、辛抱強く再現をさせることでその原因が明らかにすることができました。その原因がこの行だったのです。
少し後に、以下の行があります。
let aNormUnit = v.mul(1 / distance); // 法線単位ベクトル1
もし2つの円がまったく同じ座標にあったときの中心距離は0になり、distanceも0になります。そうです! 2つの円がまったく同じ座標になったときに0で除算が行われていたのです。JavaScriptでは0で割ると結果はNaN(Not a Number)になってしまいます。一旦NaNになると、足し算をしても掛け算をしても結果はNaNとなり、円の座標が不正なため描画されなくなってしまったのです。よって、距離が0のときは0.01という小さな値を使用することでこの状況に対処しました。
overlapは重なりの距離です。vは2つの円の中心を結ぶベクトルで、これを円の中心間の距離distanceで割ることで法線ベクトルaNormUnitを求めています。bNormUnitは逆向きの法線ベクトルです。aNormUnitを-1倍することで求めています。
あとは、if文で3つの場合に応じて処理を切り分けています。
- this.type == BodyDynamic && peer.type == BodyStatic
自分が動く円で相手が固定円の場合 - peer.type == BodyDynamic && this.type == BodyStatic
自分が固定円で相手が動く円の場合 - それ以外
片方が固定円の場合は、円と線の衝突と同じ処理を行っています。moveメソッドを使って重なった量を移動し、2つの物体が重なっている状態を解消します。そして法線ベクトルとの内積を求めて2倍し、それに自分自身のベクトルを足すことで反射後のベクトルを求めています。
“それ以外”の場合は、“動く円と動く円の衝突”で解説した内容をコードに落とし込んでいます。それぞれ円にmoveメソッドを使って重なった量の半分を移動し、2つの物体が重なっている状態を解消します。法線ベクトルのxとyを入れ替えて接線ベクトルを求めます。それぞれの円の法線と接線ベクトルが求められたので、自分の速度ベクトルとの内積から法線方向、接線方向の成分を求めます。それらを適切に加算することで反射後の速度を求めています。
Engineオブジェクトの定義
最後は、物理エンジンの全体を管理するEngineオブジェクトです。まずはコンストラクタと初期化部分からです。
物理世界の左上座標を(x, y)、そのサイズを(width, height)、重力をgravityXとgravityYで指定します。コンストラクタでは、これらの値をプロパティに格納します。entitiesは円、矩形、線といった物理エンジンの世界のオブジェクトを保持する配列です。ゲームの途中で重力の向きを変えたくなるかもしれないと思ったので、メソッドsetGravityを用意しました。
stepは物理世界の時計を少しだけ進めるメソッドです。物理エンジンの中心的役割をします。これも少し長いので、分割して解説します。
まず、重力に経過時間を掛けて、その時間分の速度gravityを求めています。次に配列の中で(e.type == BodyDynamic)を満たすオブジェクトに対して速度gravity、加速度accelによる変化分を加算し、減速度合いを掛け合わせています。その結果求められたvelocity分をmoveメソッドで移動させています。つまり移動対象となるオブジェクトの速度を更新し、その値に応じて移動させているのです。これで物理世界内にあるオブジェクトが移動します。
実は、本来であれば速度(velocity)に時間(elapsed)を掛けて移動距離を求め、その距離をmoveメソッドに引き渡すべきでした。現在の実装では単に速度を渡しているだけなので単位時間当たりの移動距離となっています。サンプルゲームレベルでは特に問題にならなかったので割り切ってしまいました。興味のある方は修正してみてください。
しかし、移動させただけでは不十分です。衝突を判定して、衝突した場合には速度ベクトルを反射させる必要があります。その処理内容は以下の通りです。
まず、物理世界の外に飛び出してしまったオブジェクトを削除します。世界の外にあるオブジェクトについて計算しても無駄だからです。Array.filterを使って物理世界の中にあるオブジェクトのみを抽出しています。
次に、衝突判定です。for文の内容がいつもの単純な2重ループと異なることに注意してください。例えば、オブジェクト1とオブジェクト2の衝突判定を行ったら、オブジェクト2とオブジェクト1の衝突判定を行う必要はありません。また、自分と自分の衝突判定を行うのもナンセンスです。このような状況を避けるためにforの繰り返し範囲を調整しています。あとはオブジェクトをe0とe1として取り出し、それぞれのタイプ、形状に応じて適切なメソッドを呼び出しています。例えば、双方がBodyStaticのときはお互いに固定されているので何も処理をする必要がありません。お互いが円のときはcollidedWithCircle()を、円と線の時はcollidedWithLine()を、円と矩形のときはcollidedWithRect()をといった具合です。それぞれのメソッドについては既に解説した通りです。
以上で物理エンジンのコードに関する詳細解説は終わりです。世間一般で広く利用されている他の物理エンジンとは比べようもないほどシンプルで最低限の機能しかありませんが、入門用のきっかけとなることを目的とした題材です。まずは、このエンジンを活用したアプリを作ってみてください。そのうち機能が足りないことに不満を覚えることでしょう。そうしたら、次の段階として物理エンジンそのものに機能を追加したり、パフォーマンスを改善したり、どんどん作り変えてより良いものにしてみてください。その過程で色々なスキルを身につけることができるはずです。
物理エンジンの動作を確認してみよう
本記事の締めくくりとして、物理エンジンを使用したデモを紹介します。説明だけではなかなか理解しにくいと思いますが、実際の動きを見ることで理解が深まると思います。
デモ(demo.html)
矩形、線、円(固定)、円(移動)といったオブジェクトを画面上に配置しただけのサンプルです。シンプルなページですが、それなりに面白い動きをします。
デモのサンプルは、本記事の冒頭でダウンロードしたファイルに含まれています。まずは、デモを実行して動きを確認してみましょう。
物理エンジンはいろいろなページから参照するので、別ファイル(サンプルファイル「Tiny2D.js」)に保存しました。外部のJavaScriptファイルを取り込む場合は以下のようにscript要素を使用します。
<script src="Tiny2D.js"></script>
それでは、サンプルコードを見てゆきましょう。Tiny2d.jsと同様に、コード(demo.html)を見ながら解説を読み進めてください。
広域変数は、engine(物理エンジンオブジェクト)、ctx(グラフィックコンテキスト)、colors(色の配列)の3つだけです。関数rand(v)は整数の乱数を返します。
init()
init()から実行が開始されます。物理世界を作成してオブジェクトを配置しています。
物理世界はEngineオブジェクトとして実装されており、以下の命令で作成します(A)。
engine = new Engine(0, 0, 600, 800, 0, 9.8);
Engineオブジェクトのコンストラクタの引数は、世界のx座標、y座標、幅、高さ、x方向の重力、y方向の重力です。ここでは、左上座標(0,0)、幅600、高800で下方向に重力がある世界を作っています。
Tiny2Dでサポートしている物理オブジェクトは、以下の3種類です。
- RectangleEntity(x, y, width, height)
(x, y)を左上座標とする幅width、高さheightの矩形を作成します。 - CircleEntity(x, y, radius, type, restitution, deceleration)
(x, y)を中心座標とする半径radiusの円を作成します。typeはBodyStatic(円が固定されている)か、BodyDynamic(動的に動くか)を指定します。デフォルトはBodyDynamicです。restitutionは反発係数、decelerationは減速度合いとなります。restitution, decelerationは省略可能です。 - LineEntity(x0, y0, x1, y1, restitution)
(x0, y0)から(x1, y1) への線を引きます。restitutionは反発係数です。restitutionは省略可能です。
矩形、円、線と作成していますが、それぞれのオブジェクトのコンストラクタを呼び出してオブジェクトを作成しているだけです。特に難しいところはないと思います。作成しているオブジェクトの様子を下図に示します。
作成したオブジェクトはengine.entities.push(r)で物理世界に追加します(B)。あとはengine.step()で物理世界の時計を進めれば、物理世界のオブジェクトが動き始めます。その座標を取得して描画すれば、あたかも物が動いているように見えるというわけです。
ちなみに、円オブジェクトは動かすことができますが、その初速度を以下のように設定しています。
r.velocity.x = rand(10) - 5;
r.velocity.y = rand(10) - 5;
あとは最後にグラフィックコンテキストを取得し、setIntervalでメインループを開始しています。
JavaScriptではオブジェクトにプロパティを追加することができます。RectangleEntityやCircleEntityといった物理世界のオブジェクトも例外ではありません。今回は描画用にcolorプロパティを追加しています。
tick()
tick()では、エンジンの時間を0.01進め、再描画を行うという処理を行っています。
repaint()
repaint()では再描画を行います。
まずAで画面全体を黒で塗りつぶしてクリアします。物理世界にあるオブジェクトは物理エンジンのentitiesプロパティ(配列)に格納されているので、Bのfor文で要素を順番に取り出します。
CでコンテキストのfillStyle(塗りつぶし色)とstrokeStyle(描画色)をその要素のcolorプロパティで設定し、Dのswitch文を使って形状に応じた処理を呼び出しています。ShapeRectangle(矩形)のときはfillRectを使って矩形を描画し、ShapeCircle(円)のときはarcを使って円を描画し、ShapeLine(線)のときはmoveToとlineToで線を描画しています。
たったこれだけで物理オブジェクトが画面上を動き回ってくれるのです。おもしろいとおもいませんか? ところで、このデモを実行していると、円が線を飛び越えるという現象に気づいた人もいると思います。実はこれはknown issueです。動く円が何かと衝突して向きを変えるとき、その速度ベクトルを変更するとともに、重なりを解消するため重なり量を移動させています。実はその際にも本当は衝突判定をすべきなのですが、このエンジンでは衝突判定をしていないのです。よって、移動量が大きかったりする場合に、このような現象が起きてしまいます。修正も考えたのですがコード量が増えそうだったので今回は見送りました。ご了承ください。
さいごに
「ゲームで学ぶJavaScript入門 増補改訂版~ブラウザゲームづくりでHTML&CSSも身につく!」では、実際にTiny2D.jsを使用した下記のようなサンプルゲームを解説しています。
これらゲームにも興味がある方は、ぜひ本書を手に取ってみてください。