【ステップ5】ファイルからモデルのデータを読み込む
前回までは、表示するモデルをコードの中に配列として書き込んでいましたが、この方法だと、表示するモデルを変更する場合には毎回コンパイルし直す必要があるため大変です。
そこで、予め別ファイルにモデルデータを保存しておき、それをアプレットから読み込むように変更してみます。
ここではモデルデータが次のようなテキスト形式で保存されたファイルを読み込むこととします。
v -1.0 0.0 0.0 v 0.0 1.0 0.0 v 0.0 0.0 -1.0 v 1.0 0.0 0.0 v 0.0 0.0 1.0 v 0.0 -1.0 0.0 f 2 5 3 f 2 1 5 f 2 3 1 f 4 3 5 f 1 6 5 f 5 6 4 f 4 6 3 f 3 6 1
上記のテキストファイルでは、vで始まる行に頂点の座標値がx, y, z座標の順に記述され、fで始まる行に、面を構成する頂点の識別番号(識別番号に0は無く、最初の頂点が1になります。)が記述されています。
このような形式を「wavefront objフォーマット」といい、多くの一般的な3DCGソフトウェアからこの形式で3D形状のデータを書き出すことができます(ソフトウェアによってはobj形式であっても、もう少し複雑な形式でファイルにデータが書き出されることがあります)。
今回は上記のようなファイルを読み込むための機能を追加します。
objファイルのファイル名は、アプレットタグを使用してHTMLファイルで指定するものとします。次のようにfilename
という名前のパラメータで指定します。ここで指定するobjファイルはアプレットクラスと同じ場所に配置します。
<applet code="Hello3D_Step5" width=300 height=300> <param name="filename" value="model.obj"> </applet>
アプレットのコードでは、フィールドの先頭に次のようなファイル名を保持する変数を定義します。
String dataFileName; // モデル情報を保存したファイル名
アプレットタグで指定されたファイル名を取得するには、init
メソッドに次のようなコードを追加します。
// 引数からモデルファイル名を取得する dataFileName = getParameter("filename");
続いて、指定されたファイルを読み込むようにsetModelData
メソッドを次のように変更します。
java.io
パッケージに含まれるStreamTokenizer
クラスを使うと、objファイルような空白文字で区切られた文字や数字(トークン)を順番に読み込むことが簡単に実現できます。
// モデルデータの設定 public void setModelData() { vertices = new ArrayList(); // 頂点列を初期化 faces = new ArrayList(); // 面列を初期化 InputStream is; double modelSize = 0; try { // ファイルのストリームとトークナイザを作成 is = new URL( getDocumentBase(), dataFileName ) .openStream(); Reader r = new BufferedReader(new InputStreamReader(is)); StreamTokenizer st = new StreamTokenizer(r); // トークンごとの読み込み int token; while((token = st.nextToken()) != StreamTokenizer.TT_EOF) { if(token == StreamTokenizer.TT_WORD) { if(st.sval.equals("v")) { // 頂点情報の取得 st.nextToken(); double x = st.nval; // x座標 st.nextToken(); double y = st.nval; // y座標 st.nextToken(); double z = st.nval; // z座標 // モデルサイズを更新 modelSize = Math.max(modelSize, x); modelSize = Math.max(modelSize, y); modelSize = Math.max(modelSize, z); // 頂点列に新しい頂点を追加 vertices.add(new Vertex(x, y, z)); } else if(st.sval.equals("f")) { // 面情報の取得 // 頂点インデックスの取得 st.nextToken(); int v0 = (int)st.nval; st.nextToken(); int v1 = (int)st.nval; st.nextToken(); int v2 = (int)st.nval; // 面列に新しい面を追加 faces.add(new Face( (Vertex)vertices.get(v0 - 1), (Vertex)vertices.get(v1 - 1), (Vertex)vertices.get(v2 - 1))); } } } } catch(Exception e) { getAppletContext().showStatus("Data Load Error"); } // モデルの大きさが1になるように正規化 for(int i = 0; i < vertices.size(); i++) { Vertex v = (Vertex)vertices.get(i); v.x /= modelSize; v.y /= modelSize; v.z /= modelSize; } }
これにより、図14のように任意のモデルを読み込んで表示できるようになります。
なお、最終的なHello3D_Step5.javaのコードは次のようになります。
import java.applet.Applet; import java.awt.*; import java.awt.event.*; import java.util.*; import java.io.*; import java.net.*; /* <applet code="Hello3D_Step5" width=300 height=300> <param name="filename" value="model.obj"> </applet> */ public class Hello3D_Step5 extends Applet implements MouseMotionListener, MouseListener { String dataFileName; // モデル情報を保存したファイル名 ArrayList vertices; // 頂点列を保持する ArrayList faces; // 面(三角形)列を保持する Point center; // アプレットの中心座標 Point mousePosition; // マウス位置 double scale; // モデル描画時のスケール double phi; // x軸周りの回転角 double theta; // y軸周りの回転角 Image bufferImage; // ダブルバッファリング用のイメージ Dimension appletSize; // アプレットサイズ public void init() { // 引数からモデルファイル名を取得する dataFileName = getParameter("filename"); // イベントリスナの登録 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) { 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(); } public void mousePressed(MouseEvent e) { // マウス位置の更新 mousePosition.setLocation(e.getX(), e.getY()); } // モデルデータの設定 public void setModelData() { vertices = new ArrayList(); // 頂点列を初期化 faces = new ArrayList(); // 面列を初期化 InputStream is; Vertex minV = new Vertex(Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE); Vertex maxV = new Vertex(-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE); try { // ファイルのストリームとトークナイザを作成 is = new URL( getDocumentBase(), dataFileName ) .openStream(); Reader r = new BufferedReader(new InputStreamReader(is)); StreamTokenizer st = new StreamTokenizer(r); // トークンごとの読み込み int token; while((token=st.nextToken()) != StreamTokenizer.TT_EOF) { if(token == StreamTokenizer.TT_WORD) { if(st.sval.equals("v")) { // 頂点情報の取得 st.nextToken(); double x = st.nval; //x座標 st.nextToken(); double y = st.nval; //y座標 st.nextToken(); double z = st.nval; //z座標 // モデルサイズを更新 minV.x = Math.min(minV.x, x); minV.y = Math.min(minV.y, y); minV.z = Math.min(minV.z, z); maxV.x = Math.max(maxV.x, x); maxV.y = Math.max(maxV.y, y); maxV.z = Math.max(maxV.z, z); // 頂点列に新しい頂点を追加 vertices.add(new Vertex(x, y, z)); } else if(st.sval.equals("f")) { // 面情報の取得 // 頂点インデックスの取得 st.nextToken(); int v0 = (int)st.nval; st.nextToken(); int v1 = (int)st.nval; st.nextToken(); int v2 = (int)st.nval; // 面列に新しい面を追加 faces.add( new Face( (Vertex)vertices.get(v0 - 1), (Vertex)vertices.get(v1 - 1), (Vertex)vertices.get(v2 - 1))); } } } } catch(Exception e) { getAppletContext().showStatus("Data Load Error"); } double modelSize = Math.max(maxV.x - minV.x, maxV.y - minV.y); modelSize = Math.max(modelSize, maxV.z - minV.z); // モデルの大きさが原点を中心とする1辺が2の立方体に収まるようにする for(int i = 0; i < vertices.size(); i++) { Vertex v = (Vertex)vertices.get(i); v.x = (v.x - (minV.x + maxV.x) / 2) / modelSize * 2; v.y = (v.y - (minV.y + maxV.y) / 2) / modelSize * 2; v.z = (v.z - (minV.z + maxV.z) / 2) / modelSize * 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 ); } 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; } // 面を奥行き座標で並び替える Collections.sort(faces, new FaceDepthComparator()); } // モデルの描画 private void drawModel(Graphics g) { // 白色で全体をクリア g.setColor(Color.white); g.fillRect(0, 0, appletSize.width, appletSize.height); // 三角形描画のための座標値を格納する配列 int[] px = new int[3]; int[] py = new int[3]; // 各面の描画 for(int i = 0; i < faces.size(); i++) { Face face = (Face)faces.get(i); // 面の法線が裏を向いている場合は描画をスキップ if(face.nz < 0) { continue; } // 面の輪郭の座標を設定 for(int j = 0; j < 3; j++) { px[j] = face.v[j].screenX; py[j] = face.v[j].screenY; } // 描画色の指定 g.setColor(Color .getHSBColor((float)0.4,(float)0.5,(float)face.nz)); // 面の塗りつぶし g.fillPolygon(px, py, 3); // 面の輪郭線の描画 g.setColor(Color.black); g.drawPolygon(px, py, 3); } } } // 面を奥行き順にソートするための Comparator class FaceDepthComparator implements Comparator { public int compare(Object f1, Object f2) { return ((Face)f1).z > ((Face)f2).z ? 1 : -1; } } // 面クラス 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; } } // 頂点クラス 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; } }
おわりに
以上、5つのステップで3Dモデルを表示するためのアプレットを作成する方法を見てきました。
実際に3Dモデルを表示するためのアプレットをゼロから自作することはあまりないと思いますが、今回説明したような3DCGの基本が理解できていると、いろいろ応用できると思います。
しかし、はじめに記したように今回紹介した3D表示方法は面単位で描画を行う非常に原始的な方法なので、面と面が交差しているような形状や、図15のような奥行き順が三つ巴の関係にあるようなものは正しく描画することができません。
また、透過オブジェクト、光の反射などを表現するには、レイトレーシングなど別のアルゴリズムを用いる必要があります。「どのように3Dモデルを画面に表示するか」という課題はCGの世界では「レンダリング」というカテゴリで多くの方法が研究されています。今回紹介した方法は、その中の極めて初歩的なものです。しかし、どのようなレンダリング手法を用いる場合でも、今回の記事で紹介した「モデルの座標値をスクリーン座標に投影する」ということは必ず必要になってくる基本的な事柄ですので、今後のCGの学習に役立てていただければ幸いです。