ヽ|∵|ゝ(Fantom) の 開発blog? ホーム »C#
このページの記事一覧

【Unity】【C#】SmoothFollow3(SmoothFollow に回転・遠近・高さ操作とピンチ・スワイプとの連携機能を付けた拡張版)  


 実は非常に好評だったりする「SmoothFollow2」の後継版。基本的には以前の内容と変わらないのだが、せっかく「ピンチ」「スワイプ」といった機能も作ったので、それらも連携できるようにしてみた。モバイル操作対応版という感じでもあるかな。ただ「SmoothFollow2」でもかなりパラメタ(フィールド)が多かったので、今回のバージョンからは少し機能ごとにまとめたため、変数名は変更した(機能はそのまま)。

SmoothFollow3PinchInput を利用してる


 自分で作るのが面倒な人はプラグイン内にデモとして使用しているので、ダウンロードしてコピペで利用して欲しい。


>>プラグイン&サンプルをダウンロード
(Google Drive を利用。画面右上にあるダウンロードアイコンを押す)



(※) Unity 5.6.3p1 / Windows10(x64) / Galaxy S7 Edge (Android 7.0) で確認



●SmoothFollow3(StanardAssets の SmoothFollow に回転・遠近・高さ操作とピンチ・スワイプとの連携機能を付けた拡張版)

using System;
using UnityEngine;

