CG飯処

腹が減ってはCGができぬ

フィルムノイズを Unity のポストエフェクトで実装する


フィルムノイズを Unity のポストエフェクトを使って実装することで、古い映画のような表現を目指します。

今回のゴール

今回は以下のようなポストエフェクトを実装していきます。
(以下の画像はフィルムノイズの他に Color Grading も使用して雰囲気を出していますが、今回説明するのはフィルムノイズのみです)

環境

  • Unity 2021.2.9f1
  • Built-in Render Pipeline

(※ このポストエフェクトを使用するためには PostProcessing パッケージのプロジェクトへの導入や、Post-process Volume の作成などをする必要がありますが、今回ははその導入の解説は行いません)

実装詳細

フィルムノイズには、大きく分けて2つ実装するものがあります。
まず一つ目がスクラッチ(黒い縦線)で、二つ目が(少しわかりにくいですが)フィルム汚れ(小さな黒い点)です。

この記事ではこの2つを順を追って実装していこうと思います。

クラッチの実装

まずはスクラッチ(縦線部分)の実装です。
ポストエフェクトで実装するため、本来はコードで実装する必要がありますが、説明のため ShaderGraph で実装したものを用意しました。

縦線を作る


まず、縦線を作っていきます。
uv の u 方向のみに値を乗算することで、横に圧縮されたような座標系をつくることができます。これを Sine ノードに通すことで白黒の縦線が表示されます。


また、縦線に動きを出したいので、Timeノードを加算します。速度を変えられるよう、Speed プロパティを作っておきます。

ノイズと加算する


次に、縦線の動きにランダム感を出すために、ノイズを用意します。
GradientNoise ノードに uv の u 値を渡すことで縦方向のノイズにすることができます。
また、Scale に大きめな値を渡すことでノイズを細かくすることができます。
(今回は GradientNoise を使いましたが、他の SimpleNoise などでも代用可能です)


このノイズと先ほどの Sine ノードの結果を加算することで、各縦線の色の濃さに違いを出すことができます。

表示する縦線を減らす


いまのままでは縦線の数が多すぎるので減らしていきます。


まず最初の Remap ノードで0~1の範囲に変換し(下画像1枚目)、次の Clamp ノードで範囲を絞ります(下画像2枚目。青の線がThreshold)。
このままでは色が暗いので、これをさらに0~1へ変換することで、全体的に明るくします(下画像3枚目)。

実際にはノイズが含まれるためブレのある波形になり、線の色が濃いところと薄いところができるので、表示される縦線の数を減らすことができます。

コードで実装

これまでのノードをコードで実装したものが、こちらです。

    half4 frag(v2f i) : SV_Target {
        float2 uv = i.uv;
        half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);        

        float result;
        // uv から縦線を作る
        float scratch = sin(uv.x * _ScratchFreq + _Time.y / 2 * _Speed);
        float noise = Unity_GradientNoise_float(float2(uv.x, uv.x), _NoiseFreq);

        result = scratch + noise;
        // 色を薄くしつつ、表示されるラインを閾値で制限する
        result = Unity_Remap_float(result, float2(-1, 1), float2(0, 1));
        result = Unity_Remap_float(clamp(result, 0, _Threshold), float2(0, _Threshold), float2(0, 1));

        col *= result;
        return col;
    }

Unity_Remap_floatUnity_GradientNoise_float は、それぞれ ShaderGraph の Remap ノードGradientNoise ノードのマニュアルから実装をもってきています。

フィルム汚れ(小さな黒い点)の実装

フィルム汚れについては、簡単に表現するのであれば黒い点のみ実装できればいいですが、糸くずや髪の毛のようなもの(短い線状のもの)も表示できるようにするとさらにリアルさが増します。


そのため今回は、少し特殊な方法ですが、糸くずなどの汚れを描いたテクスチャを用意し、それをフリップブック的に参照していくことで表現してみようと思います。
例えば以下の画像は、青い四角がフリップブックのタイル、赤い四角が汚れ部分を表しています。タイル内に汚れが入っていれば表示され、入っていなければ表示されないというようにちらつく感じを表現できます。

実装後のコード

