본문 바로가기

Unity Engine/자료 번역

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

728x90
728x90

유니티 버전

2020.3.18f1

들어가기 전 참고

이 포스트는 아래의 동영상을 한국어로 재구성한 자료임을 밝힌다. 따라서 구현 순서가 원본과 어느 정도 차이가 있을 수 있다. 어느 정도 영어를 청취할 수 있으면 아래의 동영상을 봐도 좋다. 그리고 여기에서 이어지는 강의이므로 이전 강의를 보지 않았으면 먼저 보고 오자.

 

https://youtu.be/73Dc5JTCmKI

 

우리의 목표

이전 에피소드에서 유닛의 시야 반경과 시야각을 설정해 시야 내의 적 유닛들을 raycast로 탐지하는 방법을 배웠다. 에피소드 2에서는 유닛이 볼 수 있는 가시 영역을 색이 입혀진 폴리곤으로 표현하는 것이 목적이다.

유닛이 볼 수 있는 영역은 하얀색으로 칠하고 가시 영역을 벗어나거나 장애물에 막히면 그렇지 않는다.

가시 영역에 반직선 그리기

우리는 위의 스크린샷처럼 색칠된 영역을 mesh와 meshfilter로 만들 것이다. 그전에 우리가 설정한 값에 따라 결정되는 부채꼴 또는 원 모양으로 나타나는 가시 영역의 호에서 몇몇 점을 샘플링하여 Debug.DrawLine으로 반직선을 그려보도록 하자.

가시영역으로 생성된 부채꼴에 3개의 점을 샘플링 하여 빨간 직선을 쏘는 그림

 

먼저 FieldOfView.cs에 샘플링 비율을 조정하는 float 변수를 public으로 meshResolution을 선언한다. 선언할 위치는 어디에 넣어도 상관없다.

public float meshResolution;

 

그다음 FieldOfView.cs에 DrawFieldOfView()라는 메서드를 새로 작성한다.

void DrawFieldOfView()
{
    // 샘플링할 점과 샘플링으로 나뉘어지는 각의 크기를 구함
    int stepCount = Mathf.RoundToInt(viewAngle * meshResolution);
    float stepAngleSize = viewAngle / stepCount;
    
    // 샘플링한 점으로 향하는 좌표를 계산해 stepCount 만큼의 반직선을 쏨
    for (int i = 0; i <= stepCount; i++)
    {
        float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize * i;
        // 여기서는 색상을 초록으로 결정했다.
        Debug.DrawLine(transform.position, transform.position + DirFromAngle(angle, true) * viewRadius, Color.green);
    }
}

 

LateUpdate를 추가하여 DrawFieldOfView()를 호출한다.

void LateUpdate()
{
    DrawFieldOfView();
}

 

스크립트 수정을 끝냈으면 Character의 inpector에서 meshResolution에 0.1 정도의 값을 주고 실행하면 유니티 씬에서 다음과 같은 결과를 볼 수 있다.

그래서 왜 선을 그렸나요?

사실 지금까지 한 것은 이 포스트의 목표에 직접적인 관련은 없으며 개념 이해를 돕기 위해 도입했다.

 

씬에서 나타나는 반직선들을 삼각형의 변이라고 했을 때 가시 영역은 이등변 삼각형의 집합으로도 생각할 수 있다.

그러면 우리는 여기서 viewMesh로 가시 영역을 그리기 위한 다음 아이디어를 생각할 수 있다.

 

유닛의 중심점과 샘플링된 점들을 정점으로 취급하면 삼각형 폴리곤 메쉬를 구축해 렌더링을 할 수 있지 않을까?

 

영역을 그리는 거랑 삼각형으로 쪼개는 거랑 무슨 상관이냐는 의문을 가질 수도 있는데 이를 깊게 이해하려면 컴퓨터 그래픽스 이론에 대한 이해가 필요하다. 이론을 공부하길 원하면 여기여기를 참조하도록 하자. 가능하면 첨부한 하이퍼링크의 내용을 이해할 것을 강력히 권고한다.

 

만약 시간이 촉박하다면 가시 영역을 색칠된 삼각형들로 그린다고 간단히 설명할 수 있다.

 

이론에 대한 내용은 이쯤에서 멈추고 viewMesh에 폴리곤 메쉬를 구축하러 가자.

폴리곤 메쉬 구축 - 정점 리스트 만들기

지금 viewMesh는 새로 만들어졌을 뿐 어떠한 조작도 하지 않아 그 형체가 존재하지 않는다. 따라서 우리는 적절한 계산을 통해 폴리곤 메쉬를 만들어야 한다. 그러기 위해선 각 폴리곤의 꼭짓점이 될 정점을 찾아야 한다. Raycast를 했을 때 ray가 도달하는 위치를 표현하는 struct ViewCastInfo를 만들자.

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;
    }
}

 

ViewCastInfo에서는 raycast가 hit 판정인지, ray가 마지막으로 도달한 위치는 어디인지, ray의 길이는 얼마나 되는지, 해당 ray가 이루는 각은 어떻게 되는지를 저장하도록 한다.

 

ViewCastInfo를 정의했으면 raycast 결과를 ViewCastInfo로 반환하는 메서드 ViewCast를 만들자. 이때 어느 방향으로 raycast를 할지는 오브젝트의 y축 오일러 각을 인자로 받아 DirFromAngle 메서드로 방향벡터를 만든다.

ViewCastInfo ViewCast(float globalAngle)
{
    Vector3 dir = DirFromAngle(globalAngle, true);
    RaycastHit hit;
    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);
    }
}

 

이제 DrawFieldOfView에서 Debug.DrawLine 대신 ViewCast로 폴리곤을 구성할 정점을 얻어 viewPoints라는 Vector3 리스트에 정점들을 넣을 수 있도록 한다. 그러면 DrawFieldOfView 메서드의 내용은 다음과 같이 바뀐다.

void DrawFieldOfView()
{
    int stepCount = Mathf.RoundToInt(viewAngle * meshResolution);
    float stepAngleSize = viewAngle / stepCount;
    List<Vector3> viewPoints = new List<Vector3>();

    for (int i = 0; i <= stepCount; i++)
    {
        float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize * i;

        ViewCastInfo newViewCast = ViewCast(angle);   
        viewPoints.Add(newViewCast.point);
    }
}

폴리곤 메쉬 구축 - Mesh, MeshFilter 추가

얻어낸 정점 리스트로 폴리곤 메쉬를 만들어 저장할 변수가 필요하다. 다음과 같이 변수를 추가하고 Start()에 초기화를 하도록 하자.

 

FieldOFView.cs - Start() 부분

Mesh viewMesh;
public MeshFilter viewMeshFilter;

void Start()
{
    viewMesh = new Mesh();
    viewMesh.name = "View Mesh";
    viewMeshFilter.mesh = viewMesh;
        
    StartCoroutine(FindTargetsWithDelay(0.2f)); 
}

폴리곤 메쉬 구축 - Mesh에 정점, 폴리곤 정의

viewPoints 리스트의 내용물을 토대로 앞서 만든 viewMesh의 정점과 폴리곤 정보를 정의해야 한다. DrawFieldOfView에서 다음과 같이 배열을 선언한다.

int vertexCount = viewPoints.Count + 1;
Vector3[] vertices = new Vector3[vertexCount];
int[] triangles = new int[(vertexCount - 2) * 3];
vertices[0] = Vector3.zero;

vertices와 triangles는 viewMesh의 vertices와 triangles 멤버에 넣어줄 배열이다. 이 둘을 잘 모른다면 vertices는 여기, triangles는 여기를 먼저 보고 오자.

 

여기서 vetexCount는 viewPoints의 크기에 1을 더한 값이 되는데 그 이유는 얻어낸 정점을 이어 줄 중심 정점이 하나 더 필요하기 때문이다.

그래서 위 코드의 마지막에서 vertices[0]을 Vector3.zero로 넣은 것을 볼 수 있다. 유니티 mesh는 로컬 좌표가 기준이기 때문에 중심 좌표가 (0, 0, 0)이다.

 

triangles의 경우는 (vertexCount - 2) * 3만큼의 크기를 할당하게 되는데 삼각형의 개수는 vertexCount - 2이고 삼각형의 꼭짓점은 3개이므로 모든 index를 할당해야 하기 때문이다.

 

배열에 정점의 정보를 할당하고 노말 벡터를 계산해주자.

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;
    }
}
viewMesh.Clear();
viewMesh.vertices = vertices;
viewMesh.triangles = triangles;
viewMesh.RecalculateNormals();

앞서 말했지만 Mesh에 사용되는 좌표는 로컬 좌표계이기 때문에 viewPoints의 좌표를 InverseTransformPoint 메서드로 로컬 좌표로 변환해야 한다.

