Compare commits

...

7 Commits

8 changed files with 237 additions and 53 deletions

View File

@@ -30,7 +30,8 @@ jobs:
sudo apt-get install libxfixes3 -y
sudo apt-get install libxi-dev -y
sudo apt-get install libxkbcommon-x11-0 -y
sudo apt-get install libgl1-mesa-glx -y
sudo apt-get install libgl1 -y
sudo apt-get install libglx-mesa0 -y
- name: Download & Extract Blender
run: |
wget -q $BLENDER_URL

View File

@@ -1,6 +1,6 @@
schema_version = "1.0.0"
id = "io_scene_psk_psa"
version = "7.1.3"
version = "8.0.0"
name = "Unreal PSK/PSA (.psk/.psa)"
tagline = "Import and export PSK and PSA files used in Unreal Engine"
maintainer = "Colin Basnett <cmbasnett@gmail.com>"

View File

@@ -1,6 +1,7 @@
from typing import Optional
from bpy.types import Bone, Action, PoseBone
from mathutils import Vector
from .data import *
from ..shared.helpers import *
@@ -33,6 +34,7 @@ class PsaBuildOptions:
self.sequence_name_suffix: str = ''
self.root_motion: bool = False
self.scale = 1.0
self.sampling_mode: str = 'INTERPOLATED' # One of ('INTERPOLATED', 'SUBFRAME')
def _get_pose_bone_location_and_rotation(pose_bone: PoseBone, armature_object: Object, options: PsaBuildOptions):
@@ -184,24 +186,83 @@ def build_psa(context: bpy.types.Context, options: PsaBuildOptions) -> Psa:
frame = float(frame_start)
for _ in range(frame_count):
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
def add_key(location: Vector, rotation: Quaternion):
key = Psa.Key()
key.location.x = location.x
key.location.y = location.y
key.location.z = location.z
key.rotation.x = rotation.x
key.rotation.y = rotation.y
key.rotation.z = rotation.z
key.rotation.w = rotation.w
key.time = 1.0 / psa_sequence.fps
psa.keys.append(key)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
match options.sampling_mode:
case 'INTERPOLATED':
# Used as a store for the last frame's pose bone locations and rotations.
last_frame: Optional[int] = None
last_frame_bone_poses: List[Tuple[Vector, Quaternion]] = []
key = Psa.Key()
key.location.x = location.x
key.location.y = location.y
key.location.z = location.z
key.rotation.x = rotation.x
key.rotation.y = rotation.y
key.rotation.z = rotation.z
key.rotation.w = rotation.w
key.time = 1.0 / psa_sequence.fps
psa.keys.append(key)
next_frame: Optional[int] = None
next_frame_bone_poses: List[Tuple[Vector, Quaternion]] = []
frame += frame_step
for _ in range(frame_count):
if last_frame is None or last_frame != int(frame):
# Populate the bone poses for frame A.
last_frame = int(frame)
# TODO: simplify this code and make it easier to follow!
if next_frame == last_frame:
# Simply transfer the data from next_frame to the last_frame so that we don't need to
# resample anything.
last_frame_bone_poses = next_frame_bone_poses.copy()
else:
last_frame_bone_poses.clear()
context.scene.frame_set(frame=last_frame)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone,
export_sequence.armature_object,
options)
last_frame_bone_poses.append((location, rotation))
next_frame = None
next_frame_bone_poses.clear()
# If this is not a subframe, just use the last frame's bone poses.
if frame % 1.0 == 0:
for i in range(len(pose_bones)):
add_key(*last_frame_bone_poses[i])
else:
# Otherwise, this is a subframe, so we need to interpolate the pose between the next frame and the last frame.
if next_frame is None:
next_frame = last_frame + 1
context.scene.frame_set(frame=next_frame)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
next_frame_bone_poses.append((location, rotation))
factor = frame % 1.0
for i in range(len(pose_bones)):
last_location, last_rotation = last_frame_bone_poses[i]
next_location, next_rotation = next_frame_bone_poses[i]
location = last_location.lerp(next_location, factor)
rotation = last_rotation.slerp(next_rotation, factor)
add_key(location, rotation)
frame += frame_step
case 'SUBFRAME':
for _ in range(frame_count):
context.scene.frame_set(frame=int(frame), subframe=frame % 1.0)
for pose_bone in pose_bones:
location, rotation = _get_pose_bone_location_and_rotation(pose_bone, export_sequence.armature_object, options)
add_key(location, rotation)
frame += frame_step
frame_start_index += frame_count

View File

@@ -1,4 +1,3 @@
import re
from collections import Counter
from typing import List, Iterable, Dict, Tuple, cast, Optional
@@ -12,7 +11,7 @@ from .properties import PSA_PG_export, PSA_PG_export_action_list_item, filter_se
get_sequences_from_name_and_frame_range
from ..builder import build_psa, PsaBuildSequence, PsaBuildOptions
from ..writer import write_psa
from ...shared.helpers import populate_bone_collection_list, get_nla_strips_in_frame_range
from ...shared.helpers import populate_bone_collection_list, get_nla_strips_in_frame_range, SemanticVersion
from ...shared.ui import draw_bone_filter_mode
@@ -33,14 +32,27 @@ def get_sequences_propnames_from_source(sequence_source: str) -> Optional[Tuple[
def is_action_for_armature(armature: Armature, action: Action):
if len(action.fcurves) == 0:
return False
bone_names = set([x.name for x in armature.bones])
for fcurve in action.fcurves:
match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
if not match:
continue
bone_name = match.group(1)
if bone_name in bone_names:
return True
version = SemanticVersion(bpy.app.version)
if version < SemanticVersion((4, 4, 0)):
import re
bone_names = set([x.name for x in armature.bones])
for fcurve in action.fcurves:
match = re.match(r'pose\.bones\[\"([^\"]+)\"](\[\"([^\"]+)\"])?', fcurve.data_path)
if not match:
continue
bone_name = match.group(1)
if bone_name in bone_names:
return True
else:
# Look up the armature by ID and check if its data block pointer matches the armature.
for slot in filter(lambda x: x.id_root == 'OBJECT', action.slots):
# Lop off the 'OB' prefix from the identifier for the lookup.
object = bpy.data.objects.get(slot.identifier[2:], None)
if object and object.data == armature:
return True
return False
@@ -137,6 +149,17 @@ def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actio
raise RuntimeError(f'Invalid FPS source "{fps_source}"')
def get_sequence_compression_ratio(compression_ratio_source: str, compression_ratio_custom: float, actions: Iterable[Action]) -> float:
match compression_ratio_source:
case 'ACTION_METADATA':
# Get the minimum value of action metadata compression ratio values.
return min([action.psa_export.compression_ratio for action in actions])
case 'CUSTOM':
return compression_ratio_custom
case _:
raise RuntimeError(f'Invalid compression ratio source "{compression_ratio_source}"')
def get_animation_data_object(context: Context) -> Object:
pg: PSA_PG_export = getattr(context.scene, 'psa_export')
@@ -285,25 +308,45 @@ class PSA_OT_export(Operator, ExportHelper):
sequences_panel.template_list(PSA_UL_export_sequences.bl_idname, '', pg, propname, pg, active_propname,
rows=max(3, min(len(getattr(pg, propname)), 10)))
flow = sequences_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'sequence_name_prefix', text='Name Prefix')
flow.prop(pg, 'sequence_name_suffix')
name_header, name_panel = layout.panel('Name', default_closed=False)
name_header.label(text='Name')
if name_panel:
flow = name_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
flow.prop(pg, 'sequence_name_prefix', text='Name Prefix')
flow.prop(pg, 'sequence_name_suffix')
# Determine if there is going to be a naming conflict and display an error, if so.
selected_items = [x for x in pg.action_list if x.is_selected]
action_names = [x.name for x in selected_items]
action_name_counts = Counter(action_names)
for action_name, count in action_name_counts.items():
if count > 1:
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR')
break
# Determine if there is going to be a naming conflict and display an error, if so.
selected_items = [x for x in pg.action_list if x.is_selected]
action_names = [x.name for x in selected_items]
action_name_counts = Counter(action_names)
for action_name, count in action_name_counts.items():
if count > 1:
layout.label(text=f'Duplicate action: {action_name}', icon='ERROR')
break
# FPS
flow.prop(pg, 'fps_source')
if pg.fps_source == 'CUSTOM':
flow.prop(pg, 'fps_custom', text='Custom')
sampling_header, sampling_panel = layout.panel('Data Source', default_closed=False)
sampling_header.label(text='Sampling')
if sampling_panel:
flow = sampling_panel.grid_flow()
flow.use_property_split = True
flow.use_property_decorate = False
# SAMPLING MODE
flow.prop(pg, 'sampling_mode', text='Sampling Mode')
# FPS
col = flow.row(align=True)
col.prop(pg, 'fps_source', text='FPS')
if pg.fps_source == 'CUSTOM':
col.prop(pg, 'fps_custom', text='')
# COMPRESSION RATIO
col = flow.row(align=True)
col.prop(pg, 'compression_ratio_source', text='Compression Ratio')
if pg.compression_ratio_source == 'CUSTOM':
col.prop(pg, 'compression_ratio_custom', text='')
# BONES
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
@@ -350,6 +393,9 @@ class PSA_OT_export(Operator, ExportHelper):
f'\The armature data block for "{obj.name}\" (\'{obj.data.name}\') does not match '
f'the active armature data block (\'{context.view_layer.objects.active.name}\')')
if context.scene.is_nla_tweakmode:
raise RuntimeError('Cannot export PSA while in NLA tweak mode')
def invoke(self, context, _event):
try:
self._check_context(context)
@@ -406,7 +452,7 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequence.nla_state.frame_start = action_item.frame_start
export_sequence.nla_state.frame_end = action_item.frame_end
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action_item.action])
export_sequence.compression_ratio = action_item.action.psa_export.compression_ratio
export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [action_item.action])
export_sequence.key_quota = action_item.action.psa_export.key_quota
export_sequences.append(export_sequence)
case 'TIMELINE_MARKERS':
@@ -418,6 +464,7 @@ class PSA_OT_export(Operator, ExportHelper):
nla_strips_actions = set(
map(lambda x: x.action, get_nla_strips_in_frame_range(animation_data, marker_item.frame_start, marker_item.frame_end)))
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, nla_strips_actions)
export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, nla_strips_actions)
export_sequences.append(export_sequence)
case 'NLA_TRACK_STRIPS':
for nla_strip_item in filter(lambda x: x.is_selected, pg.nla_strip_list):
@@ -426,7 +473,7 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequence.nla_state.frame_start = nla_strip_item.frame_start
export_sequence.nla_state.frame_end = nla_strip_item.frame_end
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [nla_strip_item.action])
export_sequence.compression_ratio = nla_strip_item.action.psa_export.compression_ratio
export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [nla_strip_item.action])
export_sequence.key_quota = nla_strip_item.action.psa_export.key_quota
export_sequences.append(export_sequence)
case 'ACTIVE_ACTION':
@@ -438,7 +485,7 @@ class PSA_OT_export(Operator, ExportHelper):
export_sequence.nla_state.frame_start = int(action.frame_range[0])
export_sequence.nla_state.frame_end = int(action.frame_range[1])
export_sequence.fps = get_sequence_fps(context, pg.fps_source, pg.fps_custom, [action])
export_sequence.compression_ratio = action.psa_export.compression_ratio
export_sequence.compression_ratio = get_sequence_compression_ratio(pg.compression_ratio_source, pg.compression_ratio_custom, [action])
export_sequence.key_quota = action.psa_export.key_quota
export_sequences.append(export_sequence)
case _:
@@ -453,6 +500,7 @@ class PSA_OT_export(Operator, ExportHelper):
options.sequence_name_suffix = pg.sequence_name_suffix
options.root_motion = pg.root_motion
options.scale = pg.scale
options.sampling_mode = pg.sampling_mode
try:
psa = build_psa(context, options)

