✅ 오늘의 학습 목표
1. 3D 게임 버그 수정
2. Stage 2 개발
1. 버그 수정
1. 게임 상태 흐름 정리
게임 상태를 전환할 때 GameState = EGameState.Pause로 직접 대입하여 변경하지 않고
SetGameState() 메서드를 활용해서 호출 방식을 통일시키려고 한다.
public class GameManager : Singleton<GameManager>
{
// ...
public void SetGameState(EGameState state)
{
if (state == EGameState.Pause)
{
_player.GetComponent<PlayerController>().SetState(EPlayerState.Idle);
}
GameState = state;
}
private IEnumerator LoadSceneAsync(ESceneName sceneName)
{
// GameState = EGameState.Pause;
SetGameState(EGameState.Pause);
// ...
}
protected override void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
switch (scene.name)
{
case "Main":
if (_player)
{
Destroy(_player);
_player = null;
}
break;
case "Stage01":
case "Stage02":
var spawnPoint = GameObject.FindGameObjectWithTag("SpawnPoint").transform;
if (_player)
{
_player.transform.position = spawnPoint.position;
_player.transform.rotation = spawnPoint.rotation;
_player.SetActive(true); // 위치 수정
}
else
{
_player = Instantiate(playerPrefab, spawnPoint.position, spawnPoint.rotation);
DontDestroyOnLoad(_player);
}
break;
}
// GameState = EGameState.Play;
SetGameState(EGameState.Play);
}
}
기존처럼 State 값을 직접 대입해서 상태를 변환해버리면
플레이어 입력/애니메이션/루트모션/카메라 등 state 상황에 따른 부수 효과들의 처리가 분리되어 버그가 발생할 수 있다.
SetGameState 메서드를 통해서만 상태를 변경하고 그에 따른 일을 한번에 처리하도록 스크립트를 수정해주었다.
2. 애니메이션 기반 스폰 동기화
이제 게임 상태가 Play 상태가 아니라면 움직임을 제한하는 기능을 추가해주겠다.
public class PlayerController : MonoBehaviour
{
// ...
private void Awake()
{
// 컴포넌트 초기화 ...
// 상태 객체 초기화 ...
_states = new Dictionary<EPlayerState, ICharacterState>
{
{ EPlayerState.Idle, playerStateIdle },
{ EPlayerState.Move, playerStateMove },
{ EPlayerState.Jump, playerStateJump },
{ EPlayerState.Attack, playerStateAttack },
{ EPlayerState.Hit, playerStateHit },
};
// ...
}
private void OnEnable()
{
// 카메라 초기화
_playerInput.camera = Camera.main;
if (_playerInput.camera != null)
{
_playerInput.camera.GetComponent<CameraController>().SetTarget(headTransform, _playerInput);
}
// 상태 초기화
State = EPlayerState.None;
}
private void Update()
{
if (GameManager.Instance.GameState != EGameState.Play)
{
return;
}
// ...
}
// ...
private void OnAnimatorMove()
{
if (GameManager.Instance.GameState != EGameState.Play) return;
// ...
}
}
기존 Awake()메서드를 통해서 플레이어를 스폰시킬 때 애니메이션을 Idle 상태로 고정하는 방법 대신,
OnEnable()에서 초기 상태를 None으로 설정해 주었고
스폰 종료 시점에 Idle 애니메이션을 전환되도록 수정해주었다.
그런데 이렇게 스크립트를 수정해주면
스폰 애니메이션이 종료된 후 Idle 상태로 바꿔주는 명령문이 없어서
캐릭터의 State가 계속 None에 머물고 Update()안에서 상태가 None이 아닐 때만 상태를 업데이트 해주기때문에
스폰 후 플레이어가 그냥 뚝 멈춰있는다.
using UnityEngine;
public class PlayerSmbSpawn : StateMachineBehaviour
{
private PlayerController controller;
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (controller == null) controller = animator.GetComponent<PlayerController>();
}
public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
controller.SetState(Constants.EPlayerState.Idle);
}
}
이 문제점을 해결해주기 위해서 Spawn용 SMB를 생성하여
스폰 애니메이션이 끝날 때 플레이어의 상태를 Idle로 변경해주도록 해주었다.
이제 idle 전환 타이밍은 코드가 아니라 애니메이션 타임라인을 기준으로 전환된다.

