Unityで投影テクスチャマッピングシャドウ

はじめに

3Dゲームの影生成は90年代、キャラクターの足元に黒い丸を置いた「丸影」が主流だったようです。

しかし丸影は負荷は軽いですが「影」としてのリアリティはありませんでした。それを進化させたのが「投影テクスチャマッピング」です。

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

投影テクスチャマッピングによるシャドウは以下のステップで実現できます。

まず、光源から見た(カメラに見立てて)キャラクターのシルエットをRenderTextureに書き出します。

そして、その作成したRenderTextureを影が生成される場所に貼る(マッピング)することで影が落ちているように見えます。

投影テクスチャマッピングの強みと弱み

この技法の強みはほぼすべてのGPUにサポートされることやキャラの影が別のキャラに投影されるようなことも実現する点になることです。

しかし、投影テクスチャマッピングの影の生成単位は「キャラ」であるため自分の部位の影が自分の体に映る「セルフシャドウ」は行えないことが弱点の一つになります。

また、光源からみた影のRenderTextureをどこにマッピングするのか(貼るのか)の判定が甘いと、ありえないところに影が発生してしまうことも弱点の一つのようです。

Unityでの実装例

まずは今回の成果物になります。

まず、光源と同じ位置、同じ回転値にしたカメラを用意します。

そしてこのカメラを以下のように

  • ProjectionをOrthographicに

  • CullingMaskを影を落としたいオブジェクトに設定しているLayerに

  • BackgroundColorをSolidColorにして背景色のアルファ値を0に

という風に設定しました。

スクリプト

そしてカメラにアタッチするC#スクリプトを以下のように作成します。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteAlways]
[RequireComponent(typeof(Camera))]
public class ShadowProjector : MonoBehaviour
{
    [SerializeField] private int renderTextureSize = 512;

    [SerializeField] private RenderTexture renderTexture;

    [SerializeField] private Light directionalLight;

    private Camera camera;

    private int textureSize;

    private void OnEnable()
    {
        camera = GetComponent<Camera>();
        camera.SetReplacementShader(Shader.Find("Custom/ShadowReciever"), null);
        camera.depth = -100000;
        renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
        camera.targetTexture = renderTexture;
        camera.clearFlags = CameraClearFlags.Color;
        camera.backgroundColor = Color.black;
    }

    private void OnDisable()
    {
        if (renderTexture == null)
        {
            return;
        }

        camera.targetTexture = null;
        DestroyImmediate(renderTexture);
    }

    private void LateUpdate()
    {
        textureSize = renderTexture.width;
        #if UNITY_EDITOR
        if (textureSize != renderTextureSize)
        {
            renderTexture.Release();
            renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
            camera.targetTexture = renderTexture;
        }
        #endif
        camera.transform.position = directionalLight.transform.position;
        camera.transform.rotation = directionalLight.transform.rotation;

        var viewMatrix = camera.worldToCameraMatrix;
        // プロジェクション行列のプラットフォームの差を吸収
        var projectionMatrix = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true);
        Shader.SetGlobalMatrix("_ShadowProjectorMatrixVP", projectionMatrix * viewMatrix);
        Shader.SetGlobalTexture("_ShadowProjectorTexture", renderTexture);

        // プロジェクターのポジションを渡す
        var projectPos = Vector4.zero;
        projectPos = camera.orthographic ? transform.forward : transform.position;
        projectPos.w = camera.orthographic ? 0 : 1;
        Shader.SetGlobalVector("_ShadowProjectorPos", projectPos);
    }
}

このC#ではステップ1である光源方向から見た影のRenderTextureを生成するところまでを行い、作成したRenderTextureをShader側に渡しています。

Enable処理では以下コードのようにCameraの設定、RenderTextureの生成、そしてCameraのoutputにあるTargetTextureにRenderTextureを設定するところまでを行っています。

影を描画するカメラは一番初めに描画したいためdepthをかなり小さい値にしています。

