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

第19回3次元空間で弾むオブジェクトとz座標による重ね順の並べ替え

前回の第18回クラスの継承と透視投影では、Shapeのサブクラスとして3次元座標のボールのクラスを定め、100個のオブジェクトをランダムな向きに落としたうえで、2次元平面に透視投影した。今回は仕上げとして、3つ手を加える。まず、ボールを床で弾ませる。つぎに、ステージから見えなくなったオブジェクトは、メモリを無駄に使わないように片づける。そして、3次元の表現で忘れてならないのは、オブジェクトの重ね順を整えることだ。

ボールを床で弾ませる

第18回コード2100個つくったオブジェクトの3次元座標をを透視投影してランダムな向きに落とすを書き替えていく。まず、ボールを弾ませるには、以下のようなコードを加える。弾ませる床の垂直位置は変数(floor)に与えた。そして、ボールを落とすアニメーションの関数(move())で、床より落ちたボール(ball)の垂直速度(velocityY)を逆転し、床にめり込んでしまった垂直位置(realY)はその分持ち上げる。

var floor = 50;
var bounce = -0.6;

function move(ball) {
  ball.move(gravity);
  var realY = ball.realY;
  if (realY > floor) {
    ball.velocityY *= bounce;
    ball.realY = floor - realY % floor;
  }

}

これで、ボールは床で弾むようになる。このアニメーションの処理は、第13回「モーションブラーと弾むアニメーション」オブジェクトをステージ下端で弾ませるから抜き出した次のコードと基本的に同じだ。理解に不安のある読者は、こちらの解説をお読みいただきたい。

function animate(eventObject) {

  if (newY > stageHeight) {
    child.velocityY *= -0.8;
    newY = stageHeight - newY % stageHeight;
  }

}

つぎに、要らなくなったオブジェクトをメモリから除こう。やはりボールを落とすアニメーションの関数(move())で、以下のコードのようにふたつの軸で条件を与えた。ひとつは、透視投影したボールのx座標だ。この値がステージの端(0またはstageWidth)から外に出れば、見えなくなってもう戻ってくることはない。

