개요
전투 중심의 액션 게임 프로젝트로, 전투의 패턴 구성과 연출 완성도, 화려한 이펙트를 중점으로 구현했습니다.
개발기간
25/12/10 ~ 26/2/23 (2.5개월)
파트
이펙트, 보스(새크리파이스)
개발 환경
개발 언어 및 도구
1. EffectContainer 기반 이펙트 구조
1-1. 전체 구조
이펙트는 EffectContainer라는 상위 객체가 여러 개의 Node 객체를 소유하는 하나의 프리팹 형태로 구성하였다.
EffectContainer는 내부적으로 vector 컨테이너에 여러 Node를 저장하며, 매 프레임 모든 Node를 순회하면서 각 Node의 상태를 갱신한다. 이 구조를 사용한 이유는 하나의 이펙트를 구성하는 요소가 반드시 하나의 표현 방식으로 끝나지 않기 때문이다. 실제 연출에서는 파티클만 필요한 경우도 있지만, 메쉬와 파티클을 함께 사용하거나, 여기에 트레일까지 조합해야 더 다양한 표현이 가능하다.
EffectContainer는 멤버 변수인 m_fDuration과 m_IsLoop 플래그를 통해 이펙트의 전체 진행 시간과 반복 여부를 제어하도록 하였다. 이를 통해 특정 시간 동안만 재생되는 일회성 이펙트와, 조건이 만족될 때까지 반복되는 루프형 이펙트를 동일한 구조 안에서 관리할 수 있도록 했다.
2. Node 구성
Node는 크게 다음 세 가지 타입으로 구성하였다.
- Particle Node
- Mesh Node
- Trail Node
각 Node는 표현 방식과 갱신 방식이 다르기 때문에, 공통된 상위 개념 아래에 두되 실제 동작은 타입에 맞게 분리하였다.
3. Particle Node
3-1. 구성 목적
Particle Node는 다수의 파티클을 효율적으로 생성하고 갱신하며, 이를 화면에 출력하기 위한 기능을 담당한다.
단순 CPU 갱신 방식은 파티클 수가 많아질수록 비용이 커지기 때문에, 생성 이후의 주요 갱신 연산은 GPU의 Compute Shader를 사용하도록 구성하였다.
Unity의 Particle System을 참고해서 구현하였다.
전체 흐름은 크게 두 단계로 나뉜다.
- Particle 생성 및 업데이트 단계
- 렌더링 단계
3-2. Particle 생성 단계
Particle 생성 단계에서는 해당 프레임에 생성할 파티클 개수와 각 파티클의 초기 속성을 결정한다.
생성 방식은 크게 두 가지를 지원하도록 하였다.
- Burst 생성 방식
특정 시점에 많은 수의 파티클을 한 번에 생성하는 방식이다. 폭발, 충격파, 히트 이펙트처럼 순간적인 연출에 적합하다. - 초당 생성 방식(Spawn Per Sec)
일정 시간 동안 연속적으로 파티클을 생성하는 방식이다. 화염, 연기처럼 지속적인 연출에 적합하다.
생성 시점에는 각 파티클의 초기 데이터로 다음과 같은 값들을 설정한다.
- LifeTime
- Velocity
- Position
- Size
- Color
- Noise, FrameIndex 등 업데이트와 렌더링에 필요한 추가 정보
이렇게 생성된 초기 데이터는 구조체 형태로 정리한 뒤 DeviceContext의 Map, Unmap 메서드를 사용하여 GPU 버퍼에 업로드한다. 또한 파티클 연산에 필요한 시간 정보(DeltaTime), 생성 개수 등은 상수 버퍼를 통해 GPU에 전달되도록 구성하였다.
3-3. Compute Shader를 이용한 갱신
업로드된 Particle 데이터는 Compute Shader에서 병렬적으로 갱신되도록 구성하였다.
CPU에서 모든 파티클을 순회하며 위치나 속도를 갱신하는 방식은 파티클 수가 많아질수록 부담이 커진다. 반면 Compute Shader를 이용하면 각 파티클의 갱신을 GPU 스레드 단위로 병렬 처리할 수 있기 때문에, 대량의 파티클을 보다 효율적으로 처리할 수 있다.
갱신 단계에서는 주로 다음과 같은 연산이 수행된다.
- 수명 감소
- 속도 반영
- 위치 갱신
- 크기 및 색상 변화
- 생존 여부 판정
위와 같은 연산들은 Compute Shader 함수를 작성하여 기능별로 분리하였다. 이를 통해 프레임마다 파티클 상태를 일괄적으로 갱신할 수 있도록 하였다.
3-4. Local Space / World Space 분리
파티클 연산 공간은 Local Space와 World Space로 분리하여 처리하였다.
- Local Space
파티클이 부모 행렬의 영향을 받도록 구성하였다.
캐릭터 손에서 뿜어져 나오는 마법 이펙트나 무기에 종속된 잔상처럼, 부모 오브젝트의 움직임을 따라가야 하는 경우에 적합하다. - World Space
파티클이 생성된 이후 부모의 변환과 무관하게 독립적으로 갱신되도록 하였다.
예를 들어 어떤 오브젝트가 빠르게 이동하면서 연기를 남기고 지나갈 때, 이미 생성된 연기가 계속 부모를 따라다니면 부자연스럽다. 이런 경우에는 월드 공간에서 독립적으로 갱신하는 것이 적절하다.
3-5. Instance Data 생성과 렌더링
Compute Shader에서 연산이 완료된 뒤에는, 갱신된 Particle 데이터를 바탕으로 렌더링에 필요한 Instance Data를 생성한다.
Instance Data에는 주로 다음과 같은 정보가 포함된다.
- 파티클 위치
- 방향 축 정보
- 크기
- 색상
- 기타 셰이더에서 필요한 값
이후 렌더링 단계에서는 Point Instancing 방식을 사용하여 다수의 파티클을 한 번의 Draw Call로 출력하도록 구성하였다.
이 방식의 장점은 각 파티클마다 개별 드로우 콜을 발생시키지 않아도 된다는 점이다. 파티클 수가 수백, 수천 개로 늘어나더라도 드로우 콜은 딱 한번 호출되기 때문에 굉장히 효율적으로 많은 수의 Particle을 출력할 수 있다.
4. Mesh 노드
4-1. 역할
Mesh Node는 특정 형태의 메쉬에 텍스처를 적용하고, 셰이더 매개변수 조합에 따라 다양한 표현이 가능하도록 구성하였다.
Particle은 대체로 점, 빌보드, 단순 스프라이트 기반 표현에 적합하지만, 검기 이펙트, 마법진, 충격파 면, 구체형 오라처럼 명확한 형상이 필요한 경우에는 메쉬를 사용하여 더 공간감 있게 표현 할 수 있다.
4-2. 데이터 관리 방식
효과 표현에 필요한 값들은 기능별 구조체로 분리하여 관리하였다.
이렇게 한 이유는 메쉬 기반 이펙트에서 사용하는 데이터가 단순 색상 하나로 끝나지 않고, Distortion 강도, UV 흐름, 마스크 사용 여부, 발광 값, 후처리용 파라미터 등 다양하게 늘어날 수 있기 때문이다.
또한 이 값들은 단순 고정값으로만 두지 않고, 매 프레임 진행도와 TimeDelta에 따라 갱신될 수 있도록 구성하였다.
이를 통해 시간이 지남에 따라 색이 변하거나, UV가 흐르거나, 특정 구간에서만 강하게 발광하는 등 시간 기반 연출을 만들 수 있도록 하였다.
4-3. 후처리 연계
일부 메쉬 표현은 단순히 메쉬를 그리는 것만으로 끝나지 않고, 후처리 연산이 필요하다.
이 경우 전용 렌더 타겟에 관련 정보를 기록하도록 구성하였다.
예를 들어 Distortion, Bloom에 필요한 값은 일반 색상 출력과 분리하여 별도 타겟에 기록한 뒤, 이후 후처리 셰이더 단계에서 해당 렌더 타겟을 샘플링하여 추가 연산을 수행하도록 하였다.
5. Trail Node
5-1. 목적
Trail Node는 오브젝트의 이동에 따라 뒤에 남는 궤적을 표현하기 위해 구성하였다.
잔상이나 검격 궤적, 빠르게 움직이는 물체의 흔적과 같은 연출은 단순 스프라이트를 반복 생성하는 방식보다, 이동 경로를 따라 이어지는 형태로 표현하는 편이 훨씬 자연스럽다. 이를 위해 정점 버퍼를 동적으로 갱신하는 구조를 사용하였다.
5-2. 정점 기록 방식
매 프레임 현재 오브젝트의 위치를 확인하고, 이전에 기록된 정점과의 거리가 일정 값 이상일 경우 현재 위치를 새로운 정점으로 기록하도록 하였다.
이 거리 조건을 두는 이유는 매 프레임 무조건 정점을 추가하면 지나치게 많은 정점이 쌓여 비효율적일 뿐 아니라, 실제 궤적 형태도 과하게 촘촘해져 관리가 어려워지기 때문이다. 따라서 일정 거리 이상 이동했을 때만 새 정점을 추가하여, 궤적 품질과 성능 사이의 균형을 맞추도록 하였다.
5-3. Geometry Shader를 이용한 Trail 생성
기록된 정점들은 순차적으로 순회하며, 연속한 두 정점을 기준으로 Geometry Shader에서 도형을 생성하도록 하였다.
즉, CPU에서는 궤적의 기준점만 관리하고, 실제 화면에 보이는 Trail의 폭을 가진 면은 Geometry Shader에서 확장 생성하는 구조이다. 이 방식을 사용하면 Trail의 두께, 방향, 보간 방식 등을 셰이더 단계에서 보다 유연하게 처리할 수 있다.
5-4. 시간에 따른 변화
각 Trail 정점에는 위치 정보 외에도 LifeTime과 Size 값을 함께 저장하였다.
이를 통해 시간이 지남에 따라 다음과 같은 변화가 가능하도록 하였다.
- 점점 얇아지는 궤적
- 점점 투명해지는 궤적
- 시작점과 끝점의 두께 차이
- 시간에 따른 색상 변화
단순히 선을 남기는 것이 아니라, 시간이 흐르며 자연스럽게 사라지는 연출을 만들기 위해 필요한 데이터라고 볼 수 있다.
6. 알파 이펙트 정렬 문제와 Weighted OIT
6-1. 문제점
알파 값을 가지는 이펙트를 다수 출력할 때 가장 큰 문제 중 하나는 정렬이다.
일반적인 투명 오브젝트 렌더링에서는 보통 View Z 기준으로 정렬한 뒤 뒤에서 앞으로 그리는 방식이 사용된다. 하지만 이 방식은 다음과 같은 문제가 있다.
- 이펙트 개수가 많을수록 정렬 비용이 커진다.
- 오브젝트 단위 정렬만으로는 픽셀 수준의 정확한 결과를 얻기 어렵다.
- 복잡하게 겹치는 반투명 이펙트에서는 정렬 오차가 쉽게 발생한다.
특히 파티클, 트레일, 반투명 메쉬가 동시에 많이 등장하는 상황에서는 CPU 정렬 비용과 시각적 오차가 모두 부담이 된다.
6-2. Weighted OIT 적용
이 문제를 보완하기 위해 Accumulation 렌더 타겟과 Revealage 렌더 타겟을 사용하는 Weighted OIT(Order Independent Transparency) 방식을 적용하였다.
이 방식의 핵심은 반투명 픽셀들을 엄밀하게 정렬해서 그리는 대신, 각 픽셀의 기여도를 누적하고 마지막 후처리 단계에서 조합하는 것이다.
Accumulation 타겟
Accumulation 타겟에는 각 픽셀의 색상을 알파 값과 View Z 기반 가중치를 반영하여 누적한다.
즉, 최종 색을 직접 쓰는 것이 아니라 “이 픽셀이 얼마만큼 기여하는가”를 누적하는 형태다.
Revealage 타겟
Revealage 타겟은 각 픽셀의 투명도가 누적 곱되도록 하여 배경이 얼마나 드러나야 하는지를 계산하는 용도로 사용하였다.
이를 통해 여러 반투명 레이어가 겹쳤을 때 배경 노출 정도를 근사적으로 계산할 수 있다.
6-3. 최종 합성
이후 후처리 셰이더 단계에서 두 렌더 타겟을 샘플링하여 최종 픽셀 색을 계산하도록 구현하였다.
이 방식은 완전한 물리적 정답을 보장하는 방식은 아니지만, 실시간 이펙트 시스템에서는 충분히 자연스러운 결과를 비교적 낮은 비용으로 얻을 수 있다는 장점이 있다. 특히 많은 알파 이펙트가 동시에 출력되는 상황에서 정렬 비용과 정렬 오차를 줄이는 데 효과적이었다.
7. 애니메이션 연동 이펙트
오브젝트에 부착되는 이펙트는 애니메이션 진행도에 따라 출력할 수 있도록 구현하였다.
예를 들어 공격 모션 중 특정 프레임에서만 검격 이펙트가 나오거나, 캐릭터가 손을 뻗는 순간에만 마법 이펙트가 발생해야 하는 경우가 있다. 이런 연출은 단순 시간 기준으로 제어하는 것보다, 애니메이션 진행도와 직접 연동하는 편이 훨씬 정확하다.
이때 이펙트가 애니메이션을 재생하는 오브젝트를 부모로 가지는지 여부에 따라 위치 계산 방식이 달라지도록 하였다.
- 오브젝트에 종속적인 이펙트
위치와 회전에 부모 행렬을 반영하여 월드 공간에 배치하였다.
따라서 손, 무기, 특정 본 위치에 정확히 붙어서 움직이는 연출이 가능하다. - 독립적으로 출력되는 이펙트
부모의 영향을 받지 않도록 처리하였다.
예를 들어 발동 시점에 한 번 생성된 뒤 그 자리에 남아야 하는 연출은 부모를 계속 따라가면 안 되기 때문이다.
이 구조를 통해 “부착형 이펙트”와 “독립형 이펙트”를 동일한 시스템 안에서 처리할 수 있도록 하였다.