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

第29回Box2Dで落としたボールを床に弾ませる

前回の第28回物理演算エンジンBox2Dでボールを落とすは、全体を通してなんとかひとつのオブジェクトを自由落下させた。今回は床を加えて、ボールを弾ませる。ようやく物理演算シミュレーションらしくなってきた。

床をつくる

前回つくったボールは、落ちたままぶつかるものが何もないので、ステージ下端に消えた。そこで、床を加えよう。床をつくる関数(createStaticFloor())は、Box2Dを初期化する関数(initializeBox2D())から呼び出す。引数には、ステージの幅と高さを加えた。また、床の矩形情報は、Rectangleオブジェクトで変数(floor)に納めている。

床の剛体を定義するb2BodyDefオブジェクトも、ボールと同じ関数(defineBody())でつくる。ただし、床は動かないので、引数に与える剛体の種類がb2Body.b2_staticBody定数になる(再掲第28回表1⁠。人形役の目に見える床は、これも新たに定めた関数(createVisualFloor())で矩形のShapeオブジェクトをつくって返した。中身は、BitmapとShapeでオブジェクトは異なるものの、ボールの関数(createVisualBall())と基本的に同じだ。

第28回表1 剛体の種類を定めるb2Body定数(再掲)
マウスイベント剛体の種類
b2_dynamicBody動的2
b2_kinematicBodyキネマティック1
b2_staticBody静的0
var standardRadius = 20;
var floor = new createjs.Rectangle();
function initialize() {

  floor.width = stageWidth * 0.8;
  floor.x = (stageWidth - floor.width) / 2;
  // initializeBox2D(gravity);
  initializeBox2D(gravity, stageWidth, stageHeight);

}
// function initializeBox2D(gravity) {
function initializeBox2D(gravity, stageWidth, stageHeight) {

  var floorShape = createStaticFloor(stageWidth / 2, stageHeight - standardRadius, floor.width, standardRadius, "#CCCCCC");
  stage.addChild(floorShape);
}

function createStaticFloor(nX, nY, nWidth, nHeight, color) {
  var staticBody = Box2D.Dynamics.b2Body.b2_staticBody
  var bodyDef = defineBody(nX, nY, staticBody);
  var floorShape = createVisualFloor(nWidth, nHeight, color, bodyDef);
  createBody(world, bodyDef);
  return floorShape;
}

function createVisualFloor(nWidth, nHeight, color, bodyDef) {
  var floorShape = new createjs.Shape();
  floorShape.regX = nWidth / 2;
  floorShape.regY = nHeight / 2;
  floorShape.graphics
  .beginFill(color)
  .drawRect(0, 0, nWidth, nHeight);
  bodyDef.userData = floorShape;
  return floorShape;
}

第28回コード1「Box2Dでボールを自由落下させる」に上記のスクリプトを書き加えて試すと、床がデフォルトのステージ左上角に取り残されてしまう図1⁠。これは、Box2Dの物理空間のシミュレーションに、まだ床が加わっていないためだ。

図1 床がステージの左上角に置かれたまま
図1 床がステージの左上角に置かれたまま

すべての剛体を物理シミュレーションする

Ticker.tickイベントのリスナー(tick())から呼出した物理シミュレーションを進める関数(update())は、b2World.GetBodyList()メソッドで物理空間に加えられた初めの剛体を取り出している。そして、黒子の物理演算の結果を人形役のボールに当てはめて、ボールは自由落下した。それで終わってしまうと、床についての物理シミュレーションが行われない。だから、床がデフォルトの位置に取り残されたのだ。

物理空間のつぎの剛体を取り出すには、ボールのb2Bodyオブジェクトにb2Body.GetNext()メソッドを呼び出せばよい。これで、つぎの剛体である床のb2Bodyオブジェクトが得られる。なお、初めの剛体を取り出すのはb2Worldクラスのb2World.GetBodyList()つぎの剛体からはb2Bodyクラスのb2Body.GetNext()メソッドを用いることに注意してほしい。

b2Bodyオブジェクト.GetNext()

今のところ、剛体はボールと床のふたつだ。けれど、次回はボール数を増やしたい。そこで、whileループを用い、ありったけのb2Bodyオブジェクトをb2Body.GetNext()メソッドで順に取出して物理シミュレートすることにした。最後の剛体を取り出し終わると、b2Body.GetNext()メソッドはnullを返して、ループから抜ける。

function update(delta) {

  var body = world.GetBodyList();
  while (body) {
    var myObject = body.GetUserData();

    body = body.GetNext();
  }
}

これでどうだとばかり、アニメーションを確かめると、床は確かにステージ下に表れた。ところが、ボールは床をすり抜けるイリュージョンが繰り広げられる図2⁠。私たちは人形を見て、ボールとか床とかを捉えている。だが、物理演算する黒子は、剛体の座標しか知らされていない。かたちがわからないのだから、ぶつかり合うシミュレーションのしようがないのだ。

図2 落下するボールが床をすり抜ける
図2 落下するボールが床をすり抜ける

まだ物理シミュレーションとしてでき上がっていないものの、参照の便を考えて、ここまでをコード1としてまとめておこう。つぎは、黒子にふたつの剛体のかたちを教える。

コード1 静的な床の剛体にボールを自由落下させる
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;
var standardRadius = 20;
var floor = new createjs.Rectangle();
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;
  floor.width = stageWidth * 0.8;
  floor.x = (stageWidth - floor.width) / 2;
  initializeBox2D(gravity, stageWidth, stageHeight);
  createjs.Ticker.timingMode = createjs.Ticker.RAF;
  preloadImage("images/Pen.png");
}
function initializeBox2D(gravity, stageWidth, stageHeight) {
  world = new Box2D.Dynamics.b2World(gravity, true);
  var floorShape = createStaticFloor(stageWidth / 2, stageHeight - standardRadius, floor.width, standardRadius, "#CCCCCC");
  stage.addChild(floorShape);
}
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 createStaticFloor(nX, nY, nWidth, nHeight, color) {
  var staticBody = Box2D.Dynamics.b2Body.b2_staticBody
  var bodyDef = defineBody(nX, nY, staticBody);
  var floorShape = createVisualFloor(nWidth, nHeight, color, bodyDef);
  createBody(world, bodyDef);
  return floorShape;
}
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();
  while (body) {
    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;
    }
    body = body.GetNext();
  }
}
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 createVisualFloor(nWidth, nHeight, color, bodyDef) {
  var floorShape = new createjs.Shape();
  floorShape.regX = nWidth / 2;
  floorShape.regY = nHeight / 2;
  floorShape.graphics
  .beginFill(color)
  .drawRect(0, 0, nWidth, nHeight);
  bodyDef.userData = floorShape;
  return floorShape;
}
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();
}