private void OnEnable()
{
    camera = GetComponent<Camera>();
    camera.SetReplacementShader(Shader.Find("Custom/ShadowReciever"), null);
    camera.depth = -100000;
    renderTexture = new RenderTexture(renderTextureSize, renderTextureSize, 0, RenderTextureFormat.ARGB32, 0);
    camera.targetTexture = renderTexture;
    camera.clearFlags = CameraClearFlags.Color;
    camera.backgroundColor = Color.black;
}

LateUpdate内ではShaderに必要な計算を行っています。Shader側でView行列とProjection行列が必要なのでShader側に渡しています。

カメラのView行列はcamera.worldToCameraMatrixで取得し、カメラのProjection行列はcamera.projectionMatrixでとれます。ただ、カメラ空間からクリッピング空間に座標変換(射影変換)はGPUに渡す際、OpenGLDirectXで差異があるためGL.GetGPUProjectionMatrixでプラットフォームの差異を吸収します。

これは、XYZ座標をW座標で割って正規化する際、Z値を収める範囲がOpenGLは-1~1、DirectXは0~1なのでこのような処理が必要になります。

var viewMatrix = camera.worldToCameraMatrix;
// プロジェクション行列のプラットフォームの差を吸収
var projectionMatrix = GL.GetGPUProjectionMatrix(camera.projectionMatrix, true);
Shader.SetGlobalMatrix("_ShadowProjectorMatrixVP", projectionMatrix * viewMatrix);
Shader.SetGlobalTexture("_ShadowProjectorTexture", renderTexture);

// プロジェクターのポジションを渡す
var projectPos = Vector4.zero;
projectPos = camera.orthographic ? transform.forward : transform.position;
projectPos.w = camera.orthographic ? 0 : 1;
Shader.SetGlobalVector("_ShadowProjectorPos", projectPos);

この状態でカメラのOutputにあるtargetTextureをクリックしRenderTextureの情報を見ると以下のようになっています。

RenderTextureのアルファ値のみを確認すると以下のようになっています。

この白い部分が投影された時の影になる場所になります。

Shader

まずは全文になります。このShaderをアタッチしたMaterialを影を受け取る側のオブジェクトにアタッチすれば影が描画されます。

Shader "Custom/ShadowReciever"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _ShadowColor("ShadowColor", Color) = (0,0,0,0)
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100
        Pass
        {
            NAME "ShadowReceiver"
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            struct appdata
            {
                float4 vertex : POSITION;
                float4 normal : NORMAL;
            };
            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 projectorPos : TEXCOORD0;
                float3 worldPos : TEXCOORD1;
                float3 worldNormal : TEXCOORD2;
            };
            TEXTURE2D(_MainTex);
            SAMPLER(sampler_MainTex);
            TEXTURE2D(_ShadowProjectorTexture);
            SAMPLER(sampler_ShadowProjectorTexture);
            float4x4 _ShadowProjectorMatrixVP;
            float4 _ShadowProjectorPos;
            float4 _ShadowColor;
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = TransformObjectToHClip(v.vertex);
                o.projectorPos = mul(mul(_ShadowProjectorMatrixVP, unity_ObjectToWorld), v.vertex);
                o.projectorPos = ComputeScreenPos(o.projectorPos);
                o.worldNormal = TransformObjectToWorldNormal(v.normal);
                o.worldPos = TransformObjectToWorld(v.vertex);
                return o;
            }
            float4 frag (v2f i) : SV_Target
            {
                i.projectorPos.xyz /= i.projectorPos.w;
                float2 uv = i.projectorPos.xy;
                float4 projectorTex = SAMPLE_TEXTURE2D(_ShadowProjectorTexture, sampler_ShadowProjectorTexture, uv);
                float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
                // カメラの範囲外は適用しない
                float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
                float alpha = isOut.x * isOut.y * isOut.z;

                // プロジェクターから見て裏面の面には適用しない
                alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);;
                return lerp(col, 0 , alpha * projectorTex.a);
            }
            ENDHLSL
        }
    }
}

大事になるのは頂点シェーダーです。テクスチャマッピングを行うので流れとしては

  • VP行列を頂点座標にかけて座標変換
  • _ProjectionParams.xをかけることでプラットフォームの違いを吸収する
  • wで除算することで-1~1の範囲に
  • それを0-1に変換する

の流れになります。

(影描画用の)カメラから見たときのCubeやPlaneをTextureに描画させるのでモデル行列に対して影描画用のカメラのビュー行列とプロジェクション行列をかけます。

今回はC#側から渡ってきたVP行列を使って座標変換した後、ComputeScreenPos()を行うことで上の

  • _ProjectionParams.xをかけることでプラットフォームの違いを吸収する
  • それを0-1に変換する(0-wに変換し、後でwでわると0-1の範囲になるようにしている)

を解決しています。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = TransformObjectToHClip(v.vertex);
    o.projectorPos = mul(mul(_ShadowProjectorMatrixVP, unity_ObjectToWorld), v.vertex);
    o.projectorPos = ComputeScreenPos(o.projectorPos);
    o.worldNormal = TransformObjectToWorldNormal(v.normal);
    o.worldPos = TransformObjectToWorld(v.vertex);
    return o;
}

ComputeScreenPos()は以下のようにxyが0~wに変換されてyの上下問題を解決してくれるもので、Unityが用意してくれている関数になっています。

inline float4 ComputeNonStereoScreenPos(float4 pos) {
    // この時点でxyは-wからwの値を取る(MVP変換後だから)
    // xyzwに0.5をかけるのでxyは-0.5w~0.5wになる
    float4 o = pos * 0.5f;

    // この処理でxyが0~wになる
    // yがプラットフォームにより上下反転する問題も吸収
    o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;
    o.zw = pos.zw;
    return o;
}

inline float4 ComputeScreenPos(float4 pos) {
    // 通常はこの処理だけ
    float4 o = ComputeNonStereoScreenPos(pos);

// こっちはVR用の処理
#if defined(UNITY_SINGLE_PASS_STEREO)
    o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
    return o;
}

【Unity】【シェーダ】スクリーンに対してテクスチャをマッピングする方法を完全解説する - LIGHT11から引用

yがプラットフォームによって違うのはプラットフォームによって射影行列が反転しているためです。

フラグメントシェーダーでwによる除算を行なってからテクスチャサンプリングを行います。

float4 frag (v2f i) : SV_Target
{
     i.projectorPos.xyz /= i.projectorPos.w;
     float2 uv = i.projectorPos.xy;
     float4 projectorTex = SAMPLE_TEXTURE2D(_ShadowProjectorTexture, sampler_ShadowProjectorTexture, uv);
     float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
     // カメラの範囲外は適用しない
     float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
     float alpha = isOut.x * isOut.y * isOut.z;

     // プロジェクターから見て裏面の面には適用しない
     alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);
     return lerp(col, 0 , alpha * projectorTex.a);
}

カメラの範囲外かどうかの判定

カメラの範囲外かどうかの判定式は以下で行っています。

float3 isOut = step((i.projectorPos - 0.5) * sign(i.projectorPos), 0.5);
float alpha = isOut.x * isOut.y * isOut.z;

i.projectorPosはクリップスペース空間になるので、0-1の範囲外の場合はカメラの範囲外という判定になります。

sign(x)は以下の値を返す関数になります。

1.0 (if x>0)、0 (if x=0)、-1.0 (if x<0)を返す

裏面かどうかの判定

裏面かどうかは以下の処理で見ていますが、視線方向と放線方向のベクトルの内積をとり、負である場合は裏面ということになります。

alpha *= step(-dot(lerp(-_ShadowProjectorPos.xyz, _ShadowProjectorPos.xyz - i.worldPos, _ShadowProjectorPos.w), i.worldNormal), 0);

影の描画

最後に影を描画します。今回は影を0で書いているので真っ黒ですがPropertiesで定義してあげて影色を指定してあげるといいかと思います。

return lerp(col, 0 , alpha * projectorTex.a);

最後に

上記のShaderをアタッチしたMaterialを影を描画する側のオブジェクトにアタッチし、最終的には以下のような絵が作れます。

参考

news.mynavi.jp

light11.hatenadiary.com

tech.drecom.co.jp

soramamenatan.hatenablog.com

light11.hatenadiary.com