知識0からのUnityShader勉強

知識0からのUnityShader勉強

UnityのShaderをメインとして、0から学んでいくブログです。

【UnityShader】投影テクスチャマッピング #109

前回の成果

オブジェクトの影を受け取った。

soramamenatan.hatenablog.com


今回やること

投影テクスチャマッピングを実装します。

light11.hatenadiary.com


事前準備

Scene上にGameObjectを配置し、今回制作したスクリプトをアタッチします。
f:id:soramamenatan:20210711184305p:plain

スクリーンとなるオブジェクトには、Materialをアタッチします。

f:id:soramamenatan:20210711184301p:plain


ソースコード

スクリプト

using UnityEngine;

public class TextureProjector : MonoBehaviour
{
    [SerializeField, Range(0.0001f, 180)]
    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 ProjectionType projectionType;
    [SerializeField]
    private float _orthographicSize = 1.0f;
    [SerializeField]
    private Texture2D _texture;
    [SerializeField]
    private Material _material;

    private enum ProjectionType
    {
        PERSPECTIVE,
        ORTHOGRAPHIC,
    }
    
    private int _matrixVpId;
    private int _textureId;
    private int _posId;
    
    private void Awake()
    {
        SetPropertyId();
    }

    /// <summary>
    /// PropertyIdの設定
    /// </summary>
    private void SetPropertyId()
    {
        _matrixVpId = Shader.PropertyToID("_ProjectorMatrixVP");
        _textureId = Shader.PropertyToID("_ProjectorTexture");
        _posId = Shader.PropertyToID("_ProjectorPos");
    }

    private void Update()
    {
        SetMaterialParam();
    }

    /// <summary>
    /// マテリアルのパラメータ設定
    /// </summary>
    private void SetMaterialParam()
    {
        if (_texture == null)
        {
            Debug.LogError("Not Setting Material.");
            return;
        }
        var viewMatrix = Matrix4x4.Scale(new Vector3(1, 1, -1)) * transform.worldToLocalMatrix;
        var projectionMatrix = GetProjectionMatrix();
        _material.SetMatrix(_matrixVpId, projectionMatrix * viewMatrix);
        _material.SetTexture(_textureId, _texture);
        // プロジェクターの位置を渡す
        // _ObjectSpaceLightPosのような感じでwに0が入っていたらOrthographicの前方方向とみなす
        _material.SetVector(_posId, GetProjectorPos());
    }

    /// <summary>
    /// Projection行列の取得
    /// </summary>
    /// <returns></returns>
    private Matrix4x4 GetProjectionMatrix()
    {
        Matrix4x4 projectionMatrix;
        switch (projectionType)
        {
            case ProjectionType.PERSPECTIVE :
                projectionMatrix = Matrix4x4.Perspective(_fieldOfView, _aspect, _nearClipPlane, _farClipPlane);
                break;
            case ProjectionType.ORTHOGRAPHIC :
               var orthographicWidth = _orthographicSize * _aspect;
               projectionMatrix = Matrix4x4.Ortho(-orthographicWidth, orthographicWidth, -_orthographicSize, _orthographicSize, _nearClipPlane, _farClipPlane);
               break;
           default :
               Debug.LogError("Not Expected CameraType");
               return Matrix4x4.identity;
        }
        return GL.GetGPUProjectionMatrix(projectionMatrix, true);
    }

    /// <summary>
    /// プロジェクターの座標取得
    /// </summary>
    /// <returns></returns>
    private Vector4 GetProjectorPos()
    {
        Vector4 projectorPos;
        switch (projectionType)
        {
            case ProjectionType.PERSPECTIVE :
                projectorPos = transform.position;
                projectorPos.w = 1;
                break;
            case ProjectionType.ORTHOGRAPHIC :
                projectorPos = transform.forward;
                projectorPos.w = 0;
                break;
            default :
                Debug.LogError("Not Expected CameraType");
                return Vector4.zero;
        }
        return projectorPos;
    }
    
    private void OnDrawGizmos()
    {
        var gizmosMatrix = Gizmos.matrix;
        Gizmos.matrix = Matrix4x4.TRS(transform.position, transform.rotation, Vector3.one);

        switch (projectionType)
        {
            case ProjectionType.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));
                break;
            case ProjectionType.PERSPECTIVE :
                Gizmos.DrawFrustum(Vector3.zero, _fieldOfView, _farClipPlane, _nearClipPlane, _aspect);
                break;
            default :
                Debug.LogError("Not Expected CameraType");
                break;
        }

        Gizmos.matrix = gizmosMatrix;
    }
}

