UNET NetworkAnimatorでアニメーションを同期させる

3Dの人型キャラクターが登場するネットワークゲームでは、キャラクターの位置が同期するのは当然として、アニメーションも同期している必要があります。

アニメーションの同期方法は色々と考えられますが、この記事では、UNETが標準で用意しているNetworkAnimatorという機能を使ってアニメーションを同期させる方法を解説します。

f:id:motoyamablog:20180105163431g:plain

NetworkAnimator概要

NetworkAnimatorは、Unity標準のアニメーション機能(Mecanim)によるアニメーションを同期させる機能です。

「自分のスクリプトからAnimatorにパラメータを渡してアニメーションを行わせる」というのが、Unityの標準的なアニメーションの流れですが、NetworkAnimatorは、そのパラメーターの同期を自動で行なってくれます

使い方 概要

  1. まずは普通にAnimatorを使って、普通にローカルでキャラクターがアニメーションするように作る
  2. 同オブジェクトにNetworkIdentityをアタッチする
  3. 同オブジェクトにNetworkAnimatorコンポーネントをアタッチする
  4. 同期対象のパラメーターを指定する

以上です。とても簡単です。ほぼ「NetworkAnimatorをアタッチするだけ」と言っても良いくらいです。

使ってみる

実際にプロジェクトを作成して試してみましょう。

準備

新規の3Dのプロジェクトを作成します。

キャラクターの3Dモデルをダウンロードし、解凍して、プロジェクトにインポートしてください。

今回、アニメーションデータは、StandardAssetsの中に入ってるものを使いたいと思います。
Unityのメニュー>Assets>Inport Package>Charactersをインポートしておいてください。

地面の作成

Planeを作成し、場所を(0, 0, 0)とします。

カメラは適当に見やすい位置・角度に調整してください。

プレイヤーオブジェクトの作成

インポートしたPacchi.fbxをシーンに配置します。

オブジェクト名を「Player」にします。

場所は(0, 0, 0)にしておいてください。 

移動処理の作成

NetworkAnimatorを使うだけであれば、移動は本当は関係無いのですが、ネットワークゲームでアニメーションの同期を行いたいときは、大抵、移動処理もセットだと思うので、移動処理を作ります。

PlayerオブジェクトにCharacterControllerをアタッチします。
設定はこんな感じにするとちょうど良いと思います。

f:id:motoyamablog:20180105172233p:plain

 

新規スクリプト「Player」を作成し、Playerオブジェクトにアタッチします。

まずは、ネットワーク関係無しに、普通にアニメーションするように作ります。

 

using UnityEngine;

public class Player : MonoBehaviour
{
    // 歩行速度(メートル/秒)
    [SerializeField] float m_WalkSpeed = 1.5f;
    
    // 各種コンポーネントの参照
    CharacterController m_CharacterController;

    void Start()
    {
        // 各種コンポーネントの参照を取得する
        m_CharacterController = GetComponent<CharacterController>();
    }
    
    void Update()
    {
        // 移動量(メートル/秒)
        Vector3 motion = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical")) * m_WalkSpeed;

        // 左シフトキーでダッシュ(2倍速)
        if (Input.GetKey(KeyCode.LeftShift))
        {
            motion *= 2f;
        }

        // CharacterControllerで移動する
        m_CharacterController.Move(motion * Time.deltaTime);

        // 移動先を向く
        transform.LookAt(transform.position + motion);
    }
}

ただ歩くだけでは、アニメーションさせた際に面白くないので、シフトキーでダッシュするようにしてみました。

 

f:id:motoyamablog:20180105165457g:plain

アニメーションさせる

次は、ネットワーク関係無しに、普通にアニメーションさせます。

Playerオブジェクトには、たぶん最初からAnimatorコンポーネントが付いていると思いますが、確認してください(付いてなければ追加してください)。

Projectビューから、AnimatorControllerを新規作成します。名前は「Player」とでもしましょう。

PlayerオブジェクトのAnimatorのController欄に、今作ったPlayer AnimatorControllerを設定します。

あと、移動量はスクリプトで制御するため、Apply Root Motionは外します。

f:id:motoyamablog:20180105171408p:plain

 

Player AnimatorControllerを開きます。

スクリプトから移動速度を受け取るため、float型のパラメーター「Speed」を追加します。
f:id:motoyamablog:20180105170408g:plain

