徳島ゲーム開発ごっこ 技術ブログ

ゲームを作るために役に立ったり立たなかったりする技術を学んでいきます!

【Unity】描画をずらすシェーダーのバグを直す

 新しいことにチャレンジするって素敵だなって思うんだ。
 だって、新しい発見や出会いが自分をもっと素敵な自分へと導いてくれる気がするから。
 でも新しいことにチャレンジして失敗してしまったらどうしようと思う人もいるかもしれないね。
 もしかしたら、「二度とこんなことやらないよ!」なんて思ってしまうかも。
 そんな悲しいことは言わないで。新しいチャレンジした自分を褒めてあげてほしいんだ。
 さあ、君も新しいことにチャレンジしてみよう!

 というわけで先日、今まであまり触れてこなかったシェーダーの勉強をして、自作シェーダーを作ってみた。
 だが、使ってみるとバグが見つかってしまった。
 新しいことにチャレンジしてみたけど、どうやら失敗してしまったみたいだ。
 うん。
 俺は二度とシェーダーはやらん!


この記事にはUnity2017.3.0f3を使用しています。

問題が発生しました

 これだからよく知らない分野のことをやるのは嫌だったんだよ!
 ただ、記事として書いてしまった以上、バグを直すか、ブログの記事を消すかの二択になるわけだけど、まあ折角なのでバグを修正していこう。

 さて、問題のシェーダがこれだ。

www.urablog.xyz

Doppelganger.shader

Shader "Custom/Doppelganger"
{
    Properties
    {
        _MainTex ("Texture", 2D)            = "white" {}
        _Color("Color (RGB)", Color)        = (1, 1, 1, 1)
        _Offset("Offset Position", Vector)  = (0, 0, 0, 0)
    }
    SubShader
    {
        Pass
        {
            Tags
            {
                "LightMode" = "ForwardBase"
            }

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

            struct v2f
            {
                half4 position  : SV_POSITION;
                half2 uv        : TEXCOORD0;
                half3 normal    : TEXCOORD1;
            };

            sampler2D   _MainTex;
            half4       _MainTex_ST;
            fixed4      _Color;
            half4       _Offset;
            
            v2f vert( appdata_base v )
            {
                v.vertex.xyz += _Offset;

                v2f o       = (v2f)0;
                o.position  = UnityObjectToClipPos( v.vertex );
                o.uv        = TRANSFORM_TEX( v.texcoord, _MainTex );
                o.normal    = UnityObjectToWorldNormal( v.normal );
                return o;
            }
            
            fixed4 frag( v2f i ) : SV_Target
            {
                i.normal    = normalize( i.normal );

                fixed4 color        = (fixed4)0;
                fixed4 tex          = tex2D( _MainTex, i.uv );
                fixed3 lightColor   = max(0, dot(i.normal, _WorldSpaceLightPos0.xyz)) * _LightColor0.rgb + ShadeSH9( fixed4( i.normal, 1 ) );

                color.rgb = tex.rgb * _Color.rgb * lightColor;
                return color;
            }

            ENDCG
        }

        Pass
        {
            Tags
            {
                "LightMode" = "ShadowCaster"
            }

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            #pragma multi_compile_shadowcaster
            #pragma multi_compile_instancing

            #include "UnityCG.cginc"

            struct v2f
            {
                V2F_SHADOW_CASTER;
                UNITY_VERTEX_OUTPUT_STEREO
            };

            half4   _Offset;

            v2f vert( appdata_base v )
            {
                v.vertex.xyz += _Offset;

                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
                return o;
            }

            float4 frag( v2f i ) : SV_Target
            {
                SHADOW_CASTER_FRAGMENT(i)
            }

            ENDCG
        }
    }
}

 このシェーダーのプロパティにあるオフセット値に適当な値を設定すると、実際のオブジェクトの位置から描画位置をずらすことが出来るんだ。

