08/12 AI Navigation - NavMesh 제작 및 사용하기

심지태
∙∙조회 17
08/12 AI Navigation - NavMesh 제작 및 사용하기

Unity의 Navigation AI와 NavMesh를 사용하면 복잡한 경로 탐색 로직을 직접 구현하지 않아도 AI 캐릭터가 목적지까지 자연스럽게 이동하도록 만들 수 있습니다. NavMesh는 캐릭터가 이동 가능한 영역과 장애물을 미리 계산해 두기 때문에 효율적인 경로 탐색이 가능하며, NavMeshAgent를 이용하면 이동 속도, 회전, 장애물 회피, 경로 추종 같은 기능도 손쉽게 제어할 수 있습니다.

핵심 용어

핵심 용어의미핵심 포인트
NavMeshAI 캐릭터가 이동 가능한 영역을 표현한 내비게이션 메시 데이터바닥 전체가 아니라, AI가 실제로 걸을 수 있는 공간을 지도처럼 만든 것
Bake씬의 지오메트리를 분석하여 NavMesh 데이터를 생성하는 과정장애물, 경사, 계단 등을 계산해 이동 가능/불가능 영역을 결정
NavMeshAgentNavMesh 위에서 AI의 경로 탐색과 이동을 담당하는 컴포넌트목적지를 주면 경로를 따라 이동·회전·속도 조절까지 처리
Agent RadiusAgent의 몸통 반지름 / 폭을 나타내는 Bake 설정좁은 통로를 통과할 수 있는지 결정. 값이 클수록 이동 가능 영역이 줄어듦
Agent HeightAgent의 키를 나타내는 Bake 설정천장이 낮은 곳을 지나갈 수 있는지 결정
Max SlopeAgent가 올라갈 수 있는 최대 경사각이 각도보다 가파른 경사는 이동 불가능 영역으로 처리
Step HeightAgent가 한 번에 올라갈 수 있는 최대 턱 높이계단이나 낮은 장애물을 걸어서 넘어갈 수 있는 기준
SetDestinationNavMeshAgent에게 이동할 목적지 좌표를 지정하는 명령agent.SetDestination(targetPosition) 형태로 사용
A (A-star)*목적지까지 효율적인 경로를 찾는 경로 탐색 알고리즘F = G + H를 기준으로 탐색 우선순위를 결정
G Cost시작 지점에서 현재 노드까지 실제로 이동한 비용지금까지 얼마나 이동했는지를 나타냄
H Cost / 휴리스틱현재 노드에서 목적지까지 남은 예상 비용목적지에 가까운 방향을 우선 탐색하게 만드는 핵심 요소
F Cost해당 노드의 전체 예상 비용F = G + H, 보통 F가 작은 노드를 먼저 탐색
openList앞으로 탐색할 가능성이 있는 후보 노드 집합아직 확정되지 않은 노드들이 들어 있음
closedList탐색이 끝나 확정된 노드 집합이미 처리한 노드를 반복해서 탐색하지 않도록 관리

NavMesh와 NavMeshAgent: 지도와 운전자

NavMesh를 굽기 위한 준비물 : 땅과 장애물

설치한 벽은 움직이지 않을 것이기 때문에 Static으로 만든다.

  • Static으로 설정했을 때 장점 : 움직이지 않는 오브젝트를 Static으로 설정하면 Unity가 해당 오브젝트를 미리 계산해 렌더링, 라이트맵, NavMesh 베이크 등을 최적화할 수 있습니다. 덕분에 런타임 연산량이 줄어들어 게임 성능 향상에 도움이 됩니다.

NavMesh Agent 크기와 이동 조건 설정

항목의미현재 설정 기준
NameAgent 타입 이름Humanoid
Radius캐릭터의 반지름, 즉 몸의 폭을 판단하는 기준0.5 → 폭은 대략 1m로 계산
Height캐릭터의 키2 → 높이 2m 공간이 필요
Step Height걸어서 올라갈 수 있는 최대 턱 높이0.75 이하 턱은 이동 가능
Max Slope올라갈 수 있는 최대 경사각45°까지 이동 가능
Drop Height자동 링크 생성 시 뛰어내릴 수 있는 최대 높이0 → 자동 낙하 링크 사용 안 함
Jump Distance자동 링크 생성 시 뛰어서 건널 수 있는 최대 거리0 → 자동 점프 링크 사용 안 함

