Unity URP Shader Reflection

# はじめに
Unity 6

最近、自分で実装した小さなエフェクトについてまとめたものです。実装過程を記録し、自身の知識を整理するための記事です。もし内容に誤りや改善点があれば、ぜひご指摘いただけると嬉しいです。
# 1. 実装原理
鏡面反射部分は、MainCameraに対して鏡像対称なReflectionCameraを作成し、リアルタイムで鏡像を取得してシェーダーに渡すことで実現しています。この方法により、鏡面反射の見た目を実現します。

反射平面に対してガウスぼかしをかけるには、畳み込み処理を行い、ピクセルをループしてガウス重み付けで合成する手法を採用しています。
# 2. コード実装
## 2.1 ReflectionCamera の作成とパラメータ設定
以下のスクリプトは、私が理解しやすいと感じた手法で書いたものです。反射行列の数学的な導出部分は参照していますが少し難解だったため、本稿では直接的にリフレクションカメラの設定を行う方法を用いています。
```
using System;
using System.Collections;
using UnityEngine;

public class PlanarReflection : MonoBehaviour
{
    private Camera _reflectionCamera = null; // 反射用テクスチャを取得するためのリフレクションカメラ
    private Camera _mainCamera;             // メインカメラ

    [SerializeField] private GameObject _reflectionPlane = null; // 反射を行いたい平面オブジェクト
    [SerializeField] private Material _floorMaterial = null;     // 反射平面に適用するマテリアル
    [SerializeField, Range(0.0f, 1.0f)] private float _reflectionFactor = 1.0f; // 反射強度を制御し、シェーダーへ渡す

    private RenderTexture _renderTarget = null; // リフレクションカメラの撮影結果を格納するRenderTexture

    void Start()
    {
        _mainCamera = Camera.main;

        // リフレクション用のカメラをゲームオブジェクトとして生成
        GameObject reflectionCameraGo = new GameObject("Reflection Camera");
        _reflectionCamera = reflectionCameraGo.AddComponent<Camera>();
        // 無効化：リフレクションカメラはUnityのデフォルトレンダリングフローには参加させず、不要なレンダリングや順序の問題を避ける
        _reflectionCamera.enabled = false;

        // RenderTextureを生成し、リフレクションカメラに使用
        _renderTarget = new RenderTexture(Screen.width, Screen.height, 24); // 幅・高さを画面サイズに合わせる。深度は結果に影響しない。
        _floorMaterial.SetTexture("_ReflectionTex", _renderTarget); // マテリアルにリフレクションテクスチャを設定
    }

    void Update()
    {
        // グローバルプロパティで反射係数をシェーダーに渡す
        Shader.SetGlobalFloat(Shader.PropertyToID("_reflectionFactor"), _reflectionFactor);
    }

    private void LateUpdate()
    {
        RenderReflection(); // 毎フレームLateUpdateで反射をレンダリング
    }

    void RenderReflection()
    {
        if (_mainCamera == null || _reflectionPlane == null) return;

        // メインカメラの設定をコピーし、位置・向きなどを反映
        _reflectionCamera.CopyFrom(_mainCamera);

        // ワールド空間でのメインカメラの方向・上向き・位置
        Vector3 cameraDirectionWorldSpace = _mainCamera.transform.forward;
        Vector3 cameraUpWorldSpace = _mainCamera.transform.up;
        Vector3 cameraPositionWorldSpace = _mainCamera.transform.position;

        // 反射平面オブジェクトのローカル空間に変換
        Vector3 cameraDirectionPlaneSpace = _reflectionPlane.transform.InverseTransformDirection(cameraDirectionWorldSpace);
        Vector3 cameraUpPlaneSpace = _reflectionPlane.transform.InverseTransformDirection(cameraUpWorldSpace);
        Vector3 cameraPositionPlaneSpace = _reflectionPlane.transform.InverseTransformPoint(cameraPositionWorldSpace);

        // ローカル空間では平面の法線が (0, 1, 0) と仮定し、Y軸方向を反転して鏡面対称を得る
        cameraDirectionPlaneSpace.y *= -1.0f;
        cameraUpPlaneSpace.y *= -1.0f;
        cameraPositionPlaneSpace.y *= -1.0f;

        // 再びワールド空間へ変換
        cameraDirectionWorldSpace = _reflectionPlane.transform.TransformDirection(cameraDirectionPlaneSpace);
        cameraUpWorldSpace = _reflectionPlane.transform.TransformDirection(cameraUpPlaneSpace);
        cameraPositionWorldSpace = _reflectionPlane.transform.TransformPoint(cameraPositionPlaneSpace);

        // 反射カメラに位置と向きを設定
        _reflectionCamera.transform.position = cameraPositionWorldSpace;
        _reflectionCamera.transform.LookAt(cameraPositionWorldSpace + cameraDirectionWorldSpace, cameraUpWorldSpace);

        // レンダリングターゲットを設定して描画
        _reflectionCamera.targetTexture = _renderTarget;
        _reflectionCamera.Render();
    }
}

```
このスクリプトを通じて、次のマップを取得できます
![ReflectionExport.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4105103/e7561086-f9eb-459c-9f32-920cd41ee6ad.png)

### ポイント解説
#### リフレクションカメラ生成: 
new GameObject("Reflection Camera") で生成し、Camera コンポーネントを追加。enabled = false にしてUnityの通常レンダリングフローには含めない。

