언리얼 엔진 5/멀티플레이어 게임 폴리싱

[UE5] 멀티플레이어 게임 폴리싱 (2) - 렉 최적화 (카메라, 애니메이션, 보간 시간)

ciel45 2025. 10. 24. 16:50

(이하 작업은 모두 Editor Preference에서 Network Emulation을 Bad로 두고 하였다.)

 

렉이 걸리는 상황을 유심히 본 결과, 카메라가 드드득 움직이는 것이 플레이어 입장에서 렉을 더 심하게 느끼게 한다는 생각이 들었다.

 

따라서, 캐릭터의 CameraBoom의 EnableLag을 활성화시켜주었다.

Camera Boom에서의 Lag란, 한마디로 캐릭터를 부드럽게 따라가는 효과를 의미한다.

AISeeMeCharacter::AISeeMeCharacter()
{
	//...
	CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
	//...
	// 카메라 부드럽게 움직이도록
	CameraBoom->bEnableCameraLag = true;
	CameraBoom->bEnableCameraRotationLag = true;


	// Component Replicates는 false로. 다만 기본값이 false이므로 굳이 건드릴 필요 없음
	// CameraBoom->SetIsReplicated(false);
	//...
}

 

또한, CameraBoom의 Component Replicates는 false 그대로 두었다.

캐릭터의 위치는 replicate되지만, CameraBoom은 replicate 되지 않는다.

 

 

따라서, 캐릭터의 위치가 보정되어 드드득 움직이더라도, 카메라 자체는 바뀐 캐릭터의 위치를 향해 부드럽게 움직일 것이고, 이에 따라 최소한 카메라에 한해서는 렉이 줄어든 것 처럼 보이도록 하였다.

https://youtu.be/rXcSXh3GlFk

 

 

캐릭터는 거칠게 움직이지만, 잘 보면 카메라의 움직임만큼은 부드럽게 움직인다.

 

 

이제 캐릭터의 움직임도 최대한 부드럽게 만들 차례이다.

그 전에, 언리얼 엔진 멀티플레이어 환경에서 캐릭터의 위치가 어떻게 replicate되는지 다시 정리해보았다.

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/understanding-networked-movement-in-the-character-movement-component-for-unreal-engine#%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%EC%98%A4%EB%A5%98%EB%B0%8F%EB%B3%B4%EC%A0%95%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0

 

대강 정리해보자면 다음과 같다.

  • 클라이언트가 자기 캐릭터를 조작
  • 조작한 자기 캐릭터가 어떻게 움직였는지에 대한 정보를 잘 포장해서 서버로 전송 (RPC)
  • 서버에서 이 데이터를 수신, 클라이언트가 데이터대로 움직인 결과가 최종 보고된 위치와 맞는지 확인
  • 여기서 오차가 클 경우 서버가 강제로 RPC를 활용해 보정 ( ClientAdjustPosition )
    • 클라이언트는 그 보정 요청에 따라 캐릭터의 위치 조정

 

 

뚝뚝 끊겨보이는 근본적인 원인은, 서버가 보정해준 캐릭터 위치로의 보간을 너무 빨리해서이다.

 

즉 보간을 더 많은 프레임에 걸쳐서 하도록 하면 된다.

해당 보간 시간을 담당하는 변수는 CharacterMovementComponent의 NetworkSimulatedSmoothLocationTime, ListenServerNetworkSimulatedSmoothLocationTime이다.

 

따라서 CharacterMovementComponent를 상속받은 클래스를 만들어, 해당 변수들의 값을 변경해주었다.

 

// ISMCharacterMovementComponent.h

UCLASS()
class ISEEME_API UISMCharacterMovementComponent : public UCharacterMovementComponent
{
	GENERATED_BODY()
	
public:
	UISMCharacterMovementComponent(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());
};
// ISMCharacterMovementComponent.cpp
//...

UISMCharacterMovementComponent::UISMCharacterMovementComponent(const FObjectInitializer & ObjectInitializer)
	:Super(ObjectInitializer)
{
	// 클라이언트내 서버 pawn 위치를 보정하는 시간
	NetworkSimulatedSmoothLocationTime = 0.5f; // 기본값: 0.1f

	// 서버 내 클라이언트 pawn 위치를 보정하는 시간
	ListenServerNetworkSimulatedSmoothLocationTime = 0.4f; // 기본값: 0.04f
}

 

둘다 넉넉하게 늘려주었다.

 

그리고 캐릭터 클래스에서 해당 CMC를 사용하도록 하였다.

// ...

AISeeMeCharacter::AISeeMeCharacter(const FObjectInitializer & ObjectInitializer)
	:Super(ObjectInitializer.SetDefaultSubobjectClass<UISMCharacterMovementComponent>(ACharacter::CharacterMovementComponentName))
{
// ...


이렇게 설정하고 테스트한 결과는 다음과 같다.

https://youtu.be/m6wHtIr6wiI

 

우측 화면이 서버, 좌측 화면이 클라이언트이다.

 

캐릭터의 위치 자체는 부드럽게 바뀌지만, 서버에서 클라이언트 캐릭터의 애니메이션이 렉이 걸리는 것처럼 느리게 보이는 문제가 있었다.

이게 왜 그런가 열심히 포럼을 뒤져봤더니, 다음과 같은 글을 볼 수 있었다.

 

https://forums.unrealengine.com/t/listen-server-clients-animations-are-jittery-laggy/689493/15

 

Listen Server, clients animations are jittery/laggy

The problem is not simple because as a server you’re supposed to hold truth, and interpolation/smoothness is not the truth. As an example scenario, lagging client might send moves packets (1,2,3) but you receive them in order (2,3,1). Server cannot advan

forums.unrealengine.com

주요 내용을 정리하자면:

  • 클라이언트가 서버에게 자신의 움직임을 패킷으로 전송함
  • 이때 문제가 있어 패킷 전송이 늦어질 수 있음. 패킷의 순서가 뒤바뀌거나, 일부 패킷이 drop되어 재전송한다거나..
  • 서버는 이런식으로 패킷을 늦게 받으면, 보간을 통해 클라이언트 캐릭터 메쉬를 움직임.
  • 그리고 캐릭터 애니메이션은 CMC의 TickCharacterPose 함수가 tick 시키고, TickCharacterPose 함수는 SimulatedTick, MoveAutonomous 함수가 호출함.
    • MoveAutonomous : Process a move at the given time stamp, given the compressed flags representing various events that occurred (ie jump). 
    • SimulatedTick : Special Tick for Simulated Proxies.
  • 리슨 서버 상에서,  MoveAutonomous는 '클라이언트에게서 데이터를 받을 때' PerformMovement를 수행.
  • 그말인즉슨, 클라이언트에게서 데이터를 받을 때에만 애니메이션이 tick됨.
  • 이로 인해 통신 상황이 안 좋을 시 애니메이션이 낮은 빈도로 tick되기 때문에, 애니메이션 재생이 느려보이는 것.

 

 

 

각 함수를 실제로 뜯어보면 다음과 같다.

 

TickCharacterPose:

void UCharacterMovementComponent::TickCharacterPose(float DeltaTime)
{
	if (DeltaTime < UCharacterMovementComponent::MIN_TICK_TIME)
	{
		return;
	}

	check(CharacterOwner && CharacterOwner->GetMesh());
	USkeletalMeshComponent* CharacterMesh = CharacterOwner->GetMesh();

	// bAutonomousTickPose is set, we control TickPose from the Character's Movement and Networking updates, and bypass the Component's update.
	// (Or Simulating Root Motion for remote clients)
	CharacterMesh->bIsAutonomousTickPose = true;

	if (CharacterMesh->ShouldTickPose())
	{
		// Keep track of if we're playing root motion, just in case the root motion montage ends this frame.
		// Also cache the root motion translation scale, in case the root motion ends in TickPose and
		// translation scale is reset by a blend out listener.
		const bool bWasPlayingRootMotion = CharacterOwner->IsPlayingRootMotion();
		const float RootMotionTranslationScale = CharacterOwner->GetAnimRootMotionTranslationScale();

		CharacterMesh->TickPose(DeltaTime, true); // 여기서 애니메이션 Tick 수행

// ...
	}

	CharacterMesh->bIsAutonomousTickPose = false;
}

 

MoveAutonomous:

void UCharacterMovementComponent::MoveAutonomous
	(
	float ClientTimeStamp,
	float DeltaTime,
	uint8 CompressedFlags,
	const FVector& NewAccel
	)
{
	if (!HasValidData())
	{
		return;
	}

//...

	// Defer all mesh child updates until all movement completes.
	FScopedMeshMovementUpdate ScopedMeshUpdate(CharacterOwner->GetMesh());

	PerformMovement(DeltaTime);

	//...

	// If not playing root motion, tick animations after physics. We do this here to keep events, notifies, states and transitions in sync with client updates.
	if( CharacterOwner && !CharacterOwner->bClientUpdating && !CharacterOwner->IsPlayingRootMotion() && CharacterOwner->GetMesh() )
	{
		if (!bWasPlayingRootMotion) // If we were playing root motion before PerformMovement but aren't anymore, we're on the last frame of anim root motion and have already ticked character
		{
			TickCharacterPose(DeltaTime); // 여기서 TickCharacterPose 호출
		}
		//...
		if (CharacterMovementCVars::EnableQueuedAnimEventsOnServer)
		{
			if (UAnimInstance* AnimInstance = OwnerMesh->GetAnimInstance())
			{
				if (OwnerMesh->VisibilityBasedAnimTickOption <= EVisibilityBasedAnimTickOption::AlwaysTickPose && AnimInstance->NeedsUpdate())
				{
					// If we are doing a full graph update on the server but its doing a parallel update,
					// trigger events right away since these are notifies queued from the montage update, and we could be receiving multiple ServerMoves per frame.
					OwnerMesh->ConditionallyDispatchQueuedAnimEvents();

					// We need to manually clear the anim notify queue (since normally its only is cleared in PreUpdateAnimation()) otherwise if animation ticks, the notifies queued from the ServerMove would fire twice.
					AnimInstance->ClearQueuedAnimEvents(false);

					// When animation ticks, we want its queued events to be triggered.
					OwnerMesh->AllowQueuedAnimEventsNextDispatch();
				}
			}
		}
		else
		{
			//...
		}
	}

	if (CharacterOwner && UpdatedComponent)
	{
		// Smooth local view of remote clients on listen servers
		if (CharacterMovementCVars::NetEnableListenServerSmoothing &&
			CharacterOwner->GetRemoteRole() == ROLE_AutonomousProxy &&
			IsNetMode(NM_ListenServer))
		{
			SmoothCorrection(OldLocation, OldRotation, UpdatedComponent->GetComponentLocation(), UpdatedComponent->GetComponentQuat());
		}
	}
}

 

이 문제를 해결하기 위해서는, 애니메이션 tick이 클라이언트의 데이터 전송에 의해서가 아니라, 메쉬 자체의 tick에 의해 수행되도록 하면 된다.

 

이를 위해서는 메쉬의 bOnlyAllowAutonomousTickPose를 꺼주면 된다.

void AISeeMeCharacter::PossessedBy(AController* NewController)
{
//...

	// 애니메이션 업데이트가 네트워크 업데이트에 의해서가 아닌, 메쉬 tick에 의해 이루어지도록 함
	GetMesh()->bOnlyAllowAutonomousTickPose = false;
}

 

해당 변수의 정의는 다음과 같다.

	/** If true TickPose() will not be called from the Component's TickComponent function.
	* It will instead be called from Autonomous networking updates. See ACharacter. */
	UPROPERTY(Transient)
	uint8 bOnlyAllowAutonomousTickPose : 1;

 

기본적으로 true로 되어있어 메쉬의 tick이 TickPose()를 호출하는 것이 아니고 네트워크 업데이트에 의해 TickPose()가 호출되도록 되어있다.

 

다시말해, 이걸 반대로 false로 함으로써 TickPose()가 메쉬의 tick에 의해 호출되도록 할 수 있다.

 

포렁에서는 해당 값을 false로 해주면 TickCharacterPose가 CMC에 의해서, 네트워크에 의해서 총 2번 실행될 수 있기 때문에 이를 방지하기 위해 ServerAutonomousProxyTick 함수 내에서 해당 부분을 막아줘야한다는 말도 있던데, 내 생각엔 그렇지 않았다.

이미 TickCharacterPose 함수 내에서 ShouldTickPose()를 통해 이번 프레임에 이미 포즈가 tick되었는지 검사하고 true일 경우 바로 빠져나오도록 되어있었기 때문이다.

실제로도 저렇게 세팅하고 실행한 결과 애니메이션이 2배로 빠르게 보인다거나 하는 문제는 없었다.

 

 

여기까지 진행한 결과는 다음과 같다.

https://youtu.be/kJqSMfuO8Ls

 

 

여전히 렉은 느껴지지만, 처음과 비교하면 장족의 발전이라고 할 수 있을 것 같다!

 

앞으로 더 보완하고 싶은 점은 다음과 같다.

  • 클라이언트의 본인 캐릭터의 버벅거림 해결
  • 네트워크 환경을 평가하여 CMC를 선택하여 사용하는 기능 추가
    • 네트워크 환경이 좋은데 굳이 이번에 새로 만든, 보간처리가 오래걸리는 CMC를 쓸 이유는 없으니까