파티클/애니메이터 이펙트 풀링 서비스를 만들었습니다.
사실 작업이라기엔 좀 그렇다만 암튼 작업물은 작업물이니까요.
이펙트가 어떻게 만들어질지 제대로 구상해놓은 것은 없어서 적당히 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로 불러오는 것 뿐이니 생략
}
풀링은 유니티 기본 제공 풀링을 사용하진 않았습니다.
왜 그랬느냐? 딱히 이유는 없네요. 그렇다고 해서 뭐 유니티 풀링을 갖다 쓴다고 해서 비약적으로 뭐시기가 나아진다던가 그런건 딱히 없지 않습니까.
다음에 할거: 좀만 더 생각해보기
'무제_LR' 카테고리의 다른 글
| InputProgress(연타), InputQTE 추가 (0) | 2026.01.06 |
|---|---|
| ChatCard(가제) 추가, Runtime Pivot 수정 주의점 (0) | 2026.01.04 |
| 씬 언로드, 로딩 UI 추가 (0) | 2025.12.31 |
| 다이어로그 - Talking, Selection 구현 (0) | 2025.12.29 |
| 다이어로그 에디터 - csv->Localization 및 TalkingData 툴 완성 (0) | 2025.12.21 |