본문 바로가기
Unity

[Unity] 시야 구현하기(URP)

by 남대현 2025. 1. 20.
반응형

목표

1. 플레이어는 시야를 가진다.
2. 해당 시야의 각도를 조절할 수 있다. (사진의 경우 360도로 설정)
3. 장애물에 닿으면 그 뒷쪽은 시야가 닿지 않는다. (사진의 경우 파랑색이 벽)
4. 적들은 내 시야에 들어와 있어야 보이며, 장애물 또한 시야에 닿아야 보인다.
5. 내 시야가 닿지 않는 부분은 검은색으로 칠한다.

구현 방식 간단 요약

1. 플레이어는 지정한 각도로 많은 광선을 쏴서 닿는곳을 체크. (장애물 없을 시 최대거리)
2. 1번에서 얻은 각 광선들의 끝점을 이어 시야 오브젝트를 만듦.
2-1. 추후 처리를 위해 장애물 처리가 안되는 언제나 동그란 시야 오브젝트도 생성.

3. 시야 오브젝트의 스탠실값을 1로 수정.
3-1. 2-1의 오브젝트의 스탠실값을 2로 수정.

4. 적 오브젝트는 해당 부분의 스탠실값이 1일때만 나타나게 함.
5. 장애물 오브젝트는 스탠실값이 2일때만 해당 스탠실을 1로 변경. + 값이 1일때만 표시됨. (시야밖의 장애물은 안나옴)

6. 위에는 검은 판을 덮고, 이 판은 스탠실을 반대로 구현하여 스탠실이 1이 아닌 부분만 나타나게 함.

스탠실 값은 현재 이런 상태다. (왼쪽의 네모는 시야에 들어오지 않는 장애물[파랑 네모])



자세한 설명은 아래 영상/영상을 번역해둔 블로그가 정말 잘 설명해줬으니 거기 가서 보는걸 권장.
나도 그대로 긁어와서 스탠실 쉐이더만 URP용으로 변경하고, 2-1, 3-1의 작업만 추가해줬을 뿐이다.

// 쓰면서 생각났는데 버그있음.
// 저러면 두겹으로 있는 벽들이 시야 범위내에 있으면 뒤쪽 벽도 보이네...
// 내 시야에 처음 닫는 장애물만 보이도록 개선해야할듯.

코드

메인이 되는 FieldOfView 파일.
나도 그대로 긁어와서 스탠실 쉐이더만 URP용으로 변경하고, 2-1, 3-1의 작업만 추가해줬을 뿐이다.

기타 상세한 내용은 아래의 영상/영상을 번역한 블로그 참조.

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

namespace Scripts
{
    public struct ViewCastInfo
    {
        public bool hit;
        public Vector3 point;
        public float dst;
        public float angle;

        public ViewCastInfo(bool _hit, Vector3 _point, float _dst, float _angle)
        {
            hit = _hit;
            point = _point;
            dst = _dst;
            angle = _angle;
        }
    }
    
    public struct Edge
    {
        public Vector3 PointA, PointB;
        public Edge(Vector3 _PointA, Vector3 _PointB)
        {
            PointA = _PointA;
            PointB = _PointB;
        }
    }
    
    public class FieldOfView : MonoBehaviour
    {
        public float viewRadius;
        [Range(0, 360)]
        public float viewAngle;
        [Tooltip("값이 높을수록 계산량이 많아져 더 디테일해짐")]
        public float meshResolution;
        
        public LayerMask targetMask, obstacleMask;
    
        // Target mask에 ray hit된 transform을 보관
        public List<Transform> visibleTargets = new List<Transform>();
        
        private Mesh _viewMesh;
        public MeshFilter viewMeshFilter; //내 시야 범위(장애물 적용)
        private Mesh _stencil2Mesh;
        private List<Vector3> _stencil2ViewPoints = new List<Vector3>();
        public MeshFilter stencil2ViewMeshFilter; //내 시야 범위(장애물 무시)
        
        //중간점 ray의 길이 임계치를 정할 변수 edgeDstThreshold와 이진 탐색 반복 횟수 edgeResolveIterations를 public으로 선언
        [Tooltip("값이 높을수록 계산량이 많아져 더 디테일해짐")]
        public int edgeResolveIterations;
        public float edgeDstThreshold;
        
        private void Start()
        {
            _viewMesh = new Mesh();
            _viewMesh.name = "View Mesh";
            viewMeshFilter.mesh = _viewMesh;
            
            _stencil2Mesh = new Mesh();
            _stencil2Mesh.name = "Ignoring Obstacles View Mesh";
            stencil2ViewMeshFilter.mesh = _stencil2Mesh;
            
            StartCoroutine(FindTargetsWithDelay(0.2f)); 
        }
        
        private void LateUpdate()
        {
            DrawFieldOfView();
        }

        private IEnumerator FindTargetsWithDelay(float delay)
        {
            while (true)
            {
                yield return new WaitForSeconds(delay);
                FindVisibleTargets();
            }
        }

        private void FindVisibleTargets()
        {
            visibleTargets.Clear();
            // viewRadius를 반지름으로 한 원 영역 내 targetMask 레이어인 콜라이더를 모두 가져옴
            Collider[] targetsInViewRadius = Physics.OverlapSphere(transform.position, viewRadius, targetMask);

            for (int i = 0; i < targetsInViewRadius.Length; i++)
            {
                Transform target = targetsInViewRadius[i].transform;
                Vector3 dirToTarget = (target.position - transform.position).normalized;
            
                // 플레이어와 forward와 target이 이루는 각이 설정한 각도 내라면
                if (Vector3.Angle(transform.forward, dirToTarget) < viewAngle / 2)
                {
                    float dstToTarget = Vector3.Distance(transform.position, target.transform.position);
                
                    // 타겟으로 가는 레이캐스트에 obstacleMask가 걸리지 않으면 visibleTargets에 Add
                    if (!Physics.Raycast(transform.position, dirToTarget, dstToTarget, obstacleMask))
                    {
                        visibleTargets.Add(target);
                    }
                }
            }
        }
    
        // y축 오일러 각을 3차원 방향 벡터로 변환한다.
        // 원본과 구현이 살짝 다름에 주의. 결과는 같다.
        public Vector3 DirFromAngle(float angleDegrees, bool angleIsGlobal)
        {
            if (!angleIsGlobal)
            {
                angleDegrees += transform.eulerAngles.y;
            }

            return new Vector3(Mathf.Cos((-angleDegrees + 90) * Mathf.Deg2Rad), 0, Mathf.Sin((-angleDegrees + 90) * Mathf.Deg2Rad));
        }
        
        private void DrawFieldOfView()
        {
            int stepCount = Mathf.RoundToInt(viewAngle * meshResolution);
            float stepAngleSize = viewAngle / stepCount;
            List<Vector3> viewPoints = new List<Vector3>();
            ViewCastInfo prevViewCast = new ViewCastInfo();

            for (int i = 0; i <= stepCount; i++)
            {
                float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize * i;
                ViewCastInfo newViewCast = ViewCast(angle);          
        
                // i가 0이면 prevViewCast에 아무 값이 없어 정점 보간을 할 수 없으므로 건너뛴다.
                if (i != 0)
                {
                    bool edgeDstThresholdExceed = Mathf.Abs(prevViewCast.dst - newViewCast.dst) > edgeDstThreshold;
            
                    // 둘 중 한 raycast가 장애물을 만나지 않았거나 두 raycast가 서로 다른 장애물에 hit 된 것이라면(edgeDstThresholdExceed 여부로 계산)
                    if (prevViewCast.hit != newViewCast.hit || (prevViewCast.hit && newViewCast.hit && edgeDstThresholdExceed))
                    {
                        Edge e = FindEdge(prevViewCast, newViewCast);
                
                        // zero가 아닌 정점을 추가함
                        if (e.PointA != Vector3.zero)
                        {
                            viewPoints.Add(e.PointA);
                        }

                        if (e.PointB != Vector3.zero)
                        {
                            viewPoints.Add(e.PointB);
                        }
                    }
                }

                viewPoints.Add(newViewCast.point);
                prevViewCast = newViewCast;
            }

            createMesh(_viewMesh, viewPoints);
            createMesh(_stencil2Mesh, _stencil2ViewPoints);
            _stencil2ViewPoints.Clear();
            
            void createMesh(Mesh mesh, List<Vector3> viewPoints)
            {
                var pair = makeVerticesAndTriangles(viewPoints);
                mesh.Clear();
                mesh.vertices = pair.Key;
                mesh.triangles = pair.Value;
                mesh.RecalculateNormals();
            }
            
            KeyValuePair<Vector3[], int[]> makeVerticesAndTriangles(List<Vector3> viewPoints)
            {
                int vertexCount = viewPoints.Count + 1;
                Vector3[] vertices = new Vector3[vertexCount];
                int[] triangles = new int[(vertexCount - 2) * 3];
                vertices[0] = Vector3.zero;
                for (int i = 0; i < vertexCount - 1; i++)
                {
                    vertices[i + 1] = transform.InverseTransformPoint(viewPoints[i]);
                    if (i < vertexCount - 2)
                    {
                        triangles[i * 3] = 0;
                        triangles[i * 3 + 1] = i + 1;
                        triangles[i * 3 + 2] = i + 2;
                    }
                }
            
                return new KeyValuePair<Vector3[], int[]>(vertices, triangles);
            }
        }
        
        private ViewCastInfo ViewCast(float globalAngle, bool stencil2 = true)
        {
            Vector3 dir = DirFromAngle(globalAngle, true);
            RaycastHit hit;
            
            //스탠실2 전용 그냥 원 추가..
            if (stencil2) 
            {
                ViewCastInfo info = new ViewCastInfo(false, transform.position + dir * viewRadius, viewRadius, globalAngle);
                _stencil2ViewPoints.Add(info.point);
            }
            
            if (Physics.Raycast(transform.position, dir, out hit, viewRadius, obstacleMask))
                return new ViewCastInfo(true, hit.point, hit.distance, globalAngle);
            else
                return new ViewCastInfo(false, transform.position + dir * viewRadius, viewRadius, globalAngle);
        }
        
        private Edge FindEdge(ViewCastInfo minViewCast, ViewCastInfo maxViewCast)
        {
            float minAngle = minViewCast.angle;
            float maxAngle = maxViewCast.angle;
            Vector3 minPoint = Vector3.zero;
            Vector3 maxPoint = Vector3.zero;

            for (int i = 0; i < edgeResolveIterations; i++)
            {
                float angle = minAngle + (maxAngle - minAngle) / 2;
                ViewCastInfo newViewCast = ViewCast(angle, false);
                bool edgeDstThresholdExceed = Mathf.Abs(minViewCast.dst - newViewCast.dst) > edgeDstThreshold;
                if (newViewCast.hit == minViewCast.hit && !edgeDstThresholdExceed)
                {
                    minAngle = angle;
                    minPoint = newViewCast.point;
                }
                else
                {
                    maxAngle = angle;
                    maxPoint = newViewCast.point;
                }
            }

            return new Edge(minPoint, maxPoint);
        }
    }
}

 

쉐이더 코드

