WonderPlanet DEVELOPER BLOG

ワンダープラネットの開発者ブログです。モバイルゲーム開発情報を発信。

Unityでディザリングシェーダを作ってみた

こんにちは、エンジニアの成田です。今回はUnityのシェーダを使ってちょっと遊んでみましょう!
先日、PCインディーズゲーム界隈でこのようなゲームが発表されていました。

作者の説明によればUnityで作られているらしいのですが、やはり目を引くのは"1bit風"グラフィックスでしょう。人によってはApple IIとかPC-88時代のゲームを思い出すのではないでしょうか。

この1bit風グラフィックスを特徴付けているのはディザリングと呼ばれる画像技法です。今回はUnityのシェーダを使ってディザリングを再現してみました。
device-2014-06-10-231904

1.ディザリングって何よ?

まず、スマートフォンのテザリングが思い浮かんだ人。残念ながら全く関係ありません。
ディザリングとは、簡単に言うと「限られた色数でより多くの"見かけ上の色"を表現する技法」です。
次の画像を見てください。

gradation_256
この画像は左端が黒で右端が白であるグラデーションになっています。各ピクセルの色は(R,G,B)=(0,0,0)から(R,G,B)=(255,255,255)へと次第に変化していきます。無彩色ですのでR・G・Bの各値は同値ですね。この時、この画像に使われている色の数は256色です。

さて、この画像を黒と白の2色で再現することを考えてみます。
最も単純な戦略としては、RGB値を3次元空間と見なして各ピクセルの色から黒か白の距離(ユークリッド距離)の近い方を選択することです。これを2値化と言います。2値化するとこうなります。

binarize
……これをグラデーションと言える人は恐らく居ないと思います。グラデーションを表現するためには黒と白の間の色調(中間色)を表現することは避けられません。中間色はどうしたら表現できるでしょうか?

さて、ここで役立つのが人間の錯視です。人間の目は十分に小さなサイズの複数色が交互に置かれた時、これらを混合色として知覚することができます。
次の画像を(ちょっと離れて)見てください。

gradation_2
いかがでしょう、この画像も黒と白の2色しか使われていませんがグラデーションのように見えると思います。
これがディザリングです。

以前のPCやディスプレイは今ほど多彩な色は出せませんでした。8色だったりモノによっては2色しか出せないこともありましたが、そのような環境でもゲームなどのマルチメディアで綺麗なグラフィックを表示するため、ディザリングがよく用いられていました。

ちなみにディザリングに近い技術としては、印刷業界ではハーフトーンというテクニックが大昔から存在しています。他にも鉛筆デッサンでも線の密度で濃淡を表現することができますよね。

2.アルゴリズム

ディザリングアルゴリズムは色々と存在しますが、オールドスタイルのPCゲームで用いられていたのは主に網掛けパターンによるディザリングでした。ですのでここでは同じようなアウトプットが得られる組織的ディザリング(Ordered dithering)を採用します。

組織的ディザリングは画像に対して適用する比較的単純なアルゴリズムです。まず特定の閾値マトリックスを定義し、画像全体をブロックに分割します。各ブロック内ではピクセルの色と対応する閾値マトリックス成分値との比較を行い、そのピクセルにドットを打つか打たないかを決めていきます。具体的に見て行きましょう。

組織的ディザリングで用いられる閾値マトリックスにも色々な種類がありますが、ここではオーソドックスなBayerマトリックスを用います。
Bayerマトリックスとは次のようなものです。
matrix

ちょっと4x4マトリックスに注目してみましょう。マトリックスの成分には1から16の数字が振られています。このマトリックスでは閾値16段階と閾値を超えなかった時の真っ黒の場合とで17段階の階調を作り出すことができます。

このマトリックスを画像に適用します。ここでは簡単のため原画像をグレースケールと仮定しますので、各ピクセルの明度(=RGBのうち最大値)だけ考えます。明度vが取り得る値は(0 <= v <= 255)です。
閾値マトリックスは0から1を取る値に対して用いますので、明度と値域を合わせるために閾値マトリックスを255倍するとこうなります。

matrix_255
解りやすくなったのではないでしょうか。あとは画像を4x4ピクセルのブロックに分割して、ブロック内の各ピクセルの明度と閾値を比較するだけです。

