VRCで全てを容器に入った液体にするシェーダーを作ってみる

この記事はK3 Advent Calendar2024 の18日目の記事です。


VRChatで良い感じの液体、欲しいですよね?

容器を傾けるとちゃんと水面も傾いて、振ると良い感じにゆらゆら揺れて、それっぽい反射がある液体、欲しいですよね?

去年作って力尽きた放置していたものの供養です

アセットはできれば明日配布します……

2日遅れてこちらで配布開始しました

どんな物体も容器に入った液体っぽく表示する

まず水っぽく表示してみましょう。フレネルと現在いる場所のReflectionProbのCubemap又は指定したCubeMapをオブジェクトにつけてみます

HLSL
v2f vert(appdata v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_OUTPUT(v2f, o);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    o.pos = UnityObjectToClipPos(v.vertex);
    o.modelNormal = v.normal;
    o.worldNormal = UnityObjectToWorldNormal(v.normal);
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.viewDir = normalize(getCameraPos() - o.worldPos);
    UNITY_TRANSFER_FOG(o, o.pos);
    return o;
}

fixed4 frag(v2f i) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);

    const half3 reflDir = reflect(-i.viewDir, i.worldNormal);
    // Reflection ProbeのCubeMapまたは指定したCubeMapを反射させる
    fixed4 reflrectCol = _UseCustomCubeMap == 1
             ? UNITY_SAMPLE_TEXCUBE(_Cube, reflDir)
             : UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, reflDir);
    reflrectCol.rgb = DecodeHDR(reflrectCol, unity_SpecCube0_HDR);


    const float vdotn = dot(i.viewDir, i.modelNormal);
    const half fresnel = F0 + (1.0h - F0) * pow(1.0h - vdotn, 5);
    // フレネルで反射色を適当に混ぜる
    const half refrection = clamp(fresnel, 0, _Refrection);
    fixed3 rgb = lerp(_Color.rgb, reflrectCol.rgb, refrection);

    fixed4 col = fixed4(rgb, _Color.a);
    UNITY_APPLY_FOG(i.fogCoord, col);
    return col;
}

それっぽくなりました

Grabpassによる屈折なども入れるとよりそれっぽくなると思いますが、今回は簡易液体ということで省略します


次にオブジェクトを「指定の高さまで液体で満たされている」っぽく表示します

特定のy座標以上ならクリッピングされるようにしてみましょう。