シェーダー側

Shader "Unlit/TextureProjecor"
{
    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 = ComputeScreenPos(mul(mul(_ProjectorMatrixVP, unity_ObjectToWorld), v.vertex));
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = mul(unity_ObjectToWorld, v.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // Projection座標に変換
                // tex2DProj(_ProjectorTexture, i.projectorSpacePos)と同義
                i.projectorSpacePos.xyz /= i.projectorSpacePos.w;
                float4 projectorTex = tex2D(_ProjectorTexture, i.projectorSpacePos.xy);
                // カメラの範囲外(0 ~ 1)は描画しない
                fixed3 isOut = step((i.projectorSpacePos - 0.5) * sign(i.projectorSpacePos), 0.5);
                float alpha = isOut.x * isOut.y * isOut.z;
                // プロジェクターから見て、裏側の面には描画しない
                // Perspective  : _ProjectorPos.xyz - i.worldPos
                // Orthographic : -_ProjectorPos.xyz
                alpha *= step(-dot(lerp(-_ProjectorPos.xyz, _ProjectorPos.xyz - i.worldPos, _ProjectorPos.w), i.worldNormal), 0);
                return projectorTex * alpha;
            }
            ENDCG
        }
    }
}


投影テクスチャマッピング

テクスチャをプロジェクターで写したかのように描画する技術のことです。

f:id:soramamenatan:20210707084730j:plain wgld.org | WebGL: 射影テクスチャマッピング |:より引用

射影テクスチャマッピングとも呼ばれます。


ProjectionType

カメラがシーンを投影する際に、どのような方法で投影するかを決めるものになります。
投影の手法は2つ存在します。

透視投影(perspective)

近くのオブジェクトを大きく、遠くのオブジェクトを小さく描画する手法になります。
主に3Dゲームで用いられる手法になります。

f:id:soramamenatan:20210708091928j:plain

Unity - Manual: Cameras:より引用

正投影(orthographic)

オブジェクトの距離では大きさを変えない投影手法になります。
主に2Dゲームで用いられる手法になります。

f:id:soramamenatan:20210708092110j:plain

Unity - Manual: Cameras:より引用

View行列の作成

var viewMatrix = Matrix4x4.Scale(new Vector3(1, 1, -1)) * transform.worldToLocalMatrix;

今回はこのスクリプトをアタッチしたオブジェクトをカメラのように扱います。
transform.worldToLocalMatrixは、ワールド座標からローカル座標へと変換した行列を取得するものになっているので、これを元にView行列を取得します。

ScaleのZ座標

Matrix4x4.Scale(new Vector3(1, 1, -1)) * transform.worldToLocalMatrix

transform.worldToLocalMatrixのZ座標を反転させています。
これはC#は左手座標系、シェーダーは右手座標系なので、その差を吸収するためにZ座標を反転させています。
似たメソッドにCamera.worldToCameraMatrixがあり、こちらは予め右手座標系にしてくれています。

transform.worldToLocalMatrix

f:id:soramamenatan:20210707091854p:plain

Camera.worldToCameraMatrix

f:id:soramamenatan:20210707091850p:plain


Projection行列の作成

private Matrix4x4 GetProjectionMatrix()
{
    Matrix4x4 projectionMatrix;
    switch (projectionType)
    {
        case ProjectionType.PERSPECTIVE :
            projectionMatrix = Matrix4x4.Perspective(_fieldOfView, _aspect, _nearClipPlane, _farClipPlane);
            break;
        case ProjectionType.ORTHOGRAPHIC :
           var orthographicWidth = _orthographicSize * _aspect;
           projectionMatrix = Matrix4x4.Ortho(-orthographicWidth, orthographicWidth, -_orthographicSize, _orthographicSize, _nearClipPlane, _farClipPlane);
           break;
       default :
           Debug.LogError("Not Expected CameraType");
           return Matrix4x4.identity;
    }
    projectionMatrix = GL.GetGPUProjectionMatrix(projectionMatrix, true);
    return projectionMatrix;
}

PERSPECTIVE

