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

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

【Unity】一つのGameObjectで複数の描画をあなたに

 僕は人生においてまだ自分のドッペルゲンガーに会ったことがない。
 まあドッペルゲンガーに会ったら死ぬらしいから会いたくはないけど。

 そして、僕はまだ分身することが出来ない。
 僕の中には十三尾が封印されているはずなので、チャクラは足りるはずなんだけどそれでも分身が出来ない。

 そんな山も谷もない人生で出来ていないことを、Unity上でやってみよう。
 僕の人生に何かを期待するより、ゲームでも作っていたほうがいいや。


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

はじめに

 さて、オブジェクトを複数描画したいならGameObjectが複数あればこと足りるものです
 だが、今回作るものはドッペルゲンガーや分身のような実態のないものだ。
 そういうものに、GameObjectのような実態があるのはいかがなものか。
 ……うん、別に誰が決めたわけではないんだけど。

 今回は一つのGameObjectで複数のオブジェクトを描画することでドッペルゲンガーを表現してみよう。

 ということは、シェーダーを使って何とかしてみることになりそうだ。
 よーし、シェーダーを勉強していくぞー!

シンプルなDiffuseシェーダーを作る

 さて、シェーダーを一から作るのは面倒くさい大変なので、Unityの内蔵シェーダーを改良して作りたいものだ。
 一番一般的なDiffuseから作っていきたい。
 しかし、Unityの内蔵シェーダーにあるDiffuseシェーダーはサーフェイスシェーダーを使っている。
 サーフェイスシェーダーから何か改良しようとするといろいろと面倒くさい大変なので、頂点シェーダーとフラグメントシェーダーを使う形に変更したい。
 しかし、それもやっぱり面倒くさい大変だ。

 はぁー……、誰かそういうことやってくれてないかなー。
 (五、六行目前にはあったシェーダーに対するやる気は既に風前の灯となってしまった。)

 調べたところ、Diffuseシェーダーを一から作っているサイトを見つけてしまった。
 うん、誰かが既にやっているのなら仕方ない。参考にさせていただこう。

blog.applibot.co.jp

Doppelganger.shader

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

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

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

            sampler2D   _MainTex;
            half4       _MainTex_ST;
            fixed4      _Color;
            fixed4      _LightColor0;
            
            v2f vert( appdata_base v )
            {
                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;

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

            ENDCG
        }
    }
}

f:id:urahimono:20180114233545p:plain

 これでもいいんだけど、Unityの内蔵シェーダーに比べると色味が若干違う。
 これはシーン内の環境光を計算に加えてないからだねー。
 折角なので、それぐらいの処理は追加しておこう。
 ええっと、確かドキュメントにその辺の処理の書き方が書いてあった気が。

docs.unity3d.com

Doppelganger.shader

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
// 環境光計算のためにShadeSH9を使うため追加。
#include "UnityLightingCommon.cginc"

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

sampler2D   _MainTex;
half4       _MainTex_ST;
fixed4      _Color;
fixed4      _LightColor0;

v2f vert( appdata_base v )
{
    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

 なんかエラーが出たんだが。

Shader error in 'Custom/Doppelganger': redefinition of '_LightColor0' at line 35 (on d3d11)

 あー、なるほど。
 どうやら_LightColor0UnityLightingCommon.cginc内で宣言されているようだ。
 というわけで、_LightColor0の変数宣言は削除だ。

Doppelganger.shader

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
// 環境光計算のためにShadeSH9を使うため追加。
#include "UnityLightingCommon.cginc"

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

sampler2D   _MainTex;
half4       _MainTex_ST;
fixed4      _Color;

// UnityLightingCommon.cgincで既に宣言されている。
// fixed4      _LightColor0;

v2f vert( appdata_base v )
{
    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

f:id:urahimono:20180114233600p:plain

 OK。
 内蔵シェーダーと色味が似てきた。
 あとは床に影が落ちてないぐらいだが、これもパパっと追加してしまおう。
 適当な影描画のPassをUsePassを使えばすぐに出来るはずだ。

Doppelganger.shader

SubShader
{
    Pass
    {
        // 先ほどのDiffuse処理
        ...
    }

    // StandardShaderの影の描画処理を利用する。
    UsePass "Legacy Shaders/VertexLit/SHADOWCASTER"
}

f:id:urahimono:20180114233615p:plain

 見た目上はほとんど同じものになりましたー。
 ただ、このシェーダーでは自分自身に落ちる影の計算処理は記述していないので、ライトを下の方から当てるとこうなっちゃう。

f:id:urahimono:20180114233646p:plain

 内蔵シェーダーは床がライトを遮るので暗くなっているけど、自作シェーダーは下のほうが明るくなっている。
 まぁそろそろ面倒くさくなってきたので、ここらへんでいいや。
 本題に入ろう。

描画する頂点位置をずらしてみよう

 実体のないドッペルゲンガーや分身を表現するために、実際のオブジェクトの位置と違う場所にオブジェクトが描画されるようにシェーダーを改良してみよう。

 これにはエディタ上でオフセットプロパティを設定できるようにして、頂点シェーダーで渡された頂点位置をオフセット分ずらすようにしてみれば、それっぽくなるのではないだろうか。
 試してみよう。

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
        }

        UsePass "Legacy Shaders/VertexLit/SHADOWCASTER"
    }
}

f:id:urahimono:20180114233700p:plain

 あー、うん。
 ずれたね。確かにオブジェクトの位置はずれているね。
 影は元に位置にあるっぽいがね!

 あー……、パパっと出来るから組み込んだ影に足を引っ張られたかぁ。
 仕方ない、UsePassで参考にしているシェーダーの処理をそのまま持ってきて、同じようにオフセット分ずらすようにしてみようか。

