simplestarの技術ブログ

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

自由に歩き回る:AIに身体性を与えるためのマイクロワールドの構築8

今回は(も)、ただのゲームの進捗です。
AIは関係なし。

www.youtube.com

今回はユニティちゃんがマイクロワールドを駆けまわるだけの動画です。

f:id:simplestar_tech:20171119182610j:plain

ちょっとブロックのサイズが合わなかったので、いい感じに合わせてみました。

www.youtube.com


以下は、本人用の作業メモ…

今後、ブロックを壊したり、作ったりできるようにするため、プレイヤーが注視しているオブジェクトを特定できるようにします。
まずは、注視している先のブロックをどのようにして特定するかを考える必要がありますが
ユニティちゃんを操作しながら、そのユニティちゃんが見ている先のオブジェクトを特定するのはどうでしょうか?

そのために、六角柱世界にメッシュコライダーを設定しました。

とりあえず、三人称視点で動作するユニティちゃんを配置してみたいのですが、カメラモードが新たに必要になりました。
それに、ユニティちゃんを初期配置する処理も書かなくてはなりません。
なんせ、最初は地面がありませんから…

世界が構築された後、ブロックの上に着地するユニティちゃんが出現する仕組みを作ります。
開始位置を指定して、そのブロックが完成したら、一個上の場所にポップして、ブロック作成監視作業を終えるというのがベストだと思いますが
UniRx でそういった監視に便利な機能はないものですかね。

ありました。
qiita.com

毎秒監視を続けて、着地予定のチャンクメッシュコライダーが生成されたら、そこに着地するようにしてみます。
座標から、どうやってチャンクや、そのメッシュの位置を特定するのでしょうか?

ひらめきました!
なるほど、上空からレイキャストして、最初に当たったメッシュオブジェクトとその交点座標を使えばよいのか。

これでチャンクをメッシュ側から特定するという解決方法まで示すことができました。

続いて、カメラモードの切り替え機能を加えます。
まずは現在のオブザーバーモードを右スティックの押し込み操作で無効化します。

モード切替できるようになりました。
試験していて、淵を歩いているときに移動できないバグによく遭遇することを確認しました。
こちらはサンプルシーンを参考に、物理マテリアルを摩擦なしにすることで回避できました。