もうひとつ、z座標(realZ)も考える。透視投影では、スクリーンの位置をz座標0として、視点は焦点距離下がった(マイナスの)位置に置く(再掲第16回図6⁠。すると、焦点距離の分手前(-focalLength)まで迫ったオブジェクトは眼の前にあり、さらに下がれば視点の後方に消える。したがって、そのオブジェクトも除いて構わない。

第16回図6 z軸における焦点距離と視野角(再掲)
第16回図6 z軸における焦点距離と視野角(再掲)

ステージから消すオブジェクトは、Stageオブジェクトの表示リストからContainer.removeChild()メソッドで除く。さらに、第18回コード2では、インスタンスを配列(balls)に入れてまとめた。この配列エレメントも削除したい。だが、配列エレメントはインデックスで指定しなければならない。

EaselJS 0.7.0で新たに備わったindexOf()関数に、配列と取出したいエレメントを引数に渡すと、そのエレメントのインデックスが返される。インデックスがわかれば、Array.splice()メソッドで配列エレメントは除ける。indexOf()はクラスに属さないグローバルな関数なので、名前空間「createjs」に続けて直接参照して呼び出す。

indexOf(配列, エレメント)
function move(ball) {
  ball.move(gravity);
  var x = ball.x;

  if (ball.realZ < -focalLength || x < 0 || stageWidth < x) {
    var index = createjs.indexOf(balls, ball);
    stage.removeChild(ball);
    balls.splice(index, 1);
  } else {

  }
}

これで、3次元空間にランダムに落ちたボールは床で弾む。そして、ステージから外れたオブジェクトは、参照が除かれ、やがてメモリからも消える(第14回「オブジェクトの使い回しとアニメーション素材の変更」ガベージコレクションを減らす参照⁠⁠。書直したscript要素のJavaScript全体は、つぎのコード1のとおりだ。

コード1 ランダムに落ちた100個のボールが床に落ちて弾みながらステージ外に消える
function Ball3D(radius, color) {
  this.initialize();
  this.radius = radius;
  this.color = color;
  this.realX = 0;
  this.realY = 0;
  this.realZ = 0;
  this.velocityX = 0;
  this.velocityY = 0;
  this.velocityZ = 0;
  this.drawBall(radius, color);
}
Ball3D.prototype = new createjs.Shape();
Ball3D.prototype.move = function(gravity) {
  this.realX += this.velocityX;
  this.realY += this.velocityY;
  this.realZ += this.velocityZ;
  this.velocityY += gravity;
};
Ball3D.prototype.getProjectedData = function(focalLength) {
    var scale = focalLength / (focalLength + this.realZ);
    var x = this.realX * scale;
    var y = this.realY * scale;
    return {x:x, y:y, scale:scale};
};
Ball3D.prototype.drawBall = function(radius, color) {
  this.graphics.beginFill(color)
  .drawCircle(0, 0, radius);
};
var stage;
var balls = [];
var numBalls = 100;
var stageWidth;
var centerX;
var centerY;
var gravity = 0.2;
var focalLength = 200;
var floor = 50;
var bounce = -0.6;
function initialize() {
  var canvasElement = document.getElementById("myCanvas");
  stage = new createjs.Stage(canvasElement);
  stageWidth = canvasElement.width;
  centerX = stageWidth / 2;
  centerY = canvasElement.height / 2;
  for (var i = 0; i < numBalls; i++) {
    var color = createjs.Graphics.getRGB(getRandom(0, 0xFFFFFF));
    var ball = new Ball3D(3, color);
    balls.push(ball);
    ball.realY = -50;
    ball.velocityX = getRandom(-3, 3);
    ball.velocityY = getRandom(-6, 0);
    ball.velocityZ = getRandom(-3, 3);
    stage.addChild(ball);
  }
  createjs.Ticker.addEventListener("tick", animate);
}
function animate(eventObject) {
  for (var i = balls.length - 1; i > -1; i--) {
    var ball = balls[i];
    move(ball);
  }
  stage.update();
}
function move(ball) {
  ball.move(gravity);
  var x = ball.x;
  var realY = ball.realY;
  if (realY > floor) {
    ball.velocityY *= bounce;
    ball.realY = floor - realY % floor;
  }
  if (ball.realZ < -focalLength || x < 0 || stageWidth < x) {
    var index = createjs.indexOf(balls, ball);
    stage.removeChild(ball);
    balls.splice(index, 1);

  } else {
    var data = ball.getProjectedData(focalLength);
    ball.scaleX = ball.scaleY = data.scale;
    ball.x = centerX + data.x;
    ball.y = centerY + data.y;
  }
}
function getRandom(min, max) {
  var randomNumber = Math.random() * (max - min) + min;
  return randomNumber;
}

残るは、オブジェクトの重ね順だ。このお題では、ボールの大きさはすべて同じにした。そのため、透視投影にしたがえば、手前のボールほど大きく映り、小さいボールがその前にあってはいけない図1⁠。この重ね順を正しく整えたい。

図1 透視投影で手前に見えるべきボールの前に小さいボールが重なる
図1 透視投影で手前に見えるべきボールの前に小さいボールが重なる

オブジェクトの重なりをz座標値の順に並べ替える

CreateJSでは、インスタンスは親オブジェクトの表示リストに加えられた順に手前に重なる。重ね順はあえて動かさないかぎり、ずっとそのままだ。重なりのインデックスは親インスタンス(今回はStageオブジェクト)Container.setChildIndex()メソッドで変えられる。

親オブジェクト.setChildIndex(子オブジェクト, インデックス)

すると、各ボールのz座標値の順序をどうやって調べるかだ。実は、そのためにオブジェクトを配列(balls)に納めておいた。配列にはArray.sort()という並べ替えのメソッドが備わっている。もちろん、私たちが定義したクラス(Ball3D)のプロパティ(realZ)で、何もいわずに都合よく並べ替えてはくれはしない。どう並べ替えるのかは比較関数というかたちで決めて、Array.sort()メソッドに渡す。

配列.sort(比較関数)

Array.sort()メソッドは比較関数を渡されると、並べ替えるエレメントをふたつ取り出しては関数にその順序を尋ねる[1]⁠。比較関数はそのふたつのエレメントを引数に受け取って、どちらが先になるのかを数値で返す。戻り値は、順序を入替えるなら正数、そのままでよければ負数、等しい(つまりそのままの)ときは0とする。

function 比較関数(a, b) {
  if (aがbより大きい) {
    return 1;
  } else if (aがbより小さい) {
    return -1;
  } else {   // aとbは等しい
    return 0;
  }
}

オブジェクトの重なりをz座標値(realZ)の順に整える関数(sortZ())は、配列(balls)を参照してArray.sort()メソッドでつぎのような比較関数(compare())により並べ替えればよい。配列エレメントは、z座標値の大きい(奥からの)順に揃う。

function sortZ() {

  balls.sort(compare);

}
function compare(a, b) {
  if (a.realZ > b.realZ) {
    return 1;
  } else if (a.realZ < b.realZ) {
    return -1;
  } else {
    return 0;
  }
}

そのうえで、オブジェクトの重なりを整える関数(sortZ())は、forループで配列(balls)のエレメントを順にすべて取り出して表示リストの中のインデックスをContainer.setChildIndex()メソッドで並べ替える。配列エレメントはz座標値が大きい順なので、表示リストのインデックスはいずれも0に定めれば、後から加えたオブジェクトほど奥に置かれていく。

function animate(eventObject) {

  sortZ();
  stage.update();
}

function sortZ() {
  var count = balls.length;
  balls.sort(compare);
  for (var i = 0; i < count; i++) {
    var ball = balls[i];
    stage.setChildIndex(ball, 0);
  }
}

これで、ボールのオブジェクトの重なりはz座標値の順に整えられる。手を加えたscript要素のJavaScript全体は、つぎのコード2のとおりだ。アニメーションが目で確かめづらいときは、ボールのインスタンスにアウトラインを描き、焦点距離は短くして遠近感を強め、フレームレートも落としてみるといいだろう図2⁠。

図1 ボールにアウトラインを加えて遠近感も強めたアニメーション
図1 ボールにアウトラインを加えて遠近感も強めたアニメーション
コード2 3次元座標空間に100個つくってランダムに落としたオブジェクトをz座標値の順に並べ替えたアニメーション
function Ball3D(radius, color) {
  this.initialize();
  this.radius = radius;
  this.color = color;
  this.realX = 0;
  this.realY = 0;
  this.realZ = 0;
  this.velocityX = 0;
  this.velocityY = 0;
  this.velocityZ = 0;
  this.drawBall(radius, color);
}
Ball3D.prototype = new createjs.Shape();
Ball3D.prototype.move = function(gravity) {
  this.realX += this.velocityX;
  this.realY += this.velocityY;
  this.realZ += this.velocityZ;
  this.velocityY += gravity;
};
Ball3D.prototype.getProjectedData = function(focalLength) {
    var scale = focalLength / (focalLength + this.realZ);
    var x = this.realX * scale;
    var y = this.realY * scale;
    return {x:x, y:y, scale:scale};
};
Ball3D.prototype.drawBall = function(radius, color) {
  this.graphics.beginFill(color)
  .drawCircle(0, 0, radius);
};
var stage;
var balls = [];
var numBalls = 100;
var stageWidth;
var centerX;
var centerY;
var gravity = 0.2;
var focalLength = 200;
var floor = 50;
var bounce = -0.6;
function initialize() {
  var canvasElement = document.getElementById("myCanvas");
  stage = new createjs.Stage(canvasElement);
  stageWidth = canvasElement.width;
  centerX = stageWidth / 2;
  centerY = canvasElement.height / 2;
  for (var i = 0; i < numBalls; i++) {
    var color = createjs.Graphics.getRGB(getRandom(0, 0xFFFFFF));
    var ball = new Ball3D(3, color);
    balls.push(ball);
    ball.realY = -50;
    ball.velocityX = getRandom(-3, 3);
    ball.velocityY = getRandom(-6, 0);
    ball.velocityZ = getRandom(-3, 3);
    stage.addChild(ball);
  }
  createjs.Ticker.addEventListener("tick", animate);
}
function animate(eventObject) {
  for (var i = balls.length - 1; i > -1; i--) {
    var ball = balls[i];
    move(ball);
  }
  sortZ();
  stage.update();
}
function move(ball) {
  ball.move(gravity);
  var x = ball.x;
  var realY = ball.realY;
  if (realY > floor) {
    ball.velocityY *= bounce;
    ball.realY = floor - realY % floor;
  }
  if (ball.realZ < -focalLength || x < 0 || stageWidth < x) {
    var index = createjs.indexOf(balls, ball);
    stage.removeChild(ball);
    balls.splice(index, 1);
console.log([stage.getNumChildren(), balls.length]);
  } else {
    var data = ball.getProjectedData(focalLength);
    ball.scaleX = ball.scaleY = data.scale;
    ball.x = centerX + data.x;
    ball.y = centerY + data.y;
  }
}
function sortZ() {
  var count = balls.length;
  balls.sort(compare);
  for (var i = 0; i < count; i++) {
    var ball = balls[i];
    stage.setChildIndex(ball, 0);
  }
}
function compare(a, b) {
  if (a.realZ > b.realZ) {
    return 1;
  } else if (a.realZ < b.realZ) {
    return -1;
  } else {
    return 0;
  }
}
function getRandom(min, max) {
  var randomNumber = Math.random() * (max - min) + min;
  return randomNumber;
}

jsdo.itにもコード2のサンプルを掲げた。ただし、背景色は黒に定めてある。⁠Fireworks」⁠花火)というタイトルにイメージが近づいたのではないか。

おすすめ記事

記事・ニュース一覧