simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

Unity:オンラインVRMチャットサンプルの作り方2

チャット入力画面とチャットテキストの表示

f:id:simplestar_tech:20190526084537p:plain
最後まで読むとできるようになる絵

プラス、バルーンはこんな動きをする予定

こちらの記事の続きです。
simplestar-tech.hatenablog.com
今回はチャット入力画面を作ってみましょう

UI を新たに作るので Canvas 以下を次のように配置

f:id:simplestar_tech:20190526082741p:plain
チャットテキスト入力欄

UI はこんな感じの見た目にします

f:id:simplestar_tech:20190526082845p:plain
チャットテキスト入力欄の見た目

この UI と接続するロジックを以下の通り記述します。
今回はコードの目的ごとにコメントを記入したよ。気になる処理は参考にどうぞ

using System.Collections.Generic;
using Invector.vCharacterController;
using Invector.vCharacterController.vActions;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

/// <summary>
/// ゲーム内に InputField を出現させ、テキスト入力後に吹き出しGUIに配置
/// </summary>
public class TextChatGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] GameObject panelTextChat;
    [SerializeField] InputField inputFieldTextChat;
    [SerializeField] Transform panelTextChatBalloons;
    #endregion

    #region Scene Components
    [SerializeField] VRMLoader vrmLoader;
    [SerializeField] Transform triggerActions;
    [SerializeField] Transform vrmCharacters;
    #endregion

    #region Assets
    [SerializeField] GameObject chatBalloonPrefab;
    #endregion

    UnityAction<string /*userUniqueId*/, string /*chatText*/> onEndEditMessage;

    void Start()
    {
        this.vrmLoader.onLoadVRM += this.OnLoadVRM;
        this.inputFieldTextChat.onEndEdit.AddListener(this.OnEndEditChatText);
        this.onEndEditMessage += this.OnSendMessage; // @debug 横着、オンライン通知イベントに OnSendMessage を接続予定
    }

    void Update()
    {
        // panel が非表示のときにキーボードの T を押すと panel が出現
        if (Input.GetKeyUp(KeyCode.T) && !this.panelTextChat.activeSelf)
        {
            var vInput = vrmCharacters.GetComponentInChildren<vThirdPersonInput>();
            if (null != vInput)
            {
                SwitchTextChatMode(vInput, showInputFieldTextChat : true);
            }
        }

        // 重なりが自然になるよう z 座標の小さい順にレンダリングするための処理
        if (this.panelTextChatBalloons.gameObject.activeSelf)
        {
            // 吹き出し UI の z 座標の大きい順に並べ替え
            var chatBalloons = new List<Transform>();
            foreach (var chatBalloon in this.panelTextChatBalloons)
            {
                chatBalloons.Add(chatBalloon as Transform);
            }
            chatBalloons.Sort((a, b) => b.localPosition.z > a.localPosition.z ? 1 : -1);
            // 並べ替えた順序でヒエラルキーの順序を変更
            int sibIndex = 0;
            foreach (var chatBallon in chatBalloons)
            {
                chatBallon.SetSiblingIndex(sibIndex++);
            }
        }
    }

    void OnLoadVRM(GameObject vrmRoot)
    {
        if (null == vrmRoot)
        {
            return;
        }

        // VRM インスタンスを指定 Transform 配下へ移動
        vrmRoot.transform.SetParent(this.vrmCharacters);
        // Balloon の 3D 位置用の空オブジェクトを作成し VRM インスタンス配下へ移動
        var textChatUIAnchor = new GameObject("TextChatUIAnchor", new System.Type[] { typeof(ChatBalloonLocater)});
        textChatUIAnchor.transform.SetParent(vrmRoot.transform);

        // メッシュデータから身長(m)を計測
        var heightMeasure = new MeshHeightMeasure();
        var vrmHeight = heightMeasure.GetMeshHeight(vrmRoot);
        // 身長 + オフセット上に来るように配置
        var heightOffset = 0.1f;
        textChatUIAnchor.transform.localPosition = new Vector3(0, vrmHeight + heightOffset, 0);
        // BalloonLocater が制御すべき Balloon を作成 + Locater に設定
        var chatBalloonLocater = textChatUIAnchor.GetComponent<ChatBalloonLocater>();
        var chatBalloon = Instantiate(this.chatBalloonPrefab, this.panelTextChatBalloons).GetComponent<RectTransform>();
        chatBalloonLocater.balloonRectTransform = chatBalloon.GetComponent<RectTransform>();

        // プレイヤー用の Balloon 名を 0 に設定
        chatBalloon.name = "0";
        this.playerTextChatBalloonName = chatBalloon.name;
        // 空文字列を初期テキストに設定してクリア
        UpdateBalloonText("", chatBalloon);
    }

    void OnEndEditChatText(string chatText)
    {
        var vInput = vrmCharacters.GetComponentInChildren<vThirdPersonInput>();
        if (null != vInput)
        {
            SwitchTextChatMode(vInput, showInputFieldTextChat: false);
        }
        this.onEndEditMessage?.Invoke(this.playerTextChatBalloonName, chatText);
    }

    void SwitchTextChatMode(vThirdPersonInput vInput, bool showInputFieldTextChat)
    {
        panelTextChat.SetActive(showInputFieldTextChat);

        if (showInputFieldTextChat)
        {
            // テキスト入力欄にキャレットを配置
            inputFieldTextChat.ActivateInputField();
        }
        else
        {
            // 非表示後にテキストをクリア
            inputFieldTextChat.text = "";
        }
        // プレイヤー入力のロック・ロック解除
        vInput.lockInput = showInputFieldTextChat;
        foreach (var vTriggerActions in triggerActions.GetComponentsInChildren<vTriggerGenericAction>())
        {
            vTriggerActions.actionInput.useInput = !vInput.lockInput;
        }
        // カーソル再表示とカーソルロック解除(なくてもチャットできる)
        // vInput.ShowCursor(vInput.lockInput);
        // vInput.LockCursor(vInput.lockInput);
    }

    void OnSendMessage(string userUniqueId, string chatText)
    {
        var chatBalloon = this.panelTextChatBalloons.Find($"{userUniqueId}")?.GetComponent<RectTransform>();
        UpdateBalloonText(chatText, chatBalloon);
    }

    static void UpdateBalloonText(string chatText, RectTransform chatBalloon)
    {
        if (null == chatBalloon)
        {
            return;
        }
        // 空文字列の時だけ非表示
        chatBalloon.GetComponent<Image>().enabled = 0 != chatText.Length;
        // 入力テキストが収まるよう表示幅を変更
        var chatBallonText = chatBalloon.GetComponentInChildren<Text>();
        chatBallonText.text = chatText;
        float margin = 20;
        chatBalloon.sizeDelta = new Vector2(chatBallonText.preferredWidth + margin, chatBalloon.sizeDelta.y);
    }

    string playerTextChatBalloonName = "";
}