かたちを定める

剛体の大きさやかたちはフィクスチャというオブジェクトで決める。例によって、その定義から始めなければならない。b2FixtureDefオブジェクトをつくり、そのb2FixtureDef.shapeプロパティにBox2Dのシェイプ(b2Shapeおよびそのサブクラスの)オブジェクトで大きさとかたちを与える。正円はb2CircleShape()コンストラクタの引数に半径を渡せばよい。矩形はb2PolygonShapeオブジェクトに、b2PolygonShape.SetAsBox()メソッドで幅と高さを定める。なお、幅と高さは原点(中心)から端までの長さなので、矩形の大きさの半分とする。

フィクスチャを定義する関数(defineFixture())は新たに定めた。ボールと床をつくる関数(createDynamicBall()とcreateStaticFloor())から、それぞれシェイプオブジェクトを引数に渡して呼び出す。そして、剛体のb2Bodyオブジェクトにフィクスチャオブジェクトを与えるのが、b2Body.CreateFixture()メソッドだ。b2FixtureDefオブジェクトを引数とする。そこで、剛体をつくる関数(createBody())の引数にb2FixtureDefオブジェクト(fixtureDef)が加えられ、それをメソッドに渡して呼び出すことにした。

function createDynamicBall(nX, nY, radius) {

  var circleShape = new Box2D.Collision.Shapes.b2CircleShape(radius * SCALE);
  var fixtureDef = defineFixture(circleShape);
  // createBody(world, bodyDef);
  createBody(world, bodyDef, fixtureDef);

}
function createStaticFloor(nX, nY, nWidth, nHeight, color) {

  var boxShape = new Box2D.Collision.Shapes.b2PolygonShape();
  var fixtureDef = defineFixture(boxShape);
  boxShape.SetAsBox(nWidth / 2 * SCALE, nHeight / 2 * SCALE);
  // createBody(world, bodyDef);
  createBody(world, bodyDef, fixtureDef);

}

function defineFixture(myShape) {
  var fixtureDef = new Box2D.Dynamics.b2FixtureDef();
  fixtureDef.shape = myShape;
  return fixtureDef;
}
// function createBody(world, bodyDef) {
function createBody(world, bodyDef, fixtureDef) {

  body.CreateFixture(fixtureDef);
}

黒子のBox2Dに、ふたつの剛体の大きさとかたちを教えた。これで、互いのぶつかり合いがわかるはずだ。アニメーションを見ると、ボールは床に落ちても弾みもせず、体操の完璧な着地のごとく、床に吸いつくように止まる図3⁠。そろそろ物理エンジンの性格がわかってきたろう。ボールが弾む材質だと教えていないからだ。

図3 落下したボールが床に吸いつくように着地する
図3 落下したボールが床に吸いつくように着地する

材質を定める

