💧

【Unity/URP】2Dの水面表現を考える➁(波紋)

2025/01/06に公開

はじめに

この記事では、URPの2Dプロジェクトで水面を表現する方法を検証していきます。
前回の記事では、反射やコースティクスなどを実装しました。今回は、オブジェクトが水面に接触した際に波紋が立つ表現を実装したいと思います。

何を作るか

下のような実行結果になります。今回もC#スクリプトとフラグメントシェーダーで実装しました。
画像では水面に接触しているオブジェクトは1つだけですが、複数のオブジェクトに対して反応するようにしています。アセットは前回同様にUnityTechnologiesの2D Game Kitを使用させていただきました。

コード全文

動作環境
Unity 2022.3.17f1
URP 14.0.9
Water2D.cs
using System.Collections.Generic;
using UnityEngine;

namespace Shader2DSample
{
    public class Water2D : MonoBehaviour
    {
        [SerializeField] Camera _camera;
        
        [SerializeField, Range(0.0f, 1.0f)] float _waveAmount;
        [SerializeField, Range(0.0f, 1.0f)] float _waveScale;
        [SerializeField, Range(0.0f, 10.0f)] float _waveSpeed;

        [SerializeField] Color _causticsColor;
        [SerializeField, Range(0, 10)] int _causticsScale;
        [SerializeField, Range(0.0f, 1.0f)] float _causticsIntensity;
        [SerializeField, Range(-10.0f, 10.0f)] float _aberration;
        
        [SerializeField, Range(0.0f, 100.0f)] float _rippleAmount;
        [SerializeField, Range(0.0f, 10.0f)] float _rippleScale;
        [SerializeField, Range(0.0f, 10.0f)] float _rippleSpeed;
                
        readonly string _shaderName = "Custom/Water2D";
        readonly int _textureScaleId = Shader.PropertyToID("_TextureScale");
        readonly int _waveAmountId = Shader.PropertyToID("_WaveAmount");
        readonly int _waveScaleId = Shader.PropertyToID("_WaveScale");
        readonly int _waveSpeedId = Shader.PropertyToID("_WaveSpeed");
        readonly int _causticsColorId = Shader.PropertyToID("_CausticsColor");
        readonly int _causticsScaleId = Shader.PropertyToID("_CausticsScale");
        readonly int _causticsIntensityId = Shader.PropertyToID("_CausticsIntensity");
        readonly int _aberrationId = Shader.PropertyToID("_Aberration");
        readonly int _rippleAmountId = Shader.PropertyToID("_RippleAmount");
        readonly int _rippleScaleId = Shader.PropertyToID("_RippleScale");
        readonly int _rippleSpeedId = Shader.PropertyToID("_RippleSpeed");
        readonly int _contactPointsId = Shader.PropertyToID("_ContactPoints");
        readonly int _numContactPoints = Shader.PropertyToID("_NumPoints");
        readonly int _maxPointCount = 20;

        Material _material;
        float _units;
        List<int> _contactedObjects = new List<int>();
        List<Vector4> _contactPoints = new List<Vector4>();

        void Awake()
        {
            // 水面のマテリアルを作成
            var shader = Shader.Find(_shaderName);
            _material = new Material(shader);

            // 作成したマテリアルを適用
            var spriteRenderer = GetComponent<SpriteRenderer>();
            spriteRenderer.material = _material;

            // テクスチャの縦幅が占めるユニット数を計算
            var sprite = spriteRenderer.sprite;
            _units = sprite.textureRect.height / sprite.pixelsPerUnit;

            // シェーダー側の衝突位置の配列を初期化
            _material.SetVectorArray(_contactPointsId, new Vector4[_maxPointCount]);
        }

        void Update()
        {
            _material.SetFloat(_textureScaleId, TextureScale());
            _material.SetFloat(_waveAmountId, _waveAmount);
            _material.SetFloat(_waveScaleId, _waveScale);
            _material.SetFloat(_waveSpeedId, _waveSpeed);
            _material.SetColor(_causticsColorId, _causticsColor);
            _material.SetInt(_causticsScaleId, _causticsScale);
            _material.SetFloat(_causticsIntensityId, _causticsIntensity);
            _material.SetFloat(_aberrationId, _aberration);
            _material.SetFloat(_rippleAmountId, _rippleAmount);
            _material.SetFloat(_rippleScaleId, _rippleScale);
            _material.SetFloat(_rippleSpeedId, _rippleSpeed);
        }

