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
}
단계별 설명
beginConfiguration:AVCaptureSession에 변경을 시작한다는 것을 알리며, 세션은 우리가 끝낼 때까지 대기한다.sessionPreset = .high: 고화질(디바이스에 따라 720p‑1080p) 설정.- 카메라 선택: 기본 카메라(보통 후면)를 사용한다. 입력을 만들 수 없으면 앱이 크래시한다(실제 서비스에서는 오류를 더 친절하게 처리해야 함).
- 카메라를 입력으로 추가.
- 버퍼 포맷:
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange(NV12) 를 사용하면 CPU에서 색 변환을 하지 않는다. - 늦은 프레임 버리기: 앱이 제때 처리하지 못하면 가장 오래된 프레임을 버린다.
- 출력 델리게이트: 각 프레임이
bufferQueue에서captureOutput(_:didOutput:from:)로 전달된다. - 출력 추가 여부 확인 및 추가.
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은 비디오 프레임(옵션으로 오디오, 타임스탬프, 메타데이터 포함)을 담고 있다.CMSampleBufferGetImageBuffer로CVPixelBuffer를 추출해 델리게이트에 전달한다.
세션 제어 (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) }
}
}
핵심 포인트
device와queue: Metal 디바이스와 커맨드 큐.MTKView:device를 할당하고framebufferOnly를 비활성화합니다. 외부 텍스처를 사용할 것이기 때문입니다.- Pipeline: 아래 쉐이더 섹션에서 보는 두 개의 쉐이더가 필요합니다.
CVMetalTextureCache: 메모리 복사 없이CVPixelBuffer에서 직접 Metal 텍스처를 생성할 수 있게 해줍니다.draw(pixelBuffer:in:): NV12 버퍼를 두 개의 텍스처(Y와CbCr)로 변환하고 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!–CVPixelBuffer와MTLTexture사이의 브리지 역할을 합니다.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
)
}