[개발일지] Unity Multiplay #3 - Player Synchronization

qweasfjbv·2025년 3월 5일

개발일지

목록 보기
3/8

개요


Network 환경에서 플레이어들의 Transform 및 Animator 동기화를 통해 모든 사람이 올바른 위치에 있을 수 있도록 하겠습니다.

Unity에는 NetworkObject 말고도 NetworkTransform, NetworkRigidbody, NetworkAnimator 를 지원하지만, 생각보다 성능이 좋지 않고 씹히는 경우가 있어서 ServerRPC로 직접 만들어서 사용하려고 합니다.

추가적으로, 기본적으로 이동에 대한 구현은 Rigidbody의 AddForce를 사용해서 구현하도록 하겠습니다.
Transform 조작, Rigidbody.MovePosition 은 Collider를 무시할 가능성이 있고, Velocity를 조작하는건 Slope 처리나 구 위에서 움직여야할 때 애로사항이 생길 수 있습니다.

구현


Basic Structure

기본적인 구조는 간단한 StateMachine을 사용합니다.
해당 StateMachine에 대한 글은 해당 글을 참고하시기 바랍니다.
이번 글에서는 구조보다는 개별 함수 구현을 살펴보도록 하겠습니다.

Basic Movements

중력 계산

저는 여러가지 중력이 작용할 수 있는 게임을 구상하고 있기 때문에, 우선 중력부터 정의해야 합니다.
ground 레이어를 가진 collider들의 부모에 Gravity의 방향을 알 수 있는 클래스를 정의합니다.

/** GroundBase.cs **/
	public abstract class GroundBase : MonoBehaviour
	{
		public abstract GravityType GravityType { get; }
		public abstract Vector3 GetGravityVector();
	}
    
/** PlanetGround.cs **/
	public class PlanetGround : GroundBase
	{
		public override GravityType GravityType { get => GravityType.Point; }
		public override Vector3 GetGravityVector()
		{
			return transform.position;
		}
	}
        
/** DirectionGround.cs **/
	public class DirectionGround : GroundBase
	{
		public override GravityType GravityType { get => GravityType.Direction; }
		public override Vector3 GetGravityVector()
		{
			return -transform.up;
		}
	}

PointGravityDirectionalGravity 를 나누어서 캐릭터가 중력을 받을 방향을 실시간으로 갱신합니다.

		Vector3 gravityDirection = Vector3.zero;
		Collider[] hitObjects;
		public void GroundedCheck()
		{
			Vector3 rectPosition = new Vector3(transform.position.x, transform.position.y - groundedOffset,
				transform.position.z);
			hitObjects = Physics.OverlapBox(rectPosition, groundRectSize, transform.rotation, Constants.LAYER_GROUND);
			isGrounded = hitObjects.Length > 0;

			if (!IsGrounded) return;

			SetParentServerRPC(hitObjects[0].transform.parent.GetComponent<NetworkObject>().NetworkObjectId);

			GroundBase gb = hitObjects[0].GetComponentInParent<GroundBase>();
			gravityType = gb.GravityType;
			gravityVector = gb.GetGravityVector();
		}
        
		public void CalculateGravity()
		{
			switch (gravityType)
			{
				case GravityType.Point:
					gravityDirection = Vector3.Normalize(gravityVector - transform.position);
					break;
				case GravityType.Direction:
					gravityDirection = gravityVector;
					break;
			}
		}

OverlapBox 함수를 통해서 바닥의 Ground 레이어를 탐지합니다.
만약 Ground 레이어가 있다면 해당 오브젝트를 플레이어의 부모로 설정해서 해당 오브젝트에 따라다닐 수 있도록 했습니다. ( 우주선같은 경우 우주선의 속도를 따라가야함 )

GroundBase 를 찾으면 해당 Gravity에 대한 정보를 계산하여 gravityDirection 에 저장하여 중력을 계산합니다.




중력 적용

		public void ApplyGravity()
		{
			if (!useGravity) return;
			
			projectedForward = Vector3.ProjectOnPlane(transform.forward, gravityDirection).normalized;
			Quaternion targetRotation = Quaternion.LookRotation(projectedForward, -gravityDirection);

			rigid.MoveRotation(targetRotation);
			rigid.AddForce(gravityDirection * gravityForce, ForceMode.Acceleration);
		}

위에서 계산한 중력은 중력은 ApplyGravity 함수를 통해 플레이어에게 적용시킬 수 있습니다
여기서는 PointGravity의 경우 중력이 작용하는 방향이 계속 달라지기 때문에 그에 따라 Player의 Rotation을 조정해줍니다.

물론, 움직이는 것과 점프하는 것까지 중력의 방향을 고려해야 합니다.

		public void SlopeCheck()
		{
			Debug.DrawRay(transform.position, -transform.up * slopeRayLength, Color.red);
			if (Physics.Raycast(transform.position, -transform.up, out RaycastHit hit, slopeRayLength))
			{
				normalVector = hit.normal;
				slopeAngle = Vector3.Angle(hit.normal, -gravityDirection);

				float landAngle = Vector3.Angle(hit.normal, transform.up);
				isOnSlope = slopeAngle > slopeLimit;
				isOnSlopeWhileFlying = slopeAngle > slopeLimit || landAngle > enableToLandAngle;
			}
		}

우선 땅에 닿을 때 걸을 수 있는 경사인지 체크합니다.
이 함수에서 세팅하는 isOnSlope, isOnSlopeWhileFlying 변수는 Detect 함수들에서 사용됩니다.

		public void DetectIsGround()
		{
			if (isGrounded)
			{
				stateMachine.ChangeState(idleState);
			}
		}
		public void DetectIsFalling()
		{
			if (isGrounded) return;
			DetectIsFallingWhileJump();
		}
		public void DetectIsFallingWhileJump()
		{
			if (Vector3.Dot(rigid.linearVelocity, transform.up) <= 0f)
			{
				stateMachine.ChangeState(fallState);
			}
		}

Detect 함수들은 특정 상태에서 다른 상태로 넘어가야할 때를 체크합니다.




움직임 구현

public void WalkWithArrow(float vertInputRaw, float horzInputRaw, float diag)
{
	Vector3 moveDir = (transform.forward * horzInputRaw + transform.right * vertInputRaw) * diag * walkSpeed;
	
	currentSpeed = moveDir.magnitude * walkSpeed * diag;
	rigid.AddForce(Vector3.ProjectOnPlane(moveDir, normalVector) * Time.fixedDeltaTime, ForceMode.VelocityChange);
}

우선 moveDir 에 키보드 입력값과 diag를 통해 대각선 방향으로의 속도를 보정해줍니다.
위에서와 마찬가지로 ProjectOnPlane 함수를 사용하면 바닥과 평행하게 힘을 줄 수 있습니다.


		public void RotateWithMouse(float mouseX, float mouseY)
		{
			rigid.AddTorque(transform.up * mouseX * rotationPower, ForceMode.Force);
			cameraTransform.GetComponent<Rigidbody>().AddTorque(-transform.right * mouseY * rotationPower, ForceMode.Force);
			
			Vector3 camRot = cameraTransform.localRotation.eulerAngles;
			camRot.y = Mathf.Clamp(camRot.y, minVertRot, maxVertRot);
			cameraTransform.localRotation = Quaternion.Euler(camRot);
		}

마우스로 고개를 돌리는 함수입니다.
AddTorque를 사용해서 구현했고, 위/아래를 보는 rotation은




플레이어 동기화

		[ServerRpc(RequireOwnership = false)]
		private void UpdatePlayerTransformServerRPC(Vector3 playerPosition, Quaternion playerQuat, Quaternion camQuat)
		{
			UpdatePlayerTransformClientRPC(playerPosition, playerQuat, camQuat);
		}

		[ClientRpc]
		private void UpdatePlayerTransformClientRPC(Vector3 playerPosition, Quaternion playerQuat, Quaternion camQuat)
		{
			if (IsOwner) return;
			transform.position = playerPosition;
			transform.rotation = playerQuat;
			cameraTransform.localRotation = camQuat;
		}

정말 간단하게 동기화를 시켜주는 RPC 함수입니다.
물리연산을 서버에서 시키면 딜레이가 생기고 위치가 불일치하는 경우가 생기며, 중간에 주기적으로 Transform 을 동기화 해주더라도 렉이 걸리는 것 같이 버벅입니다.

저는 경쟁 게임이 아닌, 협동 게임을 구상하고 있기 때문에 동기화는 간단하게 하고 이 이후에 필요하면 더 추가하는 방향으로 개발하겠습니다.


		[ServerRpc(RequireOwnership = false)]
		public void SetParentServerRPC(ulong parentId)
		{
			if(NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(parentId, out NetworkObject parentObject)){
				transform.parent = parentObject.transform;      // 감지하면 Parent로 설정
			}
		}
		[ServerRpc(RequireOwnership = false)]
		public void UnsetParentServerRPC()
		{
			transform.parent = null;
		}

player는 NetworkObject 이므로 부모를 설정하기 위해선 두가지 규칙이 필요합니다.

  1. 부모는 런타임에 Spawn된 NetworkObject 일 것
  2. parent 설정은 서버에서만 할 것

물론, 설정하기 위해서 Transform을 보내면 Serialize 할 수 없다고 에러가 뜨기 때문에 NetworkObject 에서 NetworkObjectId 를 받아와서 ServerRPC 를 호출해야합니다.

( 위의 GroundCheck 함수 참고 )


		public void ChangeAnimatorParam(int id, bool param)
		{
			animator.SetBool(id, param);
			ChangeAnimatorParamServerRPC(id, param);
		}
		public void ChangeAnimatorParam(int id, float param)
		{
			animator.SetFloat(id, param);
			ChangeAnimatorParamServerRPC(id, param);
		}

		[ServerRpc(RequireOwnership = false)]
		public void ChangeAnimatorParamServerRPC(int id, bool param)
		{
			ChangeAnimatorParamClientRPC(id, param);
		}
		[ServerRpc(RequireOwnership = false)]
		public void ChangeAnimatorParamServerRPC(int id, float param)
		{
			ChangeAnimatorParamClientRPC(id, param);
		}

		[ClientRpc]
		public void ChangeAnimatorParamClientRPC(int id, bool param)
		{
			if (IsOwner) return;
			animator.SetBool(id, param);
		}
		[ClientRpc]
		public void ChangeAnimatorParamClientRPC(int id, float param)
		{
			if (IsOwner) return;
			animator.SetFloat(id, param);
		}

Animator 동기화는 오버로딩을 사용해서 Server, ClientRPC를 구현해줍니다.
이렇게 구현하면 한 프레임에 여러 번 바뀌는 경우에도 파라미터가 씹히지 않고 다 바뀌게 됩니다.

마무리


구 위를 걷거나 점프하는 것, 이동 및 애니메이션 동기화까지 잘 된 모습을 확인할 수 있습니다.

아무래도 Walk를 Force로 구현하니 미끄러지는 듯한 들어서 상태마다 linearDamingPhysicsMaterial 을 지정해 줬는데도 그런 느낌이 완전히 사라지지는 않은 것 같습니다. 해당 부분은 계속 파라미터를 바꿔가며 고쳐보겠습니다.

다음은 운전하는 상태를 만들어서 입력이 플레이어가 아닌 우주선을 제어하도록 해보겠습니다.

0개의 댓글