텍스처를 사용하여 Unity에서 수많은 VFX Graph 효과를 배치(Batch)하는 방법
요약
Unity의 VFX Graph를 사용하여 대규모의 시각 효과를 효율적으로 처리하기 위한 배치(Batching) 기법을 소개합니다. 개별 게임 오브젝트를 사용하는 대신, 발생한 효과의 위치 데이터를 텍스처에 저장하고 이를 VFX Graph에서 한 번에 읽어 들임으로써 성능 문제를 해결하고 중복 실행 문제를 방지합니다.
핵심 포인트
- 개별 적에게 VFX 게임 오브젝트를 부착하는 방식은 다수의 적이 등장할 때 성능 저하를 유발함
- 동일 프레임에 발생하는 효과가 위치 값을 덮어쓰는 문제를 해결하기 위해 텍스처 기반의 데이터 전달 방식 사용
- 모든 VFX 호출을 큐에 쌓아 텍스처에 저장한 후, 단 한 번의 버스트(Burst)로 파티클을 생성하여 최적화
- particleId를 활용하여 각 파티클이 텍스처 내의 정확한 위치 데이터를 매칭하여 읽어오도록 구현
텍스처를 사용하여 Unity에서 수많은 VFX Graph 효과를 배치(Batch)하는 방법
저는 Unity에서 3년 넘게 게임을 개발해 오고 있습니다. 이번 프로젝트에서는 구현하고 싶은 효과가 매우 많았기 때문에, 대부분의 VFX(Visual Effects)에 Visual Effect Graph 시스템을 사용하기로 결정했습니다. 또한, 저는 VFX Graph에 대해 전혀 모르는 초보자였기 때문에 이것이 어떻게 작동하는지 배우고 싶기도 했습니다.
제가 직면한 첫 번째 문제 중 하나는 성능(Performance) 문제였습니다. VFX Graph를 사용하려 했던 이유 중 하나가 더 나은 성능 때문이었다는 점을 생각하면 다소 아이러니한 상황이었습니다.
배경 설명
저는 한 번에 수많은 적(보통 200~500마리 정도)이 동시에 생존할 수 있는 타워 디펜스(Tower Defense) 게임을 만들고 있습니다. 또한 화상(Burn), 독(Poison), 출혈(Bleeding), 충격 효과(Impact effects), 폭발(Explosions), 타워 사격(Towers shooting), 번개(Lightning strikes), 연기(Smoke), 운석(Meteors), 그리고 더 많은 폭발과 같은 수많은 효과를 가지고 있습니다.
저의 첫 번째 구현 방식은 상당히 좋지 않았습니다.
지속적인 효과(지속 피해와 같은 경우)를 위해, 각 효과마다 적(Enemy)에게 Visual Effect 게임 오브젝트(Gameobjects)를 붙여두었습니다. 따라서 한 명의 적이 화상 VFX, 독 VFX, 출혈 VFX, 충격 VFX 등을 가질 수 있었습니다. 이는 특히 많은 적이 동시에 살아있을 때 분명히 매우 비효율적입니다.
폭발과 같은 일회성 VFX의 경우, VFX Graph 내에 Vector3를 노출시켜 재생하기 전에 효과를 정확한 위치로 이동시킬 수 있도록 했습니다. 하지만 여기에는 또 다른 문제가 있었습니다. 만약 두 개의 폭발이 정확히 같은 프레임(Frame)에 발생한다면 어떻게 될까요?
저의 첫 번째 구현 방식에서는 한 위치가 다른 위치를 그냥 덮어써 버렸기 때문에, 실제로는 하나의 폭발만 시각화되었습니다.
해결책
웹사이트와 Unity 포럼을 뒤져본 끝에 제가 찾아낸 해결책은 텍스처(Texture)를 사용하는 것이었습니다.
모듈(Module)과 파티클 ID(particleId)를 사용하면, 양(Amount)을 알고 있을 때 텍스처 상의 정확한 위치를 얻을 수 있습니다.
기본적인 아이디어는 프레임 동안 발생하는 모든 VFX 호출을 큐(Queue)에 쌓고, 그 데이터를 텍스처(Texture)에 넣은 다음, 해당 텍스처를 한 번 업로드하고 Amount 값을 사용하여 VFX Graph를 한 번 실행하는 것입니다.
우리는 항상 단일 버스트(Single Burst)로 파티클을 생성합니다
따라서 다음과 같이 처리하는 대신:
위치 A에서 폭발 재생
위치 B에서 폭발 재생
위치 C에서 폭발 재생
모든 위치를 텍스처에 저장하고 VFX Graph에 다음과 같이 명령합니다:
이번 프레임에 3개의 효과를 생성(Spawn)하라
그러면 VFX Graph 내부에서 생성된 각 파티클은 particleId를 사용하여 텍스처로부터 올바른 위치를 읽어옵니다.
즉, 파티클 0은 엔트리(Entry) 0을 읽고, 파티클 1은 엔트리 1을 읽으며, 파티클 2는 엔트리 2를 읽는 식입니다.
단 4개의 플로트(Float) 정보만 사용하는 일회성 VFX
단순한 폭발의 경우, 저는 단 4개의 플로트(Float)만 필요합니다:
x 위치
y 위치
z 위치
지속 시간 (Duration)
따라서 이를 하나의 RGBAFloat 텍스처 픽셀에 저장할 수 있습니다.
using UnityEngine;
using UnityEngine.VFX;
public class VFXExplosion : MonoBehaviour
{
[SerializeField] private VisualEffect visual;
private const int MaxEntries = 2048;
private const float DisableAfterSeconds = 10f;
private Texture2D positionTexture;
private Color[] positions;
private int entryCount;
private float lastPlayTime;
private bool isActive;
private void Start()
{
positionTexture = new Texture2D(MaxEntries, 1, TextureFormat.RGBAFloat, false);
positions = new Color[MaxEntries];
visual.SetTexture("PosTexture", positionTexture);
}
private void Update()
{
if (entryCount > 0)
{
positionTexture.SetPixelData(positions, 0);
positionTexture.Apply(false);
visual.SetInt("Amount", entryCount);
visual.Play();
entryCount = 0;
}
if (isActive && Time.time > lastPlayTime + DisableAfterSeconds)
{
visual.gameObject.SetActive(false);
isActive = false;
}
}
public void PlayEffect(Vector3 enemyPosition, float bombDuration)
{
visual.gameObject.SetActive(true);
isActive = true;
lastPlayTime = Time.time;
if (entryCount >= MaxEntries)
return;
positions[entryCount] = new Color(
enemyPosition.x,
enemyPosition.y,
enemyPosition.z,
bombDuration
);
entryCount++;
}
}
The 핵심 아이디어는 PlayEffect가 효과 데이터만 기록한다는 것입니다. 즉시 VFX Graph를 재생하지 않습니다.
그다음 Update에서 이번 프레임에 기록된 항목이 하나라도 있다면, 데이터가 텍스처 (Texture)로 업로드되고, 그래프 (Graph)가 해당 수치를 전달받은 뒤, 그래프가 한 번 재생됩니다.
제 게임에는 이러한 VFX 시스템이 많이 있는데, 사용되지 않을 때 Visual Effect 컴포넌트가 포함된 GameObject를 비활성화하는 것이 성능 향상에 꽤 도움이 된다는 것을 발견했습니다. 제 경우에는 보통 특정 시점에 VFX 시스템의 약 70-90%가 비활성 상태입니다.
4개 이상의 float 값이 필요한 경우
4개의 float 값보다 더 많은 정보가 필요하다면, 텍스처의 Y축을 늘릴 수 있습니다.
예를 들어, 이 텍스처는 2개의 행 (Row)을 가집니다:
positionTexture = new Texture2D(MaxEntries, 2, TextureFormat.RGBAFloat, false);
positions = new Color[MaxEntries * 2];
그러면 0번 행에는 한 세트의 데이터를 저장할 수 있고, 1번 행에는 또 다른 데이터 세트를 저장할 수 있습니다.
4개의 float를 더 저장하기 위해 두 번째 텍스처 행 추가
using UnityEngine;
using UnityEngine.VFX;
public class VFXMeteor : MonoBehaviour
{
[SerializeField] private VisualEffect visual;
private const int MaxEntries = 2048;
private const float DisableAfterSeconds = 10f;
private Texture2D positionTexture;
private Color[] positions;
private int entryCount;
private float lastPlayTime;
private bool isActive;
private void Start()
{
// 2개의 행을 사용하여, 효과당 4개가 아닌 8개의 float를 저장할 수 있습니다.
positionTexture = new Texture2D(MaxEntries, 2, TextureFormat.RGBAFloat, false);
positions = new Color[MaxEntries * 2];
visual.SetTexture("PosTexture", positionTexture);
}
private void Update()
{
if (entryCount > 0)
{
positionTexture.SetPixelData(positions, 0);
positionTexture.Apply(false);
visual.SetInt("Amount", entryCount);
visual.Play();
entryCount = 0;
}
if (isActive && Time.time > lastPlayTime + DisableAfterSeconds)
{
visual.gameObject.SetActive(false);
isActive = false;
}
}
public void PlayEffect(
Vector3 enemyPosition,
float meteorDuration = 1.5f,
float meteorSize = 0.6f,
float meteorSmokeMin = 0.45f,
float meteorSmokeMax = 0.6f,
float meteorImpactSize = 0.25f)
{
visual.gameObject.SetActive(true);
isActive = true;
lastPlayTime = Time.time;
if (entryCount >= MaxEntries)
return;
// Row 0
positions[entryCount] = new Color(
meteorSize,
meteorSmokeMin,
meteorSmokeMax,
meteorImpactSize
);
// Row 1
positions[entryCount + MaxEntries] = new Color(
enemyPosition.x,
enemyPosition.y,
enemyPosition.z,
meteorDuration
);
entryCount++;
}
}
VFX Graph에서는 단순히 row 0에서 처음 4개의 값을 샘플링하고, row 1에서 다음 4개의 값을 샘플링하면 됩니다.
예를 들어:
row 0 = size, smoke min, smoke max, impact size
row 1 = position x, position y, position z, duration
프로젝트에서 사용하는 방법
이 모든 것이 작동하도록 하려면, 모든 VFX 스크립트를 가진 하나의 GameObject가 필요합니다. 이 GameObject의 자식(children)들은 실제 Visual Effect 컴포넌트를 가진 GameObject들입니다.
그런 다음 VFXCaller 컴포넌트가 이 모든 VFX 스크립트에 대한 참조를 가집니다. 이를 통해 다음과 같이 어디서든 효과를 호출할 수 있습니다:
VFXCaller.MeteorVFX.PlayEffect(position);
이것이 가장 깔끔하거나 완벽한 설정은 아닐 수도 있지만, 제 게임에서는 꽤 잘 작동했습니다. 이제 각 시각 효과(visual effect)마다 하나의 VFX 컴포넌트만 가지면 됩니다.
이 모든 방식을 통해 다음과 같은 효과들을 만들 수 있었습니다 (이것은 제가 제작 중인 트레일러의 일부이지만, 몇몇 효과들을 멋지게 보여줍니다).
보너스 (Bonus)
이 접근 방식 덕분에 데미지 숫자(damage number) VFX Graph도 제작할 수 있었습니다. 기존에는 데미지 숫자가 표시되는 속도가 느려 문제가 있었습니다 (TextMesh Pro를 사용했는데, 한 번에 500개 이상의 텍스트가 활성화되면 성능이 급격히 저하되었습니다). VFX 접근 방식은 성능 저하를 거의 일으키지 않으면서도 제가 원하는 만큼 많은 데미지 숫자를 마음껏 쏟아낼 수 있게 해줍니다.
다음은 VFX를 통해 데미지 숫자가 생성되는 (극단적인) 예시입니다 (비트레이트가 아마 상당히 낮을 것입니다).
저는 현재 거의 완료 단계에 있는 게임의 1.0 업데이트를 작업 중입니다. 여기에서 확인하실 수 있습니다: https://store.steampowered.com/app/2322090/Crystal_Guardians_TD/
해당 페이지는 현재 콘텐츠와 비교하면 약간 예전 정보입니다.
참고: 저는 이를 위해 텍스처(texture)를 사용했지만, 아마 GraphicsBuffer 같은 것을 사용하는 것이 더 깔끔할 것 같습니다. 이것은 제가 처음 접한 방식이었고 설정하기가 쉬워서 그대로 진행했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 r/Unity3D (top/week)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기