완성된 동작 모습


    완성된 스크립트 모습


    Sight2D.cs


    유니티  시야각 만들기 그 첫번째 시간, 2D 시야각 만들기입니다.
    2D는 Z축으로만 회전하기 때문에 X, Y, Z 모든 축으로 회전 할 수 있는 3D에 비해 생각해야할 것이 적어 만들기가 간단합니다. 그럼 바로 시작해보도록 하겠습니다.




    먼저 Sight2D라는 스크립트를 만들어서 열어줍니다.


    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class Sight2D : MonoBehaviour {
        [SerializeFieldprivate bool m_bDebugMode = false;
     
        [Header("View Config")]
        [Range(0f, 360f)]
        [SerializeFieldprivate float m_horizontalViewAngle = 0f;
        [SerializeFieldprivate float m_viewRadius          = 1f;
        [Range(-180f, 180f)]
        [SerializeFieldprivate float m_viewRotateZ         = 0f;
     
        [SerializeFieldprivate LayerMask m_viewTargetMask;
        [SerializeFieldprivate LayerMask m_viewObstacleMask;
     
        private List<Collider2D> hitedTargetContainer = new List<Collider2D>();
     
        private float m_horizontalViewHalfAngle = 0f;
     
        private void Awake()
        {
            m_horizontalViewHalfAngle = m_horizontalViewAngle * 0.5f;
        }

    cs


    다음 위와 같이 기본적인 변수들을 선언해주고 Awake에서 값을 세팅 해줍니다.
    m_horizontalViewAngle은 시야각을 담는 변수이고 시야각의 최소 값은 0, 최대 값은 360 밖에 될 수 없으므로 Range 어트리뷰트를 이용해 최소 값, 최대 값을 제한해줍니다.
    m_viewRotateZ는 후에 만들 AngleToDirZ 함수에 이용하기 위해 -180~180으로 값을 제한합니다.
    LayerMask들은 Physics 함수에 사용할 변수들로 '볼 수 있는 타겟'과 '시야를 가로막는 오브젝트'를 지정해주는데 사용됩니다.



    m_viewRatateZ는 tramsform의 rotation을 변경하지 않고 시야각을 회전 시키는데 이용하는 변수로 위 이미지에서처럼 캐릭터의 방향과 시야의 방향이 달라야 할 경우 이용할 수 있습니다.


    1
    2
    3
    4
    5
    6
    // 입력한 -180~180의 값을 Up Vector 기준 Local Direction으로 변환시켜줌.
    private Vector3 AngleToDirZ(float angleInDegree)
    {
        float radian = (angleInDegree - transform.eulerAngles.z) * Mathf.Deg2Rad;
        return new Vector3(Mathf.Sin(radian), Mathf.Cos(radian), 0f);
    }


    다음 입력한 Angle(-180~180)을 Up Vector 기준 Direction으로 변환해주는 AngleToDirZ 함수를 만듭니다. Angle에 eulerAngles.z를 빼주는 이유는  입력한 Angle을 Local Direction으로 변환 시켜주기 위해서입니다.


    Z축을 시계 방향(오른쪽)으로 돌리면 회전 값이 음수로 나온다.


    더하기가 아닌 빼기를 하는 이유는 우리가 만든 함수는 시계 방향(오른쪽)이 양수, 반시계 방향(왼쪽)이 음수라고 보고 계산을 하지만 유니티에서 Z축은 시계 방향(오른쪽)이 음수, 반시계 방향(왼쪽)이 양수로 반대이기 때문에 빼기(-)를 통해 음수를 양수로, 양수를 음수로 만들어 연산해주는 것 입니다.


    -15-(-20) = 5


    만일 eulerAngles.z가 -20(오른쪽으로 20도)일 때 angleInDegree가 -15(왼쪽으로 15도)라면 식이 -15-(-20) = -15+20이 되어 5가 나오므로 월드 좌표 기준으로 오른쪽으로 5도 돌린(내가 보는 방향에서 -15도 돌린) Direction이 나올 것입니다.


    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    private void OnDrawGizmos()
    {
        if (m_bDebugMode)
        {
            m_horizontalViewHalfAngle = m_horizontalViewAngle * 0.5f;
     
            Vector3 originPos = transform.position;
     
            Gizmos.DrawWireSphere(originPos, m_viewRadius);
     
            Vector3 horizontalRightDir = AngleToDirZ(-m_horizontalViewHalfAngle + m_viewRotateZ);
            Vector3 horizontalLeftDir  = AngleToDirZ(m_horizontalViewHalfAngle + m_viewRotateZ);
            Vector3 lookDir = AngleToDirZ(m_viewRotateZ);
     
            Debug.DrawRay(originPos, horizontalLeftDir * m_viewRadius, Color.cyan);
            Debug.DrawRay(originPos, lookDir * m_viewRadius, Color.green);
            Debug.DrawRay(originPos, horizontalRightDir * m_viewRadius, Color.cyan);
        }
    }

    cs


    다음은 OnDrawGizmos 함수를 만들어 줍니다. DrawWireSphere를 통해 인식할 수 있는 범위를 그려주고 우리가 만든 AngleToDirZ 함수를 통해 시야각을 방향 값으로 변환시켜 DrawRay로 그려줍니다. Angle값에 m_viewRotateZ를 더해줌으로 m_viewRatateZ 값에 따라 시야각을 더 회전시킬 수 있습니다. 



    eulerAngles.z가 0일 때 HalfAngle이 10이라면 시야각이 -10~10이지만 m_viewRotateZ가 10이라면 시야각은 오른쪽으로 10도 더 돌린 0~20도가 될 것입니다.


    빌드 한 스크립트를 넣어본 모습


    여기까지해서 빌드 후 빈 오브젝트에 스크립트를 넣어보면 처음에 보여드린 완성된 모습과 똑같은 모습이 나오게 됩니다. 이제 시야각 안에 들어온 대상을 인식할 수 있는 기능만 추가하면 완성입니다.


    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    public Collider2D[] FindViewTargets()
    {
        hitedTargetContainer.Clear();
     
        Vector2      originPos    = transform.position;
        Collider2D[] hitedTargets = Physics2D.OverlapCircleAll(originPos, m_viewRadius, m_viewTargetMask);
            
        foreach (Collider2D hitedTarget in hitedTargets)
        {
            Vector2 targetPos = hitedTarget.transform.position;
            Vector2 dir     = (targetPos - originPos).normalized;
            Vector2 lookDir = AngleToDirZ(m_viewRotateZ);
     
            // float angle = Vector3.Angle(lookDir, dir)
            // 아래 두 줄은 위의 코드와 동일하게 동작함. 내부 구현도 동일
            float dot   = Vector2.Dot(lookDir, dir);
            float angle = Mathf.Acos(dot) * Mathf.Rad2Deg;
     
            if (angle <= m_horizontalViewHalfAngle)
            {
                RaycastHit2D rayHitedTarget = Physics2D.Raycast(originPos, dir, m_viewRadius, m_viewObstacleMask);
                if (rayHitedTarget)
                {
                    if (m_bDebugMode)
                        Debug.DrawLine(originPos, rayHitedTarget.point, Color.yellow);
                }
                else
                {
                    hitedTargetContainer.Add(hitedTarget);
     
                    if (m_bDebugMode)
                        Debug.DrawLine(originPos, targetPos, Color.red);
                }
            }
        }
     
        if (hitedTargetContainer.Count > 0)
            return hitedTargetContainer.ToArray();
        else
            return null;
    }
    cs


    대상의 인식은 총 3단계의 확인 과정을 거쳐야 합니다.
    1. 나의 인식 범위 안에 들어온 대상이 있는가?
    2. 인식 범위 안에 들어온 대상이 나의 시야각 안에 있는가?
    3. 시야각 안에 들어온 대상을 볼 수 없게 가로 막는 장해물이 존재하는가?


    1
    2
    Vector2      originPos    = transform.position;
    Collider2D[] hitedTargets = Physics2D.OverlapCircleAll(originPos, m_viewRadius, m_viewTargetMask);
    cs


    먼저 Pysics2D.OverlapCircleAll 함수를 통해 인식 범위 안에 들어온 대상이 있는지 확인합니다. 이때 LayerMask인 m_viewTargetMask를 넣어줌으로써 원하는 타켓만 선별적으로 인식할 수 있습니다.


    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Vector2 targetPos = hitedTarget.transform.position;
    Vector2 dir       = (targetPos - originPos).normalized;
    Vector2 lookDir   = AngleToDirZ(m_viewRotateZ);
     
    // float angle = Vector3.Angle(lookDir, dir)
    // 아래 두 줄은 위의 코드와 동일하게 동작함. 내부 구현도 동일
    float dot   = Vector2.Dot(lookDir, dir);
    float angle = Mathf.Acos(dot) * Mathf.Rad2Deg;
     
    if (angle <= m_horizontalViewHalfAngle)
    {
    }
    cs



    대상이 시야각 안에 들어왔는지 알려면 먼저 내 위치에서 상대방 위치로의 방향 벡터를 구해야합니다. 다음 AngleToDirZ 함수를 통해 시야각이 바라보고 있는 방향 벡터를 구하여 두 방향 벡터의 내적을 구하고, 구한 내적을 Acos에 넣어 Degree값으로 변환시켜주면 시야각이 바라보고 있는 방향에서 타켓의 위치까지의 각도가 나오게 됩니다. 이때 각도는 무조건 양수로만 나오기 때문에 실제론 -45(왼쪽으로 45도)라도 양수인 45도가 나오게 됩니다. 구한 각도가 HalfAngle보다 작거나 같다면 타켓은 내 시야각 안에 들어와있는 것입니다.


    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    if (angle <= m_horizontalViewHalfAngle)
    {
        RaycastHit2D rayHitedTarget = Physics2D.Raycast(originPos, dir, m_viewRadius, m_viewObstacleMask);
        if (rayHitedTarget)
        {
            if (m_bDebugMode)
                Debug.DrawLine(originPos, rayHitedTarget.point, Color.yellow);
        }
        else
        {
            hitedTargetContainer.Add(hitedTarget);
     
            if (m_bDebugMode)
                Debug.DrawLine(originPos, targetPos, Color.red);
        }
    }
    cs


    타겟이 내 시야각 안에 있다는 것이 확인되면 마지막으로 대상을 가리고 있는 오브젝트가 있는지 Raycast로 확인합니다. 이때 LayerMask인 m_viewObstacleMask를 넣어줌으로써 시야 방해물을 선별적으로 지정해줄 수 있습니다. 이를 이용해 '투시가 가능한 벽'과 '투시가 불가능한 벽'을 간단히 구분지어 줄 수 있습니다. Raycast에 뭔가 맞은게 있다는 것은 타겟을 보지 못하게하는 방해물이 있다는 소리고 결과적으로 타겟을 보지 못한다는 것입니다. 반대로 Raycast에 맞은게 없다는 것은 시야 방해물이 없다는 소리이므로 타겟을 볼 수 있다는 것입니다. 이 경우 Container에 타겟을 넣어줍니다. 결국 Container에는 '내 인식 범위 안에 있고, 시야각 안에 있으며, 시야 방해물이 없어 확실히 볼 수 있는' 타켓만 들어가게 됩니다.


    1
    2
    3
    4
    if (hitedTargetContainer.Count > 0)
        return hitedTargetContainer.ToArray();
    else
        return null;
    cs


    마지막으로 타겟들에 대한 모든 확인(foreach)이 끝났을 때 Container가 비어있지 않다면 Array로 return을, 비어 있으면 null을 return 해주면 끝이나게 됩니다.


    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    private void OnDrawGizmos()
    {
        if (m_bDebugMode)
        {
            m_horizontalViewHalfAngle = m_horizontalViewAngle * 0.5f;
     
            Vector3 originPos = transform.position;
     
            Gizmos.DrawWireSphere(originPos, m_viewRadius);
     
            Vector3 horizontalRightDir = AngleToDirZ(-m_horizontalViewHalfAngle + m_viewRotateZ);
            Vector3 horizontalLeftDir  = AngleToDirZ(m_horizontalViewHalfAngle + m_viewRotateZ);
            Vector3 lookDir = AngleToDirZ(m_viewRotateZ);
     
            Debug.DrawRay(originPos, horizontalLeftDir * m_viewRadius, Color.cyan);
            Debug.DrawRay(originPos, lookDir * m_viewRadius, Color.green);
            Debug.DrawRay(originPos, horizontalRightDir * m_viewRadius, Color.cyan);
     
            FindViewTargets();
        }
    }
    cs


    OnDrawGizmos 함수 if 문 맨 아래에 FindViewTargets 함수를 써줍니다. 이를 통해 디버그 모드 일 때 누가 내 시야에 들어와있고 어디에 시야가 막혔는지 확인할 수 있습니다. 그럼 빌드를 하고 각종 설정을 해준 뒤 결과물을 보도록 하겠습니다.


    Layer 추가


    View Target Mask 및 Obstacle Target Mask 설정


    Enemy 객체 Layer 설정


    위 설정들의 결과


    사진에 나와있듯 모든 것이 다 제대로 작동이 됩니다. 시야에 가려진 타겟은 노란 선으로 볼 수 없다는 것이 표시가 되고, 가려지지 않은 타겟은 빨간선으로 볼 수 있다는 것이 표시가 됩니다.



    이렇게해서 2D 시야각 만들기가 끝이났습니다. 코드도 짧고 어려운 알고리즘도 없어서 이해하기 어렵지 않으셨을거라 생각합니다. 3D 시야각은 좀 더 생각해야할 것이 많지만 2D 코드에서 많은 변화가 있는 것이 아니기에 마찬가지로 이해하는데 어려움이 없으실 겁니다. 그럼 시야각(FieldOfView) 만들기 2편, 3D 시야각 만들기에서 다시 뵙겠습니다.

    Posted by Muramasa