        /// <summary>
        /// スクリーンの縦幅に対するテクスチャの縦幅の割合を取得する.
        /// </summary>
        /// <returns>テクスチャの縦幅のスケール.</returns>
        float TextureScale()
        {
            float ratio = _units / (_camera.orthographicSize * 2.0f);
            return transform.localScale.y * ratio;
        }

        /// <summary>
        /// 水面に接触したオブジェクトをリストに反映する.
        /// </summary>
        /// <param name="collision">接触しているコライダー.</param>
        void AddContactPoint(Collider2D collision)
        {
            // 接触しているオブジェクトの座標をスクリーン座標に変換.
            var contactPoint = _camera.WorldToScreenPoint(collision.transform.position);

            int id = collision.GetInstanceID();
            int index = _contactedObjects.IndexOf(id);
            if (index >= 0)
            {
                // リストにidがあれば_contactPointsの中の対応する座標を更新.
                _contactPoints[index] = contactPoint;
            }
            else if (_contactPoints.Count < _maxPointCount)
            {
                // リストにidがなければ値を追加する.
                _contactedObjects.Add(id);
                _contactPoints.Add(contactPoint);
            }
            else return;
            UpdateContactPoints();
        }

        /// <summary>
        /// 水面から離脱したオブジェクトをリストから削除する.
        /// </summary>
        /// <param name="collision"></param>
        void RemoveContactPoint(Collider2D collision)
        {
            int id = collision.GetInstanceID();
            int index = _contactedObjects.IndexOf(id);
            if (index >= 0)
            {
                // リストから除いてシェーダーの配列を更新する.
                _contactedObjects.RemoveAt(index);
                _contactPoints.RemoveAt(index);
                UpdateContactPoints();
            }
        }
        
        /// <summary>
        /// シェーダーの接触位置の配列を更新する.
        /// </summary>
        void UpdateContactPoints()
        {
            _material.SetVectorArray(_contactPointsId, _contactPoints);
            _material.SetInt(_numContactPoints, _contactPoints.Count);
        }

        void OnTriggerStay2D(Collider2D collision)
        {
            AddContactPoint(collision);
        }

