simplestarの技術ブログ

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

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);
                    }
                }
            }
        }

Unity:VRMオンラインチャット構想

■画一的なエモーションコマンド作れる?→YES
VRM には表情プロキシクラスがあるので、どのような VRM でも統一したエモーション名で表情を表出することができます。

■テキストに合わせて表情と口を動かすことできる?→YES
あいうえおの言葉の表現もあるので、テキストから母音を取り出して口の形を決めることができます。
qiita.com

MeCab は単語の意味から感情も取れる?→NO
挑む例は多くとも
qiita.com
ディフォルトでサポートするとか、そういうものではない

なら、表情はランダムに出すというより
「!」(笑)で、笑顔
「?」で終わると困り顔
「!?」で、驚き顔
に変わるとかで試験できますね

■ゲーム内に半透明で吹き出しとテキストを表示できる?→
TMP で

blogs.unity3d.com


www.hanachiru-blog.com

口の動きと、会話のタイミングについては、これで合わせられることになるだろう。

■ゲーム中のチャットテキスト入力画面の表示→調査完了
コピー&ペーストすることを考えるなら、画面下部に一行入力できるラインがあると良い

ログとして流せると良いので、マインクラフトは真似る
tを押すと半透明の帯が現れる
黒い半透明背景にテキストは白が基本
プレイヤー名が左に表示される(可能ならVRMの顔画像としたい)
教えてもらったテキストをコピーできるようにもしておく
最大コメントログ行数はディフォルトで5行、ログはtを押したときに現れる

ログの表示は困るか、不自由で良いので
近くに来て吹き出しの内容が読めるときだけ意思疎通できるようにすると良い

ところで、キャラクターに吹き出しが出て、テキストが流れつつリップシンクと表情の変化があるとなお良い

全員のログが流れるとうざい
キャラごとに色分けされていると良い(色は自分で設定できるカラーピッカー)
baba-s.hatenablog.com

吹き出しを重ならないように調整できるか

各クライアントにて、カメラに対して正対するように回転を与える
キャラクターごとに重なると読みづらいが、必ずレイヤーの上層で表示させる
吹き出しは横長にして、高さが重なったら、遠いプレイヤーの方が吹き出しをずらす。
カメラの画面にキャラが入っていない場合は、距離が近いとキャラの方に矢印が出る形で画面に入ってくる。

■そんな描画技術あるのか→発明した

UIキャンバスはそのまま使い、動的に吹き出し要素のuGUIが飛び出てくるようにする
距離に応じて小さくなるように設定できる
必ずUIとして前面に表示させることができる

シミュレーションして式の形をイメージします。
画面内にキャラクターがいる時
キャラクターの頭の上に吹き出しが現れる
頭の上に余白がないときは、画面外へ出る寸前で、端が内に収まるように配置される、アニメはしない
キャラクターが画面左へ移動したとき、画面中央から考えられるベクトルの接点で位置が決まる
画面付近にいる場合はこれで表現ができる
Positionからスクリーン座標の計算
docs.unity3d.com


画面内にキャラクターがいないとき
カメラの方向の真横にキャラクターが移動したとき

とにかく上記のインタフェースでどれくらいの計算結果が得られるのか試験します。

■試験結果
Unity2019.1.1 で試験しました。

前面に表示されるときは、矢じりの延長がクロスする点が期待通りの位置に表示され
背面に入った時は、矢筈の延長がクロスする点の位置を返すようになりました。

真横で計算できないことになると原点を返す様子です。

原点は無視
前面で枠外に出る時は、枠内に収めるようにクランプ
背面のときは、鏡面反射座標にしてから、枠外へのオフセットを与えて、枠内に収めるようにクランプ

が良さそうであることが想像できました。

コードに落として動作を見てみます。

using UnityEngine;

public class World2ScreenPoint : MonoBehaviour
{
    public Transform position;
    public RectTransform text;
    Camera cam;

    void Start()
    {
        cam = GetComponent<Camera>();
    }

    void Update()
    {
        var distance = Vector3.Distance(position.position, cam.transform.position);
        text.localScale = Vector3.one * Mathf.Clamp01(3 / distance);

        var dot = Vector3.Dot(cam.transform.forward, position.position - cam.transform.position);
        var flag = Mathf.Sign(dot);

        Vector3 screenPos = cam.WorldToScreenPoint(position.position);

        var marginX = text.rect.width / 2 * text.localScale.x;
        var marginY = text.rect.height / 2 * text.localScale.y;
        var x = Mathf.Clamp(screenPos.x, marginX, Screen.width - marginX);
        var y = Mathf.Clamp(screenPos.y, marginY, Screen.height - marginY);
        var z = screenPos.z;
        text.rotation = Quaternion.identity;

        if (0 > flag)
        {
            x = Screen.width - x;
            y = Screen.height - y;
            text.rotation = Quaternion.Euler(0, 180, 0);
        }
        text.position = new Vector3(x, y, z);
    }
}

期待通り動きました。

■キャラクターを配置してテスト
どのような動きになるのか、本番さながらの様子をみてみましょう。

キャラクターの頭上に必ずテキストが配置されるものとします。
単体での表示なら問題なさそうですね。

3体と同時に会話したときに、何が起こるのか試してみましょう。
誰の発言なのか一目でわかるようにサムネイルがほしい

サムネイルを表示してみましたが、プレイヤー名の方が自然ですね。

MeCabによる動的なテキスト解析

例のリンク先を読みます。
以下のコードにて Mac 環境 Editor でも動作させることが可能であることを確認しました。
Mac アプリだとプラットフォームで許可されてないエラーが出たので、後日ソースを使って調べる

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";

        MeCabTagger t = MeCabTagger.Create(param);
        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("");
    }
}