美文网首页
用 Metal 实现视频格式转换

用 Metal 实现视频格式转换

作者: WATER1350 | 来源:发表于2019-03-03 15:58 被阅读0次

    最近遇到将 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 大神帮忙指正!

    相关文章

      网友评论

          本文标题:用 Metal 实现视频格式转换

          本文链接:https://www.haomeiwen.com/subject/fstmuqtx.html