インスペクタービューでは以下の通り UI と接続します

f:id:simplestar_tech:20190526083136p:plain
TextChatGUIのインスペクタービュー

prefab はなんの変哲もない Text です。

f:id:simplestar_tech:20190526083356p:plain
ChatBalloonPrefab
強いて言うならアンカーが左下という点
f:id:simplestar_tech:20190526083457p:plain
Anchorタイプ

Balloon の配置は以下のクラスに担当させています。(ロジックの作成の様子は過去記事で既出)

using UnityEngine;

/// <summary>
/// キャラの頭の上の吹き出し位置を毎フレーム更新
/// </summary>
public class ChatBalloonLocater : MonoBehaviour
{
    #region UI Connection
    internal RectTransform balloonRectTransform;
    #endregion

    #region Scene Components
    new Camera camera;
    #endregion

    void Start()
    {
        this.camera = Camera.main;
    }

    void Update()
    {
        if (null == this.balloonRectTransform || !this.balloonRectTransform.gameObject.activeSelf)
        {
            return;
        }

        // 遠くなるほど小さく
        var distance = Vector3.Distance(this.transform.position, this.camera.transform.position);
        this.balloonRectTransform.localScale = Vector3.one * Mathf.Clamp01(3 / distance);

        // スクリーン座標が画面外に出る時は、画面内に納まるようにクランプ
        Vector3 screenPos = this.camera.WorldToScreenPoint(this.transform.position);
        var scaleOffset = 5.0f;
        var marginX = this.balloonRectTransform.rect.width / 2 * this.balloonRectTransform.localScale.x + scaleOffset;
        var marginY = this.balloonRectTransform.rect.height / 2 * this.balloonRectTransform.localScale.y + scaleOffset;
        var x = Mathf.Clamp(screenPos.x, marginX, Screen.width - marginX);
        var y = Mathf.Clamp(screenPos.y, marginY, Screen.height - marginY);
        var z = screenPos.z;
        this.balloonRectTransform.rotation = Quaternion.identity;

        // 3Dアンカーのカメラの前後判定
        var dot = Vector3.Dot(this.camera.transform.forward, this.transform.position - this.camera.transform.position);
        var flag = Mathf.Sign(dot);

        // 3Dアンカーが背後にあるなら左右反転
        if (0 > flag)
        {
            x = Screen.width - x;
            y = Screen.height - y;
            this.balloonRectTransform.rotation = Quaternion.Euler(0, 180, 0);
        }
        // ここまでの計算を RectTransform に反映
        this.balloonRectTransform.position = new Vector3(x, y, z);
    }
}

