【エージェント作成誌1】Unity+Live2Dでエージェントとじゃれあう

作成したインタラクションシステム (ver1.0)

システム実装環境

  • 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.9f1
  • Live2D Cubism 3 SDK for Unity:ver 5-r.
  • Live2D Cubism Editor:ver 5.1.00
  • VoiceVox:ver 0.20.00

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

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

2024/01/08 1月5日更新データでのテクスチャの不具合を修正しました。お手数をおかけいたしますが、再ダウンロード…

□音声:VoiceVox – 中国うさぎ

無料で使える中品質なテキスト読み上げ・歌声合成ソフトウェア。商用・非商用問わず無料で、誰でも簡単にお使いいただけます。イ…

0.はじめに

まずはこちらを試してみて,Live2Dモデルが動くかを確認してください

Qiita

はじめにこの記事はVTuber Tech #1 Advent Calendar 2018の1日目の記事です。こんにちは、…

なお,リップシンクについてはマイクから取得した音声ではなくUnityの音声を用いるため
公式のチュートリアルを参考に実装してください

1.ボタンを押したら音声が流れるようにする

上記のチュートリアルだと,シーンを実行した際に音声が一回流れるだけなので
ボタンを用意して,ボタンが押されたときに指定した音声が流れるような仕組みに変更します

☆完成形

Buttonの設置

Hierarchyタブで右クリックをして[UI]→[Button – TextMeshPro]をクリックしてください

初めて設置する際は次のような画面が出てくると思いますので
[Import TMP Essentials]をクリックして必要なリソースをインストールしてください
(もしTMPをインストールしたくない場合は,[UI]→[Legacy]→[Button]で従来のボタンを設置できます)

そうすると,Hierarchyタブに[Canvas]が追加され,その子要素として[Button]が追加されますので
Canvasの設定を次のように設定し,Buttonを適切な位置に移動します

CanvasのInspectorタブにある[Render Mode]を[Overlay]から[Camera]に変更

Hierarchyタブの[Main Camera]をCanvasのInspecterにある[Render Camera]にドラッグ&ドロップ

Canvasの大きさが設定したCameraにフィットするので
ButtonのInspectorタブにある[Rect Transform]の値を変更し,適切な位置・大きさにする

そうするとScene画面が次のようになると思います(ボタンの位置や大きさは各自で調整してください)

C#スクリプトの作成

