6
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

視錐台(Frustum)を利用し、ある高さにおいてオブジェクトが描画されている範囲を求める方法

Last updated at Posted at 2019-06-08

#はじめに
Qiita初投稿になります。
間違っている点、不明な点などあれば優しく沢山マサカリを投げていただけると嬉しいです。

Unityを触っていて、「オブジェクトが現在カメラに写っているか」を知りたいということがあるかと思います。
その場合、最も簡単な方法はObjectにアタッチされているRendererコンポーネントのisVisible()メソッドを呼び出すことです。

public bool IsRendered()
{
    var renderer = this.GetComponent<Renderer>();
    return renderer.isVisible();
}

この方法は監視対象のオブジェクトが少ない場合は非常に便利です。

ただ、多くのオブジェクトや特定の座標を監視したい場合、毎回このメソッドを呼ぶためにRendererコンポーネントを取得するのは不便ですし、計算負荷もそれなりにかかります。

今回、この記事ではある基準となる高さ(y座標)における、オブジェクトが描画されている範囲を求める方法を解説します。
この方法を用いると複数オブジェクトの描画範囲の計算がまとめて行えるので、床面の高さが変わらないボードゲームやアクションゲームで特に効果を発揮すると思われます。

まあここまでめんどくさい処理をしないといけない機会もなかなかなさそうですし、探せばもっと楽な方法がありそうな気もしますが、個人的な備忘録程度にまとめておきます。

#視錐台(Frustum)
シーンにおける3次元空間の中で、カメラが描画している範囲は視錐台(Frustum)と呼ばれ、以下の図のように三角柱の上部分を削り取ったような形をしています。この図ではFar clipping planeと呼ばれる面とNear clipping planeに挟まれる、台形の立体版のような範囲がこれにあたります。
ViewFrustum.png
この図形は6つの面から構成されており、それぞれカメラの「左」「右」「上」「下」「奥」「手前」を表します。
詳しくはUnity公式マニュアルをご覧ください。画像もこちらから引用させていただきました。

Frustumを構成する面はGeometryUtility.CalculateFrustumPlanes()メソッドに現在のCameraを引数として渡してあげることで取得できます。

// メソッドの戻り値の配列は要素数6で、それぞれカメラ側から見て
// [0]: 左の面, [1]: 右の面, [2]: 下の面, [3]: 上の面, [4]: 手前の面, [5]: 奥の面
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(Camera.main);

ここで得られる戻り値はUnityEngine.Planeクラスなるものの配列で、今回は要素として平面の法線ベクトル原点(Vector3.zero)からの距離を持つ、という理解のみで大丈夫です。
詳しくはUnityスクリプティングAPIをご覧ください。

今回はこの得られたplaneを平面の方程式に変換し、(上と左)(上と右)といった接する2平面の交線を求めます。
そしてその交線上のある高さにおける座標を求めることで、その2つの面の接合部のある高さにおける座標を求めます。

#ある高さにおけるオブジェクトの描画判定
ここからの説明は算数の知識が少し必要となるので(私は半分理解をあきらめました)、結論まで飛ばしていただいて大丈夫です。

##平面の方程式

3次元空間上のある平面は

ax+by+cz+d=0

という数式で表すことができます。
ここで (a, b, c) は平面の法線を表し、unityEngine.Planeクラスではnormal変数にVector3型で格納されています。

dは、平面上に存在する座標 (xon, yon, zon) を (x, y, z) にそれぞれ代入したときに求められるもので、つまり

d = -(a(x-x_{on})+b(y-y_{on})+c(z-z_{on}))

となります。

が、今回はこの平面に関する情報として「原点からの距離」が分かっているので別の方法を用いてdを求めます。
詳細は省きますが、平面外のある座標 (xout, yout, zout) からの距離Dは以下のようにあらわされます。

D = \frac{|ax_{out}+by_{out}+cz_{out}-d|}{\sqrt{a^2+b^2+c^2}}