コレを動かすと次の通り

次の記事では、オンライン通信部分を作ります。
simplestar-tech.hatenablog.com

Unity:VRMの身長を測る

まえがき

VRM を動的ロードして、そんな VRM キャラクターとオンラインでコミュニケーションを取るロビーを制作中
キャラクターの頭のパーツを隠さずに、吹き出し位置を調整したいなと思ったとき

VRM の身長データの取得方法がわからなかったので調べてみて
わからなかったので、作り方を考えて、実際取得できることまで確認しました

動いたコードがこちら
全頂点座標の中から最も高い値をイベント引数で返すようにしてみました。

using UnityEngine;
using UnityEngine.Events;

public class VRMHeightMeasure : MonoBehaviour
{
    #region Scene Components
    [SerializeField] VRMLoaderGUI vrmLoaderGUI;
    #endregion

    internal UnityAction<GameObject /*vrmRoot*/, float /*isUserPlayerFlag*/> OnMeasureVRMHeight;

    void Start()
    {
        this.vrmLoaderGUI.OnLoadVRM += OnLoadVRM;
    }

    private void OnLoadVRM(GameObject vrmRoot, bool isPlayer)
    {
        float vrmHeight = 0;
        foreach (var meshRenderer in vrmRoot.GetComponentsInChildren<SkinnedMeshRenderer>())
        {
            if (null == meshRenderer.sharedMesh)
            {
                continue;
            }
            var meshHeight = meshRenderer.transform.position.y;
            foreach (var vertex in meshRenderer.sharedMesh.vertices)
            {
                if (vrmHeight < meshHeight + vertex.y)
                {
                    vrmHeight = meshHeight + vertex.y;
                }
            }
        }
        OnMeasureVRMHeight?.Invoke(vrmRoot, vrmHeight);
    }
}

Unity:スクリーンスペースのUIを奥行き順に並べ替える

まえがき

Unity の uGUI の描画順は、Hierarchy View の上下関係で決まります。
developer.aiming-inc.com

UIの順序を 3D の奥行きで毎フレーム並べ替えたい
それができないと、手前のキャラクターの吹き出しなどが、奥のキャラクターの吹き出しに隠れてしまいます。

解決できるコードはこちら

var chatBalloons = new List<Transform>();
foreach (var chatBalloon in this.panelTextChatBalloons.transform)
{
    chatBalloons.Add(chatBalloon as Transform);
}
chatBalloons.Sort((a, b) => b.localPosition.z > a.localPosition.z ? 1 : -1);
int sibIndex = 0;
foreach (var chatBallon in chatBalloons)
{
    chatBallon.SetSiblingIndex(sibIndex++);
}

Unity:オンラインVRMチャットサンプルの作り方1

VRM 選択画面と動的ロードの実装詳細

f:id:simplestar_tech:20190519192536p:plain
最後まで読むとできるようになる絵

Unity 2019.1.3f1 で新規プロジェクトを作成
ゲームを起動すると次の UI が表示されるように UI 要素をセットします。

f:id:simplestar_tech:20190519182834p:plain
ファイル選択UI

VRM を動的にロードして TPS カメラワークで TPS キャラクターとして動かしてみます。

依存するアセットは
assetstore.unity.com
assetstore.unity.com

github.com

依存するパッケージは
Cinemachine
TMPro(日本語使えるように準備したもの)

以下のロジックを組みます。

using Cinemachine;
using Invector.vCharacterController;
using Invector.vCharacterController.vActions;
using SimpleFileBrowser;
using System;
using System.IO;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using VRM;

public class VRMPlayerModelLoader : MonoBehaviour
{
    #region UI Connection
    [SerializeField] Button buttonBrowse;
    [SerializeField] Button buttonLoadVRM;
    [SerializeField] InputField inputFieldVRMPath;
    [SerializeField] TMP_Text textStatus;
    [SerializeField] GameObject panelSelectLoadVRM;
    #endregion

    #region Scene Components
    [SerializeField] Transform playerLookAtTarget;
    [SerializeField] CinemachineFreeLook cinemachineFreeLook;
    #endregion

