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

Published: (December 16, 2025 at 03:46 PM EST)
7 min read
Source: Dev.to

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:

ClaseResponsabilidad
CameraCaptureCaptura el video.
MetalRendererConvierte el pixel buffer en texturas Metal y dibuja.
CameraMetalViewControllerOrquesta todo.
CameraCaptureDelegateProtocolo 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

  1. beginConfiguration: indica a AVCaptureSession que vamos a hacer cambios; la sesión esperará hasta que terminemos.
  2. sessionPreset = .high: calidad alta (720p‑1080p según el dispositivo).
  3. 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).
  4. Añadimos la cámara como entrada.
  5. Formato del buffer: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange (NV12) evita conversiones de color en CPU.
  6. Descartar frames tardíos: si la app no procesa a tiempo, se descarta el frame más antiguo.
  7. Delegado de salida: cada frame se entrega a captureOutput(_:didOutput:from:) en bufferQueue.
  8. Comprobación y adición de la salida.
  9. 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)
    }
}
  • CMSampleBuffer contiene el frame de vídeo (y opcionalmente audio, timestamps, metadatos).
  • Extraemos el CVPixelBuffer con CMSampleBufferGetImageBuffer y 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

  1. device y queue: el dispositivo y la cola de comandos de Metal.
  2. MTKView: se asigna el device y se desactiva framebufferOnly porque vamos a usar texturas externas.
  3. Pipeline: necesita dos shaders (ver sección de shaders más abajo).
  4. CVMetalTextureCache: permite crear texturas Metal directamente desde CVPixelBuffer sin copias de memoria.
  5. draw(pixelBuffer:in:): convierte el buffer NV12 en dos texturas (Y y CbCr) 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 entre CVPixelBuffer y MTLTexture.
  • yTexture / cbcrTexture – Planos Y y CbCr del frame.
  • update(pixelBuffer:) – Convierte el CVPixelBuffer en texturas GPU.
  • draw(in:) – Renderiza las texturas actuales.
  • init(mtkView:) – Configura MTKView, 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
    )
}
Back to Blog

Related posts

Read more »

Swift #7: Tuplas

Tuplas Una tupla contiene un grupo de uno o más valores del mismo o diferente tipos. Es útil para almacenar valores efímeros o temporales que, aunque están rel...