Help us understand the problem. What is going on with this article?

CustomRenderTextureだけでボリュームレンダリングしてみる!!

はじめに

本記事では、VRChatでボリュームレンダリングするための根底技術を記載する。
VRChatに持ち込むためスクリプト等は用いずCustomRenderTexture+Shaderのみで実装する。
また、実装に伴いCustomRenderTexture内で任意のメッシュを描画する方法を記載する。

書いてるテクニックの要約

  1. CustomRenderTexture内で任意のメッシュを描画する方法
  2. 3DのCustomRenderTextureに対して、パーティクルを書き込む方法
  3. ボリュームレンダリングの一例 (レイキャスティング)

ボリュームレンダリングとは

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を256*256*256にするのであれば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ピクセルで球を書き出す際は、
1. パーティクルのz座標から何枚目のスライスに描画するか決定する
2. 描画するxy座標での直径を決める (1.のスライスであれば直径5ピクセル)
3. 隣のスライスにも同様に描画する (この時、2.の直径はz座標がずれることを想定した数値にする)
2Dテクスチャに描画する際も同様に、何枚目のスライスに書き出すプリミティブか計算した上で、書き込めば問題ない。
一例として、下記のような形のテクスチャを作る。(256ピクセル*256ピクセルを16*16並べて、256スライス分)
image.png

「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内で計算
- カメラからの距離で色付け
image.png

終わりに

CustomRenderTexture内でGeometryShaderが使うことができれば、いろいろできる?

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした