そういうのがいいブログ

アプリ個人開発 まるブログ

アプリ開発覚え書き

【Unity】URPプロジェクトで 反射(鏡面)床を作る方法 

要件

Unity 6000.0.26f1
URPプロジェクト

はじめに

URPプロジェクトにおける反射(鏡面)床のメモです。

以下の記事を参考にさせていただき、
自プロジェクトで都合の良いように変更しています。

qiita.com

対象読者は、Unityの簡単な操作ができる方です。

概要

反射床を作る方式は複数あるようですが、
今回は「Planar Reflection」という方式で進めます。

概要としては、以下を行い反射床を表現しています。
1.床に対して反転したカメラを用意
2.反転カメラの映像をレンダーテクスチャに映す
3.レンダーテクスチャに映した映像を専用シェーダーで左右反転させる
4.左右反転したレンダーテクスチャの映像を床に映す

作成手順

1.オブジェクトの準備

1.1 反射床用のオブジェクトを用意する

平面を追加します。

ヒエラルキーの「+」ボタン
→「3Dオブジェクト」
→「平面」


このような平面が追加されます。

1.2 反射確認用オブジェクトの配置

確認用に3Dオブジェクトのキューブやカプセルを反射床の上に配置しておきます。

1.3 反射用カメラの設置

反射用カメラを追加します。

ヒエラルキーの「+」ボタン
→「カメラ」


追加したカメラの名前を「ReflectionCamera」としておきます。(好きな名前でOK)

オブジェクトの準備は以上です。


2.コンポーネントの設定

メインカメラと反射用カメラへのコンポーネント設定を行います。

2.1 メインカメラにコンポーネント追加

メインカメラにスカイボックスコンポーネントを追加します。


スカイボックスコンポーネントを追加後、
好きなスカイボックスのマテリアルを設定してください。


今回はデフォルトのスカイボックスマテリアルを使用します。


2.2 反射用カメラの設定

反射用カメラは以下の設定を行います。
・Cameraコンポーネントをオフ
・ポストプロセスを有効化
・Audio Listenerの削除
・スカイボックスコンポーネントを追加


反射カメラのスカイボックスコンポーネントには、
メインカメラのマテリアルをスクリプトで設定します。
そのため、マテリアルの設定は不要です。

3.反転シェーダーを作成

カメラから受け取った映像を反転するシェーダーを作成します。

今回作成するシェーダーは色設定、反射強度設定、ぼかし設定の機能も持ちます。

プロジェクトタブの「+」ボタン
→「シェーダー」
→「Unlitシェーダー」


シェーダーの名前は「PlanarReflection」にしてください。


シェーダーのファイルをダブルクリックしてシェーダーファイルを開き、
中身を下記に置き換えてください。

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 // 反射強度 0:反射なし ベースカラーのみ 1:完全に反射のみ
        _Roughness("Roughness", Range(0, 1)) = 0.0 // ぼかし強さ 0:ぼかし無し 1:最大ぼかし
        _BlurRadius("Blur Radius", Range(0, 10)) = 5.0 // ぼかし量を制御
    }
    SubShader
    {
        // "Queue"="Geometry" → 描画順をGeometryキュー(通常の不透明オブジェクト:2000)に設定
        // "RenderType"="Opaque" → 不透明オブジェクトとして扱う(レンダリングパスやポストプロセスで使用)
        Tags { "Queue"="Geometry" "RenderType"="Opaque" }

        // 深度バッファでの位置を調整する。
        // 負の値でカメラに近づける → 同じ座標の他オブジェクトよりも優先的に描画される
        // 今回は Offset -1, -1 で、わずかに手前扱いにする
        Offset -1, -1

        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;
            float4 _BaseColor;

            // ガウス重み関数
            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
            {
                // カメラが描画した位置のUVを取得
                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 * _BaseColor * reflectionColor;
                col = lerp(tex_col * _BaseColor, col, _reflectionFactor);

                return col;
            }
            ENDCG
        }
    }
}



4.反転シェーダーのマテリアルの作成と割当て

4.1 マテリアル作成

反転シェーダーのマテリアルを作成します。

→作成したシェーダーファイルを右クリック
→「作成」
→「マテリアル」


作成したシェーダーを使用するマテリアルが作成されました。

4.2 マテリアル割当て

作成したマテリアルを反射床のオブジェクトにドラッグ&ドロップをして割り当てます。


反射床の見た目は真っ黒になりますが問題ありません。

5.反射用スクリプトの作成と割当て

5.1 スクリプト作成

新規スクリプト「PlanarReflectionView」を作成し下記の内容に変更します。

using UnityEngine;
//using Unity.Cinemachine;
using System.Collections;

public class PlanarReflectionView : MonoBehaviour
{   
    [Header("References")]
    //[SerializeField] private CinemachineBrain _cinemachineBrain;
    [SerializeField] private Camera _mainCamera;// メインカメラ
    [SerializeField] private Camera _reflectionCamera = null;// 反射用テクスチャを取得するためのリフレクションカメラ
    [SerializeField] private GameObject _reflectionTargetPlane = null; // 反射平面を行うオブジェクト
    [SerializeField] private Skybox _mainSkybox;
    [SerializeField] private Skybox _reflectionSkybox;

    
    [Header("Render Settings")]
    [SerializeField, Range(0.3f, 1.0f)] private float _resolutionScale = 1.0f;// テクスチャ解像度(数値を上げるほど高負荷) 0.3f: 低解像度, 1.0f: フル解像度    

    [Header("Material Properties")]
    [SerializeField] private Color _reflectionColor = Color.white; // 反射の色
    [SerializeField, Range(0.0f, 1.0f)] private float _reflectionFactor = 1.0f; // 反射強度 0:反射なし ベースカラーのみ 1:完全に反射のみ
    [SerializeField, Range(0.0f, 1.0f)] private float _roughness = 0.0f; // ぼかし強さ
    private const float _blurRadius = 5.0f; // ぼかし半径
    
    [Header("Internal Runtime States")]
    private RenderTexture _renderTarget; // リフレクションカメラの撮影結果を格納するRenderTexture
    private Material _floorMaterial; // 平面のマテリアル シェーダー(PlanarReflection)操作用
    
    private int _lastScreenWidth;
    private int _lastScreenHeight;
    private float _lastResolutionScale;

    //private ICinemachineCamera _lastActiveVirtualCamera;
    
    private void Start()
    {
        /*
        if (_mainCamera == null || _reflectionTargetPlane == null || _cinemachineBrain == null)
        {
            Debug.LogError("PlanarReflection: 必要なコンポーネントが設定されていません。メインカメラ、反射平面、CinemachineBrainを確認してください。");
            enabled = false;
            return;
        }
        */

        //反射平面のマテリアル取得
        Renderer renderer = _reflectionTargetPlane.GetComponent<Renderer>();
        _floorMaterial = renderer.sharedMaterial;
        
        
        // カメラコンポーネント無効化:リフレクションカメラはUnityのデフォルトレンダリングフローには参加させず、不要なレンダリングや順序の問題を避ける
        _reflectionCamera.enabled = false;

        // 初期スクリーンサイズとスケール
        _lastScreenWidth = Screen.width;
        _lastScreenHeight = Screen.height;
        _lastResolutionScale = _resolutionScale;
        
        CreateRenderTarget();
    }

