【Unity】【Shader】Surface-Stable Fractal Ditheringをやってみた

# はじめに
https://x.com/runevision/status/1882451983543783652

Twitterで見かけたオブジェクトの表面上にドットサイズを均一にしてディザ模様をかける方法が流れた来たので勉強しました。

https://store.steampowered.com/app/653530/Return_of_the_Obra_Dinn/

Return of the Obra Dinnというゲームで見ることができます。
こちらは3Dゲームで全オブジェクトに特徴的な模様が出ます。(リンク先のSteamの動画を見るのがオススメです)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608797/88354df1-6730-40ff-a555-0a7eb6227037.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608797/f6e11d31-8b79-43d0-b4a5-399b3ec6bfda.png)

これを再現する方法になります。

# ソースコードなど
https://youtu.be/HPqGaIMVuLs?si=Vrci69LPy4ZdbunC

参考コードの解説動画

https://github.com/runevision/Dither3D

参考にしたソースコードはこちら。
こちらはUnityのBRPに対応したコードかつ、様々なパラメータが用意されていたためURP対応＆最小限動くようにした最小コードを作成しました。

https://github.com/ayaha401/Study-Surface-Stable-Fractal-Dithering

# 説明など
アルゴリズム的なことは説明しません。
上記のソースコードの説明や、動画のほうが詳しいからです。
自分の覚え書き程度です。



そのままのソースコードではURPで動かないので以下のような作業を行いました。
* `fixed`を使用している箇所を全部`half`に変更
    * HLSLには`fixed`がないから
* SurfaceShaderをやめる

また、最小コードにするために以下を行いました。
* 必要のないDefine分岐の箇所を消す


これらを行い、作成コードの紹介です。
まず、いらない`multi_compile`などを消して必要なものだけincludeします。
```HLSL
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Assets/Shader/Utility/ColorFunction.hlsl"
```
`#include "Assets/Shader/Utility/ColorFunction.hlsl"`は色にかかわる関数をまとめたHLSLです。

https://github.com/ayaha401/Shader-ColorFunction

モノクロ変換を行う関数を呼び出すためにincludeします。(3行しかないので書けばいいのですが3DCG検証プロジェクトで一括管理しているためこうなりました。)

次にSurfaceShaderをやめたので構造体から作成します。
```HLSL
struct Attributes
{
    float4 positionOS : POSITION;
    float2 uv : TEXCOORD0;
};

struct Varyings
{
    float4 positionHCS : SV_POSITION;
    float2 uv : TEXCOORD0;
};
```
もともとはFogに必要なものを含んでいますが今回はいらないので消します。

変数もSurfaceShaderだとMetallicやBumpMapの宣言などありますが使用していないので消します。
最小限であればこうなりました。
```HLSL
sampler2D _MainTex;
sampler3D _DitherTex;
sampler2D _DitherRampTex;

CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
float4 _DitherTex_TexelSize;
float _Scale;
float _SizeVariability;
float _Contrast;
float _StretchSmoothness;
CBUFFER_END
```
精度などわざわざfloatでなくてもよいパラメータがある気もしますがいったんfloatです。

次に頂点シェーダーです。
```HLSL
Varyings vert(Attributes v)
{
    Varyings o;
    o.positionHCS = TransformObjectToHClip(v.positionOS.xyz);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    return o;
}
```
特に何もしないです。

フラグメントシェーダーです
```HLSL
float4 frag(Varyings i) : SV_Target
{
    float4 col = tex2D(_MainTex, i.uv);
    col.rgb = MyGetDither3DColor(i.uv, col);
    return col;
}
```
`MyGetDither3DColor`で今回の重要なディザ処理を開始していきます。

