HTML5のCanvasでつくるダイナミックな表現―CreateJSを使う

第28回物理演算エンジンBox2Dでボールを落とす

今回から3回ほどにわたって、物理計算にもとづくアニメーションをつくってみたい。物理空間に置かれたものが、落ちたり互いにぶつかり合ったりしたときの位置や速度はライブラリで計算する。今回使うのは、さまざまな言語に移植されていて定評のあるBox2Dという2次元の物理演算エンジンだ。つぎのjsdo.itのアニメーションをお題とする。

Box2dWebを使う

Box2DはもともとC++で開発された2次元の物理演算エンジンだ[1]⁠。重力と物体の質量や摩擦、弾性にもとづく位置と動きの物理計算を行い、シミュレーションしてくれる。ActionScript 3.0やJava、C#、Pythonなど、さまざまな言語に移植されてきた。JavaScriptのライブラリとしても、いくつか公開されている。その中でも対応するバージョンが新しく、解説(英文)も整っているBox2dWebを使ってみたい。

まずは、インストールについて簡単に紹介しておこう。Box2dWebのサイトの左カラムに「Downloads」のリンクがある図1⁠。

図1 Box2dWebのサイト
図1 Box2dWebのサイト

ダウンロードしたZip圧縮ファイル(Box2dWeb-2.1a.3.zip)を展開すると、ふたつのJavaScript(JS)ファィルが入っている図2⁠。どちらかひとつのJSファイルをライブラリ用の適切なフォルダに納めればよい。サーバーに上げるにはmin(コンパクト)版が容量をくわない。実装を調べるときは通常版を用いるとよいだろう。

図2 ダウンロードして展開したBox2dWebのライブラリJSファィル
図2 ダウンロードして展開したBox2dWebのライブラリJSファィル

そして、HTMLドキュメントにscript要素で、ライブラリに置いたBox2dWeb(以下「Box2D」とする)のJSファイルを読込む。CreateJSからはEaselJSのほか、ボールに用いる画像を読込むためにPreloadJSも加える。

<script src="http://code.createjs.com/easeljs-0.7.1.min.js"></script>
<script src="http://code.createjs.com/preloadjs-0.4.1.min.js"></script>
<script src="lib/Box2dWeb-2.1.a.3.min.js"></script>

また、body要素のonLoad属性にJavaScriptコードの初期化の関数(initialize())を定めるのはいつもどおりだ。なお、Canvasの幅(400)と高さ(300)はいつもより多めにした。

<body onLoad="initialize()">
  <canvas id="myCanvas" width="400" height="300"></canvas>
</body>

ボールの画像をBitmapオブジェクトに読み込む

物理演算エンジンを初めて使うとき、まず知っておかなければならないのは、ライブラリは物体の位置や動きの数値計算をするだけで、何も動かないし見えないということだ。目に見えるオブジェクトは別につくっておいて、演算結果を与えることで、初めて物理シミュレーションが表現できる。Box2Dとオブジェクトとの間は、人形劇の黒子と人形のような関係だと捉えればよい。

先に、目に見える人形役からつくってしまおう。ボールのPNG画像ファイル(Pen.png)はアルファを抜いてフォルダ(images)に入れた図3⁠。画像ファイルの読み込み待ちには、PreloadJSを用いる。PreloadJSの具体的な使い方については、第2回「トゥイーンをランダムに定める」CreateJS新バージョンに対応した修正を加えるで解説したので、こちらをお読みいただきたい。

図3 ボールのPNGファイルはアルファを抜いてフォルダに納めておく
図3 ボールのPNGファイルはアルファを抜いてフォルダに納めておく

Box2Dでボールをひとつ落とすスクリプトは、後にコード1としてまとめた。その中で、PreloadJSで読込だ画像ファイルを、Bitmapインスタンスに与えてステージに置くまでが以下の抜書きだ。PreloadJSにより外部ファイルを読込む関数(preloadImage())は、初期設定の関数(initialize())から呼び出される。

画像ファイルのURLを引数に受取った関数(preloadImage())は、LoadQueueクラスでロードし始め、読込み終えたときのリスナー関数(loadFinished())を定める。リスナー関数は、画像の(Image)オブジェクトや半径などを変数(ballImageおよびimageRadius)に納めるとともに、Ticker.tickイベントにリスナーを加えた。そして、画像からつくったBitmapインスタンスをステージに置く関数(addBall())が呼び出される。

画像のオブジェクトをステージに置く関数(addBall())は、さらに別の関数(createVisualBall())でBitmapインスタンスをつくっている。物理シミュレーションでは物体の重心を座標の原点とするため、この関数でBitmapオブジェクトの基準点を中心に定めた。また、引数に渡す半径(radius)で、ボールの大きさが伸縮できるようにしてある。

