はじめに
IMGUIって何?
IMGUIはコードで制御されるGUIシステムで、主にプログラマー用のツールです。
IMGUIはCanvasやGameObjectを使わず、コードだけでUIを生成し、ユーザーの入力を受け取ってくれます。UIアセットを作成し、Canvasを作成してアセットを配置し、コードからアクセスするのは面倒なのでIMGUIを使います。
IMGUIをリリースビルドでも使いたい!
Unity標準のIMGUIはデバッグ用で、パフォーマンスがものすごーく悪いです。しかし実装内容によっては、IMGUIを使いたい場合があります。
自分の場合だと画像のような3Dモデルの編集可能頂点を示したい場合に、いちいちCanvasの要素にアクセスしていられないという状況になりました。
GUI.Buttonを使って実装していましたが、パフォーマンスが非常に悪く、またリリースに向けてCanvasを利用したコードを新たに書くのが嫌すぎたので、爆速のGUI.Buttonを作ることにしました。
GPU instancingのためにGraphics.RenderPrimitives使ってます!!
コード例が少ない(?)から有用な記事かも!?
実装
設計
GUI.Buttonは「Rect、ボタンに表示する内容」の2つの入力に対し、画面にボタンを表示し、ボタンがクリックされたかどうかを出力します。これを描写の度に呼び出されるMonobehaviourのOnGUIで呼び出すという使い方をします。
私の作ったIMGUI.RectButtonは最低限のサンプルとして、Rectのみを入力に、ボタンを表示し、ボタンがクリックされたかどうかを出力します。描写に使用した「Graphics.RenderPrimitives」はUpdateで呼び出すと調子が良かったのでOnGUIではなくUpdate関数内で呼び出されるように設計しました。
IMGUI.cs
GUI管理C#コード。
ゲーム開始時に呼び出される初期化関数でGameObjectを作成し、Updateを受け取れるようにしている。
アクセスできるのは描写申請関数「IMGUI.RectButton」のみ。これが呼び出されると描写用リストに描写用の情報が追加される。
Update関数内でリスト内の描写用情報をComputeBufferに詰めて描写関数に渡す。描写用の情報はMaterialPropertyBlock rectPropertyBlock を通じてshaderのStructuredBufferに渡される。描写が終わると描写用情報リストをクリアする。
バッファは頻繁に確保解放したらだめなんじゃない? という気持ちで上限を超えた場合に2倍の数を確保する設計。
RenderPrimitivesについて、頂点の数をトロポジ数の倍数以外にするとシェーダー側のSV_Indexの呼び出しが変なことになる。またトロポジをQuad、頂点数を4にすると、3頂点ずつ2回の呼び出しが発生する
IMGUI.cs コード
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
using System.Linq;
public class IMGUI:MonoBehaviour
{
public static IMGUI monoInstance;
private static Material rectMaterial;
private static RenderParams rectRenderParams;
private static MaterialPropertyBlock rectPropertyBlock;
private const int rectVertexCount = 6;// 二つの三角形用の6つ
private static List<Rect> rectList = new();
private static ComputeBuffer rectBuffer;
[RuntimeInitializeOnLoadMethod]
static void RuntimeInitialize()
{
GameObject go = new GameObject();
monoInstance = go.AddComponent<IMGUI>();
rectMaterial = Resources.Load<Material>("IMGUI/RectMaterial");
rectPropertyBlock = new();
rectRenderParams = new(rectMaterial);
rectRenderParams.matProps = rectPropertyBlock;
}
struct RectShader
{
float minX;
float minY;
float width;
float height;
public RectShader(Rect input)
{
minX = input.min.x;
minY = input.min.y;
width = input.width;
height = input.height;
}
}
public static bool RectButton(Rect rect)
{
rectList.Add(rect);
return rect.Contains(Input.mousePosition) && Input.GetMouseButtonUp(0);
}
void Update()
{
if (rectList.Any())
{
SetRectBuffer();
Graphics.RenderPrimitives(in rectRenderParams, MeshTopology.Triangles, rectVertexCount, rectList.Count);
}
rectList.Clear();
void SetRectBuffer()
{
if ( rectBuffer==null || rectBuffer.count < rectList.Count)
{
rectBuffer?.Dispose();
rectBuffer = new ComputeBuffer(rectList.Count*2, Marshal.SizeOf(typeof(RectShader)));
}
rectBuffer.SetData(rectList);
rectPropertyBlock.SetBuffer("rectBuffer", rectBuffer);
}
}
void OnDestroy()
{
rectBuffer?.Dispose();
rectBuffer = null;
Debug.LogWarning("もしゲーム終了時以外ならば、不正な呼び出しです");
}
}
IMGUI_Rect.shader
シェーダーコードではSV_InstanceIDとSV_VertexIDのセマンティクスを利用してStructuredBufferの情報を利用して描写する。
Graphics.RenderPrimitivesで「Triangle、頂点数6、インスタンス数2」を入力した場合、1つのインスタンスにつき6/3=2 で2つの三角形があることになる。インスタンス数が2なので合計4つの三角形描写が行われる。
(頂点インデックス)[インスタンスインデックス]として上記の呼び出し例を示すと
(0,1,2)[0]
(3,4,5)[0]
(0,1,2)[1]
(3,4,5)[1] というような感じになる。
vertシェーダー内では効率のため、直感的ではないコードを書いているが、やっていることは6種類の頂点インデックスに対してRectの四隅の座標を出力しているだけである。(ヒント:0~5の入力を0~3に落とし込んでいる。)if文を使って各インデックスに単純に割り当てるだけで動作するので安心して欲しい。
fragシェーダー内ではuv座標を利用した実装の可能性を示すために、uv座標が0と1に近い場合は黒、それ以外は青を出力するようにしている。単純にfixed4 frag (v2f input) : SV_Target{return 0} としても動作確認ができる。
IMGUI_Rect.shader コード
Shader "IMGUI/Rect"
{
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Transparent" }
LOD 100
Cull Off
Pass
{
ZTest Always
Name "IMGUI_Rect"
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct Rect
{
float minX,minY,width,height;
};
StructuredBuffer<Rect> rectBuffer;
struct appdata
{
float4 vertex : POSITION;
uint instanceID : SV_InstanceID;
uint index : SV_VertexID;//Unity 指定インデックスの大きさは6にしてね
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv:TEXCOORD0;
};
v2f vert (appdata input)
{
v2f output;
Rect rect = rectBuffer[input.instanceID];
int index = input.index;
if(2<index)index = index%3+1;
float2 uv01 = float2(index%2 ==1,index/2 ==0);
float2 wh = float2(rect.width,rect.height);
float2 minPos = float2(rect.minX,rect.minY);
output.vertex = (0,0,0,1);
output.vertex.xy = (minPos + uv01 * wh)/_ScreenParams;
output.vertex.xy = 2*output.vertex.xy -1;//空間変換
output.vertex.y *= -1;//上下変換
output.uv = uv01;
return output;
}
bool UVBorder(float x, float border)
{
return (x <= border) || (x >= 1.0 - border);
}
fixed4 frag (v2f input) : SV_Target
{
bool border = UVBorder(input.uv.y,0.1)||UVBorder(input.uv.x,0.1);
return border? 0 : float4(0, 0, 1, 0);
}
ENDCG
}
}
}
Resources/IMGUI/RectMaterial.material
新規マテリアルを作成し、上記のシェーダーを設定したものを、アセット下、Resources/IMGUI/RectMaterial.materialというファイル構成になるように配置してください。
パフォーマンス比較
自作関数とGUI.Buttonでボタンを60個くらい?表示して比較しました。
関数名 | セットパスコール | 表示 |
---|---|---|
GUI.Button | 870回 | 10.4ms |
IMGUI.RectButton | 2回 | 4.0ms |
GPUインスタンシングのおかげでセットパスコールが大幅に削減され、約2.5倍の高速化を達成しました。
GUI.Button (Unityデバッグ用)
使い方
Rect buttonRect = なんとかかんとか;
bool pushed = GUI.Button(buttonRect,"C");//"押"とかでも可
if(pushed)
{
押された時の処理
}
IMGUI.RectButton (自作関数)
使い方
Rect buttonRect = なんとかかんとか;
bool pushed = IMGUI.RectButton(buttonRect);
if(pushed)
{
押された時の処理
}
最後に
UnityのGUI.Buttonは半透明だし、文字やテクスチャを表示できます。それを実現するにはシェーダに渡す構造体を変更し、シェーダーを書き換える必要があります。しかしそれを解説する気はないので自力で頑張って下さい。
おまけ GPUインスタンシングプログラマの気持ち
「materialは何のシェーダーかの情報、シェーダー共通のプロパティ情報の入れ物」
「shader描写にverticesとかindex(mesh.triangles)とかいらなくね? そもそもMeshいらん」
「インスタンス変わるたびに新しいマテリアル作ってシェーダー割り当てんのだるー」→「プロパティだけ入れて変更できるMaterialPropertyBrock使うのだ」
「(GPU)インスタ(ンシング)映え~」