【Unity】Unmaskでクリック検知がしたい!【Unmask For UGUI】
概要
当記事は、ゲームエンジンUnityにてmob-sakaiさんの提供しているUnmask For UGUIと呼ばれる逆マスクを実現するライブラリを試すことに加えて、クリック検知をなんとか行ってみた体験を整理した記事です。
主題である「クリック検知」について確認したい方は、前半の記事を飛ばしてください。
使用環境
- Unity 2022.3.16f1
- Windows11 home
- JetBrains Rider 2023.3.2
マスクと逆マスク
そもそもマスクと逆マスクとはなんでしょうか?
マスクやマスク処理とは、画像合成技術のひとつです。2つ以上の画像を重ねた後、決まった条件のもとにその一部を抽出した「マスク画像」を生成します。Unityでは、親子関係にあるGameObjectの重なりに対して、親GameObjectへMaskコンポーネントをつけることで、子GameObject群(の画像)を親GameObject(の画像)で抽出した「マスク画像」を生成できます。「くりぬく」という表現の方がわかりやすいかもしれません。なお、Maskコンポーネントの詳細についてはここでは割愛します。
これに対して逆マスクは、2つ以上の画像の重なりから、決まった条件のもとにその一部を除去した「マスク画像」を生成します。(見やすさのため、半透明の黒色画像を一番前に追加しています)
こちらの機能はUnity公式ではサポートされていないため、当記事では外部の開発者が作成したUnmask For UGUIと呼ばれるライブラリと、その中で提供されているUnmaskコンポーネントを使用して実現します。
導入
Unmask For UGUIでも取り上げられている導入方法をひとつ試してみます。
Unmask For UGUIのgithubページから、関連するプログラムがまとめられたzipファイルを入手し解凍、解凍されたフォルダ「UnmaskForUGUI-main」を作業をしているUityプロジェクトにコピーペーストします。UnityEditorを開いていれば、一瞬のロード処理のあとにすぐに使えるようになると思います。
逆マスクを実現するUnmask
Unmask For UGUIの提供しているUnmaskはコンポーネントの形で提供されており、Canvasコンポーネントを持ったGameObject以下の階層に存在する任意のGameObjectにアタッチすることで効果を発揮します。ただし、その仕組み上、以下のような「特定の構造」を持ったGameObject群の準備が必要となります。
- 全体の基盤。Maskコンポーネントのついた親GameObject(上図Image_Mask)
- 取り除きたい。Unmaskコンポーネントのついた子GameObject(上図Image_Child_Unmask)
- 取り除かれたい。そのほかの子GameObject(上図Image_BlackSurface)
逆マスクを「見た目」としてだけ実現したいのであればUnmaskを利用することでそれが可能です。
逆マスクした部分の操作を実現するUnmaskRaycastFilter
クリックされない問題
さて、先述の内容で逆マスクの「見た目」は実現されました。続いて、次のようなUnmaskを利用して、「押してほしいボタンをアピールするチュートリアルのようなオーバーレイ」を実現したとします。
とても有用そうですが、すぐに困ったこととなります。逆マスクの部分をクリックしても奥にあるボタンが押されないのです。ここで、一度、Hierarchyの内容を横から見た図を用意しましょう。
これらに対してマウスのクリックを行うと以下のようなことが起こります。
このように、クリックを行う時にはRaycastと呼ばれるまっすぐな光線のようなものが上から降り注ぎ、それがぶつかったGameObjectが「クリックされた対象」として決定されます。言い換えれば、各階層にあるGameObjectにクリックが阻まれてしまうから奥のボタンが押されなかったとも言えます。なお、「透明な絵」「Unmaskで切り抜かれた絵」であってもRaycastの対象になりえます。
クリックされない問題を簡単に解決するアプローチ
各GameObjectがRaycastの対象とならない様にするためによくとられている方法は次のとおりです。
- GameObjectを非アクティブにする
- Imageなどの画像関連のコンポーネントにあるRaycast Targetプロパティをoff(false)にする
マスクの表示を残したいので、Raycast Targetプロパティをoffにする手段を採用してみたいと思います。
Raycastを阻むものがなくなったので、これでボタンが押されるようになりました。ところが、ここで新たな問題が出現します。マスクによってアピールしていないボタンまでもが押せてしまうのです。これはチュートリアルとして良い動作とはいえません。
ここで登場するのがUnmaskRaycastFilterです。UnmaskRaycastFilterは、クリックされた場所が一番上の階層のGameObjectかつUnmaskの部分であった時は、Raycastを阻まない!という効果を与えるコンポーネントです。
UnmaskRaycastFilterはコンポーネントの形で提供されており、上図一番上の階層にあたるGameObject(サンプルではBlackSurface)にアタッチし、Unmaskへの参照を渡すだけで利用可能になります。(それぞれのGameObjectのRaycast Targetのon/offには注意してください)
クリックが検知したい
さて、ここまでのサンプルを作った時点で気になることができました。チュートリアル時に注目してほしい部分やクリックしてほしい部分をアピールするオーバーレイとしての利用は確かにできる。できるけども、もし可能なら既存の仕組みに一切手を加えずに独立した挙動をとらせることはできないだろうか?言い方を変えると、上から重ねるだけでチュートリアルを簡単に実装する方法はないだろうか?
困った問題
しかし、ここで困った問題が起こります。「UnmaskRaycastFilterによって、Raycastが通った先のGameObjectはクリックが検知されるが、Unmaskの部分をクリックされたことは検知されない」のです。
解決へのアプローチ
手段はいろいろなものが考えられると思います。
ボタンAのフィードバックを返す
一番妥当なステップとしてはボタンAがクリックされたフィードバックをチュートリアルにかかわるプログラムに返してあげるのが素直です。例えば、ボタンAのOnCkickプロパティ(イベントリスナー)にメソッドを登録してみてもよいかもしれません。
しかし、私はできることならば「すでに完成している部分に手をだしたくない」と考えました。チュートリアルにおける「クリックしてほしい!」という対象がボタンではない可能性もありえます。
Raycastを一度受け止め、再度Raycastを射出する
次のアイデアはクリックがRaycastと呼ばれる仕組みを利用しているところからの発想です。クリックされたときに出るRaycastですが、実はプログラムで任意に発射することも可能です。この部分を利用して、一番上のGameObjectで一度マウスクリックのRaycastを受け止めることでクリックを検知し、さらに奥へRaycastを発射すればよいのです。(プログラムは割愛します)
一点、注意すべきなのはUnmaskRaycastFilterです。UnmaskRaycastFilterを使用しているとUnmask部分がクリックされたことが検知できませんので、検知と再射出用の別の透明なGameObjectを用意する必要があります。個人的な見解としては、他のGameObjectとの干渉を起こしかねないので、採用しにくいなと感じました。
ICanvasRaycastFilterインターフェースおよびIsRaycastLocationValidメソッドを利用する
解決のヒントは意外にも身近なところにありました。そう、UnmaskRaycastFilterです。
ふと「UnmaskRaycastFilterってどのような構造になっているのだろう?」と調べてみたところ。ICanvasRaycastFilterインターフェースを導入しており、インターフェースのもつIsRaycastLocationValidメソッドを利用していることがわかりました。IsRaycastLocationValidメソッドの挙動は図のとおりです。
簡単に説明すれば、Raycastの通り道にICanvasRaycastFilterをもったGameObjectが存在するとそのGameObject内部のIsRaycastLocationValidメソッドが誘発し、その返却値をtrueにするかfalseにするかでRaycastの受け止めと通過が制御できるようになる。と、いうものです。
これを踏まえて、コンポーネントを作ってみましたが、IsRaycastLocationValidメソッドの挙動には1点だけ問題があることがすぐにわかります。それは、どんなRaycastでもトリガーしてしまうというものでした。マウス操作だけでも、次のようなものがRaycastを発生させています。
- マウスボタンが押されたとき
- マウスカーソルが移動したとき
- マウスボタンが離されたとき
私の目的は、特定のUnmask上においての「クリックが検知したい!」というものなので、クリックする前のマウスカーソル移動で検知をされても困ります。IsRaycastLocationValidメソッドの公式ドキュメントをみても引数からはどんな操作によって発生したRaycastなのか参照を得ることはできないようでした。仕方がないのでInputSystemさんに出てきてもらい、マウス操作の分解を担ってもらうことで解決を図りました。以下は、その処理を入れたプログラムです。
public class UnmaskClickDetector : Monobehaviour, ICanvasRaycastFilter
{
public Unmask targetUnmask;
public UnityEvent onClickDetected = new UnityEvent();
public bool IsRaycastLocationValid(Vector2 sp, Camera eventCamera)
{
if( taregtUnmask == null ) return false ;
bool stopRaycast = true;
if( Mouse.current.leftButton.wasPressedThisFrame )
{
bool isInside = RectTransformUtility.RectangleContainsScreenPoint((targetUnmask.transform as RectTransform), sp, eventCamera);
stopRaycast = !isInside;
if( isInside ) onClickDetected.Invoke();
}
return stopRaycast;
}
}
終わりに
まずは、Unmaskを作成したmob-sakaiさんに改めて感謝を、素晴らしいライブラリを作られる方々のおかげで今日もゲーム作りがはかどります。しかし、クリック検知のヒントがまさかUnmaskの内部に入っていたのはさすがに盲点でした。これで目的は達成できたので、チュートリアルのなかったゲームにも既存のプログラムを大きく変更することなくチュートリアルを導入できるようになる・・・かも?
余談
この問題の解決策を練るために、AIとプログラムについての熱い弁論を繰り広げていたところ、とあるインターフェースに定義されているメソッドの処理について正確でないプログラムを提案されました。「それは正しくないぞ?どこを参考にして出力したんだそれ?証拠出せ!」と伝えたところ、公式のURLを提案されました。ところが、公式は間違ってなかったんですよね。どこで捻じ曲げられてしまったのか・・・。
みんな、AI鵜呑みにして出力をそのまま利用する低俗なプログラマーになってはいかんぞぅ。
Discussion