|
|
@@ -0,0 +1,320 @@
|
|
|
+# Usage:
|
|
|
+#
|
|
|
+# - You should make a copy of your .blend file eg. 'my-scene-export.blend'!
|
|
|
+#
|
|
|
+# - The armature's pivot must be the same as the meshe's it is attached to.
|
|
|
+#
|
|
|
+# - The animation must have at least 2 keyframes and start at position 1.
|
|
|
+#
|
|
|
+# - UVs are stored per face in Blender. The GPmesh format expects to have _one_ UV per vertex.
|
|
|
+# Blender can easily have multiple UV coordinates assigned to the same vertex-index. Thus,
|
|
|
+# in order to get UVs exported correctly, vertices must be dublicated. This can be done
|
|
|
+# in the following way:
|
|
|
+# 1.) Go into edit mode and select all edges.
|
|
|
+# 2.) From the menu choose Edge -> Mark Sharp.
|
|
|
+# 3.) Switch into object mode and assign the 'Edge Split' modifier. Apply that. Done.
|
|
|
+#
|
|
|
+# - The engine of the book expects all polygons to be a triangle. So if your mesh has
|
|
|
+# n-gons, with n > 3, you have to apply the 'Triangulate' modifier or split your polygons manually.
|
|
|
+
|
|
|
+bl_info = {
|
|
|
+ "name": "gpmesh Exporter",
|
|
|
+ "blender": (3,00,0),
|
|
|
+ "category": "Export",
|
|
|
+ "author": "Michael Eggers",
|
|
|
+ "description": "GPmesh exporter for the book Game Programming in C++"
|
|
|
+}
|
|
|
+
|
|
|
+import bpy
|
|
|
+import json
|
|
|
+
|
|
|
+def generate_gpmesh_json():
|
|
|
+ mesh = bpy.context.active_object.data
|
|
|
+ uv_layer = mesh.uv_layers.active.data
|
|
|
+
|
|
|
+ gpmesh = {
|
|
|
+ "version": 1,
|
|
|
+ "vertexformat": "PosNormSkinTex",
|
|
|
+ "shader": "Skinned",
|
|
|
+ "textures": [],
|
|
|
+ "specularPower": 100.0,
|
|
|
+ "vertices": [],
|
|
|
+ "indices": []
|
|
|
+ }
|
|
|
+
|
|
|
+ for vert in mesh.vertices:
|
|
|
+ pos = vert.co
|
|
|
+ normal = vert.normal
|
|
|
+ gp_vert = []
|
|
|
+ gp_vert.extend([pos.y, pos.x, pos.z])
|
|
|
+ gp_vert.extend([normal.y, normal.x, normal.z])
|
|
|
+
|
|
|
+ # get bone indices and their weights that affect this vertex and sort them by weight from high to low
|
|
|
+ boneToWeightTuples = []
|
|
|
+ for group in vert.groups:
|
|
|
+ u8_weight = int(group.weight * 255)
|
|
|
+ boneToWeightTuples.append((group.group, u8_weight))
|
|
|
+ boneToWeightTuples.sort(key=lambda boneToWeight : boneToWeight[1], reverse=True)
|
|
|
+
|
|
|
+ # Only keep first 4 bones with their weights. As we sorted them by their bone weight (from high to low)
|
|
|
+ # before, we only keep the once with highest influence.
|
|
|
+ boneToWeightTuples = boneToWeightTuples[:4]
|
|
|
+
|
|
|
+ # The file format expects always 4 bones.
|
|
|
+ while len(boneToWeightTuples) < 4:
|
|
|
+ boneToWeightTuples.append((0, 0))
|
|
|
+
|
|
|
+ boneIndices = []
|
|
|
+ weights = []
|
|
|
+ for boneIdx, weight in boneToWeightTuples:
|
|
|
+ boneIndices.append(boneIdx)
|
|
|
+ weights.append(weight)
|
|
|
+
|
|
|
+ gp_vert.extend(boneIndices)
|
|
|
+ gp_vert.extend(weights)
|
|
|
+
|
|
|
+ gpmesh["vertices"].append(gp_vert)
|
|
|
+
|
|
|
+ # UVs are stored separately, because even if multiple vertices share the same pos/normal/..
|
|
|
+ # they can have easily have completely differen UVs!
|
|
|
+ for l in mesh.loops:
|
|
|
+ uv = uv_layer[l.index].uv
|
|
|
+ if len(gpmesh["vertices"][l.vertex_index]) <= 14:
|
|
|
+ gpmesh["vertices"][l.vertex_index].extend([uv.x, -uv.y])
|
|
|
+ # print(vert_idx, loop_idx, uv)
|
|
|
+
|
|
|
+ for poly in mesh.polygons:
|
|
|
+ tri = []
|
|
|
+ for loop_index in poly.loop_indices:
|
|
|
+ vertIndex = mesh.loops[loop_index].vertex_index
|
|
|
+ tri.append(vertIndex)
|
|
|
+
|
|
|
+ gpmesh["indices"].append(tri)
|
|
|
+
|
|
|
+ textures = []
|
|
|
+ materialSlots = bpy.context.active_object.material_slots
|
|
|
+ for matSlot in materialSlots:
|
|
|
+ if matSlot.material:
|
|
|
+ if matSlot.material.node_tree:
|
|
|
+ for node in matSlot.material.node_tree.nodes:
|
|
|
+ if node.type == 'TEX_IMAGE':
|
|
|
+ textures.append("Assets/" + node.image.name)
|
|
|
+
|
|
|
+ gpmesh["textures"] = textures
|
|
|
+
|
|
|
+ return gpmesh
|
|
|
+
|
|
|
+def find_armature(active_object):
|
|
|
+ armature = active_object
|
|
|
+ while armature.parent and armature.type != 'ARMATURE':
|
|
|
+ armature = armature.parent
|
|
|
+
|
|
|
+ if armature.type == 'ARMATURE':
|
|
|
+ return armature
|
|
|
+ return None
|
|
|
+
|
|
|
+def generate_gpskel_json():
|
|
|
+ gpskel = {
|
|
|
+ "version": 1,
|
|
|
+ "bonecount": 0,
|
|
|
+ "bones": []
|
|
|
+ }
|
|
|
+ boneInfos = []
|
|
|
+
|
|
|
+ armature = find_armature(bpy.context.active_object)
|
|
|
+ if armature:
|
|
|
+ armature = armature.data
|
|
|
+ for i, bone in enumerate(armature.bones):
|
|
|
+ parentBone = bone.parent
|
|
|
+ parentIndex = -1
|
|
|
+ if parentBone:
|
|
|
+ parentIndex = armature.bones.find(parentBone.name)
|
|
|
+
|
|
|
+ # local_matrix = (bone.matrix_local if bone.parent is None else bone.parent.matrix_local.inverted() * bone.matrix_local)
|
|
|
+ local_matrix = bone.matrix_local
|
|
|
+ if bone.parent:
|
|
|
+ local_matrix = bone.parent.matrix_local.inverted() @ bone.matrix_local
|
|
|
+ rot = local_matrix.to_quaternion().inverted()
|
|
|
+ trans = local_matrix.to_translation()
|
|
|
+
|
|
|
+ boneInfo = {
|
|
|
+ "name": bone.name,
|
|
|
+ "index": i,
|
|
|
+ "parent": parentIndex,
|
|
|
+ "bindpose": {
|
|
|
+ "rot": [rot.y, rot.x, rot.z, rot.w],
|
|
|
+ "trans": [trans.y, trans.x, trans.z]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ boneInfos.append(boneInfo)
|
|
|
+
|
|
|
+ gpskel["bonecount"] = len(armature.bones)
|
|
|
+
|
|
|
+ gpskel["bones"] = boneInfos
|
|
|
+
|
|
|
+ return gpskel
|
|
|
+
|
|
|
+def local_matrices_for_frame(root, rootMat):
|
|
|
+ local_matrices = []
|
|
|
+ for child in root.children_recursive:
|
|
|
+ if child.parent.name == root.name:
|
|
|
+ localTransform = rootMat.inverted() @ child.matrix_basis
|
|
|
+ local_matrices.append(localTransform)
|
|
|
+ local_matrices.extend(local_matrices_for_frame(child, localTransform))
|
|
|
+
|
|
|
+ return local_matrices
|
|
|
+
|
|
|
+def generate_gpanim_json(action):
|
|
|
+ gpanim = {
|
|
|
+ "version": 1,
|
|
|
+ "sequence": {
|
|
|
+ "frames": 0,
|
|
|
+ "length": 1.0, # TODO: Calculate from framerate
|
|
|
+ "bonecount": 0,
|
|
|
+ "tracks": []
|
|
|
+ }
|
|
|
+ }
|
|
|
+ active_object = bpy.context.active_object
|
|
|
+ # active_object.animation_data.action = action
|
|
|
+ armature = find_armature(active_object)
|
|
|
+ armature.animation_data.action = action
|
|
|
+ frame_start, frame_end = int(action.frame_range.x), int(action.frame_range.y)
|
|
|
+ gpanim["sequence"]["frames"] = frame_end - 1 # TODO: Hacky (engine expects duplicate of first keyframe at the end but should not count as one)
|
|
|
+ gpanim["sequence"]["bonecount"] = len(armature.data.bones)
|
|
|
+
|
|
|
+ for i, bones in enumerate(armature.data.bones):
|
|
|
+ gpanim["sequence"]["tracks"].append({ "bone": i, "transforms": [] })
|
|
|
+
|
|
|
+ for frame in range(frame_start, frame_end):
|
|
|
+ bpy.context.scene.frame_set(frame)
|
|
|
+
|
|
|
+ localMat = armature.pose.bones[i].matrix
|
|
|
+ if armature.pose.bones[i].parent:
|
|
|
+ localMat = armature.pose.bones[i].parent.matrix.inverted() @ armature.pose.bones[i].matrix
|
|
|
+ rot = localMat.to_quaternion().inverted()
|
|
|
+ trans = localMat.to_translation()
|
|
|
+ gpanim["sequence"]["tracks"][i]["transforms"].append({ "rot": [rot.y, rot.x, rot.z, rot.w], "trans": [trans.y, trans.x, trans.z] })
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ # for frame in range(frame_start, frame_end):
|
|
|
+ # bpy.context.scene.frame_set(frame)
|
|
|
+
|
|
|
+ # rootBone = armature.pose.bones["Root"]
|
|
|
+ # rootTransform = rootBone.matrix
|
|
|
+
|
|
|
+ # pose = [ rootTransform ] # a pose contains all the bones transforms for this particular frame
|
|
|
+
|
|
|
+ # # now add children to the pose
|
|
|
+ # pose.extend(local_matrices_for_frame(rootBone, rootBone.matrix))
|
|
|
+
|
|
|
+ # print(len(pose))
|
|
|
+ # print(pose)
|
|
|
+
|
|
|
+ # # add them to the tracks
|
|
|
+ # for i, localMat in enumerate(pose):
|
|
|
+ # rot = localMat.to_quaternion()
|
|
|
+ # trans = localMat.to_translation()
|
|
|
+ # gpanim["sequence"]["tracks"][i]["transforms"].append({ "rot": [rot.y, rot.x, rot.z, rot.w], "trans": [trans.y, trans.x, trans.z] })
|
|
|
+
|
|
|
+
|
|
|
+ return gpanim
|
|
|
+
|
|
|
+def write_to_disk(context, filepath, export_gpmesh, export_gpskel, export_gpanim):
|
|
|
+ print("exporting to gpmesh...")
|
|
|
+
|
|
|
+ if export_gpmesh:
|
|
|
+ gpmesh = generate_gpmesh_json()
|
|
|
+ f = open(filepath, 'w', encoding='utf-8')
|
|
|
+ # f.write("Hello World %s" % use_some_setting)
|
|
|
+ f.write(json.dumps(gpmesh, sort_keys=False, indent=2))
|
|
|
+ f.close()
|
|
|
+
|
|
|
+ if export_gpskel:
|
|
|
+ gpskel = generate_gpskel_json()
|
|
|
+ gpskel_filepath = filepath.split(".")[0] + '.gpskel'
|
|
|
+ f = open(gpskel_filepath, "w", encoding="utf-8")
|
|
|
+ f.write(json.dumps(gpskel, sort_keys=False, indent=2))
|
|
|
+ f.close()
|
|
|
+
|
|
|
+ if export_gpanim:
|
|
|
+ print("EXPORT GPANIM")
|
|
|
+ actions = bpy.data.actions
|
|
|
+ print(actions)
|
|
|
+ for action in actions:
|
|
|
+ gpanim = generate_gpanim_json(action)
|
|
|
+ gpanim_filepath = filepath.split(".")[0] + str(action.name) + '.gpanim'
|
|
|
+ f = open(gpanim_filepath, "w", encoding="utf-8")
|
|
|
+ f.write(json.dumps(gpanim, sort_keys=False, indent=2))
|
|
|
+ f.close()
|
|
|
+
|
|
|
+ print("Done!")
|
|
|
+
|
|
|
+ return {'FINISHED'}
|
|
|
+
|
|
|
+
|
|
|
+# ExportHelper is a helper class, defines filename and
|
|
|
+# invoke() function which calls the file selector.
|
|
|
+from bpy_extras.io_utils import ExportHelper
|
|
|
+from bpy.props import StringProperty, BoolProperty, EnumProperty
|
|
|
+from bpy.types import Operator
|
|
|
+
|
|
|
+
|
|
|
+class ExportGPMESH(Operator, ExportHelper):
|
|
|
+ """Export mesh in gpmesh format."""
|
|
|
+ bl_idname = "export.gpmesh" # important since its how bpy.ops.import_test.some_data is constructed
|
|
|
+ bl_label = "Export as gpmesh"
|
|
|
+
|
|
|
+ # ExportHelper mixin class uses this
|
|
|
+ filename_ext = ".gpmesh"
|
|
|
+
|
|
|
+ filter_glob: StringProperty(
|
|
|
+ default="*.gpmesh",
|
|
|
+ options={'HIDDEN'},
|
|
|
+ maxlen=255, # Max internal buffer length, longer would be clamped.
|
|
|
+ )
|
|
|
+
|
|
|
+ # List of operator properties, the attributes will be assigned
|
|
|
+ # to the class instance from the operator settings before calling.
|
|
|
+ export_gpmesh: BoolProperty(
|
|
|
+ name="Export gpmesh",
|
|
|
+ description="Writes the mesh as .gpmesh to disk",
|
|
|
+ default=True
|
|
|
+ )
|
|
|
+
|
|
|
+ export_gpskel: BoolProperty(
|
|
|
+ name="Export gpskel",
|
|
|
+ description="Writes .gpskel file to disk",
|
|
|
+ default=True
|
|
|
+ )
|
|
|
+
|
|
|
+ export_gpanim: BoolProperty(
|
|
|
+ name="Export gpanim",
|
|
|
+ description="Writes .gpanim file to disk",
|
|
|
+ default=True
|
|
|
+ )
|
|
|
+
|
|
|
+ def execute(self, context):
|
|
|
+ return write_to_disk(context, self.filepath, self.export_gpmesh, self.export_gpskel, self.export_gpanim)
|
|
|
+
|
|
|
+
|
|
|
+# Only needed if you want to add into a dynamic menu
|
|
|
+def menu_func_export(self, context):
|
|
|
+ self.layout.operator(ExportGPMESH.bl_idname, text="gpmesh")
|
|
|
+
|
|
|
+# Register and add to the "file selector" menu (required to use F3 search "Text Export Operator" for quick access)
|
|
|
+def register():
|
|
|
+ bpy.utils.register_class(ExportGPMESH)
|
|
|
+ bpy.types.TOPBAR_MT_file_export.append(menu_func_export)
|
|
|
+
|
|
|
+
|
|
|
+def unregister():
|
|
|
+ bpy.utils.unregister_class(ExportGPMESH)
|
|
|
+ bpy.types.TOPBAR_MT_file_export.remove(menu_func_export)
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ register()
|
|
|
+
|
|
|
+ # test call
|
|
|
+ bpy.ops.export.gpmesh('INVOKE_DEFAULT')
|