今回の場合は座標 (xout, yout, zout) は原点、距離DはUnityEngine.Planeクラスのdistance変数に格納されているので、以下のようにdの式に変形できます。

d = -D * \sqrt{a^2+b^2+c^2}

これでPlaneクラスを平面の方程式に変換することができました。

C#で平面の方程式の各要素を求めるコードは以下のようになります。

Plane plane;
float a = plane.normal.x;
float b = plane.normal.y;
float c = plane.normal.z;
float d = -plane.distance * Mathf.Pow(a*a+b*b+c*c, 0.5f);

※6/10追記 式が誤っていたので修正しました

##2平面の交線を求める
まだ数式のターンは続きます。
2つの平面があったとき、その平面は一つの交線で交わるか平行であるかのどちらかです。
交線は理系の人だったら多分一度は求めたことがあると思いますが(2式を連立させて1変数を消して媒介変数表示して...)、私は遠い過去の記憶に消えてしまっているのでここのサイト様に全面的にお世話になりました。
一般的に、3次元空間上の直線は以下の式で表されます。

\begin{bmatrix}
x \\
y \\
z 
\end{bmatrix} = A+te

ここでAは直線上に存在する点の座標、tは媒介変数、eは交線の方向ベクトルです。

ちょっと何を言っているかよくわからないですが、定数であるAとeを求めることでこの式は定められます。
eは2平面の法線ベクトルと共に垂直なベクトルであり、外積計算で求められるらしいです。

2つの平面の平面の方程式をそれぞれ

a_1x+b_1y+c_1z+d_1=0 \\
a_2x+b_2y+c_2z+d_2=0

とおくと、eは以下の式で定められます。

\begin{bmatrix}
e_x \\
e_y \\
e_z 
\end{bmatrix}
=
\begin{bmatrix}
b_1c_2 - c_1b_2 \\
c_1a_2 - a_1c_2 \\
a_1b_2 - b_1a_2
\end{bmatrix}

つぎにAの値を求めます。
交線上の点Aは、両方の平面上に存在する点、ということができます。

詳しい求め方に関する話は上記のサイト様を参考にしていただくとすると、Aの値は
ez != 0 の場合、

\begin{bmatrix}
A_x \\
A_y \\
A_z 
\end{bmatrix}
=
\begin{bmatrix}
\frac{d_1b_2-d_2b_1}{e_z} \\
\frac{d_1a_2-d_2a_1}{-e_z} \\
0
\end{bmatrix}

ey != 0 の場合、

\begin{bmatrix}
A_x \\
A_y \\
A_z 
\end{bmatrix}
=
\begin{bmatrix}
\frac{d_1c_2-d_2c_1}{-e_y} \\
0 \\
\frac{d_1a_2-d_2a_1}{e_y}
\end{bmatrix}

ex != 0 の場合、

\begin{bmatrix}
A_x \\
A_y \\
A_z 
\end{bmatrix}
=
\begin{bmatrix}
0 \\
\frac{d_1c_2-d_2c_1}{e_x} \\
\frac{d_1b_2-d_2b_1}{-e_x}
\end{bmatrix}

このどれでもない場合、つまり ex = ey = ez = 0 となる場合、2つの平面は平行です。

お疲れ様です、これで2平面の交線の方程式を求めることができました!大変すぎて記事書くのやめようかと思った
平面plane0および平面plane1の交線の方程式を求めるコードは以下のようになります

var e = new Vector3(plane0.normal.y * plane1.normal.z - plane0.normal.z * plane1.normal.y,
        plane0.normal.z * plane1.normal.x - plane0.normal.x * plane1.normal.z,
        plane0.normal.x * plane1.normal.y - plane0.normal.y * plane1.normal.x);
var d0 =
    -Mathf.Pow(
        plane0.normal.x * plane0.normal.x + plane0.normal.y * plane0.normal.y +
        plane0.normal.z * plane0.normal.z, 0.5f) * plane0.distance;