namespace FantomLib
{
/// <summary>
/// SmoothFollow に左右回転アングルと高さと距離の遠近機能を追加したもの ver.3
/// 2018/01/09 Fantom (Unity 5.6.3p1)
/// http://fantom1x.blog130.fc2.com/blog-entry-289.html
/// (SmoothFollow2 からの変更点)
/// http://fantom1x.blog130.fc2.com/blog-entry-163.html
///・SwipeInput のコールバックでのスワイプで一定角度の旋回を追加。
///・PinchInput のコールバックでのピンチで距離の操作を追加(モバイル用)。
///・起動時に設定された対象(target)から、距離(distance)、高さ(height)、角度(preAngle)を算出するオプションを追加。
///・初期状態へのリセットメソッド(ResetOperations())を追加。
///・ドラッグの認識する画面上の領域(validArea)を追加。
///・各設定をクラスで分けたので、変数名が変更された(機能は全て同じ)。
///(使い方)
///・カメラなどの GameObject にアタッチして、インスペクタから target に視点となるオブジェクトを登録すれば使用可。
///(仕様説明)
///・画面全体を(0,0)-(1,1)とし、有効領域内(Valid Area)でタッチまたはマウスでクリックしたとき認識する。
///・タッチ操作は指1本のみ(かつ最初の1本)の操作が有効となる(2本以上→1本になったときは認識しない)。
///・指でのドラッグとスワイプ操作を分けるため、AngleOperation.dragWidthLimit の値(画面幅による比率)より大きいときは(=指を素早く動かしたときは)ドラッグとして認識しない
/// (スワイプは SwipeInput.validWidth の値で認識)。
///・タッチデバイスを UNITY_ANDROID, UNITY_IOS としているので、他のデバイスも加えたい場合は #if の条件文にデバイスを追加する(Input.touchCount が取得できるもののみ)。
/// </summary>
public class SmoothFollow3 : MonoBehaviour
{
public Transform target; //追従するオブジェクト

public bool autoInitOnPlay = true; //distance, height, preAngle を起動時に target 位置から自動算出する
public float distance = 2.0f; //XZ平面の距離
public float height = 0f; //Y軸の高さ
public float preAngle = 0f; //カメラアングル初期値

public bool widthReference = true; //画面幅(Screen.width)サイズを比率の基準にする(false=高さ(Screen.height)を基準)

//認識する画面上の領域
public Rect validArea = new Rect(0, 0, 1, 1); //認識する画面領域(0.0~1.0)[(0,0):画面左下, (1,1):画面右上]


//回転操作
[Serializable]
public class AngleOperation
{
public float damping = 3.0f; //左右回転のスムーズ移動速度

//キー入力
public bool keyEnable = true; //回転のキー操作の ON/OFF
public float keySpeed = 45f; //左右回転速度
public KeyCode keyLeft = KeyCode.Z; //左回転キー
public KeyCode keyRight = KeyCode.X; //右回転キー

//ドラッグ
public bool dragEnable = true; //回転のドラッグ操作の ON/OFF
public float dragSpeed = 10f; //ドラッグ操作での回転速度
public float dragWidthLimit = 0.1f; //ドラッグとして認識できる幅(0 のとき制限なし ~ 1 のとき画面幅)。この幅以上は認識しない(スワイプと区別するため)。
}
public AngleOperation angleOperation;


//旋回(一定角度回転)
[Serializable]
public class TurnOperation
{
public float angle = 90f; //旋回の角度

//キー入力
public bool keyEnable = true; //旋回キーの ON/OFF
public KeyCode keyLeft = KeyCode.KeypadMinus; //左旋回キー
public KeyCode keyRight = KeyCode.KeypadPlus; //右旋回キー

//スワイプ
public bool swipeEnable = true; //スワイプで旋回の ON/OFF
}
public TurnOperation turnOperation;


//高さの操作
[Serializable]
public class HeightOperation
{
public float damping = 2.0f; //上下高さのスムーズ移動速度

//キー入力
public bool keyEnable = true; //高さのキー操作の ON/OFF
public float keySpeed = 1.5f; //キー操作での移動速度
public KeyCode keyUp = KeyCode.C; //高さ上へキー
public KeyCode keyDown = KeyCode.V; //高さ下へキー

//ドラッグ
public bool dragEnable = true; //高さのドラッグ操作での ON/OFF
public float dragSpeed = 0.5f; //ドラッグ操作での高さ移動速度
}
public HeightOperation heightOperation;


//距離の操作
[Serializable]
public class DistanceOperation
{
public float damping = 1.0f; //距離のスムーズ移動速度(キーとホイール)
public float min = 1.0f; //XZ平面での最小距離

//キー入力
public bool keyEnable = true; //距離のキー操作の ON/OFF
public float keySpeed = 0.5f; //距離の移動速度
public KeyCode keyNear = KeyCode.B; //近くへキー
public KeyCode keyFar = KeyCode.N; //遠くへキー

//ホイール
public bool wheelEnable = true; //距離のホイール操作の ON/OFF
public float wheelSpeed = 7f; //ホイール1目盛りの速度

//ピンチ
public bool pinchEnable = true; //ピンチで距離を操作する
public float pinchDamping = 5f; //ピンチでの距離のスムーズ移動速度(キーとホイールでの操作と分けるため)
public float pinchSpeed = 40f; //ピンチでの距離の変化速度
}
public DistanceOperation distanceOperation;


//初期状態リセット操作
[Serializable]
public class ResetOperation
{
public bool keyEnable = true; //初期状態リセットキーの ON/OFF
public KeyCode key = KeyCode.KeypadPeriod; //初期状態リセットキー
}
public ResetOperation resetOperation;


//Local Values
float angle; //カメラアングル(XZ平面)
Vector3 startPos; //マウス移動始点
float wantedDistance; //変化先距離
float resetDistance; //初期距離保存用
float resetHeight; //初期位置高さ保存用
bool pinched = false; //ピンチで操作したフラグ(distanceDamping と pinchDistanceDamping を切り替える)
bool dragging = false; //ドラッグの操作中フラグ


// Use this for initialization
void Start()
{
if (autoInitOnPlay && target != null)
{
height = transform.position.y - target.position.y;
Vector3 dir = Vector3.ProjectOnPlane(target.position - transform.position, Vector3.up);
distance = dir.magnitude;
preAngle = AngleXZWithSign(target.forward, dir);
}

angle = preAngle;
resetDistance = wantedDistance = distance;
resetHeight = height;
}

// Update is called once per frame
void Update()
{
#if !UNITY_EDITOR && (UNITY_ANDROID || UNITY_IOS) //タッチで取得したいプラットフォームのみ(モバイル等)
if (Input.touchCount != 1 || Input.touches[0].fingerId != 0) //最初の指1本の操作に限定する
{
dragging = false;
return;
}
#endif

//回転のキー操作
if (angleOperation.keyEnable)
{
if (Input.GetKey(angleOperation.keyLeft))
angle = Mathf.Repeat(angle + angleOperation.keySpeed * Time.deltaTime, 360f);

if (Input.GetKey(angleOperation.keyRight))
angle = Mathf.Repeat(angle - angleOperation.keySpeed * Time.deltaTime, 360f);
}

//旋回(一定角度回転)キー操作
if (turnOperation.keyEnable)
{
if (Input.GetKeyDown(turnOperation.keyLeft))
TurnLeft();

if (Input.GetKeyDown(turnOperation.keyRight))
TurnRight();
}

//高さのキー操作
if (heightOperation.keyEnable)
{
if (Input.GetKey(heightOperation.keyUp))
height += heightOperation.keySpeed * Time.deltaTime;

if (Input.GetKey(heightOperation.keyDown))
height -= heightOperation.keySpeed * Time.deltaTime;
}

//ドラッグ操作
if (angleOperation.dragEnable || heightOperation.dragEnable)
{
Vector3 movePos = Vector3.zero;

if (!dragging && Input.GetMouseButtonDown(0))
{
startPos = Input.mousePosition;
if (validArea.xMin * Screen.width <= startPos.x && startPos.x <= validArea.xMax * Screen.width &&
validArea.yMin * Screen.height <= startPos.y && startPos.y <= validArea.yMax * Screen.height)
{
dragging = true;
}
}
else if (dragging)
{
if (Input.GetMouseButton(0))
{
movePos = Input.mousePosition - startPos;
startPos = Input.mousePosition;

//ドラッグ幅で制限する(スワイプと分別するため)
if (angleOperation.dragWidthLimit > 0)
{
float limit = (widthReference ? Screen.width : Screen.height) * angleOperation.dragWidthLimit;
float d = Mathf.Max(Mathf.Abs(movePos.x), Mathf.Abs(movePos.y)); //大きい方で判定
if (d > limit)
{
movePos = Vector3.zero; //操作を無効にする
dragging = false;
}
}
}
else //Input.GetMouseButtonUp(0), exit
{
dragging = false;
}
}

if (movePos != Vector3.zero)
{
//回転のドラッグ操作
if (angleOperation.dragEnable)
angle = Mathf.Repeat(angle + movePos.x * angleOperation.dragSpeed * Time.deltaTime, 360f);

//高さのドラッグ操作
if (heightOperation.dragEnable)
height -= movePos.y * heightOperation.dragSpeed * Time.deltaTime;
}
}

//距離のキー操作
if (distanceOperation.keyEnable)
{
if (Input.GetKey(distanceOperation.keyNear))
{
wantedDistance = Mathf.Max(distanceOperation.min, distance - distanceOperation.keySpeed);
pinched = false;
}

if (Input.GetKey(distanceOperation.keyFar))
{
wantedDistance = distance + distanceOperation.keySpeed;
pinched = false;
}
}

//距離のホイール遠近
if (distanceOperation.wheelEnable)
{
float mw = Input.GetAxis("Mouse ScrollWheel");
if (mw != 0)
{
wantedDistance = Mathf.Max(distanceOperation.min, distance - mw * distanceOperation.wheelSpeed); //0.1 x N倍
pinched = false;
}
}

//初期状態リセット
if (resetOperation.keyEnable)
{
if (Input.GetKeyDown(resetOperation.key))
ResetOperations();
}
}

void LateUpdate()
{
if (target == null)
return;

//追従先位置
float wantedRotationAngle = target.eulerAngles.y + angle;
float wantedHeight = target.position.y + height;

//現在位置
float currentRotationAngle = transform.eulerAngles.y;
float currentHeight = transform.position.y;

//追従先へのスムーズ移動距離(方向)
currentRotationAngle = Mathf.LerpAngle(currentRotationAngle, wantedRotationAngle,
angleOperation.damping * Time.deltaTime);
currentHeight = Mathf.Lerp(currentHeight, wantedHeight, heightOperation.damping * Time.deltaTime);
distance = Mathf.Lerp(distance, wantedDistance,
(pinched ? distanceOperation.pinchDamping : distanceOperation.damping) * Time.deltaTime);

//カメラの移動
var currentRotation = Quaternion.Euler(0, currentRotationAngle, 0);
Vector3 pos = target.position - currentRotation * Vector3.forward * distance;
pos.y = currentHeight;
transform.position = pos;

transform.LookAt(target);
}


//状態リセット(初期状態に戻す)
public void ResetOperations()
{
height = resetHeight;
distance = wantedDistance = resetDistance;
angle = preAngle;
}


//ピンチで距離を操作(モバイル等)
//http://fantom1x.blog130.fc2.com/blog-entry-288.html
//・PinchInput を使用して距離を操作する。
//width: ピンチ幅, delta: 直前のピンチ幅の差, ratio: ピンチ幅の開始時からの伸縮比(1:ピンチ開始時, 1以上拡大, 1より下(1/2,1/3,...)縮小)
public void OnPinch(float width, float delta, float ratio)
{
if (!distanceOperation.pinchEnable)
return;

if (delta != 0)
{
wantedDistance = Mathf.Max(distanceOperation.min, distance - delta * distanceOperation.pinchSpeed);
pinched = true;
}
}

//スワイプで旋回
//・SwipeInput を使用して旋回する。
//http://fantom1x.blog130.fc2.com/blog-entry-250.html
public void OnSwipe(Vector2 dir)
{
if (!turnOperation.swipeEnable)
return;

if (dir == Vector2.left)
TurnLeft();
else if (dir == Vector2.right)
TurnRight();
}


//左旋回
public void TurnLeft()
{
angle = Mathf.Repeat(MultipleCeil(angle - turnOperation.angle, turnOperation.angle), 360f);
}

//右旋回
public void TurnRight()
{
angle = Mathf.Repeat(MultipleFloor(angle + turnOperation.angle, turnOperation.angle), 360f);
}


//以下、static method

//より小さい倍数を求める(倍数で切り捨てられるような値)
//http://fantom1x.blog130.fc2.com/blog-entry-248.html
static float MultipleFloor(float value, float multiple)
{
return Mathf.Floor(value / multiple) * multiple;
}

//より大きい倍数を求める(倍数で繰り上がるような値)
static float MultipleCeil(float value, float multiple)
{
return Mathf.Ceil(value / multiple) * multiple;
}

//2D(XY平面)での方向ベクトル同士の角度を符号付きで返す(度)
//http://fantom1x.blog130.fc2.com/blog-entry-253.html#AngleWithSign
static float AngleXZWithSign(Vector3 from, Vector3 to)
{
Vector3 projFrom = from;
Vector3 projTo = to;
projFrom.y = projTo.y = 0; //y軸を無視する(XZ平面に投影する)
float angle = Vector3.Angle(projFrom, projTo);
float cross = CrossXZ(projFrom, projTo);
return (cross != 0) ? angle * -Mathf.Sign(cross) : angle; //2D外積の符号を反転する
}

//2Dでの外積を求める(XY平面)
//http://fantom1x.blog130.fc2.com/blog-entry-253.html#Cross2D
static float CrossXZ(Vector3 a, Vector3 b)
{
return a.x * b.z - a.z * b.x;
}
}
}

 うむ、「SmoothFollow2」より随分と簡潔に書いたつもりだが、めっちゃ長いな(笑)。セットアップや使い方などは「SmoothFollow2」と変わらないので、そちらに丸投げしよう(必要ならこちらを参照して欲しい)。また、SmoothFollow2 との主な相違点はこちらにまとめて置いた。ここでは新しく加わった要素と、モバイル対応のために追加した機能などを主に説明しておこう。

 1つ目は「autoInitOnPlay」のオプションだ。これは起動時に設定された対象(target)から、距離(distance)、高さ(height)、角度(preAngle)を自動算出するオプションで、true のときはインスペクタで設定している distance, height, preAngle は上書きされるので注意しよう。簡単に言えばこれはエディタで見えている感じをそのまま反映させるオプションである。ただし、カメラは常に LookAt(対象の方を向く)されているので、カメラ自体の回転は変わることに注意しよう。どうしてもエディタと同じような見え方にしたい場合は、あらかじめカメラの高さや方向を合わせておけば良い。そうすればオプションを true でエディタと全く同じ見え方になる。そしてその起動時に取得された距離(distance)、高さ(height)、角度(preAngle)などは「ResetOperations()」を呼び出すことで、いつでも元に戻せる

 次にドラッグなどを認識できる画面領域を設定する「validArea」を説明しておこう。これは「ピンチ」「スワイプ」「ロングタップ」に使われているものと全く同じもので、画面左下を基準に (0, 0) とし、画面右上を (1, 1) として、その中でしか操作を認識しないというものである。例えば、画面の下 1/4 ほどにはアイコンなどを置き、ドラッグなどの操作対象にしたくないときは validArea を (X, Y) = (0, 0.25), (W, H) = (1, 0.75) のように設定すれば、下の方でドラッグしても無視される(回転などはしない)。

 それ以外には「AngleOperation」と「TurnOperation」の違いを説明しておこう。「AngleOperation」の方は以前から備わっている対象を中心に自由に回転角を操作できるものだ。これに対して「TurnOperation」の方は同じように回転はするが、必ず「TurnOperation.angle」で設定された一定角度まで回転する。これはクォータービューのゲームのように常に一定角度(だいたい45度とか90度とか)で覗きたいときの回転方法だ。内部的には同じ値を参照しているので、どちらか一方を使いたい場合は「AngleOperation」と「TurnOperation」内の「~Enable」でオン・オフを設定すれば良い。

 あとはピンチ(PinchInput)スワイプ(Swipe)との連携方法だが、それらは以降を参照して欲しい。基本的にはインスペクタでセットアップするだけだ。



