on
Metal을 이용한 Particle Animation
Metal을 이용한 Particle Animation
개요
이 글에서는 iOS에서 대량의 파티클 애니메이션을 효율적으로 구현하는 실제 경험을 공유합니다.
Core Animation으로 시작해 Metal로 전환한 이유, 성능 비교, 자동화 Stress Test 경험을 공유합니다.
Core Animation 기반 파티클의 한계
이전에는 Core Animation(CALayer 기반)으로 파티클 애니메이션을 구현했습니다. 각 파티클을 CALayer로 만들고, CAAnimation으로 이동/알파/스케일 애니메이션을 적용하는 방식이었습니다.
결과물은 만족스러웠으나, Particle의 개수가 많아질 수록 FPS가 급락하였습니다. 그렇다고 Particle의 개수를 줄이면 Particle의 크기를 키울 수 밖에 없어서 원하는 결과와는 거리가 멀어졌습니다.
그래서 이번에는 Core Animation 대신 Metal을 사용해서 Particle Animation을 구현하고자 합니다.
Metal 도입 배경
더 많은 파티클을 끊김 없이 부드럽게 애니메이션하고 싶어서 Metal 기반으로 전환을 시도했습니다.
- 도입 배경:
- 기존 방식으로 성능 한계 체감
- Metal을 통해 얼마나 성능이 개선되는지 직접 검증하고 싶었음
- Metal의 장점:
- GPU의 병렬 처리 능력을 직접 활용
- Draw Call 한 번으로 수만~수십만 개 파티클을 한 번에 렌더링
- 뷰 계층 오버헤드가 없음
- 복잡한 커스텀 효과, 실시간 변화에 유리
Metal 기반 파티클 시스템 구현 흐름
- UIView를 snapshot으로 이미지 변환
- 이미지를 파티클 단위로 분할, 각 파티클 정보를 구조체 배열에 저장
- CPU 에서 Translate 계산
- 모든 파티클 정보를 MTLBuffer에 담아 한 번에 GPU로 전송
- Vertex/Fragment Shader에서 각 파티클을 그리기만 함 (Draw Call 1회)
주요 코드
Create Particle
이전에 Core Animation을 이용했던 방식과 유사하게 image를 tile size에 따라서 분해한다. 다른 부분이 있다면 UIKit의 좌표계와 Metal에서 사용하는 좌표계, 그리고 Texture의 좌표계가 모두 다르다는 것이다. 그래서 좌표계를 변환해주는 코드들이 추가되었다.
Projection Matrix를 사용하면 좌표계 변환을 일일히 해줄 필요가 없긴 하다.
Metal 좌표계 (Normalized Device Coordinate) |
Texture 좌표계 |
---|---|
![]() |
![]() |
var particles: [Particle] = []
for x in 0 ..< tilesPerRow {
for y in 0 ..< tilesPerColumn {
// Metal에서 Texture는 0...1 의 값
let textureX = Float(x) * tileSize / Float(imageWidth)
let textureY = Float(y) * tileSize / Float(imageHeight)
// 화면상의 실제 window 기준 좌표
let screenX = CGFloat(x) * CGFloat(tileSize) / scale + offsetX
let screenY = CGFloat(y) * CGFloat(tileSize) / scale + offsetY
// Metal은 Y축이 위 -> 아래로 1 → -1 임
let normalizedX = Float(screenX / windowWidth) * 2 - 1
let normalizedY = 1 - Float(screenY / windowHeight) * 2
// Particle이 이동할 위치
// 전체적으로 우상단 방향으로 이동하지만 모두가 이동하지는 않게 적절한 값으로..
let dx = Float.random(in: 0.05 ... 0.1)
let dy = Float.random(in: -0.01 ... 0.03)
let particle = Particle(
position: simd_float2(normalizedX, normalizedY),
velocity: simd_float2(dx, dy),
life: 1.0,
textureCoord: simd_float2(textureX, textureY),
)
particles.append(particle)
}
}
Create Texture
Particle로 분해할 UIView를 texture로 만든 후 fragment shader에서 색상 값으로 활용
private func uiImage(from view: UIView, bounds: CGRect) -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let snapshotImage = renderer.image { context in
view.layer.render(in: context.cgContext)
}
return snapshotImage
}
private func createTexture(from image: CGImage, bounds: CGRect) -> MTLTexture? {
guard let device else {
return nil
}
let textureLoader = MTKTextureLoader(device: device)
do {
return try textureLoader.newTexture(cgImage: image)
} catch {
return nil
}
}
Stress Test 자동화와 성능 비교 결과
파티클 개수를 자동으로 늘려가며 FPS 임계값(50 FPS)을 측정하는 Stress Test View Controller를 별도로 만들어 실기기(iPhone 16 Pro)에서 반복 측정했습니다.
방식 | FPS 50 이하 임계 | 주관적 체감 |
---|---|---|
Core Animation | 7,000개 부근 | 7,000개 이상 버벅임 |
Metal | 16만 7천개 부근 | 10만 개도 부드러움 |
Time Profiler 비교 결과 (Particle 30,000개)
Core Animation | Metal |
---|---|
![]() |
![]() |
0.3초의 hang (freezing) 발생 | hang 미발생 및 평균 40~70% 의 CPU 사용률 |
Animation Hitches 비교 결과 (Particle 30,000개)
Core Animation | Metal |
---|---|
![]() |
![]() |
60FPS (16.67ms)에 못미치는 성능 | 60FPS 충족 |
Stress Test View Controller 실행 영상
Metal을 사용하면서 어려웠던 점
1. 좌표계 매핑(UIKit ↔ Metal NDC ↔ Texture)
- Metal은 화면 좌표(NDC, -1~1), UIKit은 (0,0)~(width,height), Texture 좌표는 (0~1) 등 각기 다른 좌표계를 사용한다.
- 특히 Y축 방향이 UIKit은 “상단이 0, 아래로 증가”하고, Metal NDC는 “중앙이 0, 위가 +1, 아래가 -1”, Texture는 “(0,0) ~ (1, 1)” 등 혼란이 많았다.
- 좌표계를 잘못 매핑하면 파티클이 전혀 엉뚱한 위치에 보이거나, 화면에 아무것도 안 나오는 문제가 발생했다.
- 좌표 변환 로직을 여러 번 바꿔야 했고, 실제로 Texture 좌표가 뒤집히거나, 파티클이 화면 밖으로 벗어나는 등 시행착오를 많이 겪었다.
2. 파이프라인/셰이더 구조체 Alignment / Size 문제
- Metal에서 Swift의 구조체(예: Particle)와 Metal 셰이더의 구조체가 정확히 일치해야 한다.
- float, simd_float2, Float, float2 등 타입이 미세하게 다르거나, Swift에서 구조체에 추가 필드가 있으면 메모리구조가 달라져서 셰이더에서 데이터가 깨짐.
- 시행착오 끝에, 구조체 정의는 Swift/Metal 모두에서 멤버와 순서를 맞춰 해결해주었지만, 원인을 알기가 너무너무 힘들었다. 조금만 어긋나도 Silent Error(에러 메시지 없이 동작 안 함) 가 많아 디버깅에 시간 소요가 컸다.
3. Shader 디버깅 난이도
- Metal Shader 개발에서 가장 난이도가 높은 부분 중 하나가 바로 디버깅이다.
- 컴파일 에러는 금방 찾지만, 런타임에는 아무것도 안 그려지거나, 값이 이상하게 나와도 에러 메시지가 거의 없다.
- 그나마 Xcode의 Frame Capture를 통해서 그 프레임에서 셰이더 입출력을 확인할 수 있어서 그나마 다행이었다.
Computer Graphics 수업을 들어면서 OpenGL을 사용했을 때도 shader 디버깅은 정말 정말 힘들었던 기억이 있는데, Shader는 아무런 말도 없이 화면에 아무것도 안나오는 경우가 많아서 디버깅이 정말 힘든 것 같다.
결론
- 적은 파티클/간단한 효과는 Core Animation으로 충분
- 수만 개 이상 대량 파티클, 고성능 이펙트에는 Metal을 적극 추천
- Metal같은 Graphics Library는 학습 난이도는 높지만 효과는 정말 👍
글을 작성하고 난 이후에 position 계산 로직을 CPU -> GPU로 옮겼는데 CPU 사용률이 10 ~ 20% 로 더 최적화되었다. 하지만 여전히 particle을 CPU에서 생성하는 탓에 초기에 CPU spike가 발생한다. 100만개의 particle도 particle 생성 이후에는 무리 없이 동작하지만 다음에는 particle 생성도 GPU에 위임하는 방법을 찾아봐야 할 것 같다.
스크린샷
전체 코드: Github