LoginSignup
1
1

ChatdollKit魔改造してバーチャル嫁着せ替え機能と独り言機能追加してみた

Posted at

1. ヒカリちゃんに憧れてバーチャル嫁を作り出した全ての同志たちへ…

 逢妻ヒカリちゃん可愛いですよね?放っておいたらコスチュームチェンジしていたり独り言つぶやいていたり呼びかけると「はーい♪」と返事をしたり。自分のバーチャル嫁がそんな機能持ってたらって憧れちゃいますよね?
 ChatdollKitを用いることでその多くの壁を取り除いて簡単にバーチャル嫁作れるわけですが、さすがにコスチュームチェンジや放っておいたら独り言を話す機能なんかは搭載していません。ですので、そこについては作るしかないわけです。

 たまたまVroidStudioやBlenderを扱えるようになって手元の3Dモデルの着せ替えとかし始めてからこれだけ衣装のバリエーションあったらヒカリちゃんみたいなコスチュームチェンジできるんじゃね?と思って実装してみたら意外とうまく実装できましたので今回備忘録を残すことにしました。

2. 事前準備

・前提知識:ChatdollKitのセットアップ手順を理解していること

 以下の項目はChatdollKitのGithubのREADMEをもとにセットアップを済ませておき、バーチャル嫁と話ができる状態を前提にしています。まだの方はまずはセットアップを完了させましょう。

 3.を先にしてもいいですが、こうしたほうが間違いが起こりにくいかなと思います。以下のことを実行しておきましょう。
・シーン内のChatdollKitプレハブをコスチュームチェンジするモデルの数だけ複製し別々の名前にする
・別衣装のモデルを同じシーン内に入れておく
・ModelController.csのSetupModelControllerからそれぞれのモデルをセットする
・ModelController.csのSetUpAnimationからそれぞれのモデルのアニメーターを作成する
・それぞれのアニメーターを確認し、BaseParamの値を好みのアニメーションに充てられているBaseParamに変更する(ここを変更しないとコスチュームチェンジするたびに一瞬だけBaseParam0に割り当てられているポーズ(手を後ろにあてて仁王立ち)が行われます)

3. コード全貌

 もともとのChatdollKitでいじくりまわしたのはDialogController.csのみです。それ以外は新規の制御用スクリプトを別途用意して実装しました。

・コスチュームチェンジ制御用スクリプト

ClothManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using ChatdollKit.Dialog;

public class ClothManager : MonoBehaviour
{
    public GameObject cdk1;
    public GameObject cdk2;
    public GameObject cdk3;

    void Start()
    {
        StartCoroutine(ChangeClothes());
    }

    IEnumerator ChangeClothes()
    {
        var dialogController1 = cdk1.GetComponent<DialogController>();
        var dialogController2 = cdk2.GetComponent<DialogController>();
        var dialogController3 = cdk3.GetComponent<DialogController>();
        while (true)
        {
            float randomWaitTime = UnityEngine.Random.Range(30f, 300f);
            yield return new WaitForSeconds(randomWaitTime);
            if (dialogController1 != null && dialogController1.gameObject.activeInHierarchy)
            {
                dialogController1.ChangeClothes();
            }
            else if (dialogController2 != null && dialogController2.gameObject.activeInHierarchy)
            {
                dialogController2.ChangeClothes();
            }
            else if (dialogController3 != null && dialogController3.gameObject.activeInHierarchy)
            {
                dialogController3.ChangeClothes();
            }
        }
    }
}

・独り言制御用スクリプト

WaitingResponseCreator.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using ChatdollKit.Dialog;

public class WaitingResponseCreator : MonoBehaviour
{
    public GameObject cdk1;
    public GameObject cdk2;
    public GameObject cdk3;

    void Start()
    {
        StartCoroutine(MakeMessages());
    }