アルゴリズム適用前のブロックの色と適用後の網掛けパターンの対応を直感的に表すとこんな感じでしょうか?
taiou

では、このアルゴリズムをUnityのシェーダで実装してみましょう!

3.シェーダの実装

今回はUnity無料版を利用しますので、ディザリングはポストエフェクトとしてでなくサーフェイスシェーダの一部として実装しましょう。

3D空間には直接ディザリングアルゴリズムは適用できませんので、まず基本的なシェーディングをUnityのデフォルト実装に任せ、最後の最終色モディファイアにて各ピクセルからスクリーン座標を計算し、これに対してアルゴリズムを適用することにします。入力値が閾値を超えた場合は画像のピクセル上でドットを打つ代わりに対応するポリゴン上のピクセルの最終色を変えてやることでディザを表現します。

閾値マトリックスについてはテクスチャで与えることにします。次のファイルを右クリックから保存してください。

bayer.png

このテクスチャは、下の画像のように座標(u,v)のピクセルのRGB値が、閾値マトリックスのv+1行u+1列目の成分値と一致するようなものになっています。
bayer_desc

拡大するとこんな感じ

シェーダプログラムは次の通りです。マテリアルインスペクタで"Dither Matrix"にbayer.pngをセットすることを忘れないで下さいね。

Shader "Custom/OrderedDithering" {  
    Properties {  
        _MainTex ("Base (RGB)", 2D) = "white" {}  
        _MatrixWidth ("Dither Matrix Width/Height", int) = 4  
        _MatrixTex ("Dither Matrix", 2D) = "black" {}  
    }  
    SubShader {  
        Tags { "RenderType"="Opaque" }  
        LOD 200  
           
        CGPROGRAM  
        #pragma surface surf Lambert vertex:vert finalcolor:mycolor  
           
        sampler2D _MainTex;  
        int _MatrixWidth;  
        sampler2D _MatrixTex;  
   
        struct Input {  
            float2 uv_MainTex;  
            float4 scrPos;  
        };  
   
        void vert (inout appdata_full v, out Input o) {  
            UNITY_INITIALIZE_OUTPUT(Input,o);  
            float4 pos = mul (UNITY_MATRIX_MVP, v.vertex);  
            o.scrPos = ComputeScreenPos(pos);  
        }  
   
        void surf (Input IN, inout SurfaceOutput o) {  
            half4 c = tex2D (_MainTex, IN.uv_MainTex);  
            o.Albedo = c.rgb;  
            o.Alpha = c.a;  
        }  
   
        void mycolor (Input IN, SurfaceOutput o, inout fixed4 color) {  
            // RGB -> HSV 変換  
            float value = max(color.r, max(color.g, color.b));  
               
            // スクリーン平面に対してマトリックステクスチャを敷き詰める  
            float2 uv_MatrixTex = IN.scrPos.xy / IN.scrPos.w * _ScreenParams.xy / _MatrixWidth;  
               
            float threshold = tex2D(_MatrixTex, uv_MatrixTex).r;  
            fixed3 binary = ceil(value - threshold);  
            color.rgb = binary;  
            color.a = 1.0f;  
        }  
        ENDCG  
    }  
    FallBack "Diffuse"  
}  

最後にこのシェーダを実際にUnityで使う際に、PCゲームっぽさを出すために画面解像度を1/4程度に抑えます。
解像度が高いと冒頭で述べたように人間はディザを綺麗な中間色と知覚してしまいます。本来はそちらの方が良いのですが、今回は演出としてのディザですので……。

Screen.SetResolution( Screen.width*0.25f, Screen.height*0.25f, true);  

また、コントラストが高い絵の方が映えますのでアンビエントは切ってあります。

Android実機で動かしてみました。
comparison
左がUnityのDiffuseシェーダ、右が今回実装したディザリングシェーダの出力です。
とりあえずそれっぽいものが出せました。

他にも4x4マトリックスの代わりに2x2や3x3マトリックスを用いて出力される網掛けパターンを変えたり、マトリックスの成分値を傾斜させることで最終的なコントラストを変更したりすることができます。いろいろ遊んでみてください。