ノートの端の書き残し

UnityやらC#やら。設計が得意かもしれない。

RPGでよくある画面がパリーンって割れて戦闘シーンに入るやつのシェーダの解説

すごくジオメトリシェーダのサンプルっぽいものができました。 まずジオメトリシェーダの説明をします。 Wikipedia曰く、ジオメトリシェーダとは

頂点シェーダーの後に実行され、プリミティブ全体または隣接したプリミティブの情報を持つプリミティブを入力する。例えばトライアングルを処理するとき、3つの頂点がジオメトリシェーダーの入力となる。ジオメトリシェーダーはラスタライズされるプリミティブを出力でき、そのフラグメントは最終的にピクセルシェーダーに渡される。またプリミティブを出力せずにキャンセルすることもできる。

ものだそうです。

サンプルを見てみる

頂点シェーダが各頂点1つずつ処理を行うのに対して、 ジオメトリシェーダは 1. ポイント、ライン、プリミティブ単位で入力を行い、 2. 入力数と異なった出力数を設定できる という強みがあります。が、モバイルでは動きません。
ジオメトリシェーダの使えない環境では最初から複数のバラバラのポリゴンとして用意して動かす必要があります。

「プリミティブ単位の操作」の実例として「RPGの戦闘シーン遷移時でよくある画面が割れるやつ」を作っていきます。 戦闘シーンっぽいものとかを作っていないのでシーン遷移した感じが皆無ですが、まぁ雰囲気はわかるのではないかと……

f:id:u_osusi:20190330010452g:plain

尚、先述したようにジオメトリシェーダはプリミティブの出力数を増やすこともできるのですが、今回は初めから多数のプリミティブで構成された平面を用意しました。

f:id:u_osusi:20190325030728p:plain (雑にカットされたPlane)
多分、割るところから動的にやるなら「ドロネー分割」というアルゴリズムを利用するのが妥当なのではないかと思います。

Shader "Custom/FinalFantasyX"
{
    Properties
    {
        _MainTex("MainTex", 2D) = white {]
        distortedTime("distortedTime", float) = 0
    }
    SubShader
    {
        Cull Off
        Tags { "Queue"="Transparent" "RenderType"="Transparent" }
        Blend SrcAlpha OneMinusSrcAlpha
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma geometry geom
            #pragma fragment frag

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

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

            // 疑似乱数
            float rand(float2 st)
            {
                return frac(sin(dot(st, float2(12.9898, 78.233))) * 43758.5453);
            }

            // 回転行列をかける
            float4 rotate(float4 pos, float4 axis, float angle)
            {
                float4 nAxis = normalize(axis);
                return mul(float4x4(cos(angle) + pow(nAxis.x, 2) * (1 - cos(angle)), nAxis.x * nAxis.y * (1 - cos(angle)) - nAxis.z * sin(angle), nAxis.x * nAxis.z * (1 - cos(angle)) + nAxis.y * sin(angle), 0,
                                nAxis.y * nAxis.x * (1 - cos(angle)) + nAxis.z * sin(angle), cos(angle) + pow(nAxis.y, 2) * (1 - cos(angle)), nAxis.y * nAxis.z * (1 - cos(angle)) - nAxis.x * sin(angle), 0,
                                nAxis.z * nAxis.x * (1 - cos(angle)) - nAxis.y * sin(angle), nAxis.z * nAxis.y * (1 - cos(angle)) + nAxis.x * sin(angle), cos(angle) + pow(nAxis.z, 2) * (1 - cos(angle)), 0,
                                0, 0, 0, 1
                    ), pos);
            };

            float distortedTime;
            sampler2D _MainTex;

            appdata vert(appdata v)
            {
                return v;
            }

            [maxvertexcount(3)]
            void geom(triangle appdata input[3], inout TriangleStream<g2f> outStream)
            {
                float4 vertex0 = input[0].vertex;
                float4 vertex1 = input[1].vertex;
                float4 vertex2 = input[2].vertex;
                float3 norm = normalize(cross(vertex0.xyz - vertex1.xyz, vertex2.xyz - vertex0.xyz));
                float3 axis = normalize(rotate(vertex1 - vertex0, float4(norm, 0), rand(vertex0.xy) * 3.14159265358979 * 2));
                float4 center = (vertex0 + vertex1 + vertex2) / 3;
                float angle = rand(vertex0.xy) * distortedTime * 10;
                [unroll]
                for(int i = 0; i < 3; i++)
                {
                    float4 rotatedVert = center * ((distortedTime / 3) + 1 + pow(distortedTime, 2)) + rotate(input[i].vertex - center, float4(axis, 0), angle);
                    g2f o;
                    o.vertex = UnityObjectToClipPos(rotatedVert);
                    o.uv = v.uv;
                    outStream.Append(o);
                }

                outStream.RestartStrip();
            }

            float4 frag(g2f i) : SV_Target
            {
                float4 color = tex2D(_MainTex, i.uv);
                return half(color.xyz, color.w * exp(-distortedTime));
            }
            ENDCG
        }
    }
}