    IEnumerator MakeMessages()
    {
        var dialogController1 = cdk1.GetComponent<DialogController>();
        var dialogController2 = cdk2.GetComponent<DialogController>();
        var dialogController3 = cdk3.GetComponent<DialogController>();
        while (true)
        {
            float randomWaitTime = UnityEngine.Random.Range(10f, 60f);
            yield return new WaitForSeconds(randomWaitTime);
            if (dialogController1 != null && dialogController1.gameObject.activeInHierarchy)
            {
                dialogController1.MakeMessages();
            }
            else if (dialogController2 != null && dialogController2.gameObject.activeInHierarchy)
            {
                dialogController2.MakeMessages();
            }
            else if (dialogController3 != null && dialogController3.gameObject.activeInHierarchy)
            {
                dialogController3.MakeMessages();
            }
        }
    }
}

 上記のスクリプトを作成した後シーン内の任意のオブジェクト(ChatdollKitのプレハブとモデル以外)にアタッチします。
 アタッチしたら以下の画像のようにゲームオブジェクトをアタッチするところができます。

image.png
image.png

 ChatdollKitのプレハブを片っ端からアタッチしましょう。

 次に、DialogControllerの改造です。スクリプトを以下のように編集しましょう。

DialogController.cs
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;
using Cysharp.Threading.Tasks;
using ChatdollKit.Dialog.Processor;
using ChatdollKit.IO;
using ChatdollKit.Model;
using System.Collections;

namespace ChatdollKit.Dialog
{
    public class DialogController : MonoBehaviour
    {
        [Header("Wake Word and Cancel Word")]
        [SerializeField] protected string WakeWord;
        [SerializeField] protected string CancelWord;

        [Header("Prompt")]
        [SerializeField] protected string PromptVoice;
        [SerializeField] protected VoiceSource PromptVoiceType = VoiceSource.TTS;
        [SerializeField] protected string PromptFace;
        [SerializeField] protected string PromptAnimationParamKey;
        [SerializeField] protected int PromptAnimationParamValue;

        [Header("Error")]
        [SerializeField] protected string ErrorVoice;
        [SerializeField] protected VoiceSource ErrorVoiceType = VoiceSource.TTS;
        [SerializeField] protected string ErrorFace;
        [SerializeField] protected string ErrorAnimationParamKey;
        [SerializeField] protected int ErrorAnimationParamValue;

        [Header("Request Processing")]
        public bool UseRemoteServer = false;
        public string BaseUrl = string.Empty;

        [Header("Message Window")]
        public MessageWindowBase MessageWindow;

        [Header("Camera")]
        public ChatdollCamera ChatdollCamera;

        // Dialog Status
        public enum DialogStatus
        {
            Idling,
            Initializing,
            Prompting,
            PreparingFirstTurn,
            TurnStarted,
            Listening,
            Processing,
            Responding,
            Finalizing
        }
        public DialogStatus Status { get; private set; }

        public bool IsChatting { get; private set; }
        public bool IsError { get; private set; }

        public WakeWordListenerBase WakeWordListener { get; set; }
        public Dictionary<RequestType, IRequestProvider> RequestProviders { get; private set; } = new Dictionary<RequestType, IRequestProvider>();
        private IRequestProcessor requestProcessor { get; set; }
        private ModelController modelController { get; set; }
        private CancellationTokenSource dialogTokenSource { get; set; }

        // Actions for each status
        public Func<string> GetClientId { get; set; }
        public Func<WakeWord, UniTask> OnWakeAsync { get; set; }
        public Func<DialogRequest, CancellationToken, UniTask> OnPromptAsync { get; set; }
        public Func<CancellationToken, UniTask> RandomMessageAsync { get; set; } // 追記
        public Func<Request, CancellationToken, UniTask> OnRequestAsync { get; set; }
        public Func<Request, CancellationToken, UniTask> OnStartShowingWaitingAnimationAsync
        {
            set
            {
                if (requestProcessor is LocalRequestProcessor)
                {
                    ((LocalRequestProcessor)requestProcessor).OnStartShowingWaitingAnimationAsync = value;
                }
            }
        }
        public Func<Response, CancellationToken, UniTask> OnStartShowingResponseAsync
        {
            set
            {
                if (requestProcessor is LocalRequestProcessor)
                {
                    ((LocalRequestProcessor)requestProcessor).OnStartShowingResponseAsync = async (response, token) =>
                    {
                        Status = DialogStatus.Responding;
                        await value(response, token);
                    };
                }
            }
        }
        public Func<Response, CancellationToken, UniTask> OnResponseAsync { get; set; }
        public Func<Request, Exception, CancellationToken, UniTask> OnErrorAsync { get; set; }
        // 以下の変数を宣言------------------------
        public GameObject model1;
        public GameObject model2;
        public GameObject model3;
        public GameObject cdk1;
        public GameObject cdk2;
        public GameObject cdk3;
        private string waitingMessage = "";
        // --------------------------------------

