/** * Copyright 2012 Jason Sorensen (sorensenj@smert.net) * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ package net.smert.frameworkgl.opengl.model.obj; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.StringTokenizer; import net.smert.frameworkgl.Files.FileAsset; import net.smert.frameworkgl.Fw; import net.smert.frameworkgl.math.Vector4f; import net.smert.frameworkgl.opengl.GL; import net.smert.frameworkgl.opengl.MaterialLight; import net.smert.frameworkgl.opengl.TextureType; import net.smert.frameworkgl.opengl.constants.Primitives; import net.smert.frameworkgl.opengl.mesh.Mesh; import net.smert.frameworkgl.opengl.mesh.Segment; import net.smert.frameworkgl.opengl.mesh.SegmentMaterial; import net.smert.frameworkgl.opengl.mesh.Tessellator; import net.smert.frameworkgl.opengl.model.ModelReader; import net.smert.frameworkgl.opengl.model.obj.MaterialReader.Color; import net.smert.frameworkgl.opengl.model.obj.MaterialReader.Material; import net.smert.frameworkgl.opengl.renderable.Renderable; import net.smert.frameworkgl.opengl.renderable.RenderableConfiguration; import net.smert.frameworkgl.utils.ListUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * http://www.martinreddy.net/gfx/3d/OBJ.spec * * http://en.wikipedia.org/wiki/Wavefront_.obj_file * * @author Jason Sorensen <sorensenj@smert.net> */ public class ObjReader implements ModelReader { private final static Logger log = LoggerFactory.getLogger(ObjReader.class); private boolean resetOnFinish; private final List<Face> faces; private final List<String> comments; private final List<TexCoord> texCoords; private final List<Vertex> normals; private final List<Vertex> vertices; private final Map<String, String> objectNameToMaterialName; private final MaterialReader materialReader; private String groupName; private String materialLibrary; private String materialName; private String objectName; private String smoothingGroup; public ObjReader(MaterialReader materialReader) { resetOnFinish = true; faces = new ArrayList<>(); comments = new ArrayList<>(); texCoords = new ArrayList<>(); normals = new ArrayList<>(); vertices = new ArrayList<>(); objectNameToMaterialName = new HashMap<>(); this.materialReader = materialReader; reset(); } private void addComment(StringTokenizer tokenizer) { String comment = getRemainingTokens(tokenizer); if (comment.length() <= 0) { return; } comments.add(comment); } private void addFace(StringTokenizer tokenizer) { // Create a new Face Face face = new Face(objectName); while (tokenizer.hasMoreTokens()) { String token = tokenizer.nextToken(); String[] geometricTextureNormal = token.split("/"); // v/vt/vn int totalGeometricTextureNormal = geometricTextureNormal.length; if ((totalGeometricTextureNormal < 1) || (totalGeometricTextureNormal > 3)) { log.warn("Invalid face definition: {}", token); return; } // Add each type if (totalGeometricTextureNormal == 3) { face.addNormalIndex(geometricTextureNormal[2]); } if (totalGeometricTextureNormal >= 2) { face.addTexIndex(geometricTextureNormal[1]); } face.addVertexIndex(geometricTextureNormal[0]); } // Was the face valid? if (!face.isValid()) { log.warn("Invalid face: {}", face); return; } // Add the face faces.add(face); } private void addNormalOrVertex(StringTokenizer tokenizer, List<Vertex> normalsOrVertices) { int index = 0; Vertex normalOrVertex = new Vertex(); while (tokenizer.hasMoreTokens()) { String token = tokenizer.nextToken(); normalOrVertex.set(index++, Float.parseFloat(token)); } assert (index == 3); normalsOrVertices.add(normalOrVertex); } private void addNormal(StringTokenizer tokenizer) { addNormalOrVertex(tokenizer, normals); } private void addTexCoord(StringTokenizer tokenizer) { int index = 0; TexCoord texCoord = new TexCoord(); while (tokenizer.hasMoreTokens()) { String token = tokenizer.nextToken(); texCoord.set(index++, Float.parseFloat(token)); } assert ((index == 2) || (index == 3)); texCoords.add(texCoord); } private void addVertex(StringTokenizer tokenizer) { addNormalOrVertex(tokenizer, vertices); } private SegmentMaterial convertMaterialToSegmentMaterial(Material material, Map<Material, SegmentMaterial> materialToSegmentMaterial) { // Get segment material from map if it exists SegmentMaterial segmentMaterial = materialToSegmentMaterial.get(material); if (segmentMaterial != null) { return segmentMaterial; } // Create a new segment material and material light segmentMaterial = GL.meshFactory.createSegmentMaterial(); MaterialLight materialLight = GL.glFactory.createMaterialLight(); // Lighting Color ambient = material.getAmbient(); if (ambient.hasBeenSet()) { materialLight.setAmbient(new Vector4f(ambient.getR(), ambient.getG(), ambient.getB(), 1f)); } Color diffuse = material.getDiffuse(); if (diffuse.hasBeenSet()) { materialLight.setDiffuse(new Vector4f(diffuse.getR(), diffuse.getG(), diffuse.getB(), 1f)); } Color specular = material.getSpecular(); if (specular.hasBeenSet()) { materialLight.setSpecular(new Vector4f(specular.getR(), specular.getG(), specular.getB(), 1f)); } int specularExponent = material.convertSpecularExponent(); materialLight.setShininess(specularExponent); // Save the material light name and add the material light to the pool String materialLightName = "materialLight_" + materialLight.hashCode(); segmentMaterial.setMaterialLightName(materialLightName); if (Renderable.materialLightPool.getUniqueID(materialLightName) == -1) { Renderable.materialLightPool.add(materialLightName, materialLight); } else { log.warn("Tried to add a duplicate material light to the pool: {}", materialLightName); } // Textures String filename; filename = material.getAmbientMapFilename(); if ((filename != null) && (filename.length() > 0)) { segmentMaterial.setTexture(TextureType.AMBIENT_OCCLUSION, filename); } filename = material.getDiffuseMapFilename(); if ((filename != null) && (filename.length() > 0)) { segmentMaterial.setTexture(TextureType.DIFFUSE, filename); } filename = material.getSpecularMapFilename(); if ((filename != null) && (filename.length() > 0)) { segmentMaterial.setTexture(TextureType.SPECULAR, filename); } filename = material.getSpecularExponentMapFilename(); if ((filename != null) && (filename.length() > 0)) { segmentMaterial.setTexture(TextureType.SPECULAR_EXPONENT, filename); } // Save the created segment material to prevent duplication SegmentMaterial previousEntry = materialToSegmentMaterial.put(material, segmentMaterial); assert (previousEntry == null); return segmentMaterial; } private void convertToMesh(Mesh mesh) { mesh.reset(); // Create a renderable configuration for the mesh. Set all parameters to match OBJ capabilities. RenderableConfiguration config = GL.meshFactory.createRenderableConfiguration(); config.setColorSize(4); config.setColorTypeFloat(); config.setIndexTypeUnsignedInt(); config.setTexCoordSize(2); // Could change in 2 spots below config.setVertexSize(3); // Reset texture coordinate state MeshConversion.reset(); // Create a conversion state for each expected type Tessellator.ConversionState conversionStateQuads = Tessellator.CreateConversionState(Primitives.QUADS); Tessellator.ConversionState conversionStateTriangleFan = Tessellator.CreateConversionState(Primitives.TRIANGLE_FAN); Tessellator.ConversionState conversionStateTriangles = Tessellator.CreateConversionState(Primitives.TRIANGLES); // Map one object name to a tessellator Map<String, Tessellator> objectNameToTessellator = new HashMap<>(); // Add each face to the correct tessellator for (Face face : faces) { // Not sure how this would be even possible if (!face.hasVertices()) { log.error("The face had no vertices: {}", face); continue; } // Get the tessellator for the object name String faceObjectName = face.getObjectName(); Tessellator tessellator = objectNameToTessellator.get(faceObjectName); // Create tessellator for the object name if it doesn't exist if (tessellator == null) { tessellator = GL.meshFactory.createTessellator(); tessellator.setConfig(config); tessellator.setConvertToTriangles(true); // Don't rely on defaults tessellator.start(Primitives.TRIANGLES, true); // Force conversion objectNameToTessellator.put(faceObjectName, tessellator); } // Face definitions for the same material can be made up of triangles, quads // and triangle fans so we must convert back into triangles. if (face.isTriangle() || face.isQuad()) { // Determine what type of conversion state is needed Tessellator.ConversionState conversionState; if (face.isTriangle()) { conversionState = conversionStateTriangles; } else { conversionState = conversionStateQuads; } // Reset conversion state conversionState.reset(); if (face.hasNormals()) { Vertex normal1 = normals.get(face.getNormalIndex().get(0)); Vertex normal2 = normals.get(face.getNormalIndex().get(1)); Vertex normal3 = normals.get(face.getNormalIndex().get(2)); MeshConversion.addNormal(conversionState, normal1); MeshConversion.addNormal(conversionState, normal2); MeshConversion.addNormal(conversionState, normal3); if (face.isQuad()) { Vertex normal4 = normals.get(face.getNormalIndex().get(3)); MeshConversion.addNormal(conversionState, normal4); } } if (face.hasTexCoords()) { TexCoord texCoord1 = texCoords.get(face.getTexIndex().get(0)); TexCoord texCoord2 = texCoords.get(face.getTexIndex().get(1)); TexCoord texCoord3 = texCoords.get(face.getTexIndex().get(2)); MeshConversion.checkTexCoord(texCoord1, config); MeshConversion.addTexCoord(conversionState, texCoord1); MeshConversion.addTexCoord(conversionState, texCoord2); MeshConversion.addTexCoord(conversionState, texCoord3); if (face.isQuad()) { TexCoord texCoord4 = texCoords.get(face.getTexIndex().get(3)); MeshConversion.addTexCoord(conversionState, texCoord4); } } Vertex vertex1 = vertices.get(face.getVertexIndex().get(0)); Vertex vertex2 = vertices.get(face.getVertexIndex().get(1)); Vertex vertex3 = vertices.get(face.getVertexIndex().get(2)); MeshConversion.addVertex(conversionState, vertex1); MeshConversion.addVertex(conversionState, vertex2); MeshConversion.addVertex(conversionState, vertex3); if (face.isQuad()) { Vertex vertex4 = vertices.get(face.getVertexIndex().get(3)); MeshConversion.addVertex(conversionState, vertex4); } // Add the triangle or quad to the tessellator. If it's a quad it will be converted. if (face.isTriangle()) { conversionState.addTriangle(tessellator); } else { conversionState.addQuad(tessellator); } } else if (face.isTriangleFan()) { // Reset conversion state conversionStateTriangleFan.reset(); for (int i = 0; i < face.getVertexIndex().size(); i++) { if (face.hasNormals()) { Vertex normal = normals.get(face.getNormalIndex().get(i)); MeshConversion.addNormal(conversionStateTriangleFan, normal); } if (face.hasTexCoords()) { TexCoord texCoord = texCoords.get(face.getTexIndex().get(i)); MeshConversion.checkTexCoord(texCoord, config); MeshConversion.addTexCoord(conversionStateTriangleFan, texCoord); } Vertex vertex = vertices.get(face.getVertexIndex().get(i)); MeshConversion.addVertex(conversionStateTriangleFan, vertex); conversionStateTriangleFan.convert(tessellator); } } } // Map materials to their names for easy lookup List<Material> materials = materialReader.getMaterials(); Map<String, Material> materialNameToMaterial = new HashMap<>(); for (Material material : materials) { materialNameToMaterial.put(material.getMaterialName(), material); } // Map one material to a segment material Map<Material, SegmentMaterial> materialToSegmentMaterial = new HashMap<>(); // Check to see if a renderable configuration exists before adding it int renderableConfigID = Renderable.configPool.getOrAdd(config); mesh.setRenderableConfigID(renderableConfigID); // Add all the model data to the mesh Iterator<Map.Entry<String, Tessellator>> iterator = objectNameToTessellator.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<String, Tessellator> entry = iterator.next(); String objectName = entry.getKey(); Tessellator tessellator = entry.getValue(); assert (tessellator.getElementCount() > 0); // Stop the primitive mode of the tessellator tessellator.stop(); // Calculate normals if there were none if (tessellator.getNormalsCount() == 0) { tessellator.calculateNormals(); } // Convert model data into a mesh segment Segment segment = tessellator.createSegment(objectName); // Convert the material if it exists String materialName = objectNameToMaterialName.get(objectName); Material material = materialNameToMaterial.get(materialName); if (material != null) { SegmentMaterial segmentMaterial = convertMaterialToSegmentMaterial(material, materialToSegmentMaterial); segment.setMaterial(segmentMaterial); } // Add the segment to the mesh mesh.addSegment(segment); } // Generate indexes for the model int[] index = new int[]{0}; for (int i = 0; i < mesh.getTotalSegments(); i++) { Segment segment = mesh.getSegment(i); segment.setMinIndex(index[0]); index[0] += segment.getElementCount(); segment.setMaxIndex(index[0] - 1); } List<Integer> indexes = new ArrayList<>(); for (int i = 0; i < index[0]; i++) { indexes.add(i); } mesh.setIndexes(ListUtils.ToPrimitiveIntArray(indexes)); } private String getNextTokenOnly(StringTokenizer tokenizer) { if (!tokenizer.hasMoreTokens()) { return ""; } return tokenizer.nextToken(); } private String getRemainingTokens(StringTokenizer tokenizer) { StringBuilder remainingTokens = new StringBuilder(64); while (tokenizer.hasMoreTokens()) { String token = tokenizer.nextToken(); remainingTokens.append(token).append(" "); } if (remainingTokens.length() > 0) { int count = Character.charCount(remainingTokens.codePointAt(remainingTokens.length() - 1)); while (count > 0) { remainingTokens.deleteCharAt(remainingTokens.length() - 1); count--; } } return remainingTokens.toString(); } private void parse(String line) { StringTokenizer tokenizer = new StringTokenizer(line); if (!tokenizer.hasMoreTokens()) { return; } int totalTokens = tokenizer.countTokens(); String token = tokenizer.nextToken(); switch (token) { case "#": // Ex: "# Some random comment" addComment(tokenizer); break; case "f": // Ex: "f v1 v2 v3 ... vN" // Ex: "f v1/vt1 v2/vt2 v3/vt3 ... vN/vtN" // Ex: "f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 ... vN/vtN/vnN" // Ex: "f v1//vn1 v2//vn2 v3//vn3 ... vN//vnN" // Ex: "f v1// v2// v3// ... vN//" if (totalTokens >= 4) { addFace(tokenizer); } else { log.warn("Invalid face definition: {}", line); } break; case "g": // Blender replaces spaces with underscores. Seems to group faces. // Ex: "g some_group_name" // Spec says multiple groups can be applied at the same time. Only supporting one. // Not using groups in our implementation groupName = getNextTokenOnly(tokenizer); break; case "mtllib": // Format supports multiple but we only use the first one. The name could have spaces in it so // hopefully there is only one material. // Ex: "mtllib some material filename with spaces.mtl" materialLibrary = getRemainingTokens(tokenizer); break; case "o": // Blender replaces spaces with underscores. Seems to group vertices. // Ex: "o some_object_name" objectName = getNextTokenOnly(tokenizer); break; case "s": // Not using smoothing groups in our implementation smoothingGroup = getNextTokenOnly(tokenizer); break; case "usemtl": // Blender replaces spaces with underscores. Material applies to face definitions. // Ex: "usemtl Material_Name" materialName = getNextTokenOnly(tokenizer); String previousEntry = objectNameToMaterialName.put(objectName, materialName); if (previousEntry != null) { log.warn("The material name '{}' for the object name '{}' was overwritten.", materialName, objectName); } break; case "v": // Ex: "v vX vY vZ" if (totalTokens == 4) { addVertex(tokenizer); } else { log.warn("Invalid vertex definition: {}", line); } break; case "vn": // Ex: "vn nX nY nZ" if (totalTokens == 4) { addNormal(tokenizer); } else { log.warn("Invalid vertex definition: {}", line); } break; case "vt": // Spec says we support one texture coordinate but ignoring // Ex: "vt tU tV" // Ex: "vt tU tV tW" if ((totalTokens == 3) || (totalTokens == 4)) { addTexCoord(tokenizer); } else { log.warn("Invalid texture definition: {}", line); } break; default: log.warn("Skipped line with an unsupported token: {}", line); } } private void read(String filename) throws IOException { FileAsset fileAsset = Fw.files.getMesh(filename); try (InputStream is = fileAsset.openStream(); InputStreamReader isr = new InputStreamReader(is); BufferedReader reader = new BufferedReader(isr)) { String line; while ((line = reader.readLine()) != null) { parse(line); } } } private void readMaterial(String objFilename, Mesh mesh) throws IOException { if (materialLibrary.length() <= 0) { return; } // Take objFilename and strip the filename portion from it String materialFilename; String separator = Fw.files.INTERNAL_FILE_SEPARATOR; int lastSlash = objFilename.lastIndexOf(separator); if (lastSlash != -1) { String directory = objFilename.substring(0, lastSlash); materialFilename = directory + separator + materialLibrary; } else { materialFilename = materialLibrary; } materialReader.reset(); materialReader.load(materialFilename, mesh); } private void reset() { faces.clear(); comments.clear(); texCoords.clear(); normals.clear(); vertices.clear(); objectNameToMaterialName.clear(); groupName = ""; materialLibrary = ""; materialName = ""; objectName = ""; smoothingGroup = ""; } public boolean isResetOnFinish() { return resetOnFinish; } public void setResetOnFinish(boolean resetOnFinish) { this.resetOnFinish = resetOnFinish; } @Override public void load(String filename, Mesh mesh) throws IOException { log.info("Loading OBJ model: {}", filename); reset(); read(filename); readMaterial(filename, mesh); convertToMesh(mesh); if (resetOnFinish) { reset(); materialReader.reset(); } } private static class Face { private final List<Integer> normalIndex; private final List<Integer> texIndex; private final List<Integer> vertexIndex; private final String objectName; private Face(String objectName) { normalIndex = new ArrayList<>(); texIndex = new ArrayList<>(); vertexIndex = new ArrayList<>(); this.objectName = objectName; assert (objectName != null); } private void addIndex(List<Integer> indexes, String index) { if (index.length() <= 0) { return; } int idx = indexToArray(index); indexes.add(idx); } private int indexToArray(String index) { return Integer.parseInt(index) - 1; } public void addNormalIndex(String index) { addIndex(normalIndex, index); } public void addTexIndex(String index) { addIndex(texIndex, index); } public void addVertexIndex(String index) { addIndex(vertexIndex, index); } public boolean hasNormals() { return (normalIndex.size() > 0); } public boolean hasTexCoords() { return (texIndex.size() > 0); } public boolean hasVertices() { return (vertexIndex.size() > 0); } public List<Integer> getNormalIndex() { return normalIndex; } public List<Integer> getTexIndex() { return texIndex; } public List<Integer> getVertexIndex() { return vertexIndex; } public String getObjectName() { return objectName; } public boolean isQuad() { return (vertexIndex.size() == 4); } public boolean isTriangle() { return (vertexIndex.size() == 3); } public boolean isTriangleFan() { return (vertexIndex.size() >= 5); } public boolean isValid() { return (normalIndex.size() == normalIndex.size() == vertexIndex.size() >= 3); } @Override public String toString() { return "(normal indexes: " + normalIndex.size() + " texture indexes: " + texIndex.size() + " vertex indexes: " + vertexIndex.size() + ")"; } } private static class MeshConversion { private static boolean hasThree; private static boolean isLocked; public static void addNormal(Tessellator.ConversionState conversionState, Vertex vertex) { conversionState.getNormal().set(vertex.getX(), vertex.getY(), vertex.getZ()); conversionState.addNormalConversion(conversionState.getNormal()); } public static void addTexCoord(Tessellator.ConversionState conversionState, TexCoord texCoord) { conversionState.getTexCoord().set(texCoord.getS(), texCoord.getT(), texCoord.getR()); conversionState.addTexCoordConversion(conversionState.getTexCoord()); } public static void addVertex(Tessellator.ConversionState conversionState, Vertex vertex) { conversionState.getVertex().set(vertex.getX(), vertex.getY(), vertex.getZ(), 1f); conversionState.addVertexConversion(conversionState.getVertex()); } public static void checkTexCoord(TexCoord texCoord, RenderableConfiguration config) { if (texCoord.hasThree()) { if (!isLocked || (isLocked && hasThree)) { config.setTexCoordSize(3); hasThree = true; isLocked = true; } else { throw new RuntimeException("You cannot switch from 2 texture coords to 3"); } } else { if (!isLocked || (isLocked && !hasThree)) { hasThree = false; isLocked = true; } else { throw new RuntimeException("You cannot switch from 3 texture coords to 2"); } } } public static void reset() { hasThree = false; isLocked = false; } } private static class TexCoord { private float s; private float t; private float r; private TexCoord() { s = -Float.MIN_VALUE; t = -Float.MIN_VALUE; r = -Float.MIN_VALUE; } public float getS() { return s; } public float getT() { return t; } public float getR() { return r; } public boolean hasTwo() { return (s != -Float.MIN_VALUE) && (t != -Float.MIN_VALUE) && (r == -Float.MIN_VALUE); } public boolean hasThree() { return (s != -Float.MIN_VALUE) && (t != -Float.MIN_VALUE) && (r != -Float.MIN_VALUE); } public void set(int index, float value) { if (index == 0) { s = value; } else if (index == 1) { t = value; } else if (index == 2) { r = value; } else { throw new IllegalArgumentException("Unknown index: " + index); } } @Override public String toString() { return "(s: " + s + " t: " + t + " r: " + r + ")"; } } private static class Vertex { private float x; private float y; private float z; private Vertex() { x = 0f; y = 0f; z = 0f; } public float getX() { return x; } public float getY() { return y; } public float getZ() { return z; } public void set(int index, float value) { if (index == 0) { x = value; } else if (index == 1) { y = value; } else if (index == 2) { z = value; } else { throw new IllegalArgumentException("Unknown index: " + index); } } @Override public String toString() { return "(x: " + x + " y: " + y + " z: " + z + ")"; } } }