WinCNT

Vignette를 불투명 Mesh와 반투명 Mesh로 나눠서 구현해보자! 본문

Unity/URP or Shader 관련

Vignette를 불투명 Mesh와 반투명 Mesh로 나눠서 구현해보자!

WinCNT_SSS 2024. 4. 8. 13:17

서론

그냥 일반적인 연출 표현 중 하나인 Vignette은 사실 VR에서 그 위상이 대단하다

그 이유는 바로 멀미 방지를 위해 Vignette이 주로 사용되기 때문!!

(이런 Vignette를 따로 Tunneling Vignette라고 부르기도 한다)

ACNVR에서도 사용되었다

 

Vignette는 포스트 프로세싱으로도 구현이 가능하긴 하지만 매우 유감스럽게도 VR에서는 포스트 프로세싱의 사용을 최대한 피해야 하기 때문에…

현 프로젝트에서는 카메라의 바로 앞에 오브젝트를 두고 그 오브젝트에 구명을 뚫는 방식으로 구현하고 있다

하지만 현 프로젝트의 방식에도 문제가 있었으니…


현 방식의 문제점

ACNVR에서도 알 수 있듯이 멀미 방지를 위한 Vignette에는 구멍의 주변 부분이 그라데이션하게 반투명해지는 것을 알 수 있다

 

참고로 반투명 부분을 아예 없애는 Vignette도 존재한다

하지만 이건 주로 씬 전환이나 마무리 연출로 자주 사용된다

[스타레일] 페나코니 전리품 위치공략 05화 - 레버리 호텔 꿈세계 (완결)

 

반투명한 부분이 없는 Vignette를 멀미 방지용 으로 사용하면 사람에 따라서는 상당히 몰입을 방해한다고 느끼기도 한다

 

아무튼 현 프로젝트의 방식에 무엇이 문제인가 하면…이 반투명 부분을 위해 오브젝트 전체가 반투명으로 설정하고 있다는 점이다

다시 말해 이동 시(Vignette가 나올 시)에 화면에 상당 부분에 Overdraw가 발생한다는 끔찍하고 무시무시한 상황이라는 것…!

 

그리고 좌우의 스크린의 Offset이 맞지 않아서 VR에서 보면 화면의 가운데 부분이 Vigette에 의해 검정색으로 보이게 되는 현상도 발생하고 있었다

예를 들어 오른쪽 스크린에서는 모두 보이는 오브젝트가 Offset이 맞지 않아서 왼쪽 스크린에서는 일부 Vignette에 의해 가려졌을 때, 플레이어는 화면의 가운데가 어두워졌다고 느끼게 된다

(반대의 경우도 마찬가지)

 

위의 현상은 일단 오프셋을 조정하는 파라미터로 억지로 조정하게 했지만 어디까지나 응급 처지…

근본적인 해결책이 필요했다…!!


해결책은 Oculus의 Vignette에!

이리저리 살펴보니 Oculus의 Vignette가 그 모든 것을 해결하고 있었다!

그런데 Single-pass instanced rendering은 대응을 안 해서 VR기기에선 한쪽 밖에 안 나오거나 했다

 

하지만 처음에는 로직을 이해하는데 시간이 좀 걸렸다…

분명 Mesh만 보면 이게 Vignette이 그려질리가 없었기 때문이다

그런데 UV의 X와 Y로 Vertex의 위치를 조정해서 Vignette 그리는 것을 보고 감탄했다

암튼 이해는 어느 정도 했고, 매 Update마다 Vignette의 사이즈와 Offset을 구하는 것도 고치고 싶어서 이것을 조금 수정해서 사용하기로 했다!

 

그렇게 해서 만들어진 게 바로 아래의 샘플 코드다!

(파라미터나 주석은 그대로 두었다)


샘플 코드

Script

using UnityEngine;
using UnityEngine.Rendering;

public class Vignette : MonoBehaviour
{
    [SerializeField, Tooltip("表示に掛かる時間")] private float _showIntervalTime = 0.125f;
    [SerializeField, Tooltip("非表示に掛かる時間")] private float _hideIntervalTime = 0.25f;
    
    [SerializeField, Tooltip("Vignette用Meshの形状(基本Normalで問題ありません)")]
    private MeshComplexityLevel meshComplexity = MeshComplexityLevel.Normal;
    [SerializeField, Tooltip("VignetteのFalloffの設定")]
    private FalloffType falloff = FalloffType.Linear;
    [Tooltip("Vignetteの垂直FOV")]
    public float vignetteFieldOfView = 60;
    [Tooltip("Vignetteの水平FOVを調整するAspect Ratio")]
    public float vignetteAspectRatio = 1f;
    [Tooltip("VignetteのFalloffの広さ")]
    public float vignetteFalloffDegrees = 10f;
    [ColorUsage(false)] public Color vignetteColor;
    [Tooltip("半透明と不透明のVignetteの間隔")]
    public float middleOffset = 1.02f;

    private float _rate = 0f;
    private float _targetRate = 0f;
    private bool _isAnimating = false;
    private bool _isVignetteStart = true;
    
    /// <summary>
    /// Vignette用Meshの形状
    /// </summary>
    private enum MeshComplexityLevel
    {
        VerySimple,
        Simple,
        Normal,
        Detailed,
        VeryDetailed
    }

    /// <summary>
    /// VignetteのFalloffの設定
    /// </summary>
    private enum FalloffType
    {
        Linear,
        Quadratic
    }
    private static readonly string QUADRATIC_FALLOFF = "QUADRATIC_FALLOFF";

    [SerializeField][HideInInspector] private Shader vignetteShader;
    
    private Camera _camera;
    private MeshFilter _opaqueMeshFilter;
    private MeshFilter _transparentMeshFilter;
    private MeshRenderer _opaqueMeshRenderer;
    private MeshRenderer _transparentMeshRenderer;

