[Unity]3Dモデルを凍らせる表現

状態異常「凍結」みたいなものが必要になったので、実装してみました。

https://twitter.com/flankids/status/1699785144071246069

ぶっちゃけ、表現としては大きめの半透明の氷のオブジェクトの中に入れてしまえば簡単だと思います。

しかしこの表現は凍らせたいオブジェクトの大きさや形状が様々な場合、無理な引き伸ばしが必要だったり、体の一部がはみ出たりすることがあります。
また、凍らせる対象が画面にたくさんいる時は表示するオブジェクトを増やすコストが気になりますし、むやみに半透明オブジェクトを表示させるのも考えものです。

そういう懸念への対策として作ってみたシェーダーでした。

### シェーダー全文
<details><summary><b>----------------------------------------<br> Freeze.shader（折りたたみ）<br> ------------------------------------------</b></summary><div>

```hlsl:Freeze.shader
Shader "Custom/Freeze" {
    Properties {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadeColor ("Shade Color", Color) = (0.5,0.5,0.5)
        _SpecularPower ("Specular Power", Range(0, 30)) = 1
        _SpecularColor ("Specular Color", Color) = (0.5,0.5,0.5)
        _FreezeRate ("Freeze Rate", Range(0.0, 1.0)) = 0
        _FreezeRateMax ("Freeze Rate Max", Range(0.0, 1.0)) = 0.7
        _FreezeBorder ("Freeze Border", Range(0.0, 1.0)) = 0.05
        _FreezeBorderColor ("Freeze Border Color", Color) = (0.5,0.5,0.5)
        _IceTex ("Ice Texture", 2D) = "black" {}
        _DisolveTex ("DisolveTex", 2D) = "white" {}
        [Normal] _IceNormalMap ("Ice Normal Map", 2D) = "bump" {}
        _IceNormalMapScale ("Ice Normal Map Scale", Range(0, 3)) = 0.5
        _IceSpecularPower ("Ice Specular Power", Range(0, 30)) = 1
        _IceSpecularColor ("Ice Specular Color", Color) = (0.5,0.5,0.5)
        _RimLightBorder ("RimLight Border", Range(0.0, 1.0)) = 0.5
        _RimLightColor ("Rim Light Color", Color) = (0.5,0.5,0.5)
    }
    SubShader {
        Tags { "RenderType"="Opaque" }

        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float3 normal : NORMAL;
                float4 tangent : TANGENT;
            };

            struct v2f {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float3 normal : TEXCOORD1;
                float3 lightDir : TEXCOORD2;
                float3 viewDir : TEXCOORD3;
                float2 uvIce : TEXCOORD4;
                half4 tangent : TEXCOORD5;
                half3 binormal : TEXCOORD6;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            float3 _ShadeColor;
            float _SpecularPower;
            float3 _SpecularColor;
            float _FreezeRate;
            float _FreezeRateMax;
            float _FreezeBorder;
            float3 _FreezeBorderColor;
            sampler2D _IceTex;
            float4 _IceTex_ST;
            sampler2D _DisolveTex;
            sampler2D _IceNormalMap;
            float _IceNormalMapScale;
            float _IceSpecularPower;
            float3 _IceSpecularColor;
            float _RimLightBorder;
            float3 _RimLightColor;

            v2f vert (appdata v) {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);

                o.uvIce = TRANSFORM_TEX(v.uv, _IceTex);

                o.normal = UnityObjectToWorldNormal(v.normal);
                o.tangent = mul(unity_ObjectToWorld, v.tangent.xyz);
                // binormalはtangentのwとunity_WorldTransformParams.wを掛ける（Unityの決まり）
                o.binormal = normalize(cross(v.normal.xyz, v.tangent.xyz) * v.tangent.w * unity_WorldTransformParams.w);
                o.binormal = mul(unity_ObjectToWorld, o.binormal);

                o.lightDir = normalize(WorldSpaceLightDir(v.vertex));
                o.viewDir = normalize(WorldSpaceViewDir(v.vertex));

                return o;
            }

            fixed4 frag (v2f i) : SV_Target {
                fixed4 col = tex2D(_MainTex, i.uv);

                half3 halfDir = normalize(i.lightDir + i.viewDir);

                // 拡散反射
                float diffuse = max(dot(i.normal, i.lightDir), 0);
                float3 shade = lerp(_ShadeColor, 1, diffuse);
                col.rgb *= shade;

                // 鏡面反射
                float specularRate = pow(max(0, dot(i.normal, halfDir)), _SpecularPower);
                float3 specular = specularRate * _SpecularColor;
                col.rgb += specular;

                fixed4 disolve = tex2D(_DisolveTex, i.uvIce);
                float freezeRate = lerp(0, _FreezeRateMax, step(disolve.r, _FreezeRate));

                float freezeBorderRate = 1 - smoothstep(0, _FreezeBorder, disolve.r - _FreezeRate);
                freezeBorderRate *= step(freezeRate, 0);
                col.rgb += _FreezeBorderColor * freezeBorderRate;

                // 氷のテクスチャをブレンド
                col = lerp(col, tex2D(_IceTex, i.uvIce), freezeRate); 

                // 氷のノーマルマップを反映した法線を計算
                half3 localNormal = UnpackNormalWithScale(tex2D(_IceNormalMap, i.uvIce), _IceNormalMapScale);
                // 接空間のベクトルをワールド空間に変換
                float3 normal = i.tangent * localNormal.x + i.binormal * localNormal.y + i.normal * localNormal.z;
                i.normal = lerp(i.normal, normalize(normal), freezeRate);

                // 氷の鏡面反射
                specularRate = pow(max(0, dot(i.normal, halfDir)), _IceSpecularPower);
                specular = specularRate * _IceSpecularColor;
                col.rgb += lerp(0, specular, freezeRate);

                // リムライト
                float rimLightRate = 1 - smoothstep(0, _RimLightBorder, dot(i.normal, i.viewDir));
                rimLightRate = lerp(0, rimLightRate, freezeRate);
                col.rgb += lerp(0, _RimLightColor, rimLightRate);

                // 氷の拡散反射
                float iceShade = lerp(shade, 1, 0.5);
                col.rgb *= lerp(1, iceShade, freezeRate);

                return col;
            }
            ENDCG
        }
    }
    FallBack "Diffuse"
}
```
</div></details>

# 表現を分解

一つ一つはよくあるシェーダー表現を使っています。

|表示|概要|
|---|---|
|![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/52b40a47-1275-403e-ccf9-0e9eacee5639.png)|**初期状態**<br>凍る前の表示。今回はシンプルに拡散反射と鏡面反射のみ|
|![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/46cea924-4609-64cb-5e30-206e21f3f9b8.png)|**氷のテクスチャをブレンド**<br>氷の色や模様のテクスチャをブレンド。まだ全然氷っぽくない|
|![03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/ce27a1bf-94c0-5736-6544-1932ee109e40.png)|**鏡面反射**<br>通常時と別に鏡面反射の強度と色を設定。ちょっと氷っぽく見えてきた|
|![04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/fd125c8e-a85b-45bc-09d1-c858603430aa.png)|**氷のノーマルマップを反映**<br>ゴツゴツした質感をノーマルマップで表現。かなり氷に見えてきた|
|![05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/9ed836c8-c6ea-87cd-8919-e0edd6e7857e.png)|**リムライト**<br>氷に見える！ひとまず完成|
|![06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/cff60f47-bf0b-71fc-5214-85b1cf3f7254.png)|**ディゾルブ**<br>凍る前後の遷移演出用の表現|
|![07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/4196900f-a577-030f-8166-c5468b747874.png)|**ディゾルブの境界**<br>境界に加算カラーを滲ませて凍結が進んでいる感じをより自然に見せる|

## 氷のテクスチャをブレンド

![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/46cea924-4609-64cb-5e30-206e21f3f9b8.png)

```hlsl:Freeze.shader
// 氷のテクスチャをブレンド
col = lerp(col, tex2D(_IceTex, i.uvIce), freezeRate); 
```

メインテクスチャに対して氷の色や模様を持った氷テクスチャをブレンドしています。
`freezeRate`は凍り具合を表す変数。

今回使ったテクスチャは下記のものです。

<img width="256" alt="tex_ice.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/41f069e4-0cb3-eb4c-5ee3-5598880ab88f.jpeg">

この色味や模様のディティールの多さの適切なバランスを取るのが意外と難しかったです。
青すぎても白すぎても氷っぽく見えないし、ディティールも少なすぎるとただ青白いだけ、多すぎると野暮ったい。