■スワイプ(SwipeInput)と連携して一定角度の旋回をする(TurnOperation 連携)

 ここでは以前に作った「SwipeInput」を利用して、スワイプで一定角度の旋回をする(TurnOperation 連携)するセットアップを紹介しておこう。アタッチする GameObject は任意で構わないが、インスペクタから SwipeInput.OnSwipe に SmoothFollow3.OnSwipe を登録(Dynamic の方)をすれば良いだけである。旋回の角度は「TurnOperation.angle」で設定できるので、あとは「Swipe Enable」がオンになっていることだけを確認すればOKだ(デフォルト=オン)。


 ただし、SmoothFollow3.OnSwipe() は左右のスワイプしか受け付けない。OnSwipe() 自体の引数は Vector2 なので、例えばボタンから Vector2.leftVector2.right を送れば同じように旋回できる。

 コールバックの登録の仕方がわからない場合は、以下の記事も参照して欲しい。

【Unity】【C#】UnityEvent, Action, delegate, interface でのコールバック実装方法とインスペクタでの登録



■ピンチ(PinchInput)と連携して遠近を操作する(DistanceOperation 連携)

 もう1つ、以前に作った「PinchInput」を利用して、ピンチで距離の遠近を操作する(DistanceOperation 連携)するセットアップを紹介しておこう。アタッチする GameObject は任意で構わないが、インスペクタから PinchInput.OnPinch に SmoothFollow3.OnPinch を登録(Dynamic の方)をすれば良いだけだ(OnPinchStart は必要ない)。遠近操作の速度は「DistanceOperation.pinchSpeed」で主に調整すれば良いだろう(pinchDamping はスムーズに移動する速度で、大きくすれば急速に移動できる)。あとは「PinchInput.isNormalized:オン」「SmoothFollow3 の Pinch Enable」がオンになっていることだけを確認すればOKだ(デフォルト=オン)。


 SmoothFollow3.OnPinch() で受け取る値は主に delta(正規化されたピンチの距離差分)でピクセル単位でないことに注意しよう(PinchInput を使用していて、isNormalized=true であれば気にする必要ない:デフォルト設定)。自分で実装する場合は、以前のピンチの記事を参照して欲しい。これは画面解像度に依存しないようにした処置なので、同じような方法を用いてるなら「DistanceOperation.pinchSpeed」を調整すれば他のスクリプトからも利用できるだろう。この値(delta)は線形的な相対量なので、例えばボタンを押し続けると一定間隔で移動量を与えるなら(符号に注意)、同じように遠近操作できる。

 コールバックの登録の仕方がわからない場合は、以下の記事も参照して欲しい。

【Unity】【C#】UnityEvent, Action, delegate, interface でのコールバック実装方法とインスペクタでの登録



■SmoothFollow2 との相違点・対応パラメタ一覧

 大ざっぱに「SmoothFollow2」との主な変更点は、以下のようになる。

・一定角度の回転(45度/90度ごと等の旋回:Turn Operation)機能を追加。
・SwipeInput のコールバックでのスワイプで一定角度の旋回の連携を追加。
・PinchInput のコールバックでのピンチで距離の操作の連携を追加(モバイル用)。
・起動時に設定された対象(target)から、距離(distance)、高さ(height)、角度(preAngle)を算出するオプションを追加。
・初期状態へのリセットメソッド(ResetOperations())を追加。
・ドラッグの認識する画面上の領域(validArea)を追加。
・各設定をクラスで分けたので、変数名が変更された(機能は全て同じ)。

 角度や距離、高さの仕様はそのままなので、その辺りは以前の記事の SmoothFollow2の図解 を見て欲しい。

●変更されたパラメタ一覧(インスペクタ)
SmoothFollow2SmoothFollow3
Height DampingHeight Operation>Damping
Rotation DampingAngle Operation>Damping
Distance DampingDistance Operation>Damping
Angle Key OperationAngle Operation>Key Enable
Angle Key SpeedAngle Operation>Key Speed
Angle Key LeftAngle Operation>Key Left
Angle Key RightAngle Operation>Key Right
Angle Drag OperationAngle Operation>Drag Enable
Angle Drag SpeedAngle Operation>Drag Speed
Height Key OperationHeight Operation>Key Enable
Height Key SpeedHeight Operation>Key Speed
Height Key UpHeight Operation>Key Up
Height Key DownHeight Operation>Key Down
Height Drag OperationHeight Operation>Drag Enable
Height Drag SpeedHeight Operation>Drag Speed
Distance Key OperationDistance Operation>Key Enable
Distance MinDistance Operation>Min
Distance Key SpeedDistance Operation>Key Speed
Distance Key NearDistance Operation>Key Near
Distance Key FarDistance Operation>Key Far
Distance Wheel OperationDistance Operation>Wheel Enable
Distance Wheel SpeedDistance Operation>Wheel Speed

※基本的には各機能ごとのクラスにまとめた感じ。



■ユニティちゃんがアキバ上空を飛ぶ!(Android 版)

 以前に書いた「ユニティちゃんを飛行させる!」をスマホ用にビルドしたデモです。操作パッドは StandardAssets の CrossPlatformInput を使ってます(画像だけ替えてる)。アキバモデル(秋葉原っぽい街)は多少スケールを大きくしてますね(キャラに合わせるならもっと大きい方が実寸に近いと思うが、全体を眺めたいだけなので適当)。最適化などは何もしてないので、眺める角度によってはフレーム落ちしますが、タッチ操作を試すだけなら十分でしょう(画面半分から上でドラッグ、スワイプ、ピンチなどが実装してある)。当たり判定も無いので建物は基本的に突き抜けます(笑)。

 スタート地点は「昭和通り」のバスターミナル辺りですね。現在はつくばエクスプレスの改札口もあります。たぶん全部(100個)集めるのに1時間くらいはかかります。コインの獲得数は保存されているので、無理せず暇な時にでもどうそ。どうしても全部のコインが見つからないときはマップを載せておくので参考にして下さい(見ても難しいと思う)。結構良く街の並びも再現されてますね。色んな場所を飛んでみて楽しんで下さい。

(※) Unity 2017.3.0f3 / Windows10(x64) でビルド。Galaxy S7 Edge (Android 7.0) で確認。

デモアプリのダウンロード (Google Drive を利用)


Android 4.2以上
※「提供元不明アプリのインストール」許可が必要です。



※どうしても100個見つからないときは、以下にコインの位置を示したマップを掲載して置きます。参考にして下さい。

[>>クリックでコインのマップを見る(※ネタバレ注意)]



 参考までにインスペクタの設定はこちら(クリックでキャプチャ画像表示)。一応、横長(Landscape)前提で「Width Reference」(画面の横幅を長さの基準とする)でやってしまってますが、画面をローテーションできるアプリなら、短い方に合わせた方が良いかも知れません。




(関連記事)
【Unity】【C#】SmoothFollow に回転アングルと高さと距離の遠近機能を付けてみる
【Unity】【C#】SmoothFollow を C# で書いてみる
【Unity】【C#】ピンチ操作を取得してコールバックする
【Unity】【C#】スワイプ(フリック)を判定、方向を取得してコールバックする
【Unity】【C#】長押し(ロングタップ)を取得してコールバックする
【Unity】【C#】UnityEvent, Action, delegate, interface でのコールバック実装方法とインスペクタでの登録
【Unity】Androidのトーストやダイアログ、通知、音声認識、ハード音量操作など基本的な機能を使えるプラグインを作ってみた


スポンサーサイト

category: Unity

thread: ゲーム開発

janre: コンピュータ

tag: Unityライブラリ  Unityプラグイン  C#  カメラスクリプト 
tb: 0   cm: --

【Unity】【C#】ピンチ操作を取得してコールバックする  


 既にスワイプロングタップ(クリック)は掲載しているが、スマートフォン等はピンチという操作もよく使われるので、Androidのプラグインに同梱する形で配布することにした。プラグイン自体は Android 専用のものだが、今回のピンチや既存のスワイプロングタップは Unity 上でのスクリプトなので、iOS などでも使えると思う。

 ついでにこのピンチを使って、オブジェクトを拡大・縮小、またはカメラを近づけたり、遠ざけたりする方法(スクリプト)も書いておこう。これらはプラグインのパッケージにデモとしてセットアップされているので、自分で作るのが面倒な人はプラグインをダウンロードして、コピペで利用しても良いと思う。



>>プラグイン&サンプルをダウンロード
(Google Drive を利用。画面右上にあるダウンロードアイコンを押す)



(※) Unity 5.6.3p1 / Windows10(x64) / Galaxy S7 Edge (Android 7.0) で確認



●ピンチを取得してコールバックする

using System;
using UnityEngine;
using UnityEngine.Events;

namespace FantomLib
{
/// <summary>
/// ピンチ操作を取得してコールバックする
/// 2018/01/09 Fantom (Unity 5.6.3p1)
/// http://fantom1x.blog130.fc2.com/blog-entry-288.html
///(使い方)
///・適当な GameObject にアタッチして、インスペクタから OnPinchStart, OnPinch にコールバックする関数を登録すれば使用可。
///・またはプロパティ IsPinching, Width, Delta, Ratio をフレーム毎監視しても良い(こちらの場合は使用してない状態(IsPinching=false, Width=0, Delta=0, Ratio=1)も含まれる)。
///(仕様説明)
///・内部的には画面でタッチされた2本の指の間隔をピクセル単位で取得する。ただし戻り値は画面幅で割った正規化された値とピクセルそのもので返すかを選べる(isNormalized)。
///・ピンチの操作は1本→2本となったときのみ認識する。3本以上→2本になったときは無効。
///・タッチデバイスを UNITY_ANDROID, UNITY_IOS としているので、他のデバイスも加えたい場合は #if の条件文にデバイスを追加する(Input.touchCount が取得できるもののみ)。
/// </summary>
public class PinchInput : MonoBehaviour
{
public bool isNormalized = true; //画面幅(or 高さ)で正規化した値でコールバックする(false=ピクセル単位で返す)
public bool widthReference = true; //isNormalized=true のとき、画面幅(Screen.width)を基準にする(false=高さ(Screen.height)を基準)[単位が px/Screen.width のようになる]

//認識する画面上の領域(0.0~1.0)[(0,0):画面左下, (1,1):画面右上]
public Rect validArea = new Rect(0, 0, 1, 1);

//ピンチ検出プロパティ(フレーム毎取得用)
public bool IsPinching {
get; private set;
}

//ピンチ幅(距離) プロパティ(フレーム毎取得用)
public float Width {
get; private set;
}

//ピンチ幅(距離)の直前との差分 プロパティ(フレーム毎取得用)
public float Delta {
get; private set;
}

//ピンチ幅(距離)の変化比 プロパティ(フレーム毎取得用)
public float Ratio {
get; private set;
}


//ピンチ開始コールバック
[Serializable]
public class PinchStartHandler : UnityEvent<float, Vector2> { } //Width, center(2指間の中心座標)が返る
public PinchStartHandler OnPinchStart;

//ピンチ中コールバック(伸縮率とその差分)
[Serializable]
public class PinchHandler : UnityEvent<float, float, float> { } //Width, Delta, Ratio が返る
public PinchHandler OnPinch;


//Local Values
float startDistance; //ピンチ開始時の指の距離(px)
float oldDistance; //直前の伸縮距離(px)


//アクティブになったら、初期化する(アプリの中断などしたときはリセットする)
void OnEnable()
{
IsPinching = false;
}

// Update is called once per frame
void Update()
{
//プロパティはフレーム毎にリセット
Width = 0; Delta = 0; Ratio = 1;

#if !UNITY_EDITOR && (UNITY_ANDROID || UNITY_IOS) //タッチで取得したいプラットフォームのみ
if (Input.touchCount == 2) //ピンチでの操作(2本指のみ)
{
//※fingerId と touches[] のインデクスは必ずしも一致しないらしいので fingerId=1 となっている方を取得(指1本→2本になったとき可能とするため)
Touch touch = (Input.touches[1].fingerId == 1) ? Input.touches[1] : Input.touches[0];
if (!IsPinching && touch.phase == TouchPhase.Began) //新しく認識したときのみ
{
//認識する画面上の領域内か?(2本の指の中心の座標を基準にする)
Vector2 center = (Input.touches[0].position + Input.touches[1].position) / 2;
if (validArea.xMin * Screen.width <= center.x && center.x <= validArea.xMax * Screen.width &&
validArea.yMin * Screen.height <= center.y && center.y <= validArea.yMax * Screen.height)
{
IsPinching = true; //ピンチ開始

//fingerId=0~1 のみ(必ず最初と2本目の指)。指3本→2本(0-2 など)は不可とする。
Width = startDistance = oldDistance = Vector2.Distance(Input.touches[0].position, Input.touches[1].position);
if (isNormalized)
{
float unit = widthReference ? Screen.width : Screen.height;
Width /= unit; //画面幅で正規化すれば、解像度に依存しなくなる
center /= unit;
}

if (OnPinchStart != null)
OnPinchStart.Invoke(Width, center); //開始時は必ず Delta=0, Ratio=1 となる
}
}
else if (IsPinching) //既に認識されているときのみ:3本→2本になったときは無効になる
{
float endDistance = Vector2.Distance(Input.touches[0].position, Input.touches[1].position);
Width = endDistance;
Delta = endDistance - oldDistance; //直前との差分
Ratio = endDistance / startDistance; //開始時のピンチ幅(px距離)を基準にした倍率になる
oldDistance = endDistance;

if (isNormalized)
{
float unit = widthReference ? Screen.width : Screen.height;
Width /= unit; //画面幅で正規化すれば、解像度に依存しなくなる
Delta /= unit;
}

if (OnPinch != null)
OnPinch.Invoke(Width, Delta, Ratio);
}
}
else //タッチが2つでないときは全て無効にする
#endif
{
IsPinching = false;
}
}
}
}

 ピンチ検出の簡単な解説をしておくと、スマホなどのタッチデバイスで指を2本置いたとき、それぞれの座標からそれらの距離を取得し、指を動かすことによってその座標間の距離の差分でピンチと認識するというものである。

 ただこのスクリプトではもう少し細かく検出されていて、例えば指が1本→2本となったときはピンチと認識するが、指が3本以上→2本となったときは認識しない(1本、3本以上のときは常に無視する)

 またコールバックの引数で返ってくる値は「現在のピンチの幅(距離)」「直前のピンチ幅との差分」「ピンチ開始したときの比率」と3つの値を返す。そしてそれらは実機により画面解像度が違うため、正規化オブション(isNormalized = true)により、端末の解像度で割った値を返すようにしてある(isNormalized = false にするとピクセル単位で返ってくる)。

 他にもフレーム毎で取得したいときのプロパティ(IsPinching, Width, Delta, Ratio)や認識できる画面領域(validArea)などは以前に書いたスワイプロングタップと同じ仕様だ。実際にこれらスクリプトを同時に使用しても問題ないように作ってある。

 プラットフォームに関しては、タッチデバイスとして Android と iOS しか考えてないので、必要あればプリプロセッサ(#if 文)に条件を追加して欲しい。これらは Input.touchCount が取得できるものに限られる。しかしこのスクリプトの場合、Update() での検出部分は Input.touchCount で囲まれているので、スマホで使うこと前提なら、プリプロセッサ(#if 文)をコメントアウトしても良いだろう(Input.touchCount は PCなどでは無効になるため)。

 あと、コールバックイベントはインスペクタで設定(UnityEvent)するようにしてあるが、コードだけにしたいなら、Action を使ったコールバックや、直接 delegate でコールバックするように書き換えても良いだろう。もちろんそのままで UnityEvent.AddListener を使ってコードから追加する方法もある。自由に改造して使って欲しい(※Unity でのコールバック実装は以下を参照)。

【Unity】【C#】UnityEvent, Action, delegate, interface でのコールバック実装方法とインスペクタでの登録



■ピンチでオブジェクトを拡大・縮小する

 ここからは上記の「PinchInput」を使って、実際にオブジェクトの拡大・縮小の操作をやってみよう。このスクリプトは使い回しできるのでライブラリとして追加もしてある。考え方を理解すれば他のものに利用することも可能だろう。
●ピンチでオブジェクトを拡大・縮小する
using UnityEngine;

namespace FantomLib
{
/// <summary>
/// ピンチでスケールを変化させる(ローカルスケール)
/// 2018/01/09 Fantom (Unity 5.6.3p1)
/// http://fantom1x.blog130.fc2.com/blog-entry-288.html
///(使い方)
///・伸縮したい GameObject にアタッチして、インスペクタから PinchInput のコールバックを登録すれば使用可。
/// </summary>
public class PinchToScale : MonoBehaviour
{
public Transform target; //スケール変化させるオブジェクト

//Local Values
Vector3 startScale; //ピンチ開始時スケール
Vector3 initScale; //起動初期スケール(リセット用)


// Use this for initialization
private void Start()
{
if (target == null)
target = gameObject.transform; //指定がないときは自身を対象とする

initScale = target.localScale;
}

//width: ピンチ幅, center: ピンチの2本指の中心の座標
public void OnPinchStart(float width, Vector2 center)
{
if (target != null)
startScale = target.localScale;
}

//width: ピンチ幅, delta: 直前のピンチ幅の差, ratio: ピンチ幅の開始時からの伸縮比(1:ピンチ開始時, 1以上拡大, 1より下(1/2,1/3,...)縮小)
public void OnPinch(float width, float delta, float ratio)
{
if (target != null)
target.localScale = startScale * ratio;
}

//スケールを元に戻す
public void ResetScale()
{
if (target != null)
target.localScale = initScale;
}
}
}


 仮に拡大・縮小するオブジェクトを「Cube」としたとき、インスペクタでの設定は上のようにしよう。スクリプト自体は他の GameObject にアタッチしても構わないが、「PinchToScale」の「Target」には対象となるオブジェクトを指定し、「PinchInput」のピンチ開始時のコールバック「OnPinchStart」に「PinchToScale.OnPinchStart」を登録(Dynamicの方)、ピンチ中のコールバック「OnPinch」には「PinchToScale.OnPinch」を登録(Dynamicの方)する。あとは「PinchInput」の「isNormalize」がオンになっているのを確認すればOKだ(デフォルト=オン)。

 スクリプトの簡単な解説をしておくと、ピンチ開始時のコールバック(OnPinchStart)のときのピンチ幅(指と指の距離)を記録し、ピンチ中は指の開閉による距離の比率(指を開くと 2, 3, 4...倍[小数も含む]となり、指を閉じると 1/2, 1/3, 1/4,..倍[実際には小数=0.5, 0.3, 0.25,...など])で拡大・縮小を行っている。この操作には主にコールバック引数の「ratio」(比率)を使っている。また、スケール操作はローカルスケール(transform.localScale)になる。ただ1つだけ注意して欲しいのは、物理的に指を3倍、4倍と開くのはキツイけど、指を1/3, 1/4 倍と閉じるのは簡単だということだ。指の間隔を比率ではなく、線形的な差分にしたいなら、コールバック引数の「delta」(距離差分)を使っても良いが、実際に試してみると ratio(比率)の方が人間の感覚・見た目には合ってるようだ。線形でやりたい場合はカーブを間に挟んだ方が良い感じになるかも知れない。その辺りは自分で工夫してみて欲しい。



■ピンチでカメラの遠近を操作する

 次に上記の「PinchInput」を使って、視点となるオブジェクトの遠近操作をやってみよう。このスクリプトは使い回しできるのでライブラリとして追加もしてある。考え方を理解すれば他のものに利用することも可能だろう。
●ピンチでオブジェクトとの距離を操作する
using System;
using UnityEngine;

namespace FantomLib
{
/// <summary>
/// ピンチで距離を操作する
/// 2018/01/09 Fantom (Unity 5.6.3p1)
/// http://fantom1x.blog130.fc2.com/blog-entry-288.html
///(使い方)
///・カメラなどの GameObject にアタッチして、インスペクタから PinchInput のコールバックを登録すれば使用可。
///・距離は target からの直線距離となる。
/// </summary>
public class PinchToDistance : MonoBehaviour
{
public Transform target; //視点となるオブジェクト
public float speed = 2f; //変化速度
public float minDistance = 1.0f; //近づける最小距離
public bool lookAt = true; //オブジェクトの方を向く

//LocalValues
float initDistance; //起動初期距離(リセット用)


// Use this for initialization
private void Start()
{
if (target != null)
{
Vector3 dir = target.position - transform.position;
initDistance = dir.magnitude;
if (lookAt)
transform.LookAt(target.position);
}
}

//width: ピンチ幅, delta: 直前のピンチ幅の差, ratio: ピンチ幅の開始時からの伸縮比(1:ピンチ開始時, 1以上拡大, 1より下(1/2,1/3,...)縮小)
public void OnPinch(float width, float delta, float ratio)
{
if (target == null)
return;

Vector3 dir = target.position - transform.position;
float distance = Math.Max(minDistance, dir.magnitude - delta * speed);
Vector3 pos = target.position - dir.normalized * distance;
transform.position = pos;
if (lookAt)
transform.LookAt(target.position);
}

//初期の距離に戻す
public void ResetDistance()
{
if (target == null)
return;

Vector3 dir = target.position - transform.position;
Vector3 pos = target.position - dir.normalized * initDistance;
transform.position = pos;
if (lookAt)
transform.LookAt(target.position);
}
}
}


 仮に視点先となるオブジェクトを「Cube」としたとき、インスペクタでの設定は上のようにしよう。スクリプト自体は他の GameObject にアタッチしても構わないが、「PinchToDistance」の「Target」には対象となるオブジェクトを指定し、「PinchInput」のピンチ開始時のコールバック「OnPinchStart」に「PinchToDistance.OnPinchStart」を登録(Dynamicの方)、ピンチ中のコールバック「OnPinch」には「PinchToDistance.OnPinch」を登録(Dynamicの方)する。あとは「PinchInput」の「isNormalize」がオンになっているのを確認すればOKだ(デフォルト=オン)。

 スクリプトの簡単な解説をしておくと、ピンチ開始時のコールバック(OnPinchStart)は使ってないのだが、ピンチ中は指の開閉による直線の距離の差分(指を開くと正の値が、指を閉じると負の値が返る)で遠近操作を行っている。この操作には主にコールバック引数の「delta」(距離差分)を使っていて、直前の指と指の距離との差を利用し、オブジェクトとの距離を加減算することによって、カメラを移動する感じだ(ただし指を開いたときに近づくには距離をマイナス、指を閉じたときは遠くにするには距離をプラスなので、符号は逆)。これは「ratio」(比率)とは違って線形な値になるので、指を開くときも閉じるときも同じような相対量になる(「ratio」(比率)の解説は PinchToScale を参照)。

 具体的な値については「isNormalize」がオンになっているときは画面幅で正規化(widthReference=falseのときは画面の高さで正規化)された値が返り、オフになっているときはピクセル単位で返ってくる。それは例えば、画面解像度の幅が 800px の機種と 960px の機種があったとき、ピクセル単位だと画面の半分を移動したときそれぞれ、400px と 480px と異なった値になってしまうからだ。なので、端末の画面幅で割っておけば(=正規化する)どちらも 0.5 (= 400/800, 480/960)となり、画面解像度に依存しなくなる。しかし実際のピクセル座標で欲しいときは isNormalize=false にするか、正規化された値×解像度でもピクセル単位に変換できるので、好きな方を使うと良いだろう。



(関連記事)
【Unity】【C#】スワイプ(フリック)を判定、方向を取得してコールバックする
【Unity】【C#】長押し(ロングタップ)を取得してコールバックする
【Unity】【C#】UnityEvent, Action, delegate, interface でのコールバック実装方法とインスペクタでの登録
【Unity】Androidのトーストやダイアログ、通知、音声認識、ハード音量操作など基本的な機能を使えるプラグインを作ってみた


category: Unity

thread: ゲーム開発

janre: コンピュータ

tag: Unityライブラリ  Unityプラグイン  C# 
tb: 0   cm: --

【VisualStudio】【C#】ウィンドウのリサイズを制限する  


 前回、固定ウィンドウの設定方法「Form.FormBorderStyle」プロパティを書いたが、他の方法としてリサイズでの制限も書いておこう。最小・最大を同じにすれば、実質固定ウィンドウになる(※ただし、フォーム右下でリサイズポインタになる)。

 ウィンドウのリサイズを制限するにはフォームの「Form.MinimumSize」「Form.MaximumSize」プロパティを指定する


(※) Visual Studio 2017 / Windows10(x64) で確認

 しかし、せっかくビジュアルツールを使っているのだから、デザイン画面で設定したサイズをそのまま使いたいことも多い。なので、コードで書く方法も覚えておこう。

●リサイズに「元のサイズ~2倍」の制限を付ける
using System;
using System.Drawing;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}

