【エージェント作成誌4】Unity+Live2Dのアニメーションについて深堀りしてみる

  • 2025年10月3日
  • 2025年11月4日
  • Create

本記事で作成できるプログラム

□モデル:【カスタム】地雷系ちくわちゃん

本モデルを使用しての迷惑行為や意図的に既存キャラクターに寄せるような行為等は禁止です。 また、本モデルの使用により発生し…

□開発環境

  • OS:Windows 11 Home
  • CPU:11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz 3.00 GHz
  • GPU:Inter(R) Iris(R) Xe Graphics
  • Unity:ver 2022.3.62f3
  • Live2D Cubism Editor/Viewer 5.2.03

1.条件分岐を伴うアニメーションの実装

☆完成形

「エージェントの頭をクリックしたときのふるまい」を下図に示すような条件分岐を加えて実装しました

Editorでアニメーションを作成する

該当モデルの.cmo3をEditorで開きアニメーションを作成していきます
この際,例えば「AnimatonA→AnimationB」という遷移を想定する場合は
「AnimationA」の最後のキーと「AnimatonB」の初めのキーは同じ値になるよう調整しています

具体的に作成したモーションは以下の6つです


  • Idle(待機モーション)
  • Click(頭をかがめるモーション)
  • Tap(頭がつつかれたときのモーション)
  • NadeStart(頭なでなでに移るモーション)
  • Nade(なでなでされている最中のモーション)
  • NadeEnd(なでなでが終わった時のモーション)

アニメーションが作成出来たら,Editorのメニューバーにある[ファイル]から
[組み込み用ファイル書き出し>モーションファイル書き出し]を選択し,motion3.jsonファイルを出力
その後,UnityのProjectに出力されたJsonを配置してください
(今回は事前に「animation」というフォルダを作成して,そこにまとめて入れておくことにしました)

これで,Project内にモーションごとにAnimaton Clip -FadeData -Jsonが作成されており
かつ,これらより1つ上のディレクトリに,各モーションのFadeDataが格納されている
Motion Listが作成されていれば,モーションのインポートは完了です
もし,FadeDataやMotionListが適切に作成されていない場合は,Projectビュー上で右クリックをして
「Reflesh」や「Reimport」をしたり,再度入れなおすといった対策を行うと改善されると思います

Animation Controllerを設定する

次にインポートしたアニメーションをAnimaiton Controllerに配置していきます
Controllerはモデルをインポートした際に,Live2D用のスクリプトがプレハブなどと同じ階層に作成されています
(ない場合は,Projectビュー上で[Create>Live2D Cubism>Animator for Cubism]で作成してください)

なお,Live2D用のControllerには,BaseLayerのInspectorに「Cubism Fade State Observer」が付属しています
今回の実装では,このスクリプトの機能は全く活かされませんが,下記チュートリアルに記載されているように
モーションの作成時にフェード値を設定することで,終点-始点のキーが異なっていても自然な遷移が可能となります


では,Controllerを開いてAnimation Clipを配置して遷移の設定を行っていきます
Contollerの基本的な使い方については以下のサイトがとても参考になりますのでぜひご確認ください
(今回の内容と直結するのは「AnimatorController/ステート関連の詳細説明」という章の内容です)

えきふるゲームラボ

えきふるこんちゃ〜っす!個人でゲーム制作しています、えきふるです!みなさん、ブログを見ていて「過去の関連記事を見たいけど…

今回は以下のように各Clipを配置しました
赤丸数字が振られているのは「Trigger」が叩かれた際に遷移する設定となっており
例えば①の部分は「ClickというTriggerが叩かれたときに,IdleからClickに遷移する」ようになっています
他方で,青枠部分はアニメーションが終了したら自動的に次のアニメーションに遷移する設定となっています

具体的な設定は次の通りです(左:Idle>Click[Triggerでの制御] / 右:Nade(Start)>Nade(Loop)[自動的な遷移])
Trigger制御の場合は「Has Exit Time」のチェックを外し,Conditionに遷移の合図となるTriggerを設定します
一方で,自動的に遷移させる場合は「Has Exit Time」にチェックをいれるだけで大丈夫です

このような設定を他の箇所にも行っていくと,最終的には下のGIFのように
シーン再生時にTriggerをクリックすることで,アニメーションがスムーズに遷移するようになります

スクリプトで制御する

シーン上でエージェントの頭をクリックやドラッグした際に
自動的にTriggerが叩かれてアニメーションが変化するような仕組みを実装していきます

本章ではわかりやすいように,RawImageを頭の当たり判定に見立て
そのうえでマウスがどのような動作をしたかによって次のアニメーションをどれにするかを判断していきます
具体的には以下のような構造で,それをスクリプトで示したのが次の「RawImageTouchHandler.cs」です


  • RawImage上でマウスがクリックされた時 「Trigger:Click」を叩く
  • マウスがクリックされたままドラッグされた時 「Trigger:NadeStart」を叩く
  • マウスのクリックが解除された時
    なでなで状態になっていたら「Trigger:NadeEnd」/ そうでない場合は「Trigger:Tap」を叩く

□RawImageTouchHandler.cs

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using System.Collections;

[RequireComponent(typeof(RawImage))]
public class RawImageTouchHandler : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
{
    private bool isTouching = false;
    private bool isDragging = false;
    private Vector2 touchStartPos;

    [SerializeField] private float dragThreshold = 20f;
    [SerializeField] private Animator animator;

    void Awake()
    {
        if (animator == null)
        {
            animator = GetComponent<Animator>();
        }
    }

    public void OnPointerDown(PointerEventData eventData)
    {
        isTouching = true;
        isDragging = false;
        touchStartPos = eventData.position;

        animator.SetTrigger("Click");
    }

    public void OnDrag(PointerEventData eventData)
    {
        if (isTouching && !isDragging)
        {
            float distance = Vector2.Distance(touchStartPos, eventData.position);

            if (distance > dragThreshold)
            {
                isDragging = true;
                animator.SetTrigger("NadeStart");
            }
        }
    }

    public void OnPointerUp(PointerEventData eventData)
    {
        if (!isTouching)
            return;

        if (isDragging)
        {
            // ドラッグ判定が立っていれば「なで終了」
            animator.SetTrigger("NadeEnd");
        }
        else
        {
            // ドラッグでなければ「タップ」
            animator.SetTrigger("Tap");
        }

        isTouching = false;
        isDragging = false;
    }
}

このスクリプトは「マウスがクリックされているか」と「ドラックがあったか」をフラグとして保存しておき
OnPointerUp関数内で条件に応じて分岐を行うシンプルな構造となっています

また,少し変則的な方法として,OnPointerUpを以下のように書き換えることで
現在再生中のアニメーション情報を取得し,それを基に判断させることも可能です(意味はあまりないけど…)

    //クリックが終わった時の動作.再生中のアニメーションによって叩くトリガーを変更
    public void OnPointerUp(PointerEventData eventData)
    {
        if (!isTouching)
            return;

        // PointerUp のタイミングでまず再生中アニメーション名を取得して判定
        string currentAnim = GetCurrentAnimationName(0);
        Debug.Log($"再生中アニメーション名: '{currentAnim}'");

        if (!string.IsNullOrEmpty(currentAnim))
        {
            // 取得できた場合はその名前に基づいて分岐
            if (currentAnim.Contains("Nade"))
            {
                animator?.SetTrigger("NadeEnd");
            }
            else
            {
                animator?.SetTrigger("Tap");
            }
        }
        else
        {
            animator?.SetTrigger("Idle");
        }

        isTouching = false;
    }

    private string GetCurrentAnimationName(int layerIndex = 0)
    {
        if (animator == null) return string.Empty;

        // まずクリップ名を取得
        var clips = animator.GetCurrentAnimatorClipInfo(layerIndex);
        if (clips != null && clips.Length > 0 && clips[0].clip != null)
        {
            return clips[0].clip.name;
        } 
    }

最後に,RawImageを適当な位置に配置して,このスクリプトをアタッチ
シーン上に配置されているModelをInspectorのAnimatorのところに設定すれば
マウスの動作に応じてエージェントのふるまいが変化する処理の完成です

2.表情ファイル(exp3.json)を使った表情制御の実装

☆完成形

Viewerから表情ファイルを作成する

Live2D SDK fo Unityでは,Editorでモーションを作成して読み込んだりやパラメタを直接制御する以外にも
Viewerの方で作成できる「表情機能」や「ポーズ機能」を用いて動きを実装するワークフローが策定されています

こちらの枠組みで実装するために,まずは表情ファイルの作成から行っていきます

フォルダ内にあるモデルの.moc3データをダブルクリックしたり
Cubidm Viewerを起動して.moc3データやmodel3.jsonを読み込むことで設定画面が表示されるので
左上のメニューバーの[ファイル]から[追加>表情]と選択し,適当なファイル名と表情名を入力しOKを押します
そうすると左のサイドバーに新たに「expression/[表情名].exp3.json」が作成され
その下側の欄にパラメタを設定するような画面が現れるので,[表情名]に合うようなパラメタを設定してください

今回は,サンプルとして以下のような表情を2つ作成しました(左:FacialA / 右:FacialB)

次に,作成した表情ファイルを保存するのですが
デフォルトだと.moc3やmodel3.jsonが入っているフォルダに保存されてごちゃごちゃするので
事前に別フォルダに表情モーションを保存することをお勧めします.手順は以下の通りです


1.左上の[ファイル]から[書き出し>全ての表情モーション]を選択

2.デフォルトに保存箇所が表示されるので,適当な場所に新しくフォルダを作成

3.作成したフォルダの中に移動した後に保存

そうするとモデルデータが含まれているフォルダの構成が以下のようになるはずです


その後,モデルデータを更新すると,model3.jsonの中に表情に関する記述が追加されており
そのパスが「[作成したフォルダ] / [表情ファイル]」となっていれば,表情ファイルの作成は完了となります

Unityでモデルを読み込む

Project内にモデルデータがない場合は,作成されたモデルデータを全て選択してドラッグドロップします
その際に,モデルのprefabなどに加えてexpresiion Listというものが作成されており
elementに作成した表情が設定されていれば,適切に読み込みができています

一方で,もしProject内に既にモデルデータがあってそれを更新したい場合などは
Unityの仕様上,同名データをドラックドロップしても上書きはされませんので
データのあるフォルダをエクスプローラーなどで直接開き,上書き保存することで更新が可能です

ただし,すでにexpression Listなどが作成されていると最新の情報に更新されないことがあるので
その場合はプロジェクトビュー上から「Reimport」や「Redlesh」を実行してください


次に,モデルのprefabをHierarchyに配置しInspecterの欄にある「Cubism Expresssion Controller」に
Expressions Listがきちんと設定されているか確認します
もし,そこが「None」になっている場合は,モデルデータ内にあるExpression Listをアタッチしてください
また,[Use Legacy Blend Calculation]にチェックを入れておくと素直な挙動をして扱いやすいです


最後に,プロジェクトを実行して[Current Expression Index]の値を変えると
下のGIFのようにExpresssion Listのindexに対応した表情に表情が変化することが確認できると思います

現在のExpression Listはelement1(=indexは0)にFacialA,element2(=indexは1)にFacialBが設定されているので
値が0の場合はFacialA,1の場合はFacialB,-1の場合はデフォルトの表情を出力しています

スクリプトで制御する

Current Expression Indexの値をスクリプトを用いて動的に変更する処理として
表情に対応したトグルボタンがあり,どれが選択されているかによって表情が変わるような仕組みを作成します
まず,以下の手順で複数のToggleが縦に並ぶ + 1つのToggleしかOnにならないようなUIを作成します


  1. UIからToggleを選択してそれを複数個配置
  2. それらを[Vertical Layout Group]と[Toggle Group]をアタッチした親GameObjectで括る
  3. それぞれのToggleの設定に[Group]という欄があるので,そこに親GameObjectを設定
  4. いい感じの位置に調整したり,Toggleのラベルを変更して完成

あとは,以下のスクリプトを作成し,設定を画像に示すようにすることで完成形のような動作が作成できます

□FacialExpressionList.cs

ModelにアタッチされているCubism Expression Controllerを外側から叩くスクリプト

using UnityEngine;
using Live2D.Cubism.Framework.Expression;

public class FacialExpressionList : MonoBehaviour
{
    [Header("Cubism Expression Controller を割り当ててください")]
    [SerializeField] private CubismExpressionController expressionController;

    // ==========================
    //  基本情報取得系
    // ==========================

    // 表情が1つ以上登録されているか
    public bool HasExpressions()
    {
        if (expressionController == null) return false;
        if (expressionController.ExpressionsList == null) return false;

        var list = expressionController.ExpressionsList.CubismExpressionObjects;
        return list != null && list.Length > 0;
    }

    // 利用可能な表情数(なければ0)
    public int GetExpressionCount()
    {
        if (!HasExpressions()) return 0;
        return expressionController.ExpressionsList.CubismExpressionObjects.Length;
    }

    // Indexに対応する表情名取得(範囲外などは空文字)
    public string GetExpressionNameByIndex(int index)
    {
        if (!HasExpressions()) return string.Empty;
        var list = expressionController.ExpressionsList.CubismExpressionObjects;

        if (index < 0 || index >= list.Length) return string.Empty;
        if (list[index] == null) return string.Empty;

        return list[index].name;
    }

    // ==========================
    //  適用処理(Index / Name / Slot)
    // ==========================

    /// Index = -1 → Default(表情未適用)
    /// Index >= 0 → Cubism の表情Indexをそのまま適用

    public void ApplyByIndex(int index)
    {
        if (expressionController == null) return;

        if (index < -1)
        {
            Debug.LogWarning("[FacialExpressionList] Index は -1以上を指定してください。");
            return;
        }

        if (index >= 0)
        {
            if (!HasExpressions())
            {
                Debug.LogWarning("[FacialExpressionList] 表情が存在しないため適用できません。");
                return;
            }

            if (index >= GetExpressionCount())
            {
                Debug.LogWarning("[FacialExpressionList] Index が範囲外です。0〜" + (GetExpressionCount() - 1) + " を指定してください。");
                return;
            }
        }

        expressionController.CurrentExpressionIndex = index;

        if (index == -1)
        {
            Debug.Log("[FacialExpressionList] Default(表情未適用)を適用");
        }
        else
        {
            string name = GetExpressionNameByIndex(index);
            Debug.Log($"[FacialExpressionList] Index: {index} を適用(Name: {name})");
        }
    }

    /// slot番号=表情Index
    /// slot0 → index0、slot1 → index1、...
    /// ※ Defaultを使う場合は ApplyByIndex(-1) を呼ぶ

    public void ApplyBySlotNumber(int slotNumber)
    {
        if (slotNumber < 0)
        {
            Debug.LogWarning("[FacialExpressionList] slotNumberは0以上で指定してください。");
            return;
        }
        ApplyByIndex(slotNumber);
    }


    /// 名前指定で適用(null/empty は Default扱い)
    public void ApplyByName(string expressionName)
    {
        if (string.IsNullOrEmpty(expressionName))
        {
            ApplyByIndex(-1);
            return;
        }

        if (!HasExpressions())
        {
            Debug.LogWarning("[FacialExpressionList] 表情が存在しないため適用できません。");
            return;
        }

        var list = expressionController.ExpressionsList.CubismExpressionObjects;
        for (int i = 0; i < list.Length; i++)
        {
            if (list[i] != null && list[i].name == expressionName)
            {
                ApplyByIndex(i);
                return;
            }
        }

        Debug.LogWarning($"[FacialExpressionList] 表情 '{expressionName}' が存在しません。");
    }
}

□FacialToggleBinder.cs

上記スクリプトをToggleから叩くスクリプト

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class FacialToggleBinder : MonoBehaviour
{
    [Header("表情適用先(FacialExpressionList)")]
    [SerializeField] private FacialExpressionList expressionList;

    [Header("slot0, slot1, ... と 1:1 対応するトグル(要素0がslot0= index0)")]
    [SerializeField] private List<Toggle> slotToggles = new List<Toggle>();

    [Header("任意: Default(-1) 用トグル")]
    [SerializeField] private Toggle defaultToggle;

    [Header("任意: 相互排他にしたい場合は ToggleGroup を指定")]
    [SerializeField] private ToggleGroup toggleGroup;

    private void Awake()
    {
        if (expressionList == null)
        {
            Debug.LogError("[FacialToggleBinder] FacialExpressionList が未設定です。");
        }

        SetupListeners();

        // 初期状態として Default を適用したい場合(任意)
        if (defaultToggle != null)
        {
            defaultToggle.isOn = true;            // UI 表示も合わせる
            if (expressionList != null) expressionList.ApplyByIndex(-1);
        }
    }

    private void OnDestroy()
    {
        CleanupListeners();
    }

    /// ランタイムでトグルを差し替えたときなどに呼ぶ
    public void Rebind()
    {
        SetupListeners();
    }

    /// すべてのトグルをOFFにし、Default(-1)を適用する
    public void ResetAll()
    {
        for (int i = 0; i < slotToggles.Count; i++)
        {
            Toggle t = slotToggles[i];
            if (t != null) t.isOn = false;
        }

        if (defaultToggle != null) defaultToggle.isOn = false;

        if (expressionList != null) expressionList.ApplyByIndex(-1);
    }

    // =========================
    // 内部処理
    // =========================

    private void SetupListeners()
    {
        CleanupListeners();

        // Default トグル
        if (defaultToggle != null)
        {
            if (toggleGroup != null) defaultToggle.group = toggleGroup;

            defaultToggle.onValueChanged.AddListener((bool isOn) =>
            {
                if (isOn)
                {
                    if (expressionList != null) expressionList.ApplyByIndex(-1);
                }
                else
                {
                    ApplyDefaultIfAllOff();
                }
            });
        }

        // 各スロットトグル(要素 i が slot i = index i)
        for (int i = 0; i < slotToggles.Count; i++)
        {
            Toggle t = slotToggles[i];
            if (t == null) continue;

            if (toggleGroup != null) t.group = toggleGroup;

            int slotNumber = i; // そのまま index として使う
            t.onValueChanged.AddListener((bool isOn) =>
            {
                if (isOn)
                {
                    if (expressionList != null) expressionList.ApplyBySlotNumber(slotNumber);
                }
                else
                {
                    ApplyDefaultIfAllOff();
                }
            });
        }
    }

    /// すべてのトグル(Default 含む)が OFF なら Default(-1) を適用
    private void ApplyDefaultIfAllOff()
    {
        bool anyOn = false;

        if (defaultToggle != null && defaultToggle.isOn) anyOn = true;

        for (int i = 0; i < slotToggles.Count; i++)
        {
            Toggle t = slotToggles[i];
            if (t != null && t.isOn)
            {
                anyOn = true;
                break;
            }
        }

        if (!anyOn && expressionList != null)
        {
            expressionList.ApplyByIndex(-1);
        }
    }

    private void CleanupListeners()
    {
        for (int i = 0; i < slotToggles.Count; i++)
        {
            Toggle t = slotToggles[i];
            if (t != null) t.onValueChanged.RemoveAllListeners();
        }

        if (defaultToggle != null) defaultToggle.onValueChanged.RemoveAllListeners();
    }
}

補足:BrandTreeを使った表情制御

公式のリファレンスにおいて「expression.json」や「Pose.json」を用いる制御の他に
UnityのAnimatiorに搭載されているBlendTreeを用いた実装方法が記載されているので,その方法を試してみました

実装

既載したパラメタを用いてAngry.motion3.jsonとHappy.motion3.jsonを作成し,UnityのProjectにインポートします

次に,2章で編集したAnimaiton Controllerを開き[Layers]を選択したら[+]を押して新たなレイヤを作ります
そして,新しいレイヤ上(Expressionという名前を付けました)で右クリックをして
[Create State> From New Brend Tree]を選択することで「Brend Tree」というものが作成されます

作成されたBlend Treeをダブルクリックすると専用の画面に遷移するので
Inspectorにある[Motion]の[+]マークを押して[Add Motion Filed]を2回クリックして
アニメーションを登録する枠を作成し,ブレンドさせるアニメーションをProjectから指定します