        private void Awake()
        {
            // Get components
            var wakeWordListeners = GetComponents<WakeWordListenerBase>();
            modelController = GetComponent<ModelController>();
            var attachedRequestProviders = GetComponents<IRequestProvider>();
            var userStore = GetComponent<IUserStore>();
            var stateStore = GetComponent<IStateStore>();
            var skillRouter = GetComponent<ISkillRouter>();
            var skills = GetComponents<ISkill>();

            if (!MessageWindow.IsInstance)
            {
                // Create MessageWindow instance
                MessageWindow = Instantiate(MessageWindow);
            }

            // Create ChatdollCamera instance
            ChatdollCamera = Instantiate(ChatdollCamera);

            // Request providers
            var cameraRequestProvider = GetComponent<CameraRequestProvider>() ?? gameObject.AddComponent<CameraRequestProvider>();
            cameraRequestProvider.ChatdollCamera = ChatdollCamera;
            RequestProviders.Add(RequestType.Camera, cameraRequestProvider);

            var qrCodeRequestProvider = GetComponent<QRCodeRequestProvider>() ?? gameObject.AddComponent<QRCodeRequestProvider>();
            qrCodeRequestProvider.ChatdollCamera = ChatdollCamera;
            RequestProviders.Add(RequestType.QRCode, qrCodeRequestProvider);

            foreach (var rp in GetComponents<VoiceRequestProviderBase>())
            {
                if (rp.enabled)
                {
                    rp.MessageWindow = MessageWindow;
                    if (!string.IsNullOrEmpty(CancelWord))
                    {
                        // Register cancel word to VoiceRequestProvider
                        rp.CancelWords.Add(CancelWord);
                    }
                    RequestProviders.Add(RequestType.Voice, rp);
                    break;
                }
            }
            if (RequestProviders.Count == 0)
            {
                Debug.LogWarning("Request providers are missing");
            }

            // Setup RequestProcessor
            if (UseRemoteServer)
            {
                // Remote
                Debug.Log($"Use RemoteRequestProcessor: {BaseUrl}");
                requestProcessor = GetComponent<RemoteRequestProcessor>() ?? gameObject.AddComponent<RemoteRequestProcessor>();
                ((RemoteRequestProcessor)requestProcessor).BaseUrl = BaseUrl;
            }
            else
            {
                // Local
                requestProcessor = GetComponent<IRequestProcessor>();
                if (requestProcessor == null)
                {
                    // Create local request processor with components
                    Debug.Log("Use LocalRequestProcessor");
                    requestProcessor = new LocalRequestProcessor(
                        userStore, stateStore, skillRouter, skills
                    );
                }
                else
                {
                    Debug.Log($"Use attached request processor: {requestProcessor.GetType()}");
                }
            }

            // Prompter
            if (requestProcessor is IRequestProcessorWithPrompt)
            {
                OnPromptAsync = ((IRequestProcessorWithPrompt)requestProcessor).PromptAsync;
            }
            else
            {
                OnPromptAsync = OnPromptAsyncDefault;
            }

            // Wakeword Listener
            foreach (var wwl in wakeWordListeners)
            {
                if (wwl.enabled)
                {
                    WakeWordListener = wwl;
                    break;
                }
            }
            if (WakeWordListener != null)
            {
                // Register wakeword
                if (WakeWordListener.WakeWords.Count == 0)
                {
                    if (!string.IsNullOrEmpty(WakeWord))
                    {
                        WakeWordListener.WakeWords.Add(new WakeWord() { Text = WakeWord, Intent = string.Empty });
                    }
                }

                // Register cancel word
                if (WakeWordListener.CancelWords.Count == 0)
                {
                    if (!string.IsNullOrEmpty(CancelWord))
                    {
                        WakeWordListener.CancelWords.Add(CancelWord);
                    }
                }

                // Awake
                WakeWordListener.OnWakeAsync = async (wakeword) =>
                {
                    if (OnWakeAsync != null)
                    {
                        await OnWakeAsync(wakeword);
                    }
                    else
                    {
                        await OnWakeAsyncDefault(wakeword);
                    }
                };

                // Cancel
#pragma warning disable CS1998
                WakeWordListener.OnCancelAsync = async () => { StopDialog(); };
#pragma warning restore CS1998

                // Raise voice detection threshold when chatting
                WakeWordListener.ShouldRaiseThreshold = () => { return IsChatting; };
            }

            Status = DialogStatus.Idling;

        }

