UIToolkitとPreviewRenderUtilityを使ってオブジェクト配置ツールを作った

オブジェクト画像の表示が欲しい時にPreviewRenderUtilityという存在を知りました    
このPreviewRenderUtilityというクラスはドキュメントすらないクラスのため   
今回は備忘録としてUIElementのOverlayを使用してSceneViewのみで完結するオブジェクト配置エディタを作成しました  

Unityバージョンは2022 3.22f1です

# アタッチするオブジェクトの階層構造について
今回作るオブジェクト配置ツールは
ObjectFieldに入れるPrefabの階層構造は     
以下のようになっていないと動作しないようになっています

Prefabのルート  
┗配置したいオブジェクト1   
┗配置したいオブジェクト2   
┗配置したいオブジェクト3...   


# Overlayの作成

## PlacementData
```C#
 public class PlacementData
    {
        public string Name { get;private set; }
        public Texture2D PreviewImage { get;private set; }
        public GameObject Prefab { get;private set; }

        public PlacementData(Texture2D texture,GameObject prefab)
        {
            Name = prefab.name;
            PreviewImage = texture;
            Prefab = prefab;
        }
    }
```
PlacementObjectItem内に必要なデータのクラスです
## PlacementObjectView

```C#
[Overlay(typeof(SceneView), "配置ツール", true)]
    public class PlacementObjectView : Overlay
    {
        private VisualElement m_Root;
        private ScrollView m_ScrollView;
        private List<PlacementObjectItem> m_Items = new List<PlacementObjectItem>();

        public override VisualElement CreatePanelContent()
        {
            var root = new VisualElement();
            
            root.style.flexDirection = FlexDirection.Column;
            var placementObjectViewToolbar = new PlacementObjectViewToolbar();
            root.style.width = 800;
            m_ScrollView = new ScrollView();
            m_ScrollView.style.marginTop = 10;
            placementObjectViewToolbar.OnSelectObject += OnSelectObject;
            placementObjectViewToolbar.OnSearchObject += OnSearchObject;
            root.Add(placementObjectViewToolbar);
            root.Add(m_ScrollView);
            m_Root = root;
            return m_Root;
        }

        private void OnSearchObject(string keyword)
        {
            if( m_Items.Count == 0) return;
            
            if (keyword == "")
            {
                foreach (var item in m_Items)
                {
                    item.style.display = DisplayStyle.Flex;
                }
                return;
            }

            foreach (var item in m_Items)
            {
                DisplayStyle display = DisplayStyle.Flex;
                if (item.name.IndexOf(keyword,StringComparison.OrdinalIgnoreCase) >= 0) display = DisplayStyle.Flex;
                else display = DisplayStyle.None;
                
                item.style.display = display;
            }
        }
        private void OnSelectObject(GameObject obj)
        {
            m_ScrollView.contentContainer.Clear();
            m_ScrollView.Clear();
            m_Items.Clear();
            if (obj == null)
            {
                m_ScrollView.style.height = Length.Auto();
                return;
            }
            VisualElement itemWrapper = new VisualElement();
            itemWrapper.style.flexDirection = FlexDirection.Row;
            itemWrapper.style.flexWrap = Wrap.Wrap;
            var generator = new PlacementDataGenerator();
            PlacementData[] datas = generator.GenerateDatas(obj);
            generator.Dispose();
            if (datas == null) return;          
            foreach (PlacementData data in datas)
            {
                var item = new PlacementObjectItem(data);
                m_Items.Add(item);
                itemWrapper.Add(item);
            }
            m_ScrollView.contentContainer.Add(itemWrapper);
            m_ScrollView.style.height = 350;

        }
    }
```
メインのOverlayです   
Toolbar内のObjectFieldにPrefabがアタッチされるとActionを受け取りScrollViewの中を更新します

