import bpy import bmesh import os import shutil import struct from mathutils import *; from math import * from bpy_extras.io_utils import (axis_conversion) from dataclasses import dataclass import cProfile C = bpy.context D = bpy.data ROOMS_EXPORT_NAME = "rooms" EXPORT_DIRECTORY = "../assets/exported_3d" # the roomid is a unique reference to a room the gameplay code does. It's saved in the blend file and incremented as its used. if not "next_roomid" in C.scene: C.scene["next_roomid"] = 0 if os.path.exists(bpy.path.abspath(f"//{EXPORT_DIRECTORY}")): shutil.rmtree(bpy.path.abspath(f"//{EXPORT_DIRECTORY}")) os.makedirs(bpy.path.abspath(f"//{EXPORT_DIRECTORY}")) def write_b8(f, boolean: bool): f.write(bytes(struct.pack("?", boolean))) def write_f32(f, number: float): f.write(bytes(struct.pack("f", number))) def write_u64(f, number: int): f.write(bytes(struct.pack("Q", number))) def write_i32(f, number: int): f.write(bytes(struct.pack("i", number))) def write_u16(f, number: int): # unsigned short, used in shaders to figure out which bone index is current f.write(bytes(struct.pack("H", number))) def write_v3(f, vector): write_f32(f, vector.x) write_f32(f, vector.y) write_f32(f, vector.z) def write_quat(f, quat): write_f32(f, quat.x) write_f32(f, quat.y) write_f32(f, quat.z) write_f32(f, quat.w) def write_string(f, s: str): encoded = s.encode("utf8") write_u64(f, len(encoded)) f.write(encoded) def write_4x4matrix(f, m): # writes each row, sequentially, row major for row in range(4): for col in range(4): write_f32(f, m[row][col]) def normalize_joint_weights(weights): total_weights = sum(weights) result = [0,0,0,0] if total_weights != 0: for i, weight in enumerate(weights): result[i] = weight/total_weights return result # for the level.bin mapping = axis_conversion( from_forward = "Y", from_up = "Z", to_forward = "-Z", to_up = "Y", ) mapping.resize_4x4() project_directory = bpy.path.abspath("//") def is_file_in_project(file_path): file_name = os.path.basename(file_path) for root, dirs, files in os.walk(project_directory): if file_name in files: return True return False def name_despite_copied(name): return name.split(".")[0] saved_images = set() def ensure_tex_saved_and_get_name(o) -> str: """returns the name of the mesh's texture's png in the exported directory, of the current object""" assert o.type == "MESH", f"Object {o.name} isn't a mesh and is attempting to be saved as such" mesh_name = o.to_mesh().name # find the image object in the material of the object img_obj = None assert len(o.material_slots) == 1, f"Don't know which material slot to pick from in mesh {mesh_name} object {o.name}, there must only be one material slot on every mesh" mat = o.material_slots[0] for node in mat.material.node_tree.nodes: if node.type == "TEX_IMAGE": assert img_obj == None, f"Material on object {o.name} has more than one image node in its material, so I don't know which image node to use in the game engine. Make sure materials only use one image node" img_obj = node.image assert img_obj, f"Mesh {mesh_name} in its material doesn't have an image object" image_filename = f"{img_obj.name}.png" if image_filename in saved_images: pass else: save_to = f"//{EXPORT_DIRECTORY}/{image_filename}" if img_obj.packed_file: img_obj.save(filepath=bpy.path.abspath(save_to)) else: assert img_obj.filepath != "", f"filepath '{img_obj.filepath}' in mesh {mesh_name} Isn't there but should be, as it has no packed image" old_path = bpy.path.abspath(img_obj.filepath) if not is_file_in_project(old_path): print(f"Image {image_filename} has filepath {img_obj.filepath}, outside of the current directory. So we're copying it baby. Hoo-rah!") new_path = bpy.path.abspath(f"//{image_filename}") assert not os.path.exists(new_path), f"Tried to migrate {image_filename} to a new home {new_path}, but its already taken. It's over!" shutil.copyfile(old_path, new_path) img_obj.filepath = bpy.path.relpath(new_path) img_obj.filepath = bpy.path.relpath(img_obj.filepath) assert is_file_in_project(bpy.path.abspath(img_obj.filepath)), f"The image {image_filename} has filepath {img_obj.filepath} which isn't in the project directory {project_directory}, even after copying it! WTF" shutil.copyfile(bpy.path.abspath(img_obj.filepath),bpy.path.abspath(save_to)) return image_filename def export_armatures(): print("Exporting armatures...") exported = D.collections.get("Exported") assert exported != None, f"No exported collection named 'Exported' in scene, very bad! Did everything to get exported get deleted?" armatures_collection = exported.children.get("Armatures") assert armatures_collection != None, f"No child named 'Armatures' on the exported collection, this is required" A = armatures_collection armatures_to_export = [] for o in A.objects: if o.type == "MESH": assert o.parent, f"Mesh named {o.name} without parent in armatures collection is invalid, only armatures allowed in there!" assert o.parent.type == "ARMATURE", f"Mesh named {o.name} isn't an armature, and isn't a child of an armature. This isn't allowed." elif o.type == "ARMATURE": armatures_to_export.append(o) else: assert False, f"Object named {o.name} is of an unknown type '{o.type}', only objects that are Meshes or Armature are allowed in the armatures collection" for armature_object in armatures_to_export: # get the body mesh object body_object = None for c in armature_object.children: if c.name.startswith("Body"): assert body_object == None, f"On object {armature_object.name}, more than one child has a name that starts with 'Body', specifically '{body_object.name}' and '{c.name}', only one child's name is allowed to start with 'Body' in this object." body_object = c # initialize important variables mesh_object = body_object armature_object = armature_object output_filepath = bpy.path.abspath(f"//{EXPORT_DIRECTORY}/{armature_object.name}.bin") mesh_image_filename = ensure_tex_saved_and_get_name(mesh_object) #print(f"Exporting armature with image filename {mesh_image_filename} to {output_filepath}") with open(output_filepath, "wb") as f: write_b8(f, True) # first byte is true if it's an armature file write_string(f, armature_object.name) write_string(f, mesh_image_filename) bones_in_armature = [] for b in armature_object.data.bones: bones_in_armature.append(b) # the inverse model space pos of the bones write_u64(f, len(bones_in_armature)) for b in bones_in_armature: model_space_pose = mapping @ b.matrix_local inverse_model_space_pose = (mapping @ b.matrix_local).inverted() parent_index = -1 if b.parent: for i in range(len(bones_in_armature)): if bones_in_armature[i] == b.parent: parent_index = i break if parent_index == -1: assert False, f"Couldn't find parent of bone {b}" #print(f"Parent of bone {b.name} is index {parent_index} in list {bones_in_armature}") write_string(f, b.name) write_i32(f, parent_index) write_4x4matrix(f, model_space_pose) write_4x4matrix(f, inverse_model_space_pose) write_f32(f, b.length) # write the pose information # it's very important that the array of pose bones contains the same amount of bones # as there are in the edit bones. Because the edit bones are exported, etc etc. Cowabunga! assert(len(armature_object.pose.bones) == len(bones_in_armature)) armature = armature_object anims = [] assert armature.animation_data, "Armatures are assumed to have an animation right now" for track in armature.animation_data.nla_tracks: for strip in track.strips: anims.append(strip.action) #print(f"Writing {len(anims)} animations") write_u64(f, len(anims)) for animation in anims: write_string(f, animation.name) old_action = armature.animation_data.action armature.animation_data.action = animation startFrame = int(animation.frame_range.x) endFrame = int(animation.frame_range.y) total_frames = (endFrame - startFrame) + 1 # the end frame is inclusive #print(f"Exporting animation {animation.name} with {total_frames} frames") write_u64(f, total_frames) time_per_anim_frame = 1.0 / float(bpy.context.scene.render.fps) for frame in range(startFrame, endFrame+1): time_through_this_frame_occurs_at = (frame - startFrame) * time_per_anim_frame bpy.context.scene.frame_set(frame) write_f32(f, time_through_this_frame_occurs_at) for pose_bone_i in range(len(armature_object.pose.bones)): pose_bone = armature_object.pose.bones[pose_bone_i] # in the engine, it's assumed that the poses are in the same order as the bones # they're referring to. This checks that that is the case. assert(pose_bone.bone == bones_in_armature[pose_bone_i]) parent_space_pose = None if pose_bone.parent: parent_space_pose = pose_bone.parent.matrix.inverted() @ pose_bone.matrix else: parent_space_pose = mapping @ pose_bone.matrix translation = parent_space_pose.to_translation() rotation = parent_space_pose.to_quaternion() scale = parent_space_pose.to_scale() write_v3(f, translation) write_quat(f, rotation) write_v3(f, scale) armature.animation_data.action = old_action # write the mesh data for the armature bm = bmesh.new() mesh = mesh_object.to_mesh() bm.from_mesh(mesh) bmesh.ops.triangulate(bm, faces=bm.faces) bm.transform(mapping) bm.to_mesh(mesh) vertices = [] armature = armature_object 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] for jointBindingIndex, group in enumerate(mesh.vertices[loop.vertex_index].groups): if jointBindingIndex < 4: groupIndex = group.group boneName = mesh_object.vertex_groups[groupIndex].name jointIndices[jointBindingIndex] = armature.data.bones.find(boneName) if jointIndices[jointBindingIndex] == -1: # it's fine that this references a real bone, the bone at index 0, # because the weight of its influence is 0 jointIndices[jointBindingIndex] = 0 jointWeights[jointBindingIndex] = 0.0 else: jointWeights[jointBindingIndex] = group.weight vertices.append((position, uv, jointIndices, normalize_joint_weights(jointWeights))) write_u64(f, len(vertices)) vertex_i = 0 for v_and_uv in vertices: v, uv, jointIndices, jointWeights = v_and_uv write_f32(f, v.x) write_f32(f, v.y) write_f32(f, v.z) write_f32(f, uv.x) write_f32(f, uv.y) for i in range(4): write_u16(f, jointIndices[i]) for i in range(4): write_f32(f, jointWeights[i]) vertex_i += 1 #print(f"Wrote {len(vertices)} vertices") print("Success!") def collect_and_validate_mesh_objects(collection_with_only_mesh_objects): to_return = [] for o in collection_with_only_mesh_objects.all_objects: assert o.type == "MESH", f"The collection '{collection_with_only_mesh_objects.name}' is assumed to contain only mesh objects but the object '{o.name}' which is a child of it isn't a mesh object" to_return.append(o) return to_return def get_startswith(name_of_overarching_thing, iterable, starts_with, required = True): """ Gets the thing in iterable that starts with starts with, and makes sure there's only *one* thing that starts with starts with name_of_overarching_thing is for the error message, for reporting in what collection things went wrong in. """ to_return = None for thing in iterable: if thing.name.startswith(starts_with): assert to_return == None, f"Duplicate thing that starts with '{starts_with}' found in {name_of_overarching_thing} called {thing.name}" to_return = thing if required: assert to_return != None, f"Couldn't find thing that starts with '{starts_with}' as a child of '{name_of_overarching_thing}', but one is required" return to_return def no_hidden(objects): to_return = [] for o in objects: if not o.hide_get(): to_return.append(o) return to_return def export_meshes_and_levels(): print("Exporting meshes and levels") exported = D.collections.get("Exported") assert exported != None, f"No exported collection named 'Exported' in scene, very bad! Did everything to get exported get deleted?" mesh_names_to_export = set() meshes_to_export = [] # add the collection 'Meshes' objects to mesh_objects_to_export if True: meshes = get_startswith("Exported", exported.children, "Meshes") to_export = collect_and_validate_mesh_objects(meshes) for m in no_hidden(to_export): mesh_names_to_export.add(m.name) meshes_to_export.append(m) # export each level: its placed entities, placed meshes, and add to meshes_to_export and mesh_names_to_export. Those must be exported to their correct paths for the rooms to point at valid data. rooms = exported.children.get("Rooms") assert rooms != None, f"No child named 'Rooms' on the exported collection, this is required" with open(bpy.path.abspath(f"//{EXPORT_DIRECTORY}/{ROOMS_EXPORT_NAME}.bin"), "wb") as f: write_u64(f, len(rooms.children)) for room_collection in rooms.children: write_string(f, room_collection.name) # the name of the room is the name of the room collection if not "roomid" in room_collection: room_collection["roomid"] = C.scene["next_roomid"] C.scene["next_roomid"] += 1 write_u64(f, room_collection["roomid"]) # placed meshes (exported mesh name (which is the object's name), location, rotation, scale) placed_meshes = [] if True: meshes_collection = get_startswith(room_collection.name, room_collection.children, "Meshes") for m in no_hidden(meshes_collection.objects): assert m.rotation_euler.order == 'XYZ', f"Invalid rotation euler order for object of name '{m.name}', it's {m.rotation_euler.order} but must be XYZ" assert m.type == "MESH", f"In meshes collection '{meshes_collection.name}' object {m.name} must be of type 'MESH' but instead is of type {m.type}" if not m.name in mesh_names_to_export: mesh_names_to_export.add(m.name) meshes_to_export.append(m) placed_meshes.append((m.name, mapping @ m.location, m.rotation_euler, m.scale)) # colliders (location, dimensions) placed_colliders = [] if True: colliders_collection = get_startswith(room_collection.name, room_collection.children, "Colliders") for o in no_hidden(colliders_collection.objects): assert o.name.startswith("CollisionCube"), f"Object {o.name} doesn't start with 'CollisionCube' and it's in the Colliders group of room {room_collection.name}, colliders must be collision cubes" placed_colliders.append((o.location, o.dimensions)) # fill out placed_entities with a tuple of (name, location, rotation, scale) placed_entities = [] if True: entities_collection = get_startswith(room_collection.name, room_collection.children, "Entities", required = False) if entities_collection: for o in no_hidden(entities_collection.objects): assert o.rotation_euler.order == 'XYZ', f"Invalid rotation euler order for object of name '{o.name}', it's {o.rotation_euler.order} but must be XYZ" placed_entities.append((name_despite_copied(o.name), mapping @ o.location, o.rotation_euler, o.scale)) camera_override = None if True: camera_object = get_startswith(room_collection.name, room_collection.objects, "Camera", required = False) if camera_object: camera_override = mapping @ camera_object.location write_b8(f, camera_override != None) if camera_override: write_f32(f, camera_override.x) write_f32(f, camera_override.y) write_f32(f, camera_override.z) write_u64(f, len(placed_meshes)) for p in placed_meshes: mesh_name, blender_pos, blender_rotation, blender_scale = p write_string(f, mesh_name) write_f32(f, blender_pos.x) write_f32(f, blender_pos.y) write_f32(f, blender_pos.z) write_f32(f, blender_rotation.x) write_f32(f, blender_rotation.y) write_f32(f, blender_rotation.z) write_f32(f, blender_scale.x) write_f32(f, blender_scale.y) write_f32(f, blender_scale.z) write_u64(f, len(placed_colliders)) for c in placed_colliders: blender_pos, blender_dims = c write_f32(f, blender_pos.x) write_f32(f, -blender_pos.y) write_f32(f, blender_dims.x) write_f32(f, blender_dims.y) assert blender_dims.x > 0.0 assert blender_dims.y > 0.0 write_u64(f, len(placed_entities)) for e in placed_entities: entity_name, blender_pos, blender_rotation, blender_scale = e write_string(f, entity_name) write_f32(f, blender_pos.x) write_f32(f, blender_pos.y) write_f32(f, blender_pos.z) write_f32(f, blender_rotation.x) write_f32(f, blender_rotation.y) write_f32(f, blender_rotation.z) write_f32(f, blender_scale.x) write_f32(f, blender_scale.y) write_f32(f, blender_scale.z) # export all the meshes that the rooms file is referring to, and all the meshes that just need to be plain exported for m in meshes_to_export: assert o.type == "MESH" assert o.parent == None, f"Mesh '{m.name}' has a parent, but exporting mesh objects with parents isn't supported" mesh_name = m.name image_filename = ensure_tex_saved_and_get_name(m) output_filepath = bpy.path.abspath(f"//{EXPORT_DIRECTORY}/{mesh_name}.bin") with open(output_filepath, "wb") as f: write_b8(f, False) # if it's an armature or not, first byte of the file write_string(f, image_filename) bm = bmesh.new() mesh = m.to_mesh() assert len(mesh.uv_layers) == 1, f"Mesh object {m.name} has more than 1 uv layer, which isn't allowed" bm.from_mesh(mesh) bmesh.ops.triangulate(bm, faces=bm.faces) bm.transform(mapping) bm.to_mesh(mesh) vertices = [] 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 vertices.append((position, uv)) write_u64(f, len(vertices)) for v_and_uv in vertices: v, uv = v_and_uv write_f32(f, v.x) write_f32(f, v.y) write_f32(f, v.z) write_f32(f, uv.x) write_f32(f, uv.y) print("Success!") def export_everything(): export_armatures() export_meshes_and_levels() export_everything() #cProfile.run("export_everything") # gave up optimizing this because after reading this output I'm not sure what the best direction is, it seems like it's just a lot of data, specifically the images, is why it's slow... I could hash them and only write if the hash changes but whatever