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のテラシュール方式りに落ち着きそうな気もします。有料でいいなら宴のダイシングが描画の素直さとフィルの削減で一番バランスが良いかなぁ。

MessagePackのデータをUnityのTreeViewで表示する

f:id:enrike3:20190210162406p:plain

MessagePack-CSharpは機能も性能も充実しているのでとにかく問答無用で使うわけですがJsonと違って中のデータを汎用エディタでさくっと覗けるわけではありません。 しかしマスタデータや、ユーザーの起動時ダウンロードデータなどをブレイクポイントをはって眺めるのもたいがい非効率なので、UnityにTreeViewも来たことだし、UnityのTreeViewでMessagePack-CSharpのデータを表示できるようにしてみました。まあどこも似たようなものは作っていると思うんですが…

github.com

使い方は、MessagePackGridViewWindowにMessagePackなObjectを放りこむだけです。 ただし、Deserialize時にFormatterが登録されている必要はあるので、非Playモードで使用するならInitializeOnLoadあたりでMessagePackのFormatterの登録は必要になります。

var msgPackObj = MessagePackSerializer.Deserialize<ExampleType>(bytes);
var window = EditorWindow.GetWindow<MessagePackGridViewWindow>();
window.SetData(msgPackObj);

現時点では表示のみだしソートもフィルタもできませんしUnion非対応だし、とアルファ以前ぐらいの出来ですがあまり気にせずに公開してみました。 おいおい機能は強化していく、と思います。

C#で確保済みbyte配列でmallocモドキ

GC任せにしてbyte配列何度も確保したくないし、Spanが来るから積極的に部分配列を使いたい。 byteに対して標準Cライブラリのmallocみたいなことできないかなーとほんのり思っていました。

mallocぽいというのはこういう感じですね。フラグメンテーション上等! f:id:enrike3:20180710220418p:plain

やってみたらSortedSetが優秀だったのでえらくあっさり作れました… スレッドセーフではないですけどね。シリアライズ用にちょいちょいSpanを借りるなら ArrayPoolからbyte借りてくるよりも向くのではないかなーとかとか。

using System;
using System.Collections.Generic;

public struct ByteSegment : IComparable<ByteSegment>
{
    public int Offset { get; }
    public int Length { get; }

    public ByteSegment(int offset, int length)
    {
        Offset = offset;
        Length = length;
    }

    public int CompareTo(ByteSegment other)
    {
        return Offset - other.Offset;
    }

    public override string ToString()
    {
        return $"{Offset},{Length}";
    }
}

public class BytePool
{
    SortedSet<ByteSegment> _segments = new SortedSet<ByteSegment>();
    public byte[] Buffer { get; private set; }

    public BytePool(int capacity)
    {
        Buffer = new byte[capacity];
    }

    public ByteSegment Alloc(int length)
    {
        int offset = 0;
        if(_segments.Count != 0)
        {
            foreach(var s in _segments)
            {
                var desired = offset + length;
                if(s.Offset < desired)
                {
                    offset = s.Offset + s.Length;
                    continue;
                }
                else
                {
                    break;
                }
            }
        }

        if (Buffer.Length < (offset + length))
            throw new Exception("無理ぽ");

        var newSegment = new ByteSegment(offset, length);
        _segments.Add(newSegment);

        return newSegment;
    }

    public void Release(ByteSegment segment)
    {
        _segments.Remove(segment);
    }

    public Span<byte> ToSpan(ByteSegment segment)
    {
        return new Span<byte>(Buffer, segment.Offset, segment.Length);
    }
}