포스트

운반 가구 벽 통과 트러블슈팅

차단이 없던 게 아니라 검사에서 빠지는 자리가 있었다

가구를 들고 벽으로 걸어 들어가면 가구가 벽을 파고들었다. 벽 차단 코드는 이미 있었고, 낮게 든 가구를 통째로 건너뛰는 조건과 접점 높이로 히트를 버리는 필터가 구멍이었다. 이어서 반동이 흔들림으로 남는 문제와 벽 앞에서 가구가 떠오르는 문제를 함께 고쳤다.

운반 가구 벽 통과 트러블슈팅

가구를 든 채 벽으로 걸어 들어가면 가구가 벽을 파고들었다. 제보를 받고 처음 확인한 것은 “차단 코드가 있는가”였는데, 있었다. ApplyWallGuard가 가구 OBB를 스윕해 운반자 속도의 법선 성분을 깎는 장치가 이미 돌고 있었고, 플레이 로그에도 [벽막힘]이 8건 찍혀 있었다.

그러니 질문이 바뀐다. 있는 차단이 왜 어떤 가구에서는 안 걸리는가.

요구사항

  • 운반 중인 가구가 벽·바닥·차단 볼륨을 뚫으면 안 된다
  • 계단·문턱은 그대로 오를 수 있어야 한다 — 벽 차단을 조이다 등반을 죽이면 안 된다
  • 소파·화분 같은 다른 가구는 차단 대상이 아니다 (가구끼리는 물리 밀어내기 소관)

먼저 이 시스템의 구조적 한계를 하나 알아 둬야 한다. 운반 중인 가구는 운반자에 부착돼 따라가고, 어태치 이동에는 스윕이 없다. 즉 벽 가드는 언제나 사후 교정이다 — 가구가 들어간 뒤에 밀어낸다. 로그에 찍힌 겹침 깊이 2.6~9.2cm가 눈에는 “벽을 파고듦”으로 보인다. 이 글의 수정은 그 사후 교정을 놓치지 않고 걸리게 하는 쪽이고, 사후 교정 자체를 없애려면 이동을 스윕으로 바꿔야 한다(자동 검사 371항목을 다시 쓰는 규모라 이번 범위 밖이다).

트러블슈팅 ① — 낮게 든 가구는 검사 자체를 건너뛴다

원인 — 계단 픽스에서 들어온 면제 조건

가구 상단이 운반자의 스텝 높이 이하면 박스 스윕을 통째로 건너뛰는 플래그(bSkipBoxSweep)가 있었다. 계단을 오를 수 있게 만들 때 들어온 조건이다 — CMC 스텝업이 알아서 넘길 낮은 단차를 벽으로 읽지 않게 하려던 것.

문제는 그 기준이 가구 쪽에도 걸린다는 것이다. 발이 102, 스텝 상한이 152인 상황에서 램프·전자레인지 같은 1인 소품은 상단이 그 아래라 검사에서 통째로 빠졌다.

해결 — 스텝 위에 얇은 판을 남겨 잰다

플래그를 없애고, 스윕 박스의 세로 구간 계산을 함수 하나로 뺐다.

1
2
3
4
5
6
void UTCGrabHandleComponent::ComputeWallSweepSpan(float CenterZ, float ExtZ, float StepZ, ...)
{
    const float FloorZ   = StepZ + WallSlabLiftCm;
    const float SweepTopZ = FMath::Max(TopZ, FloorZ + WallMinSlabCm);
    ...
}
가구의 세로 위치스윕 구간
스텝 위에 통째로 있다자기 박스 그대로
스텝에 걸쳐 있다바닥만 스텝 높이로 올림 (종전과 동일)
스텝 아래에 통째로 있다스텝 위로 6cm(WallMinSlabCm) 판을 남겨 잰다

계단 통과가 유지되는 근거는 한 줄로 정리된다. 스윕 박스의 바닥이 절대 스텝 아래로 내려가지 않는다. 계단 챌면과 문턱은 그 아래에 있으니 이 박스에 안 걸린다. 이 불변식은 말로 두지 않고 검사로 세웠다 — 신설한 스모크 5종 중 하나가 “박스 바닥이 스텝 아래로 안 내려간다”를 직접 잰다.

트러블슈팅 ② — 맞은 히트를 필터가 버렸다

①을 넣고도 통과가 남았다. 그런데 로그에는 아무것도 없다. 통과는 로그를 안 남기기 때문에 무엇이 빠지는지 눈으로 셀 수가 없었고, 코드에서 감지가 사라지는 경로를 갈라야 했다.

원인 — 접점 높이가 정확히 필터 경계였다

벽 가드는 운반자별로 “발 + 스텝 높이 이하의 접촉”을 면제한다(ImpactPoint.Z <= CStepZ). 낮은 단차를 벽으로 읽지 않기 위한 필터다.

①에서 낮게 든 가구의 스윕 박스 바닥을 정확히 StepZ 맞춘 것이 여기서 자충수가 됐다. 그 박스가 벽에 닿으면 접점은 늘 박스 바닥, 즉 정확히 StepZ다. 부등호가 <=니까 스윕은 맞았는데 판정에서 버려진다.

해결 — 판을 2cm 띄우고, 걸러진 히트를 남긴다

바닥을 WallSlabLiftCm(2cm)만큼 띄웠다. 계단·문턱은 여전히 이 판 아래라 안 걸린다.

같이 넣은 것이 더 중요하다. 걸러진 히트에 [벽막힘거름] 로그를 남겼다. 이번 진단이 오래 걸린 이유가 “빠지는 것은 아무 흔적이 없다”였으니, 다음 제보 때는 스윕이 맞았는데 버려진 것인지 애초에 안 맞은 것인지가 로그 한 줄로 갈린다.

트러블슈팅 ③ — 벽이 아닌 것까지 벽으로 세고 있었다

실측 — 막은 쪽이 소파와 화분이었다

플레이 로그의 [벽막힘] 28건을 ‘막은 쪽’으로 분류했다.

막은 쪽건수
벽 (StaticMeshActor)22
BlockingVolume2
소파·화분·수리 더미4

그리고 [벽충돌피해] 24건 중 4건이 그 소품들에서 나왔다. 전자레인지 하나는 체력이 52 → 44 → … → 0으로 깎여 파괴됐다. 가구를 옆 가구에 문지른 것이 벽에 박은 것과 같은 취급을 받고 있었다.

해결 — 스윕 대상을 정적 지오메트리만으로

스윕 채널에서 WorldDynamic·PhysicsBody를 빼고 WorldStatic만 남겼다. 벽·바닥·BlockingVolume은 전부 WorldStatic이라 그대로 걸린다.

이 한 줄이 세 가지를 동시에 정리한다. 스윕 하나가 차단·반동·내구도 감소의 공통 입구라, 대상을 좁히면 셋이 함께 정적 전용이 된다. 반대로 말하면 종전에는 “소파에 문질러 전진이 막히고, 반동으로 튕기고, 내구도가 깎이는” 것이 한 자리에서 동시에 나오고 있었다.

트러블슈팅 ④ — 반동이 자세를 영영 틀어 놓았다

벽에 박으면 반동이 나가야 게임필이 산다. 그런데 두 가지가 어긋났다.

증상과 원인

증상원인
가구만 돌아가 몸과의 상대각이 틀어진다반동 회전을 자세 정본 SpinYaw에 누적했다
벽에 밀고 있으면 달달거린다 (같은 벽 0.6~1.5초 간격 12건)벽에 붙어 있는 동안 매 프레임 히트가 나는데 그때마다 반동을 줬다

SpinYaw는 목표 요를 만드는 항이라 한 번 더해지면 영영 남는다. 잠깐 흔들리라고 준 값이 자세의 정의를 바꿔 버린 것이다.

해결 ① — 흔들림을 정본 밖에 둔다

WallWobbleYaw를 따로 뒀다. 목표 요에 더해지기만 하고 매 틱 0으로 감쇠한다.

1
TargetYaw = 몸요 + GrabYawOffset + SpinYaw + WallWobbleYaw;

정본(SpinYaw)은 건드리지 않으므로 흔들리고 나면 몸과의 상대각이 그대로 돌아온다. 부착 블렌드가 끝난 뒤의 조기 반환에는 흔들림 조건을 더해, 감쇠가 도는 동안만 구동이 살아 있게 했다.

해결 ② — 접촉과 충돌을 가른다

여기가 이 회차의 핵심이다. 벽에 닿아 있는 것은 상태고, 벽에 박는 것은 사건이다. 같은 히트 스트림에서 둘을 갈라야 한다.

1
2
// '떨어졌다 다시 박은 것'만 새 충돌로 본다
bool UpdateWallContact(const FHitResult& Hit, const FVector& N);

접촉 유지 판정은 셋이 동시에 성립할 때다.

  1. 같은 컴포넌트에 닿았다
  2. 법선 내적 > 0.85 (약 32도 이내 — 같은 면이다)
  3. 마지막 접촉에서 WallContactResetSec(0.35초) 이내다

접촉이 유지되는 동안에는 속도 깎기만 남는다. 반동도, 흔들림도, 내구도 감소도 새 충돌에만 발화한다. 내구도는 여기에 속도 임계(250cm/s)와 쿨다운(0.6초)이 더 붙어, 벽에 대고 밀어도 연속 차감이 나지 않는다.

CVar기본
TC.GrabV2.WallBounce0.35되밀기 = 파고든 속도 × 이 값 (새 충돌에만)
TC.GrabV2.WallBounceSpin12충격 순간 기우는 각도(도), 감쇠로 복귀
TC.GrabV2.WallWobbleDecay6흔들림이 0으로 돌아오는 속도(1/초)
TC.GrabV2.WallContactResetSec0.35이보다 오래 떨어져야 새 충돌
TC.GrabV2.WallDamageSpeed / WallDamage / WallDamageCooldown250 / 8 / 0.6내구도

트러블슈팅 ⑤ — 벽 앞에서 가구가 떠올랐다

벽 가드를 조이자 반대편 장치가 드러났다. 벽을 향해 전진 입력을 유지하면 가구가 주기적으로 들썩이며 떠올랐다.

원인 — 두 판정이 같은 지형을 다르게 읽었다

계단 등반용 리프트는 바닥 클램프의 아래방향 박스 스윕이 내는 ‘필요 올림’으로 발동한다. 그런데 그 스윕이 벽 위 모서리를 법선 Z 1.0인 딛는 면으로 읽어 Rise = 73.1을 냈다.

리프트 분기는 그 값이 상한(MaxRise 60)을 넘는데도 발동했고, 목표는 min(73.1, 60) = 60이 되어 그쪽으로 계속 올렸다. 못 넘으니 들썩임이 반복된다.

같은 순간 벽 가드는 단차 98 > 상한 45로 그 접촉을 거부하고 있었다. 한 지형을 놓고 한쪽은 “오를 계단”이라 하고 다른 쪽은 “너무 높은 벽”이라 하고 있었던 것이다.

해결 — 발동 조건을 상한과 일치시킨다

1
2
3
4
bool UTCGrabHandleComponent::ShouldClimbLift(float Rise, float MaxRise, bool bLatched)
{
    return bLatched && Rise <= MaxRise;
}

호출부는 이것만 쓴다. 필요량이 상한을 넘으면 리프트가 안 걸리고 여유가 발 기준으로 떨어져 부분 올림 통로까지 함께 닫힌다. 벽 가드의 ‘높이 초과’ 거부와 기준이 같아진다.

한 가지는 일부러 안 건드렸다. 필요량이 상한을 넘을 때 막힘 래치 갱신까지 막을까 했으나, 계단에서 필요량이 한 프레임 튀는 순간 래치가 풀리면 기존 계단 등반이 흔들릴 수 있다. 한 번 넣었다가 되돌리고 발동만 막았다.

검증 — 검사가 못 만드는 것과 만들 수 있는 것

