포스트

[TIL] 2026-07-13 — 러버밴딩의 뿌리와 들것 운반 모델: CMC 예측·협동 운반 재설계·아웃라인 v17

[TIL] 2026-07-13 — 러버밴딩의 뿌리와 들것 운반 모델: CMC 예측·협동 운반 재설계·아웃라인 v17

TeamCarry(4인 협동 이사 게임) 협동 운반 대개편의 날. 가장 오래 끌던 “가구를 들고 이동하면 캐릭터가 순간이동하는” 러버밴딩의 뿌리를 캐릭터 무브먼트 예측 파이프라인과 서버 개입의 충돌에서 찾아 뽑았고, 2인 공동운반 회전을 카메라에서 몸으로 옮기는 ‘들것(스트레처)’ 모델을 구현했다. 잡기 스캔의 기하 오판 3건을 디버그 시각화 도구로 연달아 잡았고, 포스트 프로세스 아웃라인(M_PP_OutlineHLSL v17)에 노멀 크리스 감지와 오탐 게이트 5종을 쌓았다. 아웃라인을 전면 적용하자 레벨 배치 오차가 전부 시각화되는 부수효과까지. 마지막으로 Live Coding 운영 교훈 셋.

1. 운반 중 텔레포트·러버밴딩의 뿌리 — 캐릭터 무브먼트 예측과 서버 개입

증상: 클라이언트가 가구를 들고 이동하면 캐릭터가 툭툭 뒤로 당겨지고(러버밴딩), 심하면 순간이동한다. 호스트는 멀쩡하고 클라이언트에서만, 그것도 운반 중에만.

구조 분석 — 예측 파이프라인의 계약: 이 프로젝트에는 커스텀 CharacterMovementComponent가 없다. 즉 엔진 기본 클라이언트 예측·서버 보정이 그대로 살아 있고, 그 계약은 이렇다.

  1. 오토노머스 프록시(클라가 조종하는 캐릭터)는 입력으로 move를 만들어 로컬에서 즉시 실행(예측)하고, 같은 move를 서버로 전송한다
  2. 서버는 받은 move를 같은 시작 상태에서 재생하고 결과를 회신한다
  3. 클라 예측 결과와 서버 재생 결과가 어긋나면 ClientAdjustPosition이 날아와 클라 위치를 강제로 되돌린다 — 이것이 텔레포트 체감의 정체

그런데 가구 운반 시스템(UFurnitureGrabSystem, 서버 틱)이 견인 속도를 이 파이프라인 밖에서 CMC->Velocity에 직접 대입하고 있었다. 서버가 move 재생 사이에 시작 속도를 바꿔버리면 클라가 예측할 때의 시작 상태와 서버가 재생할 때의 시작 상태가 달라진다 → 결과 위치가 매번 어긋남 → 보정 연발. 견인 속도를 Unreliable 멀티캐스트로 클라에도 주입하고는 있었지만, 패킷이 한 번이라도 빠지면 그 프레임의 서버·클라 시작 속도가 갈라져 같은 문제가 터진다.

해결 — 이동 상태 변경을 소유 클라이언트 경로로 단일화:

1
2
3
4
5
6
7
8
9
10
11
12
// ✗ Before — 서버 틱이 모든 운반자의 CMC 속도를 직접 덮어씀 (예측 파이프라인 밖 개입)
CMC->Velocity = FVector(CarryVelocity.X, CarryVelocity.Y, CMC->Velocity.Z);
Multicast_ApplyPlayerCorrection(P, CarryVelocity, DesiredYaw);

// ✓ After — 서버 직접 기록은 로컬 제어 폰(호스트 캐릭터)에만.
//   원격 캐릭터는 멀티캐스트를 받은 '소유 클라이언트'가 스스로 적용하고,
//   서버는 그 속도가 반영된 move를 재생하며 자연히 일치한다.
if (P->IsLocallyControlled())
{
    CMC->Velocity = FVector(CarryVelocity.X, CarryVelocity.Y, CMC->Velocity.Z);
}
Multicast_ApplyPlayerCorrection(P, CarryVelocity, DesiredYaw);