    #region Assets
    [SerializeField] RuntimeAnimatorController playerAnimController;
    [SerializeField] Shader vrmShader;
    #endregion

    internal UnityAction<GameObject> OnLoadPlayerVRM;

    private void Start()
    {
        this.InitializeUi();
    }

    #region VRM

    public void OnButtonBrowse()
    {
        FileBrowser.SetFilters(false, new FileBrowser.Filter("VRM", ".vrm"));
        FileBrowser.SetDefaultFilter(".vrm");
        FileBrowser.SetExcludedExtensions(".lnk", ".tmp", ".zip", ".rar", ".exe");
        FileBrowser.AddQuickLink("Downloads", @"C:\Users\" + Environment.UserName + @"\Downloads");
        FileBrowser.ShowLoadDialog(path => this.inputFieldVRMPath.text = path, null, false, @"C:\Users\" + Environment.UserName + @"\Downloads");
    }

    public void OnButtonLoadVRM()
    {
        if (0 == this.inputFieldVRMPath.text.Length)
        {
            textStatus.text = "Input VRM Path.";
            return;
        }

        var vrmFilePath = this.inputFieldVRMPath.text;
        if (!File.Exists(vrmFilePath))
        {
            textStatus.text = "VRM Path File is Not Found.";
            return;
        }

        var vrmData = File.ReadAllBytes(vrmFilePath);
        var vrmRoot = LoadVRMinMemory(vrmData);

        if (null == vrmRoot)
        {
            textStatus.text = "Invalid VRM File, VRM Instantiation is Failed.";
            return;
        }
        PlayerPrefs.SetString(PlayerVRMPath, vrmFilePath);
        this.panelSelectLoadVRM.SetActive(false);
    }

    private void InitializeUi()
    {
        this.buttonBrowse.onClick.AddListener(OnButtonBrowse);
        this.buttonLoadVRM.onClick.AddListener(OnButtonLoadVRM);
        var playerVRMPath = PlayerPrefs.GetString(PlayerVRMPath);
        if (File.Exists(playerVRMPath))
        {
            this.inputFieldVRMPath.text = playerVRMPath;
        }
    }

    public GameObject LoadVRMinMemory(byte[] vrmData, bool isUserPlayerFlag = true)
    {
        var context = new VRMImporterContext();
        context.ParseGlb(vrmData);
        context.Load();
        if (null != context.Root && isUserPlayerFlag)
        {
            int layerMask = LayerMask.NameToLayer(LayerMask_Player);
            context.Root.SetLayerRecursively(layerMask);
            context.Root.SetTagRecursively(Tag_Player);
        }
        context.ShowMeshes();
        context.EnableUpdateWhenOffscreen();
        context.ShowMeshes();
        if (null != context.Root)
        {
            SetModel(context.Root, isUserPlayerFlag);
        }
        return context.Root;
    }

    void SetModel(GameObject vrmRoot, bool isUserPlayerFlag)
    {
        if (null != this.vrmShader)
        {
            foreach (var renderer in vrmRoot.GetComponentsInChildren<Renderer>())
            {
                foreach (var material in renderer.sharedMaterials)
                {
                    var tex = material.mainTexture;
                    material.shader = this.vrmShader;
                    material.mainTexture = tex;
                }
            }
        }
        var lookAt = vrmRoot.GetComponent<VRMLookAtHead>();
        if (lookAt)
        {
            vrmRoot.AddComponent<Blinker>();
            lookAt.Target = this.playerLookAtTarget;
            lookAt.UpdateType = UpdateType.LateUpdate; // after HumanPoseTransfer's setPose
        }

        if (isUserPlayerFlag)
        {
            var target = vrmRoot.transform.FindHeightRecursively(1.0f);
            cinemachineFreeLook.Follow = vrmRoot.transform;
            cinemachineFreeLook.LookAt = target;

            // Invector が動的コンポーネント追加を考慮していないので、必要と思われる公開パラメータをここで初期化
            var vInput = vrmRoot.AddComponent<vThirdPersonInput>();
            vInput.OnLateUpdate = new UnityEngine.Events.UnityEvent();
            vInput.jumpInput = new GenericInput("Space", "A", "A");
            vInput.rollInput = new GenericInput("Q", "Y", "Y");
            vInput.crouchInput = new GenericInput("C", "X", "X");

            var vController = vrmRoot.AddComponent<vThirdPersonController>();
            vController.strafeSpeed = new vThirdPersonMotor.vMovementSpeed();
            vController.freeSpeed = new vThirdPersonMotor.vMovementSpeed();
            vController.OnCrouch = new UnityEvent();
            vController.OnStandUp = new UnityEvent();
            vController.OnJump = new UnityEvent();
            vController.OnStartSprinting = new UnityEvent();
            vController.OnFinishSprinting = new UnityEvent();
            vController.OnFinishSprintingByStamina = new UnityEvent();
            vController.OnStaminaEnd = new UnityEvent();

            var vAction = vrmRoot.AddComponent<vGenericAction>();
            vAction.OnStartAction = new UnityEvent();
            vAction.OnEndAction = new UnityEvent();
        }

        var animator = vrmRoot.GetComponent<Animator>();
        if (animator && !animator.runtimeAnimatorController)
        {
            animator.runtimeAnimatorController = playerAnimController;
        }
        if (isUserPlayerFlag)
        {
            var rb = vrmRoot.AddComponent<Rigidbody>();
            rb.constraints = RigidbodyConstraints.FreezeRotation;
            rb.collisionDetectionMode = CollisionDetectionMode.Continuous;

            var coll = vrmRoot.AddComponent<CapsuleCollider>();
            coll.center = new Vector3(0, 0.8f, 0);
            coll.radius = 0.25f;
            coll.height = coll.center.y * 2;

            var animSync = vrmRoot.AddComponent<AnimationSync>();           
            OnLoadPlayerVRM?.Invoke(vrmRoot);
        }
    }
    
    #endregion

    const string PlayerName = "PlayerName";
    const string PlayerVRMPath = "PlayerVRMPath";
    const string Tag_Player = "Player";
    const string LayerMask_Player = "Player";
}

シーンに配置したら、次のように UI 要素やアセットと結びつけます。

f:id:simplestar_tech:20190519190230p:plain
VRMモデルローダースクリプトのインスペクタ

Cinemachine の Free Look の Radius の設定は次の通り

f:id:simplestar_tech:20190519190514p:plain
Free Look 半径の設定

追記:おっと拡張メソッド使っていますね。
以下のような拡張メソッドを実装しておきました。

TransformExtensions.cs

using UnityEngine;

public static class TransformExtensions
{
    public static Transform FindRecursively(
        this Transform self,
        string n
    )
    {
        var child = self.Find(n);
        if (null == child)
        {
            foreach (Transform c in self.transform)
            {
                child = FindRecursively(c, n);
                if (null != child)
                {
                    break;
                }
            }
        }
        return child;
    }

    public static Transform FindHeightRecursively(
        this Transform self,
        float height
    )
    {
        if (height < self.localPosition.y)
        {
            return self;
        }
        foreach (Transform c in self.transform)
        {
            var child = FindHeightRecursively(c, height - self.localPosition.y);
            if (null != child)
            {
                return child;
            }
        }
        return null;
    }
}

GameObjectExtensions.cs

using UnityEngine;

public static class GameObjectExtensions
{
    public static void SetLayerRecursively(
        this GameObject self,
        int layer
    )
    {
        self.layer = layer;

        foreach (Transform n in self.transform)
        {
            SetLayerRecursively(n.gameObject, layer);
        }
    }

    public static void SetTagRecursively(
        this GameObject self,
        string tag
    )
    {
        self.tag = tag;

        foreach (Transform n in self.transform)
        {
            SetTagRecursively(n.gameObject, tag);
        }
    }
}

Load VRM ボタンを押せば、次の通りマウスで TPS カメラ操作しつつ、WSAD キーで TPS キャラクター操作しつつ E キーでアクションできるようになっています。
カメラ操作は Free Look のディフォルトの入力を好みで反転させました。

次の記事ではチャット入力画面を作ってみましょう。
simplestar-tech.hatenablog.com

Unity:形態素解析してしゃべるVRMのサンプル

■前書き
文字列を受け取って、読みに変換し
あいうえおの母音に直した後、これを順に処理しながら
VRM の、ブレンドシェイプに繋げます。

小ゴールを切りながら、VRM キャラがしゃべる様子を確認できればゴールです。

■読みの抽出

公式ページ
MeCab: オリジナル辞書/コーパスからのパラメータ推定
より

ipadic の例です. 素性列として

品詞
品詞細分類1
品詞細分類2
品詞細分類3
活用型
活用形
基本形
読み
発音
が定義されています.

ということは、こうですね。

using NMeCab;
using UnityEngine;
using UnityEngine.UI;

public class NMeCabTest : MonoBehaviour
{
    public Text text;

    void Start()
    {
        string result = "";
        string sentence = "Unityで形態素解析";

        MeCabParam param = new MeCabParam();
        param.DicDir = $"{Application.streamingAssetsPath}/NMeCab/dic/ipadic";

        var t = MeCabTagger.Create(param);
        MeCabNode node = t.ParseToNode(sentence);
        while (node != null)
        {
            if (node.CharType > 0)
            {
                var splits = node.Feature.Split(',');
                var yomi = splits.Length < 8 ? node.Surface : splits[7];
                result += yomi + " ";
            }
            node = node.Next;
        }
        this.text.text = result;
    }
}

バッチリ動きました。

f:id:simplestar_tech:20190512180141p:plain
Unityで形態素解析

■あいうえお ニュートラル の 6 つの値に直す

ケイタイソ→エイアイオ
に直す変換です。

Japanese Katakana Unicode Chart
sites.psu.edu

ここによれば 12449 ~ 12539 の int の値を見れば、対応表が作れそうですね。

                var c = yomi.ToCharArray();
                int[] arr = new int[c.Length];
                for (int i = 0; i < c.Length; i++)
                {
                    arr[i] = (int)c[i];
                    result += arr[i].ToString();
                }

確かに、表と同じ値が確認できました。
マップを作る

12483 ッ
12484 ツ
12485 ヅ

のところだけ規則性から外れてしまう

12490 ナ
な行だけは、濁点がない

12495 ハ
は行は ハ、バ、パ の三文字単位で特殊

12510 ま行もまた、一つ

12516 ヤ
ユヨ はこれまでの5 文字連続から外れて、三文字

12521 ラ
ラ行もまた 一つ

12526 ワ
以降はもう、特殊文字として、一つ一つ、アイウエオ割り当ててあげましょう。

   int YomiToAIUEO(int yomi)
    {
        if (12449 <= yomi && 12539 > yomi)
        {
            if (12484 >= yomi)
            {
                return ((yomi - 12449) / 2) % 5;
            }
            else if (12485 == yomi)
            {
                return 2;
            }
            else if (12490 > yomi)
            {
                return ((yomi - 12486) / 2) % 5 + 3;
            }
            else if (12495 > yomi)
            {
                return (yomi - 12490) % 5;
            }
            else if (12510 > yomi)
            {
                return ((yomi - 12495) / 3) % 5;
            }
            else if (12515 > yomi)
            {
                return (yomi - 12490) % 5;
            }
            else if (12521 > yomi)
            {
                return ((yomi - 12515) / 2) * 2;
            }
            else if (12526 > yomi)
            {
                return (yomi - 12521) % 5;
            }
            else
            {
                switch (yomi)
                {
                    case 12526:
                    case 12527:
                        return 0;
                    case 12528:
                    case 12529:
                        return 3;
                    case 12530:
                        return 4;
                    case 12532:
                        return 2;
                    case 12533:
                    case 12534:
                    case 12535:
                        return 0;
                    case 12536:
                    case 12537:
                        return 3;
                    case 12538:
                        return 4;
                    default:
                        break;
                }
            }
        }
        return 6;
    }

    string DebugYomi(int aiueo)
    {
        switch (aiueo)
        {
            case 0:
                return "あ";
            case 1:
                return "い";
            case 2:
                return "う";
            case 3:
                return "え";
            case 4:
                return "お";
            default:
                break;
        }
        return "ん";
    }

ローマ字を処理してほしいところです。

}
        else if (65 <= yomi && 123 > yomi)
        {
            switch (yomi)
            {
                case 65:
                case 97:
                    return 0;
                case 73:
                case 105:
                    return 1;
                case 85:
                case 117:
                case 87:
                case 119:
                    return 2;
                case 69:
                case 101:
                    return 3;
                case 79:
                case 111:
                    return 4;
                case 78:
                case 110:
                    return 6;
                default:
                    return 7;
            }
        }
        return 7;

正しく動きました。

VRMの口を動かす

あいうえお の番号をキューに詰めて、毎秒取り出してきて、これに同期してブレンドシェイプを指定して動かせるか試します。

このコードでひとまず口は動かせました。(ブレンド値を滑らかにつなぐなどの調整が必要だけど、今回はそこまでやらない)

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

public class LipSyncTest : MonoBehaviour
{
    [SerializeField] GameObject vrmRoot;
    [SerializeField] Text text;
    [SerializeField] NMeCabTest nMeCabTest;
    [SerializeField] float wordTime = 0.3f;

