이전에 몬스터를 추가해서 특정 공간을 돌게 만들어봤는데, 문제가 몇가지 있었다.
- 몬스터가 공중에 떠있음.
- 몬스터가 장애물을 만나도 뚫고 지나감
만약 클라이언트만 개발한다거나 데디서버라면 언리얼에 내장된 기능인 NavMeshBoundsVolume 과 AI컨트롤러를 사용하면 될것이다. 하지만 나는 데디서버도 아니고 그냥 c++ 서버를 개발중이기 때문에 다른 방법을 써야한다.
그래서 방법을 알아보던 중 Recast 라는 라이브러리를 사용하면 구현할 수 있다고 하여 이것에 대해 공부해봤다.
Recast란?
게임개발에서 캐릭터가 복잡한 3D 환경을 스스로 돌아다닐 수 있게 길을 찾아주는 오픈소스 네비게이션 메시 생성 및 길찾기 툴이다. 게임 업계에서 사실상 표준이고 앞서 언급한 언리얼의 NavMeshBoundsVolume 또한 이 라이브러리를 뼈대로 만들어진 이라고 한다.
무료인데다가 함께 제공되는 데모로 직접 파라미터를 조정하면서 테스트해볼 수도 있다. 언리얼에서 맵 정보를 obj 파일로 export 해서 확인해볼 수도 있다. 라이브러리에 포함된 데모를 써보는 내용은 아래 글을 참조하자.
RecastDemo로 언리얼 맵 navigationmesh 생성해보기
윈도우11 환경에서 작성된 글입니다. 게임서버 개발을 하다가 서버사이드에서 몬스터의 움직임을 어떻게 처리해야할까 고민하다 RecastNavigation 이라는 오픈소스 라이브러리를 알게돼서 이것을
dodontak.tistory.com
RecastNavigation 라이브러리 설치
GitHub - recastnavigation/recastnavigation: Industry-standard navigation-mesh toolset for games
Industry-standard navigation-mesh toolset for games - recastnavigation/recastnavigation
github.com
위 깃허브 링크에서 zip으로 다운로드받고 아무데나 풀어준다.


폴더를 보면 여러가지 폴더와 파일들이 있는데
DebugUtils, Detour, DetourCrowd, DetourTileCache, Recast 폴더 안의 Include폴더와 Source 폴더에 있는 cpp파일과 h파일을 가져와서 쓸것이다. 그 외에는 필요없다. 다만 라이브러리를 어떻게 쓰는지는 RecastDemo에서 알려주기 때문에 RecastDemo의 파일들을 참조해서 NavMesh를 생성하는 것과 Detour로 길찾기 하는걸 배워서 써먹어야한다.
이번 글에서는 NavMesh를 생성하고, NavMesh와 Detour로 길찾기 하는것 까지 해볼 예정이다.
VS 게임서버 솔루션에 NavMesh 프로젝트 추가하기


새 프로젝트 추가 - 정적 라이브러리로 추가해주자.
프로젝트 속성 - C/C++ 에서 미리 컴파일된 헤더 사용 안 함

프로젝트 속성 - C/C++ - 코드 생성 런타임 라이브러리 MT/MTd 로 수정
우리 프로젝트가 MT라 이걸로 맞춰준다.

프로젝트 속성 - 일반 - 출력 디렉터리를 예~전에 만든 라이브러리 전용 폴더로 수정.

필터 구성 및 디렉터리 구성



아까 압축풀었던 RecastNavigation 안에 있는 h파일과 cpp파일을 위와 같이 디렉토리를 만들어 넣어준다. 필터에서도 기존파일 추가로 넣어준다.
포함 디렉터리 추가


NavMesh프로젝트 속성에서 모든 구성 모든 플렛폼에 대해위와 같이 포함 디렉터리를 추가해준다.
GameServer 프로젝트 속성에서도아래와 같이 포함 디렉터리를 추가해준다. (이후에 추가될 수도 있음)

GameServer 프로젝트 우클릭하고 추가 - 참조 NavMesh 체크

파일 추가
Main과 Utils 필터에 아래와 같이 추가해줬다.
RecastDemo에서 NavMesh 생성과 Detour 길찾기에 필수적으로 필요한 것 같은 부분만 가져왔다. 같은 이름의 파일이 있어도 내용이 좀 다를 수 있으니 각 파일의 내용은 아래 접은 글을 참고하자. or github를 참조

Recast를 써보기 전에 어떻게 돌아가는지 좀 이해를 할 필요가 있을 것 같아서 얖게나마 코드가 뭘 하는지 확인해봤다.
RecastDemo의 main에서 load함수를 호출하는 것부터 시작된다.
=== obj파일 준비 ===
1. obj파일 경로준비 (main)
=== obj파일에서 필요한 데이터 Mesh 객체에 변환 및 저장 ===
1. FileIO로 obj파일을 열어서 buffer에 내용물 싹 담음
2. 한줄씩 읽어서 v(정점)은 verts에 xyz값으로 저장, f(면)은 tris에 점의 index a, b, c로 저장 (3개 점이 1개 면)
3. 각 면의 노말벡터 계산해서 normals에 x, y, z벡터값 저장.
4. 모든 버텍스 순회해 필요한 최소 크기 직육면체 구함 (BoundsMin, BoundsMax)
5. 파티션 매시 저장 (큰 맵을 위한 tile분할 용도라 함)
=== navmesh 생성 ===
0. build 함수 호출 (sample::build 오버라이드한 sample_solomesh::build)
1. config set : navmesh 빌드에 필요한 파라미터들 설정해서 config에 저장
2. 3D매시를 복셀 격자로 변환
3. 걸을 수 없는 복셀 제거 (너무 가파른 경사, 너무 낮은 천장, 절벽 가장자리)
4. 걸을 수 있는 복셀들을 덩어리(Region)로 묶음
5. Region 외곽선 추출, 단순화 : 복셀 외곽선 -> 단순화된 Contour(계단식 외곽선을 직선만드는 것)
6. Contour를 -> PolyMesh 이게 NavMesh의 실제 폴리곤들
7. 폴리곤의 높이 정밀도 높이기
8. 경로탐색용 dtNavMesh 생성
Navigation.h
#pragma once
#include "Recast.h"
#include "InputGeom.h"
#include "DetourNavMesh.h"
#include "DetourNavMeshBuilder.h"
#include "DetourNavMeshQuery.h"
#include <memory>
#include <string>
class Navigation
{
public:
Navigation();
~Navigation() {}
bool Build();
bool FindPath(float* startPos, float* endPos, std::vector<float>& outPath);
std::string basicLevelObjPath;
std::unique_ptr<InputGeom> inputGeometry;
std::unique_ptr<rcContext> buildContext;
bool filterLowHangingObstacles = true;
bool filterLedgeSpans = true;
bool filterWalkableLowHeightSpans = true;
rcConfig config{};
unsigned char* triAreas = nullptr;
rcHeightfield* heightfield = nullptr;
rcCompactHeightfield* compactHeightfield = nullptr;
rcContourSet* contourSet = nullptr;
rcPolyMesh* polyMesh = nullptr;
rcPolyMeshDetail* detailMesh = nullptr;
dtNavMesh* navMesh = nullptr;
dtNavMeshQuery* navQuery = nullptr;
};
Navigation.cpp
#include "Navigation.h"
#include "DetourCommon.h"
#include <string>
#include <iostream>
#include <fstream>
Navigation::Navigation()
{
//TODO env에서 정보 가져오거나 폴더에있는 파일 탐색해서 가져오면 좋을 듯.
basicLevelObjPath = R"(D:\Projects\GameServerProject\Project_Island\MapData\BasicLevel.obj)";
inputGeometry = std::make_unique<InputGeom>();
buildContext = std::make_unique<rcContext>();
inputGeometry->LoadMesh(nullptr, basicLevelObjPath);
}
bool Navigation::Build()
{
if (!inputGeometry || inputGeometry->mesh.verts.empty())
{
return false;
}
const float* boundsMin = inputGeometry->getNavMeshBoundsMin();
const float* boundsMax = inputGeometry->getNavMeshBoundsMax();
const float* verts = inputGeometry->mesh.verts.data();
const int numVerts = static_cast<int>(inputGeometry->mesh.verts.size()) / 3;
const int* tris = inputGeometry->mesh.tris.data();
const int numTris = static_cast<int>(inputGeometry->mesh.tris.size()) / 3;
const float agentHeight = 180.f;
const float agentRadius = 30.f;
const float agentMaxClimb = 90.f;
// Step 1. Initialize build config.
memset(&config, 0, sizeof(rcConfig));
config.cs = 30.f;//cellSize
config.ch = 20.f;//cellHeight
config.walkableSlopeAngle = 45.0f;//agentMaxSlope
config.walkableHeight = static_cast<int>(ceilf(agentHeight / config.ch));;//agentHeight
config.walkableClimb = static_cast<int>(floorf(agentMaxClimb / config.ch));//agentMaxClimb
config.walkableRadius = static_cast<int>(ceilf(agentRadius / config.cs));//agentRadius
config.maxEdgeLen = static_cast<int>(1200.f / config.cs);//edgeMaxLen / cellSize
config.maxSimplificationError = 1.3f;//edgeMaxError
config.minRegionArea = static_cast<int>(rcSqr(8));// regionMinSize Note: area = size*size
config.mergeRegionArea = static_cast<int>(rcSqr(20));// regionMergeSize Note: area = size*size
config.maxVertsPerPoly = 6;//vertsPerPoly
config.detailSampleDist = 6.f < 0.9f ? 0 : config.cs * 6.f;//cellSize * cellSize
config.detailSampleMaxError = config.ch * 1.f;//cellHeight * detailSampleMaxError
// Set the area where the navigation will be built.
// Here the bounds of the input mesh are used, but the
// area could be specified by a user defined box, etc.
rcVcopy(config.bmin, boundsMin);
rcVcopy(config.bmax, boundsMax);
rcCalcGridSize(config.bmin, config.bmax, config.cs, &config.width, &config.height);
// Step 2. Rasterize input meshes.
heightfield = rcAllocHeightfield();
if (!heightfield)
{
return false;
}
if (!rcCreateHeightfield(
buildContext.get(),
*heightfield,
config.width,
config.height,
config.bmin,
config.bmax,
config.cs,
config.ch))
{
return false;
}
// Allocate array that can hold triangle area types.
// This is used to store terrain type information and to mark
// triangles as unwalkable.
// If you have multiple meshes you need to process, allocate
// an array which can hold the max number of triangles you need to process.
triAreas = new unsigned char[numTris];
if (!triAreas)
{
return false;
}
memset(triAreas, 0, numTris * sizeof(unsigned char));
// Record which triangles in the input mesh are walkable.
// This information is recorded in triAreas
rcMarkWalkableTriangles(buildContext.get(), config.walkableSlopeAngle, verts, numVerts, tris, numTris, triAreas);
// Rasterize the input mesh
// If your have multiple meshes, you can transform them, calculate the
// terrain type for each mesh and rasterize them here.
if (!rcRasterizeTriangles(buildContext.get(), verts, numVerts, tris, triAreas, numTris, *heightfield, config.walkableClimb))
{
return false;
}
// Step 3. Filter walkable surfaces.
// Once all geometry is rasterized, we do initial pass of filtering to
// remove unwanted overhangs caused by the conservative rasterization
// as well as spans where the character cannot possibly stand.
if (filterLowHangingObstacles)
{
rcFilterLowHangingWalkableObstacles(buildContext.get(), config.walkableClimb, *heightfield);
}
if (filterLedgeSpans)
{
rcFilterLedgeSpans(buildContext.get(), config.walkableHeight, config.walkableClimb, *heightfield);
}
if (filterWalkableLowHeightSpans)
{
rcFilterWalkableLowHeightSpans(buildContext.get(), config.walkableHeight, *heightfield);
}
// Step 4. Partition walkable surface into simple regions.
// Compact the heightfield so that it is faster to work with.
// This will result more cache coherent data. This step will also
// generate neighbor connection information between walkable cells.
compactHeightfield = rcAllocCompactHeightfield();
if (!compactHeightfield)
{
return false;
}
if (!rcBuildCompactHeightfield(
buildContext.get(),
config.walkableHeight,
config.walkableClimb,
*heightfield,
*compactHeightfield))
{
return false;
}
// Erode the walkable area by agent radius.
// This allows us to path an agent through the navmesh as if it was a single point
if (!rcErodeWalkableArea(buildContext.get(), config.walkableRadius, *compactHeightfield))
{
return false;
}
// (Optional) Marks the surface type of voxels in an area defined by a convex volume.
// Useful to mark areas of differing cost.
for (ConvexVolume& vol : inputGeometry->convexVolumes)
{
rcMarkConvexPolyArea(
buildContext.get(),
vol.verts,
vol.nverts,
vol.hmin,
vol.hmax,
(unsigned char)vol.area,
*compactHeightfield);
}
// Partition the heightfield into contiguous regions that will each be
// triangulated into navigation polygons.
//
// There are 3 partitioning methods, each with their own pros and cons:
// 1) Watershed partitioning
// - the classic Recast partitioning
// - creates the nicest tessellation
// - usually slowest
// - the are some corner cases where this method creates holes and
// overlaps in the resulting region data.
// - holes may appear when a small obstacle is close to a large open
// area. This will not cause triangulation to fail.
// - overlaps may occur if you have narrow spiral corridors
// e.g. spiral stairs. This will cause triangulation to fail.
// * Generally the best choice if you are precompute the navmesh and/or
// there are large open areas in the input geometry.
// 2) Monotone partitioning
// - fastest
// - guaranteed to partition the heightfield into regions without holes
// or overlaps
// - Can create long, thin polygons which sometimes cause paths with detours
// * Use this if you want fast navmesh generation
// 3) Layer partitioning
// - quite fast
// - partitions the heighfield into non-overlapping regions
// - relies on the triangulation code to cope with holes, which makes
// this slower than monotone partitioning
// - produces better triangles than monotone partitioning
// - does not have the corner cases of watershed partitioning
// - can be slow and create a slightly ugly tessellation (still better
// than monotone) if you have large open areas with small obstacles.
// This is less of a problem if you use a tiled navmesh.
// * A good choice for a tiled navmesh with small to medium-sized tiles
// Watershed partitioning
if (!rcBuildDistanceField(buildContext.get(), *compactHeightfield))
{
return false;
}
// Partition the walkable surface into contiguous regions.
if (!rcBuildRegions(buildContext.get(), *compactHeightfield, 0, config.minRegionArea, config.mergeRegionArea))
{
return false;
}
// Step 5. Trace and simplify region contours.
// Create contour.
contourSet = rcAllocContourSet();
if (!contourSet)
{
return false;
}
if (!rcBuildContours(buildContext.get(), *compactHeightfield, config.maxSimplificationError, config.maxEdgeLen, *contourSet))
{
return false;
}
// Step 6. Triangulate contours to build navmesh polygons.
polyMesh = rcAllocPolyMesh();
if (!polyMesh)
{
return false;
}
if (!rcBuildPolyMesh(buildContext.get(), *contourSet, config.maxVertsPerPoly, *polyMesh))
{
return false;
}
// Step 7. Create a navmesh from the triangulated polygons.
// Calculates additional information necessary to run pathing queries.
detailMesh = rcAllocPolyMeshDetail();
if (!detailMesh)
{
return false;
}
if (!rcBuildPolyMeshDetail(
buildContext.get(),
*polyMesh,
*compactHeightfield,
config.detailSampleDist,
config.detailSampleMaxError,
*detailMesh))
{
return false;
}
// Step 8. Create Detour data from Recast poly mesh.
if (config.maxVertsPerPoly <= DT_VERTS_PER_POLYGON)
{
unsigned char* navData = nullptr;
int navDataSize = 0;
// 폴리곤 플래그 설정
for (int i = 0; i < polyMesh->npolys; ++i)
{
if (polyMesh->areas[i] == RC_WALKABLE_AREA)
{
polyMesh->areas[i] = 0; // GROUND
polyMesh->flags[i] = 0x01; // WALK
}
}
dtNavMeshCreateParams params;
memset(¶ms, 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.walkableHeight = agentHeight;
params.walkableRadius = agentRadius;
params.walkableClimb = agentMaxClimb;
rcVcopy(params.bmin, polyMesh->bmin);
rcVcopy(params.bmax, polyMesh->bmax);
params.cs = config.cs;
params.ch = config.ch;
params.buildBvTree = true;
if (!dtCreateNavMeshData(¶ms, &navData, &navDataSize))
{
return false;
}
navMesh = dtAllocNavMesh();
if (!navMesh)
{
dtFree(navData);
return false;
}
dtStatus status = navMesh->init(navData, navDataSize, DT_TILE_FREE_DATA);
if (dtStatusFailed(status))
{
dtFree(navData);
return false;
}
navQuery = dtAllocNavMeshQuery();
status = navQuery->init(navMesh, 2048);
if (dtStatusFailed(status))
{
return false;
}
}
return true;
}
bool Navigation::FindPath(float* startPos, float* endPos, std::vector<float>& outPath)
{
dtQueryFilter filter;
filter.setIncludeFlags(0x01);
filter.setExcludeFlags(0);
float extents[3] = { 100.f, 100.f, 100.f }; // 시작/끝 점 근처 탐색 범위
// 시작점 근처 폴리곤 찾기
dtPolyRef startRef;
float startNearest[3];
navQuery->findNearestPoly(startPos, extents, &filter, &startRef, startNearest);
// 끝점 근처 폴리곤 찾기
dtPolyRef endRef;
float endNearest[3];
navQuery->findNearestPoly(endPos, extents, &filter, &endRef, endNearest);
if (!startRef || !endRef)
{
return false;
}
// 경로 탐색
dtPolyRef polys[256];
int npolys = 0;
navQuery->findPath(startRef, endRef, startNearest, endNearest, &filter, polys, &npolys, 256);
if (npolys == 0)
{
return false;
}
// 실제 경로 좌표 추출
float path[256 * 3];
int npath = 0;
navQuery->findStraightPath(startNearest, endNearest, polys, npolys, path, nullptr, nullptr, &npath, 256);
for (int i = 0; i < npath; ++i)
{
outPath.push_back(path[i * 3 + 0]);
outPath.push_back(path[i * 3 + 1]);
outPath.push_back(path[i * 3 + 2]);
}
return true;
}
InputGeom.h
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include <string>
#include <vector>
#include "PartitionedMesh.h"
struct PartitionedMesh;
class rcContext;
struct duDebugDraw;
static constexpr int MAX_CONVEXVOL_PTS = 12;
struct ConvexVolume
{
float verts[MAX_CONVEXVOL_PTS * 3] = {};
int nverts = 0;
float hmin = 0.0f;
float hmax = 0.0f;
int area = 0;
};
struct BuildSettings
{
/// Cell size in world units
float cellSize = 0;
/// Cell height in world units
float cellHeight = 0;
/// Agent height in world units
float agentHeight = 0;
/// Agent radius in world units
float agentRadius = 0;
/// Agent max climb in world units
float agentMaxClimb = 0;
/// Agent max slope in degrees
float agentMaxSlope = 0;
/// Region minimum size in voxels.
float regionMinSize = 0;
/// Region merge size in voxels. regionMergeSize = sqrt(regionMergeArea)
float regionMergeSize = 0;
/// Edge max length in world units
float edgeMaxLen = 0;
/// Edge max error in voxels
float edgeMaxError = 0;
int vertsPerPoly = 0;
/// Detail sample distance in voxels
float detailSampleDist = 0;
/// Detail sample max error in voxel heights.
float detailSampleMaxError = 0;
/// Partition type, see SamplePartitionType
int partitionType = 0;
/// Bounds of the area to mesh
float navMeshBMin[3]{};
float navMeshBMax[3]{};
/// Size of the tiles in voxels
float tileSize = 0;
};
struct Mesh
{
std::vector<float> verts;
std::vector<int> tris;
std::vector<float> normals; // face normals
void reset()
{
verts.clear();
tris.clear();
normals.clear();
}
[[nodiscard]] int getVertCount() const { return static_cast<int>(verts.size()) / 3; }
[[nodiscard]] int getTriCount() const { return static_cast<int>(tris.size()) / 3; }
void readFromObj(char* buf, size_t bufLen);
};
class InputGeom
{
BuildSettings buildSettings;
bool hasBuildSettings = false;
public:
std::string filename;
Mesh mesh;
float meshBoundsMin[3] = {};
float meshBoundsMax[3] = {};
PartitionedMesh partitionedMesh;
bool LoadMesh(rcContext* ctx, const std::string& filepath);
std::vector<float> offmeshConnVerts;
std::vector<float> offmeshConnRadius;
std::vector<unsigned char> offmeshConnBidirectional;
std::vector<unsigned char> offmeshConnArea;
std::vector<unsigned short> offmeshConnFlags;
std::vector<unsigned int> offmeshConnId;
std::vector<ConvexVolume> convexVolumes;
/// Method to return static mesh data.
[[nodiscard]] const float* getNavMeshBoundsMin() const { return hasBuildSettings ? buildSettings.navMeshBMin : meshBoundsMin; }
[[nodiscard]] const float* getNavMeshBoundsMax() const { return hasBuildSettings ? buildSettings.navMeshBMax : meshBoundsMax; }
[[nodiscard]] const BuildSettings* getBuildSettings() const { return hasBuildSettings ? &buildSettings : nullptr; }
private:
void clearOffMeshConnections()
{
offmeshConnVerts.clear();
offmeshConnRadius.clear();
offmeshConnBidirectional.clear();
offmeshConnArea.clear();
offmeshConnFlags.clear();
offmeshConnId.clear();
}
};
InputGeom.cpp
#include "InputGeom.h"
#include "NavMeshUtils.h"
#include "Recast.h"
#include <fstream>
#include <sstream>
namespace
{
bool intersectSegmentTriangle(const float* sp, const float* sq, const float* a, const float* b, const float* c, float& t);
bool isectSegAABB(const float* sp, const float* sq, const float* amin, const float* amax, float& tmin, float& tmax);
char* parseRow(char* buf, char* bufEnd, char* row, int len);
char* readRow(char* buf, char* bufEnd, char* row, int len);
int readFace(char* row, int* data, int maxDataLen, int vertCount);
}
bool InputGeom::LoadMesh(rcContext* ctx, const std::string& filepath)
{
FileIO file;
if (!file.openForRead(filepath.c_str()))
{
ctx->log(RC_LOG_ERROR, "buildTiledNavigation: Could not load '%s'", filepath.c_str());
return false;
}
size_t bufferLen = file.getFileSize();
char* buffer = new char[bufferLen];
if (!file.read(buffer, bufferLen))
{
ctx->log(RC_LOG_ERROR, "buildTiledNavigation: Could not load '%s'", filepath.c_str());
return false;
}
filename = filepath;
clearOffMeshConnections();
convexVolumes.clear();
mesh.reset();
mesh.readFromObj(buffer, bufferLen);
rcCalcBounds(mesh.verts.data(), mesh.getVertCount(), meshBoundsMin, meshBoundsMax);
partitionedMesh = {}; // Reset the partitioned mesh
partitionedMesh.PartitionMesh(mesh.verts.data(), mesh.tris.data(), mesh.getTriCount(), 256);
return true;
}
void Mesh::readFromObj(char* buf, size_t bufLen)
{
char* src = buf;
char* srcEnd = buf + bufLen;
char row[512];
int face[32];
float x, y, z;
int numVertices;
while (src < srcEnd)
{
// Parse one row
row[0] = '\0';
src = readRow(src, srcEnd, row, sizeof(row) / sizeof(row[0]));
if (row[0] == '#')
{
// Comment
continue;
}
if (row[0] == 'v' && row[1] != 'n' && row[1] != 't')
{
// Vertex pos
sscanf(row + 1, "%f %f %f", &x, &y, &z);
verts.push_back(x);
verts.push_back(y);
verts.push_back(z);
}
if (row[0] == 'f')
{
// Face
const int vertCount = static_cast<int>(verts.size()) / 3;
numVertices = readFace(row + 1, face, sizeof(face) / sizeof(face[0]), vertCount);
for (int i = 2; i < numVertices; ++i)
{
const int a = face[0];
const int b = face[i - 1];
const int c = face[i];
if (a < 0 || a >= vertCount || b < 0 || b >= vertCount || c < 0 || c >= vertCount)
{
continue;
}
tris.push_back(a);
tris.push_back(b);
tris.push_back(c);
}
}
}
// Calculate face normals.
normals.resize(tris.size());
for (int i = 0; i < static_cast<int>(tris.size()); i += 3)
{
const float* vertex0 = &verts[tris[i + 0] * 3];
const float* vertex1 = &verts[tris[i + 1] * 3];
const float* vertex2 = &verts[tris[i + 2] * 3];
// Construct two triangle edges
float edge0[3];
float edge1[3];
for (int j = 0; j < 3; ++j)
{
edge0[j] = vertex1[j] - vertex0[j];
edge1[j] = vertex2[j] - vertex0[j];
}
float* normal = &normals[i];
// Cross product
normal[0] = edge0[1] * edge1[2] - edge0[2] * edge1[1];
normal[1] = edge0[2] * edge1[0] - edge0[0] * edge1[2];
normal[2] = edge0[0] * edge1[1] - edge0[1] * edge1[0];
// Normalize
float normalLength = sqrtf(normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]);
if (normalLength > 0)
{
normalLength = 1.0f / normalLength;
normal[0] *= normalLength;
normal[1] *= normalLength;
normal[2] *= normalLength;
}
}
}
namespace
{
bool intersectSegmentTriangle(const float* sp, const float* sq, const float* a, const float* b, const float* c, float& t)
{
float ab[3];
rcVsub(ab, b, a);
float ac[3];
rcVsub(ac, c, a);
float qp[3];
rcVsub(qp, sp, sq);
// Compute triangle normal. Can be precalculated or cached if
// intersecting multiple segments against the same triangle
float norm[3];
rcVcross(norm, ab, ac);
// Compute denominator d. If d <= 0, segment is parallel to or points
// away from triangle, so exit early
float d = rcVdot(qp, norm);
if (d <= 0.0f)
{
return false;
}
// Compute intersection t value of pq with plane of triangle. A ray
// intersects iff 0 <= t. Segment intersects iff 0 <= t <= 1. Delay
// dividing by d until intersection has been found to pierce triangle
float ap[3];
rcVsub(ap, sp, a);
t = rcVdot(ap, norm);
if (t < 0.0f)
{
return false;
}
if (t > d)
{
return false;
} // For segment; exclude this code line for a ray test
// Compute barycentric coordinate components and test if within bounds
float e[3];
rcVcross(e, qp, ap);
float v = rcVdot(ac, e);
if (v < 0.0f || v > d)
{
return false;
}
float w = -rcVdot(ab, e);
if (w < 0.0f || v + w > d)
{
return false;
}
// Segment/ray intersects triangle. Perform delayed division
t /= d;
return true;
}
bool isectSegAABB(const float* sp, const float* sq, const float* amin, const float* amax, float& tmin, float& tmax)
{
static constexpr float EPS = 1e-6f;
float d[3];
rcVsub(d, sq, sp);
tmin = 0.0;
tmax = 1.0f;
for (int i = 0; i < 3; i++)
{
if (fabsf(d[i]) < EPS)
{
if (sp[i] < amin[i] || sp[i] > amax[i])
{
return false;
}
}
else
{
const float ood = 1.0f / d[i];
float t1 = (amin[i] - sp[i]) * ood;
float t2 = (amax[i] - sp[i]) * ood;
if (t1 > t2)
{
float tmp = t1;
t1 = t2;
t2 = tmp;
}
tmin = std::max(t1, tmin);
tmax = std::min(t2, tmax);
if (tmin > tmax)
{
return false;
}
}
}
return true;
}
char* parseRow(char* buf, char* bufEnd, char* row, int len)
{
bool start = true;
bool done = false;
int n = 0;
while (!done && buf < bufEnd)
{
char c = *buf;
buf++;
// multirow
switch (c)
{
case '\n':
if (start)
{
break;
}
done = true;
break;
case '\r':
break;
case '\t':
case ' ':
if (start)
{
break;
}
// else falls through
default:
start = false;
row[n++] = c;
if (n >= len - 1)
{
done = true;
}
break;
}
}
row[n] = '\0';
return buf;
}
char* readRow(char* buf, char* bufEnd, char* row, int len)
{
// skip leading whitespace
for (; buf < bufEnd; ++buf)
{
char c = *buf;
if (c != '\\' && c != '\r' && c != '\n' && c != '\t' && c != ' ')
{
break;
}
}
int n = 0;
for (; buf < bufEnd; ++buf)
{
char c = *buf;
if (c == '\n')
{
break;
}
if (c == '\\' || c == '\r')
{
// skip
continue;
}
// Copy character
row[n++] = c;
if (n >= len - 1)
{
break;
}
}
row[n] = '\0';
return buf;
}
int readFace(char* row, int* data, int maxDataLen, int vertCount)
{
int numVertices = 0;
while (*row != '\0')
{
// Skip initial white space
while (*row != '\0' && (*row == ' ' || *row == '\t'))
{
row++;
}
char* s = row;
// Find vertex delimiter and terminate the string there for conversion.
while (*row != '\0' && *row != ' ' && *row != '\t')
{
if (*row == '/')
{
*row = '\0';
}
row++;
}
if (*s == '\0')
{
continue;
}
int vertexIndex = atoi(s);
data[numVertices++] = vertexIndex < 0 ? vertexIndex + vertCount : vertexIndex - 1;
if (numVertices >= maxDataLen)
{
break;
}
}
return numVertices;
}
}
PartitionedMesh.h
//
// Copyright (c) 2009-2010 Mikko Mononen memon@inside.org
//
// This software is provided 'as-is', without any express or implied
// warranty. In no event will the authors be held liable for any damages
// arising from the use of this software.
// Permission is granted to anyone to use this software for any purpose,
// including commercial applications, and to alter it and redistribute it
// freely, subject to the following restrictions:
// 1. The origin of this software must not be misrepresented; you must not
// claim that you wrote the original software. If you use this software
// in a product, an acknowledgment in the product documentation would be
// appreciated but is not required.
// 2. Altered source versions must be plainly marked as such, and must not be
// misrepresented as being the original software.
// 3. This notice may not be removed or altered from any source distribution.
//
#pragma once
#include <vector>
/// A spatially-partitioned mesh (k/d tree),
/// where each node contains at max trisPerChunk triangles.
struct PartitionedMesh
{
struct Node
{
// xy bounds
float bmin[2];
float bmax[2];
int triIndex;
int numTris;
};
std::vector<Node> nodes{};
int nnodes = 0;
std::vector<int> tris{};
int maxTrisPerChunk = 0;
void PartitionMesh(const float* verts, const int* tris, int numTris, int trisPerChunk);
/// Finds the chunk indices that overlap the input rectangle.
void GetNodesOverlappingRect(float bmin[2], float bmax[2], std::vector<int>& outNodes) const;
/// Returns the chunk indices which overlap the input segment.
void GetNodesOverlappingSegment(float start[2], float end[2], std::vector<int>& outNodes) const;
};
PartitionedMesh.cpp
//
// Copyright (c) 2009-2010 Mikko Mononen memon@inside.org
//
// This software is provided 'as-is', without any express or implied
// warranty. In no event will the authors be held liable for any damages
// arising from the use of this software.
// Permission is granted to anyone to use this software for any purpose,
// including commercial applications, and to alter it and redistribute it
// freely, subject to the following restrictions:
// 1. The origin of this software must not be misrepresented; you must not
// claim that you wrote the original software. If you use this software
// in a product, an acknowledgment in the product documentation would be
// appreciated but is not required.
// 2. Altered source versions must be plainly marked as such, and must not be
// misrepresented as being the original software.
// 3. This notice may not be removed or altered from any source distribution.
//
#include "PartitionedMesh.h"
#include <cmath>
#include <cstdlib>
struct IndexedBounds
{
float bmin[2];
float bmax[2];
int index;
};
namespace
{
int compareMinX(const void* va, const void* vb)
{
return static_cast<int>(static_cast<const IndexedBounds*>(va)->bmin[0] - static_cast<const IndexedBounds*>(vb)->bmin[0]);
}
int compareMinY(const void* va, const void* vb)
{
return static_cast<int>(static_cast<const IndexedBounds*>(va)->bmin[1] - static_cast<const IndexedBounds*>(vb)->bmin[1]);
}
/// Calculates the total extent of all bounds in the given index range
void calcTotalBounds(const std::vector<IndexedBounds> bounds, const int start, const int end, float* outBMin, float* outBMax)
{
outBMin[0] = bounds[start].bmin[0];
outBMin[1] = bounds[start].bmin[1];
outBMax[0] = bounds[start].bmax[0];
outBMax[1] = bounds[start].bmax[1];
for (int boundIndex = start + 1; boundIndex < end; ++boundIndex)
{
const IndexedBounds& it = bounds[boundIndex];
outBMin[0] = std::min(it.bmin[0], outBMin[0]);
outBMin[1] = std::min(it.bmin[1], outBMin[1]);
outBMax[0] = std::max(it.bmax[0], outBMax[0]);
outBMax[1] = std::max(it.bmax[1], outBMax[1]);
}
}
void subdivide(
std::vector<IndexedBounds> triBounds,
int imin,
int imax,
int trisPerChunk,
int& curNode,
PartitionedMesh::Node* nodes,
const int maxNodes,
int& curTri,
int* outTris,
const int* inTris)
{
const int numTriBoundsInRange = imax - imin;
const int icur = curNode;
if (curNode >= maxNodes)
{
return;
}
PartitionedMesh::Node& node = nodes[curNode];
curNode++;
if (numTriBoundsInRange <= trisPerChunk) // Leaf
{
// Get total bounds of all triangles
calcTotalBounds(triBounds, imin, imax, node.bmin, node.bmax);
// Copy triangles.
node.triIndex = curTri;
node.numTris = numTriBoundsInRange;
for (int triIndex = imin; triIndex < imax; ++triIndex)
{
const int* src = &inTris[triBounds[triIndex].index * 3];
int* dst = &outTris[curTri * 3];
curTri++;
dst[0] = src[0];
dst[1] = src[1];
dst[2] = src[2];
}
}
else
{
// Split
calcTotalBounds(triBounds, imin, imax, node.bmin, node.bmax);
float xLength = node.bmax[0] - node.bmin[0];
float yLength = node.bmax[1] - node.bmin[1];
// Sort along the longest axis
qsort(
triBounds.data() + imin,
static_cast<size_t>(numTriBoundsInRange),
sizeof(IndexedBounds),
(xLength >= yLength) ? compareMinX : compareMinY);
int isplit = imin + numTriBoundsInRange / 2;
// Left
subdivide(triBounds, imin, isplit, trisPerChunk, curNode, nodes, maxNodes, curTri, outTris, inTris);
// Right
subdivide(triBounds, isplit, imax, trisPerChunk, curNode, nodes, maxNodes, curTri, outTris, inTris);
// Negative index means escape.
node.triIndex = icur - curNode;
}
}
bool checkOverlapRect(const float amin[2], const float amax[2], const float bmin[2], const float bmax[2])
{
return amin[0] <= bmax[0] && amax[0] >= bmin[0] && amin[1] <= bmax[1] && amax[1] >= bmin[1];
}
bool checkOverlapSegment(const float p[2], const float q[2], const float bmin[2], const float bmax[2])
{
float tmin = 0;
float tmax = 1;
float d[]{ q[0] - p[0], q[1] - p[1] };
for (int i = 0; i < 2; i++)
{
static const float EPSILON = 1e-6f;
if (fabsf(d[i]) < EPSILON)
{
// Ray is parallel to slab. No hit if origin not within slab
if (p[i] < bmin[i] || p[i] > bmax[i])
{
return false;
}
}
else
{
// Compute intersection t value of ray with near and far plane of slab
float ood = 1.0f / d[i];
float t1 = (bmin[i] - p[i]) * ood;
float t2 = (bmax[i] - p[i]) * ood;
if (t1 > t2)
{
float tmp = t1;
t1 = t2;
t2 = tmp;
}
if (t1 > tmin)
{
tmin = t1;
}
if (t2 < tmax)
{
tmax = t2;
}
if (tmin > tmax)
{
return false;
}
}
}
return true;
}
}
void PartitionedMesh::PartitionMesh(const float* verts, const int* tris, int numTris, int trisPerChunk)
{
// Calculate the XZ bounds of every triangle.
std::vector<IndexedBounds> triBounds;
triBounds.resize(numTris);
for (int triIndex = 0; triIndex < numTris; triIndex++)
{
const int* tri = &tris[triIndex * 3];
IndexedBounds& bound = triBounds[triIndex];
bound.index = triIndex;
bound.bmin[0] = bound.bmax[0] = verts[tri[0] * 3 + 0];
bound.bmin[1] = bound.bmax[1] = verts[tri[0] * 3 + 2];
for (int vertIndex = 1; vertIndex < 3; ++vertIndex)
{
const float x = verts[tri[vertIndex] * 3 + 0];
bound.bmin[0] = std::min(x, bound.bmin[0]);
bound.bmax[0] = std::max(x, bound.bmax[0]);
const float z = verts[tri[vertIndex] * 3 + 2];
bound.bmin[1] = std::min(z, bound.bmin[1]);
bound.bmax[1] = std::max(z, bound.bmax[1]);
}
}
// Build tree
int numChunks = static_cast<int>(ceilf(static_cast<float>(numTris) / static_cast<float>(trisPerChunk)));
nodes.resize(numChunks * 4);
this->tris.resize(numTris * 3);
int curTri = 0;
int curNode = 0;
subdivide(triBounds, 0, numTris, trisPerChunk, curNode, nodes.data(), numChunks * 4, curTri, this->tris.data(), tris);
nnodes = curNode;
// Calc max tris per chunk.
maxTrisPerChunk = 0;
for (auto& node : nodes)
{
// Skip if it's not a leaf node
if (node.triIndex < 0)
{
continue;
}
maxTrisPerChunk = std::max(maxTrisPerChunk, node.numTris);
}
}
void PartitionedMesh::GetNodesOverlappingRect(float bmin[2], float bmax[2], std::vector<int>& outNodes) const
{
// Traverse tree
for (int nodeIndex = 0; nodeIndex < this->nnodes;)
{
const Node* node = &this->nodes[nodeIndex];
const bool overlap = checkOverlapRect(bmin, bmax, node->bmin, node->bmax);
const bool isLeafNode = node->triIndex >= 0;
if (isLeafNode && overlap)
{
outNodes.emplace_back(nodeIndex);
}
if (overlap || isLeafNode)
{
nodeIndex++;
}
else
{
// escape index
nodeIndex -= node->triIndex;
}
}
}
void PartitionedMesh::GetNodesOverlappingSegment(float start[2], float end[2], std::vector<int>& outNodes) const
{
// Traverse tree
for (int nodeIndex = 0; nodeIndex < this->nnodes;)
{
const Node* node = &this->nodes[nodeIndex];
const bool overlap = checkOverlapSegment(start, end, node->bmin, node->bmax);
const bool isLeafNode = node->triIndex >= 0;
if (isLeafNode && overlap)
{
outNodes.emplace_back(nodeIndex);
}
if (overlap || isLeafNode)
{
nodeIndex++;
}
else
{
// escape index
nodeIndex -= node->triIndex;
}
}
}
NavMeshUtils.h
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include "RecastDump.h"
#include <array>
#include <string>
#include <vector>
/// stdio file implementation.
class FileIO final : public duFileIO
{
public:
FileIO() = default;
FileIO(const FileIO&) = delete;
FileIO& operator=(const FileIO&) = delete;
FileIO(FileIO&&) = default;
FileIO& operator=(FileIO&&) = default;
~FileIO() override;
bool openForWrite(const char* path);
bool openForRead(const char* path);
[[nodiscard]] bool isWriting() const override;
[[nodiscard]] bool isReading() const override;
bool write(const void* ptr, size_t size) override;
bool read(void* ptr, size_t size) override;
size_t getFileSize() const;
static void scanDirectory(const std::string& path, const std::string& ext, std::vector<std::string>& fileList);
private:
FILE* fp = nullptr;
enum class Mode { none, reading, writing };
Mode mode = Mode::none;
};
NavMeshUtils.cpp
#include "NavMeshUtils.h"
#include <algorithm>
#include <io.h>
FileIO::~FileIO()
{
if (fp)
{
fclose(fp);
}
}
bool FileIO::openForWrite(const char* path)
{
if (fp)
{
return false;
}
fp = fopen(path, "wb");
if (!fp)
{
return false;
}
mode = Mode::writing;
return true;
}
bool FileIO::openForRead(const char* path)
{
if (fp)
{
return false;
}
fp = fopen(path, "rb");
if (!fp)
{
return false;
}
mode = Mode::reading;
return true;
}
bool FileIO::isWriting() const
{
return mode == Mode::writing;
}
bool FileIO::isReading() const
{
return mode == Mode::reading;
}
bool FileIO::write(const void* ptr, const size_t size)
{
if (!fp || mode != Mode::writing)
{
return false;
}
fwrite(ptr, size, 1, fp);
return true;
}
bool FileIO::read(void* ptr, const size_t size)
{
if (!fp || mode != Mode::reading)
{
return false;
}
size_t readLen = fread(ptr, size, 1, fp);
return readLen == 1;
}
size_t FileIO::getFileSize() const
{
if (!fp || mode != Mode::reading)
{
return false;
}
const size_t currentPos = ftell(fp);
if (fseek(fp, 0, SEEK_END) != 0)
{
return 0;
}
const size_t size = ftell(fp);
if (fseek(fp, 0, static_cast<int>(currentPos)) != 0)
{
return 0;
}
return size;
}
void FileIO::scanDirectory(const std::string& path, const std::string& ext, std::vector<std::string>& fileList)
{
const std::string pathWithExt = path + "/*" + ext;
_finddata_t dir;
const intptr_t findHandle = _findfirst(pathWithExt.c_str(), &dir);
if (findHandle == -1L)
{
return;
}
do
{
fileList.emplace_back(dir.name);
} while (_findnext(findHandle, &dir) == 0);
_findclose(findHandle);
// Sort the list of files alphabetically.
std::sort(fileList.begin(), fileList.end());
}
테스트
일단 obj파일이 필요하니까 언리얼 에디터 아웃라이너에서 지형에 해당하는 부분을 선택하고 export 해준다.




맵 obj파일은 솔루션 디렉터리에 MapData 폴더를 추가해서 넣어줬다.
Navigation::Navigation()
{
//TODO env에서 정보 가져오거나 폴더에있는 파일 탐색해서 가져오면 좋을 듯.
basicLevelObjPath = R"(D:\Projects\GameServerProject\Project_Island\MapData\BasicLevel.obj)";
inputGeometry = std::make_unique<InputGeom>();
buildContext = std::make_unique<rcContext>();
inputGeometry->LoadMesh(nullptr, basicLevelObjPath);
}
이것을 Navigation 생성자에서 열고 LoadMesh를 호출해 obj파일을 파싱해서 데이터를 메모리에 저장하는 것.
NavMesh를 만들 때 파라미터는 일단 하드코딩했다. 만약 여러 크기의 몬스터가 하나의 맵에서 적절하게 움직일 수 있도록 만들려면 다른부분은 괜찮아도 agentHeight, agentRadius, agentMaxClimb 는 동적으로 수정해서 각각에 맞는 NavMesh를 만들어야 할 것 같다. (아직 해본건 아니라 잘 모르겠다)
const float* boundsMin = inputGeometry->getNavMeshBoundsMin();
const float* boundsMax = inputGeometry->getNavMeshBoundsMax();
const float* verts = inputGeometry->mesh.verts.data();
const int numVerts = static_cast<int>(inputGeometry->mesh.verts.size()) / 3;
const int* tris = inputGeometry->mesh.tris.data();
const int numTris = static_cast<int>(inputGeometry->mesh.tris.size()) / 3;
const float agentHeight = 180.f;
const float agentRadius = 30.f;
const float agentMaxClimb = 90.f;
// Step 1. Initialize build config.
memset(&config, 0, sizeof(rcConfig));
config.cs = 30.f;//cellSize
config.ch = 20.f;//cellHeight
config.walkableSlopeAngle = 45.0f;//agentMaxSlope
config.walkableHeight = static_cast<int>(ceilf(agentHeight / config.ch));;//agentHeight
config.walkableClimb = static_cast<int>(floorf(agentMaxClimb / config.ch));//agentMaxClimb
config.walkableRadius = static_cast<int>(ceilf(agentRadius / config.cs));//agentRadius
config.maxEdgeLen = static_cast<int>(1200.f / config.cs);//edgeMaxLen / cellSize
config.maxSimplificationError = 1.3f;//edgeMaxError
config.minRegionArea = static_cast<int>(rcSqr(8));// regionMinSize Note: area = size*size
config.mergeRegionArea = static_cast<int>(rcSqr(20));// regionMergeSize Note: area = size*size
config.maxVertsPerPoly = 6;//vertsPerPoly
config.detailSampleDist = 6.f < 0.9f ? 0 : config.cs * 6.f;//cellSize * cellSize
config.detailSampleMaxError = config.ch * 1.f;//cellHeight * detailSampleMaxError
이제 게임 서버에서 사용해보자.
Navigation navigation;
cout << navigation.Build() << endl;
float start[3] = { -44.0f, 457.0f, -790.0f };
float end[3] = { 0.0f, 0.0f, 0.0f };
std::vector<float> path;
if (navigation.FindPath(start, end, path))
{
cout << "경로 waypoint 수: " << path.size() / 3 << endl;
for (int i = 0; i < path.size() / 3; ++i)
{
cout << i << ": " << path[i * 3] << " " << path[i * 3 + 1] << " " << path[i * 3 + 2] << endl;
}
}

Start는 계단 위의 큐브 위치, End는 계단 아래에에 있는 큐브 위치다.
주의 할 것이 Recast에서는 X Y Z 가 각각 가로 높이 세로인데, 언리얼에서는 X Y Z가 가로 세로 높이 이기때문에 좌표를 쓰는 순서에 주의해야한다.
실행해보면 아래와 같이 waypoint가 찍힌다.
=== Server ===
1
경로 waypoint 수: 6
0: -44 479.5 -790
1: 219.997 479.5 -830
2: 639.997 299.5 -800
3: 609.997 239.5 -740
4: 309.997 99.4998 -560
5: 0 19.7285 0
이 waypoint들을 언리얼 상에서 큐브로 찍어서 확인해보면 이렇다.
계단을 타고 내려가는 모습을 상상해볼 수 있다.

지금까지의 git 버전
게임서버
Feat: Recast · Dodontak/Project_Island_GameServer@c8e3ac6
Recast 라이브러리를 솔루션에 추가함
github.com
클라이언트는 변경사항 없음
삽질 기록
처음에는 언리얼에 이미 NavMesh 기능이 내장되어있으니 이걸 어떻게 뽑아서 쓰면 되지 않을까? 하는 생각에 AI에게 물어보며 언리얼에서 생성한 NavMesh를 파일로 추출해서 서버에서 갖다 쓰는 방법을 고안해봤다.
이 방법을 쓰려고 언리얼 에디터에 NavMeshExporter를 만들어서 추출해보려 하거나, 그냥 액터의 멤버 변수로 만들어 begin play에서 추출을 하려 한다던지 하는 뻘짓을 많이 했다.
그런데 문제가 언리얼에서 쓰는 Recast 라이브러리는 언리얼용으로 개조되어있는 것인지 오픈소스의 것과는 그 내용물이 굉장히 달랐다(구조체 라던지). 그래서 이 방식대로 하려면 Recast를 언리얼에 맞춰서 개조해야 했는데 이건 진짜 애바인 것 같아서 더 좋은 방법을 고민해봐야했다.
근데 생각해보니 어차피 Recast가 obj파일을 토대로 NavMesh를 만들어주니까 그냥 지형데이터를 obj로 추출해서 서버에 놓으면 서버가 그걸 기존 Recast 라이브러리로 처리하면 되겠다 싶어서 해보니 잘 됐다. (꼭 이런 우회법은 절대 AI가 먼저 알려주지 않더라)
'프로젝트 > Project_Island' 카테고리의 다른 글
| 56. 몬스터 소환 및 이동 동기화 (0) | 2026.05.27 |
|---|---|
| 55. 이동 동기화 (0) | 2026.05.26 |
| 54. 스폰, 디스폰 (0) | 2026.05.18 |
| 53. 캐릭터 선택, 생성 (0) | 2026.05.12 |
| 52. 회원가입과 로그인 (0) | 2026.05.08 |