2. Stage 2 개발
3D 게임을 개발하게되면 게임이 많이 무겁기 때문에 최적화하는 작업이 필요하다.
특히 그래픽 쪽에서 최적화가 제대로 이루어지면 좋은데 오늘 수업은 그래픽 최적화에 대해 학습하였다.
1. Batch
동일한 머테리얼을 공유하는 복수의 드로우콜을 하나로 묶어서 드로우콜하는 기법
🚨 Draw Call (드로우 콜)
CPU가 GPU에게 화면에 오브젝트를 그려달라고 요청하는 것
한 프레임에서 오브젝트를 하나 그릴 때 여러 정보들이 CPU에서 GPU로 전달
| 종류 | 설명 |
| Static Batching | - Static은 주로 변화(위치, 회전, 스케일 등)가 없는 오브젝트에 사용되는데 드로우콜 최적화에서도 많이 사용된다. - 여러 정적 오브젝트를 하나의 메시로 처리한다. |
| Dynamic Batching | - Vertex의 갯수가 400개 이하이면서 동일한 머테리얼을 사용할 경우 한번의 명령으로 GPU에게 전달한다. |
| GPU Instancing | - 같은 메시 + 같은 머테리얼을 여러 위치에 그릴 때 사용된다. - CPU에서는 명령을 한 번만 보내고 GPU가 알아서 여러개 그린다. |
| SRP Batcher | - URP/HDRP에서 사용한다. - CPU가 쉐이더 셋업을 최소화해서 드로우콜 처리 효율을 높인다. (동일한 쉐이더를 묶음) - 드로우콜 갯수가 줄지는 않지만 한번의 드로우콜 처리 비용이 대폭 줄어드는 방식이다. |
2. Rendering 최적화(Culling)
- Frustum Culling (프러스텀 컬링)

카메라가 보고 있는 시야 영역(Frustum) 밖의 오브젝트는 렌더링하지 않는 렌더링 최적화 기술이다.
카메라의 옵션을 설정할 때 자주 사용한 값이 있다.
- Near Clip Plane : 너무 가까운 것은 보이지 않게하는 경계
- Far Clip Plane : 너무 먼 것은 보이지 않게하는 경계
즉, Frustum = Near ~ Far 사이의 공간만 그리는 기술이다.

그런데 Far 값을 너무 낮추게되면
실제 게임을 하는 사람 입장에선 뚝! 끊긴 그래픽을 보게될 것이다.
이럴 땐 Lighting > Eniroment > Fog 를 체크하면 거리에 따라 점점 뿌옇게 보이게 할 수 있다.
Start = 0 카메라로부터 0m 이후부터 안개 시작
End = 30 30m에서 완전히 안개로 덮임 (Far Clip Plane과 비슷하게 맞춤)
- Occlusion Culling

카메라에 보이긴 하지만 다른 오브젝트에 가려진 것은 그리지 않는 기술
프러스텀 컬링을 먼저 적용한 후
남은 오브젝트 중에서 GPU가 Depth(깊이) 테스트 또는 Occlusion Data(오클루전 데이터)를 이용하여 가려진 물체 감지한다.
그러나 이때 오브젝트끼리 묶여있다면 가려졌다고 한들 그려지는 문제점이 있으니 주의해야한다.
(우리 프로젝트에서는 맵 전체가 하나의 오브젝트로 만들어졌기에 카메라에 보이지 않아도 그려짐)

3. Rendering Path (렌더링 경로)
유니티가 빛과 머테리얼을 조합해서 화면을 어떤 방식으로 그릴 것인가 결정하는 방법
| 종류 | 설명 |
| Forward Rendering | - 라이트를 하나씩 순차적으로 계산 (기본 설정) - Built int (Multi-Pass) / URP (Single-Pass) |
| Deferred Rendering | - 모든 픽셀 정보를 먼저 모은 뒤 한 번에 라이트 계산 (주로 PC/콘솔용, 고성능) |
🚨 Multi-Pass (멀티패스)
오브젝트를 빛 개수만큼 여러번 그린다는 뜻
만약 맵에 Light가 3개 배치되어 있다면
첫번째 라이트의 색과 밝기 계산 > 두번째 라이트 계산 > 세번째 리이트 계산
이렇게 하나의 오브젝트를 그리기 위해 총 3번 그리게 되어 드로우콜이 증가하고 프레임 드랍이나 깜빡임 현상이 발생할 수 있다.
❓ 깜빡임이 발생하는 이유?
| 항목 | 설명 |
| Pixel Light | 각 픽셀마다 조명을 계산 → 아주 정교하지만 연산량 큼 |
| Vertex Light | 버텍스(꼭짓점) 단위로만 계산 → 부드럽지만 정확도 낮음 |
빛을 계산할 때에는 픽셀 단위 또는 버텍스 단위로 계산할 수 있는데
Pixel Light count를 초과하면 남은 라이트는 자동으로 Vertex Light로 처리된다.
이 때 픽셀 계산<> 버텍스 계산이 번갈아 일어나면 깜빡이는 현상이 발생할 수 있다.
🚨 URP
위의 멀티패스의 단점을 보완하기 위해 URP에서는 Single Pass 방식으로 그리도록 개선하였다.
여러 Light를 한번의 패스에서 동시에 계산하여 그려낸다

이 때, 한번에 처리할 수 있는 Light의 갯수를 지정할 수 있는데
Project Settings > Quality > Pixel Light Count 에서 조정 가능하다.