#### RenderTexture設定:
画面サイズ (Screen.width, Screen.height) に合わせたRenderTextureを作成し、マテリアルの _ReflectionTex プロパティにセット。

#### メインカメラ設定のコピー: 
CopyFrom(_mainCamera) でFOVやクリッピングプレーンなどを揃える。

#### 鏡像位置／向きの計算: 
反射平面オブジェクトのローカル空間に変換し、Y軸 (= 法線方向) を反転、再びワールド空間に戻すことで鏡像を得る。

#### LateUpdateでのレンダリング: 
毎フレームLateUpdate中に RenderReflection() を呼び、リフレクション用の描画を行う。
## 2.2 シェーダー部分
ReflectionTex をサンプリングするときにUVのXを反転しないと、鏡像が左右逆転しない形式でサンプリングされてしまうことがあるため、flippingが必要です。
![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4105103/33307167-3e93-48ce-b81f-c8dbd0703206.png)

```
Shader "Custom/PlanarReflection"
{
    Properties
    {
        _Color("Base Color", Color) = (1, 1, 1, 1)
        _MainTex("Main Texture", 2D) = "white" {}
        _ReflectionTex("Reflection Texture", 2D) = "white" {} // PlanarReflectionスクリプトで渡されるリフレクションテクスチャ
        _reflectionFactor("Reflection Factor", Range(0, 1)) = 1.0
        _Roughness("Roughness", Range(0, 1)) = 0.0 // 表面の粗さ: 0が完全に滑らか、1が最大ぼかし
        _BlurRadius("Blur Radius", Range(0, 10)) = 5.0 // ぼかし量を制御
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

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

            struct appdata
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD1;
            };
            struct v2f
            {
                float2 uv : TEXCOORD1;
                float4 screenPos : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };
            // パラメータ宣言
            float4 _Color;
            sampler2D _MainTex;
            float4 _MainTex_ST;
            sampler2D _ReflectionTex;
            float4 _ReflectionTex_ST;
            float _reflectionFactor;
            float _Roughness;
            float _BlurRadius;

            // ガウス重み関数
            float gaussianWeight(float x, float sigma)
            {
                // ガウス重み: exp(-x²/(2σ²))
                return exp(-(x * x) / (2.0 * sigma * sigma));
            }
            // ガウスぼかしサンプリング関数
            half4 gaussianBlur(sampler2D tex, float2 uv, float blurAmount)
            {
                // _Roughness が小さい場合は計算コストを避けるためオリジナルテクスチャをそのまま返す
                if (blurAmount <= 0.001)
                {
                    return tex2D(tex, uv);
                }
                half4 color = half4(0, 0, 0, 0);
                float totalWeight = 0.0;
                // ピクセルサイズ: 画面解像度から取得
                float2 texelSize = float2(1.0 / _ScreenParams.x, 1.0 / _ScreenParams.y);
                // 動的にサンプル範囲とステップを調整
                int sampleCount = (int)lerp(3, 9, _Roughness);
                float stepSize = blurAmount * _BlurRadius;
                float sigma = stepSize * 0.5;
                // 2次元畳み込み: 水平方向・垂直方向のぼかし
                for (int x = -sampleCount; x <= sampleCount; x++)
                {
                    for (int y = -sampleCount; y <= sampleCount; y++)
                    {
                        // オフセットの計算（ピクセル空間→UV空間）
                        float2 offset = float2(x, y) * texelSize * stepSize;
                        float2 sampleUV = uv + offset;
                        // 境界チェック: UVが 0～1 の範囲内か確認
                        if (sampleUV.x >= 0.0 && sampleUV.x <= 1.0 &&
                            sampleUV.y >= 0.0 && sampleUV.y <= 1.0)
                        {
                            // 中心からの距離を計算
                            float distance = length(float2(x, y));
                            // ガウス重みを取得
                            float weight = gaussianWeight(distance, sigma);
                            // 重み付きで色を加算
                            color += tex2D(tex, sampleUV) * weight;
                            totalWeight += weight;
                        }
                    }
                }
                // 正規化した色を返す（加重平均）
                return color / totalWeight;
            }

            v2f vert(appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.screenPos = ComputeScreenPos(o.vertex); // スクリーン座標に変換。これがないと反射描画が正しくならない
                o.uv = v.texcoord;
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float2 screenUV = i.screenPos.xy / i.screenPos.w;
                half4 tex_col = tex2D(_MainTex, i.uv);

                // screenUV の X を反転して鏡面UVとする
                float2 reflectionUV = float2(1 - screenUV.x, screenUV.y);
                // ガウスぼかしを適用
                half4 reflectionColor = gaussianBlur(_ReflectionTex, reflectionUV, _Roughness);
                // 反射とメインテクスチャを混合
                fixed4 col = tex_col * _Color * reflectionColor;
                col = lerp(tex_col * _Color, col, _reflectionFactor);

                return col;
            }
            ENDCG
        }
    }
}

```
最終的な効果は次のようになります。

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4105103/9c54843d-f6ba-4e3c-a1c2-afee8cc45822.png)
_Roughnessと_BlurRadiusを調整してぼかし効果を実現できます。

![スクリーンショット 2025-05-31 190613.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4105103/112e6071-3ca1-4339-94ea-9c7835c9115a.png)