no image
게임 오브젝트 갯수 메모리 스트레스 테스트
게임 오브젝트를 100만개 만드니까 프로세스 메모리 4.4기가를 차지한다. 단순하게 생각하면 게임오브젝트들이 하나당 4000바이트를 차지하는 것 같은데, 좀 많다. 게임 오브젝트와 컴포넌트에 STL 떡칠을 해 놓으니 이렇게 덩치가 하염없이 커진 것 같다. 게임엔진을 만들 일이 또 생기면 게임오브젝트들도 여러 분류를 나누어서 경량 게임 오브젝트들은 메모리를 적게 차지할 수 있도록 최적화를 진득하게 고려해봐야겠다.
2024.01.18
no image
게임개발 프로세스에 테스트 자동화 환경을 구축한 이야기
들어가기에 앞서, 이 글은 테스트 자동화 환경을 구축하게 된 사고의 흐름과 소감을 다루나, 환경을 구축하기 위한 세세한 절차들을 모두 명시하지는 않았음을 밝힌다. 1년간 게임 인재원에서 진행한 게임 개발 프로젝트들은 모두 약 2주~4주의 개발기간을 갖는 소규모 프로젝트들이었다. 이런 프로젝트들은 개발 도중 기능에 문제가 생길 때마다 센스와 직관으로 디버깅을 하면서 진행을 해도 큰 탈이 없었다. 하지만 게임인재원 5학기 일정을 시작하면서 1년동안 진행될 장기 프로젝트에서도 이런 식으로 개발을 할 수 있을지 의문이 들었다. 장기 프로젝트일수록 필요한 기능들을 산더미 파불고기처럼 많이 구현하게 될 텐데, 기능 중 하나가 고장이 났을 때 산더미같은 기반 코드 중 정확히 어디에서 문제가 터진 것인지 진단하는 것이..
2024.01.11
no image
PhysX Snippet ContactReport 프로젝트 분석
피직스에서 물리적인 충돌이 일어나거나 트리거 볼륨과 충돌체가 겹치는 일이 일어나면 이벤트를 발생시킬 필요가 있다. PhysX에서 물리 이벤트에 대한 콜백 함수를 어떻게 등록할 수 있는지 PhysX 예제 Snippet ContactReport 프로젝트를 분석해 알아보자. PxSceneDesc sceneDesc(gPhysics->getTolerancesScale()); sceneDesc.cpuDispatcher = gDispatcher; sceneDesc.gravity = PxVec3(0, -9.81f, 0); sceneDesc.filterShader= contactReportFilterShader; sceneDesc.simulationEventCallback = &gContactReportCallback;..
2024.01.05
no image
가디언 슈터 - 2D 런앤건 게임 개발
깃허브 주소 : https://github.com/yunu95/GuardianShooter유튜브 주소 : https://youtu.be/tS9Ps5plkps?si=ffo03FuEch4AwP40   목차 1. 프로젝트의 시작 2. Yunuty 게임엔진2.1. Collider2D2.2. D2DAnimatedSprite2.3. YunutyCycle2.4. D2DCamera 3. 인게임 로직 코드3.1. 플랫포머 시스템3.2. 디버그 출력 4. 게임 에디터 기능4.1. 에디터 버튼 기능4.2. 플랫폼 배치4.3. 장식물 배치4.4. 카메라 레일4.5. 적군 마커4.6. 적군 웨이브 5. 소감 6. 기타 설계문서 1. 프로젝트의 시작 이번에 맡게 된 게임의 기획은 2D 플랫포머 슈팅 게임을 만들자는 것으로, 한 ..
2023.11.05
no image
PhysX 코드 분석 : Hello World
HelloWorld 프로젝트에서는 간단하게 박스로 피라미드를 쌓은 예제를 보여준다. 어떻게 PhysX 환경을 조성하고 강체 시뮬레이션을 돌릴 수 있는지 코드를 분석해 알아보자. void initPhysics(bool interactive) { gFoundation = PxCreateFoundation(PX_PHYSICS_VERSION, gAllocator, gErrorCallback); gPvd = PxCreatePvd(*gFoundation); PxPvdTransport* transport = PxDefaultPvdSocketTransportCreate(PVD_HOST, 5425, 10); gPvd->connect(*transport,PxPvdInstrumentationFlag::eALL); gPhys..
2023.11.03
no image
운동량, 힘, 토크, 일, 에너지, 충격량
요약 1. 탐구배경 2. 운동량과 힘 3. 각운동량과 토크 4. 일과 에너지 5. 충격량 1. 탐구배경 게임개발을 하면서 게임 오브젝트들의 속도와 가속도, 회전속도를 조작해야 할 기회가 있을때마다 나는 힘을 가하거나 토크를 더하는 물리량에 변화를 주는 기능을 활용하기보다는 직접적으로 속도, 가속도, 회전속도를 바꾸는 방식을 선호했다. 기초적인 역학에 대한 이해가 부실한 상태에서 물리량의 변화를 함부로 일으키면 큰일이 날 것 같았기 때문이다. 하지만 이제 자체엔진에 물리엔진 라이브러리를 연동해야 하는 순간이 왔고, 미뤄둔 숙제를 해야 할 때가 되었다. 그전에 먼저 강체의 속도, 가속도, 회전속도에 가장 밀접한 영향을 주는 존재인 힘, 토크, 일, 충격량에 대해 차분하게 정리해보고자 한다. 2. 운동량과 힘..
2023.11.02
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

 

 게임 오브젝트를 100만개 만드니까 프로세스 메모리 4.4기가를 차지한다. 단순하게 생각하면 게임오브젝트들이 하나당 4000바이트를 차지하는 것 같은데, 좀 많다. 게임 오브젝트와 컴포넌트에 STL 떡칠을 해 놓으니 이렇게 덩치가 하염없이 커진 것 같다. 게임엔진을 만들 일이 또 생기면 게임오브젝트들도 여러 분류를 나누어서 경량 게임 오브젝트들은 메모리를 적게 차지할 수 있도록 최적화를 진득하게 고려해봐야겠다.

'개발의 편린' 카테고리의 다른 글

PBR  (0) 2024.04.05
Deferred Rendering에 대한 시도 : 큐브맵 적용  (0) 2024.03.18
Deferred Rendering에 대한 첫번째 시도  (0) 2024.03.15
맵 에디터 개발의 흔적  (0) 2024.01.31

 들어가기에 앞서, 이 글은 테스트 자동화 환경을 구축하게 된 사고의 흐름과 소감을 다루나, 환경을 구축하기 위한 세세한 절차들을 모두 명시하지는 않았음을 밝힌다.

 

 1년간 게임 인재원에서 진행한 게임 개발 프로젝트들은 모두 약 2주~4주의 개발기간을 갖는 소규모 프로젝트들이었다. 이런 프로젝트들은 개발 도중 기능에 문제가 생길 때마다 센스와 직관으로 디버깅을 하면서 진행을 해도 큰 탈이 없었다. 하지만 게임인재원 5학기 일정을 시작하면서 1년동안 진행될 장기 프로젝트에서도 이런 식으로 개발을 할 수 있을지 의문이 들었다. 장기 프로젝트일수록 필요한 기능들을 산더미 파불고기처럼 많이 구현하게 될 텐데, 기능 중 하나가 고장이 났을 때 산더미같은 기반 코드 중 정확히 어디에서 문제가 터진 것인지 진단하는 것이 예삿일이 아닐 것 같았다.

소규모 프로젝트(좌)에서는 하나의 기능에 문제가 생겼을 때 해당 기능이 의존적인 기능들을 전수조사하는 것에 대한 부담이 적었으나, 대규모 프로젝트(우)의 경우 문제가 생긴 부분을 찾아내기 매우 힘들것이라는 예상이 들었다.

 이 문제에 대한 진지한 고민은 학기 초에 빌드머신을 세팅할 때부터 시작했다. 빌드머신용 컴퓨터에 젠킨스 서버를 설치하고 이를 깃허브 리포지터리와 연동해 마스터 브랜치가 업데이트될때마다 자동으로 빌드를 시도하게 만들었지만 이것만으로는 아쉽다는 생각이 들었다. 젠킨스는 프로젝트 빌드가 성공했을 때 해당 빌드를 성공적인 빌드로 마킹하는데, 프로젝트가 그저 컴파일가능하다는 것 하나만으로 프로젝트의 안정성이 보장될 수는 없다고 보았기 때문이다.

빌드머신에 젠킨스를 구축해 깃허브 저장소가 업데이트될때마다 자동빌드를 시킨 모습. 하지만 코드가 빌드가능하다는 것만으로 전체 프로젝트의 안정성을 보장할 수는 없겠다는 생각이 들었다.

 빌드머신의 절차를 어떻게 더 고도화시킬 수 없을까 고민을 하다가 대학을 다닐때 소프트웨어 테스팅에 대한 강의를 들은 기억이 났다. 개발중인 소프트웨어의 복잡성이 증가할수록 뭘 더 개발하는 것이 어려워질테니, 구현된 기능들을 반복적으로 검증할 수 있는 체계를 마련해야 지속가능한 개발을 꾀할 수 있다는 교수님의 말씀이 떠올랐다. 비주얼 스튜디오에서 테스트와 관련된 견본 솔루션을 찾아보니 과연 유닛 테스트들을 작성할 수 있는 솔루션이 있었고, 이를 우리 프로젝트에 적용하기로 했다.

비주얼 스튜디오에서 제공하는 테스트케이스 솔루션(좌)과 팀 프로젝트를 진행하면서 생성한 테스트케이스 목록(우)

 테스트 주도 개발 방식을 적용하면서 하나의 기능을 구현할 때마다 하나의 테스트 코드를 작성하기로 했다. 예를 들어 게임엔진에 물리엔진을 이식한 경우, 나는 중력과 물리 충돌이 잘 작동하는지 확인하기 위해 평면 위에 상자를 떨어뜨린 뒤 몇 초 후 상자의 y축 좌표를 확인하는 코드를 짰다.

가설을 세우고 그 가설에 맞는 유닛테스트를 실행한 모습, 시뮬레이션을 잠깐 실행한 후 Assert문이 실행되며, 내부 조건이 참이라면 프로그램은 정상코드로 종료한다.

 나는 이 테스트케이스들이 누적될수록 프로젝트의 안정성 또한 보장될 것이라 판단했고, 이걸 잘 이용하면 젠킨스의 빌드 검증절차의 부족한 점을 보완할 수 있을 것 같았다. 나는 빌드머신에서 빌드 직후 수록된 모든 테스트들을 돌려 테스트가 하나라도 실패하면 빌드를 실패로 띄우게 만들었다. 이를 통해 마스터 브랜치에 커밋이 들어올 때마다 해당 커밋이 기존에 잘 작동하던 동작들을 망가뜨리지 않는지 확인할 수 있었고, 마스터 브랜치의 안정적인 빌드 히스토리를 추적할 수 있었다.

빌드 후 batch 파일을 실행해 등록된 테스트들을 전부 실행하고, 테스트가 단 하나라도 실패하면 빌드 결과를 실패로 기록하도록 절차를 만들었다.

 테스트 케이스 코드 중 빌드 검증에 동원되지 않는 테스트코드들도 프로젝트에 넣을 수 있게 했다. 검증에 동원되지 않는 테스트라니, 팥없는 붕어빵이 아닌가 하는 생각이 들겠지만 사람의 눈으로 봐야 검증이 가능한 그래픽스 기능 테스트, 혹은 특정 기능을 시연하는 쇼케이스 코드와 같이 그저 아카이빙하고 싶은 실행코드들도 있을 수 있기 때문이다. 이런 테스트코드들은 프로젝트의 조각, 편린이라는 의미로 Snippet 코드라고 부르기로 했다. 빌드머신에서 실행되는 테스트용 batch 파일은 모든 테스트들을 실행하지만, 접두어로 Snippet이 붙은 테스트들은 무시하도록 만들었다.

  Debug Release
EditorUnitTests 디버그 버전 게임 + 툴
단위테스트 빌드
릴리즈 버전 게임 + 툴
단위테스트 빌드
UnitTests 디버그 버전 게임
단위 테스트 빌드
릴리즈 버전 게임
단위 테스트 빌드
GraohucsExe 그래픽스 디버깅 빌드 그래픽스 확인용 빌드
EditorExe 툴 디버깅 빌드 툴 배포 빌드
Exe 게임 디버깅 빌드 게임 출시 빌드

 

 우리 프로젝트의 빌드 설정은 위와 같이 총 10개로 나뉘었다. 빌드머신은 마스터 브랜치가 업데이트 될 때마다 이 중 GraphicsExe 빌드를 제외한 나머지 8개 빌드가 컴파일에 성공하고, EditorUnitTests, UnitTests에 해당하는 4가지 버전에서 실행되는 단위 테스트들을 모두 통과해야 해당 빌드를 안정적인 것으로 간주했다.

 

테스트케이스라고 해서 자동화 테스트의 대상이 되는 코드만 있는 것은 아니다. 사람의 눈으로 봐야 검증이 되는 테스트, 기능의 사용법을 알려주기 위한 데모용 코드, 기능의 정상 동작을 시연하는 쇼케이스 테스트들은 따로 아카이빙 되었다.

프로세스를 개선한 소감

 새로운 작업방식에 대한 팀원들의 반응은 모두 호평 일색이었다. 다들 '내가 커밋을 하면 갑자기 멀쩡한 프로젝트가 절단나진 않을까?' 하는 불안을 품고 있었는데 '테스트들이 모두 검증을 통과하면 안정적인 빌드로 간주한다.'는 원칙이 생기니까 팀원들이 마스터 브랜치에 커밋을 넣는 부담이 줄었다. 로컬 PC에서 자체적으로 테스트를 진행하고 커밋을 해도 되고, 그런 절차 없이 바로 커밋하더라도 빌드머신에 로그가 남아 어떤 커밋이 어떤 테스트를 실패하게 만들었는지 바로 추적할 수 있기 때문에 잘못된 커밋에 대한 부담이 덜했다.

 테스트용으로 한번 쓰고 버리던 코드들을 Snippet 코드로 따로 뺄 수 있는 것도 반응이 매우 좋았다. 과거의 테스트 환경을 그대로 남기고 언제든 실행할 수 있게 만든다는 것은 생각보다 큰 의미가 있었다. 스니펫 코드들은 기능의 사용법을 설명하는 메뉴얼 역할을 했고, 구현된 기능을 시연하는 보고자 역할을 했다. 이전에 마구잡이로 개발할 때는 임시로 개발해 놓은 테스트용 코드들을 다음 테스트를 위해 지울때마다 뭔가 아깝다는 생각이 들었는데, 이제는 그럴 때마다 스니펫 코드로 저장한다. 이런 코드들은 묵혀두면 언젠가는 반드시 유용하게 쓰일 일이 생기더라.

 내가 처음 빌드머신을 세팅하려고 한 동기는 지속적 통합 / 지속적 배포( Continus Integration / Continuous Delivery )가 개발 프로세스에 미치는 영향을 온몸으로 체감하고 싶었던 이유가 컸다. 빌드머신을 세팅하고 나니 이 중 CD는 몰라도 CI, 지속적 통합이 왜 중요한지는 뼈저리게 느끼게 되었다. 작업물의 지속적 통합에 대한 대책을 마련하지 않은 타 팀의 경우 '지금 내가 브랜치를 합쳐도 되느냐?', '내용을 커밋했을 때 문제가 생길까봐 겁난다.' 등 서로의 작업물들을 통합하는 것에 대해 상당한 부담감을 호소하는 경우가 잦았다. 부담감이 큰 만큼 서로의 작업이 통합되는 빈도가 잦지 않았고, 어쩌다 타인의 작업물을 합친다 한들 구현된 기능에 대한 품질검증이 이루어지지 않기 때문에 불안한 상태에서 작업을 계속해야 했다. 잘 동작하던 기능에 갑자기 문제가 발견될 경우 이 문제가 어느 커밋으로부터 비롯된 건지 특정하는 것도 힘들었다. 이는 팀원간 불신이 쌓이기 쉬운 환경이었고, 이런 환경 위에서 효율적인 협업을 기대하기는 힘들 것이었다. 지속적 통합의 가치는 지속적 통합이 부재한 경우 생기는 협업의 한계에서 명백히 드러났다.

 

아쉬웠던 점

 만약 게임 엔진에서 그래픽스 출력 기능을 끄고 테스트할 수 있었다면 좋았을 것이다. 젠킨스 서버를 돌리는 프로세스에는 그래픽스 출력 장치에 대한 접근 권한이 없어 이 문제를 우회하는 편법을 써야 했고, 또 필요 없는 기능에 컴퓨터 자원이 소모되는 것이 아쉬웠다.

 또 원래 내가 생각했던 프로세스는 마스터 브랜치에 푸시가 들어올 때마다 빌드를 시도하는 게 아니라 풀 리퀘스트가 올라올 때마다 빌드머신에서 풀 리퀘스트를 반영해 테스트를 진행하고 문제가 없으면 마스터 브랜치와 병합하는 것이었다. 이렇게 절차를 만들었다면 마스터 브랜치에는 항상 안정적인 버전만 남길 수 있었을 텐데 아쉽다.

원래 의도했던 프로세스, 만약 이대로 동작했다면 마스터 브랜치에는 항상 안정적인 버전만 남길 수 있었을 것이다.

 다음은 내가 테스트 자동화 환경을 구축하기 위해 사용한 기술들이다.

 

- Jenkins : 빌드머신 구축하는데 필요

- Ngrok : 빌드머신의 IP주소와 포트번호를 도메인 네임 서버에 등록해야 github webhook을 연동할 수 있기 때문에 필요

- psexec  : 빌드머신은 System 계정으로 구동되는데, System 계정은 그래픽 출력 장치에 대한 권한이 없음. User계정에게 원격으로 CMD 명령어를 실행시켜 테스트를 진행시키고 싶을 때 필요

- Batch 파일 작성 : 일괄 테스트를 실행하면 한 프로세스에서 테스트코드들이 순차적으로 실행됨, 한 프로세스당 한 테스트코드를 실행시키면서 일괄테스트를 시키고 싶을 때, 어떤 테스트코드들은 빌드 검증에 관여하지 않게 만들고 싶을 때, 아무튼 자기 입맛에 맞게 임의로 테스트를 진행하고 싶다면 batch 파일 작성법을 배워야 함.

'자체엔진 Yunuty > 개발일지' 카테고리의 다른 글

그래픽스 엔진 설계변경  (0) 2023.06.21
그래픽스 엔진 인터페이스 구상  (0) 2023.06.19
개발일지 - 견적 내기  (0) 2023.06.16

 피직스에서 물리적인 충돌이 일어나거나 트리거 볼륨과 충돌체가 겹치는 일이 일어나면 이벤트를 발생시킬 필요가 있다. PhysX에서 물리 이벤트에 대한 콜백 함수를 어떻게 등록할 수 있는지 PhysX 예제 Snippet ContactReport 프로젝트를 분석해 알아보자.

ContactReport 프로젝트에서는 접촉이 일어날때마다 빨간색 선으로 충돌지점과 충격의 방향을 표시해주고 디버그 로그를 찍는다.

	PxSceneDesc sceneDesc(gPhysics->getTolerancesScale());
	sceneDesc.cpuDispatcher = gDispatcher;
	sceneDesc.gravity = PxVec3(0, -9.81f, 0);
	sceneDesc.filterShader	= contactReportFilterShader;			
	sceneDesc.simulationEventCallback = &gContactReportCallback;	
	gScene = gPhysics->createScene(sceneDesc);

 

 먼저 PhysX 씬을 생성하는 부분이다. sceneDesc에 filterShader와 simulationEventCallback를 제대로 설정해야 씬에서 생기는 충돌 이벤트를 잘 처리할 수 있다. simulationEventCallback은 당연히 물리 시뮬레이션 진행 도중 이벤트가 생길 때마다 호출되는 콜백객체고, filterShader는 타입이 PxSimulationFilterShader로 명명된 함수 포인터다. 이 함수 포인터 타입에 대한 설명은 다음과 같다.

/**
\brief Filter method to specify how a pair of potentially colliding objects should be processed.

Collision filtering is a mechanism to specify how a pair of potentially colliding objects should be processed by the
simulation. A pair of objects is potentially colliding if the bounding volumes of the two objects overlap.
In short, a collision filter decides whether a collision pair should get processed, temporarily ignored or discarded.
If a collision pair should get processed, the filter can additionally specify how it should get processed, for instance,
whether contacts should get resolved, which callbacks should get invoked or which reports should be sent etc.
The function returns the PxFilterFlag flags and sets the PxPairFlag flags to define what the simulation should do with the given collision pair.

\note A default implementation of a filter shader is provided in the PhysX extensions library, see #PxDefaultSimulationFilterShader.

This methods gets called when:
\li The bounding volumes of two objects start to overlap.
\li The bounding volumes of two objects overlap and the filter data or filter attributes of one of the objects changed
\li A re-filtering was forced through resetFiltering() (see #PxScene::resetFiltering())
\li Filtering is requested in scene queries

\note Certain pairs of objects are always ignored and this method does not get called. This is the case for the
following pairs:

\li Pair of static rigid actors
\li A static rigid actor and a kinematic actor (unless one is a trigger or if explicitly enabled through PxPairFilteringMode::eKEEP)
\li Two kinematic actors (unless one is a trigger or if explicitly enabled through PxPairFilteringMode::eKEEP)
\li Two jointed rigid bodies and the joint was defined to disable collision
\li Two articulation links if connected through an articulation joint

\note This is a performance critical method and should be stateless. You should neither access external objects 
from within this method nor should you call external methods that are not inlined. If you need a more complex
logic to filter a collision pair then use the filter callback mechanism for this pair (see #PxSimulationFilterCallback,
#PxFilterFlag::eCALLBACK, #PxFilterFlag::eNOTIFY).

\param[in] attributes0 The filter attribute of the first object
\param[in] filterData0 The custom filter data of the first object
\param[in] attributes1 The filter attribute of the second object
\param[in] filterData1 The custom filter data of the second object
\param[out] pairFlags Flags giving additional information on how an accepted pair should get processed
\param[in] constantBlock The constant global filter data (see #PxSceneDesc.filterShaderData)
\param[in] constantBlockSize Size of the global filter data (see #PxSceneDesc.filterShaderDataSize)
\return Filter flags defining whether the pair should be discarded, temporarily ignored, processed and whether the
filter callback should get invoked for this pair.

@see PxSimulationFilterCallback PxFilterData PxFilterObjectAttributes PxFilterFlag PxFilterFlags PxPairFlag PxPairFlags PxSceneDesc.filterShader
*/
typedef PxFilterFlags (*PxSimulationFilterShader)
	(PxFilterObjectAttributes attributes0, PxFilterData filterData0, 
	 PxFilterObjectAttributes attributes1, PxFilterData filterData1,
	 PxPairFlags& pairFlags, const void* constantBlock, PxU32 constantBlockSize);

 

 

 PhysX의 충돌 객체들은 서로의 바운딩 볼륨의 영역이 서로 겹치고 난 다음에야 더 정밀한 충돌체크를 하든, 트리거 이벤트를 발생시키든 추가적인 작업을 한다. PxSimulationFilterShader는 두 충돌체의 바운딩 볼륨이 겹쳤을 때 어떤 작업을 해야 하는지, 콜백 함수를 불러야 한다면 어떤 함수를 불러야 하는지를 정의한다. 충돌체들이 서로 어떤 관계를 가져야 하는지 정의하는 함수가 바로 FilterShader라고 할 수 있겠다.

 이 함수는 PxFilterFlag를 반환하며, PxPairFlags의 값들을 수정하는 것으로 물리 시뮬레이션이 충돌체 쌍을 어떻게 처리해야하는지에 대한 정보를 저장한다.

 이 함수는 두 충돌체의 바운딩 볼륨이 겹치기 시작할때, 혹은 바운딩 볼륨이 겹쳐진 상태에서 한 오브젝트의 속성값이 바뀌었을때 호출된다.

 이 함수는 여러 스레드에 의해 수없이 많이 불리기 때문에 퍼포먼스에 심각한 영향을 줄 수 있다. 정말 제대로 물리 이벤트가 터졌을 때 호출되는 함수가 아니라 충돌체들이 어느정도 가까워지기만 하면 호출되기 때문이다. 함수가 어떤 상태에 따라 동작이 달라져서는 아니될 것이며, 심지어는 함수 호출조차 인라인 함수만 사용해야 한다.

 

 함수의 각 파라미터들에 대한 정보는 다음과 같다.

attribute0 [in] : 첫번째 오브젝트의 필터 속성

filterData0 [in] : 첫번째 오브젝트의 사용자 정의 필터 데이터

attribute1 [in] : 두번째 오브젝트의 필터 속성

filterData1 [in] : 두번째 오브젝트의 사용자 정의 필터 데이터

pairFlags [out] : 충돌체 쌍이 어떻게 처리되어야 할지 추가 정보를 저장할 플래그 정보

constantBlock [in] : 전역 필터 데이터, 물리 시뮬레이션을 시작하기 전에 전역으로 임의의 필터링 플래그를 세워두고 이 전역 플래그에 따라 필터링 동작을 다르게 하고 싶을 때 쓸 수 있다.

constantBlockSize [in] : 전역 필터 데이터의 사이즈

 

이중 attribute와 filterData는 PxShape로부터 데이터를 추출한다.

 

 FilterShader 함수를 부를 가치조차 없는 충돌체 쌍들도 존재한다. 다음과 같은 경우들은 바운딩 볼륨이 겹치든 말든 어차피 뭔가를 처리할 필요가 없기 때문에 무시된다.

- 위치와 회전이 항상 고정된 정적 충돌체들로만 이루어진 쌍(Pair of static rigid actors)

- 트랜스폼 상태를 바꿀 수는 있지만 물리법칙에 상태가 영향받지는 않는 키네마틱 충돌체로 이루어진 쌍 (Two Kinematic actors)

- 키네마틱 충돌체와 정적 충돌체로 이루어진 쌍 (A static rigid actor and a kinematic actor)

- etc...

 

static PxFilterFlags contactReportFilterShader(	PxFilterObjectAttributes attributes0, PxFilterData filterData0, 
												PxFilterObjectAttributes attributes1, PxFilterData filterData1,
												PxPairFlags& pairFlags, const void* constantBlock, PxU32 constantBlockSize)
{
	PX_UNUSED(attributes0);
	PX_UNUSED(attributes1);
	PX_UNUSED(filterData0);
	PX_UNUSED(filterData1);
	PX_UNUSED(constantBlockSize);
	PX_UNUSED(constantBlock);

	// all initial and persisting reports for everything, with per-point data
	pairFlags = PxPairFlag::eSOLVE_CONTACT | PxPairFlag::eDETECT_DISCRETE_CONTACT
			  |	PxPairFlag::eNOTIFY_TOUCH_FOUND 
			  | PxPairFlag::eNOTIFY_TOUCH_PERSISTS
			  | PxPairFlag::eNOTIFY_CONTACT_POINTS;
	return PxFilterFlag::eDEFAULT;
}

 

 SnippetContactReport 프로젝트의 filterShader는 충돌체 객체들의 속성이나 필터 데이터에 상관없이 무조건 고정된 플래그들을 반환하게 만들어놨다. 이 플래그들은 각각 다음과 같은 의미를 가진다.

 

 PxPairFlag::eSOLVE_CONTACT : 충돌이 일어났을때 충돌체들이 서로 파고들지 않게 dynamics solver로 충돌체들의 트랜스폼 값들을 수정하게 한다.

 PxPairFlag::eDETECT_DISCRETE_CONTACT : 충돌체들의 충돌여부를 체크하는 방식으로 discrete_contact를 사용한다. discrete는 연속적으로 충돌여부를 체크하는 CCD(Continuous Collision Detection) 방식과 대비되는 고효율 저정밀 충돌체크 방식이다.
PxPairFlag::eNOTIFY_TOUCH_FOUND : 충돌체간에 접촉을 시작할때 접촉 콜백 함수를 호출한다.
PxPairFlag::eNOTIFY_TOUCH_PERSISTS : 충돌체가 다른 충돌체와 접촉을 유지하고 있을 때 콜백 함수를 호출한다.
PxPairFlag::eNOTIFY_CONTACT_POINTS : 충돌체가 다른 충돌체와 접촉을 그만두었을 때 콜백 함수를 호출한다.

 

/**
\brief An interface class that the user can implement in order to receive simulation events.

With the exception of onAdvance(), the events get sent during the call to either #PxScene::fetchResults() or 
#PxScene::flushSimulation() with sendPendingReports=true. onAdvance() gets called while the simulation
is running (that is between PxScene::simulate() or PxScene::advance() and PxScene::fetchResults()).

\note SDK state should not be modified from within the callbacks. In particular objects should not
be created or destroyed. If state modification is needed then the changes should be stored to a buffer
and performed after the simulation step.

<b>Threading:</b> With the exception of onAdvance(), it is not necessary to make these callbacks thread safe as 
they will only be called in the context of the user thread.

@see PxScene.setSimulationEventCallback() PxScene.getSimulationEventCallback()
*/
class PxSimulationEventCallback
{
	...

 

 sceneDesc의 simulationEventCallback은 라이브러리의 사용자가 상속받아 구현할 수 있는 인터페이스다. PhysX의 시뮬레이션 연산은 Cpu에서 멀티스레딩으로 동작하거나 GPU 상에서 동작하는데, PxSimulationEventCallback의 함수들은 한 틱의 물리 시뮬레이션을 다 끝내고 사용자가 직접 메인 스레드에서 PxScene::fetchResults() 함수를 호출할 때 실행되기 때문에 스레드 안전성을 고민할 필요까지는 없다.

 

'자체엔진 Yunuty > PhysX' 카테고리의 다른 글

PhysX 코드 분석 : Hello World  (0) 2023.11.03

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

유튜브 주소 : https://youtu.be/tS9Ps5plkps?si=ffo03FuEch4AwP40 

 

 목차

 

1. 프로젝트의 시작

 

2. Yunuty 게임엔진

2.1. Collider2D

2.2. D2DAnimatedSprite

2.3. YunutyCycle

2.4. D2DCamera

 

3. 인게임 로직 코드

3.1. 플랫포머 시스템

3.2. 디버그 출력

 

4. 게임 에디터 기능

4.1. 에디터 버튼 기능

4.2. 플랫폼 배치

4.3. 장식물 배치

4.4. 카메라 레일

4.5. 적군 마커

4.6. 적군 웨이브

 

5. 소감

 

6. 기타 설계문서


 

1. 프로젝트의 시작

 이번에 맡게 된 게임의 기획은 2D 플랫포머 슈팅 게임을 만들자는 것으로, 한 마디로 메탈슬러그와 유사한 게임을 만들자는 것이었다. 자체엔진의 안정성을 시험하고 싶었던 나에게 있어서는 좋은 기회였다. 주어진 개발 기간은 3주일이었다.

팀 구성  
프로그래밍 팀장
프로그래밍 팀원 (레벨 에디터, 전체 UI) A
프로그래밍 팀원 (인게임 로직) B
프로그래밍 팀원 (인게임 로직) C
아트 팀장( 캐릭터 ) D
아트 팀원( 배경 ) E
기획 팀장 F
기획 팀원 G

외국어 이름은 모두 가명임.

 

 A는 대학에서 기계공학과와 시각디자인학과를 복수 전공한 분으로, 동료 학생 중 가장 큰 맏형님이셨다. 코딩을 배운지 얼마되지 않으셨지만, 의지와 재능이 매우 출중하신데다 솔직하면서도 인간적인 정이 많아 여러모로 의지가 되는 분이어서 언젠가 현업에서 꼭 다시 뵈었으면 하는 마음이 있었다.

 B는 해야 하는 과제가 있으면 천천히, 그리고 묵묵히 해치우는 타입이었다. 가끔씩 작업에 대한 큰 줄기만 짚어주면 일일히 디테일하게 지시를 하지 않아도 되어 좋았다.

 C는 음악을 좋아하는 유쾌하고 싹싹한 성격의 건실청년이었다. 생각을 코드로 옮기는 것은 많이 미숙한 친구였지만, 이 친구와 이야기를 할 때마다 날카로운 포인트를 잘 짚어낼 줄 안다는 느낌을 종종 받았다.

 

2. Yunuty 게임엔진

 

 게임인재원에서 2학기를 보내면서 여유시간이 날 때마다 종갓집 김치를 개발할 때 썼던 자체 게임엔진의 기능을 개선하고 개발해왔었다. 2학기 프로젝트를 시작하기 전 구현한 게임엔진의 기능으로는 OOBB 기반 2차원 박스 충돌체, 스프라이트 애니메이션, 카메라, A*알고리즘 기반 길찾기 등이 있었다.

카메라, 스프라이트 애니메이션, 길찾기 기능을 모두 결합해 자체엔진으로 간단한 RTS 예제를 만들어본 모습. 아쉽게도 길찾기 기능은 이번 프로젝트에서 쓰이지 않았다.

2.1. Collider2D

2D 충돌체

 

 제대로 된 2D게임을 만들기 위해서는 어느정도 최적화가 고려된 2D 충돌처리 모듈들을 개발할 필요가 있었다. 따라서 OOBB(Object Oriented Bounding Box) 사각형 콜라이더와 원형 콜라이더들을 만들고 쿼드트리를 만들어 같은 구역에 있는 충돌체들만 서로 겹침 여부를 체크하게 만들었다.

더보기

 

#pragma once
#include <Windows.h>
#include <vector>
#include <unordered_set>
#include "Component.h"
#include "Vector2.h"
#include "Vector3.h"
#include "Rect.h"
#include "D2DGraphic.h"
#include "Interval.h"

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


using namespace std;
namespace YunutyEngine
{
    class BoxCollider2D;
    class CircleCollider2D;
    class LineCollider2D;
    class RigidBody2D;
    class YUNUTY_API Collider2D abstract : public Component
    {
    public:
        struct YUNUTY_API QuadTreeNode
        {
            static unique_ptr<QuadTreeNode> rootNode;
            double GetArea() { return xInterval.GetLength() * yInterval.GetLength(); }
            Interval xInterval;
            Interval yInterval;
            unique_ptr<QuadTreeNode> leftTop;
            unique_ptr<QuadTreeNode> rightTop;
            unique_ptr<QuadTreeNode> leftBottom;
            unique_ptr<QuadTreeNode> rightBottom;
            vector<Collider2D*> colliders;
        };
        const unordered_set<Collider2D*>& GetOverlappedColliders() const;
        virtual double GetArea() const = 0;
        virtual bool isOverlappingWith(const Collider2D* other) const = 0;
        virtual bool isOverlappingWith(const BoxCollider2D* other) const = 0;
        virtual bool isOverlappingWith(const CircleCollider2D* other) const = 0;
        virtual bool isOverlappingWith(const LineCollider2D* other) const = 0;
    private:
        // called by yunuty cycle
        static void InvokeCollisionEvents();
    protected:
        static unordered_set<Collider2D*> colliders2D;
        unordered_set<Collider2D*> overlappedColliders;

        Collider2D();
        virtual ~Collider2D();

        virtual bool isInsideNode(const QuadTreeNode* node)const = 0;
        static bool isOverlapping(const BoxCollider2D* a, const BoxCollider2D* b);
        static bool isOverlapping(const BoxCollider2D* a, const CircleCollider2D* b);
        static bool isOverlapping(const CircleCollider2D* b, const BoxCollider2D* a) { return isOverlapping(a, b); }
        static bool isOverlapping(const BoxCollider2D* a, const LineCollider2D* b);
        static bool isOverlapping(const LineCollider2D* b, const BoxCollider2D* a) { return isOverlapping(a,b); }
        static bool isOverlapping(const CircleCollider2D* a, const CircleCollider2D* b);
        static bool isOverlapping(const CircleCollider2D* a, const LineCollider2D* b);
        static bool isOverlapping(const LineCollider2D* b, const CircleCollider2D* a) { return isOverlapping(a,b); }
        static bool isOverlapping(const LineCollider2D* a, const LineCollider2D* b);

        friend YunutyCycle;
    };
}

 

 Collider2D는 모든 유형의 2차원 충돌체 인스턴스들의 기본 클래스다. Collider2D::InvokeCollisionEvents() 함수는 YunutyCycle 객체로부터 호출되는 스태틱 함수로 게임에 존재하는 모든 쿼드트리 노드들을 순회하며 같은 노드에 속한 충돌체들이 서로서로 겹치는지 확인하고, 콜라이더들 간에 충돌이 일어나거나, 충돌이 끝나는 등 특기할만한 이벤트가 생기면 그에 맞는 콜백 함수들을 호출하게 한다.

namespace YunutyEngine
{
        class YUNUTY_API Component : public Object
        {
		.....
        // 충돌체들이 서로 충돌을 시작할 때 호출되는 콜백 함수입니다.
        virtual void OnCollisionEnter(const Collision& collision) {};
        virtual void OnCollisionEnter2D(const Collision2D& collision) {};
        // 충돌체들이 서로 겹쳐진 상태로 있을때 매 프레임마다 호출되는 콜백 함수입니다.
        virtual void OnCollisionStay(const Collision& collision) {};
        virtual void OnCollisionStay2D(const Collision2D& collision) {};
        // 충돌체들이 충돌을 마치고 서로 떨어져 나갈때 호출되는 콜백 함수입니다.
        virtual void OnCollisionExit(const Collision& collision) {};
        virtual void OnCollisionExit2D(const Collision2D& collision) {};
        ....
        }
}

 

 이벤트가 생기면 콜라이더를 포함하는 게임오브젝트의 모든 컴포넌트들의 콜백함수가 호출되며, 호출될 수 있는 콜백함수들의 목록은 위 코드에서 확인할 수 있다.

 

2.2. D2DAnimatedSprite

좌 : 교전을 끝내고 이동하는 병사의 애니메이션, 우 : 이번 프로젝트 플레이어 캐릭터의 Idle 애니메이션.

 

 폴더에 프레임별로 이미지를 넣고 폴더의 경로를 제공하면 2D 스프라이트 애니메이션을 재생할 수 있게 하는 애니메이션 재생 컴포넌트를 만들었다. 

더보기
#pragma once
#include "D2DGraphic.h"
#include "YunutyEngine.h"

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

using namespace YunutyEngine::D2D;

namespace YunutyEngine
{
    namespace D2D
    {
        typedef vector<pair<double, wstring>> SpriteAnim;
        class YUNUTY_API D2DAnimatedSprite : public D2DGraphic
        {
        public:
            void SetIsRepeating(bool repeating) { this->isRepeating = repeating; }
            bool GetIsRepeating() { return this->isRepeating; }
            void LoadAnimationFromFile(wstring folderName, double interval = 0.0);
            void Play();
            void SetWidth(double width);
            void SetHeight(double width);
            double GetWidth() { return width; }
            double GetHeight() { return height; }
        protected:
            virtual void Update() override;
            virtual void Render(D2D1::Matrix3x2F transform) override;
            static const SpriteAnim* LoadAnimation(wstring folderName, double interval = 0.0);
            void SetAnimation(const SpriteAnim* animation);
            const SpriteAnim* GetAnimSprites() const;
        private:
            wstring loadedAnimFilePath;
            bool isRepeating = true;
            const SpriteAnim* animSprites = nullptr;
            int index = 0;
            double elapsed = 0;
            double width = 100;
            double height = 100;
            static unordered_map<wstring, vector<pair<double, wstring>>> cachedAnims;
        };
    }
}

 

 D2D AnimatedSprite는 이미지 파일 경로 하나가 아니라 폴더 경로를 매개변수로 받아 폴더 내부의 이미지들을 스프라이트 애니메이션의 매 프레임 이미지들로 등록한다. Play 함수를 호출하면 게임 오브젝트의 위치에 이미지들을 촤라라락 넘기며 스프라이트 애니메이션을 재생한다.

실상 동작은 공책 애니메이션과 다를 바 없다.
void D2DAnimatedSprite::Update()
{
    if (!animSprites)
        return;

    if (animSprites->empty())
        return;

    if (index >= animSprites->size())
        return;

    elapsed += Time::GetDeltaTime();
    if ((*animSprites)[index].first < elapsed)
    {
        index++;
        if (index >= animSprites->size())
        {
            if (isRepeating)
            {
                index = 0;
                elapsed = 0;
            }
            else
            {
                index--;
            }
        }
    }
}

 

 업데이트 함수에서는 게임엔진의 델타 타임을 누적시키며 시간이 충분히 찼다 싶으면 다음 스프라이트로 그림을 전환한다.

const SpriteAnim* D2DAnimatedSprite::LoadAnimation(wstring folderName, double interval)
{
    HANDLE dir;
    WIN32_FIND_DATA file_data;
    if (cachedAnims.find(folderName) != cachedAnims.end())
        return &cachedAnims[folderName];

    cachedAnims[folderName] = SpriteAnim();
    if ((dir = FindFirstFile((folderName + L"/*").c_str(), &file_data)) == INVALID_HANDLE_VALUE)
        return nullptr; /* No files found */

    double time = 0;
    bool intervalFixed = interval > 0;
    do {
        const wstring file_name = file_data.cFileName;
        const wstring full_file_name = folderName + L"/" + file_name;
        if (file_name == L"." || file_name == L"..")
            continue;

        if (!intervalFixed)
        {
            wsmatch match;
            smatch match2;
            interval = 0;
            regex_match(file_name, match, wregex(L".*?(\\d+)ms.*"));
            //string a(file_name.begin(), file_name.end());
            //regex_match(a, match2, regex("\\D(\\d+)ms.*"));
            if (match.size() >= 2)
                interval = 0.001 * stoi(match[1]);
            if (interval <= 0)
                interval = 0.1;
        }
        const bool is_directory = (file_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;

        if (file_name[0] == '.')
            continue;

        if (is_directory)
            continue;

        cachedAnims[folderName].push_back(make_pair(time, full_file_name));
        time += interval;
    } while (FindNextFile(dir, &file_data));

    FindClose(dir);
    return &cachedAnims[folderName];
}

 

 LoadAnimation은 폴더명을 매개변수로 받아 애니메이션 정보를 가져와 스프라이트 애니메이션 객체에 적용시킨다. 이미 불러온 정보라면 캐싱된 데이터를 쓴다.

 

2.3. YunutyCycle

#pragma once
#include <thread>
#include "Object.h"
#include <functional>

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

using namespace std;

namespace YunutyEngine
{
    class Component;
    class YUNUTY_API YunutyCycle : Object
    {
    private:
        thread updateThread;
        bool isGameRunning = false;
        void ActiveComponentsDo(function<void(Component*)> todo);
        void ActiveComponentsDo(void (Component::* method)());
        vector<Component*> GetActiveComponents();
        vector<GameObject*> GetGameObjects(bool onlyActive = true);
        static void UpdateComponent(Component* component);
        static void StartComponent(Component* component);
        void ThreadFunction();
    protected:
        static YunutyCycle* _instance;
        YunutyCycle();
        virtual ~YunutyCycle(); 
        virtual void ThreadUpdate();
    public:
        static YunutyCycle& GetInstance();
        virtual void Initialize();
        virtual void Release();
        void Play();
        void Stop();
        void Pause();
        void SetMaxFrameRate();
        bool IsGameRunning();
    };
}

( YunutyCycle 클래스의 헤더파일 )

void YunutyEngine::YunutyCycle::ThreadUpdate() 
{
	Time::Update();

	for (auto i = GlobalComponent::globalComponents.begin(); i != GlobalComponent::globalComponents.end(); i++)
		(*i)->Update();
	//i->second->Update();

	for (auto each : Scene::getCurrentScene()->destroyList)
	{
		for (auto each : each->GetComponents())
			each->OnDestroy();
		each->parent->MoveChild(each);
	}

	Scene::getCurrentScene()->destroyList.clear();

	for (auto each : GetGameObjects(false))
		each->SetCacheDirty();
	//ActiveComponentsDo(&Component::Update);
	for (auto each : GetActiveComponents())
		UpdateComponent(each);

	Collider2D::InvokeCollisionEvents();

	if (Camera::mainCamera)
		Camera::mainCamera->Render();
}

( YunutyCycle 클래스의 스레드가 한 틱마다 돌리는 함수, ThreadUpdate )

 

 모든 게임엔진에는 매 프레임마다 게임의 진행상황을 갱신하기 위해 실행시키는 게임 루프가 있다. Yunuty 엔진에서는 YunutyCycle이라는 싱글톤 객체에 게임 루프를 정의했다.

기본적인 YunutyCycle의 업데이트 한 틱 동작의 도식도

더보기
void YunutyEngine::D2D::D2DCycle::ThreadUpdate()
{
    YunutyCycle::ThreadUpdate();
    if (IsGameRunning())
        RedrawWindow(hWnd, NULL, NULL, RDW_INVALIDATE | RDW_UPDATENOW);
}

 

 게임루프는 엔진이 동작하는 기반환경에 따라 어떻게 내용이 바뀔지 모른다고 생각했기 때문에 YunutyCyle 클래스는 상속을 고려해 만들었다. 이번 프로젝트에서는 DirectX 2D 그래픽스 인터페이스를 사용해서 만들었기 때문에 D2DCycle이라는 YunutyCycle의 파생 클래스에서 게임 루프를 정의했다. D2DCycle은 YunutyCycle과 동일하게 틱 동작을 진행한 후 RedrawWindow라는 윈도우API 함수를 불러 화면의 렌더링을 유도한다.

 

2.4. D2DCamera

카메라 이동

 

 D2D카메라는 모든 DirectX 2D 그래픽스 객체들의 트랜스폼 정보를 카메라 중심의 뷰 공간으로 변환한 다음 화면에 그림을 그리는 역할을 수행했다.

더보기
void D2DCamera::Render()
{
}
LRESULT CALLBACK D2DCamera::Render(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    YunuD2D::YunuD2DGraphicCore::GetInstance()->BeginDraw();

    YunuD2D::YunuD2DGraphicCore::GetInstance()->ResizeResolution(resolutionW, resolutionH);
    SetNearPlane(Rect(resolutionW, resolutionH));

    auto halvedRenderSize = YunuD2D::YunuD2DGraphicCore::GetInstance()->GetRenderSize();
    halvedRenderSize.width /= 2;
    halvedRenderSize.height /= 2;

    vector<D2DGraphic*> graphics;

    // render world space graphics
    for (auto each : D2DGraphic::D2DGraphics[(int)CanvasRenderSpace::WorldSpace])
    {
        if (each->GetGameObject()->GetActive() && each->GetGameObject()->GetScene() == GetGameObject()->GetScene())
            graphics.push_back(each);
    }

#if _DEBUG
    GameObject::messyIndexingCalled = 0;
#endif
    sort(graphics.begin(), graphics.end(), [](const D2DGraphic* item1, const D2DGraphic* item2)->bool
        {
            return item1->GetGameObject()->GetSceneIndex() < item2->GetGameObject()->GetSceneIndex();
        });

    D2D1::Matrix3x2F eachTransform;
    Vector3d camPos;
    Vector3d pos;
    Vector3d scale;
    for (auto each : graphics)
    {
        eachTransform = D2D1::Matrix3x2F::Identity();
        camPos = GetTransform()->GetWorldPosition();
        pos = each->GetTransform()->GetWorldPosition();
        scale = each->GetTransform()->GetWorldScale();
        eachTransform = eachTransform * ScaleTransform(scale.x * 1 / zoomOutFactor, scale.y * 1 / zoomOutFactor);
        eachTransform = eachTransform * RotationTransform(each->GetTransform()->GetWorldRotation().Euler().z);
        eachTransform = eachTransform * TranslationTransform(halvedRenderSize.width + (pos.x - camPos.x) * 1 / zoomOutFactor, halvedRenderSize.height - (pos.y - camPos.y) * 1 / zoomOutFactor);

        each->Render(eachTransform);
    }
    graphics.clear();

    // render camera space graphics
    for (auto each : D2DGraphic::D2DGraphics[(int)CanvasRenderSpace::CameraSpace])
    {
        if (each->GetGameObject()->GetActive() && each->GetGameObject()->GetScene() == GetGameObject()->GetScene())
            graphics.push_back(each);
    }
    sort(graphics.begin(), graphics.end(), [](D2DGraphic*& item1, D2DGraphic*& item2)->bool
        {
            return item1->GetGameObject()->GetSceneIndex() < item2->GetGameObject()->GetSceneIndex();
        });

    for (auto each : graphics)
    {
        eachTransform = D2D1::Matrix3x2F::Identity();
        camPos = Vector2d::zero;
        pos = each->GetTransform()->GetWorldPosition();
        scale = each->GetTransform()->GetWorldScale();
        eachTransform = eachTransform * ScaleTransform(float(scale.x / zoomOutFactor), float(scale.y / zoomOutFactor));
        eachTransform = eachTransform * RotationTransform(float(each->GetTransform()->GetWorldRotation().Euler().z));
        eachTransform = eachTransform * TranslationTransform((halvedRenderSize.width + pos.x - camPos.x) * 1 / zoomOutFactor, (halvedRenderSize.height - (pos.y - camPos.y)) * 1 / zoomOutFactor);

        each->Render(eachTransform);
    }

    YunuD2D::YunuD2DGraphicCore::GetInstance()->EndDraw();
    return 0;
}

 

 이전 프로젝트에서는 카메라의 멤버 함수 Render()가 렌더링 동작을 담당했지만 이번에는 윈도우의 Redraw 이벤트의 콜백함수 LRESULT CALLBACK Render(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)가 렌더링을 담당한다.

 

3. 인게임 로직 코드

3.1. 플랫포머 시스템

 교수님께서는 자체 엔진으로 플랫포머 게임을 만들때 플랫폼 그 자체를 구현하는 것이 큰 난관이 될 것이라고 하셨다. 나는 플랫폼을 "유닛들을 수직방향으로 밀어내고, 수평방향으로 이동하게 만드는 객체"라고 정의하고 개발했다. 생각보다 처리해야할 예외사항이 많아 힘들었다.

플랫폼은 플레이어 콜라이더를 수직방향으로 밀어내고, 플레이어의 이동방향을 플랫폼의 수평방항으로 향하게 한다.

 

더보기
#pragma once
#include "YunutyEngine.h"

using namespace YunutyEngine;
class Ground : public Component
{
public:
    static const double groundHeight;
    static Ground* CreateGround(Vector2d position, double rotationRadian = 0, double length = 300, bool isPenetrable = false);
    bool isPenetrable;
    bool isSteppable()
    {
        return GetTransform()->rotation.Up().y > 0.4;
        //&&
            //Vector3d::Dot(footPosition - GetTransform()->GetWorldPosition(), GetTransform()->rotation.Up()) > 0;
    }
    bool isUnderFoot(Vector3d footPosition) { return Vector3d::Dot(footPosition - GetTransform()->GetWorldPosition(), GetTransform()->rotation.Up()) > 0; }
    BoxCollider2D* groundCollider;
protected:
    void OnCollisionStay2D(const Collision2D& colliision) override;
};
void Ground::OnCollisionStay2D(const Collision2D& collision)
{
    auto player = collision.m_OtherCollider->GetGameObject()->GetComponent<Player>();
    auto enemy = collision.m_OtherCollider->GetGameObject()->GetComponent<Threat>();
    auto position = GetTransform()->GetWorldPosition();
    auto rotation = GetTransform()->GetWorldRotation();
    if (!enemy && !player)
        return;
    if (player &&
        player->GetMovementState() == PlayerMovementState::JUMP &&
        Vector3d::Dot(player->GetPlayerSpeed(), GetTransform()->GetWorldRotation().Up()) > 0)
        return;
    if (player &&
        isPenetrable &&
        (player->GetMovementState() == PlayerMovementState::JUMP ||
            (!isUnderFoot(player->GetPlayerFootPos()))&& !isUnderFoot(player->lastFramePlayerPos)))
    {
        return;
    }
    if (player &&
        abs(Vector3d::Dot(player->GetPlayerFootPos() - position, rotation.Right())) > groundCollider->GetWidth() * 0.5
        )
    {
        return;
    }

    // 아래의 코드를 통해 지형이 플레이어나 적을 지형 반대방향으로 밀어냅니다.
    auto otherTransform = collision.m_OtherCollider->GetTransform();
    BoxCollider2D* otherBoxCollider = collision.m_OtherCollider->GetGameObject()->GetComponent<BoxCollider2D>();
    Vector3d delta;
    Vector3d delta_norm = Vector2d::DirectionByAngle((GetTransform()->GetWorldRotation().Euler().z + 90) * YunutyMath::Deg2Rad);
    //Vector3d delta_norm2 = GetTransform()->GetWorldRotation().Euler().;
    Vector3d otherBox_DeltaPoint = 0.5 * Vector3d(otherBoxCollider->GetWidth(), -otherBoxCollider->GetHeight(), 0);
    if (GetTransform()->GetWorldRotation().Up().x > 0)
        otherBox_DeltaPoint.x *= -1;
    Vector3d centerDelta = otherTransform->GetWorldPosition() - GetTransform()->GetWorldPosition();
    // 살짝 파고 들게 하기 위한 식
    const double diggingRate = 0.9;
    delta = delta_norm * ((groundCollider->GetHeight() * 0.5 + abs(Vector3d::Dot(otherBox_DeltaPoint, delta_norm))) * diggingRate - abs(Vector3d::Dot(centerDelta, delta_norm)));
    otherTransform->SetWorldPosition(otherTransform->GetWorldPosition() + delta);
}

 

 플레이어가 점프상태인지, 플랫폼이 점프로 뚫고 올라갈 수 있는 플랫폼인지의 여부에 따라 플랫폼이 플레이어와 상호작용하는 방법을 다르게 설정해줘야 했다.

 

3.2. 디버그 출력

 나는 B와 C에게 각각 플레이어 캐릭터의 구현과 적군 유닛의 구현을 맡길 생각이었다. 각 객체들의 현재 상태가 어떤지, 공격을 하면 어느 범위에 맞게 되는지, 유닛과 탄환들의 충돌 크기는 어떻게 되는지, 이벤트가 일어났는지, 안 일어났는지 일일이 코드에 중단점을 찍으며 디버깅을 하도록 시킬 수는 없었기 때문에 디버깅에 도움이 될만한 출력기능들을 몇가지 만들었다. 디버그 박스나 텍스트 필드, 팝업 효과를 간편한 인터페이스를 통해 출력할 수 있게 만들고 이런 디버깅 기능들의 활용법을 B와 C에게 알려주고 나니 이제 가만히 있어도 클라이언트 코드가 척척 만들어졌고 남은 역량을 맵 에디터 기능 개발에 쓸 수 있게 되었다.

캐릭터 위에 뜨는 난잡한 숫자들은 각각 캐릭터의 이동상태, 전투상태, 필살기 충전횟수, 필살기 충전율, 현재 목숨의 갯수 등의 정보를 나타낸다.
캐릭터 앞에 달린 노란색 박스는 근접공격의 범위, 초록색 박스는 필살기 공격의 범위를 뜻한다. 플레이어나 적이 피격당하면 파란색 팝업이, 플레이어가 새로운 발판을 밟으면 노란색 팝업이 일어난다.

더보기

 

class DebugObject : public Component
{
public:
    DebugObject();
    ~DebugObject();
    static void EnableDebugmode();
    static void DisableDebugmode();
    static void ToggleDebugmode();
    static void CreateDebugRectImage(GameObject* parent, double width, double height, D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
    static void CreateColliderImage(BoxCollider2D* collider,  D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
    static void CreateDebugCircleImage(GameObject* parent, double radius,  D2D1::ColorF color = D2D1::ColorF::Yellow, double border = 3, bool filled = false);
    static void CreateDebugText(GameObject* parent, function<wstring()>, Vector3d relativePosition = Vector3d::zero, double fontSize = 20, D2D1::ColorF color = D2D1::ColorF::Black);
    static void CreateArrow(GameObject* parent, Vector3d origin, Vector3d destination, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
    static void CreateArrow(GameObject* origin, GameObject* destination, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
    static void CreatePopUpCircle(GameObject* parent, double radius = 50, double duration = 0.5, D2D1::ColorF color = D2D1::ColorF::Yellow);
    static void CreatePopUpCircle(Vector3d position, double radius = 50, double duration = 0.5, D2D1::ColorF color = D2D1::ColorF::Yellow);
protected:
    void Update() override;
    void OnDestroy() override;
private:
    static void _CreatePopUpCircle(GameObject* parent, Vector3d position, double radius, double duration, D2D1::ColorF color);
    static tuple<GameObject*, DebugObject*, D2DRectangle* ,GameObject*,GameObject*> _CreateArrow(double lineLength, double width = 3, D2D1::ColorF color = D2D1::ColorF::Yellow);
    static bool debugMode;
    static unordered_set<DebugObject*> debugObjects;
    function<void()> onUpdate = []() {};
    function<void()> onDebugEnabled = []() {};
    function<void()> onDebugDisabled = []() {};
    function<void()> onDestory = []() {};

}

  디버그 사각형, 원, 화살표, 팝업 등을 생성하는 함수들은 아예 한 클래스의 정적 함수들로 몰아서 선언해 놓았다. 디버그 텍스트를 띄워야 하는 경우 어떤 정보를 어떻게 띄워야 하는지 알아야 하므로 string을 반환하는 functor를 매개변수로 받게 했다.

 

4. 게임 에디터 기능

 내가 한동안 클라이언트 팀원들의 개발을 독려하는데에 집중하고 있을 때 A는 에디터의 역할은 무엇인지, 코딩을 어디서부터 시작해야 하는지 고민하고 있었다. 에디터 개발이라는 큰 과제를 소단위 과제로 분할하지도 않은 채 A에게 너무 덩그러니 맡겨서 그런지, 개발의 진척도가 많이 부진한 상태였다. 기획자들에게 레벨디자인 툴을 빠르게 넘겨줘야 할 필요가 있었기 때문에 맵 에디터의 기초적인 틀과 기능들은 내가 빠르게 만들기로 했다.

 

4.1. 에디터 버튼 기능

면의 UI 버튼과 마우스로 상호작용하는 모습,

 

 먼저 맵 에디터의 UI를 조작하거나 장식물이나 지형을 배치하기 위해 마우스 클릭이 가능한 버튼을 만들었다.

더보기
#pragma once
#include "YunutyEngine.h"

class Button :
    public Component
{
public:
    virtual ~Button();
    virtual void Update() override;
    static Button* CreateButton(GameObject* parentPanel, const Vector2d& pos, const wstring& str, double width = 250, double height = 75, D2DText** text = nullptr);
    static Button* CreateToggleButton(GameObject* parentPanel, const Vector2d& pos, const wstring& str, double width = 250, double height = 75, D2DText** textOut=nullptr);
    static Button* CreateToggleIconButton(GameObject* parentPanel, const Vector2d& pos, const wstring& animSpritePath, double width = 150, double height = 150);
    static Button* CreateToggleSimpleIconButton(GameObject* parentPanel, const Vector2d& pos, const wstring& spritePath, double width = 150, double height = 150);
    static Button* AddDraggableButton(GameObject* parent, double radius = 50);
    static Button* AddDraggableButton(GameObject* parent, double width,double height,BoxCollider2D** colliderOut);

    function<void(void)> onClick = []() {};
    function<void(void)> onDrag = []() {};
    function<void(void)> onSelect = []() {};
    function<void(void)> onDeselect = []() {};
    function<void(void)> onEnable = []() {};
    function<void(void)> onMouseOver = []() {};
    function<void(void)> onMouseExit = []() {};
    function<void(void)> onUpdate = []() {};
    function<void(void)> onUpdateWhileSelected = []() {};
    virtual void OnMouseDrag();
    virtual void OnLeftClick();
    virtual void OnSelected();
    virtual void OnDeselected();
    virtual void OnMouseOver();
    virtual void OnMouseExit();

    virtual void OnEnable() { onEnable(); }
    virtual void OnCollisionEnter2D(const Collision2D& collision);
    virtual void OnCollisionExit2D(const Collision2D& collision);
    //void OnMiddleClick();
    //void OnRightClick();

    bool toggleButton = false;
    bool selected = false;
    bool deselectOnESC=false;
    vector<Button*> radioSelectGroup;

    struct Comp
    {
        bool operator()(Button* const input1, Button* const input2)const
        {
            return input1->GetGameObject()->GetSceneIndex() < input2->GetGameObject()->GetSceneIndex();
        }
    };
private:
    D2DRectangle* buttonPanel = nullptr;
};

 

  버튼은 마우스 포인터와 버튼 객체에 각각 충돌체를 달아두고 충돌이벤트가 뜨면 버튼을 하이라이트시키는 방식으로 만들었다. 각 버튼이 클릭되거나 하이라이트되었을 때 어떻게 어떻게 동작할지 커스터마이징하고 싶다면 onClick, onDrag, onMouseOver 등 버튼 객체의 std::function 멤버변수들에 적절한 콜백 함수를 등록시키면 된다.

 

 4.2. 플랫폼 배치

지도의 지점마다 노드를 찍어 사이를 잇는 플랫폼을 만드는 모습

 

 플랫폼은 뚫고 올라갈 수 있는 플랫폼과 가로막히게 되는 플랫폼, 두가지 종류로 나누었다. 레벨 디자이너는 맵에 노드들을 위치를 찍어가며 플랫폼을 배치할 수 있다. 이 지형들의 배치는 디버그 정보 표시를 끄면 보이지 않으므로 나중에 장식물 이미지를 적절히 배치해 캐릭터가 의도한 위치에 서 있을 수 있다는 당위성을 제공해야 한다.

좌 : 디버그 표시를 끈 게임 화면, 우 : 디버그 표시를 켜 지형 플랫폼들의 배치가 드러나는 게임 화면

4.3. 장식물 배치

이미지를 복사해 끌어다 놓고, 회전시켜 배치하는 모습

 

 장식물 이미지들은 빈 이미지를 배치하고 이미지 경로를 적절하게 입력하면 해당 이미지가 출력되도록 만들었다. 이미지의 위치나 회전값을 임의로 설정할 수 있으며, 캐릭터들을 가릴 것인지 캐릭터들에 가려질 것인지 설정할 수 있다.

좌 : 캐릭터의 앞에 놓이는 장식물의 배치, 우 : 캐릭터의 뒤에 놓이는 장식물의 배치

 

 카메라로부터 멀리 있는 장식물들은 스크롤 속도를 느리게 줄 필요가 있고, 카메라로부터 가까이 있는 장식물들은 스크롤 속도를 빠르게 줄 필요가 있다. 원근 투영을 하지 않는 카메라라도 이런 디테일을 넣어주면 연출에 정성을 들인 티가 난다. 

카메라보다 앞에 있는 장식물은 더 빨리 뒤로 스크롤되고 멀리 떨어져 있는 장식물들은 느리게 스크롤되는 모습

4.4. 카메라 레일

게임을 진행하면서 카메라가 따라 움직이게 될 궤적을 설정하는 모습

 

 메탈슬러그와 같은 일직선 레일 슈팅 게임은 게임의 진행경로가 고정되어 있으며 한번 앞으로 나아간 카메라가 다시 뒤로 돌아오지 못한다. 따라서 게임의 진행 방향과 카메라의 궤적을 설정할 수 있는 카메라 레일 기능을 개발할 필요가 있었다. 나는 A에게 "레벨 디자이너가 카메라 중점이 거치게 될 경유지를 노드로 찍을 수 있게 에디터 기능을 확장해달라. 카메라 중점이 나아갈 궤적을 디버그 이미지로 표시해야 하니 노드와 노드 사이에 화살표를 그려주고, 카메라의 위치는 캐릭터의 위치에 따라 화살표를 따라서 업데이트되어야 하니 이런 사양에 맞게 동작하는 카메라 컴포넌트의 코드도 짜 달라."고 부탁했다. 내가 어느정도 만들어 둔 맵 에디터의 코드들을 참고하여 A는 어렵지 않게 추가 기능을 확장할 수 있었으며 디버그 이미지 표시, 카메라 업데이트 코드 구현과 같은 코드도 어렵지 않게 만들어냈다.

 

4.5. 적군 배치

에디터에서 적 유닛 마커를 배치하는 모습

 

 게임을 진행하면서 적들을 마주해야 할테니 적 유닛을 생성하는 마커를 맵에 배치할 수 있게 했다. 카메라에 비춰지는 화면 영역에 사각형 콜라이더를 달았는데, 이 화면 콜라이더가 마커와 부딪히면 적이 생성된다. 일반병을 생성하는 마커 코드를 내가 짰고, 나머지 병사들에 대한 코드는 A에게 구현을 부탁했다.

 

4.6. 적군 웨이브 배치

적군들이 시간차를 두고 대규모로 출몰하는 웨이브 구간을 설정하는 모습. 후반에 출몰하는 엘리트 병사 두명을 필수 척결대상으로 지정하여, 반드시 이 유닛들을 처치해야만 카메라 고정이 풀리게 만들어 두었다.

 

 적군들이 스테이지를 전진하면서 앞에서만 나타난다면야 아무래도 게임의 긴장감이 떨어질 것이다. 한번씩 카메라를 고정시킨 다음 적들이 제파식 공격을 가하게 만드는 웨이브 구간을 중간 중간에 설정할 필요가 있는 것이다. 웨이브 구간을 클릭한 채로 특정 키를 누르면 웨이브의 시점을 조절할 수 있고, 이 때 병사 마커들을 배치하면 해당 시점에 적 유닛들이 출몰하게 된다. 웨이브와 관계가 있는 적 마커는 디버그 이미지로 줄을 달아 웨이브와의 연관성을 표시하게 만들었다. 반드시 처치해야만 웨이브를 넘길 수 있게 만든 필수척결 대상 마커들은 빨간색 줄로 연관성이 표시된다.

 

5. 소감

 

 개인적으로 게임 인재원 생활중 가장 기억에 남는 프로젝트였다. 내가 팀원들에게 제시한 로드맵이나, 팀원들의 생산성 향상을 위한 노력에 대한 팀원들의 호응이 매우 좋아 내심 뿌듯한 기분이 많이 들었기 때문이다. 

잘된 점 아쉬운 점
 팀원들의 적극적인 프로젝트 참여에 대한 독려가 잘 되었고, 프로젝트 진행 중 사기가 높았음.
 게임엔진의 안정성을 검증함. 
 충돌체가 너무 많아지면 FPS가 급격히 떨어졌음. 충돌 체크 코드의 최적화에 아쉬운 부분이 많았음.
 기획자들에게 에디터를 너무 늦게 줘 레벨 디자인의 완성도가 낮아짐.
 에디터 기능으로 적이나 플레이어의 공격력, 체력 등을 조절할 수 있는 수치 조절 기능을 제공하지 못함.

 

6. 기타 설계 문서

 

맵 에디터를 어떻게 구현해야 하는지 설명하기 위해 만든 ppt 이미지

 

 

적군이 등장하는 로직을 어떻게 구현하고 또 어떻게 에디터에서 배치해야 하는지 설명하기 위해 만든 ppt 이미지

 

에디터 기능을 구현하기 전에 draw.io로 그린 클래스 다이어그램

 

 

 

실행하면 세모 모양으로 쌓인 큐브 블록들을 보여주는 PhysX의 HelloWorld 예제

 

 HelloWorld 프로젝트에서는 간단하게 박스로 피라미드를 쌓은 예제를 보여준다. 어떻게 PhysX 환경을 조성하고 강체 시뮬레이션을 돌릴 수 있는지 코드를 분석해 알아보자.

void initPhysics(bool interactive)
{
	gFoundation = PxCreateFoundation(PX_PHYSICS_VERSION, gAllocator, gErrorCallback);

	gPvd = PxCreatePvd(*gFoundation);
	PxPvdTransport* transport = PxDefaultPvdSocketTransportCreate(PVD_HOST, 5425, 10);
	gPvd->connect(*transport,PxPvdInstrumentationFlag::eALL);

	gPhysics = PxCreatePhysics(PX_PHYSICS_VERSION, *gFoundation, PxTolerancesScale(),true,gPvd);

	PxSceneDesc sceneDesc(gPhysics->getTolerancesScale());
	sceneDesc.gravity = PxVec3(0.0f, -9.81f, 0.0f);
	gDispatcher = PxDefaultCpuDispatcherCreate(2);
	sceneDesc.cpuDispatcher	= gDispatcher;
	sceneDesc.filterShader	= PxDefaultSimulationFilterShader;
	gScene = gPhysics->createScene(sceneDesc);

	PxPvdSceneClient* pvdClient = gScene->getScenePvdClient();
	if(pvdClient)
	{
		pvdClient->setScenePvdFlag(PxPvdSceneFlag::eTRANSMIT_CONSTRAINTS, true);
		pvdClient->setScenePvdFlag(PxPvdSceneFlag::eTRANSMIT_CONTACTS, true);
		pvdClient->setScenePvdFlag(PxPvdSceneFlag::eTRANSMIT_SCENEQUERIES, true);
	}
	gMaterial = gPhysics->createMaterial(0.5f, 0.5f, 0.6f);

	PxRigidStatic* groundPlane = PxCreatePlane(*gPhysics, PxPlane(0,1,0,0), *gMaterial);
	gScene->addActor(*groundPlane);

	for(PxU32 i=0;i<5;i++)
		createStack(PxTransform(PxVec3(0,0,stackZ-=10.0f)), 10, 2.0f);

	if(!interactive)
		createDynamic(PxTransform(PxVec3(0,40,100)), PxSphereGeometry(10), PxVec3(0,-50,-100));
}

 샘플 코드에서 호출되는 함수들을 타고 타고 들어가면 initPhysics라는 이름의 함수가 호출되는데, 이 함수에서 피직스에 필요한 모든 초기화 작업이 진행된다.

void initPhysics(bool interactive)
{
	gFoundation = PxCreateFoundation(PX_PHYSICS_VERSION, gAllocator, gErrorCallback);

 

 PxFoundation은 PhysX와 관련된 모든 객체들의 기반이 되는 싱글톤 객체라고 한다. 이 파운데이션 객체에 매달린 객체들이 먼저 모두 릴리즈되어야 파운데이션 객체도 릴리즈할 수 있다.

	gPvd = PxCreatePvd(*gFoundation);
	PxPvdTransport* transport = PxDefaultPvdSocketTransportCreate(PVD_HOST, 5425, 10);
	gPvd->connect(*transport,PxPvdInstrumentationFlag::eALL);

 

 PxCreatePvd함수는 파운데이션 객체에 의존하는 PxPvd 클래스를 생성한다. PVD는 PhysX Visual Debugger라는 의미로, 이 클래스로부터 connect 함수를 호출하면 PhysX 기반으로 물리연산을 하는 어플리케이션으로부터 물리정보를 받아 PhysX Visual Debugger 프로그램에서 물리엔진의 동작 상태를 시각적으로 확인할 수 있다.

좌측의 프로그램은 PhysX를 기반으로 물리시뮬레이션을 돌리는 프로그램, 오른쪽은 PhysX Visual Debugger. PVD는 알짜 물리연산 정보들만 사용자에게 표시해준다,

	gPhysics = PxCreatePhysics(PX_PHYSICS_VERSION, *gFoundation, PxTolerancesScale(),true,gPvd);

 

 CreatePhysics 함수는 피직스 객체들을 생성할 수 있는 싱글톤 팩토리 클래스이다. 씬이나 매터리얼을 생성하거나 물리 객체의 형체 정보(Shape), 강체 인스턴스 등 거의 대부분의 PhysX 객체들을 생성할 때 사용된다.

	PxSceneDesc sceneDesc(gPhysics->getTolerancesScale());
	sceneDesc.gravity = PxVec3(0.0f, -9.81f, 0.0f);
	gDispatcher = PxDefaultCpuDispatcherCreate(2);
	sceneDesc.cpuDispatcher	= gDispatcher;
	sceneDesc.filterShader	= PxDefaultSimulationFilterShader;
	gScene = gPhysics->createScene(sceneDesc);

 

 PhysXScene은 PhysX 액터들이 배치될 수 있는 씬이다.

	PxPvdSceneClient* pvdClient = gScene->getScenePvdClient();
	if(pvdClient)
	{
		pvdClient->setScenePvdFlag(PxPvdSceneFlag::eTRANSMIT_CONSTRAINTS, true);
		pvdClient->setScenePvdFlag(PxPvdSceneFlag::eTRANSMIT_CONTACTS, true);
		pvdClient->setScenePvdFlag(PxPvdSceneFlag::eTRANSMIT_SCENEQUERIES, true);
	}

 

 PvdSceneClient는 Physx Visual Debugger 프로그램에 한 씬의 정보를 전달할 때 어떤 정보를 전달해 줄지 플래그를 설정해주는 객체다.

	gMaterial = gPhysics->createMaterial(0.5f, 0.5f, 0.6f);

	PxRigidStatic* groundPlane = PxCreatePlane(*gPhysics, PxPlane(0,1,0,0), *gMaterial);
	gScene->addActor(*groundPlane);

 

 Material은 물리적인 표면 재질의 상태를 나타내는 객체다. 표면의 정지마찰계수, 운동마찰계수, 반발계수 등 표면 재질의 다양한 성질을 지정할 수 있다. groundPlane을 만들고 이 재질을 입히면 표면 재질의 물리적 특성이 반영되어 지표면이 만들어진다.

	for(PxU32 i=0;i<5;i++)
		createStack(PxTransform(PxVec3(0,0,stackZ-=10.0f)), 10, 2.0f);

	if(!interactive)
		createDynamic(PxTransform(PxVec3(0,40,100)), PxSphereGeometry(10), PxVec3(0,-50,-100));
}
static void createStack(const PxTransform& t, PxU32 size, PxReal halfExtent)
{
	PxShape* shape = gPhysics->createShape(PxBoxGeometry(halfExtent, halfExtent, halfExtent), *gMaterial);
	for(PxU32 i=0; i<size;i++)
	{
		for(PxU32 j=0;j<size-i;j++)
		{
			PxTransform localTm(PxVec3(PxReal(j*2) - PxReal(size-i), PxReal(i*2+1), 0) * halfExtent);
			PxRigidDynamic* body = gPhysics->createRigidDynamic(t.transform(localTm));
			body->attachShape(*shape);
			PxRigidBodyExt::updateMassAndInertia(*body, 10.0f);
			gScene->addActor(*body);
		}
	}
	shape->release();
}

 

 createStack, createDynamic은 샘플 프로젝트에서 정의된 코드다. 하나의 박스 객체를 만들기 위해서는 Physics 객체로부터 강체를 생성해낸 다음 박스모양의 Shape를 만들어 붙여 씬에 액터로 등록한다.

PX_FORCE_INLINE	PxShape* createShape(	const PxGeometry& geometry,
											const PxMaterial& material,
											bool isExclusive = false,
											PxShapeFlags shapeFlags = PxShapeFlag::eVISUALIZATION | PxShapeFlag::eSCENE_QUERY_SHAPE | PxShapeFlag::eSIMULATION_SHAPE)
	{
		PxMaterial* materialPtr = const_cast<PxMaterial*>(&material);
		return createShape(geometry, &materialPtr, 1, isExclusive, shapeFlags);
	}

 

 CreateShape 함수는 Shape의 속성을 나타내는 PxShapeFlags를 매개변수로 받는데 여기에 어떤 플래그를 활성화시키느냐에 따라 같은 Shape를 물리 시뮬레이션이 되는 덩어리 물질로 쓸수도 있고, 다른 물질이 닿았는지만 체크하는 볼륨 트리거로 쓸 수도 있다.

void renderCallback()
{
	stepPhysics(true);

	Snippets::startRender(sCamera);

	PxScene* scene;
	PxGetPhysics().getScenes(&scene,1);
	PxU32 nbActors = scene->getNbActors(PxActorTypeFlag::eRIGID_DYNAMIC | PxActorTypeFlag::eRIGID_STATIC);
	if(nbActors)
	{
		std::vector<PxRigidActor*> actors(nbActors);
		scene->getActors(PxActorTypeFlag::eRIGID_DYNAMIC | PxActorTypeFlag::eRIGID_STATIC, reinterpret_cast<PxActor**>(&actors[0]), nbActors);
		Snippets::renderActors(&actors[0], static_cast<PxU32>(actors.size()), true);
	}

	Snippets::finishRender();
}
void stepPhysics(bool /*interactive*/)
{
	gScene->simulate(1.0f/60.0f);
	gScene->fetchResults(true);
}

 

 renderCallback은 매 렌더 업데이트마다 호출되는 함수다. 먼저 씬의 simulate 함수에 델타 타임을 매개변수로 넣어 시뮬레이션을 돌리고 그 결과를 받는다. 이 예제 코드에서는 업데이트된 액터들의 상태를 토대로 렌더링만 시키는데 내 게임엔진에서는 액터들의 상태를 이용해 컴포넌트들의 콜백 함수를 호출하거나 상태를 바꾸면 될 것 같다.

'자체엔진 Yunuty > PhysX' 카테고리의 다른 글

PhysX Snippet ContactReport 프로젝트 분석  (0) 2024.01.05

요약

 

1. 탐구배경

2. 운동량과 힘

3. 각운동량과 토크

4. 일과 에너지

5. 충격량


1. 탐구배경

 게임개발을 하면서 게임 오브젝트들의 속도와 가속도, 회전속도를 조작해야 할 기회가 있을때마다 나는 힘을 가하거나 토크를 더하는 물리량에 변화를 주는 기능을 활용하기보다는 직접적으로 속도, 가속도, 회전속도를 바꾸는 방식을 선호했다. 기초적인 역학에 대한 이해가 부실한 상태에서 물리량의 변화를 함부로 일으키면 큰일이 날 것 같았기 때문이다.

 하지만 이제 자체엔진에 물리엔진 라이브러리를 연동해야 하는 순간이 왔고, 미뤄둔 숙제를 해야 할 때가 되었다. 그전에 먼저 강체의 속도, 가속도, 회전속도에 가장 밀접한 영향을 주는 존재인 힘, 토크, 일, 충격량에 대해 차분하게 정리해보고자 한다.

언리얼 엔진의 AddForce 함수, 유니티 엔진의 물리함수

2. 운동량과 힘

 

 

 입자의 운동량은 입자의 무게 * 물체의 속도로 측정된다. 물체를 여러개의 입자로 구성된 입자계로 보았을 때 계 전체의 운동량을 구하고 싶다면 각 입자들의 운동량 합을 구하거나 적분연산을 한다.

대부분의 예제의 경우 질량이 시간에 따라 변하지는 않으므로 dm/dt는 0이다. 고로 F = ma인것

 

 물체에 가해지는 힘은 운동량을 시간에 대해 미분한 값으로, 즉 운동량의 변화율을 힘이라고 할 수 있다.

 

3. 각 운동량과 토크

 각 운동량은 축을 중심으로 계속 회전하려는 성질이다. 단일 입자에 대한 각 운동량은 회전축으로부터의 거리와 입자의 무게와 속도를 곱한 값이며, 입자계 전체의 각 운동량 합은 영역에 대한 적분으로 구한다.

 토크는 회전축으로부터 힘이 가해지는 점까지의 거리 r과 힘의 크기 F를 곱한 값으로 표시된다. 어떤 물체가 있을때 그 물체를 구성하는 모든 입자들에 가해지는 토크의 합을 계산하면 입자계 전체의 토크를 구할 수 있다. 연속적인 물체의 경우 적분을 통해 토크합을 구한다.

 

4. 일과 에너지

 

{"originWidth":590,"originHeight":250,"style":"alignCenter","caption":"입자에 F힘을 주어 x0~x1까지 이동을 시키면 d방향으로 주어진 힘의 총량

 

 일은 힘에 거리를 곱한 것이며, 더 정확히는 변위벡터와 방향이 일치하는 힘과 거리를 곱한 것이다. 토크 또한 회전축으로부터의 거리와 힘을 곱한 것이니 토크와 일의 차원은 무게 * 거리 * 가속도로 같다고 할 수 있겠다. 물론 차원은 같더라도 일과 토크의 성질은 매우 다르다.

주어지는 힘 F가 -mgk로 일정하다면 꼬불꼬불한 경로를 통해 목표지까지 가더라도 결국 적분을 통해 구한 일의 총량은 같다.

 

 시작점부터 목표점까지의 경로가 꼬불꼬불하더라도 힘이 일정하다면 일의 양은 같다.

 

5. 충격량

 충격량은 아주 짧은 시간 동안 작용한 힘으로 정의한다. 물체 둘이 충돌할 때 작용하는 힘을 충격량이라고 하는데, 아주 짧은 시간동안 주어지는 힘을 시간에 적분해 구해낸 운동량의 변화를 충격량이라고 한다. 충격량의 차원은 질량 * 거리 * 속도로 운동량과 같다.

목차

 

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들을 이용해 나의 게임엔진에 길찾기 시스템을 이식하는 내용을 다룰 것이다.