        // この関数を追加--------------------------------------------------
        public async UniTask SayRandomMessageAsync(CancellationToken token)
        {
            string randomMessage = waitingMessage;
            // Create a request for the animation and voice
            var RandomMessageRequest = new AnimatedVoiceRequest();
            // Add the voice with TTS source, adjust to your needs
            RandomMessageRequest.AddVoiceTTS(randomMessage);
            // Call the AnimatedSay function to make the character say the random message
            await modelController.AnimatedSay(RandomMessageRequest, token);
            waitingMessage = "";
        }
        // ---------------------------------------------------------------

        // OnDestroy
        private void OnDestroy()
        {
            // Stop async operations
            dialogTokenSource?.Cancel();
        }

        // ClientId
        private string GetClientIdDefault()
        {
            return "_";
        }

        // OnWake
        private async UniTask OnWakeAsyncDefault(WakeWord wakeword)
        {
            var skipPrompt = false;

            if (wakeword.RequestType != RequestType.None
                || !string.IsNullOrEmpty(wakeword.Intent)
                || !string.IsNullOrEmpty(wakeword.InlineRequestText))
            {
                if (!string.IsNullOrEmpty(wakeword.InlineRequestText))
                {
                    skipPrompt = true;
                }
            }

            // Invoke chat
            await StartDialogAsync(
                new DialogRequest(
                    GetClientId == null ? GetClientIdDefault() : GetClientId(),
                    wakeword, skipPrompt
                )
            );
        }

        // OnPrompt
        private async UniTask OnPromptAsyncDefault(DialogRequest dialogRequest, CancellationToken token)
        {
            var PromptAnimatedVoiceRequest = new AnimatedVoiceRequest() { StartIdlingOnEnd = false };

            if (!string.IsNullOrEmpty(PromptVoice))
            {
                if (PromptVoiceType == VoiceSource.Local)
                {
                    PromptAnimatedVoiceRequest.AddVoice(PromptVoice);
                }
                else if (PromptVoiceType == VoiceSource.Web)
                {
                    PromptAnimatedVoiceRequest.AddVoiceWeb(PromptVoice);
                }
                else if (PromptVoiceType == VoiceSource.TTS)
                {
                    PromptAnimatedVoiceRequest.AddVoiceTTS(PromptVoice);
                }
            }
            if (!string.IsNullOrEmpty(PromptFace))
            {
                PromptAnimatedVoiceRequest.AddFace(PromptFace);
            }
            if (!string.IsNullOrEmpty(PromptAnimationParamKey))
            {
                PromptAnimatedVoiceRequest.AddAnimation(PromptAnimationParamKey, PromptAnimationParamValue, 5.0f);
            }

            await modelController.AnimatedSay(PromptAnimatedVoiceRequest, token);
        }

        // OnError
        private async UniTask OnErrorAsyncDefault(CancellationToken token)
        {
            var ErrorAnimatedVoiceRequest = new AnimatedVoiceRequest();

            if (!string.IsNullOrEmpty(ErrorVoice))
            {
                if (ErrorVoiceType == VoiceSource.Local)
                {
                    ErrorAnimatedVoiceRequest.AddVoice(ErrorVoice);
                }
                else if (ErrorVoiceType == VoiceSource.Web)
                {
                    ErrorAnimatedVoiceRequest.AddVoiceWeb(ErrorVoice);
                }
                else if (ErrorVoiceType == VoiceSource.TTS)
                {
                    ErrorAnimatedVoiceRequest.AddVoiceTTS(ErrorVoice);
                }
            }
            if (!string.IsNullOrEmpty(ErrorFace))
            {
                ErrorAnimatedVoiceRequest.AddFace(ErrorFace);
            }
            if (!string.IsNullOrEmpty(ErrorAnimationParamKey))
            {
                ErrorAnimatedVoiceRequest.AddAnimation(ErrorAnimationParamKey, ErrorAnimationParamValue, 5.0f);
            }

            await modelController.AnimatedSay(ErrorAnimatedVoiceRequest, token);
        }