var d1 =
    -Mathf.Pow(
        plane1.normal.x * plane1.normal.x + plane1.normal.y * plane1.normal.y +
        plane1.normal.z * plane1.normal.z, 0.5f) * plane1.distance;
            
Vector3 A;
if (e.z != 0)
{
    A = new Vector3((d0 * plane1.normal.y - d1 * plane0.normal.y) / e.z,
        (d0 * plane1.normal.x - d1 * plane0.normal.x) / (-e.z), 0);
}
else if (e.y != 0)
{
    A = new Vector3((d0 * plane1.normal.z - d1 * plane0.normal.z) / (-e.y), 0,
        (d0 * plane1.normal.x - d1 * plane0.normal.x) / e.y);
}
else if (e.x != 0)
{
    A = new Vector3(0, (d0 * plane1.normal.z - d1 * plane0.normal.z) / e.x,
        (d0 * plane1.normal.y - d1 * plane0.normal.y) / (-e.x));
}
else
{
    A = Vector3.positiveInfinity;
}

##ある高さ(y座標)における描画範囲を計算する
ここまでくれば後は簡単です。
先ほど求めた直線の方程式の一つの次元(今回はy)に定数を代入し、tの値を求めることでxおよびz座標を求めます。

\begin{bmatrix}
x \\
y_0 \\
z 
\end{bmatrix}
=
\begin{bmatrix}
A_x \\
A_y \\
A_z
\end{bmatrix}
+
t
\begin{bmatrix}
e_x \\
e_y \\
e_z
\end{bmatrix}

より、

t = \frac{y_0-a_y}{e_y}

となるので、求める座標 (x0, z0) は

\begin{bmatrix}
x_0 \\
z_0 
\end{bmatrix}
=
\begin{bmatrix}
A_x + \frac{y_0-a_y}{e_y}e_x \\
A_z + \frac{y_0-a_y}{e_y}e_z
\end{bmatrix}

と求めることができました。
ある高さ y0 におけるxおよびz座標を求めるコードは以下のようになります。

var t = (y0 - A.y) / e.y;
var x0 = A.x + t * e.x;
var z0 = A.z + t * e.z;

#サンプルコードと動作例
サンプルコードを以下に示します。
ここでは、描画範囲の端を表す4つGameObject(leftUp, leftDown, rightUp, rightDown)を基準となる高さbaseHeight上で動かしています。
視錐台での近い面及び遠い面の2つの面に関しては計算を省略し、上下左右の4面の交線から各座標を計算しています。

using UnityEngine;

namespace Sample
{
    public class DrawFrustumCorner : MonoBehaviour
    {
        public float baseHeight;

        public GameObject leftUp;
        public GameObject leftDown;
        public GameObject rightUp;
        public GameObject rightDown;

        private void Update()
        {
            var planes = GeometryUtility.CalculateFrustumPlanes(Camera.main);
            
            var leftUpPosition = GetPlaneIntersention(planes[0], planes[3]);
            var rightUpPosition = GetPlaneIntersention(planes[1], planes[3]);
            var leftDownPosition = GetPlaneIntersention(planes[0], planes[2]);
            var rightDownPosition = GetPlaneIntersention(planes[1], planes[2]);

            leftUp.transform.position = new Vector3(leftUpPosition.x, baseHeight, leftUpPosition.y);
            rightUp.transform.position = new Vector3(rightUpPosition.x, baseHeight, rightUpPosition.y);
            leftDown.transform.position = new Vector3(leftDownPosition.x, baseHeight, leftDownPosition.y);
            rightDown.transform.position = new Vector3(rightDownPosition.x, baseHeight, rightDownPosition.y);
        }

