diff --git a/io_scene_psk_psa/psa/builder.py b/io_scene_psk_psa/psa/builder.py index 7723bc4..b22cdab 100644 --- a/io_scene_psk_psa/psa/builder.py +++ b/io_scene_psk_psa/psa/builder.py @@ -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 diff --git a/io_scene_psk_psa/psa/export/operators.py b/io_scene_psk_psa/psa/export/operators.py index c30d6f8..604eca0 100644 --- a/io_scene_psk_psa/psa/export/operators.py +++ b/io_scene_psk_psa/psa/export/operators.py @@ -314,13 +314,16 @@ class PSA_OT_export(Operator, ExportHelper): layout.label(text=f'Duplicate action: {action_name}', icon='ERROR') break - data_source_header, data_source_panel = layout.panel('Data Source', default_closed=False) - data_source_header.label(text='Data Sources') - if data_source_panel: - flow = data_source_panel.grid_flow() + 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') @@ -378,6 +381,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) @@ -482,6 +488,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) diff --git a/io_scene_psk_psa/psa/export/properties.py b/io_scene_psk_psa/psa/export/properties.py index d9587d9..46a12f0 100644 --- a/io_scene_psk_psa/psa/export/properties.py +++ b/io_scene_psk_psa/psa/export/properties.py @@ -217,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]: