├── .gitignore ├── __init__.py ├── asset.py ├── export.py ├── install.py ├── license.txt ├── panel_exportSettings.py ├── panel_objectSettings.py ├── panel_preferences.py ├── preview001.png ├── preview002.png ├── readme.md ├── settings.py ├── unity.py └── view.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | xxhash/ 3 | xxhash-3.2.0.dist-info/ 4 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | bl_info = { 2 | 'name': 'Game Export', 3 | 'description': 'Configure settings and export from Blender to Unity with one click.', 4 | 'author': 'codec-xyz', 5 | 'version': (2, 0, 2), 6 | 'blender': (3, 4, 1), 7 | 'location': 'View3D', 8 | 'category': 'Import-Export', 9 | } 10 | 11 | DEV_MODE = False 12 | def devPrint(*args): 13 | if DEV_MODE: print(*args) 14 | devPrint('--------------------------------------game_export init load----------------------------------------') 15 | 16 | import sys 17 | import importlib 18 | 19 | PIP_MODULES = ['xxhash'] 20 | MODULES = ['settings', 'install', 'panel_preferences'] 21 | MODULES_CORE = ['asset', 'unity', 'export', 'panel_exportSettings', 'panel_objectSettings'] 22 | 23 | def dependenciesAreLoaded(): 24 | for module in PIP_MODULES: 25 | if f'{__package__}.{module}' not in sys.modules: return False 26 | return True 27 | 28 | def loadModules(modules: 'list[str]'): 29 | for module in modules: 30 | moduleFullPath = f'{__package__}.{module}' 31 | try: 32 | if moduleFullPath in sys.modules: importlib.reload(sys.modules[moduleFullPath]) 33 | else: importlib.import_module(moduleFullPath) 34 | except Exception as e: 35 | print(f'{__package__}: module "{module}" could not be loaded') 36 | print('\n'.join(e.args)) 37 | 38 | def registerModules(modules: 'list[str]'): 39 | for module in modules: 40 | try: sys.modules[f'{__package__}.{module}'].register() 41 | except: '' 42 | 43 | def unregisterModules(modules: 'list[str]'): 44 | for module in reversed(modules): 45 | try: sys.modules[f'{__package__}.{module}'].unregister() 46 | except: '' 47 | 48 | def register(): 49 | devPrint(f'{__package__}: running register()') 50 | loadModules([*PIP_MODULES, *MODULES, *MODULES_CORE]) 51 | registerModules([*MODULES, *MODULES_CORE] if dependenciesAreLoaded() else MODULES) 52 | 53 | def unregister(): 54 | devPrint(f'{__package__}: running unregister()') 55 | unregisterModules([*MODULES, *MODULES_CORE]) -------------------------------------------------------------------------------- /asset.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import mathutils 3 | 4 | class Transform(): 5 | location: mathutils.Vector 6 | rotation: mathutils.Quaternion 7 | scale: mathutils.Vector 8 | 9 | def __init__(self): 10 | self.location = mathutils.Vector([0, 0, 0]) 11 | self.rotation = mathutils.Quaternion([1, 0, 0, 0]) 12 | self.scale = mathutils.Vector([1, 1, 1]) 13 | 14 | def parentMatrix(self, matrix: mathutils.Matrix): 15 | result = matrix @ mathutils.Matrix.LocRotScale(self.location, self.rotation, self.scale) 16 | self.location = result.to_translation() 17 | self.rotation = result.to_quaternion() 18 | self.scale = result.to_scale() 19 | return self 20 | 21 | def isIdentity(self) -> bool: 22 | return (self.location == mathutils.Vector([0, 0, 0]) 23 | and self.rotation == mathutils.Quaternion([1, 0, 0, 0]) 24 | and self.scale == mathutils.Vector([1, 1, 1])) 25 | 26 | def isNoRotation(self) -> bool: 27 | return self.rotation == mathutils.Quaternion([1, 0, 0, 0]) 28 | 29 | def copy(self): 30 | copy = Transform() 31 | copy.location = self.location.copy() 32 | copy.rotation = self.rotation.copy() 33 | copy.scale = self.scale.copy() 34 | return copy 35 | 36 | class Light(): 37 | transform: Transform 38 | light: bpy.types.Light 39 | 40 | def __init__(self): 41 | self.transform = Transform() 42 | 43 | def hasActiveModifiers(object: bpy.types.Object): 44 | for modifier in object.modifiers.values(): 45 | if modifier.show_viewport: return True 46 | return False 47 | 48 | def toMesh(depsgraph: bpy.types.Depsgraph, object: bpy.types.Object, transform: 'mathutils.Matrix | None') -> bpy.types.Mesh: 49 | if object.type == 'MESH' and not transform and not hasActiveModifiers(object): return object.data 50 | 51 | mesh = bpy.data.meshes.new_from_object(object.evaluated_get(depsgraph), preserve_all_data_layers = True, depsgraph = depsgraph) 52 | if not mesh: mesh = bpy.data.meshes.new(name='') 53 | if transform: mesh.transform(transform @ object.matrix_world) 54 | return mesh 55 | 56 | def makeNormalsJoinable(context: bpy.types.Context, obj: bpy.types.Object): 57 | mesh: bpy.types.Mesh = obj.data 58 | context.view_layer.objects.active = obj 59 | if(not mesh.use_auto_smooth): 60 | mesh.auto_smooth_angle = 3.14159 61 | mesh.use_auto_smooth = True 62 | bpy.ops.mesh.customdata_custom_splitnormals_add() 63 | elif(not obj.data.has_custom_normals): bpy.ops.mesh.customdata_custom_splitnormals_add() 64 | 65 | def makeUVsJoinable(mesh: bpy.types.Mesh): 66 | for index, uvLayer in enumerate(mesh.uv_layers): 67 | mesh.uv_layers[0].name = f'UVMap{index}' 68 | 69 | def joinMeshes(context: bpy.types.Context, meshes: 'list[bpy.types.Mesh]') -> 'bpy.types.Mesh | None': 70 | if len(meshes) == 0: return None 71 | if len(meshes) == 1: return meshes[0] 72 | 73 | tempCollection = bpy.data.collections.new(name = '') 74 | context.scene.collection.children.link(tempCollection) 75 | bpy.ops.object.select_all(action = 'DESELECT') 76 | 77 | for mesh in meshes: 78 | object = bpy.data.objects.new('', mesh) 79 | tempCollection.objects.link(object) 80 | object.select_set(True) 81 | makeNormalsJoinable(context, object) 82 | makeUVsJoinable(mesh) 83 | 84 | joinMesh = bpy.data.meshes.new('') 85 | joinObject = bpy.data.objects.new('', joinMesh) 86 | tempCollection.objects.link(joinObject) 87 | joinObject.select_set(True) 88 | context.view_layer.objects.active = joinObject 89 | 90 | bpy.ops.object.join() 91 | 92 | bpy.data.collections.remove(tempCollection) 93 | bpy.data.objects.remove(joinObject) 94 | 95 | return joinMesh 96 | 97 | def canConvertToMesh(object: bpy.types.Object) -> bool: 98 | return object.type in ['MESH', 'CURVE', 'SURFACE', 'META', 'FONT', 'CURVES'] 99 | 100 | def getBoxCollider(object: bpy.types.Object, transform: 'mathutils.Matrix | None') -> Transform: 101 | x = [vec[0] for vec in object.bound_box] 102 | y = [vec[1] for vec in object.bound_box] 103 | z = [vec[2] for vec in object.bound_box] 104 | 105 | minVec = mathutils.Vector([min(x), min(y), min(z)]) 106 | maxVec = mathutils.Vector([max(x), max(y), max(z)]) 107 | 108 | colliderTransform = Transform() 109 | colliderTransform.location = (minVec + maxVec) / 2 110 | colliderTransform.scale = maxVec - minVec 111 | 112 | if transform: colliderTransform.parentMatrix(transform @ object.matrix_world) 113 | 114 | return colliderTransform 115 | 116 | def getLightSettings(object: bpy.types.Object, transform: 'mathutils.Matrix | None') -> Light: 117 | light = Light() 118 | if(object.type != 'LIGHT'): 119 | light.light = bpy.data.lights.new("") 120 | return light 121 | 122 | if transform: light.transform.parentMatrix(transform @ object.matrix_world) 123 | 124 | light.light = object.data 125 | return light 126 | 127 | class Asset(): 128 | name: str 129 | mesh: 'bpy.types.Mesh | None' 130 | lights: 'list[Light]' 131 | boxColliders: 'list[Transform]' 132 | meshColliders: 'list[bpy.types.Mesh]' 133 | 134 | def __init__(self, depsgraph: bpy.types.Depsgraph, assetObject: bpy.types.Object, inheritMeshRender: str, inheritLightRender: str, inheritCollider: str): 135 | self.lights = [] 136 | self.boxColliders = [] 137 | self.meshColliders = [] 138 | 139 | if assetObject.type == 'EMPTY' and assetObject.instance_collection: self.name = assetObject.instance_collection.name 140 | elif assetObject.type != 'EMPTY': self.name = assetObject.data.name 141 | else: self.name = assetObject.name 142 | 143 | meshParts: 'list[bpy.types.Mesh]' = [] 144 | 145 | def addObject(object: bpy.types.Object, transform: 'mathutils.Matrix | None'): 146 | #viewport hidden objects do not get evaluted correctly 147 | if object.hide_viewport: return 148 | 149 | if object.type == 'EMPTY' and object.instance_collection: 150 | for subObject in object.instance_collection.all_objects: 151 | addObject(subObject, transform @ object.matrix_world if transform else mathutils.Matrix.Identity(4)) 152 | 153 | meshRender = inheritMeshRender if object.gameExportSettings.meshRender == 'INHERIT' else object.gameExportSettings.meshRender 154 | lightRender = inheritLightRender if object.gameExportSettings.lightRender == 'INHERIT' else object.gameExportSettings.lightRender 155 | collider = inheritCollider if object.gameExportSettings.collider == 'INHERIT' else object.gameExportSettings.collider 156 | 157 | if meshRender == 'MESH_RENDERER' and canConvertToMesh(object): 158 | meshParts.append(toMesh(depsgraph, object, transform)) 159 | 160 | elif lightRender == 'LIGHT' and object.type == 'LIGHT': 161 | self.lights.append(getLightSettings(object, transform)) 162 | 163 | if collider == 'MESH' and canConvertToMesh(object): 164 | self.meshColliders.append(toMesh(depsgraph, object, transform)) 165 | 166 | elif collider == 'BOX' and canConvertToMesh(object): 167 | self.boxColliders.append(getBoxCollider(object, transform)) 168 | 169 | addObject(assetObject, None) 170 | 171 | self.mesh = joinMeshes(bpy.context, meshParts) 172 | 173 | for meshPart in meshParts: 174 | if meshPart.users == 0 and meshPart != self.mesh: bpy.data.meshes.remove(meshPart) 175 | 176 | def preview(self): 177 | object = bpy.data.objects.new('preview', self.mesh) 178 | object.data.use_auto_smooth = True 179 | bpy.context.scene.collection.objects.link(object) 180 | 181 | def cleanUp(self): 182 | if self.mesh and self.mesh.users == 0: bpy.data.meshes.remove(self.mesh) 183 | for meshCollider in self.meshColliders: 184 | if meshCollider.users == 0: bpy.data.meshes.remove(meshCollider) 185 | 186 | def getDataUid(object: bpy.types.Object): 187 | if object.type == 'EMPTY' and object.instance_collection: return str(id(object.instance_collection)) 188 | if object.type == 'MESH' and not hasActiveModifiers(object): return str(id(object.data)) + object.gameExportSettings.meshRender + object.gameExportSettings.lightRender + object.gameExportSettings.collider 189 | if object.type == 'LIGHT': return str(id(object.data)) 190 | return str(id(object)) -------------------------------------------------------------------------------- /export.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from .settings import * 3 | from .asset import * 4 | from .unity import * 5 | 6 | def findLayerCollection(collection: bpy.types.Collection, layerCollection: bpy.types.LayerCollection): 7 | for sub in layerCollection.children: 8 | if sub.collection == collection: return sub 9 | found = findLayerCollection(collection, sub) 10 | if(found): return found 11 | return None 12 | 13 | def exportCollection(context: bpy.types.Context, filePath: str, settings: GAME_EXPORT_collection_settings): 14 | hierarchyDict: 'dict[int, list[bpy.types.Collection | bpy.types.Object]]' = {} 15 | assetDict: 'dict[str, Asset]' = {} 16 | assetNameSet: 'set[str]' = set() 17 | exportMeshNames: 'dict[int, str]' = {} #possible mesh collisions 18 | depsgraph = context.evaluated_depsgraph_get() 19 | objectRenameList = [] 20 | meshRenameList = [] 21 | 22 | def addCollection(collection: bpy.types.Collection): 23 | collectionEntry = hierarchyDict.get(id(collection), []) 24 | 25 | for child in collection.children: 26 | collectionEntry.append(child) 27 | addCollection(child) 28 | 29 | for child in collection.objects: 30 | if child.parent: 31 | objectEntry = hierarchyDict.get(id(child.parent), []) 32 | objectEntry.append(child) 33 | hierarchyDict[id(child.parent)] = objectEntry 34 | else: 35 | collectionEntry.append(child) 36 | 37 | hierarchyDict[id(collection)] = collectionEntry 38 | 39 | addCollection(settings.collection) 40 | 41 | with open(filePath + settings.exportName + '.fbx.meta', 'a+') as file: 42 | file.seek(0) 43 | fbxMetaFile = file.read() 44 | if fbxMetaFile == '': 45 | fbxFileLink = AssetLink('', makeRandomGuid()) 46 | file.write(makeDefaultMetaFile_fbx(fbxFileLink)) 47 | else: 48 | fbxFileLink = getFbxMetaFileLink(fbxMetaFile) 49 | 50 | def yamlJoin(string1: str, string2: str): 51 | if string1 == '' or string2 == '': return string1 + string2 52 | return string1 + '\n' + string2 53 | 54 | def getAsset(object: bpy.types.Object) -> Asset: 55 | asset = assetDict.get(Asset.getDataUid(object)) 56 | if asset: return asset 57 | 58 | asset = assetDict[Asset.getDataUid(object)] = Asset(depsgraph, object, settings.meshRender, settings.lightRender, settings.collider) 59 | if asset.name in assetNameSet: 60 | i = 0 61 | while f'{asset.name}.{str(i).rjust(3, "0")}' in assetNameSet: i += 1 62 | asset.name += f'.{str(i).rjust(3, "0")}' 63 | assetNameSet.add(asset.name) 64 | 65 | return asset 66 | 67 | materialLinksNameLookup = materialLinksNameLookupGet(settings) 68 | materialToUnityLink: 'dict[int, AssetLink]' = {} 69 | 70 | def getMaterialLink(material: bpy.types.Material) -> AssetLink: 71 | unityLink = materialToUnityLink.get(id(material)) 72 | if unityLink != None: return unityLink 73 | 74 | materialLink = materialLinksNameLookup.get(material.name) 75 | if materialLink: 76 | try: 77 | with open(bpy.path.abspath(materialLink.filePath) + '.meta', 'r') as file: 78 | unityLink = getMaterialMetaFileLink(file.read()) 79 | print(unityLink) 80 | except: '' 81 | 82 | if unityLink == None: unityLink = fbxFileLink.fromFile(getFbxId_material(material.name)) 83 | materialToUnityLink[id(material)] = unityLink 84 | return unityLink 85 | 86 | def getMeshMaterialsFbxOrder(mesh: bpy.types.Mesh) -> 'list[bpy.types.Material]': 87 | materialIndexList = [] 88 | materialCount = len(mesh.materials) 89 | if materialCount == 0: return [] 90 | for polygon in mesh.polygons: 91 | if polygon.material_index in materialIndexList: continue 92 | materialIndexList.append(polygon.material_index) 93 | if len(materialIndexList) == materialCount: break 94 | return [mesh.materials[index] for index in materialIndexList] 95 | 96 | def exportUnityLight(light: bpy.types.Light, componentLink: AssetLink, gameObjectLink: AssetLink) -> str: 97 | shadows = UNITY_LIGHT_SHADOWS_NONE 98 | color = light.color 99 | inensity = light.energy * settings.lightMultiplier 100 | spotlightAngleDeg = 0 101 | spotlightInnerAngleDeg = 0 102 | areaSize = [1, 1] 103 | 104 | if light.type == 'POINT': type = UNITY_LIGHT_TYPE_POINT 105 | elif light.type == 'SUN': type = UNITY_LIGHT_TYPE_DIRECTIONAL 106 | elif light.type == 'SPOT': 107 | type = UNITY_LIGHT_TYPE_SPOT 108 | spotlightAngleDeg = light.spot_size * 57.295779513 109 | spotlightInnerAngleDeg = light.spot_size * (1 - light.spot_blend) * 57.295779513 110 | elif light.type == 'AREA' and light.shape == 'SQUARE': 111 | type = UNITY_LIGHT_TYPE_AREA_RECTANGLE 112 | areaSize = [light.size, light.size] 113 | elif light.type == 'AREA' and light.shape == 'RECTANGLE': 114 | type = UNITY_LIGHT_TYPE_AREA_RECTANGLE 115 | areaSize = [light.size, light.size_y] 116 | elif light.type == 'AREA' and light.shape == 'DISK': 117 | type = UNITY_LIGHT_TYPE_AREA_DISK 118 | areaSize = [light.size, light.size] 119 | elif light.type == 'AREA' and light.shape == 'ELLIPSE': 120 | type = UNITY_LIGHT_TYPE_AREA_DISK 121 | size = (light.size + light.size_y) * 0.5 122 | areaSize = [size, size] 123 | 124 | if context.scene.render.engine == 'BLENDER_EEVEE': 125 | if light.use_shadow: shadows = UNITY_LIGHT_SHADOWS_SOFT 126 | elif context.scene.render.engine == 'CYCLES': 127 | if light.cycles.cast_shadow: shadows = UNITY_LIGHT_SHADOWS_SOFT 128 | 129 | return makeLight(componentLink, gameObjectLink, type, UNITY_LIGHT_MODE_BAKED, shadows, color, inensity, spotlightAngleDeg, spotlightInnerAngleDeg, areaSize, 10) 130 | 131 | def exportUnityAsset(asset: Asset, gameObjectLink: AssetLink, transformLink = AssetLink()) -> 'tuple[list[str], list[str], str, mathutils.Quaternion]': 132 | file = '' 133 | tansformLinks = [] 134 | componentLinks = [] 135 | applyRotation = mathutils.Quaternion([1, 0, 0, 0]) 136 | 137 | #mesh 138 | if asset.mesh: 139 | componentLink = AssetLink(makeRandomAssetId()) 140 | componentLinks.append(componentLink) 141 | meshName = exportMeshNames.get(id(asset.mesh)) 142 | if not meshName: 143 | exportMeshNames[id(asset.mesh)] = meshName = asset.name 144 | file = yamlJoin(file, makeMeshFilter(componentLink, gameObjectLink, fbxFileLink.fromFile(getFbxId_mesh(meshName)))) 145 | 146 | componentLink = AssetLink(makeRandomAssetId()) 147 | componentLinks.append(componentLink) 148 | materialList = [getMaterialLink(material) for material in getMeshMaterialsFbxOrder(asset.mesh)] 149 | if len(materialList) == 0: materialList = [UNITY_LINK_DEFAULT_MATERIAL] 150 | file = yamlJoin(file, makeMeshRenderer(componentLink, gameObjectLink, materialList)) 151 | 152 | #lights 153 | lightIndex = 0 154 | for light in asset.lights: 155 | if not asset.mesh and len(asset.lights) == 1 and len(asset.boxColliders) == 0 and len(asset.meshColliders) == 0: 156 | if not light.light.type == 'POINT': applyRotation = mathutils.Quaternion([0.7071068286895752, 0.7071068286895752, 0, 0]) 157 | componentLink = AssetLink(makeRandomAssetId()) 158 | componentLinks.append(componentLink) 159 | file = yamlJoin(file, exportUnityLight(light.light, componentLink, gameObjectLink)) 160 | continue 161 | 162 | subGameObjectLink = AssetLink(makeRandomAssetId()) 163 | subTransformLink = AssetLink(makeRandomAssetId()) 164 | subComponentLink = AssetLink(makeRandomAssetId()) 165 | file = yamlJoin(file, makeGameObject(subGameObjectLink, f'{asset.name}.light.{str(lightIndex).rjust(3, "0")}', [subTransformLink, subComponentLink])) 166 | transform = light.transform.copy() 167 | transform.rotation.rotate(mathutils.Quaternion([0.7071068286895752, 0.7071068286895752, 0, 0])) 168 | file = yamlJoin(file, makeTransfom(subTransformLink, subGameObjectLink, transformLink, [], transform)) 169 | file = yamlJoin(file, exportUnityLight(light.light, subComponentLink, subGameObjectLink)) 170 | 171 | lightIndex += 1 172 | 173 | #box colliders 174 | boxColliderIndex = 0 175 | for boxCollider in asset.boxColliders: 176 | if boxCollider.isNoRotation(): 177 | componentLink = AssetLink(makeRandomAssetId()) 178 | componentLinks.append(componentLink) 179 | file = yamlJoin(file, makeBoxCollider(componentLink, gameObjectLink, boxCollider.scale, boxCollider.location)) 180 | continue 181 | 182 | subGameObjectLink = AssetLink(makeRandomAssetId()) 183 | subTransformLink = AssetLink(makeRandomAssetId()) 184 | subComponentLink = AssetLink(makeRandomAssetId()) 185 | file = yamlJoin(file, makeGameObject(subGameObjectLink, f'{asset.name}.boxCollider.{str(boxColliderIndex).rjust(3, "0")}', [subTransformLink, subComponentLink])) 186 | file = yamlJoin(file, makeTransfom(subTransformLink, subGameObjectLink, transformLink, [], boxCollider)) 187 | file = yamlJoin(file, makeBoxCollider(subComponentLink, subGameObjectLink)) 188 | 189 | boxColliderIndex += 1 190 | 191 | #mesh colliders 192 | for i, meshCollider in enumerate(asset.meshColliders): 193 | componentLink = AssetLink(makeRandomAssetId()) 194 | componentLinks.append(componentLink) 195 | meshName = exportMeshNames.get(id(meshCollider)) 196 | if not meshName: 197 | if meshCollider == asset.mesh: meshName = asset.name 198 | else: meshName = f'{asset.name}.meshCollider.{str(i).rjust(3, "0")}' 199 | exportMeshNames[id(meshCollider)] = meshName 200 | file = yamlJoin(file, makeMeshCollider(componentLink, gameObjectLink, fbxFileLink.fromFile(getFbxId_mesh(meshName)))) 201 | 202 | return (tansformLinks, componentLinks, file, applyRotation) 203 | 204 | def exportUnityObject(object: 'bpy.types.Collection | bpy.types.Object', parentTransformLink = AssetLink()) -> 'tuple[str, str]': 205 | file = '' 206 | children = hierarchyDict.get(id(object), []) 207 | gameObjectLink = AssetLink(makeRandomAssetId()) 208 | transformLink = AssetLink(makeRandomAssetId()) 209 | childTransformLinks = [] 210 | childComponentLinks = [transformLink] 211 | 212 | for child in children: 213 | childData = exportUnityObject(child, transformLink) 214 | childTransformLinks.append(childData[0]) 215 | file = yamlJoin(file, childData[1]) 216 | 217 | assetData = None 218 | if type(object) == bpy.types.Collection: transformMatrix = mathutils.Matrix.Identity(4) 219 | else: 220 | transformMatrix = object.matrix_local 221 | assetData = exportUnityAsset(getAsset(object), gameObjectLink, transformLink) 222 | childTransformLinks.extend(assetData[0]) 223 | childComponentLinks.extend(assetData[1]) 224 | file = yamlJoin(file, assetData[2]) 225 | 226 | 227 | staticFlagsList = settings.static 228 | if type(object) == bpy.types.Object and not object.gameExportSettings.static[0]: staticFlagsList = object.gameExportSettings.static[1:] 229 | staticFlags = sum([pow(2, i) * isOn for i, isOn in enumerate(staticFlagsList)]) 230 | if staticFlags == 127: staticFlags = UNITY_GAME_OBJECT_STATIC_EVERYTHING 231 | 232 | file = yamlJoin(file, makeGameObject(gameObjectLink, object.name, childComponentLinks, staticFlags)) 233 | transform = Transform().parentMatrix(transformMatrix) 234 | if assetData: 235 | assetData[3].rotate(transform.rotation) 236 | transform.rotation = assetData[3] 237 | file = yamlJoin(file, makeTransfom(transformLink, gameObjectLink, parentTransformLink, childTransformLinks, transform)) 238 | 239 | return (transformLink, file) 240 | 241 | prefabFile = yamlJoin(unityYamlHeader, exportUnityObject(settings.collection)[1]) 242 | 243 | with open(filePath + settings.exportName + '.prefab', 'w') as file: file.write(prefabFile) 244 | 245 | tempCollection = bpy.data.collections.new(name = '') 246 | context.scene.collection.children.link(tempCollection) 247 | context.view_layer.active_layer_collection = findLayerCollection(tempCollection, context.view_layer.layer_collection) 248 | 249 | def freeObjectName(name: str): 250 | if name not in bpy.data.objects: return 251 | randomName = ''.join(random.choice(string.ascii_letters) for _ in range(16)) 252 | bpy.data.objects[name].name = randomName 253 | objectRenameList.append((name, randomName)) 254 | 255 | def freeMeshName(name: str): 256 | if name not in bpy.data.meshes: return 257 | randomName = ''.join(random.choice(string.ascii_letters) for _ in range(16)) 258 | bpy.data.meshes[name].name = randomName 259 | meshRenameList.append((name, randomName)) 260 | 261 | exportedMeshes: 'set[bpy.types.Mesh]' = set() 262 | def addExportMesh(mesh: bpy.types.Mesh): 263 | if mesh in exportedMeshes: return 264 | exportedMeshes.add(mesh) 265 | name = exportMeshNames.get(id(mesh)) 266 | freeObjectName(name) 267 | if mesh.name != name: 268 | freeMeshName(name) 269 | meshRenameList.append((mesh.name, name)) 270 | mesh.name = name 271 | object = bpy.data.objects.new(name, mesh) 272 | object.data.use_auto_smooth = True 273 | tempCollection.objects.link(object) 274 | 275 | for asset in assetDict.values(): 276 | if asset.mesh: 277 | addExportMesh(asset.mesh) 278 | for meshCollider in asset.meshColliders: 279 | addExportMesh(meshCollider) 280 | 281 | changedMaterialValues = [] 282 | 283 | if settings.fixFBXTextureTint: 284 | for materialName in materialNameSetGet(settings.collection): 285 | nodeTree = bpy.data.materials[materialName].node_tree 286 | 287 | materialOutput = None 288 | for node in nodeTree.nodes: 289 | if node.type == 'OUTPUT_MATERIAL': 290 | materialOutput = node 291 | break 292 | if materialOutput == None: continue 293 | 294 | surfaceLinks = materialOutput.inputs['Surface'].links 295 | if len(surfaceLinks) == 0: continue 296 | surfaceNode = surfaceLinks[0].from_node 297 | if surfaceNode.bl_idname != 'ShaderNodeBsdfPrincipled': continue 298 | 299 | if not surfaceNode.inputs['Base Color'].is_linked: continue 300 | 301 | changedMaterialValues.append((surfaceNode, 302 | [*surfaceNode.inputs['Base Color'].default_value], 303 | )) 304 | 305 | surfaceNode.inputs['Base Color'].default_value = [1, 1, 1, 1] 306 | 307 | try: 308 | bpy.ops.export_scene.fbx(filepath = filePath + settings.exportName + ".fbx", use_active_collection = True, bake_space_transform = True) 309 | except Exception as e: 310 | print(f'{__package__}: {settings.collection.name} failed to export fbx\n{e}') 311 | 312 | for change in changedMaterialValues: 313 | print(change[0], change[1]) 314 | change[0].inputs['Base Color'].default_value = change[1] 315 | 316 | for object in tempCollection.objects: bpy.data.objects.remove(object) 317 | bpy.data.collections.remove(tempCollection) 318 | 319 | for rename in reversed(objectRenameList): 320 | if rename[1] not in bpy.data.objects: continue 321 | bpy.data.objects[rename[1]].name = rename[0] 322 | 323 | for rename in reversed(meshRenameList): 324 | if rename[1] not in bpy.data.meshes: continue 325 | bpy.data.meshes[rename[1]].name = rename[0] 326 | 327 | for asset in assetDict.values(): 328 | asset.cleanUp() -------------------------------------------------------------------------------- /install.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import bpy 3 | import subprocess 4 | import os 5 | import sys 6 | from .settings import * 7 | from . import * 8 | PYTHON_BINARY = sys.executable if bpy.app.version >= (2,91,0) else bpy.app.binary_path_python 9 | MODULES_PATH = os.path.dirname(os.path.abspath(__file__)) 10 | 11 | class GAME_EXPORT_OT_install_modules(bpy.types.Operator): 12 | '''Install missing modules''' 13 | bl_idname = 'game_export.install_modules' 14 | bl_label = 'Install missing modules' 15 | 16 | def drawTable(layout: bpy.types.UILayout): 17 | layout.label(text = 'Required Modules') 18 | column = layout.column() 19 | for module in PIP_MODULES: 20 | row = column.row() 21 | if f'{__package__}.{module}' in sys.modules: row.label(icon = 'CHECKBOX_HLT') 22 | else: row.label(icon = 'CHECKBOX_DEHLT') 23 | row.label(text = module + ' (pip package)') 24 | layout.operator(GAME_EXPORT_OT_install_modules.bl_idname) 25 | 26 | @classmethod 27 | def poll(cls, context): 28 | return not dependenciesAreLoaded() 29 | 30 | def execute(self, context): 31 | modulesToInstall = [] 32 | for module in PIP_MODULES: 33 | if f'{__package__}.{module}' in sys.modules: continue 34 | try: 35 | importlib.import_module(f'{__package__}.{module}') 36 | except: 37 | modulesToInstall.append(module) 38 | 39 | try: 40 | subprocess.run([PYTHON_BINARY, '-m', 'ensurepip'], check = True, capture_output = True) 41 | subprocess.run([PYTHON_BINARY, '-m', 'pip', 'install', *modulesToInstall, '-t', MODULES_PATH], check = True, capture_output = True) 42 | except subprocess.CalledProcessError as e: 43 | def draw(self, context): 44 | self.layout.label(text = 'Some modules failed to install...') 45 | for line in e.stderr.decode().split('\n'): 46 | self.layout.label(text = line) 47 | bpy.context.window_manager.popup_menu(draw, title = 'Error installing modules', icon = 'IMPORT') 48 | 49 | loadModules(PIP_MODULES) 50 | if dependenciesAreLoaded(): 51 | loadModules(MODULES_CORE) 52 | registerModules(MODULES_CORE) 53 | bpy.utils.unregister_class(GAME_EXPORT_PT_temp_insall_modules_info) 54 | 55 | return { 'FINISHED' } 56 | 57 | class GAME_EXPORT_PT_temp_insall_modules_info(bpy.types.Panel): 58 | bl_idname = 'GAME_EXPORT_PT_temp_insall_modules_info' 59 | bl_label = 'Game Export' 60 | bl_space_type = 'VIEW_3D' 61 | bl_region_type = 'UI' 62 | bl_category = 'GameExport' 63 | 64 | def draw(self, context): 65 | column = self.layout.column(align=True) 66 | column.label(text='Install missing modules') 67 | column.label(text='More info under...') 68 | column.label(text='Edit > Preferences > Add-ons > Game Export') 69 | self.layout.operator(GAME_EXPORT_OT_install_modules.bl_idname) 70 | 71 | class GAME_EXPORT_PT_temp_data_delete_info(bpy.types.Panel): 72 | bl_idname = 'GAME_EXPORT_PT_temp_data_delete_info' 73 | bl_label = 'Game Export' 74 | bl_space_type = 'VIEW_3D' 75 | bl_region_type = 'UI' 76 | bl_category = 'GameExport' 77 | 78 | def draw(self, context): 79 | column = self.layout.column(align=True) 80 | column.label(text='Data deleted') 81 | column.label(text='Turn the addon off and on to use again') 82 | 83 | class GAME_EXPORT_OT_delete_data(bpy.types.Operator): 84 | '''Delete addon data from this blender file''' 85 | bl_idname = 'game_export.delete_data' 86 | bl_label = 'Delete Addon Data' 87 | 88 | @classmethod 89 | def poll(cls, context): 90 | return doesDataExist() 91 | 92 | def execute(self, context): 93 | deleteData() 94 | unregisterModules(MODULES_CORE) 95 | bpy.utils.register_class(GAME_EXPORT_PT_temp_data_delete_info) 96 | return { 'FINISHED' } 97 | 98 | classes = ( 99 | GAME_EXPORT_OT_install_modules, 100 | GAME_EXPORT_OT_delete_data, 101 | ) 102 | 103 | def register(): 104 | for cls in classes: bpy.utils.register_class(cls) 105 | if not dependenciesAreLoaded(): bpy.utils.register_class(GAME_EXPORT_PT_temp_insall_modules_info) 106 | 107 | def unregister(): 108 | for cls in reversed(classes): bpy.utils.unregister_class(cls) 109 | if not dependenciesAreLoaded(): bpy.utils.unregister_class(GAME_EXPORT_PT_temp_insall_modules_info) 110 | if not doesDataExist(): bpy.utils.unregister_class(GAME_EXPORT_PT_temp_data_delete_info) -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 codec_xyz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /panel_exportSettings.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import os 3 | import random 4 | import string 5 | import time 6 | import math 7 | from .settings import * 8 | from .export import exportCollection 9 | 10 | def isPathFileWritable(path): 11 | checkFilePath = '' 12 | for _ in range(6): 13 | checkFilePath = path + '\\' + ''.join(random.choice(string.ascii_letters) for i in range(16)) 14 | if(not os.path.isfile(checkFilePath)): break 15 | try: 16 | with open(checkFilePath, 'w+') as _: '' 17 | os.remove(checkFilePath) 18 | return True 19 | except IOError as _: 20 | return False 21 | 22 | exportInfo = [] 23 | 24 | class GAME_EXPORT_OT_show_export_info(bpy.types.Operator): 25 | bl_idname = 'game_export.show_export_info' 26 | bl_label = 'Game Export Info' 27 | 28 | def draw(self, context): 29 | global exportInfo 30 | if(len(exportInfo) == 0): 31 | self.layout.box().label(text = 'Nothing To Export') 32 | return 33 | 34 | row = self.layout.box().split(factor = 0.7) 35 | row.label(text = 'Export Completed Successfully') 36 | row.label(text = f'{sum([info[1] for info in exportInfo]):0.4f} s') 37 | row = self.layout.box().split(factor = 0.7) 38 | c1 = row.column() 39 | c2 = row.column() 40 | for info in exportInfo: 41 | c1.label(text = info[0]) 42 | c2.label(text = f'{info[1]:0.4f} s') 43 | 44 | def execute(self, context): 45 | return {'FINISHED'} 46 | 47 | def invoke(self, context, event): 48 | context.window_manager.invoke_props_dialog(self) 49 | return {'RUNNING_MODAL'} 50 | 51 | class GAME_EXPORT_OT_export(bpy.types.Operator): 52 | '''Game export''' 53 | bl_idname = 'game_export.export' 54 | bl_label = 'Game Export' 55 | 56 | def execute(self, context): 57 | settings = context.scene.gameExportSettings 58 | fullPath = bpy.path.abspath(settings.filePath) 59 | if(not isPathFileWritable(fullPath)): 60 | def draw(self, context): 61 | self.layout.label(text = fullPath) 62 | self.layout.label(text = 'Likely: Folder does not exist or you cannot write here') 63 | context.window_manager.popup_menu(draw, title = 'Invalid folder path', icon = 'EXPORT') 64 | return { 'CANCELLED' } 65 | 66 | global exportInfo 67 | exportInfo = [] 68 | context.window_manager.progress_begin(0, 1) 69 | context.window_manager.progress_update(0) 70 | totalObjs = 0 71 | currentObjs = 0 72 | for collectionSettings in settings.collectionList: 73 | if(collectionSettings.shouldExport): totalObjs += len(collectionSettings.collection.all_objects) 74 | for collectionSettings in settings.collectionList: 75 | if(collectionSettings.shouldExport): 76 | start = time.perf_counter() 77 | exportCollection(context, fullPath, collectionSettings) 78 | timeElapsed = time.perf_counter() - start 79 | exportInfo.append((collectionSettings.exportName, timeElapsed)) 80 | currentObjs += len(collectionSettings.collection.all_objects) 81 | context.window_manager.progress_update(currentObjs / totalObjs) 82 | 83 | context.window_manager.progress_end() 84 | bpy.ops.game_export.show_export_info('INVOKE_DEFAULT') 85 | return { 'FINISHED' } 86 | 87 | def availableCollections(scene, context): 88 | return [(c.name, c.name, '') for c in bpy.data.collections] 89 | 90 | class GAME_EXPORT_OT_collection_add(bpy.types.Operator): 91 | '''Add collection to export''' 92 | bl_idname = 'game_export.collection_add' 93 | bl_label = 'Add Collection' 94 | bl_property = 'collections' 95 | bl_options = {'UNDO'} 96 | 97 | collections: bpy.props.EnumProperty(name = 'Collections', description = '', items = availableCollections) 98 | 99 | def execute(self, context): 100 | settings = context.scene.gameExportSettings 101 | collectionSettings = settings.collectionList.add() 102 | collectionSettings.collection = next(c for c in bpy.data.collections if c.name == self.collections) 103 | collectionSettings.exportName = collectionSettings.collection.name 104 | settings.collectionListIndex = len(settings.collectionList) - 1 105 | context.area.tag_redraw() 106 | return {'FINISHED'} 107 | 108 | def invoke(self, context, event): 109 | wm = context.window_manager 110 | wm.invoke_search_popup(self) 111 | return {'FINISHED'} 112 | 113 | class GAME_EXPORT_OT_collection_remove(bpy.types.Operator): 114 | '''Remove collection to export''' 115 | bl_idname = 'game_export.collection_remove' 116 | bl_label = 'Remove Collection' 117 | bl_options = {'UNDO'} 118 | 119 | @classmethod 120 | def poll(cls, context): 121 | return len(context.scene.gameExportSettings.collectionList) != 0 122 | 123 | def execute(self, context): 124 | settings = context.scene.gameExportSettings 125 | settings.collectionList.remove(settings.collectionListIndex) 126 | if(settings.collectionListIndex > 0): settings.collectionListIndex -= 1 127 | return {'FINISHED'} 128 | 129 | class GAME_EXPORT_OT_collection_move_up(bpy.types.Operator): 130 | '''Remove collection to export''' 131 | bl_idname = 'game_export.collection_move_up' 132 | bl_label = 'Move Collection Up' 133 | bl_options = {'UNDO'} 134 | 135 | @classmethod 136 | def poll(cls, context): 137 | return context.scene.gameExportSettings.collectionListIndex > 0 138 | 139 | def execute(self, context): 140 | settings = context.scene.gameExportSettings 141 | index = settings.collectionListIndex 142 | settings.collectionList.move(index, index - 1) 143 | settings.collectionListIndex -= 1 144 | return {'FINISHED'} 145 | 146 | class GAME_EXPORT_OT_collection_move_down(bpy.types.Operator): 147 | '''Remove collection to export''' 148 | bl_idname = 'game_export.collection_move_down' 149 | bl_label = 'Move Collection Up' 150 | bl_options = {'UNDO'} 151 | 152 | @classmethod 153 | def poll(cls, context): 154 | return len(context.scene.gameExportSettings.collectionList) - 1 > context.scene.gameExportSettings.collectionListIndex 155 | 156 | def execute(self, context): 157 | settings = context.scene.gameExportSettings 158 | index = settings.collectionListIndex 159 | settings.collectionList.move(index, index + 1) 160 | settings.collectionListIndex += 1 161 | return {'FINISHED'} 162 | 163 | class GAME_EXPORT_UL_collection_list(bpy.types.UIList): 164 | bl_idname = 'GAME_EXPORT_UL_collection_list' 165 | def draw_item(self, context, layout: bpy.types.UILayout, data, item, icon, active_data, active_propname, index): 166 | useIcon = 'OUTLINER_COLLECTION' 167 | if(item.collection.color_tag != 'NONE'): useIcon = 'COLLECTION_' + item.collection.color_tag 168 | layout.label(text = item.exportName, icon = useIcon) 169 | layout.prop(item, 'shouldExport', text = '', emboss = False, icon = 'CHECKBOX_HLT' if item.shouldExport else 'CHECKBOX_DEHLT') 170 | 171 | class GAME_EXPORT_OT_collection_static(bpy.types.Operator): 172 | '''Set collection Static type''' 173 | bl_idname = 'game_export.collection_static' 174 | bl_label = 'Set static type' 175 | bl_options = {'UNDO'} 176 | 177 | setTypeOptions = ['NOTHING','EVERYTHING', 'CONTRIBUTE_GI', 'OCCLUDER_STATIC', 'BATCHING_STATIC', 'NAVIGATION_STATIC', 'OCCLUDEE_STATIC', 'OFF_MESH_LINK_GENERATION', 'REFLECTION_PROBE_STATIC'] 178 | 179 | setType: bpy.props.EnumProperty(name='Set Type', items=[ 180 | ('NOTHING', 'Nothing', ''), 181 | ('EVERYTHING', 'Everything', ''), 182 | ('CONTRIBUTE_GI', 'Contribute GI', ''), 183 | ('OCCLUDER_STATIC', 'Occluder Static', ''), 184 | ('BATCHING_STATIC', 'Batching Static', ''), 185 | ('NAVIGATION_STATIC', 'Navigation Static', ''), 186 | ('OCCLUDEE_STATIC', 'Occludee Static', ''), 187 | ('OFF_MESH_LINK_GENERATION', 'Off Mesh Link Generation', ''), 188 | ('REFLECTION_PROBE_STATIC', 'Reflection Probe Static', ''), 189 | ]) 190 | 191 | value: bpy.props.BoolProperty(name='Set Value') 192 | 193 | @classmethod 194 | def poll(cls, context: bpy.types.Context): 195 | settings = context.scene.gameExportSettings 196 | return len(settings.collectionList) != 0 197 | 198 | def execute(self, context): 199 | settings = context.scene.gameExportSettings 200 | collectionSettings = settings.collectionList[settings.collectionListIndex] 201 | if self.setType == 'NOTHING': collectionSettings.static = [not self.value] * 7 202 | elif self.setType == 'EVERYTHING': collectionSettings.static = [self.value] * 7 203 | else: collectionSettings.static[GAME_EXPORT_OT_collection_static.setTypeOptions.index(self.setType) - 2] = self.value 204 | context.area.tag_redraw() 205 | return {'FINISHED'} 206 | 207 | class GAME_EXPORT_PT_collection_static(bpy.types.Panel): 208 | bl_idname = 'GAME_EXPORT_PT_collection_static' 209 | bl_label = 'Collection Static' 210 | bl_space_type = 'VIEW_3D' 211 | bl_region_type = 'HEADER' 212 | bl_category = '' 213 | 214 | @classmethod 215 | def poll(cls, context: bpy.types.Context): 216 | settings = context.scene.gameExportSettings 217 | return len(settings.collectionList) != 0 218 | 219 | def drawSummary(context: bpy.types.Context, layout: bpy.types.UILayout): 220 | settings = context.scene.gameExportSettings 221 | collectionSettings = settings.collectionList[settings.collectionListIndex] 222 | isNothingStatic = not any(collectionSettings.static) 223 | isEverythingStatic = all(collectionSettings.static) 224 | 225 | if isNothingStatic: icon = 'BLANK1' 226 | elif isEverythingStatic: icon = 'CHECKMARK' 227 | else: icon = 'REMOVE' 228 | 229 | row = layout.row(align=True) 230 | row.label(text='Static') 231 | 232 | sub = row.row(align=True) 233 | g = sub.operator(GAME_EXPORT_OT_collection_static.__name__, text='', icon=icon, depress=isEverythingStatic) 234 | g.setType = 'EVERYTHING' 235 | g.value = not isEverythingStatic 236 | 237 | row.popover(panel=GAME_EXPORT_PT_collection_static.__name__, text='', icon='DOWNARROW_HLT') 238 | 239 | def draw(self, context: bpy.types.Context): 240 | settings = context.scene.gameExportSettings 241 | collectionSettings = settings.collectionList[settings.collectionListIndex] 242 | 243 | self.layout.label(text='Static') 244 | column = self.layout.column(align=True) 245 | for i in [0, 1, 2, 3, 6, 4, 5, 7, 8]: 246 | if i == 0: isOn = not any(collectionSettings.static) 247 | elif i == 1: isOn = all(collectionSettings.static) 248 | else: isOn = collectionSettings.static[i - 2] 249 | 250 | row = column.row(align=True) 251 | g = row.operator(GAME_EXPORT_OT_collection_static.__name__, text=['Nothing', 'Everything', 'Contribute GI', 'Occluder Static', 'Batching Static', 'Navigation Static', 'Occludee Static', 'Off Mesh Link Generation', 'Reflection Probe'][i], icon='CHECKMARK' if isOn else 'BLANK1', depress=isOn) 252 | g.setType = GAME_EXPORT_OT_collection_static.setTypeOptions[i] 253 | g.value = not isOn 254 | 255 | def collectionQuickToggleEnumEdit(name: str, codeName: str, enum, propName: str, shortName: 'list[str] | None' = None): 256 | class GAME_EXPORT_OT_collection_(bpy.types.Operator): 257 | bl_description = f'Set {name} Type' 258 | bl_idname = f'game_export.collection_{codeName}' 259 | bl_label = f'{name} Type' 260 | bl_options = {'UNDO'} 261 | 262 | value: bpy.props.EnumProperty(name='Set Value', items=enum) 263 | 264 | @classmethod 265 | def poll(cls, context: bpy.types.Context): 266 | settings = context.scene.gameExportSettings 267 | return len(settings.collectionList) != 0 268 | 269 | def execute(self, context): 270 | settings = context.scene.gameExportSettings 271 | collectionSettings = settings.collectionList[settings.collectionListIndex] 272 | setattr(collectionSettings, propName, self.value) 273 | context.area.tag_redraw() 274 | return {'FINISHED'} 275 | 276 | class GAME_EXPORT_PT_collection_(bpy.types.Panel): 277 | bl_idname = f'GAME_EXPORT_PT_collection_{codeName}' 278 | bl_label = f'Collection {name}' 279 | bl_space_type = 'VIEW_3D' 280 | bl_region_type = 'HEADER' 281 | bl_category = '' 282 | 283 | @classmethod 284 | def poll(cls, context: bpy.types.Context): 285 | settings = context.scene.gameExportSettings 286 | return len(settings.collectionList) != 0 287 | 288 | def drawSummary(context: bpy.types.Context, layout: bpy.types.UILayout): 289 | settings = context.scene.gameExportSettings 290 | collectionSettings = settings.collectionList[settings.collectionListIndex] 291 | 292 | isOn = getattr(collectionSettings, propName) != enum[0][0] 293 | 294 | if shortName: value = shortName[[i[0] for i in enum].index(getattr(collectionSettings, propName))] 295 | else: value = '' 296 | 297 | row = layout.row(align=True) 298 | row.label(text=name) 299 | 300 | sub = row.row(align=True) 301 | sub.alignment = 'RIGHT' 302 | g = sub.operator(f'GAME_EXPORT_OT_collection_{codeName}', text=value, icon='CHECKMARK' if isOn else 'BLANK1', depress=isOn) 303 | g.value = enum[0][0] if isOn else enum[1][0] 304 | 305 | row.popover(f'GAME_EXPORT_PT_collection_{codeName}', text='', icon='DOWNARROW_HLT') 306 | 307 | def draw(self, context: bpy.types.Context): 308 | settings = context.scene.gameExportSettings 309 | collectionSettings = settings.collectionList[settings.collectionListIndex] 310 | value = getattr(collectionSettings, propName) 311 | 312 | self.layout.label(text=name) 313 | column = self.layout.column(align=True) 314 | for enumValue in enum: 315 | isOn = (value == enumValue[0]) 316 | 317 | row = column.row(align=True) 318 | g = row.operator(f'GAME_EXPORT_OT_collection_{codeName}', text=enumValue[1], icon='CHECKMARK' if isOn else 'BLANK1', depress=isOn) 319 | g.value = enumValue[0] 320 | 321 | return (GAME_EXPORT_OT_collection_, GAME_EXPORT_PT_collection_) 322 | 323 | (GAME_EXPORT_OT_collection_mesh_render, GAME_EXPORT_PT_collection_mesh_render) = collectionQuickToggleEnumEdit('Mesh Render', 'mesh_render', meshRenderEnum[1:], 'meshRender') 324 | (GAME_EXPORT_OT_collection_light_render, GAME_EXPORT_PT_collection_light_render) = collectionQuickToggleEnumEdit('Light Render', 'light_render', lightRenderEnum[1:], 'lightRender') 325 | (GAME_EXPORT_OT_collection_collider, GAME_EXPORT_PT_collection_collider) = collectionQuickToggleEnumEdit('Collider', 'collider', colliderEnum[1:], 'collider', ['', 'Box', 'Mesh']) 326 | 327 | class GAME_EXPORT_PT_settings(bpy.types.Panel): 328 | bl_idname = 'GAME_EXPORT_PT_settings' 329 | bl_label = 'Game Export' 330 | bl_space_type = 'VIEW_3D' 331 | bl_region_type = 'UI' 332 | bl_category = 'GameExport' 333 | 334 | def draw(self, context): 335 | settings = context.scene.gameExportSettings 336 | self.layout.operator(GAME_EXPORT_OT_export.bl_idname, text = 'Export') 337 | self.layout.prop(settings, 'filePath', text = '') 338 | 339 | row = self.layout.row() 340 | col = row.column() 341 | col.template_list(GAME_EXPORT_UL_collection_list.bl_idname, '', settings, 'collectionList', settings, 'collectionListIndex', item_dyntip_propname = '') 342 | col = row.column(align=True) 343 | col.operator(GAME_EXPORT_OT_collection_add.bl_idname, icon='ADD', text='') 344 | col.operator(GAME_EXPORT_OT_collection_remove.bl_idname, icon='REMOVE', text='') 345 | col.separator() 346 | col.operator(GAME_EXPORT_OT_collection_move_up.bl_idname, icon='TRIA_UP', text='') 347 | col.operator(GAME_EXPORT_OT_collection_move_down.bl_idname, icon='TRIA_DOWN', text='') 348 | 349 | if(len(settings.collectionList) != 0): 350 | collectionSettings = settings.collectionList[settings.collectionListIndex] 351 | useIcon = 'OUTLINER_COLLECTION' 352 | if(collectionSettings.collection.color_tag != 'NONE'): useIcon = 'COLLECTION_' + collectionSettings.collection.color_tag 353 | self.layout.label(text = collectionSettings.collection.name, icon = useIcon) 354 | self.layout.prop(collectionSettings, 'exportName', text = '') 355 | self.layout.prop(collectionSettings, 'lightMultiplier') 356 | self.layout.prop(collectionSettings, 'fixFBXTextureTint') 357 | 358 | class GAME_EXPORT_PT_settings_inherit(bpy.types.Panel): 359 | bl_idname = 'GAME_EXPORT_PT_settings_inherit' 360 | bl_label = 'Inherit Values' 361 | bl_space_type = 'VIEW_3D' 362 | bl_region_type = 'UI' 363 | bl_parent_id = 'GAME_EXPORT_PT_settings' 364 | 365 | @classmethod 366 | def poll(cls, context: bpy.types.Context): 367 | settings = context.scene.gameExportSettings 368 | return len(settings.collectionList) != 0 369 | 370 | def draw(self, context): 371 | GAME_EXPORT_PT_collection_static.drawSummary(context, self.layout) 372 | GAME_EXPORT_PT_collection_mesh_render.drawSummary(context, self.layout) 373 | GAME_EXPORT_PT_collection_light_render.drawSummary(context, self.layout) 374 | GAME_EXPORT_PT_collection_collider.drawSummary(context, self.layout) 375 | 376 | class GAME_EXPORT_OT_collection_material_link_search_files(bpy.types.Operator): 377 | bl_description = 'Search folder for material links' 378 | bl_idname = 'game_export.collection_material_link_search_files' 379 | bl_label = 'Search Folder' 380 | bl_options = {'UNDO'} 381 | 382 | relativeFilePath: bpy.props.BoolProperty(name='Relative Path', default=True) 383 | replaceLinked: bpy.props.BoolProperty(name='Replace Linked Materials', description='Replace already linked materials if found in folder', default=False) 384 | filepath: bpy.props.StringProperty(subtype='DIR_PATH') 385 | 386 | @classmethod 387 | def poll(cls, context: bpy.types.Context): 388 | settings = context.scene.gameExportSettings 389 | return len(settings.collectionList) != 0 390 | 391 | def draw(self, context): 392 | import textwrap 393 | box = self.layout.box() 394 | column = box.column(align = True) 395 | text = 'Save (Ctrl-S) before running. If opened on a large folder this operation could take a while. The only way to stop is to force quit blender.' 396 | wrapper = textwrap.TextWrapper(max(int(context.region.width / 8), 6)) 397 | textLines = wrapper.wrap(text=text) 398 | for textLine in textLines: 399 | column.label(text=textLine) 400 | 401 | self.layout.prop(self, 'relativeFilePath') 402 | self.layout.prop(self, 'replaceLinked') 403 | 404 | def execute(self, context): 405 | settings = context.scene.gameExportSettings 406 | if len(settings.collectionList) == 0: 407 | def draw(self, context): self.layout.label(text = 'No export collection to edit') 408 | context.window_manager.popup_menu(draw, title = 'No export collection', icon = 'ERROR') 409 | return { 'CANCELLED' } 410 | collectionSettings = settings.collectionList[settings.collectionListIndex] 411 | 412 | searchFolder = os.path.dirname(bpy.path.abspath(self.filepath)) 413 | 414 | if not os.path.exists(searchFolder): 415 | def draw(self, context): self.layout.label(text = 'Likely: Folder does not exist') 416 | context.window_manager.popup_menu(draw, title = 'Invalid folder path', icon = 'IMPORT') 417 | return { 'CANCELLED' } 418 | 419 | materialFiles: 'dict[str, str]' = {} 420 | 421 | for walkFolder in os.walk(searchFolder): 422 | for item in walkFolder[2]: 423 | if os.path.splitext(item)[1] not in ['.mat']: continue 424 | materialFiles[os.path.splitext(item)[0].lower()] = os.path.join(walkFolder[0], item) 425 | 426 | materialNameSet = materialNameSetGet(collectionSettings.collection) 427 | materialLinksNameLookup = materialLinksNameLookupGet(collectionSettings) 428 | 429 | addCount = 0 430 | updateCount = 0 431 | unlinkedCount = 0 432 | 433 | for materialName in materialNameSet: 434 | materialLink = materialLinksNameLookup.get(materialName) 435 | if materialLink == None: unlinkedCount += 1 436 | if materialLink != None and not self.replaceLinked: continue 437 | 438 | file = materialFiles.get(materialName.lower()) 439 | if file == None: continue 440 | 441 | if materialLink == None: 442 | addCount += 1 443 | materialLink = collectionSettings.materialLinks.add() 444 | materialLink.material = bpy.data.materials[materialName] 445 | else: 446 | updateCount += 1 447 | 448 | if self.relativeFilePath: materialLink.filePath = bpy.path.relpath(file) 449 | else: materialLink.filePath = file 450 | 451 | unlinkedCount -= addCount 452 | 453 | def draw(self, context): 454 | self.layout.label(text=f'{addCount} material link{"s" * (addCount != 1)} add') 455 | self.layout.label(text=f'{updateCount} material link{"s" * (updateCount != 1)} updated') 456 | self.layout.label(text=f'{unlinkedCount} material{"s" * (unlinkedCount != 1)} still unlinked') 457 | context.window_manager.popup_menu(draw, title = 'Search Folder Complete', icon = 'VIEWZOOM') 458 | 459 | context.area.tag_redraw() 460 | return {'FINISHED'} 461 | 462 | def invoke(self, context, event): 463 | context.window_manager.fileselect_add(self) 464 | return {'RUNNING_MODAL'} 465 | 466 | class GAME_EXPORT_OT_collection_material_link_clean(bpy.types.Operator): 467 | bl_description = 'Checks for linked material files and removes any that no longer exist' 468 | bl_idname = 'game_export.collection_material_link_clean' 469 | bl_label = 'Check Material Links' 470 | bl_options = {'UNDO'} 471 | 472 | @classmethod 473 | def poll(cls, context: bpy.types.Context): 474 | settings = context.scene.gameExportSettings 475 | return len(settings.collectionList) != 0 476 | 477 | def execute(self, context): 478 | settings = context.scene.gameExportSettings 479 | collectionSettings = settings.collectionList[settings.collectionListIndex] 480 | 481 | removedCount = 0 482 | 483 | removeIndexes = [] 484 | for i, materialLink in enumerate(collectionSettings.materialLinks): 485 | if materialLink.material == None: 486 | removeIndexes.append(i) 487 | continue 488 | 489 | if not os.path.isfile(bpy.path.abspath(materialLink.filePath)): 490 | removedCount += 1 491 | removeIndexes.append(i) 492 | 493 | for index in removeIndexes: 494 | collectionSettings.materialLinks.remove(index) 495 | 496 | def draw(self, context): 497 | self.layout.label(text=f'{removedCount} material link{"s" * (removedCount != 1)} removed') 498 | context.window_manager.popup_menu(draw, title = 'Check Material Links', icon = 'VIEWZOOM') 499 | 500 | context.area.tag_redraw() 501 | return {'FINISHED'} 502 | 503 | class GAME_EXPORT_OT_collection_material_link_remove_all(bpy.types.Operator): 504 | bl_description = 'Remove all material links' 505 | bl_idname = 'game_export.collection_material_link_remove_all' 506 | bl_label = 'Remove All' 507 | bl_options = {'UNDO'} 508 | 509 | @classmethod 510 | def poll(cls, context: bpy.types.Context): 511 | settings = context.scene.gameExportSettings 512 | return len(settings.collectionList) != 0 513 | 514 | def execute(self, context): 515 | settings = context.scene.gameExportSettings 516 | collectionSettings = settings.collectionList[settings.collectionListIndex] 517 | 518 | while len(collectionSettings.materialLinks) > 0: 519 | collectionSettings.materialLinks.remove(0) 520 | 521 | context.area.tag_redraw() 522 | return {'FINISHED'} 523 | 524 | class GAME_EXPORT_OT_collection_material_link_remove(bpy.types.Operator): 525 | bl_description = 'Remove material link' 526 | bl_idname = 'game_export.collection_material_link_remove' 527 | bl_label = 'Remove Material Link' 528 | bl_options = {'UNDO'} 529 | 530 | materialName: bpy.props.StringProperty() 531 | 532 | @classmethod 533 | def poll(cls, context: bpy.types.Context): 534 | settings = context.scene.gameExportSettings 535 | return len(settings.collectionList) != 0 536 | 537 | def execute(self, context): 538 | settings = context.scene.gameExportSettings 539 | collectionSettings = settings.collectionList[settings.collectionListIndex] 540 | 541 | materialLinkIndex = -1 542 | for i, materialLink in enumerate(collectionSettings.materialLinks): 543 | if materialLink.material == None: continue 544 | if materialLink.material.name == self.materialName: 545 | materialLinkIndex = i 546 | break 547 | 548 | if materialLinkIndex != -1: 549 | collectionSettings.materialLinks.remove(materialLinkIndex) 550 | context.area.tag_redraw() 551 | return {'FINISHED'} 552 | 553 | class GAME_EXPORT_OT_collection_material_link_set(bpy.types.Operator): 554 | bl_description = 'Set material link' 555 | bl_idname = 'game_export.collection_material_link_set' 556 | bl_label = 'Set Material Link' 557 | bl_options = {'UNDO'} 558 | 559 | relativeFilePath: bpy.props.BoolProperty(name='Relative Path', default=True) 560 | materialName: bpy.props.StringProperty() 561 | filepath: bpy.props.StringProperty(subtype='FILE_PATH') 562 | 563 | @classmethod 564 | def poll(cls, context: bpy.types.Context): 565 | settings = context.scene.gameExportSettings 566 | return len(settings.collectionList) != 0 567 | 568 | def draw(self, context): 569 | self.layout.prop(self, 'relativeFilePath') 570 | self.layout.label(text='Select Material File For') 571 | self.layout.label(text=self.materialName) 572 | 573 | def execute(self, context): 574 | settings = context.scene.gameExportSettings 575 | if len(settings.collectionList) == 0: 576 | def draw(self, context): self.layout.label(text = 'No export collection to edit') 577 | context.window_manager.popup_menu(draw, title = 'No export collection', icon = 'ERROR') 578 | return { 'CANCELLED' } 579 | collectionSettings = settings.collectionList[settings.collectionListIndex] 580 | 581 | if bpy.path.basename(self.filepath) == '': 582 | def draw(self, context): self.layout.label(text = 'Select a file, not a folder') 583 | context.window_manager.popup_menu(draw, title = 'Not a file', icon = 'IMPORT') 584 | return { 'CANCELLED' } 585 | 586 | editMaterialLink = None 587 | for materialLink in collectionSettings.materialLinks: 588 | if materialLink.material == None: continue 589 | if materialLink.material.name == self.materialName: 590 | editMaterialLink = materialLink 591 | break 592 | 593 | if editMaterialLink == None: 594 | try: material = bpy.data.materials[self.materialName] 595 | except: return {'CANCELLED'} 596 | editMaterialLink = collectionSettings.materialLinks.add() 597 | editMaterialLink.material = material 598 | 599 | if self.relativeFilePath: editMaterialLink.filePath = bpy.path.relpath(self.filepath) 600 | else: editMaterialLink.filePath = self.filepath 601 | context.area.tag_redraw() 602 | return {'FINISHED'} 603 | 604 | def invoke(self, context, event): 605 | context.window_manager.fileselect_add(self) 606 | return {'RUNNING_MODAL'} 607 | 608 | class GAME_EXPORT_PT_settings_material_link(bpy.types.Panel): 609 | bl_idname = 'GAME_EXPORT_PT_settings_material_link' 610 | bl_label = 'Material Links' 611 | bl_space_type = 'VIEW_3D' 612 | bl_region_type = 'UI' 613 | bl_parent_id = 'GAME_EXPORT_PT_settings' 614 | bl_options = {'DEFAULT_CLOSED'} 615 | 616 | @classmethod 617 | def poll(cls, context: bpy.types.Context): 618 | settings = context.scene.gameExportSettings 619 | return len(settings.collectionList) != 0 620 | 621 | def draw(self, context): 622 | settings = context.scene.gameExportSettings 623 | collectionSettings = settings.collectionList[settings.collectionListIndex] 624 | materialNameSet = materialNameSetGet(collectionSettings.collection) 625 | materialLinksNameLookup = materialLinksNameLookupGet(collectionSettings) 626 | 627 | linkedCount = 0 628 | unlinkedCount = 0 629 | 630 | for materialName in materialNameSet: 631 | if materialLinksNameLookup.get(materialName) == None: unlinkedCount += 1 632 | else: linkedCount += 1 633 | 634 | self.layout.operator(GAME_EXPORT_OT_collection_material_link_search_files.__name__, icon='VIEWZOOM') 635 | self.layout.operator(GAME_EXPORT_OT_collection_material_link_clean.__name__, icon='FILE_REFRESH') 636 | self.layout.operator(GAME_EXPORT_OT_collection_material_link_remove_all.__name__, icon='TRASH') 637 | 638 | infoColumn = self.layout.column(align=True) 639 | infoColumn.label(text=f'{linkedCount} material{"s" * (linkedCount != 1)} linked') 640 | infoColumn.label(text=f'{unlinkedCount} material{"s" * (unlinkedCount != 1)} not linked') 641 | 642 | class GAME_EXPORT_PT_settings_material_link_manual(bpy.types.Panel): 643 | bl_idname = 'GAME_EXPORT_PT_settings_material_link_manual' 644 | bl_label = 'Manually Edit' 645 | bl_space_type = 'VIEW_3D' 646 | bl_region_type = 'UI' 647 | bl_parent_id = 'GAME_EXPORT_PT_settings_material_link' 648 | bl_options = {'DEFAULT_CLOSED'} 649 | 650 | @classmethod 651 | def poll(cls, context: bpy.types.Context): 652 | settings = context.scene.gameExportSettings 653 | return len(settings.collectionList) != 0 654 | 655 | def draw(self, context): 656 | settings = context.scene.gameExportSettings 657 | collectionSettings = settings.collectionList[settings.collectionListIndex] 658 | 659 | materialNameList = [*materialNameSetGet(collectionSettings.collection)] 660 | materialNameList.sort(key=str.casefold) 661 | materialLinksNameLookup = materialLinksNameLookupGet(collectionSettings) 662 | 663 | grid = self.layout.grid_flow(align=True, columns=max(math.floor(context.region.width / 300), 1)) 664 | 665 | for materialName in materialNameList: 666 | row = grid.row() 667 | row.label(text=materialName) 668 | 669 | materialLink = materialLinksNameLookup.get(materialName) 670 | haveLink = (materialLink != None) 671 | if haveLink: text = bpy.path.display_name_from_filepath(materialLink.filePath) 672 | else: text = 'None' 673 | 674 | buttonGroup = row.row(align=True) 675 | sub = buttonGroup.row(align=True) 676 | sub.alignment = 'RIGHT' 677 | sub.operator(GAME_EXPORT_OT_collection_material_link_remove.__name__, text=text, icon='CHECKMARK' if haveLink else 'NONE', depress=haveLink).materialName = materialName 678 | sub.enabled = haveLink 679 | op = buttonGroup.operator(GAME_EXPORT_OT_collection_material_link_set.__name__, text='', icon='FILE_FOLDER') 680 | op.materialName = materialName 681 | if haveLink: op.filepath = materialLink.filePath 682 | 683 | classes = ( 684 | GAME_EXPORT_OT_show_export_info, 685 | GAME_EXPORT_OT_export, 686 | GAME_EXPORT_OT_collection_add, 687 | GAME_EXPORT_OT_collection_remove, 688 | GAME_EXPORT_OT_collection_move_up, 689 | GAME_EXPORT_OT_collection_move_down, 690 | GAME_EXPORT_UL_collection_list, 691 | GAME_EXPORT_OT_collection_static, 692 | GAME_EXPORT_PT_collection_static, 693 | GAME_EXPORT_OT_collection_mesh_render, 694 | GAME_EXPORT_PT_collection_mesh_render, 695 | GAME_EXPORT_OT_collection_light_render, 696 | GAME_EXPORT_PT_collection_light_render, 697 | GAME_EXPORT_OT_collection_collider, 698 | GAME_EXPORT_PT_collection_collider, 699 | GAME_EXPORT_PT_settings, 700 | GAME_EXPORT_PT_settings_inherit, 701 | GAME_EXPORT_OT_collection_material_link_search_files, 702 | GAME_EXPORT_OT_collection_material_link_clean, 703 | GAME_EXPORT_OT_collection_material_link_remove_all, 704 | GAME_EXPORT_OT_collection_material_link_remove, 705 | GAME_EXPORT_OT_collection_material_link_set, 706 | GAME_EXPORT_PT_settings_material_link, 707 | GAME_EXPORT_PT_settings_material_link_manual, 708 | ) 709 | 710 | def register(): 711 | for cls in classes: bpy.utils.register_class(cls) 712 | 713 | def unregister(): 714 | for cls in reversed(classes): bpy.utils.unregister_class(cls) -------------------------------------------------------------------------------- /panel_objectSettings.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from .settings import * 3 | 4 | class GAME_EXPORT_OT_selection_static(bpy.types.Operator): 5 | '''Set Selection Static type''' 6 | bl_idname = 'game_export.selection_static' 7 | bl_label = 'Set static type' 8 | bl_options = {'UNDO'} 9 | 10 | setTypeOptions = ['INHERIT', 'NOTHING','EVERYTHING', 'CONTRIBUTE_GI', 'OCCLUDER_STATIC', 'BATCHING_STATIC', 'NAVIGATION_STATIC', 'OCCLUDEE_STATIC', 'OFF_MESH_LINK_GENERATION', 'REFLECTION_PROBE_STATIC'] 11 | 12 | setType: bpy.props.EnumProperty(name='Set Type', items=[ 13 | ('INHERIT', 'Inherit', ''), 14 | ('NOTHING', 'Nothing', ''), 15 | ('EVERYTHING', 'Everything', ''), 16 | ('CONTRIBUTE_GI', 'Contribute GI', ''), 17 | ('OCCLUDER_STATIC', 'Occluder Static', ''), 18 | ('BATCHING_STATIC', 'Batching Static', ''), 19 | ('NAVIGATION_STATIC', 'Navigation Static', ''), 20 | ('OCCLUDEE_STATIC', 'Occludee Static', ''), 21 | ('OFF_MESH_LINK_GENERATION', 'Off Mesh Link Generation', ''), 22 | ('REFLECTION_PROBE_STATIC', 'Reflection Probe Static', ''), 23 | ]) 24 | 25 | value: bpy.props.BoolProperty(name='Set Value') 26 | 27 | @classmethod 28 | def poll(cls, context: bpy.types.Context): 29 | return len(context.selected_objects) > 0 30 | 31 | def execute(self, context): 32 | for object in bpy.context.selected_objects: 33 | if self.setType == 'INHERIT': object.gameExportSettings.static[0] = self.value 34 | elif self.setType == 'NOTHING': object.gameExportSettings.static[1:] = [not self.value] * 7 35 | elif self.setType == 'EVERYTHING': object.gameExportSettings.static[1:] = [self.value] * 7 36 | else: object.gameExportSettings.static[GAME_EXPORT_OT_selection_static.setTypeOptions.index(self.setType) - 2] = self.value 37 | context.area.tag_redraw() 38 | return {'FINISHED'} 39 | 40 | class GAME_EXPORT_PT_selection_static(bpy.types.Panel): 41 | bl_idname = 'GAME_EXPORT_PT_selection_static' 42 | bl_label = 'Selection Static' 43 | bl_space_type = 'VIEW_3D' 44 | bl_region_type = 'HEADER' 45 | bl_category = '' 46 | 47 | @classmethod 48 | def poll(cls, context: bpy.types.Context): 49 | return len(context.selected_objects) > 0 50 | 51 | def getSelectionInfo(context: bpy.types.Context, index: int): 52 | settings = context.scene.gameExportSettings 53 | if len(settings.collectionList) != 0: staticInherit = settings.collectionList[settings.collectionListIndex].static 54 | else: staticInherit = [True] * 7 55 | 56 | def objectStaticInfo(static: 'list[bool]'): 57 | if index == 0: return static[0] 58 | static = staticInherit if static[0] else static[1:] 59 | if index == 1: return not any(static) 60 | if index == 2: return all(static) 61 | return static[index - 3] 62 | 63 | value = objectStaticInfo(context.selected_objects[0].gameExportSettings.static) 64 | for object in context.selected_objects[1:]: 65 | if value != objectStaticInfo(object.gameExportSettings.static): 66 | return 1 67 | if value: return 2 68 | return 0 69 | 70 | def drawSummary(context: bpy.types.Context, layout: bpy.types.UILayout): 71 | isInherit = GAME_EXPORT_PT_selection_static.getSelectionInfo(context, 0) 72 | isNothingStatic = GAME_EXPORT_PT_selection_static.getSelectionInfo(context, 1) 73 | isEverythingStatic = GAME_EXPORT_PT_selection_static.getSelectionInfo(context, 2) 74 | 75 | if isInherit == 2: icon = 'DUPLICATE' 76 | elif isInherit == 1: icon = 'REMOVE' 77 | elif isNothingStatic == 2: icon = 'BLANK1' 78 | elif isEverythingStatic == 2: icon = 'CHECKMARK' 79 | else: icon = 'REMOVE' 80 | 81 | row = layout.row(align=True) 82 | row.label(text=f'Static{" (Inherit)" * (isInherit == 2)}') 83 | 84 | sub = row.row(align=True) 85 | g = sub.operator(GAME_EXPORT_OT_selection_static.__name__, text='', icon=icon, depress=(isEverythingStatic == 2)) 86 | g.setType = 'EVERYTHING' 87 | g.value = (isEverythingStatic != 2) 88 | if isInherit != 0: sub.enabled = False 89 | 90 | row.popover(panel=GAME_EXPORT_PT_selection_static.__name__, text='', icon='DOWNARROW_HLT') 91 | 92 | def draw(self, context: bpy.types.Context): 93 | self.layout.label(text='Static') 94 | column = self.layout.column(align=True) 95 | for i in [0, 1, 2, 3, 4, 7, 5, 6, 8, 9]: 96 | value = GAME_EXPORT_PT_selection_static.getSelectionInfo(context, i) 97 | if i == 0: isInherit = value 98 | 99 | row = column.row(align=True) 100 | g = row.operator(GAME_EXPORT_OT_selection_static.__name__, text=['Inherit', 'Nothing', 'Everything', 'Contribute GI', 'Occluder Static', 'Batching Static', 'Navigation Static', 'Occludee Static', 'Off Mesh Link Generation', 'Reflection Probe'][i], icon=['BLANK1', 'REMOVE', 'CHECKMARK'][value], depress=(value == 2)) 101 | g.setType = GAME_EXPORT_OT_selection_static.setTypeOptions[i] 102 | g.value = (value != 2) 103 | if i != 0 and isInherit != 0: row.enabled = False 104 | 105 | def selectionQuickToggleEnumEdit(name: str, codeName: str, enum, propName: str, shortName: 'list[str] | None' = None): 106 | class GAME_EXPORT_OT_selection_(bpy.types.Operator): 107 | bl_description = f'Set Selection {name} Type' 108 | bl_idname = f'game_export.selection_{codeName}' 109 | bl_label = f'{name} Type' 110 | bl_options = {'UNDO'} 111 | 112 | value: bpy.props.EnumProperty(name='Set Value', items=[*enum, ('LAZY_ON', 'Lazy on', '')]) 113 | 114 | @classmethod 115 | def poll(cls, context: bpy.types.Context): 116 | return len(context.selected_objects) > 0 117 | 118 | def execute(self, context): 119 | for object in bpy.context.selected_objects: 120 | if self.value == 'LAZY_ON': 121 | if getattr(object.gameExportSettings, propName) == enum[1][0]: 122 | setattr(object.gameExportSettings, propName, enum[2][0]) 123 | else: setattr(object.gameExportSettings, propName, self.value) 124 | context.area.tag_redraw() 125 | return {'FINISHED'} 126 | 127 | class GAME_EXPORT_PT_selection_(bpy.types.Panel): 128 | bl_idname = f'GAME_EXPORT_PT_selection_{codeName}' 129 | bl_label = f'Selection {name}' 130 | bl_space_type = 'VIEW_3D' 131 | bl_region_type = 'HEADER' 132 | bl_category = '' 133 | 134 | @classmethod 135 | def poll(cls, context: bpy.types.Context): 136 | return len(context.selected_objects) > 0 137 | 138 | def getSelectionInfo(context: bpy.types.Context, index: int): 139 | settings = context.scene.gameExportSettings 140 | if len(settings.collectionList) != 0: inheritValue = getattr(settings.collectionList[settings.collectionListIndex], propName) 141 | else: inheritValue = enum[2][0] 142 | 143 | def objectStaticInfo(value: str): 144 | if index == 0: return value == 'INHERIT' 145 | value = inheritValue if value == 'INHERIT' else value 146 | return value == enum[index][0] 147 | 148 | value = objectStaticInfo(getattr(context.selected_objects[0].gameExportSettings, propName)) 149 | for object in context.selected_objects[1:]: 150 | if value != objectStaticInfo(getattr(object.gameExportSettings, propName)): 151 | return 1 152 | if value: return 2 153 | return 0 154 | 155 | def drawSummary(context: bpy.types.Context, layout: bpy.types.UILayout): 156 | isInherit = GAME_EXPORT_PT_selection_.getSelectionInfo(context, 0) 157 | isOff = GAME_EXPORT_PT_selection_.getSelectionInfo(context, 1) 158 | 159 | if shortName: 160 | settings = context.scene.gameExportSettings 161 | if len(settings.collectionList) != 0: inheritValue = getattr(settings.collectionList[settings.collectionListIndex], propName) 162 | else: inheritValue = enum[2][0] 163 | def getValue(value): return inheritValue if value == 'INHERIT' else value 164 | 165 | value = getValue(getattr(context.selected_objects[0].gameExportSettings, propName)) 166 | for object in context.selected_objects[1:]: 167 | if value != getValue(getattr(object.gameExportSettings, propName)): 168 | value = '' 169 | break 170 | if value: value = shortName[[i[0] for i in enum].index(value) - 1] 171 | else: value = '' 172 | 173 | if isInherit == 2: icon = 'DUPLICATE' 174 | elif isInherit == 1: icon = 'REMOVE' 175 | elif isOff == 2: icon = 'BLANK1' 176 | elif isOff == 0: icon = 'CHECKMARK' 177 | else: icon = 'REMOVE' 178 | 179 | row = layout.row(align=True) 180 | row.label(text=f'{name}{" (Inherit)" * (isInherit == 2)}') 181 | 182 | sub = row.row(align=True) 183 | sub.alignment = 'RIGHT' 184 | g = sub.operator(f'GAME_EXPORT_OT_selection_{codeName}', text=value, icon=icon, depress=(isOff == 0)) 185 | g.value = enum[1][0] if isOff == 0 else 'LAZY_ON' 186 | if isInherit != 0: sub.enabled = False 187 | 188 | row.popover(f'GAME_EXPORT_PT_selection_{codeName}', text='', icon='DOWNARROW_HLT') 189 | 190 | def draw(self, context: bpy.types.Context): 191 | self.layout.label(text=name) 192 | column = self.layout.column(align=True) 193 | for i, enumValue in enumerate(enum): 194 | value = GAME_EXPORT_PT_selection_.getSelectionInfo(context, i) 195 | if i == 0: isInherit = value 196 | 197 | row = column.row(align=True) 198 | g = row.operator(f'GAME_EXPORT_OT_selection_{codeName}', text=enumValue[1], icon=['BLANK1', 'REMOVE', 'CHECKMARK'][value], depress=(value == 2 and (i == 0 or isInherit == 0))) 199 | g.value = enumValue[0] 200 | 201 | return (GAME_EXPORT_OT_selection_, GAME_EXPORT_PT_selection_) 202 | 203 | (GAME_EXPORT_OT_selection_mesh_render, GAME_EXPORT_PT_selection_mesh_render) = selectionQuickToggleEnumEdit('Mesh Render', 'mesh_render', meshRenderEnum, 'meshRender') 204 | (GAME_EXPORT_OT_selection_light_render, GAME_EXPORT_PT_selection_light_render) = selectionQuickToggleEnumEdit('Light Render', 'light_render', lightRenderEnum, 'lightRender') 205 | (GAME_EXPORT_OT_selection_collider, GAME_EXPORT_PT_selection_collider) = selectionQuickToggleEnumEdit('Collider', 'collider', colliderEnum, 'collider', ['', 'Box', 'Mesh']) 206 | 207 | class GAME_EXPORT_PT_selection_settings(bpy.types.Panel): 208 | bl_idname = 'GAME_EXPORT_PT_selection_settings' 209 | bl_label = 'Selected Objects Settings' 210 | bl_space_type = 'VIEW_3D' 211 | bl_region_type = 'UI' 212 | bl_category = 'GameExport' 213 | 214 | @classmethod 215 | def poll(cls, context: bpy.types.Context): 216 | return len(context.selected_objects) > 0 217 | 218 | def draw(self, context: bpy.types.Context): 219 | self.layout.box().label(text=f'{len(context.selected_objects)} object{"s" if len(context.selected_objects) != 1 else ""} selected') 220 | 221 | GAME_EXPORT_PT_selection_static.drawSummary(context, self.layout) 222 | GAME_EXPORT_PT_selection_mesh_render.drawSummary(context, self.layout) 223 | GAME_EXPORT_PT_selection_light_render.drawSummary(context, self.layout) 224 | GAME_EXPORT_PT_selection_collider.drawSummary(context, self.layout) 225 | 226 | classes = ( 227 | GAME_EXPORT_OT_selection_static, 228 | GAME_EXPORT_PT_selection_static, 229 | GAME_EXPORT_OT_selection_mesh_render, 230 | GAME_EXPORT_PT_selection_mesh_render, 231 | GAME_EXPORT_OT_selection_light_render, 232 | GAME_EXPORT_PT_selection_light_render, 233 | GAME_EXPORT_OT_selection_collider, 234 | GAME_EXPORT_PT_selection_collider, 235 | GAME_EXPORT_PT_selection_settings, 236 | ) 237 | 238 | def register(): 239 | for cls in classes: bpy.utils.register_class(cls) 240 | 241 | def unregister(): 242 | for cls in reversed(classes): bpy.utils.unregister_class(cls) -------------------------------------------------------------------------------- /panel_preferences.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from .install import * 3 | 4 | class GAME_EXPORT_preferences(bpy.types.AddonPreferences): 5 | bl_idname = __package__ 6 | 7 | def draw(self, context): 8 | box = self.layout.box() 9 | GAME_EXPORT_OT_install_modules.drawTable(box) 10 | 11 | box = self.layout.box() 12 | if not GAME_EXPORT_OT_delete_data.poll(context=context): 13 | box.label(text='Data deleted') 14 | box.label(text='Turn the addon off and on to use again') 15 | box.operator(GAME_EXPORT_OT_delete_data.bl_idname) 16 | 17 | classes = ( 18 | GAME_EXPORT_preferences, 19 | ) 20 | 21 | def register(): 22 | for cls in classes: bpy.utils.register_class(cls) 23 | 24 | def unregister(): 25 | for cls in reversed(classes): bpy.utils.unregister_class(cls) -------------------------------------------------------------------------------- /preview001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codec-xyz/game_export/d154924ca4a10e461d9ab5098b3cf176c737967a/preview001.png -------------------------------------------------------------------------------- /preview002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codec-xyz/game_export/d154924ca4a10e461d9ab5098b3cf176c737967a/preview002.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Game Export Blender Addon 2 | 3 | Configure settings and export from Blender to Unity with one click. 4 | 5 | Exports collections as Unity prefabs along with mesh data in an fbx file. Lets you configure Unity static settings, material remapping, colliders, and more in blender. Joins collection instances into a single mesh. Instances mesh data. 6 | 7 | ## Why 8 | Exporting from blender to Unity is not difficult. Unity is able to import directly from a blend file. However small things like needing change settings for new objects in Unity, setting up colliders, excluding parts from the export, etc. all add up and add room for error. The goal of this addon in to let you setup export settings in advance and export with one click. 9 | 10 | Collection instances are a great way to scatter the same asset through out your scene, and still be able to edit it later. With this addon there is not need to join meshes in the instaced collection for performace as this is done for you at export. Also instaced collection can include any number of lights and colliders. 11 | 12 | ## Installation 13 | Download the zip file from the [latest release](https://github.com/codec-xyz/game_export/releases/latest) 14 | 15 | Instructions to install the zip file can be found here... 16 | 17 | https://docs.blender.org/manual/en/latest/editors/preferences/addons.html#installing-add-ons 18 | 19 | When installed expand the addon information box and click "Install missing modules" 20 | 21 | ## More Info 22 | ![preview001](preview001.png) 23 | - First there is the export folder and a list of collections to export. Each collection will produce one prefab and one fbx file and their meta files if they are not already present. 24 | - **Inherit values** are the object settings to be used when that value is marked as inherit on the object 25 | - All object settings are set to inherit by default 26 | - **Material Links** let you link your materials to Unity material files to use instead of materials exported in the fbx file 27 | 28 | ![preview002](preview002.png) 29 | - Below that are the per object settings 30 | - This box will show up when one or more objects are selected and shows the settings of all selected objects together 31 | - Changing settings in this box changes the settings of all selected objects, not just the active one 32 | 33 | ## Notes on box colliders 34 | - if you want to visualize the collider go to Object Properties > Viewport Display > Bounds check the box and make sure the dropdown is set to box 35 | - if you want to rotate the collider you need to rotate the object, meaning rotate in object mode not edit mode 36 | - I recommend using a cube mesh with mesh rendering turned off. As long as you rotate in object mode and do not skew the shape it will match the box collider exactly. In edit mode you can use the snap tool on the faces and double tapping an axis (x, y, z) when moving will lock to the local axis. 37 | 38 | ## Other notes 39 | - Viewport disabled objects (the screen icon, not the eye icon) will cause the objects to be skipped during export 40 | - Empty objects will be included in the exported prefab file 41 | 42 | ## Future 43 | - Don't expect anything, but if anything does get done these are likely... 44 | - More object settings 45 | - Internal code improvments 46 | - Fbx only export mode 47 | - Armature export 48 | - The goal of this addon is not just Blender to Unity, idealy there would options for other game engines, but this is unlikely to happen -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | meshRenderEnum = [ 4 | ('INHERIT', 'Inherit', ''), 5 | ('NONE', 'None', ''), 6 | ('MESH_RENDERER', 'Mesh Renderer', ''), 7 | #('SKINNED_MESH_RENDERER', 'Skinned Mesh Renderer', ''), 8 | ] 9 | 10 | lightRenderEnum = [ 11 | ('INHERIT', 'Inherit', ''), 12 | ('NONE', 'None', ''), 13 | ('LIGHT', 'Light', ''), 14 | ] 15 | 16 | colliderEnum = [ 17 | ('INHERIT', 'Inherit', ''), 18 | ('NONE', 'None', ''), 19 | ('BOX', 'Box Collider', ''), 20 | ('MESH', 'Mesh Collider', ''), 21 | ] 22 | 23 | class GAME_EXPORT_collection_material_link_settings(bpy.types.PropertyGroup): 24 | material: bpy.props.PointerProperty(name='Material', type=bpy.types.Material) 25 | filePath: bpy.props.StringProperty(name='Material File Path', description='Choose a material file', default = '//', subtype='FILE_PATH') 26 | 27 | class GAME_EXPORT_collection_settings(bpy.types.PropertyGroup): 28 | collection: bpy.props.PointerProperty(name='Collection', type=bpy.types.Collection) 29 | shouldExport: bpy.props.BoolProperty(name='Should Export', default=True) 30 | exportName: bpy.props.StringProperty(name='Export Name', default='gameExport') 31 | lightMultiplier: bpy.props.FloatProperty(name='Light Multiplier', default=0.1, soft_min=0) 32 | fixFBXTextureTint: bpy.props.BoolProperty(name='Fix FBX Texture Tint', description='Fixes FBX exporter exporting material textures with the hidden color value as the tint', default=True) 33 | 34 | #inherit settings 35 | static: bpy.props.BoolVectorProperty(name='Static Flags', size=7, default=[True]*7) 36 | meshRender: bpy.props.EnumProperty(name='Mesh Render', items=meshRenderEnum[1:], default='MESH_RENDERER') 37 | lightRender: bpy.props.EnumProperty(name='Light Render', items=lightRenderEnum[1:], default='LIGHT') 38 | collider: bpy.props.EnumProperty(name='Collider', items=colliderEnum[1:], default='NONE') 39 | 40 | materialLinks: bpy.props.CollectionProperty(type=GAME_EXPORT_collection_material_link_settings) 41 | 42 | class GAME_EXPORT_settings(bpy.types.PropertyGroup): 43 | filePath: bpy.props.StringProperty(name = 'Export File Path', description='Choose a directory', default = '//', subtype='DIR_PATH') 44 | collectionList: bpy.props.CollectionProperty(type = GAME_EXPORT_collection_settings) 45 | collectionListIndex: bpy.props.IntProperty(name = 'Export Collection Index', default = -1) 46 | 47 | class GAME_EXPORT_object_settings(bpy.types.PropertyGroup): 48 | static: bpy.props.BoolVectorProperty(name='Static Flags', size=8, default=[True]*8) 49 | meshRender: bpy.props.EnumProperty(name='Mesh Render', items=meshRenderEnum, default='INHERIT') 50 | lightRender: bpy.props.EnumProperty(name='Light Render', items=lightRenderEnum, default='INHERIT') 51 | collider: bpy.props.EnumProperty(name='Collider', items=colliderEnum, default='INHERIT') 52 | 53 | def materialNameSetGet(collection: bpy.types.Collection) -> 'set[str]': 54 | materialsNameSet: 'set[str]' = set() 55 | for object in collection.all_objects: 56 | if object.type == 'EMPTY' and object.instance_collection: 57 | materialsNameSet.update(materialNameSetGet(object.instance_collection)) 58 | for materialName in object.material_slots: 59 | materialsNameSet.add(materialName.name) 60 | return materialsNameSet 61 | 62 | def materialLinksNameLookupGet(collectionSettings: GAME_EXPORT_collection_settings) -> 'dict[str, GAME_EXPORT_collection_material_link_settings]': 63 | materialLinksLookup = {} 64 | for materialLink in collectionSettings.materialLinks: 65 | if materialLink.material == None: continue 66 | materialLinksLookup[materialLink.material.name] = materialLink 67 | return materialLinksLookup 68 | 69 | classes = ( 70 | GAME_EXPORT_collection_material_link_settings, 71 | GAME_EXPORT_collection_settings, 72 | GAME_EXPORT_settings, 73 | GAME_EXPORT_object_settings, 74 | ) 75 | 76 | def register(): 77 | for cls in classes: bpy.utils.register_class(cls) 78 | bpy.types.Scene.gameExportSettings = bpy.props.PointerProperty(name = 'Game Export Settings', type = GAME_EXPORT_settings) 79 | bpy.types.Object.gameExportSettings = bpy.props.PointerProperty(name = 'Game Export Object Settings', type = GAME_EXPORT_object_settings) 80 | 81 | def unregister(): 82 | for cls in reversed(classes): bpy.utils.unregister_class(cls) 83 | del bpy.types.Scene.gameExportSettings 84 | del bpy.types.Object.gameExportSettings 85 | 86 | def doesDataExist(): 87 | return hasattr(bpy.types.Scene, 'gameExportSettings') 88 | 89 | def deleteData(): 90 | del bpy.types.Scene.gameExportSettings 91 | del bpy.types.Object.gameExportSettings 92 | 93 | for scene in bpy.data.scenes: del scene['gameExportSettings'] 94 | for object in bpy.data.objects: del object['gameExportSettings'] 95 | -------------------------------------------------------------------------------- /unity.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from .xxhash import xxh64_intdigest 4 | from .asset import Transform 5 | from mathutils import Vector 6 | 7 | UNITY_LINK_TYPE_DEFAULT = 0 #used for nonexistant default things like default material 8 | UNITY_LINK_TYPE_MATERIAL_FILE = 2 #file types like: mat 9 | UNITY_LINK_TYPE_OTHER = 3 #other file types like: fbx, obj, prefab 10 | 11 | class AssetLink: 12 | assetId: str = '' 13 | assetFileId: str = '' 14 | linkType: str = '' 15 | 16 | def __init__(self, assetId: str = '', assetFileId: str = '', linkType: str = UNITY_LINK_TYPE_OTHER): 17 | self.assetId = assetId 18 | self.assetFileId = assetFileId 19 | self.linkType = linkType 20 | 21 | def fromFile(self, assetId: str = ''): 22 | return AssetLink(assetId, self.assetFileId, self.linkType) 23 | 24 | def toString(self, fileId: str = ''): 25 | if self.assetId == '': return '{fileID: 0}' 26 | if self.assetFileId == fileId: return f'{{fileID: {self.assetId}}}' 27 | return f'{{fileID: {self.assetId}, guid: {self.assetFileId}, type: {self.linkType}}}' 28 | 29 | #Default Material - {fileID: 10303, guid: 0000000000000000f000000000000000, type: 0} 30 | UNITY_LINK_DEFAULT_MATERIAL = AssetLink('10303', '0000000000000000f000000000000000', UNITY_LINK_TYPE_DEFAULT) 31 | 32 | NEWLINE = '\n' 33 | 34 | def yamlFindFirstValue(file: str, variable: str, start: int = None, end: int = None): 35 | index = file.find(variable, start, end) 36 | if index == -1: return '' 37 | index += len(variable) + 2 38 | lineEnd = file.find('\n', index) 39 | if lineEnd == -1: return file[index:] 40 | return file[index:lineEnd] 41 | 42 | def makeDefaultMetaFile_fbx(link: AssetLink): 43 | return f'''fileFormatVersion: 2 44 | guid: {link.assetFileId} 45 | ModelImporter: 46 | serializedVersion: 19301 47 | internalIDToNameTable: [] 48 | externalObjects: {{}} 49 | materials: 50 | materialImportMode: 1 51 | materialName: 0 52 | materialSearch: 1 53 | materialLocation: 1 54 | animations: 55 | legacyGenerateAnimations: 4 56 | bakeSimulation: 0 57 | resampleCurves: 1 58 | optimizeGameObjects: 0 59 | motionNodeName: 60 | rigImportErrors: 61 | rigImportWarnings: 62 | animationImportErrors: 63 | animationImportWarnings: 64 | animationRetargetingWarnings: 65 | animationDoRetargetingWarnings: 0 66 | importAnimatedCustomProperties: 0 67 | importConstraints: 0 68 | animationCompression: 1 69 | animationRotationError: 0.5 70 | animationPositionError: 0.5 71 | animationScaleError: 0.5 72 | animationWrapMode: 0 73 | extraExposedTransformPaths: [] 74 | extraUserProperties: [] 75 | clipAnimations: [] 76 | isReadable: 0 77 | meshes: 78 | lODScreenPercentages: [] 79 | globalScale: 1 80 | meshCompression: 0 81 | addColliders: 0 82 | useSRGBMaterialColor: 1 83 | sortHierarchyByName: 1 84 | importVisibility: 1 85 | importBlendShapes: 1 86 | importCameras: 1 87 | importLights: 1 88 | fileIdsGeneration: 2 89 | swapUVChannels: 0 90 | generateSecondaryUV: 1 91 | useFileUnits: 1 92 | keepQuads: 0 93 | weldVertices: 1 94 | preserveHierarchy: 0 95 | skinWeightsMode: 0 96 | maxBonesPerVertex: 4 97 | minBoneWeight: 0.001 98 | meshOptimizationFlags: -1 99 | indexFormat: 0 100 | secondaryUVAngleDistortion: 8 101 | secondaryUVAreaDistortion: 15.000001 102 | secondaryUVHardAngle: 88 103 | secondaryUVPackMargin: 4 104 | useFileScale: 1 105 | tangentSpace: 106 | normalSmoothAngle: 60 107 | normalImportMode: 0 108 | tangentImportMode: 3 109 | normalCalculationMode: 4 110 | legacyComputeAllNormalsFromSmoothingGroupsWhenMeshHasBlendShapes: 0 111 | blendShapeNormalImportMode: 1 112 | normalSmoothingSource: 0 113 | referencedClips: [] 114 | importAnimation: 1 115 | humanDescription: 116 | serializedVersion: 3 117 | human: [] 118 | skeleton: [] 119 | armTwist: 0.5 120 | foreArmTwist: 0.5 121 | upperLegTwist: 0.5 122 | legTwist: 0.5 123 | armStretch: 0.05 124 | legStretch: 0.05 125 | feetSpacing: 0 126 | globalScale: 1 127 | rootMotionBoneName: 128 | hasTranslationDoF: 0 129 | hasExtraRoot: 0 130 | skeletonHasParents: 1 131 | lastHumanDescriptionAvatarSource: {{instanceID: 0}} 132 | autoGenerateAvatarMappingIfUnspecified: 1 133 | animationType: 2 134 | humanoidOversampling: 1 135 | avatarSetup: 0 136 | additionalBone: 0 137 | userData: 138 | assetBundleName: 139 | assetBundleVariant: 140 | ''' 141 | 142 | def makeDefaultMetaFile_prefab(link: AssetLink): 143 | return f'''fileFormatVersion: 2 144 | guid: {link.assetFileId} 145 | PrefabImporter: 146 | externalObjects: {{}} 147 | userData: 148 | assetBundleName: 149 | assetBundleVariant: 150 | ''' 151 | 152 | def getFbxMetaFileLink(file: str): 153 | return AssetLink('', yamlFindFirstValue(file, 'guid')) 154 | 155 | def getMaterialMetaFileLink(file: str): 156 | #return AssetLink(yamlFindFirstValue(file, 'mainObjectFileID'), yamlFindFirstValue(file, 'guid'), UNITY_LINK_TYPE_MATERIAL_FILE) 157 | return AssetLink('2100000', yamlFindFirstValue(file, 'guid'), UNITY_LINK_TYPE_MATERIAL_FILE) 158 | 159 | def getFbxId_mesh(name: str): 160 | hash = xxh64_intdigest(f'Type:Mesh->{name}0') 161 | if(hash < 9223372036854775807): return hash 162 | return hash - 18446744073709551616 163 | 164 | def getFbxId_material(name: str): 165 | hash = xxh64_intdigest(f'Type:Material->{name}0') 166 | if(hash < 9223372036854775807): return hash 167 | return hash - 18446744073709551616 168 | 169 | #used to id files 170 | def makeRandomGuid(): 171 | return ''.join(random.choice(string.hexdigits) for _ in range(32)) 172 | 173 | #labeled fileId by untiy, used to id things within a file 174 | #64 bit signed int 175 | def makeRandomAssetId(): 176 | return random.getrandbits(63) 177 | 178 | unityYamlHeader = '''%YAML 1.1 179 | %TAG !u! tag:unity3d.com,2011:''' 180 | 181 | def makeLinksList(componentLink: AssetLink, name: str, links: 'list[AssetLink]'): 182 | if len(links) == 0: return ' []' 183 | return ''.join(f'{NEWLINE} - {name}{link.toString(componentLink.assetFileId)}' for link in links) 184 | 185 | UNITY_GAME_OBJECT_STATIC_NOTHING = 0 186 | UNITY_GAME_OBJECT_STATIC_EVERYTHING = 4294967295 187 | UNITY_GAME_OBJECT_STATIC_CONTRIBUTE_GI = 1 188 | UNITY_GAME_OBJECT_STATIC_OCCLUDER_STATIC = 2 189 | UNITY_GAME_OBJECT_STATIC_OCCLUDEE_STATIC = 16 190 | UNITY_GAME_OBJECT_STATIC_BATCHING_STATIC = 4 191 | UNITY_GAME_OBJECT_STATIC_NAVIGATION_STATIC = 8 192 | UNITY_GAME_OBJECT_STATIC_OFF_MESH_LINK_GENERATION = 32 193 | UNITY_GAME_OBJECT_STATIC_REFLECTION_PROBE_STATIC = 64 194 | 195 | def makeGameObject(link: AssetLink, name: str, componentLinks: 'list[AssetLink]', staticFlags: int = UNITY_GAME_OBJECT_STATIC_EVERYTHING): 196 | return f'''--- !u!1 &{link.assetId} 197 | GameObject: 198 | m_ObjectHideFlags: 0 199 | m_CorrespondingSourceObject: {{fileID: 0}} 200 | m_PrefabInstance: {{fileID: 0}} 201 | m_PrefabAsset: {{fileID: 0}} 202 | serializedVersion: 6 203 | m_Component:{makeLinksList(link, 'component: ', componentLinks)} 204 | m_Layer: 0 205 | m_Name: {name} 206 | m_TagString: Untagged 207 | m_Icon: {{fileID: 0}} 208 | m_NavMeshLayer: 0 209 | m_StaticEditorFlags: {staticFlags} 210 | m_IsActive: 1''' 211 | 212 | def makeTransfom(link: AssetLink, gameObjectLink: AssetLink, parentTransformLink: AssetLink, childTransformLinks: 'list[AssetLink]', transform: Transform): 213 | return f'''--- !u!4 &{link.assetId} 214 | Transform: 215 | m_ObjectHideFlags: 0 216 | m_CorrespondingSourceObject: {{fileID: 0}} 217 | m_PrefabInstance: {{fileID: 0}} 218 | m_PrefabAsset: {{fileID: 0}} 219 | m_GameObject: {gameObjectLink.toString(link.assetFileId)} 220 | m_LocalRotation: {{x: {transform.rotation.x}, y: {-transform.rotation.z}, z: {transform.rotation.y}, w: {transform.rotation.w}}} 221 | m_LocalPosition: {{x: {-transform.location.x}, y: {transform.location.z}, z: {-transform.location.y}}} 222 | m_LocalScale: {{x: {transform.scale.x}, y: {transform.scale.z}, z: {transform.scale.y}}} 223 | m_Children:{makeLinksList(link, '', childTransformLinks)} 224 | m_Father: {parentTransformLink.toString(link.assetFileId)} 225 | m_RootOrder: 0 226 | m_LocalEulerAnglesHint: {{x: 0, y: 0, z: 0}}''' 227 | 228 | def makeMeshFilter(link: AssetLink, gameObjectLink: AssetLink, meshLink: AssetLink): 229 | return f'''--- !u!33 &{link.assetId} 230 | MeshFilter: 231 | m_ObjectHideFlags: 0 232 | m_CorrespondingSourceObject: {{fileID: 0}} 233 | m_PrefabInstance: {{fileID: 0}} 234 | m_PrefabAsset: {{fileID: 0}} 235 | m_GameObject: {gameObjectLink.toString(link.assetFileId)} 236 | m_Mesh: {meshLink.toString(link.assetFileId)}''' 237 | 238 | #ignored if not GI static 239 | UNITY_MESH_RENDERER_RECEIVE_GI_LIGHTMAPS = 1 240 | UNITY_MESH_RENDERER_RECEIVE_GI_LIGHT_PROBES = 2 241 | 242 | def makeMeshRenderer(link: AssetLink, gameObjectLink: AssetLink, materialLinks: 'list[AssetLink]', receiveGI: int = UNITY_MESH_RENDERER_RECEIVE_GI_LIGHTMAPS): 243 | return f'''--- !u!23 &{link.assetId} 244 | MeshRenderer: 245 | m_ObjectHideFlags: 0 246 | m_CorrespondingSourceObject: {{fileID: 0}} 247 | m_PrefabInstance: {{fileID: 0}} 248 | m_PrefabAsset: {{fileID: 0}} 249 | m_GameObject: {gameObjectLink.toString(link.assetFileId)} 250 | m_Enabled: 1 251 | m_CastShadows: 1 252 | m_ReceiveShadows: 1 253 | m_DynamicOccludee: 1 254 | m_MotionVectors: 1 255 | m_LightProbeUsage: 1 256 | m_ReflectionProbeUsage: 1 257 | m_RayTracingMode: 2 258 | m_RenderingLayerMask: 1 259 | m_RendererPriority: 0 260 | m_Materials:{makeLinksList(link, '', materialLinks)} 261 | m_StaticBatchInfo: 262 | firstSubMesh: 0 263 | subMeshCount: 0 264 | m_StaticBatchRoot: {{fileID: 0}} 265 | m_ProbeAnchor: {{fileID: 0}} 266 | m_LightProbeVolumeOverride: {{fileID: 0}} 267 | m_ScaleInLightmap: 1 268 | m_ReceiveGI: {receiveGI} 269 | m_PreserveUVs: 0 270 | m_IgnoreNormalsForChartDetection: 0 271 | m_ImportantGI: 0 272 | m_StitchLightmapSeams: 1 273 | m_SelectedEditorRenderState: 3 274 | m_MinimumChartSize: 4 275 | m_AutoUVMaxDistance: 0.5 276 | m_AutoUVMaxAngle: 89 277 | m_LightmapParameters: {{fileID: 0}} 278 | m_SortingLayerID: 0 279 | m_SortingLayer: 0 280 | m_SortingOrder: 0''' 281 | 282 | def makeMeshCollider(link: AssetLink, gameObjectLink: AssetLink, meshLink: AssetLink): 283 | return f'''--- !u!64 &{link.assetId} 284 | MeshCollider: 285 | m_ObjectHideFlags: 0 286 | m_CorrespondingSourceObject: {{fileID: 0}} 287 | m_PrefabInstance: {{fileID: 0}} 288 | m_PrefabAsset: {{fileID: 0}} 289 | m_GameObject: {gameObjectLink.toString(link.assetFileId)} 290 | m_Material: {{fileID: 0}} 291 | m_IsTrigger: 0 292 | m_Enabled: 1 293 | serializedVersion: 4 294 | m_Convex: 0 295 | m_CookingOptions: 30 296 | m_Mesh: {meshLink.toString(link.assetFileId)}''' 297 | 298 | def makeBoxCollider(link: AssetLink, gameObjectLink: AssetLink, size: Vector = Vector([1, 1, 1]), center: Vector = Vector([0, 0, 0])): 299 | return f'''--- !u!65 &{link.assetId} 300 | BoxCollider: 301 | m_ObjectHideFlags: 0 302 | m_CorrespondingSourceObject: {{fileID: 0}} 303 | m_PrefabInstance: {{fileID: 0}} 304 | m_PrefabAsset: {{fileID: 0}} 305 | m_GameObject: {gameObjectLink.toString(link.assetFileId)} 306 | m_Material: {{fileID: 0}} 307 | m_IsTrigger: 0 308 | m_Enabled: 1 309 | serializedVersion: 2 310 | m_Size: {{x: {-size.x}, y: {size.z}, z: {-size.y}}} 311 | m_Center: {{x: {-center.x}, y: {center.z}, z: {-center.y}}}''' 312 | 313 | UNITY_LIGHT_TYPE_SPOT = 0 314 | UNITY_LIGHT_TYPE_DIRECTIONAL = 1 315 | UNITY_LIGHT_TYPE_POINT = 2 316 | UNITY_LIGHT_TYPE_AREA_RECTANGLE = 3 317 | UNITY_LIGHT_TYPE_AREA_DISK = 4 318 | 319 | UNITY_LIGHT_MODE_MIXED = 1 320 | UNITY_LIGHT_MODE_BAKED = 2 321 | UNITY_LIGHT_MODE_REALTIME = 4 322 | 323 | UNITY_LIGHT_SHADOWS_NONE = 0 324 | UNITY_LIGHT_SHADOWS_HARD = 1 325 | UNITY_LIGHT_SHADOWS_SOFT = 2 326 | 327 | def makeLight(link: AssetLink, gameObjectLink: AssetLink, type: int, mode: int, shadows: int, color: 'list[float]', inensity: float, spotlightAngleDeg: float, spotlightInnerAngleDeg: float, areaSize: 'list[float]', cutoffDistance: int = 10): 328 | return f'''--- !u!108 &{link.assetId} 329 | Light: 330 | m_ObjectHideFlags: 0 331 | m_CorrespondingSourceObject: {{fileID: 0}} 332 | m_PrefabInstance: {{fileID: 0}} 333 | m_PrefabAsset: {{fileID: 0}} 334 | m_GameObject: {gameObjectLink.toString(link.assetFileId)} 335 | m_Enabled: 1 336 | serializedVersion: 10 337 | m_Type: {type} 338 | m_Shape: 0 339 | m_Color: {{r: {color[0]}, g: {color[1]}, b: {color[2]}, a: 1}} 340 | m_Intensity: {inensity} 341 | m_Range: {cutoffDistance} 342 | m_SpotAngle: {spotlightAngleDeg} 343 | m_InnerSpotAngle: {spotlightInnerAngleDeg} 344 | m_CookieSize: 10 345 | m_Shadows: 346 | m_Type: {shadows} 347 | m_Resolution: -1 348 | m_CustomResolution: -1 349 | m_Strength: 1 350 | m_Bias: 0.05 351 | m_NormalBias: 0.4 352 | m_NearPlane: 0.2 353 | m_CullingMatrixOverride: 354 | e00: 1 355 | e01: 0 356 | e02: 0 357 | e03: 0 358 | e10: 0 359 | e11: 1 360 | e12: 0 361 | e13: 0 362 | e20: 0 363 | e21: 0 364 | e22: 1 365 | e23: 0 366 | e30: 0 367 | e31: 0 368 | e32: 0 369 | e33: 1 370 | m_UseCullingMatrixOverride: 0 371 | m_Cookie: {{fileID: 0}} 372 | m_DrawHalo: 0 373 | m_Flare: {{fileID: 0}} 374 | m_RenderMode: 0 375 | m_CullingMask: 376 | serializedVersion: 2 377 | m_Bits: 4294967295 378 | m_RenderingLayerMask: 1 379 | m_Lightmapping: {mode} 380 | m_LightShadowCasterMode: 0 381 | m_AreaSize: {{x: {areaSize[0]}, y: {areaSize[0]}}} 382 | m_BounceIntensity: 1 383 | m_ColorTemperature: 6570 384 | m_UseColorTemperature: 0 385 | m_BoundingSphereOverride: {{x: 0, y: 0, z: 0, w: 0}} 386 | m_UseBoundingSphereOverride: 0 387 | m_ShadowRadius: 0 388 | m_ShadowAngle: 0''' -------------------------------------------------------------------------------- /view.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import os.path 3 | import random 4 | import string 5 | import time 6 | from .settings import * 7 | from .export import exportCollection, Settings 8 | 9 | def isPathFileWritable(path): 10 | checkFilePath = '' 11 | for s in range(6): 12 | checkFilePath = path + '\\' + ''.join(random.choice(string.ascii_letters) for i in range(16)) 13 | if(not os.path.isfile(checkFilePath)): break 14 | try: 15 | with open(checkFilePath, 'w+') as f: '' 16 | os.remove(checkFilePath) 17 | return True 18 | except IOError as x: 19 | return False 20 | 21 | exportInfo = [] 22 | 23 | class GAME_EXPORT_OT_show_export_info(bpy.types.Operator): 24 | bl_idname = 'game_export.show_export_info' 25 | bl_label = 'Game Export Info' 26 | 27 | def draw(self, context): 28 | global exportInfo 29 | if(len(exportInfo) == 0): 30 | self.layout.box().label(text = 'Nothing To Export') 31 | return 32 | 33 | row = self.layout.box().split(factor = 0.7) 34 | row.label(text = 'Export Completed Successfully') 35 | row.label(text = f'{sum([info[1] for info in exportInfo]):0.4f} s') 36 | row = self.layout.box().split(factor = 0.7) 37 | c1 = row.column() 38 | c2 = row.column() 39 | for info in exportInfo: 40 | c1.label(text = info[0]) 41 | c2.label(text = f'{info[1]:0.4f} s') 42 | 43 | def execute(self, context): 44 | return {'FINISHED'} 45 | 46 | def invoke(self, context, event): 47 | context.window_manager.invoke_props_dialog(self) 48 | return {'RUNNING_MODAL'} 49 | 50 | class GAME_EXPORT_OT_export(bpy.types.Operator): 51 | '''Game export''' 52 | bl_idname = 'game_export.export' 53 | bl_label = 'Game Export' 54 | 55 | def execute(self, context): 56 | settings = context.scene.gameExportSettings 57 | fullPath = settings.filePath 58 | if(fullPath[:2] == '//'): fullPath = '.\\' + fullPath[2:] 59 | fullPath = os.path.abspath(fullPath) + '\\' 60 | if(not isPathFileWritable(fullPath)): 61 | def draw(self, context): 62 | self.layout.label(text = fullPath) 63 | self.layout.label(text = 'Likely: Folder does not exist or you cannot write here') 64 | bpy.context.window_manager.popup_menu(draw, title = 'Invalid folder path', icon = 'EXPORT') 65 | return { 'FINISHED' } 66 | 67 | global exportInfo 68 | exportInfo = [] 69 | context.window_manager.progress_begin(0, 1) 70 | context.window_manager.progress_update(0) 71 | totalObjs = 0 72 | currentObjs = 0 73 | for collectionSettings in settings.collectionList: 74 | if(collectionSettings.shouldExport): totalObjs += len(collectionSettings.collection.all_objects) 75 | for collectionSettings in settings.collectionList: 76 | if(collectionSettings.shouldExport): 77 | specificSettings = Settings() 78 | specificSettings.collection = collectionSettings.collection 79 | specificSettings.includeMeshes = collectionSettings.shouldIncludeMeshes 80 | specificSettings.includeLights = collectionSettings.shouldIncludeLights 81 | specificSettings.lightMultiplier = collectionSettings.lightMultiplier 82 | start = time.perf_counter() 83 | exportCollection(context, fullPath, specificSettings) 84 | timeElapsed = time.perf_counter() - start 85 | exportInfo.append((collectionSettings.exportName, timeElapsed)) 86 | currentObjs += len(collectionSettings.collection.all_objects) 87 | context.window_manager.progress_update(currentObjs / totalObjs) 88 | 89 | context.window_manager.progress_end() 90 | bpy.ops.game_export.show_export_info('INVOKE_DEFAULT') 91 | return { 'FINISHED' } 92 | 93 | def availableCollections(scene, context): 94 | return [(c.name, c.name, '') for c in bpy.data.collections] 95 | 96 | class GAME_EXPORT_OT_collection_add(bpy.types.Operator): 97 | '''Add collection to export''' 98 | bl_idname = 'game_export.collection_add' 99 | bl_label = 'Add Collection' 100 | bl_property = 'collections' 101 | bl_options = {'REGISTER', 'UNDO'} 102 | 103 | collections: bpy.props.EnumProperty(name = 'Collections', description = '', items = availableCollections) 104 | 105 | def execute(self, context): 106 | settings = context.scene.gameExportSettings 107 | collectionSettings = settings.collectionList.add() 108 | collectionSettings.collection = next(c for c in bpy.data.collections if c.name == self.collections) 109 | collectionSettings.exportName = collectionSettings.collection.name 110 | settings.collectionListIndex = len(settings.collectionList) - 1 111 | context.area.tag_redraw() 112 | return {'FINISHED'} 113 | 114 | def invoke(self, context, event): 115 | wm = context.window_manager 116 | wm.invoke_search_popup(self) 117 | return {'FINISHED'} 118 | 119 | class GAME_EXPORT_OT_collection_remove(bpy.types.Operator): 120 | '''Remove collection to export''' 121 | bl_idname = 'game_export.collection_remove' 122 | bl_label = 'Remove Collection' 123 | 124 | @classmethod 125 | def poll(cls, context): 126 | return len(context.scene.gameExportSettings.collectionList) != 0 127 | 128 | def execute(self, context): 129 | settings = context.scene.gameExportSettings 130 | settings.collectionList.remove(settings.collectionListIndex) 131 | if(settings.collectionListIndex > 0): settings.collectionListIndex -= 1 132 | return {'FINISHED'} 133 | 134 | class GAME_EXPORT_OT_collection_move_up(bpy.types.Operator): 135 | '''Remove collection to export''' 136 | bl_idname = 'game_export.collection_move_up' 137 | bl_label = 'Move Collection Up' 138 | 139 | @classmethod 140 | def poll(cls, context): 141 | return context.scene.gameExportSettings.collectionListIndex > 0 142 | 143 | def execute(self, context): 144 | settings = context.scene.gameExportSettings 145 | index = settings.collectionListIndex 146 | settings.collectionList.move(index, index - 1) 147 | settings.collectionListIndex -= 1 148 | return {'FINISHED'} 149 | 150 | class GAME_EXPORT_OT_collection_move_down(bpy.types.Operator): 151 | '''Remove collection to export''' 152 | bl_idname = 'game_export.collection_move_down' 153 | bl_label = 'Move Collection Up' 154 | 155 | @classmethod 156 | def poll(cls, context): 157 | return len(context.scene.gameExportSettings.collectionList) - 1 > context.scene.gameExportSettings.collectionListIndex 158 | 159 | def execute(self, context): 160 | settings = context.scene.gameExportSettings 161 | index = settings.collectionListIndex 162 | settings.collectionList.move(index, index + 1) 163 | settings.collectionListIndex += 1 164 | return {'FINISHED'} 165 | 166 | class GAME_EXPORT_UL_collection_list(bpy.types.UIList): 167 | bl_idname = 'GAME_EXPORT_UL_collection_list' 168 | def draw_item(self, context, layout: bpy.types.UILayout, data, item, icon, active_data, active_propname, index): 169 | useIcon = 'OUTLINER_COLLECTION' 170 | if(item.collection.color_tag != 'NONE'): useIcon = 'COLLECTION_' + item.collection.color_tag 171 | layout.label(text = item.exportName, icon = useIcon) 172 | layout.prop(item, 'shouldExport', text = '', emboss = False, icon = 'CHECKBOX_HLT' if item.shouldExport else 'CHECKBOX_DEHLT') 173 | 174 | class GAME_EXPORT_PT_settings(bpy.types.Panel): 175 | bl_idname = 'GAME_EXPORT_PT_settings' 176 | bl_label = 'Game Export' 177 | bl_space_type = 'VIEW_3D' 178 | bl_region_type = 'UI' 179 | bl_category = 'GameExport' 180 | 181 | def draw(self, context): 182 | settings = context.scene.gameExportSettings 183 | self.layout.operator(GAME_EXPORT_OT_export.bl_idname, text = 'Export') 184 | self.layout.prop(settings, 'filePath', text = '') 185 | 186 | row = self.layout.row() 187 | col = row.column() 188 | col.template_list(GAME_EXPORT_UL_collection_list.bl_idname, '', settings, 'collectionList', settings, 'collectionListIndex', item_dyntip_propname = '') 189 | col = row.column(align=True) 190 | col.operator(GAME_EXPORT_OT_collection_add.bl_idname, icon='ADD', text='') 191 | col.operator(GAME_EXPORT_OT_collection_remove.bl_idname, icon='REMOVE', text='') 192 | col.separator() 193 | col.operator(GAME_EXPORT_OT_collection_move_up.bl_idname, icon='TRIA_UP', text='') 194 | col.operator(GAME_EXPORT_OT_collection_move_down.bl_idname, icon='TRIA_DOWN', text='') 195 | 196 | if(len(settings.collectionList) != 0): 197 | item = settings.collectionList[settings.collectionListIndex] 198 | useIcon = 'OUTLINER_COLLECTION' 199 | if(item.collection.color_tag != 'NONE'): useIcon = 'COLLECTION_' + item.collection.color_tag 200 | self.layout.label(text = item.collection.name, icon = useIcon) 201 | self.layout.prop(item, 'exportName', text = '') 202 | column = self.layout.column(align = True) 203 | column.prop(item, 'shouldIncludeMeshes', toggle = 1, text = 'Meshes') 204 | column.prop(item, 'shouldIncludeLights', toggle = 1, text = 'Lights') 205 | self.layout.prop(item, 'shouldJoin') 206 | self.layout.prop(item, 'lightMultiplier') 207 | 208 | classes = ( 209 | GAME_EXPORT_collection_settings, 210 | GAME_EXPORT_settings, 211 | GAME_EXPORT_OT_show_export_info, 212 | GAME_EXPORT_OT_export, 213 | GAME_EXPORT_OT_collection_add, 214 | GAME_EXPORT_OT_collection_remove, 215 | GAME_EXPORT_OT_collection_move_up, 216 | GAME_EXPORT_OT_collection_move_down, 217 | GAME_EXPORT_PT_settings, 218 | GAME_EXPORT_UL_collection_list, 219 | ) 220 | 221 | objectTypes = [ 222 | ('DEFAULT', 'Default', 'Export object normally'), 223 | ('IGNORE', 'Ignore', 'Ignore object when exporting'), 224 | ('BOX_COLLIDER', 'Box Collider', 'Export object as a box collider'), 225 | ('MESH_COLLIDER', 'Mesh Collider', 'Export object as a mesh collider') 226 | ] 227 | 228 | def register(): 229 | for cls in classes: bpy.utils.register_class(cls) 230 | bpy.types.Scene.gameExportSettings = bpy.props.PointerProperty(name = 'Game Export Settings', type = GAME_EXPORT_settings) 231 | bpy.types.Object.gameExportType = bpy.props.EnumProperty(items = objectTypes, name = 'Game Export Type', default = 'DEFAULT') 232 | 233 | def unregister(): 234 | for cls in classes: bpy.utils.unregister_class(cls) --------------------------------------------------------------------------------