Last active
November 15, 2022 05:27
-
-
Save green224/ce9c257c9b55fbd38f849bf3f6863224 to your computer and use it in GitHub Desktop.
アニメーション付きFBXを正しく出力するためのBlenderアドオン
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
アニメーションを全BakeしてFBX出力をするアドオン。 | |
そのままのFBX出力には複数の問題がある。 | |
・一部のConstraintsやDriverなどで、姿勢の反映が1フレーム遅延するタイプのものが正常に出力できない。 | |
・スケーリングしたボーンの子ボーンをConstraintsで回転させた場合に、正常なモーションを出力できない。 | |
また組み込みのActionベイク処理は、諸々正常にベイクを行わないため、 | |
この問題に対する解決法として使用することができない。 | |
このアドオンでは、正しくBakeして、出力を行う。 | |
使い方 | |
1.File->Export Baked Anim FBX を選択する | |
2.出力先ファイルを指定する | |
3.FBXが出力される。 | |
出力過程で作業状態に変更が加わってしまうので、最後にリバートが行われる。 | |
そのため、セーブ済みのファイルで無いと実行できないようにしている | |
注意 | |
・出力対象のアニメーションは、NLAトラックのみ。 | |
Actionは出力されないので、出力したいアニメーションはNLAトラック化すること。 | |
・ミュート状態のNLAトラックは出力されない仕様なので、 | |
出力したいNLAトラックのミュート状態はOFFにしておくこと。 | |
・Bake範囲は、キーが打ってある範囲に限定される。 | |
・名前に "[DONT_BAKE]" が含まれるボーンはベイク対象外となる。 | |
(ベイク処理を行わずにそのままFBX出力される) | |
・オブジェクトのVisibilityなど、そもそもBlenderではベイクできないものもある。 | |
""" | |
import bpy | |
import math | |
import os | |
from bpy.props import IntProperty, FloatProperty, EnumProperty | |
from bpy.props import FloatVectorProperty, StringProperty | |
from bpy_extras.io_utils import ( | |
ImportHelper, | |
ExportHelper, | |
orientation_helper, | |
path_reference_mode, | |
axis_conversion, | |
) | |
# プラグインに関する情報 | |
bl_info = { | |
"name" : "Baked FBX Exporter", | |
"author" : "Shu", | |
"version" : (2,2), | |
'blender': (2, 80, 0), | |
"location": "File > Import-Export", | |
"description" : "Export FBX with baked animation", | |
"warning" : "", | |
"wiki_url" : "", | |
"tracker_url" : "", | |
"category" : "Import-Export" | |
} | |
#------------------------------------------------------- | |
# ベイク対象から外すボーンの名前に追加する文字列 | |
DONT_BAKE_KEYWORD = "[DONT_BAKE]" | |
# ボーンの状態を更新する。 | |
# そのままだと最終結果のL2W変換行列を使用するタイプのDriver等、反映が1フレ遅延するタイプの | |
# Driver/Constraintが反映されないままになってしまうので、これで強制的に再更新をかける。 | |
def forceRefreshBones(armature): | |
# view_layer.updateだけではL2W行列は更新されないが、なぜか選択をすると更新される。 | |
# 逆にこの方法以外で強制更新を行う方法が見つかっていない。 | |
for i in armature.data.bones: i.select = True | |
bpy.context.view_layer.update() | |
for i in armature.data.bones: i.select = False | |
bpy.context.view_layer.update() | |
# ボーンのL2Wマトリクスを取得する。 | |
# そのままだと初期位置や親ボーンなども含めた行列しか取れず、 | |
# 直接回転などを取得できないため、これを使用する。 | |
def getBoneMtx(amt, boneName): | |
pBone = amt.pose.bones[boneName] | |
dBone = amt.data.bones[boneName] | |
pMtx = pBone.matrix.copy() | |
dMtx = dBone.matrix_local.copy() | |
if pBone.parent is not None: | |
pMtxP = pBone.parent.matrix.copy() | |
dMtxP = dBone.parent.matrix_local.copy() | |
pMtxP.invert() | |
dMtxP.invert() | |
pMtx = pMtxP @ pMtx | |
dMtx = dMtxP @ dMtx | |
dMtx.invert() | |
return dMtx @ pMtx | |
# 対象Armatureを取得する。1Armatureにだけ対応している。 | |
# (そもそも複数ArmatureはUnityで再生できない) | |
def tgtArmature( operator ): | |
ret = None | |
for obj in bpy.data.objects: | |
if not obj :continue | |
if obj.type != 'ARMATURE':continue | |
ret = obj | |
break | |
if not ret: | |
operator.report({'WARNING'},'There is no armature') | |
return None | |
if not ret.animation_data: | |
operator.report({'WARNING'},'There is no animation') | |
return None | |
if not ret.animation_data.nla_tracks: | |
operator.report({'WARNING'},'There is no NLA tracks') | |
return None | |
return ret | |
# ベイク処理本体 | |
def bakeAnim( operator ): | |
# 対象Armatureを取得 | |
tgt_armature = tgtArmature(operator) | |
if tgt_armature is None: return False | |
# オリジナルArmatureのモードおよび、NLAトラックのミュート状態を記憶しておく | |
bpy.context.view_layer.objects.active = tgt_armature | |
old_mode = bpy.context.object.mode | |
bpy.ops.object.mode_set(mode='POSE') | |
old_tracks_mute = [] | |
for track in tgt_armature.animation_data.nla_tracks: | |
old_tracks_mute.append(track.mute) | |
track.mute = True | |
# ベイクする必要のあるボーン名を収集 | |
deformBoneKeys = [] | |
for key in tgt_armature.data.bones.keys(): | |
if tgt_armature.data.bones[key].use_deform : | |
deformBoneKeys.append(key) | |
# アニメーションの再生のために、Armatureの状態をクリーンにする | |
def cleanArmaturePose(action): | |
# 目標Armatureのみを選択 | |
bpy.context.view_layer.objects.active = tgt_armature | |
bpy.ops.object.mode_set(mode='OBJECT') | |
bpy.ops.object.select_all(action='DESELECT') | |
bpy.context.view_layer.objects.active = tgt_armature | |
tgt_armature.select_set( True ) | |
tgt_armature.animation_data.action = action | |
bpy.ops.object.mode_set(mode='POSE') | |
# キーのないボーンのTransformをデフォルト状態にしておく | |
bpy.ops.pose.select_all(action='SELECT') | |
bpy.ops.pose.transforms_clear() | |
# アクションのキーフレーム長を計算 | |
def getActionFrameLength(action): | |
firstFrame = 9999999 | |
lastFrame = -9999999 | |
for fcu in action.fcurves: | |
for keyframe in fcu.keyframe_points: | |
x, y = keyframe.co | |
k = math.ceil(x) | |
if k < firstFrame : firstFrame = k | |
if k > lastFrame : lastFrame = k | |
return firstFrame, lastFrame | |
# 全アクションを列挙 | |
#actions = [ a for a in bpy.data.actions if bool(a) & a.use_fake_user ] | |
actions = [ a for a in bpy.data.actions if bool(a) ] | |
transCaches = [] | |
for action in actions: | |
cleanArmaturePose(action) | |
firstFrame, lastFrame = getActionFrameLength(action) | |
# アクションの全フレームにわたって、Transform情報を収集する | |
transCache = [] | |
for i in range(firstFrame, lastFrame+1): | |
bpy.context.scene.frame_set(i) | |
forceRefreshBones(tgt_armature) | |
transCache_1bone = [getBoneMtx(tgt_armature,j) for j in deformBoneKeys] | |
transCache.append(transCache_1bone) | |
transCaches.append(transCache) | |
for action_idx, action in enumerate(actions): | |
cleanArmaturePose(action) | |
firstFrame, lastFrame = getActionFrameLength(action) | |
# 既存のキーを全削除する | |
fcCache = [] | |
for fcu in action.fcurves: | |
if not DONT_BAKE_KEYWORD in fcu.data_path: fcCache.append(fcu) # ベイク対象外オブジェクトは除く | |
for fcu in fcCache: action.fcurves.remove(fcu) | |
# Deformボーンについて、全フレームにTransform情報をベイク | |
def makeFC(boneName, path, idx): | |
ret = action.fcurves.find('pose.bones["'+boneName+'"].'+path, index=idx) | |
if ret is None: | |
ret = action.fcurves.new('pose.bones["'+boneName+'"].'+path, index=idx) | |
# ret.auto_smoothing = 'CONT_ACCEL' | |
ret.keyframe_points.add(lastFrame-firstFrame+1) | |
return ret | |
def makeFCs(boneName, path, len): | |
return [makeFC(boneName, path, i) for i in range(len)] | |
def setKeyFramePoints(fcs, frameIdx, frameT, vals): | |
for idx, fc in enumerate(fcs): | |
pnt = fc.keyframe_points[frameIdx] | |
pnt.co = frameT, vals[idx] | |
pnt.interpolation = 'BEZIER' | |
pnt.handle_left_type = 'AUTO_CLAMPED' | |
pnt.handle_right_type = 'AUTO_CLAMPED' | |
for boneIdx, boneName in enumerate(deformBoneKeys): | |
fc_l = makeFCs(boneName ,"location", 3) | |
fc_r = makeFCs(boneName ,"rotation_quaternion", 4) | |
fc_s = makeFCs(boneName ,"scale", 3) | |
for frameIdx, frameT in enumerate(range(firstFrame, lastFrame+1)): | |
mtx = transCaches[action_idx][frameIdx][boneIdx] | |
t = mtx.to_translation() | |
r = mtx.to_quaternion() | |
s = mtx.to_scale() | |
setKeyFramePoints(fc_l, frameIdx, frameT, [t.x, t.y, t.z]) | |
setKeyFramePoints(fc_r, frameIdx, frameT, [r.w, r.x, r.y, r.z]) | |
setKeyFramePoints(fc_s, frameIdx, frameT, [s.x, s.y, s.z]) | |
for fc in fc_l: fc.update() | |
for fc in fc_r: fc.update() | |
for fc in fc_s: fc.update() | |
print("Action: {}, First frame: {}, Second frame: {}".format(action.name, firstFrame, lastFrame)) | |
# 表示アクションを非選択にする | |
cleanArmaturePose(None) | |
# Constraintsを全削除 | |
for bone in tgt_armature.pose.bones: | |
if not DONT_BAKE_KEYWORD in bone.name: # ベイク対象外オブジェクトは除く | |
cstrCache = [i for i in bone.constraints] | |
for i in cstrCache: bone.constraints.remove(i) | |
# Driverを全削除 | |
drvCache = [i for i in tgt_armature.animation_data.drivers] | |
for i in drvCache: tgt_armature.animation_data.drivers.remove(i) | |
# 全ボーンの回転モードを四元数に変更 | |
for bone in tgt_armature.pose.bones: bone.rotation_mode = "QUATERNION" | |
# Deformボーン以外を削除する | |
bpy.ops.object.mode_set(mode='EDIT') | |
for key in tgt_armature.data.bones.keys(): | |
if not tgt_armature.data.edit_bones[key].use_deform \ | |
and not DONT_BAKE_KEYWORD in tgt_armature.data.bones[key].name: # ベイク対象外オブジェクトは除く | |
tgt_armature.data.edit_bones.remove(tgt_armature.data.edit_bones[key]) | |
bpy.ops.object.mode_set(mode='OBJECT') | |
# オリジナルArmatureのモードおよびNLAトラックのミュート状態を復元する | |
bpy.context.view_layer.objects.active = tgt_armature | |
bpy.ops.object.mode_set(mode=old_mode) | |
for i,track in enumerate(tgt_armature.animation_data.nla_tracks): | |
track.mute = old_tracks_mute[i] | |
bpy.context.view_layer.update() | |
return True | |
# FBX出力する処理 | |
def exportFBX(file_name): | |
mdl_filepath = bpy.data.filepath | |
mdl_directory = os.path.dirname( mdl_filepath ) | |
fbx_filepath = os.path.join( mdl_directory, file_name ) | |
bpy.ops.export_scene.fbx( | |
filepath=fbx_filepath, | |
check_existing=False, | |
axis_up='Y', | |
axis_forward='-Z', | |
filter_glob="*.fbx", | |
use_selection=False, | |
use_active_collection=True, | |
global_scale=1.0, | |
bake_space_transform=True, | |
object_types={'MESH', 'ARMATURE'}, | |
use_mesh_modifiers=True, | |
mesh_smooth_type='OFF', | |
use_mesh_edges=False, | |
use_tspace=False, | |
use_custom_props=False, | |
add_leaf_bones=True, | |
primary_bone_axis='Y', | |
secondary_bone_axis='X', | |
use_armature_deform_only=True, | |
bake_anim=True, | |
bake_anim_use_all_bones=True, | |
bake_anim_use_nla_strips=True, | |
bake_anim_use_all_actions=False, | |
bake_anim_step=1.0, | |
bake_anim_simplify_factor=1.0, | |
path_mode='AUTO', | |
embed_textures=False, | |
batch_mode='OFF', | |
use_metadata=True | |
) | |
# 出力処理本体 | |
def export( operator, context, file_name ): | |
print("[ExportFBX] Begin") | |
if not bakeAnim(operator): return False | |
exportFBX(file_name) | |
print("[ExportFBX] Complete") | |
# 色々ぶっ壊れるのでリバートする | |
bpy.ops.wm.revert_mainfile() | |
print("[ExportFBX] Reverted") | |
return True | |
#------------------------------------------------------- | |
# メニュー項目オペレータ | |
class ExpBakedFBXOpe(bpy.types.Operator, ExportHelper): | |
bl_idname = "export_scene.baked_fbx" | |
bl_label = "Export Baked Anim FBX" | |
bl_options = {'UNDO', 'PRESET'} | |
filename_ext = ".fbx" | |
filter_glob: StringProperty(default="*.fbx", options={'HIDDEN'}) | |
filepath = StringProperty(subtype="FILE_PATH") | |
filename = StringProperty() | |
directory = StringProperty(subtype="FILE_PATH") | |
def execute(self, context): | |
if bpy.data.is_dirty : return {'CANCELLED'} | |
if export( self, context, self.filepath ): return {'FINISHED'} | |
""" | |
self.report( | |
{'INFO'}, | |
"[FilePath] %s, [FileName] %s, [Directory] %s" | |
% (self.filepath, self.filename, self.directory) | |
) | |
""" | |
return {'CANCELLED'} | |
def invoke(self, context, event): | |
if bpy.data.is_dirty : | |
self.report({'WARNING'},'Please save before export') | |
return {'CANCELLED'} | |
context.window_manager.fileselect_add(self) | |
return {'RUNNING_MODAL'} | |
# メニューを登録する関数 | |
def menu_func(self, context): | |
self.layout.operator( ExpBakedFBXOpe.bl_idname, text="Baked FBX (.fbx)" ) | |
classes = ( | |
ExpBakedFBXOpe, | |
) | |
# プラグインをインストールしたときの処理 | |
def register(): | |
for cls in classes: | |
bpy.utils.register_class(cls) | |
bpy.types.TOPBAR_MT_file_export.append(menu_func) | |
# プラグインをアンインストールしたときの処理 | |
def unregister(): | |
bpy.types.TOPBAR_MT_file_export.remove(menu_func) | |
for cls in classes: | |
bpy.utils.unregister_class(cls) | |
if __name__ == "__main__": | |
register() | |
#------------------------------------------------------- |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment