【Unity】滲みにこだわるブラウン管シェーダ

# 概要
Unityのポストエフェクトとして、このようなブラウン管シェーダを作成します。

https://youtu.be/lGRVKlrh1oQ

![名称未設定 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189786/97231411-2101-8e77-8d0d-ecd6811e130b.png)
左は元のドット絵、右はシェーダ適用後。
ドット絵の引用元：https://twitter.com/ruuupu1/status/1319645771289956352

Unityバージョン：2021.2.7f1 (Built-in Render Pipeline)
使用アセット：[2D Game Kit](https://assetstore.unity.com/packages/templates/tutorials/2d-game-kit-107098?locale=ja-JP)

# 背景
レトロ感のある絵作りを考えた際、 **ブラウン管っぽい見た目** というのは一つの候補になるでしょう。
ドット絵がピクセルアートとして一つの芸術表現となったように、ブラウン管表現もその可能性を秘めていると思います。

UnityのアセットストアにCRTと検索をかけると、このような表現を再現しようとするエフェクトが並んでいます。

https://assetstore.unity.com/packages/vfx/shaders/fullscreen-camera-effects/ultimate-crt-80048?locale=ja-JP

https://assetstore.unity.com/packages/vfx/shaders/fullscreen-camera-effects/natural-crt-145367?locale=ja-JP

これらはとても完成度が高く見えるのですが、有料ですので少し手を伸ばしづらいです。
さあ、自作しましょう。


# ブラウン管の見え方の考察

昔のドット絵をブラウン管で見ると綺麗に見えるというのをご存じの方も多いでしょう。
これはブラウン管の、明るいピクセルは膨らんで表示され、暗い部分は小さく表示されるという物理特性によるものです。

https://twitter.com/tinpet002/status/1262577764353183749

Wikipediaで[ブラウン管](https://ja.wikipedia.org/wiki/%E3%83%96%E3%83%A9%E3%82%A6%E3%83%B3%E7%AE%A1)を調べると、ブラウン管は蛍光物質の配置によって以下のように分類されるようです。
（図はWikipediaから引用）

- シャドーマスク
<img width="300" alt="Shadow_mask_closeup_cursor.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189786/e21b6f9e-8e4a-f696-92c8-2e94b25b1ca7.jpeg">


- アパーチャーグリル
<img width="300" alt="800px-Aperture_grille_closeup_teletext.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189786/e9da912a-cbea-0aea-12b0-6694fb3738dd.jpeg">

- スロットマスク
<img width="300" alt="Aperture_Grille.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189786/82bc1b0c-57d7-c34c-c7c3-062b3a81c4a3.jpeg">


個人的なブラウン管のイメージはアパーチャーグリルやスロットマスクが近かったので、これらを意識して作ってみようと思います。



他にも参考資料を検索すると、アパーチャーグリル方式の画面を拡大してあげてくださっている方がいました。

https://twitter.com/ruuupu1/status/1356955170551156739


以上の参考資料から、アパーチャーグリル方式のブラウン管の特徴としては、以下の点があると言えるでしょう。

1. 縦方向に同じ色の画素が並ぶ
- 上下の画素の中間には暗くなる領域が存在する
- 画素の並びは色ごとに異なるオフセットがある
- 暗い部分では、**画素の大きさ**が小さくなる


以上の点を再現するシェーダを作っていきたいと思います。


# 既存のブラウン管シェーダ

すでに無料でブラウン管風シェーダを公開されている方がいらっしゃいます。
（2025/02/06 追記：下画像のリンクを貼っていましたが、リンクが切れてしまったので省略します）

<img width="500" alt="無題7.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189786/7dcc784c-a305-fcbe-e0b8-adb4d385788a.png">


ノイズ表現などは入っていますが、縦方向に同じ色が並んでいることからアパーチャーグリル方式のブラウン管の再現であることがわかります。
先ほど挙げた特徴と照らし合わせると、

1. 縦方向に同じ色の画素が並ぶ
- 上下の画素の中間には暗くなる領域が存在する

という特徴は満たしています。
これに、残りの二つの特徴を加えることで、よりブラウン管の風合いを再現できるのではないでしょうか。

# 実装
実装は2D Game Kitを使いたかったのでBuilt-in Render Pipelineで行います。

以下のスクリプトを作成し、カメラにアタッチします。

```C#:Crt.cs
using UnityEngine;

[ExecuteAlways]
public class Crt: MonoBehaviour
{
    // ポストエフェクト用のシェーダを付けたマテリアルを入れる
    [SerializeField] private Material m_Material;

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        Graphics.Blit(src, dest, m_Material);
    }
}
```

Crtコンポーネントのm_Materialフィールドには、以下のシェーダを使用したマテリアルをアタッチします。

```hlsl:Crt.shader
Shader "Custom/CRT"
{
    SubShader
    {
        ZTest Always
        Cull Off
        ZWrite Off
        Fog {Mode Off}
        
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            
            sampler2D _MainTex;

            struct Attributes
            {
                float4 positionOS : POSITION;
                float2 uv : TEXCOORD0;
            };

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

            Varyings vert(Attributes IN)
            {                
                Varyings OUT;
                OUT.pos = UnityObjectToClipPos(IN.positionOS);
                OUT.uv = IN.uv;
                return OUT;
            }

            // 2次元ベクトルをシードとして0~1のランダム値を返す
            float rand(float2 co)
            {
                return frac(sin(dot(co.xy, float2(12.9898, 78.233))) * 43756.5453);
            }


            // 画面が出っ張っているようにゆがませる
            float2 distort(float2 uv, float rate)
            {
                uv -= 0.5;
                uv /= 1 - length(uv) * rate;
                uv += 0.5;
                return uv;
            }

            // 3x3のガウシアンフィルタをかける
            half4 gaussian_sample(float2 uv, float2 dx, float2 dy)
            {
                half4 col = 0;
                //col = tex2D(_MainTex, uv);
                col += tex2D(_MainTex, uv - dx - dy) * 1/16;
                col += tex2D(_MainTex, uv - dx) * 2/16;
                col += tex2D(_MainTex, uv - dx + dy) * 1/16;
                col += tex2D(_MainTex, uv - dy) * 2/16;
                col += tex2D(_MainTex, uv) * 4/16;
                col += tex2D(_MainTex, uv + dy) * 2/16;
                col += tex2D(_MainTex, uv + dx - dy) * 1/16;
                col += tex2D(_MainTex, uv + dx) * 2/16;
                col += tex2D(_MainTex, uv + dx + dy) * 1/16;
                return col;
            }
            
            // easing
            // 参考: https://easings.net/#easeInOutCubic
            float ease_in_out_cubic(const float x)
            {
                return x < 0.5
                    ? 4 * x * x * x
                    : 1 - pow(-2 * x + 2, 3) / 2; 
            }
            
            // CRTの1画素の上下端が暗くなる現象を再現する
            float crt_ease(const float x, const float base, const float offset)
            {
                float tmp =  fmod(x + offset, 1);
                float xx = 1 - abs(tmp * 2 - 1);
                float ease = ease_in_out_cubic(xx);
                return ease * base + base * 0.8;
            }
            

            fixed4 frag(Varyings IN) : SV_Target
            {
                float2 uv = IN.uv;

                // uvを画面が出っ張ているようにゆがませる
                uv = distort(uv, 0.2);

                // uvが範囲内出なければ黒く塗りつぶす
                if(uv.x < 0 || 1 < uv.x || uv.y < 0 || 1 < uv.y )
                {
                    return float4(0, 0, 0, 1);
                }

                // 現在のピクセルの色がRGBのどれか
                // x軸だけを見ることで、
                // 1. 縦方向に同じ色の画素が並ぶ
                // を達成する
                const float floor_x = fmod(IN.uv.x * _ScreenParams.x / 3, 1);
                const float isR = floor_x <= 0.3;
                const float isG = 0.3 < floor_x && floor_x <= 0.6;
                const float isB = 0.6 < floor_x;

                // 隣のピクセルまでのUV座標での差を計算しておく
                const float2 dx = float2(1 / _ScreenParams.x, 0);
                const float2 dy = float2(0, 1 / _ScreenParams.y);

                // RGBごとにUVをずらすことで、
                // 3. 画素の並びは色ごとに異なるオフセットがある
                // を達成する
                uv += isR * -1 * dy;
                uv += isG * 0 * dy;
                uv += isB * 1 * dy;
                
                // ガウシアンフィルタによって、境界をぼかす
                // 特に、黒背景にドット絵だけが浮かんでいるような場合に
                // 背景とオブジェクトがハッキリ分かれてしまうことを防いでいる
                half4 col = gaussian_sample(uv, dx, dy);

                // 縦方向をNピクセルごとに分割して端を暗くする処理を加えることで、
                // 2. 上下の画素の中間には暗くなる領域が存在する
                // 4. 暗い部分では画素の大きさが小さくなる
                // を同時に達成する
                const float floor_y = fmod(uv.y * _ScreenParams.y / 6, 1);
                const float ease_r = crt_ease(floor_y, col.r, rand(uv)* 0.1);
                const float ease_g = crt_ease(floor_y, col.g, rand(uv)* 0.1);
                const float ease_b = crt_ease(floor_y, col.b, rand(uv)* 0.1);

                // 現在のピクセルによってRGBのうち一つの色だけを表示する
                float r = isR * ease_r;
                float g = isG * ease_g;
                float b = isB * ease_b;
                
                return half4(r, g, b, 1);
            }
            ENDCG
        }
    }
}

```

## 工夫点

いくつか工夫点があり、それぞれコード内にコメントを書いておきましたが、一つだけ、

- 暗い部分では、**画素の大きさ**が小さくなる

について触れようと思います。

```hlsl:Crt.shader
//[...]
const float floor_y = fmod(uv.y * _ScreenParams.y / 6, 1);
const float ease_r = crt_ease(floor_y, col.r, rand(uv)* 0.1);
//[...]
```
の箇所で、Easingをかけています。
この`6`は、アパーチャーグリル方式の画素の縦の長さを表しており、
`floor_y`は、現在のピクセルがブラウン管の画素の中でどの位置にいるかを[0, 1)で表したものです。
`rand(uv)*0.1`の項によって画素の並びにノイズを与え、風合いを増そうとしています。

```hlsl:Crt.shader
//[...]
float crt_ease(const float x, const float base, const float offset)
{
    float tmp =  fmod(x + offset, 1);
    float xx = 1 - abs(tmp * 2 - 1);
    float ease = ease_in_out_cubic(xx);
    return ease * base + base * 0.8;
}
//[...]
```
`ease_in_out_cubic(xx)`はこのような関数です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189786/47b19b4e-0733-f59c-a699-033a7f2a6a7d.png)
引用元：https://easings.net/#easeInOutCubic

これを左右反転して右につなげることで、[0, 1]の0付近と1付近では値が小さくなるようにしています。
また、実際のブラウン管ではその画素が暗いと光っている部分が小さくなり、明るいと大きくなることから、`ease * base` の項を加えています。

最後の`base * 0.8`は、上下端を暗くしたことによって画面全体が暗くなってしまうのを防ぐために導入した項です。
特に根拠はないのでもっと良い項はあると思います。

このEasingによって下の図のように、暗い部分は小さく、明るい部分は滲んで上下の画素と合体しているような表現ができるようになりました。
<img width=400 alt=名称未設定 3.png src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189786/89e33776-fe89-3b08-f82c-17144232b99d.png>



# 結果
ブラウン管シェーダを実装して、冒頭に挙げたような絵作りをすることができました。
最後にアパーチャーグリル方式の参考にさせていただいたtwitterの画像との比較も載せておきます。

![名称未設定 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/189786/ddab26ac-1502-84a2-8b5c-b841a397b474.png)
（左が実際のブラウン管で見た画像、中央は元のドット絵、右は中央に今回作成したシェーダをかけたもの）

実際のブラウン管と比べると色味に大きく違いはありますが、形の見え方はかなり近いのではないかと思います。

# 課題
結果で述べたように、色味は実際のブラウン管とかなり違うなと思いました。
ブラウン管の出力はもっとコントラストが強く彩度も高い、毒々しい感じなのかなと思います。

一つ検討をつけるとすると、今回はにじみにこだわると言いつつ、縦方向のにじみしか考えていませんでした。
実際には横方向にもにじみは発生します。
特に、白色のような明るい部分では、かなりにじみが大きくなっています。
これは、(r,g,b)でいうと
`(1, 0.3, 0.3), (0.3, 1, 0.3), (0.3, 0.3, 1)`
のように並んでいると考えられます。

一方、今回作成したシェーダでは、横方向のにじみは考えていないので、完全な白だとしても、
`(1,0,0), (0,1,0), (0,0,1)`
と並んでいるだけになり、全体でみると実際より暗く見えてしまうのでしょう。

彩度についても同様で、RGBのうち明るい色は横方向ににじんでより強く見えるため、より高く見えるのかなと思いました。

今後機会があれば、横方向のにじみについても考えてみたいと思います。