用 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  device = MTLCreateSystemDefaultDevice();
id  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 lumaTexture [[ texture(0) ]],
               texture2d 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 lumaTexture [[ texture(0) ]],
texture2d 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  defaultLibrary = [device newDefaultLibrary];
id  vertexProgram = [defaultLibrary newFunctionWithName:@"vertexShader"];
id  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  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  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 lumaTexture = CVMetalTextureGetTexture(yTexture);
id chromaTexture = CVMetalTextureGetTexture(uvTexture);

3、采样器

采样的结果是产生纹素,纹素通常都包含一种颜色,对视频帧做格式转换的时候,需要用采样器对 Y 纹理和 UV 纹理进行采样,提取纹素的 Y、U、V 分量,再应用颜色空间转换公式,转换成 R、G、B 分量。
首先创建一个采样器描述符对象,设置纹理被缩小时使用最近点采样,设置纹理被放大时使用线性纹理过滤:

MTLSamplerDescriptor *samplerDescriptor = [MTLSamplerDescriptor new];
samplerDescriptor.minFilter = MTLSamplerMinMagFilterNearest;
samplerDescriptor.magFilter = MTLSamplerMinMagFilterLinear;

采样器描述符对象描述了如何创建采样器,接下来我们需要根据采样器描述符对象创建一个采样器状态对象:

id 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 matrixBuffer = [device newBufferWithBytes: &yuvToRGB2 length: sizeof(yuvToRGB2) options:0];

执行渲染命令

1、创建渲染路径描述符

从 metalLayer 上获取一个可绘制的资源对象(CAMetalDrawable),它包含一个纹理(MTLTexture)对象,这个纹理对象代表一个可用作图形呈现命令目标的缓冲区(一个可被附加到帧缓冲上的纹理):

id 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  commandBuffer = [commandQueue commandBuffer];

3、创建命令编码器

创建一个命令编码器( MTLRenderCommandEncoder ),开始编写绘制指令,编码器会将我们的绘制指令转换为 GPU 能理解的语言对象:

id  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 大神帮忙指正!

你可能感兴趣的:(用 Metal 实现视频格式转换)