[Unity Dedicated Server] #6 - TPS - Recoil

qweasfjbv·2026년 4월 16일

UnityServer

목록 보기
11/14
post-thumbnail

개요


총을 발사하는 이벤트를 구현하기 전에, Recoil 시스템을 만들어보도록 하겠습니다.

총을 발사할 때에는 (정확히는 yaw, pitch, recoil offset ...) Reconciliation을 하지 않습니다.

rotation에 Reconciliation을 적용하게 되면, 사용자 경험에 큰 영향을 끼치게 됩니다.

다만 플레이어의 입력으로 움직이는 yaw, pitch 와 Recoil에 의해 움직이는 Recoil Offset 을 구분하여 서버와 클라이언트에서 같은 결과가 나올 수 있도록 하고,

서버에서의 결과를 통해 에임 계산 및 발사 이벤트를 발생시켜야 합니다.

구현


우선 어떻게 구현해야할지부터 확인해보겠습니다.

저는 recoil을 pitchKick과 yawKick으로 나누어 생각하였습니다.

여기서 반동이 전부 사라진 이후에, Aim이 다시 Origin aim으로 돌아가면 어색할 것 같아서 pitch kick을 Permanent와 Recoverable로 나누어 계산해주었습니다.

이제 코드로 구현해보겠습니다.


[System.Serializable]
public class RecoilProfile
{
	[SerializeField] private float damping;
	[SerializeField] private float recovery;
	[SerializeField] private float permanentRatio;	// pitch kick 보존율
	[SerializeField] private float pitchKick;
	[SerializeField] private float yawKick;


	public float Damping => damping;
	public float Recovery => recovery;
	public float PermanentRatio => permanentRatio;
	public float RecoverableRatio => 1 - permanentRatio;
	public float PitchKick => pitchKick;
	public float YawKick => yawKick;
}

반동을 위한 정보를 저장할 클래스입니다.

이 반동 정보를 통해서 결정론적인 Recoil 결과를 계산해보도록 하겠습니다.

public static RecoilState SimulateRecoil(
	RecoilState state,
	PlayerInput input,
	RecoilProfile profile,
	float dt
	)
{
	if (input.isFired)
	{
		System.Random rng = new System.Random(input.tick);
		Vector2 totalKick = new Vector2(profile.PitchKick, (float)(rng.NextDouble() * 2.0 - 1.0) * profile.YawKick);
				
		state.pitchKickVelocity += totalKick.x * profile.PermanentRatio;
		state.recoilVelocity += new Vector2(totalKick.x * profile.RecoverableRatio, totalKick.y);
	}

	// recoil damping
	state.pitchKickVelocity = Mathf.Lerp(state.pitchKickVelocity, 0, profile.Damping * dt);
	state.recoilVelocity = Vector2.Lerp(state.recoilVelocity, Vector2.zero, profile.Damping * dt);
	state.recoilOffset += state.recoilVelocity * dt;

	// recoil recovery
	state.pitchKickVelocity = Mathf.Lerp(state.pitchKickVelocity, 0f, profile.Recovery * dt);
	state.recoilOffset = Vector2.Lerp(state.recoilOffset, Vector2.zero, profile.Recovery * dt);
	return state;
}

pitchKick은 Velocity를 결과로, (Recoverable)recoil 은 offset을 결과로 반환합니다.

pitchKickVelocity는 외부에서 실제 pitch값에 누적해서 더해주고, recoilOffset은 애니메이션 및 View 세팅 값에만 포함시켜 실제 캐릭터의 pitch값을 수정하지 않도록 합니다.

private void ApplyView(in PlayerInput input, in WeaponState weaponState)
{
	// ...

	currentPitch = Mathf.Clamp(currentPitch - weaponState.recoilState.pitchKickVelocity * Constants.TICK_DT, -viewPitchLimit, viewPitchLimit);
	float finalPitch = currentPitch - weaponState.recoilState.recoilOffset.x;
	transform.rotation = Quaternion.Euler(0f, currentYaw + weaponState.recoilState.recoilOffset.y, 0f); 
	cameraBoom.localRotation = Quaternion.Euler(finalPitch, 0f, 0f);
}

위 코드를 보면 currentPitch 값에 pitchKickVelocity를 빼주고, recoilOffset은 finalPitch에 따로 저장하여 카메라 Rotation 및 animation에 사용합니다.

아래는 반동 영상입니다.
(애니메이션은 AimOffset과 다른 UpperBody 레이어를 만들어 추가하였습니다.)

마무리


TPS에서 필수적인 recoil 시스템을 만들어 보았습니다.

생각보다 RecoverableKick과 PermanentKick을 나누는게 어려워서 오래 걸렸습니다.

코드를 조금 정리한 이후에 이어서 Fire를 구현하도록 하겠습니다.

0개의 댓글