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

第21回水平に回す立方体の面を塗る

前回の第20回立方体のワイヤーフレームを水平に回すでつくった立方体の四方の面を塗りたい。ただしその場合は、面をどの順序で塗るか、考えておかなければならない。そうしないと、面の重ね順が崩れる図1⁠。今回は、⁠ベクトルの外積」を使って解決することにしよう。

図1 順序を考えずに塗ると面の重ね順が崩れる
図1 順序を考えずに塗ると面の重ね順が崩れる 図1 順序を考えずに塗ると面の重ね順が崩れる

面の塗り順をどう扱うか

3次元空間でオブジェクトを描く順序については、第19回3次元空間で弾むオブジェクトとz座標による重ね順の並べ替えで学んだ。このときは、Array.sort()メソッドにより、オブジェクトの重なりをz座標値の順に並べ替えることにしたのだった。今回も、配列の並べ替えで塗り順を決める手は使える。だが、Array.sort()メソッドの処理の負荷は決して軽くない。第19回※1をつぎに改めて引用した。この動画を見れば、JavaScriptの手間もうかがい知れよう。

実は、今回扱う立方体は、閉じた凸の多面体という扱いやすいかたちだ。まず、面の裏側は外から決して見えない。つまり、裏返った面は描かなければ済む。つぎに、表向きの面は、互いに重なることはない。したがって、表の面を描く順序は考えなくて構わないのだ。

閉じた凸の多面体
  • 面の裏は決して見えない
  • 表向きの面は重ならない

2次元ベクトルの外積で面の裏表を調べる

そこで、面の裏表をどうやって調べるかだ。ベクトルには「外積」という計算がある。この外積をうまくつかえば、面が裏か表か、たやすくわかる。外積は、3次元ベクトルで用いられることが多い。けれども、今回は透視投影した後の2次元平面のベクトルで考える。

ベクトルは大きさと向きをもつ。けれど、どこを始点とするかは問わない。つまり、始点が違っても、向きが等しく平行で、長さの等しいベクトルは、互いに等しい図2左⁠。そして、始点を原点に揃えると、ベクトルは終点の座標のみで表せる図2右⁠。原点を始点とするベクトルは位置ベクトルという。

図2 等しいベクトルと位置ベクトル
図2左 等しいベクトルと位置ベクトル 図2右 等しいベクトルと位置ベクトル

2次元平面の位置ベクトルA(ax, ay)とB(bx, by)の外積はA×Bで表し[1]⁠、つぎの簡単な四則演算の式で定められる。この式の数学的な意味については、興味をもたれた読者向けに後で解説する。計算そのものは、ただの掛け算と引き算だ。

2次元ベクトルA(ax, ay)とB(bx, by)の外積
A×B =axby - aybx

ここで覚えていただきたいのは、上記の外積の式はふたつの項の引き算なので、A×BとB×Aは正負が逆になるということだ。つまり、外積には交換法則が成立たない。外積A×Bについて幾何学的に説明すると、ベクトルBがAに対して右ネジの位置にあると正、左ネジの位置にあれば負になる図3⁠。

図3 2次元ベクトルの外積の正負は位置が右ネジか左ネジかで決まる
図3左 2次元ベクトルの外積の正負は位置が右ネジか左ネジかで決まる 図3右 2次元ベクトルの外積の正負は位置が右ネジか左ネジかで決まる

では、2次元ベクトルの外積で面の裏表を確かめよう。あらかじめ表向きの面の3頂点からふたつのベクトルを定め、位置関係を(たとえば右ネジに)決めておく図4⁠。すると、面がどう回転しようが、表向きであるかぎり、ふたつのベクトルの間の位置は(右ネジのまま)変わらない。

図4 3頂点から定めたふたつのベクトルの位置関係を予め決める
図4 3頂点から定めたふたつのベクトルの位置関係を予め決める

実はこのために、前回立方体の4面の頂点座標はすべて、つぎのようにそれぞれの面ごとに時計回りのインデックスを与えておいた第20回図3再掲⁠⁠。したがって、各面の第1頂点から第2頂点へのベクトルと第1頂点から第3頂点へのベクトルの位置は、みな右ネジに揃う。

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;
}
図3 立方体の8頂点にインデックスを与える(再掲)
図3 立方体の8頂点にインデックスを与える(再掲)

表向きの面のふたつのベクトルの位置関係を右ネジに定めれば、外積が正なら表のままで、負になったら裏返ったことがわかる図5⁠。そして、立方体の面のうち、ふたつのベクトルの外積が正の場合だけ描けば、それですべて丸く治まる。

図5 外積の正負から面の向きを調べて裏返った面は描かない
図5左 外積の正負から面の向きを調べて裏返った面は描かない 図5右 外積の正負から面の向きを調べて裏返った面は描かない

立方体の表向きの面のみを塗る

まずは、第20回につくったサンプルコード1および2の立方体の4面を塗ろう。まだ、面の裏表は考えない。面のクラス(Face)には、つぎのように塗り色のプロパティ(color)を加える。また、塗り色はランダムに決めたいので、ランダムな整数を返す関数がほしい。そこで、クラスのように扱える名前空間のオブジェクト(MathUtils)を定めて、その静的メソッド(getRandomInt())として加えた。引数の最大値と最小値(minとmax)の順序は、逆でも値が求まるように条件判定の処理を入れている。

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

// MathUtils
var MathUtils = {};
MathUtils.getRandomInt = function(min, max) {
  if (min > max) {
    var temp = min;
    min = max;
    max = temp;
  }
  var randomNumber = Math.random() * (max - min) + min;
  return Math.floor(randomNumber);
};

面のクラス(Face)のコンストラクタに塗り色の引数が加わったので、面の配列をつくる関数(getFacesVertices())からの呼出しにランダムなカラーを与える。ランダムなカラーを返す関数(getRandomColor())は、前掲のランダムな整数が得られるメソッド(MathUtils.getRandomInt())を用いた。

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)
    */
    new Face(0, 1, 2, 3, getRandomColor()),
    new Face(1, 5, 6, 2, getRandomColor()),
    new Face(4, 0, 3, 7, getRandomColor()),
    new Face(5, 4, 7, 6, getRandomColor())
  ];
  return vertices;
}
function getRandomColor() {
  return createjs.Graphics.getRGB(Math.floor(MathUtils.getRandomInt(0, 0xFFFFFF)));
}

そして、ひとつひとつの面を描く関数(draw())にも塗り色を引数(color)として加える。面の線描は除いた。立方体を描く関数(drawFaces())は、面のオブジェクト(face)のプロパティ(color)から塗り色を定めている。

function drawFaces(points, faces) {

  var facePoints = face.getFacePoints(points);
  // draw(facePoints);
  draw(facePoints, face.color);

}

// function draw(points) {
function draw(points, color) {

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

}

