はじめに
Cinemachineを意地でも使わないyoship1639です。
対象のオブジェクトを舐めまわすように回転・追従する多機能カメラコントローラを作ってみたのでその紹介です。
SceneのMain Cameraにアタッチし追従対象のTransformを指定するだけでいいのでお手軽で、また調整すればプレイヤーの追従にもそのまま使えるので様々なシーンで使えるのではないかと思います。
キャラクタをアタッチするだけで使える多機能カメラスクリプトを作ってみた。いろんな場面で使えるように中心回転、遅延、フリールック、距離、高さ、手ブレ、ドリーズームを搭載しました。プレイヤー追従にもそのまま使えます。
— yoship@東方ACT開発中 (@yoship1639) November 3, 2019
qiita記事書いて配布予定です(*'ω'*) pic.twitter.com/SSYJBybaJX
どの様な機能があるのかご紹介します。
カメラの機能
今回作成したカメラスクリプトは標準入力からの操作でも他スクリプトからでも扱える様にしてあります。
Target Rotation
ターゲットを中心に周ります。基本機能です。
マウス左+移動です。
Following
ターゲットを中心にしているのでそのまま追従します。
Damping
遅延です。Dampingの値を大きくするとカメラが遅延して追従します。
滑らかに見せるには必須の手法です。
Free Look
ターゲットを中心としてその周りを見渡すことができます。
特定の場所を見たい場合に使えます。
マウス右+移動です。マウス中央で初期化します。
Distance
ターゲットとの距離を変える事が出来ます。これもなくてはならない基本機能ですね。
マウスホイールです。
Look Height
一々プレイヤーの目の位置にトランスフォーム用のゲームオブジェクトを設定するのが面倒な人のための機能です。
見る高さを変えます。
Noise
手ブレです。いかにも人が手で撮っている様に見せます。
あまり大きくすると酔います。
Dolly Zoom
めまいショットともいわれるドリーズーム。演出を凝るためにはこの効果を試してみるのもいいかもしれません。
Vibration
振動です。縦振動のみ、横振動のみ等も設定できます。
Wall Detection
カメラとキャラクタの間に遮蔽物がある場合に正しく処理する機能です。
壁裏にカメラがめり込むのを防ぎます。
Fixed Point
有効にするとカメラの位置をその場から動かさないようにできます。
Update Function
UpdateでシミュレートするかFixedUpdateでシミュレートするかを選べます。
移動対象が剛体の場合はFixedUpdateで追従するのが有効です。
各種アルゴリズム解説
それぞれの機能をどのように実現しているのかを簡単に解説したいと思います。
Target Rotation + Following + Distance + Height
ターゲットを中心にカメラを回転する手法ですが、これはSinとCosをうまく組み合わせることで簡単に実現できます。
ターゲットを中心としたカメラの水平位置は、上から見て単位円を描けば簡単に理解できます。ターゲットを後ろから見た状態を基準にした場合単位円のX座標はSin(rot)、Z座標は*-Cos(rot)*であることが分かるので、まずそれをそのままプログラムに落とし込みます。
[SerializeField] public float rot = 0.0f; // 水平回転角度
var pos = Vector3.zero;
pos.x = Mathf.Sin(rot * Mathf.Deg2Rad);
pos.z = -Mathf.Cos(rot * Mathf.Deg2Rad);
次に、Y座標ですが、これは単純で*Sin(height)*です。そして、上下に回り込むほどX,Z座標は小さくなるので、*Cos(height)*を掛けてあげます。
[SerializeField] public float rot = 0.0f; // 水平回転角度
[SerializeField] public float height = 0.0f; // 上下回り込み角度
var pos = Vector3.zero;
pos.x = Mathf.Sin(rot * Mathf.Deg2Rad) * Mathf.Cos(height * Mathf.Deg2Rad);
pos.z = -Mathf.Cos(rot * Mathf.Deg2Rad) * Mathf.Cos(height * Mathf.Deg2Rad);
pos.y = Mathf.Sin(height * Mathf.Deg2Rad);
単位円の長さは1なので、このままではターゲットからカメラまでの距離が1で固定されてしまうので、distanceを掛けてあげます。また、中心をターゲットにするので、var pos = Vector3.zero
はvar pos = target.position
に直してあげます。これでターゲットを中心にして回るという処理ができました。ターゲットを中心とするので、自動的にFollowing機能が付いてきます。更に、posにVector3.up * eyeHeightを足してあげれば高さも調整できます。
[SerializeField] public Transform target = null; // 追従ターゲット
[SerializeField] public float rot = 0.0f; // 水平回転角度
[SerializeField] public float height = 0.0f; // 上下回り込み角度
[SerializeField] public float distance = 10.0f; // カメラ距離
[SerializeField] public float eyeHeight = 1.0f; // ターゲットの視点の高さ
var pos = target.position + Vector3.up * eyeHeight;
pos.x += Mathf.Sin(rot * Mathf.Deg2Rad) * Mathf.Cos(height * Mathf.Deg2Rad) * distance;
pos.z += -Mathf.Cos(rot * Mathf.Deg2Rad) * Mathf.Cos(height * Mathf.Deg2Rad) * distance;
pos.y += Mathf.Sin(height * Mathf.Deg2Rad) * distance;
camera.transform.position = pos;
camera.transform.LookAt(target.position + Vector3.up * eyeHeight);
これで、4つの機能が出来上がりました。
Damping
遅延はMathf.Lerp
を使えば大丈夫です。
先ほどのrotやheightの値を遅延させればいいので、このような処理をかませます。
[SerializeField] public float rotationDamping = 10.0f;
private float targetRot = 0.0f;
targetRot = Mathf.Lerp(targetRot, rot, Mathf.Clamp01(Time.deltaTime * 100.0f / rotationDamping));
Mathf.Lerp
のrate値を時間にしてあげる事で、時間がたつにつれてtargetRotはrotに近づくようになります。近づく速度はrotationDampingで調整可能です。この処理をheightやdistanceにも適用させると動きが滑らかになります。
Free Look
これは意外と簡単で、camera.transform.LookAt
の後にcamera.transform.Rotate
で回転させればいいだけです。
[SerializeField] public Vector3 freeLookRotation = Vector3.zero;
camera.transform.LookAt(target.position + Vector3.up * eyeHeight);
camera.transform.Rotate(freeLookRotation);
Noise
手ブレは、パーリンノイズを使います。引数を経過時間にすればいいだけなので、これも簡単に作れてしまいます。
Free Lookと同じ様にcamera.transform.LookAt
の後にcamera.transform.Rotate
を行います。
Zだけ別の変数にしているのは、cameraのupの変動が大きいと見ずらくなってしまうので個別に調整できるようにするためです。
[SerializeField] public float noise = 1.0f;
[SerializeField] public float noiseZ = 0.4f;
[SerializeField] public float noiseSpeed = 1.0f;
var rotNoise = Vector3.zero;
rotNoise.x = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.0f) - 0.5f) * noise;
rotNoise.y = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.4f) - 0.5f) * noise;
rotNoise.z = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.8f) - 0.5f) * noiseZ;
camera.transform.Rotate(rotNoise);
Vibration
手ブレと要領でcamera.transform.Rotate
してあげます。
引数をランダムにすればいいだけです。
[SerializeField] public Vector3 vibration = Vector3.zero;
var vib = Vector3.zero;
vib.x = new Vector3(Random.Range(-1.0f, 1.0f) * vibration.x;
vib.y = new Vector3(Random.Range(-1.0f, 1.0f) * vibration.y;
vib.z = new Vector3(Random.Range(-1.0f, 1.0f) * vibration.z;
camera.transform.Rotate(vib);
Dolly Zoom
これはちょっと考えなければできない処理です。ドリーズームはカメラのField of ViewとDistanceをうまく調整する事でターゲットの大きさを変えずに周りの遠近感を変える手法です。
説明が面倒なので抜粋ソースコードを見て納得してください(投げやり)
最初の奴のdistanceをdollyDistに置き換えればおkです。
[SerializeField] public float dolly = 0.34f;
private float GetDollyDistance(float fov, float distance)
{
return distance / (2.0f * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad));
}
private float GetDollyFoV(float dolly, float distance)
{
return 2.0f * Mathf.Atan(distance * 0.5f / dolly) * Mathf.Rad2Deg;
}
var dollyFoV = GetDollyFoV(Mathf.Pow(1.0f / dolly, 2.0f), distance);
var dollyDist = GetDollyDistance(dollyFoV, distance);
camera.fieldOfView = dollyFoV;
一応Unity公式リファレンスにも説明があります。
https://docs.unity3d.com/jp/460/Manual/DollyZoom.html
Wall Detection
壁を検知し壁に埋まらない様にする手法です。ターゲットからカメラ方向にRayを飛ばして壁があったらdistanceをその壁までの距離に置き換えます。
[SerializeField] public float wallDetectionDistance = 0.3f;
RaycastHit hit;
var dir = (target.position - camera.transform.position).normalized;
if (Physics.SphereCast(pos, wallDetectionDistance, dir, out hit, dollyDist))
{
dollyDist = hit.distance;
}
これだけで壁にめり込まなくなります。
残りのFixed PointとUpdate Functionは特に説明することはないので割愛です。
ソースコード
前章のアルゴリズムをごった煮して入力を受け付ける様にしたら多機能カメラコントローラの完成です。
コピペしてメインカメラに追加しターゲットを設定すればそのまま使えます。
using UnityEngine;
public class MultifunctionFollowingCamera : MonoBehaviour
{
[SerializeField] public Transform target;
[SerializeField] public bool enableInput = true;
[SerializeField] public bool simulateFixedUpdate = false;
[SerializeField] public bool enableDollyZoom = true;
[SerializeField] public bool enableWallDetection = true;
[SerializeField] public bool enableFixedPoint = false;
[SerializeField] public float inputSpeed = 4.0f;
[SerializeField] public Vector3 freeLookRotation;
[SerializeField] public float height;
[SerializeField] public float distance = 8.0f;
[SerializeField] public Vector3 rotation;
[SerializeField] [Range(0.01f, 100.0f)] public float positionDamping = 16.0f;
[SerializeField] [Range(0.01f, 100.0f)] public float rotationDamping = 16.0f;
[SerializeField] [Range(0.1f, 0.99f)] public float dolly = 0.34f;
[SerializeField] public float noise = 0.0f;
[SerializeField] public float noiseZ = 0.0f;
[SerializeField] public float noiseSpeed = 1.0f;
[SerializeField] public Vector3 vibration = Vector3.zero;
[SerializeField] public float wallDetectionDistance = 0.3f;
[SerializeField] public LayerMask wallDetectionMask = 1;
private Camera cam;
private float targetDistance;
private Vector3 targetPosition;
private Vector3 targetRotation;
private Vector3 targetFree;
private float targetHeight;
private float targetDolly;
void Start()
{
cam = GetComponent<Camera>();
targetDistance = distance;
targetRotation = rotation;
targetFree = freeLookRotation;
targetHeight = height;
targetDolly = dolly;
var dollyDist = targetDistance;
if (enableDollyZoom)
{
var dollyFoV = GetDollyFoV(Mathf.Pow(1.0f / targetDolly, 2.0f), targetDistance);
dollyDist = GetDollyDistance(dollyFoV, targetDistance);
cam.fieldOfView = dollyFoV;
}
if (target == null) return;
var pos = target.position + Vector3.up * targetHeight;
var offset = Vector3.zero;
offset.x += Mathf.Sin(targetRotation.y * Mathf.Deg2Rad) * Mathf.Cos(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
offset.z += -Mathf.Cos(targetRotation.y * Mathf.Deg2Rad) * Mathf.Cos(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
offset.y += Mathf.Sin(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
targetPosition = pos + offset;
}
void Update()
{
if (!simulateFixedUpdate) Simulate(Time.deltaTime);
}
void FixedUpdate()
{
if (simulateFixedUpdate) Simulate(Time.fixedDeltaTime);
}
private void Simulate(float deltaTime)
{
if (enableInput)
{
if (Input.GetKey(KeyCode.LeftAlt))
{
dolly += Input.GetAxis("Mouse ScrollWheel") * 0.2f;
dolly = Mathf.Clamp(dolly, 0.1f, 0.99f);
}
else
{
distance *= 1.0f - Input.GetAxis("Mouse ScrollWheel");
distance = Mathf.Clamp(distance, 0.01f, 1000.0f);
}
if (Input.GetMouseButton(0))
{
rotation.x -= Input.GetAxis("Mouse Y") * inputSpeed;
rotation.x = Mathf.Clamp(rotation.x, -89.9f, 89.9f);
rotation.y -= Input.GetAxis("Mouse X") * inputSpeed;
}
if (Input.GetMouseButton(1))
{
freeLookRotation.x -= Input.GetAxis("Mouse Y") * inputSpeed * 0.2f;
freeLookRotation.y += Input.GetAxis("Mouse X") * inputSpeed * 0.2f;
}
if (Input.GetMouseButtonDown(2))
{
freeLookRotation = Vector3.zero;
}
}
var posDampRate = Mathf.Clamp01(deltaTime * 100.0f / positionDamping);
var rotDampRate = Mathf.Clamp01(deltaTime * 100.0f / rotationDamping);
targetDistance = Mathf.Lerp(targetDistance, distance, posDampRate);
targetRotation = Vector3.Lerp(targetRotation, rotation, rotDampRate);
targetFree = Vector3.Lerp(targetFree, freeLookRotation, rotDampRate);
targetHeight = Mathf.Lerp(targetHeight, height, posDampRate);
targetDolly = Mathf.Lerp(targetDolly, dolly, posDampRate);
if (Mathf.Abs(targetDolly - dolly) > 0.005f)
{
targetDistance = distance;
}
var dollyDist = targetDistance;
if (enableDollyZoom)
{
var dollyFoV = GetDollyFoV(Mathf.Pow(1.0f / targetDolly, 2.0f), targetDistance);
dollyDist = GetDollyDistance(dollyFoV, targetDistance);
cam.fieldOfView = dollyFoV;
}
if (target == null) return;
var pos = target.position + Vector3.up * targetHeight;
if (enableWallDetection)
{
RaycastHit hit;
var dir = (targetPosition - pos).normalized;
if (Physics.SphereCast(pos, wallDetectionDistance, dir, out hit, dollyDist, wallDetectionMask))
{
dollyDist = hit.distance;
}
}
var offset = Vector3.zero;
offset.x += Mathf.Sin(targetRotation.y * Mathf.Deg2Rad) * Mathf.Cos(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
offset.z += -Mathf.Cos(targetRotation.y * Mathf.Deg2Rad) * Mathf.Cos(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
offset.y += Mathf.Sin(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
if (Mathf.Abs(targetDolly - dolly) > 0.005f)
{
targetPosition = offset + pos;
}
else
{
targetPosition = Vector3.Lerp(targetPosition, offset + pos, posDampRate);
}
if (!enableFixedPoint) cam.transform.position = targetPosition;
cam.transform.LookAt(pos, Quaternion.Euler(0.0f, 0.0f, targetRotation.z) * Vector3.up);
cam.transform.Rotate(targetFree);
if (noise > 0.0f || noiseZ > 0.0f)
{
var rotNoise = Vector3.zero;
rotNoise.x = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.0f) - 0.5f) * noise;
rotNoise.y = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.4f) - 0.5f) * noise;
rotNoise.z = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.8f) - 0.5f) * noiseZ;
cam.transform.Rotate(rotNoise);
}
if (vibration.sqrMagnitude > 0.0f)
{
cam.transform.Rotate(new Vector3(Random.Range(-1.0f, 1.0f) * vibration.x, Random.Range(-1.0f, 1.0f) * vibration.y, Random.Range(-1.0f, 1.0f) * vibration.z));
}
}
private float GetDollyDistance(float fov, float distance)
{
return distance / (2.0f * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad));
}
private float GetFrustomHeight(float distance, float fov)
{
return 2.0f * distance * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
}
private float GetDollyFoV(float dolly, float distance)
{
return 2.0f * Mathf.Atan(distance * 0.5f / dolly) * Mathf.Rad2Deg;
}
}
おわりに
以前もカメラワークに関する記事を書かせていただきました。
ゲームの質を劇的に上げるカメラワークの3つの手法解説【減衰・FoV・手ブレ】
3D関連はカメラワークを変えるだけで見た目が大きく変わるのでぜひ参考にしていただければと思います。
この機能が足りない、この挙動がおかしい等ありましたら遠慮なくコメントください!修正バージョンを記載します。
よきUnityライフを。
※本記事で使用しているモデルは以下のライセンスで提供されています。
© Unity Technologies Japan/UCL