simplestarの技術ブログ

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

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

Unity:オンラインVRM TPSゲーム作りログ5

■前書き
今回でこの連番タイトルもラストになりそうな進捗でてます。

前回の記事はこちら
simplestar-tech.hatenablog.com
要約すると
1.送信するタイミングとデータ構造を知るための調査
2.送受信のRPCインタフェースを設計
3.実際に送受信できていることを確認
でした。

今回は最後の繋ぎこみ部分、これがうまくいけばキャラクターはレイテンシが小さい状態で同期してアニメーションしてくれるはず。

■キャラクターの移動と回転
速度と角速度の適用

送られてくるデータを適用するには何かしらのキーでゲームオブジェクトを見分けないといけません。
プレイヤー名は自由に決められてしまうので、GUID を生成してから、それを整数値にすることで、これをプレイヤーIDとしました。
重なったら奇跡としましょう。

GUID の生成と文字列にするフォーマットまではここを参考にし
C#でGUIDをToStringするときに使用できる書式 - PG日誌

文字列から int 値を作る書式はここを参考にしました。
C# GUIDをシードにして整数の乱数を作る - 備忘録

ついでに Unity 2019.1.1f1 に更新して作業再開

最初はキーマップに VRM インスタンスを登録して、これを参照しつつ、受け取った位置と回転を適用するサンプルの動作を見てみます。
動きは完璧でした。

補間をどうするか考える必要があります。

■キャラクターのモーションの再生
CrossFace できることは知っているので、これを実装して動きを見てみましょう。
送受信のインタフェースは完成しているので、実際に送受信するコードだけは書いてしまいましょう。
書きました。

受け取ったモーションパラメータの適用を書いていきます。(ローカルで動作確認済みなので、期待できる)
書きました。

動作確認してみます。
念のため、受信しているかのログも記載しておきます。

動作の同期は完璧でした。
あとは、ログを取り除いて、送受信する頻度を state 変化は毎フレーム適用するようにしてみます。

悲しいことにログ吹っ飛んじゃった…

なんやかんやで、リアルタイム通信同期できるようになりました。

作業ログはここまでとします。

■補間
これは記録すべきだなぁと後から思い出したのでここに書くことにします。

using UnityEngine;

public class TransformInterpolation : MonoBehaviour
{
    internal Vector3 position;
    internal Quaternion rotation;

    [SerializeField] float acceleration = 0.2f;
    [SerializeField] float convergence = 0.9f;
    [SerializeField] float warpDistance = 1.5f;
    [SerializeField] float rotateSpeed = 500f;

    float velocity = 0;

    void Update()
    {
        transform.position = Vector3.MoveTowards(transform.position, position, velocity * Time.deltaTime);
        var distance = Vector3.Distance(position, transform.position);
        if (0.001f > distance)
        {
            velocity *= convergence;
        }
        else
        {
            velocity += acceleration;
        }
        velocity = Mathf.Clamp(velocity, 0, 5);
        if (warpDistance < Vector3.Distance(transform.position, position))
        {
            transform.position = position;
        }        
        transform.rotation = Quaternion.RotateTowards(transform.rotation, rotation, rotateSpeed * Time.deltaTime);
    }
}

Unity:オンラインVRM TPSゲーム作りログ4

■前書き
前回の記事はこちら
simplestar-tech.hatenablog.com

前回の内容をまとめると
1.扉のあるチュートリアル閉鎖空間を用意し、VRMファイル選択で動的に VRM キャラクターを読み込める
2.扉の前でアクションを行うとAWS Cognito のサインアップ・サインインが行える
3.サインイン後は S3 に VRM をアップロードし、プレイヤー名を決めて Magic Onion のサーバーに入室する
4.二人目のプレイヤーが入室した通知で S3 オブジェクトキーを取得し、これを S3 からダウンロードしてシーン内に二人目のプレイヤーの姿が表示される
5.プレイヤーに外部から Animator パラメータを与えることで自由に操作できることを確認
6.Animator から同期するべき情報を取得できそうな関数を前調査、報告