Shader "Custom/StencilMask"
{
    // 해당 픽셀의 스탠실 레퍼런스를 인스펙터에서 지정한 값으로 변경.
    Properties
    {
        [IntRange] _StencilID("Stencil ID",Range(0,255)) = 0
        _MainTex ("Mask Texture", 2D) = "white" {}
        _Color ("Mask Color", Color) = (0, 0, 0, 1)
    }

    SubShader
    {
        Tags
        {
            "Queue" = "Geometry-1"
        }
        Pass
        {
            Zwrite off
            ColorMask 0
            Cull Back
            
            Stencil
            {
                Ref [_StencilID]
                Comp always
                Pass replace
            }
        }
    }
}
Shader "Custom/StencilReverse"
{
    // 해당 픽셀의 스탠실 레퍼런스가 지정한 값과 다를 때만 출력합니다.
    Properties
    {
        [IntRange] _StencilID("Stencil ID",Range(0,255)) = 0
        _MainTex ("Mask Texture", 2D) = "white" {}
        _Color ("Mask Color", Color) = (0, 0, 0, 1)
    }

    SubShader
    {
        Tags
        {
            "Queue" = "Geometry-1"
        }
        Pass
        {
            Stencil
            {
                Ref [_StencilID]
                Comp notequal
                Pass keep
            }
            
            ColorMask RGBA
            //Blend SrcAlpha OneMinusSrcAlpha
            
            //Lighting Off
            
            SetTexture [_MainTex]
            {
                Combine texture * constant
                ConstantColor [_Color]
            }
        }
    }
}
Shader"Custom/StencilTarget"
{
    // 해당 픽셀의 스탠실 레퍼런스가 지정한 값과 같을 때만, 출력합니다.
    Properties
    {
        [IntRange] _StencilID("Stencil ID",Range(0,255)) = 0
        _MainTex ("Mask Texture", 2D) = "white" {}
        _Color ("Mask Color", Color) = (1, 1, 1, 1)
    }

    SubShader
    {
        Tags
        {
            "Queue" = "Geometry-1"
        }
        Pass
        {
            Stencil
            {
                Ref [_StencilID]
                Comp Equal
                Pass Keep
            }
            
            ColorMask RGBA
            //Blend SrcAlpha OneMinusSrcAlpha
            
            //Lighting Off
            
            SetTexture [_MainTex]
            {
                Combine texture * constant
                ConstantColor [_Color]
            }
        }
    }
}
Shader"Custom/StencilObstacle"
{
    // 해당 픽셀의 스탠실 레퍼런스가 지정한 값보다 더 크다면 덮어씌우고, 출력합니다.
    Properties
    {
        [IntRange] _StencilID("Stencil ID",Range(0,255)) = 0
        _MainTex ("Mask Texture", 2D) = "white" {}
        _Color ("Mask Color", Color) = (1, 1, 1, 1)
    }

    SubShader
    {
        Tags
        {
            "Queue" = "Geometry-1"
        }
        Pass
        {
            Stencil
            {
                Ref [_StencilID]
                Comp LEqual
                Pass Replace
            }
            
            ColorMask RGBA
            //Blend SrcAlpha OneMinusSrcAlpha
            
            //Lighting Off
            
            SetTexture [_MainTex]
            {
                Combine texture * constant
                ConstantColor [_Color]
            }
        }
    }
}

 

참고 사이트

시야각 구현 참고 (영상/해당 영상 번역+설명 블로그)

https://www.youtube.com/watch?v=rQG9aUWarwE&embeds_referring_euri=https%3A%2F%2Fnicotina04.tistory.com%2F197&source_ve_path=MjM4NTE

https://nicotina04.tistory.com/197

 

유니티 Field Of View 유닛 시야 구현하기 1

유니티 버전 2020.3.18f1 들어가기 전 참고 이 포스트는 아래의 동영상을 한국어로 재구성한 자료임을 밝힌다. 어느 정도 영어 청취가 가능하다면 동영상을 봐도 좋다. https://youtu.be/rQG9aUWarwE 이 포

nicotina04.tistory.com

 

URP 스탠실 참고 (블로그/실 적용 프로젝트)

https://giseung.tistory.com/65

 

[Unity] URP 스텐실(Stencil) 활용하기

스텐실은 GPU의 스텐실 버퍼(Stencil Buffer)를 활용하여특정 픽셀의 렌더링을 제어하는 기능이다.  이번에는 스텐실을 활용한 간단한 예시를 구현해보도록 하겠다.아래의 결과 화면부터 보도록

giseung.tistory.com

https://github.com/BongYunnong/CodingExpress 

 

GitHub - BongYunnong/CodingExpress: [게임코딩급행열차] 게임을 개발하면서 필요한 여러가지 기능들을 구

[게임코딩급행열차] 게임을 개발하면서 필요한 여러가지 기능들을 구현해보는 프로젝트. Contribute to BongYunnong/CodingExpress development by creating an account on GitHub.

github.com

반응형

댓글