実装後のコードは以下の通りです。

        // フリップブックのいまのタイルインデックスを求める (8x8のフリップブックなら0~63が返る)
        float index = floor(_Time.y / _FlipSec % (_FlipWidth * _FlipHeight));
        // 1タイル分の大きさ
        float2 xy = float2(1, 1) / float2(_FlipWidth, _FlipHeight);
        // u方向は 0~_FlipWidth を繰り返す
        // v方向は index が _FlipWidth まで到達したら 1 だけ上に移動する
        uv = (uv + float2(index % _FlipWidth, floor(index / _FlipWidth))) * xy;

        half4 filmDartCol = SAMPLE_TEXTURE2D(_FilmDartTex, sampler_FilmDartTex, uv*_FlipFreq);
        result *= filmDartCol;

ここで、_FlipWidth と _FlipHeight はそれぞれ横方向と縦方向のタイル数を表しています。


少し処理がややこしくなっていますが、処理の内容としては左下から右上に向かってタイルを進めていくだけになっています。

ソースコード全文

これまでのコードを含め、ソースコードの全文を記載します。
パラメータの初期値や Attribute で Range 指定している箇所がありますが、ここは参考程度にしていただき、したい表現にあわせて調整してください。
(けっこう調整が難しいかもしれません)


ポストエフェクトでの実装を想定しているため、C#スクリプト部分のコードも記載します。

シェーダー

Shader "FilmNoise"
{
    Properties {
        _ScratchFreq ("ScratchFreq", Int) = 200
        _Speed ("Speed", Int) = 50
        _NoiseFreq ("NoiseFreq", Int) = 30
        _Threshold ("Threshold", Float) = 0.16
        _FilmDartTex ("FilmDartTex", 2D) = "" {}
        _FlipWidth ("FlipWidth", Int) = 100
        _FlipHeight ("FlipHeight", Int) = 100
        _FlipSec ("FlipSec", Float) = 0.2
        _FlipFreq ("FlipFreq", Int) = 56
    }

    HLSLINCLUDE
    #include "Packages/com.unity.postprocessing/PostProcessing/Shaders/StdLib.hlsl"

    TEXTURE2D_SAMPLER2D(_MainTex, sampler_MainTex);
    uniform float4 _MainTex_TexelSize;
    half4 _MainTex_ST;

    float _ScratchFreq;
    float _Speed;
    float _NoiseFreq;
    float _Threshold;
    TEXTURE2D_SAMPLER2D(_FilmDartTex, sampler_FilmDartTex);
    float _FlipWidth;
    float _FlipHeight;
    float _FlipSec;
    float _FlipFreq;

    // ref: https://docs.unity3d.com/ja/Packages/com.unity.shadergraph@10.0/manual/Gradient-Noise-Node.html
    float2 unity_gradientNoise_dir(float2 p)
    {
        p = p % 289;
        float x = (34 * p.x + 1) * p.x % 289 + p.y;
        x = (34 * x + 1) * x % 289;
        x = frac(x / 41) * 2 - 1;
        return normalize(float2(x - floor(x + 0.5), abs(x) - 0.5));
    }

    float unity_gradientNoise(float2 p)
    {
        float2 ip = floor(p);
        float2 fp = frac(p);
        float d00 = dot(unity_gradientNoise_dir(ip), fp);
        float d01 = dot(unity_gradientNoise_dir(ip + float2(0, 1)), fp - float2(0, 1));
        float d10 = dot(unity_gradientNoise_dir(ip + float2(1, 0)), fp - float2(1, 0));
        float d11 = dot(unity_gradientNoise_dir(ip + float2(1, 1)), fp - float2(1, 1));
        fp = fp * fp * fp * (fp * (fp * 6 - 15) + 10);
        return lerp(lerp(d00, d01, fp.y), lerp(d10, d11, fp.y), fp.x);
    }

    float Unity_GradientNoise_float(float2 UV, float Scale)
    {
        return unity_gradientNoise(UV * Scale) + 0.5;
    }

    // ref: https://docs.unity3d.com/ja/Packages/com.unity.shadergraph@10.0/manual/Remap-Node.html
    float Unity_Remap_float(float In, float2 InMinMax, float2 OutMinMax)
    {
        return OutMinMax.x + (In - InMinMax.x) * (OutMinMax.y - OutMinMax.x) / (InMinMax.y - InMinMax.x);
    }

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


    half4 frag(v2f i) : SV_Target {
        float2 uv = i.uv;
        half4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);        

        float result;
        // uv から縦線を作る
        float scratch = sin(uv.x * _ScratchFreq + _Time.y / 2 * _Speed);
        float noise = Unity_GradientNoise_float(float2(uv.x, uv.x), _NoiseFreq);

        result = scratch + noise;
        // 色を薄くしつつ、表示されるラインを閾値で制限する
        result = Unity_Remap_float(result, float2(-1, 1), float2(0, 1));
        result = Unity_Remap_float(clamp(result, 0, _Threshold), float2(0, _Threshold), float2(0, 1));
        
        // フィルム汚れの描画。テクスチャをフリップブック的に読み込んでいく
        float index = floor(_Time.y / _FlipSec % (_FlipWidth * _FlipHeight));
        float2 xy = float2(1, 1) / float2(_FlipWidth, _FlipHeight);
        uv = (uv + float2(index % _FlipWidth, floor(index / _FlipWidth))) * xy;

        half4 filmDartCol = SAMPLE_TEXTURE2D(_FilmDartTex, sampler_FilmDartTex, uv);
        result *= filmDartCol;

        col *= result;
        return col;
    }

    ENDHLSL

    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            HLSLPROGRAM

            #pragma vertex VertDefault
            #pragma fragment frag

            ENDHLSL
        }
    }
}

