【ステップ2】ワイヤフレームモデルをグルグル回してみる
このステップでは、ステップ1で作成したワイヤフレームモデルをマウスで回転できるようにしてみます。
具体的には、マウスを左にドラッグしたときにはy軸を中心に、下にドラッグしたときにはx軸を中心に回転するものとします。
軸を中心にどの程度回転させるかは、回転角の値で指定します。この回転角は軸に向かって時計回りに定めます。この方向を右ネジの向きと言い、右手座標系で一般的に用いられる回転方向です。

3次元空間でモデルを回転させるには、行列の知識が若干必要になります。
3次元空間の点P(x, y, z)をx軸, y軸, z軸それぞれを中心に角度θだけ回転させた場所の点をP'(x', y', z')とすると、PとP'の関係は次のような行列で表現できます。

図7のように、マウスをドラッグすることでy軸を中心に角度θ、x軸を中心に角度φだけ回転させる場合には、それぞれの行列を次のように掛け合わせることで、図8のように回転後の座標値を求めることができます。


それでは、実際にマウスのドラッグによるモデルの回転を行うためのコードを見てみましょう。
今回は頂点を回転させて新しい座標値を求める必要があるため、頂点を回転させた後の座標値(rx, ry, rz)、及びアプレットに表示する際に用いるスクリーン上の座標値(screenX, screenY)を保持するように頂点クラスを拡張します。
新しいVertexクラスは次のようになります(Faceクラスに変更はありません)。
// 頂点クラス class Vertex { public double x, y, z; // モデルの頂点座標 public double rx, ry, rz; // 回転させた後の座標 public int screenX, screenY; // スクリーン上の座標 public Vertex(double x,double y,double z) { this.x = x; this.y = y; this.z = z; } }
アプレット自体には、y軸、x軸周りの回転角を保持するためのメンバ変数、thetaとphiを追加します。
この回転角を元に、図8で示した行列計算によって回転後の頂点の座標値を設定するsetScreenPositionメソッドを次のように作成します。
// 頂点のスクリーン座標を更新する private void setScreenPosition() { for(int i = 0; i < vertices.size(); i++) { Vertex v = (Vertex)vertices.get(i); // 回転後の座標値の算出 v.rx = v.x * Math.cos(theta) + v.z * Math.sin(theta); v.ry = v.x * Math.sin(phi) * Math.sin(theta) + v.y * Math.cos(phi) - v.z * Math.sin(phi) * Math.cos(theta); v.rz = - v.x * Math.cos(phi) * Math.sin(theta) + v.y * Math.sin(phi) + v.z * Math.cos(phi) * Math.cos(theta); // スクリーン座標の算出 v.screenX = (int)(center.x + scale * v.rx ); v.screenY = (int)(center.y - scale * v.ry ); } }
このメソッドでは、頂点の回転後の座標値とスクリーン座標での座標値を求めています。スクリーン座標の求め方はステップ1で用いた方法と同じです。後はこのスクリーン座標値を元に画面へ表示すればよいことになります。
ステップ2のコード全体は次のようになります。マウスで操作を行うため、MouseMotionListenerとMouseListenerインターフェイスを実装しています。また、描画時に画面がちらつかないように、バッファイメージに描画を行い、そのイメージをアプレットに転送するダブルバッファリングの方法も取り入れているため、ステップ1のコードに比べると若干複雑になっています。
repaint()メソッドを呼び出す場合、そのままでは画面がちらついてしまったり、描画の途中段階が表示されてしまったりすることがあります。これは、再描画の際に一度画面を背景色で塗りつぶす処理が行われているのと、描画が完了する前に表示が行われてしまうことがある、という2つのことが原因となっています。このような問題を避けるために、まずpublic void update(Graphics g){ paint(g); }
java.lang.Componentクラスのupdate関数をオーバーライドしています)。次に、アプレットと同じ大きさの
Imageオブジェクトを作成して、描画はこのImageオブジェクトに行うようにします。このImageオブジェクトへの描画が終わった段階で、このImageオブジェクトを画面に表示することで、描画の途中段階が表示される問題が解決します。このように、実際に表示される画面と同じサイズのImageを準備して、そちらに描画を行ってからアプレットに描画結果を転送することをダブルバッファリングと言います。import java.applet.Applet; import java.awt.*; import java.awt.event.*; import java.util.*; /* <applet code="Hello3D_Step2" width=300 height=300> </applet> */ public class Hello3D_Step2 extends Applet implements MouseMotionListener, MouseListener { // 頂点データ final double[][] VERTEX_DATA = { {-1, 0, 0}, {0, 1, 0}, {0, 0, -1}, {1, 0, 0}, {0, 0, 1}, {0, -1, 0}}; // 面データ final int[][] FACE_DATA = { {1, 4, 2}, {1, 0, 4}, {1, 2, 0}, {3, 2, 4}, {0, 5, 4}, {4, 5, 3}, {3, 5, 2}, {2, 5, 0}}; ArrayList vertices; // 頂点列を保持する ArrayList faces; // 面(三角形)列を保持する Point center; // アプレットの中心座標 Point mousePosition; // マウス位置 double scale; // モデル描画時のスケール double phi; // x軸周りの回転角 double theta; // y軸周りの回転角 Image bufferImage; // ダブルバッファリング用のイメージ Dimension appletSize; // アプレットサイズ public void init() { // イベントリスナの登録 addMouseMotionListener(this); addMouseListener(this); // アプレットサイズの取得 appletSize = getSize(); // アプレットの中心座標の取得 center = new Point(appletSize.width / 2, appletSize.height / 2); // マウス位置の初期化 mousePosition = new Point(0, 0); // 描画スケールの設定 scale = appletSize.width * 0.8 / 2; // 回転角の初期化 phi = 0.0; theta = 0.0; // モデルデータの設定 setModelData(); // 頂点のスクリーン座標の設定 setScreenPosition(); } public void paint(Graphics g) { // ダブルバッファリング用のイメージを作成 if(bufferImage == null) { bufferImage = createImage(appletSize.width, appletSize.height); } // バッファにモデルを描画 drawModel(bufferImage.getGraphics()); // バッファイメージをアプレットに描画 g.drawImage(bufferImage, 0, 0, this); } //描画更新時に背景の塗りつぶし処理を行わないためのオーバーライド public void update(Graphics g) { paint(g); } public void mouseMoved(MouseEvent e) {} public void mouseClicked(MouseEvent e) { } public void mouseEntered(MouseEvent e) { } public void mouseExited(MouseEvent e) { } public void mouseReleased(MouseEvent e) { } public void mouseDragged(MouseEvent e) { // 回転角の更新 theta += (e.getX() - mousePosition.x) * 0.01; phi += (e.getY() - mousePosition.y) * 0.01; // x軸周りの回転角に上限を設定 phi = Math.min(phi, Math.PI/2); phi = Math.max(phi, -Math.PI/2); // マウス位置の更新 mousePosition.setLocation(e.getX(), e.getY()); // 頂点のスクリーン座標の更新 setScreenPosition(); // 描画更新 repaint(); } public void mousePressed(MouseEvent e) { // マウス位置の更新 mousePosition.setLocation(e.getX(), e.getY()); } // モデルデータの設定 public void setModelData() { vertices = new ArrayList(); // 頂点列を初期化 faces = new ArrayList(); // 面列を初期化 // 頂点の作成 for(int i = 0; i < VERTEX_DATA.length; i++) { vertices.add(new Vertex(VERTEX_DATA[i][0], VERTEX_DATA[i][1], VERTEX_DATA[i][2])); } // 面の作成 for(int i = 0; i < FACE_DATA.length; i++) { faces.add(new Face( (Vertex)vertices.get(FACE_DATA[i][0]), (Vertex)vertices.get(FACE_DATA[i][1]), (Vertex)vertices.get(FACE_DATA[i][2]))); } } // 頂点のスクリーン座標を更新する private void setScreenPosition() { for(int i = 0; i < vertices.size(); i++) { Vertex v = (Vertex)vertices.get(i); // 回転後の座標値の算出 v.rx = v.x * Math.cos(theta) + v.z * Math.sin(theta); v.ry = v.x * Math.sin(phi) * Math.sin(theta) + v.y * Math.cos(phi) - v.z * Math.sin(phi) * Math.cos(theta); v.rz = - v.x * Math.cos(phi) * Math.sin(theta) + v.y * Math.sin(phi) + v.z * Math.cos(phi) * Math.cos(theta); // スクリーン座標の算出 v.screenX = (int)(center.x + scale * v.rx ); v.screenY = (int)(center.y - scale * v.ry ); } } // モデルの描画 private void drawModel(Graphics g) { // 白色で全体をクリア g.setColor(Color.white); g.fillRect(0, 0, appletSize.width, appletSize.height); // 各面の描画 for(int i = 0; i < faces.size(); i++) { Face face = (Face)faces.get(i); // 面の輪郭線の描画 g.setColor(Color.black); for(int j = 0; j < 3; j++) { g.drawLine(face.v[j].screenX, face.v[j].screenY, face.v[(j + 1) % 3].screenX, face.v[(j + 1) % 3].screenY ); } } } } // 面クラス class Face { public Vertex[] v = new Vertex[3]; // 面を構成する3つの頂点 public Face(Vertex v0,Vertex v1,Vertex v2) { v[0] = v0; v[1] = v1; v[2] = v2; } } // 頂点クラス class Vertex { public double x, y, z; // モデルの頂点座標 public double rx, ry, rz; // 回転させた後の座標 public int screenX, screenY; // スクリーン上の座標 public Vertex(double x,double y,double z) { this.x = x; this.y = y; this.z = z; } }
上記のコードを実行した結果は次のようになります。

マウスでワイヤフレームモデルを回転させることができるようになりました。しかし、面が無いため今ひとつ存在感が無く、全体の形を把握するのはやはり困難です。
次のステップでは、面を表示する方法を紹介します。
【ステップ3】面のあるモデルを表示する
ステップ2で作成したワイヤフレームモデルに面を追加してみましょう。
ワイヤフレームの場合は奥にある線も透けて見えますが、面を描画する場合は後ろ側の面は手前側の面に隠れて見えなくなります。これを実現する簡単な方法が、遠くにある面から手前にある面に向かって順番に上書きしながら描画を行う方法です。こうすることで、手前にある面によって奥の面が隠されることになります。
今回はz軸の正の方向から原点方向に視線が向いているので、(視点から遠くにある面)=(重心のz座標値が小さい面)と言い換えることができます。つまり、面の重心のz座標値を小さいものから順に描画すればよいことになります。
そのために、面を表すFaceクラスに新しく奥行き値を表すメンバ変数zを追加します。
// 面クラス class Face { public Vertex[] v = new Vertex[3]; // 面を構成する3つの頂点 public double z; // 奥行き public Face(Vertex v0,Vertex v1,Vertex v2) { v[0] = v0; v[1] = v1; v[2] = v2; } }
面の奥行き値の設定は、回転後の各頂点の座標値(rx, ry, rz)を算出した直後に、面を構成する頂点のz座標値(rz)を足し合わせて求めます。
for(int i = 0; i < faces.size(); i++) { Face face = (Face)faces.get(i); // 面の奥行き座標を更新 face.z = 0.0; for(int j = 0; j < 3; j++) { face.z += face.v[j].rz; } }
本来なら重心なので3で割るべきですが、この奥行きの値は面の前後判定のためだけに用いるので、割っても割らなくても関係はありません。
全ての面に奥行き座標を設定した後、この奥行きの順で面を並べ替えます。値の小さい順(または大きい順)に並べ替えることを「ソート(sort)」と言い、アルゴリズムの学習によく取り上げられます。高速で確実なソートを行えるコードを書くのは手間ですが、Javaに予め備わっているCollectionsクラスのsortメソッドを用いると簡単に並び替えを実現することができます。これには、次のようにコードを書きます。
// 面を奥行き座標で並び替える Collections.sort(faces, new FaceDepthComparator());
Collectionsクラスのsortメソッドの2番目の引数には、面の並び替えの時にどちらを前でどちらを後ろにするかを判定するために用いるComparatorインターフェイスを実装したクラスのインスタンスを指定します。
今回は、次のようなFaceDepthComparatorクラスを新しく作成し、これを使用して面をソートすることにします。
// 面を奥行き順にソートするための Comparator class FaceDepthComparator implements Comparator { public int compare(Object f1, Object f2) { return ((Face)f1).z > ((Face)f2).z ? 1 : -1; } }
FaceDepthComparatorクラスのcompareメソッドではFaceクラスのz値の大小を比較し、値の小さい順に並ぶようにしています。この方法で、facesに格納されている面は、視点から遠くにあるものから順に並び替えられます。従って、drawModelメソッドの中では、次のコードのようにfacesに格納されている面を先頭から順番に描画することで、奥にある面から手前にある面に向かって順に上書き描画していくことができます。
// 三角形描画のための座標値を格納する配列 int[] px = new int[3]; int[] py = new int[3]; // 各面の描画 for(int i = 0; i < faces.size(); i++) { Face face = (Face)faces.get(i); // 面の輪郭の座標を設定 for(int j = 0; j < 3; j++) { px[j] = face.v[j].screenX; py[j] = face.v[j].screenY; } // 描画色の指定 g.setColor(Color.green); // 面の塗りつぶし g.fillPolygon(px, py, 3); // 面の輪郭線の描画 g.setColor(Color.black); g.drawPolygon(px, py, 3); }
このように変更したアプレットを実行した結果は図10のようになります。ワイヤフレームの描画よりも、遙かに立体的な形を把握しやすくなりました。

【ステップ4】面の明るさを設定する
ステップ3で描画したモデルは全ての面が同じ色であったため、少し現実味が乏しいものでした。
そこで、面に光が当たった場合に明るさが異なる様子を表現するための工夫をしてみます。
光には点光源と平行光源があります。点光源とは、電球のように1点から発せられ、光線が放射状に広がる光のことを言います。他方の平行光源は、太陽光のように光線が平行に物体に当たるものを言います。
今回は扱いが簡単な平行光源を使うことにします。平行光源があたった場合、その光の向きと面の法線のなす角によって明るさが定まります。
今回は、z軸の正の方向から原点に向かって平行光源が当たっているものとします。こうすると、面の法線のz成分だけに着目して、明るさを考えればよくなります。
今回は厳密な計算はせずに、色をHSBで表した時のB(明度0.0~1.0)を法線と光の向きが成す角(図11)の余弦であるものとします。

まず面クラスを拡張して、法線ベクトル(nx, ny, nz)の値を保持できるようにします。
// 面クラス class Face { public Vertex[] v = new Vertex[3]; // 面を構成する3つの頂点 public double z; // 奥行き public double nx, ny, nz; // 法線 public Face(Vertex v0,Vertex v1,Vertex v2) { v[0] = v0; v[1] = v1; v[2] = v2; } }
前回のステップで追加したsetScreenPositionメソッドの中で、次のように各面の奥行きを求めると共に、法線ベクトルも求めることとします。面の法線の向きは図12のように、面を構成する2辺のベクトルの外積で求めることができます。なお、コードの中では法線ベクトルの大きさを1に正規化しています。

for(int i = 0; i < faces.size(); i++) { Face face = (Face)faces.get(i); // 面の奥行き座標を更新 face.z = 0.0; for(int j = 0; j < 3; j++) { face.z += face.v[j].rz; } // 2辺のベクトルを計算 double v1_v0_x = face.v[1].rx - face.v[0].rx; double v1_v0_y = face.v[1].ry - face.v[0].ry; double v1_v0_z = face.v[1].rz - face.v[0].rz; double v2_v0_x = face.v[2].rx - face.v[0].rx; double v2_v0_y = face.v[2].ry - face.v[0].ry; double v2_v0_z = face.v[2].rz - face.v[0].rz; // 法線ベクトルを外積から求める face.nx = v1_v0_y * v2_v0_z - v1_v0_z * v2_v0_y; face.ny = v1_v0_z * v2_v0_x - v1_v0_x * v2_v0_z; face.nz = v1_v0_x * v2_v0_y - v1_v0_y * v2_v0_x; // 法線ベクトルの正規化 double l = Math.sqrt(face.nx * face.nx + face.ny * face.ny + face.nz * face.nz ); face.nx /= l; face.ny /= l; face.nz /= l; }
実際に面を描画する時に、ここで求めた法線ベクトルのz成分を元に、次のように描画色を定めます(法線ベクトルは大きさが1であり、光線はz軸に平行であるため、法線と光線の成す角の余弦は法線のz成分に一致します)。
// 描画色の指定 g.setColor(Color .getHSBColor((float)0.4,(float)0.5, (float)face.nz));
また、[Shift]キーを押しながらマウスをドラッグした場合には、モデル表示の拡大率を変更できるように、mouseDraggedメソッドを次のように変更します。
public void mouseDragged(MouseEvent e) { if ((e.getModifiers() & e.SHIFT_MASK) == e.SHIFT_MASK ) { // スケールの更新 scale += (e.getX() - mousePosition.x); } else { // 回転角の更新 theta += (e.getX() - mousePosition.x) * 0.01; phi += (e.getY() - mousePosition.y) * 0.01; // x軸周りの回転角に上限を設定 phi = Math.min(phi, Math.PI/2); phi = Math.max(phi, -Math.PI/2); } // マウス位置の更新 mousePosition.setLocation(e.getX(), e.getY()); // 頂点のスクリーン座標の更新 setScreenPosition(); // 描画更新 repaint(); }
このように変更したアプレットを実行した結果は図13のようになります。マウスのドラッグでモデルを回転させ、[Shift]キーを押しながらドラッグすることでスケールを変えることができます。ここアプレットで、3Dモデルを表示するための機能が一通り備わりました。