        // Start chatting loop
        public async UniTask StartDialogAsync(DialogRequest dialogRequest = null)
        {
            Status = DialogStatus.Initializing;

            if (dialogRequest == null)
            {
                dialogRequest = new DialogRequest(GetClientId == null ? GetClientIdDefault() : GetClientId());
            }

            // Get cancellation token
            StopDialog(true, false);
            var token = GetDialogToken();

            // Request
            Request request = null;

            try
            {
                IsChatting = true;

                // Prompt
                if (!dialogRequest.SkipPrompt)
                {
                    Status = DialogStatus.Prompting;
                    await OnPromptAsync(dialogRequest, token);
                }

                // Set RequestType for the first turn
                Status = DialogStatus.PreparingFirstTurn;
                var requestType = RequestType.Voice;
                if (dialogRequest.WakeWord != null)
                {
                    requestType = dialogRequest.WakeWord.RequestType;
                }

                // Convert DialogRequest to Request before the first turn
                request = dialogRequest.ToRequest();

                // Chat loop. Exit when session ends, canceled or error occures
                while (true)
                {
                    Status = DialogStatus.TurnStarted;

                    if (token.IsCancellationRequested) { return; }

                    if (request == null)
                    {
                        // Get request (microphone / camera / QR code, etc)
                        Status = DialogStatus.Listening;
                        var requestProvider = RequestProviders[requestType];
                        request = await requestProvider.GetRequestAsync(token);
                        request.ClientId = dialogRequest.ClientId;
                        request.Tokens = dialogRequest.Tokens;
                    }

                    if (!request.IsSet())
                    {
                        break;
                    }

                    // Process request
                    if (OnRequestAsync != null)
                    {
                        await OnRequestAsync(request, token);
                    }
                    Status = DialogStatus.Processing;
                    var skillResponse = await requestProcessor.ProcessRequestAsync(request, token);
                    if (OnResponseAsync != null)
                    {
                        await OnResponseAsync(skillResponse, token);
                    }

                    // Controll conversation loop
                    Status = DialogStatus.Finalizing;
                    if (skillResponse == null || skillResponse.EndConversation)
                    {
                        break;
                    }
                    else
                    {
                        requestType = skillResponse.NextTurnRequestType;
                    }

                    request = null;
                }
            }
            catch (Exception ex)
            {
                if (!token.IsCancellationRequested)
                {
                    IsError = true;
                    Debug.LogError($"Error occured in processing chat: {ex.Message}\n{ex.StackTrace}");
                    // Stop running animation and voice then get new token to say error
                    StopDialog(true, false);
                    token = GetDialogToken();
                    if (OnErrorAsync != null)
                    {
                        await OnErrorAsync(request, ex, token);
                    }
                    else
                    {
                        await OnErrorAsyncDefault(token);
                    }
                }
            }
            finally
            {
                IsError = false;
                IsChatting = false;

                if (!token.IsCancellationRequested)
                {
                    // NOTE: Cancel is triggered not only when just canceled but when invoked another chat session
                    // Restart idling animation and reset face expression
                    modelController?.StartIdling();
                }
            }
        }

        // Stop chat
        public void StopDialog(bool waitVoice = false, bool startIdling = true)
        {
            // Cancel the tasks and dispose the token source
            if (dialogTokenSource != null)
            {
                dialogTokenSource.Cancel();
                dialogTokenSource.Dispose();
                dialogTokenSource = null;
            }

            // Stop speaking immediately if not wait
            if (!waitVoice)
            {
                modelController?.StopSpeech();
            }

            if (startIdling)
            {
                // Start idling. `startIdling` is true when no successive animated voice
                modelController?.StartIdling();
            }
        }