f:id:urahimono:20180124235705p:plain

 うん、ずれちゃいるんだよ。そこはいいんだよ。
 問題はこのオブジェクトに回転や拡縮の値を変更することで問題が発生してしまうのさ。

 まずは回転させてみよう。

f:id:urahimono:20180124235724g:plain
f:id:urahimono:20180124235804p:plain

 変な挙動をしやがる。
 こちらの意図した動きとしては、描画位置はシェーダーで指定したオフセット分だけずれた場所にあり、その場でオブジェクトが回転しているようなものを想定している。
 それなのにこの状況はなんだ!
 実際のオブジェクトの位置を中心にぐるんぐるん位置ごと回っておるではないか!

 スケールを変えたらこうなる。

f:id:urahimono:20180124235827g:plain
f:id:urahimono:20180124235903p:plain

 ずれる位置のオフセットもスケールによって変わっておるではないか。

 はぁー……、困ったもんだ。

頂点シェーダーを見直す

 とりあえず原因を探してみようか。
 作ったシェーダーのオブジェクト描画パスの頂点シェーダー辺りを調べていこう。

Doppelganger.shader

v2f vert( appdata_base v )
{
    // 頂点位置をオフセット分ずらす。
    v.vertex.xyz += _Offset;

    v2f o       = (v2f)0;
    o.position  = UnityObjectToClipPos( v.vertex );
    o.uv        = TRANSFORM_TEX( v.texcoord, _MainTex );
    o.normal    = UnityObjectToWorldNormal( v.normal );
    return o;
}

 あー……、オフセットの値を加算するのが、ちと早い気がするさ。
 UnityObjectToClipPos()で計算した後の座標に対してオフセット値を加算するようにしてみようか。

Doppelganger.shader

v2f vert( appdata_base v )
{
    v2f o       = (v2f)0;

    o.position  = UnityObjectToClipPos( v.vertex );

    // 計算後の位置をオフセット分ずらす。
    o.position.xyz += _Offset;

    o.uv        = TRANSFORM_TEX( v.texcoord, _MainTex );
    o.normal    = UnityObjectToWorldNormal( v.normal );
    return o;
}

f:id:urahimono:20180124235945p:plain

 うん、いいんじゃね。
 おっ、あっさり解決したよ。
 よかった、よかった。

オフセット値を加算するタイミング

 ふむ、だけど若干位置が指定したオフセット分移動していない気がするのだが。
 描画予定位置に別のオブジェクトを置いてみよう。

f:id:urahimono:20180125000015p:plain

 若干位置が違う。
 いや、これぐらい誤差の範囲でしょ!
 ……と言いたいところだが、問題になりそうだ。

 そもそもUnityObjectToClipPos()は何をしているというのさ。

docs.unity3d.com

同次座標において、オブジェクト空間からカメラのクリップ空間へ点を変換します。これは、mul(UNITY_MATRIX_MVP, float4(pos, 1.0)) と同等で、その場所で使用されます。

 なるほど、この計算後の座標はワールド座標ではなくクリップ空間座標だと。
 そして最初の場合は、モデルのローカル座標に対してオフセット値を足してしまっているから問題になるわけかね。
 ちっ、面倒くさい。
 とりあえず、このUnityObjectToClipPos()のヘルパー関数のままでは駄目だ。
 一度mul()を使う計算式に変更してしまった方がよさそうだ。

Doppelganger.shader

v2f vert( appdata_base v )
{
    v2f o       = (v2f)0;

    // UnityObjectToClipPos()の処理をmul()を使う形に変換
    // o.position  = UnityObjectToClipPos( v.vertex );
    o.position  = mul( UNITY_MATRIX_MVP, v.vertex );

    o.position.xyz += _Offset;

    o.uv        = TRANSFORM_TEX( v.texcoord, _MainTex );
    o.normal    = UnityObjectToWorldNormal( v.normal );
    return o;
}

 そして、このままでも駄目だ。
 UNITY_MATRIX_MVPでは、モデル行列・ビュー行列・射影行列の各乗算が一緒に行われてしまっている。
 オフセットの値を加算するタイミングは、モデル行列の乗算が終わり、ビュー行列の乗算が行われる前にする必要がありそうだ。
 そのため更に計算式の分解が必要だ。

