Added PSA resampling on import + some fixes for 4.1

This commit is contained in:
Colin Basnett
2024-02-13 14:03:04 -08:00
parent f7bbe911ea
commit 11bf205fe2
4 changed files with 92 additions and 52 deletions

View File

@@ -91,14 +91,15 @@ def update_actions_and_timeline_markers(context: Context, armature: Armature):
def get_sequence_fps(context: Context, fps_source: str, fps_custom: float, actions: Iterable[Action]) -> float:
if fps_source == 'SCENE':
match fps_source:
case 'SCENE':
return context.scene.render.fps
elif fps_source == 'CUSTOM':
case 'CUSTOM':
return fps_custom
elif fps_source == 'ACTION_METADATA':
case 'ACTION_METADATA':
# Get the minimum value of action metadata FPS values.
return min([action.psa_export.fps for action in actions])
else:
case _:
raise RuntimeError(f'Invalid FPS source "{fps_source}"')

View File

@@ -2,7 +2,7 @@ import os
from pathlib import Path
from bpy.props import StringProperty
from bpy.types import Operator, Event, Context
from bpy.types import Operator, Event, Context, FileHandler
from bpy_extras.io_utils import ImportHelper
from .properties import get_visible_sequences
@@ -89,23 +89,6 @@ class PSA_OT_import_sequences_deselect_all(Operator):
return {'FINISHED'}
class PSA_OT_import_select_file(Operator):
bl_idname = 'psa_import.select_file'
bl_label = 'Select'
bl_options = {'INTERNAL'}
bl_description = 'Select a PSA file from which to import animations'
filepath: StringProperty(subtype='FILE_PATH')
filter_glob: StringProperty(default='*.psa', options={'HIDDEN'})
def execute(self, context):
getattr(context.scene, 'psa_import').psa_file_path = self.filepath
return {'FINISHED'}
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def load_psa_file(context, filepath: str):
pg = context.scene.psa_import
pg.sequence_list.clear()
@@ -270,10 +253,13 @@ class PSA_OT_import(Operator, ImportHelper):
col.prop(pg, 'action_name_prefix')
class PSA_FH_import(FileHandler):
classes = (
PSA_OT_import_sequences_select_all,
PSA_OT_import_sequences_deselect_all,
PSA_OT_import_sequences_from_text,
PSA_OT_import,
PSA_OT_import_select_file,
)

View File

@@ -1,8 +1,9 @@
import math
import typing
from typing import List, Optional
import bpy
import numpy
import numpy as np
from bpy.types import FCurve, Object, Context
from mathutils import Vector, Quaternion
@@ -80,6 +81,50 @@ def _get_armature_bone_index_for_psa_bone(psa_bone_name: str, armature_bone_name
return None
def _resample_sequence_data_matrix(sequence_data_matrix: np.ndarray, target_frame_count: int):
'''
Resamples the sequence data matrix to the target frame count.
@param sequence_data_matrix: FxBx7 matrix where F is the number of frames, B is the number of bones, and X is the
number of data elements per bone.
@param target_frame_count: The number of frames to resample to.
@return: The resampled sequence data matrix, or sequence_data_matrix if no resampling is necessary.
'''
def get_sample_times(source_frame_count: int, target_frame_count: int) -> typing.Iterable[float]:
time = 0.0
time_step = source_frame_count / target_frame_count
while time <= target_frame_count - 1:
yield time
time += time_step
yield 1.0
source_frame_count, bone_count = sequence_data_matrix.shape[:1]
if target_frame_count == source_frame_count:
# No resampling is necessary.
return sequence_data_matrix
resampled_sequence_data_matrix = np.zeros((target_frame_count, bone_count, 7), dtype=float)
sample_times = get_sample_times(source_frame_count, target_frame_count)
for frame_index, sample_time in enumerate(sample_times):
if sample_time % 1.0 == 0.0:
# Sample time has no fractional part, so just copy the frame.
resampled_sequence_data_matrix[frame_index, :, :] = sequence_data_matrix[int(sample_time), :, :]
else:
# Sample time has a fractional part, so interpolate between two frames.
for bone_index in range(bone_count):
frame_index = int(sample_time)
source_frame_1_data = sequence_data_matrix[frame_index, bone_index, :]
source_frame_2_data = sequence_data_matrix[frame_index + 1, bone_index, :]
factor = sample_time - frame_index
q = Quaternion((source_frame_1_data[:4])).slerp(Quaternion((source_frame_2_data[:4])), factor)
q.normalize()
l = Vector(source_frame_1_data[4:]).lerp(Vector(source_frame_2_data[4:]), factor)
resampled_sequence_data_matrix[frame_index, bone_index, :] = q.w, q.x, q.y, q.z, l.x, l.y, l.z
return resampled_sequence_data_matrix
def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult:
result = PsaImportResult()
sequences = [psa_reader.sequences[x] for x in options.sequence_names]
@@ -186,12 +231,9 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
case _:
raise ValueError(f'Unknown FPS source: {options.fps_source}')
keyframe_time_dilation = target_fps / sequence.fps
if options.should_write_keyframes:
# Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2)
while len(action.fcurves) > 0:
action.fcurves.remove(action.fcurves[-1])
# Remove existing f-curves.
action.fcurves.clear()
# 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():
@@ -225,19 +267,27 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
# Calculate the local-space key data for the bone.
sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data)
# Write the keyframes out.
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float)
# Resample the sequence data to the target FPS.
# TODO: target frame count can be fractional.
target_frame_count = math.ceil(sequence.frame_count * (target_fps / sequence.fps))
# Write the keyframes out.
# Note that the f-curve data consists of alternating time and value data.
fcurve_data = np.zeros(2 * target_frame_count, dtype=float)
fcurve_data[0::2] = range(0, target_frame_count)
# Resample the sequence data matrix to the target frame count.
# If the target frame count is the same as the source frame count, this will be a no-op.
resampled_sequence_data_matrix = _resample_sequence_data_matrix(sequence_data_matrix, target_frame_count)
# Populate the keyframe time data.
fcurve_data[0::2] = [x * keyframe_time_dilation for x in range(sequence.frame_count)]
for bone_index, import_bone in enumerate(import_bones):
if import_bone is None:
continue
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.keyframe_points.add(sequence.frame_count)
fcurve_data[1::2] = resampled_sequence_data_matrix[:, bone_index, fcurve_index]
fcurve.keyframe_points.add(target_frame_count)
fcurve.keyframe_points.foreach_set('co', fcurve_data)
for fcurve_keyframe in fcurve.keyframe_points:
fcurve_keyframe.interpolation = 'LINEAR'

View File

@@ -11,15 +11,15 @@ from ..reader import read_psk
empty_set = set()
class PSX_FH_psk(FileHandler):
bl_idname = 'PSX_FH_psk'
bl_label = 'Unreal PSK/PSKX'
class PSK_FH_import(FileHandler):
bl_idname = 'PSK_FH_import'
bl_label = 'File handler for Unreal PSK/PSKX import'
bl_import_operator = 'import_scene.psk'
bl_file_extensions = '.psk;.pskx'
bl_file_extensions = '.psk'
@classmethod
def poll_drop(cls, context: Context):
return context.area.type == 'VIEW_3D'
return context.area and context.area.type == 'VIEW_3D'
class PSK_OT_import(Operator, ImportHelper):
@@ -143,10 +143,11 @@ class PSK_OT_import(Operator, ImportHelper):
col.use_property_decorate = False
col.prop(self, 'scale')
layout.prop(self, 'should_import_mesh')
mesh_header, mesh_panel = layout.panel('mesh_panel_id', default_closed=False)
mesh_header.prop(self, 'should_import_mesh')
if self.should_import_mesh:
row = layout.row()
if mesh_panel and self.should_import_mesh:
row = mesh_panel.row()
col = row.column()
col.use_property_split = True
col.use_property_decorate = False
@@ -158,9 +159,11 @@ class PSK_OT_import(Operator, ImportHelper):
col.prop(self, 'vertex_color_space')
col.prop(self, 'should_import_shape_keys', text='Shape Keys')
layout.prop(self, 'should_import_skeleton')
if self.should_import_skeleton:
row = layout.row()
skeleton_header, skeleton_panel = layout.panel('skeleton_panel_id', default_closed=False)
skeleton_header.prop(self, 'should_import_skeleton')
if skeleton_panel and self.should_import_skeleton:
row = skeleton_panel.row()
col = row.column()
col.use_property_split = True
col.use_property_decorate = False
@@ -169,5 +172,5 @@ class PSK_OT_import(Operator, ImportHelper):
classes = (
PSK_OT_import,
PSX_FH_psk,
PSK_FH_import,
)