오늘 학습 키워드

유니티 숙련 팀 프로젝트, 스탠다드 반 강의

오늘 학습 한 내용을 나만의 언어로 정리하기

팀플

스폰 위치에서 너무 멀어지면 돌아가게 하고 싶어요

  • 그룹을 만들기 전에, 이 몬스터가 그룹에서 너무 멀리 떨어지면 원 위치로 돌아가도록 만들려고 했음.
  • 그래서 AIState.Returning을 추가함
// Monster.cs
 
void Update()  
{  
    // 플레이어와 몬스터간의 거리 계산  
    playerDistance = Vector3.Distance(CharacterManager.Instance.Player.transform.position, transform.position);  
    spawnDistance = Vector3.Distance(spawnPosition, transform.position);  
    if (!isReturning && spawnDistance > MaxDistFromSpawn)  
    {  
        SetState(AIState.Returning);  
        isReturning = true;  
    }  
      
    Debug.Log($"{aiState}");  
      
    // State가 멈춤 상태가 아니면 Moving 애니메이션 재생  
    if(animator != null)  animator.SetBool("Moving", aiState != AIState.Idle);  
  
    switch (aiState)  
    {  
        case AIState.Idle:  
        case AIState.Wandering:  
            PassiveUpdate();  
            break;  
        case AIState.Returning:  
            // Debug.Log("너무 멀어짐. 돌아감.");  
            ReturningUpdate();  
            break;  
        case AIState.Attacking:  
            AttackingUpdate();  
            break;  
    }  
}
 
void ReturningUpdate()  
{  
    if (isReturning && agent.remainingDistance < 0.1f)  
    {  
        SetState(AIState.Wandering);  
        isReturning = false;  
    }  
    else if (isReturning && spawnDistance > MaxDistFromSpawn)  
    {  
        Debug.Log("돌아가기");  
        agent.isStopped = true;  
  
        // 스폰 위치 바라보기  
        Vector3 lookDirection = (spawnPosition - transform.position).normalized;  
        lookDirection.y = 0;   
        if (lookDirection != Vector3.zero)  
        {  
            Quaternion targetRotation = Quaternion.LookRotation(lookDirection);  
            transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 5f);  
        }  
          
        agent.isStopped = false;  
        agent.SetDestination(spawnPosition);  
    }  
}
 

이제 그룹 단위로 몬스터를 소환해요

  • 이번에 GPT의 도움을 많이 받음
  • MonsterManager가 사각형의 두 꼭짓점을 입력받으면, 그 두 점으로 생성할 수 있는 큰 사각형 내에서 랜덤으로 위치를 뽑아서 스폰하도록 함
  • 몬스터 그룹을 결정짓는 키는 Vector3 위치임
public class MonsterManager : MonoSingleton<MonsterManager>  
{  
    [Header("Monster Group Prefabs")]  
    [SerializeField] private List<GameObject> monsterGroupPrefabs; // 여러 개의 프리팹 중 랜덤 선택  
  
    [Header("Spawn Settings")]  
    [SerializeField] private Vector3 cornerA; // 평지의 꼭짓점 A    [SerializeField] private Vector3 cornerB; // 평지의 꼭짓점 B    [SerializeField] private int groupCount = 5; // 몬스터 그룹 갯수  
    [SerializeField] private float minDistance = 5f; // 그룹 간 최소 간격  
    [SerializeField] private int maxRetryCount = 30; // NavMesh 유효 위치 찾기 재시도 횟수  
    [SerializeField] private float respawnDelay = 60f;  
      
    private Dictionary<Vector3, GameObject> activeMonsterGroups = new Dictionary<Vector3, GameObject>();  
  
    private Dictionary<Vector3, ObjectPool<GameObject>> groupPools = new Dictionary<Vector3, ObjectPool<GameObject>>();  
    private List<Vector3> spawnPositions = new List<Vector3>();  
  
    private void Awake()  
    {  
        spawnPositions = GenerateRandomPositions(cornerA, cornerB, groupCount, minDistance);  
  
        // 포지션 하나마다 몬스터 그룹 설정  
        foreach (var pos in spawnPositions)  
        {  
            GameObject prefab = monsterGroupPrefabs[Random.Range(0, monsterGroupPrefabs.Count)];  
  
            var pool = new ObjectPool<GameObject>(  
                createFunc: () =>  
                {  
                    GameObject obj = Instantiate(prefab, pos, Quaternion.Euler(0f, Random.Range(0f, 360f), 0f));  
                    obj.SetActive(false);  
                    // 각 몬스터에 manager/그룹 키 알려주기  
                    foreach (var m in obj.GetComponentsInChildren<Monster>())  
                    {  
                        m.Init(this, pos);  
                        m.onDeath += OnMonsterDeath;  
                    }  
                    return obj;  
                },  
                actionOnRelease: (obj) => { obj.SetActive(false); },  
                actionOnDestroy: (obj) => { Destroy(obj); },  
                collectionCheck: true,  
                defaultCapacity: 1, // 어차피 풀 관리 할 게 하나밖에 없음. 그룹단위라서  
                maxSize: 5  
            );  
  
            groupPools[pos] = pool;  
        }  
  
        // 초기 스폰  
        foreach (var pos in spawnPositions)  
        {  
            Debug.Log($"{pos}에서 생성됨");  
            SpawnGroupAt(pos);  
        }  
    }  
  
    /// <summary>  
    /// 사각형의 네 꼭짓점 중 두 꼭짓점(a, b)를 받아서 count 만큼의 Vector3을 생성함. 이 때 각 Vector3은 minDist 이상의 거리 차이를 가지게 함.  
    /// </summary>    /// <param name="a"></param>    /// <param name="b"></param>    /// <param name="count"></param>    /// <param name="minDist"></param>    /// <returns></returns>    private List<Vector3> GenerateRandomPositions(Vector3 a, Vector3 b, int count, float minDist)  
    {  
        List<Vector3> positions = new List<Vector3>();  
        float minX = Mathf.Min(a.x, b.x);  
        float maxX = Mathf.Max(a.x, b.x);  
        float minZ = Mathf.Min(a.z, b.z);  
        float maxZ = Mathf.Max(a.z, b.z);  
  
        int safetyLimit = 500;  
        int placed = 0;  
  
        while (placed < count && safetyLimit > 0)  
        {  
            safetyLimit--;  
            Vector3 randomPos = new Vector3(  
                Random.Range(minX, maxX),  
                a.y,  
                Random.Range(minZ, maxZ)  
            );  
  
            if (NavMesh.SamplePosition(randomPos, out NavMeshHit hit, 2f, NavMesh.AllAreas))  
            {  
                bool valid = true;  
                foreach (var p in positions)  
                {  
                    if (Vector3.Distance(p, hit.position) < minDist)  
                    {  
                        valid = false;  
                        break;  
                    }  
                }  
  
                if (valid)  
                {  
                    positions.Add(hit.position);  
                    placed++;  
                }  
            }  
        }  
  
        return positions;  
    }  
  
    public void SpawnGroupAt(Vector3 pos)  
    {  
        if (!groupPools.ContainsKey(pos)) return;  
        // Get 할때 GameObject SetActive True 해놔서 Get만 해도 됨  
        var group = groupPools[pos].Get();  
        group.SetActive(true);  
        if(!activeMonsterGroups.ContainsKey(pos))  
            activeMonsterGroups.Add(pos, group);  
          
        // group이 active 되어도 안에 있는 애들이 inactive라 다시 세팅 해줘야함  
        foreach (var m in group.GetComponentsInChildren<Monster>(true))  
        {  
            m.gameObject.SetActive(true);  
        }  
    }  
  
    public void OnMonsterDeath(Vector3 groupKey)  
    {  
        if (!groupPools.ContainsKey(groupKey)) return;  
          
        GameObject group = activeMonsterGroups[groupKey];  
        bool allDead = false;  
          
        // 활성화 된 애들이 한 명도 없으면 다 죽은거  
        int length = group.GetComponentsInChildren<Monster>(false).Length;  
        if (length == 0)  
            allDead = true;  
  
        if (allDead)  
        {  
            // 풀에 돌려주기  
            groupPools[groupKey].Release(group);  
            StartCoroutine(RespawnAfterDelay(groupKey));  
        }  
    }  
  
    private IEnumerator RespawnAfterDelay(Vector3 groupKey)  
    {  
        yield return new WaitForSeconds(respawnDelay);  
        Debug.Log("몬스터 재생성");  
        SpawnGroupAt(groupKey);  
    }  
}

스탠다드 반 강의 (주제 : 비동기, 코루틴, Task)

프로세스와 스레드

  • 프로세스 : 현재 실행되어 작업중인 컴퓨터 프로그램
  • 스레드 : 프로세스 내에서 실행되는 단위.
    • 하나의 프로세스는 여러 스레드를 가질 수 있음

동기와 비동기

  • 동기 : 코드를 순차적으로 실행하는 방식
    • 한 작업이 시작되면 그 작업이 끝날 때까지 다음 작업을 시작할 수 없음
  • 비동기 : 작업이 완료될 때까지 기다리지 않고 다음 작업을 시작할 수 있음

코루틴

  • 작업을 다수 프레임에 분산할 수 있음.
  • 실행을 일시 정지하고 제어를 Unity에 반환하지만 중단한 부분에서 이어서 계속할 수 있는 메소드
  • 장점
    • 비동기 작업 처리
    • 간편한 시간 지연 구현
    • 코드 가독성 향상
  • 단점
    • GameObject에 의존적임. 오브젝트가 비활성화 되거나 삭제되면 코루틴도 종료됨
    • 여러 개 사용 시 관리가 어려움. 중첩 시 메모리 사용량 크게 증가.

Yield Return의 종류

  • null : update 끝날 때 까지
  • new WaitForEndOfFrame() : 렌더링 끝난 프레임이 종료될 때 까지
  • new WaitForSeconds() : 시간이 지날 때까지
  • new WaitForSecondsRealtime() : 시간이 지날 때까지 (Time.timeScale 영향 안 받음)
  • new WaitUntil() : 괄호 안의 조건이 True일 때까지
  • new WaitWhile() : 괄호 안의 조건이 False일 때까지
  • break : 코루틴 종료 (yield break로 씀)

