[UE5] 네트워크 캐릭터 이동과 관련된 스냅백, 지터링 문제 해결 및 의문점들 feat. 솔루나 시프트

연하·2024년 9월 29일
0

Trapper

목록 보기
31/32

예전부터 계속해서 빌드만 뽑으면 클라이언트 캐릭터가 움직일 때 프레임이 떨어지는 것처럼 속도가 느려지고, 순간이동하는 등 여러 문제가 있었다. 에디터 내에서 한 프로세스로 플레이 할 때나, 발표할 때 공유기를 사용해 로컬로 연결할 때는 버벅임이 거의 없는 것 "같은" 느낌이라 그동안은 내 코드가 아니라 내부 인터넷 속도에 탓을 돌리고 흐린눈을 하고 있었는데...

실제로 두 컴퓨터에 랜선을 연결하고 로컬로 테스트 플레이를 진행해보니 똑같이 버벅이는 현상이 있었고, 이게 최적화의 문제인지 내 코드의 문제인지를 판단해야 했다. 이틀정도 언리얼 인사이트를 공부하고, 최적화에 관련된 것들을 찾아보았다. 결과적으로 CPU에서 병목이 생기는 것을 알았고, 어디를 최적화해야할 지에 대한 감은 잡혔는데..

최적화 문제가 아닐 것이라는 확신이 들기 시작했다. 우선, 문제가 '클라이언트' 에서만 발생한다는 것. 애초에 프레임이 떨어진다면 서버나 클라이언트 둘 다 버벅이는게 맞잖아..? 내가 새로 만들어서 달아준 무브먼트 컴포넌트, 솔루나 시프트 동기화 로직이 의심이 가기 시작했고, 실제로 게임모드를 바꿔 언리얼 Third Person 캐릭터를 사용하도록 빌드하여 플레이해보니 모든 문제가 해결되었다ㅎㅎ

이제 어디가 문제인건지 찾고 고쳐야한다. (내가 해결할 수 있을까..)

이동 문제 해결하기

자성이동과 이동 모두 같은 문제일거라 생각했는데, 일단 이동과 점프에서 순간이동 하는 문제는 해결된 것 같다. 도대체 이게 왜 켜져있는지 모르겠는데, 캡슐 컴포넌트의 리플리케이트 옵션이 켜져있었다.. 꺼주니까 부드럽게 잘 이동한다.

솔루나 시프트 문제 해결

이전 게시글 보며 문제 찾아보기

자성이동 때문에 이미 충분히 삽질을 했었던 것 같은데 또 하게 될 줄이야.. PIE에서는 정상적으로 동작하지만 빌드만 뽑으면 클라이언트에서 이상하게 동작한다ㅠ (도대체 왜그러는거야.. 왜!!!!!)

일단, 예전에 작성했던 솔루나 시프트 삽질 기록을 다시 읽고 어떻게 해결해볼지 고민해보았다.

  • 클라이언트측에서 로컬로 움직이고, RPC를 이용해 서버쪽의 프록시도 움직여 주었을 때 : 클라이언트쪽에서 지터링이 발생한다 => 서버의 수정 사항이 반영되어서 생긴 문제로 추측

  • 무브먼트 컴포넌트 오버라이드를 통한 예측 기능 추가 : 지터링은 사라졌지만, 목적지에 도착하기 전에 착지해버림 => 서버가 이동하고 있는 도중, 클라이언트쪽에서 자성이동을 종료하면서 플래그가 동기화되면서 생기는 문제로 추측

  • AddMovementInput을 이용한 로직 : 에디터 내에선 잘 동작했으나, 빌드 후 비정상적으로 동작함을 확인. 이때 게시글에선 빌드를 뽑아보지 않았기도 하고, 이것저것 여러 코드가 추가되었기 때문에 어떤게 문제인지 확인 불가능.

이 외에도 서버에서만 델타타임을 계산하도록 하고, 누적시킨 델타타임을 리플리케이트 하여 그 값을 보간해 이동하게끔 하는 로직도 사용해봤는데, 지금까지 해봤던 모든 방법보단 나았지만 지연이 있는 상황이 발생했을 시 가만히 멈추는 문제가 발생했다. 사용성이 너무 좋지 않아서 이것도 폐기.

해결을 위한 2주간의 대장정

