Eero Harmaala  —  Game Developer

Game Programming  ::  3D Modeling  ::  3D Model Exporters  

Model formats before the use of Python script plugins for Blender

All my first projects used simple 3d model file formats, which were converted from, for example, ASCII formats easily exportable from many 3d modeling tools. I've done such converters for at least ASC, ASE, and OBJ formats, all with C++. Obviously the main problem with those was usually the extra conversion step before the models were usable. Sooner or later, the slowness of the asset pipeline turned my attention to actual file exporters. As Blender was already then my main modeling tool, I had to make an exporter with Python.

Exporters made with Python

Python is an easy language, but I'm not a fan of its syntax (especially the indentation=scope thing), so my approach towards making an exporter with it was to make something quick-and-dirty that would just serve its purpose. The earliest such exporter I found was apparently made for Blender 2.32, and it can be seen below.

#!BPY

"""
Name: 'eentity (.een) ...'
Blender: 232
Group: 'Export'
Tooltip: 'Export [eentity] EEN file'
"""
__author__ = "Eero Harmaala"
__url__ = ["blender", "elysiun"]
__version__ = "0.11"

__bpydoc__ = """\
This script is an exporter to [eentity] EEN file format.

Usage:

Run this script from "File->Export" menu to export the selected mesh.
"""


import Blender
import struct

def write_een(filepath):
    fout = file(filepath, 'wb')
    # objects = Blender.Object.GetSelected()
    object = Blender.Object.GetSelected()[0]
    mesh = object.getData()
    
    meshTriAmount = 0
    for face in mesh.faces:
        if len(face.v) == 3:
            meshTriAmount += 1  # Triangle
        else:
            meshTriAmount += 2  # Quad
    
    # Amount of triangles
    fout.write( struct.pack("i", meshTriAmount) )
    
    # Texture coordinates included?
    if mesh.hasFaceUV():
        isUVs = 1                       # Yes
    else:
        isUVs = 0                       # No
    
    fout.write( struct.pack("i", isUVs) )
    
    # Vertices (In OpenGL coordinates y negative and y/z swapped)
    for face in mesh.faces:
        if len(face.v) == 3:    # Triangle
            v1, v2, v3 = face.v
            fout.write( struct.pack("fff", v1.co.x, v1.co.z, -v1.co.y) )
            fout.write( struct.pack("fff", v2.co.x, v2.co.z, -v2.co.y) )
            fout.write( struct.pack("fff", v3.co.x, v3.co.z, -v3.co.y) )
        else:                       # Quad (will be converted to two triangles)
            v1, v2, v3, v4 = face.v
            fout.write( struct.pack("fff", v1.co.x, v1.co.z, -v1.co.y) )
            fout.write( struct.pack("fff", v2.co.x, v2.co.z, -v2.co.y) )
            fout.write( struct.pack("fff", v3.co.x, v3.co.z, -v3.co.y) )
            
            fout.write( struct.pack("fff", v1.co.x, v1.co.z, -v1.co.y) )
            fout.write( struct.pack("fff", v3.co.x, v3.co.z, -v3.co.y) )
            fout.write( struct.pack("fff", v4.co.x, v4.co.z, -v4.co.y) )
    
    # Normals
    for face in mesh.faces:
        if len(face.v) == 3:    # Triangle
            v1, v2, v3 = face.v
            fout.write( struct.pack("fff", v1.no.x, v1.no.z, -v1.no.y) )
            fout.write( struct.pack("fff", v2.no.x, v2.no.z, -v2.no.y) )
            fout.write( struct.pack("fff", v3.no.x, v3.no.z, -v3.no.y) )
        else:                       # Quad
            v1, v2, v3, v4 = face.v
            fout.write( struct.pack("fff", v1.no.x, v1.no.z, -v1.no.y) )
            fout.write( struct.pack("fff", v2.no.x, v2.no.z, -v2.no.y) )
            fout.write( struct.pack("fff", v3.no.x, v3.no.z, -v3.no.y) )
            
            fout.write( struct.pack("fff", v1.no.x, v1.no.z, -v1.no.y) )
            fout.write( struct.pack("fff", v3.no.x, v3.no.z, -v3.no.y) )
            fout.write( struct.pack("fff", v4.no.x, v4.no.z, -v4.no.y) )
    
    # Texture coordinates included?
    if mesh.hasFaceUV():
        # Texture coordinates
        for face in mesh.faces:
            if len(face.v) == 3:    # Triangle
                uv1, uv2, uv3 = face.uv
                fout.write( struct.pack("ff", uv1[0], uv1[1]) )
                fout.write( struct.pack("ff", uv2[0], uv2[1]) )
                fout.write( struct.pack("ff", uv3[0], uv3[1]) )
            else:                       # Quad
                uv1, uv2, uv3, uv4 = face.uv
                fout.write( struct.pack("ff", uv1[0], uv1[1]) )
                fout.write( struct.pack("ff", uv2[0], uv2[1]) )
                fout.write( struct.pack("ff", uv3[0], uv3[1]) )
                
                fout.write( struct.pack("ff", uv1[0], uv1[1]) )
                fout.write( struct.pack("ff", uv3[0], uv3[1]) )
                fout.write( struct.pack("ff", uv4[0], uv4[1]) )
    
    fout.close()
    return

Blender.Window.FileSelector(write_een, "Export")

Clearly the purpose was to get just some raw binary data tables that could be read without extra processing. Curiously though I also made some extra optimization tools with C++ for these models in the hope that I could restructure the data more optimally (e.g. by the use of interleaved arrays for cache-friendliness). One attempt even used nVidia's NvTriStrip tool library, but the performance gains turned out to be negligible with the type of meshes I used.

With the development of Blender, also its Python API was often revised, and with major version changes I had to renew my exporters. In the same time I refined the script structure, and added some features. The latest version of the exporter (for Blender 2.63+) writes the vertex data to interleaved vertex arrays, and does some vertex stitching/merging based on texture coordinates. It consists of two files, __init__.py and export_erm.py (click the big plus (+) sign to expand code), where __init__.py just presents the user interface:

bl_info = {
    "name": "Erm file exporter",
    "description": "Export .erm files",
    "author": "Eero Harmaala",
    "version": (0, 3),
    "blender": (2, 63, 0),
    "api": 31236,
    "location": "File > Export",
    "warning": "", # used for warning icon and text in addons panel
    "wiki_url": "http://wiki.blender.org/index.php/Extensions:2.5/Py/"
                "Scripts/My_Script",
    "tracker_url": "http://projects.blender.org/tracker/index.php?"
                 "func=detail&aid=<number>",
    "category": "Import-Export"}

if "bpy" in locals():
import imp
if "export_erm" in locals():
imp.reload(export_erm)
else:
import bpy

from bpy.props import StringProperty, BoolProperty


class ErmExporter(bpy.types.Operator):
    '''Save Erm mesh data'''
    bl_idname = "export_mesh.erm"
    bl_label = "Export ERM"
    
    filepath = StringProperty(
        name="File Path",
        description="Filepath used for exporting the ERM file",
        maxlen=1024,
        subtype='FILE_PATH',
        )
    check_existing = BoolProperty(
        name="Check Existing",
        description="Check and warn on overwriting existing files",
        default=True,
        options={'HIDDEN'},
        )
    apply_modifiers = BoolProperty(
        name="Apply Modifiers",
        description="Use transformed mesh data from each object",
        default=True,
        )
    lefty_coords = BoolProperty(
        name="Left-handed coordinate system",
        description="Convert coordinates to left-hand order",
        default=False,
        )
    uvOriginUpLeft = BoolProperty(
        name="Texture origin at upper left corner",
        description="Texture coordinate origin (0,0) at upper left corner instead of lower left corner",
        default=True,
        )
    
    def execute(self, context):
        from . import export_erm
        export_erm.Write(self.filepath, self.apply_modifiers, self.lefty_coords, self.uvOriginUpLeft)
        return {'FINISHED'}
    
    def invoke(self, context, event):
        if not self.filepath:
            self.filepath = bpy.path.ensure_ext(bpy.data.filepath, ".erm")
        wm = context.window_manager
        wm.fileselect_add(self)
        return {'RUNNING_MODAL'}


def menu_export(self, context):
self.layout.operator(ErmExporter.bl_idname, text="Erm Model (.erm)")


def register():
bpy.utils.register_module(__name__)
bpy.types.INFO_MT_file_export.append(menu_export)


def unregister():
bpy.utils.unregister_module(__name__)
bpy.types.INFO_MT_file_export.remove(menu_export)

if __name__ == "__main__":
register()

The actual exporter code is in export_erm.py:

__author__ = ["Eero Harmaala"]
__version__ = '0.3'
__bpydoc__ = """\
This script is an exporter to ERM file format.

Usage:

Run this script from "File->Export" menu to export the selected mesh.
You can select whether modifiers should be applied and if the mesh
should be triangulated.

"""

import bpy
from math import fabs
import struct

# Notes:
#
# As of 19.8.2012, the exported coordinate system is set to match to Blenders coordinate system:
# Right-handed coordinate system exports simply x,y,z: Blender has z up, x to the right and y forward.
# (Thus we get straightforwardly a right-forward-up coordinate system as well.)
# The left-handed coordinate system flips y-coordinate: It exports x,-y,z.
#
# Mesh.faces exists no more, using Mesh.polygons instead
# -> It is still unclear if polygon can contain more than 3 vertices (api docs says 3),
#       so we keep the CountMeshTriangles() function as it was
#

# We use only triangles -> this calculates amount of those on a mesh
#
def CountMeshTriangles(mesh):
    triangleCount = 0
    for polygon in mesh.polygons:
        if len(polygon.vertices) == 3:
            triangleCount += 1      # Triangle
        else:
            triangleCount += 2      # Quad
    return triangleCount


# Write mesh header (10 bytes):
# 4 bytes = (amount of vertices)
# 4 bytes = (amount of indices)
# 1 byte = (do we have uvs) ? 1 : 0
# 1 byte = (do we use 4 bytes / index) ? 1 : 0
#
def WriteMeshHeader(mesh, file, vertexCount, indexCount, haveUVs = False, haveLongIndices = False):
    
    if (haveUVs):
        haveUVsByte = 1
    else:
        haveUVsByte = 0
    
    if (haveLongIndices):
        haveLongIndicesByte = 1
    else:
        haveLongIndicesByte = 0
    
    file.write(struct.pack("IIBB", vertexCount, indexCount, haveUVsByte, haveLongIndicesByte))
    
    return


# Write mesh without texture coordinates (uv's)
#
def WriteMeshWithoutUVs(mesh, matrix, file, lefty_coords):
    
    indexCount = 3 * CountMeshTriangles(mesh)
    vertexCount = len(mesh.vertices)
    
    if (vertexCount < 0xFFFF):
        longIndices = False
        indexFormat = "HHH"
    else:
        longIndices = True
        indexFormat = "III"
    
    WriteMeshHeader(mesh, file, vertexCount, indexCount, False, longIndices)
    
    if lefty_coords:
        for v in mesh.vertices:
            file.write(struct.pack("fff", v.co.x, -v.co.y, v.co.z))
            file.write(struct.pack("fff", v.normal.x, -v.normal.y, v.normal.z))
    else:
        for v in mesh.vertices:
            file.write(struct.pack("fff", v.co.x, v.co.y, v.co.z))
            file.write(struct.pack("fff", v.normal.x, v.normal.y, v.normal.z))
    
    for polygon in mesh.polygons:
        if len(polygon.vertices) == 3:
            v1, v2, v3 = polygon.vertices
            file.write(struct.pack(indexFormat, v1, v2, v3))
        else:
            v1, v2, v3, v4 = polygon.vertices
            file.write(struct.pack(indexFormat, v1, v2, v3))
            file.write(struct.pack(indexFormat, v1, v3, v4))
    
    return