private void Form1_Load(object sender, EventArgs e)
{
//ウィンドウの最小サイズを初期サイズにする
this.MinimumSize = new Size(this.Width, this.Height);

//ウィンドウの最大サイズを初期サイズの2倍までにする
this.MaximumSize = new Size(this.Width * 2, this.Height * 2);
}
}
}

●元~2倍までリサイズできる




 またあまりスマートなやり方とは言えないが、以下のように最小・最大サイズを元のサイズにすれば、リサイズできないウィンドウになる。ただし、フォーム右下でリサイズポインタになるので、フォームを固定サイズにした方がユーザーにとってもわかりやすいだろう。

this.MinimumSize = this.MaximumSize = new Size(this.Width, this.Height);



(関連記事)
【VisualStudio】フォームを固定ウィンドウ(ダイアログ)にする
【VisualStudio】ウィンドウの最大化・最小化を無効にする


category: VisualStudio

thread: プログラミング

janre: コンピュータ

tag: VisualStudioリファレンス  C# 
tb: 0   cm: --

【Unity】AnimationCurve をコードで初期化する  


 ただのメモ。簡単にイージングなどの効果の得られる「AnimationCurve」をインスペクタで設定できるようにしたとき、あらかじめ初期値を入れておきたいことってあるよね。カーブは一度エディタで登録しておけば使い回しできるけど、デフォルトに無い曲線などのときはコードで設定おくと便利。ググれば複雑なカーブのライブラリもあるけど、手軽に作ってしまいたいときの簡易リファレンス的な。


(※) Unity 2017.2.0b9 / Windows10(x64) で確認



■直線的なカーブ(Static Function)

using UnityEngine;

public class AnimationCurveTest : MonoBehaviour {

public AnimationCurve curve = AnimationCurve.Linear(0, 0, 1, 1); //timeStart, valueStart, timeEnd, valueEnd

}

●カーブエディタでは

●移動に使ってみると

 AnimationCurve.Linear() は static なメソッドである。引数は開始と終了の時間と値のペアを列挙したものになる。



