👹

ComputeShaderでレトロ雰囲気なポストエフェクトをする

2023/03/25に公開

前置き&成果物

ComputeShaderはGPU側で計算を並列実行するためのShaderです。
数値計算や物理シミュレーション、機械学習など多くの使用用途があります。今回はComputeShaderを画像処理につかってUnityでポストエフェクトを行っていきたいと思います。
今回は画像にあるように解像度を低くして昭和の写真のような仕上がりを目指したいと思います。

大まかな方針は下記のとおりです。
現在のCameraの画像をセットするRenderTextureとそれを加工して出力するためのRenderTextureの二種類を用意します。
その後、OnRenderImageの中でGraphic.Blitをおこなってポストエフェクトを実現していきます。

  • 注意 URPではOnRenderImageは使用できないので他の方法でポストエフェクトをする必要があります。(SwapBufferなど)

ステップ1 ポストエフェクト用の親クラスを作成する

まずは、ポストエフェクト用のクラスを作成します。

using UnityEngine;

[RequireComponent(typeof(Camera))]
public class ComputeShaderPostProcessBase : MonoBehaviour
{
    [SerializeField] protected ComputeShader shader = null;

    protected string kernelName = "CSMain";

    protected Vector2Int texSize = new Vector2Int(0,0);
    protected Vector2Int groupSize = new Vector2Int();
    protected Camera thisCamera;

    protected RenderTexture output = null;
    protected RenderTexture renderedSource = null;

    protected int kernelHandle = -1;
    protected bool init = false;

    protected virtual void Init()
    {
        if (!SystemInfo.supportsComputeShaders)
        {
            Debug.LogError("It seems your target Hardware does not support Compute Shaders.");
            return;
        }

        if (!shader)
        {
            Debug.LogError("No shader");
            return;
        }

        kernelHandle = shader.FindKernel(kernelName);

        if (kernelHandle == -1)
        {
            Debug.LogError("No kernelhandle");
            return;
        }
        
        thisCamera = GetComponent<Camera>();

        if (!thisCamera)
        {
            Debug.LogError("Object has no Camera");
            return;
        }

        CreateTextures();

        init = true;
    }

    protected void ClearTexture(ref RenderTexture textureToClear)
    {
        // Clear Textures
        if (null != textureToClear)
        {
            textureToClear.Release();
            textureToClear = null;
        }
    }

    protected virtual void ClearTextures()
    {
        ClearTexture(ref output);
        ClearTexture(ref renderedSource);
    }

    protected void CreateTexture(ref RenderTexture textureToMake, int divide=1)
    {
        textureToMake = new RenderTexture(texSize.x/divide, texSize.y/divide, 0);
        textureToMake.enableRandomWrite = true;
        textureToMake.Create();
    }


    protected virtual void CreateTextures()
    {
        texSize.x = thisCamera.pixelWidth;
        texSize.y = thisCamera.pixelHeight;

        if (shader)
        {
            uint x, y;
            shader.GetKernelThreadGroupSizes(kernelHandle, out x, out y, out _);
            groupSize.x = Mathf.CeilToInt((float)texSize.x / (float)x);
            groupSize.y = Mathf.CeilToInt((float)texSize.y / (float)y);
        }

        CreateTexture(ref output);
        CreateTexture(ref renderedSource);

        shader.SetTexture(kernelHandle, "source", renderedSource);
        shader.SetTexture(kernelHandle, "output", output);
    }

    protected virtual void OnEnable()
    {
        Init();
        CreateTextures();
    }

    protected virtual void OnDisable()
    {
        ClearTextures();
        init = false;
    }

    protected virtual void OnDestroy()
    {
        ClearTextures();
        init = false;
    }

    protected virtual void DispatchWithSource(ref RenderTexture source, ref RenderTexture destination)
    {
        Graphics.Blit(source, renderedSource);

        shader.Dispatch(kernelHandle, groupSize.x, groupSize.y, 1);
        
        Graphics.Blit(output, destination);
    }
    
    protected virtual void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        if (!init || shader == null)
        {
            Graphics.Blit(source, destination);
        }
        else
        {
            DispatchWithSource(ref source, ref destination);
        }
    } 
}

要点だけ説明します。

  • protected virtual
    このクラスは後で継承するのでアクセス修飾子をprotectedにしています。overrideするものはvirtualをつけて仮想関数にしています。

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/protected

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/virtual

  • ref out
    refとoutはどちらも参照型ですが違いがあります。
    refは値を書き換える、読み取るどちらの目的でも使用されます。メソッドに渡す前に値の初期化が必要です。
    outは値を書き換えることが目的で使用されます。メソッドに渡す前の初期化は必要ではないです。その代わり、メソッド内で初期化する必要があります。

参考URL
https://qiita.com/fksk36/items/dfa7e7e6ab40ecdd4ee8

  • Init
    ここでComputeShaderを実行できる環境かどうかをチェックしています。RequireComponentしているのにCameraをチェックしているなど少しやりすぎな気もしますが、気にしないでください。

  • CreateRenderTexture
    ポストプロセスをするのでカメラのピクセルサイズと幅、高さとRenderTextureのサイズを同じにします。CreateTexture内で使用されているenableRandmWriteはRenderTextureへの書き込みを許可するプロパティで、これをしないとポストポロセスの加工ができないので注意です。
    CreateTexturesではComputeShaderのGetkernelThreadGroupSizezを利用してスレッドグループのサイズを取得しています。テクセルサイズをスレッドグループのサイズで除算することでスレッドグループの数を取得できます。

  • ClearRenderTexture
    RenderTextureは動的にカメラに映る画像を取得して、その分メモリを確保します。なので必要なくなったらメモリを解放したほうが良いとされています。ClearTexturesでは実際にそれをおこなっています。

  • OnRenderImage
    OnRenderImageでは実際にポストエフェクトを行っています。DispatchWithSourceメソッドを実行して、現在移っているカメラの画像をTextureとしてGraphic.Blitした後、ComputeShaderをDispatchして、加工後のTextureを再度Blitしています。
    Dispatchにはカーネル番号とスレッドグループの数をいれます。(セットするのはスレッドグループの数であり、スレッドの総数や1スレッドグループにおけるスレッド数ではないの注意)

https://docs.unity3d.com/ja/2020.3/ScriptReference/ComputeShader.Dispatch.html

ステップ2 実際にポストプロセスを実行するクラスを作成

using UnityEngine;

public class RetroPostProcess : ComputeShaderPostProcessBase
{
    [SerializeField,Range(0.0f, 1.0f)] private float tintStrength = 0.7f;
    [SerializeField] private Color tint = Color.white;
    [SerializeField, Range(0, 50)] private int lines = 100;
    [SerializeField, Range(1, 5)] private float lineSpeed = 2.0f;
    
    private void Start() {
	SetProperties();
    }

    private void SetProperties()
    {
        shader.SetVector("tintColor", tint);
        shader.SetFloat("tintStrength", tintStrength);
        shader.SetInt("lines", lines);
    }

    protected override void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        shader.SetFloat("time", Time.time * lineSpeed);
        base.OnRenderImage(source,destination);
    }
}

先ほどのポストエフェクト用のクラスを継承しています。そのため、基本的にBaseクラスの関数を呼び出せば良いのですが、今回はRenderTextureを加工するのにいくつかのパラメータをセットしたかったので少しいじっています。

ステップ3 ComputeShaderを作成

#pragma kernel CSMain

Texture2D<float4> source;
RWTexture2D<float4> output;
int lines;
float4 tintColor;
float tintStrength;
float time;

float random (float2 pt, float seed) {
    const float a = 12.9898;
    const float b = 78.233;
    const float c = 43758.543123;
    return frac(sin(dot(pt, float2(a, b)) + seed) * c );
}

[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    // 3で除算してInt型に変化した後に3を乗算することによってIndexを作成。
    uint2 index = (uint2( id.x, id.y) / 3) * 3;
    // 時間によって変化する乱数を取得
    float ran = random((float2)id.xy, time);
    // 乱数を補間値として線形補間。解像度を低くする。
    float3 srcColor = lerp(source[id.xy].rgb * 2, source[index].rgb, ran);
    // 色の平均をとってグレースケール化する
    float3 grayScale = (srcColor.r + srcColor.g + srcColor.b) / 3.0;
    // 色付け
    float3 tinted = grayScale * tintColor.rgb;
    // 色付けの強さを調整
    float3 finalColor = lerp(srcColor, tinted, tintStrength);

    // 現在のuvY座標を取得
    float uvY = (float)id.y/(float)source.Length.y;
    // uvYをLines倍することで等間隔で線が配置されるように調整&時間で動くようにする。
    float scanline = smoothstep(0.1, 0.5, frac(uvY * lines + time));
    // scanlineを補間値として線形補完して終了
    finalColor = lerp(source[id.xy].rgb, finalColor, scanline);
   
    output[id.xy] = float4(finalColor, 1);
}

ComputeShaderの方針は解像度を低くする=>グレースケールする=>色付け=>昔っぽくするためのLineを作成です。詳細はコメントに書いてある通りです。
最後にTextureであるOutputに色の値を書き込んであげれば終了です。

Tintcolorを茶色っぽくすれば、冒頭の画像のようなレトロ雰囲気のものが実現できます。

最後まで読んでいただきありがとうございましたmm

Discussion