파티클 최적화
파티클 시스템 예산 관리는 앱인토스 미니앱의 제한된 WebGL 환경에서 파티클 효과를 효율적으로 사용하는 핵심 도구예요.
모바일 기기의 성능 한계와 토스 앱의 리소스 제약을 고려해, 파티클의 품질과 성능 사이에서 최적의 균형을 맞춰요.
앱인토스 플랫폼 특화 파티클 관리
1. 동적 파티클 예산 시스템
c#
// Unity C# - 앱인토스 파티클 예산 매니저
using UnityEngine;
using System.Collections.Generic;
using System.Linq;
public class AITParticleBudgetManager : MonoBehaviour
{
private static AITParticleBudgetManager instance;
public static AITParticleBudgetManager Instance => instance;
[System.Serializable]
public class ParticleBudget
{
[Header("전역 예산")]
public int maxTotalParticles = 1000; // 전체 최대 파티클 수
public int maxActiveEmitters = 20; // 동시 활성 이미터 수
public float maxMemoryUsage = 50f; // 최대 메모리 사용량 (MB)
[Header("품질별 예산")]
public int highQualityParticles = 500; // 고품질 파티클 수
public int mediumQualityParticles = 300; // 중품질 파티클 수
public int lowQualityParticles = 200; // 저품질 파티클 수
[Header("카테고리별 예산")]
public int uiEffectParticles = 200; // UI 이펙트 파티클
public int gameplayParticles = 600; // 게임플레이 파티클
public int backgroundParticles = 200; // 배경 파티클
[Header("성능 임계값")]
public float cpuUsageThreshold = 70f; // CPU 사용률 임계값 (%)
public float fpsThreshold = 30f; // FPS 임계값
public float memoryThreshold = 80f; // 메모리 사용률 임계값 (%)
}
[System.Serializable]
public class ParticleSystemInfo
{
public ParticleSystem particleSystem;
public string category;
public int priority; // 우선순위 (높을수록 중요)
public int currentParticleCount;
public float memoryUsage;
public bool isActive;
public float lastActiveTime;
public QualityLevel qualityLevel;
public enum QualityLevel
{
Low = 0,
Medium = 1,
High = 2
}
}
[Header("파티클 예산 설정")]
public ParticleBudget budget;
public bool enableDynamicBudgeting = true; // 동적 예산 조정
public bool enableAutomaticCleanup = true; // 자동 정리
public float budgetUpdateInterval = 1f; // 예산 업데이트 간격
private List<ParticleSystemInfo> managedParticles;
private Dictionary<string, int> categoryUsage;
private Dictionary<ParticleSystemInfo.QualityLevel, int> qualityUsage;
// 성능 메트릭
private float currentCPUUsage;
private float currentFPS;
private float currentMemoryUsage;
private int totalActiveParticles;
// 이벤트
public static event System.Action<int> OnBudgetExceeded;
public static event System.Action<ParticleSystemInfo> OnParticleSystemCulled;
public static event System.Action<ParticleBudget> OnBudgetUpdated;
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
InitializeParticleBudget();
}
else
{
Destroy(gameObject);
}
}
private void InitializeParticleBudget()
{
managedParticles = new List<ParticleSystemInfo>();
categoryUsage = new Dictionary<string, int>();
qualityUsage = new Dictionary<ParticleSystemInfo.QualityLevel, int>();
// 초기 예산 설정
ApplyInitialBudget();
if (enableDynamicBudgeting)
{
InvokeRepeating(nameof(UpdateBudget), budgetUpdateInterval, budgetUpdateInterval);
}
if (enableAutomaticCleanup)
{
InvokeRepeating(nameof(CleanupInactiveParticles), 5f, 5f);
}
}
private void ApplyInitialBudget()
{
// 디바이스 성능에 따른 초기 예산 조정
var deviceClass = GetDevicePerformanceClass();
switch (deviceClass)
{
case DevicePerformanceClass.Low:
budget.maxTotalParticles = Mathf.RoundToInt(budget.maxTotalParticles * 0.5f);
budget.maxActiveEmitters = Mathf.RoundToInt(budget.maxActiveEmitters * 0.6f);
break;
case DevicePerformanceClass.Medium:
budget.maxTotalParticles = Mathf.RoundToInt(budget.maxTotalParticles * 0.8f);
budget.maxActiveEmitters = Mathf.RoundToInt(budget.maxActiveEmitters * 0.8f);
break;
case DevicePerformanceClass.High:
// 기본 예산 사용
break;
}
Debug.Log($"[AIT Particle] 초기 예산 설정 - 총 파티클: {budget.maxTotalParticles}, 이미터: {budget.maxActiveEmitters}");
}
private DevicePerformanceClass GetDevicePerformanceClass()
{
// 시스템 성능 기반 클래스 분류
int memorySize = SystemInfo.systemMemorySize;
int processorCount = SystemInfo.processorCount;
if (memorySize < 2048 || processorCount < 4)
return DevicePerformanceClass.Low;
else if (memorySize < 4096 || processorCount < 6)
return DevicePerformanceClass.Medium;
else
return DevicePerformanceClass.High;
}
public enum DevicePerformanceClass
{
Low,
Medium,
High
}
public void RegisterParticleSystem(ParticleSystem ps, string category, int priority,
ParticleSystemInfo.QualityLevel quality = ParticleSystemInfo.QualityLevel.Medium)
{
if (ps == null) return;
var info = new ParticleSystemInfo
{
particleSystem = ps,
category = category,
priority = priority,
qualityLevel = quality,
isActive = false,
lastActiveTime = Time.time
};
managedParticles.Add(info);
Debug.Log($"[AIT Particle] 파티클 시스템 등록: {ps.name} ({category}, 우선순위: {priority})");
}
public bool RequestParticleActivation(ParticleSystem ps, int requestedParticles)
{
var info = GetParticleSystemInfo(ps);
if (info == null)
{
Debug.LogWarning($"[AIT Particle] 등록되지 않은 파티클 시스템: {ps.name}");
return false;
}
// 예산 확인
if (!CanAllocateParticles(requestedParticles, info))
{
Debug.LogWarning($"[AIT Particle] 파티클 예산 초과: {ps.name} (요청: {requestedParticles})");
// 낮은 우선순위 파티클 정리 시도
if (TryFreeUpBudget(requestedParticles, info.priority))
{
Debug.Log($"[AIT Particle] 예산 확보 성공: {ps.name}");
}
else
{
OnBudgetExceeded?.Invoke(requestedParticles);
return false;
}
}
// 파티클 활성화
ActivateParticleSystem(info, requestedParticles);
return true;
}
private bool CanAllocateParticles(int requestedParticles, ParticleSystemInfo info)
{
// 전역 예산 확인
if (totalActiveParticles + requestedParticles > budget.maxTotalParticles)
return false;
// 카테고리별 예산 확인
int categoryBudget = GetCategoryBudget(info.category);
int currentCategoryUsage = categoryUsage.ContainsKey(info.category) ? categoryUsage[info.category] : 0;
if (currentCategoryUsage + requestedParticles > categoryBudget)
return false;
// 품질별 예산 확인
int qualityBudget = GetQualityBudget(info.qualityLevel);
int currentQualityUsage = qualityUsage.ContainsKey(info.qualityLevel) ? qualityUsage[info.qualityLevel] : 0;
if (currentQualityUsage + requestedParticles > qualityBudget)
return false;
return true;
}
private int GetCategoryBudget(string category)
{
switch (category.ToLower())
{
case "ui":
case "uieffect":
return budget.uiEffectParticles;
case "gameplay":
case "game":
return budget.gameplayParticles;
case "background":
case "ambient":
return budget.backgroundParticles;
default:
return budget.maxTotalParticles / 4; // 기본 할당
}
}
private int GetQualityBudget(ParticleSystemInfo.QualityLevel quality)
{
switch (quality)
{
case ParticleSystemInfo.QualityLevel.High:
return budget.highQualityParticles;
case ParticleSystemInfo.QualityLevel.Medium:
return budget.mediumQualityParticles;
case ParticleSystemInfo.QualityLevel.Low:
return budget.lowQualityParticles;
default:
return budget.mediumQualityParticles;
}
}
private bool TryFreeUpBudget(int requiredParticles, int requestPriority)
{
var sortedParticles = managedParticles
.Where(p => p.isActive && p.priority < requestPriority)
.OrderBy(p => p.priority)
.ThenBy(p => p.lastActiveTime);
int freedParticles = 0;
foreach (var info in sortedParticles)
{
if (freedParticles >= requiredParticles)
break;
freedParticles += info.currentParticleCount;
DeactivateParticleSystem(info);
Debug.Log($"[AIT Particle] 낮은 우선순위 파티클 정리: {info.particleSystem.name}");
}
return freedParticles >= requiredParticles;
}
private void ActivateParticleSystem(ParticleSystemInfo info, int particleCount)
{
info.isActive = true;
info.currentParticleCount = particleCount;
info.lastActiveTime = Time.time;
// 사용량 업데이트
totalActiveParticles += particleCount;
if (categoryUsage.ContainsKey(info.category))
categoryUsage[info.category] += particleCount;
else
categoryUsage[info.category] = particleCount;
if (qualityUsage.ContainsKey(info.qualityLevel))
qualityUsage[info.qualityLevel] += particleCount;
else
qualityUsage[info.qualityLevel] = particleCount;
// 파티클 시스템 설정 적용
ApplyQualitySettings(info);
}
private void DeactivateParticleSystem(ParticleSystemInfo info)
{
if (!info.isActive) return;
// 사용량 업데이트
totalActiveParticles -= info.currentParticleCount;
categoryUsage[info.category] -= info.currentParticleCount;
qualityUsage[info.qualityLevel] -= info.currentParticleCount;
info.isActive = false;
info.currentParticleCount = 0;
// 파티클 시스템 정지
info.particleSystem.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
OnParticleSystemCulled?.Invoke(info);
}
private void ApplyQualitySettings(ParticleSystemInfo info)
{
var ps = info.particleSystem;
var main = ps.main;
switch (info.qualityLevel)
{
case ParticleSystemInfo.QualityLevel.Low:
main.maxParticles = Mathf.RoundToInt(main.maxParticles * 0.5f);
SetParticleSystemLOD(ps, 0); // 최저 품질
break;
case ParticleSystemInfo.QualityLevel.Medium:
main.maxParticles = Mathf.RoundToInt(main.maxParticles * 0.75f);
SetParticleSystemLOD(ps, 1); // 중간 품질
break;
case ParticleSystemInfo.QualityLevel.High:
// 원래 설정 유지
SetParticleSystemLOD(ps, 2); // 최고 품질
break;
}
}
private void SetParticleSystemLOD(ParticleSystem ps, int lodLevel)
{
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer == null) return;
switch (lodLevel)
{
case 0: // 낮음
renderer.sortMode = ParticleSystemSortMode.None;
DisableExpensiveModules(ps);
break;
case 1: // 중간
renderer.sortMode = ParticleSystemSortMode.Distance;
OptimizeModules(ps);
break;
case 2: // 높음
// 모든 기능 활성화
break;
}
}
private void DisableExpensiveModules(ParticleSystem ps)
{
// 비용이 높은 모듈들 비활성화
var collision = ps.collision;
collision.enabled = false;
var lights = ps.lights;
lights.enabled = false;
var trails = ps.trails;
trails.enabled = false;
}
private void OptimizeModules(ParticleSystem ps)
{
// 모듈별 최적화 설정
var emission = ps.emission;
if (emission.enabled)
{
var rate = emission.rateOverTime;
emission.rateOverTime = rate.constant * 0.8f;
}
var shape = ps.shape;
if (shape.enabled)
{
shape.meshRenderer = null; // 메시 렌더러 제거로 성능 향상
}
}
private void UpdateBudget()
{
// 성능 메트릭 업데이트
UpdatePerformanceMetrics();
// 동적 예산 조정
if (enableDynamicBudgeting)
{
AdjustBudgetBasedOnPerformance();
}
// 사용량 통계 업데이트
UpdateUsageStatistics();
}
private void UpdatePerformanceMetrics()
{
currentFPS = 1f / Time.deltaTime;
// 메모리 사용량 (근사치)
currentMemoryUsage = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemory(UnityEngine.Profiling.Profiler.Area.Total) / (1024 * 1024);
// CPU 사용률은 프레임 시간으로 추정
currentCPUUsage = (Time.deltaTime / (1f/60f)) * 100f; // 60fps 기준
}
private void AdjustBudgetBasedOnPerformance()
{
bool budgetChanged = false;
ParticleBudget newBudget = budget;
// FPS 기반 조정
if (currentFPS < budget.fpsThreshold)
{
newBudget.maxTotalParticles = Mathf.RoundToInt(newBudget.maxTotalParticles * 0.9f);
budgetChanged = true;
Debug.Log($"[AIT Particle] FPS 저하로 인한 예산 감소: {newBudget.maxTotalParticles}");
}
else if (currentFPS > budget.fpsThreshold * 1.5f && newBudget.maxTotalParticles < 1000)
{
newBudget.maxTotalParticles = Mathf.RoundToInt(newBudget.maxTotalParticles * 1.1f);
budgetChanged = true;
Debug.Log($"[AIT Particle] 성능 여유로 인한 예산 증가: {newBudget.maxTotalParticles}");
}
// 메모리 기반 조정
if (currentMemoryUsage > budget.memoryThreshold)
{
newBudget.maxTotalParticles = Mathf.RoundToInt(newBudget.maxTotalParticles * 0.8f);
budgetChanged = true;
Debug.LogWarning($"[AIT Particle] 메모리 부족으로 인한 예산 감소: {newBudget.maxTotalParticles}");
}
if (budgetChanged)
{
budget = newBudget;
OnBudgetUpdated?.Invoke(budget);
// 현재 예산을 초과하는 파티클들 정리
EnforceBudgetLimits();
}
}
private void EnforceBudgetLimits()
{
if (totalActiveParticles <= budget.maxTotalParticles) return;
int excessParticles = totalActiveParticles - budget.maxTotalParticles;
// 낮은 우선순위부터 정리
var particlesToCull = managedParticles
.Where(p => p.isActive)
.OrderBy(p => p.priority)
.ThenBy(p => p.lastActiveTime);
int culledParticles = 0;
foreach (var info in particlesToCull)
{
if (culledParticles >= excessParticles) break;
culledParticles += info.currentParticleCount;
DeactivateParticleSystem(info);
}
}
private void UpdateUsageStatistics()
{
// 실시간 파티클 카운트 업데이트
totalActiveParticles = 0;
categoryUsage.Clear();
qualityUsage.Clear();
foreach (var info in managedParticles)
{
if (!info.isActive) continue;
int actualCount = info.particleSystem.particleCount;
info.currentParticleCount = actualCount;
totalActiveParticles += actualCount;
if (categoryUsage.ContainsKey(info.category))
categoryUsage[info.category] += actualCount;
else
categoryUsage[info.category] = actualCount;
if (qualityUsage.ContainsKey(info.qualityLevel))
qualityUsage[info.qualityLevel] += actualCount;
else
qualityUsage[info.qualityLevel] = actualCount;
}
}
private void CleanupInactiveParticles()
{
var inactiveParticles = managedParticles
.Where(p => !p.isActive && Time.time - p.lastActiveTime > 30f) // 30초 이상 비활성
.ToList();
foreach (var info in inactiveParticles)
{
if (info.particleSystem == null)
{
managedParticles.Remove(info);
continue;
}
// 완전히 정리
if (!info.particleSystem.isPlaying && info.particleSystem.particleCount == 0)
{
Debug.Log($"[AIT Particle] 비활성 파티클 시스템 정리: {info.particleSystem.name}");
managedParticles.Remove(info);
}
}
}
private ParticleSystemInfo GetParticleSystemInfo(ParticleSystem ps)
{
return managedParticles.FirstOrDefault(p => p.particleSystem == ps);
}
// 공개 API 메서드들
public void SetParticlePriority(ParticleSystem ps, int newPriority)
{
var info = GetParticleSystemInfo(ps);
if (info != null)
{
info.priority = newPriority;
Debug.Log($"[AIT Particle] 우선순위 변경: {ps.name} -> {newPriority}");
}
}
public void SetParticleQuality(ParticleSystem ps, ParticleSystemInfo.QualityLevel newQuality)
{
var info = GetParticleSystemInfo(ps);
if (info != null)
{
info.qualityLevel = newQuality;
if (info.isActive)
{
ApplyQualitySettings(info);
}
}
}
public ParticleBudgetStats GetBudgetStats()
{
return new ParticleBudgetStats
{
totalActiveParticles = totalActiveParticles,
maxTotalParticles = budget.maxTotalParticles,
activeEmitters = managedParticles.Count(p => p.isActive),
maxActiveEmitters = budget.maxActiveEmitters,
memoryUsage = currentMemoryUsage,
maxMemoryUsage = budget.maxMemoryUsage,
fps = currentFPS,
categoryUsage = new Dictionary<string, int>(categoryUsage),
qualityUsage = new Dictionary<ParticleSystemInfo.QualityLevel, int>(qualityUsage)
};
}
public void ForceCleanup()
{
foreach (var info in managedParticles.ToList())
{
if (info.particleSystem == null || !info.isActive)
{
managedParticles.Remove(info);
}
}
UpdateUsageStatistics();
Debug.Log($"[AIT Particle] 강제 정리 완료 - 활성 파티클: {totalActiveParticles}");
}
public void SetGlobalQuality(ParticleSystemInfo.QualityLevel globalQuality)
{
foreach (var info in managedParticles)
{
if (info.qualityLevel > globalQuality)
{
info.qualityLevel = globalQuality;
if (info.isActive)
{
ApplyQualitySettings(info);
}
}
}
Debug.Log($"[AIT Particle] 전역 품질 설정: {globalQuality}");
}
}
[System.Serializable]
public class ParticleBudgetStats
{
public int totalActiveParticles;
public int maxTotalParticles;
public int activeEmitters;
public int maxActiveEmitters;
public float memoryUsage;
public float maxMemoryUsage;
public float fps;
public Dictionary<string, int> categoryUsage;
public Dictionary<AITParticleBudgetManager.ParticleSystemInfo.QualityLevel, int> qualityUsage;
public float GetBudgetUtilization()
{
return maxTotalParticles > 0 ? (float)totalActiveParticles / maxTotalParticles : 0f;
}
public float GetEmitterUtilization()
{
return maxActiveEmitters > 0 ? (float)activeEmitters / maxActiveEmitters : 0f;
}
public float GetMemoryUtilization()
{
return maxMemoryUsage > 0 ? memoryUsage / maxMemoryUsage : 0f;
}
}2. 파티클 LOD(Level of Detail) 시스템
c#
// Unity C# - 파티클 LOD 관리자
using UnityEngine;
using System.Collections.Generic;
public class AITParticleLODManager : MonoBehaviour
{
[System.Serializable]
public class ParticleLOD
{
[Header("거리 기반 LOD")]
public float[] lodDistances = { 10f, 25f, 50f }; // LOD 전환 거리
public float[] particleMultipliers = { 1f, 0.5f, 0.25f, 0f }; // 거리별 파티클 배수
[Header("성능 기반 LOD")]
public bool enablePerformanceLOD = true;
public float fpsThreshold = 30f;
public float memoryThreshold = 80f; // MB
[Header("가시성 기반 LOD")]
public bool enableFrustumCulling = true;
public bool enableOcclusionCulling = false;
public LayerMask cullingLayers = -1;
}
[System.Serializable]
public class ParticleLODInfo
{
public ParticleSystem particleSystem;
public Transform referenceTransform; // LOD 계산 기준점
public int originalMaxParticles;
public int currentLODLevel;
public bool isVisible;
public float distanceToCamera;
public Vector3 lastPosition;
public Bounds bounds;
}
private Camera mainCamera;
private List<ParticleLODInfo> lodParticles;
private AITParticleBudgetManager budgetManager;
[Header("LOD 설정")]
public ParticleLOD lodSettings;
public float updateInterval = 0.5f; // LOD 업데이트 간격
public bool enableDebugVisualization = false; // 디버그 시각화
private void Start()
{
mainCamera = Camera.main ?? FindObjectOfType<Camera>();
budgetManager = AITParticleBudgetManager.Instance;
lodParticles = new List<ParticleLODInfo>();
// 씬의 모든 파티클 시스템 등록
RegisterAllParticleSystems();
InvokeRepeating(nameof(UpdateLOD), 0f, updateInterval);
}
private void RegisterAllParticleSystems()
{
var allParticles = FindObjectsOfType<ParticleSystem>();
foreach (var ps in allParticles)
{
RegisterParticleSystem(ps);
}
Debug.Log($"[AIT ParticleLOD] {allParticles.Length}개 파티클 시스템 등록");
}
public void RegisterParticleSystem(ParticleSystem ps, Transform referenceTransform = null)
{
if (ps == null) return;
var lodInfo = new ParticleLODInfo
{
particleSystem = ps,
referenceTransform = referenceTransform ?? ps.transform,
originalMaxParticles = ps.main.maxParticles,
currentLODLevel = 0,
isVisible = true,
lastPosition = ps.transform.position
};
// 파티클 시스템의 바운드 계산
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null)
{
lodInfo.bounds = renderer.bounds;
}
lodParticles.Add(lodInfo);
}
private void UpdateLOD()
{
if (mainCamera == null) return;
Vector3 cameraPos = mainCamera.transform.position;
Plane[] frustumPlanes = null;
if (lodSettings.enableFrustumCulling)
{
frustumPlanes = GeometryUtility.CalculateFrustumPlanes(mainCamera);
}
foreach (var lodInfo in lodParticles)
{
if (lodInfo.particleSystem == null) continue;
UpdateParticleLOD(lodInfo, cameraPos, frustumPlanes);
}
}
private void UpdateParticleLOD(ParticleLODInfo lodInfo, Vector3 cameraPos, Plane[] frustumPlanes)
{
var ps = lodInfo.particleSystem;
var psTransform = lodInfo.referenceTransform;
// 거리 계산
lodInfo.distanceToCamera = Vector3.Distance(cameraPos, psTransform.position);
// 가시성 검사
bool wasVisible = lodInfo.isVisible;
lodInfo.isVisible = CheckVisibility(lodInfo, frustumPlanes);
// LOD 레벨 결정
int newLODLevel = CalculateLODLevel(lodInfo);
// LOD 적용
if (newLODLevel != lodInfo.currentLODLevel || wasVisible != lodInfo.isVisible)
{
ApplyLOD(lodInfo, newLODLevel);
lodInfo.currentLODLevel = newLODLevel;
}
// 위치 업데이트
lodInfo.lastPosition = psTransform.position;
}
private bool CheckVisibility(ParticleLODInfo lodInfo, Plane[] frustumPlanes)
{
// 프러스텀 컬링
if (lodSettings.enableFrustumCulling && frustumPlanes != null)
{
if (!GeometryUtility.TestPlanesAABB(frustumPlanes, lodInfo.bounds))
{
return false;
}
}
// 오클루전 컬링
if (lodSettings.enableOcclusionCulling)
{
if (IsOccluded(lodInfo))
{
return false;
}
}
return true;
}
private bool IsOccluded(ParticleLODInfo lodInfo)
{
Vector3 cameraPos = mainCamera.transform.position;
Vector3 particlePos = lodInfo.referenceTransform.position;
// 레이캐스트를 통한 오클루전 검사
if (Physics.Raycast(cameraPos, (particlePos - cameraPos).normalized,
out RaycastHit hit, lodInfo.distanceToCamera, lodSettings.cullingLayers))
{
return hit.distance < lodInfo.distanceToCamera - 1f; // 1미터 버퍼
}
return false;
}
private int CalculateLODLevel(ParticleLODInfo lodInfo)
{
// 보이지 않으면 최고 LOD (비활성화)
if (!lodInfo.isVisible)
{
return lodSettings.lodDistances.Length;
}
// 거리 기반 LOD
for (int i = 0; i < lodSettings.lodDistances.Length; i++)
{
if (lodInfo.distanceToCamera <= lodSettings.lodDistances[i])
{
return i;
}
}
return lodSettings.lodDistances.Length; // 최고 거리 = 최고 LOD
}
private void ApplyLOD(ParticleLODInfo lodInfo, int lodLevel)
{
var ps = lodInfo.particleSystem;
var main = ps.main;
if (lodLevel >= lodSettings.particleMultipliers.Length)
{
// 완전히 비활성화
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
return;
}
float multiplier = lodSettings.particleMultipliers[lodLevel];
if (multiplier <= 0f)
{
// 파티클 비활성화
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
}
else
{
// 파티클 수 조정
int newMaxParticles = Mathf.RoundToInt(lodInfo.originalMaxParticles * multiplier);
main.maxParticles = Mathf.Max(1, newMaxParticles);
// 이미션 레이트 조정
var emission = ps.emission;
if (emission.enabled)
{
var rate = emission.rateOverTime;
emission.rateOverTime = rate.constant * multiplier;
}
// 필요시 파티클 시스템 재시작
if (!ps.isPlaying && lodInfo.isVisible)
{
ps.Play();
}
}
// 성능 기반 추가 조정
ApplyPerformanceBasedLOD(lodInfo, multiplier);
if (enableDebugVisualization)
{
Debug.Log($"[AIT ParticleLOD] {ps.name} LOD {lodLevel} 적용 (배수: {multiplier}, 파티클: {main.maxParticles})");
}
}
private void ApplyPerformanceBasedLOD(ParticleLODInfo lodInfo, float baseMutiplier)
{
if (!lodSettings.enablePerformanceLOD) return;
var budgetStats = budgetManager?.GetBudgetStats();
if (budgetStats == null) return;
float performanceMultiplier = 1f;
// FPS 기반 조정
if (budgetStats.fps < lodSettings.fpsThreshold)
{
performanceMultiplier *= 0.7f; // 30% 감소
}
// 메모리 기반 조정
if (budgetStats.memoryUsage > lodSettings.memoryThreshold)
{
performanceMultiplier *= 0.8f; // 20% 감소
}
// 예산 활용률 기반 조정
float budgetUtilization = budgetStats.GetBudgetUtilization();
if (budgetUtilization > 0.9f)
{
performanceMultiplier *= 0.6f; // 40% 감소
}
if (performanceMultiplier < 1f)
{
var ps = lodInfo.particleSystem;
var main = ps.main;
int adjustedMaxParticles = Mathf.RoundToInt(main.maxParticles * performanceMultiplier);
main.maxParticles = Mathf.Max(1, adjustedMaxParticles);
if (enableDebugVisualization)
{
Debug.Log($"[AIT ParticleLOD] 성능 기반 조정: {ps.name} (배수: {performanceMultiplier})");
}
}
}
// 공개 API 메서드들
public void SetLODDistance(int lodLevel, float distance)
{
if (lodLevel >= 0 && lodLevel < lodSettings.lodDistances.Length)
{
lodSettings.lodDistances[lodLevel] = distance;
}
}
public void SetLODMultiplier(int lodLevel, float multiplier)
{
if (lodLevel >= 0 && lodLevel < lodSettings.particleMultipliers.Length)
{
lodSettings.particleMultipliers[lodLevel] = multiplier;
}
}
public void ForceUpdateLOD()
{
UpdateLOD();
}
public ParticleLODStats GetLODStats()
{
var stats = new ParticleLODStats();
foreach (var lodInfo in lodParticles)
{
if (lodInfo.particleSystem == null) continue;
stats.totalParticleSystems++;
if (lodInfo.isVisible)
stats.visibleParticleSystems++;
stats.lodLevelCounts[lodInfo.currentLODLevel]++;
stats.totalActiveParticles += lodInfo.particleSystem.particleCount;
}
return stats;
}
public void SetGlobalLODMultiplier(float globalMultiplier)
{
for (int i = 0; i < lodSettings.particleMultipliers.Length; i++)
{
lodSettings.particleMultipliers[i] *= globalMultiplier;
}
// 모든 파티클에 즉시 적용
ForceUpdateLOD();
}
// 디버그 시각화
private void OnDrawGizmos()
{
if (!enableDebugVisualization || lodParticles == null) return;
foreach (var lodInfo in lodParticles)
{
if (lodInfo.particleSystem == null) continue;
// LOD 레벨에 따른 색상
Color gizmoColor = GetLODColor(lodInfo.currentLODLevel);
Gizmos.color = gizmoColor;
// 파티클 시스템 위치에 구 그리기
Gizmos.DrawWireSphere(lodInfo.referenceTransform.position, 1f);
// 거리 정보 표시
if (mainCamera != null)
{
Vector3 cameraPos = mainCamera.transform.position;
Vector3 particlePos = lodInfo.referenceTransform.position;
Gizmos.color = gizmoColor * 0.5f;
Gizmos.DrawLine(cameraPos, particlePos);
}
}
// LOD 거리 표시
if (mainCamera != null)
{
Vector3 cameraPos = mainCamera.transform.position;
for (int i = 0; i < lodSettings.lodDistances.Length; i++)
{
Gizmos.color = GetLODColor(i) * 0.3f;
Gizmos.DrawWireSphere(cameraPos, lodSettings.lodDistances[i]);
}
}
}
private Color GetLODColor(int lodLevel)
{
Color[] lodColors = {
Color.green, // LOD 0 - 최고 품질
Color.yellow, // LOD 1 - 중간 품질
Color.orange, // LOD 2 - 낮은 품질
Color.red // LOD 3+ - 매우 낮은/비활성
};
int colorIndex = Mathf.Min(lodLevel, lodColors.Length - 1);
return lodColors[colorIndex];
}
}
[System.Serializable]
public class ParticleLODStats
{
public int totalParticleSystems;
public int visibleParticleSystems;
public int totalActiveParticles;
public Dictionary<int, int> lodLevelCounts = new Dictionary<int, int>();
public float GetVisibilityRatio()
{
return totalParticleSystems > 0 ? (float)visibleParticleSystems / totalParticleSystems : 0f;
}
public int GetParticleSystemsAtLOD(int lodLevel)
{
return lodLevelCounts.ContainsKey(lodLevel) ? lodLevelCounts[lodLevel] : 0;
}
}3. 파티클 풀링 시스템
c#
// Unity C# - 파티클 오브젝트 풀
using UnityEngine;
using System.Collections.Generic;
using System.Linq;
public class AITParticlePool : MonoBehaviour
{
[System.Serializable]
public class ParticlePoolEntry
{
public string poolName;
public ParticleSystem prefab;
public int initialSize = 10;
public int maxSize = 50;
public bool expandable = true;
public float autoReturnTime = 10f; // 자동 반환 시간
}
[System.Serializable]
public class PooledParticle
{
public ParticleSystem particleSystem;
public string poolName;
public bool isActive;
public float spawnTime;
public float autoReturnTime;
public Transform originalParent;
public bool ShouldAutoReturn()
{
return isActive && autoReturnTime > 0 &&
Time.time - spawnTime > autoReturnTime;
}
}
private static AITParticlePool instance;
public static AITParticlePool Instance => instance;
[Header("파티클 풀 설정")]
public List<ParticlePoolEntry> poolEntries;
public Transform poolContainer; // 풀 컨테이너
public bool enableAutoReturn = true; // 자동 반환 활성화
public float autoReturnCheckInterval = 2f; // 자동 반환 체크 간격
private Dictionary<string, Queue<PooledParticle>> availablePools;
private Dictionary<string, List<PooledParticle>> activePools;
private Dictionary<string, ParticlePoolEntry> poolConfigs;
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
InitializePools();
}
else
{
Destroy(gameObject);
}
}
private void InitializePools()
{
availablePools = new Dictionary<string, Queue<PooledParticle>>();
activePools = new Dictionary<string, List<PooledParticle>>();
poolConfigs = new Dictionary<string, ParticlePoolEntry>();
// 풀 컨테이너 생성
if (poolContainer == null)
{
poolContainer = new GameObject("ParticlePoolContainer").transform;
poolContainer.SetParent(transform);
}
// 각 풀 초기화
foreach (var entry in poolEntries)
{
CreatePool(entry);
}
if (enableAutoReturn)
{
InvokeRepeating(nameof(CheckAutoReturn), autoReturnCheckInterval, autoReturnCheckInterval);
}
Debug.Log($"[AIT ParticlePool] {poolEntries.Count}개 파티클 풀 초기화 완료");
}
private void CreatePool(ParticlePoolEntry entry)
{
if (string.IsNullOrEmpty(entry.poolName) || entry.prefab == null)
{
Debug.LogWarning("[AIT ParticlePool] 잘못된 풀 설정");
return;
}
var availableQueue = new Queue<PooledParticle>();
var activeList = new List<PooledParticle>();
// 풀 컨테이너 생성
var poolObject = new GameObject($"Pool_{entry.poolName}");
poolObject.transform.SetParent(poolContainer);
// 초기 파티클 생성
for (int i = 0; i < entry.initialSize; i++)
{
var pooledParticle = CreatePooledParticle(entry, poolObject.transform);
availableQueue.Enqueue(pooledParticle);
}
availablePools[entry.poolName] = availableQueue;
activePools[entry.poolName] = activeList;
poolConfigs[entry.poolName] = entry;
Debug.Log($"[AIT ParticlePool] 풀 생성: {entry.poolName} ({entry.initialSize}개)");
}
private PooledParticle CreatePooledParticle(ParticlePoolEntry entry, Transform parent)
{
var particleObj = Instantiate(entry.prefab, parent);
particleObj.gameObject.SetActive(false);
var pooledParticle = new PooledParticle
{
particleSystem = particleObj,
poolName = entry.poolName,
isActive = false,
autoReturnTime = entry.autoReturnTime,
originalParent = parent
};
return pooledParticle;
}
public ParticleSystem SpawnParticle(string poolName, Vector3 position, Quaternion rotation = default, Transform parent = null)
{
if (!availablePools.ContainsKey(poolName))
{
Debug.LogWarning($"[AIT ParticlePool] 존재하지 않는 풀: {poolName}");
return null;
}
var availableQueue = availablePools[poolName];
var activeList = activePools[poolName];
var config = poolConfigs[poolName];
PooledParticle pooledParticle = null;
// 사용 가능한 파티클 찾기
if (availableQueue.Count > 0)
{
pooledParticle = availableQueue.Dequeue();
}
else if (config.expandable && activeList.Count < config.maxSize)
{
// 풀 확장
pooledParticle = CreatePooledParticle(config, poolContainer.Find($"Pool_{poolName}"));
Debug.Log($"[AIT ParticlePool] 풀 확장: {poolName} ({activeList.Count + 1}/{config.maxSize})");
}
else
{
Debug.LogWarning($"[AIT ParticlePool] 풀 고갈: {poolName}");
return null;
}
// 파티클 활성화
ActivatePooledParticle(pooledParticle, position, rotation, parent);
activeList.Add(pooledParticle);
return pooledParticle.particleSystem;
}
private void ActivatePooledParticle(PooledParticle pooledParticle, Vector3 position, Quaternion rotation, Transform parent)
{
var ps = pooledParticle.particleSystem;
var psTransform = ps.transform;
// 위치 및 회전 설정
psTransform.position = position;
psTransform.rotation = rotation;
// 부모 설정
if (parent != null)
{
psTransform.SetParent(parent, true);
}
else
{
psTransform.SetParent(null);
}
// 파티클 시스템 활성화
ps.gameObject.SetActive(true);
ps.Clear();
ps.Play();
// 풀링 정보 업데이트
pooledParticle.isActive = true;
pooledParticle.spawnTime = Time.time;
// 예산 시스템에 등록
if (AITParticleBudgetManager.Instance != null)
{
var main = ps.main;
AITParticleBudgetManager.Instance.RequestParticleActivation(ps, main.maxParticles);
}
}
public void ReturnParticle(ParticleSystem particleSystem)
{
if (particleSystem == null) return;
// 활성 풀에서 찾기
PooledParticle targetPooled = null;
string targetPoolName = null;
foreach (var kvp in activePools)
{
var pooled = kvp.Value.FirstOrDefault(p => p.particleSystem == particleSystem);
if (pooled != null)
{
targetPooled = pooled;
targetPoolName = kvp.Key;
break;
}
}
if (targetPooled != null)
{
ReturnPooledParticle(targetPooled, targetPoolName);
}
}
private void ReturnPooledParticle(PooledParticle pooledParticle, string poolName)
{
var ps = pooledParticle.particleSystem;
// 파티클 시스템 정지 및 정리
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
ps.gameObject.SetActive(false);
// 원래 위치로 복귀
ps.transform.SetParent(pooledParticle.originalParent);
ps.transform.localPosition = Vector3.zero;
ps.transform.localRotation = Quaternion.identity;
// 풀링 정보 업데이트
pooledParticle.isActive = false;
pooledParticle.spawnTime = 0f;
// 풀 간 이동
activePools[poolName].Remove(pooledParticle);
availablePools[poolName].Enqueue(pooledParticle);
}
private void CheckAutoReturn()
{
foreach (var kvp in activePools)
{
var poolName = kvp.Key;
var activeList = kvp.Value;
var particlesToReturn = new List<PooledParticle>();
foreach (var pooled in activeList)
{
if (pooled.ShouldAutoReturn() || !pooled.particleSystem.isPlaying)
{
particlesToReturn.Add(pooled);
}
}
foreach (var pooled in particlesToReturn)
{
ReturnPooledParticle(pooled, poolName);
}
}
}
// 편의 메서드들
public ParticleSystem SpawnParticleAtPosition(string poolName, Vector3 position)
{
return SpawnParticle(poolName, position);
}
public ParticleSystem SpawnParticleWithDuration(string poolName, Vector3 position, float duration)
{
var ps = SpawnParticle(poolName, position);
if (ps != null)
{
StartCoroutine(ReturnParticleAfterDelay(ps, duration));
}
return ps;
}
private System.Collections.IEnumerator ReturnParticleAfterDelay(ParticleSystem ps, float delay)
{
yield return new WaitForSeconds(delay);
ReturnParticle(ps);
}
public void ReturnAllParticles(string poolName = null)
{
if (string.IsNullOrEmpty(poolName))
{
// 모든 풀의 파티클 반환
foreach (var kvp in activePools)
{
ReturnAllParticlesInPool(kvp.Key);
}
}
else
{
ReturnAllParticlesInPool(poolName);
}
}
private void ReturnAllParticlesInPool(string poolName)
{
if (!activePools.ContainsKey(poolName)) return;
var activeList = activePools[poolName];
var particlesToReturn = new List<PooledParticle>(activeList);
foreach (var pooled in particlesToReturn)
{
ReturnPooledParticle(pooled, poolName);
}
}
public ParticlePoolStats GetPoolStats(string poolName = null)
{
if (string.IsNullOrEmpty(poolName))
{
// 전체 통계
var totalStats = new ParticlePoolStats();
foreach (var kvp in availablePools)
{
string name = kvp.Key;
totalStats.totalAvailable += kvp.Value.Count;
totalStats.totalActive += activePools[name].Count;
totalStats.poolCount++;
}
return totalStats;
}
else
{
// 특정 풀 통계
if (availablePools.ContainsKey(poolName))
{
return new ParticlePoolStats
{
poolName = poolName,
totalAvailable = availablePools[poolName].Count,
totalActive = activePools[poolName].Count,
maxSize = poolConfigs[poolName].maxSize,
poolCount = 1
};
}
return new ParticlePoolStats();
}
}
public void PrewarmPool(string poolName, int count)
{
if (!poolConfigs.ContainsKey(poolName)) return;
var config = poolConfigs[poolName];
var availableQueue = availablePools[poolName];
var poolParent = poolContainer.Find($"Pool_{poolName}");
int currentTotal = availableQueue.Count + activePools[poolName].Count;
int targetCount = Mathf.Min(currentTotal + count, config.maxSize);
for (int i = currentTotal; i < targetCount; i++)
{
var pooledParticle = CreatePooledParticle(config, poolParent);
availableQueue.Enqueue(pooledParticle);
}
Debug.Log($"[AIT ParticlePool] 풀 예열: {poolName} ({targetCount}개)");
}
}
[System.Serializable]
public class ParticlePoolStats
{
public string poolName = "All";
public int totalAvailable;
public int totalActive;
public int maxSize;
public int poolCount;
public float GetUtilization()
{
int total = totalAvailable + totalActive;
return maxSize > 0 ? (float)total / maxSize : 0f;
}
public float GetActiveRatio()
{
int total = totalAvailable + totalActive;
return total > 0 ? (float)totalActive / total : 0f;
}
}코드 예제 및 설정
1. 파티클 효과 매니저
c#
// Unity C# - 통합 파티클 효과 매니저
using UnityEngine;
using System.Collections.Generic;
public class AITParticleEffectManager : MonoBehaviour
{
[System.Serializable]
public class ParticleEffect
{
public string effectName;
public string poolName;
public Vector3 offset;
public float duration;
public int priority;
public string category;
public ParticleSystemInfo.QualityLevel quality;
public AudioClip soundEffect;
public float soundVolume = 1f;
}
private AITParticleBudgetManager budgetManager;
private AITParticlePool particlePool;
private AudioSource audioSource;
[Header("파티클 효과 설정")]
public List<ParticleEffect> particleEffects;
public bool enableSoundEffects = true;
public LayerMask effectLayers = -1;
private Dictionary<string, ParticleEffect> effectLookup;
private void Start()
{
budgetManager = AITParticleBudgetManager.Instance;
particlePool = AITParticlePool.Instance;
audioSource = GetComponent<AudioSource>() ?? gameObject.AddComponent<AudioSource>();
InitializeEffects();
}
private void InitializeEffects()
{
effectLookup = new Dictionary<string, ParticleEffect>();
foreach (var effect in particleEffects)
{
if (!string.IsNullOrEmpty(effect.effectName))
{
effectLookup[effect.effectName] = effect;
}
}
Debug.Log($"[AIT ParticleEffect] {particleEffects.Count}개 파티클 효과 등록");
}
public ParticleSystem PlayEffect(string effectName, Vector3 position, Quaternion rotation = default, Transform parent = null)
{
if (!effectLookup.ContainsKey(effectName))
{
Debug.LogWarning($"[AIT ParticleEffect] 존재하지 않는 효과: {effectName}");
return null;
}
var effect = effectLookup[effectName];
Vector3 finalPosition = position + rotation * effect.offset;
// 파티클 스폰
var ps = particlePool?.SpawnParticle(effect.poolName, finalPosition, rotation, parent);
if (ps == null) return null;
// 예산 관리에 등록
budgetManager?.RegisterParticleSystem(ps, effect.category, effect.priority, effect.quality);
// 사운드 효과 재생
if (enableSoundEffects && effect.soundEffect != null)
{
PlaySoundEffect(effect.soundEffect, effect.soundVolume, position);
}
// 자동 정리 설정
if (effect.duration > 0)
{
StartCoroutine(StopEffectAfterDelay(ps, effect.duration));
}
return ps;
}
private void PlaySoundEffect(AudioClip clip, float volume, Vector3 position)
{
if (audioSource.isPlaying && audioSource.clip == clip) return;
audioSource.clip = clip;
audioSource.volume = volume;
audioSource.Play();
}
private System.Collections.IEnumerator StopEffectAfterDelay(ParticleSystem ps, float delay)
{
yield return new WaitForSeconds(delay);
if (ps != null)
{
particlePool?.ReturnParticle(ps);
}
}
// 편의 메서드들
public ParticleSystem PlayHitEffect(Vector3 position, Vector3 normal)
{
Quaternion rotation = Quaternion.LookRotation(normal);
return PlayEffect("hit", position, rotation);
}
public ParticleSystem PlayExplosionEffect(Vector3 position, float scale = 1f)
{
var ps = PlayEffect("explosion", position);
if (ps != null && scale != 1f)
{
ps.transform.localScale = Vector3.one * scale;
}
return ps;
}
public ParticleSystem PlayTrailEffect(Vector3 startPos, Vector3 endPos)
{
Vector3 direction = (endPos - startPos).normalized;
Quaternion rotation = Quaternion.LookRotation(direction);
return PlayEffect("trail", startPos, rotation);
}
public void StopAllEffects()
{
particlePool?.ReturnAllParticles();
}
public void StopEffectsByCategory(string category)
{
// 카테고리별 효과 중지 (구현 필요)
Debug.Log($"[AIT ParticleEffect] 카테고리별 효과 중지: {category}");
}
}2. 파티클 성능 모니터
c#
// Unity C# - 파티클 성능 모니터링
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
public class AITParticlePerformanceMonitor : MonoBehaviour
{
[System.Serializable]
public class PerformanceMetrics
{
public int totalActiveParticles;
public int totalActiveEmitters;
public float particleMemoryUsage;
public float averageFPS;
public float budgetUtilization;
public Dictionary<string, int> categoryBreakdown;
public Dictionary<int, int> qualityBreakdown;
}
private AITParticleBudgetManager budgetManager;
private AITParticleLODManager lodManager;
private AITParticlePool particlePool;
[Header("모니터링 UI")]
public Canvas monitorUI;
public Text totalParticlesText;
public Text budgetUtilizationText;
public Text fpsText;
public Text memoryText;
public Slider budgetSlider;
public Button optimizeButton;
[Header("모니터링 설정")]
public bool showMonitorUI = true;
public float updateInterval = 0.5f;
public bool enableAutoOptimization = true;
public float optimizationThreshold = 0.85f; // 85% 이상 사용률에서 최적화
private PerformanceMetrics currentMetrics;
private List<float> fpsHistory;
private const int FPS_HISTORY_SIZE = 30;
private void Start()
{
budgetManager = AITParticleBudgetManager.Instance;
lodManager = FindObjectOfType<AITParticleLODManager>();
particlePool = AITParticlePool.Instance;
fpsHistory = new List<float>();
currentMetrics = new PerformanceMetrics
{
categoryBreakdown = new Dictionary<string, int>(),
qualityBreakdown = new Dictionary<int, int>()
};
SetupUI();
InvokeRepeating(nameof(UpdateMetrics), 0f, updateInterval);
if (enableAutoOptimization)
{
InvokeRepeating(nameof(CheckAutoOptimization), 5f, 5f);
}
}
private void SetupUI()
{
if (monitorUI != null)
{
monitorUI.gameObject.SetActive(showMonitorUI);
}
if (optimizeButton != null)
{
optimizeButton.onClick.AddListener(PerformManualOptimization);
}
}
private void UpdateMetrics()
{
CollectMetrics();
UpdateUI();
// 성능 히스토리 업데이트
fpsHistory.Add(currentMetrics.averageFPS);
if (fpsHistory.Count > FPS_HISTORY_SIZE)
{
fpsHistory.RemoveAt(0);
}
}
private void CollectMetrics()
{
// 예산 관리자에서 메트릭 수집
if (budgetManager != null)
{
var budgetStats = budgetManager.GetBudgetStats();
currentMetrics.totalActiveParticles = budgetStats.totalActiveParticles;
currentMetrics.totalActiveEmitters = budgetStats.activeEmitters;
currentMetrics.budgetUtilization = budgetStats.GetBudgetUtilization();
currentMetrics.particleMemoryUsage = budgetStats.memoryUsage;
currentMetrics.categoryBreakdown = new Dictionary<string, int>(budgetStats.categoryUsage);
}
// FPS 계산
currentMetrics.averageFPS = 1f / Time.deltaTime;
// LOD 관리자에서 추가 메트릭
if (lodManager != null)
{
var lodStats = lodManager.GetLODStats();
// LOD 관련 메트릭 추가 처리
}
}
private void UpdateUI()
{
if (!showMonitorUI || monitorUI == null) return;
// 총 파티클 수
if (totalParticlesText != null)
{
totalParticlesText.text = $"활성 파티클: {currentMetrics.totalActiveParticles}";
}
// 예산 활용률
if (budgetUtilizationText != null)
{
float utilization = currentMetrics.budgetUtilization * 100f;
budgetUtilizationText.text = $"예산 사용률: {utilization:F1}%";
// 색상 변경
if (utilization > 90f)
budgetUtilizationText.color = Color.red;
else if (utilization > 70f)
budgetUtilizationText.color = Color.yellow;
else
budgetUtilizationText.color = Color.green;
}
// 예산 슬라이더
if (budgetSlider != null)
{
budgetSlider.value = currentMetrics.budgetUtilization;
}
// FPS
if (fpsText != null)
{
fpsText.text = $"FPS: {currentMetrics.averageFPS:F1}";
if (currentMetrics.averageFPS < 30f)
fpsText.color = Color.red;
else if (currentMetrics.averageFPS < 45f)
fpsText.color = Color.yellow;
else
fpsText.color = Color.green;
}
// 메모리
if (memoryText != null)
{
memoryText.text = $"메모리: {currentMetrics.particleMemoryUsage:F1} MB";
}
}
private void CheckAutoOptimization()
{
if (currentMetrics.budgetUtilization > optimizationThreshold)
{
Debug.Log($"[AIT ParticleMonitor] 자동 최적화 실행 (사용률: {currentMetrics.budgetUtilization:P1})");
PerformAutomaticOptimization();
}
}
private void PerformAutomaticOptimization()
{
// 1. LOD 시스템 최적화
if (lodManager != null)
{
lodManager.SetGlobalLODMultiplier(0.8f);
}
// 2. 품질 저하
if (budgetManager != null)
{
budgetManager.SetGlobalQuality(AITParticleBudgetManager.ParticleSystemInfo.QualityLevel.Medium);
}
// 3. 불필요한 파티클 정리
if (particlePool != null)
{
particlePool.ReturnAllParticles();
}
Debug.Log("[AIT ParticleMonitor] 자동 최적화 완료");
}
public void PerformManualOptimization()
{
Debug.Log("[AIT ParticleMonitor] 수동 최적화 실행");
// 사용자가 요청한 최적화
PerformAutomaticOptimization();
// 추가적인 최적화 옵션들
OptimizeParticleQuality();
CleanupInactiveParticles();
}
private void OptimizeParticleQuality()
{
// 현재 FPS에 따른 품질 조정
AITParticleBudgetManager.ParticleSystemInfo.QualityLevel targetQuality;
if (currentMetrics.averageFPS < 25f)
{
targetQuality = AITParticleBudgetManager.ParticleSystemInfo.QualityLevel.Low;
}
else if (currentMetrics.averageFPS < 40f)
{
targetQuality = AITParticleBudgetManager.ParticleSystemInfo.QualityLevel.Medium;
}
else
{
targetQuality = AITParticleBudgetManager.ParticleSystemInfo.QualityLevel.High;
}
budgetManager?.SetGlobalQuality(targetQuality);
}
private void CleanupInactiveParticles()
{
budgetManager?.ForceCleanup();
particlePool?.ReturnAllParticles();
}
// 공개 API 메서드들
public PerformanceMetrics GetCurrentMetrics()
{
return currentMetrics;
}
public float GetAverageFPS(int sampleCount = 10)
{
if (fpsHistory.Count == 0) return 0f;
int samples = Mathf.Min(sampleCount, fpsHistory.Count);
float total = 0f;
for (int i = fpsHistory.Count - samples; i < fpsHistory.Count; i++)
{
total += fpsHistory[i];
}
return total / samples;
}
public void SetOptimizationThreshold(float threshold)
{
optimizationThreshold = Mathf.Clamp01(threshold);
}
public void ToggleMonitorUI()
{
showMonitorUI = !showMonitorUI;
if (monitorUI != null)
{
monitorUI.gameObject.SetActive(showMonitorUI);
}
}
// 디버그 정보 출력
[ContextMenu("Print Performance Report")]
public void PrintPerformanceReport()
{
var report = $@"
=== 파티클 성능 리포트 ===
총 활성 파티클: {currentMetrics.totalActiveParticles}
총 활성 이미터: {currentMetrics.totalActiveEmitters}
예산 사용률: {currentMetrics.budgetUtilization:P1}
평균 FPS: {currentMetrics.averageFPS:F1}
메모리 사용량: {currentMetrics.particleMemoryUsage:F1} MB
=== 카테고리별 분석 ===";
foreach (var kvp in currentMetrics.categoryBreakdown)
{
report += $"\n{kvp.Key}: {kvp.Value}개";
}
report += "\n========================";
Debug.Log(report);
}
}문제 해결 및 디버깅
1. 일반적인 파티클 성능 문제
메모리 누수 감지 및 해결
c#
public class AITParticleMemoryDiagnostic : MonoBehaviour
{
public void DiagnoseMemoryLeaks()
{
// 활성 파티클 시스템 분석
var allParticles = FindObjectsOfType<ParticleSystem>();
var suspiciousParticles = new List<ParticleSystem>();
foreach (var ps in allParticles)
{
// 긴 시간 동안 계속 파티클을 생성하는 시스템 감지
if (ps.isPlaying && ps.main.loop && ps.particleCount > ps.main.maxParticles * 0.8f)
{
suspiciousParticles.Add(ps);
}
}
Debug.Log($"[AIT Diagnostic] 메모리 누수 의심 파티클: {suspiciousParticles.Count}개");
foreach (var ps in suspiciousParticles)
{
Debug.LogWarning($" - {ps.name}: {ps.particleCount}/{ps.main.maxParticles} 파티클");
}
}
public void FixMemoryLeaks()
{
var allParticles = FindObjectsOfType<ParticleSystem>();
foreach (var ps in allParticles)
{
// 과도한 파티클 생성 중단
if (ps.particleCount > 1000)
{
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
Debug.Log($"[AIT Fix] 과도한 파티클 시스템 정지: {ps.name}");
}
}
}
}성능 최적화 권장사항
c#
public class AITParticleOptimizationSuggestions
{
public static void AnalyzeAndSuggest(ParticleSystem ps)
{
Debug.Log($"=== {ps.name} 최적화 분석 ===");
var main = ps.main;
var emission = ps.emission;
var shape = ps.shape;
var renderer = ps.GetComponent<ParticleSystemRenderer>();
// 파티클 수 최적화
if (main.maxParticles > 500)
{
Debug.LogWarning("권장사항: 최대 파티클 수를 500개 이하로 제한하세요");
}
// 이미션 최적화
if (emission.enabled && emission.rateOverTime.constant > 100)
{
Debug.LogWarning("권장사항: 초당 이미션을 100개 이하로 줄이세요");
}
// 렌더링 최적화
if (renderer != null)
{
if (renderer.material != null && renderer.material.shader.name.Contains("Standard"))
{
Debug.LogWarning("권장사항: 모바일 최적화 쉐이더 사용을 고려하세요");
}
if (renderer.sortMode == ParticleSystemSortMode.Distance)
{
Debug.LogWarning("권장사항: 정렬이 필요하지 않다면 None으로 설정하세요");
}
}
// 충돌 검사 최적화
var collision = ps.collision;
if (collision.enabled && collision.type == ParticleSystemCollisionType.World)
{
Debug.LogWarning("권장사항: 월드 충돌 대신 Planes 사용을 고려하세요");
}
}
}관련 도구 및 리소스
1. 파티클 예산 설정 도구
c#
// Unity Editor - 파티클 예산 설정 에디터
#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;
[CustomEditor(typeof(AITParticleBudgetManager))]
public class AITParticleBudgetEditor : Editor
{
public override void OnInspectorGUI()
{
DrawDefaultInspector();
AITParticleBudgetManager manager = (AITParticleBudgetManager)target;
EditorGUILayout.Space();
EditorGUILayout.LabelField("실시간 통계", EditorStyles.boldLabel);
if (Application.isPlaying && manager != null)
{
var stats = manager.GetBudgetStats();
EditorGUILayout.LabelField($"활성 파티클: {stats.totalActiveParticles}/{stats.maxTotalParticles}");
EditorGUILayout.LabelField($"활성 이미터: {stats.activeEmitters}/{stats.maxActiveEmitters}");
EditorGUILayout.LabelField($"메모리 사용: {stats.memoryUsage:F1}/{stats.maxMemoryUsage} MB");
EditorGUILayout.LabelField($"현재 FPS: {stats.fps:F1}");
// 예산 사용률 프로그레스 바
float budgetUtilization = stats.GetBudgetUtilization();
EditorGUILayout.LabelField($"예산 사용률: {budgetUtilization:P1}");
Rect rect = EditorGUILayout.GetControlRect();
EditorGUI.ProgressBar(rect, budgetUtilization, "Budget Utilization");
// 카테고리별 분석
EditorGUILayout.Space();
EditorGUILayout.LabelField("카테고리별 사용량", EditorStyles.boldLabel);
foreach (var category in stats.categoryUsage)
{
EditorGUILayout.LabelField($"{category.Key}: {category.Value}개");
}
}
else
{
EditorGUILayout.LabelField("플레이 모드에서만 통계 표시");
}
EditorGUILayout.Space();
// 유틸리티 버튼들
if (GUILayout.Button("강제 정리"))
{
if (Application.isPlaying)
{
manager.ForceCleanup();
}
}
if (GUILayout.Button("전체 품질 - 낮음"))
{
if (Application.isPlaying)
{
manager.SetGlobalQuality(AITParticleBudgetManager.ParticleSystemInfo.QualityLevel.Low);
}
}
if (GUILayout.Button("성능 리포트 출력"))
{
var monitor = FindObjectOfType<AITParticlePerformanceMonitor>();
if (monitor != null)
{
monitor.PrintPerformanceReport();
}
}
}
}
#endif2. 자동화된 파티클 최적화 도구
c#
// Unity Editor - 자동 파티클 최적화 도구
#if UNITY_EDITOR
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
public class AITParticleOptimizerWindow : EditorWindow
{
private Vector2 scrollPosition;
private List<ParticleSystem> sceneParticles;
private Dictionary<ParticleSystem, OptimizationSuggestion> suggestions;
[System.Serializable]
public class OptimizationSuggestion
{
public string issue;
public string solution;
public System.Action fix;
public bool canAutoFix;
}
[MenuItem("앱인토스/파티클 최적화 도구")]
public static void ShowWindow()
{
GetWindow<AITParticleOptimizerWindow>("파티클 최적화 도구");
}
private void OnGUI()
{
GUILayout.Label("파티클 시스템 최적화 도구", EditorStyles.boldLabel);
if (GUILayout.Button("씬 분석"))
{
AnalyzeScene();
}
if (sceneParticles != null && sceneParticles.Count > 0)
{
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
foreach (var ps in sceneParticles)
{
if (ps == null) continue;
EditorGUILayout.BeginVertical("box");
EditorGUILayout.LabelField(ps.name, EditorStyles.boldLabel);
if (suggestions.ContainsKey(ps))
{
var suggestion = suggestions[ps];
EditorGUILayout.LabelField($"문제: {suggestion.issue}");
EditorGUILayout.LabelField($"해결책: {suggestion.solution}");
if (suggestion.canAutoFix && GUILayout.Button("자동 수정"))
{
suggestion.fix?.Invoke();
}
}
EditorGUILayout.EndVertical();
}
EditorGUILayout.EndScrollView();
if (GUILayout.Button("모든 문제 자동 수정"))
{
AutoFixAllIssues();
}
}
}
private void AnalyzeScene()
{
sceneParticles = new List<ParticleSystem>();
suggestions = new Dictionary<ParticleSystem, OptimizationSuggestion>();
var allParticles = FindObjectsOfType<ParticleSystem>();
foreach (var ps in allParticles)
{
sceneParticles.Add(ps);
var suggestion = AnalyzeParticleSystem(ps);
if (suggestion != null)
{
suggestions[ps] = suggestion;
}
}
Debug.Log($"파티클 분석 완료: {sceneParticles.Count}개 발견, {suggestions.Count}개 최적화 가능");
}
private OptimizationSuggestion AnalyzeParticleSystem(ParticleSystem ps)
{
var main = ps.main;
// 과도한 파티클 수 검사
if (main.maxParticles > 1000)
{
return new OptimizationSuggestion
{
issue = $"과도한 파티클 수: {main.maxParticles}",
solution = "1000개 이하로 줄이기",
canAutoFix = true,
fix = () => {
var mainModule = ps.main;
mainModule.maxParticles = 500;
EditorUtility.SetDirty(ps);
}
};
}
// 비효율적 렌더링 검사
var renderer = ps.GetComponent<ParticleSystemRenderer>();
if (renderer != null && renderer.sortMode == ParticleSystemSortMode.Distance)
{
return new OptimizationSuggestion
{
issue = "거리 정렬 활성화됨",
solution = "정렬 비활성화로 성능 향상",
canAutoFix = true,
fix = () => {
renderer.sortMode = ParticleSystemSortMode.None;
EditorUtility.SetDirty(ps);
}
};
}
return null;
}
private void AutoFixAllIssues()
{
int fixedCount = 0;
foreach (var kvp in suggestions)
{
if (kvp.Value.canAutoFix)
{
kvp.Value.fix?.Invoke();
fixedCount++;
}
}
Debug.Log($"{fixedCount}개 파티클 시스템 자동 수정 완료");
AnalyzeScene(); // 재분석
}
}
#endif모범 사례
파티클 시스템 설계 가이드라인
- 파티클 수 제한
- UI 효과: 최대 50개
- 게임플레이 효과: 최대 200개
- 배경 효과: 최대 100개
- 폭발/임팩트: 최대 300개
- 메모리 효율성
- 텍스처 아틀라스 사용
- 파티클 풀링 필수
- 자동 정리 메커니즘 구현
- 성능 최적화
- LOD 시스템 적용
- 동적 품질 조정
- 예산 기반 관리
- 모바일 최적화
- 알파 블렌딩 최소화
- 오버드로우 방지
- 배터리 소모 고려
이러한 파티클 예산 관리 시스템을 통해 앱인토스 플랫폼의 제한된 환경에서도 풍부하고 효율적인 파티클 효과를 구현할 수 있어요.