    void Update()
    {
        if (_floorMaterial != null)
        {
            _floorMaterial.SetColor("_BaseColor", _reflectionColor);
            _floorMaterial.SetFloat("_reflectionFactor", _reflectionFactor);
            _floorMaterial.SetFloat("_Roughness", _roughness);            
        }
        
        // スクリーンサイズ or 解像度スケール変更検出
        if (Screen.width != _lastScreenWidth ||
            Screen.height != _lastScreenHeight ||
            !Mathf.Approximately(_resolutionScale, _lastResolutionScale))
            //|| _lastActiveVirtualCamera != _cinemachineBrain.ActiveVirtualCamera) シネマシーンを使用する場合はこの条件もif分に追加
        {
            _lastScreenWidth = Screen.width;
            _lastScreenHeight = Screen.height;
            _lastResolutionScale = _resolutionScale;
            RecreateRenderTarget();
           
            //_lastActiveVirtualCamera = _cinemachineBrain.ActiveVirtualCamera;
        }
    }

    private void LateUpdate()
    {
        // フレーム終了時に反射描画
        StartCoroutine(RenderReflectionAtEndOfFrame());
    }
    
    private IEnumerator RenderReflectionAtEndOfFrame()
    {
        /*
        WaitForEndOfFrame は Unity がフレーム描画を行う「直前」に実行されます。
        CinemachineBrain の LateUpdate → Transform 更新 → WaitForEndOfFrame() → 反射描画 という順番で、確実に正しい位置で反射を描画できます。
        */
        yield return new WaitForEndOfFrame();
        RenderReflection();
    }

    
    private void CreateRenderTarget()
    {
        int width = Mathf.Max(256, Mathf.RoundToInt(Screen.width * _resolutionScale));
        int height = Mathf.Max(256, Mathf.RoundToInt(Screen.height * _resolutionScale));
        
        // 既存のRenderTextureがあれば解放
        if (_renderTarget != null)
        {
            _reflectionCamera.targetTexture = null;
            _renderTarget.Release();
            DestroyImmediate(_renderTarget);
        }
        
        // 新しいRenderTextureを作成
        _renderTarget = new RenderTexture(width, height, 24)
        {
            name = "PlanarReflectionRT",
            useMipMap = true,
            autoGenerateMips = true
        };

        _floorMaterial.SetTexture("_ReflectionTex", _renderTarget);// マテリアルにリフレクションテクスチャを設定
        _floorMaterial.SetFloat("_BlurRadius", _blurRadius);
    }
    
    private void RecreateRenderTarget()
    {
        if (_renderTarget != null)
        {
            _reflectionCamera.targetTexture = null;
            _renderTarget.Release();
            DestroyImmediate(_renderTarget);
        }
        CreateRenderTarget();
        
        RenderReflection();//カメラ変更時に真っ黒な床が一瞬表示されるためすぐ描画する。
    }

    private void RenderReflection()
    {
        // メインカメラの設定をコピーし、位置・向きなどを反映
        _reflectionCamera.CopyFrom(_mainCamera);
        
        // Skybox同期
        if (_mainSkybox != null && _mainSkybox.material != null)
        {
            _reflectionSkybox.material = _mainSkybox.material;
        }

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

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

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

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


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

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

※一部コメントアウトしているのはCinemachineを使う時用です。


5.2 スクリプト割当て

空のオブジェクトを作成し、作成したスクリプト「PlanarReflectionView」を割り当てます。


6.スクリプト必要情報割当て

空のオブジェクトをクリックして
インスペクター上に表示されているReferencesの5項目に情報を割り当てます。

MainCamera → メインカメラのオブジェクトを割当て
ReflectionCamera → 反射カメラのオブジェクトを割当て
ReflectionTargetPlane → 反射床オブジェクトを割当て
MainSkybox → メインカメラのオブジェクトを割当て
ReflectionSkybox → 反射カメラのオブジェクトを割当て

以上で準備完了です。

確認

プレイボタンを押してメインカメラの位置や回転を変更してみると、
平面上に反射しているかのような映像が映されます。


※メインカメラの映像を基に平面へ映像を映しているという手法のため
 シーンビュー上では反射しているように見えません。

おわりに

アプリケーションとして出力した場合でもウインドウサイズの変更にも対応しています。
お役に立てましたら幸いです。