Matrix4x4.Perspective(_fieldOfView, _aspect, _nearClipPlane, _farClipPlane);

でProjection行列を計算しています。 Matrix4x4.PerspectiveはPerspectiveなProjection行列を生成する関数になっています。

定義
/// <summary>
/// PerspectiveなProjection行列を生成
/// </summary>
/// <param name="fov">垂直な視野角(度)</param>
/// <param name="aspect">アスペクト比</param>
/// <param name="zNear">Near Clip</param>
/// <param name="zFar">Far Clip</param>
/// <returns></returns>
public static Matrix4x4 Perspective(float fov, float aspect, float zNear, float zFar);

ORTHOGRAPHIC

Matrix4x4.Ortho(-orthographicWidth, orthographicWidth, -_orthographicSize, _orthographicSize, _nearClipPlane, _farClipPlane);

でProjection行列を計算しています。 Matrix4x4.OrthoはOrthographicなProjection行列を生成する関数になっています。

定義
/// <summary>
/// OrthographicなProjection行列を生成
/// </summary>
/// <param name="left">左のx座標</param>
/// <param name="right">右のx座標</param>
/// <param name="bottom">下のy座標</param>
/// <param name="top">上のy座標</param>
/// <param name="zNear">Near Clip</param>
/// <param name="zFar">Far Clip</param>
/// <returns></returns>
public static Matrix4x4 Ortho(float left, float right, float bottom, float top, float zNear, float zFar);

プラットフォームの違いを吸収

projectionMatrix = GL.GetGPUProjectionMatrix(projectionMatrix, true);

取得したProjection行列をProjection変換に使用する際に、
View空間にあるxyzを-1~1に収めるような座標に変換する必要があります。
これを正規化デバイス座標と呼びます。
この際にwで除算する必要があるのですが、現在取得したProjection行列はC#側で生成したので正規化した際にShader側とz値を収める範囲が異なります。 その差を吸収してくれるのがGL.GetGPUProjectionMatrixとなります。

定義
/// <summary>
/// Projection行列のプラットフォームの違いを吸収する
/// </summary>
/// <param name="proj">Projection行列</param>
/// <param name="renderIntoTexture">Projection行列をRenderTextureで使用するか</param>
/// <returns></returns>
public static Matrix4x4 GetGPUProjectionMatrix(Matrix4x4 proj, bool renderIntoTexture);


プロジェクターの座標を取得

private Vector4 GetProjectorPos()
{
    Vector4 projectorPos;
    switch (projectionType)
    {
        case ProjectionType.PERSPECTIVE :
            projectorPos = transform.position;
            projectorPos.w = 1;
            break;
        case ProjectionType.ORTHOGRAPHIC :
            projectorPos = transform.forward;
            projectorPos.w = 0;
            break;
        default :
            Debug.LogError("Not Expected CameraType");
            return Vector4.zero;
    }
    return projectorPos;
}

シェーダーの定義済の値に_ObjectSpaceLightPosがあり、
wの値が0の場合は指向性ライト、1の場合はその他のライトとなっています。
それに習い、wの値をPerspectiveかOrthographicによって変化させています。


範囲外の描画をしない

カメラ外

fixed3 isOut = step((i.projectorSpacePos - 0.5) * sign(i.projectorSpacePos), 0.5);

i.projectorSpacePosクリップスペース空間に変換されているので、0~1の範囲となっています。
そこでsignstepを組み合わせて0~1の範囲外は描画しないようにしています。

グラフ
赤 : (x-0.5) * sign(x)
青 : step((x-0.5) * sign(x), 0.5)

f:id:soramamenatan:20210711182917p:plain

sign(x)

xが正なら+1.0、0.0なら0.0、負なら-1.0を返す関数になります。


裏麺

step(-dot(lerp(-_ProjectorPos.xyz, _ProjectorPos.xyz - i.worldPos, _ProjectorPos.w), i.worldNormal), 0);

lerpを使用しているのは、スクリプト側から_ProjectorPosを渡した際のwで投影法を切り替えるためになります。
その後、視線ベクトルと法線ベクトルの内積で裏面かを判断しています。


結果

Perspective

f:id:soramamenatan:20210711184045p:plain

Orthographic

f:id:soramamenatan:20210711184050p:plain


今回は以上となります。
ここまでご視聴ありがとうございました。

参考サイト様

wgld.org

light11.hatenadiary.com