コポうぇぶろぐ

コポコポによる備忘録&Tipsブログ

【Unity】Imposterをお試し実装

はじめに

本記事は Unity Advent Calendar 2019 の 18日目 の記事です。遅れまして申し訳ございません。
前日の記事は saragaiさん の「【Unity】ゼロから作るノードベースエディター【UIElements】」でした!
qiita.com


本記事では、 Imposter の実装について簡単にまとめてみました。
Imposter は UE4 では無料で利用でき、Unity では AssetStore にて購入することで利用できます。考案者のブログで実装内容が解説されているので、その内容を可能な限り踏襲しておりますが、時間の都合もあり雑な部分が多々ありますのでご注意ください。

f:id:coposuke:20191223004006j:plain

諸注意

本記事およびGithub上にあるプロジェクトは UnityChan License のアセットデータを利用しています。

© Unity Technologies Japan/UCL

Twitterにアップした動画にライセンス表記を忘れており、大変恐縮ですが関連ツイートはこの場にて表記いたします。

ソースはGithubからご覧ください。
github.com

Imposterとは

Imposter とは、様々な角度に対応したスプライトを用いて、ビルボードで3Dに見せる手法のことです。最初商品名や機能名だと思ってましたが、Houdini/UE4/Unity で Imposter と呼ばれていたので、技術名ということで恐らく合っていると思います。使用目的としては LOD で 遠距離な状態だけどそれっぽい見た目のモデルを表示したい場合に利用されます。

この度参考にさせていただいた記事(Epic Gamesの方(考案者?))
www.shaderbits.com

スプライトを作る

エディタを準備する

EditorWindow にて、多方向アングルのスプライトを作成します。今回は PreviewRenderUtility にて描画するシステムを作りましたが、シーンビューに諸々を配置したほうが良いです(後述)。

public class OctahedralTextureEditor : EditorWindow
{
    [System.Serializable]
    private struct MouseAction
    {
        public Vector3 rotateEuler;
        public float zoom;
    };

    private PreviewRenderUtility renderer;
    [SerializeField]
    private MouseAction mouseAction;
    [SerializeField]
    private float cameraFov = 15.0f;

    [MenuItem("Window/OctahedralTextureEditor")]
    public static void CreateWindow()
    {
        var window = EditorWindow.CreateWindow<OctahedralTextureEditor>();
        window.Show();
        window.mouseAction.zoom = 10.0f;
        window.mouseAction.rotateEuler = Quaternion.LookRotation(new Vector3(0f, -0.5f, 0.5f).normalized).eulerAngles;
    }

    private void OnDestroy()
    {
        if (this.renderer != null)
            this.renderer.Cleanup();
    }

    private void OnGUI()
    {
        Setup();

        DrawScene();
        DrawUI(); // 後述

        DoEvent(); // 後述
        if (GUI.changed) { Repaint(); }
    }

    private void Setup()
    {
        if (this.renderer == null)
        {
            this.renderer = new PreviewRenderUtility();
            this.renderer.ambientColor = RenderSettings.ambientLight;
            this.renderer.m_Light = FindObjectsOfType<Light>();
        }
    }

    private void DrawScene()
    {
        var rect = new Rect(0, 0, this.position.size.x, this.position.size.y);
        this.renderer.camera.nearClipPlane = 0.1f;
        this.renderer.camera.farClipPlane = 10000.0f;
        this.renderer.camera.fieldOfView = cameraFov;
        this.renderer.camera.transform.position = Quaternion.Euler(mouseAction.rotateEuler) * new Vector3(0, 0, -this.mouseAction.zoom);
        this.renderer.camera.transform.rotation = Quaternion.Euler(mouseAction.rotateEuler);
        this.renderer.camera.clearFlags = CameraClearFlags.SolidColor;

        // グリッドの表示(実装は割愛いたします)
        var grid = OctahedralGrid.CreatePrimitive(10.0f, 100, 10, Color.white * 0.125f, Color.white * 0.25f);
        var gridMaterial = new Material(Shader.Find("OctahedralImposter/Grid"));
        renderer.DrawMesh(grid, Vector3.zero, Quaternion.identity, gridMaterial, 0);

        this.renderer.BeginPreview(rect, GUIStyle.none);
        this.renderer.camera.Render();
        var tex = this.renderer.EndPreview();

        GameObject.DestroyImmediate(grid);

        GUI.DrawTexture(rect, tex);
    }

    // ...
}

f:id:coposuke:20191223022201j:plain

上記のソースは主要な部分だけを抜粋しています。残りの実装としては…

◆グリッド実装部分
mesh.SetIndices(indecies, MeshTopology.Lines, 0) で メッシュではなく線を描画しています。途中 "OctahedralImposter/Grid" シェーダを取得してきていますが、単色を描画しているだけのシェーダになります。

◆マウスでカメラ操作
EventType.MouseDrag や EventType.ScrollWheel イベントを用いてカメラを操作できるようにしています。確認用に用意したものです。

ひとまずこれでモデルを表示する準備が整いました。端折ったソースは Github にございますので、気になる方はご覧ください。

平面を作る

いきなりですが、ここは通常の Imposter とは違う実装になります。

Imposter では Octahedron Sphere を作成し、各頂点に適切なインデックスを付けることで、無駄が少ないスプライトを作成しているのですが、本記事ではお試し実装ということもありかなり雑です。今回は平面を作り、平面から半球になるようにいい加減に補正した実装になりますので、ご注意ください。
クラス名にも Octahedral となっておりますが、OctahedronSphere で実装されておりません(悲)

多方面から見たモデルのスプライトを作成しますが、下の画像のような平面となる頂点を作成します。
f:id:coposuke:20191223032145j:plain

static public class OctahedralHemiSphere
{
    static public Mesh CreatePrimitive(float radius, int length, float ratio = 1.0f)
    {
        if (length <= 1)
        {
            Debug.LogWarning("OctahedralMeshGenerator length is must 1 over.");
            length = 2;
        }

        length++;
        Vector2 add    = Vector2.one * (1.0f / (length - 1));
        Vector2 offset = new Vector2(-0.5f, -0.5f);

        Vector3[] vertices = new Vector3[length * length];
        Vector3[] normals = new Vector3[length * length];
        for (int y = 0; y < length; y++)
        {
            for (int x = 0; x < length; x++)
            {
                var vertex = new Vector3(add.x * x + offset.x, 0f, -add.y * y - offset.y) * 2.0f;
                vertices[y * length + x] = vertex * radius;
                normals[y * length + x] = Vector3.up;
            }
        }

        int lineSquares = length - 1;
        int lineSquaresHalf = Mathf.RoundToInt(lineSquares * 0.5f);
        int[] triangles = new int[lineSquares * lineSquares * 2 * 3];
        for (int y = 0, i = 0, square = 0; y < lineSquares; y++)
        {
            for (int x = 0; x < lineSquares; x++)
            {
                if (x < lineSquaresHalf ^ y < lineSquaresHalf)
                {
                    triangles[i++] = square;
                    triangles[i++] = square + 1;
                    triangles[i++] = square + length;
                    triangles[i++] = square + 1;
                    triangles[i++] = square + length + 1;
                    triangles[i++] = square + length;
                }
                else
                {
                    triangles[i++] = square;
                    triangles[i++] = square + 1;
                    triangles[i++] = square + length + 1;
                    triangles[i++] = square;
                    triangles[i++] = square + length + 1;
                    triangles[i++] = square + length;
                }
                square++;
            }
            square++;
        }


        Mesh mesh = new Mesh();
        mesh.vertices = vertices;
        mesh.normals = normals;
        mesh.triangles = triangles;
        return mesh;
    }
}

public class OctahedralTextureEditor : EditorWindow
{
    [SerializeField]
    private float modelRatio = 1.0f;
    [SerializeField]
    private int modelMeshes = 5;
    [SerializeField]
    private float modelScale = 1.0f;

    private void DrawScene()
    {
        // ... this.renderer.camera ...

        var mesh = OctahedralHemiSphere.CreatePrimitive(modelScale, modelMeshes, modelRatio); // ←追記
        var grid = OctahedralGrid.CreatePrimitive(10.0f, 100, 10, Color.white * 0.125f, Color.white * 0.25f);
        var gridMaterial = new Material(Shader.Find("OctahedralImposter/Grid"));
        var material = new Material(Shader.Find("OctahedralImposter/Wireframe")); // ←追記

        renderer.DrawMesh(mesh, Vector3.zero, Quaternion.identity, material, 0); // ←追記
        renderer.DrawMesh(grid, Vector3.zero, Quaternion.identity, gridMaterial, 0);

        // ... this.renderer.camera.Render(); ...

        GameObject.DestroyImmediate(mesh); // ←追記
        GameObject.DestroyImmediate(grid);
    }
}

毎回エディター描画を更新するたびに Create - Destroy しておりますが、かなり無駄な処理ですのでご注意ください。途中 "OctahedralImposter/Wireframe" シェーダを取得していますが、下記URLのものを利用させていただいております。
github.com

平面から半球(Hemi-sphere)を作る

本来ならこんな作業必要ないんですけどね…。半球を作ります。
f:id:coposuke:20191223040720g:plain

static public class OctahedralHemiSphere
{
    static public Mesh CreatePrimitive(float radius, int length, float ratio = 1.0f)
    {
        // ...

        Vector3[] vertices = new Vector3[length * length];
        Vector3[] normals = new Vector3[length * length];
        for (int y = 0; y < length; y++)
        {
            for (int x = 0; x < length; x++)
            {
                var vertex = new Vector3(add.x * x + offset.x, 0f, -add.y * y - offset.y) * 2.0f;
                float angle = Mathf.Atan2(vertex.z, vertex.x);

                float oneFrameRatio = 1.0f / Mathf.Max(Mathf.Abs(vertex.x), Mathf.Abs(vertex.z), 1e-5f);
                float maxMagnitude = Mathf.Max((vertex * oneFrameRatio).magnitude, 1.0f);

                Vector3 deformedVertex;
                deformedVertex = vertex / maxMagnitude;
                deformedVertex.y = Mathf.Cos(vertex.magnitude / maxMagnitude * Mathf.PI * 0.5f);
                deformedVertex.Normalize();

                vertices[y * length + x] = Vector3.Lerp(vertex, deformedVertex, ratio) * radius;
                normals[y * length + x] = Vector3.up;
            }
        }

        // ...
    }
}

先程頂点を作成していた箇所にて、上記のように変更します。平面の四隅を中心に引っ張るような処理で、平面(-1.0 ~ 1.0)までの距離を算出し(maxMagnitude)、最大との割合を求めたら自然と1を半径とした円が出来上がります。そしてY軸は割合から徐々に天辺に向かってCosで膨らませていますが、ここはかなりいい加減な処理になっています。なので挙句の果てには Normalize してごまかしています。

スプライトを生成する

半球が出来ましたので、早速スプライトを作成していきます。
半球の各頂点の位置にカメラを配置し、モデルに向かってキャプチャします。

public class OctahedralTextureEditor : EditorWindow
{
    [SerializeField]
    private int captureResolution = 10;
    [SerializeField]
    private Vector3 captureLookatOffset;
    [SerializeField]
    private GameObject targetObject = null;