## PlacementObjectViewToolbar
```C#
 public class PlacementObjectViewToolbar : VisualElement
{
    public Action<GameObject> OnSelectObject { get; set; }

    public Action<string> OnSearchObject { get; set; }

    public PlacementObjectViewToolbar()
    {
        this.style.marginTop = 5;
        this.style.flexDirection = FlexDirection.Row;
        this.style.justifyContent = Justify.SpaceBetween;
        ObjectField objectField = new ObjectField();
        this.Add(objectField);
        objectField.objectType = typeof(GameObject);
        objectField.RegisterValueChangedCallback(OnObjectRegisterdValue);
        objectField.UnregisterValueChangedCallback(OnObjectUnRegisterdValue);

        ToolbarSearchField searchField = new ToolbarSearchField();
        this.Add(searchField);
        searchField.RegisterValueChangedCallback(OnSearchFieldRegisterdValue);
        searchField.UnregisterValueChangedCallback(OnSearchFieldUnRegisterdValue);
    }

    private void OnObjectRegisterdValue(ChangeEvent<Object> value)
    {
        OnSelectObject?.Invoke(value.newValue.GameObject());
    }

    private void OnObjectUnRegisterdValue(ChangeEvent<Object> value)
    {
        OnSelectObject?.Invoke(null);
    }

    private void OnSearchFieldRegisterdValue(ChangeEvent<string> value)
    {
        OnSearchObject?.Invoke(value.newValue);
    }

    private void OnSearchFieldUnRegisterdValue(ChangeEvent<string> value)
    {
        OnSearchObject?.Invoke("");
    }
}
```
ObjectFieldと検索バーをOverlayToolbarでまとめたクラスです

## PlacementObjectItem
```C#
public class PlacementObjectItem :Button
{
    private GameObject m_Prefab; 
    
    public PlacementObjectItem(PlacementData data)
    {
        this.style.width = this.style.height = 100;
        m_Prefab = data.Prefab;
        this.style.backgroundImage = data.PreviewImage;
        Label label = new Label();
        label.text = data.Name;
        
        this.Add(label);
        this.clicked += OnClicked;
        this.name = data.Name;
    }

    private void OnClicked()
    {
        Debug.Log($"OnClicked {m_Prefab.name}");
        var obj = GameObject.Instantiate(m_Prefab);
        obj.name = obj.name.Replace("(Clone)","");
        obj.transform.position = SceneView.lastActiveSceneView.camera.transform.forward * 3;
    }
}
```
Buttonとオブジェクト名を入れているだけです    
今回はPlacementObjectViewをごちゃごちゃにしたくないのと   
Instantiate後クラスで管理する必要は全くないのでこのクラスに押下後の動作も記入しています

## PlacementDataGenerator
```C#
 public class PlacementDataGenerator:IDisposable
{
    private const float OBJECT_DISTANCE = 3f;
    private const float OBJECT_PREVIEW_SIZE_FACTOR = 0.6f;

    private PreviewRenderUtility m_PreviewRenderUtility = new();

    public PlacementData[] GenerateDatas(GameObject objects)
    {
        GameObject parentObject = m_PreviewRenderUtility.InstantiatePrefabInScene(objects);
        m_PreviewRenderUtility.AddSingleGO(parentObject);
        m_PreviewRenderUtility.camera.clearFlags = CameraClearFlags.Depth;
        m_PreviewRenderUtility.camera.orthographic = true;
        
        List<GameObject> childs = new List<GameObject>();
        List<PlacementData> placementDatas = new List<PlacementData>();

        parentObject.transform.Cast<Transform>().ToList().ForEach(child => child.gameObject.SetActive(false));
        for (int i = 0; i < parentObject.transform.childCount; i++)
        {
            GameObject child = parentObject.transform.GetChild(i).gameObject;
            Texture2D image = CaptureObjectImage(child);
            placementDatas.Add(new PlacementData(image,objects.transform.GetChild(i).gameObject)); 
        }
        return placementDatas.ToArray();
    }
    private Texture2D CaptureObjectImage(GameObject child)
    {
        child.SetActive(true);
        Bounds bounds = GetRenderersBounds(child);
        //NOTE:child.transform.rightをしているのは今回使用しているアセットの大半がright方向を向いていたためです
        //本来はforwardにしています
        m_PreviewRenderUtility.camera.transform.position = bounds.center + child.transform.right * OBJECT_DISTANCE;
        m_PreviewRenderUtility.camera.transform.LookAt(bounds.center);
        m_PreviewRenderUtility.camera.orthographicSize = bounds.size.magnitude / (OBJECT_DISTANCE * OBJECT_PREVIEW_SIZE_FACTOR);
        m_PreviewRenderUtility.BeginStaticPreview(new Rect(0, 0, 400, 400));
        m_PreviewRenderUtility.Render();
        child.SetActive(false);
        return m_PreviewRenderUtility.EndStaticPreview();
    }
    private Bounds GetRenderersBounds(GameObject child)
    {
        Renderer[] renderers = child.GetComponentsInChildren<Renderer>();
        bool first = true;
        Bounds totalBounds = new Bounds();
        foreach (var renderer in renderers)
        {
            if (first)
            {
                totalBounds = renderer.bounds;
                first = false;
                continue;
            }
            totalBounds.Encapsulate(renderer.bounds);
        }
        return totalBounds;
    }
    public void Dispose()
    {
        m_PreviewRenderUtility.Cleanup();
    }
}
```
入力されたオブジェクトからPlacementDataを作成するためのクラスです     
PreviewRenderUtilityはこのクラスで使用されています

