用 Metal 实现视频格式转换
最近遇到将 YUV 格式的视频转换成 RGB 格式的问题,解决方法也比较多,比如 openCV 或 OpenGL 等,听闻 Metal 上 CPU 和 GPU 之间可以共享内存数据,性能甩 OpenGL 几条街,遂决定用 Metal 来折腾一下。虽然 Metal 在语法上和 OpenGL ES 有较大的差异,但是 Metal 也是基于可编程渲染管线设计的一套图形编程接口,openGL 上的许多概念,如顶点和片元着色器、帧缓冲、纹理采样等,在 Metal 上同样适用。
大致流程是:先通过 AVCaptureVideoDataOutput 回调函数捕获视频帧,然后将视频帧分别拆解成 luma 纹理和 chroma 纹理,再提交到 Metal 着色器做色彩空间转换:
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection;
自定义图层
使用 OpenGL 的时候,我们可能会设置自定义图层 CAEAGLLayer 来展示渲染结果,而 CAMetalLayer 则是 Metal 专门用来渲染的图层,它也是 CALayer 的子类,可以展示 Metal 帧缓冲区的内容:
+ (Class)layerClass
{
return [CAMetalLayer class];
}
创建命令队列
首先通过调用 MTLCreateSystemDefaultDevice( ) 函数来获取一个系统能够使用的 MTLDevice 对象,MTLDevice 代表一个执行渲染命令的 GPU 设备,然后通过 MTLDevice 对象创建一个命令队列 MTLCommandQueue:
id <MTLDevice> device = MTLCreateSystemDefaultDevice();
id <MTLCommandQueue> commandQueue = [device newCommandQueue];
MTLDevice 和 MTLCommandQueue 实际上是定义了相关接口的协议,Metal 中许多的接口定义采用了这种设计方式。使用 Metal 执行渲染命令的时候,一般要先将命令经过渲染命令编码器(MTLRenderCommandEncoder)编码后,添加到一个命令缓冲(MTLCommandBuffer)对象,一个命令缓冲可以包含多个被编码过的命令,然后命令缓冲对象会被提交到命令队列(MTLCommandQueue),最后由命令队列按顺序提交给 GPU 处理。
创建渲染管道
1、创建着色器程序
创建一个扩展名为 .metal 的文件,编写实现颜色空间转换的 Shader 代码( .metal 文件自带 Metal Shader 语法高亮和语法检查):
typedef struct {
packed_float3 position;
packed_float2 textureCoordinate;
} AAPLVertex;
typedef struct {
float4 clipSpacePosition [[position]];
float2 textureCoordinate;
} RasterizerData;
vertex RasterizerData
vertexShader(constant AAPLVertex *vertexArray [[ buffer(0) ]],
uint vertexID [[ vertex_id ]])
{
RasterizerData out;
out.clipSpacePosition = float4(vertexArray[vertexID].position,1);
out.textureCoordinate = vertexArray[vertexID].textureCoordinate;
return out;
}
fragment half4
samplingShader(RasterizerData in [[ stage_in ]],
texture2d<float> lumaTexture [[ texture(0) ]],
texture2d<float> chromaTexture [[ texture(1) ]],
sampler textureSampler [[ sampler(0) ]],
constant float3x3 *yuvToRGBMatrix [[ buffer(0) ]])
{
float3 yuv;
yuv.x = lumaTexture.sample(textureSampler, in.textureCoordinate).r - float(0.062745);
yuv.yz = chromaTexture.sample(textureSampler, in.textureCoordinate).rg - float2(0.5);
return half4(half3((*yuvToRGBMatrix) * yuv), yuv.x);
}
Metal Shader 语法确实比较怪异,尤其是变量属性 [[ attribute(x) ]] 让笔者懵了好久。
Shader 代码中定义了两个结构体:
- AAPLVertex 结构体定义了传入顶点着色器的顶点数据类型;
- RasterizerData 结构体定义了从顶点着色器传入片段着色器的顶点数据类型;
带 vertex 标志的函数 vertexShader 是顶点着色器函数,它接收一个顶点数组指针 vertexArray 和一个索引 vertexID 作为参数:
constant AAPLVertex *vertexArray [[ buffer(0) ]],
uint vertexID [[ vertex_id ]]
vertexArray 参数后面紧跟着的属性 [[ buffer(0) ]] 标明从索引为0的缓冲区中读取顶点数组的值(后面我们会将顶点数组加载到索引为0的缓冲区中),与 [[ buffer(index) ]] 类似的变量属性还有 [[ texture(index) ]] 和 [[ sampler(index) ]],分别表示读取索引为 index 的纹理和采样器,index 对应着我们在渲染命令编码器中设置纹理、缓冲区或采样器时指定的索引值。
vertexID 参数后面紧跟着的属性 [[ vertex_id ]] 标明当前处理的顶点的索引,顶点着色器函数会对顶点数组 vertexArray 中的每个顶点执行一次。这里顶点着色器函数 vertexShader 不对顶点数据做额外处理,将顶点坐标及其对应的纹理坐标直接输出。
带 fragment 标志的函数 samplingShader 是片元着色器函数,它接收五个参数:
RasterizerData in [[ stage_in ]],
texture2d<float> lumaTexture [[ texture(0) ]],
texture2d<float> chromaTexture [[ texture(1) ]],
sampler textureSampler [[ sampler(0) ]],
constant float3x3 *yuvToRGBMatrix [[ buffer(0) ]]
其中带 [[ stage_in ]] 标记的 in 参数是从顶点着色器传入片段着色器的顶点数据(包括顶点坐标和纹理坐标);其它参数包括视频帧的Y纹理 lumaTexture 和 UV 纹理 chromaTexture、纹理采样器 textureSampler 以及 YUV-RGB 的转换矩阵 yuvToRGBMatrix,这几个参数都是通过渲染命令编码器设置的。获取到这些参数后,就是根据 YUV 到 RGB 的转换规则,做一下颜色空间转换了:
// YUV-RGB 转换公式
B = 1.164(Y - 0.0627) + 2.018(U - 0.500)
G = 1.164(Y - 0.0627) - 0.813(V - 0.500) - 0.391(U - 0.500)
R = 1.164(Y - 0.0627) + 1.596(V - 0.500)
2、加载着色器程序
创建一个 MTLLibrary 对象来加载顶点着色器和片元着色器程序
id <MTLLibrary> defaultLibrary = [device newDefaultLibrary];
id <MTLFunction> vertexProgram = [defaultLibrary newFunctionWithName:@"vertexShader"];
id <MTLFunction> fragmentProgram = [defaultLibrary newFunctionWithName:@"samplingShader"];
3、创建渲染管道
首先创建一个 MTLRenderPipelineDescriptor 对象,渲染管道描述符用来指定图形函数(包括顶点着色器函数和片元着色器函数)和多重采样等渲染配置
MTLRenderPipelineDescriptor *pipelineStateDescriptor = [MTLRenderPipelineDescriptor new];
pipelineStateDescriptor.label = @"Simple Pipeline";
pipelineStateDescriptor.vertexFunction = vertexProgram;
pipelineStateDescriptor.fragmentFunction = fragmentProgram;
pipelineStateDescriptor.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;
设置完顶点着色器、片元着色器函数和帧缓冲区的像素格式后,通过调用同步方法 newRenderPipelineStateWithDescriptor 来编译顶点和片元着色器程序,
同时生成一个渲染管道状态( MTLRenderPipelineState )对象,这一步会比较耗时,因此 Metal 官方文档建议应该尽早创建渲染管道状态对象并于后期复用该对象:
id <MTLRenderPipelineState> pipelineState = [device newRenderPipelineStateWithDescriptor:pipelineStateDescriptor error:&error];
到这一步,渲染管道已经创建完成。前面提到 Metal 渲染指令需要经过命令编码器编码后才能提交 GPU 处理,着色器程序等渲染管道配置需要通过 MTLRenderPipelineState 对象传递给命令编码器
数据准备
根据前面 Shader 代码,需要向 GPU 传递的数据包括:顶点数据、视频帧纹理、纹理采样器和 YUV-RGB 转换矩阵
1、顶点数据
static const float quad[] =
{
-0.5, 0.5, 0, 1, 1,
0.5, -0.5, 0, 0, 0,
0.5, 0.5, 0, 0, 1,
-0.5, 0.5, 0, 1, 1,
0.5, -0.5, 0, 0, 0,
-0.5, -0.5, 0, 1, 0,
};
每一行的前三个数字代表了每一个顶点的(x,y,z)坐标,后两个数字代表每个顶点的纹理坐标。为了使用 GPU 绘制顶点数据,需要将它放入缓冲区(MTLBuffer)中,缓冲区是被 CPU 和 GPU 共享的内存块。
id <MTLBuffer> vertexBuffer = [device newBufferWithBytes:quad length:sizeof(quad) options:0];
vertexBuffer.label = @"Vertices";
2、视频帧纹理
这里处理的视频帧(pixelBuffer)是 NV12 格式,双平面,存储顺序是先存储 Y,再 UV 交替存储。可以先通过 Core Video 接口从视频帧中拆解出 Y 平面和 UV 平面数据,再将两个平面数据分别解析成 Y 纹理和 UV 纹理:
CVMetalTextureCacheRef textureCache;
CVMetalTextureRef yTexture ;
float yWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0);
float yHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0);
CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, textureCache, pixelBuffer, nil, MTLPixelFormatR8Unorm, yWidth, yHeight, 0, &yTexture);
CVMetalTextureRef uvTexture;
float uvWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, 1);
float uvHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, 1);
CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, textureCache, pixelBuffer, nil, MTLPixelFormatRG8Unorm, uvWidth, uvHeight, 1, &uvTexture);
id<MTLTexture> lumaTexture = CVMetalTextureGetTexture(yTexture);
id<MTLTexture> chromaTexture = CVMetalTextureGetTexture(uvTexture);
3、采样器
采样的结果是产生纹素,纹素通常都包含一种颜色,对视频帧做格式转换的时候,需要用采样器对 Y 纹理和 UV 纹理进行采样,提取纹素的 Y、U、V 分量,再应用颜色空间转换公式,转换成 R、G、B 分量。
首先创建一个采样器描述符对象,设置纹理被缩小时使用最近点采样,设置纹理被放大时使用线性纹理过滤:
MTLSamplerDescriptor *samplerDescriptor = [MTLSamplerDescriptor new];
samplerDescriptor.minFilter = MTLSamplerMinMagFilterNearest;
samplerDescriptor.magFilter = MTLSamplerMinMagFilterLinear;
采样器描述符对象描述了如何创建采样器,接下来我们需要根据采样器描述符对象创建一个采样器状态对象:
id<MTLSamplerState> samplerState = [device newSamplerStateWithDescriptor:samplerDescriptor];
4、YUV-RGB 转换矩阵
根据 YUV-RGB 颜色转换规则,构造一个 3x3 的转换矩阵,并把矩阵放到缓冲区里:
simd::float3 firstColumn = simd::float3{1.164, 1.164, 1.164};
simd::float3 secondColumn = simd::float3{0, 0.392, 2.017};
simd::float3 thirdColumn = simd::float3{1.596, 0.813, 0};
simd::float3x3 yuvToRGB2 = simd::float3x3{firstColumn, secondColumn, thirdColumn};
id<MTLBuffer> matrixBuffer = [device newBufferWithBytes: &yuvToRGB2 length: sizeof(yuvToRGB2) options:0];
执行渲染命令
1、创建渲染路径描述符
从 metalLayer 上获取一个可绘制的资源对象(CAMetalDrawable),它包含一个纹理(MTLTexture)对象,这个纹理对象代表一个可用作图形呈现命令目标的缓冲区(一个可被附加到帧缓冲上的纹理):
id<CAMetalDrawable> drawable = [metalLayer nextDrawable];
创建一个渲染路径描述符(MTLRenderPassDescriptor)对象,它包含了一些用于呈现渲染结果的附件(包括颜色附件、深度附件等),通俗地讲,通过 MTLRenderPassDescriptor 对象可以给帧缓冲附加颜色附件、深度附件和模板附件。将 drawable 对象的纹理赋给颜色附件的纹理属性后,相当于把一个纹理附加到帧缓冲上,所有渲染命令会写入到 drawable 对象的纹理上,渲染结果将展示到该 drawable 对象所对应的一个CAMetalLayer 对象上。同时,我们设置每次执行渲染命令前先清除帧缓冲区颜色,执行渲染命令后,将结果存储到帧缓冲区中:
MTLRenderPassDescriptor *renderPassDescriptor = [MTLRenderPassDescriptor renderPassDescriptor];
MTLRenderPassColorAttachmentDescriptor *colorAttachment = renderPassDescriptor.colorAttachments[0];
colorAttachment.texture = drawable.texture;
colorAttachment.loadAction = MTLLoadActionClear;
colorAttachment.clearColor = MTLClearColorMake(1, 1, 1, 1);
colorAttachment.storeAction = MTLStoreActionStore;
2、创建命令缓冲
Metal 渲染指令需要经过命令编码器编码后添加到一个命令缓冲对象,最后由命令缓冲对象提交到命令队列执行。前面已经创建好了命令队列,这里通过命令队列获取一个命令缓冲( MTLCommandBuffer )对象:
id <MTLCommandBuffer> commandBuffer = [commandQueue commandBuffer];
3、创建命令编码器
创建一个命令编码器( MTLRenderCommandEncoder ),开始编写绘制指令,编码器会将我们的绘制指令转换为 GPU 能理解的语言对象:
id <MTLRenderCommandEncoder> renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
前面我们创建了一个渲染管道状态(MTLRenderPipelineState)对象,它包含了预编译的顶点着色器函数和片元着色器函数等管道配置,这里将该对象赋给命令编码器,命令编码器会将着色器程序提交到 GPU 去执行:
[renderEncoder setRenderPipelineState:pipelineState];
前面已经准备好了着色器程序运行所需要的数据,包括顶点数据、视频帧纹理、采样器等,这些数据将通过命令编码器传递到 GPU 处理(个人感觉 Metal 上的参数传递操作确实要比 OpenGL 来得简单一些)
// 设置顶点数据
[renderEncoder setVertexBuffer:vertexBuffer offset:0 atIndex:0];
// 设置转换矩阵
[renderEncoder setFragmentBuffer:matrixBuffer offset:0 atIndex:0];
// 设置纹理数据
[renderEncoder setFragmentTexture:videoTexture[0] atIndex:0];
[renderEncoder setFragmentTexture:videoTexture[1] atIndex:1];
// 设置采样器
[renderEncoder setFragmentSamplerState:samplerState atIndex:0];
一切准备就绪后,通知命令编码器执行图形绘制,视频展示区域是一个矩形,因此需要依据6个顶点坐标来绘制两个三个角形:
[renderEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:6 instanceCount:2];
结束本次命令编码过程:
[renderEncoder endEncoding];
通知渲染缓冲,一旦绘图指令执行完毕,将渲染结果展示到屏幕上:
[commandBuffer presentDrawable:drawable];
最后,提交渲染缓冲给 GPU 处理:
[commandBuffer commit];
笔者对 Metal 还处于初学状态,如有理解错误或表述不当,欢迎 Metal 大神帮忙指正!