今回は2つ目となるワールドを作りました。NPCのラスクちゃんとねこじゃらしで遊べるワールドです。
(1つ目の制作記はこちら

ワールド名 NPC Cat toy – ねこじゃらし遊び
URL https://vrchat.com/home/world/wrld_972f24e5-99c7-4517-9dc0-a36ba5adfcd7

Comminity Labsの検索オプションをオンにして、NPC Cat toyで検索すると出てくると思います。(と思ったら出てきませんでした。時間がかかるんですかね。リンクから行けない場合は、Labs抜けまでしばしお待ちを)

ワールドの概要

このワールドでは、NPCのラスクちゃんとねこじゃらしで遊ぶことができます。NPCというのはNon Player Characterの略で、ゲーム中に登場するキャラクター、プレイヤーが操作をしないコンピューターによって動いているキャラクターのことです。

今回はねこじゃらしで遊ぶラスクちゃんのほかに、ベッドでお昼寝しているラスクちゃんと、縦横無尽に走り回るラスクちゃんも実装しました。

※以下、Unityの基礎をちゃんと理解できてないので、内容に間違った箇所も多いかと思います。基本的に「動けばヨシ!」という考え方でやってます。笑

NPCを動かす方法

まずはじめに、ワールド上でNPCを動かすにはどうしたらいいか?最初に思いついたのは、走るアニメーションを再生しながら、スクリプトで座標を変更すればできそうです。しかしそれでは事前に決めたルートしか移動しなかったり、方向転換や減速を違和感なく再生するにはとても難しそうです。

調べてみると、UnityにはNavMeshという、まさにNPCシステムのための機能があることがわかりました。これを使えばNPCのシステムが簡単に作れそうです。

NavMeshは簡単に言うと、NPCがルートを自動的に探索して、障害物を避けながら目的地まで誘導してくれるシステムです。NavMeshについては、こちら()の記事を参考にしました。めっちゃ参考になりました。

NavMeshを使うと、目的地を指定するだけで勝手に動いてくれます。とはいえどこが通れる場所なのか、どこが障害物なのか、という情報がなければ動きようがありません。そこでNavMesh(通れる場所の情報)を作成する必要があります。

UnityにはNavMeshを視覚的に作成できるインターフェースが用意されています。キャラクターの身長、直径、上れる段の高さ、角度などの情報を与えると、自動的に通れる場所を計算して作ってくれます。

今回は部屋となるワールド環境に、ねこやま君さんの 3Dモデル [Room S2] を使用させていただきました。この部屋の床の上を通れる場所として設定したいと思います。通れる場所となるには、オブジェクトがStaticであり(Navigation Staticを選択)、NavigationのObject設定でNavigation AreaをWalkableに設定する必要があります。

まずはじめに部屋の全オブジェクトを選択して、Navigation Staticのみをオフ、Navigation AreaをNot Walkableにしました。天井とか歩かれても困りますからね。次に床やソファ、ベッドなど通ってもよい場所を選択して、Navigation Staticをオン、Navigation AreaをWalkableに設定しました。

これでベイクをすると通れる場所を計算して、水色のメッシュで表示してくれます。最初は期待通りの形にならなかったので、何度もパラメーターを調整しながら試行錯誤してみました。

キャラクターを動かす

NavMeshができたら次は動かしたいキャラクターに、Nav Mesh Agentコンポーネントを追加します。先ほど作成したNavMeshは、通れる場所の情報でしかありません。キャラクターに命を吹き込むのがNav Mesh Agentです。

Nav Mesh Agentはキャラクターを目的の場所まで誘導するための機能です。キャラクターの移動速度などは個別に設定できるようになっていて、キャラクター別に使い分けることもできます。今回はラスクちゃんという元気なイメージを再現するために、走る、ダッシュする、という表現にしたいと思います。そこでspeed(速度)は3m、Angular Speed(回転速度)は360、Accelerarion(加速度)は16に設定しました。

Stop Distanceは目標までどのくらいの距離で止まるかという設定なのですが、なぜか設定しても通り過ぎてしまいました。キャラクターが移動するときは、加速→等速→減速 という3段階の動作をするのですが、減速が間に合わずに滑ったまま通り過ぎてしまうのです。

結局これについてはAccelerationを大きくすることで対処しました。この設定は加速だけでなく減速でも共通なんですかね。なのでAccelerationとStop Distanceの値を調整しながら、ちょうどいいところで止まるようにしてみました。

キャラクターの制御

実際にキャラクターを動かすには、どの場所に移動させるか指示を出すスクリプトが必要になります。今回は右も左もわからない状態だったので、UnityのStandard Assetsのスクリプトをベースに作りました。

<作成したスクリプト>
コントローラー AICharacterControl2.cs
アニメーションの制御 ThirdPersonCharacter2.cs

スクリプトの構成

キャラクターの制御は2つのスクリプトで構成されています。コントローラー (AICharacterControl2.cs) はキャラクターに行ってほしい目的地をセットしたり、目的地に到着した場合にどうゆう動作をするかをコントロールするものです。今回はねこじゃらしの先端を目的地にして、ラスクちゃんが目的地まで走ってきます。目的地に着いたら、ねこじゃらしでじゃれるというのが一連の動作になります。

アニメーションの制御 (ThirdPersonCharacter2.cs) は、動作状態に合ったアニメーションの再生を行います。Nav Mesh Agentを使えばキャラクターを目的地まで自動的に移動してくれますが、このままではTポーズのまま、向きも変えずに動いてしまいます。そこで、進む方向に向いたり、走ったり歩いたり、という動作を実現するのがこのスクリプトです。

このスクリプトの Move2() メソッドがコントローラーから呼ばれることで、再生するアニメーションが更新されます。Move2() の第1引数の move は進む方向と強さ(速さ)で、これによってどのアニメーションが再生されるかが決まります。

アニメーターコントローラーとブレンドツリー

アニメーターコントローラーは同じくStandard Assetsに同胞されている ThirdPersonAnimatorControllerを使いました。アニメーションの切り替えはブレンドツリーで制御されていて、先ほど出てきたmoveという引数の値によって、前方向に走るのか、歩くのか、横に回転するのか、止まるのか、動作に応じたアニメーションが選択されるようになってます。ブレンドツリーってすごいですね!こんなのスクリプトで書いてたら気が狂いそうです。笑

元々あった動作にはジャンプやしゃがむモーションもありましたが、今回は必要なかったので、ジャンプとしゃがむは削除しました。「しゃがむ」については、なんか戦闘姿勢を取ってるゴブリンのような凄い姿勢で、とてもケモ耳美少女キャラには合わなかったです。

アイドル時のアニメーションで生きているように見せる

静止しているときでも人はわずかに揺れています。3Dキャラクターの場合、そのまで静止していると置き物のようになってしまうのですが、かすかに揺れ動くようなアニメーションを加えると、まるで生きているかのように見えて面白いです。Standard Assetsに入ってるアイドル時のアニメーションは動きが多すぎたので、今回はアイドル時のみ別のものを使用しました。

アニメーションの作成

走ったりするアニメーションはStandard Assetsのものがそのまま使えましたが、ねこじゃらしで「じゃれる」というアニメーションはありません。BOOTHやAsset Storeで探しましたが無さそうでした。こうなったらもう自分で作るしかありませんね。ということでUnityのアニメーションの編集画面で作成を試みたのですが、肘を曲げたところで早くも力尽きました。これはポーズを作るためのツールじゃない。

Very Animationが便利すぎる!

どうやったら簡単にポーズを作成できるのか調べてみると、Very Animationというツールを使うのが定番のようでした。しかし50ドルと結構お高い。しかし解説動画を見るととても使いやすそうなインターフェース。これは将来への投資だ。今回のためだけに買うんじゃない。これからこうゆう作品を作っていくんだ。そう自分に言い聞かせてポチっとしました。笑

実際にVery Animationを使ってみたら滅茶苦茶便利でした。特にIKのターゲットを動かすと関節が一緒に着いてきてくれるので、1つ1つ関節を動かす必要もありませんし、あり得ない形状になってしまうこともありません。指を曲げたり伸ばしたりするのもスライダーを動かすだけ。これは素晴らしいツールでした。

アニメーションは難しかった

そして完成したのがこのアニメーション。そもそもアニメーションなんて作ったことが無い人間がやったので、どうしても自然な感じにはできませんでした。ポーズの位置だけでなく、どのくらいのスピードで動かすかというのがとても重要なんだと知りました。アニメを作ってる人って凄いんだな。

今回は高い位置でじゃれるのと、中くらいの位置でじゃれるのの、2つのアニメーションを作りました。ねこじゃらしが高い位置にある場合は高い位置のアニメーションを、低い位置にある場合は低い位置のアニメーションを、切り替えて再生するようにします。

プログラムの説明 ~より楽しめるようにしたこと~

ここからはプログラムについて書いていきたいと思います。やはりVRなのでリアルっぽい体験を重視したいところです。機械的にならずより本物のように、面白い体験ができるように考えてみました。

ねこじゃらしを持ったときだけ近づいてくるように

普段はそっぽ向いてるのに、プレイヤーがねこじゃらしを持つとラスクちゃんが近づいてくるようにしました。

スクリプト nekojarashi.cs

仕組みは簡単で、VRC Pickupでオブジェクトをつかむと OnPickup() が呼ばれるので、目標となるオブジェクトをアクティブにします。この目標はただのCubeで、ねこじゃらしの子要素として配置されています。

ラスクちゃん (AICharacterControl2.cs) が目標点がアクティブになったことを検知すると、この目標点の座標を行き先に設定する仕組みです。

            for (var i=0; i<_target.Length; i++)
            {
                if (_target[i] != null && _target[i].activeSelf)
                {

動くルンバを追いかける

ねこじゃらしを持っていないときにただ突っ立っているだけでは面白くないので、自動掃除機ルンバ(?)を追いかけるようにしました。ルンバはこちらのアセットを使用させていただきました。このルンバに目標点となるCubeを配置することで、ラスクちゃんがルンバを追いかけてくれます。

よりネコっぽい動きにするために、追従対象がルンバの場合は、ルンバに近づいたら止まってしばらく見つめて、遠くに行ったら走り始めるようにしてみました。こうすると、ちょこっ、ちょこっ、と小動物のような動きになります。

            else if (_selected == 9)
            {
                // ルンバに近づいたら止まってしばらく見つめて、遠くに行ったら走り始める処理
                agent.speed = _agent_speed_roomba;
                if (agent.stoppingDistance == _roomba_stop && agent.remainingDistance <= _roomba_stop + 0.2f)
                {
                    agent.stoppingDistance = _roomba_run;   //2.5
                }
                else if (agent.stoppingDistance == _roomba_run && agent.remainingDistance > _roomba_run - 0.2f)
                {
                    agent.stoppingDistance = _roomba_stop;  //0.5
                }
                else if (agent.stoppingDistance != _roomba_stop && agent.stoppingDistance != _roomba_run)
                {
                    agent.stoppingDistance = _roomba_run;   //2.5
                }
            }

agent.remainingDistance が目標までの(残りの)距離、agent.stoppingDistance が設定した停止する距離です。目標点に到着したら停止距離を伸ばして着いていかないようにして、一定距離以上離れたら、また停止距離を縮めて追いかけるという動作をします。

ルンバの動作方法がなかなか興味深かった

スクリプトはこのアセットに含まれているので詳細はダウンロードして見ていただきたのですが、このルンバはとても短いプログラムで動いてます。NavMeshのような仕組みを使ってルンバの座標をコントロールしているのではなく、床の上を摩擦をなくした状態で横から加速させて、壁(Collider)にぶつかったら回転させる、という仕組みになってます。これ考えた人すごい。発想自体が違いますね。

ただこのまま使うと部屋の隅で固まったりしてしまったので、switch文の手前に、以下のようなコードを追加して救出するようにしました。

            // 移動速度の低下が連続したらリセットする
            if (_state == 0)
            {
                speedmav = (speedmav / 300f) *299f + _rb.velocity.magnitude / 300f;
                if (speedmav < limit)
                {
                    speedmav = 4f;
                    _state = 1;
                }
            }

あと、ひたすら壁に沿って進んでしまう現象があったので、回転する時間をランダムではなく固定で与えて調整できるようにしました。なぜこれでうまくいくのかわかりませんがヨシ!

            _rotateTarget = _rorate_time;// Random.Range(2f, 3f);

複数のターゲットを使えるようにする

ねこじゃらしとルンバだけでは物足りないので、ぬいぐるみもいくつか追加しましょう!ぬいぐるみを5つ追加したので、合計で7個の目標点があります。ぬいぐるみも仕組みはねこじゃらしと同じで、持ったときだけ目標点が有効になって、ラスクちゃんが近寄ってくるようにします。上ほど優先順位が高いので、同時に有効になっていたら、ねこじゃらしは最優先、ルンバは最後になります。

目の前に来たときだけじゃれる処理

これでラスクちゃんが目標まで近づいてくることができました。次は「じゃれる」です。目標が手の届く範囲にあれば、じゃれるアニメーションを再生します。

ここでははじめに、ラスクちゃんの正面から水平方向に見た角度 angle と、距離 distance、高さ height を求めています。

    // 変更できるパラメーター
    [SerializeField, Range(0f, 2f)] float _nyaa_high = 2.00f;  // じゃれる高さ・上
    [SerializeField, Range(0f, 2f)] float _nyaa_mid = 0.98f;   // じゃれる高さ・中
    [SerializeField, Range(0f, 2f)] float _nyaa_low = 0.37f;   // じゃれる高さ・下
    [SerializeField, Range(0f, 90f)] float _nyaa_angle = 30f;  // じゃれる角度(正面から)
    [SerializeField, Range(0f, 2f)] float _nyaa_distance = 0.5f;   // じゃれる最大距離(正面)
    [SerializeField, Range(0f, 2f)] float _nyaa_hdistance = 0.2f;   // じゃれる最大距離(頭付近)

    // 目標が視界内の一定距離 & 高さにあるか?(0=範囲外 1=高い位置 2=低い位置)
    private int check_target_near()
    {
        int nyaa = 0;
        Vector3 eyeDir = this.transform.forward; // プレイヤーの視線ベクトル
        Vector3 playerPos = this.transform.position; // プレイヤーの位置
        Vector3 targetPos = _target[_selected-1].transform.position;    // 目標の位置(遅延なし)
        //Vector3 targetPos = _dtargetpos;    // 目標の位置(遅延あり)
        float height = targetPos.y - playerPos.y;   // 目標の高さ
        eyeDir.y = 0;
        playerPos.y = 0;
        targetPos.y = 0;
        float angle = Vector3.Angle((targetPos - playerPos).normalized, eyeDir);
        float distance = Vector3.Distance(playerPos, targetPos);
        //Debug.Log("**** angle=" + angle + " / distance=" + distance + " / height=" + height);
        if (distance >= 0.1f && distance < _nyaa_distance && angle < _nyaa_angle)   // 正面にある場合
        {
            if (height > _nyaa_mid && height <= _nyaa_high) nyaa = 1;    // 高い位置にある
            else if (height > _nyaa_low && height <= _nyaa_mid) nyaa = 2;   // 低い位置にある
        }
        else if (height > _nyaa_mid && height <= _nyaa_high && distance < _nyaa_hdistance)   // 頭付近にある場合
        {
            nyaa = 1;
        }
        return nyaa;
    }

距離 distance が0.1m~0.5mであり、角度が30度以内(左右あわせて60度)だった場合、近くにあると判定します。次に高さ height が0.98m~2mの場合は高い位置、0.37m~0.98mの場合は低い位置、それ以外は範囲外にあると判定します。

この check_target_near() の戻り値は character.Move2() の第2引数として引き渡されます。

                // 停止範囲内に入った場合
                int nyaa = check_target_near();
                character.Move2(Vector3.zero, nyaa);
                ashioto(false);

この値はアニメーションの制御スクリプト ThirdPersonCharacter2.cs より、アニメーターの変数にそのまま代入されます。アニメーターの2つ目のレイヤーがじゃれるアニメーションになっていて、高い位置用と低い位置用を切り分けて再生してます。

じゃれたときに表情を変える

じゃれてるときは真顔ではなく、目をキラキラさせたり、興奮するようにしてみました。アバターにはシェイプキーという便利な機能があって、パラメーターを変更するだけで簡単に表情を変えることができます。ラスクちゃんの場合はシェイプキーがなんと100種類もあります。

	// 使用するシェイプキー
	string _mabataki_shapeky = "blink"; // まばたき
	string _kuchiake_shapekey = "kuchi_pokan";	// ランダム口あけ
	string[] _nyaa1_shapekeys = new string[] { "kuchi_△_2", "kuchi_yaeba_×", "surprise", "shiitake" };	// じゃれるとき有効
	string[] _nyaa1_shapekeys_rev = new string[] { "kuchi_pokan" }; // じゃれるとき無効
	string[] _nyaa2_shapekeys = new string[] { "><", "kuchi_wa", "kuchi_smile_1" };   // 興奮時有効
	string[] _nyaa2_shapekeys_rev = new string[] { "kuchi_pokan" }; // 興奮時無効

長時間じゃれると興奮するように、_nyaa_pointを加算していき、その値によって表情を分けることにします。目や口などの同じ場所のシェイプキーを同時に動かすと表情が崩壊してしまうので、使用しないシェイプキーを0に戻す処理も入ってます。たとえば口を “kuchi_△_2″ にするときは、”kuchi_pokan” を無効にするといった感じです。

		// じゃれた時の目と口のシェイプキーを変更する
		if (_Nyaa > 0)
		{
			_nyaa_point++;
			if (_nyaa_point < 1000)	// 目キラキラ☆
			{
				foreach (string name in _nyaa2_shapekeys) m_Face.SetBlendShapeWeight(s2i(name), 0f);
				foreach (string name in _nyaa1_shapekeys) m_Face.SetBlendShapeWeight(s2i(name), 100f);
				foreach (string name in _nyaa1_shapekeys_rev) m_Face.SetBlendShapeWeight(s2i(name), 0f);
				_mabataki_on = true;
			}
			else if (_nyaa_point < 1600)	// たくさん遊ぶと興奮><
			{
				foreach (string name in _nyaa1_shapekeys) m_Face.SetBlendShapeWeight(s2i(name), 0f);
				foreach (string name in _nyaa2_shapekeys) m_Face.SetBlendShapeWeight(s2i(name), 100f);
				foreach (string name in _nyaa2_shapekeys_rev) m_Face.SetBlendShapeWeight(s2i(name), 0f);
				_mabataki_on = false;
			}
			else if (_nyaa_point >= 1600)
            {
				_nyaa_point = 0;
            }
		}
		else if (_Nyaa == 0)
		{
			foreach (string name in _nyaa1_shapekeys) m_Face.SetBlendShapeWeight(s2i(name), 0f);
			foreach (string name in _nyaa2_shapekeys) m_Face.SetBlendShapeWeight(s2i(name), 0f);
			_mabataki_on = true;
			_nyaa_point--;
			if (_nyaa_point < 0) _nyaa_point = 0;
		}

まばたきをする

表情で大切なのがまばたき。これがあるだけで生きている感が増します。まばたきも同様にシェイプキーで変更することができます。

// ランダムまばたき
	private void BlendShapeRandomMabataki()
	{
		if (_mabataki_on)
		{
			_pastsecm += Time.deltaTime;
			if (_pastsecm > _nextsecm)
			{
				_nextsecm = Random.Range(4.0f, 7.5f);    // 4~7.5秒ごとにまばたきする
				_pastsecm = 0.0f;
			}
			if (_pastsecm < 0.2f)
			{
				if (_pastsecm < 0.1f)
				{
					_Mabataki_Weight += (Time.deltaTime / 0.1f) * 100f;
					if (_Mabataki_Weight > 99.9f) _Mabataki_Weight = 100f;
					m_Face.SetBlendShapeWeight(s2i(_mabataki_shapeky), _Mabataki_Weight);
				}
				else if (_pastsecm < 0.2f)
				{
					_Mabataki_Weight -= (Time.deltaTime / 0.1f) * 100f;
					if (_Mabataki_Weight < 0.01f) _Mabataki_Weight = 0.0f;
					m_Face.SetBlendShapeWeight(s2i(_mabataki_shapeky), _Mabataki_Weight);
				}
			}
		} else
        {
			m_Face.SetBlendShapeWeight(s2i(_mabataki_shapeky), 0f);
		}
	}

まばたきが等間隔だと不自然なので、ここではランダムで4~7.5秒ごとにまばたきをするようになっています。目の状態によってはまばたきをするとおかしい場面もあるため、_mabataki_on というフラグでまばたきをするかどうか制御しています。

にゃ~

せっかくなので、ランダムで口をあーんと開ける仕草も加えてみました。アイドル時の揺れに加えてまばたき、口開けが入って、より生きてる感が出たと思います。

	// ランダム口あけ
	private void BlendShapeRandomKuchiake(int nyaa)
	{
		_pastseck += Time.deltaTime;
		if (nyaa != 0)
        {
			_nextseck = _pastseck + 5.0f;	// じゃれた後は次のあーんまで5秒おく
			return;
        }
		if (_pastseck > _nextseck)
		{
			_nextseck = Random.Range(10.0f, 20.0f);  // 10~20秒ごとにあーんする
			_pastseck = 0.0f;
		}
		if (_pastseck < 0.5f)
		{
			_Kuchi_Weight += (Time.deltaTime / 0.4f) * 100f;
			if (_Kuchi_Weight > 99.9f) _Kuchi_Weight = 100f;
			m_Face.SetBlendShapeWeight(s2i(_kuchiake_shapekey), _Kuchi_Weight);
		}
		else if (_pastseck > 2.0f && _pastseck <= 2.5f)
		{
			_Kuchi_Weight -= (Time.deltaTime / 0.4f) * 100f;
			if (_Kuchi_Weight < 0.01f) _Kuchi_Weight = 0f;
			m_Face.SetBlendShapeWeight(s2i(_kuchiake_shapekey), _Kuchi_Weight);
		}
	}

ちょっと間を置く、クールタイム

最初は目標が切り替わると、すぐにその目標へと走るようにしていたのですが、切り替わった瞬間にダッシュしていくのはちょっと不自然に感じました。そこで目標が切り替わった場合は、しばらく時間をおいてから動かすようにしました。

_selected という変数は今目標にする(している)オブジェクトの番号で、_next_select は次に目標とするオブジェクトの番号を入れます。

                    if (_selected != i + 1)
                    {
                        _next_select = i + 1;
                        _next_cooltime = (i == (_target.Length-1)) ? 0.7f : 0.2f;    // ルンバのときはクール時間長く
                    }

クールタイムの間は視線追従をやめ、 _selected=0 にしてアイドル状態で過ごすようにします。ただし瞬間的に首の位置が戻ることを防止するため、クールタイムの開始直後は徐々にweightを下げる処理を入れてます。_next_cooltime に指定した時間が経過すると _selected に代入されます。

            // クールタイム
            _next_cooltime -= Time.deltaTime;
            if (_next_cooltime < 0f)
            {
                _selected = _next_select;
                _next_select = -1;
                fill_delay_target_buffer(_target[_selected-1].transform.position);
                _ikall = _ikall_default;
            }
            else
            {
                _ikall -= Time.deltaTime / 0.15f;   // 首が急に戻るのを防止する
                if (_ikall < 0f)
                {
                    _ikall = 0f;
                    _selected = 0;
                }
            }

視線をターゲットに追従させる

ねこじゃらしにじゃれるとき、アニメーションだけでは正面を向いたままなので不自然です。ねこじゃらしを右に動かすと、顔も右の方に向く、上下に動かせば、顔も上下に向くといいですよね。それをやってるのが OnAnimatorIK() のところです。

    // 視線追従
    private void OnAnimatorIK(int layerIndex)
    {
        if (_selected > 0 && _target[_selected-1] != null && _selected > 0 && agent.remainingDistance < 3.0f)  //目標がセットされ距離が3m以内
        {
            animator.SetLookAtWeight(_ikall, _ikbody, _ikhead, _ikeye, _ikmotion); // 全体,体,頭,目,モーション
            //animator.SetLookAtPosition(_target[_selected-1].transform.position);  // 遅延なしver
            animator.SetLookAtPosition(_dtargetpos);  // 遅延ありver
        }
    }

agent.remainingDistance は目標までの距離で、3m以上離れている場合は視線を向けないようにしています。animator.SetLookAtPosition() を指定すると、その方向を向いてくれます。たったこれだけで視線追従ができるなんて便利ですね。

animator.SetLookAtWeight() はどのくらい比重(weight)で体のパーツを反映させるかという設定で、以下のように設定しました。

    // 視線追従の設定(0~1.0)
    float _ikall = 1.00f;
    float _ikall_default = 1.00f;
    float _ikbody = 0.36f;
    float _ikhead = 0.55f;
    float _ikeye = 1.0f;
    float _ikmotion = 1.0f;

weightを大きくするとそれだけ大きく動きますが、首があり得ない角度に曲がってホラワになってしまうので、体と頭のweightは下げています。不自然に曲がらないように値を変えながら調整しました。この辺はおそらく使用するアバターによっても変わってくると思います。

動かしたときに反応を少し遅らせる

普通に視線追従すると、ねこじゃらしとラスクちゃんの顔が紐で繋がっているかのように、同時に動いてしまいます。人は目標を捉えてから動くまでわずかな時間がかかるため、視線の追従に遅延を加えることにしました。

仕組みは簡単で、配列にひたすら目標の座標を格納していき、FIFO(先入れ先出し)で取り出すというものです。配列からデータを取り出すのに Shift() が使えれば楽だったんですが、使えなかったのでfor文で代用しています。

    [SerializeField, Range(1,99)] int _delay = 15;   // 追従の遅延 1=0.02秒、15=0.3秒
    Vector3[] _dtargetpos_hists = new Vector3[100];  // 遅延用 目標座標の履歴
    Vector3 _dtargetpos;    // 遅延後の目標座標

    // 目標の遅延処理
    private void delay_target()
    {
        if (_selected > 0 && _target[_selected-1] != null)
        {
            _dtargetpos = _dtargetpos_hists[0]; // Shift()が使えないのなんで?
            for (var i = 1; i < _dtargetpos_hists.Length; i++)
            {
                _dtargetpos_hists[i - 1] = _dtargetpos_hists[i];
            }
            _dtargetpos_hists[_delay] = _target[_selected-1].transform.position;
            if (_dtargetpos == null) _dtargetpos = _target[_selected-1].transform.position;
        }
    }
    private void fill_delay_target_buffer(Vector3 pos)
    {
        for (var i = 0; i < _dtargetpos_hists.Length; i++)
        {
            _dtargetpos_hists[i] = pos;
        }
    }

この delay_target() は FixedUpdate() から0.02秒ごとに呼ばれるので、1秒間で50個の配列を使用します。遅延の量は _delay で変更できるようになっています。今回は0.3秒の遅延を持たせるようになってます。

最初は Update() でやってたんですが、Questだと3倍くらい遅くなるんですよね。Update() はフレームごとの処理なので、時間を扱う処理には向いていなかったみたいです。そこで FixedUpdate() にしました。

寝ているラスクちゃん

2Fのフロアにはベッドがあるので、そちらでは寝ているラスクちゃんを追加してみました。寝ているアニメーションはこちらの動きの多い睡眠モーションV1.0を使用しました。このアニメーションには表情は含まれていないので、目をつむる、開ける、あくびをするというアニメーションを追加しました。

走り回るラスクちゃん

せっかくなのでラスクちゃんをあと4人増やしてみました。4人が一斉に部屋中を縦横無尽に走り回ります。ただこちらは走り回るだけで、ねこじゃらしには反応しません。アニメーションの制御のスクリプトはそのまま流用し、コントローラーをアレンジしました。

コントローラー AICharacterControl2_Route.cs
アニメーションの制御 ThirdPersonCharacter2.cs

走り回るためのコントローラーでは、あらかじめルートを決めておき、ルートに沿って動きます。といっても決めるのは目標点だけで、経路はNav Mesh Anegtによって自動で計算されます。

ルートは簡単に確認できるように、ただのCubeを並べて作ります。このCubeの座標を順番に拾っていって、目的地に着いたら次の目標へと進む仕組みです。

この方法の便利なのが、視覚的にルートがわかりやすいというところです。調整も簡単にでき、たとえばソファーのところでぐるぐる回る回数を増やしたいという場合も、Cubeをコピーして増やすだけでできます。個人的には再起処理させて、ルートをグループ化できるともっといいなぁと思いました。

                // 目標に到達したら次の目標へ向かう
                _selected++;
                if (_selected >= _route_object.transform.childCount) _selected = 0;
                set_next_target(_selected);
    // 目標を設定する
    private void set_next_target(int no)
    {
        if (_route_object.transform.childCount > 0)
        {
            if (_route_object.transform.GetChild(no) != null)
            {
                agent.SetDestination(_route_object.transform.GetChild(no).transform.position);
            }
        }
    }

足音を鳴らして臨場感を出す

VRChatでは音源の距離や方向を感じられるような、臨場感のあるサウンドの再現が可能です。Audio SourceにVRC Spatial Audio Sourceコンポーネントを加えるだけで簡単にできます。今回はラスクちゃんが近づくと足音が大きくなり、離れると小さくなっていくようにしました。

ただ止まっているときは足音が鳴ってはいけませんから、目標地点に向かっているときは再生する、到着したら止めるというようにしました。

    // 足音を鳴らす
    private void ashioto(bool newstat)
    {
        if (_ashioto_sound != null)
        {
            if (newstat && !_ashioto_sound.isPlaying)
            {
                _ashioto_sound.pitch = (agent.speed / 3.0f);    // サウンドは速さ3.0を基準で計算
                _ashioto_sound.Play();
            }
            else if (!newstat && _ashioto_sound.isPlaying)
            {
                _ashioto_sound.Stop();
            }
        }
    }

音が小さい?

直接サウンドファイルを再生すると結構大きな音で聞こえるんですが、実際にVRChat内で聴くと、なぜか音が小さいんですよね。ボリュームも変えてなく、ゲインも設定できる最大値にしています(10以上は設定できない)。もしかすると、ユーザーが不快な体験をすることがないように、VRChatの方で音量調整が行われているのかもしれません。

アバターの明るさをスライダーで変更できるように

明るさについては後述しますが、スライダーでアバターの明るさを調整できるようにしました。ヨドコロちゃんハプティックコントローラーを使用して、Directional Lightの明るさを調整できるようにしました。

流れとしてはこのようになってます。
スライダー → レシーバー(DirectionalLightReceiver.cs) → Directional Light

public class DirectionalLightReceiver : UdonSharpBehaviour
{
    [SerializeField] public Light _directional_light;   // Directional Lightのオブジェクト
    [SerializeField] public GameObject _mirror;    // ミラーのオブジェクト
    [SerializeField] public GameObject _plate;    // プレートのオブジェクト

    public bool Yodo_isReceiveSliderValueChangeEvent = true;
    public float Yodo_lightIntensity = 0.75f;
    private float remain = 0f;
    private int cnt = 0;

    // スライダーから明るさを変更を行うレシーバー
    public void Yodo_OnSliderValueChanged()
    {
        _directional_light.intensity = Yodo_lightIntensity;
        _mirror.SetActive(true);
        _plate.SetActive(false);
        remain = (cnt++ > 0) ? 3.0f : 0f;
    }

    // ミラーを消すタイマー
    private void Update()
    {
        remain -= Time.deltaTime;
        if (remain < 0f && remain > -1f)
        {
            _mirror.SetActive(false);
            _plate.SetActive(true);
        }
    }
}

スライダーにUdonを指定すると、その中の変数を書き換えて実行してくれる、めっちゃ便利なアセットです。レシーバーはスライダーから値を受け取ったら、Directional LightのIntensityを変更します。また、ミラーがあったほうが見ながら調整できるので、スライダーをいじったら3秒間ミラーを表示して、自動的に消えるようにしました。

イスの高さ調整

ワールド内の設置物で悩ましいのがイス。小さいアバターだとイスの中に埋まってしまい、大きいアバターだと空気椅子のようになってしまいます。これを解消すべく、プレイヤー自身で高さを調整できるようにしてみました。

左右の丸い部分を押すと上がったり下がったりできます。説明がないとわからない気がするんですが、かといって上とか下とか書くのもどうかなぁと。マッサージチェアのリモコンみたいな3Dモデルがあったらいいんですけどね。一応フォーカスすると説明が出ます。

スクリプト ChairUpDown.cs

public class ChairUp : UdonSharpBehaviour
{
    [SerializeField] private GameObject _target;    // イスのオブジェクト(VRC_Station)
    [SerializeField] private bool _invert = false;  // 下げるときはtrue
    [SerializeField] private float _step = 0.03f;   // 昇降間隔
    [SerializeField] private float _max = 0.3f; // 昇降最大値

    // ボタンを押した場合
    public override void Interact()
    {
        // イスのオブジェクトのオーナーを変更する(VRC Object Syncで同期させるため)
        if (!Networking.IsOwner(Networking.LocalPlayer, _target.gameObject)) {
            Networking.SetOwner(Networking.LocalPlayer, _target.gameObject); //自分をオーナーにする
        }
        // 位置の変更
        Vector3 pos = _target.transform.position;
        pos.y += (_invert) ? -_step : _step;
        if ((pos.y - transform.position.y) >= _max) pos.y = transform.position.y + _max;
        if ((transform.position.y - pos.y) >= _max) pos.y = transform.position.y - _max;
        _target.transform.position = pos;
    }
}

ボタンが押されたらイスのY座標を上下するだけの簡単なプログラムです。ただこのままだと他の人から見た位置は変わってないので、イスにVRC Object Syncを付けて、イスの高さを同期しています。

同期するときの「オーナー」という概念がよくわかってなかったんですが、最初に操作した人とは別の人がイスを操作した場合、イスがうまく動かない問題がありました。ここでオーナーを変更する処理を入れていますが、これがあることで後から触った人でも操作できるようになりました。

その他調整したこと

ライティングなんもわからん

前回に引き続き、ライティングはさっぱりわかりません。幸い今回使用したワールド環境はライティングの設定が済んでおり、ベイクされています。ですので何もいじらずに、そのまま使用することにしました。下手にいじっておかしくなるといけないので、ライティングは一切いじりません。笑 ライティングは鬼門!

でも部屋の明るさを変更したい

ライティングの設定はいじりたくないけど、照明を暗くしたり、暖かい色に変更できるといいですよね。そこで今回はPost Processingを利用することにしました。Lura’s Switch にPost Processingを変更するスイッチがあるので、これのパラメーターを変えて明るい部屋、暗い部屋、暖かい部屋、夜明けの部屋などの雰囲気を出してみました。

階段の移動がガッタガタ

実際にラスクちゃんを走りまわしてみると、階段の上り下りでガタガタに揺れる現象が起こりました。なぜこうゆうことが起こるかというと、階段を1段1段ジャンプしているためです。NavMeshはコライダーではなく、メッシュの上を歩こうとします。そのため階段に斜めのコライダーが設定されていたとしても、NavMeshは階段の表面上を忠実に歩いてしまうのです。

これを避けるために、NavMesh専用の斜め床を作成しました。通常の階段はNot Walkableに設定し、斜め床をWalkableに設定することで、段差を飛び越えるという動作をしなくなります。斜め床を透明にしておけば、NavMesh用の通路が見えることもありません。

なぜかアバターが暗い

ワールドは明るいのに、なぜかアバターだけ暗く表示される現象が起こりました。ほかのアバターに変えてもやはり暗くなってしまいます。そこでDirectional Lightを真上から照らすようにしてみました。これでアバターは明るくなりました。しかしワールドも少し明るくなり、部屋の質感が失われてしまいました。せっかく部屋のモデルの作者が最良の状態で仕上げてくれてるのに、なんかいい方法ないですかねぇ。

英語表記をどうする?

「ねこじゃらし」って英語でなんて言うんだろうと思って調べたら、green foxtail、cat toy、catnipなどいろいろと出てきました。Deeplだとcatnipでしたが、catnipで画像検索すると想像とは違うハーブの写真が出てきます。cat toyだと猫のおもちゃが出てきました。このワールドのイメージは「猫と遊ぶ」なので、Cat toyというタイトルにしました。

同期対応

NavMeshを使っていて悩ましいのが同期対応です。というのもNavMeshで動いているNPCはユーザーのローカル側で動いています。ワールド内に2人いれば、別々のNPCがそれぞれのローカルで動いていることになります。これを同期させるには、片方をNavMeshにして、もう片方を同期させなければなりません。さすがにそこまでやらなくてもいいよなぁ。

ということで、ねこじゃらしやぬいぐるみの位置と状態を同期させることにしました。これが同期されていれば、基本的には同じような場所にNPCが来るはずです。

同期方法の変更

前回は変数を同期してから全ユーザーのメソッドを”オーナーが”呼び出す、という処理で同期を実現していました。しかしこんな面倒なことをしなくてもいいことに気づきました。今回採用した方法は、変数を同期して、変数の内容が変わったら”各自が”メソッドを呼び出すという方法です。

    // 同期する変数
    [UdonSynced(UdonSyncMode.None)] public bool nowstate = false;
    private bool nowstate_old = true;

    // 変数が同期されて内容が変わったら処理を実行する
    private void Update()
    {
        if (nowstate != nowstate_old)
        {
            setactive();
            nowstate_old = nowstate;
        }
    }

これなら変数の同期1回だけで済みます。あとVRC Pickupのコンポーネントを付けている場合、Udon BehaviourのSymchronization MethodをManualにできず、前回の方法ではContinousだとうまく動かないという理由もありあます。

Quest対応

今回もQuestでプレイできるようにします。まずはそのままAndroid用にビルドしたところ、ラスクちゃんだけが見えないという現象が起こりました。足音は聞こえる、姿は見えない、なにこれ怖い!!

シェーダーなんもわからん

どうやらラスクちゃんで使用されているSunao Shaderというものが、Questには対応していないようでした。そもそもシェーダーが何かよくわかってなくて、見え方を変えるものくらいの理解しかないんですが、だったら別のシェーダーにすればいいんじゃね?ってことでやってみました。

Standardシェーダーに変更したところ、表示されるものの、今度は明るさが暗く表示されてしまいました。そこでいろいろ変えてみたところ、VRChat/Mobile/Toon Litというシェーダーだと正常に表示されました。VRChatのだし、Mobileで軽そうだし、Toon Litって何かわからないけどまあ動いてるからヨシ!

FPSもまずまず

FPSを測定したところ、Questでもそれほど重くはありませんでした。さすがライティングのベイクをしているだけはありますね。NavMeshは重いという話でしたが、ラスクちゃんがたくさん走り回っても処理落ちすることはありませんでした。あとQuestでもDynamic Boneは使えるんですね。Quest用のアバターではDynamic Boneが使えませんが、ワールド内では問題ないようです。

これから直していきたいところ

ワールドというか、プログラムの書き方ですが、変数名などの命名ルールが滅茶苦茶なんです。hoge_huga だったり HogeHuga だったり Hoge_Huga だったり、先頭に何を付ける付けないなど、スクリプトによって統一されてなくて、この辺ちゃんとしたいなぁと思います。次回からがんばる。笑

Udonsharpスクリプト まとめ

キャラクターのアニメーション制御 ThirdPersonCharacter2.cs
キャラクターのコントロール AICharacterControl2.cs
キャラクターのコントロール(固定ルートver) AICharacterControl2_Route.cs
ねこじゃらし/ぬいぐるみのON/OFF nekojarashi.cs
イスの高さ調整 ChairUpDown.cs
アバターの明るさ調整用のレシーバー DirectionalLightReceiver.cs

使用させていただいたアセット

■アバター 『ラスク』-Rusk- 作者 こまどさん、販売 あまとうさぎさん
■ワールド Room S2 ねこやま君さん
■ぬいぐるみ どうぶつぬいぐるみ kamoさん
■ぬいぐるみ ねずみのぬいぐるみ そのに Cheap_Shopさん
■ルンバ VRoomba – VRChat Udon Robot Prefab Vowgan VRさん
■ねこじゃらし 揺れるねこじゃらし【VRC向け3Dモデル】 Yotaka Labさん
■スライダー ヨドコロちゃんハプティックコントローラー 生チョコ教団さん
■スライダー 高さ無段階調整コライダー 生チョコ教団さん
■スイッチ Lura’s Switch 仮想狐のデザイン工房さん
■Video Player [VRChat] KineL式VideoPlayer (SDK3) にりらぼ(KineL)さん
■睡眠モーション 動きの多い睡眠モーションV1.0 Hearty Laboratoryさん
■時計 クリアクロック あしすと!さん

※ラスクちゃんは作者様の承諾を得てワールド内で使用しています。

追記

アップデートの記事を公開しました
【VRCHat】NPCのボール遊び機能が想像以上に大変だった件

LINEで送る
Pocket