Z를 보존하는 이유도 기록해 둔다: 견인 속도는 XY 평면 값이라 통째로 대입하면 낙하 속도가 매 틱 0으로 리셋되어 공중에서 슬로모션으로 떨어진다. 이날의 핵심 교훈 한 줄 — 오토노머스 프록시의 이동 상태는 소유 클라이언트 경로로만 바꾼다. 서버가 직접 손대는 순간 그 프레임의 move 재생은 반드시 어긋난다.

부가 수정 ① — 달리기(Shift)의 MaxWalkSpeed 하드코딩: 달리기 입력이 MaxWalkSpeed를 500/250으로 하드코딩 덮어쓰고 있었다. 운반 속도는 GrabSystem이 서버·클라 양쪽에서 권위 있게 관리하는데(인원수·가구 무게 기반 감속), 여기에 달리기가 끼어들면 서버 운반 속도와 어긋나 매 move가 보정된다. 운반 중에는 달리기 입력이 속도를 못 건드리게 게이트했고, 서버 RPC에도 같은 가드를 넣었다.

1
2
3
4
5
6
7
8
9
10
void ATCPlayerCharacter::ServerStartRun_Implementation()
{
    // 운반 중 도착한 낡은 RPC(그랩 '직전'에 발사돼 그랩 후 도착) 무시
    // — 서버 운반 속도 덮어쓰기 방지. 클라 입력 핸들러에도 동일 게이트.
    if (GrabComponent && GrabComponent->GetGrabbedActor())
    {
        return;
    }
    GetCharacterMovement()->MaxWalkSpeed = 500.f;
}

클라 게이트만으로는 부족하다 — 그랩 직전에 발사된 RPC가 그랩 뒤에 도착하는 레이스가 실제로 있었다. 네트워크 가드는 보내는 쪽과 받는 쪽 양쪽에 세운다.

부가 수정 ② — 시퀀스 가드의 ‘불일치=전부 폐기’: 가구를 수동 회전한 직후 가구가 멈췄다가 한 번에 튀는 히치. 가구 트랜스폼 멀티캐스트에 붙은 시퀀스 가드가 SeqID != LocalSequence전부 버리고 있었다. 수동 회전으로 시퀀스가 오른 뒤 Reliable 갱신이 도착할 때까지, 새 시퀀스가 달린 최신 위치 패킷이 통째로 버려져 보간 타깃이 정체됐다가 일괄 점프한 것.

1
2
3
4
5
6
7
8
// ✗ if (SeqID != LocalSystemOffsetSequence) return;   // 낡은 것도 최신도 전부 폐기

// ✓ uint8 순환 안전 비교 — 낡은 것만 폐기, 최신은 항상 수용
const int8 SeqDelta = (int8)(SeqID - LocalSystemOffsetSequence);
if (SeqDelta < 0)
{
    return;
}

255→0으로 넘어가도 int8 차이의 부호가 앞뒤를 올바르게 판정한다. TCP 시퀀스 번호 비교와 같은 원리 — 순환 카운터는 등호가 아니라 부호로 비교한다.

부가 수정 ③ — 문턱을 벽으로 오판하던 캡슐 스윕: 운반자 벽막힘 감지가 캡슐을 발바닥 높이 그대로 수평 스윕하고 있어서, CMC가 걸어 오를 수 있는 문턱·낮은 단차까지 벽으로 판정했다. 가구 후퇴 ↔ 견인이 반복되며 서버·클라 위치가 어긋나 이것도 러버밴딩 체감으로 나타났다.

