はじめに
本記事では、VRChatでボリュームレンダリングするための根底技術を記載する。
VRChatに持ち込むためスクリプト等は用いずCustomRenderTexture+Shaderのみで実装する。
また、実装に伴いCustomRenderTexture内で任意のメッシュを描画する方法を記載する。
書いてるテクニックの要約
- CustomRenderTexture内で任意のメッシュを描画する方法
- 3DのCustomRenderTextureに対して、パーティクルを書き込む方法
- ボリュームレンダリングの一例 (レイキャスティング)
ボリュームレンダリングとは
wikipedia(ボリュームレンダリング)
要は、3Dのボリュームテクスチャに対して、何かしらのShaderでレンダリングを行う技術。
いわゆるCTやMRIの様なデータを表示する技術。
「1. CustomRenderTexture内で任意のメッシュを描画する方法」
CustomRenderTexture
CustomRenderTextureのサンプルの多くは、頂点シェーダーに"UnityCustomRenderTexture.cginc"の"CustomRenderTextureVertexShader"を使っている。
頂点シェーダーにこれを適応すると、フラグメントシェーダーで容易に"v2f_customrendertexture"構造体を入力として得ることができ、本質的に「あるピクセルの色を決める」というシェーダーが書ける。
「あるピクセルの色を決める」際には、下記のようないくつかの方法が考えられる。
- 1フレーム前の自身と同じピクセルを使う; 徐々に暗くすることで残像のようなエフェクトなど
- 1フレーム前の自身とその周辺のピクセルを使う; ライフゲームの実装など
- 単純に時間から計算する; 時間で回転する色相環など
- その他; テクスチャを合成するなど
基本的には「あるピクセルの色を決める」ために、複数のテクスチャの任意のピクセル等を参照して色(ないしデータ)を計算し続けることになる。
CustomRenderTextureでGeometryShaderを書く
CustomRenderTextureは対象テクスチャのRenderTargetを用いて、対象テクスチャ全域に板ポリを1枚描画しているに過ぎない。
RenderTargetにバインドされているためシェーダーを書けば任意のメッシュを描画することができる。
これによりできることは下記のようなことである。
- 任意のメッシュを描画することができる; メッシュベースでのテクスチャ生成
- GPUでパーティクル等の計算を行う際、任意の座標にデータを書き出せる; 例えばあるパーティクルの寿命が尽きた時に、複数のパーティクルを追加する等の処理がGeometryShaderだけで完結できる(予め書き出す座標等は取り決めておく必要があるが)
この様な汎用性の高いGeometryShaderをCustomRenderTexture内で書くために、いくつか乗り越える障害があるため、段階に分けて記載する。
1. 頂点シェーダーを書く
"UnityCustomRenderTexture.cginc"の"CustomRenderTextureVertexShader"が行っていることは、SV_VertexIDをもとにSV_POSITIONとTEXCOORD0を計算しているだけである。
SV_POSITIONは対象テクスチャ全域になるような板ポリ1枚の座標になる。
今回、GeometryShaderステージを多数(最大131,072個分)呼ぶため、テッセレーションを行う。テッセレーションでは頂点座標のみ必要なため、下記に最小の頂点シェーダー例を示す。
VS_OUT mainVS(VS_IN In)
{
float4 vertex[6] =
{
float4(+1, -1, 0, 1),
float4(+1, +1, 0, 1),
float4(-1, -1, 0, 1),
float4(-1, -1, 0, 1),
float4(+1, +1, 0, 1),
float4(+1, -1, 0, 1),
};
VS_OUT Out;
Out.pos = vertex[In.vertexID % 6];
return Out;
}
2. テッセレーションを行う
wikipedia(テッセレーション)
テッセレーションを用いる基本概念は、板ポリ1枚からGeometryShaderステージに大量のプリミティブを与えるために、メッシュの細分化を行うためである。
上記wikipediaにある画像のように、板ポリに対し正しくquadでテッセレーションを行うことで、きれいな格子状に分割できる。
分割した格子の座標(厳密にはuv座標)から、連続一意なidを生成することが可能である。
- ハルシェーダー; 分割する数を指定 (TESS=64が上限であるため、最大4096(64^2)分割できる)
- ドメインシェーダー; 分割後に、格子を構成するuv座標をもとに、連続一意なidを発行しGeometryShaderに与える
下記に、CustomRenderTextureで使用する連続一意なidを発行するためのハルシェーダー・ドメインシェーダーの例を示す。
CONSTANT_HS_OUT mainCHS()
{
CONSTANT_HS_OUT Out;
int t = TESS + 1;
Out.Edges[0] = t;
Out.Edges[1] = t;
Out.Edges[2] = t;
Out.Edges[3] = t;
Out.Inside[0] = t;
Out.Inside[1] = t;
return Out;
}
[domain("quad")]
[partitioning("pow2")]
[outputtopology("point")]
[outputcontrolpoints(4)]
[patchconstantfunc("mainCHS")]
HS_OUT mainHS()
{
}
[domain("quad")]
DS_OUT mainDS(CONSTANT_HS_OUT In, const OutputPatch<HS_OUT, 4> patch, float2 uv : SV_DomainLocation)
{
DS_OUT Out;
Out.pid = (uint)(uv.x * (TESS - 1)) + ((uint)(uv.y * (TESS)) * (TESS - 1));
return Out;
}
3. ジオメトリシェーダーを書く
ジオメトリシェーダー内でidをもとにメッシュを描画する。
TriangleStreamを使えばメッシュの描画を行うことができる。
PointStreamを使えば特定の1ピクセルにデータを書き出すことができる。
→ 特にGPUを使ってパーティクル等の計算を行う際に便利
下記にジオメトリシェーダーの例を示す。
なお、同じプリミティブに対してGeometryShaderを複数回呼ぶ機能がinstanceであり最大32まで指定可能で、gsidは0~31の値を取る。
テッセレーションと合わせると、最大4096*32=131,072個分となる。
(ジオメトリシェーダーはmaxvertexcountの上限の関係上、独立したポリゴンを最大で85ポリゴン分作れるので、板ポリ1枚から11,141,120ポリゴン描画することができる)
Texture2D<float4> _DataTex; // (rgba Float 想定)
[maxvertexcount(3)]
[instance(NUM_INSTANCE)]
void mainGS(point DS_OUT input[1], inout TriangleStream<GS_OUT> outStream, uint gsid : SV_GSInstanceID)
{
uint id = input[0].pid * NUM_INSTANCE + gsid;
// 以降idを用いた処理をなんでも
// 例えばidから_DataTexをロードして、dataをもとに描画するなど
float4 data = _DataTex.Load(int3(id % TEX_W, id / TEX_W, 0));
「2. 3DのCustomRenderTextureに対して、パーティクルを書き込む方法」
3Dのテクスチャは通常の2Dのテクスチャが何枚も重なったものである。(通常のxとyに対して、"何枚目"と考えると理解しやすい)
下記に3Dテクスチャにパーティクルを書き込む方法を記載する。
なお、書き込むのは"ボリュームレンダリングに用いるパーティクルの密度"であり、描画するものは"パーティクルの座標周囲数ピクセルに球状"に書き込む。
1. 2Dテクスチャを介して3Dテクスチャに書き込む
3DのCustomRenderTextureに直接データを書き込もうとしたが、下記の通りいくつかの問題が発生したため、一度2Dのテクスチャに書き込んでしまう方法を採用する。
- Shader側で書き込むスライスを指定したが全てのスライスに描画された
- InitializationModeをRealtimeにしたが機能しなかった
パーティクルを一度2Dのテクスチャに書き出す場合は、上記の問題は関係ない。
例えば3Dを256256256にするのであれば2Dは4096*4096のテクスチャを介す必要がある。
下記の様なシェーダー例で3Dテクスチャに書き込む。
#define TEX_SIZE 256 // 3Dテクスチャの解像度
#define ARRAY_W 16 // 2Dテクスチャ内の横方向に、スライスをいくつ格納するか; 16*16で256スライス
Texture2D<float> _MainTex;
float mainFS(v2f_customrendertexture input) : SV_Target
{
// 書き込もうとしている座標
int3 lpc = int3(input.localTexcoord.x * (TEX_SIZE - 1), input.localTexcoord.y * (TEX_SIZE - 1), input.localTexcoord.z * (TEX_SIZE - 1));
uint id = lpc.z;
uint w = id % ARRAY_W;
uint h = id / ARRAY_W;
int3 lp = int3(lpc.x + w * TEX_SIZE, 4095 - (lpc.y + h * TEX_SIZE), 0);
return _MainTex.Load(lp).r;
}
2. 2Dテクスチャにパーティクルの密度を書き込む
そもそも3Dテクスチャにメッシュを描画するときの方法は「xy座標で構成されるプリミティブを何枚目のスライスに描画する」という指定になる。
例えば直径5ピクセルで球を書き出す際は、
- パーティクルのz座標から何枚目のスライスに描画するか決定する
- 描画するxy座標での直径を決める (1.のスライスであれば直径5ピクセル)
- 隣のスライスにも同様に描画する (この時、2.の直径はz座標がずれることを想定した数値にする)
2Dテクスチャに描画する際も同様に、何枚目のスライスに書き出すプリミティブか計算した上で、書き込めば問題ない。
一例として、下記のような形のテクスチャを作る。(256ピクセル256ピクセルを1616並べて、256スライス分)
「3. ボリュームレンダリングの一例 (レイキャスティング)」
生成した3Dテクスチャをもとにボリュームレンダリングを行う。
一例としてレイを飛ばして、一定値以上になった時の深度値を描画するフラグメントシェーダーを下記に示す。
なお、通常のCubeメッシュに対して割り当てるマテリアルを想定している。
Texture3D<float> _MainTex;
SamplerState sampler_MainTex;
float4 mainFS(VS_OUT In, float vface : VFACE) : SV_Target
{
float3 cpos = _WorldSpaceCameraPos; // カメラ座標
float3 start = vface >= 0 ? In.wpos : cpos; // レイを飛ばす座標
float3 v = normalize(In.wpos - cpos); // レイのベクトル
float3 t = start;
float step = 0.035f;
int loop = 0;
int loop_max = 100;
bool hit = false;
// なにかにヒットするまで、レイを進め続ける
while(!hit && loop < loop_max)
{
// レイをすすめる
t += v * step;
loop++;
// 3Dボリューム外であれば、無視する
hit = !(t.x < -1 || t.x > 1 || t.y < -1 || t.y > 1 || t.z < -1 || t.z > 1);
if (hit)
{
// 3Dテクスチャをサンプリングして、一定値以上あれば何かしらあるとして、止める
float3 tex = float3(t.x + 1, t.y + 1, t.z + 1) / 2.0f;
hit = _MainTex.Sample(sampler_MainTex, tex) > 0.5f;
}
}
// 何もヒットしなかったのでdiscard
if (loop == loop_max) discard;
// とりあえず[カメラ-ヒットした座標]の距離を返す
return distance(t, cpos );
}
一例
終わりに
CustomRenderTexture内でGeometryShaderが使うことができれば、いろいろできる?