Compare commits
7 Commits
feature-or
...
feature-ps
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df6bdb96a4 | ||
|
|
8495482345 | ||
|
|
1ac0870b31 | ||
|
|
19ff47cc83 | ||
|
|
31c0ec16ab | ||
|
|
a31c3ab2ae | ||
|
|
19a8f88686 |
11
README.md
11
README.md
@@ -1,4 +1,13 @@
|
||||
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). In addition, the non-standard PSKX format is also supported for import only.
|
||||
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).
|
||||
|
||||
# Features
|
||||
* Full PSK/PSA import and export capabilities
|
||||
* Non-standard PSKX file format with vertex normals, extra UV channels and vertex colors is supported for import only
|
||||
* Fine-grained PSA sequence importing for efficient workflow when working with large PSA files
|
||||
* Automatic keyframe reduction on PSA import
|
||||
* PSA sequence metadata (e.g., frame rate, sequence name) is preserved on import, allowing this data to be reused on export
|
||||
* An armature's [bone groups](https://docs.blender.org/manual/en/latest/animation/armatures/properties/bone_groups.html) can be excluded from PSK/PSA export (useful for excluding non-contributing bones such as IK controllers)
|
||||
* 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), allowing use of the [NLA](https://docs.blender.org/manual/en/latest/editors/nla/index.html) when creating sequences
|
||||
|
||||
# Installation
|
||||
1. Download the zip file for the latest version from the [releases](https://github.com/DarklightGames/io_export_psk_psa/releases) page.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import datetime
|
||||
from collections import Counter
|
||||
from typing import List
|
||||
from typing import List, Iterable
|
||||
|
||||
from bpy.types import NlaStrip
|
||||
from bpy.types import NlaStrip, Object
|
||||
from .types import BoneGroupListItem
|
||||
|
||||
|
||||
class Timer:
|
||||
@@ -56,7 +57,26 @@ def get_nla_strips_in_timeframe(object, frame_min, frame_max) -> List[NlaStrip]:
|
||||
return strips
|
||||
|
||||
|
||||
def populate_bone_group_list(armature_object, bone_group_list):
|
||||
def populate_bone_group_list(armature_object: Object, bone_group_list: Iterable[BoneGroupListItem]) -> None:
|
||||
"""
|
||||
Updates the bone group collection.
|
||||
|
||||
Bone group selections are preserved between updates unless none of the groups were previously selected;
|
||||
otherwise, all groups are selected by default.
|
||||
"""
|
||||
has_selected_groups = any([g.is_selected for g in bone_group_list])
|
||||
unassigned_group_is_selected, selected_assigned_group_names = True, []
|
||||
|
||||
if has_selected_groups:
|
||||
# Preserve group selections before clearing the list.
|
||||
# We handle selections for the unassigned group separately to cover the edge case
|
||||
# where there might be an actual group with 'Unassigned' as its name.
|
||||
unassigned_group_idx, unassigned_group_is_selected = next(iter([
|
||||
(i, g.is_selected) for i, g in enumerate(bone_group_list) if g.index == -1]), (-1, False))
|
||||
|
||||
selected_assigned_group_names = [
|
||||
g.name for i, g in enumerate(bone_group_list) if i != unassigned_group_idx and g.is_selected]
|
||||
|
||||
bone_group_list.clear()
|
||||
|
||||
if armature_object and armature_object.pose:
|
||||
@@ -66,14 +86,14 @@ def populate_bone_group_list(armature_object, bone_group_list):
|
||||
item.name = 'Unassigned'
|
||||
item.index = -1
|
||||
item.count = 0 if None not in bone_group_counts else bone_group_counts[None]
|
||||
item.is_selected = True
|
||||
item.is_selected = unassigned_group_is_selected
|
||||
|
||||
for bone_group_index, bone_group in enumerate(armature_object.pose.bone_groups):
|
||||
item = bone_group_list.add()
|
||||
item.name = bone_group.name
|
||||
item.index = bone_group_index
|
||||
item.count = 0 if bone_group not in bone_group_counts else bone_group_counts[bone_group]
|
||||
item.is_selected = True
|
||||
item.is_selected = bone_group.name in selected_assigned_group_names if has_selected_groups else True
|
||||
|
||||
|
||||
def get_psa_sequence_name(action, should_use_original_sequence_name):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Dict, Iterable
|
||||
|
||||
from bpy.types import Action
|
||||
from mathutils import Matrix
|
||||
|
||||
from .data import *
|
||||
from ..helpers import *
|
||||
@@ -19,13 +20,7 @@ class PsaBuilderOptions(object):
|
||||
self.should_trim_timeline_marker_sequences = True
|
||||
self.sequence_name_prefix = ''
|
||||
self.sequence_name_suffix = ''
|
||||
|
||||
|
||||
class PsaBuilderPerformance:
|
||||
def __init__(self):
|
||||
self.frame_set_duration = datetime.timedelta()
|
||||
self.key_build_duration = datetime.timedelta()
|
||||
self.key_add_duration = datetime.timedelta()
|
||||
self.root_motion = False
|
||||
|
||||
|
||||
class PsaBuilder(object):
|
||||
@@ -53,7 +48,6 @@ class PsaBuilder(object):
|
||||
raise RuntimeError(f'Invalid FPS source "{options.fps_source}"')
|
||||
|
||||
def build(self, context, options: PsaBuilderOptions) -> Psa:
|
||||
performance = PsaBuilderPerformance()
|
||||
active_object = context.view_layer.objects.active
|
||||
|
||||
if active_object.type != 'ARMATURE':
|
||||
@@ -201,40 +195,38 @@ class PsaBuilder(object):
|
||||
frame_count = frame_max - frame_min + 1
|
||||
|
||||
for frame in range(frame_count):
|
||||
with Timer() as t:
|
||||
context.scene.frame_set(frame_min + frame)
|
||||
performance.frame_set_duration += t.duration
|
||||
context.scene.frame_set(frame_min + frame)
|
||||
|
||||
for pose_bone in pose_bones:
|
||||
with Timer() as t:
|
||||
key = Psa.Key()
|
||||
key = Psa.Key()
|
||||
|
||||
if pose_bone.parent is not None:
|
||||
pose_bone_matrix = pose_bone.matrix
|
||||
pose_bone_parent_matrix = pose_bone.parent.matrix
|
||||
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
|
||||
else:
|
||||
if options.root_motion:
|
||||
# Export root motion
|
||||
pose_bone_matrix = armature.matrix_world @ pose_bone.matrix
|
||||
else:
|
||||
pose_bone_matrix = pose_bone.matrix
|
||||
|
||||
if pose_bone.parent is not None:
|
||||
pose_bone_parent_matrix = pose_bone.parent.matrix
|
||||
pose_bone_matrix = pose_bone_parent_matrix.inverted() @ pose_bone_matrix
|
||||
location = pose_bone_matrix.to_translation()
|
||||
rotation = pose_bone_matrix.to_quaternion().normalized()
|
||||
|
||||
location = pose_bone_matrix.to_translation()
|
||||
rotation = pose_bone_matrix.to_quaternion().normalized()
|
||||
if pose_bone.parent is not None:
|
||||
rotation.conjugate()
|
||||
|
||||
if pose_bone.parent is not None:
|
||||
rotation.x = -rotation.x
|
||||
rotation.y = -rotation.y
|
||||
rotation.z = -rotation.z
|
||||
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
|
||||
|
||||
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
|
||||
performance.key_build_duration += t.duration
|
||||
|
||||
with Timer() as t:
|
||||
psa.keys.append(key)
|
||||
performance.key_add_duration += t.duration
|
||||
psa.keys.append(key)
|
||||
|
||||
psa_sequence.bone_count = len(pose_bones)
|
||||
psa_sequence.track_time = frame_count
|
||||
@@ -264,8 +256,12 @@ class PsaBuilder(object):
|
||||
frame_max = sorted_timeline_markers[next_marker_index].frame
|
||||
if options.should_trim_timeline_marker_sequences:
|
||||
nla_strips = get_nla_strips_in_timeframe(object, marker.frame, frame_max)
|
||||
frame_max = min(frame_max, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips)))
|
||||
frame_min = max(frame_min, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
|
||||
if len(nla_strips) > 0:
|
||||
frame_max = min(frame_max, max(map(lambda nla_strip: nla_strip.frame_end, nla_strips)))
|
||||
frame_min = max(frame_min, min(map(lambda nla_strip: nla_strip.frame_start, nla_strips)))
|
||||
else:
|
||||
# No strips in between this marker and the next, just export this as a one-frame animation.
|
||||
frame_max = frame_min
|
||||
else:
|
||||
# There is no next marker.
|
||||
# Find the final frame of all the NLA strips and use that as the last frame of this sequence.
|
||||
|
||||
@@ -65,6 +65,12 @@ def should_use_original_sequence_names_updated(_, context):
|
||||
|
||||
|
||||
class PsaExportPropertyGroup(PropertyGroup):
|
||||
root_motion: BoolProperty(
|
||||
name='Root Motion',
|
||||
options=set(),
|
||||
default=False,
|
||||
description='When set, the root bone will be transformed as it appears in the scene',
|
||||
)
|
||||
sequence_source: EnumProperty(
|
||||
name='Source',
|
||||
options=set(),
|
||||
@@ -165,6 +171,9 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
# SOURCE
|
||||
layout.prop(pg, 'sequence_source', text='Source')
|
||||
|
||||
# ROOT MOTION
|
||||
layout.prop(pg, 'root_motion', text='Root Motion')
|
||||
|
||||
# SELECT ALL/NONE
|
||||
row = layout.row(align=True)
|
||||
row.label(text='Select')
|
||||
@@ -297,6 +306,7 @@ class PsaExportOperator(Operator, ExportHelper):
|
||||
options.should_trim_timeline_marker_sequences = pg.should_trim_timeline_marker_sequences
|
||||
options.sequence_name_prefix = pg.sequence_name_prefix
|
||||
options.sequence_name_suffix = pg.sequence_name_suffix
|
||||
options.root_motion = pg.root_motion
|
||||
|
||||
builder = PsaBuilder()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user