UIElementsの基本構造

UIElementsがいまいち使いにくい。もっと拡張性を上げられないものか…とおもってUIElementsのソースコードをいろいろ読み漁っていたら理解が深まってしまったのでメモとして残します。 読んでも「ふーん」と思うだけできっと役に立ちません。

VisualTreeAssetとは何なのか

var vitualTreeAsset = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>("Assets/Editor/Nantoka.uxml");
var rootElement = vitualTreeAsset .CloneTree();

UIElementsを使う場合はいつも上記のようなコードを書くと思います。拡張子がuxmlなのでuxmlファイルの生テキストそのものを読んでいるような気持ちになりますが、これは厳密には違います。

XMLが記述されたテキストファイルであるところのuxmlファイルは専用のScriptedImporterによって前処理され、ScriptableObjectに変換されます。このScriptableObjectがVisualTreeAssetです。つまりVisualTreeAssetはXMLのパースが完了した結果生まれたバイナリです。

VisualTreeAssetに含まれる要素はいくつかの種類に分類されます。

  • VisualElementAsset
  • inlineStyleSheet
  • UsingEntry
  • TemplateAsset

などです。順にみていきましょう。

VisualElementAsset

VisualElementAssetは以下のような代物です

[Serializable]
class VisualElementAsset
{
    int Id;
    string 要素名;
    string フルネーム(解決済み名前空間付き要素名);
    int 親のId;
    (string attributeName, string attributeValue)[] 要素がもつ全属性;
    string 要素内のテキスト;
}

VisualTreeAssetにはこれがListで入っています。 この時点では、まだButtonやImageなどの具体的なVIsualElementに解決はされていません。解決のための文字列情報をもっている状態です。

真の姿をみたければ公式のソースを。Propertyまわりがちょっとウッとなります。 https://github.com/Unity-Technologies/UnityCsReference/blob/master/Modules/UIElements/UXML/VisualElementAsset.cs

inlineStyleSheet

UXMLのビジュアル要素には以下のようにstyle属性を書けますが、これを抽出してかき集めたものです。

<engine:Button name="btn" text="Click!" style="height: 40px" />

uxmlファイルと同様にussファイルもScriptedImporterを経由してStyleSheetというScriptableObjectになります。inlineStyleSheetもソースがussファイルじゃなくなっただけでできるものは同じでStyleSheetを作ってVisualTreeAssetのSubAssetとして保持されます。

UsingEntry

UsingEntryは外部のテンプレートへの参照です。

<engine:Template path="Assets/Portrait.uxml" name="Portrait" />

上記のように書けばUXMLでは外部テンプレート(UXML)を参照して名前をつけることができますが、この要素それ自体は何もVisualElementを作りませんのでVisualElementAssetとして保持せずUsingEntryという構造体のListで持っています。

[Serializable]
struct UsingEntry
{
    public string alias;
    public string path;
    public VisualTreeAsset asset;
}

実際に外部Assetへの参照になっているのでAssetBundleの依存解決なんかも上手くいきそうな気がします。というかこうしないと文字列だけではUnityの参照解決の仕組みに乗っかれないので不安というか。

TemplateAsset

TemplateAssetは、UsingEntryで保持した外部テンプレートを使うための仕組みです。

<engine:Template path="Assets/Portrait.uxml" name="Portrait" />

<engine:Instance template="Portrait" />
<engine:Instance template="Portrait" />

上記のようにInstance要素をかけばTemplateを実体化できるわけですが、これは単純なVisualElementとは異なる挙動をする必要がるのでVisualElementAssetだけでは保持されません。TemplateContainerというVisualElementとTemplateAssetの合わせ技で処理されます。

その他のVisualTreeAsset内の要素

Slotsなどあるんですが、まだ私がちゃんと理解していないのでこの記事では触れません。
気になる方は公式のソースを読むなりマニュアルからあたりをつけるなりで平に御容赦を。

IUxmlFactory

IUxmlFactoryはVisualElementAssetをLabelやButtonなどの具体的なVisualElementに変換するための仕組みです。

public interface IUxmlFactory
{
    string uxmlName { get; }
    string uxmlNamespace { get; }
    string uxmlQualifiedName { get; }
    bool canHaveAnyAttribute { get; }
    IEnumerable<UxmlAttributeDescription> uxmlAttributesDescription { get; }
    IEnumerable<UxmlChildElementDescription> uxmlChildElementsDescription { get; }
    string substituteForTypeName { get; }
    string substituteForTypeNamespace { get; }
    string substituteForTypeQualifiedName { get; }
    bool AcceptsAttributeBag(IUxmlAttributes bag, CreationContext cc);
    VisualElement Create(IUxmlAttributes bag, CreationContext cc);
}

メンバーは結構ありますが、もっとも大事なのは uxmlQualifiedName とCreateです。
VisualElementAsset内のフルネームとuxmlQualifiedNameが一致したIUxmlFactoryのCreateがVisualElementの生成に使われます。逆にいうとuxmlQualifiedNameが一致するIUxmlFactoryが存在しそのCreateが何らかのVisualElementを返すならば、UXMLにはどんなXML要素を書いても良いのです。これがUXMLの拡張モデルの中核になります。

個人的に胸熱なのが、どうも公式のソースを読む限りUnity2020.1から、IUxmlFactory.Createがnullを返すことが許されるようになる気配があります。これは、UXMLの中に非VisualElement要素を自由に追加できるようになることを意味します。2019.2現在ではUXMLに非VisualElementを混ぜるにはScriptedImporterで特別扱いするしかなく、それためまったくカスタム不可能な領域です。ここに手が入るのは大変好ましいと思います。ただし未来のことなので取り下げられても文句は言えないので鵜呑みにしないでもらえると助かります。

UxmlTraits

IUxmlFactoryは要素とのマッピングを行いますが、UxmlTraitsは属性とのマッピングを行います。VisalElementAsset内の属性のキーと値のペア群を元にしてIUxmlFactory内で生成されるVisualElementのプロパティをセットします。UxmlTraitsは基本的にはIUxmlFactory実装の内部メンバーとして仕事します。

XMLスキーマ生成

Unityのアセットメニューで「Update UIElements Schema」を選択すると、IUxmlFactoryとUxmlTraitsの情報をもとにXMLスキーマが自動生成されます。XMLスキーマはUXMLを書く時のコード補完に使用されますので、ユーザーが追加したカスタムUML要素に対してもコード補完が効くようになります。

なお<engine:Template />や<engine:Instance />にコード補完が効かないのもこの仕組みが理由です。この二つはScriptedImporterで特別扱いされることで機能しているので対応したIUxmlFactoryが存在しないのです。これは2020.1でCreateがnullを返すIUxmlFactoryが作成されることで改善されそうな気配です。

まとめ

UXMLはScriptedImporterによってVisualTreeAssetに変換され、VisualTreeAsset内の各VisualElementAsset要素は対応するIUxmlFactoryによってButtonやLabelなどのVIsualElementに変換されます。 カスタムのIUxmlFactoryを書くことでUXMLを拡張できます。

f:id:enrike3:20191013173439p:plain

SRP Batcher良き良き

SRPを導入するならSRP Batcher使いたい!

SRP Batcherの解説といえば公式Blog。充実した内容です。

blogs.unity3d.com

Unity 2019.2からOpen GL ES 3.1もサポートし、幅広く使えるようになりました。

SRP Batcherは、Materialが切り替わってもShaderVariantさえ変わらなければ高効率な描画を維持できるという点で従来描画より優れています。

Dynamic BatchはMaterialに違いがあればバッチが効かず、また効いたとしてもMesh結合がCPUに優しくないため頂点数の多い3Dプロジェクトではデフォルトでオフになりました。

GPU Instancingは高効率ですが、メッシュが同一の描画のみを束ねます。メッシュが異なるとbreak。

SRP Batcherはメッシュ結合を行わないためDrawCallは減りませんがMaterialが切り替わってもOKということで使えるシーンが広がります。

https://blogs.unity3d.com/wp-content/uploads/2019/02/image3-5.png 公式blogより画像引用

ShaderはSRP Batcher対応のものを使わなければいけませんが、対応は拍子抜けするぐらい簡単です。

比較のために最初にSRP Batcher非対応のシェーダを書いて、次にSRP Batcherに対応させてみます。

というわけで以下はTextureに色を乗算する簡単なTransparentシェーダ 。この時点ではSRP Batcherとはcompatibleではありません。

Shader "SRPB/Hoge"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _Color("Color", Color) = (1,1,1,1)
    }
    SubShader
    {
        Tags
        {
            "Queue" = "Transparent"
            "IgnoreProjector" = "True"
            "RenderType" = "Transparent"
            "PreviewType" = "Plane"
            "CanUseSpriteAtlas" = "True"
        }

        Cull Off
        ZWrite Off
        ZTest Always
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"
    
            fixed4 _Color;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv) * _Color;
                return col;
            }
            ENDCG
        }
    }
}

f:id:enrike3:20191011001112p:plain

_Colorが白と赤のMaterialを用意して、QuadとSphereで描画してみます。

f:id:enrike3:20191011003617p:plain Sphereはちょっとグロかった、反省している…
Materialが分かれているので、同一のShaderVariantでもSetPassCallが分かれてBatches(DrawCall)と同数です。

f:id:enrike3:20191011001512p:plain
FrameDebugger。Materialが異なるので当たり前ですがQuadとSphereの描画は別

シェーダをSRP Batcherとcompatibleに修正する

fixed4 _Color;

CBUFFER_START(UnityPerMaterial)
    fixed4 _Color;
CBUFFER_END

に書き換えるだけです。

f:id:enrike3:20191011002126p:plain
無事compatibleになりました

f:id:enrike3:20191011002402p:plain
DrawCallであるところのBatchesは据え置きですが、SetPassCallは一つ減ります。

f:id:enrike3:20191011002713p:plain
FrameDebugger。SRP BatcherによってSetPassCall1に対してDrawCallが2になっていることが表示されます。 Dynamic Batchだと1Meshの描画になるのでDrawCall自体が減るのですが、SRP BatcherはMesh結合をしないのでDrawCallは減りません。

このあたりはRenderDocでみると、もっとはっきりとわかります。

f:id:enrike3:20191011003221p:plain
QuadとSphereのDrawCallはちゃんと別々ですね。

まとめ

SRP Batcher良き。 シェーダの対応も簡単なので今後シェーダを書くときは最初からSRP Batcher対応シェーダを書くように癖をつけていこうと思ってます。

ScriptableRenderPipelineの部品化とUniversalRP

先日の記事で素朴なUnlitのSRPを書きました。

enrike3.hatenablog.com

今回は、素朴なSRPとUniversalRPの間を埋めUniversalRPの構造を理解すべく、SRPの部品化を試みます。
最終的にはUniversalRPの下図の構造を理解できることが目標です。

f:id:enrike3:20191010024130p:plain 我ながら配色のセンスがやばすぎて震える

さて、先日の素朴なパイプラインの中には、いかにも描画要素に分解できそうな三つのメソッドがありました。

private void ClearRenderTarget(ScriptableRenderContext context, Camera camera)
{
    //カメラをクリアする
}