空いてる部分を右クリックし、新規Blend Treeを追加します。
f:id:motoyamablog:20180105170653g:plain

作成したBlend Treeをダブルクリックで開きます。

次のように設定します。
f:id:motoyamablog:20180105170134p:plain

これで、移動速度が0のときはIdleアニメーション、移動速度が1.5のときはWalkアニメーション、移動速度が3のときはRunアニメーションとなります(移動速度が中途半端な値のときは、良い感じにブレンドされて再生されます)。 

次に、Playerスクリプトを改修します。

using UnityEngine;

public class Player : MonoBehaviour
{
    // 歩行速度(メートル/秒)
    [SerializeField] float m_WalkSpeed = 1.5f;
    
    // 各種コンポーネントの参照
    CharacterController m_CharacterController;
    Animator m_Animator;

    void Start()
    {
        // 各種コンポーネントの参照を取得する
        m_CharacterController = GetComponent<CharacterController>();
        m_Animator = GetComponent<Animator>();
    }
    
    void Update()
    {
        // 移動量(メートル/秒)
        Vector3 motion = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical")) * m_WalkSpeed;

        // 左シフトキーでダッシュ(2倍速)
        if (Input.GetKey(KeyCode.LeftShift))
        {
            motion *= 2f;
        }

        // CharacterControllerで移動する
        m_CharacterController.Move(motion * Time.deltaTime);

        // 移動先を向く
        transform.LookAt(transform.position + motion);

        // Animatorに「Speed」というパラメーター名で移動速度を渡す
        m_Animator.SetFloat("Speed", motion.magnitude);
    }
}

 

f:id:motoyamablog:20180105171922g:plain
良い感じにアニメーションするようになりました。

ネットワーク対応させる

いよいよNetworkAnimatorなどを使って、ネットワーク対応させていきたいと思います。

プレイヤーオブジェクトにNetworkIdentityをアタッチします。
それぞれのローカルでプレイヤーオブジェクトを制御するために、LocalPlayerAuthorityはオンにしましょう。

f:id:motoyamablog:20180105172704p:plain

続いて、NetworkTransformをアタッチします。
設定はとりあえずそのままで良いです(TransformSyncModeがSyncCharacterControllerになっていることを確認してください)
f:id:motoyamablog:20180105172812p:plain

Playerスクリプトを少し改修します。

using UnityEngine;
using UnityEngine.Networking;

public class Player : NetworkBehaviour
{
    // 歩行速度(メートル/秒)
    [SerializeField] float m_WalkSpeed = 1.5f;
    
    // 各種コンポーネントの参照
    CharacterController m_CharacterController;
    Animator m_Animator;

    void Start()
    {
        // 各種コンポーネントの参照を取得する
        m_CharacterController = GetComponent<CharacterController>();
        m_Animator = GetComponent<Animator>();
    }
    
    void Update()
    {
        // ローカルプレイヤー以外では何もしない
        if (!isLocalPlayer)
            return;

        // 移動量(メートル/秒)
        Vector3 motion = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical")) * m_WalkSpeed;

        // 左シフトキーでダッシュ(2倍速)
        if (Input.GetKey(KeyCode.LeftShift))
        {
            motion *= 2f;
        }

        // CharacterControllerで移動する
        m_CharacterController.Move(motion * Time.deltaTime);

        // 移動先を向く
        transform.LookAt(transform.position + motion);

        // Animatorに「Speed」というパラメーター名で移動速度を渡す
        m_Animator.SetFloat("Speed", motion.magnitude);
    }
}

そしていよいよ、NetworkAnimatorをアタッチします。
Animator欄には、自分(Playerオブジェクト)を登録してください。
すると、AnimatorControllerが持つパラメーターが一覧表示されます(今回はSpeedというパラメーターひとつだけ)。Speedというパラメーターを同期したいので、チェックをオンにします。
f:id:motoyamablog:20180105173043p:plain

以上でPlayerオブジェクトは完成です。
プレハブ化し、シーンからPlayerオブジェクトは削除してください。

空のオブジェクトを新規作成し、「NetworkManager」という名前にします。
NetworkManagerコンポーネントとNetworkManagerHUDコンポーネントをアタッチしてください。
NetworkManagerのPlayer Prefab欄にPlayerプレハブを登録します。