```HLSL
half4 MyGetDither3DColor(float2 uv_DitherTex, half4 color)
{
    // UV座標の変化率を取得
    float2 dx = ddx(uv_DitherTex);
    float2 dy = ddy(uv_DitherTex);

    // Brightnessはモノクロに変換して代入
    half4 dither = MyGetDither3D(uv_DitherTex, dx, dy, convertMonochrome(color.rgb));

    color.rgb = dither.x;

    return color;
}
```
uvのddxやddyから今後使用するUVを算出するためここで作っておきます。
`convertMonochrome`はモノクロ変換を行います。
最小コードでディザ模様を出したいのでモノクロ変換を行います。
```HLSL
// モノクロ変換
// rgbCol : RGBの色
float convertMonochrome(float3 rgbCol)
{
    return dot(rgbCol, float3(.299, .587, .114));
}
```
このコードは自作の`#include "Assets/Shader/Utility/ColorFunction.hlsl"`から呼びます。

`MyGetDither3D`は長いのでちょっとずつ書きます。

```HLSL
float xResolution = _DitherTex_TexelSize.z; // widthを取得(heightも同じだとする)
float inverseXResolution = _DitherTex_TexelSize.x; // 1/widthされた値

// 3DテクスチャのZの解像度を計算する
float dotsPerSide = xResolution / 16.0; // 16で割るのは8x8のディザだから1つ16pxのはずだから？
float dotsTotal = pow(dotsPerSide, 2); // 正方形の面積を求めるのと同じロジック(多分)
float inverseZResolution = 1.0 / dotsTotal;
```
今後使用する変数を作成します。
Texture名+TexelSizeでTextureのサイズなどを得ることができます。
|要素|入ってる値|
-|-
|x|1.0/width|
|y|1.0/height|
|z|width|
|w|height|

また、3DTextureのResolutionなどは取れないようなので自力で計算するようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608797/7b4a4764-6064-4259-a358-17df9e4e266e.png)

`float dotsPerSide = xResolution / 16.0;`
ディザ8x8パターンで解像度が128のテクスチャを使用しているため1つ16pxになるので16で割っていると思われます(最小コードなら定数でもよいかも)
`float dotsTotal = pow(dotsPerSide, 2);`
こちらはドットの総数を計算してると思います。

inverseと書いてある変数は`1/何か`をしており、大体この後のコードで値を0~1の割合に計算しなおすのに使用したりします。

```HLSL
// 明るさを計算(いい感じに補正していてわからん)
// float2 lookup = float2((0.5 * inverseXResolution + (1 - inverseXResolution) * brightness), 0.5);
// half brightnessCurve = tex2D(_DitherRampTex, lookup).r;
// 最小限で構成するならこれでもよい気がする
half brightnessCurve = tex2D(_DitherRampTex, brightness).r;
```
ソースコード上はコメントアウトされているコードだったのですが、`0.5 * inverseXResolution`をすると値は`0.00390625`になり結構小さい値で個の計算を行っている理由がわからなかったため
`half brightnessCurve = tex2D(_DitherRampTex, brightness).r;`
に置き換えました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/608797/db40d1fd-d9f3-49f3-8a13-43bd7d4138a4.png)
RampTextureから明るさを取り出しているだけなので正直問題ないと思っています。

