3Dについて勉強したのでその備忘録です。
理解につながった記事のリンク集と、自分の理解した内容をメモとして残しています。
(いくつかの画像は参考にした記事から拝借しています)
実際にJavaScriptのCanvas2Dを使って簡単な自作3Dエンジンを作ってみました。
##参考にした記事など
- フォグについてのPDF
- 自作Quaternionサンプル
- いわずとしれたTHREE.js(GitHub)
- その60 変換行列A×BとB×Aの違いを知ろう
- 3Dプログラミングの基礎知識(1)
- 3Dプログラミングの基礎知識(5)
- 3Dプログラミングの基礎知識(6)
- 3D座標変換
- 自前でビュー変換行列を作成
- 3D座標変換(別記事)
- ワールド座標→スクリーン座標変換
- 1-9 座標変換其の四
- その39 知っていると便利?ワールド変換行列から情報を抜き出そう
##行列のかける順番
OpenGLで扱う行列は列オーダー。DirectXで扱うのは行オーダー。
違いは以下のように、 行列を作る際のベクトルの方向が違う ので注意。
シェーダに渡すのは1次元配列となり、上記画像の順番で配列を生成する。
若干、直感から反する配置となるため注意が必要。
##座標変換手順
プロジェクション、スクリーン以外は基本的に3D空間上の座標変換となる。
平行移動、回転、拡大・縮小がメインとなるが、それぞれの行列に関してはこの記事の最後にまとめて載せてあるので、そちらを参照。
- ローカル座標
- ワールド座標変換
- ワールド座標
- ビュー座標変換
- ビュー座標
- プロジェクション座標変換(射影変換)
- プロジェクション座標
- スクリーン座標変換
- スクリーン座標
- 描画(レンダリング)
という手順で実現する。
-
01. ローカル座標
ローカル座標は1モデルの座標空間。またの名をモデリング空間。
イメージとしては3Dモデルを作成しているときを考えると分かりやすい。
顔や腕など、基本的にひとつの物体(オブジェクト)を構成する要素が相対的に配置される空間。 -
02. ワールド座標
モデリングされたオブジェクトをワールド(世界)に配置した際の座標。
ワールドのどの位置にどんなオブジェクトが存在するのか、そしてそのオブジェクトは全体の中でどの位置にあるのかを表す座標。
3Dモデルを配置していくことをイメージすると分かりやすい。 -
03. ビュー座標
ビュー(View)、つまりカメラが存在する座標。
カメラもワールド空間に存在するひとつのオブジェクトでしかないので、まずはそのカメラがワールドのどこに配置されているのかを特定する。
さらに、基本的にスクリーンに画像が投影されるのはカメラの位置と向き、そして上方向を定義することでどういう映像が映し出されるかが決まる。
カメラマンが転倒した際に画像が乱れる様を想像するとイメージしやすい。 -
04. プロジェクション座標
3Dの座標変換の中で一番むずかしい点がここ。
理由としては、03までの座標変換はあくまで3D空間上の座標変換を行なっていたのに対し、プロジェクション変換はスクリーン、つまり2D(平面)にその3Dの座標を投影する場合にどういう位置関係になるかを計算し変換する作業となる。
遠いほど小さく、近くのものほど大きい。また、遠くに行けば行くほど画面中央にオブジェクトが寄っていくような変換となる。 -
05. スクリーン座標
04のプロジェクション座標変換で行われた座標変換を、スクリーンのサイズに応じて**「実際に」**表示サイズに変換する作業を行う。
04の座標変換までは基本的に -1 から 1 の間の変換を行なっている。
その数値に合わせ、実際に表示されるスクリーンサイズに応じてその値を変換し、人の目に触れる映像として投影する。 -
最後に、変換された座標を元に点(Particle)やテクスチャマッピングなどのTriangleなどがレンダリングされる。
###ワールド座標変換のポイント
ローカル座標(またはオブジェクト座標、モデル座標)からワールド座標に変換する場合は、 座標系を変換する ことを意識する。
OpenGLでは、行列に対して 左に姿勢制御行列をかけると変換先、つまりワールド空間上での変換 となり、 右側にかけるとローカル座標上での変換 となる。(ちなみにDirectXでは掛ける順番の意味が左右逆になる)
例えば、ワールド空間上でX軸方向に30動かしたい場合は左側に平行移動行列をかければいいことになる。
逆に、ローカル空間上で自分自身の軸を中心にY軸中心に30°回転したい、といった場合は回転行列を右側に掛けることで達成できる。
###ビュー変換のポイント01
ビュー変換(カメラが映し出すべき面に座標を変換する処理)は
- カメラの位置
- 注視点
- 上方向
を元に変換を行う。
上方向 はカメラの上方がどこを向いているか、を決める。
(同じ位置、同じ注視点であっても、カメラの上方が異なれば映し出される映像が変わるという理屈)
基本的な処理の流れは、「カメラの位置、注視点、上部」を元に、ワールド座標のX, Y, Z軸をカメラ基準(カメラの前が-z方向、カメラの右が+x方向、カメラの上が+y方向)に変換する処理を行う。
この変換以後は、新しく求めた軸を元に座標変換を行うことになる。
###ビュー変換のポイント02
ビュー変換の座標は、軸を求める作業がメインとなる。
そのため、上記3つのベクトルを用いて軸を求める。
具体的にはそれぞれの 外積を使ってベクトルを求めていく ことになる。
カメラの後ろ方向(つまり、カメラの視点方向の逆)は、単純にカメラの位置からカメラの注視点のベクトルの差分を取ることで求めることができる。
上記差分からカメラを基準としたZ軸(Z'軸)が求まる。
そしてカメラ上部を向くベクトルはすでに与えられているので、ここで求めたZ'軸との外積を取ることでX'軸が求まる。
最後に、Z'軸、X'軸の外積を取ることでY'軸を求める。
(カメラ上部のベクトルはY'軸ではなく、あくまでカメラの上方向のベクトルを示すだけなのでそのままY'軸としては使えないので注意)
上記の新しい3軸は、方向を示す単位ベクトルとして保持していればいいので、それぞれを正規化しておく。
これらを実行するスクリプトは以下になる。(自作しているライブラリから一部抜粋)
こちらの記事を参考にさせていただきました。
lookAt: do ->
#カメラに対してのX, Y, Z軸をそれぞれ定義
x = new Vector3
y = new Vector3
z = new Vector3
return (eye, target, up) ->
te = @elements
z.subVectors(eye, target).normalize()
x.crossVector(up, z).normalize()
y.crossVector(z, x).normalize()
tx = eye.dot x
ty = eye.dot y
tz = eye.dot z
te[0] = x.x; te[4] = y.x; te[8] = z.x;
te[1] = x.y; te[5] = y.y; te[9] = z.y;
te[2] = x.z; te[6] = y.z; te[10] = z.z;
te[3] = tx; te[7] = ty; te[11] = tz;
return @
####プロジェクション変換のポイント01
zoomXとzoomYはそれぞれ対象スクリーンに対するスケーリングを実行する。
つまり、X(Y)座標を $-1 ≦ (x|y) ≦ 1$ になるように正規化する。
そのためにはスクリーンサイズの半分で割る(つまり 2 / width
と 2 / height
をXとYに対して掛ける)。
※ ここで割る値を半分にするのは単純に与えられるX座標、Y座標は画面中心を (0, 0)
とし、画面サイズの半分だけ正負の値で表現されるため。
画面サイズが仮に 800
だとすると、画面内に入る座標は -400 ~ 400
と、絶対値で見ると半分になる。
そしてさらにそれを、near / zを掛けることによって中央に寄せる(遠いものほど画面中央に)処理を施す。
(nearは任意で設定された再近接面、farが任意で設定された最遠面。そしてzは、今まさに計算対象としている頂点のz値。つまり、z値がnearと同じなら near/near = 1
となって縮小の変換はされない。逆にz値がfarと同じならnear / far
となるときが一番縮小率が大きくなり、一番小さい(奥の)頂点となる)
つまり、(2 / width) * (zn / z)
となる。(これを整理すると (2 * zn) / (width * z)
)
しかし、zは固定値ではないため、これを外に出す。
つまり、最終的にw
成分で割られるため、w
にz
が出力されるよう変換する。
(w
成分は、4次元ベクトルの最後の成分(x, y, z, w
)のこと)
さらにこのw
成分で割られる対象は(x, y, z)すべてになるため、z値もそれを考慮する必要がある。
つまり、near
とz
が同じなら-1
、far
とz
が同じなら1
となる計算式を考えればよい。
(X軸、Y軸同様、Z軸も-1
から1
に正規化されるため、nearが-1
、farが1
となる)
※ プロジェクション行列の意味についてはこちらの記事(その55 そもそも「w」って何なのか?)がとても参考になります。
[2015.04.14 追記]
なお、これはOpenGLの考え方であり、DirectXでは0〜1
に正規化されるようです。
ちなみに上記の計算を簡単にプログラムで示すと以下のようになります。
var posZ = 100;
var near = 10;
var far = 100;
function z() {
return (far + near) / (far - near);
}
function w() {
return (2 * near * far) / (near - far);
}
var ret = (posZ * z() + w()) / posZ;
console.log(ret); // => 1
// 仮に posZ を 10、つまり near と同じにすると -1 となる。
ポイントは最後にposZ
で割られる点です。
posZ
で割られた段階で初めて、-1〜1
に正規化されます。
(行列を掛けただけではZ値は-1〜1
に正規化されていない)
そしてこれらを踏まえると、以下の様な行列が完成する。
[2016.06.13 追記]
以下の行列はこちらの記事からそのまま拝借したものです。なので詳細はこちらを見たほうがいいかも→ 床井研究室 第5回 座標変換
ちなみにThree.jsの行列生成もこれと同じになっています。
\begin{vmatrix}
\frac{2 near}{right - left} &0 &\frac{right + left}{right - left} &0 \\
0 &\frac{2 near}{top - bottom} &\frac{top + bottom}{top - bottom} &0 \\
0 &0 &-\frac{far + near}{far - near} &-\frac{2 far\,near}{far - near} \\
0 &0 &-1 &0
\end{vmatrix}
\\
width = right - left \\
height = top - bottom \\
ここで $-\frac{far + near}{far - near}$ と出てきましたが、これもX, Yと似た発想です。
far - near
の意味するところはZ軸に対する「長さ」を算出しています。
長さで割っている、つまりX, Yをwidthやheightで割るのとまったく同じことをやっているわけですね。
プロジェクション変換のポイント02
プロジェクション変換が行なっているのは、あくまで「ビュー(カメラ)」から見た場合の各オブジェクトのx,y座標の変換のみを行いっている。視体積の手前側が大きく、奥側が小さく、という変換は されない 。
サンプルのtransformPoints
関数で行なっている処理は、その変換時のZ値(奥行き情報)に合わせてW値(ベクトルの第4成分)に、視体積膨らみ情報を格納している。(こちらの記事を参考に書いてます)
「W値」によりnear / farのクリップ(nearからfarの間だけをレンダリング範囲とする処理)を行い、この範囲にあるもののみ
x = x / W
y = y / W
を実行し、手前ほど大きく、奥ほど小さい、という変換を行なっている。
nearクリップ面をznで表すとすると、zn / zをかけることによって座標を画面中央に寄せる処理を行う。
計算理由は、zn、つまり手前からオブジェクトが遠ざかる(Z値が大きくなる)と次第に座標は0に近づいていく。
(hoge * (1 / 10000000...)
となるとhoge
が限りなく0に近付くことを考えるとイメージしやすい)
注意点として、通常のスクリーン座標は(0, 0)は左上だが、スクリーン座標変換を行うまでは(0, 0)は画面中央を意味する。そのため、x, yともに0に近付くということは画面中央に近付く、ということになる。
実際の処理↓
transformPoints = (out, pts, mat, viewWidth, viewHeight) ->
len = pts.length
transformed_temp = [0, 0, 0, 0]
oi = 0
for i in [0...len] by 3
mat.transVec3(transformed_temp, pts[i + 0], pts[i + 1], pts[i + 2])
W = transformed_temp[3]
transformed_temp[0] /= W
transformed_temp[1] /= W
transformed_temp[2] /= W
transformed_temp[0] *= viewWidth
transformed_temp[1] *= -viewHeight
transformed_temp[0] += viewWidth / 2
transformed_temp[1] += viewHeight / 2
out[oi++] = transformed_temp[0]
out[oi++] = transformed_temp[1]
###スクリーン座標変換のポイント
スクリーン座標変換前までに計算されたのは(上記で説明した通り)x,yともに -1 ≦ (x|y) ≦ 1の間に収まる。
その間で算出された値を元に通常のスクリーン座標系に変換する計算を行う。
具体的には、表示しようとしているスクリーンへのスケーリングとY軸反転を行う。(スクリーン座標系は(0, 0)が左上で、Y軸は下向きに正の数値を取る)
そしてその後、原点(0, 0)をスクリーン中央に移動する平行を移動を行う。
行列で表すと、
w = screen\_width / 2 \\
h = screen\_height / 2 \\
\begin{equation}
M(screen) =
\begin{bmatrix}
w &0 &0 &0 \\
0 &-h &0 &0 \\
0 &0 &1 &0 \\
w &h &0 &1
\end{bmatrix}
\end{equation}
####原点移動の理由
下記画像を見てもらうと一目瞭然だが、3D空間の(0, 0)座標は、スクリーン座標の左上と一致する。
この3D空間上の(0, 0)とスクリーン座標の(0, 0)を一致させるには、X軸方向にスクリーンサイズの半分を正方向に、Y軸方向にスクリーンサイズの半分を負方向に移動することで達成できる。
射影変換の参考画像
(ここはちょっと解釈があってるか自信なし・・)
射影変換の解釈は、上記画像のd
が zoom 1
の状態だとすると、この位置にあるオブジェクトが基準位置となる。(同次座標空間のW?)
この位置より手前(近く)のものは大きく、奥のものは小さくなる。
Y座標の位置は、上記画像より d * tan(θ / 2)
で割ることで得られる。(上記画像の場合、d * tan(θ / 2)
で割れば1となる)
(θを2で割っているのは、単純に上半分の直角三角形のがθ/2で表されるから)
つまり、d * tan(θ / 2)
で割ることで、奥に行けば小さく、逆に手前に行けば大きくなるという結果を得ることができる。
(d = z = 10
とすると、d * tan(θ / 2) = 10 * tan(θ / 2)
となり、逆に d = z = 2
とすると、 2 * tan(θ / 2)
で割ることになる。結果、奥に行けば行くほど割る数値が大きくなっていく)
lookAt
[参考記事]
##ライティング
###スペキュラー成分の計算式
####■Phoneのモデル
Specular = K_s * L_{color} * (R \cdot V)^n
####■Blinのモデル
Specular = K_s * L_{color} * (N \cdot H)^n \\
ただし H = normalize(L + V);
//「n」が大きいほど、ハイライトがシャープになる(はず)
//【プログラム例】
//■phongのモデル
float3 V = normalize(eyePos - P);
float3 R = normalize(2*dot(L,N)*N - L);
float specularLight = pow(max(dot(R,V), 0), shininess);
if (diffuseLight <= 0) specularLight = 0;
float3 specular = Ks * lightColor * specularLight;
//■Blinのモデル
float3 V = normalize(eyePos - P);//Blinのモデルで計算
float3 H = normalize(L + V);
float specularLight = pow(max(dot(N, H), 0), shininess);
if (diffuseLight <= 0) specularLight = 0;
float3 specular = Ks * lightColor * specularLight;
##ワールド座標変換行列からローカル座標のXYZ軸の向きを割り出す
その39 知っていると便利?ワールド変換行列から情報を抜き出そう
詳細についてはこちらを見てもらったほうがいいので、あくまで自分理解用メモです。
ワールド変換座標行列(入れ子の場合は親→ワールドを含めた最終変換行列)は、モデルをワールド空間に配置するための行列です。
モデル変換行列にさらにワールド変換行列を掛けることで、最終的にそのモデルがどういう向きにどう置かれるか、が決まります。
モデル変換行列は、ローカル空間上でのモデルの生成制御なのでここではいったん無視します。
問題にしたいのはワールド変換行列です。言い換えれば、ローカル座標空間をどう変換する行列か、と言えます。
なので、モデルの姿勢はどうあれ、ローカルのXYZ軸がどこを向いているかもこれで決定することができます。
今回のメモは、このワールド変換行列から、ローカルのXYZ軸の向きを抽出する、というものです。
結論から言ってしまえば、ワールド変換行列と抽出したい軸のベクトルを掛け算してやれば求まります。
ワールド変換行列を「W」、軸ベクトルを「Vn」とした場合、
X軸の向き=WVx
Y軸の向き=WVy
Z軸の向き=WVz
です。(OpenGLをベースにしているので、かける順番が上記記事のものと逆になっています)
ここで、Vxは4次ベクトルで、[1, 0, 0, 0]です。(Vy [0, 1, 0, 0], Vz [0, 0, 1, 0])
[1, 0, 0, 0](実際は列ベクトルなので縦)を行列に掛けることで、1列目の値がそのまま出力されます。つまり、この1列目の値がズバリ、X軸の向き、となります。
計算式は以下のイメージです。
###X軸の向き
\begin{equation}
\begin{vmatrix}
m_{11} &m_{12} &m_{13} &m_{14} \\
m_{21} &m_{22} &m_{23} &m_{24} \\
m_{31} &m_{32} &m_{33} &m_{34} \\
m_{41} &m_{42} &m_{43} &m_{44} \\
\end{vmatrix}
\times
\begin{vmatrix}
1 \\
0 \\
0 \\
0
\end{vmatrix}
=
\begin{vmatrix}
m_{11} \\
m_{21} \\
m_{31} \\
m_{41}
\end{vmatrix}
\end{equation}
memo
###各種座標変換の例
####拡大・縮小
\begin{equation}
\begin{vmatrix}
X' \\
Y' \\
Z' \\
W'
\end{vmatrix}
=
\begin{vmatrix}
S_x &0 &0 &0 \\
0 &S_y &0 &0 \\
0 &0 &S_z &0 \\
0 &0 &0 &1
\end{vmatrix}
\times
\begin{vmatrix}
X \\
Y \\
Z \\
1
\end{vmatrix}
\end{equation}
####X軸を中心に回転
\begin{equation}
\begin{vmatrix}
X' \\
Y' \\
Z' \\
W'
\end{vmatrix}
=
\begin{vmatrix}
1 &0 &0 &0 \\
0 &\cos\theta x &-\sin\theta x &0 \\
0 &\sin\theta x &\cos\theta x &0 \\
0 &0 &0 &1
\end{vmatrix}
\times
\begin{vmatrix}
X \\
Y \\
Z \\
1
\end{vmatrix}
\end{equation}
####Y軸を中心に回転
\begin{equation}
\begin{vmatrix}
X' \\
Y' \\
Z' \\
W'
\end{vmatrix}
=
\begin{vmatrix}
\cos\theta y &0 &\sin\theta y &0 \\
0 &1 &0 &0 \\
-\sin\theta y &0 &\cos\theta y &0 \\
0 &0 &0 &1
\end{vmatrix}
\times
\begin{vmatrix}
X \\
Y \\
Z \\
1
\end{vmatrix}
\end{equation}
####Z軸を中心に回転
\begin{equation}
\begin{vmatrix}
X' \\
Y' \\
Z' \\
W'
\end{vmatrix}
=
\begin{vmatrix}
\cos\theta z &-\sin\theta z &0 &0 \\
\sin\theta z &\cos\theta z &0 &0 \\
0 &0 &1 &0 \\
0 &0 &0 &1
\end{vmatrix}
\times
\begin{vmatrix}
X \\
Y \\
Z \\
1
\end{vmatrix}
\end{equation}
####ワールド座標に平行移動
\begin{equation}
\begin{vmatrix}
X' \\
Y' \\
Z' \\
W'
\end{vmatrix}
=
\begin{vmatrix}
1 &0 &0 &P_x \\
0 &1 &0 &P_y \\
0 &0 &1 &P_z \\
0 &0 &0 &1
\end{vmatrix}
\times
\begin{vmatrix}
X \\
Y \\
Z \\
1
\end{vmatrix}
\end{equation}