シェーダでホログラムを再現してみる

こんにちは。プログラマの古矢です。

今回はUnityのシェーダプログラミングを行ってみました。
Unityもシェーダもほとんど経験がありませんが、以下のようなホログラム表現ができました。

どのように実現するか

ホログラムの原理はわかりませんが、光が偏って反射しているように見えます。
そこで私が最初に連想したのは法線マッピングです。

法線マッピングとは、ポリゴン表面に対して垂直な方向(法線ベクトル)を追加の画像(法線マップ)から与え、光の計算結果に手を加える手法です。
ポリゴン化すると細かすぎて処理負荷が高い凹凸を表現するときに使います。

今回は法線マップとしてホログラムのパターンを与え、さらに法線の解釈をシェーダプログラミングによってカスタマイズします。

法線マップの用意

法線マップは、以下のような画像をプログラムから生成しました。

同じような正方形が敷き詰められており、各ピクセルは小さな正方形の中心から離れる方向に、つまり放射状の単位ベクトルを持っています。
これらは全てXY平面上にあり、Zは全て0です。

シェーダの実装

それではシェーダを見てみましょう。
ただ、自分でも腑に落ちない部分があるので、どこか間違っているかもしれません。

Shader "Custom/HologramShader" {
    Properties {
        _PatternTex ("PatternTex", 2D)    = "white" {}
        _BaseColor  ("BaseColor",  Color) = (1, 1, 1, 1)
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200

        Pass {
            CGPROGRAM

            #include "UnityCG.cginc"
            #pragma vertex vert
            #pragma fragment frag

            sampler2D _PatternTex;
            float4    _BaseColor;
            float4    _LightColor0; // ライトの色が取得できるようです

            // 頂点シェーダからフラグメントシェーダへ渡す情報
            struct v2f {
                float4 pos        : SV_POSITION;
                float2 uv         : TEXCOORD0;
                float3 normal     : TEXCOORD1;
                float3 lightDir   : TEXCOORD2;
                float3 halfVector : TEXCOORD3;
            };

            // 頂点シェーダ
            v2f vert(appdata_tan v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv  = v.texcoord;

                // オブジェクト空間から接空間への変換マトリクス作成
                float3   normal     = normalize(v.normal);
                float3   tangent    = normalize(v.tangent);
                float3   binormal   = cross(normal, tangent);
                float3x3 localToTan = transpose(float3x3(tangent, binormal, normal));
                o.normal = mul(normal, localToTan);

                float3 localLightPos = mul(unity_WorldToObject, _WorldSpaceLightPos0);
                o.lightDir = normalize(mul(-localLightPos, localToTan));

                float3 localCameraPos = mul(unity_WorldToObject, _WorldSpaceCameraPos);
                float3 tanCameraDir   = normalize(mul(localCameraPos, localToTan));
                o.halfVector = normalize(o.lightDir + tanCameraDir);

                return o;
            }

            // フラグメントシェーダ
            float4 frag(v2f i) : SV_Target
            {
                float3 faceNormal    = normalize(i.normal);
                float3 patternNormal = UnpackNormal(tex2D(_PatternTex, i.uv));

                float3 lightDir     = normalize(i.lightDir);
                float3 halfVector   = normalize(i.halfVector);
                float3 halfVectorXY = normalize(float3(halfVector.x, halfVector.y, 0));
                // 逆光の場合など、halfVectroXYがゼロベクトルになることがあります

                float ambient  = 0.1;
                float diffuse  = max(0, dot(faceNormal, lightDir));
                float specular = max(0, dot(faceNormal, halfVector)) * abs(dot(patternNormal, halfVectorXY));
                specular = pow(specular, 4);

                return ((min(1, (ambient + diffuse)) - specular) * _BaseColor) + (specular * _LightColor0);
            }

            ENDCG
        }
    }
}

ディフューズ、スペキュラ、ハーフベクトル辺りは特別なことをしていません。
調べればほぼ理解できると思いますので、説明は割愛します。
ただし、アンビエント、ディフューズ、スペキュラの合成は調べずに式を立てたので、一般的ではないかもしれません。

さて、今回のポイントはフラグメントシェーダの以下の部分です。

float specular = max(0, dot(faceNormal, halfVector)) * abs(dot(patternNormal, halfVectorXY));

法線マップをそのまま法線として使わず、スペキュラ部分にのみ計算を追加しています。
スペキュラの分は、面法線とハーフベクトルの内積を用い、負数は0に丸めています。
法線マップの分は、Z=0の法線マップとハーフベクトルの内積の絶対値を用いています。

絶対値を用いているところが重要です。
前述の通り、法線マップは小さな四角形に対して放射状の単位ベクトルを持っていますが、絶対値を用いることで180°異なる法線を同値として扱えます。

実行結果

Unityのシーンはほぼ初期状態です。
マテリアルを追加し、作成したシェーダをアタッチ。
Inspectorから、マテリアルの法線マップ(PatternTex)には上で紹介したものを、下地の色(BaseColor)にはRGB(64, 64, 255)を設定。
そして、作成したマテリアルを平面に貼り付けました。

平面をY軸で回転

光源をY軸で回転

カメラをY軸で回転

残った課題

おおむね思った通りの画面が作れましたが、課題も残ってしまいました。

フラグメントシェーダ中のhalfVectorXYを算出する際、halfVectorのYを0にすることでゼロベクトルになってしまう可能性があります。
ゼロベクトルを正規化しようとしてもエラーにならなかったようですが、halfVectorXYがどうなるかわからないので対応した方がよさそうです。

また、他のオブジェクトを置くとホログラムが崩れることがあるようです。
これは単純にUnityかシェーダの使い方を間違えているのかもしれません。

まだまだ勉強が必要そうですね。