うら干物書き

ゲームを作りたい!

【Unity】空間がループするシーンを作る

 諸君は知っているか!(黒岩省吾風に)
 地球が丸いということを!

 はい、というわけで今回は空間がループするシーンを作っていきましょう。
 空間がループするとは?
 地球上で飛行機を使って東に進み続けていけば、いずれ西から出発地点にたどり着くことでしょう。
 ゲーム上でも+x方向に進み続けていれば、-x方向から出てくるようなものを作ってみましょう。

 まあ、ループそのものは座標を一定範囲内で繰り返せばいいだけなので簡単に作れるとは思います。
 ただ、そのままではいろいろ問題が発生します。
 それを何とか良い方向にもっていくという企画です。

 更に付け加えるならば、以前作ったシェーダーを何かしらに使えないかというところからこの企画を始めているので、正直、結構無茶があります。ご了承をば。


この記事にはUnity2017.3.0f3を使用しています。

下準備 オブジェクトとカメラの制御処理を作る

 まず本題に入る前に、オブジェクトをxz平面上で移動制御させるコンポーネントそのオブジェクトを追尾するカメラのコンポーネントを作成しましょう。
 ゼロから作るのも面倒くさいので、前作ったものをそのまま持ってきましょう。

www.urablog.xyz

ObjectController.cs

using UnityEngine;

[RequireComponent( typeof( Rigidbody ) )]
public class ObjectController : MonoBehaviour
{
    [SerializeField]
    private float   m_moveSpeed     = 0.0f;
    [SerializeField]
    private float   m_turnSpeed     = 0.0f;
    [SerializeField]
    private float   m_jumpForce     = 0.0f;

    [Header("Camera")]
    [SerializeField]
    private CameraController    m_camera    = null;
    [SerializeField]
    private float   m_camraTurnSpeed    = 0.0f;

    private Rigidbody   m_rigidbody     = null;


    private void Awake()
    {
        m_rigidbody = GetComponent<Rigidbody>();
    }

    private void Update()
    {
        ControlCamera();
        ControlObject();
    }

    private void ControlObject()
    {
        Vector3 moveDir     = Vector3.zero;

        Vector3 forwardDir  = m_camera.transform.forward;
        Vector3 rightDir    = m_camera.transform.right;

        if( Input.GetKey( KeyCode.UpArrow ) )
        {
            moveDir += forwardDir;
        }
        if( Input.GetKey( KeyCode.DownArrow ) )
        {
            moveDir -= forwardDir;
        }
        if( Input.GetKey( KeyCode.RightArrow ) )
        {
            moveDir += rightDir;
        }
        if( Input.GetKey( KeyCode.LeftArrow ) )
        {
            moveDir -= rightDir;
        }

        if( moveDir.sqrMagnitude > Mathf.Epsilon )
        {
            moveDir = moveDir.normalized;
            Turn( moveDir );
            Move( moveDir );
        }

        if( Input.GetKeyDown( KeyCode.Space ) )
        {
            Jump();
        }
    }

    private void ControlCamera()
    {
        if( Input.GetKey( KeyCode.A ) )
        {
            m_camera.Turn( m_camraTurnSpeed * Time.deltaTime );
        }
        if( Input.GetKey( KeyCode.D ) )
        {
            m_camera.Turn( -m_camraTurnSpeed * Time.deltaTime );
        }
    }

    private void Move( Vector3 i_forward )
    {
        Vector3 delta       = i_forward * m_moveSpeed * Time.deltaTime;
        Vector3 targetPos   = transform.position + delta;
        m_rigidbody.MovePosition( targetPos );
    }

    private void Turn( Vector3 i_forward )
    {
        Quaternion  toRot   = Quaternion.LookRotation( i_forward );
        Quaternion  fromRot = transform.rotation;

        float delta             = m_turnSpeed * Time.deltaTime;
        Quaternion targetRot    = Quaternion.RotateTowards( fromRot, toRot, delta );

        m_rigidbody.MoveRotation( targetRot );
    }

    private void Jump()
    {
        // えっ、このままじゃ空中でもジャンプできちゃうって!?
        // 仕様だよ!

        m_rigidbody.velocity    = Vector3.zero;
        
        Vector3 jumpVec         = Vector3.up * m_jumpForce;
        m_rigidbody.AddForce( jumpVec, ForceMode.VelocityChange );
    }

} // class ObjectController

CameraController.cs

using UnityEngine;

public class CameraController : MonoBehaviour
{
    [SerializeField]
    private Transform   m_target    = null;
    [SerializeField]
    private float       m_speed     = 0.0f;
    [SerializeField]
    private float       m_waitRange = 0.0f;

    public  Transform Target
    {
        get { return m_target; }
    }

    private Transform   m_cameraTransform   = null;
    private Transform   m_pivot = null;

    private void Awake()
    {
        SetCameraTransform();
    }

    private void LateUpdate()
    {
        UpdateCamera();
    }

    public void Turn( float i_angle )
    {
        transform.rotation *= Quaternion.AngleAxis( i_angle, Vector3.up );
    }

    private void UpdateCamera()
    {
        if( Target == null )
        {
            return;
        }

        Vector3 toTargetVec = Target.position - transform.position;
        float sqrLength     = toTargetVec.sqrMagnitude;

        // 設定した範囲内なら更新しない。
        // magnitudeはルート計算が重いので、二乗された値を利用しよう。
        if( sqrLength <= m_waitRange * m_waitRange )
        {
            return;
        }

        // ターゲットの位置から指定範囲内ギリギリの位置を目指すようにするよ。
        Vector3 targetPos   = Target.position - toTargetVec.normalized * m_waitRange;

        float deltaSpeed    = m_speed * Time.deltaTime;
        transform.position  = Vector3.MoveTowards( transform.position, targetPos, deltaSpeed );
    }

    [ContextMenu( "ApplyTarget" )]
    private void ApplyForceTarget()
    {
        if( Target == null )
        {
            return;
        }

        transform.position = Target.position;

        SetCameraTransform();
        if( m_cameraTransform == null )
        {
            return;
        }

        m_cameraTransform.transform.LookAt( Target );
    }

    private void SetCameraTransform()
    {
        Camera camera   = GetComponentInChildren<Camera>();
        Debug.AssertFormat( camera != null, "カメラが無ぇよ!" );
        if( camera == null )
        {
            return;
        }

        m_cameraTransform   = camera.transform;
        m_pivot             = m_cameraTransform.parent;
    }

} // class CameraController

f:id:urahimono:20180202132350g:plain

 これで下準備は完了です。

本題 座標を一定範囲内で繰り返す

 次に座標を繰り返す処理を作成していきましょう。
 冒頭でも言った通り、この処理は簡単に作ってしまいましょう。
 空間の範囲が 0 ~ 3 の場合は、3を超えた場合は3引いてやればいいですし、0を下回った場合は3足してやるような処理を作成していきましょう。

 ではまずループする空間を管理するコンポーネントを作成しましょう。

LoopedField.cs

using UnityEngine;

public class LoopedField : MonoBehaviour
{
    // ループフィールドのサイズ(今回はY軸は使わないようなものだけど)。
    [SerializeField]
    private Vector3 m_fieldSize = Vector3.zero;

    // ループのフィールドの管理にはBoundsを使用する。
    // これはフィールドの中心が(0,0,0)の地点でない場合の最小位置と最大位置の計算を、Boundsクラスを使って手を抜きたいため。
    private Bounds  m_bounds    = new Bounds();
    public  Bounds  FieldBounds
    {
        get
        {
            return m_bounds;
        }
    }

    // ループする各オブジェクトがアクセスしたいので、シングルトンにしよう。
    public static LoopedField Instance
    {
        get;
        private set;
    }

    private void Awake()
    {
        if( Instance != null )
        {
            Destroy( this );
            return;
        }

        Instance = this;
        OnValidate();
    }

    private void OnDestroy()
    {
        if( Instance == this )
        {
            Instance = null;
        }
    }

    private void OnValidate()
    {
        // エディタ上でフィールド範囲が変わってしまった際の対応。
        m_bounds    = new Bounds( transform.position, m_fieldSize );
    }

    private void OnDrawGizmos()
    {
        // ビュー上でループする範囲が見えるようにしておこう。
        Gizmos.color = Color.green;
        Gizmos.DrawWireCube( transform.position, m_fieldSize );
    }

    public Vector3 ComputePosition( Vector3 i_position )
    {
        Vector3 pos = i_position;

        // ループ用の位置計算処理
        // Mathf.Repeat()を使えばもうコードが綺麗になるかもしれないけど、別にいいや。

        if( i_position.x < m_bounds.min.x )
        {
            pos.x += m_bounds.size.x;
        }
        if( i_position.x > m_bounds.max.x )
        {
            pos.x -= m_bounds.size.x;
        }
        if( i_position.z < m_bounds.min.z )
        {
            pos.z += m_bounds.size.x;
        }
        if( i_position.z > m_bounds.max.z )
        {
            pos.z -= m_bounds.size.x;
        }

        return pos;
    }

} // class LoopedField

f:id:urahimono:20180202132451p:plain

 空間の範囲の管理をわざわざコンポーネントにする必要があるのかとは思いますがね。
 別に固定値でもいいですし、ScriptableObjectを使って値だけ抜き出してもいいんですが……。
 コンポーネントにしてHierarchy上に置くメリットしては、中心地の指定をTransformで指定すればいいので楽だとか、OnDrawGizmos()を使うことでギズモとしてループ範囲が視覚的に見えるようになることでしょうか。
 まあ、GameObjectとして利用しないものをHierarchy上に置くのは、処理面でいまいちでしょうけど。

f:id:urahimono:20180202132516p:plain

 とりあえず今回は、コンポーネントにしておきます。

 次にループするオブジェクト側のコンポーネントを作成しましょう。

LoopedObject.cs

using UnityEngine;

public class LoopedObject : MonoBehaviour
{
    private void LateUpdate()
    {
        // ループ管理システムがあれば、位置の計算をしてもらおう。
        // 通常の操作処理をUpdate()で行っているので、とりあえずLateUpdate()で処理しているよ。
        if( LoopedField.Instance != null )
        {
            transform.position = LoopedField.Instance.ComputePosition( transform.position );
        }
    }

} // class LoopedObject

f:id:urahimono:20180202132535p:plain

 では動かしてみましょうか。

f:id:urahimono:20180202132548g:plain

 うん、シーンビューで見る限り一定範囲内にオブジェクトがとどまるようになっていますね。

問題点 自分がループする場合

 さてシーンビューではよかった。
 ただ、ゲームビューではどうなっているのか。カメラで描画している画面を見てみましょう。

f:id:urahimono:20180202132625g:plain

 うん、これはGif画像が悪いわけではなく、背景やらが何もないのが悪い。
 とりあえずフィールド上に何かオブジェクトを置いて、移動処理がわかるようにしましょう。

f:id:urahimono:20180202132702g:plain

 これで問題がわかりやすくなりましたね。
 カメラが置いてかれていますね。
 ループはしているようなんですが、ループした瞬間にカメラの追尾速度的な問題で画面がよくわからんことになっています。

 この問題の解決としては、追尾速度を上げるというシンプルな解決方法を使いましょう。

f:id:urahimono:20180202132743p:plain
f:id:urahimono:20180202132753g:plain

 うん、いいんじゃないでしょうか。

問題点 他人がループする場合

 自分が移動制御を行うオブジェクトはこれでいいんですが、他のオブジェクトがループしているものはどう見えるのでしょうか。
 まず自動で移動制御をおこなうコンポーネントをパッと作ってしまいましょう。

AutoObjectController.cs

using UnityEngine;

[RequireComponent( typeof( Rigidbody ) )]
public class AutoObjectController : MonoBehaviour
{
    [SerializeField]
    private Vector3 m_move  = Vector3.zero;

    private Rigidbody m_rigidbody = null;

    private void Awake()
    {
        m_rigidbody = GetComponent<Rigidbody>();
    }

    private void Update()
    {
        Vector3 delta       = m_move * Time.deltaTime;
        Vector3 targetPos   = transform.position + delta;
        m_rigidbody.MovePosition( targetPos );
    }

} // class AutoObjectController

f:id:urahimono:20180202132839p:plain

 ではこのオブジェクトをゲームビュー上で見てみましょう。

f:id:urahimono:20180202132858g:plain
f:id:urahimono:20180202132928p:plain

 うん、突然出てきてビックリするよね。
 ループ先の座標を映していると、オブジェクトが突然出てくるので、ループしているのではなく瞬間移動しているだけにしか見えないですね。
 コード的には確かに瞬間移動させてはいるんですが、ゲーム上ではループしているように見せかけねばなりません。
 何か策を用いましょう。

真の本題 ダミーのオブジェクトを描画してループっぽくする

 ループはしているけど、問題は見た目。
 瞬間移動が見た目で分からないようにする必要がありそうだ。
 ループするオブジェクトがフィールドサイズ分ずれたところに本体とは別に描画されていれば見た目上よくなるのではないだろうか。

 ふっふっふ、ついに使う時が来た。
 あの作ってみたはいいが何に使えばいいかわからないあのシェーダーを!

www.urablog.xyz
www.urablog.xyz

 ……まぁ、このシェーダーを使うために立てた企画なので、使うタイミングが来るに決まっているのですが。

 とりあえず、このシェーダーを使って、オブジェクトの前後左右八方向のループフィールド分
ずらした位置にダミーのオブジェクトを描画してみましょう。

DummyRenderer.cs

using UnityEngine;
using System.Collections.Generic;

public class DummyRenderer : MonoBehaviour
{
    private readonly int OFFSET_ID = Shader.PropertyToID( "_Offset" );

    [SerializeField]
    private Material m_dummyMaterial = null;

    private void Start()
    {
        if( LoopedField.Instance != null )
        {
            Vector3 fieldSize   = LoopedField.Instance.FieldBounds.size;

            SetDummyMaterials( fieldSize );
            ExpandBounds( fieldSize );
        }
        
    }

    private void SetDummyMaterials( Vector3 i_fieldSize )
    {
        if( m_dummyMaterial == null )
        {
            return;
        }

        var renderer = GetComponentInChildren<Renderer>();
        if( renderer == null )
        {
            return;
        }


        // 周り八方向にダミー用のマテリアルでオブジェクトを描画する。
        // 専用のシェーダーを使っているマテリアル限定だけど……。
        var dummyMaterials = new Material[]
        {
            new Material( m_dummyMaterial ),
            new Material( m_dummyMaterial ),
            new Material( m_dummyMaterial ),
            new Material( m_dummyMaterial ),
            new Material( m_dummyMaterial ),
            new Material( m_dummyMaterial ),
            new Material( m_dummyMaterial ),
            new Material( m_dummyMaterial ),
        };

        dummyMaterials[ 0 ].SetVector( OFFSET_ID, new Vector4(  i_fieldSize.x, 0.0f, 0.0f, 0.0f ) );
        dummyMaterials[ 1 ].SetVector( OFFSET_ID, new Vector4(  i_fieldSize.x, 0.0f,  i_fieldSize.z, 0.0f ) );
        dummyMaterials[ 2 ].SetVector( OFFSET_ID, new Vector4(  i_fieldSize.x, 0.0f, -i_fieldSize.z, 0.0f ) );
        dummyMaterials[ 3 ].SetVector( OFFSET_ID, new Vector4( -i_fieldSize.x, 0.0f, 0.0f, 0.0f ) );
        dummyMaterials[ 4 ].SetVector( OFFSET_ID, new Vector4( -i_fieldSize.x, 0.0f,  i_fieldSize.z, 0.0f ) );
        dummyMaterials[ 5 ].SetVector( OFFSET_ID, new Vector4( -i_fieldSize.x, 0.0f, -i_fieldSize.z, 0.0f ) );
        dummyMaterials[ 6 ].SetVector( OFFSET_ID, new Vector4( 0.0f, 0.0f,  i_fieldSize.z, 0.0f ) );
        dummyMaterials[ 7 ].SetVector( OFFSET_ID, new Vector4( 0.0f, 0.0f, -i_fieldSize.z, 0.0f ) );
        
        var materials = new List<Material>( renderer.materials );
        materials.AddRange( dummyMaterials );

        renderer.materials  = materials.ToArray();
    }

    private void ExpandBounds( Vector3 i_fieldSize )
    {
        // メインのGameObjectが描画範囲から外れていても描画されるように、
        // boundsのサイズを大きくしておこう。
        var meshFilter = GetComponentInChildren<MeshFilter>();
        if( meshFilter == null )
        {
            return;
        }

        var bounds = meshFilter.mesh.bounds;
        bounds.size    += i_fieldSize;
        meshFilter.mesh.bounds = bounds;
    }

} // class DummyRenderer

f:id:urahimono:20180202132957p:plain
f:id:urahimono:20180202133012g:plain

 うん、ループしているようには見えるよね。
 本体もダミーも映っちゃっているので、めっちゃオブジェクトが増えたように映っているけど。

f:id:urahimono:20180202133039p:plain

 シーンビューで見るとこんな感じ。
f:id:urahimono:20180202133102g:plain

 ループするフィールドを広くしようか。
 カメラをもう少し寄らせてもいいけど。
 さすがにループフィールドが狭すぎた気がする。

f:id:urahimono:20180202133123p:plain
f:id:urahimono:20180202133137g:plain

 おっ、いいんじゃない。
 ループしているように見えるよ!

f:id:urahimono:20180202133225g:plain
f:id:urahimono:20180202133210p:plain

 ……それでもカメラ位置次第では映ってしまうのだが……。
 まあ、目的は達したからもういいや。

おまけ アニメーションするオブジェクトだとどうなる?

 そういえば、このシェーダーを使ったマテリアルをアニメーションするオブジェクトに使ったらどうなるのだろうか。
 試してみよう。

 使用するキャラクターは、皆のアイドル「イーサン」だ。

f:id:urahimono:20180202133301g:plain
f:id:urahimono:20180202133326g:plain

 おお、動いた。
 シーンビューにいっぱいいるイーサンはちと気味悪いけど

締め

 このドッペルゲンガーシェーダーを何とか使えないものかと考えてみたのですが、このループぐらいしか思いつかなかったですね。
 あっ、ちなみにですけど、ループするオブジェクトを余計に八個描画しており、なおかつ描画可能範囲を拡大させています。
 そのため結構描画処理は重そうだという点にご注意くだされ。