[Unity]カジュアルゲームに使いやすい「これでいい」水面シェーダー

**[グレンジ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/grenge)** 6日目担当の、flankidsです。
普段は操作、アニメーション、カメラワークなどなどで遊び心地を作ることを主にやっています。楽しいものを作るのが好きです！

今回はシンプルで使いやすい水面の表現ついて書きました。
**もしこの記事がお役に立てたら「いいね」を押してもらえると、とても励みになります！**

# なんでもいいから水を張りたい

ゲームのモックアップやカジュアルゲームを作る中で「優先度的にそこまでこだわるつもりはないけど、最低限は水面っぽく見せたい」と思うことがたびたびありました。

そういうモチベーションなので、あんまり表現に時間をかけたくないけど‥‥

![スクリーンショット 2024-12-05 08.24.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/01de378e-a7d8-b1e4-ed67-5a9290b009f3.png)


さすがにこれ↑を水面と呼ぶのはちょっと無理があるよなぁ‥‥
ということで、そんな場面で使いやすい水面シェーダーを作ってみました。

# 「これでいい」水面シェーダー

使いやすさと無難さを重視して、こんな感じの見た目になりました。

https://x.com/flankids/status/1864450170068586988

シェーダーは以下のとおりです。

<details><summary><b>----------------------------------------<br> SmoothOutline.shader（折りたたみ）<br> ------------------------------------------</b></summary><div>

```hlsl:WaterSurface.shader
Shader "Custom/WaterSurface" {
    Properties {
        _BaseColor ("Base Color", Color) = (0,0.6,1)
        _MainTex ("Texture", 2D) = "black" {}
        _TilingScale ("Tiling Scale", float) = 0.07

        [Header(Main Wave)]
        _WaveTiling ("Tiling", float) = 1
        _WaveColor ("Color", Color) = (1,1,1,0.7)
        _WaveSpeed ("Scroll Speed", float) = 1
        _WaveAngle ("Scroll Angle", Range(0, 360)) = 0
        
        [Header(Sub Wave)]
        _WaveTilingSub ("Tiling Scale", float) = 1.5
        _WaveColorSub ("Color", Color) = (1,1,1,0.5)
        _WaveSpeedSub ("Scroll Speed", float) = 0.7
        _WaveAngleSub ("Scroll Angle", Range(0, 360)) = 120

        _EdgeColor("EdgeColor", Color) = (1, 1, 1, 1)
        _DepthFactor("Depth Factor", Range(0.01, 10)) = 1
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            struct appdata {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
                float4 screenPos : TEXCOORD2;
            };

            float4 _BaseColor;
            sampler2D _MainTex;
            float4 _MainTex_ST;
            float _TilingScale;

            float _WaveTiling;
            float4 _WaveColor;
            float _WaveSpeed;
            float _WaveAngle;

            float _WaveTilingSub;
            float4 _WaveColorSub;
            float _WaveSpeedSub;
            float _WaveAngleSub;

            uniform sampler2D _CameraDepthTexture;
            fixed4 _EdgeColor;
            float _DepthFactor;

            float2 GetWorldUV(float4 vertexWS, float4 tex_ST) {
                float2 tiling = tex_ST.xy;
                float2 offset = tex_ST.zw;
                return vertexWS.xz * tiling + offset;
            }

            v2f vert (appdata v) {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                float4 vertexWS = mul(unity_ObjectToWorld, v.vertex);
                // ワールド座標からUV座標を計算
                o.uv = GetWorldUV(vertexWS, _MainTex_ST);
                UNITY_TRANSFER_FOG(o,o.vertex);
                // スクリーン座標を計算
                o.screenPos = ComputeScreenPos(o.vertex);
                return o;
            }

            float2 CalcVector(float angle, float length) {
                float rad = radians(angle);
                return float2(cos(rad), sin(rad)) * length;
            }

            fixed4 CalcWaveColor(fixed4 base, float2 uv, float tiling, float4 color, float speed, float angle) {
                // UVスクロールのオフセットベクトルを計算
                float2 scroll = CalcVector(angle, _Time.x * speed);
                tiling *= _TilingScale;
                fixed4 tex = tex2D(_MainTex, uv * tiling + scroll);
                color = lerp(_BaseColor, color, color.a);
                return lerp(base, color, tex.a);
            }

            fixed4 frag (v2f i) : SV_Target {
                fixed4 col = _BaseColor;

                // 波テクスチャの適用とスクロール
                col = CalcWaveColor(col, i.uv, _WaveTilingSub, _WaveColorSub, _WaveSpeedSub, _WaveAngleSub);
                col = CalcWaveColor(col, i.uv, _WaveTiling, _WaveColor, _WaveSpeed, _WaveAngle);

                //深度の計算
                float4 depthSample = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos));
                half depth = LinearEyeDepth(depthSample);
                half screenDepth = depth - i.screenPos.w;
                float edgeLine = 1 - saturate(_DepthFactor * screenDepth);
                col = lerp(col, _EdgeColor, edgeLine * _EdgeColor.a);

                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}
```
</div></details>

テクスチャは以下のようなグレースケール画像を使っていて、白に近いところほど色が強く反映されるようにしています。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/e252c4f6-f0c9-4346-3499-1761b60a070d.png">

この画像を使う場合は下記設定をお忘れなく！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/c1e5911a-ed45-19f4-6509-b6a7c083cc25.png)