    VRMBlendShapeProxy proxy;
    BlendShapeKey[] aiueoKey = new BlendShapeKey[5];
    Queue<int> aiueoQueue = new Queue<int>();
    float prevTime = 0;

    float fade = 0;

    // Start is called before the first frame update
    void Start()
    {
        this.proxy = vrmRoot.GetComponent<VRMBlendShapeProxy>();
        for (int aiueo = 0; aiueo < this.aiueoKey.Length; aiueo++)
        {
            this.aiueoKey[aiueo] = new BlendShapeKey
            {
                Preset = BlendShapePreset.A + aiueo
            };
        }
    }

    // Update is called once per frame
    void Update()
    {
        float time = Time.realtimeSinceStartup - this.prevTime;
        int sayWord = -1;
        if (time >= this.wordTime)
        {
            this.prevTime = Time.realtimeSinceStartup;

            if (0 < this.aiueoQueue.Count)
            {
                sayWord = this.aiueoQueue.Dequeue();
            }
        }
        
        if (Input.GetKey(KeyCode.A))
        {
            sayWord = 0;            
        }
        if (Input.GetKey(KeyCode.I))
        {
            sayWord = 1;
        }
        if (Input.GetKey(KeyCode.U))
        {
            sayWord = 2;
        }
        if (Input.GetKey(KeyCode.E))
        {
            sayWord = 3;
        }
        if (Input.GetKey(KeyCode.O))
        {
            sayWord = 4;
        }
        for (int aiueo = 0; aiueo < this.aiueoKey.Length; aiueo++)
        {
            float lastKey = this.proxy.GetValue(aiueoKey[aiueo]);
            this.proxy.AccumulateValue(aiueoKey[aiueo], sayWord == aiueo ? 1 : lastKey * 0.95f);
        }
        this.proxy.Apply();
    }

