透視投影変換の計算
透視投影変換には、『OpenGLプログラミングガイド』や『ゲームプログラミングのための3Dグラフィックス数学』などのCGの参考書によく載っている、同軸座標系の行列を使用しました。透視投影変換行列は、以下のようになります。

ここで、nは視点からビューボリュームのニアクリップ面までの距離、fはファークリップ面までの距離となります。また、t、b、l、rはニアクリップ面と視線との交点からの、上辺、下辺、左辺、右辺の位置となります。

実際の計算では、この行列にモデル変換後の各頂点へのベクトル v = [x, y, z, 1]tを右からかけてやります。
v' = P v = [x', y', z', -z]t
ここで、v'の4番目の項は-zとなりますが、同軸座標系なので、x'、y'、z'をこの-zで割り、x" = - x'/z、y" = - y'/z、z" = - z' / zとしておきます。2次元座標系でのx, yを求めるときは、x"、y"をそのまま、ウィンドウ座標系にマッピングします。z"は、各頂点の奥行きにあたり、zバッファ法を使うときなどに、この値を使います。
計算方法の概要
詳しい解説は、CGの参考書に譲りますが、透視投影変換行列の計算方法が気になる方のために、簡単に計算方法を説明します。
まず、視点を原点に置き、視線の前方をz軸のマイナス方向とおきます。このとき、3次元空間上の点v=[x, y, z]tと視点を結ぶ直線と、ビューボリュームのニアクリップ面との交点をv'=[x', y', z']tとおきます。
このとき、
z' = -n x':x = y':y = z':z
となるため、
x' = - nx / z y' = - ny / z
となります。