코루틴은 멀티스레드가 아니다!!!!!!!!!!

  • 유니티 API는 여러 이슈를 고려해 단일 스레드를 사용함
  • 메인 스레드 내에서 비동기처럼 작동하는 것.

Task

  • C#에서 지원하는 기능

  • 멀티스레드 가능 (유니티에서 쓰면 단일스레드 방식으로 씀)

  • async와 await를 통해 반환값을 받을 수 있음

  • 주로 외부 플러그인이나 서버와 통신할 때 Task 형식의 비동기로 처리함

  • 단점

    • Unity 생명주기와 다르게 작동하기 때문에 효율이 안좋음.
    • C#을 포함한 외부 플러그인들은 멀티스레드를 활용하는 메소드가 많음.
    • 그래서 메인스레드로 유도하는 기능(MainThreadDispather)이 필요함.
  • 예시 코드

void Start()
{
	TaskTest();
}
 
public async void TaskTest()
{
	Debug.Log("실행");
	await Task.Yield(); // yield return null과 동일
	Debug.Log("종료");
}

await의 종류

  • Task.Yield() : yield return null과 동일
  • Task.Delay() : 시간 기다리기. 밀리세컨드 기준임

반환값을 받으려면?

void Start()
{
	var operation = TaskTest();
	// operation.Result 쓰면 됨. 
	// 근데 바로 Debug.Log로 찍는다고 해도 안나옴. 
	// 왜냐? 3초 기다리게 해놨으니까.
}
 
public async Task<string> TaskTest()
{
	await Task.Delay(3000);
	return "결과";
}
  • 값이 나올 때까지 기다려봅시다 기다리는 애도 비동기로 만들어 주어야 함
public async void TaskTest()
{
	var operation = Plus(3, 5);
	// 또는, var operation = await Plus(3, 5);
	while(!operation.IsCompleted)
	{
		Debug.Log("기다리는 중");
		await Task.Yield();
	}
}
 
public async Task<int> Plus(int a, int b)
{
	Debug.Log("3초 이후에 실행");
	await Task.Delay(3000);
	return a+b;
}
  • 스레드를 확인할 수 있음
Debug.Log(Thread.currentThread.ManagedThreadId)
  • 아래의 방법대로 하면 멀티스레드로 가능함. 다만 이러면 Task 안에서 유니티 API 사용 불가
Task.Run(() => {}); 
Task task = new Task(함수이름)

대체 기술

  • UniTask : Task와 사용 방식은 거의 동일함. 최적화에 유리. 다만 외부 플러그인과 호환성이 좋지 않고 멀티스레드 사용으로는 부적합함
  • Awaitable : Unity 2023 이상부터 사용 가능. Unity 6 에서는 코루틴과 Task를 대체할 수 있을 정도로 기능이 크게 개선되었다고 함.

실사용 예시

적이 플레이어를 쫓아오도록 만들기

  • 이렇게 update문에 안넣고 따로 관리하면 최적화 굿
public GameObject target;
 
IEnumerator Update()
{
	while(true)
	{
	transform.LookAt(target.transform.position);
	Vector3 direction = (target.transform.position - transform.position).normalized;
	tranform.position += direction;
	yield return new WaitForSeconds(0.1f);
	}
	
}

코루틴을 멈추기

// 참고 : 함수 명으로 멈추는게 가능은 하지만, 당연히 권장되는 사항은 아님.
IEnumerator co;
co = StartCoroutine(co);
StopCoroutine(co);
// 이렇게 관리하면 나중에 다시 시작시키기에도 좋음

StopAllCoroutine() 쓰지마세요!!!!!

  • 모든 코루틴이 다 종료되는거기 때문에 위험함!!!!

씬 이동도 비동기로 가능

var operation = SceneManager.LoadSceneAsync("다른씬");
// 다만, 여기서 SceneManager.LoadSceneAsync의 반환은 Task는 아니고 AsyncOperation임.
// 그래서 await은 안됨.
 
while(!operation.isDone)
{
	Debug.Log("로딩 창 띄우기");
	await Task.Yield();
}
 
Debug.Log("완료")

데이터를 비동기로 불러옵시다.

 
var operation = Resources.LoadAsync<GameObject>("object");
 
while(!operation.isDone)
{
	Debug.Log("로딩중");
	Debug.Log(operation.progress); // 로드 진행상황 받아올 수 있으니 좋음.
	await Task.Yield();
}

질문

  • OnSceneLoaded vs SceneManager.LoadSceneAsync
    • 퍼포먼스 상의 차이는 없음
    • 다만 LoadSceneAsync는 코드의 흐름이 한 눈에 보인다는 점이 좋고
    • 그리고 OnSceneLoaded는 델리게이트라서 코드 추적이 어려움.
    • 튜터님은 후자를 선호하심