Agent Radius가 너무 크면 좁은 문을 통과하지 못하고 지도에서 그 통로 자체가 사라집니다. 너무 작으면 실제 모델은 벽에 닿아 보이는데 경로상으로는 지나갈 수 있다고 판단해 벽에 비비적거립니다. Agent Height가 낮으면 지나갈 수 없는 천장 아래를 갈 수 있다고 오해할 수 있고, Max Slope와 Step Height는 경사와 계단을 다룰 때 특히 중요합니다.

NavMesh Surface 설정 이해하기

항목의미
Agent Type이 NavMesh를 사용할 AI의 Agent 타입을 지정합니다. Radius, Height, Step Height 등의 설정이 적용됩니다.
Default Area생성되는 NavMesh 영역의 기본 Area 타입을 지정합니다. 일반적으로 Walkable을 사용합니다.
Generate LinksNavMesh 생성 시 점프나 단차 이동 등에 사용할 NavMesh Link를 자동 생성할지 설정합니다.
Use GeometryNavMesh를 계산할 때 어떤 지오메트리 정보를 사용할지 결정합니다. Render Meshes는 화면에 보이는 메시를 기준으로 계산합니다.
Object CollectionNavMesh를 Bake할 때 어떤 오브젝트들을 수집해서 계산할지 설정합니다.
AdvancedNavMesh 생성과 관련된 세부 옵션을 설정합니다.
NavMesh DataBake를 통해 생성된 NavMesh 데이터를 저장하고 연결하는 항목입니다.
Clear현재 생성되어 있는 NavMesh 데이터를 삭제합니다.
Bake설정한 조건을 바탕으로 AI가 이동할 수 있는 NavMesh를 생성합니다.

파란색으로 표시된 부분이 NavMesh가 구워진 부분, 에이전트가 이동할 수 있는 범위

경사면에서 에이전트 점프 시키는법

Navigation 탭에서 Drop Hegiht 설정해주고, Navmesh Suface에 generate links를 활성화해준다.

소제목: NavMeshAgent 이해하기

NavMeshAgent는 NavMesh 위에서 AI 캐릭터가 목적지까지 이동하도록 제어하는 컴포넌트입니다. 이동 속도, 회전 속도, 가속도, 정지 거리 등을 설정해 캐릭터의 움직임을 조절할 수 있습니다.

항목의미효과
Speed최고 이동 속도값이 클수록 더 빠르게 이동합니다.
Angular Speed회전 속도값이 클수록 방향을 빠르게 바꾸고, 작으면 코너에서 천천히 크게 회전합니다.
Acceleration가속도값이 클수록 빠르게 최고 속도에 도달하고, 작으면 서서히 속도가 붙습니다.
Stopping Distance정지 거리목적지로부터 몇 m 앞에서 멈출지를 결정합니다.
RadiusAgent의 반지름다른 Agent나 장애물과 충돌을 피할 때 캐릭터의 폭 기준으로 사용됩니다.
HeightAgent의 높이캐릭터의 높이를 나타내며 장애물 회피 계산 등에 사용됩니다.
Auto Braking자동 감속 여부활성화하면 목적지에 가까워질수록 자동으로 속도를 줄입니다.
Obstacle Avoidance장애물 회피 품질다른 Agent나 장애물을 얼마나 정교하게 피할지 결정합니다.
Priority회피 우선순위여러 Agent가 만났을 때 서로 피하는 우선순위를 결정합니다. 값이 낮을수록 우선순위가 높습니다.

경로 탐색의 원리: A*는 어떻게 길을 찾는가

다익스트라와 A*의 차이

먼저 두 알고리즘을 나란히 놓고 봅니다.