次に.ボタンが押された指定した音声が流れる処理を作成します
Projectタブの上で右クリックをして[Create]→[C# Script]を作成

作成されたC# Scriptに適当な名前(今回は「PlayAudio」)をつけて中身を以下のように書き換える

□PlayAudio.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class PlayAudio : MonoBehaviour
{
    public AudioSource audioSource; // 音声を再生するGameObjectを指定
    public AudioClip audioClip; //再生する音声を指定
    public Button button; //ボタンを指定

    void Start()
    {
        //指定したボタンが押されたときに,Onclickという処理を実行する
        button.onClick.AddListener(OnClick);
    }

    public void OnClick()
    {
        //指定した音声を取得して再生する
        audioSource.clip = audioClip;
        audioSource.Play();
    }
}

書き換えたら,Hierarchタブに新しい[Game Object]を作成し
その[Game Object]のInspecterに,先ほど作成したC# Scriptをドラッグ&ドロップ

Inspector内に[Audio Play (Script)]というのが追加されているので次の3つを指定する
・[AudioSorce]:[AudioSoruce]をアタッチした[GameObject]
・[AudioClip]:適当な音声
・[Button]:作成したボタン

これで,指定したボタンを押した際に指定した音声が流れるようになるはずです
(シーンを再生した瞬間に流れる場合,AudioSorceに音声が指定されていないか確認してください)

2.ボタンが押された時にアニメーションを再生

この手のアプリケーションでお決まりの処理である
「エージェントの頭や胴体を触ったときに何らかの反応を返す」処理を作成します

☆完成形

この動画ではボタンを可視化していますが,これを透明にしてエージェントの手前に置けば
記事冒頭で紹介した動画のようなふるまいを生み出すことができます

透明なボタンの作り方や手間側にボタンを持ってくる方法は下記のサイトをご確認ください

LIGHT11

UnityのuGUIの描画順ルールは意外と複雑なので、描画順の制御方法をまとめます。…

音声に合わせたアニメーションを作成

今回は音声に合わせてアニメーションを再生したいので,まずはベースとなる音声を作成します

VoiceVoxから適当な音声を作成して[ファイル]→[音声をつなげて書き出し]を選択
これで今回は「にゃっ…あの…頭をつつかないでください…」という音声を作成しました

次にこれに合わせたアニメーションを作成していきます
基本的な手順については以下の公式チュートリアルを参考にしてください
なお,[シーンの大きさ]や[モデルの大きさ]等は特に重要ではありませんので適当に設定してください

一通り準備が出来たら,作成した音声をLive2D Editorのタイムラインに追加し
その音声にあったアニメーションを作成していきます(コマ打ちは気合で頑張ってください)

今回は次のようなアニメーションを作成しました(かわいい)

モデルによっては物理演算が設定されているパーツ(このモデルの場合「髪」と「しっぽ」)があり
そこは胴体の動きなどから勝手に動いてくれるので、無理にコマ打ちをする必要性はありません
(ただし、アニメーションの画面では物理演算は反映されないので注意です)

Jsonで書き出しUnityに取り込む

Unityでアニメーションを再生するために必要なデータは
[どのパラメタ]が[どのフレーム]で[どの位置にあるか]というJsonファイルなのでそれを出力します
下記の公式チュートリアルを参考にJsonファイルの書き出しを行ってください

Jsonファイルが出力されたら下記のUnityのProjectにドラッグ&ドロップすることで
ドラッグ&ドロップした箇所に[xxx.json]に加えて[xxx.fade]と[xxx(Animation Clip)]が作成されます

とりあえずアニメーションを再生したい場合は,下記の公式チュートリアルを参考に
作成されたAnimationClipをLive2Dmodelの[Animeter]に設定すると再生することができます

ただ,今回は「ボタンが押されたときにアニメーションを再生」するので,別の方法で実装を行います

C#スクリプトの作成

「ボタンが押されたときにアニメーションを再生」する処理の仕方は
公式がすでにチュートリアルを用意してくれていますので,こちらをベースに作成していきます

追加:アニメーションの0F目のパラメタを取得して現在パラメタから自然に遷移させる

「ニュートラル」→「アニメーション再生」がスムーズに移行できるように
Modelにある[現在の各パーツのparameter]を取得して
再生するアニメーションの0F目の[parameter]へと連続的に遷移させる処理を追加します
なお,パラメタの変更は公式チュートリアルに記されているように,LateUpdate()内で行います

□L2DJson.cs

L2DからエクスポートしたJsonファイルから0F目のパラメタ値を取得する処理を追加するための
事前準備として「そのJsonファイルはどんな形をしているのか」というのを定義しておきます

using System.Collections.Generic;

[System.Serializable]
public class CurvesData
{
    public string Target;
    public string Id;
    public List<float> Segments;
}

[System.Serializable]
public class MetaData
{
    public float Duration;
    public float Fps;
    public bool Loop;
    public bool AreBeziersRestricted;
    public int CurveCount;
    public int TotalSegmentCount;
    public int TotalPointCount;
    public int UserDataCount;
    public int TotalUserDataSize;
}

[System.Serializable]
public class Root
{
    public int Version;
    public MetaData Meta;
    public List<CurvesData> Curves;
}
□TouchToPlay.cs

処理としては次のようなことをやっています

  1. Jsonファイルからアニメーション開始前の[ParameterID]と[ParameterValue]を取得
  2. ボタンがクリックされた際,そのタイミングでの[ParameterID]と[ParameterValue]を取得し
    Jsonファイルから取得した[アニメーション開始前のValue]との差を計算して配列に保存
  3. LateUpdata()内でその差を基にモデルのParameterを毎フレーム修正
  4. 全てのParemeterの差が閾値を下回ったらアニメーションを再生
using UnityEngine;
using UnityEngine.UI;
using Live2D.Cubism.Core;
using Live2D.Cubism.Framework;
using Live2D.Cubism.Framework.Motion;
using System.Collections;
using System.Collections.Generic;
using System.Linq;


public class ToutchToPlay : MonoBehaviour
{
    //必要なデータだけ保存するための型を準備
    [System.Serializable]
    public class ParameterData
    {
        public string id;
        public float value;
    }
    [System.Serializable]
    public class ParameterDataList
    {
        public List<ParameterData> parameters;
    }

    public AnimationClip animationClip; 
    public Button button;
    public TextAsset animationJson;
    public float threshold = 0.01f; // 差分が小さくなるとみなす閾値
    public float transitionSpeed = 1.0f; // パラメータ遷移の速度(調整可能)

    private CubismModel _model; 
    private CubismMotionController _motionController; 
    public List<ParameterData> DefaultParameters { get; private set; } = new List<ParameterData>(); // JSONから読み込んだデフォルトパラメータ
    private Dictionary<string, float> _targetValues = new Dictionary<string, float>(); // 各パラメータの目標値
    private Dictionary<string, float> _currentValues = new Dictionary<string, float>(); // 現在のパラメータ値 private bool _click = false;
    private bool _click = false;

    void Start()
    {
        _model = GetComponent<CubismModel>();
        _motionController = GetComponent<CubismMotionController>();

        if (_model == null)
        {
            Debug.LogError("CubismModelが設定されていません");
            return;
        }
        if (animationJson != null)
        {
            LoadDefaultParameterValues(animationJson.text);
        }
        else
        {
            Debug.LogError("デフォルトパラメータJSONファイルが指定されていません");
        }
        button.onClick.AddListener(OnClick);
    }

    public void OnClick()
    {
        _click = true;
        CalculateTargetValues();
    }

    private void LoadDefaultParameterValues(string json)
    {
        ///////////////////////////////////////////////////
        //ここで用いているRootクラスなどの定義は,他スクリプトで行っています
        ///////////////////////////////////////////////////
        
        // JSON 文字列を Root クラスにデシリアライズ
        Root root = JsonUtility.FromJson<Root>(json);
        if (root == null || root.Curves == null)
        {
            Debug.LogError("JSONデータのデシリアライズに失敗しました");
            return;
        }
        // DefaultParameters をクリア
        DefaultParameters.Clear();
        // Root クラスの Curves から ID と初めの値を抽出
        foreach (var curve in root.Curves)
        {
            if (curve.Segments != null && curve.Segments.Count > 0)
            {
                float initialValue = curve.Segments[0];
                
                // ParameterData を作成して DefaultParameters に追加
                ParameterData parameterData = new ParameterData
                {
                    id = curve.Id,
                    value = initialValue
                };
                DefaultParameters.Add(parameterData);
            }
        }
    }

    private void CalculateTargetValues()
    {
        if (_model == null)
        {
            Debug.LogError("CubismModelが設定されていません");
            return;
        }

        _targetValues.Clear();
        _currentValues.Clear();

        foreach (var parameter in _model.Parameters)
        {
            _currentValues[parameter.Id] = parameter.Value;
        }

        foreach (var defaultParam in DefaultParameters)
        {
            var parameter = _model.Parameters.FirstOrDefault(p => p.Id == defaultParam.id);
            if (parameter != null)
            {
                _targetValues[defaultParam.id] = defaultParam.value;
            }
        }
    }

    private void LateUpdate()
    {
        if (_click)
        {
            bool allParametersSmall = true;
            if (_model == null)
            {
                Debug.LogError("CubismModelが設定されていません");
                return;
            }
            foreach (var (key, targetValue) in _targetValues)
            {
                var parameter = _model.Parameters.FirstOrDefault(p => p.Id == key);
                if (parameter != null)
                {
                    // 現在の値をスムーズに遷移させる
                    float currentValue = _currentValues.ContainsKey(key) ? _currentValues[key] : parameter.Value;
                    float newValue = Mathf.MoveTowards(currentValue, targetValue, transitionSpeed*10 * Time.deltaTime);
                    // パラメータの値を設定
                    parameter.Value = Mathf.Clamp(newValue, parameter.MinimumValue, parameter.MaximumValue);
                    // 現在値を更新
                    _currentValues[key] = parameter.Value;
                    // 目標値と現在値の差分が閾値以下であれば、すべてのパラメータが小さくなったと見なす
                    if (Mathf.Abs(targetValue - parameter.Value) >= threshold)
                    {
                        allParametersSmall = false;
                    }
                }
            }

            if (allParametersSmall)
            {
                _click = false;
                StartCoroutine(PlayAnimation(animationClip));
            }
        }
    }

    private IEnumerator PlayAnimation(AnimationClip animation)
    {
        if (_motionController == null || animation == null)
        {
            yield break;
        }
        // アニメーションの再生
        _motionController.PlayAnimation(animation, isLoop: false);
    }
}

ただ,このままだとマウスの位置を追いかける[GazeController]と処理が競合してしまうので
この処理が動いている間は[GazeController]を停止する内容をプログラムに追加します
また,このままだとアニメーションしか再生されないので
アニメーションが再生されるタイミングで音声が流れるような処理を追加します

//処理を追加する部分のみ抜粋して記載
//全てまとめたやつは,冒頭の「スクリプトまとめ」に添付しています

・・・・・・・・・・・
public AudioSource audioSource; // 音声を再生するGameObjectを指定
public AudioClip audioClip; //再生する音声を指定
private GazeController _gazeController; // GazeControllerの参照
・・・・・・・・・・・

    void Start()
    {
    ・・・・・・・・・
        _gazeController = GetComponent<GazeController>();
    ・・・・・・・・・
   }

    public void OnClick()
    {
        _click = true;
        if (_gazeController != null)
        {
            _gazeController.enabled = false;
        }

        CalculateTargetValues();
    }

・・・・・・・・・・・・・

    private IEnumerator PlayAnimation(AnimationClip animation)
    {

        // アニメーションの再生
    ・・・・・・・・・・
        audioSource.clip = audioClip;
        audioSource.Play();

        // アニメーションの再生が終了するまで待機
        float animationDuration = animation.length;
        yield return new WaitForSeconds(animationDuration);

        if (_gazeController != null)
        {
            _gazeController.enabled = true;
            _gazeController.Initialize();
        }
    }
}

