Rather than sit around waiting for the kind of support I need, I decided to learn Python and write my own exporter for Blender. The python code and a sample output file are posted below. It sort of follows the same organizational structure as Collada, but it's much more straightforward and less bloat.
The point I want to make here is that Blender is an awesome modelling and editing package. I've recently been using it as my editor of choice for setting up my game scenes and environments. Collada is about the closest anyone can get to an off-the-shelf scene file format without inventing your own, but it's a massive format and as I said, not fully supported in Blender yet. I wanted to share my results with others who may either benefit from the format itself or simply learn from my python code how to write your own exporter for Blender. Blender's Python API documentation isn't bad, but there are some holes in it, especially when it comes to matching armatures, objects, and animations. Looking at my code should cut down your search significantly.
The Python code is posted below. You can download the code here or copy/paste the code below.
# NUT Blender Export Script
#
# Author: Nathaniel Meyer
# Website: http://www.nutty.ca
#
# Notes:
# 1. Built with Blender version 2.58, but works in 2.59 as well.
#
# 2. To install this script, place it in your <BlenderHome>/<BlenderVersion>/scripts/addons/ folder.
# Open the "User Preferences" window, select Add-Ons, and search for the script
# "Import-Export: NUT Scene Format(.nut)".
#
# 3. This script is provided as-is. Do whatever you want with it. Use it, modify it, call it your own,
# sell it, gift wrap it for your mother, whatever. It's 100.0% free, with no added GNU.
bl_info = {
"name": "NUT Scene Format (.nut)",
"author": "Nathaniel Meyer",
"version": (1, 0),
"blender": (2, 5, 8),
"api": 38019,
"location": "File > Export > Nut (.nut)",
"description": "Export NUT Scene Format (.nut)",
"warning": "",
"wiki_url": "http://www.nutty.ca",
"tracker_url": "http://www.nutty.ca",
"category": "Import-Export"}
# Blender and Python Imports
import bpy
from bpy.props import StringProperty, BoolProperty
from bpy_extras.io_utils import ExportHelper
import math
# Convert RAD to degrees
RAD = 180.0 / math.pi
# Summary:
# Export cameras.
#
# @param file
# File stream to output.
def ExportCameras(file):
if bpy.data.cameras and (len(bpy.data.cameras) > 0):
file.write("\t<cameras>\n")
for camera in bpy.data.cameras:
file.write("\t\t<camera name=\"%s\" type=\"%s\">\n" % (camera.name, "perspective" if camera.type == "PERSP" else "orthographic"))
file.write("\t\t\t<resolution x=\"%d\" y=\"%d\" />\n" % (bpy.data.scenes[0].render.resolution_x, bpy.data.scenes[0].render.resolution_y))
file.write("\t\t\t<angle>%s</angle>\n" % (camera.angle * RAD))
file.write("\t\t\t<clip near=\"%f\" far=\"%f\" />\n" % (camera.clip_start, camera.clip_end))
file.write("\t\t\t<lens>%s</lens>\n" % camera.lens)
file.write("\t\t</camera>\n")
file.write("\t</cameras>\n")
# Summary:
# Export lights. Blender calls them "lamps".
#
# @param file
# File stream to output.
def ExportLights(file):
if bpy.data.lamps and (len(bpy.data.lamps) > 0):
file.write("\t<lights>\n")
for lamp in bpy.data.lamps:
file.write("\t\t<light name=\"%s\" type=\"%s\">\n" % (lamp.name, "point" if lamp.type == "POINT" else "ambient" if lamp.type == "SUN" else "directional" if lamp.type == "SPOT" else "area" if lamp.type == "AREA" else ""))
file.write("\t\t\t<colour r=\"%f\" g=\"%f\" b=\"%f\" />\n" % (lamp.color[0], lamp.color[1], lamp.color[2]))
if lamp.falloff_type == "INVERSE_LINEAR":
file.write("\t\t\t<attenuation l=\"%f\" q=\"%f\" />\n" % (1.0 / lamp.distance, 0.0))
elif lamp.falloff_type == "INVERSE_SQUARE":
file.write("\t\t\t<attenuation l=\"%f\" q=\"%f\" />\n" % (0.0, 1.0 / (lamp.distance * lamp.distance)))
elif lamp.falloff_type == "LINEAR_QUADRATIC_WEIGHTE":
file.write("\t\t\t<attenuation l=\"%f\" q=\"%f\" />\n" % (lamp.linear_attenuation, lamp.quadratic_attenuation))
file.write("\t\t</light>\n")
file.write("\t</lights>\n")
# Summary:
# Export images. Images are used by textures.
#
# @param file
# File stream to output.
def ExportImages(file):
if bpy.data.images and (len(bpy.data.images) > 0):
file.write("\t<images>\n")
for image in bpy.data.images:
file.write("\t\t<image name=\"%s\" url=\"%s\" />\n" % (image.name, image.filepath.replace("\\", "/")))
file.write("\t</images>\n")
# Summary:
# Export textures. Only image textures are supported.
#
# @param file
# File stream to output.
def ExportTextures(file):
if bpy.data.textures and (len(bpy.data.textures) > 0):
file.write("\t<textures>\n")
for texture in bpy.data.textures:
file.write("\t\t<texture name=\"%s\" type=\"%s\">\n" % (texture.name, texture.type.lower()))
if (texture.type == "IMAGE") and texture.image:
file.write("\t\t\t<image>%s</image>\n" % texture.image.name)
file.write("\t\t</texture>\n")
file.write("\t</textures>\n")
# Summary:
# Export materials.
#
# @param file
# File stream to output.
def ExportMaterials(file):
if bpy.data.materials and (len(bpy.data.materials) > 0):
file.write("\t<materials>\n")
for material in bpy.data.materials:
file.write("\t\t<material name=\"%s\">\n" % material.name)
file.write("\t\t\t<diffuse r=\"%f\" g=\"%f\" b=\"%f\" />\n" % (material.diffuse_color[0], material.diffuse_color[1], material.diffuse_color[2]))
file.write("\t\t\t<specular r=\"%f\" g=\"%f\" b=\"%f\" />\n" % (material.specular_color[0], material.specular_color[1], material.specular_color[2]))
file.write("\t\t\t<shininess>%f</shininess>\n" % material.specular_hardness)
file.write("\t\t\t<alpha>%f</alpha>\n" % material.alpha)
for texSlot in material.texture_slots:
if texSlot:
file.write("\t\t\t<texture name=\"%s\">\n" % texSlot.texture.name)
file.write("\t\t\t\t<offset x=\"%f\" y=\"%f\" z=\"%f\" />\n" % (texSlot.offset[0], texSlot.offset[1], texSlot.offset[2]))
file.write("\t\t\t\t<scale x=\"%f\" y=\"%f\" z=\"%f\" />\n" % (texSlot.scale[0], texSlot.scale[1], texSlot.scale[2]))
file.write("\t\t\t</texture>\n")
file.write("\t\t</material>\n")
file.write("\t</materials>\n")
# Summary:
# Export geometries. Blender stores UV coordinates per-index, not
# per-vertex. If you have 4 vertices and 6 face indices, there are 6 UV
# coordinates. If you wish to convert from per-index to per-vertex,
# you must reconstruct the index list with unshared vertices. This
# takes away render efficiency.
#
# @param file
# File stream to output.
def ExportMeshes(file):
if bpy.data.meshes and (len(bpy.data.meshes) > 0):
file.write("\t<geometries>\n")
for mesh in bpy.data.meshes:
file.write("\t\t<geometry name=\"%s\">\n" % mesh.name)
# Vertices
maxBonePerVertex = 0
vertCount = len(mesh.vertices)
file.write("\t\t\t<vertices count=\"%d\">" % vertCount)
for i in range(vertCount):
if i > 0:
file.write(" ")
file.write("%f %f %f" % (mesh.vertices[i].co[0], mesh.vertices[i].co[1], mesh.vertices[i].co[2]))
if mesh.vertices[i].groups and (len(mesh.vertices[i].groups) > 0):
groupCount = len(mesh.vertices[i].groups);
if groupCount > maxBonePerVertex:
maxBonePerVertex = groupCount
file.write("</vertices>\n")
# UV Coordinates
for uvTexture in mesh.uv_textures:
file.write("\t\t\t<uv count=\"%d\">" % len(uvTexture.data))
uvDataCount = len(uvTexture.data)
for i in range(uvDataCount):
# UV per face index, not per vertex
if i > 0:
file.write(" ")
uvCount = len(uvTexture.data[i].uv)
for j in range(uvCount):
if j > 0:
file.write(" ")
file.write("%f %f" % (uvTexture.data[i].uv[j][0], uvTexture.data[i].uv[j][1]))
file.write("</uv>\n")
# Normals - Save space and calculate them later
#file.write("\t\t\t<normals count=\"%d\">" % vertCount);
#for i in range(vertCount):
# if i > 0:
# file.write(" ")
# file.write("%f %f %f" % (mesh.vertices[i].normal[0], mesh.vertices[i].normal[1], mesh.vertices[i].normal[2]))
#file.write("</normals>\n")
# Indices. Polygons are separated by commas. It is intuitive to know when you're
# dealing with a triangle or a quad.
faceCount = len(mesh.faces);
file.write("\t\t\t<indices count=\"%d\">" % faceCount);
for face in range(faceCount):
if face > 0:
file.write(",")
count = len(mesh.faces[face].vertices)
for i in range(count):
file.write("%d" % mesh.faces[face].vertices[i])
if i != (count - 1):
file.write(" ")
file.write("</indices>\n")
# Bone groups and weights. Bone groups and weights are separated by commas and represent
# the bone groups and weights for a particular vertex.
# Ex: 0 0.5 3 0.4 2 0.1, ... <- This means the first vertex has 3 bones that effect it.
# They are groups 0, 3, and 2. The weights for each of these bones are listed beside them,
# 0.5, 0.4, and 0.1 respectively. Some vertices may have more or fewer bones than others.
# maxBonePerVertex attribute tells you what the maximum number of bones are for any vertex.
if maxBonePerVertex > 0:
boneOutput = False
file.write("\t\t\t<bones count=\"%d\" maxBonePerVertex=\"%d\">" % (vertCount, maxBonePerVertex))
for i in range(vertCount):
if i > 0:
file.write(",")
if mesh.vertices[i].groups and (len(mesh.vertices[i].groups) > 0):
groupCount = len(mesh.vertices[i].groups)
for j in range(groupCount):
file.write("%d %f" % (mesh.vertices[i].groups[j].group, mesh.vertices[i].groups[j].weight))
if (j + 1) < groupCount:
file.write(" ")
file.write("</bones>\n")
# Find the object with this mesh and extract the vertex groups from it
for object in bpy.data.objects:
if object.data == mesh:
# Vertex Groups
if object.vertex_groups and (len(object.vertex_groups) > 0):
file.write("\t\t\t<groups>\n")
for vertexGroup in object.vertex_groups:
file.write("\t\t\t\t<group name=\"%s\" index=\"%d\" />\n" % (vertexGroup.name, vertexGroup.index))
file.write("\t\t\t</groups>\n")
break;
file.write("\t\t</geometry>\n")
file.write("\t</geometries>\n")
# Summary:
# This method will traverse up the bone's parent until the root bone is found.
#
# @param bone
# Bone to traverse up until the root bone is found.
# @return
# Reference to the root bone.
def GetRootBone(bone):
while bone:
if bone.parent:
bone = bone.parent;
else:
break;
return bone
# Summary:
# Export bones. Called directly by ExportSkeletons.
#
# @param file
# File stream to output.
# @param bone
# Current bone to output.
# @param relative
# Set true to output relative coordiantes, otherwise false for absolute coordinates.
# @param tab
# String to store XML tabbing to keep things ordered.
def ExportBones(file, bone, relative, tab):
if relative and bone.parent:
# Calculate relative position
rx = (bone.head_local[0] - bone.parent.tail_local[0]) + (bone.parent.tail_local[0] - bone.parent.head_local[0]);
ry = (bone.head_local[1] - bone.parent.tail_local[1]) + (bone.parent.tail_local[1] - bone.parent.head_local[1]);
rz = (bone.head_local[2] - bone.parent.tail_local[2]) + (bone.parent.tail_local[2] - bone.parent.head_local[2]);
file.write("%s<bone name=\"%s\" x=\"%f\" y=\"%f\" z=\"%f\"" % (tab, bone.name, rx, ry, rz))
else:
# Write out absolute position (relative to armature)
file.write("%s<bone name=\"%s\" x=\"%f\" y=\"%f\" z=\"%f\"" % (tab, bone.name, bone.head_local[0], bone.head_local[1], bone.head_local[2]))
# Process bone children
if bone.children and (len(bone.children) > 0):
file.write(">\n")
for childBone in bone.children:
ExportBones(file, childBone, relative, tab + "\t");
file.write("%s</bone>\n" % tab)
else:
file.write(" />\n")
# Summary:
# Export skeletons. Blender calls them armatures.
# 1. Blender armatures can have multiple root bones (need to find them).
# 2. bone.head_local is relative to armature.
# 3. bone.head is relative to parent, but doesn't appear correct.
#
# @param file
# File stream to output.
def ExportSkeletons(file):
if bpy.data.armatures and (len(bpy.data.armatures) > 0):
file.write("\t<skeletons>\n")
for armature in bpy.data.armatures:
file.write("\t\t<skeleton name=\"%s\">\n" % armature.name)
# Store a list of processed root bones
rootBoneList = list()
# Find root bones
# Blender's bone list is a flat array, so we need to reconstruct the hierarchy.
if armature.bones and (len(armature.bones) > 0):
for bone in armature.bones:
# Get the root bone using recursion
rootBone = GetRootBone(bone);
# Process only unprocessed root bones, avoid duplicates.
if rootBone not in rootBoneList:
rootBoneList.append(rootBone);
ExportBones(file, rootBone, True, "\t\t\t");
file.write("\t\t</skeleton>\n")
file.write("\t</skeletons>\n")
# Summary:
# Export animations. Blender calls them actions.
Attached is a sample output containing a fully rigged skeleton with a basic walk animation. It's XML based, easy to load, and contains most of what you need to setup a game scene or several scenes.













