0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

アタッチするオブジェクトの階層構造について

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

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

Overlayの作成

PlacementData

 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

[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

 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

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

 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についての解説

以下のリンクでソースの中身は見れます

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

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が取得できるようなのでカメラの調整を行います

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として返すような感じです
こうすることで子階層にあるパーツをすべて含んだ中央の位置を取得できます

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にドロップするのが面倒な方はぜひ作ってみてください
完成映像

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?