🖼️
【Unity2D】UI, 非UI間でも当たり判定をとりたい【GIFアリ】
今回の開発物のリポジトリ
当たり判定を何故とりたいか
操作キャラが UI と重なった際、UI を半透明にして操作キャラを見やすくする例
- UIと非UIの重なりを検知し、UI を半透明にしたい
- 以前は
CanvasMode - Camera
とRaycast
の併用で実装していたが、もっと汎用的にしたい - そこで、UI, 非UI の当たり判定クラスを開発する
処理の流れを考える
- 図中の
Enter
,Stay
,Exit
はそれぞれ判定の開始, 続行, 終了で発行するイベント
処理の流れを設計に反映
クラス, インターフェースの解説と実装内容
UISpriteOverlapDetector
- UIと非UIの画面上の重なりを検出する中心クラス
- 対象 RectTransform, Component (SpriteRenderer等)を登録し、画面上の重なりを判定
- 状況に応じ、Enter, Stay, Exit のイベントを発行
- 判定方法は IOverlapStrategy を使って動的に切り替えられる
IOverlapStrategy
- 重なり判定アルゴリズムを差し替えるために利用
-
Overlap()
の引数がRect
ではなくVector2
な理由は工夫点で解説
AABBStrategy
AABB (Axis-Aligned Bounding Boxes)での挙動
- 傾きは無視して、最小の囲い矩形同士の重なりをチェック
- SAT(OBB) よりも低コスト
SATStrategy
OBB (Oriented Bounding Box)での挙動
- SAT (Separating Axis Theorem) を使い、傾いた矩形同士の重なりをチェック
- 両図形の辺の法線方向を軸として投影、全ての軸で投影が重なれば交差していると判定
- 描画上の見た目に忠実な判定が必要なときに使用
実装内容
- Unity は 2022.3.9f1 を使用
UISpriteOverlapDetector
/// <summary>
/// UI(RectTransform)とSpriteRendererのスクリーン上での重なりを検出するクラス
/// </summary>
[DisallowMultipleComponent]
public sealed class UISpriteOverlapDetector : MonoBehaviour
{
[Header("監視対象環境")]
[SerializeField] private Canvas targetCanvas;
[SerializeField] private Camera targetCamera;
[Header("監視対象リスト")]
[SerializeField] private List<Component> notUIs = new();
[SerializeField] private List<RectTransform> UIs = new();
[Header("オプション")]
[SerializeField] private bool visualizeGizmos = true;
[SerializeField] private bool includeRotated = false;
public event Action<Component, RectTransform> OnOverlapEnter;
public event Action<Component, RectTransform> OnOverlapStay;
public event Action<Component, RectTransform> OnOverlapExit;
private readonly struct PairKey : IEquatable<PairKey>
{
public readonly Component c;
public readonly RectTransform r;
public PairKey(Component c, RectTransform r) { this.c = c; this.r = r; }
public bool Equals(PairKey other) => c == other.c && r == other.r;
public override int GetHashCode() => HashCode.Combine(c, r);
}
private readonly List<PairKey> previousState = new();
private IOverlapStrategy strategy;
#region 外部公開関数
public void AddNotUI(Component comp)
{
if (comp is SpriteRenderer &&
notUIs.Contains(comp) == false)
{
notUIs.Add(comp);
}
}
public void RemoveNotUI(Component comp)
{
notUIs.Remove(comp);
}
public void AddUI(RectTransform rt)
{
if (rt &&
UIs.Contains(rt) == false)
{
UIs.Add(rt);
}
}
public void RemoveUI(RectTransform rt)
{
UIs.Remove(rt);
}
#endregion
private void Awake()
{
if (targetCanvas == null)
{
targetCanvas = GetComponentInParent<Canvas>();
}
strategy = includeRotated ? new SATStrategy() : new AABBStrategy();
}
private void LateUpdate()
{
var cam = (targetCamera != null) ? targetCamera : Camera.main;
var currentState = new List<PairKey>();
//監視対象グループからnullを破棄
notUIs.RemoveAll(x => x == null);
UIs.RemoveAll(x => x == null);
//監視グループの全組み合わせを走査
//始めに各要素の矩形化(Vector2)を行う
foreach (var nonUI in notUIs)
{
Vector2[] quad_nonUI = CalcScreenQuad(nonUI, targetCanvas, cam);
if (quad_nonUI == null) continue;
foreach (var ui in UIs)
{
Vector2[] quad_UI = CalcScreenQuad(ui, targetCanvas, cam);
if (quad_UI == null) continue;
//重なりを検知した場合
if (strategy.Overlap(quad_nonUI, quad_UI))
{
var key = new PairKey(nonUI, ui);
currentState.Add(key);
if (previousState.Contains(key) == false)
{
OnOverlapEnter?.Invoke(nonUI, ui);
}
else
{
OnOverlapStay?.Invoke(nonUI, ui);
}
}
}
}
//Exitイベント発行判定
foreach (var key in previousState)
{
if (currentState.Contains(key) == false)
{
OnOverlapExit?.Invoke(key.c, key.r);
}
}
//重なり検知状態の記録
previousState.Clear();
foreach (var k in currentState)
{
previousState.Add(k);
}
}
private static Vector2[] CalcScreenQuad(Component obj, Canvas canvas, Camera cam)
{
Vector3[] worldCorners = new Vector3[4];
//ワールド空間上の四つ角の取得
if (obj is RectTransform rt)
{
rt.GetWorldCorners(worldCorners);
}
else if (obj is SpriteRenderer sr)
{
var tf = sr.transform;
var sprite = sr.sprite;
if (sprite == null) return null;
var bounds = sprite.bounds;
var ext = bounds.extents;
//ローカル空間のOBB四隅
Vector3[] localCorners = new Vector3[]
{
new(-ext.x, -ext.y, 0),
new( ext.x, -ext.y, 0),
new( ext.x, ext.y, 0),
new(-ext.x, ext.y, 0),
};
for (int i = 0; i < 4; i++)
{
worldCorners[i] = tf.TransformPoint(localCorners[i]);
}
}
else
{
return null;
}
//四つ角をスクリーン上の座標に変換
Vector2[] screenPts = new Vector2[4];
for (int i = 0; i < 4; i++)
{
if (obj is RectTransform)
{
//CanvaModeで分岐
if(canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
screenPts[i] = RectTransformUtility.WorldToScreenPoint(null, worldCorners[i]);
}
else
{
screenPts[i] = cam.WorldToScreenPoint(worldCorners[i]);
}
}
else
{
//非UIは常にCameraベースで変換
screenPts[i] = cam.WorldToScreenPoint(worldCorners[i]);
}
}
return screenPts;
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
if (visualizeGizmos == false) return;
var cam = (targetCamera != null) ? targetCamera : Camera.main;
if (cam == null) return;
Gizmos.color = Color.yellow;
foreach (var nonUI in notUIs)
{
if (nonUI == null) continue;
var quad = CalcScreenQuad(nonUI, targetCanvas, cam);
if (quad != null) DrawQuadGizmo(quad, cam, strategy);
}
Gizmos.color = Color.cyan;
foreach (var ui in UIs)
{
if (ui == null) continue;
var quad = CalcScreenQuad(ui, targetCanvas, cam);
if (quad != null) DrawQuadGizmo(quad, cam, strategy);
}
}
private static void DrawQuadGizmo(
Vector2[] quad, Camera cam, IOverlapStrategy strategy)
{
if (strategy is AABBStrategy)
{
//AABBを求めて軸整列の矩形を描く
float minX = quad[0].x, minY = quad[0].y,
maxX = quad[0].x, maxY = quad[0].y;
foreach (var p in quad)
{
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
}
Vector3 tl = cam.ScreenToWorldPoint(new(minX, maxY, cam.nearClipPlane));
Vector3 tr = cam.ScreenToWorldPoint(new(maxX, maxY, cam.nearClipPlane));
Vector3 br = cam.ScreenToWorldPoint(new(maxX, minY, cam.nearClipPlane));
Vector3 bl = cam.ScreenToWorldPoint(new(minX, minY, cam.nearClipPlane));
Gizmos.DrawLine(tl, tr); Gizmos.DrawLine(tr, br);
Gizmos.DrawLine(br, bl); Gizmos.DrawLine(bl, tl);
}
else
{
for (int i = 0; i < 4; i++)
{
Vector3 a = cam.ScreenToWorldPoint(
new(quad[i].x, quad[i].y, cam.nearClipPlane));
Vector3 b = cam.ScreenToWorldPoint(
new(quad[(i + 1) % 4].x, quad[(i + 1) % 4].y, cam.nearClipPlane));
Gizmos.DrawLine(a, b);
}
}
}
#endif
}
IOverlapStrategy, AABBStrategy, SATStrategy
///////////////////////////
IOverlapStrategy
///////////////////////////
public interface IOverlapStrategy
{
//OBB対応のためRectではなくVector2を使用(Rectは回転しないため)
bool Overlap(Vector2[] a, Vector2[] b);
}
///////////////////////////
AABBStrategy
///////////////////////////
public sealed class AABBStrategy : IOverlapStrategy
{
public bool Overlap(Vector2[] a, Vector2[] b)
{
Rect ra = CalcBoundingRect(a);
Rect rb = CalcBoundingRect(b);
return ra.Overlaps(rb);
}
private static Rect CalcBoundingRect(Vector2[] points)
{
float minX = points[0].x, minY = points[0].y;
float maxX = points[0].x, maxY = points[0].y;
foreach (var pt in points)
{
if (pt.x < minX) minX = pt.x;
if (pt.x > maxX) maxX = pt.x;
if (pt.y < minY) minY = pt.y;
if (pt.y > maxY) maxY = pt.y;
}
return Rect.MinMaxRect(minX, minY, maxX, maxY);
}
}
///////////////////////////
SATStrategy
///////////////////////////
public sealed class SATStrategy : IOverlapStrategy
{
public bool Overlap(Vector2[] a, Vector2[] b)
{
//分離軸判定
for (int i = 0; i < 4; ++i)
{
//各辺ベクトルを計算し、その方向に正規化
Vector2 axis;
if (i < 2)
{
axis = (a[(i + 1) % 4] - a[i]).normalized;
}
else
{
axis = (b[(i - 2 + 1) % 4] - b[i - 2]).normalized;
}
//1つでも分離軸への投影に重なりがなければ衝突していない
if (IsOverlapOnAxis(a, b, axis) == false)
{
return false;
}
}
return true;
}
private static bool IsOverlapOnAxis(Vector2[] A, Vector2[] B, Vector2 axis)
{
Project(A, axis, out float minA, out float maxA);
Project(B, axis, out float minB, out float maxB);
return maxA >= minB && maxB >= minA;
}
private static void Project(Vector2[] pts, Vector2 axis, out float min, out float max)
{
min = Vector2.Dot(pts[0], axis);
max = Vector2.Dot(pts[0], axis);
for (int i = 1; i < pts.Length; ++i)
{
float d = Vector2.Dot(pts[i], axis);
if (d < min) min = d;
else if (d > max) max = d;
}
}
}
使用例
public class Test_OverlapDetector : MonoBehaviour
{
[SerializeField] private UISpriteOverlapDetector detector;
[SerializeField] private SpriteRenderer targetSprite;
[SerializeField] private RectTransform targetUI;
private void Start()
{
detector.AddNotUI(targetSprite);
detector.AddUI(targetUI);
detector.OnOverlapEnter += HandleEnter;
detector.OnOverlapStay += HandleStay;
detector.OnOverlapExit += HandleExit;
}
private void HandleEnter(Component obj, RectTransform ui)
{
Debug.Log($"Enter: {obj.name} × {ui.name}");
}
private void HandleStay(Component obj, RectTransform ui)
{
Debug.Log($"Stay: {obj.name} × {ui.name}");
}
private void HandleExit(Component obj, RectTransform ui)
{
Debug.Log($"Exit: {obj.name} × {ui.name}");
}
}
実際に使ってみた
実装機能を使用する例
- テキスト(UI)と操作キャラ(非UI(
SpriteRenderer
))の重なりを検知している - 重なった瞬間、テキストは青に変化 (
Enter
イベント発行) - 重なっている間、テキストの数値は減少 (
Stay
イベント発行) - 重なりが検知されなくなった瞬間、テキストは赤に変化 (
Exit
イベント発行)
工夫点
Rect
の代わりに Vector2
public interface IOverlapStrategy
{
//OBB対応のためRectではなくVector2を使用(Rectは回転しないため)
bool Overlap(Vector2[] a, Vector2[] b);
}
-
Rect
構造体は回転の状態を持たない (Unity - Rect) -
Vector2
を採用し、~Strategy
ごとに必要な矩形を再計算するようにした-
AABBStrategy
は AABB,SATStrategy
は OBB
-
拡張性の考慮
- 現状、非 UI は
SpriteRenderer
のみの対応となっている - しかし、
UISpriteOverlapDetector
の、矩形(Vector2
)取得関数(CalcScreenQuad
)に
他コンポーネント用コードを追加することで、簡単に他への対応ができる設計となっている- 以下、
Collider2D
の例 - 例1:
.bounds
を用いて 矩形を再計算 (AABB) - 例2:
.attachedRigidbody.rotation
を用いて矩形を再計算 (OBB)
- 以下、
renderMode
対応
Canvas の //四つ角をスクリーン上の座標に変換
Vector2[] screenPts = new Vector2[4];
for (int i = 0; i < 4; i++)
{
if (obj is RectTransform)
{
//CanvaModeで分岐
if(canvas.renderMode == RenderMode.ScreenSpaceOverlay)
{
screenPts[i] = RectTransformUtility.WorldToScreenPoint(null, worldCorners[i]);
}
else
{
screenPts[i] = cam.WorldToScreenPoint(worldCorners[i]);
}
}
else
{
//非UIは常にCameraベースで変換
screenPts[i] = cam.WorldToScreenPoint(worldCorners[i]);
}
}
-
UISpriteOverlapDetector
のCalcScreenQuad()
から抜粋 -
ScreenSpaceOverlay
ではCamera
を使わずにスクリーン座標へ変換する必要がある - そのため、
WorldToScreenPoint
の第一引数にnull
を指定している
現状の課題
private static Vector2[] CalcScreenQuad(Component obj, Canvas canvas, Camera cam)
{
Vector3[] worldCorners = new Vector3[4];
//ワールド空間上の四つ角の取得
if (obj is RectTransform rt) {...}
else if (obj is SpriteRenderer sr)
{
var tf = sr.transform;
var sprite = sr.sprite;
if (sprite == null) return null;
var bounds = sprite.bounds;
var ext = bounds.extents;
//ローカル空間のOBB四隅
Vector3[] localCorners = new Vector3[]
{
new(-ext.x, -ext.y, 0),
new( ext.x, -ext.y, 0),
new( ext.x, ext.y, 0),
new(-ext.x, ext.y, 0),
};
for (int i = 0; i < 4; i++)
{
worldCorners[i] = tf.TransformPoint(localCorners[i]);
}
}
else {...}
//以下略
}
-
SpriteRenderer
のワールド空間上の四つ角を取得する際、
drawMode
,flipX, Y
の設定を考慮していない(Pivot調整機能がない)
Discussion