続いて、不具合としては、ジャンプしたときに移動レバーを倒していたら、そちらに移動してほしいというものがあります。

		void HandleAirborneMovement()
		{
			// apply extra gravity from multiplier:
			Vector3 extraGravityForce = (Physics.gravity * m_GravityMultiplier) - Physics.gravity + transform.forward * m_ForwardAmount * 5; // Air walk
			m_Rigidbody.AddForce(extraGravityForce);

こんな感じで、空中で加速度を進行方向に与えるようにして、空中移動できるようにした。

カメラの位置が壁にめり込む問題を解決したいので、目的のカメラ位置までピボットからレイを飛ばして、ヒットした位置にカメラを置くように修正してみました。
なかなかカメラ制御がうまくいかないですね。
と思っていたら、もともとのサンプルにカメラ制御のスクリプトがあったので、そちらを使うことにしました。
完ぺきではありませんが、とても優秀でした。

注視しているブロックを壊した後、その時にメッシュを更新する必要があります。
どのようにして効率的に、素早く更新できるのか、仕組みを考える必要があります。

そこで思いついたこととして、まずレイキャストとメッシュコライダーで三角形のインデックスを取得し、その三角形インデックスから頂点インデックスを取得し、その頂点インデックスから頂点座標を取得し
オブジェクトTransform から、世界座標系の頂点座標を取得し、メッシュ生成時のルールで頂点バッファの並びから、どの頂点がブロックの中心なのかを特定できるようにします。

良く思いついたなーなんて自分でも思います。
これで、ブロック単位で選択できるようになりそうです。
効率的にメッシュを更新する操作は、破壊についてはチャンクごとに新しいメッシュを作り直す必要がありますが、作成については、一度特定のブロック位置にブロックオブジェクトを配置して
その後、チャンクのメッシュを作って置き換えるというのはどうでしょうか?
これならばブロックの作成はノータイムで行えて、都合がよく、この一部のオブジェクトを個として切り離したり、置き換えたりする機能は
今後、落盤やブロックの一部落下の現象を作り出すのに使えそうです。

加えて、カメラモードはもう一つほしいですね。
FPSモード
作業は続きます。

Unity:mecanimのホヘト

mecanim 初めての人はイロハを先に読むとわかりやすいです。
今回はもう一歩先へ進んで、三人称視点で動き回るユニティちゃんを作ります。

simplestar-tech.hatenablog.com

まずは次のリンク先からモデルデータを用意します。
unity-chan.com

Unity でユニティちゃんモデルを読み込みます。
f:id:simplestar_tech:20171118212924j:plain

三人称視点で動き回る Animator を用意したいと思うので、標準アセットをインポートします。
https://www.assetstore.unity3d.com/jp/#!/content/32351

同梱の ThirdPersonAnimatorController をユニティちゃんにセットしますと

f:id:simplestar_tech:20171118225732j:plain

このように、ブレンドツリーのタイプ 2D Freeform Cartesian に補間関係のポイントが示され
それぞれに対応した歩行アニメーションが割り当たった状態となります。

あとは、次のように Animator パラメータとパッドコントローラとの接続処理を書くことで、パッド入力で動き回るユニティちゃんが出来上がります。

        // Fixed update is called in sync with physics
        private void FixedUpdate()
        {
            // read inputs
            float h = Input.GetAxis("Horizontal");
            float v = Input.GetAxis("Vertical");
            bool crouch = Input.GetButton("Fire3");

            // calculate move direction to pass to character
            if (m_Cam != null)
            {
                // calculate camera relative direction to move:
                m_CamForward = Vector3.Scale(m_Cam.forward, new Vector3(1, 0, 1)).normalized;
                m_Move = v*m_CamForward + h*m_Cam.right;
            }
            else
            {
                // we use world-relative directions in the case of no main camera
                m_Move = v*Vector3.forward + h*Vector3.right;
            }
#if !MOBILE_INPUT
			// walk speed multiplier
	        if (Input.GetKey(KeyCode.LeftShift)) m_Move *= 0.5f;
#endif

            // pass all parameters to the character control script
            m_Character.Move(m_Move, crouch, m_Jump);
            m_Jump = false;
        }
		public void Move(Vector3 move, bool crouch, bool jump)
		{

			// convert the world relative moveInput vector into a local-relative
			// turn amount and forward amount required to head in the desired
			// direction.
			if (move.magnitude > 1f) move.Normalize();
			move = transform.InverseTransformDirection(move);
			CheckGroundStatus();
			move = Vector3.ProjectOnPlane(move, m_GroundNormal);
			m_TurnAmount = Mathf.Atan2(move.x, move.z);
			m_ForwardAmount = move.z;

			ApplyExtraTurnRotation();

			// control and velocity handling is different when grounded and airborne:
			if (m_IsGrounded)
			{
				HandleGroundedMovement(crouch, jump);
			}
			else
			{
				HandleAirborneMovement();
			}

			ScaleCapsuleForCrouching(crouch);
			PreventStandingInLowHeadroom();

			// send input and other state parameters to the animator
			UpdateAnimator(move);
		}
		void UpdateAnimator(Vector3 move)
		{
			// update the animator parameters
			m_Animator.SetFloat("Forward", m_ForwardAmount, 0.1f, Time.deltaTime);
			m_Animator.SetFloat("Turn", m_TurnAmount, 0.1f, Time.deltaTime);
			m_Animator.SetBool("Crouch", m_Crouching);
			m_Animator.SetBool("OnGround", m_IsGrounded);
			if (!m_IsGrounded)
			{
				m_Animator.SetFloat("Jump", m_Rigidbody.velocity.y);
			}

			// calculate which leg is behind, so as to leave that leg trailing in the jump animation
			// (This code is reliant on the specific run cycle offset in our animations,
			// and assumes one leg passes the other at the normalized clip times of 0.0 and 0.5)
			float runCycle =
				Mathf.Repeat(
					m_Animator.GetCurrentAnimatorStateInfo(0).normalizedTime + m_RunCycleLegOffset, 1);
			float jumpLeg = (runCycle < k_Half ? 1 : -1) * m_ForwardAmount;
			if (m_IsGrounded)
			{
				m_Animator.SetFloat("JumpLeg", jumpLeg);
			}

			// the anim speed multiplier allows the overall speed of walking/running to be tweaked in the inspector,
			// which affects the movement speed because of the root motion.
			if (m_IsGrounded && move.magnitude > 0)
			{
				m_Animator.speed = m_AnimSpeedMultiplier;
			}
			else
			{
				// don't use that while airborne
				m_Animator.speed = 1;
			}
		}

これで、ブレンドツリーのブレンドパラメータがパッド入力によって変化しますので、次のGIF動画のように、入力した通りに動き回ります。
ルートモーションを適用して移動しているので、地面と足はスリップしません。

f:id:simplestar_tech:20171118230823g:plain

もともとの Animator に設定されている、しゃがみ移動、ジャンプ移動も付けてみます。
とりあえずここまではサンプルシーンを確認するだけでチャチャっと導入できます。

f:id:simplestar_tech:20171118233733g:plain

ちょっとややこしいのがカメラ操作を行うスクリプト
こちらも標準アセットを基に作成してみました。

f:id:simplestar_tech:20171119021149g:plain

キャラクターを追いかけるようにカメラが移動してます。

カメラ操作スクリプトはこちら↓

using System;
using UnityEngine;

namespace SimpleStar
{
    public class FreeLookCam : MonoBehaviour
    {
        // This script is designed to be placed on the root object of a camera rig,
        // comprising 3 gameobjects, each parented to the next:

        // 	Camera Rig
        // 		Pivot
        // 			Camera
        [SerializeField] private Transform m_Target;            // The target object to follow
        [SerializeField] private float m_MoveSpeed = 1f;                      // How fast the rig will move to keep up with the target's position.
        [Range(0f, 10f)] [SerializeField] private float m_TurnSpeed = 1.5f;   // How fast the rig will rotate from user input.
        [SerializeField] private float m_TurnSmoothing = 0.0f;                // How much smoothing to apply to the turn input, to reduce mouse-turn jerkiness
        [SerializeField] private float m_TiltMax = 75f;                       // The maximum value of the x axis rotation of the pivot.
        [SerializeField] private float m_TiltMin = 45f;                       // The minimum value of the x axis rotation of the pivot.
        [SerializeField] private bool m_LockCursor = false;                   // Whether the cursor should be hidden and locked.
        [SerializeField] private bool m_VerticalAutoReturn = false;           // set wether or not the vertical axis should auto return
        [SerializeField] private float TiltRate = 0.52f;

        private Transform m_Cam; // the transform of the camera
        private Transform m_Pivot; // the point at which the camera pivots around

        private float m_LookAngle;                    // The rig's y axis rotation.
        private float m_TiltAngle;                    // The pivot's x axis rotation.
		private Vector3 m_PivotEulers;
		private Quaternion m_PivotTargetRot;
		private Quaternion m_TransformTargetRot;
        private Vector3 m_CameraTargetPosition;
        private float m_CameraDefaultDistance;
        

        void Awake()
        {
            m_Cam = GetComponentInChildren<Camera>().transform;
            m_Pivot = m_Cam.parent;
            // Lock or unlock the cursor.
            Cursor.lockState = m_LockCursor ? CursorLockMode.Locked : CursorLockMode.None;
            Cursor.visible = !m_LockCursor;
			m_PivotEulers = m_Pivot.rotation.eulerAngles;

	        m_PivotTargetRot = m_Pivot.transform.localRotation;
			m_TransformTargetRot = transform.localRotation;
            m_CameraDefaultDistance = m_Cam.transform.localPosition.z;
        }


        protected void Update()
        {
            FollowTarget(Time.deltaTime);
            HandleRotationMovement();
            if (m_LockCursor && Input.GetMouseButtonUp(0))
            {
                Cursor.lockState = m_LockCursor ? CursorLockMode.Locked : CursorLockMode.None;
                Cursor.visible = !m_LockCursor;
            }
        }


        private void OnDisable()
        {
            Cursor.lockState = CursorLockMode.None;
            Cursor.visible = true;
        }


        void FollowTarget(float deltaTime)
        {
            if (m_Target == null) return;
            // Move the rig towards target position.
            transform.position = Vector3.Lerp(transform.position, m_Target.position, deltaTime*m_MoveSpeed);
        }


        private void HandleRotationMovement()
        {
			if(Time.timeScale < float.Epsilon)
			return;

            // Read the user input
            var x = Input.GetAxis("HorizontalTurn");
            var y = Input.GetAxis("VerticalTurn");

            // Adjust the look angle by an amount proportional to the turn speed and horizontal input.
            m_LookAngle += x*m_TurnSpeed;

            // Rotate the rig (the root object) around Y axis only:
            m_TransformTargetRot = Quaternion.Euler(0f, m_LookAngle, 0f);

            if (m_VerticalAutoReturn)
            {
                // For tilt input, we need to behave differently depending on whether we're using mouse or touch input:
                // on mobile, vertical input is directly mapped to tilt value, so it springs back automatically when the look input is released
                // we have to test whether above or below zero because we want to auto-return to zero even if min and max are not symmetrical.
                m_TiltAngle = y > 0 ? Mathf.Lerp(0, -m_TiltMin, y) : Mathf.Lerp(0, m_TiltMax, -y);
            }
            else
            {
                // on platforms with a mouse, we adjust the current angle based on Y mouse input and turn speed
                m_TiltAngle -= y*m_TurnSpeed;
                // and make sure the new value is within the tilt range
                m_TiltAngle = Mathf.Clamp(m_TiltAngle, -m_TiltMin, m_TiltMax);
            }

            // Tilt input around X is applied to the pivot (the child of this object)
            m_PivotTargetRot = Quaternion.Euler(m_TiltAngle, m_PivotEulers.y , m_PivotEulers.z);

            float cameraDistanceRate = (1.0f -  (m_TiltAngle > 0 ? (TiltRate * m_TiltAngle / m_TiltMax) : TiltRate * (m_TiltAngle / -m_TiltMin)));
            float cameraTargetDistance = cameraDistanceRate * m_CameraDefaultDistance;

            if (m_TurnSmoothing > 0)
			{
                Vector3 cameraLocalPos = m_Cam.transform.localPosition;
                cameraLocalPos.z = Mathf.Lerp(cameraLocalPos.z, cameraTargetDistance, m_TurnSmoothing);
                m_Cam.transform.localPosition = cameraLocalPos;

                m_Pivot.localRotation = Quaternion.Slerp(m_Pivot.localRotation, m_PivotTargetRot, m_TurnSmoothing * Time.deltaTime);
				transform.localRotation = Quaternion.Slerp(transform.localRotation, m_TransformTargetRot, m_TurnSmoothing * Time.deltaTime);
			}
			else
			{
                Vector3 cameraLocalPos = m_Cam.transform.localPosition;
                cameraLocalPos.z = cameraTargetDistance;
                m_Cam.transform.localPosition = cameraLocalPos;

                m_Pivot.localRotation = m_PivotTargetRot;
				transform.localRotation = m_TransformTargetRot;
			}
        }
    }
}

標準アセットのサンプル-シーンと同じく
Rig オブジェクトにこのスクリプトを割り当てますが、構成として、子供に Pivot、孫に Camera が来る構成にしなくてはなりません。
こちらのスクリプトを使う際はお気を付けください。

Unity の Humanoid のアニメーションは等身が異なるモデルでも問題なく流用できるので、いいですね。
リグを入れるデザイナーの方は、ぜひ Humanoid の適用を意識してボーンを設定していただけると嬉しいです!

次は、マイクロワールドにこのユニティちゃんを登場させてみます。

おまけ:公式アセットの全3キャラについて、同じ処理を行った結果(3人ともまったく同じ動きをしています。)
f:id:simplestar_tech:20171119084748g:plain

ユニティちゃんライセンス

この作品はユニティちゃんライセンス条項の元に提供されています

AIに身体性を与えるためのマイクロワールドの構築7

進捗を報告しておきます。

www.youtube.com

今後の予定ですが

主要なブロックとして








溶岩

あたりを用意して、まずはプレイヤーが操作できるキャラクターをコントロールして
ブロックを破壊したり、ブロックを追加したりできるようにしてみようと思います。

AIの実装はある程度ブロックの種類が決まり、法則性などができてきたら考え始めようと思います。

追記:
同僚に相談したところ、現実世界とは異なる変化の激しい自然界の掟のようなものがあると
ゲームが面白くなるかもしれないとのことだった。

例えば太陽が3つあって、それぞれが違う周期で移動する場合、そこに作られる自然界はどういったものとなり
どういった生態系が生まれるのか?
地殻変動が激しく、日をまたぐたびに大陸の移動が起きて、地の利が変化していく、など

地殻変動というより、川により大地が削られて渓谷となったり、海が干上がって塩湖となったり、作物が育たなかったり
豊かに実りのある森が雨の降る地域に広がったりと、地の利がその場所場所で生まれるというのは、ありだなと感じている。
火山の発生や、海底や地面の隆起、陥没なども、地殻変動を模してあってもよいかもしれない。

これらはすべて、まずモデル側に組み込み、その計算結果を Unity 側のビューアで更新表示するようにしていこうと思います。

追記2:
これまた、尊敬している方からアドバイスをもらったのですが
マテリアル数を増やすとパフォーマンスが落ちてくるはず、とのことで
処理の内容的には、マテリアルの切り替えは一度レンダリングパスを止めてしまうからなので、とのことで
パフォーマンスを上げたくなったら、テクスチャを一つに統合して、マテリアルを一つにしてマテリアル切り替えをなくし
マテリアル切り替えをテクスチャ座標の変更で見せかけると高速化するとのこと

なるほど、パフォーマンス向上のネタとしてストックしておきます。

GitHubのREADME.mdファイルをプレビューしながら編集するならATOM

以前の記事ですが
simplestar-tech.hatenablog.com

おまけとして、README.md のマークダウン方式の記入方法を示します
と書きましたけど、エディタを紹介していませんでしたね。

.md ファイルをプレビューしながら編集するなら ATOM です。

atom.io

ダウンロード&インストールして、README.md ファイルを ATOM で開きます。
ショートカットコマンド Ctrl + Shift + M キーを押します。(メインメニュー→Packages→Markdown Preview→Toggle Preview のショートカットです。)
すると、次のように ATOM でプレビューを確認しながらマークダウン入力を編集することができるようになります。

f:id:simplestar_tech:20171112151951j:plain

.md ファイルの体裁を整えてからアップしたいと思っているそこのあなた
ATOM 使いましょう。

以上、ツールの紹介でした。

AIに身体性を与えるためのマイクロワールドの構築6

まずは、どこまで移動しても4096ブロック進んだら、ループができる仕組みを作ります。

f:id:simplestar_tech:20171111185410j:plain

チャンク単位で3次元配列を保持させます。

private int[][][] _chunkData = null;

持たせました!
そのチャンクも2次元配列で保持させます。

Chunk[][] _chunkArray = null;

持たせました!

効率的にメモリを利用するため
デザインパターンの一つ Proxy パターンに開放機能を付けます。

具体的には、観測者の位置と有効視界範囲設定から、自分に近いチャンクごとのキューを作り
キューから取り出したチャンクごとに別スレッドで、チャンクファイルデータのメモリ読み込み
読み込み終えたら、六角柱メッシュの構築を行うようにします。

観測者の位置情報と、観測者の位置の更新が必要になりました。

f:id:simplestar_tech:20171105224430g:plain
移動する観測者を用意しました!

具体的な観測者の移動スクリプトはこちら

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace SimpleStar
{
    [RequireComponent(typeof(Camera))]
    public class Observer : MonoBehaviour
    {
        // Use this for initialization
        void Start()
        {
            _camera = this.GetComponent<Camera>();
        }

        // Update is called once per frame
        void Update()
        {
            float hMove = Input.GetAxis("Horizontal");
            float vMove = Input.GetAxis("Vertical");
            float hTurn = Input.GetAxis("HorizontalTurn");
            float vTurn = Input.GetAxis("VerticalTurn");
            float hStep = Input.GetAxis("DPAD-Horizontal");
            float vStep = Input.GetAxis("DPAD-Vertical");

            transform.Translate(Vector3.right * hMove);
            transform.Translate(Vector3.forward * vMove);

            transform.Rotate(Vector3.up * hTurn, Space.World);
            transform.Rotate(Vector3.right * vTurn);

            transform.Translate(Vector3.right * hStep);
            transform.Translate(Vector3.up * vStep, Space.World);
        }

        Camera _camera = null;
    }
}

観測者のチャンクが切り替わったら、チャンクキューから有効視界範囲外に出たチャンクを除外し、開放チャンクキューに入れます。

まずは、観測者がどのチャンクに内包されているのかを特定する計算が必要です。
そもそもチャンクの内側の判定はどのように行われるべきでしょうか?

チャンク作成時に、チャンクに緯度と経度情報を埋め込みます。
まずは、観測者のワールド座標から経度と緯度を求めます。

ブロックとチャンク、そして、ワールド座標系の関係を調べました。

f:id:simplestar_tech:20171106082150j:plain

小さく映っているのは、直径 1 m の球体です。
六角形は一辺 1 m の正三角形 6つによって構成されているため、まず x 方向は ルート3 * 16 ずつチャンクが切り替わります。
単純に x 方向のチャンクインデックスは x / (ルート3 * 16) で出てきます。
同じく、単純に z 方向のチャンクインデックスは z / (1.5 * 16) で出てきます。(互いに嚙合わせるように重ねているので、ここは単純な 2 ではありません)
ところで x, z が負の値だった場合はどうしましょうか?
x, z が最大チャンク数を超えた場合もどうしましょうか?
世界は 256 チャンクで周回しますので、ここは単純に負だったら (ルート3 * 16) * 256, (1.5 * 16) * 256 を足したり
チャンクインデックスが 256 以上だったら、単純に 256 を引いたりしましょう。

具体的なコードは次の通りです。

        void Update()
        {
            float x = _observer.transform.position.x;
            float z = _observer.transform.position.z;

            float root3 = Mathf.Sqrt(3.0f);
            int chunkIndexX = 0;
            int chunkIndexZ = 0;

            chunkIndexX = Mathf.FloorToInt(x / (root3 * (1 << 4)));
            while (0 > chunkIndexX)
            {
                chunkIndexX += (1 << 8);
            }
            chunkIndexX %= (1 << 8);

            if (lastchunkIndexX != chunkIndexX)
            {
                lastchunkIndexX = chunkIndexX;
                Debug.Log("chunkIndexX = " + chunkIndexX);
            }

            chunkIndexZ = Mathf.FloorToInt(z / (1.5f * (1 << 4)));
            while (0 > chunkIndexZ)
            {
                chunkIndexZ += (1 << 8);
            }
            chunkIndexZ %= (1 << 8);

            if (lastchunkIndexZ != chunkIndexZ)
            {
                lastchunkIndexZ = chunkIndexZ;
                Debug.Log("chunkIndexZ = " + chunkIndexZ);
            }
        }

動作的に問題なさそうでした。

続いて、自分に近い順番でチャンク配列を作り、非同期で頂点リスト、インデックスリスト、UVリストを作成するところまで作りました。
しかし、やっぱりUnityの機能でメッシュを作る作業はメインスレッドで動かさなければならない様子。
ここは UniRx でイベントを定期的に発行して、少しずつ必要な処理をメインスレッドでこなしていくように作っていきます。

ということで UniRx 導入について、公式ページを参照
github.com

Unity の Asset Store でそのままインポートするのが早いかな
https://www.assetstore.unity3d.com/jp/#!/content/17276

Plugins>UniRx>Examples に13例ほど用意されているので、まずはここで書式と使い方を学ぶのが良いのではないでしょうか。

イベントを定期的に発行する最も単純なコードはこちら

using UniRx;

        public void Async(long a)
        {
            Observable.Start(() => {
                // asynchronous work
                return a;
            }) 
            .ObserveOnMainThread()
            .Subscribe(x => Debug.Log(x));
        }

        private void Awake()
        {
            Observable.Interval(System.TimeSpan.FromMilliseconds(100))
                .Subscribe(_ => {
                    long t = 0;
                    this.Async(t);
                });

永遠に 100 ms 間隔で this.Async(t); をメインスレッドから呼び出し
Async(long a) 関数内にて別スレッドで実行する{ return a; }の処理を開始して
その別スレッドでの作業完了と同時に、メインスレッドに戻って Debug.Log(x); を実行するというコード

これを使って、定期的にメッシュ更新対象のチャンクキューからデキューして得たチャンクについて
別スレッドでファイルロードとメッシュ配列の生成を行い
メインスレッドに戻って、メッシュの生成を行います。

メインスレッドに戻って、チャンクのメッシュの生成を行いたいけど、チャンクにも 16 個のメッシュがあるから
16フレームに分割して1フレームにメッシュを更新したい、という要望を UniRx で実現する方法を思いついた。

        public int UpdateMeshSync(int h = 0)
        {
            int works = 0;
            if (0 == h)
            {
                for (int i = 0; i < _meshObjects.Length; i++)
                {
                    if (_meshObjects[i]._needUpdateMesh)
                    {
                        ++works;
                    }
                }
            }

            while (!_meshObjects[h]._needUpdateMesh)
            {
                ++h;
                if (_meshObjects.Length == h)
                {
                    break;
                }
            }
            if (_meshSources.Length > h && 0 < _meshSources[h]._subMeshCount)
            {
                Mesh mesh = new Mesh();

                mesh.vertices = _meshSources[h]._vertices;
                mesh.uv = _meshSources[h]._uvs;
                mesh.subMeshCount = _meshSources[h]._subMeshCount;
                for (int i = 0; i < mesh.subMeshCount; i++)
                {
                    if (null != _meshSources[h]._indices[i])
                    {
                        mesh.SetIndices(_meshSources[h]._indices[i], MeshTopology.Triangles, i);
                    }
                }
                mesh.RecalculateNormals();
                MeshRenderer renderer = _meshObjects[h]._meshObject.GetComponent<MeshRenderer>();
                renderer.materials = _meshSources[h]._materials;
                _meshObjects[h]._meshObject.GetComponent<MeshFilter>().sharedMesh = mesh;
                _meshObjects[h]._needUpdateMesh = false;
            }
            ++h;
            if (_meshSources.Length > h)
            {
                Observable.NextFrame().Subscribe(_ => {
                    this.UpdateMeshSync(h);
                });
            }
            return works;
        }

これぞ Simplestar 流、for 文の要素処理を frame 分割する負荷分散化 UniRx コーディング…はずかしい、だれでも考えることでしたね。

続いて、すでにチャンクを作った場所には、チャンクオブジェクトを構築しないような仕組みを入れます。
そして、一定距離離れたらそのチャンクを削除するようにします。

有効視界内に新たに入るチャンクを、自分に近いチャンクごとのキューに加えて、未読み込みのチャンクについて、チャンクファイルデータの読み込みとメッシュの構築を走らせます。
開放チャンクキューに入っているチャンクを次のアクティブチャンクの切り替え時に取り出して、その時に有効視界範囲外のチャンクになっていたら、チャンクデータを開放します。
これにより、チャンクデータは必要な範囲までしかメモリに読み込まれなくなります。

ここまで作ってみましたが、スパイクが発生したときに、問題を特定しづらいという UniRx の落とし穴に気付きました。
コーディングは楽ですが、いったい何が原因でフレームレートが落ちるのか、隠れてしまうのです。

一度、UniRx を使わない単純なコードに戻して、重たい処理を見つけました。
次の関数のように、自分のチャンクを中心に周囲のチャンクについて読み込みをかけるという処理が重かったようです。

        // これが重い!
        private void UpdateUnityWorld(Chunk chunk, Transform ts)
        {
            int tsx = Mathf.FloorToInt(ts.position.x / (_root3 * Chunk.Width));
            int tsz = Mathf.FloorToInt(ts.position.z / (1.5f * Chunk.Depth));
            ShowChunk(chunk, tsx, tsz, 0, 0);
            for (int radius = 1; radius < _uniyWorldShowRadius; radius++)
            {
                int ofzl = -radius;
                for (int ofx = -radius; ofx < radius; ofx++)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, ofx)][_RemapZ(_myChunk.Latitude, ofzl)];
                    ShowChunk(c, tsx, tsz, ofx, ofzl);
                }
                int ofxh = radius;
                for (int ofz = -radius; ofz < radius; ofz++)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, ofxh)][_RemapZ(_myChunk.Latitude, ofz)];
                    ShowChunk(c, tsx, tsz, ofxh, ofz);
                }
                int ofzh = radius;
                for (int ofx = radius; ofx > -radius; ofx--)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, ofx)][_RemapZ(_myChunk.Latitude, ofzh)];
                    ShowChunk(c, tsx, tsz, ofx, ofzh);
                }
                int ofxl = -radius;
                for (int ofz = radius; ofz > -radius; ofz--)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, ofxl)][_RemapZ(_myChunk.Latitude, ofz)];
                    ShowChunk(c, tsx, tsz, ofxl, ofz);
                }
            }
        }

