【Siv3D】2Dカスタムシェーダを利用した簡易ブラウン管風シェーダ

はじめまして。しののめです。
これは[Siv3D Advent Calendar 2023](https://qiita.com/advent-calendar/2023/siv3d "Siv3D Advent Calendar 2023")の8日目の記事です。
## 概要
こんな感じのシェーダを、ロジック部分のみだと70行程度で作りました。
```plantuml
@startuml
<style>
root {
  Padding 0
  Margin 0
}
footer {
  FontColor black
  FontSize 17
}
</style>
footer 左:ブラウン管風シェーダー適用前　右:適用後
title <img:https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623299/257097ae-b76a-cd1f-d7f9-bf42e7632e30.png>
@enduml
```

## ブラウン管
ブラウン管は、映像表示の技術の一つです。2000年前後までは一般家庭などでも広く使われていましたが液晶の普及にしたがって、消費電力の高さや本体の厚さといった原因によりあまり使われなくなっていきました。映像に関して液晶ディスプレイと比べると、映像の歪みやにじみといった特徴を持っています。
## ドット絵
ドット絵はゲーム黎明期から現在まで使われている技法です。
80年代~90年代のドット絵には、ゲーム機やコンピュータで画像を表示する際に利用できる色数等の制約があったため、前述したブラウン管のにじみを利用して複雑な色を表現する技法が存在していました。
有名な例として、ディザリング(少ない色を網状に配置することでグラデーションを疑似的に再現する方法)等が挙げられます。
## ブラウン管風シェーダー
本題に入ります。ドット絵を当時利用されていたブラウン管で表示した画像に近づけるためのシェーダを、HLSLにて作成しました。
```plantuml
@startuml
<style>
root {
  Padding 0
  Margin 0
}
footer {
  FontColor black
  FontSize 17
}
</style>
footer 左:ブラウン管風シェーダー適用前　右:適用後
title <img:https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623299/257097ae-b76a-cd1f-d7f9-bf42e7632e30.png>
@enduml
```
上の画面処理を、Siv3dの2Dカスタムシェーダとレンダーテクスチャを使うことで、簡単に利用できます。

## シェーダのソースコード(hlsl)
```hlsl:shader.hlsl
// example/shader/hlsl/default2d.hlslより引用

//
//	Textures
//
Texture2D g_texture0 : register(t0);
SamplerState g_sampler0 : register(s0);

namespace s3d
{
	//
	//	VS Input
	//
    struct VSInput
    {
        float2 position : POSITION;
        float2 uv : TEXCOORD0;
        float4 color : COLOR0;
    };

	//
	//	VS Output / PS Input
	//
    struct PSInput
    {
        float4 position : SV_POSITION;
        float4 color : COLOR0;
        float2 uv : TEXCOORD0;
    };

	//
	//	Siv3D Functions
	//
    float4 Transform2D(float2 pos, float2x4 t)
    {
        return float4((t._13_14 + (pos.x * t._11_12) + (pos.y * t._21_22)), t._23_24);
    }
}

//
//	Constant Buffer
//
cbuffer VSConstants2D : register(b0)
{
    row_major float2x4 g_transform;
    float4 g_colorMul;
}

cbuffer PSConstants2D : register(b0)
{
    float4 g_colorAdd;
    float4 g_sdfParam;
    float4 g_sdfOutlineColor;
    float4 g_sdfShadowColor;
    float4 g_internal;
}

// 引用ここまで

float4 getColors(float2 uv)
{
    float4 ret;
    const float MOV_D = 0.002;
    float wheights[3][3] =
    {
        { 0.1, 0.1, 0.1 },
        { 0.1, 0.2, 0.1 },
        { 0.1, 0.1, 0.1 }
    };
    for (int y = 0; y < 3; ++y)
    {
        for (int x = 0; x < 3; ++x)
        {
            float u = uv.x + (x - 1) * MOV_D;
            float v = uv.y + (y - 1) * MOV_D;
            ret += g_texture0.Sample(g_sampler0, float2(u,v))*wheights[y][x];
        }

    }
    return ret;
}
float4 PS_Texture(s3d::PSInput input) : SV_TARGET
{
 
    const float4 texColors = getColors(input.uv);
    float4 color = float4(0.0, 0.0, 0.0, texColors.a);
    const float MOD = 4;
    const float STEP = 2;
    {
        float r = ((texColors * input.color) + g_colorAdd).r;
        float a = step(1.9, step(STEP, fmod(input.position.y, MOD)) + step(STEP, fmod(input.position.x, MOD)));
        color.x = a * r;
    }
    {
        float g = ((texColors * input.color) + g_colorAdd).g;
        float a = step(1.9, step(STEP, fmod(input.position.y+STEP, MOD)) + step(1.0, fmod(input.position.x, MOD)));
        color.y = a * g;
    }
    {
        float b = ((texColors * input.color) + g_colorAdd).b;
        float a = step(1.9, step(STEP, fmod(input.position.y, MOD)) + step(STEP, fmod(input.position.x+STEP, MOD)));
        color.z = a * b;
    }
    float alpha =  1-step(1.9,step(STEP, fmod(input.position.y+STEP, MOD)) + step(STEP, fmod(input.position.x+STEP, MOD)));
    color.a = max(color.a, 1 - alpha);
    color.x = min(color.x, alpha);
    color.y = min(color.y, alpha);
    color.z = min(color.z, alpha);
    return color;
}

// 画面のゆがみ
float2 barrel(float2 uv)
{
    float s1 = .99, s2 = .05;
    float2 centre = 2. * uv - 1.;
    float barrel = min(1. - length(centre) * s1, 1.0) * s2;
    return uv - centre * barrel;
}

float4 PS_Texture_Barrel(s3d::PSInput input) : SV_TARGET
{
    input.uv = barrel(input.uv);
    float4 texColors = g_texture0.Sample(g_sampler0, input.uv);
    float m = 1.0 - step(1.0, max(max(input.uv.x, input.uv.y), 1 + max(input.uv.x * -1, input.uv.y * -1)));
    texColors.xyz = min(texColors.xyz, m);
    return texColors;
}

```
## シェーダの中身
シェーダの中身はおおまかに、表示色を制限するパートと画面を歪ませるパートの二つに分かれています。

### 表示色の制限
赤色成分のみを表示するピクセルを2×2の範囲、緑色成分のみを表示するピクセルを2×2の範囲、青色成分のみを表示するピクセルを2×2の範囲、黒色を表示するピクセルを2×2の範囲で作り、それらを4×4の正方形毎に設定しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3623299/98b4f0cb-8184-5771-2409-346c036e24ab.png)
<details><summary>該当部分</summary><div>

```hlsl:
float4 PS_Texture(s3d::PSInput input) : SV_TARGET
{
 
    const float4 texColors = getColors(input.uv);
    float4 color = float4(0.0, 0.0, 0.0, texColors.a);
    const float MOD = 4;
    const float STEP = 2;
    {
        float r = ((texColors * input.color) + g_colorAdd).r;
        float a = step(1.9, step(STEP, fmod(input.position.y, MOD)) + step(STEP, fmod(input.position.x, MOD)));
        color.x = a * r;
    }
    {
        float g = ((texColors * input.color) + g_colorAdd).g;
        float a = step(1.9, step(STEP, fmod(input.position.y+STEP, MOD)) + step(1.0, fmod(input.position.x, MOD)));
        color.y = a * g;
    }
    {
        float b = ((texColors * input.color) + g_colorAdd).b;
        float a = step(1.9, step(STEP, fmod(input.position.y, MOD)) + step(STEP, fmod(input.position.x+STEP, MOD)));
        color.z = a * b;
    }
    float alpha =  1-step(1.9,step(STEP, fmod(input.position.y+STEP, MOD)) + step(STEP, fmod(input.position.x+STEP, MOD)));
    color.a = max(color.a, 1 - alpha);
    color.x = min(color.x, alpha);
    color.y = min(color.y, alpha);
    color.z = min(color.z, alpha);
    return color;
}
```
</div></details>
ブラウン管の表示方式にはいくつかありますが、シャドーマスクという方式を参考にして採用しました。

また、にじみを表現するために、各ピクセルごとに周囲のピクセルの色を一定量混ぜています。
<details><summary>該当部分</summary><div>

