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
no image
스트레시 - 소코반 퍼즐게임
스토브 인디 게시 페이지 : https://store.onstove.com/ko/games/2070깃허브 주소 : https://github.com/yunu95/Stresh유튜브 주소 : https://youtu.be/BMZWLyMs3lc?si=edAJFXUATMV_2R46 게임 플레이 영상 목차1. 배경2. C언어 기반 라이프사이클 개발3. 클라이언트 구현4. 소감  1. 배경 게임 인재원은 총 2년 8학기 커리큘럼으로 운영되었고, 첫 1년의 4학기는 학기말마다 기획반 학생, 아트반 학생, 프로그래밍반 학생들이 연합해 팀을 짜 2주~ 4주의 시간에 걸쳐 게임을 개발하는 미니게임 프로젝트를 진행했다. 이 글이 소개하는 프로젝트는 내가 첫 1학기때 개발한 프로젝트로 개발완료까지 1주일 반의 기간이 주어졌고..
2023.10.11
no image
종갓집 김치 - 클리커 게임 모작 프로젝트
깃허브 주소 : https://github.com/yunu95/EndingFamily-Kimchi유튜브 주소 : https://www.youtube.com/watch?v=mDR3Lyo6fZk&ab_channel=Floater 목차계획 단계사전 작업- Yunuty 게임 엔진의 개발프로젝트의 시작소감 1. 계획단계 2022년 11월 게임인재원 1학기, 약 1주~1주 반 정도의 시간을 들여 간단한 게임을 만들어 보는 시간이 있었다. 오후 5시까지 배정된 수업은 수업대로 진행하고 남는 시간에 게임을 개발하는 과제였다. 할당된 개발 시간이 절대적으로 부족하고, 팀원들도 모두 프로그래밍 초심자였기 때문에 개발역량의 견적이 나오지 않는 상황에서 떠올린 나의 전략은 "최소기능의 구현은 매우 간단하지만 확장이 용이한 게..
2023.10.05
no image
서문 - 필연적일 수 밖에 없는 유년기의 좌절
책의 서문은 저자가 미술가 친구의 집을 방문한 이야기로 시작한다. 친구는 자신이 5살 때 그린 그림의 이야기를 하며 저자에게 "내 엄마는 한번도 내가 평소에 뭘 하는지 물어본적이 없어, 내 예술은 물론이고, 뭐, 사실상 모든 면에서 관심이 없으셨지."라는 이야기를 스스럼없이 이야기하는데, 저자는 그런 이야기를 구김없이 하는 친구의 태도에 깊은 인상을 받았다고 한다. 부모가 자녀의 유년기에 그들의 정신세계에 아무런 관심을 가지지 못했다는 것은 대부분의 사람들에게는 평생의 한으로 남을 수 있는 일이다. 유년기때 느낀 허전한 공백감을 성인이 되어서도 마치 영원히 메워지지 않는 싱크홀처럼 여기며, 애꿎은 곳에서 달래려 하는 사람들이 얼마나 많을까? 그런데 이 미술가 양반은 부모님의 관심이 결여되었던 자신의 유년..
2023.09.09
no image
Thoughts without a thinker
2015년, 우연히 이 책을 읽어보고 첫 장에 매료되어 끝까지 읽었던 기억이 난다. 한국어로 번역된 이 책의 이름은 '붓다의 심리학'이었는데, 어릴적부터 모태신앙으로서 불교행사에 참가하기를 강요받으며 자라 별로 불교에 좋은 기억이 없던 나에게, 이런 제목은 도발로 다가왔다. '공덕이니 전생이니 윤회니 꿈같은 이야기만 하는 너희 컬티스트들이 무슨 심리학을 논한단 말이냐?' 불교재단에서 운영하는 대학을 다니면서, 스님들의 괴팍한 면모를 많이 보고 실망도 자주 한 나였다. 책 제목에서 느껴지는 회의감에도 불구하고 이 책을 꺼내든 이유는 불교에 대한 실망감을 한층 더 굳히고 싶어서였을 것이다. 하지만 이 책은, 일체의 과장을 보태지 않고 말하건데, 내 인생을 바꿔 놓았다. 책을 읽은 후 원문이 궁금해진 나는 원..
2023.08.28
no image
그래픽스 엔진 설계변경
이전 설계에서 말도 안되는 부분을 발견했다. 3d 애니메이션을 구현하기 위해 게임 엔진에서 애니메이션이 적용된 본들의 TM들을 본의 이름을 키값으로 하는 map에 넣어 그래픽스 엔진에 통째로 전달하면 그래픽스 엔진이 노드들의 상태를 반영해 메시를 그리는 방식으로 설계를 짰었다. 하지만 매 프레임 각각의 메시 인스턴스들에게 map을 만들어서 넘겨주고, 또 그래픽스 엔진에서는 이를 받아 문자열 해싱을 통해 본을 찾아서 적용한다? 이 설계 아래에서 그래픽스 엔진이 최적화를 할 수 있는 여지도 없을 것 같고, 해싱같은 비싼 연산을 매 업데이트마다, 매 메시마다, 매 본마다 수행한다는게 매우 탐탁지 않다. 그래서 애니메이션 인스턴스 하나에 대응되는 인터페이스를 만들고, 오프셋 시간을 매개변수로 전달해 메시에 적용..
2023.06.21
no image
그래픽스 엔진 인터페이스 구상
카메라, 메시, 메터리얼, 애니메이션. 대략 이정도만 있다면 기본적인 게임은 돌아갈 것이다. 아직 그래픽스 프로그래밍을 배우고 있는 상태기 때문에, 이 중 더더욱 구현대상을 추려 IMesh정도만 구현해봐야겠다.
2023.06.19