やっていることはチャンクごとのオブジェクトの作成と非同期読み込みの開始ですけど、確かに、これは重くなりますね。
そこで、順番にチャンクごとの処理をキューに積んで、これを処理するようにしていこうと思います。

ということで、更新すると次のコード

        private void _UpdateUnityWorld(Chunk chunk, Transform ts)
        {
            _createChunkMeshTask.Clear();
            int transformX = Mathf.FloorToInt(ts.position.x / (_root3 * Chunk.Width));
            int transformZ = Mathf.FloorToInt(ts.position.z / (1.5f * Chunk.Depth));
            _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, chunk, transformX, transformZ, 0, 0));
            for (int radius = 1; radius < _uniyWorldShowRadius; radius++)
            {
                int offsetZlow = -radius;
                for (int offsetX = -radius; offsetX < radius; offsetX++)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, offsetX)][_RemapZ(_myChunk.Latitude, offsetZlow)];
                    _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, c, transformX, transformZ, offsetX, offsetZlow));
                }
                int offsetXHigh = radius;
                for (int offsetZ = -radius; offsetZ < radius; offsetZ++)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, offsetXHigh)][_RemapZ(_myChunk.Latitude, offsetZ)];
                    _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, c, transformX, transformZ, offsetXHigh, offsetZ));
                }
                int offsetZHigh = radius;
                for (int offsetX = radius; offsetX > -radius; offsetX--)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, offsetX)][_RemapZ(_myChunk.Latitude, offsetZHigh)];
                    _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, c, transformX, transformZ, offsetX, offsetZHigh));
                }
                int offsetXLow = -radius;
                for (int offsetZ = radius; offsetZ > -radius; offsetZ--)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, offsetXLow)][_RemapZ(_myChunk.Latitude, offsetZ)];
                    _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, c, transformX, transformZ, offsetXLow, offsetZ));
                }
            }
        }

            Observable.IntervalFrame(1)
                .Subscribe(_ =>
                {
                    Chunk lastChunk = _myChunk;
                    _myChunk = GetMyChunk(_observer.transform);
                    if (lastChunk != _myChunk)
                    {
                        _UpdateUnityWorld(_myChunk, _observer.transform);
                    }

                    while (0 < _createChunkMeshTask.Count)
                    {
                        CreateChunkMeshSourcesTask task = _createChunkMeshTask.Dequeue();
                        if (task.Run())
                        {
                            break;
                        }
                    }

                    while (0 < ChunksToUpdateMesh.Count)
                    {
                        UnityChunk chunk = ChunksToUpdateMesh.Dequeue();
                        if (null != chunk)
                        {
                            if (0 < chunk.SetRenderMesh())
                            {
                                break;
                            }
                        }
                    }
                });