    private Mesh _opaqueMesh;
    private Mesh _transparentMesh;
    private Material _opaqueMaterial;
    private Material _transparentMaterial;
    private int _shaderScaleAndOffset0Property;
    private int _shaderScaleAndOffset1Property;
    
    private readonly float[] _innerScaleX = new float[2];
    private readonly float[] _innerScaleY = new float[2];
    private readonly float[] _middleScaleX = new float[2];
    private readonly float[] _middleScaleY = new float[2];
    private readonly float[] _outerScaleX = new float[2];
    private readonly float[] _outerScaleY = new float[2];
    private readonly float[] _offsetX = new float[2];
    private readonly float[] _offsetY = new float[2];
    private readonly float[] _maxVignetteRange = new float[2];
    
    private readonly Vector4[] _TransparentScaleAndOffset0 = new Vector4[2];
    private readonly Vector4[] _TransparentScaleAndOffset1 = new Vector4[2];
    private readonly Vector4[] _OpaqueScaleAndOffset0 = new Vector4[2];
    private readonly Vector4[] _OpaqueScaleAndOffset1 = new Vector4[2];
    
    private bool _opaqueVignetteVisible = false;
    private bool _transparentVignetteVisible = false;
    
#if UNITY_EDITOR
    // in the editor, allow these to be changed at runtime
    private MeshComplexityLevel _InitialMeshComplexity;
    private FalloffType _InitialFalloff;
#endif
    private int GetTriangleCount()
    {
        switch (meshComplexity)
        {
            case MeshComplexityLevel.VerySimple: return 32;
            case MeshComplexityLevel.Simple: return 64;
            case MeshComplexityLevel.Normal: return 128;
            case MeshComplexityLevel.Detailed: return 256;
            case MeshComplexityLevel.VeryDetailed: return 512;
            default: return 128;
        }
    }

    /// <summary>
    /// Vignette用不透明Mesh、半透明Meshを作成する
    /// </summary>
    private void BuildMeshes()
    {
#if UNITY_EDITOR
        _InitialMeshComplexity = meshComplexity;
#endif
        // Vignette用Meshの構成する三角形の数
        // ※ 四角いVignetteのためには8個のVertexが必要
        int triangleCount = GetTriangleCount();

        // Vignette用半透明MeshのVertexバッファー、及びUVの情報を格納
        Vector3[] innerVerts = new Vector3[triangleCount];
        Vector2[] innerUVs = new Vector2[triangleCount];
        // Vignette用不透明MeshのVertexバッファー、及びUVの情報を格納
        Vector3[] outerVerts = new Vector3[triangleCount];
        Vector2[] outerUVs = new Vector2[triangleCount];
        // Vignette用半透明Meshと不透明MeshのIndexバッファー(VertexのTriangleの順番)を格納
        int[] tris = new int[triangleCount * 3];
        
        // Vignette用Meshの作成
        // この処理だけではVertexが三角形ではなくて線になるが、
        // Shader側でUVのXの値からOuterかInnerかを判定し、移動させることで三角形になる
        for (int i = 0; i < triangleCount; i += 2)
        {
            // Vertexの座標が円周上になるように計算
            float angle = 2 * i * Mathf.PI / triangleCount;
            float x = Mathf.Cos(angle);
            float y = Mathf.Sin(angle);

            // 不透明MeshのVertex、UVの情報を格納
            // outerVerts[i]とouterVerts[i + 1]は同じ座標
            outerVerts[i] = new Vector3(x, y, 0);
            outerVerts[i + 1] = new Vector3(x, y, 0);
            // UVのXはVertex ShaderでVertexを外側・内側に移動するかを判定するために使われる
            // 結果的にouterVerts[i]は外側、outerVerts[i + 1]は内側に移動される
            // UVのYはVignetteのMeshの半透明度(アルファ)に使われるので、不透明には1に設定
            outerUVs[i] = new Vector2(0, 1);
            outerUVs[i + 1] = new Vector2(1, 1);

            // 半透明MeshのVertex、UVの情報を格納
            // innerVerts[i]とinnerVerts[i + 1]は同じ座標
            innerVerts[i] = new Vector3(x, y, 0);
            innerVerts[i + 1] = new Vector3(x, y, 0);
            // UVのXはVertex ShaderでVertexを外側・内側に移動するかを判定するために使われる
            // 結果的にinnerVerts[i]は内側、innerVerts[i + 1]は外側に移動される
            // UVのYはVignetteのMeshの半透明度(アルファ)のために使われる
            // 外側->内側が1->0なので、結果的に半透明のVignetteがグラデーションになる
            innerUVs[i] = new Vector2(0, 1);
            innerUVs[i + 1] = new Vector2(1, 0);

            // 三角形のIndexを計算
            int ti = i * 3;
            tris[ti] = i;
            tris[ti + 1] = i + 1;
            tris[ti + 2] = (i + 2) % triangleCount;
            tris[ti + 3] = i + 1;
            tris[ti + 4] = (i + 3) % triangleCount;
            tris[ti + 5] = (i + 2) % triangleCount;
            
            // 例えば、triangleCountが8の場合、Meshの形状は以下となる(不透明や半透明も同じ形状)
            //                      , - ~V23~ - ,
            //                  , '    I     I    ' ,
            //                ,     I           I     ,
            //               ,   I                 I   ,
            //              , I                       I ,
            //              V45                         V01
            //              , I                       I ,
            //               ,   I                 I   ,
            //                ,     I           I     ,
            //                  ,      I     I     , '
            //                    ' - , _V67_ ,  '
            // (VはVertexとその番号で、IはIndex順に引いた線のこと)
            //
            // 同じ座標にあるVertexはVertex Shaderにて移動されて三角形になる
            // UVのXの値からVertexを外側に移動するか、内側に移動するかを判定する
            // 移動の値はCalculateVignetteScaleAndOffsetで計算してShaderに渡す
            // 
            // 例えば、triangleCountが8の場合のMeshの形状は以下となる
            //                            V2
            //                         I  I  I
            //                     I    I I I    I
            //                  I   ,  I~ I ~I  ,   I
            //               I  , '   I   I   I   ' ,  I
            //            I   ,      I    V3    I     ,   I 
            //         I     ,      I   I    I   I     ,     I
            //      I       ,      I  I        I  I     ,       I
            //    V4 I I I I I I I V5            V1 I I I I I I I V0
            //      I       ,      I  I        I  I     ,       I
            //         I     ,      I   I    I   I     ,     I
            //            I   ,      I    V7    I     ,   I
            //               I  ,     I   I   I    , '  I
            //                  I ' - ,I_ I _I,  '   I
            //                     I    I I I    I
            //                         I  I  I
            //                           V6
            // (VはVertexとその番号で、IはIndex順に引いた線のこと)
            // 
            // 上記の形状は半透明Vignetteも不透明Vignetteも同じ
            // ただ、半透明Vignetteが不透明Vignetteの一番奥の四角形(実際には円)に当てはまるように
            // CalculateVignetteScaleAndOffsetで計算してShaderに渡すので、
            // 半透明Vignetteと不透明Vignetteが分かられてVignetteを描画することができる
        }

        if (_opaqueMesh != null)
        {
            DestroyImmediate(_opaqueMesh);
        }

        if (_transparentMesh != null)
        {
            DestroyImmediate(_transparentMesh);
        }

        _opaqueMesh = new Mesh()
        {
            name = "Opaque Vignette Mesh",
            hideFlags = HideFlags.HideAndDontSave
        };
        _transparentMesh = new Mesh()
        {
            name = "Transparent Vignette Mesh",
            hideFlags = HideFlags.HideAndDontSave
        };

        _opaqueMesh.vertices = outerVerts;
        _opaqueMesh.uv = outerUVs;
        _opaqueMesh.triangles = tris;
        _opaqueMesh.UploadMeshData(true);
        _opaqueMesh.bounds = new Bounds(Vector3.zero, Vector3.one * 10000);
        _opaqueMeshFilter.sharedMesh = _opaqueMesh;

        _transparentMesh.vertices = innerVerts;
        _transparentMesh.uv = innerUVs;
        _transparentMesh.triangles = tris;
        _transparentMesh.UploadMeshData(true);
        _transparentMesh.bounds = new Bounds(Vector3.zero, Vector3.one * 10000);
        _transparentMeshFilter.sharedMesh = _transparentMesh;
    }
    
