在 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
}
步骤说明
beginConfiguration:告诉AVCaptureSession我们即将进行修改;会话会等到配置完成后才生效。sessionPreset = .high:高质量(根据设备可达 720p‑1080p)。- 摄像头选择:使用默认摄像头(通常是后置)。如果无法创建输入,程序会崩溃(实际生产环境应更友好地处理错误)。
- 将摄像头作为输入加入。
- 缓冲区格式:
kCVPixelFormatType_420YpCbCr8BiPlanarFullRange(NV12)避免在 CPU 上进行颜色转换。 - 丢弃延迟帧:如果应用处理不及时,最旧的帧会被丢弃。
- 输出代理:每帧会在
bufferQueue中回调captureOutput(_:didOutput:from:)。 - 检查并添加输出。
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) }
}
}
关键要点
device和queue:Metal 的设备和命令队列。MTKView:为其分配device,并关闭framebufferOnly,因为我们要使用外部纹理。- Pipeline:需要两个着色器(见下文的着色器章节)。
CVMetalTextureCache:允许直接从CVPixelBuffer创建 Metal 纹理,避免内存拷贝。draw(pixelBuffer:in:):将 NV12 缓冲区转换为两张纹理(Y和CbCr),并绘制一个 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!–CVPixelBuffer与MTLTexture之间的桥接。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
)
}