JS/TIL(Today I Learned)

2025-02-14 <최종 프로젝트 D-28> NavMeshLoader 구현

프린스 알리 2025. 2. 14.

구현 목적

  • 클라이언트와 동일한 NavMesh 데이터를 서버도 보유하기 위함
  • 동일한 NavMesh 데이터를 기반으로 플레이어의 이동 경로를 도출하기 위함
  • 클라이언트 오브젝트가 NavMesh의 알고리즘에 의해 이동할 때, 매 10프레임마다 좌표를 검증하기 위함

구현 방법

  • obj 파일이란?
    • 3차원 데이터를 기반으로 ASCII 형식으로 저장한 파일 포맷 중 하나이다. 각 정점의 위치와 각 텍스처 좌표 정점의 UV 위치, 정점 노멀, 그리고 각 다각형을 구성하는 정점 목록 및 텍스처 정점으로 정의된 면을 포함한다. 쉽게 말해 3D 데이터를 간단하게 표현한 형식이라고 보면 된다.
  • 따라서 NavMesh로 베이크한 데이터도 obj로 export가 가능하다. 유니티 상에서 export하기 위해 유니티 커뮤니티를 찾아보았다. 참고한 게시글 링크 : https://discussions.unity.com/threads/accessing-navmesh-vertices.130883/
  • NavMesh 파일을 obj로 추출하는 데 성공했다. 

  • 이제 남은 작업은 해당 파일을 파싱하고, 적절한 모듈을 이용해 경로를 생성하는 것. 검색한 결과 유니티는 recastnavigation이라는 C++기반의 라이브러리를 사용하고 있었다. 즉, 서버에서 경로를 계산할 때 해당 라이브러리의 로직을 사용하면 클라이언트와 동일한 결과를 도출할 수 있다는 의미이다.
  • 자바스크립트에서 위 라이브러리를 이식한 모듈이 여럿 있는데, 처음엔 navmesh 모듈을 사용했으나 2D만을 지원했기에 고사하게 되었다. 그 다음엔 recastnavigation을 있는 그대로 이식한 recast-detour 모듈을 사용해봤으나, 사용법이 너무 복잡해서 더 사용자 친화적인 모듈을 찾아보게 되었다.
  • 그러다 발견한 것이 recast-navigation이라는 이름의 깃헙 프로젝트였다. 깃헙 링크 : isaac-mason/recast-navigation-js: JavaScript navigation mesh construction, path-finding, and spatial reasoning toolkit. WebAssembly port of Recast Navigation.
  • recast-navigation은 동명의 라이브러리(C++)를 WebAssembly형식으로 이식한 라이브러리이다.
  • WebAssembly형식이란, 웹 브라우저에서 실행할 수 있는 저수준 이진 코드 형식이며, C, C++, Rust, Go 같은 언어로 작성된 코드를 WebAssembly 형식으로 변환할 수도 있다. 결정적으로, JavaScript상에서 WebAssembly 모듈을 불러와서 사용할 수 있고 친절한 리드미 파일이 제공되었기 때문에 채택하게 되었다.

코드 뜯어보기

(1) wasm파일 비동기 방식으로 초기화

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { NavMeshQuery } from 'recast-navigation';
import { generateSoloNavMesh } from 'recast-navigation/generators';
import { init } from 'recast-navigation';

await init();

자바스크립트에서 WebAssembly(wasm) 파일을 사용할 때는 비동기적으로 초기화해야 한다. WebAssembly 모듈을 로드하고 컴파일하는 과정에서 네트워크 요청 및 바이너리 데이터 처리가 필요하기 때문이라고 한다. recast-navigation 모듈에서는 해당 작업을 await init();으로 간편하게 제공해주고 있다.

(2) NavMesh 초기화 및 생성(Config and Build)

recast-navigation 깃헙 링크의 리드미를 자세히 읽어보면 자바스크립트 상에서 NavMesh 데이터를 생성하기 위한 일련의 과정들이 모두 친절하게 소개되어 있다. NavMesh를 빌드하기 위해선 아래와 같은 함수를 import해서 이용해야 한다.

