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

第16回3次元空間で座標を回す

前回の第15回「Matrix2Dクラスで座標を回す」では、星形の2次元平面座標をPointオブジェクトで定めて直線で描き、平面上で回るアニメーションまでつくった第15回コード3「マウスポインタの水平位置に応じてアニメーションが回る向きと速さを変える⁠⁠。今回は座標を3次元に拡げて、y軸で水平方向に回転させる。そのとき、遠近法の計算も加えていく。

z座標を加えてy軸で回す

前回述べた通り、EaselJSライブラリそのものには3次元空間を扱うクラスがない。Pointクラスもxy座標しかもたない。そこで、3次元空間の座標はObjectインスタンスでつくることにする。もちろん、プロパティはxyz座標だ。

{x:x座標値, y:y座標値, z:z座標値}

第15回コード3に新たな関数(newPoint3D())を加え、戻り値は3次元座標のオブジェクトとする。すでに定めてあった星形の座標をつくる関数(createStarPoints())は、Point()コンストラクタの呼出しを、この新たな関数に差し替える。z座標値はすべて0でよい。

function createStarPoints(numVertices, longRadius, shortRadius) {
  var starPoints = [];
  var angle = Math.PI;
  var theta = angle / numVertices;
  angle /= -2;
  for (var i = 0; i < numVertices; i++) {
    // starPoints.push(new createjs.Point(longRadius * Math.cos(angle), longRadius * Math.sin(angle)));
    starPoints.push(newPoint3D(longRadius * Math.cos(angle), longRadius * Math.sin(angle), 0));
    angle += theta;
    // starPoints.push(new createjs.Point(shortRadius * Math.cos(angle), shortRadius * Math.sin(angle)));
    starPoints.push(newPoint3D(shortRadius * Math.cos(angle), shortRadius * Math.sin(angle), 0));
    angle += theta;
  }
  return starPoints;
}
function newPoint3D(x, y, z) {
  var point3D = {x:x, y:y, z:z};
  return point3D;
}

この書替えをしても、コードはそのまま動く。もちろん、z座標値など見てもいないし、回転は2次元平面、つまり第15回コード3と変わらないアニメーションになる図1⁠。コードを書くときにはこのように小さな段階で、正しく動いているかどうか確かめられるように進めるとよい。

図1 星形が2次元平面で回る
図1 星形が2次元平面で回る 図1 星形が2次元平面で回る

では、座標をy軸で水平に回そう。だが、どうやって。前回使ったMatrix2D.transformPoint()メソッドは、2次元平面の座標変換しか扱えない。ここで、文字どおり視点を変えよう。3次元空間を真上から見下ろす図2⁠。今回のお題は座標をy軸で回すのだから、y座標はずっとそのままだ。つまり、xz平面で座標を変換すれば済むことになる[1]⁠。

図1 xz平面で座標を回す
図1 xz平面で座標を回す

だったら、(x, z)座標を(x, y)に見立てて、Matrix2D.transformPoint()メソッドで回した座標を得る。そのy座標をz座標に入れてやればよい。回転するアニメーションの関数(rotate())につぎのような手を加える。Matrix2D.transformPoint()メソッドには3次元座標のxとzを渡す。結果をひとまず入れるPointオブジェクトは予め変数(_point)に与えた。そのうえで、結果のPointオブジェクトのxy座標値を、3次元座標オブジェクトのxとzにそれぞれ定める。

var _point = new createjs.Point();

function rotate(eventObject) {
  var count = points.length;
  matrix.identity().rotate(angle);
  for (var i = 0; i < count; i++) {
    var point = points[i];
    // matrix.transformPoint(point.x, point.y, point);
    matrix.transformPoint(point.x, point.z, _point);
    point.x = _point.x;
    point.z = _point.y;
  }
  draw(points);
}

これで、3次元座標はy軸で回転する。星形が水平に回るアニメーションになった。第13回コード3を書直した全体がつぎのコード1だ。マウスポインタの水平位置に応じて回転の向きと速さも変わる。だが、アニメーションをじっと見続けていると違和感を覚えるだろう。見方によっては、回っているというより、単に水平に伸び縮みしているともいえる図3⁠。この表現でよいなら、初めからShapeインスタンスを水平方向に伸縮した方が早い。こうなってしまうのは、座標の計算に遠近法が加えられていないからだ。

