Everyone would like to have "the .png of 3D file formats," but such a thing doesn't exist. 3D data is too complex and every rendering engine needs a different set of information. Formats that try to support everything take a lot of work to parse. Even the code to integrate a library in your engine can become quite large for generic formats.
Say you use an OBJ loading library for your project, and later you decide you want to support skeleton animations. OBJ doesn't support skeletons, so now you have to find a new library to load a new format and redo your integration code from scratch.
If you're in control of all models you will need to load (most games, for example), making your own format and exporter that does only what you need cuts down on complexity and work. You can have Blender output a simple, predictable binary format that's easy for the engine to parse. If you need more data later, you can extend your existing exporter.
Blender makes it easy enough to get the data you're interested in, though where to find it in Blender's Python API can be hard to figure out. I was able to write my own exporter thanks to IQM having the most readable blender export scripts I've seen by far. I recommend referencing those in addition to this guide.
This repo includes a complete export script that produces files for meshes and skeleton animations. The file format has no name, and looks like this:
Vertex {
f32 positions[3]
f32 UVs[2]
f32 normals[3]
u8 bone_indeces[4] (if hasJointBindings)
f32 bone_weights[4] (if hasJointBindings)
}
file: Mesh {
bool8 hasJointBindings
u32 faceCount
u32 vertexCount
u16 faces[3 * faceCount]
Vertex vertices[vertexCount]
}
SkeletonJoint {
u32 parent index
f32 model_space_inverse_bind_pose_matrix[16]
}
AnimationJoint {
f32 position[3]
f32 rotationQuaternion[4]
f32 scale[3]
}
AnimationFrame {
AnimationJoint joints[skeletonJointCount]
}
Animation {
uint32 frameCount
uint32 nameLength
char8 name[nameLength]
Frame frames[frameCount]
}
file: Skeleton {
u32 skeletonJointCount
SkeletonJoint joints[skeletonJointCount]
u32 animation_count
Animation animations[animationCount]
}
The example code in this text omits some lines from the complete export script in order to focus on one topic at a time. When comparing your implementation to this one, reference the complete script.
You could make a proper export dialog, but making it a panel allows for one-click export and is very nice for rapid iterating, so that's what this guide will show you how to do.
Metadata about the script and where its UI is located is defined by a magic bl_info
dictionary
bl_info = {
"name": "Game Asset Exporter",
"author": "Your Name",
"version": (2022, 7, 18),
"blender": (3, 2, 1),
"location": "Properties > Object > Export",
"description": "One-click export game asset files.",
"category": "Export"}
A Property Group stores configuration for the exporter. A button to do an action is connected to an Operator class. A Panel class's draw method defines the order in which configuration UI and operator buttons are displayed.
This exporter's properties are the mesh file path, skeleton and animations file path, and drop-down boxes to redefine Forward and Up axes.
axesEnum = [("X","X","",1),("-X","-X","",2),("Y","Y","",3),("-Y","-Y","",4),("Z","Z","",5),("-Z","-Z","",6)]
class ExportProperties(bpy.types.PropertyGroup):
meshPath: bpy.props.StringProperty(name="Mesh Path", subtype='FILE_PATH')
skeletonPath: bpy.props.StringProperty(name="Skeleton+animations Path", subtype='FILE_PATH')
forwardAxis: bpy.props.EnumProperty(name="Forward", items=axesEnum, default="-Y")
upAxis: bpy.props.EnumProperty(name="Up", items=axesEnum, default="Z")
class ExportMeshOperator(bpy.types.Operator):
bl_idname = "object.export_mesh"
bl_label = "Export Mesh"
def execute(self, context):
# Do stuff...
return {'FINISHED'}
class ExportSkeletonOperator(bpy.types.Operator):
bl_idname = "object.export_skeleton"
bl_label = "Export Skeleton and Animations"
def execute(self, context):
# Do stuff...
return {'FINISHED'}
class ExportPanel(bpy.types.Panel):
bl_label = "Export"
bl_idname = "OBJECT_PT_layout"
bl_space_type = 'PROPERTIES'
bl_region_type = 'WINDOW'
bl_context = "object"
def draw(self, context):
self.layout.prop(context.scene.exportProperties, "meshPath")
self.layout.prop(context.scene.exportProperties, "skeletonPath")
self.layout.prop(context.scene.exportProperties, "forwardAxis")
self.layout.prop(context.scene.exportProperties, "upAxis")
self.layout.operator('object.export_mesh')
self.layout.operator('object.export_skeleton')
Finally, you need to register these classes with Blender, and tell it how to clean up if the user disables this export plugin.
def register():
bpy.utils.register_class(ExportProperties)
bpy.utils.register_class(ExportMeshOperator)
bpy.utils.register_class(ExportSkeletonOperator)
bpy.utils.register_class(ExportPanel)
bpy.types.Scene.exportProperties = bpy.props.PointerProperty(type=ExportProperties)
def unregister():
bpy.utils.unregister_class(ExportProperties)
bpy.utils.unregister_class(ExportMeshOperator)
bpy.utils.unregister_class(ExportSkeletonOperator)
bpy.utils.unregister_class(ExportPanel)
del bpy.types.Scene.exportProperties
if __name__ == "__main__":
register()
Artists may have a lot of objects on the side they created while making the final mesh, or multiple game objects in the file. Many exporters have an option for only exporting the selected meshes.
meshList = []
for object in bpy.context.selected_objects:
if object.type == "MESH":
meshList.append(object)
You can access an object's mesh with object.to_mesh()
, but objects contain data we'll need that the mesh doesn't, so we'll leave it as-is for now.
To get armatures, check if object.type == "ARMATURE"
instead.
Get the evaluated dependency graph
sceneWithAppliedModifiers = bpy.context.evaluated_depsgraph_get()
Use it to make a copy of the object's mesh with modifiers applied
mesh = object.evaluated_get(sceneWithAppliedModifiers).to_mesh(preserve_all_data_layers=True, depsgraph=bpy.context.evaluated_depsgraph_get())
Blender's Forward, Right, and Up axes may not match the game engine's. Exporters allow you to remap these axes by selecting positive or negative X, Y, or Z axes for Forward and Up, then deriving Right from them. Use Blender's built-in axis_conversion function to get the final matrix
return axis_conversion("-Y", "Z", bpy.context.scene.exportProperties.forwardAxis, bpy.context.scene.exportProperties.upAxis).to_4x4()
Apply the transformation to the mesh with matrix multiplication, which is @
in Python
mesh.transform(transformMatrix @ object.matrix_world)
BMesh allows you to do operations on a mesh in Python scripts. You can convert a regular mesh to a BMesh, modify it, then convert back.
bm = bmesh.new()
bm.from_mesh(mesh)
# Do operations to the geometry...
bm.to_mesh(mesh)
Graphics cards don't deal with quads or n-gons, only triangles. Convert all faces with more than 3 sides to triangles with a BMesh
bmesh.ops.triangulate(bm, faces=bm.faces)
The faces of a mesh are stored in the polygon
property. Polygons are made up of vertices, a.k.a polygon corners, a.k.a. loops. Each polygon has a list of loop indices which can be used to look up vertex data. While a polygon has a list of vertex indices, these are not very useful by themselves. Loop indices allow you to associate vertex position, uv, normal, and bone bindings.
for polygon in mesh.polygons:
if len(polygon.loop_indices) == 3:
for loopIndex in polygon.loop_indices:
loop = mesh.loops[loopIndex]
position = mesh.vertices[loop.vertex_index].undeformed_co
uv = mesh.uv_layers.active.data[loop.index].uv
normal = loop.normal
jointIndices = [0,0,0,0]
jointWeights = [0,0,0,0]
if armature:
for jointBindingIndex, group in enumerate(mesh.vertices[loop.vertex_index].groups):
if jointBindingIndex < 4:
groupIndex = group.group
boneName = object.vertex_groups[groupIndex].name
jointIndices[jointBindingIndex] = armature.data.bones.find(boneName)
jointWeights[jointBindingIndex] = group.weight
Multiple triangles may connect at the same vertex position. That doesn't mean the vertex has the same UVs (it may be on a seam) or the same normals (it may be on a sharp edge). You need to gather all the vertex data together to determine if it's a true duplicate and can be removed. Blender has a loop for every polygon vertex, whether or not it's a duplicate.
To avoid duplicate vertices, we can make a dictionary of vertices instead of an array, with the key as the vertex and value as the index. As we add faces, they are re-mapped to this de-duplicated dictionary.
vertex = Vertex(position, uv, normal, jointIndices, normalizeJointWeights(jointWeights))
if vertices.get(vertex) == None:
vertices[vertex] = len(vertices)
faceIndices.append(vertices[vertex])
Convert them to an array with
vertices.keys()
Dictionaries maintain order since Python 3.7.
If the mesh has an armature modifier, the current pose will be applied to vertices. Change the armature to rest pose before extracting vertex data.
originalArmaturePosition = "REST"
if armature:
originalArmaturePosition = armature.data.pose_position
setArmaturePosition(armature, "REST")
After getting all the mesh data, change the armature back to the previous pose. You could just set it to "POSE", but it may have been in "REST" position already.
if armature:
setArmaturePosition(armature, originalArmaturePosition)
An armature is a skeleton containing bones. More specifically, an armature is a set of joints that are linked together. Each joint is a transform, and the joints form a tree. Animations apply additional transforms to joints over time.
My setup is to have one armature per file, and animations are made up of Blender's Actions (in the dopesheet panel, change to Action Editor). We can extract animation data by just playing the animation and getting each bone's final transform at different times.
Games tend to be interested in the inverse model-space pose, since you only need the rest positions of bones when building the skinning matrix. bone.matrix_local
is unaffected by animations.
for bone in armature.data.bones:
parentIndex = armature.data.bones.find(bone.parent.name) if bone.parent else 0
modelSpacePose = transform @ bone.matrix_local
inverseModelSpacePose = modelSpacePose.inverted()
Iterate through the actions one at a time, and each frame in the action one at a time. I haven't seen other exporters encode the interpolation styles; they just export the current state at each frame. You could optimize this by skipping transforms that don't change.
for animation in bpy.data.actions:
armature.animation_data.action = animation
startFrame = int(animation.frame_range.x)
endFrame = int(animation.frame_range.y)
for frame in range(startFrame, endFrame+1):
bpy.context.scene.frame_set(frame)
Save the current frame when the script is ran and restore it when finished.
bone.matrix
contains the bone's pose with animation transforms applied. Transform each bone into a space relative to the parent (with animation transforms applied). Transform the root bone by the axis remapping matrix.
for bone in armature.pose.bones:
parentSpacePose = bone.matrix
if bone.parent:
parentSpacePose = bone.parent.matrix.inverted() @ bone.matrix
else:
parentSpacePose = axisRemapping @ bone.matrix
translation = parentSpacePose.to_translation()
rotation = parentSpacePose.to_quaternion()
# Does not support negative scales
scale = parentSpacePose.to_scale()
The easiest way to explore data Blender makes available is to open a Python Console panel and start with
bpy.context.selected_objects[0]
To see what it contains, write
dir(bpy.context.selected_objects[0])
Pick one that sounds interesting and keep digging.