폴리곤 메쉬 구축 - 메쉬 오브젝트 생성 및 시각화 초안

여기까지 했으면 Character의 자식 오브젝트를 하나 추가해서 그 오브젝트가 가시영역 모양의 메쉬를 렌더링 하도록 한다. 그리고 자식 오브젝트에 Mesh Filter랑 Mesh Renderer 컴포넌트를 추가하도록 하자.

 

그런 다음 FieldOfView 컴포넌트의 viewMeshFilter의 값으로 방금 만든 자식 오브젝트를 넣어주도록 한다.

 

여기까지 했으면 씬을 실행해 폴리곤 메쉬가 잘 렌더링 되는지 확인하도록 하자.

드디어 가시적으로 무언가 보이기 시작했다! 하지만 이리저리 움직이다 보면 장애물 경계 부분이 심하게 떨리거나 일부 보여야 할 부분이 보이지 않는 등 전반적으로 거친 모습을 보여주므로 부드럽게 렌더링 되도록 개선해야 한다. 그전에 기본으로 칠해지는 분홍색은 미관상 좋지 않으니 머터리얼을 하나 만들어서 자식 오브젝트의 Materials에 만든 머터리얼을 넣도록 하자.

시각화에 사용되는 머터리얼은 다음과 같이 만든다. 어느 정도 투명하게 하기 위해 Albedo의 알파 값을 127 정도로 조정하자.

 

자식 오브젝트인 ViewVisualisation의 라이팅 세팅을 다음과 같이 한다. 시각화 오브젝트에 그림자가 생기지 않아야 함은 당연하다.

개선하기

원본 영상

 

위 이미지를 참고하면 raycast로 얻은 정점이 장애물 경계에 있을 경우 해당 정점을 기준으로 삼각형이 구성되기 때문에 시야가 소실되는 현상이 발생한다. 따라서 우리는 이를 커버할 정점을 더 만들어야 한다.

정점을 보간하기 위해 이진 탐색을 응용할 것이다. 이진 탐색을 응용하는 아이디어는 다음과 같다.

 

1. minPoint와 maxPoint 중간점 mid 방향으로 raycast를 쏜다. min과 max를 결정하는 기준은 y축 오일러 각이며 중간점은 두 각의 평균으로 얻어낸다.

2. raycast와 minCast의 hit 상태가 같고 ray의 길이와 오브젝트 중심과 minPoint 사이의 길이 차가 설정한 임계치 이하면 minPoint를 mid로 바꾼다.

3. 그렇지 않으면 maxPoint를 mid로 바꾼다.

 

이 과정을 통해 다음과 같이 최대 두 개의 정점을 얻어낼 수 있다.

 

그림에서는 오른쪽 점이 max라고 되어있지만 실제 구현엔 y축 오일러 각을 기준으로 하므로 왼쪽이 max여야 한다는 점을 기억하자.

 

두 점을 간선으로 저장하기 위해 Edge struct를 정의한다.

public struct Edge
{
    public Vector3 PointA, PointB;
    public Edge(Vector3 _PointA, Vector3 _PointB)
    {
        PointA = _PointA;
        PointB = _PointB;
    }
}

 

중간점 ray의 길이 임계치를 정할 변수 edgeDstThreshold와 이진 탐색 반복 횟수 edgeResolveIterations를 public으로 선언한다. 사실 실수 값에 대해 이진 탐색을 진행할 때는 100번이면 충분하다고 알려져 있으므로 변수를 따로 두지 않고 100을 넣어도 될 것이다.

public int edgeResolveIterations;
public float edgeDstThreshold;

 

두 ViewCastInfo를 가지고 보간 된 간선을 찾는 메서드 FindEdge를 작성한다.

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);
        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);
}

 

이제 DrawFieldOfView에서 반복마다 FindEdge를 호출하여 정점 보간을 하도록 메서드를 고친다. 이 에피소드에서 DrawFieldOfView 메서드의 최종 코드는 다음과 같다.

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;
    }

    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;
        }
    }
    viewMesh.Clear();
    viewMesh.vertices = vertices;
    viewMesh.triangles = triangles;
    viewMesh.RecalculateNormals();
}

 

스크립트를 수정하고 씬을 실행하면 다음과 같이 개선된 렌더링 결과를 얻을 수 있다.

여기까지 에피소드 2의 내용을 끝마쳤으며 다음에는 에피소드 3의 내용도 이어서 해보겠다.

728x90
728x90