在 iOS 上使用 Metal 渲染相机 (AVFoundation + MetalKit)

发布: (2025年12月17日 GMT+8 04:46)
8 min read
原文: Dev.to

Source: Dev.to

为什么使用这种方法?

  • 你需要 使用着色器进行后处理
  • 你正在集成 AR / 计算机视觉 / 机器学习
  • 你想要 最大控制和性能

项目架构

最终项目需要以下四个类:

责任
CameraCapture捕获视频。
MetalRenderer将像素缓冲区转换为 Metal 纹理并绘制。
CameraMetalViewController协调所有工作。
CameraCaptureDelegate用于在不耦合类的情况下传递缓冲区的协议。

1️⃣ 协议 CameraCaptureDelegate

protocol CameraCaptureDelegate: AnyObject {
    func cameraCapture(_ capture: CameraCapture,
                       didOutput pixelBuffer: CVPixelBuffer)
}

Source:

2️⃣ 类 CameraCapture

基本声明

class CameraCapture {
    weak var delegate: CameraCaptureDelegate?
}

属性与队列

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() {
    session.beginConfiguration()                     // 1
    session.sessionPreset = .high                    // 2
    
    // 3️⃣ 选择默认摄像头(通常是后置摄像头)
    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️⃣ 像素缓冲区格式(NV12,避免在 CPU 上进行 RGB 转换)
    output.videoSettings = [
        kCVPixelBufferPixelFormatTypeKey as String:
        kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
    ]
    
    output.alwaysDiscardsLateVideoFrames = true    // 6
    output.setSampleBufferDelegate(self, queue: bufferQueue) // 7
    
    // 8️⃣ 将输出添加到会话
    guard session.canAddOutput(output) else {
        fatalError("Cannot add video output")
    }
    session.addOutput(output)
    
    session.commitConfiguration()                  // 9
}

步骤说明

  1. beginConfiguration:告诉 AVCaptureSession 我们即将进行修改;会话会等到配置完成后才生效。
  2. sessionPreset = .high:高质量(根据设备可达 720p‑1080p)。
  3. 摄像头选择:使用默认摄像头(通常是后置)。如果无法创建输入,程序会崩溃(实际生产环境应更友好地处理错误)。
  4. 将摄像头作为输入加入
  5. 缓冲区格式kCVPixelFormatType_420YpCbCr8BiPlanarFullRange(NV12)避免在 CPU 上进行颜色转换。
  6. 丢弃延迟帧:如果应用处理不及时,最旧的帧会被丢弃。
  7. 输出代理:每帧会在 bufferQueue 中回调 captureOutput(_:didOutput:from:)
  8. 检查并添加输出
  9. commitConfiguration:应用所有更改。

遵循 AVCaptureVideoDataOutputSampleBufferDelegate

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)
    }
}
  • CMSampleBuffer 包含视频帧(以及可选的音频、时间戳、元数据)。
  • 使用 CMSampleBufferGetImageBuffer 提取 CVPixelBuffer 并将其发送给代理。

会话控制(启动 / 停止)

func start() {
    sessionQueue.async { [weak self] in
        self?.session.startRunning()
    }
}

func stop() {
    sessionQueue.async { [weak self] in
        self?.session.stopRunning()
    }
}

这些方法在 sessionQueue 中执行,以避免阻塞主线程。

Source:

3️⃣ 类 MetalRenderer

此类负责 CVPixelBuffer 转换为 Metal 纹理 并在 MTKView 上绘制它们。

初始框架

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) }
    }
}

关键要点

  1. devicequeue:Metal 的设备和命令队列。
  2. MTKView:为其分配 device,并关闭 framebufferOnly,因为我们要使用外部纹理。
  3. Pipeline:需要两个着色器(见下文的着色器章节)。
  4. CVMetalTextureCache:允许直接从 CVPixelBuffer 创建 Metal 纹理,避免内存拷贝。
  5. draw(pixelBuffer:in:):将 NV12 缓冲区转换为两张纹理(YCbCr),并绘制一个 full‑screen quad

4️⃣ 着色器 (Metal 着色语言)

将此文件保存为 Shaders.metal 并将其添加到 target。

#include <metal_stdlib>
using namespace metal;

// 覆盖整个屏幕的三角形的顶点数据
struct VertexOut {
    float4 position [[position]];
    float2 texCoord;
};

vertex VertexOut vertex_passthrough(uint vertexID [[vertex_id]]) {
    // 由两个三角形(6 个顶点)组成的覆盖屏幕的三角形
    const float2 positions[6] = {
        float2(-1.0, -1.0), // 左下
        float2( 1.0, -1.0), // 右下
        float2(-1.0,  1.0), // 左上
        
        float2( 1.0, -1.0), // 右下
        float2( 1.0,  1.0), // 右上
        float2(-1.0,  1.0)  // 左上
    };
    
    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;
}

// 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)
    );
    // (其余着色器代码需根据所需的转换完成)
    return float4(0.0);
}

Swift 代码(使用示例)

// Texture? // 5
private var cbcrTexture: MTLTexture? // 6

init(mtkView: MTKView) {
    // …
}

func update(pixelBuffer: CVPixelBuffer) { // 7
    // …
}

func draw(in view: MTKView) { // 8
    // …
}

组件说明

  • device: MTLDevice – 表示设备的物理 GPU。
  • queue: MTLCommandQueue – 用于向 GPU 发送指令的队列。
  • pipeline: MTLRenderPipelineState – 预编译状态对象,将着色器与帧缓冲关联。
  • textureCache: CVMetalTextureCache!CVPixelBufferMTLTexture 之间的桥接。
  • yTexture / cbcrTexture – 帧的 Y 平面和 CbCr 平面。
  • update(pixelBuffer:) – 将 CVPixelBuffer 转换为 GPU 纹理。
  • draw(in:) – 渲染当前纹理。
  • init(mtkView:) – 配置 MTKView、创建 pipeline 和纹理缓存。

初始化器实现

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

相关文章

阅读更多 »

Swift #7: 元组

元组 元组包含一个或多个相同或不同类型的值的集合。它用于存储临时或短暂的值,尽管它们是…