본문 바로가기

무제_LR

Effect Prefab 생성 - Pooling

 

 

 

파티클/애니메이터 이펙트 풀링 서비스를 만들었습니다.

사실 작업이라기엔 좀 그렇다만 암튼 작업물은 작업물이니까요.

 

이펙트가 어떻게 만들어질지 제대로 구상해놓은 것은 없어서 적당히 ParticleSystem/Animator 두 종류로 호환되는 클래스를 만들었습니다.

Animator를 사용하는 AnimEffectObject

ParticleSystem을 사용하는 ParticleEffectObject

이 두 클래스는 BaseEffectObject를 상속합니다.

 

  public abstract class BaseEffectObject : MonoBehaviour
  {
    private EffectTableSO effectTable;
    protected EffectTableSO EffectTable
    {
      get
      {
        if (effectTable == null)
          effectTable = GlobalManager.instance.Table.EffectTableSO;

        return effectTable;
      }
    }//MonoBehaviour다보니 테이블 클래스 주입 메소드를 만들기보단
   	 //프로퍼티로 필요할 때마다 알아서 가져오게 만드는 것이 좋아 보였음

    public abstract UniTask PlayAsync(UnityAction onComplete = null);

    public abstract void DestoryImmediately();
  }

Animator나 ParticleSystem이나 '실행 후 끝나면 알아서 사라져라'라는 행동은 일맥상통할 테니 하나의 클래스로 통일하는건 딱히 어렵지 않았습니다.

 

  public class AnimEffectObject : BaseEffectObject	//Animator 기반 BaseEffectObject
  {
    //각종 필드와 CTS 생략
    public override async UniTask PlayAsync(UnityAction onComplete = null)
    {
      cts.Create();
      var token = cts.token;
      await UniTask.WaitForSeconds(beforeDelay, false, PlayerLoopTiming.Update, token);

      var playHash = EffectTable.AnimEffectTable.PlayHash;
      foreach (var animator in animators)
        animator.Play(playHash);	//굳이 파라미터를 만들지 않고 애니메이션 직접 실행

      await UniTask.NextFrame(token);

      await UniTask.WaitUntil(() =>
      {
        foreach (var animator in animators)
        {
          if (animator == null)
            continue;

          var state = animator.GetCurrentAnimatorStateInfo(0);
			//AnimatorStateInfo를 사용해 현재 작동하는 클립에 대한 정보 사용
          if (!state.shortNameHash.Equals(playHash) ||
              state.normalizedTime < 1.0f)
          {
            return false;
          }
        }
        return true;
      }, cancellationToken: token);

      await UniTask.WaitForSeconds(afterDelay, false, PlayerLoopTiming.Update, token);
      onComplete?.Invoke();
    }
  }

AnimatorEffectObject는 특정 애니메이터의 형태를 따와 파생시키는 AnimatorOverrideController와 현재 클립에 대한 정보를 조회할 수 있는 AnimatorStateInfo를 사용했습니다.

아무 적당한 오브젝트를 선택해 애니메이터를 추가하고, Idle과 Play 클립을 생성(내용 없는 빈 클립)합니다.

 

그리고 그 빈 애니메이터에 State를 생성하고 알맞는 빈 클립을 넣어줬습니다.

Animator Window에 state만 생성하지 말고 clip도 할당해줘야 합니다.

 

이후 해당 애니메이터의 뼈대를 물려받을 Animator Override Controller를 생성합니다.

 

Controller 필드에 만들어뒀던 Animator를 할당하면 해당 Animator의 State 및 Parameter 골격을 그대로 물려받는 Animator가 생겨납니다.

내용물이 존재하는 새 클립들을 만들어주고 Override 필드에 넣어주면 내용물만 바뀌는 새로운 애니메이터가 뿅!

 

암튼 Animator를 사용하는 이펙트들은 항상 해당 애니메이터 골격을 물려받을 예정입니다.

 

  public class ParticleEffectObject : BaseEffectObject
  {
    //필드, cts 생략

    public override async UniTask PlayAsync(UnityAction onComplete = null)
    {
      cts.Create();
      var token = cts.token;

      await UniTask.WaitForSeconds(beforeDelay, false, PlayerLoopTiming.Update, token);

      foreach (var particle in particles)
        particle.Play();

      await UniTask.WaitUntil(() =>
      {
        foreach (var particle in particles)
          if (particle.IsAlive())	//particle.IsAlive 외엔 별 볼게 없는
            return false;

        return true;
      }, PlayerLoopTiming.Update, token);

      await UniTask.WaitForSeconds(afterDelay, false, PlayerLoopTiming.Update, token);

      onComplete?.Invoke();
    }

  }

ParticleEffectObject는 보다시피 particleSystem.IsAlive()만으로 처리가 되니 딱히 설명할 게 없네요.

굳이 말하자면 Looping, PlayOnAwake 주의할 것?

 

  private class EffectPool
  {
    private readonly EffectType effectType;
    private readonly string basePath;
    private readonly IResourceManager resourceManager;
    private readonly int poolingCount;

    private readonly List<BaseEffectObject> enables = new();  //시간 서순 고려해 List로
    private readonly Queue<BaseEffectObject> disables = new();//서순 문제가 없을테니 Queue

    public EffectPool(EffectType effectType, string basePath, IResourceManager resourceManager, int poolingCount)
    {
      this.effectType = effectType;
      this.basePath = basePath;
      this.resourceManager = resourceManager;
      this.poolingCount = poolingCount;
    }

    public async UniTask PlayOnceAsync(EffectType effectType, Vector3 position, Quaternion rotation, Transform root, UnityAction onComplete)
    {
      if(disables.TryDequeue(out var baseEffectObject) == false)
        baseEffectObject = await CreateAsync(root);
	//disableQueue에 pooling할 오브젝트가 없으면 생성
    
      baseEffectObject.transform.SetPositionAndRotation(position, rotation);
      baseEffectObject.gameObject.SetActive(true);

      enables.Add(baseEffectObject);

      baseEffectObject.PlayAsync(() =>
      {
      //이펙트가 완료됐을 때
        onComplete?.Invoke();
        if(disables.Count < poolingCount)
        {
        //풀링 개수 제한이 널널하면 비활성화 후 disable Queue에서 대기
          enables.Remove(baseEffectObject);
          baseEffectObject.gameObject.SetActive(false);
          disables.Enqueue(baseEffectObject);
        }
        else
        {
        //풀링 개수 제한이 꽉 찼으면 오브젝트 파괴
          GameObject.Destroy(baseEffectObject.gameObject);
        }
      }).Forget();
    }

    private async UniTask<BaseEffectObject> CreateAsync(Transform root)
    //그냥 addressable로 불러오는 것 뿐이니 생략
  }

풀링은 유니티 기본 제공 풀링을 사용하진 않았습니다.

 

왜 그랬느냐? 딱히 이유는 없네요. 그렇다고 해서 뭐 유니티 풀링을 갖다 쓴다고 해서 비약적으로 뭐시기가 나아진다던가 그런건 딱히 없지 않습니까.

 

다음에 할거: 좀만 더 생각해보기