経緯
初期のドラ●エみたいな昔のゲームだと、容量削減のためにパレット色変換で同じ元絵から、色違いのキャラ画像出したりしてましたよね。あれの発展形で、R,G,Bの三原色の明暗を維持しつつ、違う色に置き換えるシェーダーを書いてみました。
下図は色変換のイメージです(変更後の色はサンプル画像のものとは異なります)。
通常このような色置換はパレット指定でやると思うんですが、このシェーダーのメリットはパレット管理しなくてよいこと。たった3色シェーダーに設定するだけで同系色の明暗グラデーション置換ができるし、一度覚えて貰えばデザイナーさんに描いてもらう場合のやりとりも楽です。
デメリットとしてはパレット置換に比べると色相の異なる4色以上の変換ができないので自由度が減る場合がありますが、現実的には3系統の明暗グラデーション置換ができれば事足りる場合がほとんどだと思います。
元ネタはこれです。
http://www7b.biglobe.ne.jp/~nao8140/freetrain/patch.htm#rgb_trans
言葉で説明するとどうしても若干ややこしくなるんですが、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); // 変化なし!
シェーダー実装(基本編)
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 関数だけです
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 の算出方法を何とか捻りだそうと試行錯誤していて、偶然見つけたようなものです。そんな経緯なので、直感的にわかりやすい説明はなく、なぜこれでいいのか後付けで証明してみました。
一応、以下に詳しく解説しますが、自分でも眠くなりそうな長い説明なんで、興味のない方は読み飛ばしてください。
詳しい説明
最初の三行は 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 値を乗じたものが書き出し色になります。
結果
四つ並んだ左上の画像が、色置換のない素の画像です。(元ネタサイトの許可を得て画像を利用させていただいています)ほかの三つはそれぞれRGBを違う色に置換しています。上手くいきました。
光沢も出したい(白混色対応)
自分としては、一応ここまでで十分目的は果たしたんですが、せっかく記事にしたのでもう少し発展させてみます。
このシェーダーの弱点は黒との混色だけで白との混色には対応できていません。
どういうことかと言うと、下記のような光沢のある絵で使われる RGB と白との混色(ハイライト)が置換できないということです。
上の絵を色置換したものが下の画像です。ハイライト部分が置換できていないのがわかります。
シェーダー実装(拡張編)
今まで、黒混色のみ対応していたシェーダーを、同時に白混色にも対応させます。
下記は色変換のイメージ
黒混色のみ | 黒混色+白混色 |
---|---|
修正は transferColor 関数だけなので、他は割愛します。
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系以外の色は影響を受けていないことも確認できました。
更なるコード効率化(改良編)
当初は上に挙げたコードで満足していましたが、白混色ももっとスッキリ効率的な書き方ができるんじゃないかと思って検討してみました。
そして思いのほか上手く行ったので下記に紹介します。
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);
}
変数名の変更を除けば、実質的な計算は、黒混色だけの時から3行増えただけ&変換後の色算出に1項増えただけで済みました。
詳しい説明
まず、追加された冒頭の三行について。
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)なので何も影響はありません。
ご使用上の注意
今回ご紹介したシェーダーは、RGB値の厳密な比較を行ってますので、オリジナル画像のRGB値が変わるような不可逆圧縮や、隣のピクセルと色が混じるような FilterMode は使えないと思います。要するにPixelPerfectなドット絵前提ってことです。
お勧めは以下のように、FilterMode を Pointにして Format を無圧縮にすることです。
感想
最近ある程度シェーダーが書けるようになってきた気がします。
普段高級言語で開発してる時は、リーダブルコードを意識して、些細な効率よりも見やすい書き方を心がけてるんですが、たまにこういった、いかに効率的に書けるかっていう挑戦も面白くて、いい刺激になりますね。
ただそれだけだと、半年もすればどうしてこんな式で動くのか自分でも忘れてしまいそうなので、こうやって記事に残すのは備忘録としても役立ちそうです。