qiita.com

Doppelganger.shader

v2f vert( appdata_base v )
{
    v2f o       = (v2f)0;

    // モデル行列計算。
    o.position = mul( unity_ObjectToWorld, v.vertex );

    // きっと今ならワールド座標っぽいものになっているはずだ!
    // このタイミングでオフセットを加算。
    o.position.xyz += _Offset;

    // ビュー行列・射影行列計算。
    o.position = mul( UNITY_MATRIX_VP, o.position );

    o.uv        = TRANSFORM_TEX( v.texcoord, _MainTex );
    o.normal    = UnityObjectToWorldNormal( v.normal );
    return o;
}

 これならどうだろうか。
 いいタイミングでオフセット値を加算できた気がする。

f:id:urahimono:20180125000044g:plain
f:id:urahimono:20180125000118g:plain
f:id:urahimono:20180125000146p:plain

 よっしゃ、今度こそうまくいったぜ。
 影がまだ直ってないけどな!

影の頂点シェーダーも修正する

 さーて、この調子で影の頂点シェーダー部分も修正していこう。

Doppelganger.shader

v2f vert( appdata_base v )
{
    // オフセットを加算するのが早すぎる!
    v.vertex.xyz += _Offset;

  // 以下はビルドインシェーダーから持ってきただけなので詳しくは知らない。
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
    return o;
}

 あー……、そうだった。
 影の部分の処理はよくわかんなかったから、ビルドインシェーダーの影の処理をそのまま持ってきただけだった。

 どこかの処理で行列計算を行っているところがあるはずだ。
 それをまず突き止めよう。

 うむ、恐らく名前にとらんすふぁーと書いているからTRANSFER_SHADOW_CASTER_NORMALOFFSET()が行列計算であろうと俺の灰色の脳細胞が告げておる。
 だが、これに関するいい感じドキュメントない。

 やむ得まい、とりあえずUnityフォルダ内のUnityCG.cgincの中でも検索してみるか……。
 おっと、何かあったぞ。

UnityCG.cginc

#define TRANSFER_SHADOW_CASTER_NORMALOFFSET(o) TRANSFER_SHADOW_CASTER_NOPOS(o,o.pos)

 どうやらTRANSFER_SHADOW_CASTER_NORMALOFFSET()TRANSFER_SHADOW_CASTER_NOPOS(o,o.pos)変換されるようだ。
 まだマクロじゃねぇか。
 Ok、引き続き検索だ。

UnityCG.cginc

#if defined(SHADOWS_CUBE) && !defined(SHADOWS_CUBE_IN_DEPTH_TEX)
    // Rendering into point light (cubemap) shadows
    #define V2F_SHADOW_CASTER_NOPOS float3 vec : TEXCOORD0;
    #define TRANSFER_SHADOW_CASTER_NOPOS_LEGACY(o,opos) o.vec = mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz; opos = UnityObjectToClipPos(v.vertex);
    #define TRANSFER_SHADOW_CASTER_NOPOS(o,opos) o.vec = mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz; opos = UnityObjectToClipPos(v.vertex);
    #define SHADOW_CASTER_FRAGMENT(i) return UnityEncodeCubeShadowDepth ((length(i.vec) + unity_LightShadowBias.x) * _LightPositionRange.w);

