Merge branch 'master' into scale_keys

This commit is contained in:
Colin Basnett
2024-01-20 15:41:54 -08:00
10 changed files with 157 additions and 28 deletions

View File

@@ -1,9 +1,19 @@
[![Blender](https://img.shields.io/badge/Blender->=4.0-blue?logo=blender&logoColor=white)](https://www.blender.org/download/ "Download Blender") [![Blender](https://img.shields.io/badge/Blender->=2.9-blue?logo=blender&logoColor=white)](https://www.blender.org/download/ "Download Blender")
[![GitHub release](https://img.shields.io/github/release/DarklightGames/io_scene_psk_psa?include_prereleases=&sort=semver&color=blue)](https://github.com/DarklightGames/io_scene_psk_psa/releases/) [![GitHub release](https://img.shields.io/github/release/DarklightGames/io_scene_psk_psa?include_prereleases=&sort=semver&color=blue)](https://github.com/DarklightGames/io_scene_psk_psa/releases/)
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L3853VR) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/L4L3853VR)
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) used in many versions of the Unreal Engine. This Blender addon 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) used in many versions of the Unreal Engine.
## Compatibility
| Blender Version | Addon Version | Long Term Support |
|--------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------|
| 4.0+ | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD |
| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.5](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.5) | ✅️ June 2025 |
| [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | ✅️ September 2024 |
Bug fixes will be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon versions will no longer be supported by the maintainers of this repository, although we will accept pull requests for bug fixes.
# Features # Features
* Full PSK/PSA import and export capabilities. * Full PSK/PSA import and export capabilities.
@@ -14,16 +24,6 @@ This Blender add-on allows you to import and export meshes and animations to and
* PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html) or NLA track strips, allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences. * PSA sequences can be exported directly from actions or delineated using a scene's [timeline markers](https://docs.blender.org/manual/en/latest/animation/markers.html) or NLA track strips, allowing direct use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences.
* Manual re-ordering of material slots when exporting multiple mesh objects. * Manual re-ordering of material slots when exporting multiple mesh objects.
## Compatibility
| Blender Version | Addon Version | LTS Support |
|--------------------------------------------------------------|--------------------------------------------------------------------------------|-------------------|
| 4.0+ | [latest](https://github.com/DarklightGames/io_scene_psk_psa/releases/latest) | TBD |
| [3.4 - 3.6](https://www.blender.org/download/lts/3-6/) | [5.0.5](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/5.0.5) | ✅️ June 2025 |
| [2.93 - 3.3](https://www.blender.org/download/releases/3-3/) | [4.3.0](https://github.com/DarklightGames/io_scene_psk_psa/releases/tag/4.3.0) | ✅️ September 2024 |
Bug fixes will be issued for legacy addon versions that are under [Blender's LTS maintenance period](https://www.blender.org/download/lts/). Once the LTS period has ended, legacy addon-on versions will no longer be supported by the maintainers of this repository, although we will accept pull requests for bug fixes.
# 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.
2. Open Blender 4.0.0 or later. 2. Open Blender 4.0.0 or later.
@@ -54,7 +54,15 @@ Bug fixes will be issued for legacy addon versions that are under [Blender's LTS
3. Select the PSA file you want to import. 3. Select the PSA file you want to import.
4. Select the sequences that you want to import and click `Import`. 4. Select the sequences that you want to import and click `Import`.
> Note that in order to see the imported actions applied to your armature, you must use the [Dope Sheet](https://docs.blender.org/manual/en/latest/editors/dope_sheet/introduction.html) or [Nonlinear Animation](https://docs.blender.org/manual/en/latest/editors/nla/introduction.html) editors.
# FAQ # FAQ
## Why can't I see the animations imported from my PSA?
Simply importing an animation into the scene will not automatically apply the action to the armature. This is in part because a PSA can have multiple sequences imported from it, and also that it's generally bad form for importers to modify the scene when they don't need to.
The PSA importer creates [Actions](https://docs.blender.org/manual/en/latest/animation/actions.html) for each of the selected sequences in the PSA. These actions can be applied to your armature via the [Action Editor](https://docs.blender.org/manual/en/latest/editors/dope_sheet/action.html) or [NLA Editor](https://docs.blender.org/manual/en/latest/editors/nla/index.html).
## Why are the mesh normals not accurate when importing a PSK extracted from [UE Viewer](https://www.gildor.org/en/projects/umodel)? ## Why are the mesh normals not accurate when importing a PSK extracted from [UE Viewer](https://www.gildor.org/en/projects/umodel)?
If preserving the mesh normals of models is important for your workflow, it is *not recommended* to export PSK files from UE Viewer. This is because UE Viewer makes no attempt to reconstruct the original [smoothing groups](https://en.wikipedia.org/wiki/Smoothing_group). As a result, the normals of imported PSK files will be incorrect when imported into Blender and will need to be manually fixed. If preserving the mesh normals of models is important for your workflow, it is *not recommended* to export PSK files from UE Viewer. This is because UE Viewer makes no attempt to reconstruct the original [smoothing groups](https://en.wikipedia.org/wiki/Smoothing_group). As a result, the normals of imported PSK files will be incorrect when imported into Blender and will need to be manually fixed.

View File

@@ -3,7 +3,7 @@ from bpy.app.handlers import persistent
bl_info = { bl_info = {
"name": "PSK/PSA Importer/Exporter", "name": "PSK/PSA Importer/Exporter",
"author": "Colin Basnett, Yurii Ti", "author": "Colin Basnett, Yurii Ti",
"version": (6, 0, 0), "version": (6, 1, 1),
"blender": (4, 0, 0), "blender": (4, 0, 0),
"description": "PSK/PSA Import/Export (.psk/.psa)", "description": "PSK/PSA Import/Export (.psk/.psa)",
"warning": "", "warning": "",
@@ -30,6 +30,7 @@ if 'bpy' in locals():
importlib.reload(psk_import_operators) importlib.reload(psk_import_operators)
importlib.reload(psa_data) importlib.reload(psa_data)
importlib.reload(psa_config)
importlib.reload(psa_reader) importlib.reload(psa_reader)
importlib.reload(psa_writer) importlib.reload(psa_writer)
importlib.reload(psa_builder) importlib.reload(psa_builder)
@@ -55,6 +56,7 @@ else:
from .psk.import_ import operators as psk_import_operators from .psk.import_ import operators as psk_import_operators
from .psa import data as psa_data from .psa import data as psa_data
from .psa import config as psa_config
from .psa import reader as psa_reader from .psa import reader as psa_reader
from .psa import writer as psa_writer from .psa import writer as psa_writer
from .psa import builder as psa_builder from .psa import builder as psa_builder

View File

@@ -0,0 +1,78 @@
import re
from configparser import ConfigParser
from typing import Dict
from .reader import PsaReader
REMOVE_TRACK_LOCATION = (1 << 0)
REMOVE_TRACK_ROTATION = (1 << 1)
class PsaConfig:
def __init__(self):
self.sequence_bone_flags: Dict[str, Dict[int, int]] = dict()
def _load_config_file(file_path: str) -> ConfigParser:
"""
UEViewer exports a dialect of INI files that is not compatible with Python's ConfigParser.
Specifically, it allows values in this format:
[Section]
Key1
Key2
This is not allowed in Python's ConfigParser, which requires a '=' character after each key name.
To work around this, we'll modify the file to add the '=' character after each key name if it is missing.
"""
with open(file_path, 'r') as f:
lines = f.read().split('\n')
lines = [re.sub(r'^\s*(\w+)\s*$', r'\1=', line) for line in lines]
contents = '\n'.join(lines)
config = ConfigParser()
config.read_string(contents)
return config
def _get_bone_flags_from_value(value: str) -> int:
match value:
case 'all':
return (REMOVE_TRACK_LOCATION | REMOVE_TRACK_ROTATION)
case 'trans':
return REMOVE_TRACK_LOCATION
case 'rot':
return REMOVE_TRACK_ROTATION
case _:
return 0
def read_psa_config(psa_reader: PsaReader, file_path: str) -> PsaConfig:
psa_config = PsaConfig()
config = _load_config_file(file_path)
if config.has_section('RemoveTracks'):
for key, value in config.items('RemoveTracks'):
match = re.match(f'^(.+)\.(\d+)$', key)
sequence_name = match.group(1)
# Map the sequence name onto the actual sequence name in the PSA file.
try:
psa_sequence_names = list(psa_reader.sequences.keys())
lowercase_sequence_names = [sequence_name.lower() for sequence_name in psa_sequence_names]
sequence_name = psa_sequence_names[lowercase_sequence_names.index(sequence_name.lower())]
except ValueError:
# Sequence name is not in the PSA file.
continue
if sequence_name not in psa_config.sequence_bone_flags:
psa_config.sequence_bone_flags[sequence_name] = dict()
bone_index = int(match.group(2))
psa_config.sequence_bone_flags[sequence_name][bone_index] = _get_bone_flags_from_value(value)
return psa_config

View File

@@ -152,7 +152,7 @@ class PSA_PG_export(PropertyGroup):
default=False, default=False,
name='Enforce Bone Name Restrictions', name='Enforce Bone Name Restrictions',
description='Bone names restrictions will be enforced. Note that bone names without properly formatted names ' description='Bone names restrictions will be enforced. Note that bone names without properly formatted names '
'cannot be referenced in scripts' 'may not be able to be referenced in-engine'
) )
sequence_name_prefix: StringProperty(name='Prefix', options=empty_set) sequence_name_prefix: StringProperty(name='Prefix', options=empty_set)
sequence_name_suffix: StringProperty(name='Suffix', options=empty_set) sequence_name_suffix: StringProperty(name='Suffix', options=empty_set)

View File

@@ -1,10 +1,12 @@
import os import os
from pathlib import Path
from bpy.props import StringProperty from bpy.props import StringProperty
from bpy.types import Operator, Event, Context from bpy.types import Operator, Event, Context
from bpy_extras.io_utils import ImportHelper from bpy_extras.io_utils import ImportHelper
from .properties import get_visible_sequences from .properties import get_visible_sequences
from ..config import read_psa_config
from ..importer import import_psa, PsaImportOptions from ..importer import import_psa, PsaImportOptions
from ..reader import PsaReader from ..reader import PsaReader
@@ -156,6 +158,10 @@ class PSA_OT_import(Operator, ImportHelper):
psa_reader = PsaReader(self.filepath) psa_reader = PsaReader(self.filepath)
sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected] sequence_names = [x.action_name for x in pg.sequence_list if x.is_selected]
if len(sequence_names) == 0:
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected')
return {'CANCELLED'}
options = PsaImportOptions() options = PsaImportOptions()
options.sequence_names = sequence_names options.sequence_names = sequence_names
options.should_use_fake_user = pg.should_use_fake_user options.should_use_fake_user = pg.should_use_fake_user
@@ -170,9 +176,14 @@ class PSA_OT_import(Operator, ImportHelper):
options.fps_source = pg.fps_source options.fps_source = pg.fps_source
options.fps_custom = pg.fps_custom options.fps_custom = pg.fps_custom
if len(sequence_names) == 0: if options.should_use_config_file:
self.report({'ERROR_INVALID_CONTEXT'}, 'No sequences selected') # Read the PSA config file if it exists.
return {'CANCELLED'} config_path = Path(self.filepath).with_suffix('.config')
if config_path.exists():
try:
options.psa_config = read_psa_config(psa_reader, str(config_path))
except Exception as e:
self.report({'WARNING'}, f'Failed to read PSA config file: {e}')
result = import_psa(context, psa_reader, context.view_layer.objects.active, options) result = import_psa(context, psa_reader, context.view_layer.objects.active, options)
@@ -254,6 +265,8 @@ class PSA_OT_import(Operator, ImportHelper):
col.use_property_decorate = False col.use_property_decorate = False
col.prop(pg, 'should_use_fake_user') col.prop(pg, 'should_use_fake_user')
col.prop(pg, 'should_stash') col.prop(pg, 'should_stash')
col.prop(pg, 'should_use_config_file')
col.prop(pg, 'should_use_action_name_prefix') col.prop(pg, 'should_use_action_name_prefix')
if pg.should_use_action_name_prefix: if pg.should_use_action_name_prefix:

View File

@@ -32,6 +32,12 @@ class PSA_PG_import(PropertyGroup):
description='Assign each imported action a fake user so that the data block is ' description='Assign each imported action a fake user so that the data block is '
'saved even it has no users', 'saved even it has no users',
options=empty_set) options=empty_set)
should_use_config_file: BoolProperty(default=True, name='Use Config File',
description='Use the .config file that is sometimes generated when the PSA '
'file is exported from UEViewer. This file contains '
'options that can be used to filter out certain bones tracks '
'from the imported actions',
options=empty_set)
should_stash: BoolProperty(default=False, name='Stash', should_stash: BoolProperty(default=False, name='Stash',
description='Stash each imported action as a strip on a new non-contributing NLA track', description='Stash each imported action as a strip on a new non-contributing NLA track',
options=empty_set) options=empty_set)

View File

@@ -6,6 +6,7 @@ import numpy
from bpy.types import FCurve, Object, Context from bpy.types import FCurve, Object, Context
from mathutils import Vector, Quaternion from mathutils import Vector, Quaternion
from .config import PsaConfig, REMOVE_TRACK_LOCATION, REMOVE_TRACK_ROTATION
from .data import Psa from .data import Psa
from .reader import PsaReader from .reader import PsaReader
@@ -24,6 +25,8 @@ class PsaImportOptions(object):
self.bone_mapping_mode = 'CASE_INSENSITIVE' self.bone_mapping_mode = 'CASE_INSENSITIVE'
self.fps_source = 'SEQUENCE' self.fps_source = 'SEQUENCE'
self.fps_custom: float = 30.0 self.fps_custom: float = 30.0
self.should_use_config_file = True
self.psa_config: PsaConfig = PsaConfig()
class ImportBone(object): class ImportBone(object):
@@ -164,6 +167,11 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
sequence_name = sequence.name.decode('windows-1252') sequence_name = sequence.name.decode('windows-1252')
action_name = options.action_name_prefix + sequence_name action_name = options.action_name_prefix + sequence_name
# Get the bone track flags for this sequence, or an empty dictionary if none exist.
sequence_bone_track_flags = dict()
if sequence_name in options.psa_config.sequence_bone_flags.keys():
sequence_bone_track_flags = options.psa_config.sequence_bone_flags[sequence_name]
if options.should_overwrite and action_name in bpy.data.actions: if options.should_overwrite and action_name in bpy.data.actions:
action = bpy.data.actions[action_name] action = bpy.data.actions[action_name]
else: else:
@@ -188,18 +196,21 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
# Create f-curves for the rotation and location of each bone. # Create f-curves for the rotation and location of each bone.
for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items(): for psa_bone_index, armature_bone_index in psa_to_armature_bone_indices.items():
bone_track_flags = sequence_bone_track_flags.get(psa_bone_index, 0)
import_bone = import_bones[psa_bone_index] import_bone = import_bones[psa_bone_index]
pose_bone = import_bone.pose_bone pose_bone = import_bone.pose_bone
rotation_data_path = pose_bone.path_from_id('rotation_quaternion') rotation_data_path = pose_bone.path_from_id('rotation_quaternion')
location_data_path = pose_bone.path_from_id('location') location_data_path = pose_bone.path_from_id('location')
add_rotation_fcurves = (bone_track_flags & REMOVE_TRACK_ROTATION) == 0
add_location_fcurves = (bone_track_flags & REMOVE_TRACK_LOCATION) == 0
import_bone.fcurves = [ import_bone.fcurves = [
action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name), # Qw action.fcurves.new(rotation_data_path, index=0, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qw
action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name), # Qx action.fcurves.new(rotation_data_path, index=1, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qx
action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name), # Qy action.fcurves.new(rotation_data_path, index=2, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qy
action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name), # Qz action.fcurves.new(rotation_data_path, index=3, action_group=pose_bone.name) if add_rotation_fcurves else None, # Qz
action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name), # Lx action.fcurves.new(location_data_path, index=0, action_group=pose_bone.name) if add_location_fcurves else None, # Lx
action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name), # Ly action.fcurves.new(location_data_path, index=1, action_group=pose_bone.name) if add_location_fcurves else None, # Ly
action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name), # Lz action.fcurves.new(location_data_path, index=2, action_group=pose_bone.name) if add_location_fcurves else None, # Lz
] ]
if options.should_write_scale_keys: if options.should_write_scale_keys:
@@ -225,11 +236,15 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
# Write the keyframes out. # Write the keyframes out.
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float)
# Populate the keyframe time data.
fcurve_data[0::2] = [x * keyframe_time_dilation for x in range(sequence.frame_count)] fcurve_data[0::2] = [x * keyframe_time_dilation for x in range(sequence.frame_count)]
for bone_index, import_bone in enumerate(import_bones): for bone_index, import_bone in enumerate(import_bones):
if import_bone is None: if import_bone is None:
continue continue
for fcurve_index, fcurve in enumerate(import_bone.fcurves): for fcurve_index, fcurve in enumerate(import_bone.fcurves):
if fcurve is None:
continue
fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index] fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index]
fcurve.keyframe_points.add(sequence.frame_count) fcurve.keyframe_points.add(sequence.frame_count)
fcurve.keyframe_points.foreach_set('co', fcurve_data) fcurve.keyframe_points.foreach_set('co', fcurve_data)