処理の行数はさほどないのに関数が多いのは、後あと書き加えたいからだ。読込んだ画像からつくられたBitmapインスタンスは、とくに座標を与えられていないため、中心の基準点がステージの左上角に置かれる図4⁠。Bitmapオブジェクトは人形であって、位置は黒子のBox2Dが決めるので、このままにしておいて構わない。

図4 人形役のボールがデフォルトの左上角に置かれた
図4 人形役のボールがデフォルトの左上角に置かれた
var stage;

var ballImage;
var imageRadius;
function initialize() {
  var canvasElement = document.getElementById("myCanvas");

  stage = new createjs.Stage(canvasElement);

  createjs.Ticker.timingMode = createjs.Ticker.RAF;
  preloadImage("images/Pen.png");
}

function tick(eventObject) {

  stage.update();
}
function addBall() {
  var ball = createVisualBall(imageRadius);
  stage.addChild(ball);
}

function createVisualBall(radius) {
  var ball = new createjs.Bitmap(ballImage);
  ball.regX = ballImage.width / 2;
  ball.regY = ballImage.height / 2;
  ball.scaleX = ball.scaleY = radius / imageRadius;

  return ball;
}
function preloadImage(file) {
  var loader = new createjs.LoadQueue(false);
  loader.addEventListener("fileload", loadFinished);
  loader.loadFile(file);
}
function loadFinished(eventObject) {
  ballImage = eventObject.result;
  imageRadius = ballImage.width / 2;
  createjs.Ticker.addEventListener("tick", tick);
  addBall();
}

物理空間と剛体を定める

ここから、本題の物理シミュレーションにかかる。ただし、理科の実験と似て、その準備から始めなければならない。しかも、この段階で結果は動きとして見えないので、少しばかり辛抱が要る。準備するのは、つぎの3つだ。なお、⁠剛体」というのは物理演算で扱いやすい、かたちのかわらない物体だ[2]⁠。

【物理演算シミュレーションの準備】
  1. 物理空間をつくる
  2. 剛体を定義する
  3. 剛体定義に表示オブジェクトを関連づける

第1の物理空間は、Box2Dのb2Worldクラスのオブジェクトとしてつくる。b2World()コンストラクタの第1引数に渡す重力は、2次元のベクトルを表すb2Vec2オブジェクトで定める。第2引数のスリープはブール(論理)値で、trueを渡すと動かなくなった剛体はシミュレーションから外して(スリープして)無駄を省く[3]⁠。

var world = new Box2D.Dynamics.b2World(重力, スリープ);

Box2dWebには、数多くのクラスがある。それらは「Box2D」から始まる名前空間でまとめられたり、分けられたりしている。そのため、そのままではクラスを示すのに打ち込む文字がどうしても長くなる。だが、今のところ使うクラスが多くないので、そのまま書くことにしよう。

物理空間をつくる関数(initializeBox2D())は、初期設定の関数(initialize())から呼び出される。引数には、重力を示すb2Vec2オブジェクトを渡す。重力は通常垂直(y軸)方向の力だ。その値は、変数(gravityVertical)に定めて、b2Vec2()コンストラクタの第2引数に渡した。第1引数のx座標値は0だ。物理空間をつくる関数が受取った引数(gravity)のb2Vec2オブジェクトは、b2World()コンストラクタの第1引数に渡す。そして、でき上がったb2Worldインスタンスは、変数(world)に納めた。

var world;
var gravityVertical = 15;

function initialize() {

  var gravity = new Box2D.Common.Math.b2Vec2(0, gravityVertical); 

  initializeBox2D(gravity);

}
function initializeBox2D(gravity) {
  world = new Box2D.Dynamics.b2World(gravity, true);
}

第2に、剛体の定義だ。剛体はすぐにはできず、あらかじめb2BodyDefオブジェクトで定義をしなければならない。そこで、落下するオブジェクトをつくる関数(addBall())は、新たな関数(createDynamicBall())を呼び出すよう書き替えた。その関数から剛体定義の関数(defineBody())とBitmapインスタンス生成の関数(createVisualBall())が呼び出される。剛体を定義する関数には、引数として剛体のxy座標と、剛体の種類をつぎに掲げたb2Body定数で渡す表1⁠。

表1 剛体の種類を定めるb2Body定数
b2Body定数剛体の種類
b2_dynamicBody動的2
b2_kinematicBodyキネマティック1
b2_staticBody静的0

剛体定義の関数(defineBody())は、b2BodyDef()コンストラクタで新たにつくったインスタンス(bodyDef)にxy座標と剛体の種類を定める。前者はb2BodyDef.positionプロパティから得られるb2Vec2オブジェクトにb2Vec2.Set()メソッドで、後者はb2BodyDef.typeプロパティで与えた。重力で落とす剛体は、b2Body.b2_dynamicBody定数だ。