#else
    // Rendering into directional or spot light shadows
    #define V2F_SHADOW_CASTER_NOPOS
    // Let embedding code know that V2F_SHADOW_CASTER_NOPOS is empty; so that it can workaround
    // empty structs that could possibly be produced.
    #define V2F_SHADOW_CASTER_NOPOS_IS_EMPTY
    #define TRANSFER_SHADOW_CASTER_NOPOS_LEGACY(o,opos) \
        opos = UnityObjectToClipPos(v.vertex.xyz); \
        opos = UnityApplyLinearShadowBias(opos);
    #define TRANSFER_SHADOW_CASTER_NOPOS(o,opos) \
        opos = UnityClipSpaceShadowCasterPos(v.vertex, v.normal); \
        opos = UnityApplyLinearShadowBias(opos);
    #define SHADOW_CASTER_FRAGMENT(i) return 0;
#endif

 おおぅ、何か面倒くさいことになっておる。
 どうやらSHADOWS_CUBEの宣言の有無によって処理が変わっちまうようだね。
 しかもSHADOWS_CUBEの情報がググってもあんまり出てこない。

 今の俺の能力ではSHADOWS_CUBEが何なのか解明できそうにない。
 やむ得まい、勘だ!
 きっとSHADOWS_CUBEは宣言していないと思いマース。

UnityCG.cginc

#define TRANSFER_SHADOW_CASTER_NOPOS_LEGACY(o,opos) \
    opos = UnityObjectToClipPos(v.vertex.xyz); \
    opos = UnityApplyLinearShadowBias(opos);

 ううー……、目的地が遠いなぁ。
 次はUnityClipSpaceShadowCasterPos()を探せばいいのね。

UnityCG.cginc

float4 UnityClipSpaceShadowCasterPos(float4 vertex, float3 normal)
{
    float4 wPos = mul(unity_ObjectToWorld, vertex);

    if (unity_LightShadowBias.z != 0.0)
    {
        float3 wNormal = UnityObjectToWorldNormal(normal);
        float3 wLight = normalize(UnityWorldSpaceLightDir(wPos.xyz));

        // apply normal offset bias (inset position along the normal)
        // bias needs to be scaled by sine between normal and light direction
        // (http://the-witness.net/news/2013/09/shadow-mapping-summary-part-1/)
        //
        // unity_LightShadowBias.z contains user-specified normal offset amount
        // scaled by world space texel size.

        float shadowCos = dot(wNormal, wLight);
        float shadowSine = sqrt(1-shadowCos*shadowCos);
        float normalBias = unity_LightShadowBias.z * shadowSine;

        wPos.xyz -= wNormal * normalBias;
    }

    return mul(UNITY_MATRIX_VP, wPos);
}

 ようやく行列計算処理にたどり着けたぜ。
 それにしてもシェーダー内でif()を使ってるの初めてみた。
 何してんのかな。
 とにかくこの関数の処理を移植して、いい感じのタイミングでオフセット値を加算する処理を追加してみよう。

Doppelganger.shader

v2f vert( appdata_base v )
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    o.pos  = mul( unity_ObjectToWorld, v.vertex );
    
    // 急げ!
    // 今のうちにオフセットを加算するんだ!
    o.pos += _Offset;

    // if()が何をしているかは知らんが、とりあえずUnityClipSpaceShadowCasterPos()をごっそり持ってくる。
    if( unity_LightShadowBias.z != 0.0 )
    {
        float3 wNormal      = UnityObjectToWorldNormal(v.normal);
        float3 wLight       = normalize(UnityWorldSpaceLightDir(o.pos.xyz));
        float  shadowCos    = dot(wNormal, wLight);
        float  shadowSine   = sqrt(1-shadowCos*shadowCos);
        float  normalBias   = unity_LightShadowBias.z * shadowSine;

        o.pos.xyz -= wNormal * normalBias;
    }

    o.pos = mul( UNITY_MATRIX_VP, o.pos );

    return o;
}

 これならどうだ!

f:id:urahimono:20180125000223p:plain

 影も想定した挙動し始めた。
 やった!

 しかし明らかに影の挙動とかよくわからずに使っているなぁ。
 俺がシェーダーを扱うのは十年ぐらい早かったのかもしれない。