가구 로테이션 적용 트러블슈팅
회전량이 아니라 회전의 기준을 잘못 잡고 있었다
가구를 잡는 순간 몸 앞에서 빙글 도는 결함을 세 번에 나눠 고쳤다. 매번 다른 자리였지만 원인은 같은 종류였다 — 회전을 어느 기준에서 재고 있었는가.
운반 게임에서 가구를 잡으면 손에 붙기 전에 몸 앞에서 한 번 크게 도는 증상이 있었다. 옆에서 잡아도 앞으로 돌아 눕고, 비스듬히 서서 잡으면 그 각도만큼 휙 돌았다. 처음엔 “회전 속도를 줄이면 되겠지” 하고 각속도를 만졌는데, 그건 도는 시간을 늘렸을 뿐 도는 각을 줄이지 못했다.
세 번에 나눠 고쳤고 매번 자리가 달랐다. 그런데 원인은 셋 다 같은 종류였다 — 가구가 얼마나 돌지를 결정하는 기준을 잘못 잡고 있었다. 이 글은 그 기준 세 개를 차례로 바로잡은 기록이다.
요구사항
- 옆에서 잡은 가구는 옆으로 든 자세 그대로 들려야 한다
- 잡는 프레임에 눈에 보이는 회전이 없어야 한다
- 서버와 클라이언트가 같은 결과를 내야 한다 (배치가 포즈에 의존하면 안 된다)
먼저 배치 식을 알아야 아래 이야기가 읽힌다. 가구의 목표 요는 이렇게 정한다.
1
TargetYaw = 몸요 + GrabYawOffset + SpinYaw;
GrabYawOffset은 잡을 때 한 번 정해지는 상수고, 부착 블렌드가 잡은 순간의 가구 요에서 이 목표까지 0.35~0.60초에 걸쳐 보간한다. 즉 화면에서 보이는 “잡을 때 도는 각”은 정확히 그랩 첫 프레임의 목표 − 현재 가구 요다. 세 결함 모두 이 한 줄을 어디서 재느냐의 문제였다.
트러블슈팅 ① — 목표가 하나뿐이라 180도를 돈다
증상 — 어느 방향에서 잡아도 정면으로 눕는다
1인용 가구는 잡으면 몸 정면으로 정렬된다. 그립이 가까운 면에 있어서 잡은 각을 그대로 두면 가구 몸통이 운반자 쪽을 향해 몸을 파고들기 때문이다. 그런데 그 정렬 목표가 저작 전방(에셋을 만들 때 정해 둔 가구의 앞면) 하나뿐이었다.
수정 전 — 상자를 잡는 순간 손 안에서 크게 돌아 눕는다
앞을 보고 잡으면 저작 전방까지 180도를 통째로 돌았다. 게다가 회전 피벗이 가구 중심이 아니라 그립점이라, 몸통이 그립 반경만큼의 호를 그리며 쓸려 간다. Chair4 기준 수평 그립 거리가 42cm였으니 그 반경으로 몸 앞을 훑는 셈이다.
원인 — 정렬 목표를 한 점으로 잡았다
정렬을 “저작 전방과 일치시키기”로 정의한 것이 문제였다. 사용자가 원한 건 정면 일치가 아니라 몸을 파고들지 않는 자세였고, 그 조건을 만족하는 자세는 하나가 아니다. 옆으로 든 자세도, 뒤집힌 자세도 그립이 몸 밖에 있으면 똑같이 성립한다.
해결 — 가장 가까운 90도 배수로 접는다
목표를 한 점에서 네 점으로 늘렸다.
1
2
// 가장 가까운 90도 배수로 스냅 — 옆에서 잡으면 옆으로 든 자세가 그대로 남고 회전은 45도 이하다
GrabYawOffset = FMath::RoundToFloat(AimGap / 90.0f) * 90.0f;
AimGap은 [−180, 180]이므로 90으로 나눠 반올림하면 {−2,−1,0,1,2}, 곱해서 {−180,−90,0,90,180}이 된다. −180과 180은 같은 요이고 최종 목표가 NormalizeAxis를 거치므로 따로 처리할 것이 없다.
| 잡은 각 | 스냅 결과 | 실제 회전량 |
|---|---|---|
| 0~45도 | 0 | 0~45도 |
| 45~135도 | ±90 (옆으로 든 자세 유지) | 45~0~45도 |
| 135~180도 | ±180 | 45~0도 |
최대 회전량이 45도로 떨어진다. 정확히 옆(90도)에서 잡으면 스냅 목표가 90이라 회전이 0에 수렴한다 — 이게 “옆으로 잡은 건 옆으로 그대로 들린다”의 구현이다.
여기까지 오는 데 한 단계를 더 거쳤다. 처음엔 2방향(0/±180)으로만 접어서 최대 회전이 180도에서 90도로 줄었는데, 옆 그랩이 여전히 앞뒤 중 하나로 눕는 문제가 남아 4방향으로 넓혔다.
트러블슈팅 ② — 기준을 ‘지금 몸’으로 재면 조향이 더해진다
증상 — 접었는데도 120도를 돌았다
4방향으로 접었으니 산술적으로는 45도가 상한인데, 실측에서 120도가 나오는 그랩이 있었다. 로그를 분해하니 이렇게 갈렸다.
1
정렬 88도 + 조향 32도 = 120도
정렬이 88도를 돌았다는 건 접기 판정 자체가 최단이 아니었다는 뜻이다.
원인 — 몸은 그랩 직후에도 계속 돈다
접기 판정을 그랩 순간의 몸 요로 재고 있었다. 그런데 1인 홀더의 몸은 잡은 뒤에도 조향이 시선 방향으로 계속 돌린다. 목표 식이 몸요 + GrabYawOffset이라, 그랩 이후에 몸이 돌아간 각이 가구의 세계 회전에 그대로 더해진다.
가구가 실제로 도는 세계 각을 식으로 쓰면 이렇다.
1
세계 회전 = |GrabYawOffset − AimGap| (몸요_최종 ≈ 시선 요)
| GrabYawOffset 선택 | 세계 회전 |
|---|---|
0 | |AimGap| |
±180 | |180 − |AimGap|| |
이 둘 중 작은 쪽을 고르는 것이 곧 세계 회전 최소화인데, 종전 식은 AimGap이 아니라 그랩 시점 몸 요 기준 각을 최소화하고 있었다. 두 기준의 차이가 정확히 “그랩 직후 몸이 시선으로 돌아간 각”이고, 그게 위의 조향 32도였다.
해결 — 몸이 수렴할 곳을 기준으로 삼는다
지금 몸이 아니라 몸이 수렴할 시선 요로 잰다. 조향 목표와 같은 소스를 쓰는 것이다.
1
2
3
4
5
// 접기 판정 기준은 그랩 시점 몸이 아니라 몸이 수렴할 시선 요다(몸돌림 '시선' 조향과 같은 소스).
// 지금 몸으로 재면 그 뒤 몸이 돌아간 만큼이 가구 세계 회전에 그대로 더해진다
const float AimGap = FMath::FindDeltaAngleDegrees(
(float)FRotator::NormalizeAxis(Carrier->GetBaseAimRotation().Yaw),
(float)Owner->GetActorRotation().Yaw);
기준을 바꾼 것이 실제로 판정을 뒤집었다는 직접 증거도 남았다. 기존 기능 자동 검사에서 정렬 대상 50건의 원시 정면차를 찍어 보니, 종전 기준에서는 50건 중 49건이 180.0도로 전건 뒤집힌 채 정착하고 있었다.
함께 걷어낸 것 — 매 틱 감쇠
원래 이 정렬은 GrabYawOffset을 매 틱 150도/초로 0까지 깎는 구조였다. 문제는 이 감쇠가 부착 블렌드와 다른 시계로 돈다는 것이다. 블렌드가 끝난 뒤에도 남은 각이 살아 있으면 그 구간은 “손 안에서 도는” 것으로 보인다.
목표를 그랩 시점에 확정하고 감쇠를 지웠다. 회전이 부착 블렌드 창에만 실리고, 블렌드 후 도는 경로가 구조적으로 사라진다. 죽은 손잡이가 된 TC.GrabV2.OrientSpeed CVar도 같이 제거했다.
수정 후 — 잡은 자세가 그대로 남고 손 안에서 도는 구간이 없다
PIE 실측으로 그랩 17회 전건이 정렬 회전 35도 이하였다(종전 최대 120도).
트러블슈팅 ③ — 2인 가구는 오프셋 기준이 어긋나 있었다
증상 — 비스듬히 서서 잡으면 그만큼 휙 돈다
1인이 정리된 뒤에도 2인 가구는 남아 있었다. 정면으로 서서 잡으면 멀쩡한데, 비스듬히 서서 잡을수록 크게 돈다.
수정 전 — 책장을 비스듬히 잡자 잡는 순간 통째로 돈다
원인 — 오프셋을 ‘가구를 향한 이론 요’로 쟀다
GrabYawOffset을 이렇게 계산하고 있었다.
1
2
BaseYaw = atan2(가구위치 - 캐리어위치); // 몸이 실제로 향한 방향이 아니다
GrabYawOffset = FindDeltaAngle(BaseYaw, 가구요);
배치는 몸요 + GrabYawOffset인데 오프셋은 BaseYaw 기준이다. 그랩 첫 프레임의 목표를 풀어 쓰면
1
TargetYaw(t=0) = 몸요 + (가구요 − BaseYaw) = 가구요 + (몸요 − BaseYaw)
즉 목표가 현재 가구 요와 (몸요 − BaseYaw)만큼 어긋난 채 시작한다. 부착 블렌드가 그 각을 창 안에 그대로 쓸어 담는 것이 스윙의 정체다. 몸과 “가구를 향한 방향”이 어긋날수록 — 비스듬히 설수록 — 커진다.
1인 가구는 ①②의 접기가 이 오프셋을 덮어써서 이미 가려져 있었고, 정원 2 이상만 남아 있었다. 그래서 “1인만 고쳐진 상태”로 보였던 것이다.
산수와 실측을 대조했다. CoffeeTable(정원 2) 그랩에서 정착 차 71.7도를 역산하면 BaseYaw = 90.0 − 71.7 = 18.3, 예측 스윙은 0.5 − 18.3 = −17.8도다. 로그 실측도 −17.8도로 소수점까지 일치했다. 그 구간 몸 요가 +0.5로 고정이었으므로 이 회전은 순수하게 그랩이 만든 것이다.
해결 — 기준을 지금 몸 요로
1
2
3
4
// 오프셋 기준은 지금 몸 요다 — 목표(몸 요 + 오프셋)가 첫 프레임에 현재 가구 요와 같아야
// 블렌드가 쓸 각이 0이 된다. '가구를 향한 요'로 재면 몸과의 차만큼이 잡는 순간 스윙으로 나온다
GrabYawOffset = FMath::FindDeltaAngleDegrees(
(float)Carrier->GetActorRotation().Yaw, (float)Owner->GetActorRotation().Yaw);
목표가 첫 프레임에 현재 가구 요와 일치하므로 블렌드가 쓸 각이 0이다. 정착 자세는 잡은 각 그대로이고(2인은 접지 않는다), 공전·조향·1인 경로는 건드리지 않았다.
수정 후 — 탁자·책장·소파·상자를 각기 다른 각도에서 잡아도 잡은 각 그대로 들린다
PIE 실측 비스듬한 그랩 7회 중 6회가 그랩 유발 회전 0.0도였다.
여기서 한 번 되돌린 것
처음에는 접기 게이트에서 GetCrewCapacity() <= 1을 지워 2인 가구도 90도 배수로 접게 했다. 자동 검사가 이걸 반증했다.
1
2
[실패] 2인 ⑨그랩요보존 — 최대 편차 120.05도 (허용 2.0)
[실패] 3인 ⑨그랩요보존 — 최대 편차 149.85도 | 근접면 -38~+40cm(설계 40) | 몸겹침 회차 1
2인 이상은 요 보존이 사양이고, 3인에서는 근접면 −38cm에 몸 겹침까지 떴다. 지웠던 게이트 주석의 경고(“큰 가구는 그립을 손에 둔 채 돌리면 반대쪽 끝이 몸으로 들어오고, 근접면 보정이 전방 성분만 밀어내 그 침범을 못 뺀다”)가 추측이 아니라 자동 검사로 재현되는 사실이었다. 단언을 무르지 않고 수정을 전량 되돌린 뒤 원인을 다시 찾은 것이 위의 오프셋 기준이다.
곁들여 — 회전이 아예 안 되던 반대 사례
같은 요 축에서 반대 방향 결함도 하나 있었다. 2인 공전(가구를 축으로 도는 동작) 전이 구간에서 클라이언트 캐릭터의 몸 요가 얼어붙는다.
원인은 그 창에서 몸 요를 쓰는 주체가 하나도 없다는 것이었다. 공전 플래그는 가구 채널로, 어태치는 캐릭터 채널로 와서 도착 순서가 어긋나는 창이 있고(진입 39회 중 25회 계측), 그 창에서는 어태치도 없고 CMC 회전도 꺼진 채 클라 추종만 공전 게이트에 막혀 있었다.
1
2
3
// 공전 중에도 돌린다 — 어태치가 늦거나 거부되면 몸을 돌리는 씬그래프가 없어 writer가 사라진다.
// 이중화는 FollowReplicatedBodyYaw의 어태치 스킵이 막는다
ApplyCarrierBodyYaw(DeltaTime);
게이트를 제거하고 추종을 무조건 돌린다. 부착된 몸은 추종 쪽의 어태치 스킵이 걸러 주므로 값을 쓰는 쪽이 둘이 되지는 않는다. 하나의 값을 여러 경로가 쓸 때는 “겹치지 않게 막는 것”보다 “쓰는 쪽이 하나도 없는 창이 생기지 않는지”를 먼저 봐야 한다는 사례로 남았다.
세 결함이 같은 질문이었던 이유
| 무엇을 기준으로 쟀나 | 그래서 더해진 회전 | |
|---|---|---|
| ① 1인 정렬 목표 | 저작 전방 한 점 | 최대 180도 |
| ② 1인 접기 판정 | 그랩 시점 몸 요 | 그 뒤 몸이 조향한 각 (실측 32도) |
| ③ 2인 오프셋 | 가구를 향한 이론 요 | 몸과 그 방향의 차 (실측 17.8도) |
셋 다 회전량을 줄이는 문제가 아니라 어느 기준에서 0을 정의하느냐의 문제였다. 각속도 CVar로 접근했을 때 아무것도 나아지지 않은 이유가 이것이다 — 목표가 틀린 자리에 있으면 천천히 가든 빨리 가든 결국 그 자리까지 간다.
기준을 고를 때의 판정법도 하나로 정리됐다. 배치 식에 실제로 들어가는 값과 같은 것으로 재라. 목표가 몸요 + 오프셋이면 오프셋은 몸 요 기준이어야 하고, 몸이 시선으로 수렴한다면 판정도 시선 기준이어야 한다. 식에 없는 값(가구를 향한 이론 요)으로 재면 그 차이가 고스란히 화면의 회전으로 나온다.
검증 — 하네스가 못 재는 각이 있었다
세 수정 모두 기존 기능 자동 검사 5종 371항목을 통과한 상태로 넣었다. 다만 그 검사가 이 결함들을 잡아 준 것은 아니다.
- ①은 자동 검사 단언 자체가 옛 사양(저작 전방 일치)을 박아 두고 있어서, 사양을 바꾸면서 단언도 90도 배수 기준으로 같이 고쳐야 했다. 어중간한 각(45도 등)은 여전히 실패하도록 남겨 커버리지는 잃지 않았다.
- ③은 하네스 더미가 정면으로만 서서 이 결함을 잴 수가 없다. 비스듬한 그랩이 시나리오에 없으니 자동 검사는 영원히 통과한다. 효과 근거는 위의 산수 대조와 PIE 실측이다.
그래서 이번 축의 실증은 자동 검사가 아니라 로그 두 줄을 같은 프레임에서 대조하는 것이었다. [진단]의 가구 요와 [몸돌림]의 현재 몸 요를 같은 타임스탬프로 놓고 차를 보면, 정렬이 완료됐는지·몇 도를 돌았는지가 그대로 나온다. 눈으로 “돈다/안 돈다”를 판정하다가 이 대조로 옮긴 뒤에야 88도와 32도가 갈렸다.
결과
옆에서 잡으면 옆으로, 뒤에서 잡으면 뒤로 든 자세 그대로 들린다. 1인은 최대 45도, 2인은 잡은 각 그대로다. 잡는 프레임의 눈에 보이는 회전은 사라졌고, 부착 블렌드가 끝난 뒤 손 안에서 도는 구간도 없다. 덤으로 블렌드 완료 후 구동이 실제로 멈추면서 클라이언트로 나가던 어태치 오프셋 스트리밍도 함께 끊겼다.
핵심 요약 — 화면에서 보이는 회전은
그랩 첫 프레임의 목표 − 현재 회전이다. 그러니 회전을 줄이려면 각속도가 아니라 목표를 봐야 한다. 목표를 정하는 기준은 배치 식에 실제로 들어가는 값이어야 하고, 그 값이 시간에 따라 움직인다면(몸이 시선으로 수렴한다면) 지금 값이 아니라 수렴할 값으로 재야 한다. 식에 없는 기준으로 재면 두 기준의 차가 그대로 화면의 회전이 된다.