【Unity】EditorWindowで表を表示する

はじめに

Unityで自作したEditor Windowで収集した情報を表形式で表示したいことがあったので、TreeViewを使って実装してみました。
この記事ではサンプルとしてシーン内のTransformの情報を表示するツールを作成しています。

実装

行単位のデータを表すクラスを作成する

表示要素を表すクラスではTreeViewItemを継承します。後ほどTreeViewにはこの型のリストを渡します。
今回はTransformの情報を表示したいのでPosition, Rotation, Scaleをstringで取得できるようにしました。

public class TransformTableItem : TreeViewItem
{
    private Transform _transform;

    // 表示要素
    public Transform TransformObj => _transform;

    public string PositionStr
    {
        get
        {
            if (_transform == null)
            {
                return string.Empty;
            }

            return $"({_transform.position.x}, {_transform.position.y}, {_transform.position.z})";
        }
    }

    public string RotationStr
    {
        get
        {
            if (_transform == null)
            {
                return string.Empty;
            }

            return $"({_transform.rotation.x}, {_transform.rotation.y}, {_transform.rotation.z})";
        }
    }

    public string ScaleStr
    {
        get
        {
            if (_transform == null)
            {
                return string.Empty;
            }

            return $"({_transform.localScale.x}, {_transform.localScale.y}, {_transform.localScale.z})";
        }
    }

    public TransformTableItem(int id, Transform transform) : base(id, 0, transform.name)
    {
        _transform = transform;
    }
}

表を表すクラスを作成する

後ほどEditorWindowで使用する表部分を表すクラスを、TreeViewを継承して作成します。
コンストラクタで受け取るitemListは表示要素のリスト、TreeViewStateはスクロール位置などを表すオブジェクトです。

    public TransformTableView(TreeViewState state, List<TransformTableItem> itemList) : base(state, CreateHeader())
    {
        _itemList = itemList;
        // 背景色を交互にする
        showAlternatingRowBackgrounds = true;
        // 枠線あり
        showBorder = true;
        Reload();
    }
ルート要素の作成

TreeViewの表示要素を子として持つルートオブジェクトを作成します。

    protected override TreeViewItem BuildRoot()
    {
        // ルートを作って表示したい要素をその子にする
        var root = new TreeViewItem { id = 0, depth = -1, displayName = "root" };
        foreach (var item in _itemList)
        {
            root.AddChild(item);
        }
        return root;
    }
見出しと内部の表示

CreateHeader関数でカラム名などを設定します。表示幅や最小/最大幅などもここで設定できます。
RowGUI関数では各行の内容を描画します。args.GetCellRect(列番号)でその行、列に対応するRectが取得でき、LabelFieldに限らずObjectFieldなどを使った柔軟な表示が可能でした。

    /// <summary>
    /// 見出し行を作成
    /// </summary>
    /// <returns></returns>
    private static MultiColumnHeader CreateHeader()
    {
        var columnArray = new[]
        {
            new MultiColumnHeaderState.Column() {headerContent = new GUIContent("Name"), width = 200},
            new MultiColumnHeaderState.Column() {headerContent = new GUIContent("Position"), width = 200},
            new MultiColumnHeaderState.Column() {headerContent = new GUIContent("Rotation"), width = 200},
            new MultiColumnHeaderState.Column() {headerContent = new GUIContent("LocalScale"), width = 200},
        };

        var headerState = new MultiColumnHeaderState(columnArray);
        return new MultiColumnHeader(headerState);
    }

    protected override void RowGUI(RowGUIArgs args)
    {
        var item = args.item as TransformTableItem;

        EditorGUI.ObjectField(args.GetCellRect(0), item.TransformObj, typeof(Transform), true);
        EditorGUI.LabelField(args.GetCellRect(1), item.PositionStr);
        EditorGUI.LabelField(args.GetCellRect(2), item.RotationStr);
        EditorGUI.LabelField(args.GetCellRect(3), item.ScaleStr);
    }

    protected override float GetCustomRowHeight(int row, TreeViewItem item)
    {
        return EditorGUIUtility.singleLineHeight + 2;
    }

EditorWindowに組み込む

表示側ではシーン内のTransformを取得して表示要素のオブジェクトを作成し、TreeViewに渡す処理を実装しました。
検索窓を追加し、名前により絞り込みを行えるようにしています。

    private void OnGUI()
    {
        if (!_isInitialized)
        {
            Initialize();
        }

        var searchRect = EditorGUILayout.GetControlRect(false, GUILayout.ExpandWidth(true), GUILayout.Height(EditorGUIUtility.singleLineHeight));
        _tableView.searchString = _searchField.OnGUI(searchRect, _tableView.searchString);

        var tableRect = EditorGUILayout.GetControlRect(false, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true));
        _tableView.OnGUI(tableRect);

        if (GUI.Button(EditorGUILayout.GetControlRect(false, GUILayout.Width(100), GUILayout.Height(EditorGUIUtility.singleLineHeight)), "更新"))
        {
            UpdateTable();
        }
    }

    private void Initialize()
    {
        if (_treeViewState == null)
        {
            _treeViewState = new TreeViewState();
        }
        if (_searchField == null)
        {
            _searchField = new SearchField();
        }

        UpdateTable();

        _isInitialized = true;
    }

    /// <summary>
    /// 表を更新
    /// </summary>
    private void UpdateTable()
    {
        // シーン内のTransformを取得
        var transformArray = FindObjectsByType<Transform>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);

        // 表に表示する要素を作成
        var tableItemList = new List<TransformTableItem>();
        for (var i = 0; i < transformArray.Length; i++)
        {
            var transform = transformArray[i];
            tableItemList.Add(new TransformTableItem(i, transform));
        }

        _tableView = new TransformTableView(_treeViewState, tableItemList);
    }

