特に後半、だれてしまって荒い説明になってしまっています。
視錐台の例(右手系)、Unityでは主に左手系が用いられている。(右手系 - Wikipedia)
やりたかった事
[この記事では正投影(orthographic)モードのカメラは対象外です: <[Unity - マニュアル: カメラ](https://docs.unity3d.com/jp/current/Manual/CamerasOverview.html)>]
透過投影(perspective)モードのカメラで見せたい3Dオブジェクトを色々な角度から画面一杯に表示したい時、
- 画面の縦横比が想定と違ってると小さすぎたりはみ出したり
- 表示エリアのサイズを自由に設定するなら縦横比のパターンは多すぎる
- スマートフォン向けなら画面の向きが縦だったり横だったり
- とにかく手動でのカメラアングル調整が面倒
という事で、今回は透過投影(perspective)モードのカメラのための以下のような関数を作成しています。
- 入力
- カメラの向きの角度(Unityでは
Quaternion
で表現) - 画面内に収めたい物体もしくは空間を代表する点の集合
- カメラの画角(縦・横)
- nearクリッピング距離
- カメラの向きの角度(Unityでは
- 出力
- 指定した点の集合で構成される空間が画角からはみ出さないカメラの位置
但し、今回は以下のような事は考慮しません。
- カメラ位置の拘束条件
- 部屋(床、壁)やフィールドなどの境界の外側にカメラを置くような描画を避けたい、目標物とカメラの間に障害物があるアングルを避けたい、などのそもそも前提となるカメラの向きや画角なども調整する必要がある場合の考察は今回は行いません。
- 視錐台のfarクリッピング距離の考慮
- 物体の形状によっては、画角に合わせるだけではカメラに近すぎ/遠すぎて視錐台からはみ出し、一部もしくは全部が描画されない場合があります。
- nearクリッピング距離についてはハミ出さないよう考慮しますが、farクリッピング距離については最終的にハミ出しているかどうかの判定に触れるのみとし、ここでは基本的に考慮しません。
- 均等な余白
- 例えば上下方向は画角ぴったりで左右方向には余白が生じる場合、左辺と右辺の余白幅が大きく異なる場合があります。
- まっすぐカメラを引いた場合、それぞれの端になる点の距離が等しくないと余白は均等にはなりません(例:下図)。今回は簡易化のため、余白を余らせる場合はこの(まっすぐカメラを引く)処理のままで解を求めます。(最悪計算量を下手に増やすよりは $O(n)$ で出来る範囲という事で。)
- また、このカメラを引く処理によって元とは別の点がより画面端に現れる場合があります。これによって画角からはみ出すことは無いので今回は踏み込みませんでしたが、発展課題として余白を均等にならす場合はこのような点が現れないかも同時に確認する方が良いでしょう。
- 下図ではカメラが $(0,0,0)$ にあった時は $(-20,0,20)$ と $(10,0,10)$ が画面端にある例を示していますが、カメラを $(0,0,-10)$ まで引いた場合、 $(36,0,40)$ の点が $(10,0,10)$ より画面端方向に飛び出して見えることになります。$x$軸方向にカメラを動かして左右の余白を均等にならす場合、 $(-20,0,20)$ と $(10,0,10)$ の点だけを注視すると $(-2,0,-10)$ の位置にカメラを動かすのが良さそうですが、 $(36,0,40)$ の点も意識すると逆に $(1,0,-10)$ の位置にカメラを動かした方が良さそうです。
やってみた例
今回提示する手法で、さまざまな方向におけるカメラ位置を計算した例:
https://www.geogebra.org/o/rzqChVjh
https://ggbm.at/rzqChVjh
Unityで、さまざまな方向におけるカメラ位置を計算した例:
WebGL版サンプルビルド (家モデルは Asset Store: Town Houses Pack を使用しました。)
マウスによるボタン操作の他、q,w,e,a,s,dキーでカメラの回転操作ができます。
SetCamPos.cs
: カメラ方向・GUI周りなどの制御
using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
public class SetCamPos : MonoBehaviour {
// 画面内に収めたい対象物
public GameObject[] targetObject = {};
public float lastUpdateTime = 0f;
public GUISkin guiskin;
// 前回のカメラ情報の保存
CamAutoPos prevCam = null;
Vector3[] transVec(Matrix4x4 mat, Vector3[] vec) {
Vector3[] trvec = new Vector3[vec.Length];
for(int i = 0; i < vec.Length; ++i) {
trvec[i] = mat.MultiplyPoint3x4(vec[i]);
}
return trvec;
}
Vector3[] combVec(Vector3[][] v) {
int ofs = 0, len = 0;
foreach(Vector3[] vi in v) {
len += vi.Length;
}
Vector3[] c = new Vector3[len];
foreach(Vector3[] vi in v) {
vi.CopyTo(c, ofs);
ofs += vi.Length;
}
return c;
}
Vector3[] getMeshVerts(GameObject[] gobj) {
List<Vector3[]> lvec = new List<Vector3[]>();
foreach(GameObject o in gobj) {
foreach(MeshFilter m in o.GetComponentsInChildren<MeshFilter>()) {
if(m.gameObject.activeInHierarchy) {
lvec.Add(transVec(
m.transform.localToWorldMatrix,
m.mesh.vertices
));
}
}
}
return combVec(lvec.ToArray());
}
void OnGUI() {
GUI.skin = guiskin;
Transform tr = gameObject.transform;
// ボタンサイズ計算
int sH = Screen.height, sW = Screen.width;
int cmvBtn = Math.Max(Math.Max(sH, sW) / 12, 20), cmvBtnS = cmvBtn + 10;
// カメラ回転ボタン
if(GUI.RepeatButton(new Rect(sW - cmvBtnS * 3, 50 + cmvBtnS * 0, cmvBtn, cmvBtn), "↙")) {
tr.localRotation *= Quaternion.Euler( 0f, 0f, 0.1f);
}
if(GUI.RepeatButton(new Rect(sW - cmvBtnS * 1, 50 + cmvBtnS * 0, cmvBtn, cmvBtn), "↘")) {
tr.localRotation *= Quaternion.Euler( 0f, 0f, -0.1f);
}
if(GUI.RepeatButton(new Rect(sW - cmvBtnS * 2, 50 + cmvBtnS * 0, cmvBtn, cmvBtn), "↑")) {
tr.localRotation *= Quaternion.Euler( 0.1f, 0f, 0f);
}
if(GUI.RepeatButton(new Rect(sW - cmvBtnS * 2, 50 + cmvBtnS * 1, cmvBtn, cmvBtn), "↓")) {
tr.localRotation *= Quaternion.Euler(-0.1f, 0f, 0f);
}
if(GUI.RepeatButton(new Rect(sW - cmvBtnS * 3, 50 + cmvBtnS * 1, cmvBtn, cmvBtn), "←")) {
tr.localRotation *= Quaternion.Euler( 0f, 0.1f, 0f);
}
if(GUI.RepeatButton(new Rect(sW - cmvBtnS * 1, 50 + cmvBtnS * 1, cmvBtn, cmvBtn), "→")) {
tr.localRotation *= Quaternion.Euler( 0f, -0.1f, 0f);
}
GUI.Box(new Rect(sW - 360, 10, 350, 30),
tr.localRotation.eulerAngles.ToString() + " : " + tr.localPosition.ToString()
);
}
void Start() {
}
void Update() {
float nowRealtime = Time.realtimeSinceStartup;
Transform tr = gameObject.transform;
Camera cam = gameObject.GetComponent<Camera>();
// キー入力による回転
if(Input.GetKey("a")) { tr.localRotation *= Quaternion.Euler( 0f, 0.1f, 0f); }
if(Input.GetKey("d")) { tr.localRotation *= Quaternion.Euler( 0f, -0.1f, 0f); }
if(Input.GetKey("w")) { tr.localRotation *= Quaternion.Euler( 0.1f, 0f, 0f); }
if(Input.GetKey("s")) { tr.localRotation *= Quaternion.Euler(-0.1f, 0f, 0f); }
if(Input.GetKey("q")) { tr.localRotation *= Quaternion.Euler( 0f, 0f, 0.1f); }
if(Input.GetKey("e")) { tr.localRotation *= Quaternion.Euler( 0f, 0f, -0.1f); }
// 現在のカメラの回転角・画角などを取得
CamAutoPos newCam = new CamAutoPos(
tr.rotation, 0.5f * cam.fieldOfView,
Mathf.Rad2Deg * Mathf.Atan(Mathf.Tan(0.5f * Mathf.Deg2Rad * cam.fieldOfView) * Screen.width / Screen.height),
cam.nearClipPlane, cam.farClipPlane);
// 変更があればカメラ位置を再計算して設定
if(!newCam.Equals(this.prevCam) || nowRealtime > lastUpdateTime + 1f) {
lastUpdateTime = nowRealtime;
this.prevCam = newCam;
tr.position = newCam.getCamPos(getMeshVerts(targetObject));
}
}
}
CamAutoPos.cs
: カメラ位置の計算
using UnityEngine;
/**
<summary>透過投影にて対象物を指定方向から指定画角におさめるカメラ位置の計算</summary>
<example>
Cameraに紐付けるScriptの例:
<code><![CDATA[
public class CamRotateTest : MonoBehaviour {
// 画面内に収めたい対象物もしくはそれを覆う頂点群
// 例: 原点中心の1辺の長さ1の立方体
public Vector3[] targetVerts = {
new Vector3(-0.5f, -0.5f, -0.5f),
new Vector3(+0.5f, -0.5f, -0.5f),
new Vector3(-0.5f, +0.5f, -0.5f),
new Vector3(+0.5f, +0.5f, -0.5f),
new Vector3(-0.5f, -0.5f, +0.5f),
new Vector3(+0.5f, -0.5f, +0.5f),
new Vector3(-0.5f, +0.5f, +0.5f),
new Vector3(+0.5f, +0.5f, +0.5f)
};
// 前回のカメラ情報の保存
CamAutoPos prevCam = null;
void Start() {}
void Update() {
Transform tr = gameObject.transform;
Camera cam = gameObject.GetComponent<Camera>();
// キー入力による回転
if(Input.GetKey("a")) { tr.localRotation *= Quaternion.Euler( 0f, 0.1f, 0f); }
if(Input.GetKey("d")) { tr.localRotation *= Quaternion.Euler( 0f, -0.1f, 0f); }
if(Input.GetKey("w")) { tr.localRotation *= Quaternion.Euler( 0.1f, 0f, 0f); }
if(Input.GetKey("s")) { tr.localRotation *= Quaternion.Euler(-0.1f, 0f, 0f); }
if(Input.GetKey("q")) { tr.localRotation *= Quaternion.Euler( 0f, 0f, 0.1f); }
if(Input.GetKey("e")) { tr.localRotation *= Quaternion.Euler( 0f, 0f, -0.1f); }
// 現在のカメラの回転角・画角などを取得
CamAutoPos newCam = new CamAutoPos(
tr.localRotation, 0.5f * cam.fieldOfView,
Mathf.Rad2Deg * Mathf.Atan(Mathf.Tan(0.5f * Mathf.Deg2Rad * cam.fieldOfView) * Screen.width / Screen.height),
cam.nearClipPlane, cam.farClipPlane);
// 変更があればカメラ位置を再計算して設定
if(!newCam.Equals(this.prevCam)) {
this.prevCam = newCam;
tr.localPosition = newCam.getCamPos(targetVerts);
}
}
}
]]></code>
</example>
*/
public class CamAutoPos : System.Object {
public Quaternion rot;
public float vfv, hfv, Znear, Zfar;
/**
<summary>カメラ設定構築</summary>
*/
public CamAutoPos(Quaternion? rot = null, float vfv = float.NaN, float hfv = float.NaN, float Znear = -1f, float Zfar = -1f) {
this.rot = rot ?? Camera.main.transform.localRotation;
this.vfv = float.IsNaN(vfv) ? 0.5f * Camera.main.fieldOfView : vfv;
this.hfv = float.IsNaN(hfv) ? Mathf.Rad2Deg * Mathf.Atan(Mathf.Tan(Mathf.Deg2Rad * this.vfv) * Screen.width / Screen.height) : hfv;
this.Znear = Znear = (Znear >= 0f)? Znear: Camera.main.nearClipPlane;
this.Zfar = Zfar = (Zfar >= 0f)? Zfar: Camera.main.farClipPlane;
}
/**
<summary>カメラ設定比較</summary>
*/
public override bool Equals(System.Object obj) {
if(obj == null) {
return false;
}
CamAutoPos p = obj as CamAutoPos;
if((System.Object)p == null) {
return false;
}
return
(this.rot == p.rot) &&
(this.vfv == p.vfv) &&
(this.hfv == p.hfv) &&
(this.Znear == p.Znear) &&
(this.Zfar == p.Zfar);
}
/**
<summary>ハッシュ</summary>
*/
public override int GetHashCode() {
return (int)(vfv * hfv);
}
/**
<summary>最大内積</summary>
*/
static public float maxDotVec(Vector3[] posTrCam, Vector3 vVec) {
float u, v = float.NaN;
for(int i = 0; i < posTrCam.Length; ++i) {
u = Vector3.Dot(posTrCam[i], vVec);
if(u > v || float.IsNaN(v)) { v = u; }
}
return v;
}
/**
<summary>カメラ位置計算</summary>
<param name="targetPos">目標物、若しくはそれを覆う点群</param>
<param name="rot">頂点群の座標に対するカメラの縦画角</param>
<param name="vfv">カメラの垂直方向の画角の半分(度) [vfv > 0f && vfv < 90f]</param>
<param name="hfv">カメラの水平方向の画角の半分(度) [hfv > 0f && hfv < 90f]</param>
<param name="Znear">nearクリッピング距離</param>
<param name="Zfar">farクリッピング距離</param>
*/
static public Vector3 calcCamPos(Vector3[] targetPos, Quaternion? rot = null, float vfv = float.NaN, float hfv = float.NaN, float Znear = 0f, float Zfar = float.PositiveInfinity) {
// デフォルト値設定
Quaternion _rot = rot ?? Camera.main.transform.localRotation;
vfv = float.IsNaN(vfv) ? 0.5f * Camera.main.fieldOfView : vfv;
hfv = float.IsNaN(hfv) ? Mathf.Rad2Deg * Mathf.Atan(Mathf.Tan(Mathf.Deg2Rad * vfv) * Screen.width / Screen.height) : hfv;
// 縦画角/2, 横画角/2
float
Yrad = Mathf.Deg2Rad * vfv,
Ysin = Mathf.Sin(Yrad),
Ycos = Mathf.Cos(Yrad),
Xrad = Mathf.Deg2Rad * hfv,
Xsin = Mathf.Sin(Xrad),
Xcos = Mathf.Cos(Xrad);
// 垂線方向の単位ベクトル
Vector3
YNperp = new Vector3(0f, -Ycos, -Ysin),
YPperp = new Vector3(0f, +Ycos, -Ysin),
XNperp = new Vector3(-Xcos, 0f, -Xsin),
XPperp = new Vector3(+Xcos, 0f, -Xsin);
// 原点から画角面までの垂線長さ
float
maxYN = maxDotVec(targetPos, _rot * YNperp),
maxYP = maxDotVec(targetPos, _rot * YPperp),
maxXN = maxDotVec(targetPos, _rot * XNperp),
maxXP = maxDotVec(targetPos, _rot * XPperp),
maxNear = maxDotVec(targetPos, _rot * Vector3.back);
// カメラ位置決定
return _rot * new Vector3(
0.5f * (maxXP - maxXN) / Xcos,
0.5f * (maxYP - maxYN) / Ycos,
-Mathf.Max(
0.5f * (maxXN + maxXP) / Xsin,
0.5f * (maxYN + maxYP) / Ysin,
maxNear + Znear));
}
/**
<summary>カメラ位置取得</summary>
*/
public Vector3 getCamPos(Vector3[] targetPos) {
return calcCamPos(targetPos, rot, vfv, hfv, Znear, Zfar);
}
}
カメラの方向・画角・near距離
- "視錐台" (View frustum, Viewing fustum) の解説記事
- 視錐台 - MSDN - Microsoft
- Unity - マニュアル: 視錐台を理解する
- Viewing frustum - Wikipedia(en)
- カメラ視野基準のベクトル$P$ $\to$ 実座標でのベクトル$P'$ に回転する関数$T$を $T(P) = P'$ とする
T = (Camera cam, Vector3 P) => cam.transform.localRotation * P
- Unity では回転の表現には
Quaternion
を用い、Quaternion
やVector3
の回転は 乗算オペレータ "*
" にて行う - 画面中央から画面上端中央または画面下端中央までの垂直方向の視野角を$\theta_y$とする
- 画面縦方向の端から端までの垂直方向の視野角の半分が $\theta_y$ 。
- $0^\circ < \theta_y < 90^\circ$
- Unityでは
Camera.fieldOfView
が $2\theta_y$ に相当(但し、単位はラジアンではなく度)。 float theta_y = 0.5f * Mathf.Deg2Rad * cam.fieldOfView;
- 画面中心から画面左端中央または画面右端中央までの視野角を$\theta_x$とする
- 画面横方向の端から端までの水平方向の視野角の半分が $\theta_x$ 。
- $0^\circ < \theta_x < 90^\circ$
- Unityでは直接これを示すものはないので、画面一杯に描写したい場合は
Camera.fieldOfView
,Screen.width
,Screen.height
を用いて算出 float theta_x = Mathf.Atan(Mathf.Tan(theta_y) * (float)Width / (float)Height);
- near/farクリッピング距離
- Unityでは
Camera.nearClipPlane
,Camera.farClipPlane
。
平面に対する点の高さ
平面$P$の単位法線ベクトルを$\vec{n}$($|\vec{n}|=1$)、平面$P$上の任意の点を$Q$とした時、空間上の任意の点$R$の平面$P$に対する$\vec{n}$方向の高さ$h$はベクトルの内積を用いて $h=\vec{QR}\cdot\vec{n}$ と表される。
- Unity では
Vector3
の内積はVector3.Dot
にて行う
画角境界面の単位法線ベクトル
画面の外側方向を向く単位法線ベクトルを4方向それぞれに定義すると、
- 画面下端面: $\vec{Y^-}=(0,-\cos\theta_y,-\sin\theta_y)$
- 画面上端面: $\vec{Y^+}=(0,+\cos\theta_y,-\sin\theta_y)$
- 画面左端面: $\vec{X^-}=(-\cos\theta_x,0,-\sin\theta_x)$
- 画面右端面: $\vec{X^+}=(+\cos\theta_x,0,-\sin\theta_x)$
最も画面隅にある点を通す面の位置の検出
原点を$O$、目標の点の集合を$\{R_i\}$として、原点$O$にカメラを置いた場合の画角面(計算のための基準面)と、最も画面隅にある点を通るそれに平行な平面(それぞれの方向で望む画角に収めるための面、求めるべきカメラ位置の逆算用)との距離はそれぞれ
- 画面下端面: $\mathcal{Y^-}=\max\{\vec{OR_i}\cdot T(\vec{Y^-})\}$
- 画面上端面: $\mathcal{Y^+}=\max\{\vec{OR_i}\cdot T(\vec{Y^+})\}$
- 画面左端面: $\mathcal{X^-}=\max\{\vec{OR_i}\cdot T(\vec{X^-})\}$
- 画面右端面: $\mathcal{X^+}=\max\{\vec{OR_i}\cdot T(\vec{X^+})\}$
near/farクリッピング距離
- nearクリッピング距離を $l_{near}$ 、farクリッピング距離を $l_{far}$ $(0 \le l_{near} < l_{far})$ として、
- 画面手前方向の単位ベクトル: $\vec{Z^-}=(0,0,-1)$
- 画面奥方向の単位ベクトル: $\vec{Z^+}=(0,0,1)$
- 最近点: $\mathcal{Z^-}=\max\{\vec{OR_i}\cdot T(\vec{Z^-})\}=-\min\{\vec{OR_i}\cdot T(\vec{Z^+})\}$
- nearクリッピング面との相対位置比較用
- 最遠点: $\mathcal{Z^+}=\min\{\vec{OR_i}\cdot T(\vec{Z^-})\}=-\max\{\vec{OR_i}\cdot T(\vec{Z^+})\}$
- farクリッピング面との相対位置比較用
カメラ位置の解
以上より、カメラの位置ベクトルは
T\left(\left(
\frac{\mathcal{X^+}-\mathcal{X^-}}{2\cos\theta_x},
\frac{\mathcal{Y^+}-\mathcal{Y^-}}{2\cos\theta_y},
-\max\left\{
\frac{\mathcal{X^+}+\mathcal{X^-}}{2\sin\theta_x},
\frac{\mathcal{Y^+}+\mathcal{Y^-}}{2\sin\theta_y},
\left(\mathcal{Z^-}+l_{near}\right)
\right\}
\right)\right)
となる。また、目標点が全てfarクリッピング距離に収まるかは、以下の不等式で判別する(不等式が成り立てばfarクリッピング距離からはみ出さないと見なす)。
(\mathcal{Z^+}+l_{far}) \ge \max\left\{\frac{\mathcal{X^+}+\mathcal{X^-}}{2\sin\theta_x},\frac{\mathcal{Y^+}+\mathcal{Y^-}}{2\sin\theta_y},\left(\mathcal{Z^-}+l_{near}\right)\right\}