포스트

[TIL] 2026-07-14 — 공동 운반 견인 동기화와 콜리전 수술: 관통 동결·평형점 계산·면도날 판

[TIL] 2026-07-14 — 공동 운반 견인 동기화와 콜리전 수술: 관통 동결·평형점 계산·면도날 판

TeamCarry(4인 협동 이사 게임, 리슨서버 멀티) 공동 운반 시스템 트러블슈팅으로 하루를 통째로 썼다. 트레이스에 안 잡히던 가구 6종의 정체가 콜리전 쿡 데이터 손상이었고, 바닥에 1uu 박힌 가구가 모든 조작을 무력화하는 스윕 관통 동결을 해제 밸브와 CCD로 풀었다. 어제 세운 “서버는 원격 폰 속도에 손대지 않는다”는 원칙이 견인을 무효화하고 있었다는 걸 로그로 확정해 하루 만에 정정했고, 견인 램프를 조이다 만난 30Hz 애니메이션 떨림에서 평형점과 판정 반경의 경계 계산이라는 교훈을 얻었다. 마무리는 TV의 2uu ‘면도날 판’과 침대-매트리스 상호 관통을 걷어낸 콜리전 수술.

1. 안 잡히는 가구의 정체 — 콜리전 쿡 데이터 손상

증상: 특정 가구 6종이 잡기 스캔(박스 트레이스)에 전혀 안 잡힌다. 컴포넌트를 아무리 뒤져도 이상이 없다 — 콜리전 프로파일, 채널 응답, Query and Physics, 전부 다른 멀쩡한 가구와 동일.

진단: 속성이 아니라 실체가 없었다. 씬 쿼리는 컴포넌트 속성이 아니라 물리 바디를 상대한다. 이 여섯 에셋은 스태틱메시의 콜리전 데이터가 손상돼 물리 바디 자체가 생성되지 않고 있었다. 속성은 “바디가 생기면 이렇게 동작하라”는 의도일 뿐이고, 바디가 없으면 트레이스에게 그 가구는 유령이다.

해법: 에디터에서 해당 스태틱메시들의 박스 콜리전을 제거 후 재구성하고, 에디터를 재시작해야 물리 바디가 재생성됐다. 재구성만 하고 세션을 유지하면 손상된 쿡 결과가 그대로 살아 있어 증상이 계속된다.

교훈: 트레이스 미스가 나면 속성 비교보다 먼저 바디 존재부터 확인한다. “설정은 전부 정상인데 안 잡힌다”는 말 자체가 이미 에셋 데이터 계층을 가리키고 있다.

2. 스윕 관통 동결 — bStartPenetrating과 해제 밸브

증상: 어떤 가구가 어느 순간부터 들기도, 끌기도, 문턱 스텝업도 전부 안 된다. 영구 동결.

원인: 언리얼의 SetActorLocation(..., /*bSweep=*/true)는 이동 시작 시점에 이미 다른 콜리전과 겹쳐 있으면(bStartPenetrating) 이동을 t=0에서 거부한다. 가구가 바닥에 1~2uu만 박혀 있어도 모든 스윕 이동이 첫 프레임에 기각되므로, 이동으로는 영원히 빠져나올 수 없는 자기 잠금 상태가 된다.

해법 — 해제 밸브: 이동 스윕이 시작-관통을 보고하면, 겹침이 풀릴 때까지 소량씩 수직 텔레포트(스윕 없이)로 꺼낸 뒤 이동을 재시도한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FHitResult MoveHit;
Owner->SetActorLocation(NewLocation, true, &MoveHit);
if (MoveHit.bStartPenetrating)
{
    // 스윕이 t=0에서 기각된 상태 — 이동으로는 탈출 불가.
    // 겹침이 풀릴 때까지 소량 수직 텔레포트로 꺼낸 뒤 재시도한다.
    FVector Probe = Owner->GetActorLocation();
    while (StillOverlapping(Probe) && LiftAccum < MaxLiftPerTick)
    {
        Probe.Z   += LiftStep;
        LiftAccum += LiftStep;
    }
    Owner->SetActorLocation(Probe, false);   // 텔레포트로 탈출
}

