凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

uRaymarching を使った VRChat のワールドを作ってみた

はじめに

こりんさん(@korinVR)が VRChat で行われる以下のようなイベントを企画してくれました。

以前簡単な Avatar を作ってみただけで VRChat ではあまり活動できていなかったのですが、今回のイベント用になにかワールドを作りたいなと思い、uRaymarching を使ったワールドの作成に取り組んでみました。幸い、特に uRaymarching の更新はせずにそのまま出力したシェーダを VRChat で使うことが出来ました。本記事は作ってみたデモの内容について紹介したいと思います。

デモ

キューブだけで出来たワールドを作ってみました。

この前半部のメタボールの紹介をします。

uRaymarching について

距離関数を記述するだけで他のポリゴンのオブジェクトやライティングと整合の取れたレイマーチングによるオブジェクトを出すことのできる Unity のアセットです。HDRP は未だですが、Forward / Deferred / URP に対応していて、VR にも凡そ対応しています。シェーダだけで完結するので VRChat でもそのまま使えます(ただしレイマーチングは御存知の通り重いので用法・容量には注意が必要です)。

github.com

tips.hecomi.com

VR 版解説

デモの内容は、以下の GitHub にあげてある uRaymarching のデモの移植になります。

github.com

f:id:hecomi:20200731001731g:plain

まずはこちらの Examples の解説から見ていきましょう。

設定と距離関数

uRaymarching の Conditions > World Space にチェックを入れて、距離関数にワールド座標が入ってくるようにします。

f:id:hecomi:20200731003832p:plain

その上で次のような距離関数を記述します:

float4x4 _Cube;
float4x4 _Sphere;
float4x4 _Torus;
float4x4 _Plane; 
float _Smooth;

inline float DistanceFunction(float3 wpos)
{
    float4 cPos = mul(_Cube, float4(wpos, 1.0));
    float4 sPos = mul(_Sphere, float4(wpos, 1.0));
    float4 tPos = mul(_Torus, float4(wpos, 1.0));
    float4 pPos = mul(_Plane, float4(wpos, 1.0));
    float s = Sphere(sPos, 0.5);
    float c = Box(cPos, 0.5);
    float t = Torus(tPos, float2(0.5, 0.2));
    float p = Plane(pPos, float3(0, 1, 0));
    float sc = SmoothMin(s, c, _Smooth);
    float tp = SmoothMin(t, p, _Smooth);
    return SmoothMin(sc, tp, _Smooth);
}

f:id:hecomi:20200731012517p:plain

幾つか uRaymarching 付属の関数を使ってしまっていて読みづらいかもしれませんが...、各プリミティブの距離関数(SphereBox など)を SmoothMin(滑らかに結合)で合成しています。オブジェクトそれぞれのトランスフォームの逆行列float4x4C# 側から与えています。つまり、DistanceFunction() にはワールド座標 wpos が入ってきているのですが、ここにその逆行列をかけると各ポリゴンのローカル座標へと変換され、そのローカル座標を距離関数にいれることで C# 側から与えた位置で移動・回転可能なレイマーチングのオブジェクトを記述できます。

トランスフォームの受け渡し

この C# 側のスクリプトは以下のようになります:

using UnityEngine;

[ExecuteInEditMode]
public class TransformProvider : MonoBehaviour
{
    [System.Serializable]
    public class NameTransformPair
    {
        public string name;
        public Transform transform;
    }

    [SerializeField]
    NameTransformPair[] pairs;

    void Update()
    {
        var renderer = GetComponent<Renderer>();
        if (!renderer) return;

        var material = renderer.sharedMaterial;
        if (!material) return;

        foreach (var pair in pairs)
        {
            var pos = pair.transform.position;
            var rot = pair.transform.rotation;
            var mat = Matrix4x4.TRS(pos, rot, Vector3.one);
            var invMat = Matrix4x4.Inverse(mat);
            material.SetMatrix(pair.name, invMat);
        }
    }
}

シェーダ側に渡すキーワード名と、渡す座標の Transform をペアにして登録し、この Transform から Matrix4x4 を取り出して逆行列にしたものを Material.SetMatrix() する形になります。インスペクタでは次のように登録します。

f:id:hecomi:20200731004218p:plain

これで準備は出来ました。この上でレンダリングする方法は 2 通りあります。

レンダリング①:単一のキューブで描画

キューブポリゴンを 1 つ用意し、ここに上記シェーダから生成したマテリアルを指定します。Cube や Sphere などは Renderer コンポーネントを持たない単なる Transform を提供するだけのロケータとして扱います。これで次のようになります。