윗부분을 작성하고, 지금 내용을 작성하기까지 약 2주정도의 시간이 흘렀다. 아무리 생각해도 무브먼트 컴포넌트에 대한 더 심도깊은 이해가 필요하다고 확신했고, 유튜브와 구글, GPT 등 사용할 수 있는 모든 수단을 이용해 무브먼트 컴포넌트에 대한 공부를 했다. 물론 이마저도 시간이 얼마 없었기에 제대로 하진 못했지만, 대충 어떻게 굴러가는구나~ 정도는 이해한 것 같다. 그리고 나서야 확신한건데, 제대로 동작할리가 없는 코드를 사용하고 있었다. 무브먼트 컴포넌트와 클라이언트측 예측에 대한 이해가 없었기에 일어났던 예고된 참사였음..

이전에 작성했던 코드에 대한 리뷰

void ATrapperPlayer::Interact(const FInputActionValue& Value)
{
	float Data = Value.Get<float>();

	if (!Movement->CanMagneticMoving() || !Data)
		return;

	if (HasAuthority()) MulticastRPCMagneticMoveStart();
	else if (IsLocallyControlled()) ServerRPCMagneticMoveStart();
}

사실상 여기만 보면 될 것 같은데, 자성 이동을 시작하는 쪽의 코드다. 자성이동 로직에서 교수님이 알려주신 AddMovementInput 함수를 사용한 것 까지는 좋았다. 근데.. 무브먼트 컴포넌트를 통해 알아서 처리되고 있기 때문에, 우리는 클라이언트에서 인풋을 입력할때 RPC 함수를 쓰지 않는다.

근데 나는 양쪽의 캐릭터에 모두 인풋을 넣고 있었던 것이다(..이게 말이 되는 코드일까?..) 에디터 내에서는 정상 작동하는 것처럼 보일 수밖에 없었을 것 같다. 패킷 손실이나 지연이 0에 수렴해서 동시에 움직이고, 그럼 서버가 보정을 할 필요가 없었을 테니까.

서버에게 타겟 위치는 전달했지만, 서버가 이동하던 도중 타겟이 바뀌어버린 케이스도 있었을 것이고, 심지어 자성이동 로직에 서버만 Max Speed로 바꾸는 코드도 있다. 점프를 두군데서 호출한 것도 문제였을 것 같고.. (아무튼 총체적 난국이다)

무브먼트 컴포넌트가 이동을 어떻게 처리해야하는지 이해하고 인풋 함수를 사용했다면, 문제 없이 잘 구현됐을 것 같은데 하는 아쉬움이 많이 남았다. 추후에 프로젝트가 끝난 뒤, 더 자세한 공부와 함께 여러 사례들을 테스트해보며 이전 문제에 대해 다시 리뷰해볼 생각이다.

해결 후의 의문점들

결국 강의에서 사용한 코드의 구조를 보고 공부하며 최대한 비슷하게 구현해보려고 노력했고, 결론적으로 클라이언트측 예측을 사용해 (아직 찝찝하지만) 해결에 성공하긴 했다.

다만 여기서 로직을 엄청 자세하게 설명하진 않을 예정이고, 위에서 말했듯 프로젝트가 끝난 후에 다른 프로젝트를 하나 파서 다시 구현해볼 것이다. 그때 제대로 정리해서 조금이나마 제대로 된 정보로 포스팅을 작성하려고 한다.

Trigger

void ATrapperPlayer::Interact(const FInputActionValue& Value)
{
	float Data = Value.Get<float>();

	if (Movement->CanMagneticMoving() && !Movement->GetMagneticMovingState())
	{
		if (Movement->IsFalling())
		{
			// Start Magnetic Move
			if (HasAuthority() && IsLocallyControlled())
			{
				MulticastWantsToMagneticMove();
			}
			if (!HasAuthority() && IsLocallyControlled())
			{
				WantsToMagneticMove();
				ServerWantsToMagneticMove();
			}
		}
		else
		{
			// Cast Animation
			if (HasAuthority() && IsLocallyControlled())
			{
				MulticastPlayCastAnimation();
			}
			if (!HasAuthority() && IsLocallyControlled())
			{
				PlayCastAnimation();
				ServerPlayCastAnimation();
			}
		}
	}

	return;
}

void ATrapperPlayer::WantsToMagneticMove()
{
	Movement->bWantsToMagneticMove = true;
	Movement->bWantsToMagneticCast = true;
}

void ATrapperPlayer::PlayCastAnimation()
{
	Movement->bWantsToMagneticCast = true;

	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	if (!IsValid(AnimInstance) && !IsValid(MagneticMoveMontage))
	{
		return;
	}

	AnimInstance->Montage_Play(MagneticMoveMontage, 1.0);
	FOnMontageEnded EndDelegate;
	EndDelegate.BindUObject(this, &ATrapperPlayer::CastEnd);
	AnimInstance->Montage_SetEndDelegate(EndDelegate, MagneticMoveMontage);
}

void ATrapperPlayer::CastEnd(UAnimMontage* Montage, bool bInterrupted)
{
	Movement->bWantsToMagneticMove = true;
}

플레이어가 자성이동 트리거 키를 입력했을 때 사용하는 코드이다. 사실 처음에는 로컬 무브먼트 컴포넌트 내의 압축 플래그를 뒤집어주는 식으로만 작성했는데, 각자의 컴퓨터에서 복제되는 캐릭터(서버에서 보는 클라이언트, 클라이언트에서 보는 서버의 복제본)에서 애니메이션이 출력되지 않는 문제가 발생해서 RPC 함수를 통해 플래그를 뒤집어주었다. 이 과정에서 캐스트 애니메이션을 몽타주로 변경해주었다(원래는 애니메이션 상태였음).

내가 공부하고 이해한 바로는, 압축 플래그를 사용하면 몽타주 애니메이션 재생을 제외하고는 RPC 함수 없이도 플래그값과 무브먼트가 서버에도 반영되어 애니메이션이 잘 반영되어야 하는데, 이게 잘 안되어서 의문이었다. 그래서 어떻게든 플래그 상태를 반영하기 위해 RPC 릴라이어블 함수로 변경해주었고, 결론적으로는 잘 된다.. 사실 이게 클라이언트측 예측을 위한 코드로 변경해준 후에 제대로 동작하지 않았던 것들을 해결한 방법의 거의 전부이다. 변수를 리플리케이티드 처리하던가, RPC 함수를 통해 상태를 동기화 하거나.

점프, 낙하 등의 상태일 때는 자성이동 시전 없이 곧바로 bWantsToMagneticMove의 플래그를 뒤집어 자성이동을 실행시키고, 땅에 닿아있는 상태일 때는 캐스트 몽타주 애니메이션을 실행한 후에 자성이동을 실행시킨다.

Target

void UTrapperPlayerMovementComponent::EnterMagneticMove(EMovementMode PrevMode, ECustomMovementMode PrevCustomMode)
{
	PlayerRef->StartManeticMovingSetting();

	// Set Target RPC
	if (PlayerRef->HasAuthority() && PlayerRef->IsLocallyControlled())
	{
		ClientSetTargetPos(TargetPosition);
	}
	else if (!PlayerRef->HasAuthority() && PlayerRef->IsLocallyControlled())
	{
		ServerSetTargetPos(TargetPosition);
	}

	// Rotation in Target Direction
	FVector Direction = (TargetPosition - UpdatedComponent->GetComponentLocation()).GetSafeNormal();
	Direction.Z = 0.0f;
	FQuat Rot = Direction.ToOrientationQuat();
	FHitResult Hit;
	SafeMoveUpdatedComponent(FVector::ZeroVector, Rot, false, Hit);
}

void UTrapperPlayerMovementComponent::ServerSetTargetPos_Implementation(const FVector& TargetPos)
{
	TargetPosition = TargetPos;
	bCanChangeTarget = false;
}

void UTrapperPlayerMovementComponent::ClientSetTargetPos_Implementation(const FVector& TargetPos)
{
	TargetPosition = TargetPos;
}

타겟의 경우 자성 이동에서 사용하는 유일한 외부변수이다. 서버에서 클라이언트의 위치를 보정하고, 클라이언트의 보류된 움직임을 재생성할 때 이 변수가 변경되어 있으면 치명적일 수 있다. 하지만 특정 방향으로 자동 이동하기 위해 꼭 필요한 변수이므로, 서버에 타겟을 세팅해 줄 때 bCanChangeTarget 를 false로 바꿔준 후(리플리케이트 되는 변수). 서버에서 해당 이동을 종료할 때 true로 바꿔주어서 안전하게 처리하려고 노력했다.

그리고 이 경우에 두 컴퓨터로 테스트하는 경우, 클라이언트쪽에 서버가 보낸 리플리케이트 패킷이 손실되는건지 한번씩 클라이언트가 타겟을 설정할 수 없는 경우가 생겼다.

void UTrapperPlayerMovementComponent::ExitMagneticMove()
{
	PlayerRef->FinishMagneticMovingSetting();

	// Jump After Arrived & Gravity Set
	GravityScale *= ArrivedJumpGravity;
	bProxyGravityChange = ~bProxyGravityChange;
	Velocity = JumpImpulse * (FVector::UpVector * 0.1f);

	// Set Movement & Flag 
	bWantsToMagneticMove = false;
	bWantsToMagneticCast = false;

	// Change to enable Target Setting
	if (PlayerRef->HasAuthority() && !PlayerRef->IsLocallyControlled())
	{
		bCanChangeTarget = true;
		
		// 클라이언트에게 자성이동 종료 명시
		GetWorld()->GetTimerManager().SetTimer(MagneticMoveEndCheckHandle, this,
			&UTrapperPlayerMovementComponent::MagneticMoveEndCheck, 1.f, false, 0.1f);
	}
}

void UTrapperPlayerMovementComponent::ClientMagneticMoveEndCheck_Implementation()
{
	bCanChangeTarget = true;

	if (bWantsToMagneticMove || bWantsToMagneticCast)
	{
		bWantsToMagneticMove = false;
		bWantsToMagneticCast = false;
	}

	PlayerRef->FinishMagneticMovingSetting();
}

타이머를 이용해 0.1초뒤에 한번 더 체크하도록 클라이언트 RPC를 보내주었고, 지금까지 테스트해본 바로는 아직까지 클라이언트가 타겟을 설정할 수 없는 문제는 없는 것 같다.

Proxy

애니메이션도 잘 나오고, 이동도 잘 하는데도 불구하고 있었던 마지막 문제가 있었다. 클라이언트측에서 보는 서버의 프록시가 자성이동을 끝내고 내려올 때 미친듯이 지터링하고 그 후에 하는 점프에서도 지터링하는 현상이 발생했다.

서버의 중력은 변경됐는데 클라이언트쪽의 서버 프록시에서는 변경되지 않아 생긴 문제인 것 같았고, 서버의 중력이 변경될 때마다 리플리케이티드 되는 변수를 하나 만든 뒤 서버의 프록시쪽에서 변수가 복제될 때마다 함수를 호출해 중력 값을 변경해주는 식으로 처리했더니 문제가 해결됐다. 마찬가지로 혹시 몰라 타겟 위치도 복제하도록 변경했다.

서버의 프록시는 서버의 움직임 값만 받아서 보간하는식으로 처리하는 걸로 알고있는데 왜 이런 문제가 발생하는건지 이해를 할 수가 없다.. 프로젝트가 끝난 뒤에 정말 확실하게 알아봐야겠다.

결과

두 컴퓨터에서 테스트 한 영상이다. 지스타에서도 거의 같은 환경에서 플레이를 진행할 예정이기 때문에, 크게 문제가 생기진 않을 것으로 보인다.

<서버의 로컬 캐릭터가 자성 이동을 사용하는 모습>

<클라이언트에 있는 서버의 프록시가 자성 이동을 사용하는 모습>

<클라이언트의 로컬 캐릭터가 자성 이동을 사용하는 모습>

<서버에 있는 클라이언트가 자성 이동을 사용하는 모습>


드디어 자성 이동의 네트워크 이슈가 사라졌으니, 자성이동 이펙트, 카메라 등 완성도를 위한 작업들을 진행할 수 있게 됐다. 드디어 폴리싱 작업에 합류할 수 있다 ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ 이거 해결한다고 컨텐츠쪽을 거의 못하고 있었는데, 듬직한 팀원들이 많이 도와줬다 정말 너무 고맙다....ㅠㅠ 너무 고생한 나에게도 박수를.. 그래도 팀장님이 주신 데드라인에 맞춰 딱 해결되어 너무 다행이구.. 공부도 정말 많이 됐다. 시간적 여유만 있었으면 더 즐겁게 공부하고 해결할 수 있었을텐데 그게 아쉽지만, 뭐 회사를 가도 시간적 여유는 없을테니까..?! 암튼 솔루나 시프트 로직 진짜 진짜로 끝...!

0개의 댓글