    public void OnSayClick()
    {
        var sentence = text.text;
        nMeCabTest.ReturnAiueo(sentence, ref aiueoQueue);
    }
}

できた。

Unity:NMeCabのソースコードを使って動的に漢字の読み仮名を推定する方法

■前書き
Unityで形態素解析

私たち人間の脳内では「何か」が実行され

ユニティ デ ケイタイソ カイセ

という「音」が脳内で響き渡ります。

これを、自分の手で作り上げ、コンピュータに行わせる手段を示します。

■横道
形態素解析って言葉は、難しい
ので、動的に漢字の読み仮名を推定する方法、といった少しわかりやすい表現に変えてみた。(機能は限定されちゃうけど)

■本題
MeCab京都大学のとある共同研究ユニットにて開発されたオープンソース 形態素解析エンジン
高速に動作するのが持ち味で、和布蕪(めかぶ)は, 作者の好物だからだとのこと

オリジナルの MeCabC++ で書かれているけど、Unity から呼びやすいように C# で動くようにコードを移植してくれた例が見つかりました。
ja.osdn.net

Unity への導入の参考記事はこちら
qiita.com

MacOS, AndroidでNMecabが使えずに困る→解決方法
dll を配置してサンプルを書く場合は Windows, Editor 上でしかうまくいかず
MacOS ビルド、Android Build などで機能しなくなります。

解決するには、込み入った手順が必要なので、本記事にその作業内容だけ記録します。
Android で動いたら完了である

Android 環境で動かないことを確認
dll を配置して、問題が再現することを確かめます。→確かに機能しない
例外が発生しており
PlatformNotSupportedException: Operation is not supported on this platform.

ソースコードで動くように調整
LibNMeCab.dll の代わりに
src/LibNMeCab 以下にある
Properties, app.config, LibNMeCab.csproj 以外のファイルを全コピー
unsafe コード許可を求められるので、Project Settings > Player > Allow 'unsafe' Code にチェックを入れます。

SettingChangingEventArgs が見つからないエラーが出たら、それを利用している空の関数を削除し

MeCabParam の初期値の未実装エラーについては、以下のコードを、次のコードブロックに書き換えます。

