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 기반 파티클 시스템 구현 흐름

  1. UIView를 snapshot으로 이미지 변환
  2. 이미지를 파티클 단위로 분할, 각 파티클 정보를 구조체 배열에 저장
  3. CPU 에서 Translate 계산
  4. 모든 파티클 정보를 MTLBuffer에 담아 한 번에 GPU로 전송
  5. Vertex/Fragment Shader에서 각 파티클을 그리기만 함 (Draw Call 1회)

주요 코드

Create Particle

이전에 Core Animation을 이용했던 방식과 유사하게 image를 tile size에 따라서 분해한다. 다른 부분이 있다면 UIKit의 좌표계와 Metal에서 사용하는 좌표계, 그리고 Texture의 좌표계가 모두 다르다는 것이다. 그래서 좌표계를 변환해주는 코드들이 추가되었다.

Projection Matrix를 사용하면 좌표계 변환을 일일히 해줄 필요가 없긴 하다.

Metal 좌표계
(Normalized Device Coordinate)
Texture 좌표계
Metal Coordinate System Texture Coordinate
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
CoreAnimation Time Profiler CoreAnimation Time Profiler
0.3초의 hang (freezing) 발생 hang 미발생 및 평균 40~70% 의 CPU 사용률

Animation Hitches 비교 결과 (Particle 30,000개)

Core Animation Metal
CoreAnimation Animation Hitches Metal Animation Hitches
60FPS (16.67ms)에 못미치는 성능 60FPS 충족

Stress Test View Controller 실행 영상

Stress Test


Metal을 사용하면서 어려웠던 점

1. 좌표계 매핑(UIKit ↔ Metal NDC ↔ Texture)

2. 파이프라인/셰이더 구조체 Alignment / Size 문제

3. Shader 디버깅 난이도

Computer Graphics 수업을 들어면서 OpenGL을 사용했을 때도 shader 디버깅은 정말 정말 힘들었던 기억이 있는데, Shader는 아무런 말도 없이 화면에 아무것도 안나오는 경우가 많아서 디버깅이 정말 힘든 것 같다.


결론


글을 작성하고 난 이후에 position 계산 로직을 CPU -> GPU로 옮겼는데 CPU 사용률이 10 ~ 20% 로 더 최적화되었다. 하지만 여전히 particle을 CPU에서 생성하는 탓에 초기에 CPU spike가 발생한다. 100만개의 particle도 particle 생성 이후에는 무리 없이 동작하지만 다음에는 particle 생성도 GPU에 위임하는 방법을 찾아봐야 할 것 같다.

After Optimization


스크린샷

screenshot

전체 코드: Github


References