    private void Capture()
    {
        var mesh = OctahedralHemiSphere.CreatePrimitive(modelScale, modelMeshes, modelRatio);
        var meshVertices = mesh.vertices;
        var rect = new Rect(0, 0, this.position.size.x, this.position.size.y);
        var captures = modelMeshes + 1;
        var oneSize = 1.0f / captures;

        int textureResolution = Mathf.FloorToInt(Mathf.Pow(2, this.captureResolution));
        var bgColor = this.renderer.camera.backgroundColor;
        this.renderer.BeginPreview(new Rect(0f, 0f, textureResolution, textureResolution), GUIStyle.none);

        for (int i = 0; i < meshVertices.Length; ++i)
        {
            var capturePoint = meshVertices[i];
            var captureLookAt = Quaternion.LookRotation(Vector3.Normalize(this.targetObject.transform.localPosition + captureLookatOffset - capturePoint));

            this.renderer.camera.transform.localPosition = capturePoint;
            this.renderer.camera.transform.localRotation = captureLookAt;

            this.renderer.camera.rect = new Rect(oneSize * (i % captures), oneSize * (i / captures), oneSize, oneSize);
            this.renderer.camera.backgroundColor = Color.clear;
            this.renderer.camera.Render();
        }

        var tex = this.renderer.EndPreview();
        var tex2D = ToTexture2D(tex);
        System.IO.File.WriteAllBytes(Application.dataPath + "/capture.png", tex2D.EncodeToPNG());

        this.renderer.camera.backgroundColor = bgColor;
        this.renderer.camera.rect = new Rect(0.0f, 0.0f, 1.0f, 1.0f);

        Debug.Log(Application.dataPath);
        DestroyImmediate(mesh);
        AssetDatabase.Refresh();
    }

    public static Texture2D ToTexture2D(Texture self)
    {
        var sw = self.width;
        var sh = self.height;
        var format = TextureFormat.RGBA32;
        var result = new Texture2D(sw, sh, format, false);
        var currentRT = RenderTexture.active;
        var rt = new RenderTexture(sw, sh, 32);
        Graphics.Blit(self, rt);
        RenderTexture.active = rt;
        var source = new Rect(0, 0, rt.width, rt.height);
        result.ReadPixels(source, 0, 0);
        result.Apply();
        RenderTexture.active = currentRT;
        return result;
    }
}

this.targetObject の実装は割愛させていただきますが、DragAndDropをトリガーにAssetDatabaseからロード&インスタンスしています。this.renderer.camera の rect や backgroundColor を調整して、連続して描画しています。その後書き込んだ内容を PNG に出力しています。

苦戦したこととして、Texture から Texture2Dに変換する方法が分からず、下記サイトを参考にさせていただきました。GetPixel - SetPixel するのかなと絶望してましたが、Graphics.Blit でスマートに実装されています。
nakamura001.hatenablog.com

又、ライティングの調整にも手こずってしまいました。今回 PreviewRenderUtility にて描画していましたが、自由に買いティング設定ができなかったので、シーンにカメラとオブジェクトを配置して行うべきだと思いました。(PreviewRenderUtility.m_Lights にて無理やり設定しましたが Obsolete です)

cameraFov = 60、modelScale = 1.3、modelMeshes = 6 でキャプチャすると、下の画像のようなテクスチャが完成します。これでスプライトの準備が整いました!(数値調整は目分量)
f:id:coposuke:20191223043808p:plain

ビルボードを表示する

がむさんの記事を参考に、頂点シェーダでビルボードにしています。軸による制限はないので、ビュー行列の回転をスキップして、常に正面を向くような形にします。
gam0022.net