```hlsl:
float4 getColors(float2 uv)
{
    float4 ret;
    const float MOV_D = 0.002;
    float wheights[3][3] =
    {
        { 0.1, 0.1, 0.1 },
        { 0.1, 0.2, 0.1 },
        { 0.1, 0.1, 0.1 }
    };
    for (int y = 0; y < 3; ++y)
    {
        for (int x = 0; x < 3; ++x)
        {
            float u = uv.x + (x - 1) * MOV_D;
            float v = uv.y + (y - 1) * MOV_D;
            ret += g_texture0.Sample(g_sampler0, float2(u,v))*wheights[y][x];
        }

    }
    return ret;
}
```
</div></details>

### 画面の歪み

ブラウン管テレビでは、映像が画面中央に向かって歪みます。それを再現するために、バレルディストーションという方法を使ってuv座標を歪ませています。
<details><summary>該当部分</summary><div>
```hlsl:
float2 barrel(float2 uv)
{
    float s1 = .99, s2 = .05;
    float2 centre = 2. * uv - 1.;
    float barrel = min(1. - length(centre) * s1, 1.0) * s2;
    return uv - centre * barrel;
}
```
</div></details>
また、uv座標の要素が0未満か1より大きくなった場合にピクセルを黒くする処理をバレルディストーションの後に行っています。
<details><summary>該当部分</summary><div>
```hlsl:
float4 PS_Texture_Barrel(s3d::PSInput input) : SV_TARGET
{
    input.uv = barrel(input.uv);
    float4 texColors = g_texture0.Sample(g_sampler0, input.uv);
    float m = 1.0 - step(1.0, max(max(input.uv.x, input.uv.y), 1 + max(input.uv.x * -1, input.uv.y * -1)));
    texColors.xyz = min(texColors.xyz, m);
    return texColors;
}
```
</div></details>

## 利用例
サンプルとしてSiv3Dを利用して先述したスクリーンショットの画面を表示したソースコードを示します。
```c++:main.cpp
# include <Siv3D.hpp>

void Main()
{
	const PixelShader ps2DTexture = HLSL{ U"shader.hlsl", U"PS_Texture" };
	const PixelShader ps2DTextureDistortion = HLSL{ U"shader.hlsl", U"PS_Texture_Barrel" };
	if (not ps2DTexture || not ps2DTextureDistortion) {
		return;
	}
	const Texture texture{ U"FFSS.jpg" };
	const Size SCENE_SIZE = { texture.size().x*2,texture.size().y};
	Window::Resize(SCENE_SIZE.x, SCENE_SIZE.y);
	const RenderTexture renderTexture{ uint32(SCENE_SIZE.x/2), uint32(SCENE_SIZE.y) };
	const RenderTexture distortionTexture{ uint32(SCENE_SIZE.x / 2), uint32(SCENE_SIZE.y) };
	while (System::Update())
	{
		renderTexture.clear(Palette::White);
		distortionTexture.clear(Palette::White);
		const ScopedRenderStates2D sampler{ SamplerState::ClampNearest };
		{
			{
				// レンダーテクスチャに書き込む
				const ScopedRenderTarget2D target{ renderTexture };
				texture.draw();
			}
			{
				// レンダーテクスチャの内容をブラウン管風シェーダーを通して、ディストーションテクスチャへ書きこむ
				const ScopedCustomShader2D shader{ ps2DTexture };
				const ScopedRenderTarget2D target{ distortionTexture };
				renderTexture.draw();
			}
			{
				// ディストーションテクスチャの内容を歪ませて画面へ書き込む
				const ScopedCustomShader2D shader{ ps2DTextureDistortion };
				distortionTexture.draw();
			}
			//比較用にデフォルトのシェーダーで画像表示	
			texture.draw(SCENE_SIZE.x/2, 0);
			}
	}
	
}
```
## 利用方法
`shader.hlsl`の内容をコピペして同名のファイルを作り、[Siv3Dの2D カスタムシェーダチュートリアル](https://zenn.dev/reputeless/books/siv3d-documentation/viewer/tutorial-shader2d)に従えば利用できます．

もし可視性の都合などで画面のゆがみを無効化したい場合は、歪み用の`distortionTexture`を経由せずに、`renderTexture`の内容を描画してください．

## 参考記事
冒頭のFF4のスクリーンショット、ブラウン管の種類や一部実装等を参考にさせていただきました。ありがとうございます。
https://qiita.com/saragai/items/2885d83f55a46f206d1d
https://sayachang-bot.hateblo.jp/entry/2019/12/11/231351
https://jp.finalfantasy.com/topics/294