Doppelganger.shader

SubShader
{
    Pass
    {
        // 先ほどのDiffuse処理
        ...
    }

    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:20180114233712p:plain

 おおっ、いいんじゃない。
 ただ、このままではオブジェクトの描画位置をずらしただけ。
 別に複数描画はされてない。
 ここからさらに改良が必要だ。

いっぱいドッペルゲンガーを作ってみよう

 オブジェクトをずらして描画するパスの処理が作れたのだから、あとは普通に描画するPassを付け加えてあげれば複数個描画されるようになるはずだ。
 もっと多くのオブジェクトを描画したければ、もっとPassを追加してやればいい!

 ……本気でやる気か、そんな面倒くさいこと。

 しかもPassを動的に増やすことは出来んような気がするので、二つドッペルゲンガーには二つドッペルゲンガーを作る用のシェーダーを、三つドッペルゲンガーには三つドッペルゲンガーを作る用のシェーダーを、という話になってくる。
 すなわちこうなる。

f:id:urahimono:20180114233730p:plain

 これはひどい……。

 うーん、どうしようかなぁ。
 マテリアルをいっぱい作って設定するというのはどうだろう。
 Rendererのマテリアル設定にオフセットの位置をずらしたマテリアルを複数指定してみよう。

f:id:urahimono:20180114233745p:plain

 わーい、念願のドッペルゲンガーに会えたよー。
 めっさWarning出とるけど。

スクリプトでマテリアルのパラメーターを設定する

 しかし、これではマテリアルが山ほど必要になってくる。
 それも管理が面倒だ。
 スクリプトでマテリアルのプロパティを設定するようにしてみよう。
 これならマテリアルは一つで十分なはずだ。

Doppelganger.cs

using UnityEngine;

public class Doppelganger : MonoBehaviour
{
    private readonly int OFFSET_ID  = Shader.PropertyToID( "_Offset" );

    [SerializeField]
    private Material    m_material  = null;
    [SerializeField]
    private Vector4[]   m_offsets   = null;

    private void Start()
    {
        if( m_material == null )
        {
            return;
        }

        if( m_offsets == null || m_offsets.Length == 0 )
        {
            return;
        }

        var renderer    = GetComponent<Renderer>();
        if( renderer == null )
        {
            return;
        }

        var materials   = new Material[ m_offsets.Length ];
        for( int i = 0; i < materials.Length; ++i )
        {
            materials[ i ]  = new Material( m_material );
            materials[ i ].SetVector( OFFSET_ID, m_offsets[ i ] );
        }

        renderer.materials = materials;
    }

} // class Doppelganger

f:id:urahimono:20180114233853p:plain

 これで同じように描画されるようになるのだけど、これはこれで問題になってくる。
 スクリプトでパラメーターを変えているので、ランタイム実行時しか描画が確認できなくなってしまう。
 それはそれで嫌だなー。

 ランタイム時以外でも描画状態は確認できるようにしてしまおう。
 確かそんなアトリビュートがあったような気がする。ExecuteInEditModeだっけ?

docs.unity3d.com

 あと、エディタ上でも動くのならば、Start()よりOnEnable()の方が都合がよさそうだなぁ。
 エディタ上でコンポーネントのチェックボックスを切り替えるだけで呼び出せるようになりそうだし。

Doppelganger.cs

using UnityEngine;

[ExecuteInEditMode]
public class Doppelganger : MonoBehaviour
{
    private readonly int OFFSET_ID  = Shader.PropertyToID( "_Offset" );

    [SerializeField]
    private Material    m_material  = null;
    [SerializeField]
    private Vector4[]   m_offsets   = null;

    private void OnEnable()
    {
        if( m_material == null )
        {
            return;
        }

        if( m_offsets == null || m_offsets.Length == 0 )
        {
            return;
        }

        var renderer    = GetComponent<Renderer>();
        if( renderer == null )
        {
            return;
        }

        // EnableになるたびにMaterialを再生成しまくるのはどうかと思うが……。

        var materials   = new Material[ m_offsets.Length ];
        for( int i = 0; i < materials.Length; ++i )
        {
            materials[ i ]  = new Material( m_material );
            materials[ i ].SetVector( OFFSET_ID, m_offsets[ i ] );
        }

        renderer.materials = materials;
    }

} // class Doppelganger

 これでエディタ上でも描画を確認できるようになるんだけど、このようなメッセージが表示されちゃう。

Instantiating material due to calling renderer.material during edit mode. This will leak materials into the scene. You most likely want to use renderer.sharedMaterial instead.
UnityEngine.Renderer:get_materials()

 シーンにマテリアルがリークすっから、sharedMaterialの方がよくね? とのことだ。

 うーん、その忠告はありがたいのだが、プロパティの値は違うからsharedMaterialを使うわけには……。

カメラに映らないとき

2018/01/20 追記
 本来オブジェクトが描画されるあたりの位置がカメラの視錐台から外れたら、このシェーダーの描画がされなくなる対応を追加しましたよ。

www.urablog.xyz

そもそもバグがあったよ

2018/01/24 追記
 オブジェクトの回転や拡大縮小を行うと、描画位置がおかしく成る不具合を修正しましたよ。

www.urablog.xyz

おわりに

 いろいろ問題はあったけど、とりあえず目的は達成できたかな。
 ただこの機能……、実際にどんな場合に使えばいいんだろうか。

 ……。

 何も思いつかない……。