これで、立方体の面がランダムな色で塗られる。もちろん、まだ塗り重ねについて考えていないので、面の前後関係は崩れている(前掲図1参照⁠⁠。面の裏表を調べるために必要なメソッドをクラス(MathUtils)に加えよう。

外積を計算するには、ベクトルを定めなければならない。始点(vector0)と終点(vector1)のふたつの座標から、位置ベクトルを求めるメソッド(MathUtils.subtractVectors())は、xy座標それぞれを引き算してPointオブジェクトで返す。ふたつのベクトル(vector0とvector1)から外積を導く関数(crossProduct2D())は、前項で示した2次元ベクトルの外積の計算式にしたがって値を返している。

MathUtils.subtractVectors = function(vector0, vector1) {
  var vectorX = vector1.x - vector0.x;
  var vectorY = vector1.y - vector0.y;
  return new createjs.Point(vectorX, vectorY);
};
MathUtils.crossProduct2D = function(vector0, vector1) {
  return vector0.x * vector1.y - vector0.y * vector1.x;
}

立方体を描く関数(drawFaces())は、表向きの面だけを描く。面が表向きかどうかは、関数(isFront())で調べる。引数(facePoints)は4頂点座標をもつ面のオブジェクトだ。初めの3頂点座標から、表の面に対して右ネジに定められたふたつのベクトル(vector0とvector1)を得る。そのうえで、外積が0以上、つまりふたつのベクトルが右ネジの位置にあるかどうかをブール(論理)値で返している。

function drawFaces(points, faces) {

  if (isFront(facePoints)) {
    draw(facePoints, face.color);
  }

}
function isFront(facePoints) {
  var origin = facePoints[0];
  var vector0 = MathUtils.subtractVectors(origin, facePoints[1]);
  var vector1 = MathUtils.subtractVectors(origin, facePoints[2]);
  return (0 

これで、立方体は表向きの面だけが塗られる。面を描く順序はとくに変えることなく、水平に回る立方体が正しく表現される。ベクトルを求めたり、外積を計算したりするのは、簡単な四則演算だ。Array.sort()メソッドより、ずっと負荷は軽い。しかも、見えない面は描かないので、無駄な描画も省ける。かなりお得な処理といえる。

図6 水平に回る立方体の表向きの面だけを描く
図6左 水平に回る立方体の表向きの面だけを描く 図6右 水平に回る立方体の表向きの面だけを描く

書上げたJavaScriptコードを以下にまとめよう。まずは、クラスの定めがコード1だ。面のクラス(Face)には塗り色のプロパティ(color)を加えた。また、数学的な計算のためのクラス(MathUtils)を新たに備えた。このクラスのメソッドはすべて静的に(クラスを直接参照して)呼び出す。

コード1 3次元座標と面および数学計算のクラス定義
// 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, color) {
  this.length = 4;
  this.color = color;
  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;
};
// MathUtils
var MathUtils = {};
MathUtils.getRandomInt = function(min, max) {
  if (min > max) {
    var temp = min;
    min = max;
    max = temp;
  }
  var randomNumber = Math.random() * (max - min) + min;
  return Math.floor(randomNumber);
};
MathUtils.subtractVectors = function(vector0, vector1) {
  var vectorX = vector1.x - vector0.x;
  var vectorY = vector1.y - vector0.y;
  return new createjs.Point(vectorX, vectorY);
};
MathUtils.crossProduct2D = function(vector0, vector1) {
  return vector0.x * vector1.y - vector0.y * vector1.x;
}

回転する立方体を描くつぎのコード2は、表向きの面だけを塗り重ねた。新たに定めた関数(isFront())は、2次元ベクトルの外積(MathUtils.crossProduct2D())から面が表か裏かを確かめて返している。

コード2 水平に回す立方体の表向きの面だけを描く
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);
    if (isFront(facePoints)) {
      draw(facePoints, face.color);
    }
  }
  stage.update();
}
function isFront(facePoints) {
  var origin = facePoints[0];
  var point0 = MathUtils.subtractVectors(origin, facePoints[1]);
  var point1 = MathUtils.subtractVectors(origin, facePoints[2]);
  return (0 <= MathUtils.crossProduct2D(point0, point1));
}
function draw(points, color) {
  var count = points.length;
  var point = points[count - 1];
  drawGraphics
  .beginFill(color)
  .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, getRandomColor()),
    new Face(1, 5, 6, 2, getRandomColor()),
    new Face(4, 0, 3, 7, getRandomColor()),
    new Face(5, 4, 7, 6, getRandomColor())
  ];
  return vertices;
}
function getRandomColor() {
  return createjs.Graphics.getRGB(Math.floor(MathUtils.getRandomInt(0, 0xFFFFFF)));
}

サンプルのコードをjsdo.itに掲げた。クラス定義のscript要素(前掲コード1は[HTML]の欄に書き分けてある。次回は、立方体の6面をすべて塗ったうえで、さらにx軸で垂直にも回るようにしたい。

ベクトルの外積とは

ベクトルの外積についてもう少し知りたい読者のために、数学的な説明を補っておく。3次元空間のベクトルAとBの外積はA×Bで表され、つぎのような新たなベクトルとして求められる表1⁠。そして、ふたつのベクトルAからBに回したとき右ネジが進む向きに定められる図7左⁠⁠。つまり、外積には交換法則が成り立たない。

表1 3次元ベクトルの外積A×Bで求められるベクトル
外積の要素外積のベクトルとふたつのベクトルの関係
角度ふたつのベクトルAとBを含む平面に垂直(図7左)
方向ベクトルAからBに向かう回転を考えたとき、その回し方で右ネジの進む方向(図7左)
大きさベクトルAとBを隣り合う2辺とした平行四辺形の面積(図7右)
図7 3次元ベクトルの外積
図7左 3次元ベクトルの外積 図7右 3次元ベクトルの外積

3次元空間のベクトルAとBの位置座標を、それぞれ(ax, ay, az)および(bx, by, bzとすると、外積はつぎの式で定められる。

A×B = (aybz - azby, azbx - axbz, axby - aybx)

2次元平面で考えると、z座標値(azとbz)はつねに0だ。したがって、A×B = (0, 0, axby - aybx)となる。そこで、2次元平面のベクトルA(ax, ay)とB(bx, by)の外積は、3次元の外積のz座標値で表す。ベクトルAに対してBが右ネジの位置にあるとき値は正、左ネジなら負となる。

A×B =axby - aybx

2次元ベクトルの外積は、ほかにも使い道がある。興味がある読者は、珍味ベクトル外積3種盛りをお読みいただきたい。

おすすめ記事

記事・ニュース一覧