- 投稿日:2019-12-21T23:47:36+09:00
VRChatで現実と同じ月齢の月を実装する
はじめに
VRChatにUdonというプログラミング言語?が実装されました!
2019年12月22日現在まだアルファ版らしいですが、これで今まで以上にUnity標準の機能がいろいろ使えそうですね。
例えばAPIをたたいたりできると今回解説する現実と同じ月齢の月をもっと簡単に実装できそうなので、今後のアップデートが楽しみです。
ということで、今回はVRChatで現実と同じ月を実装したのでその解説をします!環境
Unity : 2018.4.14f1
OS : Windows10 Pro 1909
CPU : Intel(R) Core(TM) i5-4690 CPU @ 3.50GHz
メモリ : 16GB
グラフィックボード : GeForce GTX 1060 3GBどんなものを作ったか
今回は、VRChatで動作する現実と同じ月齢の月を作りました。
ちなみに、月齢には2種類の意味があります。
一つは、赤ちゃんの年齢を月(month)で表したものです。
生後nヶ月なんていったりしますよね、あれです。
もう一つは、月の満ち欠けのことです。
今回はもちろん月の満ち欠けのことです。
なので、現実世界の月が満月だとVRChat世界の月も満月になる、というわけです。解説
今回作った現実と同じ月齢の月にはRealTimeMoonという名前をつけました。
以降、この名前で呼びます。
RealTimeMoonの概要を説明すると、作業は主に2種類に分けられます。
まず、サーバーからVRC_Panoramaで月齢の情報をエンコードしておとしこんだ画像をダウンロードします。
次に、それをシェーダーでデコードして月齢に反映させます。
それでは、詳しく解説しましょう。サーバー
まず、サーバーで画像を生成します。
サーバーはNode.jsで動かしているのですが、月齢の計算にはSunCulcというライブラリを使っています。ちなみに、月齢は以下の式で求めることもできます。
Year年Month月Day日の月齢MoonAge日は、
MoonAge = (((Year - 11) % 19) × 11 + リスト[Month] + Day) % 30
1月 2月 3月 4月 5月 6月 7月 8月 9月 10月 11月 12月 0 2 0 2 2 4 5 6 7 8 9 10 しかし、この計算式は簡易的な計算式なので、数日ずれることがあります。
おそらく、うるう年等が原因でしょう。
そのため、今回はSunCalcライブラリを使いました。次に、画像ですが、例えば一つのピクセルが保有することができる情報はいくつでしょうか。
答えは、(R, G, B)の3つです。
では、扱える数字(分解能)はいくつになるでしょうか。
(0~255, 0~255, 0~255)でしょうか。
答えは、今回の場合ですと違います。
正解は、(0or1, 0or1, 0or1)です。なぜなのか解説する前に、別のお話をしましょう。
ぼくは、時計もこの方法でVRChatで実装しています。
しかし、3~5時にかけてなぜか短針が1時間ずれるバグが見つかりました。
なぜでしょうか。
実は、VRC_Panoramaで取得した画像にガンマ補正をかけてあったのです。
ガンマ補正をかけないと、劣化した画像が使われてしまうのです。
じゃあ、ガンマ補正をかけたからもう大丈夫だね、と思いますよね?
しかし、ガンマ補正をかけた画像は実はもとの画像の色とはちょっとだけ違うのです。
つまり、分解能が255ではあっても、ガンマ補正をかけることによって実は分解能はさらに少なくなっていたのです。そこで、今回は分解能を気にしなくていいように、1ピクセルの持つ情報を(0or1, 0or1, 0or1)にして、月齢の数値を2進数として画像におとしこみました。
これにより、0.5より大きいか小さいかという判定でデジタル的に正確に月齢を取得できるようになりました。実際に生成した画像は以下の通りです。
※2019年12月22日
けっこう原色感が強いですよね。
理由はもちろん、RGBが上から
R G B 色 00 FF FF 水色 FF FF FF 白色 00 FF 01 黄緑色 という風にほぼ0or1になっているからです。
シェーダー
ソースは以下の通りです。
RealTimeMoon.shader// 略 fixed4 frag(v2f i) : SV_Target { float range = (step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.85, 0, 0)).r) * 256.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.85, 0, 0)).g) * 128.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.85, 0, 0)).b) * 64.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.5, 0, 0)).r) * 32.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.5, 0, 0)).g) * 16.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.5, 0, 0)).b) * 8.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.15, 0, 0)).r) * 4.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.15, 0, 0)).g) * 2.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.15, 0, 0)).b) * 1.0) / 10.0; float overQuarter = (step(7.5, range % 15.0) * step(range % 15.0, 15.0) - 0.5) * (-2.0); // -1.0 or 1.0 float overHalf = step(15.0, range) * step(range, 29.999999); range = abs(distance(range % 15, 7.5) - 7.5); float theta = UNITY_PI * (range / 15.0); float r = 0.25; float a = r * cos(theta); float inCircle = step(pow(i.originalPos.x, 2.0) / pow(r, 2.0) + pow(i.originalPos.y, 2.0) / pow(r, 2.0), 1.0); float dist = distance(acos(((step(0.0, i.originalPos.x) - 0.5) * 2.0) * abs(sqrt(pow(i.originalPos.x, 2.0) / (1.0 - (pow(i.originalPos.y, 2.0) / pow(r, 2.0))))) / r), overQuarter * theta + UNITY_PI * saturate(-overQuarter)); float check = abs(abs(saturate(step(pow(i.originalPos.x, 2.0) / pow(a, 2.0) + pow(i.originalPos.y, 2.0) / pow(r, 2.0), 1.0) + inCircle * step(overQuarter * i.originalPos.x, 0.0)) - saturate(-overQuarter)) - overHalf + (((overHalf - 0.5) * (-2.0)) * distance(dist, 10.0 * UNITY_PI / 180.0) / (10.0 * UNITY_PI / 180.0)) * step(dist, 10.0 * UNITY_PI / 180.0)) * step(0.0, i.originalPos.z); check = inCircle ? check : 0.0; half4 tex = texCUBE(_Tex, i.texcoord); half3 c = DecodeHDR(tex, _Tex_HDR); c = c * _Tint.rgb * unity_ColorSpaceDouble.rgb; c *= _MoonExposure * inCircle + _SkyboxExposure * (1.0 - inCircle); return float4(lerp(c.r, 0.0, check), lerp(c.g, 0.0, check), lerp(c.b, 0.0, check), 1.0); }複雑で説明が難しいので、簡単に説明します。
まず、画像の(R, G, B)からstep関数を使って0か1を判定します。
もちろん、0.5未満だったら0、0.5以上だったら1です。
そして、次に2進数から10進数に変換します。
順番としては、上.r * 256 + 上.g * 128 + 上.b * 64 + 真ん中.r * 32 + 真ん中.g + 16 + 真ん中.b * 8 + 下.r * 4 + 下.g * 2 + 下.b * 1
です。
逆の順番にエンコードした場合は逆になるので、サーバー側にあわせます。次に、月の満ち欠けですが、いい感じに円から楕円を引いたりいろいろしていい感じにします。
余談
今回は月齢をサーバーに計算させましたが、ほかにもいろいろできそうですね。
月の欠けてる部分は黒で塗りつぶしています。
星が見えてしまったら、物理的に月が欠けていることになってしまいます。月の満ちているところと欠けているところの境目は少しグラデーションをかけています。
おそらく現実世界の月もそうなっているはずなので。RealTimeMoonシェーダーはSkybox用シェーダーなのですが、SkyboxのマテリアルにVRC_Panoramaを追加することはできないので、実はVRC_Panoramaを追加したQuadにテクスチャを表示させ、それをカメラで読み取ってRenderTextureに出力し、RealTimeMoonマテリアルに入力しています。
おわりに
この記事を書くにあたって、この宇宙を生んでくれたビッグバンに感謝したいと思います。
今回解説したRealTimeMoonはBOOTHで配布中です。参考文献
- 投稿日:2019-12-21T22:34:42+09:00
unityでtoio連携iOS app開発 3歳児でも遊べるシューティングゲームを作った話
これは「toio™(ロボットトイ | toio(トイオ)) Advent Calendar 2019」の23日目の記事になります。
はじめに
2ヶ月前からiOS Appを作りたくて、Unityをいじり始めました。
せっかくなのでtoioも動かしたいなと思っていたのですが、BLEの制御はハードルが高くて二の足を踏んでいました。そんなところにtoioアドベントカレンダーでちょうどいい記事が!
Unityを使って手軽にスマホアプリからtoioを操作する方法(ライブラリ付き)有料のUnity Assetを使ってtoioをUnityからBLE制御することができていたので、
この記事のソースコードを使ってシューティングゲームを作ってみました。
※なぜシューティングゲームを作ったの? については記事最後に。私のスキル
以前の記事でも書きましたが、元々はC言語しか知らない程度でした。
そんな中、Unityを使ったiOS App開発は2ヶ月前に勉強を始めたばかりでしたが、
この本を一通りなぞることでかなり初歩的な概要は掴めたように思います。
Unityの教科書 Unity2019完全対応版 2D&3Dスマートフォンゲーム入門講座この本は私のような初心者におすすめできます。
作ったもの
先ほどのを応用して、シューティングゲームも作りました!
— Yuya Hirano @ BREMENGames ゲムマ秋Q15 (@idiot_radio_hy) December 23, 2019
toioを持って逃げる人と、スマホの画面タップして射的する人。
後ほどtoioアドベントカレンダーに記事載せます。#toio pic.twitter.com/92jiEMwNIB・実空間でcore Cubeをマット上で動かして逃げる人
・スマホ内で動くcore Cubeを狙って画面をタップしてボールを打つ人
の二人で遊ぶ、対戦型シューティングゲームです。大雑把にいうと、
- 1. toioの読み取りセンサの値をリアルタイムにアプリ内の描画に反映
- 2. スマホをタップしたら、その位置に向かってボールを飛ばす
- 3. ボールがcore Cubeに当たったら得点を入れる
こんな感じでプログラミングしていきました。toioプログラミングに関わるのは1.の部分がほとんどなので、
この記事では1.に主に焦点を当てて説明します。
他はかなり大雑把に省きますので、質問など会ったら個別にコメント等お願いいたします。1. toioの読み取りセンサの値をリアルタイムにアプリ内の描画に反映
Unity上の画面はこんな感じです。
オブジェクトの定義と、それらを動かすプログラム(スクリプト)を書くことでゲームを作っていきます。1-1 実空間とアプリ空間のScale合わせ
toio Core Cubeの実空間上の動きとスマホ内の動きが連動するので、サイズ感を合わせる必要があります。
今回はオブジェクトの"Scale"をcm単位として、モデリングしていきました。例えば、core Cubeは実寸法が32x24x32mmなので、
Unity上では3.2 x 2.4 x 3.2 で定義する と言った感じです。
Matも同様に、実寸法を測定して定義しました。
1-2 読み取りセンサのScale合わせ
読み取りセンサで取得できるX/Y座標ですが、公式の技術仕様にもあるように、単位がミリメートルではありません。
これは現物合わせなのですが、
toio IDで得られる座標を1.4倍することで実寸法とのスケールを合わせました。1-3. 読み取りセンサの値のリアルタイム反映
この章が今回の記事の肝の部分になります。
ちょっと込み入った話になりますがご容赦ください。参考記事のプログラムを使用することで、読み取りセンサの値をnotifyで取得できるようになります。
今回はこのソースコードを拡張することでUhityオブジェクトの位置制御に利用します。
サンプルプログラムでは、読み取りセンサの値をNotifyで取得する度にUI上のtextを書き換えるような処理が書かれています。この辺です。
this.CubeController.GetComponent<CubeController>().GetIdInformation((positionX, positionY, standardID, angle) => { if (positionX != 0xffff || positionY != 0xffff) { this.PositionID.GetComponent<Text>().text = "X座標 : " + positionX.ToString("D3") + " / Y座標 : " + positionY.ToString("D3"); }この座標情報をCubeの位置の描画に反映すれば良さそうですが、そのままでは処理不可的にもったいないことになってしまいそうです。
アプリ上では画面更新のタイミング(1フレーム)に一度だけ表示を書き換えれば良いのですが、
このコードだとNotificationが飛んでくる度に書き換え処理が走ってしまいます。なので、ここはスクリプトを分けてちょっと書き換えてみました。
以下のように動作するループごとに役割を分けてあげるとうまくいきそうです。・最新の座標情報を保持するスクリプト (director.cs)
・notificationが飛んでくるごとに最新の座標を保持
・1フレームごとの処理よりも早いタイミングで動作・core Cubeの位置の描画を行うスクリプト (coreCubeController.cs)
・1フレームに1度だけ、最新の描画処理を行うスクリプト分けたので、スクリプト間で値のやりとりが発生しますが実現方法は以下。
coreCubeController.csの中でpublicのclassでCubeの座標/Angle情報を保持するclassをpublicで宣言してあげて
//coreCubeController.cs内で宣言 public class CubeStatus { public int cube_PosX; public int cube_PosY; public int cube_Angle; } public class coreCubeController : MonoBehaviour { public CubeStatus myCubeStatus = new CubeStatus(); }それをdirector.csで読んで値を代入します。
これで最新の座標がmyCubeStatusの中に保持されます。//director.cs内の処理 座標代入だけ this.CubeController.GetComponent<CubeController>().GetIdInformation((positionX, positionY, standardID, angle) => { if (positionX != 0xffff || positionY != 0xffff) { coreCube.GetComponent<coreCubeController>().myCubeStatus.cube_PosX = positionX; coreCube.GetComponent<coreCubeController>().myCubeStatus.cube_PosY = positionY; } if (angle != 0xffff) { coreCube.GetComponent<coreCubeController>().myCubeStatus.cube_Angle = angle; }で、coreCubeController.csではそのmyCubeStatusの情報を使ってobjectの座標に反映するだけ。
coreCubeController.cs内のUpdate()はフレーム毎に呼ばれる処理なので、これで1フレームに1度だけの描画を実現することができました。
なお、past_*は前フレームの値を保持している変数です。この変数と平均をとることで、動きを多少平滑化しました。void Update() { transform.position = new Vector3(1.4f*((myCubeStatus.cube_PosX + past_x)/2 -(955+545)/2) / 10.0f, 0, -1.4f*((myCubeStatus.cube_PosY + past_y)/2 -(45+455)/2) / 10.0f); transform.eulerAngles = new Vector3(0,(myCubeStatus.cube_Angle + past_angle)/2 , 0); past_x = myCubeStatus.cube_PosX; past_y = myCubeStatus.cube_PosY; past_angle = myCubeStatus.cube_Angle; }ここまでのことをやることで、Cube位置のリアルタイム反映は実現完了です。
結構ヌルヌル動いています。これなら十分ゲームに使えそうです。toioの動きをリアルタイムにiPhoneアプリ上に反映出来た!
— Yuya Hirano @ BREMENGames ゲムマ秋Q15 (@idiot_radio_hy) December 23, 2019
unityでプログラミングしてます。
2ヶ月前に初めて勉強し始めたけど、unityでのiPhoneアプリ開発思ったより簡単で楽しい。#toio pic.twitter.com/OmU8fMZjLU2. スマホをタップしたら、その位置に向かってボールを飛ばす
これは参考文献
Unityの教科書 Unity2019完全対応版 2D&3Dスマートフォンゲーム入門講座
の7章を参考にすることで実現できます。Camera.main.ScreenPointToRay()という関数がめちゃくちゃ便利で、メインカメラの原点からタップした座標に向かうベクトルを取得できます。
・タップしたらカメラ原点にボールを出現させる
・Camera.main.ScreenPointToRay()でタップした座標に向かうベクトル取得
・そのベクトル方向へ一定速度でボールを移動させる
で目的の挙動を作ることができます。3. ボールがcore Cubeに当たったら得点を入れる
これも参考文献の8章を参考にすることで実現できます。
流れは以下のような感じで書いてます。
・ボールとMat、ボールとCubeの当たり判定を行う
・ボールとMatが当たったら、ボールの移動を止め、1秒後にボールを消す
・ボールとCubeが当たったら、UIで用意していた得点表示用テキストの内容を書き換える(+100point)さいごに
まず、Unityほんとすごいなと。
スマホアプリ開発、着手するまでものすごく遠い世界の話だったのですが、この2ヶ月のUnity修行で簡単に作れるようになりました。
しかも、マルチプラットフォームなので、Androidでもきっと動くはず。夢が広がります!
あと、toio連携はやはり面白いです。実空間とスマホ内を連携させた遊び方はユニーク。
今回はまだ、ゲーム的に面白くなるような調整も出来てない初歩の初歩と言った段階でこの記事を書きましたが、
面白い物作ってApp storeに公開するのを目標に頑張っていきたいと思います。余談
なぜシューティングゲームを作ったか?
ですが、3歳になる息子でも楽しめるものを作りたいという思いからです。
toioは6歳以上が対象で、ちょっとうちの息子にはまだ早い。Unityの教科書 Unity2019完全対応版 2D&3Dスマートフォンゲーム入門講座
に有るサンプルアプリを作っていたとき、
「画面をタップしたらボールが飛んでいくだけ」のサンプルアプリにいやに興味を示していたので、
toioと組み合わせてシューティングゲームにしてみました。思惑通り、キャッキャ言って遊んでくれたので、今回は目的達成です!
- 投稿日:2019-12-21T19:44:54+09:00
mixamoで制作した3DキャラクターをUnityで使ってみた
mixamoで制作した3DキャラクターをUnityで使ってみた
本記事は、サムザップ Advent Calendar 2019 #2 の12/21の記事です。
はじめに
こんにちは。Unityエンジニアの金崎です。
mixamoというサービスをご存じでしょうか。mixamoは3Dキャラのボーンアニメーションを簡単に作ることができます。モック開発や個人開発などでとても役立つサービスだと思います。
今回はこのmixamoを使用してボーンアニメーションを制作し、Unityで動かすまでの流れを紹介します。mixamoとは
mixamoは2015年にadobeに買収された3Dキャラクタをカスタマイズしたり、ボーンアニメーションを簡単に作成できるwebサービスです。
Adobe Creative Cloudのアカウントがあれば、利用することができますmixamoでのボーンアニメーション制作の手順
以下の順番でアニメーションを作っていきます
1. 3Dモデルを用意
2. mixamoにキャラクターデータをアップロード
3. ポイントの設定
4. アニメーションの設定1. 3dモデルの用意
3Dモデルを用意します。
mixamoには、3Dモデルが用意されており、それを利用することもできます。
今回は自分で制作したボクセルで制作したモデルを使います。
2. mixamoにキャラクターデータをアップする
用意した3Dモデルを使うにはUPLOAD CHARACTERボタンを押します。
アップロードするためのダイアログが開くので、使用したい3Dモデルをドラッグアンドドロップします。
対応ファイルはFBX, OBJ, ZIPファイルです。
今回はOBJファイル、マテリアルファイル、テクスチャ画像をZipファイルにしてアップしています。
正常にアップロードされると以下のようなダイアログが開かれます。
問題がなければ、NEXTボタンを押して、次に進みます。
3. ポイントの設定
3Dモデルにポイントを設定します。(あご、両手首、両肘、両膝、股下)
また、赤枠の箇所で指の設定も指定できます。今回はNo Fingerに設定しました。
設定が終わったら、次へボタンを押下します
正常に設定が終わると3Dモデルがアニメーションする表示に切り替わります。
NEXTボタンを押して、アニメーションの設定をしていきます。
4. アニメーション設定の設定
mixamoに用意されたボーンアニメーションを3Dモデルに反映させることができます。
アニメーションが決まったら、DOWNLOADボタンを押します。
Formatが指定できるので、FBX for unityに設定してDOWNLOADボタンを押します。
これでアニメーションデータの完成です。
unityに導入
1. 3Dモデルのインポートと設定
unityに3Dモデルをドラッグアンドドロップします。
Assets/Man配下に配置しました。
ただし、ドラッグアンドドロップしただけでは、マテリアルが貼られていません。
この問題を解決するためにテクスチャとマテリアルを生成する必要があります。
配置した3Dモデルを選択し、Inspector上のMaterialsタブを選択します。
Extract Textures...とExtract Materials...ボタンを押下し、テクスチャとマテリアルを生成します。
これでマテリアルが正しく設定されます。
2. アニメーションの制御の追加
まず、AnimationControllerファイルを作ります。
AnimationControllerに新しくステート(今回はPunch)を作り、アニメーションファイルを設定します。
作成したAnimationControllerを3Dモデルのインスペクタに設定します。
再生するとキャラクターがmixamoで作った動きをします。
まとめ
今回はmixamoでのボーンアニメーションの作成とunityへの導入方法の紹介しました。
とても簡単に3Dキャラクターをアニメーションをさせることができますので、一度体験してみてはいかがですか。明日は、@ichikawa_masahiro さんの記事です。
- 投稿日:2019-12-21T19:26:24+09:00
初心者がUnityでゲームを制作する過程[その1]
はじめに
タイトル通りプログラミングを学び始めて数ヶ月ですが、ゲームを作ってみようと思ったのでその過程を投稿してみようと思いました。なので、これがQiitaでの初投稿になります。そのため稚拙な部分など多々ありますがそこはご指摘いただけると幸いです。またこの投稿が、同じように手を出して見ようと思っている人たちの助けとなれればと思います。
現在のスキル
C#である程度かけるので、UnityでもC#を使用していきます。
また、Unityの基本的な知識を身に付けるために映像コンテンツを使用しました。使用した教材:ドットインストール
Unity ゲーム開発入門:インディーゲームクリエイターが教えるマリオのようなゲームを作成する方法ゲーム内容
今回はモノポリーのような桃鉄のようなものを作っていきたいと思います。
ゲーム盤に駅を配置し、サイコロの出た目にそって電車が駅を回っていくことを想定していますでは次の回から制作していきます。
- 投稿日:2019-12-21T18:45:25+09:00
【Unity】ゼロから作るノードベースエディター
概要
目標は、UnityのUIElementsによってノードベースエディターを作ることです。
タイトルの「ゼロから」が意味するところは、「UIElements」を知らないところから、という意味です。
そのため、UIElemtnsに関する前提知識は必要ありません。Unityのバージョンは2019.1.3f1です。
プロジェクトはGitHubに挙げておきます。
https://github.com/saragai/GraphEditor追記:バージョン2019.2.16f1でこのエディタを使用したところ、エッジの選択ができなくなっていました。
背景
Unity2019からUIElementsというUIツールが入りました。
現在はエディタ拡張にしか使えませんが、将来的にはゲーム内部のUIにも使えるようになるそうです。最近の機能でいえば、ShaderGraphのようなGUIツールもUIElementで作られています。
[画像は引用:https://unity.com/ja/shader-graph]これはGraphViewというノードベースエディタによって作られていて、GraphViewを使えばShaderGraphのようなヴィジュアルツールを作成できます。
[参照:GraphView完全理解した(2019年末版)]さて、本記事の目標はGraphViewのようなのツールを作ることです。
いやGraphView使えばいいじゃん、と思った方は鋭いです。実用に耐えるものを作るなら、使った方がよいと思います。
さらに、本記事はUnityが公開しているGraphViewの実装を大いに参考にしているので、GraphViewを使うならすべて無視できる内容です。とはいえ、内部でどんなことをすると上記画像のようなエディタ拡張ができるのか、気になる方も多いのではと思います。
その理解の一助となればと思います。注)この記事は手順を細かく解説したり、あえて不具合のある例を付けたりしているので、冗長な部分が多々あります。
実装
公式ドキュメントを見ながら実装していきます。
https://docs.unity3d.com/2019.1/Documentation/Manual/UIElements.html
https://docs.unity3d.com/2019.3/Documentation/ScriptReference/UIElements.VisualElement.html0. 挨拶
エディタ拡張用のスクリプトは必ず Assets/[どこでもいい]/Editor というディレクトリの下に置きます。ビルド時にビルド対象から外すためです。
というわけで、Assets/Scripts/Editor にC#ファイルを作って GraphEditor と名付けます。Graphとは、頂点と辺からなるデータ構造を示す用語で、ビヘイビアツリーや有限オートマトンもGraphの一種です。ビヘイビアツリーの場合はアクションやデコレータが頂点、それぞれがどのようにつながっているかが辺に対応します。ひとまずの目標は、このグラフを可視化できるようにすることです。
とはいえ、まだUIElementsと初めましてなので、まずは挨拶から始めます。
// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] // Unityのメニュー/Window/GraphEditorから呼び出せるように public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); // ウィンドウを作成。 graphEditor.Show(); // ウィンドウを表示 graphEditor.titleContent = new GUIContent("Graph Editor"); // Windowの名前の設定 } public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); } }Unity公式ブログのはじめの例を参考にしました。
ウィンドウが作成されたときに呼ばれるOnEnable()で、はじめてUIElementsと対面します。
見たところ、UIElementsはウィンドウの大元にあるrootVisualElementにどんどん要素を追加していく方式なんですね。
rootVisualElementはVisualElementクラスで、LabelもVisualElementクラスを継承しています。さあ、メニューからWindow/GraphEditorを選択すると以下のようなウィンドウが表示されます。
こんにちは!
ひとまず、挨拶は終わりました。1. ノードを表示する
Inspectorのように、行儀よく上から下へ情報を追加していくUIであれば、あとは色を変えてみたり、ボタンを追加してみたり、水平に並べてみたりすればいいのですが、ノードベースエディタを作ろうとしているのでそれだけでは不十分です。
四角形を自由自在に動かせなければいけません。ドキュメントには、UIElementの構造の説明として、このような図がありました。
[画像は引用:https://docs.unity3d.com/ja/2019.3/Manual/UIE-VisualTree.html]
まずは、このred containerのような四角形を出したいですね。というわけでいろいろ試してみます。
1.1 表示場所を指定する
ドキュメントによるとVisualElementはそれぞれlayoutなるメンバを持っていて、layout.positionやlayout.transformによって親に対する位置が決まるようです。実際に試してみましょう。
// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); } public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); } }// NodeElement.cs using UnityEngine; using UnityEngine.UIElements; public class NodeElement : VisualElement { public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); transform.position = pos; Add(new Label(name)); } }先ほどと違うのは、NodeElementクラスですね。
NodeはGraphの頂点のことで、有限オートマトンでいうと状態に対応します。このNodeElementのコンストラクタに色と位置を渡して、内部でstyle.backgroundClorとtransform.positionを設定します。
それをrootにAddして、どのように表示されるかを見てみます。以下、結果です。
お!
表示位置が唐突な場所になっていますね。
右にずっと伸びていますが、まだ幅を指定していないからでしょう。もう一つ追加してみましょう。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); }
……あれ?
同じY座標を指定したのに二つのノードは重なっていません。本当は、
このようになって欲しかったのです。
ちなみに、この時点で上の図のようにするには以下を書きました。// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 32))); // y座標を変更 }どうやら18ピクセルだけ勝手に下にずらされていたようです。困ります。いい感じに自動レイアウトしてくれるという親切心だとは思うのですが、私が今作りたいのはヴィジュアルツールなので、上下左右に自在に動かしたいのです。
探すと別のドキュメントにありました。
Set the position property to absolute to place an element relative to its parent position rectangle. In this case, it does not affect the layout of its siblings or parent.
positionプロパティをabsoluteにすれば兄弟(=siblings)や親の影響を受けないよとあります。
positionプロパティってなんだと思いましたが、VisualStudioの予測変換機能を駆使して見つけました。NodeElementのコンストラクタを以下のように書き換えます
// NodeElementクラス public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; // 追加。これがposition propertyらしい transform.position = pos; Add(new Label(name)); }すると、
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); }ちゃんと同じ高さになりました!
なぜか横方向の帯も消えています。これで位置を自由に指定できるようになりました。
1.2 大きさを指定する
次は四角形の大きさを指定します。位置指定はラベルで実験したので勝手に大きさを合わせてくれていましたが、自由に幅や高さを指定したいです。
このような見た目に関する部分はだいたいVisualElement.style
にまとまっているようで、以下のように指定します。// NodeElementクラス public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50, style.width = 100, transform.position = pos; Add(new Label(name)); }初めに言った、red containerのようになったと思います。
2. ノードを動かす
次は表示した四角形をインタラクティブに移動させます。
ヴィジュアルツールでは、見やすいように位置を動かすことは大事です。挙動としては、
1. 四角形を左クリックして選択
2. そのままドラッグすると一緒に動く
3. ドロップで現在の位置に固定
というのを想定しています。2.1 まずは試してみる
これらはどれもマウスの挙動に対しての反応なので、マウスイベントに対するコールバックとして実装します。
探すと公式ドキュメントにThe Event Systemという項がありました。
いろいろと重要そうなことが書いてある気がしますが、今はとりあえずイベントを取りたいのでその中のResponding to Eventsを見てみます。
どうやら、VisualElement.RegisterCallback()
によってコールバックを登録できるみたいですね。マウスに関するイベントはそれぞれ、
1.MouseDownEvent
2.MouseMoveEvent
3.MouseUpEvent
でとることができそうです。NodeElementクラスを以下のように書き換えます。
// NodeElementクラス public NodeElement (string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = pos; Add(new Label(name)); bool focus = false; RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) // 左クリック { focus = true; // 選択 } }); RegisterCallback((MouseUpEvent evt) => { focus = false; // 選択を解除 }); RegisterCallback((MouseMoveEvent evt) => { if (focus) { transform.position += (Vector3)evt.mouseDelta; // マウスが動いた分だけノードを動かす } }); }すると、以下のような挙動になります。
動きましたが、少し使いにくそうな動きです。
まず、赤いノードが黄色のノードの下にあるせいで、赤を動かしている途中にカーソルが黄色の上に来ると、赤が動かなくなってしまいます。
さらにそのあと右クリックをやめても選択が解除されておらず、赤が勝手に動いてしまいます。これは、MouseUpEvent
が赤いノードに対して呼ばれていないことが問題のようです。改善策は、
1. 選択したノードは最前面に来てほしい
2. カーソルがノードの外に出たときにも、マウスイベントは呼ばれてほしい
の二つです。2.2 VisualElementの表示順を変える
ドキュメントのThe Visual Treeの項目に、Drawing orderの項があります。
Drawing order
The elements in the visual tree are drawn in the following order:
- parents are drawn before their children
- children are drawn according to their sibling listThe only way to change their drawing order is to reorder VisualElementobjects in their parents.
描画順を変えるには親オブジェクトが持つVisualElementを並び替えないといけないようです。
それ以上の情報がないのでVisualElementのスクリプトリファレンスを見てみます。その中のメソッドでそれらしいものがないかを探すと……ありました。
BringToFront()
というメソッドで、親の子供リストの中の最後尾へ自分を持っていくものです。
これをMouseDownEventのコールバックに追加します。// NodeElementクラス RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) { focus = true; BringToFront(); // 自分を最前面に持ってくる } });実行結果は以下です。
クリックしたものが最前面へきているのがわかります。
しかし、動画後半のように、マウスを勢いよく動かすとノードがついてこられないことがわかります。2.3 マウスイベントをキャプチャする
マウスを勢いよく動かしたとき、カーソルがノードの外に出るので
MouseLeaveEvent
が呼ばれるはずです。その時にPositionを更新してドラッグ中は常にノードがカーソルの下にあるようにすればよい、と初めは思っていました。
ですが、それだと勢いよく動かした直後にマウスクリックを解除した場合に、MouseUpEvent
が選択中のノードに対して呼ばれないようなのです。
イベントの呼ばれる順序にかかわる部分で、丁寧に対応してもバグの温床になりそうです。いい方法はないかなとドキュメントを読んでいると、よさそうなものを見つけました。
Dispatching Eventsの中のCapture the mouseという項です。VisualElementは
CaptureMouse()
を呼ぶことによって、カーソルが自身の上にないときでもマウスイベントを自分のみに送ってくれるようになるということで、まさにマウスをキャプチャしています。
キャプチャすると、マウスが自分の上にあるかどうかを気にしなくてよくなるので、安心して使えそうです。ということで、MouseDown時にキャプチャし、MouseUp時に解放するように書き換えてみます。
// NodeElementクラス RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) { focus = true; BringToFront(); CaptureMouse(); // マウスイベントをキャプチャ } }); RegisterCallback((MouseUpEvent evt) => { ReleaseMouse(); // キャプチャを解放 focus = false; }); RegisterCallback((MouseCaptureOutEvent evt) => { m_Focus = false; // キャプチャが外れたときはドラッグを終了する }
MouseCaptureOutEvent
は他のVisualElement
などによってキャプチャを奪われたときに呼ばれる関数です。実行結果は以下になります。
無事に意図した動きになりました。2.4 ノードを動かすコードをManipulatorによって分離する
この後もノードには様々な機能が追加される予定ですので、コードが煩雑にならないためにも、ノードを動かす部分を分離してしまいたいです。
どうしようか悩んでいましたが、UIElementsにはManipulatorという仕組みがあることを見つけました。
Manipulatorを使うことで、「ノードを動かす」のような操作を追加するコードをきれいに分離して書くことができます。NodeDraggerというクラスを作成します。
// NodeDragger.cs using UnityEngine; using UnityEngine.UIElements; public class NodeDragger : MouseManipulator { private bool m_Focus; public NodeDragger() { // 左クリックで有効化する activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse }); } /// Manipulatorにターゲットがセットされたときに呼ばれる protected override void RegisterCallbacksOnTarget() { m_Focus = false; target.RegisterCallback<MouseDownEvent>(OnMouseDown); target.RegisterCallback<MouseUpEvent>(OnMouseUp); target.RegisterCallback<MouseMoveEvent>(OnMouseMove); target.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut); } /// Manipulatorのターゲットが変わる直前に呼ばれる protected override void UnregisterCallbacksFromTarget() { target.UnregisterCallback<MouseDownEvent>(OnMouseDown); target.UnregisterCallback<MouseUpEvent>(OnMouseUp); target.UnregisterCallback<MouseMoveEvent>(OnMouseMove); target.UnregisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut); } protected void OnMouseDown(MouseDownEvent evt) { // 設定した有効化条件をみたすか (= 左クリックか) if (CanStartManipulation(evt)) { m_Focus = true; target.BringToFront(); target.CaptureMouse(); } } protected void OnMouseUp(MouseUpEvent evt) { // CanStartManipulation()で条件を満たしたActivationのボタン条件と、 // このイベントを発火させているボタンが同じか // (= 左クリックを離したときか) if (CanStopManipulation(evt)) { target.ReleaseMouse(); m_Focus = false; } } protected void OnMouseCaptureOut(MouseCaptureOutEvent evt) { m_Focus = false; } protected void OnMouseMove(MouseMoveEvent evt) { if (m_Focus) { target.transform.position += (Vector3)evt.mouseDelta; } } }
RegisterCallBacksOnTarget()
とUnregisterCallbacksFromTarget()
はManipulator
クラスの関数で、イベントのコールバックの登録・解除を担っています。
activators
やCanStartManipulation()
、CanStopManipulation()
はManipulator
クラスを継承するMouseManipulator
クラスの関数で、マウスのボタンの管理がしやすくなっています。
細かいことはコード中のコメントに記載しました。このManipulatorを使用するには、対象のVisualElementを設定しなければいけません。
// NodeElementクラス public NodeElement (string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = pos; Add(new Label(name)); AddManipulator(new NodeDragger()); // 操作の追加が一行で済む }
AddManipulator
という関数によって対象のVisualElementを設定しています。
実はこのコードは以下のようにもかけます。new NodeDragger(){target = container};内部の実装を見ると、
AddManipulator
ではIManipulator.target
プロパティに自身をセットしているだけでした。
そしてsetter内で、セットする前に既存のtargetがあればUnregisterCallbacksFromTarget()
を呼び、そのあと新規のターゲットをセットしてからRegisterCallbacksOnTarget()
を呼びます。[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/Manipulators.cs]
[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/MouseManipulator.cs]3. ノードを追加する
これまではテストのためにノードの数は2つで固定されていましたが、自在に追加できなければグラフエディタとはとても呼べません。
想定している挙動は、
- 右クリックでメニューが出てくる
- 「Add Node」を選択する
- 右クリックした場所にノードが生成される
です。
......実は前章の最後あたりで、このままのペースで書いていると時間がいくらあっても足りないと思い、先に実装してから記事を書くことにしました。
ですので、これからの説明は少しスムーズに(悪く言えば飛躍気味に)なるかもしれません。ご了承ください。3.1 メニューを表示する
2.4節で見たようなManipulatorと同じように、この挙動も操作としてまとめることができそうです。
というか、こんなみんなが欲しそうな機能が公式に用意されていないはずがありません。
案の定、存在しました。例によって公式ドキュメントです。コードを載せます。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); } void OnContextMenuPopulate(ContextualMenuPopulateEvent evt) { // 項目を追加 evt.menu.AppendAction( "Add Node", // 項目名 AddEdgeMenuAction, // 選択時の挙動 DropdownMenuAction.AlwaysEnabled // 選択可能かどうか ); } void AddEdgeMenuAction(DropdownMenuAction menuAction) { Debug.Log("Add Node"); } }期待していた挙動は、「背景を左クリックしたときはメニューが開いて、ノードを左クリックしたときは何も起こらない」です。でも、これでは逆ですね。
イベント発行についてのドキュメントを見てみます。
[図は引用:https://docs.unity3d.com/2019.1/Documentation/Manual/UIE-Events-Dispatching.html]イベントは root -> target -> root と呼ばれるみたいですね。イベント受け取りについてのドキュメントには、デフォルトではTargetフェイズとBubbleUpフェイズにイベントが登録されるともあります。
とにかく、思い当たるのは、ルートに登録したコールバックがノード経由で伝わっているということです。
いろいろ試してみてわかったのは、ルートではデフォルトでpickingMode
がPickingMode.Ignore
に設定されているということでした。リファレンスによると、マウスのクリック時にターゲットを決める際、その位置にある一番上のVisualElementを取ってきているらしいのですが、この
pickingMode
がPickingMode.Ignore
に設定されていた場合は候補から外す、という挙動になるようです。実際、このようにすると動きます。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); root.pickingMode = PickingMode.Position; // ピッキングモード変更 root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); }
でも、ルートをむやみに変更するのはよくないですね。
そこで、ルートの一つ下に一枚挟むことにします。今の作りでは、EditorWindowとVisualElementが不可分になってしまっていましたが、それを分離可能にするという意味合いもあります。さあ、いよいよもってGraphViewに近づいてきました。
分離自体はすぐにできます。// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); } GraphEditorElement m_GraphEditorElement; public void OnEnable() { VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(); root.Add(m_GraphEditorElement); } }// GraphEditorElement.cs using UnityEngine; using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditorElement: VisualElement { public GraphEditorElement() { style.flexGrow = 1; // サイズを画面いっぱいに広げる style.overflow = Overflow.Hidden; // ウィンドウの枠からはみ出ないようにする Add(new NodeElement("One", Color.red, new Vector2(100, 50))); Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); } void OnContextMenuPopulate(ContextualMenuPopulateEvent evt) { evt.menu.AppendAction( "Add Node", AddNodeMenuAction, DropdownMenuAction.AlwaysEnabled ); } void AddNodeMenuAction(DropdownMenuAction menuAction) { Debug.Log("Add Node"); } }二つのスタイルを適用しました。
style.flexGrow = 1;
によってGraphEditorElementのサイズが画面いっぱいに広がり、クリックイベントを拾う背景の役割を果たしてくれます。
style.overflow = Overflow.Hidden;
は親の領域からはみ出た部分を表示しないようにします。ノードを動かすとウィンドウの枠からはみ出したりしていましたが、これでもう心配はいりません。挙動はこのようになります。
まだノードの上で右クリックしたときもAdd Nodeメニューが出てしまいます。
これはノードに対しても何かを設定する必要がありそうですね。後でノードを左クリックしたときにエッジを追加する挙動を実装します。そのとき考えましょう。
とにかくメニューは出たということで、次へ進んでいきます。
3.2 ノードを生成する
Add Nodeというログを出していた部分を少し変更すると、新しいノードが生成できます。
// GraphEditorElementクラス void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; // マウス位置はeventInfoの中にあります Add(new NodeElement("add", Color.green, mousePosition)); }挙動です。
これで、表示の上では新しいノードを生成できました。4. ノードを永続化する
3章で生成したノードはGraphEditorウィンドウを開きなおしたりすると消えてしまいます。
Unityで何らかのデータを保存しておくには、どこかのファイルにシリアライズしておく必要があります。「シリアライズとはXXXである」と一言で言えたらいいのですが、短く上手く説明できる気がしません。
脱線になってしまってもよくないので、気になる方は「Unity シリアライズ」などで検索してみてください。4.1 グラフ構造とは
方針としては、グラフを再現するのに最低限必要なものを用意します。
冒頭でも少し触れましたが、ここでグラフの定義を明確にしておきます。グラフには大きく分けて二種類あります。
無向グラフと有向グラフです。
[図は引用:https://qiita.com/drken/items/4a7869c5e304883f539b]エッジ、つまり辺に向きがあるかないかの差があります。
ゲームで使う場合、ロジックを表現するためのグラフはほとんどが有向グラフなのではと思います。ビヘイビアツリー: ノードはアクション、エッジは遷移
有限オートマトン: ノードは状態、エッジは遷移ということで、作成中のグラフエディタも、有向グラフを表せるものを作りたいと思います。
余談ですが、無向グラフは有効グラフの矢印と逆向きに同じ矢印を付けると実現することができます。4.2 シリアライズ用のクラスを作る
構造としては、
グラフアセット:ノードリストを持つ
ノード:エッジリストを持つ
エッジ:つながるノードを持つとして、グラフアセットをアセットとして新規作成できるようにしようと思います。
実装は以下のようにします。// GraphAsset.cs using System.Collections.Generic; using UnityEngine; [CreateAssetMenu(fileName ="graph.asset", menuName ="Graph Asset")] public class GraphAsset : ScriptableObject { public List<SerializableNode> nodes = new List<SerializableNode>(); } [System.Serializable] public class SerializableNode { public Vector2 position; public List<SerializableEdge> edges = new List<SerializableEdge>(); } [System.Serializable] public class SerializableEdge { public SerializableNode toNode; }
ScriptableObject
に[CreateAssetMenu]
を付けることで、Unityのプロジェクトなどで右クリックをしたときにメニューから生成できるようになります。
また、[System.Serializable]
アトリビュートによって、指定したクラスをシリアライズ可能にしています。早速、グラフアセットを作ってみました。
すると、このようなエラーが出ます。Serialization depth limit 7 exceeded at 'SerializableEdge.toNode'. There may be an object composition cycle in one or more of your serialized classes. Serialization hierarchy: 8: SerializableEdge.toNode 7: SerializableNode.edges 6: SerializableEdge.toNode 5: SerializableNode.edges 4: SerializableEdge.toNode 3: SerializableNode.edges 2: SerializableEdge.toNode 1: SerializableNode.edges 0: GraphAsset.nodes UnityEditor.InspectorWindow:RedrawFromNative()そう、ノードがシリアライズしようとするエッジが、さらにノードをシリアライズしようとして、循環が発生しているのです。
シリアライズの仕組みとして、クラスの参照をそのまま保存することはできません。では、どうするかというと、ノードのIDを保存しておくことにします。
UnityのGUIDみたいに大きなIDを振ってもいいのですが、振るのとか対応付けとかが面倒そうです。
そこで、ここではGraphAssetが持っているノードリストの何番目にあるか、というのをIDとしようと思います。SerializableEdgeだけ以下のように直します。
// GraphAsset.cs [System.Serializable] public class SerializableEdge { public int toId; }これでワーニングは出なくなります。
4.3 アセットとエディタを対応付ける
どのアセットを表示・編集するかを決めるために、エディタにアセットの情報を持たせなければいけません。
実際にエディタを使うときのことを考えると、アセットからエディタが開けて、その際にそのアセットについて編集するようにできたらいいですね。というわけで要件としては、
1. GraphAssetをダブルクリックするとエディタが開く
2. どこかのGraphEditorElementクラスにGraphAssetクラスを渡す
です。// GraphAsset.cs using UnityEngine; using UnityEditor; using UnityEditor.Callbacks; // OnOpenAssetアトリビュートのために追加 using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); if(Selection.activeObject is GraphAsset graphAsset) { graphEditor.Initialize(graphAsset); } } [OnOpenAsset()] // Unityで何らかのアセットを開いたときに呼ばれるコールバック static bool OnOpenAsset(int instanceId, int line) { if(EditorUtility.InstanceIDToObject(instanceId) is GraphAsset) // 開いたアセットがGraphAssetかどうか { ShowWindow(); return true; } return false; } GraphAsset m_GraphAsset; // メンバ変数として持っておく GraphEditorElement m_GraphEditorElement; public void OnEnable() { // ShowWindow()を通らないような時(スクリプトのコンパイル後など) // のために初期化への導線を付ける if (m_GraphAsset != null) { // 初期化はInitializeに任せる Initialize(m_GraphAsset); } } // 初期化 public void Initialize(GraphAsset graphAsset) { m_GraphAsset = graphAsset; // 以下はもともとOnEnable() で行っていた処理 // OnEnable() はCreateInstance<GraphEditor>() の際に呼ばれるので、まだgraphAssetが渡されていない // 初期化でもgraphAssetを使うことになるのでここに移す VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(); root.Add(m_GraphEditorElement); } }これで、GraphAssetファイルをダブルクリックしたときにエディタが開くようになります。
4.4 アセットのデータからノードを表示するようにする
続いて、アセットにある情報からノードを構築、表示したいと思います。
まずはGraphAssetにダミーの情報を手打ちします。
(100, 50)と(200, 50)の位置、つまり今まで表示してきた赤と黄色の位置、にノードが表示されればOKです。まず、NodeElementを少し変えます。
色の情報はアセットにはないので省きますし、位置はシリアライズされますからね。具体的には、生成をSerializableNodeから行うようにします。
// NodeElement.cs // BackgroundColorがなくなると見えなくなるので、周囲を枠線で囲んだVisualElement、Boxを継承する public class NodeElement : Box { public SerializableNode serializableNode; public NodeElement (SerializableNode node) // 引数を変更 { serializableNode = node; // シリアライズ対象を保存しておく style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = node.position; // シリアライズされている位置を取る this.AddManipulator(new NodeDragger()); } }GraphEditorElementも伴って変更します。
// GraphEditorElement.cs using UnityEngine; using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditorElement: VisualElement { GraphAsset m_GraphAsset; // 渡されたアセットを保存 List<NodeElement> m_Nodes; // 作ったノードを入れておく。順序が重要 public GraphEditorElement(GraphAsset graphAsset) { m_GraphAsset = graphAsset; style.flexGrow = 1; style.overflow = Overflow.Hidden; this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); m_Nodes = new List<NodeElement>(); // 順番にノードを生成。この作る際の順番がSerializableEdgeが持つNodeのIDとなる foreach(var node in graphAsset.nodes) { CreateNodeElement(node); } } void CreateNodeElement(SerializableNode node) { var nodeElement = new NodeElement(node); Add(nodeElement); // GraphEditorElementの子として追加 m_Nodes.Add(nodeElement); // 順番を保持するためのリストに追加 } /* ... 省略 */ void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; CreateNodeElement(new SerializableNode() { position = mousePosition }); // 追加生成時には仮で新しく作る } }GraphEditorElementのコンストラクタにGraphAssetを渡すようにしたので、GraphEditorから生成するときに必要です
// GraphEditorクラス public void Initialize(GraphAsset graphAsset) { m_GraphAsset = graphAsset; VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(graphAsset); // アセットを渡す root.Add(m_GraphEditorElement); }以上で、アセットに保持された情報を描画することができました。
書き込みはしていないので、当然開きなおすと追加したノードは消えてしまいます。4.5 追加作成したノードをアセットに書き込む
前節までできたら、あとはもう少し変えるだけです。
// GraphEditorElementクラス private void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; var node = new SerializableNode() { position = mousePosition }; m_GraphAsset.nodes.Add(node); // アセットに追加する CreateNodeElement(node); }これでアセットに書き込まれます。
おっと、動かしたことを記録するのを忘れていました。// NodeDraggerクラス protected void OnMouseUp(MouseUpEvent evt) { if (CanStopManipulation(evt)) { target.ReleaseMouse(); if(target is NodeElement node) { //NodeElementに保存しておいたシリアライズ対象のポジションをいじる node.serializableNode.position = target.transform.position; } m_Focus = false; } }動かしてドラッグをやめた瞬間に記録するとよいと思います。
これで動かしたことも保存されるようになりました。
5. エッジを追加する
頂点の表示ができたので、次は辺です。辺は頂点同士を線で結ぶことで表します。
コンテナ的な仕組みでは直線や曲線は引けないように思うので、ここは既存の仕組みで線を引きます。
Handles.Draw
系の関数が一番楽かなと思います。
DrawLine
やDrawBezier
などです。ちなみにGraphViewでは、エッジ用のメッシュを作って、
Graphics.DrawMeshNow()
で描画をしていました。5.1 エッジを表示する
とりあえずダミーでデータを作ってみます。
Element0のEgesに要素を追加しました。
このまま表示するとこうなります。
イメージとしては、左上のノードから右下のノードへ繋がっている矢印があればいいなと思います。VisualElementは初期化字に一度呼べば後は自動で描画してくれていましたが、
Handles
で描画をするならウィンドウ更新のたびに呼ぶ必要があります。
EditorWindowの更新といえば、OnGUI
です。
ウィンドウの更新のたびにOnGUI
が呼ばれますので、そこからGraphEditorElementの描画関数を呼ぶことにします。ひとまずこのように実装してみます。
// GraphEditorクラス private void OnGUI() { if(m_GraphEditorElement == null) { return; } m_GraphEditorElement.DrawEdge(); }// GraphEditorElementクラス public void DrawEdge() { for(var i = 0; i < m_GraphAsset.nodes.Count; i++) { var node = m_GraphAsset.nodes[i]; foreach(var edge in node.edges) { DrawEdge( startPos: m_Nodes[i].transform.position, startNorm: new Vector2(0f, 1f), endPos: m_Nodes[edge.toId].transform.position, endNorm: new Vector2(0f, -1f)); } } } private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm) { Handles.color = Color.blue; // 色指定 // エッジをベジェ曲線で描画 Handles.DrawBezier( startPos, endPos, startPos + 50f * startNorm, endPos + 50f * endNorm, color: Color.blue, texture: null, width: 2f); // 矢印の三角形の描画 Vector2 arrowAxis = 10f * endNorm; Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward); Handles.DrawAAConvexPolygon(endPos, endPos + arrowAxis + arrowNorm, endPos + arrowAxis - arrowNorm); Handles.color = Color.white; // 色指定をデフォルトに戻す }ポジションとして単に
VisualElement.transform.position
を利用しているので左上隅に始点・終点が来ています。
元ノードは下辺中央から、先ノードの上辺中央につながってほしい気がします。
とはいえ、GraphEditorElementでNodeの形に関する部分を決め打ちで呼んでしまうのはちょっと気持ち悪いので、NodeElementに始点や終点の位置・方向の情報を返す関数を作ろうと思います。// GraphEditorElementクラス public void DrawEdge() { for(var i = 0; i < m_GraphAsset.nodes.Count; i++) { var node = m_GraphAsset.nodes[i]; foreach(var edge in node.edges) { // ノードに情報を問い合わせる DrawEdge( startPos: m_Nodes[i].GetStartPosition(), startNorm: m_Nodes[i].GetStartNorm(), endPos: m_Nodes[edge.toId].GetEndPosition(), endNorm: m_Nodes[edge.toId].GetEndNorm()); } } }// NodeElementクラス public Vector2 GetStartPosition() { return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, style.height.value.value); } public Vector2 GetEndPosition() { return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, 0f); } public Vector2 GetStartNorm() { return new Vector2(0f, 1f); } public Vector2 GetEndNorm() { return new Vector2(0f, -1f); }また、エッジを描画するだけなら、エッジのVisualElementを作らずにGraphAssetに保存されているSerializableEdgeの値を見ていればよいのですが、エッジの追加・削除・付け替えなど、いずれ必要になるであろう操作がやりにくくなります。
そこで、エッジにもEdgeElementクラスを作ります。
// EdgeElement.cs using UnityEngine; using UnityEngine.UIElements; using UnityEditor; public class EdgeElement : VisualElement { public SerializableEdge serializableEdge; // データを持っておく public NodeElement From { get; private set; } // 元ノード public NodeElement To { get; private set; } // 先ノード public EdgeElement(SerializableEdge edge, NodeElement from, NodeElement to ) { serializableEdge = edge; From = from; To = to; } public void DrawEdge() { if(From != null && To != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: To.GetEndPosition(), endNorm: To.GetEndNorm()); } } // GraphEditorElementからそのまま移した private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm) { Handles.color = Color.blue; Handles.DrawBezier( startPos, endPos, startPos + 50f * startNorm, endPos + 50f * endNorm, color: Color.blue, texture: null, width: 2f); Vector2 arrowAxis = 10f * endNorm; Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward); Handles.DrawAAConvexPolygon(endPos, endPos + arrowAxis + arrowNorm, endPos + arrowAxis - arrowNorm); Handles.color = Color.white; } }このクラスもノードと同様に、GraphEditorElementが生成し、GraphEditorElementの子として保持することにします。
ノードが持っていて、ノードの子として生成というのも考えましたが、GraphEditorで一元管理した方が構造が単純になりそうだと思ったのが理由です。実装はこうです。
// GraphEditorElementクラス List<EdgeElement> m_Edges; // エッジもノードと同じくまとめて保持しておく public GraphEditorElement(GraphAsset graphAsset) { m_GraphAsset = graphAsset; style.flexGrow = 1; style.overflow = Overflow.Hidden; this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); m_Nodes = new List<NodeElement>(); foreach(var node in graphAsset.nodes) { CreateNodeElement(node); } // すべてのノードの生成が終わってからエッジの生成を行う // エッジが持っているノードIDからノードを取得するため m_Edges = new List<EdgeElement>(); foreach(var node in m_Nodes) { foreach(var edge in node.serializableNode.edges) { CreateEdgeElement(edge, node, m_Nodes); } } } // エッジの生成 public EdgeElement CreateEdgeElement(SerializableEdge edge, NodeElement fromNode, List<NodeElement> nodeElements) { var edgeElement = new EdgeElement(edge, fromNode, nodeElements[edge.toId]); Add(edgeElement); m_Edges.Add(edgeElement); return edgeElement; } // GraphEditor.OnGUI() 内で呼ばれる。描画処理をエッジに移したので小さくなった public void DrawEdge() { foreach(var edge in m_Edges) { edge.DrawEdge(); } }見た目は先ほどと変わりません。
5.2 エッジを追加できるようにする
あるノードからあるノードにエッジをつけようと思う時、元ノードから先ノードへ線を伸ばしていくようなイメージになると思います。
UnityのGraphViewやUnrealEngineのBluePrintではノードに備わった接続用のポートをクリックしてそのままドラッグすると線が引かれていきます。
UnrealEngineのBehaviourTreeでは、ノードの上下にエッジ接続領域があります。
これらのようなポートや接続領域などはあると便利そうですが、いったんメニューにAdd Edgeを追加するので良いでしょう。
重要なのは、追加中に元ノードからエッジがマウスの位置を追従していることです。
このUIによって、現在エッジ追加操作中であることと、つなげるノードを指定する方法が直感的にわかります。
これは実装したいです。挙動としては、
1. ノードを右クリックする
2. メニューから「Add Edge」を選択する
3. 元ノードからマウスの位置に向かうエッジ候補ができる
4. 他のノードを左クリックして、エッジの向かい先を確定するを想定します。
ノードに対する操作なので、ノードにManipulator
を追加します。
エッジをつなぐ操作なので、EdgeConnectorクラスとします。5.2.1 EdgeConnectorクラスを作る
EdgeConnectorの役割はメニューを出してエッジ追加モードに入ることと、そのあとに別のノードをクリックして実際にノードを接続することの二つあります。
その中でメニューを出す部分はContexturalMenuManipulator
の役割ですので、EdgeConnectorクラスの中でContexturalMenuManipulator
を作成し、それをEdgeConnectorのターゲットノードにAddManipulator
しようと思います。こうすることで、NodeElementにEdgeConnectorを追加するだけで、エッジ追加の処理をすべてEdgeConnectorクラスに投げることができます。
// NodeElementクラス public NodeElement (SerializableNode node) { /* ... 省略 */ this.AddManipulator(new NodeDragger()); this.AddManipulator(new EdgeConnector()); // 追加 }そして、EdgeConnectorの内部はひとまずこのようにしておきます。
using UnityEngine; using UnityEngine.UIElements; public class EdgeConnector : MouseManipulator { bool m_Active = false; ContextualMenuManipulator m_AddEdgeMenu; public EdgeConnector() { // ノードの接続は左クリックで行う activators.Add(new ManipulatorActivationFilter() { button = MouseButton.LeftMouse }); m_Active = false; // メニュー選択マニピュレータは作っておくが、この時点ではターゲットが確定していないので、 // RegisterCallbacksOnTarget()で追加する m_AddEdgeMenu = new ContextualMenuManipulator(OnContextualMenuPopulate); } private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement node) { // エッジ追加中に右クリックを押されたときのために、ノードの上かどうかを見る if (!node.ContainsPoint(node.WorldToLocal(evt.mousePosition))) { // イベントを即座に中断 evt.StopImmediatePropagation(); return; } evt.menu.AppendAction( "Add Edge", (DropdownMenuAction menuItem) => { m_Active = true; Debug.Log("Add Edge"); // ここでエッジ追加モード開始処理を書く target.CaptureMouse(); }, DropdownMenuAction.AlwaysEnabled); } } protected override void RegisterCallbacksOnTarget() { target.RegisterCallback<MouseDownEvent>(OnMouseDown); target.RegisterCallback<MouseUpEvent>(OnMouseUp); target.RegisterCallback<MouseMoveEvent>(OnMouseMove); target.RegisterCallback<MouseCaptureOutEvent>(OnCaptureOut); target.AddManipulator(m_AddEdgeMenu); } protected override void UnregisterCallbacksFromTarget() { target.RemoveManipulator(m_AddEdgeMenu); target.UnregisterCallback<MouseDownEvent>(OnMouseDown); target.UnregisterCallback<MouseUpEvent>(OnMouseUp); target.UnregisterCallback<MouseMoveEvent>(OnMouseMove); target.UnregisterCallback<MouseCaptureOutEvent>(OnCaptureOut); } protected void OnMouseDown(MouseDownEvent evt) { if (!CanStartManipulation(evt)) return; // マウス押下では他のイベントが起きてほしくないのでPropagationを中断する if (m_Active) evt.StopImmediatePropagation(); } protected void OnMouseUp(MouseUpEvent evt) { if (!CanStopManipulation(evt)) return; if (!m_Active) return; Debug.Log("Try Connect"); // ここでマウスの下にあるノードにエッジを接続しようとする m_Active = false; target.ReleaseMouse(); } protected void OnMouseMove(MouseMoveEvent evt) { if (!m_Active) return; Debug.Log("move"); // ここで、追加中のエッジの再描画を行う } private void OnCaptureOut(MouseCaptureOutEvent evt) { if (!m_Active) return; m_Active = false; target.ReleaseMouse(); } }5.2.2 エッジ追加のためにエッジ・グラフクラスを整備
次に、EdgeElementクラスに追加中のEdgeを作成するための準備をします。
これまではEdgeには元ノードと先ノードを渡して作成していましたが、追加中には先ノード確定していないので、元ノードと矢印の位置からエッジを描画できるようにします。// EdgeElementクラス Vector2 m_ToPosition; public Vector2 ToPosition { get { return m_ToPosition; } set { m_ToPosition = this.WorldToLocal(value); // ワールド座標で渡されることを想定 MarkDirtyRepaint(); // 再描画をリクエスト } } // 新しいコンストラクタ public EdgeElement(NodeElement fromNode, Vector2 toPosition) { From = fromNode; ToPosition = toPosition; } // つなげるときに呼ぶ public void ConnectTo(NodeElement node) { To = node; MarkDirtyRepaint(); // 再描画をリクエスト } public void DrawEdge() { if (From != null && To != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: To.GetEndPosition(), endNorm: To.GetEndNorm()); } else { // 追加中の描画用 if (From != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: ToPosition, endNorm: Vector2.zero); } } }これにより、追加中のEdgeElementをGraphEditorElementのEdgesに追加すれば自動的に描画されるようになったはずです。
ということで、GraphEditorElementにエッジ追加リクエストを投げられるようにします。
ついでに、ノード追加を中断したときのためにエッジ削除関数も作っておきます。// GraphEditorElementクラス public EdgeElement CreateEdgeElement(NodeElement fromNode, Vector2 toPosition) { var edgeElement = new EdgeElement(fromNode, toPosition); Add(edgeElement); m_Edges.Add(edgeElement); return edgeElement; } public void RemoveEdgeElement(EdgeElement edge) { Remove(edge); m_Edges.Remove(edge); }5.2.3 エッジ追加の挙動を実装
上で作った関数をEdgeConnectorクラスから呼びます。
// EdgeConnectorクラス GraphEditorElement m_Graph; EdgeElement m_ConnectingEdge; private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement node) { evt.menu.AppendAction( "Add Edge", (DropdownMenuAction menuItem) => { m_Active = true; // 親をたどってGraphEditorElementを取得する m_Graph = target.GetFirstAncestorOfType<GraphEditorElement>(); m_ConnectingEdge = m_Graph.CreateEdgeElement(node, menuItem.eventInfo.mousePosition); target.CaptureMouse(); }, DropdownMenuAction.AlwaysEnabled); } } /* ... 省略 */ protected void OnMouseUp(MouseUpEvent evt) { if (!CanStopManipulation(evt)) return; if (!m_Active) return; var node = m_Graph.GetDesignatedNode(evt.originalMousePosition); if (node == null // 背景をクリックしたとき || node == target // 自分自身をクリックしたとき || m_Graph.ContainsEdge(m_ConnectingEdge.From, node)) // すでにつながっているノード同士をつなげようとしたとき { m_Graph.RemoveEdgeElement(m_ConnectingEdge); } else { m_ConnectingEdge.ConnectTo(node); } m_Active = false; m_ConnectingEdge = null; // 接続終了 target.ReleaseMouse(); } protected void OnMouseMove(MouseMoveEvent evt) { if (!m_Active) { return; } m_ConnectingEdge.ToPosition = evt.originalMousePosition; // 位置更新 } private void OnCaptureOut(MouseCaptureOutEvent evt) { if (!m_Active) return; // 中断時の処理 m_Graph.RemoveEdgeElement(m_ConnectingEdge); m_ConnectingEdge = null; m_Active = false; target.ReleaseMouse(); }// GraphEditorElementクラス // マウスの位置にあるノードを返す public NodeElement GetDesignatedNode(Vector2 position) { foreach(NodeElement node in m_Nodes) { if (node.ContainsPoint(node.WorldToLocal(position))) return node; } return null; } // すでに同じエッジがあるかどうか public bool ContainsEdge(NodeElement from, NodeElement to) { return m_Edges.Exists(edge => { return edge.From == from && edge.To == to; }); }5.2.4 追加したエッジをシリアライズする
今のままではEdgeElementを追加しただけなので、つないだエッジはデータとして残っていません。
ノードのときと同じようにシリアライズする必要があります。// EdgeConnectorクラス protected void OnMouseUp(MouseUpEvent evt) { /* ... 省略 */ var node = m_Graph.GetDesignatedNode(evt.originalMousePosition); if (node == null || node == target || m_Graph.ContainsEdge(m_ConnectingEdge.From, node)) { m_Graph.RemoveEdgeElement(m_ConnectingEdge); } else { m_ConnectingEdge.ConnectTo(node); m_Graph.SerializeEdge(m_ConnectingEdge); // つないだ時にシリアライズする } /* ... 省略 */ }// GraphEditorElementクラス public void SerializeEdge(EdgeElement edge) { var serializableEdge = new SerializableEdge() { toId = m_Nodes.IndexOf(edge.To) // ここで先ノードのIDを数える }; edge.From.serializableNode.edges.Add(serializableEdge); // 実際に追加 edge.serializableEdge = serializableEdge; // EdgeElementに登録しておく }保存されています。
5.3 エッジを削除できるようにする
エッジの追加ができるようになったので、やはり削除もできなければいけません。
ノードを削除するときと同様に、エッジの削除もコンテキストメニューから行いたいと思います。
しかし、このとき問題があります。
ノードは大きさのあるVisualElement
だったため、ContextualManipulator
を付けるとそのままクリックで選択ができました。
しかし、エッジのVisualElement
は大きさがありません。5.3.1 エッジを選択できるようにする
VisualElement
をクリックして選択するときの挙動について、ドキュメントに記載がありました。
Event targetのPicking mode and custom shapesの項です。You can override the
VisualElement.ContainsPoint()
method to perform custom intersection logic.この
VisualElement.ContainsPoint()
は、マウス座標を与えると、その座標と自分が衝突しているかを判定する関数です。
それをオーバーライドして、独自の衝突判定を埋め込むことで、VisualElement
のRect
以外の形に対応させることができます。実際にベジェ曲線と点との距離を計算するのは面倒なので、近似した線分との距離を計算して、指定距離以内だったら選択したことにしようと思います。
さて、衝突を判定の実装に当たって、ログを出すものが必要です
というわけで最初に、エッジに削除用のコンテキストメニューを作ります。// EdgeElementクラス // 削除用マニピュレータの追加 public EdgeElement() { this.AddManipulator(new ContextualMenuManipulator(evt => { if (evt.target is EdgeElement) { evt.menu.AppendAction( "Remove Edge", (DropdownMenuAction menuItem) => { Debug.Log("Remove Edge"); }, DropdownMenuAction.AlwaysEnabled); } })); } public EdgeElement(NodeElement fromNode, Vector2 toPosition):this() // 上のコンストラクタを呼ぶ { From = fromNode; ToPosition = toPosition; } public EdgeElement(SerializableEdge edge, NodeElement fromNode, NodeElement toNode):this() // 上のコンストラクタを呼ぶ { serializableEdge = edge; From = fromNode; To = toNode; }まず、接続元と接続先が収まるバウンディングボックスと衝突しているかどうかを判定してみます。
// EdgeElementクラス public override bool ContainsPoint(Vector2 localPoint) { if (From == null || To == null) return false; Vector2 start = From.GetStartPosition(); Vector2 end = To.GetEndPosition(); // ノードを覆うRectを作成 Vector2 rectPos = new Vector2(Mathf.Min(start.x, end.x), Mathf.Min(start.y, end.y)); Vector2 rectSize = new Vector2(Mathf.Abs(start.x - end.x), Mathf.Abs(start.y - end.y)); Rect bound = new Rect(rectPos, rectSize); if (!bound.Contains(localPoint)) { return false; } return true; }結果はこうなりました。
確かに、エッジのバウンディングボックスとの当たりを判定できていそうです。次に、近似線分との距離を計算してみます。
先にバウンディングボックスに入っていないものを弾いているので、端点が一番近い場合などを考えなくて済みます。
つまり、線分ではなく直線と点の距離を考えればよいということです。// EdgeElementクラス readonly float INTERCEPT_WIDHT = 15f; // エッジと当たる距離 public override bool ContainsPoint(Vector2 localPoint) { /* ... 省略 */ if (!bound.Contains(localPoint)) { return false; } // 近似線分ab Vector2 a = From.GetStartPosition() + 12f * From.GetStartNorm(); Vector2 b = To.GetEndPosition() + 12f * To.GetEndNorm(); // 一致した場合はaからの距離 if (a == b) { return Vector2.Distance(localPoint, a) < INTERCEPT_WIDHT; } // 直線abとlocalPointの距離 float distance = Mathf.Abs( (b.y - a.y) * localPoint.x - (b.x - a.x) * localPoint.y + b.x * a.y - b.y * a.x ) / Vector2.Distance(a, b); return distance < INTERCEPT_WIDHT; }結果はこうなりました。
...ちょっとずれている気もしますが、まあ、許容範囲でしょう。5.3.2 エッジデータを削除する
GraphAssetからエッジのデータを消します。
EdgeElementには元ノードの情報が既にありますので、そこから自分のデータが入っているSerializableNodeを取得することができます。
これを消せばよいですね。// EdgeElementクラス public EdgeElement() { this.AddManipulator(new ContextualMenuManipulator(evt => { if (evt.target is EdgeElement) { evt.menu.AppendAction( "Remove Edge", (DropdownMenuAction menuItem) => { // 親をたどってGraphEditorElementに削除リクエストを送る var graph = GetFirstAncestorOfType<GraphEditorElement>(); graph.RemoveEdgeElement(this); }, DropdownMenuAction.AlwaysEnabled); } })); }// GraphEditorElementクラス public void RemoveEdgeElement(EdgeElement edge) { // 消すエッジにSerializableEdgeがあれば、それを消す if(edge.serializableEdge != null) { edge.From.serializableNode.edges.Remove(edge.serializableEdge); } Remove(edge); m_Edges.Remove(edge); }無事、削除できています。
6. ノードを削除する
最後に、ノードを削除できるようにしたいと思います。
ノードを削除したときには、
- NodeElementを削除する
- 対応するSerializableNodeを削除する
- そのノードとつながるEdgeElementを削除する
- 対応するSerializableEdgeを削除する
- 他ノードのIDが変わるので、それに応じてSerializableEdgeのIDを振りなおすのすべてを行う必要があります。
// NodeElementクラス public NodeElement (SerializableNode node) { /* ... 省略 */ this.AddManipulator(new NodeDragger()); this.AddManipulator(new EdgeConnector()); this.AddManipulator(new ContextualMenuManipulator(OnContextualMenuPopulate)); // 削除用マニピュレータ } private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement) { evt.menu.AppendAction( "Remove Node", RemoveNodeMenuAction, DropdownMenuAction.AlwaysEnabled); } } private void RemoveNodeMenuAction(DropdownMenuAction menuAction) { // 親をたどって削除をリクエスト var graph = GetFirstAncestorOfType<GraphEditorElement>(); graph.RemoveNodeElement(this); }// GraphEditorElementクラス public void RemoveNodeElement(NodeElement node) { m_GraphAsset.nodes.Remove(node.serializableNode); // アセットから削除 int id = m_Nodes.IndexOf(node); // エッジの削除とID変更 // m_Edgesに変更が伴うため、降順で行う for (int i = m_Edges.Count - 1; i >= 0; i--) { var edgeElement = m_Edges[i]; var edge = edgeElement.serializableEdge; // 削除されるノードにつながるエッジを削除 if (edgeElement.To == node || edgeElement.From == node) { RemoveEdgeElement(edgeElement); continue; } // 変更が生じるIDを持つエッジに対して、IDに修正を加える if (edge.toId > id) edge.toId--; } Remove(node); // VisualElementの子としてのノードを削除 m_Nodes.Remove(node); // 順序を保持するためのリストから削除 }ウィンドウを開きなおしてもちゃんと構造が保存されています。
結果
ゼロからノードベースエディタを作りました。
現状ではグラフ構造を保存するアセットを作れるだけですが、このノード部分に何か情報を載せると立派なヴィジュアルツールが出来上がります。おわりに
UIElementの使い方を勉強したいと思ったので、ノードベースエディタを作ってみました。
ドキュメントとリファレンスを読み込むことになり、GraphViewの実装もかなり追ったので勉強になってよかったです。
実をいうと、このGraphEditorを使ってBehaviorTreeを作るところまでやりたかったのですが、エディタを作るだけで相当の時間がかかってしまったので、この記事はここまでにしておきます。また、ゼロから作るを銘打って、実装する手順通りに事細かく書いてしまったので、やたら長くなってしまいました。
とはいえ、エディタを作るにあたって得た知見をふんだんに盛り込めたのではないかと思います。ここはもっとこうした方がよい、のような意見があればコメントで教えていただけるとありがたいです。
ご拝読ありがとうございました。
- 投稿日:2019-12-21T18:45:25+09:00
【Unity UIElements】ゼロから作るノードベースエディター
概要
目標は、UnityのUIElementsによってノードベースエディターを作ることです。
タイトルの「ゼロから」が意味するところは、「UIElements」を知らないところから、という意味です。
そのため、UIElemtnsに関する前提知識は必要ありません。Unityのバージョンは2019.1.3f1です。
プロジェクトはGitHubに挙げておきます。
https://github.com/saragai/GraphEditor追記:バージョン2019.2.16f1でこのエディタを使用したところ、エッジの選択ができなくなっていました。
背景
Unity2019からUIElementsというUIツールが入りました。
現在はエディタ拡張にしか使えませんが、将来的にはゲーム内部のUIにも使えるようになるそうです。最近の機能でいえば、ShaderGraphのようなGUIツールもUIElementで作られています。
[画像は引用:https://unity.com/ja/shader-graph]これはGraphViewというノードベースエディタによって作られていて、GraphViewを使えばShaderGraphのようなヴィジュアルツールを作成できます。
[参照:GraphView完全理解した(2019年末版)]さて、本記事の目標はGraphViewのようなのツールを作ることです。
いやGraphView使えばいいじゃん、と思った方は鋭いです。実用に耐えるものを作るなら、使った方がよいと思います。
さらに、本記事はUnityが公開しているGraphViewの実装を大いに参考にしているので、GraphViewを使うならすべて無視できる内容です。とはいえ、内部でどんなことをすると上記画像のようなエディタ拡張ができるのか、気になる方も多いのではと思います。
その理解の一助となればと思います。注)この記事は手順を細かく解説したり、あえて不具合のある例を付けたりしているので、冗長な部分が多々あります。
実装
公式ドキュメントを見ながら実装していきます。
https://docs.unity3d.com/2019.1/Documentation/Manual/UIElements.html
https://docs.unity3d.com/2019.3/Documentation/ScriptReference/UIElements.VisualElement.html0. 挨拶
エディタ拡張用のスクリプトは必ず Assets/[どこでもいい]/Editor というディレクトリの下に置きます。ビルド時にビルド対象から外すためです。
というわけで、Assets/Scripts/Editor にC#ファイルを作って GraphEditor と名付けます。Graphとは、頂点と辺からなるデータ構造を示す用語で、ビヘイビアツリーや有限オートマトンもGraphの一種です。ビヘイビアツリーの場合はアクションやデコレータが頂点、それぞれがどのようにつながっているかが辺に対応します。ひとまずの目標は、このグラフを可視化できるようにすることです。
とはいえ、まだUIElementsと初めましてなので、まずは挨拶から始めます。
// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] // Unityのメニュー/Window/GraphEditorから呼び出せるように public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); // ウィンドウを作成。 graphEditor.Show(); // ウィンドウを表示 graphEditor.titleContent = new GUIContent("Graph Editor"); // Windowの名前の設定 } public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); } }Unity公式ブログのはじめの例を参考にしました。
ウィンドウが作成されたときに呼ばれるOnEnable()で、はじめてUIElementsと対面します。
見たところ、UIElementsはウィンドウの大元にあるrootVisualElementにどんどん要素を追加していく方式なんですね。
rootVisualElementはVisualElementクラスで、LabelもVisualElementクラスを継承しています。さあ、メニューからWindow/GraphEditorを選択すると以下のようなウィンドウが表示されます。
こんにちは!
ひとまず、挨拶は終わりました。1. ノードを表示する
Inspectorのように、行儀よく上から下へ情報を追加していくUIであれば、あとは色を変えてみたり、ボタンを追加してみたり、水平に並べてみたりすればいいのですが、ノードベースエディタを作ろうとしているのでそれだけでは不十分です。
四角形を自由自在に動かせなければいけません。ドキュメントには、UIElementの構造の説明として、このような図がありました。
[画像は引用:https://docs.unity3d.com/ja/2019.3/Manual/UIE-VisualTree.html]
まずは、このred containerのような四角形を出したいですね。というわけでいろいろ試してみます。
1.1 表示場所を指定する
ドキュメントによるとVisualElementはそれぞれlayoutなるメンバを持っていて、layout.positionやlayout.transformによって親に対する位置が決まるようです。実際に試してみましょう。
// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); } public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); } }// NodeElement.cs using UnityEngine; using UnityEngine.UIElements; public class NodeElement : VisualElement { public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); transform.position = pos; Add(new Label(name)); } }先ほどと違うのは、NodeElementクラスですね。
NodeはGraphの頂点のことで、有限オートマトンでいうと状態に対応します。このNodeElementのコンストラクタに色と位置を渡して、内部でstyle.backgroundClorとtransform.positionを設定します。
それをrootにAddして、どのように表示されるかを見てみます。以下、結果です。
お!
表示位置が唐突な場所になっていますね。
右にずっと伸びていますが、まだ幅を指定していないからでしょう。もう一つ追加してみましょう。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); }
……あれ?
同じY座標を指定したのに二つのノードは重なっていません。本当は、
このようになって欲しかったのです。
ちなみに、この時点で上の図のようにするには以下を書きました。// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 32))); // y座標を変更 }どうやら18ピクセルだけ勝手に下にずらされていたようです。困ります。いい感じに自動レイアウトしてくれるという親切心だとは思うのですが、私が今作りたいのはヴィジュアルツールなので、上下左右に自在に動かしたいのです。
探すと別のドキュメントにありました。
Set the position property to absolute to place an element relative to its parent position rectangle. In this case, it does not affect the layout of its siblings or parent.
positionプロパティをabsoluteにすれば兄弟(=siblings)や親の影響を受けないよとあります。
positionプロパティってなんだと思いましたが、VisualStudioの予測変換機能を駆使して見つけました。NodeElementのコンストラクタを以下のように書き換えます
// NodeElementクラス public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; // 追加。これがposition propertyらしい transform.position = pos; Add(new Label(name)); }すると、
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); }ちゃんと同じ高さになりました!
なぜか横方向の帯も消えています。これで位置を自由に指定できるようになりました。
1.2 大きさを指定する
次は四角形の大きさを指定します。位置指定はラベルで実験したので勝手に大きさを合わせてくれていましたが、自由に幅や高さを指定したいです。
このような見た目に関する部分はだいたいVisualElement.style
にまとまっているようで、以下のように指定します。// NodeElementクラス public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50, style.width = 100, transform.position = pos; Add(new Label(name)); }初めに言った、red containerのようになったと思います。
2. ノードを動かす
次は表示した四角形をインタラクティブに移動させます。
ヴィジュアルツールでは、見やすいように位置を動かすことは大事です。挙動としては、
1. 四角形を左クリックして選択
2. そのままドラッグすると一緒に動く
3. ドロップで現在の位置に固定
というのを想定しています。2.1 まずは試してみる
これらはどれもマウスの挙動に対しての反応なので、マウスイベントに対するコールバックとして実装します。
探すと公式ドキュメントにThe Event Systemという項がありました。
いろいろと重要そうなことが書いてある気がしますが、今はとりあえずイベントを取りたいのでその中のResponding to Eventsを見てみます。
どうやら、VisualElement.RegisterCallback()
によってコールバックを登録できるみたいですね。マウスに関するイベントはそれぞれ、
1.MouseDownEvent
2.MouseMoveEvent
3.MouseUpEvent
でとることができそうです。NodeElementクラスを以下のように書き換えます。
// NodeElementクラス public NodeElement (string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = pos; Add(new Label(name)); bool focus = false; RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) // 左クリック { focus = true; // 選択 } }); RegisterCallback((MouseUpEvent evt) => { focus = false; // 選択を解除 }); RegisterCallback((MouseMoveEvent evt) => { if (focus) { transform.position += (Vector3)evt.mouseDelta; // マウスが動いた分だけノードを動かす } }); }すると、以下のような挙動になります。
動きましたが、少し使いにくそうな動きです。
まず、赤いノードが黄色のノードの下にあるせいで、赤を動かしている途中にカーソルが黄色の上に来ると、赤が動かなくなってしまいます。
さらにそのあと右クリックをやめても選択が解除されておらず、赤が勝手に動いてしまいます。これは、MouseUpEvent
が赤いノードに対して呼ばれていないことが問題のようです。改善策は、
1. 選択したノードは最前面に来てほしい
2. カーソルがノードの外に出たときにも、マウスイベントは呼ばれてほしい
の二つです。2.2 VisualElementの表示順を変える
ドキュメントのThe Visual Treeの項目に、Drawing orderの項があります。
Drawing order
The elements in the visual tree are drawn in the following order:
- parents are drawn before their children
- children are drawn according to their sibling listThe only way to change their drawing order is to reorder VisualElementobjects in their parents.
描画順を変えるには親オブジェクトが持つVisualElementを並び替えないといけないようです。
それ以上の情報がないのでVisualElementのスクリプトリファレンスを見てみます。その中のメソッドでそれらしいものがないかを探すと……ありました。
BringToFront()
というメソッドで、親の子供リストの中の最後尾へ自分を持っていくものです。
これをMouseDownEventのコールバックに追加します。// NodeElementクラス RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) { focus = true; BringToFront(); // 自分を最前面に持ってくる } });実行結果は以下です。
クリックしたものが最前面へきているのがわかります。
しかし、動画後半のように、マウスを勢いよく動かすとノードがついてこられないことがわかります。2.3 マウスイベントをキャプチャする
マウスを勢いよく動かしたとき、カーソルがノードの外に出るので
MouseLeaveEvent
が呼ばれるはずです。その時にPositionを更新してドラッグ中は常にノードがカーソルの下にあるようにすればよい、と初めは思っていました。
ですが、それだと勢いよく動かした直後にマウスクリックを解除した場合に、MouseUpEvent
が選択中のノードに対して呼ばれないようなのです。
イベントの呼ばれる順序にかかわる部分で、丁寧に対応してもバグの温床になりそうです。いい方法はないかなとドキュメントを読んでいると、よさそうなものを見つけました。
Dispatching Eventsの中のCapture the mouseという項です。VisualElementは
CaptureMouse()
を呼ぶことによって、カーソルが自身の上にないときでもマウスイベントを自分のみに送ってくれるようになるということで、まさにマウスをキャプチャしています。
キャプチャすると、マウスが自分の上にあるかどうかを気にしなくてよくなるので、安心して使えそうです。ということで、MouseDown時にキャプチャし、MouseUp時に解放するように書き換えてみます。
// NodeElementクラス RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) { focus = true; BringToFront(); CaptureMouse(); // マウスイベントをキャプチャ } }); RegisterCallback((MouseUpEvent evt) => { ReleaseMouse(); // キャプチャを解放 focus = false; }); RegisterCallback((MouseCaptureOutEvent evt) => { m_Focus = false; // キャプチャが外れたときはドラッグを終了する }
MouseCaptureOutEvent
は他のVisualElement
などによってキャプチャを奪われたときに呼ばれる関数です。実行結果は以下になります。
無事に意図した動きになりました。2.4 ノードを動かすコードをManipulatorによって分離する
この後もノードには様々な機能が追加される予定ですので、コードが煩雑にならないためにも、ノードを動かす部分を分離してしまいたいです。
どうしようか悩んでいましたが、UIElementsにはManipulatorという仕組みがあることを見つけました。
Manipulatorを使うことで、「ノードを動かす」のような操作を追加するコードをきれいに分離して書くことができます。NodeDraggerというクラスを作成します。
// NodeDragger.cs using UnityEngine; using UnityEngine.UIElements; public class NodeDragger : MouseManipulator { private bool m_Focus; public NodeDragger() { // 左クリックで有効化する activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse }); } /// Manipulatorにターゲットがセットされたときに呼ばれる protected override void RegisterCallbacksOnTarget() { m_Focus = false; target.RegisterCallback<MouseDownEvent>(OnMouseDown); target.RegisterCallback<MouseUpEvent>(OnMouseUp); target.RegisterCallback<MouseMoveEvent>(OnMouseMove); target.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut); } /// Manipulatorのターゲットが変わる直前に呼ばれる protected override void UnregisterCallbacksFromTarget() { target.UnregisterCallback<MouseDownEvent>(OnMouseDown); target.UnregisterCallback<MouseUpEvent>(OnMouseUp); target.UnregisterCallback<MouseMoveEvent>(OnMouseMove); target.UnregisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut); } protected void OnMouseDown(MouseDownEvent evt) { // 設定した有効化条件をみたすか (= 左クリックか) if (CanStartManipulation(evt)) { m_Focus = true; target.BringToFront(); target.CaptureMouse(); } } protected void OnMouseUp(MouseUpEvent evt) { // CanStartManipulation()で条件を満たしたActivationのボタン条件と、 // このイベントを発火させているボタンが同じか // (= 左クリックを離したときか) if (CanStopManipulation(evt)) { target.ReleaseMouse(); m_Focus = false; } } protected void OnMouseCaptureOut(MouseCaptureOutEvent evt) { m_Focus = false; } protected void OnMouseMove(MouseMoveEvent evt) { if (m_Focus) { target.transform.position += (Vector3)evt.mouseDelta; } } }
RegisterCallBacksOnTarget()
とUnregisterCallbacksFromTarget()
はManipulator
クラスの関数で、イベントのコールバックの登録・解除を担っています。
activators
やCanStartManipulation()
、CanStopManipulation()
はManipulator
クラスを継承するMouseManipulator
クラスの関数で、マウスのボタンの管理がしやすくなっています。
細かいことはコード中のコメントに記載しました。このManipulatorを使用するには、対象のVisualElementを設定しなければいけません。
// NodeElementクラス public NodeElement (string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = pos; Add(new Label(name)); AddManipulator(new NodeDragger()); // 操作の追加が一行で済む }
AddManipulator
という関数によって対象のVisualElementを設定しています。
実はこのコードは以下のようにもかけます。new NodeDragger(){target = container};内部の実装を見ると、
AddManipulator
ではIManipulator.target
プロパティに自身をセットしているだけでした。
そしてsetter内で、セットする前に既存のtargetがあればUnregisterCallbacksFromTarget()
を呼び、そのあと新規のターゲットをセットしてからRegisterCallbacksOnTarget()
を呼びます。[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/Manipulators.cs]
[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/MouseManipulator.cs]3. ノードを追加する
これまではテストのためにノードの数は2つで固定されていましたが、自在に追加できなければグラフエディタとはとても呼べません。
想定している挙動は、
- 右クリックでメニューが出てくる
- 「Add Node」を選択する
- 右クリックした場所にノードが生成される
です。
......実は前章の最後あたりで、このままのペースで書いていると時間がいくらあっても足りないと思い、先に実装してから記事を書くことにしました。
ですので、これからの説明は少しスムーズに(悪く言えば飛躍気味に)なるかもしれません。ご了承ください。3.1 メニューを表示する
2.4節で見たようなManipulatorと同じように、この挙動も操作としてまとめることができそうです。
というか、こんなみんなが欲しそうな機能が公式に用意されていないはずがありません。
案の定、存在しました。例によって公式ドキュメントです。コードを載せます。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); } void OnContextMenuPopulate(ContextualMenuPopulateEvent evt) { // 項目を追加 evt.menu.AppendAction( "Add Node", // 項目名 AddEdgeMenuAction, // 選択時の挙動 DropdownMenuAction.AlwaysEnabled // 選択可能かどうか ); } void AddEdgeMenuAction(DropdownMenuAction menuAction) { Debug.Log("Add Node"); } }期待していた挙動は、「背景を左クリックしたときはメニューが開いて、ノードを左クリックしたときは何も起こらない」です。でも、これでは逆ですね。
イベント発行についてのドキュメントを見てみます。
[図は引用:https://docs.unity3d.com/2019.1/Documentation/Manual/UIE-Events-Dispatching.html]イベントは root -> target -> root と呼ばれるみたいですね。イベント受け取りについてのドキュメントには、デフォルトではTargetフェイズとBubbleUpフェイズにイベントが登録されるともあります。
とにかく、思い当たるのは、ルートに登録したコールバックがノード経由で伝わっているということです。
いろいろ試してみてわかったのは、ルートではデフォルトでpickingMode
がPickingMode.Ignore
に設定されているということでした。リファレンスによると、マウスのクリック時にターゲットを決める際、その位置にある一番上のVisualElementを取ってきているらしいのですが、この
pickingMode
がPickingMode.Ignore
に設定されていた場合は候補から外す、という挙動になるようです。実際、このようにすると動きます。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); root.pickingMode = PickingMode.Position; // ピッキングモード変更 root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); }
でも、ルートをむやみに変更するのはよくないですね。
そこで、ルートの一つ下に一枚挟むことにします。今の作りでは、EditorWindowとVisualElementが不可分になってしまっていましたが、それを分離可能にするという意味合いもあります。さあ、いよいよもってGraphViewに近づいてきました。
分離自体はすぐにできます。// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); } GraphEditorElement m_GraphEditorElement; public void OnEnable() { VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(); root.Add(m_GraphEditorElement); } }// GraphEditorElement.cs using UnityEngine; using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditorElement: VisualElement { public GraphEditorElement() { style.flexGrow = 1; // サイズを画面いっぱいに広げる style.overflow = Overflow.Hidden; // ウィンドウの枠からはみ出ないようにする Add(new NodeElement("One", Color.red, new Vector2(100, 50))); Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); } void OnContextMenuPopulate(ContextualMenuPopulateEvent evt) { evt.menu.AppendAction( "Add Node", AddNodeMenuAction, DropdownMenuAction.AlwaysEnabled ); } void AddNodeMenuAction(DropdownMenuAction menuAction) { Debug.Log("Add Node"); } }二つのスタイルを適用しました。
style.flexGrow = 1;
によってGraphEditorElementのサイズが画面いっぱいに広がり、クリックイベントを拾う背景の役割を果たしてくれます。
style.overflow = Overflow.Hidden;
は親の領域からはみ出た部分を表示しないようにします。ノードを動かすとウィンドウの枠からはみ出したりしていましたが、これでもう心配はいりません。挙動はこのようになります。
まだノードの上で右クリックしたときもAdd Nodeメニューが出てしまいます。
これはノードに対しても何かを設定する必要がありそうですね。後でノードを左クリックしたときにエッジを追加する挙動を実装します。そのとき考えましょう。
とにかくメニューは出たということで、次へ進んでいきます。
3.2 ノードを生成する
Add Nodeというログを出していた部分を少し変更すると、新しいノードが生成できます。
// GraphEditorElementクラス void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; // マウス位置はeventInfoの中にあります Add(new NodeElement("add", Color.green, mousePosition)); }挙動です。
これで、表示の上では新しいノードを生成できました。4. ノードを永続化する
3章で生成したノードはGraphEditorウィンドウを開きなおしたりすると消えてしまいます。
Unityで何らかのデータを保存しておくには、どこかのファイルにシリアライズしておく必要があります。「シリアライズとはXXXである」と一言で言えたらいいのですが、短く上手く説明できる気がしません。
脱線になってしまってもよくないので、気になる方は「Unity シリアライズ」などで検索してみてください。4.1 グラフ構造とは
方針としては、グラフを再現するのに最低限必要なものを用意します。
冒頭でも少し触れましたが、ここでグラフの定義を明確にしておきます。グラフには大きく分けて二種類あります。
無向グラフと有向グラフです。
[図は引用:https://qiita.com/drken/items/4a7869c5e304883f539b]エッジ、つまり辺に向きがあるかないかの差があります。
ゲームで使う場合、ロジックを表現するためのグラフはほとんどが有向グラフなのではと思います。ビヘイビアツリー: ノードはアクション、エッジは遷移
有限オートマトン: ノードは状態、エッジは遷移ということで、作成中のグラフエディタも、有向グラフを表せるものを作りたいと思います。
余談ですが、無向グラフは有効グラフの矢印と逆向きに同じ矢印を付けると実現することができます。4.2 シリアライズ用のクラスを作る
構造としては、
グラフアセット:ノードリストを持つ
ノード:エッジリストを持つ
エッジ:つながるノードを持つとして、グラフアセットをアセットとして新規作成できるようにしようと思います。
実装は以下のようにします。// GraphAsset.cs using System.Collections.Generic; using UnityEngine; [CreateAssetMenu(fileName ="graph.asset", menuName ="Graph Asset")] public class GraphAsset : ScriptableObject { public List<SerializableNode> nodes = new List<SerializableNode>(); } [System.Serializable] public class SerializableNode { public Vector2 position; public List<SerializableEdge> edges = new List<SerializableEdge>(); } [System.Serializable] public class SerializableEdge { public SerializableNode toNode; }
ScriptableObject
に[CreateAssetMenu]
を付けることで、Unityのプロジェクトなどで右クリックをしたときにメニューから生成できるようになります。
また、[System.Serializable]
アトリビュートによって、指定したクラスをシリアライズ可能にしています。早速、グラフアセットを作ってみました。
すると、このようなエラーが出ます。Serialization depth limit 7 exceeded at 'SerializableEdge.toNode'. There may be an object composition cycle in one or more of your serialized classes. Serialization hierarchy: 8: SerializableEdge.toNode 7: SerializableNode.edges 6: SerializableEdge.toNode 5: SerializableNode.edges 4: SerializableEdge.toNode 3: SerializableNode.edges 2: SerializableEdge.toNode 1: SerializableNode.edges 0: GraphAsset.nodes UnityEditor.InspectorWindow:RedrawFromNative()そう、ノードがシリアライズしようとするエッジが、さらにノードをシリアライズしようとして、循環が発生しているのです。
シリアライズの仕組みとして、クラスの参照をそのまま保存することはできません。では、どうするかというと、ノードのIDを保存しておくことにします。
UnityのGUIDみたいに大きなIDを振ってもいいのですが、振るのとか対応付けとかが面倒そうです。
そこで、ここではGraphAssetが持っているノードリストの何番目にあるか、というのをIDとしようと思います。SerializableEdgeだけ以下のように直します。
// GraphAsset.cs [System.Serializable] public class SerializableEdge { public int toId; }これでワーニングは出なくなります。
4.3 アセットとエディタを対応付ける
どのアセットを表示・編集するかを決めるために、エディタにアセットの情報を持たせなければいけません。
実際にエディタを使うときのことを考えると、アセットからエディタが開けて、その際にそのアセットについて編集するようにできたらいいですね。というわけで要件としては、
1. GraphAssetをダブルクリックするとエディタが開く
2. どこかのGraphEditorElementクラスにGraphAssetクラスを渡す
です。// GraphAsset.cs using UnityEngine; using UnityEditor; using UnityEditor.Callbacks; // OnOpenAssetアトリビュートのために追加 using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); if(Selection.activeObject is GraphAsset graphAsset) { graphEditor.Initialize(graphAsset); } } [OnOpenAsset()] // Unityで何らかのアセットを開いたときに呼ばれるコールバック static bool OnOpenAsset(int instanceId, int line) { if(EditorUtility.InstanceIDToObject(instanceId) is GraphAsset) // 開いたアセットがGraphAssetかどうか { ShowWindow(); return true; } return false; } GraphAsset m_GraphAsset; // メンバ変数として持っておく GraphEditorElement m_GraphEditorElement; public void OnEnable() { // ShowWindow()を通らないような時(スクリプトのコンパイル後など) // のために初期化への導線を付ける if (m_GraphAsset != null) { // 初期化はInitializeに任せる Initialize(m_GraphAsset); } } // 初期化 public void Initialize(GraphAsset graphAsset) { m_GraphAsset = graphAsset; // 以下はもともとOnEnable() で行っていた処理 // OnEnable() はCreateInstance<GraphEditor>() の際に呼ばれるので、まだgraphAssetが渡されていない // 初期化でもgraphAssetを使うことになるのでここに移す VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(); root.Add(m_GraphEditorElement); } }これで、GraphAssetファイルをダブルクリックしたときにエディタが開くようになります。
4.4 アセットのデータからノードを表示するようにする
続いて、アセットにある情報からノードを構築、表示したいと思います。
まずはGraphAssetにダミーの情報を手打ちします。
(100, 50)と(200, 50)の位置、つまり今まで表示してきた赤と黄色の位置、にノードが表示されればOKです。まず、NodeElementを少し変えます。
色の情報はアセットにはないので省きますし、位置はシリアライズされますからね。具体的には、生成をSerializableNodeから行うようにします。
// NodeElement.cs // BackgroundColorがなくなると見えなくなるので、周囲を枠線で囲んだVisualElement、Boxを継承する public class NodeElement : Box { public SerializableNode serializableNode; public NodeElement (SerializableNode node) // 引数を変更 { serializableNode = node; // シリアライズ対象を保存しておく style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = node.position; // シリアライズされている位置を取る this.AddManipulator(new NodeDragger()); } }GraphEditorElementも伴って変更します。
// GraphEditorElement.cs using UnityEngine; using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditorElement: VisualElement { GraphAsset m_GraphAsset; // 渡されたアセットを保存 List<NodeElement> m_Nodes; // 作ったノードを入れておく。順序が重要 public GraphEditorElement(GraphAsset graphAsset) { m_GraphAsset = graphAsset; style.flexGrow = 1; style.overflow = Overflow.Hidden; this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); m_Nodes = new List<NodeElement>(); // 順番にノードを生成。この作る際の順番がSerializableEdgeが持つNodeのIDとなる foreach(var node in graphAsset.nodes) { CreateNodeElement(node); } } void CreateNodeElement(SerializableNode node) { var nodeElement = new NodeElement(node); Add(nodeElement); // GraphEditorElementの子として追加 m_Nodes.Add(nodeElement); // 順番を保持するためのリストに追加 } /* ... 省略 */ void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; CreateNodeElement(new SerializableNode() { position = mousePosition }); // 追加生成時には仮で新しく作る } }GraphEditorElementのコンストラクタにGraphAssetを渡すようにしたので、GraphEditorから生成するときに必要です
// GraphEditorクラス public void Initialize(GraphAsset graphAsset) { m_GraphAsset = graphAsset; VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(graphAsset); // アセットを渡す root.Add(m_GraphEditorElement); }以上で、アセットに保持された情報を描画することができました。
書き込みはしていないので、当然開きなおすと追加したノードは消えてしまいます。4.5 追加作成したノードをアセットに書き込む
前節までできたら、あとはもう少し変えるだけです。
// GraphEditorElementクラス private void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; var node = new SerializableNode() { position = mousePosition }; m_GraphAsset.nodes.Add(node); // アセットに追加する CreateNodeElement(node); }これでアセットに書き込まれます。
おっと、動かしたことを記録するのを忘れていました。// NodeDraggerクラス protected void OnMouseUp(MouseUpEvent evt) { if (CanStopManipulation(evt)) { target.ReleaseMouse(); if(target is NodeElement node) { //NodeElementに保存しておいたシリアライズ対象のポジションをいじる node.serializableNode.position = target.transform.position; } m_Focus = false; } }動かしてドラッグをやめた瞬間に記録するとよいと思います。
これで動かしたことも保存されるようになりました。
5. エッジを追加する
頂点の表示ができたので、次は辺です。辺は頂点同士を線で結ぶことで表します。
コンテナ的な仕組みでは直線や曲線は引けないように思うので、ここは既存の仕組みで線を引きます。
Handles.Draw
系の関数が一番楽かなと思います。
DrawLine
やDrawBezier
などです。ちなみにGraphViewでは、エッジ用のメッシュを作って、
Graphics.DrawMeshNow()
で描画をしていました。5.1 エッジを表示する
とりあえずダミーでデータを作ってみます。
Element0のEgesに要素を追加しました。
このまま表示するとこうなります。
イメージとしては、左上のノードから右下のノードへ繋がっている矢印があればいいなと思います。VisualElementは初期化字に一度呼べば後は自動で描画してくれていましたが、
Handles
で描画をするならウィンドウ更新のたびに呼ぶ必要があります。
EditorWindowの更新といえば、OnGUI
です。
ウィンドウの更新のたびにOnGUI
が呼ばれますので、そこからGraphEditorElementの描画関数を呼ぶことにします。ひとまずこのように実装してみます。
// GraphEditorクラス private void OnGUI() { if(m_GraphEditorElement == null) { return; } m_GraphEditorElement.DrawEdge(); }// GraphEditorElementクラス public void DrawEdge() { for(var i = 0; i < m_GraphAsset.nodes.Count; i++) { var node = m_GraphAsset.nodes[i]; foreach(var edge in node.edges) { DrawEdge( startPos: m_Nodes[i].transform.position, startNorm: new Vector2(0f, 1f), endPos: m_Nodes[edge.toId].transform.position, endNorm: new Vector2(0f, -1f)); } } } private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm) { Handles.color = Color.blue; // 色指定 // エッジをベジェ曲線で描画 Handles.DrawBezier( startPos, endPos, startPos + 50f * startNorm, endPos + 50f * endNorm, color: Color.blue, texture: null, width: 2f); // 矢印の三角形の描画 Vector2 arrowAxis = 10f * endNorm; Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward); Handles.DrawAAConvexPolygon(endPos, endPos + arrowAxis + arrowNorm, endPos + arrowAxis - arrowNorm); Handles.color = Color.white; // 色指定をデフォルトに戻す }ポジションとして単に
VisualElement.transform.position
を利用しているので左上隅に始点・終点が来ています。
元ノードは下辺中央から、先ノードの上辺中央につながってほしい気がします。
とはいえ、GraphEditorElementでNodeの形に関する部分を決め打ちで呼んでしまうのはちょっと気持ち悪いので、NodeElementに始点や終点の位置・方向の情報を返す関数を作ろうと思います。// GraphEditorElementクラス public void DrawEdge() { for(var i = 0; i < m_GraphAsset.nodes.Count; i++) { var node = m_GraphAsset.nodes[i]; foreach(var edge in node.edges) { // ノードに情報を問い合わせる DrawEdge( startPos: m_Nodes[i].GetStartPosition(), startNorm: m_Nodes[i].GetStartNorm(), endPos: m_Nodes[edge.toId].GetEndPosition(), endNorm: m_Nodes[edge.toId].GetEndNorm()); } } }// NodeElementクラス public Vector2 GetStartPosition() { return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, style.height.value.value); } public Vector2 GetEndPosition() { return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, 0f); } public Vector2 GetStartNorm() { return new Vector2(0f, 1f); } public Vector2 GetEndNorm() { return new Vector2(0f, -1f); }また、エッジを描画するだけなら、エッジのVisualElementを作らずにGraphAssetに保存されているSerializableEdgeの値を見ていればよいのですが、エッジの追加・削除・付け替えなど、いずれ必要になるであろう操作がやりにくくなります。
そこで、エッジにもEdgeElementクラスを作ります。
// EdgeElement.cs using UnityEngine; using UnityEngine.UIElements; using UnityEditor; public class EdgeElement : VisualElement { public SerializableEdge serializableEdge; // データを持っておく public NodeElement From { get; private set; } // 元ノード public NodeElement To { get; private set; } // 先ノード public EdgeElement(SerializableEdge edge, NodeElement from, NodeElement to ) { serializableEdge = edge; From = from; To = to; } public void DrawEdge() { if(From != null && To != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: To.GetEndPosition(), endNorm: To.GetEndNorm()); } } // GraphEditorElementからそのまま移した private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm) { Handles.color = Color.blue; Handles.DrawBezier( startPos, endPos, startPos + 50f * startNorm, endPos + 50f * endNorm, color: Color.blue, texture: null, width: 2f); Vector2 arrowAxis = 10f * endNorm; Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward); Handles.DrawAAConvexPolygon(endPos, endPos + arrowAxis + arrowNorm, endPos + arrowAxis - arrowNorm); Handles.color = Color.white; } }このクラスもノードと同様に、GraphEditorElementが生成し、GraphEditorElementの子として保持することにします。
ノードが持っていて、ノードの子として生成というのも考えましたが、GraphEditorで一元管理した方が構造が単純になりそうだと思ったのが理由です。実装はこうです。
// GraphEditorElementクラス List<EdgeElement> m_Edges; // エッジもノードと同じくまとめて保持しておく public GraphEditorElement(GraphAsset graphAsset) { m_GraphAsset = graphAsset; style.flexGrow = 1; style.overflow = Overflow.Hidden; this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); m_Nodes = new List<NodeElement>(); foreach(var node in graphAsset.nodes) { CreateNodeElement(node); } // すべてのノードの生成が終わってからエッジの生成を行う // エッジが持っているノードIDからノードを取得するため m_Edges = new List<EdgeElement>(); foreach(var node in m_Nodes) { foreach(var edge in node.serializableNode.edges) { CreateEdgeElement(edge, node, m_Nodes); } } } // エッジの生成 public EdgeElement CreateEdgeElement(SerializableEdge edge, NodeElement fromNode, List<NodeElement> nodeElements) { var edgeElement = new EdgeElement(edge, fromNode, nodeElements[edge.toId]); Add(edgeElement); m_Edges.Add(edgeElement); return edgeElement; } // GraphEditor.OnGUI() 内で呼ばれる。描画処理をエッジに移したので小さくなった public void DrawEdge() { foreach(var edge in m_Edges) { edge.DrawEdge(); } }見た目は先ほどと変わりません。
5.2 エッジを追加できるようにする
あるノードからあるノードにエッジをつけようと思う時、元ノードから先ノードへ線を伸ばしていくようなイメージになると思います。
UnityのGraphViewやUnrealEngineのBluePrintではノードに備わった接続用のポートをクリックしてそのままドラッグすると線が引かれていきます。
UnrealEngineのBehaviourTreeでは、ノードの上下にエッジ接続領域があります。
これらのようなポートや接続領域などはあると便利そうですが、いったんメニューにAdd Edgeを追加するので良いでしょう。
重要なのは、追加中に元ノードからエッジがマウスの位置を追従していることです。
このUIによって、現在エッジ追加操作中であることと、つなげるノードを指定する方法が直感的にわかります。
これは実装したいです。挙動としては、
1. ノードを右クリックする
2. メニューから「Add Edge」を選択する
3. 元ノードからマウスの位置に向かうエッジ候補ができる
4. 他のノードを左クリックして、エッジの向かい先を確定するを想定します。
ノードに対する操作なので、ノードにManipulator
を追加します。
エッジをつなぐ操作なので、EdgeConnectorクラスとします。5.2.1 EdgeConnectorクラスを作る
EdgeConnectorの役割はメニューを出してエッジ追加モードに入ることと、そのあとに別のノードをクリックして実際にノードを接続することの二つあります。
その中でメニューを出す部分はContexturalMenuManipulator
の役割ですので、EdgeConnectorクラスの中でContexturalMenuManipulator
を作成し、それをEdgeConnectorのターゲットノードにAddManipulator
しようと思います。こうすることで、NodeElementにEdgeConnectorを追加するだけで、エッジ追加の処理をすべてEdgeConnectorクラスに投げることができます。
// NodeElementクラス public NodeElement (SerializableNode node) { /* ... 省略 */ this.AddManipulator(new NodeDragger()); this.AddManipulator(new EdgeConnector()); // 追加 }そして、EdgeConnectorの内部はひとまずこのようにしておきます。
using UnityEngine; using UnityEngine.UIElements; public class EdgeConnector : MouseManipulator { bool m_Active = false; ContextualMenuManipulator m_AddEdgeMenu; public EdgeConnector() { // ノードの接続は左クリックで行う activators.Add(new ManipulatorActivationFilter() { button = MouseButton.LeftMouse }); m_Active = false; // メニュー選択マニピュレータは作っておくが、この時点ではターゲットが確定していないので、 // RegisterCallbacksOnTarget()で追加する m_AddEdgeMenu = new ContextualMenuManipulator(OnContextualMenuPopulate); } private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement node) { // エッジ追加中に右クリックを押されたときのために、ノードの上かどうかを見る if (!node.ContainsPoint(node.WorldToLocal(evt.mousePosition))) { // イベントを即座に中断 evt.StopImmediatePropagation(); return; } evt.menu.AppendAction( "Add Edge", (DropdownMenuAction menuItem) => { m_Active = true; Debug.Log("Add Edge"); // ここでエッジ追加モード開始処理を書く target.CaptureMouse(); }, DropdownMenuAction.AlwaysEnabled); } } protected override void RegisterCallbacksOnTarget() { target.RegisterCallback<MouseDownEvent>(OnMouseDown); target.RegisterCallback<MouseUpEvent>(OnMouseUp); target.RegisterCallback<MouseMoveEvent>(OnMouseMove); target.RegisterCallback<MouseCaptureOutEvent>(OnCaptureOut); target.AddManipulator(m_AddEdgeMenu); } protected override void UnregisterCallbacksFromTarget() { target.RemoveManipulator(m_AddEdgeMenu); target.UnregisterCallback<MouseDownEvent>(OnMouseDown); target.UnregisterCallback<MouseUpEvent>(OnMouseUp); target.UnregisterCallback<MouseMoveEvent>(OnMouseMove); target.UnregisterCallback<MouseCaptureOutEvent>(OnCaptureOut); } protected void OnMouseDown(MouseDownEvent evt) { if (!CanStartManipulation(evt)) return; // マウス押下では他のイベントが起きてほしくないのでPropagationを中断する if (m_Active) evt.StopImmediatePropagation(); } protected void OnMouseUp(MouseUpEvent evt) { if (!CanStopManipulation(evt)) return; if (!m_Active) return; Debug.Log("Try Connect"); // ここでマウスの下にあるノードにエッジを接続しようとする m_Active = false; target.ReleaseMouse(); } protected void OnMouseMove(MouseMoveEvent evt) { if (!m_Active) return; Debug.Log("move"); // ここで、追加中のエッジの再描画を行う } private void OnCaptureOut(MouseCaptureOutEvent evt) { if (!m_Active) return; m_Active = false; target.ReleaseMouse(); } }5.2.2 エッジ追加のためにエッジ・グラフクラスを整備
次に、EdgeElementクラスに追加中のEdgeを作成するための準備をします。
これまではEdgeには元ノードと先ノードを渡して作成していましたが、追加中には先ノード確定していないので、元ノードと矢印の位置からエッジを描画できるようにします。// EdgeElementクラス Vector2 m_ToPosition; public Vector2 ToPosition { get { return m_ToPosition; } set { m_ToPosition = this.WorldToLocal(value); // ワールド座標で渡されることを想定 MarkDirtyRepaint(); // 再描画をリクエスト } } // 新しいコンストラクタ public EdgeElement(NodeElement fromNode, Vector2 toPosition) { From = fromNode; ToPosition = toPosition; } // つなげるときに呼ぶ public void ConnectTo(NodeElement node) { To = node; MarkDirtyRepaint(); // 再描画をリクエスト } public void DrawEdge() { if (From != null && To != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: To.GetEndPosition(), endNorm: To.GetEndNorm()); } else { // 追加中の描画用 if (From != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: ToPosition, endNorm: Vector2.zero); } } }これにより、追加中のEdgeElementをGraphEditorElementのEdgesに追加すれば自動的に描画されるようになったはずです。
ということで、GraphEditorElementにエッジ追加リクエストを投げられるようにします。
ついでに、ノード追加を中断したときのためにエッジ削除関数も作っておきます。// GraphEditorElementクラス public EdgeElement CreateEdgeElement(NodeElement fromNode, Vector2 toPosition) { var edgeElement = new EdgeElement(fromNode, toPosition); Add(edgeElement); m_Edges.Add(edgeElement); return edgeElement; } public void RemoveEdgeElement(EdgeElement edge) { Remove(edge); m_Edges.Remove(edge); }5.2.3 エッジ追加の挙動を実装
上で作った関数をEdgeConnectorクラスから呼びます。
// EdgeConnectorクラス GraphEditorElement m_Graph; EdgeElement m_ConnectingEdge; private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement node) { evt.menu.AppendAction( "Add Edge", (DropdownMenuAction menuItem) => { m_Active = true; // 親をたどってGraphEditorElementを取得する m_Graph = target.GetFirstAncestorOfType<GraphEditorElement>(); m_ConnectingEdge = m_Graph.CreateEdgeElement(node, menuItem.eventInfo.mousePosition); target.CaptureMouse(); }, DropdownMenuAction.AlwaysEnabled); } } /* ... 省略 */ protected void OnMouseUp(MouseUpEvent evt) { if (!CanStopManipulation(evt)) return; if (!m_Active) return; var node = m_Graph.GetDesignatedNode(evt.originalMousePosition); if (node == null // 背景をクリックしたとき || node == target // 自分自身をクリックしたとき || m_Graph.ContainsEdge(m_ConnectingEdge.From, node)) // すでにつながっているノード同士をつなげようとしたとき { m_Graph.RemoveEdgeElement(m_ConnectingEdge); } else { m_ConnectingEdge.ConnectTo(node); } m_Active = false; m_ConnectingEdge = null; // 接続終了 target.ReleaseMouse(); } protected void OnMouseMove(MouseMoveEvent evt) { if (!m_Active) { return; } m_ConnectingEdge.ToPosition = evt.originalMousePosition; // 位置更新 } private void OnCaptureOut(MouseCaptureOutEvent evt) { if (!m_Active) return; // 中断時の処理 m_Graph.RemoveEdgeElement(m_ConnectingEdge); m_ConnectingEdge = null; m_Active = false; target.ReleaseMouse(); }// GraphEditorElementクラス // マウスの位置にあるノードを返す public NodeElement GetDesignatedNode(Vector2 position) { foreach(NodeElement node in m_Nodes) { if (node.ContainsPoint(node.WorldToLocal(position))) return node; } return null; } // すでに同じエッジがあるかどうか public bool ContainsEdge(NodeElement from, NodeElement to) { return m_Edges.Exists(edge => { return edge.From == from && edge.To == to; }); }5.2.4 追加したエッジをシリアライズする
今のままではEdgeElementを追加しただけなので、つないだエッジはデータとして残っていません。
ノードのときと同じようにシリアライズする必要があります。// EdgeConnectorクラス protected void OnMouseUp(MouseUpEvent evt) { /* ... 省略 */ var node = m_Graph.GetDesignatedNode(evt.originalMousePosition); if (node == null || node == target || m_Graph.ContainsEdge(m_ConnectingEdge.From, node)) { m_Graph.RemoveEdgeElement(m_ConnectingEdge); } else { m_ConnectingEdge.ConnectTo(node); m_Graph.SerializeEdge(m_ConnectingEdge); // つないだ時にシリアライズする } /* ... 省略 */ }// GraphEditorElementクラス public void SerializeEdge(EdgeElement edge) { var serializableEdge = new SerializableEdge() { toId = m_Nodes.IndexOf(edge.To) // ここで先ノードのIDを数える }; edge.From.serializableNode.edges.Add(serializableEdge); // 実際に追加 edge.serializableEdge = serializableEdge; // EdgeElementに登録しておく }保存されています。
5.3 エッジを削除できるようにする
エッジの追加ができるようになったので、やはり削除もできなければいけません。
ノードを削除するときと同様に、エッジの削除もコンテキストメニューから行いたいと思います。
しかし、このとき問題があります。
ノードは大きさのあるVisualElement
だったため、ContextualManipulator
を付けるとそのままクリックで選択ができました。
しかし、エッジのVisualElement
は大きさがありません。5.3.1 エッジを選択できるようにする
VisualElement
をクリックして選択するときの挙動について、ドキュメントに記載がありました。
Event targetのPicking mode and custom shapesの項です。You can override the
VisualElement.ContainsPoint()
method to perform custom intersection logic.この
VisualElement.ContainsPoint()
は、マウス座標を与えると、その座標と自分が衝突しているかを判定する関数です。
それをオーバーライドして、独自の衝突判定を埋め込むことで、VisualElement
のRect
以外の形に対応させることができます。実際にベジェ曲線と点との距離を計算するのは面倒なので、近似した線分との距離を計算して、指定距離以内だったら選択したことにしようと思います。
さて、衝突を判定の実装に当たって、ログを出すものが必要です
というわけで最初に、エッジに削除用のコンテキストメニューを作ります。// EdgeElementクラス // 削除用マニピュレータの追加 public EdgeElement() { this.AddManipulator(new ContextualMenuManipulator(evt => { if (evt.target is EdgeElement) { evt.menu.AppendAction( "Remove Edge", (DropdownMenuAction menuItem) => { Debug.Log("Remove Edge"); }, DropdownMenuAction.AlwaysEnabled); } })); } public EdgeElement(NodeElement fromNode, Vector2 toPosition):this() // 上のコンストラクタを呼ぶ { From = fromNode; ToPosition = toPosition; } public EdgeElement(SerializableEdge edge, NodeElement fromNode, NodeElement toNode):this() // 上のコンストラクタを呼ぶ { serializableEdge = edge; From = fromNode; To = toNode; }まず、接続元と接続先が収まるバウンディングボックスと衝突しているかどうかを判定してみます。
// EdgeElementクラス public override bool ContainsPoint(Vector2 localPoint) { if (From == null || To == null) return false; Vector2 start = From.GetStartPosition(); Vector2 end = To.GetEndPosition(); // ノードを覆うRectを作成 Vector2 rectPos = new Vector2(Mathf.Min(start.x, end.x), Mathf.Min(start.y, end.y)); Vector2 rectSize = new Vector2(Mathf.Abs(start.x - end.x), Mathf.Abs(start.y - end.y)); Rect bound = new Rect(rectPos, rectSize); if (!bound.Contains(localPoint)) { return false; } return true; }結果はこうなりました。
確かに、エッジのバウンディングボックスとの当たりを判定できていそうです。次に、近似線分との距離を計算してみます。
先にバウンディングボックスに入っていないものを弾いているので、端点が一番近い場合などを考えなくて済みます。
つまり、線分ではなく直線と点の距離を考えればよいということです。// EdgeElementクラス readonly float INTERCEPT_WIDHT = 15f; // エッジと当たる距離 public override bool ContainsPoint(Vector2 localPoint) { /* ... 省略 */ if (!bound.Contains(localPoint)) { return false; } // 近似線分ab Vector2 a = From.GetStartPosition() + 12f * From.GetStartNorm(); Vector2 b = To.GetEndPosition() + 12f * To.GetEndNorm(); // 一致した場合はaからの距離 if (a == b) { return Vector2.Distance(localPoint, a) < INTERCEPT_WIDHT; } // 直線abとlocalPointの距離 float distance = Mathf.Abs( (b.y - a.y) * localPoint.x - (b.x - a.x) * localPoint.y + b.x * a.y - b.y * a.x ) / Vector2.Distance(a, b); return distance < INTERCEPT_WIDHT; }結果はこうなりました。
...ちょっとずれている気もしますが、まあ、許容範囲でしょう。5.3.2 エッジデータを削除する
GraphAssetからエッジのデータを消します。
EdgeElementには元ノードの情報が既にありますので、そこから自分のデータが入っているSerializableNodeを取得することができます。
これを消せばよいですね。// EdgeElementクラス public EdgeElement() { this.AddManipulator(new ContextualMenuManipulator(evt => { if (evt.target is EdgeElement) { evt.menu.AppendAction( "Remove Edge", (DropdownMenuAction menuItem) => { // 親をたどってGraphEditorElementに削除リクエストを送る var graph = GetFirstAncestorOfType<GraphEditorElement>(); graph.RemoveEdgeElement(this); }, DropdownMenuAction.AlwaysEnabled); } })); }// GraphEditorElementクラス public void RemoveEdgeElement(EdgeElement edge) { // 消すエッジにSerializableEdgeがあれば、それを消す if(edge.serializableEdge != null) { edge.From.serializableNode.edges.Remove(edge.serializableEdge); } Remove(edge); m_Edges.Remove(edge); }無事、削除できています。
6. ノードを削除する
最後に、ノードを削除できるようにしたいと思います。
ノードを削除したときには、
- NodeElementを削除する
- 対応するSerializableNodeを削除する
- そのノードとつながるEdgeElementを削除する
- 対応するSerializableEdgeを削除する
- 他ノードのIDが変わるので、それに応じてSerializableEdgeのIDを振りなおすのすべてを行う必要があります。
// NodeElementクラス public NodeElement (SerializableNode node) { /* ... 省略 */ this.AddManipulator(new NodeDragger()); this.AddManipulator(new EdgeConnector()); this.AddManipulator(new ContextualMenuManipulator(OnContextualMenuPopulate)); // 削除用マニピュレータ } private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement) { evt.menu.AppendAction( "Remove Node", RemoveNodeMenuAction, DropdownMenuAction.AlwaysEnabled); } } private void RemoveNodeMenuAction(DropdownMenuAction menuAction) { // 親をたどって削除をリクエスト var graph = GetFirstAncestorOfType<GraphEditorElement>(); graph.RemoveNodeElement(this); }// GraphEditorElementクラス public void RemoveNodeElement(NodeElement node) { m_GraphAsset.nodes.Remove(node.serializableNode); // アセットから削除 int id = m_Nodes.IndexOf(node); // エッジの削除とID変更 // m_Edgesに変更が伴うため、降順で行う for (int i = m_Edges.Count - 1; i >= 0; i--) { var edgeElement = m_Edges[i]; var edge = edgeElement.serializableEdge; // 削除されるノードにつながるエッジを削除 if (edgeElement.To == node || edgeElement.From == node) { RemoveEdgeElement(edgeElement); continue; } // 変更が生じるIDを持つエッジに対して、IDに修正を加える if (edge.toId > id) edge.toId--; } Remove(node); // VisualElementの子としてのノードを削除 m_Nodes.Remove(node); // 順序を保持するためのリストから削除 }ウィンドウを開きなおしてもちゃんと構造が保存されています。
結果
ゼロからノードベースエディタを作りました。
現状ではグラフ構造を保存するアセットを作れるだけですが、このノード部分に何か情報を載せると立派なヴィジュアルツールが出来上がります。おわりに
UIElementの使い方を勉強したいと思ったので、ノードベースエディタを作ってみました。
ドキュメントとリファレンスを読み込むことになり、GraphViewの実装もかなり追ったので勉強になってよかったです。
実をいうと、このGraphEditorを使ってBehaviorTreeを作るところまでやりたかったのですが、エディタを作るだけで相当の時間がかかってしまったので、この記事はここまでにしておきます。また、ゼロから作るを銘打って、実装する手順通りに事細かく書いてしまったので、やたら長くなってしまいました。
とはいえ、エディタを作るにあたって得た知見をふんだんに盛り込めたのではないかと思います。ここはもっとこうした方がよい、のような意見があればコメントで教えていただけるとありがたいです。
ご拝読ありがとうございました。
- 投稿日:2019-12-21T18:45:25+09:00
【Unity】ゼロから作るノードベースエディター【UIElements】
概要
UnityのUIElementsによってこのようなノードベースエディタを作ります。
タイトルの「ゼロから」は、「UIElements」を知らないところから、という意味です。
そのため、UIElemtnsに関する前提知識は必要ありません。Unityのバージョンは2019.1.3f1です。
プロジェクトはGitHubに挙げておきます。
https://github.com/saragai/GraphEditor追記:バージョン2019.2.16f1でこのエディタを使用したところ、エッジの選択ができなくなっていました。
背景
Unity2019からUIElementsというUIツールが入りました。
現在はエディタ拡張にしか使えませんが、将来的にはゲーム内部のUIにも使えるようになるそうです。最近の機能でいえば、ShaderGraphのようなGUIツールもUIElementで作られています。
[画像は引用:https://unity.com/ja/shader-graph]これはGraphViewというノードベースエディタによって作られていて、GraphViewを使えばShaderGraphのようなヴィジュアルツールを作成できます。
[参照:GraphView完全理解した(2019年末版)]さて、本記事の目標はGraphViewのようなのツールを作ることです。
いやGraphView使えばいいじゃん、と思った方は鋭いです。実用に耐えるものを作るなら、使った方がよいと思います。
さらに、本記事はUnityが公開しているGraphViewの実装を大いに参考にしているので、GraphViewを使うならすべて無視できる内容です。とはいえ、内部でどんなことをすると上記画像のようなエディタ拡張ができるのか、気になる方も多いのではと思います。
その理解の一助となればと思います。注)この記事は手順を細かく解説したり、あえて不具合のある例を付けたりしているので、冗長な部分が多々あります。
実装
公式ドキュメントを見ながら実装していきます。
https://docs.unity3d.com/2019.1/Documentation/Manual/UIElements.html
https://docs.unity3d.com/2019.3/Documentation/ScriptReference/UIElements.VisualElement.html0. 挨拶
エディタ拡張用のスクリプトは必ず Assets/[どこでもいい]/Editor というディレクトリの下に置きます。ビルド時にビルド対象から外すためです。
というわけで、Assets/Scripts/Editor にC#ファイルを作って GraphEditor と名付けます。Graphとは、頂点と辺からなるデータ構造を示す用語で、ビヘイビアツリーや有限オートマトンもGraphの一種です。ビヘイビアツリーの場合はアクションやデコレータが頂点、それぞれがどのようにつながっているかが辺に対応します。ひとまずの目標は、このグラフを可視化できるようにすることです。
とはいえ、まだUIElementsと初めましてなので、まずは挨拶から始めます。
// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] // Unityのメニュー/Window/GraphEditorから呼び出せるように public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); // ウィンドウを作成。 graphEditor.Show(); // ウィンドウを表示 graphEditor.titleContent = new GUIContent("Graph Editor"); // Windowの名前の設定 } public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); } }Unity公式ブログのはじめの例を参考にしました。
ウィンドウが作成されたときに呼ばれるOnEnable()で、はじめてUIElementsと対面します。
見たところ、UIElementsはウィンドウの大元にあるrootVisualElementにどんどん要素を追加していく方式なんですね。
rootVisualElementはVisualElementクラスで、LabelもVisualElementクラスを継承しています。さあ、メニューからWindow/GraphEditorを選択すると以下のようなウィンドウが表示されます。
こんにちは!
ひとまず、挨拶は終わりました。1. ノードを表示する
Inspectorのように、行儀よく上から下へ情報を追加していくUIであれば、あとは色を変えてみたり、ボタンを追加してみたり、水平に並べてみたりすればいいのですが、ノードベースエディタを作ろうとしているのでそれだけでは不十分です。
四角形を自由自在に動かせなければいけません。ドキュメントには、UIElementの構造の説明として、このような図がありました。
[画像は引用:https://docs.unity3d.com/ja/2019.3/Manual/UIE-VisualTree.html]
まずは、このred containerのような四角形を出したいですね。というわけでいろいろ試してみます。
1.1 表示場所を指定する
ドキュメントによるとVisualElementはそれぞれlayoutなるメンバを持っていて、layout.positionやlayout.transformによって親に対する位置が決まるようです。実際に試してみましょう。
// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); } public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); } }// NodeElement.cs using UnityEngine; using UnityEngine.UIElements; public class NodeElement : VisualElement { public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); transform.position = pos; Add(new Label(name)); } }先ほどと違うのは、NodeElementクラスですね。
NodeはGraphの頂点のことで、有限オートマトンでいうと状態に対応します。このNodeElementのコンストラクタに色と位置を渡して、内部でstyle.backgroundClorとtransform.positionを設定します。
それをrootにAddして、どのように表示されるかを見てみます。以下、結果です。
お!
表示位置が唐突な場所になっていますね。
右にずっと伸びていますが、まだ幅を指定していないからでしょう。もう一つ追加してみましょう。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); }
……あれ?
同じY座標を指定したのに二つのノードは重なっていません。本当は、
このようになって欲しかったのです。
ちなみに、この時点で上の図のようにするには以下を書きました。// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 32))); // y座標を変更 }どうやら18ピクセルだけ勝手に下にずらされていたようです。困ります。いい感じに自動レイアウトしてくれるという親切心だとは思うのですが、私が今作りたいのはヴィジュアルツールなので、上下左右に自在に動かしたいのです。
探すと別のドキュメントにありました。
Set the position property to absolute to place an element relative to its parent position rectangle. In this case, it does not affect the layout of its siblings or parent.
positionプロパティをabsoluteにすれば兄弟(=siblings)や親の影響を受けないよとあります。
positionプロパティってなんだと思いましたが、VisualStudioの予測変換機能を駆使して見つけました。NodeElementのコンストラクタを以下のように書き換えます
// NodeElementクラス public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; // 追加。これがposition propertyらしい transform.position = pos; Add(new Label(name)); }すると、
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new Label("Hello, World!")); root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); }ちゃんと同じ高さになりました!
なぜか横方向の帯も消えています。これで位置を自由に指定できるようになりました。
1.2 大きさを指定する
次は四角形の大きさを指定します。位置指定はラベルで実験したので勝手に大きさを合わせてくれていましたが、自由に幅や高さを指定したいです。
このような見た目に関する部分はだいたいVisualElement.style
にまとまっているようで、以下のように指定します。// NodeElementクラス public NodeElement (Node node,string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50, style.width = 100, transform.position = pos; Add(new Label(name)); }初めに言った、red containerのようになったと思います。
2. ノードを動かす
次は表示した四角形をインタラクティブに移動させます。
ヴィジュアルツールでは、見やすいように位置を動かすことは大事です。挙動としては、
1. 四角形を左クリックして選択
2. そのままドラッグすると一緒に動く
3. ドロップで現在の位置に固定
というのを想定しています。2.1 まずは試してみる
これらはどれもマウスの挙動に対しての反応なので、マウスイベントに対するコールバックとして実装します。
探すと公式ドキュメントにThe Event Systemという項がありました。
いろいろと重要そうなことが書いてある気がしますが、今はとりあえずイベントを取りたいのでその中のResponding to Eventsを見てみます。
どうやら、VisualElement.RegisterCallback()
によってコールバックを登録できるみたいですね。マウスに関するイベントはそれぞれ、
1.MouseDownEvent
2.MouseMoveEvent
3.MouseUpEvent
でとることができそうです。NodeElementクラスを以下のように書き換えます。
// NodeElementクラス public NodeElement (string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = pos; Add(new Label(name)); bool focus = false; RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) // 左クリック { focus = true; // 選択 } }); RegisterCallback((MouseUpEvent evt) => { focus = false; // 選択を解除 }); RegisterCallback((MouseMoveEvent evt) => { if (focus) { transform.position += (Vector3)evt.mouseDelta; // マウスが動いた分だけノードを動かす } }); }すると、以下のような挙動になります。
動きましたが、少し使いにくそうな動きです。
まず、赤いノードが黄色のノードの下にあるせいで、赤を動かしている途中にカーソルが黄色の上に来ると、赤が動かなくなってしまいます。
さらにそのあと右クリックをやめても選択が解除されておらず、赤が勝手に動いてしまいます。これは、MouseUpEvent
が赤いノードに対して呼ばれていないことが問題のようです。改善策は、
1. 選択したノードは最前面に来てほしい
2. カーソルがノードの外に出たときにも、マウスイベントは呼ばれてほしい
の二つです。2.2 VisualElementの表示順を変える
ドキュメントのThe Visual Treeの項目に、Drawing orderの項があります。
Drawing order
The elements in the visual tree are drawn in the following order:
- parents are drawn before their children
- children are drawn according to their sibling listThe only way to change their drawing order is to reorder VisualElementobjects in their parents.
描画順を変えるには親オブジェクトが持つVisualElementを並び替えないといけないようです。
それ以上の情報がないのでVisualElementのスクリプトリファレンスを見てみます。その中のメソッドでそれらしいものがないかを探すと……ありました。
BringToFront()
というメソッドで、親の子供リストの中の最後尾へ自分を持っていくものです。
これをMouseDownEventのコールバックに追加します。// NodeElementクラス RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) { focus = true; BringToFront(); // 自分を最前面に持ってくる } });実行結果は以下です。
クリックしたものが最前面へきているのがわかります。
しかし、動画後半のように、マウスを勢いよく動かすとノードがついてこられないことがわかります。2.3 マウスイベントをキャプチャする
マウスを勢いよく動かしたとき、カーソルがノードの外に出るので
MouseLeaveEvent
が呼ばれるはずです。その時にPositionを更新してドラッグ中は常にノードがカーソルの下にあるようにすればよい、と初めは思っていました。
ですが、それだと勢いよく動かした直後にマウスクリックを解除した場合に、MouseUpEvent
が選択中のノードに対して呼ばれないようなのです。
イベントの呼ばれる順序にかかわる部分で、丁寧に対応してもバグの温床になりそうです。いい方法はないかなとドキュメントを読んでいると、よさそうなものを見つけました。
Dispatching Eventsの中のCapture the mouseという項です。VisualElementは
CaptureMouse()
を呼ぶことによって、カーソルが自身の上にないときでもマウスイベントを自分のみに送ってくれるようになるということで、まさにマウスをキャプチャしています。
キャプチャすると、マウスが自分の上にあるかどうかを気にしなくてよくなるので、安心して使えそうです。ということで、MouseDown時にキャプチャし、MouseUp時に解放するように書き換えてみます。
// NodeElementクラス RegisterCallback((MouseDownEvent evt) => { if (evt.button == 0) { focus = true; BringToFront(); CaptureMouse(); // マウスイベントをキャプチャ } }); RegisterCallback((MouseUpEvent evt) => { ReleaseMouse(); // キャプチャを解放 focus = false; }); RegisterCallback((MouseCaptureOutEvent evt) => { m_Focus = false; // キャプチャが外れたときはドラッグを終了する }
MouseCaptureOutEvent
は他のVisualElement
などによってキャプチャを奪われたときに呼ばれる関数です。実行結果は以下になります。
無事に意図した動きになりました。2.4 ノードを動かすコードをManipulatorによって分離する
この後もノードには様々な機能が追加される予定ですので、コードが煩雑にならないためにも、ノードを動かす部分を分離してしまいたいです。
どうしようか悩んでいましたが、UIElementsにはManipulatorという仕組みがあることを見つけました。
Manipulatorを使うことで、「ノードを動かす」のような操作を追加するコードをきれいに分離して書くことができます。NodeDraggerというクラスを作成します。
// NodeDragger.cs using UnityEngine; using UnityEngine.UIElements; public class NodeDragger : MouseManipulator { private bool m_Focus; public NodeDragger() { // 左クリックで有効化する activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse }); } /// Manipulatorにターゲットがセットされたときに呼ばれる protected override void RegisterCallbacksOnTarget() { m_Focus = false; target.RegisterCallback<MouseDownEvent>(OnMouseDown); target.RegisterCallback<MouseUpEvent>(OnMouseUp); target.RegisterCallback<MouseMoveEvent>(OnMouseMove); target.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut); } /// Manipulatorのターゲットが変わる直前に呼ばれる protected override void UnregisterCallbacksFromTarget() { target.UnregisterCallback<MouseDownEvent>(OnMouseDown); target.UnregisterCallback<MouseUpEvent>(OnMouseUp); target.UnregisterCallback<MouseMoveEvent>(OnMouseMove); target.UnregisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOut); } protected void OnMouseDown(MouseDownEvent evt) { // 設定した有効化条件をみたすか (= 左クリックか) if (CanStartManipulation(evt)) { m_Focus = true; target.BringToFront(); target.CaptureMouse(); } } protected void OnMouseUp(MouseUpEvent evt) { // CanStartManipulation()で条件を満たしたActivationのボタン条件と、 // このイベントを発火させているボタンが同じか // (= 左クリックを離したときか) if (CanStopManipulation(evt)) { target.ReleaseMouse(); m_Focus = false; } } protected void OnMouseCaptureOut(MouseCaptureOutEvent evt) { m_Focus = false; } protected void OnMouseMove(MouseMoveEvent evt) { if (m_Focus) { target.transform.position += (Vector3)evt.mouseDelta; } } }
RegisterCallBacksOnTarget()
とUnregisterCallbacksFromTarget()
はManipulator
クラスの関数で、イベントのコールバックの登録・解除を担っています。
activators
やCanStartManipulation()
、CanStopManipulation()
はManipulator
クラスを継承するMouseManipulator
クラスの関数で、マウスのボタンの管理がしやすくなっています。
細かいことはコード中のコメントに記載しました。このManipulatorを使用するには、対象のVisualElementを設定しなければいけません。
// NodeElementクラス public NodeElement (string name, Color color, Vector2 pos) { style.backgroundColor = new StyleColor(color); style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = pos; Add(new Label(name)); AddManipulator(new NodeDragger()); // 操作の追加が一行で済む }
AddManipulator
という関数によって対象のVisualElementを設定しています。
実はこのコードは以下のようにもかけます。new NodeDragger(){target = container};内部の実装を見ると、
AddManipulator
ではIManipulator.target
プロパティに自身をセットしているだけでした。
そしてsetter内で、セットする前に既存のtargetがあればUnregisterCallbacksFromTarget()
を呼び、そのあと新規のターゲットをセットしてからRegisterCallbacksOnTarget()
を呼びます。[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/Manipulators.cs]
[参照:https://github.com/Unity-Technologies/UnityCsReference/blob/2019.1/Modules/UIElements/MouseManipulator.cs]3. ノードを追加する
これまではテストのためにノードの数は2つで固定されていましたが、自在に追加できなければグラフエディタとはとても呼べません。
想定している挙動は、
- 右クリックでメニューが出てくる
- 「Add Node」を選択する
- 右クリックした場所にノードが生成される
です。
......実は前章の最後あたりで、このままのペースで書いていると時間がいくらあっても足りないと思い、先に実装してから記事を書くことにしました。
ですので、これからの説明は少しスムーズに(悪く言えば飛躍気味に)なるかもしれません。ご了承ください。3.1 メニューを表示する
2.4節で見たようなManipulatorと同じように、この挙動も操作としてまとめることができそうです。
というか、こんなみんなが欲しそうな機能が公式に用意されていないはずがありません。
案の定、存在しました。例によって公式ドキュメントです。コードを載せます。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); } void OnContextMenuPopulate(ContextualMenuPopulateEvent evt) { // 項目を追加 evt.menu.AppendAction( "Add Node", // 項目名 AddEdgeMenuAction, // 選択時の挙動 DropdownMenuAction.AlwaysEnabled // 選択可能かどうか ); } void AddEdgeMenuAction(DropdownMenuAction menuAction) { Debug.Log("Add Node"); } }期待していた挙動は、「背景を左クリックしたときはメニューが開いて、ノードを左クリックしたときは何も起こらない」です。でも、これでは逆ですね。
イベント発行についてのドキュメントを見てみます。
[図は引用:https://docs.unity3d.com/2019.1/Documentation/Manual/UIE-Events-Dispatching.html]イベントは root -> target -> root と呼ばれるみたいですね。イベント受け取りについてのドキュメントには、デフォルトではTargetフェイズとBubbleUpフェイズにイベントが登録されるともあります。
とにかく、思い当たるのは、ルートに登録したコールバックがノード経由で伝わっているということです。
いろいろ試してみてわかったのは、ルートではデフォルトでpickingMode
がPickingMode.Ignore
に設定されているということでした。リファレンスによると、マウスのクリック時にターゲットを決める際、その位置にある一番上のVisualElementを取ってきているらしいのですが、この
pickingMode
がPickingMode.Ignore
に設定されていた場合は候補から外す、という挙動になるようです。実際、このようにすると動きます。
// GraphEditorクラス public void OnEnable() { VisualElement root = this.rootVisualElement; root.Add(new NodeElement("One", Color.red, new Vector2(100, 50))); root.Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); root.pickingMode = PickingMode.Position; // ピッキングモード変更 root.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); }
でも、ルートをむやみに変更するのはよくないですね。
そこで、ルートの一つ下に一枚挟むことにします。今の作りでは、EditorWindowとVisualElementが不可分になってしまっていましたが、それを分離可能にするという意味合いもあります。さあ、いよいよもってGraphViewに近づいてきました。
分離自体はすぐにできます。// GraphEditor.cs using UnityEngine; using UnityEditor; using UnityEngine.UIElements; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); } GraphEditorElement m_GraphEditorElement; public void OnEnable() { VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(); root.Add(m_GraphEditorElement); } }// GraphEditorElement.cs using UnityEngine; using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditorElement: VisualElement { public GraphEditorElement() { style.flexGrow = 1; // サイズを画面いっぱいに広げる style.overflow = Overflow.Hidden; // ウィンドウの枠からはみ出ないようにする Add(new NodeElement("One", Color.red, new Vector2(100, 50))); Add(new NodeElement("Two", Color.yellow, new Vector2(200, 50))); this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); } void OnContextMenuPopulate(ContextualMenuPopulateEvent evt) { evt.menu.AppendAction( "Add Node", AddNodeMenuAction, DropdownMenuAction.AlwaysEnabled ); } void AddNodeMenuAction(DropdownMenuAction menuAction) { Debug.Log("Add Node"); } }二つのスタイルを適用しました。
style.flexGrow = 1;
によってGraphEditorElementのサイズが画面いっぱいに広がり、クリックイベントを拾う背景の役割を果たしてくれます。
style.overflow = Overflow.Hidden;
は親の領域からはみ出た部分を表示しないようにします。ノードを動かすとウィンドウの枠からはみ出したりしていましたが、これでもう心配はいりません。挙動はこのようになります。
まだノードの上で右クリックしたときもAdd Nodeメニューが出てしまいます。
これはノードに対しても何かを設定する必要がありそうですね。後でノードを左クリックしたときにエッジを追加する挙動を実装します。そのとき考えましょう。
とにかくメニューは出たということで、次へ進んでいきます。
3.2 ノードを生成する
Add Nodeというログを出していた部分を少し変更すると、新しいノードが生成できます。
// GraphEditorElementクラス void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; // マウス位置はeventInfoの中にあります Add(new NodeElement("add", Color.green, mousePosition)); }挙動です。
これで、表示の上では新しいノードを生成できました。4. ノードを永続化する
3章で生成したノードはGraphEditorウィンドウを開きなおしたりすると消えてしまいます。
Unityで何らかのデータを保存しておくには、どこかのファイルにシリアライズしておく必要があります。「シリアライズとはXXXである」と一言で言えたらいいのですが、短く上手く説明できる気がしません。
脱線になってしまってもよくないので、気になる方は「Unity シリアライズ」などで検索してみてください。4.1 グラフ構造とは
方針としては、グラフを再現するのに最低限必要なものを用意します。
冒頭でも少し触れましたが、ここでグラフの定義を明確にしておきます。グラフには大きく分けて二種類あります。
無向グラフと有向グラフです。
[図は引用:https://qiita.com/drken/items/4a7869c5e304883f539b]エッジ、つまり辺に向きがあるかないかの差があります。
ゲームで使う場合、ロジックを表現するためのグラフはほとんどが有向グラフなのではと思います。ビヘイビアツリー: ノードはアクション、エッジは遷移
有限オートマトン: ノードは状態、エッジは遷移ということで、作成中のグラフエディタも、有向グラフを表せるものを作りたいと思います。
余談ですが、無向グラフは有効グラフの矢印と逆向きに同じ矢印を付けると実現することができます。4.2 シリアライズ用のクラスを作る
構造としては、
グラフアセット:ノードリストを持つ
ノード:エッジリストを持つ
エッジ:つながるノードを持つとして、グラフアセットをアセットとして新規作成できるようにしようと思います。
実装は以下のようにします。// GraphAsset.cs using System.Collections.Generic; using UnityEngine; [CreateAssetMenu(fileName ="graph.asset", menuName ="Graph Asset")] public class GraphAsset : ScriptableObject { public List<SerializableNode> nodes = new List<SerializableNode>(); } [System.Serializable] public class SerializableNode { public Vector2 position; public List<SerializableEdge> edges = new List<SerializableEdge>(); } [System.Serializable] public class SerializableEdge { public SerializableNode toNode; }
ScriptableObject
に[CreateAssetMenu]
を付けることで、Unityのプロジェクトなどで右クリックをしたときにメニューから生成できるようになります。
また、[System.Serializable]
アトリビュートによって、指定したクラスをシリアライズ可能にしています。早速、グラフアセットを作ってみました。
すると、このようなエラーが出ます。Serialization depth limit 7 exceeded at 'SerializableEdge.toNode'. There may be an object composition cycle in one or more of your serialized classes. Serialization hierarchy: 8: SerializableEdge.toNode 7: SerializableNode.edges 6: SerializableEdge.toNode 5: SerializableNode.edges 4: SerializableEdge.toNode 3: SerializableNode.edges 2: SerializableEdge.toNode 1: SerializableNode.edges 0: GraphAsset.nodes UnityEditor.InspectorWindow:RedrawFromNative()そう、ノードがシリアライズしようとするエッジが、さらにノードをシリアライズしようとして、循環が発生しているのです。
シリアライズの仕組みとして、クラスの参照をそのまま保存することはできません。では、どうするかというと、ノードのIDを保存しておくことにします。
UnityのGUIDみたいに大きなIDを振ってもいいのですが、振るのとか対応付けとかが面倒そうです。
そこで、ここではGraphAssetが持っているノードリストの何番目にあるか、というのをIDとしようと思います。SerializableEdgeだけ以下のように直します。
// GraphAsset.cs [System.Serializable] public class SerializableEdge { public int toId; }これでワーニングは出なくなります。
4.3 アセットとエディタを対応付ける
どのアセットを表示・編集するかを決めるために、エディタにアセットの情報を持たせなければいけません。
実際にエディタを使うときのことを考えると、アセットからエディタが開けて、その際にそのアセットについて編集するようにできたらいいですね。というわけで要件としては、
1. GraphAssetをダブルクリックするとエディタが開く
2. どこかのGraphEditorElementクラスにGraphAssetクラスを渡す
です。// GraphAsset.cs using UnityEngine; using UnityEditor; using UnityEditor.Callbacks; // OnOpenAssetアトリビュートのために追加 using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditor : EditorWindow { [MenuItem("Window/GraphEditor")] public static void ShowWindow() { GraphEditor graphEditor = CreateInstance<GraphEditor>(); graphEditor.Show(); graphEditor.titleContent = new GUIContent("Graph Editor"); if(Selection.activeObject is GraphAsset graphAsset) { graphEditor.Initialize(graphAsset); } } [OnOpenAsset()] // Unityで何らかのアセットを開いたときに呼ばれるコールバック static bool OnOpenAsset(int instanceId, int line) { if(EditorUtility.InstanceIDToObject(instanceId) is GraphAsset) // 開いたアセットがGraphAssetかどうか { ShowWindow(); return true; } return false; } GraphAsset m_GraphAsset; // メンバ変数として持っておく GraphEditorElement m_GraphEditorElement; public void OnEnable() { // ShowWindow()を通らないような時(スクリプトのコンパイル後など) // のために初期化への導線を付ける if (m_GraphAsset != null) { // 初期化はInitializeに任せる Initialize(m_GraphAsset); } } // 初期化 public void Initialize(GraphAsset graphAsset) { m_GraphAsset = graphAsset; // 以下はもともとOnEnable() で行っていた処理 // OnEnable() はCreateInstance<GraphEditor>() の際に呼ばれるので、まだgraphAssetが渡されていない // 初期化でもgraphAssetを使うことになるのでここに移す VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(); root.Add(m_GraphEditorElement); } }これで、GraphAssetファイルをダブルクリックしたときにエディタが開くようになります。
4.4 アセットのデータからノードを表示するようにする
続いて、アセットにある情報からノードを構築、表示したいと思います。
まずはGraphAssetにダミーの情報を手打ちします。
(100, 50)と(200, 50)の位置、つまり今まで表示してきた赤と黄色の位置、にノードが表示されればOKです。まず、NodeElementを少し変えます。
色の情報はアセットにはないので省きますし、位置はシリアライズされますからね。具体的には、生成をSerializableNodeから行うようにします。
// NodeElement.cs // BackgroundColorがなくなると見えなくなるので、周囲を枠線で囲んだVisualElement、Boxを継承する public class NodeElement : Box { public SerializableNode serializableNode; public NodeElement (SerializableNode node) // 引数を変更 { serializableNode = node; // シリアライズ対象を保存しておく style.position = Position.Absolute; style.height = 50; style.width = 100; transform.position = node.position; // シリアライズされている位置を取る this.AddManipulator(new NodeDragger()); } }GraphEditorElementも伴って変更します。
// GraphEditorElement.cs using UnityEngine; using UnityEngine.UIElements; using System.Collections.Generic; public class GraphEditorElement: VisualElement { GraphAsset m_GraphAsset; // 渡されたアセットを保存 List<NodeElement> m_Nodes; // 作ったノードを入れておく。順序が重要 public GraphEditorElement(GraphAsset graphAsset) { m_GraphAsset = graphAsset; style.flexGrow = 1; style.overflow = Overflow.Hidden; this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); m_Nodes = new List<NodeElement>(); // 順番にノードを生成。この作る際の順番がSerializableEdgeが持つNodeのIDとなる foreach(var node in graphAsset.nodes) { CreateNodeElement(node); } } void CreateNodeElement(SerializableNode node) { var nodeElement = new NodeElement(node); Add(nodeElement); // GraphEditorElementの子として追加 m_Nodes.Add(nodeElement); // 順番を保持するためのリストに追加 } /* ... 省略 */ void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; CreateNodeElement(new SerializableNode() { position = mousePosition }); // 追加生成時には仮で新しく作る } }GraphEditorElementのコンストラクタにGraphAssetを渡すようにしたので、GraphEditorから生成するときに必要です
// GraphEditorクラス public void Initialize(GraphAsset graphAsset) { m_GraphAsset = graphAsset; VisualElement root = this.rootVisualElement; m_GraphEditorElement = new GraphEditorElement(graphAsset); // アセットを渡す root.Add(m_GraphEditorElement); }以上で、アセットに保持された情報を描画することができました。
書き込みはしていないので、当然開きなおすと追加したノードは消えてしまいます。4.5 追加作成したノードをアセットに書き込む
前節までできたら、あとはもう少し変えるだけです。
// GraphEditorElementクラス private void AddNodeMenuAction(DropdownMenuAction menuAction) { Vector2 mousePosition = menuAction.eventInfo.localMousePosition; var node = new SerializableNode() { position = mousePosition }; m_GraphAsset.nodes.Add(node); // アセットに追加する CreateNodeElement(node); }これでアセットに書き込まれます。
おっと、動かしたことを記録するのを忘れていました。// NodeDraggerクラス protected void OnMouseUp(MouseUpEvent evt) { if (CanStopManipulation(evt)) { target.ReleaseMouse(); if(target is NodeElement node) { //NodeElementに保存しておいたシリアライズ対象のポジションをいじる node.serializableNode.position = target.transform.position; } m_Focus = false; } }動かしてドラッグをやめた瞬間に記録するとよいと思います。
これで動かしたことも保存されるようになりました。
5. エッジを追加する
頂点の表示ができたので、次は辺です。辺は頂点同士を線で結ぶことで表します。
コンテナ的な仕組みでは直線や曲線は引けないように思うので、ここは既存の仕組みで線を引きます。
Handles.Draw
系の関数が一番楽かなと思います。
DrawLine
やDrawBezier
などです。ちなみにGraphViewでは、エッジ用のメッシュを作って、
Graphics.DrawMeshNow()
で描画をしていました。5.1 エッジを表示する
とりあえずダミーでデータを作ってみます。
Element0のEgesに要素を追加しました。
このまま表示するとこうなります。
イメージとしては、左上のノードから右下のノードへ繋がっている矢印があればいいなと思います。VisualElementは初期化字に一度呼べば後は自動で描画してくれていましたが、
Handles
で描画をするならウィンドウ更新のたびに呼ぶ必要があります。
EditorWindowの更新といえば、OnGUI
です。
ウィンドウの更新のたびにOnGUI
が呼ばれますので、そこからGraphEditorElementの描画関数を呼ぶことにします。ひとまずこのように実装してみます。
// GraphEditorクラス private void OnGUI() { if(m_GraphEditorElement == null) { return; } m_GraphEditorElement.DrawEdge(); }// GraphEditorElementクラス public void DrawEdge() { for(var i = 0; i < m_GraphAsset.nodes.Count; i++) { var node = m_GraphAsset.nodes[i]; foreach(var edge in node.edges) { DrawEdge( startPos: m_Nodes[i].transform.position, startNorm: new Vector2(0f, 1f), endPos: m_Nodes[edge.toId].transform.position, endNorm: new Vector2(0f, -1f)); } } } private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm) { Handles.color = Color.blue; // 色指定 // エッジをベジェ曲線で描画 Handles.DrawBezier( startPos, endPos, startPos + 50f * startNorm, endPos + 50f * endNorm, color: Color.blue, texture: null, width: 2f); // 矢印の三角形の描画 Vector2 arrowAxis = 10f * endNorm; Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward); Handles.DrawAAConvexPolygon(endPos, endPos + arrowAxis + arrowNorm, endPos + arrowAxis - arrowNorm); Handles.color = Color.white; // 色指定をデフォルトに戻す }ポジションとして単に
VisualElement.transform.position
を利用しているので左上隅に始点・終点が来ています。
元ノードは下辺中央から、先ノードの上辺中央につながってほしい気がします。
とはいえ、GraphEditorElementでNodeの形に関する部分を決め打ちで呼んでしまうのはちょっと気持ち悪いので、NodeElementに始点や終点の位置・方向の情報を返す関数を作ろうと思います。// GraphEditorElementクラス public void DrawEdge() { for(var i = 0; i < m_GraphAsset.nodes.Count; i++) { var node = m_GraphAsset.nodes[i]; foreach(var edge in node.edges) { // ノードに情報を問い合わせる DrawEdge( startPos: m_Nodes[i].GetStartPosition(), startNorm: m_Nodes[i].GetStartNorm(), endPos: m_Nodes[edge.toId].GetEndPosition(), endNorm: m_Nodes[edge.toId].GetEndNorm()); } } }// NodeElementクラス public Vector2 GetStartPosition() { return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, style.height.value.value); } public Vector2 GetEndPosition() { return (Vector2)transform.position + new Vector2(style.width.value.value / 2f, 0f); } public Vector2 GetStartNorm() { return new Vector2(0f, 1f); } public Vector2 GetEndNorm() { return new Vector2(0f, -1f); }また、エッジを描画するだけなら、エッジのVisualElementを作らずにGraphAssetに保存されているSerializableEdgeの値を見ていればよいのですが、エッジの追加・削除・付け替えなど、いずれ必要になるであろう操作がやりにくくなります。
そこで、エッジにもEdgeElementクラスを作ります。
// EdgeElement.cs using UnityEngine; using UnityEngine.UIElements; using UnityEditor; public class EdgeElement : VisualElement { public SerializableEdge serializableEdge; // データを持っておく public NodeElement From { get; private set; } // 元ノード public NodeElement To { get; private set; } // 先ノード public EdgeElement(SerializableEdge edge, NodeElement from, NodeElement to ) { serializableEdge = edge; From = from; To = to; } public void DrawEdge() { if(From != null && To != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: To.GetEndPosition(), endNorm: To.GetEndNorm()); } } // GraphEditorElementからそのまま移した private void DrawEdge(Vector2 startPos, Vector2 startNorm, Vector2 endPos, Vector2 endNorm) { Handles.color = Color.blue; Handles.DrawBezier( startPos, endPos, startPos + 50f * startNorm, endPos + 50f * endNorm, color: Color.blue, texture: null, width: 2f); Vector2 arrowAxis = 10f * endNorm; Vector2 arrowNorm = 5f * Vector3.Cross(endNorm, Vector3.forward); Handles.DrawAAConvexPolygon(endPos, endPos + arrowAxis + arrowNorm, endPos + arrowAxis - arrowNorm); Handles.color = Color.white; } }このクラスもノードと同様に、GraphEditorElementが生成し、GraphEditorElementの子として保持することにします。
ノードが持っていて、ノードの子として生成というのも考えましたが、GraphEditorで一元管理した方が構造が単純になりそうだと思ったのが理由です。実装はこうです。
// GraphEditorElementクラス List<EdgeElement> m_Edges; // エッジもノードと同じくまとめて保持しておく public GraphEditorElement(GraphAsset graphAsset) { m_GraphAsset = graphAsset; style.flexGrow = 1; style.overflow = Overflow.Hidden; this.AddManipulator(new ContextualMenuManipulator(OnContextMenuPopulate)); m_Nodes = new List<NodeElement>(); foreach(var node in graphAsset.nodes) { CreateNodeElement(node); } // すべてのノードの生成が終わってからエッジの生成を行う // エッジが持っているノードIDからノードを取得するため m_Edges = new List<EdgeElement>(); foreach(var node in m_Nodes) { foreach(var edge in node.serializableNode.edges) { CreateEdgeElement(edge, node, m_Nodes); } } } // エッジの生成 public EdgeElement CreateEdgeElement(SerializableEdge edge, NodeElement fromNode, List<NodeElement> nodeElements) { var edgeElement = new EdgeElement(edge, fromNode, nodeElements[edge.toId]); Add(edgeElement); m_Edges.Add(edgeElement); return edgeElement; } // GraphEditor.OnGUI() 内で呼ばれる。描画処理をエッジに移したので小さくなった public void DrawEdge() { foreach(var edge in m_Edges) { edge.DrawEdge(); } }見た目は先ほどと変わりません。
5.2 エッジを追加できるようにする
あるノードからあるノードにエッジをつけようと思う時、元ノードから先ノードへ線を伸ばしていくようなイメージになると思います。
UnityのGraphViewやUnrealEngineのBluePrintではノードに備わった接続用のポートをクリックしてそのままドラッグすると線が引かれていきます。
UnrealEngineのBehaviourTreeでは、ノードの上下にエッジ接続領域があります。
これらのようなポートや接続領域などはあると便利そうですが、いったんメニューにAdd Edgeを追加するので良いでしょう。
重要なのは、追加中に元ノードからエッジがマウスの位置を追従していることです。
このUIによって、現在エッジ追加操作中であることと、つなげるノードを指定する方法が直感的にわかります。
これは実装したいです。挙動としては、
1. ノードを右クリックする
2. メニューから「Add Edge」を選択する
3. 元ノードからマウスの位置に向かうエッジ候補ができる
4. 他のノードを左クリックして、エッジの向かい先を確定するを想定します。
ノードに対する操作なので、ノードにManipulator
を追加します。
エッジをつなぐ操作なので、EdgeConnectorクラスとします。5.2.1 EdgeConnectorクラスを作る
EdgeConnectorの役割はメニューを出してエッジ追加モードに入ることと、そのあとに別のノードをクリックして実際にノードを接続することの二つあります。
その中でメニューを出す部分はContexturalMenuManipulator
の役割ですので、EdgeConnectorクラスの中でContexturalMenuManipulator
を作成し、それをEdgeConnectorのターゲットノードにAddManipulator
しようと思います。こうすることで、NodeElementにEdgeConnectorを追加するだけで、エッジ追加の処理をすべてEdgeConnectorクラスに投げることができます。
// NodeElementクラス public NodeElement (SerializableNode node) { /* ... 省略 */ this.AddManipulator(new NodeDragger()); this.AddManipulator(new EdgeConnector()); // 追加 }そして、EdgeConnectorの内部はひとまずこのようにしておきます。
using UnityEngine; using UnityEngine.UIElements; public class EdgeConnector : MouseManipulator { bool m_Active = false; ContextualMenuManipulator m_AddEdgeMenu; public EdgeConnector() { // ノードの接続は左クリックで行う activators.Add(new ManipulatorActivationFilter() { button = MouseButton.LeftMouse }); m_Active = false; // メニュー選択マニピュレータは作っておくが、この時点ではターゲットが確定していないので、 // RegisterCallbacksOnTarget()で追加する m_AddEdgeMenu = new ContextualMenuManipulator(OnContextualMenuPopulate); } private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement node) { // エッジ追加中に右クリックを押されたときのために、ノードの上かどうかを見る if (!node.ContainsPoint(node.WorldToLocal(evt.mousePosition))) { // イベントを即座に中断 evt.StopImmediatePropagation(); return; } evt.menu.AppendAction( "Add Edge", (DropdownMenuAction menuItem) => { m_Active = true; Debug.Log("Add Edge"); // ここでエッジ追加モード開始処理を書く target.CaptureMouse(); }, DropdownMenuAction.AlwaysEnabled); } } protected override void RegisterCallbacksOnTarget() { target.RegisterCallback<MouseDownEvent>(OnMouseDown); target.RegisterCallback<MouseUpEvent>(OnMouseUp); target.RegisterCallback<MouseMoveEvent>(OnMouseMove); target.RegisterCallback<MouseCaptureOutEvent>(OnCaptureOut); target.AddManipulator(m_AddEdgeMenu); } protected override void UnregisterCallbacksFromTarget() { target.RemoveManipulator(m_AddEdgeMenu); target.UnregisterCallback<MouseDownEvent>(OnMouseDown); target.UnregisterCallback<MouseUpEvent>(OnMouseUp); target.UnregisterCallback<MouseMoveEvent>(OnMouseMove); target.UnregisterCallback<MouseCaptureOutEvent>(OnCaptureOut); } protected void OnMouseDown(MouseDownEvent evt) { if (!CanStartManipulation(evt)) return; // マウス押下では他のイベントが起きてほしくないのでPropagationを中断する if (m_Active) evt.StopImmediatePropagation(); } protected void OnMouseUp(MouseUpEvent evt) { if (!CanStopManipulation(evt)) return; if (!m_Active) return; Debug.Log("Try Connect"); // ここでマウスの下にあるノードにエッジを接続しようとする m_Active = false; target.ReleaseMouse(); } protected void OnMouseMove(MouseMoveEvent evt) { if (!m_Active) return; Debug.Log("move"); // ここで、追加中のエッジの再描画を行う } private void OnCaptureOut(MouseCaptureOutEvent evt) { if (!m_Active) return; m_Active = false; target.ReleaseMouse(); } }5.2.2 エッジ追加のためにエッジ・グラフクラスを整備
次に、EdgeElementクラスに追加中のEdgeを作成するための準備をします。
これまではEdgeには元ノードと先ノードを渡して作成していましたが、追加中には先ノード確定していないので、元ノードと矢印の位置からエッジを描画できるようにします。// EdgeElementクラス Vector2 m_ToPosition; public Vector2 ToPosition { get { return m_ToPosition; } set { m_ToPosition = this.WorldToLocal(value); // ワールド座標で渡されることを想定 MarkDirtyRepaint(); // 再描画をリクエスト } } // 新しいコンストラクタ public EdgeElement(NodeElement fromNode, Vector2 toPosition) { From = fromNode; ToPosition = toPosition; } // つなげるときに呼ぶ public void ConnectTo(NodeElement node) { To = node; MarkDirtyRepaint(); // 再描画をリクエスト } public void DrawEdge() { if (From != null && To != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: To.GetEndPosition(), endNorm: To.GetEndNorm()); } else { // 追加中の描画用 if (From != null) { DrawEdge( startPos: From.GetStartPosition(), startNorm: From.GetStartNorm(), endPos: ToPosition, endNorm: Vector2.zero); } } }これにより、追加中のEdgeElementをGraphEditorElementのEdgesに追加すれば自動的に描画されるようになったはずです。
ということで、GraphEditorElementにエッジ追加リクエストを投げられるようにします。
ついでに、ノード追加を中断したときのためにエッジ削除関数も作っておきます。// GraphEditorElementクラス public EdgeElement CreateEdgeElement(NodeElement fromNode, Vector2 toPosition) { var edgeElement = new EdgeElement(fromNode, toPosition); Add(edgeElement); m_Edges.Add(edgeElement); return edgeElement; } public void RemoveEdgeElement(EdgeElement edge) { Remove(edge); m_Edges.Remove(edge); }5.2.3 エッジ追加の挙動を実装
上で作った関数をEdgeConnectorクラスから呼びます。
// EdgeConnectorクラス GraphEditorElement m_Graph; EdgeElement m_ConnectingEdge; private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement node) { evt.menu.AppendAction( "Add Edge", (DropdownMenuAction menuItem) => { m_Active = true; // 親をたどってGraphEditorElementを取得する m_Graph = target.GetFirstAncestorOfType<GraphEditorElement>(); m_ConnectingEdge = m_Graph.CreateEdgeElement(node, menuItem.eventInfo.mousePosition); target.CaptureMouse(); }, DropdownMenuAction.AlwaysEnabled); } } /* ... 省略 */ protected void OnMouseUp(MouseUpEvent evt) { if (!CanStopManipulation(evt)) return; if (!m_Active) return; var node = m_Graph.GetDesignatedNode(evt.originalMousePosition); if (node == null // 背景をクリックしたとき || node == target // 自分自身をクリックしたとき || m_Graph.ContainsEdge(m_ConnectingEdge.From, node)) // すでにつながっているノード同士をつなげようとしたとき { m_Graph.RemoveEdgeElement(m_ConnectingEdge); } else { m_ConnectingEdge.ConnectTo(node); } m_Active = false; m_ConnectingEdge = null; // 接続終了 target.ReleaseMouse(); } protected void OnMouseMove(MouseMoveEvent evt) { if (!m_Active) { return; } m_ConnectingEdge.ToPosition = evt.originalMousePosition; // 位置更新 } private void OnCaptureOut(MouseCaptureOutEvent evt) { if (!m_Active) return; // 中断時の処理 m_Graph.RemoveEdgeElement(m_ConnectingEdge); m_ConnectingEdge = null; m_Active = false; target.ReleaseMouse(); }// GraphEditorElementクラス // マウスの位置にあるノードを返す public NodeElement GetDesignatedNode(Vector2 position) { foreach(NodeElement node in m_Nodes) { if (node.ContainsPoint(node.WorldToLocal(position))) return node; } return null; } // すでに同じエッジがあるかどうか public bool ContainsEdge(NodeElement from, NodeElement to) { return m_Edges.Exists(edge => { return edge.From == from && edge.To == to; }); }5.2.4 追加したエッジをシリアライズする
今のままではEdgeElementを追加しただけなので、つないだエッジはデータとして残っていません。
ノードのときと同じようにシリアライズする必要があります。// EdgeConnectorクラス protected void OnMouseUp(MouseUpEvent evt) { /* ... 省略 */ var node = m_Graph.GetDesignatedNode(evt.originalMousePosition); if (node == null || node == target || m_Graph.ContainsEdge(m_ConnectingEdge.From, node)) { m_Graph.RemoveEdgeElement(m_ConnectingEdge); } else { m_ConnectingEdge.ConnectTo(node); m_Graph.SerializeEdge(m_ConnectingEdge); // つないだ時にシリアライズする } /* ... 省略 */ }// GraphEditorElementクラス public void SerializeEdge(EdgeElement edge) { var serializableEdge = new SerializableEdge() { toId = m_Nodes.IndexOf(edge.To) // ここで先ノードのIDを数える }; edge.From.serializableNode.edges.Add(serializableEdge); // 実際に追加 edge.serializableEdge = serializableEdge; // EdgeElementに登録しておく }保存されています。
5.3 エッジを削除できるようにする
エッジの追加ができるようになったので、やはり削除もできなければいけません。
ノードを削除するときと同様に、エッジの削除もコンテキストメニューから行いたいと思います。
しかし、このとき問題があります。
ノードは大きさのあるVisualElement
だったため、ContextualManipulator
を付けるとそのままクリックで選択ができました。
しかし、エッジのVisualElement
は大きさがありません。5.3.1 エッジを選択できるようにする
VisualElement
をクリックして選択するときの挙動について、ドキュメントに記載がありました。
Event targetのPicking mode and custom shapesの項です。You can override the
VisualElement.ContainsPoint()
method to perform custom intersection logic.この
VisualElement.ContainsPoint()
は、マウス座標を与えると、その座標と自分が衝突しているかを判定する関数です。
それをオーバーライドして、独自の衝突判定を埋め込むことで、VisualElement
のRect
以外の形に対応させることができます。実際にベジェ曲線と点との距離を計算するのは面倒なので、近似した線分との距離を計算して、指定距離以内だったら選択したことにしようと思います。
さて、衝突を判定の実装に当たって、ログを出すものが必要です
というわけで最初に、エッジに削除用のコンテキストメニューを作ります。// EdgeElementクラス // 削除用マニピュレータの追加 public EdgeElement() { this.AddManipulator(new ContextualMenuManipulator(evt => { if (evt.target is EdgeElement) { evt.menu.AppendAction( "Remove Edge", (DropdownMenuAction menuItem) => { Debug.Log("Remove Edge"); }, DropdownMenuAction.AlwaysEnabled); } })); } public EdgeElement(NodeElement fromNode, Vector2 toPosition):this() // 上のコンストラクタを呼ぶ { From = fromNode; ToPosition = toPosition; } public EdgeElement(SerializableEdge edge, NodeElement fromNode, NodeElement toNode):this() // 上のコンストラクタを呼ぶ { serializableEdge = edge; From = fromNode; To = toNode; }まず、接続元と接続先が収まるバウンディングボックスと衝突しているかどうかを判定してみます。
// EdgeElementクラス public override bool ContainsPoint(Vector2 localPoint) { if (From == null || To == null) return false; Vector2 start = From.GetStartPosition(); Vector2 end = To.GetEndPosition(); // ノードを覆うRectを作成 Vector2 rectPos = new Vector2(Mathf.Min(start.x, end.x), Mathf.Min(start.y, end.y)); Vector2 rectSize = new Vector2(Mathf.Abs(start.x - end.x), Mathf.Abs(start.y - end.y)); Rect bound = new Rect(rectPos, rectSize); if (!bound.Contains(localPoint)) { return false; } return true; }結果はこうなりました。
確かに、エッジのバウンディングボックスとの当たりを判定できていそうです。次に、近似線分との距離を計算してみます。
先にバウンディングボックスに入っていないものを弾いているので、端点が一番近い場合などを考えなくて済みます。
つまり、線分ではなく直線と点の距離を考えればよいということです。// EdgeElementクラス readonly float INTERCEPT_WIDHT = 15f; // エッジと当たる距離 public override bool ContainsPoint(Vector2 localPoint) { /* ... 省略 */ if (!bound.Contains(localPoint)) { return false; } // 近似線分ab Vector2 a = From.GetStartPosition() + 12f * From.GetStartNorm(); Vector2 b = To.GetEndPosition() + 12f * To.GetEndNorm(); // 一致した場合はaからの距離 if (a == b) { return Vector2.Distance(localPoint, a) < INTERCEPT_WIDHT; } // 直線abとlocalPointの距離 float distance = Mathf.Abs( (b.y - a.y) * localPoint.x - (b.x - a.x) * localPoint.y + b.x * a.y - b.y * a.x ) / Vector2.Distance(a, b); return distance < INTERCEPT_WIDHT; }結果はこうなりました。
...ちょっとずれている気もしますが、まあ、許容範囲でしょう。5.3.2 エッジデータを削除する
GraphAssetからエッジのデータを消します。
EdgeElementには元ノードの情報が既にありますので、そこから自分のデータが入っているSerializableNodeを取得することができます。
これを消せばよいですね。// EdgeElementクラス public EdgeElement() { this.AddManipulator(new ContextualMenuManipulator(evt => { if (evt.target is EdgeElement) { evt.menu.AppendAction( "Remove Edge", (DropdownMenuAction menuItem) => { // 親をたどってGraphEditorElementに削除リクエストを送る var graph = GetFirstAncestorOfType<GraphEditorElement>(); graph.RemoveEdgeElement(this); }, DropdownMenuAction.AlwaysEnabled); } })); }// GraphEditorElementクラス public void RemoveEdgeElement(EdgeElement edge) { // 消すエッジにSerializableEdgeがあれば、それを消す if(edge.serializableEdge != null) { edge.From.serializableNode.edges.Remove(edge.serializableEdge); } Remove(edge); m_Edges.Remove(edge); }無事、削除できています。
6. ノードを削除する
最後に、ノードを削除できるようにしたいと思います。
ノードを削除したときには、
- NodeElementを削除する
- 対応するSerializableNodeを削除する
- そのノードとつながるEdgeElementを削除する
- 対応するSerializableEdgeを削除する
- 他ノードのIDが変わるので、それに応じてSerializableEdgeのIDを振りなおすのすべてを行う必要があります。
// NodeElementクラス public NodeElement (SerializableNode node) { /* ... 省略 */ this.AddManipulator(new NodeDragger()); this.AddManipulator(new EdgeConnector()); this.AddManipulator(new ContextualMenuManipulator(OnContextualMenuPopulate)); // 削除用マニピュレータ } private void OnContextualMenuPopulate(ContextualMenuPopulateEvent evt) { if (evt.target is NodeElement) { evt.menu.AppendAction( "Remove Node", RemoveNodeMenuAction, DropdownMenuAction.AlwaysEnabled); } } private void RemoveNodeMenuAction(DropdownMenuAction menuAction) { // 親をたどって削除をリクエスト var graph = GetFirstAncestorOfType<GraphEditorElement>(); graph.RemoveNodeElement(this); }// GraphEditorElementクラス public void RemoveNodeElement(NodeElement node) { m_GraphAsset.nodes.Remove(node.serializableNode); // アセットから削除 int id = m_Nodes.IndexOf(node); // エッジの削除とID変更 // m_Edgesに変更が伴うため、降順で行う for (int i = m_Edges.Count - 1; i >= 0; i--) { var edgeElement = m_Edges[i]; var edge = edgeElement.serializableEdge; // 削除されるノードにつながるエッジを削除 if (edgeElement.To == node || edgeElement.From == node) { RemoveEdgeElement(edgeElement); continue; } // 変更が生じるIDを持つエッジに対して、IDに修正を加える if (edge.toId > id) edge.toId--; } Remove(node); // VisualElementの子としてのノードを削除 m_Nodes.Remove(node); // 順序を保持するためのリストから削除 }ウィンドウを開きなおしてもちゃんと構造が保存されています。
結果
ゼロからノードベースエディタを作りました。
現状ではグラフ構造を保存するアセットを作れるだけですが、このノード部分に何か情報を載せると立派なヴィジュアルツールが出来上がります。おわりに
UIElementの使い方を勉強したいと思ったので、ノードベースエディタを作ってみました。
ドキュメントとリファレンスを読み込むことになり、GraphViewの実装もかなり追ったので勉強になってよかったです。
実をいうと、このGraphEditorを使ってBehaviorTreeを作るところまでやりたかったのですが、エディタを作るだけで相当の時間がかかってしまったので、この記事はここまでにしておきます。また、ゼロから作るを銘打って、実装する手順通りに事細かく書いてしまったので、やたら長くなってしまいました。
とはいえ、エディタを作るにあたって得た知見をふんだんに盛り込めたのではないかと思います。ここはもっとこうした方がよい、のような意見があればコメントで教えていただけるとありがたいです。
ご拝読ありがとうございました。
- 投稿日:2019-12-21T14:19:32+09:00
Unity Addressableの重複アドレスや依存グループチェック機能を作る
はじめに
Addresabblesを使うには便利機能が足りていない部分があるので自作していきます。
Unity Addressable Asset Systemの使用方法と自動化の機能調査の続きとなる内容です。使用バージョン
unity 2019.3.0b4
Addresabbles version1.5.0スクリプトを作る
重複アドレスチェック
アセットにユニークなアドレスを設定しロードを簡単に書けるということですが、重複したアドレスを設定することができます。
この状態でもビルドは成功します。アドレスが重複しているときは最後に追加されたアセットをロードしてきます。
このときも特に警告やエラーログは出ません。アドレスの命名規則を決めておき重複しないように運用できていればいいですが、重複アドレスが無いかのチェックを行いましょう。
/// <summary> /// 全アドレスの重複チェック /// </summary> [MenuItem(menuName + "/CheckAllAddress")] static public void CheckAllAddress() { var s = GetSettings(); List<AddressableAssetEntry> entries = new List<AddressableAssetEntry>(); s.GetAllAssets(entries, true); List<string> checkedAddress = new List<string>(); foreach (var e in entries) { //チェック済みアドレスはコンテニュー if (checkedAddress.Contains(e.address)) continue; //全アセットで重複があるかチェック bool ret = CheckAddress(entries, e.address); if (!ret) { checkedAddress.Add(e.address); } } } /// <summary> /// 重複アドレスチェック /// </summary> /// <param name="entries"></param> /// <param name="address"></param> /// <returns>重複なしtrue、ありfalse</returns> static public bool CheckAddress(List<AddressableAssetEntry> entries, string address) { var s = GetSettings(); var duplicateEntries = entries.FindAll(e=>e.address == address); if(duplicateEntries.Count > 1) { string str = "Address=" + address + System.Environment.NewLine; foreach (var e in duplicateEntries) { string assetname = System.IO.Path.GetFileName(e.AssetPath); str += "Group=" + e.parentGroup.Name + "," + "AssetName=" + assetname + System.Environment.NewLine; } Debug.LogAssertion("DuplicateAddress" + System.Environment.NewLine + str); return false; } return true; }並び替え
AddresabblesGroupsのGUI上ではGroupを並び替えることが手動でできます。
Group内をPathで並び替えはできましたがなぜかGroup自体の並び替えはできませんでした。
内部的な並びは
AddressableAssetSettings.groupsのソートすれば変わるようですがGUI上は画面が更新されない限り
変わりませんでした。
GroupNameの欄をクリックするか、インポート時やコンパイル時に反映されました。/// <summary> /// 文字列順にソートする /// </summary> [MenuItem(menuName + "/Sort")] static public void Sort() { var s = GetSettings(); s.groups.Sort(new GroupCompare()); }文字列の大きさ比較してソートをするだけです。
このままでは不便すぎるので、Addresabblesのソースコードを書き換えてGUI上も更新させます。
スクリプトリファレンスのGUIのページにはAPIについて載っていないので勘で探します。
https://docs.unity3d.com/Packages/com.unity.addressables@1.5/api/UnityEditor.AddressableAssets.GUI.htmlAddresabbles GroupsのGUIは
AddressableAssetEntryTreeView.cs
で描画されています。
UnityEditorのTreeViewを継承しています。
Reload関数を呼ぶことで強制的にデータを再読み込みさせられるとあるので呼べるようにしていきます。AddressableAssetsSettingsGroupEditor.cs に
AddressableAssetEntryTreeView m_EntryTree; というメンバでインスタンスを持っておりAddresabblesAssetsWindow.cs に
AddressableAssetsSettingsGroupEditor m_GroupEditor; というメンバでインスタンスを持っています。
このAddressablesAssetWindowを取得して、TreeViewのReloadを呼び出します。AddressableAssetWindowにReload関数を追加します。
1. AddressableAssetWindowクラスをpublicにする。
2. m_GroupEditor.Reload()を呼び出すpublic Reload関数を作る
3. エディタウィンドウを取得してReload関数を呼び出す。/// <summary> /// 文字列順にソートする /// </summary> [MenuItem(menuName + "/Sort")] static public void Sort() { var s = GetSettings(); s.groups.Sort(new GroupCompare()); EditorWindow.GetWindow<UnityEditor.AddressableAssets.GUI.AddressableAssetsWindow>().Reload(); }空グループ削除
アセットの管理が変わってグループが必要なくなった時に、空グループは自動で削除されてほしい場合があります。
グループのアセットエントリ数をカウントして0ならば削除するだけです。
デフォルトグループの判定もあるので0かつデフォルトグループではない時に削除します。/// <summary> /// 空グループを削除 /// </summary> [MenuItem(menuName+"/Remove/EmptyGroup")] static public void DeleteEmptyGroup() { var s = GetSettings(); var groups = s.groups; foreach (var g in groups) { if (g.entries.Count == 0 && !g.IsDefaultGroup()) { s.RemoveGroup(g); } } }依存関係チェック
アセットがどのグループに依存しているかをチェックする仕組みを作り、依存関係による巨大なバンドルが作られないようにします。
依存自体はAssetDatabase.GetDependenciesで取得できるので、このAPIとグループ内のエントリを調べていきます。注意としてアセット数が千や万を超える膨大な数になると全アセット検索をしては数分かかるので、適宜フィルタリングをする必要が出てきます。
[MenuItem("test/FindDependency")] static public void DependCheck() { var s = GetSettings(); List<AddressableAssetEntry> entries = new List<AddressableAssetEntry>(); s.GetAllAssets(entries, true); foreach (var e in entries) { string str = ("Group:" + e.parentGroup+ " Asset:" + e.AssetPath + System.Environment.NewLine); var dependencies = AssetDatabase.GetDependencies(e.AssetPath); foreach (var d in dependencies) { var guid = AssetDatabase.AssetPathToGUID(d); foreach (var g in s.groups) { if (e.parentGroup == g) continue; var entry = g.GetAssetEntry(guid); if(entry != null && entry != e) { str += g.Name + System.Environment.NewLine; str += d + System.Environment.NewLine; } } } Debug.Log(str); } }グループの設定はこの状態で、prefabB.prefabのスクリプトがprefabC.prefabを参照しています。
検索結果のログはこうなります。GroupCに依存しています。
おわりに
Addresabblesはver1.5.0になりバグも減っていると思います。
APIも十分あり処理をフックして自前の処理に置き換えることもできるので、それぞれのプロジェクトに応じて拡張をしていくことも可能です。
足りない機能はそれぞれ自作していきましょう。
- 投稿日:2019-12-21T08:44:46+09:00
OculusQuest ハンドトラッキングをとりあえず試す
ついにハンドトラッキングのSDKが実装!
2019年12月20日に待望のOculusQuestのハンドトラッキングのSDKが公開されたのでとりあえずハンドトラッキングを試したい人向け
より詳しい情報は公式のリファレンスを読んだりすることをお勧めしますこの記事は以下の環境での実装です
Unity 2018.4.14f1
OculusIntegration ver 12.0 ( Unityの対象バージョンは 2017.4.30 以上 )AssetStoreのバージョン情報がバグってる?
既にOculusIntegrationを導入してるプロジェクトだとアップデートするか否かのダイアログに 1.44 とかって書いてあったからこっちが本来のバージョンと思われるまた本記事はOculusQuestのアプリビルドができることを前提としています
参考記事
0から始めるQuestビルド
Unityで作ったアプリをOculus Questで動かす導入手順
シーンへの配置
- 先ずは空のプロジェクトを作成し、適当なシーンを作成
- OVRCameraRigをシーン上に配置
- OVRCamerarig > TrackingSpace > LeftHandAnchor, RightHandAnchorにOVRHandPrefabを配置する
OVRHandPrefabの場所は下記画像のとおり、OVRCameraRig等のプレハブがあるところに置いてあるので右手、左手それぞれのAnchorにOVRHandPrefabを配置する
パラメータの設定
次にOVRHandPrefabのInspectorを見てみる
以下の三つのコンポーネント
OVRHand の HandType
OVRSkeleton の SkeletonType
OVRMesh の MeshType
をそれぞれ左手なら Hand Left、右手なら Hand Right に設定する次にOVRCameraRigを選択
OVRManager の Hand Tracking Support のドロップダウンを Controllers And Hands または Hands Only に設定する
当記事ではHands Onlyに設定
それぞれの設定での動作の違い
Hands Only
コントローラ設定でアプリを起動することが出来、その時はコントローラのトラッキングのみ行わる
ハンドトラッキングアプリとして動く、コントローラはトラッキングされない
アプリ中にコントローラのボタンを押下してもハンドトラッキングからコントローラに切り替わることは無いControllers And Hand
基本的にハンドトラッキングのアプリとして動く、アプリ起動中にコントローラを操作するとコントローラを認識し、以後ハンドトラッキングが切れる( ボタン押下、コントローラ動かしただけでは特に反応しなかった )
アプリを閉じるとコントローラ操作の状態になっており設定から再度手を使うを選択する必要があるControllers Only
手を使う設定でアプリを起動しようとするとコントローラが必要ですというダイアログが開きアプリを起動できない
アプリ中は当然ハンドトラッキングは行われないこれらの動作は今後のOculusIntegrationの更新で変わると思われるのであくまで現時点での動作ということで...
ビルド、そして実機へ...
良し、いい感じ
とりあえず駆け足気味だったがこれでハンドトラッキングをとりあえず試すことが出来た
感想
遅延も言われなきゃ気づかないくらいだしトラッキングが切れる時は割と思い切りのいい感じにトラッキングが切れるから不快感はない
トラッキング切れるか否かの境目らへんで手がぐちゃぐちゃになるのが一番いやだからねコントローラ設定の時にアプリを起動すると初めからコントローラ操作になる、今のところハンドトラッキング -> コントローラは遷移するがコントローラ -> ハンドトラッキングへの遷移はないと思われるてかできるようにしてほしい、うっかりコントローラ操作しちゃったときにホームの設定を直すのは手間なので
ハンドトラッキングのアプリの時はアプリを閉じる方法が特にない、ジェスチャとか何かしらのメニューを自作する必要があると思われる
- 投稿日:2019-12-21T04:51:54+09:00
【Unity】Lightコンポーネントで色温度を使用するには
はじめに
光の色を表現する尺度として色温度(ColorTemperature)がありますが、Lightコンポーネントで使用する際の処理が少々分かり辛かったため紹介いたします。
色温度を使用するには
GraphicsSettingsの必要なコードを記述する
下記のコードを任意のスクリプトのStartイベントやEntryPointなどに記述します
GraphicsSettings.lightsUseLinearIntensity = true; GraphicsSettings.lightsUseColorTemperature = true;useColorTemperatureプロパティをtrueにする
色温度を使用するLightコンポーネントのuseColorTemperatureプロパティのトグルをtrueにします
おわりに
以上2つの処理により色温度をLightコンポーネントで使用することが出来ます。
色を表現する際に色温度を使用することは稀だと思いますが、AR FoundationのLightEstimateのaverageColorTemperatureを動作させる際などに必要な処理になります。
- 投稿日:2019-12-21T04:37:24+09:00
VRChat Udon VMのバイトコードを読んでみた
これは?
VRChatでUdonというVMがつかえるようになったらしいのでアセンブリを分析して読んでみた。読めるとこだけ。
なお、調査には cannorin 氏のtweet Udon VM 向けのコンパイラを作ろうとしている[1] を参考にした。
あと当方Unityと.NETとC#に明るくないのでデータ構造以外の細かい認識なんかは保証しない。
概要
Udon VMは.NET VM上で動くVM(多分厳密にはUnityのMonoらしい)。
公式情報は以下にある
https://ask.vrchat.com/t/getting-started-with-udon/80
https://ask.vrchat.com/t/getting-started-with-udon-assembly/84この公式サイトによると存在するニーモニックは以下の通り。
- NOP
- PUSH, Adress
- POP
- JUMP_IF_FALSE, Address
- JUMP, Address
- EXTERN, ExternMethodSignature
- ANNOTATION
- JUMP_INDIRECT, Address
- COPY
計算ができないのでどうするのかとおもったら、EXTERNで外部に投げつけて処理するっぽい。
IUdonProgram
細かい話はさておき、このVMで動くデータはIUdonProgramというIFで表現される
namespace VRC.Udon.Common.Interfaces { public interface IUdonProgram { string InstructionSetIdentifier { get; } int InstructionSetVersion { get; } byte[] ByteCode { get; } IUdonHeap Heap { get; } IUdonSymbolTable EntryPoints { get; } IUdonSymbolTable SymbolTable { get; } IUdonSyncMetadataTable SyncMetadataTable { get; } } }コード部分がByteCodeで他データがいろいろなクラスに入っていると思われる。
ByteCode
ザックリ調べてみるとオペコードは1 byte, オペランドは0-4 bytesの可変長なバイトコードを吐く様子。
オペランド オペコード 説明 0x00 none NOP: なにもしない 0x01 Address(4bytes) PUSH: アドレスをstackにpushする 0x02 none POP: stackからアドレスをpopする(そのまま捨てられそう) 0x03 不明 0x04 Address(4bytes) JUMP_IF_FALSE: 最新のstackをpopし、それがtrueならPCにAddressをセットする 0x05 Address(4bytes) JUMP: PCにAddressをセットする 0x06 ExternMethodSignature(4bytes) EXTERN: 0x07 ???(4bytes) ANNOTATION: なにもしない、らしいがドキュメントと違って値を求められた。ANNOTATION自体が予約状態かもしれないから使うべきではなさそう 0x08 Address(4bytes) JUMP_INDIRECT: PCにHeapのAddressに入っている値をセットする 0x09 none COPY: AddressはHeapのアドレスの様子。
ExternMethodSignatureもHeapのアドレスだった。逆アセンブルとバイトコードの対応例
前述の https://ask.vrchat.com/t/getting-started-with-udon-assembly/84 のコードを使って、その逆アセンブルとバイトコードの対応例を示す。
0x0100000003 PUSH, 0x00000003 0x0100000002 PUSH, 0x00000002 0x09 COPY 0x0100000004 PUSH, 0x00000004 0x0100000005 PUSH, 0x00000005 0x0600000006 EXTERN, "UnityEngineInput.__GetAxis__SystemString__SystemSingle" 0x0100000002 PUSH, 0x00000002 0x0100000001 PUSH, 0x00000001 0x0100000005 PUSH, 0x00000005 0x0600000007 EXTERN, "UnityEngineTransform.__Rotate__UnityEngineVector3_SystemSingle__SystemVoid" 0x0500ffffff JUMP, 0x00ffffffこの時のHeap
この内容はdebuggerでぶち抜いて、全部は確認してないので参考程度に
address(配列の添え字) 内容物 0x00 System.Single(0) 0x01 UnityEngine.Vector3(0,0,0) 0x02 VRC.Udon.Common.UdonGameObjectComponentHeapReference(this) 0x03 VRC.Udon.Common.UdonGameObjectComponentHeapReference(this) 0x04 System.String(null) 0x05 System.Single(0) 0x06 System.String("UnityEngineInput.__GetAxis__SystemString__SystemSingle") 0x07 System.String("UnityEngineInput.__GetAxis__SystemString__SystemSingle") これから、Heapはまずdata領域を詰め、その後EXTERNの文字列を詰めていると思われる。
この時のEntryPoint
addressToSymbolだけ示す
address(配列の添え字) 内容物 0x00 (Key, Value) = (0, Udon.Common.UdonSymbol(address:0, name:"_update", type: null)) EntryPointはcode領域のラベルを管理していると思われる。
この時のSymbolTable
addressToSymbolだけ示す
address(配列の添え字) 内容物 0x00 (Key, Value) = (0, Udon.Common.UdonSymbol(address:0, name:"angle_0", type: System.Single)) 0x01 (Key, Value) = (1, Udon.Common.UdonSymbol(address:1, name:"axis_0", type: System.Single)) 0x02 (Key, Value) = (2, Udon.Common.UdonSymbol(address:2, name:"instance_0", type: System.Single)) 0x03 (Key, Value) = (3, Udon.Common.UdonSymbol(address:3, name:"Target", type: System.Single)) 0x04 (Key, Value) = (4, Udon.Common.UdonSymbol(address:4, name:"axisName_0", type: System.Single)) 0x05 (Key, Value) = (5, Udon.Common.UdonSymbol(address:5, name:"Single_0", type: System.Single)) SymbolTableはデータ領域のラベルのみ管理していると思われる。
気づいたこと
- EXTERN
- EXTERNの文字に適当なものをつめてもassemblyエラーは発生しなかった。突合せは実行時に行っているようにみえる
- EXTERNに使えるものは、前述のとおり実行時の判定になるのでアセンブリのみの分析からはわからなかった
- data領域
- 現状null/thisしかdefault値を渡せないらしいが、nullで初期化した該当型のインスタンスが生成されている様子。この辺C#に疎いから確信はない
- code領域
- 0xffffffが何かわからない
結論
- とりあえず今示されているアセンブリ命令のオペコード・オペランドは把握できた
- それぞれのラベルはすべて保存されているため動的に解決可能にみえるが、(ドキュメントを信用するなら)EXTERN以外のheapアドレスはバイトコードの段階で展開されている
- EXTERNは完全に動的解決
- 使えるAPIやcode領域のアドレス空間の予約状況はよくわからなかった
- 投稿日:2019-12-21T01:10:18+09:00
ScriptableObjectをそのまま別フォルダに上書き移動するAssetPostprocessor
テラシュールさんのExcelImporter
【Unity】Excel Importer Maker、xlsxに対応
で出力したデータを、独自のフォルダにコピーしてゲーム中にロードしているのですが、
独自フォルダに自動でコピーするAssetPostprocessorを書いたのでメモしておきます。using UnityEngine; using UnityEditor; namespace Twilight { public class MasterDataOverrider : AssetPostprocessor { private const string kSrcDirectoryPath = "Assets/App/Data"; private const string kDestDirectoryPath = "Assets/App/Resources/Data"; /// <summary> /// OnPostprocessAllAssetsには効かないらしいけど一応 /// </summary> /// <returns></returns> public override int GetPostprocessOrder() { return 100; } static void OnPostprocessAllAssets( string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromPath) { foreach (var asset in importedAssets) { if (!asset.Contains(kSrcDirectoryPath)) { continue; } var loadAsset = AssetDatabase.LoadAssetAtPath<ScriptableObject>(asset); if (loadAsset == null) { continue; } var destFilePath = asset.Replace(kSrcDirectoryPath, kDestDirectoryPath); AssetDatabase.CopyAsset(asset, destFilePath); AssetDatabase.DeleteAsset(asset); AssetDatabase.Refresh(); } } } }使う時は kSrcDirectoryPath、kDestDirectoryPath を、書き換えると良いかと。
※最初にAssetDatabase.CopyAsset()ではなくFile.Copy()でやろうとして時間がかかってしまった;
アセットのコピー(AssetDatabase.CopyAsset VS File.Copy)【Unity】【エディタ拡張】
- 投稿日:2019-12-21T01:07:11+09:00
VRChatで動くポスターを作る
これはVRChat Advent Calendar 2019の21日目の記事です。
昨日は落雷さんのAmplify Shader Editorを用いてメッシュを雪で覆うシェーダーを作るでした。冬も来ましたしいろんな冬なワールドで使えそうですね。
まずはこちらの動画をご覧にください。
ShaderFesのポスターがかっこよかったので動くポスターを書きました。 #ShaderFes #VRChat pic.twitter.com/MNJCvUTXz5
— fotfla (@fotfla) 2019年12月15日このポスターはもともとVRChat上で行われたShaderFes2019にてるらさんが制作したポスターをShaderのみを使って再現し、かつ動きを付けたものです。
もともとはこういったものです。
シェーダーフェス2019の記念品として作成した動くポスター。
— るら/VRC (@Lu_Ra_999) November 13, 2019
シェーダーに関する意匠を取り入れてシックな仕上がりになり、インテリアとしても良い感じです。お家や集会所などで是非貼って下さい!
ShaderFes2019は12/14オープン予定です。#ShaderFes #VRChathttps://t.co/J4kmTeiAHH pic.twitter.com/DQovVLZ7ds物理世界のポスターというのは紙に印刷されていて基本動くものではありません(最近はデジタルサイネージという形でディスプレイに映像を映しているので必ずしもそうではないですが)。そして最近VRChatでもポスターをたくさん見るようになりましたが当たり前ですが動きません。でも動いて欲しくないですか? なのでShaderでポスターを書いていきます。(それこそポスターに限らず3Dモデルの服やなにかのテクスチャをプロシージャルに生成することができる)
Shaderで絵を書く
例えば、Shaderで2D絵を書くといえばこういったものがあります。
今回もここでも紹介されてるディスタンスフィールドで書いていきます。
ディスタンスフィールド
今回使うディスタンスフィールドはある点からその図形までの最小の距離を関数にしたものです。なので図形の線上は0外側は0より大きく、内側は0未満となります。ここで基本的なディスタンスフィールドを紹介します
Circle
円です。rが半径です。
float sdCircle(float2 p, float r){ return length(p) - r; }Box
基本的な長方形です。sが長方形のサイズです。
float sdBox(float2 p, float2 s){ float2 d = abs(p) - s; return lenghth(max(d,0.0)) + min(max(d.x,.dy),0.0); }Line
直線です。a,bを結ぶ直線になります。
float sdLine(float2 p, float2 a, float2 b){ float2 pa = p-a, float2 ba = b-a; float2 h = saturate(dot(pa,ba)/dot(ba,ba)); return length( pa - ba*h ); }Triangle
二等辺三角形です。qの値がfloat2(辺の長さ,高さ)のようになっています。
float sdTriangleIsosceles(float2 p, float2 q ) { p.x = abs(p.x); float2 a = p - q*saturate(dot(p,q)/dot(q,q)); float2 b = p - q*vec2(saturate(p.x/q.x), 1.0); float s = -sign( q.y ); float2 d = min( float2( dot(a,a), s*(p.x*q.y-p.y*q.x) ), float2( dot(b,b), s*(p.y-q.y) )); return -sqrt(d.x)*sign(d.y); }これらの関数を組み合わせると一通りの図形ができます。
これら距離関数は内側が0未満で外側が0より大きく、これらが区別できればいいのでstepやsmoothstepを使えば図形を描画することができます。
例えば円を描画するときにはfloat sphere = sdSphere(uv,1.0) float c = smoothstep(0.001,0.0,sphere); // step(0,sphere)これで円が描画されます。
この他にもShader界では有名なiq氏のサイト(http://www.iquilezles.org/www/articles/distfunctions2d/distfunctions2d.htm) にて他の2Dの距離関数が載っています。ここではGLSLというUnity上で使われてるものと少し違いますが少しの変更で同様に扱えます。
いくつかの描画テクニック
これらの図形だけでは複雑な図形はかけないのでいくつかよく使ったものを紹介します。
min
// 2つの図形を結合します。 float d = min(a,b);max
// a,bの図形の共通部分を取る float d1 = max(a,b);// aの図形からbの図形をくり抜く float d2 max(a,-b);またaの図形の枠線を作りたいときは
// aが距離関数, wが線の太さ float d = abs(a) - w;これらや、他にも回転や繰り返しなど、最初に紹介したシェーダーお絵かきなどで紹介されてるテクニックを使って絵を描いていきます。
書いた絵を動かす
次に絵を絵を動かすことを考えます。基本的にはループアニメーションを考えます。基本的に0~1でループさせることを考えるといろいろなことが楽になると思います。ループの長さは時間のスピードを変えることで調整します。
時間を繰り返すには次の関数を使って表現することができます。float t = _Time.y; // Unity内の時間 float t1 = frac(t); // 0~1を繰り返す float t2 = fmod(t,10.0); // 0~1を繰り返すアニメーションカーブ
アニメションの仕方は時間に対してカーブを書くことで動かし方を自由にできます。
アニメーションの種類の例// アニメーションの速度がSpeed倍 float t = _Time.y * Speed; // tが0~1をループする float t1 = frac(t); // Liner float t2 = pow(t1, a); // a < 1 イーズイン ,a > 1 イーズアウト float t3 = smoothstep(0,1,t1); // イーズイン・アウト float t4 = sin(t) * 0.5 + 0.5 // 0~1を振動でこのように動きます。下の動画は上からt1~t5の関数で四角形をアニメーションさせたものです。
float t = _Time.y; float t1 = frac(t); float t2 = pow(t1, 0.5); float t3 = pow(t1, 2.0); float t4 = smoothstep(0,1,t1); float t5 = sin(t * UNITY_PI - UNITY_PI * 0.5) * 0.5 + 0.5 // t = 0 で他の関数と合うように調整このように動きをつけることでかっこよく見えます。
アニメーションカーブ pic.twitter.com/886ewx0jRD
— fotfla (@fotfla) December 20, 2019アニメーションの遷移
アニメーションの遷移は
clamp(x,a,b)
関数を使うとできます。clamp関数はある数値xをa以下はすべてa、b以上をすべてbにする関数です。これらを例えば下記のように使うことでアニメーションの遷移が実現できます。// tが0~20の間をループする float t = fmod(_Time.y,20.0); // tが0~5の間だけ動く float t1 = clamp(t,0,5); // tが5~15の間だけ動く float t2 = clamp(t - 5,0,10); // tが15~20の間だけ動く float t3 = clamp(t- 15,0,5);でしたのツイートは5秒かけて一番上が移動、その後10秒かけて真ん中が移動、最後に5秒かけて一番下が移動してこれを20秒ループする動画です。
アニメーションの遷移 pic.twitter.com/0hDB9BpuAE
— fotfla (@fotfla) December 20, 2019アニメーションの遷移の場合動く秒数で値を割って0~1を取るように調整しておくと上記のアニメーションカーブと合わせて使いやすくなると思います。
以上で、今回紹介したものを基本的に使って最初に紹介した動画は構成されています。
図形の組み合わせなどでいろいろな絵を描いたり、映像をつくれると思うのでぜひShaderでお絵かきをしてVRChatで動くポスターなど作ってみてください。
紹介したもの
- 投稿日:2019-12-21T00:47:14+09:00
オンラインマルチプレイのUnityでの実装について学ぶ
概要
スマホゲームはオンラインマルチプレイ(オンラインで他のユーザーとリアルタイムな協力・対戦をする)アプリがとても人気。
そういったゲームには大体以下のような機能が実装されていると思われる。
- ユーザーマッチング
- チャット
- ボイスチャット
- 同期通信、レンダリング
これらを実装する上でどのような便利なライブラリ(と呼べばよいのかな?)が世の中に転がっているのか、それを使ってどう実装するのかがとても気になるところ。この記事ではその辺をまとめていきたい。
便利なライブラリ
Tech AcademyのUnityの学習でメンターさんから例えばPUN2というのがありますよ、と聞いていた。
また、"Unity リアルタイム通信"でググると、"MagicOnion"というワードがよく引っかかった。
この2つについて調べてみる1. PUN2(Photon Unity Network2)
そもそもPhotonとは
- リアルアイムネットワーク通信フレームワークの現主流がPhoton。Photon製品は複数あるが、ここを起点にまとめると以下のようになる。
- Photon:マルチプレイを簡単に実現するためのネットワークエンジン。ExitGames社が開発している。マッチング、メッセージの同期送信などができる。
- Photon Server:Photonをオンプレのサーバーで利用するプラン
- Photon Cloud:PhotonをSaasで利用するプラン
- Photon Unity Network2(PUN2):PhotonをUnityで利用するためのパッケージ。サーバーはPhoton Cloudが裏で使われ、フリープランは20人同時接続可能、有料プランは100人同時接続可能らしい。
概要
- リアルタイムネットワーク通信フレームワークの王様PhotonをUnityで扱えるようにしたもので、サーバー側はPhoton Cloudを利用するものらしい。
- 最大20人まで同時接続できるサーバーが無料で利用可能らしい
- プレイヤーは同一ルーム上で遊び、ルーム内に生成したオブジェクトは各端末間で同期することができるらしい
2. MagicOnion
概要
- リアルタイムネットワーク通信のフレームワークらしい(Photonと同じ位置付けのものらしい)
- MagicOnionはクライアント側もサーバー側もC#で統一したいという思想のもと作られているらしい
- クライアントおよびサーバーでC#でインターフェースを定義したファイルを設置、サーバー側でインターフェースを実装したクラスのファイルを設置するとさほどクライアント側で通信処理を意識せずにサーバー側の処理を呼び出せる、という感じらしい
- MagicOnion自体はOSSだが、別途サーバーが必要になるため、個人開発の場合はPhoton Cloudを使うのもありらしい
3. まとめ
PhotonとMagicONIおnは双方ともにリアルタイムネットワーク通信フレームワークである。
主流はPUN2であり、無料で20人まで同時接続できるのがすごい。
とりあえずUnityで個人開発する上では、PUN2を使えばよさげ。マッチング処理
チャット
ボイスチャット
同期通信処理
参考