View File

@@ -156,6 +156,16 @@ class PSA_PG_export(PropertyGroup):
)
fps_custom: FloatProperty(default=30.0, min=sys.float_info.epsilon, soft_min=1.0, options=empty_set, step=100,
soft_max=60.0)
compression_ratio_source: EnumProperty(
name='Compression Ratio Source',
options=empty_set,
description='',
items=(
('ACTION_METADATA', 'Action Metadata', 'The compression ratio will be determined by action\'s Compression Ratio property found in the PSA Export panel.\n\nIf the Sequence Source is Timeline Markers, the lowest value of all contributing actions will be used', 'ACTION', 1),
('CUSTOM', 'Custom', '', 2)
)
)
compression_ratio_custom: FloatProperty(default=1.0, min=0.0, max=1.0, subtype='FACTOR', description='The key sampling ratio of the exported sequence.\n\nA compression ratio of 1.0 will export all frames, while a compression ratio of 0.5 will export half of the frames')
action_list: CollectionProperty(type=PSA_PG_export_action_list_item)
action_list_index: IntProperty(default=0)
marker_list: CollectionProperty(type=PSA_PG_export_timeline_markers)
@@ -207,6 +217,16 @@ class PSA_PG_export(PropertyGroup):
min=0.0,
soft_max=100.0
)
sampling_mode: EnumProperty(
name='Sampling Mode',
options=empty_set,
description='The method by which frames are sampled',
items=(
('INTERPOLATED', 'Interpolated', 'Sampling is performed by interpolating the evaluated bone poses from the adjacent whole frames.', 'INTERPOLATED', 0),
('SUBFRAME', 'Subframe', 'Sampling is performed by evaluating the bone poses at the subframe time.\n\nNot recommended unless you are also animating with subframes enabled.', 'SUBFRAME', 1),
),
default='INTERPOLATED'
)
def filter_sequences(pg: PSA_PG_export, sequences) -> List[int]:

