Compare commits

..

5 Commits

Author SHA1 Message Date
Colin Basnett
6e8faa38bf Updated documentation 2022-01-27 18:23:29 -08:00
Colin Basnett
a62ee207e1 Added vertex color space conversion option for sRGBA (this is now the default to match UE4.24) 2022-01-27 15:11:32 -08:00
Colin Basnett
2ba29b04d3 Fixed a comment 2022-01-25 21:09:52 -08:00
Colin Basnett
2f5ed901b2 Fixed has_vertex_colors and has_vertex_normals return values 2022-01-25 21:09:47 -08:00
Colin Basnett
4099c95381 Initial commit for PSKX support 2022-01-25 19:41:09 -08:00
8 changed files with 206 additions and 30 deletions

View File

@@ -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 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 # Installation
1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page. 1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page.
@@ -10,9 +10,9 @@ This Blender add-on allows you to import and export meshes and animations to the
7. Enable the newly added "Import-Export: PSK/PSA Importer/Exporter" addon. 7. Enable the newly added "Import-Export: PSK/PSA Importer/Exporter" addon.
# Usage # Usage
## Exporting a PSK ## Exporting a PSK/PSKX
1. Select the mesh objects you wish to export. 1. Select the mesh objects you wish to export.
3. Navigate to File > Export > Unreal PSK (.psk) 3. Navigate to File > Export > Unreal PSK (.psk/.pskx)
4. Enter the file name and click "Export". 4. Enter the file name and click "Export".
## Importing a PSK ## Importing a PSK

View File

@@ -1,7 +1,7 @@
bl_info = { bl_info = {
"name": "PSK/PSA Importer/Exporter", "name": "PSK/PSA Importer/Exporter",
"author": "Colin Basnett", "author": "Colin Basnett",
"version": (1, 2, 1), "version": (1, 2, 0),
"blender": (2, 80, 0), "blender": (2, 80, 0),
# "location": "File > Export > PSK Export (.psk)", # "location": "File > Export > PSK Export (.psk)",
"description": "PSK/PSA Import/Export (.psk/.psa)", "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): 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): 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_import.append(psk_import_menu_func)
bpy.types.TOPBAR_MT_file_export.append(psa_export_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.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.psa_export = PointerProperty(type=psa_exporter.PsaExportPropertyGroup)
bpy.types.Scene.psk_export = PointerProperty(type=psk_exporter.PskExportPropertyGroup) bpy.types.Scene.psk_export = PointerProperty(type=psk_exporter.PskExportPropertyGroup)

View File

@@ -1,4 +1,43 @@
from ctypes import * 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): class Vector3(Structure):

View File

@@ -1,6 +1,13 @@
from typing import List 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): def populate_bone_group_list(armature_object, bone_group_list):
bone_group_list.clear() bone_group_list.clear()

View File

@@ -16,6 +16,7 @@ class PsaImportOptions(object):
self.should_use_fake_user = False self.should_use_fake_user = False
self.should_stash = False self.should_stash = False
self.sequence_names = [] self.sequence_names = []
self.action_name_prefix = ''
class PsaImporter(object): class PsaImporter(object):
@@ -115,7 +116,8 @@ class PsaImporter(object):
actions = [] actions = []
for sequence in sequences: for sequence in sequences:
# Add the action. # Add the action.
action = bpy.data.actions.new(name=sequence.name.decode()) action_name = options.action_name_prefix + sequence.name.decode('windows-1252')
action = bpy.data.actions.new(name=action_name)
action.use_fake_user = options.should_use_fake_user action.use_fake_user = options.should_use_fake_user
# Create f-curves for the rotation and location of each bone. # Create f-curves for the rotation and location of each bone.
@@ -233,7 +235,7 @@ def on_psa_file_path_updated(property, context):
pass pass
class PsaImportPropertyGroup(bpy.types.PropertyGroup): class PsaImportPropertyGroup(PropertyGroup):
psa_file_path: StringProperty(default='', update=on_psa_file_path_updated, name='PSA File Path') psa_file_path: StringProperty(default='', update=on_psa_file_path_updated, name='PSA File Path')
psa_bones: CollectionProperty(type=PsaImportPsaBoneItem) psa_bones: CollectionProperty(type=PsaImportPsaBoneItem)
action_list: CollectionProperty(type=PsaImportActionListItem) action_list: CollectionProperty(type=PsaImportActionListItem)
@@ -242,6 +244,7 @@ class PsaImportPropertyGroup(bpy.types.PropertyGroup):
should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', description='Exclude unnecessary keyframes from being written to the actions.') should_clean_keys: BoolProperty(default=True, name='Clean Keyframes', description='Exclude unnecessary keyframes from being written to the actions.')
should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.') should_use_fake_user: BoolProperty(default=True, name='Fake User', description='Assign each imported action a fake user so that the data block is saved even it has no users.')
should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track') should_stash: BoolProperty(default=False, name='Stash', description='Stash each imported action as a strip on a new non-contributing NLA track')
action_name_prefix: StringProperty(default='', name='Action Name Prefix')
class PSA_UL_ImportActionList(UIList): class PSA_UL_ImportActionList(UIList):
@@ -349,13 +352,13 @@ class PSA_PT_ImportPanel(Panel):
row.operator('psa_import.actions_select_all', text='All') row.operator('psa_import.actions_select_all', text='All')
row.operator('psa_import.actions_deselect_all', text='None') row.operator('psa_import.actions_deselect_all', text='None')
row = layout.row() col = layout.column(heading="Options")
row.prop(property_group, 'should_clean_keys') col.use_property_split = True
col.use_property_decorate = False
# DATA col.prop(property_group, 'should_clean_keys')
row = layout.row() col.prop(property_group, 'should_use_fake_user')
row.prop(property_group, 'should_use_fake_user') col.prop(property_group, 'should_stash')
row.prop(property_group, 'should_stash') col.prop(property_group, 'action_name_prefix')
layout.operator('psa_import.import', text=f'Import') layout.operator('psa_import.import', text=f'Import')
@@ -399,6 +402,7 @@ class PsaImportOperator(Operator):
options.should_clean_keys = property_group.should_clean_keys options.should_clean_keys = property_group.should_clean_keys
options.should_use_fake_user = property_group.should_use_fake_user options.should_use_fake_user = property_group.should_use_fake_user
options.should_stash = property_group.should_stash options.should_stash = property_group.should_stash
options.action_name_prefix = property_group.action_name_prefix
PsaImporter().import_psa(psa_reader, context.view_layer.objects.active, options) PsaImporter().import_psa(psa_reader, context.view_layer.objects.active, options)
self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)') self.report({'INFO'}, f'Imported {len(sequence_names)} action(s)')
return {'FINISHED'} return {'FINISHED'}