HLSL
fixed4 frag(v2f i) : SV_Target
{
    // 一定より上は表示しないようにする
    clip(_ClipHeight - i.worldPos.y);
    
    ....

これだと裏面が表示されないので微妙ですね。裏面も描画するようにしてみます。半透明オブジェクトなので以下の記事と同様のことをしておきます

…くり抜かれた箱っぽくなってしまいましたね。

裏面の法線がBoxのものままなのでそれはそうです。ひとまず面が裏側なら法線を上向きにするように変更してみましょう。

HLSL
  fixed4 frag(v2f i,fixed facing : VFACE) : SV_Target
  {
      // 一定より上は表示しないようにする
      clip(_ClipHeight - i.worldPos.y);

      // 裏面の場合は法線を上に向ける
      const float3 modelNormal = facing < 0 ? fixed3(0, 1, 0) : i.modelNormal;
      
      ...

これでオブジェクトが「指定の高さまで液体で満たされている」っぽくなりました

傾けても水面の位置は変わらないので、容器の角度に従って水が移動しているように見え…見えるよね?

水面を波立たせる

水面っぽく波立たせていきます。

オブジェクトのxz座標を入力としてパーリンノイズを生成し、その値に応じてクリッピング位置を変更しましょう

HLSL
inline float2 rand(float2 st)
{
    st = float2(dot(st,fixed2(127.1, 311.7)), dot(st,fixed2(269.5, 183.3)));
    return -1.0 + 2.0 * frac(sin(st) * 43758.5453123);
}

  inline float perlinNoise(float2 st)
  {
      float2 p = floor(st);
      float2 f = frac(st);

      float w00 = dot(rand(p), f);
      float w10 = dot(rand(p + float2(1, 0)), f - float2(1, 0));
      float w01 = dot(rand(p + float2(0, 1)), f - float2(0, 1));
      float w11 = dot(rand(p + float2(1, 1)), f - float2(1, 1));

      float2 u = f * f * (3 - 2 * f);

      return lerp(lerp(w00, w10, u.x), lerp(w01, w11, u.x), u.y);
  }

inline float getWaveNoise(const float2 st)
{
    // 大きさを調整
    float2 uv = st * _UVScale + _Time.x * _WaveSpeed;
    const float noise = perlinNoise(uv);
    return noise;
}

inline float calcClipYPos(const float2 posXZ)
{
    return getWaveNoise(posXZ) * _WaveHeight + _ClipHeight;
}

fixed4 frag(v2f i,fixed facing : VFACE) : SV_Target
{
    // 一定より上は表示しないようにする
    clip(calcClipYPos(i.worldPos.xz) - i.worldPos.y);
    
    ...

形は波打つようになりましたが、水面自体は平らで変わらないので微妙な感じですね。これも法線が変わっていないからです。

先ほど裏面の法線は強制的に上方向になるようにしましたが、ここでパーリンノイズから法線も計算して反映してみましょう。クリッピング位置と同様にひとまずワールドのxz座標を入力にして法線を計算してみます。

HLSL
inline float3 calcWaveNormal(const float2 posXZ)
{
    float du = getWaveNoise(posXZ + float2(1.0, 0.0)).r - getWaveNoise(posXZ - float2(1.0, 0.0)).r;
    float dv = getWaveNoise(posXZ + float2(0.0, 1.0)).r - getWaveNoise(posXZ - float2(0.0, 1.0)).r;
    float3 normal = normalize(float3(-du, -dv, 1));
    normal = normalize(float3(normal.xy * _WaveHeight, normal.z));
    // xz平面上になるように回転
    const half3x3 rotationMatrix = half3x3(
        1, 0, 0,
        0, 0, 1,
        0, -1, 0);
    return mul(rotationMatrix, normal);
}

fixed4 frag(v2f i,fixed facing : VFACE) : SV_Target
{
    // 一定より上は表示しないようにする
    clip(calcClipYPos(i.worldPos.xz) - i.worldPos.y);

    // xz座標を入力にして法線を計算してみる
    const float3 modelNormal = facing < 0
                                    ? calcWaveNormal(i.worldPos.xz)
                                    : i.modelNormal;
    const float3 worldNormal = facing < 0 ? modelNormal : i.worldNormal;
    return fixed4(modelNormal, 1);
    ...

なんか変ですね。そういえばこの水面はオブジェクトの裏面をそれっぽく表示しているだけです。なのでこのxz座標は元の面の位置であって、水面上の位置ではないのです。この見かけ上の水面があるy座標におけるxz座標を求めて、それを入力にしてみましょう。

HLSL
fixed4 frag(v2f i,fixed facing : VFACE) : SV_Target
{
    // 一定より上は表示しないようにする
    clip(calcClipYPos(i.worldPos.xz) - i.worldPos.y);

    // 視線ベクトルと水面位置との交点を調べる
    const float3 eye = -i.viewDir;
    const float3 cameraPos = getCameraPos();
    const float t = (_ClipHeight - cameraPos .y) / eye.y;
    const float3 pos = float3(cameraPos .x + t * eye.x, _ClipHeight,
                              cameraPos.z + t * eye.z);
    // 水面における法線ベクトルを求める
    i.normal = facing < 0 ? calcWaveNormal(pos.xz) : i.normal;
    ...

これでいい感じに水面が波立ちました

もちろん法線でやってるだけなので波の高さが高いと違和感がありますが(レイマーチングで実装するともっと表現度が上がりそうですが簡易液体ということで(略

オブジェクトを振ったら水面が動くようにする

オブジェクトを降った時だけ水面が揺れて、徐々に凪いでいく感じにしましょう。

UdonSharp側でコンポーネントを用意して送ってあげます。

先ほどまではワールド座標で水面の高さを指定していました。「容器の何%液体が入っているか」で指定できるようにしたいので、メッシュのAABBの現在の最も高い位置と低い位置から水面の位置を計算してシェーダーへ指定します。折角なのでボタンを押すだけでAABBを自動計算してくれるようにします

(UdonSharpってpropertyID でのマテリアルのプロパティ変更できないんですね。エラーは出ないのでしばらく悩んでました…)

C#
public class VRCBottleLiquidComponent : UdonSharpBehaviour
{
    #region serializedFields

    // 液体の充填率
    [SerializeField]
    private float fillingRate = 0.5f;

    // 波の大きさ(細かさ)の減衰率
    [SerializeField]
    private float waveDensityAttenuationRate = 0.998f;

    // 波の大きさ
    [SerializeField]
    private float waveMaxDensity = 15f;

    // 波の高さの減衰率
    [SerializeField]
    private float waveHeightAttenuationRate = 0.995f;

    // 波の高さの最大値
    [SerializeField]
    private float waveMaxHeight = 0.12f;

    // 波の速さ
    [SerializeField]
    private float waveSpeed = 30f;

    [SerializeField]
    private float positionInfluenceRate = 0.5f;

    [SerializeField]
    private float rotationInfluenceRate = 0.5f;

    // マテリアル設定
    [SerializeField]
    private Color liquidColor = new Color(0f, 0.7f, 1.0f, 0.5f);

    [SerializeField]
    private Cubemap waveReflectionMap;
    
    [SerializeField]
    private float waveReflectionIntensity = 0.3f;

    [SerializeField]
    private Vector3[] bottleAABB;

    [SerializeField]
    private bool isShowAABB = true;
    
    [SerializeField]
    private Material initialMaterial;

    #endregion

    #region privateFields
    private float _currentWaveHeightRatio;
    private float _currentWaveDensityRatio;

    private Vector3 _prevPosition;
    private Quaternion _prevRotation;
    private float _rotationInfluence;
    private float _positionInfluence;

    private Material[] _targetMaterials = null;
    #endregion
    
    private void Start()
    {
        SetupMaterial();
        UpdateMaterial();
        _prevPosition = transform.position;
        _prevRotation = transform.rotation;
    }

    private void Update()
    {
        if (_targetMaterials == null|| _targetMaterials.Length == 0)
            return;

        UpdateWaveParameter();
        BackupTransform();
    }

    private void BackupTransform()
    {
        // 位置と回転の差分から変化値を計算
        _positionInfluence = (transform.position - _prevPosition).magnitude * positionInfluenceRate;
        // 回転の変化量の取得
        _rotationInfluence = Quaternion.Angle(transform.rotation, _prevRotation) * 0.01f * rotationInfluenceRate;
        _prevPosition = transform.position;
        _prevRotation = transform.rotation;
    }


    private void SetupMaterial()
    {
        Material[] materials = GetComponent<Renderer>().materials;
        int liquidMaterialCount = 0;
        foreach (var material in materials)
        {
            if (material.shader.name == "UsagiMeteor/BottleLiquidShader")
            {
                liquidMaterialCount++;
            }
        }
        _targetMaterials = new Material[liquidMaterialCount];
        int index = 0;
        foreach (var material in materials)
        {
            if (material.shader.name == "UsagiMeteor/BottleLiquidShader")
            {
                _targetMaterials[index] = material;
                index++;
            }
        }
    }

    private void UpdateMaterial()
    {
        if (_targetMaterials == null)
        {
            return;
        }
        
        foreach (var material in _targetMaterials)
        {
            material.SetFloat("_WaveSpeed", waveSpeed);
            material.SetColor("_Color", liquidColor);
            material.SetFloat("_Refrection", waveReflectionIntensity);
            if (waveReflectionMap != null)
            {
                material.SetTexture("_Cube", waveReflectionMap);
                material.SetInt("_UseCustomCubeMap", 1);
            }
            else
            {
                material.SetInt("_UseCustomCubeMap", 0);
            }
        }
    }

    private void UpdateWaveParameter()
    {
        _currentWaveDensityRatio = CalculateAttenuation(_currentWaveDensityRatio, waveDensityAttenuationRate);
        _currentWaveHeightRatio = CalculateAttenuation(_currentWaveHeightRatio, waveHeightAttenuationRate);
        float clipHeight = CalculateLiquidSurfaceHeight();
        float waveHeight = waveMaxHeight * _currentWaveHeightRatio * fillingRate;
        float uvScale = Mathf.Max(1, waveMaxDensity * _currentWaveDensityRatio);
        foreach (var material in _targetMaterials)
        {
            material.SetFloat("_ClipHeight", clipHeight);
            material.SetFloat("_WaveHeight", waveHeight);
            material.SetFloat("_UVScale", uvScale);
        }
    }

    private float CalculateAttenuation(float currentRatio, float attenuationRate)
    {
        // 減衰
        currentRatio *= attenuationRate;

        // 移動・回転に寄る影響を加算
        currentRatio += _positionInfluence;
        currentRatio += _rotationInfluence;

        currentRatio = Mathf.Clamp01(currentRatio);

        return currentRatio;
    }

    /// <summary>
    /// 液面の高さを取得
    /// </summary>
    private float CalculateLiquidSurfaceHeight()
    {
        if (bottleAABB == null || bottleAABB.Length == 0)
        {
            return 0;
        }

        Transform thisTransform = transform;
        float min = float.MaxValue;
        float max = float.MinValue;
        foreach (var t in bottleAABB)
        {
            Vector3 localPoint = thisTransform.TransformPoint(t) - thisTransform.position;
            max = Mathf.Max(max, localPoint.y);
            min = Mathf.Min(min, localPoint.y);
        }

        return Mathf.Lerp(min, max, fillingRate) + transform.position.y;
    }

    #region EditorOnly

#if UNITY_EDITOR && !COMPILER_UDONSHARP
    /// <summary>
    /// AABBを自動で設定
    /// エディタ拡張側から呼ぶ
    /// </summary>
    public void SetUpOffsetPoints()
    {
        Mesh mesh = null;
        if (TryGetComponent(out MeshFilter meshFilter))
        {
            mesh = meshFilter.sharedMesh;
        }
        
        if(mesh == null && TryGetComponent(out SkinnedMeshRenderer skinnedMeshRenderer))
        {
            mesh = skinnedMeshRenderer.sharedMesh;
        }

        if (mesh == null)
        {
            return;
        }

        Vector3 maxPos = mesh.vertices[0];
        Vector3 minPos = mesh.vertices[0];

        foreach (Vector3 vertex in mesh.vertices)
        {
            maxPos.x = Mathf.Max(maxPos.x, vertex.x);
            maxPos.y = Mathf.Max(maxPos.y, vertex.y);
            maxPos.z = Mathf.Max(maxPos.z, vertex.z);

            minPos.x = Mathf.Min(minPos.x, vertex.x);
            minPos.y = Mathf.Min(minPos.y, vertex.y);
            minPos.z = Mathf.Min(minPos.z, vertex.z);
        }
        
        Vector3[] pos = new Vector3[8];
        for (int bit = 0; bit < Mathf.Pow(2, 3); bit++)
        {
            pos[bit] = new Vector3((1 & bit >> 2) == 1 ? maxPos.x : minPos.x,
                (1 & bit >> 1) == 1 ? maxPos.y : minPos.y, (1 & bit >> 0) == 1 ? maxPos.z : minPos.z);
        }
        
        bottleAABB = pos;
    }
#endif

    #endregion
}

これでできました🎉メッシュの付いたオブジェクトにこのコンポーネントを指定して設定をするだけで簡単に液体にできます

パラメーターがめっちゃ多いので粘度指定とかオブジェクトの大きさからよしなにするようにしたかったんだけど力尽きた。誰かお願いします

参考

VRChat

関連記事
guest
0 コメント
古い順
新しい順 投票数順
Inline Feedbacks
全てのコメントを見る