処理の一番後ろ部分で[GazeController]の「Initialize()」という関数を呼び出して実行していますが
現状そのような関数は存在しないので,[GazeController]に初期化用の関数を作成します

using UnityEngine;
using Live2D.Cubism.Core;
using Live2D.Cubism.Framework;
/// <summary>
/// 目線の追従を行うクラス
/// </summary>
public class GazeController : MonoBehaviour
{
    [SerializeField]
    Transform Anchor = null;
    Vector3 centerOnScreen;
    void Start()
    {
        centerOnScreen = Camera.main.WorldToScreenPoint(Anchor.position);
    }

    ///////////////////////////////////////
    ///追加部分
    ///////////////////////////////////////
    public void Initialize()
    {
        currentRotateion = Vector3.zero;
        eulerVelocity = Vector3.zero;
    }

    void LateUpdate()
    {
        var mousePos = Input.mousePosition - centerOnScreen;
        UpdateRotate(new Vector3(mousePos.x, mousePos.y, 0) * 0.2f);
    }

    Vector3 currentRotateion = Vector3.zero;
    Vector3 eulerVelocity = Vector3.zero;

    [SerializeField]
    CubismParameter HeadAngleX = null, HeadAngleY = null, HeadAngleZ = null;
    [SerializeField]
    CubismParameter EyeBallX = null, EyeBallY = null;
    [SerializeField]
    float EaseTime = 0.2f;
    [SerializeField]
    float EyeBallXRate = 0.05f;
    [SerializeField]
    float EyeBallYRate = 0.02f;
    [SerializeField]
    bool ReversedGazing = false;

    void UpdateRotate(Vector3 targetEulerAngle)
    {
        currentRotateion = Vector3.SmoothDamp(currentRotateion, targetEulerAngle, ref eulerVelocity, EaseTime);
        // 頭の角度
        SetParameter(HeadAngleX, currentRotateion.x);
        SetParameter(HeadAngleY, currentRotateion.y);
        SetParameter(HeadAngleZ, currentRotateion.z);
        // 眼球の向き
        SetParameter(EyeBallX, currentRotateion.x * EyeBallXRate * (ReversedGazing ? -1 : 1));
        SetParameter(EyeBallY, currentRotateion.y * EyeBallYRate * (ReversedGazing ? -1 : 1));
    }

    void SetParameter(CubismParameter parameter, float value)
    {
        if (parameter != null)
        {
            parameter.BlendToValue(CubismParameterBlendMode.Additive,value);
            parameter.Value = Mathf.Clamp(value, parameter.MinimumValue, parameter.MaximumValue);
        }
    }

これで完成です
このスクリプトをアニメーションを再生する[Cubism Model]にアタッチすると
Inspectorに次のような画面が出てきますので
①再生したいアニメーション,②開始Fが記載されているjsonファイル
③アニメーションをするためのボタン,④音声を再生するAudioSource,⑤再生する音声
⑥アニメーションを再生するための閾値,⑦開始パラメタへの遷移速度を設定してください


最後に[Cubism Eye Blink Controller]の[Blend Mode]を「Multiply」に変更します(競合を防ぐため)
また,[Cubism Fade Controller]にパラメタがNoneの場合は,
Modelがあるディレクトリ(今回は「…/ziraitikuwa」)の[(モデル名).fadeMotionList]を指定してください

それらを指定したうえでシーンを実行すると,ボタンを押さない間はマウスを追いかけて
押した瞬間に正面へとスームズに遷移した後アニメーションを再生するような動きになるはずです

3.特定の状況で自動的にアニメーションを再生

BOOTHを覗いていたら次のようなアクセサリがあって使いたいと思ったので実装
音声が再生されている間だけ,話しているようなアニメーションが再生されるようにします

リップシンク機能を使用した、声を出しているときにエフェクトが表示される、VTubeStudio向けのアイテム用モデルです…

☆完成形

アニメーションの作成+Unityへの読み込み

先ほどと同じ手順なので割愛します
作ったアニメーションは次のような感じです(ガイドがあるのすっごい助かる…)

これをUnityに取り込んでHierarchyに登録するのですが,管理をしやすくするために
「CubismModelに関するもの」「ボタンなどのUI」を別々のCanvasで描画します
(なんか見慣れないものがあるかもしれませんが,今回の内容と関係ないので無視してください)

この変更に伴い「Anchor」の位置や「CubismModel(今回の場合「ziraitikuwa」)」の大きさが
変わるので適切に変更し,新しく追加したCubismModelも適切な位置に配置してください
また,Canvasの設定も[Camera]から[World Space]に変更しておくとCanvas単位での調整ができます

C#スクリプトの作成

今回は,次の2点のスクリプトを実装することで,話している間だけ再生される処理を実現します