View File

@@ -41,6 +41,15 @@ class Psk(object):
('smoothing_groups', c_int32) ('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): class Material(Structure):
_fields_ = [ _fields_ = [
('name', c_char * 64), ('name', c_char * 64),
@@ -71,6 +80,18 @@ class Psk(object):
('bone_index', c_int32), ('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): def __init__(self):
self.points: List[Vector3] = [] self.points: List[Vector3] = []
self.wedges: List[Psk.Wedge] = [] self.wedges: List[Psk.Wedge] = []
@@ -78,3 +99,6 @@ class Psk(object):
self.materials: List[Psk.Material] = [] self.materials: List[Psk.Material] = []
self.weights: List[Psk.Weight] = [] self.weights: List[Psk.Weight] = []
self.bones: List[Psk.Bone] = [] self.bones: List[Psk.Bone] = []
self.extra_uvs: List[Vector2] = []
self.vertex_colors: List[Color] = []
self.vertex_normals: List[Vector3] = []

View File

@@ -1,23 +1,35 @@
import os import os
import bpy import bpy
import bmesh import bmesh
import numpy as np
from math import inf
from typing import Optional from typing import Optional
from .data import Psk from .data import Psk
from ..helpers import rgb_to_srgb
from mathutils import Quaternion, Vector, Matrix from mathutils import Quaternion, Vector, Matrix
from .reader import PskReader from .reader import PskReader
from bpy.props import StringProperty from bpy.props import StringProperty, EnumProperty, BoolProperty
from bpy.types import Operator from bpy.types import Operator, PropertyGroup
from bpy_extras.io_utils import ImportHelper 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): class PskImporter(object):
def __init__(self): def __init__(self):
pass pass
def import_psk(self, psk: Psk, name: str, context): def import_psk(self, psk: Psk, context, options: PskImportOptions):
# ARMATURE # ARMATURE
armature_data = bpy.data.armatures.new(name) armature_data = bpy.data.armatures.new(options.name)
armature_object = bpy.data.objects.new(name, armature_data) armature_object = bpy.data.objects.new(options.name, armature_data)
armature_object.show_in_front = True armature_object.show_in_front = True
context.scene.collection.objects.link(armature_object) context.scene.collection.objects.link(armature_object)
@@ -95,8 +107,8 @@ class PskImporter(object):
edit_bone['post_quat'] = import_bone.local_rotation.conjugated() edit_bone['post_quat'] = import_bone.local_rotation.conjugated()
# MESH # MESH
mesh_data = bpy.data.meshes.new(name) mesh_data = bpy.data.meshes.new(options.name)
mesh_object = bpy.data.objects.new(name, mesh_data) mesh_object = bpy.data.objects.new(options.name, mesh_data)
# MATERIALS # MATERIALS
for material in psk.materials: for material in psk.materials:
@@ -120,7 +132,6 @@ class PskImporter(object):
bm_face.material_index = face.material_index bm_face.material_index = face.material_index
except ValueError: except ValueError:
degenerate_face_indices.add(face_index) degenerate_face_indices.add(face_index)
pass
if len(degenerate_face_indices) > 0: if len(degenerate_face_indices) > 0:
print(f'WARNING: Discarded {len(degenerate_face_indices)} degenerate face(s).') print(f'WARNING: Discarded {len(degenerate_face_indices)} degenerate face(s).')
@@ -129,7 +140,7 @@ class PskImporter(object):
# TEXTURE COORDINATES # TEXTURE COORDINATES
data_index = 0 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): for face_index, face in enumerate(psk.faces):
if face_index in degenerate_face_indices: if face_index in degenerate_face_indices:
continue continue
@@ -138,11 +149,63 @@ class PskImporter(object):
uv_layer.data[data_index].uv = wedge.u, 1.0 - wedge.v uv_layer.data[data_index].uv = wedge.u, 1.0 - wedge.v
data_index += 1 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.normal_update()
bm.free() bm.free()
# VERTEX WEIGHTS
# Get a list of all bones that have weights associated with them. # 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)) 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))): for import_bone in map(lambda x: import_bones[x], sorted(list(vertex_group_bone_indices))):
@@ -164,12 +227,27 @@ class PskImporter(object):
pass 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): class PskImportOperator(Operator, ImportHelper):
bl_idname = 'import.psk' bl_idname = 'import.psk'
bl_label = 'Export' bl_label = 'Export'
__doc__ = 'Load a PSK file' __doc__ = 'Load a PSK file'
filename_ext = '.psk' filename_ext = '.psk'
filter_glob: StringProperty(default='*.psk', options={'HIDDEN'}) filter_glob: StringProperty(default='*.psk;*.pskx', options={'HIDDEN'})
filepath: StringProperty( filepath: StringProperty(
name='File Path', name='File Path',
description='File path used for exporting the PSK file', description='File path used for exporting the PSK file',
@@ -177,13 +255,28 @@ class PskImportOperator(Operator, ImportHelper):
default='') default='')
def execute(self, context): def execute(self, context):
pg = context.scene.psk_import
reader = PskReader() reader = PskReader()
psk = reader.read(self.filepath) psk = reader.read(self.filepath)
name = os.path.splitext(os.path.basename(self.filepath))[0] options = PskImportOptions()
PskImporter().import_psk(psk, name, context) 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'} 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__ = [ __classes__ = [
PskImportOperator PskImportOperator,
PskImportPropertyGroup,
] ]

View File

@@ -41,6 +41,14 @@ class PskReader(object):
PskReader.read_types(fp, Psk.Bone, section, psk.bones) PskReader.read_types(fp, Psk.Bone, section, psk.bones)
elif section.name == b'RAWWEIGHTS': elif section.name == b'RAWWEIGHTS':
PskReader.read_types(fp, Psk.Weight, section, psk.weights) 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: else:
raise RuntimeError(f'Unrecognized section "{section.name}"') raise RuntimeError(f'Unrecognized section "{section.name} at position {15:fp.tell()}"')
return psk return psk