なお、Box2Dは物理学と同じメートル(m⁠⁠・キログラム(kg⁠⁠・秒の単位にもとづいてシミュレーションする。そこで、地図の縮尺のように、ピクセルからメートルへの換算比率(メートル/ピクセル)を定数(SCALE)にした。したがって、ピクセルで与えられた数値をBox2Dの物理空間座標として渡すときには、この定数値を乗じる。

var SCALE = 1 / 30;

function addBall() {
  // var ball = createVisualBall(imageRadius);
  var ball = createDynamicBall(stageWidth / 2, -imageRadius, imageRadius);
}
function createDynamicBall(nX, nY, radius) {
  var dynamicBody = Box2D.Dynamics.b2Body.b2_dynamicBody;
  var bodyDef = defineBody(nX, nY, dynamicBody);
  var ball = createVisualBall(radius, bodyDef);

  return ball;
}
function defineBody(nX , nY, bodyType) {
  var bodyDef = new Box2D.Dynamics.b2BodyDef();
  bodyDef.position.Set(nX * SCALE, nY * SCALE);
  bodyDef.type = bodyType;
  return bodyDef;
}

第3は、剛体の定義と目に見えるBitmapオブジェクトを関連づける。Bitmapインスタンス生成の関数(createVisualBall())に加えた第2引数にb2BodyDefオブジェクトが渡されるので、b2BodyDef.userDataプロパティに定めた。物理シミュレーションを行うときには、このプロパティからインスタンスを取り出して位置や回転角を与えることになる。

// function createVisualBall(radius) {
function createVisualBall(radius, bodyDef) {
  var ball = new createjs.Bitmap(ballImage);

  bodyDef.userData = ball;
  return ball;
}

剛体をつくって落とす ー 物理演算シミュレーションの実行

ようやく、物理演算シミュレーションに取りかかれる。そのためには、剛体の定義から剛体をつくらなければならない。そのうえで、時間を進めてシミュレーションし、物理演算の結果を目に見えるオブジェクトに当てはめる。

【物理演算シミュレーションの準備】
  1. 剛体の定義から剛体をつくる
  2. 時間を進めてシミュレーションする

第4の仕事となる剛体づくりは、第1で定めた物理空間b2Worldオブジェクトのメソッドb2World.CreateBody()で行う。引数に渡すのが剛体を定義したb2BodyDefオブジェクトだ。

b2Worldオブジェクト.CreateBody(b2BodyDefオブジェクト)

b2World.CreateBody()メソッドは、新たに定める関数(createBody())から呼び出す。そして、この関数の呼出しは、落下するボールをつくる関数(createDynamicBall())に加えた。引数にはb2Worldとb2BodyDefのオブジェクト(worldとbodyDef)を渡す。これで、このボールをつくる関数は、すでに定めてあった剛体の定義と目に見えるボールの生成に加えて、ボールの剛体をつくるまで一手に担うことになった。

function createDynamicBall(nX, nY, radius) {

  createBody(world, bodyDef);

}

function createBody(world, bodyDef) {
  var body = world.CreateBody(bodyDef);
}

いよいよ、第5の物理シミュレーションだ。物理空間の時間を進めて、人形劇の黒子であるBox2Dの演算を行う。物理空間のb2Worldオブジェクトに対して時間を進めるメソッドが、b2World.Step()だ。第1引数には、シミュレーションのために進める時間を秒数で渡す。第2および第3引数は、物理的な制約に合わせて調整の再計算をさせる回数だ。前者は速度、後者が位置についての演算を定める[4]⁠。

b2Worldオブジェクト.Step(経過秒数, 速度再計算, 位置再計算)

人形であるボールのBitmapオブジェクトは、Ticker.tickイベントのリスナー(tick())から呼出す新たな関数(update())で動かす。この関数がb2World.Step()メソッドを呼び出している。物理シミュレーションで進める秒数は、Ticker.tickイベントリスナーが受け取るイベントオブジェクトのdeltaプロパティから得て、関数の引数(delta)に渡した。単位はミリ秒なので、秒数に直してb2World.Step()メソッドの第1引数に渡している。

物理演算の時間を進めたら、剛体の幾何情報を人形のオブジェクトに伝える。物理空間に加えられた剛体のうち初めのb2Bodyオブジェクトを取り出すのが、b2World.GetBodyList()メソッドだ。剛体の位置は、b2Body.GetPosition()メソッドでb2Vec2オブジェクトとして得られる。また、その回転角は、b2Body.GetAngle()メソッドから返されるラジアン角を用いた。

そして、剛体定義のb2BodyDef.userDataプロパティに与えた人形のオブジェクトの参照は、定義でつくられた剛体のオブジェクトからb2Body.GetUserData()メソッドを呼び出して得られる。なお、DisplayObject.rotationプロパティの単位は度数なので、ラジアン角はMatrix2D.DEG_TO_RAD定数で除した。

var velocityIterations = 8;
var positionIterations = 3;

function tick(eventObject) {
  var delta = eventObject.delta;
  update(delta);

}

function update(delta) {
  world.Step(delta / 1000, velocityIterations, positionIterations);
  var body = world.GetBodyList();
  var myObject = body.GetUserData();
  if (myObject) {
    var position = body.GetPosition();
    myObject.x = position.x / SCALE;
    myObject.y = position.y / SCALE;
    myObject.rotation = body.GetAngle() / createjs.Matrix2D.DEG_TO_RAD;
  }
}

以上の5つの手順がBox2Dを使う最小限の仕込みだ。これで、物理シミュレーションの人形劇は演じられる。以下のコード1が、これまでのJavaScriptをまとめたものだ。さて、見るといささかがっかりするかもしれない。ステージ上端の真ん中からボールが落ちてきて下端に消えて終わる図5⁠。でも、重力にしたがう剛体を宙に置いたのだから、これが物理演算の結果であることに間違いはない。

図5 ステージ上端から表れたボールが落ちて下端に消える
図5 ステージ上端から表れたボールが落ちて下端に消える
コード1 Box2Dでボールを自由落下させる
var SCALE = 1 / 30;
var stage;
var world;
var gravityVertical = 15;
var velocityIterations = 8;
var positionIterations = 3;
var stageWidth;
var stageHeight;
var ballImage;
var imageRadius;
function initialize() {
  var canvasElement = document.getElementById("myCanvas");
  var gravity = new Box2D.Common.Math.b2Vec2(0, gravityVertical);
  stage = new createjs.Stage(canvasElement);
  stageWidth = canvasElement.width;
  stageHeight = canvasElement.height;
  initializeBox2D(gravity);
  createjs.Ticker.timingMode = createjs.Ticker.RAF;
  preloadImage("images/Pen.png");
}
function initializeBox2D(gravity) {
  world = new Box2D.Dynamics.b2World(gravity, true);
}
function tick(eventObject) {
  var delta = eventObject.delta;
  update(delta);
  stage.update();
}
function addBall() {
  var ball = createDynamicBall(stageWidth / 2, -imageRadius, imageRadius);
  stage.addChild(ball);
}
function createDynamicBall(nX, nY, radius) {
  var dynamicBody = Box2D.Dynamics.b2Body.b2_dynamicBody;
  var bodyDef = defineBody(nX, nY, dynamicBody);
  var ball = createVisualBall(radius, bodyDef);

  createBody(world, bodyDef);
  return ball;
}
function defineBody(nX , nY, bodyType) {
  var bodyDef = new Box2D.Dynamics.b2BodyDef();
  bodyDef.position.Set(nX * SCALE, nY * SCALE);
  bodyDef.type = bodyType;
  return bodyDef;
}
function createBody(world, bodyDef) {
  var body = world.CreateBody(bodyDef);
}
function update(delta) {
  world.Step(delta / 1000, velocityIterations, positionIterations);
  var body = world.GetBodyList();
  var myObject = body.GetUserData();
  if (myObject) {
    var position = body.GetPosition();
    myObject.x = position.x / SCALE;
    myObject.y = position.y / SCALE;
    myObject.rotation = body.GetAngle() / createjs.Matrix2D.DEG_TO_RAD;
  }
}
function createVisualBall(radius, bodyDef) {
  var ball = new createjs.Bitmap(ballImage);
  ball.regX = ballImage.width / 2;
  ball.regY = ballImage.height / 2;
  ball.scaleX = ball.scaleY = radius / imageRadius;
  bodyDef.userData = ball;
  return ball;
}
function preloadImage(file) {
  var loader = new createjs.LoadQueue(false);
  loader.addEventListener("fileload", loadFinished);
  loader.loadFile(file);
}
function loadFinished(eventObject) {
  ballImage = eventObject.result;
  imageRadius = ballImage.width / 2;
  createjs.Ticker.addEventListener("tick", tick);
  addBall();
}

結果がつまらないのは、オブジェクトがひとつしかないからだ。複数の剛体が互いにぶつかり合えば、跳ね返って動きも変わる。ただ、残念ながら今回はここまでとする。すでに剛体をひとつつくって動かした。これを増やすことはさほど難しくない。これまでの長い道のりは次回に活かされるので、ご期待いただきたい。なお、つまらないサンプルとはいえ、ご参考までにjsdo.itにもコード1を掲げた。

おすすめ記事

記事・ニュース一覧