e.blog

主にUnity/UE周りのことについてまとめていきます

VFX GraphのConform to Signed Distance Fieldのコードを読んでみる

概要

最近VFX Graphをちょっとずつ触り始めています。
そのVFX Graphの中にConform to Signed Distance FieldというBlockがあります。

SDFを活用することで表現力が向上するので、今回はそのコードを読んでSigned Distance Field、SDFがどういうふうに使われているのかを見ていきたいと思います。

実際に実行するとこんな感じでパーティクルがSDFの形状に変わります。

f:id:edo_m18:20210102110842g:plain

ちなみにSDFファイル自体にどういう情報が格納されているのかというのは以下の記事を参考にさせていただきました。

qiita.com

VFX Graphが生成したコードを確認する

VFX Graphファイルを生成し、グラフを作成するとそれに準じたコードが自動生成されます。
今回はこれをエディタで開いて見ていくことにします。

VFX Graphのファイルの左横に「▼」マークがあるのでこれを開くと以下のように、自動生成されたシェーダが表示されるので、これをダブルクリックすることで普通にエディタで開くことができます。

f:id:edo_m18:20210101183112p:plain

特に今回はUpdateの内容を確認したいので一番下の「[GenerateDynamicSignedDistanceField] [System] Update Particle」を開いて確認していきます。

VFX GraphはGPU上で動作するため、各パーティクルのUpdateはCompute Shaderによって行われます。
Compute Shader自体の説明はここでは割愛します。興味がある方は以前に書いた以下の記事を参考にしてください。

edom18.hateblo.jp

edom18.hateblo.jp

Update Particleのカーネル

早速見ていきましょう。生成されたシェーダのカーネルは新規作成したときと同じようにCSMainです。
コードを見ていくとConformToSDFという関数が呼ばれている箇所がありました。これがきっと更新処理を行っているところでしょう。

呼び出し部分のコードを見ると以下のようになっています。

ConformToSDF( /*inout */attributes.velocity, attributes.position, attributes.mass, GetVFXSampler(DistanceField_a, samplerDistanceField_a), InvFieldTransform_a, FieldTransform_a, attractionSpeed_a, attractionForce_a, stickDistance_a, stickForce_a, deltaTime_a);

引数が多いですが、名前を見ればなんとなくどういう情報が使われているか分かるかと思います。

ConformToSDF関数を読み解く

ConformToSDF関数を詳しく見ていきましょう。
詳細についてはコード自体にコメント形式で注釈を加えました。

void ConformToSDF(inout float3 velocity, float3 position, float mass, VFXSampler3D DistanceField, float4x4 InvFieldTransform, float4x4 FieldTransform, float attractionSpeed, float attractionForce, float stickDistance, float stickForce, float deltaTime)
{
    // パーティクルのワールド位置をローカル位置に変換する
    float3 tPos = mul(InvFieldTransform, float4(position,1.0f)).xyz;

    // -0.5 ~ 0.5 を 0.0 ~ 1.0 に変換している
    float3 coord = saturate(tPos + 0.5f);

    // 3Dテクスチャからサンプリング
    float dist = SampleSDF(DistanceField, coord);
    
    // ローカル位置の絶対値(のちの判定をしやすくしている)
    float3 absPos = abs(tPos);

    // 各軸に対して一番遠い位置を取得
    // = -0.5 ~ 0.5範囲のAABBに対して外に出ているかチェックするための値
    float outsideDist = max(absPos.x,max(absPos.y,absPos.z));

    float3 dir;

    // AABBの内か外かの判定。-0.5 ~ 0.5の範囲かつ絶対値判定なので
   // 0.5より上ならAABB範囲外と判定できる
    if (outsideDist > 0.5f) // Check wether point is outside the box
    {
        // in that case just move towards center
        // 範囲外の場合はたんに中心に向かって進む
        dist += outsideDist - 0.5f;

        // FieldTransformの4カラム目の3要素=translateの内容なのでVFX Effect自体のワールド座標
        dir = normalize(float3(FieldTransform[0][3],FieldTransform[1][3],FieldTransform[2][3]) - position);
    }
    else
    {
        // compute normal
        // 法線の計算
        // 偏微分を用いて距離ベクトルの勾配を求め、その方向へ移動させる
        dir = SampleSDFDerivativesFast(DistanceField, coord, dist);

        // distが0以上なら向きを反転させる
        if (dist > 0)
            dir = -dir;

        // ワールド座標に変換した上で正規化する
        dir = normalize(mul(FieldTransform,float4(dir,0)).xyz);
    }
    
    // SDFの表面までの距離
    float distToSurface = abs(dist);
    
    // SDF方向と速度の内積=速度を方向に射影してスピードを計算?
    float spdNormal = dot(dir,velocity);

    // 距離に応じてsmoothstepを掛ける
    float ratio = smoothstep(0.0,stickDistance * 2.0,abs(distToSurface));

    // sign = -1.0 or 0.0 or 1.0に変換。そして絶対値を取っているので実質0.0 or 1.0に変換(=これを乗ずることで向きが決まる)
    // 向き * 引きつける力 * 比率
    float tgtSpeed = sign(distToSurface) * attractionSpeed * ratio;

    // SDFによって引きつけられる力 - 速度による影響度を引く
    float deltaSpeed = tgtSpeed - spdNormal;

    // abs(deltaSpeed)か
    // deltaTime * lerp(stickForce, attractionForce,ratio)
    // の計算結果のうち、小さい値を採用
    // sign(deltaSpeed)は移動すべき方向を示すためのもの?
    // つまり、(引力 * speed * dir) / massを計算している。体積で割ることで加速度を求めている(F = ma)
    // 加速度を速度に加算して終了
    velocity += sign(deltaSpeed) * min(abs(deltaSpeed),deltaTime * lerp(stickForce,attractionForce,ratio)) * dir / mass ;
}