図3 遠近法が加わらない回る星のアニメーション
図3 遠近法が加わらない回る星のアニメーション 図3 遠近法が加わらない回る星のアニメーション
コード1 星形の3次元座標をy軸で回すアニメーション
var stage;
var drawGraphics;
var points;
var angle = Math.PI / 36;
var matrix = new createjs.Matrix2D();
var stageCenterX;
var _point = new createjs.Point();
function initialize() {
  var canvasElement = document.getElementById("myCanvas");
  stage = new createjs.Stage(canvasElement);
  stageCenterX = canvasElement.width / 2;
  drawGraphics = createGraphics(stageCenterX, canvasElement.height / 2);
  points = createStarPoints(5, 65, 25);
  draw(points);
  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;
  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;
  }
  draw(points);
}
function createGraphics(x, y) {
  var drawShape = new createjs.Shape();
  drawShape.x = x;
  drawShape.y = y;
  stage.addChild(drawShape);
  return drawShape.graphics;
}
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();
}
function createStarPoints(numVertices, longRadius, shortRadius) {
  var starPoints = [];
  var angle = Math.PI;
  var theta = angle / numVertices;
  angle /= -2;
  for (var i = 0; i < numVertices; i++) {
    starPoints.push(newPoint3D(longRadius * Math.cos(angle), longRadius * Math.sin(angle), 0));
    angle += theta;
    starPoints.push(newPoint3D(shortRadius * Math.cos(angle), shortRadius * Math.sin(angle), 0));
    angle += theta;
  }
  return starPoints;
}
function newPoint3D(x, y, z) {
  var point3D = {x:x, y:y, z:z};
  return point3D;
}

遠近法の投影(透視投影)

コンピュータグラフィックス(CG)の3次元座標空間の描画は、最終的に2次元平面のスクリーンに対して行われる。したがって、3次元空間で計算した座標に遠近法の効果を加えたうえで、スクリーンに投影するという処理になる。この処理を英語で"perspective projection"と呼び、⁠遠近法投影」とか透視投影と訳される。

遠近法では、奥行き(z座標)が遠いものほど小さく、また距離も縮めて表現する(デザインの世界では「パース」といわれる⁠⁠。そして、かぎりなく遠ざかるにつれ、ある1点に向かって小さくなり、やがて見えなくなる。この点を「消失点」と呼ぶ図4⁠。

図4 遠ざかるオブジェクトは消失点に向けて小さくなる
図4 遠ざかるオブジェクトは消失点に向けて小さくなる

また、カメラのレンズでは「焦点距離」ということばが使われる。たとえば、広角レンズは焦点距離が短く、同じ距離でも広い範囲が撮影できる。他方、焦点距離の長い望遠レンズは、写せる範囲は狭くなるものの、遠くのものを近寄せる。それだけでなく、画角は遠近感にも影響を及ぼす。

ふたつのものの奥行きの距離は、レンズが広角になり、焦点距離が短くなるほど、離れて見えるようになる。これを、遠近感の「誇張効果」という。逆に、焦点距離の長い望遠レンズでは、肉眼よりも距離が近づいて見える図5⁠。一眼レフカメラでは、撮影する範囲や距離だけでなく、このような遠近感の効果も考えてレンズを選ぶ[2]⁠。

図5 焦点距離による表現の違い
短い焦点距離
図5 短い焦点距離

長い焦点距離
図5 長い焦点距離

焦点距離は、3次元座標空間における奥行きとなるz軸座標の視点から、投影面(スクリーン)までの距離を表す。焦点距離が長いほど、z位置の離れた(奥の)オブジェクトは、投影像がスクリーンに相対的に大きく表示される。また、視野角は視点から投影面全体を見た角度だ図6⁠。したがって、焦点距離は、視野角と連動する。焦点距離を操作すれば、視野角もそれに応じて変わることになる。

図6 z軸における焦点距離と視野角
図6 z軸における焦点距離と視野角

実際のオブジェクトと投影像をそれぞれ底辺とし、ともに視点が頂点となる相似なふたつの三角形から、比率がつぎのように求められる。

投影像の大きさ/オブジェクトの大きさ = 焦点距離 / (焦点距離 + z位置)
投影像の大きさ = オブジェクトの大きさ×焦点距離 / (焦点距離 + z位置)

したがって、3次元空間のz座標値に応じてxy座標値を2次元平面に透視投影する比率はつぎのとおりだ。予め焦点距離を定めたうえで、この投影比率をxy座標に乗じれば、透視投影された座標が定まる。

透視投影比率 = 焦点距離 / (焦点距離 + z位置)

3次元空間座標を透視投影する

前掲コード1に、透視投影するための新たな関数(getProjetedPoint())を加えよう。引数は焦点距離と3次元座標のオブジェクトのふたつだ。投影された2次元座標をPointオブジェクトで返す。

焦点距離は変数(focalLength)に定めた。透視投影する関数(getProjetedPoint())は、アニメーションのリスナー関数(rotate())から、各3次元座標(point)を回転するごとに呼び出す。気をつけなければならないのは、透視投影された座標は2次元平面に描くための値で、3次元座標とは別ということだ。そのため投影後のPointオブジェクトを入れる配列を別に変数(points2D)として定めた。

透視投影の関数(getProjetedPoint())は、引数に受取った3次元座標のオブジェクト(_point3D)から、前項で示した式の比率(w)にもとづいてxy座標を求める。そして、座標値を新たなPointオブジェクト(point2D)に定めて返している。戻された値は、アニメーションの関数(rotate())が投影座標の配列(points2D)にすべて納めたうえで、線描の関数(draw())に渡されて透視投影した星形が描かれることになる。

var points2D = [];
var focalLength = 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] = getProjetedPoint(focalLength, point);
  }
  // draw(points);
  draw(points2D);
}