  • 音声が再生されている間だけアニメーションを実行するスクリプト
  • 音声が再生されている間だけModelを表示するスクリプト
□音声が再生されている間だけアニメーションを実行するスクリプト(VoiceToMotionControlerLoop.cs)

処理としては,Update()内で毎F「音声が再生されているか」を確認して
条件がそろえばアニメーションを再生したり停止したりするだけです

using Live2D.Cubism.Framework.Motion;
using UnityEngine;

public class VoiceToMotionControlerLoop : MonoBehaviour
{
    // CubismMotionController のインスタンスを保持するプライベート変数
    private CubismMotionController _motionController;
    // モーションが再生中かどうかを保持するフラグ
    private bool _isMotionPlaying;

    // AnimationClip を public にしてエディタから設定できるようにする
    public AnimationClip animation;
    // AudioSource コンポーネント
    public AudioSource audioSource;

    private void Start()
    {
        // Start メソッドで、CubismMotionController コンポーネントを取得
        _motionController = GetComponent<CubismMotionController>();
        
        // AudioSource が設定されていない場合のエラーチェック
        if (audioSource == null)
        {
            Debug.LogError("AudioSource not assigned.");
        }
        
        // アニメーションが設定されていない場合のエラーチェック
        if (animation == null)
        {
            Debug.LogError("AnimationClip not assigned.");
        }
    }