■イージング的なカーブ(ゆっくりと始まり、ゆっくりと終わる)(Static Function)

using UnityEngine;

public class AnimationCurveTest : MonoBehaviour {

public AnimationCurve curve = AnimationCurve.EaseInOut(0, 0, 1, 1); //timeStart, valueStart, timeEnd, valueEnd

}

●カーブエディタでは

●移動に使ってみると

 AnimationCurve.EaseInOut() も static なメソッドである。引数は開始と終了の時間と値のペアを列挙したものになる。ゆっくりと始まり、ゆっくりと終わる移動などが簡単に作れる。



■トップスピード→だんだんゆっくりになっていくイージング[※プリセットと同じ](カスタム:Keyframe を2つ使う)

using UnityEngine;

public class AnimationCurveTest : MonoBehaviour {

public AnimationCurve curve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 2f), //time, value, inTangent, outTangent
new Keyframe(1f, 1f, 0f, 0f) //time, value, inTangent, outTangent
);
}

●カーブエディタでは

●移動に使ってみると

 このカーブはエディタのプリセットにも入っているが、上記のようにコードで設定することもできる。独自にカーブのポイントを打っていくには「Keyframe」を「AnimationCurve」のコンストラクタに列挙していく。

 「inTangent, outTangent」とはエディタのグラフから見れば、それぞれのポイントの左側・右側の傾きで、タンジェント(Tangent)というのは数学的に y/x で表せる傾きのことだから、0.5f(=1/2) なら「上に1, 右に2」の傾き、2f(=2/1) なら「上に2, 右に1」の傾きと考えれば良いのではないだろうか。時間軸に対する値なので、時間や値の逆数(分子と分母を入れ替える)を設定すれば簡単に反転したカーブも作れる。



■ジャンプのようなイージング(カスタム:Keyframe を3つ使う)

using UnityEngine;

public class AnimationCurveTest : MonoBehaviour {

public AnimationCurve curve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 4f), //time, value, inTangent, outTangent
new Keyframe(0.5f, 1f, 0f, 0f), //time, value, inTangent, outTangent
new Keyframe(1f, 0f, -4f, 0f) //time, value, inTangent, outTangent
);
}

●カーブエディタでは

●移動に使ってみると(Y軸方向)

 「トップスピード→だんだんゆっくり→頂点まで来たら反転→だんだん速く」といったジャンプのような動きになる。独自にカーブのポイントを打っていくには「Keyframe」を「AnimationCurve」のコンストラクタに列挙していく。

 「inTangent, outTangent」とはエディタのグラフから見れば、それぞれのポイントの左側・右側の傾きで、タンジェント(Tangent)というのは数学的に y/x で表せる傾きのことだから、0.5f(=1/2) なら「上に1, 右に2」の傾き、2f(=2/1) なら「上に2, 右に1」の傾きと考えれば良いのではないだろうか。時間軸に対する値なので、時間や値の逆数(分子と分母を入れ替える)を設定すれば簡単に反転したカーブも作れる。



■線形補間に利用する

 一応使い方を書いておくと、「AnimationCurve.Evaluate()」を使って、時間軸に対する値を取り出せばカーブのような変化になる。特に線形補間系のメソッド「~.Lerp()」と組み合わせると使い勝手が良い。以下のサンプルはオブジェクトの位置をスイっと動かす、かなりの手抜きのコードだが、応用すればアルファでフェードイン・アウトしたり、スケールを変化してアクションを表現したり、スワイプでスイっと動かすなんてこともできる(もちろんエディタ上ではアニメーションクリップを使う手もある)。

using UnityEngine;

public class AnimationCurveTest : MonoBehaviour {

public AnimationCurve curve = new AnimationCurve(
new Keyframe(0f, 0f, 0f, 2f), //time, value, inTangent, outTangent
new Keyframe(1f, 1f, 0f, 0f) //time, value, inTangent, outTangent
);

public Vector3 destination;
public float duration = 1f;

Vector3 origin;
float passed = 0;

// Use this for initialization
void Start () {
origin = transform.position;
}

// Update is called once per frame
void Update () {
passed += Time.deltaTime;
float t = curve.Evaluate(Mathf.Clamp01(passed / duration));
transform.position = Vector3.Lerp(origin, destination, t);
}
}


 もっと複雑なカーブが欲しい・イージングタイプを知りたい場合は、以下の参考URLを使うのも良いだろう。

(参考)
AnimationCurveのプリセットにペナーイージングを加える
AnimationCurveをInspectorで設定し、スクリプトから使う
Easing Function早見表

※トゥイーンとして使うなら DOTween みたいなものでも良いかも。
DOTween (Asset Store)
DOTween (公式:英語)
DOTweenをふわっとまとめてみた
【Unity】Tween アニメーション(DOTween)の話

■DOTween 参考書籍



●床や柱のイルミネーションにイージングを入れて、いい感じにフェードイン・アウトさせている


(関連記事)
【Unity】【C#】スワイプ(フリック)を判定、方向を取得してコールバックする


category: Unity

thread: ゲーム開発

janre: コンピュータ

tag: Unityリファレンス  C# 
tb: 0   cm: --

【Unity】【C#】床面(Yaw)/YZ平面(Pitch)に射影された角度を、符号(方向)付きで求める [3D]  


 今回は前回の「2Dの外積」を利用して、3DでXZ平面(床面)またはYZ平面に射影された正負符号付き角度を求めてみよう。例えばこれは相対的にキャラの左右に向く角度(Yaw)と、上下に向く角度(Pitch)を求めるのに使える。

 気をつけて欲しいのは今回用いる「2Dの外積」というのは本来の「外積」の意味ではないということだ。外積の定義は以下に参考URLを載せておくが、この 3Dの外積の1つの軸を無視して(0を代入したものと考えても良い)「XZ平面」または「YZ平面」に適用したものとなる。その使い方は前回詳しく記したので割愛するが、要するに「2Dの外積」は平面上で方向を表すのに便利なので、その性質を利用しようということだ。数学的にきちんと定義されているものではないので注意しよう。

(参考)外積の定義

 また、Unity では Vector3.Angle() という便利な関数があるが、これも利用する。ただし、この関数が返す角度は大きさだけであって、その方向(正負符号)は含まれてない。あるオブジェクトから対象オブジェクトの角度を調べたとき、その方向(右か左/上か下か等)も知りたいことは多いだろう。それを計算するために「なんちゃって外積」(2D外積)を利用しようというわけだ(笑)。

 今回は 2Dのときと違って、3D では少なくとも「XZ平面」「YZ平面」「XY平面」3つの平面を基準と考えてみる必要がある。しかし「XY平面」というのは 2Dで考えた平面と同じものなので、解説は省略することにする。Unity で 3D モードを使用する場合、「XZ平面」は床の平面となり利用頻度が高いので、これを中心に考えよう。「YZ平面」「XY平面」も同じように考えるものとする。

 今回も正負の方向や2D外積の定義などかなり詳細に記しているので、既に知っている、またはとりあえず今すぐ使いたいなどの場合は「■XZ平面で符号付き角度を求める [3D]」まで読み飛ばした方が良いだろう。


(※) Unity 5.6.2p2 / Windows10(x64) で確認



■3Dでの回転方向と符号の対応

 まずはじめに、Unity 上での回転方向の符号を調べておこう。数学的なものを用いるときに気をつけなければならないのは、Unity は左手系座標で数学は右手系座標なので、符号や見た目方向が異なる点だ。ちなみに「右手系」「左手系」というのは親指を X軸、人差し指を Y軸、中指を Z軸に見立てて、座標軸の正方向に合わせればわかる。腕をひっくり返しても良い。詳しくは以下の参考URLでも見て欲しいが、とりあえずここでは理屈は抜きにして、実際に試すことで正負を調べてみよう。

(参考)3D 座標系


回転なしの状態。Z軸の奥の方向が正、手前方向が負となっている。


Rotation X を 30度回転した状態。前方が床に向かう方が正、空へ向かう方が負となる。


Rotation Y を 30度回転した状態。右を向く方が正、左を向く方が負となる。


Rotation Z を 30度回転した状態。左へ体を傾ける方が正、右へ傾ける方が負となる。
2Dモードを使っているときと同じ動作になる。

 試してみると、Rotation の X を正方向に 30 度回転すると、床方向に傾き、Rotation の Y を正方向に 30 度回転すると、右方向を向き、Rotation の Z を正方向に 30 度回転すると、体を左に傾ける感じになる。ただしこれはあくまでカメラがZ軸に沿って、キャラ(▲)の後ろから写している場合であって、例えば向かい側から写していたら、逆向きに見えるので気をつけよう。つまり、常に何かを基準に考えることが必要である。まとめると以下のようになる。これら正負符号の方向を基準として、次にそれぞれの平面での2D外積を考えてみよう。


Unity の 3D での回転方向と符号(前、上、右は Z軸正方向を向いているとき)