C#スクリプト

using System;
using UnityEngine;
using UnityEngine.Rendering.PostProcessing;

[Serializable]
[PostProcess(typeof(FilmNoiseRenderer), PostProcessEvent.AfterStack, "Custom/FilmNoise", false)]
public sealed class FilmNoise : PostProcessEffectSettings
{
    [Range(50,300)]
    public IntParameter ScratchFreq         = new IntParameter { value = 200 };
    [Range(30,80)]
    public IntParameter Speed               = new IntParameter { value = 50 };
    [Range(30,100)]
    public IntParameter NoiseFreq           = new IntParameter { value = 30 };
    [Range(0.1f,0.3f)]
    public FloatParameter Threshold         = new FloatParameter { value = 0.16f };
    
    public TextureParameter FilmDartTexture = new TextureParameter { };
    public IntParameter FlipWidth           = new IntParameter { value = 100 };
    public IntParameter FlipHeight          = new IntParameter { value = 100 };
    [Range(0.1f, 1.0f)]
    public FloatParameter FlipSec           = new FloatParameter { value = 0.2f };
    [Range(30,80)]
    public IntParameter FlipFreq            = new IntParameter { value = 56 };

}

public sealed class FilmNoiseRenderer : PostProcessEffectRenderer<FilmNoise>
{
    public override void Init()
    {
        base.Init();
    }

    public override void Render(PostProcessRenderContext context)
    {
        var sheet = context.propertySheets.Get(Shader.Find("FilmNoise"));

        sheet.properties.SetInt("_ScratchFreq", settings.ScratchFreq);
        sheet.properties.SetInt("_Speed", settings.Speed);
        sheet.properties.SetInt("_NoiseFreq", settings.NoiseFreq);
        sheet.properties.SetFloat("_Threshold", settings.Threshold);
        sheet.properties.SetTexture("_FilmDartTex", settings.FilmDartTexture);
        sheet.properties.SetInt("_FlipWidth", settings.FlipWidth);
        sheet.properties.SetInt("_FlipHeight", settings.FlipHeight);
        sheet.properties.SetFloat("_FlipSec", settings.FlipSec);
        sheet.properties.SetInt("_FlipFreq", settings.FlipFreq);

        context.command.BlitFullscreenTriangle(context.source, context.destination, sheet, 0);
    }

    public override void Release()
    {
        base.Release();
    }
}

使用アセット

今回のサンプル画像を作るにあたって使用したアセットはこちらです。
assetstore.unity.com