David GoyesEn 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:
El proyecto final necesita las siguientes cuatro clases:
CameraCapture: captura el video.MetalRenderer: convierte el pixel buffer en texturas Metal y dibuja.CameraMetalViewController: Orquesta todoCameraCapture se va a encargar de capturar frames usando AVCaptureSession y AVCaptureVideoDataOutput.
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)
}
La clase CameraCapture va a tener su delegado, CameraCaptureDelegate.
class CameraCapture {
weak var delegate: CameraCaptureDelegate?
}
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
}
}
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:
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.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.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).
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()
}
}
Ahora necesitamos dibujar cada frame de video en una vista MTKView. Esto lo vamos a hacer dentro de MetalRenderer
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: MTLDevice representa la GPU física del dispositivo y se usa para crear a todos los objetos Metal (MTLCommandQueue, MTLLibrary, MTLTexture, MTLRenderPipelineState).let queue: MTLCommandQueue es una cola donde se envía trabajo a la GPU.let pipeline: MTLRenderPipelineState que indica qué "vertex shader" y "fragment shader" usar, y cómo escribir en el framebuffer.var textureCache: CVMetalTextureCache! es un puente entre CVPixelBuffer y MTLTexture.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 el CVPixelBuffer en texturas GPU, actualizando yTexture y cbcrTexture.draw(in:) vive en el mundo Metal, consumiendo las texturas actuales.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
)
}