Renderizando la cámara con Metal en iOS (AVFoundation + MetalKit)

# metalkit# avfoundation# vuforia# ios
Renderizando la cámara con Metal en iOS (AVFoundation + MetalKit)David Goyes

En este tutorial vamos a renderizar el video de la cámara directamente en pantalla usando Metal, sin...

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)
}
Enter fullscreen mode Exit fullscreen mode

Punto de partida

La clase CameraCapture va a tener su delegado, CameraCaptureDelegate.

class CameraCapture {
  weak var delegate: CameraCaptureDelegate?
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

En el código anterior ocurre lo siguiente:

  1. Le decimos a AVCaptureSession que 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.
  2. Queremos calidad alta: 720p o 1080p dependiendo del dispositivo.
  3. 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).
  4. Se añade la cámara como entrada de la sesión. De lo contrario, no produce frames.
  5. 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.
  6. Si la aplicación no alcanza a procesar un frame a tiempo, lo descarta y entrega el más reciente.
  7. 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 protocolo AVCaptureVideoDataOutputSampleBufferDelegate, va a aparecer un error en esta línea.
  8. 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).
  9. 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)
   }
}
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Aquí:

  1. let device: MTLDevice representa la GPU física del dispositivo y se usa para crear a todos los objetos Metal (MTLCommandQueue, MTLLibrary, MTLTexture, MTLRenderPipelineState).
  2. let queue: MTLCommandQueue es una cola donde se envía trabajo a la GPU.
  3. Como Metal no compila shaders en tiempo real, se crea un pipeline precompilado let pipeline: MTLRenderPipelineState que indica qué "vertex shader" y "fragment shader" usar, y cómo escribir en el framebuffer.
  4. var textureCache: CVMetalTextureCache! es un puente entre CVPixelBuffer y MTLTexture.
  5. var yTexture: MTLTexture? es el plano de luminancia (Y) del frame.
  6. cbcrTexture: MTLTexture? es el plano de crominancia (CbCr) del frame.
  7. update(pixelBuffer:), vive en el mundo de la cámara, conviertiendo el CVPixelBuffer en texturas GPU, actualizando yTexture y cbcrTexture.
  8. 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
  )
}
Enter fullscreen mode Exit fullscreen mode