diff --git a/README.md b/README.md index 54a88fc..e77ab3c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -This Blender add-on allows you to import and export meshes and animations to the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats). +This Blender add-on allows you to import and export meshes and animations to and from the [PSK and PSA file formats](https://wiki.beyondunreal.com/PSK_%26_PSA_file_formats). In addition, the non-standard PSKX format is also supported for import only. # Installation 1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page. @@ -15,8 +15,8 @@ This Blender add-on allows you to import and export meshes and animations to the 3. Navigate to File > Export > Unreal PSK (.psk) 4. Enter the file name and click "Export". -## Importing a PSK -1. Navigate to File > Import > Unreal PSK (.psk) +## Importing a PSK/PSKX +1. Navigate to File > Import > Unreal PSK (.psk/.pskx) 2. Select the PSK file you want to import and click "Import" ## Exporting a PSA diff --git a/io_scene_psk_psa/__init__.py b/io_scene_psk_psa/__init__.py index f69c45f..0252bd3 100644 --- a/io_scene_psk_psa/__init__.py +++ b/io_scene_psk_psa/__init__.py @@ -1,7 +1,7 @@ bl_info = { "name": "PSK/PSA Importer/Exporter", "author": "Colin Basnett", - "version": (1, 2, 1), + "version": (2, 1, 0), "blender": (2, 80, 0), # "location": "File > Export > PSK Export (.psk)", "description": "PSK/PSA Import/Export (.psk/.psa)", @@ -58,7 +58,7 @@ def psk_export_menu_func(self, context): def psk_import_menu_func(self, context): - self.layout.operator(psk_importer.PskImportOperator.bl_idname, text='Unreal PSK (.psk)') + self.layout.operator(psk_importer.PskImportOperator.bl_idname, text='Unreal PSK (.psk/.pskx)') def psa_export_menu_func(self, context): @@ -72,6 +72,7 @@ def register(): bpy.types.TOPBAR_MT_file_import.append(psk_import_menu_func) bpy.types.TOPBAR_MT_file_export.append(psa_export_menu_func) bpy.types.Scene.psa_import = PointerProperty(type=psa_importer.PsaImportPropertyGroup) + bpy.types.Scene.psk_import = PointerProperty(type=psk_importer.PskImportPropertyGroup) bpy.types.Scene.psa_export = PointerProperty(type=psa_exporter.PsaExportPropertyGroup) bpy.types.Scene.psk_export = PointerProperty(type=psk_exporter.PskExportPropertyGroup) diff --git a/io_scene_psk_psa/data.py b/io_scene_psk_psa/data.py index 72bb979..1639a01 100644 --- a/io_scene_psk_psa/data.py +++ b/io_scene_psk_psa/data.py @@ -1,4 +1,43 @@ from ctypes import * +from typing import Tuple + + +class Color(Structure): + _fields_ = [ + ('r', c_ubyte), + ('g', c_ubyte), + ('b', c_ubyte), + ('a', c_ubyte), + ] + + def __iter__(self): + yield self.r + yield self.g + yield self.b + yield self.a + + def __eq__(self, other): + return all(map(lambda x: x[0] == x[1], zip(self, other))) + + def __repr__(self): + return repr(tuple(self)) + + def normalized(self) -> Tuple: + return tuple(map(lambda x: x / 255.0, iter(self))) + + +class Vector2(Structure): + _fields_ = [ + ('x', c_float), + ('y', c_float), + ] + + def __iter__(self): + yield self.x + yield self.y + + def __repr__(self): + return repr(tuple(self)) class Vector3(Structure): diff --git a/io_scene_psk_psa/helpers.py b/io_scene_psk_psa/helpers.py index 1217dca..8bf856a 100644 --- a/io_scene_psk_psa/helpers.py +++ b/io_scene_psk_psa/helpers.py @@ -1,6 +1,13 @@ from typing import List +def rgb_to_srgb(c): + if c > 0.0031308: + return 1.055 * (pow(c, (1.0 / 2.4))) - 0.055 + else: + return 12.92 * c + + def populate_bone_group_list(armature_object, bone_group_list): bone_group_list.clear() diff --git a/io_scene_psk_psa/psk/data.py b/io_scene_psk_psa/psk/data.py index ba52a44..e663a22 100644 --- a/io_scene_psk_psa/psk/data.py +++ b/io_scene_psk_psa/psk/data.py @@ -41,6 +41,15 @@ class Psk(object): ('smoothing_groups', c_int32) ] + class Face32(Structure): + _pack_ = 1 + _fields_ = [ + ('wedge_indices', c_uint32 * 3), + ('material_index', c_uint8), + ('aux_material_index', c_uint8), + ('smoothing_groups', c_int32) + ] + class Material(Structure): _fields_ = [ ('name', c_char * 64), @@ -71,6 +80,18 @@ class Psk(object): ('bone_index', c_int32), ] + @property + def has_extra_uvs(self): + return len(self.extra_uvs) > 0 + + @property + def has_vertex_colors(self): + return len(self.vertex_colors) > 0 + + @property + def has_vertex_normals(self): + return len(self.vertex_normals) > 0 + def __init__(self): self.points: List[Vector3] = [] self.wedges: List[Psk.Wedge] = [] @@ -78,3 +99,6 @@ class Psk(object): self.materials: List[Psk.Material] = [] self.weights: List[Psk.Weight] = [] self.bones: List[Psk.Bone] = [] + self.extra_uvs: List[Vector2] = [] + self.vertex_colors: List[Color] = [] + self.vertex_normals: List[Vector3] = [] diff --git a/io_scene_psk_psa/psk/importer.py b/io_scene_psk_psa/psk/importer.py index 4b593bd..c47d183 100644 --- a/io_scene_psk_psa/psk/importer.py +++ b/io_scene_psk_psa/psk/importer.py @@ -1,23 +1,35 @@ import os import bpy import bmesh +import numpy as np +from math import inf from typing import Optional from .data import Psk +from ..helpers import rgb_to_srgb from mathutils import Quaternion, Vector, Matrix from .reader import PskReader -from bpy.props import StringProperty -from bpy.types import Operator +from bpy.props import StringProperty, EnumProperty, BoolProperty +from bpy.types import Operator, PropertyGroup from bpy_extras.io_utils import ImportHelper +class PskImportOptions(object): + def __init__(self): + self.name = '' + self.should_import_vertex_colors = True + self.vertex_color_space = 'sRGB' + self.should_import_vertex_normals = True + self.should_import_extra_uvs = True + + class PskImporter(object): def __init__(self): pass - def import_psk(self, psk: Psk, name: str, context): + def import_psk(self, psk: Psk, context, options: PskImportOptions): # ARMATURE - armature_data = bpy.data.armatures.new(name) - armature_object = bpy.data.objects.new(name, armature_data) + armature_data = bpy.data.armatures.new(options.name) + armature_object = bpy.data.objects.new(options.name, armature_data) armature_object.show_in_front = True context.scene.collection.objects.link(armature_object) @@ -95,8 +107,8 @@ class PskImporter(object): edit_bone['post_quat'] = import_bone.local_rotation.conjugated() # MESH - mesh_data = bpy.data.meshes.new(name) - mesh_object = bpy.data.objects.new(name, mesh_data) + mesh_data = bpy.data.meshes.new(options.name) + mesh_object = bpy.data.objects.new(options.name, mesh_data) # MATERIALS for material in psk.materials: @@ -120,7 +132,6 @@ class PskImporter(object): bm_face.material_index = face.material_index except ValueError: degenerate_face_indices.add(face_index) - pass if len(degenerate_face_indices) > 0: print(f'WARNING: Discarded {len(degenerate_face_indices)} degenerate face(s).') @@ -129,7 +140,7 @@ class PskImporter(object): # TEXTURE COORDINATES data_index = 0 - uv_layer = mesh_data.uv_layers.new() + uv_layer = mesh_data.uv_layers.new(name='VTXW0000') for face_index, face in enumerate(psk.faces): if face_index in degenerate_face_indices: continue @@ -138,11 +149,63 @@ class PskImporter(object): uv_layer.data[data_index].uv = wedge.u, 1.0 - wedge.v data_index += 1 + # EXTRA UVS + if psk.has_extra_uvs and options.should_import_extra_uvs: + extra_uv_channel_count = int(len(psk.extra_uvs) / len(psk.wedges)) + wedge_index_offset = 0 + for extra_uv_index in range(extra_uv_channel_count): + data_index = 0 + uv_layer = mesh_data.uv_layers.new(name=f'EXTRAUV{extra_uv_index}') + for face_index, face in enumerate(psk.faces): + if face_index in degenerate_face_indices: + continue + for wedge_index in reversed(face.wedge_indices): + u, v = psk.extra_uvs[wedge_index_offset + wedge_index] + uv_layer.data[data_index].uv = u, 1.0 - v + data_index += 1 + wedge_index_offset += len(psk.wedges) + + # VERTEX COLORS + if psk.has_vertex_colors and options.should_import_vertex_colors: + size = (len(psk.points), 4) + vertex_colors = np.full(size, inf) + vertex_color_data = mesh_data.vertex_colors.new(name='VERTEXCOLOR') + ambiguous_vertex_color_point_indices = [] + + for wedge_index, wedge in enumerate(psk.wedges): + point_index = wedge.point_index + psk_vertex_color = psk.vertex_colors[wedge_index].normalized() + if vertex_colors[point_index, 0] != inf and tuple(vertex_colors[point_index]) != psk_vertex_color: + ambiguous_vertex_color_point_indices.append(point_index) + else: + vertex_colors[point_index] = psk_vertex_color + + if options.vertex_color_space == 'SRGBA': + for i in range(vertex_colors.shape[0]): + vertex_colors[i, :3] = tuple(map(lambda x: rgb_to_srgb(x), vertex_colors[i, :3])) + + for loop_index, loop in enumerate(mesh_data.loops): + vertex_color = vertex_colors[loop.vertex_index] + if vertex_color is not None: + vertex_color_data.data[loop_index].color = vertex_color + else: + vertex_color_data.data[loop_index].color = 1.0, 1.0, 1.0, 1.0 + + if len(ambiguous_vertex_color_point_indices) > 0: + print(f'WARNING: {len(ambiguous_vertex_color_point_indices)} vertex(es) with ambiguous vertex colors.') + + # VERTEX NORMALS + if psk.has_vertex_normals and options.should_import_vertex_normals: + mesh_data.polygons.foreach_set("use_smooth", [True] * len(mesh_data.polygons)) + normals = [] + for vertex_normal in psk.vertex_normals: + normals.append(tuple(vertex_normal)) + mesh_data.normals_split_custom_set_from_vertices(normals) + mesh_data.use_auto_smooth = True + bm.normal_update() bm.free() - # VERTEX WEIGHTS - # Get a list of all bones that have weights associated with them. vertex_group_bone_indices = set(map(lambda weight: weight.bone_index, psk.weights)) for import_bone in map(lambda x: import_bones[x], sorted(list(vertex_group_bone_indices))): @@ -164,12 +227,27 @@ class PskImporter(object): pass +class PskImportPropertyGroup(PropertyGroup): + should_import_vertex_colors: BoolProperty(default=True, name='Vertex Colors', description='Import vertex colors from PSKX files, if available') + vertex_color_space: EnumProperty( + name='Vertex Color Space', + description='The source vertex color space', + default='SRGBA', + items=( + ('LINEAR', 'Linear', ''), + ('SRGBA', 'sRGBA', ''), + ) + ) + should_import_vertex_normals: BoolProperty(default=True, name='Vertex Normals', description='Import vertex normals from PSKX files, if available') + should_import_extra_uvs: BoolProperty(default=True, name='Extra UVs', description='Import extra UV maps from PSKX files, if available') + + class PskImportOperator(Operator, ImportHelper): bl_idname = 'import.psk' bl_label = 'Export' __doc__ = 'Load a PSK file' filename_ext = '.psk' - filter_glob: StringProperty(default='*.psk', options={'HIDDEN'}) + filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'}) filepath: StringProperty( name='File Path', description='File path used for exporting the PSK file', @@ -177,13 +255,29 @@ class PskImportOperator(Operator, ImportHelper): default='') def execute(self, context): + pg = context.scene.psk_import reader = PskReader() psk = reader.read(self.filepath) - name = os.path.splitext(os.path.basename(self.filepath))[0] - PskImporter().import_psk(psk, name, context) + options = PskImportOptions() + options.name = os.path.splitext(os.path.basename(self.filepath))[0] + options.vertex_color_space = pg.vertex_color_space + PskImporter().import_psk(psk, context, options) return {'FINISHED'} + def draw(self, context): + pg = context.scene.psk_import + layout = self.layout + layout.use_property_split = True + layout.use_property_decorate = False + layout.prop(pg, 'should_import_vertex_normals') + layout.prop(pg, 'should_import_extra_uvs') + layout.prop(pg, 'should_import_vertex_colors') + if pg.should_import_vertex_colors: + layout.prop(pg, 'vertex_color_space') + + classes = ( PskImportOperator, + PskImportPropertyGroup, ) diff --git a/io_scene_psk_psa/psk/reader.py b/io_scene_psk_psa/psk/reader.py index 8f94c36..29eb4ba 100644 --- a/io_scene_psk_psa/psk/reader.py +++ b/io_scene_psk_psa/psk/reader.py @@ -41,6 +41,14 @@ class PskReader(object): PskReader.read_types(fp, Psk.Bone, section, psk.bones) elif section.name == b'RAWWEIGHTS': PskReader.read_types(fp, Psk.Weight, section, psk.weights) + elif section.name == b'FACE3200': + PskReader.read_types(fp, Psk.Face32, section, psk.faces) + elif section.name == b'VERTEXCOLOR': + PskReader.read_types(fp, Color, section, psk.vertex_colors) + elif section.name.startswith(b'EXTRAUVS'): + PskReader.read_types(fp, Vector2, section, psk.extra_uvs) + elif section.name == b'VTXNORMS': + PskReader.read_types(fp, Vector3, section, psk.vertex_normals) else: - raise RuntimeError(f'Unrecognized section "{section.name}"') + raise RuntimeError(f'Unrecognized section "{section.name} at position {15:fp.tell()}"') return psk