함정 — 검사 채널의 완전성: 처음에는 탈출 판정(StillOverlapping)이 WorldStatic만 검사했다. 그러자 동적 오브젝트에 박힌 가구는 “정적 겹침 없음 = 탈출 성공”으로 오판하고, 이동 스윕은 여전히 기각되니 매 틱 밸브가 헛돌았다. 정적+동적을 전부 넣고서야 밸브가 실제 스윕 판정과 같은 세계를 보게 됐다. 탈출 조건 검사와 이동 거부 검사가 같은 집합을 봐야 밸브가 성립한다.

3. 놓을 때 바닥 터널링 — 릴리즈 CCD

관통 동결의 주 공급원도 찾았다. 기울어진 자세로 가구를 놓으면 접지한 모서리를 축으로 빠르게 회전하며 낙하하는데, 이산 충돌 판정만으로는 프레임 사이에 가는 다리가 얇은 바닥을 그냥 통과(터널링)한다. 다리가 바닥 아래에 박힌 채 안착하면 그대로 2절의 영구 동결로 이어진다.

릴리즈 시점에 CCD(연속 충돌 감지)를 켜는 것으로 해결했다.

1
2
3
4
// 릴리즈 직후의 회전 낙하는 이산 판정으로는 얇은 바닥을 터널링한다.
// 물리 활성화 전에 CCD를 켜 프레임 사이의 스윕을 보장.
Prim->BodyInstance.bUseCCD = true;
Prim->SetSimulatePhysics(true);

상시 CCD는 비용이 아까우니 릴리즈~안착 구간에만 쓰면 된다. 빠른 회전 + 얇은 지오메트리 + 이산 판정, 세 조건이 겹치면 터널링은 확률이 아니라 시간문제다.

4. 견인이 원격 폰에는 무효였다 — CMC 예측의 함정, 2일차

어제의 결론을 하루 만에 정정한다.

어제의 설계: 동료를 가구 쪽으로 끌어오는 견인 속도를 “서버는 원격 폰의 속도를 건드리지 않고, 멀티캐스트를 받은 소유 클라이언트만 주입”으로 바꿨었다. 서버가 예측 파이프라인 밖에서 CMC를 덮어쓰며 생기던 보정 텔레포트를 없애려는 목적이었고, 실제로 러버밴딩은 사라졌다.

오늘의 발견: 그런데 견인 자체가 원격 폰에는 작동하지 않고 있었다. 구조를 따라가 보면 필연이다.

  1. 소유 클라이언트가 견인 속도를 주입받아 움직인다 (로컬 예측)
  2. 클라이언트는 ServerMove로 이동을 보고하는데, 여기 실리는 건 가속도다 — 속도가 아니라
  3. 서버는 받은 가속도를 자신이 아는 시작 속도에서 재적분한다. 서버는 견인 속도를 주입한 적이 없으므로 시작 속도는 0 근방 → 서버 측 캐릭터는 제자리
  4. 서버 위치와 클라 위치가 어긋남 → ClientAdjustPosition이 클라이언트를 서버 위치로 되돌림
  5. 견인 순효과 0. 클라이언트는 끌려가다 되돌려지기를 반복한다

로그로 확정: 견인 시작/도달/이탈을 기록하는 로그를 심어놓은 게 결정적이었다. 견인 시작 후 1초 만에 목표 이탈 거리가 52 → 303uu로 벌어지는데 서버 측 이동량은 0. “견인이 약한가?”가 아니라 “견인이 서버 세계에는 존재하지 않는다”였다.

해법 — 불개입이 아니라 동일 개입: 서버와 소유 클라이언트가 같은 견인 속도를 같은 틱 흐름에서 각자 주입한다. 클라 예측의 시작 상태와 서버 재생의 시작 상태가 같아지면 재생 결과가 일치하고, 결과가 일치하면 보정 자체가 발생하지 않는다.

1
2
3
4
5
6
7
// ✗ 어제 — 서버 불개입: 서버는 원격 폰 속도에 손대지 않고 소유 클라만 주입
//   → ServerMove는 가속도만 나르므로 서버 재적분 결과는 제자리, 보정이 견인을 지운다

// ✓ 오늘 — 동일 개입: 서버 틱과 소유 클라이언트가 같은 값을 주입
//   양쪽 재생의 '시작 속도'가 일치 → 결과 일치 → ClientAdjustPosition 자체가 안 뜬다
CMC->Velocity = FVector(PullVelocity.X, PullVelocity.Y, CMC->Velocity.Z); // 서버 틱
Multicast_ApplyPullVelocity(P, PullVelocity);                             // 소유 클라도 동일 값

