RSS2.0

LWJGL で Blender の 3D モデルを表示してみる

普通 3D モデルを作るには Blender やメタセコイアなどのモデリングソフトを使うため、ゲームプログラム側ではこれらのモデリングソフトで作った 3D モデルを読み込み、表示する機能が必要になります。今回は Blender で作成した 3D モデルを Wavefront OBJ フォーマットと呼ばれるデータ形式で書き出し、LWJGL で読み込んで表示してみたいと思います。

完成図はこちら。
kuma.png
カクカクしててすごくレトロな感じがしますね!(注意: 作ったモデルのせいです(笑)

ソースコード

サンプルプログラムのソースコードです。
実行には昔使った以下のファイルが必要になりますので、いつも通りプロジェクトに配置してください。
test.monolith.v3.TextureLoader
test.monolith.v3.Texture
test.alphablend.AlphaBlend
test.light.LightManager

今回のサンプルプログラムのソースコードは、以下の 3 つです。

BlenderPlayer.java
package test.blender;

import static org.lwjgl.opengl.GL11.GL_BACK;
import static org.lwjgl.opengl.GL11.GL_BLEND;
import static org.lwjgl.opengl.GL11.GL_CULL_FACE;
import static org.lwjgl.opengl.GL11.GL_DEPTH_TEST;
import static org.lwjgl.opengl.GL11.GL_FRONT_AND_BACK;
import static org.lwjgl.opengl.GL11.GL_LINES;
import static org.lwjgl.opengl.GL11.GL_MODELVIEW;
import static org.lwjgl.opengl.GL11.GL_NORMALIZE;
import static org.lwjgl.opengl.GL11.GL_PROJECTION;
import static org.lwjgl.opengl.GL11.GL_TEXTURE_2D;
import static org.lwjgl.opengl.GL11.glClear;
import static org.lwjgl.opengl.GL11.glClearColor;
import static org.lwjgl.opengl.GL11.glColor4f;
import static org.lwjgl.opengl.GL11.glCullFace;
import static org.lwjgl.opengl.GL11.glEnable;
import static org.lwjgl.opengl.GL11.glLoadIdentity;
import static org.lwjgl.opengl.GL11.glMatrixMode;
import static org.lwjgl.opengl.GL11.glOrtho;
import static org.lwjgl.opengl.GL11.glPolygonMode;
import static org.lwjgl.opengl.GL11.glPopMatrix;
import static org.lwjgl.opengl.GL11.glPushMatrix;
import static org.lwjgl.opengl.GL11.glRotatef;
import static org.lwjgl.opengl.GL11.glScalef;
import static org.lwjgl.opengl.GL11.glTranslatef;

import java.io.IOException;

import org.lwjgl.LWJGLException;
import org.lwjgl.Sys;
import org.lwjgl.opengl.Display;
import org.lwjgl.opengl.DisplayMode;
import org.lwjgl.opengl.GL11;

import test.light.LightManager;
import test.monolith.v3.TextureLoader;

public class BlenderPlayer {
	private int			width = 1000;
	private int			height = 700;
	private int			depth = 1000;
	private String		title = "Blender player";

	private long		time;
	private long		timeDelta;
	private long		fps;
	private long		calcedFps;

	private TextureLoader	textureLoader;
	private LightManager	lightManager;
	private Model			model;

	public void start() {
		try {
			//	ウインドウを生成する
			Display.setDisplayMode(new DisplayMode(width, height));
			Display.setTitle(title);
			Display.create();
		} catch(LWJGLException e) {
			e.printStackTrace();
			return;
		}

		try {
			//	OpenGL の初期設定

			//	オフスクリーンを初期化する際の背景色を指定する
			glClearColor(0.5f, 0.5f, 0.5f, 1f);

			//	テクスチャーを有効化する
			glEnable(GL_TEXTURE_2D);

			//	アルファブレンドを有効化する
			glEnable(GL_BLEND);

			//	ポリゴンの表示面を表のみに設定する
			glEnable(GL_CULL_FACE);
			glCullFace(GL_BACK);

			//	カメラ用の設定変更を宣言する
			glMatrixMode(GL_PROJECTION);
			//	設定を初期化する
			glLoadIdentity();
			//	視体積(目に見える範囲)を定義する
			glOrtho(0, width, 0, height, 0, depth);

			//	物体モデル用の設定変更を宣言する
			glMatrixMode(GL_MODELVIEW);

			//	デプステストを有効にする
			glEnable(GL_DEPTH_TEST);
			
			init();

			while (!Display.isCloseRequested()) {
				//	オフスクリーンを初期化する
				glClear(GL11.GL_COLOR_BUFFER_BIT | GL11.GL_DEPTH_BUFFER_BIT);

				//	FPS を計算する
				calcFps();

				update();

				//	オフスクリーンに描画する
				render();

				//	オフスクリーンをスクリーンに反映する
				Display.update();
			}
		} catch(Exception e) {
			e.printStackTrace();
		} finally {
			terminate();
			Display.destroy();
		}
	}

	private void init() throws Exception {
		time = getMillisecond();
		fps = 0;

		textureLoader = new TextureLoader();
		
		lightManager = new LightManager();
		lightManager.initLight();
		
		//	ファイルのパス指定で画像を読み込む
		model = ObjectLoader.load("C:/blender/kuma.obj");
		model.compile(textureLoader);
	}
	
	private void terminate() {
		if (model != null) model.dispose();
	}

	private void update() throws IOException {
		lightManager.update();
		
		Display.setTitle(title + "(" + calcedFps + "fps) " + lightManager.getLabel());

		//	FPS を調整する
		Display.sync(60);
	}

	private void render() {
		glPolygonMode(GL_FRONT_AND_BACK, GL_LINES);
		
		glEnable(GL_NORMALIZE);
		
		//	設定を初期化する
		glLoadIdentity();

		lightManager.light();
		
		//	行列スタックに現在の行列を退避させ、新しい行列に対してモデルビュー変換を開始する
		glPushMatrix();
		
		//	画面中央の座標を (0, 0, 0) とするよう座標系を移動する
		glTranslatef(width / 2f, height / 2f, depth / -2f);

		glRotatef(lightManager.getXAngle(), 0, 1, 0);
		glRotatef(lightManager.getYAngle(), 1, 0, 0);
		
		float sclae = 200f;
		glScalef(sclae, sclae, sclae);
		
		glColor4f(1f, 1f, 1f, 1f);

		model.render();

		//	行列スタックからもとの行列を取り出す
		glPopMatrix();
	}

	private void calcFps() {
		long	now = getMillisecond();

		//	前のフレームからの経過時間を求め、timeDelta に足す
		timeDelta += now - time;
		time = now;

		if (1000 <= timeDelta) {
			//	timeDelta が 1 秒を上回ったら、FPS をリセットする
			timeDelta -= 1000;
			calcedFps = fps;
			fps = 1;
		} else {
			fps++;
		}
	}
	
	public static long getMillisecond() {
		//	現在の時間をミリ秒で返す
		return (Sys.getTime() * 1000) / Sys.getTimerResolution();
	}

	public static void main(String[] args) {
		new BlenderPlayer().start();
	}
}

ObjectLoader.java
package test.blender;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;

import test.blender.Model.DisplayList;
import test.blender.Model.Material;

public class ObjectLoader {
	private static final String DEFAULT_IMAGES_DIR = "images";

	/**
	 *	渡されたファイルパスの OBJ ファイルを読み込み、Model インスタンスとして返す
	 */
	public static Model load(String filePath) throws IOException {
		Model			model = new Model(filePath, DEFAULT_IMAGES_DIR);
		DisplayList		displayList = null;
		File			file = new File(filePath);
		BufferedReader	br = null;
		String			line;
		
		//	テキストファイルなので 1 行ずつ読み込んでパースしていく
		try {
			br = new BufferedReader(new FileReader(file));
			
			while ((line = br.readLine()) != null) {
				String[]	tokens = line.split(" ");
				
				if ((tokens.length == 4) && (tokens[0].equals("v"))) {
					//	頂点座標としてパースする
					float	x = Float.valueOf(tokens[1]);
					float	y = Float.valueOf(tokens[2]);
					float	z = Float.valueOf(tokens[3]);
				
					model.addVertex(x, y ,z);
				} else if ((tokens.length == 3) && (tokens[0].equals("vt"))) {
					//	テクスチャー座標としてパースする
					float	x = Float.valueOf(tokens[1]);
					float	y = Float.valueOf(tokens[2]);

					model.addTextureVertex(x, y);
				} else if ((tokens.length == 4) && (tokens[0].equals("vn"))) {
					//	法線としてパースする
					float	x = Float.valueOf(tokens[1]);
					float	y = Float.valueOf(tokens[2]);
					float	z = Float.valueOf(tokens[3]);
					
					model.addNormal(x, y ,z);
				} else if ((tokens.length == 4) && (tokens[0].equals("f"))) {
					//	面(頂点の集合)としてパースする
					String[]	data1 = tokens[1].split("/");
					String[]	data2 = tokens[2].split("/");
					String[]	data3 = tokens[3].split("/");

					int		vertex1Index = Integer.valueOf(data1[0]);
					int		vertex2Index = Integer.valueOf(data2[0]);;
					int		vertex3Index = Integer.valueOf(data3[0]);;

					int		textureVertex1Index = intIfExists(data1[1]);
					int		textureVertex2Index = intIfExists(data2[1]);;
					int		textureVertex3Index = intIfExists(data3[1]);;

					int		normal1Index = Integer.valueOf(data1[2]);;
					int		normal2Index = Integer.valueOf(data2[2]);;
					int		normal3Index = Integer.valueOf(data3[2]);;

					displayList.addFace(
							vertex1Index, vertex2Index, vertex3Index,
							textureVertex1Index, textureVertex2Index, textureVertex3Index,
							normal1Index, normal2Index, normal3Index);
				} else if ((tokens.length == 2) && (tokens[0].equals("mtllib"))) {
					//	mtl ファイルからマテリアルを読み込む
					loadMaterials(file.getParent(), tokens[1], model);
				} else if ((tokens.length == 2) && (tokens[0].equals("usemtl"))) {
					//	面に適用するマテリアルの指定を読み込む
					displayList.setMaterialName(tokens[1]);
				} else if ((tokens.length == 2) && (tokens[0].equals("o"))) {
					//	オブジェクトとして読み込む
					displayList = model.createDisplayList();
				}
			}
		} finally {
			if (br != null) {
				try {
					br.close();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
		}
		
		return model;
	}
	
	/**
	 *	渡されたファイルパスの mtl ファイルを読み込み、Model インスタンスに追加する
	 */
	private static void loadMaterials(String parentDir, String filePath, Model model) throws IOException {
		Material		material = null;
		BufferedReader	br = null;
		String			line;
		
		//	ファイルパスは、OBJ ファイルの場所を基点として扱う
		if (parentDir == null) {
			parentDir = "";
		} else if (0 < parentDir.length()) {
			parentDir = parentDir + File.separator;
		}

		//	テキストファイルなので 1 行ずつ読み込んでパースしていく
		try {
			br = new BufferedReader(new FileReader(parentDir + filePath));
			
			while ((line = br.readLine()) != null) {
				String[]	tokens = line.split(" ");

				if ((tokens.length == 2) && (tokens[0].equals("newmtl"))) {
					//	マテリアル名を読み込む
					material = model.createMaterial(tokens[1]);
				} else if ((tokens.length == 2) && (tokens[0].equals("map_Kd"))) {
					//	テクスチャーのファイル名を読み込む
					material.setTextureName(tokens[1]);
				}
			}
		} finally {
			if (br != null) {
				try {
					br.close();
				} catch (IOException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
			}
		}
	}

	private static int intIfExists(String value) {
		try {
			return Integer.parseInt(value);
		} catch(NumberFormatException e) {
			return -1;
		}
	}

}

Model.java
package test.blender;

import static org.lwjgl.opengl.GL11.GL_COMPILE;
import static org.lwjgl.opengl.GL11.GL_TRIANGLES;
import static org.lwjgl.opengl.GL11.glBegin;
import static org.lwjgl.opengl.GL11.glCallList;
import static org.lwjgl.opengl.GL11.glDeleteLists;
import static org.lwjgl.opengl.GL11.glEnd;
import static org.lwjgl.opengl.GL11.glEndList;
import static org.lwjgl.opengl.GL11.glGenLists;
import static org.lwjgl.opengl.GL11.glNewList;
import static org.lwjgl.opengl.GL11.glNormal3f;
import static org.lwjgl.opengl.GL11.glTexCoord2f;
import static org.lwjgl.opengl.GL11.glVertex3f;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.lwjgl.util.vector.Vector3f;

import test.alphablend.AlphaBlend;
import test.monolith.v3.Texture;
import test.monolith.v3.TextureLoader;

public class Model {
	private final File					objFile;
	private final File					imagesDir;
	private final List<Vector3f>		vertices = new ArrayList<Vector3f>();
	private final List<Point>			textureVertices = new ArrayList<Point>();
	private final List<Vector3f>		normals = new ArrayList<Vector3f>();
	private final Map<String, Material>	materials = new HashMap<String, Material>();
	private final List<DisplayList>		displayLists = new ArrayList<DisplayList>();
	
	public Model(String objFile, String imagesDir) {
		this.objFile = new File(objFile);
		this.imagesDir = new File(imagesDir);
	}

	/**
	 *	この Model を最適化し、必要なリソースを確保する
	 */
	public void compile(TextureLoader textureLoader) throws IOException {
		for (DisplayList displayList: displayLists) {
			displayList.compile(textureLoader);
		}
	}

	/**
	 *	この Model が持つすべてのリソースを破棄する
	 */
	public void dispose() {
		for (DisplayList displayList: displayLists) {
			displayList.dispose();
		}
	}

	/**
	 *	この Model を描画する
	 */
	public void render() {
		for (DisplayList displayList: displayLists) {
			Material	material = materials.get(displayList.materialName);

			if (material != null) {
				Texture	texture = material.texture;
				AlphaBlend.AlphaBlend.config(texture);
				texture.bind();
			}

			glCallList(displayList.objectDisplayList);
		}
	}

	public DisplayList createDisplayList() {
		DisplayList	displayList = new DisplayList();
		
		displayLists.add(displayList);
		
		return displayList;
	}

	public void addVertex(float x, float y, float z) {
		vertices.add(new Vector3f(x, y, z));
	}
	
	public Vector3f getVertex(int index) {
		return vertices.get(index - 1);
	}
	
	public void addNormal(float x, float y, float z) {
		normals.add(new Vector3f(x, y, z));
	}
	
	public Vector3f getNormal(int index) {
		return normals.get(index - 1);
	}
	
	public Point getTextureVertex(int index) {
		return textureVertices.get(index - 1);
	}
	
	public void addTextureVertex(float x, float y) {
		textureVertices.add(new Point(x, y));
	}
	
	public Material createMaterial(String name) {
		Material	material = new Material(name);

		this.materials.put(name, material);
		
		return material;
	}

	/**
	 *	モデルを構成するオブジェクトを表すクラス
	 */
	class DisplayList {
		private final List<Face>	faces = new ArrayList<Face>();
		
		private String			materialName;
		private int				objectDisplayList;

		public void setMaterialName(String materialName) {
			this.materialName = materialName;
		}

		public void compile(TextureLoader textureLoader) throws IOException {
			//	ディスプレイリストを確保し、ID を生成する
			objectDisplayList = glGenLists(1);
			
			//	ディスプレイリストへ描画処理を保存する
			glNewList(objectDisplayList, GL_COMPILE);
			{
				glBegin(GL_TRIANGLES);

				for (Face face: faces.toArray(new Face[] {})) {
					Point		texCord1 = (0 < face.textureVertex1Index)? getTextureVertex(face.textureVertex1Index): null;
					Point		texCord2 = (0 < face.textureVertex2Index)? getTextureVertex(face.textureVertex2Index): null;
					Point		texCord3 = (0 < face.textureVertex3Index)? getTextureVertex(face.textureVertex3Index): null;

					Vector3f	normal1 = getNormal(face.normal1Index);
					Vector3f	normal2 = getNormal(face.normal2Index);
					Vector3f	normal3 = getNormal(face.normal3Index);
					Vector3f	vector1 = getVertex(face.vertex1Index);
					Vector3f	vector2 = getVertex(face.vertex2Index);
					Vector3f	vector3 = getVertex(face.vertex3Index);

					glNormal3f(normal1.x, normal1.y, normal1.z);
					if (texCord1 != null) glTexCoord2f(texCord1.getX(), texCord1.getY() * -1);
					glVertex3f(vector1.x, vector1.y, vector1.z);

					glNormal3f(normal2.x, normal2.y, normal2.z);
					if (texCord2 != null) glTexCoord2f(texCord2.getX(), texCord2.getY() * -1);
					glVertex3f(vector2.x, vector2.y, vector2.z);

					glNormal3f(normal3.x, normal3.y, normal3.z);
					if (texCord3 != null) glTexCoord2f(texCord3.getX(), texCord3.getY() * -1);
					glVertex3f(vector3.x, vector3.y, vector3.z);
				}
				glEnd();
			}
			//	ディスプレイリストへの保存を終了する
			glEndList();
			
			//	テクスチャーをロードする
			Material	material = materials.get(materialName);

			if (material != null) {
				String	texturePath;
				File	textureFile = new File(material.textureName);
				
				if (textureFile.isAbsolute()) {
					texturePath = textureFile.getPath();
				} else if (imagesDir.isAbsolute()) {
					texturePath = imagesDir.getCanonicalFile().getParent() + File.separator + textureFile.getPath();
				} else {
					texturePath = objFile.getCanonicalFile().getParent() + File.separator + imagesDir.getPath() + File.separator + textureFile.getPath();
				}
				
				material.texture = textureLoader.loadTexture(texturePath);
			}
		}

		public void addFace(
				int vertex1Index, int vertex2Index, int vertex3Index,
				int textureVertex1Index, int textureVertex2Index, int textureVertex3Index,
				int normal1Index, int normal2Index, int normal3Index) {
			faces.add(new Face(
					vertex1Index, vertex2Index, vertex3Index,
					textureVertex1Index, textureVertex2Index, textureVertex3Index,
					normal1Index, normal2Index, normal3Index));
		}

		public void dispose() {
			//	テクスチャーを破棄する
			for (Material material: materials.values()) {
				material.dispose();
			}

			//	モデル描画の記録を破棄する
			glDeleteLists(objectDisplayList, 1);
		}
	}
	
	/**
	 *	オブジェクトを構成する面を表すクラス
	 *	面は三角形のみとし、各頂点がそれぞれ頂点座標、テクスチャー座標、法線のインデックスを持つ
	 */
	class Face {
		private final int		vertex1Index;
		private final int		vertex2Index;
		private final int		vertex3Index;

		private final int		textureVertex1Index;
		private final int		textureVertex2Index;
		private final int		textureVertex3Index;

		private final int		normal1Index;
		private final int		normal2Index;
		private final int		normal3Index;
		
		public Face(
				int vertex1Index, int vertex2Index, int vertex3Index,
				int textureVertex1Index, int textureVertex2Index, int textureVertex3Index,
				int normal1Index, int normal2Index, int normal3Index) {
			this.vertex1Index = vertex1Index;
			this.vertex2Index = vertex2Index;
			this.vertex3Index = vertex3Index;
			this.textureVertex1Index = textureVertex1Index;
			this.textureVertex2Index = textureVertex2Index;
			this.textureVertex3Index = textureVertex3Index;
			this.normal1Index = normal1Index;
			this.normal2Index = normal2Index;
			this.normal3Index = normal3Index;
		}
	}
	
	/**
	 *	二次元座標を表すクラス
	 */
	class Point {
		private final float	x;
		private final float	y;

		public Point(float x, float y) {
			this.x = x;
			this.y = y;
		}

		public float getX() {
			return x;
		}

		public float getY() {
			return y;
		}
	}

	/**
	 *	マテリアルを表すクラス
	 */
	class Material {
		private final String name;

		private String		textureName;
		private Texture		texture;

		public Material(String name) {
			this.name = name;
		}

		public void setTextureName(String textureName) {
			this.textureName = textureName;
		}

		public void dispose() {
			if (texture != null) {
				texture.dispose();
			}
		}
	}
}

こちらが使用する画像です。プロジェクトのディレクトリ直下に images というディレクトリを用意して、その中に指定の名前で置いてください。

kuma_body.png
kuma_body.png

kuma_parts.png
kuma_parts.png

これらは読み込んでいる 3D モデルのデータです。プロジェクトのディレクトリ直下にそのままの名前で置いてください。
kuma.obj
kuma.mtl

Blender から OBJ ファイルを書き出す

サンプルプログラムでは書き出したサンプルデータを用意していますが、自分で作ったモデルを書き出す方法について書いておきたいと思います。

実際に書き出す前に、適切にモデルが作られているか、いくつか確認しておくことがあります。
私自身が Blender で作った通りに LWJGL 側で表示することができず、すっきり表示させるためにも見ておきましょう。特に Blender にまだ慣れてない方は気をつけてくださいー。

モデル作成時の注意

1. 法線の向き

Blender で作った 3D モデルで法線が適切な方向に向いているかを確認してください。法線は面から外側に向かって設定されていなければなりません。
法線は、Edit モードで、Mesh Display の Normals 欄にある "面" ボタンを押すと、水色の線として表示されます。法線は面の中心から外側に向かって伸びているので、表示さえしてしまえば確認するのは簡単です。
blender_view_normals.png
法線が反対になっている場合には、同じく Edit モードの Mesh Tools の Flip Direction ボタンを押すことで、現在選択中の面の向きを反転させることができます。

2. オブジェクトのスケール

各オブジェクトのスケールに負の値が設定されていないか(反転していないか)も確認してください。この値はデフォルトでは 1 になっています。
私はモデル作成時にオブジェクトモードでのミラー(反転)を使ってしまっていて、X 座標のスケールが -1 のままになっていることに気づかず、LWJGL で表示すると法線の向きが反転しているように見えてしまいました。法線の向き自体は適切だったのですが、そもそも座標系が X 軸で反転していたため、面が意図した方向を向いて表示されなかったのです。
blender_scale.png
オブジェクトモードでのスケールが -1 などで反転している場合には 1 に戻し(この欄で直接編集可能)、改めて Edit モードで反転させたいオブジェクトをミラーしてください。

OBJ ファイルを書き出す

Blender でモデルを書き出す場合、デフォルトでは法線の出力と、面の三角形への分割が行われません。法線の計算を LWJGL で読み込んだ時点で行うとその分処理が多くなりますし、面が四角形以上になると LWJGL 側での処理が複雑になってきますので、あらかじめ三角形に分割しておくと都合がよくなります。
そこで、Blender からのモデル書き出しは以下の手順で行い、法線の出力と面の三角形への分割を有効にしておきます。

1. 「File」メニューから、「Export」>「Wavefront(.obj)」を押す
blender_export01.png
2. 左側の Export OBJ 欄にて、「Include Normals」と「Triangulate Faces」にチェックを入れる
blender_export02.png
3. 書き出すディレクトリを選び、ファイル名を入力して、「Export ObJ」ボタンを押す

これで、選択したディレクトリ内に、拡張子が .obj と .mtl のファイルとしてモデルが書き出されます。
obj ファイルはオブジェクトの定義で、頂点やテクスチャー、法線の座標が書かれています。
mtl ファイルはマテリアルの定義で、テクスチャーのファイル名や、ライティングでのマテリアルの色(環境光、拡散光、鏡面光等)の設定が書かれています。

各ファイルのフォーマット

次に、書き出した2つのファイルのフォーマットについて見ていきましょう。

obj ファイル

obj ファイルは半角スペース区切りのテキストファイル(Space-Separated Values)となっています。
各行の、半角スペース区切りで先頭の要素が、その行が表すデータの種類を表しています。
また、obj ファイルには複数のオブジェクトのデータを記載でき、o 文から次の o 文の直前までが、1つのオブジェクトに関するデータとなります。

以下は、主要データを抜き出した obj ファイルのサンプルです。
# mtl ファイル名
mtllib kuma.mtl

# オブジェクト名
o Sphere.005_Sphere.006

# 頂点座標 (x, y, z)
# LWJGL の glVertex3f() の引数に相当
v 0.720604 1.154850 0.090884
v 0.716250 1.155737 0.105454
v 0.704354 1.156385 0.116119
...

# テクスチャー座標 (x, y)
# LWJGL の glTexCoord2f() の引数に相当
vt 0.137180 0.625775
vt 0.133314 0.591103
vt 0.154389 0.604571
...

# 法線 (x, y, z)
# LWJGL の glNormal3f() の引数に相当
vn 0.956884 0.071054 0.281647
vn 0.667766 0.082637 0.739770
vn 0.233977 0.072171 0.969560
...

# マテリアル名。これ移行の面にはこのマテリアルが適用される。
usemtl Material.002_kuma_parts.png

# 面の頂点データ。
# 各頂点は、頂点座標のインデックス/法線のインデックス/テクスチャー座標のインデックス
f 1/1/1 13/2/1 14/3/1
f 1/1/1 14/3/1 2/4/1
f 2/4/2 14/3/2 15/5/2
...

# 以下、同じようにすべてのオブジェクトが記載されていく
o Sphere.003
v 0.959391 0.363066 -0.120141
v 0.916678 0.363066 -0.304399
v 0.857310 0.036067 -0.284332
...
面を表す f 文に、頂点座標ではなく頂点座標のインデックスが記載されているのは、頂点を共有する複数の面がある場合、それぞれが同じ頂点を共有していることを示します。LWJGL 側で特定の頂点を移動したい場合、その頂点を共有するすべての面について座標を変更しなければなりません。頂点座標を一元管理しておけば、この作業は簡単になります。

なお、インデックスは Java の添字と違い、1 からの連番になっているので注意してください。

mtl ファイル

mtl ファイルのフォーマットも obj ファイルと同じようになっています。
mtl ファイルにも複数のマテリアルのデータを記載でき、newmtl 文から次の newmtl 文の直前までが、1つのマテリアルに関するデータとなります。

以下は、主要データを抜き出した mtl ファイルのサンプルです。
# マテリアル名。
# obj ファイルの usemtl でマテリアルを指定する際に使われる。
newmtl Material.001_kuma_body.png

Ns 96.078431

# マテリアルの環境光 (r, g, b)
# LWJGL の glLight() で指定する色(第三引数)に相当
Ka 0.000000 0.000000 0.000000

# マテリアルの拡散光 (r, g, b)
# LWJGL の glLight() で指定する色(第三引数)に相当
Kd 0.640000 0.640000 0.640000

# マテリアルの鏡面光 (r, g, b)
# LWJGL の glLight() で指定する色(第三引数)に相当
Ks 0.500000 0.500000 0.500000

Ni 1.000000
d 1.000000
illum 2

# テクスチャーのファイル名。絶対パスか、obj ファイルのディレクトリからの相対パスで指定されている。
map_Kd kuma_body.png


# 以下、同じようにすべてのマテリアルが記載されていく
newmtl Material.002_kuma_parts.png
...
mtl ファイルも obj ファイルと同じく、記載されているデータは OpenGL で使うもの(関数の引数)そのままといった感じです。

OBJ ファイルの読み込み

ここからは今回のサンプルプログラムの解説です。
ObjectLoader の load() メソッドで obj ファイルを読み込み、Model インスタンスとして読み込んだデータを返します。
読み込み処理自体は、テキストファイルとして1行ずつ読み込み、前述のファイルフォーマットに応じてパースしていきます。
/**
 *	渡されたファイルパスの OBJ ファイルを読み込み、Model インスタンスとして返す
 */
public static Model load(String filePath) throws IOException {
	Model			model = new Model(filePath);
	File			file = new File(filePath);
	BufferedReader	br = null;
	String			line;
	
	//	テキストファイルなので 1 行ずつ読み込んでパースしていく
	try {
		br = new BufferedReader(new FileReader(file));
		
		while ((line = br.readLine()) != null) {
			String[]	tokens = line.split(" ");
			
			if ((tokens.length == 4) && (tokens[0].equals("v"))) {
				//	頂点座標としてパースする
				...
			} else if ((tokens.length == 3) && (tokens[0].equals("vt"))) {
				//	テクスチャー座標としてパースする
				...
			} else if ((tokens.length == 4) && (tokens[0].equals("vn"))) {
				//	法線としてパースする
				...
			} else if ((tokens.length == 4) && (tokens[0].equals("f"))) {
				//	面(頂点の集合)としてパースする
				...
			} else if ((tokens.length == 2) && (tokens[0].equals("mtllib"))) {
				//	mtl ファイルからマテリアルを読み込む
				...
			} else if ((tokens.length == 2) && (tokens[0].equals("usemtl"))) {
				//	面に適用するマテリアルの指定を読み込む
				...
			} else if ((tokens.length == 2) && (tokens[0].equals("o"))) {
				//	オブジェクトとして読み込む
				...
			}
		}
	} finally {
		if (br != null) {
			try {
				br.close();
			} catch (IOException e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
	}
	
	return model;
}
パースしたデータを格納する Model クラスは、obj ファイルとほぼ同じデータ構造にしました。
obj ファイル全体を Model クラスに、obj ファイルの o 行から次の o 行までを個別のオブジェクト(DisplayList クラス)に、o 行間の f 行を各オブジェクトの持つ面(Face クラス)に、という感じです。
v、vt、vn 行も o 行間にありますが、f 行で指定されるインデックスが obj ファイル全体で一意なため、DisplayList クラスではなく Model クラスのメンバーとしています。

mtl ファイルも、記載されている個々のマテリアルを Material クラスに対応させ、Model クラスのメンバーとして持たせています。
なお、マテリアルについては newmtl 文と map_Kd 文のみパースするよう実装していて、マテリアルの環境光や、拡散光、鏡面光などについては読み込んでいません。こちらについては必要であれば追加で読み込むよう実装してみてください。

ディスプレイリスト

読み込んだデータから描画処理を行うのは test.blender.Model.render() メソッドですが、ここでは OpenGL のディスプレイリストという機能を利用しています。
ディスプレイリストは、あらかじめ複数の頂点座標や法線の指定などをまとめて保存しておき、描画時には1つの命令文で描画処理を実行するものです。ディスプレイリストでまとめられた命令は最適化されるため、ディスプレイリストを使わない時よりも高速に実行することができます。

ディスプレイリストの保存

ディスプレイリストへの保存は、以下のように行います。
//	ディスプレイリストを確保し、ID を取得する
int	objectDisplayList = glGenLists(1);

//	ディスプレイリストへの保存を開始する
glNewList(objectDisplayList, GL_COMPILE);

//	保存する描画処理を書く
glBegin(GL_TRIANGLES);
glNormal3f(normal1.x, normal1.y, normal1.z);
glTexCoord2f(texCord1.getX(), texCord1.getY() * -1);
glVertex3f(vector1.x, vector1.y, vector1.z);
...
glEnd();

//	ディスプレイリストへの保存を終了する
glEndList();
まずはディスプレイリストの確保を行います。ディスプレイリストは同時に複数個作ることができるので、その識別のための ID を取得しなければなりません。
glGenLists() メソッドを呼び出すと、新しい空のディスプレイリストが作られ、その ID が返されます。
引数には同時に作りたいディスプレイリストの数を指定します。返り値には作られた先頭のディスプレイリストの ID が返ってきます。例えば引数を 3 とし、返り値が 10 だった場合、作られたディスプレイリストの ID は 10, 11, 12 となります。

ディスプレイリストに描画処理を保存するには、glNewList() メソッドを呼び出してから描画処理を行います。この後、glEndList() メソッドを呼び出すまでに行われた描画処理が保存されるのですが、glBindTexture() など一部の処理は保存対象外となります。
glNewList() の第一引数には、保存先のディスプレイリストの ID を渡します。
第二引数には、ディスプレイリストへの保存時に実際に描画処理を行うかを指定します。GL_COMPILE を渡すと保存のみが行われ、GL_COMPILE_AND_EXECUTE を渡すと保存と同時に実際に画面への描画処理も行われます。

ディスプレイリストの実行

保存したディスプレイリストを呼び出して描画処理を行う場合には、glCallList() メソッドを使います。
//	ディスプレイリストを実行する
glCallList(objectDisplayList);
引数には、glGenLists() で取得した ID を渡します。

前にも触れましたが、ディスプレイリスト内にはテクスチャーのバインド命令が保存されません。glTexCoord2f() などでテクスチャーマッピングをしている場合には、ディスプレイリストを実行する前にバインドしておいてください。必然的に、1 つのディスプレイリストでマッピングできるテクスチャーの数は 1 つということになります。

ディスプレイリストの破棄

使い終わったディスプレイリストは、glDeleteLists() メソッドで破棄することができます。
glDeleteLists(objectDisplayList, 1);
第一引数には、glGenLists() で取得した ID を渡します。
第二引数には、破棄するディスプレイリストの個数を指定します。こちらも使い方は glGenLists() と同じで、実際に破棄されるディスプレイリストは、第一引数の ID 0 ID + (第二引数 - 1) となります。

おまけ: 先生!Blender でクマをつるつるにするクマ!

Blender でクマをモデリングしていたら、モデルを滑らかにする機能があったので試してみました。
こちらがビフォー&アフター。
kuma.png
smooth_kuma.png
か、かどが取れてる!

使用した機能はこちら。オブジェクトモードで表示される Object Tools の Shading: Smooth ボタンです。
blender_smooth.png

Smooth 適用前後で何が違うのか調べるために OBJ ファイルに書き出して比較してみたところ、頂点数では違いはまったくなく、法線ベクトルの向き(と、数も少しだけ増加)、そして面の頂点に割り当てられる法線のインデックスが変わっていました。
# Smooth 適用前
f 47//1 1//1 3//1 
f 47//1 3//1 45//1
f 4//2 2//2 48//2
...
# Smooth 適用前
f 47//1 1//2 3//3
f 47//1 3//3 45//4
f 4//5 2//6 48//7
これは同じ面の f 文を比較したものです。Smooth 適用前では 1 つの面上の頂点はすべて法線のインデックスが同じで、同じ向きを向いています。しかし、Smooth 適用後では同じ面でも頂点毎に法線のインデックスが異なり、別の方向を向いていることが分かります。
つまり、Smooth 適用前は面法線、Smooth 適用後は頂点法線が適用されていることになります。
面法線と頂点法線についてはライティングの回で扱いましたが、面法線の計算だけでも外戚だとなんだので面倒くさかった物が、Blender を使えばボタン 1 つでできてしまうのです。しかもあらかじめ計算済みなので計算量的にもある程度減らせています。もうこれは OBJ ファイルから読み込むしかないですね!!

ちなみに、隣にある Flat ボタンを押すと、反対に頂点法線から面法線に切り替えることができます。

おわり

今回の記事では OBJ ファイルの読み込みよりも Blender へ慣れること、3D モデリングの初実践でかなりの時間を費やしてしまいました(決してドラゴンと戦って装備を鍛えていた訳ではありませんよ、念のため)。題材に P4 のクマを選んだのは、今にしてみれば無謀だったかも…と思うところもありますが、なんとか形に…なってないか…。最後に作ってた胸の丸い奴とか耳とかは、もう力尽きそうになって、プリセットの球体を変形させてくっつけました。おかげで小さいパーツなのにその部分のほうがポリゴン数がやたら多いことに(以下略

モデリングソフトとしての Blender はかなり操作に癖があるとのことですが、私はあまり経験がないのでよくわかりませんでした。ショートカットキー前提なのと、画面分割や UV マップの表示がわかりにくいのが気になった所ですね。そのうちにこの辺りも書いてみるかもしれません。
とりあえず…

いっしょにゲーム作ってくれる 3D モデル職人さんを募集中です!!

  LWJGLBlender  コメント (2)  2012/06/03 11:33:06


公開範囲:
2015/03/09 15:28:39   yosimasa4403 (106.159.246.149) 公開範囲: すべて, 承認済み
はじめまして、こんにちは。
今回、lwjglでゲームを作らさせていただきました。
その際にはこちらの記事を大いに参考にさせていただきました。(かなりコピペしました)
本当に本当にありがとうございました。

http://yosimasa4403.b.sourceforge.jp/
2015/03/19 08:01:44   momokan 公開範囲: すべて, 承認済み
ご丁寧にありがとうございます!
そしてゲーム完成おめでとうございます!
ゲームをきちんと 1 本作り上げられるなんて、とてもすばらしいと思います。
今度ゆっくりプレイさせていただきます(笑
プロフィール HN: ももかん
ゲーム作ったり雑談書いたり・・・していた時期が私にもありました。
カレンダー
<<2019, 9>>
1234567
891011121314
15161718192021
22232425262728
293012345