Renderizando la cámara con Metal en iOS (AVFoundation + MetalKit)
Source: Dev.to
¿Por qué usar este enfoque?
- Necesitas post‑procesamiento con shaders.
- Estás integrando AR / computer vision / ML.
- Quieres máximo control y performance.
Arquitectura del proyecto
El proyecto final necesita las siguientes cuatro clases:
| Clase | Responsabilidad |
|---|---|
CameraCapture | Captura el video. |
MetalRenderer | Convierte el pixel buffer en texturas Metal y dibuja. |
CameraMetalViewController | Orquesta todo. |
CameraCaptureDelegate | Protocolo para pasar los buffers sin acoplar clases. |
1️⃣ Protocolo CameraCaptureDelegate
protocol CameraCaptureDelegate: AnyObject {
func cameraCapture(_ capture: CameraCapture,
didOutput pixelBuffer: CVPixelBuffer)
}
2️⃣ Clase CameraCapture
Declaración básica
class CameraCapture {
weak var delegate: CameraCaptureDelegate?
}
Propiedades y colas
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()
}
}
// …
}
Configuración de la sesión
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
}
Explicación paso a paso
beginConfiguration: indica aAVCaptureSessionque vamos a hacer cambios; la sesión esperará hasta que terminemos.sessionPreset = .high: calidad alta (720p‑1080p según el dispositivo).- Selección de cámara: usamos la cámara por defecto (normalmente la trasera). Si no se puede crear la entrada, la app falla (en producción deberías manejar el error de forma más amable).
- Añadimos la cámara como entrada.
- Formato del buffer:
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange(NV12) evita conversiones de color en CPU. - Descartar frames tardíos: si la app no procesa a tiempo, se descarta el frame más antiguo.
- Delegado de salida: cada frame se entrega a
captureOutput(_:didOutput:from:)enbufferQueue. - Comprobación y adición de la salida.
commitConfiguration: aplica todos los cambios.
Conformidad a 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)
}
}
CMSampleBuffercontiene el frame de vídeo (y opcionalmente audio, timestamps, metadatos).- Extraemos el
CVPixelBufferconCMSampleBufferGetImageBuffery lo enviamos al delegado.
Control de la sesión (start / stop)
func start() {
sessionQueue.async { [weak self] in
self?.session.startRunning()
}
}
func stop() {
sessionQueue.async { [weak self] in
self?.session.stopRunning()
}
}
Los métodos se ejecutan en sessionQueue para evitar bloquear el hilo principal.
3️⃣ Clase MetalRenderer
Esta clase se encargará de convertir el CVPixelBuffer en texturas Metal y dibujarlas en un MTKView.
Esqueleto inicial
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)
var cbcrTexRef: CVMetalTexture?
CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
textureCache,
pixelBuffer,
nil,
.rg8Unorm,
CVPixelBufferGetWidthOfPlane(pixelBuffer, 1),
CVPixelBufferGetHeightOfPlane(pixelBuffer, 1),
1,
&cbcrTexRef)
cbcrTexture = cbcrTexRef.flatMap { CVMetalTextureGetTexture($0) }
}
}
Puntos clave
deviceyqueue: el dispositivo y la cola de comandos de Metal.MTKView: se asigna eldevicey se desactivaframebufferOnlyporque vamos a usar texturas externas.- Pipeline: necesita dos shaders (ver sección de shaders más abajo).
CVMetalTextureCache: permite crear texturas Metal directamente desdeCVPixelBuffersin copias de memoria.draw(pixelBuffer:in:): convierte el buffer NV12 en dos texturas (YyCbCr) y dibuja un full‑screen quad.
4️⃣ Shaders (Metal Shading Language)
Guarda este archivo como Shaders.metal y añádelo al target.
#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);
}
Código Swift (ejemplo de uso)
// Texture? // 5
private var cbcrTexture: MTLTexture? // 6
init(mtkView: MTKView) {
// …
}
func update(pixelBuffer: CVPixelBuffer) { // 7
// …
}
func draw(in view: MTKView) { // 8
// …
}
Explicación de los componentes
device: MTLDevice– Representa la GPU física del dispositivo.queue: MTLCommandQueue– Cola donde se envía el trabajo a la GPU.pipeline: MTLRenderPipelineState– Estado precompilado que enlaza los shaders con el framebuffer.textureCache: CVMetalTextureCache!– Puente entreCVPixelBufferyMTLTexture.yTexture/cbcrTexture– Planos Y y CbCr del frame.update(pixelBuffer:)– Convierte elCVPixelBufferen texturas GPU.draw(in:)– Renderiza las texturas actuales.init(mtkView:)– ConfiguraMTKView, crea la pipeline y la caché de texturas.
Implementación del inicializador
init(mtkView: MTKView) {
guard let device = MTLCreateSystemDefaultDevice(),
let queue = device.makeCommandQueue() else {
fatalError("Metal not supported")
}
self.device = device
self.queue = queue
// Configuración del MTKView
mtkView.device = device
mtkView.framebufferOnly = false
// Biblioteca de shaders
let library = device.makeDefaultLibrary()!
// Descripción del pipeline
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(name: "vertex_main")
descriptor.fragmentFunction = library.makeFunction(name: "fragment_main")
descriptor.colorAttachments[0].pixelFormat = mtkView.colorPixelFormat
// Estado del pipeline (precompilado)
self.pipeline = try! device.makeRenderPipelineState(descriptor: descriptor)
// Creación del cache de texturas
CVMetalTextureCacheCreate(
kCFAllocatorDefault,
nil,
device,
nil,
&textureCache
)
}