        public MeCabParam()
        {
            this.Theta = MeCabParam.DefaultTheta;
            this.RcFile = MeCabParam.DefaultRcFile;

            Properties.Settings settings = Properties.Settings.Default;
            this.DicDir = settings.DicDir;
            this.UserDic = this.SplitStringArray(settings.UserDic, ',');
            this.OutputFormatType = settings.OutputFormatType;
        }
        public MeCabParam()
        {
            this.Theta = MeCabParam.DefaultTheta;
            this.RcFile = MeCabParam.DefaultRcFile;

            this.DicDir = "";
            this.UserDic = new string[] { };
            this.OutputFormatType = "lattice";
        }

一応これでソースコードを用いて、dll と同じ処理結果が得られるようになります。

Android ビルドでまずいコードの書き換え

先ほどと同じように Android 端末上でデバッグ実行すると、次の例外により処理が止まっていることを確認できるようになります。
DirectoryNotFoundException: Could not find a part of the path "/jar:file:/data/app/com.XXXXXXX.NMeCabTest-yWcMcBBKreSyIzMxor-Saw==/base.apk!/assets/NMeCab/dic/ipadic/char.bin".

ビルドするプラットフォームごとにファイルアクセス方法が変わるので、ここは UnityWebRequest の出番です。

デバッグ中に
MeCabInvalidFileException: dictionary file is broken が発生

問題を明らかにしていくとこういうことらしい
reader.BaseStream.Length 65536 != (magic ^ DictionaryMagicID) 49205956

一回のファイルダウンロードの最大サイズに見合わない巨大なファイルを開くからこうなる
解決策は?

char.bin, unk.dic, sys.dic, matrix.bin という順番でファイルを Open していく流れでした。
これについて、それぞれの Open を呼ぶコードを以下の WebRequest コードを挟み込む UniOpen に置き換えていきます。