ちなみに、スクショのものではブレンド率（`freezeRate`）の上限を`0.7`としていて、元の色がちょっと透けてる感じにしました。
モデルのディティールがテクスチャではなくポリゴンでそれなりに表現されていれば、完全に氷のテクスチャに置き換えてみてもいいですね。

## 鏡面反射

![03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/ce27a1bf-94c0-5736-6544-1932ee109e40.png)

```hlsl:Freeze.shader
// 氷の鏡面反射
specularRate = pow(max(0, dot(i.normal, halfDir)), _IceSpecularPower);
specular = specularRate * _IceSpecularColor;
col.rgb += lerp(0, specular, freezeRate);
```

凍る前の表示とは別に鏡面反射の強度と色を用意して、加算しています。
強度強め、色は白めだといい感じです。

## 氷のノーマルマップを反映
![04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/fd125c8e-a85b-45bc-09d1-c858603430aa.png)

```hlsl:Freeze.shader
// 氷のノーマルマップを反映した法線を計算
half3 localNormal = UnpackNormalWithScale(tex2D(_IceNormalMap, i.uvIce), _IceNormalMapScale);
// 接空間のベクトルをワールド空間に変換
float3 normal = i.tangent * localNormal.x + i.binormal * localNormal.y + i.normal * localNormal.z;
i.normal = lerp(i.normal, normalize(normal), freezeRate);
```

氷の鏡面反射計算の前に氷の質感としてノーマルマップを反映する処理を追加しています。
鏡面反射の反映角度が程よく分散して氷っぽさがここでグッと上がりました。

今回使ったノーマルマップは下記のものです。

<img width="256" alt="tex_ice_normal.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/cacfbbee-ffbe-c331-b42a-00b86ac2b0a3.png">

## リムライト
![05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/9ed836c8-c6ea-87cd-8919-e0edd6e7857e.png)

```hlsl:Freeze.shader
// リムライト
float rimLightRate = 1 - smoothstep(0, _RimLightBorder, dot(i.normal, i.viewDir));
rimLightRate = lerp(0, rimLightRate, freezeRate);
col.rgb += lerp(0, _RimLightColor, rimLightRate);
```
`_RimLightBorder`を`smoothstep`に使ってリムライトの影響幅を調整しています。
フチが明るくなることで氷の厚みが感じられるようになりました。
氷の表現としてはこれで完成です。

## ディゾルブ

![06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/cff60f47-bf0b-71fc-5214-85b1cf3f7254.png)

```hlsl:Freeze.shader
fixed4 disolve = tex2D(_DisolveTex, i.uvIce);
float freezeRate = lerp(0, _FreezeRateMax, step(disolve.r, _FreezeRate));
```

この処理は **氷のテクスチャのブレンド** の工程の前に追加しているものです。
冒頭の動画のように徐々に凍る演出を作ったり、逆に解凍される演出を作ったりすることを想定して、凍り具合`_FreezeRate`の反映範囲をディゾルブでまばらにしています。

今回使ったノイズテクスチャは下記のものです。

<img width="256" alt="tex_noise.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/faf52850-5e8a-7719-883e-c4635217075e.jpeg">

## ディゾルブの境界

![07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/184294/4196900f-a577-030f-8166-c5468b747874.png)

```hlsl:Freeze.shader
float freezeBorderRate = 1 - smoothstep(0, _FreezeBorder, disolve.r - _FreezeRate);
freezeBorderRate *= step(freezeRate, 0);
col.rgb += _FreezeBorderColor * freezeBorderRate;
```

凍っていない箇所からディゾルブの境界に近づくほど、反映度が強くなる加算カラーを当てています。
`_FreezeBorder`を`smoothstep`に使って、加算カラーの影響範囲を調整。
`step`関数は既に凍っている箇所に加算カラーが反映されないよう切り分けるための計算です。

これで自然と凍結の影響が進行して見えるようになりました。

# 所感

https://www.youtube.com/watch?v=U74P9WxCHfg&t=43s

今回のネタはAssetStoreで見かけた上記の動画43秒あたりの「Ice Soldier」を3D落とし込んだものでした。
まだまだシェーダーを使った表現の引き出しが少ないので、ゲームに汎用的に使える演出の引き出しを増やすために、こういう感じで模写するのは地力が付きそうだなと思いました。

次は燃える表現をやってみたいです。
頂点シェーダーを使った表現の引き出しも増やしたいけど何か良いネタないかな・・・。