1
2
3
4
5
6
7
8
9
// 캡슐 밑단을 MaxStepHeight만큼 들어올려 스윕 (반높이 축소 + 중심 상향, 머리 높이 유지)
// → 걸어 오를 수 있는 턱은 벽으로 안 잡히고, 실제 등반은 CMC 스텝업에 위임
const float StepH         = PCMC ? PCMC->MaxStepHeight : 45.0f;
const float NewHalfHeight = FMath::Max(CapHalfHeight - StepH * 0.5f, CapRadius);
const FVector LiftZ(0.0f, 0.0f, CapHalfHeight - NewHalfHeight);  // 밑단만 올라가도록 중심 상향

FCollisionShape Shape = FCollisionShape::MakeCapsule(CapRadius, NewHalfHeight);
World->SweepSingleByProfile(Hit, StartPos + LiftZ, EndPos + LiftZ, FQuat::Identity,
                            Cap->GetCollisionProfileName(), Shape, QP);

2. 협동 운반 ‘들것’ 모델 — 카메라에서 몸으로

기존 방식의 문제: 2인 공동운반 시 가구 회전이 각자 카메라 yaw 변화량의 가중 평균을 추종했다. 캐릭터가 아니라 카메라다. 가구를 든 채 시야만 돌려도 가구가 따라 돌고, 반대로 가구를 돌리지 않으려면 시야가 부자유해진다.

재설계 — 가구 yaw = 두 운반자를 잇는 선의 회전: 무빙아웃 방식. 실제 들것처럼 앞사람이 코너를 돌면 선이 돌고 가구가 따라 돈다. 걷기만으로 코너링이 되고 카메라는 자유. 다만 그대로 붙이면 안 되고, 안정화 세 가지가 본체였다.

① 능동 기여만 누적: 선의 회전량에는 ‘견인당해 끌려간 이동’도 섞여 들어온다. 이걸 회전 의도로 셈하면 회전 → 견인 → 선 재회전 → 재견인의 폭주 피드백이 생긴다. 매 틱 선 yaw 변화량을 ‘A만 움직였을 때 / B만 움직였을 때’로 분해해, 직접 걷는(능동) 플레이어의 기여만 누적한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
auto LineYaw = [](const FVector& From, const FVector& To)
{
    return FMath::RadiansToDegrees(FMath::Atan2(To.Y - From.Y, To.X - From.X));
};
// 선 회전량을 'A 단독 이동분 / B 단독 이동분'으로 분해
const float PrevYaw    = LineYaw(PrevPosA, PrevPosB);
const float DeltaFromA = FMath::FindDeltaAngleDegrees(PrevYaw, LineYaw(PosA, PrevPosB));
const float DeltaFromB = FMath::FindDeltaAngleDegrees(PrevYaw, LineYaw(PrevPosA, PosB));

float IntentDelta = 0.0f;
if (!DraggedLastTick.Contains(A)) IntentDelta += DeltaFromA;  // 능동 이동만 의도로 인정
if (!DraggedLastTick.Contains(B)) IntentDelta += DeltaFromB;  // 견인당한 이동은 제외
PairLineTargetYaw = FRotator::NormalizeAxis(PairLineTargetYaw + IntentDelta);

② 윈드업 방지: 의도 누적치가 실제 가구 yaw보다 45° 이상 앞서지 못하게 클램프하고, 벽 막힘·교착으로 회전이 보류되는 동안은 의도를 실제 yaw로 재기준한다. 빠르게 한 바퀴 돈 뒤 가구 혼자 한참 도는 현상, 막혔다 풀리는 순간 홱 도는 현상이 이걸로 사라졌다.

③ 수동 회전 키와 병존: 수동 회전으로 가구 yaw가 밀린 만큼 누적치도 함께 이동시킨다. 안 그러면 다음 틱의 선 회전 계산이 수동 회전을 곧바로 되돌린다.

견인 데드존 — 진입만 넓게(히스테리시스): 정위치 반경 50uu 안에서는 견인이 없다(자유 이동). 단 한 번 견인이 시작되면 정밀 데드존(0.5uu)까지 완전히 끌어 대형을 복원한다. 처음엔 진입·해제 반경을 같게 했다가 회차마다 대형이 뒤로 밀리는 누적 드리프트를 만났다 — 도달 시 앵커를 재기록하는데, 해제 반경이 넓으면 그 잔여 오차가 앵커에 구워지기 때문이다.

1
2
3
4
5
6
7
// [견인 데드존] 정착 상태에서는 PullStartRadius(50uu)까지 자유 이동을 허용하고,
// 일단 견인이 시작되면 CorrectionDeadzone(0.5uu)까지 완전히 끌어 대형을 복원한다.
// 해제 반경까지 넓히면 도달 앵커 재기록에 잔여 오차가 구워져
// 견인이 반복될수록 대형이 뒤로 밀리는 누적 드리프트가 생긴다 — 진입만 넓게.
const float AtTargetRadius = bWasDragged
    ? CorrectionDeadzone
    : FMath::Max(PullStartRadius, CorrectionDeadzone);

‘멈췄다가 순간이동’ 2차 버그 2건: 데드존을 넣자 새 증상이 나왔다.

  • 정지 파트너의 브레이크: 데드존 안에서 ‘서 있는’ 파트너의 낡은 앵커 제안이 이동 수요 기반 가중치를 크게 받아, 가구를 뒤로 당기는 브레이크가 됐다. 걷는 쪽이 아무리 당겨도 가구가 안 가다가 견인 전환 순간 점프. → 자기 이동량 이상의 발언권 금지 — 제안 가중치에 속도×dt 기반 상한을 걸었다. 서 있으면 최소 가중치, 걸으면 즉시 회복.
  • 견인 시작 순간의 속도 폭탄: 벌어진 50uu를 한 틱에 닫으려고 수천 cm/s가 주입됐다. → 0.12초에 걸쳐 지수적으로 닫는 견인 램프.
1
2
3
4
5
const float SafeDeltaTime = FMath::Max(DeltaTime, KINDA_SMALL_NUMBER); // 첫 틱 dt=0 가드
// 벌어진 거리를 한 틱에 닫으면 시작 순간 수천 cm/s → '멈췄다가 순간이동' 체감.
// 0.12초에 걸쳐 지수적으로 닫는다.
const float PullCloseTime = FMath::Max(SafeDeltaTime, 0.12f);
const FVector NeededVelocity = (Delta / PullCloseTime).GetClampedToMaxSize(MaxCorrectionSpeed);

손높이 차 → 가구 기울기: 카메라 피치의 평균은 가구 높이(기존 기능), 차이는 두 사람을 잇는 선 축의 기울기로 — 낮게 든 쪽으로 ±20° 기운다. 월드 기울기축 쿼터니언과 yaw 쿼터니언을 합성하고 RInterpTo로 접근시켜 합류 순간 스냅을 막았다.

1
2
3
4
5
6
7
8
const float TiltDeg = FMath::Clamp(FMath::RadiansToDegrees(
    FMath::Atan2(HandHeightB - HandHeightA, PairDist)), -20.0f, 20.0f);
const FVector TiltAxis = FVector::CrossProduct(LineDir, FVector::UpVector);
const FQuat GoalQuat = FQuat(TiltAxis, FMath::DegreesToRadians(TiltDeg))
                     * FQuat(FRotator(0.0f, TargetYaw, 0.0f));   // 월드 기울기축 × yaw
TargetRot = FMath::RInterpTo(Owner->GetActorRotation(), GoalQuat.Rotator(),
                             DeltaTime, FurnitureHeightInterpSpeed);
TargetRot.Yaw = TargetYaw;   // yaw는 기존 FixedTurn 결과 유지, 피치·롤만 보간

겹침 방지 — 방향 인식형 데드존: 운반자-가구 충돌은 그랩 중 꺼져 있어 데드존 여유 안에서 몸이 가구를 관통해 보였다. 데드존 여유를 옆·뒤 방향까지만 허용하고, 앵커 자리에서 가구 중심 방향으로 12uu 이상 파고들면 도달 판정을 깨고 견인을 발동시켜 대형을 복원한다. 견인 램프 덕에 밀려나는 것도 부드럽다.

인원미달 연출 — 혼자서는 못 드는 가구: 2인 가구를 혼자 들면 잡은 쪽만 들리고 반대쪽 끝이 14° 기울어 바닥에 끌린다. 접지 높이는 ‘기울어진 가구의 월드 AABB 하단 = 발밑 바닥’으로 역산했는데, 여기서 버그 하나 —

1
2
3
// Bounds.BoxExtent는 회전이 반영된 '월드 AABB'라 기울기 성분이 이미 포함돼 있다.
// 여기에 sin(기울기)×길이를 또 더하면 이중 가산되어 그만큼 공중에 뜬다 (버그로 확인).
const float DesiredCenterZ = FloorZ + Ext.Z + 1.0f;   // +1: 관통 없이 '닿아 보이는' 높이

이 상태에서는 카메라 피치 들어올리기도 무시한다 — “혼자서는 못 든다”는 시각 언어. 놓을 때는 물리를 켜기 전에 지형 겹침이 풀릴 때까지 3uu씩 상승시키는 디페네트레이션 단계를 추가했다. 위치는 스윕으로 보정되지만 회전(기울기)은 스윕이 없어 모서리가 바닥에 박힌 채 물리가 켜질 수 있었기 때문.

3. 잡기 스캔의 기하 함정 3연전

함정 ① — 피벗 기준 각도 판정: 벤치에 걸터서듯 바짝 붙으면 눈앞의 가구를 못 잡았다. 각도 판정이 가구 피벗(액터 위치) 기준이라, 긴 가구에 붙으면 피벗이 전방 반평면 뒤로 떨어져 내적<0 → ‘등 뒤’ 오판. 대상까지의 방향·거리를 박스 트레이스가 실제로 맞힌 지점 기준으로 바꿨다.

1
2
3
4
5
6
7
// ✗ 피벗 기준 — 긴 가구에 붙으면 피벗이 등 뒤로 떨어져 내적<0
// FVector Dir = (Target->GetActorLocation() - Start).GetSafeNormal();

// ✓ 박스가 실제로 맞힌 지점 기준
const FVector AimPoint = Hit.ImpactPoint;
FVector DirectionToTarget = (AimPoint - Start).GetSafeNormal();
float   Distance          = FVector::Distance(Start, AimPoint);

함정 ② — 시작 겹침의 ImpactPoint: 그래도 재발하는 케이스가 있었다. 박스가 시작부터 가구와 겹치면(bStartPenetrating) ImpactPoint가 밀어내기 계산상 등 뒤로 나올 수 있다. 지근거리(시작 겹침 또는 60uu 이내)는 각도 검사를 생략하고 거리 점수를 우선하게 했다 — 경계 조건(겹침)은 일반 수식에 태우지 말고 따로 처리.

1
2
const bool bPointBlank = Hit.bStartPenetrating || Distance < 60.0f;
if (!bPointBlank && DotProduct < 0.0f) continue;   // 품 안 거리는 각도 검사 면제

함정 ③ — 밟은 가구를 들고 난다: 경사 대응으로 스캔 박스를 수직 확장(±40→±70)했더니 자기가 올라선 가구까지 후보로 잡혀, ‘밟은 가구를 들고 나는’ 상태가 됐다. CMC CurrentFloor의 액터를 후보에서 제외하고, 서버 그랩 게이트에도 같은 검증을 넣었다(클라 검증만 믿지 않는다).

1
2
3
4
5
if (OwnerCMC->CurrentFloor.bBlockingHit
    && OwnerCMC->CurrentFloor.HitResult.GetActor() == TargetActor)
{
    return;   // 발밑 가구 — 들면 '가구 타고 부양'이 되므로 서버에서도 차단
}

가시선(LOS) 검사 개선: 바닥 높이 피벗을 향해 쏘던 라인을 바운즈 중심으로 바꿔 경사 지형 오탐을 없앴고, 파트너 캐릭터(폰)를 차단물에서 제외해 2인 협동에서 몸에 가려 못 잡던 문제를 풀었다.