View File

@@ -146,7 +146,9 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult:
psk_material.texture_index = len(psk.materials) psk_material.texture_index = len(psk.materials)
psk.materials.append(psk_material) psk.materials.append(psk_material)
for input_mesh_object in input_objects.mesh_objects: context.window_manager.progress_begin(0, len(input_objects.mesh_objects))
for object_index, input_mesh_object in enumerate(input_objects.mesh_objects):
# MATERIALS # MATERIALS
material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots] material_indices = [material_names.index(material_slot.material.name) for material_slot in input_mesh_object.material_slots]
@@ -288,6 +290,10 @@ def build_psk(context, options: PskBuildOptions) -> PskBuildResult:
bpy.data.meshes.remove(mesh_data) bpy.data.meshes.remove(mesh_data)
del mesh_data del mesh_data
context.window_manager.progress_update(object_index)
context.window_manager.progress_end()
result.psk = psk result.psk = psk
return result return result

View File

@@ -113,7 +113,7 @@ class PSK_OT_import(Operator, ImportHelper):
message += '\n'.join(result.warnings) message += '\n'.join(result.warnings)
self.report({'WARNING'}, message) self.report({'WARNING'}, message)
else: else:
self.report({'INFO'}, f'PSK imported') self.report({'INFO'}, f'PSK imported ({options.name})')
return {'FINISHED'} return {'FINISHED'}

View File

@@ -222,12 +222,13 @@ def import_psk(psk: Psk, context, options: PskImportOptions) -> PskImportResult:
# VERTEX NORMALS # VERTEX NORMALS
if psk.has_vertex_normals and options.should_import_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)) mesh_data.polygons.foreach_set('use_smooth', [True] * len(mesh_data.polygons))
normals = [] normals = []
for vertex_normal in psk.vertex_normals: for vertex_normal in psk.vertex_normals:
normals.append(tuple(vertex_normal)) normals.append(tuple(vertex_normal))
mesh_data.normals_split_custom_set_from_vertices(normals) mesh_data.normals_split_custom_set_from_vertices(normals)
mesh_data.use_auto_smooth = True else:
mesh_data.shade_smooth()
bm.normal_update() bm.normal_update()
bm.free() bm.free()