        private Vector2 GetPlaneIntersention(Plane plane0, Plane plane1)
        {
            var e = new Vector3(plane0.normal.y * plane1.normal.z - plane0.normal.z * plane1.normal.y,
                plane0.normal.z * plane1.normal.x - plane0.normal.x * plane1.normal.z,
                plane0.normal.x * plane1.normal.y - plane0.normal.y * plane1.normal.x);
            var d0 =
                -Mathf.Pow(
                    plane0.normal.x * plane0.normal.x + plane0.normal.y * plane0.normal.y +
                    plane0.normal.z * plane0.normal.z, 0.5f) * plane0.distance;
            var d1 =
                -Mathf.Pow(
                    plane1.normal.x * plane1.normal.x + plane1.normal.y * plane1.normal.y +
                    plane1.normal.z * plane1.normal.z, 0.5f) * plane1.distance;
            
            Vector3 A;
            if (e.z != 0)
            {
                A = new Vector3((d0 * plane1.normal.y - d1 * plane0.normal.y) / e.z,
                    (d0 * plane1.normal.x - d1 * plane0.normal.x) / (-e.z), 0);
            }
            else if (e.y != 0)
            {
                A = new Vector3((d0 * plane1.normal.z - d1 * plane0.normal.z) / (-e.y), 0,
                    (d0 * plane1.normal.x - d1 * plane0.normal.x) / e.y);
            }
            else if (e.x != 0)
            {
                A = new Vector3(0, (d0 * plane1.normal.z - d1 * plane0.normal.z) / e.x,
                    (d0 * plane1.normal.y - d1 * plane0.normal.y) / (-e.x));
            }
            else
            {
                A = Vector3.positiveInfinity;
            }
            
            var t = (baseHeight - A.y) / e.y;
            
            return new Vector2(A.x + t * e.x, A.z + t * e.z);
        }
    }
}

このクラスを適当なGameObjectにアタッチして実行させると、以下のようになりました。
分かりやすいようにベースとなる高さにplaneを置き、カメラを四角柱、求められた四隅を球で表現しています
result.gif

今回は一定の高さにおける描画範囲を可視化させただけですが、この4点の内側判定はこの記事の三角形の内側判定を2回行うことで実現できます。
カメラ位置が変化しないのであれば、この手法を用いることで各オブジェクトに対してこれまでの計算を省略できるため、オブジェクト数が多いプロジェクトでは特に効果を発揮するかと思います。

#結論
今回は、一定の高さにおけるオブジェクトの描画判定をFrustumの各面の法線ベクトル及び距離から行う方法について説明しました。
この手法はオブジェクトが多く、かつ一定の高さに存在するようなプロジェクトにおいて、「カメラの中にいる間だけアニメーションを実行する」などといった状況で高速に描画判定を行うことができます。

ここまで書いてきて思いましたが、Shaderを書くときに出てくるMVP行列などは確かこれと同じような処理をしてたような気がするので、もしかしたらさらに簡潔に書ける方法があるかもしれませんね、まあ一例として見ていただけたらと思います。

#補足:Planeを用いた別の描画判定方法
今回の主題とは異なる方法ですが、それぞれのPlaneオブジェクトは視錐台の内側を向いた法線を持つため、次の関数にオブジェクトの範囲(Bounds)を引数として与えることで内外判定(true/false)を行うこともできます。

public bool IsRendered(Bounds bounds)
{
    var cam = Camera.main;
    var planes = GeometryUtility.CalculateFrustumPlanes(cam);
    return GeometryUtility.TestPlanesAABB(planes, bounds);
}

BoundsはUnityスクリプトリファレンスに詳しい内容があるのでここでは割愛しますが、3次元空間のある範囲を表します。
TestPlanesAABB()メソッドはここに詳しく書いてありますが、Boundsが引数として渡されたPlaneすべての法線方向に存在しているときにtrueを返す関数です。

ある範囲が描画されているか知りたい場合は、こちらの方法を使うのがよいでしょう。

#参考文献
視錐台を理解する -Unity公式マニュアル
GeometryUtility.CalculateFrustumPlanes -UnityScriptingAPI
GeometryUtility.TestPlanesAABB -UnityスクリプティングAPI
2平面の交線
点と三角形の当たり判定( 内外判定 )

6
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?