次に、ニアクリップ面の座標を、-1≦x"≦1、-1≦y"≦1となるように、x'をx"、y'をy"に変換します。こうすることで、透視投影変換後の座標から、ウィンドウ座標系へのマッピングが可能になります。
すなわち、例えばx座標なら以下のように変換します。
[l, r] → [-1, 1]
このとき、以下の式が成り立ちます。
x"+1 : 2 = x'-l : r-l
したがって、
x"+1 = 2(x'-l) / (r-l) x" = 2(x'-l) / (r-l) - 1 = 2x'/(r-l) - 2l/(r-l) - 1
この式に、先ほど求めたx'の式を代入すると、
x" = - 2nx/(r-l)z - 2l/(r-l) - 1 = - 2nx/(r-l)z + (-2l-r+l)/(r-l) = - 2nx/(r-l)z - (r+l)/(r-l)
よって、
x" = - 2nx/(r-l)z - (r+l)/(r-l)
同様に、y"についても、
y" = - 2ny/(t-b)z - (t+b)/(t-b)
次に、z"ですが、x"、y"がともに、定数をzで割った式と定数の和という形になっているため、z"についても、同じ形の式を仮定します。
z" = a / z + b
-1≦z"≦1となるように、z= -n、z= -fのときの式を求めると、
1 = a/-f + b -1 = a/-n + b
以上より、
2 = a(1/n - 1/f) = a(f-n)/fn a = 2nf/(f-n)
また、
b = 1 + a/f = 1 + 2n/(f-n) = (f+n) / (f-n)
したがって、
z" = 2nf / (f-n)z + (f+n)/(f-n)
以上より、以下の3式が導かれます。
- x"z = (2n / (r-l))x + ((r+l) / (r-l))z - y"z = (2n / (t-b))y + ((t+b) / (t-b))z - z"z = (-(f+n) / (f-n))z - 2nf/(f-n)
ところで、v"=[x", y", z"]tは、同時座標系の4次元座標[-x"z, -y"z, -z"z, -z]tをw座標値-zで割ったものになります。
したがって、透視投影変換行列Pは、[x, y, z, 1]tを[-x"z, -y"z, -z"z, -z]tに変換する行列となり、前出の式のようになります。
透視投影変換の概要は以上のとおりです。
変換処理の実装
透視投影変換処理のソースコードを示します。まず、透視投影変換行列を求めるJavaの行列クラスのメソッドは、以下のとおりです。
public void makePerspective(double l, double r, double b, double t, double n, double f) { mat[0][0] = 2.0 * n / (r - l); mat[0][1] = 0.0; mat[0][2] = (r + l) / (r - l); mat[0][3] = 0.0; mat[1][0] = 0.0; mat[1][1] = 2.0 * n / (t - b); mat[1][2] = (t + b) / (t - b); mat[1][3] = 0.0; mat[2][0] = 0.0; mat[2][1] = 0.0; mat[2][2] = -(f + n) / (f - n); mat[2][3] = -2.0 * f * n / (f - n); mat[3][0] = 0.0; mat[3][1] = 0.0; mat[3][2] = -1.0; mat[3][3] = 0.0; }
また、面の描画メソッド内での透視投影変換の処理は、以下のとおりです。
// 受け取ったモデル変換行列、透視投影変換行列を利用して面を描画する public void persDraw(Vector2d winCenter, Vector2d winScale, Matrix4x4d persM, Matrix4x4d modelM, Matrix4x4d rotM, Vector3d eyePos, Vector3d lightDir, Graphics g) { Vector3d Mv[] = new Vector3d[vertex.length]; Vector3d PMv[] = new Vector3d[vertex.length]; for (int i = 0; i < vertex.length; i++) { // M:モデル変換行列、v:頂点、Mv:モデル変換後頂点 Mv[i] = modelM.multiplyVecRight(vertex[i]); // P:透視投影変換行列、PMv:透視投影変換後頂点 PMv[i] = persM.multiplyVecRight(Mv[i]); // Pが同軸座標のためX、YをZで割る PMv[i].divideEqual(-Mv[i].getZ()); } ……中略…… // v2d:透視投影後の頂点(2次元座標系) Vector2d v2d[] = new Vector2d[vertex.length]; for (int i = 0; i < vertex.length; i++) { v2d[i] = new Vector2d(PMv[i].getX(), PMv[i].getY()); } int x[] = new int[vertex.length]; int y[] = new int[vertex.length]; // 透視投影後の頂点のウィンドウ座標へのマッピング for (int i = 0; i < vertex.length; i++) { x[i] = (int)(winCenter.getX() + v2d[i].getX() * winScale.getX()); y[i] = (int)(winCenter.getY() - v2d[i].getY() * winScale.getY()) } // ポリゴンの描画 g.setColor(new Color(red, green, blue)); g.fillPolygon(x, y, vertex.length); }
回転・移動・ズームのオペレーション
マウス操作でのオペレーションは、マウス左ボタンを押しながらのドラッグで回転、右ボタンを押しながらのドラッグで上下左右の移動、左ボタン、右ボタン同時に押しながらのドラッグで前後へのズームという風に割り当てました。マウスドラッグから各オペレーションへの変換ですが、まず、描画処理は別スレッドとして走らせ、そこで各変換のパラメータからモデル変換行列を計算し、それを使用して変換後の図形を描画します。マウスドラッグのハンドラでは、マウスドラッグが始まると、マウス位置を保持して前回位置との差分を取り、それを各変換のパラメータの計算に使用します。
以下に、マウスドラッグのハンドラのソースを示します。
public void mouseDragged(MouseEvent e) { if (e.getModifiers() == InputEvent.BUTTON1_MASK) { int x = e.getX(); int y = e.getY(); axis.setValue(y - prevY, x - prevX, 0.0); double length = axis.normalize(); angle = 0.5 * length; prevX = x; prevY = y; } else if (e.getModifiers() == InputEvent.BUTTON3_MASK) { Dimension size = getSize(); int width = size.width; int height = size.height; double w = 2.0 * Math.tan((fov / 2.0) * Math.PI / 180.0) * n; double h = w * (double)height / (double)width; int x = e.getX(); int y = e.getY(); double r = 1.0 / (double)width; double dx = r * (x - prevX) * w * - transDistance / n; double dy = r * (prevY - y) * h * - transDistance / n; Vector3d t = new Vector3d(dx, dy, 0.0); transVec.addEqual(t); prevX = x; prevY = y; } else if (e.getModifiers() == (InputEvent.BUTTON1_MASK | InputEvent.BUTTON3_MASK)) { int x = e.getX(); int y = e.getY(); transDistance += (y - prevY); prevX = x; prevY = y; } }
最後に
今回は、立方体という単純な立体図形の描画にとどめたため、あまり面白味がなかったかもしれません。陰面消去の処理にzバッファ法などを利用して、複数の立体図形や、凹んだ部分がある立体図形も描画できるように改良すると、より面白いのではないかと思います。次回はぜひ、zバッファ法などで、しっかりとした陰面消去を行ってみたいと思います。
参考資料
- 『OpenGLプログラミングガイド 第2版』 OpenGL Architecture Review Board・Mason Woo・Tom Davis・Jackie Neider 著、株式会社アクロス 訳、ピアソンエデュケーション、2002年4月
- 『独習Java 第2版』 Joseph O’Neil 著、株式会社トップスタジオ 訳、武藤健志 監修、翔泳社、2002年7月
- 『ゲームプログラミングのための3Dグラフィックス数学』 Eric Lengyel 著、狩野智英 訳、ボーンデジタル、2002年10月
