En este tutorial vamos a renderizar el video de la cámara directamente en pantalla usando Metal, sin pasar por AVCaptureVideoPreviewLayer. La idea es tomar el CVPixelBuffer que entrega AVFoundation y dibujarlo en un MTKView, controlando todo el pipeline gráfico.
Este enfoque es especialmente útil si:
- Necesitas post‑procesamiento con shaders
- Estás integrando AR / computer vision / ML
- Quieres máximo control y performance
El proyecto final necesita las siguientes cuatro clases:
-
CameraCapture: captura el video. -
MetalRenderer: convierte el pixel buffer en texturas Metal y dibuja. -
CameraMetalViewController: Orquesta todo
Captura de cámara con AVFoundation
CameraCapture se va a encargar de capturar frames usando AVCaptureSession y AVCaptureVideoDataOutput.
Emitir resultados
Necesitamos mandar el buffer de pixeles a Metal, sin acoplar las clases, así que voy a usar un protocolo CameraCaptureDelegate.
protocol CameraCaptureDelegate: AnyObject {
func cameraCapture(_ capture: CameraCapture,
didOutput pixelBuffer: CVPixelBuffer)
}
Punto de partida
La clase CameraCapture va a tener su delegado, CameraCaptureDelegate.
class CameraCapture {
weak var delegate: CameraCaptureDelegate?
}
Sesión de captura de video
Necesitamos agregar un AVCaptureSession y configurarlo. Este proceso no puede ejecutarse en primer plano, así que se hará con una cola (DispatchQueue) serial. Por otro lado, los frames de video se van a guardar en un buffer de salida de tipo AVCaptureVideoDataOutput que necesita otra cola serial para gestionarlos.
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() {
sessionQueue.async { [weak self] in
self?.configureSession()
}
}
private func configureSession() {
// TODO
}
}
Configurar la sesión de captura
private func configureSession() {
session.beginConfiguration() // 1
session.sessionPreset = .high // 2
guard let device = AVCaptureDevice.default(for: .video), // 3
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
fatalError("Cannot create camera input")
}
session.addInput(input) // 4
output.videoSettings = [ // 5
kCVPixelBufferPixelFormatTypeKey as String:
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
]
output.alwaysDiscardsLateVideoFrames = true // 6
output.setSampleBufferDelegate(self, queue: bufferQueue) // 7
guard session.canAddOutput(output) else { // 8
fatalError("Cannot add video output")
}
session.addOutput(output)
session.commitConfiguration() // 9
}
En el código anterior ocurre lo siguiente:
- Le decimos a
AVCaptureSessionque vamos a empezar a hacer cambios, así que debe esperar hasta que termine. Esto es importante porque puede ser costoso cambiar la configuración mientras la sesión está activa. - Queremos calidad alta: 720p o 1080p dependiendo del dispositivo.
- Selecciona la cámara por defecto, que normalmente es la trasera. Luego, envuelve la cámara en un objeto de la sesión puede usar. Finalmente se verifica si se puede añadir la cámara como entrada de la sesión. - No debería haber ningún problema pero, si lo hay, la aplicación falla (aunque en realidad, debería tener un manejo más amable).
- Se añade la cámara como entrada de la sesión. De lo contrario, no produce frames.
- Se define el formato del buffer de pixeles que corresponde al formato nativo de la cámara, y evita la conversión a RGB en CPU.
- Si la aplicación no alcanza a procesar un frame a tiempo, lo descarta y entrega el más reciente.
- Cada frame capturado se entrega a un delegado por medio de
captureOutput(_:didOutput:from:). Esto ocurre en una cola privada. Como hasta ahora no se ha conformado el protocoloAVCaptureVideoDataOutputSampleBufferDelegate, va a aparecer un error en esta línea. - Se verifica si se puede agregar el buffer de salida a la sesión y se lo añade en caso positivo. De lo contrario, hay un error fatal (recordar que se puede manejar mejor).
- Se finaliza la configuración, aplicando todos los cambios.
Manejando el delegado del buffer de salida de video
Como se mencionó antes, hace falta que la calse CameraCapture conforme el protocolo AVCaptureVideoDataOutputSampleBufferDelegate para recibir los frames de videos. Para ello también se requiere que herede de NSObject.
class CameraCapture: NSObject {
// ...
}
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)
}
}
El parámetro de tipo CMSampleBuffer es un contenedor que puede incluir imagen (video), audio, timestamps o metadatos. En este caso, contiene un frame de video de la cámara.
Dentro del método captureOutput se extrae el buffer de imagen real con CMSampleBufferGetImageBuffer(sampleBuffer) y luego se envía al delegado con delegate?.cameraCapture(self, didOutput: pixelBuffer).
Iniciar y terminar la captura
La sesión de captura de video puede iniciar y terminar. Para ello se invocan los métodos startRunning() y stopRunning() respectivamente, desde la cola serial sessionQueue.
func start() {
sessionQueue.async { [weak self] in
self?.session.startRunning()
}
}
func stop() {
sessionQueue.async { [weak self] in
self?.session.stopRunning()
}
}
Dibujar el frame de la cámara con Metal
Ahora necesitamos dibujar cada frame de video en una vista MTKView. Esto lo vamos a hacer dentro de MetalRenderer
Esqueleto
Para empezar, el esqueleto:
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
private var cbcrTexture: MTLTexture? // 6
init(mtkView: MTKView) {
// ...
}
func update(pixelBuffer: CVPixelBuffer) { // 7
// ...
}
func draw(in view: MTKView) { // 8
// ...
}
}
Aquí:
-
let device: MTLDevicerepresenta la GPU física del dispositivo y se usa para crear a todos los objetos Metal (MTLCommandQueue,MTLLibrary,MTLTexture,MTLRenderPipelineState). -
let queue: MTLCommandQueuees una cola donde se envía trabajo a la GPU. - Como Metal no compila shaders en tiempo real, se crea un pipeline precompilado
let pipeline: MTLRenderPipelineStateque indica qué "vertex shader" y "fragment shader" usar, y cómo escribir en el framebuffer. -
var textureCache: CVMetalTextureCache!es un puente entreCVPixelBufferyMTLTexture. -
var yTexture: MTLTexture?es el plano de luminancia (Y) del frame. -
cbcrTexture: MTLTexture?es el plano de crominancia (CbCr) del frame. -
update(pixelBuffer:), vive en el mundo de la cámara, conviertiendo elCVPixelBufferen texturas GPU, actualizandoyTextureycbcrTexture. -
draw(in:)vive en el mundo Metal, consumiendo las texturas actuales.
Inicializador
En el inicializador no se dibuja nada, sino que se prepara y valida todo para que, cuando llegue el primer frame de la cámara, la GPU pueda trabajar sin fricción.
init(mtkView: MTKView) {
guard let device = MTLCreateSystemDefaultDevice(),
let queue = device.makeCommandQueue() else {
fatalError("Metal not supported")
}
self.device = device
self.queue = queue
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
)
}
Top comments (0)