    private void BuildMaterials()
    {
#if UNITY_EDITOR
        _InitialFalloff = falloff;
#endif
        if (vignetteShader == null)
        {
            vignetteShader = Shader.Find("App/Vignette");
        }

        if (vignetteShader == null)
        {
            Debug.LogError("Could not find Vignette Shader! Vignette will not be drawn!");
            return;
        }

        if (_opaqueMaterial == null)
        {
            _opaqueMaterial = new Material(vignetteShader)
            {
                name = "Opaque Vignette Material",
                hideFlags = HideFlags.HideAndDontSave,
                renderQueue = (int)RenderQueue.Background
            };
            _opaqueMaterial.SetFloat("_BlendSrc", (float)BlendMode.One);
            _opaqueMaterial.SetFloat("_BlendDst", (float)BlendMode.Zero);
            _opaqueMaterial.SetFloat("_ZWrite", 1);
        }

        _opaqueMeshRenderer.sharedMaterial = _opaqueMaterial;

        if (_transparentMaterial == null)
        {
            _transparentMaterial = new Material(vignetteShader)
            {
                name = "Transparent Vignette Material",
                hideFlags = HideFlags.HideAndDontSave,
                renderQueue = (int)RenderQueue.Overlay
            };

            _transparentMaterial.SetFloat("_BlendSrc", (float)BlendMode.SrcAlpha);
            _transparentMaterial.SetFloat("_BlendDst", (float)BlendMode.OneMinusSrcAlpha);
            _transparentMaterial.SetFloat("_ZWrite", 0);
        }

        if (falloff == FalloffType.Quadratic)
        {
            _transparentMaterial.EnableKeyword(QUADRATIC_FALLOFF);
        }
        else
        {
            _transparentMaterial.DisableKeyword(QUADRATIC_FALLOFF);
        }

        _transparentMeshRenderer.sharedMaterial = _transparentMaterial;
    }
    
    private void OnEnable()
    {
        RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
    }

    private void OnDisable()
    {
        RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
        DisableRenderers();
    }
    
    public void Init(Camera camera)
    {
        _camera = camera;

        _shaderScaleAndOffset0Property = Shader.PropertyToID("_ScaleAndOffset0");
        _shaderScaleAndOffset1Property = Shader.PropertyToID("_ScaleAndOffset1");

        GameObject opaqueObject = new GameObject("Opaque Vignette") { hideFlags = HideFlags.HideAndDontSave };
        opaqueObject.transform.SetParent(_camera.transform, false);
        _opaqueMeshFilter = opaqueObject.AddComponent<MeshFilter>();
        _opaqueMeshRenderer = opaqueObject.AddComponent<MeshRenderer>();

        _opaqueMeshRenderer.receiveShadows = false;
        _opaqueMeshRenderer.shadowCastingMode = ShadowCastingMode.Off;
        _opaqueMeshRenderer.lightProbeUsage = LightProbeUsage.Off;
        _opaqueMeshRenderer.reflectionProbeUsage = ReflectionProbeUsage.Off;
        _opaqueMeshRenderer.allowOcclusionWhenDynamic = false;
        _opaqueMeshRenderer.enabled = false;

        GameObject transparentObject = new GameObject("Transparent Vignette") { hideFlags = HideFlags.HideAndDontSave };
        transparentObject.transform.SetParent(_camera.transform, false);
        _transparentMeshFilter = transparentObject.AddComponent<MeshFilter>();
        _transparentMeshRenderer = transparentObject.AddComponent<MeshRenderer>();

        _transparentMeshRenderer.receiveShadows = false;
        _transparentMeshRenderer.shadowCastingMode = ShadowCastingMode.Off;
        _transparentMeshRenderer.lightProbeUsage = LightProbeUsage.Off;
        _transparentMeshRenderer.reflectionProbeUsage = ReflectionProbeUsage.Off;
        _transparentMeshRenderer.allowOcclusionWhenDynamic = false;
        _transparentMeshRenderer.enabled = false;

        _innerScaleX.Initialize();
        _innerScaleY.Initialize();
        _middleScaleX.Initialize();
        _middleScaleY.Initialize();
        _outerScaleX.Initialize();
        _outerScaleY.Initialize();
        _offsetX.Initialize();
        _offsetY.Initialize();
        _maxVignetteRange[0] = 1.0f;
        _maxVignetteRange[1] = 1.0f;
        
        BuildMeshes();
        BuildMaterials();
    }

    private void Update()
    {

#if UNITY_EDITOR
    if (meshComplexity != _InitialMeshComplexity)
    {
        // rebuild meshes
        BuildMeshes();
    }

    if (falloff != _InitialFalloff)
    {
        // rebuild materials
        BuildMaterials();
    }
#endif 
        // Nullチェック
        if (_opaqueMaterial == null)
        {
            return;
        }
        
        // Vignettingチェック
        if (!_isAnimating)
        {
            return;
        }
        
        // 初期化
        _transparentVignetteVisible = false;
        _opaqueVignetteVisible = false;

        // Vignetteのrate計算
        var deltaTime = Time.deltaTime;
        if (_targetRate > _rate)
        {
            //表示処理
            var rate = _rate + deltaTime / _showIntervalTime;
            _rate = Mathf.Clamp(rate, 0f, 1f);

            if (_rate >= _targetRate)
            {
                _isAnimating = false;
            }
        }
        else
        {
            //非表示処理
            var rate = _rate - deltaTime / _hideIntervalTime;
            _rate = Mathf.Clamp(rate, 0f, 1f);

            if (_rate <= _targetRate)
            {
                _isAnimating = false;
            }
        }

        SetVignetteMaterial();
    }

    private void CalculateVignetteScaleAndOffset()
    {
        var tanInnerFovY = Mathf.Tan(vignetteFieldOfView * Mathf.Deg2Rad * 0.5f);
        var tanInnerFovX = tanInnerFovY * vignetteAspectRatio;
        var tanMiddleFovX = Mathf.Tan((vignetteFieldOfView + vignetteFalloffDegrees) * Mathf.Deg2Rad * 0.5f);
        var tanMiddleFovY = tanMiddleFovX * vignetteAspectRatio;
        
        for (int i = 0; i < 2; i++)
        {
            float tanFovX, tanFovY;
            if (_camera.stereoEnabled)
            {
                GetTanFovAndOffsetForStereoEye((Camera.StereoscopicEye)i, out tanFovX, out tanFovY, out _offsetX[i], out _offsetY[i]);
            }
            else
            {
                GetTanFovAndOffsetForMonoEye(out tanFovX, out tanFovY, out _offsetX[i], out _offsetY[i]);
            }

            float borderScale = new Vector2((1 + Mathf.Abs(_offsetX[i])) / vignetteAspectRatio, 1 + Mathf.Abs(_offsetY[i])).magnitude * 1.01f;

            _innerScaleX[i] = tanInnerFovX / tanFovX;
            _innerScaleY[i] = tanInnerFovY / tanFovY;
            _middleScaleX[i] = tanMiddleFovX / tanFovX;
            _middleScaleY[i] = tanMiddleFovY / tanFovY;
            _outerScaleX[i] = borderScale * vignetteAspectRatio;
            _outerScaleY[i] = borderScale;

            // Vignette非表示時の最大値
            _maxVignetteRange[i] = new Vector2((1 + Mathf.Abs(_offsetX[i])) / _innerScaleX[i], (1 + Mathf.Abs(_offsetY[i])) / _innerScaleY[i]).magnitude;
        }
    }

    private void SetVignetteMaterial()
    {
        for (var i = 0; i < 2; i++)
        {
            var vignette = 1f - _rate;
            vignette = Remap(vignette, 0f, 1f, 1f, _maxVignetteRange[i]);
            
            // VignetteのIn、Out
            var innerScaleX = _innerScaleX[i] * vignette;
            var innerScaleY = _innerScaleY[i] * vignette;
            var middleScaleX = _middleScaleX[i] * vignette;
            var middleScaleY = _middleScaleY[i] * vignette;
            var outerScaleX = _outerScaleX[i];
            var outerScaleY = _outerScaleY[i];
            var offsetX = _offsetX[i];
            var offsetY = _offsetY[i];

            _OpaqueScaleAndOffset0[i] = new Vector4(outerScaleX, outerScaleY, offsetX, offsetY);
            _OpaqueScaleAndOffset1[i] = new Vector4(middleScaleX, middleScaleY, offsetX, offsetY);
            middleScaleX *= middleOffset;
            middleScaleY *= middleOffset;
            _TransparentScaleAndOffset0[i] = new Vector4(middleScaleX, middleScaleY, offsetX, offsetY);
            _TransparentScaleAndOffset1[i] = new Vector4(innerScaleX, innerScaleY, offsetX, offsetY);
            
            // Vignetteの表示チェック
            _transparentVignetteVisible |= VisibilityTest(_innerScaleX[i], _innerScaleY[i], _offsetX[i], _offsetY[i]);
            _opaqueVignetteVisible |= VisibilityTest(_middleScaleX[i], _middleScaleY[i], _offsetX[i], _offsetY[i]);
        }
        
        // VignetteFalloffDegreesが0以下だと、透明なメッシュを描画する必要がない
        _transparentVignetteVisible &= vignetteFalloffDegrees > 0.0f;
        
        _opaqueMaterial.SetVectorArray(_shaderScaleAndOffset0Property, _OpaqueScaleAndOffset0);
        _opaqueMaterial.SetVectorArray(_shaderScaleAndOffset1Property, _OpaqueScaleAndOffset1);
        _opaqueMaterial.color = vignetteColor;
        _transparentMaterial.SetVectorArray(_shaderScaleAndOffset0Property, _TransparentScaleAndOffset0);
        _transparentMaterial.SetVectorArray(_shaderScaleAndOffset1Property, _TransparentScaleAndOffset1);
        _transparentMaterial.color = vignetteColor;
    }

    public void VignetteIn()
    {
        if (_rate >= 1f)
        {
            //既に表示されていたら抜ける
            if (!_isVignetteStart)
            {
                _isVignetteStart = true;
            }
            return;
        }
        _targetRate = 1f;
        _isAnimating = true;
        
        if (_isVignetteStart)
        {
            CalculateVignetteScaleAndOffset();
            _isVignetteStart = false;
        }
    }
    
    public void VignetteOut()
    {
        if (_rate <= 0f)
        {
            //既に非表示だったら抜ける
            if (!_isVignetteStart)
            {
                _isVignetteStart = true;
            }
            return;
        }
        _targetRate = 0f;
        _isAnimating = true;
        
        if (_isVignetteStart)
        {
            CalculateVignetteScaleAndOffset();
            _isVignetteStart = false;
        }
    }

    private void GetTanFovAndOffsetForStereoEye(Camera.StereoscopicEye eye, out float tanFovX, out float tanFovY, out float offsetX, out float offsetY)
    {
        // VRの場合のTangent FOV、Offsetを計算
        var pt = _camera.GetStereoProjectionMatrix(eye).transpose;

        var right = pt * new Vector4(-1, 0, 0, 1);
        var left = pt * new Vector4(1, 0, 0, 1);
        var up = pt * new Vector4(0, -1, 0, 1);
        var down = pt * new Vector4(0, 1, 0, 1);

        var rightTanFovX = right.z / right.x;
        var leftTanFovX = left.z / left.x;
        var upTanFovY = up.z / up.y;
        var downTanFovY = down.z / down.y;

        offsetX = -(rightTanFovX + leftTanFovX) / 2;
        offsetY = -(upTanFovY + downTanFovY) / 2;

        tanFovX = (rightTanFovX - leftTanFovX) / 2;
        tanFovY = (upTanFovY - downTanFovY) / 2;
    }

    private void GetTanFovAndOffsetForMonoEye(out float tanFovX, out float tanFovY, out float offsetX, out float offsetY)
    {
        // VRではない場合のTangent FOV計算
        tanFovY = Mathf.Tan(Mathf.Deg2Rad * _camera.fieldOfView * 0.5f);
        tanFovX = tanFovY * _camera.aspect;
        offsetX = 0f;
        offsetY = 0f;
    }

    private static bool VisibilityTest(float scaleX, float scaleY, float offsetX, float offsetY)
    {
        // Vignetteが見えている状態かチェックする
        // ViewportのコーナーがVignetteから一番遠いため、ViewportのコーナーがVignetteの外にあるかどうかチェックするだけで良い
        return new Vector2((1 + Mathf.Abs(offsetX)) / scaleX, (1 + Mathf.Abs(offsetY)) / scaleY).sqrMagnitude > 1.0f;
    }
    
    private static float Remap(float val, float inMin, float inMax, float outMin, float outMax)
    {
        return outMin + (val - inMin) * (outMax - outMin) / (inMax - inMin);
    }
    
    private void EnableRenderers()
    {
        _opaqueMeshRenderer.enabled = _opaqueVignetteVisible;
        _transparentMeshRenderer.enabled = _transparentVignetteVisible;
    }

    private void DisableRenderers()
    {
        _opaqueMeshRenderer.enabled = false;
        _transparentMeshRenderer.enabled = false;
    }

    private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
    {
        if (camera == _camera)
        {
            EnableRenderers();
        }
        else
        {
            DisableRenderers();
        }
    }
}

 

Shader

Shader "App/Vignette"
{
    Properties
    {
        _Color("Color", Color) = (0,0,0,0)
        [Enum(UnityEngine.Rendering.BlendMode)]_BlendSrc ("Blend Source", Float) = 1
        [Enum(UnityEngine.Rendering.BlendMode)]_BlendDst ("Blend Destination", Float) = 0
        _ZWrite ("Z Write", Float) = 0
    }
    SubShader
    {
        Tags { "IgnoreProjector" = "True" }

        Pass
        {
            Blend [_BlendSrc] [_BlendDst]
            ZTest Always
            ZWrite [_ZWrite]
            Cull Off

            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile _ QUADRATIC_FALLOFF
            // #pragma enable_d3d11_debug_symbols

            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                half4 color : COLOR;
                UNITY_VERTEX_INPUT_INSTANCE_ID
                UNITY_VERTEX_OUTPUT_STEREO
            };

            float4 _ScaleAndOffset0[2];
            float4 _ScaleAndOffset1[2];

            CBUFFER_START(UnityPerMaterial)
            float4 _Color;
            CBUFFER_END

            v2f vert (appdata v)
            {
                v2f o;
                ZERO_INITIALIZE(v2f, o);
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

                // _ScaleAndOffset0はVertexを外側に、_ScaleAndOffset1は内側に移動するための値が格納されている
                // また、VignetteのMesh作成時、奇数のVertextのUVのXは0、偶数のVertexのUVのXは1になるようにしているため、
                // Lerpで、奇数のVertextは外側、偶数のVertexは内側に移動される
                float4 scaleAndOffset = lerp(_ScaleAndOffset0[unity_StereoEyeIndex], _ScaleAndOffset1[unity_StereoEyeIndex], v.uv.x);

                // UNITY_NEAR_CLIP_VALUEはHClip Spaceで、Vignetteをカメラの手前に表示するための値
                o.vertex = float4(scaleAndOffset.zw + v.vertex.xy * scaleAndOffset.xy, UNITY_NEAR_CLIP_VALUE, 1);

                o.color.rgb = _Color.rgb;
                // UVのYをアルファ値として使う
                // VignetteのMesh作成時に不透明はYを1、半透明は外->内が0->1になるようにしている
                o.color.a = v.uv.y;
                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                UNITY_SETUP_INSTANCE_ID(i);
                UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(i);
#if QUADRATIC_FALLOFF
                i.color.a *= i.color.a;
#endif
                return i.color;
            }
            ENDHLSL
        }
    }
}

Vignette용 Mesh 작성에 대한 분석

우선 Vignette용 Mesh가 어떤 식으로 만들어지는지 알아보자

private int GetTriangleCount()
{
    switch (meshComplexity)
    {
        case MeshComplexityLevel.VerySimple: return 32;
        case MeshComplexityLevel.Simple: return 64;
        case MeshComplexityLevel.Normal: return 128;
        case MeshComplexityLevel.Detailed: return 256;
        case MeshComplexityLevel.VeryDetailed: return 512;
        default: return 128;
    }
}

private void BuildMeshes()
{
    int triangleCount = GetTriangleCount();

    // Transparent Vignette Mesh
    Vector3[] innerVerts = new Vector3[triangleCount];
    Vector2[] innerUVs = new Vector2[triangleCount];
    // Opaque Vignette Mesh
    Vector3[] outerVerts = new Vector3[triangleCount];
    Vector2[] outerUVs = new Vector2[triangleCount];
    int[] tris = new int[triangleCount * 3];
    for (int i = 0; i < triangleCount; i += 2)
    {
        float angle = 2 * i * Mathf.PI / triangleCount;

        float x = Mathf.Cos(angle);
        float y = Mathf.Sin(angle);

        outerVerts[i] = new Vector3(x, y, 0);
        outerVerts[i + 1] = new Vector3(x, y, 0);
        outerUVs[i] = new Vector2(0, 1);
        outerUVs[i + 1] = new Vector2(1, 1);

        innerVerts[i] = new Vector3(x, y, 0);
        innerVerts[i + 1] = new Vector3(x, y, 0);
        innerUVs[i] = new Vector2(0, 1);
        innerUVs[i + 1] = new Vector2(1, 0);

        int ti = i * 3;
        tris[ti] = i;
        tris[ti + 1] = i + 1;
        tris[ti + 2] = (i + 2) % triangleCount;
        tris[ti + 3] = i + 1;
        tris[ti + 4] = (i + 3) % triangleCount;
        tris[ti + 5] = (i + 2) % triangleCount;
    }

    if (_opaqueMesh != null)
    {
        DestroyImmediate(_opaqueMesh);
    }

    if (_transparentMesh != null)
    {
        DestroyImmediate(_transparentMesh);
    }

    _opaqueMesh = new Mesh()
    {
        name = "Opaque Vignette Mesh",
        hideFlags = HideFlags.HideAndDontSave
    };
    _transparentMesh = new Mesh()
    {
        name = "Transparent Vignette Mesh",
        hideFlags = HideFlags.HideAndDontSave
    };

    _opaqueMesh.vertices = outerVerts;
    _opaqueMesh.uv = outerUVs;
    _opaqueMesh.triangles = tris;
    _opaqueMesh.UploadMeshData(true);
    _opaqueMesh.bounds = new Bounds(Vector3.zero, Vector3.one * 10000);
    _opaqueMeshFilter.sharedMesh = _opaqueMesh;

    _transparentMesh.vertices = innerVerts;
    _transparentMesh.uv = innerUVs;
    _transparentMesh.triangles = tris;
    _transparentMesh.UploadMeshData(true);
    _transparentMesh.bounds = new Bounds(Vector3.zero, Vector3.one * 10000);
    _transparentMeshFilter.sharedMesh = _transparentMesh;
}

 

구현상 triangleCount가 32를 최소로 제한 하고 있지만, 로직상 가능한 최소값은 8이다

8인 경우 사각형(마름모)의 Vignette이 만들어지며, 128이면 64각형(거의 원)의 비넷이 만들어진다

 

우선은 triangleCount가 8(즉, 사각형)인 경우를 예를 들어 분석해보겠다

이때의 버텍스 버퍼와 UV는 다음과 같다(Z축은 셰이더에서 덮여쓰기하니 무시)

// 불투명
outerVerts[0] =>  1, 0, 0    UV 0, 1
outerVerts[1] =>  1, 0, 0    UV 1, 1
outerVerts[2] =>  0, 1, 0    UV 0, 1
outerVerts[3] =>  0, 1, 0    UV 1, 1
outerVerts[4] => -1, 0, 0    UV 0, 1
outerVerts[5] => -1, 0, 0    UV 1, 1
outerVerts[6] => -1,-1, 0    UV 0, 1
outerVerts[7] => -1,-1, 0    UV 1, 1

