はじめまして。
アメーバ事業本部コア室コアディベロップメントグループの新屋です。
コア室では3Dアニメーションライブラリの開発を行っています。


リアルタイム・プリレンダ問わず3Dモーショングラフィックが好きで趣味でも3Dの制作をしています。プリレンダはTAKCOMさん、WOWのdaihei shibataさん、ogaoooooさん、リアルタイムはMasato Tsutsuiさん等の作品が好きです。

リアルタイム3D


昨今のスマートフォンの処理能力の向上に伴い、webブラウザでも一昔前にPCで動作していたようなリッチなコンテンツも高速に処理できるようになりました。
スマホサービスでは必ずと言っていい程CSSやjavascriptでのUI・演出アニメーションが組込まれています。
そうしたアニメーションの表現力を引き上げる方法の一つとしてリアルタイム3Dを紹介します。


リアルタイム3Dを扱う言語としてはopenGLやDirectXがありますが、スマートフォンのブラウザでは動作しません。
しかし3D理論は数学で成り立っているので、線を描いたり塗りつぶしたりするメソッドがあれば作ることができます。
スマートフォンでも動作可能なjavascriptのcanvasAPIでも実装が可能です。


もちろん専用の言語ではないので表現力ではopenGL等には及びません。
しかしアイディアとの組合せによって実用するに耐えうるコンテンツを作ることができます。


canvasAPIで実装されたデモを用意しました。
サンプルコード(jsdo.it)

デモには今回解説には含まれていない理論なども含まれていますが、javascriptのみでもこのような表現が可能です。


今回はリアルタイム3Dの基本となる座標変換を解説します。
長いですが最後までお付き合いください。


座標変換

3次元のオブジェクトにはXYZ方向への座標情報がありますが、デバイスのディスプレイは2次元のXY方向にしかピクセルが存在しません。


その為3次元のオブジェクトを投影するには座標を2次元に変換する必要があります。
この算出方法は形が決まっていてこのような流れで行われます。


この変換の流れをまとめて3次元アフィン変換と言います。
こうして見ると大変そうな感じもしますが、実際に実装しながら覚えていくと理解しやすいと思います。


カメラの定義

3Dは2Dと違いオブジェクトだけではなく観察者が存在します。
空間内のある位置からある角度でオブジェクトを観察した時の座標を算出する為です。
この観察者がビュー座標変換のビューです。


アフィン変換を実装する前にこの観察者の情報を定義しましょう。
ビューに必要な情報は様々ありますが今回は下記の最小限に絞ります。

・視点
・注視点
・視点と注視点の距離
・視線角度
・拡縮率
・ディスプレイ座標(本来ビューの情報ではないですがここにまとめます)
・距離と視線角度のアップデート用メソッド

camera変数として定義します。

var camera = {
 self : {
  x : 0,
  y : 0,
  z : 300
 },
 target : {
  x : 0,
  y : 0,
  z : 0
 },
 distance : {
  x : 0,
  y : 0,
  z : 0
 },
 angle : {
  cosPhi : 0,
  sinPhi : 0,
  cosTheta : 0,
  sinTheta : 0
 },
 zoom : 1,
 display : {
  x : setup.width/2,
  y : setup.height/2,
  z : 0
 },
 update : function() {
  camera.distance.x = camera.target.x - camera.self.x;
  camera.distance.y = camera.target.y - camera.self.y;
  camera.distance.z = camera.target.z - camera.self.z;
  camera.angle.cosPhi = -camera.distance.z / Math.sqrt(camera.distance.x*camera.distance.x + camera.distance.z*camera.distance.z);
  camera.angle.sinPhi = camera.distance.x / Math.sqrt(camera.distance.x*camera.distance.x + camera.distance.z*camera.distance.z);
  camera.angle.cosTheta = Math.sqrt(camera.distance.x*camera.distance.x + camera.distance.z*camera.distance.z) / Math.sqrt(camera.distance.x*camera.distance.x + camera.distance.y*camera.distance.y + camera.distance.z*camera.distance.z);
  camera.angle.sinTheta = -camera.distance.y / Math.sqrt(camera.distance.x*camera.distance.x + camera.distance.y*camera.distance.y + camera.distance.z*camera.distance.z);
 }
};

このカメラ変数を元にアフィン変換のメソッド群を実装します。


アフィン変換の実装

行列計算

アフィン変換には行列計算を使うため行列計算自体を行うメソッドを用意し下記のような行列を当てはめて値を求めると便利ですが、スマートフォンで動かすには計算量が多いため手順毎に省略したメソッドを用意しました。


