2019年2月3日

ジオメトリシェーダでワイヤーフレーム ~Unlit 編~


ジオメトリシェーダを使って厚みのあるワイヤーフレームを描画する方法をメモメモ…


※このページの内容の動作確認にはUnity2018.2を使用しています。
Unityでメッシュをワイヤーフレーム状にして表示するシェーダがないか探していたところ、めちゃめちゃ面白そうな投稿を見つけました。

UnityでVJ的な作品をつくる - Qiita
https://qiita.com/n0mimono/items/d360ce933e3328fd6339

ジオメトリシェーダを使ってワイヤーフレームやボクセルを作り出しています。「ジオメトリシェーダ」というのは、点や線や面(ポリゴン)といったプリミティブの情報を受け取って、そこからさらに新しいプリミティブを作成して出力できるシェーダのことです。これを使えば、たとえばポリゴン数の少ない粗いモデルの面を再分割して滑らかにしたり(テッセレーション)、メッシュを無数の点々で描画したりと様々なことが実現できます。

テッセレーションの例 カクカクのローポリ林檎を不自然なまでに丸くできる

上の投稿で紹介されているワイヤーフレームの例では、1つのポリゴンから新たに6つのポリゴン(18個の頂点)を作成して並べることで、幅のある線を描画しています。

ワイヤーフレーム

というわけでこれを参考にして新しいシェーダを書いてみましょう!ためしにワイヤーフレームに「厚み」を加えてみます。


Shader "Custom/Wireframe Unlit"
{
 Properties
 {
  _Color("Color", Color) = (1,1,1,1)
  _Width("Width", Range(0, 1)) = 0.2
  _Thickness("Thickness", Range(0, 1)) = 0.01
 }
 SubShader
 {
  Tags { "RenderType"="Transparent" "Queue" = "Transparent" }
  LOD 100
  
  Zwrite Off
  Blend SrcAlpha OneMinusSrcAlpha

  Pass
  {
   CGPROGRAM
   #pragma target 4.0
   #pragma vertex vert
   #pragma geometry geo
   #pragma fragment frag
   #pragma multi_compile_fog
   
   #include "UnityCG.cginc"

   struct appdata {
    float4 vertex : POSITION;
    float3 normal : NORMAL;
   };
   
   struct v2g {
    float4 vertex : SV_POSITION;
    float3 normal : TEXCOORD0;
   };

   struct g2f {
    float4 vertex : SV_POSITION;
    float3 normal : TEXCOORD0;
    UNITY_FOG_COORDS(1)
   };

   v2g vert (appdata v)
   {
    v2g o;
    o.vertex = v.vertex;
    o.normal = v.normal;
    return o;
   }

   struct vData {
    float3 pos;
    float3 normal;
   };

   g2f SetVertex(vData data)
   {
    g2f o;
    o.vertex = UnityObjectToClipPos(float4(data.pos, 1));
    o.normal = data.normal;
    UNITY_TRANSFER_FOG(o, o.vertex);
    return o;
   }

   fixed4 _Color;
   float _Width;
   float _Thickness;

   [maxvertexcount(54)]
   void geo(triangle v2g IN[3], inout TriangleStream<g2f> triStream)
   {
    #define ADDV(v) triStream.Append(SetVertex(v))
    #define ADDTRI(v1, v2, v3) ADDV(v1); ADDV(v2); ADDV(v3); triStream.RestartStrip()

    float width = lerp(0, 2.0 / 3.0, _Width);
    float thickness = width * _Thickness;
    float3 triNormal = normalize(IN[0].normal + IN[1].normal + IN[2].normal);

    vData v[3][4];

    for (uint i = 0; i < 3; i++)
    {
     v2g IN_b = IN[(i + 0) % 3];
     v2g IN_1 = IN[(i + 1) % 3];
     v2g IN_2 = IN[(i + 2) % 3];

     /* もとの頂点 */
     v[i][0].pos = IN_b.vertex.xyz;
     v[i][0].normal = normalize(IN_b.normal);

     /* もとの位置から横にずらした頂点(側面の表側の頂点) */
     v[i][1].pos = IN_b.vertex.xyz + ((IN_1.vertex.xyz + IN_2.vertex.xyz) * 0.5 - IN_b.vertex.xyz) * width;
     v[i][1].normal = lerp(v[i][0].normal, triNormal, _Width);

     /* 横にずらした頂点の裏(側面の裏側の頂点) */
     v[i][2].pos = v[i][1].pos - v[i][1].normal * thickness;
     v[i][2].normal = -v[i][1].normal;

     /* もとの頂点位置の裏 */
     v[i][3].pos = v[i][0].pos - v[i][0].normal * thickness;
     v[i][3].normal = -v[i][0].normal;
    }

    /* 表面 */
    ADDTRI(v[1][0], v[1][1], v[0][0]);
    ADDTRI(v[0][1], v[0][0], v[1][1]);
    ADDTRI(v[0][0], v[0][1], v[2][0]);
    ADDTRI(v[2][1], v[2][0], v[0][1]);
    ADDTRI(v[2][0], v[2][1], v[1][0]);
    ADDTRI(v[1][1], v[1][0], v[2][1]);
    
    /* 側面 */
    ADDTRI(v[1][1], v[0][2], v[0][1]);
    ADDTRI(v[0][2], v[1][1], v[1][2]);
    ADDTRI(v[0][1], v[2][2], v[2][1]);
    ADDTRI(v[2][2], v[0][1], v[0][2]);
    ADDTRI(v[2][1], v[1][2], v[1][1]);
    ADDTRI(v[1][2], v[2][1], v[2][2]);
    
    /* 裏面 */
    ADDTRI(v[1][2], v[1][3], v[0][2]);
    ADDTRI(v[0][3], v[0][2], v[1][3]);
    ADDTRI(v[0][2], v[0][3], v[2][2]);
    ADDTRI(v[2][3], v[2][2], v[0][3]);
    ADDTRI(v[2][2], v[2][3], v[1][2]);
    ADDTRI(v[1][3], v[1][2], v[2][3]);
   }
   
   fixed4 frag (g2f i) : SV_Target
   {
    fixed4 col = _Color;
    UNITY_APPLY_FOG(i.fogCoord, col);
    return col;
   }
   ENDCG
  }
 }
}