function getProjetedPoint(focalLength, _point3D) {
  var point2D = new createjs.Point();
  var w = focalLength / (focalLength + _point3D.z);
  point2D.x = _point3D.x * w;
  point2D.y = _point3D.y * w;
  return point2D;
}

なお、アニメーションのリスナー関数(rotate())で投影座標の配列(points2D)Array.lengthプロパティを0にしているのは、エレメントが入っていない空っぽの状態にするためだ。もっとも今回のお題では、エレメント数が決まっていて上書きされるため、必ずしも空にしなくても構わない。ただ、このように配列を初期化しておけば、後でエレメント数が変わっても大丈夫だ。

前掲コード1にこれらの遠近法の計算を加えたのが、つぎのコード2だ。これで、水平に回る星のアニメーションに遠近感が与えられる(図7⁠⁠。水平の伸び縮みではなく、回っているという感じがするだろう。

図7 遠近法が投影された回る星のアニメーション
図7 遠近法が投影された回る星のアニメーション 図7 遠近法が投影された回る星のアニメーション
コード2 星形の3次元座標に遠近法を加えてy軸で回すアニメーション
var stage;
var drawGraphics;
var points;
var angle = Math.PI / 36;
var matrix = new createjs.Matrix2D();
var stageCenterX;
var _point = new createjs.Point();
var points2D = [];
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 = createStarPoints(5, 65, 25);
  draw(points);
  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] = getProjetedPoint(focalLength, point);
  }
  draw(points2D);
}
function createGraphics(x, y) {
  var drawShape = new createjs.Shape();
  drawShape.x = x;
  drawShape.y = y;
  stage.addChild(drawShape);
  return drawShape.graphics;
}
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();
}
function createStarPoints(numVertices, longRadius, shortRadius) {
  var starPoints = [];
  var angle = Math.PI;
  var theta = angle / numVertices;
  angle /= -2;
  for (var i = 0; i < numVertices; i++) {
    starPoints.push(newPoint3D(longRadius * Math.cos(angle), longRadius * Math.sin(angle), 0));
    angle += theta;
    starPoints.push(newPoint3D(shortRadius * Math.cos(angle), shortRadius * Math.sin(angle), 0));
    angle += theta;
  }
  return starPoints;
}
function getProjetedPoint(focalLength, _point3D) {
  var point2D = new createjs.Point();
  var w = focalLength / (focalLength + _point3D.z);
  point2D.x = _point3D.x * w;
  point2D.y = _point3D.y * w;
  return point2D;
}
function newPoint3D(x, y, z) {
  var point3D = {x:x, y:y, z:z};
  return point3D;
}

前掲コード2で焦点距離(focalLength)の値をどう決めたらよいか、疑問に思われた読者もおられよう。結論から申し上げれば、実際に試して各自の判断で決める。ただ、その際ステージの大きさを基準に考えるとよい。

コード2の値(300)はステージ幅(240)より気持ち大きめだ。もっと焦点距離が長くなれば、遠近感は弱まる図8左⁠⁠。あまり短くすると、遠近感の強い歪んだ表現になってくる図8右⁠⁠。

図8 焦点距離による遠近感の違い
焦点距離500
図8 焦点距離500

焦点距離100
図8 焦点距離100

今回のお題の表現は、これででき上がりだ。jsdo.itにもサンプルコードを掲げた。次回は、スクリプトの組立てに手を入れて仕上げたい。具体的には、ごく簡単なクラスを定義してみる。

おすすめ記事

記事・ニュース一覧