목차


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이다. 얘들을 이용한 런타임 길찾기는 어떻게 진행되는건지 다음 포스트에서 알아보자.

스토브 인디 게시 페이지 : https://store.onstove.com/ko/games/2070

깃허브 주소 : https://github.com/yunu95/Stresh

유튜브 주소 : https://youtu.be/BMZWLyMs3lc?si=edAJFXUATMV_2R46 

게임 플레이 영상

 목차

1. 배경

2. C언어 기반 라이프사이클 개발

3. 클라이언트 구현

4. 소감

 

 1. 배경

 게임 인재원은 총 2년 8학기 커리큘럼으로 운영되었고, 첫 1년의 4학기는 학기말마다 기획반 학생, 아트반 학생, 프로그래밍반 학생들이 연합해 팀을 짜 2주~ 4주의 시간에 걸쳐 게임을 개발하는 미니게임 프로젝트를 진행했다. 이 글이 소개하는 프로젝트는 내가 첫 1학기때 개발한 프로젝트로 개발완료까지 1주일 반의 기간이 주어졌고, 학생들이 C++을 아직 안 배웠기 때문에 뒤쳐지거나 소외되는 사람들이 생기지 않도록 하기 위해 사용 가능한 언어가 C언어로 강제되었다.

팀 구성  
프로그래밍 팀장
프로그래밍 부팀장 A
프로그래밍 팀원 B
아트 팀장 C
아트 팀원 D
기획 팀장 E
기획 팀원 F

외국어 이름은 다 가명임

 

 A는 비전공자 출신임에도 엄청난 의욕과 역량을 가진 친구로 팀의 부선장 역할을 맡길 수 있었다. 리소스를 관리하는 로직을 짜는 것, UI 버튼 기능을 구현하는 것, 컷씬을 연출하는 것 등 시간도 오래 걸리고 프로그래밍 역량도 필요한 과업을 믿고 맡길 수 있어 매우 든든했다. B는 아직 프로그래밍에 익숙지 않아 반복문, 조건문, 함수 모듈화와 같이 기초적인 프로그래밍 기법에 익숙해질 수 있도록 작업의 범위를 조절해 가며 일을 맡겼다.

 

2. C언어 기반 라이프사이클 개발

C언어에서 만든 게임 루프 함수, 그냥 무한루프를 돌리고 업데이트 함수들만 주구장창 불렀다.

 일반적인 게임 루프라면 여러가지 역할들을 하는 클래스들을 같은 인터페이스로 묶어 일괄적으로 업데이트 함수를 불렀겠지만, C언어는 상속이나 다형성 같은 개념이 언어에 반영되어 있지 않기 때문에 그런 추상화된 구조를 적용하는 게 힘들었다. 어떻게 구조체와 함수 포인터를 잘 사용하면 C++의 클래스처럼 코드를 짤 수 있었겠으나 애초에 C언어를 쓰는 취지가 프로그래밍 초심자를 배려해 코드를 알아보기 쉽게 짜란 것이니 속 편히 포기하기로 했다. 사용자 입력, 컷씬, UI 창, 사운드 시스템 등 다양한 모듈들의 업데이트 함수들은 모두 서로 다른 이름의 전역 함수로 구현하고 그것을 게임 루프에서 차례대로 호출했다.

 