これで、スパイクがなくなりました。
ということで、オープンワールドのように、チャンクデータをすべて記録しつつも、ほぼ無限に続く世界が完成しました。

www.youtube.com

そしていよいよ、ブロック同士の相互作用の話です。
ここは"妖精"にまかせることにします。

…あの、まじめに答えています。
ここで妖精とはルールに従って現象に働きかける見えない存在のことを指します。

私たちも電磁気力、重力、光が直進、反射する現象などを観測しますが
すべて見えない妖精によって作られた結果と考えることができます。
同様の仕組みをマイクロワールドにも取り入れるという方針です。

妖精は対象とするブロックの種類、圧力、温度、主成分の種類と割合と、その周囲のブロックのそれらの情報を得て、決められたルールの結果を書き込みます。
例えば、周囲よりも温度の高い空気ブロックは主成分の水を上のブロックへ渡し
湖は接している空気ブロックに主成分として水を与え続けることになり、そのうち水の割合が減って湖の水ブロックは霧→水を限界まで含んだ空気ブロック→乾いた空気ブロックに変化していきます。
時間をおいて眺めてみると、湖が枯渇したかのような現象が確認できることになります。

このように妖精がブロックに訪れると、ルールに従ってブロックが変化することで世界は形を変えていきます。

デザインパターンの Visitor パターンが、妖精がブロックを巡回していく構造に利用できるかもしれませんね。
このシステムの狙いは、ブロックには 32bit という非常に小さなデータ単位しか持たせないという点にあります。

