ジオメトリシェーダでファーシェーダを実装した話

こんにちは、mo-takusanです。
今回は題名の通りファーシェーダを実装しました。

https://kcs1959.jp/archives/1928/research/unity%E3%81%A7fur-shader

こちらの先輩の記事を見て私も実装してみようと思い、今回取り組んでみました。
またググってみると、unityでファーシェーダを実装している記事は多くあるのですが、ジオメトリシェーダを利用しているものが見当たらなかったため、今回はこれを利用したものにすることを目標としました。

完成図は図のようになります。左側が今回実装したファーシェーダになります。左右どちらの球も同じテクスチャを貼っているのですが、違いは一目瞭然ですね。

2018-09-16_11h12_28

なお、実装においてはこちらの記事を大いに参考にしました。
https://qiita.com/edo_m18/items/75db04f117355adcadbb

また、実装において利用したテクスチャは次のサイトから使わせて頂きました。
http://photoshopvip.net/25382

ファーシェーダとは

ファーシェーダとは、その名の通り毛並みのふわふわ感を表現するシェーダを指します。
ファーシェーダは主にシェル法やフィン法などの実装方法がありますが、今回はシェル法を用いていきます。

シェル法

シェル法においては毛並みを一本一本描画していくわけではなく、層状に毛の断面を描画していくことで、全体として毛並みのように表現する手法です。
具体的には、次のように行います。

  1. 各頂点に対してそれぞれの法線方向に一定距離だけ移動した頂点を生成し、元のメッシュより一回り大きいメッシュを生成する。
  2. メインテクスチャとは別に毛束の断面のようなテクスチャを用意し、そのテクスチャのRGB値が一定を超えたテクセルに対してのみメインテクスチャの色を乗せ、それ以外の部分は描画を行わないようにする。
  3. メインテクスチャを貼る閾値を少し大きくしていきながら、1.及び2.の操作をくり返す。

1.の操作をジオメトリシェーダで、2.の操作をフラグメントシェーダによって実装していきます。

先述のようにシェル法では毛を一本一本描画するわけではないため、層の数が十分でない時は毛束に見えません。以下の図に示すように層が目立ってしまうことが分かると思います。

2018-09-16_11h19_14

実装

それでは実装を示していきます。
先述のように、1.をジオメトリシェーダで、2.をフラグメントシェーダで実装します。

まずは1.です。

[maxvertexcount(81)] //これ以上多くの頂点を生成することはできない
void geom(triangle appdata input[3], inout TriangleStream outStream)
{
    const float spacing = 0.35;
    const int start = _Iterator * START;
    const int end = start + _Iterator;

    [unroll(27)]
    for(int x = start; x < end; x++)
    {       
        float shellPos = _ShellInterval * x;

        [unroll]
        for(int y = 0; y < 3; y++)
        {
            g2f o;

            appdata v = input[y];

            float3 forceDir = 0;
            float4 pos = v.vertex;

            //ここの値はなんでも良さそうなのでそのまま流用させて頂いた
            forceDir.x = sin(_Time.y + pos.x * 0.05) * 0.2;
            forceDir.y = cos(_Time.y * 0.7 + pos.y * 0.04) * 0.2;
            forceDir.z = sin(_Time.y * 0.7 + pos.y * 0.04) * 0.2;
            forceDir += _Gravity - _Inertia;

            float factor = pow(shellPos, 3.0);
                        
            float3 norm = v.normal;
            norm += forceDir * factor;
            norm = normalize(norm) * shellPos * spacing;

            pos.xyz += norm;
            pos.w = 1.0; 

            o.position = UnityObjectToClipPos(pos);

            o.uv = v.texcoord;

            TANGENT_SPACE_ROTATION;
            o.lightDir = normalize(mul(rotation, ObjSpaceLightDir(pos)));

            TRANSFER_VERTEX_TO_FRAGMENT(o);

            o.iter = x;

            outStream.Append(o);
        }
        outStream.RestartStrip();
    }
}

今回ジオメトリシェーダでは三角メッシュを受け取って三角メッシュを出力します。それぞれのメッシュは依存関係がないため、PointStreamの入出力でもよさそうですが、仕様上PointStreamで出力してしまうと、面を貼ってくれなくなってしまうため、このように記述するしかありません。
ちなみに、maxvertexcountが81になっていますが、これより大きい3の倍数でVaridation Errorになってしまうためです(maxvertexcountのリファレンスを読んだのですがこれに関する記述は見当たりませんでした)。この値はフラグメントシェーダに値を渡す構造体の大きさにも依存していたので、渡せるデータサイズの上限値があるのでしょう。

残りは先ほど提示したページの実装とほとんど同じものです。
異なる部分はライティングのためのものになりますが、今回の内容と直接の関係はないので割愛します。触りだけ説明すると、ライトの方向を計算し、ライト情報を適切にフラグメントシェーダに渡しています。
詳しくはこちらの記事をご覧ください。
https://qiita.com/edo_m18/items/21d3b37596da3fd4b32b#%E9%96%A2%E9%80%A3%E3%83%AA%E3%83%B3%E3%82%AF

2.についてもライティング以外は同様の処理です。違いといえばノーマルマップによるライティングを行っている程度です。

さて3.についてですが、先ほどmaxvertexcountの値が81だと述べましたが、これでは層の数が最大でも27層までとなってしまい、元々表現したかったフサフサ感が損なわれてしまいます。これでは本末転倒なので、今回の実装でも参考ページと同様に同じパスを何度も記述して層の量増しを行いました。
といっても一回のパスで27層描画できるので3つだけ同じパスを用意しました。

ジオメトリシェーダによる実装のメリット

ジオメトリシェーダを利用して実装を行うと、プラットフォームによっては動かない可能性があるため、望ましくないことも多々あります。しかしながら、ジオメトリシェーダによってファーシェーダを実装した場合、次のようなメリットが考えられます。

  • 全く同じパスを数十個書く必要がないためスマート
  • バッチ数を抑えられる
  • 層の数を可変にすることができる

特に、層を可変にできることでお好みのフサフサ具合を表現でき、非常に便利でしょう。バッチ数を抑えることで
パフォーマンスは改善されるのでしょうか?ジオメトリシェーダがどれほど重いか計測が面倒だったので言及しないことにします。

まとめ

ジオメトリシェーダを利用することで、比較的スマートにファーシェーダを実装することができました。シェーダはすぐにビジュアルに表れるので実装していてとても楽しいですね!

Posted on