[TIL] 2026-07-16 — 1인 운반 정밀화: 클라 전진 벽 현상·급회전 관통·몸통 축 카메라 직결
리쉬 도입 다음 날, 클라이언트에서만 전진이 벽처럼 깎이는 새 증상을 만났다 — 서버는 실제 트랜스폼, 클라는 보간 위치로 부착점을 재는 기준 어긋남이 원인. 측정 기준 통일로 전날의 여유 반경 보정까지 걷어내고, 급회전 관통을 극좌표 접근으로 막은 기록.
문제: TeamCarry(4인 협동 이사 게임, 리슨서버 멀티)의 1인 운반에서 클라이언트만 전진이 사선으로 휘거나 벽처럼 막혔고, 카메라를 급회전하면 가구가 캐릭터 몸을 뚫고 반대편으로 넘어갔다. 해결: 전진 막힘은 리쉬 부착점을 재는 기준이 서버(실제 트랜스폼)와 클라(보간 위치)가 서로 달랐던 것이 뿌리라 측정 기준을 복제 트랜스폼으로 통일했고, 관통은 오프셋 성분(X/Y) 보간이 0점(몸 중심)을 지나는 문제라 극좌표 접근(방향 회전 + 거리 보간)으로 바꿨다. 성과: 기준 통일로 전날(7/15) 넣었던 “클라 여유 반경 -8” 보정의 전제가 사라져 함께 걷어냈고, 리쉬 반경을 서버·클라 동일 53으로 단일화했으며, 입력이 깎일 때만 발화하는 진단 로그(F9)로 재발 시 원인 항을 즉시 판별할 수 있게 했다.
1. 클라이언트만 전진이 벽처럼 막힌다 — 부착점을 재는 자가 서로 달랐다
증상: 1인 가구를 들고 전진하면 클라이언트에서만 입력이 깎여 사선 이동이 되거나 제자리걸음이 된다. 호스트는 정상. 전날 리쉬 이동 제한을 넣은 직후라 반경 튜닝부터 의심했지만, 값을 아무리 만져도 잡히지 않았다.
구조 분석: 리쉬 입력 필터는 “부착점에서 반경 R 밖으로 나가는 원심 입력 성분”을 깎는다. 문제는 부착점을 계산하는 기준이 양쪽에서 달랐다는 것.
- 서버: 가구의 실제 트랜스폼으로 부착점 계산
- 클라: 보간된 액터 위치로 계산 — 이동 중엔 보간이 서버보다 뒤처지므로 부착점이 항상 뒤로 밀려 있다
클라 기준 부착점이 실제보다 뒤에 있으니 순수 전진 입력도 “부착점에서 멀어지는 원심 성분”으로 오판되어 깎인다. 깎임이 비대칭이라 전진이 사선으로 휘고, 심하면 벽이 된다. 여기에 서버가 매 틱 다듬는 앵커 오프셋(테더·정면 복원)은 그랩·리셋 같은 이벤트 시점에만 멀티캐스트로 내려오기 때문에, 운반이 길어질수록 클라 부착점이 수십 uu까지 어긋나 있었다.
해결과 근거: 측정 기준부터 통일했다.
1
2
3
4
5
6
// 클라는 보간된 액터 위치가 서버보다 뒤처져(이동 중) 부착점이 뒤로 밀리고, 그 어긋난
// 기준으로 전진 입력이 깎여 사선 이동이 된다 — 복제된 서버 트랜스폼 기준으로 계산
const bool bAuth = Owner->HasAuthority();
const FVector FurnLoc = bAuth ? Owner->GetActorLocation() : ServerLocation;
const float FurnYaw = bAuth ? Owner->GetActorRotation().Yaw : ServerRotation.Yaw;
OutAttach = GetAttachedLocation(Player, FurnLoc - FVector(0.0f, 0.0f, CurrentHeightOffset), FurnYaw);
매 틱 변형되는 앵커 오프셋은 0.5초 주기 재동기화를 붙였다(Reliable RPC 과다 방지 스로틀). 이벤트 동기화만 믿는 상태가 서버에서 매 틱 다듬어진다면, 클라는 낡은 값으로 판정하고 있는 것이다.
흥미로운 건 그다음이다. 전날 “클라 여유 반경은 서버보다 좁게(-8)”라는 결론을 얻었는데, 이날 그걸 뒤집었다. -8은 측정 기준이 어긋난 상태에서 클라가 서버 한계 밖까지 걸어가는 걸 막던 보정이었다 — 기준을 통일하니 보정의 전제가 사라졌고, 좁은 반경은 정상 전진을 깎는 부작용만 남겼다. 반경은 운반 보행의 평형 간극(가구 추격 지연 + 가구 측 대칭 클램프 48)보다 넓은 53으로 서버·클라 동일하게 뒀다. 전제를 제거했으면 그 전제 위의 보정도 함께 걷어내야 한다.
마지막으로 입력 필터의 하드 컷을 10uu 소프트 숄더로 바꿨다. 운반 보행은 리쉬 경계 위에서 평형을 이루며 상주하는데, 경계에서 입력이 0/1로 끊기면 그 자체가 벽 체감이 된다.
1
2
3
4
// 소프트 숄더: 반경 초과 10uu에 걸쳐 점진 차단 — 하드 컷은 경계에 상주하는 보행
// 평형 상태에서 입력을 벽처럼 끊는다 (초과분이 클수록 강하게 차단)
const float Soft = FMath::Clamp((Dist - R) / 10.0f, 0.0f, 1.0f);
const FVector Result = (Outward > 0.0f) ? WorldInput - Away * (Outward * Soft) : WorldInput;
관성 클램프와 서버 안전망은 숄더 밖(R+10)에서만 개입하게 물러세웠다 — 필터가 허용한 부분 전진을 다른 안전망이 되막으면 도로 벽이다. 문턱값이 여럿이면 순서를 그린다: 입력 필터 소프트 구간(R~R+10) < 관성 클램프 = 서버 안전망(R+10~).
2. 급회전하면 가구가 몸을 뚫는다 — 성분 보간은 0점을 지난다
증상: 1인 운반 중 카메라를 크게 돌리면 가구가 캐릭터 몸을 관통해 반대편으로 넘어간다. 몸도 홱 돈다.
구조 분석: 원인은 “목표가 한 틱에 반대편으로 점프”하는 지름길 두 개였다.
- 카메라 회전 의도를 가구 목표에 전량 반영 — 급회전 시 제안 위치가 즉시 반대편으로 점프한다.
- 저장 오프셋을 X/Y 성분별 Lerp로 목표에 접근 — 목표 방향이 크게 돌면 보간 경로가 반대편으로 가는 최단 직선, 즉 0점(몸 중심)을 통과한다. 가구가 플레이어를 뚫는 직접 원인.
해결과 근거: 어느 경로든 호를 그리게 만들었다. 회전 의도는 실제 가구 회전 진행보다 ±45°까지만 앞서게 선행 제한을 걸고(2인 들것의 선행 제한과 동일 원리), 오프셋 접근은 극좌표로 분리했다.
1
2
3
4
5
6
7
8
// [극좌표 접근] 성분(X/Y) 보간은 목표 방향이 크게 돌 때(급회전) 경로가 0점을
// 지나 가구가 몸을 관통한다 — 방향은 회전, 거리는 보간으로 분리해 항상 호를 그린다
const float CurAng = FMath::RadiansToDegrees(FMath::Atan2(CurOff.Y, CurOff.X));
const float DesAng = FMath::RadiansToDegrees(FMath::Atan2(DesOff.Y, DesOff.X));
const float NewAng = FMath::DegreesToRadians(FMath::FixedTurn(CurAng, DesAng, 120.0f * DeltaTime));
const float NewLen = FMath::FInterpTo(CurOff.Size(), DesOff.Size(), DeltaTime, 3.0f);
Anc->InitialOffset.X = NewLen * FMath::Cos(NewAng);
Anc->InitialOffset.Y = NewLen * FMath::Sin(NewAng);
방향은 FixedTurn(120°/s)으로 돌고 거리는 FInterpTo로 접근하니, 목표가 정반대에 있어도 경로는 원호다. 속도도 동조시켰다 — 1인 가구 회전 상한을 2배(180°/s)로 올리고, 몸통 회전은 지수 보간의 초반 폭주를 막기 위해 보간 속도 0.4배 + 스텝 상한 180°/s. 몸과 가구가 같은 각속도로 함께 돈다.
정면 복원의 기준도 카메라에서 몸통 실제 방향으로 바꿨다. 같은 날 오전에 카메라 기준으로 통일했던 걸 저녁에 다시 뒤집은 것인데, 카메라는 즉각 점프하는 목표라 복원 경로가 몸을 관통하기 때문이다. 몸은 카메라를 완속으로 쫓는 중간 매개이므로, 카메라 → 몸(완속 추종) → 가구(몸 기준 복원) 체인으로 두면 가구가 자연히 호를 그리며 따라온다.
3. 몸통 축 카메라 직결이 깨운 동결 버그 두 개
1인 운반의 몸통 목표를 매 틱 카메라 정면(GetBaseAimRotation().Yaw) 기준으로 직결하는 개편을 하면서, 잠복해 있던 버그 두 개가 같이 드러났다.
리셋 재기록이 몸을 휙 돌린다: 회전막힘 리셋은 가구 기준각(InitialFurnitureYaw)을 현재 각으로 재기록한다. 이때 몸통 기준(InitialPlayerYaw)을 그대로 두면 둘의 상대 관계가 그랩 시점으로 되감겨 몸통 목표가 순간 점프한다. 기준각 하나를 재기록하면 그 기준으로 파생되는 다른 기준도 델타 시프트로 함께 옮겨야 연속성이 보존된다.
1
2
3
4
5
// 몸통 기준은 델타 시프트로 연속 보존 — 가구기준만 현재로 재기록하면
// 몸통 목표가 그랩 시점 값으로 되돌아가 몸이 휙 돌아간다
Anc.InitialPlayerYaw = FRotator::NormalizeAxis(Anc.InitialPlayerYaw
+ FMath::FindDeltaAngleDegrees(Anc.InitialFurnitureYaw, ActualYaw));
Anc.InitialFurnitureYaw = ActualYaw;
무입력 1인의 제자리 회전 동결: 리쉬 모드는 이동 입력이 없는 운반자를 피동(dragged)으로 분류해 회전 의도 가중치에서 제외한다. 2인+에선 맞는 설계다 — 끌려가는 사람의 시선은 회전 의도가 아니니까. 하지만 1인 운반자는 유일한 의도원이라, 피동으로 지우면 제자리에서 카메라를 돌려도 가중치 0으로 통째로 무시돼 가구·몸이 얼어붙는다. 피동 추적을 2인+에만 적용해서 풀었다. 다수결·가중치 설계는 참여자가 1명일 때의 경로를 반드시 따로 확인해야 한다.
4. 운반 링이 몸을 뚫고 보인다 — 깊이 비교의 여유값이 판정 해상도다
증상: 운반 중 가구가 몸에 밀착하면, 캐릭터에 가려진 가구의 포커스·운반 링이 몸 위로 비쳐 보인다. 벽 너머 X-ray는 임박(빨간) 링에만 의도한 기능인데, 일반 링까지 투시가 됐다.
구조 분석과 해결: 포스트 프로세스 아웃라인(M_PP_OutlineHLSL)의 가시부 판정이 커스텀뎁스-씬뎁스 비교에 여유를 50uu나 주고 있었다. 몸에 밀착한 가구는 가림 깊이 차가 그 안에 들어와 전부 “보이는 부분”으로 오판된 것. 판정을 이웃 지점 씬뎁스 비교로 바꾸고 여유를 5uu로 조여서 해결했다. 7/13에 정리한 “커스텀뎁스는 가림을 모른다 — 가시부 한정은 씬뎁스 비교로”의 연장선인데, 이번 교훈은 하나 더 나아간다: 깊이 비교의 여유값이 곧 판정 해상도다. 여유는 Z-파이팅만 막을 만큼 최소로.
오늘의 교훈
- 분산 판정은 측정 기준부터 통일한다. 서버는 실제 트랜스폼, 클라는 보간 위치로 같은 반경을 재면 같은 코드가 다른 답을 낸다. 어긋난 측정을 여유값 보정으로 덮으면, 나중에 기준을 고쳤을 때 그 보정이 부작용이 된다.
- 매 틱 다듬는 상태를 이벤트 동기화에 맡기지 마라. 그랩·리셋 시점에만 내려보내는 값이 서버에서 매 틱 변형되면, 클라는 항상 낡은 값으로 판정한다 — 주기 재동기화를 붙인다.
- 경계에 상주하는 시스템에 하드 컷은 벽이다. 평형점이 경계 위에 있으면 0/1 차단 대신 소프트 숄더로, 다른 안전망은 숄더 밖으로.
- 위치 접근은 성분 보간이 아니라 극좌표로. X/Y Lerp는 목표가 반대편일 때 중심(0점)을 관통한다. 방향 회전 + 거리 보간이면 경로는 항상 호다.
- 기준각을 재기록하면 파생 기준은 델타 시프트로 함께. 하나만 갱신하면 나머지가 과거로 되감긴다.
- “의도 없음 = 제외” 규칙은 의도원이 하나일 때 시스템을 죽인다. N=1 경로는 따로 검증한다.
오늘 배운 것
- 분산 시스템의 판정 불일치는 로직 차이보다 측정 기준 차이(실제 트랜스폼 vs 보간 위치)를 먼저 의심한다 — 기준을 통일하면 그 위의 보정 계층도 함께 걷어낼 수 있다
- 성분(X/Y) 보간은 목표가 반대편으로 돌 때 0점을 관통한다 — 방향 회전 + 거리 보간의 극좌표 분리로 경로를 항상 호로 만든다
- 경계 위에서 평형을 이루는 이동 시스템은 하드 컷 대신 소프트 숄더로, 여타 안전망은 숄더 밖에서만
면접에서 이렇게 말한다
- 예상 질문: “서버·클라이언트에서 같은 코드가 다른 판정을 내면 어디부터 보시겠어요?” → 이 사례로 답변 — 로직은 동일했고, 판정의 입력(부착점)을 재는 기준이 서버(실제 트랜스폼)와 클라(보간 위치)로 달랐다. 기준 통일 후 전날의 여유 반경 보정이 오히려 부작용이 되어 함께 제거했다는 것까지.
- 꼬리 질문: “보간 지연은 어쩔 수 없지 않나요?” → 렌더링은 보간 위치를 쓰되, 판정은 복제된 서버 상태로 — 용도별로 기준을 분리하면 된다. 매 틱 변형되는 서버 상태는 이벤트 멀티캐스트에 안 실리므로 주기 재동기화(0.5s 스로틀)를 병행.
- 키워드: interpolation lag, replicated transform, soft shoulder, polar interpolation, delta shift