プログラムdeタマゴ

nodamushiの著作物は、文章、画像、プログラムにかかわらず全てUnlicenseです

Unityでシェーダープログラムに入門1: テッセレーション

 ゲームが作りたくて最近Unityを触っています。………のハズだったのですが、気がついたら入門書はchapter5で投げ出してずっとシェーダーばっかり弄ってます。どういうことだってばよ。

 特にテッセレーションを数日弄ってました。私がまだおっさんでなかった頃、OpenGLESを弄っていたときには無かったからね。……私がおっさんじゃなかった時期ってあったかな。

 はい、というわけで、初テッセレーションでサッパリワカランので記事にして理解度深めていこうかと。

テッセレーションシェーダ と ジオメトリシェーダ

 テッセレーションというのは、GPU側でポリゴンを分割して、新しい頂点を作ること。

 これをテッセレーションシェーダで行うことが出来る。

 ………ん?それってジオメトリシェーダでもできるんでは?とか思ったけど、以下の様な判断基準らしい。

● テッセレーションシェーダを使った方がいい場合

  1. 組込のテッセレーションパターンで満足できる場合
  2. 表面を定義する頂点数が6以上

● ジオメトリシェーダを使った方がいい場合

  1. 入力プリミティブから異なるプリミティブを出力したい場合(三角形から線とか点とか)

 テッセレーションシェーダのドメインシェーダで頂点レベルよりは細かいけどピクセルレベルよりは荒いシェーディング計算を行って、フラグメンテシェーダでは単に線形補完するだけという利用方法もある模様。

○参考

Tessellation Shaders Oregon State University Mike Bailey 51ページ目

テッセレーションシェーダ

 テッセレーションは「ハルシェーダー」→「テッセレータ(ハードウェア)」→「ドメインシェーダ」の順にデータが処理されます。

f:id:nodamushi:20220211082655p:plain

Graphics Pipelineより引用

 ハルシェーダーではテッセレータがポリゴンを分割するために必要な2つの出力「制御点」と「係数(定数)」の算出を行って、テッセレータに渡します。2つの出力があるので、hullシェーダは制御点を生成するシェーダと係数(定数)を算出するシェーダの2つを実装する必要があります。

f:id:nodamushi:20220211082821p:plain (Tessellation Stagesより引用)

 テッセレータは渡された制御点と係数から、ポリゴンを分割します。生成された新しい頂点は各頂点からの重みという形でドメインシェーダーに渡されるっぽい。

 ドメインシェーダーはハルシェーダとテッセレータから渡された情報を基に頂点の位置を計算します。

f:id:nodamushi:20220211082943p:plainTessellation Stagesより引用)

 とりあえずUnityで簡単にテッセレーションを動かしてみたかったら、以下のコードが手っ取り早いのかな?

Shader "Custom/sample1" {
  Properties {
    _TessFactor("TessFactor", Float) = 3
  }

  SubShader {
    Tags { "RenderType" = "Opaque" }

    CGPROGRAM
    #pragma surface surf Standard tessellate:tess vertex:disp

    // テッセレーション
    #include "Tessellation.cginc"

    float _TessFactor;

    // ハルシェーダの係数算出
    float4 tess(appdata_full v0, appdata_full v1, appdata_full v2) {
      return _TessFactor;
    }

    // ドメインシェーダに相当
    void disp(inout appdata_full v) { }

    // テッセレーション終わり

    struct Input {
      float2 uv_MainTex;
    };

    void surf(Input i, inout SurfaceOutputStandard o) {
      o.Albedo = fixed4(.1f, 1.0f, 1.0f, 1);
    }
    ENDCG
  }
  FallBack "Diffuse"
}

 青い球体のポリゴンが白い球体より細かくなっていますね。

f:id:nodamushi:20220211080633p:plain

 なお、ドメインシェーダ相当の disp で何もしてないので、テッセレーションしてるけど滑らかさに変化はありません。