```HLSL
// fwidth(uv_DitherTex)が簡単な方法だが、これだとアーティファクトが出るので他の方法で精度よく作る
// 特異値分解を使用する
float2x2 matr = { dx, dy }; // UV座標の微分（変化率） を表す。
float4 vectorized = float4(dx, dy); // dx と dy を float4 に変換しているだけ。これは dot(vectorized, vectorized) を計算するための準備。
float Q = dot(vectorized, vectorized); // 各要素の2乗和を求める。これは、すべての変化率の総和 に近い値。
float R = determinant(matr); //ad-bc R=dx.x×dy.y−dx.y×dy.x
float discriminantSqr = max(0, Q*Q-4*R*R); // 解の公式のルートの中 maxはsqrtの中がマイナスになるのを防ぐ
float discriminant = sqrt(discriminantSqr); // 解の公式のルート計算
```
ここが一番わからなかったです。ここ([Youtube](https://youtu.be/HPqGaIMVuLs?t=746))で使用するための変数を作成するのですがfwidthを使う方法だとXとY方向に沿ってアーティファクトが出るらしく、特異値分解を用いて精度よく作成するようです。
特異値分解については調べましたがわからなかったので精度よくfwidthをやっていると考えて飛ばします。
`float discriminantSqr = max(0, Q*Q-4*R*R);`
ここは二次方程式の解の公式？をやっており、この後でfloat2に解を入れます。

```HLSL
// ここでの「freq」は、画面上の UV 座標の変化率を意味します。
// 解の公式の+-の分の解をfloat2に入れつつ、解の公式の解にsqrtをする
float2 freq = sqrt(float2(Q + discriminant, Q - discriminant) / 2);

// ドット間隔はmin値を使用する
float spacing = freq.y;

// 指定された入力スケール（2 の累乗）で間隔を拡大縮小します。
float scaleExp = exp2(_Scale);
spacing *= scaleExp;
spacing *= dotsPerSide * 0.125; // なんでするのかわからん

// 明るさによってドットサイズを変更するための調整
// _SizeVariabilityが0で間隔を明るさで割る
// _SizeVariabilityが1の時は間隔をそのままにする
// 0.001は中が0になるのを防止する
float brightnessSpacingMultiplier = pow(brightnessCurve * 2 + 0.001, -(1 - _SizeVariability));
spacing *= brightnessSpacingMultiplier;
```
一つ上で求めた解をfloat2の`freq`に入れてこの後使用していきます。
動画のここ([Youtube](https://youtu.be/HPqGaIMVuLs?t=746))で3DTextureのどこの奥行のテクスチャを使用するのかを決めるための`spacing`を調整します。
`_Scale`や`_SizeVariability`はドットのサイズを決める調整パラメータです。

```HLSL
// ドット間隔に対応するフラクタルレベルを決める
float spacingLog = log2(spacing);
int patternScaleLevel = floor(spacingLog); // Fractal level.
float f = spacingLog - patternScaleLevel; // Fractional part.

// フラクタルレベルのUV座標を取得
float2 uv = uv_DitherTex / exp2(patternScaleLevel);

// 3DテクスチャのZに沿ったレイヤーを取得するために作成
float subLayer = lerp(0.25 * dotsTotal, dotsTotal, 1 - f);
subLayer = (subLayer - 0.5) * inverseZResolution; // 0 ～ 1 の範囲に正規化して扱う

// 3D テクスチャをサンプリング
half pattern = tex3D(_DitherTex, float3(uv, subLayer)).r;
```
3Dテクスチャのどこを使用するのかを決めるメインロジックです([Youtube](https://youtu.be/HPqGaIMVuLs?t=746))
`float spacingLog = log2(spacing);`
([Youtube](https://youtu.be/HPqGaIMVuLs?t=764))で解説している対数を取ります。
`int patternScaleLevel = floor(spacingLog);`
([Youtube](https://youtu.be/HPqGaIMVuLs?t=778))で最も近い小さい整数に切り捨てることができるはここで行っています。`floor`で整数を取り出すことができます。
`float f = spacingLog - patternScaleLevel;`
([Youtube](https://youtu.be/HPqGaIMVuLs?t=832))逆に取り出した整数を引くと0~1までの増加を繰り返す値を手に入れることができます。

最後に`inverseZResolution`を使用して0～1の値に納めます。

```HLSL
// SDFの円が入っているから乗算して色をシャープにする(だと思う)
float contrast = _Contrast * scaleExp * brightnessSpacingMultiplier * 0.1;
contrast *= pow(freq.y / freq.x, _StretchSmoothness);

// わからん、いい感じの明るさの値
half baseVal = lerp(0.5, brightness, saturate(1.05 / (1 + contrast)));

half threshold = 1 - brightnessCurve;

// ソースコードのbw変数名が何を意図しているのか不明。Black White？
half bw = saturate((pattern - threshold) * contrast + baseVal);

return half4(bw, frac(uv.x), frac(uv.y), subLayer);
```
ここもわからないことが多かったですがTextureに格納されている情報はSDFの円なので明るさのパラメータなどで乗算を行い色を出していると思います。

# おわりに
動画本編で一番説明されているLogを使用してレイヤー番号を決める工程などは説明されているので理解できましたが、それ以外の明るさパラメータ周りがわからないことがおおかったでｓ