Game Programming in C++ - Day 21

이응민·2024년 12월 11일
0

Game Programming in C++

목록 보기
21/21

Day 21 충돌 적용하기

게임 코드에 충돌 추가하기

BoxComponent 클래스

BoxComponent의 선언은 다른 컴포넌트와 다를 것이 없지만 Update함수를 재정의하는 대신 OnUpdateWorldTransform 함수를 재정의 한다. 소유자 액터는 세계 변환을 재정의할때마다 Box Component의 OnUpdateWorldTransform 함수를 호출한다. BoxComponent 클래스의 데이터 멤버에는 2개의 AABB구조체 인스턴스가 있는데, 하나는 오브젝트 공간 경계를 위한 AABB이고, 하나는 세계 공간상의 경계 AABB이다. 오브젝트 공간의 경계는 BoxComponent가 초기화 된 후에는 변경되지 않아야한다. 하지만 세계 공간 경계는 소유자 액터의 세계 변환이 변경될 때마다 바뀐다. 마지막으로 BoxComponent 클래스는 BoxComponent를 세계 회전을 기반으로 회전시킬지 정하는 이진값을 갖는다. 액터가 회전하면 BoxComponent는 이 이진값에 따라 회전 유무를 선택할 수 있다.

메시 파일의 오브젝트 공간 경계(바운딩)을 얻기 위해 Mesh 클래스에도 멤버 데이터로 AABB를 추가한다. gpmesh 파일을 로드할 때 Mesh는 각 버텍스마다 AABB::UpdateMinMax를 호출해서 최전화된 오브젝트 공간 AABB를 산출한다. 그러면 메시를 사용하는 액터는 메시의 오브젝트 공간 바운딩 박스를 얻어서 액터의 BoxComponent로 이 바운딩 박스를 전달한다.

Mesh* mesh = GetGame()->GetRenderer()->GetMesh("Assets/Plane.gpmesh");
// 충돌 박스 추가
BoxComponent* bc = new BoxComponent(this);
bc->SetObjectBox(mesh->GetBox());

메시를 사용하는 모든 액터를 메시로부터 바운딩 박스를 얻기위해 위 코드처럼 수정해야한다. 오브젝트 바운딩 박스를 세계 바운딩 박스로 변환하기 위해서는 바운딩 박스에 스케일, 회전, 이동을 적용하면 된다. 세계 변환 행렬을 구축할 때처럼 회전은 원점이 중심이므로 순서가 중요하다.

위 소스코드는 OnUpdateWorldTransform 함수 코드이다.박스의 크기는 소유자 액터의 스케일 값을 min, max에 곲해서 조절한다. 박스를 회전하려면 AABB::Rotate에 소유자 액터의 쿼터니언을 전달해야한다. mShouldRotate값이 true(기본값이 true)이다.인 경우에만 이 회전이 사용된다. 박스의 이동은 소유자 액터의 위치를 min과 max에 더하면된다.

PhysWorld 클래스

렌더러와 오디오 시스템 클래스가 별도로 구현된 것처럼 물리 세게를 위한 별도의 PhysWorld 클래스를 만들면 좋다. Game에 PhysWorld 포인터를 추가하고, Game::Initialize함수에서 PhysWorld를 초기화한다.

위 소스코드에서 볼 수 있듯 BoxComponent의 포인터 벡터와 public 함수 AddBox와 RemoveBox 함수를 호출한다. 이를 통해 PhysWorld 클래스는 렌더러가 모든 스프라이트 컴포넌트의 벡터를 가졌던 것처럼 모든 박스 컴포넌트의 벡터를 가진다. 이제 PhysWorld는 게임 세계상의 모든 박스 컴포넌트를 추적할 수 있으니 다음 단계로 이러한 박수들의 충돌 테스트 기능 지원을 추가한다. 선분을 파라미터로 받고 이 선분이 박스와 교차하면 true를 반환하는 SegmentCast 함수를 정의한다. 이 SegmentCast 함수는 최초 충돌에 대한 참조 정보를 반환한다.

bool SegmentCast(const LineSegment& l, CollisionInfo& outColl);

CollisionInfo 구조체는 교차점과 교차 지점에서의 법선 벡터, 그리고 충돌에 관여한 BoxComponent와 Actor 객체 포인터를 포함한다.

struct CollisionInfo
{
	// 충돌 지점
    Vector3 mPoint;
    // 충돌 시의 법선 벡터
    Vector3 mNormal;
    // 충돌한 컴포넌트
    class BoxComponent* mBox;
    // 컴포넌트의 소유자 액터
    class Actor* mActor;
}