private void RenderOpaque(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
{
    //不透明描画を行う
}

private void RenderTransparent(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
{
    //透明描画を行う
}

これを、まずはSRPRenderPassという概念のオブジェクトにしてしまいます。

public abstract class SRPRenderPass
{
    public abstract void ExecutePass(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults);
}

public class ClearPass : SRPRenderPass
{
    public override void ExecutePass(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
    {
        //カメラをクリアする
    }
}

public class OpaquePass : SRPRenderPass
{
    public override void ExecutePass(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
    {
        //不透明描画を行う
    }
}

public class TransparentPass : SRPRenderPass
{
    public override void ExecutePass(ScriptableRenderContext context, Camera camera, ref CullingResults cullResults)
    {
        //透明描画を行う
    }
}

これらのクラスがあると、SRP側は以下のようになります。SceneViewとかGizmoまわりは説明を単純化するために除外します。

class MyRenderPipeline : RenderPipeline
{
    SRPRenderPass[] _renderPass = null;

    public MyRenderPipeline()
    {
        _renderPass = new SRPRenderPass[]
        {
            new ClearPass(),
            new OpaquePass(),
            new TransparentPass(),
        };
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        foreach(var camera in cameras)
        {
            context.SetupCameraProperties(camera);

            if (!camera.TryGetCullingParameters(out ScriptableCullingParameters cullingParameters))
                continue;

            var cullResults = context.Cull(ref cullingParameters);

            foreach(var pass in _renderPass)
            {
                pass.ExecutePass(context, camera, ref cullResults);
            }
        }

        context.Submit();
    }
}

SRP側は、カメラに対してカリングを行い複数のSRPRenderPassを実行するという構造になりました。

さらに部品化をすすめましょう。カメラ操作と複数のSRPRenderPassを実行する役割をSRPRendererという概念にしてしまいます。

public abstract class SRPRenderer
{
    public abstract void Execute(ScriptableRenderContext context, Camera camera);
}

public class MyRenderer : SRPRenderer
{
    SRPRenderPass[] _renderPass = null;

    public MyRenderer()
    {
        _renderPass = new SRPRenderPass[]
        {
                new ClearPass(),
                new OpaquePass(),
                new TransparentPass(),
        };
    }

    public override void Execute(ScriptableRenderContext context, Camera camera)
    {
        context.SetupCameraProperties(camera);

        if (!camera.TryGetCullingParameters(out ScriptableCullingParameters cullingParameters))
            return;

        var cullResults = context.Cull(ref cullingParameters);

        foreach (var pass in _renderPass)
        {
            pass.ExecutePass(context, camera, ref cullResults);
        }
    }
}

これを導入すると、SRP側はさらに簡素になります。カメラループの中の処理はすべてRendererに委譲できました。

class MyRenderPipeline : RenderPipeline
{
    SRPRenderer _renderer = null;

    public MyRenderPipeline()
    {
        _renderer = new MyRenderer();
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        foreach(var camera in cameras)
        {
            _renderer.Execute(context, camera);
        }

        context.Submit();
    }
}

さて、SRPRendererという便利な単位ができたので、カメラに応じてSRPRendererを切り替えてみましょう。 カメラに対してコンポーネントを付けます。

[RequireComponent(typeof(Camera))]
public class AdditionalCameraData : MonoBehaviour
{
    public int RendererIndex;
}

SRPは、Rendererを複数持つように変更します。

class MyRenderPipeline : RenderPipeline
{
    SRPRenderer[] _renderers = null;

    public SRPRenderer DefaultRenderer => _renderers[0];

    public MyRenderPipeline()
    {
        _renderers = new Renderer[]
        {
            new MyRenderer(),
            new NazoRenderer(),
        };
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        foreach(var camera in cameras)
        {
            var cameraData = camera.GetComponent<AdditionalCameraData>();
            var renderer = cameraData == null ? DefaultRenderer : _renderers[cameraData.RendererIndex];

            renderer.Execute(context, camera);
        }

        context.Submit();
    }
}

カメラ毎に描画を大きく切り替えることができるようになりました。

さて、部品化が進んでくるとSRPRendererをエディタから設定したくなってきます。Unityでエディタ設定といえばScriptableObjectです。
SRPRendererをScriptableObjectからもらうようにしましょう。SRPRendererDataというclassを導入します。

public abstract class SRPRendererData : ScriptableObject
{
    public abstract SRPRenderer Create();
}

[CreateAssetMenu(menuName = "MySRP/MyRendererData")]
public class MyRendererData : SRPRendererData
{
    public override SRPRenderer Create() => new MyRenderer();
}

[CreateAssetMenu(menuName = "MySRP/NazoRendererData")]
public class NazoRendererData : SRPRendererData
{
    public override SRPRenderer Create() => new NazoRenderer();
}

これでSRPRendererのネタをアセットとしてSerializeできるので、 RenderPipelineAssetにSRPRendererの配列を持たせることでエディタによるカスタマイズ性を手にいれることができます。

public class MyRenderPipelineAsset : UnityEngine.Rendering.RenderPipelineAsset
{
    public SRPRendererData[] RendererData;

    protected override RenderPipeline CreatePipeline()
    {
        return new MyRenderPipeline(this);
    }
}

public class MyRenderPipeline : RenderPipeline
{
    SRPRenderer[] _renderers = null;

    public SRPRenderer DefaultRenderer => _renderers[0];

    public MyRenderPipeline(MyRenderPipelineAsset asset)
    {
        _renderers = asset.RendererData.Select(x => x.Create()).ToArray();
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {
        //略
    }
}

f:id:enrike3:20191010015647p:plain

同様にSRPRenderPassに対応するScriptableObjectを作れば、SRPRendererの内容をカスタマイズすることも可能です。

さて、ここまでを振り返ってみましょう。

  • 小さな描画コードをRenderPassという単位に切り出した
  • カメラ単位の処理、すなわちカメラのカリングと複数のRenderPassの実行をRendererという単位に束ねた
  • RenderPipeline側で複数のRendererをもち、カメラに応じて切り替えられるようにした
  • Rendererを作成するScriptableObjectを用意することで、RenderPipelineを構成するRenderer群をエディタで設定できるようにした
  • さらに頑張れば、Renderer内のRenderPassをエディタで設定するためのScriptableObjectを作ることも可能

これらすべてをUnity公式がしっかり実装したものがUniversalRPです。

  • UniversalRPはScriptableRendererを複数持つことができ、カメラに応じて切り替えることができます
  • UniversalAdditionalCameraDataがRendererのIndexをもっています。が、UniversalRPはCameraのインスペクタを乗っ取っているので、UniversalAdditionalCameraDataの項目をCameraのインスペクタで設定します
  • ScriptableRendererは、複数のScriptableRenderPassの入れ物です
  • ScriptableRendererを生成するアセットとして、ScriptableRendererDataというScriptableObjectがあります
  • ScriptableRenderPassをScriptableRendererに複数注入するために、ScriptableRenderFeatureというScriptableObjectがあります
    • ScriptableRenderFeatureとScriptableRenderPassが1:1でないのは、複数のパスがまとまって1機能となることがあるためでしょう

冒頭の図を再掲 f:id:enrike3:20191010024130p:plain

上記構造が分かっていれば、UniversalRPのコードリーディングもはかどります。

まず読むべきはForwardRendererがどのようなScriptableRenderPassを保持しているか。これで描画のおおよそを掴むことができます。
ForwardRendererがもつScriptableRenderPassの組み合わせが、求める用途にまったくそぐわないのであれば、ScriptableRendererを自作すべきでしょう。 ForwardRendererにいくつかのScriptableRenderPassを足せば求める用途を満たせるのであれば、ScriptableRenderFeatureを自作してForwardRendererにScriptableRenderPassをいくつか注入します。

この二つの方法で上手くいかないのであればUniversalRPは採用しないほうが良いでしょう。
が、まかなえるシナリオのほうが多いのではと思います。UniversalRP使いこなしていきたいですね。

素朴なUnlitのScriptableRenderPipelineを自作する

Camera Stacking対応目前、ということでそろそろSRPの時代を感じています。 学習がてらに簡単なUnlitのパイプラインを作成してみました。

なお、以下を参考にしました。SRPDefaultUnlitとか良く見つけたなと思います。先人の知恵に感謝です。

learning.unity3d.jp

using UnityEngine;
using UnityEngine.Rendering;

[CreateAssetMenu(menuName = "MyRenderPipelineAsset")]
public class MyRenderPipelineAsset : UnityEngine.Rendering.RenderPipelineAsset
{
    class MyRenderPipeline : RenderPipeline
    {
        protected override void Render(ScriptableRenderContext context, Camera[] cameras)
        {
            foreach(var camera in cameras)
            {
                context.SetupCameraProperties(camera);

                ClearRenderTarget(context, camera);

#if UNITY_EDITOR
                if (camera.cameraType == CameraType.SceneView)
                    ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
#endif

                if (!camera.TryGetCullingParameters(out ScriptableCullingParameters cullingParameters))
                    continue;

                var cullingResults = context.Cull(ref cullingParameters);

                RenderOpaque(context, camera, ref cullingResults);

#if UNITY_EDITOR
                if (camera.cameraType == CameraType.SceneView)
                    context.DrawGizmos(camera, GizmoSubset.PreImageEffects);
#endif
                RenderTransparent(context, camera, ref cullingResults);
#if UNITY_EDITOR
                if (camera.cameraType == CameraType.SceneView)
                    context.DrawGizmos(camera, GizmoSubset.PostImageEffects);
#endif
            }

            context.Submit();
        }

        private void ClearRenderTarget(ScriptableRenderContext context, Camera camera)
        {
            var cmd = CommandBufferPool.Get();
            cmd.Clear();

            switch (camera.clearFlags)
            {
                case CameraClearFlags.SolidColor:
                case CameraClearFlags.Depth:
                    var clearDepath = true;
                    var clearColor = camera.clearFlags == CameraClearFlags.SolidColor;
                    cmd.ClearRenderTarget(clearDepath, clearColor, camera.backgroundColor);
                    context.ExecuteCommandBuffer(cmd);
                    break;
                case CameraClearFlags.Skybox:
                    cmd.ClearRenderTarget(true, false, camera.backgroundColor);
                    context.ExecuteCommandBuffer(cmd);
                    context.DrawSkybox(camera);
                    break;
            }

            CommandBufferPool.Release(cmd);
        }

        private void RenderOpaque(ScriptableRenderContext context, Camera camera, ref CullingResults cullingResults)
        {
            var sortingSettings = new SortingSettings(camera)
            {
                criteria = SortingCriteria.CommonOpaque
            };

            var drawingSettings = new DrawingSettings(new ShaderTagId("SRPDefaultUnlit"), sortingSettings);
            var filterSettings = new FilteringSettings(
                new RenderQueueRange(RenderQueueRange.minimumBound, (int)RenderQueue.GeometryLast),
                camera.cullingMask
            );

            context.DrawRenderers(cullingResults, ref drawingSettings, ref filterSettings);
        }

        private void RenderTransparent(ScriptableRenderContext context, Camera camera, ref CullingResults cullingResults)
        {
            var sortingSettings = new SortingSettings(camera)
            {
                criteria = SortingCriteria.CommonTransparent
            };

            var drawingSettings = new DrawingSettings(new ShaderTagId("SRPDefaultUnlit"), sortingSettings);
            var filterSettings = new FilteringSettings(
                new RenderQueueRange((int)RenderQueue.GeometryLast + 1, RenderQueueRange.maximumBound),
                camera.cullingMask
            );

            context.DrawRenderers(cullingResults, ref drawingSettings, ref filterSettings);
        }
    }

    protected override RenderPipeline CreatePipeline()
    {
        return new MyRenderPipeline();
    }
}

描画結果 f:id:enrike3:20191009020310p:plain

シーンビュー。ちゃんとGizmoが表示されます。 PreImageEffect/PostImageEffectの違いは正直謎です… f:id:enrike3:20191009020543p:plain

フレームデバッガ。想定通りの挙動です。
f:id:enrike3:20191009020752p:plain

コードの全体の流れとしては以下の通りです

カメラ毎に以下を行う

  • レンダーターゲットをクリア (今回の例だとDepthのみクリアしカラーはSkyBoxで埋める)
  • 描画対象を抽出 (カリング)
    • カメラ渡せばカリングパラメータがとれる便利メソッドがあるので完全にそれまかせ
  • 不透明描画
    • 不透明描画の場合は手前から描くとZTestが効率的なので、SortingSettingはOpaque用(不透明)
    • RnderQueueRangeは、UnityはMaterialのRenderQueueは2500以下を不透明描画とみなすので、下限 ~ 2500
  • 透明描画
    • 透明/半透明描画の場合は奥から描かないと破綻するので、SortingSettingはTransparent用(透明)
    • RnderQueueRangeは、UnityはMaterialのRenderQueueは2500より上を透明描画とみなすので、2501 ~ 上限
  • エディタの場合はカメラがシーンビューの場合はおまじないコードをかく。Gizmoとか

描画そこまで詳しいわけじゃないですけど、Unlitだからだいたいこんなもんでしょうたぶんきっと。SkyBoxはOpaque描画の最後にもってきたほうがいいとかあるかもしれないけど。

Unlitではなくライト対応にする場合は、まずベースの描画をしてからライト毎の加算をしたり 影をつくるためにシャドウマップに書き込んだりが発生してくると思います。 また、ポストエフェクトを使うならRenderTextureに描画をまとめてからごにょごにょしたり。

しかし、実際に使い物になるレベルに引き上げるのは大変です。今回のような素朴なSRPですらシーンカメラ用の謎のおまじないを書く必要があって、しかもこれだけで足りているのかよくわかりません。 今回の例はあくまでSRPとはこんなものという学習用、大づかみ用と割り切ってやはりUnity公式がメンテし機能追加していくUniversalRPを使っていきたいところです。

次の記事は UniversalRPのモジュラー構造について書くつもりです。書くつもりです。書くつもりです。

TaskとTaskCompletionSource<TResult>

C#で非同期はとりあえずasyncなメソッドをawaitつけて呼んどきゃいい」

これだけではいけないコードを最近よく書いているので、 生のTaskTaskCompletionSource<TResult>について書いてみることにしました。

なお、UniTaskにもUniTaskCompletionSourceがあるので、この記事の内容はUniTaskでも活きるはずです、たぶん…

Task/TaskCompletionSource<TResult> 基礎編

そもそもTaskだとかawaitは「なんかの処理が終わったら続きを呼び出してくれる」機構です。 処理の終わりが戻り値相当の値を伴うこともあれば例外でエラー終了することもあります。

Task/Task<TResult>には、TaskCompletionSource<TResult>という相方クラスがあり 処理の終了は、TaskCompletionSource<TResult>.SetResult(result)TaskCompletionSource<TResult>.SetException(exception)で行い、 終了時に継続して欲しい処理は、Task.ContinueWith(continuation)で行います。

var tcs = new TaskCompletionSource<int>();
Console.WriteLine("#0");

//継続処理を登録する
var task = tcs.Task;
task.ContinueWith(t => Console.WriteLine("#1"), TaskContinuationOptions.ExecuteSynchronously);

Console.WriteLine("#2");

//結果を突っ込むことでTaskの待ちを終わらせ、ContinueWithに登録した継続処理を走らせる
tcs.SetResult(0);
#0
#2
#1

すでに処理が終了した(=Resultをもつ)TaskにContinueWithで継続を登録するとどうなるでしょうか。 これは同期実行されます。

var tcs = new TaskCompletionSource<int>();
Console.WriteLine("#0");

//継続処理を登録する
var task = tcs.Task;
//結果を突っ込むことでTaskの待ちを終わらせる
tcs.SetResult(0);

//終わっているTaskに継続を登録
task.ContinueWith(t => Console.WriteLine("#1"), TaskContinuationOptions.ExecuteSynchronously);
Console.WriteLine("#2");
#0
#1
#2

つまり、Taskに継続を登録するときに、完了しているかどうかを気にする必要はないわけです。

async/awaitを使うと、継続の登録をすごくよしなにやってくれます。 以下のコード例はAsyncStateMachineとかAwaiterとかSyncronizationContextを無視していてものすごく不正確ですが、動作は雰囲気こんな感じ。

このasyncは

async Task HogeAsync(int x)
{
    if (x > 0)
        return;

    await FugaAsync();
    Console.WriteLine("Hoge");
}

こう変換されます。(繰り返しますがすごい不正確ですからね。雰囲気掴みのためのコードです)

Task HogeAsync(int x)
{
    TaskCompletionSource<int> __tcs__ = new TaskCompletionSource<int>();
    if (x > 0)
    {
        __tcs__.SetResult(0);
        return __tcs__.Task;
    }

    FugaAsync().ContinueWith(_ => {
        Console.WriteLine("Hoge");
        __tcs__.SetResult(0);
    });
    return __tcs__.Task;    
}

asyncと書くとTaskCompletionSourceが暗黙のうちに作られて、 それ経由で戻り値や例外が伝搬するのだ、という感覚でOKです。

何もしない非同期メソッドがある場合

async Task NoWait()
{
}

と書くよりも

Task NoWait()
{
    return Task.CompletedTask;
}

と書く方が警告がウザくないです。

同期で値を返す場合は

async Task<int> NoWait()
{
    return 0;
}

と書くよりも

Task<int> NoWait()
{
    return Task.FromResult(0);
}

と書く方が警告がウザくないです。

Task/TaskCompletionSource<TResult> 応用編

コルーチンをTaskに変換してawait可能にする

コルーチンの〆にTaskCompletionSourceでSetResultしてあげるだけですね。簡単

public class Maintainer : MonoBehaviour
{
    async void Start()
    {
        bool sirannoka = true;
        while(sirannoka)
        {
            //メンテが終わるまでまつ。終わるとどうなる?
            await DoMaintenanceAsync();
        }
    }

    Task<string> DoMaintenanceAsync()
    {
        var tcs = new TaskCompletionSource<string>();
        StartCoroutine(DoMaintenanceYield(tcs));
        return tcs.Task;
    }

    IEnumerator DoMaintenanceYield(TaskCompletionSource<string> tcs)
    {
        yield return new WaitForSeconds(60 * 60 * 72);
        tcs.SetResult("メンテが終わる");
    }
}

余談ですが、非同期対応のループって言語の支援が無ければなかなか難しいのですが、 awaitなら簡単に同期っぽく書けますね。これにはコブラさんもにっこり。

Taskの終了をコルーチンでまつ

コルーチンにタスクを渡して状態をチェックします。 Task<TResult>の場合はResultプロパティから値を取れます。

void Start()
{
    var messageTask = DelayedMessage();
    StartCoroutine(WaitYield(messageTask));
}

async Task<string> DelayedMessage()
{
    await Task.Delay(1000 * 60 * 60 * 72);
    return "またせたな";
}

IEnumerator WaitYield(Task<string> task)
{
    while(!task.IsCompleted)
    {
        //Taskが終わるまではフレームスキップ
        yield return null;
    }
    Debug.Log(task.Result); //完了前にResultにアクセスするとスレッド止まるんで注意
}

これは別にコルーチンでなくてUpdateでも構いません。 Unity組み込みの毎フレーム処理と統合するには単にTaskに生えている各種フラグをチェックするだけでいい、ということです。

非同期の結果をキャッシュする

待たされる処理の結果をキャッシュして、待たされるのは一回だけにしたい。

//NGの例
SugoiHeavyItem _cached = null;
async Task<SugoiHeavyItem> LoadOrCached()
{
    if(_cached != null)
    {
        return _cached;
    }

    _cached = await LoadSugoiHeavyItem();
    return _cached;
}

この処理だと、LoadSugoiHeavyItem()が完了するまでの間にLoadOrCachedを複数回呼ばれるといけません。 awaitは時間が空くので、_cachedが埋まるまでに隙があります。awaitを使う場合はこの手の注意が必要です。

修正するには、awaitをやめてTask自体をキャッシュしてしまいます。

Task<SugoiHeavyItem> _cached = null;
Task<SugoiHeavyItem> LoadOrCached()
{
    if(_cached != null)
    {
        return _cached;
    }

    _cached = LoadSugoiHeavyItem();
    return _cached;
}

awaitがなく_cachedが埋まるまで同期処理なので、同じスレッドから呼ばれる限り隙はありません。 マルチスレッドを考慮してlockで囲うのも簡単です。

MessageBrokerのPublish先で長い処理をさせてそれを待つ

MessageBrokerに限らずeventでもなんでもいいですが、たまに欲しくなるんですよね。

//Publish先で複数フレームにまたがる処理をさせてそれをawaitしたいけど、
//Publishが同期メソッドだからawaitできないお
MessageBroker.Publish();

こういう時はTaskCompletionSourceを渡してしまいます。

//呼び出し側
var tcs = new TaskCompletionSource<int>();
MessageBroker.Publish(tcs);
//Publish先でTaskが完了するのを待つ
await tcs.Task;
//呼び出され側
MessageBroker.Register(tcs => StartCoroutine(DoCoroutine(tcs)));

IEnumerator DoCoroutine(TaskCompletionSource<int> tcs)
{
    yield return new WaitForSecondes(3);
    tcs.SetResult(0);
}

まとめ

  • awaitは非常に便利なので、特にループ、分岐、例外処理などスムーズに書ける。言語サポート万歳
  • 何も考えずにawaitするだけだとできないこともあり、それを生のTaskTaskCompletionSourceを触ることで超えられたりする

表情差分の抽出と切り替え(Tightメッシュ対応編)

前回の記事で、 FullRectはフィル落とせないから微妙だなぁ、という結論を書いたのですが 表情差分を切り替えやすいメッシュを作りつつもTightメッシュ対応する方法を思いついたので実装してみました。

方法は結構簡単でして、 まず、表情差分領域を除外した領域をTightメッシュ化すべくSpriteに切り出します。 これは4枚のSpriteを使えば実現できます。

f:id:enrike3:20190217032907p:plain

_Tight-L, _Tight-R, _Tight-B, _Tight-Tが該当です。

この状態でSpriteのMesh TypeをTightに指定すると、 Unityが_Tight-L, _Tight-R, _Tight-B, _Tight-Tそれぞれに対してTightメッシュを切ってくれます。

あとは、表情差分用の板ポリと_Tight-L, _Tight-R, _Tight-B, _Tight-Tそれぞれに作られたメッシュを結合します。 今回はMesh.CombineMeshを使用しました。

生まれたメッシュがこちら

f:id:enrike3:20190217033334p:plain

表情差分をコードで切り替えやすいシンプルな板ポリが顔にあって、 あとはTightメッシュできっちりフィルを落とせるのが見て取れます。 ちなみにこれで414頂点です。 2Dゲームは頂点シェーダよりピクセルシェーダに負荷がいきがちであることを考えると、 およそ400頂点増でここまで透明ピクセルを落とせるなら悪くないトレードオフに思います。

もちろん描画結果も問題なし

f:id:enrike3:20190217042501p:plain

16頂点のFullRect版を使うかTightメッシュを使うかは選べるようにしました。 意外と満足のいく出来になってきたので、こんどはTightパッキングに挑戦してみようかな…

表情差分の抽出と切り替え

2019/02/17追記
Tightメッシュにも対応しました

ノベルパートでの立ち絵での表情の切り替え テラシュール御大でも何度かネタにされているあれです。

tsubakit1.hateblo.jp

これについて、自分は「表情差分とそれ以外がちゃんと分かれたメッシュを作って顔の箇所のUVを切り替えればよいのでは」と長年うっすらと考えていました。 9sliceの真ん中を書き換えるようなイメージです。

で、このたび実装してみました。

github.com

どんな感じか

アセットの準備

何はともあれ表情切り替えのある画像を用意します。 以下はUnity-Chanの公式よりありがたくいただいてきました。

f:id:enrike3:20190216213215p:plain
assets

次にぎっはぶのコードをプロジェクトに突っ込みます。 コンパイルが通ったらProjectViewを右クリックして、「Create → ScenarioCharacterAssetGenerator」をぽちります。

f:id:enrike3:20190216213729p:plain
creategenerator

これは表情差分の抽出、画像のパッキング、Meshの生成をするためのGenerator (ScriptableObject)が作られます。 インスペクタは下図のようになっています。

f:id:enrike3:20190216214337p:plain
generator

こやつに、画像と出力パスを設定します。 立ち絵のデフォルトになる画像をBaseTextureに、差分をDiffTexturesに、出力先をDestination Pathに入力します。 Block Sizeは圧縮テクスチャのブロックサイズです。DTX1/DTX5, ETC2, ASTC(4x4)あたりを使うなら初期値4のままでかまわないです。RGBA16やRGBA32などの圧縮なしや減色のみのフォーマットを使うならBlock Sizeは1としても良いです。 Diff Rect, Diff Rect Adjusted By Block Sizeは差分抽出中に勝手に入る数値です。デバッグ用に表示してます。

「表情差分画像生成」ボタンをポチると、マルチスプライトが生成されます。

f:id:enrike3:20190216215044p:plain
scm_multisprite

表情差分側のほうはBlockSize分の余分なピクセルが周辺にあります。これは圧縮テクスチャ使用時、引き延ばしたときにBilinearフィルタで絵のつなぎ目が破綻しないように、という処置で今回のこだわりポイントです。単なる色の引き延ばしであるExtrudeではダメでした。

f:id:enrike3:20190216215219p:plain
aroundblocksize

パックされた画像が出来上がったら「Mesh生成」をぽちります。パック画像と同じフォルダにメッシュができます。

実際に使用する

MeshRendererにScenarioCharacterMeshコンポーネントを貼り付け、表情のSpriteを全部ぶち込みます。 Materialは_MainTex_Colorを指定できるものならなんでもよく、つまりSprites-Defaultを使えます。 MeshFilterには生成したMeshを指定してください。

f:id:enrike3:20190216215814p:plain
scm_scminspector

SpriteIndexに、使いたい表情のIndex値を指定すれば表情が切り替わるはずです。 なんか描画がおかしかったら一度プレイモードで再生してみてください。わりと起きます。ExecuteInEditMode難しいんじゃい…

f:id:enrike3:20190216220235p:plain
rendering

5枚の板ポリが含まれたメッシュをただ描画するだけで、画像もパックされてますので当然ドローコールは1です。板ポリ1枚描画するのと大差ないといっていいでしょう。

f:id:enrike3:20190216220412p:plain
stats

良い手法かどうか

メッシュを準備する手間があるかわりに描画方法としてはとても単純で素直なものなので、全体と表情差分を別描画する方式と違って半透明の問題が起きません。

ただし結構大きな欠点として、TightメッシュではなくFullRectで描いているので透明領域のピクセルの塗りが無駄オブ無駄です。フィルが良くない。

よって、頂点数を増やしてフィルを落としたいなら結局Tightメッシュ+表情別描画+SpriteMaskのテラシュール方式りに落ち着きそうな気もします。有料でいいなら宴のダイシングが描画の素直さとフィルの削減で一番バランスが良いかなぁ。