3. 클라이언트 구현

윈도우에 그림을 그리는 함수는 모두 전역으로 선언된 GDIEngine.h 헤더 파일

 그림을 그리는 함수들은 모두 Wingdi.h 헤더의 함수들을 응용하여 전역함수로 만들어 놓았다. 함수에 매개변수로 출력대상 이미지 파일의 경로를 문자열로 넣으면 그 문자열을 키값으로 캐싱된 이미지 리소스를 사용해 화면에 이미지를 그린다. 문자열 해싱의 구현은 부팀장 A에게 맡겼다.

 Stage는 주인공의 상태, 주인공의 위치, 스테이지 타일의 배치와 스테이지 위 오브젝트들의 위치 등, 현재 스테이지를 진행하는데 필요한 모든 맥락을 저장했다. 인게임 로직이 크게 어려울 것이 없었기 때문에, 구현을 여러 코드에 산재시키지 않기 위해 모든 원죄를 짊어진 구조체를 만든 것이다.

메모장으로 작성한 레벨 디자인이 스테이지에 적용된 모습

 각 스테이지마다 타일과 오브젝트의 배치도는 메모장으로 작성했다. 문자 4개로 하나의 타일에 대한 정보를 기록했다.

 그 외 주인공의 애니메이션, 오브젝트들이 둥실거리는 움직임, 사운드 모듈 등 게임의 완성도를 끌어올릴 수 있는 디테일적인 부분들을 개선했다.

 

4. 소감

 이 프로젝트 진행중에는 A의 존재감이 매우 컸다. 실력도 있고 힘도 있는데, 욕심도 많고 본인이 참가한 게임은 절대 남에게 얕보이면 안된다는 자존심까지 있어서 처음에는 꽤 부담스러운 면도 있었다. 유능한 이를 동료나 수하로 두면 시기하는 마음이 들기 마련이라더니, 불꽃같이 강렬한  A 모습에 프로그래밍 팀장으로서의 내 위치가 위협받는 것 같아 두려운 마음도 들었지만 '항상 유능해야만 하는 나'라는 나의 자의식 문제를 제대로 직면할 수 있는 기회라는 생각도 들었다.

 불교에는 자신의 문제를 남에게 덮어씌워 투사하지 말고, 스스로의 마음을 비추어 바라보라는 말이 있다. 과연 나의 문제를 A에게 그만 덮어씌우고 가만히 스스로의 마음을 관찰하며 바라보니 A가 나를 도모하려는 불온세력에서 든든한 조력자로 바뀌는 인식의 전환을 경험할 수 있었다.

 

깃허브 주소 : https://github.com/yunu95/EndingFamily-Kimchi

유튜브 주소 : https://www.youtube.com/watch?v=mDR3Lyo6fZk&ab_channel=Floater 

목차

  1. 계획 단계
  2. 사전 작업- Yunuty 게임 엔진의 개발
  3. 프로젝트의 시작
  4. 소감

 

1. 계획단계

 2022년 11월 게임인재원 1학기, 약 1주~1주 반 정도의 시간을 들여 간단한 게임을 만들어 보는 시간이 있었다. 오후 5시까지 배정된 수업은 수업대로 진행하고 남는 시간에 게임을 개발하는 과제였다. 할당된 개발 시간이 절대적으로 부족하고, 팀원들도 모두 프로그래밍 초심자였기 때문에 개발역량의 견적이 나오지 않는 상황에서 떠올린 나의 전략은 "최소기능의 구현은 매우 간단하지만 확장이 용이한 게임을 만들자."였다. 이런 전략에 맞는 게임의 기획은 바로 클리커 게임이었다.

그저 쿠키를 많이 만들기만 하면 되는 게임, 쿠키 클리커

 유명한 웹 게임, 쿠키 클리커로부터 유래한 클리커 장르 게임은 생산 시설을 구입하고 시설의 효율을 증가시켜 자원 수치를 끝없이 올리는 것에서 재미를 찾게 만드는 게임이다. 사실상 이런 게임은 사용자가 스페이스 바를 눌렀을 때 어떤 숫자가 1씩 증가하는 시스템만 만들어도 게임이 성립되기 때문에 최소기능 구현이 매우 간단하다고 볼 수 있다. 이렇게 먼저 최소기능만 구현한 후, 시설, 업그레이드, 쿠키 생산량에 따라 출력되는 뉴스 내용과 같은 시스템들은 모두 추가 기능으로 두고 하나씩 개발하는 것을 계획으로 잡았다.

 원본 게임의 설정까지 완전히 다 베낄 수는 없으니 쿠키가 아니라 김치를 만드는 게임으로 설정을 바꿨다.

 

2. 사전작업 - Yunuty 게임엔진의 개발

같은 솔루션에서 게임 엔진 프로젝트와 게임 클라이언트 프로젝트를 분리하여 작성했다. 왼쪽이 클라이언트, 오른쪽이 게임 엔진.

 나는 애초부터 게임 엔진을 밑바닥부터 만들어보고 싶다는 생각으로 게임 인재원이라는 기관에 들어왔기 때문에, 평소에도 유니티를 모방한 자체 게임엔진 개발을 진행하고 있었다. 그래서 이번 프로젝트를 시작하기 전에 먼저 팀원들에게 양해를 구해 이 게임엔진 위에 클라이언트 코드를 얹는 방식으로 개발을 진행하기로 했다.

상용엔진 유니티의 라이프 사이클
게임엔진 사이클을 정의하는 싱글톤 클래스 YunutyCycle의 선언부(좌)와 구현부(우), 객체의 구조만 잡기 위해 구현부는 비어있는 함수들도 많다.
게임 스레드가 돌리는 함수인 ThreadFunction의 구현부, while(true)로부터 그 성질을 알 수 있는 함수로, 프로세스가 끝날때까지 이 함수의 반복문 내부는 무한정 실행된다.

 먼저, 유니티처럼 엔진이 끊임없이 동작하게 만들고 싶다면 스레드가 끊임없이 동작하며 게임 오브젝트 객체들의 함수들을 순서대로 부를 수 있어야 했다. 한마디로 엔진의 라이프 사이클을 정의해야 했는데, 시간 여건상 Start, Update 정도만 구현하고 넘기기로 했다.

게임 씬과 게임오브젝트 클래스의 선언부

 게임 씬과 게임 오브젝트는 자식 게임 오브젝트들을 계층적으로 배치할 수 있는 구조로 만들었다.

컴포넌트 클래스의 코드, 게임 사이클의 특정 이벤트마다 실행할 함수들인 Start,Update,OnEnable 등의 함수들이 가상 함수로 정의되어 있다.
게임 오브젝트 클래스의 코드 중 AddComponent, GetComponent등의 함수로 컴포넌트를 추가하거나 레퍼런스를 가져올 수 있게 만들었다.

 그 다음 게임엔진에서 가장 핵심적인 코드라 할 수 있는 컴포넌트 코드를 만들었다. 컴포넌트는 게임 오브젝트에 붙일 수 있는 부품같은 존재로, 게임 사이클의 특정 주기마다 내부의 함수들이 호출된다. 클라이언트 코드 개발자는 컴포넌트 클래스를 상속받는 임의의 컴포넌트 객체를 만들고 각 게임 사이클마다 호출될 멤버 함수들을 재정의하는 것으로 클라이언트 로직을 구현할 수 있다.

콘솔 창 프로젝트에서만 쓰일 코드가 필터로 분리된 모습

 이번 프로젝트는 "게임 화면을 콘솔창에서만 출력해야 한다."는 특수 룰이 적용되어 있었다. 나중에 알게 된 사실이지만 게임화면의 경우 게임 엔진과 별도로 그래픽스 엔진을 개발해 화면 출력의 역할을 전담시키는 것이 일반적인 개발방식이라고 한다. 당시 그래픽스에 대한 개념이 전혀 없었던 나는 화면 출력과 관련된 코드를 따로 라이브러리로 뺄 생각까지는 하지 못했지만, 게임 엔진의 핵심적 동작과 이질적인 코드들을 따로 관리해야겠다는 생각은 하고 있었다. 따라서 나는 콘솔창에서 게임 화면이 출력되는 것을 염두에 두는 코드들은 언제든지 떼어버릴 수 있도록 따로 Console 필터에 몰아넣어 분류하였다.

카메라 클래스와 그를 상속받는 콘솔창 카메라

 카메라는 카메라의 위치선정, 메인 카메라의 지정 로직을 넘어 아예 렌더링 함수까지 카메라에 넣었다. 물론 콘솔창에다가 렌더링을 하는 코드는 바깥으로 빼고 싶었기 때문에 콘솔창용 카메라는 카메라의 파생 클래스로  따로 만들었다. 환경이 달라질때마다 렌더링 방법도 달라져야 했기에 베이스 카메라 클래스의 렌더 함수는 완전 가상함수로 선언했다.

사용자 입력을 처리하는 인풋 클래스와 그를 상속받는 콘솔창 인풋 클래스

 사용자 입력을 처리하는 클래스도 나는 추상클래스로 만들어 처리했다. 콘솔 창에서 사용자 입력을 받는 로직이 나중에 환경이 달라짐에 따라 얼마든지 바뀔 수 있다고 생각했기 때문이다. 지금은 게임 엔진을 안드로이드, 리눅스에서도 쓸 생각을 하지 않는다면야 굳이 이렇게까지 할 필요는 없었다는 생각이 든다. GetAsyncKeyState라는 함수가 윈도우 운영체제 위에서의 사용자 입력은 충분히 다 받아줄 수 있었기 때문이다.

 어쨌든 게임엔진 사이클, 게임 오브젝트 - 컴포넌트 구조, 사용자 입력 감지 기능, 콘솔창의 카메라 출력 기능과 콘솔창의 그래픽스 기능을 거의 다 개발한 상태에서 프로젝트 팀이 구성되었다.

 

3. 프로젝트의 시작

팀 구성  
팀장
클라이언트 근간 시스템, UI A
사운드, 이미지 등 리소스 탐색 B
외부 라이브러리 연동 C

외국어 이름은 모두 가명임

 

 A는 대학에서 경영을, B는 건축을 배웠으며 C는 러시아어를 전공했다. 이들은 모두 코딩을 게임 인재원에 들어와서 처음 배운 초심자들로, 코딩을 배운 지 한 달 반 정도 뒤에 이 프로젝트를 시작했으니 다들 C++은 고사하고 C언어의 개념에도 익숙지 않은 상태였다. 큰 문제는 아니었다. 상황이 주어지면 상황에 맞게 계획을 짜면 된다. 팀원들의 개발 역량을 많이 기대할 수 없었고 그들의 현재 상태를 파악하는 것도 힘든 상황이었지만, 작업을 적절한 단위로 쪼갠 후 가장 작은 일부터 시작해 점진적으로 큰 일을 맡기면서 자연스럽게 작은 책임으로부터 큰 책임으로 역할의 확장을 유도하면 되는 일이었다.

 프로젝트 시작후 2일에서 3일정도 엔진 개발을 마무리하는 시간을 가지면서 그동안 팀원들에게는 쿠키 클리커를 플레이해보고 아이디어를 구상해보도록 했다. 나의 작업 때문에 전체 인원의 작업에 병목이 생기는 게 싫긴 했지만, 작업의 규모와 영역을 체계적으로 잘라 분배하기 위해서는 어느 정도 프레임워크가 있는 것이 없는 것보단 나을 거라고 판단했다.

반복문을 활용해 텍스트박스를 그리는 함수, 몇몇 클래스의 경우 함수의 선언만 내가 하고 구현은 팀원들에게 맡겼다. 위 함수는 A가 작업했다.

 팀원들은 프로그래밍 실력을 따지기에 앞서 프로젝트 자체에 대단히 큰 부담감을 느끼고 있는 것 같았다. 아마 자신이 코드를 어설프게 짜면 팀플을 망치게 되지 않을까 그랬던 것 같았다. 때문에 팀원들에게 먼저 자기확신이 들 수 있는 작업경험을 느끼도록 유도하고, 그 동력을 살려 자신감의 스노우볼을 굴려야겠다는 생각을 했다. 나의 이런 시도에 가장 고맙게 호응해 준 친구가 A였다. 나는 A에게 함수 기능 중 특정 반복문의 중괄호 내부를 채워달라고 부탁했는데 그 작업을 성공하면서부터 코딩에 대한 자신감이 붙더니 나중에 가서는 함수 전체를 구현하질 않나, 내가 짠 예시 코드를 보고 화면들의 UI 구성을 다 해주지 않나, 나중에 가서는 반복되는 코드를 함수로 모듈화하는것까지 너끈하게 해내는 것이 여간 든든한 게 아니었다.

 C는 다른 의미에서 나를 든든하게 도와줬다. 나보다 한살 많은 형님이셨는데, 사운드출력 기능을 구현해야 하는 일이 생기자 "개발이 끝나기 전까지 사운드 라이브러리 FMOD를 공부해 오겠다."라는 말을 남기고 며칠 뒤 라이브러리 기능을 가져오고 사용법을 설명해 주시는 호방한 형님으로, 실로 뜨거운 술이 식기 전에 안량의 목을 취한 관우의 기상이 있는 분이셨다. 프로그래밍을 갓 시작하신 분 치고는 대단한 독립심과 자주성을 갖고 계셨으며, 목표달성에 필요하면 가리지 않고 공부하고 배우는 자세는 나도 배울점이 많았던 것 같다

 B는 이번 프로젝트에서 큰 성취를 이뤄내지는 못했다. 의기소침해있던 B에게 A와 비슷하게 접근해 가장 작은 단위의 작업을 먼저 맡겨 거기서부터 코딩에 대한 자신감을 심어주고 싶었지만, 내가 B에게 믿음을 주지 못했는지 그는 결국 한 줄의 코드도 짜지 못하고 프로그래밍 작업에서 완전히 소외되고 말았다. 리소스를 찾는 역할, 기획을 보조하는 역할을 주긴 했지만 결국 프로그래밍을 배우러 온 친구에게 잡일만 시킨 꼴이니, 마음 한켠에 항상 미안한 마음이 들었다.

커밋 메시지 목록과 커밋 비율

 어쨌든 팀의 응집력을 최대한 끈끈하게 유지하려 했던 나의 노력이 부족했을 수도 있고 세련되지 못했을 수도 있지만 어떻게든 팀은 굴러갔고, 게임의 윤곽이 서서히 드러나기 시작했다.

게임화면 중 생산시설 구매창

마지막으로 생산시설이나 업그레이드의 가격, 효율을 세심하게 세팅하는 것도 중요했다. 이 수치들을 아무렇게나 설정할 수는 없으니, 빠르게 해당 부분에 대해서만 기획문서를 작성했다.

 수치 조절까지 적절하게 끝내고 나니 상당히 재미있는 게임플레이가 탄생했다. 업그레이드들의 효율개선 수치가 각자 2배~256배까지 달랐기 때문에 업그레이드를 할 때마다 생산시설들의 효율이 극적으로 달라져 굉장히 역동적으로 생산 시설과 업그레이드를 구매하게 만드는 플레이를 유도했던 것 같다.

 

4. 소감

 처음으로 진행한 게임 개발 팀플이었는데, 팀 분위기도 기간 내내 괜찮았고, 결과물도 생각보다 잘 나와서 좋았다. 자동사냥게임 같은 느낌이 있어서 지금도 간간히 하는 게임이다.

 책의 서문은 저자가 미술가 친구의 집을 방문한 이야기로 시작한다. 친구는 자신이 5살 때 그린 그림의 이야기를 하며 저자에게 "내 엄마는 한번도 내가 평소에 뭘 하는지 물어본적이 없어, 내 예술은 물론이고, 뭐, 사실상 모든 면에서 관심이 없으셨지."라는 이야기를 스스럼없이 이야기하는데, 저자는 그런 이야기를 구김없이 하는 친구의 태도에 깊은 인상을 받았다고 한다. 부모가 자녀의 유년기에 그들의 정신세계에 아무런 관심을 가지지 못했다는 것은 대부분의 사람들에게는 평생의 한으로 남을 수 있는 일이다. 유년기때 느낀 허전한 공백감을 성인이 되어서도 마치 영원히 메워지지 않는 싱크홀처럼 여기며, 애꿎은 곳에서 달래려 하는 사람들이 얼마나 많을까? 그런데 이 미술가 양반은 부모님의 관심이 결여되었던 자신의 유년기 환경을 마치 카드게임에서 첫 손패가 좀 꼬인 것 정도의 불행으로 이야기하고 있었다는 것이다.

 불행한 유년기를 겪은 사람들은 대부분 심적으로 괴로운 성인으로 자라나고, 또 이들 중 많은 사람들은 정신치료의 도움을 찾는다. 이들에게 가장 시원하게 느껴질 수 있는 정신치료는 아마 정신분석일 것이다. 왜 A 씨는 평소에 남에게 과시하는걸 좋아하고, 일이 자기 뜻대로 안되면 분노를 주체할 수 없는가? 유년기에 마땅히 받았어야 했던 관심과 대우를 받지 못한 것이 한으로 남아, 그걸 사회적 성공과 인정으로 메우려 했기 때문이다. 왜 B 씨는 만성적인 불안에 시달리는가? 바로 정서적으로 불안정하고 신경질적이었던 부모 밑에서 전전긍긍하며 눈치를 봐야만 했던 유년기를 보냈기 때문이다. 왜 C 씨는 세상이 통 공허한 것 같고, 본인의 내면은 텅 빈 것만 같은 느낌을 받는 걸까? 바로 항상 우울했던 어머니의 기분을 달래기 위해 어릴때부터 자신의 정서적 요구를 누른채로 철이 들어야만 했고, 그 때문에 스스로의 정서를 돌아보고 다스릴 역량이 결여된 채로 어른이 되었기 때문이다. 이같은 이야기들은 실로 명쾌하고 시원하게 느껴지지만, 때때로 이런 분석들은 내담자들을 억울함과 분노의 굴레에서 헤어나올 수 없게 만든다. 저자는 이런 전개를 경계하며, 유년기의 좌절은 필연적일 수 밖에 없는 측면도 있다는 도널드 위니컷이라는 정신분석학자의 통찰을 이야기한다.

 위니컷은 영국의 소아과 의사, 정신분석학자로, 그는 아이들이 정상적으로 발달하기 위해서는, 부모가 의도적으로 자녀를 실망시킬 수 있는 능력이 중요하다고 이야기했다. 아이들은 신생아때부터 전적으로 모든 것을 부모에게 의존해야 하는 관계로부터, 부모없이 자립할 수 있는 상태로까지 나아가야 하는데, 이때 필연적으로 따라올 부모역할의 후퇴과정에서 자녀는 실망감을 느낄 수 밖에 없지 않은가? 위니컷은 이같은 과정에서 생길수밖에 없는 자식의 필연적인 분노와 공격으로부터 살아남을 수 있는 어머니를 "충분히 괜찮은 어머니"(good enough mother)라고 불렀다. 진짜 목숨을 부지한다는 의미로 살아남는다는 것은 아니고, 부모가 자식의 실망감을 분노나 무관심으로 보복하지 않으면서, 또 자식의 분노에 굴복하지도 않으면서 버틸수 있어야 한다는 이야기였던 것 같다. 그리고 그렇게 자식의 공격으로부터 부모가 살아남을 수 있다면, 자식은 부모를 자신의 시다바리(an extension of a child, magically appearing to assuage every need)가 아니라 별도의 한계를 가진 별개의 인간으로 인식하기 시작하며, 그렇게 되고서야 아이는 부모를 비롯한 외부세계에 대해 사려깊은 감정을 발달시킬 수 있다는 말이었다. 물론 부모가 이런 자식의 분노를 적절히 처리하지 못할 경우, 자식은 끔찍한 고통을 맛보게 된다고 한다. (When the child's hatred and aggresive urges are improperly met, the child's rage knows no bounds, and she becomes relegated to a hell-ish existence)

 저자는 정신분석이 자신의 과거를 이해하는데에는 도움이 될 수 있겠으나 분석만으로 트라우마를 극복하려는 것에는 한계가 있을 것이라 지적한다. (Psychoanalysis helps one to make sense of one's history, but at the same time, it is best read as a long elegy for the intelligibility of our lives). 나 또한 개인적으로 정신분석에 많이 의존했으나, 이런 시도는 실질적으로 나의 분한 감정, 억울한 감정을 다스리는 데에는 큰 도움이 되지 못했다. 분석을 하면 할수록 같은 부스럼을 피가 나도록 긁는 느낌이었다. 나의 억울한 감정은 스스로를 달래고 위로하는 것으로 다스려지지 않았다. 그보다는, 비탄에 빠져 꽁해 있는 나의 머리를 갑자기 죽비로 때리는 듯한 불교의 충격적이고도 상남자스러운 현실인식으로부터 더 자유로움을 얻을 수 있었다. 마치 서문에서 등장한 저자의 미술가 친구가 자신의 적막한 유년기 서사 속에 갇히지 않고 쿨하게, "첫 손패가 좀 꼬였지 뭐," 정도의 태도로 과거를 대할 수 있었던 것처럼 말이다.

' > thoughts without a thinker' 카테고리의 다른 글

Thoughts without a thinker  (0) 2023.08.28

 2015년, 우연히 이 책을 읽어보고 첫 장에 매료되어 끝까지 읽었던 기억이 난다. 한국어로 번역된 이 책의 이름은 '붓다의 심리학'이었는데, 어릴적부터 모태신앙으로서 불교행사에 참가하기를 강요받으며 자라 별로 불교에 좋은 기억이 없던 나에게, 이런 제목은 도발로 다가왔다. '공덕이니 전생이니 윤회니 꿈같은 이야기만 하는 너희 컬티스트들이 무슨 심리학을 논한단 말이냐?' 불교재단에서 운영하는 대학을 다니면서, 스님들의 괴팍한 면모를 많이 보고 실망도 자주 한 나였다. 책 제목에서 느껴지는 회의감에도 불구하고 이 책을 꺼내든 이유는 불교에 대한 실망감을 한층 더 굳히고 싶어서였을 것이다. 하지만 이 책은, 일체의 과장을 보태지 않고 말하건데, 내 인생을 바꿔 놓았다. 책을 읽은 후 원문이 궁금해진 나는 원서로 책을 구입해 여러번 읽었고, 그 때마다 머리가 깨지는 듯한 충격과 감동을 느꼈다. 하지만 지금까지도 나는 다른 사람들이 '그래서 무슨 책인데? 어떤 내용인데?' 라고 물으면 제대로 말을 정리해서 대답을 할 수가 없다. 아마 책을 읽고 느낀 심상을 마음속으로만 담아 두고, 제대로 글로 정리한 적이 없기 때문일 것이다. 그래서 여유가 있을 때 다시금 책을 정독하며 이해한 바를 정리하려 한다.

 이전 설계에서 말도 안되는 부분을 발견했다. 3d 애니메이션을 구현하기 위해 게임 엔진에서 애니메이션이 적용된 본들의 TM들을 본의 이름을 키값으로 하는 map에 넣어 그래픽스 엔진에 통째로 전달하면 그래픽스 엔진이 노드들의 상태를 반영해 메시를 그리는 방식으로 설계를 짰었다. 하지만 매 프레임 각각의 메시 인스턴스들에게 map을 만들어서 넘겨주고, 또 그래픽스 엔진에서는 이를 받아 문자열 해싱을 통해 본을 찾아서 적용한다? 이 설계 아래에서 그래픽스 엔진이 최적화를 할 수 있는 여지도 없을 것 같고, 해싱같은 비싼 연산을 매 업데이트마다, 매 메시마다, 매 본마다 수행한다는게 매우 탐탁지 않다.

 

 그래서 애니메이션 인스턴스 하나에 대응되는 인터페이스를 만들고, 오프셋 시간을 매개변수로 전달해 메시에 적용하는 식으로 구조를 바꿨다.

 모든 리소스에 범용 고유 식별자(uuid)를 넣어 리소스들을 관리할 생각이었지만, 일단 리플렉션, 시리얼라이제이션을 구현하기 전까지는 리소스에 대한 키 값은 파일경로로 대체해야 할 것 같다.

그래픽스 엔진 YunuDX11과 게임 엔진, Yunuty 사이를 잇는 Yunu3D 인터페이스의 클래스 다이어그램

  카메라, 메시, 메터리얼, 애니메이션. 대략 이정도만 있다면 기본적인 게임은 돌아갈 것이다. 아직 그래픽스 프로그래밍을 배우고 있는 상태기 때문에, 이 중 더더욱 구현대상을 추려 IMesh정도만 구현해봐야겠다.