// 반투명
innerVerts[0] =>  1, 0, 0    UV 0, 1
innerVerts[1] =>  1, 0, 0    UV 1, 0
innerVerts[2] =>  0, 1, 0    UV 0, 1
innerVerts[3] =>  0, 1, 0    UV 1, 0
innerVerts[4] => -1, 0, 0    UV 0, 1
innerVerts[5] => -1, 0, 0    UV 1, 0
innerVerts[6] => -1,-1, 0    UV 0, 1
innerVerts[7] => -1,-1, 0    UV 1, 0

 

그리고 인덱스 버퍼는 다음과 같다

0 1 2 / 1 3 2
2 3 4 / 3 5 4
4 5 6 / 5 7 6
6 7 0 / 7 1 0

 

여기서 바로 의문점이 생긴다

바로 인덱스 버퍼에 따라서 버텍스를 그려보면 Mesh의 삼각형이 안 그려진다는 것!

i와 i+1 버텍스의 좌표가 같기 때문에 선이 될 뿐이다…

(그리고 Topology를 따로 설정하지 않는 이상 결국 선은 안 그려질 것 같다)


선을 삼각형으로 바꾸는 비밀은?!

사실 엄청난 비밀이 있는 것은 아니다

단지 i와 i+1 버텍스의 위치를 이동시키면 된다

 

조금 정리하자면 이번에 구현하는 건 불투명 Vignette Mesh와 그 안에 들어하는 반투명 Vignette Mesh이다

즉, 버텍스의 위치가 중심으로부터 제일 멀리 있는 순서대로 적어보면 아래와 같이 될 것이다

불투명 Vignette의 바깥쪽 > 불투명 Vignette의 안쪽 = 반투명 Vignette의 바깥쪽 > 반투명 Vignette의 안쪽

 

다시 말해 Outer, Middle, Inner를 구해 각각 이동시키면 된다!

private void CalculateVignetteScaleAndOffset()
{
    var tanInnerFovY = Mathf.Tan(vignetteFieldOfView * Mathf.Deg2Rad * 0.5f);
    var tanInnerFovX = tanInnerFovY * vignetteAspectRatio;
    var tanMiddleFovX = Mathf.Tan((vignetteFieldOfView + vignetteFalloffDegrees) * Mathf.Deg2Rad * 0.5f);
    var tanMiddleFovY = tanMiddleFovX * vignetteAspectRatio;
    
    for (int i = 0; i < 2; i++)
    {
        float tanFovX, tanFovY;
        if (_camera.stereoEnabled)
        {
            GetTanFovAndOffsetForStereoEye((Camera.StereoscopicEye)i, out tanFovX, out tanFovY, out _offsetX[i], out _offsetY[i]);
        }
        else
        {
            GetTanFovAndOffsetForMonoEye(out tanFovX, out tanFovY, out _offsetX[i], out _offsetY[i]);
        }

        float borderScale = new Vector2((1 + Mathf.Abs(_offsetX[i])) / vignetteAspectRatio, 1 + Mathf.Abs(_offsetY[i])).magnitude * 1.01f;

        _innerScaleX[i] = tanInnerFovX / tanFovX;
        _innerScaleY[i] = tanInnerFovY / tanFovY;
        _middleScaleX[i] = tanMiddleFovX / tanFovX;
        _middleScaleY[i] = tanMiddleFovY / tanFovY;
        _outerScaleX[i] = borderScale * vignetteAspectRatio;
        _outerScaleY[i] = borderScale;

        // Vignette를 비활성화할 때의 최대값
        _maxVignetteRange[i] = new Vector2((1 + Mathf.Abs(_offsetX[i])) / _innerScaleX[i], (1 + Mathf.Abs(_offsetY[i])) / _innerScaleY[i]).magnitude;
    }
}

 

여기서 카메라가 stereo(즉, VR)인 경우에는 GetTanFovAndOffsetForStereoEye란 메소드를 사용하고, 그렇지 않을 경우에는 GetTanFovAndOffsetForMonoEye를 사용한다

GetTanFovAndOffsetForStereoEye에서 양쪽 스크린에 대한 Offset 구하기 때문에 결과적으로 Vignette으로 인해 가운데가 어두워지는 현상이 해결된다

필자도 제대로 이해한 건 아니라서 자세한 설명은 생략하도록 하겠다

 

아무튼 버텍스를 얼마만큼 이동시킬지 구했으면 이제 그걸 셰이더에 넘겨준다!

// Script
private void SetVignetteMaterial()
{
    for (var i = 0; i < 2; i++)
    {
        var vignette = 1f - _rate;
        vignette = Remap(vignette, 0f, 1f, 1f, _maxVignetteRange[i]);
        
        var innerScaleX = _innerScaleX[i] * vignette;
        var innerScaleY = _innerScaleY[i] * vignette;
        var middleScaleX = _middleScaleX[i] * vignette;
        var middleScaleY = _middleScaleY[i] * vignette;
        var outerScaleX = _outerScaleX[i];
        var outerScaleY = _outerScaleY[i];
        var offsetX = _offsetX[i];
        var offsetY = _offsetY[i];

        _OpaqueScaleAndOffset0[i] = new Vector4(outerScaleX, outerScaleY, offsetX, offsetY);
        _OpaqueScaleAndOffset1[i] = new Vector4(middleScaleX, middleScaleY, offsetX, offsetY);
        middleScaleX *= middleOffset;
        middleScaleY *= middleOffset;
        _TransparentScaleAndOffset0[i] = new Vector4(middleScaleX, middleScaleY, offsetX, offsetY);
        _TransparentScaleAndOffset1[i] = new Vector4(innerScaleX, innerScaleY, offsetX, offsetY);
        
        _transparentVignetteVisible |= VisibilityTest(_innerScaleX[i], _innerScaleY[i], _offsetX[i], _offsetY[i]);
        _opaqueVignetteVisible |= VisibilityTest(_middleScaleX[i], _middleScaleY[i], _offsetX[i], _offsetY[i]);
    }
    
    _transparentVignetteVisible &= vignetteFalloffDegrees > 0.0f;
        
    _opaqueMaterial.SetVectorArray(_shaderScaleAndOffset0Property, _OpaqueScaleAndOffset0);
    _opaqueMaterial.SetVectorArray(_shaderScaleAndOffset1Property, _OpaqueScaleAndOffset1);
    _opaqueMaterial.color = vignetteColor;
    _transparentMaterial.SetVectorArray(_shaderScaleAndOffset0Property, _TransparentScaleAndOffset0);
    _transparentMaterial.SetVectorArray(_shaderScaleAndOffset1Property, _TransparentScaleAndOffset1);
    _transparentMaterial.color = vignetteColor;
}

 