import { generateSoloNavMesh } from 'recast-navigation/generators';

const { success, navMesh } = generateSoloNavMesh(
    vertices.flat(),
    indices,
    navMeshConfig,
);

그 전에 데이터를 어떤 설정으로 초기화할지 정해주어야 하는데 해당 라이브러리에선 아래와 같은 형식을 지원하고 있다.

const navMeshConfig = {
    cs: 0.1, // Cell Size
    ch: 0.1, // Cell Height
    walkableSlopeAngle: 45, // 최대 기울기(AI 캐릭터가 이동할 수 있는 최대 지형 경사각
    walkableHeight: 2, // 이동 가능한 최소 높이
    walkableClimb: 0.9, // 이동 가능한 최대 계단 높이
    walkableRadius: 0.6, // 이동 가능한 영역 반경
    maxEdgeLen: 3, // 최대 엣지 길이
    maxSimplificationError: 0.01, // 내비게이션 메시 단순화 시 오차 허용량
    minRegionArea: 1, // 최소 영역 크기
    mergeRegionArea: 2, // 병합할 최소 영역 크기
    tileSize: 8, // 타일 크기
    borderSize: 4, // 경계 크기
};

(3) 경로 생성하기

이 라이브러리에서 제공하는 수많은 메서드들은 아래와 같은 인스턴스를 통해 접근할 수 있다.

const navMeshQuery = new NavMeshQuery(navMesh);

예를 들어 경로를 생성하기 위한 findPath 메서드는 이렇게 사용이 가능하다.

const { success, polys } = navMeshQuery.findPath(
    startRef,
    endRef,
    start,
    end,
    {
        maxPathPolys: 256,
    },
);

(4) 보간 처리하기

다만 (3)번의 방식으로 경로를 생성하면 매우 적은 좌표가 생성된다. 왜냐하면 recast-navigation 라이브러리에서 경로를 생성할 땐 폴리곤의 경계선과 맞닿는 부분의 좌표만 반환해주기 때문이다.(예외가 있긴 하지면 거의 그렇다.) 즉, 평평한 땅이라서 폴리곤이 하나의 커다란 덩어리라면 경로가 아예 생성되지 않을 수도 있다는 것이다.

그렇다면 유니티 클라이언트에서 AI들은 어떻게 이동하는 걸까? 공식 문서를 열람해본 결과 Steering Target이라는 보간법을 쓴다는 걸 알게 되었다.

https://docs.unity3d.com/540/Documentation/ScriptReference/NavMeshAgent.html

 

Unity - Scripting API: NavMeshAgent

Success! Thank you for helping us improve the quality of Unity Documentation. Although we cannot accept all submissions, we do read each suggested change from our users and will make updates where applicable. Close

docs.unity3d.com

 

// 찾아보니까 유니티 navMesh에서 쓰는 보간법은 steering target이라고 한다.(아마도?)
// 현재 위치에서 다음 방향을 미리 설정하고 부드럽게 회전 => 경로상의 점을 직선으로 따라가는 것이 아니라, 미리 방향을 예측하고 보간함
// 다음 방향을 미리 설정할 때 일정 거리 내 가장 먼 지점을 고르는데 그 범위를 lookaheadDistance로 설정하는 셈
function getSteeringTarget(currentPosition, path, lookaheadDistance = 2) {
  let closestPoint = path[0];
  let closestDistance = Infinity;
  let steeringTarget = path[path.length - 1]; // 기본적으로 마지막 점

  for (let i = 0; i < path.length; i++) {
    const point = path[i];
    const dx = point.x - currentPosition.x;
    const dz = point.z - currentPosition.z;
    const distance = Math.sqrt(dx * dx + dz * dz);

    if (distance < closestDistance) {
      closestDistance = distance;
      closestPoint = point;
    }

    if (distance > lookaheadDistance) {
      steeringTarget = point;
      break;
    }
  }

  return steeringTarget;
}

구글링의 도움을 받아서 어찌저찌 보간법을 함수로 구현하는 데 성공했다.
return interpolatePath(rawPath, stepSize);
이제 findPath 함수에서 반환값을 보간처리한 경로로 설정해준다면 서버에서도 클라이언트와 거의 동일한 경로를 생성할 수 있다.(stepSize는 얼마만큼의 길이로 경로를 구분할 건지 설정해주는 것.)

핸들러 함수 구현하기

로직 작동방식

(1) 클라이언트에서 NavMesh 상의 특정 위치를 클릭하면 C_Move 패킷을 서버로 전송한다.

// <클라이언트 코드>
private void HandleInput()
{
    if (Input.GetMouseButtonDown(0) && !eSystem.IsPointerOverGameObject())
    {
        if (Physics.Raycast(Camera.main.ScreenPointToRay(Input.mousePosition), out rayHit))
        {
            targetPosition = rayHit.point;
        }
    }
}

IEnumerator ExecuteEvery10Frames()
{
    while (true)
    {
        yield return null; // 1 프레임 대기
        frameCount++;

        // 마지막으로 전송했던 좌표(lastTargetPosition)와 달라졌을 때에만 실행
        if (frameCount >= targetFrames && targetPosition != lastTargetPosition)
        {
            frameCount = 0;
            MoveAndSendMovePacket();
        }
    }
}

private void MoveAndSendMovePacket()
{
    // 플레이어 이동시키기
    agent.SetDestination(targetPosition);

    // 마지막으로 전송했던 좌표 기억해두기
    lastTargetPosition = targetPosition;

    // 패킷 전송
    var movePacket = new C_Move
    {
        StartPosX = transform.position.x,
        StartPosY = transform.position.y,
        StartPosZ = transform.position.z,
        TargetPosX = targetPosition.x,
        TargetPosY = targetPosition.y,
        TargetPosZ = targetPosition.z
    };
    GameManager.Network.Send(movePacket);
}

(2) 현재 위치를 기반으로 경로를 생성한다.(좌표들로 이루어진 배열)

// <서버 코드>
const targetPos = { x: targetPosX, y: targetPosY, z: targetPosZ };
const currentPos = { x: startPosX, y: startPosY, z: startPosZ };
const navMesh = await loadNavMesh('Town Exported NavMesh.obj');

// NavMesh 기반 경로 탐색
const path = await findPath(navMesh, currentPos, targetPos);

(3) 클라이언트는 자체적으로 NavMeshAgent를 이용해서 이동한다.

(4) 플레이어 오브젝트가 이동 중일 때, 클라이언트는 서버로 C_Location 패킷을 전송한다.

// <클라이언트 코드>
private void CheckMove()
{
        float distanceMoved = Vector3.Distance(lastPos, transform.position);
        animator.SetFloat(Constants.TownPlayerMove, distanceMoved * 100);

        if (distanceMoved > 0.01f)
        {
                SendLocationPacket();
        }

        lastPos = transform.position;
}

(5) C_Location 패킷에는 현재 플레이어의 위치가 담겨있으므로, 이것을 (2)번에서 구한 좌표들의 배열과 비교한다. 가장 가까운 좌표와의 거리가 오차범위 이내인지 확인한다.

(6) 만약 오차범위를 넘어간다면 가장 가까운 좌표(즉, 경로 상의 좌표)로 강제 이동시킨다.

// <서버 코드>
if (minDistance > 1.4) {
    // 오차범위를 벗어나면 플레이어의 위치를 closestPoint로 재조정한다.
    const syncLocationPacket = Packet.S_Chat(
        0,
        '플레이어의 위치를 재조정합니다.',
    );

    const newTransform = { ...closestPoint, rot: transform.rot };
    const packet = Packet.S_Location(player.id, newTransform, false);

    // 위치동기화 브로드 캐스트
    const dungeonId = player.getDungeonId();
    if (dungeonId) {
        // 만약 던전이면
        const dungeonSessions = getDungeonSessions();
        const dungeon = dungeonSessions.getDungeon(dungeonId);
        dungeon.notify(packet);
        dungeon.notify(syncLocationPacket);
    } else {
        // 던전이 아니면
        playerSession.notify(packet);
        playerSession.notify(syncLocationPacket);
    }

    return;
}

댓글