diff --git a/__init__.py b/__init__.py index b1d0052..98d49af 100644 --- a/__init__.py +++ b/__init__.py @@ -57,6 +57,7 @@ gui.OBJECT_OT_dff_add_2dfx_cover_point, gui.OBJECT_OT_dff_add_2dfx_escalator, gui.OBJECT_OT_dff_add_cull, + gui.MATERIAL_PT_txdTextures, # gui.MATERIAL_PT_dffMaterials, gui.OBJECT_PT_dffObjects, gui.OBJECT_PT_dffCollections, @@ -67,6 +68,7 @@ gui.CULLObjectProps, gui.IMPORT_OT_ParticleTXDNames, gui.DFFMaterialProps, + gui.COLMaterialEnumProps, gui.DFFObjectProps, gui.DFFCollectionProps, gui.MapImportPanel, @@ -94,7 +96,12 @@ gui.Escalator2DFXGizmoGroup, gui.UVAnimatorProperties, gui.UV_OT_AnimateSpriteSheet, - gui.NODE_PT_UVAnimator + gui.NODE_PT_UVAnimator, + gui.COLSceneProps, + gui.TXDImageProps, + gui.TXDTextureListProps, + gui.TEXTURES_UL_txd_image_list, + gui.MATERIAL_OT_dff_import_uv_anim, ] ####################################################### @@ -111,6 +118,9 @@ def register(): bpy.types.Object.dff = bpy.props.PointerProperty(type=gui.DFFObjectProps) bpy.types.Collection.dff = bpy.props.PointerProperty(type=gui.DFFCollectionProps) bpy.types.Scene.dff_uv_animator_props = bpy.props.PointerProperty(type=gui.UVAnimatorProperties) + bpy.types.Scene.dff_col = bpy.props.PointerProperty(type=gui.COLSceneProps) + bpy.types.Image.dff = bpy.props.PointerProperty(type=gui.TXDImageProps) + bpy.types.Scene.dff_txd_texture_list = bpy.props.PointerProperty(type=gui.TXDTextureListProps) bpy.types.TOPBAR_MT_file_import.append(gui.import_dff_func) bpy.types.TOPBAR_MT_file_export.append(gui.export_dff_func) @@ -132,6 +142,9 @@ def unregister(): del bpy.types.Object.dff del bpy.types.Collection.dff del bpy.types.Scene.dff_uv_animator_props + del bpy.types.Scene.dff_col + del bpy.types.Image.dff + del bpy.types.Scene.dff_txd_texture_list bpy.types.TOPBAR_MT_file_import.remove(gui.import_dff_func) bpy.types.TOPBAR_MT_file_export.remove(gui.export_dff_func) diff --git a/gtaLib/txd.py b/gtaLib/txd.py index 97c3ce4..3b8a7fe 100644 --- a/gtaLib/txd.py +++ b/gtaLib/txd.py @@ -93,12 +93,83 @@ def rgba_to_bgra8888(rgba_data): @staticmethod def rgba_to_bgra888(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r = rgba_data[i] + g = rgba_data[i + 1] + b = rgba_data[i + 2] + ret.extend([b, g, r, 0xFF]) + return bytes(ret) + + @staticmethod + def rgba_to_rgb565(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b = rgba_data[i:i+3] + r5 = (r * 31) // 255 + g6 = (g * 63) // 255 + b5 = (b * 31) // 255 + rgb565 = (r5 << 11) | (g6 << 5) | b5 + ret.extend(rgb565.to_bytes(2, 'little')) + return bytes(ret) + + @staticmethod + def rgba_to_rgba1555(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b, a = rgba_data[i:i+4] + a1 = 1 if a > 127 else 0 + r5 = (r * 31) // 255 + g5 = (g * 31) // 255 + b5 = (b * 31) // 255 + rgba1555 = (a1 << 15) | (r5 << 10) | (g5 << 5) | b5 + ret.extend(rgba1555.to_bytes(2, 'little')) + return bytes(ret) + + @staticmethod + def rgba_to_rgba4444(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b, a = rgba_data[i:i+4] + a4 = (a * 15) // 255 + r4 = (r * 15) // 255 + g4 = (g * 15) // 255 + b4 = (b * 15) // 255 + rgba4444 = (a4 << 12) | (r4 << 8) | (g4 << 4) | b4 + ret.extend(rgba4444.to_bytes(2, 'little')) + return bytes(ret) + + @staticmethod + def rgba_to_rgb555(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b = rgba_data[i:i+3] + r5 = (r * 31) // 255 + g5 = (g * 31) // 255 + b5 = (b * 31) // 255 + rgb555 = (r5 << 10) | (g5 << 5) | b5 + ret.extend(rgb555.to_bytes(2, 'little')) + return bytes(ret) + + @staticmethod + def rgba_to_lum8(rgba_data): ret = bytearray() for i in range(0, len(rgba_data), 4): r, g, b = rgba_data[i:i+3] - ret.extend([b, g, r]) + lum = int(0.299 * r + 0.587 * g + 0.114 * b) + ret.append(lum) return bytes(ret) + @staticmethod + def rgba_to_lum8a8(rgba_data): + ret = bytearray() + for i in range(0, len(rgba_data), 4): + r, g, b, a = rgba_data[i:i+4] + lum = int(0.299 * r + 0.587 * g + 0.114 * b) + ret.extend([lum, a]) + return bytes(ret) + + ####################################################### class ImageDecoder: diff --git a/gui/col_menus.py b/gui/col_menus.py new file mode 100644 index 0000000..4c16eeb --- /dev/null +++ b/gui/col_menus.py @@ -0,0 +1,176 @@ +import bpy +from ..gtaLib.data.col_materials import COL_PRESET_SA, COL_PRESET_VC, COL_PRESET_GROUP + +_ENUM_CACHE = {} + +######################################################## +def generate_col_mat_enums(): + global _ENUM_CACHE + + for game in ["SA", "VC"]: + mats = COL_PRESET_SA if game == "SA" else COL_PRESET_VC + + for group_id in COL_PRESET_GROUP.keys(): + + normal_items = [("NONE", "Select a material below", "")] + proc_items = [("NONE", "Select a material below", "")] + + for gid, flag_id, mat_id, name, is_proc in mats: + if gid != group_id: + continue + + item = (f"{flag_id}|{mat_id}", name, "") + + if game == "VC": + normal_items.append(item) + else: + if is_proc: + proc_items.append(item) + else: + normal_items.append(item) + + _ENUM_CACHE[f"{game}_{group_id}_normal"] = normal_items + _ENUM_CACHE[f"{game}_{group_id}_proc"] = proc_items + +generate_col_mat_enums() + + +####################################################### +def get_col_mat_items_normal(self, context): + scn = context.scene + game = scn.dff_col.col_game_vers + group = scn.dff_col.col_mat_group + key = f"{game}_{group}_normal" + return _ENUM_CACHE.get(key, [("NONE", "No materials", "")]) + +def get_col_mat_items_proc(self, context): + scn = context.scene + game = scn.dff_col.col_game_vers + group = scn.dff_col.col_mat_group + key = f"{game}_{group}_proc" + return _ENUM_CACHE.get(key, [("NONE", "No materials", "")]) + + +####################################################### +def apply_collision_material(self, context): + if self.col_mat_enum_normal != "NONE": + enum_value = self.col_mat_enum_normal + elif self.col_mat_enum_proc != "NONE": + enum_value = self.col_mat_enum_proc + else: + return + + flag_id, mat_id = enum_value.split('|') + flag_id = int(flag_id) + mat_id = int(mat_id) + + if context.object: + obj = context.object + mat = context.material + + if mat and obj.type == 'MESH': + mat.dff.col_mat_index = mat_id + mat.dff.col_flags = flag_id + + elif obj.type == 'EMPTY' and obj.dff.type == 'COL': + obj.dff.col_material = mat_id + obj.dff.col_flags = flag_id + + +####################################################### +def update_type(self, context, changed): + if changed == "normal" and self.col_mat_norm: + self.col_mat_proc = False + elif changed == "proc" and self.col_mat_proc: + self.col_mat_norm = False + + if not self.col_mat_norm and not self.col_mat_proc: + if changed == "normal": + self.col_mat_norm = True + else: + self.col_mat_proc = True + + +####################################################### +def draw_col_preset_helper(layout, context): + + box = layout.box() + box.label(text="Collision Presets") + + split = box.split(factor=0.4) + split.label(text="Game") + split.prop(context.scene.dff_col, "col_game_vers", text="") + + split = box.split(factor=0.4) + split.label(text="Group") + split.prop(context.scene.dff_col, "col_mat_group", text="") + + row = box.row(align=True) + row.prop(context.scene.dff_col, "col_mat_norm", toggle=True) + row.prop(context.scene.dff_col, "col_mat_proc", toggle=True) + + if context.scene.dff_col.col_mat_norm: + box.prop(context.object.dff.col_mat, "col_mat_enum_normal", expand=True) + else: + box.prop(context.object.dff.col_mat, "col_mat_enum_proc", expand=True) + + +####################################################### +def reset_col_mat_enum(self, context): + obj = context.object + if obj and hasattr(obj.dff, "col_mat"): + obj.dff.col_mat.col_mat_enum_normal = "NONE" + obj.dff.col_mat.col_mat_enum_proc = "NONE" + + +######################################################## +def update_col_mat_norm(self, context): + update_type(self, context, "normal") + reset_col_mat_enum(self, context) + + +######################################################### +def update_col_mat_proc(self, context): + update_type(self, context, "proc") + reset_col_mat_enum(self, context) + + +####################################################### +class COLSceneProps(bpy.types.PropertyGroup): + col_game_vers: bpy.props.EnumProperty( + items=[("SA", "San Andreas", ""), ("VC", "Vice City", "")], + default="SA", + update=reset_col_mat_enum + ) + + col_mat_group: bpy.props.EnumProperty( + items=[(str(k), v[0], "", v[1], k) for k, v in COL_PRESET_GROUP.items()], + update=reset_col_mat_enum + ) + + col_mat_norm: bpy.props.BoolProperty( + name="Normal", + default=True, + update=update_col_mat_norm + ) + + col_mat_proc: bpy.props.BoolProperty( + name="Procedural", + default=False, + update=update_col_mat_proc + ) + + +######################################################## +class COLMaterialEnumProps(bpy.types.PropertyGroup): + col_mat_enum_normal: bpy.props.EnumProperty( + name="Material", + items=get_col_mat_items_normal, + update=apply_collision_material + ) + + col_mat_enum_proc: bpy.props.EnumProperty( + name="Material", + items=get_col_mat_items_proc, + update=apply_collision_material + ) \ No newline at end of file diff --git a/gui/dff_menus.py b/gui/dff_menus.py index bc4386c..70ce479 100644 --- a/gui/dff_menus.py +++ b/gui/dff_menus.py @@ -11,18 +11,53 @@ from .map_ot import EXPORT_OT_ipl_cull from .cull_menus import CULLObjectProps, CULLMenus from ..gtaLib.data import presets +from .col_menus import draw_col_preset_helper +from .col_menus import COLMaterialEnumProps +from ..gtaLib.dff import dff +from ..ops.importer_common import material_helper + +texture_raster_items = ( + ("0", "Default", ""), + ("1", "8888", ""), + ("2", "4444", ""), + ("3", "1555", ""), + ("4", "888", ""), + ("5", "565", ""), + ("6", "555", ""), + ("7", "LUM", "") +) + +texture_palette_items = ( + ("0", "None", ""), + ("1", "PAL4", ""), + ("2", "PAL8", "") +) + +texture_compression_items = ( + ("0", "None", ""), + ("1", "DXT1", ""), + ("2", "DXT2", ""), + ("3", "DXT3", ""), + ("4", "DXT4", ""), + ("5", "DXT5", "") +) -texture_filters_items = ( +texture_mipmap_items = ( + ("0", "None", ""), + ("1", "Full", "") +) + +texture_filter_items = ( ("0", "Disabled", ""), - ("1", "Nearest", "Point sampled"), - ("2", "Linear", "Bilinear"), - ("3", "Mip Nearest", "Point sampled per pixel MipMap"), - ("4", "Mip Linear", "Bilinear per pixel MipMap"), - ("5", "Linear Mip Nearest", "MipMap interp point sampled"), - ("6", "Linear Mip Linear", "Trilinear") + ("1", "Nearest", ""), + ("2", "Linear", ""), + ("3", "Mip Nearest", ""), + ("4", "Mip Linear", ""), + ("5", "Linear Mip Nearest", ""), + ("6", "Linear Mip Linear", "") ) -texture_uv_addressing_items = ( +texture_uvaddress_items = ( ("0", "Disabled", ""), ("1", "Wrap", ""), ("2", "Mirror", ""), @@ -45,6 +80,7 @@ ("11", "Src Alpha Sat", "Source alpha saturated") ) + ####################################################### def breakable_obj_poll_func(self, obj): return obj.dff.type == 'BRK' @@ -53,15 +89,46 @@ def breakable_obj_poll_func(self, obj): class MATERIAL_PT_dffMaterials(bpy.types.Panel): bl_idname = "MATERIAL_PT_dffMaterials" - bl_label = "DragonFF - Export Material" + bl_label = "DragonFF - Material Settings" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "material" - ambient : bpy.props.BoolProperty( - name = "Export Material", - default = False - ) + ######################################################## + def update_texture(self, context): + material = getattr(context, "material", None) + if material is None or not material.node_tree: + return + + principled = next((n for n in material.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None) + if not principled: + return + + image_node = None + for link in material.node_tree.links: + if link.to_node == principled and link.to_socket.name == 'Base Color': + if link.from_node.type == 'TEX_IMAGE': + image_node = link.from_node + break + + tex_name = self.tex_name + + if tex_name: + image = bpy.data.images.get(tex_name) + if image: + if not image_node: + image_node = material.node_tree.nodes.new(type='ShaderNodeTexImage') + material.node_tree.links.new(image_node.outputs['Color'], principled.inputs['Base Color']) + material.node_tree.links.new(image_node.outputs['Alpha'], principled.inputs['Alpha']) + + image_node.image = image + image_node.label = tex_name + else: + if image_node: + for link in list(material.node_tree.links): + if link.from_node == image_node: + material.node_tree.links.remove(link) + material.node_tree.nodes.remove(image_node) ####################################################### def draw_col_menu(self, context): @@ -71,17 +138,29 @@ def draw_col_menu(self, context): box = layout.box() box.label(text="Collision properties") - props = [ - ["col_mat_index", "Material"], - ["col_flags", "Flags"], - ["col_brightness", "Brightness"], - ["col_day_light", "Day Light"], - ["col_night_light", "Night Light"], - ] + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Material") + split.prop(settings, "col_mat_index", text="") - for prop in props: - self.draw_labelled_prop(box.row(), settings, [prop[0]], prop[1]) + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Flags") + split.prop(settings, "col_flags", text="") + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Brightness") + split.prop(settings, "col_brightness", text="") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Light") + prop_row = split.row(align=True) + prop_row.prop(settings, "col_day_light", text="Day") + prop_row.prop(settings, "col_night_light", text="Night") + + draw_col_preset_helper(layout, context) ####################################################### def draw_labelled_prop(self, row, settings, props, label, text=""): @@ -93,24 +172,32 @@ def draw_labelled_prop(self, row, settings, props, label, text=""): ####################################################### def draw_texture_prop_box(self, context, box): settings = context.material.dff - box.label(text="Texture properties") - + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Texture") + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "tex_name", bpy.data, "images", text="", icon='IMAGE_DATA') + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False + split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Filtering") split.prop(settings, "tex_filters", text="") - + split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Addressing") - prop_row = split.row(align=True) + + prop_row = split.row(align=True) prop_row.prop(settings, "tex_u_addr", text="") prop_row.prop(settings, "tex_v_addr", text="") ####################################################### def draw_material_prop_box(self, context, box): settings = context.material.dff - box.label(text="Material properties") # This is for conveniently setting the base colour from the settings # without removing the texture node @@ -153,10 +240,14 @@ def draw_bump_map_box(self, context, box): if settings.export_bump_map: split = box.row().split(factor=0.4) - split.alignment = 'LEFT' split.label(text="Texture") - split.prop(settings, "bump_map_tex", text="") - + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "bump_map_tex", bpy.data, "images", text="", icon='IMAGE_DATA') + + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False + split = box.row().split(factor=0.4) split.alignment = 'LEFT' split.label(text="Intensity") @@ -172,9 +263,13 @@ def draw_env_map_box(self, context, box): if settings.export_env_map: split = box.row().split(factor=0.4) - split.alignment = 'LEFT' split.label(text="Texture") - split.prop(settings, "env_map_tex", text="") + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "env_map_tex", bpy.data, "images", text="", icon='IMAGE_DATA') + + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -191,9 +286,13 @@ def draw_dual_tex_box(self, context, box): if settings.export_dual_tex: split = box.row().split(factor=0.4) - split.alignment = 'LEFT' split.label(text="Texture") - split.prop(settings, "dual_tex", text="") + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "dual_tex", bpy.data, "images", text="", icon='IMAGE_DATA') + + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -206,12 +305,20 @@ def draw_dual_tex_box(self, context, box): def draw_uv_anim_box(self, context, box): settings = context.material.dff box.row().prop(settings, "export_animation") - + if settings.export_animation: - split = box.row().split(factor=0.4) + row = box.row(align=True) + + split = row.split(factor=0.4) split.alignment = 'LEFT' split.label(text="Name") - split.prop(settings, "animation_name", text="") + + sub = split.row(align=True) + sub.prop(settings, "animation_name", text="", icon='FCURVE') + + op = sub.operator("material.dff_import_uv_anim", text="", icon='FILEBROWSER') + if context.active_object and context.active_object.active_material: + op.material_name = context.active_object.active_material.name self.draw_labelled_prop( box.row(), settings, ["force_dual_pass"], "Force Dual Pass") @@ -223,9 +330,13 @@ def draw_specl_box(self, context, box): if settings.export_specular: split = box.row().split(factor=0.4) - split.alignment = 'LEFT' split.label(text="Texture") - split.prop(settings, "specular_texture", text="") + + prop_row = split.row(align=True) + prop_row.prop_search(settings, "specular_texture", bpy.data, "images", text="", icon='IMAGE_DATA') + + op = prop_row.operator("image.open", text="", icon='FILEBROWSER') + op.filter_image = True; op.filter_movie = False split = box.row().split(factor=0.4) split.alignment = 'LEFT' @@ -265,14 +376,22 @@ def draw_mesh_menu(self, context): layout = self.layout settings = context.material.dff - self.draw_material_prop_box (context, layout.box()) - self.draw_texture_prop_box (context, layout.box()) - self.draw_bump_map_box (context, layout.box()) - self.draw_env_map_box (context, layout.box()) - self.draw_dual_tex_box (context, layout.box()) - self.draw_uv_anim_box (context, layout.box()) - self.draw_specl_box (context, layout.box()) - self.draw_refl_box (context, layout.box()) + box = layout.box() + box.label(text="Material Properties") + self.draw_material_prop_box (context, box.box()) + self.draw_texture_prop_box (context, box.box()) + + box = layout.box() + box.label(text="Material Effects") + self.draw_bump_map_box (context, box.box()) + self.draw_env_map_box (context, box.box()) + self.draw_dual_tex_box (context, box.box()) + self.draw_uv_anim_box (context, box.box()) + + box = layout.box() + box.label(text="Rockstar Extensions") + self.draw_specl_box (context, box.box()) + self.draw_refl_box (context, box.box()) ####################################################### # Callback function from preset_mat_cols enum @@ -320,6 +439,7 @@ def draw(self, context): self.draw_mesh_menu(context) + #######################################################@ class DFF_MT_ExportChoice(bpy.types.Menu): bl_label = "DragonFF" @@ -385,6 +505,51 @@ def pose_dff_func(self, context): self.layout.separator() self.layout.menu("DFF_MT_Pose", text="DragonFF") +####################################################### +class MATERIAL_OT_dff_import_uv_anim(bpy.types.Operator, material_helper): + bl_idname = "material.dff_import_uv_anim" + bl_label = "Import UV Animation" + bl_description = "Import UV animation from another DFF file" + + filename_ext = ".dff" + filter_glob: bpy.props.StringProperty(default="*.dff", options={'HIDDEN'}) + filepath: bpy.props.StringProperty(subtype='FILE_PATH') + material_name: bpy.props.StringProperty(options={'HIDDEN'}) + + def invoke(self, context, event): + if not self.filepath: + self.filepath = "" + + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + + def execute(self, context): + material = bpy.data.materials.get(self.material_name) + if not material: + self.report({'ERROR'}, "Material not found") + return {'CANCELLED'} + + try: + dff_instance = dff() + dff_instance.load_file(self.filepath) + + if not dff_instance.uvanim_dict: + self.report({'ERROR'}, "No UV animation found in file") + return {'CANCELLED'} + + uv_anim = dff_instance.uvanim_dict[0] + + helper = material_helper(material) + helper.set_uv_animation(uv_anim) + + self.report({'INFO'}, f"Imported UV animation: {uv_anim.name}") + + except Exception as e: + self.report({'ERROR'}, f"Failed to import: {str(e)}") + return {'CANCELLED'} + + return {'FINISHED'} + ####################################################### class OBJECT_PT_dffObjects(bpy.types.Panel): @@ -490,12 +655,30 @@ def draw_col_menu(self, context): box = layout.box() box.label(text="Material Surface") - - box.prop(settings, "col_material", text="Material") - box.prop(settings, "col_flags", text="Flags") - box.prop(settings, "col_brightness", text="Brightness") - box.prop(settings, "col_day_light", text="Day Light") - box.prop(settings, "col_night_light", text="Night Light") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Material") + split.prop(settings, "col_material", text="") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Flags") + split.prop(settings, "col_flags", text="") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Brightness") + split.prop(settings, "col_brightness", text="") + + split = box.row().split(factor=0.4) + split.alignment = 'LEFT' + split.label(text="Light") + prop_row = split.row(align=True) + prop_row.prop(settings, "col_day_light", text="Day") + prop_row.prop(settings, "col_night_light", text="Night") + + draw_col_preset_helper(layout, context) ####################################################### def draw_2dfx_menu(self, context): @@ -612,32 +795,267 @@ def draw(self, context): self.draw_collection_menu(context) # Custom properties +####################################################### +class TXDTextureListProps(bpy.types.PropertyGroup): + active_index : bpy.props.IntProperty(name="Active Texture Index", default=0) + +####################################################### +class TXDImageProps(bpy.types.PropertyGroup): + image_raster : bpy.props.EnumProperty (items = texture_raster_items, default="0") + image_palette : bpy.props.EnumProperty (items = texture_palette_items, default="0") + image_compression : bpy.props.EnumProperty (items = texture_compression_items, default="0") + image_mipmap : bpy.props.EnumProperty (items = texture_mipmap_items, default="1") + image_filter : bpy.props.EnumProperty (items = texture_filter_items, default="6") + image_uaddress : bpy.props.EnumProperty (items = texture_uvaddress_items, default="1") + image_vaddress : bpy.props.EnumProperty (items = texture_uvaddress_items, default="1") + +####################################################### +class TEXTURES_UL_txd_image_list(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + layout.label(text=item.name, icon_value=item.preview.icon_id) + + def filter_items(self, context, data, propname): + images = getattr(data, propname) + flt_flags = [0] * len(images) + flt_order = list(range(len(images))) + + obj = context.active_object + if not obj: + return flt_flags, flt_order + + used_names = set() + + for slot in obj.material_slots: + if not slot.material: + continue + m = slot.material + if not m.dff: + continue + d = m.dff + for tex in (d.tex_name, d.env_map_tex, d.bump_map_tex, + d.dual_tex, d.specular_texture): + if tex: + used_names.add(tex) + + used_images = set() + for slot in obj.material_slots: + if not slot.material or not slot.material.node_tree: + continue + m = slot.material + for node in m.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image: + used_images.add(node.image.name) + + for i, img in enumerate(images): + if not hasattr(img, 'pixels') or len(img.pixels) == 0 or img.size[0] == 0 or img.size[1] == 0: + flt_flags[i] = 0 + continue + + name = img.name + if '/' in name: + parts = name.split('/') + if len(parts) >= 2: + tex_name = parts[1] + else: + tex_name = name + else: + tex_name = name.rsplit('.', 1)[0] if '.' in name else name + + if name in used_names or tex_name in used_names or name in used_images: + flt_flags[i] = self.bitflag_filter_item + + return flt_flags, flt_order + +######################################################## +class MATERIAL_PT_txdTextures(bpy.types.Panel): + bl_label = "DragonFF - Texture Settings" + bl_space_type = 'PROPERTIES' + bl_region_type = 'WINDOW' + bl_context = "material" + + @classmethod + def poll(cls, context): + return context.active_object is not None + + def draw(self, context): + layout = self.layout + obj = context.active_object + + if not obj: + return + + layout.template_list( + "TEXTURES_UL_txd_image_list", + "", + bpy.data, + "images", + context.scene.dff_txd_texture_list, + "active_index", + rows=3 + ) + + active_idx = context.scene.dff_txd_texture_list.active_index + + if active_idx < 0 or active_idx >= len(bpy.data.images): + return + + img = bpy.data.images[active_idx] + + if not hasattr(img, "dff"): + return + + # Check if this image is being used + is_used = False + + name = img.name + if '/' in name: + parts = name.split('/') + if len(parts) >= 2: + tex_name = parts[1] + else: + tex_name = name + else: + tex_name = name.rsplit('.', 1)[0] if '.' in name else name + + for slot in obj.material_slots: + if not slot.material: + continue + + # Check for material effects textures + if hasattr(slot.material, 'dff'): + d = slot.material.dff + for tex in (d.tex_name, d.env_map_tex, d.bump_map_tex, + d.dual_tex, d.specular_texture): + if tex and (tex == name or tex == tex_name): + is_used = True + break + + if is_used: + break + + if slot.material.node_tree: + for node in slot.material.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image == img: + is_used = True + break + + if is_used: + break + + if not is_used: + return + + settings = img.dff + + box = layout.box() + row = box.row() + split = row.split(factor=0.4) + + # Preview + preview_box = split.box() + preview_col = preview_box.column(align=True) + + if img.preview.icon_id != 0: + preview_col.template_icon(img.preview.icon_id, scale=6.6) + else: + preview_col.label(text="No preview available") + + # Properties Box + props_box = split.box() + + split = props_box.split(factor=0.4) + split.label(text="Raster") + split.prop(settings, "image_raster", text="") + + split = props_box.split(factor=0.4) + split.label(text="Compression") + split.prop(settings, "image_compression", text="") + + split = props_box.split(factor=0.4) + split.label(text="Mipmaps") + split.prop(settings, "image_mipmap", text="") + + split = props_box.split(factor=0.4) + split.label(text="Filtering") + split.prop(settings, "image_filter", text="") + + split = props_box.split(factor=0.4) + split.label(text="Addressing") + addr = split.row(align=True) + addr.prop(settings, "image_uaddress", text="") + addr.prop(settings, "image_vaddress", text="") + +######################################################## +def set_texture_fake_user(self, context): + # This is needed, otherwise when saving .blend files, blender wont save textures used in material effects + + if getattr(context, "material", None) is None: + return + + if not context.material: + return + + # Clear all fake users + for image in bpy.data.images: + if image.use_fake_user: + + # Check if this image is used in any other material + still_used = False + for mat in bpy.data.materials: + if hasattr(mat, 'dff'): + effect_textures = [ + mat.dff.env_map_tex, + mat.dff.bump_map_tex, + mat.dff.dual_tex, + mat.dff.specular_texture + ] + + if image.name in effect_textures: + still_used = True + break + + if not still_used: + image.use_fake_user = False + + # Set fake users again for currently used textures + for mat in bpy.data.materials: + if hasattr(mat, 'dff'): + for tex_name in [ + mat.dff.env_map_tex, + mat.dff.bump_map_tex, + mat.dff.dual_tex, + mat.dff.specular_texture + ]: + if tex_name and tex_name in bpy.data.images: + bpy.data.images[tex_name].use_fake_user = True + ####################################################### class DFFMaterialProps(bpy.types.PropertyGroup): ambient : bpy.props.FloatProperty (name="Ambient Shading", default=0.5) specular : bpy.props.FloatProperty (name="Specular Lighting", default=0.5) diffuse : bpy.props.FloatProperty (name="Diffuse Intensity", default=0.5) - tex_filters : bpy.props.EnumProperty (items=texture_filters_items, default="0") - tex_u_addr : bpy.props.EnumProperty (name="", items=texture_uv_addressing_items, default="0") - tex_v_addr : bpy.props.EnumProperty (name="", items=texture_uv_addressing_items, default="0") + tex_name : bpy.props.StringProperty (name="Texture", update=MATERIAL_PT_dffMaterials.update_texture) + tex_filters : bpy.props.EnumProperty (items=texture_filter_items, default="0") + tex_u_addr : bpy.props.EnumProperty (name="", items=texture_uvaddress_items, default="0") + tex_v_addr : bpy.props.EnumProperty (name="", items=texture_uvaddress_items, default="0") # Environment Map export_env_map : bpy.props.BoolProperty (name="Environment Map") - env_map_tex : bpy.props.StringProperty () + env_map_tex : bpy.props.StringProperty (update=set_texture_fake_user) env_map_coef : bpy.props.FloatProperty (default=1.0) env_map_fb_alpha : bpy.props.BoolProperty () # Bump Map export_bump_map : bpy.props.BoolProperty (name="Bump Map") bump_map_intensity : bpy.props.FloatProperty (default=1.0) - bump_map_tex : bpy.props.StringProperty () + bump_map_tex : bpy.props.StringProperty (update=set_texture_fake_user) height_map_tex : bpy.props.StringProperty () # Store internally just in case bump_dif_alpha : bpy.props.BoolProperty (name="Use Diffuse Alpha") # Dual Texture export_dual_tex : bpy.props.BoolProperty (name="Dual Texture") - dual_tex : bpy.props.StringProperty () + dual_tex : bpy.props.StringProperty (update=set_texture_fake_user) dual_src_blend : bpy.props.EnumProperty (name="", items=texture_blend_items, default="5") dual_dst_blend : bpy.props.EnumProperty (name="", items=texture_blend_items, default="6") @@ -670,7 +1088,7 @@ class DFFMaterialProps(bpy.types.PropertyGroup): # Specularity export_specular : bpy.props.BoolProperty(name="Specular Material") specular_level : bpy.props.FloatProperty (default=0.1) - specular_texture : bpy.props.StringProperty () + specular_texture : bpy.props.StringProperty (update=set_texture_fake_user) # Pre-set Specular Level preset_specular_levels : bpy.props.EnumProperty( @@ -881,6 +1299,9 @@ class DFFObjectProps(bpy.types.PropertyGroup): # CULL properties cull: bpy.props.PointerProperty(type=CULLObjectProps) + # COL properties + col_mat: bpy.props.PointerProperty(type=COLMaterialEnumProps) + # Miscellaneous properties is_frame_locked : bpy.props.BoolProperty() diff --git a/gui/dff_ot.py b/gui/dff_ot.py index e6019f6..666eb12 100644 --- a/gui/dff_ot.py +++ b/gui/dff_ot.py @@ -695,23 +695,76 @@ class EXPORT_OT_txd(bpy.types.Operator, ExportHelper): default="", subtype='DIR_PATH') - mass_export : bpy.props.BoolProperty( - name = "Mass Export", + selected_only : bpy.props.BoolProperty( + name = "Selected Only", + description = "Export textures only from selected objects", + default = False + ) + + separate_files : bpy.props.BoolProperty( + name = "Separate Files", + description = "Export a separate TXD file for each object", default = False ) only_used_textures : bpy.props.BoolProperty( name = "Only Used Textures", - description = "Export only textures that are used in the scene materials", + description = "Export only textures that are currently used", default = True ) + dxt_quality: bpy.props.EnumProperty( + name="Quality", + description="Change the compression algorithm used", + items=[ + ("Poor", "Poor", "Poor quality, fast speed"), + ("Good", "Good", "Good quality, good speed"), + ("Best", "Best", "Best quality, slow speed"), + ], + default='Good' + ) + + dxt_metric: bpy.props.EnumProperty( + name="Metric", + description="Change the color metric used", + items=[ + ("Uniform", "Uniform", "Uniform color weights"), + ("Perceptual", "Perceptual", "Perceptual color weights"), + ], + default='Perceptual' + ) + ####################################################### def draw(self, context): layout = self.layout - layout.prop(self, "mass_export") - layout.prop(self, "only_used_textures") + # Export settings + main_box = layout.box() + main_box.label(text="Export Settings") + + row = main_box.row() + row.label(text="Selected Only") + row.prop(self, "selected_only", text="") + + row = main_box.row() + row.label(text="Separate Files") + row.prop(self, "separate_files", text="") + + row = main_box.row() + row.label(text="Used Textures") + row.prop(self, "only_used_textures", text="") + + # Compression settings + box = layout.box() + box.label(text="Compression Settings") + + split = box.row().split(factor=0.3) + split.label(text="Quality") + split.prop(self, "dxt_quality", text="") + + split = box.row().split(factor=0.3) + split.label(text="Metric") + split.prop(self, "dxt_metric", text="") return None @@ -724,8 +777,11 @@ def execute(self, context): { "file_name" : self.filepath, "directory" : self.directory, - "mass_export" : self.mass_export, + "selected_only" : self.selected_only, + "separate_files" : self.separate_files, "only_used_textures" : self.only_used_textures, + "dxt_quality" : self.dxt_quality, + "dxt_metric" : self.dxt_metric, "version" : 0x36003, # TODO: more versions support } ) diff --git a/lib/squish.dll b/lib/squish.dll new file mode 100644 index 0000000..e80eb9e Binary files /dev/null and b/lib/squish.dll differ diff --git a/lib/squish.py b/lib/squish.py new file mode 100644 index 0000000..29198b8 --- /dev/null +++ b/lib/squish.py @@ -0,0 +1,151 @@ +import ctypes +import os + +class SquishFlags: + BC1 = 1 << 0 # BC1 - 8 bytes / 4x4 block + BC2 = 1 << 1 # BC2 - 16 bytes / 4x4 block + BC3 = 1 << 2 # BC3 - 16 bytes / 4x4 block + BC4 = 1 << 3 # BC4 - 16 bytes / 4x4 block + BC5 = 1 << 4 # BC5 - 16 bytes / 4x4 block + + QUALITY_CLUSTER = 1 << 5 # good quality, good speed + QUALITY_RANGE = 1 << 6 # poor quality, fast speed + QUALITY_ITERATIVE = 1 << 8 # best quality, slow speed + + WEIGHT_COLOR_BY_ALPHA = 1 << 7 + SOURCE_IS_BGRA = 1 << 9 + +class SquishCompressor: + def __init__(self, dll_path=None): + if dll_path is None: + lib_dir = os.path.dirname(__file__) + candidates = [ + os.path.join(lib_dir, "squish.dll"), + os.path.join(lib_dir, "squish.so"), + ] + for candidate in candidates: + if os.path.exists(candidate): + dll_path = candidate + break + + if dll_path is None: + raise FileNotFoundError( + f"Could not find squish library" + ) + + self.squish = ctypes.CDLL(dll_path) + self._setup_function_signatures() + + def _setup_function_signatures(self): + self.squish.GetStorageRequirements.argtypes = [ + ctypes.c_int, ctypes.c_int, ctypes.c_int + ] + self.squish.GetStorageRequirements.restype = ctypes.c_int + + self.squish.CompressImage.argtypes = [ + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_int, ctypes.c_int, + ctypes.c_void_p, + ctypes.c_int, + ctypes.POINTER(ctypes.c_float) + ] + + self.squish.DecompressImage.argtypes = [ + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_int, ctypes.c_int, + ctypes.c_void_p, + ctypes.c_int + ] + + def compress(self, rgba_data, width, height, compression_type='DXT5', + quality='Good', metric='Uniform', premultiply_alpha=False): + + # Build compression flags + flags = SquishFlags.WEIGHT_COLOR_BY_ALPHA + + # Compression type + if compression_type == 'DXT1': + flags |= SquishFlags.BC1 + elif compression_type in ('DXT2', 'DXT3'): + flags |= SquishFlags.BC2 + if compression_type == 'DXT2': + premultiply_alpha = True + elif compression_type in ('DXT4', 'DXT5'): + flags |= SquishFlags.BC3 + if compression_type == 'DXT4': + premultiply_alpha = True + else: + raise ValueError(f"Unknown compression type: {compression_type}") + + # Quality setting + if quality == 'Best': + flags |= SquishFlags.QUALITY_ITERATIVE + elif quality == 'Good': + flags |= SquishFlags.QUALITY_CLUSTER + elif quality == 'Poor': + flags |= SquishFlags.QUALITY_RANGE + else: + raise ValueError(f"Unknown quality setting: {quality}") + + # Color metric weights + if metric == 'Perceptual': + metric_weights = (ctypes.c_float * 3)(0.2126, 0.7152, 0.0722) + metric_ptr = ctypes.cast(metric_weights, ctypes.POINTER(ctypes.c_float)) + else: + metric_ptr = None + + # Premultiply alpha if needed + if premultiply_alpha: + for i in range(0, len(rgba_data), 4): + alpha_factor = rgba_data[i + 3] / 255.0 + rgba_data[i + 0] = int(rgba_data[i + 0] * alpha_factor) + rgba_data[i + 1] = int(rgba_data[i + 1] * alpha_factor) + rgba_data[i + 2] = int(rgba_data[i + 2] * alpha_factor) + + + rgba_size = width * height * 4 + rgba_array = (ctypes.c_ubyte * rgba_size).from_buffer(rgba_data) + + compressed_size = self.squish.GetStorageRequirements(width, height, flags) + compressed_data = bytearray(compressed_size) + compressed_array = (ctypes.c_ubyte * compressed_size).from_buffer(compressed_data) + + self.squish.CompressImage( + rgba_array, width, height, + compressed_array, flags, metric_ptr + ) + + return bytes(compressed_data) + + def decompress(self, compressed_data, width, height, compression_type='DXT5'): + + flags = 0 + + if compression_type in ('DXT1', 'DXT2'): + flags |= SquishFlags.BC1 + elif compression_type in ('DXT3', 'DXT4'): + flags |= SquishFlags.BC2 + elif compression_type == 'DXT5': + flags |= SquishFlags.BC3 + + rgba_size = width * height * 4 + rgba_data = bytearray(rgba_size) + rgba_array = (ctypes.c_ubyte * rgba_size).from_buffer(rgba_data) + + compressed_size = len(compressed_data) + compressed_array = (ctypes.c_ubyte * compressed_size).from_buffer_copy(compressed_data) + + self.squish.DecompressImage( + rgba_array, width, height, + compressed_array, flags + ) + + return bytes(rgba_data) + +_compressor = None + +def get_compressor(): + global _compressor + if _compressor is None: + _compressor = SquishCompressor() + return _compressor \ No newline at end of file diff --git a/ops/col_importer.py b/ops/col_importer.py index 5f7496b..96eef61 100644 --- a/ops/col_importer.py +++ b/ops/col_importer.py @@ -53,6 +53,18 @@ def __add_spheres(self, collection, array): for index, entity in enumerate(array): name = collection.name + ".ColSphere.%d" % index + # Check if this is a vehicle sphere + if entity.surface.material in (6, 7, 45, 63, 64, 65): + + presets = mats.COL_PRESET_SA if col.Sections.version == 3 else mats.COL_PRESET_VC + + for preset in presets: + if (preset[0] == 13 and + preset[1] == entity.surface.material and + preset[2] == entity.surface.flags): + name = collection.name + "." + preset[3].replace(" ", "_") + break + obj = bpy.data.objects.new(name, None) obj.location = entity.center @@ -124,6 +136,7 @@ def __add_mesh_mats(self, object, materials): mat = bpy.data.materials.new(name) mat.dff.col_mat_index = surface.material + mat.dff.col_flags = surface.flags mat.dff.col_brightness = surface.brightness mat.dff.col_day_light = surface.light & 0xf mat.dff.col_night_light = (surface.light >> 4) & 0xf diff --git a/ops/dff_exporter.py b/ops/dff_exporter.py index ad851b0..b981c4d 100755 --- a/ops/dff_exporter.py +++ b/ops/dff_exporter.py @@ -37,6 +37,16 @@ class material_helper: """ Material Helper for Blender 2.7x and 2.8 compatibility""" + ######################################################## + def clean_texture_name(self, name): + if '/' in name: + parts = name.split('/') + if len(parts) >= 2: + name = parts[1] + + k = name.rfind('.') + return name if k < 0 else name[:k] + ####################################################### def get_base_color(self): @@ -103,7 +113,7 @@ def get_bump_map(self): if not self.material.dff.export_bump_map: return None - bump_texture_name = self.material.dff.bump_map_tex + bump_texture_name = self.clean_texture_name(self.material.dff.bump_map_tex) intensity = self.material.dff.bump_map_intensity bump_dif_alpha = self.material.dff.bump_dif_alpha @@ -128,7 +138,7 @@ def get_environment_map(self): if not self.material.dff.export_env_map: return None - texture_name = self.material.dff.env_map_tex + texture_name = self.clean_texture_name(self.material.dff.env_map_tex) coef = self.material.dff.env_map_coef use_fb_alpha = self.material.dff.env_map_fb_alpha @@ -144,7 +154,7 @@ def get_dual_texture(self): if not self.material.dff.export_dual_tex: return None - texture_name = self.material.dff.dual_tex + texture_name = self.clean_texture_name(self.material.dff.dual_tex) src_blend = int(self.material.dff.dual_src_blend) dst_blend = int(self.material.dff.dual_dst_blend) @@ -162,8 +172,10 @@ def get_specular_material(self): if not props.export_specular: return None + clean_name = self.clean_texture_name(props.specular_texture) + return dff.SpecularMat(props.specular_level, - props.specular_texture.encode('ascii')) + clean_name.encode('ascii')) ####################################################### def get_reflection_material(self): diff --git a/ops/dff_importer.py b/ops/dff_importer.py index 52e390a..7e29d78 100755 --- a/ops/dff_importer.py +++ b/ops/dff_importer.py @@ -30,6 +30,7 @@ from .col_importer import import_col_mem from ..ops.ext_2dfx_importer import ext_2dfx_importer from ..ops.state import State +from ..gtaLib.dff import strlen ####################################################### class dff_importer: @@ -518,10 +519,12 @@ def import_materials(geometry, frame, mesh, mat_order): mat.dff.bump_map_tex = bump_fx.bump_map.name mat.dff.bump_dif_alpha = True mat.dff.bump_map_intensity = bump_fx.intensity + bump_img = self.find_texture_image(bump_fx.bump_map.name) elif bump_fx.bump_map is not None: mat.dff.bump_map_tex = bump_fx.bump_map.name mat.dff.bump_map_intensity = bump_fx.intensity + bump_img = self.find_texture_image(bump_fx.bump_map.name) # Surface Properties if material.surface_properties is not None: @@ -537,16 +540,19 @@ def import_materials(geometry, frame, mesh, mat_order): if 'env_map' in material.plugins: plugin = material.plugins['env_map'][0] helper.set_environment_map(plugin) + env_img = self.find_texture_image(plugin.env_map.name) if plugin.env_map else None # Dual Texture if 'dual' in material.plugins: plugin = material.plugins['dual'][0] helper.set_dual_texture(plugin) + dual_img = self.find_texture_image(plugin.texture.name) if plugin.texture else None # Specular Material if 'spec' in material.plugins: plugin = material.plugins['spec'][0] helper.set_specular_material(plugin) + spec_img = self.find_texture_image(plugin.texture[:strlen(plugin.texture)].decode('ascii')) if plugin.texture else None # Reflection Material if 'refl' in material.plugins: diff --git a/ops/txd_exporter.py b/ops/txd_exporter.py index f1cf0f0..1ea347c 100644 --- a/ops/txd_exporter.py +++ b/ops/txd_exporter.py @@ -21,6 +21,27 @@ from ..gtaLib import txd from ..gtaLib.txd import ImageEncoder from ..gtaLib.dff import NativePlatformType +from ..lib import squish + +_RASTER_TO_FORMAT = { + "0": None, + "1": txd.RasterFormat.RASTER_8888, + "2": txd.RasterFormat.RASTER_4444, + "3": txd.RasterFormat.RASTER_1555, + "4": txd.RasterFormat.RASTER_888, + "5": txd.RasterFormat.RASTER_565, + "6": txd.RasterFormat.RASTER_555, + "7": txd.RasterFormat.RASTER_LUM, +} + +_COMPRESSION_TO_DXT = { + "0": None, + "1": "DXT1", + "2": "DXT2", + "3": "DXT3", + "4": "DXT4", + "5": "DXT5", +} ####################################################### def clear_extension(string): @@ -29,8 +50,11 @@ def clear_extension(string): ####################################################### class txd_exporter: + dxt_quality = 'Good' # 'Best', 'Good', 'Poor' + dxt_metric = 'Perceptual' # 'Uniform', 'Perceptual - mass_export = False + selected_only = False + separate_files = False only_used_textures = True version = None file_name = "" @@ -43,17 +67,28 @@ def _create_texture_native_from_image(image, image_name): pixels = list(image.pixels) width, height = image.size + image_palette = getattr(image.dff, 'image_palette', '0') # TODO + image_raster = _RASTER_TO_FORMAT[getattr(image.dff, 'image_raster', '0')] + image_compression = _COMPRESSION_TO_DXT[getattr(image.dff, 'image_compression', '5')] + image_mipmap = getattr(image.dff, 'image_mipmap', '1') == '1' + image_filter = int(getattr(image.dff, 'image_filter', '6')) + image_uaddress = int(getattr(image.dff, 'image_uaddress', '1')) + image_vaddress = int(getattr(image.dff, 'image_vaddress', '1')) + rgba_data = bytearray() for h in range(height - 1, -1, -1): offset = h * width * 4 row_pixels = pixels[offset:offset + width * 4] rgba_data.extend(int(round(p * 0xff)) for p in row_pixels) + # Detect if image has alpha channel + has_alpha = txd_exporter.detect_alpha_channel(rgba_data) + texture_native = txd.TextureNative() texture_native.platform_id = NativePlatformType.D3D9 - texture_native.filter_mode = 0x06 # Linear Mip Linear (Trilinear) - texture_native.uv_addressing = 0b00010001 # Wrap for both U and V - + texture_native.filter_mode = image_filter + texture_native.uv_addressing = image_uaddress << 4 | image_vaddress + # Clean texture name - remove invalid characters and limit length clean_name = "".join(c for c in image_name if c.isalnum() or c in "_-.") clean_name = clean_name[:31] # Limit to 31 chars (32 with null terminator) @@ -61,32 +96,222 @@ def _create_texture_native_from_image(image, image_name): clean_name = "texture" texture_native.name = clean_name texture_native.mask = "" - - # Raster format flags for RGBA8888: format type (8888=5) at bit 8-11, no mipmaps, no palette - texture_native.raster_format_flags = txd.RasterFormat.RASTER_8888 << 8 - texture_native.d3d_format = txd.D3DFormat.D3D_8888 + + if image_compression is not None: + # Raster format flags: set the format type based on DXT variant and alpha + if image_compression == 'DXT1': + if has_alpha: + texture_native.raster_format_flags = txd.RasterFormat.RASTER_1555 << 8 + else: + texture_native.raster_format_flags = txd.RasterFormat.RASTER_565 << 8 + else: + texture_native.raster_format_flags = txd.RasterFormat.RASTER_4444 << 8 + + if image_compression == 'DXT1': + texture_native.d3d_format = txd.D3DFormat.D3D_DXT1 + elif image_compression == 'DXT2': + texture_native.d3d_format = txd.D3DFormat.D3D_DXT2 + elif image_compression == 'DXT3': + texture_native.d3d_format = txd.D3DFormat.D3D_DXT3 + elif image_compression == 'DXT4': + texture_native.d3d_format = txd.D3DFormat.D3D_DXT4 + elif image_compression == 'DXT5': + texture_native.d3d_format = txd.D3DFormat.D3D_DXT5 + + texture_native.depth = 16 + + texture_native.platform_properties = type('PlatformProperties', (), { + 'alpha': has_alpha, + 'cube_texture': False, + 'auto_mipmaps': False, + 'compressed': True + })() + + else: + if image_raster is not None: + texture_native.raster_format_flags = image_raster << 8 + else: + # We pick a format based on alpha presence + if has_alpha: + texture_native.raster_format_flags = txd.RasterFormat.RASTER_8888 << 8 + else: + texture_native.raster_format_flags = txd.RasterFormat.RASTER_888 << 8 + + texture_native.d3d_format = txd_exporter.get_d3d_from_raster(texture_native.get_raster_format_type()) + texture_native.depth = txd_exporter.get_depth_from_raster(texture_native.get_raster_format_type()) + + # Platform properties + texture_native.platform_properties = type('PlatformProperties', (), { + 'alpha': has_alpha, + 'cube_texture': False, + 'auto_mipmaps': False, + 'compressed': False + })() + texture_native.width = width texture_native.height = height - texture_native.depth = 32 texture_native.num_levels = 1 texture_native.raster_type = 4 # Texture - - texture_native.platform_properties = type('PlatformProperties', (), { - 'alpha': True, - 'cube_texture': False, - 'auto_mipmaps': False, - 'compressed': False - })() - - # No palette for RGBA8888 format + + # No palette for any format we're using texture_native.palette = b'' - - # Convert RGBA to BGRA8888 format - pixel_data = ImageEncoder.rgba_to_bgra8888(rgba_data) - texture_native.pixels = [pixel_data] - + + # Generate mipmaps + if image_mipmap: + mip_levels = txd_exporter.generate_mipmaps(rgba_data, width, height) + texture_native.raster_format_flags |= (1 << 15) # has_mipmaps + else: + mip_levels = [(width, height, rgba_data)] + + texture_native.num_levels = len(mip_levels) + + # Encode pixels based on compression type + if image_compression is not None: + # DXT compression path + compressor = squish.get_compressor() + texture_native.pixels = [] + + # Determine if we need to premultiply alpha (DXT2/DXT4) + premultiply = image_compression in ('DXT2', 'DXT4') + + for mip_width, mip_height, level_data in mip_levels: + compressed = compressor.compress( + level_data, + mip_width, + mip_height, + image_compression, + quality=txd_exporter.dxt_quality, + metric=txd_exporter.dxt_metric, + premultiply_alpha=premultiply + ) + texture_native.pixels.append(compressed) + + else: + encoder = txd_exporter.get_encoder_from_raster(texture_native.get_raster_format_type()) + + texture_native.pixels = [ + txd_exporter.pad_mipmap_level( + encoder(level_data), + mip_width, + mip_height, + texture_native.depth + ) + for mip_width, mip_height, level_data in mip_levels + ] + return texture_native + ####################################################### + @staticmethod + def detect_alpha_channel(rgba_data): + for i in range(3, len(rgba_data), 4): + if rgba_data[i] < 255: + return True + return False + + ######################################################## + @staticmethod + def get_encoder_from_raster(raster_format): + return { + txd.RasterFormat.RASTER_8888: ImageEncoder.rgba_to_bgra8888, + txd.RasterFormat.RASTER_888: ImageEncoder.rgba_to_bgra888, + txd.RasterFormat.RASTER_4444: ImageEncoder.rgba_to_rgba4444, + txd.RasterFormat.RASTER_1555: ImageEncoder.rgba_to_rgba1555, + txd.RasterFormat.RASTER_565: ImageEncoder.rgba_to_rgb565, + txd.RasterFormat.RASTER_555: ImageEncoder.rgba_to_rgb555, + txd.RasterFormat.RASTER_LUM: ImageEncoder.rgba_to_lum8, + }.get(raster_format, None) + + ####################################################### + @staticmethod + def get_depth_from_raster(raster_format): + return { + txd.RasterFormat.RASTER_8888: 32, + txd.RasterFormat.RASTER_888: 24, + txd.RasterFormat.RASTER_4444: 16, + txd.RasterFormat.RASTER_1555: 16, + txd.RasterFormat.RASTER_565: 16, + txd.RasterFormat.RASTER_555: 16, + txd.RasterFormat.RASTER_LUM: 8, + }.get(raster_format, 0) + + ####################################################### + @staticmethod + def get_d3d_from_raster(raster_format): + return { + txd.RasterFormat.RASTER_8888: txd.D3DFormat.D3D_8888, + txd.RasterFormat.RASTER_888: txd.D3DFormat.D3D_888, + txd.RasterFormat.RASTER_4444: txd.D3DFormat.D3D_4444, + txd.RasterFormat.RASTER_1555: txd.D3DFormat.D3D_1555, + txd.RasterFormat.RASTER_565: txd.D3DFormat.D3D_565, + txd.RasterFormat.RASTER_555: txd.D3DFormat.D3D_555, + txd.RasterFormat.RASTER_LUM: txd.D3DFormat.D3DFMT_L8, + }.get(raster_format, 0) + + ####################################################### + @staticmethod + def pad_mipmap_level(pixel_data, width, height, depth): + # Calculate D3D9-aligned row size + row_bytes = (width * depth + 7) // 8 + row_size = ((row_bytes + 3) // 4) * 4 + aligned_size = row_size * height + + if len(pixel_data) < aligned_size: + padded = bytearray(pixel_data) + padded.extend(b'\x00' * (aligned_size - len(pixel_data))) + return bytes(padded) + + return pixel_data + + ####################################################### + @staticmethod + def generate_mipmaps(rgba_data, width, height): + # Generates full mipmap chain including 1x1 similar to how magictxd does it with 2x2 box filter, edge clamp, float averaging + round to nearest + mipmaps = [(width, height, rgba_data)] + + current_width = width + current_height = height + current_data = rgba_data + + while current_width > 1 or current_height > 1: + new_width = max(1, current_width // 2) + new_height = max(1, current_height // 2) + + new_data = bytearray(new_width * new_height * 4) + + for y in range(new_height): + for x in range(new_width): + r_sum = g_sum = b_sum = a_sum = 0.0 + for dy in range(2): + sy = min(y * 2 + dy, current_height - 1) + row_offset = sy * current_width * 4 + for dx in range(2): + sx = min(x * 2 + dx, current_width - 1) + offset = row_offset + sx * 4 + + r_sum += current_data[offset] + g_sum += current_data[offset + 1] + b_sum += current_data[offset + 2] + a_sum += current_data[offset + 3] + avg_r = round(r_sum / 4.0) + avg_g = round(g_sum / 4.0) + avg_b = round(b_sum / 4.0) + avg_a = round(a_sum / 4.0) + + out_offset = (y * new_width + x) * 4 + new_data[out_offset] = int(avg_r) + new_data[out_offset + 1] = int(avg_g) + new_data[out_offset + 2] = int(avg_b) + new_data[out_offset + 3] = int(avg_a) + + mipmaps.append((new_width, new_height, new_data)) + + current_width = new_width + current_height = new_height + current_data = new_data + + return mipmaps + ####################################################### @staticmethod def extract_texture_info_from_name(name): @@ -103,10 +328,10 @@ def extract_texture_info_from_name(name): def get_used_textures(objects_to_scan=None): """Collect textures that are used in scene materials""" used_textures = set() - + # Use provided objects or all scene objects objects = objects_to_scan if objects_to_scan is not None else bpy.context.scene.objects - + for obj in objects: for mat_slot in obj.material_slots: mat = mat_slot.material @@ -119,10 +344,64 @@ def get_used_textures(objects_to_scan=None): for node in node_tree.nodes: if node.type == 'TEX_IMAGE': + if not node.image: + continue + texture_name = clear_extension(node.label or node.image.name) used_textures.add((texture_name, node.image)) return used_textures + + ####################################################### + @staticmethod + def get_material_effects_textures(objects_to_scan=None): + effects_textures = set() + + objects = objects_to_scan if objects_to_scan is not None else bpy.context.scene.objects + + for obj in objects: + for mat_slot in obj.material_slots: + mat = mat_slot.material + if not mat or not hasattr(mat, 'dff'): + continue + + texture_names = [ + mat.dff.env_map_tex, + mat.dff.bump_map_tex, + mat.dff.dual_tex, + mat.dff.specular_texture + ] + + for tex_name in texture_names: + if not tex_name or not tex_name.strip(): + continue + + image = bpy.data.images.get(tex_name) + + if not image: + for img in bpy.data.images: + + if '/' in img.name: + parts = img.name.split('/') + if len(parts) >= 2: + if parts[1] == tex_name: + image = img + break + + elif clear_extension(img.name) == tex_name: + image = img + break + + if image: + texture_name, mipmap_level = txd_exporter.extract_texture_info_from_name(image.name) + + if mipmap_level > 0: + continue + + texture_name = clear_extension(texture_name) + effects_textures.add((texture_name, image)) + + return effects_textures ####################################################### @staticmethod @@ -130,17 +409,37 @@ def populate_textures(objects_to_scan=None): self = txd_exporter self.txd.native_textures = [] + all_textures = {} # Use dict to avoid duplicates: {image: texture_name} # Determine which textures to export based on context if objects_to_scan is not None: - # Mass export mode: only export textures used by specific objects + # Specific objects mode: only export textures used by these objects used_textures = self.get_used_textures(objects_to_scan) + effects_textures = self.get_material_effects_textures(objects_to_scan) + + # Combine both sets + for texture_name, image in used_textures: + all_textures[image] = texture_name + for texture_name, image in effects_textures: + # Only add if not already present (prioritize used_textures naming) + if image not in all_textures: + all_textures[image] = texture_name + elif self.only_used_textures: # Single export with "only used textures" option used_textures = self.get_used_textures() + effects_textures = self.get_material_effects_textures() + + # Combine both sets + for texture_name, image in used_textures: + all_textures[image] = texture_name + for texture_name, image in effects_textures: + # Only add if not already present (prioritize used_textures naming) + if image not in all_textures: + all_textures[image] = texture_name + else: # Single export, all textures - used_textures = set() for image in bpy.data.images: # Skip invalid/system textures if (image.name.startswith("//") or @@ -149,7 +448,7 @@ def populate_textures(objects_to_scan=None): image.size[0] == 0 or image.size[1] == 0): continue - # Extract texture name from node.label (in case it follows TXD naming pattern) + # Extract texture name (in case it follows TXD naming pattern) texture_name, mipmap_level = self.extract_texture_info_from_name(image.name) # Skip mipmaps @@ -157,9 +456,10 @@ def populate_textures(objects_to_scan=None): continue texture_name = clear_extension(texture_name) - used_textures.add((texture_name, image)) + all_textures[image] = texture_name - for texture_name, image in used_textures: + # Export all collected textures + for image, texture_name in all_textures.items(): # Skip images without pixel data if not hasattr(image, 'pixels') or len(image.pixels) == 0: continue @@ -187,43 +487,63 @@ def export_txd(file_name): self.file_name = file_name - if self.mass_export: - # Export TXD files per selected object - selected_objects = bpy.context.selected_objects - - if not selected_objects: - print("No objects selected for mass export, exporting all textures to single file") - self.export_textures() + # Determine which objects to scan + if self.selected_only: + objects_to_process = [obj for obj in bpy.context.selected_objects + if obj.material_slots] + if not objects_to_process: + print("No selected objects with materials, falling back to all objects") + objects_to_process = None + else: + objects_to_process = None + + if self.separate_files: + # Export one TXD per object + if objects_to_process is None: + # Get all objects with materials + objects_to_process = [obj for obj in bpy.context.scene.objects + if obj.material_slots] + + if not objects_to_process: + print("No objects with materials found") return - selected_objects_num = 0 - - for obj in bpy.context.selected_objects: - # Only export for objects that have materials - if not obj.material_slots: - continue - + exported_count = 0 + for obj in objects_to_process: # Create filename based on object name safe_name = "".join(c for c in obj.name if c.isalnum() or c in "_-.") + if not safe_name: + safe_name = "object" + file_name = os.path.join(self.path, f"{safe_name}.txd") print(f"Exporting textures for object '{obj.name}' to {file_name}") # Export textures used by this specific object only - self.export_txd([obj], file_name) - selected_objects_num += 1 + self.export_textures([obj], file_name) + exported_count += 1 - print(f"Mass export completed for {selected_objects_num} objects") + print(f"Separate files export completed for {exported_count} objects") else: - self.export_textures() + # Export single TXD file + if objects_to_process: + # Export only textures from selected objects + self.export_textures(objects_to_process, self.file_name) + else: + # Export all textures + self.export_textures(None, self.file_name) ####################################################### def export_txd(options): - txd_exporter.mass_export = options.get('mass_export', False) + txd_exporter.selected_only = options.get('selected_only', False) + txd_exporter.separate_files = options.get('separate_files', False) txd_exporter.only_used_textures = options.get('only_used_textures', True) txd_exporter.version = options.get('version', 0x36003) + txd_exporter.dxt_quality = options.get('dxt_quality', 'GOOD') + txd_exporter.dxt_metric = options.get('dxt_metric', 'PERCEPTUAL') + txd_exporter.path = options['directory'] txd_exporter.export_txd(options['file_name']) diff --git a/ops/txd_importer.py b/ops/txd_importer.py index 533ae36..4e14ac8 100644 --- a/ops/txd_importer.py +++ b/ops/txd_importer.py @@ -19,6 +19,49 @@ from ..gtaLib import txd +_D3D_TO_COMPRESSION = { + txd.D3DFormat.D3D_DXT1: "1", + txd.D3DFormat.D3D_DXT2: "2", + txd.D3DFormat.D3D_DXT3: "3", + txd.D3DFormat.D3D_DXT4: "4", + txd.D3DFormat.D3D_DXT5: "5", +} + +_RASTER_TO_ENUM = { + txd.RasterFormat.RASTER_8888: "1", + txd.RasterFormat.RASTER_4444: "2", + txd.RasterFormat.RASTER_1555: "3", + txd.RasterFormat.RASTER_888: "4", + txd.RasterFormat.RASTER_565: "5", + txd.RasterFormat.RASTER_555: "6", + txd.RasterFormat.RASTER_LUM: "7", +} + +_MODE_TO_FILTER = { + 0x00: "0", + 0x01: "1", + 0x02: "2", + 0x03: "3", + 0x04: "4", + 0x05: "5", + 0x06: "6", +} + +_NIBBLE_TO_ADDR = { + 0x00: "0", + 0x01: "1", + 0x02: "2", + 0x03: "3", + 0x04: "4", +} + +_PALETTE_TO_ENUM = { + txd.PaletteType.PALETTE_NONE: "0", + txd.PaletteType.PALETTE_4: "1", + txd.PaletteType.PALETTE_8: "2", +} + + ####################################################### class txd_importer: @@ -55,6 +98,34 @@ def _create_image(name, rgba, width, height, pack=False): return image + ####################################################### + def _populate_texture_props(image, tex): + if not hasattr(image, 'dff'): + return + + props = image.dff + + if tex.d3d_format in _D3D_TO_COMPRESSION: + props.image_compression = _D3D_TO_COMPRESSION[tex.d3d_format] + else: + props.image_compression = "0" + + raster_type = tex.get_raster_format_type() + props.image_raster = _RASTER_TO_ENUM.get(raster_type, "0") + + palette_type = tex.get_raster_palette_type() + props.image_palette = _PALETTE_TO_ENUM.get(palette_type, "0") + + has_mips = tex.get_raster_has_mipmaps() + props.image_mipmap = "1" if has_mips else "0" + + props.image_filter = _MODE_TO_FILTER.get(tex.filter_mode, "6") + + u_nibble = tex.uv_addressing & 0x0F + v_nibble = (tex.uv_addressing >> 4) & 0x0F + props.image_uaddress = _NIBBLE_TO_ADDR.get(u_nibble, "1") + props.image_vaddress = _NIBBLE_TO_ADDR.get(v_nibble, "1") + ####################################################### def import_textures(): self = txd_importer @@ -75,6 +146,10 @@ def import_textures(): tex.get_width(level), tex.get_height(level), self.pack) + + if level == 0: + txd_importer._populate_texture_props(image, tex) + images.append(image) self.images[tex.name] = images