그럼 버텍스 셰이더에서는 UV의 X좌표와 Lerp로 버텍스의 위치를 조정한다

스크립트에서 _ScaleAndOffset0 변수에 바깥쪽에 대한 Scale과 Offset 값을, _ScaleAndOffset1 변수에는 안쪽에 대한 Scale과 Offset 값을 넘기고 있다

// Shader
v2f vert (appdata v)
{
    v2f o;
    ZERO_INITIALIZE(v2f, o);
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    float4 scaleAndOffset = lerp(_ScaleAndOffset0[unity_StereoEyeIndex], _ScaleAndOffset1[unity_StereoEyeIndex], v.uv.x);

    o.vertex = float4(scaleAndOffset.zw + v.vertex.xy * scaleAndOffset.xy, UNITY_NEAR_CLIP_VALUE, 1);

    o.color.rgb = _Color.rgb;
    o.color.a = v.uv.y;
    return o;
}

 

Mesh를 만들 때 홀수에는 UV의 X를 0으로, 짝수에는 1로 설정했기 때문에 Lerp를 통해서 버텍스가 바깥쪽과 안쪽으로 적절히 이동하게 된다

(HClip 공간이므로 1 이상이 되면 화면 바깥이 됨)

이를 통해 좌표를 가지던 버텍스가 아래처럼 삼각형으로 바뀌게 된다!!

 

참고로 UV의 Y 값은 알파 값을 대체한다

반투명용 Vignette인 경우에는 바깥쪽 버텍스에 1, 안쪽 버텍스에 0을 설정한다

이러면 래스터라이저의 보간을 통해 바깥에서 안쪽으로 그라데이션이 된다!

불투명용 Vignette인 경우에는 전부 1로 설정하면 끝!


불투명은 제일 먼저, 반투명은 제일 나중에 그리기

최적화 관점에서 봤을 때 Vignette의 불투명 Mesh는 제일 먼저 그려야 한다

또한 렌더링 순서를 방해하지 않게 반투명 Mesh는 제일 마지막에 그려야 한다

하지만 Vignette용 셰이더는 하나이므로 불투명과 반투명 머티리얼이 따로 만들고, 각각 대한 키워드와 RenderQueue 등을 설정한다!

private void BuildMaterials()
{
#if UNITY_EDITOR
    _InitialFalloff = falloff;
#endif
    if (vignetteShader == null)
    {
        vignetteShader = Shader.Find("App/Vignette");
    }

    if (vignetteShader == null)
    {
        Debug.LogError("Could not find Vignette Shader! Vignette will not be drawn!");
        return;
    }

    if (_opaqueMaterial == null)
    {
        _opaqueMaterial = new Material(vignetteShader)
        {
            name = "Opaque Vignette Material",
            hideFlags = HideFlags.HideAndDontSave,
            renderQueue = (int)RenderQueue.Background
        };
        _opaqueMaterial.SetFloat("_BlendSrc", (float)BlendMode.One);
        _opaqueMaterial.SetFloat("_BlendDst", (float)BlendMode.Zero);
        _opaqueMaterial.SetFloat("_ZWrite", 1);
    }

    _opaqueMeshRenderer.sharedMaterial = _opaqueMaterial;

    if (_transparentMaterial == null)
    {
        _transparentMaterial = new Material(vignetteShader)
        {
            name = "Transparent Vignette Material",
            hideFlags = HideFlags.HideAndDontSave,
            renderQueue = (int)RenderQueue.Overlay
        };

        _transparentMaterial.SetFloat("_BlendSrc", (float)BlendMode.SrcAlpha);
        _transparentMaterial.SetFloat("_BlendDst", (float)BlendMode.OneMinusSrcAlpha);
        _transparentMaterial.SetFloat("_ZWrite", 0);
    }

    if (falloff == FalloffType.Quadratic)
    {
        _transparentMaterial.EnableKeyword(QUADRATIC_FALLOFF);
    }
    else
    {
        _transparentMaterial.DisableKeyword(QUADRATIC_FALLOFF);
    }

    _transparentMeshRenderer.sharedMaterial = _transparentMaterial;
}

결과

임시로 다음과 같은 VignetteController를 만들어서 실행했다

using UnityEngine;

public class VignetteController : MonoBehaviour
{
    public Camera camera;
    public Vignette vignette;
    
    // Start is called before the first frame update
    void Start()
    {
        vignette.Init(camera);
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKey(KeyCode.Space))
        {
            vignette.VignetteIn();
        }
        else
        {
            vignette.VignetteOut();
        }
    }
}

 

짜잔~!

 

렌더링 순서도 보면 불투명 Vignette가 가장 먼저, 반투명 Vignette가 가장 나중에 그려지는 것을 확인 할 수 있다

(구별하기 쉽게 Vigette의 색상을 노란색으로 변경)

 


마무리

사실 VR에서라면 몇가지 더 신경써야 하는 부분이 있긴 한데(AppSW라던가)

Vignette 구현하고 직접적인 관련이 있는 건 아니라 생략하도록 하겠다

사실 그쪽을 해결하느라 더 고생한 것 같다


참고 사이트

Oculus의 Vignette를 참고했다!