【Unity】【シェーダ】3Dモデルにテクスチャを投影する投影テクスチャマッピングを実装する

Unityのシェーダで3Dモデルにテクスチャを投影する投影テクスチャマッピングを実装する方法をまとめました。

Unity2019.2.18

はじめに

この記事では投影テクスチャマッピングを実装する方法についてまとめます。
投影テクスチャマッピングとは、現実世界のプロジェクターのように、3Dモデルに画像を投影する技術です。

f:id:halya_11:20200203182818p:plain

実装する上で座標変換の基礎知識が前提となりますが、
これについては以下の記事にまとめていますので必要に応じて参照してください。

light11.hatenadiary.com

原理

仕組みとしては、まずカメラのように視錐台情報を持つプロジェクターを作ります。
そして投影する3Dモデルに対して、このプロジェクターのビュー行列とプロジェクション行列を渡します。

f:id:halya_11:20200203182930p:plain

次に3Dモデルを描画します。
このとき、通常の描画処理に加えて、頂点をプロジェクターのビュー行列とプロジェクション行列で座標変換します。
この処理により、プロジェクターのプロジェクション空間における頂点座標が求められます。

f:id:halya_11:20200203184315p:plain

あとはこの空間におけるxy座標に応じてテクスチャをサンプリングすることで、プロジェクターの効果が得られます。

f:id:halya_11:20200203182818p:plain

スクリプト

それでは実際に実装していきます。
まずはスクリプトです。

using UnityEngine;

[ExecuteAlways]
[DefaultExecutionOrder(10000)] // ExecutionOrderは適宜設定
public class Example : MonoBehaviour
{
    [SerializeField, Range(0.0001f, 179)]
    private float _fieldOfView = 60;
    [SerializeField, Range(0.2f, 5.0f)]
    private float _aspect = 1.0f;
    [SerializeField, Range(0.0001f, 1000.0f)]
    private float _nearClipPlane = 0.01f;
    [SerializeField, Range(0.0001f, 1000.0f)]
    private float _farClipPlane = 100.0f;
    [SerializeField]
    private bool _orthographic = false;
    [SerializeField]
    private float _orthographicSize = 1.0f;
    [SerializeField]
    private Texture2D _texture;

    // とりあえず今回はLateUpdateで更新
    private void LateUpdate()
    {
        if (_texture == null)
        {
            return;
        }
        var viewMatrix = Matrix4x4.Scale(new Vector3(1, 1, -1)) * transform.worldToLocalMatrix;
        Matrix4x4 projectionMatrix;
        if (_orthographic)
        {
            var orthographicWidth = _orthographicSize * _aspect;
            projectionMatrix = Matrix4x4.Ortho(-orthographicWidth, orthographicWidth, -_orthographicSize, _orthographicSize, _nearClipPlane, _farClipPlane);
        }
        else
        {
            var camera = GetComponent<Camera>();
            projectionMatrix = Matrix4x4.Perspective(_fieldOfView, _aspect, _nearClipPlane, _farClipPlane);
        }
        projectionMatrix = GL.GetGPUProjectionMatrix(projectionMatrix, true);
        Shader.SetGlobalMatrix("_ProjectorMatrixVP", projectionMatrix * viewMatrix);
        Shader.SetGlobalTexture("_ProjectorTexture", _texture);
        // プロジェクターの位置を渡す
        // _ObjectSpaceLightPosのような感じでwに0が入っていたらOrthographicの前方方向とみなす
        var projectorPos = Vector4.zero;
        projectorPos = _orthographic ? transform.forward : transform.position;
        projectorPos.w = _orthographic ? 0 : 1;
        Shader.SetGlobalVector("_ProjectorPos", projectorPos);
    }

    private void OnDrawGizmos()
    {
        var gizmosMatrix = Gizmos.matrix;
        Gizmos.matrix = Matrix4x4.TRS(transform.position, transform.rotation, Vector3.one);

        if (_orthographic)
        {
            var orthographicWidth = _orthographicSize * _aspect;
            var length = _farClipPlane - _nearClipPlane;
            var start = _nearClipPlane + length / 2;
            Gizmos.DrawWireCube(Vector3.forward * start, new Vector3(orthographicWidth * 2, _orthographicSize * 2, length));
        }
        else
        {
            Gizmos.DrawFrustum(Vector3.zero, _fieldOfView, _farClipPlane, _nearClipPlane, _aspect);
        }

        Gizmos.matrix = gizmosMatrix;
    }
}

カメラと同じように視錐台を定義するための変数を用意し、VP行列を渡しています。
注意点として、ビュー行列をシェーダに渡すときにはtransform.worldToLocalMatrixのz値の符号を反転させる必要があります。

またプロジェクション行列はGL.GetGPUProjectionMatrix()で変換することでプラットフォーム間の差を吸収します。
これについては以下の記事で説明しています。

light11.hatenadiary.com

また、投影するためのテクスチャと、投影するかどうかを決めるための判定に使うプロジェクターの座標も渡しています。

なお今回はこれらの行列をGlobalなプロパティとしてマテリアルに渡していますが、
プロジェクターが複数存在する場合のことを考えるとマテリアルへの受け渡しはもう少し工夫するべきです。

シェーダ

次に投影されるモデルにアサインするためのシェーダを書きます。

Shader "Example"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float4 normal : NORMAL;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 projectorSpacePos : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
                float3 worldNormal : TEXCOORD2;
            };
            
            sampler2D _ProjectorTexture;
            float4x4 _ProjectorMatrixVP;
            float4 _ProjectorPos;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.projectorSpacePos = mul(mul(_ProjectorMatrixVP, unity_ObjectToWorld), v.vertex);
                o.projectorSpacePos = ComputeScreenPos(o.projectorSpacePos);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                half4 color = 1;
                i.projectorSpacePos.xyz /= i.projectorSpacePos.w;
                float2 uv = i.projectorSpacePos.xy;
                float4 projectorTex = tex2D(_ProjectorTexture, uv);
                // カメラの範囲外には適用しない
                fixed3 isOut = step((i.projectorSpacePos - 0.5) * sign(i.projectorSpacePos), 0.5);
                float alpha = isOut.x * isOut.y * isOut.z;
                // プロジェクターから見て裏側の面には適用しない
                alpha *= step(-dot(lerp(-_ProjectorPos.xyz, _ProjectorPos.xyz - i.worldPos, _ProjectorPos.w), i.worldNormal), 0);
                return projectorTex * alpha;
            }
            ENDCG
        }
    }
}

まず頂点シェーダでプロジェクターから渡した_ProjectorMatrixVPを使って座標変換を行います。
またComputeScreenPos()を使ってからフラグメントシェーダでw除算することでプロジェクション座標に変換しています。
このあたりの詳細については以下の記事にまとめていますので、必要に応じて参照してください。

light11.hatenadiary.com

プロジェクション座標に変換出来たらあとはxy座標を使ってテクスチャをマッピングするだけです。

なお上記のシェーダでは、法線やプロジェクターの座標を使って範囲外の描画が行われないようにする処理も追加しています。

結果

このスクリプトを適当なGameObjectにアタッチして、テクスチャを設定してプロジェクターを作ります。
また、シェーダをPlaneモデルにアサインします。

すると以下のようにPlaneモデルにプロジェクターがテクスチャを投影したレンダリング結果が得られます。

f:id:halya_11:20200203212002p:plain

関連

light11.hatenadiary.com

light11.hatenadiary.com

light11.hatenadiary.com