f:id:nodamushi:20220211081011p:plain

 UnityのサーフェイスシェーダはPhongテッセレーションをしてくれる機能があるので、以下の様に有効化すると滑らかになります。

Shader "Custom/sample1" {
  Properties {
    _TessFactor("TessFactor", Float) = 3
    _Phong("Phong Strength", Range(0, 1)) = 0.5
  }

  SubShader {
    Tags { "RenderType" = "Opaque" }

    CGPROGRAM
    // tessphong:_Phongを追加
    #pragma surface surf Standard tessellate:tess vertex:disp tessphong:_Phong

    // テッセレーション
    #include "Tessellation.cginc"
    float _TessFactor;
    float _Phong; // Phong テッセレーションのための係数

// 以下同じなので省略

f:id:nodamushi:20220211081233p:plain

 おぉ、丸い丸い。

 しかし、サーフェイスシェーダ隠蔽されて hull シェーダとか domain シェーダがよくわからない………。よし、自分でPhong Tessellationを実装しよう。

 

Phong Tessellationを実装する

 Phong Tessellationの論文はこちら

 補正する前の点を各頂点の接平面に投射した点を制御点として補正後の点の位置を算出するという考え方らしい。

 そのままだと補正が効き過ぎるので、「α補正後 + (1-α)補正前」を最終的な結果とするみたい。というわけで、まんま論文の式を実装したのがこちら。

Shader "Custom/sample2" {
  Properties {
    _Phong("Phong Strength", Range(0, 1)) = 0.5
    _EdgeTessFactor("Tess Edge Factor", Range(0, 32)) = 2
    _InsideTessFactor("Tess Inside Factor", Range(0, 32)) = 2
  }

  SubShader {
    Tags { "RenderType" = "Opaque" }

    PASS {

      CGPROGRAM

      #pragma vertex vert
      #pragma hull hullMain
      #pragma domain domain
      #pragma fragment frag

      #include "UnityCG.cginc"
      #include "Tessellation.cginc"
      #define INPUT_PATCH_SIZE 3
      #define OUTPUT_PATCH_SIZE 3

      //---- Define structure ----

      struct appdata {
        //! 頂点
        float3 vertex : POSITION;
        //! 法線
        float3 normal : NORMAL;
      };

      // 頂点シェーダからhullシェーダに渡すデータ
      struct v2h {
        float3 pos: POS;
        float3 normal: NORMAL;
      };

      // コントロールポイント
      struct h2dMain {
        float3 pos: POS;
        float3 normal: NORMAL;
      };

      struct h2dConst {
        // テッセレーションの辺の係数
        float edgeFactor[3] : SV_TessFactor;
        // テッセレーションの内部分割係数
        float insideFactor : SV_InsideTessFactor;

        // Phongテッセレーション用データ
        float3 phongs[3]: POS;
      };

      // domainシェーダからフラグメントシェーダへ
      struct d2f {
        float4 pos: SV_Position;
        float3 normal: NORMAL;
      };

      // --------------------

      // 頂点シェーダ
      v2h vert(appdata i)
      {
        v2h o;
        o.pos = i.vertex;
        o.normal = i.normal;
        return o;
      }

      // テッセレーションシェーダ
      float _Phong; // Phong Tessellationのα
      float _EdgeTessFactor;
      float _InsideTessFactor;

      [domain("tri")]
      [partitioning("integer")]
      [outputtopology("triangle_cw")]
      [outputcontrolpoints(OUTPUT_PATCH_SIZE)]
      [patchconstantfunc("hullConst")]
      h2dMain hullMain(InputPatch<v2h, INPUT_PATCH_SIZE> i, uint id: SV_OutputControlPointID)
      {
        h2dMain o;
        o.pos = i[id].pos.xyz;
        o.normal = i[id].normal;
        return o;
      }

      // Phong Tessellationのπi(Pk) + πk(Pi)
      float3 funcPi(float3 pi, float3 ni, float3 pk, float3 nk)
      {
        float3 pik = pi - pk;
        return pi + pk + dot(pik, ni) * ni - dot(pik, nk) * nk;
      }

      h2dConst hullConst(InputPatch<v2h, INPUT_PATCH_SIZE> i)
      {
        h2dConst o;
        o.edgeFactor[0] = _EdgeTessFactor;
        o.edgeFactor[1] = _EdgeTessFactor;
        o.edgeFactor[2] = _EdgeTessFactor;
        o.insideFactor = _InsideTessFactor;

        o.phongs[0] = funcPi(i[0].pos, i[0].normal, i[1].pos, i[1].normal);
        o.phongs[1] = funcPi(i[0].pos, i[0].normal, i[2].pos, i[2].normal);
        o.phongs[2] = funcPi(i[1].pos, i[1].normal, i[2].pos, i[2].normal);
        return o;
      }

      // domainシェーダ
      [domain("tri")]
      d2f domain(h2dConst c, const OutputPatch<h2dMain, OUTPUT_PATCH_SIZE> i, float3 bary: SV_DomainLocation) {
        d2f o;
        float3 b2 = bary * bary;
        float3 pos_phong = i[0].pos * b2.x + i[1].pos * b2.y + i[2].pos * b2.z
                           + bary.x * bary.y * c.phongs[0]
                           + bary.x * bary.z * c.phongs[1]
                           + bary.y * bary.z * c.phongs[2];
        float3 pos_default = i[0].pos * bary.x + i[1].pos * bary.y + i[2].pos * bary.z;
        float3 pos = pos_phong * _Phong + pos_default * (1 - _Phong);
        o.pos = UnityObjectToClipPos(pos);
        o.normal = normalize(i[0].normal * bary.x + i[1].normal * bary.y + i[2].normal * bary.z);
        return o;
      }

      float4 frag(d2f i): SV_Target {
        return float4(1.0f, 1.0f, 0.1f, 1);
      }
      ENDCG
    }
  }
  FallBack "Diffuse"
}

 適応した結果は黄色い球です。サーフェイスシェーダと同じように輪郭が丸く綺麗になっていますね。よっしゃよっしゃ。

f:id:nodamushi:20220211094239p:plain

適応的テッセレーションを作る前にWireFrameシェーダを付けておく

 現状はテッセレーション係数を固定にしてるので、オブジェクトが遠かろうが近かろうが同じ分割をしてしまいます。

f:id:nodamushi:20220211165353p:plain

 ディスプレースメントマッピングをしてない現状、正直輪郭以外は大して重要じゃないので球の真ん中部分は分割数を減らし、ついでに画面ベースでも点が近い場合は分割を減らすべきですね。

 ついでにやってみましょう………と思ったのですが、UnityのSceneビューで中ボタンをスクロールすると一気にズームするので変化を観察しにくいですね………。良い方法ないんでしょうか?Chapter5で投げ出しただけあって、Unity力が低すぎますね

 わからないので、とりあえず手っ取り早くゲームでカメラを動かすことにしました。カメラの移動には 【Unity】カメラ移動を制御するスクリプト - Qiita を使用しました。

 しかし、GameビューだとWireFrameが表示されないので、分割がどうなってるのかサッパリ見えないですねぇ。困った。

f:id:nodamushi:20220211170313p:plain

 しょーがないので、とりあえず WireFrame を追加で実装しました。参考にしたのはこちら(nvidia White Paper Solid Wireframe)

 全体は省略しますが、以下の様にジオメトリシェーダを追加し、フラグメントシェーダで辺の描画をします。

 ジオメトリシェーダでは、各頂点に対辺までのスクリーンスペースでの距離を付加し、フラグメントシェーダでは距離から線を描画するか、黄色を描画するかを切り替えるだけです。

 なお、判断にstep+lerpではなく、clamp+lerpを使ってるので、アンチエイリアスは勝手にかかります。

      [maxvertexcount(3)]
      void geom(triangle d2g i[3], inout TriangleStream<g2f> triangleStream)
      {
        half2 p0 = ComputeScreenPos(i[0].pos).xy / i[0].pos.w * _ScreenParams.xy;
        half2 p1 = ComputeScreenPos(i[1].pos).xy / i[1].pos.w * _ScreenParams.xy;
        half2 p2 = ComputeScreenPos(i[2].pos).xy / i[2].pos.w * _ScreenParams.xy;

        half2 e0 = p1 - p0;
        half2 e1 = p2 - p1;
        half2 e2 = p0 - p2;

        half2 n0 = normalize(e0);
        half2 n1 = normalize(e1);
        half2 n2 = normalize(e2);

        g2f o;
        o.pos = i[0].pos;
        o.dist = float3(0, distance(e0, dot(e0, n1) * n1), 0);
        o.normal = i[0].normal;
        triangleStream.Append(o);

        o.pos = i[1].pos;
        o.dist = float3(0, 0, distance(e1, dot(e1, n2) * n2));
        o.normal = i[1].normal;
        triangleStream.Append(o);

        o.pos = i[2].pos;
        o.dist = float3(distance(e2, dot(e2, n0) * n0), 0, 0);
        o.normal = i[2].normal;
        triangleStream.Append(o);
      }


      float _LineWidth;

      float4 frag(g2f i): SV_Target {
        float dist = min(i.dist[0], min(i.dist[1], i.dist[2]));
        float d = clamp(dist, 0, _LineWidth) / _LineWidth;
        return lerp(float4(0.0f, 0.0f, 0.1f, 1), float4(1, 1, 0.1, 1), d);
      }

f:id:nodamushi:20220211171045p:plain

 わーい、ゲームビューでもワイヤーフレームが綺麗に表示されたよ。

適応的テッセレーションを作ってみる

 視線ベクトルと、法線ベクトルの内積を用いて係数を操作するだけ。テッセレーション係数に0を渡してしまうと、破棄しまうので、clampで適当な値で0にならないようにしています。

      h2dConst hullConst(InputPatch<v2h, INPUT_PATCH_SIZE> i)
      {
        h2dConst o;
        half3 camLocal = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1));
        half3 cam01 = normalize((i[0].pos + i[1].pos) * 0.5 - camLocal);
        half3 cam12 = normalize((i[1].pos + i[2].pos) * 0.5 - camLocal);
        half3 cam02 = normalize((i[0].pos + i[2].pos) * 0.5 - camLocal);
        half3 n01 = normalize(i[0].normal + i[1].normal);
        half3 n12 = normalize(i[1].normal + i[2].normal);
        half3 n02 = normalize(i[2].normal + i[0].normal);

        half3 d = 1 - abs(half3(dot(n12, cam12), dot(n02, cam02), dot(n01, cam01)));
        half3 f = clamp(d * d, 0.01, 1) * _EdgeTessFactor;

        o.edgeFactor[0] = f[0];
        o.edgeFactor[1] = f[1];
        o.edgeFactor[2] = f[2];
        o.insideFactor = (f[0] + f[1] + f[2]) / 3;

        o.phongs[0] = funcPi(i[0].pos, i[0].normal, i[1].pos, i[1].normal);
        o.phongs[1] = funcPi(i[0].pos, i[0].normal, i[2].pos, i[2].normal);
        o.phongs[2] = funcPi(i[1].pos, i[1].normal, i[2].pos, i[2].normal);

        return o;
      }

f:id:nodamushi:20220211181643g:plain

 (はてなブログWebPに対応してないってどういうことよ。Zennに移行しようかなぁ……)

 出来てみるとたったこれだけなんだけど、実はメチャクチャ苦労した。

half3 camLocal = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1));

 ここを私は最初UnityWorldToObjectDirを使っていて、ちゃんと変換できていなかったのです。

half3 camLocal = UnityWorldToObjectDir(_WorldSpaceCameraPos);

 法線を色として出力したり色々頑張って、やっと気がついたよ………。一度ハマるとプリント デバッグ出来ないのが辛いところだね。