no image
자체 게임 엔진에 RecastNavigation 이식하기
목차 1. 서문 2. 구상 3. NavigationField 3.1. 네비게이션 메시 빌드 3.2. 군중(dtCrowd) 업데이트 4. NavigationAgent 4.1. 유닛 상태 설정 4.2. 유닛 이동 명령 1. 서문 이 글에서는 RecastNavigation 라이브러리의 함수와 기능을 내 게임 엔진에 이식한 결과를 소개한다. 2. 구상 내 게임엔진의 클라이언트 프로그램은 RecastNavigation 라이브러리가 어떤 녀석인지 전혀 몰라도 길찾기 기능을 사용할 수 있어야 한다. 버텍스 좌표와 인덱스 좌표를 받아 네비게이션 필드를 만드는 NavigationField 클래스와 이동 명령을 받고 길을 찾아 움직이는 NavigationAgent 클래스만 만들어 주면 클라이언트 입장에서 손쉽게 활용이 가..
2023.11.01
no image
RecastNavigation - Detour Crowd의 분석
목차 1. Detour Crowd란 2. AddAgent 3. SetMoveTarget 4. CrowdUpdate 1. Detour Crowd란 RecastDemo 프로젝트에서는 프로그램을 실행하고 네비게이션을 빌드한 후, 경로 위에 dtAgent 객체들을 배치하고 임의의 지점으로 이동명령을 내릴 수 있다.dtAgent는 길 위에 배치하고 움직이게 만들 수 있는 하나의 길찾기 주체이며, DetourCrowd는 dtAgent 객체들의 군집을 일컫는 말이다. 스타크래프트에 익숙한 우리네 정서에 맞게 dtAgent는 지금부터 유닛이라 부르겠다. 2. AddAgent void CrowdToolState::addAgent(const float* p) { if (!m_sample) return; dtCrowd* c..
2023.10.25
no image
RecastNavigation의 경로계산
목차 1. RecastDemo의 findPath 함수 2. 폴리곤 경로 탐색 3. 부드러운 경로 탐색 4. 요약 1. RecastDemo의 findPath 함수 일전에 네비게이션 빌드의 결과로 navQuery와 navMesh 객체를 생성하는 것을 확인했다. 이제 빌드된 네비게이션 맵 위에 경로의 시작점과 끝 점을 설정했을 때 경유해야할 지점들을 어떻게 얻어낼 수 있는지 알아보자. RecastDemo 프로젝트를 실행한 후 Test Navmesh 옵션이 활성화된 상태에서 지면에 마우스를 클릭하면 경로의 시작점과 종점을 지정할 수 있다. 이렇게 경로를 지정할때마다 NavMeshTesterTool 클래스의 recalc 함수가 호출되는데, 시작점과 종점이 모두 지정된 상태라면 일전에 생성한 navQuery 객체로..
2023.10.23
no image
RecastNavigation 경로맵 빌드 코드의 분석
목차 1. 서문 2. 빌드 설정값 초기화 3. 네비게이션 메시 빌드 4. 요약 1. 서문 게임개발에 길찾기 기능이 필요하게 되어 RecastNavigation이라는 오픈소스 길찾기 라이브러리를 내 게임엔진에 통합할 일이 생겼다. 깃허브에서 소스 코드를 다운로드 받으면 라이브러리 프로젝트와 함께 라이브러리 기능의 시연용 프로젝트로 RecastDemo라는 프로젝트가 있는데, 이 글은 시연용 프로젝트의 기능 중 네비게이션 메시를 생성하는 빌드 함수를 분석한 내용을 정리한 것이다. RecastNavigation 깃허브 주소 : https://github.com/recastnavigation/recastnavigation GitHub - recastnavigation/recastnavigation: Navigat..
2023.10.19

목차

 

1. 서문

2. 구상

3. NavigationField

3.1. 네비게이션 메시 빌드

3.2. 군중(dtCrowd) 업데이트

4. NavigationAgent

4.1. 유닛 상태 설정

4.2. 유닛 이동 명령


1. 서문

 이 글에서는 RecastNavigation 라이브러리의 함수와 기능을 내 게임 엔진에 이식한 결과를 소개한다.

 

2. 구상

 내 게임엔진의 클라이언트 프로그램은 RecastNavigation 라이브러리가 어떤 녀석인지 전혀 몰라도 길찾기 기능을 사용할 수 있어야 한다.

 버텍스 좌표와 인덱스 좌표를 받아 네비게이션 필드를 만드는 NavigationField 클래스와 이동 명령을 받고 길을 찾아 움직이는 NavigationAgent 클래스만 만들어 주면 클라이언트 입장에서 손쉽게 활용이 가능할 것이다.

 

3. NavigationField

#pragma once
#include "YunutyEngine.h"
#include "Component.h"
#include "Vector3.h"
#include <vector>
#include <assert.h>

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

namespace yunutyEngine
{
    class NavigationAgent;
    class YUNUTY_API NavigationField : public Component
    {
    public:
        class Impl;
        struct BuildSettings
        {
            // 길찾기 주체들의 최대 개체수
            int maxCrowdNumber{ 1024 };
            // 길찾기 주체들의 최대 충돌반경
            float maxAgentRadius{ 0.6 };
            // 오를수 있는 경사
            float walkableSlopeAngle{ 30 };
            // 오를 수 있는 단차
            float walkableClimb{ 0.2 };
            // 천장의 최소 높이
            float walkableHeight{ 0.3 };
            // x축,z축 공간 분할의 단위, 단위가 작을수록 판정이 더 세밀해지지만, 네비게이션 빌드와 길찾기 시스템의 부하가 늘게 된다.
            float divisionSizeXZ{ 0.3 };
            // y축 공간 분할의 단위, 단위가 작을수록 판정이 더 세밀해지지만, 네비게이션 빌드와 길찾기 시스템의 부하가 늘게 된다.
            float divisionSizeY{ 0.2 };
            // 공간 분할은 xz축으로 250*330, y축으로 200개 정도 분할되는 정도면 순식간에 네비게이션 빌드도 되면서 길찾기도 무리없이 하게 되는 정도다.
            // xz축으로 743* 989개 정도 분할이 되도 큰 부하는 없다.
        };
        NavigationField();
        virtual ~NavigationField();
        virtual void Update();
        void BuildField(const float* worldVertices, size_t verticesNum, const int* faces, size_t facesNum, const BuildSettings& buildSettings = BuildSettings{});
        void BuildField(std::vector<Vector3f> worldVertices, std::vector<int> faces, const BuildSettings& buildSettings = BuildSettings{})
        {
            static_assert(sizeof(Vector3f) == sizeof(float) * 3);
            assert(!worldVertices.empty() && !faces.empty());
            assert(faces.size() % 3 == 0);
            BuildField(reinterpret_cast<float*>(&worldVertices[0]), worldVertices.size(), &faces[0], faces.size() / 3, buildSettings);
        }
    private:
        Impl* impl{ nullptr };
        friend NavigationAgent;
    };
}

( YunutyNavigationField.h )

#pragma once
#include "YunutyNavigationField.h"
#include "Recast.h"
#include "DetourNavMesh.h"
#include "DetourNavMeshBuilder.h"
#include "DetourNavMeshQuery.h"
#include "DetourCrowd.h"

namespace yunutyEngine
{
    // Impl은 그저 데이터만 쌓아두는 곳으로 쓴다.
    class NavigationField::Impl
    {
    private:
        Impl(NavigationField* navFieldComponent) :navFieldComponent(navFieldComponent)
        {
            navQuery = dtAllocNavMeshQuery();
            crowd = dtAllocCrowd();
            context = std::make_unique<rcContext>(rcContext());
        }
        virtual ~Impl()
        {
            dtFreeCrowd(crowd);
            dtFreeNavMeshQuery(navQuery);
        }
        friend NavigationField;
    public:
        NavigationField* navFieldComponent;

        std::unique_ptr<rcContext> context;
        rcPolyMesh* polyMesh;
        rcConfig config;
        rcPolyMeshDetail* polyMeshDetail;
        class dtNavMesh* navMesh;
        class dtNavMeshQuery* navQuery;
        class dtCrowd* crowd;
    };
}

( YunutyNavigationFieldImpl.h )

 

 NavigationField 클래스는 클라이언트에게 헤더파일이 노출되는 클래스이다. 클라이언트 개발자는 구태여 RecastNavigation의 존재를 알 필요가 없기 때문에 RecastNavigation과 관련된 선언은 따로 이너 클래스 Impl을 만들어 그 안에 집어 넣는다.

 

3.1. 네비게이션 메시 빌드

void yunutyEngine::NavigationField::BuildField(const float* worldVertices, size_t verticesNum, const int* faces, size_t facesNum, const BuildSettings& buildSettings)
{
    float bmin[3]{ std::numeric_limits<float>::max(),std::numeric_limits<float>::max(),std::numeric_limits<float>::max() };
    float bmax[3]{ -std::numeric_limits<float>::max(),-std::numeric_limits<float>::max(),-std::numeric_limits<float>::max() };
    // 바운더리 정보부터 설정
    for (auto i = 0; i < verticesNum; i++)
    {
        if (bmin[0] > worldVertices[i * 3])
            bmin[0] = worldVertices[i * 3];
        if (bmin[1] > worldVertices[i * 3 + 1])
            bmin[1] = worldVertices[i * 3 + 1];
        if (bmin[2] > worldVertices[i * 3 + 2])
            bmin[2] = worldVertices[i * 3 + 2];

        if (bmax[0] < worldVertices[i * 3])
            bmax[0] = worldVertices[i * 3];
        if (bmax[1] < worldVertices[i * 3 + 1])
            bmax[1] = worldVertices[i * 3 + 1];
        if (bmax[2] < worldVertices[i * 3 + 2])
            bmax[2] = worldVertices[i * 3 + 2];
    }
    auto& config{ impl->config };
    memset(&config, 0, sizeof(rcConfig));

    config.cs = buildSettings.divisionSizeXZ;
    config.ch = buildSettings.divisionSizeY;
    config.walkableSlopeAngle = buildSettings.walkableSlopeAngle;
    config.walkableHeight = (int)ceilf(buildSettings.walkableHeight / config.ch);
    config.walkableClimb = (int)floorf(buildSettings.walkableClimb / config.ch);
    config.walkableRadius = (int)ceilf(config.cs * 2 / config.cs);
    config.maxEdgeLen = (int)(config.cs * 40 / config.cs);
    config.maxSimplificationError = 1.3f;
    config.minRegionArea = (int)rcSqr(config.cs * 27);		// Note: area = size*size
    config.mergeRegionArea = (int)rcSqr(config.cs * 67);	// Note: area = size*size
    config.maxVertsPerPoly = (int)6;
    config.detailSampleDist = 6.0f < 0.9f ? 0 : config.cs * 6.0f;
    config.detailSampleMaxError = config.ch * 1;

    rcVcopy(config.bmin, bmin);
    rcVcopy(config.bmax, bmax);
    rcCalcGridSize(config.bmin, config.bmax, config.cs, &config.width, &config.height);

    // 작업 맥락을 저장할 context 객체 생성, 작업의 성패여부를 저장할 processResult 선언
    auto* context = impl->context.get();
    bool processResult{ false };
    // 복셀 높이필드 공간 할당
    rcHeightfield* heightField{ rcAllocHeightfield() };
    assert(heightField != nullptr);

    processResult = rcCreateHeightfield(context, *heightField, config.width, config.height, config.bmin, config.bmax, config.cs, config.ch);
    assert(processResult == true);

    std::vector<unsigned char> triareas;
    triareas.resize(facesNum);
    //unsigned char * triareas = new unsigned char[facesNum];
    //memset(triareas, 0, facesNum*sizeof(unsigned char));

    rcMarkWalkableTriangles(context, config.walkableSlopeAngle, worldVertices, verticesNum, faces, facesNum, triareas.data());
    processResult = rcRasterizeTriangles(context, worldVertices, verticesNum, faces, triareas.data(), facesNum, *heightField, config.walkableClimb);
    assert(processResult == true);

    // 필요없는 부분 필터링
    rcFilterLowHangingWalkableObstacles(context, config.walkableClimb, *heightField);
    rcFilterLedgeSpans(context, config.walkableHeight, config.walkableClimb, *heightField);
    rcFilterWalkableLowHeightSpans(context, config.walkableHeight, *heightField);

    // 밀집 높이 필드 만들기
    rcCompactHeightfield* compactHeightField{ rcAllocCompactHeightfield() };
    assert(compactHeightField != nullptr);

    processResult = rcBuildCompactHeightfield(context, config.walkableHeight, config.walkableClimb, *heightField, *compactHeightField);
    //rcFreeHeightField(heightField);
    assert(processResult == true);

    //processResult = rcErodeWalkableArea(context, config.walkableRadius, *compactHeightField);
    //assert(processResult == true);

    processResult = rcBuildDistanceField(context, *compactHeightField);
    assert(processResult == true);

    rcBuildRegions(context, *compactHeightField, 0, config.minRegionArea, config.mergeRegionArea);
    assert(processResult == true);

    // 윤곽선 만들기
    rcContourSet* contourSet{ rcAllocContourSet() };
    assert(contourSet != nullptr);

    processResult = rcBuildContours(context, *compactHeightField, config.maxSimplificationError, config.maxEdgeLen, *contourSet);
    assert(processResult == true);

    // 윤곽선으로부터 폴리곤 생성
    rcPolyMesh*& polyMesh{ impl->polyMesh = rcAllocPolyMesh() };
    assert(polyMesh != nullptr);

    processResult = rcBuildPolyMesh(context, *contourSet, config.maxVertsPerPoly, *polyMesh);
    assert(processResult == true);

    // 디테일 메시 생성
    auto& detailMesh{ impl->polyMeshDetail = rcAllocPolyMeshDetail() };
    assert(detailMesh != nullptr);

    processResult = rcBuildPolyMeshDetail(context, *polyMesh, *compactHeightField, config.detailSampleDist, config.detailSampleMaxError, *detailMesh);
    assert(processResult == true);

    //rcFreeCompactHeightfield(compactHeightField);
    //rcFreeContourSet(contourSet);

    // detour 데이터 생성
    unsigned char* navData{ nullptr };
    int navDataSize{ 0 };

    assert(config.maxVertsPerPoly <= DT_VERTS_PER_POLYGON);

    // Update poly flags from areas.
    for (int i = 0; i < polyMesh->npolys; ++i)
    {
        if (polyMesh->areas[i] == RC_WALKABLE_AREA)
        {
            polyMesh->areas[i] = 0;
            polyMesh->flags[i] = 1;
        }
    }
    dtNavMeshCreateParams params;
    memset(&params, 0, sizeof(params));
    params.verts = polyMesh->verts;
    params.vertCount = polyMesh->nverts;
    params.polys = polyMesh->polys;
    params.polyAreas = polyMesh->areas;
    params.polyFlags = polyMesh->flags;
    params.polyCount = polyMesh->npolys;
    params.nvp = polyMesh->nvp;
    params.detailMeshes = detailMesh->meshes;
    params.detailVerts = detailMesh->verts;
    params.detailVertsCount = detailMesh->nverts;
    params.detailTris = detailMesh->tris;
    params.detailTriCount = detailMesh->ntris;
    params.offMeshConVerts = 0;
    params.offMeshConRad = 0;
    params.offMeshConDir = 0;
    params.offMeshConAreas = 0;
    params.offMeshConFlags = 0;
    params.offMeshConUserID = 0;
    params.offMeshConCount = 0;
    params.walkableHeight = config.walkableHeight;
    params.walkableRadius = config.walkableRadius;
    params.walkableClimb = config.walkableClimb;
    rcVcopy(params.bmin, polyMesh->bmin);
    rcVcopy(params.bmax, polyMesh->bmax);
    params.cs = config.cs;
    params.ch = config.ch;
    params.buildBvTree = true;

    processResult = dtCreateNavMeshData(&params, &navData, &navDataSize);
    assert(processResult == true);

    dtNavMesh* navMesh{ impl->navMesh = dtAllocNavMesh() };
    assert(navMesh != nullptr);

    dtStatus status;
    status = navMesh->init(navData, navDataSize, DT_TILE_FREE_DATA);
    //dtFree(navData);
    assert(dtStatusFailed(status) == false);

    dtNavMeshQuery* navQuery{ impl->navQuery };
    status = navQuery->init(navMesh, 2048);
    assert(dtStatusFailed(status) == false);

    impl->crowd->init(1024, buildSettings.maxAgentRadius, navMesh);
}

( YunutyNavigationField.cpp의 BuildField 함수 )

 

 네비게이션 메시 빌드 함수는 버텍스 정보와 페이스 정보를 매개변수로 받아 동작하도록 만들었다. 그 외의 부분은 RecastDemo 프로젝트의 빌드 코드를 거의 복사해서 붙여넣었다. 실패는 용납할 수 없기 때문에 빌드과정에서 차질이 있거나(dtStatusFailed(status)) 객체가 제대로 생성되지 않았을 경우(navMesh...etc !=nullptr)바로 런타임 에러를 일으킬 수 있도록 assert를 박았다.

 

- 페이스를 구성하는 인덱스들은 세 점을 이어 화살표를 만들었을 때 시계방향으로 도는 모양이 되어야 한다. 외적연산을 통해 도출되는 평면의 수직방향이 중요하게 취급되기 때문이다.

- polyMesh는 밟을 수 있는 평면들(polygon)에 대한 정보를 담고 있다. 각 평면들의 필터는 반드시 0이 아닌 값이 들어가야 한다. NavigationAgent는 통행할 수 있는 평면들을 필터링하기 위해 논리곱 비트플래그 연산(&)을 사용하는데 평면의 필터값이 0이면 참 값이 반환될수가 없다. 이 필터링 동작을 간과한 것 때문에 족히 여섯시간을 날렸다.

 

3.2. 군중(dtCrowd) 업데이트

void yunutyEngine::NavigationField::Update()
{
    if (impl->crowd == nullptr)
        return;

    impl->crowd->update(yunutyEngine::Time::GetDeltaTime(), nullptr);
}

( YunutyNavigationField.cpp의 코드 )

 

 네비게이션 메시 위에서 dtAgent들이 돌아다니려면, dtAgent들의 집합인 dtCrowd의 업데이트 함수를 불러줘야 한다. 유닛들의 이동은 서로가 서로의 상태에 의해 영향을 받기 때문에 유닛별로 업데이트를 시키는 것이 아니라 군중에 대해 업데이트를 시키는 것이다. 매 게임엔진 업데이트 주기마다 프레임간 시간 간격을 매개변수로 군중의 이동 상태를 업데이트해준다.

 

4.NavigationAgent

#pragma once
#include "Vector3.h"
#include "Component.h"

#ifdef YUNUTY_EXPORTS
#define YUNUTY_API __declspec(dllexport)
#else
#define YUNUTY_API __declspec(dllimport)
#endif

namespace yunutyEngine
{
    class NavigationField;
    class YUNUTY_API NavigationAgent : public Component
    {
    public:
        class Impl;
        NavigationAgent();
        virtual ~NavigationAgent();
        virtual void Update();
        void AssignToNavigationField(NavigationField* navField);
        void SetSpeed(float speed);
        void SetAcceleration(float accel);
        void SetRadius(float radius);
        const Vector3f& GetTargetPosition();
        float GetSpeed();
        float GetAcceleration();
        float GetRadius();
        void MoveTo(Vector3f destination);
        void MoveTo(Vector3d destination) { MoveTo(Vector3f{ destination }); }
    private:
        Impl* impl;
        NavigationField* navField;
        friend NavigationField;
    };
}

( YunutyNavigationAgent.h )

#pragma once
#include "DetourCrowd.h"
#include "YunutyNavigationAgent.h"

namespace yunutyEngine
{
    class NavigationAgent::Impl
    {
    private:
        Impl(NavigationAgent* navAgentComponent) :navAgentComponent(navAgentComponent)
        {
            navAgentComponent = navAgentComponent;
        }
        virtual ~Impl()
        {
            if (crowd != nullptr && agentIdx != -1)
                crowd->removeAgent(agentIdx);
        }
        friend NavigationAgent;
    public:
        int agentIdx{-1};
        //const dtCrowdAgent* agent{ nullptr };
        dtCrowd* crowd{ nullptr };
        dtPolyRef targetRef;
        float targetPos[3];
        dtCrowdAgentParams agentParams
        {
            .radius = 1,
            .height = 0.3,
            .maxAcceleration = std::numeric_limits<float>::max(),
            .maxSpeed = 5,
            .collisionQueryRange = 12,
            .pathOptimizationRange = 30,
            .separationWeight = 2,
            .updateFlags = DT_CROWD_ANTICIPATE_TURNS |
            DT_CROWD_OPTIMIZE_VIS |
            DT_CROWD_OBSTACLE_AVOIDANCE,
            .obstacleAvoidanceType = (unsigned char)3,
        };
        NavigationAgent* navAgentComponent;
    };
}

( YunutyNavigationAgentImpl.h )

 

 NavigationAgent도 마찬가지로 RecastDetour와 관련된 선언은 게임엔진의 사용자에게 노출되지 않는 Impl 클래스의 헤더에 넣는다.

 

4.1. 유닛 상태 설정

void yunutyEngine::NavigationAgent::SetSpeed(float speed)
{
    impl->agentParams.maxSpeed = speed;
    if (impl->crowd != nullptr)
        impl->crowd->updateAgentParameters(impl->agentIdx, &impl->agentParams);
}
void yunutyEngine::NavigationAgent::SetAcceleration(float accel)
{
    impl->agentParams.maxAcceleration = accel;
    if (impl->crowd != nullptr)
        impl->crowd->updateAgentParameters(impl->agentIdx, &impl->agentParams);
}
void yunutyEngine::NavigationAgent::SetRadius(float radius)
{
    impl->agentParams.radius = radius;
    if (impl->crowd != nullptr)
        impl->crowd->updateAgentParameters(impl->agentIdx, &impl->agentParams);
}

( YunutyNavigationAgent.cpp의 코드 중 일부 )

 

 네비게이션 메시에 배치된 유닛들은 각자 충돌크기, 속도와 가속도를 다르게 가질 수 있다. 이런 정보를 바꿀 수 있게 인터페이스를 뚫어주고 값이 바뀔때마다 crowd->updateAgentParameters 함수를 불러준다.

 

4.2. 유닛 이동 명령

void yunutyEngine::NavigationAgent::MoveTo(Vector3f destination)
{
    if (navField == nullptr)
        return;
    const dtQueryFilter* filter{ impl->crowd->getFilter(0) };
    const dtCrowdAgent* agent = impl->crowd->getAgent(impl->agentIdx);
    const float* halfExtents = impl->crowd->getQueryExtents();

    navField->impl->navQuery->findNearestPoly(reinterpret_cast<float*>(&destination), halfExtents, filter, &impl->targetRef, impl->targetPos);
    impl->crowd->requestMoveTarget(impl->agentIdx, impl->targetRef, impl->targetPos);
}

( YunutyNavigationAgent.cpp의 코드 중 MoveTo  함수 )

 

 유닛에게 이동 명령을 내릴 때는 명령의 대상지점으로부터 가장 가까운 접근가능지점을 찾아 그 위치에 requestMoveTarget 함수를 호출하면 된다. NavigationAgent클래스의 역할은 이렇게 유닛의 상태를 바꾸는 것 뿐이고, 유닛의 상태에 따라 위치정보를 업데이트하는 것은 NavigationField 클래스의 역할이 된다.

 

5. 테스트 예제 코드

void CreateNavPlane(Vector3f botleft, Vector3f topright, std::vector<Vector3f>& worldVertices, std::vector<int>& worldFaces)
{
    int startingIdx = worldVertices.size();
    worldVertices.push_back({ botleft.x,0,topright.z });
    worldVertices.push_back({ botleft.x,0,botleft.z });
    worldVertices.push_back({ topright.x,0,botleft.z });
    worldVertices.push_back({ topright.x,0,topright.z });

    worldFaces.push_back(startingIdx + 2);
    worldFaces.push_back(startingIdx + 1);
    worldFaces.push_back(startingIdx + 0);
    worldFaces.push_back(startingIdx + 3);
    worldFaces.push_back(startingIdx + 2);
    worldFaces.push_back(startingIdx + 0);

    auto tilePlane = yunutyEngine::Scene::getCurrentScene()->AddGameObject()->AddComponent<DebugTilePlane>();
    auto size = topright - botleft;
    tilePlane->GetTransform()->SetWorldPosition((botleft + topright) / 2.0);
    tilePlane->width = size.x;
    tilePlane->height = size.z;
    tilePlane->SetTiles();
}

NavigationAgent* CreateAgent(NavigationField* navField)
{
    auto agent = yunutyEngine::Scene::getCurrentScene()->AddGameObject()->AddComponent<yunutyEngine::NavigationAgent>();
    agent->SetSpeed(2);
    agent->SetRadius(0.5);
    agent->AssignToNavigationField(navField);
    auto staticMesh = agent->GetGameObject()->AddGameObject()->AddComponent<yunutyEngine::graphics::StaticMeshRenderer>();
    staticMesh->GetGI().LoadMesh("Capsule");
    staticMesh->GetGI().GetMaterial()->SetColor({ 0.75,0.75,0.75,0 });
    staticMesh->GetTransform()->position = Vector3d{ 0,0.5,0 };
    return agent;
}
.....

int main()
{
.....
// 길찾기 테스트
    {
        //auto tilePlane = yunutyEngine::Scene::getCurrentScene()->AddGameObject()->AddComponent<DebugTilePlane>();
        //tilePlane->width = 10;
        //tilePlane->height = 10;
        //tilePlane->SetTiles();

        const float corridorRadius = 3;
        std::vector<Vector3f> worldVertices {
        };
        std::vector<int> worldFaces { };

        CreateNavPlane({ -2,0,-8 }, { 2,0,8 }, worldVertices, worldFaces);
        CreateNavPlane({ -8,0,-2 }, { 8,0,2 }, worldVertices, worldFaces);
        CreateNavPlane({ -8,0,-8 }, { -6,0,8 }, worldVertices, worldFaces);
        CreateNavPlane({ 6,0,-8 }, { 8,0,8 }, worldVertices, worldFaces);
        CreateNavPlane({ -8,0,6 }, { 8,0,8 }, worldVertices, worldFaces);
        CreateNavPlane({ -2,0,-8 }, { 2,0,8 }, worldVertices, worldFaces);
        auto navField = Scene::getCurrentScene()->AddGameObject()->AddComponent<yunutyEngine::NavigationField>();
        navField->BuildField(worldVertices, worldFaces);
        auto agent = CreateAgent(navField);
        auto agent2 = CreateAgent(navField);
        auto agent3 = CreateAgent(navField);
        rtsCam->groundRightClickCallback = [=](Vector3d position) {
            agent->MoveTo(position);
            agent2->MoveTo(position);
            agent3->MoveTo(position);
        };
    }

( main.cpp 코드 )

 

 이제 테스트용 코드를 만들어내어 엔진에서 잘 작동하는지 확인해야 한다. CreateNavPlane함수는 사각형 메시의 좌측하단의 점과 우측상단의 점을 매개로 사각형 평면을 만들고 버텍스 정보와 페이스 정보를 추가한다. 이 정보를 토대로 BuildField 함수를 호출하고 완성된 네비게이션 필드에 유닛을 3기 추가시킨다. 평면과 유닛 객체에는 모두 디버그 메시를 달아놓아 그 형체를 구분할 수 있게 하고 화면 우클릭에 해당하는 콜백함수를 달아 유닛의 MoveTo 함수를 호출할 수 있게 한다.

 

( Recast 네비게이션 시스템을 테스트한 모습 )

 잘 되는 것 같다.

목차

 

1. Detour Crowd란

2. AddAgent

3. SetMoveTarget

4. CrowdUpdate


1. Detour Crowd란

RecastDemo 프로젝트를 실행해 detour crowd들을 배치하고 움직이는 모습

RecastDemo 프로젝트에서는 프로그램을 실행하고 네비게이션을 빌드한 후, 경로 위에 dtAgent 객체들을 배치하고 임의의 지점으로 이동명령을 내릴 수 있다.dtAgent는 길 위에 배치하고 움직이게 만들 수 있는 하나의 길찾기 주체이며, DetourCrowd는 dtAgent 객체들의 군집을 일컫는 말이다. 스타크래프트에 익숙한 우리네 정서에 맞게 dtAgent는 지금부터 유닛이라 부르겠다.


2. AddAgent

void CrowdToolState::addAgent(const float* p)
{
	if (!m_sample) return;
	dtCrowd* crowd = m_sample->getCrowd();
	
	dtCrowdAgentParams ap;
	memset(&ap, 0, sizeof(ap));
	ap.radius = m_sample->getAgentRadius();
	ap.height = m_sample->getAgentHeight();
	ap.maxAcceleration = 8.0f;
	ap.maxSpeed = 3.5f;
	ap.collisionQueryRange = ap.radius * 12.0f;
	ap.pathOptimizationRange = ap.radius * 30.0f;
	ap.updateFlags = 0; 
	if (m_toolParams.m_anticipateTurns)
		ap.updateFlags |= DT_CROWD_ANTICIPATE_TURNS;
	if (m_toolParams.m_optimizeVis)
		ap.updateFlags |= DT_CROWD_OPTIMIZE_VIS;
	if (m_toolParams.m_optimizeTopo)
		ap.updateFlags |= DT_CROWD_OPTIMIZE_TOPO;
	if (m_toolParams.m_obstacleAvoidance)
		ap.updateFlags |= DT_CROWD_OBSTACLE_AVOIDANCE;
	if (m_toolParams.m_separation)
		ap.updateFlags |= DT_CROWD_SEPARATION;
	ap.obstacleAvoidanceType = (unsigned char)m_toolParams.m_obstacleAvoidanceType;
	ap.separationWeight = m_toolParams.m_separationWeight;
	
	int idx = crowd->addAgent(p, &ap);
	if (idx != -1)
	{
		if (m_targetRef)
			crowd->requestMoveTarget(idx, m_targetRef, m_targetPos);
		
		// Init trail
		AgentTrail* trail = &m_trails[idx];
		for (int i = 0; i < AGENT_MAX_TRAIL; ++i)
			dtVcopy(&trail->trail[i*3], p);
		trail->htrail = 0;
	}
}

dtCrowdAgentParams는 CrowdAgent, 즉 유닛의 길찾기 관련 설정을 지정하기 위해 쓰이는 구조체이다. 유닛의 크기, 높이, 최대 속도와 최대 가속도, 길찾기 원칙 등을 저장한다. 이 매개변수 구조체의 필드값을 알맞게 설정하고 dtCrowd->addAgent 함수를 호출하면 유닛이 생성된다.


3. SetMoveTarget

void CrowdToolState::setMoveTarget(const float* p, bool adjust)
{
	......
	navquery->findNearestPoly(p, halfExtents, filter, &m_targetRef, m_targetPos);
	......
		{
			for (int i = 0; i < crowd->getAgentCount(); ++i)
			{
				const dtCrowdAgent* ag = crowd->getAgent(i);
				if (!ag->active) continue;
				crowd->requestMoveTarget(i, m_targetRef, m_targetPos);
			}
		}
	}
}

 유닛을 생성하고 이동모드로 지면을 클릭하면 클릭된 위치로 유닛이 이동한다. 이 함수의 코드의 양도 여차저차 내용이 길지만, 핵심적인 부분은 클릭한 지점으로부터 가장 가까운 지점을 query->findnearestPoly 함수로 찾아 목표지로 지정하는 부분과 유닛 군집(dtCrowdAgent)을 순회하며 개개의 유닛들에게 requestMoveTarget 함수를 통해 목표지까지의 이동을 부탁하는 부분이다. 필요할때맏 유닛들의 길찾기 상태만 이렇게 바꾸어 주면 유닛들이 매 순간 이동하면서 생기는 필요연산은 CrowdUpdate 함수에서 처리된다.


4. CrowdUpdate

void CrowdToolState::updateTick(const float dt)
{
	if (!m_sample) return;
	dtNavMesh* nav = m_sample->getNavMesh();
	dtCrowd* crowd = m_sample->getCrowd();
	if (!nav || !crowd) return;

	TimeVal startTime = getPerfTime();

	if (m_toolParams.m_showCorners)
		crowd->update(dt, &m_agentDebug);

	TimeVal endTime = getPerfTime();

	// Update agent trails
	for (int i = 0; i < crowd->getAgentCount(); ++i)
	{
		const dtCrowdAgent* ag = crowd->getAgent(i);
		AgentTrail* trail = &m_trails[i];
		if (!ag->active)
			continue;
		// Update agent movement trail.
		trail->htrail = (trail->htrail + 1) % AGENT_MAX_TRAIL;
		dtVcopy(&trail->trail[trail->htrail * 3], ag->npos);
	}

	m_agentDebug.vod->normalizeSamples();

	m_crowdSampleCount.addSample((float)crowd->getVelocitySampleCount());
	m_crowdTotalTime.addSample(getPerfTimeUsec(endTime - startTime) / 1000.0f);
}

 군집들의 상태설정만 필요할때마다 해주면 각각의 길찾기 주체들의 상태 업데이트는 crowd->Update 함수만 주기적으로 호출하면 알아서 실행된다. RecastDemo 프로젝트에서는 updateTick이라는 함수에서 군집의 Update 함수를 호출해준다.

 

 다음은 지금까지 파악한 네비게이션 메시 빌드와 dtAgent,dtCrowd들을 이용해 나의 게임엔진에 길찾기 시스템을 이식하는 내용을 다룰 것이다.

목차


1. RecastDemo의 findPath 함수

2. 폴리곤 경로 탐색

3. 부드러운 경로 탐색

4. 요약


1. RecastDemo의 findPath 함수

 

 일전에 네비게이션 빌드의 결과로 navQuery와 navMesh 객체를 생성하는 것을 확인했다. 이제 빌드된 네비게이션 맵 위에 경로의 시작점과 끝 점을 설정했을 때 경유해야할 지점들을 어떻게 얻어낼 수 있는지 알아보자.

 

그림 1. Start와 End 지점이 찍혔을 때 사이의 경로가 점선으로 찍히는 모습

RecastDemo 프로젝트를 실행한 후 Test Navmesh 옵션이 활성화된 상태에서 지면에 마우스를 클릭하면 경로의 시작점과 종점을 지정할 수 있다. 이렇게 경로를 지정할때마다 NavMeshTesterTool 클래스의 recalc 함수가 호출되는데, 시작점과 종점이 모두 지정된 상태라면 일전에 생성한 navQuery 객체로부터 findPath 함수가 호출된다.


2. 폴리곤 경로 탐색

void NavMeshTesterTool::recalc()
{
	if (!m_navMesh)
		return;
	
	if (m_sposSet)
		m_navQuery->findNearestPoly(m_spos, m_polyPickExt, &m_filter, &m_startRef, 0);
	else
		m_startRef = 0;
	
	if (m_eposSet)
		m_navQuery->findNearestPoly(m_epos, m_polyPickExt, &m_filter, &m_endRef, 0);
	else
		m_endRef = 0;
	
	m_pathFindStatus = DT_FAILURE;
	
	if (m_toolMode == TOOLMODE_PATHFIND_FOLLOW)
	{
		m_pathIterNum = 0;
		if (m_sposSet && m_eposSet && m_startRef && m_endRef)
		{
#ifdef DUMP_REQS
			printf("pi  %f %f %f  %f %f %f  0x%x 0x%x\n",
				   m_spos[0],m_spos[1],m_spos[2], m_epos[0],m_epos[1],m_epos[2],
				   m_filter.getIncludeFlags(), m_filter.getExcludeFlags()); 
#endif

			m_navQuery->findPath(m_startRef, m_endRef, m_spos, m_epos, &m_filter, m_polys, &m_npolys, MAX_POLYS);
            
			m_nsmoothPath = 0;

 findPath 함수는 시작좌표와 끝 좌표로부터 가장 가까운 시작 폴리곤, 끝 폴리곤의 레퍼런스를 가져오고, 시작부터 끝 폴리곤까지 가면서 경유하게 되는 폴리곤들의 레퍼런스들도 가져온다.

 

경로의 시점이 포함된 폴리곤은 주황색으로, 종점이 포함된 폴리곤은 초록색으로 표시되었다. 갈색으로 표시된 폴리곤은 경로를 이동하면서 지나가게 되는 폴리곤이다. 이 경우 경유 폴리곤의 갯수는 시점 폴리곤과 종점 폴리곤을 포함해 총 5개가 된다.

3. 부드러운 경로 탐색

			if (m_npolys)
			{
				// Iterate over the path to find smooth path on the detail mesh surface.
				dtPolyRef polys[MAX_POLYS];
				memcpy(polys, m_polys, sizeof(dtPolyRef)*m_npolys); 
				int npolys = m_npolys;
				
				float iterPos[3], targetPos[3];
				m_navQuery->closestPointOnPoly(m_startRef, m_spos, iterPos, 0);
				m_navQuery->closestPointOnPoly(polys[npolys-1], m_epos, targetPos, 0);
				
				static const float STEP_SIZE = 0.5f;
				static const float SLOP = 0.01f;
				
				m_nsmoothPath = 0;
				
				dtVcopy(&m_smoothPath[m_nsmoothPath*3], iterPos);
				m_nsmoothPath++;

 이제 시작점부터 종착점까지 전진거리(STEP_SIZE)를 0.5로 잡고 경로에 위치한 경유지점들을 찾아내야 한다. 시작점과 종착점을 시점 폴리곤과 종점 폴리곤 위에 사영시켜 이터레이터용 좌표변수(iterPos)를 시점으로부터 초기화한다.

 

	
				// Move towards target a small advancement at a time until target reached or
				// when ran out of memory to store the path.
				while (npolys && m_nsmoothPath < MAX_SMOOTH)
				{
					// Find location to steer towards.
					float steerPos[3];
					unsigned char steerPosFlag;
					dtPolyRef steerPosRef;
					
					if (!getSteerTarget(m_navQuery, iterPos, targetPos, SLOP,
										polys, npolys, steerPos, steerPosFlag, steerPosRef))
						break;
					
					bool endOfPath = (steerPosFlag & DT_STRAIGHTPATH_END) ? true : false;
					bool offMeshConnection = (steerPosFlag & DT_STRAIGHTPATH_OFFMESH_CONNECTION) ? true : false;

 지금부터는 반복문에 진입하여 부드러운 경로(smoothPath)를 만들어내는 구간이다.

 getSteerTarget함수는 네비게이션 메시가 목적지까지 가기 위해 직진하다가 경로를 꺾어야(Steer) 하는 지점을 찾아준다. 더 이상 경로를 꺾을 것도 없이 직진만 해도 목표지점에 도달할 수 있다면 endOfPath가 참 값이 된다.

만약 경로를 꺾어야 하는 지점이 네비게이션 메시 바깥에 존재한다면 offMeshConnection이 참 값이 된다.

 

	
					// Find movement delta.
					float delta[3], len;
					dtVsub(delta, steerPos, iterPos);
					len = dtMathSqrtf(dtVdot(delta, delta));
					// If the steer target is end of path or off-mesh link, do not move past the location.
					if ((endOfPath || offMeshConnection) && len < STEP_SIZE)
						len = 1;
					else
						len = STEP_SIZE / len;
					float moveTgt[3];
					dtVmad(moveTgt, iterPos, delta, len);

 먼저 전환점(steerPos)과 현재위치(iterPos)의 변위벡터(delta)를 구한 다음, 이로부터 현재위치와 전환점 사이의 거리(len = dtMathSqrtf(dtVdot(delta,delta)))를 구한다. 그리고 현재위치로부터 변위벡터 방향으로 보폭(STEP_SIZE)만큼 떨어진 곳을 이동대상지점(moveTgt)으로 정한다. 목표 경유지까지 딱 한걸음만 이동하려는 것이다. 만약 거리가 보폭보다 짧다면 딱 전환점까지만 이동한다.

 

	
					// Move
					float result[3];
					dtPolyRef visited[16];
					int nvisited = 0;
					m_navQuery->moveAlongSurface(polys[0], iterPos, moveTgt, &m_filter,
												 result, visited, &nvisited, 16);

					npolys = fixupCorridor(polys, npolys, MAX_POLYS, visited, nvisited);
					npolys = fixupShortcuts(polys, npolys, m_navQuery);

					float h = 0;
					m_navQuery->getPolyHeight(polys[0], result, &h);
					result[1] = h;
					dtVcopy(iterPos, result);

 이제 네비게이션 쿼리의 moveAlongSurface 함수를 호출하면 목표위치로 이동을 시도하고, 이동이 끝난 좌표를 result에 저장한다. 길을 찾아 진행하는 과정에서 어떤 네비게이션 폴리곤들을 탐색하였는지에 대한 결과를 visitied,nvisited 변수로 받아온다. fixupShortcuts 함수는 탐색된 경로에 유턴이 없는지 확인하고 수정한다. fixUpCorridor도 유사하게 잘못된 경로를 수정하는 역할인 것 같은데, 정확하게 뭔지는 모르겠다.

 

					// Handle end of path and off-mesh links when close enough.
					if (endOfPath && inRange(iterPos, steerPos, SLOP, 1.0f))
					{
						// Reached end of path.
						dtVcopy(iterPos, targetPos);
						if (m_nsmoothPath < MAX_SMOOTH)
						{
							dtVcopy(&m_smoothPath[m_nsmoothPath*3], iterPos);
							m_nsmoothPath++;
						}
						break;
					}

 만약 경로의 끝(endOfPath)에 도달했다면 현재위치를 종착점으로 바꾸고 반복문을 종료한다.

 

					else if (offMeshConnection && inRange(iterPos, steerPos, SLOP, 1.0f))
					{
						// Reached off-mesh connection.
						float startPos[3], endPos[3];
						
						// Advance the path up to and over the off-mesh connection.
						dtPolyRef prevRef = 0, polyRef = polys[0];
						int npos = 0;
						while (npos < npolys && polyRef != steerPosRef)
						{
							prevRef = polyRef;
							polyRef = polys[npos];
							npos++;
						}
						for (int i = npos; i < npolys; ++i)
							polys[i-npos] = polys[i];
						npolys -= npos;
						
						// Handle the connection.
						dtStatus status = m_navMesh->getOffMeshConnectionPolyEndPoints(prevRef, polyRef, startPos, endPos);
						if (dtStatusSucceed(status))
						{
							if (m_nsmoothPath < MAX_SMOOTH)
							{
								dtVcopy(&m_smoothPath[m_nsmoothPath*3], startPos);
								m_nsmoothPath++;
								// Hack to make the dotted path not visible during off-mesh connection.
								if (m_nsmoothPath & 1)
								{
									dtVcopy(&m_smoothPath[m_nsmoothPath*3], startPos);
									m_nsmoothPath++;
								}
							}
							// Move position at the other side of the off-mesh link.
							dtVcopy(iterPos, endPos);
							float eh = 0.0f;
							m_navQuery->getPolyHeight(polys[0], iterPos, &eh);
							iterPos[1] = eh;
						}
					}

 만약 다음 목표지가 현재 탐색중인 폴리곤 바깥에 존재한다면(offMeshConnection) 현재 폴리곤을 다음 경유 폴리곤으로 바꾼다.

 

					// Store results.
					if (m_nsmoothPath < MAX_SMOOTH)
					{
						dtVcopy(&m_smoothPath[m_nsmoothPath*3], iterPos);
						m_nsmoothPath++;
					}

반복문의 마지막 단계에서는 경유지 포인트의 정보를 m_moothPath로 지정한다. 이 코드 이후는 얻어낸 경유지마다 디버그 그래픽스 객체를 그리는 내용이다.

 

한번 경로계산이 끝나고 smoothPath에 경유지가 101개 찍힌 모습.
101개의 경유지를 거쳐 목적지까지 가는 길이 표시된 모습


4. 요약

 

 전체 프로세스를 간략하게 표시하면 이렇게 된다.

 

 다음 글에서는 detour 군집(dtCriwd)에게 이동 명령을 내렸을 때 이 주체들이 어떻게 각자 충돌 크기를 가지고 실시간으로 경로를 계산하며 이동하는지 확인해보겠다.

목차

 

1. 서문

2. 빌드 설정값 초기화

3. 네비게이션 메시 빌드

4. 요약


1. 서문

 

 게임개발에 길찾기 기능이 필요하게 되어 RecastNavigation이라는 오픈소스 길찾기 라이브러리를 내 게임엔진에 통합할 일이 생겼다. 깃허브에서 소스 코드를 다운로드 받으면 라이브러리 프로젝트와 함께 라이브러리 기능의 시연용 프로젝트로 RecastDemo라는 프로젝트가 있는데, 이 글은 시연용 프로젝트의 기능 중 네비게이션 메시를 생성하는 빌드 함수를 분석한 내용을 정리한 것이다.

 

 RecastNavigation 깃허브 주소 : https://github.com/recastnavigation/recastnavigation

 

GitHub - recastnavigation/recastnavigation: Navigation-mesh Toolset for Games

Navigation-mesh Toolset for Games. Contribute to recastnavigation/recastnavigation development by creating an account on GitHub.

github.com

 Recast Demo 프로젝트를 실행하고, 메시를 로드한 후 빌드 버튼을 누르면 푸른색으로 이동경로 맵이 생성된다.

 이때 실행되는 함수는 sample 객체의 handleBuild 함수다. 이 글의 이후 내용은 모두 이 handleBuild 함수의 본문이 어떻게 전개되는지를 다룬다.


2. 빌드 설정값 초기화

사전에 불러들인 지오메트리의 데이터를 이용해 네비게이션 메시 빌드에 필요한 정보들을 초기화하는 모습

 handleBuild는 실질적인 네비게이션 메시 빌드 함수를 부르기 전에 필요한 설정값을 먼저 초기화한다. 먼저 지오메트리에서 가져온 메시로부터 버텍스 리스트와 페이스 리스트, 바운더리에 대한 정보를 가져와 변수에 담는다.

빌드에 필요한 설정값들을 m_cfg 구조체에 다 집어넣는 모습

 m_cfg는 길찾기 주체(Agent)들의 보행가능 경사각(WalkableSlopeAngle), 보행가능 높이(WalkableClimb) 등 앞으로의 네비게이션 메시 빌드과정에 두고 두고 쓰이게 될 설정값들을 몰아서 저장하는 구조체 변수이다. 일전에 구해둔 공간 바운더리 정보(bmin,bmax)도 rcConfig의 멤버변수 값으로 저장한다.


3. 네비게이션 메시 빌드

네비게이션 빌드가 시작되면서 rcContext 클래스가 빌드의 시작을 알리는 로그를 남기는 모습

 네비게이션 빌드와 관련된 코드에 진입하면서 빌드의 시작을 알리는 로깅 함수가 호출된다. m_ctx는 네비게이션 빌드가 진행될 때의 진행내역을 로그로 남기는 역할을 하는 맥락(rcContext) 변수다. 맥락이라는 클래스 이름은 이 클래스의 역할이 네비게이션 빌드 작업의 진행 내역을 로그로 계속 추적하는 역할을 맡고 있기에 이런 것이리라. 코드를 해석하려는 사람 입장에선 이런 로깅 함수가 사용되는 부분들이 코드의 역할과 진행상황을 설명해주는 주석 역할을 해준다.

 빌드가 시작되면 먼저 높이필드(HeightField)라는 객체를 생성한다. rcAllocHeightfield 함수로 메모리를 할당받고, rcCreateHeightfield 함수로 높이필드의 3차원 볼륨(m_cfg.bmin, bmax), 2차원 면적(width, height) 등의 정보를 토대로 높이필드의 상태를 초기화한다.

이어 폴리곤 데이터를 저장할 수 있는 배열을 동적 생성한 후, 먼저 보행가능 경사각(WalkableSlopeAngle)을 보고 rcMarkWalkableTriangles 함수를 통해 걸어다닐 수 있는 영역을 걸러낸다. 그 다음 rcRasterizeTriangles를 통해 보행가능 높이(walkableClimb) 안에서 고저차가 있는 가파른 지형들을 서로 연결시켜 이어준다. 계단같은 경우 고저차는 낮지만 경사각이 가팔라 통행이 불가능한데, 보행가능 높이를 적절히 높여주면 계단의 영역들이 서로 이어져 건널 수 있는 통로가 된다.

rcMarkWalkableTriangles 함수는 보행가능 경사각(WalkableSlopeAngle)을 보고 폴리곤(Triangles)중 걸어다닐 수 있는(Walkable) 영역을 표시(Mark)한다. 왼쪽은 경사각을 20도로 두었을 때, 오른쪽은 경사각을 45도로 두었을 때 네비게이션 메시 빌드의 결과다.
rcRasterizeTriangles 함수는 보행가능 높이(WalkableClimb)를 토대로 무시할 수 있는 고저차를 두고 떨어져 있는 폴리곤(Triangles)들 사이의 공백을 채워(Rasterize) 준다.

 그 후 필터링 단계에서는 절벽 가장자리, 장애물의 유무 등 다른 다양한 조건들을 보고 접근불가한 부분을 판별한다. 이 부분은 필터링 옵션에 따라 진행할수도, 진행하지 않을 수도 있다.

 경로에 대한 정보들이 다 정리가 되어 높이필드(rcHeightField)가 만들어지면, 이를 적절히 가공해서 경로계산 연산에 최적화된 형태의 데이터로 만든다. 이 최적화된 데이터 형태를 밀집 높이필드(rcCompactHeightfield)라 하는데, 아마 데이터를 밀집시켜서 캐시 히트율을 높이는 것으로 연산시간의 감소를 노린 것 같다. 밀집 높이필드가 생성되면 더이상 쓸모가 없는 복셀 높이필드는 할당해제한다. 밀집 높이필드가 할당되고 나면 연이어 최적화 함수들을 더 호출해서 필요한 데이터의 양은 더욱 압축시키고, 서로 연관성이 높은 데이터들은 더욱 응집시킨다.

 rcErodeWalkableArea 함수는 유닛의 최소 충돌크기(m_cfg.walkableRadius)에 따라 접근가능한 영역을 더 제한한다. 아마 지형 가장자리의 접근영역을 유닛의 반지름크기만큼 잘라내는 역할일 것이다.

 Mark Area는 지역의 특징을 플래그로 지정한다. 네비게이션 주체는 나중에 이 플래그를 보고 특정 지역을 통행할 수 있는지 없는지 판단하게 된다.

높이필드의 분할방법에 대한 장단점을 개발자가 주석으로 남겨놓았다.
분할 방식에 따라 코드가 갈리는 모습

 다음으로, 네비게이션 영역을 적절히 분할한다. 분할방법은 Watershed, Monotone, Layer 이 세가지로 나뉘어 각각 장단점이 있다고 한다. 심화 길찾기 알고리즘의 구현방법에 대한 나의 이해가 부족하기 때문에 triangulation, tesselation 등이 무슨 말을 하는 건지 알 수 없었지만, Layer 분할 방식이 타일형 맵에 적합하다고 하는 것 같기에 우선 이걸 쓰면 될 것 같다.

사용하는 분할방식에 따라 부르는 함수도 달라지는 것을 볼 수 있다.

윤곽선 집합(Countour set) 정보를 만들어내는 모습

 다음으로 완성된 경로의 가장자리 정보를 만들어낸다.

 그리고 완성된 윤곽선 정보로부터 네비게이션 메시를 만들어낸다.

 일반 네비게이션 메시와 디테일 메시가 또 다른가보다. 일반메시로부터 디테일 메시를 만들어내고, 디테일 메시까지 제대로 생성되면 이제 쓰임이 다한 밀집 높이필드(CompatctHeightField)와 윤곽선 집합(CountourSet)을 할당해제한다.

"지금부터는 Detour 기능을 사용하는 구간입니다" 라는 표지판 역할을 하는 주석문

 여기까지는 Recast 라이브러리를 사용해서 일반 메시로부터 네비게이션용 메시를 생성하는 코드였고, 이 데모 프로젝트에서는 이렇게 한번 가공된 메시 데이터를 추려 Detour 라이브러리용 네비게이션 메시를 만드는 데에 사용한다. Recast가 순수 지형메시로부터 네비게이션이 적용될 구간을 추리는 역할을 한다면, Detour는 런타임 중 길찾기 연산을 시도할 때 사용될 네비게이션 메시를 생성하는 것 같다.

 메시의 분할된 영역들에는 여러가지 플래그를 저장할 수 있다. 앞에서 MarkArea 함수를 통해 지정한 각 구역(area)의 특징에 따라 지형 메시의 플래그를 설정한다. 이 플래그들은 나중에 네비게이션 주체들의 필터 플래그들과 대조되어 유닛이 특정 구역을 통행할 수 있는지의 여부를 판단하는데에 쓰이게 된다.

Detour 네비게이션 메시 생성에 쓰이는 파라미터 구조체의 초기화

 params, navData, navMesh를 차례로 할당,초기화하고 navMesh와 navQuery에서 init 함수를 호출하면 이로서 빌드가 모두 끝난다.

장구한 네비게이션 메시 빌드 함수가 참 값을 반환하며 끝나는 부분, 빌드 타이머를 종료시키고 빌드에 걸린 시간을 로그에 기록하는 것을 볼 수 있다.


4. 요약

 RecastDemo에서 사용된 네비게이션 메시 빌드 함수의 수행단계를 이해하기 쉽게 그림을 그려 정리해보자.

 이 기나긴 빌드 프로세스의 최종 결과물은 dtNavMesh, dtNavMeshQuery이다. 얘들을 이용한 런타임 길찾기는 어떻게 진행되는건지 다음 포스트에서 알아보자.