View File

@@ -279,7 +279,7 @@ class PSK_OT_export_collection(Operator, ExportHelper):
bones_header, bones_panel = layout.panel('Bones', default_closed=False)
bones_header.label(text='Bones', icon='BONE_DATA')
if bones_panel:
draw_bone_filter_mode(bones_panel, self)
draw_bone_filter_mode(bones_panel, self, True)
if self.bone_filter_mode == 'BONE_COLLECTIONS':
bones_panel.operator(PSK_OT_populate_bone_collection_list.bl_idname, icon='FILE_REFRESH')
rows = max(3, min(len(self.bone_collection_list), 10))

View File

@@ -1,4 +1,4 @@
from typing import List, Iterable, cast
from typing import List, Iterable, cast, Tuple
import bpy
from bpy.props import CollectionProperty
@@ -63,7 +63,7 @@ def populate_bone_collection_list(armature_object: Object, bone_collection_list:
item.count = sum(map(lambda bone: 1 if len(bone.collections) == 0 else 0, armature.bones))
item.is_selected = unassigned_collection_is_selected
for bone_collection_index, bone_collection in enumerate(armature.collections):
for bone_collection_index, bone_collection in enumerate(armature.collections_all):
item = bone_collection_list.add()
item.name = bone_collection.name
item.index = bone_collection_index
@@ -92,7 +92,7 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
# Get a list of the bone indices that we are explicitly including.
bone_index_stack = []
is_exporting_unassigned_bone_collections = -1 in bone_collection_indices
bone_collections = list(armature_data.collections)
bone_collections = list(armature_data.collections_all)
for bone_index, bone in enumerate(bones):
# Check if this bone is in any of the collections in the bone collection indices list.
@@ -154,3 +154,57 @@ def get_export_bone_names(armature_object: Object, bone_filter_mode: str, bone_c
def is_bdk_addon_loaded() -> bool:
return 'bdk' in dir(bpy.ops)
class SemanticVersion(object):
def __init__(self, version: Tuple[int, int, int]):
self.major, self.minor, self.patch = version
def __iter__(self):
yield self.major
yield self.minor
yield self.patch
@staticmethod
def compare(lhs: 'SemanticVersion', rhs: 'SemanticVersion') -> int:
"""
Compares two semantic versions.
Returns:
-1 if lhs < rhs
0 if lhs == rhs
1 if lhs > rhs
"""
for l, r in zip(lhs, rhs):
if l < r:
return -1
if l > r:
return 1
return 0
def __str__(self):
return f'{self.major}.{self.minor}.{self.patch}'
def __repr__(self):
return str(self)
def __eq__(self, other):
return self.compare(self, other) == 0
def __ne__(self, other):
return not self == other
def __lt__(self, other):
return self.compare(self, other) == -1
def __le__(self, other):
return self.compare(self, other) <= 0
def __gt__(self, other):
return self.compare(self, other) == 1
def __ge__(self, other):
return self.compare(self, other) >= 0
def __hash__(self):
return hash((self.major, self.minor, self.patch))

View File

@@ -9,10 +9,10 @@ def is_bone_filter_mode_item_available(pg, identifier):
return True
def draw_bone_filter_mode(layout: UILayout, pg):
def draw_bone_filter_mode(layout: UILayout, pg, should_always_show_bone_collections=False):
row = layout.row(align=True)
for item_identifier, _, _ in bone_filter_mode_items:
identifier = item_identifier
item_layout = row.row(align=True)
item_layout.prop_enum(pg, 'bone_filter_mode', item_identifier)
item_layout.enabled = is_bone_filter_mode_item_available(pg, identifier)
item_layout.enabled = should_always_show_bone_collections or is_bone_filter_mode_item_available(pg, identifier)