어제의 교훈 “서버가 예측 파이프라인 밖에서 개입하면 보정이 터진다”는 여전히 옳다. 오늘 추가된 절반은 — 서버가 아예 개입하지 않으면 클라 개입이 보정에 지워진다. 예측 시스템에서 외부 속도의 정답은 ‘한쪽만’이 아니라 ‘양쪽이 같게’다.

5. 들것 조작 체계 — 축과 조향

2인 운반의 회전 모델(카메라가 아니라 ‘두 운반자를 잇는 선’의 회전을 추종)에 오늘은 조작 체계를 얹었다. 목표는 무빙아웃식 협동 감각: 서 있는 동료는 축, 걷는 사람이 조향.

  • 조향 가중: 가구의 목표 위치를 두 운반자 위치의 가중 평균으로 잡되, 이동 입력 중인 운반자에게 가중치를 몰아준다. 한쪽이 서 있으면 가구가 그 사람 쪽에 정박한 채, 걷는 쪽의 궤적이 회전으로 변환된다 — 제자리에 선 동료를 축으로 걸어서 코너를 도는 조작이 자연스럽게 성립한다.
  • 팽팽한 줄 모델: 피동 운반자(끌려오는 쪽)는 가구로부터의 한계 반경을 넘을 때만 견인된다. 반경 안에서는 자유 — 그래서 축 역할(제자리 유지)이 가능하고, 반경을 넘으면 줄이 팽팽해지듯 끌려와 직선 이동 추종도 된다. 축과 추종이 상충하지 않고 반경 하나로 양립한다.

6. 평형점과 히스테리시스 경계 — 애니메이션 발발 사건

증상: 견인 램프(벌어진 거리를 닫는 시간)를 0.05초로 조이자, 끌려오는 캐릭터의 애니메이션이 30Hz로 덜덜 떨리기 시작했다.

원인 — 평형점이 판정 반경 안으로 들어왔다: 등속 주행 중인 견인의 정상 상태를 계산해 보면, 피견인자는 목표에서 이동속도 × 램프시간만큼 뒤처진 평형점에 수렴한다. 250uu/s × 0.05s = 12.5uu. 그런데 도달 판정 반경이 25uu였다. 평형 이탈 거리가 도달 반경보다 작으니, 주행 중에 매 틱 이런 교대가 일어난다.

  1. 이탈 12.5uu < 도달 반경 25uu → “도달” 판정 → 견인 종료, 속도 0 주입
  2. 다음 틱, 가구는 전진했는데 나는 멈춤 → 이탈 증가 → “재견인” → 540uu/s 주입
  3. 1로 복귀. 속도가 틱마다 0 ↔ 540을 오가며 애니메이션 블렌드가 30Hz로 진동

해법: 도달 반경을 평형점 아래인 8uu로 내렸다. 주행 중 이탈(12.5uu)은 이제 도달 반경 밖이므로 도달 판정이 아예 뜨지 않고, 견인 속도가 일정하게 유지된다. 도달은 가구가 실제로 멈춰 평형 이탈이 0으로 수렴할 때만 성립한다.

교훈: 제어 파라미터 하나를 조일 때는 평형점이 어느 경계 구간에 떨어지는지 먼저 계산해야 한다. 램프 0.12초일 때는 평형 이탈 30uu > 도달 반경 25uu라 우연히 무사했던 것 — 파라미터 두 개가 만드는 부등호가 뒤집히는 순간 시스템이 진동자로 변한다.

7. 원격 피치 랩어라운드와 기울기 캡의 진화

기울기 제어에서 두 건.

피치 랩어라운드: 카메라 피치로 가구 높이를 조절하는데, 원격 플레이어의 피치(RemoteViewPitch 계열 압축 값)에서 144° 같은 [-90, 90] 밖 랩 값이 섞여 들어왔다. 두 운반자 피치의 평균이 오염되니 높이 목표가 ±500uu로 널뛰며 가구가 수직 텔레포트. 피치를 [-90, 90]으로 접고(90 초과는 180 - x로 미러) 높이 매핑 입력을 ±45°로 클램프해 해결했다.

