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

第20回立方体のワイヤーフレームを水平に回す

第15回からこれまで、3次元表現のお題をふたつ仕上げた。3次元座標や透視投影にも慣れ、簡単なクラスの扱いもわかってきたことだろう。そこでもうひとつ、3次元空間のインタラクティブなお題に取り組みたい。立方体を、マウスポインタの位置に応じて水平および垂直に回してみる。今回を含めて、3回ほどでつくるつもりのサンプルが、つぎのjsdo.itのコードだ。

立方体の頂点をワイヤーフレームで結ぶ

今回書くコードは、ワイヤーフレームの立方体を、マウスポインタの水平座標に応じてy軸で回すところまでになる。ワイヤーフレームの星形をy軸で回すというのが、ひとつ目のお題だった。そのでき上がりとなる第17回コード2冒頭に3次元座標のクラスと透視投影のメソッドを定義したy軸で星形が回るアニメーションを書き替えていくことにする(再掲第17回図1⁠。ご参考までに、このjsdo.itのサンプルも以下に添えた。

第17回図1 星形の3次元座標がy軸で水平に回って透視投影される(再掲)
第17回図1 星形の3次元座標がy軸で水平に回って透視投影される 第17回図1 星形の3次元座標がy軸で水平に回って透視投影される

第17回コード2では、y軸で回した3次元の頂点座標は、透視投影したうえで、2次元の頂点を直線で結んだ。描くかたちは、星形にはかぎられない。そこで、座標を立方体の8頂点に差し替えてみよう。原点(0, 0, 0)は立方体の真ん中に置く。すると、一辺の半分の長さ(halfEdge)を決めれば、立方体の8頂点座標は定まる図1⁠。

図1 立方体の原点を中心に置くと一辺の半分の長さで8頂点座標が決められる
図1 立方体の原点を中心に置くと一辺の半分の長さで8頂点座標が決められる

一辺の半分の長さから8頂点座標(Point3Dオブジェクト)の配列を返す関数(createCubePoints())はつぎのように定めて、第17回コード2の星形座標の関数(createStarPoints())と差し替える。なお、回り始める角速度(angle)は0にして、初めは止まった状態とした。

var points;
var angle = 0;    // Math.PI / 36;

function initialize() {

  // points = createStarPoints(5, 65, 25);
  points = createCubePoints(50);

}

/*
function createStarPoints(numVertices, longRadius, shortRadius) {

}
*/
function createCubePoints(halfEdge) {
  var cubePoints = [
    new Point3D(-halfEdge, -halfEdge, -halfEdge),
    new Point3D(halfEdge,  -halfEdge, -halfEdge),
    new Point3D(halfEdge, halfEdge, -halfEdge),
    new Point3D(-halfEdge, halfEdge, -halfEdge),
    new Point3D(-halfEdge, -halfEdge, halfEdge),
    new Point3D(halfEdge, -halfEdge, halfEdge),
    new Point3D(halfEdge, halfEdge, halfEdge),
    new Point3D(-halfEdge, halfEdge, halfEdge)
  ];
  return cubePoints;
}

この書替えだけで、まがりなりにもワイヤーフレームのアニメーションとしては動く。もっとも、頂点はひと筆書きで結ばれてしまうため、6面の立方体には見えない図2⁠。したがって、つぎは面ごとに分けて描くにはどうしたらよいかだ。

図2 立方体の8頂点がひと筆書きで結ばれたアニメーションになる
図2 立方体の8頂点がひと筆書きで結ばれたアニメーションになる 図2 立方体の8頂点がひと筆書きで結ばれたアニメーションになる

立方体の面を頂点番号で定める

立方体のそれぞれの面を、分けて扱わなければならないことはわかった。とはいえ、面ごとに座標をもたせるのは無駄だ。四角形の4頂点座標に6面を掛けると、24座標になる。けれど、立方体はひとつの頂点を3つの四角形が共有しているので、座標そのものは8つで済む。

そこで、立方体の8頂点にインデックス(整数番号)を与える図3⁠。すると、立方体の各面はその4頂点の番号で定められる。そして、回転の変換と透視投影は8頂点座標に加え、面を描くときに頂点番号から座標を求めればよい。そのため、頂点に定めるインデックスは、8頂点座標を入れた配列内のインデックスと揃えておく。一般に、3次元座標の変換や透視投影より、インデックスから値を探して取出す方が負荷は低い。

図3 立方体の8頂点にインデックスを与える
図3 立方体の8頂点にインデックスを与える

3次元座標(Point3D)と同じく、面もつぎのようにクラス(Face)で定めよう。コンストラクタの引数は、4頂点番号だ。また、配列(Arrayオブジェクト)と同じように扱いたいので、頂点番号はインスタンスの整数インデックス(0~3)にエレメントとして納め、インスタンスのlengthプロパティも定めた[1]⁠。

function Face(pos0, pos1, pos2, pos3) {
  this.length = 4;
  this[0] = pos0;
  this[1] = pos1;
  this[2] = pos2;
  this[3] = pos3;
}

この面のクラス(Face)のインスタンスを配列に納めて返す関数(getFacesVertices())は、つぎのとおりだ。とりあえず、立方体の前と後ろの2面だけで試す。立方体の8頂点座標(points)と面の頂点番号(facesVertices)から面を描く関数(drawFaces())は、この後定める。

var points;
var facesVertices;
function initialize() {

  points = createCubePoints(50);
  facesVertices = getFacesVertices();
  // draw(points);
  drawFaces(points, facesVertices);
}

function getFacesVertices() {
  var vertices = [
    new Face(0, 1, 2, 3),
    new Face(5, 4, 7, 6)
  ];
  return vertices;
}

面(Face)のオブジェクトの配列(facesVertices)から順にエレメントを取出して、面の頂点番号を座標に直したうえでひとつひとつ描く。ひとつの面の座標の組(配列)からひと筆書きで線描する仕事は、第17回コード2の関数(draw())がほぼそのまま使える。

新たにほしいのは、立方体の8頂点の座標から面の頂点番号に対応したものを拾って返す関数だ。これは、面のクラス(Face)のメソッド(getFacePoints())として定めよう。面の頂点番号はインスタンスがもっているのだから、引数には立方体の8頂点の座標の配列を渡せばよい。戻り値は、面の頂点の座標を納めた配列だ。

このメソッドはできたとしよう。論文をあらすじから考えるのと同じで、いきなり細かい実装に手をつけるより、処理の大きな流れを先に考えた方が見通しはよくなる。また、実装の中身もはっきりするだろう。

面(Face)のオブジェクトの配列(facesVertices)から順にエレメントを取出して、すべての面を描く関数(drawFaces())はつぎのように定めた。引数は、立方体の8頂点座標が納められた配列(points)と面のオブジェクトの配列(faces)だ。この関数は、前掲の初期化の関数(initialize())に加え、アニメーションのリスナー関数(rotate())からも呼び出される。

function rotate(eventObject) {

  // draw(points2D);
  drawFaces(points2D, facesVertices);
}
function drawFaces(points, faces) {
  var numFaces = faces.length;
  drawGraphics.clear();
  for (var i = 0; i < numFaces; i++) {
    var face = faces[i];
    var facePoints = face.getFacePoints(points);
    draw(facePoints);
  }
  stage.update();
}

面のクラス(Face)のメソッド(getFacePoints())ができたことにしてしまえば、とくに難しいところはない。ひとつ補っておくと、1面を線描する関数(draw())につぎのような手直しが加わる。複数の面を描くことになったので、Graphics.clear()メソッドの呼出しは前掲のすべての面を描く関数(drawFaces())から行う。Stage.update()メソッドの呼出しも同じだ。

function draw(points) {
  var count = points.length;
  var point = points[count - 1];
  drawGraphics   // .clear()
  .beginStroke("mediumblue")
  .setStrokeStyle(3)
  .moveTo(point.x, point.y);
  for (var i = 0; i < count; i++) {
    point = points[i];
    drawGraphics.lineTo(point.x, point.y);
  }
  // stage.update();
}

今回の課題で残るは、面のクラス(Face)に新たに加えるメソッド(getFacePoints())だ。透視投影した立方体の8頂点座標が納められた配列を引数(points)に受け取って、自らの4頂点番号に合った座標エレメントを取り出し、頂点番号の順に新たな配列(facePoints)に入れて返す。予め、8頂点座標の配列内のインデックスと頂点番号は揃えておいた。したがって、自らに定められた4つの頂点番号から、そのインデックスの座標エレメントを取り出せばよい。

Face.prototype.getFacePoints = function (points) {
  var faces = this.length;
  var facePoints = [];
  for (var i = 0; i < faces; i++) {
    facePoints[i] = points[this[i]];
  }
  return facePoints;
};

これで、アニメーションの骨組みはできた。確かめてみると、立方体の前面と後面が、マウスポインタの位置に応じて水平に回る図4⁠。では、以下のように立方体の両側面も座標の配列を返す関数(getFacesVertices())に含めよう。また、面のワイヤーフレームを描く関数(draw())における線幅の指定は細くする。

図4 立方体の前面と後面がマウスポインタの位置に応じて水平に回る
図4 立方体の前面と後面がマウスポインタの位置に応じて水平に回る 図4 立方体の前面と後面がマウスポインタの位置に応じて水平に回る
function draw(points) {

  drawGraphics
  .beginStroke("mediumblue")
  .setStrokeStyle(1)
  .moveTo(point.x, point.y);

}

function getFacesVertices() {
  var vertices = [
    new Face(0, 1, 2, 3),
    new Face(1, 5, 6, 2),
    new Face(4, 0, 3, 7),
    new Face(5, 4, 7, 6)
  ];
  return vertices;
}

読者の中には、初めから立方体の4面を入れておけば早かったと思われた方もあろう。けれど実は、筆者が初めてコードを試したとき、立方体の1面しか描かれなかった。理由を探るために、ワイヤーフレームを描く関数(draw())が呼び出されているか、引数の座標は正しいかといったことを調べた。原因は関数に、前述したGraphics.clear()メソッドの呼出しが残っていたためだった。こうした場合、面の数が多いと、その分確かめる情報が増えて煩わしいことになるのだ。

今回書上げたコード全体を以下にまとめよう。見やすさも考えて、クラス定義はscript要素を分けたコード1⁠。クラスを使い回す機会ができたら、このJavaScriptコードはJavaScript(JS)ファイルにして読み込んでもよいだろう。

コード1 EaselJS 0.7.1の読込みと3次元座標および面のクラス定義
<script src="http://code.createjs.com/easeljs-0.7.1.min.js"></script>
<script>
// Point3D
function Point3D(x, y, z) {
  this.x = x;
  this.y = y;
  this.z = z;
}
Point3D.prototype.getProjetedPoint = function(focalLength) {
  var point2D = new createjs.Point();
  var w = focalLength / (focalLength + this.z);
  point2D.x = this.x * w;
  point2D.y = this.y * w;
  return point2D;
};
// Face
function Face(pos0, pos1, pos2, pos3) {
  this.length = 4;
  this[0] = pos0;
  this[1] = pos1;
  this[2] = pos2;
  this[3] = pos3;
}
Face.prototype.getFacePoints = function (points) {
  var faces = this.length;
  var facePoints = [];
  for (var i = 0; i < faces; i++) {
    facePoints[i] = points[this[i]];
  }
  return facePoints;
};
</script>

立方体の4面をワイヤーフレームで描き、マウスポインタの位置に応じて水平に回すアニメーションのスクリプトは以下のとおりだコード2⁠。つぎは、立方体の4面を塗りたい。その場合、前回第19回3次元空間で弾むオブジェクトとz座標による重ね順の並べ替えで学んだように、面の重ね順を考えなければならない。次回は、配列の並べ替えではなく、ベクトルの外積を使って解決する。

コード2 ワイヤーフレームで描いた立方体の4面をマウスポインタの位置に応じて水平に回す
<script>
var stage;
var drawGraphics;
var points;
var angle = 0;
var matrix = new createjs.Matrix2D();
var stageCenterX;
var _point = new createjs.Point();
var points2D = [];
var facesVertices;
var focalLength = 300;
function initialize() {
  var canvasElement = document.getElementById("myCanvas");
  stage = new createjs.Stage(canvasElement);
  stageCenterX = canvasElement.width / 2;
  drawGraphics = createGraphics(stageCenterX, canvasElement.height / 2);
  points = createCubePoints(50);
  facesVertices = getFacesVertices();
  drawFaces(points, facesVertices);
  createjs.Ticker.addEventListener("tick", rotate);
  stage.addEventListener("stagemousemove", setAngle);
}
function setAngle(eventObject) {
  var mouseX = eventObject.stageX;
  angle = (mouseX - stageCenterX) * 1 / 300;
}
function rotate(eventObject) {
  var count = points.length;
  points2D.length = 0;
  matrix.identity().rotate(angle);
  for (var i = 0; i < count; i++) {
    var point = points[i];
    matrix.transformPoint(point.x, point.z, _point);
    point.x = _point.x;
    point.z = _point.y;
    points2D[i] = point.getProjetedPoint(focalLength);
  }
  drawFaces(points2D, facesVertices);
}
function drawFaces(points, faces) {
  var numFaces = faces.length;
  drawGraphics.clear();
  for (var i = 0; i < numFaces; i++) {
    var face = faces[i];
    var facePoints = face.getFacePoints(points);
    draw(facePoints);
  }
  stage.update();
}
function draw(points) {
  var count = points.length;
  var point = points[count - 1];
  drawGraphics
  .beginStroke("mediumblue")
  .setStrokeStyle(1)
  .moveTo(point.x, point.y);
  for (var i = 0; i < count; i++) {
    point = points[i];
    drawGraphics.lineTo(point.x, point.y);
  }
}
function createGraphics(x, y) {
  var drawShape = new createjs.Shape();
  drawShape.x = x;
  drawShape.y = y;
  stage.addChild(drawShape);
  return drawShape.graphics;
}
function createCubePoints(halfEdge) {
  var cubePoints = [
    new Point3D(-halfEdge, -halfEdge, -halfEdge),
    new Point3D(halfEdge,  -halfEdge, -halfEdge),
    new Point3D(halfEdge, halfEdge, -halfEdge),
    new Point3D(-halfEdge, halfEdge, -halfEdge),
    new Point3D(-halfEdge, -halfEdge, halfEdge),
    new Point3D(halfEdge, -halfEdge, halfEdge),
    new Point3D(halfEdge, halfEdge, halfEdge),
    new Point3D(-halfEdge, halfEdge, halfEdge)
  ];
  return cubePoints;
}
function getFacesVertices() {
  var vertices = [
    new Face(0, 1, 2, 3),
    new Face(1, 5, 6, 2),
    new Face(4, 0, 3, 7),
    new Face(5, 4, 7, 6)
  ];
  return vertices;
}
</script>

いつものとおり、jsdo.itにもサンプルを掲げた。クラス定義のscript要素は[HTML]の欄に加えてある。

おすすめ記事

記事・ニュース一覧