f:id:motoyamablog:20180105173513p:plain

以上で完成です。

実行ファイルを作成してゲームを2つ以上起動し、動作確認してみてください。

f:id:motoyamablog:20180105163431g:plain

NetworkAnimatorの注意点

以上のように、NetworkAnimatorはとても簡単に使うことができますが、いくつか注意点があります。

NetworkAnimatorはAnimatorのパラメーターを同期する機能なので、パラメーターを使わずにアニメーションさせるような場合は使えません(例:Animator.Play()やAnimator.CrossFade()で直接アニメーションさせる場合)。

あと、Triggerを使ったアニメーションも注意が必要です。

パンチ、キック、銃撃や被ダメージなどの瞬発的なアニメーションは数値型のパラメーターではなく、トリガーで行う場合が多いと思いますが、NetworkAnimatorはバグっていてトリガーによるアニメーションが正しく動きません(※少なくともUnity2017.3ではまだ直ってない)。

まず、トリガーの使い方ですが、トリガー以外のパラメーター(float, int, bool)と違い、自動的な同期の対象外です。例えば、「Punch」という名前のTrigger型のパラメーターがあったとして、下図の箇所をオンにしても、期待する動作はしません。

f:id:motoyamablog:20180105223106p:plain

トリガーを使いたい場合、NetworkAnimatorにSetTriggerというメソッドが用意されているので、それを使います。次のような感じです。

        // スペースキーでトリガーによるアクションを行う
        if (Input.GetKeyDown(KeyCode.Space))
        {
            m_NetworkAnimator.SetTrigger("Punch");
        }

スペースキーでPunchという名のトリガーを発火し、アニメーションをさせる例です。

ただし、これだけだと、前述の通り、バグのせいで、こんなことになってしまいます。

f:id:motoyamablog:20180105232141g:plain

ホスト側(左画面)で、アニメーションが二重に再生されてしまいます。トリガーが二回発火されてしまうのです。

NetworkAnimatorのソースを読んでみましたが、まあバグだろうなあ、という感じでした。残念ながらこのバグはずーっと放置されています。 

このバグ、回避できないこともないのですが、結構めんどくさいし、しかもこの問題を回避したとしても、NetworkAnimatorのSetTriggerって、リモートクライアントにとって、すごくラグいので、もう使わないほうが良いかなって思います。

なぜ、リモートクライアントにとってラグいのかというと、その実装方法のためです。NetworkAnimatorのSetTriggerは、呼ばれると、その情報をサーバーに送信し、それを受信したサーバーは、それを全クライアントに配信します。クライアントがサーバーから情報を受信して初めてアニメーションが実施されます。自分のローカルプレイヤーオブジェクトでさえもです。なので、ボタンを押した瞬間にアニメーションが再生されることはなく、一度ネットワーク経由でサーバーに行って、返ってきて、初めてアニメーションされるのです。だからとてもラグいです。

どうしてこんな実装方法になっているのか、理解できない・・・

とにかく、NetworkAnimatorでトリガー使い物にならないです。

NetworkAnimatorに頼らず、CommandとClientRPCなどを使って自前実装したほうが良いでしょう。どうせ、パンチとかキックとか実装するときは、CommandとClientRPC使うでしょうし、そのついでにアニメーションさせてしまえば良いでしょう。以下、サンプルコードです。

using UnityEngine;
using UnityEngine.Networking;

public class Player : NetworkBehaviour
{
    Animator m_Animator;

    void Start()
    {
        m_Animator = GetComponent<Animator>();
    }

    void Update()
    {
        // ローカルプレイヤー以外では何もしない
        if (!isLocalPlayer)
            return;

        // スペースキーでパンチ
        if (Input.GetKeyDown(KeyCode.Space))
        {
            m_Animator.SetTrigger("Punch");
            CmdPunch();
        }
    }

    [Command]
    void CmdPunch()
    {
        RpcPunch();
    }

    [ClientRpc]
    void RpcPunch()
    {
        // 他人のプレイヤーの場合のみパンチを再生する。
        // 自分のプレイヤーは既にしているはずだからしない。
        if (!isLocalPlayer)
        {
            m_Animator.SetTrigger("Punch");
        }
    }
}

これで、ラグも最小限となり、ホストで二重にトリガーが発火されることもなくなります。