기존 기능 자동 검사 5종 371항목은 세 회차 모두 통과 상태로 넣었다(그랩 120 · 스핀 120 유지). 다만 앞선 계단 회차와 마찬가지로 이 표는 “다른 걸 깨지 않았다”만 말한다.

이번 축에서 실제로 결함을 잡은 것은 새로 세운 헤드리스 스모크였다.

스모크 묶음신설무엇을 고정하나
벽 스윕 기하5종높이 든 가구 / 걸친 가구 / 낮게 든 가구 / 박스 바닥이 스텝 아래로 안 내려감 / 판 두께
반동5종첫 충돌은 새 충돌 / 같은 벽 계속 닿으면 무발화 / 미는 동안 무발화 / 다른 면은 새 충돌 / 흔들림 무누적
리프트 발동4종상한 이내 발동 / 딱 상한도 발동 / 필요 73.1 > 상한 60 무발동 / 래치 없으면 무발동

세 번째 묶음의 굵은 줄이 ⑤의 실측값을 그대로 단언으로 옮긴 것이다. 로그에서 읽은 숫자를 검사에 박아 두면 같은 지형을 다시 밟았을 때 판정이 뒤집혔는지가 바로 나온다.

반대로 스모크로 못 덮는 것도 분명했다. ‘가구끼리 무반동’과 벽 반동의 손맛은 살아 있는 운반이 필요해 헤드리스로 못 만든다. 그쪽 확인은 플레이 로그 두 가지로 갈랐다 — 막은쪽BP_Furniture_*가 안 나오는지, 같은 벽에서 [벽충돌피해]가 연속으로 안 뜨는지.

남은 천장

정직하게 남겨 둔 것이 셋 있다.

  1. 몸을 돌려서 생기는 회전에는 검사가 없다. 벽 가드는 평행이동만 스윕하고, 축 회전에는 스핀 벽 가드가 따로 있다. 그런데 부착 블렌드가 끝난 뒤 가구는 캐릭터의 자식이라 몸이 돌면 같이 돈다 — 벽 옆 제자리 회전이면 가구가 벽을 쓸고 지나간다. 막았을 때 몸 요를 되돌릴지 가구만 둘지가 갈리고 스핀 자동 검사 120항목이 걸린 영역이라 임의로 손대지 않았다.
  2. 고속 충돌에서 수 cm 파고듦은 구조적으로 남는다 (사후 교정).
  3. 이후 고강도 코드 리뷰에서 이 축의 지적이 셋 더 나왔다 — 얇은 판이 가구가 점유하지 않는 높이 대역을 항상 검사해 낮은 가구로 카운터·선반 밑을 지날 때 미접촉인데 막히는 것, 반동 기울기 방향을 정하는 이동 벡터가 이미 덮어써진 값을 읽어 부호가 사실상 무작위인 것, 벽 데미지가 가구 데미지 게이트를 우회해 뽁뽁이 감쇠가 안 걸리는 것. 셋 다 다음 배치 안건으로 적어 뒀다.

결과

벽·바닥·차단 볼륨은 낮게 든 소품까지 포함해 막힌다. 계단과 문턱은 그대로 오르고, 소파·화분은 차단·반동·내구도 어느 쪽으로도 잡히지 않는다. 벽에 박으면 한 번 톡 튀고 자세가 제자리로 돌아온다.

핵심 요약 — “차단이 안 된다”는 제보에서 먼저 확인할 것은 차단 코드의 유무가 아니라 검사에서 빠지는 경로다. 여기서는 면제 플래그 하나와 접점 높이 필터 하나가 그 자리였고, 둘 다 원래는 계단을 오르게 하려고 넣은 조건이었다. 그리고 걸러진 것에도 로그를 남겨야 한다 — 통과는 흔적을 안 남기므로, 안 걸린 것과 걸렸다가 버려진 것을 구분할 수단이 없으면 진단이 코드 리딩으로만 가능해진다. 마지막으로 히트 스트림에서 접촉(상태)과 충돌(사건)을 가르면 반동·내구도처럼 “한 번만 일어나야 하는 것”이 매 프레임 발화하는 문제가 한 번에 정리된다.

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