追記: ソートに対応する

ヘッダー行をクリックしたときにそのカラムでソートされるようにします。

ヘッダー行の生成処理でソートを有効にする

MultiColumnHeaderState.Columnでソートの有効/無効を指定するフィールドをtrueにします

new MultiColumnHeaderState.Column() {headerContent = new GUIContent("Position"), width = 200, canSort = true }
ソート処理を追加する

ソート処理時に呼ばれる関数で対象のカラムのindex, 昇順/降順のどちらかが取得できるので必要な列のソート処理を実装します。
処理後は表示中の要素を消去してソート後の要素を登録するようにしないと"InvalidOperationException: Number of rows does not match number of row rects."のようなエラーが発生します。

   /// <summary>
   /// ソート設定変更時の処理
   /// </summary>
   private void OnSortingChanged(MultiColumnHeader header)
   {
       int columnIndex = header.sortedColumnIndex;
       if (columnIndex == -1)
       {
           return;
       }

       var isAscending = header.IsSortedAscending(columnIndex);
       // ソート実行
       SortItem(columnIndex, isAscending);

       Repaint();
   }

   private void SortItem(int columnIndex, bool ascending)
   {
       if (!(rootItem is TreeViewItem root))
       {
           return;
       }

       var childItemList = root.children;
       if (childItemList == null)
       {
           return;
       }

       System.Comparison<TreeViewItem> comparison = null;

       switch (columnIndex)
       {
           case 1: // Position
               // x座標でソート
               comparison = (a, b) =>
               {
                   if ((a is TransformTableItem itemA) && (b is TransformTableItem itemB))
                   {
                       return itemA.Position.x.CompareTo(itemB.Position.x);
                   }
                   
                   return ascending ?  -1 : 1;
               };
               break;

           default:
               Debug.LogWarning($"未対応のソート columnIndex: {columnIndex}");
               break;
       }

       if (comparison != null)
       {
           childItemList.Sort(comparison);

           if (!ascending)
           {
               childItemList.Reverse();
           }

           // ソート後の要素を登録しなおす
           rootItem.children = childItemList;
           var rows = GetRows();
           rows.Clear();
           foreach (var item in rootItem.children)
           {
               rows.Add(item);
           }
       }
   }

また、ソート時処理が呼ばれるようにmultiColumnHeaderのデリゲートを設定します

    public TransformTableView(TreeViewState state, List<TransformTableItem> itemList) : base(state, CreateHeader())
    {
        _itemList = itemList;

        // 背景色を交互にする
        showAlternatingRowBackgrounds = true;
        // 枠線あり
        showBorder = true;

        // ソート時処理を追加
        multiColumnHeader.sortingChanged += OnSortingChanged;

        Reload();
    }

一通り実装するとヘッダー行をクリックすることでソートが行えるようになります

おわりに

TreeViewを使うと見栄えの良い表形式のUIが簡単に作れました。
今回は使いませんでしたが要素を階層構造にすることも可能なのでまだまだ活用の幅がありそうですね!

【Unity】MemoryProfilerからメモリ使用量をプログラムで取得する

はじめに

UnityのMemory Profilerではメモリ使用量の計測やスナップショット間の比較ができて便利なのですが、メモリの内訳をCSVなどに出力できたらより便利だと思っていました。
今回はスナップショット間の比較から内訳の部分をプログラムから取得できるようにしました。

記事中ではMemory Profilerのバージョン1.1.5を使用しています。

メモリの内訳を取得する

リフレクションを用いてMemory ProfilerのEditorWindowのインスタンスを取得し、最終的に"Compare Snapshots" > "Unity Objects"のTreeViewに表示されている要素を取得します。

Memory Profiler Windowを取得する

Memory ProfilerのEditorWindowを取得します。別アセンブリでinternalクラスなのでGetTypeの引数ではアセンブリ名を直接指定しています。

        var memoryProfilerWindowType = Type.GetType("Unity.MemoryProfiler.Editor.MemoryProfilerWindow, Unity.MemoryProfiler.Editor");
        var memoryProfilerWindow = EditorWindow.GetWindow(memoryProfilerWindowType);

TreeViewの表示要素のうち親要素を取得する

TreeViewの表示要素は階層構造になっています。表示要素のプロパティから子要素にアクセスできるのでまず親要素のリストを取得します。

        // ViewControllerを取得
        var viewControllerName = "m_ProfilerViewController";
        var viewControllerField = memoryProfilerWindow.GetType().GetField(viewControllerName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        var viewController = viewControllerField.GetValue(memoryProfilerWindow);
        Debug.Log($"ViewControllerを取得: {viewController != null}");

        // AnalysysViewControllerを取得
        var analysisViewControllerName = "m_AnalysisViewController";
        var analysisViewControllerField = viewController.GetType().GetField(analysisViewControllerName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        var analysisViewController = analysisViewControllerField.GetValue(viewController);
        Debug.Log($"AnalysisViewControllerを取得: {viewController != null}");

        // ComparisonViewControllerを取得
        var optionListFieldName = "m_Options";
        var optionListField = analysisViewController.GetType().GetField(optionListFieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        var optionList = (IList)optionListField.GetValue(analysisViewController);
        var comparisonViewOption = optionList[1];

        var comparisonViewFieldName = "ViewController";
        var comparisonViewField = comparisonViewOption.GetType().GetProperty(comparisonViewFieldName, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
        var comparisonViewController = comparisonViewField.GetValue(comparisonViewOption);
        Debug.Log($"ComparisonViewControllerを取得: {comparisonViewController != null}, list: {optionList.Count}");

        // TreeView部分のModelクラスを取得
        var treeModelFieldName = "m_Model";
        var treeModelField = comparisonViewController.GetType().GetField(treeModelFieldName, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        var treeModel = treeModelField.GetValue(comparisonViewController);
        Debug.Log($"TreeModelを取得: {treeModel != null}");

        var rootNodesPropertyName = "RootNodes";
        var rootNodesProperty = treeModel.GetType().GetProperty(rootNodesPropertyName, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
        var rootNodes = (IList)rootNodesProperty.GetValue(treeModel);
        Debug.Log($"RootNodesを取得: {rootNodes != null}");

各要素の名前とサイズを取得する

最後に各オブジェクトの名前とサイズの差分を取得します

       private const int TO_MEGABYTE_DENOMINATOR = 1024 * 1024;

        var dataPropertyName = "data";
        var dataProperty = rootNodes[0].GetType().GetProperty("data", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
        var dataPropertyValue = dataProperty.GetValue(rootNodes[0]);

        // 名前とメモリサイズの差分を取得
        var namePropertyName = "Name";
        var nameProperty = dataPropertyValue.GetType().GetProperty(namePropertyName, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
        var sizeDeltaPropertyName = "SizeDelta";
        var sizeDeltaProperty = dataPropertyValue.GetType().GetProperty(sizeDeltaPropertyName, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
        foreach ( var rootItemData in rootNodes)
        {
            // itemData->children
            var data = dataProperty.GetValue(rootItemData);
            var nameValue = (string)nameProperty.GetValue(data);
            var sizeDeltaValue = (long)sizeDeltaProperty.GetValue(data);
            Debug.Log($"<Root> Name: {nameValue} SizeDelta: {((float)sizeDeltaValue / TO_MEGABYTE_DENOMINATOR):F2} MB");

            var childrenPropertyName = "children";
            var childrenProperty = rootItemData.GetType().GetProperty(childrenPropertyName, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance);
            var childItemList = (IList)childrenProperty.GetValue(rootItemData);
            foreach ( var childItem in childItemList)
            {
                var childItemData = dataProperty.GetValue(childItem);
                var childItemName = (string)nameProperty.GetValue(childItemData);
                var childSizeDeltaValue = (long)sizeDeltaProperty.GetValue (childItemData);
                Debug.Log($"<Child> Name: {childItemName} SizeDelta: {((float)childSizeDeltaValue / TO_MEGABYTE_DENOMINATOR):F2} MB");
            }

これで下のようにメモリ使用量の情報をConsoleに出力できるようになります。

おわりに

今回の実装でMemory Profilerからメモリ使用量の情報をプログラムで取得できるようになりました。次は取得した情報をCSVなどとして出力できるようにしたいと思います。

【Unity】キャラクターモデルに縁取りとドロップシャドウをつける

はじめに

プリキュアシリーズのEDはCGアニメで作られるのが定番で、2025年1月まで放送されていた『わんだふるぷりきゅあ!』の後期EDは2D風の演出が印象的でした。
このEDの中で使われていたキャラクターに縁取りとドロップシャドウをつける加工をUnityで作ってみたので紹介します。

実装の流れ

今回作るもの

EDムービーを見ると、キャラクターの縁取りは腕と髪などモデルの重なった部分には無くスクリーン上の境界部分に描かれています。このような縁取りは以前実装した背面法では描けないため、今回は次の流れで縁取りとドロップシャドウを描くこととします。

①キャラクターのアウトラインを背面法で描く
アウトラインを背面法で描きます。後からキャラクターが描画された部分を区別できるようにステンシルを書き込みます。

②キャラクター部分をマスクするテクスチャを作成する
後でキャラクターのエッジ検出に使うため、キャラクター部分をマスクするテクスチャを作成します


③エッジを検出する
ポストエフェクトとしてキャラクターのエッジを検出し、縁取りを描きます

④ドロップシャドウをつける
エッジの位置をずらしてドロップシャドウをつけます

①キャラクターのアウトラインを背面法で描く

アウトラインは背面法で描きますが、後からキャラクターの部分を区別するためステンシル値として100を書き込むようにしています。

        Stencil
        {
            Ref 100
            Comp Always
            Pass Replace
        }
②キャラクター部分をマスクするテクスチャを作成する

ここから先はポストエフェクトとして処理します。実装はこちらの記事を参考にしました。
ステンシルをテクスチャに書き出してポストエフェクトで使う【URP14】

ScriptableRenderPassではキャラクターをマスクするRenderTextureを持っています。

public class StencilToTexturePass : ScriptableRenderPass
{
    private readonly Material _stencilToTextureMaterial;
    private readonly Material _postEffectMaterial;
    private  RenderTexture _renderTexture;
    private RTHandle _cameraRenderTargetHandle;

    public StencilToTexturePass(Material stencilToTextureMaterial, Material postEffectMaterial)
    {
        _stencilToTextureMaterial = stencilToTextureMaterial;
        _postEffectMaterial = postEffectMaterial;

        renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    }

    public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
    {
        if (_renderTexture == null)
        {
            _renderTexture = new RenderTexture(cameraTextureDescriptor);
            _renderTexture.Create();
        }
    }

始めはConfigureでcameraTextureDescriptorから取得したwidth, heightを_renderTextureに代入する方法を取っていたのですが、「Invalid output merger - Depth target is differend size or MS count to render target(s)」のエラーが発生したため上記の形に修正しました。

Executeでは「キャラクターを白でマスクしたテクスチャを作成→作成したテクスチャを縁取りをつけるポストエフェクトのシェーダーにセット→縁取りの描画」の順に処理します。

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        if (renderingData.cameraData.camera.cameraType != CameraType.Game)
        {
            return;
        }

        var cmd = CommandBufferPool.Get("StencilToTexture");
        _cameraRenderTargetHandle = renderingData.cameraData.renderer.cameraColorTargetHandle;

        // キャラクターをマスクしたテクスチャを作成
        CoreUtils.SetRenderTarget(cmd, _renderTexture, renderingData.cameraData.renderer.cameraDepthTargetHandle);
        CoreUtils.ClearRenderTarget(cmd, ClearFlag.Color, Color.black);
        Blitter.BlitTexture(cmd, _cameraRenderTargetHandle, new Vector4(1, 1, 0, 0), _stencilToTextureMaterial, 0);

        // ポストエフェクトにStencilに対応したテクスチャを渡す
        cmd.SetRenderTarget(_cameraRenderTargetHandle);
        _postEffectMaterial.SetTexture(Shader.PropertyToID("_MaskTex"), _renderTexture);

        // ポストエフェクトを実行
        Blitter.BlitCameraTexture(cmd, _cameraRenderTargetHandle, _cameraRenderTargetHandle, _postEffectMaterial, 0);

        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }

ステンシルを参照してキャラクター部分を白で塗りつぶすシェーダーを作成しました。これを③のポストエフェクトの中で使用します。

    SubShader
    {
        Cull Off
        ZWrite Off
        ZTest Always

        Stencil
        {
            Ref [_Stencil]
            Comp Equal
        }

        Pass
        {
            HLSLPROGRAM
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/Shaders/PostProcessing/Common.hlsl"
            #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

            #pragma vertex Vert 
            #pragma fragment frag

            half4 frag(Varyings i) : SV_Target 
            {
                return half4(1, 1, 1, 1);
            }

            ENDHLSL
        }
    }
③エッジを検出する

②で作成したテクスチャからキャラクターのエッジをSobelフィルターで検出します。検出された部分を縁取りとして描画するため、サンプリングする座標を操作して太さを調節できるようにしました。
Sobelフィルターの実装はこちらの記事を参考にしました。
media.colorfulpalette.co.jp

検出されたエッジ
    half4 RGBToLuminance(half3 color) {
        return dot(color, half3(0.299, 0.587, 0.114));
    }

    half DetectEdge(float2 texcoord, float width) {
        // Sobelフィルターの係数
        float3x3 sobel_x = float3x3(-1, 0, 1, -2, 0, 2, -1, 0, 1);
        float3x3 sobel_y = float3x3(-1, -2, -1, 0, 0, 0, 1, 2, 1);

        float edgeX = 0;
        float edgeY = 0;

        UNITY_UNROLL
        for (int x = -1; x <= 1; x++) {
            UNITY_UNROLL
            for (int y = -1; y <= 1; y++) {
                float luminance = RGBToLuminance(SAMPLE_TEXTURE2D(_MaskTex, sampler_LinearClamp, texcoord + float2(width * x, width * y)).rgb);
                edgeX += luminance * sobel_x[x + 1][y + 1];
                edgeY += luminance * sobel_y[x + 1][y + 1];
            }
        }

        return length(float2(edgeX, edgeY));
    }
④ドロップシャドウをつける

③で作成したのと同じ関数を使ってエッジの位置をずらして検出し、ドロップシャドウの領域とします。
縁取りとドロップシャドウそれぞれの太さと色をプロパティとして設定できるようにし、キャラクター部分にはアウトラインやドロップシャドウを描かないようにしています。

    half4 frag (Varyings input) : SV_Target {
        half4 color = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearRepeat, input.texcoord);

        // キャラクター部分のエッジを検出
        half outlineEdge = DetectEdge(input.texcoord, _OutlineWidth);
        half dropshadowEdge = DetectEdge(input.texcoord + float2(_DropshadowOffsetX, _DropshadowOffsetY), _DropshadowWidth);

        // キャラクター、アウトライン、ドロップシャドウの各領域に含まれるか判定
        half isCharacter = step(0.9, SAMPLE_TEXTURE2D(_MaskTex, sampler_MaskTex, input.texcoord).r);
        half isOutline = step(0.1, outlineEdge);
        half isDropshadow = step(0.1, dropshadowEdge);

        half4 dropshadowColor = lerp(color, _DropshadowColor, isDropshadow);
        half4 outlineColor = lerp(dropshadowColor, _OutlineColor, isOutline);

        return lerp(outlineColor, color, isCharacter);
    }

おわりに

キャラクターの領域をポストエフェクトで使用するのに苦戦しましたがきれいに再現できてよかったです。
ご覧いただきありがとうございました!

【Unity】URPでポストエフェクトを追加する

Unityでポストエフェクトを使った効果を試そうと思ったのですが、URP14以降ではBlitの方法が以前と変わったりしていて苦戦したので備忘録として手順を残します。
この記事ではURP16.0.5を使用しています。

今回は実験として画面全体に色を乗算するだけのポストエフェクトを作ります。

ポストエフェクトのパスを追加する

次の2つのクラスを継承したクラスを作ることでRendererに追加できるようになります。

  • ScriptableRenderPass: 実行タイミングの定義、描画処理の実装
  • ScriptableRendererFeature: パスの生成、パスへのパラメータの受け渡し
ScriptableRenderPass

今回のパスで使用するScriptableRenderPassを継承したクラスを追加します。最低限実行タイミングの設定と描画処理(Execute)を実装する必要があると思います。

public class PostEffectTestRenderPass : ScriptableRenderPass
{
    private Material _postEffectTestMaterial;
    private RTHandle _source;

    private const string COMMAND_BUFFER_NAME = nameof(PostEffectTestRenderPass);

Materialと実行タイミング(renderPassEvent)はコンストラクタで設定しますが、カメラの描画テクスチャに対応するRTHandleの取得はパス生成のタイミングで行うとするとエラーになるため関数を分けました。
(ScriptableRendererFeatureでコンストラクタと別のタイミングで実行します)

    public PostEffectTestRenderPass(Material postEffectTestMaterial)
    {
        _postEffectTestMaterial = postEffectTestMaterial;
        renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;
    }

    public void Setup(RTHandle cameraColor)
    {
        _source = cameraColor;
    }

パス実行時の処理はExecute関数で実装します。
内容は描画テクスチャにポストエフェクト用のMaterialを適用するのみですが、URP14以降ではCommandBufferのBlit関数ではなくBlitter.BlitCameraTextureを使うようです。

   public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
   {
       if (_postEffectTestMaterial == null)
       {
           return;
       }

       // カメラの描画結果にマテリアルを適用して書き戻す
       var commandBuffer = CommandBufferPool.Get(COMMAND_BUFFER_NAME);
       
       Blitter.BlitCameraTexture(commandBuffer, _source, _source, _postEffectTestMaterial, 0);

       context.ExecuteCommandBuffer(commandBuffer);
       CommandBufferPool.Release(commandBuffer);
   }

docs.unity3d.com

ScriptableRendererFeature

パスの生成と設定を行うScriptableRendererFeatureを追加します。最低限Create, AddRenderPassesを実装する必要があります。
パスに渡すMaterialはScriptableRendererFeatureにアタッチできるようにしておきます。

public class PostEffectTestRendererFeature : ScriptableRendererFeature
{
    [Serializable]
    public class PostEffectTestPassSetting
    {
        public Material PostEffectTestMaterial;
    }

    [SerializeField]
    public PostEffectTestPassSetting PassSetting;
    PostEffectTestRenderPass _pass;

CreateではScriptableRenderPassの生成、AddRenderPassesではRendererへパスを追加します。

    public override void Create()
    {
        if (PassSetting == null || PassSetting.PostEffectTestMaterial == null)
        {
            return;
        }

        _pass = new PostEffectTestRenderPass(PassSetting.PostEffectTestMaterial);
    }

    public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
    {
        if (_pass == null)
        {
            return;
        }
        renderer.EnqueuePass(_pass);
    }

SetupRenderPassesも実装します。描画テクスチャのRTHandleはこちらで実装しないとエラーになります。

    public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
    {
        if (renderingData.cameraData.cameraType == CameraType.Game)
        {
            _pass.ConfigureInput(ScriptableRenderPassInput.Color); // カラーテクスチャを使用
            _pass.Setup(renderer.cameraColorTargetHandle);
        }
    }

docs.unity3d.com

Rendererの設定

RendererのアセットにRendererFeatureを追加し、Materialも設定します。Post-processingのEnabledにチェックが入っていない場合はチェックを入れておきます。

シェーダーを作る

Blitに対応させるため, シェーダー側でPackages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlslをincludeします。Blit.hlslの側で頂点シェーダーが定義されているため、今回はフラグメントシェーダーのみ作成します。
フラグメントシェーダーでは色の乗算のみ行っています

Shader "Original/PostEffectTest" {
    HLSLINCLUDE
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
    #include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"

    TEXTURE2D(_CameraColorTexture);
    SAMPLER(sampler_CameraColorTexture);

    half4 frag (Varyings input) : SV_Target {
        half4 color = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearRepeat, input.texcoord);
        return color * half4(1.0, 0.6, 1.0, 1.0);
    }

    ENDHLSL

    SubShader {
        Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" }

        Pass {
            Name "PostEffectTest"

            Cull Off ZWrite Off ZTest Always

            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment frag
            ENDHLSL
        }
    }
}

参考

使用しているモデル
3d.nicovideo.jp

docs.unity3d.com
shibuya24.info
zenn.dev

【Unity】背面法で3Dモデルのアウトラインを描いてみる

こんにちは。
今回は背面法による3Dモデルのアウトラインの描画を実装してみたのでまとめておこうと思います。
この記事ではUnity 2023.2.18f1, URP 16.0.5を使用しています。
また、キャラクターモデルは夏色花梨の公式MMDモデルを使用しています。
3d.nicovideo.jp

背面法について

背面法によるアウトラインの描画は、通常のモデルの描画の前にパスを1つ加えて行われます。このパスでは対象のメッシュを少し膨らませたメッシュを作り、裏面のみ描画します。
この上からモデルを描画すると、膨らませたメッシュのうち通常のメッシュで隠れなかった部分がアウトラインとして見えるようになります。

背面法の流れ

実装する

テクスチャを貼り付けるだけの簡単なシェーダーにアウトラインを描画するパスを追加しました。
シェーダーの全文は次の通りです。

上からコードを見ていきます。まず、アウトラインの太さはプロパティとしてInspectorから簡単に変えられるようにしています。

_Thickness("Outline Thickness", Float) = 0.001

アウトラインパスの頂点シェーダーでは頂点を法線方向に移動させることで膨らませたメッシュを作ります。移動量の係数で太さを調整できます。

        // 法線方向に膨らませる
        float3 expandedPos = input.vertex.xyz + input.normal.xyz * _Thickness;
        output.pos = TransformObjectToHClip(expandedPos.xyz);
        output.uv = TRANSFORM_TEX(input.uv, _MainTex);

フラグメントシェーダーでは今回は黒を固定で返すようにして、アウトラインを単色にしています。

    float4 outlineFrag(vertexOutput input) : COLOR {
        return float4(0, 0, 0, 1.0);
    }

頭や服などにこのシェーダーを適用すると、下のようにアウトラインが描かれます。
▼アウトラインなし

▼アウトラインあり

色トレスを導入

前節で描いたアウトラインは黒の単色のため、次は色トレスを導入してみます。
色トレスはイラストで塗りの色を暗くしたものを線画に適用し、線画と塗りをなじませる手法です。

まず、テクスチャの色の適用率をプロパティに追加します。この値が小さいほどアウトラインの色が黒に近づきます。

_TexColorWeight("Texture Color Weight", Float) = 0.25

CBUFFERの定義にも追加します

    CBUFFER_START(UnityPerMaterial)
        float4 _MainTex_ST;
        float _Thickness; // アウトラインの太さ(膨らませる係数)
        float _TexColorWeight;
    CBUFFER_END

先ほどのフラグメントシェーダーに処理を追加して、テクスチャの色を取得して指定された係数を掛けてアウトラインの色にします。

    float4 outlineFrag(vertexOutput input) : COLOR {
        float4 texColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
        
        float4 outlineColor = texColor * _TexColorWeight;
        outlineColor.a = 1.0;

        return outlineColor;
    }

これによりアウトラインが先ほどよりもキャラクターになじむようになりました。

アウトラインの色トレスあり

カメラからの距離を考慮する

ここまでの実装では頂点を常に一定量押し出してアウトラインを作っています。このような方法ではカメラからの距離が変わった見た目が安定しないため、カメラからの距離を考慮に入れます。
参考:
game.watch.impress.co.jp

カメラが近いときにアウトラインが太くなる、遠いときに細くなるのを抑えられるよう、アウトラインの太さをカメラからの距離に比例する形にします。

    vertexOutput outlineVert(vertexInput input) {
        vertexOutput output;

        // カメラからの距離を求める
        float4 originalPos = TransformObjectToHClip(input.vertex.xyz);
        float linearDepth = LinearEyeDepth(originalPos.z / originalPos.w, _ZBufferParams);
        // アウトラインの太さをカメラからの距離に比例させる
        float scaledThickness = _Thickness * (1.0 + linearDepth);

        // 法線方向に膨らませる
        float3 expandedPos = input.vertex.xyz + input.normal.xyz * scaledThickness;
        output.pos = TransformObjectToHClip(expandedPos.xyz);
        output.uv = TRANSFORM_TEX(input.uv, _MainTex);

        return output;
    }

補正を入れることでカメラからの距離によらずアウトラインが整って見えるようになりました。

おわりに

髪など一部形が複雑な部分でアウトラインが浮いて見えることがあるのですが、これは頂点カラーなどでより細かく押し出し量を制御することで抑えられるようです。
お付き合いいただきありがとうございました!

【Unity】3Dモデルを注視するカメラを作る

Unityで3Dモデルの見た目を簡単に確認できるよう

  • モデルの周りを動き、一定の点を注視するカメラ制御クラス
  • マウス操作でカメラの角度や距離を調整するクラス

を作ります。

カメラを制御するクラスを作る


このクラスでは注視点からの距離と水平方向、垂直方向の回転角を指定してカメラを適当な位置に配置します。

位置の計算

回転角はモデルの正面にカメラがある状態を(0, 0)としました。
また、注視点がモデルの原点(0, 0, 0)に固定されていると不便なので、コード中ではモデルの原点から実際の注視点の差をLookAtPosとしてその分だけカメラを移動させるようにしています。

カメラ位置の計算
/// <summary>
/// 注視点を中心として回転させた位置にカメラを配置
/// </summary>
private void UpdatePosition()
{
    // 水平・垂直方向の回転角(ラジアン)
    var horizontalAngle = Angle.x * Mathf.Deg2Rad;
    var verticalAngle = Angle.y * Mathf.Deg2Rad;

    var posX = -Distance * Mathf.Sin(horizontalAngle) * Mathf.Cos(verticalAngle);
    var posY = Distance * Mathf.Sin(verticalAngle);
    var posZ = Distance * Mathf.Cos(horizontalAngle) * Mathf.Cos(verticalAngle);

    transform.position = new Vector3(posX, posY, posZ) + LookAtPos;

    // 注視点を向くようにする
    transform.LookAt(LookAtPos);
}
Inspectorのカスタマイズ

現在の設定値の確認と簡単なカメラ操作が行えるようにInspectorの表示を拡張しました。

#if UNITY_EDITOR
[CustomEditor(typeof(ModelViewerCamera))]
public class ModelViewerCameraInspector : Editor
{
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        if (target is ModelViewerCamera modelViewerCamera)
        {
            GUILayout.Label($"LookAtOffset: {modelViewerCamera.LookAtOffset}");
            GUILayout.Label($"Distance: { modelViewerCamera.Distance}");

            if (GUILayout.Button("正面に戻す"))
            {
                modelViewerCamera.ResetFront();
            }

            if (GUILayout.Button("右に45°回転"))
            {
                // 水平方向の回転角を45°加算
                var cameraAngle = modelViewerCamera.Angle + Vector2.right * 45f;
                modelViewerCamera.Angle = cameraAngle;
            }

            if (GUILayout.Button("上に45°回転"))
            {
                // 垂直方向の回転角を45°加算
                var cameraAngle = modelViewerCamera.Angle + Vector2.up * 45f;
                modelViewerCamera.Angle = cameraAngle;
            }
        }
    }
}
#endif
クラス全体

マウス操作でカメラの位置を調整するクラスを作る

3Dモデルを確認するシーン(ModelViewerScene)を作り、この中にマウス操作を処理するクラスを持たせました。

回転操作

マウスのドラッグでカメラの回転角を調整できるようにします。
左ボタンが押されている間、マウス位置の差分を取って回転角としてカメラ制御クラスに設定しています。
マウスを止めている間にカメラが動かないよう、左ボタンが押された時点の値を基準として回転角を設定します。

 /// <summary>
 /// カメラの回転操作
 /// </summary>
 private void HandleRotationInput()
 {
     // ドラッグの開始と終了
     if (Input.GetMouseButtonDown(0))
     {
         _isDragging = true;
         _lastMousePosition = Input.mousePosition;
         _lastCameraAngle = _viewerCamera.Angle;
     }
     else if (Input.GetMouseButtonUp(0))
     {
         _isDragging = false;
     }

     if (_isDragging)
     {
         Vector2 mousePosDelta = (Vector2)Input.mousePosition - _lastMousePosition;
         var cameraRotation = -mousePosDelta * _mouseSensitivity;
         // カメラが動き続けないようにするため現在の回転角でなくドラッグ開始時点の回転角を基準にする
         _viewerCamera.Angle = _lastCameraAngle + cameraRotation;
     }
 }

ズームイン/ズームアウト

ホイールを上方向に回すとズームイン、下方向に回すとズームアウトするようにします。
ホイールが操作されなかったフレームではInput.GetAxis("Mouse ScrollWheel")は0になります。

/// <summary>
/// カメラのズームイン/アウト
/// </summary>
private void HandleZoomInput()
{
    var scroll = Input.GetAxis("Mouse ScrollWheel");
    // 上方向のスクロールで近づける
    _viewerCamera.Distance -= scroll;
}

平行移動

ホイールをクリックしたままマウスを動かすとカメラの注視点を動かせるようにします。
画面の上下左右方向へ移動させられるようにカメラの上方向、右方向のベクトルを取得して注視点の移動量を求めています

 /// <summary>
 /// カメラの平行移動
 /// </summary>
 private void HandleTranslationInput()
 {
     const int MOUSE_WHEEL_BUTTON = 2;

     if (Input.GetMouseButtonDown(MOUSE_WHEEL_BUTTON))
     {
         _isWheelDragging = true;
         _lastMousePosition = Input.mousePosition;
         _lastCameraLookAtOffset = _viewerCamera.LookAtOffset;
     }
     else if (Input.GetMouseButtonUp(MOUSE_WHEEL_BUTTON))
     {
         _isWheelDragging = false;
     }

     if (_isWheelDragging)
     {
         Vector2 mousePosDelta = (Vector2)Input.mousePosition - _lastMousePosition;

         // カメラの右方向、上方向のベクトル
         Vector3 right = math.normalize(_viewerCamera.transform.right);
         Vector3 up = math.normalize(_viewerCamera.transform.up);

         // 画面の上下左右に注視点を移動させる
         var movement = (mousePosDelta.x * right + mousePosDelta.y * up) * _translationSpeed;
         _viewerCamera.LookAtOffset = _lastCameraLookAtOffset - movement;
     }
 }
クラス全体

おわりに


マウス操作で簡単に3Dモデルの見た目を確認できるシーンを作成しました。再生中の状態で好きな角度からモデルを確認できるのは便利かなと思います。
また、今回CustomInspectorを初めて使ったのですが、プロパティの確認や簡易的な操作がInspectorからできるのが実装中に助かったのでまた使っていきたいです。

※動画では公式で配布されている夏色花梨のMMDモデルを使用しています。
配布サイト
3d.nicovideo.jp

飲み物オタクが教える冬を乗り切る美味しい飲み物 #kuac2019

この記事はKyoto University Advent Calender 2019の17日目の記事です。

はじめに

はじめまして、ごーとぅー(@goto0312)です。


京大総合人間学部自然科学系の4回生で無機化学の研究室に所属しています。


12月も中旬になり、そろそろ後期試験に向けた勉強や卒論で忙しくなるころですし、作業中には温かい飲み物が欲しくなりますよね。そこでこの記事では、下宿にコーヒー豆や紅茶の茶葉、緑茶、ココアなどを常備している飲み物好きの私がおすすめする、作業のお供やリラックスに最適なおいしい飲み物の作り方をいくつか紹介します。

1. ココア

スーパーで売っているココアも鍋で丁寧に作ると喫茶店の味が出せます。

材料

  • 牛乳 飲みたい量
  • ココア 適量(牛乳200 mLの場合スプーン山盛り2杯程度)

作り方

1. 粉末のココアに牛乳を入れ、中火で加熱する
f:id:goto0312:20191217201645j:plain
ココアが鍋底で焦げ付かないよう軽く混ぜます


2. ふちに泡が出てココアが溶け始めたら弱火にする


f:id:goto0312:20191217201651j:plain


3. 残った粉を徹底的に溶かす
f:id:goto0312:20191217203715j:plain
溶け残りが少ないほど口当たりが滑らかになって美味しくなります。引き続き焦げ付けないよう気を付けながらひたすら溶かしましょう


f:id:goto0312:20191217201701j:plain
このくらいになれば十分だと思います


4. コップに注いで完成
f:id:goto0312:20191217201706j:plain

2. カフェモカ

カフェモカエスプレッソに牛乳とチョコレートソースを加えた飲み物です[1]。ここでは家でも簡単にできるドリップコーヒー(またはインスタントコーヒー)とココアを使った作り方を紹介します。


(ココアは簡単に作っていますが上で紹介した方法で作ったらよりおいしくできると思います。)

材料

  • 牛乳 作りたい量の1/2
  • ココア 適量
  • コーヒー(インスタントコーヒーでもよい) 作りたい量の1/2

作り方

1. まずは普通にココアを作ります。
f:id:goto0312:20191217201550j:plain
牛乳は100 mLなら500Wの電子レンジに1分ほどかけると程よく温まります。膜が張っていたら取り除いておきましょう


2. コーヒーを加える
f:id:goto0312:20191217201609j:plain
ここではココア:コーヒー=1:1で作っていますが好みに応じて加減してください。インスタントコーヒーを使う場合は濃い目に作ると美味しいようです[2]


3. 軽くかき混ぜて完成
f:id:goto0312:20191217201630j:plain

3. ロイヤルミルクティー

紅茶を水でなく牛乳で煮出したものがロイヤルミルクティーです。しかし、牛乳を沸騰させると抽出しにくいため先にお湯で茶葉を開かせる方法をとります[3]。

材料

  • 水 作りたい量の1/2
  • 牛乳 作りたい量の1/2
  • 紅茶の茶葉(ティーバッグでもよい) やや多め(水100 mLの場合ティースプーン山盛り1杯ちょっと)

作り方

1. 鍋で水を沸騰させ、茶葉を加えて火を止める
f:id:goto0312:20191217201726j:plain


2. 蓋をして2~3分蒸らす


3. 牛乳を加えて弱火で加熱する
f:id:goto0312:20191217201751j:plain


4. 沸騰する直前に火を止める
f:id:goto0312:20191217201810j:plain
小さい泡が出て、対流で茶葉が上がってくるのが目安です


5. 茶こしでこして完成
f:id:goto0312:20191217201826j:plain
お好みで砂糖などを入れても美味しいです。

4. アップルティー

買って飲むもののイメージが強いアップルティーですが、意外と簡単に作れます。

材料

  • りんご 1/2個
  • 砂糖 適量(大さじ1杯半程度)
  • 水 飲みたい量
  • 茶葉 適量(水200 mLの場合ティースプーン山盛り1杯程度)

作り方

1. りんごをスライスして砂糖をまぶす
f:id:goto0312:20191217201318j:plain
りんごは味が染み出せばよいので薄さにこだわらなくても大丈夫です。安全第一でスライスしましょう


2. ラップをかけてレンジで加熱する
f:id:goto0312:20191217201341j:plain
時間は500 Wのレンジで2分くらい。温めた後はお皿にりんごのシロップがたまります。


3. 温めたリンゴをシロップとともにポットに入れる
f:id:goto0312:20191217201401j:plain


4. 茶葉と沸騰したお湯を入れ4分ほど蒸らす
f:id:goto0312:20191217201420j:plain
待ち時間を長くするほどりんごの風味が強くなります(4分だとほのかにりんごの風味がする程度)。好みに応じて調節してください


f:id:goto0312:20191217201442j:plain
ポットにはポットカバーをかけておくと冷めにくくなりますが、マフラーやネックウォーマーでも代用できる気がします。


5. 軽く混ぜて完成
f:id:goto0312:20191217201508j:plain

おわりに

溶かして作るインスタントコーヒーや紅茶も良いですが、今回紹介したようなメニューは作る過程も楽しめるのが魅力だと思います。温かくて美味しい飲み物を飲んで険しい冬を乗り切りましょう!


明日のKyoto University Advent Calender 2019はnonamea774さんです。お楽しみに!

参考文献

[1]「カフェモカとは?カフェオレやラテとの違い、おいしい飲み方を紹介 – macaroni」(https://macaro-ni.jp/50730)
[2]「ココアを使って簡単に!カフェモカの作り方・レシピ」(https://cafelte.com/coffee-drinks/161/)
[3]「ミルクティーWikipedia」(https://ja.wikipedia.org/wiki/%E3%83%9F%E3%83%AB%E3%82%AF%E3%83%86%E3%82%A3%E3%83%BC)
[4]「ロイヤルミルクティー|紅茶のおいしいいれ方|日東紅茶」(http://www.nittoh-tea.com/enjoy/brew/brew03.html)
[5]「アップルシナモンティー | 季節のおいしいレシピ | 日本紅茶協会」(http://www.tea-a.gr.jp/recipe/autumn/26.html)