といったものでした。

今回は、前調査でわかっている Unity の Animator のパラメータの変化を観察して送受信するインタフェースを設計します。
観察→理解→想像→試行→観察 のループを高速で回して、機械が完成する感じのことします。

■横道
本筋とは関係ないけど、気になるのは S3 アップロードは即時完了しないので、可能なら Magic Onion サーバーに入ることを決めたら
そのときはログイン処理中とかのメッセージを出し、S3 アップロード完了と同時にサーバールームに入室を決めたい

あとは、S3 アップロード権限が無いとかで失敗したり、サーバーに入れなかった時などはその旨を表示して、ローカルのゲームに戻ってこれるつくりも入れてあげたい

一般公開したときは、ルーム最大数を越えたら、その旨で弾かれるつくりとかもいいね
あと、チャット機能と表情やエモーションアクションを送れるメニューと機能も欲しいところ

■本題
Animator のパラメータの変化を検知して、これを報告するプログラムを書いてみます。
期待通り動くかな

layer は 5つ、そのうち、アクションをしている最中は State の nameHash 値が -1903714832 のときローリング
直立ロコモーション時は 1905792070 の値になることを確認

hash 値を与えて CrossFade できるか確認します。
できました。

気づいたことに、Invector のコンポーネントの割当たっている VRM キャラに CrossFade させると移動し、そうでない VRM キャラはその場でアニメーションしました。
Root Motion の違い?
yes

ただ、単に apply root motion にチェックを入れると、アイドル時に振動してしまうので、何らかのスクリプト編集が必要そう

そうだ、 Animator.applyRootMotion も同期するステートにしてしまおう
動的に切り替えて、期待通りのモーションとなることは確認できた。

パラメータ、レイヤーごとのステート、あとは位置と回転と速度などですね
これも Animator から取れるのでしょうか

取れますが、OnAnimatorIK とか OnState とかの関数内で呼べと怒られました。

Debug.Log($"bodyPosition = {this.debugAnimator.bodyPosition}");
Debug.Log($"rootRotation = {this.debugAnimator.bodyRotation}");
Debug.Log($"velocity = {this.debugAnimator.velocity}");
Debug.Log($"angularVelocity = {this.debugAnimator.angularVelocity}");

速度や角速度が取れるのはいいですね、これなら毎フレーム送らなくても滑らかな補間が行える?

この関数はいったい
yutakaseda3216.hatenablog.com

無知だったので、落とし穴に気付かせてもらい、助かりました。

OnAnimatorIK 関数はどうでしょう。
Animator に IK レイヤーを一つ加えると、呼び出してもらえるようになり警告が消えました。

小ゴールとして、この関数でモニタリングしつつ、頻度を調整できるようにしつつ、前回ブロードキャストした状態からの差異をまとめて列挙できる仕組みを書いてみます。

定期的ってのは FPS とかで調べると解決
OnAnimatorIK は 50 FPS であることもわかった。

アニメーションが設定されていると Body の位置が移動する
Distance 計算して余白を求めればゆるやかに更新

ところで BodyPosition の更新は意味あるのか
意味なかった、なんと、ではこのオブジェクトの transform を利用することにします。

これなら、適用後
移動することを確認

アニメーターパラメータも差分をチェックする機構を作って動作を見てみます。
問題なさそうですね

アニメーションステートを見ます。
こちらも差分の検出は問題なし

animator.applyRootMotion は変化を受け取れなかったので、無視します。

これで小ゴール達成です。

■スナップショット差分が発生したデータだけを送信するインタフェース

値に変更があったものを瞬時に送信できるような仕組みとするので
差異があったパラメータを配列にして送信できたらいいなと

動的に増減するので List 型にしてみる

まずはリストが変かあったときに要素を持ち、これを送信する
そんなリストが作られる様子を確認してから

