瓶の中に入っている液体の簡易表現を実装してみる@Unity

本記事は[Craft Egg Advent Calendar 2021](https://qiita.com/advent-calendar/2021/craftegg)の12/9の記事となります。
12/8の記事は@bikitaroさんの「国内タイトルを海外向けに開発運用した経験を振り返る」でした。

# 目次
[実装&動作確認環境](#実装動作確認環境)
[実装のきっかけ](#実装のきっかけ)
[出来上がったもの](#出来上がったもの)
[実装解説](#実装解説)
　　[方針＆要件決め](#方針要件決め)
　　[シェーダの実装](#シェーダの実装)
　　　　[1.単色塗りつぶしの単純シェーダを用意](#1-単色塗りつぶしの単純シェーダを用意)
　　　　[2.特定高さより上の部分をクリップ](#2-特定高さより上の部分をクリップ)
　　　　[3.液体表面/側面の塗り分け](#3-液体表面側面の塗り分け)
　　　　[4.xz平面に広がる波に沿ってクリップ](#4-xz平面に広がる波に沿ってクリップ)
　　　　[5.時間経過で波を動かす](#5-時間経過で波を動かす)
　　　　[6.各種パラメータをシェーダプロパティとして定義](#6-各種パラメータをシェーダプロパティとして定義
)
　　[制御コンポーネントの実装](#制御コンポーネントの実装)
　　　　[1.液体充填率の計算と設定](#1-液体充填率の計算と設定)
　　　　[2.オブジェクト挙動に連動して液体表面が波打つようなパラメータの計算と設定](#2-オブジェクト挙動に連動して液体表面が波打つようなパラメータの計算と設定)
　　　　[3.その他の設定](#3-その他の設定)
[まとめ](#まとめ)
[実装物](#実装物)
　　[シェーダ](#シェーダ)
　　[コンポーネントスクリプト](#コンポーネントスクリプト)

# 実装&動作確認環境

- Unity 2020.3.20f1
    - URP 10.6.0

# 実装のきっかけ
ある日、
__「ガラス瓶のような透明な容器に水が入っている表現で何か良さそうなのないかな…？」__
というふんわりとした要望を受けました。

流体シミュレーションなどは負荷等の面から真っ先に選択肢から外れるため、どういうのが良さそうかな、と思っていたら「こんなのできない？」と言われて見せられたのが以下です。

https://assetstore.unity.com/packages/vfx/shaders/liquid-shader-urp-lwrp-202713

自分は知らなかったんですが、この簡易表現は結構良くあるものらしいです。
運良く動画やスクショから手法が推察できたため、一から実装してみることにしました。

# 出来上がったもの
![bottleliquid_01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/fbbe0f35-153e-70ee-ca13-594dafcab6e2.gif)
![bottleliquid_02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/d85020ad-225a-9288-51d8-b8bfe06f0ab0.gif)
参考のものと同じような実装になっているかはわかりませんが、こういう表現になりました。
簡易表現ということでいろいろ制限があります。以下のようなことは__できません__。（詳細は実装解説部にて）

- 液体を半透明で描画する
- 液体内に何かを入れたり浮かばせたりする
- 液体表面のシェーディング

今回実装したシェーダやスクリプトは、[最後](#実装物)にまるっと載せておきます。
ソースを直接見た方が理解しやすい方はこちらを参照して下さい。

# 実装解説
### 方針＆要件決め
参考動画/スクショを見ていて、いくつか気付いた点がありました。

- 波打ってない状態の見た目が、カメラに近づきすぎてニアクリップされたモデルっぽい
- 波揺れのシルエットが単純形状
- 基本単色塗りつぶし
    - 液体表面部が単色
    - 液体側面部はリムによるグラデーションが入ってはいるが、基本単色

よって以下のような方針で実装していくことにしました。

- 特定の高さからより上の部分をシェーダ側で単純にクリップする
- クリップする高さyはxz座標に応じて変動させる
    - xz平面に広がるsinカーブを想定
- 液体表面/側面は単色塗りつぶし
    - 単純にクリップしているだけなので、液体表面に相当するメッシュは存在しない
    - 液体表面のように見えるのはクリップすることで描画されるようになるモデルメッシュ裏側
    - なので単色塗りつぶしで誤魔化す
- 液体は不透明
    - 上記の通り、半透明にするといろいろバレる

### シェーダの実装
#### 1. 単色塗りつぶしの単純シェーダを用意
ベースとなるシェーダを用意します。
URPの`Unlit`シェーダ標準パスの頂点/フラグメントシェーダから必要部分を引っ張ってくれば問題ありません。
ここではテクスチャサンプリング含む基本的な各種処理も不要なものはカットしてしまいます。
残った処理は以下になります（シェーダコードの主要部抜粋）。
塗りつぶしカラーはとりあえずテキトーに水色にしています。

```hlsl:BottleLiquid.shader
struct Attributes
{
    float4 positionOS : POSITION;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 vertex : SV_POSITION;
    float fogCoord : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

Varyings vert(Attributes input)
{
    Varyings output;
    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);

    output.vertex = vertexInput.positionCS;
    output.fogCoord = ComputeFogFactor(vertexInput.positionCS.z);
    return output;
}

half4 frag(Varyings input) : SV_TARGET
{
    UNITY_SETUP_INSTANCE_ID(input);

    half4 liquidColor = half4(0.5f, 0.6f, 0.9f, 1.0f);
    half4 color = liquidColor;
    color.rgb = MixFog(color.rgb, input.fogCoord);

    return color;
}
```

このシェーダを原点配置のcubeに適用したのが以下です。
陰影がついていないので単なるシルエットですね。
![スクリーンショット 2021-11-19 10.10.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/4d936884-3ae1-a22e-b2c3-826bd441fd7d.png)


#### 2. 特定高さより上の部分をクリップ
まずは単純に特定の高さより上の部分をクリップするような処理を加えてみます。ワールド原点（y=0.0f）より下なら描画されるようなコードは以下になります。

```hlsl
struct Varyings
{
    float4 vertex : SV_POSITION;
    float3 posWS : TEXCOORD0; // ワールド座標情報追加
    float fogCoord : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

Varyings vert(Attributes input)
{
    // ...

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    output.posWS = vertexInput.positionWS;

    // ...
}

half4 frag(Varyings input) : SV_TARGET
{
    // ...

    float clipPosY = 0.0f;
    // 指定高さより上部のピクセルを破棄
    clip(clipPosY - input.posWS.y);

    // ...
}
```

頂点シェーダでワールド空間の座標位置を計算し、フラグメントシェーダに渡します。
そのワールド座標を元にフラグメントシェーダにてclipを行うだけです。
高さゼロのxz平面で切り取られているんですが、背面カリングされているのでよくわからない表示にしかなっていません。
![スクリーンショット 2021-11-19 10.11.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/03cebfad-ce1e-5dff-1587-78dba42bd237.png)

#### 3. 液体表面/側面の塗り分け
上の画像だと液体側面部しか見えないため、表示物として意味不明です。
<font color="Blue">モデルメッシュの裏側を表示することで液体表面を擬装します。</font>（今回の表現のキモとなる部分）
![bottleliquid_03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/42dea2e3-e5d0-ca3d-6da6-ade467f70c35.gif)
液体表面のように見える少し明るくなっている水色の部分が、BackFace表示の裏メッシュ側です。
__単色にすることで表面なのか裏側面なのか区別がつきにくくなります。__

シェーダ上では、まずメッシュが表裏でカリングされないようにした上で、頂点の法線方向と視線方向の内積にて角度関係をチェックします。
向かい合っていれば表なので側面カラー、同じ方向なら裏なので表面カラーを割り当てます。

```hlsl
// ブレンド設定や深度設定と同じところに記載
Cull Off // 両面描画

struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS : NORMAL; // 法線情報追加
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 vertex : SV_POSITION;
    float3 posWS : TEXCOORD0;
    float3 normalWS : TEXCOORD1; // 法線情報追加
    float3 viewDir : TEXCOORD2; // 視線方向追加
    float fogCoord : TEXCOORD3;
    UNITY_VERTEX_INPUT_INSTANCE_ID
    UNITY_VERTEX_OUTPUT_STEREO
};

Varyings vert(Attributes input)
{
    // ...

    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS);
    output.normalWS = NormalizeNormalPerPixel(normalInput.normalWS);
    output.viewDir = GetWorldSpaceViewDir(vertexInput.positionWS);

    // ...
}

half4 frag(Varyings input) : SV_TARGET
{
    // ...

    // 残った下部のピクセルについて、法線方向を確認し表/裏で色分けする
    // 裏面で表示される部分が水面のように見える
    half4 liquidColorForward = half4(0.5h, 0.6h, 0.9h, 1.0h);
    half4 liquidColorBack = half4(0.6h, 0.7h, 1.0h, 1.0h);
    half NdotV = dot(input.normalWS, input.viewDir);
    half4 color = lerp(liquidColorBack, liquidColorForward, step(0.0h, NdotV));

    // ...
}
```
座標、法線、視線方向などの処理はURPのShaderLibraryに主なものが揃っているので、それを流用してしまうのが楽ですね。

#### 4. xz平面に広がる波に沿ってクリップ
[2の工程](#2-特定高さより上の部分をクリップ)で水平にクリップされるようにしましたが、これを発展させて波の形でクリップされるようにしてみます。
xz平面に広がる波の形は次のようなものを想定しています。
（画像はz軸が上方向となっているので、xy平面に広がる波となっています）
![スクリーンショット 2021-11-17 19.06.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/1ffc3c9a-dabe-a9a7-b4ff-453ded99600f.png)
画像に記載されている波の式をそのまま使用し、基準高さから波形分のオフセットを付けてクリップするようにすれば表面が波の形になります。
![bottleliquid_04.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/d62d9909-6b88-9967-4b6d-4a277dc349ba.gif)

```hlsl
half4 frag(Varyings input) : SV_TARGET
{
    // ...

    float waveBaseY = 0.0f;
    float waveSize = 0.1f;
    float waveCycleCoef = 10.0f;

    // 設定されたパラメータとXZ座標から波の高さを算出
    float2 waveInput = input.posWS.xz * waveCycleCoef;
    float clipPosY = waveBaseY + (sin(waveInput.x) + sin(waveInput.y)) * waveSize;

    // 指定高さより上部のピクセルを破棄
    clip(clipPosY - input.posWS.y);

    // ...
}
```

波のパラメータは一旦固定値で用意しています。
また、確認しやすくするために上記動画では波形計算に使用するxzにワールド座標を使用していますが、それだとどの液体の波形も一緒になってしまうため、実際にはローカル座標を使用するべきです。

#### 5. 時間経過で波を動かす
波形が固定されていると波感が全く無いので、常時動かすようにしてみます。
![bottleliquid_05.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/4a95fcef-6d2a-36c6-d4f4-fa4d375fa068.gif)
unityのビルトインシェーダ変数である`_Time`を波形計算に組み込むだけでなので簡単です。
`_Time`の各要素にどんな値が入っているかはマニュアルの方を参照して下さい。
https://docs.unity3d.com/ja/2020.3/Manual/SL-UnityShaderVariables.html

```hlsl
half4 frag(Varyings input) : SV_TARGET
{
    // ...

    float waveOffsetCycleCoef = 10.0f;
    // 設定されたパラメータとXZ座標から波の高さを算出
    float2 waveInput = input.posWS.xz * waveCycleCoef;
    float waveInputOffset = _Time.y * waveOffsetCycleCoef;
    waveInput += waveInputOffset;
    float clipPosY = waveBaseY + (sin(waveInput.x) + sin(waveInput.y)) * waveSize;

    // 指定高さより上部のピクセルを破棄
    clip(clipPosY - input.posWS.y);

    // ...
}
```

#### 6. 各種パラメータをシェーダプロパティとして定義
これで基本のシェーダ実装は完了しました。
ただ、これまでクリップ基準高さや波形振幅、周期の値をシェーダ内にベタ書きしてきたため、外部からのパラメータ変更ができません。
マテリアル経由でパラメータを受け取れるようにシェーダプロパティとして定義し、各処理が参照する変数を修正します。

```hlsl
float4 _WaveCenter; // 波中心位置（xzはモデルの中心、yは波の基準高さ）
float4 _WaveParams; // 波パラメータ
float4 _LiquidColorForward; // 液体モデル表カラー（液体側面カラー）
float4 _LiquidColorBack; // 液体モデル裏カラー（液体表面カラー）

#define WaveSize (_WaveParams.x) // 波振幅
#define WaveCycleCoef (_WaveParams.y) // 波周期
#define WaveOffsetCycleCoef (_WaveParams.z) // 波の移動速度
```

あとは、制御コンポーネントの方でこれらシェーダプロパティを制御して液体挙動を調整していきます。

### 制御コンポーネントの実装
コンポーネントスクリプトは、スクリプトとして素直な実装となっているので、
それぞれの処理についての概要だけかいつまんで説明します。

#### 1. 液体充填率の計算と設定
シェーダの実装から分かる通り、この表現は容器の形状に制限はありません。
（ただ単に特定座標より高いピクセルをクリップしているだけなので）
ただ、どの高さより上をクリップすればいいのかを決めるにあたり、容器の一番高い箇所と一番低い箇所をワールド座標系で計算できるようになっている必要があります。

容器の特徴となる点をローカル座標でコンポーネントにリストで登録できるようにしておき、
その点を元に容器がどんな姿勢になってもワールド座標上で一番高い点と低い点を計算できるようにします。
あとは充填率パラメータを元にクリップ基準となる高さを算出します。
厳密である必要は全くなく、特徴を捉えた座標だけが登録されていれば問題ありません。
cubeモデルに設定した場合、下の画像の黄色い球の座標を設定する感じです。
![スクリーンショット 2021-11-19 13.58.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/9486e56c-096c-8f5a-dd87-ec8f105ba419.png)
また、「充填率」という名前ですが、内部的にはクリップ基準高さの割合でしかないため上下左右が非対称の容器だと不自然になってしまう点に注意してください。
例えば、下の動画のような容器の場合、同じ充填率50%でも体積が大きい方が下の場合と小さい方が下の場合では、高さが同じでも液体として見える量が大きく異なるためなるべく避けるようにした方が無難です。
![bottleliquid_06.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/b157e4d5-e6e0-c2bf-4019-d26da191cb0f.gif)
この設定を行うことで、どの姿勢でも充填率に応じた液体表面の高さを算出することができます。
![bottleliquid_07.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/8bc544a9-0361-59bd-c14f-6eb2e7d1e672.gif)

#### 2. オブジェクト挙動に連動して液体表面が波打つようなパラメータの計算と設定
オブジェクトの動き、つまり平行移動と回転移動によって波のパラメータを変化させるようにします。
併せて、波の振幅と周期については毎フレーム一定量減衰させます。
そうすると、これまで微妙だった機械的な動きが目立たなくなり液体感が一気に上がります。
![bottleliquid_02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/d85020ad-225a-9288-51d8-b8bfe06f0ab0.gif)
処理としては直前のフレームの位置と回転値を保存しておき、差分が発生したらその大きさに応じて波の振幅/周期を加算するという単純なものですが、減衰を含めたこの辺りのパラメータ調整が大きく品質に関わるので、少しややこしくはなりますがしっかりパラメータを細分化します。
![bottleliquid_08.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/0d81defd-6007-c36f-a65b-e54750f73379.gif)
![bottleliquid_09.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2298208/98d440ff-9f03-6690-6fe9-d6d641b03338.gif)

- 減衰率
    - 振幅
    - 周期
- 移動による波への影響率
    - 平行移動
    - 回転
- 最大変化量
    - 振幅
    - 周期
- 時間による波の位相変化係数

最低限このくらいは必要になりそうです。

スクリプトに記載されているパラメータのデフォルト値は、今回の実装動作確認にて調整したものになります。
一連の動画の液体挙動もこのパラメータ設定で動作しています。

#### 3. その他の設定
残りは単純設定となりますが、液体カラーもコンポーネントから指定できるようにします。
液体表面と液体側面で全く異なるカラーを設定することもできますが、側面カラーを少し明るくしたものを表面カラーに設定するのが最も自然に見えるので、そうしておきます。

# まとめ
今回実装したのは最低限の機能だけで、以下のような拡張が考えられます。

- deltaTime対応
    - フレームレートが変わっても同じパラメータで同じ挙動となるようにする
- 波の形状
    - 波形が単純なので、もっと周期が目立たない波の形状を考えてみる
- 充填率と波パラメータの連動
    - 瓶内の液体が空or満タンの状態で波打つのは不自然なので、その対処
- 液体を単色以外にカラーリング
    - すぐ対応できるのは液体側面のリムや水位による上下グラデーション
    - 法線やテクスチャを使ってもう少し凝ったこともできるかも

個々の処理や実装は非常に単純なんですが、その使い方を工夫するだけで少し「おっ？」と目を引けるような表現になるっていうのは面白いですね。
流体シミュレーションのような正攻法による解決を追い求めるのは他の人に任せて、こういう裏道のような表現で面白いものがあればまた一から実装してみたいと思います。

[Craft Egg Advent Calendar](https://qiita.com/advent-calendar/2021/craftegg)、次回は@kawase_ikutaさんの記事になります。

# 実装物
### シェーダ

```hlsl:BottleLiquid.shader
Shader "Model/BottleLiquid"
{
    Properties
    {
        _WaveCenter("Wave Center", Vector) = (0.0, 0.0, 0.0, 0.0)
        _WaveParams("Wave Params", Vector) = (0.0, 0.0, 0.0, 0.0)
        _LiquidColorForward("Liquid Color Forward", Color) = (0.5, 0.5, 0.5, 1)
        _LiquidColorBack("Liquid Color Back", Color) = (0.8, 0.8, 0.8, 1)
    }
    SubShader
    {
        Tags {"RenderType" = "Opaque" "IgnoreProjector" = "True" "RenderPipeline" = "UniversalPipeline" "ShaderModel"="4.5"}
        LOD 100

        Blend One Zero
        ZWrite On
        ZTest LEqual

        // 両面描画
        Cull Off

        Pass
        {
            Name "BottleLiquid"

            HLSLPROGRAM
            #pragma exclude_renderers gles gles3 glcore
            #pragma target 4.5

            #pragma vertex vert
            #pragma fragment frag

            // -------------------------------------
            // Unity defined keywords
            #pragma multi_compile_fog
            #pragma multi_compile_instancing
            #pragma multi_compile _ DOTS_INSTANCING_ON

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            float4 _WaveCenter;
            float4 _WaveParams;
            float4 _LiquidColorForward;
            float4 _LiquidColorBack;

            #define WaveSize (_WaveParams.x)
            #define WaveCycleCoef (_WaveParams.y)
            #define WaveOffsetCycleCoef (_WaveParams.z)

            struct Attributes
            {
                float4 positionOS : POSITION;
                float3 normalOS   : NORMAL;

                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct Varyings
            {
                float4 vertex   : SV_POSITION;
                float3 posWS    : TEXCOORD0;
                float3 normalWS : TEXCOORD1;
                float3 viewDir  : TEXCOORD2;
                float fogCoord  : TEXCOORD3;

                UNITY_VERTEX_INPUT_INSTANCE_ID
                UNITY_VERTEX_OUTPUT_STEREO
            };

            Varyings vert(Attributes input)
            {
                Varyings output;
                UNITY_SETUP_INSTANCE_ID(input);
                UNITY_TRANSFER_INSTANCE_ID(input, output);

                VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
                VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS);

                output.vertex = vertexInput.positionCS;
                output.posWS = vertexInput.positionWS;
                output.normalWS = NormalizeNormalPerPixel(normalInput.normalWS);
                output.viewDir = GetWorldSpaceViewDir(vertexInput.positionWS);
                output.fogCoord = ComputeFogFactor(vertexInput.positionCS.z);
                return output;
            }

            half4 frag(Varyings input) : SV_TARGET
            {
                UNITY_SETUP_INSTANCE_ID(input);

                // 設定されたパラメータとローカルXZ座標から波の高さを算出
                float waveBaseY = _WaveCenter.y;
                float2 localXZ = _WaveCenter.xz - input.posWS.xz;
                float2 waveInput = localXZ * WaveCycleCoef;
                float waveInputOffset = _Time.y * WaveOffsetCycleCoef;
                waveInput += waveInputOffset;
                float clipPosY = waveBaseY + (sin(waveInput.x) + sin(waveInput.y)) * WaveSize;

                // 計算したY位置より上部のピクセルを破棄
                clip(clipPosY - input.posWS.y);

                // 残った下部のピクセルについて、法線方向を確認し表/裏で色分けする
                // 裏面で表示される部分が水面のように見える
                half NdotV = dot(input.normalWS, input.viewDir);
                half4 color = lerp(_LiquidColorBack, _LiquidColorForward, step(0.0h, NdotV));

                color.rgb = MixFog(color.rgb, input.fogCoord);
                color.a = 1.0h;

                return color;
            }

            ENDHLSL
        }
    }

    FallBack "Hidden/Universal Render Pipeline/FallbackError"
}
```

### コンポーネントスクリプト

```cs:BottleLiquid.cs
using System.Collections.Generic;
using UnityEngine;

[ExecuteAlways]
[RequireComponent(typeof(Renderer))]
public class BottleLiquid : MonoBehaviour
{
    /// <summary>
    /// BottleLiquidシェーダ関連のプロパティID定義
    /// </summary>
    private static class ShaderPropertyId
    {
        public static readonly int BottleLiquidWaveCenter = Shader.PropertyToID("_WaveCenter");
        public static readonly int BottleLiquidWaveParams = Shader.PropertyToID("_WaveParams");
        public static readonly int BottleLiquidColorForward = Shader.PropertyToID("_LiquidColorForward");
        public static readonly int BottleLiquidColorBack = Shader.PropertyToID("_LiquidColorBack");
    }

    /// <summary>液面カラーオフセット値</summary>
    private static readonly Color LiquidColorTopOffset = new Color(0.15f, 0.15f, 0.15f, 0.0f);

    /// <summary>液体カラー</summary>
    [SerializeField] private Color liquidColor;

    /// <summary>瓶形状の概要を表すオフセットポイントリスト</summary>
    [SerializeField] private Vector3[] bottleSizeOffsetPoints;

    /// <summary>充填率</summary>
    [Range(0.0f, 1.0f)] [SerializeField] private float fillingRate = 0.5f;

    /// <summary>位置差分による動きの影響率</summary>
    [Range(0.0f, 2.0f)] [SerializeField] private float positionInfluenceRate = 0.7f;

    /// <summary>回転差分による動きの影響率</summary>
    [Range(0.0f, 2.0f)] [SerializeField] private float rotationInfluenceRate = 0.4f;

    /// <summary>波の大きさの減衰率</summary>
    [Range(0.0f, 1.0f)] [SerializeField] private float sizeAttenuationRate = 0.92f;

    /// <summary>波の周期の減衰率</summary>
    [Range(0.0f, 1.0f)] [SerializeField] private float cycleAttenuationRate = 0.97f;

    /// <summary>時間による位相変化係数</summary>
    [SerializeField] private float cycleOffsetCoef = 12.0f;

    /// <summary>差分による変化量最大（波の大きさ）</summary>
    [SerializeField] private float deltaSizeMax = 0.15f;

    /// <summary>差分による変化量最大（波の周期）</summary>
    [SerializeField] private float deltaCycleMax = 10.0f;

    /// <summary>制御対象となるマテリアル</summary>
    private Material[] targetMaterials;

    /// <summary>前回参照位置</summary>
    private Vector3 prevPosition;

    /// <summary>前回参照オイラー角度</summary>
    private Vector3 prevEulerAngles;

    /// <summary>現在の液体波パラメータ</summary>
    private Vector4 waveCurrentParams;

    /// <summary>
    /// 開始時処理
    /// </summary>
    private void Start()
    {
        Renderer targetRenderer = GetComponent<Renderer>();
        if (targetRenderer == null)
        {
            return;
        }

        if (targetMaterials == null || targetMaterials.Length <= 0)
        {
            List<Material> targetMaterialList = new List<Material>();
            for (int index = 0; index < targetRenderer.sharedMaterials.Length; index++)
            {
                Material material = targetRenderer.sharedMaterials[index];
                if (material.shader.name.Contains("BottleLiquid"))
                {
                    targetMaterialList.Add(material);
                }
            }

            targetMaterials = targetMaterialList.ToArray();
        }

        waveCurrentParams = Vector4.zero;

        BackupTransform();
    }

    /// <summary>
    /// 更新処理
    /// </summary>
    private void Update()
    {
        if (targetMaterials == null || targetMaterials.Length <= 0)
        {
            return;
        }

        CalculateWaveParams();
        SetupMaterials();

        BackupTransform();
    }

    /// <summary>
    /// 波パラメータ算出
    /// </summary>
    private void CalculateWaveParams()
    {
        // waveParamsにそのままベクトル演算
        // xは振幅、yは周期
        Vector4 attenuationRateVec = new Vector4(sizeAttenuationRate, cycleAttenuationRate, 0.0f, 0.0f);
        Vector4 deltaMaxVec = new Vector4(deltaSizeMax, deltaCycleMax, 0.0f, 0.0f);

        // 減衰処理
        waveCurrentParams = Vector4.Scale(waveCurrentParams, attenuationRateVec);

        // 位置と回転の差分から変化値産出
        Transform thisTransform = transform;
        Vector3 currentRotation = thisTransform.eulerAngles;
        Vector3 diffPos = thisTransform.position - prevPosition;
        Vector3 diffRot = new Vector3(
            Mathf.DeltaAngle(currentRotation.x, prevEulerAngles.x),
            Mathf.DeltaAngle(currentRotation.y, prevEulerAngles.y),
            Mathf.DeltaAngle(currentRotation.z, prevEulerAngles.z));

        waveCurrentParams += deltaMaxVec * (diffPos.magnitude * positionInfluenceRate);
        waveCurrentParams += deltaMaxVec * (diffRot.magnitude * rotationInfluenceRate * 0.01f); // オイラー角差分は元の値が大きいので補正

        waveCurrentParams = Vector4.Min(waveCurrentParams, deltaMaxVec);

        // 時間による位相変化は減衰対象外
        waveCurrentParams.z = cycleOffsetCoef;
    }

    /// <summary>
    /// 波の中心位置算出
    /// </summary>
    private Vector4 CalculateWaveCenter()
    {
        // xzはオブジェクトの中心（ワールド座標系）
        // yは瓶形状と充填率から液面の高さを設定（ワールド座標系）
        (float min, float max) liquidSurfaceHeight = GetLiquidSurfaceHeight();
        return transform.position +
               Vector3.up * Mathf.Lerp(liquidSurfaceHeight.min, liquidSurfaceHeight.max, fillingRate);
    }

    /// <summary>
    /// マテリアル設定
    /// </summary>
    private void SetupMaterials()
    {
        Vector4 waveCenter = CalculateWaveCenter();

        for (int index = 0; index < targetMaterials.Length; index++)
        {
            Material material = targetMaterials[index];
            material.SetVector(ShaderPropertyId.BottleLiquidWaveCenter, waveCenter);
            material.SetVector(ShaderPropertyId.BottleLiquidWaveParams, waveCurrentParams);
            material.SetColor(ShaderPropertyId.BottleLiquidColorForward, liquidColor);
            material.SetColor(ShaderPropertyId.BottleLiquidColorBack, liquidColor + LiquidColorTopOffset);
        }
    }

    /// <summary>
    /// 姿勢情報の保存
    /// </summary>
    private void BackupTransform()
    {
        prevPosition = transform.position;
        prevEulerAngles = transform.eulerAngles;
    }

    /// <summary>
    /// オブジェクトローカルにおける液面の高さ（最小/最大）を取得
    /// </summary>
    private (float min, float max) GetLiquidSurfaceHeight()
    {
        if (bottleSizeOffsetPoints == null || bottleSizeOffsetPoints.Length <= 0)
        {
            return (0.0f, 0.0f);
        }

        Transform thisTransform = transform;
        (float min, float max) ret = (float.MaxValue, float.MinValue);
        for (int index = 0; index < bottleSizeOffsetPoints.Length; index++)
        {
            Vector3 localPoint = thisTransform.TransformPoint(bottleSizeOffsetPoints[index]) - thisTransform.position;
            ret.min = Mathf.Min(ret.min, localPoint.y);
            ret.max = Mathf.Max(ret.max, localPoint.y);
        }

        return ret;
    }

#if UNITY_EDITOR
    /// <summary>
    /// 選択時のギズモ表示
    /// </summary>
    private void OnDrawGizmosSelected()
    {
        if (bottleSizeOffsetPoints == null || bottleSizeOffsetPoints.Length <= 0)
        {
            return;
        }

        // 瓶形状オフセットポイントの表示
        Gizmos.color = Color.yellow;
        for (int index = 0; index < bottleSizeOffsetPoints.Length; index++)
        {
            Vector3 point = bottleSizeOffsetPoints[index];
            Gizmos.DrawSphere(transform.TransformPoint(point), 0.05f);
        }
    }
#endif
}
```