LoginSignup
8

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-09-23

はじめに

本記事では、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が使うことができれば、いろいろできる?

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
8