それを送信できるインタフェースを Magic Onion 側に用意してコードジェネレートしてビルド通してみましょう

リストになれない
値が float, int, bool, Vector3, Quaternion に分かれます。

name hash を送信すれば float, int, bool はそれぞれリスト化できそう
インタフェースは三つになる形か

レイヤーごとのアニメーションステートは一つインタフェースを呼ぶ形

Vector3 とかはそれぞれインタフェースを用意してしまう形で x 4 ってことで

となるとインタフェースは全部で 8 つ増えることに
List に関してだけ namehash と value のセットを送信する形になりますね。

作って、送受信できているか、試してみます。

サーバー側でも UnityEngine.dllを選んでAddをクリックして参照追加すれば Vector3 を引数に指定して処理できるようになります。
C:\Program Files\Unity\Hub\Editor\2019.1.0f2\Editor\Data\Managed\UnityEngine.dll
これです。

あれ、サーバーとして起動できない。
プロジェクトに含めないといけない様子だが
ビルドは通るけど、ランタイムエラーという

Magic Onion で Unity Vecotr 3 とか使えた気がしたけど、気のせい?
全部 float にして送受信するように書き直して Magic Onion のコード生成を実行

ミスマッチで作れないとエラー

追加した行をすべて元に戻して、同じエラー

内容が読めないけど、これは?
もう見落としている箇所が見つけられないのだけど…

入念にチェックをしてみます。

エラーが発生するのはコードを書いていた部分ではなく Unity アセンブリからの MessagePack コード自動生成の部分でした。
なぞ、解決方法がわからん。

簡易なプロジェクトで Magic Onion だけのプロジェクトを作ってコードジェネレートだけするというのがいいのかな…
それで解決を試みていきましょう。

通信はできるようになった、けど
同じアカウントで複数ログインを試行すると、プレイヤーIDが重なってしまう。

ここはプレイヤーごとに異なる、ログインハッシュ値を一時的に作成してユーザーの重なりを除去するのが良いか
NameToHash を使わせてもらうのがよいか

リアルタイムに情報を送受信しあっているので、小ゴールはクリアしていた

■日が暮れた…
続きは、次の記事に書きます。

改竄防止しつつ、コードを変えずにパラメータを再利用する案

ゲーム内コードで自らの dll の hash 値を計算して、それと公開鍵によって解錠する暗号化されたファイルを設定ファイルとして外部に配置し
ゲーム外コードから dll の hash 値と秘密鍵で設定ファイルを暗号化しておけば、ゲームロジックに変更を加えると解錠できなくなってゲームに接続できない
& 設定ファイルを複合化できないから、コードに直接データを埋め込むよりは良いかも

だめか

編集前の hash 値は割れるので、複合化のコードが割られれば、hash 値計算部分を改竄して編集前の hash 値を利用するように変えてあげれば、あとはコードの改竄し放題じゃん

でも、ガードは固くなったので
コードを難読化して、hash 値計算している箇所を隠すようにできれば
大分、ライトな層のクラッカーをあきらめさせられるかな

暗号化とかはこれで

追記:
ゲームのロジックとは別のアセンブリから、そのゲームロジックのアセンブリハッシュ値を計算してユーザー管理サーバーに送り
サーバーが期待するハッシュ値と比較、違っていたらロジックの改ざんが確定するので、然るべきチーターのための処理フラグをサーバー側で立ててあげる
それとなくリーダーボードから外されていて、報酬がもらえないなど、チート判定されたことの発見が遅れるとか、本人から不具合報告の連絡をさせて、犯罪者をあぶり出すとか
ハッシュ値の送信箇所まで改ざんされたら終わりだけど、ただ、ゲームロジックをどんなに眺めても解放にたどり着けないはず(予想が的中されることはありそうだけど)
これもライトな層のクラッカーをあきらめさせる程度のものでしかないか

あ、このページを見られて予想されるという線もある