        public void Open(string fileName)
        {
            using (FileStream stream = new FileStream(fileName, FileMode.Open, FileAccess.Read))
            using (BinaryReader reader = new BinaryReader(stream))
            {
                this.Open(reader, fileName);
            }
        }

        public void UniOpen(string filePath)
        {
            var r = new System.Text.RegularExpressions.Regex(".*file://.*", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
            if (r.IsMatch(filePath))
            {
                UnityEngine.Networking.UnityWebRequest www = UnityEngine.Networking.UnityWebRequest.Get(filePath);
                var asyncOp = www.SendWebRequest();
                while (!asyncOp.isDone)
                {
                    System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(0.01f));
                }
                if (www.isNetworkError || www.isHttpError)
                {
                    UnityEngine.Debug.Log(www.error);
                }
                else
                {
                    UnityEngine.Debug.Log($"isDone {www.isDone} path = {filePath}");
                    byte[] byteArray = www.downloadHandler.data;
                    using (Stream stream = new MemoryStream(byteArray))
                    using (BinaryReader reader = new BinaryReader(stream))
                    {
                        this.Open(reader);
                    }
                }
            }
            else
            {
                Open(filePath);
            }
        }

具体的には CharProperty.cs, MeCabDictionary.cs, Connector.cs の三つの Open 関数の実装に、上のような UniOpen 関数を追加して
これを各種呼び出しの箇所で呼ぶようにしてあげます。

以上

次のテストコードで Android 上でも形態素解析が機能することを確認できました!

using NMeCab;
using UnityEngine;
using UnityEngine.UI;

public class NMeCabTest : MonoBehaviour
{
    public Text text;

    void Start()
    {
        string result = "";
        string sentence = "Unityで形態素解析";
        this.text.text = sentence;
        Debug.Log($"sentence = {sentence}");

        MeCabParam param = new MeCabParam();
        param.DicDir = $"{Application.streamingAssetsPath}/NMeCab/dic/ipadic";

        var t = MeCabTagger.Create(param);
        Debug.Log($"OutPutFormatType = {t.OutPutFormatType}");
        MeCabNode node = t.ParseToNode(sentence);
        while (node != null)
        {
            if (node.CharType > 0)
            {
                Debug.Log(node.Surface + "\t" + node.Feature);
                result += node.Surface + "\t" + node.Feature + "\r\n";
            }
            node = node.Next;
        }
        this.text.text = result;
        Debug.Log("");
    }
}

これにて、記事は完成です。超うれしい。

コマンド処理の正規表現マッチング

誰かが VRM をロードしたら、コマンドを送信するようにしたけど
単なるセキュリティホールだったので消します。

消す前に動いていたコードを記録しておきます。

        public void OnSendMessage(MessageResponse response)
        {
            Debug.Log($"OnSendMessage playerName = {response.PlayerName}, userUniqueId = {response.Message}");
            if (Regex.IsMatch(response.Message, @"^\/.*"))
            {
                if (Regex.IsMatch(response.Message, @"^\/event .*"))
                {
                    string[] command = response.Message.Split(' ');
                    if(0 == string.Compare(command[1], Event_VRMLoaded))
                    {
                        var position = this.vrmRoot.transform.position;
                        this.streamingClient.SendPositionAsync(position.x, position.y, position.z);
                        var rotation = this.vrmRoot.transform.rotation;
                        this.streamingClient.SendRotationAsync(rotation.x, rotation.y, rotation.z, rotation.w);
                    }
                }
            }
        }