kikuta@dev:~$cat ./notes/blender-python-animation-authoring.md
·13 min·kikuta

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攻撃予備動作
Attackactive / 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として扱えることが大きな利点でした。