ジオメトリシェーダの解説

[maxvertexcount(3)]
void geom(triangle appdata input[3], inout TriangleStream<g2f> outStream)
{
    float4 vertex0 = input[0].vertex;
    float4 vertex1 = input[1].vertex;
    float4 vertex2 = input[2].vertex;
    float3 norm = normalize(cross(vertex0.xyz - vertex1.xyz, vertex2.xyz - vertex0.xyz));
    float3 axis = normalize(rotate(vertex1 - vertex0, float4(norm, 0), rand(vertex0.xy) * 3.14159265358979 * 2));
    float4 center = (vertex0 + vertex1 + vertex2) / 3;
    float angle = rand(vertex0.xy) * distortedTime * 10;
    [unroll]
    for(int i = 0; i < 3; i++)
    {
        float4 rotatedVert = center * ((distortedTime / 3) + 1 + pow(distortedTime, 2)) + rotate(input[i].vertex - center, float4(axis, 0), angle);
        g2f o;
        o.vertex = UnityObjectToClipPos(rotatedVert);
        o.uv = v.uv;
        outStream.Append(o);
    }

    outStream.RestartStrip();
}

こいつを1つ1つ見ていきます。

Attribute

[maxvertexcount(3)]

これは、1回の入力に対して最大で頂点数3個までしか出力しませんという宣言です。必須です。 尚、出力頂点数には上限があり、1024/要素数より大きくはできません。 今回出力構造体は計float6つなので、1024/6 =170.66...より大きくはできません。

引数、戻り値

void geom(triangle appdata input[3], inout TriangleStream<g2f> outStream)

triangle単位で、appdata型の要素数3の配列を入力、g2f型の配列をtriangleのストリームとして出力する、と言っています。 入出力プリミティブはtriangleには限らず、いくつか用意されていますが、今回は割愛します。(参考リンク先に記述あります)

真ん中の算数

今、各トライアングルにやってほしい動きは 1. 回転する 2. 初めの長方形の中心点から遠ざかっていく の2つです。 回転軸は最初の長方形平面に平行だといい感じな気がするので、そうなるようにランダムに軸を設定します。

float3 axis = normalize(rotate(vertex1 - vertex0, float4(norm, 0), rand(vertex0.xy) * 3.14159265358979 * 2));

トライアングルの一辺を、法線を軸にランダムな角度回転させたものを回転軸としています。

rotatedVertが、指定時間における各頂点のオブジェクト内ローカル座標となります。 それをMVP変換して、座標いじりは終わり。 これで動きは完成です。

    outStream.Append(o);
}

outStream.RestartStrip();

Appendで、出力として登録、 RestartStripで、今登録されてる頂点群を一つのプリミティブとみなす宣言です。 3つ送られるたびにまとめられるイメージです。

あとは、直前までカメラが写していた映像を貼り付けてやりましょう。

カメラの映像を貼り付ける

C#スクリプトで記述できます。 Texture2D.ReadPixelsというメソッドがあり、非常に名前がわかりづらいのですが、これがスクリーンのピクセルをテクスチャにコピーするメソッドです。ということでこれで終わりですね。

終わり

RPGの戦闘シーン遷移時でよくある画面が割れるやつ」に必要なものが揃いました。
ここに載せたシェーダ以外には、遷移時に誤魔化しのように全体にブラーをかけるポストエフェクトを適用しています。

さて、以上のことを以下の手順で実行すればOK。

1. テクスチャを割れるやつ貼り付け、割れるやつをアクティブに(or 生成)する。
2. 遷移前シーンを削除する。
3. 割れるシェーダアニメーションを開始する。
4. Mainカメラにブラーをかける。
5. 遷移先シーンをアクティブにする。
6. Mainカメラのブラーを弱めていく。

さて、改めて完成したものを見てみましょう。 f:id:u_osusi:20190330010452g:plain

シェーダをいじり始めて3か月ほど、ジオメトリシェーダは初めて触りました。
まだかなり初心者っぽいですが、一応はちゃんと動いて整えれば使えそうなものができて楽しいですね。

参考

最終的に作ったものが違う以外完全上位互換な記事↓とてもわかりやすいです。 http://edom18.hateblo.jp/entry/2018/07/11/140455