もりもりゲーム制作ブログ

それほどもりもりしてません。Unityの忘備録的ブログ。

Unity3D 動的にメッシュコリダー生成して囲み判定

はじめに

制作中の変則スネークゲームの新ルールとして
・ヘビが敵を囲んで倒す
というものを実装することができたのでまとめます。

f:id:monimoni114514:20181204225309g:plain
実際の挙動
やることは動的メッシュ生成です。

前提

スネークゲームではヘビを構成するセル(ブロック)があるので、頭セルから体セル通って尻尾セルまでを結んだ線で囲んだオブジェクトを消すというものを実装します。

f:id:monimoni114514:20181203133342p:plain
実装するモノ

必要なスクリプト
  • Player.cs 頭セルについていて方向入力で移動
  • BodyCell.cs 頭セル以外は前セルに追従
  • EncloseRange 線で囲んだ範囲に関係

Player.cs

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

public class Player : MonoBehaviour
{
    //移動関係の変数
    [SerializeField]
    private float speed, inputX, inputZ;
    private Vector3 moveDirection;
    private Rigidbody rb;
    
    //Mesh生成関係の変数
    private BodyCell headCell;
    private MeshCollider meshCollider;
    [SerializeField]
    private Material mat;

    void Start()
    {
        //RigidBody、BodyCellの取得
        rb = GetComponent<Rigidbody>();
        headCell = GetComponent<BodyCell>();
    }

    void Update()
    {
        //方向ベクトルを入力に対応させる
        inputX = Input.GetAxisRaw("Horizontal");
        inputZ = Input.GetAxisRaw("Vertical");
        moveDirection = new Vector3(inputX, 0, inputZ);
    }

    private void FixedUpdate()
    {
        //ベクトルの方向に移動する
        rb.velocity = moveDirection * speed * Time.deltaTime;
    }
}

キーボードからの方向入力で移動するようにしています。
また、ヘビの頭もセルであるためアタッチされたBodyCellクラスの格納先headCellや
作成するMeshColliderの格納先encloseColliderとMeshのマテリアル格納先matを宣言しています。

BodyCell.cs

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

public class BodyCell : MonoBehaviour
{
    public BodyCell prev, next;
    private Vector3 offset;
    public float speed;
    private Rigidbody rb;

    private void Start()
    {
        //現在地-offsetで進行方向を取得するため
        offset = transform.position;
        rb = GetComponent<Rigidbody>();
        if (prev != null)
            prev.next = this;
    }

    private void FixedUpdate()
    {
        //頭セル以外は前のセルに追従する
        if (prev != null)
            BodyCellMove();
    }
    void BodyCellMove()
    {
        //前セルとの距離を取得
        //距離が一定以上のときに前セルとの差分方向に移動
        Vector3 dis = prev.transform.position - transform.position;
        if (dis.magnitude > 2f)
        {
            rb.velocity = dis * speed * Time.deltaTime;
        }
        //単位時間での移動方向を取得
        Vector3 diff = transform.position - offset;

        //移動距離が一定以上でその方向を向く
        if (diff.magnitude > 0.05f)
        {
            if (next == null)
                diff.y = 0;
            transform.rotation = Quaternion.LookRotation(diff);
        }
        offset = transform.position;
    }
}

BodyCellクラスのインスタンスprevとnextがあってprevにはひとつ前のセル、nextにはひとつ後のセルを格納します。これでセル同士の連結・順番を再現します。
BodyCellクラスがアタッチされたオブジェクトはprevのセルに追従し、nextのセルはそのオブジェクトに追従します。

f:id:monimoni114514:20181204215216p:plain
こんなかんじ

EncloseRange.cs

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

public class EncloseRange : MonoBehaviour
{

    private void OnTriggerStay(Collider other)
    {
        //otherを消す処理
    }
}

Meshの衝突判定もPlayerオブジェクトでやろうと思ったんですが、
Meshコンポーネントはアタッチされたオブジェクトのtransformに影響を受けるので、
今回は別オブジェクトにMesh,MeshFilter,MeshRenderer,MeshColliderコンポーネントとEncloseRangeクラスをアタッチし衝突判定を管理します。

この辺は別の記事でスネークゲームの作り方として別の記事で書こうと思います。

本題

今回実装したのは、それぞれのセルを頂点とする図形のMeshColliderを生成し、OnTriggerStay関数でMeshCollider内のオブジェクトを消すという流れです。

だいたいの流れ
  1. ヘビが輪になる
  2. 頭→体→尻尾のセル座標をListに順次追加
  3. Listを元にMeshを生成
  4. Meshを元にMeshColliderを生成
  5. MeshColliderのOnTriggerEnterでオブジェクト取得
  6. オブジェクトを消す
①ヘビが輪になる

頭セルのPlayer.csのOncollisionEnterで尻尾セルとの衝突を確認したら②の処理へ。
Player.cs

    private void OnCollisionEnter(Collision collision)
    {
        var Cell = collision.collider.GetComponent<BodyCell>();
        //nextがnullのセルは尻尾セルだけ
        if (Cell != null)
            if (Cell.next == null)
            {
                MakeEncloseMesh();
            }
    }
    //メッシュ生成関数 生成したメッシュを返す
    //Vector3型のListに生成するメッシュの頂点を追加する
    Mesh MakeEncloseMesh(List<Vector3> pos)
    {
        //②の処理を頭セルから呼び出す
        headCell.AddPositionToList(pos);
    }
②頭→体→尻尾のセル座標をListに順次追加

BodyCell.csに追加した関数②で引数のListにセル座標を追加、そしてnextセルの関数②にListを渡して呼び出し、尻尾セルについたらreturnしていく再帰的処理。
BodyCell.cs

    public List<Vector3> AddPositionToList(List<Vector3> meshPos)
    {
        meshPos.Add(transform.position);

        if (next != null)
            return next.AddPositionToList(meshPos);
        else
            return meshPos;
    }
③Listを元にMeshを生成

平面多角形のMeshを生成するとき
頂点座標配列vertices
三角形頂点配列triangles
が必要になります。

詳しいことはそれぞれ調べてもらうとして、
例えば三角系Mesh"ABC"を生成するときは以下のようになります。

f:id:monimoni114514:20181204220421p:plain
mesh.vertices(頂点配列)への代入
f:id:monimoni114514:20181204220424p:plain
mesh.triangles(頂点番号配列)への代入

多角形メッシュを生成するときでも頂点verticesにはすべての頂点座標を代入するので、verticesにはListの内容を順番にいれます。
多角形を三角形の集合に分解し、三角形の頂点番号すべてを順番にtrianglesに格納します。

f:id:monimoni114514:20181204222153p:plain
五角形の場合のverticesとtrianglesの振り分け

Player.cs

    Mesh MakeEncloseMesh(List<Vector3> pos)
    {
        Mesh mesh = new Mesh();
        Vector3[] vertices = new Vector3[pos.Count];
        int[] triangles = new int[(pos.Count - 2) * 3];
        
        //vertices,trianglesにList内容や番号を割り振る
        for (int i = 0; i < pos.Count; i++)
            vertices[i] = pos[i];
        int j = 0;
        for (int i = 0; i < (pos.Count - 2) * 3; i++)
        {
            if (i > 0 && i % 3 == 0)
            {
                triangles[i] = 0;
                j += 2;
            }
            else
                triangles[i] = i - j;
            //Debug.Log(triangles[i]);
        }
        //以下にメッシュ生成処理
    }

Meshクラスのインスタンスmeshを宣言して、
meshのパラメータにverticesとtrianglesを代入。
meshの法線を計算しなおす(難しい話だったんで調べてください)
これでMeshは生成できています。
Player.cs

    Mesh CreateMesh(List<Vector3> pos)
    {
        Mesh mesh = new Mesh();
        Vector3[] vertices = new Vector3[pos.Count];
        int[] triangles = new int[(pos.Count - 2) * 3];
        
        for (int i = 0; i < pos.Count; i++)
            vertices[i] = pos[i];
        int j = 0;
        for (int i = 0; i < (pos.Count - 2) * 3; i++)
        {
            if (i > 0 && i % 3 == 0)
            {
                triangles[i] = 0;
                j += 2;
            }
            else
                triangles[i] = i - j;
            //Debug.Log(triangles[i]);
        }

        //以下にメッシュ生成処理
        //vertices,trianglesの代入と法線再計算(RecalculateNormals)
        mesh.vertices = vertices;
        mesh.triangles = triangles;
        mesh.RecalculateNormals();

        return mesh;
    }
④Meshを元にMeshColliderを生成

①で宣言したmeshColliderのパラメータsharedMeshにmeshを代入。
MeshColliderは生成しましたがGameViewには映っていないので、MeshFilterとMeshRendererのパラメータを設定します。
MeshFilterのパラメータmeshにmeshを代入。
MeshRendererのパラメータsharedMaterialに任意のMaterialを代入。
Player.cs

    void MakeEncloseMesh()
    {
        //①②の処理
        var meshPos = new List<Vector3>();
        headCell.AddPositionToList(meshPos);

        //③のCreateMeshの呼び出し・代入
        var mesh = CreateMesh(meshPos);
        //meshColliderにはEditor上でEncloseRangeのアタッチされたオブジェクトをアタッチ
        meshCollider.sharedMesh = mesh;
        meshCollider.transform.GetComponent<MeshFilter>().mesh = mesh;
        meshCollider.transform.GetComponent<MeshRenderer>().sharedMaterial = mat;
    }
⑤MeshColliderのOnTriggerEnterでオブジェクト取得

EncloseRange.cs内のOnTriggerEnter(Collider other)のotherに、BodyCellクラスがアタッチされていなければ、それがヘビに囲まれたオブジェクトになります。
EncloseRange.cs

    private void OnTriggerStay(Collider other)
    {
        if (other.GetComponent<BodyCell>() == null)
            if (other.name == "Enemy")
            {
                //otherを消す処理⑥
            }
    }
⑥オブジェクトを消す

Destroy関数なりで消しましょう。
EncloseRange.cs

                Destroy(other.gameObject);

まとめ

②~④が動的メッシュ生成のメインです。
平面メッシュの生成はmesh.trianglesの代入順にかなり影響されます。
右回り左回りどちらの順に代入するかでメッシュの向きがかわるためメッシュが描画されない面がカメラに映る可能性があります。
ですので、Material matには両面描画可能(Cull off)なシェーダーを使用したマテリアルをアタッチするといいと思います。
セルのかわりにマウスポインタの座標を代入していけばポケモンレンジャーのような処理も再現できると思います。

参考にさせていただいたサイト様

おねむゲーマーの備忘録様
平面Mesh生成の基本(③のあたり)がとてもわかりやすく書いてありました。ありがたい・・・
sleepygamersmemo.blogspot.com