Shader "Custom/OctahedralImposter"
{
    Properties
    {
        _MainTex("Texture", 2D) = "white" {}
        _Tiles("Tiles", Int) = 6
    }

    SubShader
    {
        Tags { "Queue" = "Transparent" "RenderType" = "Transparent" }
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

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

            sampler2D _MainTex;
            float4 _MainTex_ST;
            int _Tiles;

            v2f vert(appdata v)
            {
                v2f o;

                // Billboard
                float3 viewPos = UnityObjectToViewPos(float3(0, 0, 0));
                float viewUpInverse = lerp(-1, 1, (0 <= UNITY_MATRIX_V._m11));
                float3 scaleRotatePos = mul((float3x3)unity_ObjectToWorld, v.vertex * viewUpInverse);
                viewPos += float3(scaleRotatePos.xy, -scaleRotatePos.z);
                o.vertex = mul(UNITY_MATRIX_P, float4(viewPos, 1));
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);

                return o;
            }

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

ビルボードにスプライトを貼る

各方向に対応したスプライトを貼ります。

struct v2f
{
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD0;
    float3 uvDir : TEXCOORD1; // ←追記
};

fixed4 clerp(fixed4 a, fixed4 b, float ratio)
{
    a.rgb = lerp(b.rgb, a.rgb, a.a);
    b.rgb = lerp(a.rgb, b.rgb, b.a);
    return lerp(a, b, ratio);
}

v2f vert(appdata v)
{
    v2f o;

    // ... Billboard ...

    float3 viewDir = unity_ObjectToWorld._m03_m13_m23 - _WorldSpaceCameraPos.xyz;
    viewDir = normalize(viewDir);

    float oneFrameRatio = 1.0f / max(max(abs(viewDir.x), abs(viewDir.z)), 1e-5);
    float maxMagnitude = max(length(viewDir.xz * oneFrameRatio), 1.0);
    o.uvDir = viewDir * maxMagnitude;

    return o;
}

fixed4 frag(v2f i) : SV_Target
{
    float tiles = _Tiles + 1;
    float2 uvDir = float2(i.uvDir.x, -i.uvDir.z);
    float2 uv = (float2(0.5, 0.5) - uvDir * 0.5);
    int2   gridID = (int2)floor(uv * tiles);

    fixed4 color = tex2D(_MainTex, (i.uv + gridID) / tiles);

    // debug
    color = clerp(color, tex2D(_MainTex, i.uv), 0.5); // origin texture
    color = lerp(color, float4(1, 0, 0, 1), step(distance(i.uv, uv), 0.01)); // red circle

    return color;
}

カメラからオブジェクトへの視線(viewDir)を算出し、作成したスプライトの位置となるように uvDir を作成します。頂点シェーダではエディタで平面から半球を作った時とは真逆に、テクスチャの四隅を中心から離れるように処理します。X軸かZ軸の大きい方が1となるようにベクトルを伸ばしています。

ピクセルシェーダの uvDir は中心(0.5, 0.5)からの向きをさしています。ただし下の画像のように、作成したスプライトではX軸だけ反転しているので、それに対応します。
f:id:coposuke:20191223055744j:plain

ピクセルシェーダの debug コメント以下は開発用表示です。これによりどのスプライトを際しているかビジュアライズされます。clerp はアルファ値が大きい方を優先してRGBを選択しています。
f:id:coposuke:20191223060237g:plain

見え方の調整

既に雑な箇所がいくつかありましたが、見た目をちょっと調整していきます。

トップビューの調整

生成されたスプライトのど真ん中であるトップビューは、下向きの状態でキャプチャ―されます。それにより真上あたりにカメラが来てしまうと、激しく回転したような見た目になってしまいます。この対策方法は今でも分かっておらず、今後の研究課題とさせてください。今回は時間がないので真上のテクスチャだけ向きに対応することにしました。

float2x2 rotate(float angle)
{
    float s = sin(angle), c = cos(angle);
    return float2x2(c, s, -s, c);
}

fixed4 frag(v2f i) : SV_Target
{
    float tiles = _Tiles + 1;
    float2 uvDir = float2(-i.uvDir.x, i.uvDir.z);
    float2 uv = (float2(0.5, 0.5) + uvDir * 0.5);
    int2   gridID = (int2)floor(uv * tiles);

    float whichUp = lerp(-1, 1, (0 <= UNITY_MATRIX_V._m11));
    float2 viewUp = UNITY_MATRIX_V._m10_m12 * whichUp;
    float uvRot = clamp(atan2(viewUp.x, viewUp.y), -UNITY_PI, UNITY_PI);
    float2 uvOrg = i.uv;
    float2 uvOrgRot = mul(rotate(uvRot), i.uv - 0.5) + 0.5;

    int gridCenter = 1;
    int enableRotate = (_Tiles % 2 == 0);

    gridCenter = (gridID.x == _Tiles / 2) && (gridID.x == gridID.y);
    float2 uvC = lerp(uvOrg, uvOrgRot, enableRotate * gridCenter) + gridID;

    fixed4 color = tex2D(_MainTex, uvC / tiles);

    // debug
    color = clerp(color, tex2D(_MainTex, i.uv), 0.25); // origin texture
    color = lerp(color, float4(1, 0, 0, 1), step(distance(i.uv, uv), 0.01)); // red circle

    return color;
}

f:id:coposuke:20191223070202g:plain

トップ周辺はいまだにスピンが発生します。これを防ぐにはトップビュー1か所でも複数のアングルが必要になる上に、カメラの姿勢に対してスプライトの向きを調整する必要がありそう(?)です。この辺りは詳細不明で、もうちょっと時間をかけて調べたいと思います。今回の半球の各頂点からキャプチャする方法はトップビュー付近に対して明らかにテクスチャが足りないので、実は初手の時点で失敗していました…。

垂直アングルの調整

Octahedron Sphere だとこのような調整は必要ないのかもしれないですが、今回は平面から半球に直す際に、Cosを用いたいい加減な調整でしたので、垂直移動した際のアングルとうまく一致していません。なので、またまた目分量ではありますが、X軸に足が乗っている状態となるように調整します。

v2f vert(appdata v)
{
    v2f o;

    // ... Billboard
    // ... ViewDir
    
    float oneFrameRatio = 1.0f / max(max(abs(viewDir.x), abs(viewDir.z)), 1e-5);
    float maxMagnitude = max(length(viewDir.xz * oneFrameRatio), 1.0);
    viewDir.y = sin(viewDir.y * UNITY_PI * 0.5f); // about..
    viewDir = normalize(viewDir);                 // about...
    o.uvDir = viewDir * maxMagnitude;

    return o;
}

f:id:coposuke:20191223061144j:plain

カメラの横移動の調整

オブジェクトとカメラの位置関係で ViewDir を求めていたので、カメラを横移動(トラック)させたときに、余計に向きを変えてしまうので、これまたいい加減な調整を施します。

v2f vert(appdata v)
{
    // ... Billboard

    float3 camDir = -UNITY_MATRIX_V._m20_m21_m22;
    float3 viewDir = unity_ObjectToWorld._m03_m13_m23 - _WorldSpaceCameraPos.xyz;
    //viewDir = normalize(viewDir);
    viewDir = normalize(viewDir + camDir * dot(camDir, viewDir)); // about..

    // ... UV Dir
}

f:id:coposuke:20191223062227j:plain

スプライトの補間

次のスプライトに徐々に切り替わる処理を施します。今回はアルファブレンドで徐々に切り替わるようにしています。

fixed4 frag (v2f i) : SV_Target
{
    float tiles = _Tiles + 1;
    float2 uvDir = float2(i.viewDir.x, -i.viewDir.z);
    float2 uv = (float2(0.5, 0.5) - uvDir * 0.5);
    int2   gridID = (int2)floor(uv * tiles);
    float2 gridPos = frac(uv * tiles);

    int gridCorner = (gridID.x == gridID.y) || ((gridID.x + gridID.y) == (tiles - 1));
    int which = abs(uvDir.x) > abs(uvDir.y); // for rotate
    float  gridSign = lerp(sign(uvDir.y), sign(uvDir.x), which);
    float2 gridH = lerp(float2(gridSign, 0.0), float2(0.0, gridSign), (float)which); // horizontal
    float2 gridV = lerp(float2(0.0, gridSign), float2(gridSign, 0.0), (float)which); // vertical
    gridV = lerp(gridV, sign(uvDir), (float)gridCorner);

    float gridRatioH = distance(float2(0.5, 0.5) * gridH, gridPos * gridH); // 0で余計な軸成分を消す
    float gridRatioV = distance(float2(0.5, 0.5) * gridV, gridPos * gridV); // 0で余計な軸成分を消す
    float2 gridRatio = (float2(0.5, 0.5) - gridPos) * 2.0; // -1 .. 1

    gridH *= sign(gridRatio) * -gridSign;
    gridV *= sign(gridRatio) * -gridSign;

    // ... uvOrg Rotation

    int2 gridIDC = gridID;
    int2 gridIDH = gridID + gridH;
    int2 gridIDV = min(abs(gridID + gridV), _Tiles);
    int gridCenter = 1;
    int enableRotate = (_Tiles % 2 == 0);

    gridCenter = (gridIDC.x == _Tiles / 2) && (gridIDC.x == gridIDC.y);
    float2 uvC = lerp(uvOrg, uvOrgRot, enableRotate * gridCenter) + gridIDC;
    gridCenter = (gridIDH.x == _Tiles / 2) && (gridIDH.x == gridIDH.y);
    float2 uvH = lerp(uvOrg, uvOrgRot, enableRotate * gridCenter) + gridIDH;
    gridCenter = (gridIDV.x == _Tiles / 2) && (gridIDV.x == gridIDV.y);
    float2 uvV = lerp(uvOrg, uvOrgRot, enableRotate * gridCenter) + gridIDV;

    fixed4 color = fixed4(0,0,0,0);
    fixed4 colorC = tex2D(_MainTex, uvC / tiles); // current
    fixed4 colorH = tex2D(_MainTex, uvH / tiles); // horizontal
    fixed4 colorV = tex2D(_MainTex, uvV / tiles); // vertical

    float4 colorMixH = clerp(colorC, colorH, gridRatioH);
    float4 colorMixV = clerp(colorC, colorV, gridRatioV);
    color = lerp(colorMixH, colorMixV, colorMixH.a < colorMixV.a);

    // debug
    color = clerp(colorC, tex2D(_MainTex, i.uv), 0.5);
    color = lerp(color, float4(1, 0, 0, 1), step(distance(i.uv, uv), 0.01));
    return color;
}

gridSign、gridH、gridVは次に補完するスプライトの選定する為の変数です。gridIDに対して加算することで、次のスプライトのgridIDが分かるようになっています。gridH は四隅で直角に回転しますが、XとYのどちらが大きいかで判断できます。gridV は四隅で斜めになりますので、コーナー専用の処理が入っています。
f:id:coposuke:20191223073217j:plain

gridRatioH、gridRatioV、gradRatio は補完する量の変数です。取りうる値が 0.0~0.5 となっており、一見おかしい処理に見えますが、これは下の画像のように、0.5 で 次のスプライトと逆転することによりこのような実装になっています。gradH と gradV に対して gradRatio を乗算しているのは、まさに逆転させるための処理です。
f:id:coposuke:20191223075605j:plain

最後に、各スプライトのカラーが取得出来たら、アルファ合成していきます。debug でも clerp を用いて、アルファ値が大きい方を選択してRGBを抽出していましたが、3色同時にというのは難しいので、H か V のアルファ値が大きい方を優先しています。
この処理はアルファ値に Distance Field を入れてあげて、Ratio に合わせてブレンドしていくとエッジがくっきりとした良い感じの補間となるみたいです。今回は Distance Field を作るところまでやるとキャパシティオーバー(ほんとにタイムオーバーしてる)ので実装しませんでした。

まとめ

長文な割に大半の部分がいい加減な処理で本当に申し訳ないです…。正直初手で転んだので立て直すのに必死でした…。今回いい加減になってしまった箇所も含めて、反省点と改善点を列挙したいと思います。

  • トップビューは要調査
  • キャプチャーするときは PreviewRenderUtility ではなくちゃんとシーンに置く(プレビューじゃないので)
  • とりあえずやってみる は辛い(OctahedronSphere や JumpFlooding等色々あった)

参考にしていたサイトでは、様々なテクニックが複合的に効果的に使われており、本記事には到底収まらないくらいの内容量でした。今回は一旦ここで置いておいて、上記テクニックを試した後再挑戦しようかなと思いました。

明日

明日は herieru さんの「Shaderお絵かきを初めて行うためのアプローチ - Qiita」です!
(遅刻して申し訳ございません。一応Advent Calenderということで体裁を整えておきます)