항목다익스트라A*
목적시작점에서 모든 노드까지의 최단 거리특정 목적지까지의 최단 거리
휴리스틱 사용없음 (실제 거리만 계산)있음 (남은 거리를 추정)
탐색 범위넓음 (모든 방향으로 균등하게)좁음 (목표 쪽으로 집중)
효율성느릴 수 있음휴리스틱이 정확하면 빠름
최적성항상 최적휴리스틱이 적절하면 최적

A*의 전부는 이 한 줄입니다.

scss
F(x) = G(x) + H(x)

F : 예상 최종 거리       - 이 칸을 거쳐 가면 총 얼마나 걸릴 것 같은가  
G : 지금까지 걸린 거리   - 시작점에서 이 칸까지 실제로 든 비용  
H : 남은 예상 거리       - 이 칸에서 목적지까지 대략 얼마나 남았는가 (휴리스틱)
  1. Node는 Map의 한 칸을 나타냄. 데이터로 위치/이동 가능 여부/G, H, F/부모(이전 노드)의 참조값을 가짐
  • 단, 현재 코드에서는 이동 가능 여부를 Node.walkable이 아니라 map 배열로 확인함
  • F는 별도로 저장하지 않고 G + H로 계산함
  1. 시작 노드의 G를 0, H를 목적지까지의 예상 거리로 설정하고 openList에 넣으며 시작
  • openList == 발견했지만 아직 주변 탐색을 완료하지 않은 후보 노드들
  1. openList가 빌 때까지 탐색을 반복
  2. openList에서 가장 좋은 노드를 찾음
  • 가장 좋은 노드 == F가 가장 작은 노드
  • F가 같다면 H가 가장 작은 노드
  • 찾은 노드를 current로 설정
  1. current를 openList에서 제거하고 closedList에 추가
  • closedList == 주변 탐색 대상으로 선택되어 처리가 끝났거나 처리 중인 노드의 좌표들
  1. current가 목적지라면 parent를 따라 경로를 역추적하고 탐색을 종료
  2. 목적지가 아니라면 current의 주변 8방향을 하나씩 탐색하여 다음 조건을 확인
  • Map 범위 안에 있는가?
  • 이동 가능한 칸인가?
  • closedList에 없는가?
  • 대각선 이동이라면 양옆이 장애물로 막혀 있지 않은가?
  1. 조건을 통과한 이웃 노드까지의 새로운 이동 비용 newG를 계산
  • newG = current.G + 이동 비용
  • 직선 이동 비용은 10
  • 대각선 이동 비용은 14
  1. Dictionary에서 해당 이웃 좌표의 Node가 이미 만들어졌는지 확인
  • 이미 만들어졌다면 기존 Node를 가져옴
  • 없다면 새 Node를 만들고 Dictionary에 저장
  • 이를 통해 같은 좌표의 Node가 여러 개 생성되는 것을 방지
  1. 이웃 노드가 openList에 있는지 확인
  2. 이웃 노드가 openList에 없거나, newG가 기존 이웃 노드의 G보다 작다면 이웃 노드의 정보를 업데이트
  • G = newG
  • H = 목적지까지의 예상 거리
  • parent = current
  1. 이웃 노드가 openList에 없었던 경우, 앞으로 탐색할 수 있도록 openList에 추가
  • 이미 있었다면 중복으로 추가하지 않고 정보만 갱신
  1. 다시 openList에서 가장 좋은 노드를 선택하면서 탐색을 반복
  2. 목적지를 찾기 전에 openList가 비었다면 목적지까지 갈 수 있는 경로가 없으므로 null을 반환

특히 기존 설명에서 보완할 부분은 세 가지야.

  • current는 목적지인지 검사하기 전에 먼저 openList에서 빠지고 closedList에 들어감
  • 이웃 노드를 처리할 때 Dictionary에서 기존 노드를 가져오거나 새로 만드는 과정이 있음
  • 종료 조건은 목적지를 찾는 경우와 openList가 비어 경로가 없다고 판단하는 경우, 두 가지임
