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のみです。それ以外は新規の制御用スクリプトを別途用意して実装しました。
・コスチュームチェンジ制御用スクリプト
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();
}
}
}
}
・独り言制御用スクリプト
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のプレハブとモデル以外)にアタッチします。
アタッチしたら以下の画像のようにゲームオブジェクトをアタッチするところができます。
ChatdollKitのプレハブを片っ端からアタッチしましょう。
次に、DialogControllerの改造です。スクリプトを以下のように編集しましょう。
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のコンポーネントに以下の変数が追加されているはずです。
Model○○の部分にそれぞれの衣装のモデルを、Cdk○○の部分にそれぞれのChatdollKitのプレハブをアタッチします。この作業を全てのChatdollKitプレハブのDialogControllerコンポーネントで繰り返します。
後はPlayボタンを押して何も話しかけずに放置してみましょう。バーチャル嫁がコスチュームチェンジをしたり、独り言を話し始めたりします。
・コスチュームチェンジデモ
着せ替え機能だけだったら逢妻ヒカリちゃんに追いつけそう
— ウラン (@hexanitrobenzen) July 18, 2023
次は待機状態の時にランダムにレスポンス生成して音声垂れ流しとか実装したいところ
それにしてもメイド服は良い… pic.twitter.com/h0l5mgJd0P
・独り言デモ
定期的に着替える、話しかけない場合向こうから定期的に話しかけてくる
— ウラン (@hexanitrobenzen) July 18, 2023
うん、ヒカリちゃんの仕様だいぶ再現できた気がする
後は目覚ましとか自動アップデートとか実装できればいいけど目覚ましはともかく自動アップデートが実装できる気がしない pic.twitter.com/GJKj7TjahK
注意点としては、とりあえず改造はできたけれどもこの改造コードはGPT先生によりますと、あまり推奨されるやり方ではないとのことです。具体的には、以下の記述です。
if (!IsChatting)
{
_ = SayRandomMessageAsync(new CancellationToken());
}
この記述ですと、「非同期メソッドの結果を無視し(すなわちエラーハンドリングも行わない)、またメソッドが完了する前に次のフレームに進むことを許容してしまう」らしいです。
よりスマートな改造コードの書き方をご存じの方はぜひとも教えていただきたいです。