f:id:hecomi:20200731004735p:plain

レンダリング②:複数のキューブで描画

もう 1 つの方法は、各 Cube や Sphere ゲームオブジェクトを Transform の提供のロケータとして使うと同時に、Renderer で描画もそこに行う方法です。各キューブポリゴンはオーバーラップしますが、SmoothMin によってオーバーラップがきれいに解決されます。

f:id:hecomi:20200731005122p:plain

メリット・デメリット

②の方法は描画エリアが限定されるので、小さいものがたくさんあるような場合は隙間のピクセルレンダリングが走らないので軽くなります。ただ、たくさんオーバーラップしてかつカメラが近い場合は重複してピクセルシェーダが起動されるので重くなります。

①の方法はたくさんのピクセルシェーダが起動されてしまいますが安定してシンプルです。また、ライティング周りでライトプローブの影響などを考慮したい場合も整合が取れます。①の方法だと各ポリゴン単位でライトプローブのサンプリングが行われてしまうのでオーバーラップした接続部でライティングの不整合が起こり、破綻してしまいます。

色付け

色つけですが、こんな感じで PostEffect で再度レイマーチングを走らせ、その結果の距離から近ければその色が濃くなるようにすればキレイに混ざり合うブレンドが可能です。

float4 _CubeColor;
float4 _SphereColor;
float4 _TorusColor;
float4 _PlaneColor;

inline void PostEffect(RaymarchInfo ray, inout PostEffectOutput o)
{
    float3 wpos = ray.endPos;
    float4 cPos = mul(_Cube, float4(wpos, 1.0));
    float4 sPos = mul(_Sphere, float4(wpos, 1.0));
    float4 tPos = mul(_Torus, float4(wpos, 1.0));
    float4 pPos = mul(_Plane, float4(wpos, 1.0));
    float s = Sphere(sPos, 0.5);
    float c = Box(cPos, 0.5);
    float t = Torus(tPos, float2(0.5, 0.2));
    float p = Plane(pPos, float3(0, 1, 0));
    float4 a = normalize(float4(1.0 / s, 1.0 / c, 1.0 / t, 1.0 / p));
    o.Albedo =
        a.x * _SphereColor +
        a.y * _CubeColor +
        a.z * _TorusColor +
        a.w * _PlaneColor;
}

VRChat への移植

uRaymarching で生成したオブジェクトは基本的にはそのまま publish してもらえれば VR 内でも問題なく見れると思います。ただ今回はトランスフォームの受け渡しのところで見たようにスクリプトが必要です。このスクリプトの部分には Udon を使うことにします。U# を使ったほうが簡単とは思いますが、まずは初めてなので Udon Graph で実装してみました。全体像はこんな感じです:

f:id:hecomi:20200731010737p:plain

レンダラのループ

f:id:hecomi:20200731010832p:plain

renderers という Renderer[] な public な変数を用意しておきます。これを LateUpdate() 起点で for ループで回して一つ一つ処理していきます。

トランスフォームの受け渡し

f:id:hecomi:20200731011115p:plain

各図形のトランスフォームを public な変数として用意しておき、ここから先程と同様に行列を作成、逆行列にしてマテリアルに SetMatrix() します。配列に詰めたりすればもう少しシンプルに書けるとは思うのですが、まぁ量がそれほど多くないので愚直に 4 つ並べてみました。

持てるようにする

適当なコライダを各トランスフォームゲームオブジェクトに取り付け、その上で VRC_Pickup コンポーネントをつけておきました。UdonBehaviorSynchronize Position つけておけばこれで同期されるんでしょうか...、イベント前には試しておきたいです。

レンダリング

ベイクしたライトプローブによるライティングの反映も見てみたかったので今回はレンダリング①を採用しました。キューブの中に入る形になるので、Camera Inside Object にチェックを入れ、かつ裏面も描画されるようにマテリアルの設定で CullingOff にしておきます。

f:id:hecomi:20200731011718p:plain

なお、この範囲から外に持って出るとそのオブジェクトは消えてしまいます。見失わないように中心部に小さいキューブポリゴンを仕込んであります(冒頭の動画のトーラスの中心を見ると入っているのが分かると思います…)。

おわりに

イベントまでには少し調整して皆さんに公開できるようにしておきます。初めて試しましたが、uRaymarching が VRChat でもそのまま動いて嬉しかったです!ただライティングや影周りでまだ幾つかバグを見つけたので継続して改善していきたいです。