なんか後半すごいことになっていますが、一旦そこはスルーしてください。

まずは全体の流れを整理します。21~23行目で指定したとおり、頂点シェーダは vert 関数、ジオメトリシェーダは geo 関数、そしてフラグメントシェーダは frag 関数で処理を行い、それぞれ appdata、v2g、g2f という構造体を使ってデータを受け取ります。したがってデータの流れは 頂点データ ⇒ (appdata) ⇒ vert関数 ⇒ (v2g) ⇒ geo関数 ⇒ (v2f) ⇒ frag関数 ⇒ (fixed4) ピクセルの色 のようになりますね。一応わかりやすいように3つの構造体を定義しましたが、今回の例だと使うデータ型がほぼ一緒なので必要なかったかも…。

では、次に70行目から始まるgeo関数の冒頭部分を見てみましょう。


[maxvertexcount(54)]
void geo(triangle v2g IN[3], inout TriangleStream<g2f> triStream)


最初に必ず [maxvertexcount(N)] で作成したい頂点の最大数を宣言します。実際に作成する頂点数よりも少なく宣言してしまうと、頂点の一部が描画されなくなってしまうので注意。逆に多めに宣言する分には問題ありませんが、ただし数が多すぎると次のようなエラーが出て怒られるのでやはり正確に記述したほうがいいです。

Shader error in 'Custom/Wireframe Unlit': Program 'geo', error X8000: Validation Error: Declared output vertex count (256) multiplied by the total number of declared scalar components of output data (7) equals 1792. This value cannot be greater than 1024. (on d3d11)

最大出力頂点数については GLSLによるジオメトリシェーダ - PukiWiki for PBCG Lab で簡潔にまとめられているので参考にしてください。今回は表面、側面、裏面それぞれで6ポリゴン(18頂点)ずつ出力するので、合計54頂点になります。54頂点!う~ん、リッチ!

上記の geo 関数では triangle つまり三角形の各頂点情報を受け取っていますが、ほかにも point(点) や line(辺=2点)、lineadj(辺+隣接する2点の計4点)、triangleadj(三角面+辺を共有する隣り合う三角面の頂点の計6点)などのデータを受け取ることも可能です。…と文字で羅列しても「なんのこっちゃ」だと思うので、詳しくは Microsoft の公式ドキュメント を見てください。イラストつきでわかりやすいです。

さて、肝心の geo 関数内での処理内容ですが…




こんなふうに各頂点に番号を割り振って、あとはひたすらそれを結んで三角形を作っているだけです。後半のADDTRIを連発している箇所もループ処理とか使えばもう少し綺麗に書けるかもしれませんが、パッと見で「54個頂点がある!」とわかるので、これはこれでよしとします。側面部分に位置する頂点の法線方向はもう少しちゃんと計算したほうがいいかもしれません…。

ジオメトリシェーダで作成した頂点は、フラグメントシェーダに渡す前に責任をもってデータ処理を行いましょう。今回は SetVertex() 関数を定義して、その中で位置情報と法線情報、それから fog の処理もまとめて記述しています。あとは各頂点のデータの準備が整いしだい Append() を使ってストリーム(この場合はTriangleStream<g2f>)に順次登録していけば完了です。

ごちゃごちゃ説明が長くなりましたが、何はともあれ完成したワイヤーフレームを見てみましょう!




うーん、正直よくわかりません… 動画なら伝わるでしょうか…




いや全然わからん!

ライティングや影の処理を書いていないので、残念ながら立体感はまるで伝わってきません。なんとなく輪郭が濃くなっているかな…という感じですね。となると今回の作業の意味は…?

このままだと口惜しいので、ライティングに対応したバージョンも作ってみました。Diffuse編へ続きます!


ジオメトリシェーダでワイヤーフレーム ~Diffuse 編~

0 件のコメント:

コメントを投稿