コードはそんなに長くないですね。
いくつかの箇所は想像による部分と、自身がなくて「?」付きになっている箇所があります。
今回はコード自体の大まかな流れを把握するためなのでそこまで深堀りしていません。

SDFファイルの中身

ここは自分もまだしっかり理解しきれていないのですが、もんしょの巣穴さんの記事(DXRで生成するSigned Distance Field)では以下のように記載されていました。

SDF テクスチャを作るのに使用したのは DirectX Raytracing です。 ボリュームテクスチャの1つのVoxelに対して、Voxel中心から複数のレイを飛ばして衝突判定を取り、最接近距離を求めています。 最接近距離ですので絶対値の最小値を利用し、その距離を1~-1の値になるように正規化します。 あるAABB内で最大の距離は対角線の距離になりますが、面倒だったのでAABBの1辺の長さの3倍を今回は用いています。

このことから、(通常は)AABBの対角線の距離で各距離を割ることで正規化を行うようです。
そしてAABBの中心から一番遠い外側は0.5、一番遠い内側は-0.5になります。
(対角線の長さが1なので、中心からは0.5ずつ離れることになる)

これが、0.5を足して補正している理由だと思います。
またAABBの内か外かの判定に0.5より上を指定しているのもそれが理由ですね。

偏微分を用いた力の計算

参考にさせていただいた記事でも言及されていますが、SDFファイル内に保存されている値自体というよりは方向が重要な意味を持っているようです。

言及箇所を引用させていただくと、

Conform to Signed Distance Field BlockとCollide with Signed Distance Field Blockで生成されるコードを見た感じでは、その地点の符号付き距離の値自体というよりもその地点の符号付き距離を微分したベクトル(つまり符号付き距離が0になる地点への最短方向)のほうが大事そうでした。そのベクトルは距離関数が正規化されていなくても求まるはずなので、細かいことは考えずにTexture3Dに符号付き距離を入れればよさそうです

とのこと。

ということで、実際に微分を行っている箇所を見てみると以下のようになっています。

float3 dir;
if (outsideDist > 0.5f) // Check wether point is outside the box
{
    // 中略
}
else
{
    // compute normal
    dir = SampleSDFDerivativesFast(DistanceField, coord, dist);
    // 後略
}

SampleSDFDerivativesFastという関数が呼び出されているのが分かります。
Derivatives微分という意味なのでまさにそれを行っている箇所ですね。

関数は以下のように実装されていました。

float3 SampleSDFDerivativesFast(VFXSampler3D s, float3 coords, float dist, float level = 0.0f)
{
    float3 d;
    // 3 taps
    const float kStep = 0.01f;
    d.x = SampleSDF(s, coords + float3(kStep, 0, 0));
    d.y = SampleSDF(s, coords + float3(0, kStep, 0));
    d.z = SampleSDF(s, coords + float3(0, 0, kStep));
    return d - dist;
}

float SampleSDF(VFXSampler3D s,float3 coords,float level = 0.0f)
{
    return SampleTexture(s,coords,level).x;
}

float4 SampleTexture(VFXSampler3D s,float3 coords,float level = 0.0f)
{
    return s.t.SampleLevel(s.s,coords,level);
}

SampleSDFはただのテクスチャサンプリングです。
サンプル位置(coords)は以下のように計算されています。

float3 tPos = mul(InvFieldTransform, float4(position,1.0f)).xyz;
float3 coord = saturate(tPos + 0.5f);

これを見て分かる通り、パーティクルの位置をローカル座標空間に変換したのちに+0.5fし、-0.5 ~ 0.50.0 ~ 1.0に変換していると思われます。
なのでローカル座標空間で3Dテクスチャをサンプリングしている、というわけですね。

SampleSDFDerivativesFastでは少しずつサンプリング位置をずらして3Dテクスチャをサンプリングしそれを距離と引いた値を返しています。

レイマーチングで使われる「偏微分を用いて法線を求める」ことと同じことをやっていますね。
偏微分は勾配を求めるために使われます。つまり「その方向に進めばゼロに近づくベクトル」を求めていることに他なりません。

そして求めた勾配ベクトルを利用して最終的には加速度を算出し、それを速度に加算しています。

velocity += sign(deltaSpeed) * min(abs(deltaSpeed),deltaTime * lerp(stickForce,attractionForce,ratio)) * dir / mass ;

なので最終的に行っていることは、SDFから勾配ベクトルを求め、その方向に向かって引き寄せられるように速度を調整している、というわけですね。

ちなみに偏微分で勾配ベクトルを求めることがどうして法線につながるのか、というイメージについて以前記事を書いているので興味がある方は読んでみてください。

qiita.com

MeshをSDFに変換する

SDFについてまとめてきましたが、単純な形状だけを扱うことはあまりないと思います。
そこで、ありがたいことにMeshからSDFに変換してくれるアセットを公開してくれている方がいます。

以下のGitHubからアセットをダウンロードしてインポートすることで利用できるので、MeshをSDFに変換したい方は試してみてください。

github.com

まとめ

コードをざーっと読んできましたが、一言でなにをしているかを言うと「SDFを利用して勾配ベクトルを求め、その方向にパーティクルを近づける」ということですね。

色々細かい計算が入っていますが、そのあたりについては時間があるときにもう少し深ぼってみようと思います。

その他参考にした記事

colourmath.com

marupeke296.com