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

View File

@@ -2,7 +2,7 @@ import os
from pathlib import Path 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, FileHandler
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
@@ -89,23 +89,6 @@ class PSA_OT_import_sequences_deselect_all(Operator):
return {'FINISHED'} 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): def load_psa_file(context, filepath: str):
pg = context.scene.psa_import pg = context.scene.psa_import
pg.sequence_list.clear() pg.sequence_list.clear()
@@ -270,10 +253,13 @@ class PSA_OT_import(Operator, ImportHelper):
col.prop(pg, 'action_name_prefix') col.prop(pg, 'action_name_prefix')
class PSA_FH_import(FileHandler):
classes = ( classes = (
PSA_OT_import_sequences_select_all, PSA_OT_import_sequences_select_all,
PSA_OT_import_sequences_deselect_all, PSA_OT_import_sequences_deselect_all,
PSA_OT_import_sequences_from_text, PSA_OT_import_sequences_from_text,
PSA_OT_import, PSA_OT_import,
PSA_OT_import_select_file,
) )

View File

@@ -1,8 +1,9 @@
import math
import typing import typing
from typing import List, Optional from typing import List, Optional
import bpy import bpy
import numpy import numpy as np
from bpy.types import FCurve, Object, Context from bpy.types import FCurve, Object, Context
from mathutils import Vector, Quaternion 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 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: def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object, options: PsaImportOptions) -> PsaImportResult:
result = PsaImportResult() result = PsaImportResult()
sequences = [psa_reader.sequences[x] for x in options.sequence_names] 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 _: case _:
raise ValueError(f'Unknown FPS source: {options.fps_source}') raise ValueError(f'Unknown FPS source: {options.fps_source}')
keyframe_time_dilation = target_fps / sequence.fps
if options.should_write_keyframes: if options.should_write_keyframes:
# Remove existing f-curves (replace with action.fcurves.clear() in Blender 3.2) # Remove existing f-curves.
while len(action.fcurves) > 0: action.fcurves.clear()
action.fcurves.remove(action.fcurves[-1])
# 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():
@@ -225,19 +267,27 @@ def import_psa(context: Context, psa_reader: PsaReader, armature_object: Object,
# Calculate the local-space key data for the bone. # Calculate the local-space key data for the bone.
sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data) sequence_data_matrix[frame_index, bone_index] = _calculate_fcurve_data(import_bone, key_data)
# Write the keyframes out. # Resample the sequence data to the target FPS.
fcurve_data = numpy.zeros(2 * sequence.frame_count, dtype=float) # 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): 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: if fcurve is None:
continue continue
fcurve_data[1::2] = sequence_data_matrix[:, bone_index, fcurve_index] fcurve_data[1::2] = resampled_sequence_data_matrix[:, bone_index, fcurve_index]
fcurve.keyframe_points.add(sequence.frame_count) fcurve.keyframe_points.add(target_frame_count)
fcurve.keyframe_points.foreach_set('co', fcurve_data) fcurve.keyframe_points.foreach_set('co', fcurve_data)
for fcurve_keyframe in fcurve.keyframe_points: for fcurve_keyframe in fcurve.keyframe_points:
fcurve_keyframe.interpolation = 'LINEAR' fcurve_keyframe.interpolation = 'LINEAR'

View File

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