■XZ平面(床面/Yaw)での2D外積の定義

 冒頭に書いたように、今回使う2D外積は本来の3Dのものとは異なる。その本来の外積の成分表示は参考資料を見て欲しいが、ここでは 2Dのように使うので、以下の式を使う。なお、Vector3 を利用したいので、成分[=座標]:(a1, a3), (b1, b3) → (ax, az), (bx, bz) に置き換えてある。式を見ただけではどういう計算をしているのかイマイチわからないので、図のように成分の外側と内側を掛け合わせて差を取るということを機会的に覚えてしまう方が良いだろう。理論より実践で使えることの方がよほど重要なことである(笑)。また、XZ平面(床面)で考えるにあたって、Y軸を無視して消去したものであることに注意して欲しい。y 成分に0を代入したもの、または XZ平面に射影したベクトルと考えても良い。

(参考)外積の成分表示


●成分の外側と内側を掛け合わせて差を取る


●XZ平面(床面)で2D外積を求める関数
using UnityEngine;

/// <summary>
/// XZ 平面(床面)での2D外積を求める
///・a×b = a1b3 - a3b1
///・2D 的に計算する。y軸を無視したものと考える。
///・外積 = 0 のとき、両ベクトルは平行(0または180度)。
///・外積 > 0 のとき、transform.forward を基準にすると左側。
///・外積 < 0 のとき、transform.forward を基準にすると右側。
///※Y軸での回転とは正負が逆になるので注意。
/// </summary>
/// <param name="a">基準の方向ベクトル</param>
/// <param name="b">対象の方向ベクトル</param>
/// <returns>2Dの外積</returns>
public static float CrossXZ(Vector3 a, Vector3 b)
{
return a.x * b.z - a.z * b.x;
}

 コードにすると上記のようになる。図を見れば説明はいらないだろう。その対応はこれから調べることにする。



■XZ平面(床面/Yaw)での2D外積と符号の対応

 さて、XZ平面での2Dでの外積の計算も示したので、今度は具体的に座標を代入して計算してみよう。調べる座標をすべて1で構成すれば、暗算でも可能だろう。▲の進行方向を基準として、XZ平面(床面)で左右の2D外積を求めると、以下の図のようになる。Y軸を無視しているので、上空から見てる感じと思って良いだろう。同じ象限にいれば符号は変わらないので、これと前述の「3Dでの回転方向と符号の対応」(Y軸で回転) と照らし合わせれば、符号が逆であることがわかる。ちなみに平行なベクトル(0または180度)の2D外積は0になる。


Y軸を無視したXZ平面は上空から見ている感じになる(※地の底から空へでも良いが逆向きに見えるので注意)。

・(0, 0, 1) が基準で、(-1, 0, 1) の XZ平面での 2D外積は、(0, 1)×(-1, 1) = 0 * 1 - 1 * (-1) = 0 + 1 = +1
・(0, 0, 1) が基準で、(1, 0, 1) の XZ平面での 2D外積は、(0, 1)×(1, 1) = 0 * 1 - 1 * 1 = 0 - 1 = -1

・(1, 0, 0) が基準で、(1, 0, 1) の XZ平面での 2D外積は、(1, 0)×(1, 1) = 1 * 1 - 0 * 1 = 1 - 0 = +1
・(1, 0, 0) が基準で、(1, 0, -1) の XZ平面での 2D外積は、(1, 0)×(1, -1) = 1 * (-1) - 0 * 1 = (-1) - 0 = -1

・(0, 0, -1) が基準で、(1, 0, -1) の XZ平面での 2D外積は、(0, -1)×(1, -1) = 0 * (-1) - (-1) * 1 = 0 + 1 = +1
・(0, 0, -1) が基準で、(-1, 0, -1) の XZ平面での 2D外積は、(0, -1)×(-1, -1) = 0 * (-1) - (-1) * (-1) = 0 - 1 = -1

・(-1, 0, 0) が基準で、(-1, 0, -1) の XZ平面での 2D外積は、(-1, 0)×(-1, -1) = (-1) * (-1) - 0 * (-1) = 1 + 0 = +1
・(-1, 0, 0) が基準で、(-1, 0, 1) の XZ平面での 2D外積は、(-1, 0)×(-1, 1) = (-1) * 1 - 0 * (-1) = (-1) + 0 = -1




■XZ平面(床面/Yaw)で符号付き角度を求める [3D]

 以上で、XZ平面(床面)では回転方向と2D外積の符号が逆であることがわかった。つまり符号付きで角度を求めるには、

 XZ平面(床面)に射影されたベクトルの符号付き角度=射影されたベクトルの角度の大きさ × 2D外積の符号×(-1)

で良いことがわかる。注意して欲しいのは、これは Unity の座標系でのことであって、基準方向(軸)によって符号が異なったりするので気をつけよう。また、冒頭に書いた「座標系」でも回転方向や符号は異なるので、少なくとも1度は調べておいた方が良い。ともかく、Unity での XZ平面(床面)での符号付き角度の計算は以下のようになる。

●XZ平面(床面)での符号付き角度を返す
using UnityEngine;

/// <summary>
/// XZ平面(床面)での方向ベクトル同士の角度を符号付きで返す(度)
///・XZ平面(床面)に射影された from から to への角度を返す。
///・transform.forward を基準とすると、右向き(時計回り)が正、左向き(反時計回り)が負となる。
/// </summary>
/// <param name="from">基準の方向ベクトル</param>
/// <param name="to">対象の方向ベクトル</param>
/// <returns>-180 <= t <= 180 [degree]</returns>
public static float AngleXZWithSign(this Vector3 from, Vector3 to)
{
Vector3 projFrom = from;
Vector3 projTo = to;
projFrom.y = projTo.y = 0; //y軸を無視する(XZ平面に射影する)
float angle = Vector3.Angle(projFrom, projTo);
float cross = CrossXZ(projFrom, projTo);
return (cross != 0) ? angle * -Mathf.Sign(cross) : angle; //2D外積の符号を反転する
}

 関数内部で使っている「CrossXZ()」(XZ平面での2D外積)は前述のものを使う。解説も前述の図や計算式を見れば十分だろう。

 なお、XZ平面にベクトルを射影する部分は、y 成分に0を代入することで取得しているが、「Vector3.ProjectOnPlane()」を使って取得しても良い。結果は同じになる。

Vector3 projFrom = Vector3.ProjectOnPlane(from, Vector3.up);
Vector3 projTo = Vector3.ProjectOnPlane(to, Vector3.up);
//projFrom.y = projTo.y = 0; //※この行は不要になる

 これを使って、基準となる方向ベクトル(引数:from)に transform.forward を、対象への方向ベクトル(引数:to)に target.transform.position - transform.position を代入すれば、常に進行方向に対して対象が右方向(時計回り)にいるか(正の値)、左方向(反時計回り)にいるか(負の値)とその角度がわかる。もちろん、引数の基準となる方向ベクトルと対象の方向ベクトルを逆にすると、符号が逆になる。


Yaw での回転の角度と考える




■YZ平面(Pitch)で符号付き角度を求める [3D]

 ここまで理解できたら、あとは駆け足で行こう。YZ平面(Pitch)での2D外積の定義とその符号、回転との対応を同じように調べてみよう。

●YZ平面(Pitch)での2D外積の定義


●YZ平面(Pitch)で2D外積を求める関数
using UnityEngine;

/// <summary>
/// YZ 平面(Pitch)での2D外積を求める
///・a×b = a2b3 - a3b2
///・2D 的に計算する。x軸を無視したものと考える。
///・外積 = 0 のとき、両ベクトルは平行(0または180度)。
///・外積 > 0 のとき、transform.forward を基準にすると下側。
///・外積 < 0 のとき、transform.forward を基準にすると上側。
/// </summary>
/// <param name="a">基準の方向ベクトル</param>
/// <param name="b">対象の方向ベクトル</param>
/// <returns>2Dの外積</returns>
public static float CrossYZ(Vector3 a, Vector3 b)
{
return a.y * b.z - a.z * b.y;
}


●YZ平面(Pitch)での2D外積と符号の対応

X軸を無視したYZ平面は横(右)から見た感じになる。緑の横線が床となり、原点を中心にぐるりと回転する感じ。
キャラが人型なら、でんぐり返しが正方向、バク転が負方向になる。

・(0, 0, 1) が基準で、(0, 1, 1) の YZ平面での 2D外積は、(0, 1)×(1, 1) = 0 * 1 - 1 * 1 = 0 - 1 = -1
・(0, 0, 1) が基準で、(0, -1, 1) の YZ平面での 2D外積は、(0, 1)×(-1, 1) = 0 * 1 - 1 * (-1) = 0 + 1 = +1

・(0, -1, 0) が基準で、(0, -1, 1) の YZ平面での 2D外積は、(-1, 0)×(-1, 1) = (-1) * 1 - 0 * (-1) = (-1) + 0 = -1
・(0, -1, 0) が基準で、(0, -1, -1) の YZ平面での 2D外積は、(-1, 0)×(-1, -1) = (-1) * (-1) - 0 * (-1) = 1 + 0 = +1