        void OnTriggerExit2D(Collider2D collision)
        {
            RemoveContactPoint(collision);
        }        
    }
}
Water2D.shader
Shader "Custom/Water2D"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "LightMode"="Universal2D" }
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag

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

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
                half4 color : COLOR;
            };

            struct Varyings
            {
                float2 uv : TEXCOORD0;
                float4 positionHCS : SV_POSITION;
                half4 color : COLOR;
            };

            TEXTURE2D(_MainTex);
            TEXTURE2D(_CameraSortingLayerTexture);
            SAMPLER(sampler_CameraSortingLayerTexture);

            float4 _CameraSortingLayerTexture_TexelSize;
            float _TextureScale;
            float _WaveAmount;
            float _WaveScale;
            float _WaveSpeed;
            half4 _CausticsColor;
            int _CausticsScale;
            float _CausticsIntensity;
            float _Aberration;
            float _RippleAmount;
            float _RippleScale;
            float _RippleSpeed;
            #define MAX_POINT_COUNT 20
            float4 _ContactPoints[MAX_POINT_COUNT];
            int _NumPoints;

            CBUFFER_START(UnityPerMaterial)
            float4 _MainTex_ST;
            float4 _MainTex_TexelSize;
            CBUFFER_END

            // 0~1のランダムな2次元座標を返す.
            float2 random2(float2 co)
            {
                co = float2(dot(co, float2(127.1, 311.7)), dot(co, float2(269.5, 183.3)));
                return frac(sin(co) * 43758.5453);
            }

            // セルラーノイズを計算する.
            float cellularNoise(float2 co, int scale)
            {
                co = co * scale;
                float minDist = 1;

                for (int i = -1; i <= 1; i++)
                {
                    for (int j = -1; j <= 1; j++)
                    {
                        float2 n = float2(i, j);
                        float2 p = random2(floor(co) + n);
                        p = sin(p * 6.2831 + _Time.y) * 0.5 + 0.5;
                        float dist = distance(frac(co), n + p);
                        minDist = min(minDist, dist);
                    }
                }
                return minDist;
            }

            // コースティクスを計算する.
            float caustics(float2 co)
            {
                float noise = cellularNoise(co, _CausticsScale);
                return pow(abs(noise), 1 / (0.05 + _CausticsIntensity)) * (1 - step(_CausticsIntensity, 0));
            }

            Varyings vert (Attributes IN)
            {
                Varyings OUT;
                OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex);
                OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
                OUT.color = IN.color;
                return OUT;
            }
            
            half4 frag (Varyings IN) : SV_Target
            {
                // 接触している最も近いオブジェクトまでの距離を取得する.
                float minDist = length(_ScaledScreenParams.xy);
                for (int i = 0; i < _NumPoints; i++)
                {
                    float dist = distance(IN.positionHCS.xy, _ContactPoints[i].xy);
                    minDist = min(minDist, dist);
                }

                // 波紋の量、速さ、大きさを決定.
                float rippleFactor = IN.uv.x * _RippleAmount + _Time.y * _RippleSpeed;
                float rippleScale = _RippleScale / (50 + minDist);

                // uv座標を波状に変形.v座標値が閾値を上回ったら描画しない.
                IN.uv.y += (cos(rippleFactor) * 0.5 + 0.5) * rippleScale;
                clip(-step(1, IN.uv.y));

                // 揺れの量、速さ、大きさを決定.
                float waveFactor = IN.positionHCS.y * _WaveAmount + _Time.y * _WaveSpeed;
                float waveScale = _WaveScale * 0.005;

                // _CameraSortingLayerTextureのuv座標を求める.
                float2 uv = IN.positionHCS.xy / _ScaledScreenParams.xy;
                uv.x += cos(waveFactor) * waveScale;
                uv.y += (1 - IN.uv.y) * _TextureScale * 2;

                // 水面の色を決定する.
                half4 reflection = SAMPLE_TEXTURE2D(_CameraSortingLayerTexture, sampler_CameraSortingLayerTexture, uv);
                half4 baseColor = reflection * IN.color;

                // コースティクスの強さを求める.
                float r = caustics(IN.uv + _MainTex_TexelSize.xy * _Aberration);
                float g = caustics(IN.uv);
                float b = caustics(IN.uv - _MainTex_TexelSize.xy * _Aberration);

                // 水面の色とコースティクスの色を線形補完して出力.
                return lerp(baseColor, _CausticsColor, half4(r, g, b, g));
            }

            ENDHLSL
        }
    }
}

githubに公開しています。

Water2D.shader

前回のフラグメントシェーダーに少し処理を追加しました。追加した内容に絞って取り上げているのでご了承ください。

オブジェクトとの距離を取得

波紋の強さをピクセルの位置とオブジェクトとの距離で決めるため、スクリプトからプロパティに渡された値を使って距離を求めます。

Water2D.shader
// 接触している最も近いオブジェクトまでの距離を取得する.
float minDist = length(_ScaledScreenParams.xy);
for (int i = 0; i < _NumPoints; i++)
{
    float dist = distance(IN.positionHCS.xy, _ContactPoints[i].xy);
    minDist = min(minDist, dist);
}

ここに登場する_NumPointsは水面に接触しているオブジェクトの数、_ContactPointsは接触オブジェクトたちのスクリーン座標の配列になっています。複数のオブジェクトが接触している場合は、上記の処理で最も近いオブジェクトとの距離を求め、その値を使うようにします。

波紋の見え方を設定

先程取得した距離を使って、波紋の影響範囲やスクロールの速度を設定します。

Water2D.cs
// 波紋の量、速さ、大きさを決定.
float rippleFactor = IN.uv.x * _RippleAmount + _Time.y * _RippleSpeed;
float rippleScale = _RippleScale / (50 + minDist);

rippleFactorは後で三角関数に渡す値です。_RippleAmountが波の量、_RippleSpeedが波の速さに相当します。
rippleScaleは後で三角関数に乗算する値で、一つの波の大きさに相当します。_RippleScaleに設定した値を反映しつつ、距離が遠いほど小さくなるように上記のような式にしました。

uv座標を書き変える

上記の設定を使ってuv座標を変換し、波状の境界線を描くようにします。

