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

はじめに

今、レトロ表現がアツい!
ので、VHS(っぽい)ポストエフェクトを作成しました。
お金に余裕がある人はこちらの有料アセットを使った方が早いです。
VHS風動画を撮影したい人はこちらのiOSアプリを使うといいかもです。

できたもの

3d0d8e9bebdb11709b74c6bb75ece584.gif

方針

1.Post Processing Stack v2で色味を調整する
2.shaderでエフェクトをかける
3.GUIで日付等を表示する

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

1.Post Processing Stack v2で色味を調整する

そもそもPost Processing Stack v2ってなんぞ?って方はこちらを。
画づくりに関してはこちらを参考にしました。
AddEffect→Unity→ColorGradingで色味の調整を行います。
彩度(saturation)を下げて、コントラスト(contrast)を上げて、Gainをいじると、
キャプチャ.PNG
これ
キャプチャ.PNG

キャプチャ.PNG
こんな感じになります

2.shaderでエフェクトをかける

とりあえずコード

Shader

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#

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の説明

//ブロックノイズ
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);

ビデオ伝送方式では、色情報をRGBではなくYIQという色と輝度信号を分離した形式で扱っています。これは、人の色変化に敏感な情報を優先したり、画質向上を目指したりが理由なのですが、細かい部分はカットします。RGB→YIQの変換式等説明はこちらを参照。
また、テレビは走査線を元に映像を表示しています。

テレビ画面やディスプレイにおいて、画像を表示するために光を発する水平方向の線のこと。画面上で瞬間的に光っているのは1つの点であり、走査線はその光が左から右へ高速で移動するためのレールのようなもの。さらに、光は上の走査線から下の走査線へ移動していき、目と画面の残像効果によって面に見えるというしくみ。

コトバンクより引用

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

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

//テープノイズ
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);
            }

このあたりのコードはこちらを参考にしました。
テープノイズは、上から下に流れるチリチリしたノイズです。

3.GUIで日付等を表示する

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

Textに当てる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);
    }
}

フォントはこちらを使わせていただきました。
テキストサイズを小さくして、widthとheightを大きくすると、テキストの画質が荒くなって良い感じになります。

おわりに

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

参考

3yen
初心者です
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
サービス利用規約に基づき、このコメントは削除されました。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした