Help us understand the problem. What is going on with this article?

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

はじめに

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

できたもの

c92c704e3c470cd8982863530abfe88e.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" {}
        _NoiseTex("Texture", 2D) = "white" {}
        _Saturation("Saturation", Float) = 2
        _BleedTaps("BleedTaps", Int) = 4
        _BleedDelta("BleedDelta", Float) = 2.0
        _FringeDelta("FringeDelta", Float)= 0
        _Scanline("Scanline", Float) = 0
        _src("src", Float) = 0
        _RGBNoise("RGBNoise", Range(0, 1)) = 0.5
        _SamplingDistance("Sampling Distance", float) = 1.0
        _Amount("Distort", Float) = 0.0009
        _Amount2("Distort2", Float) = 0.0
        _Size("Size", int) = 10
    }

    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;
            sampler2D _NoiseTex;
            float4 _NoiseTex_ST;
            float _Saturation;
            int _BleedTaps;
            float _BleedDelta;
            float _FringeDelta;
            float _Scanline;
            float _RGBNoise;
            float  _Amount;
            float  _Amount2;
            int _Size;


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

            float2 uv = i.uv;
            //うねうねsin波
            //ここの数をいじって波長を変える
            float x = 500 * (uv.y + _Time*0.8);
            uv.x += _Amount * sin(x);

            //パルスノイズ
            float x2 = 2 * uv.y;
            uv.x += _Amount2 * sin(_Size * x2)*(-(x2 - 1)*(x2 - 1) + 1);


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

            //画面端を暗くする
            //float vignet = length(uv-0.5);
            //col*= 1 - vignet * 1.1;


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


    float NoiseInterval = Random.Range(5.0f, 10.0f);
    float IntervalTime;

    private void Start()
    {
        //インターバル設定
        NoiseInterval = Random.Range(1.0f, 3.0f);
    }

    IEnumerator GeneratePulseNoise()
    {
        //良い感じのランダム値設定
        //Random.Range(a, b)はa以上b未満
        int size = Random.Range(0, 30);
        int j = Random.Range(1, 3) * 180;
        int k = Random.Range(6, 11) * 10;

        //ノイズ発生後、テクスチャを元に戻す為sinが0になる値を代入
        for (int i = Random.Range(-360, 1); i <= j; i += k)
        {
            if (i + k > j)
            {
                i = j;
            }
           VHS.SetFloat("_Amount2", 0.8f * Mathf.Sin(i * Mathf.Deg2Rad));
           VHS.SetFloat("_Size", size);
            yield return null;
        }
    }

    private void Update()
    {
        //ノイズをランダム秒後に発生させる
        IntervalTime += Time.deltaTime;
    }

    void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        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);

        if (IntervalTime >= NoiseInterval)
        {
            StartCoroutine(GeneratePulseNoise());
            IntervalTime = 0;
            NoiseInterval = Random.Range(1.0f, 3.0f);
        }

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

以下、frag shaderの説明

//ここの数をいじって波長を変える
float x = 500 * (uv.y + _Time*0.8);
uv.x += _Amount * sin(x);

//パルスノイズ
float x2 = 2 * uv.y;
uv.x += _Amount2 * sin(_Size * x2)*(-(x2 - 1)*(x2 - 1) + 1);

単純な数式で表されるノイズです。
恒常的に流れて、オブジェクトの輪郭を歪めるsin波と、ランダム秒おきに流れるパルスノイズです。こちらこちらを参考にしました。

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

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

shaderを適用すると、こんな感じになります
d6361c0833da301fb151f0400aa6108a.gif

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

お好みで、「PLAY」や日付を加えるとノスタルジック感が増すような気がします。

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を大きくすると、テキストの画質が荒くなって良い感じになります。

おわりに

リファレンスが少ない+表現が個人でまちまちだったので、かなり苦戦しました。
ブロックノイズを加えたかったのですが、作り方が分からず断念しました。

参考

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away