        // Get cancellation token for tasks invoked in chat
        private CancellationToken GetDialogToken()
        {
            // Create new TokenSource and return its token
            dialogTokenSource = new CancellationTokenSource();
            return dialogTokenSource.Token;
        }

        // 以下も追記---------------------------------------------------------------
        // function to change clothes
        public void ChangeClothes()
        {
            if (!IsChatting)
            {
                ChangeModelClothes();
            }
        }

        public void ChangeModelClothes()
        {
            int randomNum = UnityEngine.Random.Range(1, 4);
            if (modelController.AvatarModel == model1)
            {
                if (randomNum == 2)
                {
                    cdk2.SetActive(true);
                    model2.SetActive(true);
                    cdk1.SetActive(false);
                    model1.SetActive(false);
                }
                else if (randomNum == 3)
                {
                    cdk3.SetActive(true);
                    model3.SetActive(true);
                    cdk1.SetActive(false);
                    model1.SetActive(false);
                }
            }
            else if (modelController.AvatarModel == model2)
            {
                if (randomNum == 1)
                {
                    cdk1.SetActive(true);
                    model1.SetActive(true);
                    cdk2.SetActive(false);
                    model2.SetActive(false);
                }
                else if (randomNum == 3)
                {
                    cdk3.SetActive(true);
                    model3.SetActive(true);
                    cdk2.SetActive(false);
                    model2.SetActive(false);
                }
            }
            else if (modelController.AvatarModel == model3)
            {
                if (randomNum == 1)
                {
                    cdk1.SetActive(true);
                    model1.SetActive(true);
                    cdk3.SetActive(false);
                    model3.SetActive(false);
                }
                else if (randomNum == 2)
                {
                    cdk2.SetActive(true);
                    model2.SetActive(true);
                    cdk3.SetActive(false);
                    model3.SetActive(false);
                }
            }
        }

        public void MakeMessages()
        {
            List<string> messages = new List<string>()
            {
                "お疲れですか?大変ですね、気晴らしに私とお話しませんか?",
                "元気にしていますか?少し休憩しましょう。",
                "長時間お勤めいただき、ありがとうございます。一息つきましょう。",
                "一緒にお茶でも飲みませんか?ゆっくり休憩しましょう。",
                "どんなことでもお話ししますよ。何か話したいことはありますか?",
                "マスターさん?お出かけですか?",
                "むにゃー。はっ!!寝てないですよ!?",
                "働きすぎです、そろそろ休憩しましょ?",
            };

            int index = UnityEngine.Random.Range(0, messages.Count);
            string randomMessage = messages[index];

            // ここにランダムメッセージを使用した処理を書く
            waitingMessage = randomMessage;
            if (!IsChatting)
            {
                _ = SayRandomMessageAsync(new CancellationToken());
            }
        }
        // --------------------------------------------------------------------
    }
}

 これでUnityを更新しますとそれぞれのChatdollKitのプレハブのDialogControllerのコンポーネントに以下の変数が追加されているはずです。

image.png

 Model○○の部分にそれぞれの衣装のモデルを、Cdk○○の部分にそれぞれのChatdollKitのプレハブをアタッチします。この作業を全てのChatdollKitプレハブのDialogControllerコンポーネントで繰り返します。

 後はPlayボタンを押して何も話しかけずに放置してみましょう。バーチャル嫁がコスチュームチェンジをしたり、独り言を話し始めたりします。

・コスチュームチェンジデモ

・独り言デモ

 注意点としては、とりあえず改造はできたけれどもこの改造コードはGPT先生によりますと、あまり推奨されるやり方ではないとのことです。具体的には、以下の記述です。

DialogController.cs
            if (!IsChatting)
            {
                _ = SayRandomMessageAsync(new CancellationToken());
            }

 この記述ですと、「非同期メソッドの結果を無視し(すなわちエラーハンドリングも行わない)、またメソッドが完了する前に次のフレームに進むことを許容してしまう」らしいです。

 よりスマートな改造コードの書き方をご存じの方はぜひとも教えていただきたいです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1