디버그 시각화의 힘: 이날 이 3건의 원인 규명이 전부 디버그 뷰 하나로 이뤄졌다. 콘솔 변수 토글(F9, DebugExecBindings 바인딩, Shipping 제외)로 스캔 박스와 후보별 판정 선, 화면 상단 대상·판정 텍스트를 띄운다. 제한시간 타이머 정지 토글(F10)도 함께 만들어 관찰 시간을 확보했다.

선 색의미
초록모든 검사 통과 (현재 대상)
빨강가시선 차단 탈락
보라후방 각도 탈락
파랑발밑(밟고 있는 가구) 제외

스크린샷 한 장이면 후보가 어느 단계에서 떨어지는지 즉시 특정된다. 기하 버그는 로그보다 그림 — 도구를 먼저 만들면 그 다음부터는 버그가 스스로 자백한다.

4. 포스트 프로세스 아웃라인 심화 — 깊이 전용의 한계 넘기

M_PP_OutlineHLSL v17. 깊이 기반 엣지만으로 버텨온 아웃라인에 하루 종일 오탐·미탐 게이트를 쌓았다.

천장-벽 이음새에 선이 없던 이유: 깊이 기반 엣지는 ‘깊이 불연속‘만 잡는다. 두 면이 만나는 모서리(크리스)는 깊이가 연속이고 기울기만 꺾인다 → 깊이 엣지가 원리적으로 못 잡는다. GBuffer 월드노멀로 크리스 감지를 추가했다.

노멀 비교는 ‘마주보는 이웃 쌍’으로: 중심-이웃 비교로 시작했더니 1px짜리 얇은 옆면 조각(낮은 단차 상판 테두리)에 발화해 점선을 만들었다. 마주보는 쌍끼리 직접 비교하면 조각을 건너뛴다.

1
2
3
4
5
6
// ✗ 중심-이웃 비교 — 1px 옆면 조각에도 발화해 점선 생성
// float Crease = 1.0 - dot(NC, NL);

// ✓ 마주보는 이웃 '쌍' 비교 — 조각을 건너뛰어 양쪽 다 바닥이면 무시, 진짜 모서리만 감지
float3 NL = GetNormal(UV - DX), NR = GetNormal(UV + DX);
float  Crease = 1.0 - saturate(dot(NL, NR));

사선 이음새의 점선: 축 4방향 샘플만 쓰면 화면상 빗금 경계가 샘플 사이로 빠졌다 걸렸다 한다(TAA 지터까지 겹쳐 반짝임). 대각을 포함한 8방향 등방 샘플로 확장.

그레이징 면의 미세 단차 점선: 비스듬히 보는 면은 픽셀 간 깊이 차가 원래 크다. 엣지 게이트에 경사 비례항을 추가 — 정면 이음새는 영향 없이 그레이징 오탐만 줄었다.

모듈 메시 이음의 헤어라인 틈: 모듈 벽 사이 실틈은 픽셀에 걸릴 때만 깊이가 튀어 점 궤적을 만든다. 2픽셀 바깥을 추가 확인해 ‘깊은 쪽 폭이 1px’이면 억제했다.

1
2
3
// 1px 슬릿 억제: 2px 바깥이 다시 얕아지면(틈새) 무시, 계속 깊으면(실루엣) 보존.
// 가까운 얇은 물체(의자 다리·난간)는 깊이 부호가 반대라 보존된다 — 이 비대칭이 포인트.
bool bHairlineSlit = (DeepNear - DC > Th) && (DeepFar - DC < Th);

천장의 곡선 점 궤적 — 노멀맵 오탐: 노멀맵 붓터치가 픽셀 노멀을 60° 이상 꺾어 크리스로 오탐됐다. 진짜 모서리는 깊이 기울기도 꺾인다는 사실로 분리 — 노멀 마스크에 깊이 곡률(라플라시안) 게이트를 곱해, 평평한 면 위 텍스처 디테일을 무력화했다.