今回はBlendの値が0に近づくほど怒りの度合いが強まり,1に近づくほど喜びの度合いが強まる設定となっており
Blendの値を書き換えたりBlendTreeにあるスライドバーを動かすことで
下のGIFのように怒りから喜びまでの顔表情がシームレスに遷移するようになります

もし,上記の設定でも表情が変化しない場合は,Layerの設定を開きWeightが1になっているかを確認してください

ただ,この方法だと単体では動いても,既載のAnimatorでの制御と組み合わせた際に競合が発生しました
理由はおそらく,同じパラメタ(e.g,ParamEyeROpen など)をいじっているからだと思われます
なので,シンプルに表情を制御するのであれば,Expression Controllerの方が使い勝手がいいと私的には思いました

3.両方の処理を組み合わせる

☆完成形

スクリプトの修正

次の処理を実装するために,RawImageTouchHandlerからFacialExpressionList.の処理を呼び出すように加筆します


  • 頭をつつかれたら怒った表情になる / 頭を撫でたら喜びの表情になる
  • タッチアクションが5秒間無い場合はデフォルト表情に戻る

■RawImageTouchHandler.cs(加筆版)

using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

[RequireComponent(typeof(RawImage))]
public class RawImageTouchHandler : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
{
    private bool isTouching = false;
    private bool isDragging = false;
    private Vector2 touchStartPos;

    [SerializeField] private float dragThreshold = 20f;
    [SerializeField] private Animator animator;

    ///////////////////////////
    //追加
    ///////////////////////////

    [SerializeField] private FacialExpressionList facialExpressionList;

    [Header("Tap / NadeEnd 時に適用する slot 番号")]
    [SerializeField] private int tapSlot = 0;
    [SerializeField] private int nadeEndSlot = 1;

    // ▼ Idle Reset用
    [Header("Idle Reset 設定")]
    [SerializeField] private float idleTimeThreshold = 5f;
    private float idleTimer = 0f;

    void Awake()
    {
        if (animator == null)
            animator = GetComponent<Animator>();
        if (facialExpressionList == null)
            facialExpressionList = GetComponent<FacialExpressionList>();
    }

    ///////////////////////////
    //秒数を数える処理を追加
    //////////////////////////
    void Update()
    {
        idleTimer += Time.deltaTime;

        if (idleTimer >= idleTimeThreshold)
        {
            ResetFacialToDefault();
        }
    }

    private void ResetFacialToDefault()
    {
        if (facialExpressionList != null)
        {
            facialExpressionList.ApplyByIndex(-1);
        }
    }
    private void NotifyTouch()
    {
        idleTimer = 0f;
    }

    public void OnPointerDown(PointerEventData eventData)
    {
        isTouching = true;
        isDragging = false;
        touchStartPos = eventData.position;

        animator.SetTrigger("Click");
        NotifyTouch();
    }

    public void OnDrag(PointerEventData eventData)
    {
        if (isTouching && !isDragging)
        {
            float distance = Vector2.Distance(touchStartPos, eventData.position);
            if (distance > dragThreshold)
            {
                isDragging = true;
                animator.SetTrigger("NadeStart");
                NotifyTouch();
            }
        }
    }

    public void OnPointerUp(PointerEventData eventData)
    {
        if (!isTouching)
            return;

        if (isDragging)
        {
            animator.SetTrigger("NadeEnd");

            if (facialExpressionList != null)
                facialExpressionList.ApplyBySlotNumber(nadeEndSlot);
        }
        else
        {
            animator.SetTrigger("Tap");

            if (facialExpressionList != null)
                facialExpressionList.ApplyBySlotNumber(tapSlot);
        }

        NotifyTouch();
        isTouching = false;
        isDragging = false;
    }
}

あとは,このスクリプトがくっついているRawImageをモデルの頭部にもってきて,不透明度を0にすれば完成です

4.課題点

BlendTreeを上手に使えていない
中間表情が出るのはとても魅力的なので,競合しないような枠組みを作成する必要がある