1
2
3
4
5
// 원격 피치는 압축·복원 과정에서 [-90,90] 밖 랩 값이 나올 수 있다.
float Pitch = FRotator::NormalizeAxis(RawPitch);
if (Pitch >  90.f) { Pitch = 180.f - Pitch; }   // 144° → 36° : 위를 본다는 의미는 보존
if (Pitch < -90.f) { Pitch = -180.f - Pitch; }
Pitch = FMath::Clamp(Pitch, -45.f, 45.f);        // 높이 매핑 입력 클램프

기울기 캡: 혼자 2인 가구를 끌면 잡은 쪽만 들리는 연출 — 상한을 각도 고정(22°)으로 뒀더니 스팬 150uu짜리 작은 가구는 끝 들림이 반스팬 × sin(22°) ≈ 28uu밖에 안 돼 “안 들리는” 체감이었다. 그래서 ‘끝 들림 높이(~70uu)’ 기준으로 스팬에서 상한 각도를 역산하는 동적 캡으로 교체 — 큰 가구는 낮은 각도로, 작은 가구는 큰 각도로 같은 들림 높이를 만든다.

그런데 짧은 스팬에서는 역산 각도가 커진 상태에서 측정-보정 오버슈트가 겹쳐 순간적으로 45°를 넘겼고, ‘넘어짐’ 판정이 발동해 운반 모드가 널뛰는 부작용이 나왔다. 각도 소스를 하나하나 다듬는 대신 하드 안전핀으로 마무리 — 최종 회전 결과의 업벡터를 검사해 기울기 성분이 35°를 넘으면 그 틱의 기울기 갱신을 폐기한다. 상류에서 어떤 조합이 사고를 쳐도 하류의 한 줄이 막는다.

8. 박스 콜리전과 배치 겹침 — 수술

1절의 콜리전 재구성 과정에서 더 근본적인 문제들이 드러났다.

면도날 판: TV의 박스 콜리전이 두께 2uu짜리 판이었고, 심지어 동일 박스가 2중으로 중복돼 있었다. 얇은 판 콜리전은 회전 판정이 장애물을 그냥 통과하고(각도가 조금만 커도 판이 프레임 사이로 빠져나간다) 스윕도 모서리에서 미끄러진다. 에디터에서 실제 부피에 맞는 두께로 재구성했다.

배치 겹침의 연쇄: 그런데 두께를 키우자 이번엔 배치 상태의 상호 관통이 드러났다. 침대가 문제였다 — 프레임의 콜리전이 침대 전체를 덮는 풀-바운즈 박스 하나여서, 매트리스의 박스가 그 안에 완전히 포함돼 있었다. 결과 두 가지: 잡기 스캔이 위에 놓인 매트리스 대신 프레임을 잡고(트레이스가 바깥 박스에 먼저 맞는다), PIE 시작 순간 물리 엔진이 겹침을 해소하느라 가구가 사출된다.

수술은 3종 세트로.

  1. 프레임 박스 분할: 풀-바운즈 박스 하나를 ‘플랫폼 슬래브 + 헤드보드 판’ 두 개로 분할해, 매트리스가 놓이는 볼륨을 개방했다. 콜리전은 실루엣이 아니라 점유 공간을 모델링해야 한다.
  2. 매트리스 하단 슬림: 매트리스 박스 하단을 얇게 다듬어 슬래브와의 접촉 여유를 확보.
  3. 시작 시 물리 OFF: 배치된 가구는 시작 시 물리를 끈 정적 상태로 두고, 첫 그랩 이후부터 동적으로 전환. 시작 사출을 콜리전 정밀도와 무관하게 원천 차단하는 보험이다 — 남은 미세 겹침이 있어도 아무도 건드리기 전에는 폭발할 물리가 없다.

9. 로그 기반 디버깅 루프

오늘 십수 건의 버그를 잡은 엔진은 특정 수정이 아니라 로그 체계였다. 화면 디버그(단축키 토글 — 목표 선, 상태 색, 후퇴 화살표)와 별개로, 운반 시스템의 모든 상태 전이를 항상 로그로 남긴다: 그랩/해제, 견인 시작/도달/이탈, 가구 후퇴, 관통 밸브 발동, 자동 해제, 속도 강제, 체공 드랍.

1
2
3
4
5
6
7
8
DEFINE_LOG_CATEGORY_STATIC(LogCarry, Log, All);

// 엣지 트리거 — 상태가 '바뀐' 틱에만 기록. 지속 상태는 주기 스로틀로 요약.
if (bDragged != bWasDraggedLastTick)
{
    UE_LOG(LogCarry, Log, TEXT("[Pull] %s %s  dist=%.1f  vel=%.0f"),
        *P->GetName(), bDragged ? TEXT("START") : TEXT("END"), Dist, Vel);
}

전용 카테고리라 필터 한 줄로 운반 타임라인만 뽑히고, 엣지 트리거 + 스로틀이라 스팸이 없어 항상 켜둘 수 있다 — 그래서 재현 순간에 이미 증거가 확보돼 있다. 4절의 견인 무효(이탈 52 → 303uu, 서버 이동량 0)도, 6절의 도달↔재견인 30Hz 교대도, 전부 로그 타임라인 판독으로 확정했다. 재현 → 판독 → 원인 확정 → 수정의 루프. 로그의 가치는 “느낌상 이상하다”를 “어느 틱에 어떤 판정이 뒤집혔다”로 바꿔주는 데 있다.

오늘 배운 것 정리

  1. 트레이스 미스는 속성보다 물리 바디의 존재부터 의심한다. 콜리전 쿡 데이터가 손상되면 컴포넌트 설정이 완벽해도 씬 쿼리에는 유령이다 — 에디터에서 콜리전 재구성 + 에디터 재시작으로 바디를 재생성.
  2. 스윕 이동은 시작-관통이면 t=0에서 기각된다. 1uu만 박혀도 영구 동결 — 해제 밸브(소량 수직 텔레포트 후 재시도)가 필요하고, 밸브의 겹침 검사는 이동 스윕과 같은 채널 집합을 봐야 한다. 정적만 검사하면 동적에 박힌 경우 매 틱 헛돈다.
  3. 빠른 회전 낙하 + 얇은 지오메트리 + 이산 판정 = 터널링. 릴리즈~안착 구간에만 CCD를 켠다.
  4. 예측 시스템의 외부 속도는 ‘한쪽만’이 아니라 ‘양쪽이 같게’. ServerMove는 가속도만 나르므로 서버가 불개입하면 재적분 결과가 제자리가 되고, 보정이 클라의 견인을 지운다. 서버와 소유 클라가 같은 값을 주입해 재생 결과를 일치시키면 보정 자체가 안 뜬다.
  5. 제어 파라미터는 평형점이 어느 경계에 떨어지는지 계산하고 조인다. 평형 이탈(속도×램프)이 도달 반경보다 작아지는 순간 도달↔재견인이 틱 주기로 교대하는 진동자가 된다 — 도달 반경은 평형점 아래로.
  6. 네트워크로 넘어온 각도는 정의역부터 복원한다. 원격 피치의 랩 값([-90,90] 밖)이 평균에 섞이면 수직 텔레포트 — 접고(미러) 클램프한 뒤에 쓴다.
  7. 연출 상한은 각도가 아니라 결과량(끝 들림 높이) 기준으로 역산하되, 최종 결과에 하드 안전핀을 하나 둔다. 상류 조합이 어떤 사고를 쳐도 업벡터 검사 한 줄이 막는다.
  8. 콜리전은 실루엣이 아니라 점유 공간을 모델링한다. 면도날 판은 회전을 통과시키고, 풀-바운즈 박스는 위에 놓인 물건을 삼킨다 — 분할·슬림으로 볼륨을 열고, 배치 가구는 첫 그랩 전까지 물리를 꺼 시작 사출을 원천 차단.
  9. 상태 전이 로그는 항상 켜둘 수 있게 설계한다(전용 카테고리 + 엣지 트리거 + 스로틀). 재현 순간에 증거가 이미 있어야 루프가 돈다.

돌아보면 오늘 잡은 버그 중 단일 원인짜리는 하나도 없었다. 예측 파이프라인, 스윕 규칙, 히스테리시스 경계, 콜리전 에셋 데이터 — 멀티플레이 물리 조작은 이 층들이 서로를 가리는 시스템 문제라서, “이상해 보이는 곳”을 바로 고치러 들어가면 반드시 엉뚱한 층을 파게 된다. 계측(로그·시각화)을 먼저 심고, 수치로 원인 층을 확정한 뒤에 고치는 습관이 멀리 도는 것 같아도 결국 오늘 하루 가장 빨랐다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.