1
2
3
// 진짜 크리스는 깊이 2차 미분(곡률)도 양수 — 평면 위 노멀맵 디테일은 Lap≈0
float Lap = (DL + DR + DU + DD) - 4.0 * DC;
CreaseMask *= saturate(Lap * CurvatureGain);

한 신호(노멀)의 오탐은 그 신호 안에서 문턱값을 조이는 것보다 다른 신호(깊이 곡률)와의 곱(AND 게이트) 으로 거르는 쪽이 부작용이 없다.

하이라이트 링의 가림 무시: 커스텀뎁스는 오클루전과 무관하게 렌더된다 → 벽 뒤 가구의 실루엣이 벽 틈 위로 노란 선으로 떠올랐다. 에디터의 액터 선택 하이라이트도 같은 스텐실 채널을 쓰는데 동일 증상을 보인 것이 구조 문제라는 방증. 커스텀뎁스≈씬뎁스인 픽셀만 링·필을 허용해 가시부로 한정했다.

1
2
3
4
// 실제로 보이는 픽셀만 링 허용 — 커스텀뎁스가 씬뎁스와 일치하는 곳
float Visible = step(abs(CustomDepth - SceneDepth), DepthTolerance);
RingMask *= Visible;
// 예외: 잔여시간 임박 빨간 링은 의도적 X-ray라 이 게이트를 타지 않는다

스텐실 우선순위 설계: 메시당 스텐실 값은 하나뿐인데 포커스·운반·임박 세 시스템이 매 틱 서로 덮어쓰고 있었다. 우선순위를 세우고, 커스텀뎁스 스텐실은 복제되지 않는 로컬 렌더 상태라는 점을 역이용해 ‘내가 잡을 수 있는가’를 화면별로 다르게 표시했다. 원격 캐릭터의 스캔이 내 화면에 포커스를 칠하던 교차 오염도 포커스 발화를 로컬 조종 캐릭터로 한정해 차단.

우선순위스텐실의미비고
11로컬 플레이어 조준 포커스노랑화면(로컬)별로 다르게 표시
23운반 중초록 
34잔여시간 임박빨강유일하게 X-ray 허용

5. 레벨 배치 정밀도가 셰이더에 드러난다

아웃라인을 전면 적용하자 L_Level1의 배치 오차가 전부 시각화됐다. 셰이더가 공짜 배치 검사기가 된 셈.

  • 천장 패널 하나만 z=339.9 (주변은 340.0) — 1mm 단차가 그레이징 각에서 점 궤적으로 나타났다. z 정렬로 해소.
  • 두 벽의 맞닿는 면이 완전 동일 평면 — 코플레이너 면은 깊이·노멀 어느 쪽으로도 불안정하다. 1uu 겹침 용접(weld)으로 해소.
  • 슬랫월이 바닥에서 4cm 부양 — 빛샘. 최하단 슬랫을 연장해 밀봉.
  • 끌려난 액터 복원: 같은 규격 바닥판들이 y=1565, z=91, x 150 간격 대열임을 확인하고, 빠진 칸(-1850)으로 복원. ‘형제 패턴에서 원위치 추론’ 이 생각보다 강력하다 — 어긋난 액터의 원래 자리는 대개 이웃들이 알고 있다.
  • 데이터 함정 하나: 가구 스탯(필요 인원)은 데이터테이블에서 BeginPlay에 로드된다. 에디터 월드에서 컴포넌트 값을 읽으면 전부 기본값(1)으로 보인다 — 런타임 값이나 데이터테이블 에셋을 봐야 한다. 하마터면 “2인 가구가 하나도 없네?”로 헛수고할 뻔.