この妖精っていうやつは、次の記事で実装していこうと思います。

Unity:Xbox Elite ワイヤレス コントローラーの入力を処理する

ゲームパッドを使って Unity のキャラクターを動かしたい場合に、ボタンやジョイスティックの値を C# コードで受け取って処理しますが
現実のボタンやスティックの方向と、ゲームプログラム内でのIDやfloat値との関係は調べないとわかりません。

調べた結果、次の通りでした。
直感的に参照できるようにメモメモ…( ..)φ

f:id:simplestar_tech:20171105174300j:plain

f:id:simplestar_tech:20171105201411j:plain

f:id:simplestar_tech:20171105174327j:plain

f:id:simplestar_tech:20171105174338j:plain

動作チェック用のスクリプト、シーンはこちらの無料アセットを使わせてもらいました。
https://www.assetstore.unity3d.com/jp/#!/content/43621

重要な確認操作とスクリプトコードだけ以下に示します。

Unity のメインメニューの Edit > Project Settings > Input を開きます。
Inspector を確認すると、次のように X axis → "Axis 1" という紐づけが行われています。
f:id:simplestar_tech:20171105175953j:plain

あとは、文字列 "Axis 1" で次のようにジョイスティックの値を取得します。

float xAxis = Input.GetAxis("Axis 1");

ボタンは Input 設定が無くても次のように KeyCode から押されたことを取得できます。

        if (Input.GetKey(KeyCode.JoystickButton0) == true)
            button0Value.text = "pressed";
        else
            button0Value.text = "";

ちなみに、Unity が最初から設定している Input の文字列一覧を確認したところ、次の通りでした。

Horizontal → X axis
Vertical → Y axis
Fire1 → JoystickButton0 (A ボタン)
Fire2 → JoystickButton1 (B ボタン)
Fire3 → JoystickButton2 (X ボタン)
Jump → JoystickButton3 (Y ボタン)
Submit → JoystickButton0 (A ボタン)
Cancel → → JoystickButton1 (B ボタン)

右スティックや、十字キー、左右のトリガーなどの float 入力値を処理できるようにするためには
自分で Input の設定を追加していく必要がありますね。

みなさんびっくりしたことに、Y axis には直感と反対の符号が付いていたと思います。
そこで Unity の初期設定の Vertical は、符号を反転するための Invert にチェックを入れています。
上図の符号で間違いありません。

Unityのゲーム実装の基礎知識

ちょっと大きめのゲームを作る可能性が出てきたので、ソフトウェア開発の基礎的な部分をおさらいしておこうと思います。

これまで仕事や趣味で何度もデザインパターンというものに触れてきましたが
本を読んで理解したことが正しいかも含めて、素早く確認するページを探しました。

ここ、いい感じにまとめてくれていますね。
qiita.com

23種類全部確認してみました。
interpreter パターンだけ、具体例が示されていなくて、これだけでは理解が難しいかも?
数式の構文解析のようなものを作ったとき、新しい構文が後から追加されても柔軟に対応できる便利な仕組み、と覚えておくと良いと思います。

さて、これらのデザインパターンは今作っている、ゲームのモデル部分の実装設計で役立つでしょう。
まとめていただいてありがとうございました。

次に Unity で非同期処理を行う

Reactive Extensions

UniRx のことをおさらい

qiita.com

こちらも実装の参考になりそう
一通り読んでみます。

LINQ の概念を使って説明しているところがあるので、LINQ についてもおさらいします。

C# 3.0 から使える LINQ という言語クエリ
要は、foreach でリストから条件に合うリストを取り出す処理をコード量を減らして書ける文法のこと

qiita.com

慣れてない人は LINQ を使ってみた、とか口にするけど
C# のリストを操作しているだけなので、技術で優れた点はないです。

そして、 UniRx とは何なのかというと、従来はユーザー入力などのイベントが来たら
イベントを処理するべきか決めるコードを書く必要があったが、イベントを発火させる条件を与えるように書けるようになるので
イベントを受け取った後の処理が複雑にならない。
これは便利、ということ。