Cooooding!!

Unity(C#)を使ったゲーム開発関連Tipsなど

クリックした先のXY平面上の座標を取得する【Unity】

概要

クリックした先のXY平面上にエフェクトや敵を生成するテスト用のシーンを作るために、その座標を計算する関数を実装しました。Colliderを置いてPhysics.Raycastで座標を取得する方法もあるのですが、わざわざColliderを置くのも手間なのでColliderを使わない方法で実装してみました。

座標取得方法

public bool GetPosition(out Vector3 result)
{
    // カメラはメインカメラを使う
    var camera = Camera.main;
    // クリック位置を取得
    var touchPosition = Input.mousePosition;
    // XY平面を作る
    var plane = new Plane(Vector3.forward, 0);
    // カメラからのRayを作成
    var ray = camera.ScreenPointToRay(touchPosition);
    // rayと平面の交点を求める(交差しない可能性もある)
    if (plane.Raycast(ray, out float enter))
    {
        result = ray.GetPoint(enter);
        return true;
    }
    else
    {
        // rayと平面が交差しなかったので座標が取得できなかった
        result = Vector3.zero;
        return false;
    }
}

この関数はカメラの向きがXY平面に対して垂直になっていなくてもXY平面上の座標を返します。

XY平面上の座標ではなくXZ平面上の座標を取りたい場合にはplaneの値を変えればOKです。

// XZ平面を作る
var plane = new Plane(Vector3.up, 0);

環境

  • Unity 2019.1.9f1
  • VisualStudio 2019

Unity2019に移行して発生したトラブルとその対処【Unity】

概要

これまでUnity2018.4で作っていたプロジェクトをそろそろ2019.1に乗り換えようと思い移行してみたところいくつかトラブルが発生しました。そのトラブル内容と対処内容をメモしておきます。

トラブル

1. prefabがおかしくなった

prefabに付けた自作のコンポーネントのSerializeFieldに保存していた値の一部が何故か初期値に戻っていました。Nestして配置した先やProjectViewで選択してInspectorを見ると初期値に戻っていますが、ダブルクリックして編集画面を開くと何故か正しい値が保存されている奇妙な状態になっていました。

これはUnityを再起動しても直りませんでしたが、Libraryフォルダを削除してから再起動したら直りました。

2. UnityEngine.Vector3.magnitudeの計算結果が微妙に変わった

これは計算誤差を許容するように実装していなかった自分が悪いのですが、UnityEngine.Vector3.magnitude(もしくはその中で使われている関数)の計算結果が微妙に変わったようで処理に影響が出たところがありました。

サンプルコード

var vec = new UnityEngine.Vector3(1, 2, 3).normalized * -5.0f;
Debug.LogFormat("{0} == {1}: {2}", 5, vec.magnitude.ToString("r"), 5 == vec.magnitude);

Unity2018.4.2f1の出力結果

5 == 5: True

Unity2019.1.9f1の出力結果

5 == 4.99999952: False

これは計算誤差を許容するように修正して解決しました。

3. NUnit.Framework.Assert.AreEqualの比較結果が一部変わった

これも自分の実装が悪かったのですが、NUnit.Framework.Assert.AreEqualの比較処理が一部変更になったようでTest Runnerでエラーが出るようになりました。

Unity2018.4では通るがUnity2019.1では通らないサンプルコード

Assert.AreEqual(
    new Vector3(float.NaN, float.NaN, float.NaN),
    new Vector3(float.NaN, float.NaN, float.NaN)
);

NaN同士を==で比較した場合はfalseになるのが正しいのでこちらの結果の方が正しいように思えます。これはIsNaNという関数を作って判定することで解決しました。

public static bool IsNaN(UnityEngine.Vector3 value)
{
    return float.IsNaN(value.x) &&
        float.IsNaN(value.y) &&
        float.IsNaN(value.z);
}

ちなみに、float.NaN同士をAreEqualで比較した場合は通ってしまうのでこの変更は意図したものではないのかもしれません。

Assert.AreEqual(float.NaN, float.NaN); // 何故かこれは通る

環境

  • Unity 2018.4.2f1
  • Unity 2019.1.9f1

Planeのdistanceについて【Unity】

概要

UnityEngine.Planeのdistanceがわかりにくく理解に少し苦労したのでわかったことをまとめました。

法線と距離を使った平面の表現

平面は法線と原点との距離を使って表すことができます。
Planeと法線とdistanceの関係図

UnityのPlaneも法線と距離を指定して初期化することができます。

var plane = new Plane(Vector3.up, 1);

ただ、ここで注意しなければならないのはdistanceが「原点からの距離」ではなく「原点までの距離」であるということです。

例えば、Plane(Vector3.up, 1)ならば以下の図のようになるのは間違いです。
間違い。これはPlane(Vector3.up, 1)ではない

正解はこちらです。
こちらが正解。これがPlane(Vector3.up, 1)

公式ドキュメントには「原点から平面への距離を返します(Distance from the origin to the plane)」と書かれていますが、おそらくこれが間違っています。GetDistanceToPointという平面と点の距離を計算するメソッドがあるのでこれを使って確認してみます。

ソースコード

// y軸方向の法線を持ち、距離1の平面を作る
var plane = new Plane(Vector3.up, 1);
// (x, y, z) = (0, 1, 0)の点と平面の距離を計算
var distance = plane.GetDistanceToPoint(new Vector3(0, 1, 0));
// 距離を出力
Debug.LogFormat("distance = {0}", distance);

出力結果

distance = 2

(0, 1, 0)と平面の距離が2になるのでPlane(Vector3.up, 1)は(0, 1, 0)を通っていないことがわかります。

環境

  • Unity 2018.4.2f1

Gizmosで平面を描画する【Unity】

概要

計算結果の平面(UnityEngine.Plane)が正しいかどうかを確認するためにGizmosで描画しようと思ったら簡単に描画できる関数がなかったので実装してみました。Planeは長さを持たず無限に広がるため、描画時に座標とサイズを指定して一部分を描画することになります。

実装

片面の平面を描画

Planeは向き(法線方向)を持つため向きが確認できるように片面だけ描画します。

/// <summary>
/// 片面の平面を描画する
/// </summary>
/// <param name="plane">平面</param>
/// <param name="point">この点に最も近い平面上の座標を中心とする</param>
/// <param name="size">描画するサイズ</param>
public static void Draw(
    Plane plane, Vector3 point, Vector2 size
)
{
    var mesh = MakeMesh(plane, point, size);
    Gizmos.DrawMesh(mesh);
}

/// <summary>
/// 平面を描画するためのMeshを作る
/// </summary>
public static Mesh MakeMesh(Plane plane, Vector3 point, Vector2 size)
{
    // Unityは左手系なので、時計回りの頂点で法線は(0, 0, -1)方向
    var vertices = GetVertices(plane, point, size);
    var mesh = new Mesh();
    mesh.SetVertices(new List<Vector3>(vertices));
    mesh.SetTriangles(new List<int>(){ 0, 1, 2, 0, 2, 3 }, 0);
    mesh.RecalculateBounds();
    mesh.RecalculateNormals();
    return mesh;
}

/// <summary>
/// 平面を描画するための頂点位置計算
/// </summary>
public static Vector3[] GetVertices(
    Plane plane, Vector3 point, Vector2 size
)
{
    // pointが平面上にないなら最も近い平面上の座標を中心とする
    var center = plane.ClosestPointOnPlane(point);

    var rotation = Quaternion.LookRotation(-plane.normal);
    var width = 0.5f * size.x;
    var height = 0.5f * size.y;
    var vertices = new Vector3[] {
        new Vector3(-width, -height, 0),
        new Vector3(-width,  height, 0),
        new Vector3( width,  height, 0),
        new Vector3( width, -height, 0),
    };
    for (int i = 0; i < vertices.Length; ++i)
    {
        vertices[i] = rotation * vertices[i] + center;
    }
    return vertices;
}

片面の平面を描画した結果

両面の平面を描画

Planeは向きを持つものの両面表示したいこともあります。これは細長いCubeを使うと簡単に実装できます。

public static void DrawBothFace(
    Plane plane, Vector3 point, Vector2 size
)
{
    // pointが平面上にないなら最も近い平面上の座標を中心とする
    var center = plane.ClosestPointOnPlane(point);

    var backup = UnityEngine.Gizmos.matrix;
    Gizmos.matrix = Matrix4x4.TRS(
        center,                                 // 中心
        Quaternion.LookRotation(plane.normal),  // 回転
        new Vector3(size.x, size.y, 0.0001f)    // サイズ(厚みをほぼ0する)
    );
    Gizmos.DrawCube(Vector3.zero, Vector3.one);
    Gizmos.matrix = backup;
}

両面の平面を描画した結果

枠だけを描画

簡単なのでついでに枠の描画も実装してみます。

public static void DrawWire(
    Plane plane, Vector3 point, Vector2 size
)
{
    var vertices = GetVertices(plane, point, size);
    for (int i = 0, length = vertices.Length; i < length; ++i)
    {
        var p0 = vertices[i];
        var p1 = vertices[(i + 1) % length];
        Gizmos.DrawLine(p0, p1);
    }
}

平面の枠だけ描画した結果

環境

  • Unity 2018.4.2f1
  • VisualStudio 2019 (ver16.1.5)

4つの頂点座標からRectを求める【C#】

概要

与えられた複数の頂点(Vector2)が矩形(Rect)になるかどうかを判定して処理を分岐させたかったので、与えられた頂点からRectを求める関数を実装してみました。

4頂点から矩形を求める

実装

Rectを求める関数の実装:

using System.Collections.Generic;
using UnityEngine;

public static class RectUtility
{
    /// <summary>
    /// 頂点が矩形を表すならtrueを返し、その情報をresultに格納する
    /// </summary>
    public static bool ToRect(
        out Rect result,
        IList<Vector2> vertices,
        float allowedError = 0      // 許容できる誤差
    )
    {
        result = default;
        if (vertices == null || vertices.Count != 4)
        {
            return false;
        }
        // 0番目の頂点から見て左 or 右にある頂点を特定
        var h = GetHorizontalVertexIndex(vertices, 0, allowedError);
        if (h <= 0) { return false; }
        // 0番目の頂点から見て上 or 下にある頂点を特定
        var v = GetVerticalVertexIndex(vertices, 0, allowedError);
        if (v <= 0) { return false; }
        // 0番目の頂点から見て斜めの位置にある頂点を特定
        var d = GetDiagonalVertexIndex(vertices, 0, allowedError);
        if (d <= 0) { return false; }

        // 位置関係に問題がないかを確認
        if (!(
            Equals(vertices[h].x, vertices[d].x, allowedError) &&
            Equals(vertices[v].y, vertices[d].y, allowedError)
        ))
        {
            return false;
        }
        // 位置やサイズを設定
        result = new Rect(
            Mathf.Min(vertices[h].x, vertices[0].x),
            Mathf.Min(vertices[v].y, vertices[0].y),
            Mathf.Abs(vertices[h].x - vertices[0].x),
            Mathf.Abs(vertices[v].y - vertices[0].y)
        );
        return true;
    }

    /// <summary>
    /// selfIndex番目の頂点から斜めの位置にある頂点のIndexを取得する
    /// </summary>
    private static int GetDiagonalVertexIndex(
        IList<Vector2> vertices,
        int selfIndex,
        float allowedError
    )
    {
        var self = vertices[selfIndex];
        for (int i = 0; i < vertices.Count; ++i)
        {
            if (i == selfIndex) { continue; }

            if (
                !Equals(vertices[i].x, self.x, allowedError) &&
                !Equals(vertices[i].y, self.y, allowedError)
            )
            {
                return i;
            }
        }
        return -1;
    }

    /// <summary>
    /// selfIndex番目の頂点から上 or 下の位置にある頂点のIndexを取得する
    /// </summary>
    private static int GetVerticalVertexIndex(
        IList<Vector2> vertices,
        int selfIndex,
        float allowedError
    )
    {
        var self = vertices[selfIndex];
        for (int i = 0; i < vertices.Count; ++i)
        {
            if (i == selfIndex) { continue; }

            if (Equals(vertices[i].x, self.x, allowedError))
            {
                return i;
            }
        }
        return -1;
    }

    /// <summary>
    /// selfIndex番目の頂点から左 or 右の位置にある頂点のIndexを取得する
    /// </summary>
    private static int GetHorizontalVertexIndex(
        IList<Vector2> vertices,
        int selfIndex,
        float allowedError
    )
    {
        var self = vertices[selfIndex];
        for (int i = 0; i < vertices.Count; ++i)
        {
            if (i == selfIndex) { continue; }

            if (Equals(vertices[i].y, self.y, allowedError))
            {
                return i;
            }
        }
        return -1;
    }

    /// <summary>
    /// 誤差を許容するfloatの比較
    /// </summary>
    private static bool Equals(float a, float b, float allowedError)
    {
        return Mathf.Abs(a - b) <= allowedError;
    }
}

使用例:

var list = new Vector2[]
{
    new Vector2(1, -1),
    new Vector2(1, 0),
    new Vector2(3, -1),
    new Vector2(3, 0)
};
if (RectUtility.ToRect(out Rect rect, list))
{
    Debug.Log(rect);
}

ToRectの第三引数を指定すれば誤差を許容することもできます。

出力結果:

(x:1.00, y:-1.00, width:2.00, height:1.00)

※ 回転した状態の矩形には対応していません

環境

  • Unity 2018.4.2f1
  • VisualStudio 2019

Tilemapの情報をスクリプトから取得する【Unity】

概要

Tilemapの情報をスクリプトで取得して処理したかったので取得方法を調べてみました。

取得方法

タイルがどこに存在するかを取得する

タイルが存在する範囲はTilemap.cellBoundsで知ることができます。この範囲のセルに対してTilemap.HasTileを使うことでどのセルにタイルが存在するかを取得できます。

実装例:

public static void OutputPosition(Tilemap tilemap)
{
    var builder = new StringBuilder();
    var bound = tilemap.cellBounds;
    for (int y = bound.max.y - 1; y >= bound.min.y; --y)
    {
        for (int x = bound.min.x; x < bound.max.x; ++x)
        {
            builder.Append(tilemap.HasTile(new Vector3Int(x, y, 0)) ? "■" : "□");
        }
        builder.Append("\n");
    }
    Debug.Log(builder.ToString());
}

出力例:
f:id:nyama41:20190622221507p:plain

cellBoundsの外側にタイルを置くと自動的にこの範囲は拡張されますが、そのタイルを消しても自動的に縮小されることはありません。縮小したい場合はTilemap.CompressBoundsを使って縮小できます。

タイルにどのSpriteが設定されているかを取得する

タイルにどのSpriteが設定されているか知りたいときはTilemap.GetTile<Tile>を使ってTile型の値を取得しTile.spriteをチェックします。

実装例:

public static void OutputSpriteType(Tilemap tilemap)
{
    // 使われているSpriteをリストアップ
    var bound = tilemap.cellBounds;
    var spriteList = new List<Sprite>();
    for (int y = bound.max.y - 1; y >= bound.min.y; --y)
    {
        for (int x = bound.min.x; x < bound.max.x; ++x)
        {
            var tile = tilemap.GetTile<Tile>(new Vector3Int(x, y, 0));
            if (tile != null && !spriteList.Contains(tile.sprite))
            {
                spriteList.Add(tile.sprite);
            }
        }
    }
    // どの場所でそのSpriteが使われているかを出力
    var builder = new StringBuilder();
    for (int y = bound.max.y - 1; y >= bound.min.y; --y)
    {
        for (int x = bound.min.x; x < bound.max.x; ++x)
        {
            var tile = tilemap.GetTile<Tile>(new Vector3Int(x, y, 0));
            if (tile == null)
            {
                builder.Append("_");
            }
            else
            {
                var index = spriteList.IndexOf(tile.sprite);
                builder.Append(index);
            }
        }
        builder.Append("\n");
    }
    Debug.Log(builder.ToString());
}

出力例:
f:id:nyama41:20190622221524p:plain

特定のタイルがある範囲を取得する

特定のタイルがある範囲を取得したい場合はGetTileは使わずにTilemap.GetCellCenterLocalTilemap.originなどを使って範囲を取得します。
実装:

public static Rect OutputTilePosition(Tilemap tilemap, int x, int y)
{
    var rect = new Rect();
    rect.center = tilemap.GetCellCenterLocal(new Vector3Int(x, y, 0)) + tilemap.origin;
    rect.size = tilemap.cellSize;
    return rect;
}

環境

  • Unity 2018.4.2f1
  • VisualStudio 2019

2Dの地形に3DのColliderを付けてみる【Unity】

概要

少し前の記事で地形は2Dで3Dモデルに2DのColliderを付ける検証をしました。今回は逆に地形の方に3DのColliderを付ける検証をしてみました。3DモデルはRigidbodyのZ方向の移動とXY方向の回転を禁止(Freeze)してRigidbody2Dのような動きしかしないようにしています。また、こちらの問題が発生するためDefault Solver Iterationsを100に変更して検証しています。

今回はTilemapだけでなくSpriteShapeも使っています。

検証

Tilemap + シンプルな3Dオブジェクト

TilemapにはこのようにColliderを付けました。
Tilemapに3DのColliderを付けた
Colliderのz軸方向の厚みが0でも問題なく動くことが多いですが後述する理由により厚みを付けています。

Cylinderなどのオブジェクトは以下のようにFreezeにチェックを入れてRigidbody2Dのような動きをするようにしています。
Rigidbody2Dのような動きをするようにFreeze

これらのオブジェクトを地形に落下させてみるとこのようになります。

特に問題なく動作しています。

Tilemap + 複雑なモデル

次に複雑なモデルを使ってみます。
Rigidbody2Dのような動きをするようにFreeze


これも問題なさそうに見えますが…

3Dモデルに2D Colliderを付けたときと同じ問題

3Dモデルに2DのColliderを付けたときと同じでPerspectiveの問題が発生します。
Sceneビューでは問題なさそうに見えるけど、Gameビューでははみだしている
これもOrthographicにすればはみ出すことはなくなりますが3D感が薄れます。

Colliderのz座標に注意

2DのColliderを使ったときとは違ってz座標にも注意する必要があります。例えば、地形のColliderの厚みを0にすると以下のように一部のColliderが地形と当たらなくなることがあります。
地形のColliderの厚みを0にすると、剣のColliderがz=0ではないため地形と当たらない
これは地形のColliderに厚みを付ければいいだけなので付け忘れないように注意していれば大丈夫です。

SpriteShape

SpriteShapeでも同じように検証してみます。ColliderはBox Colliderを複数組み合わせました。
地形に沿ってBox Colliderを複数付ける


Sprite Shapeでも特に問題なさそうです。

Perspectiveの問題が目立ちにくい分 Sprite Shapeの方がいいかもしれません。
Perspectiveの問題が目立ちにくい

感想

2Dの地形に3DのColliderを付けても大きな問題はなさそうです。

3Dモデルは人型に限定しなければ様々なバリエーションがありアニメが付いていることも多いと考えると、3Dモデルに2DのColliderを付けるよりも2Dの地形に3DのColliderを付けた方が想定外のトラブルでハマる可能性が低くなりそうな気がしました。

環境

  • Unity 2018.4.0f1