例式はY軸に対して回転を行う行列です。


省略した式がこのようになります。

x` = x*cosθ+ z*sinθ
y` = y
z` = -x*sinθ + z*cosθ

今回は行列についての解説は省略しますが、3Dを掘り下げていくには行列計算は必須なので3Dが面白くなったら調べてみてください。
特にwebGLはopenGLと違い座標変換メソッドが省かれているので一式自分で用意する必要があります。


実装

アフィン変換は三角関数を多用する為、まずは角度をディグリーからラジアンに変換するメソッドを用意します。


var dtr = function(d) {
 return d*Math.PI/180;
};

アフィン変換のオブジェクトを用意してメソッドを一つずつ書き加えていきます。

var affine = {};

ローカル→ワールド変換
affine内にworldオブジェクトを用意し記述していきます。


△拡大・縮小
座標とXYZ三方向への拡縮率を受取り、ローカル座標の拡大・縮小を行います。

size : function(p, size) {
 return {
  x : p.x * size.x,
  y : p.y * size.y,
  z : p.z * size.z
 }
}

△回転
座標とXYZ軸の回転角を受取り、ローカル座標の回転を行います。

rotate: {
 x : function(p, rotate) {
  return {
   x : p.x,
   y : p.y*Math.cos(dtr(rotate.x)) - p.z*Math.sin(dtr(rotate.x)),
   z : p.y*Math.sin(dtr(rotate.x)) + p.z*Math.cos(dtr(rotate.x))
  }
 },
 y : function(p, rotate) {
  return {
   x : p.x*Math.cos(dtr(rotate.y)) + p.z*Math.sin(dtr(rotate.y)),
   y : p.y,
   z : -p.x*Math.sin(dtr(rotate.y)) + p.z*Math.cos(dtr(rotate.y))
  }
 },
 z : function(p, rotate) {
  return {
   x : p.x*Math.cos(dtr(rotate.z)) - p.y*Math.sin(dtr(rotate.z)),
   y : p.x*Math.sin(dtr(rotate.z)) + p.y*Math.cos(dtr(rotate.z)),
   z : p.z
  }
 }
}

△位置移動
座標とXYZ三方向への移動量を受取り、ローカル座標の移動を行います。

position : function(p, position) {
 return {
  x : p.x + position.x,
  y : p.y + position.y,
  z : p.z + position.z
 }
}

○ワールド→ビュー変換
affine内にviewオブジェクトを用意し記述していきます。


△視線方向の回転
座標を受け取り、視点と注視点を元に算出した回転角で座標の回転を行います。

phi : function(p) {
 return {
  x : p.x*camera.angle.cosPhi + p.z*camera.angle.sinPhi,
  y : p.y,
  z : p.x*-camera.angle.sinPhi + p.z*camera.angle.cosPhi
 }
}
theta : function(p) {
 return {
  x : p.x, 
  y : p.y*camera.angle.cosTheta - p.z*camera.angle.sinTheta,
  z : p.y*camera.angle.sinTheta + p.z*camera.angle.cosTheta
 }
}

△視点位置補正
視点を(x0, y0, z0)に補正します。

viewReset : function(p) {
 return {
  x : p.x - camera.self.x,
  y : p.y - camera.self.y,
  z : p.z - camera.self.z
 }
}

○ビュー→パースペクティブ変換
座標を透視投影で近いものは大きく、遠いものは小さく見えるように変換します。

perspective : function(p) {
 return {
  x : p.x * camera.distance.z/p.z * camera.zoom,
  y : p.y * camera.distance.z/p.z * camera.zoom,
  z : p.z * camera.zoom,
  p : camera.distance.z/p.z
 }
}

○パースペクティブ→ディスプレイ変換
webブラウザは左上を基準にX方向は右・Y方向は下に行くほど大きくなりますが、
3Dではステージの原点を基準にX方向は右・Y方向は上・Z方向は手前に来るほど数値が大きくなるのでこの座標変換で向きを変えます。

display : function(p, display) {
 return {
  x : p.x + display.x,
  y : -p.y + display.y,
  z : p.z + display.z,
  p : p.p
 }
}

○一括処理
最後にここまで書いてきたメソッドを一括で処理するためのメソッドを用意します。
頂点・拡縮・回転・移動・ディスプレイ座標を引数で渡すと座標変換された値が返ってきます。

process : function(model, size, rotate, position,display) {
 var ret = affine.world.size(model, size);
 ret = affine.world.rotate.x(ret, rotate);
 ret = affine.world.rotate.y(ret, rotate);
 ret = affine.world.rotate.z(ret, rotate);
 ret = affine.world.position(ret, position);
 ret = affine.view.phi(ret);
 ret = affine.view.theta(ret);
 ret = affine.view.viewReset(ret);
 ret = affine.perspective(ret);
 ret = affine.display(ret, display);
 return ret;
}

以上ここまでがアフィン変換です。



頂点クラス

更にここまで組んできたアフィン変換を楽に使うために頂点情報を保持するクラスを作ります。
内容としてはインスタンス生成時に初期値を引数で受取ってaffineInに保持し、それを元にアフィン変換された値をaffineOutに入れるものです。


単純な内容ですが多数の頂点情報を扱う際にはこうした管理用クラスが無いと大変です。

var vertex3d = function(param) {
 this.affineIn = new Object;
 this.affineOut = new Object;
 this.affineIn.vertex = (param.vertex);
 this.affineIn.size = (param.size);
 this.affineIn.rotate = (param.rotate);
 this.affineIn.position = (param.position);
};
vertex3d.prototype = {
 vertexUpdate : function() {
  this.affineOut = affine.process(
   this.affineIn.vertex,
   this.affineIn.size,
   this.affineIn.rotate,
   this.affineIn.position,
   camera.display
  );
 }
};

描画

ここまで長くなりましたが書いてきたコードで立方体を描画してみましょう。
8つの頂点を線で結んだだけの単純なオブジェクトです。


先ほども書いた通り3Dでは画面の中心を基準にXは右・Yは上・Zは手前に数値が大きくなるので、頂点は原点を基準に-1~1の範囲で配置します。
さらに拡縮率100を3方向に掛け合わせることで一辺200の立方体の頂点が完成します。

var v = new Array();
v[0] = new vertex3d({
 vertex : {x:-1,y:1,z:1},
 size : {x:100,y:100,z:100},
 rotate : {x:20,y:-20,z:0},
 position : {x:0,y:0,z:0}
});
v[1] = new vertex3d({
 vertex : {x:1,y:1,z:1},
 size : {x:100,y:100,z:100},
 rotate : {x:20,y:-20,z:0},
 position : {x:0,y:0,z:0}
});
v[2] = new vertex3d({
 vertex : {x:1,y:-1,z:1},
 size : {x:100,y:100,z:100},
 rotate : {x:20,y:-20,z:0},
 position : {x:0,y:0,z:0}
});
v[3] = new vertex3d({
 vertex : {x:-1,y:-1,z:1},
 size : {x:100,y:100,z:100},
 rotate : {x:20,y:-20,z:0},
 position : {x:0,y:0,z:0}
});
v[4] = new vertex3d({
 vertex : {x:-1,y:1,z:-1},
 size : {x:100,y:100,z:100},
 rotate : {x:20,y:-20,z:0},
 position : {x:0,y:0,z:0}
});
v[5] = new vertex3d({
 vertex : {x:1,y:1,z:-1},
 size : {x:100,y:100,z:100},
 rotate : {x:20,y:-20,z:0},
 position : {x:0,y:0,z:0}
});
v[6] = new vertex3d({
 vertex : {x:1,y:-1,z:-1},
 size : {x:100,y:100,z:100},
 rotate : {x:20,y:-20,z:0},
 position : {x:0,y:0,z:0}
});
v[7] = new vertex3d({
 vertex : {x:-1,y:-1,z:-1},
 size : {x:100,y:100,z:100},
 rotate : {x:20,y:-20,z:0},
 position : {x:0,y:0,z:0}
});

ここで頂点を-100~100、拡縮率は1で記述しても構いません。


必要な頂点インスタンスが用意できたらタイマー関数内でアップデートと描画を行ないます。
せっかくなので回転もさせてみましょう。

//オブジェクトの回転角変数
var cubeRotate = {
 x : 0,
 y : 0,
 z : 0
};
var loop = function() {
 setTimeout(function() {
  ctx.clearRect(0, 0, 500, 500); //canvasクリア
  camera.update(); //注視距離・視線角のアップデート


  //回転角を加算
  cubeRotate.x += 0.5;
  cubeRotate.y += 1;
  cubeRotate.z += 3;


  //各頂点に回転角を代入し頂点をアップデート
  for(var i=0; i<v.length; i++) {
   v[i].affineIn.rotate = cubeRotate;  v[i].vertexUpdate();
  };


  // それぞれの頂点座標を結ぶ
  ctx.beginPath();
  ctx.moveTo(v[0].affineOut.x, v[0].affineOut.y);
  ctx.lineTo(v[1].affineOut.x, v[1].affineOut.y);
  ctx.lineTo(v[2].affineOut.x, v[2].affineOut.y);
  ctx.lineTo(v[3].affineOut.x, v[3].affineOut.y);
  ctx.lineTo(v[0].affineOut.x, v[0].affineOut.y);


  ctx.moveTo(v[4].affineOut.x, v[4].affineOut.y);
  ctx.lineTo(v[5].affineOut.x, v[5].affineOut.y);
  ctx.lineTo(v[6].affineOut.x, v[6].affineOut.y);
  ctx.lineTo(v[7].affineOut.x, v[7].affineOut.y);
  ctx.lineTo(v[4].affineOut.x, v[4].affineOut.y);


  ctx.moveTo(v[0].affineOut.x, v[0].affineOut.y);
  ctx.lineTo(v[4].affineOut.x, v[4].affineOut.y);


  ctx.moveTo(v[1].affineOut.x, v[1].affineOut.y);
  ctx.lineTo(v[5].affineOut.x, v[5].affineOut.y);


  ctx.moveTo(v[2].affineOut.x, v[2].affineOut.y);
  ctx.lineTo(v[6].affineOut.x, v[6].affineOut.y);


  ctx.moveTo(v[3].affineOut.x, v[3].affineOut.y);
  ctx.lineTo(v[7].affineOut.x, v[7].affineOut.y);


  ctx.stroke(); //描画
  loop();
 }, 1000/60);
};
loop();

実行結果


サンプルコード(jsdo.it)


キューブが描画されました。次は線ではなく面を描画してみましょう。
どの面かが分かりやすいよう一面ずつ色を塗り分けます。


描画部分をこのように記述します。

//面1の描画
  ctx.beginPath();
  ctx.moveTo(v[0].affineOut.x, v[0].affineOut.y);
  ctx.lineTo(v[1].affineOut.x, v[1].affineOut.y);
  ctx.lineTo(v[2].affineOut.x, v[2].affineOut.y);
  ctx.lineTo(v[3].affineOut.x, v[3].affineOut.y);
  ctx.closePath();
  ctx.fillStyle = "hsla(0, 100%, 70%, 1)";
  ctx.fill();
  
  //面2の描画
  ctx.beginPath();
  ctx.moveTo(v[1].affineOut.x, v[1].affineOut.y);
  ctx.lineTo(v[5].affineOut.x, v[5].affineOut.y);
  ctx.lineTo(v[6].affineOut.x, v[6].affineOut.y);
  ctx.lineTo(v[2].affineOut.x, v[2].affineOut.y);
  ctx.closePath();  
  ctx.fillStyle = "hsla(30, 100%, 70%, 1)";
  ctx.fill();


  //面3の描画
  ctx.beginPath();
  ctx.moveTo(v[5].affineOut.x, v[5].affineOut.y);
  ctx.lineTo(v[4].affineOut.x, v[4].affineOut.y);
  ctx.lineTo(v[7].affineOut.x, v[7].affineOut.y);
  ctx.lineTo(v[6].affineOut.x, v[6].affineOut.y);
  ctx.closePath();
  ctx.fillStyle = "hsla(60, 100%, 70%, 1)";
  ctx.fill();


  //面4の描画
  ctx.beginPath();
  ctx.moveTo(v[4].affineOut.x, v[4].affineOut.y);
  ctx.lineTo(v[0].affineOut.x, v[0].affineOut.y);
  ctx.lineTo(v[3].affineOut.x, v[3].affineOut.y);
  ctx.lineTo(v[7].affineOut.x, v[7].affineOut.y);
  ctx.closePath();
  ctx.fillStyle = "hsla(90, 100%, 70%, 1)";
  ctx.fill();


  //面5の描画
  ctx.beginPath();
  ctx.moveTo(v[4].affineOut.x, v[4].affineOut.y);
  ctx.lineTo(v[5].affineOut.x, v[5].affineOut.y);
  ctx.lineTo(v[1].affineOut.x, v[1].affineOut.y);
  ctx.lineTo(v[0].affineOut.x, v[0].affineOut.y);
  ctx.closePath();
  ctx.fillStyle = "hsla(120, 100%, 70%, 1)";
  ctx.fill();


  //面6の描画
  ctx.beginPath();
  ctx.moveTo(v[3].affineOut.x, v[3].affineOut.y);
  ctx.lineTo(v[2].affineOut.x, v[2].affineOut.y);
  ctx.lineTo(v[6].affineOut.x, v[6].affineOut.y);
  ctx.lineTo(v[7].affineOut.x, v[7].affineOut.y);
  ctx.closePath();
  ctx.fillStyle = "hsla(150, 100%, 70%, 1)";
  ctx.fill();

実行結果


サンプルコード(jsdo.it)


明らかにおかしいです。
これは本来奥にあるはずの面を関係なしに面1~6の順に描画してしまっている為です。
正確に描画するには奥にあるか手前にあるかを面ごとに判定する必要があります。


このように面ごとにソートする方法をZソートと言います。


奥行き判定する際、手前か奥かというとz座標を使うことを考えてしまいます。
しかしz座標はカメラに対する順序ではないのでオブジェクトの裏に回り込んだ際などに描画が崩れてしまいます。


判定に使用するのはアフィン変換で出力したパースペクティブ値です。

instance.affineOut.p;

各面に使用されている頂点のパースペクティブ値を平均してsort関数にかけましょう。


面ごとにどの頂点を使用するかを保持する配列を作ります。
そのまま配列をsortするとどの面が何色か判断できないので一緒に面の色も持たせます。

var f = new Array();
f[0] = {
 color : "hsla(0, 100%, 70%, 1)",
 verticies : [0,1,2,3]
};
f[1] = {
 color : "hsla(30, 100%, 70%, 1)",
 verticies : [1,5,6,2]
};
f[2] = {
 color : "hsla(60, 100%, 70%, 1)",
 verticies : [5,4,7,6]
};
f[3] = {
 color : "hsla(90, 100%, 70%, 1)",
 verticies : [4,0,3,7]
};
f[4] = {
 color : "hsla(120, 100%, 70%, 1)",
 verticies : [4,5,1,0]
};
f[5] = {
 color : "hsla(150, 100%, 70%, 1)",
 verticies : [3,2,6,7]
};

各面が使用する頂点のパースペクティブ値を平均化して比較するsort関数を作成します。
これで面の配列が視点からの距離に則した順序に並び替えられます。

f.sort(
 function(a, b) {
  var pA = 0;
  for(var i=0; i<a.verticies.length; i++) {
   pA += v[a.verticies[i]].affineOut.p;
   if(i == a.verticies.length-1) {
    pA /= 4;
   };
  };
  var pB = 0;
  for(var i=0; i<b.verticies.length; i++) {
   pB += v[b.verticies[i]].affineOut.p;
   if(i == b.verticies.length-1) {
    pB /= 4;
   };
  };
  if (pA < pB) {
   return -1;
  };
  if (pA > pB) {
   return 1;
  };
  return 0;
 }
);

並び替えた面を描画します。

for(var i=0; i<f.length; i++) {
 ctx.beginPath();
 for(var j=0; j<f[i].verticies.length; j++) {
  if(j==0) {
   ctx.moveTo(v[f[i].verticies[j]].affineOut.x, v[f[i].verticies[j]].affineOut.y);
  } else {
   ctx.lineTo(v[f[i].verticies[j]].affineOut.x, v[f[i].verticies[j]].affineOut.y);
  };
 };
 ctx.closePath();
 ctx.fillStyle = f[i].color;
 ctx.fill();
};

実行結果


サンプルコード(jsdo.it)


正確に描画されました。
ここまで組めれば後は頂点位置を変更して様々な形が組めるようになっています。
更にこうしたオブジェクトも形ごとにクラス化しておくと大量のオブジェクトを動かす際に便利です。



まとめ

以上ここまでいかがでしたでしょうか?
今回解説したのは3Dの基本中の基本ですが、自分はここまで理解するだけでも大変だったのを覚えています。
しかしその分動いた時の喜びも大きかったです。


3Dに興味があるけど難しいという人は先に理論から考えずに、3D空間を思い浮かべて一つ一つの事象をイメージで理論と結びつけてください。
数学自体を追及しているわけではないので、行列式などは「この式が空間を再現する為の式なんだ」くらいの認識で十分です。


簡単だったという人は、次にライティングとテクスチャマッピングを調べて実装するとぐっと表現力が増します。


この記事で一人でも多くの方の人に3Dに興味を持ってもらえるとうれしいです。
最後までお付き合いいただきありがとうございました。