이번 프로젝트에서는 VR 방탈출 콘텐츠의 클라이언트를 개발했다.
방탈출이다 보니 일반적인 게임보다 플레이어가 직접 물건을 만지는 상황이 많았다. 열쇠를 집거나 드릴을 사용하고, 해머로 물체를 부수는 식이다.
PC 게임처럼 버튼 하나 눌러서 처리하면 되는 부분도 있었지만, VR에서는 손을 실제로 움직여야 하기 때문에 그 동작이 게임 안에서 어떻게 보이는지도 꽤 중요했다.
이번 프로젝트에서 주로 맡았던 부분은 HMD와 양손 컨트롤러를 이용한 전신 표현, 물리 오브젝트 Grab/Release, 타격 판정, 드릴과 나사 조작, 잠금장치와 퍼즐 이벤트 등이다.
HMD와 양손만으로 전신을 표현하기
처음 구현하면서 가장 먼저 걸렸던 부분이 전신이었다.
사용할 수 있는 트래킹 정보는 HMD와 양손 컨트롤러뿐이었다. 머리와 손 위치는 받을 수 있었지만 팔꿈치나 허리, 발에는 별도의 트래커가 없었다.
그렇다고 미리 만들어진 전신 애니메이션만 사용하기에는 VR에서 움직임이 잘 맞지 않았다.
가령 플레이어가 책상 밑을 보기 위해 몸을 숙이면 HMD도 같이 내려간다. 그런데 몸은 그대로 서 있으면 바로 티가 난다. 손을 앞으로 뻗었는데 팔이 다른 방향을 보고 있는 경우도 마찬가지였다.
이 부분은 직접 IK를 만들어 처리했다.
2-Bone IK
팔은 어깨, 팔꿈치, 손목을 기준으로 2-Bone IK를 구성했다.
상완 길이를 a, 하완 길이를 b, 어깨에서 손목까지 거리를 c라고 하면 제2코사인 법칙을 이용해 팔꿈치 각도를 구할 수 있다.
cos(θ) = (a² + b² - c²) / (2ab)
수식 자체는 어렵지 않았는데 실제로 적용해보니 예외 상황에서 문제가 더 많이 생겼다.
손이 팔 길이보다 멀리 나가면 삼각형을 만들 수 없다. 반대로 손이 어깨에 너무 가까워도 계산이 이상해진다. VR에서는 계속 값이 들어오기 때문에 부동소수점 오차도 무시하기 어려웠다.
특히 Acos()에 들어가는 값이 -1 ~ 1을 벗어나면서 NaN이 생기는 문제가 있었다. 이 값이 회전 계산까지 넘어가면 팔이 갑자기 튀었다.
그래서 계산하기 전에 범위를 제한하고 NaN도 확인했다.
float CosAngle(float a, float b, float c)
{
float cos = (a * a + b * b - c * c) / (2f * a * b);
if (!float.IsNaN(cos))
{
cos = Mathf.Clamp(cos, -1f, 1f);
return Mathf.Acos(cos) * Mathf.Rad2Deg;
}
return 1f;
}
팔꿈치가 어느 방향으로 꺾여야 하는지도 별도로 처리했다.
손 위치만 보고 팔을 계산하면 어느 순간 팔꿈치가 반대 방향으로 넘어가는 경우가 있었다. 그래서 어깨에서 손으로 향하는 방향과 Pole 위치를 같이 사용해서 팔꿈치가 꺾일 방향을 잡았다.
Vector3 en = Vector3.Cross(
Target.position - Upper.position,
Pole.position - Upper.position
);
이후 LookRotation, AngleAxis, 상대 회전 등을 조합해서 상완과 하완의 회전을 계산했다.
반복해서 값을 찾는 방식의 IK도 있지만 이 경우에는 팔 구조가 단순한 2-Bone 형태였기 때문에 해석적인 방법으로 구현했다. 매 프레임 돌아가는 VR 코드라서 이 정도 문제에 반복 솔버를 넣는 것은 굳이 필요하지 않다고 봤다.
몸을 숙이는 동작
팔을 맞추고 나니 몸 쪽에서도 문제가 보였다.
VR 방탈출에서는 생각보다 몸을 숙이는 일이 많았다. 바닥에 있는 물건을 확인하거나 낮은 곳을 보는 경우가 계속 있었다.
HMD의 높이를 이용하면 플레이어가 얼마나 숙였는지 어느 정도는 추정할 수 있었다.
기준 키를 DefaultHeight로 두고 현재 머리 높이와 발 위치를 이용해 Crouch 비율을 계산했다.
CrouchRatio = 1 - (HeadY - FeetY) / DefaultHeight
이 값을 캐릭터 전체에 그대로 적용하지는 않았다. 그렇게 하면 몸 전체가 단순히 위아래로 줄어드는 느낌이 났다.
상체는 조금 앞으로 숙이고 골반은 별도로 위치를 보정했다. 실제 사람의 신체 움직임을 정확하게 재현한 것은 아니고, VR에서 보이는 모습이 자연스러운지를 기준으로 값을 계속 조정했다.
몸통이 어느 방향을 바라볼지도 조금 애매했다.
처음에는 머리 방향을 그대로 몸통에 적용했는데, 플레이어가 고개만 돌렸는데 몸 전체가 같이 돌아가는 문제가 있었다.
그래서 양손의 중간 위치를 구해서 양손이 향하고 있는 방향도 같이 사용했다.
Atan2(center.z, center.x)
여기에 머리 방향을 같이 반영하고 Slerp로 따라가게 했다.
고개는 자유롭게 움직이면서도 양손으로 물건을 조작할 때는 몸통이 어느 정도 따라오는 형태가 됐다.
발 트래커 없이 걸음걸이 만들기
전신을 맞추다 보니 발도 그냥 넘어갈 수 없었다.
발 트래커가 없으니 실제 발 위치를 알 방법이 없었다. 캐릭터 루트만 움직이면 몸은 이동하는데 발이 바닥에 붙어 있는 것처럼 보여서 생각보다 어색했다.
그래서 실제 발 위치를 찾는 대신 플레이어의 이동량을 이용해 간단하게 걸음걸이를 만들었다.
FixedUpdate에서 이전 위치와 현재 위치를 비교하고 일정 거리 이상 움직이면 보행 진행도를 증가시켰다.
발 높이는 사인 함수로 처리했다.
sin(StridePosition * 2π) * StepHeight
왼발과 오른발에는 위상 차이를 줬다. 이동 중에는 양발이 번갈아 올라가고, 멈추면 높이가 조금씩 줄어들면서 바닥으로 내려오게 했다.
사람의 걸음걸이를 제대로 복원한 시스템은 아니다. 그래도 트래커가 없는 상태에서 발이 그대로 붙어 있는 느낌은 어느 정도 없앨 수 있었다.
Grab한 물체가 플레이어를 밀어내는 문제
Grab을 구현하고 실제로 테스트하면서 물리 충돌 문제가 하나 생겼다.
드릴이나 해머를 잡은 상태에서 플레이어 몸에 가까이 가져가면 오브젝트 콜라이더와 플레이어 바디 콜라이더가 서로 부딪혔다.
PhysX 입장에서는 둘 다 충돌해야 하는 물체이기 때문에 당연한 동작이다. 문제는 VR에서 플레이어가 직접 잡고 있는 물건이라는 점이었다.
오브젝트가 몸을 밀면서 플레이어가 갑자기 튕기거나 HMD 화면이 흔들렸다.
VR에서는 이런 작은 움직임도 꽤 크게 느껴졌다.
Grab하는 순간 해당 오브젝트의 콜라이더를 찾아 플레이어 바디와 카메라 콜라이더의 충돌을 끄는 방식으로 처리했다.
public void InteractorObjectAdd(GameObject add_obj)
{
Collider[] colliders =
add_obj.transform.Find("Meshes")
.GetComponentsInChildren<Collider>();
foreach (Collider col in colliders)
{
Physics.IgnoreCollision(col, playerBodyCollider, true);
Physics.IgnoreCollision(col, mainCameraCollider, true);
}
}
Release하면 다시 충돌을 켰다.
그런데 여기서도 문제가 있었다.
물건을 플레이어 몸 안쪽에 놓는 경우가 있었기 때문이다. 충돌을 바로 복구하면 오브젝트가 몸을 밀면서 다시 튀어버렸다.
그래서 Release할 때 주변 상태를 확인하고 충돌을 복구하도록 수정했다.
손을 휘두르는 것을 타격으로 판단하기
해머나 주먹으로 물체를 부수는 기능도 만들었다.
처음에는 손의 속도를 이용하면 될 것 같았다. 그런데 실제 컨트롤러 데이터를 받아보니 작은 손 움직임이나 센서 노이즈가 계속 들어왔다.
속도만 가지고 판단하면 가만히 있는 것 같은 상황에서도 타격으로 잡히는 경우가 있었다.
그래서 다른 방법을 사용했다.
플레이어 중심과 손 사이의 거리 변화를 이용했다.
Δd = CurrentDistance - PreviousDistance
손을 앞으로 뻗으면 플레이어 중심에서 손까지의 거리가 증가한다.
그립 상태에서 이 값이 일정 수준 이상 증가하면 앞으로 휘두르는 동작으로 보고 타격력을 올렸다. 반대로 손을 다시 당기거나 움직임이 줄어들면 값을 빠르게 낮췄다.
이렇게 하니 단순히 손을 흔드는 것보다 실제로 무언가를 향해 손을 뻗는 동작을 구분하기가 나았다.
충돌 지점에서 파괴 가능한 대상과 접촉하고 타격력이 일정 값 이상이면 파괴를 실행했다.
파괴 대상은 여러 조각으로 나누고 충격 위치를 기준으로 파편이 튀어나오게 했다. 벽이나 장애물을 실제로 부수는 것처럼 보이게 하기 위한 연출이었다.
드릴과 나사
방탈출 기믹 중에서는 드릴을 사용하는 부분이 꽤 기억에 남는다.
버튼을 누르면 나사가 풀리게 만들 수도 있었지만 VR에서 굳이 드릴을 잡는 의미가 없어 보였다.
그래서 드릴을 잡은 상태에서 트리거를 누르고, 실제 드릴 비트가 나사 위치에 들어왔을 때만 동작하도록 만들었다.
흐름은 대략 이렇다.
드릴 Grab -> 트리거 입력 -> 모터 작동 -> 비트가 나사에 접촉 -> 나사 회전 -> 나사 이동 -> 나사 분리 -> 커버 해제
나사의 움직임 자체는 Rigidbody에 맡기지 않고 애니메이션 시간을 직접 조절했다.
public void TurnScrew()
{
if (ani_time <= 5f)
{
ani_time += 0.01f * (Time.deltaTime / 0.1f);
anim.speed = 0f;
anim.Play("ScrewOne", -1, ani_time);
}
}
드릴이 나사에 닿아 있고 모터가 돌아가는 동안 ani_time을 증가시켰다.
나사가 끝까지 풀리면 Rigidbody를 활성화해서 실제로 떨어지게 만들었다.
나사가 여러 개 있는 커버라면 각각의 나사가 풀렸는지 확인하고 전부 풀린 경우에만 커버 잠금을 해제했다.
드릴, 나사, 커버의 역할을 한 곳에서 전부 처리하지 않고 나눠놓은 것도 나중에 수정할 때 도움이 됐다.
잠금장치와 퍼즐 이벤트
방탈출 콘텐츠는 개발하면서 기획이 바뀌는 경우가 많았다.
처음에는 번호를 입력해서 문을 여는 방식이었다가 다른 방에서는 같은 형태의 잠금장치가 벽을 움직이는 데 사용되기도 했다.
그래서 입력과 실제 동작을 직접 연결하지 않았다.
숫자 버튼을 누르면 문자열 버퍼에 값을 넣고 정답과 비교한다.
정답이면 SolvedLockEvent를 발생시키고 이후에 무엇을 할지는 이벤트를 받은 쪽에서 처리하도록 했다.
예를 들어 DoorCtrl은 문을 열고 LockOpenWall은 벽을 움직이는 식이다.
이렇게 해두니 기획이 바뀌었을 때 잠금장치 코드를 다시 수정할 일이 줄었다. 인스펙터에서 이벤트 연결만 변경해서 같은 잠금장치를 다른 기믹에 사용할 수 있었다.
방탈출처럼 비슷한 형태의 퍼즐이 반복되는 프로젝트에서는 이런 식으로 분리해두는 게 편했다.
단서 조사와 씬 전환
방 안의 문서나 특정 오브젝트를 조사하는 기능도 구현했다.
플레이어가 단서 오브젝트를 집고 정면을 바라보면 관련 텍스트가 표시되는 방식이었다.
HistoryQuizManager, HistoryQuizObj를 사용해서 조사 대상과 퀴즈 데이터를 분리했다.
최종 퍼즐을 풀면 포탈을 통해 다음 방으로 넘어간다.
씬 전환에는 LoadSceneAsync를 사용했고 페이드 처리도 같이 넣었다.
VR에서는 화면이 갑자기 바뀌거나 플레이어 위치가 순간적으로 이동하면 불편함이 크기 때문에 로딩과 트래킹 릭 전환이 최대한 튀지 않도록 처리했다.
IK에서 발생했던 회전 플립
IK를 만들면서 한동안 잡지 못했던 문제가 회전 플립이었다.
특정 각도를 넘어가면 관절이 갑자기 반대쪽으로 돌아갔다.
처음에는 IK 수식이 잘못됐다고 생각했다. 그런데 하나씩 확인해보니 오일러 각을 직접 수정하는 부분이 문제였다.
360도 경계에서 값이 359 -> 0처럼 바뀌면서 이전 값과 차이가 크게 생겼고, 그 값이 다음 회전 계산에 들어가면서 관절이 튀었다.
결국 회전 계산을 Quaternion 중심으로 다시 구성했다.
Quaternion.LookRotation, Quaternion.AngleAxis 등을 사용하고 오일러 각을 직접 수정하는 부분을 줄였다.
이후 경계에서 발생하던 플립은 많이 줄었다.
IK를 만들기 전에는 위치 계산이 가장 중요한 부분이라고 생각했는데 실제로 구현해보니 회전 표현 방식과 경계값 처리도 같이 봐야 했다.
물리적으로 정확한 것과 플레이하기 좋은 것
VR 인터랙션을 만들면서 계속 손을 봤던 부분이 판정 범위였다.
PC에서는 마우스로 정확한 위치를 클릭할 수 있지만 VR에서는 사용자의 손이 매번 같은 위치에 오지 않는다.
드릴과 나사를 맞추는 기믹에서 이 문제가 특히 컸다. 실제 충돌 범위를 너무 작게 잡아놓으니 몇 mm 정도의 차이로 계속 실패했다.
그래서 검출용 Sphere Trigger는 실제 크기보다 조금 넓게 잡았다.
그리고 실제 작업이 시작되는 순간에는 드릴 팁을 목표 위치에 살짝 맞춰주는 Soft Snapping을 넣었다.
겉으로 보기에는 플레이어가 정확하게 드릴을 가져다 댄 것처럼 보이지만 내부에서는 별도의 조건으로 올바른 나사인지 확인하는 방식이다.
이런 부분을 작업하면서 VR에서는 물리적으로 정확한 것이 항상 좋은 것은 아니라는 생각을 하게 됐다.
플레이어가 분명히 하려고 했던 행동인데 몇 mm 차이로 실패한다면 시스템이 정확한 게 아니라 그냥 불편한 것이다.
재사용을 고려해서 만든 시스템
프로젝트 후반에는 이동 방식도 몇 가지 추가됐다.
기본적인 조이스틱 이동 외에 전방향 러닝머신인 OmniSDK, 차량 탑승과 관련된 SyncTranformCar 같은 시스템도 사용했다.
이때 상체와 손 인터랙션을 이동 시스템과 분리해놓은 것이 도움이 됐다.
조이스틱으로 움직이든 러닝머신을 사용하든 차량에 탑승하든 HMD와 양손을 기준으로 동작하는 IK와 인터랙션은 그대로 사용할 수 있었다.
새로운 이동 방식을 추가한다고 기존 IK 코드를 크게 수정할 필요가 없었다.
처음부터 거창하게 구조를 설계한 것은 아니지만, 서로 다른 기능이 직접 물려 있지 않도록 만든 부분은 결과적으로 잘한 선택이었다.
마무리
이번 프로젝트에서 VR 클라이언트를 개발하면서 일반적인 PC 게임과 다른 문제를 꽤 많이 경험했다.
트래커가 없으니 팔꿈치나 발의 위치를 직접 추정해야 했고, 물건을 잡으면 그 물건이 다시 플레이어를 밀어내는 문제도 있었다.
손의 움직임 역시 컨트롤러에서 들어오는 값을 그대로 사용하기에는 노이즈가 있었고, 드릴 같은 기믹은 실제 물리 충돌만으로 처리하면 오히려 플레이하기 어려웠다.
그래서 어떤 부분은 직접 계산했고, 물리 엔진을 사용하는 게 맞지 않는 부분은 별도의 로직으로 처리했다.
특히 HMD와 양손이라는 제한된 데이터만 가지고 팔과 상체를 만들어보면서, 가지고 있는 데이터만으로 어디까지 움직임을 추정할 수 있는지 직접 확인해볼 수 있었다.
VR에서는 현실과 똑같이 만드는 것보다 플레이어가 의도한 행동을 제대로 받아주는 것이 더 중요했다.
드릴을 조금 비껴 잡았다고 계속 실패하게 만들 필요는 없었다. 반대로 아무 곳에나 드릴을 가져다 대도 작동하면 기믹 자체가 의미가 없어진다.
결국 그 사이에서 적당한 판정 범위와 보정 방법을 찾는 작업이 필요했다.
이번 프로젝트는 이런 부분을 직접 구현하고 테스트하면서 VR 트래킹, IK, 물리 충돌, 인터랙션과 퍼즐 시스템을 실제 콘텐츠에 적용해본 경험이었다.