🌊

【Unity/URP】2Dの水面表現を考える➀(反射・コースティクス)

2025/01/03に公開

はじめに

この記事では、URPの2Dプロジェクトで水面を表現する方法を検証していきます。
プライベートで2Dゲームを作成しているのですが、2D向けのシェーダーやエフェクトってなかなか欲しい情報にたどり着けないなと感じたので、やりがいのありそうな水面の表現を通して色々な手法に触れたいと思っています。

何を作るか

今回は主にフラグメントシェーダーを実装して、水面らしさを演出する反射やコースティクスを表現したいと思います。コースティクスという言葉は聞きなじみがない方もいるかもしれませんが、水やガラスを通った光が作る網目のような模様です。
動作結果は下のような感じです。白っぽい筋のような部分がコースティクス(のつもり)です。

なお、キャラクターのアセットはUnityTechnologiesの2D Game Kitを使用しています。

コード全文

動作環境
Unity 2022.3.17f
URP 14.0.9
Water2D.cs
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;

        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");

        Material _material;
        float _units;

        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;
        }

        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);
        }

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

            float _TextureScale;
            float _WaveAmount;
            float _WaveScale;
            float _WaveSpeed;
            half4 _CausticsColor;
            int _CausticsScale;
            float _CausticsIntensity;
            float _Aberration;

            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 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にも公開中です。

反射

まずは水面の反射を見ていきます。方針としては、2D用のRendererによって生成できる_CameraSortingLayerTextureを使い、スクリーン上の色をサンプリングして水面に反映させます。

水面用のSortingLayerを追加する

_CameraSortingLayerTextureは、指定したSortingLayerから後ろの描画結果をキャプチャします。そのため、今回は水面用にレイヤーを追加し、それよりも後ろのレイヤーにあるオブジェクトが水面へ映り込むようにしていきます。
Edit>Project Settings>Tags and Layers】の 【Sorting Layers】に新しいレイヤー【Water】を追加しました。水面用のオブジェクトはこのレイヤーに配置しておきます。

_CameraSortingLayerTextureを設定する

デフォルトでは_CameraSortingLayerTextureが無効になっているので、URPの設定を変更します。
使用しているRenderer 2D Dataを選び、Inspectorの【CameraSortingLayerTexture】を開きます。ここにある【Foremost Sorting Layer】でキャプチャする最前面のレイヤーを指定しておきます。

水面の揺れ方を設定

ここからはシェーダーの中身を見ていきます。頂点シェーダーは特に変わったことはしていないので、フラグメントシェーダーに焦点を当てています。はじめに、水面の揺れを作るためのパラメータの設定です。

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

waveFactorはあとで三角関数に渡す値です。縦に波が出るようにスクリーンスペースのY座標を使っています。また、_Timeの値を足し合わせて波が動くようにしました。_WaveAmountと_WaveSpeedはそれぞれ揺れの量とスピードを決めるプロパティですね。
waveScaleはあとで三角関数に乗算する値で、一つの揺れの大きさに相当します。適当な数値を乗算して値を抑えるようにしてあります。

uv座標を計算

_CameraSortingLayerTexture用のuv座標を求めます。

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

IN.positionHCS.xyはスクリーン上の座標、_ScaledScreenParams.xyはスクリーンの横幅・縦幅なので、上のように除算することでuv座標として使えます。
uv.xにcos関数の値を足すことで、サンプリングの位置が波状に変化するようにしています。また、uv.yは水面と対称な位置にあるオブジェクトの色を拾うように値をずらしています。下のイラストのようなイメージです。

_TextureScaleはスクリーンの縦幅に対する水面の縦幅の割合になります。この値はカメラの距離やオブジェクトのスケールによって変わるので、後ほどC#スクリプトで計算して渡すようにします。

色を決める

計算したuv座標を使って_CameraSortingLayerTextureの色をサンプリングし、水面の色に反映させます。

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

ここに関しては特に説明することはないかなと思います。サンプリングした色をSpriteRendererの方で指定した色と乗算するようにしました。フラグメントシェーダーがbaseColorを返すようにすれば、反射と揺れが反映されるはずです。

テクスチャサイズを渡す

シェーダーの_TextureScaleプロパティにテクスチャサイズを渡すために、C#スクリプトを書きます。
まず、Awake()の中を見てみましょう。

Water2D.cs
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;
}

大事なのは最後の行ですね。水面に使用しているテクスチャの高さをそのテクスチャのPixelsPerUnit(1ユニットあたりのピクセル数)で除算することで、高さが何ユニット分かを算出しています。
次に、テクスチャサイズを計算するメソッドです。

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

ratioがスクリーンの高さに対してテクスチャが占める割合になります。_camera.othrograhicSizeでカメラが映す範囲の縦のサイズが取得できるのですが、このサイズの値は縦全体のユニット数の半分になっているので、2を乗算した値を使っています。
ratioにオブジェクトとしてのスケールを反映させるために、戻り値ではtransform.localScale.yをかけています。


ここまで来たら、SquareにWater2D.csをアタッチしてみます。次のような結果が得られました。

下のように水面のサイズや位置を変えても破綻していません。(※ただし、向きを変えるとuvの位置がずれるので上手く反射されません...)

コースティクス

ここからは、コースティクスの表現方法の紹介です。セルラーノイズという手法を使うと網目状のような模様を作ることができるので、今回はこの手法でコースティクスの色を決定し、さらに色収差をかけることでそれらしい見かけにしていきます。

セルラーノイズを計算

早速ですが、セルラーノイズを計算するための関数を用意します。

Water2D.shader
// 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;
}

このアルゴリズムについては、The Book of Shadersというサイトを参考にさせていただきました。
ものすごく簡単に説明すると、テクスチャをマス目状に分割し、サンプリングする点とその周囲のマスの中のランダムな点との距離を比べ、最も小さい距離の値を出力するといった内容です。
直感的には、どのランダムな点からも遠い点たちが繋がって模様になるといった感じでしょうか。

コースティクスを計算

先程のセルラーノイズを利用して、コースティクスの強さを求める関数も作っておきます。

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

_CausticsScaleはセルラーノイズの計算に使うマス目の数=コースティクスの量を決めるプロパティです。
ここでは、指数関数pow()を使ってコースティクスの強さを調整できるようにしています。noiseは[0,1]の範囲に収まるため、指数部分が大きいほどコースティクスの範囲が小さくなっていきます。今回は、_CausticsIntensityが大きくなるほどコースティクスが強く出てほしかったので、指数部分には逆数を設定しました。

色収差をかけて水面に反映

用意した関数をフラグメントシェーダーで呼び出します。

Water2D.shader
// コースティクスの強さを求める.
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));

色収差をかけるために、RGBの色ごとにuvをずらしてコースティクスの強さを求めています。ここで、_Abberationはuvのずれの大きさを決めています。
最後に、得られた値を使って先ほど計算した水面の色とプロパティに設定したコースティクスの色を補完し、出力しています。


適当にプロパティを設定すれば、下のような結果になります。ひとまず今回はこれで完成にしたいと思います。

最後に

以上、2Dの水面表現➀でした。今度は水面の境界部分に手を加えて波を立たせるようにしたいなと考えています。URPの2Dプロジェクトはあまり見かけない気がするので、この記事が参考になれば幸いです。
ここまで読んでいただきありがとうございました!

リンク

https://assetstore.unity.com/packages/templates/tutorials/2d-game-kit-107098
https://thebookofshaders.com/12/?lan=jp
https://github.com/kr405/UnityWater2DSample

Discussion