・(0, 0, -1) が基準で、(0, -1, -1) の YZ平面での 2D外積は、(0, -1)×(-1, -1) = 0 * (-1) - (-1) * (-1) = 0 - 1 = -1
・(0, 0, -1) が基準で、(0, 1, -1) の YZ平面での 2D外積は、(0, -1)×(1, -1) = 0 * (-1) - (-1) * 1 = 0 + 1 = +1

・(0, 1, 0) が基準で、(0, 1, -1) の YZ平面での 2D外積は、(1, 0)×(1, -1) = 1 * (-1) - 0 * 1 = (-1) - 0 = -1
・(0, 1, 0) が基準で、(0, 1, 1) の YZ平面での 2D外積は、(1, 0)×(1, 1) = 1 * 1 - 0 * 1 = 1 - 0 = +1

 前述の「3Dでの回転方向と符号の対応」(X軸で回転) と照らし合わせてみると、符号が同じであることがわかる。つまり以下のようになる。

 YZ平面(Pitch)に射影されたベクトルの符号付き角度=射影されたベクトルの角度の大きさ × 2D外積の符号

●YZ平面(Pitch)での符号付き角度を返す
using UnityEngine;

/// <summary>
/// YZ平面(Pitch)での方向ベクトル同士の角度を符号付きで返す(度)
///・YZ平面(Pitch)に射影された from から to への角度を返す。
///・transform.forward を基準とすると、右向き(時計回り)が正、左向き(反時計回り)が負となる。
///・transform.forward を基準とすると、下方向が正、上方向が負となる。
/// </summary>
/// <param name="from"></param>
/// <param name="to"></param>
/// <returns>-180 <= t <= 180 [degree]</returns>
public static float AngleYZWithSign(this Vector3 from, Vector3 to)
{
Vector3 projFrom = from;
Vector3 projTo = to;
projFrom.x = projTo.x = 0; //x軸を無視する(YZ平面に射影する)
float angle = Vector3.Angle(projFrom, projTo);
float cross = CrossYZ(projFrom, projTo);
return (cross != 0) ? angle * Mathf.Sign(cross) : angle;
}

 関数内部で使っている「CrossYZ()」(YZ平面での2D外積)は前述のものを使う。解説も前述の図や計算式を見れば十分だろう。

 なお、YZ平面にベクトルを射影する部分は、x 成分に0を代入することで取得しているが、「Vector3.ProjectOnPlane()」を使って取得しても良い。結果は同じになる。

Vector3 projFrom = Vector3.ProjectOnPlane(from, Vector3.right);
Vector3 projTo = Vector3.ProjectOnPlane(to, Vector3.right);
//projFrom.x = projTo.x = 0; //※この行は不要になる

 これを使って、基準となる方向ベクトル(引数:from)に transform.forward を、対象への方向ベクトル(引数:to)に target.transform.position - transform.position を代入すれば、常に進行方向に対して対象が下方向にいるか(正の値)、上方向にいるか(負の値)とその角度がわかる。もちろん、引数の基準となる方向ベクトルと対象の方向ベクトルを逆にすると、符号が逆になる。


Pitch での回転の角度と考える




■XY平面(Roll)で符号付き角度を求める [3D]

 最後に XY平面(Roll)での符号付き角度を求めてみよう。といっても XY平面というのは Unity の 2Dモードそのものなので、詳しい解説は 2Dの記事を見て欲しい。ここでは 2Dのときの引数を Vector2 → Vector3 に書き換えただけのものを掲載しておく。実は Unity は Vector2 ←→ Vector3 の相互変換を自動でやってくれるので必要はないのだが、インテリセンス(入力補完)は型で関数の一覧を出すので、あった方が何かと便利だろう。内容的には今までのものと同じなので、難しくはないハズだ。

●XY平面(Roll)で2D外積を求める関数と符号付き角度を返す関数
using UnityEngine;

//※Cross2D() と同じ内容で、引数を Vector3 にしたもの
/// <summary>
/// XY 平面(Roll)での2D外積を求める
///・a×b = a1b2 - a2b1
///・2D 的に計算する。z軸を無視したものと考える。
///・外積 = 0 のとき、両ベクトルは平行(0または180度)。
///・外積 > 0 のとき、transform.up を基準にすると左側。
///・外積 < 0 のとき、transform.up を基準にすると右側。
/// </summary>
/// <param name="a">基準の方向ベクトル</param>
/// <param name="b">対象の方向ベクトル</param>
/// <returns>2Dの外積</returns>
public static float CrossXY(Vector3 a, Vector3 b)
{
return a.x * b.y - a.y * b.x;
}

//※AngleWithSign() とほぼ同じ内容で、引数を Vector3 にしたもの
/// <summary>
/// XY平面(Roll)での方向ベクトル同士の角度を符号付きで返す(度)
///・XY平面に射影された from から to への角度を返す。
///・transform.up を基準とすると、左側(反時計回り)に位置するとき正、右側(時計回り)に位置するとき負となる。
/// </summary>
/// <param name="from">基準の方向ベクトル</param>
/// <param name="to">対象の方向ベクトル</param>
/// <returns>-180 <= t <= 180 [degree]</returns>
public static float AngleXYWithSign(this Vector3 from, Vector3 to)
{
Vector3 projFrom = from;
Vector3 projTo = to;
projFrom.z = projTo.z = 0; //z軸を無視する(XY平面に射影する)
float angle = Vector3.Angle(projFrom, projTo);
float cross = CrossXY(projFrom, projTo);
return (cross != 0) ? angle * Mathf.Sign(cross) : angle;
}

 「Cross2D()」と「AngleWithSign()」の詳細は前回の記事を見て欲しい。

 ちなみに 3Dでの XY平面というのは Unity の場合 Roll であって、例えるなら首をかしげて見る回転のことである。なので transform.forward を基準にするといつでも直角になってしまうので、transform.up を基準として考えた方が良い。つまり頭のてっぺん方向を基準として左に首をかしげた場合、正方向であり、右に首をかしげた場合、負の方向となる。前述の「3Dでの回転方向と符号の対応」(Z軸で回転) と照らし合わせておこう。


Roll での回転の角度と考える


 以上で、XZ平面(Yaw)、YZ平面(Pitch)、XY平面(Roll)の3平面に射影した角度を符号付きで求めたが、射影しないで直接の符号付き角度を求めたいなら、どの方向を符号の基準にするかの軸を引数にし、これら3平面のどれかの2D外積と Vector3.Angle() を掛け合わせば良いと思う。とは言え、大抵は床面を基準とした方向を知りたいことが多く、2つのベクトルの中間的な方向なら Vector3.Lerp() のような関数で求められるので、あまりまとめる必要はないかも知れない。


 これら関数を上手く利用すれば、NavMeshAgent なしでも敵がプレイヤーを追跡してくるなんてものも簡単にできる。例えば以下のサンプルはプレイヤーがキャラ(猫耳娘)に一定距離近づくと、プレイヤーとの角度によって近づいてくるように作ってある。その角度は「AngleXZWithSign()」を使って求めている。またプレイヤー追跡は簡単な線形補間だ。経路検索してないのでとても軽い。ここでは300体のキャラを同時に動かしている。



 ちなみに Yaw(ヨー)、Pitch(ピッチ)、Roll(ロール)は回転する方向のことであって、それぞれ Y, X, Z軸で回転するという意味ではない。なので例えば、右手系座標のときは Z, Y, X軸がそれぞれ Yaw, Pitch, Roll になったりもする。簡単な覚え方は、首を左右に振る回転をYaw(ヨー)、首を縦に振る回転をPitch(ピッチ)、首をかしげる回転をRoll(ロール)と覚えれば楽だ。これなら 座標系が異なるときでも回転方向がわかりやすいので、間違えることはないだろう(符号は座標系によるので注意)。

Yaw Pitch Roll by UnityChan
首の回し方で視界がどう見えるかを考えれば、簡単に覚えられる


 なお、さんざん「2D外積」という用語を使っているが、正式な名称でも定義でもないため、数学に詳しい人に言ったら怒られるかも(笑)。私が便宜上使っているだけの言葉なので、他人に説明するときは何らか公共性のある言葉に置き換えた方が良いかも知れない(投げっぱなし)。


(関連記事)
【Unity】【C#】符号(方向)付き角度を、2Dの外積を使って求める [2D]
【Unity】【C#】n度ごとの角度を返す(一定角度ごとの切り捨て、切り上げ、四捨五入)




このコンテンツの一部には『ユニティちゃんライセンス』で許諾されたアセットが使用されています。
Portions of this work are licensed under Unity-Chan License


category: Unity

thread: ゲーム開発

janre: コンピュータ

tag: Unityライブラリ  C# 
tb: 0   cm: --
IS<インフィニット・ストラトス> アーキタイプ・ブレイカー
キルドヤ


プロフィール

Twitter

検索フォーム

全記事一覧

カテゴリ

ユーザータグ

最新記事

リンク

PR

▲ Pagetop