ジオメトリシェーダー入門
https://scrapbox.io/files/69fb5eb62dc79442f0d89f3a.png
最近知り合った方々と話していると、シェーダーを作れるようになりたいという方がそこそこいます。
そして詳しく話を聞いてみると、大抵の場合はジオメトリシェーダーやGPUパーティクルを作れるようになりたいという意味だったりします。
難しいのでフラグメントシェーダーや頂点シェーダーから始めたほうが良いと思ってしまいますが、良く使う機能は十分以上に既存の汎用シェーダーでカバーされているという状況です。lilToonとかMochies Shaderとかで出来ないことをしたい、そうでなければ書く意味がないというのもとてもわかります。
しかしジオメトリシェーダーはあまりオススメできる入門記事が無く、自分も当初かなり苦労した記憶があったのでこれを書いています。
頑張ってわかりやすく書くつもりですが、どうしてもそれなりに難しいのでシェーダーについて基本的なことはわかっている方が望ましいです。しかし最近はAIもあるので興味があったら読めるのではないでしょうか。
ある程度イメージの掴みやすさを優先した説明をします。厳密さは公式ドキュメントとかに任せます。
GPUパーティクルも含めると長くなるので、少しだけ触れて別記事で書く予定です。
まず先に、シェーダーの基本的な書き方について知りたい場合は以下がオススメです。
最小構成のシェーダーについて、全文の意味を丁寧に解説しています。
プログラムを書いたことはあるがシェーダーについてよく知らないという人は、以下の動画シリーズもオススメです。
1.何ができるか
ジオメトリシェーダーは頂点(Vertex)シェーダーとフラグメント(Fragment, Pixel)シェーダーの間に入れることができるものです。無くても成立するので、普通のシェーダーには無いです。Quest・iOS環境では対応していないので使えません。
頂点シェーダーは名前の通り頂点の位置を決めます。板を波やカーテンのように変形させたりするのがわかりやすいです。あとは3Dモデルを2Dの画面に描画するための座標変換ということをやったり、それを特殊な内容に置き換えてビルボードにしたりしています。
フラグメントシェーダーはピクセルシェーダーとも言い、主にピクセルの色を決めます。一般的にはuvの情報を使って対応するテクスチャの色を読み取ったりしますが、法線や座標の情報からライトの色を反映したり、その他あらゆるピクセル単位の処理をします。
ジオメトリシェーダーは頂点シェーダーの拡張版のようなイメージで捉えるのが良いと思います。
頂点、線、三角ポリゴンのいずれかを入力に取り、複数の頂点・面を出力することができます。
↑これは正しいですがわかりにくいです。
殆どのケースで1つの三角ポリゴンを入力に複数の三角ポリゴンを出力するために使います。
(稀に入力が頂点の方がデータが減って気分がいいことはありますが、それに使える元データを用意するのも結構面倒なのでこの記事を読む時点では考えなくていいです)
1つの三角ポリゴンを入力に複数の三角ポリゴンを出力する…つまりプログラムで決めたルールに従ってポリゴンを増やせます。
身近なところではファーシェーダーや草シェーダーがあります。
あれは肌や地面の1ポリゴンから生えるような角度で数枚のポリゴンを生やしています。
他には面の押し出しのような表現ができます。
単に面が法線方向に動くだけなら頂点シェーダーでも可能ですが、押し出しには新たに側面を作る必要があります。
更に、元のポリゴンの位置とは全く関係ないところにポリゴンを飛ばすようなコードを書けばパーティクルのような表現ができます。俗にいうGPUパーティクルの1つです。ポリゴンを増やす必要が無ければ頂点シェーダーでも可能ですが、Quest対応したいとか最適化を詰めたいとかがなければ、VRChat環境では大抵ジオメトリシェーダーで作られています。
ポリゴンをプログラムで自在に増やし、動かせるのが特徴といえます。
一方で実行時の負荷は内容や数に応じて大きくなりますし、プログラムで表現しにくい形状や動かないものであれば、大抵はモデルを用意したほうが良いことになります。
規則的に増やせるものや、動きのある表現に向いていると思います。
また生成したポリゴンがBounds外に出る場合、カメラカリングで消えてしまいます。
これの対処は別記事で書いているので、そちらを参考にしてください。
2.どう書くか
サンプルコードに解説を付けるのが早いのでそうします。
2-1.モデルを複製するシェーダー
https://scrapbox.io/files/69fb5e592dc79442f0d89e86.png
code:Duplicate.shader
Shader "GeoShaderSample/Duplicate"
{
Properties
{
_Offset("Offset", Vector) = (1,0,0,0)
IntRange _CopyCount("Copy Count", Range(0, 5)) = 1 }
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
CGPROGRAM
#pragma target 4.0 // ジオメトリシェーダーを使うため、コンパイルターゲットを4.0以上にする #pragma geometry geom // geom関数をジオメトリシェーダーとして使う指定、書き忘れやすいので注意 float3 _Offset;
float _CopyCount;
struct appdata
{
float4 vertex : POSITION;
};
struct v2g
{
float4 vertex : POSITION;
};
struct g2f
{
float4 vertex : SV_POSITION;
};
v2g vert(appdata v)
{
v2g o;
// 複製時に頂点座標へオフセットを足したいので、ここではまだクリップ空間へ変換しない
// オブジェクト空間の座標をそのままgeomへ渡す
o.vertex = v.vertex;
return o;
}
/*
ジオメトリシェーダーは三角形1枚ごとに呼ばれる。
triangle v2g input3には、その三角形を構成する3頂点の情報(v2g)が入る。 TriangleStreamには、これから描画したい頂点(g2f)をAppendしていく。
今回は「元の三角形 + コピー5個」なので、最大で 3頂点 x 6 = 18頂点 を出力する。
maxvertexcountには、ジオメトリシェーダー1回の実行で出力する最大頂点数を書く。
実際の出力頂点数がmaxvertexcountを超えないようにする必要がある。
*/
void geom(triangle v2g input3, inout TriangleStream<g2f> stream) {
// コピーごとのループ
for (int copyIndex = 0; copyIndex <= _CopyCount; copyIndex++)
{
float4 offset = float4(_Offset * copyIndex, 0.0);
// 頂点ごとのループ
for (int vertexIndex = 0; vertexIndex < 3; vertexIndex++)
{
g2f o;
// ここで初めてオブジェクト空間からクリップ空間へ変換する
o.vertex = UnityObjectToClipPos(inputvertexIndex.vertex + offset); stream.Append(o);
}
// RestartStrip()は、直前までにAppendした頂点列と、次にAppendする頂点列の繋がりを切る。
// これにより、次の3頂点を別の三角形として出力できる。
stream.RestartStrip();
}
}
fixed4 frag(g2f i) : SV_Target
{
return fixed4(1, 1, 1, 1);
}
ENDCG
}
}
}
わかりにくいのはここだと思います。私は最初よくわからなかったです。
void geom(triangle v2g input[3], inout TriangleStream<g2f> stream)
triangle: 入力に三角形を使うということ。他にpointやlineと書けるが、前述の通りあまり使わない。
v2g: 入力の型。自分でstruct v2gと宣言しているものなので、どんな名前にしても良いしappdataやg2fと共用にしても良い。
input[3]: 3頂点分のデータが入る配列。この後のコードでここからデータを取り出す。In[3] とかどんな名前でも良い。
inout: outでいいようにも感じるがエラーになるのでinout、定型的に。
TriangleStream<g2f>: 出力にTriangleStreamを使うということ。同じくPointStream、LineStream等があるがあまり使わない。
stream: 出力の実体、ここに頂点の情報を入れていく。好きに名前を付けて良い。
つまりtriangleとinout TriangleStreamは決められている文言で、残りはあなたが決める(決めた)名前です。
triangleとTriangleStreamは他の決められた文言に変えることもできますが、ほとんどのケースで変えないです。
2-2.ポリゴンを法線方向に移動するシェーダー
normalやuvを使っています。
https://scrapbox.io/files/69fb5e552dc79442f0d89e81.gif
code:NormalOffset.shader
Shader "GeoShaderSample/NormalOffset"
{
Properties
{
_MainTex("Texture", 2D) = "white" {}
_Offset("Offset", Float) = 0.5
Toggle _UseFaceNormal("Use Face Normal", Float) = 0 }
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
CGPROGRAM
sampler2D _MainTex;
float4 _MainTex_ST;
float _Offset;
float _UseFaceNormal;
struct appdata
{
float4 vertex : POSITION;
// 今回は座標に加えて、法線とUVもgeomへ渡す
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct v2g
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct g2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2g vert(appdata v)
{
v2g o;
// 法線方向へ移動させるためnormal、fragでテクスチャを読むためuvを渡す
o.vertex = v.vertex;
o.normal = v.normal;
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
// 今回はポリゴンを増やさず、入力された三角形1枚をそのまま出力するので最大3頂点
// primitiveIdには、今処理している三角形の番号が入る
// 面ごとに違う値になるので、アニメーションのタイミングをズラしたり、ランダムのシード値に使うことができる
void geom(triangle v2g input3, uint primitiveId : SV_PrimitiveID, inout TriangleStream<g2f> stream) {
// sinの結果を0-1に変換し、_Offsetに掛けて移動量にする
// 時間にprimitiveIdを足すことで、面ごとに動くタイミングをズラせる
float offset = _Offset * (sin(_Time.y + (float)primitiveId * 0.1) * 0.5 + 0.5);
// 3頂点の法線を平均した向きを、この三角形共通の法線として使う
float3 faceNormal = normalize(input0.normal + input1.normal + input2.normal); for (int vertexIndex = 0; vertexIndex < 3; vertexIndex++)
{
// ToggleがOFFなら頂点ごとの元々の法線、ONなら三角形内で共通の法線を使う
if (_UseFaceNormal > 0.5)
{
normal = faceNormal;
}
g2f o;
o.vertex = UnityObjectToClipPos(inputvertexIndex.vertex + float4(normal * offset, 0.0)); stream.Append(o);
}
stream.RestartStrip();
}
fixed4 frag(g2f i) : SV_Target
{
// geomから受け取ったUVでテクスチャを読む
return tex2D(_MainTex, i.uv);
}
ENDCG
}
}
}
元メッシュがSphereなので、元々の法線を使う場合はポリゴンが膨らむような挙動になり、共通の法線(平均値)を使う場合はポリゴンの大きさは変わらないまま移動することになります。
面が増えていないので頂点シェーダーでも実装できそうですが、法線の平均値をシェーダー内で計算するならジオメトリシェーダーを使う必要があります。
頂点シェーダーは頂点毎に実行されるため、面に含まれる3頂点の情報全てにアクセスする方法が無いからです。
面毎の処理が行えるのも地味ながらジオメトリシェーダーの特徴といえます。
次に進む前に、できればUnityObjectToClipPosの中身がわかってると良いかもしれません。
3Dモデルを2Dの画面に正しい前後関係で描画するため、大抵のシェーダーは
モデル(オブジェクト)空間→ワールド空間→ビュー空間→プロジェクション空間という座標変換をしています。
以下のような記事が色々あります。
その中のどのタイミングでジオメトリシェーダーの頂点の座標を計算するか、というのがあります。
多くはUnity上でオブジェクトを移動したら付いてきてほしいのでモデル空間で操作するだろうと思いますが、GPUパーティクル的なものはワールド空間で制御したいことがあったり、次のサンプルのようにビルボードの面を貼るときはビュー空間で貼ると楽だったりします。
2-3.シンプルなメモリ無しGPUパーティクル
モデルの各三角ポリゴンをビルボードの四角形にし、平面上に並べ上下させるシェーダーです。
なおTriangleStreamに4頂点を入れると勝手に三角形2枚として解釈します。頂点の順番は気にする必要があります。
https://scrapbox.io/files/69fb5e4f2dc79442f0d89e7a.gif
code:SimpleParticle.shader
Shader "GeoShaderSample/SimpleParticle"
{
Properties
{
_Size("Size", Float) = 0.1
_Spacing("Spacing", Float) = 0.3
_Height("Height", Float) = 1.0
IntRange _ColumnCount("Column Count", Range(1, 100)) = 30 }
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
Cull Off
CGPROGRAM
float _Size;
float _Spacing;
float _Height;
float _ColumnCount;
struct appdata
{
float4 vertex : POSITION;
};
struct v2g
{
float4 vertex : POSITION;
};
struct g2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2g vert(appdata v)
{
v2g o;
o.vertex = v.vertex;
return o;
}
// 1つのポリゴンから4頂点のビルボードを出力する
void geom(triangle v2g input3, uint primitiveId : SV_PrimitiveID, inout TriangleStream<g2f> stream) {
// primitiveIdを使って、このポリゴンが何番目のパーティクルかを決める
int id = (int)primitiveId;
// idからcolumnとrowを計算し、XZ平面上にグリッド状に並べる
int columnCount = max(1, (int)_ColumnCount);
int column = id % columnCount;
int row = id / columnCount;
// X方向は中央揃え、Z方向はZ+に向かってただ並べる
float x = column - (columnCount - 1) * 0.5;
float z = row;
// Y方向だけsinで上下に動かす
float y = sin(_Time.y + id * 0.1) * _Height;
// ここまでがモデル空間でのパーティクルの中心座標の計算
float4 center = float4(x * _Spacing, y, z * _Spacing, 1.0);
// 簡易なビルボードはカメラから見て上下左右に移動すれば作れるので、中心座標をビュー空間へ変換する
float4 centerView = mul(UNITY_MATRIX_MV, center);
float halfSize = _Size * 0.5;
g2f o;
// +-1のuvをそのまま中心からの上下左右オフセットとして使う
// 4頂点をAppendすると、2枚の三角形からなる四角形として描画される
o.uv = float2(-1.0, -1.0);
o.vertex = mul(UNITY_MATRIX_P, centerView + float4(o.uv * halfSize, 0.0, 0.0));
stream.Append(o);
o.uv = float2(-1.0, 1.0);
o.vertex = mul(UNITY_MATRIX_P, centerView + float4(o.uv * halfSize, 0.0, 0.0));
stream.Append(o);
o.uv = float2(1.0, -1.0);
o.vertex = mul(UNITY_MATRIX_P, centerView + float4(o.uv * halfSize, 0.0, 0.0));
stream.Append(o);
o.uv = float2(1.0, 1.0);
o.vertex = mul(UNITY_MATRIX_P, centerView + float4(o.uv * halfSize, 0.0, 0.0));
stream.Append(o);
stream.RestartStrip();
}
fixed4 frag(g2f i) : SV_Target
{
// +-1のuvはlengthに繋いで円を描くとパーティクル的なものも容易に作れる
return fixed4(abs(i.uv), 0.0, 1.0);
}
ENDCG
}
}
}
このサンプルでは、入力された三角形の頂点座標は使っていません。
ジオメトリシェーダーが「ポリゴンごとに1回呼ばれる」性質を利用し、各ポリゴンを1つのパーティクル発生源として扱っています。つまり、ポリゴン数の多いメッシュに適用すればコードを変更しなくともパーティクルが増えることになります。
uvもモデルのuvは使わず、ジオメトリシェーダー内で新たに定義して4頂点に与えています。0-1のuvではなく+-1なのはパーティクル的な形状の描画が容易になるからです。
なおビルボードの作り方は結構奥が深くて、これは特にシンプルな方法です。
VRでは視界端の見栄えがイマイチだったり頭の回転に追従したりするので、その点は注意が必要です。
2-4.シンプルな草シェーダー
ここまでの内容を踏まえて、シンプルな草シェーダーが書けるはずです。
https://scrapbox.io/files/69fb5e4a2dc79442f0d89e72.gif
code:SimpleGrass.shader
Shader "GeoShaderSample/SimpleGrass"
{
Properties
{
IntRange _GrassPerPolygon("Grass per Polygon", Range(1, 42)) = 42 _RootWidth("Root Width", Float) = 0.05
_Length("Length", Float) = 0.5
_TopRandomOffset("Top Random Offset", Float) = 0.1
_NoiseTex("Noise Texture", 2D) = "white" {}
_NoiseScale("Noise Scale", Float) = 0.05
_WindSpeed("Wind Speed", Float) = 0.05
_WindStrength("Wind Strength", Float) = 0.2
_Color1("Color 1", Color) = (0.10, 0.35, 0.05, 1.00)
_Color2("Color 2", Color) = (0.50, 0.85, 0.20, 1.00)
_ColorRandomness("Color Randomness", Float) = 0.1
}
SubShader
{
Tags { "RenderType" = "Opaque" }
Pass
{
Cull Off
CGPROGRAM
float _GrassPerPolygon;
float _RootWidth;
float _Length;
float _TopRandomOffset;
sampler2D _NoiseTex;
float _NoiseScale;
float _WindSpeed;
float _WindStrength;
fixed4 _Color1;
fixed4 _Color2;
float _ColorRandomness;
struct appdata
{
float4 vertex : POSITION;
};
struct v2g
{
float4 vertex : POSITION;
};
struct g2f
{
float4 vertex : SV_POSITION;
fixed4 color : COLOR;
};
// 短いxorshift乱数
uint3 xorshift(uint3 value)
{
value ^= value.yzx << 13;
value ^= value.zxy >> 17;
value ^= value.yzx << 5;
return value;
}
float3 random3(uint seed)
{
// uintからfloatの0-1の乱数に変換する
uint3 value = uint3(seed, seed * 1664525u + 1013904223u, seed * 22695477u + 1u);
return (float3)xorshift(value) * (1.0 / 4294967295.0);
}
v2g vert(appdata v)
{
v2g o;
o.vertex = v.vertex;
return o;
}
// 1ポリゴンから最大42本の草を出力する
void geom(triangle v2g input3, uint primitiveId : SV_PrimitiveID, inout TriangleStream<g2f> stream) {
// 入力された三角形の3頂点。ここから草を生やす位置を決める
float3 p0 = input0.vertex.xyz; float3 p1 = input1.vertex.xyz; float3 p2 = input2.vertex.xyz; for (int grassIndex = 0; grassIndex < _GrassPerPolygon; grassIndex++)
{
// primitiveIdとgrassIndexから、この草1本用の乱数を作る
uint seed = primitiveId * 12345u + (uint)grassIndex * 6789u;
float3 rand = random3(seed);
// 三角形の面内からランダムな位置を選び、草の中心にする
float r1 = sqrt(rand.x);
float r2 = rand.y;
float3 center = p0 * (1.0 - r1)
+ p1 * (r1 * (1.0 - r2))
+ p2 * (r1 * r2);
// モデル空間XZ平面でランダムな向きを作り、根元の2点を決める
float3 side = normalize(float3(rand.z - 0.5, 0.0, rand.x - 0.5));
float3 root1 = center - side * _RootWidth * 0.5;
float3 root2 = center + side * _RootWidth * 0.5;
// 草はシンプルにモデル空間+Y方向へ伸ばし、先端だけ少しランダムにずらす
float3 topRandomOffset = float3(rand.y - 0.5, 0.0, rand.z - 0.5) * _TopRandomOffset;
float3 top = center + float3(0.0, _Length, 0.0) + topRandomOffset;
// スクロールするノイズを風として使い、先端だけXZ方向に動かす
// 根元から回転を計算する方が妥当だが、今回はシンプルに横移動だけにする
float2 noiseUv = center.xz * _NoiseScale + _Time.y * _WindSpeed;
float2 wind = tex2Dlod(_NoiseTex, float4(noiseUv, 0.0, 0.0)).rg * 2.0 - 1.0;
top.xz += wind * _WindStrength;
// 草ごとに色を少しずらして個体差を作る
float colorOffset = (rand.z * 2.0 - 1.0) * _ColorRandomness;
fixed4 color1 = _Color1;
fixed4 color2 = _Color2;
color1.rgb = saturate(color1.rgb + colorOffset);
color2.rgb = saturate(color2.rgb + colorOffset);
g2f o;
// 根元2頂点にColor1、先端1頂点にColor2を渡す
// 三角形内の色のグラデーションはラスタライザの補間に任せる
o.color = color1;
o.vertex = UnityObjectToClipPos(float4(root1, 1.0));
stream.Append(o);
o.color = color1;
o.vertex = UnityObjectToClipPos(float4(root2, 1.0));
stream.Append(o);
o.color = color2;
o.vertex = UnityObjectToClipPos(float4(top, 1.0));
stream.Append(o);
stream.RestartStrip();
}
}
fixed4 frag(g2f i) : SV_Target
{
// geomから渡された色をそのまま出す
return i.color;
}
ENDCG
}
}
}
特にコメント外で説明することは無いです。
極力シンプルに書いたので、色々直したいところが見つかるはずです。
各方向を法線方向を基準に決めるとか、ライトの情報を反映するとか。
3.余談
先の草シェーダーが色をジオメトリシェーダーで決めているというのに注目してみます。
色は頂点シェーダー、ジオメトリシェーダー、フラグメントシェーダーのどこで決めることもできます。
頂点シェーダーで決めてジオメトリシェーダーでは補間するようにすると、色の分布が連続的になります。
フラグメントシェーダーで色を決めると、もっと細かい表現もできるようになります。
早い段階で決めると、後段へ渡すデータや後段の計算を減らせます。
一方でフラグメントシェーダーで決めると、ピクセル単位の細かい表現がしやすくなります。
また、フラグメント側で決めることで v2g / g2f で受け渡すデータを減らせることがあります。
もちろん負荷は増えますが、すべてのステージで色に関する処理を行い、それらをブレンドしながら次に渡しても良いです。
色に限らず、ジオメトリシェーダーは各要素をどのステージで決めるのかというのも面白い部分で、最適化にも繋がってきます。
まずは表現を優先してとにかく作り進めてみるのをオススメしますが、負荷が高くなりすぎたときには一度この辺りを見直してみるのも改善につながるかもしれません。
4.データ量の制限とジオメトリインスタンシング
ジオメトリシェーダーは無限にデータを増やすことはできず、制約があります。
何ポリゴンと決まっているのではなく、各呼び出しで生成できるデータ量の上限があります。
要はg2fの要素が多いほど、少ないポリゴン数しか生成できないということになります。
例えば先ほどの草シェーダーでmaxvertexcountを129にすると以下のようにエラーが出ます。
code:error
Shader error in 'GeoShaderSample/SimpleGrass': Program 'geom', error X8000: Validation Error: Declared output vertex count (129) multiplied by the total number of declared scalar components of output data (8) equals 1032. This value cannot be greater than 1024. at line 26 (on d3d11)
VRChat PC環境が該当するUnityのd3d11では1024要素が上限になっています。
1024/8(g2fがfloat4 vertexとfixed4 colorで8要素)=128なので、このケースではmaxvertexcountは128が上限になります。
それに収まらない出力をする方法として、ジオメトリインスタンシングというものがあります。
これは1つのポリゴンに対しジオメトリシェーダーを複数回実行するようにするものです。
最大32回呼び出せますが、総出力量が増えれば一般的に負荷は増えるのでその点に注意して使うことになります。
以下のように書きます。
code:sample.cginc
...
void geom(triangle v2g IN3, inout TriangleStream<g2f> triStream, uint primitiveID: SV_PRIMITIVEID, uint instanceID : SV_GSInstanceID) {
uint id = primitiveID * 32 + instanceID;
...
}