## PreviewRenderUtilityについての解説
以下のリンクでソースの中身は見れます

https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/Inspector/PreviewRenderUtility.cs

今回具体的に実装したのはプレビュー画像の生成部分でした     
解説していきます

```C#:PlacementDataGenerator.cs
GameObject parentObject = m_PreviewRenderUtility.InstantiatePrefabInScene(objects);
m_PreviewRenderUtility.AddSingleGO(parentObject);
m_PreviewRenderUtility.camera.clearFlags = CameraClearFlags.Depth;
m_PreviewRenderUtility.camera.orthographic = true;
```
まず入力されたオブジェクトをインスタンス化します       
Editorでの生成ですのでInstantiatePrefabInSceneを使用してインスタンス化し     
AddSignalGOで入れてあげるとシーンにオブジェクトが追加されます    
PreviewRenderUtilityではcameraが取得できるようなのでカメラの調整を行います

```C#:PlacementDataGenerator.cs
private Bounds GetRenderersBounds(GameObject child)
{
    Renderer[] renderers = child.GetComponentsInChildren<Renderer>();
    bool first = true;
    Bounds totalBounds = new Bounds();
    foreach (var renderer in renderers)
    {
        if (first)
        {
            totalBounds = renderer.bounds;
            first = false;
            continue;
        }
        totalBounds.Encapsulate(renderer.bounds);
    }
    return totalBounds;
}
```
GetRenderersBounds関数内は使用したアセットの家具が細かいパーツに分かれていたので   
子にある全てのRendererを取得しBoundsの合成をしてBoundsとして返すような感じです   
こうすることで子階層にあるパーツをすべて含んだ中央の位置を取得できます
```C#:PlacementDataGenerator.cs
private Texture2D CaptureObjectImage(GameObject child)
{
    child.SetActive(true);
    Bunds bounds = GetRenderersBounds(child);
    m_PreviewRenderUtility.camera.transform.position = bounds.center + child.transform.right * OBJECT_DISTANCE;
    m_PreviewRenderUtility.camera.transform.LookAt(bounds.center);
    m_PreviewRenderUtility.camera.orthographicSize = bounds.size.magnitude / (OBJECT_DISTANCE * OBJECT_PREVIEW_SIZE_FACTOR);
    m_PreviewRenderUtility.BeginStaticPreview(new Rect(0, 0, 400, 400));
    m_PreviewRenderUtility.Render();
    child.SetActive(false);
    return m_PreviewRenderUtility.EndStaticPreview();
}o
```
ここがメインの撮影処理です
カメラをオブジェクトのforward方向(今回は正面がrightだったのでright方向)に移動させて     
LookAtでBoundsの中央の方向を向かせています     
orthographicSizeは bounds.size.magnitudeとカメラとオブジェクトとの距離を割ってあげるとぴったりのサイズになるので     
良い感じに倍率かけて調整してあげてください
BeginStaticPreviewとRenderを実行した後         
EndStaticPreviewの帰り値でTexture2Dが返ってくるのでそれをオブジェクトの画像として利用しています    
最後に使用した後はCleanupという関数を呼び出さないとエラーが出るので呼び出してあげましょう    
今回はIDisposableに実装しました
## 完成
こんな感じで出来上がりました  
UIToolkitはコードだけでも完結するのがとても良いところです   
配置するオブジェクトが多くてProjectからPrefabを探してHierarchyにドロップするのが面倒な方はぜひ作ってみてください
![完成映像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/915230/6d6c0b81-b0d5-9cc3-d67e-0a407e8f8cdb.gif)