Water2D.cs
// uv座標を波状に変形.v座標値が閾値を上回ったら描画しない.
IN.uv.y += (cos(rippleFactor) * 0.5 + 0.5) * rippleScale;
clip(-step(1, IN.uv.y));

v座標値にcos関数の値を加算し、波状に変化させています。波がテクスチャの領域を超えないように、cos関数の結果を[0,1]にマッピングしてあります。最後の行では、vの値が1以上になったところをclip()で破棄するようにしています。

Water2D.cs

こちらも前回からの変更箇所に絞っての説明となります。

シェーダーの配列を初期化

まずはAwake()の中身です。先程シェーダーに登場した_ContactPointsの値を初期化しておきます。

Water2D.cs
// シェーダー側の衝突位置の配列を初期化
_material.SetVectorArray(_contactPointsId, new Vector4[_maxPointCount]);

SetVectorArray()でVector4型の配列またはリストをプロパティに渡すことができますが、仕様上はじめに渡したデータの要素数が最大数に設定されるようなので、余分に確保しておくためにこのようにしました。

プロパティを更新する

シェーダーの_NumPointsと_ContactPointsを更新するためのメソッドを用意します。

Water2D.cs
/// <summary>
/// シェーダーの接触位置の配列を更新する.
/// </summary>
void UpdateContactPoints()
{
    _material.SetVectorArray(_contactPointsId, _contactPoints);
    _material.SetInt(_numContactPoints, _contactPoints.Count);
}

オブジェクトの接触を検知

水面に接触したオブジェクトをリストに保持しておくためのメソッドを用意します。

Water2D.cs
/// <summary>
/// 水面に接触したオブジェクトをリストに反映する.
/// </summary>
/// <param name="collision">接触しているコライダー.</param>
void AddContactPoint(Collider2D collision)
{
    // 接触しているオブジェクトの座標をスクリーン座標に変換.
    var contactPoint = _camera.WorldToScreenPoint(collision.transform.position);

    int id = collision.GetInstanceID();
    int index = _contactedObjects.IndexOf(id);
    if (index >= 0)
    {
        // リストにidがあれば_contactPointsの中の対応する座標を更新.
        _contactPoints[index] = contactPoint;
    }
    else if (_contactPoints.Count < _maxPointCount)
    {
        // リストにidがなければ値を追加する.
        _contactedObjects.Add(id);
        _contactPoints.Add(contactPoint);
    }
    else return;
    UpdateContactPoints();
}

_contactObjectsに接触中のオブジェクトのインスタンスidを格納しておき、この値を使ってリスト内のデータが重複していないかチェックしています。
すでにリストにいたら_contactPoints中の座標を書き変え、いなければ新しく座標を追加してシェーダーのプロパティに反映します。

オブジェクトの離脱を検知

先程とは反対に、オブジェクトが水面から離れた時にリストの値を削除するメソッドを用意します。

Water2D.cs
/// <summary>
/// 水面から離脱したオブジェクトをリストから削除する.
/// </summary>
/// <param name="collision"></param>
void RemoveContactPoint(Collider2D collision)
{
    int id = collision.GetInstanceID();
    int index = _contactedObjects.IndexOf(id);
    if (index >= 0)
    {
        // リストから除いてシェーダーの配列を更新する.
        _contactedObjects.RemoveAt(index);
        _contactPoints.RemoveAt(index);
        UpdateContactPoints();
    }
}   

OnTrigger~から呼び出す

上の2つのメソッドが適当なタイミングで実行されるようにしておきます。

Water2D.cs
void OnTriggerStay2D(Collider2D collision)
{
    AddContactPoint(collision);
}

void OnTriggerExit2D(Collider2D collision)
{
    RemoveContactPoint(collision);
}

実行すると、無事にキャラクターのいるところに波が出てきました。(静止画だとわかりにくいですね。)

最後に

以上、2Dの水面表現➁でした。元々2Dでジオメトリシェーダーを使いたくてそっちで実装していたんですが、「これやっぱりフラグメントシェーダーで良くないか...」となって今回の内容に着地しました。何か面白いアイデアがあったら改めて書きたいと思います。
ここまで読んでいただきありがとうございました!

リンク

https://assetstore.unity.com/packages/templates/tutorials/2d-game-kit-107098
https://github.com/kr405/UnityWater2DSample

Discussion