Blender Pythonでゲーム用アニメーションをコード生成する
Root MotionをGameplayから分離し、Rig・Pose・frame区間・secondary motion・seamをPythonで管理する方法。
Blender Pythonで3Dモデルを生成できるようになると、次に欲しくなるのがanimationのコード化です。
3D将棋では、歩・銀・金・王・桂・香・角・飛などのanimationをBlender側で生成し、Unityへ渡しています。
ただし、animationをコード化するときに最も重要だったのはkeyframe APIではありません。
Gameplayが所有する移動と、Animationが所有する見た目を分離することでした。
Root Motionを使わない
3D将棋はマス単位で移動するゲームです。
盤面上の正確なposition、occupancy、knockback、jump landingなどはGameplay側が決めます。
そのためAnimation側には次の契約があります。
CTRL_Root
-> gameplay-owned
-> animationで動かさない
CTRL_Body以下
-> visual-owned
-> animationしてよい
仕様にも明示しています。
Gameplay TransformをAnimationが所有しない。
CTRL_RootはActor原点に固定。
盤上移動はUnity GridMotorが行う。
例えば桂馬のJumpでも、Blender animation側では身体の屈み、脚の畳み、着地圧縮を表現しますが、L字移動そのものはUnity側です。
「動いて見える」と「座標が動く」を分ける
歩行motionでも、実際に1マス前進するtranslationをclipへ入れません。
Animation:
footwork
body bob
weight shift
weapon lag
Gameplay:
world position
board cell
facing
こうしておくと、同じMove clipを移動速度や盤面状況に合わせて再生できます。Root Motionが盤面logicを上書きすることもありません。
Rig contractをコードで作る
歩兵ではUnity Humanoidへ渡すbone名をPython側で固定しています。
class RigNames:
ROOT = "Root"
HIPS = "Hips"
SPINE = "Spine"
CHEST = "Chest"
NECK = "Neck"
HEAD = "Head"
UPPER_ARM_L = "UpperArm_L"
LOWER_ARM_L = "LowerArm_L"
HAND_L = "Hand_L"
UPPER_LEG_L = "UpperLeg_L"
LOWER_LEG_L = "LowerLeg_L"
FOOT_L = "Foot_L"
WEAPON_SOCKET = "WeaponSocket"
生成後にもrequired boneを検査します。
missing = [
name
for name in REQUIRED_HUMANOID_BONES
if name not in armature.data.bones
]
if missing:
raise RuntimeError(
f"Rig generation failed; missing bones: {missing}"
)
「ExportしてUnityで初めてHeadがないと分かる」より、Blender build時点で止めた方が原因が明確です。
Toy modelならrigid bone parentingも使える
3D将棋の兵士は、有機的なskin deformationを主役にしたキャラクターではありません。木製ミニチュア風の分割パーツです。
そこで一部ではmeshを一つのboneへ100% rigid parentしています。
def parent_to_bone(
obj,
armature,
bone_name,
):
world = obj.matrix_world.copy()
obj.parent = armature
obj.parent_type = "BONE"
obj.parent_bone = bone_name
obj.matrix_world = world
この方式ならweight paintが不要です。
もちろん肩や腰を滑らかに変形させるcharacterには向きません。造形の性質に合わせてrig complexityを必要以上に上げないようにしています。
Animationは「意味のstate」を先に決める
駒ごとにbone構造は違っても、Gameplayから見えるVisual Stateは揃えています。
| State | 意味 |
|---|---|
| Idle | 待機 |
| Move | 移動中のvisual |
| Anticipation | 攻撃予備動作 |
| Attack | active / impact |
| Hit | 非致死被弾 |
| Defeated | 撃破 |
| Revive | 味方化復帰 |
Gameplayは「銀にはCTRL_Elbow_Rがある」とは知りません。
Gameplay
-> Attack
Visual implementation
-> silver-specific controls and keyframes
この境界があると、馬型の桂や機械型の飛でも同じgame stateへ接続できます。
keyframeはpose helperで書く
駒固有motionでは、controlごとに毎回Blender APIを書くのではなく、rest poseからのoffsetでposeを指定しています。
def pose(
name,
frame,
offset=(0, 0, 0),
rotation=(0, 0, 0),
scale=(1, 1, 1),
):
obj = bpy.data.objects[
"CTRL_" + name
]
key(
obj,
frame,
loc=(
Vector(obj["rest_location"])
+ Vector(offset)
),
rot=tuple(
math.radians(v)
for v in rotation
),
scale=scale,
)
こうするとmotion sourceが「frame 104でbodyを何度傾けたか」というデータとして読めます。
動きの設計をframe rangeで契約する
例えば金では次のように区間を決めています。
Idle 1 - 36
Move 42 - 72
Anticipation 80 - 116
Impact 116 - 126
Recovery 126 - 150
Hit 160 - 176
Defeated 184 - 203
Revive 203 - 240
TurnLeft 250 - 264
TurnRight 270 - 284
これを単なるtimeline整理ではなく、Gameplayのtelegraph時間やrecovery時間と対応させます。
ただしDamage発生そのものをAnimation Eventへ委ねません。
Animation:
見た目を同期する
Gameplay:
damage
hit shape
commit
recovery authority
animation frameが少し修正されただけでdamage timingまで壊れるのを避けるためです。
Secondary motionは少し遅らせる
王のcape、飛のthruster、武器、頭などは、bodyと完全同期させないようにしています。
王のmotionではbody、head、capeの反応量を変えています。
pose(
"Body",
f,
off,
rot,
)
pose(
"Head",
f,
rotation=(
-rot[0] * 0.35,
0,
-rot[2] * 0.4,
),
)
pose(
"Cape",
f,
rotation=(
-rot[0] * 1.3,
0,
cape_roll,
),
)
同じタイミング・同じ角度で全部が動くと、rig全体が一つの硬いobjectに見えます。
Pythonでmotionを書く場合も、secondary controlへ時間差や振幅差を持たせる方が重量感を出しやすくなります。
Weaponの向きを途中frameでも検証する
攻撃animationはstart/end poseだけ合っていても不十分です。
途中で槍が逆向きになったり、腕を補間した結果weapon tipが敵から逸れることがあります。
歩兵用verificationではAttack02 / Attack03の途中frameで槍axisを取り、前方とのdot productを確認しています。
axis = (
spear_tip.matrix_world.translation
- spear_shaft.matrix_world.translation
).normalized()
assert (
axis.dot(
Vector((0, -1, 0))
)
> 0.80
)
「攻撃終了時のposeが正しい」ではなく、途中frameでも攻撃方向が破綻していないことを機械検証します。
Loop seamもmatrix差分で見る
IdleやRunはclip末尾から先頭へ戻ります。
最後と最初のposeがわずかにずれていると、loop時に跳ねます。
そこで全pose boneのmatrix差を比較します。
start_pose = pose_matrices(rig)
end_pose = pose_matrices(rig)
assert max_pose_delta(
start_pose,
end_pose,
) < 0.00001
Defeatedの終端とReviveの開始も同じようにseamを確認します。
animationをコード化する価値
AnimationはGUIの方が直感的な場面も多いです。
それでもコード化が有効だったのは、
- clip区間が多数ある
- 同じ契約を全駒へ適用する
- Root Motion禁止を保証したい
- weapon directionを数値検査したい
- loop seamを自動で見る
- galleryとproductionを同じsourceから作る
という条件が揃っていたからです。
重要なのは「keyframeをPythonで書くこと」そのものではありません。
animationをゲームシステムから独立した、検証可能なvisual programとして扱えることが大きな利点でした。