가구 운반 계단 등반 트러블슈팅
상한을 올리는 것과 상한을 푸는 것은 다르다
가구를 든 채 계단 앞에 서면 전진이 멈췄다. 상한을 두 번 올려도 안 됐고, 상한을 조건부로 푸는 것으로 풀렸다. 그다음엔 오르긴 오르는데 멈칫거리는 문제가 남았다.
가구를 혼자 들고 계단 앞에 서면 더 이상 앞으로 못 간다. 가구가 계단 챌면(한 단의 수직면)에 비벼지고, 그 접촉이 벽 가드에 벽으로 잡혀 전진 속도가 통째로 깎인다.
수정 전 — 의자를 든 채 계단 앞에서 16초를 오르내리며 붙어 있다
이 글은 이걸 세 번에 나눠 푼 기록이다. 첫 번째는 상한을 올려서, 두 번째는 상한을 조건부로 풀어서, 세 번째는 “오르긴 오르는데 멈칫거리는” 것을 없애서 끝났다. 각 단계에서 무엇이 부족했는지가 다음 단계의 설계를 정했다.
요구사항
- 계단·문턱은 가구를 든 채로 오를 수 있어야 한다
- 벽은 여전히 막혀야 한다 — 가구가 벽을 통과하거나 스르르 떠오르면 안 된다
- 경사로 동작은 바뀌면 안 된다
먼저 두 장치를 알아야 한다. 바닥 클램프는 운반 중 가구가 바닥을 파고들지 않게 필요한 만큼 위로 올려 주는 장치고, 벽 가드는 가구가 지형에 막혔을 때 운반자의 전진 속도를 깎아 가구가 벽을 뚫지 않게 하는 장치다. 이 둘은 같은 지형 접촉을 서로 다른 목적으로 읽는다. 그래서 둘의 판정 기준이 어긋나면 서로 싸운다 — 이 글의 절반은 그 기준을 맞추는 이야기다.
트러블슈팅 ① — 상한이 계단 한 단을 못 덮는다
증상 — 1인 운반만 정면 계단에서 막힌다
2인으로 들면 오르는데 혼자 들면 못 오른다. 이 비대칭이 첫 단서였다.
원인 — 여유가 고정 10cm였다
바닥 클램프는 아무 데서나 가구를 올려 주지 않는다. 운반자가 딛고 선 바닥까지만 책임진다. 발보다 높은 지형은 “오르는 중인 계단”이지 가구가 박힌 게 아니라고 보기 때문이다. 그 여유가 상수 10cm였다.
1
2
3
const float BottomZ = Center.Z - Ext.Z;
HeadRoom = FMath::Clamp(TopFootZ + FloorClampFootMargin - BottomZ, 0.0f, MaxRise);
// ~~~~~~~~~~~~~~~~~~~~ 고정 10cm
기준이 운반자 전원 중 가장 높은 발이라, 2인이면 한 명이 이미 한 단 위에 올라서 있어 기준 자체가 올라간다. 1인은 그럴 사람이 없다. 계단 한 단이 15~25cm인데 여유가 10cm면 가구 밑면이 챌면에 걸린 채로 남고, 그 접촉을 벽 가드가 벽으로 읽어 전진을 깎는다. 사슬이 두 번째 고리에서 끊긴다.
해결 — 벽 가드가 쓰는 기준과 같은 값으로
벽 가드는 이미 “발 + MaxStepHeight” 아래 접촉을 면제하고 있었다. CMC 스텝업이 처리할 낮은 단차이기 때문이다. 클램프의 여유도 같은 값으로 맞췄다.
1
2
3
4
5
6
7
8
if (const UCharacterMovementComponent* CMC = C->GetCharacterMovement())
{
TopStep = FMath::Max(TopStep, CMC->MaxStepHeight);
}
...
// 여유는 스텝 높이다 — 발보다 그만큼 높은 지형은 올라설 계단이지 가구가 박힌 것이
// 아니다. 벽 가드가 그 높이 접촉을 면제하는 기준과 같아야 둘이 싸우지 않는다
FootRoom = FMath::Clamp(TopFootZ + TopStep - BottomZ, 0.0f, MaxRise);
상수 10cm는 CMC가 없을 때의 하한으로만 남겼다. PIE 실측에서 발 Z가 102에서 372까지 올라가 계단 한 층을 통과했다.
트러블슈팅 ② — 발 기준으로는 영영 못 채우는 자리가 있다
증상 — 상한을 올렸는데 여전히 못 오르는 계단
①로 낮은 턱과 완만한 계단은 통과했다. 그런데 몸 앞에 낮게 들린 가구는 여전히 붙었다. 로그를 보면 답이 그대로 찍혀 있다.
1
[바닥클램프][서버] BP_Furniture_CoffeeTable_C_1 | 올림 +3.9 cm (필요 +90.3) | 상한 60 발상한 19.4 | 답면
필요한 올림이 90.3cm인데 발 기준 여유는 19.4cm다. 실제로 올린 건 3.9cm. 상한을 90으로 올려도 마찬가지다 — 여유 계산 자체가 TopFootZ + TopStep이라, 발이 아직 아래에 있는 한 그 위로는 못 올라간다.
원인 — 접촉하는 챌면이 딛는 단이 아니다
몸 앞에 낮게 든 가구는 자기가 서 있는 단의 챌면이 아니라 두세 단 위의 챌면에 걸린다. 그런데 여유는 “발 높이 + 한 단”이니까, 발이 그 높이까지 실제로 올라가기 전에는 필요량을 채울 수가 없다. 그리고 발은 가구가 막혀 있어서 못 올라간다. 닫힌 고리다.
해결 — 상한을 올리지 말고, 막힌 동안만 푼다
발 기준 여유(FootRoom)는 그대로 두고, 막힌 동안에만 상한을 MaxRise로 풀어 필요량을 따라 든다.
1
2
3
4
5
6
// 몸 앞 낮게 든 가구는 두세 단 위 챌면에 걸려 발 기준으로는 필요량을 영영 못 채운다 —
// 막힌 동안엔 필요량을 따라 든다. 진짜 벽은 시작부터 겹쳐 필요량이 0이라 발동하지 않는다
const bool bWantLift = bMoveInput && NowSec - LastClimbNeedTime < ClimbBlockLatchSec;
bLifting = bWantLift
&& (IsClimbLifting() || NowSec - LastClimbBlockTime < ClimbBlockLatchSec);
HeadRoom = bLifting ? MaxRise : FootRoom;
발동 조건은 셋이 동시에 성립할 때다.
- 벽 가드 접촉이 0.35초 이내에 있었다
- 홀더에게 이동 입력이 있다 (팔로워 입력은 합력으로 홀더에 실려 온다)
- 필요 올림이 발 기준 여유보다 크다
실측 대입: 필요 +90.3 / 발상한 19.4 / 상한 60 → 올림 60cm. 가구 바닥이 33.8에서 93.8로 올라가고, 계단 접점이 Z 66.4~91.3이므로 그 위로 넘어간다.
벽과 계단을 무엇으로 가르나
여기가 이 수정의 핵심이다. 접점 높이로 가르면 안 된다. 겹쳐서 시작한 히트의 접점은 MTD라 임의의 지점이고, 계단이든 벽이든 높이만으로는 구별되지 않는다.
가른 것은 필요 올림 자체다. 필요 올림은 가구 박스 위에서 아래로 쏘는 바닥 스윕(ECC_WorldStatic)에서 나오는데,
- 계단은 가구 밑에 답면(밟고 서는 수평면)이 있으니 “여기까지 올리면 안 박힌다”가 잡힌다 → 필요량 > 0
- 벽은 스윕이 시작부터 겹쳐
bStartPenetrating이라 → 필요량이 0
조건 3이 거짓이 되어 벽에서는 리프트가 발동 자체를 못 한다. 게다가 필요 올림은 올린 양을 뺀 자리에서 매 틱 다시 재므로, 리프트가 자기 발동 조건을 부풀리는 되먹임도 없다. 경사 분기는 손대지 않았고(경사에서는 플래그가 항상 꺼진다), 상한으로도 못 채우는 지형에서는 결손이 남아 차단이 그대로 산다.
곁들여 — 1cm 차로 벽이 되던 턱
같은 회차에 문턱 마진 5cm를 넣었다. 실측에 단차 45.9 (상한 45.0)처럼 1cm 차로 벽 판정을 받는 턱이 있었다. 중요한 건 이 마진을 네 곳에 같이 넣었다는 것이다 — 벽 가드 스윕 바닥 트리밍, 접촉 면제 기준, 팔로워 캡슐 검사, 그리고 클램프의 발 기준 여유. 한 곳만 넣으면 ①에서 맞춰 놓은 “둘의 기준이 같아야 한다”는 불변식이 다시 깨진다.
트러블슈팅 ③ — 오르긴 오르는데 멈칫거린다
증상 — 한 계단마다 걸리고 미는 느낌이 무겁다
리프트가 발동해서 오르기는 하는데 계단마다 툭툭 끊긴다. 두 증상(“계단 딜레이”와 “무거움”)을 따로 보고 있었는데, 로그를 재 보니 같은 원인이었다.
원인 — 세 갈래 왕복
[벽막힘] 17건이 전부 이렇게 찍혔다.
1
2
3
겹침시작 아니오 · 겹침깊이 0.0 · 탈출주입 0.0 · 위치복원 0.0 · 제거 속도 220~250
단차 51.9 / 67.9 / 105.5 / 65.9 / 62.4 (상한 45.0) 전부 높이초과
발 높이 102.1 → 121.1 → 191.0 → 231.1 → 285.6
발이 오르고 있으니 등반 자체는 되는데, 오르는 내내 걷기 전속(220~250cm/s)이 통째로 깎이고 있었다. 이 로그는 가구당 0.5초에 한 줄만 남기도록 묶여 있어 17건은 하한이고, 계단 구간의 모든 샘플에 줄이 있으니 사실상 상시 차단이다.
[바닥클램프] 27건은 계단리프트 ↔ 답면을 왕복한다. 답면으로 떨어진 줄을 보면 필요량이 +0.0이거나, 필요 +22.9인데 발상한 17.1·올림 5.9다. 리프트가 유지되지 못하고 풀려서 가구가 내려오고 다시 박히는 왕복 — 이게 멈칫거림의 정체였다. 원인이 셋 있었다.
- 못 잰 필요량을 0으로 읽었다. 바닥 스윕의 최근접 히트가 겹쳐 시작하면 필요량이 0으로 잡히는데, 그걸 그대로 쓰면 다 올린 가구를 도로 내려보내 스스로 챌면에 박는다.
- 되밀기가 12cm/프레임(=720cm/s)이었다. 가구가 올라가는 몇 프레임 동안 계단 앞에서 밀려나고, 밀려나면 필요 올림이 사라져 리프트가 꺼지고, 다시 다가가 또 막힌다.
- 추종 속도가 평지용이었다. 계단을 오르는 동안 필요 올림은 계속 커진다.
FInterpTo의 정상 오차는필요 증가율 / 속도라, 평지용 속도 10으로는 4~7cm 뒤처진 채 따라가고(올림 16.3~18.6 대 필요 20.2~23.5) 그 차이만큼 가구가 챌면에 박혀 전진이 멈춘다.
해결 — 네 군데
1
2
3
4
5
6
// 못 쟀다고 0으로 읽으면 다 올린 가구를 도로 내려보내 스스로 챌면에 박는다 — 계단에서
// 오르내림이 반복되는 원인이다. 지금 올린 만큼을 이어받아 자리를 지킨다
if (!bRiseMeasured)
{
Rise = FloorClampRise;
}
1
2
3
4
5
// 리프트 중엔 남은 올림에 비례해서만 깎는다 — 다 올라온 프레임부터 전진이 살아나 걸음이
// 끊기지 않는다. 올림이 필요를 못 따라가면 결손이 큰 채로 남아 차단이 그대로 산다
const float BlockScale = bLifting
? FMath::Clamp(ClimbLiftDeficit / FMath::Max(ClimbLiftBlockCm, 1.0f), 0.0f, 1.0f)
: 1.0f;
전진 차단을 전량이 아니라 남은 올림에 비례해서 건다. 다 올라온 프레임부터 전진이 100%로 돌아오니 걸음이 안 끊긴다. 되밀기(탈출 주입·위치 복원)는 리프트 중에만 끈다 — 이건 “더 박히는 것을 막는 장치”지 “이미 오르는 중인 가구를 밀어내는 장치”가 아니다.
추종 속도는 리프트 전용으로 분리했다(기본 30, 평지는 10 유지). 속도를 3배로 하면 정상 오차가 1/3로 준다.
그리고 결손을 잴 때 기준을 하나 더 조심했다.
1
2
3
// 상한(MaxRise)이 아니라 실제 필요량과의 차 — 다 올려도 못 채우는 지형에서는 결손이 남아야
// 벽 가드가 계속 막는다. Target 기준이면 상한에 붙는 순간 결손 0이 되어 뚫고 들어간다
ClimbLiftDeficit = bLifting ? FMath::Max(0.0f, Rise - Applied) : -1.0f;
Target은 상한에 걸린 값이라, 상한에 붙는 순간 결손이 0이 되고 전진 차단이 전부 풀린다. 그러면 상한으로도 못 채우는 지형에서 가구가 그냥 뚫고 들어간다. 결손은 반드시 실제 필요량 기준이어야 한다.
마지막으로 이동 입력 판정을 가속도 직독에서 기존 정의(HasMoveInputLatched)로 통일했다. 가속도를 직접 읽으면 원격 홀더에서 ServerMove 사이 프레임마다 0이라 리프트가 프레임 단위로 깜빡인다 — 클라이언트가 홀더일 때만 나오는 결함이라 로컬 테스트로는 안 보인다.
리프트 적용 빌드 — 같은 계단을 의자를 든 채 그대로 오른다
한 가지가 더 남는다. 판정이 서버 전용(클램프가 서버 전용)이라 결손 값을 복제한다. 안 그러면 클라이언트가 자기 폰만 계단 앞에서 되밀어 서버와 싸운다 — 실측 로그에 [벽막힘][클라] 위치복원 4.3이 있었다.
검증 — 자동 검사가 이 코드를 한 줄도 안 밟는다
세 회차 모두 기존 기능 자동 검사 5종 371항목 전부 통과다.
| 스위트 | 기대 | 실측 | 실패 |
|---|---|---|---|
공전 TC.Orbit.Regression | 120 | 120 | 0 |
그랩 TC.Carry.GrabRegression | 120 | 120 | 0 |
스핀 TC.Carry.GrabRegressionSpin | 120 | 120 | 0 |
겹침 TC.Grab.OverlapRegression | 9 | 9 | 0 |
클라부착 TC.Carry.ClientFxRegression | 2 | 2 | 0 |
| 합계 | 371 | 371 | 0 |
그런데 이 표는 이번 수정에 대해 아무것도 증명하지 않는다. 자동 검사 5종 로그에 바닥클램프·벽막힘 태그가 0건이다. 검사 시나리오에 단차가 없어서 새 코드 경로를 한 줄도 안 밟는다. 즉 이 371은 “다른 걸 깨지 않았다”만 말한다.
그래서 실증은 PIE 로그 대조로 따로 세웠다. TC.GrabV2.Debug 1을 켜고 계단을 오르며 두 태그만 본다.
| 볼 것 | 합격 | 불합격 신호 |
|---|---|---|
| 꼬리표 | 답면 → 계단리프트로 바뀌고 유지 | 계속 답면(조건 미달) / 왕복(래치 부족) |
| 올림 vs 필요 | 차가 2cm 이내 | 4~7cm 뒤처짐 (추종 속도 부족) |
| 필요량 | 계단 중간에 +0.0으로 튀는 줄 없음 | 튀면 필요량 이어받기가 안 먹은 것 |
| 벽막힘 제거 속도 | 0~80, 다 오른 프레임엔 줄 자체가 없음 | 220~250 상시 (비례 감속 미적용) |
| 벽 | 필요 +0.0 · 답면 유지, 가구가 안 뜸 | 벽 앞에서 계단리프트 뜨면 버그 |
| 경사 | 꼬리표 경사 유지 | 경사에서 계단리프트가 뜨면 버그 |
숫자를 코드에 굳히지 않고 세 개를 CVar로 뺐다. 리프트 상한이 실측 계단을 3cm 여유로 넘는 계산이라 여유가 얇다는 걸 알고 있었기 때문이다.
| CVar | 기본 | 언제 만지나 |
|---|---|---|
TC.GrabV2.ClimbLiftSpeed | 30 | 아직 뒤처지면 45~60, 팔로워가 들썩이면 20 |
TC.GrabV2.ClimbLiftBlockCm | 20 | 아직 무거우면 10, 가구가 계단을 파고들면 35 |
TC.GrabV2.FloorClampMaxRise | 60 | 높은 계단에서 못 넘으면 90 |
남은 천장
바닥의 다른 가구가 막는 경우는 이 수정 범위 밖이다. 바닥 클램프 스윕이 ECC_WorldStatic만 보기 때문에 그때는 필요 올림이 0으로 잡히고, 그러면 리프트도 안 듣는다. 채널을 넓히면 파괴 잔해까지 지면으로 세는 기존 결함이 되살아나므로 별도 회차 안건으로 남겼다. 현장에서는 가구를 치우고 같은 자리를 다시 통과해 보는 것으로 갈린다 — 통과되면 지형 턱이 아니라 가구 간 충돌이다.
결과
가구를 든 채 계단을 오른다. 벽은 여전히 막히고, 경사 동작은 그대로다. 세 회차를 지나며 배운 것은 방법보다 판정 기준을 어디서 가져오느냐였다.
핵심 요약 — 같은 지형 접촉을 두 장치가 다른 목적으로 읽고 있다면 판정 기준을 반드시 같은 값으로 맞춰라. 클램프의 여유와 벽 가드의 면제 높이가 어긋난 것이 1차 원인이었고, 문턱 마진을 네 곳에 함께 넣은 것도 같은 이유다. 그리고 계단과 벽처럼 겉보기가 비슷한 둘을 가를 때는 접점 좌표 같은 못 믿을 값 대신 그 상황에서만 정의되는 양(여기서는 “필요 올림”)을 찾아라 — 벽은 스윕이 겹쳐 시작해 그 값이 구조적으로 0이 되므로, 예외 처리 없이도 분기가 갈린다.