物理演算エンジンは教えたことしかやらない。しかし、教えさえすれば、仕事は完璧にこなす。あと、もう少しの辛抱だ。材質はフィクスチャの定義に定める。b2FixtureDefオブジェクトには、つぎの表1のように3つのプロパティが備わっている[1]⁠。それらを、以下の新たな関数(setFixture())によりb2FixtureDefオブジェクトに与えた。

表1 フィクスチャを定義するb2FixtureDefオブジェクトに定めるプロパティと値
b2FixtureDefプロパティ意味
density密度重さ(kg)/大きさ(m2
friction摩擦0から1の間の数値
restitution弾性0から1の間の数値
function createDynamicBall(nX, nY, radius) {

  setFixture(fixtureDef, 1, 0.1, 0.8);

}

function setFixture(fixtureDef, density, friction, restitution) {
  fixtureDef.density = density;
  fixtureDef.friction = friction;
  fixtureDef.restitution = restitution;
}

これでようやく、動的なボールの剛体を静的な床の剛体の上に自由落下させて、弾ませる物理演算シミュレーションができた。アニメーションを確かめると、ボールが床の上で軽やかにバウンドする図4⁠。弾性は大きめにしたので、弾みがいい。摩擦はボールの数をもう少し増やさないと、違いがわかりにくいだろう。

図4 落下したボールが床の上で弾む
図4 落下したボールが床の上で弾む

今回はここまでとしよう。JavaScript全体はコード2にまとめた。また、jsdo.itにもサンプルコードを掲げてある。次回は、たくさんのボールを、位置や大きさを変えながらつぎつぎと落とす。もっとも、ここまでできてしまうと、Box2Dについて新たに学ぶべき知識はさほどない。そこで、スクリプトの改善も少し加えるつもりだ。

コード2 自由落下させたボールを静的な床の上で弾ませる
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;
var standardRadius = 20;
var floor = new createjs.Rectangle();
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;
  floor.width = stageWidth * 0.8;
  floor.x = (stageWidth - floor.width) / 2;
  initializeBox2D(gravity, stageWidth, stageHeight);
  createjs.Ticker.timingMode = createjs.Ticker.RAF;
  preloadImage("images/Pen.png");
}
function initializeBox2D(gravity, stageWidth, stageHeight) {
  world = new Box2D.Dynamics.b2World(gravity, true);
  var floorShape = createStaticFloor(stageWidth / 2, stageHeight - standardRadius, floor.width, standardRadius, "#CCCCCC");
  stage.addChild(floorShape);
}
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);
  var circleShape = new Box2D.Collision.Shapes.b2CircleShape(radius * SCALE);
  var fixtureDef = defineFixture(circleShape);
  setFixture(fixtureDef, 1, 0.1, 0.8);
  createBody(world, bodyDef, fixtureDef);
  return ball;
}
function createStaticFloor(nX, nY, nWidth, nHeight, color) {
  var staticBody = Box2D.Dynamics.b2Body.b2_staticBody;
  var bodyDef = defineBody(nX, nY, staticBody);
  var floorShape = createVisualFloor(nWidth, nHeight, color, bodyDef);
  var boxShape = new Box2D.Collision.Shapes.b2PolygonShape();
  var fixtureDef = defineFixture(boxShape);
  boxShape.SetAsBox(nWidth / 2 * SCALE, nHeight / 2 * SCALE);
  createBody(world, bodyDef, fixtureDef);
  return floorShape;
}
function defineBody(nX , nY, bodyType) {
  var bodyDef = new Box2D.Dynamics.b2BodyDef();
  bodyDef.position.Set(nX * SCALE, nY * SCALE);
  bodyDef.type = bodyType;
  return bodyDef;
}
function defineFixture(myShape) {
  var fixtureDef = new Box2D.Dynamics.b2FixtureDef();
  fixtureDef.shape = myShape;
  return fixtureDef;
}
function setFixture(fixtureDef, density, friction, restitution) {
  fixtureDef.density = density;
  fixtureDef.friction = friction;
  fixtureDef.restitution = restitution;
}
function createBody(world, bodyDef, fixtureDef) {
  var body = world.CreateBody(bodyDef);
  body.CreateFixture(fixtureDef);
}
function update(delta) {
  world.Step(delta / 1000, velocityIterations, positionIterations);
  var body = world.GetBodyList();
  while (body) {
    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;
    }
    body = body.GetNext();
  }
}
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 createVisualFloor(nWidth, nHeight, color, bodyDef) {
  var floorShape = new createjs.Shape();
  floorShape.regX = nWidth / 2;
  floorShape.regY = nHeight / 2;
  floorShape.graphics
  .beginFill(color)
  .drawRect(0, 0, nWidth, nHeight);
  bodyDef.userData = floorShape;
  return floorShape;
}
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();
}

おすすめ記事

記事・ニュース一覧