[Unity] RGB三チャネル同時色相変換シェーダー（明暗グラデーション対応）

# 経緯
初期のドラ●エみたいな昔のゲームだと、容量削減のためにパレット色変換で同じ元絵から、色違いのキャラ画像出したりしてましたよね。あれの発展形で、**R,G,Bの三原色の明暗を維持しつつ、違う色に置き換えるシェーダー**を書いてみました。
![huetransHouse.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/f13425a5-dff3-00c1-0fd1-c656ee35959f.jpeg)

下図は色変換のイメージです（変更後の色はサンプル画像のものとは異なります）。
![huetrans.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/deb02034-f309-261e-8413-2cfb999bdbbb.png)
通常このような色置換はパレット指定でやると思うんですが、このシェーダーのメリットは**パレット管理しなくてよい**こと。たった３色シェーダーに設定するだけで**同系色の明暗グラデーション置換ができる**し、一度覚えて貰えばデザイナーさんに描いてもらう場合のやりとりも楽です。
デメリットとしてはパレット置換に比べると**色相の異なる４色以上の変換ができない**ので自由度が減る場合がありますが、現実的には３系統の明暗グラデーション置換ができれば事足りる場合がほとんどだと思います。

元ネタはこれです。
http://www7b.biglobe.ne.jp/~nao8140/freetrain/patch.htm#rgb_trans
![](http://www7b.biglobe.ne.jp/~nao8140/freetrain/images/rgbTranshormSample.gif)

言葉で説明するとどうしても若干ややこしくなるんですが、RGBが (x,0,0),(0,y,0),(0,0,z) となるようなピクセルをそれぞれ、A,B,C の任意のカラーへ、元の明度に比例した明度で置換するわけです。

```
  赤系:(x,0,0) => （x*A.r, x*A.g, x*A.b）;
  緑系:(0,y,0) => （y*B.r, y*B.g, y*B.b）;
  青系:(0,0,z) => （z*C.r, z*C.g, z*C.b）;
  それ以外:(x,y,z) => (x,y,z); // 変化なし！
```

# シェーダー実装（基本編）

```glsl:HueTrans.shader
Shader "Custom/HueTrans"
{
    Properties
    {
        [NoScaleOffset] _MainTex ("Day Texture", 2D) = "white" {}

        _RedTransfar ("RED Transfer Color", Color) = (1,0,0,1)
        _GreenTransfar ("GREEN Transfer Color", Color) = (0,1,0,1)
        _BlueTransfar ("BLUE Transfer Color", Color) = (0,0,1,1)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            fixed3 _RedTransfar;
            fixed3 _GreenTransfar;
            fixed3 _BlueTransfar;


            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }
            
            inline fixed4 transferColor(in fixed4 srcCol) {
                // test srcCol if the two of r,g,b are 0.
                float d1 = (1 - srcCol.r) * (1 - srcCol.g) * (1 - srcCol.b);
                float d2 = srcCol.r + srcCol.g + srcCol.b;
                float flag = step(d1 + d2, 1);

                fixed3 rgb = lerp(
                    srcCol.rgb,
                    _RedTransfar * srcCol.r + _GreenTransfar * srcCol.g + _BlueTransfar * srcCol.b,
                    flag );

                return fixed4(rgb,1);
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);

                // stop rendering if it is transpalent.
                if(col.a < 0.0001) discard;

                // apply hue transform.
                col =  transferColor(col);

                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}
```

色置換で重要なのは transferColor 関数だけです

```glsl
    inline fixed4 transferColor(in fixed4 srcCol) {
       // test srcCol if the two of r,g,b are 0.
        float d1 = (1 - srcCol.r) * (1 - srcCol.g) * (1 - srcCol.b);
        float d2 = srcCol.r + srcCol.g + srcCol.b;
        float flag = step(d1 + d2, 1);

        fixed3 rgb = lerp(
            srcCol.rgb,
            _RedTransfar * srcCol.r + _GreenTransfar * srcCol.g + _BlueTransfar * srcCol.b,
            flag );

        return fixed4(rgb,1);
    }
```

ここだけで以下の変換を全て行っていることになります。意外とコンパクトに纏まってますよね（自画自賛）！？

```
  赤系:(x,0,0) => （x*A.r, x*A.g, x*A.b）;
  緑系:(0,y,0) => （y*B.r, y*B.g, y*B.b）;
  青系:(0,0,z) => （z*C.r, z*C.g, z*C.b）;
  それ以外:(x,y,z) => (x,y,z); // 変化なし！
```

シェーダーで使用している計算式は、（私が知らないだけでどこかの偉い人が発明済みかもしれませんが）自力で捻りだしたものですので参考資料とかはありません。

rgb の同時計算自体はわりとすぐに思い付いたので、それを利用できるような flag の算出方法を何とか捻りだそうと試行錯誤していて、偶然見つけたようなものです。そんな経緯なので、直感的にわかりやすい説明はなく、なぜこれでいいのか後付けで証明してみました。

一応、以下に詳しく解説しますが、自分でも眠くなりそうな長い説明なんで、興味のない方は読み飛ばしてください。

<details><summary>詳しい説明</summary><div>

最初の三行は **r,g,b のうち少なくとも二つが0である条件**を求めるためのものです。
`d1 + d2 > 1` を展開すると `rg + gb + br > rgb` という不等式ができますが、もし r,g,b のうち**二つ以上が0であれば**両辺共に0になってこれは**成り立ちません**。
一方で **r,g,b のうち少なくとも二つ以上が0でない**場合ですが、一つだけ0がある場合はすぐおわかりでしょう。全て0でない場合は、両辺 rgb で割って `1/b + 1/r + 1/g > 1` と変形するとr,g,b いずれも1以下であることから必ず成り立ちつことがわかります。
ゆえに、**r,g,b のうち少なくとも二つが0であるは `d1 + d2 == 1` であり、それ以外の場合は `d1 + d2 > 1` である**、と言えます。これを 0,1 値に変換したのが `float flag = step(d1 + d2, 1);` の行です。

その次の lerp 関数を使った式は、シェーダーでおなじみの**三項演算子的な使い方**をしてます。
`flag == 0` の時、rgb には元のピクセル値 (`srcCol.rgb`) が入り、 `flag == 1` の時は rgb には `_RedTransfar * srcCol.r + _GreenTransfar * srcCol.g + _BlueTransfar * srcCol.b` が入ります。
しかし `flag == 1` の時は r,g,b のうち少なくとも二つは0であるので、結局のところ `_RedTransfar * srcCol.r`、`_GreenTransfar * srcCol.g`、`_BlueTransfar * srcCol.b` のどれか1つのうち0でないものが入ります（※厳密には r,g,b 全て0の場合もこっちの計算が適用されるので、全て0になることもあるが、黒が書き出されるだけなので問題ない）。
例えば0でないのが r だったら、**_RedTransfar で指定した色に、元絵の r 値を乗じたものが書き出し色**になります。
</div></detail>


## 結果
四つ並んだ左上の画像が、色置換のない素の画像です。（[元ネタサイト](http://www7b.biglobe.ne.jp/~nao8140/freetrain/patch.htm#rgb_trans)の許可を得て画像を利用させていただいています）ほかの三つはそれぞれRGBを違う色に置換しています。上手くいきました。
![huetransHouse.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/f13425a5-dff3-00c1-0fd1-c656ee35959f.jpeg)


# 光沢も出したい（白混色対応）
自分としては、一応ここまでで十分目的は果たしたんですが、せっかく記事にしたのでもう少し発展させてみます。
このシェーダーの弱点は黒との混色だけで白との混色には対応できていません。
どういうことかと言うと、下記のような**光沢のある絵で使われる RGB と白との混色（ハイライト）が置換できない**ということです。

![hueTransSprite.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/e511a693-e911-e018-2f5b-772ed8bf1ce3.png)
上の絵を色置換したものが下の画像です。**ハイライト部分が置換できていない**のがわかります。
![huetrance-darken.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/ac34d67f-2266-edb1-e6ce-f292ceb414bd.jpeg)![huetrans-zoom.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/fd539d1c-4d8b-1bd6-666e-0d0fb257fefe.jpeg)

## シェーダー実装（拡張編）

今まで、黒混色のみ対応していたシェーダーを、同時に白混色にも対応させます。
下記は色変換のイメージ

|黒混色のみ|黒混色＋白混色|
|:--:|:--:|
|![huetrans.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/deb02034-f309-261e-8413-2cfb999bdbbb.png)|![huetrans-ex.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/04047165-ba79-c598-b1e0-8f10537402d4.png)|

修正は transferColor 関数だけなので、他は割愛します。

```glsl
    inline fixed4 transferColor(in fixed4 srcCol) {
        // test srcCol if the two of r,g,b are 0.
        float d1 = (1 - srcCol.r) * (1 - srcCol.g) * (1 - srcCol.b);
        float d2 = srcCol.r + srcCol.g + srcCol.b;
        float flag = step(d1 + d2, 1);

        fixed3 rgb = lerp(
            srcCol.rgb,
            _RedTransfar * srcCol.r + _GreenTransfar * srcCol.g + _BlueTransfar * srcCol.b,
            flag );

        fixed3 white = fixed3(1,1,1);
        flag = (1 - flag) * step(1, srcCol.r) * step(1, 1- abs(srcCol.g - srcCol.b));
        rgb = lerp(
            rgb,
            lerp(_RedTransfar, white, srcCol.g),
            flag);

        flag = (1 - flag) * step(1, srcCol.g) * step(1, 1- abs(srcCol.b - srcCol.r));
        rgb = lerp(
            rgb,
            lerp(_GreenTransfar, white, srcCol.b),
            flag);

        flag = (1 - flag) * step(1, srcCol.b) * step(1, 1- abs(srcCol.r - srcCol.g));
        rgb = lerp(
            rgb,
            lerp(_BlueTransfar, white, srcCol.r),
            flag);

        return fixed4(rgb,1);
    }
```

今回追加した白混色の置換処理は **r,g,bそれぞれに分けて三回計算**しています。
flag は `(1-flag)` で直前の式の else 条件とし、**r, g, b のどれか一つが1で、他の二つが等しい場合**に 1 になるようになっています。例えば赤系のハイライトなら (r,g,b) == (1,x,x) になるような色が対象です。この時の書き出し色は lerp 関数を使って `x * white + (1-x) * _RedTransfar` となります。

全体をまとめると、こんな感じの変換対応になります。

```
  赤系+黒:(x,0,0) => （x*A.r, x*A.g, x*A.b）;
  赤系+白:(1,x,x) => （x + (1-x)*A.r, x + (1-x)*A.g, x + (1-x)*A.b）;
  緑系+黒:(0,y,0) => （y*B.r, y*B.g, y*B.b）;
  緑系+白:(y,1,y) => （y + (1-y)*B.r, y + (1-y)*B.g, y + (1-y)*B.b）;
  青系+黒:(0,0,z) => （z*C.r, z*C.g, z*C.b）;
  青系+白:(z,z,1) => （z + (1-z)*C.r, z + (1-z)*C.g, z + (1-z)*C.b）;
  それ以外:(x,y,z) => (x,y,z); // 変化なし！
```

## 結果

ご覧の通り、ハイライト部分も色置換できるようになりました！
黄色、マゼンタ、シアンなどRGB系以外の色は影響を受けていないことも確認できました。
![huetrans-advanced.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/4384ca15-ae87-84a1-22e2-be633cf884b8.jpeg)

# 更なるコード効率化（改良編）
当初は上に挙げたコードで満足していましたが、**白混色ももっとスッキリ効率的な書き方ができるんじゃないか**と思って検討してみました。

そして**思いのほか上手く行った**ので下記に紹介します。

```glsl
    inline fixed4 transferColor(in fixed4 srcCol) {
        float lowest = min(srcCol.r, min(srcCol.g, srcCol.b));
        fixed3 hueCol = srcCol.rgb - lowest;
        fixed3 hilight = fixed3(lowest,lowest,lowest);

        // test srcCol if the two of r,g,b are 0.
        float d1 = (1 - hueCol.r) * (1 - hueCol.g) * (1 - hueCol.b);            
        float d2 = hueCol.r + hueCol.g + hueCol.b;                
        float flag = step(d1 + d2, 1);

        fixed3 rgb = lerp(
            srcCol.rgb,
            _RedTransfar * hueCol.r + _GreenTransfar * hueCol.g + _BlueTransfar * hueCol.b + hilight,
            flag );
              
        return fixed4(rgb,1);
    }
```

変数名の変更を除けば、実質的な計算は、黒混色だけの時から３行増えただけ＆変換後の色算出に１項増えただけで済みました。

<details><summary>詳しい説明</summary><div>

まず、追加された冒頭の三行について。
**lowest** は r,g,b のうちで最小値を求めています。最小値ですが、**黒混色も白混色も r,g,b のうち二つが最小値になる**はずです。
**hueCol** は元の色から最小値を引いた色です。この操作により、白混色の対象カラーも**黒混色と同じ (x,0,0),(0,y,0),(0,0,z) のどれか**に変わりますね！
**highlight** はスカラー値である lowest を fixed3 に変えただけです。RGBが等しいのでグレー系ですね。これは**色変換後に足すべき白混色の度合い**を意味します。

さて、その次の三行は黒混色の時と同じ条件判定用の計算ですね。先ほど述べたように、hueCol は黒変換と白変換で同じ条件式が使えるようになっているはずです。
メインの **rgb** 計算式は、 lerp の二つ目の引数式の最後に **+ highlight** が追加されていることに注目してください。これで白混色の場合は**元色と同じ割合だけ白成分が追加**されます。黒混色の場合 highlight は黒(0,0,0)なので何も影響はありません。
</div></detail>

# ご使用上の注意

今回ご紹介したシェーダーは、RGB値の厳密な比較を行ってますので、オリジナル画像の**RGB値が変わるような不可逆圧縮**や、隣のピクセルと**色が混じるような FilterMode は使えない**と思います。要するにPixelPerfectなドット絵前提ってことです。

お勧めは以下のように、**FilterMode を Pointにして Format を無圧縮にする**ことです。
![texture.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75035/8bfd3ff3-7801-7712-964d-bfa99c638bed.jpeg)



# 感想
最近ある程度シェーダーが書けるようになってきた気がします。
普段高級言語で開発してる時は、[リーダブルコード](https://www.oreilly.co.jp/books/9784873115658/)を意識して、些細な効率よりも見やすい書き方を心がけてるんですが、たまにこういった、**いかに効率的に書けるか**っていう挑戦も面白くて、いい刺激になりますね。
ただそれだけだと、半年もすれば**どうしてこんな式で動くのか自分でも忘れてしまいそう**なので、こうやって記事に残すのは備忘録としても役立ちそうです。