# Write mesh with texture coordinates (uv's)
#
def WriteMeshWithUVs(mesh, matrix, file, lefty_coords, uvOriginUpLeft):
    
    vertexData = []
    
    # the last part (8th item) in elements of vertexData is the array of different UVs the vertex has
    # the (7th) item before that is the final index given for the vertex (we may add new vertices)
    
    # NOTE: In exported coordinates, y-axis is up
    
    if lefty_coords:
        for v in mesh.vertices:
            vertexData.append([v.co.x, -v.co.y, v.co.z, v.normal.x, -v.normal.y, v.normal.z, 0, []])
    else:
        for v in mesh.vertices:
            vertexData.append([v.co.x, v.co.y, v.co.z, v.normal.x, v.normal.y, v.normal.z, 0, []])
    
    
    # if two different uv coordinates for one vertex differ less than same_uv_limit,
    # interpret them as same
    same_uv_limit = 1.0 / 8192.0
    
    polygonUVIndices = []
    
    for i in range(len(mesh.polygons)):
        # for each polygon create an array of uv "indices" (length = len(polygon.vertices))
        # That is, if polygonUVIndices[i] = [n, m, o, p], then for this polygon,
        # the m:th uv of vertex 2 is used
        polygon = mesh.polygons[i]
        polygonUVIndices.append([])
        # take each vertex
        for j in range(len(polygon.vertices)):
            vertexIndex = polygon.vertices[j]
            # the uv coordinates of the polygon as an array of 8 floats [u1, v1, u2, v2, ...]
            polygonUVs = mesh.tessface_uv_textures.active.data[i].uv_raw
            # The uv coordinates that this polygon has for the vertex
            newUV = [polygonUVs[j * 2], polygonUVs[j * 2 + 1]]
            
            # amount of uvs the vertex currently has
            vertexUVCount = len(vertexData[vertexIndex][7])
            
            # We want to find out if we need new uv coordinates for the vertex
            # Initially set index as vertexUVCount -> outside array, new uv
            uvToBeUsed = vertexUVCount;
            
            # check if the new uv given by this polygon is "same" (close enough) as some old one
            # in that case, change uvToBeUsed as an index of some existing uv
            for u in range(vertexUVCount):
                oldUV = vertexData[vertexIndex][7][u]
                uDiff = fabs(newUV[0] - oldUV[0])
                vDiff = fabs(newUV[1] - oldUV[1])
                # are we close enough ? If so, use an old uv coordinate
                if (uDiff < same_uv_limit and vDiff < same_uv_limit):
                    uvToBeUsed = u
                    break
            
            # in case uvToBeUsed was not changed, we need a new uv
            if uvToBeUsed == vertexUVCount:
                vertexData[vertexIndex][7].append(newUV)
            
            # Finally, for each polygon, add info about which uv is used by each vertex
            polygonUVIndices[i].append(uvToBeUsed)
    
    
    # Calculate the total amount of vertices
    # AND give each vertex a new index
    vertexCount = 0
    for v in vertexData:
        v[6] = vertexCount          # new index for this vertex
        # we want a unique index for each vertex-uv pair, so next index = current index + amount of uv's
        vertexCount += len(v[7])    
    
    
    # if there are < 0xFFFF vertices, each index fits in 2 byte integer (unsigned short)
    if (vertexCount < 0xFFFF):
        longIndices = False
        indexFormat = "HHH"
    else:
        longIndices = True          # otherwise use 4 bytes (unsigned int)
        indexFormat = "III"
    
    indexCount = 3 * CountMeshTriangles(mesh)
    
    # Write header
    WriteMeshHeader(mesh, file, vertexCount, indexCount, True, longIndices)
    
    
    # Now, write a new vertex FOR EACH vertex-uv pair
    if (uvOriginUpLeft):
        for v in vertexData:
            for uv in v[7]:
                file.write(struct.pack("ffffff", v[0], v[1], v[2], v[3], v[4], v[5])) # Position & normal
                file.write(struct.pack("ff", uv[0], 1 - uv[1]))       # Texture coordinates
    else:
        for v in vertexData:
            for uv in v[7]:
                file.write(struct.pack("ffffff", v[0], v[1], v[2], v[3], v[4], v[5]))
                file.write(struct.pack("ff", uv[0], uv[1]))
    
    
    # Write polygons:
    # Index of new vertex = old vertex index + n, when we use n:th uv
    for i in range(len(mesh.polygons)):
        polygon = mesh.polygons[i]
        if len(polygon.vertices) == 3:          # Triangle
            v1, v2, v3 = polygon.vertices
            i1 = vertexData[v1][6] + polygonUVIndices[i][0]
            i2 = vertexData[v2][6] + polygonUVIndices[i][1]
            i3 = vertexData[v3][6] + polygonUVIndices[i][2]
            file.write(struct.pack(indexFormat, i1, i2, i3))    # indexFormat = "HHH" or "III"
        else:                                   # Quad
            v1, v2, v3, v4 = polygon.vertices
            i1 = vertexData[v1][6] + polygonUVIndices[i][0]
            i2 = vertexData[v2][6] + polygonUVIndices[i][1]
            i3 = vertexData[v3][6] + polygonUVIndices[i][2]
            i4 = vertexData[v4][6] + polygonUVIndices[i][3]
            file.write(struct.pack(indexFormat, i1, i2, i3))
            file.write(struct.pack(indexFormat, i1, i3, i4))
    
    return


# Mesh writing divided into two possibilities: with uv's or without
#
def WriteMesh(mesh, matrix, file, lefty_coords, uvOriginUpLeft):
    if (len(mesh.tessface_uv_textures) == 0):
        WriteMeshWithoutUVs(mesh, matrix, file, lefty_coords)
    else:
        WriteMeshWithUVs(mesh, matrix, file, lefty_coords, uvOriginUpLeft)
    return


def Write(filepath, applyModifiers = True, lefty_coords = True, uvOriginUpLeft = True):
    
    scene = bpy.context.scene
    
    #TODO!:
    # - Amount of objects!
    # - Apply matrix transformation
    
    file = open(filepath, "wb")
    
    for obj in bpy.context.selected_objects:
        if applyModifiers or obj.type != 'MESH':
            try:
                mesh = obj.to_mesh(scene, True, "PREVIEW")
            except:
                mesh = None
            is_tmp_mesh = True
        else:
            mesh = obj.data
            is_tmp_mesh = False
        
        if mesh is not None:
            matrix = obj.matrix_world.copy()
            
            WriteMesh(mesh, matrix, file, lefty_coords, uvOriginUpLeft)
            
            #No more meshes:
            #file.write(struct.pack("iii", 0, 0, 0))
            
            if is_tmp_mesh:
                bpy.data.meshes.remove(mesh)
    
    file.close()
    
    return

Note that even this exporter has only the capability to export static meshes. For the latest exporter, I haven't done work on exporting armatures and animations mostly because of the frequent API changes I couldn't keep up with. In fact, it turned out to be lot easier to use an other actively-updated exporter for animated models. Although I try to keep my own exporter alive, I have started to use the Inter-Quake Model (IQM) Format on its side, since it has a nice and up-to-date Blender exporter.