## 表現の構成
### UVスクロールで水面を表現

水の動きは王道のUVスクロールです。

![bottle.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/144a3e84-6492-3934-8a4d-54386993ef2e.gif)

ただし一枚のテクスチャだとちょっとチャチな印象なので、同じテクスチャのスケールや色、スクロール方向を変えて重ねることで、タイリングと単一方向のスクロールであることがバレにくくなるようにしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/9d4d020c-8064-0e01-0046-9e3668842253.png)


### 深度情報を使って接触面を表現

水面を半透明にすると、水面下にもオブジェクトを配置しないといけなくなってコストが増えるので、着水しているオブジェクトの接触面（エッジライン）を着色しています。

▼ エッジラインに何も施さないとめり込んでるっぽい見栄えになりますが‥‥

![スクリーンショット 2024-12-05 08.38.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/47fc0b36-a250-1baa-519b-4a68c33a1dd7.png)


▼ 深度情報を使って水面に近いところを着色することで、着水感が出ます

![スクリーンショット 2024-12-05 08.38.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/da6f3e53-3f28-591f-9561-59afedc2952f.png)


### 拡縮や回転に対応。複数オブジェクトを並べてもOK

水面に限らず、背景オブジェクトはいろんな形状・スケール・角度で配置することになります。
特にオブジェクトのスケールを変えるたびにマテリアルのテクスチャタイリングを調整しないと不自然な見た目になってしまう、というのはありがちな手間かなと思います。

▼ Z軸の広さが足りないので引き伸ばしたらテクスチャが明らかに潰れてしまったケース

![スクリーンショット 2024-12-05 08.42.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/2cf23d7f-f4c9-8f11-3d7b-334cd93d3bc3.png)


▼ 複数のオブジェクトで川を作ったら境界が目立ってしまったケース

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/8d67f501-7163-c522-3c83-be4ca36b7f74.png">


頂点のワールド座標を使ってテクスチャを適用することで、雑にオブジェクト配置しても単一マテリアルで破綻なく表示されるようにしました。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/1856157d-7e3a-f960-7fba-b9d22385b3f9.png">

# シェーダーの説明

## 水面テクスチャのUV

```hlsl:WaterSurface.shader
float4 vertexWS = mul(unity_ObjectToWorld, v.vertex);
// ワールド座標からUV座標を計算
o.uv = GetWorldUV(vertexWS, _MainTex_ST);
```

モデルの持つUVではなく、頂点のワールド空間座標からUV情報を作っています。
これにより、オブジェクトのスケールや角度の影響を受けずにテクスチャが反映されるようにしています。

GetWorldUV関数は`TRANSFORM_TEX`と同じような役割で、テクスチャのタイリング・オフセットの値が反映されるようにしています。

```hlsl:WaterSurface.shader
float2 GetWorldUV(float4 vertexWS, float4 tex_ST) {
    float2 tiling = tex_ST.xy;
    float2 offset = tex_ST.zw;
    return vertexWS.xz * tiling + offset;
}
```


## UVのクロススクロール

```hlsl:WaterSurface.shader
fixed4 CalcWaveColor(fixed4 base, float2 uv, float tiling, float4 color, float speed, float angle) {
    // UVスクロールのオフセットベクトルを計算
    float2 scroll = CalcVector(angle, _Time.x * speed);
    tiling *= _TilingScale;
    fixed4 tex = tex2D(_MainTex, uv * tiling + scroll);
    color = lerp(_BaseColor, color, color.a);
    return lerp(base, color, tex.a);
}
```

- タイリングスケール
- 色
- スクロール速度
- スクロール角度

を引数に入れて、テクスチャを重ねるCalcWaveColor関数を作っています。

フラグメントシェーダー部分で以下のように複数回呼び出すことで、タイリングや色、スクロールが異なる複数の波模様テクスチャが重なるようになっています。
後から呼ぶほど表示が上になります。

```hlsl:WaterSurface.shader
// 波テクスチャの適用とスクロール
col = CalcWaveColor(col, i.uv, _WaveTilingSub, _WaveColorSub, _WaveSpeedSub, _WaveAngleSub);
col = CalcWaveColor(col, i.uv, _WaveTiling, _WaveColor, _WaveSpeed, _WaveAngle);
```

## 深度差を使って接触面（エッジライン）を表現

```hlsl:WaterSurface.shader
//深度の計算
float4 depthSample = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.screenPos));
half depth = LinearEyeDepth(depthSample);
half screenDepth = depth - i.screenPos.w;
float edgeLine = 1 - saturate(_DepthFactor * screenDepth);
col = lerp(col, _EdgeColor, edgeLine * _EdgeColor.a);
```

対象ピクセルの深度値と水面の深度値の差を計算して、差が近いところを水面と他のオブジェクトが接している部分として、`_EdgeColor`を反映しています。

# 所感

以前リッチな水面表現にも挑戦していましたが、表現の変数が増えるほど使う際のコストが増えるなーと思いました。
（処理負荷的な意味ではなく、設定項目やシーン配置で考慮することの多さなど、取り回しの良さの意味で）

モックアップや小規模の開発では力の入れどころの取捨選択が大事なので、最小要素でサッと使える表現の引き出しを増やしていきたいです。

最小単位に切り出すことで結果的に身につく情報も多いので、グラフィカルな部分に限らずいろいろ手を伸ばしていこうと思いました。

https://x.com/flankids/status/1466585390589308932