6. Live Coding 운영 교훈

  • PIE 중 라이브 컴파일 금지 항목이 늘었다: 플레이 중인 세션에 전역 콘솔 변수/커맨드 statics 추가가 포함된 라이브 컴파일을 걸면 패치 리로드 도중 에디터가 통째로 크래시. 이후 컴파일 전 PIE 상태 확인을 습관화.
  • 라이브 패치는 소실되지 않는다 — 하지만 Standalone은 못 본다: 라이브 패치는 디스크의 patch DLL로 남아 에디터 재시작 시 자동 재적용된다. 반면 Play Standalone은 별도 프로세스라 마지막 정식 빌드의 DLL만 사용한다 — 라이브 패치로만 존재하는 기능은 Standalone에서 안 보인다. “같은 코드인데 창마다 동작이 다르면 프로세스별 바이너리 출처부터 의심”.
  • 위젯 삭제 후 ‘Variable was deleted but still has a GUID’ ensure: 블루프린트 변수 GUID 맵의 스테일 엔트리. 재컴파일이 자가 정리하는 1회성이라 놀랄 것 없음.

오늘 배운 것 정리

  1. 오토노머스 프록시의 이동 상태는 소유 클라이언트 경로로만 바꾼다. 서버가 예측 파이프라인 밖에서 CMC 속도를 덮어쓰면 move 재생 시작 상태가 어긋나 ClientAdjustPosition(=텔레포트 체감)이 발사된다. 원격 캐릭터는 멀티캐스트를 받은 소유 클라가 스스로 적용하게 하고, 서버는 그 move를 재생하며 자연히 일치시킨다.
  2. 순환 카운터는 등호가 아니라 부호로 비교한다. ‘불일치=전부 폐기’ 시퀀스 가드는 최신 패킷까지 버려 히치를 만든다 — uint8 차이를 int8로 캐스팅해 부호로 앞뒤를 판정하면 낡은 것만 걸러진다.
  3. 공동운반 회전은 카메라가 아니라 몸의 기하(두 운반자를 잇는 선)에서. 단 피동(견인) 이동까지 의도로 셈하면 회전→견인→재회전의 폭주 피드백 — 능동 기여만 분해·누적하고, 윈드업은 실제 상태 기준 ±45°로 제한한다.
  4. 보정 시스템에는 히스테리시스와 램프. 진입·해제 반경이 같으면 앵커 재기록에 잔여 오차가 구워져 누적 드리프트, 벌어진 거리를 한 틱에 닫으면 속도 폭탄 — 진입만 넓게, 닫기는 시간을 들여서.
  5. 기하 판정은 대표점(피벗)이 아니라 실제 접촉점 기준으로, 경계 조건(시작 겹침)은 따로 처리한다. 그리고 판정 파이프라인을 만들 때 디버그 시각화를 먼저 만들면 이후의 버그는 스크린샷 한 장으로 자백한다.
  6. 깊이 엣지는 불연속만 잡는다 — 크리스는 노멀로, 노멀 오탐은 깊이 곡률로. 한 신호의 오탐은 문턱값 조이기보다 다른 신호와의 곱(AND 게이트)으로 걸러야 부작용이 없다. 비교는 중심-이웃이 아니라 마주보는 쌍으로, 샘플은 8방향 등방으로.
  7. 커스텀뎁스는 가림을 모르고, 스텐실은 복제되지 않는 로컬 렌더 상태다. 가시부 한정은 씬뎁스 비교로 풀고, 화면별 차등 표시는 ‘복제 안 됨’을 역이용한다. 메시당 스텐실 값은 하나뿐이므로 여러 시스템이 쓰면 우선순위를 명시적으로 세운다.
  8. 아웃라인 셰이더는 공짜 배치 검사기다. 1mm 단차·코플레이너 면·4cm 부양이 전부 점선과 빛샘으로 드러난다. 어긋난 액터의 원위치는 형제 패턴(간격 대열)이 알고 있다.
  9. 라이브 패치는 patch DLL로 지속되지만 Standalone은 정식 빌드 DLL만 쓴다. 같은 코드가 창마다 다르게 동작하면 로직보다 프로세스별 바이너리 출처부터 확인한다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.