Skip to content

Instantly share code, notes, and snippets.

@green224
Last active November 15, 2022 05:27
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save green224/ce9c257c9b55fbd38f849bf3f6863224 to your computer and use it in GitHub Desktop.
Save green224/ce9c257c9b55fbd38f849bf3f6863224 to your computer and use it in GitHub Desktop.
アニメーション付きFBXを正しく出力するためのBlenderアドオン
"""
アニメーションを全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