선분은 잠재적으로 여러 박스와 교차하므로 SegmentCast는 가장 가까운 교차가 가장 중요한 교차라고 가정한다. 박스 컴포넌트들의 벡터는 정렬되지 않았으므로 SegmentCast는 모든 박스를 테스트하고 난 뒤 가장 작은 tt값을 교차 결과로 반환해야한다. 가장 작은 tt값은 교차 점이 선분의 시작점과 가장 가깝다는 것을 의미한다. SegmentCast는 앞에서 설명한 선분과 AABB 교차함수를 활용한다. 하지만 이 교차 함수는 선분과 교차하는 평면의 법선 벡터를 반환하도록 수정됐다.

SegmentCast를 이용한 공의 충돌

이제 게임에서 플레이어가 공을 쏘게된다. 그래서 그 공 발사체와 물체가 충돌하는지 판별하기 위햐 SegmentCast를 사용한다. 공이 오브젝트 표면과 충돌하면 공은 표면의 법선 방향으로 튕긴다. 즉, 공이 표면에 부딪치면 다른 방향으로 향하도록 액터를 회전시켜야한다. 먼저 액터가 특정 방향으로 향하기 위해서는 회전값을 변경하는 함수가 필요하다. Actor 클래스에 내적, 외적 그리고 쿼터니언을 사용해서 회전값을 변경하는 헬퍼 함수를 추가한다.

위 소스코드에서 x축을 기준으로한 각도를 구현다. 그래서 x축과 진행 방향을 외적해서 그 축과 그 축을 기준으로 x축으로부터 진행방향까지의 각도를 사용해서 회전값을 쿼터니언으로 저장한다. 다음은 BallActor 클래스를 구현한다. 그리고 BallActor의 구체적인 이동을 구현하는 새로운 MoveComponent의 서브클래스 BallMove를 BallActor에 붙인다.

위 코드에서 보여지는 BallMove::Update 함수는 처음에 볼이 이동하는 방향으로 선분을 생성한다. 이 섭눙이 게임 세계상에서의 뭔가와 교차하면 표면에서 튕길 것이다. Vector3::Reflect를 사용해서 이동 방향을 표면에서 반사시킨 다음 공이 이 새로운 방향을 향하도록 RotateNewForward를 사용한다. 주의해야할 점은 플레이어에 BoxComponent를 추가할 때 일어나는 일에 대해서이다. 플레이어가 공을 쏠 때 공이 플레이어와 충돌하는 것은 원하는 상황이 아니다. 다행히 SegmentCast로부터 얻은 CollisionInfo에 박스 컴포넌트를 소유한 액터의 포인터가 있다. 그래서 어딘가에 저장된 플레이어 포인터와 CollisionInfo의 액터 포인터를 비교햐서 같다면 공이 플레이어와 충돌하지 않게 처리하면 된다.

PhysWorld에서 박스 충돌 테스트

일부 게임에서는 물리 세게의 모든 박스간 충돌 테스트가 필요할 수 있다. 이 테스트를 위한 단순한 구현은 세계상의 모든 상자 쌍 조합에 충돌 테스트를 수행하는 것이다. 이 기초적인 접근법은 O(n2)O(n^2) 알고리즘을 사용한다. TestPairwise 함수는 유저가 정의한 함수 f를 파라미터로 받아서 박스가 서로 교차하면 f를 호출한다.

이 함수는 개념적으로는 간단하지만 불필요한 Intersect 함수 호출이 너무 많다. 이 함수는 세계상에서 서로 반대편에 있는 박스들도 바로 옆에 있는 상자인 것처럼 처리한다. 축 정렬 박스 2개가 두 좌표축에서 겹치지 않는다면 교차하지 않는다는 사실을 활용하면 TestPairwise의 최적화가 가능하다. 예를 들어 두 박스가 교차한다면 한 박스의 구간 [min.x, max.x]는 또 다른 박스의 구간 [min.x, max.x]와 겹쳐야한다. SAP(sweep and prune) 알고리즘은 박스 교차 테스트의 수를 줄이기 위해 두 좌표축이 겹치는지의 유무를 관찰한다. SAP 알고리즘은 축을 선택하고 축에 따라 겹치는 부분이 있는 상자만을 테스트한다.

위 그림은 몇 개의 AABB를 보여주며 x축 방향으로 AABB의 간격을 보여준다. 박스 AA와 박스 BB의 x축 간격은 겹친다. 그래서 AABB는 교차할 수 있다. 하지만 박스 AA와 박스 CC의 간격은 겹치지 않는다. 그래서 박스 AA와 박스 CC는 교차할 수 없다. 비슷하게 박스 DD는 다른 박스들과는 겹치지않는다. 그래서 박스 DD는 나머지 박스들과 교차할 수 없다. 이 경우에 SAP알고리즘은 6가지 박스 선택 조합 대신에 (A,B)(A, B), (B,C)(B, C) 2가지 싸에 대해서만 Intersect 함수를 호출한다.

위 소스코드는 x축에 대한 SAP 알고리즘 메소드가 동작하는 코드를 봉준다. 먼저 박스의 최소 x값으로 박스를 정렬하고 그 다음 외부 루프에서 박스의 최대 x값을 얻어서max에 그값을 저장한다. 내부 루프에서는 min.x 가 max보다 작은지만 살펴보고 내부 루프에서 max보다 더 큰 min.x를 가진 최초의 박스와 만나면 외부 루프의 상자는 x축 간격이 겹치는 박스가 더 이상 없다는 것을 뜻한다. 즉 외부 루프 상자는 이제 교차 가능한 박스가 없으므로 내부 루프를 나오며 외부 루프의 다음 상자에서 실행을 반복한다.

이 SAP 알고리즘의 복잡도는 O(nlog n)O(nlog\ n)이다. SAP 방법이 정렬을 필요로 하기는 하지만, 상자가 몇 개 안되는 경우가 아니면 일반적으로 TestPairwise같이 단순한 테스트보다는 매우 효율적이다. SAP 알고리즘 계열 중에는 세 개의 축 전부에서 쓸모없는 부분을 쳐낸다. 이를 위해서는 여러 개의 정렬된 벡터가 필요하다. 세 축 전부에서 테스트를 하면 불필요한 박스를 모두 쳐내게 되므로 남아있는 박스 세트는 서로 간에 반드시 교차해야한다. SAP은 넓은 단계(board phase) 테크닉 범주에 속한다. 넓은 단계 테크닉은 개별 쌍의 충돌을 테스트하는 좁은 단계(narrow phase) 이전에 가능한 한 많은 충돌을 제거한다. 다른 테크닉 범주에는 그리드, 셀, 트리 등이 있다.

벽과 플레이어와의 충돌

MoveComponent는 캐릭터를 앞뒤로 움직이기 위해 mForwardSpeed 변수를 사용했다. 그러나 지금까지의 구현으로는 플레이어가 벽을 뚫고 지나가는 것을 막을 수 없다. 이를 수정하려면 플레이어 뿐만 아니라 각각의 벽에(PlaneActor로 캡슐화된) BoxComponent를 추가해야한다. 또한, PlaneActor간의 충돌여부는 테스트할 필요가 없으므로 TestSweepAndPrune 함수는 사용하지 않는다. 대신 Game에 PlaneActor 포인터 벡터를 만든 다음, 플레이어 코드가 이 벡터를 사용한다. 기본 아이디어는 프레임마다 모든 PlaneActor와 플레이어와의 충돌을 계산하는 것이다. AABB가 교차한다면 플레이어의 위치를 조정해서 벽과 충돌하지 않게한다. 이 계산을 이해하기위해 2D 상에서 이 상황을 시각화했다.

위 그림은 플레이어의 AABB와 플랫폼의 AABB 충돌을 묘사했다. 일단 축마다 차이를 계산한다. 예를 들어 dx1은 플레이어 max.x와 플랫폼 min.x의 차다. 반대로 dx2는 플레이어 min.x와 플랫폼 max.x사이의 차다. 이러한 차이값 중에서 가장 작은 절대값이 두 AABB에서 최소 겹침이다. 그래서 위 그림에서 최소 겹침은 dy1이다. 플레이어의 위치값에 dy1을 더하면 플레이어는 정확히 플랫폼 바로 위에 서게 된다. 그러므로 충돌을 올바르게 보정하려면 최소 겹침의 축 방향으로 위치를 보정해주면 된다. 3D에서는 축이 3개라서 6개의 차이값이 있다는 걸 제외하면 원리는 2D와 동일하다.

플레이어 위치가 변경되면 플레이어의 BoxComponent 또한 변경되므로 교차 시에 BoxComponent의 경계값을 재계산해야된다. 그리고 UpdateActor에서 FixCollision 함수를 호출한다. UpdateActor 함수는 MoveComponent가 플레이어의 위치를 갱신한 후 호출된다.

0개의 댓글