ini
// 경로를 탐색하는 함수
    public List<Vector2Int> FindPath(Vector2Int start, Vector2Int goal)
    {
        // [준비]
        // openList: 앞으로 탐색할 후보 노드들
        // closedList: 이미 확정된 노드들(다시 안 봄)
        List<Node> openList = new List<Node>();
        List<Vector2Int> closedList = new List<Vector2Int>();

        // 같은 좌표의 Node를 매번 만들지 않도록 좌표를 통해 노드를 찾을 수 있도록 해준다.
        Dictionary<Vector2Int, Node> nodes = new Dictionary<Vector2Int, Node>();

        // [시작]
        // 시작 노드의 G = 0, H = 휴리스틱 계산
        Node startNode = new Node(start, true)
        {
            G = 0,
            H = GetDistance(start, goal)
        };
        // 시작 노드를 openList에 추가
        openList.Add(startNode);
        nodes[start] = startNode;

        // [반복] openList가 비어있지 않은 동안
        while (openList.Count > 0)
        {
            //   ① openList에서 F가 가장 낮은 노드를 고른다
            //        (F가 같으면 H가 낮은 쪽)
            Node current = openList[0];

            // F와 H를 비교해서 가장 낮은 것을 찾아준다.
            for (int i = 1; i < openList.Count; i++)
            {
                bool isBetterF = openList[i].F < current.F;
                bool isBetterH = openList[i].F == current.F && openList[i].H < current.H;

                if (isBetterF || isBetterH)
                {
                    current = openList[i];
                }
            }
            //   ② 그 노드를 openList에서 빼고 closedList에 넣는다  
            openList.Remove(current);
            closedList.Add(current.position);


            //   ③ 그 노드가 목적지면 → 경로를 역추적하고 종료
            if (current.position == goal)
            {
                return RetracePath(startNode, current);
            }

            //   ④ 그 노드의 이웃 칸들을 하나씩 확인한다
            foreach (Vector2Int dir in Directions)
            {
                Vector2Int neighborPos = current.position + dir;

                // 격자 밖이거나
                if (!IsInsideGrid(neighborPos))
                {
                    continue;
                }
                // 벽이면 건너뛴다
                if (!map[neighborPos.x, neighborPos.y])
                {
                    continue;
                }
                // 이미 closedList에 있으면 건너뛴다 == 이미 확정된 칸이면 건너뛴다.
                if (closedList.Contains(neighborPos))
                {
                    continue;
                }
                // 대각선이면 인접한 수직·수평이 모두 열려있는지 확인
                if (IsDiagonal(dir) && !CanMoveDiagonally(current.position, dir))
                {
                    continue;
                }
                // 새 G = 현재 노드의 G +이동 비용(직선 10 / 대각선 14)
                int moveCost = IsDiagonal(dir) ? DIAGONAL_COST : STRAIGHT_COST;
                int newG = current.G + moveCost;

                // 현재 위치에 만들어진 노드가 존재하지 않는 경우
                // openList에 중복으로 들어가게 되는 현상을 막기위해
                if (!nodes.TryGetValue(neighborPos, out Node neighborNode))
                {
                    // 새로 노드를 만들어 준다.
                    neighborNode = new Node(neighborPos, true);
                    nodes[neighborPos] = neighborNode;
                }

                bool isOpenListExist = openList.Contains(neighborNode);

                // 새 G가 기존에 기록된 G보다 작거나, openList에 없다면
                if (newG < neighborNode.G || !isOpenListExist)
                {
                    // G, H를 갱신하고  
                    neighborNode.G = newG;
                    neighborNode.H = GetDistance(neighborPos, goal);

                    // 부모(어디서 왔는지)를 현재 노드로 기록하고  
                    neighborNode.parent = current;

                    // openList에 없으면 openList 추가까지 진행.
                    if (!isOpenListExist)
                    {
                        openList.Add(neighborNode);
                    }
                }
            }
        }
        // [종료]
        // openList가 비었는데 목적지에 도달하지 못했다면 → 경로 없음
        return null;
    }
plain text