https://qiita.com/advent-calendar/2020/qualiarts の2日目の記事になります。
今回はUnity UI(uGUI)のタッチ判定を可視化するための実装について書いていきます。
ゴール
次のgifのように、既存のUIがおいてあるシーン(例えば Unityのサンプル として配布されている Menu 3Dシーンとか)に、プレハブをおくだけでタッチ領域が緑の枠線で示されたり、カーソル位置で反応しそうなUIが虹色になったりするようにしてみます。
つらつら実装の説明が長くなるので、とにかく試してみたい、という人は "PackageManager経由で使ってみるには" まで飛ばしていただければと思います。
なお、本投稿ではビルドしたアプリでも同様に機能することを目指して、ずいぶん大仰な実装を紹介しますが、Editor上のSceneViewで確認できれば良い、という要件であればbaba-sさんの Raycast Target が true なゲームオブジェクトの描画範囲を Scene ビューに表示するエディタ拡張 の実装を覗いてみるのがシンプルでわかりやすいです。
1. RaycastTarget領域を緑の枠線で表示してみる
1.1. GraphicRaycasterの一覧を取得する
Unity UIにおいてタッチイベントの検出はGraphicRaycaster
配下のGraphicコンポーネントに対して行われています。
つまり、GraphicRaycaster配下ではないImageやRawImageについては無視できるということでもあります。
なのでまずはGraphicRaycasterを取得します。
Editor上の処理であれば Object.FindObjectsOfType<GraphicRaycaster>()
でも良いかもしれません。
ただしGraphicRaycasterが後からInstantiateされることもあるので、キャッシュすることができないので、高コストですが描画のたびにFindObjectsする必要が出てきます。
そこで、internalなapiですが RaycasterManager.GetRaycasters を利用することで、ずっと効率のいい実装にすることができます。
このGetRaycasters
メソッドはListの参照を返す実装なので、
以下のようにListの参照をキャッシュしておけば、一度のリフレクション呼び出しで、以降はListアクセスのコストだけで済みます。
IReadOnlyList<BaseRaycaster> raycasterListCache;
void OnEnable() {
raycasterListCache = typeof(BaseRaycaster).Assembly
.GetType("UnityEngine.EventSystems.RaycasterManager")
.GetMethod("GetRaycasters",
BindingFlags.Static | BindingFlags.Public | BindingFlags.InvokeMethod)
.Invoke(null, Array.Empty<object>()) as IReadOnlyList<BaseRaycaster>;
}
ただし、このRaycasterManagerにGraphicRaycasterが登録されるのはPlayモードに入った時だけのため、編集中にも動くようにするには、FindObjectsで収集するしかありません。
1.2. GraphicRaycasterからその配下のGraphicの一覧を取得する
GraphicRaycasterは [RequireComponent(typeof(Canvas))]
属性が付与されているので、安全にCanvasをGetComponentすることができます。
そしてCanvasがあれば GraphicRegistry.GetGraphicsForCanvas を使い、次の例のように配下のGraphicの一覧を取得することができます。
// GraphicRaycaster配下のGraphic一覧を取得する例
IList<Graphic> GetGraphicsForRaycaster(GraphicRaycaster raycaster) {
if (raycaster.TryGetComponent<Canvas>(out var cav) == false) return null;
return GraphicRegistry.GetGraphicsForCanvas(cav);
}
1.3. Graphicのタッチ判定が有効か調べる
Graphic毎のタッチ判定の詳細な実装は Graphic.Raycast を読むことで調べられます。
この Graphic.Raycast
の実装を参考に、Rayを評価している処理を省く(実際にタッチしているかではなく、タッチの領域さえわかればいいので)ことで、そのGraphicがタッチ可能か評価できます。
一例としては、以下のように実装できるでしょう。
// Graphic.Raycastのうち、Rayを使わない部分の処理を抜粋
private bool IsRaycastTarget(Transform graphicTransform)
{
var t = graphicTransform;
var ignoreParentGroups = false;
var continueTraversal = true;
var components = new List<Component>();
while (t != null)
{
components.Clear();
t.GetComponents(components);
for (var i = 0; i < components.Count; i++)
{
var cav = components[i] as Canvas;
if (cav != null && cav.overrideSorting) continueTraversal = false;
var filter = components[i] as ICanvasRaycastFilter;
if (filter == null) continue;
var raycastValid = true;
var group = components[i] as CanvasGroup;
if (group != null)
{
if (ignoreParentGroups == false && group.ignoreParentGroups)
{
ignoreParentGroups = true;
raycastValid = group.blocksRaycasts;
}
else if (ignoreParentGroups == false)
{
raycastValid = group.blocksRaycasts;
}
}
if (raycastValid == false)
{
components.Clear();
return false;
}
}
t = continueTraversal ? t.parent : null;
}
components.Clear();
return true;
}
1.4. タッチ判定が有効なGraphicを四角で囲む
矩形の描画の方法はいろいろ(GizmosとかIMGUIとか)ありますが、今回はCanvasとGraphicを使うものとします。
1.4.1. Graphic継承する
以下のような感じで、GraphicかMaskableGraphicを継承すればUnity UI上に任意の形を描画できるようになります。
[RequireComponent(typeof(CanvasRenderer))] // いつの間にかGraphicから削除されていたCanvasRendererへのRequireComponent
[ExecuteAlways] // 非PlayMode状態でも描画できるように
public class TouchRectDrawer : MaskableGraphic {
protected override void OnPopulateMesh(VertexHelper vh)
{
// なんか描画したい形を定義する処理
}
}
1.4.2. Graphicのリストの更新とリビルド
Updateメソッドなどで、フレーム毎にタッチ判定の有効なGraphicを収集して、base.SetVerticesDirty
を呼び出すことで、自身をリビルド対象とします。(毎フレームSetVerticesDirty呼ぶのは無駄な負荷ですが・・・)
List<Graphic> _targets = new List<Graphic>();
void Update()
{
#if UNITY_EDITOR
// 編集モード中は常にRaycastersを取得し直し
if (Application.isPlaying == false) CacheRaycasterList();
#endif
_targets.Clear();
for (var i = 0; i < _raycasters.Count; i++)
{
FindTargetGraphics(_raycasters[i] as GraphicRaycaster, _targets);
}
// _targetsに変化がなければSetVerticesDirtyを叩かないようにした方が優しい
base.SetVerticesDirty();
}
void CacheRaycasterList()
{
#if UNITY_EDITOR
// BaseRaycasterの登録がプレイモード中だけっぽい
if (Application.isPlaying == false)
{
_raycasters = FindObjectsOfType<GraphicRaycaster>()
.Where(x => x.isActiveAndEnabled)
.ToArray();
return;
}
#endif
_raycasters = GetRaycasters.Value.Invoke(null, Array.Empty<object>()) as IReadOnlyList<BaseRaycaster>;
}
// Raycaster毎にタッチ判定の有効なGraphicを収集してtargetListに格納していく
void FindTargetGraphics(GraphicRaycaster raycaster, List<Graphic> targetList)
{
if (raycaster == null) return;
if (raycaster.TryGetComponent<Canvas>(out var cav) == false) return;
var graphics = GraphicRegistry.GetGraphicsForCanvas(cav);
var eventCamera = cav.worldCamera;
var hasCamera = eventCamera != null;
var farClip = hasCamera ? eventCamera.farClipPlane : 0f;
for (var i = 0; i < graphics.Count; i++)
{
var graphic = graphics[i];
var graphicsTransform = graphic.rectTransform;
// この辺はGraphicRaycasterから拝借
if (graphic.raycastTarget == false || graphic.canvasRenderer.cull || graphic.depth == -1) continue;
if (hasCamera && eventCamera.WorldToScreenPoint(graphicsTransform.position).z > farClip) continue;
if (IsRaycastTarget(graphicsTransform))
{
targetList.Add(graphic);
}
}
}
ここまで来ればあとは OnPopulateMesh
の中で _targets
を囲むような四角形を定義してあげればいいでしょう。
(実際には2020からRaycastPaddingが入ったり、CameraやらScreen座標からの変換が若干面倒だったりするのですが、詳細な実装は こちらのAddFrameとか を眺めていただければと)
2. タッチ対象を虹色にしてみる
2.1. タッチ座標を取得する
まずはPCアプリやEditorであればマウスカーソルの座標、スマホやタブレットであればスクリーン上の指の座標を取得します。
Unityではそれらの座標を扱うためのモジュールとして、 Input Manager
と Input System
が存在します。
それぞれでの座標の取得の仕方を紹介します。
2.1.1. Input ManagerでのmousePositionとtouches
Input Managerとはずっと昔のUnityから存在している Input
クラスとその周辺機能を差します。
今のところまだ非推奨にはなっていませんが、後述する Input System で代替されていくかもしれません。
とはいえ、まだまだ現役なので、Input Managerに対応した処理を実装しておいた方がいいでしょう。
次のコードのように Input.mousePosition
と Input.GetTouch
で座標を取得できます。
List<Vector2> _touchPositions = new List<Vector2>();
// `IEnumerable<Vector2>` を返り値型として、 `yield return` する実装の方がすっきりしそうだけど、gcに気をつかってみる
IReadOnlyList<Vector2> GetTouchPositionsFromInputManager()
{
_touchPositions.Clear();
_touchPositions.Add(Input.mousePosition);
for (var i = 0; i < Input.touchCount; i++)
{
_touchPositions.Add(Input.GetTouch(i).position);
}
return _touchPositions;
}
2.1.2. Input SystemでのmousePositionとtouches
Input System はUnity2018.3以降でPackage Managerから導入することができます。
本投稿ではInput Systemの導入方法は紹介しません。ごめんなさい。
Input Systemでのマウス入力は UnityEngine.InputSystem.Mouse
クラス、タッチデバイス入力は UnityEngine.InputSystem.Touchscreen
クラスから取得できます。
次のような実装で両方をサポートできるでしょう。
List<Vector2> _touchPositions = new List<Vector2>();
IReadOnlyList<Vector2> GetTouchPositionsFromInputSystem()
{
_touchPositions.Clear();
if (Mouse.current != null) _touchPositions.Add(Mouse.current.position.ReadValue());
if (Touchscreen.current != null)
{
var touches = Touchscreen.current.touches;
for (var i = 0; i < touches.Count; i++)
{
if (touches[i] == null) continue;
if (touches[i].isInProgress == false) continue;
_touchPositions.Add(touches[i].ReadValue().position);
}
}
return _touchPositions;
}
2.1.3. Input ManagerとInput Systemの両方に対応する
Input Systemを有効にするとScriptDefinesに ENABLE_INPUT_SYSTEM
が定義されます。
同様に、Input Managerを有効にすると ENABLE_LEGACY_INPUT_MANAGER
が定義されます。
なので、Input Systemだけの処理を書きたい場合は #if ENABLE_INPUT_SYSTEM ~~ #endif
で括ってあげればいいということになります。
また、Input SystemとInput Managerを共存させることもできるので、Unity UIのEventSystemがどちらで動いているかの判定も必要になるかもしれません。
それには EventSystem.current.currentInputModule
の型で評価できます。
currentInputModuleが StandaloneInputModule
型ならばInput Managerで動いていて、
currentInputModuleが InputSystemUIInputModule
型ならばInput Systemで動いている、
ということにできます。
次のようなメソッドでラップしておくのがいいかもしれません。
IReadOnlyList<Vector2> GetTouchPositions()
{
#if ENABLE_INPUT_SYSTEM
if (EventSystem.current == null) return System.Array.Empty<Vector2>();
if (EventSystem.current.currentInputModule is InputSystemUIInputModule)
{
return GetTouchPositionsFromInputSystem();
}
else
{
return GetTouchPositionsFromInputManager();
}
#else
// Input Systemが有効でない時はInput Managerへ
return GetTouchPositionsFromInputManager();
#endif
}
2.2. 最前面で触れているものを取得する
EventSystem.current.RaycastAll
を利用することで、Unity UI的に触れられていると判定されるGraphicを取得できます。
前述した方法で入力座標が取得できていれば、その座標を PointerEventData
に設定してRaycastAllを実行するだけです。
タッチデバイスなど、複数同時タップなども対応した方が親切かもしれません。
以下のような実装で、触れられているGraphicの一覧を取得することができます。
List<RaycastResult> GetHits()
{
// タッチ座標を取得
var touchPositions = GetTouchPositions();
var tmp = new List<RaycastResult>();
var eventData = new PointerEventData(EventSystem.current);
var hits = new List<RaycastResult>();
// 各タッチ座標でUnity UI的に衝突するものを列挙
for (var i = 0; i < touchPositions.Count; i++)
{
tmp.Clear();
eventData.Reset();
// eventDataのpositionだけ更新して使い回す
eventData.position = touchPositions[i];
// 衝突結果は
EventSystem.current.RaycastAll(eventData, tmp);
// EventSystem側でソート済みなので先頭を拾ってくればいい
if (tmp.Count > 0) hits.Add(tmp[0]);
}
return hits;
}
2.3. 触れている対象を虹色にする
緑の四角で囲ったように、Graphicの継承で実装してしまいます。
- タッチ対象のGraphicのタッチ判定領域(の四角)のScreen座標を取得
- Screen座標をワールド座標に変換
- ワールド座標を虹色を表示するGraphicからみたローカル座標に変換
- 虹色のGraphicの頂点としてAddUIVertexQuad
言葉にするとよくわからなくなりますが、以下のような処理です。
static readonly UIVertex[] Vertices = new UIVertex[4];
void AddQuad(VertexHelper helper, RaycastResult result)
{
if ((result.gameObject == null) || (result.module == null)) return;
if (result.gameObject.TryGetComponent(out Graphic graphic) == false) return;
// graphicのタッチ判定の範囲をワールド空間で取得する
// https://github.com/QualiArts/ugui-touch-rect-drawer/blob/main/Packages/ugui-touch-rect-drawer/Runtime/DrawerUtils.cs
var corners = DrawerUtils.GetWorldRaycastCorners(graphic);
if (corners == null) return;
var baseColor = color;
var rt = rectTransform;
var cam = canvas.worldCamera;
for (var i = 0; i < corners.Length; i++)
{
// graphicの各コーナーのスクリーン座標を取得する
var pos = RectTransformUtility.WorldToScreenPoint(result.module.eventCamera, corners[i]);
// スクリーン座標を、虹色の基点からみたローカル座標に変換する
RectTransformUtility.ScreenPointToLocalPointInRectangle(rt, pos, cam, out pos);
Vertices[i].position = new Vector3(pos.x, pos.y, 0f);
// 虹色っぽい頂点カラーを設定
Vertices[i].color = GetCornerColor(i) * baseColor;
}
// 四角として登録する
helper.AddUIVertexQuad(Vertices);
}
これで緑の四角や虹のオーバーレイの実装が終わりです。
PackageManager経由で使ってみるには
こちらのリポジトリ にPackageの形にしたものを上げておきました。
manifest.jsonに
"ugui-touch-rect-drawer": "https://github.com/QualiArts/ugui-touch-rect-drawer.git?path=Packages/ugui-touch-rect-drawer"
を記述します。
"dependencies": {
"ugui-touch-rect-drawer": "https://github.com/QualiArts/ugui-touch-rect-drawer.git?path=Packages/ugui-touch-rect-drawer",
...,
}
Packageの解決ができたら DrawerContainer
をシーンに配置します。
Scriptから描画の有無を切り替えるには、上記prefabがシーンにある状態で UGUIRaycastDrawer.DrawerUtils
のメソッドを利用できます。
UGUIRaycastDrawer.DrawerUtils.SetTouchRectDrawer(drawTouchRect);
UGUIRaycastDrawer.DrawerUtils.SetTouchResultDrawer(drawTouchTarget, logTouchTarget);
最後に
本投稿に掲載したコードは、説明のし易さの都合のため実際の実装から多少異なったものになっています。詳細を確認したい場合はリポジトリを参照してください。
以上です。
閲覧ありがとうございました。
明日は iyuさん です。