    private void Update()
    {
        if (_motionController == null || audioSource == null || animation == null)
        {
            return;
        }

        // 音声が再生中かどうかでアニメーションを制御
        if (audioSource.isPlaying)
        {
            // 音声が再生中でアニメーションが再生されていない場合
            if (!_isMotionPlaying)
            {
                PlayMotion();
            }
        }
        else
        {
            // 音声が停止している場合でアニメーションが再生中であれば停止
            if (_isMotionPlaying)
            {
                StopMotion();
            }
        }
    }

    private void PlayMotion()
    {
        if (_motionController != null && animation != null && _isMotionPlaying == false)
        {
            _motionController.PlayAnimation(animation, isLoop: true);
            _isMotionPlaying = true;
        }
    }

    private void StopMotion()
    {
        if (_motionController != null)
        {
            _motionController.StopAnimation(0,0);
            _isMotionPlaying = false;
        }
    }
}

このスクリプトが作成できたら,アニメーションを再生するCubismObjectにアタッチして
再生するアニメーションと音声が流れるAudio Sorceを指定してください

□音声が再生されている間だけCubismModelを表示するスクリプト(VoiceToDisplayControler.cs)

今回用いるCubismModelの「Parrameter[7]」が表示を制御するパラメタなので
そこの値を音声に応じて変化させることで,音声に応じた表示・非表示を実現します

記述するスクリプトは以下の通りです
シンプルに「音声が再生されていたら値をプラス / されていなかったらマイナス」するだけの処理です

using UnityEngine;
using Live2D.Cubism.Core;
using Live2D.Cubism.Framework;

public class VoiceToDisplayContoloer: MonoBehaviour
{

    // CubismModel コンポーネント
    private CubismModel _model;
    // 音声が流れている/途切れている間に値が増加する速度
    private float _f;
    private float _increaseSpeed = 2f;
    private float _decreaseSpeed = 2f;

    // AudioSource コンポーネント
    public AudioSource audioSource;
    // パラメーターのインデックス(エディタで設定するか、コード内で指定)
    public int parameterIndex = 7;
    // パラメーターの表示値
    public float maxValue = 1f;
    public float minValue = 0f;

    private void Start()
    {
        // CubismModelコンポーネントを取得
        _model = this.FindCubismModel();

        // CubismModel が取得できていない場合のエラーチェック
        if (_model == null)
        {
            Debug.LogError("CubismModel not found.");
        }
        // AudioSource が設定されていない場合のエラーチェック
        if (audioSource == null)
        {
            Debug.LogError("AudioSource not assigned.");
        }
    }

    private void LateUpdate()
    {
        if (_model == null || audioSource == null)
        {
            return;
        }

        // 音声が再生中かどうかでパラメーターを制御
        if (audioSource.isPlaying)
        {
            _f += _increaseSpeed * Time.deltaTime;     
            _f = Mathf.Clamp(_f, minValue, maxValue);
        }
        else
        {
            _f -= _decreaseSpeed * Time.deltaTime;     
            _f = Mathf.Clamp(_f, minValue, maxValue);
        }
        // パラメーターの値を更新
        var parameter = _model.Parameters[parameterIndex];
        parameter.Value = _f;
    }
}

このスクリプトが作成出来たら,[VoiceToMotionControlerLoop]と同様に
モーションを再生するCubismModelにアタッチして,AudioSorceとパラメタを設定してください
前述したように表示の切り替えを行うパラメタは[7],値の幅は0~1なので次のように設定します

最後に,エージェント本体のモーションは「Layer 1」で動かしているので
それと競合しないように[Cubism Motion Controller]の「Layer Count」を2に設定してください
また,[Cubism Fade Controller]が「None」の場合は「…fade Motion list」を追加しましょう

これでシーンを実行すると,音声が再生されている間だけ
アクセサリがぴょこぴょこ動きながら現れる処理ができると思います

4.課題点

アニメーションの遷移はAnimeterを使えばもっと簡単に出来そう
1つのスクリプトにいろんな処理を詰め込みすぎているのでデバックがしづらいし応用もしづらい
できるだけ単体の処理に分割してスクリプトを作成する