UnityでVHS風ポストエフェクトを作成する

#はじめに
**今、レトロ表現がアツい！**
ので、VHS（っぽい）ポストエフェクトを作成しました。
お金に余裕がある人は[こちら](https://assetstore.unity.com/packages/vfx/shaders/fullscreen-camera-effects/vhs-pro-44925)の有料アセットを使った方が早いです。
VHS風動画を撮影したい人は[こちら](https://apps.apple.com/jp/app/vhs-camcorder/id679454835?ign-mpt=uo%3D4)のiOSアプリを使うといいかもです。

#できたもの
![3d0d8e9bebdb11709b74c6bb75ece584.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378758/7d6c5762-3b94-dabb-f2b1-7a4eed405178.gif)

#方針
１．Post Processing Stack v2で色味を調整する
２．shaderでエフェクトをかける
３．GUIで日付等を表示する

以下で順に説明していきます

#１．Post Processing Stack v2で色味を調整する
そもそもPost Processing Stack v2ってなんぞ？って方は[こちら](https://www.gaprot.jp/gaprot-x-chogiken/xr/unity-lookdev-post-processing-stack-v2)を。
画づくりに関しては[こちら](https://ae-style.net/tutorials/e33.html)を参考にしました。
AddEffect→Unity→ColorGradingで色味の調整を行います。
彩度（saturation）を下げて、コントラスト（contrast）を上げて、Gainをいじると、
<img width="164" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378758/eb156f50-c63b-305a-19c6-ae80ad54ba61.png">
これ
<img width="301" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378758/04997aaf-6a88-0f51-5a85-a88d1e3843ee.png">
が
<img width="301" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/378758/2f2a77b4-7e8a-2186-8d94-18b8882c3bbe.png">
こんな感じになります

#２．shaderでエフェクトをかける
とりあえずコード

Shader

```glsl

Shader "Unlit/VHS"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
		_BleedTaps("BleedTaps", Int) = 4
		_BleedDelta("BleedDelta", Float) = 2.0
		_FringeDelta("FringeDelta", Float)= 0
		_Scanline("Scanline", Float) = 0
		_src("src", Float) = 0
		_SamplingDistance("Sampling Distance", float) = 1.0
		_NoiseY("NoiseY", float) = 0.0
    }

    SubShader
    {
		Cull Off ZWrite Off ZTest Always

        Pass
        {
			 Tags
		{
			"RenderType" = "Opaque"
			"Queue" = "Geometry"
		}

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

			
            sampler2D _MainTex;
            float4 _MainTex_ST;
			int _BleedTaps;
			float _BleedDelta;
			float _FringeDelta;
			float _Scanline;
			float _NoiseY;


			static const int samplingCount = 10;
			half _Weights[samplingCount];
			


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

			
			float rand(float2 co) {
				return frac(sin(dot(co.xy, float2(12.9898, 78.233))) * 43758.5453);
			}

			float2 mod(float2 a, float2 b)
			{
				return a - floor(a / b) * b;
			}

			//RGBからYYIQへの変換
			half3 RGB2YIQ(fixed3 rgb)
			{
				rgb = saturate(rgb);
                #ifndef UNITY_COLORSPACE_GAMMA //←が定義されていなければ
				rgb = LinearToGammaSpace(rgb); //これを実行する
                #endif //#ifndefの終端を示す（デフォで1行）
				return mul(half3x3(0.299, 0.587, 0.114,
					0.596, -0.274, -0.322,
					0.211, -0.523, 0.313), rgb);
			}

			//YIQからRGBの変換
			fixed3 YIQ2RGB(half3 yiq)
			{
				half3 rgb = mul(half3x3(1, 0.956, 0.621,
					1, -0.272, -0.647,
					1, -1.106, 1.703), yiq);
				rgb = saturate(rgb);
                #ifndef UNITY_COLORSPACE_GAMMA
				rgb = GammaToLinearSpace(rgb);
                #endif
				return rgb;
			}

			half3 SampleYIQ(float2 uv, float du)
			{
				uv.x += du;
				return RGB2YIQ(tex2D(_MainTex, uv).rgb);
			}

			float4 hash42(float2 p) {
				float4 p4 = frac(float4(p.xyxy) * float4(443.8975, 397.2973, 491.1871, 470.7827));
				p4 += dot(p4.wzxy, p4 + 19.19);
				return frac(float4(p4.x * p4.y, p4.x*p4.z, p4.y*p4.w, p4.x*p4.w));
			}

			float hash(float n) {
				return frac(sin(n)*43758.5453123);
			}

			float n(in float3 x) {
				float3 p = floor(x);
				float3 f = frac(x);
				f = f * f*(3.0 - 2.0*f);
				float n = p.x + p.y*57.0 + 113.0*p.z;
				float res = lerp(lerp(lerp(hash(n + 0.0), hash(n + 1.0), f.x),
					lerp(hash(n + 57.0), hash(n + 58.0), f.x), f.y),
					lerp(lerp(hash(n + 113.0), hash(n + 114.0), f.x),
						lerp(hash(n + 170.0), hash(n + 171.0), f.x), f.y), f.z);
				return res;
			}

			float nn(float2 p, float t) {

				float y = p.y;
				float s = t * 2.;

				float v = (n(float3(y*0.01 + s, 1.0, 1.0)) + 0.0)
					*(n(float3(y*0.011 + 1000.0 + s, 1.0, 1.0)) + 0.0)
					*(n(float3(y*0.51 + 421.0 + s, 1.0, 1.0)) + 0.0)
					;
				//v*= n( vec3( (fragCoord.xy + vec2(s,0.))*100.,1.0) );
				v *= hash42(float2(p.x + t * 0.01, p.y)).x + .3;


				v = pow(v + .3, 1.);
				if (v < .7) v = 0.;  //threshold
				return v;
			}



            fixed4 frag (v2f i) : SV_Target
            {

			float2 uv = i.uv;


			//ブロックノイズ
			if (uv.y == _NoiseY) _NoiseY = 0.5;

			if (uv.y > _NoiseY && uv.y < _NoiseY + 0.03) {
				uv.y = _NoiseY;
			}

			half3 yiq = SampleYIQ(uv, 0);


			// Bleeding
			for (uint i = 0; i < _BleedTaps; i++)
			{
				yiq.y += SampleYIQ(uv, -_BleedDelta * i).y;
				yiq.z += SampleYIQ(uv, +_BleedDelta * i).z;
			}
			yiq.yz /= _BleedTaps + 1;

			// Fringing
			half y1 = SampleYIQ(uv, -_FringeDelta).x;
			half y2 = SampleYIQ(uv, +_FringeDelta).x;
			yiq.yz += y2 - y1;

			
			// Scanline
			half scan = sin(uv.y *  500 * UNITY_PI + _Time.y *3 );
			scan = lerp(1, (scan + 1) / 2, _Scanline);
			
			float3 col = YIQ2RGB(yiq*scan);
			

			//テープノイズ
			float2 hw = _ScreenParams.xy;
			float linesN = 500;
			float one_y = hw.y / linesN;
			uv = floor(((uv+0.5)*0.9)*hw.xy / one_y)*one_y;

			
			float col2 = nn(((uv + 0.5)*0.9), _Time*10);
			if (col2 > 0.5) {
				col = float3(col2, col2, col2);
			}

			return fixed4(col,1);
            }
            ENDCG
        }
    }
}
```

C#

```C#
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PostEffet : MonoBehaviour
{
    public Material VHS;
    [SerializeField, Range(0, 1)] float _bleeding = 0.8f;
    [SerializeField, Range(0, 1)] float _fringing = 1.0f;
    [SerializeField, Range(0, 1)] float _scanline = 0.125f;
	public RenderTexture tex;
	private float t;

    private void Start()
    {

    }


    private void Update()
    {
		t += Time.deltaTime;
		if (t >= 1.0f)
		{
			if (t > Random.Range(3.0f, 8.0f)) t = 0.0f;
		}
    }

    void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
		if(tex!=null) src = tex;
        VHS.SetFloat("_src", 0.5f);
        var bleedWidth = 0.04f * _bleeding;  // width of bleeding
        var bleedStep = 2.5f / src.width; // max interval of taps
        var bleedTaps = Mathf.CeilToInt(bleedWidth / bleedStep);
        var bleedDelta = bleedWidth / bleedTaps;
        var fringeWidth = 0.0025f * _fringing; // width of fringing

        VHS.SetInt("_Width", src.width);
        VHS.SetInt("_Height", src.height);
        VHS.SetInt("_BleedTaps", bleedTaps);
        VHS.SetFloat("_BleedDelta", bleedDelta);
        VHS.SetFloat("_FringeDelta", fringeWidth);
        VHS.SetFloat("_Scanline", _scanline);
        VHS.SetFloat("_NoiseY", 1.0f-t);


        Graphics.Blit(src, dest, VHS);
    }
}

```

以下、frag shaderの説明

```glsl
//ブロックノイズ
if (uv.y == _NoiseY) _NoiseY = 0.5;

if (uv.y > _NoiseY && uv.y < _NoiseY + 0.03) {
	uv.y = _NoiseY;
}
```
ランダム秒おきに上から下に流れるブロックノイズです

```glsl
half3 yiq = SampleYIQ(uv, 0);


// Bleeding
for (uint i = 0; i < _BleedTaps; i++)
{
	yiq.y += SampleYIQ(uv, -_BleedDelta * i).y;
	yiq.z += SampleYIQ(uv, +_BleedDelta * i).z;
}
	yiq.yz /= _BleedTaps + 1;

// Fringing
half y1 = SampleYIQ(uv, -_FringeDelta).x;
half y2 = SampleYIQ(uv, +_FringeDelta).x;
yiq.yz += y2 - y1;

			
// Scanline
half scan = sin(uv.y *  500 * UNITY_PI + _Time.y *3 );
scan = lerp(1, (scan + 1) / 2, _Scanline);
			
float3 col = YIQ2RGB(yiq*scan);
```



ビデオ伝送方式では、色情報をRGBではなくYIQという色と輝度信号を分離した形式で扱っています。これは、人の色変化に敏感な情報を優先したり、画質向上を目指したりが理由なのですが、細かい部分はカットします。RGB→YIQの変換式等説明は[こちら](https://www-ui.is.s.u-tokyo.ac.jp/~o/Computer/Note/010106-ColorRepresentation/)を参照。
また、テレビは走査線を元に映像を表示しています。
>テレビ画面やディスプレイにおいて、画像を表示するために光を発する水平方向の線のこと。画面上で瞬間的に光っているのは1つの点であり、走査線はその光が左から右へ高速で移動するためのレールのようなもの。さらに、光は上の走査線から下の走査線へ移動していき、目と画面の残像効果によって面に見えるというしくみ。

[コトバンク](https://kotobank.jp/word/%E8%B5%B0%E6%9F%BB%E7%B7%9A-5320)より引用

そのため、情報のズレは基本的に水平方向にしか発生しません。

このあたりのコードは[こちら](https://github.com/keijiro/KinoTube)を参考にしました。
Bleedingは色染みで、輝度信号を残して色情報がずれた時に起こります。
Fringingはオブジェクトの周りに発生する輪郭線のようなものだそうです。
Scanlineはディスプレイに移る走査線です。


```glsl

//テープノイズ
float2 hw = _ScreenParams.xy;
float linesN = 500;
float one_y = hw.y / linesN;
uv = floor(((uv+0.5)*0.9)*hw.xy / one_y)*one_y;
			
float col2 = nn(((uv + 0.5)*0.9), _Time*10);
if (col2 > 0.5) {
    col = float3(col2, col2, col2);
			}
```

このあたりのコードは[こちら](https://www.shadertoy.com/view/MlfSWr)を参考にしました。
テープノイズは、上から下に流れるチリチリしたノイズです。


#３．GUIで日付等を表示する
お好みで、日付なんかを加えるとノスタルジック感が増すような気がします。
UIにもエフェクトをかけたいので、ScreenSpaceからWorldSpaceに変更しています。

Textに当てるC#スクリプト例

```C#
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System;

public class Text2 : MonoBehaviour
{
    public Text Qtext;
    float a_color;
    // Use this for initialization
    void Start()
    {
        //Qtext = GetComponentInChildren<Text>();
        
        a_color = 0.8f;
    }

    // Update is called once per frame
    void Update()
    {
        DateTime dt = DateTime.Now;
        string AMPM = "a";
        string Mon = "a";

        string TimeString = dt.ToString();
        if (dt.ToString("tt") == "午前")
        {
            AMPM = "AM";
        }
        else
        {
            AMPM = "PM";
        }

        switch (Convert.ToString(dt.Month))
        {
            case "1":
                Mon = "Jan";
                break;
            case "2":
                Mon = "Feb";
                break;
            case "3":
                Mon = "Mar";
                break;
            case "4":
                Mon = "Apr";
                break;
            case "5":
                Mon = "May";
                break;
            case "6":
                Mon = "Jun";
                break;
            case "7":
                Mon = "Jul";
                break;
            case "8":
                Mon = "Aug";
                break;
            case "9":
                Mon = "Sep";
                break;
            case "10":
                Mon = "Oct";
                break;
            case "11":
                Mon = "Nob";
                break;
            case "12":
                Mon = "Dec";
                break;
        }

        string AmPmString = AMPM + "  " + dt.ToString("hh:mm") + Environment.NewLine + Mon+"." + dt.Day +" "+ dt.Year ; //12時間表示のstring型へ変換

        Qtext.text = AmPmString;

        //テキストの透明度を変更する
        Qtext.color = new Color(1, 1, 1, a_color);
    }
}
```

フォントは[こちら](https://www.dafont.com/vcr-osd-mono.font)を使わせていただきました。
テキストサイズを小さくして、widthとheightを大きくすると、テキストの画質が荒くなって良い感じになります。

#おわりに
リファレンスが少ない＋表現が個人でまちまちだったので、かなり苦戦しました。

#参考
* https://ae-style.net/tutorials/e33.html
* http://nn-hokuson.hatenablog.com/entry/2017/06/10/063322
* https://qiita.com/3yen/items/ca906b6dec38776d7293
* https://www-ui.is.s.u-tokyo.ac.jp/~o/Computer/Note/010106-ColorRepresentation/
* https://github.com/keijiro/KinoTube
* https://www.shadertoy.com/view/MlfSWr