1. 개요
오늘은 언리얼 엔진에서 파티클의 데이터를 관리하는 방식인 payload에 대해서 소개하도록 하겠습니다.
우리에게는 데이터를 담을 수 있는 다양한 도구들이 있습니다. (TArray, List, Vector)등..
그럼에도 언리얼엔진에서 파티클에 대한 데이터를 관리할때는 PayLoad를 사용합니다.
일련의 데이터들중에 가장 첫번째 데이터의 주소를 uint8*로 유지하고 접근하는 방식입니다.
이렇게 하면 디버깅하기도 굉장히 어렵고 시각적으로 확인도 하기 어렵습니다.
또한 구현에도 많은 노력이 들어갑니다.
그럼에도 이러한 데이터 관리 방식을 사용하는 이유와 Payload에 대해서 자세히 알아보는 시간을 가지겠습니다.
2. 파티클 정보 구조체
기본적인 파티클의 데이터를 담는 구조체는 아래와 같이 정의되어 있습니다.
struct FBaseParticle // 파티클 하나의 완전한 상태를 저장하는 구조체
{
// 48 bytes
FVector OldLocation; // Last frame's location, used for collision
FVector Location; // Current location
// 16 bytes
FVector BaseVelocity; // Velocity = BaseVelocity at the start of each frame.
float Rotation; // Rotation of particle (in Radians)
// 16 bytes
FVector Velocity; // Current velocity, gets reset to BaseVelocity each frame to allow
float BaseRotationRate; // Initial angular velocity of particle (in Radians per second)
// 16 bytes
FVector BaseSize; // Size = BaseSize at the start of each frame
float RotationRate; // Current rotation rate, gets reset to BaseRotationRate each frame
// 16 bytes
FVector Size; // Current size, gets reset to BaseSize each frame
int32 Flags; // Flags indicating various particle states
// 16 bytes
FLinearColor Color; // Current color of particle.
// 16 bytes
FLinearColor BaseColor; // Base color of the particle
// 16 bytes
float RelativeTime; // Relative time, range is 0 (==spawn) to 1 (==death)
float OneOverMaxLifetime; // Reciprocal of lifetime
float Placeholder0;
float Placeholder1;
FBaseParticle()
: OldLocation(FVector::ZeroVector)
, Location(FVector::ZeroVector)
, BaseVelocity(FVector::ZeroVector)
, Rotation(0.0f)
, Velocity(FVector::ZeroVector)
, BaseRotationRate(0.0f)
, BaseSize(FVector(1.0f)) // 기본 크기를 (1,1,1)로
, RotationRate(0.0f)
, Size(BaseSize)
, Flags(0)
, Color(FLinearColor::White)
, BaseColor(FLinearColor::White)
, RelativeTime(0.0f)
, OneOverMaxLifetime(0.0f) // LifetimeModule에서 값을 설정하지 않으면 삭제되지 않음, 언리얼도 동일
, Placeholder0(0.0f)
, Placeholder1(0.0f)
{}
};
이 구조체에 대한 정보는 UParticleSystemComponent안의 FParticleEmitterInstance가 멤버 변수로 가지고 있습니다.
이 사진처럼 포인터로 가지고 있습니다.
처음 구조를 볼때에 가장 큰 의문은 어째서 uint8* 로 데이터를 가지고 있냐는 것이었습니다.
직관적인 생각으로는 FBaseParticle자체를 들고있거나 배열등으로 들고있는 것이 더 편할것이라고 생각이 들었습니다.
그럼에도 Unreal Engine5에서는 uint8*로 데이터를 관리합니다.
어째서 이러한 방법을 쓰고 어떻게 이러한 방식으로 데이터 관리가 될까요?
3. 왜 이렇게 관리하나?
파티클은 정말 수많은 입자들의 정보를 다뤄야하는 부분입니다.
이때 이러한 방식으로 데이터를 관리하면 성능상의 큰 이점이 있습니다.
3-1. 연속적인 데이터로 캐시 적중률 향상
uint8* 형태의 포인터가 연속된 데이터의 첫번째 요소의 주소를 가지고 있습니다.
다시 말하면 파티클 데이터들은 (FBaseParticle + 페이로드)가 한 덩어리(Stride)로 메모리에 쭉 이어져 있습니다.
이렇게 되면 cache 적중률이 향상되어서 성능이 향상됩니다.
좀더 구체적으로 연속된 메모리에 접근할때 빠른 이유에 대해 설명하자면 다음과 같습니다.
1. 캐시 라인 단위 로딩
- 캐시 라인(Cache Line): CPU 캐시는 보통 64바이트 단위로 메모리에서 데이터를 가져와 저장해 놓습니다.
- 연속 주소: A[0], A[1], A[2] … 처럼 메모리가 쭉 이어져 있으면, 한 번의 캐시 미스(miss)로 가져온 64바이트 안에 연속된 여러 요소가 함께 올라옵니다.
- 예를 들어 A[0]을 읽으면, 실제로 A[0]부터 A[7] (float 기준)까지가 한 번에 캐시에 올라가요.
- 흩어진 주소: p->next 같은 포인터 체이닝 구조라면, 각 노드가 힙의 임의 위치에 흩어져 있기 때문에
- 매번 다른 캐시 라인이 필요해서 매번 캐시 미스가 발생합니다.
2. 하드웨어 프리페처(Hardware Prefetcher)
- CPU는 “다음에 이 주소를 또 쓸 것 같다”고 판단하면 백그라운드에서 미리 메모리를 읽어둡니다.
- 연속된 주소 패턴을 쉽게 감지하고, 미리 다음 캐시 라인을 채워 놓기 때문에
- 실제 코드가 for (i=0; i<N; ++i) use(A[i]); 형태면, CPU가 미리 A[i+1], A[i+2]… 를 읽어 옵니다.
- 흩어진 접근은 예측하기 어려워서 프리페처가 거의 도움을 못 줍니다.
3. TLB(Locality Translation Lookaside Buffer)
- 가상 주소 → 물리 주소 매핑 정보를 저장하는 버퍼입니다.
- 연속된 가상 주소들은 같은 페이지(4KB)에 몰려 있기 때문에, 한 번 매핑한 TLB 엔트리를 여러 번 재활용할 수 있습니다.
- 반대로 흩어진 주소들은 페이지가 자주 바뀌어 TLB 미스가 자주 발생하고, 이로 인한 페이지 테이블 워크(page walk) 비용이 커집니다.
4. 메모리 레이턴시 vs 대역폭
- RAM 접근 레이턴시는 대략 50–100ns,
- L1 캐시 접근은 1–4ns 수준으로 10–20배 빠릅니다.
- 연속 메모리 접근은 “한 번 메인 메모리에서 읽어온 블록(캐시 라인)을 여러 번 재활용”하므로
→ 대역폭과 레이턴시 이득을 동시에 누릴 수 있어요.
3-2. 의문1 : 연속적인 메모리는 배열로도 확보 가능하지 않나?
저의 의문은 연속된 메모리가 속도를 높힌다면 굳이 어려운 payload로 데이터를 관리하는 것이 아니라 그냥 배열로 선언하면
쉽게 구현할 수 있지 않을까 라는 의문이 있었습니다.
그러나 payload 시스템은 배열 이상의 장점을 가지고 있습니다.
payload를 사용하면 다양한 타입과 크기의 데이터들을 붙여서 관리하는것이 편합니다.
만약 새로운 모듈에 대한 데이터가 필요하면 배열을 사용할 경우에는 원본 struct를 수정해야 합니다.
또한 수많은 파티클들이 자신에게 필요없는 정보도 가지고 있어서 메모리가 낭비되기도 합니다.
그러나 payload를 사용하면 크기만 알면 기존 데이터의 뒤에다 붙히면 됩니다.
즉 확장성이 크게 개선되는 장점이 있습니다.
3-3. 의문2 : 원본 데이터를 수정하지 않고 상속을 쓰면 되지 않나?
상속을 사용해서 원본데이터도 수정하지 않고 필요한 데이터만 가지게 하는것이 가능하지 않을까 라는 의문이 또 있었습니다.
참고로 여기서 말하는 상속은 파티클 개별 데이터 객체의 구조에 대한 상속입니다.
아래가 그 잘못된 예시입니다.
struct FBaseParticle { FVector Location; ... };
struct FColorParticle : public FBaseParticle { FLinearColor Color; };
struct FSizeParticle : public FBaseParticle { FVector Size; };
이렇게 데이터 구조를 상속하면 아래와 같은 문제가 생깁니다.
1. 파티클 하나마다 vtable 포인터 셍상
- 파티클 수천 개 생성하면, 각각에 가상 함수 포인터가 들어가서 메모리 낭비발생
- 그리고 파티클 업데이트할 때마다 가상 함수로 파티클 타입 판단필요
2. 연속된 메모리 불가
- std::vector<FBaseParticle*>에 다양한 파생 클래스를 담으면 → 메모리는 전부 흩어짐 (Heap scattered)
- CPU cache locality 완전히 상실
- 파티클 1개 처리할 때마다 다른 메모리 위치 접근 → 캐시 미스, TLB 미스 폭증
현재 프로젝트에서는 모듈 시스템을 상속하고 있어서 이 부분과는 다른점을 알려드립니다.
지금 구조는 모듈 수만큼만 가상함수 호출이 일어나고
해당 모듈의 업데이트에서 활성화된 파티클 수만큼 반복을 하며 처리를 합니다.
그래서 불필요한 가상함수 호출은 최소화 되었습니다.
3-4. 의문3 : 왜 하필 uint8*인가?
uint8은 1바이트의 크기를 가집니다.
float이나 int등 다양한 데이터 타입은 모두 바이트단위의 크기를 가지고 있습니다.
그래서 이러한 자료들에 접근할 수 있는 최소한의 단위인 1바이트를 가르키는 uint8*를 사용합니다.
만약 다른 타입을 쓰면 사용이 매우 불편해집니다.
float* ptr = base + 1; // 1 * sizeof(float) 만큼 이동 (4바이트)
uint8* ptr = base + 1; // 1바이트만 이동 (정확히 내가 원하는 바이트 단위)
4. 구현방식, 구조
지금까지 uint8*로 데이터를 관리하는 것의 이점을 알아보았습니다.
그러면 어떠한 방식으로 구현되어 사용되는지 알아보겠습니다.
아래는 FParticleEmitterInstance의 Init함수의 핵심적인 부분입니다.
const int32 BaseSize = sizeof(FBaseParticle);
int32 RequiredPayload = 0;//SpriteTemplate->ReqInstanceBytes;
for (UParticleModule* M : CurrentLODLevel->Modules)
{
ModulePayloadOffsetMap[M] = BaseSize + RequiredPayload;
RequiredPayload += M->ReqInstanceBytes;
}
// 2) 전체 파티클당 사이즈 (페이로드 포함)
ParticleSize = BaseSize + RequiredPayload;
// 16바이트 정렬 (SSE-friendly)
ParticleSize = (ParticleSize + 15) & ~15;
// 3) 최대 파티클 수 설정
MaxActiveParticles = 2048;
// 3) 스트라이드 계산 (패딩 포함)
ParticleStride = ParticleSize;
// 4) 시뮬레이션용 메모리 블록 할당
{
int32 DataBytes = ParticleStride * MaxActiveParticles;
int32 IndexCount = MaxActiveParticles;
int32 BlockSize = DataBytes + sizeof(uint16) * IndexCount;
ParticleData = static_cast<uint8*>(std::malloc(BlockSize));
ParticleIndices = reinterpret_cast<uint16*>(ParticleData + DataBytes);
}
잘라서 하나씩 설명해보겠습니다.
1. BaseSize바탕으로 PayloadOffset Map만들기
const int32 BaseSize = sizeof(FBaseParticle);
int32 RequiredPayload = 0; // SpriteTemplate->ReqInstanceBytes;
for (UParticleModule* M : CurrentLODLevel->Modules)
{
ModulePayloadOffsetMap[M] = BaseSize + RequiredPayload;
RequiredPayload += M->ReqInstanceBytes;
}
우선 파티클의 기본적인 정보를 담은 FBaseParticle의 크기를 BaseSize에 저장합니다.
그리고 현재 LODLevel이 보유한 파티클 모듈들을 반복문을 통해서 접근합니다.
Map은 Key가 모듈이고 Value는 해당 모듈의 Offset입니다.
해당모듈을 Key로 넣으면 파티클 1개의 메모리 안에서 이 모듈의 데이터가 어디서부터 시작되는지(offset)를 알 수 있게 됩니다.
왜냐하면 RequiredPayLoad값은 계속해서 더해지는 방식이기 때문입니다.
여기서 ReqInstanceBytes는 각 모듈이 파티클 1개당 필요로 하는 페이로드의 크기이며,
이는 각 모듈의 생성자나 초기화 시점에 정의됩니다.
모듈마다 필요한 데이터가 다르기 때문에 이 값은 모듈마다 달라질 수 있습니다.
즉 필요한 추가정보만큼의 크기를 가지게 됩니다.
아래 사진은 2개의 모듈이 있다는 가정하에 데이터 구조를 표현한 예시입니다.
2. ParticleSize 16바이트 정렬
ParticleSize = BaseSize + RequiredPayload;
ParticleSize = (ParticleSize + 15) & ~15; // 16바이트 정렬
이 부분은 16바이트로 크기를 정렬하는 부분입니다.
SIMD명령어 호환, CPU가 최적화해서 접근하도록 정렬한 것입니다.
3. 파티클 최대수, Stide설정
MaxActiveParticles = 2048;
ParticleStride = ParticleSize;
최대 파티클수는 임의로 2048개로 정했습니다.
파티클 간 메모리 간격인 Stride는 정렬된 ParticleSize를 그대로 사용합니다.
4. 메모리 블록 할당
int32 DataBytes = ParticleStride * MaxActiveParticles;
int32 IndexCount = MaxActiveParticles;
int32 BlockSize = DataBytes + sizeof(uint16) * IndexCount;
ParticleData = static_cast<uint8*>(std::malloc(BlockSize));
ParticleIndices = reinterpret_cast<uint16*>(ParticleData + DataBytes);
ParticleStride와 파티클 최대수를 곱해서 데이터의 크기를 저장합니다.
또한 인덱스수 역시 최대 파티클 수와 같습니다.
한 블록(데이터와 인덱스를 포함한 사이즈)는 최종 데이터 크기와 인덱스 카운트를 더해줍니다.
여기서 uint16의 크기만큼 곱해주는 이유는 인덱스 하나의 크기가 2바이트 정수를 사용하기 때문입니다.
파티클 데이터에는 BlockSize만큼 메모리 공간을 할당해주고
Indicies는 "ParticleData + DataBytes"는 파티클 시뮬레이션 데이터가 끝나는 지점을 가리킵니다.
reinterpret_cast<uint16*>를 통해 이 위치부터는 uint16 배열처럼 접근하게 됩니다.
이 인덱스 배열은 주로 활성 파티클을 빠르게 액세스하기 위해 사용됩니다.
5. 모듈 업데이트 방식, 매크로
이러한 방식의 매크로는 저 역시 문법을 이해하기가 난해합니다.
이렇게 매크로를 정의하고 사용하는 부분을 직접 보여드리겠습니다.
#define BEGIN_UPDATE_LOOP \
{ \
/* -- 초기 유효성 검사 -- */ \
do \
{ \
if ((Owner == nullptr) || (Owner->Component == nullptr)) \
{ \
UE_LOG(LogLevel::Error, "BEGIN_UPDATE_LOOP NULL"); \
} \
} while (0); \
\
/* -- 루프 준비 -- */ \
int32& ActiveParticles = Owner->ActiveParticles; \
uint32 CurrentOffset = Offset; \
const uint8* ParticleData = Owner->ParticleData; \
const uint32 ParticleStride = Owner->ParticleStride; \
uint16* ParticleIndices = Owner->ParticleIndices; \
\
/* -- 파티클 루프 시작 -- */ \
for (int32 i = ActiveParticles - 1; i >= 0; i--) \
{ \
const int32 CurrentIndex = ParticleIndices[i]; \
const uint8* ParticleBase = ParticleData + CurrentIndex * ParticleStride; \
FBaseParticle& Particle = *((FBaseParticle*) ParticleBase); \
\
/* -- 프리징된 파티클 제외 조건문 시작 -- */ \
if ((Particle.Flags & STATE_Particle_Freeze) == 0) \
{
#define END_UPDATE_LOOP \
} /* ← 위 조건문 닫힘 */ \
\
/* -- 루프 후처리 -- */ \
CurrentOffset = Offset; \
} /* ← for 루프 닫힘 */ \
} /* ← 전체 매크로 블럭 닫힘 */
아래 코드는 눈내리는 움직임을 표현하는 파티클 모듈입니다.
복잡해 보이지만 우리가 봐야할 핵심은 따로 있습니다.
void UParticleModuleSnow::Update(FParticleEmitterInstance* Owner, int32 Offset, float DeltaTime)
{
BEGIN_UPDATE_LOOP;
// 기본 Z축 낙하 - GravityScale 적용
Particle.Velocity.Z = Particle.BaseVelocity.Z * GravityScale.GetValue(0);
// 파티클별 고유 특성 계산
float timeOffset = static_cast<float>(Particle.Flags % 1000) / 200.0f;
float currentTime = Owner->EmitterTime + timeOffset;
// 사인 함수로 부드러운 좌우 흔들림 (시간에 따라 변화)
float swayAmount = SwayMinAmount.GetValue(0) + ((Particle.Flags % 500) / 500.0f) *
(SwayMaxAmount.GetValue(0) - SwayMinAmount.GetValue(0));
float swayValue = sinf(currentTime * SwayFrequency.GetValue(0)) * swayAmount;
Particle.Velocity.X = swayValue;
Particle.BaseVelocity.X = swayValue;
// 회전 속도 추가 (파티클별로 다른 속도와 방향)
float rotSpeed = RotationSpeedMin.GetValue(0) + ((Particle.Flags % 400) / 400.0f) *
(RotationSpeedMax.GetValue(0) - RotationSpeedMin.GetValue(0));
// 회전 방향 결정 (RotationDirectionBias 사용)
// Flags의 홀수/짝수로 회전 방향 결정하되, RotationDirectionBias 값으로 방향 결정 로직 수정
bool clockwise = (Particle.Flags % 2 == 0);
// RotationDirectionBias 값에 따라 회전 방향 결정
float dirBias = RotationDirectionBias.GetValue(0);
if (dirBias != 0.0f) {
if (dirBias > 0.0f && Particle.Flags % 100 < dirBias * 100) {
clockwise = true; // 양수: 시계방향 선호
}
else if (dirBias < 0.0f && Particle.Flags % 100 < -dirBias * 100) {
clockwise = false; // 음수: 반시계방향 선호
}
}
if (!clockwise) {
rotSpeed = -rotSpeed;
}
Particle.RotationRate = rotSpeed;
END_UPDATE_LOOP;
}
void UParticleModuleSnow::Update(FParticleEmitterInstance* Owner, int32 Offset, float DeltaTime)
{
BEGIN_UPDATE_LOOP;
// 모듈의 로직
END_UPDATE_LOOP;
}
결국 이런식으로 된다는 것이지요
간단하게 저 매크로를 설명하자면 ActiveParicle의 수만큼 루프를 돌며 각 파티클의 offset과 index를 적절하게 설정해주고
로직에서 해당 파티클의 정보를 변환하는 방식입니다.
이 부분이 잘 이해가 안가면 풀어서 써보도록 하겠습니다.
우리가 매크로로 위와 같이 적었지만 실제 컴파일러는 이 코드를 컴파일 할때 아래와 같이 읽습니다.
#define BEGIN_UPDATE_LOOP에 대한 코드는 아래처럼 치환
do{
if ((Owner == NULL) || (Owner->Component == NULL))
{
UE_LOG(LogLevel::Error, "BEGINE_UPDATE_LOOP NULL");
}
} while(0);
int32& ActiveParticles = Owner->ActiveParticles;
uint32 CurrentOffset = Offset;
const uint8* ParticleData = Owner->ParticleData;
const uint32 ParticleStride = Owner->ParticleStride;
uint16* ParticleIndices = Owner->ParticleIndices;
for(int32 i=ActiveParticles-1; i>=0; i--)
{
const int32 CurrentIndex = ParticleIndices[i];
const uint8* ParticleBase = ParticleData + CurrentIndex * ParticleStride;
FBaseParticle& Particle = *((FBaseParticle*) ParticleBase);
if ((Particle.Flags & STATE_Particle_Freeze) == 0)
{
#define END_UPDATE_LOOP는 아래처럼 치환
}
CurrentOffset= Offset;
}
}
결론적으로 저 사이에 우리의 로직이 들어가는 것입니다.
Indicies와 Stride를 이용해서 위치를 잡아내고 해당 위치의 Particle을 로직 연산의 대상으로 삼게 되고
END LOOP에서는 오프셋을 바꿔주게 됩니다.
6. 트러블 슈팅
아래 영상은 눈 파티클이 생성하자 마자 사라지고있는 현상입니다.
이 문제를 해결하기위해서 생명주기 관련 변수를 전부 찾아서 디버깅 해보았습니다.
그러나 생명주기 관련 변수 문제는 아니었습니다.
결론적으로 찾은 문제점은 바로 KillParticle코드였습니다.
void FParticleEmitterInstance::KillParticle(int32 Index)
{
// 1) 유효성 체크
if(!(Index >= 0 && Index < ActiveParticles)) return;
// 2) 마지막 활성 슬롯 인덱스 가져오기
int32 LastArrayIndex = ActiveParticles - 1;
uint16 LastSlotIndex = ParticleIndices[LastArrayIndex];
// 3) 제거할 위치에 마지막 슬롯 복사
// → O(1)로 중간 삭제
ParticleIndices[Index] = LastSlotIndex;
// 4) 활성 개수 하나 줄이기
--ActiveParticles;
}
이 코드는 문제가 됐던 KillParticle코드입니다.
기본적으로 삭제하려고 하는 곳을 마지막 인덱스로 덮어 씌우고 있는 구조입니다.
어차피 인덱스 값들은 순서가 중요하지 않기 때문에 새로운 값으로 밀어버리면 삭제가 되는것입니다.
그러나 이 코드는 큰 문제가 있습니다.
바로 삭제를 하게 된 곳의 인덱스이외에도 실제 렌더 데이터도 초기화 시켜주어야 합니다.
또한 마지막 인덱스의 데이터 역시 비워줘야 합니다.
그리고 인덱스뿐만 아니라 데이터도 새롭게 바꾸어 주어야 합니다.
그렇지 않으면 새로운 파티클이 생성되어도 이미 죽은 파티클의 정보를 그대로 가져오게 되어서
생성되자마자 사라지는 현상이 발생하는 것입니다.
결론적으로는 아래 코드가 정상 작동하는 코드입니다.
삭제할 곳의 데이터를 가장 마지막 파티클의 데이터로 밀어주고 있고
밀어준 곳의 데이터는 0으로 전부 초기화 해주고 있습니다.
void FParticleEmitterInstance::KillParticle(int32 Index)
{
if (Index < 0 || Index >= ActiveParticles)
return;
int32 LastArrayIndex = ActiveParticles - 1;
uint16 LastSlotIndex = ParticleIndices[LastArrayIndex];
uint16 CurrSlotIndex = ParticleIndices[Index];
// 데이터 블록에서 마지막 슬롯 데이터를
// 삭제할 슬롯 위치(CurrSlotIndex)로 통째로 복사
if (CurrSlotIndex != LastSlotIndex)
{
uint8* Src = ParticleData + LastSlotIndex * ParticleStride;
uint8* Dst = ParticleData + CurrSlotIndex * ParticleStride;
memcpy(Dst, Src, ParticleStride);
}
// 남은 "마지막" 슬롯은 0으로 초기화
{
uint8* DeadPtr = ParticleData + LastSlotIndex * ParticleStride;
memset(DeadPtr, 0, ParticleStride);
}
// 4) 활성 개수 감소
--ActiveParticles;
}
해결
전체적으로 C스타일의 메모리 관리를 사용해서 이해하기 정말 난해한 부분이었다.
메모리, 포인터에 대한 이해가 충분하지 않으면 정말 쉽지 않은 파트였고, 앞으로도 더 열심히 공부해야겠다는 큰 동기부여가 됐다.
'DirectX11' 카테고리의 다른 글
[DirectX11] 게임테크랩 자체 엔진 게임잼 - 토끼굴의 비밀 (0) | 2025.06.13 |
---|---|
[DirectX11] DOF (Depth Of Field) 피사계 심도 (0) | 2025.05.28 |
[DirextX11] FBX Animation, Unreal Engine의 Animation 구조, Notify (0) | 2025.05.16 |
[DirectX11] CPU Skinning, GPU Skinning 비교 (0) | 2025.05.16 |
[DirectX11] FBX, CPU Skinning (0) | 2025.05.09 |