iOS에서 Metal을 사용한 카메라 렌더링 (AVFoundation + MetalKit)

발행: (2025년 12월 17일 오전 05:46 GMT+9)
9 min read
원문: Dev.to

Source: Dev.to

왜 이 접근 방식을 사용하나요?

  • 셰이더를 이용한 후처리가 필요합니다.
  • AR / 컴퓨터 비전 / 머신러닝을 통합하고 있습니다.
  • 최대의 제어와 성능을 원합니다.

프로젝트 아키텍처

최종 프로젝트는 다음 네 가지 클래스를 필요로 합니다:

클래스책임
CameraCapture비디오를 캡처합니다.
MetalRenderer픽셀 버퍼를 Metal 텍스처로 변환하고 그립니다.
CameraMetalViewController모든 것을 조정합니다.
CameraCaptureDelegate클래스를 결합하지 않고 버퍼를 전달하기 위한 프로토콜.

1️⃣ 프로토콜 CameraCaptureDelegate

protocol CameraCaptureDelegate: AnyObject {
    func cameraCapture(_ capture: CameraCapture,
                       didOutput pixelBuffer: CVPixelBuffer)
}

2️⃣ 클래스 CameraCapture

기본 선언

class CameraCapture {
    weak var delegate: CameraCaptureDelegate?
}

속성 및 큐

class CameraCapture {
    weak var delegate: CameraCaptureDelegate?
    private let session = AVCaptureSession()
    private let output = AVCaptureVideoDataOutput()
    private let sessionQueue = DispatchQueue(label: "camera.capture.session.queue")
    private let bufferQueue  = DispatchQueue(label: "camera.capture.buffer.queue")
    
    init() {
        // Configuramos la sesión en una cola de fondo
        sessionQueue.async { [weak self] in
            self?.configureSession()
        }
    }
    
    // …
}

세션 구성

private func configureSession() {
    session.beginConfiguration()                     // 1
    session.sessionPreset = .high                    // 2
    
    // 3️⃣ Seleccionamos la cámara por defecto (normalmente la trasera)
    guard let device = AVCaptureDevice.default(for: .video),
          let input = try? AVCaptureDeviceInput(device: device),
          session.canAddInput(input) else {
        fatalError("Cannot create camera input")
    }
    
    session.addInput(input)                          // 4
    
    // 5️⃣ Formato del buffer de píxeles (NV12, sin conversión a RGB en CPU)
    output.videoSettings = [
        kCVPixelBufferPixelFormatTypeKey as String:
        kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
    ]
    
    output.alwaysDiscardsLateVideoFrames = true    // 6
    output.setSampleBufferDelegate(self, queue: bufferQueue) // 7
    
    // 8️⃣ Añadimos la salida a la sesión
    guard session.canAddOutput(output) else {
        fatalError("Cannot add video output")
    }
    session.addOutput(output)
    
    session.commitConfiguration()                  // 9
}

단계별 설명

  1. beginConfiguration: AVCaptureSession에 변경을 시작한다는 것을 알리며, 세션은 우리가 끝낼 때까지 대기한다.
  2. sessionPreset = .high: 고화질(디바이스에 따라 720p‑1080p) 설정.
  3. 카메라 선택: 기본 카메라(보통 후면)를 사용한다. 입력을 만들 수 없으면 앱이 크래시한다(실제 서비스에서는 오류를 더 친절하게 처리해야 함).
  4. 카메라를 입력으로 추가.
  5. 버퍼 포맷: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange(NV12) 를 사용하면 CPU에서 색 변환을 하지 않는다.
  6. 늦은 프레임 버리기: 앱이 제때 처리하지 못하면 가장 오래된 프레임을 버린다.
  7. 출력 델리게이트: 각 프레임이 bufferQueue에서 captureOutput(_:didOutput:from:) 로 전달된다.
  8. 출력 추가 여부 확인 및 추가.
  9. commitConfiguration: 모든 변경을 적용한다.

AVCaptureVideoDataOutputSampleBufferDelegate 준수

class CameraCapture: NSObject {
    // … (propiedades y métodos anteriores)
}

extension CameraCapture: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput,
                       didOutput sampleBuffer: CMSampleBuffer,
                       from connection: AVCaptureConnection) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
            return
        }
        delegate?.cameraCapture(self, didOutput: pixelBuffer)
    }
}
  • CMSampleBuffer 은 비디오 프레임(옵션으로 오디오, 타임스탬프, 메타데이터 포함)을 담고 있다.
  • CMSampleBufferGetImageBufferCVPixelBuffer 를 추출해 델리게이트에 전달한다.

세션 제어 (start / stop)

func start() {
    sessionQueue.async { [weak self] in
        self?.session.startRunning()
    }
}

func stop() {
    sessionQueue.async { [weak self] in
        self?.session.stopRunning()
    }
}

메서드는 sessionQueue 에서 실행되어 메인 스레드가 차단되지 않도록 한다.

3️⃣ MetalRenderer 클래스

이 클래스는 CVPixelBuffer를 Metal 텍스처로 변환하고 이를 MTKView에 그리는 역할을 합니다.

초기 골격

import Metal
import MetalKit
import CoreVideo

class MetalRenderer {
    private let device: MTLDevice                // 1
    private let queue: MTLCommandQueue           // 2
    private let pipeline: MTLRenderPipelineState // 3
    private var textureCache: CVMetalTextureCache! // 4
    private var yTexture: MTLTexture?            // 5 (Y plane)
    private var cbcrTexture: MTLTexture?         // 6 (CbCr plane)
    
    init(mtkView: MTKView) {
        // 1️⃣ Device y command queue
        guard let device = MTLCreateSystemDefaultDevice(),
              let queue = device.makeCommandQueue() else {
            fatalError("Metal not supported on this device")
        }
        self.device = device
        self.queue = queue
        
        // 2️⃣ Configuramos el MTKView
        mtkView.device = device
        mtkView.framebufferOnly = false   // Necesario para renderizar texturas externas
        
        // 3️⃣ Creamos la pipeline (vertex + fragment shaders)
        let library = device.makeDefaultLibrary()!
        let vertexFn = library.makeFunction(name: "vertex_passthrough")!
        let fragmentFn = library.makeFunction(name: "fragment_ycbcr")!
        
        let descriptor = MTLRenderPipelineDescriptor()
        descriptor.vertexFunction = vertexFn
        descriptor.fragmentFunction = fragmentFn
        descriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat
        
        do {
            pipeline = try device.makeRenderPipelineState(descriptor: descriptor)
        } catch {
            fatalError("Unable to create pipeline state: \\(error)")
        }
        
        // 4️⃣ Creamos la caché de texturas Metal  CoreVideo
        CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &textureCache)
    }
    
    // MARK: - Public API
    
    /// Convierte un `CVPixelBuffer` NV12 en dos texturas Metal (Y y CbCr) y las dibuja.
    func draw(pixelBuffer: CVPixelBuffer, in view: MTKView) {
        // 1️⃣ Creamos texturas Metal a partir del pixel buffer
        createTextures(from: pixelBuffer)
        
        // 2️⃣ Renderizamos
        guard let drawable = view.currentDrawable,
              let descriptor = view.currentRenderPassDescriptor else { return }
        
        let commandBuffer = queue.makeCommandBuffer()!
        let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)!
        encoder.setRenderPipelineState(pipeline)
        
        // Pasamos las texturas al fragment shader
        if let y = yTexture, let cbcr = cbcrTexture {
            encoder.setFragmentTexture(y,   index: 0)
            encoder.setFragmentTexture(cbcr, index: 1)
        }
        
        // Dibujamos un triángulo completo (full‑screen quad)
        encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
        encoder.endEncoding()
        
        commandBuffer.present(drawable)
        commandBuffer.commit()
    }
    
    // MARK: - Private helpers
    
    private func createTextures(from pixelBuffer: CVPixelBuffer) {
        // Y plane (luma)
        var yTexRef: CVMetalTexture?
        CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                                  textureCache,
                                                  pixelBuffer,
                                                  nil,
                                                  .r8Unorm,
                                                  CVPixelBufferGetWidthOfPlane(pixelBuffer, 0),
                                                  CVPixelBufferGetHeightOfPlane(pixelBuffer, 0),
                                                  0,
                                                  &yTexRef)
        yTexture = yTexRef.flatMap { CVMetalTextureGetTexture($0) }
        
        // CbCr plane (chroma)

Source:

        var cbcrTexRef: CVMetalTexture?
        CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                                  textureCache,
                                                  pixelBuffer,
                                                  nil,
                                                  .rg8Unorm,
                                                  CVPixelBufferGetWidthOfPlane(pixelBuffer, 1),
                                                  CVPixelBufferGetHeightOfPlane(pixelBuffer, 1),
                                                  1,
                                                  &cbcrTexRef)
        cbcrTexture = cbcrTexRef.flatMap { CVMetalTextureGetTexture($0) }
    }
}

핵심 포인트

  1. devicequeue: Metal 디바이스와 커맨드 큐.
  2. MTKView: device를 할당하고 framebufferOnly를 비활성화합니다. 외부 텍스처를 사용할 것이기 때문입니다.
  3. Pipeline: 아래 쉐이더 섹션에서 보는 두 개의 쉐이더가 필요합니다.
  4. CVMetalTextureCache: 메모리 복사 없이 CVPixelBuffer에서 직접 Metal 텍스처를 생성할 수 있게 해줍니다.
  5. draw(pixelBuffer:in:): NV12 버퍼를 두 개의 텍스처(YCbCr)로 변환하고 full‑screen quad를 그립니다.

4️⃣ 셰이더 (Metal Shading Language)

Shaders.metal 파일로 저장하고 타깃에 추가하세요.

#include <metal_stdlib>
using namespace metal;

// Vertex data para un triángulo que cubre toda la pantalla
struct VertexOut {
    float4 position [[position]];
    float2 texCoord;
};

vertex VertexOut vertex_passthrough(uint vertexID [[vertex_id]]) {
    // Triángulo de dos triángulos (6 vértices) que cubre la pantalla
    const float2 positions[6] = {
        float2(-1.0, -1.0), // bottom‑left
        float2( 1.0, -1.0), // bottom‑right
        float2(-1.0,  1.0), // top‑left
        
        float2( 1.0, -1.0), // bottom‑right
        float2( 1.0,  1.0), // top‑right
        float2(-1.0,  1.0)  // top‑left
    };
    
    const float2 texCoords[6] = {
        float2(0.0, 1.0),
        float2(1.0, 1.0),
        float2(0.0, 0.0),
        
        float2(1.0, 1.0),
        float2(1.0, 0.0),
        float2(0.0, 0.0)
    };
    
    VertexOut out;
    out.position = float4(positions[vertexID], 0.0, 1.0);
    out.texCoord = texCoords[vertexID];
    return out;
}

// Conversión YCbCr → RGB (BT.709)
fragment float4 fragment_ycbcr(VertexOut in [[stage_in]],
                              texture2d<float> yTex   [[texture(0)]],
                              texture2d<float> cbcrTex[[texture(1)]],
                              sampler s [[sampler(0)]]) {
    constexpr float3 offset = float3(-0.0625, -0.5, -0.5);
    constexpr float3x3 matrix = float3x3(
        float3(1.164,  1.0,  1.0),
        float3(0.0,   -0.391, 2.018),
        float3(1.596, -0.813, 0.0)
    );
    // (El resto del shader debe completarse según la conversión deseada)
    return float4(0.0);
}

Swift 코드 (사용 예시)

// Texture? // 5
private var cbcrTexture: MTLTexture? // 6

init(mtkView: MTKView) {
    // …
}

func update(pixelBuffer: CVPixelBuffer) { // 7
    // …
}

func draw(in view: MTKView) { // 8
    // …
}

구성 요소 설명

  • device: MTLDevice – 디바이스의 물리적 GPU를 나타냅니다.
  • queue: MTLCommandQueue – GPU에 작업을 전달하는 커맨드 큐입니다.
  • pipeline: MTLRenderPipelineState – 셰이더와 프레임버퍼를 연결하는 사전 컴파일된 파이프라인 상태입니다.
  • textureCache: CVMetalTextureCache!CVPixelBufferMTLTexture 사이의 브리지 역할을 합니다.
  • yTexture / cbcrTexture – 프레임의 Y 및 CbCr 플레인 텍스처입니다.
  • update(pixelBuffer:)CVPixelBuffer를 GPU 텍스처로 변환합니다.
  • draw(in:) – 현재 텍스처를 렌더링합니다.
  • init(mtkView:)MTKView를 설정하고 파이프라인 및 텍스처 캐시를 생성합니다.

초기화 메서드 구현

init(mtkView: MTKView) {
    guard let device = MTLCreateSystemDefaultDevice(),
          let queue = device.makeCommandQueue() else {
        fatalError("Metal not supported")
    }
    self.device = device
    self.queue = queue

    // MTKView 설정
    mtkView.device = device
    mtkView.framebufferOnly = false

    // 셰이더 라이브러리
    let library = device.makeDefaultLibrary()!

    // 파이프라인 디스크립터
    let descriptor = MTLRenderPipelineDescriptor()
    descriptor.vertexFunction = library.makeFunction(name: "vertex_main")
    descriptor.fragmentFunction = library.makeFunction(name: "fragment_main")
    descriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat

    // 파이프라인 상태 (사전 컴파일)
    self.pipeline = try! device.makeRenderPipelineState(descriptor: descriptor)

    // 텍스처 캐시 생성
    CVMetalTextureCacheCreate(
        kCFAllocatorDefault,
        nil,
        device,
        nil,
        &textureCache
    )
}
Back to Blog

관련 글

더 보기 »

React Native용 iOS Bridging Header 설정

브리징 헤더 설정하기 1. 브리징 헤더 파일 만들기 - Xcode에서 프로젝트 폴더를 오른쪽 클릭합니다. - New File → Header File을 선택합니다. - 이름을 지정합니다.

Swift #7: 튜플

Tuple은 하나 이상의 값(동일하거나 서로 다른 타입)을 그룹으로 포함합니다. 일시적이거나 임시적인 값을 저장하는 데 유용합니다, 비록 …